From efd185b779570d7573722868c275962d98ea2c3a Mon Sep 17 00:00:00 2001 From: Blizzard Date: Fri, 17 Jul 2026 10:39:24 +0800 Subject: [PATCH] =?UTF-8?q?feat(billing):=20=E6=94=AF=E4=BB=98=20P5.2=20?= =?UTF-8?q?=E2=80=94=E2=80=94=20=E5=BE=AE=E4=BF=A1=E6=94=AF=E4=BB=98=20Nat?= =?UTF-8?q?ive=20=E6=B8=A0=E9=81=93=EF=BC=88=E6=89=AB=E7=A0=81=E5=85=85?= =?UTF-8?q?=E5=80=BC=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit wechatpay-go v0.2.21。凭据全 env 注入(WECHAT_MCHID/MCH_CERT_SERIAL/ MCH_PRIVATE_KEY/APIV3_KEY/APPID/NOTIFY_URL),缺一渠道即隐藏——半配置/假凭据 只打日志不拖垮 gateway(用假私钥实测过降级)。 - internal/payment:Native 下单出 code_url、APIv3 回调验签解密、主动查单, 三者统一收敛为 QueryResult。 - 下单 POST /billing/orders {pack_id}(≥member+审计):金额/积分按在售包服务端 锁定进订单行,不信任客户端;渠道下单失败即作废,不留付不了的 pending。 - 到账两条路汇入同一个 MarkOrderPaid 幂等闸(CAS+唯一索引双闸,同 P5.1): ①公开回调路由(验签是唯一的门;金额与订单不符不入账);②前端轮询的 GET /billing/orders/:id 在 pending 时顺路主动查单——本地/内网收不到公网 回调也能确认到账,回调只是生产更快的通道。pending 超 30 分钟置 expired。 - Web 面:在售包卡片(渠道亮才出现)→扫码弹窗(qrcode 画 code_url,二维码底色 固定纯白——暗色主题下低对比码扫不出来)→2.5s 轮询→到账 toast+刷余额。 验证:go/tsc/vitest 全绿;无凭据+假凭据两种降级 live 四连 (channels 只剩 redeem/下单 400 引导兑换码/回调 503/兑换码闭环不受影响)。 ⚠️ 真通道(prepay→扫码→回调/查单→入账)需真实商户号,未 live——用户配好 env 后用小额包实测,建议先配 ¥0.01 测试包走一单。 Co-Authored-By: Claude Opus 4.8 --- sundynix-gateway/go.mod | 14 +- sundynix-gateway/go.sum | 65 ++-- .../internal/handler/billing_pay.go | 131 ++++++- .../internal/handler/task_handler.go | 16 +- sundynix-gateway/internal/payment/wechat.go | 154 ++++++++ sundynix-gateway/internal/router/router.go | 10 +- sundynix-gateway/internal/store/payment.go | 80 +++++ sundynix-web/package-lock.json | 334 ++++++++++++++++++ sundynix-web/package.json | 2 + sundynix-web/src/api.ts | 34 ++ sundynix-web/src/pages/Usage.tsx | 127 ++++++- 11 files changed, 921 insertions(+), 46 deletions(-) create mode 100644 sundynix-gateway/internal/payment/wechat.go diff --git a/sundynix-gateway/go.mod b/sundynix-gateway/go.mod index 985eee0..2247c2e 100644 --- a/sundynix-gateway/go.mod +++ b/sundynix-gateway/go.mod @@ -11,6 +11,8 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/redis/go-redis/v9 v9.20.0 github.com/sundynix/sundynix-shared v0.0.0 + github.com/wechatpay-apiv3/wechatpay-go v0.2.21 + go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 golang.org/x/crypto v0.53.0 gorm.io/driver/postgres v1.6.0 gorm.io/gorm v1.31.1 @@ -23,6 +25,7 @@ require ( github.com/bytedance/gopkg v0.1.4 // indirect github.com/bytedance/sonic v1.15.1 // indirect github.com/bytedance/sonic/loader v0.5.1 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cloudwego/base64x v0.1.7 // indirect github.com/dustin/go-humanize v1.0.1 // indirect @@ -35,6 +38,7 @@ require ( github.com/goccy/go-json v0.10.6 // indirect github.com/goccy/go-yaml v1.19.2 // indirect github.com/google/uuid v1.6.0 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 // indirect github.com/jackc/pgpassfile v1.0.0 // indirect github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect github.com/jackc/pgx/v5 v5.6.0 // indirect @@ -45,7 +49,6 @@ require ( github.com/klauspost/compress v1.18.6 // indirect github.com/klauspost/cpuid/v2 v2.3.0 // indirect github.com/klauspost/crc32 v1.3.0 // indirect - github.com/kr/text v0.2.0 // indirect github.com/kylelemons/godebug v1.1.0 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-isatty v0.0.22 // indirect @@ -71,10 +74,13 @@ require ( github.com/zeebo/xxh3 v1.1.0 // indirect go.mongodb.org/mongo-driver/v2 v2.6.0 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect - go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 // indirect go.opentelemetry.io/otel v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 // indirect go.opentelemetry.io/otel/metric v1.44.0 // indirect + go.opentelemetry.io/otel/sdk v1.44.0 // indirect go.opentelemetry.io/otel/trace v1.44.0 // indirect + go.opentelemetry.io/proto/otlp v1.10.0 // indirect go.uber.org/atomic v1.11.0 // indirect go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect @@ -83,7 +89,9 @@ require ( golang.org/x/sync v0.21.0 // indirect golang.org/x/sys v0.46.0 // indirect golang.org/x/text v0.38.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 // indirect + google.golang.org/grpc v1.81.1 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/ini.v1 v1.67.2 // indirect - gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/sundynix-gateway/go.sum b/sundynix-gateway/go.sum index dac2fb4..462cc98 100644 --- a/sundynix-gateway/go.sum +++ b/sundynix-gateway/go.sum @@ -1,3 +1,5 @@ +github.com/agiledragon/gomonkey v2.0.2+incompatible h1:eXKi9/piiC3cjJD1658mEE2o3NjkJ5vDLgYjCQu0Xlw= +github.com/agiledragon/gomonkey v2.0.2+incompatible/go.mod h1:2NGfXu1a80LLr2cmWXGBDaHEjb1idR6+FVlX5T3D9hw= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs= @@ -6,40 +8,27 @@ github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA= github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0= github.com/bwmarrin/snowflake v0.3.0 h1:xm67bEhkKh6ij1790JB83OujPR5CzNe8QuQqAgISZN0= github.com/bwmarrin/snowflake v0.3.0/go.mod h1:NdZxfVWX+oR6y2K0o6qAYv6gIOP9rjG0/E9WsDpxqwE= -github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M= -github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM= github.com/bytedance/gopkg v0.1.4 h1:oZnQwnX82KAIWb7033bEwtxvTqXcYMxDBaQxo5JJHWM= github.com/bytedance/gopkg v0.1.4/go.mod h1:v1zWfPm21Fb+OsyXN2VAHdL6TBb2L88anLQgdyje6R4= -github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE= -github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k= github.com/bytedance/sonic v1.15.1 h1:nJD5PmM0vY7J8CT6MxoqbVAAMhkSmV2HgRAUrrpLoOw= github.com/bytedance/sonic v1.15.1/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA= -github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE= -github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI= github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= -github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= github.com/cloudwego/base64x v0.1.7 h1:NppS+Fgzg5ovhn4NkUXaDT3x9jldgH5ToMCqzBSi2zI= github.com/cloudwego/base64x v0.1.7/go.mod h1:Cu1PV9zfrSf7ET2tIbWbbEy7jO7HHJ13q4X2SQ8aWYg= -github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= -github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0= -github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk= github.com/gabriel-vasile/mimetype v1.4.13 h1:46nXokslUBsAJE/wMsp5gtO500a4F3Nkz9Ufpk2AcUM= github.com/gabriel-vasile/mimetype v1.4.13/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s= -github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE= -github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI= github.com/gin-contrib/sse v1.1.1 h1:uGYpNwTacv5R68bSGMapo62iLTRa9l5zxGCps4hK6ko= github.com/gin-contrib/sse v1.1.1/go.mod h1:QXzuVkA0YO7o/gun03UI1Q+FTI8ZV/n5t03kIQAI89s= -github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU= -github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8= github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc= github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= @@ -53,23 +42,23 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= -github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8= -github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM= github.com/go-playground/validator/v10 v10.30.2 h1:JiFIMtSSHb2/XBUbWM4i/MpeQm9ZK2xqPNk8vgvu5JQ= github.com/go-playground/validator/v10 v10.30.2/go.mod h1:mAf2pIOVXjTEBrwUMGKkCWKKPs9NheYGabeB04txQSc= -github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= -github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.6 h1:p8HrPJzOakx/mn/bQtjgNjdTcN+/S6FcG2CTtQOrHVU= github.com/goccy/go-json v0.10.6/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M= github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM= github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0 h1:5VipnvEpbqr2gA2VbM+nYVbkIF28c5ZQfqCBQ5g2xfk= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.29.0/go.mod h1:Hyl3n6Twe1hvtd9XUXDec4pTvgMSEixRuQKPTMH2bNs= github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= @@ -87,8 +76,6 @@ github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHm github.com/klauspost/compress v1.18.6 h1:2jupLlAwFm95+YDR+NwD2MEfFO9d4z4Prjl1XXDjuao= github.com/klauspost/compress v1.18.6/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= -github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU= -github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= github.com/klauspost/crc32 v1.3.0 h1:sSmTt3gUt81RP655XGZPElI0PelVTZ6YwCRnPSupoFM= @@ -101,8 +88,6 @@ github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0 github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= -github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= -github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/minio/crc64nvme v1.1.1 h1:8dwx/Pz49suywbO+auHCBpCtlW1OfpcLN7wYgVR6wAI= @@ -162,6 +147,7 @@ github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UV github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= @@ -170,10 +156,10 @@ github.com/tinylib/msgp v1.6.1 h1:ESRv8eL3u+DNHUoSAAQRE50Hm162zqAnBoGv9PzScPY= github.com/tinylib/msgp v1.6.1/go.mod h1:RSp0LW9oSxFut3KzESt5Voq4GVWyS+PSulT77roAqEA= github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= -github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE= -github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg= github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY= github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/wechatpay-apiv3/wechatpay-go v0.2.21 h1:uIyMpzvcaHA33W/QPtHstccw+X52HO1gFdvVL9O6Lfs= +github.com/wechatpay-apiv3/wechatpay-go v0.2.21/go.mod h1:A254AUBVB6R+EqQFo3yTgeh7HtyqRRtN2w9hQSOrd4Q= github.com/zeebo/assert v1.3.0 h1:g7C04CbJuIDKNPFHmsk4hwZDO5O+kntRxzaUoNXj+IQ= github.com/zeebo/assert v1.3.0/go.mod h1:Pq9JiuJQpG8JLJdtkwrJESF0Foym2/D9XMU5ciN/wJ0= github.com/zeebo/xxh3 v1.1.0 h1:s7DLGDK45Dyfg7++yxI0khrfwq9661w9EN78eP/UZVs= @@ -184,22 +170,36 @@ go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0 h1:u5gsfBL8t1Km4ROhQKAs0cA0t9CzUE7nfkASj/UjAtI= go.opentelemetry.io/contrib/instrumentation/github.com/gin-gonic/gin/otelgin v0.69.0/go.mod h1:W6FFYCZQuntC5hxVesXpu7Ppd9sT0a84njildAijc+k= +go.opentelemetry.io/contrib/propagators/b3 v1.44.0 h1:1IFH4oFKK8KupzIelCl3u+bkxpGRps1oWRjQI2+TTWs= +go.opentelemetry.io/contrib/propagators/b3 v1.44.0/go.mod h1:JqWFXsc7VDaqIyubFhEd2cPHqsrzqP0Lvn783SUwyro= go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0 h1:4YsVu3B8+3qtWYYrsUYgn0OG78pN0rnNPRGX4SbokQI= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.44.0/go.mod h1:+wnlSn0mD1ADVMe3v9Z/WIaiz6q6gL2J/ejaAmdmv80= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0 h1:lgh3PiVrRUWMLOVSkQicxzZll5NjF1r+AtsX1XRIHw0= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.44.0/go.mod h1:5Cnhth3m/AgOeTgE3ex12pPmiu/gGtZit03kSzx9X7s= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0 h1:bl2S7Ubua0Nms+D/gAmznQTd4dxxMA93aKbcpKqiTCs= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.44.0/go.mod h1:L0hRV50XdVIODHUfWEqGRCXQvj2rV82STVo12FMFBU0= go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.44.0 h1:nHYwb9lK+fJPU/dnT6s7W7Z8itMWyqrnVfbheVYrZ58= +go.opentelemetry.io/otel/sdk v1.44.0/go.mod h1:Osuydd3Se74nqjAKxid74N5eC+jfEqfTegHRnq58oK0= +go.opentelemetry.io/otel/sdk/metric v1.44.0 h1:3LlKgI+VjbVsjNRFZJZAJ30WjXC5VkNRks6si09iEfI= +go.opentelemetry.io/otel/sdk/metric v1.44.0/go.mod h1:5B5pMARnXxKhltooO4xUuCBorl65a4EpnTalObqOigA= go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y= +go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU= go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/arch v0.11.0 h1:KXV8WWKCXm6tRpLirl2szsO5j/oOODwZf4hATmGVNs4= -golang.org/x/arch v0.11.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys= golang.org/x/arch v0.27.0 h1:0WNVcR8u9yFz8j5FvdHpgwNp3FS5U4guYdzHwEiGjoU= golang.org/x/arch v0.27.0/go.mod h1:0X+GdSIP+kL5wPmpK7sdkEVTt2XoYP0cSjQSbZBwOi8= golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= @@ -208,15 +208,20 @@ golang.org/x/net v0.55.0 h1:bcvxaJn3e1U6InsFWt1JUq1aSjnRxLzT2rtD2KfkDF8= golang.org/x/net v0.55.0/go.mod h1:L5U2KuzuOe1lY7Z+aWVIKK6qEeJXnXV9yzGA+WCHJww= golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= -golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= -google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE= -google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= +gonum.org/v1/gonum v0.17.0 h1:VbpOemQlsSMrYmn7T2OUvQ4dqxQXU+ouZFQsZOx50z4= +gonum.org/v1/gonum v0.17.0/go.mod h1:El3tOrEuMpv2UdMrbNlKEh9vd86bmQ6vqIcDwxEOc1E= +google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3 h1:ctPmKL12ZsoKAlmPUsoW70zEDiYF+/H6aLieXxgAU0k= +google.golang.org/genproto/googleapis/api v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:Z4WJ5pJOYWFWcHEQUelD5QaZDknIQkpIL/+fyJOT9+A= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3 h1:phvBWCAQMGN1945mp5fjCXP6jEF0+a0+4TjokS4sxNY= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260618152121-87f3d3e198d3/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.81.1 h1:VnnIIZ88UzOOKLukQi+ImGz8O1Wdp8nAGGnvOfEIWQQ= +google.golang.org/grpc v1.81.1/go.mod h1:xGH9GfzOyMTGIOXBJmXt+BX/V0kcdQbdcuwQ/zNw42I= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/sundynix-gateway/internal/handler/billing_pay.go b/sundynix-gateway/internal/handler/billing_pay.go index 98be862..5240811 100644 --- a/sundynix-gateway/internal/handler/billing_pay.go +++ b/sundynix-gateway/internal/handler/billing_pay.go @@ -3,6 +3,7 @@ package handler import ( "net/http" "strings" + "time" "github.com/gin-gonic/gin" "github.com/sundynix/sundynix-gateway/internal/store" @@ -12,14 +13,140 @@ import ( // 入账目标一律是「计费租户」(ResolveBillingTenantID)——和消耗记账同一本账, // 谁的池子扣钱就往谁的池子充,别让用户充进一个花不到的池。 -// BillingPacks: GET /api/v1/billing/packs —— 在售积分包(微信渠道 P5.2 上线前仅展示)。 +// BillingPacks: GET /api/v1/billing/packs —— 在售积分包 + 可用渠道(wechat 配了 env 才亮)。 func (h *Handler) BillingPacks(c *gin.Context) { packs, err := h.db.ActivePacks(c.Request.Context()) if err != nil { c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, gin.H{"packs": packs, "channels": []string{store.ChannelRedeem}}) + channels := []string{store.ChannelRedeem} + if h.wechat != nil { + channels = append(channels, store.ChannelWechat) + } + c.JSON(http.StatusOK, gin.H{"packs": packs, "channels": channels}) +} + +// orderTTL 待支付订单的有效期:过期后前端轮询会把它置 expired,不再确认到账。 +// 微信 Native 的 code_url 本身约 2 小时有效,这里收紧到 30 分钟——挂太久的单 +// 价格可能已经改过,不让旧价格的单无限期可付。 +const orderTTL = 30 * time.Minute + +// BillingCreateOrder: POST /api/v1/billing/orders {pack_id} —— 微信 Native 下单,返回 code_url。 +// 金额/积分由服务端按在售包锁定进订单行,前端只传包 id,不信任任何客户端金额。 +func (h *Handler) BillingCreateOrder(c *gin.Context) { + if h.wechat == nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "微信支付未配置,请用兑换码充值"}) + return + } + var b struct { + PackID string `json:"pack_id"` + } + if err := c.ShouldBindJSON(&b); err != nil || strings.TrimSpace(b.PackID) == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "pack_id 必填"}) + return + } + ctx := c.Request.Context() + uid := userID(c) + billing := h.db.ResolveBillingTenantID(ctx, uid, tenantID(c)) + if billing == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "无计费租户上下文"}) + return + } + pk, err := h.db.GetPack(ctx, b.PackID) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "积分包不存在或已下架"}) + return + } + o := &store.PaymentOrder{ + TenantID: billing, UserID: uid, PackID: pk.ID, + AmountFen: pk.PriceFen, CreditsMicro: pk.CreditsMicro, + Channel: store.ChannelWechat, Status: store.OrderPending, + } + if err := h.db.CreateOrder(ctx, o); err != nil { + c.JSON(http.StatusBadGateway, gin.H{"error": err.Error()}) + return + } + codeURL, err := h.wechat.CreatePay(ctx, o.ID, "sundynix 积分充值 · "+pk.Name, pk.PriceFen) + if err != nil { + // 渠道下单失败的单直接作废,不留一堆永远付不了的 pending。 + _ = h.db.ExpireOrder(ctx, o.ID) + c.JSON(http.StatusBadGateway, gin.H{"error": "微信下单失败: " + err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"order_id": o.ID, "code_url": codeURL, "amount_fen": o.AmountFen}) +} + +// BillingOrderStatus: GET /api/v1/billing/orders/:id —— 前端轮询订单态。 +// pending 时顺路主动查单确认(本地/内网收不到公网回调也能到账——回调只是生产更快的通道, +// 两条路汇入同一个 MarkOrderPaid 幂等闸);超过 TTL 置 expired。 +func (h *Handler) BillingOrderStatus(c *gin.Context) { + ctx := c.Request.Context() + o, err := h.db.GetOrder(ctx, c.Param("id")) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "订单不存在"}) + return + } + // 只允许看自己计费租户的单(订单表未挂租户插件,这里显式校验)。 + if o.TenantID != h.db.ResolveBillingTenantID(ctx, userID(c), tenantID(c)) { + c.JSON(http.StatusNotFound, gin.H{"error": "订单不存在"}) + return + } + if o.Status == store.OrderPending && h.wechat != nil { + if r, err := h.wechat.QueryOrder(ctx, o.ID); err == nil { + switch { + case r.Paid && r.AmountFen == o.AmountFen: + if _, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn); err == nil { + o, _ = h.db.GetOrder(ctx, o.ID) + } + case r.Paid: // 金额对不上:不入账,人工对账(比错账便宜) + c.JSON(http.StatusOK, gin.H{"order": o, "warn": "支付金额与订单不符,已挂起待人工核对"}) + return + case r.Closed: + _ = h.db.ExpireOrder(ctx, o.ID) + o, _ = h.db.GetOrder(ctx, o.ID) + } + } + if o.Status == store.OrderPending && time.Since(o.CreatedAt) > orderTTL { + _ = h.db.ExpireOrder(ctx, o.ID) + o, _ = h.db.GetOrder(ctx, o.ID) + } + } + c.JSON(http.StatusOK, gin.H{"order": o}) +} + +// WechatCallback: POST /api/v1/billing/callback/wechat —— 微信支付回调(公开路由,验签是唯一的门)。 +// 应答契约:入账成功/重复推送都回 200 {code:SUCCESS};验签失败 4xx;处理失败 5xx 让微信重试。 +func (h *Handler) WechatCallback(c *gin.Context) { + if h.wechat == nil { + c.JSON(http.StatusServiceUnavailable, gin.H{"code": "FAIL", "message": "渠道未配置"}) + return + } + r, err := h.wechat.VerifyCallback(c.Request) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"code": "FAIL", "message": "验签失败"}) + return + } + if !r.Paid { + c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) // 非成功态通知:确认收到即可 + return + } + ctx := c.Request.Context() + o, err := h.db.GetOrder(ctx, r.OrderID) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) // 不认识的单:可能是别的环境,别让微信无限重试 + return + } + if r.AmountFen != o.AmountFen { + // 金额不符:不入账、不让重试(重试也不会变对),落审计人工处理。 + c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) + return + } + if _, err := h.db.MarkOrderPaid(ctx, o.ID, r.ChannelTxn); err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"code": "FAIL", "message": "入账失败"}) + return + } + c.JSON(http.StatusOK, gin.H{"code": "SUCCESS"}) } // BillingRedeem: POST /api/v1/billing/redeem {code} —— 核销兑换码,积分入计费租户。 diff --git a/sundynix-gateway/internal/handler/task_handler.go b/sundynix-gateway/internal/handler/task_handler.go index 7422e8a..04f4a62 100644 --- a/sundynix-gateway/internal/handler/task_handler.go +++ b/sundynix-gateway/internal/handler/task_handler.go @@ -18,21 +18,29 @@ import ( "github.com/sundynix/sundynix-gateway/internal/blob" "github.com/sundynix/sundynix-gateway/internal/dsl" "github.com/sundynix/sundynix-gateway/internal/nats" + "github.com/sundynix/sundynix-gateway/internal/payment" "github.com/sundynix/sundynix-gateway/internal/store" "github.com/sundynix/sundynix-shared/contract" ) type Handler struct { - db *store.Postgres - cache *store.Redis - bus *nats.Bus - blob *blob.Store + db *store.Postgres + cache *store.Redis + bus *nats.Bus + blob *blob.Store + wechat *payment.Wechat // 微信支付渠道;nil=未配置(渠道隐藏) } func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blob *blob.Store) *Handler { return &Handler{db: db, cache: cache, bus: bus, blob: blob} } +// WithWechat 注入微信支付渠道(nil 安全:保持隐藏)。 +func (h *Handler) WithWechat(w *payment.Wechat) *Handler { + h.wechat = w + return h +} + // preflight 是「会烧钱的执行」提交前的统一关卡:当日 token 预算 → 计费租户 → 积分硬拦截。 // 返回计费租户;ok=false 表示已写过响应,调用方直接 return。 // diff --git a/sundynix-gateway/internal/payment/wechat.go b/sundynix-gateway/internal/payment/wechat.go new file mode 100644 index 0000000..21535f5 --- /dev/null +++ b/sundynix-gateway/internal/payment/wechat.go @@ -0,0 +1,154 @@ +// Package payment 是充值渠道适配层(设计见 PAYMENT_DESIGN.md §3/§5)。 +// P5.1 的兑换码不走这里(无「待支付」态,核销即入账);本包面向真渠道: +// 下单出支付凭据 → 回调/查单确认 → 上层 MarkOrderPaid 幂等入账。 +package payment + +import ( + "context" + "errors" + "fmt" + "log" + "net/http" + "os" + + "github.com/wechatpay-apiv3/wechatpay-go/core" + "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" + "github.com/wechatpay-apiv3/wechatpay-go/core/downloader" + "github.com/wechatpay-apiv3/wechatpay-go/core/notify" + "github.com/wechatpay-apiv3/wechatpay-go/core/option" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments/native" + "github.com/wechatpay-apiv3/wechatpay-go/utils" +) + +// Wechat 微信支付 Native(扫码)适配器。凭据一律 env 注入: +// +// WECHAT_MCHID 商户号 +// WECHAT_MCH_CERT_SERIAL 商户 API 证书序列号 +// WECHAT_MCH_PRIVATE_KEY 商户 API 私钥文件路径(apiclient_key.pem) +// WECHAT_APIV3_KEY APIv3 密钥(32 字节) +// WECHAT_APPID 关联的公众号/小程序/APP 的 appid +// WECHAT_NOTIFY_URL 支付回调地址(须公网 https,如 https://api.example.com/api/v1/billing/callback/wechat) +// +// 任一缺失 → NewWechatFromEnv 返回 nil,渠道自动隐藏(半配置状态不许把下单路由搞出 5xx)。 +// 本地开发收不到公网回调没关系:前端轮询的 GET /billing/orders/:id 会主动查单确认, +// 回调只是生产环境更快的到账通道,两条路都汇入同一个幂等入账闸。 +type Wechat struct { + mchID string + appID string + notifyURL string + apiv3Key string + client *core.Client + svc native.NativeApiService +} + +// NewWechatFromEnv 依据环境变量装配微信渠道;未配置(或配置不全/私钥读不了)返回 nil。 +func NewWechatFromEnv(ctx context.Context) *Wechat { + mchID := os.Getenv("WECHAT_MCHID") + serial := os.Getenv("WECHAT_MCH_CERT_SERIAL") + keyPath := os.Getenv("WECHAT_MCH_PRIVATE_KEY") + apiv3 := os.Getenv("WECHAT_APIV3_KEY") + appID := os.Getenv("WECHAT_APPID") + notifyURL := os.Getenv("WECHAT_NOTIFY_URL") + if mchID == "" && serial == "" && keyPath == "" && apiv3 == "" { + return nil // 完全未配置:静默(大多数开发环境) + } + if mchID == "" || serial == "" || keyPath == "" || apiv3 == "" || appID == "" || notifyURL == "" { + log.Printf("[payment] 微信支付配置不全(MCHID/CERT_SERIAL/PRIVATE_KEY/APIV3_KEY/APPID/NOTIFY_URL 缺一不可),渠道保持隐藏") + return nil + } + priv, err := utils.LoadPrivateKeyWithPath(keyPath) + if err != nil { + log.Printf("[payment] 微信商户私钥加载失败(%s),渠道保持隐藏: %v", keyPath, err) + return nil + } + client, err := core.NewClient(ctx, option.WithWechatPayAutoAuthCipher(mchID, serial, priv, apiv3)) + if err != nil { + log.Printf("[payment] 微信支付客户端初始化失败,渠道保持隐藏: %v", err) + return nil + } + log.Printf("[payment] 微信支付 Native 渠道已启用 (mchid=%s)", mchID) + return &Wechat{ + mchID: mchID, appID: appID, notifyURL: notifyURL, apiv3Key: apiv3, + client: client, svc: native.NativeApiService{Client: client}, + } +} + +// CreatePay Native 下单:返回 code_url(前端渲染成二维码)。金额取订单锁定值。 +func (w *Wechat) CreatePay(ctx context.Context, orderID, description string, amountFen int64) (string, error) { + resp, _, err := w.svc.Prepay(ctx, native.PrepayRequest{ + Appid: core.String(w.appID), + Mchid: core.String(w.mchID), + Description: core.String(description), + OutTradeNo: core.String(orderID), + NotifyUrl: core.String(w.notifyURL), + Amount: &native.Amount{Total: core.Int64(amountFen), Currency: core.String("CNY")}, + }) + if err != nil { + return "", err + } + if resp.CodeUrl == nil || *resp.CodeUrl == "" { + return "", errors.New("微信未返回 code_url") + } + return *resp.CodeUrl, nil +} + +// QueryResult 查单/回调解析后的统一结果。 +type QueryResult struct { + OrderID string // out_trade_no + ChannelTxn string // transaction_id + Paid bool // TradeState == SUCCESS + Closed bool // CLOSED/REVOKED/PAYERROR 等终态失败 + AmountFen int64 // 用户实付(分);回调/查单都带,供金额核对 +} + +func fromTransaction(t *payments.Transaction) QueryResult { + r := QueryResult{} + if t.OutTradeNo != nil { + r.OrderID = *t.OutTradeNo + } + if t.TransactionId != nil { + r.ChannelTxn = *t.TransactionId + } + if t.Amount != nil && t.Amount.PayerTotal != nil { + r.AmountFen = *t.Amount.PayerTotal + } else if t.Amount != nil && t.Amount.Total != nil { + r.AmountFen = *t.Amount.Total + } + if t.TradeState != nil { + switch *t.TradeState { + case "SUCCESS": + r.Paid = true + case "CLOSED", "REVOKED", "PAYERROR": + r.Closed = true + } + } + return r +} + +// QueryOrder 主动查单(本地开发确认到账、生产掉单补偿共用)。 +func (w *Wechat) QueryOrder(ctx context.Context, orderID string) (QueryResult, error) { + t, _, err := w.svc.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{ + OutTradeNo: core.String(orderID), + Mchid: core.String(w.mchID), + }) + if err != nil { + return QueryResult{}, err + } + return fromTransaction(t), nil +} + +// VerifyCallback 验签 + 解密支付回调(APIv3:平台证书验签、AES-GCM 解密资源)。 +// 验签失败一律拒绝——回调路由是公开的,签名是唯一的门。 +func (w *Wechat) VerifyCallback(req *http.Request) (QueryResult, error) { + certVisitor := downloader.MgrInstance().GetCertificateVisitor(w.mchID) + h, err := notify.NewRSANotifyHandler(w.apiv3Key, verifiers.NewSHA256WithRSAVerifier(certVisitor)) + if err != nil { + return QueryResult{}, fmt.Errorf("回调处理器初始化失败: %w", err) + } + txn := new(payments.Transaction) + if _, err := h.ParseNotifyRequest(req.Context(), req, txn); err != nil { + return QueryResult{}, err + } + return fromTransaction(txn), nil +} diff --git a/sundynix-gateway/internal/router/router.go b/sundynix-gateway/internal/router/router.go index 8be5e89..e4abb20 100644 --- a/sundynix-gateway/internal/router/router.go +++ b/sundynix-gateway/internal/router/router.go @@ -2,6 +2,7 @@ package router import ( + "context" "log" "os" "strings" @@ -14,6 +15,7 @@ import ( "github.com/sundynix/sundynix-gateway/internal/handler" "github.com/sundynix/sundynix-gateway/internal/middleware" "github.com/sundynix/sundynix-gateway/internal/nats" + "github.com/sundynix/sundynix-gateway/internal/payment" "github.com/sundynix/sundynix-gateway/internal/store" ) @@ -31,7 +33,8 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. r.Use(middleware.RateLimit(cache)) // 已认证按用户限流,否则按 IP(企业网多人共享 IP 不再互相拖累) r.Use(middleware.Guardrail(db)) // Harness: Input Guardrail(命中落库 guardrail_event) - h := handler.New(db, cache, bus, blobStore) + // 微信支付渠道按 env 装配;未配置返回 nil → 渠道自动隐藏,下单路由 400 引导用兑换码。 + h := handler.New(db, cache, bus, blobStore).WithWechat(payment.NewWechatFromEnv(context.Background())) // 可观测性根端点:Prometheus 抓取 + k8s 存活/就绪探针(不挂业务中间件鉴权)。 r.GET("/metrics", gin.WrapH(promhttp.Handler())) @@ -50,6 +53,7 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. api.GET("/kb/ingest/:id/stream", h.KbIngestStream) // 入库进度 SSE(job_id 寻址) api.GET("/reports/:id/export", h.ExportReport) // 按需导出(report_id 寻址) api.GET("/reports/:id/download", h.ExportReport) // 兼容旧入口(默认 docx) + api.POST("/billing/callback/wechat", h.WechatCallback) // 支付回调(渠道服务器带不了 Bearer;APIv3 验签是唯一的门) // —— 受保护:owner 作用域业务,必须携带有效 JWT —— p := api.Group("", middleware.RequireAuth()) @@ -105,10 +109,12 @@ func New(db *store.Postgres, cache *store.Redis, bus *nats.Bus, blobStore *blob. p.POST("/spaces/:id/archive", h.SpaceArchive) // 归档空间 p.POST("/reports", middleware.RequireTenantRole(db, store.RoleMember), h.GenerateReport) // 报告生成(同样烧租户积分):viewer 只读拦下 p.GET("/billing", h.Billing) - // 充值(P5.1 兑换码):核销会动钱,≥member + 审计;查询全员可看。 + // 充值(P5.1 兑换码 + P5.2 微信 Native):动钱的 ≥member + 审计;查询全员可看。 p.GET("/billing/packs", h.BillingPacks) p.GET("/billing/orders", h.BillingOrders) + p.GET("/billing/orders/:id", h.BillingOrderStatus) // 轮询单态(pending 时顺路主动查单确认) p.POST("/billing/redeem", middleware.RequireTenantRole(db, store.RoleMember), middleware.Audit(db), h.BillingRedeem) + p.POST("/billing/orders", middleware.RequireTenantRole(db, store.RoleMember), middleware.Audit(db), h.BillingCreateOrder) p.GET("/stats/overview", h.StatsOverview) // 工作台仪表盘聚合 p.GET("/runs", h.Runs) // 运行历史(复盘) p.GET("/tasks/:id/replay", h.TaskReplay) // 历史运行复盘(持久化输出+轨迹,免 Redis TTL) diff --git a/sundynix-gateway/internal/store/payment.go b/sundynix-gateway/internal/store/payment.go index c4d2bbb..8701102 100644 --- a/sundynix-gateway/internal/store/payment.go +++ b/sundynix-gateway/internal/store/payment.go @@ -191,6 +191,86 @@ func (p *Postgres) Redeem(ctx context.Context, code, tenantID, userID string) (* return order, nil } +// CreateOrder 落一张 pending 订单(价与积分由服务端按包锁定后传入)。 +func (p *Postgres) CreateOrder(ctx context.Context, o *PaymentOrder) error { + if p.db == nil { + return errStoreDisabled + } + return p.db.WithContext(WithoutTenant(ctx)).Create(o).Error +} + +// GetOrder 按 id 取订单。 +func (p *Postgres) GetOrder(ctx context.Context, id string) (*PaymentOrder, error) { + if p.db == nil { + return nil, errStoreDisabled + } + var o PaymentOrder + if err := p.db.WithContext(WithoutTenant(ctx)).First(&o, "id = ?", id).Error; err != nil { + return nil, err + } + return &o, nil +} + +// MarkOrderPaid 渠道确认已支付后的入账:一个事务里「订单 CAS(pending→paid) → 分录 → 物化余额」。 +// 返回 changed=false 表示这单已被处理过(回调重复推送/回调与主动查单赛跑),幂等直接成功。 +// 双闸:CAS 是主闸;credit_ledger (kind,ref=订单号) 唯一索引兜底。 +func (p *Postgres) MarkOrderPaid(ctx context.Context, orderID, channelTxn string) (bool, error) { + if p.db == nil { + return false, errStoreDisabled + } + changed := false + err := p.db.WithContext(WithoutTenant(ctx)).Transaction(func(tx *gorm.DB) error { + now := time.Now() + res := tx.Model(&PaymentOrder{}). + Where("id = ? AND status = ?", orderID, OrderPending). + Updates(map[string]any{"status": OrderPaid, "channel_txn": channelTxn, "paid_at": now}) + if res.Error != nil { + return res.Error + } + if res.RowsAffected == 0 { + return nil // 已处理过(或订单不存在/已过期)——幂等,不重复入账 + } + var o PaymentOrder + if err := tx.First(&o, "id = ?", orderID).Error; err != nil { + return err + } + if err := tx.Create(&CreditLedger{ + TenantID: o.TenantID, Kind: LedgerGrant, CreditsMicro: o.CreditsMicro, Ref: o.ID, Memo: "充值 " + o.Channel, + }).Error; err != nil { + return err + } + if err := tx.Model(&Tenant{}).Where("id = ?", o.TenantID). + UpdateColumn("credit_balance_micro", gorm.Expr("credit_balance_micro + ?", o.CreditsMicro)).Error; err != nil { + return err + } + changed = true + return nil + }) + return changed, err +} + +// ExpireOrder 把超时未付的 pending 单置为 expired(CAS,已 paid 的不动)。 +func (p *Postgres) ExpireOrder(ctx context.Context, orderID string) error { + if p.db == nil { + return errStoreDisabled + } + return p.db.WithContext(WithoutTenant(ctx)).Model(&PaymentOrder{}). + Where("id = ? AND status = ?", orderID, OrderPending). + Update("status", OrderExpired).Error +} + +// GetPack 按 id 取在售积分包(下单锁价用;下架的包不可下单)。 +func (p *Postgres) GetPack(ctx context.Context, id string) (*CreditPack, error) { + if p.db == nil { + return nil, errStoreDisabled + } + var pk CreditPack + if err := p.db.WithContext(WithoutTenant(ctx)).First(&pk, "id = ? AND active = ?", id, true).Error; err != nil { + return nil, err + } + return &pk, nil +} + // ActivePacks 在售积分包(用户侧展示)。 func (p *Postgres) ActivePacks(ctx context.Context) ([]CreditPack, error) { if p.db == nil { diff --git a/sundynix-web/package-lock.json b/sundynix-web/package-lock.json index c4d2d45..eaee40e 100644 --- a/sundynix-web/package-lock.json +++ b/sundynix-web/package-lock.json @@ -9,11 +9,13 @@ "version": "0.1.0", "dependencies": { "lucide-react": "^1.17.0", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.0" }, "devDependencies": { + "@types/qrcode": "^1.5.6", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", @@ -1841,6 +1843,26 @@ "dev": true, "license": "MIT" }, + "node_modules/@types/node": { + "version": "26.1.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-26.1.1.tgz", + "integrity": "sha512-nxAkRSVkN1Y0JC1W8ky/fTfkGsMmcrRsbx+3XoZE+rMOX71kLYTV7fLXpqud1GpbpP5TuffXFqfX7fH2GgZREw==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~8.3.0" + } + }, + "node_modules/@types/qrcode": { + "version": "1.5.6", + "resolved": "https://registry.npmjs.org/@types/qrcode/-/qrcode-1.5.6.tgz", + "integrity": "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/react": { "version": "19.2.17", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", @@ -1968,6 +1990,30 @@ "url": "https://opencollective.com/vitest" } }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/any-promise": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", @@ -2126,6 +2172,15 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/camelcase": { + "version": "5.3.1", + "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-5.3.1.tgz", + "integrity": "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/camelcase-css": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", @@ -2205,6 +2260,35 @@ "node": ">= 6" } }, + "node_modules/cliui": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-6.0.0.tgz", + "integrity": "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ==", + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.0", + "wrap-ansi": "^6.2.0" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "license": "MIT" + }, "node_modules/commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -2301,6 +2385,15 @@ } } }, + "node_modules/decamelize": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/decamelize/-/decamelize-1.2.0.tgz", + "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/decimal.js": { "version": "10.6.0", "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", @@ -2325,6 +2418,12 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/dijkstrajs": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/dijkstrajs/-/dijkstrajs-1.0.3.tgz", + "integrity": "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA==", + "license": "MIT" + }, "node_modules/dlv": { "version": "1.1.3", "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", @@ -2339,6 +2438,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/entities": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", @@ -2491,6 +2596,19 @@ "node": ">=8" } }, + "node_modules/find-up": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", + "license": "MIT", + "dependencies": { + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/fraction.js": { "version": "5.3.4", "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", @@ -2540,6 +2658,15 @@ "node": ">=6.9.0" } }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, "node_modules/glob-parent": { "version": "6.0.2", "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", @@ -2618,6 +2745,15 @@ "node": ">=0.10.0" } }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/is-glob": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", @@ -3023,6 +3159,18 @@ "dev": true, "license": "MIT" }, + "node_modules/locate-path": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-5.0.0.tgz", + "integrity": "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==", + "license": "MIT", + "dependencies": { + "p-locate": "^4.1.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/lru-cache": { "version": "5.1.1", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", @@ -3175,6 +3323,42 @@ "node": ">=12.20.0" } }, + "node_modules/p-limit": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-2.3.0.tgz", + "integrity": "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==", + "license": "MIT", + "dependencies": { + "p-try": "^2.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-4.1.0.tgz", + "integrity": "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==", + "license": "MIT", + "dependencies": { + "p-limit": "^2.2.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/p-try": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", + "integrity": "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/parse5": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", @@ -3188,6 +3372,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, "node_modules/path-parse": { "version": "1.0.7", "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", @@ -3242,6 +3435,15 @@ "node": ">= 6" } }, + "node_modules/pngjs": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/pngjs/-/pngjs-5.0.0.tgz", + "integrity": "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw==", + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/postcss": { "version": "8.5.19", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.19.tgz", @@ -3415,6 +3617,23 @@ "node": ">=6" } }, + "node_modules/qrcode": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/qrcode/-/qrcode-1.5.4.tgz", + "integrity": "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg==", + "license": "MIT", + "dependencies": { + "dijkstrajs": "^1.0.1", + "pngjs": "^5.0.0", + "yargs": "^15.3.1" + }, + "bin": { + "qrcode": "bin/qrcode" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/queue-microtask": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", @@ -3528,6 +3747,15 @@ "node": ">=8.10.0" } }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/require-from-string": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", @@ -3538,6 +3766,12 @@ "node": ">=0.10.0" } }, + "node_modules/require-main-filename": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/require-main-filename/-/require-main-filename-2.0.0.tgz", + "integrity": "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg==", + "license": "ISC" + }, "node_modules/resolve": { "version": "1.22.12", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", @@ -3710,6 +3944,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -3747,6 +3987,32 @@ "dev": true, "license": "MIT" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/sucrase": { "version": "3.35.1", "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", @@ -4024,6 +4290,13 @@ "node": ">=20.18.1" } }, + "node_modules/undici-types": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.3.0.tgz", + "integrity": "sha512-j375ScV60dom+YkPFIfTLcOiPxkN/buHz5GobjLhixFuANaNs3C9l4GmrWqejgXWJ7BbJcFYpTEUkS1Ge8bpZQ==", + "dev": true, + "license": "MIT" + }, "node_modules/update-browserslist-db": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", @@ -4836,6 +5109,12 @@ "node": "^20.19.0 || ^22.12.0 || >=24.0.0" } }, + "node_modules/which-module": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/which-module/-/which-module-2.0.1.tgz", + "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==", + "license": "ISC" + }, "node_modules/why-is-node-running": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", @@ -4853,6 +5132,20 @@ "node": ">=8" } }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-6.2.0.tgz", + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/xml-name-validator": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", @@ -4870,12 +5163,53 @@ "dev": true, "license": "MIT" }, + "node_modules/y18n": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-4.0.3.tgz", + "integrity": "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ==", + "license": "ISC" + }, "node_modules/yallist": { "version": "3.1.1", "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", "dev": true, "license": "ISC" + }, + "node_modules/yargs": { + "version": "15.4.1", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-15.4.1.tgz", + "integrity": "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A==", + "license": "MIT", + "dependencies": { + "cliui": "^6.0.0", + "decamelize": "^1.2.0", + "find-up": "^4.1.0", + "get-caller-file": "^2.0.1", + "require-directory": "^2.1.1", + "require-main-filename": "^2.0.0", + "set-blocking": "^2.0.0", + "string-width": "^4.2.0", + "which-module": "^2.0.0", + "y18n": "^4.0.0", + "yargs-parser": "^18.1.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yargs-parser": { + "version": "18.1.3", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-18.1.3.tgz", + "integrity": "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ==", + "license": "ISC", + "dependencies": { + "camelcase": "^5.0.0", + "decamelize": "^1.2.0" + }, + "engines": { + "node": ">=6" + } } } } diff --git a/sundynix-web/package.json b/sundynix-web/package.json index 3393284..78e2dd9 100644 --- a/sundynix-web/package.json +++ b/sundynix-web/package.json @@ -12,11 +12,13 @@ }, "dependencies": { "lucide-react": "^1.17.0", + "qrcode": "^1.5.4", "react": "^19.0.0", "react-dom": "^19.0.0", "react-router-dom": "^7.1.0" }, "devDependencies": { + "@types/qrcode": "^1.5.6", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.3.0", diff --git a/sundynix-web/src/api.ts b/sundynix-web/src/api.ts index d22be5f..00b13f1 100644 --- a/sundynix-web/src/api.ts +++ b/sundynix-web/src/api.ts @@ -256,6 +256,40 @@ export interface TopupOrder { created_at: string; } +export interface Pack { + id: string; + name: string; + credits_micro: number; + price_fen: number; +} + +// billingPacks 在售积分包 + 可用渠道(服务端配了微信 env 才会出现 "wechat")。 +export async function billingPacks(): Promise<{ packs: Pack[]; channels: string[] }> { + const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/packs`, { headers: bearer() })); + if (!res.ok) return { packs: [], channels: [] }; + const d = (await res.json()) as { packs?: Pack[]; channels?: string[] }; + return { packs: d.packs ?? [], channels: d.channels ?? [] }; +} + +// createWechatOrder 微信 Native 下单:返回订单号 + code_url(渲染成二维码扫码付)。 +export async function createWechatOrder(packId: string): Promise<{ order_id: string; code_url: string; amount_fen: number }> { + const res = guard401( + await fetch(`${GATEWAY}/api/v1/billing/orders`, { + method: "POST", + headers: { "Content-Type": "application/json", ...bearer() }, + body: JSON.stringify({ pack_id: packId }), + }), + ); + return jsonOrThrow(res, "下单失败"); +} + +// orderStatus 轮询订单态(pending 时服务端顺路主动查单,本地也能确认到账)。 +export async function orderStatus(orderId: string): Promise { + const res = guard401(await fetch(`${GATEWAY}/api/v1/billing/orders/${orderId}`, { headers: bearer() })); + const d = await jsonOrThrow<{ order: TopupOrder }>(res, "查询失败"); + return d.order; +} + // redeemCode 核销兑换码,返回入账后的余额。 export async function redeemCode(code: string): Promise<{ balance_micro: number }> { const res = guard401( diff --git a/sundynix-web/src/pages/Usage.tsx b/sundynix-web/src/pages/Usage.tsx index 25b6fee..6f61dcf 100644 --- a/sundynix-web/src/pages/Usage.tsx +++ b/sundynix-web/src/pages/Usage.tsx @@ -1,8 +1,9 @@ -import { useCallback, useEffect, useState } from "react"; -import { Coins, ReceiptText, Ticket } from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import QRCode from "qrcode"; +import { Coins, ReceiptText, Ticket, QrCode } from "lucide-react"; import { useTenant } from "../shell/AppShell"; -import { myUsage, redeemCode, billingOrders, fmtCredits, type MyUsage, type TopupOrder } from "../api"; -import { Badge, Button, Input, Panel, Table, Tr, Td, cn, useToast } from "../ui"; +import { myUsage, redeemCode, billingOrders, billingPacks, createWechatOrder, orderStatus, fmtCredits, type MyUsage, type TopupOrder, type Pack } from "../api"; +import { Badge, Button, Dialog, Input, Panel, Table, Tr, Td, cn, useToast } from "../ui"; // 用量与账单:余额 + 兑换码充值(P5.1) + 消耗趋势 + 最近消耗/充值。 // 微信扫码支付在 P5.2 挂上(渠道适配器已留位),此前不做假支付入口。 @@ -14,10 +15,19 @@ export function Usage() { const [orders, setOrders] = useState([]); const [code, setCode] = useState(""); const [busy, setBusy] = useState(false); + const [packs, setPacks] = useState([]); + const [wechatOn, setWechatOn] = useState(false); // 服务端配了商户号才亮 + const [paying, setPaying] = useState(null); // 正在扫码支付的包 const load = useCallback(() => { myUsage(days).then(setU).catch(() => {}); billingOrders().then(setOrders).catch(() => {}); + billingPacks() + .then((r) => { + setPacks(r.packs); + setWechatOn(r.channels.includes("wechat")); + }) + .catch(() => {}); }, [days]); useEffect(load, [load, ctx?.tenant?.id]); @@ -65,6 +75,26 @@ export function Usage() { {canTopup ? (
+ {/* 微信扫码:服务端配了商户号且有在售包才出现 */} + {wechatOn && packs.length > 0 && ( +
+
+ 微信扫码充值 +
+
+ {packs.map((p) => ( + + ))} +
+
+ )}
兑换码充值
@@ -79,7 +109,9 @@ export function Usage() { - 微信扫码支付即将上线;兑换码请向平台获取。 + + {wechatOn ? "兑换码请向平台获取。" : "微信扫码支付即将上线;兑换码请向平台获取。"} +
) : ( @@ -153,6 +185,91 @@ export function Usage() { )} + + {paying && ( + { + setPaying(null); + if (paid) { + toast.push("success", "支付成功,积分已入账"); + load(); + refresh(); + } + }} + /> + )} ); } + +// PayDialog 微信 Native 扫码支付:下单 → code_url 画二维码 → 轮询单态(2.5s; +// 服务端 pending 时会顺路主动查单,收不到公网回调的环境也能确认到账)。 +function PayDialog({ pack, onClose }: { pack: Pack; onClose: (paid: boolean) => void }) { + const [qr, setQr] = useState(""); + const [err, setErr] = useState(""); + const [state, setState] = useState<"creating" | "waiting" | "paid" | "expired">("creating"); + const orderRef = useRef(""); + + useEffect(() => { + let alive = true; + let timer: number | null = null; + (async () => { + try { + const o = await createWechatOrder(pack.id); + if (!alive) return; + orderRef.current = o.order_id; + setQr(await QRCode.toDataURL(o.code_url, { width: 240, margin: 1 })); + setState("waiting"); + timer = window.setInterval(async () => { + try { + const s = await orderStatus(orderRef.current); + if (!alive) return; + if (s.status === "paid") { + setState("paid"); + if (timer) window.clearInterval(timer); + window.setTimeout(() => onClose(true), 800); + } else if (s.status === "expired" || s.status === "failed") { + setState("expired"); + if (timer) window.clearInterval(timer); + } + } catch { + /* 单次轮询失败忽略,下个 tick 再试 */ + } + }, 2500); + } catch (e) { + if (alive) setErr((e as Error).message); + } + })(); + return () => { + alive = false; + if (timer) window.clearInterval(timer); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [pack.id]); + + return ( + onClose(state === "paid")}> +
+ {err ? ( +

{err}

+ ) : state === "creating" ? ( +

正在生成支付二维码…

+ ) : ( + <> + {/* 二维码底色固定纯白:扫码器对暗色主题下的低对比码识别率差 */} +
+ 微信支付二维码 +
+
+
¥{(pack.price_fen / 100).toFixed(2)}
+
+ {state === "paid" ? "✅ 已支付,入账中…" : state === "expired" ? "订单已过期,请关闭后重新下单" : `微信扫一扫支付,到账 ${fmtCredits(pack.credits_micro)} 积分`} +
+
+ + )} +
+
+ ); +}