diff --git a/.env.example b/.env.example index 785ce24..eca7ad2 100644 --- a/.env.example +++ b/.env.example @@ -20,6 +20,24 @@ ZHINIAN_BILLING_ACCOUNT_NAME= ZHINIAN_BILLING_ACCOUNT_BANK= ZHINIAN_BILLING_ACCOUNT_NUMBER= ZHINIAN_BILLING_CONTACT= + +# Optional WeChat Native recharge. Keep false until 0007 migration, credentials, +# Native permission and the authenticated mini-program/service-account AppID binding are ready. +# Server only; never use NEXT_PUBLIC_ names for payment credentials. +WECHAT_PAY_ENABLED=false +WECHAT_PAY_MCH_ID= +WECHAT_PAY_APP_ID= +WECHAT_PAY_NOTIFY_URL=https://YOUR_DOMAIN/api/payments/wechat/notify +WECHAT_PAY_MERCHANT_SERIAL= +WECHAT_PAY_API_V3_KEY= +WECHAT_PAY_PUBLIC_KEY_ID= +# PKCS#8 merchant private key and WeChat payment public key (PEM files). +# File paths are relative to the Go process/container. *_PEM inline values are also supported. +WECHAT_PAY_PRIVATE_KEY_FILE= +WECHAT_PAY_PUBLIC_KEY_FILE= +WECHAT_PAY_MIN_AMOUNT_FEN=100 +WECHAT_PAY_MAX_AMOUNT_FEN=1000000 +WECHAT_PAY_PRESET_AMOUNTS_FEN=10000,50000,100000 # Run `npm run bootstrap:admin -- --phone 13800138000 --password 'change-me-now'` once to create the first super admin. # Public API v1 and worker task management. @@ -45,7 +63,15 @@ ZHINIAN_GO_EMBEDDED_WORKER=true # an explicitly non-production single-process development run. ZHINIAN_DATA_BACKEND=postgres DATABASE_URL= -# Migration runner only: PostgreSQL role used by the Go API DATABASE_URL. +# Optional Go startup migration. ACK enables this with a separate Secret; +# default false for local/Compose. Keep the migration role's URL server-side. +# ACK/Compose inject environment variables; direct `go run` needs explicit +# exported variables and does not load these database startup flags from here. +DATABASE_AUTO_MIGRATE=false +DATABASE_MIGRATION_URL= +DATABASE_MIGRATION_TIMEOUT_SECONDS=180 +# PostgreSQL role used by the Go API DATABASE_URL; startup and manual migration +# grant new tables/functions to this role. DATABASE_APP_ROLE= # PostgreSQL transport is intentionally plaintext; clients force sslmode=disable. # Example: postgresql://user:password@rds-host:5432/app?sslmode=disable diff --git a/README.md b/README.md index c8de0cf..bae8cf6 100644 --- a/README.md +++ b/README.md @@ -122,7 +122,7 @@ https://你的域名/api/auth/callback 计费目录由平台维护各接口的标准成本与参数档案,超级管理员只调整上浮倍率。真实生成任务提交时按“基础标准成本 × 参数档位系数 × 任务数量 × 组合倍率”报价并从组织余额冻结,单价、参数、倍率、数量和最终金额会快照到任务;每个任务在创作结果和历史任务中显示扣费状态。普通用户余额不足时会被拒绝提交并提示“余额不足,请先充值”,不会提交服务商;超级管理员仍计算并记录生成费用,但不检查或扣减组织额度,也不产生钱包扣费、退款流水。Seedance 成功后按上游 `usage.completion_tokens` 重新结算,MiniMax H3 成功后按上游实际输出秒数和输入图数量重新结算,Wan 3.0 按实际输出时长重新结算,多退少补;没有返回用量时保留冻结金额并标记为估算。组织成员通过 `/billing` 查看组织余额、自己的消耗和账务流水,余额属于组织而不是个人;充值和人工余额调整也只记入组织账本,不设置个人上账归属。 -当前组织余额由超级管理员直接上账,充值和人工余额调整不选择个人归属;未来接入用户自主支付时,支付成功回调自动入账,不设置人工审核队列。余额不足或未配置对应计费规则时,真实生成任务不会提交给服务商。未绑定组织的开放 API 任务暂保持兼容,不纳入组织余额扣费。 +组织余额支持超级管理员人工上账,以及配置启用后的组织管理员微信 Native 扫码充值(固定档位和自定义金额)。充值只归组织,支付成功后自动幂等入账,无人工审核队列;接入配置见 [微信支付部署说明](docs/PAYMENTS.md)。余额不足或未配置对应计费规则时,真实生成任务不会提交给服务商。未绑定组织的开放 API 任务暂保持兼容,不纳入组织余额扣费。 系统首次打开超管计费中心或提交真实任务时会自动补齐平台标准价格目录,不覆盖已有目录。当前默认目录为:百炼 `wan2.7-image-pro` ¥0.50/张,百炼 `wan2.7-i2v-2026-04-25` 720P ¥0.60/秒、1080P ¥1.00/秒;百炼 `wan3.0-video` 480P/720P/1080P 标准价分别为 ¥0.30/¥0.60/¥1.20 每秒(独立规则,按官网原价维护,不计限时折扣);MiniMax H3 的 768P/2K 分别为 ¥0.50/秒、¥0.80/秒,前 5 张参考图免费、第 6 张起每张标准成本 ¥0.20,并计入预估与预扣;火山方舟 `doubao-seedance-2-0-260128` 480P/720P/1080P/4K 分别为 ¥0.46/秒、¥0.99/秒、¥2.48/秒、¥5.05/秒,`doubao-seedance-2-5-260628` 480P/720P/1080P 分别为 ¥0.67/秒、¥1.51/秒、¥3.74/秒;EvoLink `gpt-image-2` medium/1K/1:1/无参考图基础估算 ¥0.34/张(固定汇率 1 USD = 7.20 CNY),并列出质量、分辨率、画面比例和参考图数量档位;即梦 `jimeng_seedream46_cvtob` 暂按公开资源包折算参考 ¥0.20/张,官方实时计费以控制台为准;Seedream 5.0 Pro 的 1K/1.5K 基础生图为 ¥0.30/张、2K 为 ¥0.60/张,首张输入图免费,从第 2 张起 ¥0.02/张。各标准成本均按平台 `1.2×` 上浮并向上取整到分。参数化报价按基础成本乘以所选参数档位系数,组合倍率取所选档位中的最高倍率;超管只在 `/billing` 调整倍率,标准成本和参数档案由平台维护。 @@ -266,7 +266,7 @@ cp .env.example .env.local database/migrations/ ``` -首次部署和每次 schema 变更均按 `database/migrations/*.sql` 中的版本化 SQL 文件手工执行(当前依次执行 0001 至 0006,再加角色授权语句),不部署迁移 Job Pod;执行完成后再滚动工作负载。 +ACK 模板启用 Go 服务启动前自动迁移:使用独立的 `DATABASE_MIGRATION_URL` 执行镜像内已同步的版本化 SQL,并通过 `DATABASE_APP_ROLE` 给应用数据库账号授权;成功后服务才开始监听。新增 SQL 后先运行 `npm run db:bundle`,提交生成的 Go 迁移包,再构建镜像。默认代码不自动迁移,其他部署可按需启用或手工执行 `npm run db:migrate`;生产不部署迁移 Job Pod。配置及失败处理见 [`docs/DEPLOYMENT.md`](./docs/DEPLOYMENT.md)。 当前仍保留必要数据表,供上传、生成任务和用量记录使用: diff --git a/README.zh-CN.md b/README.zh-CN.md index 5bae882..eeab1e0 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -141,7 +141,7 @@ npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码 ## 组织账号管理 -组织、账号、角色、停用、密码重置和归档均由平台本地接口处理,不再调用外部组织服务。生产部署前由部署负责人手工执行 [`database/migrations`](./database/migrations) 中的版本化 SQL 文件(依次执行 0001、0002、0003、0004)及角色授权语句完成建库建表,不部署迁移 Job Pod;超级管理员由 Go 后端首次启动时通过 `ZHINIAN_BOOTSTRAP_ADMIN_*` 环境变量自动创建一次。 +组织、账号、角色、停用、密码重置和归档均由平台本地接口处理,不再调用外部组织服务。ACK 模板启用 Go 服务启动前自动迁移,通过独立迁移账号执行镜像内尚未应用的版本化 SQL,再向应用账号授权;全部成功后才开始提供服务。新增 SQL 后先运行 `npm run db:bundle` 同步 Go 迁移包并提交,再构建镜像。生产不部署迁移 Job Pod;超级管理员由 Go 后端首次启动时通过 `ZHINIAN_BOOTSTRAP_ADMIN_*` 环境变量自动创建一次。详见 [`docs/DEPLOYMENT.md`](./docs/DEPLOYMENT.md)。 ## 账号、组织用量与计费 @@ -154,7 +154,7 @@ npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码 普通用户点击页头账号 ID,可切换今天、近 7 天、近 30 天和本月,并查看最近 5 条记录。管理员通过 `/usage` 按日期、组织、账号、功能类型和服务商查看汇总、趋势及明细;所有组织成员可在 `/billing` 查看组织余额和自己的净消耗。 -超级管理员在 `/billing` 配置各接口来源的标准单价与上浮倍率。真实生成任务按“标准计费单位 × 数量 × 上浮倍率”在提交时从组织余额冻结,并在任务中保存计费快照;失败、取消、过期任务会在最终终态退款。普通用户余额不足时会被拒绝提交并提示“余额不足,请先充值”,不会提交服务商;超级管理员仍计算并记录生成费用,但不检查或扣减组织额度,也不产生钱包扣费、退款流水。Seedance 成功后按上游返回的 `usage.completion_tokens` 重新结算,多退少补;上游没有返回用量时保留冻结金额。当前充值和人工余额调整由超级管理员在“余额与上账”中直接记入组织额度,不设置个人上账归属;未来接入用户自主支付时,支付成功回调将自动入账,不进入人工审核队列。未绑定组织的开放 API 任务暂保持兼容,不纳入组织余额扣费。 +超级管理员在 `/billing` 配置各接口来源的标准单价与上浮倍率。真实生成任务按“标准计费单位 × 数量 × 上浮倍率”在提交时从组织余额冻结,并在任务中保存计费快照;失败、取消、过期任务会在最终终态退款。普通用户余额不足时会被拒绝提交并提示“余额不足,请先充值”,不会提交服务商;超级管理员仍计算并记录生成费用,但不检查或扣减组织额度,也不产生钱包扣费、退款流水。Seedance 成功后按上游返回的 `usage.completion_tokens` 重新结算,多退少补;上游没有返回用量时保留冻结金额。超级管理员可以在“余额与上账”中手工调整组织额度;配置启用后,组织管理员也可以通过微信 Native 扫码充值,支持固定档位和自定义金额。支付成功后自动幂等计入组织额度,不进入人工审核队列。配置见 [微信支付部署说明](docs/PAYMENTS.md)。未绑定组织的开放 API 任务暂保持兼容,不纳入组织余额扣费。 首次打开超管计费中心或提交真实任务时,系统会自动补齐以下平台标准成本目录(不会覆盖已有目录)。默认上浮倍率为 `1.2×`,最终用户价按整数分计算并向上取整: @@ -178,7 +178,7 @@ npm run bootstrap:admin -- --phone 13800138000 --password '请替换为强密码 其中 Seedream 5.0 Pro 首张输入图免费,从第 2 张起按 ¥0.02/张计入基础成本;平台统一在基础成本合计后乘以 1.2 并向上取整到分。EvoLink 按固定 `1 USD = 7.20 CNY` 换算,并在价格目录中列出质量、分辨率、画面比例和参考图数量档位;参数化报价按基础成本乘以所选档位系数,组合倍率取所选档位中的最高倍率。即梦 4.6 官方计费说明要求以控制台实时价格为准,因此该条目是平台维护的参考基准。超管只在价格目录中调整上浮倍率,标准成本、参数档案和规则状态由平台维护;来源链接和定价口径会随规则保留。 -使用 PostgreSQL 时,首次部署和每次 schema 变更均按 `database/migrations/*.sql` 中的版本化 SQL 文件手工执行(拒绝已应用脚本被静默改写),执行完成后再滚动工作负载。`local` 模式下,本地 JSON 数据会在读取时按相同口径兼容旧记录。 +使用 PostgreSQL 时,ACK 模板中的 Go 服务在启动前自动执行镜像内尚未应用的迁移,并拒绝已应用脚本被静默改写;迁移失败时服务不会开始监听。其他部署可手工运行 `npm run db:migrate`。`local` 模式下,本地 JSON 数据会在读取时按相同口径兼容旧记录。 ## 引擎说明 diff --git a/backend/README.md b/backend/README.md index f22e217..5d41d0a 100644 --- a/backend/README.md +++ b/backend/README.md @@ -77,6 +77,20 @@ docker build -f backend/Dockerfile.runtime \ ``` The runner defaults to `CGO_ENABLED=0` for reproducible cross-platform builds. +When adding a SQL migration, run `npm run db:bundle` at the repository root and +commit the resulting Go migration bundle before building any backend image. +The image uses the existing `backend/` build context and needs no Node/npm at +runtime. In the ACK template, `DATABASE_AUTO_MIGRATE=true` runs unapplied +bundled migrations with `DATABASE_MIGRATION_URL` before the HTTP server +listens. `DATABASE_APP_ROLE` names the application database user to grant; +`DATABASE_MIGRATION_TIMEOUT_SECONDS` defaults to 180. Other deployments leave +automatic migration disabled unless explicitly configured. See +[`../docs/DEPLOYMENT.md`](../docs/DEPLOYMENT.md) for Secret and rollout details. +The migration and application URLs must target the same database host, port, +and database name; their usernames may differ. ACK/Compose inject environment +variables into Go. When starting `go run` or the binary directly, explicitly +export the database startup variables; `.env.local` alone does not supply them. + To exercise the backend locally, listen on port 8080 and route browser API requests there with a same-origin development proxy: diff --git a/backend/cmd/zhinian-api/main.go b/backend/cmd/zhinian-api/main.go index 0d2b8e8..4e882bf 100644 --- a/backend/cmd/zhinian-api/main.go +++ b/backend/cmd/zhinian-api/main.go @@ -14,6 +14,7 @@ import ( "time" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/application" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/migrations" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/settings" ) @@ -33,9 +34,9 @@ func run() error { if err != nil { return fmt.Errorf("load Go backend runtime settings: %w", err) } - app, err := application.New(application.Options{Context: ctx, Getenv: getenv}) + app, err := initializeApplication(ctx, getenv) if err != nil { - return fmt.Errorf("initialize Go backend: %w", err) + return err } defer app.Close() @@ -71,6 +72,19 @@ func run() error { return nil } +// Complete schema updates before constructing services, bootstrapping accounts, +// starting background workers, or opening the HTTP listener. +func initializeApplication(ctx context.Context, getenv func(string) string) (*application.App, error) { + if err := migrations.Run(ctx, getenv); err != nil { + return nil, fmt.Errorf("migrate database before startup: %w", err) + } + app, err := application.New(application.Options{Context: ctx, Getenv: getenv}) + if err != nil { + return nil, fmt.Errorf("initialize Go backend: %w", err) + } + return app, nil +} + func runtimeGetenv(process func(string) string) (func(string) string, error) { path := strings.TrimSpace(process("ZHINIAN_SETTINGS_FILE")) if path == "" { diff --git a/backend/cmd/zhinian-api/main_test.go b/backend/cmd/zhinian-api/main_test.go index 9207bed..6c6a33d 100644 --- a/backend/cmd/zhinian-api/main_test.go +++ b/backend/cmd/zhinian-api/main_test.go @@ -1,11 +1,34 @@ package main import ( + "context" "os" "path/filepath" + "strings" "testing" ) +func TestInitializeApplicationFailsBeforeCreatingServicesWhenMigrationIsInvalid(t *testing.T) { + runtimeDir := filepath.Join(t.TempDir(), "not-created") + values := map[string]string{ + "DATABASE_AUTO_MIGRATE": "true", + "ZHINIAN_DATA_BACKEND": "local", + "ZHINIAN_RUNTIME_DIR": runtimeDir, + "ZHINIAN_AUTH_REQUIRED": "false", + } + app, err := initializeApplication(context.Background(), func(name string) string { return values[name] }) + if app != nil { + app.Close() + t.Fatal("application was created despite invalid migration configuration") + } + if err == nil || !strings.Contains(err.Error(), "migrate database before startup:") { + t.Fatalf("expected migration failure before application initialization, got %v", err) + } + if _, err := os.Stat(runtimeDir); !os.IsNotExist(err) { + t.Fatalf("application startup touched runtime storage before migration succeeded: %v", err) + } +} + func TestListenAddressDefaultsToLoopbackAndSupportsExplicitBinding(t *testing.T) { if got := listenAddress(func(string) string { return "" }); got != "127.0.0.1:8080" { t.Fatalf("listenAddress(defaults) = %q", got) diff --git a/backend/go.mod b/backend/go.mod index 684693a..112f632 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -4,7 +4,9 @@ go 1.21 require ( github.com/jackc/pgx/v5 v5.5.5 + github.com/wechatpay-apiv3/wechatpay-go v0.2.21 golang.org/x/crypto v0.17.0 + golang.org/x/text v0.14.0 ) require ( @@ -12,5 +14,4 @@ require ( github.com/jackc/pgservicefile v0.0.0-20221227161230-091c0ba34f0a // indirect github.com/jackc/puddle/v2 v2.2.1 // indirect golang.org/x/sync v0.1.0 // indirect - golang.org/x/text v0.14.0 // indirect ) diff --git a/backend/go.sum b/backend/go.sum index 59b2f34..d0738f3 100644 --- a/backend/go.sum +++ b/backend/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/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= @@ -12,10 +14,16 @@ github.com/jackc/puddle/v2 v2.2.1/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFr github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 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 h1:w7B6lhMri9wdJUVmEZPGGhZzrYTPvgJArz7wNPgYKsk= github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +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= golang.org/x/crypto v0.17.0 h1:r8bRNjWL3GshPW3gkd+RpvzWrZAwPS49OmTGZ/uhM4k= golang.org/x/crypto v0.17.0/go.mod h1:gCAAfMLgwOJRpTjQ2zCCt2OcSfYMTeZVSRtQlPC7Nq4= golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= diff --git a/backend/internal/application/application.go b/backend/internal/application/application.go index a4b5691..50fc12e 100644 --- a/backend/internal/application/application.go +++ b/backend/internal/application/application.go @@ -20,6 +20,7 @@ import ( "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/layercompositions" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/localstore" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/orchestration" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/prompt" "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/publicapi" @@ -56,9 +57,11 @@ type Options struct { } type App struct { - handler http.Handler - db *postgres.Module - worker *jobs.WorkerLoop + handler http.Handler + db *postgres.Module + worker *jobs.WorkerLoop + paymentCancel context.CancelFunc + paymentDone <-chan struct{} } func New(options Options) (*App, error) { @@ -79,6 +82,13 @@ func New(options Options) (*App, error) { if err != nil { return nil, err } + payConfig, err := paymentConfig(getenv, readFile) + if err != nil { + return nil, err + } + if payConfig.Enabled && config.Backend != postgres.BackendPostgres { + return nil, fmt.Errorf("WeChat payments require persistent PostgreSQL storage") + } database, err := postgres.Open(ctx, config) if err != nil { return nil, err @@ -174,6 +184,23 @@ func New(options Options) (*App, error) { } cookieSecure := startupGetenv("ZHINIAN_AUTH_COOKIE_SECURE") publicBaseURL := firstAuthEnv(startupGetenv, "NEXT_PUBLIC_APP_URL", "ZHINIAN_PUBLIC_BASE_URL") + var paymentService *payments.Service + var paymentHTTPService httpapi.PaymentService + if payConfig.Enabled { + if !authConfig.Configured || !authConfig.Required { + return nil, fmt.Errorf("WeChat payments require configured, mandatory authentication") + } + provider, err := payments.NewWeChatProvider(payConfig) + if err != nil { + return nil, fmt.Errorf("initialize WeChat payments: %w", err) + } + paymentService = payments.NewService(database.Store, provider, payConfig) + paymentHTTPService = paymentService + } else if config.Backend == postgres.BackendPostgres { + // Historical payment and refund records remain available when collection is disabled. + paymentHTTPService = payments.NewService(database.Store, nil, payConfig) + } + paymentHandler := httpapi.NewPaymentHandler(platformAuthorizer, paymentHTTPService, publicBaseURL) authPassword, err := httpapi.NewAuthPasswordHandler(httpapi.PasswordAuthConfig{ Configured: authConfig.Configured, SessionSecret: authConfig.SessionSecret, @@ -362,6 +389,7 @@ func New(options Options) (*App, error) { mux.Handle("/api/billing/", billingHandler) mux.Handle("/api/admin/billing", billingHandler) mux.Handle("/api/admin/billing/", billingHandler) + mux.Handle("/api/payments/", paymentHandler) mux.Handle("/api/usage", usageHandler) mux.Handle("/api/admin/usage", usageHandler) mux.Handle("/api/generations/", jobsHandler) @@ -378,8 +406,14 @@ func New(options Options) (*App, error) { mux.Handle("/api/v1/openapi.json", miscHandler) mux.Handle("/", foundation) closeOnError = false + var paymentCancel context.CancelFunc + var paymentDone <-chan struct{} + if paymentService != nil { + paymentCancel, paymentDone = startPaymentReconciliation(ctx, paymentService) + } return &App{ db: database, handler: WithHTTPEventLogging(httpapi.WithRouteMethodCompatibility(mux), eventLogger), worker: workerLoop, + paymentCancel: paymentCancel, paymentDone: paymentDone, }, nil } @@ -388,6 +422,10 @@ func (app *App) Handler() http.Handler { } func (app *App) Close() { + if app.paymentCancel != nil { + app.paymentCancel() + <-app.paymentDone + } if app.worker != nil { app.worker.Stop() } diff --git a/backend/internal/application/payments.go b/backend/internal/application/payments.go new file mode 100644 index 0000000..25cc476 --- /dev/null +++ b/backend/internal/application/payments.go @@ -0,0 +1,130 @@ +package application + +import ( + "context" + "fmt" + "log" + "net/url" + "strconv" + "strings" + "time" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres" +) + +func paymentConfig(getenv postgres.Getenv, readFile postgres.ReadFile) (payments.Config, error) { + config := payments.Config{Enabled: parseBool(getenv("WECHAT_PAY_ENABLED")), MinAmountFen: 100, MaxAmountFen: 1000000, PresetAmountsFen: []int64{10000, 50000, 100000}, OrderTTL: 15 * time.Minute} + if !config.Enabled { + return config, nil + } + config.MchID = strings.TrimSpace(getenv("WECHAT_PAY_MCH_ID")) + config.AppID = strings.TrimSpace(getenv("WECHAT_PAY_APP_ID")) + config.NotifyURL = strings.TrimSpace(getenv("WECHAT_PAY_NOTIFY_URL")) + config.MerchantSerial = strings.TrimSpace(getenv("WECHAT_PAY_MERCHANT_SERIAL")) + config.APIv3Key = strings.TrimSpace(getenv("WECHAT_PAY_API_V3_KEY")) + config.WechatPublicKeyID = strings.TrimSpace(getenv("WECHAT_PAY_PUBLIC_KEY_ID")) + for _, value := range []struct{ name, value string }{ + {"WECHAT_PAY_MCH_ID", config.MchID}, {"WECHAT_PAY_APP_ID", config.AppID}, + {"WECHAT_PAY_MERCHANT_SERIAL", config.MerchantSerial}, {"WECHAT_PAY_PUBLIC_KEY_ID", config.WechatPublicKeyID}, + } { + if value.value == "" { + return config, fmt.Errorf("%s is required when WECHAT_PAY_ENABLED=true", value.name) + } + } + if len(config.APIv3Key) != 32 { + return config, fmt.Errorf("WECHAT_PAY_API_V3_KEY must contain 32 bytes") + } + notify, err := url.Parse(config.NotifyURL) + if err != nil || notify.Scheme != "https" || notify.Host == "" || notify.User != nil || notify.RawQuery != "" || notify.Fragment != "" || notify.Path != "/api/payments/wechat/notify" { + return config, fmt.Errorf("WECHAT_PAY_NOTIFY_URL must be an HTTPS URL ending in /api/payments/wechat/notify without query or credentials") + } + config.PrivateKeyPEM, err = paymentPEM(getenv, readFile, "WECHAT_PAY_PRIVATE_KEY") + if err != nil { + return config, err + } + config.WechatPublicKeyPEM, err = paymentPEM(getenv, readFile, "WECHAT_PAY_PUBLIC_KEY") + if err != nil { + return config, err + } + for _, bound := range []struct { + name string + dest *int64 + }{ + {"WECHAT_PAY_MIN_AMOUNT_FEN", &config.MinAmountFen}, {"WECHAT_PAY_MAX_AMOUNT_FEN", &config.MaxAmountFen}, + } { + if raw := strings.TrimSpace(getenv(bound.name)); raw != "" { + value, err := strconv.ParseInt(raw, 10, 64) + if err != nil || value < 1 || value > 1000000 { + return config, fmt.Errorf("%s must be an integer between 1 and 1000000", bound.name) + } + *bound.dest = value + } + } + if config.MinAmountFen > config.MaxAmountFen { + return config, fmt.Errorf("payment minimum exceeds maximum") + } + if raw := strings.TrimSpace(getenv("WECHAT_PAY_PRESET_AMOUNTS_FEN")); raw != "" { + config.PresetAmountsFen = nil + parts := strings.Split(raw, ",") + if len(parts) > 8 { + return config, fmt.Errorf("WECHAT_PAY_PRESET_AMOUNTS_FEN allows at most 8 amounts") + } + seen := map[int64]bool{} + for _, part := range parts { + value, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64) + if err != nil || value < config.MinAmountFen || value > config.MaxAmountFen || seen[value] { + return config, fmt.Errorf("WECHAT_PAY_PRESET_AMOUNTS_FEN must contain unique integer amounts within the configured bounds") + } + seen[value] = true + config.PresetAmountsFen = append(config.PresetAmountsFen, value) + } + } else { + valid := make([]int64, 0, len(config.PresetAmountsFen)) + for _, value := range config.PresetAmountsFen { + if value >= config.MinAmountFen && value <= config.MaxAmountFen { + valid = append(valid, value) + } + } + config.PresetAmountsFen = valid + } + return config, nil +} + +func paymentPEM(getenv postgres.Getenv, readFile postgres.ReadFile, name string) (string, error) { + if path := strings.TrimSpace(getenv(name + "_FILE")); path != "" { + data, err := readFile(path) + if err != nil { + return "", fmt.Errorf("unable to read %s_FILE", name) + } + return string(data), nil + } + if value := strings.TrimSpace(getenv(name + "_PEM")); value != "" { + return strings.ReplaceAll(value, `\n`, "\n"), nil + } + return "", fmt.Errorf("%s_FILE or %s_PEM is required", name, name) +} + +func startPaymentReconciliation(parent context.Context, service *payments.Service) (context.CancelFunc, <-chan struct{}) { + ctx, cancel := context.WithCancel(parent) + done := make(chan struct{}) + go func() { + defer close(done) + ticker := time.NewTicker(30 * time.Second) + defer ticker.Stop() + for { + run, stop := context.WithTimeout(ctx, 25*time.Second) + err := service.Reconcile(run) + stop() + if err != nil && ctx.Err() == nil { + log.Print("wechat payment reconciliation encountered an error; pending orders will be retried") + } + select { + case <-ctx.Done(): + return + case <-ticker.C: + } + } + }() + return cancel, done +} diff --git a/backend/internal/application/payments_test.go b/backend/internal/application/payments_test.go new file mode 100644 index 0000000..47f28da --- /dev/null +++ b/backend/internal/application/payments_test.go @@ -0,0 +1,48 @@ +package application + +import ( + "fmt" + "strings" + "testing" +) + +func TestPaymentConfigDisabledDoesNotRequireCredentials(t *testing.T) { + c, err := paymentConfig(func(string) string { return "" }, nil) + if err != nil || c.Enabled { + t.Fatalf("config=%+v err=%v", c, err) + } +} +func TestPaymentConfigLoadsOnlyServerCredentialsAndValidatesLimits(t *testing.T) { + values := map[string]string{ + "WECHAT_PAY_ENABLED": "true", "WECHAT_PAY_MCH_ID": "merchant", "WECHAT_PAY_APP_ID": "wxapp", + "WECHAT_PAY_NOTIFY_URL": "https://pay.example.test/api/payments/wechat/notify", + "WECHAT_PAY_MERCHANT_SERIAL": "serial", "WECHAT_PAY_API_V3_KEY": strings.Repeat("a", 32), + "WECHAT_PAY_PUBLIC_KEY_ID": "PUB_KEY_ID_test", "WECHAT_PAY_PRIVATE_KEY_FILE": "/private/key.pem", + "WECHAT_PAY_PUBLIC_KEY_PEM": "PUBLIC\\nKEY", "WECHAT_PAY_MIN_AMOUNT_FEN": "1", + "WECHAT_PAY_PRESET_AMOUNTS_FEN": "1,10000,50000", + } + read := func(path string) ([]byte, error) { + if path != "/private/key.pem" { + return nil, fmt.Errorf("unexpected file") + } + return []byte("PRIVATE"), nil + } + c, err := paymentConfig(func(key string) string { return values[key] }, read) + if err != nil || c.MinAmountFen != 1 || c.PrivateKeyPEM != "PRIVATE" || c.WechatPublicKeyPEM != "PUBLIC\nKEY" || len(c.PresetAmountsFen) != 3 { + t.Fatalf("config load err=%v", err) + } + for _, test := range []struct{ key, value string }{ + {"WECHAT_PAY_NOTIFY_URL", "http://pay.example.test/api/payments/wechat/notify"}, + {"WECHAT_PAY_NOTIFY_URL", "https://user:secret@pay.example.test/api/payments/wechat/notify"}, + {"WECHAT_PAY_API_V3_KEY", "secret"}, {"WECHAT_PAY_MAX_AMOUNT_FEN", "1000001"}, + {"WECHAT_PAY_PRESET_AMOUNTS_FEN", "100,100"}, {"WECHAT_PAY_PRESET_AMOUNTS_FEN", "1.50"}, + } { + old := values[test.key] + values[test.key] = test.value + _, err := paymentConfig(func(key string) string { return values[key] }, read) + values[test.key] = old + if err == nil || strings.Contains(err.Error(), "user:secret") { + t.Fatalf("key=%s err=%v", test.key, err) + } + } +} diff --git a/backend/internal/httpapi/method_compat_test.go b/backend/internal/httpapi/method_compat_test.go index 875fd99..2b9793a 100644 --- a/backend/internal/httpapi/method_compat_test.go +++ b/backend/internal/httpapi/method_compat_test.go @@ -12,8 +12,8 @@ import ( func TestRouteMethodCompatibilityDerivesEverySurfacePath(t *testing.T) { patterns := routeMethodPatterns(GoRouteSurface()) - if len(patterns) != 50 { - t.Fatalf("route patterns=%d want 50", len(patterns)) + if len(patterns) != 58 { + t.Fatalf("route patterns=%d want 58", len(patterns)) } for _, pattern := range patterns { if _, ok := pattern.methods[http.MethodOptions]; !ok { diff --git a/backend/internal/httpapi/payment_refunds.go b/backend/internal/httpapi/payment_refunds.go new file mode 100644 index 0000000..d37d7d3 --- /dev/null +++ b/backend/internal/httpapi/payment_refunds.go @@ -0,0 +1,172 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "io" + "mime" + "net/http" + "strconv" + "strings" + "unicode/utf8" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments" +) + +type PaymentRefundService interface { + AdminList(context.Context, string, int, int) (payments.AdminOrdersPage, error) + CreateRefund(context.Context, string, string, string, string, int64) (payments.Refund, error) + GetRefund(context.Context, string) (payments.Refund, error) + ListRefunds(context.Context, string) ([]payments.Refund, error) + RefundNotify(context.Context, *http.Request) error +} + +func (h *paymentHandler) adminPayments(w http.ResponseWriter, r *http.Request) { + parts := strings.Split(strings.TrimPrefix(r.URL.Path, "/api/payments/admin/"), "/") + list := len(parts) == 1 && parts[0] == "orders" + order := len(parts) == 3 && parts[0] == "orders" && parts[2] == "refunds" && len(parts[1]) <= 32 && paymentIdentifier.MatchString(parts[1]) + refund := len(parts) == 2 && parts[0] == "refunds" && len(parts[1]) <= 64 && paymentIdentifier.MatchString(parts[1]) + if !list && !order && !refund { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet && !(order && r.Method == http.MethodPost) { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + session, err := h.authorizer.Authorize(r, PlatformSuperAdmin) + if err != nil { + writePaymentError(w, err) + return + } + service, ok := h.service.(PaymentRefundService) + if !ok { + writeRefundError(w, payments.ErrUnavailable) + return + } + if list { + limit, offset := 50, 0 + if raw := r.URL.Query().Get("limit"); raw != "" { + limit, err = strconv.Atoi(raw) + if err != nil || limit < 1 || limit > 100 { + writeAPIError(w, 400, "分页参数无效。") + return + } + } + if raw := r.URL.Query().Get("offset"); raw != "" { + offset, err = strconv.Atoi(raw) + if err != nil || offset < 0 || offset > 10000000 { + writeAPIError(w, 400, "分页参数无效。") + return + } + } + org := r.URL.Query().Get("organizationId") + if len(org) > 128 { + writeAPIError(w, 400, "组织参数无效。") + return + } + page, err := service.AdminList(r.Context(), org, limit, offset) + if err != nil { + writeRefundError(w, err) + return + } + if page.Orders == nil { + page.Orders = []payments.AdminOrder{} + } + for i := range page.Orders { + page.Orders[i].CodeURL = "" + } + writeJSON(w, 200, page) + return + } + if refund { + value, err := service.GetRefund(r.Context(), parts[1]) + if err != nil { + writeRefundError(w, err) + return + } + writeJSON(w, 200, map[string]any{"refund": value}) + return + } + if r.Method == http.MethodGet { + values, err := service.ListRefunds(r.Context(), parts[1]) + if err != nil { + writeRefundError(w, err) + return + } + if values == nil { + values = []payments.Refund{} + } + writeJSON(w, 200, map[string]any{"refunds": values}) + return + } + if !h.sameOrigin(r) { + writeAPIError(w, 403, "请从本站发起退款。") + return + } + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + writeAPIError(w, 415, "请使用 JSON 请求。") + return + } + var body struct { + AmountFen int64 `json:"amountFen"` + Reason string `json:"reason"` + RequestKey string `json:"requestKey"` + } + r.Body = http.MaxBytesReader(w, r.Body, 4096) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&body); err != nil { + writeAPIError(w, 400, "退款参数无效,金额必须是整数分。") + return + } + body.Reason = strings.TrimSpace(body.Reason) + if err := decoder.Decode(&struct{}{}); err != io.EOF || body.AmountFen <= 0 || !paymentIdentifier.MatchString(body.RequestKey) || body.Reason == "" || len(body.Reason) > 80 || !utf8.ValidString(body.Reason) { + writeAPIError(w, 400, "退款金额、请求标识或原因无效(原因最多 80 字节)。") + return + } + value, err := service.CreateRefund(r.Context(), parts[1], session.User.ID, body.RequestKey, body.Reason, body.AmountFen) + if err != nil { + writeRefundError(w, err) + return + } + writeJSON(w, 200, map[string]any{"refund": value}) +} + +func (h *paymentHandler) refundNotify(w http.ResponseWriter, r *http.Request) { + if !allow(w, r, http.MethodPost) { + return + } + service, ok := h.service.(PaymentRefundService) + if !ok { + writeJSON(w, 503, map[string]string{"code": "FAIL", "message": "退款服务未启用"}) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + if err := service.RefundNotify(r.Context(), r); err != nil { + status := http.StatusInternalServerError + if errors.Is(err, payments.ErrVerification) { + status = http.StatusBadRequest + } + writeJSON(w, status, map[string]string{"code": "FAIL", "message": "退款通知处理失败"}) + return + } + w.WriteHeader(http.StatusNoContent) +} + +func writeRefundError(w http.ResponseWriter, err error) { + status, message := 500, "退款服务暂时不可用,请稍后刷新记录。" + switch { + case errors.Is(err, payments.ErrNotFound): + status, message = 404, "充值或退款记录不存在。" + case errors.Is(err, payments.ErrConflict): + status, message = 409, "订单状态、可退金额或退款请求已变化,请刷新记录后重试。" + case errors.Is(err, payments.ErrInvalidAmount): + status, message = 400, "退款金额超过可退金额或参数无效。" + case errors.Is(err, payments.ErrUnavailable): + status, message = 503, "微信退款尚未启用。" + } + writeAPIError(w, status, message) +} diff --git a/backend/internal/httpapi/payment_refunds_test.go b/backend/internal/httpapi/payment_refunds_test.go new file mode 100644 index 0000000..f83a6de --- /dev/null +++ b/backend/internal/httpapi/payment_refunds_test.go @@ -0,0 +1,157 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments" +) + +type refundHTTPStub struct { + paymentStub + refundCalls, notifyCalls, readCalls int + order, operator, key, reason, filter string + refundAmount int64 + limit, offset int + refundErr error +} + +func (s *refundHTTPStub) AdminList(_ context.Context, org string, limit, offset int) (payments.AdminOrdersPage, error) { + s.readCalls++ + s.filter, s.limit, s.offset = org, limit, offset + return payments.AdminOrdersPage{Orders: []payments.AdminOrder{{Order: payments.Order{ID: "cross-org", OrganizationID: "org2", Status: payments.StatusPending, CodeURL: "weixin://private"}}}}, s.refundErr +} +func (s *refundHTTPStub) CreateRefund(_ context.Context, order, operator, key, reason string, amount int64) (payments.Refund, error) { + s.refundCalls++ + s.order, s.operator, s.key, s.reason, s.refundAmount = order, operator, key, reason, amount + return payments.Refund{ID: "refund1", Status: payments.RefundPending, AmountFen: amount}, s.refundErr +} +func (s *refundHTTPStub) GetRefund(_ context.Context, id string) (payments.Refund, error) { + s.readCalls++ + return payments.Refund{ID: id, Status: payments.RefundSuccess}, s.refundErr +} +func (s *refundHTTPStub) ListRefunds(_ context.Context, id string) ([]payments.Refund, error) { + s.readCalls++ + s.order = id + return nil, s.refundErr +} +func (s *refundHTTPStub) RefundNotify(_ context.Context, _ *http.Request) error { + s.notifyCalls++ + return s.refundErr +} + +func TestAdminRefundRoutesRequireSuperAdmin(t *testing.T) { + for _, role := range []string{"user", "organization_admin", "super_admin"} { + for _, authenticated := range []bool{false, true} { + for _, route := range []struct{ method, path string }{ + {"GET", "/api/payments/admin/orders"}, + {"GET", "/api/payments/admin/orders/order1/refunds"}, + {"POST", "/api/payments/admin/orders/order1/refunds"}, + {"GET", "/api/payments/admin/refunds/refund1"}, + } { + s := &refundHTTPStub{} + got := paymentRequest(paymentTestHandler(t, role, s), route.method, route.path, `{"amountFen":100,"requestKey":"retry-1","reason":"用户申请"}`, "https://pay.example.test", authenticated) + want := 200 + if !authenticated { + want = 401 + } else if role != "super_admin" { + want = 403 + } + if got.Code != want { + t.Fatalf("%s %s role=%s auth=%t got=%d body=%s", route.method, route.path, role, authenticated, got.Code, got.Body.String()) + } + if want != 200 && s.refundCalls+s.readCalls != 0 { + t.Fatal("unauthorized service call") + } + } + } + } +} + +func TestAdminWithoutOrganizationCanViewAllOrdersAndRefund(t *testing.T) { + s := &refundHTTPStub{} + auth, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, &fixedSessionResolver{session: identity.Session{AuthMode: identity.AuthModeAdmin, User: identity.User{ID: "global-admin", Role: "super_admin"}}}) + if err != nil { + t.Fatal(err) + } + h := NewPaymentHandler(auth, s, "https://pay.example.test") + got := paymentRequest(h, "GET", "/api/payments/admin/orders?organizationId=org2&limit=10&offset=20", "", "", true) + if got.Code != 200 || s.filter != "org2" || s.limit != 10 || s.offset != 20 || strings.Contains(got.Body.String(), "weixin:") { + t.Fatalf("status=%d body=%s", got.Code, got.Body.String()) + } + got = paymentRequest(h, "POST", "/api/payments/admin/orders/order2/refunds", `{"amountFen":123,"requestKey":"retry-1","reason":" 用户申请 "}`, "", true) + if got.Code != 200 || s.operator != "global-admin" || s.order != "order2" || s.refundAmount != 123 || s.reason != "用户申请" || s.key != "retry-1" { + t.Fatalf("status=%d stub=%+v", got.Code, s) + } +} + +func TestAdminRefundRejectsTamperedBodyAndCrossOrigin(t *testing.T) { + s := &refundHTTPStub{} + h := paymentTestHandler(t, "super_admin", s) + for _, body := range []string{ + `{"amountFen":1.1,"requestKey":"x","reason":"ok"}`, + `{"amountFen":0,"requestKey":"x","reason":"ok"}`, + `{"amountFen":-1,"requestKey":"x","reason":"ok"}`, + `{"amountFen":100,"requestKey":"x","reason":"ok","operatorId":"victim"}`, + `{"amountFen":100,"requestKey":"x","reason":"ok","organizationId":"victim"}`, + `{"amountFen":100,"requestKey":"a/b","reason":"ok"}`, + `{"amountFen":100,"requestKey":"x","reason":" "}`, + `{"amountFen":100,"requestKey":"x","reason":"` + strings.Repeat("退", 27) + `"}`, + `{"amountFen":100,"requestKey":"x","reason":"ok"} {}`, + } { + got := paymentRequest(h, "POST", "/api/payments/admin/orders/order1/refunds", body, "", true) + if got.Code != 400 { + t.Fatalf("body=%s status=%d", body, got.Code) + } + } + got := paymentRequest(h, "POST", "/api/payments/admin/orders/order1/refunds", `{"amountFen":100,"requestKey":"x","reason":"ok"}`, "https://evil.test", true) + if got.Code != 403 || s.refundCalls != 0 { + t.Fatalf("status=%d calls=%d", got.Code, s.refundCalls) + } + for _, query := range []string{"limit=0", "limit=101", "limit=abc", "offset=-1", "offset=10000001"} { + if got := paymentRequest(h, "GET", "/api/payments/admin/orders?"+query, "", "", true); got.Code != 400 { + t.Fatalf("query=%s status=%d", query, got.Code) + } + } +} + +func TestRefundCallbackHasNoSessionAndOnlyAcknowledgesDurableSuccess(t *testing.T) { + for _, test := range []struct { + err error + want int + }{{nil, 204}, {payments.ErrVerification, 400}, {errors.New("secret database details"), 500}} { + s := &refundHTTPStub{refundErr: test.err} + h := paymentTestHandler(t, "user", s) + got := paymentRequest(h, "POST", "/api/payments/wechat/refund-notify", `{}`, "", false) + if got.Code != test.want || s.notifyCalls != 1 || strings.Contains(got.Body.String(), "secret") { + t.Fatalf("status=%d body=%s", got.Code, got.Body.String()) + } + got = paymentRequest(h, "GET", "/api/payments/wechat/refund-notify", "", "", false) + if got.Code != 405 || s.notifyCalls != 1 { + t.Fatal("GET invoked refund callback") + } + } +} + +func TestRefundErrorMappingAndStrictRoutes(t *testing.T) { + for _, test := range []struct { + err error + want int + }{{payments.ErrNotFound, 404}, {payments.ErrConflict, 409}, {payments.ErrInvalidAmount, 400}, {payments.ErrUnavailable, 503}, {errors.New("secret"), 500}} { + s := &refundHTTPStub{refundErr: test.err} + got := paymentRequest(paymentTestHandler(t, "super_admin", s), "GET", "/api/payments/admin/refunds/refund1", "", "", true) + if got.Code != test.want || strings.Contains(got.Body.String(), "secret") { + t.Fatalf("status=%d body=%s", got.Code, got.Body.String()) + } + } + h := paymentTestHandler(t, "super_admin", &refundHTTPStub{}) + for _, path := range []string{"/api/payments/admin/", "/api/payments/admin/orders/a/b/refunds", "/api/payments/admin/orders/order1/refunds/"} { + if got := paymentRequest(h, "GET", path, "", "", true); got.Code != 404 { + t.Fatalf("%s status=%d", path, got.Code) + } + } +} diff --git a/backend/internal/httpapi/payments.go b/backend/internal/httpapi/payments.go new file mode 100644 index 0000000..95f04b1 --- /dev/null +++ b/backend/internal/httpapi/payments.go @@ -0,0 +1,199 @@ +package httpapi + +import ( + "context" + "encoding/json" + "errors" + "io" + "mime" + "net/http" + "net/url" + "regexp" + "strings" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments" +) + +type PaymentService interface { + Options() payments.Options + Create(context.Context, string, string, string, int64) (payments.Order, error) + Get(context.Context, string, string) (payments.Order, error) + List(context.Context, string) ([]payments.Order, error) + Notify(context.Context, *http.Request) error +} + +type paymentHandler struct { + authorizer *PlatformAuthorizer + service PaymentService + publicOrigin string +} + +func NewPaymentHandler(authorizer *PlatformAuthorizer, service PaymentService, publicOrigin string) http.Handler { + return &paymentHandler{authorizer: authorizer, service: service, publicOrigin: publicOrigin} +} + +var paymentIdentifier = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,64}$`) + +func (h *paymentHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Cache-Control", "no-store") + if r.URL.Path == "/api/payments/wechat/refund-notify" { + h.refundNotify(w, r) + return + } + if strings.HasPrefix(r.URL.Path, "/api/payments/admin/") { + h.adminPayments(w, r) + return + } + if r.URL.Path == "/api/payments/wechat/notify" { + if !allow(w, r, http.MethodPost) { + return + } + if h.service == nil { + writeJSON(w, 503, map[string]string{"code": "FAIL", "message": "支付服务未启用"}) + return + } + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + if err := h.service.Notify(r.Context(), r); err != nil { + status := http.StatusInternalServerError + if errors.Is(err, payments.ErrVerification) { + status = http.StatusBadRequest + } + // Acknowledge only after durable settlement; never leak decrypted data. + writeJSON(w, status, map[string]string{"code": "FAIL", "message": "支付通知处理失败"}) + return + } + w.WriteHeader(http.StatusNoContent) + return + } + optionsPath := r.URL.Path == "/api/payments/options" + ordersPath := r.URL.Path == "/api/payments/orders" + orderID := strings.TrimPrefix(r.URL.Path, "/api/payments/orders/") + itemPath := strings.HasPrefix(r.URL.Path, "/api/payments/orders/") && len(orderID) <= 32 && paymentIdentifier.MatchString(orderID) + if !optionsPath && !ordersPath && !itemPath { + http.NotFound(w, r) + return + } + if r.Method != http.MethodGet && !(ordersPath && r.Method == http.MethodPost) { + w.WriteHeader(http.StatusMethodNotAllowed) + return + } + session, err := h.authorizer.Authorize(r, PlatformApp) + if err != nil { + writePaymentError(w, err) + return + } + canRecharge := session.User.OrganizationID != "" && (session.User.Role == "organization_admin" || session.User.Role == "super_admin") + if optionsPath { + options := payments.Options{PresetAmountsFen: []int64{}} + if h.service != nil { + options = h.service.Options() + } + options.CanRecharge = options.Enabled && canRecharge + writeJSON(w, http.StatusOK, options) + return + } + if !canRecharge { + writeAPIError(w, http.StatusForbidden, "需要所属组织的管理员权限。") + return + } + if h.service == nil { + writePaymentError(w, payments.ErrUnavailable) + return + } + org := session.User.OrganizationID + if itemPath { + order, err := h.service.Get(r.Context(), org, orderID) + if err != nil { + writePaymentError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"order": publicPaymentOrder(order)}) + return + } + if r.Method == http.MethodGet { + orders, err := h.service.List(r.Context(), org) + if err != nil { + writePaymentError(w, err) + return + } + if orders == nil { + orders = []payments.Order{} + } + for i := range orders { + orders[i] = publicPaymentOrder(orders[i]) + } + writeJSON(w, http.StatusOK, map[string]any{"orders": orders}) + return + } + if !h.sameOrigin(r) { + writeAPIError(w, http.StatusForbidden, "请从本站发起充值。") + return + } + mediaType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err != nil || mediaType != "application/json" { + writeAPIError(w, http.StatusUnsupportedMediaType, "请使用 JSON 请求。") + return + } + var body struct { + AmountFen int64 `json:"amountFen"` + RequestKey string `json:"requestKey"` + } + r.Body = http.MaxBytesReader(w, r.Body, 4096) + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&body); err != nil { + writeAPIError(w, 400, "充值参数无效,金额必须是整数分。") + return + } + if err := decoder.Decode(&struct{}{}); err != io.EOF || !paymentIdentifier.MatchString(body.RequestKey) { + writeAPIError(w, 400, "充值请求标识无效。") + return + } + order, err := h.service.Create(r.Context(), org, session.User.ID, body.RequestKey, body.AmountFen) + if err != nil { + writePaymentError(w, err) + return + } + writeJSON(w, http.StatusOK, map[string]any{"order": publicPaymentOrder(order)}) +} + +func publicPaymentOrder(order payments.Order) payments.Order { + if order.Status != payments.StatusPending { + order.CodeURL = "" + } + return order +} + +func (h *paymentHandler) sameOrigin(r *http.Request) bool { + if site := r.Header.Get("Sec-Fetch-Site"); site != "" && site != "same-origin" && site != "none" { + return false + } + origin := r.Header.Get("Origin") + if origin == "" { + return true + } + actual, err := url.Parse(origin) + if err != nil || actual.User != nil || actual.RawQuery != "" || actual.Fragment != "" || actual.Path != "" { + return false + } + expected, err := url.Parse(h.publicOrigin) + return err == nil && expected.Host != "" && actual.Scheme == expected.Scheme && strings.EqualFold(actual.Host, expected.Host) +} + +func writePaymentError(w http.ResponseWriter, err error) { + status, message := http.StatusInternalServerError, "支付服务暂时不可用,请稍后重试。" + var authErr *PlatformAuthError + switch { + case errors.As(err, &authErr): + status, message = authErr.Status, authErr.Message + case errors.Is(err, payments.ErrNotFound): + status, message = 404, "充值订单不存在。" + case errors.Is(err, payments.ErrConflict): + status, message = 409, "充值请求与已有订单不一致,请刷新后重试。" + case errors.Is(err, payments.ErrInvalidAmount): + status, message = 400, "充值金额不在允许范围内。" + case errors.Is(err, payments.ErrUnavailable): + status, message = 503, "微信支付尚未启用。" + } + writeAPIError(w, status, message) +} diff --git a/backend/internal/httpapi/payments_test.go b/backend/internal/httpapi/payments_test.go new file mode 100644 index 0000000..cab989a --- /dev/null +++ b/backend/internal/httpapi/payments_test.go @@ -0,0 +1,129 @@ +package httpapi + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/identity" + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments" +) + +type paymentStub struct { + org, account, requestKey string + amount int64 + notifications int + err error +} + +func (s *paymentStub) Options() payments.Options { + return payments.Options{Enabled: true, PresetAmountsFen: []int64{10000}, MinAmountFen: 100, MaxAmountFen: 1000000} +} +func (s *paymentStub) Create(_ context.Context, org, account, key string, amount int64) (payments.Order, error) { + s.org, s.account, s.requestKey, s.amount = org, account, key, amount + return payments.Order{ID: "order1", OrganizationID: org, AmountFen: amount, Status: payments.StatusPending}, s.err +} +func (s *paymentStub) Get(_ context.Context, org, id string) (payments.Order, error) { + s.org = org + return payments.Order{ID: id, Status: payments.StatusPaid, CodeURL: "weixin://stale"}, s.err +} +func (s *paymentStub) List(_ context.Context, org string) ([]payments.Order, error) { + s.org = org + return nil, s.err +} +func (s *paymentStub) Notify(_ context.Context, _ *http.Request) error { + s.notifications++ + return s.err +} + +func paymentTestHandler(t *testing.T, role string, service PaymentService) http.Handler { + t.Helper() + auth, err := NewPlatformAuthorizer(AuthState{Required: true, Configured: true}, &fixedSessionResolver{session: identity.Session{AuthMode: identity.AuthModeAdmin, User: identity.User{ID: "account1", OrganizationID: "org1", Role: role}}}) + if err != nil { + t.Fatal(err) + } + return NewPaymentHandler(auth, service, "https://pay.example.test") +} +func paymentRequest(h http.Handler, method, path, body, origin string, authenticated bool) *httptest.ResponseRecorder { + r := httptest.NewRequest(method, path, strings.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + if origin != "" { + r.Header.Set("Origin", origin) + } + if authenticated { + r.AddCookie(&http.Cookie{Name: identity.SessionCookieName, Value: "valid"}) + } + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} +func TestPaymentCreateUsesSessionAndRejectsMoneyTampering(t *testing.T) { + s := &paymentStub{} + h := paymentTestHandler(t, "organization_admin", s) + got := paymentRequest(h, "POST", "/api/payments/orders", `{"amountFen":12345,"requestKey":"retry-1"}`, "https://pay.example.test", true) + if got.Code != 200 || s.org != "org1" || s.account != "account1" || s.amount != 12345 { + t.Fatalf("status=%d scope=%s/%s amount=%d", got.Code, s.org, s.account, s.amount) + } + for _, body := range []string{ + `{"amountFen":1.01,"requestKey":"x"}`, `{"amountFen":1e4,"requestKey":"x"}`, + `{"amountFen":100,"requestKey":"x","organizationId":"victim"}`, + `{"amountFen":100,"requestKey":"x"} {}`, `{"amountFen":100,"requestKey":"a/b"}`, + } { + if got := paymentRequest(h, "POST", "/api/payments/orders", body, "", true); got.Code != 400 { + t.Fatalf("body=%s status=%d", body, got.Code) + } + } +} +func TestPaymentAuthorizationAndOrigin(t *testing.T) { + for _, test := range []struct { + role, origin string + authenticated bool + want int + }{ + {"user", "", true, 403}, {"organization_admin", "https://evil.example", true, 403}, + {"organization_admin", "null", true, 403}, {"organization_admin", "", false, 401}, + } { + s := &paymentStub{} + h := paymentTestHandler(t, test.role, s) + got := paymentRequest(h, "POST", "/api/payments/orders", `{"amountFen":100,"requestKey":"x"}`, test.origin, test.authenticated) + if got.Code != test.want || s.account != "" { + t.Fatalf("test=%+v status=%d", test, got.Code) + } + } +} +func TestPaymentCallbackUsesVerifierWithoutBrowserSessionAndRetriesFailures(t *testing.T) { + for _, test := range []struct { + err error + want int + }{ + {nil, 204}, {payments.ErrVerification, 400}, {errors.New("secret error detail"), 500}, + } { + s := &paymentStub{err: test.err} + h := paymentTestHandler(t, "user", s) + got := paymentRequest(h, "POST", "/api/payments/wechat/notify", `{}`, "", false) + if got.Code != test.want || s.notifications != 1 || strings.Contains(got.Body.String(), "secret") { + t.Fatalf("status=%d body=%s", got.Code, got.Body.String()) + } + } +} +func TestPaymentDisabledOptionsAndScopedReads(t *testing.T) { + h := paymentTestHandler(t, "organization_admin", nil) + got := paymentRequest(h, "GET", "/api/payments/options", "", "", true) + if got.Code != 200 || !strings.Contains(got.Body.String(), `"enabled":false`) || got.Header().Get("Cache-Control") != "no-store" { + t.Fatal(got.Body.String()) + } + s := &paymentStub{} + h = paymentTestHandler(t, "organization_admin", s) + got = paymentRequest(h, "GET", "/api/payments/orders/order1?organizationId=victim", "", "", true) + if got.Code != 200 || s.org != "org1" || strings.Contains(got.Body.String(), "weixin") { + t.Fatalf("status=%d body=%s", got.Code, got.Body.String()) + } + h = paymentTestHandler(t, "user", s) + got = paymentRequest(h, "GET", "/api/payments/options", "", "", true) + if !strings.Contains(got.Body.String(), `"canRecharge":false`) { + t.Fatal(got.Body.String()) + } +} diff --git a/backend/internal/httpapi/route_surface.go b/backend/internal/httpapi/route_surface.go index 7ebf8e8..753b57e 100644 --- a/backend/internal/httpapi/route_surface.go +++ b/backend/internal/httpapi/route_surface.go @@ -13,6 +13,8 @@ type RouteSurface struct { } var goRouteSurface = []RouteSurface{ + {"GET", "/api/payments/options"}, {"GET", "/api/payments/orders"}, {"POST", "/api/payments/orders"}, {"GET", "/api/payments/orders/{id}"}, {"POST", "/api/payments/wechat/notify"}, + {"GET", "/api/payments/admin/orders"}, {"GET", "/api/payments/admin/orders/{id}/refunds"}, {"POST", "/api/payments/admin/orders/{id}/refunds"}, {"GET", "/api/payments/admin/refunds/{id}"}, {"POST", "/api/payments/wechat/refund-notify"}, {"DELETE", "/api/admin/accounts"}, {"GET", "/api/admin/accounts"}, {"PATCH", "/api/admin/accounts"}, {"POST", "/api/admin/accounts"}, {"PUT", "/api/admin/accounts"}, {"POST", "/api/admin/accounts/groups"}, {"POST", "/api/admin/accounts/password"}, {"GET", "/api/admin/billing"}, {"PATCH", "/api/admin/billing/account"}, {"POST", "/api/admin/billing/adjustments"}, {"GET", "/api/admin/billing/prices"}, {"PATCH", "/api/admin/billing/prices"}, {"PATCH", "/api/admin/billing/prices/{id}"}, {"DELETE", "/api/admin/organizations"}, {"GET", "/api/admin/organizations"}, {"PATCH", "/api/admin/organizations"}, {"POST", "/api/admin/organizations"}, {"GET", "/api/admin/usage"}, {"GET", "/api/assets"}, {"POST", "/api/assets"}, {"POST", "/api/assets/upload"}, {"POST", "/api/assets/import-image"}, {"DELETE", "/api/assets/{id}"}, {"GET", "/api/assets/{id}/download"}, {"GET", "/api/auth/callback"}, {"GET", "/api/auth/captcha"}, {"GET", "/api/auth/login"}, {"GET", "/api/auth/logout"}, {"POST", "/api/auth/logout"}, {"GET", "/api/auth/me"}, {"POST", "/api/auth/password"}, {"POST", "/api/auth/password/change"}, diff --git a/backend/internal/migrations/migrations.go b/backend/internal/migrations/migrations.go new file mode 100644 index 0000000..411885b --- /dev/null +++ b/backend/internal/migrations/migrations.go @@ -0,0 +1,272 @@ +// Package migrations applies the canonical SQL migrations before the API starts. +// SQL in sql/ is generated from database/migrations by scripts/sync-go-migrations.mjs. +package migrations + +import ( + "context" + "crypto/sha256" + "embed" + "encoding/hex" + "errors" + "fmt" + "io/fs" + "log" + "sort" + "strconv" + "strings" + "time" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/postgres" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +//go:embed sql/*.sql +var sqlFiles embed.FS + +const migrationLockID int64 = 7308731946202608 + +type migration struct { + version string + checksum string + sql string +} + +// Run is a no-op unless DATABASE_AUTO_MIGRATE=true. Its context covers +// connection, advisory-lock waiting, every migration, and role provisioning. +func Run(ctx context.Context, getenv func(string) string) error { + if getenv == nil { + return errors.New("environment getter is required") + } + switch strings.ToLower(strings.TrimSpace(getenv("DATABASE_AUTO_MIGRATE"))) { + case "", "false": + return nil + case "true": + default: + return errors.New("DATABASE_AUTO_MIGRATE must be true or false") + } + if strings.ToLower(strings.TrimSpace(getenv("ZHINIAN_DATA_BACKEND"))) != "postgres" { + return errors.New("DATABASE_AUTO_MIGRATE requires ZHINIAN_DATA_BACKEND=postgres") + } + timeout := 180 * time.Second + if raw := strings.TrimSpace(getenv("DATABASE_MIGRATION_TIMEOUT_SECONDS")); raw != "" { + seconds, err := strconv.ParseInt(raw, 10, 64) + if err != nil || seconds < 1 || seconds > 86400 { + return errors.New("DATABASE_MIGRATION_TIMEOUT_SECONDS must be an integer from 1 to 86400") + } + timeout = time.Duration(seconds) * time.Second + } + role := strings.TrimSpace(getenv("DATABASE_APP_ROLE")) + if role == "" && strings.EqualFold(strings.TrimSpace(getenv("NODE_ENV")), "production") { + return errors.New("DATABASE_APP_ROLE is required in production") + } + if role != "" && !rolePattern.MatchString(role) { + return errors.New("DATABASE_APP_ROLE must match [a-z_][a-z0-9_]{0,62}") + } + all, err := discover(sqlFiles) + if err != nil { + return err + } + migrationURL := strings.TrimSpace(getenv("DATABASE_MIGRATION_URL")) + appURL := strings.TrimSpace(getenv("DATABASE_URL")) + if migrationURL == "" { + migrationURL = appURL + } + if migrationURL == "" { + return errors.New("DATABASE_MIGRATION_URL or DATABASE_URL is required") + } + // Reuse the application's URI validation and forced transport policy. + config, err := postgres.ParseConfig(func(key string) string { + if key == "DATABASE_URL" { + return migrationURL + } + return getenv(key) + }, nil) + if err != nil { + return fmt.Errorf("migration database configuration: %w", err) + } + pgconfig, err := pgx.ParseConfig(config.DatabaseURL) + if err != nil { + return errors.New("invalid migration database connection configuration") + } + if appURL != "" && strings.TrimSpace(getenv("DATABASE_MIGRATION_URL")) != "" { + appConfig, err := postgres.ParseConfig(func(key string) string { + if key == "DATABASE_URL" { + return appURL + } + return getenv(key) + }, nil) + if err != nil { + return fmt.Errorf("application database configuration: %w", err) + } + appPGConfig, err := pgx.ParseConfig(appConfig.DatabaseURL) + if err != nil { + return errors.New("invalid application database connection configuration") + } + if !strings.EqualFold(pgconfig.Host, appPGConfig.Host) || pgconfig.Port != appPGConfig.Port || pgconfig.Database != appPGConfig.Database { + return errors.New("DATABASE_MIGRATION_URL must target the same host, port, and database as DATABASE_URL") + } + } + pgconfig.RuntimeParams["application_name"] = "zhinian-migrate" + pgconfig.ConnectTimeout = config.ConnectionTimeout + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + log.Printf("zhinian-api checking database migrations (%d bundled versions)", len(all)) + conn, err := pgx.ConnectConfig(ctx, pgconfig) + if err != nil { + return safeDBError("connect", "", err) + } + defer conn.Close(context.Background()) + log.Print("zhinian-api waiting for database migration lock") + if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockID); err != nil { + return safeDBError("acquire migration lock", "", err) + } + // Session-level lock is released when conn closes, including on timeout. + if err := apply(ctx, conn, all, role); err != nil { + return err + } + log.Printf("zhinian-api database migrations ready (%d versions)", len(all)) + return nil +} + +func apply(ctx context.Context, conn *pgx.Conn, all []migration, role string) error { + if _, err := conn.Exec(ctx, `CREATE TABLE IF NOT EXISTS schema_migrations ( + version text PRIMARY KEY, + checksum text NOT NULL, + applied_at timestamptz NOT NULL DEFAULT now() + )`); err != nil { + return safeDBError("create migration tracking table", "", err) + } + rows, err := conn.Query(ctx, "SELECT version, checksum FROM schema_migrations") + if err != nil { + return safeDBError("read migration history", "", err) + } + applied := make(map[string]string) + for rows.Next() { + var version, checksum string + if err := rows.Scan(&version, &checksum); err != nil { + rows.Close() + return safeDBError("read migration history", "", err) + } + applied[version] = checksum + } + err = rows.Err() + rows.Close() + if err != nil { + return safeDBError("read migration history", "", err) + } + known := make(map[string]string, len(all)) + for _, item := range all { + known[item.version] = item.checksum + } + for version, checksum := range applied { + expected, exists := known[version] + if !exists { + return fmt.Errorf("applied migration %q is absent from bundle", version) + } + if checksum != expected { + return fmt.Errorf("applied migration %q checksum differs from bundle", version) + } + } + for _, item := range all { + if _, exists := applied[item.version]; exists { + continue + } + body, err := withoutOuterTransaction(item.sql) + if err != nil { + return fmt.Errorf("migration %s: %w", item.version, err) + } + tx, err := conn.Begin(ctx) + if err != nil { + return safeDBError("begin migration", item.version, err) + } + // PostgreSQL's simple protocol permits multi-statement migration files. + _, err = tx.Exec(ctx, body, pgx.QueryExecModeSimpleProtocol) + if err == nil { + _, err = tx.Exec(ctx, "INSERT INTO schema_migrations(version, checksum) VALUES ($1, $2)", item.version, item.checksum) + } + if err == nil { + err = tx.Commit(ctx) + } + if err != nil { + _ = tx.Rollback(context.Background()) + return safeDBError("apply migration", item.version, err) + } + log.Printf("zhinian-api applied database migration %s", item.version) + } + if role != "" { + if err := provisionRole(ctx, conn, role); err != nil { + return err + } + } + return nil +} + +func discover(fsys fs.FS) ([]migration, error) { + entries, err := fs.ReadDir(fsys, "sql") + if err != nil { + return nil, errors.New("read embedded migration bundle") + } + var all []migration + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".sql") { + continue + } + data, err := fs.ReadFile(fsys, "sql/"+name) + if err != nil { + return nil, fmt.Errorf("read embedded migration %s", name) + } + checksum := sha256.Sum256(data) + all = append(all, migration{strings.TrimSuffix(name, ".sql"), hex.EncodeToString(checksum[:]), string(data)}) + } + sort.Slice(all, func(i, j int) bool { return all[i].version < all[j].version }) + if len(all) == 0 { + return nil, errors.New("embedded migration bundle is empty") + } + return all, nil +} + +// The canonical SQL may contain an explicit outer BEGIN/COMMIT. Strip only +// when both commands are the first and last non-comment statements; otherwise +// our transaction would commit before recording the checksum. +func withoutOuterTransaction(sql string) (string, error) { + first, last := -1, -1 + lines := strings.SplitAfter(sql, "\n") + for i, line := range lines { + trimmed := strings.TrimSpace(line) + if trimmed != "" && !strings.HasPrefix(trimmed, "--") { + if first < 0 { + first = i + } + last = i + } + } + if first < 0 { + return "", errors.New("empty migration SQL") + } + begin := strings.EqualFold(strings.TrimSpace(lines[first]), "begin;") + commit := strings.EqualFold(strings.TrimSpace(lines[last]), "commit;") + if begin != commit { + return "", errors.New("outer transaction must contain both BEGIN and COMMIT") + } + if !begin { + return sql, nil + } + lines[first], lines[last] = "", "" + return strings.Join(lines, ""), nil +} + +func safeDBError(operation, version string, err error) error { + if version != "" { + operation += " " + version + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) { + return fmt.Errorf("%s failed (SQLSTATE %s)", operation, pgErr.Code) + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + return fmt.Errorf("%s timed out or was canceled", operation) + } + return fmt.Errorf("%s failed (%T)", operation, err) +} diff --git a/backend/internal/migrations/migrations_integration_test.go b/backend/internal/migrations/migrations_integration_test.go new file mode 100644 index 0000000..fbe8632 --- /dev/null +++ b/backend/internal/migrations/migrations_integration_test.go @@ -0,0 +1,162 @@ +package migrations + +import ( + "context" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/jackc/pgx/v5" +) + +// Requires an independently disposable database with no valuable contents. +// It never consults the application's DATABASE_URL or .env files. +func TestIntegrationRunOnDisposableDatabase(t *testing.T) { + dsn := os.Getenv("NIAN_TEST_MIGRATION_DATABASE_URL") + if dsn == "" { + t.Skip("set NIAN_TEST_MIGRATION_DATABASE_URL for disposable PostgreSQL integration test") + } + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + conn, err := pgx.Connect(ctx, dsn) + if err != nil { + t.Fatal("connect disposable database:", err) + } + defer conn.Close(context.Background()) + if _, err := conn.Exec(ctx, `DO $$ BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_roles WHERE rolname = 'nian_migration_test') THEN + CREATE ROLE nian_migration_test NOLOGIN; + END IF; + END $$`); err != nil { + t.Fatal("create disposable test role:", err) + } + all, err := discover(sqlFiles) + if err != nil { + t.Fatal(err) + } + // Repair only markers this test might have left if an earlier assertion + // stopped execution before normal cleanup. + var trackingTable *string + if err := conn.QueryRow(ctx, "SELECT to_regclass('public.schema_migrations')::text").Scan(&trackingTable); err != nil { + t.Fatal(err) + } + if trackingTable != nil { + if _, err := conn.Exec(ctx, "UPDATE schema_migrations SET checksum=$1 WHERE version=$2 AND checksum='test-mismatch'", all[0].checksum, all[0].version); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, "DELETE FROM schema_migrations WHERE version='test_unknown'"); err != nil { + t.Fatal(err) + } + } + env := func(key string) string { + switch key { + case "DATABASE_AUTO_MIGRATE": + return "true" + case "ZHINIAN_DATA_BACKEND": + return "postgres" + case "DATABASE_MIGRATION_URL", "DATABASE_URL": + return dsn + case "DATABASE_APP_ROLE": + return "nian_migration_test" + case "NODE_ENV": + return "production" + default: + return "" + } + } + var wg sync.WaitGroup + results := make(chan error, 2) + for i := 0; i < 2; i++ { + wg.Add(1) + go func() { + defer wg.Done() + results <- Run(ctx, env) + }() + } + wg.Wait() + close(results) + for err := range results { + if err != nil { + t.Fatal("concurrent migration:", err) + } + } + if err := Run(ctx, env); err != nil { + t.Fatal("repeat migration:", err) + } + var count int + if err := conn.QueryRow(ctx, "SELECT count(*) FROM schema_migrations").Scan(&count); err != nil || count != len(all) { + t.Fatalf("recorded migrations: count=%d, err=%v", count, err) + } + var privilege bool + if err := conn.QueryRow(ctx, "SELECT has_table_privilege('nian_migration_test', 'public.payment_orders', 'SELECT,INSERT,UPDATE')").Scan(&privilege); err != nil || !privilege { + t.Fatalf("payment order privileges: granted=%v, err=%v", privilege, err) + } + // A mismatch must prevent any later migration or startup. + first := all[0] + if _, err := conn.Exec(ctx, "UPDATE schema_migrations SET checksum='test-mismatch' WHERE version=$1", first.version); err != nil { + t.Fatal(err) + } + defer func() { + cleanupCtx, cleanupCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cleanupCancel() + _, _ = conn.Exec(cleanupCtx, "UPDATE schema_migrations SET checksum=$1 WHERE version=$2", first.checksum, first.version) + _, _ = conn.Exec(cleanupCtx, "DELETE FROM schema_migrations WHERE version='test_unknown'") + }() + if err := Run(ctx, env); err == nil || !strings.Contains(err.Error(), "checksum differs") { + t.Fatalf("expected checksum rejection, got %v", err) + } + if _, err := conn.Exec(ctx, "UPDATE schema_migrations SET checksum=$1 WHERE version=$2", first.checksum, first.version); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, "INSERT INTO schema_migrations(version, checksum) VALUES ('test_unknown', 'test')"); err != nil { + t.Fatal(err) + } + if err := Run(ctx, env); err == nil || !strings.Contains(err.Error(), "absent from bundle") { + t.Fatalf("expected unknown-version rejection, got %v", err) + } + if _, err := conn.Exec(ctx, "DELETE FROM schema_migrations WHERE version='test_unknown'"); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, "SELECT pg_advisory_lock($1)", migrationLockID); err != nil { + t.Fatal(err) + } + defer conn.Exec(context.Background(), "SELECT pg_advisory_unlock($1)", migrationLockID) + shortCtx, shortCancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer shortCancel() + if err := Run(shortCtx, env); err == nil || !strings.Contains(err.Error(), "timed out") { + t.Fatalf("expected advisory-lock timeout, got %v", err) + } + if _, err := conn.Exec(ctx, "SELECT pg_advisory_unlock($1)", migrationLockID); err != nil { + t.Fatal(err) + } + // The outer BEGIN/COMMIT in a legacy migration must not commit its DDL + // before the version/checksum row is inserted by apply. + const syntheticVersion = "9999_atomicity_test" + const syntheticTable = "public.nian_migration_atomicity_test" + if _, err := conn.Exec(ctx, "DROP TABLE IF EXISTS "+syntheticTable); err != nil { + t.Fatal(err) + } + if _, err := conn.Exec(ctx, "DELETE FROM schema_migrations WHERE version=$1", syntheticVersion); err != nil { + t.Fatal(err) + } + synthetic := migration{ + version: syntheticVersion, + sql: "-- synthetic migration\nBEGIN;\nCREATE TABLE " + syntheticTable + " (id integer PRIMARY KEY);\nSELECT 1 / 0;\nCOMMIT;\n", + } + if err := apply(ctx, conn, append(all, synthetic), ""); err == nil || !strings.Contains(err.Error(), syntheticVersion) { + t.Fatalf("expected synthetic migration failure with version, got %v", err) + } + var objectName *string + if err := conn.QueryRow(ctx, "SELECT to_regclass($1)::text", syntheticTable).Scan(&objectName); err != nil { + t.Fatal(err) + } + if objectName != nil { + t.Fatalf("failed migration left DDL object %s", *objectName) + } + var recorded int + if err := conn.QueryRow(ctx, "SELECT count(*) FROM schema_migrations WHERE version=$1", syntheticVersion).Scan(&recorded); err != nil || recorded != 0 { + t.Fatalf("failed migration recorded version: count=%d, err=%v", recorded, err) + } +} diff --git a/backend/internal/migrations/migrations_test.go b/backend/internal/migrations/migrations_test.go new file mode 100644 index 0000000..2944dbe --- /dev/null +++ b/backend/internal/migrations/migrations_test.go @@ -0,0 +1,97 @@ +package migrations + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + "testing/fstest" +) + +func TestEmbeddedBundleMatchesCanonicalSQL(t *testing.T) { + all, err := discover(sqlFiles) + if err != nil { + t.Fatal(err) + } + canonical, err := filepath.Glob("../../../database/migrations/*.sql") + if err != nil { + t.Fatal(err) + } + if len(canonical) != len(all) { + t.Fatalf("canonical count %d differs from embedded count %d", len(canonical), len(all)) + } + for i, path := range canonical { + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + version := strings.TrimSuffix(filepath.Base(path), ".sql") + if all[i].version != version || !bytes.Equal(data, []byte(all[i].sql)) { + t.Fatalf("embedded migration %s differs from canonical SQL", version) + } + checksum := sha256.Sum256(data) + if all[i].checksum != hex.EncodeToString(checksum[:]) { + t.Fatalf("checksum differs for %s", version) + } + } +} + +func TestDiscoverMigrations(t *testing.T) { + fsys := fstest.MapFS{ + "sql/0002.sql": {Data: []byte("select 2;\n")}, + "sql/0001.sql": {Data: []byte("select 1;\n")}, + } + all, err := discover(fsys) + if err != nil || len(all) != 2 || all[0].version != "0001" || all[1].version != "0002" { + t.Fatalf("discover: %+v, %v", all, err) + } +} + +func TestOuterTransactionRemoval(t *testing.T) { + for _, tc := range []struct { + in, want string + }{ + {"-- title\n\nBEGIN;\nselect 1;\nCOMMIT;\n", "-- title\n\nselect 1;\n"}, + {"do $$ begin raise notice 'x'; end $$;", "do $$ begin raise notice 'x'; end $$;"}, + } { + got, err := withoutOuterTransaction(tc.in) + if err != nil || got != tc.want { + t.Fatalf("got %q, %v; want %q", got, err, tc.want) + } + } + for _, sql := range []string{"begin;\nselect 1;", "select 1;\ncommit;"} { + if _, err := withoutOuterTransaction(sql); err == nil { + t.Fatalf("accepted unmatched transaction: %q", sql) + } + } +} + +func TestRunDisabledAndConfiguration(t *testing.T) { + getter := func(mapper map[string]string) func(string) string { + return func(key string) string { return mapper[key] } + } + if err := Run(context.Background(), getter(nil)); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + env map[string]string + want string + }{ + {map[string]string{"DATABASE_AUTO_MIGRATE": "yes"}, "must be true or false"}, + {map[string]string{"DATABASE_AUTO_MIGRATE": "true", "ZHINIAN_DATA_BACKEND": "local"}, "requires ZHINIAN_DATA_BACKEND=postgres"}, + {map[string]string{"DATABASE_AUTO_MIGRATE": "true", "ZHINIAN_DATA_BACKEND": "postgres", "DATABASE_MIGRATION_TIMEOUT_SECONDS": "0"}, "must be an integer"}, + {map[string]string{"DATABASE_AUTO_MIGRATE": "true", "ZHINIAN_DATA_BACKEND": "postgres", "NODE_ENV": "production"}, "DATABASE_APP_ROLE is required"}, + {map[string]string{"DATABASE_AUTO_MIGRATE": "true", "ZHINIAN_DATA_BACKEND": "postgres"}, "DATABASE_MIGRATION_URL or DATABASE_URL is required"}, + {map[string]string{"DATABASE_AUTO_MIGRATE": "true", "ZHINIAN_DATA_BACKEND": "postgres", "DATABASE_APP_ROLE": "bad;role"}, "DATABASE_APP_ROLE must match"}, + {map[string]string{"DATABASE_AUTO_MIGRATE": "true", "ZHINIAN_DATA_BACKEND": "postgres", "DATABASE_MIGRATION_URL": "postgres://admin:secret@localhost/db1", "DATABASE_URL": "postgres://app:secret@localhost/db2"}, "must target the same"}, + } { + err := Run(context.Background(), getter(tc.env)) + if err == nil || !strings.Contains(err.Error(), tc.want) { + t.Fatalf("env %+v: got %v, want %q", tc.env, err, tc.want) + } + } +} diff --git a/backend/internal/migrations/role.go b/backend/internal/migrations/role.go new file mode 100644 index 0000000..f8b4090 --- /dev/null +++ b/backend/internal/migrations/role.go @@ -0,0 +1,90 @@ +package migrations + +import ( + "context" + "fmt" + "regexp" + "strings" + + "github.com/jackc/pgx/v5" +) + +var rolePattern = regexp.MustCompile(`^[a-z_][a-z0-9_]{0,62}$`) + +var appTables = []struct { + name string + privileges string +}{ + {"assets", "SELECT, INSERT, DELETE"}, + {"generation_jobs", "SELECT, INSERT, UPDATE, DELETE"}, + {"seedream_layer_compositions", "SELECT, INSERT, UPDATE, DELETE"}, + {"usage_events", "SELECT, INSERT, UPDATE"}, + {"projects", "SELECT, UPDATE"}, + {"image_templates", "SELECT, INSERT, UPDATE, DELETE"}, + {"platform_organizations", "SELECT, INSERT, UPDATE, DELETE"}, + {"platform_users", "SELECT, INSERT, UPDATE, DELETE"}, + {"platform_account_migrations", "SELECT, INSERT, UPDATE"}, + {"platform_runtime_settings", "SELECT, INSERT, UPDATE"}, + {"billing_price_rules", "SELECT, INSERT, UPDATE"}, + {"billing_wallets", "SELECT, INSERT, UPDATE"}, + {"billing_ledger", "SELECT, INSERT"}, + {"payment_orders", "SELECT, INSERT, UPDATE"}, + {"payment_refunds", "SELECT, INSERT, UPDATE"}, +} + +func provisionRole(ctx context.Context, conn *pgx.Conn, role string) error { + // rolePattern has already validated the identifier. Quote it consistently. + quotedRole := `"` + role + `"` + managed := make([]string, len(appTables)) + for i, table := range appTables { + managed[i] = `public."` + table.name + `"` + } + tx, err := conn.Begin(ctx) + if err != nil { + return safeDBError("begin application role provisioning", "", err) + } + commands := []string{ + "REVOKE CREATE ON SCHEMA public FROM PUBLIC", + "GRANT USAGE ON SCHEMA public TO " + quotedRole, + "REVOKE ALL ON TABLE " + strings.Join(managed, ", ") + " FROM " + quotedRole, + } + for _, table := range appTables { + commands = append(commands, fmt.Sprintf("GRANT %s ON TABLE public.\"%s\" TO %s", table.privileges, table.name, quotedRole)) + } + commands = append(commands, + "REVOKE ALL ON FUNCTION public.claim_generation_jobs(text, integer, integer) FROM PUBLIC", + "REVOKE ALL ON FUNCTION public.billing_post_wallet_entry(text, text, text, text, text, bigint, text, text, text, jsonb) FROM PUBLIC", + "GRANT EXECUTE ON FUNCTION public.claim_generation_jobs(text, integer, integer) TO "+quotedRole, + "GRANT EXECUTE ON FUNCTION public.billing_post_wallet_entry(text, text, text, text, text, bigint, text, text, text, jsonb) TO "+quotedRole, + ) + for _, command := range commands { + if _, err := tx.Exec(ctx, command); err != nil { + _ = tx.Rollback(context.Background()) + return safeDBError("provision application role", "", err) + } + } + if err := tx.Commit(ctx); err != nil { + return safeDBError("commit application role privileges", "", err) + } + tableNames := make([]string, len(appTables)) + privileges := make([]string, len(appTables)) + for i, table := range appTables { + tableNames[i] = "public." + table.name + privileges[i] = strings.ReplaceAll(table.privileges, " ", "") + } + var tablesReady, claimReady, billingReady bool + err = conn.QueryRow(ctx, `SELECT + bool_and(has_table_privilege($1, table_name, privileges)) AS tables_ready, + has_function_privilege($1, 'public.claim_generation_jobs(text,integer,integer)', 'EXECUTE') AS claim_ready, + has_function_privilege($1, + 'public.billing_post_wallet_entry(text,text,text,text,text,bigint,text,text,text,jsonb)', + 'EXECUTE') AS billing_ready + FROM unnest($2::text[], $3::text[]) AS required(table_name, privileges)`, role, tableNames, privileges).Scan(&tablesReady, &claimReady, &billingReady) + if err != nil { + return safeDBError("verify application role privileges", "", err) + } + if !tablesReady || !claimReady || !billingReady { + return fmt.Errorf("application role privilege verification failed") + } + return nil +} diff --git a/backend/internal/migrations/sql/0001_initial_schema.sql b/backend/internal/migrations/sql/0001_initial_schema.sql new file mode 100644 index 0000000..4b007d8 --- /dev/null +++ b/backend/internal/migrations/sql/0001_initial_schema.sql @@ -0,0 +1,467 @@ +create table if not exists assets ( + id text primary key, + owner_id text not null, + kind text not null, + name text not null, + url text not null, + storage_path text, + source text not null, + tags text[] not null default '{}', + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists generation_jobs ( + id text primary key, + owner_id text not null, + external_client_id text, + capability text not null, + provider text not null, + req_key text not null, + status text not null, + prompt text, + input_asset_ids text[] not null default '{}', + input_urls text[] not null default '{}', + output_asset_ids text[] not null default '{}', + provider_task_id text, + request_payload jsonb not null default '{}'::jsonb, + response_payload jsonb, + error jsonb, + retry_of text, + idempotency_key text, + idempotency_fingerprint text, + priority integer not null default 0, + attempts integer not null default 0, + max_attempts integer not null default 3, + scheduled_at timestamptz not null default now(), + locked_at timestamptz, + locked_by text, + started_at timestamptz, + completed_at timestamptz, + webhook_url text, + webhook_attempts integer not null default 0, + webhook_last_status jsonb, + usage_context jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table generation_jobs add column if not exists external_client_id text; +alter table generation_jobs add column if not exists idempotency_key text; +alter table generation_jobs add column if not exists idempotency_fingerprint text; +alter table generation_jobs add column if not exists priority integer not null default 0; +alter table generation_jobs add column if not exists attempts integer not null default 0; +alter table generation_jobs add column if not exists max_attempts integer not null default 3; +alter table generation_jobs add column if not exists scheduled_at timestamptz not null default now(); +alter table generation_jobs add column if not exists locked_at timestamptz; +alter table generation_jobs add column if not exists locked_by text; +alter table generation_jobs add column if not exists started_at timestamptz; +alter table generation_jobs add column if not exists completed_at timestamptz; +alter table generation_jobs add column if not exists webhook_url text; +alter table generation_jobs add column if not exists webhook_attempts integer not null default 0; +alter table generation_jobs add column if not exists webhook_last_status jsonb; +alter table generation_jobs add column if not exists usage_context jsonb; + +create table if not exists usage_events ( + id text primary key, + owner_id text not null, + job_id text not null, + source text not null default 'platform', + capability text not null, + provider text, + req_key text, + account_username text, + account_display_name text, + tenant_id text, + organization_id text, + organization_name text, + quantity integer not null default 1, + estimated_unit text not null default 'job', + created_at timestamptz not null default now() +); + +alter table usage_events add column if not exists source text; +alter table usage_events add column if not exists provider text; +alter table usage_events add column if not exists req_key text; +alter table usage_events add column if not exists account_username text; +alter table usage_events add column if not exists account_display_name text; +alter table usage_events add column if not exists tenant_id text; +alter table usage_events add column if not exists organization_id text; +alter table usage_events add column if not exists organization_name text; +alter table usage_events add column if not exists quantity integer not null default 1; +alter table usage_events add column if not exists estimated_unit text not null default 'job'; + +update usage_events as usage +set source = case + when jobs.external_client_id is not null or usage.owner_id like 'api:%' then 'api' + else 'platform' + end, + provider = coalesce(usage.provider, jobs.provider), + req_key = coalesce(usage.req_key, jobs.req_key), + quantity = 1, + estimated_unit = 'job' +from generation_jobs as jobs +where jobs.id = usage.job_id; + +update usage_events +set source = case when owner_id like 'api:%' then 'api' else 'platform' end, + quantity = 1, + estimated_unit = 'job' +where source is null; + +alter table usage_events alter column source set default 'platform'; +alter table usage_events alter column source set not null; +alter table usage_events alter column estimated_unit set default 'job'; +alter table usage_events drop constraint if exists usage_events_job_id_fkey; + +create table if not exists projects ( + id text primary key, + owner_id text not null, + name text not null, + brief text not null default '', + type text not null default 'custom', + asset_ids text[] not null default '{}', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists image_templates ( + id text primary key, + owner_id text not null, + name text not null, + description text, + prompt text not null, + preview_image_url text, + settings jsonb not null default '{}'::jsonb, + sort_order integer not null default 0, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create index if not exists assets_owner_created_idx on assets(owner_id, created_at desc); +create index if not exists generation_jobs_owner_created_idx on generation_jobs(owner_id, created_at desc); +create index if not exists generation_jobs_status_idx on generation_jobs(status); +create index if not exists generation_jobs_claim_idx on generation_jobs(status, scheduled_at, locked_at, priority desc); +create index if not exists generation_jobs_external_client_idx on generation_jobs(owner_id, external_client_id, created_at desc); +create unique index if not exists generation_jobs_idempotency_idx + on generation_jobs(owner_id, external_client_id, idempotency_key) + where external_client_id is not null and idempotency_key is not null; +create index if not exists usage_events_owner_created_idx on usage_events(owner_id, created_at desc); +do $$ +begin + if exists ( + select 1 + from usage_events + group by job_id + having count(*) > 1 + ) then + raise exception using + errcode = '23505', + message = 'USAGE_EVENTS_DUPLICATE_JOB_ID: back up and clean duplicate usage_events.job_id rows before retrying this migration'; + end if; +end; +$$; +create unique index if not exists usage_events_job_id_idx on usage_events(job_id); +create index if not exists usage_events_source_created_idx on usage_events(source, created_at desc); +create index if not exists usage_events_organization_created_idx on usage_events(organization_id, created_at desc); +create index if not exists image_templates_owner_sort_idx on image_templates(owner_id, sort_order asc, updated_at desc); + +create or replace function claim_generation_jobs( + p_worker_id text, + p_limit integer default 1, + p_lock_timeout_seconds integer default 300 +) +returns setof generation_jobs +language plpgsql +set search_path = public, pg_temp +as $$ +declare + v_now timestamptz := now(); +begin + return query + with candidates as ( + select id + from generation_jobs + where status in ('queued', 'running') + and coalesce(scheduled_at, created_at) <= v_now + and ( + locked_at is null + or locked_at < v_now - make_interval(secs => p_lock_timeout_seconds) + ) + order by coalesce(priority, 0) desc, coalesce(scheduled_at, created_at) asc, created_at asc + limit greatest(1, least(coalesce(p_limit, 1), 20)) + for update skip locked + ), + updated as ( + update generation_jobs + set locked_at = v_now, + locked_by = p_worker_id, + started_at = coalesce(generation_jobs.started_at, v_now), + updated_at = v_now + where id in (select id from candidates) + returning generation_jobs.* + ) + select * from updated; +end; +$$; + +revoke all on function claim_generation_jobs(text, integer, integer) from public; + +create table if not exists platform_organizations ( + id text primary key, + name text not null unique, + status text not null default 'active' check (status in ('active', 'disabled')), + archive_owner_id text not null unique, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists platform_users ( + id text primary key, + phone text not null unique, + display_name text not null, + role text not null default 'user' check (role in ('super_admin', 'organization_admin', 'user')), + organization_id text references platform_organizations(id) on delete set null, + status text not null default 'active' check (status in ('active', 'disabled')), + password_hash text not null, + password_salt text not null, + failed_login_count integer not null default 0, + locked_until timestamptz, + session_version integer not null default 1, + last_login_at timestamptz, + legacy_subject text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists platform_account_migrations ( + id text primary key, + legacy_owner_id text not null unique, + legacy_phone text, + platform_user_id text not null, + created_at timestamptz not null default now() +); + +create index if not exists platform_users_organization_idx on platform_users(organization_id, created_at desc); +create index if not exists platform_users_status_idx on platform_users(status, role); +create index if not exists platform_account_migrations_user_idx on platform_account_migrations(platform_user_id); + +alter table generation_jobs add column if not exists billing jsonb; +alter table usage_events add column if not exists charged_amount_fen bigint; +alter table usage_events add column if not exists currency text; + +create table if not exists billing_price_rules ( + id text primary key, + provider text not null check (provider in ('volcengine-visual', 'evolink', 'seedance', 'bailian', 'mock')), + capability text not null check (capability in ('image.generate', 'video.generate')), + req_key text, + variant_key text, + unit text not null check (unit in ('request', 'image', 'video_second')), + standard_unit_price_fen bigint not null check (standard_unit_price_fen >= 0), + markup_multiplier numeric(12, 4) not null default 1.0000 check (markup_multiplier >= 1), + enabled boolean not null default true, + conditions jsonb not null default '{}'::jsonb, + quantity_source text check (quantity_source in ('request', 'image_count', 'duration')), + priority integer not null default 0, + note text, + source jsonb, + parameter_dimensions jsonb not null default '[]'::jsonb, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +alter table billing_price_rules add column if not exists variant_key text; +alter table billing_price_rules add column if not exists source jsonb; +alter table billing_price_rules add column if not exists conditions jsonb not null default '{}'::jsonb; +alter table billing_price_rules add column if not exists quantity_source text; +alter table billing_price_rules add column if not exists priority integer not null default 0; +alter table billing_price_rules add column if not exists parameter_dimensions jsonb not null default '[]'::jsonb; +alter table billing_price_rules drop constraint if exists billing_price_rules_quantity_source_check; +alter table billing_price_rules add constraint billing_price_rules_quantity_source_check check (quantity_source is null or quantity_source in ('request', 'image_count', 'duration')); + +drop index if exists billing_price_rules_match_idx; +create unique index if not exists billing_price_rules_match_idx + on billing_price_rules(provider, capability, coalesce(req_key, ''), coalesce(variant_key, ''), coalesce(conditions, '{}'::jsonb)); + +create table if not exists billing_wallets ( + organization_id text primary key references platform_organizations(id) on delete restrict, + balance_fen bigint not null default 0 check (balance_fen >= 0), + total_recharged_fen bigint not null default 0 check (total_recharged_fen >= 0), + total_charged_fen bigint not null default 0 check (total_charged_fen >= 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now() +); + +create table if not exists billing_ledger ( + id text primary key, + organization_id text not null references platform_organizations(id) on delete restrict, + account_id text, + job_id text, + kind text not null check (kind in ('recharge', 'charge', 'refund', 'adjustment')), + delta_fen bigint not null check (delta_fen <> 0), + balance_after_fen bigint not null check (balance_after_fen >= 0), + currency text not null default 'CNY' check (currency = 'CNY'), + idempotency_key text not null, + description text not null, + metadata jsonb not null default '{}'::jsonb, + created_at timestamptz not null default now() +); + +do $$ +declare + v_constraint record; + v_index record; +begin + for v_constraint in + select constraint_schema, constraint_name + from information_schema.table_constraints + where table_schema = current_schema() + and table_name = 'billing_ledger' + and constraint_type = 'UNIQUE' + and array( + select key_column_usage.column_name::text + from information_schema.key_column_usage + where key_column_usage.constraint_schema = table_constraints.constraint_schema + and key_column_usage.constraint_name = table_constraints.constraint_name + and key_column_usage.table_name = table_constraints.table_name + order by key_column_usage.ordinal_position + ) = array['idempotency_key']::text[] + loop + execute format('alter table %I.%I drop constraint %I', current_schema(), 'billing_ledger', v_constraint.constraint_name); + end loop; + + for v_index in + select indexes.schemaname, indexes.indexname + from pg_indexes as indexes + join pg_class as index_class on index_class.relname = indexes.indexname + join pg_namespace as index_namespace + on index_namespace.oid = index_class.relnamespace + and index_namespace.nspname = indexes.schemaname + join pg_index as index_metadata on index_metadata.indexrelid = index_class.oid + join pg_attribute as indexed_column + on indexed_column.attrelid = index_metadata.indrelid + and indexed_column.attnum = index_metadata.indkey[0] + left join pg_constraint as backing_constraint on backing_constraint.conindid = index_class.oid + where indexes.schemaname = current_schema() + and indexes.tablename = 'billing_ledger' + and index_metadata.indisunique + and index_metadata.indnkeyatts = 1 + and indexed_column.attname = 'idempotency_key' + and backing_constraint.oid is null + loop + execute format('drop index %I.%I', v_index.schemaname, v_index.indexname); + end loop; +end; +$$; + +create unique index if not exists billing_ledger_organization_idempotency_idx + on billing_ledger(organization_id, idempotency_key); +create index if not exists billing_ledger_organization_created_idx on billing_ledger(organization_id, created_at desc); +create index if not exists billing_ledger_account_created_idx on billing_ledger(account_id, created_at desc); +create index if not exists billing_ledger_job_idx on billing_ledger(job_id); + +create or replace function billing_post_wallet_entry( + p_ledger_id text, + p_organization_id text, + p_account_id text, + p_job_id text, + p_kind text, + p_delta_fen bigint, + p_currency text, + p_idempotency_key text, + p_description text, + p_metadata jsonb +) +returns table ( + ledger_id text, + balance_after_fen bigint, + balance_fen bigint, + total_recharged_fen bigint, + total_charged_fen bigint, + created_at timestamptz, + updated_at timestamptz, + delta_fen bigint +) +language plpgsql +set search_path = public, pg_temp +as $$ +declare + v_existing billing_ledger%rowtype; + v_wallet billing_wallets%rowtype; + v_entry billing_ledger%rowtype; + v_account_id text; +begin + if p_currency <> 'CNY' then + raise exception 'BILLING_UNSUPPORTED_CURRENCY'; + end if; + if p_delta_fen = 0 then + raise exception 'BILLING_ZERO_DELTA'; + end if; + + v_account_id := case when p_kind in ('recharge', 'adjustment') then null else p_account_id end; + + perform pg_advisory_xact_lock( + hashtextextended(jsonb_build_array(p_organization_id, p_idempotency_key)::text, 0) + ); + + select * into v_existing + from billing_ledger + where organization_id = p_organization_id + and idempotency_key = p_idempotency_key; + if found then + if v_existing.account_id is distinct from v_account_id + or v_existing.job_id is distinct from p_job_id + or v_existing.kind is distinct from p_kind + or v_existing.delta_fen is distinct from p_delta_fen + or v_existing.currency is distinct from p_currency + then + raise exception using + errcode = 'P0001', + message = 'BILLING_IDEMPOTENCY_PAYLOAD_MISMATCH'; + end if; + + select * into v_wallet from billing_wallets where organization_id = p_organization_id; + return query select v_existing.id, v_existing.balance_after_fen, v_wallet.balance_fen, + v_wallet.total_recharged_fen, v_wallet.total_charged_fen, v_existing.created_at, + v_wallet.updated_at, v_existing.delta_fen; + return; + end if; + + insert into billing_wallets(organization_id) + values (p_organization_id) + on conflict (organization_id) do nothing; + + select * into v_wallet from billing_wallets + where organization_id = p_organization_id + for update; + + if p_delta_fen < 0 and v_wallet.balance_fen < abs(p_delta_fen) then + raise exception 'BILLING_INSUFFICIENT_BALANCE'; + end if; + + update billing_wallets + set balance_fen = v_wallet.balance_fen + p_delta_fen, + total_recharged_fen = v_wallet.total_recharged_fen + case when p_kind = 'recharge' and p_delta_fen > 0 then p_delta_fen else 0 end, + total_charged_fen = v_wallet.total_charged_fen + case when p_kind = 'charge' and p_delta_fen < 0 then abs(p_delta_fen) else 0 end, + updated_at = now() + where organization_id = p_organization_id + returning * into v_wallet; + + insert into billing_ledger( + id, organization_id, account_id, job_id, kind, delta_fen, + balance_after_fen, currency, idempotency_key, description, metadata + ) values ( + p_ledger_id, p_organization_id, + v_account_id, + p_job_id, p_kind, p_delta_fen, + v_wallet.balance_fen, p_currency, p_idempotency_key, p_description, coalesce(p_metadata, '{}'::jsonb) + ) returning * into v_entry; + + return query select v_entry.id, v_entry.balance_after_fen, v_wallet.balance_fen, + v_wallet.total_recharged_fen, v_wallet.total_charged_fen, v_entry.created_at, + v_wallet.updated_at, v_entry.delta_fen; +end; +$$; + +revoke all on function billing_post_wallet_entry(text, text, text, text, text, bigint, text, text, text, jsonb) from public; diff --git a/backend/internal/migrations/sql/0002_generation_lifecycle_fencing.sql b/backend/internal/migrations/sql/0002_generation_lifecycle_fencing.sql new file mode 100644 index 0000000..22481b7 --- /dev/null +++ b/backend/internal/migrations/sql/0002_generation_lifecycle_fencing.sql @@ -0,0 +1,69 @@ +-- Recoverable generation finalization and charge-before-dispatch fencing. +-- Additive so the previous release remains rollback-compatible. + +alter table public.generation_jobs + add column if not exists dispatch_ready_at timestamptz; + +alter table public.generation_jobs + add column if not exists finalized_at timestamptz; + +alter table public.generation_jobs + add column if not exists provider_dispatch_started_at timestamptz; + +-- Existing rows were created before dispatch fencing and are therefore ready. +update public.generation_jobs +set dispatch_ready_at = coalesce(dispatch_ready_at, created_at, now()) +where dispatch_ready_at is null; + +-- Rows already terminal before this migration have completed their legacy +-- finalization path and must not be replayed into billing/webhook side effects. +update public.generation_jobs +set finalized_at = coalesce(finalized_at, completed_at, updated_at, now()) +where finalized_at is null + and status in ('succeeded', 'failed', 'expired', 'cancelled'); + +create index if not exists generation_jobs_finalize_claim_idx + on public.generation_jobs(finalized_at, scheduled_at, locked_at, priority desc); + +create or replace function public.claim_generation_jobs( + p_worker_id text, + p_limit integer default 1, + p_lock_timeout_seconds integer default 300 +) +returns setof public.generation_jobs +language plpgsql +set search_path = public, pg_temp +as $$ +declare + v_now timestamptz := now(); +begin + return query + with candidates as ( + select id + from public.generation_jobs + where dispatch_ready_at is not null + and finalized_at is null + and status in ('queued', 'running', 'succeeded', 'failed', 'expired', 'cancelled') + and coalesce(scheduled_at, created_at) <= v_now + and ( + locked_at is null + or locked_at < v_now - make_interval(secs => p_lock_timeout_seconds) + ) + order by coalesce(priority, 0) desc, coalesce(scheduled_at, created_at) asc, created_at asc + limit greatest(1, least(coalesce(p_limit, 1), 20)) + for update skip locked + ), + updated as ( + update public.generation_jobs + set locked_at = v_now, + locked_by = p_worker_id, + started_at = coalesce(public.generation_jobs.started_at, v_now), + updated_at = v_now + where id in (select id from candidates) + returning public.generation_jobs.* + ) + select * from updated; +end; +$$; + +revoke all on function public.claim_generation_jobs(text, integer, integer) from public; diff --git a/backend/internal/migrations/sql/0003_platform_runtime_settings.sql b/backend/internal/migrations/sql/0003_platform_runtime_settings.sql new file mode 100644 index 0000000..e03d480 --- /dev/null +++ b/backend/internal/migrations/sql/0003_platform_runtime_settings.sql @@ -0,0 +1,12 @@ +-- Mutable provider configuration is relational runtime state. Values are +-- intentionally plaintext in this first functional migration; at-rest +-- encryption and key rotation are separate hardening work. +create table if not exists platform_runtime_settings ( + setting_key text primary key, + setting_value text not null, + revision bigint not null default 1 check (revision > 0), + updated_at timestamptz not null default now() +); + +create index if not exists platform_runtime_settings_updated_at_idx + on platform_runtime_settings(updated_at desc); diff --git a/backend/internal/migrations/sql/0004_seedream_billing_provider.sql b/backend/internal/migrations/sql/0004_seedream_billing_provider.sql new file mode 100644 index 0000000..2c2f210 --- /dev/null +++ b/backend/internal/migrations/sql/0004_seedream_billing_provider.sql @@ -0,0 +1,14 @@ +-- Allow the independently routed Ark Seedream image provider in the durable +-- billing catalog. Keep the historical migration immutable and widen only the +-- existing provider constraint. + +begin; + +alter table public.billing_price_rules + drop constraint if exists billing_price_rules_provider_check; + +alter table public.billing_price_rules + add constraint billing_price_rules_provider_check + check (provider in ('volcengine-visual', 'evolink', 'seedance', 'seedream', 'bailian', 'mock')); + +commit; diff --git a/backend/internal/migrations/sql/0005_seedream_layer_compositions.sql b/backend/internal/migrations/sql/0005_seedream_layer_compositions.sql new file mode 100644 index 0000000..771cb6e --- /dev/null +++ b/backend/internal/migrations/sql/0005_seedream_layer_compositions.sql @@ -0,0 +1,14 @@ +create table if not exists public.seedream_layer_compositions ( + job_id text primary key references public.generation_jobs(id) on delete cascade, + owner_id text not null, + base_asset_id text not null, + base_visible boolean not null default true, + layers jsonb not null default '[]'::jsonb, + version bigint not null default 1 check (version > 0), + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + constraint seedream_layer_compositions_layers_array_check check (jsonb_typeof(layers) = 'array') +); + +create index if not exists seedream_layer_compositions_owner_updated_idx + on public.seedream_layer_compositions(owner_id, updated_at desc); diff --git a/backend/internal/migrations/sql/0006_minimax_h3_billing_provider.sql b/backend/internal/migrations/sql/0006_minimax_h3_billing_provider.sql new file mode 100644 index 0000000..e3d28b3 --- /dev/null +++ b/backend/internal/migrations/sql/0006_minimax_h3_billing_provider.sql @@ -0,0 +1,13 @@ +-- Allow MiniMax H3 rules in the durable billing catalog. Runtime provider +-- credentials already use the generic platform_runtime_settings table. + +begin; + +alter table public.billing_price_rules + drop constraint if exists billing_price_rules_provider_check; + +alter table public.billing_price_rules + add constraint billing_price_rules_provider_check + check (provider in ('volcengine-visual', 'evolink', 'seedance', 'seedream', 'bailian', 'minimax', 'mock')); + +commit; diff --git a/backend/internal/migrations/sql/0007_wechat_native_payments.sql b/backend/internal/migrations/sql/0007_wechat_native_payments.sql new file mode 100644 index 0000000..21d0f74 --- /dev/null +++ b/backend/internal/migrations/sql/0007_wechat_native_payments.sql @@ -0,0 +1,32 @@ +-- Orders are written before calling WeChat. Keep them when a user or +-- organization is deleted so paid money cannot lose its audit trail. +create table if not exists public.payment_orders ( + id text primary key check (length(id) between 1 and 32 and id ~ '^[A-Za-z0-9_-]+$'), + organization_id text not null references public.platform_organizations(id) on delete restrict, + account_id text not null references public.platform_users(id) on delete restrict, + request_key text not null check (length(request_key) between 1 and 64 and request_key ~ '^[A-Za-z0-9_-]+$'), + mch_id text not null check (length(mch_id) between 1 and 64), + app_id text not null check (length(app_id) between 1 and 64), + amount_fen bigint not null check (amount_fen between 1 and 1000000), + currency text not null check (currency = 'CNY'), + status text not null default 'pending' check (status in ('pending', 'paid', 'closed')), + code_url text, + transaction_id text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + expires_at timestamptz not null, + paid_at timestamptz, + constraint payment_orders_paid_fields_check check ( + (status = 'paid' and transaction_id is not null and length(transaction_id) > 0 and paid_at is not null) + or (status <> 'paid' and transaction_id is null and paid_at is null) + ), + constraint payment_orders_expiry_check check (expires_at > created_at), + constraint payment_orders_org_request_unique unique (organization_id, request_key) +); + +create unique index if not exists payment_orders_provider_transaction_unique + on public.payment_orders(mch_id, transaction_id) where transaction_id is not null; +create index if not exists payment_orders_org_created_idx + on public.payment_orders(organization_id, created_at desc); +create index if not exists payment_orders_pending_check_idx + on public.payment_orders(updated_at, created_at) where status = 'pending'; diff --git a/backend/internal/migrations/sql/0008_wechat_refunds.sql b/backend/internal/migrations/sql/0008_wechat_refunds.sql new file mode 100644 index 0000000..4a573fb --- /dev/null +++ b/backend/internal/migrations/sql/0008_wechat_refunds.sql @@ -0,0 +1,34 @@ +-- Reserve each requested refund against its original, successful payment. +-- A successful external refund can exceed the current wallet balance. +alter table public.billing_wallets drop constraint if exists billing_wallets_balance_fen_check; +alter table public.billing_ledger drop constraint if exists billing_ledger_balance_after_fen_check; +alter table public.billing_ledger drop constraint if exists billing_ledger_kind_check; +alter table public.billing_ledger add constraint billing_ledger_kind_check + check (kind in ('recharge', 'charge', 'refund', 'adjustment', 'recharge_refund')); + +create table public.payment_refunds ( + id text primary key check (length(id) between 1 and 32 and id ~ '^[A-Za-z0-9_-]+$'), + order_id text not null references public.payment_orders(id) on delete restrict, + organization_id text not null references public.platform_organizations(id) on delete restrict, + operator_id text not null references public.platform_users(id) on delete restrict, + request_key text not null check (length(request_key) between 1 and 64 and request_key ~ '^[A-Za-z0-9_-]+$'), + mch_id text not null check (length(mch_id) between 1 and 64), + transaction_id text not null check (length(transaction_id) > 0), + provider_id text, + amount_fen bigint not null check (amount_fen > 0), + total_fen bigint not null check (total_fen > 0 and total_fen >= amount_fen), + currency text not null check (currency = 'CNY'), + reason text not null, + status text not null default 'pending' check (status in ('pending', 'processing', 'success', 'closed', 'abnormal', 'rejected')), + failure_reason text not null default '', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + succeeded_at timestamptz, + constraint payment_refunds_request_unique unique (order_id, request_key), + constraint payment_refunds_success_check check ((status = 'success') = (succeeded_at is not null)) +); +create unique index payment_refunds_provider_unique on public.payment_refunds(mch_id, provider_id) + where provider_id is not null; +create index payment_refunds_order_idx on public.payment_refunds(order_id, created_at desc, id desc); +create index payment_refunds_pending_idx on public.payment_refunds(updated_at, created_at, id) + where status in ('pending', 'processing', 'abnormal'); diff --git a/backend/internal/payments/model.go b/backend/internal/payments/model.go new file mode 100644 index 0000000..a8ba5b0 --- /dev/null +++ b/backend/internal/payments/model.go @@ -0,0 +1,84 @@ +// Package payments manages WeChat Native recharge orders for organization wallets. +package payments + +import ( + "context" + "errors" + "net/http" + "time" +) + +var ( + ErrNotFound = errors.New("payment order not found") + ErrConflict = errors.New("payment order conflicts with existing data") + ErrInvalidAmount = errors.New("invalid payment amount") + ErrUnavailable = errors.New("payment unavailable") + ErrVerification = errors.New("payment verification failed") + ErrRemoteNotFound = errors.New("remote payment order not found") +) + +const ( + StatusPending = "pending" + StatusPaid = "paid" + StatusClosed = "closed" +) + +type Order struct { + ID string `json:"id"` + OrganizationID string `json:"organizationId"` + AccountID string `json:"accountId"` + RequestKey string `json:"-"` + MchID string `json:"-"` + AppID string `json:"-"` + AmountFen int64 `json:"amountFen"` + Currency string `json:"currency"` + Status string `json:"status"` + CodeURL string `json:"codeUrl,omitempty"` + TransactionID string `json:"transactionId,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + ExpiresAt time.Time `json:"expiresAt"` + PaidAt *time.Time `json:"paidAt,omitempty"` +} + +type Transaction struct { + OrderID, MchID, AppID, TransactionID, TradeState, Currency string + AmountFen int64 + SuccessTime time.Time +} + +// Repository settlement must validate the stored order identity and amount, +// atomically post the wallet ledger and mark paid, and reject reused transaction IDs. +type Repository interface { + CreatePaymentOrder(context.Context, Order) (Order, error) + GetPaymentOrder(context.Context, string) (Order, error) + ListPaymentOrders(context.Context, string, int) ([]Order, error) + PendingPaymentOrders(context.Context, int) ([]Order, error) + UpdatePaymentOrder(context.Context, string, string, string) (Order, error) + SettlePaymentOrder(context.Context, Transaction) (Order, error) +} + +type Provider interface { + Create(context.Context, Order) (string, error) + Query(context.Context, string) (Transaction, error) + Close(context.Context, string) error + ParseNotification(context.Context, *http.Request) (Transaction, error) +} + +type Config struct { + Enabled bool + MchID, AppID, NotifyURL string + MerchantSerial, PrivateKeyPEM, APIv3Key string + WechatPublicKeyID, WechatPublicKeyPEM string + PresetAmountsFen []int64 + MinAmountFen, MaxAmountFen int64 + OrderTTL time.Duration +} + +type Options struct { + Enabled bool `json:"enabled"` + CanRecharge bool `json:"canRecharge"` + PresetAmountsFen []int64 `json:"presetAmountsFen"` + MinAmountFen int64 `json:"minAmountFen"` + MaxAmountFen int64 `json:"maxAmountFen"` +} diff --git a/backend/internal/payments/refund_model.go b/backend/internal/payments/refund_model.go new file mode 100644 index 0000000..e8736bf --- /dev/null +++ b/backend/internal/payments/refund_model.go @@ -0,0 +1,78 @@ +package payments + +import ( + "context" + "net/http" + "time" +) + +const ( + RefundPending = "pending" + RefundProcessing = "processing" + RefundSuccess = "success" + RefundClosed = "closed" + RefundAbnormal = "abnormal" + RefundRejected = "rejected" +) + +type Refund struct { + ID string `json:"id"` + OrderID string `json:"orderId"` + OrganizationID string `json:"organizationId"` + OperatorID string `json:"operatorId"` + OperatorName string `json:"operatorName,omitempty"` + RequestKey string `json:"-"` + MchID string `json:"-"` + TransactionID string `json:"transactionId"` + ProviderID string `json:"providerId,omitempty"` + AmountFen int64 `json:"amountFen"` + TotalFen int64 `json:"totalFen"` + Currency string `json:"currency"` + Reason string `json:"reason"` + Status string `json:"status"` + FailureReason string `json:"failureReason,omitempty"` + CreatedAt time.Time `json:"createdAt"` + UpdatedAt time.Time `json:"updatedAt"` + SucceededAt *time.Time `json:"succeededAt,omitempty"` +} + +type RefundTransaction struct { + ID, OrderID, MchID, TransactionID, ProviderID, Status, Currency string + AmountFen, TotalFen int64 + SuccessTime time.Time +} + +type AdminOrder struct { + Order + OrganizationName string `json:"organizationName"` + AccountName string `json:"accountName"` + RefundedFen int64 `json:"refundedFen"` + RefundingFen int64 `json:"refundingFen"` + RefundableFen int64 `json:"refundableFen"` +} + +type AdminOrdersPage struct { + Orders []AdminOrder `json:"orders"` + HasMore bool `json:"hasMore"` + Enabled bool `json:"enabled"` +} + +// Creating a refund reserves the remaining refundable amount under the payment +// order lock. Successful settlement atomically debits the wallet once, allowing +// that specific debit to make the wallet balance negative. +type RefundRepository interface { + RejectPaymentRefund(context.Context, string, string) (Refund, error) + ListAdminPaymentOrders(context.Context, string, int, int) ([]AdminOrder, error) + CreatePaymentRefund(context.Context, Refund) (Refund, error) + GetPaymentRefund(context.Context, string) (Refund, error) + ListPaymentRefunds(context.Context, string) ([]Refund, error) + PendingPaymentRefunds(context.Context, int) ([]Refund, error) + TouchPaymentRefund(context.Context, string) (Refund, error) + RecordPaymentRefund(context.Context, RefundTransaction) (Refund, error) +} + +type RefundProvider interface { + CreateRefund(context.Context, Refund) (RefundTransaction, error) + QueryRefund(context.Context, string) (RefundTransaction, error) + ParseRefundNotification(context.Context, *http.Request) (RefundTransaction, error) +} diff --git a/backend/internal/payments/refunds.go b/backend/internal/payments/refunds.go new file mode 100644 index 0000000..98161e9 --- /dev/null +++ b/backend/internal/payments/refunds.go @@ -0,0 +1,215 @@ +package payments + +import ( + "context" + "errors" + "fmt" + "net/http" + "strings" + "time" +) + +func (s *Service) refundDependencies() (RefundRepository, RefundProvider, error) { + repo, repoOK := s.repo.(RefundRepository) + provider, providerOK := s.provider.(RefundProvider) + if !s.Options().CanRecharge || !repoOK || !providerOK { + return nil, nil, ErrUnavailable + } + return repo, provider, nil +} + +func (s *Service) AdminList(ctx context.Context, organizationID string, limit, offset int) (AdminOrdersPage, error) { + repo, ok := s.repo.(RefundRepository) + if !ok { + return AdminOrdersPage{}, ErrUnavailable + } + if limit < 1 || limit > 100 || offset < 0 { + return AdminOrdersPage{}, ErrInvalidAmount + } + orders, err := repo.ListAdminPaymentOrders(ctx, organizationID, limit+1, offset) + if err != nil { + return AdminOrdersPage{}, err + } + page := AdminOrdersPage{Orders: orders, Enabled: s.Options().CanRecharge} + if len(orders) > limit { + page.Orders = orders[:limit] + page.HasMore = true + } + return page, nil +} + +func (s *Service) CreateRefund(ctx context.Context, orderID, operatorID, requestKey, reason string, amountFen int64) (Refund, error) { + repo, provider, err := s.refundDependencies() + if err != nil { + return Refund{}, err + } + reason = strings.TrimSpace(reason) + if orderID == "" || operatorID == "" || !requestKeyPattern.MatchString(requestKey) || reason == "" || len([]byte(reason)) > 80 || amountFen <= 0 { + return Refund{}, ErrInvalidAmount + } + order, err := s.repo.GetPaymentOrder(ctx, orderID) + if err != nil { + return Refund{}, err + } + if order.Status != StatusPaid || order.TransactionID == "" || order.MchID != s.config.MchID || order.AppID != s.config.AppID || order.Currency != "CNY" || amountFen > order.AmountFen { + return Refund{}, ErrConflict + } + id, err := newOrderID() + if err != nil { + return Refund{}, err + } + now := s.now().UTC() + wanted := Refund{ID: id, OrderID: orderID, OrganizationID: order.OrganizationID, OperatorID: operatorID, + RequestKey: requestKey, MchID: order.MchID, TransactionID: order.TransactionID, + AmountFen: amountFen, TotalFen: order.AmountFen, Currency: order.Currency, Reason: reason, + Status: RefundPending, CreatedAt: now, UpdatedAt: now} + refund, err := repo.CreatePaymentRefund(ctx, wanted) + if err != nil { + return Refund{}, err + } + if refund.OrderID != wanted.OrderID || refund.OrganizationID != wanted.OrganizationID || refund.OperatorID != wanted.OperatorID || refund.RequestKey != wanted.RequestKey || refund.MchID != wanted.MchID || refund.TransactionID != wanted.TransactionID || refund.AmountFen != wanted.AmountFen || refund.TotalFen != wanted.TotalFen || refund.Currency != wanted.Currency || refund.Reason != wanted.Reason { + return Refund{}, ErrConflict + } + if refund.Status != RefundPending { + return refund, nil + } + if refund.ID != wanted.ID { + return refund, nil // A repeated request waits for reconciliation of the same remote ID. + } + // The durable refund ID is the WeChat out_refund_no. Retrying it never creates a second refund. + txn, err := provider.CreateRefund(ctx, refund) + if err != nil { + var rejected *RefundRejectionError + if errors.As(err, &rejected) { + return rejectRefund(ctx, repo, refund.ID, rejected.Reason) + } + return refund, nil + } // Remote acceptance can be ambiguous; reconciliation queries this exact ID. + return s.recordRefund(ctx, repo, refund, txn) +} + +func (s *Service) GetRefund(ctx context.Context, id string) (Refund, error) { + repo, ok := s.repo.(RefundRepository) + if !ok { + return Refund{}, ErrUnavailable + } + return repo.GetPaymentRefund(ctx, id) +} + +func (s *Service) ListRefunds(ctx context.Context, orderID string) ([]Refund, error) { + repo, ok := s.repo.(RefundRepository) + if !ok { + return nil, ErrUnavailable + } + return repo.ListPaymentRefunds(ctx, orderID) +} + +func (s *Service) RefundNotify(ctx context.Context, request *http.Request) error { + repo, provider, err := s.refundDependencies() + if err != nil { + return err + } + txn, err := provider.ParseRefundNotification(ctx, request) + if err != nil { + return err + } + refund, err := repo.GetPaymentRefund(ctx, txn.ID) + if err != nil { + return err + } + _, err = s.recordRefund(ctx, repo, refund, txn) + return err +} + +func (s *Service) recordRefund(ctx context.Context, repo RefundRepository, refund Refund, txn RefundTransaction) (Refund, error) { + if txn.ID != refund.ID || txn.OrderID != refund.OrderID || txn.MchID != refund.MchID || txn.MchID != s.config.MchID || + txn.TransactionID != refund.TransactionID || txn.ProviderID == "" || txn.AmountFen != refund.AmountFen || + txn.TotalFen != refund.TotalFen || txn.Currency != refund.Currency || + (txn.Status != RefundProcessing && txn.Status != RefundSuccess && txn.Status != RefundClosed && txn.Status != RefundAbnormal) || + (txn.Status == RefundSuccess && txn.SuccessTime.IsZero()) { + return Refund{}, ErrVerification + } + order, err := s.repo.GetPaymentOrder(ctx, txn.OrderID) + if err != nil { + return Refund{}, err + } + if order.Status != StatusPaid || order.ID != refund.OrderID || order.MchID != refund.MchID || order.TransactionID != refund.TransactionID || order.AmountFen != refund.TotalFen || order.Currency != refund.Currency || order.OrganizationID != refund.OrganizationID { + return Refund{}, ErrVerification + } + return repo.RecordPaymentRefund(ctx, txn) +} + +func (s *Service) reconcileRefunds(ctx context.Context) error { + repo, provider, err := s.refundDependencies() + if err != nil { + return nil + } // Existing payment-only providers have no refund queue. + refunds, err := repo.PendingPaymentRefunds(ctx, 100) + if err != nil { + return err + } + var failures []error + for _, refund := range refunds { + if ctx.Err() != nil { + return ctx.Err() + } + if s.now().Sub(refund.UpdatedAt) < refundQueryInterval(s.now().Sub(refund.CreatedAt)) { + continue + } + current, err := repo.TouchPaymentRefund(ctx, refund.ID) + if err != nil { + failures = append(failures, fmt.Errorf("refund %s: %w", refund.ID, err)) + continue + } + if current.Status == RefundClosed || current.Status == RefundSuccess || current.Status == RefundRejected { + continue + } + txn, err := provider.QueryRefund(ctx, current.ID) + if errors.Is(err, ErrRemoteNotFound) { + // An earlier create could have timed out before receipt; the same out_refund_no is safe to retry. + order, orderErr := s.repo.GetPaymentOrder(ctx, current.OrderID) + if orderErr != nil || order.AppID != s.config.AppID || order.MchID != s.config.MchID { + failures = append(failures, fmt.Errorf("refund %s: %w", current.ID, ErrVerification)) + continue + } + txn, err = provider.CreateRefund(ctx, current) + var rejected *RefundRejectionError + if errors.As(err, &rejected) { + if _, rejectErr := rejectRefund(ctx, repo, current.ID, rejected.Reason); rejectErr != nil { + failures = append(failures, fmt.Errorf("refund %s rejection: %w", current.ID, rejectErr)) + } + continue + } + } + if err != nil { + failures = append(failures, fmt.Errorf("refund %s: %w", current.ID, err)) + continue + } + if _, err = s.recordRefund(ctx, repo, current, txn); err != nil { + failures = append(failures, fmt.Errorf("refund %s: %w", current.ID, err)) + } + } + return errors.Join(failures...) +} + +func rejectRefund(ctx context.Context, repo RefundRepository, id, reason string) (Refund, error) { + refund, err := repo.RejectPaymentRefund(ctx, id, reason) + if errors.Is(err, ErrConflict) { + // A signed provider callback can win this race. Return its recorded state. + latest, getErr := repo.GetPaymentRefund(ctx, id) + if getErr == nil && latest.Status != RefundPending { + return latest, nil + } + } + return refund, err +} + +func refundQueryInterval(age time.Duration) time.Duration { + if age < 5*time.Minute { + return time.Minute + } + if age < time.Hour { + return 2 * time.Minute + } + return 5 * time.Minute +} diff --git a/backend/internal/payments/refunds_test.go b/backend/internal/payments/refunds_test.go new file mode 100644 index 0000000..f84c1ec --- /dev/null +++ b/backend/internal/payments/refunds_test.go @@ -0,0 +1,193 @@ +package payments + +import ( + "context" + "errors" + "net/http" + "testing" + "time" +) + +type refundTestRepo struct { + *testRepo + refunds map[string]Refund + byRefundKey map[string]string + records int +} + +func (r *refundTestRepo) ListAdminPaymentOrders(_ context.Context, _ string, _, _ int) ([]AdminOrder, error) { + return nil, nil +} +func (r *refundTestRepo) CreatePaymentRefund(_ context.Context, refund Refund) (Refund, error) { + if id := r.byRefundKey[refund.RequestKey]; id != "" { + return r.refunds[id], nil + } + r.refunds[refund.ID] = refund + r.byRefundKey[refund.RequestKey] = refund.ID + return refund, nil +} +func (r *refundTestRepo) GetPaymentRefund(_ context.Context, id string) (Refund, error) { + refund, ok := r.refunds[id] + if !ok { + return Refund{}, ErrNotFound + } + return refund, nil +} +func (r *refundTestRepo) ListPaymentRefunds(_ context.Context, orderID string) ([]Refund, error) { + var out []Refund + for _, refund := range r.refunds { + if refund.OrderID == orderID { + out = append(out, refund) + } + } + return out, nil +} +func (r *refundTestRepo) PendingPaymentRefunds(_ context.Context, _ int) ([]Refund, error) { + var out []Refund + for _, refund := range r.refunds { + if refund.Status != RefundSuccess && refund.Status != RefundClosed && refund.Status != RefundRejected { + out = append(out, refund) + } + } + return out, nil +} +func (r *refundTestRepo) TouchPaymentRefund(_ context.Context, id string) (Refund, error) { + refund := r.refunds[id] + refund.UpdatedAt = time.Now().UTC() + r.refunds[id] = refund + return refund, nil +} +func (r *refundTestRepo) RejectPaymentRefund(_ context.Context, id, reason string) (Refund, error) { + refund := r.refunds[id] + refund.Status, refund.FailureReason = RefundRejected, reason + r.refunds[id] = refund + return refund, nil +} +func (r *refundTestRepo) RecordPaymentRefund(_ context.Context, txn RefundTransaction) (Refund, error) { + refund := r.refunds[txn.ID] + if txn.Status == RefundSuccess && refund.Status != RefundSuccess { + r.records++ + } + refund.Status = txn.Status + refund.ProviderID = txn.ProviderID + r.refunds[txn.ID] = refund + return refund, nil +} + +type refundTestProvider struct { + *testProvider + txn RefundTransaction + createErr, queryErr error + createIDs []string + queries int +} + +func (p *refundTestProvider) CreateRefund(_ context.Context, refund Refund) (RefundTransaction, error) { + p.createIDs = append(p.createIDs, refund.ID) + if p.createErr != nil { + return RefundTransaction{}, p.createErr + } + txn := p.txn + txn.ID, txn.OrderID, txn.MchID, txn.TransactionID = refund.ID, refund.OrderID, refund.MchID, refund.TransactionID + txn.AmountFen, txn.TotalFen, txn.Currency = refund.AmountFen, refund.TotalFen, refund.Currency + return txn, nil +} +func (p *refundTestProvider) QueryRefund(_ context.Context, _ string) (RefundTransaction, error) { + p.queries++ + return p.txn, p.queryErr +} +func (p *refundTestProvider) ParseRefundNotification(_ context.Context, _ *http.Request) (RefundTransaction, error) { + return p.txn, nil +} + +func newRefundService() (*Service, *refundTestRepo, *refundTestProvider, Order) { + r := &refundTestRepo{testRepo: &testRepo{orders: map[string]Order{}, byKey: map[string]string{}}, refunds: map[string]Refund{}, byRefundKey: map[string]string{}} + p := &refundTestProvider{testProvider: &testProvider{}, txn: RefundTransaction{ProviderID: "wx-refund", Status: RefundProcessing}} + s := NewService(r, p, Config{Enabled: true, MchID: "mch", AppID: "app"}) + order := Order{ID: "order1", OrganizationID: "org1", MchID: "mch", AppID: "app", TransactionID: "wx-pay", AmountFen: 10000, Currency: "CNY", Status: StatusPaid} + r.orders[order.ID] = order + return s, r, p, order +} + +func TestRefundAcceptanceDoesNotDebitUntilVerifiedSuccess(t *testing.T) { + s, repo, provider, order := newRefundService() + refund, err := s.CreateRefund(context.Background(), order.ID, "admin", "key1", "客户申请", 5000) + if err != nil || refund.Status != RefundProcessing || repo.records != 0 { + t.Fatalf("acceptance incorrectly debited: %+v %v %d", refund, err, repo.records) + } + provider.txn = RefundTransaction{ID: refund.ID, OrderID: order.ID, MchID: "mch", TransactionID: order.TransactionID, ProviderID: "wx-refund", Status: RefundSuccess, AmountFen: 4999, TotalFen: order.AmountFen, Currency: "CNY", SuccessTime: time.Now()} + if err := s.RefundNotify(context.Background(), &http.Request{}); !errors.Is(err, ErrVerification) || repo.records != 0 { + t.Fatalf("mismatched callback: %v %d", err, repo.records) + } + provider.txn.AmountFen = 5000 + for i := 0; i < 2; i++ { + if err := s.RefundNotify(context.Background(), &http.Request{}); err != nil { + t.Fatal(err) + } + } + if repo.records != 1 { + t.Fatalf("duplicate debit: %d", repo.records) + } +} + +func TestRefundTimeoutReusesSameRemoteID(t *testing.T) { + s, repo, provider, order := newRefundService() + provider.createErr = errors.New("gateway timeout") + refund, err := s.CreateRefund(context.Background(), order.ID, "admin", "key1", "客户申请", 5000) + if err != nil || refund.Status != RefundPending || repo.records != 0 { + t.Fatalf("timeout not pending: %+v %v", refund, err) + } + again, err := s.CreateRefund(context.Background(), order.ID, "admin", "key1", "客户申请", 5000) + if err != nil || again.ID != refund.ID { + t.Fatalf("retry id changed: %+v %v", again, err) + } + provider.createErr = nil + provider.queryErr = ErrRemoteNotFound + current := repo.refunds[refund.ID] + current.UpdatedAt = time.Now().Add(-time.Minute) + repo.refunds[refund.ID] = current + if err := s.Reconcile(context.Background()); err != nil { + t.Fatal(err) + } + for _, id := range provider.createIDs { + if id != refund.ID { + t.Fatalf("new remote refund ID: %q", id) + } + } + if repo.refunds[refund.ID].Status != RefundProcessing || repo.records != 0 { + t.Fatalf("unexpected reconciled state: %+v", repo.refunds[refund.ID]) + } +} + +func TestRefundValidationAndMerchantRotation(t *testing.T) { + s, repo, provider, order := newRefundService() + for _, amount := range []int64{0, -1, 10001} { + if _, err := s.CreateRefund(context.Background(), order.ID, "admin", "key", "原因", amount); err == nil { + t.Fatalf("accepted amount %d", amount) + } + } + if _, err := s.CreateRefund(context.Background(), order.ID, "admin", "key", " ", 100); !errors.Is(err, ErrInvalidAmount) { + t.Fatal(err) + } + if len(provider.createIDs) != 0 { + t.Fatal("provider called before validation") + } + order.MchID = "old-merchant" + repo.orders[order.ID] = order + if _, err := s.CreateRefund(context.Background(), order.ID, "admin", "key", "原因", 100); !errors.Is(err, ErrConflict) { + t.Fatal(err) + } +} + +func TestDefinitiveRefundRejectionReleasesPendingAndDoesNotRetry(t *testing.T) { + s, repo, provider, order := newRefundService() + provider.createErr = &RefundRejectionError{Reason: "商户退款资金不足"} + refund, err := s.CreateRefund(context.Background(), order.ID, "admin", "key1", "客户申请", 5000) + if err != nil || refund.Status != RefundRejected || refund.FailureReason != "商户退款资金不足" || repo.records != 0 { + t.Fatalf("definitive rejection: %+v %v", refund, err) + } + again, err := s.CreateRefund(context.Background(), order.ID, "admin", "key1", "客户申请", 5000) + if err != nil || again.ID != refund.ID || len(provider.createIDs) != 1 { + t.Fatalf("rejected request retried remotely: %+v %v %v", again, err, provider.createIDs) + } +} diff --git a/backend/internal/payments/service.go b/backend/internal/payments/service.go new file mode 100644 index 0000000..90f9bf9 --- /dev/null +++ b/backend/internal/payments/service.go @@ -0,0 +1,236 @@ +package payments + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "net/http" + "regexp" + "strings" + "time" +) + +const queryInterval = 5 * time.Second + +var requestKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]{1,64}$`) + +type Service struct { + repo Repository + provider Provider + config Config + now func() time.Time +} + +func NewService(repo Repository, provider Provider, config Config) *Service { + if config.MinAmountFen == 0 { + config.MinAmountFen = 100 + } + if config.MaxAmountFen == 0 { + config.MaxAmountFen = 1000000 + } + if config.OrderTTL == 0 { + config.OrderTTL = 15 * time.Minute + } + if config.PresetAmountsFen == nil { + config.PresetAmountsFen = []int64{10000, 50000, 100000} + } + return &Service{repo: repo, provider: provider, config: config, now: time.Now} +} + +func (s *Service) Options() Options { + presets := make([]int64, len(s.config.PresetAmountsFen)) + copy(presets, s.config.PresetAmountsFen) + return Options{Enabled: s.config.Enabled, CanRecharge: s.config.Enabled && s.repo != nil && s.provider != nil, + PresetAmountsFen: presets, MinAmountFen: s.config.MinAmountFen, MaxAmountFen: s.config.MaxAmountFen} +} + +func (s *Service) Create(ctx context.Context, organizationID, accountID, requestKey string, amountFen int64) (Order, error) { + if !s.Options().CanRecharge { + return Order{}, ErrUnavailable + } + if organizationID == "" || accountID == "" || !requestKeyPattern.MatchString(requestKey) || amountFen < s.config.MinAmountFen || amountFen > s.config.MaxAmountFen { + return Order{}, ErrInvalidAmount + } + id, err := newOrderID() + if err != nil { + return Order{}, err + } + now := s.now().UTC() + wanted := Order{ID: id, OrganizationID: organizationID, AccountID: accountID, RequestKey: requestKey, + MchID: s.config.MchID, AppID: s.config.AppID, AmountFen: amountFen, Currency: "CNY", Status: StatusPending, + CreatedAt: now, UpdatedAt: now, ExpiresAt: now.Add(s.config.OrderTTL)} + order, err := s.repo.CreatePaymentOrder(ctx, wanted) + if err != nil { + return Order{}, err + } + if order.OrganizationID != organizationID || order.AccountID != accountID || order.RequestKey != requestKey || order.AmountFen != amountFen || order.MchID != s.config.MchID || order.AppID != s.config.AppID || order.Currency != "CNY" { + return Order{}, ErrConflict + } + if order.Status != StatusPending || order.CodeURL != "" { + return order, nil + } + if !s.now().Before(order.ExpiresAt) { + return s.refresh(ctx, order, true) + } + codeURL, err := s.provider.Create(ctx, order) + if err != nil { + return order, err + } + if !validCodeURL(codeURL) { + return order, ErrVerification + } + return s.repo.UpdatePaymentOrder(ctx, order.ID, StatusPending, codeURL) +} + +func (s *Service) Get(ctx context.Context, organizationID, id string) (Order, error) { + if s.repo == nil { + return Order{}, ErrUnavailable + } + order, err := s.repo.GetPaymentOrder(ctx, id) + if err != nil { + return Order{}, err + } + if order.OrganizationID != organizationID { + return Order{}, ErrNotFound + } + if order.Status != StatusPending || s.provider == nil || s.now().Sub(order.UpdatedAt) < queryInterval { + return order, nil + } + refreshed, err := s.refresh(ctx, order, true) + if err != nil { + return order, nil + } // Polling remains available during a provider outage. + return refreshed, nil +} + +func (s *Service) List(ctx context.Context, organizationID string) ([]Order, error) { + if s.repo == nil { + return nil, ErrUnavailable + } + return s.repo.ListPaymentOrders(ctx, organizationID, 100) +} + +func (s *Service) Notify(ctx context.Context, request *http.Request) error { + if s.repo == nil || s.provider == nil { + return ErrUnavailable + } + txn, err := s.provider.ParseNotification(ctx, request) + if err != nil { + return err + } + _, err = s.settle(ctx, txn) + return err +} + +func (s *Service) Reconcile(ctx context.Context) error { + if s.repo == nil || s.provider == nil { + return ErrUnavailable + } + // Payment and refund queues must each get a chance within the application's + // bounded reconciliation context, even when the other provider API is slow. + errorsCh := make(chan error, 1) + go func() { errorsCh <- s.reconcileRefunds(ctx) }() + paymentErr := s.reconcilePayments(ctx) + refundErr := <-errorsCh + return errors.Join(paymentErr, refundErr) +} + +func (s *Service) reconcilePayments(ctx context.Context) error { + orders, err := s.repo.PendingPaymentOrders(ctx, 100) + if err != nil { + return err + } + var failures []error + for _, order := range orders { + if ctx.Err() != nil { + return ctx.Err() + } + if s.now().Sub(order.UpdatedAt) < queryInterval { + continue + } + if _, err := s.refresh(ctx, order, true); err != nil { + failures = append(failures, fmt.Errorf("order %s: %w", order.ID, err)) + } + } + return errors.Join(failures...) +} + +func (s *Service) refresh(ctx context.Context, order Order, expire bool) (Order, error) { + // Advance the oldest-pending cursor before any remote call. Failed queries, + // prepay retries and close attempts must not starve newer paid orders. + current, err := s.repo.UpdatePaymentOrder(ctx, order.ID, StatusPending, "") + if err != nil { + return order, err + } + if current.Status != StatusPending { + return current, nil + } + order = current + txn, err := s.provider.Query(ctx, order.ID) + if err != nil && !errors.Is(err, ErrRemoteNotFound) { + return order, err + } + if err == nil { + if txn.OrderID != order.ID || txn.MchID != order.MchID || txn.AppID != order.AppID || + (txn.AmountFen != 0 && txn.AmountFen != order.AmountFen) || + (txn.Currency != "" && txn.Currency != order.Currency) { + return order, ErrVerification + } + if txn.TradeState == "SUCCESS" { + return s.settle(ctx, txn) + } + if txn.TradeState == "CLOSED" || txn.TradeState == "REVOKED" || txn.TradeState == "PAYERROR" { + return s.repo.UpdatePaymentOrder(ctx, order.ID, StatusClosed, "") + } + if txn.TradeState != "NOTPAY" && txn.TradeState != "USERPAYING" { + return order, ErrVerification + } + } + if expire && !s.now().Before(order.ExpiresAt) { + if err == nil { + if err = s.provider.Close(ctx, order.ID); err != nil { + return order, err + } + } + return s.repo.UpdatePaymentOrder(ctx, order.ID, StatusClosed, "") + } + if order.CodeURL == "" && (errors.Is(err, ErrRemoteNotFound) || (err == nil && txn.TradeState == "NOTPAY")) { + codeURL, createErr := s.provider.Create(ctx, order) + if createErr != nil { + return order, createErr + } + if !validCodeURL(codeURL) { + return order, ErrVerification + } + return s.repo.UpdatePaymentOrder(ctx, order.ID, StatusPending, codeURL) + } + return order, nil +} + +func (s *Service) settle(ctx context.Context, txn Transaction) (Order, error) { + if txn.OrderID == "" || txn.TransactionID == "" || txn.TradeState != "SUCCESS" || txn.MchID != s.config.MchID || txn.AppID != s.config.AppID || txn.Currency != "CNY" || txn.AmountFen <= 0 { + return Order{}, ErrVerification + } + order, err := s.repo.GetPaymentOrder(ctx, txn.OrderID) + if err != nil { + return Order{}, err + } + if txn.AmountFen != order.AmountFen || txn.MchID != order.MchID || txn.AppID != order.AppID || txn.Currency != order.Currency { + return Order{}, ErrVerification + } + return s.repo.SettlePaymentOrder(ctx, txn) +} + +func newOrderID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", err + } + return hex.EncodeToString(b[:]), nil +} + +func validCodeURL(url string) bool { + return strings.HasPrefix(url, "weixin://wxpay/bizpayurl?") && len(url) <= 2048 && !strings.ContainsAny(url, "\r\n") +} diff --git a/backend/internal/payments/service_test.go b/backend/internal/payments/service_test.go new file mode 100644 index 0000000..852be84 --- /dev/null +++ b/backend/internal/payments/service_test.go @@ -0,0 +1,298 @@ +package payments + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + "time" +) + +func TestOptionsExplicitEmptyPresetsMarshalAsArray(t *testing.T) { + s := NewService(nil, nil, Config{PresetAmountsFen: []int64{}, MinAmountFen: 500, MaxAmountFen: 900}) + options := s.Options() + if options.PresetAmountsFen == nil || options.MinAmountFen != 500 || options.MaxAmountFen != 900 { + t.Fatalf("unexpected options: %+v", options) + } + body, err := json.Marshal(options) + if err != nil { + t.Fatal(err) + } + var decoded map[string]any + if err := json.Unmarshal(body, &decoded); err != nil { + t.Fatal(err) + } + if presets, ok := decoded["presetAmountsFen"].([]any); !ok || len(presets) != 0 { + t.Fatalf("expected JSON empty array: %s", body) + } +} + +type testRepo struct { + orders map[string]Order + byKey map[string]string + settles int +} + +func (r *testRepo) CreatePaymentOrder(_ context.Context, o Order) (Order, error) { + if id := r.byKey[o.RequestKey]; id != "" { + return r.orders[id], nil + } + r.orders[o.ID] = o + r.byKey[o.RequestKey] = o.ID + return o, nil +} +func (r *testRepo) GetPaymentOrder(_ context.Context, id string) (Order, error) { + o, ok := r.orders[id] + if !ok { + return Order{}, ErrNotFound + } + return o, nil +} +func (r *testRepo) ListPaymentOrders(_ context.Context, org string, _ int) ([]Order, error) { + var out []Order + for _, o := range r.orders { + if o.OrganizationID == org { + out = append(out, o) + } + } + return out, nil +} +func (r *testRepo) PendingPaymentOrders(_ context.Context, _ int) ([]Order, error) { + var out []Order + for _, o := range r.orders { + if o.Status == StatusPending { + out = append(out, o) + } + } + return out, nil +} +func (r *testRepo) UpdatePaymentOrder(_ context.Context, id, status, url string) (Order, error) { + o := r.orders[id] + if o.Status == StatusPaid { + return o, nil + } + o.Status = status + if url != "" { + o.CodeURL = url + } + o.UpdatedAt = time.Now().UTC() + r.orders[id] = o + return o, nil +} +func (r *testRepo) SettlePaymentOrder(_ context.Context, txn Transaction) (Order, error) { + o := r.orders[txn.OrderID] + if o.AmountFen != txn.AmountFen { + return Order{}, ErrVerification + } + if o.Status != StatusPaid { + r.settles++ + } + o.Status = StatusPaid + o.TransactionID = txn.TransactionID + r.orders[o.ID] = o + return o, nil +} + +type testProvider struct { + txn Transaction + queryErr error + closeErr error + lastCreateID string + creates, queries, closes int +} + +func (p *testProvider) Create(_ context.Context, o Order) (string, error) { + p.creates++ + p.lastCreateID = o.ID + return "weixin://wxpay/bizpayurl?pr=abc", nil +} +func (p *testProvider) Query(_ context.Context, _ string) (Transaction, error) { + p.queries++ + return p.txn, p.queryErr +} +func (p *testProvider) Close(_ context.Context, _ string) error { p.closes++; return p.closeErr } +func (p *testProvider) ParseNotification(_ context.Context, _ *http.Request) (Transaction, error) { + return p.txn, nil +} + +func newTestService() (*Service, *testRepo, *testProvider) { + r := &testRepo{orders: map[string]Order{}, byKey: map[string]string{}} + p := &testProvider{} + s := NewService(r, p, Config{Enabled: true, MchID: "mch", AppID: "app"}) + return s, r, p +} + +func TestCreateRetryAndConflict(t *testing.T) { + s, _, p := newTestService() + ctx := context.Background() + a, err := s.Create(ctx, "org", "account", "request", 10000) + if err != nil { + t.Fatal(err) + } + b, err := s.Create(ctx, "org", "account", "request", 10000) + if err != nil { + t.Fatal(err) + } + if a.ID != b.ID || p.creates != 1 { + t.Fatalf("idempotency failed: %q %q creates=%d", a.ID, b.ID, p.creates) + } + if _, err := s.Create(ctx, "org", "account", "request", 20000); !errors.Is(err, ErrConflict) { + t.Fatalf("want conflict: %v", err) + } +} + +func TestNotifyRejectsMismatchedAmountAndSettlesOnce(t *testing.T) { + s, r, p := newTestService() + ctx := context.Background() + o, err := s.Create(ctx, "org", "account", "request", 10000) + if err != nil { + t.Fatal(err) + } + p.txn = Transaction{OrderID: o.ID, MchID: "mch", AppID: "app", TransactionID: "wx1", TradeState: "SUCCESS", Currency: "CNY", AmountFen: 9999} + if err := s.Notify(ctx, &http.Request{}); !errors.Is(err, ErrVerification) || r.settles != 0 { + t.Fatalf("mismatch settled: %v, %d", err, r.settles) + } + p.txn.AmountFen = 10000 + if err := s.Notify(ctx, &http.Request{}); err != nil { + t.Fatal(err) + } + if err := s.Notify(ctx, &http.Request{}); err != nil { + t.Fatal(err) + } + if r.settles != 1 { + t.Fatalf("duplicate credit: %d", r.settles) + } +} + +func TestReconcileQueriesBeforeClose(t *testing.T) { + s, r, p := newTestService() + ctx := context.Background() + o, err := s.Create(ctx, "org", "account", "request", 10000) + if err != nil { + t.Fatal(err) + } + o.ExpiresAt = time.Now().Add(-time.Minute) + o.UpdatedAt = time.Now().Add(-time.Minute) + r.orders[o.ID] = o + p.txn = Transaction{OrderID: o.ID, MchID: "mch", AppID: "app", TransactionID: "wx1", TradeState: "SUCCESS", Currency: "CNY", AmountFen: 10000} + if err := s.Reconcile(ctx); err != nil { + t.Fatal(err) + } + if r.settles != 1 || p.closes != 0 { + t.Fatalf("paid order closed: settles=%d closes=%d", r.settles, p.closes) + } +} + +func TestReconcileClosesOnlyAfterConfirmedUnpaid(t *testing.T) { + s, r, p := newTestService() + ctx := context.Background() + o, err := s.Create(ctx, "org", "account", "request", 10000) + if err != nil { + t.Fatal(err) + } + o.ExpiresAt = time.Now().Add(-time.Minute) + o.UpdatedAt = time.Now().Add(-time.Minute) + r.orders[o.ID] = o + p.queryErr = errors.New("gateway unavailable") + if err := s.Reconcile(ctx); err == nil { + t.Fatal("expected query error") + } + if r.orders[o.ID].Status != StatusPending || p.closes != 0 || r.settles != 0 { + t.Fatal("query outage changed payment state") + } + o = r.orders[o.ID] + o.UpdatedAt = time.Now().Add(-time.Minute) + r.orders[o.ID] = o + p.queryErr = nil + p.txn = Transaction{OrderID: o.ID, MchID: "mch", AppID: "app", TradeState: "NOTPAY", Currency: "CNY", AmountFen: 10000} + if err := s.Reconcile(ctx); err != nil { + t.Fatal(err) + } + if r.orders[o.ID].Status != StatusClosed || p.closes != 1 || r.settles != 0 { + t.Fatal("unpaid order was not safely closed") + } +} + +func TestRecoverMissingCodeURLFromRemoteUnpaid(t *testing.T) { + s, r, p := newTestService() + ctx := context.Background() + o, err := s.Create(ctx, "org", "account", "request", 10000) + if err != nil { + t.Fatal(err) + } + o.CodeURL = "" // Prepay succeeded, but local URL persistence was lost. + o.UpdatedAt = time.Now().Add(-time.Minute) + r.orders[o.ID] = o + p.txn = Transaction{OrderID: o.ID, MchID: "mch", AppID: "app", TradeState: "NOTPAY", Currency: "CNY", AmountFen: 10000} + got, err := s.Get(ctx, "org", o.ID) + if err != nil { + t.Fatal(err) + } + if got.ID != o.ID || got.CodeURL == "" || p.lastCreateID != o.ID || p.creates != 2 { + t.Fatalf("same-order prepay recovery failed: %+v lastCreateID=%s creates=%d", got, p.lastCreateID, p.creates) + } +} + +func TestFailedCloseAdvancesReconcileCursor(t *testing.T) { + s, r, p := newTestService() + ctx := context.Background() + o, err := s.Create(ctx, "org", "account", "request", 10000) + if err != nil { + t.Fatal(err) + } + o.ExpiresAt = time.Now().Add(-time.Minute) + o.UpdatedAt = time.Now().Add(-time.Minute) + r.orders[o.ID] = o + p.txn = Transaction{OrderID: o.ID, MchID: "mch", AppID: "app", TradeState: "NOTPAY", Currency: "CNY", AmountFen: 10000} + p.closeErr = errors.New("close unavailable") + before := o.UpdatedAt + if err := s.Reconcile(ctx); err == nil { + t.Fatal("expected close failure") + } + got := r.orders[o.ID] + if got.Status != StatusPending || !got.UpdatedAt.After(before) || p.closes != 1 { + t.Fatalf("failed close did not advance cursor: %+v closes=%d", got, p.closes) + } + if err := s.Reconcile(ctx); err != nil { + t.Fatal(err) + } + if p.closes != 1 { + t.Fatal("retry ignored query throttle") + } +} + +func TestGetScopesBeforeQuery(t *testing.T) { + s, r, p := newTestService() + ctx := context.Background() + o, err := s.Create(ctx, "org", "account", "request", 10000) + if err != nil { + t.Fatal(err) + } + o.UpdatedAt = time.Now().Add(-time.Minute) + r.orders[o.ID] = o + if _, err := s.Get(ctx, "other", o.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("scope: %v", err) + } + if p.queries != 0 { + t.Fatal("queried before authorization") + } +} + +func TestQueryCannotSettleDifferentOrder(t *testing.T) { + s, r, p := newTestService() + ctx := context.Background() + o, err := s.Create(ctx, "org", "account", "request", 10000) + if err != nil { + t.Fatal(err) + } + o.UpdatedAt = time.Now().Add(-time.Minute) + r.orders[o.ID] = o + p.txn = Transaction{OrderID: "other-order", MchID: "mch", AppID: "app", TransactionID: "wx1", TradeState: "SUCCESS", Currency: "CNY", AmountFen: 10000} + if _, err := s.Get(ctx, "org", o.ID); err != nil { + t.Fatal(err) + } + if r.settles != 0 || r.orders[o.ID].Status != StatusPending { + t.Fatal("mismatched query settled payment") + } +} diff --git a/backend/internal/payments/wechat.go b/backend/internal/payments/wechat.go new file mode 100644 index 0000000..e1fe66f --- /dev/null +++ b/backend/internal/payments/wechat.go @@ -0,0 +1,136 @@ +package payments + +import ( + "context" + "errors" + "fmt" + "net/http" + "time" + + "github.com/wechatpay-apiv3/wechatpay-go/core" + "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" + "github.com/wechatpay-apiv3/wechatpay-go/core/notify" + "github.com/wechatpay-apiv3/wechatpay-go/core/option" + wechatpayments "github.com/wechatpay-apiv3/wechatpay-go/services/payments" + "github.com/wechatpay-apiv3/wechatpay-go/services/payments/native" + "github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic" + "github.com/wechatpay-apiv3/wechatpay-go/utils" +) + +type wechatProvider struct { + service native.NativeApiService + refunds refunddomestic.RefundsApiService + notifier *notify.Handler + config Config +} + +func NewWeChatProvider(config Config) (Provider, error) { + if config.MchID == "" || config.AppID == "" || config.NotifyURL == "" || config.MerchantSerial == "" || config.APIv3Key == "" || config.WechatPublicKeyID == "" || config.PrivateKeyPEM == "" || config.WechatPublicKeyPEM == "" { + return nil, fmt.Errorf("wechat payment configuration incomplete") + } + if len(config.APIv3Key) != 32 { + return nil, fmt.Errorf("APIv3 key must be 32 bytes") + } + privateKey, err := utils.LoadPrivateKey(config.PrivateKeyPEM) + if err != nil { + return nil, fmt.Errorf("merchant private key: %w", err) + } + publicKey, err := utils.LoadPublicKey(config.WechatPublicKeyPEM) + if err != nil { + return nil, fmt.Errorf("wechat public key: %w", err) + } + client, err := core.NewClient(context.Background(), + option.WithWechatPayPublicKeyAuthCipher(config.MchID, config.MerchantSerial, privateKey, config.WechatPublicKeyID, publicKey), + option.WithHTTPClient(&http.Client{Timeout: 10 * time.Second})) + if err != nil { + return nil, fmt.Errorf("wechat client: %w", err) + } + return &wechatProvider{service: native.NativeApiService{Client: client}, refunds: refunddomestic.RefundsApiService{Client: client}, + notifier: notify.NewNotifyHandler(config.APIv3Key, verifiers.NewSHA256WithRSAPubkeyVerifier(config.WechatPublicKeyID, *publicKey)), + config: config}, nil +} + +func (p *wechatProvider) Create(ctx context.Context, order Order) (string, error) { + ctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + resp, _, err := p.service.Prepay(ctx, native.PrepayRequest{ + Appid: &p.config.AppID, Mchid: &p.config.MchID, Description: ptr("智念AIGC平台使用额度"), OutTradeNo: &order.ID, + TimeExpire: &order.ExpiresAt, NotifyUrl: &p.config.NotifyURL, + Amount: &native.Amount{Total: &order.AmountFen, Currency: ptr("CNY")}, + }) + if err != nil { + return "", err + } + if resp == nil || resp.CodeUrl == nil || !validCodeURL(*resp.CodeUrl) { + return "", ErrVerification + } + return *resp.CodeUrl, nil +} + +func (p *wechatProvider) Query(ctx context.Context, id string) (Transaction, error) { + ctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + resp, _, err := p.service.QueryOrderByOutTradeNo(ctx, native.QueryOrderByOutTradeNoRequest{OutTradeNo: &id, Mchid: &p.config.MchID}) + if err != nil { + var apiErr *core.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + return Transaction{}, ErrRemoteNotFound + } + return Transaction{}, err + } + return transactionFromSDK(resp) +} + +func (p *wechatProvider) Close(ctx context.Context, id string) error { + ctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + _, err := p.service.CloseOrder(ctx, native.CloseOrderRequest{OutTradeNo: &id, Mchid: &p.config.MchID}) + return err +} + +func (p *wechatProvider) ParseNotification(ctx context.Context, request *http.Request) (Transaction, error) { + if request == nil || request.Body == nil { + return Transaction{}, ErrVerification + } + request.Body = http.MaxBytesReader(nil, request.Body, 128*1024) + var sdkTxn wechatpayments.Transaction + n, err := p.notifier.ParseNotifyRequest(ctx, request, &sdkTxn) + if err != nil { + return Transaction{}, fmt.Errorf("%w: %v", ErrVerification, err) + } + if n.EventType != "TRANSACTION.SUCCESS" || n.ResourceType != "encrypt-resource" { + return Transaction{}, ErrVerification + } + return transactionFromSDK(&sdkTxn) +} + +func transactionFromSDK(t *wechatpayments.Transaction) (Transaction, error) { + if t == nil || t.OutTradeNo == nil || t.TradeState == nil || t.Mchid == nil || t.Appid == nil { + return Transaction{}, ErrVerification + } + txn := Transaction{OrderID: *t.OutTradeNo, MchID: *t.Mchid, AppID: *t.Appid, TradeState: *t.TradeState} + if t.TransactionId != nil { + txn.TransactionID = *t.TransactionId + } + if t.Amount != nil { + if t.Amount.Total != nil { + txn.AmountFen = *t.Amount.Total + } + if t.Amount.Currency != nil { + txn.Currency = *t.Amount.Currency + } + } + if txn.TradeState == "SUCCESS" { + if txn.TransactionID == "" || txn.AmountFen <= 0 || txn.Currency != "CNY" || t.SuccessTime == nil { + return Transaction{}, ErrVerification + } + parsed, err := time.Parse(time.RFC3339, *t.SuccessTime) + if err != nil { + return Transaction{}, ErrVerification + } + txn.SuccessTime = parsed + } + return txn, nil +} + +func ptr(s string) *string { return &s } diff --git a/backend/internal/payments/wechat_refunds.go b/backend/internal/payments/wechat_refunds.go new file mode 100644 index 0000000..60913a0 --- /dev/null +++ b/backend/internal/payments/wechat_refunds.go @@ -0,0 +1,185 @@ +package payments + +import ( + "context" + "errors" + "fmt" + "net/http" + "net/url" + "time" + + "github.com/wechatpay-apiv3/wechatpay-go/core" + "github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic" +) + +func (p *wechatProvider) CreateRefund(ctx context.Context, refund Refund) (RefundTransaction, error) { + if refund.MchID != p.config.MchID || refund.TransactionID == "" || refund.ID == "" || refund.AmountFen <= 0 || refund.TotalFen < refund.AmountFen || refund.Currency != "CNY" { + return RefundTransaction{}, ErrVerification + } + ctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + notifyURL, err := refundNotifyURL(p.config.NotifyURL) + if err != nil { + return RefundTransaction{}, err + } + response, _, err := p.refunds.Create(ctx, refunddomestic.CreateRequest{ + TransactionId: &refund.TransactionID, + OutRefundNo: &refund.ID, + Reason: &refund.Reason, + NotifyUrl: ¬ifyURL, + Amount: &refunddomestic.AmountReq{Refund: &refund.AmountFen, Total: &refund.TotalFen, Currency: &refund.Currency}, + }) + if err != nil { + if rejection := classifyRefundRejection(err); rejection != nil { + return RefundTransaction{}, rejection + } + return RefundTransaction{}, err + } + return refundFromSDK(response, p.config.MchID) +} + +// Only the documented request-level failures can be treated as a definitive +// rejection. Rate limits, provider 5xx, transport and verification errors may +// hide an accepted refund and must keep the same out_refund_no reserved. +type RefundRejectionError struct{ Reason string } + +func (e *RefundRejectionError) Error() string { return e.Reason } + +func classifyRefundRejection(err error) *RefundRejectionError { + var apiErr *core.APIError + if !errors.As(err, &apiErr) { + return nil + } + var reason string + switch { + case apiErr.StatusCode == 400 && (apiErr.Code == "PARAM_ERROR" || apiErr.Code == "INVALID_REQUEST"): + reason = "微信支付拒绝退款参数或业务规则" + case apiErr.StatusCode == 401 && apiErr.Code == "SIGN_ERROR": + reason = "微信支付签名验证失败" + case apiErr.StatusCode == 403 && apiErr.Code == "NOT_ENOUGH": + reason = "商户退款资金不足" + case apiErr.StatusCode == 403 && apiErr.Code == "USER_ACCOUNT_ABNORMAL": + reason = "微信支付账户异常,退款申请被拒绝" + case apiErr.StatusCode == 404 && apiErr.Code == "MCH_NOT_EXISTS": + reason = "微信支付商户号不存在" + case apiErr.StatusCode == 404 && apiErr.Code == "RESOURCE_NOT_EXISTS": + reason = "微信支付原订单不存在" + default: + return nil + } + return &RefundRejectionError{Reason: reason} +} + +func (p *wechatProvider) QueryRefund(ctx context.Context, id string) (RefundTransaction, error) { + ctx, cancel := context.WithTimeout(ctx, 12*time.Second) + defer cancel() + response, _, err := p.refunds.QueryByOutRefundNo(ctx, refunddomestic.QueryByOutRefundNoRequest{OutRefundNo: &id}) + if err != nil { + var apiErr *core.APIError + if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { + return RefundTransaction{}, ErrRemoteNotFound + } + return RefundTransaction{}, err + } + return refundFromSDK(response, p.config.MchID) +} + +func (p *wechatProvider) ParseRefundNotification(ctx context.Context, request *http.Request) (RefundTransaction, error) { + if request == nil || request.Body == nil { + return RefundTransaction{}, ErrVerification + } + request.Body = http.MaxBytesReader(nil, request.Body, 128*1024) + var payload struct { + MchID string `json:"mchid"` + RefundID string `json:"refund_id"` + OutRefundNo string `json:"out_refund_no"` + TransactionID string `json:"transaction_id"` + OutTradeNo string `json:"out_trade_no"` + RefundStatus string `json:"refund_status"` + SuccessTime string `json:"success_time"` + Amount struct { + Total int64 `json:"total"` + Refund int64 `json:"refund"` + Currency string `json:"currency"` + } `json:"amount"` + } + notification, err := p.notifier.ParseNotifyRequest(ctx, request, &payload) + if err != nil { + return RefundTransaction{}, fmt.Errorf("%w: %v", ErrVerification, err) + } + if notification == nil || notification.ResourceType != "encrypt-resource" { + return RefundTransaction{}, ErrVerification + } + var status string + switch notification.EventType { + case "REFUND.SUCCESS": + status = RefundSuccess + case "REFUND.CLOSED": + status = RefundClosed + case "REFUND.ABNORMAL": + status = RefundAbnormal + default: + return RefundTransaction{}, ErrVerification + } + // The refund notification schema omits currency; this ordinary-merchant flow is CNY only. + if payload.RefundStatus != notification.EventType[len("REFUND."):] || payload.MchID == "" || payload.RefundID == "" || payload.OutRefundNo == "" || payload.OutTradeNo == "" || payload.TransactionID == "" || payload.Amount.Total <= 0 || payload.Amount.Refund <= 0 || (payload.Amount.Currency != "" && payload.Amount.Currency != "CNY") { + return RefundTransaction{}, ErrVerification + } + txn := RefundTransaction{ID: payload.OutRefundNo, OrderID: payload.OutTradeNo, MchID: payload.MchID, + TransactionID: payload.TransactionID, ProviderID: payload.RefundID, Status: status, + AmountFen: payload.Amount.Refund, TotalFen: payload.Amount.Total, Currency: "CNY"} + if status == RefundSuccess { + if payload.SuccessTime == "" { + return RefundTransaction{}, ErrVerification + } + txn.SuccessTime, err = time.Parse(time.RFC3339, payload.SuccessTime) + if err != nil { + return RefundTransaction{}, ErrVerification + } + } + return txn, nil +} + +func refundFromSDK(r *refunddomestic.Refund, mchID string) (RefundTransaction, error) { + if r == nil || r.RefundId == nil || r.OutRefundNo == nil || r.TransactionId == nil || r.OutTradeNo == nil || r.Status == nil || r.Amount == nil || r.Amount.Refund == nil || r.Amount.Total == nil || r.Amount.Currency == nil { + return RefundTransaction{}, ErrVerification + } + var status string + switch string(*r.Status) { + case "PROCESSING": + status = RefundProcessing + case "SUCCESS": + status = RefundSuccess + case "CLOSED": + status = RefundClosed + case "ABNORMAL": + status = RefundAbnormal + default: + return RefundTransaction{}, ErrVerification + } + txn := RefundTransaction{ID: *r.OutRefundNo, OrderID: *r.OutTradeNo, MchID: mchID, + TransactionID: *r.TransactionId, ProviderID: *r.RefundId, Status: status, + AmountFen: *r.Amount.Refund, TotalFen: *r.Amount.Total, Currency: *r.Amount.Currency} + if txn.ID == "" || txn.OrderID == "" || txn.TransactionID == "" || txn.ProviderID == "" || txn.TotalFen <= 0 || txn.AmountFen <= 0 || txn.Currency != "CNY" { + return RefundTransaction{}, ErrVerification + } + if status == RefundSuccess { + if r.SuccessTime == nil || r.SuccessTime.IsZero() { + return RefundTransaction{}, ErrVerification + } + txn.SuccessTime = *r.SuccessTime + } + return txn, nil +} + +func refundNotifyURL(paymentNotifyURL string) (string, error) { + u, err := url.Parse(paymentNotifyURL) + if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil { + return "", ErrVerification + } + u.Path = "/api/payments/wechat/refund-notify" + u.RawPath = "" + u.RawQuery = "" + u.Fragment = "" + return u.String(), nil +} diff --git a/backend/internal/payments/wechat_refunds_test.go b/backend/internal/payments/wechat_refunds_test.go new file mode 100644 index 0000000..770d23a --- /dev/null +++ b/backend/internal/payments/wechat_refunds_test.go @@ -0,0 +1,138 @@ +package payments + +import ( + "bytes" + "context" + "crypto" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/wechatpay-apiv3/wechatpay-go/core" + "github.com/wechatpay-apiv3/wechatpay-go/core/auth/verifiers" + "github.com/wechatpay-apiv3/wechatpay-go/core/notify" + "github.com/wechatpay-apiv3/wechatpay-go/services/refunddomestic" +) + +func TestRefundFromSDKRequiresVerifiedFieldsAndSuccessTime(t *testing.T) { + status := refunddomestic.Status("SUCCESS") + refundID, outRefundNo, transactionID, outTradeNo := "wx-refund", "local-refund", "wx-payment", "local-payment" + amount, total, currency := int64(500), int64(1000), "CNY" + response := &refunddomestic.Refund{RefundId: &refundID, OutRefundNo: &outRefundNo, + TransactionId: &transactionID, OutTradeNo: &outTradeNo, Status: &status, + Amount: &refunddomestic.Amount{Refund: &amount, Total: &total, Currency: ¤cy}} + if _, err := refundFromSDK(response, "mch"); !errors.Is(err, ErrVerification) { + t.Fatal("accepted SUCCESS without success_time", err) + } + now := time.Now() + response.SuccessTime = &now + txn, err := refundFromSDK(response, "mch") + if err != nil || txn.Status != RefundSuccess || txn.MchID != "mch" || txn.AmountFen != 500 { + t.Fatalf("mapping failed: %+v %v", txn, err) + } + currency = "USD" + if _, err := refundFromSDK(response, "mch"); !errors.Is(err, ErrVerification) { + t.Fatal("accepted non-CNY refund", err) + } +} + +func TestRefundNotifyURLUsesDedicatedPath(t *testing.T) { + got, err := refundNotifyURL("https://nianxxaigc.nianxx.cn/api/payments/wechat/notify") + if err != nil || got != "https://nianxxaigc.nianxx.cn/api/payments/wechat/refund-notify" { + t.Fatalf("unexpected URL: %q %v", got, err) + } + if _, err := refundNotifyURL("http://example.com/notify"); !errors.Is(err, ErrVerification) { + t.Fatal("accepted HTTP notify", err) + } +} + +func TestOnlyDocumentedDefinitiveRefundErrorsAreRejected(t *testing.T) { + cases := []struct { + status int + code string + rejected bool + }{ + {400, "PARAM_ERROR", true}, {400, "INVALID_REQUEST", true}, + {401, "SIGN_ERROR", true}, {403, "NOT_ENOUGH", true}, + {403, "USER_ACCOUNT_ABNORMAL", true}, {404, "MCH_NOT_EXISTS", true}, + {404, "RESOURCE_NOT_EXISTS", true}, {429, "FREQUENCY_LIMITED", false}, + {500, "SYSTEM_ERROR", false}, {403, "UNKNOWN", false}, + } + for _, tc := range cases { + got := classifyRefundRejection(&core.APIError{StatusCode: tc.status, Code: tc.code, Message: "sensitive raw message"}) + if (got != nil) != tc.rejected { + t.Fatalf("%d %s rejected=%v", tc.status, tc.code, got != nil) + } + if got != nil && got.Reason == "sensitive raw message" { + t.Fatal("raw provider message exposed") + } + } + if got := classifyRefundRejection(errors.New("network timeout")); got != nil { + t.Fatal("network error rejected") + } +} + +func TestRefundNotificationVerifiesSignatureAndDecrypts(t *testing.T) { + privateKey, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + const apiKey = "12345678901234567890123456789012" + const keyID = "PUB_KEY_ID_test" + provider := &wechatProvider{notifier: notify.NewNotifyHandler(apiKey, + verifiers.NewSHA256WithRSAPubkeyVerifier(keyID, privateKey.PublicKey))} + block, err := aes.NewCipher([]byte(apiKey)) + if err != nil { + t.Fatal(err) + } + aead, err := cipher.NewGCM(block) + if err != nil { + t.Fatal(err) + } + nonce := []byte("123456789012") + plaintext := []byte(`{"mchid":"mch","out_trade_no":"order","transaction_id":"wx-pay","out_refund_no":"refund","refund_id":"wx-refund","refund_status":"SUCCESS","success_time":"2026-10-12T10:00:00+08:00","amount":{"total":1000,"refund":500}}`) + resource := map[string]any{"algorithm": "AEAD_AES_256_GCM", "original_type": "refund", "ciphertext": base64.StdEncoding.EncodeToString(aead.Seal(nil, nonce, plaintext, nil)), "associated_data": "", "nonce": string(nonce)} + makeRequest := func(event string) *http.Request { + body, err := json.Marshal(map[string]any{"id": "notice", "event_type": event, "resource_type": "encrypt-resource", "resource": resource}) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body)) + timestamp := strconv.FormatInt(time.Now().Unix(), 10) + const signNonce = "nonce123" + message := fmt.Sprintf("%s\n%s\n%s\n", timestamp, signNonce, body) + digest := sha256.Sum256([]byte(message)) + signature, err := rsa.SignPKCS1v15(rand.Reader, privateKey, crypto.SHA256, digest[:]) + if err != nil { + t.Fatal(err) + } + req.Header.Set("Wechatpay-Serial", keyID) + req.Header.Set("Wechatpay-Timestamp", timestamp) + req.Header.Set("Wechatpay-Nonce", signNonce) + req.Header.Set("Wechatpay-Signature", base64.StdEncoding.EncodeToString(signature)) + return req + } + txn, err := provider.ParseRefundNotification(context.Background(), makeRequest("REFUND.SUCCESS")) + if err != nil || txn.Status != RefundSuccess || txn.Currency != "CNY" || txn.AmountFen != 500 || txn.MchID != "mch" { + t.Fatalf("signed refund notification rejected: %+v %v", txn, err) + } + badSignature := makeRequest("REFUND.SUCCESS") + badSignature.Header.Set("Wechatpay-Signature", "invalid") + if _, err := provider.ParseRefundNotification(context.Background(), badSignature); !errors.Is(err, ErrVerification) { + t.Fatal("accepted unsigned notification", err) + } + if _, err := provider.ParseRefundNotification(context.Background(), makeRequest("REFUND.CLOSED")); !errors.Is(err, ErrVerification) { + t.Fatal("accepted mismatched event and refund_status", err) + } +} diff --git a/backend/internal/payments/wechat_test.go b/backend/internal/payments/wechat_test.go new file mode 100644 index 0000000..d1163ab --- /dev/null +++ b/backend/internal/payments/wechat_test.go @@ -0,0 +1,83 @@ +package payments + +import ( + "bytes" + "context" + "crypto" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/x509" + "encoding/base64" + "encoding/json" + "encoding/pem" + "fmt" + "net/http" + "strconv" + "testing" + "time" +) + +func TestVerifiedNotificationRejectsReplayAndTampering(t *testing.T) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatal(err) + } + pubDER, err := x509.MarshalPKIXPublicKey(&key.PublicKey) + if err != nil { + t.Fatal(err) + } + privDER, err := x509.MarshalPKCS8PrivateKey(key) + if err != nil { + t.Fatal(err) + } + config := Config{MchID: "mch", AppID: "app", NotifyURL: "https://example.com/notify", MerchantSerial: "serial", + PrivateKeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PRIVATE KEY", Bytes: privDER})), + WechatPublicKeyID: "PUB_KEY_ID_123", WechatPublicKeyPEM: string(pem.EncodeToMemory(&pem.Block{Type: "PUBLIC KEY", Bytes: pubDER})), + APIv3Key: "12345678901234567890123456789012"} + provider, err := NewWeChatProvider(config) + if err != nil { + t.Fatal(err) + } + wp := provider.(*wechatProvider) + txn := map[string]any{"out_trade_no": "order1", "mchid": "mch", "appid": "app", "transaction_id": "wx1", + "trade_state": "SUCCESS", "amount": map[string]any{"total": 10000, "currency": "CNY"}, "success_time": time.Now().UTC().Format(time.RFC3339)} + plain, _ := json.Marshal(txn) + block, _ := aes.NewCipher([]byte(config.APIv3Key)) + aead, _ := cipher.NewGCM(block) + nonce := "123456789012" + ciphertext := aead.Seal(nil, []byte(nonce), plain, []byte("associated")) + body, _ := json.Marshal(map[string]any{"id": "event1", "event_type": "TRANSACTION.SUCCESS", "resource_type": "encrypt-resource", + "resource": map[string]any{"algorithm": "AEAD_AES_256_GCM", "ciphertext": base64.StdEncoding.EncodeToString(ciphertext), "associated_data": "associated", "nonce": nonce}}) + makeRequest := func(timestamp int64, tamper bool) *http.Request { + ts := strconv.FormatInt(timestamp, 10) + signed := fmt.Sprintf("%s\n%s\n%s\n", ts, "nonce1", body) + hash := sha256.Sum256([]byte(signed)) + signature, err := rsa.SignPKCS1v15(rand.Reader, key, crypto.SHA256, hash[:]) + if err != nil { + t.Fatal(err) + } + actualBody := body + if tamper { + actualBody = append(bytes.Clone(body), ' ') + } + req, _ := http.NewRequest(http.MethodPost, "https://example.com/notify", bytes.NewReader(actualBody)) + req.Header.Set("Wechatpay-Serial", config.WechatPublicKeyID) + req.Header.Set("Wechatpay-Timestamp", ts) + req.Header.Set("Wechatpay-Nonce", "nonce1") + req.Header.Set("Wechatpay-Signature", base64.StdEncoding.EncodeToString(signature)) + return req + } + valid, err := wp.ParseNotification(context.Background(), makeRequest(time.Now().Unix(), false)) + if err != nil || valid.AmountFen != 10000 || valid.OrderID != "order1" { + t.Fatalf("valid notification: %v %+v", err, valid) + } + if _, err := wp.ParseNotification(context.Background(), makeRequest(time.Now().Add(-10*time.Minute).Unix(), false)); err == nil { + t.Fatal("accepted stale timestamp") + } + if _, err := wp.ParseNotification(context.Background(), makeRequest(time.Now().Unix(), true)); err == nil { + t.Fatal("accepted tampered body") + } +} diff --git a/backend/internal/postgres/database.go b/backend/internal/postgres/database.go index 0cb213c..121a229 100644 --- a/backend/internal/postgres/database.go +++ b/backend/internal/postgres/database.go @@ -22,7 +22,9 @@ WITH required_table_privileges(table_name, privilege_name) AS ( ('platform_runtime_settings', 'SELECT'), ('platform_runtime_settings', 'INSERT'), ('platform_runtime_settings', 'UPDATE'), ('billing_price_rules', 'SELECT'), ('billing_price_rules', 'INSERT'), ('billing_price_rules', 'UPDATE'), ('billing_wallets', 'SELECT'), ('billing_wallets', 'INSERT'), ('billing_wallets', 'UPDATE'), - ('billing_ledger', 'SELECT'), ('billing_ledger', 'INSERT') + ('billing_ledger', 'SELECT'), ('billing_ledger', 'INSERT'), + ('payment_orders', 'SELECT'), ('payment_orders', 'INSERT'), ('payment_orders', 'UPDATE'), + ('payment_refunds', 'SELECT'), ('payment_refunds', 'INSERT'), ('payment_refunds', 'UPDATE') ), required_generation_job_columns(column_name) AS ( VALUES diff --git a/backend/internal/postgres/payments.go b/backend/internal/postgres/payments.go new file mode 100644 index 0000000..eecf7e7 --- /dev/null +++ b/backend/internal/postgres/payments.go @@ -0,0 +1,331 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "errors" + "fmt" + "regexp" + "time" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments" + "github.com/jackc/pgx/v5/pgconn" +) + +const paymentOrderColumns = `id, organization_id, account_id, request_key, mch_id, app_id, + amount_fen, currency, status, COALESCE(code_url, ''), COALESCE(transaction_id, ''), + created_at, updated_at, expires_at, paid_at` + +const createPaymentOrderSQL = `INSERT INTO public.payment_orders + (id, organization_id, account_id, request_key, mch_id, app_id, amount_fen, currency, expires_at) +SELECT $1::text, organization.id, account.id, $4::text, $5::text, $6::text, + $7::bigint, $8::text, $9::timestamptz +FROM public.platform_organizations AS organization +JOIN public.platform_users AS account ON account.id = $3::text +WHERE organization.id = $2::text AND organization.status = 'active' + AND account.status = 'active' AND account.organization_id = organization.id + AND account.role IN ('organization_admin', 'super_admin') +ON CONFLICT DO NOTHING +RETURNING ` + paymentOrderColumns + +const getPaymentOrderSQL = `SELECT ` + paymentOrderColumns + ` FROM public.payment_orders WHERE id = $1::text` +const getPaymentOrderByRequestSQL = `SELECT ` + paymentOrderColumns + ` FROM public.payment_orders WHERE organization_id = $1::text AND request_key = $2::text` +const listPaymentOrdersSQL = `SELECT ` + paymentOrderColumns + ` FROM public.payment_orders WHERE organization_id = $1::text ORDER BY created_at DESC, id DESC LIMIT $2::integer` +const pendingPaymentOrdersSQL = `SELECT ` + paymentOrderColumns + ` FROM public.payment_orders WHERE status = 'pending' ORDER BY updated_at ASC, created_at ASC, id ASC LIMIT $1::integer` +const updatePendingPaymentOrderSQL = `UPDATE public.payment_orders SET + code_url = CASE WHEN $2::text = '' THEN code_url ELSE $2::text END, + updated_at = now() +WHERE id = $1::text AND status = 'pending' +RETURNING ` + paymentOrderColumns +const closePaymentOrderSQL = `UPDATE public.payment_orders SET + status = 'closed', code_url = NULL, updated_at = now() +WHERE id = $1::text AND status = 'pending' +RETURNING ` + paymentOrderColumns +const lockPaymentOrderSQL = `SELECT ` + paymentOrderColumns + ` FROM public.payment_orders WHERE id = $1::text FOR UPDATE` +const paidPaymentOrderSQL = `UPDATE public.payment_orders SET + status = 'paid', transaction_id = $2::text, code_url = NULL, + paid_at = $3::timestamptz, updated_at = now() +WHERE id = $1::text AND status IN ('pending', 'closed') +RETURNING ` + paymentOrderColumns + +var paymentKeyPattern = regexp.MustCompile(`^[A-Za-z0-9_-]+$`) + +func (db *Database) paymentQuerier() (Querier, error) { + if db == nil || db.config.Backend != BackendPostgres || db.querier == nil { + return nil, payments.ErrUnavailable + } + return db.querier, nil +} + +func validPaymentKey(s string, maxLength int) bool { + return len(s) > 0 && len(s) <= maxLength && paymentKeyPattern.MatchString(s) +} + +func (db *Database) CreatePaymentOrder(ctx context.Context, input payments.Order) (payments.Order, error) { + q, err := db.paymentQuerier() + if err != nil { + return payments.Order{}, err + } + if !validPaymentKey(input.ID, 32) || !validPaymentKey(input.RequestKey, 64) || + input.OrganizationID == "" || input.AccountID == "" || input.MchID == "" || input.AppID == "" || + len(input.MchID) > 64 || len(input.AppID) > 64 || input.Currency != "CNY" || + input.ExpiresAt.IsZero() || !input.ExpiresAt.After(time.Now()) { + return payments.Order{}, payments.ErrConflict + } + if input.AmountFen < 1 || input.AmountFen > 1_000_000 { + return payments.Order{}, payments.ErrInvalidAmount + } + created, found, err := queryPaymentOrder(ctx, q, createPaymentOrderSQL, + input.ID, input.OrganizationID, input.AccountID, input.RequestKey, + input.MchID, input.AppID, input.AmountFen, input.Currency, input.ExpiresAt) + if err != nil { + return payments.Order{}, paymentSQLError("create payment order", err) + } + if found { + return created, nil + } + previous, found, err := queryPaymentOrder(ctx, q, getPaymentOrderByRequestSQL, input.OrganizationID, input.RequestKey) + if err != nil { + return payments.Order{}, paymentSQLError("read payment request", err) + } + if !found || previous.AccountID != input.AccountID || previous.AmountFen != input.AmountFen || + previous.Currency != input.Currency || previous.AppID != input.AppID || previous.MchID != input.MchID { + return payments.Order{}, payments.ErrConflict + } + return previous, nil +} + +func (db *Database) GetPaymentOrder(ctx context.Context, id string) (payments.Order, error) { + q, err := db.paymentQuerier() + if err != nil { + return payments.Order{}, err + } + order, found, err := queryPaymentOrder(ctx, q, getPaymentOrderSQL, id) + if err != nil { + return payments.Order{}, paymentSQLError("get payment order", err) + } + if !found { + return payments.Order{}, payments.ErrNotFound + } + return order, nil +} + +func (db *Database) ListPaymentOrders(ctx context.Context, organizationID string, limit int) ([]payments.Order, error) { + q, err := db.paymentQuerier() + if err != nil { + return nil, err + } + if organizationID == "" { + return nil, payments.ErrConflict + } + return queryPaymentOrders(ctx, q, listPaymentOrdersSQL, organizationID, boundedPaymentLimit(limit)) +} + +func (db *Database) PendingPaymentOrders(ctx context.Context, limit int) ([]payments.Order, error) { + q, err := db.paymentQuerier() + if err != nil { + return nil, err + } + return queryPaymentOrders(ctx, q, pendingPaymentOrdersSQL, boundedPaymentLimit(limit)) +} + +func boundedPaymentLimit(limit int) int { + if limit < 1 { + return 1 + } + if limit > 500 { + return 500 + } + return limit +} + +func (db *Database) UpdatePaymentOrder(ctx context.Context, id, status, codeURL string) (payments.Order, error) { + q, err := db.paymentQuerier() + if err != nil { + return payments.Order{}, err + } + if id == "" || len(codeURL) > 2048 || (status != payments.StatusPending && status != payments.StatusClosed) { + return payments.Order{}, payments.ErrConflict + } + statement := closePaymentOrderSQL + args := []any{id} + if status == payments.StatusPending { + statement = updatePendingPaymentOrderSQL + args = append(args, codeURL) + } + order, found, err := queryPaymentOrder(ctx, q, statement, args...) + if err != nil { + return payments.Order{}, paymentSQLError("update payment order", err) + } + if found { + return order, nil + } + return db.GetPaymentOrder(ctx, id) +} + +func (db *Database) SettlePaymentOrder(ctx context.Context, transaction payments.Transaction) (payments.Order, error) { + if _, err := db.paymentQuerier(); err != nil || db.transactions == nil { + return payments.Order{}, payments.ErrUnavailable + } + if transaction.OrderID == "" || transaction.MchID == "" || transaction.AppID == "" || + transaction.TransactionID == "" || transaction.TradeState != "SUCCESS" || + transaction.Currency != "CNY" || transaction.AmountFen < 1 || transaction.AmountFen > 1_000_000 { + return payments.Order{}, payments.ErrVerification + } + tx, err := db.transactions.Begin(ctx) + if err != nil { + return payments.Order{}, paymentSQLError("begin payment settlement", err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback(ctx) + } + }() + order, found, err := queryPaymentOrder(ctx, tx, lockPaymentOrderSQL, transaction.OrderID) + if err != nil { + return payments.Order{}, paymentSQLError("lock payment order", err) + } + if !found { + return payments.Order{}, payments.ErrNotFound + } + if order.MchID != transaction.MchID || order.AppID != transaction.AppID || + order.AmountFen != transaction.AmountFen || order.Currency != transaction.Currency { + return payments.Order{}, payments.ErrVerification + } + if order.Status == payments.StatusPaid { + if order.TransactionID != transaction.TransactionID { + return payments.Order{}, payments.ErrConflict + } + if err := tx.Commit(ctx); err != nil { + return payments.Order{}, paymentSQLError("commit payment replay", err) + } + committed = true + return order, nil + } + if order.Status != payments.StatusPending && order.Status != payments.StatusClosed { + return payments.Order{}, payments.ErrConflict + } + paidAt := transaction.SuccessTime + if paidAt.IsZero() { + paidAt = time.Now().UTC() + } + paid, found, err := queryPaymentOrder(ctx, tx, paidPaymentOrderSQL, order.ID, transaction.TransactionID, paidAt) + if err != nil { + return payments.Order{}, paymentSQLError("record payment transaction", err) + } + if !found { + return payments.Order{}, payments.ErrConflict + } + metadata, err := json.Marshal(map[string]string{ + "source": "wechat_native", "paymentOrderId": order.ID, + "transactionId": transaction.TransactionID, "mchId": transaction.MchID, + "appId": transaction.AppID, "initiatedByAccountId": order.AccountID, + }) + if err != nil { + return payments.Order{}, err + } + rows, err := tx.Query(ctx, PostWalletEntrySQL, + "wechat-native-entry:"+order.ID, order.OrganizationID, nil, nil, + "recharge", order.AmountFen, "CNY", "wechat-native:"+order.MchID+":"+order.ID, + "微信扫码充值", json.RawMessage(metadata)) + if err != nil { + return payments.Order{}, paymentSQLError("post payment wallet credit", err) + } + if !rows.Next() { + rowErr := rows.Err() + rows.Close() + if rowErr != nil { + return payments.Order{}, paymentSQLError("read payment wallet credit", rowErr) + } + return payments.Order{}, fmt.Errorf("payment wallet credit returned no row") + } + var posting WalletEntry + if err := rows.Scan(&posting.LedgerID, &posting.BalanceAfterFen, &posting.BalanceFen, + &posting.TotalRechargedFen, &posting.TotalChargedFen, &posting.CreatedAt, + &posting.UpdatedAt, &posting.DeltaFen); err != nil { + rows.Close() + return payments.Order{}, paymentSQLError("scan payment wallet credit", err) + } + rowErr := rows.Err() + rows.Close() + if rowErr != nil { + return payments.Order{}, paymentSQLError("complete payment wallet credit", rowErr) + } + if posting.DeltaFen != order.AmountFen || posting.LedgerID != "wechat-native-entry:"+order.ID { + return payments.Order{}, payments.ErrConflict + } + if err := tx.Commit(ctx); err != nil { + // The commit result can be ambiguous. A replay with this transaction ID + // resolves it safely because paid status and wallet entry commit together. + return payments.Order{}, paymentSQLError("commit payment settlement; query or replay the order", err) + } + committed = true + return paid, nil +} + +func queryPaymentOrder(ctx context.Context, q Querier, statement string, args ...any) (payments.Order, bool, error) { + rows, err := q.Query(ctx, statement, args...) + if err != nil { + return payments.Order{}, false, err + } + defer rows.Close() + if !rows.Next() { + return payments.Order{}, false, rows.Err() + } + order, err := scanPaymentOrder(rows) + if err != nil { + return payments.Order{}, false, err + } + return order, true, rows.Err() +} + +func queryPaymentOrders(ctx context.Context, q Querier, statement string, args ...any) ([]payments.Order, error) { + rows, err := q.Query(ctx, statement, args...) + if err != nil { + return nil, paymentSQLError("list payment orders", err) + } + defer rows.Close() + out := make([]payments.Order, 0) + for rows.Next() { + order, err := scanPaymentOrder(rows) + if err != nil { + return nil, paymentSQLError("scan payment order", err) + } + out = append(out, order) + } + return out, paymentSQLError("read payment orders", rows.Err()) +} + +func scanPaymentOrder(rows Rows) (payments.Order, error) { + var order payments.Order + var paidAt sql.NullTime + err := rows.Scan(&order.ID, &order.OrganizationID, &order.AccountID, &order.RequestKey, + &order.MchID, &order.AppID, &order.AmountFen, &order.Currency, &order.Status, + &order.CodeURL, &order.TransactionID, &order.CreatedAt, &order.UpdatedAt, + &order.ExpiresAt, &paidAt) + if err != nil { + return payments.Order{}, err + } + if paidAt.Valid { + order.PaidAt = &paidAt.Time + } + if order.Status != payments.StatusPending { + order.CodeURL = "" + } + return order, nil +} + +func paymentSQLError(action string, err error) error { + if err == nil { + return nil + } + var pgErr *pgconn.PgError + if errors.As(err, &pgErr) && (pgErr.Code == "23505" || pgErr.Code == "23503" || pgErr.Code == "23514") { + return fmt.Errorf("%w: %s: %v", payments.ErrConflict, action, err) + } + return fmt.Errorf("%s: %w", action, err) +} + +var _ payments.Repository = (*Database)(nil) diff --git a/backend/internal/postgres/payments_integration_test.go b/backend/internal/postgres/payments_integration_test.go new file mode 100644 index 0000000..c333314 --- /dev/null +++ b/backend/internal/postgres/payments_integration_test.go @@ -0,0 +1,116 @@ +package postgres + +import ( + "context" + "errors" + "os" + "sync" + "testing" + "time" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Run only against a disposable PostgreSQL instance. The test never reads the +// application's DATABASE_URL or local credentials. +func TestPaymentSettlementInDisposablePostgres(t *testing.T) { + databaseURL := os.Getenv("NIAN_TEST_PAYMENT_DATABASE_URL") + if databaseURL == "" { + t.Skip("set NIAN_TEST_PAYMENT_DATABASE_URL to a disposable PostgreSQL database") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, databaseURL) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + db := NewDatabase(Config{Backend: BackendPostgres}, &pgxPoolAdapter{pool: pool}) + if err := db.Readiness(ctx); err != nil { + t.Fatalf("migrations/readiness: %v", err) + } + _, err = pool.Exec(ctx, `INSERT INTO public.platform_organizations(id, name, archive_owner_id) VALUES ('pay_test_org', 'payment test org', 'pay_test_archive'); +INSERT INTO public.platform_users(id, phone, display_name, role, organization_id, status, password_hash, password_salt) +VALUES ('pay_test_admin', '13900000123', 'Payment Test', 'organization_admin', 'pay_test_org', 'active', 'hash', 'salt')`) + if err != nil { + t.Fatal(err) + } + makeOrder := func(id, requestKey string, amount int64) payments.Order { + return payments.Order{ID: id, OrganizationID: "pay_test_org", AccountID: "pay_test_admin", + RequestKey: requestKey, MchID: "test_mch", AppID: "test_app", AmountFen: amount, + Currency: "CNY", ExpiresAt: time.Now().Add(15 * time.Minute)} + } + first, err := db.CreatePaymentOrder(ctx, makeOrder("pay_test_order_1", "pay_test_request_1", 5200)) + if err != nil || first.Status != payments.StatusPending { + t.Fatalf("create first: %+v %v", first, err) + } + replayed, err := db.CreatePaymentOrder(ctx, makeOrder("pay_test_order_new", "pay_test_request_1", 5200)) + if err != nil || replayed.ID != first.ID { + t.Fatalf("idempotent create: %+v %v", replayed, err) + } + if _, err := db.CreatePaymentOrder(ctx, makeOrder("pay_test_order_new", "pay_test_request_1", 5201)); !errors.Is(err, payments.ErrConflict) { + t.Fatalf("changed request payload: %v", err) + } + txn := payments.Transaction{OrderID: first.ID, MchID: "test_mch", AppID: "test_app", + TransactionID: "wx_test_transaction_1", TradeState: "SUCCESS", Currency: "CNY", + AmountFen: 5200, SuccessTime: time.Now().UTC()} + var wg sync.WaitGroup + errorsOut := make(chan error, 12) + for i := 0; i < 12; i++ { + wg.Add(1) + go func() { + defer wg.Done() + order, err := db.SettlePaymentOrder(ctx, txn) + if err == nil && order.Status != payments.StatusPaid { + err = payments.ErrConflict + } + errorsOut <- err + }() + } + wg.Wait() + close(errorsOut) + for err := range errorsOut { + if err != nil { + t.Fatalf("concurrent settlement: %v", err) + } + } + var balance, recharged int64 + if err := pool.QueryRow(ctx, `SELECT balance_fen, total_recharged_fen FROM public.billing_wallets WHERE organization_id = 'pay_test_org'`).Scan(&balance, &recharged); err != nil { + t.Fatal(err) + } + var ledgerCount int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM public.billing_ledger WHERE organization_id = 'pay_test_org' AND idempotency_key = 'wechat-native:test_mch:pay_test_order_1'`).Scan(&ledgerCount); err != nil { + t.Fatal(err) + } + if balance != 5200 || recharged != 5200 || ledgerCount != 1 { + t.Fatalf("after 12 deliveries: balance=%d recharged=%d ledger=%d", balance, recharged, ledgerCount) + } + second, err := db.CreatePaymentOrder(ctx, makeOrder("pay_test_order_2", "pay_test_request_2", 300)) + if err != nil { + t.Fatal(err) + } + if _, err := db.UpdatePaymentOrder(ctx, second.ID, payments.StatusClosed, ""); err != nil { + t.Fatal(err) + } + collision := txn + collision.OrderID = second.ID + collision.AmountFen = 300 + if _, err := db.SettlePaymentOrder(ctx, collision); !errors.Is(err, payments.ErrConflict) { + t.Fatalf("reused WeChat transaction: %v", err) + } + stillClosed, err := db.GetPaymentOrder(ctx, second.ID) + if err != nil || stillClosed.Status != payments.StatusClosed { + t.Fatalf("transaction collision changed order: %+v %v", stillClosed, err) + } + late := collision + late.TransactionID = "wx_test_transaction_2" + if _, err := db.SettlePaymentOrder(ctx, late); err != nil { + t.Fatalf("valid late success: %v", err) + } + if err := pool.QueryRow(ctx, `SELECT balance_fen, total_recharged_fen FROM public.billing_wallets WHERE organization_id = 'pay_test_org'`).Scan(&balance, &recharged); err != nil { + t.Fatal(err) + } + if balance != 5500 || recharged != 5500 { + t.Fatalf("after late success: balance=%d recharged=%d", balance, recharged) + } +} diff --git a/backend/internal/postgres/payments_test.go b/backend/internal/postgres/payments_test.go new file mode 100644 index 0000000..bf2efd6 --- /dev/null +++ b/backend/internal/postgres/payments_test.go @@ -0,0 +1,177 @@ +package postgres + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments" + "github.com/jackc/pgx/v5/pgconn" +) + +func paymentTestOrder(status, transactionID string) payments.Order { + now := time.Now().UTC() + order := payments.Order{ + ID: "pmt_123", OrganizationID: "org_1", AccountID: "account_1", RequestKey: "request_1", + MchID: "mch_1", AppID: "wx_app_1", AmountFen: 5200, Currency: "CNY", + Status: status, CodeURL: "weixin://qr", TransactionID: transactionID, + CreatedAt: now, UpdatedAt: now, ExpiresAt: now.Add(15 * time.Minute), + } + if status == payments.StatusPaid { + order.PaidAt = &now + } + return order +} + +func paymentTestRow(order payments.Order) []any { + var paidAt any + if order.PaidAt != nil { + paidAt = *order.PaidAt + } + return []any{order.ID, order.OrganizationID, order.AccountID, order.RequestKey, + order.MchID, order.AppID, order.AmountFen, order.Currency, order.Status, + order.CodeURL, order.TransactionID, order.CreatedAt, order.UpdatedAt, order.ExpiresAt, paidAt} +} + +func paymentTestTransaction() payments.Transaction { + return payments.Transaction{ + OrderID: "pmt_123", MchID: "mch_1", AppID: "wx_app_1", + TransactionID: "wx_transaction_1", TradeState: "SUCCESS", Currency: "CNY", + AmountFen: 5200, SuccessTime: time.Now().UTC(), + } +} + +func TestPaymentSettlementAtomicallyPostsExactAmountAndMarksPaid(t *testing.T) { + order := paymentTestOrder(payments.StatusPending, "") + paid := paymentTestOrder(payments.StatusPaid, "wx_transaction_1") + now := time.Now().UTC() + tx := &generationStateTransaction{results: []*jobRows{ + {rows: [][]any{paymentTestRow(order)}}, + {rows: [][]any{paymentTestRow(paid)}}, + {rows: [][]any{{"wechat-native-entry:pmt_123", int64(5200), int64(5200), int64(5200), int64(0), now, now, int64(5200)}}}, + }} + db := NewDatabase(Config{Backend: BackendPostgres}, &generationStatePool{tx: tx}) + got, err := db.SettlePaymentOrder(context.Background(), paymentTestTransaction()) + if err != nil || got.Status != payments.StatusPaid || tx.commits != 1 || tx.rollbacks != 0 { + t.Fatalf("got=%+v err=%v commits=%d rollbacks=%d", got, err, tx.commits, tx.rollbacks) + } + if len(tx.queries) != 3 || tx.queries[0].sql != lockPaymentOrderSQL || + tx.queries[1].sql != paidPaymentOrderSQL || tx.queries[2].sql != PostWalletEntrySQL { + t.Fatalf("queries=%+v", tx.queries) + } + args := tx.queries[2].args + if args[0] != "wechat-native-entry:pmt_123" || args[1] != "org_1" || args[2] != nil || args[3] != nil || + args[4] != "recharge" || args[5] != int64(5200) || args[7] != "wechat-native:mch_1:pmt_123" { + t.Fatalf("wallet args=%#v", args) + } + var metadata map[string]string + if err := json.Unmarshal(args[9].(json.RawMessage), &metadata); err != nil || metadata["initiatedByAccountId"] != "account_1" { + t.Fatalf("metadata=%#v err=%v", metadata, err) + } +} + +func TestPaymentSettlementRejectsMismatchedAmountBeforeWalletWrite(t *testing.T) { + tx := &generationStateTransaction{results: []*jobRows{{rows: [][]any{paymentTestRow(paymentTestOrder(payments.StatusPending, ""))}}}} + db := NewDatabase(Config{Backend: BackendPostgres}, &generationStatePool{tx: tx}) + transaction := paymentTestTransaction() + transaction.AmountFen-- + _, err := db.SettlePaymentOrder(context.Background(), transaction) + if !errors.Is(err, payments.ErrVerification) || tx.commits != 0 || tx.rollbacks != 1 || len(tx.queries) != 1 { + t.Fatalf("err=%v commits=%d rollbacks=%d queries=%d", err, tx.commits, tx.rollbacks, len(tx.queries)) + } +} + +func TestPaymentSettlementReplaysSamePaidTransactionWithoutRecredit(t *testing.T) { + tx := &generationStateTransaction{results: []*jobRows{{rows: [][]any{paymentTestRow(paymentTestOrder(payments.StatusPaid, "wx_transaction_1"))}}}} + db := NewDatabase(Config{Backend: BackendPostgres}, &generationStatePool{tx: tx}) + _, err := db.SettlePaymentOrder(context.Background(), paymentTestTransaction()) + if err != nil || tx.commits != 1 || len(tx.queries) != 1 { + t.Fatalf("err=%v commits=%d queries=%d", err, tx.commits, len(tx.queries)) + } +} + +func TestPaymentSettlementAllowsVerifiedLateSuccessAfterClose(t *testing.T) { + closed := paymentTestOrder(payments.StatusClosed, "") + paid := paymentTestOrder(payments.StatusPaid, "wx_transaction_1") + now := time.Now().UTC() + tx := &generationStateTransaction{results: []*jobRows{ + {rows: [][]any{paymentTestRow(closed)}}, + {rows: [][]any{paymentTestRow(paid)}}, + {rows: [][]any{{"wechat-native-entry:pmt_123", int64(5200), int64(5200), int64(5200), int64(0), now, now, int64(5200)}}}, + }} + db := NewDatabase(Config{Backend: BackendPostgres}, &generationStatePool{tx: tx}) + got, err := db.SettlePaymentOrder(context.Background(), paymentTestTransaction()) + if err != nil || got.Status != payments.StatusPaid || tx.commits != 1 { + t.Fatalf("got=%+v err=%v commits=%d", got, err, tx.commits) + } +} + +func TestPaymentSettlementRollsBackOrderWhenWalletFails(t *testing.T) { + tx := &generationStateTransaction{results: []*jobRows{ + {rows: [][]any{paymentTestRow(paymentTestOrder(payments.StatusPending, ""))}}, + {rows: [][]any{paymentTestRow(paymentTestOrder(payments.StatusPaid, "wx_transaction_1"))}}, + {err: &pgconn.PgError{Code: "P0001", Message: "wallet failed"}}, + }} + db := NewDatabase(Config{Backend: BackendPostgres}, &generationStatePool{tx: tx}) + _, err := db.SettlePaymentOrder(context.Background(), paymentTestTransaction()) + if err == nil || tx.commits != 0 || tx.rollbacks != 1 { + t.Fatalf("err=%v commits=%d rollbacks=%d", err, tx.commits, tx.rollbacks) + } +} + +func TestPaymentSettlementRejectsReusedProviderTransactionAndRollsBack(t *testing.T) { + tx := &generationStateTransaction{results: []*jobRows{ + {rows: [][]any{paymentTestRow(paymentTestOrder(payments.StatusPending, ""))}}, + {err: &pgconn.PgError{Code: "23505", ConstraintName: "payment_orders_provider_transaction_unique"}}, + }} + db := NewDatabase(Config{Backend: BackendPostgres}, &generationStatePool{tx: tx}) + _, err := db.SettlePaymentOrder(context.Background(), paymentTestTransaction()) + if !errors.Is(err, payments.ErrConflict) || tx.commits != 0 || tx.rollbacks != 1 || len(tx.queries) != 2 { + t.Fatalf("err=%v commits=%d rollbacks=%d queries=%d", err, tx.commits, tx.rollbacks, len(tx.queries)) + } +} + +func TestPaymentSettlementRejectsDifferentTransactionForPaidOrder(t *testing.T) { + tx := &generationStateTransaction{results: []*jobRows{{rows: [][]any{paymentTestRow(paymentTestOrder(payments.StatusPaid, "other_transaction"))}}}} + db := NewDatabase(Config{Backend: BackendPostgres}, &generationStatePool{tx: tx}) + _, err := db.SettlePaymentOrder(context.Background(), paymentTestTransaction()) + if !errors.Is(err, payments.ErrConflict) || tx.commits != 0 || tx.rollbacks != 1 || len(tx.queries) != 1 { + t.Fatalf("err=%v commits=%d rollbacks=%d queries=%d", err, tx.commits, tx.rollbacks, len(tx.queries)) + } +} + +func TestPaymentCreateRejectsInvalidMoneyAndIdentifiersBeforeWrite(t *testing.T) { + for _, mutate := range []func(*payments.Order){ + func(o *payments.Order) { o.AmountFen = 0 }, + func(o *payments.Order) { o.AmountFen = 1_000_001 }, + func(o *payments.Order) { o.Currency = "USD" }, + func(o *payments.Order) { o.RequestKey = "request:key" }, + func(o *payments.Order) { o.ID = strings.Repeat("a", 33) }, + } { + q := &fakeQuerier{} + db := NewDatabase(Config{Backend: BackendPostgres}, q) + order := paymentTestOrder(payments.StatusPending, "") + mutate(&order) + if _, err := db.CreatePaymentOrder(context.Background(), order); err == nil || q.sql != "" { + t.Fatalf("order=%+v err=%v sql=%s", order, err, q.sql) + } + } +} + +func TestPaymentCreateUsesAccountOrganizationAndRequestUniqueness(t *testing.T) { + if !strings.Contains(createPaymentOrderSQL, "account.organization_id = organization.id") || + !strings.Contains(createPaymentOrderSQL, "account.role IN ('organization_admin', 'super_admin')") || + !strings.Contains(createPaymentOrderSQL, "ON CONFLICT DO NOTHING") { + t.Fatalf("create SQL lacks account relation or conflict control: %s", createPaymentOrderSQL) + } + order := paymentTestOrder(payments.StatusPending, "") + q := &fakeQuerier{rows: [][]any{paymentTestRow(order)}} + db := NewDatabase(Config{Backend: BackendPostgres}, q) + created, err := db.CreatePaymentOrder(context.Background(), order) + if err != nil || created.ID != order.ID || q.sql != createPaymentOrderSQL { + t.Fatalf("created=%+v err=%v sql=%s", created, err, q.sql) + } +} diff --git a/backend/internal/postgres/refunds.go b/backend/internal/postgres/refunds.go new file mode 100644 index 0000000..7333b65 --- /dev/null +++ b/backend/internal/postgres/refunds.go @@ -0,0 +1,533 @@ +package postgres + +import ( + "context" + "database/sql" + "encoding/json" + "strings" + "time" + "unicode" + "unicode/utf8" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments" +) + +const refundColumns = `r.id, r.order_id, r.organization_id, r.operator_id, + COALESCE(u.display_name, ''), r.request_key, r.mch_id, r.transaction_id, + COALESCE(r.provider_id, ''), r.amount_fen, r.total_fen, r.currency, r.reason, + r.status, r.failure_reason, r.created_at, r.updated_at, r.succeeded_at` + +const refundSelect = `SELECT ` + refundColumns + ` FROM public.payment_refunds r + LEFT JOIN public.platform_users u ON u.id = r.operator_id ` + +const adminOrdersSQL = `SELECT payment_orders.id, payment_orders.organization_id, + payment_orders.account_id, payment_orders.request_key, payment_orders.mch_id, + payment_orders.app_id, payment_orders.amount_fen, payment_orders.currency, + payment_orders.status, COALESCE(payment_orders.code_url, ''), + COALESCE(payment_orders.transaction_id, ''), payment_orders.created_at, + payment_orders.updated_at, payment_orders.expires_at, payment_orders.paid_at, + COALESCE(org.name, ''), COALESCE(account.display_name, ''), + COALESCE(refunds.refunded_fen, 0), COALESCE(refunds.refunding_fen, 0) +FROM public.payment_orders +JOIN public.platform_organizations org ON org.id = payment_orders.organization_id +JOIN public.platform_users account ON account.id = payment_orders.account_id +LEFT JOIN LATERAL ( + SELECT sum(amount_fen) FILTER (WHERE status = 'success') AS refunded_fen, + sum(amount_fen) FILTER (WHERE status IN ('pending', 'processing', 'abnormal')) AS refunding_fen + FROM public.payment_refunds WHERE order_id = payment_orders.id +) refunds ON true +WHERE ($1::text = '' OR payment_orders.organization_id = $1::text) +ORDER BY payment_orders.created_at DESC, payment_orders.id DESC +LIMIT $2::integer OFFSET $3::integer` + +// The caller requests limit+1 to discover whether another page exists. +func (db *Database) ListAdminPaymentOrders(ctx context.Context, organizationID string, limit, offset int) ([]payments.AdminOrder, error) { + q, err := db.paymentQuerier() + if err != nil { + return nil, err + } + if offset < 0 { + return nil, payments.ErrConflict + } + rows, err := q.Query(ctx, adminOrdersSQL, organizationID, boundedAdminOrdersLimit(limit), offset) + if err != nil { + return nil, paymentSQLError("list admin payment orders", err) + } + defer rows.Close() + out := make([]payments.AdminOrder, 0) + for rows.Next() { + var item payments.AdminOrder + var paidAt sql.NullTime + if err := rows.Scan(&item.ID, &item.OrganizationID, &item.AccountID, &item.RequestKey, + &item.MchID, &item.AppID, &item.AmountFen, &item.Currency, &item.Status, + &item.CodeURL, &item.TransactionID, &item.CreatedAt, &item.UpdatedAt, + &item.ExpiresAt, &paidAt, &item.OrganizationName, &item.AccountName, + &item.RefundedFen, &item.RefundingFen); err != nil { + return nil, paymentSQLError("scan admin payment order", err) + } + if paidAt.Valid { + item.PaidAt = &paidAt.Time + } + item.CodeURL = "" + item.RefundableFen = item.AmountFen - item.RefundedFen - item.RefundingFen + if item.Status != payments.StatusPaid || item.RefundableFen < 0 { + item.RefundableFen = 0 + } + out = append(out, item) + } + return out, paymentSQLError("read admin payment orders", rows.Err()) +} + +func boundedAdminOrdersLimit(limit int) int { + if limit < 1 { + return 1 + } + if limit > 101 { + return 101 + } + return limit +} + +func (db *Database) CreatePaymentRefund(ctx context.Context, input payments.Refund) (payments.Refund, error) { + if _, err := db.paymentQuerier(); err != nil || db.transactions == nil { + return payments.Refund{}, payments.ErrUnavailable + } + if !validPaymentKey(input.ID, 32) || !validPaymentKey(input.RequestKey, 64) || + !validPaymentKey(input.OrderID, 32) || input.OperatorID == "" || input.AmountFen <= 0 || + len(input.Reason) > 80 { + return payments.Refund{}, payments.ErrConflict + } + tx, err := db.transactions.Begin(ctx) + if err != nil { + return payments.Refund{}, paymentSQLError("begin refund reservation", err) + } + defer tx.Rollback(ctx) + order, found, err := queryPaymentOrder(ctx, tx, lockPaymentOrderSQL, input.OrderID) + if err != nil { + return payments.Refund{}, paymentSQLError("lock original payment", err) + } + if !found { + return payments.Refund{}, payments.ErrNotFound + } + if order.Status != payments.StatusPaid { + return payments.Refund{}, payments.ErrConflict + } + operatorOK, err := queryRefundOperator(ctx, tx, input.OperatorID) + if err != nil { + return payments.Refund{}, paymentSQLError("verify refund operator", err) + } + if !operatorOK { + return payments.Refund{}, payments.ErrConflict + } + previous, found, err := queryRefund(ctx, tx, refundSelect+` WHERE r.order_id = $1 AND r.request_key = $2`, order.ID, input.RequestKey) + if err != nil { + return payments.Refund{}, paymentSQLError("read refund request", err) + } + if found { + if previous.OperatorID != input.OperatorID || previous.AmountFen != input.AmountFen || previous.Reason != input.Reason { + return payments.Refund{}, payments.ErrConflict + } + if err := tx.Commit(ctx); err != nil { + return payments.Refund{}, paymentSQLError("commit refund replay", err) + } + return previous, nil + } + used, err := queryRefundUsed(ctx, tx, order.ID, "") + if err != nil { + return payments.Refund{}, paymentSQLError("check refundable balance", err) + } + if used > order.AmountFen || input.AmountFen > order.AmountFen-used { + return payments.Refund{}, payments.ErrInvalidAmount + } + created, found, err := queryRefund(ctx, tx, `INSERT INTO public.payment_refunds + (id, order_id, organization_id, operator_id, request_key, mch_id, transaction_id, + amount_fen, total_fen, currency, reason) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + RETURNING `+refundColumnsNoJoin, + input.ID, order.ID, order.OrganizationID, input.OperatorID, input.RequestKey, + order.MchID, order.TransactionID, input.AmountFen, order.AmountFen, order.Currency, input.Reason) + if err != nil { + return payments.Refund{}, paymentSQLError("create refund reservation", err) + } + if !found { + return payments.Refund{}, payments.ErrConflict + } + if err := tx.Commit(ctx); err != nil { + return payments.Refund{}, paymentSQLError("commit refund reservation", err) + } + return created, nil +} + +const refundColumnsNoJoin = `id, order_id, organization_id, operator_id, ''::text, request_key, + mch_id, transaction_id, COALESCE(provider_id, ''), amount_fen, total_fen, + currency, reason, status, failure_reason, created_at, updated_at, succeeded_at` + +func queryRefundOperator(ctx context.Context, q Querier, operatorID string) (bool, error) { + rows, err := q.Query(ctx, `SELECT 1 FROM public.platform_users WHERE id = $1 AND role = 'super_admin' AND status = 'active'`, operatorID) + if err != nil { + return false, err + } + defer rows.Close() + if !rows.Next() { + return false, rows.Err() + } + var one int + if err := rows.Scan(&one); err != nil { + return false, err + } + return one == 1, rows.Err() +} + +func queryRefundUsed(ctx context.Context, q Querier, orderID, excludeID string) (int64, error) { + rows, err := q.Query(ctx, `SELECT COALESCE(sum(amount_fen), 0)::bigint FROM public.payment_refunds + WHERE order_id = $1 AND id <> $2 AND status IN ('pending', 'processing', 'abnormal', 'success')`, orderID, excludeID) + if err != nil { + return 0, err + } + defer rows.Close() + if !rows.Next() { + return 0, rows.Err() + } + var used int64 + if err := rows.Scan(&used); err != nil { + return 0, err + } + return used, rows.Err() +} + +func (db *Database) GetPaymentRefund(ctx context.Context, id string) (payments.Refund, error) { + q, err := db.paymentQuerier() + if err != nil { + return payments.Refund{}, err + } + item, found, err := queryRefund(ctx, q, refundSelect+` WHERE r.id = $1`, id) + if err != nil { + return payments.Refund{}, paymentSQLError("get refund", err) + } + if !found { + return payments.Refund{}, payments.ErrNotFound + } + return item, nil +} + +func (db *Database) ListPaymentRefunds(ctx context.Context, orderID string) ([]payments.Refund, error) { + q, err := db.paymentQuerier() + if err != nil { + return nil, err + } + return queryRefunds(ctx, q, refundSelect+` WHERE r.order_id = $1 ORDER BY r.created_at DESC, r.id DESC`, orderID) +} + +func (db *Database) PendingPaymentRefunds(ctx context.Context, limit int) ([]payments.Refund, error) { + q, err := db.paymentQuerier() + if err != nil { + return nil, err + } + return queryRefunds(ctx, q, refundSelect+` WHERE r.status IN ('pending', 'processing', 'abnormal') + ORDER BY r.updated_at, r.created_at, r.id LIMIT $1`, boundedPaymentLimit(limit)) +} + +func (db *Database) TouchPaymentRefund(ctx context.Context, id string) (payments.Refund, error) { + q, err := db.paymentQuerier() + if err != nil { + return payments.Refund{}, err + } + item, found, err := queryRefund(ctx, q, `UPDATE public.payment_refunds + SET updated_at = now() WHERE id = $1 AND status IN ('pending', 'processing', 'abnormal') + RETURNING `+refundColumnsNoJoin, id) + if err != nil { + return payments.Refund{}, paymentSQLError("touch refund", err) + } + if found { + return item, nil + } + return db.GetPaymentRefund(ctx, id) +} + +// Reject only an unsubmitted request. The original payment lock serializes +// reservation release with new requests and late provider notifications. +func (db *Database) RejectPaymentRefund(ctx context.Context, id, reason string) (payments.Refund, error) { + if _, err := db.paymentQuerier(); err != nil || db.transactions == nil { + return payments.Refund{}, payments.ErrUnavailable + } + if !validPaymentKey(id, 32) || reason == "" || len(reason) > 160 || !utf8.ValidString(reason) || + strings.IndexFunc(reason, unicode.IsControl) >= 0 { + return payments.Refund{}, payments.ErrConflict + } + tx, err := db.transactions.Begin(ctx) + if err != nil { + return payments.Refund{}, paymentSQLError("begin refund rejection", err) + } + defer tx.Rollback(ctx) + initial, found, err := queryRefund(ctx, tx, refundSelect+` WHERE r.id = $1`, id) + if err != nil { + return payments.Refund{}, paymentSQLError("read refund for rejection", err) + } + if !found { + return payments.Refund{}, payments.ErrNotFound + } + if _, found, err := queryPaymentOrder(ctx, tx, lockPaymentOrderSQL, initial.OrderID); err != nil { + return payments.Refund{}, paymentSQLError("lock original payment for rejection", err) + } else if !found { + return payments.Refund{}, payments.ErrNotFound + } + item, found, err := queryRefund(ctx, tx, refundSelect+` WHERE r.id = $1 FOR UPDATE OF r`, id) + if err != nil { + return payments.Refund{}, paymentSQLError("lock refund for rejection", err) + } + if !found { + return payments.Refund{}, payments.ErrNotFound + } + if item.OrderID != initial.OrderID { + return payments.Refund{}, payments.ErrConflict + } + if item.Status == payments.RefundRejected { + if err := tx.Commit(ctx); err != nil { + return payments.Refund{}, paymentSQLError("commit rejected refund replay", err) + } + return item, nil + } + if item.Status != payments.RefundPending || item.ProviderID != "" { + return payments.Refund{}, payments.ErrConflict + } + item, found, err = queryRefund(ctx, tx, `UPDATE public.payment_refunds + SET status = 'rejected', failure_reason = $2, updated_at = now() + WHERE id = $1 AND status = 'pending' AND provider_id IS NULL + RETURNING `+refundColumnsNoJoin, id, reason) + if err != nil { + return payments.Refund{}, paymentSQLError("reject refund", err) + } + if !found { + return payments.Refund{}, payments.ErrConflict + } + if err := tx.Commit(ctx); err != nil { + return payments.Refund{}, paymentSQLError("commit refund rejection", err) + } + return item, nil +} + +func (db *Database) RecordPaymentRefund(ctx context.Context, remote payments.RefundTransaction) (payments.Refund, error) { + if _, err := db.paymentQuerier(); err != nil || db.transactions == nil { + return payments.Refund{}, payments.ErrUnavailable + } + if !validPaymentKey(remote.ID, 32) || !validPaymentKey(remote.OrderID, 32) || + remote.MchID == "" || remote.TransactionID == "" || remote.ProviderID == "" || + remote.Currency != "CNY" || remote.AmountFen <= 0 || remote.TotalFen <= 0 || + (remote.Status != payments.RefundPending && remote.Status != payments.RefundProcessing && + remote.Status != payments.RefundSuccess && remote.Status != payments.RefundClosed && remote.Status != payments.RefundAbnormal) { + return payments.Refund{}, payments.ErrVerification + } + tx, err := db.transactions.Begin(ctx) + if err != nil { + return payments.Refund{}, paymentSQLError("begin refund reconciliation", err) + } + defer tx.Rollback(ctx) + order, found, err := queryPaymentOrder(ctx, tx, lockPaymentOrderSQL, remote.OrderID) + if err != nil { + return payments.Refund{}, paymentSQLError("lock original payment", err) + } + if !found { + return payments.Refund{}, payments.ErrNotFound + } + item, found, err := queryRefund(ctx, tx, refundSelect+` WHERE r.id = $1 FOR UPDATE OF r`, remote.ID) + if err != nil { + return payments.Refund{}, paymentSQLError("lock refund", err) + } + if !found { + return payments.Refund{}, payments.ErrNotFound + } + if order.Status != payments.StatusPaid || item.OrderID != order.ID || item.OrganizationID != order.OrganizationID || + item.MchID != order.MchID || item.TransactionID != order.TransactionID || item.TotalFen != order.AmountFen || + item.ID != remote.ID || item.OrderID != remote.OrderID || item.MchID != remote.MchID || + item.TransactionID != remote.TransactionID || item.AmountFen != remote.AmountFen || + item.TotalFen != remote.TotalFen || item.Currency != remote.Currency || + (item.ProviderID != "" && item.ProviderID != remote.ProviderID) { + return payments.Refund{}, payments.ErrVerification + } + if item.Status == payments.RefundSuccess { + if err := tx.Commit(ctx); err != nil { + return payments.Refund{}, paymentSQLError("commit refund replay", err) + } + return item, nil + } + if remote.Status == payments.RefundSuccess { + // A provider-confirmed refund can arrive after a former CLOSED + // reservation was released and another refund was requested. Pending + // requests are only local reservations; they cannot invalidate money + // already returned by WeChat. Count completed refunds for this check. + used, err := queryRefundSucceeded(ctx, tx, order.ID, item.ID) + if err != nil { + return payments.Refund{}, paymentSQLError("check total refunded", err) + } + if used > order.AmountFen || item.AmountFen > order.AmountFen-used { + return payments.Refund{}, payments.ErrConflict + } + if err := debitRefundWallet(ctx, tx, item, remote.ProviderID); err != nil { + return payments.Refund{}, err + } + } + // Once closed, a delayed genuine success may still arrive. Other stale states + // never reopen it; success itself can never be reversed locally. + status := remote.Status + if item.Status == payments.RefundClosed && status != payments.RefundSuccess { + status = payments.RefundClosed + } + if item.Status == payments.RefundRejected && status != payments.RefundSuccess && + status != payments.RefundProcessing && status != payments.RefundAbnormal { + status = payments.RefundRejected + } + if item.Status == payments.RefundProcessing && status == payments.RefundPending { + status = payments.RefundProcessing + } + if item.Status == payments.RefundAbnormal && status == payments.RefundPending { + status = payments.RefundAbnormal + } + when := remote.SuccessTime + if status == payments.RefundSuccess && when.IsZero() { + when = time.Now().UTC() + } + item, found, err = queryRefund(ctx, tx, `UPDATE public.payment_refunds SET + status = $2, provider_id = $3, succeeded_at = CASE WHEN $2 = 'success' THEN $4::timestamptz ELSE NULL END, + failure_reason = CASE WHEN $2 = 'rejected' THEN failure_reason ELSE '' END, + updated_at = now() WHERE id = $1 RETURNING `+refundColumnsNoJoin, + item.ID, status, remote.ProviderID, nullableRefundTime(status, when)) + if err != nil { + return payments.Refund{}, paymentSQLError("record refund status", err) + } + if !found { + return payments.Refund{}, payments.ErrConflict + } + if err := tx.Commit(ctx); err != nil { + return payments.Refund{}, paymentSQLError("commit refund reconciliation", err) + } + return item, nil +} + +func queryRefundSucceeded(ctx context.Context, q Querier, orderID, excludeID string) (int64, error) { + rows, err := q.Query(ctx, `SELECT COALESCE(sum(amount_fen), 0)::bigint FROM public.payment_refunds + WHERE order_id = $1 AND id <> $2 AND status = 'success'`, orderID, excludeID) + if err != nil { + return 0, err + } + defer rows.Close() + if !rows.Next() { + return 0, rows.Err() + } + var amount int64 + if err := rows.Scan(&amount); err != nil { + return 0, err + } + return amount, rows.Err() +} + +func nullableRefundTime(status string, when time.Time) any { + if status == payments.RefundSuccess { + return when + } + return nil +} + +func debitRefundWallet(ctx context.Context, tx Transaction, item payments.Refund, providerID string) error { + metadata, err := json.Marshal(map[string]string{ + "source": "wechat_refund", "paymentOrderId": item.OrderID, "refundId": item.ID, + "transactionId": item.TransactionID, "providerRefundId": providerID, + }) + if err != nil { + return err + } + rows, err := tx.Query(ctx, `INSERT INTO public.billing_wallets(organization_id) VALUES ($1) + ON CONFLICT (organization_id) DO NOTHING RETURNING organization_id`, item.OrganizationID) + if err != nil { + return paymentSQLError("ensure refund wallet", err) + } + rows.Close() + rows, err = tx.Query(ctx, `UPDATE public.billing_wallets SET balance_fen = balance_fen - $2, + updated_at = now() WHERE organization_id = $1 RETURNING balance_fen`, item.OrganizationID, item.AmountFen) + if err != nil { + return paymentSQLError("debit refund wallet", err) + } + if !rows.Next() { + rows.Close() + return payments.ErrConflict + } + var balance int64 + if err := rows.Scan(&balance); err != nil { + rows.Close() + return paymentSQLError("scan refund wallet", err) + } + rows.Close() + key := "wechat-refund:" + item.MchID + ":" + item.ID + rows, err = tx.Query(ctx, `INSERT INTO public.billing_ledger + (id, organization_id, kind, delta_fen, balance_after_fen, currency, + idempotency_key, description, metadata) + VALUES ($1, $2, 'recharge_refund', $3, $4, 'CNY', $5, '微信充值退款', $6::jsonb) + RETURNING id`, "wechat-refund-entry:"+item.ID, item.OrganizationID, -item.AmountFen, + balance, key, json.RawMessage(metadata)) + if err != nil { + return paymentSQLError("record refund wallet ledger", err) + } + if !rows.Next() { + rows.Close() + return payments.ErrConflict + } + var id string + if err := rows.Scan(&id); err != nil { + rows.Close() + return paymentSQLError("scan refund ledger", err) + } + rows.Close() + if id != "wechat-refund-entry:"+item.ID { + return payments.ErrConflict + } + return nil +} + +func queryRefund(ctx context.Context, q Querier, statement string, args ...any) (payments.Refund, bool, error) { + rows, err := q.Query(ctx, statement, args...) + if err != nil { + return payments.Refund{}, false, err + } + defer rows.Close() + if !rows.Next() { + return payments.Refund{}, false, rows.Err() + } + item, err := scanRefund(rows) + if err != nil { + return payments.Refund{}, false, err + } + return item, true, rows.Err() +} + +func queryRefunds(ctx context.Context, q Querier, statement string, args ...any) ([]payments.Refund, error) { + rows, err := q.Query(ctx, statement, args...) + if err != nil { + return nil, paymentSQLError("list refunds", err) + } + defer rows.Close() + out := make([]payments.Refund, 0) + for rows.Next() { + item, err := scanRefund(rows) + if err != nil { + return nil, paymentSQLError("scan refund", err) + } + out = append(out, item) + } + return out, paymentSQLError("read refunds", rows.Err()) +} + +func scanRefund(rows Rows) (payments.Refund, error) { + var item payments.Refund + var succeeded sql.NullTime + if err := rows.Scan(&item.ID, &item.OrderID, &item.OrganizationID, &item.OperatorID, + &item.OperatorName, &item.RequestKey, &item.MchID, &item.TransactionID, + &item.ProviderID, &item.AmountFen, &item.TotalFen, &item.Currency, &item.Reason, + &item.Status, &item.FailureReason, &item.CreatedAt, &item.UpdatedAt, &succeeded); err != nil { + return payments.Refund{}, err + } + if succeeded.Valid { + item.SucceededAt = &succeeded.Time + } + return item, nil +} + +var _ payments.RefundRepository = (*Database)(nil) diff --git a/backend/internal/postgres/refunds_integration_test.go b/backend/internal/postgres/refunds_integration_test.go new file mode 100644 index 0000000..0af4351 --- /dev/null +++ b/backend/internal/postgres/refunds_integration_test.go @@ -0,0 +1,285 @@ +package postgres + +import ( + "context" + "errors" + "os" + "sync" + "testing" + "time" + + "git.nianxx.cn/wangxuming/NianAIGC/backend/internal/payments" + "github.com/jackc/pgx/v5/pgxpool" +) + +// Run against a disposable, migrated database only. Never uses DATABASE_URL. +func TestRefundSettlementInDisposablePostgres(t *testing.T) { + url := os.Getenv("NIAN_TEST_REFUND_DATABASE_URL") + if url == "" { + t.Skip("set NIAN_TEST_REFUND_DATABASE_URL to a disposable migrated database") + } + ctx := context.Background() + pool, err := pgxpool.New(ctx, url) + if err != nil { + t.Fatal(err) + } + defer pool.Close() + db := NewDatabase(Config{Backend: BackendPostgres}, &pgxPoolAdapter{pool: pool}) + if err := db.Readiness(ctx); err != nil { + t.Fatal(err) + } + _, err = pool.Exec(ctx, `INSERT INTO public.platform_organizations(id,name,archive_owner_id) + VALUES ('refund_test_org','refund test org','refund_test_archive'); + INSERT INTO public.platform_users(id,phone,display_name,role,organization_id,status,password_hash,password_salt) + VALUES ('refund_test_admin','13900000901','Refund Admin','super_admin',NULL,'active','hash','salt'), + ('refund_test_orgadmin','13900000902','Org Admin','organization_admin','refund_test_org','active','hash','salt')`) + if err != nil { + t.Fatal(err) + } + order, err := db.CreatePaymentOrder(ctx, payments.Order{ID: "refund_test_order", OrganizationID: "refund_test_org", AccountID: "refund_test_orgadmin", RequestKey: "refund_test_pay_key", MchID: "refund_test_mch", AppID: "refund_test_app", AmountFen: 500, Currency: "CNY", ExpiresAt: time.Now().Add(15 * time.Minute)}) + if err != nil { + t.Fatal(err) + } + _, err = db.SettlePaymentOrder(ctx, payments.Transaction{OrderID: order.ID, MchID: order.MchID, AppID: order.AppID, TransactionID: "refund_test_payment_tx", TradeState: "SUCCESS", Currency: "CNY", AmountFen: 500, SuccessTime: time.Now()}) + if err != nil { + t.Fatal(err) + } + // Spend 400 before refund; successful refund must leave a negative balance. + rows, err := pool.Query(ctx, PostWalletEntrySQL, "refund_test_charge", "refund_test_org", nil, nil, "charge", int64(-400), "CNY", "refund_test_charge_key", "consumption", nil) + if err != nil { + t.Fatal(err) + } + for rows.Next() { + } // consume the SQL function result + if err := rows.Err(); err != nil { + t.Fatal(err) + } + rows.Close() + create := func(id, key string, amount int64, actor string) (payments.Refund, error) { + return db.CreatePaymentRefund(ctx, payments.Refund{ID: id, OrderID: order.ID, OperatorID: actor, RequestKey: key, AmountFen: amount, Reason: "customer request"}) + } + if _, err := create("refund_test_bad", "refund_test_bad_key", 100, "refund_test_orgadmin"); !errors.Is(err, payments.ErrConflict) { + t.Fatalf("non-super-admin refund: %v", err) + } + first, err := create("refund_test_refund", "refund_test_refund_key", 500, "refund_test_admin") + if err != nil { + t.Fatal(err) + } + if first.TransactionID != "refund_test_payment_tx" || first.MchID != "refund_test_mch" { + t.Fatalf("refund snapshot: %+v", first) + } + second, err := create("refund_test_replay", "refund_test_refund_key", 500, "refund_test_admin") + if err != nil || second.ID != first.ID { + t.Fatalf("idempotent refund: %+v %v", second, err) + } + if _, err := create("refund_test_other", "refund_test_other_key", 1, "refund_test_admin"); !errors.Is(err, payments.ErrInvalidAmount) { + t.Fatalf("over-refund reserved: %v", err) + } + remote := payments.RefundTransaction{ID: first.ID, OrderID: order.ID, MchID: order.MchID, TransactionID: "refund_test_payment_tx", ProviderID: "wx_refund_test_id", Status: payments.RefundSuccess, Currency: "CNY", AmountFen: 500, TotalFen: 500, SuccessTime: time.Now()} + var wg sync.WaitGroup + result := make(chan error, 8) + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { defer wg.Done(); _, err := db.RecordPaymentRefund(ctx, remote); result <- err }() + } + wg.Wait() + close(result) + for err := range result { + if err != nil { + t.Fatalf("concurrent callback: %v", err) + } + } + var balance, gross, charged int64 + if err := pool.QueryRow(ctx, `SELECT balance_fen,total_recharged_fen,total_charged_fen FROM public.billing_wallets WHERE organization_id='refund_test_org'`).Scan(&balance, &gross, &charged); err != nil { + t.Fatal(err) + } + if balance != -400 || gross != 500 || charged != 400 { + t.Fatalf("balance=%d gross=%d charged=%d", balance, gross, charged) + } + var count int + if err := pool.QueryRow(ctx, `SELECT count(*) FROM public.billing_ledger WHERE organization_id='refund_test_org' AND kind='recharge_refund'`).Scan(&count); err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("refund ledger count=%d", count) + } + // Ordinary debits remain guarded even after the refund debit caused debt. + rows, err = pool.Query(ctx, PostWalletEntrySQL, "refund_test_charge2", "refund_test_org", nil, nil, "charge", int64(-1), "CNY", "refund_test_charge_key2", "consumption", nil) + if err == nil { + for rows.Next() { + } + err = rows.Err() + rows.Close() + } + if err == nil { + t.Fatal("ordinary charge accepted negative balance") + } + admin, err := db.ListAdminPaymentOrders(ctx, "", 10, 0) + if err != nil || len(admin) != 1 || admin[0].RefundedFen != 500 || admin[0].RefundableFen != 0 { + t.Fatalf("admin orders: %+v %v", admin, err) + } + // A confirmed close frees the reservation. A genuine delayed SUCCESS still + // settles, provided the total of settled and reserved refunds fits the payment. + order2, err := db.CreatePaymentOrder(ctx, payments.Order{ID: "refund_test_order2", OrganizationID: "refund_test_org", AccountID: "refund_test_orgadmin", RequestKey: "refund_test_pay_key2", MchID: "refund_test_mch", AppID: "refund_test_app", AmountFen: 500, Currency: "CNY", ExpiresAt: time.Now().Add(15 * time.Minute)}) + if err != nil { + t.Fatal(err) + } + _, err = db.SettlePaymentOrder(ctx, payments.Transaction{OrderID: order2.ID, MchID: order2.MchID, AppID: order2.AppID, TransactionID: "refund_test_payment_tx2", TradeState: "SUCCESS", Currency: "CNY", AmountFen: 500, SuccessTime: time.Now()}) + if err != nil { + t.Fatal(err) + } + late, err := db.CreatePaymentRefund(ctx, payments.Refund{ID: "refund_test_late", OrderID: order2.ID, OperatorID: "refund_test_admin", RequestKey: "refund_test_late_key", AmountFen: 350, Reason: "late success"}) + if err != nil { + t.Fatal(err) + } + lateRemote := payments.RefundTransaction{ID: late.ID, OrderID: order2.ID, MchID: order2.MchID, TransactionID: "refund_test_payment_tx2", ProviderID: "wx_refund_test_late", Status: payments.RefundClosed, Currency: "CNY", AmountFen: 350, TotalFen: 500} + if closed, err := db.RecordPaymentRefund(ctx, lateRemote); err != nil || closed.Status != payments.RefundClosed { + t.Fatalf("close refund: %+v %v", closed, err) + } + replacement, err := db.CreatePaymentRefund(ctx, payments.Refund{ID: "refund_test_replacement", OrderID: order2.ID, OperatorID: "refund_test_admin", RequestKey: "refund_test_replacement_key", AmountFen: 200, Reason: "replacement"}) + if err != nil { + t.Fatalf("reserve after close: %v", err) + } + lateRemote.Status = payments.RefundSuccess + if settled, err := db.RecordPaymentRefund(ctx, lateRemote); err != nil || settled.Status != payments.RefundSuccess { + t.Fatalf("late refund success: %+v %v", settled, err) + } + if _, err := db.RecordPaymentRefund(ctx, lateRemote); err != nil { + t.Fatalf("late refund replay: %v", err) + } + if err := pool.QueryRow(ctx, `SELECT balance_fen FROM public.billing_wallets WHERE organization_id='refund_test_org'`).Scan(&balance); err != nil { + t.Fatal(err) + } + if balance != -250 { + t.Fatalf("late success wallet balance=%d, want -250", balance) + } + if _, err := db.RejectPaymentRefund(ctx, late.ID, "明确拒绝"); !errors.Is(err, payments.ErrConflict) { + t.Fatalf("successful refund was rejected: %v", err) + } + rejectedReplacement, err := db.RejectPaymentRefund(ctx, replacement.ID, "微信明确拒绝退款") + if err != nil || rejectedReplacement.Status != payments.RefundRejected || rejectedReplacement.FailureReason != "微信明确拒绝退款" { + t.Fatalf("reject unsubmitted refund: %+v %v", rejectedReplacement, err) + } + if again, err := db.RejectPaymentRefund(ctx, replacement.ID, "different reason"); err != nil || again.FailureReason != "微信明确拒绝退款" { + t.Fatalf("rejected replay: %+v %v", again, err) + } + if replay, err := db.CreatePaymentRefund(ctx, payments.Refund{ID: "refund_test_replacement_again", OrderID: order2.ID, OperatorID: "refund_test_admin", RequestKey: "refund_test_replacement_key", AmountFen: 200, Reason: "replacement"}); err != nil || replay.ID != replacement.ID || replay.Status != payments.RefundRejected { + t.Fatalf("same request after rejection: %+v %v", replay, err) + } + var createWG sync.WaitGroup + createResults := make(chan error, 2) + for _, n := range []string{"a", "b"} { + createWG.Add(1) + go func(n string) { + defer createWG.Done() + _, err := db.CreatePaymentRefund(ctx, payments.Refund{ID: "refund_test_concurrent_" + n, OrderID: order2.ID, OperatorID: "refund_test_admin", RequestKey: "refund_test_concurrent_key_" + n, AmountFen: 100, Reason: "concurrent"}) + createResults <- err + }(n) + } + createWG.Wait() + close(createResults) + var accepted, rejected int + for err := range createResults { + if err == nil { + accepted++ + } else if errors.Is(err, payments.ErrInvalidAmount) { + rejected++ + } else { + t.Fatalf("concurrent reservation: %v", err) + } + } + if accepted != 1 || rejected != 1 { + t.Fatalf("concurrent accepted=%d rejected=%d", accepted, rejected) + } + all, err := db.ListPaymentRefunds(ctx, order2.ID) + if err != nil { + t.Fatal(err) + } + for _, item := range all { + if item.Status != payments.RefundPending { + continue + } + processing := payments.RefundTransaction{ID: item.ID, OrderID: order2.ID, MchID: order2.MchID, + TransactionID: "refund_test_payment_tx2", ProviderID: "wx_refund_test_processing", Status: payments.RefundProcessing, + Currency: "CNY", AmountFen: item.AmountFen, TotalFen: 500} + if _, err := db.RecordPaymentRefund(ctx, processing); err != nil { + t.Fatalf("processing refund: %v", err) + } + if _, err := db.RejectPaymentRefund(ctx, item.ID, "明确拒绝"); !errors.Is(err, payments.ErrConflict) { + t.Fatalf("processing refund was rejected: %v", err) + } + break + } + order3, err := db.CreatePaymentOrder(ctx, payments.Order{ID: "refund_test_order3", OrganizationID: "refund_test_org", AccountID: "refund_test_orgadmin", RequestKey: "refund_test_pay_key3", MchID: "refund_test_mch", AppID: "refund_test_app", AmountFen: 100, Currency: "CNY", ExpiresAt: time.Now().Add(15 * time.Minute)}) + if err != nil { + t.Fatal(err) + } + _, err = db.SettlePaymentOrder(ctx, payments.Transaction{OrderID: order3.ID, MchID: order3.MchID, AppID: order3.AppID, TransactionID: "refund_test_payment_tx3", TradeState: "SUCCESS", Currency: "CNY", AmountFen: 100, SuccessTime: time.Now()}) + if err != nil { + t.Fatal(err) + } + unexpected, err := db.CreatePaymentRefund(ctx, payments.Refund{ID: "refund_test_unexpected", OrderID: order3.ID, OperatorID: "refund_test_admin", RequestKey: "refund_test_unexpected_key", AmountFen: 100, Reason: "unexpected success"}) + if err != nil { + t.Fatal(err) + } + if _, err := db.RejectPaymentRefund(ctx, unexpected.ID, "微信明确拒绝退款"); err != nil { + t.Fatal(err) + } + confirmed := payments.RefundTransaction{ID: unexpected.ID, OrderID: order3.ID, MchID: order3.MchID, TransactionID: "refund_test_payment_tx3", ProviderID: "wx_refund_test_unexpected", Status: payments.RefundSuccess, Currency: "CNY", AmountFen: 100, TotalFen: 100} + if recovered, err := db.RecordPaymentRefund(ctx, confirmed); err != nil || recovered.Status != payments.RefundSuccess || recovered.FailureReason != "" { + t.Fatalf("trusted success after rejection: %+v %v", recovered, err) + } + order4, err := db.CreatePaymentOrder(ctx, payments.Order{ID: "refund_test_order4", OrganizationID: "refund_test_org", AccountID: "refund_test_orgadmin", RequestKey: "refund_test_pay_key4", MchID: "refund_test_mch", AppID: "refund_test_app", AmountFen: 100, Currency: "CNY", ExpiresAt: time.Now().Add(15 * time.Minute)}) + if err != nil { + t.Fatal(err) + } + _, err = db.SettlePaymentOrder(ctx, payments.Transaction{OrderID: order4.ID, MchID: order4.MchID, AppID: order4.AppID, TransactionID: "refund_test_payment_tx4", TradeState: "SUCCESS", Currency: "CNY", AmountFen: 100, SuccessTime: time.Now()}) + if err != nil { + t.Fatal(err) + } + recoveredRefund, err := db.CreatePaymentRefund(ctx, payments.Refund{ID: "refund_test_recovered", OrderID: order4.ID, OperatorID: "refund_test_admin", RequestKey: "refund_test_recovered_key", AmountFen: 100, Reason: "remote recovery"}) + if err != nil { + t.Fatal(err) + } + if _, err := db.RejectPaymentRefund(ctx, recoveredRefund.ID, "微信明确拒绝退款"); err != nil { + t.Fatal(err) + } + progress := payments.RefundTransaction{ID: recoveredRefund.ID, OrderID: order4.ID, MchID: order4.MchID, TransactionID: "refund_test_payment_tx4", ProviderID: "wx_refund_test_recovered", Status: payments.RefundProcessing, Currency: "CNY", AmountFen: 100, TotalFen: 100} + if resumed, err := db.RecordPaymentRefund(ctx, progress); err != nil || resumed.Status != payments.RefundProcessing || resumed.FailureReason != "" { + t.Fatalf("trusted processing after rejection: %+v %v", resumed, err) + } + pending, err := db.PendingPaymentRefunds(ctx, 100) + if err != nil { + t.Fatal(err) + } + var queued bool + for _, item := range pending { + if item.ID == recoveredRefund.ID { + queued = true + } + } + if !queued { + t.Fatal("recovered processing refund missing from reconciliation queue") + } + progress.Status = payments.RefundAbnormal + if resumed, err := db.RecordPaymentRefund(ctx, progress); err != nil || resumed.Status != payments.RefundAbnormal || resumed.FailureReason != "" { + t.Fatalf("trusted abnormal after rejection: %+v %v", resumed, err) + } + _, err = pool.Exec(ctx, `INSERT INTO public.payment_orders + (id, organization_id, account_id, request_key, mch_id, app_id, amount_fen, currency, status, transaction_id, paid_at, expires_at) + SELECT 'refund_page_' || lpad(i::text, 3, '0'), 'refund_test_org', 'refund_test_orgadmin', + 'refund_page_key_' || i, 'refund_test_mch', 'refund_test_app', 100, 'CNY', 'paid', + 'refund_page_tx_' || i, now(), now() + interval '15 minutes' + FROM generate_series(1,105) AS i`) + if err != nil { + t.Fatal(err) + } + firstPage, err := db.ListAdminPaymentOrders(ctx, "", 101, 0) + if err != nil || len(firstPage) != 101 { + t.Fatalf("admin page limit+1: len=%d err=%v", len(firstPage), err) + } + nextPage, err := db.ListAdminPaymentOrders(ctx, "", 101, 100) + if err != nil || len(nextPage) != 9 { + t.Fatalf("admin page offset: len=%d err=%v", len(nextPage), err) + } +} diff --git a/components/admin-wechat-payments.module.css b/components/admin-wechat-payments.module.css new file mode 100644 index 0000000..e03bc2a --- /dev/null +++ b/components/admin-wechat-payments.module.css @@ -0,0 +1,40 @@ +.section { margin: 22px 0; padding: 24px; border: 1px solid #e5eaf1; border-radius: 20px; background: #fff; color: #20334b; } +.header, .toolbar, .subheading, .pagination { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.header h2 { margin: 0; font-size: 20px; } +.header p, .form p { margin: 6px 0 0; color: #617187; font-size: 14px; line-height: 1.6; } +.toolbar { margin: 22px 0 16px; flex-wrap: wrap; } +.toolbar label, .field { display: grid; gap: 7px; color: #42576e; font-size: 14px; } +.toolbar select, .field input { min-width: 180px; padding: 9px 11px; border: 1px solid #ced9e5; border-radius: 9px; background: #fff; color: #20334b; font: inherit; } +.secondary, .link, .icon { display: inline-flex; align-items: center; justify-content: center; gap: 5px; border: 1px solid #d8e3ed; border-radius: 8px; padding: 8px 11px; background: #fff; color: #146e69; cursor: pointer; font: inherit; font-size: 13px; } +.link, .icon { border: 0; } +.secondary:disabled, .icon:disabled, .primary:disabled { opacity: .5; cursor: not-allowed; } +.hint, .empty { color: #718096; font-size: 14px; } +.empty { padding: 36px; border: 1px dashed #d7e1ea; border-radius: 10px; text-align: center; } +.error { color: #ad3030; font-size: 14px; } +.notice { color: #116d58; font-size: 14px; } +.tableWrap { overflow-x: auto; } +.table { width: 100%; min-width: 920px; border-collapse: collapse; text-align: left; font-size: 13px; } +.table th { padding: 11px 10px; border-bottom: 1px solid #dfe8ef; color: #617187; font-weight: 500; } +.table td { padding: 14px 10px; border-bottom: 1px solid #ecf0f4; vertical-align: top; } +.table td strong { display: block; font-weight: 600; } +.table small { display: block; margin-top: 4px; color: #708096; } +.ids span, .ids small { display: block; max-width: 225px; overflow-wrap: anywhere; } +.pagination { justify-content: flex-end; margin-top: 16px; font-size: 13px; color: #617187; } +.backdrop { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 20px; background: rgba(15, 27, 43, .55); } +.dialog { width: min(100%, 620px); max-height: 90vh; overflow: auto; padding: 24px; border-radius: 18px; background: #fff; box-shadow: 0 18px 65px rgba(0, 0, 0, .22); } +.details { display: grid; gap: 8px; margin: 20px 0; } +.details > div { display: grid; grid-template-columns: 132px minmax(0, 1fr); gap: 12px; align-items: baseline; font-size: 13px; } +.details span { color: #718096; } +.details code { overflow-wrap: anywhere; } +.subheading { border-top: 1px solid #e8eef3; padding-top: 15px; } +.subheading h3, .form h3 { margin: 0; font-size: 16px; } +.refund { padding: 12px 0; border-bottom: 1px solid #edf1f5; font-size: 13px; } +.refund small { display: block; margin-top: 5px; color: #718096; overflow-wrap: anywhere; } +.refund .failure { color: #ad3030; } +.form { display: grid; gap: 14px; margin-top: 20px; padding-top: 18px; border-top: 1px solid #e8eef3; } +.form p { margin: 0; } +.mode { display: flex; gap: 18px; flex-wrap: wrap; font-size: 14px; } +.mode label { display: inline-flex; align-items: center; gap: 6px; } +.field input { width: 100%; } +.primary { padding: 11px 15px; border: 0; border-radius: 9px; background: #087b72; color: #fff; font: inherit; font-weight: 600; cursor: pointer; } +@media (max-width: 620px) { .section, .dialog { padding: 18px; } .details > div { grid-template-columns: 100px minmax(0,1fr); } .header { align-items: flex-start; } } diff --git a/components/admin-wechat-payments.tsx b/components/admin-wechat-payments.tsx new file mode 100644 index 0000000..00fd043 --- /dev/null +++ b/components/admin-wechat-payments.tsx @@ -0,0 +1,193 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; +import { RefreshCw, X } from "lucide-react"; +import { + createPaymentRefund, getPaymentRefund, listAdminPaymentOrders, listPaymentRefunds, + parseYuanToFen, type AdminPaymentOrder, type PaymentRefund +} from "@/lib/client/payment-api"; +import { formatBillingAmount } from "@/lib/billing"; +import styles from "./admin-wechat-payments.module.css"; + +type Organization = { id: string; name: string }; + +function refundLabel(status: PaymentRefund["status"]): string { + return ({ pending: "已受理,等待微信处理", processing: "微信处理中", success: "退款成功", closed: "退款关闭", abnormal: "退款异常", rejected: "申请被拒绝" })[status]; +} + +function orderLabel(status: AdminPaymentOrder["status"]): string { + return ({ pending: "待支付", paid: "已支付", closed: "已关闭" })[status]; +} + +function displayTime(value: string | undefined): string { + if (!value) return "—"; + const date = new Date(value); + return Number.isNaN(date.getTime()) ? value : date.toLocaleString("zh-CN", { hour12: false }); +} + +export function AdminWechatPayments({ organizations, onChanged }: { organizations: Organization[]; onChanged: () => void }) { + const [organizationId, setOrganizationId] = useState(""); + const [offset, setOffset] = useState(0); + const [page, setPage] = useState<{ orders: AdminPaymentOrder[]; hasMore: boolean; enabled: boolean } | null>(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [refreshIndex, setRefreshIndex] = useState(0); + const [selected, setSelected] = useState(null); + const [refunds, setRefunds] = useState([]); + const [detailLoading, setDetailLoading] = useState(false); + const [detailError, setDetailError] = useState(null); + const [mode, setMode] = useState<"full" | "partial">("full"); + const [amountYuan, setAmountYuan] = useState(""); + const [reason, setReason] = useState(""); + const [submitting, setSubmitting] = useState(false); + const [notice, setNotice] = useState(null); + const attempt = useRef<{ intent: string; key: string } | null>(null); + const selectedRef = useRef(null); + selectedRef.current = selected; + + const reload = useCallback(() => setRefreshIndex((current) => current + 1), []); + + useEffect(() => { + const controller = new AbortController(); + setLoading(true); + setError(null); + listAdminPaymentOrders(organizationId, offset, controller.signal) + .then((result) => { + if (!controller.signal.aborted) { + setPage(result); + const current = selectedRef.current; + if (current) { + const fresh = result.orders.find((order) => order.id === current.id); + if (fresh) setSelected(fresh); + } + } + }) + .catch((cause) => { if (!controller.signal.aborted) setError(cause instanceof Error ? cause.message : String(cause)); }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => controller.abort(); + }, [organizationId, offset, refreshIndex]); + + useEffect(() => { + if (!selected) return; + const controller = new AbortController(); + setDetailLoading(true); + setDetailError(null); + listPaymentRefunds(selected.id, controller.signal) + .then((entries) => { if (!controller.signal.aborted) setRefunds(entries); }) + .catch((cause) => { if (!controller.signal.aborted) setDetailError(cause instanceof Error ? cause.message : String(cause)); }) + .finally(() => { if (!controller.signal.aborted) setDetailLoading(false); }); + return () => controller.abort(); + }, [selected?.id]); + + function openOrder(order: AdminPaymentOrder) { + setSelected(order); + setRefunds([]); + setMode("full"); + setAmountYuan(""); + setReason(""); + setNotice(null); + setDetailError(null); + attempt.current = null; + } + + async function refreshDetail() { + if (!selected) return; + setDetailLoading(true); + setDetailError(null); + try { + const entries = await listPaymentRefunds(selected.id); + const updated = await Promise.all(entries.map(async (entry) => { + if (entry.status === "pending" || entry.status === "processing" || entry.status === "abnormal") { + return getPaymentRefund(entry.id); + } + return entry; + })); + setRefunds(updated); + reload(); + onChanged(); + } catch (cause) { + setDetailError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setDetailLoading(false); + } + } + + async function submit(event: FormEvent) { + event.preventDefault(); + if (!selected || submitting) return; + const amountFen = mode === "full" ? selected.refundableFen : parseYuanToFen(amountYuan, 1, selected.refundableFen); + const cleanReason = reason.trim(); + if (!amountFen || !cleanReason || new TextEncoder().encode(cleanReason).length > 80) { + setDetailError("请填写有效退款金额和原因(原因不超过 80 字节)。"); + return; + } + const intent = `${selected.id}:${amountFen}:${cleanReason}`; + if (attempt.current?.intent !== intent) attempt.current = { intent, key: crypto.randomUUID() }; + setSubmitting(true); + setDetailError(null); + setNotice(null); + try { + const refund = await createPaymentRefund(selected.id, amountFen, cleanReason, attempt.current.key); + setRefunds((current) => [refund, ...current.filter((entry) => entry.id !== refund.id)]); + if (refund.status === "pending" || refund.status === "processing" || refund.status === "success") { + setSelected((current) => current?.id === selected.id ? { + ...current, + refundableFen: Math.max(0, current.refundableFen - refund.amountFen), + refundingFen: current.refundingFen + (refund.status === "success" ? 0 : refund.amountFen), + refundedFen: current.refundedFen + (refund.status === "success" ? refund.amountFen : 0) + } : current); + } + if (refund.status === "rejected") { + setDetailError(`退款申请被拒绝${refund.failureReason ? `:${refund.failureReason}` : ",请核对后重试"}。`); + } else if (refund.status === "closed") { + setDetailError("退款已关闭,款项未退回;请刷新可退金额后重试。"); + } else { + setNotice(refund.status === "success" ? "微信退款成功,组织余额已扣减。" : `退款申请已提交:${refundLabel(refund.status)}。请稍后刷新查看最终结果。`); + } + attempt.current = null; + reload(); + onChanged(); + } catch (cause) { + setDetailError(cause instanceof Error ? cause.message : String(cause)); + } finally { + setSubmitting(false); + } + } + + return
+

微信充值记录

查看所有组织的扫码充值与原路退款。

+
+ + {page && !page.enabled ? 支付服务当前未启用,仍可查看已有记录。 : null} +
+ {error ?

读取充值记录失败:{error}

: null} + {loading ?

正在读取充值记录…

: null} + {!loading && !error && page?.orders.length === 0 ?

暂无微信充值记录。

: null} + {!error && page?.orders.length ?
{page.orders.map((order) => + + + + + + + )}
组织 / 充值人订单 / 微信交易号充值金额状态 / 退款时间
{order.organizationName || order.organizationId}{order.accountName || order.accountId}{order.id}{order.transactionId || "暂无微信交易号"}{formatBillingAmount(order.amountFen)}{orderLabel(order.status)}已退 {formatBillingAmount(order.refundedFen)} · 处理中 {formatBillingAmount(order.refundingFen)}可退 {formatBillingAmount(order.refundableFen)}{displayTime(order.paidAt || order.createdAt)}
: null} +
第 {Math.floor(offset / 50) + 1} 页
+ {selected ?
{ if (event.target === event.currentTarget && !submitting) setSelected(null); }}>
+

充值与退款详情

+
组织{selected.organizationName || selected.organizationId}
充值人{selected.accountName || selected.accountId}
充值金额{formatBillingAmount(selected.amountFen)}
已退 / 处理中 / 可退{formatBillingAmount(selected.refundedFen)} / {formatBillingAmount(selected.refundingFen)} / {formatBillingAmount(selected.refundableFen)}
订单号{selected.id}
微信交易号{selected.transactionId || "—"}
+

退款记录

+ {detailError ?

{detailError}

: null} + {notice ?

{notice}

: null} + {detailLoading ?

正在查询退款记录…

: null} + {!detailLoading && refunds.length === 0 ?

暂无退款记录。

: null} + {refunds.map((refund) =>
{formatBillingAmount(refund.amountFen)} · {refundLabel(refund.status)}{displayTime(refund.createdAt)} · 操作人:{refund.operatorName || refund.operatorId}原因:{refund.reason}{refund.status === "rejected" && refund.failureReason ? 拒绝原因:{refund.failureReason} : null}退款单号:{refund.id}{refund.providerId ? ` · 微信退款单号:${refund.providerId}` : ""}
)} + {selected.status === "paid" && selected.refundableFen > 0 && page?.enabled ?
void submit(event)}> +

发起原路退款

退款成功后会从「{selected.organizationName || selected.organizationId}」的余额扣除本次退款金额;余额不足时将变为负数。

+
+ {mode === "partial" ? : 本次退款:{formatBillingAmount(selected.refundableFen)}} + + +
: null} +
: null} +
; +} diff --git a/components/billing-manager.tsx b/components/billing-manager.tsx index 2a27d5c..7ae4b0d 100644 --- a/components/billing-manager.tsx +++ b/components/billing-manager.tsx @@ -21,6 +21,8 @@ import { } from "lucide-react"; import { billingUnitLabel, formatBillingAmount } from "@/lib/billing"; import { parseAdminBillingPayload, parseMemberBillingPayload } from "@/lib/client/billing-api"; +import { WechatRecharge } from "@/components/wechat-recharge"; +import { AdminWechatPayments } from "@/components/admin-wechat-payments"; import { groupedPriceRuleSections, priceCatalogBulkTarget, priceTierEditTarget, type PriceEditTarget } from "@/lib/client/billing-price-catalog"; import { pulseFeedback, revealChildren, runScopedMotion } from "@/lib/ui/motion"; import type { BillingAccountConfig, BillingParameterDimension, BillingParameterTier, BillingPriceRule, OrganizationWallet } from "@/lib/types"; @@ -39,7 +41,7 @@ type LedgerEntry = { organizationId: string; accountId?: string; jobId?: string; - kind: "recharge" | "charge" | "refund" | "adjustment"; + kind: "recharge" | "charge" | "refund" | "recharge_refund" | "adjustment"; deltaFen: number; balanceAfterFen: number; description: string; @@ -76,7 +78,7 @@ type AdminPayload = { priceRules: BillingPriceRule[]; }; -type BillingTabId = "overview" | "ledger" | "pricing" | "balance" | "account"; +type BillingTabId = "overview" | "ledger" | "pricing" | "balance" | "account" | "wechat"; type BillingTabDefinition = { id: BillingTabId; @@ -129,6 +131,7 @@ export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) { { id: "overview", label: "概览" }, { id: "pricing", label: "价格与计费" }, { id: "balance", label: "余额与上账" }, + { id: "wechat", label: "微信充值" }, { id: "account", label: "收款设置" } ] : [ { id: "overview", label: "概览" }, @@ -300,6 +303,7 @@ export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) { {!isSuperAdmin && billing ? ( <> {activeTab === "overview" ? <> + void load()} /> setActiveTab("ledger")} />} /> : null} {activeTab === "ledger" ? 最近 {Math.min(billing.ledger.length, 100)} 条} /> : null} @@ -311,6 +315,7 @@ export function BillingManager({ isSuperAdmin }: { isSuperAdmin: boolean }) { {activeTab === "overview" ? : null} {activeTab === "pricing" ? setPriceEditState({ rule, target })} onBulkEdit={() => { setError(null); setBulkPriceEditOpen(true); }} /> : null} {activeTab === "balance" ? : null} + {activeTab === "wechat" ? void load()} /> : null} {activeTab === "account" ? setEditingAccount(true)} onCancel={() => { setEditingAccount(false); setAccountDraft({ ...emptyAccountDraft, ...admin.billingAccount }); }} onChange={(key, value) => setAccountDraft((current) => ({ ...current, [key]: value }))} onSubmit={saveBillingAccount} /> : null} ) : null} diff --git a/components/wechat-recharge.module.css b/components/wechat-recharge.module.css new file mode 100644 index 0000000..01b0031 --- /dev/null +++ b/components/wechat-recharge.module.css @@ -0,0 +1,37 @@ +.section { margin: 22px 0; padding: 24px; border: 1px solid #e5eaf1; border-radius: 20px; background: #fff; } +.heading, .orderHeading, .dialogHead { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +.heading h2, .orderHeading h3, .dialogHead h2 { margin: 0; color: #17263c; } +.heading h2 { font-size: 20px; } +.heading p, .dialogHead p { margin: 6px 0 0; color: #66758b; font-size: 14px; } +.orderHeading { margin: 22px 0 10px; } +.orderHeading h3 { font-size: 15px; } +.orderHeading button, .dialogHead button { display: inline-flex; align-items: center; gap: 5px; border: 0; background: transparent; color: #48617d; cursor: pointer; } +.orders { display: grid; gap: 8px; } +.order { width: 100%; padding: 12px 14px; display: flex; justify-content: space-between; align-items: center; gap: 12px; text-align: left; border: 1px solid #e7edf3; border-radius: 12px; background: #fbfcfe; cursor: pointer; } +.order:hover { border-color: #95acc7; } +.order span:first-child { display: grid; gap: 3px; } +.order strong { color: #1b2c43; } +.order small, .note { color: #718096; font-size: 13px; } +.status { color: #315b86; font-size: 13px; } +.backdrop { position: fixed; inset: 0; z-index: 1000; display: grid; place-items: center; padding: 20px; background: rgba(15, 27, 43, .55); } +.dialog { width: min(100%, 460px); max-height: min(90vh, 850px); overflow: auto; padding: 24px; border-radius: 18px; background: #fff; box-shadow: 0 18px 65px rgba(0, 0, 0, .22); } +.dialogHead { align-items: flex-start; border-bottom: 1px solid #e9eef4; padding-bottom: 18px; } +.dialogHead h2 { font-size: 20px; } +.form { display: grid; gap: 15px; padding-top: 20px; } +.label { font-weight: 600; color: #263c55; } +.presets { display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px; } +.presets button { padding: 11px 5px; border: 1px solid #dce5ef; border-radius: 10px; background: #f8fafc; color: #294867; font-weight: 600; cursor: pointer; } +.presets button.selected { border-color: #327bd1; background: #eaf4ff; color: #1764ae; } +.custom { display: grid; gap: 7px; font-size: 14px; color: #3a4e65; } +.custom input { width: 100%; padding: 11px 12px; border: 1px solid #cedae8; border-radius: 9px; font: inherit; } +.custom input[aria-invalid="true"] { border-color: #c64848; } +.form small, .payment small { color: #718096; } +.form :global(.billing-action-button) { justify-content: center; } +.payment { display: grid; justify-items: center; gap: 10px; padding-top: 20px; text-align: center; } +.payment p { margin: 2px 0; color: #455b73; } +.payment .amount { font-size: 30px; color: #172d47; } +.qr { padding: 12px; border: 1px solid #e2e9f1; border-radius: 10px; background: #fff; } +.payment small { line-height: 1.6; } +.payment :global(.billing-secondary-button) { margin-top: 6px; } +.error { margin: 12px 0 0; color: #b12929; font-size: 13px; } +@media (max-width: 600px) { .section { padding: 18px; } .heading { align-items: flex-start; flex-direction: column; } .presets { grid-template-columns: repeat(2, 1fr); } } diff --git a/components/wechat-recharge.tsx b/components/wechat-recharge.tsx new file mode 100644 index 0000000..d9dc352 --- /dev/null +++ b/components/wechat-recharge.tsx @@ -0,0 +1,241 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState, type FormEvent } from "react"; +import { QRCodeSVG } from "qrcode.react"; +import { Loader2, RefreshCw, WalletCards, X } from "lucide-react"; +import { + createPaymentOrder, + formatYuan, + getPaymentOptions, + getPaymentOrder, + listPaymentOrders, + parseYuanToFen, + type PaymentOptions, + type PaymentOrder +} from "@/lib/client/payment-api"; +import styles from "./wechat-recharge.module.css"; + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : "支付服务暂时不可用"; +} + +function orderState(order: PaymentOrder): string { + if (order.status === "paid") return "已支付"; + if (order.status === "closed") return "已关闭"; + return Date.parse(order.expiresAt) <= Date.now() ? "确认中" : "待支付"; +} + +function preferSettledOrder(current: PaymentOrder, incoming: PaymentOrder): PaymentOrder { + return current.status !== "pending" && incoming.status === "pending" ? current : incoming; +} + +function updateOrderList(current: PaymentOrder[], incoming: PaymentOrder): PaymentOrder[] { + const existing = current.find((order) => order.id === incoming.id); + return [existing ? preferSettledOrder(existing, incoming) : incoming, ...current.filter((order) => order.id !== incoming.id)]; +} + +export function WechatRecharge({ organizationName, onPaid }: { organizationName: string; onPaid: () => void }) { + const [options, setOptions] = useState(null); + const [orders, setOrders] = useState([]); + const [selectedFen, setSelectedFen] = useState(null); + const [customYuan, setCustomYuan] = useState(""); + const [open, setOpen] = useState(false); + const [activeOrder, setActiveOrder] = useState(null); + const [loading, setLoading] = useState(true); + const [submitting, setSubmitting] = useState(false); + const [error, setError] = useState(null); + const attemptRef = useRef<{ amountFen: number; requestKey: string } | null>(null); + const activeRef = useRef(false); + const modalGenerationRef = useRef(0); + const paidNotifiedRef = useRef(new Set()); + const onPaidRef = useRef(onPaid); + const closeButtonRef = useRef(null); + + useEffect(() => { onPaidRef.current = onPaid; }, [onPaid]); + + const refreshOrders = useCallback(async () => { + const next = await listPaymentOrders(); + setOrders(next); + setError(null); + }, []); + + useEffect(() => { + const controller = new AbortController(); + getPaymentOptions(controller.signal) + .then(async (nextOptions) => { + if (controller.signal.aborted) return; + setOptions(nextOptions); + setSelectedFen(nextOptions.presetAmountsFen[0] ?? null); + setLoading(false); + if (!nextOptions.enabled || !nextOptions.canRecharge) return; + try { + const nextOrders = await listPaymentOrders(controller.signal); + if (!controller.signal.aborted) setOrders(nextOrders); + } catch (cause) { + if (!controller.signal.aborted) setError(errorMessage(cause)); + } + }) + .catch((cause) => { if (!controller.signal.aborted) setError(errorMessage(cause)); }) + .finally(() => { if (!controller.signal.aborted) setLoading(false); }); + return () => { controller.abort(); activeRef.current = false; modalGenerationRef.current += 1; }; + }, []); + + useEffect(() => { + activeRef.current = open; + }, [open]); + + function closeDialog() { + modalGenerationRef.current += 1; + activeRef.current = false; + setSubmitting(false); + setOpen(false); + } + + function openNew() { + modalGenerationRef.current += 1; + activeRef.current = true; + setActiveOrder(null); + setError(null); + setOpen(true); + } + + useEffect(() => { + if (!open) return; + const previousFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null; + closeButtonRef.current?.focus(); + function onKeyDown(event: KeyboardEvent) { + if (event.key === "Escape") closeDialog(); + if (event.key !== "Tab") return; + const dialog = document.getElementById("wechat-recharge-dialog"); + const focusable = [...(dialog?.querySelectorAll("button:not(:disabled), input:not(:disabled)") || [])]; + const first = focusable[0]; + const last = focusable.at(-1); + if (!first || !last) return; + if (event.shiftKey && document.activeElement === first) { event.preventDefault(); last.focus(); } + else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus(); } + } + document.addEventListener("keydown", onKeyDown); + return () => { document.removeEventListener("keydown", onKeyDown); previousFocus?.focus(); }; + }, [open]); + + useEffect(() => { + if (!open || activeOrder?.status !== "pending") return; + const controller = new AbortController(); + let timer: ReturnType | undefined; + const orderId = activeOrder.id; + const generation = modalGenerationRef.current; + async function poll() { + try { + const next = await getPaymentOrder(orderId, controller.signal); + if (controller.signal.aborted || !activeRef.current || generation !== modalGenerationRef.current) return; + setActiveOrder((current) => current?.id === orderId ? preferSettledOrder(current, next) : current); + setOrders((current) => updateOrderList(current, next)); + if (next.status === "paid" && !paidNotifiedRef.current.has(next.id)) { + paidNotifiedRef.current.add(next.id); + onPaidRef.current(); + } + if (next.status === "pending") timer = setTimeout(poll, 3000); + } catch (cause) { + if (controller.signal.aborted || !activeRef.current || generation !== modalGenerationRef.current) return; + setError(errorMessage(cause)); + timer = setTimeout(poll, 3000); + } + } + timer = setTimeout(poll, 3000); + return () => { controller.abort(); if (timer) clearTimeout(timer); }; + }, [activeOrder?.id, activeOrder?.status, open]); + + if (loading) return null; + if (!options?.enabled || !options.canRecharge) return null; + + const customFen = options ? parseYuanToFen(customYuan, options.minAmountFen, options.maxAmountFen) : null; + const amountFen = selectedFen === null ? customFen : selectedFen; + + function choosePreset(fen: number) { + modalGenerationRef.current += 1; + setSubmitting(false); + setSelectedFen(fen); + setCustomYuan(""); + setError(null); + } + + function chooseCustom(value: string) { + modalGenerationRef.current += 1; + setSubmitting(false); + setSelectedFen(null); + setCustomYuan(value); + setError(null); + } + + async function startPayment(event: FormEvent) { + event.preventDefault(); + if (amountFen === null || !options?.canRecharge || submitting) return; + const generation = modalGenerationRef.current; + setError(null); + setSubmitting(true); + try { + if (!attemptRef.current || attemptRef.current.amountFen !== amountFen) { + attemptRef.current = { amountFen, requestKey: crypto.randomUUID() }; + } + const order = await createPaymentOrder(amountFen, attemptRef.current.requestKey); + if (!activeRef.current || generation !== modalGenerationRef.current) return; + setActiveOrder(order); + setOrders((current) => updateOrderList(current, order)); + if (order.status === "paid" && !paidNotifiedRef.current.has(order.id)) { + paidNotifiedRef.current.add(order.id); + onPaidRef.current(); + } + attemptRef.current = null; + } catch (cause) { + if (activeRef.current && generation === modalGenerationRef.current) setError(errorMessage(cause)); + } finally { + if (generation === modalGenerationRef.current) setSubmitting(false); + } + } + + function openOrder(order: PaymentOrder) { + modalGenerationRef.current += 1; + activeRef.current = true; + setError(null); + setActiveOrder(order); + setOpen(true); + } + + return
+
+

微信扫码充值

充值到账后计入「{organizationName}」的共享余额。

+ +
+ {error && !open ?

{error}

: null} +

最近支付订单

+ {orders.length ?
{orders.slice(0, 8).map((order) => )}
:

暂无支付订单。

} + + {open ?
{ if (event.target === event.currentTarget) closeDialog(); }}> + +
: null} +
; +} diff --git a/contracts/http/route-surface.v1.json b/contracts/http/route-surface.v1.json index 6fc93db..5a334dd 100644 --- a/contracts/http/route-surface.v1.json +++ b/contracts/http/route-surface.v1.json @@ -71,6 +71,16 @@ { "method": "POST", "path": "/api/v1/jobs/{id}/cancel" }, { "method": "GET", "path": "/api/v1/openapi.json" }, { "method": "GET", "path": "/generated-results/{path...}" }, - { "method": "GET", "path": "/uploads/{path...}" } + { "method": "GET", "path": "/uploads/{path...}" }, + { "method": "GET", "path": "/api/payments/options" }, + { "method": "GET", "path": "/api/payments/orders" }, + { "method": "POST", "path": "/api/payments/orders" }, + { "method": "GET", "path": "/api/payments/orders/{id}" }, + { "method": "POST", "path": "/api/payments/wechat/notify" }, + { "method": "GET", "path": "/api/payments/admin/orders" }, + { "method": "GET", "path": "/api/payments/admin/orders/{id}/refunds" }, + { "method": "POST", "path": "/api/payments/admin/orders/{id}/refunds" }, + { "method": "GET", "path": "/api/payments/admin/refunds/{id}" }, + { "method": "POST", "path": "/api/payments/wechat/refund-notify" } ] } diff --git a/database/migrations/0007_wechat_native_payments.sql b/database/migrations/0007_wechat_native_payments.sql new file mode 100644 index 0000000..21d0f74 --- /dev/null +++ b/database/migrations/0007_wechat_native_payments.sql @@ -0,0 +1,32 @@ +-- Orders are written before calling WeChat. Keep them when a user or +-- organization is deleted so paid money cannot lose its audit trail. +create table if not exists public.payment_orders ( + id text primary key check (length(id) between 1 and 32 and id ~ '^[A-Za-z0-9_-]+$'), + organization_id text not null references public.platform_organizations(id) on delete restrict, + account_id text not null references public.platform_users(id) on delete restrict, + request_key text not null check (length(request_key) between 1 and 64 and request_key ~ '^[A-Za-z0-9_-]+$'), + mch_id text not null check (length(mch_id) between 1 and 64), + app_id text not null check (length(app_id) between 1 and 64), + amount_fen bigint not null check (amount_fen between 1 and 1000000), + currency text not null check (currency = 'CNY'), + status text not null default 'pending' check (status in ('pending', 'paid', 'closed')), + code_url text, + transaction_id text, + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + expires_at timestamptz not null, + paid_at timestamptz, + constraint payment_orders_paid_fields_check check ( + (status = 'paid' and transaction_id is not null and length(transaction_id) > 0 and paid_at is not null) + or (status <> 'paid' and transaction_id is null and paid_at is null) + ), + constraint payment_orders_expiry_check check (expires_at > created_at), + constraint payment_orders_org_request_unique unique (organization_id, request_key) +); + +create unique index if not exists payment_orders_provider_transaction_unique + on public.payment_orders(mch_id, transaction_id) where transaction_id is not null; +create index if not exists payment_orders_org_created_idx + on public.payment_orders(organization_id, created_at desc); +create index if not exists payment_orders_pending_check_idx + on public.payment_orders(updated_at, created_at) where status = 'pending'; diff --git a/database/migrations/0008_wechat_refunds.sql b/database/migrations/0008_wechat_refunds.sql new file mode 100644 index 0000000..4a573fb --- /dev/null +++ b/database/migrations/0008_wechat_refunds.sql @@ -0,0 +1,34 @@ +-- Reserve each requested refund against its original, successful payment. +-- A successful external refund can exceed the current wallet balance. +alter table public.billing_wallets drop constraint if exists billing_wallets_balance_fen_check; +alter table public.billing_ledger drop constraint if exists billing_ledger_balance_after_fen_check; +alter table public.billing_ledger drop constraint if exists billing_ledger_kind_check; +alter table public.billing_ledger add constraint billing_ledger_kind_check + check (kind in ('recharge', 'charge', 'refund', 'adjustment', 'recharge_refund')); + +create table public.payment_refunds ( + id text primary key check (length(id) between 1 and 32 and id ~ '^[A-Za-z0-9_-]+$'), + order_id text not null references public.payment_orders(id) on delete restrict, + organization_id text not null references public.platform_organizations(id) on delete restrict, + operator_id text not null references public.platform_users(id) on delete restrict, + request_key text not null check (length(request_key) between 1 and 64 and request_key ~ '^[A-Za-z0-9_-]+$'), + mch_id text not null check (length(mch_id) between 1 and 64), + transaction_id text not null check (length(transaction_id) > 0), + provider_id text, + amount_fen bigint not null check (amount_fen > 0), + total_fen bigint not null check (total_fen > 0 and total_fen >= amount_fen), + currency text not null check (currency = 'CNY'), + reason text not null, + status text not null default 'pending' check (status in ('pending', 'processing', 'success', 'closed', 'abnormal', 'rejected')), + failure_reason text not null default '', + created_at timestamptz not null default now(), + updated_at timestamptz not null default now(), + succeeded_at timestamptz, + constraint payment_refunds_request_unique unique (order_id, request_key), + constraint payment_refunds_success_check check ((status = 'success') = (succeeded_at is not null)) +); +create unique index payment_refunds_provider_unique on public.payment_refunds(mch_id, provider_id) + where provider_id is not null; +create index payment_refunds_order_idx on public.payment_refunds(order_id, created_at desc, id desc); +create index payment_refunds_pending_idx on public.payment_refunds(updated_at, created_at, id) + where status in ('pending', 'processing', 'abnormal'); diff --git a/deploy/ack/configmap.yaml b/deploy/ack/configmap.yaml index 7c2ff25..a67f8f8 100644 --- a/deploy/ack/configmap.yaml +++ b/deploy/ack/configmap.yaml @@ -10,6 +10,11 @@ data: GO_BACKEND_HOST: 0.0.0.0 GO_BACKEND_PORT: "8080" ZHINIAN_DATA_BACKEND: postgres + # Run the checked-in Go migration bundle before the API starts listening. + DATABASE_AUTO_MIGRATE: "true" + DATABASE_MIGRATION_TIMEOUT_SECONDS: "180" + # PostgreSQL role named by zhinian-go-db's APP_USER; replace before deploy. + DATABASE_APP_ROLE: REPLACE_WITH_APP_DB_ROLE ZHINIAN_AUTH_REQUIRED: "true" ZHINIAN_AUTH_COOKIE_SECURE: "true" ZHINIAN_PUBLIC_BASE_URL: https://REPLACE_WITH_PUBLIC_HOST @@ -17,3 +22,8 @@ data: ZHINIAN_RUNTIME_DIR: /var/lib/zhinian/runtime ZHINIAN_LOG_DIR: /var/lib/zhinian/logs ZHINIAN_SETTINGS_FILE: /var/lib/zhinian/settings.env + WECHAT_PAY_ENABLED: "false" + WECHAT_PAY_NOTIFY_URL: https://REPLACE_WITH_PUBLIC_HOST/api/payments/wechat/notify + WECHAT_PAY_MIN_AMOUNT_FEN: "100" + WECHAT_PAY_MAX_AMOUNT_FEN: "1000000" + WECHAT_PAY_PRESET_AMOUNTS_FEN: "10000,50000,100000" diff --git a/deploy/ack/go-api.yaml b/deploy/ack/go-api.yaml index 4bd65db..60e7958 100644 --- a/deploy/ack/go-api.yaml +++ b/deploy/ack/go-api.yaml @@ -42,12 +42,22 @@ spec: - secretRef: name: zhinian-go-providers optional: true + - secretRef: + name: zhinian-go-wechat-pay + optional: true env: - name: DATABASE_URL valueFrom: secretKeyRef: name: zhinian-go-db key: DATABASE_URL + # Migration credentials stay separate from the least-privilege + # application DATABASE_URL above. Never inject this Secret via envFrom. + - name: DATABASE_MIGRATION_URL + valueFrom: + secretKeyRef: + name: zhinian-migration-db + key: DATABASE_URL # Go alone creates and verifies the browser session cookie. The # static Web workload must never receive this signing secret. - name: ZHINIAN_AUTH_SESSION_SECRET @@ -75,7 +85,8 @@ spec: path: /api/health port: http periodSeconds: 5 - failureThreshold: 24 + # 180s migration deadline plus DB/bootstrap and startup headroom. + failureThreshold: 60 readinessProbe: httpGet: path: /api/ready diff --git a/deploy/ack/secrets.example.yaml b/deploy/ack/secrets.example.yaml index 92d9e16..c608ce6 100644 --- a/deploy/ack/secrets.example.yaml +++ b/deploy/ack/secrets.example.yaml @@ -8,7 +8,9 @@ metadata: namespace: zhinian type: Opaque stringData: - # Manual schema execution only. This connection intentionally uses plaintext. + # Startup migration connection, also usable for manual migrations. This + # account needs schema ownership/DDL and grant permissions. Plaintext RDS + # transport is limited to the private VPC by this deployment's DB policy. DATABASE_URL: "postgresql://MIGRATION_USER:MIGRATION_PASSWORD@RDS_INTERNAL_HOST:5432/APP_DATABASE?sslmode=disable" --- apiVersion: v1 @@ -46,7 +48,9 @@ metadata: namespace: zhinian type: Opaque stringData: - # Application role (least privilege). PostgreSQL transport is plaintext. + # Least-privilege application role. Set DATABASE_APP_ROLE in configmap.yaml + # to this connection's PostgreSQL user (APP_USER) so migrations grant it + # access to new tables/functions. PostgreSQL transport is plaintext. DATABASE_URL: "postgresql://APP_USER:APP_PASSWORD@RDS_INTERNAL_HOST:5432/APP_DATABASE?sslmode=disable" --- apiVersion: v1 @@ -60,3 +64,21 @@ stringData: # administrator exists. Password must be at least 8 characters. ZHINIAN_BOOTSTRAP_ADMIN_PHONE: REPLACE_WITH_ADMIN_PHONE ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD: REPLACE_WITH_STRONG_PASSWORD +--- +# Optional. Apply only when enabling WeChat Native payments; Go-only envFrom. +apiVersion: v1 +kind: Secret +metadata: + name: zhinian-go-wechat-pay + namespace: zhinian +type: Opaque +stringData: + WECHAT_PAY_MCH_ID: REPLACE_WITH_MERCHANT_ID + WECHAT_PAY_APP_ID: REPLACE_WITH_BOUND_APP_ID + WECHAT_PAY_MERCHANT_SERIAL: REPLACE_WITH_MERCHANT_CERT_SERIAL + WECHAT_PAY_API_V3_KEY: REPLACE_WITH_32_BYTE_API_V3_KEY + WECHAT_PAY_PUBLIC_KEY_ID: REPLACE_WITH_WECHAT_PUBLIC_KEY_ID + WECHAT_PAY_PRIVATE_KEY_PEM: | + REPLACE_WITH_PKCS8_MERCHANT_PRIVATE_KEY_PEM + WECHAT_PAY_PUBLIC_KEY_PEM: | + REPLACE_WITH_WECHAT_PAYMENT_PUBLIC_KEY_PEM diff --git a/docs/API.md b/docs/API.md index 929962a..eb2ec49 100644 --- a/docs/API.md +++ b/docs/API.md @@ -86,7 +86,18 @@ curl -X POST https://你的域名/api/v1/jobs \ 开放 API 仍按 API Key 和账号分区运行。当前未绑定组织的开放 API 任务保持兼容,不从组织钱包扣费;平台浏览器用户的真实图片/视频任务会按超级管理员配置的服务商标准单价、计费单位和上浮倍率,从所属组织余额冻结。普通用户余额不足时不会提交服务商并返回余额不足错误;超级管理员仍保存计算费用,但不检查、冻结或扣减组织额度,也不产生钱包扣费、退款流水。计费规则与最终金额会随任务保存,任务失败、取消或过期后由 Worker 在最终终态退款;Seedance 成功后按 `usage.completion_tokens` 多退少补,缺少该字段时保留冻结金额。MiniMax H3 按实际输出秒数及输入图片数结算;Wan 3.0 按 `usage.output_video_duration` 的实际时长结算(无视频输入时可回退 `usage.duration`),缺少有效用量时保留预扣并标记为估算。 -组织余额当前只能由超级管理员通过 `/billing` 的“余额与上账”直接入账。所有充值和人工余额调整都只记入组织账本,不存在个人上账归属;组织管理员和员工共同使用组织额度。未来接入支付时,支付成功回调应使用同一幂等上账逻辑自动入账,不产生待审核申请。余额不足时,平台会拒绝创建真实计费任务并返回错误;未配置真实服务商凭据时也不会生成占位结果。 +组织余额可由超级管理员通过 `/billing` 的“余额与上账”手工入账,也可在启用微信 Native 支付后由组织管理员扫码充值。所有充值和人工余额调整均计入组织账本,组织管理员和员工共同使用额度。支付回调和补单经核验后,在一个数据库事务中更新支付状态并幂等入账。超级管理员还能在「计费 → 微信充值」查看所有组织的订单并发起部分或剩余金额的微信原路退款;微信确认成功后,组织余额才会扣除,必要时允许变为负数。退款写入独立的 `recharge_refund` 账本类型,与生成失败返还余额区分。余额不足时,平台会拒绝创建真实计费任务并返回错误;未配置真实服务商凭据时也不会生成占位结果。 + +支付后台接口使用平台登录会话,不接受本页开放 API 的 API Key。只有超级管理员可以调用: + +| 接口 | 用途 | +| --- | --- | +| `GET /api/payments/admin/orders?organizationId=&limit=50&offset=0` | 分页查看跨组织充值订单;可按组织筛选,返回已退、退款中和可退金额 | +| `GET /api/payments/admin/orders/{id}/refunds` | 查看指定订单的退款记录 | +| `POST /api/payments/admin/orders/{id}/refunds` | 发起退款,请求体 `{ "amountFen": 5000, "requestKey": "唯一请求ID", "reason": "客户申请" }` | +| `GET /api/payments/admin/refunds/{id}` | 查询退款状态 | + +退款原因必填、最多 80 个 UTF-8 字节;同一 `requestKey` 重试不会创建第二笔退款。`pending` 和 `processing` 状态占用可退额度,但不会立即扣除余额;`success` 才扣除,`closed` 释放额度,`abnormal` 保留占用等待核对。关闭充值开关后仍可查看历史订单和退款,不能发起新退款。微信退款通知走无需登录的 `POST /api/payments/wechat/refund-notify`,由后端验签、解密和核对;回调 URL 从支付回调域名派生,无需另设环境变量。接口、配置和财务处理见 [微信支付说明](PAYMENTS.md)。 创作页通过 `GET /api/billing/balance` 展示当前组织的可用余额。接口仅接受平台会话认证,从服务端刷新后的会话确定组织,忽略客户端组织参数,返回 `organization` 和 `wallet`,不加载账本或价格目录,并设置 `Cache-Control: no-store`。未绑定组织返回 422。页面支持手动刷新,并在提交任务、任务计费状态变化、重新切回页面及可见状态下每 30 秒刷新;超级管理员显示“超管不计额度”。余额提示供用户参考,实际扣费及余额校验仍由服务端执行。 diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md index 8a9c453..5717b15 100644 --- a/docs/DEPLOYMENT.md +++ b/docs/DEPLOYMENT.md @@ -47,29 +47,35 @@ docker build -f backend/Dockerfile.alpine \ 生产固定使用 PostgreSQL。数据库连接只通过 Go Deployment 的 Secret 注入,不得进入 Web 镜像或 ConfigMap。优先使用 RDS 内网地址,并仅对白名单中的 ACK 工作负载网段放行。 -`DATABASE_URL` 必须使用 `sslmode=disable`。Go 和手工迁移使用的 Node 客户端都会强制 +`DATABASE_URL` 和 `DATABASE_MIGRATION_URL` 必须使用 `sslmode=disable`。Go 和手工迁移使用的 Node 客户端都会强制 归一化为该值;即使旧 Secret 仍包含 TLS 参数,客户端也不会读取 CA 或协商 TLS。 这表示 ACK 到 RDS 的数据库流量不使用 TLS、链路内容为明文。只应使用 RDS 内网地址, 并通过 VPC、安全组和白名单严格限制访问;若未来需要链路加密,必须同时修改客户端策略 和 RDS 配置后再部署。 -首次发布前,部署负责人按顺序手工执行: +ACK 模板设置 `DATABASE_AUTO_MIGRATE=true`。Go API 启动时先连接 +`DATABASE_MIGRATION_URL`(`zhinian-migration-db` Secret),按版本顺序执行镜像内已 +同步的 `database/migrations/*.sql`,并用 `DATABASE_APP_ROLE` 给应用账号授权;全部成功后 +才启动 HTTP 服务。该变量未提供时,启动器会回退使用 `DATABASE_URL`,但 ACK 明确使用 +独立迁移账号。`DATABASE_APP_ROLE` 必须填写为 `zhinian-go-db` 连接串中的 PostgreSQL +用户名,且迁移账号须具备建表、修改表和授权权限。`DATABASE_MIGRATION_URL` 与 +`DATABASE_URL` 的主机、端口和数据库名必须相同,连接用户名可以不同;启动器会检查。 +默认启动迁移超时 180 秒;对应的 startupProbe 提供约 300 秒预算。每个迁移版本的 DDL +与版本记录在同一事务中;失败的版本会回滚,之前已成功提交的版本保留。迁移失败时 Pod +不监听端口,查看 Go Pod 日志并修复原因后再重启。回滚镜像不会逆向撤销数据库迁移。 +遇到历史重复数据,先人工审计,不能跳过或 +自动删除计费与用量记录。 -1. `database/migrations/0001_initial_schema.sql` -2. `database/migrations/0002_generation_lifecycle_fencing.sql` -3. `database/migrations/0003_platform_runtime_settings.sql` -4. `database/migrations/0004_seedream_billing_provider.sql` -5. `database/migrations/0005_seedream_layer_compositions.sql` -6. `database/migrations/0006_minimax_h3_billing_provider.sql` -7. 应用角色授权语句(参考 `scripts/migrate-postgres.mjs`) +每次新增迁移,在仓库根目录运行 `npm run db:bundle`,把根目录 SQL 同步成提交到 Git 的 +Go 迁移包,然后构建 Go 镜像;不能修改已应用的历史 SQL。新镜像启动时会核对版本记录, +只执行未应用的迁移,重启不会重复建表。手工处理仍可从仓库根目录执行 +`npm run db:migrate`;其连接账号也应具备上述权限。生产不部署迁移 Job 或 Node Worker, +Go 镜像运行时不需要 Node/npm,也不用在 ACK「启动执行」中填写命令。 -升级已有生产库接入 MiniMax H3 时,只需在发布新 Go 镜像前执行尚未应用的 `0006`; -新版本 `/api/ready` 会同时检查计费服务商约束中已包含 `seedream` 和 `minimax`,未迁移的 -Pod 不会进入 Ready,避免运行后才在报价阶段返回 500。 - -生产不部署迁移 Job。遇到历史重复数据时迁移会失败,需先人工审计,不能跳过或自动删除 -计费与用量记录。仓库不再提供可误执行的 Node Migration Job 或 Node Worker 清单;后台 -任务由 Go API 内嵌 WorkerLoop 处理。 +虽然当前 `replicas: 1`,RollingUpdate 的 `maxSurge: 1` 可能短暂同时运行新旧两个 Go +Pod。迁移器使用 PostgreSQL 锁串行化迁移,不能以单副本假定没有并发。旧版 Pod 可能在 +新版迁移期间继续接流量,因此新增 SQL 需兼容滚动期间的新旧程序。数据库迁移不能靠 +回滚镜像自动撤销。 Go 首次启动会读取 `zhinian-go-bootstrap` Secret 中的 `ZHINIAN_BOOTSTRAP_ADMIN_PHONE` 和 `ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD`,仅在不存在 @@ -82,6 +88,11 @@ Go 首次启动会读取 `zhinian-go-bootstrap` Secret 中的 Git 的 `deploy/ack/secrets.production.yaml`,替换所有占位值。Web 不需要任何运行时 Secret;会话密钥只属于 Go。 +将 `configmap.yaml` 中 `DATABASE_APP_ROLE: REPLACE_WITH_APP_DB_ROLE` 替换为应用数据库 +用户(`zhinian-go-db` 的 `APP_USER`),并确认 `zhinian-migration-db` 的连接串指向同一 +数据库;两条连接串的主机、端口和数据库名须一致。先发布 Secret/ConfigMap 再更新 Go Deployment。ACK 控制台的「启动执行」命令 +与参数保持空白,镜像原本的 `zhinian-api` 启动入口会自行迁移。 + 仓库内静态检查: ```bash @@ -126,6 +137,7 @@ Pod 的 `emptyDir`,Pod 重建或滚动升级同样会永久丢失上传文件 Kubernetes Secret: - `DATABASE_URL` +- `DATABASE_MIGRATION_URL`(独立迁移账号,来自 `zhinian-migration-db`) - `ZHINIAN_AUTH_SESSION_SECRET`(Secret 名 `zhinian-go-auth`,仅注入 Go) - `ZHINIAN_BOOTSTRAP_ADMIN_PHONE`、`ZHINIAN_BOOTSTRAP_ADMIN_PASSWORD` - OSS 凭据;服务商凭据也可以先放 Secret 作为数据库尚未配置时的回退值 @@ -141,7 +153,7 @@ Secret/ConfigMap 为事实来源。设置页全部 27 项配置由超级管理 ## 探针与验收 - Web `/healthz`:Nginx 静态进程存活检查。 -- Go `/api/health`:Go 进程存活检查;以 250ms 上限刷新服务商状态,失败时使用进程内缓存并仍返回存活状态。 +- Go `/api/health`:迁移完成且服务开始监听后的进程存活检查;以 250ms 上限刷新服务商状态,失败时使用进程内缓存并仍返回存活状态。 - Go `/api/ready`:检查 PostgreSQL schema、表权限和业务函数权限。 发布后执行: @@ -176,6 +188,10 @@ cp .env.example .env.local ./scripts/deploy.sh ``` +ACK 和 Compose 会向 Go 进程注入环境变量。若直接在本地执行 `go run` 或 Go 二进制, +需显式导出 `DATABASE_AUTO_MIGRATE`、`DATABASE_MIGRATION_URL`、`DATABASE_URL` 等 +数据库启动参数;仅填写 `.env.local` 不等于已向 Go 进程注入这些变量。 + 根目录 Compose 会启动 `zhinian-web` 与 `zhinian-go-api`,挂载 `deploy/nginx-compose.conf` 将 `/api`、`/uploads`、`/generated-results` 转发给 Go;Go 内嵌 WorkerLoop,不再启动独立 Node Worker。ACK 生产仍使用上面的两镜像 + Ingress 拓扑, diff --git a/docs/PAYMENTS.md b/docs/PAYMENTS.md new file mode 100644 index 0000000..76c8b6f --- /dev/null +++ b/docs/PAYMENTS.md @@ -0,0 +1,79 @@ +# 微信 Native 扫码充值 + +电脑账单页面生成支付二维码,用户用手机微信扫码,支付确认后金额进入当前组织钱包。支持固定档位和自定义金额;组织管理员可以发起和查看本组织的支付订单,普通成员继续共用组织额度。超级管理员可在计费页查看所有组织的充值订单、按订单发起微信原路退款,也保留原有人工上账入口。 + +## 微信商户侧准备 + +- 公司商户号已开通 **Native 支付**。小程序支付权限不能替代 Native 权限。 +- 已认证的小程序或服务号 AppID 已绑定此商户号。已有小程序 AppID 可用,网页无需跳转小程序,也不需要用户微信登录。 +- 商户经营资料、网站和实际 AI 服务相符,并完成适用的审核及网站运营手续。 +- 取得商户 API 证书序列号、商户私钥、32 字节 APIv3 密钥,以及微信支付公钥和公钥 ID。这里使用微信支付公钥模式。 + +参考:[Native 接入准备](https://pay.wechatpay.cn/doc/v3/merchant/4015614538)、[开发参数](https://pay.wechatpay.cn/doc/v3/merchant/4013070756)。 + +## 部署配置 + +1. ACK 发布的新 Go 镜像开启了启动前迁移,会自动应用尚未执行的 `0007_wechat_native_payments.sql` 和 `0008_wechat_refunds.sql`,并通过 `DATABASE_APP_ROLE` 给应用账号授权。构建镜像前运行 `npm run db:bundle` 并提交同步后的 Go 迁移包;不要修改已执行的历史迁移。其他部署方式可手工运行 `npm run db:migrate`,或按 [`部署说明`](./DEPLOYMENT.md) 配置同一启动迁移流程。 +2. 在 **Go 后端** 配置下表变量;配置项不进入前端静态构建或网页设置接口。私钥文件来自微信商户平台的商户 API 证书生成流程,格式为 PKCS#8 `-----BEGIN PRIVATE KEY-----`。 +3. 将公开网站地址 `ZHINIAN_PUBLIC_BASE_URL`(或已设置的 `NEXT_PUBLIC_APP_URL`)配置为正确的 HTTPS 网站地址。回调路径由已有 `/api` 反向代理转发给 Go,公网微信服务器应能访问,且该路径不应被网站登录网关、验证码或 WAF 挑战页拦截。 +4. 配置完整后设置 `WECHAT_PAY_ENABLED=true` 并重启 Go。支付开启时强制要求 PostgreSQL 持久化和有效的强制登录配置;缺少密钥会启动失败。默认关闭时不展示充值入口。 + +| 变量 | 含义 | +| --- | --- | +| `WECHAT_PAY_MCH_ID` | 普通商户号 | +| `WECHAT_PAY_APP_ID` | 已认证且绑定商户号的小程序/服务号 AppID | +| `WECHAT_PAY_NOTIFY_URL` | `https://正式域名/api/payments/wechat/notify` | +| `WECHAT_PAY_MERCHANT_SERIAL` | 商户 API 证书序列号 | +| `WECHAT_PAY_PRIVATE_KEY_FILE` | 商户 PKCS#8 私钥 PEM 文件路径 | +| `WECHAT_PAY_API_V3_KEY` | 32 字节 APIv3 密钥,用于回调解密 | +| `WECHAT_PAY_PUBLIC_KEY_ID` | 微信支付公钥 ID,通常以 `PUB_KEY_ID_` 开头 | +| `WECHAT_PAY_PUBLIC_KEY_FILE` | 微信支付公钥 PEM 文件路径 | +| `WECHAT_PAY_MIN_AMOUNT_FEN` | 自定义充值最低金额,默认 `100`(1 元) | +| `WECHAT_PAY_MAX_AMOUNT_FEN` | 自定义充值最高金额,默认 `1000000`(10000 元),不得超过此上限 | +| `WECHAT_PAY_PRESET_AMOUNTS_FEN` | 固定档位,默认 `10000,50000,100000`(100、500、1000 元) | + +文件变量也支持对应的 `WECHAT_PAY_PRIVATE_KEY_PEM` / `WECHAT_PAY_PUBLIC_KEY_PEM`,同时配置时文件优先。金额均为整数分,最多配置 8 个不重复且在上下限内的固定档位。 + +ACK 的 `secrets.example.yaml` 已提供 `zhinian-go-wechat-pay` 模板,只有 Go 工作负载加载它。模板保留占位符;实际 Secret 通过部署环境注入。Docker Compose 可通过已有 `.env.local` 注入 PEM 字符串(换行可写作 `\n`),或在 Go 容器挂载只读密钥文件并填写容器内路径。若同时配置两个公开网站地址,`NEXT_PUBLIC_APP_URL` 优先,需确保它不是旧的 localhost 地址。 + +微信退款通知使用 `https://正式域名/api/payments/wechat/refund-notify`。后端从已配置的 `WECHAT_PAY_NOTIFY_URL` 派生同一 HTTPS 域名,无需新增环境变量。两个回调路径都需由公网 `/api` 路由转发给 Go。 + +## 订单与到账 + +- 服务端从会话确定组织和发起人,校验整数金额及范围。每次充值尝试带 `requestKey`,重试返回同一订单;更换金额应创建新尝试。 +- 默认支付有效期 15 分钟。二维码在浏览器本地生成,不向第三方二维码服务传递支付链接。 +- 回调通过官方 SDK 验签、检查时间戳并解密,然后核对商户、AppID、订单、币种、金额和支付状态。 +- 支付成功状态、交易号和钱包账本在同一 PostgreSQL 事务提交。重复回调、页面查单和后台补单并发到达仍只入账一次。 +- 每 30 秒后台扫描待支付订单,结合微信查单补齐漏通知。超时订单查询后向微信关单;回调晚到仍可确认有效付款。网络失败保留待确认状态继续重试。 +- 钱包流水的充值类型为 `recharge`,发起人在支付订单和账本 metadata 里记录。财务可使用本地订单号及微信交易号与微信商户平台核对。 + +## 原路退款与余额 + +只有超级管理员可在「计费 → 微信充值」查看跨组织订单并发起退款;组织管理员和普通成员不能发起或读取后台退款接口。订单列表可按组织筛选,展示已退款、退款中、可退金额。仅已支付且属于当前商户号的订单可退,单次可退一部分或整笔剩余可退金额,累计不得超过原支付金额。退款原因必填,去除首尾空白后最多 80 个 UTF-8 字节。 + +创建退款时,系统先为该笔订单保留可退额度,并以 `requestKey` 保证重复提交仍指向同一笔退款。微信受理或退款处理中**不会立即扣除组织余额**;只有经微信退款查询或签名通知确认成功,才在同一数据库事务中写入成功状态并从组织钱包扣款。该笔扣款允许钱包变为负数,避免已消费充值金额阻止真实原路退款;后续计费仍按原余额规则处理。退款账本流水使用独立的 `recharge_refund` 类型,不与生成任务失败返还的退款流水混用。 + +退款状态包括 `pending`、`processing`、`success`、`closed`、`abnormal`、`rejected`。微信明确拒绝申请时,记录 `rejected` 和可读原因,释放可退额度;网络超时、限流或服务端错误仍保留待确认,沿用同一微信商户退款单号查询及重试。已关闭退款也会释放预留额度,可重新申请;异常退款继续占用额度,需核对微信商户平台及后台查询结果,避免重复退款。后台定期查询处理中订单,修复遗漏回调。退款通知无需用户登录,但必须经过微信官方签名验证、解密,以及商户号、交易号、金额和订单核对。关闭充值开关后仍可查看历史订单和退款记录,不能新建退款;待处理退款的通知和对账需保持微信支付配置开启。 + +## API + +| 接口 | 用途 | +| --- | --- | +| `GET /api/payments/options` | 登录后读取开启状态、权限、固定金额和上下限 | +| `GET /api/payments/orders` | 组织管理员读取本组织最近 100 个订单 | +| `POST /api/payments/orders` | `{ "amountFen": 10000, "requestKey": "客户端生成的UUID" }` 创建或重试订单 | +| `GET /api/payments/orders/{id}` | 本组织订单状态;适时向微信补查 | +| `POST /api/payments/wechat/notify` | 微信签名通知,成功持久化后返回 204;不依赖用户 Cookie | +| `GET /api/payments/admin/orders?organizationId=&limit=50&offset=0` | 超管分页查看全部组织订单;组织筛选可选,包含可退与已退金额 | +| `GET /api/payments/admin/orders/{id}/refunds` | 超管查看指定订单的退款记录 | +| `POST /api/payments/admin/orders/{id}/refunds` | 超管发起部分或剩余金额退款;请求体 `{ "amountFen": 5000, "requestKey": "客户端生成的UUID", "reason": "客户申请" }` | +| `GET /api/payments/admin/refunds/{id}` | 超管查询单笔退款状态 | +| `POST /api/payments/wechat/refund-notify` | 微信退款签名通知;不依赖用户 Cookie | + +## 上线联调 + +在测试商户或受控正式环境中先核对配置和回调可达性,再由业务负责人发起一笔小额支付,确认网页成功状态、组织余额、唯一充值流水及微信商户平台交易号一致。必要时可暂时把最低金额设为 `1` 分,并同时调整固定档位。检查重复回调不重复加钱、关闭网页后仍自动到账、刷新后能恢复待支付订单以及二维码失效后可新建订单。退款需另行用受控小额订单核对微信成功状态、退款通知、组织余额只扣一次和 `recharge_refund` 流水;切勿为测试而对真实客户订单发起退款。 + +此版本提供收款充值、查询、关单、自动补单和超管原路退款;尚无普通用户自助退款或微信账单下载。正式收款前仍应确定退款审批与财务核对流程。若绕过平台直接在微信商户平台退款,平台余额不会自动同步,需要人工核对并处理账本。现有生成任务失败退回平台余额的逻辑继续独立运行。 + +真实商户权限、证书和线上微信回调需要部署后联调,本地模拟测试不能确认这些外部条件。 diff --git a/docs/manual-images/billing-overview.jpg b/docs/manual-images/billing-overview.jpg new file mode 100644 index 0000000..d19243a Binary files /dev/null and b/docs/manual-images/billing-overview.jpg differ diff --git a/docs/manual-images/image-create.jpg b/docs/manual-images/image-create.jpg new file mode 100644 index 0000000..4658887 Binary files /dev/null and b/docs/manual-images/image-create.jpg differ diff --git a/docs/manual-images/organization-members.jpg b/docs/manual-images/organization-members.jpg new file mode 100644 index 0000000..16683f0 Binary files /dev/null and b/docs/manual-images/organization-members.jpg differ diff --git a/docs/manual-images/task-failure.jpg b/docs/manual-images/task-failure.jpg new file mode 100644 index 0000000..a25eae8 Binary files /dev/null and b/docs/manual-images/task-failure.jpg differ diff --git a/docs/manual-images/task-result.jpg b/docs/manual-images/task-result.jpg new file mode 100644 index 0000000..c45259e Binary files /dev/null and b/docs/manual-images/task-result.jpg differ diff --git a/docs/manual-images/video-reference-guide.jpg b/docs/manual-images/video-reference-guide.jpg new file mode 100644 index 0000000..a4735ef Binary files /dev/null and b/docs/manual-images/video-reference-guide.jpg differ diff --git a/docs/manual-images/video-settings.jpg b/docs/manual-images/video-settings.jpg new file mode 100644 index 0000000..41b5886 Binary files /dev/null and b/docs/manual-images/video-settings.jpg differ diff --git a/docs/智念AIGC平台操作手册.docx b/docs/智念AIGC平台操作手册.docx new file mode 100644 index 0000000..c4d634a Binary files /dev/null and b/docs/智念AIGC平台操作手册.docx differ diff --git a/lib/billing.ts b/lib/billing.ts index 65cc19a..995d2f4 100644 --- a/lib/billing.ts +++ b/lib/billing.ts @@ -128,13 +128,14 @@ function billingScalarEquals(left: string | number | boolean, right: string | nu } export function formatBillingAmount(fen: number, currency = BILLING_CURRENCY): string { - const amount = Math.max(0, Number(fen || 0)) / 100; - return new Intl.NumberFormat("zh-CN", { + const amount = Number(fen || 0) / 100; + const formatted = new Intl.NumberFormat("zh-CN", { style: "currency", currency, minimumFractionDigits: 2, maximumFractionDigits: 2 - }).format(amount); + }).format(Math.abs(amount)); + return amount < 0 ? `−${formatted}` : formatted; } export function billingUnitLabel(unit: BillingUnit): string { diff --git a/lib/client/payment-api.ts b/lib/client/payment-api.ts new file mode 100644 index 0000000..451b2ca --- /dev/null +++ b/lib/client/payment-api.ts @@ -0,0 +1,124 @@ +export type PaymentOrder = { + id: string; + organizationId: string; + accountId: string; + amountFen: number; + currency: string; + status: "pending" | "paid" | "closed"; + codeUrl?: string; + transactionId?: string; + createdAt: string; + updatedAt: string; + expiresAt: string; + paidAt?: string; +}; + +export type AdminPaymentOrder = PaymentOrder & { + organizationName: string; + accountName: string; + refundedFen: number; + refundingFen: number; + refundableFen: number; +}; + +export type PaymentRefund = { + id: string; + orderId: string; + organizationId: string; + operatorId: string; + operatorName?: string; + transactionId: string; + providerId?: string; + amountFen: number; + totalFen: number; + currency: string; + reason: string; + status: "pending" | "processing" | "success" | "closed" | "abnormal" | "rejected"; + failureReason?: string; + createdAt: string; + updatedAt: string; + succeededAt?: string; +}; + +export type AdminPaymentOrdersPage = { orders: AdminPaymentOrder[]; hasMore: boolean; enabled: boolean }; + +export type PaymentOptions = { + enabled: boolean; + canRecharge: boolean; + presetAmountsFen: number[]; + minAmountFen: number; + maxAmountFen: number; +}; + +// Work with decimal text so every accepted value maps to exactly one amount in fen. +export function parseYuanToFen(value: string, minimum: number, maximum: number): number | null { + const trimmed = value.trim(); + if (!/^(?:0|[1-9]\d*)(?:\.\d{1,2})?$/.test(trimmed)) return null; + const [yuan, fraction = ""] = trimmed.split("."); + const amount = Number(yuan) * 100 + Number(fraction.padEnd(2, "0")); + return Number.isSafeInteger(amount) && amount >= minimum && amount <= maximum ? amount : null; +} + +export function formatYuan(fen: number): string { + return (fen / 100).toFixed(2); +} + +async function paymentRequest(url: string, init?: RequestInit): Promise { + const response = await fetch(url, { cache: "no-store", ...init }); + let payload: { error?: string } & Partial; + try { + payload = await response.json(); + } catch { + throw new Error(`支付服务返回了无效响应(HTTP ${response.status})`); + } + if (!response.ok) throw new Error(payload.error || `支付请求失败(HTTP ${response.status})`); + return payload as T; +} + +export async function getPaymentOptions(signal?: AbortSignal): Promise { + return paymentRequest("/api/payments/options", { signal }); +} + +export async function listPaymentOrders(signal?: AbortSignal): Promise { + const result = await paymentRequest<{ orders: PaymentOrder[] }>("/api/payments/orders", { signal }); + return result.orders || []; +} + +export async function createPaymentOrder(amountFen: number, requestKey: string): Promise { + const result = await paymentRequest<{ order: PaymentOrder }>("/api/payments/orders", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amountFen, requestKey }) + }); + return result.order; +} + +export async function getPaymentOrder(id: string, signal?: AbortSignal): Promise { + const result = await paymentRequest<{ order: PaymentOrder }>(`/api/payments/orders/${encodeURIComponent(id)}`, { signal }); + return result.order; +} + +export async function listAdminPaymentOrders(organizationId: string, offset: number, signal?: AbortSignal): Promise { + const query = new URLSearchParams({ limit: "50", offset: String(offset) }); + if (organizationId) query.set("organizationId", organizationId); + return paymentRequest(`/api/payments/admin/orders?${query}`, { signal }); +} + +export async function listPaymentRefunds(orderId: string, signal?: AbortSignal): Promise { + const result = await paymentRequest<{ refunds: PaymentRefund[] }>(`/api/payments/admin/orders/${encodeURIComponent(orderId)}/refunds`, { signal }); + return result.refunds || []; +} + +export async function createPaymentRefund(orderId: string, amountFen: number, reason: string, requestKey: string): Promise { + const result = await paymentRequest<{ refund: PaymentRefund }>(`/api/payments/admin/orders/${encodeURIComponent(orderId)}/refunds`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ amountFen, reason, requestKey }) + }); + return result.refund; +} + +export async function getPaymentRefund(id: string, signal?: AbortSignal): Promise { + const result = await paymentRequest<{ refund: PaymentRefund }>(`/api/payments/admin/refunds/${encodeURIComponent(id)}`, { signal }); + return result.refund; +} diff --git a/package-lock.json b/package-lock.json index fcdf574..8197165 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,7 @@ "lucide-react": "^0.468.0", "next": "15.5.18", "pg": "^8.16.3", + "qrcode.react": "^4.2.0", "react": "19.2.6", "react-dom": "19.2.6", "server-only": "0.0.1", @@ -2734,6 +2735,15 @@ "once": "^1.3.1" } }, + "node_modules/qrcode.react": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/qrcode.react/-/qrcode.react-4.2.0.tgz", + "integrity": "sha512-QpgqWi8rD9DsS9EP3z7BT+5lY5SFhsqGjpgW5DY/i3mK4M9DTBNz3ErMi8BWYEfI3L0d8GIbGmcdFAS1uIRGjA==", + "license": "ISC", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/qs": { "version": "6.15.2", "resolved": "https://registry.npmjs.org/qs/-/qs-6.15.2.tgz", diff --git a/package.json b/package.json index b1ee684..4cce350 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,8 @@ "info": "node scripts/print-app-info.mjs", "bootstrap:admin": "node scripts/bootstrap-admin.mjs", "db:migrate": "node scripts/migrate-postgres.mjs", + "db:bundle": "node scripts/sync-go-migrations.mjs", + "db:bundle:check": "node scripts/sync-go-migrations.mjs --check", "deploy:check": "node scripts/check-ack-manifests.mjs", "migrate:accounts": "node scripts/import-legacy-accounts.mjs", "go:fmt": "node scripts/run-go-command.mjs fmt ./...", @@ -28,6 +30,7 @@ "lucide-react": "^0.468.0", "next": "15.5.18", "pg": "^8.16.3", + "qrcode.react": "^4.2.0", "react": "19.2.6", "react-dom": "19.2.6", "server-only": "0.0.1", diff --git a/scripts/check-ack-manifests.mjs b/scripts/check-ack-manifests.mjs index b0fd068..7714605 100644 --- a/scripts/check-ack-manifests.mjs +++ b/scripts/check-ack-manifests.mjs @@ -1,4 +1,9 @@ import { readFileSync, readdirSync } from "node:fs"; +import { execFileSync } from "node:child_process"; +import { fileURLToPath } from "node:url"; + +// Reject a deploy with migrations missing from the self-contained Go image. +execFileSync(process.execPath, [fileURLToPath(new URL("./sync-go-migrations.mjs", import.meta.url)), "--check"], { stdio: "inherit" }); const directory = new URL("../deploy/ack/", import.meta.url); const repository = new URL("../", import.meta.url); @@ -41,6 +46,15 @@ assert(!goApi.includes("name: zhinian-web-auth"), "Go API must not reference the assert(goApi.includes("name: zhinian-go-bootstrap"), "Go API must receive bootstrap administrator credentials"); assert(goApi.includes("name: zhinian-go-providers"), "Go API must accept provider environment fallbacks"); assert(goApi.includes("optional: true"), "Provider fallback Secret must be optional when settings are stored in PostgreSQL"); +assert( + /- name: DATABASE_URL\s+valueFrom:\s+secretKeyRef:\s+name: zhinian-go-db\s+key: DATABASE_URL/.test(goApi), + "Go API must retain its least-privilege application DATABASE_URL", +); +assert( + /- name: DATABASE_MIGRATION_URL\s+valueFrom:\s+secretKeyRef:\s+name: zhinian-migration-db\s+key: DATABASE_URL/.test(goApi), + "Go API must inject the separate migration credential without replacing DATABASE_URL", +); +assert(/startupProbe:[\s\S]*?failureThreshold: 60/.test(goApi), "Go startup probe must allow migration timeout plus startup headroom"); assert(!goApi.includes("rds-ca"), "Go API must not mount an RDS CA when PostgreSQL TLS is disabled"); assert(!goApi.includes("/etc/zhinian/rds"), "Go API must not retain the removed RDS CA path"); assert(!/\bTLS\b/i.test(goApi), "Go API manifest must not retain PostgreSQL TLS configuration"); @@ -50,6 +64,7 @@ assert(!goApi.includes("DATABASE_CA_CERT_PATH"), "Go API must not receive a data const secrets = read("secrets.example.yaml"); assert(secrets.includes("name: zhinian-go-auth"), "Example secrets must name Go as the session Secret owner"); assert(secrets.includes("name: zhinian-go-providers"), "Example secrets must define optional provider environment fallbacks"); +assert(secrets.includes("name: zhinian-migration-db"), "Example secrets must define the startup migration credential"); assert(!secrets.includes("name: zhinian-web-auth"), "Example secrets must not retain the removed Web auth Secret"); assert(!secrets.includes("zhinian-rds-ca"), "Example secrets must not define the removed RDS CA Secret"); assert(!secrets.includes("sslrootcert"), "Example DATABASE_URL values must not reference an RDS CA"); @@ -66,6 +81,9 @@ assert(!configMap.includes("name: zhinian-runtime"), "Static Web must not have a assert((configMap.match(/^kind: ConfigMap$/gm) ?? []).length === 1, "Only the Go runtime ConfigMap should remain"); assert(configMap.includes("ZHINIAN_GO_EMBEDDED_WORKER: \"true\""), "Go runtime ConfigMap must embed the WorkerLoop"); assert(configMap.includes("GO_BACKEND_HOST: 0.0.0.0"), "Go runtime ConfigMap must listen on the Pod interface"); +assert(configMap.includes('DATABASE_AUTO_MIGRATE: "true"'), "ACK Go runtime must enable startup migrations"); +assert(configMap.includes('DATABASE_MIGRATION_TIMEOUT_SECONDS: "180"'), "ACK startup migrations must have a bounded deadline"); +assert(configMap.includes("DATABASE_APP_ROLE: REPLACE_WITH_APP_DB_ROLE"), "ACK must require the application database role to be supplied"); assert(!configMap.includes("DATABASE_SSL_MODE"), "ConfigMaps must not carry database TLS settings"); assert(!configMap.includes("DATABASE_CA_CERT_PATH"), "ConfigMaps must not carry database CA paths"); diff --git a/scripts/migrate-postgres.mjs b/scripts/migrate-postgres.mjs index b5e2aa4..af0a2c5 100644 --- a/scripts/migrate-postgres.mjs +++ b/scripts/migrate-postgres.mjs @@ -167,7 +167,9 @@ function applicationRoleTablePrivileges() { ["platform_runtime_settings", "SELECT, INSERT, UPDATE"], ["billing_price_rules", "SELECT, INSERT, UPDATE"], ["billing_wallets", "SELECT, INSERT, UPDATE"], - ["billing_ledger", "SELECT, INSERT"] + ["billing_ledger", "SELECT, INSERT"], + ["payment_orders", "SELECT, INSERT, UPDATE"], + ["payment_refunds", "SELECT, INSERT, UPDATE"] ]; } diff --git a/scripts/run-go-command.mjs b/scripts/run-go-command.mjs index 591e7d2..0f28816 100644 --- a/scripts/run-go-command.mjs +++ b/scripts/run-go-command.mjs @@ -1,6 +1,6 @@ #!/usr/bin/env node -import { spawn } from "node:child_process"; +import { spawn, spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const [, , command, ...args] = process.argv; @@ -10,6 +10,10 @@ if (!command) { } const backendDirectory = fileURLToPath(new URL("../backend/", import.meta.url)); +if (command === "build") { + const check = spawnSync(process.execPath, [fileURLToPath(new URL("./sync-go-migrations.mjs", import.meta.url)), "--check"], { stdio: "inherit" }); + if (check.error || check.status !== 0) process.exit(check.status ?? 1); +} const child = spawn("go", [command, ...args], { cwd: backendDirectory, env: { diff --git a/scripts/sync-go-migrations.mjs b/scripts/sync-go-migrations.mjs new file mode 100644 index 0000000..c01c009 --- /dev/null +++ b/scripts/sync-go-migrations.mjs @@ -0,0 +1,32 @@ +import { readdir, readFile, mkdir, writeFile, unlink } from "node:fs/promises"; +import { fileURLToPath } from "node:url"; +import { join } from "node:path"; + +const source = fileURLToPath(new URL("../database/migrations/", import.meta.url)); +const target = fileURLToPath(new URL("../backend/internal/migrations/sql/", import.meta.url)); +const check = process.argv.includes("--check"); +if (process.argv.length > 3 || (process.argv.length === 3 && !check)) { + throw new Error("Usage: node scripts/sync-go-migrations.mjs [--check]"); +} +if (!check) await mkdir(target, { recursive: true }); +const sourceFiles = (await readdir(source)).filter((name) => name.endsWith(".sql")).sort(); +const targetFiles = (await readdir(target)).filter((name) => name.endsWith(".sql")).sort(); +const sourceSet = new Set(sourceFiles); +const stale = targetFiles.filter((name) => !sourceSet.has(name)); +if (check && stale.length) { + throw new Error(`Go migration bundle has stale files: ${stale.join(", ")}; run npm run db:bundle`); +} +for (const name of stale) await unlink(join(target, name)); +for (const name of sourceFiles) { + const canonical = await readFile(join(source, name)); + let bundled; + try { + bundled = await readFile(join(target, name)); + } catch (error) { + if (error.code !== "ENOENT") throw error; + } + if (bundled?.equals(canonical)) continue; + if (check) throw new Error(`Go migration bundle differs at ${name}; run npm run db:bundle`); + await writeFile(join(target, name), canonical); +} +console.log(check ? `Go migration bundle current (${sourceFiles.length} files)` : `Go migration bundle synchronized (${sourceFiles.length} files)`); diff --git a/tests/billing.test.ts b/tests/billing.test.ts index 369da1a..84f313d 100644 --- a/tests/billing.test.ts +++ b/tests/billing.test.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { calculateBillingAmountFen } from "@/lib/billing"; +import { calculateBillingAmountFen, formatBillingAmount } from "@/lib/billing"; import { BillingConfigurationError, chargeGenerationJob, @@ -42,6 +42,12 @@ describe("organization billing", () => { expect(calculateBillingAmountFen(1, 1, 1.01)).toBe(2); }); + it("shows a negative wallet balance after a refund exceeds available funds", () => { + expect(formatBillingAmount(-60)).toBe("−¥0.60"); + expect(formatBillingAmount(0)).toBe("¥0.00"); + expect(formatBillingAmount(60)).toBe("¥0.60"); + }); + it("matches the most specific parameter rule and snapshots normalized parameters", async () => { await createBillingPriceRule({ id: "rule-parameter-generic", diff --git a/tests/payment-api.test.ts b/tests/payment-api.test.ts new file mode 100644 index 0000000..d25ded1 --- /dev/null +++ b/tests/payment-api.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { createPaymentOrder, createPaymentRefund, formatYuan, getPaymentOrder, getPaymentRefund, listAdminPaymentOrders, listPaymentRefunds, parseYuanToFen } from "@/lib/client/payment-api"; + +afterEach(() => vi.unstubAllGlobals()); + +describe("recharge amounts", () => { + it("converts decimal yuan to exact integer fen within the server range", () => { + expect(parseYuanToFen("1", 100, 1_000_000)).toBe(100); + expect(parseYuanToFen("1.01", 100, 1_000_000)).toBe(101); + expect(parseYuanToFen(" 10000.00 ", 100, 1_000_000)).toBe(1_000_000); + expect(formatYuan(101)).toBe("1.01"); + }); + + it.each(["0.99", "1.001", "1e2", "-1", "+1", "1,000", "01", "10000.01", "0", "1."])("rejects %s", (value) => { + expect(parseYuanToFen(value, 100, 1_000_000)).toBeNull(); + }); +}); + +describe("admin payment requests", () => { + it("lists across organizations or filters by organization and page", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ orders: [], hasMore: false, enabled: true }) }); + vi.stubGlobal("fetch", fetchMock); + await listAdminPaymentOrders("org/a", 50); + expect(fetchMock.mock.calls[0][0]).toBe("/api/payments/admin/orders?limit=50&offset=50&organizationId=org%2Fa"); + await listAdminPaymentOrders("", 0); + expect(fetchMock.mock.calls[1][0]).toBe("/api/payments/admin/orders?limit=50&offset=0"); + }); + + it("uses admin endpoints and preserves the refund attempt key", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ refund: { id: "r1", status: "pending" }, refunds: [] }) }); + vi.stubGlobal("fetch", fetchMock); + await createPaymentRefund("a/b", 250, "客户申请", "same-attempt"); + expect(fetchMock.mock.calls[0][0]).toBe("/api/payments/admin/orders/a%2Fb/refunds"); + expect(JSON.parse(fetchMock.mock.calls[0][1].body)).toEqual({ amountFen: 250, reason: "客户申请", requestKey: "same-attempt" }); + await listPaymentRefunds("a/b"); + expect(fetchMock.mock.calls[1][0]).toBe("/api/payments/admin/orders/a%2Fb/refunds"); + await getPaymentRefund("r/1"); + expect(fetchMock.mock.calls[2][0]).toBe("/api/payments/admin/refunds/r%2F1"); + }); +}); + +describe("payment requests", () => { + it("sends integer fen and a stable idempotency key", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: async () => ({ order: { id: "pay_1", status: "pending" } }) }); + vi.stubGlobal("fetch", fetchMock); + await createPaymentOrder(10000, "same-attempt"); + const [, init] = fetchMock.mock.calls[0]; + expect(JSON.parse(init.body)).toEqual({ amountFen: 10000, requestKey: "same-attempt" }); + }); + + it("escapes order IDs and reports server errors", async () => { + const fetchMock = vi.fn().mockResolvedValue({ ok: false, status: 404, json: async () => ({ error: "订单不存在" }) }); + vi.stubGlobal("fetch", fetchMock); + await expect(getPaymentOrder("a/b")).rejects.toThrow("订单不存在"); + expect(fetchMock.mock.calls[0][0]).toBe("/api/payments/orders/a%2Fb"); + }); +});