diff --git a/README.md b/README.md index 2e54383..fd3856f 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ default: - **套餐生效条件覆盖与购买快照**:支持代理分配覆盖套餐生效条件,并将生效条件、周期类型和购买时长固化到套餐使用记录;激活与排队接续只消费购买快照。详见 [功能总结](docs/ur55-package-expiry-base/功能总结.md) - **UR#33 套餐临期列表与 C 端节点提醒**:平台和代理可按统一最终到期口径分页查看 0~15 天临期卡/设备及分类数量,返回高亮和优先标识;每日 15/7/3 天节点通过公共 Outbox 向关联个人客户幂等发送站内通知。详见 [功能总结](docs/ur33-package-expiry-reminder/功能总结.md) - **企业微信最小审批 Adapter、退款与员工线下代充值**:超级管理员可维护自建应用连接参数、默认发起人和后台模板映射;管理接口和数据库均使用明文连接凭据。Adapter 支持成员显式绑定、异步提交、加密回调、权威详情同步、未终态轮询和结果未知时间窗恢复;退款和员工线下代充值均原子创建业务单与审批事实,approved 复用既有资金用例幂等执行,其他终态不产生资金副作用。未知结果只有唯一审批单号候选才关联,绝不盲目重提。详见 [功能总结](docs/wecom-application-connection/功能总结.md) +- **代理钱包桌面扫码充值**:代理可使用微信 Native 或支付宝当面付为当前店铺主钱包在线自充;充值接口通过 `recharge_source` 区分平台线下代充与代理在线自充,支付确认后由 Outbox/Worker 幂等入账,并保留创建支付商户身份供未来对账。详见 [功能总结](docs/feature-034-agent-wallet-qr-recharge/功能总结.md) - **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 [功能总结](docs/package-price-fallback-and-platform-gift-policy/功能总结.md) 与 [最终验收清单](docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md) - **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md) - **对象存储**:S3 兼容的对象存储服务集成(联通云 OSS),支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md) diff --git a/cmd/worker/main.go b/cmd/worker/main.go index b95d4cb..197eced 100644 --- a/cmd/worker/main.go +++ b/cmd/worker/main.go @@ -26,6 +26,7 @@ import ( "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" notificationInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/notification" + paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment" shopInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/shop" walletInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom" @@ -42,6 +43,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/logger" "github.com/break/junhong_cmp_fiber/pkg/queue" "github.com/break/junhong_cmp_fiber/pkg/storage" + "github.com/break/junhong_cmp_fiber/pkg/wechat" "gorm.io/gorm" ) @@ -142,6 +144,7 @@ func runWorker(cfg *config.Config) { taskHandler := createTaskHandler(runtime, appLogger) taskHandler.RegisterHandlers() registerWeComApprovalTasks(taskHandler.GetMux(), runtime, cfg, appLogger) + registerAgentRechargeRecoveryTask(taskHandler.GetMux(), runtime, appLogger) outboxHandler := outbox.NewHandler(runtime.outboxConsumers) taskHandler.GetMux().HandleFunc(constants.TaskTypeOutboxDeliver, outboxHandler.Handle) startOutboxRelay(ctx, runtime, cfg.Worker.InstanceName, appLogger) @@ -404,6 +407,24 @@ func registerWeComApprovalTasks(mux *asynq.ServeMux, runtime *workerRuntime, cfg appLogger.Info("注册企业微信审批主动恢复任务处理器", zap.String("task_type", constants.TaskTypeWeComApprovalRecovery)) } +// registerAgentRechargeRecoveryTask 注册代理在线充值支付恢复任务。 +func registerAgentRechargeRecoveryTask(mux *asynq.ServeMux, runtime *workerRuntime, appLogger *zap.Logger) { + integration := integrationlog.NewRepository(runtime.db) + confirm := agentrechargeApp.NewConfirmOnlinePaymentService( + runtime.db, + paymentInfra.NewAgentRechargePaymentEventWriter(outbox.NewRepository()), + ) + recovery := agentrechargeApp.NewRecoverOnlinePaymentService( + runtime.db, + paymentInfra.NewWechatNativeAdapter(wechat.NewRedisCache(runtime.redisClient), integration, appLogger), + paymentInfra.NewAlipayPreCreateAdapter(integration), + confirm, + ) + handler := paymentInfra.NewAgentRechargeRecoveryTaskHandler(recovery) + mux.HandleFunc(constants.TaskTypeAgentRechargeRecovery, handler.Handle) + appLogger.Info("注册代理在线充值支付恢复任务处理器", zap.String("task_type", constants.TaskTypeAgentRechargeRecovery)) +} + // registerCardObservationOutboxConsumer 注册卡观测领域事件消费者。 func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) { stopResumeService, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface) @@ -446,6 +467,12 @@ func registerCardObservationOutboxConsumer(runtime *workerRuntime, appLogger *za // registerWalletOutboxConsumer 注册代理主钱包资金事实消费者。 func registerWalletOutboxConsumer(runtime *workerRuntime, appLogger *zap.Logger) { + agentRechargePosting := walletApp.NewPostingService(walletInfra.NewCreditEventWriter(outbox.NewRepository()), nil) + agentRechargeConsumer := paymentInfra.NewAgentRechargePaymentConsumer(runtime.db, agentRechargePosting) + if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAgentRechargePaymentConfirmed, agentRechargeConsumer); err != nil { + appLogger.Fatal("注册代理在线充值入账 Outbox 消费者失败", + zap.String("event_type", constants.OutboxEventTypeAgentRechargePaymentConfirmed), zap.Error(err)) + } debitConsumer := walletInfra.NewDebitEventConsumer(runtime.db) if err := runtime.outboxConsumers.Register(constants.OutboxEventTypeAgentMainWalletDebited, debitConsumer); err != nil { appLogger.Fatal("注册代理主钱包扣款 Outbox 消费者失败", @@ -649,6 +676,16 @@ func startAsynqScheduler(cfg *config.Config, redisAddr string, appLogger *zap.Lo // registerAsynqScheduleTasks 注册 Worker 入口需要的全部定时任务。 func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error { + if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask( + constants.TaskTypeAgentRechargeRecovery, + nil, + asynq.MaxRetry(3), + asynq.Timeout(10*time.Minute), + asynq.Unique(10*time.Minute), + asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAgentRechargeRecovery)), + )); err != nil { + return fmt.Errorf("注册代理在线充值支付恢复定时任务失败: %w", err) + } if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask( constants.TaskTypeOrderExpire, nil, diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index d58fa09..7bbcd96 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -490,6 +490,91 @@ components: description: 总记录数 type: integer type: object + DtoAgentRechargeOnlineResponse: + properties: + amount: + description: 在线充值金额(分),范围10000分~100000000分 + type: integer + payment_method: + description: 支付方式 (wechat:微信, alipay:支付宝) + type: string + payment_no: + description: 支付单号(PAY前缀) + type: string + qr_content: + description: 支付渠道原始扫码付款内容,由前端渲染二维码 + type: string + recharge_id: + description: 充值记录ID + minimum: 0 + type: integer + recharge_no: + description: 充值单号(ARCH前缀) + type: string + recharge_source: + description: 充值来源 (agent_online:代理在线自充) + type: string + recharge_source_name: + description: 充值来源名称(中文) + type: string + status: + description: 状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回) + type: integer + status_name: + description: 状态名称(中文) + type: string + type: object + DtoAgentRechargePaymentMethodsResponse: + properties: + max_amount: + description: 在线充值最大金额(分),固定为100000000 + type: integer + methods: + description: 可用支付方式,固定顺序 (wechat:微信, alipay:支付宝) + items: + type: string + nullable: true + type: array + min_amount: + description: 在线充值最小金额(分),固定为10000 + type: integer + type: object + DtoAgentRechargePaymentStatusResponse: + properties: + completed_at: + description: 钱包入账完成时间 + nullable: true + type: string + paid_at: + description: 第三方支付确认时间 + nullable: true + type: string + payment_status: + description: 支付状态 (0:待支付, 1:已支付, 2:已失败, 3:已退款) + type: integer + payment_status_name: + description: 支付状态名称(中文) + type: string + recharge_id: + description: 充值记录ID + minimum: 0 + type: integer + recharge_no: + description: 充值单号(ARCH前缀) + type: string + recharge_source: + description: 充值来源 (platform_offline:平台线下代充, agent_online:代理在线自充) + type: string + recharge_source_name: + description: 充值来源名称(中文) + type: string + status: + description: 充值状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回) + type: integer + status_name: + description: 充值状态名称(中文) + type: string + type: object DtoAgentRechargeRejectParams: properties: rejection_reason: @@ -546,7 +631,7 @@ components: nullable: true type: integer payment_method: - description: 支付方式 (wechat:微信在线支付, offline:线下转账) + description: 支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账) type: string payment_transaction_id: description: 第三方支付流水号 @@ -560,6 +645,12 @@ components: recharge_no: description: 充值单号(ARCH前缀) type: string + recharge_source: + description: 充值来源 (platform_offline:平台线下代充, agent_online:代理在线自充) + type: string + recharge_source_name: + description: 充值来源名称(中文) + type: string rejection_reason: description: 驳回原因,仅 status=6 时有值 nullable: true @@ -3580,7 +3671,7 @@ components: minimum: 1 type: integer payment_method: - description: 支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用) + description: 支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账仅平台可用) type: string payment_voucher_key: description: 支付凭证对象存储Key列表(payment_method=offline 时至少1个,最多5个,微信支付时忽略) @@ -3593,12 +3684,16 @@ components: description: 运营备注(可选,创建后只读) maxLength: 1000 type: string + request_id: + description: 在线充值幂等请求标识,微信或支付宝支付时必填 + maxLength: 64 + type: string shop_id: - description: 目标店铺ID,代理只能填自己店铺 + description: 目标店铺ID,仅平台线下代充可填;代理在线充值禁止传入 minimum: 0 + nullable: true type: integer required: - - shop_id - amount - payment_method type: object @@ -12194,6 +12289,12 @@ paths: description: 按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回) nullable: true type: integer + - description: 按充值来源过滤 (platform_offline:平台线下代充, agent_online:代理在线自充) + in: query + name: recharge_source + schema: + description: 按充值来源过滤 (platform_offline:平台线下代充, agent_online:代理在线自充) + type: string - description: 创建时间起始日期(YYYY-MM-DD) in: query name: start_date @@ -12279,7 +12380,7 @@ paths: example: 0 type: integer data: - $ref: '#/components/schemas/DtoAgentRechargeResponse' + $ref: '#/components/schemas/DtoAgentRechargeOnlineResponse' msg: description: 响应消息 example: success @@ -12463,6 +12564,73 @@ paths: summary: 确认线下充值 tags: - 代理预充值 + /api/admin/agent-recharges/{id}/payment-status: + get: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAgentRechargePaymentStatusResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询代理充值本地支付与到账状态 + tags: + - 代理预充值 /api/admin/agent-recharges/{id}/reject: post: parameters: @@ -12509,6 +12677,64 @@ paths: summary: 驳回代理充值订单 tags: - 代理预充值 + /api/admin/agent-recharges/payment-methods: + get: + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAgentRechargePaymentMethodsResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询代理在线充值可用支付方式 + tags: + - 代理预充值 /api/admin/asset-allocation-records: get: parameters: diff --git a/docs/feature-034-agent-wallet-qr-recharge/代理钱包扫码充值接口对接说明.md b/docs/feature-034-agent-wallet-qr-recharge/代理钱包扫码充值接口对接说明.md new file mode 100644 index 0000000..d9cac24 --- /dev/null +++ b/docs/feature-034-agent-wallet-qr-recharge/代理钱包扫码充值接口对接说明.md @@ -0,0 +1,327 @@ +# 代理钱包扫码充值实现与接口对接说明 + +> 面向:产品、前端和联调人员 +> 范围:代理微信/支付宝桌面扫码自充、平台线下代充来源区分、支付状态轮询 +> 接口细节:以 [`docs/admin-openapi.yaml`](../../admin-openapi.yaml) 为准,本文说明前端调用顺序、字段口径和页面状态。 + +## 一、先看这几个关键结论 + +1. **同一个创建接口有两条业务路径**:代理账号使用 `wechat|alipay` 在线自充;平台或超级管理员使用 `offline` 线下代充。 +2. **前端必须使用 `recharge_source` 区分来源**:`platform_offline` 是平台线下代充,`agent_online` 是代理在线自充;不要根据账号名称、备注或审批字段猜测。 +3. **代理不能选择充值店铺**:在线充值的店铺和主钱包由登录上下文确定,请求不得发送 `shop_id`、支付凭证或备注。 +4. **二维码由前端渲染**:后端返回支付渠道原始 `qr_content`,不返回二维码图片。 +5. **网络重试不能创建新请求 ID**:同一次提交重试复用原 `request_id`;用户主动发起下一笔充值时生成新的 `request_id`。 +6. **支付和钱包到账是两个阶段**:`status=2` 表示第三方已收款、钱包入账处理中;只有 `status=3` 才表示钱包到账完成。 +7. **页面轮询只调用本地状态接口**:前端不得直接调用微信、支付宝查单,也不要反复调用创建接口查询状态。 +8. **在线金额单位为分**:最低 `10000` 分(100 元),最高 `100000000` 分(100 万元);不要提交浮点元金额。 + +## 二、本次需求怎么实现 + +| 能力 | 后端实现 | 前端要做什么 | +| --- | --- | --- | +| 可用支付方式 | 根据当前有效支付配置返回真正可用的 `wechat`、`alipay` | 打开充值弹窗时先查询;只展示返回数组中的方式 | +| 代理在线自充 | 从登录账号取得当前店铺和主钱包,创建充值单、支付单并向第三方预下单 | 只提交金额、支付方式和请求 ID;用 `qr_content` 渲染二维码 | +| 平台线下代充 | 保留既有目标店铺、凭证和企业微信审批流程 | 平台页面继续提交 `offline` 请求并只读展示审批状态 | +| 充值来源 | 根据受控创建方式返回稳定来源枚举 | 列表、详情和支付状态统一展示 `recharge_source_name` | +| 支付确认 | 微信/支付宝回调确认第三方收款事实 | 前端无需调用回调接口 | +| 钱包入账 | Outbox/Worker 幂等增加代理主钱包余额 | `status=2` 展示“入账处理中”,`status=3` 展示“已到账” | +| 回调丢失恢复 | Worker 使用原支付单号主动查单并收敛状态 | 页面继续轮询本地状态,无需提供“重新查单”按钮 | + +## 三、接口对接表 + +| 页面动作 | 接口 | 前端调用说明 | +| --- | --- | --- | +| 打开代理在线充值弹窗 | `GET /api/admin/agent-recharges/payment-methods` | 仅代理账号调用;返回支付方式和金额上下限 | +| 创建在线扫码充值 | `POST /api/admin/agent-recharges` | 代理只传 `amount + payment_method + request_id` | +| 创建平台线下代充 | `POST /api/admin/agent-recharges` | 平台/超级管理员传 `shop_id + amount + payment_method=offline + payment_voucher_key + remark` | +| 查询充值列表 | `GET /api/admin/agent-recharges` | 支持 `recharge_source=platform_offline|agent_online` 筛选 | +| 查询充值详情 | `GET /api/admin/agent-recharges/:id` | 使用返回的来源、充值状态和审批状态展示 | +| 轮询在线支付状态 | `GET /api/admin/agent-recharges/:id/payment-status` | 仅在线单使用;页面可见时每 3 秒调用,终态停止 | +| 支付回调 | 微信/支付宝既有回调地址 | 只由支付渠道调用,前端禁止调用 | + +所有接口都使用 Bearer Token,成功响应统一为: + +```json +{ + "code": 0, + "msg": "success", + "data": {}, + "timestamp": "2026-07-27T16:00:00+08:00" +} +``` + +前端应同时检查 HTTP 状态和响应体 `code`,失败时优先展示后端返回的 `msg`。 + +## 四、代理在线扫码充值调用流程 + +### 4.1 查询可用支付方式 + +```http +GET /api/admin/agent-recharges/payment-methods +Authorization: Bearer {token} +``` + +响应示例: + +```json +{ + "code": 0, + "msg": "success", + "data": { + "methods": ["wechat", "alipay"], + "min_amount": 10000, + "max_amount": 100000000 + }, + "timestamp": "2026-07-27T16:00:00+08:00" +} +``` + +前端处理规则: + +- `methods=[]`:隐藏或禁用提交按钮,提示“当前暂无可用在线支付方式”。 +- 只显示返回的支付方式,不自行补充微信或支付宝。 +- 金额校验直接使用返回的 `min_amount`、`max_amount`,提交值仍为整数分。 +- 平台、超级管理员和企业账号不调用此接口。 + +### 4.2 创建在线充值单 + +```http +POST /api/admin/agent-recharges +Authorization: Bearer {token} +Content-Type: application/json +``` + +微信请求示例: + +```json +{ + "amount": 10000, + "payment_method": "wechat", + "request_id": "recharge-20260727-8f73d95d" +} +``` + +支付宝只需将 `payment_method` 改为 `alipay`。 + +在线请求禁止发送: + +- `shop_id` +- `payment_voucher_key` +- `remark` +- 商户号、应用 ID、密钥、支付配置 ID + +成功响应示例: + +```json +{ + "code": 0, + "msg": "success", + "data": { + "recharge_id": 101, + "recharge_no": "ARCH20260727160000123456", + "payment_no": "PAY20260727160000987654", + "payment_method": "wechat", + "recharge_source": "agent_online", + "recharge_source_name": "代理在线自充", + "amount": 10000, + "qr_content": "weixin://wxpay/bizpayurl?pr=...", + "status": 1, + "status_name": "待支付" + }, + "timestamp": "2026-07-27T16:00:00+08:00" +} +``` + +前端处理规则: + +1. 将 `qr_content` 原样交给二维码组件,不解析、不改写、不拼接。 +2. 二维码页面保存 `recharge_id`,后续轮询使用该 ID,不使用 `payment_no` 查询。 +3. 同一次请求超时或网络断开时,重试必须复用原 `request_id`;后端会返回原充值单和原二维码内容。 +4. 用户关闭弹窗后重新点击“立即充值”,视为新一笔主动提交,必须生成新的 `request_id`。 +5. 相同 `request_id` 改变金额或支付方式会返回冲突,前端不得自动覆盖原请求。 + +### 4.3 轮询支付和到账状态 + +```http +GET /api/admin/agent-recharges/101/payment-status +Authorization: Bearer {token} +``` + +响应示例: + +```json +{ + "code": 0, + "msg": "success", + "data": { + "recharge_id": 101, + "recharge_no": "ARCH20260727160000123456", + "recharge_source": "agent_online", + "recharge_source_name": "代理在线自充", + "status": 2, + "status_name": "已支付", + "payment_status": 1, + "payment_status_name": "已支付", + "paid_at": "2026-07-27 16:01:20", + "completed_at": null + }, + "timestamp": "2026-07-27T16:01:21+08:00" +} +``` + +页面建议每 3 秒轮询一次,仅在二维码弹窗可见时执行。按以下状态展示: + +| `status` | 含义 | 页面展示 | 是否继续轮询 | +| --- | --- | --- | --- | +| `1` | 待支付 | 等待用户扫码付款 | 是 | +| `2` | 已支付 | 支付成功,钱包入账处理中 | 是 | +| `3` | 已完成 | 充值成功,钱包已到账 | 否 | +| `4` | 已关闭 | 订单已关闭,请重新发起充值 | 否 | +| `5` | 已退款 | 已退款 | 否 | +| `6` | 已驳回 | 已驳回 | 否 | + +`payment_status` 的值为:`0=待支付`、`1=已支付`、`2=已失败`、`3=已退款`。页面主流程优先根据充值 `status` 判断;`payment_status` 用于补充支付阶段文案。 + +进入 `status=3` 后,前端刷新钱包余额和充值列表。不要在 `status=2` 时提前增加页面余额。 + +## 五、平台线下代充调用流程 + +平台或超级管理员继续调用同一个创建接口: + +```json +{ + "shop_id": 20, + "amount": 50000, + "payment_method": "offline", + "payment_voucher_key": ["recharge-voucher/20260727/abc.png"], + "remark": "银行转账" +} +``` + +线下代充响应和后续列表/详情会返回: + +```json +{ + "recharge_source": "platform_offline", + "recharge_source_name": "平台线下代充", + "approval_provider": "wecom", + "approval_status": 1, + "approval_status_name": "审批中" +} +``` + +前端处理规则: + +- 代理账号不得展示线下代充表单。 +- 平台线下单展示凭证、备注和企微审批状态,不展示扫码二维码。 +- 存在 `approval_instance_id` 或 `approval_provider=wecom` 时,只读展示审批进度,不显示旧的本地确认/驳回按钮。 +- 在线充值的 `approval_instance_id` 为 `null`,不要显示企微审批区域。 +- `/:id/payment-status` 是在线支付状态接口,线下代充详情使用 `GET /:id`,不要轮询支付状态接口。 + +## 六、列表和详情对接 + +列表请求示例: + +```http +GET /api/admin/agent-recharges?page=1&page_size=20&recharge_source=agent_online&status=1 +``` + +可用筛选参数: + +| 参数 | 说明 | +| --- | --- | +| `page` | 页码,默认 1 | +| `page_size` | 每页条数,默认 20,最大 100 | +| `shop_id` | 店铺 ID,按既有数据权限生效 | +| `status` | 充值状态 `1~6` | +| `recharge_source` | `platform_offline` 或 `agent_online` | +| `start_date` | 开始日期,格式 `YYYY-MM-DD` | +| `end_date` | 结束日期,格式 `YYYY-MM-DD` | + +列表成功响应的分页数据位于: + +```json +{ + "data": { + "items": [], + "total": 0, + "page": 1, + "size": 20 + } +} +``` + +建议列表至少展示:充值单号、店铺、金额、`recharge_source_name`、支付方式、`status_name`、提交人和创建时间。 + +详情接口: + +```http +GET /api/admin/agent-recharges/{id} +``` + +页面分支必须以 `recharge_source` 为准: + +- `agent_online`:展示支付方式、第三方流水号、支付时间、完成时间;隐藏凭证和企微审批操作。 +- `platform_offline`:展示支付凭证、备注、提交人和企微审批状态;隐藏二维码和在线轮询区域。 + +## 七、充值来源与状态字段 + +### 7.1 充值来源 + +| 字段值 | 中文名 | 创建人和路径 | +| --- | --- | --- | +| `platform_offline` | 平台线下代充 | 平台/超级管理员提交 `payment_method=offline` | +| `agent_online` | 代理在线自充 | 代理提交 `payment_method=wechat|alipay` | + +前端展示中文时优先使用后端返回的 `recharge_source_name`,业务分支使用稳定枚举 `recharge_source`。 + +### 7.2 充值状态 + +| 状态 | 名称 | 说明 | +| --- | --- | --- | +| `1` | 待支付 | 在线单等待扫码;线下单等待审批 | +| `2` | 已支付 | 第三方收款已确认,钱包可能仍在入账 | +| `3` | 已完成 | 钱包余额和唯一流水已提交 | +| `4` | 已关闭 | 在线支付关闭或预下单失败 | +| `5` | 已退款 | 充值已退款 | +| `6` | 已驳回 | 线下代充审批驳回 | + +## 八、权限和错误处理 + +| 操作 | 平台/超级管理员 | 代理账号 | 企业账号 | +| --- | --- | --- | --- | +| 创建在线扫码充值 | 禁止 | 仅当前店铺主钱包 | 禁止 | +| 创建线下代充 | 允许 | 禁止 | 禁止 | +| 查询在线支付方式 | 禁止 | 允许 | 禁止 | +| 查询列表/详情 | 按既有数据范围 | 按店铺层级数据范围 | 禁止 | +| 查询在线支付状态 | 按既有数据范围 | 按店铺层级数据范围 | 禁止 | + +前端不要根据错误文案判断资源是否存在。无权限或资源不可见时,后端可能统一返回“无权限操作该资源或资源不存在”。常见处理: + +- `400`:保留表单输入并展示参数错误,不自动重试。 +- `401`:按现有登录失效流程处理。 +- `403`:关闭无权限操作入口,不尝试换账号类型绕过。 +- `409` 或业务冲突码:检查是否错误复用了 `request_id`。 +- `500`/超时:在线创建可使用原 `request_id` 重试一次;不要生成新 ID,否则可能创建第二张充值单。 + +## 九、前端本期最容易漏掉的工作 + +- 在线充值表单不要复用线下表单对象后整体序列化,避免携带 `shop_id`、凭证或备注。 +- 支付方式必须来自 `/payment-methods`,不要写死微信和支付宝都可用。 +- 金额显示元、提交分;禁止使用浮点数直接乘除后提交。 +- 二维码内容原样渲染,不记录到埋点、错误日志或浏览器持久化缓存。 +- 同一次网络重试复用 `request_id`,新一笔主动充值换新 ID。 +- `status=2` 只表示已收款、入账中,不能提前刷新成“钱包已到账”。 +- 列表、详情的页面分支使用 `recharge_source`,不要用 `payment_method` 或审批字段猜来源。 +- 线下单不调用支付状态接口;在线单不展示企微审批按钮。 +- 页面隐藏或浏览器切到后台时停止轮询,恢复可见后再继续。 +- 进入终态后停止轮询并刷新钱包余额、充值列表。 + +## 十、联调和验收边界 + +- OpenAPI 已包含 `/payment-methods`、在线创建、列表来源筛选、详情来源字段和 `/payment-status`。 +- 后端已完成微信 Native、支付宝 PreCreate、支付确认、异步钱包入账和回调丢失恢复的代码装配。 +- 当前交付已通过 `go build ./...`、`go vet ./...` 和 OpenSpec 严格校验。 +- 按需求方要求,本次未启动 API/Worker,未连接 PostgreSQL/Redis,未调用真实支付渠道,未发送真实回调,也未运行自动化测试。 +- 前端联调环境需具备完整支付配置;至少确认微信和支付宝创建响应分别返回非空 `qr_content`。 +- 本需求不支持 JSAPI、H5、WAP、同设备拉起支付、主动取消、支付方式切换、二维码图片接口或在线退款。 diff --git a/docs/feature-034-agent-wallet-qr-recharge/功能总结.md b/docs/feature-034-agent-wallet-qr-recharge/功能总结.md new file mode 100644 index 0000000..6d7fba4 --- /dev/null +++ b/docs/feature-034-agent-wallet-qr-recharge/功能总结.md @@ -0,0 +1,85 @@ +# 代理钱包桌面扫码充值 + +前端调用顺序、请求示例和页面状态详见 [`代理钱包扫码充值接口对接说明.md`](代理钱包扫码充值接口对接说明.md)。 + +## 功能范围 + +代理账号可在后台为当前所属店铺主钱包创建微信 Native 或支付宝当面付扫码充值。平台与超级管理员继续使用既有线下代充和企业微信审批,两个创建路径不共用权限。 + +充值订单相关响应统一返回: + +- `recharge_source=platform_offline`、`recharge_source_name=平台线下代充` +- `recharge_source=agent_online`、`recharge_source_name=代理在线自充` + +来源由受控支付方式推导:`offline` 为平台线下代充,`wechat|alipay` 为代理在线自充。列表支持 `recharge_source` 筛选,不新增重复数据库列。 + +## API 对接 + +### 查询可用支付方式 + +`GET /api/admin/agent-recharges/payment-methods` + +仅代理账号可用,返回当前真正可预下单、验签和查单的 `wechat`、`alipay`,以及在线充值金额范围 `10000~100000000` 分。 + +### 创建充值订单 + +`POST /api/admin/agent-recharges` + +代理在线自充请求仅允许: + +```json +{ + "amount": 10000, + "payment_method": "wechat", + "request_id": "客户端生成的唯一请求标识" +} +``` + +响应包含 `recharge_id`、`payment_no`、`qr_content`、`recharge_source` 和充值状态。前端直接使用 `qr_content` 渲染二维码,不展示或记录支付配置、商户身份和密钥。 + +平台线下代充使用 `payment_method=offline`,并按既有契约提交目标 `shop_id`、支付凭证和备注;响应来源为 `platform_offline`。 + +### 列表、详情与支付状态 + +- `GET /api/admin/agent-recharges?recharge_source=agent_online` +- `GET /api/admin/agent-recharges/:id` +- `GET /api/admin/agent-recharges/:id/payment-status` + +列表和详情均返回充值来源。支付状态接口仅用于在线充值,只读取本地充值单和支付单,返回支付确认时间与钱包完成时间,不返回 `qr_content`、内部重试错误或其他钱包余额,也不调用第三方渠道。 + +前端在页面可见时可每 3 秒轮询;进入已完成、已关闭、已退款或已驳回终态后停止轮询。 + +## 支付与到账 + +第三方收款成功后,回调只确认支付事实并写入 `agent_recharge.payment_confirmed.v1` Outbox。Worker 在独立事务中复用主钱包入账能力,以充值记录 ID 的唯一流水保证最多入账一次,并将充值状态从已支付推进为已完成。 + +支付单保存创建时的 `payment_config_id` 和 `merchant_identity`:微信记录商户号,支付宝记录应用 ID,供未来导出按实际收款身份核对。该内部字段不在创建、列表、详情或支付状态响应中暴露。 + +## 异常恢复 + +Worker 每分钟调度一次恢复任务,并使用唯一任务约束避免并发重复扫描。任务固定扫描最多 50 张创建超过 2 分钟的代理在线待支付单: + +- 尚无付款内容:使用原 `payment_no` 重试预下单。 +- 已有付款内容:按创建时的支付方式与配置主动查单。 +- 渠道确认成功:复用统一支付确认用例。 +- 渠道明确关闭:条件关闭仍待支付的本地单。 +- 超时、未知或渠道异常:保持原状态,等待后续重试。 + +微信、支付宝每次预下单和查单均写 Integration Log,其中包含耗时和安全摘要,不保存密钥或完整二维码内容。 + +## 部署与回滚 + +推荐顺序: + +1. 执行 `000197`、`000198` 数据库迁移。 +2. 确认微信商户号、证书、APIv3 Key、回调地址,或支付宝应用 ID、私钥、公钥、回调地址完整。 +3. 同时发布 API 与 Worker,确认回调路由、Outbox Relay、代理充值消费者和恢复任务均已装配。 +4. 最后开放前端扫码充值入口。 + +回滚时先关闭前端入口和在线支付方式,停止创建新单;回调、恢复任务、Outbox Relay 和钱包消费者必须继续处理已收款订单。不得删除待支付、已支付或待入账的支付事实。确认资金单全部收敛后,才可回滚应用与数据库迁移。 + +## 静态验收与未验证边界 + +本次按用户要求不启动 API/Worker,不连接 PostgreSQL/Redis,不调用真实支付,不发送回调,也不运行自动化测试。交付仅执行格式化、差异检查、编译、静态分析、OpenAPI 生成和 OpenSpec 严格校验。 + +上线前由发布方核对支付配置与回调公网可达性,并使用一笔微信和一笔支付宝订单确认渠道能返回非空 `qr_content`。回调丢失、重复回调与 Worker 重放可在需要时按上述状态和唯一流水口径人工核对。 diff --git a/internal/application/agentrecharge/confirm_online_payment.go b/internal/application/agentrecharge/confirm_online_payment.go new file mode 100644 index 0000000..6e71438 --- /dev/null +++ b/internal/application/agentrecharge/confirm_online_payment.go @@ -0,0 +1,190 @@ +package agentrecharge + +import ( + "context" + "strconv" + "strings" + "time" + + "gorm.io/gorm" + "gorm.io/gorm/clause" + + domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ConfirmOnlinePaymentCommand 描述验签或主动查单后得到的第三方收款事实。 +type ConfirmOnlinePaymentCommand struct { + PaymentNo string + PaymentMethod string + ConfigID uint + MerchantIdentity string + ThirdPartyTradeNo string + Amount int64 + PaidAt time.Time + RequestID string + CorrelationID string +} + +// PaymentConfirmedEvent 是第三方收款事实提交后的代理充值入账事件。 +type PaymentConfirmedEvent struct { + EventID string `json:"event_id"` + RechargeID uint `json:"recharge_id"` + RechargeNo string `json:"recharge_no"` + PaymentID uint `json:"payment_id"` + PaymentNo string `json:"payment_no"` + ShopID uint `json:"shop_id"` + WalletID uint `json:"wallet_id"` + UserID uint `json:"user_id"` + Amount int64 `json:"amount"` + PaymentMethod string `json:"payment_method"` + ThirdPartyTradeNo string `json:"third_party_trade_no"` + PaidAt time.Time `json:"paid_at"` + RequestID string `json:"request_id,omitempty"` + CorrelationID string `json:"correlation_id,omitempty"` +} + +// PaymentConfirmedEventWriter 在支付确认事务内追加可靠入账事件。 +type PaymentConfirmedEventWriter interface { + Append(ctx context.Context, tx *gorm.DB, event PaymentConfirmedEvent) error +} + +// ConfirmOnlinePaymentResult 返回支付确认是否属于幂等重放。 +type ConfirmOnlinePaymentResult struct { + RechargeID uint + AlreadyConfirmed bool +} + +// ConfirmOnlinePaymentService 统一处理回调和主动查单得到的代理充值支付事实。 +type ConfirmOnlinePaymentService struct { + db *gorm.DB + eventWriter PaymentConfirmedEventWriter +} + +// NewConfirmOnlinePaymentService 创建代理充值支付确认用例。 +func NewConfirmOnlinePaymentService(db *gorm.DB, eventWriter PaymentConfirmedEventWriter) *ConfirmOnlinePaymentService { + return &ConfirmOnlinePaymentService{db: db, eventWriter: eventWriter} +} + +// Execute 在一个短事务中校验并固化支付事实和可靠入账事件。 +func (s *ConfirmOnlinePaymentService) Execute(ctx context.Context, command ConfirmOnlinePaymentCommand) (*ConfirmOnlinePaymentResult, error) { + if s == nil || s.db == nil || s.eventWriter == nil { + return nil, errors.New(errors.CodeServiceUnavailable, "代理充值支付确认能力未配置") + } + command.PaymentNo = strings.TrimSpace(command.PaymentNo) + command.PaymentMethod = strings.TrimSpace(command.PaymentMethod) + command.MerchantIdentity = strings.TrimSpace(command.MerchantIdentity) + command.ThirdPartyTradeNo = strings.TrimSpace(command.ThirdPartyTradeNo) + if command.PaymentNo == "" || command.ConfigID == 0 || command.PaidAt.IsZero() { + return nil, errors.New(errors.CodeInvalidParam, "代理充值支付确认参数不完整") + } + + result := &ConfirmOnlinePaymentResult{} + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + payment, recharge, err := lockPaymentConfirmationFacts(ctx, tx, command.PaymentNo) + if err != nil { + return err + } + alreadyConfirmed, err := domain.ValidatePaymentConfirmation(toDomainConfirmationFacts(payment, recharge, command)) + if err != nil { + return err + } + result.RechargeID = recharge.ID + if alreadyConfirmed { + result.AlreadyConfirmed = true + return nil + } + if err := ensureTradeNoAvailable(ctx, tx, payment, command); err != nil { + return err + } + paidAt := command.PaidAt.UTC() + paymentUpdate := tx.WithContext(ctx).Model(&model.Payment{}). + Where("id = ? AND status IN ?", payment.ID, []int{model.PaymentRecordStatusPending, model.PaymentRecordStatusFailed}). + Updates(map[string]any{"status": model.PaymentRecordStatusPaid, "third_party_trade_no": command.ThirdPartyTradeNo, "paid_at": paidAt}) + if paymentUpdate.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, paymentUpdate.Error, "更新代理充值支付单失败") + } + if paymentUpdate.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "代理充值支付单状态已变化") + } + rechargeUpdate := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}). + Where("id = ? AND status IN ?", recharge.ID, []int{constants.RechargeStatusPending, constants.RechargeStatusClosed}). + Updates(map[string]any{"status": constants.RechargeStatusPaid, "payment_transaction_id": command.ThirdPartyTradeNo, "paid_at": paidAt}) + if rechargeUpdate.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, rechargeUpdate.Error, "更新代理充值单支付状态失败") + } + if rechargeUpdate.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "代理充值单状态已变化") + } + event := PaymentConfirmedEvent{ + EventID: "agent-recharge:" + strconv.FormatUint(uint64(recharge.ID), 10) + ":payment-confirmed", + RechargeID: recharge.ID, RechargeNo: recharge.RechargeNo, PaymentID: payment.ID, PaymentNo: payment.PaymentNo, + ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, UserID: recharge.UserID, Amount: recharge.Amount, + PaymentMethod: command.PaymentMethod, ThirdPartyTradeNo: command.ThirdPartyTradeNo, + PaidAt: paidAt, RequestID: command.RequestID, CorrelationID: command.CorrelationID, + } + if err := s.eventWriter.Append(ctx, tx, event); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "写入代理充值支付确认事件失败") + } + return nil + }) + if err != nil { + return nil, err + } + return result, nil +} + +func lockPaymentConfirmationFacts(ctx context.Context, tx *gorm.DB, paymentNo string) (*model.Payment, *model.AgentRechargeRecord, error) { + var payment model.Payment + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("payment_no = ?", paymentNo).First(&payment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil, errors.New(errors.CodeNotFound, "代理充值支付单不存在") + } + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理充值支付单失败") + } + var recharge model.AgentRechargeRecord + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", payment.OrderID).First(&recharge).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil, errors.New(errors.CodeConflict, "支付单关联的代理充值单不存在") + } + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理充值单失败") + } + return &payment, &recharge, nil +} + +func toDomainConfirmationFacts(payment *model.Payment, recharge *model.AgentRechargeRecord, command ConfirmOnlinePaymentCommand) domain.PaymentConfirmationFacts { + paymentConfigID, rechargeConfigID, rechargeChannel := uint(0), uint(0), "" + if payment.PaymentConfigID != nil { + paymentConfigID = *payment.PaymentConfigID + } + if recharge.PaymentConfigID != nil { + rechargeConfigID = *recharge.PaymentConfigID + } + if recharge.PaymentChannel != nil { + rechargeChannel = *recharge.PaymentChannel + } + return domain.PaymentConfirmationFacts{ + OrderType: payment.OrderType, ExpectedOrderType: model.PaymentOrderTypeAgentRecharge, + PaymentMethod: payment.PaymentMethod, RechargePaymentMethod: recharge.PaymentMethod, RechargePaymentChannel: rechargeChannel, + PaymentConfigID: paymentConfigID, RechargePaymentConfigID: rechargeConfigID, ConfirmedConfigID: command.ConfigID, + MerchantIdentity: payment.MerchantIdentity, ConfirmedMerchantIdentity: command.MerchantIdentity, + PaymentAmount: payment.Amount, RechargeAmount: recharge.Amount, ConfirmedAmount: command.Amount, + PaymentOrderID: payment.OrderID, RechargeID: recharge.ID, PaymentState: domain.PaymentState(payment.Status), + RechargeStatus: recharge.Status, StoredTradeNo: payment.ThirdPartyTradeNo, ConfirmedTradeNo: command.ThirdPartyTradeNo, + } +} + +func ensureTradeNoAvailable(ctx context.Context, tx *gorm.DB, payment *model.Payment, command ConfirmOnlinePaymentCommand) error { + var count int64 + if err := tx.WithContext(ctx).Unscoped().Model(&model.Payment{}). + Where("id <> ? AND payment_method = ? AND third_party_trade_no = ?", payment.ID, command.PaymentMethod, command.ThirdPartyTradeNo). + Count(&count).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "校验第三方交易号唯一性失败") + } + if count > 0 { + return errors.New(errors.CodeConflict, "第三方交易号已被其他支付单使用") + } + return nil +} diff --git a/internal/application/agentrecharge/online_creation.go b/internal/application/agentrecharge/online_creation.go new file mode 100644 index 0000000..61571c9 --- /dev/null +++ b/internal/application/agentrecharge/online_creation.go @@ -0,0 +1,331 @@ +package agentrecharge + +import ( + "context" + cryptorand "crypto/rand" + stderrors "errors" + "fmt" + "math/big" + "strings" + "time" + + "gorm.io/gorm" + + domain "github.com/break/junhong_cmp_fiber/internal/domain/agentrecharge" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + apperrors "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/idempotency" +) + +const onlineCreationOperation = "agent-recharge.create-online" + +// CreateOnlineCommand 描述从认证上下文构造的在线充值命令。 +type CreateOnlineCommand struct { + AccountID uint + UserType int + CurrentShopID uint + Amount int64 + PaymentMethod string + RequestID string +} + +// CreateOnlineResult 返回在线充值单、支付单及原始付款内容。 +type CreateOnlineResult struct { + Recharge *model.AgentRechargeRecord + Payment *model.Payment +} + +// AvailablePaymentMethodsResult 返回当前可用渠道及在线金额边界。 +type AvailablePaymentMethodsResult struct { + Methods []string + MinAmount int64 + MaxAmount int64 +} + +// OnlineCreationService 创建代理在线扫码充值单。 +type OnlineCreationService struct { + db *gorm.DB + wechat OnlinePaymentPort + alipay OnlinePaymentPort +} + +// NewOnlineCreationService 创建代理在线充值用例并以结构体字段注入两个渠道 Adapter。 +func NewOnlineCreationService(db *gorm.DB, wechat, alipay OnlinePaymentPort) *OnlineCreationService { + return &OnlineCreationService{db: db, wechat: wechat, alipay: alipay} +} + +// Execute 以短事务建单,事务外预下单,再条件保存付款内容或关闭失败订单。 +func (s *OnlineCreationService) Execute(ctx context.Context, command CreateOnlineCommand) (*CreateOnlineResult, error) { + if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil { + return nil, apperrors.New(apperrors.CodeServiceUnavailable, "代理在线充值能力未配置") + } + command.PaymentMethod = strings.TrimSpace(command.PaymentMethod) + command.RequestID = strings.TrimSpace(command.RequestID) + if err := domain.ValidateOnlineCreation(command.UserType, command.Amount, command.PaymentMethod); err != nil { + return nil, err + } + if command.AccountID == 0 || command.CurrentShopID == 0 || len(command.RequestID) > 64 || + !idempotency.ValidateScope(onlineCreationScope(command.AccountID), command.RequestID) { + return nil, apperrors.New(apperrors.CodeInvalidParam) + } + fingerprint, err := idempotency.Fingerprint(struct { + Amount int64 `json:"amount"` + PaymentMethod string `json:"payment_method"` + }{command.Amount, command.PaymentMethod}) + if err != nil { + return nil, apperrors.Wrap(apperrors.CodeInternalError, err, "生成在线充值请求指纹失败") + } + if replay, found, err := s.loadReplay(ctx, command, fingerprint); err != nil || found { + return replay, err + } + account, shop, wallet, config, adapter, err := s.loadCreationFacts(ctx, command) + if err != nil { + return nil, err + } + result, err := s.createLocalFacts(ctx, command, fingerprint.Value, account, shop, wallet, config) + if err != nil { + if replay, found, replayErr := s.loadReplay(ctx, command, fingerprint); replayErr != nil || found { + return replay, replayErr + } + return nil, err + } + paymentResult, err := adapter.PreCreate(ctx, OnlinePaymentRequest{ + PaymentNo: result.Payment.PaymentNo, Description: "代理主钱包充值", Amount: command.Amount, + ExpireAt: *result.Payment.ExpireAt, Config: config, + }) + if err != nil { + if !isUnknownPaymentResult(err) { + if closeErr := s.closeFailedCreation(ctx, result); closeErr != nil { + return nil, apperrors.Wrap(apperrors.CodeDatabaseError, closeErr, "支付预下单失败且关闭本地订单失败") + } + } + return nil, err + } + if strings.TrimSpace(paymentResult.QRContent) == "" { + if closeErr := s.closeFailedCreation(ctx, result); closeErr != nil { + return nil, closeErr + } + return nil, apperrors.New(apperrors.CodeServiceUnavailable, "支付渠道未返回付款内容") + } + update := s.db.WithContext(ctx).Model(&model.Payment{}). + Where("id = ? AND status = ? AND qr_content = ''", result.Payment.ID, model.PaymentRecordStatusPending). + Update("qr_content", paymentResult.QRContent) + if update.Error != nil { + return nil, apperrors.Wrap(apperrors.CodeDatabaseError, update.Error, "保存扫码付款内容失败") + } + if update.RowsAffected != 1 { + return nil, apperrors.New(apperrors.CodeConflict, "在线充值付款内容已变化") + } + result.Payment.QRContent = paymentResult.QRContent + return result, nil +} + +// AvailablePaymentMethods 按固定顺序返回配置完整的扫码支付方式。 +func (s *OnlineCreationService) AvailablePaymentMethods(ctx context.Context, userType int) (AvailablePaymentMethodsResult, error) { + result := AvailablePaymentMethodsResult{ + Methods: []string{}, MinAmount: constants.AgentOnlineRechargeMinAmount, MaxAmount: constants.AgentRechargeMaxAmount, + } + if userType != constants.UserTypeAgent { + return result, apperrors.New(apperrors.CodeForbidden, "仅代理账号可以查询在线支付方式") + } + var config model.WechatConfig + if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + return result, nil + } + return result, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败") + } + if s.wechat.Available(&config) { + result.Methods = append(result.Methods, constants.RechargeMethodWechat) + } + if s.alipay.Available(&config) { + result.Methods = append(result.Methods, constants.RechargeMethodAlipay) + } + return result, nil +} + +func (s *OnlineCreationService) loadCreationFacts( + ctx context.Context, + command CreateOnlineCommand, +) (*model.Account, *model.Shop, *model.AgentWallet, *model.WechatConfig, OnlinePaymentPort, error) { + var account model.Account + if err := s.db.WithContext(ctx).Where("id = ? AND user_type = ? AND status = ?", command.AccountID, constants.UserTypeAgent, constants.StatusEnabled).First(&account).Error; err != nil || account.ShopID == nil || *account.ShopID != command.CurrentShopID { + if err != nil && !stderrors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询在线充值账号失败") + } + return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "当前代理账号不可为该店铺充值") + } + var shop model.Shop + if err := s.db.WithContext(ctx).Where("id = ? AND status = ?", command.CurrentShopID, constants.StatusEnabled).First(&shop).Error; err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeForbidden, "无权限操作该资源或资源不存在") + } + return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺失败") + } + var wallet model.AgentWallet + if err := s.db.WithContext(ctx).Where("shop_id = ? AND wallet_type = ? AND status = ?", command.CurrentShopID, constants.AgentWalletTypeMain, constants.AgentWalletStatusNormal).First(&wallet).Error; err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeWalletNotFound, "当前店铺主钱包不存在或不可用") + } + return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询当前店铺主钱包失败") + } + var config model.WechatConfig + if err := s.db.WithContext(ctx).Where("is_active = ?", true).First(&config).Error; err != nil { + if stderrors.Is(err, gorm.ErrRecordNotFound) { + return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig) + } + return nil, nil, nil, nil, nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "查询生效支付配置失败") + } + adapter := s.adapter(command.PaymentMethod) + if adapter == nil || !adapter.Available(&config) { + return nil, nil, nil, nil, nil, apperrors.New(apperrors.CodeNoPaymentConfig) + } + return &account, &shop, &wallet, &config, adapter, nil +} + +func (s *OnlineCreationService) createLocalFacts( + ctx context.Context, + command CreateOnlineCommand, + fingerprint string, + account *model.Account, + shop *model.Shop, + wallet *model.AgentWallet, + config *model.WechatConfig, +) (*CreateOnlineResult, error) { + rechargeNo, err := newBusinessNo(constants.AgentRechargeOrderPrefix, time.Now().Format("20060102150405")) + if err != nil { + return nil, err + } + paymentNo, err := newBusinessNo("PAY", fmt.Sprintf("%d", time.Now().UnixMilli())) + if err != nil { + return nil, err + } + expireMinutes := config.AliPayExpireMinutes + if expireMinutes <= 0 { + expireMinutes = model.DefaultAliPayExpireMinutes + } + expireAt := time.Now().Add(time.Duration(expireMinutes) * time.Minute) + channel, requestID := command.PaymentMethod, command.RequestID + record := &model.AgentRechargeRecord{ + UserID: account.ID, AgentWalletID: wallet.ID, ShopID: shop.ID, RechargeNo: rechargeNo, + Amount: command.Amount, PaymentMethod: command.PaymentMethod, PaymentChannel: &channel, + PaymentConfigID: &config.ID, Status: constants.RechargeStatusPending, + RequestID: &requestID, RequestFingerprint: &fingerprint, + ShopIDTag: wallet.ShopIDTag, EnterpriseIDTag: wallet.EnterpriseIDTag, + } + payment := &model.Payment{ + PaymentNo: paymentNo, OrderType: model.PaymentOrderTypeAgentRecharge, + PaymentMethod: command.PaymentMethod, MerchantIdentity: paymentMerchantIdentity(command.PaymentMethod, config), + Amount: command.Amount, Status: model.PaymentRecordStatusPending, + PaymentConfigID: &config.ID, ExpireAt: &expireAt, + } + err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if err := tx.Create(record).Error; err != nil { + return err + } + payment.OrderID = record.ID + if err := tx.Create(payment).Error; err != nil { + return err + } + return nil + }) + if err != nil { + return nil, apperrors.Wrap(apperrors.CodeDatabaseError, err, "创建在线充值本地订单失败") + } + return &CreateOnlineResult{Recharge: record, Payment: payment}, nil +} + +func paymentMerchantIdentity(paymentMethod string, config *model.WechatConfig) string { + if config == nil { + return "" + } + if paymentMethod == constants.RechargeMethodWechat { + return config.WxMchID + } + if paymentMethod == constants.RechargeMethodAlipay { + return config.AliAppID + } + return "" +} + +func (s *OnlineCreationService) loadReplay( + ctx context.Context, + command CreateOnlineCommand, + fingerprint idempotency.FingerprintValue, +) (*CreateOnlineResult, bool, error) { + var record model.AgentRechargeRecord + err := s.db.WithContext(ctx).Where("user_id = ? AND request_id = ?", command.AccountID, command.RequestID).First(&record).Error + if stderrors.Is(err, gorm.ErrRecordNotFound) { + return nil, false, nil + } + if err != nil { + return nil, false, apperrors.Wrap(apperrors.CodeDatabaseError, err, "读取在线充值幂等记录失败") + } + existingFingerprint := "" + if record.RequestFingerprint != nil { + existingFingerprint = *record.RequestFingerprint + } + if existingFingerprint != fingerprint.Value { + return nil, true, apperrors.New(apperrors.CodeConflict, "同一请求标识对应的充值内容不一致") + } + var payment model.Payment + if err := s.db.WithContext(ctx).Where("order_id = ? AND order_type = ?", record.ID, model.PaymentOrderTypeAgentRecharge).First(&payment).Error; err != nil { + return nil, true, apperrors.Wrap(apperrors.CodeDatabaseError, err, "读取在线充值支付单失败") + } + if payment.QRContent == "" { + if record.Status == constants.RechargeStatusClosed || payment.Status == model.PaymentRecordStatusFailed { + return nil, true, apperrors.New(apperrors.CodeInvalidStatus, "原在线充值请求预下单失败") + } + return nil, true, apperrors.New(apperrors.CodeConflict, "在线充值请求正在处理中,请稍后重试") + } + return &CreateOnlineResult{Recharge: &record, Payment: &payment}, true, nil +} + +func (s *OnlineCreationService) closeFailedCreation(ctx context.Context, result *CreateOnlineResult) error { + if result == nil || result.Recharge == nil || result.Payment == nil || !domain.CanCloseAfterPreCreateFailure(result.Recharge.Status) { + return nil + } + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + paymentUpdate := tx.Model(&model.Payment{}). + Where("id = ? AND status = ?", result.Payment.ID, model.PaymentRecordStatusPending). + Update("status", model.PaymentRecordStatusFailed) + if paymentUpdate.Error != nil { + return apperrors.Wrap(apperrors.CodeDatabaseError, paymentUpdate.Error, "关闭失败支付单失败") + } + rechargeUpdate := tx.Model(&model.AgentRechargeRecord{}). + Where("id = ? AND status = ?", result.Recharge.ID, constants.RechargeStatusPending). + Update("status", constants.RechargeStatusClosed) + if rechargeUpdate.Error != nil { + return apperrors.Wrap(apperrors.CodeDatabaseError, rechargeUpdate.Error, "关闭失败充值单失败") + } + return nil + }) +} + +func (s *OnlineCreationService) adapter(paymentMethod string) OnlinePaymentPort { + if paymentMethod == constants.RechargeMethodWechat { + return s.wechat + } + if paymentMethod == constants.RechargeMethodAlipay { + return s.alipay + } + return nil +} + +func onlineCreationScope(accountID uint) idempotency.Scope { + return idempotency.Scope{Subject: fmt.Sprintf("account:%d", accountID), Operation: onlineCreationOperation} +} + +func newBusinessNo(prefix, timestamp string) (string, error) { + random, err := cryptorand.Int(cryptorand.Reader, big.NewInt(1000000)) + if err != nil { + return "", apperrors.Wrap(apperrors.CodeInternalError, err, "生成业务单号失败") + } + return fmt.Sprintf("%s%s%06d", prefix, timestamp, random.Int64()), nil +} + +func isUnknownPaymentResult(err error) bool { + var appErr *apperrors.AppError + return stderrors.As(err, &appErr) && appErr.Code == apperrors.CodeTimeout +} diff --git a/internal/application/agentrecharge/online_payment.go b/internal/application/agentrecharge/online_payment.go new file mode 100644 index 0000000..71cfa58 --- /dev/null +++ b/internal/application/agentrecharge/online_payment.go @@ -0,0 +1,48 @@ +package agentrecharge + +import ( + "context" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" +) + +const ( + // OnlinePaymentStatePending 表示渠道仍在等待付款。 + OnlinePaymentStatePending = "pending" + // OnlinePaymentStatePaid 表示渠道已确认收款。 + OnlinePaymentStatePaid = "paid" + // OnlinePaymentStateClosed 表示渠道订单已明确关闭。 + OnlinePaymentStateClosed = "closed" + // OnlinePaymentStateUnknown 表示渠道状态暂时无法确定。 + OnlinePaymentStateUnknown = "unknown" +) + +// OnlinePaymentRequest 描述扫码预下单所需的最小事实。 +type OnlinePaymentRequest struct { + PaymentNo string + Description string + Amount int64 + ExpireAt time.Time + Config *model.WechatConfig +} + +// OnlinePaymentResult 描述渠道返回的扫码付款内容。 +type OnlinePaymentResult struct { + QRContent string +} + +// OnlinePaymentQueryResult 描述统一后的渠道支付状态。 +type OnlinePaymentQueryResult struct { + State string + ThirdPartyTradeNo string + Amount int64 + PaidAt *time.Time +} + +// OnlinePaymentPort 定义代理扫码充值需要的最小渠道能力。 +type OnlinePaymentPort interface { + Available(config *model.WechatConfig) bool + PreCreate(ctx context.Context, request OnlinePaymentRequest) (OnlinePaymentResult, error) + Query(ctx context.Context, paymentNo string, config *model.WechatConfig) (OnlinePaymentQueryResult, error) +} diff --git a/internal/application/agentrecharge/recover_online_payment.go b/internal/application/agentrecharge/recover_online_payment.go new file mode 100644 index 0000000..e13a490 --- /dev/null +++ b/internal/application/agentrecharge/recover_online_payment.go @@ -0,0 +1,189 @@ +package agentrecharge + +import ( + "context" + "time" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// RecoverOnlinePaymentService 批量收敛长期待预下单或待支付的代理在线充值。 +type RecoverOnlinePaymentService struct { + db *gorm.DB + wechat OnlinePaymentPort + alipay OnlinePaymentPort + confirm *ConfirmOnlinePaymentService + now func() time.Time +} + +// NewRecoverOnlinePaymentService 创建代理在线充值支付恢复用例。 +func NewRecoverOnlinePaymentService(db *gorm.DB, wechat, alipay OnlinePaymentPort, confirm *ConfirmOnlinePaymentService) *RecoverOnlinePaymentService { + return &RecoverOnlinePaymentService{db: db, wechat: wechat, alipay: alipay, confirm: confirm, now: time.Now} +} + +// ProcessBatch 按固定批次读取本地待处理事实并调用对应渠道收敛状态。 +func (s *RecoverOnlinePaymentService) ProcessBatch(ctx context.Context) (int, error) { + if s == nil || s.db == nil || s.wechat == nil || s.alipay == nil || s.confirm == nil { + return 0, errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复能力未配置") + } + now := s.now().UTC() + var payments []model.Payment + if err := s.db.WithContext(ctx). + Where("order_type = ? AND status = ? AND created_at <= ?", model.PaymentOrderTypeAgentRecharge, model.PaymentRecordStatusPending, now.Add(-constants.AgentRechargeRecoveryMinimumAge)). + Order("created_at ASC, id ASC").Limit(constants.AgentRechargeRecoveryBatchSize). + Find(&payments).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "扫描待恢复代理充值支付单失败") + } + if len(payments) == 0 { + return 0, nil + } + recharges, configs, err := s.loadRecoveryFacts(ctx, payments) + if err != nil { + return 0, err + } + processed := 0 + var firstErr error + for index := range payments { + payment := &payments[index] + recharge := recharges[payment.OrderID] + config := recoveryConfig(payment, configs) + if recharge == nil || config == nil { + if firstErr == nil { + firstErr = errors.New(errors.CodeConflict, "待恢复支付单缺少充值单或创建配置") + } + continue + } + if err := s.recoverOne(ctx, payment, recharge, config, now); err != nil { + if firstErr == nil { + firstErr = err + } + continue + } + processed++ + } + return processed, firstErr +} + +func (s *RecoverOnlinePaymentService) recoverOne(ctx context.Context, payment *model.Payment, recharge *model.AgentRechargeRecord, config *model.WechatConfig, now time.Time) error { + adapter := s.adapter(payment.PaymentMethod) + if adapter == nil { + return errors.New(errors.CodeNoPaymentConfig, "代理充值创建时支付配置不可用") + } + if payment.QRContent == "" { + if !adapter.Available(config) { + return errors.New(errors.CodeNoPaymentConfig, "代理充值预下单配置不可用") + } + expireAt := now.Add(30 * time.Minute) + if payment.ExpireAt != nil && payment.ExpireAt.After(now) { + expireAt = *payment.ExpireAt + } + result, err := adapter.PreCreate(ctx, OnlinePaymentRequest{ + PaymentNo: payment.PaymentNo, Description: "代理主钱包充值", Amount: payment.Amount, + ExpireAt: expireAt, Config: config, + }) + if err != nil { + // 恢复阶段不能仅凭预下单错误推断未收款,保留本地状态等待下次查单。 + return nil + } + if result.QRContent == "" { + return nil + } + update := s.db.WithContext(ctx).Model(&model.Payment{}). + Where("id = ? AND status = ? AND qr_content = ''", payment.ID, model.PaymentRecordStatusPending). + Update("qr_content", result.QRContent) + if update.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, update.Error, "恢复代理充值扫码付款内容失败") + } + return nil + } + queryResult, err := adapter.Query(ctx, payment.PaymentNo, config) + if err != nil { + return nil + } + switch queryResult.State { + case OnlinePaymentStatePaid: + if queryResult.PaidAt == nil { + return errors.New(errors.CodeConflict, "支付渠道成功结果缺少支付时间") + } + _, err = s.confirm.Execute(ctx, ConfirmOnlinePaymentCommand{ + PaymentNo: payment.PaymentNo, PaymentMethod: payment.PaymentMethod, ConfigID: config.ID, + MerchantIdentity: paymentMerchantIdentity(payment.PaymentMethod, config), + ThirdPartyTradeNo: queryResult.ThirdPartyTradeNo, Amount: queryResult.Amount, PaidAt: *queryResult.PaidAt, + CorrelationID: payment.PaymentNo, + }) + return err + case OnlinePaymentStateClosed: + return s.closePending(ctx, payment.ID, recharge.ID) + default: + return nil + } +} + +func (s *RecoverOnlinePaymentService) loadRecoveryFacts(ctx context.Context, payments []model.Payment) (map[uint]*model.AgentRechargeRecord, map[uint]*model.WechatConfig, error) { + rechargeIDs := make([]uint, 0, len(payments)) + configIDs := make([]uint, 0, len(payments)) + for index := range payments { + rechargeIDs = append(rechargeIDs, payments[index].OrderID) + if payments[index].PaymentConfigID != nil { + configIDs = append(configIDs, *payments[index].PaymentConfigID) + } + } + var rechargeRows []model.AgentRechargeRecord + if err := s.db.WithContext(ctx).Where("id IN ?", rechargeIDs).Find(&rechargeRows).Error; err != nil { + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询待恢复代理充值单失败") + } + var configRows []model.WechatConfig + if len(configIDs) > 0 { + if err := s.db.WithContext(ctx).Where("id IN ?", configIDs).Find(&configRows).Error; err != nil { + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "批量查询代理充值创建配置失败") + } + } + recharges := make(map[uint]*model.AgentRechargeRecord, len(rechargeRows)) + for index := range rechargeRows { + recharges[rechargeRows[index].ID] = &rechargeRows[index] + } + configs := make(map[uint]*model.WechatConfig, len(configRows)) + for index := range configRows { + configs[configRows[index].ID] = &configRows[index] + } + return recharges, configs, nil +} + +func recoveryConfig(payment *model.Payment, configs map[uint]*model.WechatConfig) *model.WechatConfig { + if payment.PaymentConfigID == nil { + return nil + } + return configs[*payment.PaymentConfigID] +} + +func (s *RecoverOnlinePaymentService) closePending(ctx context.Context, paymentID, rechargeID uint) error { + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + paymentUpdate := tx.Model(&model.Payment{}). + Where("id = ? AND status = ?", paymentID, model.PaymentRecordStatusPending). + Update("status", model.PaymentRecordStatusFailed) + if paymentUpdate.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, paymentUpdate.Error, "关闭失效代理充值支付单失败") + } + rechargeUpdate := tx.Model(&model.AgentRechargeRecord{}). + Where("id = ? AND status = ?", rechargeID, constants.RechargeStatusPending). + Update("status", constants.RechargeStatusClosed) + if rechargeUpdate.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, rechargeUpdate.Error, "关闭失效代理充值单失败") + } + return nil + }) +} + +func (s *RecoverOnlinePaymentService) adapter(paymentMethod string) OnlinePaymentPort { + if paymentMethod == constants.RechargeMethodWechat { + return s.wechat + } + if paymentMethod == constants.RechargeMethodAlipay { + return s.alipay + } + return nil +} diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 542d208..a728601 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -17,6 +17,7 @@ import ( systemConfigInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/systemconfig" wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom" pollingPkg "github.com/break/junhong_cmp_fiber/internal/polling" + agentRechargeQuery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge" assetQuery "github.com/break/junhong_cmp_fiber/internal/query/asset" exchangeQuery "github.com/break/junhong_cmp_fiber/internal/query/exchange" notificationQuery "github.com/break/junhong_cmp_fiber/internal/query/notification" @@ -215,7 +216,11 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant), AdminOrder: admin.NewOrderHandler(svc.Order, validate), AdminExchange: admin.NewExchangeHandler(svc.Exchange, exchangeQuery.NewListQuery(deps.DB), validate), - PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge, deps.WechatPayment, svc.WechatConfig, paymentStore, deps.Logger), + PaymentCallback: callback.NewPaymentHandler( + svc.Order, svc.Recharge, rechargeOrderService, svc.AgentRecharge, + deps.WechatPayment, svc.WechatConfig, paymentStore, + svc.AgentRechargePaymentConfirm, integrationlog.NewRepository(deps.DB), deps.Logger, + ), CTCCRealnameCallback: callback.NewCTCCRealnameHandler( carriercallback.NewCTCCRealnameTranslator(), carriercallback.NewCTCCCardResolver(deps.DB), integrationlog.NewRepository(deps.DB), svc.CardObservation, svc.CardObservationSeries, systemConfigReader, deps.Logger, @@ -261,8 +266,13 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { h.SetAssetService(svc.Asset) return h }(), - WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig), - AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate), + WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig), + AgentRecharge: func() *admin.AgentRechargeHandler { + handler := admin.NewAgentRechargeHandler(svc.AgentRecharge, validate) + handler.SetOnlineCreationService(svc.AgentRechargeOnline) + handler.SetPaymentStatusQuery(agentRechargeQuery.NewPaymentStatusQuery(deps.DB)) + return handler + }(), Refund: admin.NewRefundHandler(svc.Refund), OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(svc.OrderPackageInvalidate), AssetPackageBatchOrder: admin.NewAssetPackageBatchOrderHandler(svc.AssetPackageBatchOrder, validate), diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index 996a626..1f11bcc 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -16,6 +16,7 @@ import ( exchangeInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/exchange" "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + paymentInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/payment" walletinfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wallet" wecomInfra "github.com/break/junhong_cmp_fiber/internal/infrastructure/wecom" "github.com/break/junhong_cmp_fiber/internal/polling" @@ -37,6 +38,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/payment" "github.com/break/junhong_cmp_fiber/pkg/queue" + "github.com/break/junhong_cmp_fiber/pkg/wechat" assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset" assetPackageBatchOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_package_batch_order" @@ -123,6 +125,8 @@ type services struct { StopResumeService *iotCardSvc.StopResumeService WechatConfig *wechatConfigSvc.Service AgentRecharge *agentRechargeSvc.Service + AgentRechargeOnline *agentrechargeApp.OnlineCreationService + AgentRechargePaymentConfirm *agentrechargeApp.ConfirmOnlinePaymentService PackageActivation *packageSvc.ActivationService Refund *refundSvc.Service TrafficQuery *trafficSvc.QueryService @@ -265,6 +269,16 @@ func initServices(s *stores, deps *Dependencies) *services { ) agentWalletPosting := walletapp.NewPostingService(walletinfra.NewCreditEventWriter(walletOutbox), nil) agentRechargeService.SetAgentWalletPostingService(agentWalletPosting) + paymentIntegration := integrationlog.NewRepository(deps.DB) + agentRechargeOnline := agentrechargeApp.NewOnlineCreationService( + deps.DB, + paymentInfra.NewWechatNativeAdapter(wechat.NewRedisCache(deps.Redis), paymentIntegration, deps.Logger), + paymentInfra.NewAlipayPreCreateAdapter(paymentIntegration), + ) + agentRechargePaymentConfirm := agentrechargeApp.NewConfirmOnlinePaymentService( + deps.DB, + paymentInfra.NewAgentRechargePaymentEventWriter(outbox.NewRepository()), + ) refundService := refundSvc.New( deps.DB, s.RefundRequest, @@ -361,51 +375,53 @@ func initServices(s *stores, deps *Dependencies) *services { commissionStatsSvc.New(s.ShopSeriesCommissionStats), deps.Logger, ), - Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account), - EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization, s.IotCard), - EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger), - Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger), - IotCard: iotCard, - IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient, assetAudit), - ExportTask: exportTaskSvc.New(deps.DB, s.ExportTask, deps.QueueClient, deps.StorageService), - Device: device, - DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient, assetAudit), - AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account), - Carrier: carrierSvc.New(s.Carrier), - PackageSeries: packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package), - Package: packageService, - PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger), - PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger), - ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, accountAudit), - ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop), - ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger), - CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats), - PurchaseValidation: purchaseValidation, - Order: orderService, - Exchange: exchangeService, - Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger), - PollingConfig: pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger), - PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis), - PollingMonitoring: pollingSvc.NewMonitoringServiceWithQueueMgr(deps.Redis, pollingQueueMgr, deps.Logger), - PollingAlert: pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger), - PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger), - PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger), - Asset: assetService, - AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, assetAudit), - AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction), - StopResumeService: stopResumeService, - WechatConfig: wechatConfig, - AgentRecharge: agentRechargeService, - PackageActivation: packageActivation, - TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage), - OperationPassword: operationPassword, - AgentOpenAPI: agentOpenAPI, - Refund: refundService, - CustomerBinding: customerBinding, - OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient), - AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient), - ObservationSeries: observationSeries, - CardObservation: cardObservationService, - CardObservationSeries: cardObservationSeries, + Enterprise: enterpriseSvc.New(deps.DB, s.Enterprise, s.Shop, s.Account), + EnterpriseCard: enterpriseCardSvc.New(deps.DB, s.Enterprise, s.EnterpriseCardAuthorization, s.IotCard), + EnterpriseDevice: enterpriseDeviceSvc.New(deps.DB, s.Enterprise, s.Device, s.DeviceSimBinding, s.EnterpriseDeviceAuthorization, s.EnterpriseCardAuthorization, deps.Logger), + Authorization: enterpriseCardSvc.NewAuthorizationService(s.Enterprise, s.IotCard, s.EnterpriseCardAuthorization, deps.Logger), + IotCard: iotCard, + IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient, assetAudit), + ExportTask: exportTaskSvc.New(deps.DB, s.ExportTask, deps.QueueClient, deps.StorageService), + Device: device, + DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient, assetAudit), + AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account), + Carrier: carrierSvc.New(s.Carrier), + PackageSeries: packageSeriesSvc.New(s.PackageSeries, s.ShopSeriesAllocation, s.Package), + Package: packageService, + PackageDailyRecord: packageSvc.NewDailyRecordService(deps.DB, deps.Redis, s.PackageUsageDailyRecord, deps.Logger), + PackageCustomerView: packageSvc.NewCustomerViewService(deps.DB, deps.Redis, s.PackageUsage, deps.Logger), + ShopPackageBatchAllocation: shopPackageBatchAllocationSvc.New(deps.DB, s.Package, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.Shop, accountAudit), + ShopPackageBatchPricing: shopPackageBatchPricingSvc.New(deps.DB, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop), + ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger), + CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats), + PurchaseValidation: purchaseValidation, + Order: orderService, + Exchange: exchangeService, + Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger), + PollingConfig: pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger), + PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis), + PollingMonitoring: pollingSvc.NewMonitoringServiceWithQueueMgr(deps.Redis, pollingQueueMgr, deps.Logger), + PollingAlert: pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger), + PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger), + PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger), + Asset: assetService, + AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device, assetAudit), + AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction), + StopResumeService: stopResumeService, + WechatConfig: wechatConfig, + AgentRecharge: agentRechargeService, + AgentRechargeOnline: agentRechargeOnline, + AgentRechargePaymentConfirm: agentRechargePaymentConfirm, + PackageActivation: packageActivation, + TrafficQuery: trafficSvc.NewQueryService(deps.Redis, s.CardDailyUsage), + OperationPassword: operationPassword, + AgentOpenAPI: agentOpenAPI, + Refund: refundService, + CustomerBinding: customerBinding, + OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient), + AssetPackageBatchOrder: assetPackageBatchOrderSvc.New(s.AssetPackageBatchOrderTask, s.Package, deps.QueueClient), + ObservationSeries: observationSeries, + CardObservation: cardObservationService, + CardObservationSeries: cardObservationSeries, } } diff --git a/internal/domain/agentrecharge/online.go b/internal/domain/agentrecharge/online.go new file mode 100644 index 0000000..74b1743 --- /dev/null +++ b/internal/domain/agentrecharge/online.go @@ -0,0 +1,30 @@ +// Package agentrecharge 定义代理在线充值的纯业务规则。 +package agentrecharge + +import ( + "strings" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// ValidateOnlineCreation 校验代理在线充值的角色、金额和支付方式不变量。 +func ValidateOnlineCreation(userType int, amount int64, paymentMethod string) error { + if userType != constants.UserTypeAgent { + return errors.New(errors.CodeForbidden, "仅代理账号可以创建在线扫码充值") + } + if amount < constants.AgentOnlineRechargeMinAmount || amount > constants.AgentRechargeMaxAmount { + return errors.New(errors.CodeInvalidParam, "在线充值金额必须在100元至100万元之间") + } + switch strings.TrimSpace(paymentMethod) { + case constants.RechargeMethodWechat, constants.RechargeMethodAlipay: + return nil + default: + return errors.New(errors.CodeInvalidParam, "在线充值支付方式无效") + } +} + +// CanCloseAfterPreCreateFailure 判断预下单明确失败后能否关闭充值单。 +func CanCloseAfterPreCreateFailure(status int) bool { + return status == constants.RechargeStatusPending +} diff --git a/internal/domain/agentrecharge/payment_confirmation.go b/internal/domain/agentrecharge/payment_confirmation.go new file mode 100644 index 0000000..267bc45 --- /dev/null +++ b/internal/domain/agentrecharge/payment_confirmation.go @@ -0,0 +1,82 @@ +package agentrecharge + +import ( + "strings" + + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// PaymentState 是支付单在确认用例中的领域状态。 +type PaymentState int + +const ( + // PaymentStatePending 表示支付单仍待确认。 + PaymentStatePending PaymentState = iota + // PaymentStatePaid 表示支付单已确认收款。 + PaymentStatePaid + // PaymentStateFailed 表示支付单曾被明确关闭。 + PaymentStateFailed + // PaymentStateRefunded 表示支付单已经退款。 + PaymentStateRefunded +) + +// PaymentConfirmationFacts 是支付确认所需的渠道与本地权威事实。 +type PaymentConfirmationFacts struct { + OrderType string + ExpectedOrderType string + PaymentMethod string + RechargePaymentMethod string + RechargePaymentChannel string + PaymentConfigID uint + RechargePaymentConfigID uint + ConfirmedConfigID uint + MerchantIdentity string + ConfirmedMerchantIdentity string + PaymentAmount int64 + RechargeAmount int64 + ConfirmedAmount int64 + PaymentOrderID uint + RechargeID uint + PaymentState PaymentState + RechargeStatus int + StoredTradeNo string + ConfirmedTradeNo string +} + +// ValidatePaymentConfirmation 校验支付确认不变量,并返回是否属于完全一致的重复确认。 +func ValidatePaymentConfirmation(facts PaymentConfirmationFacts) (bool, error) { + if facts.ExpectedOrderType == "" || facts.OrderType != facts.ExpectedOrderType || + facts.RechargeID == 0 || facts.PaymentOrderID != facts.RechargeID { + return false, errors.New(errors.CodeConflict, "支付单与代理充值单关联不一致") + } + method := strings.TrimSpace(facts.PaymentMethod) + if method == "" || method != strings.TrimSpace(facts.RechargePaymentMethod) || + method != strings.TrimSpace(facts.RechargePaymentChannel) { + return false, errors.New(errors.CodeConflict, "支付渠道与代理充值单不一致") + } + identity := strings.TrimSpace(facts.MerchantIdentity) + if facts.PaymentConfigID == 0 || facts.PaymentConfigID != facts.RechargePaymentConfigID || + facts.PaymentConfigID != facts.ConfirmedConfigID || identity == "" || + identity != strings.TrimSpace(facts.ConfirmedMerchantIdentity) { + return false, errors.New(errors.CodeConflict, "支付配置身份与创建记录不一致") + } + tradeNo := strings.TrimSpace(facts.ConfirmedTradeNo) + if tradeNo == "" || facts.ConfirmedAmount <= 0 || facts.PaymentAmount != facts.RechargeAmount || + facts.PaymentAmount != facts.ConfirmedAmount { + return false, errors.New(errors.CodeConflict, "支付金额或第三方交易号无效") + } + if facts.PaymentState == PaymentStatePaid { + if strings.TrimSpace(facts.StoredTradeNo) == tradeNo && + (facts.RechargeStatus == constants.RechargeStatusPaid || facts.RechargeStatus == constants.RechargeStatusCompleted) { + return true, nil + } + return false, errors.New(errors.CodeConflict, "支付单已存在不一致的确认事实") + } + validPending := facts.PaymentState == PaymentStatePending && facts.RechargeStatus == constants.RechargeStatusPending + validLateSuccess := facts.PaymentState == PaymentStateFailed && facts.RechargeStatus == constants.RechargeStatusClosed + if !validPending && !validLateSuccess { + return false, errors.New(errors.CodeInvalidStatus, "代理充值单当前状态不可确认支付") + } + return false, nil +} diff --git a/internal/handler/admin/agent_recharge.go b/internal/handler/admin/agent_recharge.go index 9c3acde..c752648 100644 --- a/internal/handler/admin/agent_recharge.go +++ b/internal/handler/admin/agent_recharge.go @@ -1,23 +1,42 @@ package admin import ( + "bytes" "strconv" + "strings" + "github.com/bytedance/sonic" "github.com/go-playground/validator/v10" "github.com/gofiber/fiber/v2" + agentrechargeapp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" "github.com/break/junhong_cmp_fiber/internal/model/dto" + agentrechargequery "github.com/break/junhong_cmp_fiber/internal/query/agentrecharge" agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge" + "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/response" ) // AgentRechargeHandler 代理预充值 Handler type AgentRechargeHandler struct { service *agentRechargeSvc.Service + online *agentrechargeapp.OnlineCreationService + status *agentrechargequery.PaymentStatusQuery validator *validator.Validate } +// SetOnlineCreationService 注入代理在线扫码充值用例。 +func (h *AgentRechargeHandler) SetOnlineCreationService(service *agentrechargeapp.OnlineCreationService) { + h.online = service +} + +// SetPaymentStatusQuery 注入代理充值本地支付状态 Query。 +func (h *AgentRechargeHandler) SetPaymentStatusQuery(query *agentrechargequery.PaymentStatusQuery) { + h.status = query +} + // NewAgentRechargeHandler 创建代理预充值 Handler func NewAgentRechargeHandler(service *agentRechargeSvc.Service, validator *validator.Validate) *AgentRechargeHandler { return &AgentRechargeHandler{service: service, validator: validator} @@ -27,13 +46,21 @@ func NewAgentRechargeHandler(service *agentRechargeSvc.Service, validator *valid // POST /api/admin/agent-recharges func (h *AgentRechargeHandler) Create(c *fiber.Ctx) error { var req dto.CreateAgentRechargeRequest - if err := c.BodyParser(&req); err != nil { + decoder := sonic.ConfigStd.NewDecoder(bytes.NewReader(c.Body())) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { return errors.New(errors.CodeInvalidParam, "请求参数解析失败") } if err := h.validator.Struct(&req); err != nil { return errors.New(errors.CodeInvalidParam) } + if req.PaymentMethod == constants.RechargeMethodWechat || req.PaymentMethod == constants.RechargeMethodAlipay { + return h.createOnline(c, req) + } + if strings.TrimSpace(req.RequestID) != "" { + return errors.New(errors.CodeInvalidParam, "线下充值不能传入在线请求标识") + } result, err := h.service.Create(c.UserContext(), &req) if err != nil { return err @@ -42,6 +69,45 @@ func (h *AgentRechargeHandler) Create(c *fiber.Ctx) error { return response.Success(c, result) } +func (h *AgentRechargeHandler) createOnline(c *fiber.Ctx, req dto.CreateAgentRechargeRequest) error { + if h.online == nil { + return errors.New(errors.CodeServiceUnavailable, "代理在线充值能力未配置") + } + if req.ShopID != nil || len(req.PaymentVoucherKey) > 0 || strings.TrimSpace(req.Remark) != "" { + return errors.New(errors.CodeInvalidParam, "在线充值不能指定店铺、支付凭证或运营备注") + } + result, err := h.online.Execute(c.UserContext(), agentrechargeapp.CreateOnlineCommand{ + AccountID: middleware.GetUserIDFromContext(c.UserContext()), UserType: middleware.GetUserTypeFromContext(c.UserContext()), + CurrentShopID: middleware.GetShopIDFromContext(c.UserContext()), Amount: req.Amount, + PaymentMethod: req.PaymentMethod, RequestID: req.RequestID, + }) + if err != nil { + return err + } + rechargeSource, rechargeSourceName := constants.GetAgentRechargeSource(result.Payment.PaymentMethod) + return response.Success(c, &dto.AgentRechargeOnlineResponse{ + RechargeID: result.Recharge.ID, RechargeNo: result.Recharge.RechargeNo, PaymentNo: result.Payment.PaymentNo, + PaymentMethod: result.Payment.PaymentMethod, Amount: result.Payment.Amount, QRContent: result.Payment.QRContent, + RechargeSource: rechargeSource, RechargeSourceName: rechargeSourceName, + Status: result.Recharge.Status, StatusName: constants.GetRechargeStatusName(result.Recharge.Status), + }) +} + +// PaymentMethods 查询代理在线充值可用支付方式。 +// GET /api/admin/agent-recharges/payment-methods +func (h *AgentRechargeHandler) PaymentMethods(c *fiber.Ctx) error { + if h.online == nil { + return errors.New(errors.CodeServiceUnavailable, "代理在线充值能力未配置") + } + result, err := h.online.AvailablePaymentMethods(c.UserContext(), middleware.GetUserTypeFromContext(c.UserContext())) + if err != nil { + return err + } + return response.Success(c, &dto.AgentRechargePaymentMethodsResponse{ + Methods: result.Methods, MinAmount: result.MinAmount, MaxAmount: result.MaxAmount, + }) +} + // List 查询代理充值订单列表 // GET /api/admin/agent-recharges func (h *AgentRechargeHandler) List(c *fiber.Ctx) error { @@ -49,6 +115,9 @@ func (h *AgentRechargeHandler) List(c *fiber.Ctx) error { if err := c.QueryParser(&req); err != nil { return errors.New(errors.CodeInvalidParam, "请求参数解析失败") } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } list, total, err := h.service.List(c.UserContext(), &req) if err != nil { @@ -74,6 +143,23 @@ func (h *AgentRechargeHandler) Get(c *fiber.Ctx) error { return response.Success(c, result) } +// PaymentStatus 查询代理充值本地支付与到账状态。 +// GET /api/admin/agent-recharges/:id/payment-status +func (h *AgentRechargeHandler) PaymentStatus(c *fiber.Ctx) error { + if h.status == nil { + return errors.New(errors.CodeServiceUnavailable, "代理充值支付状态查询未配置") + } + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "无效的充值记录ID") + } + result, err := h.status.Get(c.UserContext(), uint(id)) + if err != nil { + return err + } + return response.Success(c, result) +} + // Reject 驳回代理充值订单 // POST /api/admin/agent-recharges/:id/reject func (h *AgentRechargeHandler) Reject(c *fiber.Ctx) error { diff --git a/internal/handler/callback/payment.go b/internal/handler/callback/payment.go index 16700a1..c089caa 100644 --- a/internal/handler/callback/payment.go +++ b/internal/handler/callback/payment.go @@ -2,16 +2,18 @@ package callback import ( "context" - "fmt" "net/http" "net/url" "strconv" "strings" + "time" "github.com/gofiber/fiber/v2" "github.com/valyala/fasthttp/fasthttpadaptor" "go.uber.org/zap" + agentrechargeApp "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" "github.com/break/junhong_cmp_fiber/internal/model" orderService "github.com/break/junhong_cmp_fiber/internal/service/order" rechargeService "github.com/break/junhong_cmp_fiber/internal/service/recharge" @@ -21,6 +23,7 @@ import ( "github.com/break/junhong_cmp_fiber/pkg/constants" "github.com/break/junhong_cmp_fiber/pkg/errors" "github.com/break/junhong_cmp_fiber/pkg/fuiou" + pkgmiddleware "github.com/break/junhong_cmp_fiber/pkg/middleware" "github.com/break/junhong_cmp_fiber/pkg/wechat" "gorm.io/gorm" ) @@ -44,6 +47,8 @@ type PaymentHandler struct { wechatPayment wechat.PaymentServiceInterface wechatConfigService WechatConfigServiceInterface paymentStore *postgres.PaymentStore + agentPaymentConfirm *agentrechargeApp.ConfirmOnlinePaymentService + integration *integrationlog.Repository logger *zap.Logger } @@ -55,6 +60,8 @@ func NewPaymentHandler( wechatPayment wechat.PaymentServiceInterface, wechatConfigService WechatConfigServiceInterface, paymentStore *postgres.PaymentStore, + agentPaymentConfirm *agentrechargeApp.ConfirmOnlinePaymentService, + integration *integrationlog.Repository, logger *zap.Logger, ) *PaymentHandler { return &PaymentHandler{ @@ -65,15 +72,30 @@ func NewPaymentHandler( wechatPayment: wechatPayment, wechatConfigService: wechatConfigService, paymentStore: paymentStore, + agentPaymentConfirm: agentPaymentConfirm, + integration: integration, logger: logger, } } +type verifiedPaymentCallback struct { + PaymentNo string + PaymentMethod string + TransactionID string + Amount int64 + ConfigID uint + MerchantIdentity string + PaidAt time.Time + Provider string + RawPayload []byte + ContentType string +} + // WechatPayCallback 微信支付回调(带签名验证) // POST /api/callback/wechat-pay func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error { body := c.Body() - ctx := context.Background() + ctx := c.UserContext() // 预解析订单号(不验签),用于按 payment_config_id 加载创建订单时所用的配置 orderNo, err := wechat.PeekOrderNo(body) @@ -103,7 +125,13 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error { } // TotalFee 为字符串格式的分,解析失败则降级为 0(后续 handlePaymentCallback 会记录日志) totalFee, _ := strconv.ParseInt(result.TotalFee, 10, 64) - if err := h.dispatchWechatCallback(ctx, result.OutTradeNo, result.TransactionID, totalFee); err != nil { + paidAt, _ := time.ParseInLocation("20060102150405", result.SuccessTime, time.Local) + if err := h.dispatchWechatCallback(ctx, verifiedPaymentCallback{ + PaymentNo: result.OutTradeNo, PaymentMethod: model.PaymentMethodWechat, + TransactionID: result.TransactionID, Amount: totalFee, ConfigID: cfg.ID, + MerchantIdentity: cfg.WxMchID, PaidAt: paidAt, Provider: constants.IntegrationProviderWechatPay, + RawPayload: body, ContentType: c.Get("Content-Type"), + }); err != nil { return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "处理微信支付回调失败") } @@ -117,7 +145,13 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error { if result.TradeState != "SUCCESS" { return nil } - return h.dispatchWechatCallback(ctx, result.OutTradeNo, result.TransactionID, result.TotalAmount) + paidAt, _ := time.Parse(time.RFC3339, result.SuccessTime) + return h.dispatchWechatCallback(ctx, verifiedPaymentCallback{ + PaymentNo: result.OutTradeNo, PaymentMethod: model.PaymentMethodWechat, + TransactionID: result.TransactionID, Amount: result.TotalAmount, ConfigID: cfg.ID, + MerchantIdentity: cfg.WxMchID, PaidAt: paidAt, Provider: constants.IntegrationProviderWechatPay, + RawPayload: body, ContentType: c.Get("Content-Type"), + }) }) if err != nil { return errors.Wrap(errors.CodeWechatCallbackInvalid, err, "处理微信支付回调失败") @@ -130,20 +164,22 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error { return h.wechatV2SuccessResponse(c) } -func (h *PaymentHandler) dispatchPaymentRecordCallback(ctx context.Context, paymentNo, paymentMethod, transactionID string, paidAmount int64) (bool, error) { +func (h *PaymentHandler) dispatchPaymentRecordCallback(ctx context.Context, callback verifiedPaymentCallback) (bool, error) { if h.paymentStore != nil { - payment, err := h.paymentStore.GetByPaymentNo(ctx, paymentNo) + payment, err := h.paymentStore.GetByPaymentNo(ctx, callback.PaymentNo) if err == nil { switch payment.OrderType { case model.PaymentOrderTypePackage: - return true, h.orderService.HandlePaymentRecordCallback(ctx, paymentNo, paymentMethod, transactionID, paidAmount) + return true, h.orderService.HandlePaymentRecordCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID, callback.Amount) case model.PaymentOrderTypeRecharge: if h.rechargeOrderService != nil { - return true, h.rechargeOrderService.HandlePaymentCallback(ctx, paymentNo, paymentMethod, transactionID) + return true, h.rechargeOrderService.HandlePaymentCallback(ctx, callback.PaymentNo, callback.PaymentMethod, callback.TransactionID) } - return true, fmt.Errorf("充值订单服务未配置,无法处理支付单: %s", paymentNo) + return true, errors.New(errors.CodeInternalError, "充值订单服务未配置") + case model.PaymentOrderTypeAgentRecharge: + return true, h.confirmAgentRechargePayment(ctx, callback) default: - return true, fmt.Errorf("未知支付记录类型: %s", payment.OrderType) + return true, errors.New(errors.CodeInvalidStatus, "未知支付记录类型") } } if err != gorm.ErrRecordNotFound { @@ -154,27 +190,83 @@ func (h *PaymentHandler) dispatchPaymentRecordCallback(ctx context.Context, paym } // dispatchWechatCallback 优先按支付单分发,旧单号继续按前缀兼容处理。 -func (h *PaymentHandler) dispatchWechatCallback(ctx context.Context, outTradeNo, transactionID string, paidAmount int64) error { - handled, err := h.dispatchPaymentRecordCallback(ctx, outTradeNo, model.PaymentMethodWechat, transactionID, paidAmount) +func (h *PaymentHandler) dispatchWechatCallback(ctx context.Context, callback verifiedPaymentCallback) error { + handled, err := h.dispatchPaymentRecordCallback(ctx, callback) if handled || err != nil { return err } switch { - case strings.HasPrefix(outTradeNo, "ORD"): - return h.orderService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, paidAmount) - case strings.HasPrefix(outTradeNo, constants.AssetRechargeOrderPrefix): + case strings.HasPrefix(callback.PaymentNo, "ORD"): + return h.orderService.HandlePaymentCallback(ctx, callback.PaymentNo, model.PaymentMethodWechat, callback.Amount) + case strings.HasPrefix(callback.PaymentNo, constants.AssetRechargeOrderPrefix): if h.rechargeOrderService != nil { - return h.rechargeOrderService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentByWechat, transactionID) + return h.rechargeOrderService.HandlePaymentCallback(ctx, callback.PaymentNo, model.PaymentByWechat, callback.TransactionID) } - return fmt.Errorf("充值订单服务未配置,无法处理订单: %s", outTradeNo) - case strings.HasPrefix(outTradeNo, constants.AgentRechargeOrderPrefix): + return errors.New(errors.CodeInternalError, "充值订单服务未配置") + case strings.HasPrefix(callback.PaymentNo, constants.AgentRechargeOrderPrefix): if h.agentRechargeService != nil { - return h.agentRechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, transactionID, paidAmount) + return h.agentRechargeService.HandlePaymentCallback(ctx, callback.PaymentNo, model.PaymentMethodWechat, callback.TransactionID, callback.Amount) } - return fmt.Errorf("代理充值服务未配置,无法处理订单: %s", outTradeNo) + return errors.New(errors.CodeInternalError, "代理充值服务未配置") default: - return fmt.Errorf("未知订单号前缀: %s", outTradeNo) + return errors.New(errors.CodeInvalidStatus, "未知订单号前缀") + } +} + +func (h *PaymentHandler) confirmAgentRechargePayment(ctx context.Context, callback verifiedPaymentCallback) error { + if h.agentPaymentConfirm == nil || h.integration == nil { + return errors.New(errors.CodeInternalError, "代理充值支付回调能力未配置") + } + resourceID, correlationID := callback.PaymentNo, callback.PaymentNo + idempotencyKey := callback.TransactionID + if idempotencyKey == "" { + idempotencyKey = callback.PaymentNo + } + log, _, err := h.integration.RecordInbound(ctx, integrationlog.InboundAttempt{ + IdempotencyKey: idempotencyKey, Provider: callback.Provider, + Operation: constants.IntegrationOperationPaymentCallback, ExternalID: callback.TransactionID, + ResourceType: constants.IntegrationResourceTypeAgentRechargePayment, ResourceID: &resourceID, + RawPayload: callback.RawPayload, ContentType: callback.ContentType, + RequestID: pkgmiddleware.GetRequestIDFromContext(ctx), CorrelationID: &correlationID, + }) + if err != nil { + return err + } + result, confirmErr := h.agentPaymentConfirm.Execute(ctx, agentrechargeApp.ConfirmOnlinePaymentCommand{ + PaymentNo: callback.PaymentNo, PaymentMethod: callback.PaymentMethod, ConfigID: callback.ConfigID, + MerchantIdentity: callback.MerchantIdentity, ThirdPartyTradeNo: callback.TransactionID, + Amount: callback.Amount, PaidAt: callback.PaidAt, CorrelationID: correlationID, + }) + if confirmErr != nil { + h.completePaymentCallbackLog(ctx, log, integrationlog.Completion{ + Result: constants.IntegrationResultFailed, ProviderMessage: confirmErr.Error(), + ResponseSummary: map[string]any{"confirmed": false}, + }) + return confirmErr + } + if log.Result == constants.IntegrationResultPending { + _, err = h.integration.Complete(ctx, log.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultSuccess, ProviderCode: "SUCCESS", + ResponseSummary: map[string]any{"confirmed": true, "already_confirmed": result.AlreadyConfirmed}, + StateChanged: !result.AlreadyConfirmed, + }) + if err != nil { + return err + } + } + return nil +} + +func (h *PaymentHandler) completePaymentCallbackLog(ctx context.Context, log *model.IntegrationLog, completion integrationlog.Completion) { + if log == nil || log.Result != constants.IntegrationResultPending { + return + } + if _, err := h.integration.Complete(ctx, log.IntegrationID, completion); err != nil { + h.logger.Error("支付回调 Integration Log 终结失败", + zap.String("integration_id", log.IntegrationID), + zap.Error(err), + ) } } @@ -305,14 +397,16 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error { return errors.New(errors.CodeWechatCallbackInvalid, "支付金额校验失败") } - // 写入支付宝交易流水号 - if err := h.paymentStore.UpdatePaymentInfo(ctx, payment.ID, notification.TradeNo, nil); err != nil { - h.logger.Error("支付宝回调:写入 third_party_trade_no 失败", - zap.String("out_trade_no", outTradeNo), - zap.String("trade_no", notification.TradeNo), - zap.Error(err), - ) - // 不中断业务,继续分发(幂等业务层会处理状态) + // 新代理充值由统一确认事务原子写入交易号,旧业务保持原处理方式。 + if payment.OrderType != model.PaymentOrderTypeAgentRecharge { + if err := h.paymentStore.UpdatePaymentInfo(ctx, payment.ID, notification.TradeNo, nil); err != nil { + h.logger.Error("支付宝回调:写入 third_party_trade_no 失败", + zap.String("out_trade_no", outTradeNo), + zap.String("trade_no", notification.TradeNo), + zap.Error(err), + ) + // 不中断存量业务,继续由原幂等业务层处理状态。 + } } // 按支付单 order_type 分发业务 @@ -353,6 +447,28 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error { zap.String("order_type", payment.OrderType), ) + case model.PaymentOrderTypeAgentRecharge: + paidAt, parseErr := time.ParseInLocation("2006-01-02 15:04:05", notification.GmtPayment, time.Local) + if parseErr != nil { + h.logger.Error("支付宝回调:付款时间格式无效", + zap.String("out_trade_no", outTradeNo), + zap.Error(parseErr), + ) + return errors.New(errors.CodeWechatCallbackInvalid, "支付宝付款时间格式错误") + } + if err := h.confirmAgentRechargePayment(ctx, verifiedPaymentCallback{ + PaymentNo: outTradeNo, PaymentMethod: model.PaymentByAlipay, + TransactionID: notification.TradeNo, Amount: notifyAmountFen, ConfigID: cfg.ID, + MerchantIdentity: notification.AppId, PaidAt: paidAt, Provider: constants.IntegrationProviderAlipay, + RawPayload: c.Body(), ContentType: c.Get("Content-Type"), + }); err != nil { + h.logger.Error("支付宝回调:确认代理充值支付失败", + zap.String("out_trade_no", outTradeNo), + zap.Error(err), + ) + return errors.Wrap(errors.CodeInternalError, err, "处理支付宝代理充值回调失败") + } + default: h.logger.Error("支付宝回调:未知支付记录类型", zap.String("out_trade_no", outTradeNo), @@ -452,7 +568,11 @@ func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error { orderNo := notify.MchntOrderNo // OrderAmt 为字符串格式的分,解析失败则降级为 0 orderAmt, _ := strconv.ParseInt(notify.OrderAmt, 10, 64) - if handled, err := h.dispatchPaymentRecordCallback(ctx, orderNo, "fuiou", notify.TransactionId, orderAmt); err != nil { + if handled, err := h.dispatchPaymentRecordCallback(ctx, verifiedPaymentCallback{ + PaymentNo: orderNo, PaymentMethod: "fuiou", TransactionID: notify.TransactionId, Amount: orderAmt, + ConfigID: cfg.ID, MerchantIdentity: cfg.FyMchntCd, Provider: model.ProviderTypeFuiou, + RawPayload: body, ContentType: c.Get("Content-Type"), + }); err != nil { return c.Send(fuiou.BuildNotifyFailResponse(err.Error())) } else if handled { return c.Send(fuiou.BuildNotifySuccessResponse()) diff --git a/internal/infrastructure/payment/agent_recharge_consumer.go b/internal/infrastructure/payment/agent_recharge_consumer.go new file mode 100644 index 0000000..ed44199 --- /dev/null +++ b/internal/infrastructure/payment/agent_recharge_consumer.go @@ -0,0 +1,110 @@ +package payment + +import ( + "context" + "time" + + "github.com/bytedance/sonic" + "gorm.io/gorm" + "gorm.io/gorm/clause" + + agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" + walletapp "github.com/break/junhong_cmp_fiber/internal/application/wallet" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// AgentRechargePaymentConsumer 将已确认收款的代理在线充值幂等入账主钱包。 +type AgentRechargePaymentConsumer struct { + db *gorm.DB + posting *walletapp.PostingService +} + +// NewAgentRechargePaymentConsumer 创建代理在线充值入账消费者。 +func NewAgentRechargePaymentConsumer(db *gorm.DB, posting *walletapp.PostingService) *AgentRechargePaymentConsumer { + return &AgentRechargePaymentConsumer{db: db, posting: posting} +} + +// Consume 校验支付与充值权威事实后,在独立事务中完成唯一入账和充值终态。 +func (c *AgentRechargePaymentConsumer) Consume(ctx context.Context, envelope outbox.DeliveryEnvelope) error { + if c == nil || c.db == nil || c.posting == nil { + return errors.New(errors.CodeInternalError, "代理在线充值入账消费者未配置") + } + if envelope.EventType != constants.OutboxEventTypeAgentRechargePaymentConfirmed || + envelope.PayloadVersion != constants.AgentRechargePaymentConfirmedPayloadVersionV1 { + return errors.New(errors.CodeInvalidParam, "代理充值支付确认事件类型或版本不受支持") + } + var event agentrecharge.PaymentConfirmedEvent + if err := sonic.Unmarshal(envelope.Payload, &event); err != nil { + return errors.Wrap(errors.CodeInvalidParam, err, "代理充值支付确认事件载荷无法解析") + } + if event.EventID == "" || event.EventID != envelope.EventID || event.RechargeID == 0 || event.PaymentID == 0 || + event.ShopID == 0 || event.WalletID == 0 || event.Amount <= 0 || event.ThirdPartyTradeNo == "" { + return errors.New(errors.CodeInvalidParam, "代理充值支付确认事件载荷不完整") + } + return c.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + recharge, _, err := lockCreditingFacts(ctx, tx, event) + if err != nil { + return err + } + if recharge.Status != constants.RechargeStatusPaid && recharge.Status != constants.RechargeStatusCompleted { + return errors.New(errors.CodeInvalidStatus, "代理在线充值当前状态不可入账") + } + if _, err := c.posting.PostInTx(ctx, tx, walletapp.PostingCommand{ + ShopID: recharge.ShopID, WalletID: recharge.AgentWalletID, Amount: recharge.Amount, + ReferenceType: constants.ReferenceTypeTopup, ReferenceID: recharge.ID, + TransactionType: constants.AgentTransactionTypeRecharge, + UserID: recharge.UserID, Creator: recharge.UserID, Remark: "代理在线扫码充值", + RequestID: envelope.RequestID, CorrelationID: envelope.CorrelationID, + }); err != nil { + return err + } + if recharge.Status == constants.RechargeStatusCompleted { + return nil + } + completedAt := time.Now().UTC() + update := tx.WithContext(ctx).Model(&model.AgentRechargeRecord{}). + Where("id = ? AND status = ?", recharge.ID, constants.RechargeStatusPaid). + Updates(map[string]any{"status": constants.RechargeStatusCompleted, "completed_at": completedAt}) + if update.Error != nil { + return errors.Wrap(errors.CodeDatabaseError, update.Error, "完成代理在线充值单失败") + } + if update.RowsAffected != 1 { + return errors.New(errors.CodeConflict, "代理在线充值状态已变化") + } + return nil + }) +} + +func lockCreditingFacts(ctx context.Context, tx *gorm.DB, event agentrecharge.PaymentConfirmedEvent) (*model.AgentRechargeRecord, *model.Payment, error) { + var recharge model.AgentRechargeRecord + if err := tx.WithContext(ctx).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ?", event.RechargeID).First(&recharge).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil, errors.New(errors.CodeNotFound, "代理在线充值单不存在") + } + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "锁定代理在线充值单失败") + } + var payment model.Payment + if err := tx.WithContext(ctx).Where("id = ?", event.PaymentID).First(&payment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil, errors.New(errors.CodeConflict, "代理在线充值支付单不存在") + } + return nil, nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理在线充值支付单失败") + } + if recharge.ID != event.RechargeID || recharge.RechargeNo != event.RechargeNo || recharge.ShopID != event.ShopID || + recharge.AgentWalletID != event.WalletID || recharge.UserID != event.UserID || recharge.Amount != event.Amount || + recharge.PaymentTransactionID == nil || *recharge.PaymentTransactionID != event.ThirdPartyTradeNo || + (recharge.PaymentMethod != constants.RechargeMethodWechat && recharge.PaymentMethod != constants.RechargeMethodAlipay) { + return nil, nil, errors.New(errors.CodeConflict, "代理充值事件与充值权威事实不一致") + } + if payment.OrderType != model.PaymentOrderTypeAgentRecharge || payment.OrderID != recharge.ID || + payment.PaymentNo != event.PaymentNo || payment.PaymentMethod != event.PaymentMethod || payment.Amount != event.Amount || + payment.Status != model.PaymentRecordStatusPaid || payment.ThirdPartyTradeNo != event.ThirdPartyTradeNo { + return nil, nil, errors.New(errors.CodeConflict, "代理充值事件与支付权威事实不一致") + } + return &recharge, &payment, nil +} + +var _ outbox.EventConsumer = (*AgentRechargePaymentConsumer)(nil) diff --git a/internal/infrastructure/payment/agent_recharge_event.go b/internal/infrastructure/payment/agent_recharge_event.go new file mode 100644 index 0000000..fb921f9 --- /dev/null +++ b/internal/infrastructure/payment/agent_recharge_event.go @@ -0,0 +1,38 @@ +package payment + +import ( + "context" + "strconv" + + "gorm.io/gorm" + + agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/messaging/outbox" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// AgentRechargePaymentEventWriter 将支付确认事实写入公共 Outbox。 +type AgentRechargePaymentEventWriter struct { + outbox *outbox.Repository +} + +// NewAgentRechargePaymentEventWriter 创建代理充值支付确认事件 Writer。 +func NewAgentRechargePaymentEventWriter(repository *outbox.Repository) *AgentRechargePaymentEventWriter { + return &AgentRechargePaymentEventWriter{outbox: repository} +} + +// Append 在支付确认事务内追加稳定的代理充值入账事件。 +func (w *AgentRechargePaymentEventWriter) Append(ctx context.Context, tx *gorm.DB, event agentrecharge.PaymentConfirmedEvent) error { + if w == nil || w.outbox == nil { + return errors.New(errors.CodeInternalError, "代理充值支付确认 Outbox Writer 未配置") + } + _, err := w.outbox.Append(ctx, tx, outbox.Envelope{ + EventID: event.EventID, EventType: constants.OutboxEventTypeAgentRechargePaymentConfirmed, + PayloadVersion: constants.AgentRechargePaymentConfirmedPayloadVersionV1, + AggregateType: "agent_recharge", AggregateID: strconv.FormatUint(uint64(event.RechargeID), 10), + ResourceType: "payment", ResourceID: strconv.FormatUint(uint64(event.PaymentID), 10), + BusinessKey: event.EventID, RequestID: event.RequestID, CorrelationID: event.CorrelationID, Payload: event, + }) + return err +} diff --git a/internal/infrastructure/payment/agent_recharge_recovery_task.go b/internal/infrastructure/payment/agent_recharge_recovery_task.go new file mode 100644 index 0000000..88b8d1a --- /dev/null +++ b/internal/infrastructure/payment/agent_recharge_recovery_task.go @@ -0,0 +1,29 @@ +package payment + +import ( + "context" + + "github.com/hibiken/asynq" + + agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// AgentRechargeRecoveryTaskHandler 执行代理在线充值支付恢复任务。 +type AgentRechargeRecoveryTaskHandler struct { + service *agentrecharge.RecoverOnlinePaymentService +} + +// NewAgentRechargeRecoveryTaskHandler 创建代理在线充值支付恢复任务 Handler。 +func NewAgentRechargeRecoveryTaskHandler(service *agentrecharge.RecoverOnlinePaymentService) *AgentRechargeRecoveryTaskHandler { + return &AgentRechargeRecoveryTaskHandler{service: service} +} + +// Handle 扫描长期待支付记录并复用原支付单号收敛渠道状态。 +func (h *AgentRechargeRecoveryTaskHandler) Handle(ctx context.Context, _ *asynq.Task) error { + if h == nil || h.service == nil { + return errors.New(errors.CodeServiceUnavailable, "代理在线充值支付恢复任务未配置") + } + _, err := h.service.ProcessBatch(ctx) + return err +} diff --git a/internal/infrastructure/payment/alipay_precreate.go b/internal/infrastructure/payment/alipay_precreate.go new file mode 100644 index 0000000..f3daa65 --- /dev/null +++ b/internal/infrastructure/payment/alipay_precreate.go @@ -0,0 +1,178 @@ +package payment + +import ( + "context" + "strings" + "time" + + sdkalipay "github.com/smartwalle/alipay/v3" + + agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/internal/model" + alipaypkg "github.com/break/junhong_cmp_fiber/pkg/alipay" + "github.com/break/junhong_cmp_fiber/pkg/constants" + apperrors "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// AlipayPreCreateAdapter 使用现有 smartwalle/alipay 实现当面付预下单与查单。 +type AlipayPreCreateAdapter struct { + integration *integrationlog.Repository +} + +// NewAlipayPreCreateAdapter 创建支付宝当面付适配器。 +func NewAlipayPreCreateAdapter(integration *integrationlog.Repository) *AlipayPreCreateAdapter { + return &AlipayPreCreateAdapter{integration: integration} +} + +// Available 判断配置是否完整支持支付宝预下单、验签与查单。 +func (a *AlipayPreCreateAdapter) Available(config *model.WechatConfig) bool { + return alipayConfigComplete(config, true) +} + +// PreCreate 创建支付宝当面付扫码订单。 +func (a *AlipayPreCreateAdapter) PreCreate(ctx context.Context, request agentrecharge.OnlinePaymentRequest) (agentrecharge.OnlinePaymentResult, error) { + client, err := a.client(request.Config) + if err != nil { + return agentrecharge.OnlinePaymentResult{}, err + } + attempt, err := a.startAttempt(ctx, request.PaymentNo, constants.IntegrationOperationPaymentPreCreate, request.Config.ID, request.Amount) + if err != nil { + return agentrecharge.OnlinePaymentResult{}, err + } + startedAt := time.Now() + response, callErr := client.TradePreCreate(ctx, sdkalipay.TradePreCreate{Trade: sdkalipay.Trade{ + NotifyURL: request.Config.AliNotifyURL, Subject: request.Description, OutTradeNo: request.PaymentNo, + TotalAmount: alipaypkg.FenToYuan(request.Amount), ProductCode: "FACE_TO_FACE_PAYMENT", + TimeExpire: request.ExpireAt.Format("2006-01-02 15:04:05"), + }}) + if callErr != nil { + return agentrecharge.OnlinePaymentResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr) + } + if response == nil || response.IsFailure() || strings.TrimSpace(response.QRCode) == "" { + providerCode, providerMessage := "empty_qr_code", "支付宝预下单未返回付款内容" + responseSummary := map[string]any{"success": false} + if response != nil && response.IsFailure() { + providerCode, providerMessage = string(response.Code), response.SubMsg + if response.SubCode != "" { + providerCode += ":" + response.SubCode + responseSummary["sub_code"] = response.SubCode + } + } + _, completeErr := a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultFailed, ProviderCode: providerCode, ProviderMessage: providerMessage, + ResponseSummary: responseSummary, DurationMS: time.Since(startedAt).Milliseconds(), + }) + if completeErr != nil { + return agentrecharge.OnlinePaymentResult{}, completeErr + } + return agentrecharge.OnlinePaymentResult{}, apperrors.New(apperrors.CodeServiceUnavailable, "支付宝预下单失败") + } + if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultSuccess, ProviderCode: string(response.Code), + ResponseSummary: map[string]any{"success": true}, DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: true, + }); err != nil { + return agentrecharge.OnlinePaymentResult{}, err + } + return agentrecharge.OnlinePaymentResult{QRContent: response.QRCode}, nil +} + +// Query 查询支付宝当面付订单状态。 +func (a *AlipayPreCreateAdapter) Query(ctx context.Context, paymentNo string, config *model.WechatConfig) (agentrecharge.OnlinePaymentQueryResult, error) { + client, err := a.queryClient(config) + if err != nil { + return agentrecharge.OnlinePaymentQueryResult{}, err + } + attempt, err := a.startAttempt(ctx, paymentNo, constants.IntegrationOperationPaymentQuery, config.ID, 0) + if err != nil { + return agentrecharge.OnlinePaymentQueryResult{}, err + } + startedAt := time.Now() + response, callErr := client.TradeQuery(ctx, sdkalipay.TradeQuery{OutTradeNo: paymentNo}) + if callErr != nil { + return agentrecharge.OnlinePaymentQueryResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr) + } + if response == nil || response.IsFailure() { + providerCode, providerMessage := "empty_response", "支付宝查单未返回有效响应" + if response != nil { + providerCode, providerMessage = string(response.Code), response.SubMsg + } + _, completeErr := a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultFailed, ProviderCode: providerCode, ProviderMessage: providerMessage, + ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(), + }) + if completeErr != nil { + return agentrecharge.OnlinePaymentQueryResult{}, completeErr + } + return agentrecharge.OnlinePaymentQueryResult{}, apperrors.New(apperrors.CodeServiceUnavailable, "支付宝查单失败") + } + result := agentrecharge.OnlinePaymentQueryResult{ + State: mapAlipayTradeState(response.TradeStatus), ThirdPartyTradeNo: response.TradeNo, + } + result.Amount, _ = alipaypkg.YuanToFen(response.TotalAmount) + if paidAt, parseErr := time.ParseInLocation("2006-01-02 15:04:05", response.SendPayDate, time.Local); parseErr == nil { + result.PaidAt = &paidAt + } + if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultSuccess, ProviderCode: string(response.TradeStatus), + ResponseSummary: map[string]any{"state": result.State, "has_trade_no": result.ThirdPartyTradeNo != ""}, + DurationMS: time.Since(startedAt).Milliseconds(), + }); err != nil { + return agentrecharge.OnlinePaymentQueryResult{}, err + } + return result, nil +} + +func (a *AlipayPreCreateAdapter) client(config *model.WechatConfig) (*sdkalipay.Client, error) { + if !a.Available(config) { + return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "支付宝扫码支付配置不可用") + } + return alipaypkg.NewClientFromConfig(config) +} + +func (a *AlipayPreCreateAdapter) queryClient(config *model.WechatConfig) (*sdkalipay.Client, error) { + if !alipayConfigComplete(config, false) { + return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "支付宝查单配置不可用") + } + return alipaypkg.NewClientFromConfig(config) +} + +func alipayConfigComplete(config *model.WechatConfig, requireActive bool) bool { + return config != nil && (!requireActive || config.IsActive) && config.AliAppID != "" && config.AliPrivateKey != "" && + config.AliPublicKey != "" && config.AliNotifyURL != "" +} + +func (a *AlipayPreCreateAdapter) startAttempt(ctx context.Context, paymentNo, operation string, configID uint, amount int64) (*model.IntegrationLog, error) { + resourceID := paymentNo + return a.integration.Start(ctx, integrationlog.Attempt{ + Provider: constants.IntegrationProviderAlipay, Direction: constants.IntegrationDirectionOutbound, + Operation: operation, ResourceType: constants.IntegrationResourceTypeAgentRechargePayment, + ResourceID: &resourceID, ExternalID: &resourceID, + RequestSummary: map[string]any{"payment_config_id": configID, "amount": amount}, + }) +} + +func (a *AlipayPreCreateAdapter) completeUnknown(ctx context.Context, integrationID string, startedAt time.Time, cause error) error { + _, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{ + Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: cause.Error(), + ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(), + RecoveryStrategy: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单", + }) + if err != nil { + return err + } + return apperrors.Wrap(apperrors.CodeTimeout, cause, "支付宝支付请求结果未知") +} + +func mapAlipayTradeState(state sdkalipay.TradeStatus) string { + switch state { + case sdkalipay.TradeStatusSuccess, sdkalipay.TradeStatusFinished: + return agentrecharge.OnlinePaymentStatePaid + case sdkalipay.TradeStatusClosed: + return agentrecharge.OnlinePaymentStateClosed + case sdkalipay.TradeStatusWaitBuyerPay: + return agentrecharge.OnlinePaymentStatePending + default: + return agentrecharge.OnlinePaymentStateUnknown + } +} diff --git a/internal/infrastructure/payment/wechat_native.go b/internal/infrastructure/payment/wechat_native.go new file mode 100644 index 0000000..caf5c5f --- /dev/null +++ b/internal/infrastructure/payment/wechat_native.go @@ -0,0 +1,171 @@ +// Package payment 提供代理在线充值使用的支付渠道薄适配器。 +package payment + +import ( + "context" + "strings" + "time" + + "github.com/ArtisanCloud/PowerWeChat/v3/src/kernel" + sdkpayment "github.com/ArtisanCloud/PowerWeChat/v3/src/payment" + orderRequest "github.com/ArtisanCloud/PowerWeChat/v3/src/payment/order/request" + "go.uber.org/zap" + + agentrecharge "github.com/break/junhong_cmp_fiber/internal/application/agentrecharge" + "github.com/break/junhong_cmp_fiber/internal/infrastructure/integrationlog" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + apperrors "github.com/break/junhong_cmp_fiber/pkg/errors" + wechatpay "github.com/break/junhong_cmp_fiber/pkg/wechat" +) + +// WechatNativeAdapter 使用现有 PowerWeChat 实现 Native 预下单与查单。 +type WechatNativeAdapter struct { + cache kernel.CacheInterface + integration *integrationlog.Repository + logger *zap.Logger +} + +// NewWechatNativeAdapter 创建微信 Native 支付适配器。 +func NewWechatNativeAdapter(cache kernel.CacheInterface, integration *integrationlog.Repository, logger *zap.Logger) *WechatNativeAdapter { + return &WechatNativeAdapter{cache: cache, integration: integration, logger: logger} +} + +// Available 判断配置是否完整支持微信 Native 预下单、验签与查单。 +func (a *WechatNativeAdapter) Available(config *model.WechatConfig) bool { + return wechatConfigComplete(config, true) +} + +// PreCreate 创建微信 Native 扫码支付单。 +func (a *WechatNativeAdapter) PreCreate(ctx context.Context, request agentrecharge.OnlinePaymentRequest) (agentrecharge.OnlinePaymentResult, error) { + app, err := a.paymentApp(request.Config) + if err != nil { + return agentrecharge.OnlinePaymentResult{}, err + } + attempt, err := a.startAttempt(ctx, request.PaymentNo, constants.IntegrationOperationPaymentPreCreate, request.Config.ID, request.Amount) + if err != nil { + return agentrecharge.OnlinePaymentResult{}, err + } + startedAt := time.Now() + response, callErr := app.Order.TransactionNative(ctx, &orderRequest.RequestNativePrepay{ + Description: request.Description, + OutTradeNo: request.PaymentNo, + TimeExpire: request.ExpireAt.Format(time.RFC3339), + Amount: &orderRequest.NativeAmount{Total: int(request.Amount), Currency: "CNY"}, + }) + if callErr != nil { + return agentrecharge.OnlinePaymentResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr) + } + if response == nil || strings.TrimSpace(response.CodeURL) == "" { + err = apperrors.New(apperrors.CodeWechatPayFailed, "微信 Native 预下单未返回付款内容") + _, completeErr := a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultFailed, ProviderCode: "empty_code_url", ProviderMessage: err.Error(), + ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(), + }) + if completeErr != nil { + return agentrecharge.OnlinePaymentResult{}, completeErr + } + return agentrecharge.OnlinePaymentResult{}, err + } + if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultSuccess, ResponseSummary: map[string]any{"success": true}, + DurationMS: time.Since(startedAt).Milliseconds(), StateChanged: true, + }); err != nil { + return agentrecharge.OnlinePaymentResult{}, err + } + return agentrecharge.OnlinePaymentResult{QRContent: response.CodeURL}, nil +} + +// Query 查询微信 Native 支付单状态。 +func (a *WechatNativeAdapter) Query(ctx context.Context, paymentNo string, config *model.WechatConfig) (agentrecharge.OnlinePaymentQueryResult, error) { + app, err := a.queryPaymentApp(config) + if err != nil { + return agentrecharge.OnlinePaymentQueryResult{}, err + } + attempt, err := a.startAttempt(ctx, paymentNo, constants.IntegrationOperationPaymentQuery, config.ID, 0) + if err != nil { + return agentrecharge.OnlinePaymentQueryResult{}, err + } + startedAt := time.Now() + info, callErr := wechatpay.NewPaymentService(app, a.logger).QueryOrder(ctx, paymentNo) + if callErr != nil { + return agentrecharge.OnlinePaymentQueryResult{}, a.completeUnknown(ctx, attempt.IntegrationID, startedAt, callErr) + } + result := agentrecharge.OnlinePaymentQueryResult{ + State: mapWechatTradeState(info.TradeState), ThirdPartyTradeNo: info.TransactionID, Amount: info.TotalAmount, + } + if paidAt, parseErr := time.Parse(time.RFC3339, info.SuccessTime); parseErr == nil { + result.PaidAt = &paidAt + } + if _, err = a.integration.Complete(ctx, attempt.IntegrationID, integrationlog.Completion{ + Result: constants.IntegrationResultSuccess, ProviderCode: info.TradeState, + ResponseSummary: map[string]any{"state": result.State, "has_trade_no": result.ThirdPartyTradeNo != ""}, + DurationMS: time.Since(startedAt).Milliseconds(), + }); err != nil { + return agentrecharge.OnlinePaymentQueryResult{}, err + } + return result, nil +} + +func (a *WechatNativeAdapter) paymentApp(config *model.WechatConfig) (*sdkpayment.Payment, error) { + if !a.Available(config) { + return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "微信 Native 支付配置不可用") + } + app, err := wechatpay.NewPaymentAppFromConfig(config, config.OaAppID, a.cache, a.logger) + if err != nil { + return nil, apperrors.Wrap(apperrors.CodeNoPaymentConfig, err, "微信 Native 支付配置不可用") + } + return app, nil +} + +func (a *WechatNativeAdapter) queryPaymentApp(config *model.WechatConfig) (*sdkpayment.Payment, error) { + if !wechatConfigComplete(config, false) { + return nil, apperrors.New(apperrors.CodeNoPaymentConfig, "微信查单配置不可用") + } + app, err := wechatpay.NewPaymentAppFromConfig(config, config.OaAppID, a.cache, a.logger) + if err != nil { + return nil, apperrors.Wrap(apperrors.CodeNoPaymentConfig, err, "微信查单配置不可用") + } + return app, nil +} + +func wechatConfigComplete(config *model.WechatConfig, requireActive bool) bool { + return config != nil && (!requireActive || config.IsActive) && config.ProviderType == model.ProviderTypeWechat && + config.OaAppID != "" && config.WxMchID != "" && config.WxAPIV3Key != "" && + config.WxCertContent != "" && config.WxKeyContent != "" && config.WxSerialNo != "" && config.WxNotifyURL != "" +} + +func (a *WechatNativeAdapter) startAttempt(ctx context.Context, paymentNo, operation string, configID uint, amount int64) (*model.IntegrationLog, error) { + resourceID := paymentNo + return a.integration.Start(ctx, integrationlog.Attempt{ + Provider: constants.IntegrationProviderWechatPay, Direction: constants.IntegrationDirectionOutbound, + Operation: operation, ResourceType: constants.IntegrationResourceTypeAgentRechargePayment, + ResourceID: &resourceID, ExternalID: &resourceID, + RequestSummary: map[string]any{"payment_config_id": configID, "amount": amount}, + }) +} + +func (a *WechatNativeAdapter) completeUnknown(ctx context.Context, integrationID string, startedAt time.Time, cause error) error { + _, err := a.integration.Complete(ctx, integrationID, integrationlog.Completion{ + Result: constants.IntegrationResultUnknown, ProviderCode: "request_unknown", ProviderMessage: cause.Error(), + ResponseSummary: map[string]any{"success": false}, DurationMS: time.Since(startedAt).Milliseconds(), + RecoveryStrategy: "使用原支付单号主动查单,确认不存在或关闭后才允许关闭本地支付单", + }) + if err != nil { + return err + } + return apperrors.Wrap(apperrors.CodeTimeout, cause, "微信支付请求结果未知") +} + +func mapWechatTradeState(state string) string { + switch state { + case "SUCCESS": + return agentrecharge.OnlinePaymentStatePaid + case "CLOSED", "REVOKED", "PAYERROR": + return agentrecharge.OnlinePaymentStateClosed + case "NOTPAY", "USERPAYING": + return agentrecharge.OnlinePaymentStatePending + default: + return agentrecharge.OnlinePaymentStateUnknown + } +} diff --git a/internal/model/agent_wallet.go b/internal/model/agent_wallet.go index 35d8d9e..b0916c7 100644 --- a/internal/model/agent_wallet.go +++ b/internal/model/agent_wallet.go @@ -89,6 +89,8 @@ type AgentRechargeRecord struct { Remark string `gorm:"column:remark;type:text;comment:运营备注(创建时填写,不可修改)" json:"remark,omitempty"` RejectionReason *string `gorm:"column:rejection_reason;type:varchar(500);comment:驳回原因,仅驳回时写入" json:"rejection_reason,omitempty"` ApprovalInstanceID *uint `gorm:"column:approval_instance_id;comment:员工线下代充值关联的唯一通用审批实例ID" json:"approval_instance_id,omitempty"` + RequestID *string `gorm:"column:request_id;type:varchar(64);comment:提交账号提供的在线充值幂等请求标识" json:"request_id,omitempty"` + RequestFingerprint *string `gorm:"column:request_fingerprint;type:varchar(64);comment:在线充值请求业务字段指纹" json:"-"` PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"` CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"` ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"` diff --git a/internal/model/dto/agent_recharge_dto.go b/internal/model/dto/agent_recharge_dto.go index 748f3ac..b25f9da 100644 --- a/internal/model/dto/agent_recharge_dto.go +++ b/internal/model/dto/agent_recharge_dto.go @@ -2,13 +2,49 @@ package dto // CreateAgentRechargeRequest 创建代理充值请求 type CreateAgentRechargeRequest struct { - ShopID uint `json:"shop_id" validate:"required" required:"true" description:"目标店铺ID,代理只能填自己店铺"` + ShopID *uint `json:"shop_id,omitempty" description:"目标店铺ID,仅平台线下代充可填;代理在线充值禁止传入"` Amount int64 `json:"amount" validate:"required,min=1,max=100000000" required:"true" minimum:"1" maximum:"100000000" description:"充值金额(分),范围1分~100万元"` - PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat offline" required:"true" description:"支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用)"` + PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat alipay offline" required:"true" description:"支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账仅平台可用)"` + RequestID string `json:"request_id,omitempty" validate:"omitempty,max=64" maxLength:"64" description:"在线充值幂等请求标识,微信或支付宝支付时必填"` PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表(payment_method=offline 时至少1个,最多5个,微信支付时忽略)"` Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"` } +// AgentRechargeOnlineResponse 代理在线扫码充值创建响应。 +type AgentRechargeOnlineResponse struct { + RechargeID uint `json:"recharge_id" description:"充值记录ID"` + RechargeNo string `json:"recharge_no" description:"充值单号(ARCH前缀)"` + PaymentNo string `json:"payment_no" description:"支付单号(PAY前缀)"` + PaymentMethod string `json:"payment_method" description:"支付方式 (wechat:微信, alipay:支付宝)"` + RechargeSource string `json:"recharge_source" description:"充值来源 (agent_online:代理在线自充)"` + RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"` + Amount int64 `json:"amount" description:"在线充值金额(分),范围10000分~100000000分"` + QRContent string `json:"qr_content" description:"支付渠道原始扫码付款内容,由前端渲染二维码"` + Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"` + StatusName string `json:"status_name" description:"状态名称(中文)"` +} + +// AgentRechargePaymentMethodsResponse 代理在线充值可用支付方式响应。 +type AgentRechargePaymentMethodsResponse struct { + Methods []string `json:"methods" description:"可用支付方式,固定顺序 (wechat:微信, alipay:支付宝)"` + MinAmount int64 `json:"min_amount" description:"在线充值最小金额(分),固定为10000"` + MaxAmount int64 `json:"max_amount" description:"在线充值最大金额(分),固定为100000000"` +} + +// AgentRechargePaymentStatusResponse 代理充值本地支付与到账状态响应。 +type AgentRechargePaymentStatusResponse struct { + RechargeID uint `json:"recharge_id" description:"充值记录ID"` + RechargeNo string `json:"recharge_no" description:"充值单号(ARCH前缀)"` + RechargeSource string `json:"recharge_source" description:"充值来源 (platform_offline:平台线下代充, agent_online:代理在线自充)"` + RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"` + Status int `json:"status" description:"充值状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"` + StatusName string `json:"status_name" description:"充值状态名称(中文)"` + PaymentStatus int `json:"payment_status" description:"支付状态 (0:待支付, 1:已支付, 2:已失败, 3:已退款)"` + PaymentStatusName string `json:"payment_status_name" description:"支付状态名称(中文)"` + PaidAt *string `json:"paid_at" description:"第三方支付确认时间"` + CompletedAt *string `json:"completed_at" description:"钱包入账完成时间"` +} + // AgentOfflinePayRequest 代理线下充值确认请求 type AgentOfflinePayRequest struct { OperationPassword string `json:"operation_password" validate:"required" required:"true" description:"操作密码"` @@ -28,7 +64,9 @@ type AgentRechargeResponse struct { ShopName string `json:"shop_name" description:"店铺名称"` AgentWalletID uint `json:"agent_wallet_id" description:"代理钱包ID"` Amount int64 `json:"amount" description:"充值金额(分)"` - PaymentMethod string `json:"payment_method" description:"支付方式 (wechat:微信在线支付, offline:线下转账)"` + PaymentMethod string `json:"payment_method" description:"支付方式 (wechat:微信在线支付, alipay:支付宝在线支付, offline:线下转账)"` + RechargeSource string `json:"recharge_source" description:"充值来源 (platform_offline:平台线下代充, agent_online:代理在线自充)"` + RechargeSourceName string `json:"recharge_source_name" description:"充值来源名称(中文)"` PaymentChannel string `json:"payment_channel" description:"实际支付通道 (wechat_direct:微信直连, fuyou:富友, offline:线下转账)"` PaymentConfigID *uint `json:"payment_config_id" description:"关联支付配置ID,线下充值为null"` PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号"` @@ -62,12 +100,13 @@ type AgentRechargeRejectParams struct { // AgentRechargeListRequest 代理充值记录列表请求 type AgentRechargeListRequest struct { - Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"` - PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认20,最大100"` - ShopID *uint `json:"shop_id" query:"shop_id" description:"按店铺ID过滤"` - Status *int `json:"status" query:"status" description:"按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"` - StartDate string `json:"start_date" query:"start_date" description:"创建时间起始日期(YYYY-MM-DD)"` - EndDate string `json:"end_date" query:"end_date" description:"创建时间截止日期(YYYY-MM-DD)"` + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认20,最大100"` + ShopID *uint `json:"shop_id" query:"shop_id" description:"按店铺ID过滤"` + Status *int `json:"status" query:"status" description:"按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"` + RechargeSource string `json:"recharge_source" query:"recharge_source" validate:"omitempty,oneof=platform_offline agent_online" description:"按充值来源过滤 (platform_offline:平台线下代充, agent_online:代理在线自充)"` + StartDate string `json:"start_date" query:"start_date" description:"创建时间起始日期(YYYY-MM-DD)"` + EndDate string `json:"end_date" query:"end_date" description:"创建时间截止日期(YYYY-MM-DD)"` } // AgentRechargeListResponse 代理充值记录列表响应 diff --git a/internal/model/payment.go b/internal/model/payment.go index de4eb16..aa88ad8 100644 --- a/internal/model/payment.go +++ b/internal/model/payment.go @@ -3,6 +3,8 @@ package model import ( "gorm.io/gorm" "time" + + "github.com/break/junhong_cmp_fiber/pkg/constants" ) type Payment struct { @@ -11,11 +13,13 @@ type Payment struct { OrderID uint `gorm:"column:order_id;not null" json:"order_id"` OrderType string `gorm:"column:order_type;type:varchar(30);not null" json:"order_type"` PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null" json:"payment_method"` + MerchantIdentity string `gorm:"column:merchant_identity;type:varchar(100)" json:"-"` Amount int64 `gorm:"column:amount;type:bigint;not null" json:"amount"` Status int `gorm:"column:status;type:smallint;not null;default:0" json:"status"` ThirdPartyTradeNo string `gorm:"column:third_party_trade_no;type:varchar(100)" json:"third_party_trade_no,omitempty"` PaymentConfigID *uint `gorm:"column:payment_config_id" json:"payment_config_id,omitempty"` PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500)" json:"payment_voucher_key,omitempty"` + QRContent string `gorm:"column:qr_content;type:text" json:"-"` PaidAt *time.Time `gorm:"column:paid_at" json:"paid_at,omitempty"` ExpireAt *time.Time `gorm:"column:expire_at" json:"expire_at,omitempty"` CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"` @@ -28,15 +32,16 @@ func (Payment) TableName() string { } const ( - PaymentRecordStatusPending = 0 // 待支付 - PaymentRecordStatusPaid = 1 // 已支付 - PaymentRecordStatusFailed = 2 // 已失败 - PaymentRecordStatusRefunded = 3 // 已退款 + PaymentRecordStatusPending = constants.PaymentRecordStatusPending // 待支付 + PaymentRecordStatusPaid = constants.PaymentRecordStatusPaid // 已支付 + PaymentRecordStatusFailed = constants.PaymentRecordStatusFailed // 已失败 + PaymentRecordStatusRefunded = constants.PaymentRecordStatusRefunded // 已退款 ) const ( - PaymentOrderTypePackage = "package_order" - PaymentOrderTypeRecharge = "recharge_order" + PaymentOrderTypePackage = "package_order" // 套餐订单 + PaymentOrderTypeRecharge = "recharge_order" // 客户充值订单 + PaymentOrderTypeAgentRecharge = "agent_recharge" // 代理充值订单 ) const ( diff --git a/internal/query/agentrecharge/payment_status.go b/internal/query/agentrecharge/payment_status.go new file mode 100644 index 0000000..1d1eec3 --- /dev/null +++ b/internal/query/agentrecharge/payment_status.go @@ -0,0 +1,62 @@ +// Package agentrecharge 提供代理充值本地状态只读投影。 +package agentrecharge + +import ( + "context" + + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" +) + +// PaymentStatusQuery 查询代理充值本地支付与到账事实。 +type PaymentStatusQuery struct { + db *gorm.DB +} + +// NewPaymentStatusQuery 创建代理充值支付状态 Query。 +func NewPaymentStatusQuery(db *gorm.DB) *PaymentStatusQuery { + return &PaymentStatusQuery{db: db} +} + +// Get 读取当前数据范围内的充值单和支付单,不调用第三方渠道。 +func (q *PaymentStatusQuery) Get(ctx context.Context, rechargeID uint) (*dto.AgentRechargePaymentStatusResponse, error) { + if q == nil || q.db == nil || rechargeID == 0 { + return nil, errors.New(errors.CodeInvalidParam, "代理充值支付状态查询参数无效") + } + var recharge model.AgentRechargeRecord + if err := q.db.WithContext(ctx).Where("id = ?", rechargeID).First(&recharge).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值状态失败") + } + var payment model.Payment + if err := q.db.WithContext(ctx). + Where("order_id = ? AND order_type = ?", recharge.ID, model.PaymentOrderTypeAgentRecharge). + First(&payment).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理充值支付状态失败") + } + source, sourceName := constants.GetAgentRechargeSource(recharge.PaymentMethod) + result := &dto.AgentRechargePaymentStatusResponse{ + RechargeID: recharge.ID, RechargeNo: recharge.RechargeNo, + RechargeSource: source, RechargeSourceName: sourceName, + Status: recharge.Status, StatusName: constants.GetRechargeStatusName(recharge.Status), + PaymentStatus: payment.Status, PaymentStatusName: constants.GetPaymentRecordStatusName(payment.Status), + } + if payment.PaidAt != nil { + paidAt := payment.PaidAt.Format("2006-01-02 15:04:05") + result.PaidAt = &paidAt + } + if recharge.CompletedAt != nil { + completedAt := recharge.CompletedAt.Format("2006-01-02 15:04:05") + result.CompletedAt = &completedAt + } + return result, nil +} diff --git a/internal/routes/agent_recharge.go b/internal/routes/agent_recharge.go index f49dedb..f9d8e76 100644 --- a/internal/routes/agent_recharge.go +++ b/internal/routes/agent_recharge.go @@ -25,7 +25,7 @@ func registerAgentRechargeRoutes(router fiber.Router, handler *admin.AgentRechar Summary: "创建代理充值订单", Tags: []string{"代理预充值"}, Input: new(dto.CreateAgentRechargeRequest), - Output: new(dto.AgentRechargeResponse), + Output: new(dto.AgentRechargeOnlineResponse), Auth: true, }) @@ -37,6 +37,21 @@ func registerAgentRechargeRoutes(router fiber.Router, handler *admin.AgentRechar Auth: true, }) + Register(group, doc, groupPath, "GET", "/payment-methods", handler.PaymentMethods, RouteSpec{ + Summary: "查询代理在线充值可用支付方式", + Tags: []string{"代理预充值"}, + Output: new(dto.AgentRechargePaymentMethodsResponse), + Auth: true, + }) + + Register(group, doc, groupPath, "GET", "/:id/payment-status", handler.PaymentStatus, RouteSpec{ + Summary: "查询代理充值本地支付与到账状态", + Tags: []string{"代理预充值"}, + Input: new(dto.IDReq), + Output: new(dto.AgentRechargePaymentStatusResponse), + Auth: true, + }) + Register(group, doc, groupPath, "GET", "/:id", handler.Get, RouteSpec{ Summary: "查询代理充值订单详情", Tags: []string{"代理预充值"}, diff --git a/internal/service/agent_recharge/service.go b/internal/service/agent_recharge/service.go index 81cc82e..ed15422 100644 --- a/internal/service/agent_recharge/service.go +++ b/internal/service/agent_recharge/service.go @@ -95,20 +95,16 @@ func (s *Service) SetOfflineCreationService(service *agentrechargeapp.OfflineCre func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeRequest) (*dto.AgentRechargeResponse, error) { userID := middleware.GetUserIDFromContext(ctx) userType := middleware.GetUserTypeFromContext(ctx) - userShopID := middleware.GetShopIDFromContext(ctx) - - // 代理只能充自己店铺 - if userType == constants.UserTypeAgent && req.ShopID != userShopID { - return nil, errors.New(errors.CodeForbidden, "代理只能为自己的店铺充值") + if req.PaymentMethod != constants.RechargeMethodOffline { + return nil, errors.New(errors.CodeInvalidStatus, "在线充值必须通过代理在线创建用例处理") } - - // 线下充值仅平台可用 - if req.PaymentMethod == constants.RechargeMethodOffline && userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin { + if userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin { return nil, errors.New(errors.CodeForbidden, "线下充值仅平台管理员可操作") } - - // 线下充值必须上传支付凭证 - if req.PaymentMethod == constants.RechargeMethodOffline && len(req.PaymentVoucherKey) == 0 { + if req.ShopID == nil || *req.ShopID == 0 { + return nil, errors.New(errors.CodeInvalidParam, "线下充值必须指定目标店铺") + } + if len(req.PaymentVoucherKey) == 0 { return nil, errors.New(errors.CodeInvalidParam, "线下充值必须上传支付凭证") } @@ -117,73 +113,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques } rechargeNo := s.generateRechargeNo() - if req.PaymentMethod == constants.RechargeMethodOffline { - return s.createOffline(ctx, req, userID, userType, rechargeNo) - } - - // 查找目标店铺的主钱包 - wallet, err := s.agentWalletStore.GetMainWallet(ctx, req.ShopID) - if err != nil { - if err == gorm.ErrRecordNotFound { - return nil, errors.New(errors.CodeNotFound, "目标店铺主钱包不存在") - } - return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺主钱包失败") - } - - // 查询店铺名称 - shop, err := s.shopStore.GetByID(ctx, req.ShopID) - if err != nil { - if err == gorm.ErrRecordNotFound { - return nil, errors.New(errors.CodeNotFound, "目标店铺不存在") - } - return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺失败") - } - - // 在线支付需要查询生效的支付配置 - var paymentConfigID *uint - var paymentChannel string - if req.PaymentMethod == "wechat" { - activeConfig, cfgErr := s.wechatConfigService.GetActiveConfig(ctx) - if cfgErr != nil || activeConfig == nil { - return nil, errors.New(errors.CodeNoPaymentConfig, "当前无可用的支付配置,请联系管理员") - } - paymentConfigID = &activeConfig.ID - paymentChannel = activeConfig.ProviderType - } else { - paymentChannel = "offline" - } - - record := &model.AgentRechargeRecord{ - UserID: userID, - AgentWalletID: wallet.ID, - ShopID: req.ShopID, - RechargeNo: rechargeNo, - Amount: req.Amount, - PaymentMethod: req.PaymentMethod, - PaymentChannel: &paymentChannel, - PaymentConfigID: paymentConfigID, - PaymentVoucherKey: model.StringJSONBArray(req.PaymentVoucherKey), - Remark: req.Remark, - Status: constants.RechargeStatusPending, - ShopIDTag: wallet.ShopIDTag, - EnterpriseIDTag: wallet.EnterpriseIDTag, - } - - if err := s.agentRechargeStore.Create(ctx, record); err != nil { - return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败") - } - - s.logger.Info("创建代理充值订单成功", - zap.Uint("recharge_id", record.ID), - zap.String("recharge_no", rechargeNo), - zap.Int64("amount", req.Amount), - zap.Uint("shop_id", req.ShopID), - zap.Uint("user_id", userID), - ) - - resp := toResponse(record, shop.ShopName) - resp.SubmitterName = s.loadSubmitterNameBestEffort(ctx, record.UserID) - return resp, nil + return s.createOffline(ctx, req, userID, userType, rechargeNo) } func (s *Service) createOffline( @@ -199,7 +129,7 @@ func (s *Service) createOffline( result, err := s.offlineCreation.Execute(ctx, agentrechargeapp.CreateOfflineCommand{ SubmitterAccountID: userID, SubmitterUserType: userType, - ShopID: req.ShopID, + ShopID: *req.ShopID, RechargeNo: rechargeNo, Amount: req.Amount, PaymentVoucherKeys: req.PaymentVoucherKey, @@ -522,6 +452,12 @@ func (s *Service) List(ctx context.Context, req *dto.AgentRechargeListRequest) ( if req.Status != nil { query = query.Where("status = ?", *req.Status) } + switch req.RechargeSource { + case constants.AgentRechargeSourcePlatformOffline: + query = query.Where("payment_method NOT IN ?", []string{constants.RechargeMethodWechat, constants.RechargeMethodAlipay}) + case constants.AgentRechargeSourceAgentOnline: + query = query.Where("payment_method IN ?", []string{constants.RechargeMethodWechat, constants.RechargeMethodAlipay}) + } if req.StartDate != "" { query = query.Where("created_at >= ?", req.StartDate+" 00:00:00") } @@ -590,6 +526,7 @@ func (s *Service) generateRechargeNo() string { // toResponse 将模型转换为响应 DTO func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRechargeResponse { + rechargeSource, rechargeSourceName := constants.GetAgentRechargeSource(record.PaymentMethod) resp := &dto.AgentRechargeResponse{ ID: record.ID, RechargeNo: record.RechargeNo, @@ -598,6 +535,8 @@ func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRe AgentWalletID: record.AgentWalletID, Amount: record.Amount, PaymentMethod: record.PaymentMethod, + RechargeSource: rechargeSource, + RechargeSourceName: rechargeSourceName, PaymentVoucherKey: []string(record.PaymentVoucherKey), Remark: record.Remark, Status: record.Status, diff --git a/migrations/000197_add_agent_recharge_online_payment.down.sql b/migrations/000197_add_agent_recharge_online_payment.down.sql new file mode 100644 index 0000000..a7cb45f --- /dev/null +++ b/migrations/000197_add_agent_recharge_online_payment.down.sql @@ -0,0 +1,11 @@ +DROP INDEX IF EXISTS idx_payment_agent_recharge_pending; +DROP INDEX IF EXISTS idx_agent_recharge_online_pending; +DROP INDEX IF EXISTS uk_payment_method_trade_no; +DROP INDEX IF EXISTS uk_agent_recharge_user_request; + +ALTER TABLE tb_payment + DROP COLUMN IF EXISTS qr_content; + +ALTER TABLE tb_agent_recharge_record + DROP COLUMN IF EXISTS request_fingerprint, + DROP COLUMN IF EXISTS request_id; diff --git a/migrations/000197_add_agent_recharge_online_payment.up.sql b/migrations/000197_add_agent_recharge_online_payment.up.sql new file mode 100644 index 0000000..c4a00fa --- /dev/null +++ b/migrations/000197_add_agent_recharge_online_payment.up.sql @@ -0,0 +1,42 @@ +-- 代理在线扫码充值:请求幂等、付款内容与待处理查询索引。 +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM tb_payment + WHERE third_party_trade_no IS NOT NULL + AND third_party_trade_no <> '' + GROUP BY payment_method, third_party_trade_no + HAVING COUNT(*) > 1 + ) THEN + RAISE EXCEPTION '检测到重复的支付方式与第三方交易号,请先人工核对资金事实'; + END IF; +END +$$; + +ALTER TABLE tb_agent_recharge_record + ADD COLUMN request_id varchar(64), + ADD COLUMN request_fingerprint varchar(64); + +ALTER TABLE tb_payment + ADD COLUMN qr_content text; + +CREATE UNIQUE INDEX uk_agent_recharge_user_request + ON tb_agent_recharge_record (user_id, request_id) + WHERE request_id IS NOT NULL AND request_id <> ''; + +CREATE UNIQUE INDEX uk_payment_method_trade_no + ON tb_payment (payment_method, third_party_trade_no) + WHERE third_party_trade_no IS NOT NULL AND third_party_trade_no <> ''; + +CREATE INDEX idx_agent_recharge_online_pending + ON tb_agent_recharge_record (status, created_at, id) + WHERE deleted_at IS NULL AND payment_method IN ('wechat', 'alipay') AND status IN (1, 2, 4); + +CREATE INDEX idx_payment_agent_recharge_pending + ON tb_payment (status, created_at, id) + WHERE deleted_at IS NULL AND order_type = 'agent_recharge' AND status = 0; + +COMMENT ON COLUMN tb_agent_recharge_record.request_id IS '提交账号提供的在线充值幂等请求标识'; +COMMENT ON COLUMN tb_agent_recharge_record.request_fingerprint IS '在线充值请求业务字段指纹'; +COMMENT ON COLUMN tb_payment.qr_content IS '支付渠道返回的原始扫码付款内容,禁止写入日志或普通查询响应'; diff --git a/migrations/000198_add_payment_merchant_identity.down.sql b/migrations/000198_add_payment_merchant_identity.down.sql new file mode 100644 index 0000000..575f97c --- /dev/null +++ b/migrations/000198_add_payment_merchant_identity.down.sql @@ -0,0 +1,4 @@ +DROP INDEX IF EXISTS idx_payment_merchant_reconciliation; + +ALTER TABLE tb_payment + DROP COLUMN IF EXISTS merchant_identity; diff --git a/migrations/000198_add_payment_merchant_identity.up.sql b/migrations/000198_add_payment_merchant_identity.up.sql new file mode 100644 index 0000000..90dbd60 --- /dev/null +++ b/migrations/000198_add_payment_merchant_identity.up.sql @@ -0,0 +1,8 @@ +ALTER TABLE tb_payment + ADD COLUMN merchant_identity varchar(100); + +CREATE INDEX idx_payment_merchant_reconciliation + ON tb_payment (payment_method, merchant_identity, paid_at, id) + WHERE deleted_at IS NULL AND merchant_identity IS NOT NULL AND merchant_identity <> ''; + +COMMENT ON COLUMN tb_payment.merchant_identity IS '创建支付单时的收款身份快照:微信商户号或支付宝应用ID'; diff --git a/openspec/changes/add-agent-wallet-qr-recharge/.openspec.yaml b/openspec/changes/add-agent-wallet-qr-recharge/.openspec.yaml new file mode 100644 index 0000000..8e7013b --- /dev/null +++ b/openspec/changes/add-agent-wallet-qr-recharge/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-07-27 diff --git a/openspec/changes/add-agent-wallet-qr-recharge/design.md b/openspec/changes/add-agent-wallet-qr-recharge/design.md new file mode 100644 index 0000000..50ad6db --- /dev/null +++ b/openspec/changes/add-agent-wallet-qr-recharge/design.md @@ -0,0 +1,169 @@ +## Context + +当前 `POST /api/admin/agent-recharges` 已能创建代理充值记录,平台线下代充已经接入企业微信审批;代理在线分支仍是 Stub:只保存 `tb_agent_recharge_record` 和当前支付配置,不创建 `tb_payment`,也不向微信或支付宝预下单。现有回调对新支付单可按 `tb_payment.order_type` 分发,但代理充值仍依赖 `ARCH` 前缀回退到旧 Service,并在回调事务内同步完成钱包入账。 + +项目已有可复用基础: + +- `tb_payment`、`PaymentStore` 和按创建配置加载回调验签能力; +- 微信 v3 支付封装及查单能力、支付宝 SDK 客户端和安全回调验签; +- `internal/application/wallet.PostingService`、代理主钱包领域规则和钱包流水唯一索引; +- 公共 Outbox Relay/Consumer、Integration Log、请求指纹工具和支付配置管理; +- 代理充值状态 `1=待支付、2=已支付、3=已完成、4=已关闭、5=已退款、6=已驳回`。 + +本变更涉及金额、支付状态机、外部调用、并发幂等和可靠事件,主通道采用 `Handler → Application UseCase → Domain → Repository/Infrastructure`。充值支付状态读取采用 `Handler → Query → GORM/DTO`。未触碰的线下审批、旧列表/详情和其他支付业务不迁移。 + +利益相关方包括代理付款人、目标店铺主账号、平台财务、支付回调服务和钱包 Worker。 + +## Goals / Non-Goals + +**Goals:** + +- 让代理在桌面后台使用微信 Native 或支付宝 PreCreate 二维码为当前店铺主钱包充值。 +- 在线单笔金额限制为 `10000~100000000` 分,并由信任边界和业务用例双重校验。 +- 每次主动提交创建新业务单,同一次网络重试持久化幂等。 +- 将第三方收款事实与钱包入账事实分开提交,保证已收款资金可恢复、不重不漏。 +- 复用当前支付配置、回调、钱包入账、Outbox 和外部交互日志,不引入新依赖。 +- 为前端提供可用支付方式和轻量本地状态轮询接口。 + +**Non-Goals:** + +- 不支持手机浏览器、JSAPI、H5、WAP 或同设备拉起支付。 +- 不生成或保存二维码图片,只保存并返回渠道原始付款字符串。 +- 不允许代理选择受益店铺或替下级代理充值。 +- 不允许平台或超级管理员替代理创建在线支付码。 +- 不提供主动取消、支付方式切换、待支付单复用、在线退款或自动反向扣款。 +- 不建设多商户通道池、权重路由、自动故障切换或新的支付框架。 +- 不迁移未触碰的线下审批、客户资产充值、套餐支付或代理充值查询实现。 + +## Decisions + +### 1. 沿用代理充值资源,按账号类型分流创建用例 + +继续使用 `POST /api/admin/agent-recharges`,Handler 只解析判别请求并转为稳定 Command: + +- 代理:只允许 `wechat|alipay`,从认证上下文取得 `account_id` 与当前 `shop_id`,在线 Command 不包含可由调用方指定的 `shop_id`。 +- 平台/超级管理员:只允许现有 `offline` Command,继续调用 `OfflineCreationService`。 +- 企业账号:路由层直接拒绝。 + +在线创建进入新的 `internal/application/agentrecharge` 用例;旧 Service 可临时作为 Handler 装配门面,但不得继续保存另一套在线创建或支付确认规则。 + +选择理由:保留前端已有资源路径并限制迁移范围。备选方案是新增 `/online-recharges` 资源,但会复制权限、列表和详情语义,当前没有收益。 + +### 2. 使用现有状态区分收款与到账,不增加处理状态列 + +状态语义固定为: + +```text +1 待支付 ──支付确认──▶ 2 已支付 ──钱包入账──▶ 3 已完成 + │ + └──第三方明确关闭/预下单失败──▶ 4 已关闭 +``` + +`2=已支付` 本身表达“第三方已收款、钱包入账处理中”;失败重试状态由 Outbox/Worker 权威记录承载,API 不暴露内部错误。这样无需新增 `processing_status`,也避免两个状态字段组合出非法状态。 + +迟到的真实成功通知可以从 `1` 或受控关闭状态恢复为支付成功,但必须重新执行完整金额、配置、渠道和交易号校验;不得恢复已退款或已完成且交易号冲突的记录。 + +选择理由:现有状态已经足够表达用户需要看到的阶段。备选方案是增加独立处理状态列,但第一期只会重复 Outbox 状态并增加组合复杂度。 + +### 3. 本地事务先建单,渠道预下单后保存付款内容 + +在线创建分为三个短边界: + +1. GORM 事务内锁定幂等作用域,校验当前店铺主钱包和可用支付配置,创建充值单与 `tb_payment`; +2. 事务提交后调用对应扫码 Adapter; +3. 成功时保存 `qr_content` 并返回,失败时短事务条件关闭支付单和充值单。 + +不得在数据库事务中持有锁等待第三方网络。若进程在步骤 1 与步骤 2 之间退出,恢复任务按“本地待预下单”事实重试相同 `payment_no`;如果渠道返回订单已存在,则通过查单或渠道等价能力收敛,不能盲目创建另一支付单。 + +新增最小持久化字段: + +- `tb_agent_recharge_record.request_id`、`request_fingerprint`; +- `(user_id, request_id)` 条件唯一索引,保证同一提交账号持久化幂等; +- `tb_payment.qr_content`,仅用于同请求重放和当前所有者首次支付页面恢复; +- `tb_payment.merchant_identity`,创建时固化微信 `wx_mch_id` 或支付宝 `ali_app_id`,与 `payment_method/payment_config_id` 共同支持后续导出对账; +- `(payment_method, third_party_trade_no)` 非空条件唯一索引,禁止第三方交易号跨支付单复用; +- `PaymentOrderTypeAgentRecharge = "agent_recharge"`。 + +`qr_content` 视为敏感支付材料:不写日志和审计正文,不出现在列表、详情或支付状态接口,只在在线创建的首次/幂等响应中返回,并复用现有 Sanitizer 规则。 + +`merchant_identity` 是支付事实快照,不随配置后续修改而变化;当前支付宝配置未保存商户 PID,因此以实际签约应用 ID 作为可核对身份。该快照不属于创建接口的用户可见响应字段。 + +选择理由:请求重放必须返回原付款内容,数据库事实比 Redis 可靠。备选方案是只将付款内容放 Redis,但缓存丢失后无法满足持久化幂等。 + +### 4. 两个薄 Adapter,共用一个最小扫码支付端口 + +Application 通过结构体字段注入两个明确 Adapter,不增加工厂或通道注册框架: + +```text +WechatNativeAdapter → 现有 PowerWeChat TransactionNative / QueryOrder +AlipayPreCreateAdapter → 现有 smartwalle/alipay TradePreCreate / TradeQuery +``` + +端口仅覆盖本用例需要的 `PreCreate` 与 `Query`,返回统一的付款内容和渠道状态。微信 Adapter 返回 `code_url`,支付宝 Adapter 返回 `qr_code`,Application 映射为 `qr_content`。配置缺失和渠道错误转换为 `pkg/errors` 统一错误码;Adapter 的每次真实外呼通过现有 Integration Log Repository 记录开始和完成。 + +选择理由:已有 SDK 已能覆盖需求,无需新增依赖。备选方案是建设通用支付编排框架,但当前只有两个固定分支,属于无请求抽象。 + +### 5. 支付确认统一收口,钱包入账异步可靠执行 + +新增 `ConfirmOnlinePayment` 用例作为回调与查单的唯一写入口。输入包含支付单号、渠道、配置身份、第三方交易号、实付金额与支付时间。用例在事务中锁定支付单和充值单,调用纯领域状态规则校验转换,然后: + +- 条件更新 `tb_payment` 为已支付并保存第三方交易号; +- 条件更新充值单为 `2=已支付`; +- 同事务写 `agent_recharge.payment_confirmed.v1` Outbox。 + +回调 Handler 保留现有渠道验签与成功报文格式,只把已验证事实传给该用例。新支付单按 `order_type=agent_recharge` 分发;旧 `ARCH` 前缀分支仅兼容迁移前存量单。 + +消费者以 `agent-recharge:{recharge_id}:credit` 为业务键,在独立 GORM 事务中调用现有 `wallet.PostingService.PostInTx`,复用 `reference_type=topup` 与充值记录 ID 的数据库唯一流水约束,再条件更新充值单为 `3=已完成`。钱包、唯一流水、充值完成状态与已有 `wallet.agent_main.credited` 事件在同一事务提交。 + +选择理由:支付渠道不能因为钱包短暂故障反复认为回调失败,已收款事实也不能随钱包事务回滚。备选方案是继续在回调事务同步入账,故障耦合和恢复风险不可接受。 + +### 6. 本地轮询与后台查单职责分离 + +`GET /api/admin/agent-recharges/:id/payment-status` 走 Query 通道,只读取本地 `tb_agent_recharge_record`、`tb_payment` 和必要的成功流水事实,不调用第三方,避免桌面端每 3 秒轮询放大外部流量。 + +后台受控任务批量领取长时间待支付或待预下单记录,通过创建时的 `payment_config_id` 和支付方式调用 Adapter 查单。成功结果复用 `ConfirmOnlinePayment`;明确关闭才条件关闭本地单;未知或超时保持原状态并重试。查询按状态、创建时间和 ID 建立组合索引并分页领取,单批数量固定为项目常量,避免全表扫描。 + +不为支付状态增加 Redis 缓存:读取是按主键/支付单索引的轻量查询,缓存会引入资金状态短暂不一致,收益不足。 + +### 7. 领域账本、外部日志与 Outbox 分工 + +- **Audit Event**:按用户决定为 N/A;本 Change 不新增表、Writer、Adapter 或发布门禁。 +- **Domain Ledger**:`tb_agent_recharge_record`、`tb_payment`、`tb_agent_wallet` 与 `tb_agent_wallet_transaction` 是支付、充值和资金权威事实。 +- **Integration Log**:微信/支付宝预下单、查单和入站回调每次真实尝试均记录,正文经过 Sanitizer,禁止保存密钥和完整 `qr_content`。 +- **Outbox**:支付确认事务写 `agent_recharge.payment_confirmed.v1`;钱包入账继续写现有 `wallet.agent_main.credited`。消费者以稳定业务键幂等。 + +现有全局审计覆盖基线不作为本 Change 的实现或发布门禁。 + +### 8. API 与文档装配 + +新增静态路由 `/payment-methods` 必须注册在 `/:id` 之前;`/:id/payment-status` 使用现有认证和数据范围。DTO 枚举说明从 `pkg/constants` 原文复制,int 状态响应同时返回 `status_name`。 + +充值来源不新增数据库列,直接由现有创建方式稳定映射:`offline` 映射为 `platform_offline`,`wechat|alipay` 映射为 `agent_online`。创建、列表、详情和支付状态响应返回 `recharge_source/recharge_source_name`,列表可按 `recharge_source` 筛选;支付商户对账仍以支付单创建时保存的 `merchant_identity` 为准。 + +Handler 继续通过 Fiber、Validator、统一 `pkg/response` 和全局 ErrorHandler 工作,不拼接底层错误。新增 Handler 方法时同步更新 OpenAPI RouteSpec;若新增 Handler 类型,则同时更新 `cmd/api/docs.go` 与 `cmd/gendocs/main.go`。所有导出符号、日志、错误和文档使用中文。 + +## Risks / Trade-offs + +- [本地建单后进程在预下单前退出] → 记录明确待预下单事实,由恢复任务使用原 `payment_no` 重试,禁止创建替代单。 +- [第三方回调丢失] → 后台任务受控查单,成功复用统一确认用例。 +- [支付成功但钱包 Worker 失败] → 保持 `2=已支付`,Outbox/Worker 重试;唯一流水约束保证不重入账。 +- [重复或伪造回调] → 渠道验签、创建配置身份、金额、业务关联和第三方交易号唯一约束共同防护。 +- [保存付款内容扩大敏感面] → 仅保存原始字符串用于幂等重放,禁止日志/审计输出,读取只限创建者且其他查询不返回。 +- [轮询流量增加] → 使用本地主键/组合索引查询,前端页面可见时每 3 秒轮询,终态立即停止;不引入缓存。 +- [旧代理充值单仍依赖前缀回调] → 保留有界兼容分支;所有新单必须有 `tb_payment.order_type=agent_recharge`,后续存量清零后再独立删除兼容逻辑。 +- [支付 SDK Native/PreCreate 方法签名与当前版本不同] → 实施前以已锁定依赖版本的实际 API 做最小适配,不升级或替换 SDK。 + +## Migration Plan + +1. 增加可回滚迁移:请求幂等字段、`qr_content`、必要的唯一/查询索引和注释;迁移前检查重复第三方交易号,禁止静默清洗资金事实。 +2. 增加常量、DTO、领域状态规则、扫码 Adapter、在线创建/确认/入账 Application 与 Query,并完成结构体字段注入。 +3. 注册 API 与回调分发,更新 OpenAPI 生成装配、README 和功能总结。 +4. 先运行迁移与后端服务,再同时切换回调/Worker 和代理后台入口;未启用前前端不展示扫码充值。 +5. 在联调环境分别完成微信和支付宝真实扫码,人工核对重复回调、回调丢失查单、Worker 重试和唯一流水。 +6. 观察支付成功未完成数量、Outbox 重试、Integration Log 失败率和钱包入账延迟后再开放生产入口。 + +回滚时先关闭前端入口和在线支付方式,再停止创建新单;回调、查单和钱包 Worker MUST 保持运行直至已收款订单全部收敛。应用代码可回滚到兼容读旧字段版本,新增列和索引最后单独回滚;不得通过删除待处理支付单完成回滚。 + +## Open Questions + +无。业务范围已经确认:仅桌面扫码、最低 100 元、代理仅充自己主钱包、每次主动提交新单、同请求持久化幂等、第一期不做主动取消和在线退款。 diff --git a/openspec/changes/add-agent-wallet-qr-recharge/proposal.md b/openspec/changes/add-agent-wallet-qr-recharge/proposal.md new file mode 100644 index 0000000..a187613 --- /dev/null +++ b/openspec/changes/add-agent-wallet-qr-recharge/proposal.md @@ -0,0 +1,39 @@ +## Why + +当前代理充值只能由平台员工通过线下代充完成;现有代理在线充值入口仅创建本地记录,不能生成微信或支付宝扫码支付内容,也无法在真实收款后可靠、幂等地增加代理主钱包余额。需要补齐桌面端扫码充值闭环,让代理能够安全地为当前店铺主钱包自主充值,同时保持平台线下代充审批链路不变。 + +功能 ID:`feature-034-agent-wallet-qr-recharge`。 + +## What Changes + +- 代理可在后台选择微信或支付宝,为当前所属店铺的主钱包创建在线充值单;请求不接受目标 `shop_id`。 +- 微信使用 Native 支付、支付宝使用 `alipay.trade.precreate`,后端统一返回原始 `qr_content`,由前端渲染二维码。 +- 代理在线充值金额范围固定为 `10000~100000000` 分,即最低 100 元、最高 100 万元;平台线下代充金额规则不随之改变。 +- 每次主动创建生成新的充值单与支付单;同一次请求重试使用提交账号与 `request_id` 持久化幂等。 +- 支付成功先固化支付事实,再通过可靠 Outbox/Worker 幂等增加代理主钱包余额、写唯一钱包流水并完成充值单。 +- 增加本地支付与入账状态查询,供桌面端页面轮询;不提供 JSAPI、H5、WAP、手机唤起、二维码图片、主动取消、支付方式切换或在线退款。 +- 微信、支付宝回调按 `tb_payment.order_type` 分发代理充值,校验支付配置、渠道、金额、第三方交易号及业务关联,重复回调不得重复入账。 +- 支付单保存创建时的收款身份快照:微信商户号或支付宝应用 ID,联合支付方式与配置 ID 支持后续导出对账;该字段不在在线创建响应中暴露。 +- 平台员工线下代充继续走现有企业微信审批,不与代理在线充值互相复用创建权限或审批状态。 +- 充值创建、列表、详情和在线支付状态响应统一返回稳定来源字段:`platform_offline` 表示平台线下代充,`agent_online` 表示代理在线自充;列表支持按该字段筛选。 +- 复用现有 Fiber、GORM、Asynq、支付 SDK、统一钱包入账、公共 Outbox 与 Integration Log,不新增依赖;本 Change 不建设或接入 Audit Event。 + +## Capabilities + +### New Capabilities + +无。 + +### Modified Capabilities + +- `agent-recharge`: 将现有代理充值 Stub 扩展为微信/支付宝桌面扫码支付、支付状态收敛和可靠钱包入账完整能力,并收紧代理自主充值权限与在线金额规则。 + +## Impact + +- API:调整 `POST /api/admin/agent-recharges` 的角色判别请求契约与在线响应;新增可用支付方式和轻量支付状态读取能力。 +- 复杂写主通道:`Handler → Application UseCase → Domain → Repository/Infrastructure`,收口在线创建、支付确认和钱包入账状态机;旧 Service 仅保留未触碰查询或作为迁移门面。 +- 读取辅助通道:`Handler → Query → GORM/DTO`,用于充值列表、详情和支付状态轮询。 +- Infrastructure:扩展微信 Native、支付宝 PreCreate Adapter,复用 `tb_payment`、公共 Outbox、Integration Log 和现有支付回调入口。 +- 数据:增加代理充值支付业务类型、请求幂等、收款身份快照和处理状态所需字段/约束;禁止外键和 GORM 关联标签。 +- 验收:按用户明确要求不新增或运行自动化测试;通过编译、静态检查、迁移演练、接口/数据库人工核对和联调环境真实扫码验证角色权限、100 元边界、请求幂等、回调校验、异步入账与唯一流水。 +- 性能:创建接口只做必要数据库写入与一次渠道预下单;轮询接口走轻量本地查询,避免每次请求第三方,维持 API P95 小于 200ms(第三方预下单耗时单独观测)。 diff --git a/openspec/changes/add-agent-wallet-qr-recharge/specs/agent-recharge/spec.md b/openspec/changes/add-agent-wallet-qr-recharge/specs/agent-recharge/spec.md new file mode 100644 index 0000000..9820c34 --- /dev/null +++ b/openspec/changes/add-agent-wallet-qr-recharge/specs/agent-recharge/spec.md @@ -0,0 +1,181 @@ +## MODIFIED Requirements + +### Requirement: 创建代理充值订单 + +系统 SHALL 通过 `POST /api/admin/agent-recharges` 保留同一代理充值资源,并按登录账号类型与 `payment_method` 执行严格分流。 + +代理在线请求 MUST 仅接受 `amount`、`payment_method` 与 `request_id`: + +- `payment_method` MUST 为 `wechat` 或 `alipay`。 +- `amount` MUST 使用分为单位,范围 MUST 为 `10000~100000000`。 +- 目标店铺与主钱包 MUST 从当前认证上下文确定;请求 MUST NOT 接受或信任 `shop_id`、支付凭证或运营备注。 +- 每次新的主动提交 MUST 创建新的代理充值单和 `tb_payment` 支付单,不得按金额或待支付记录复用旧单。 +- 同一提交账号与 `request_id` MUST 形成持久化幂等作用域;相同指纹重放 MUST 返回原业务结果,不同指纹重放 MUST 返回统一冲突错误。 + +平台或超级管理员的 `offline` 线下代充 MUST 继续使用现有目标 `shop_id`、付款凭证和企业微信审批契约,在线充值 100 元最低金额 MUST NOT 改变线下代充金额规则。平台、超级管理员和企业账号 MUST NOT 创建代理在线扫码充值单。 + +在线创建 MUST 在同一 GORM 事务中保存充值单、支付单及请求幂等事实,再调用创建时选定的支付 Adapter:微信 MUST 使用 Native 支付,支付宝 MUST 使用 `alipay.trade.precreate`。成功响应 MUST 使用统一 `{code,msg,data,timestamp}` 格式,并在 `data` 中至少返回 `recharge_id`、`recharge_no`、`payment_no`、`payment_method`、`recharge_source`、`recharge_source_name`、`amount`、`qr_content`、`status` 与 `status_name`。后端 MUST 返回支付渠道原始付款字符串,不得生成或保存二维码图片。 + +充值订单创建、列表、详情和在线支付状态响应 MUST 使用稳定来源枚举区分创建路径:`platform_offline` 表示平台线下代充,`agent_online` 表示代理在线自充。列表 MUST 支持使用 `recharge_source` 筛选;来源可由受控创建方式推导,不要求新增重复数据库字段。 + +支付单 MUST 同时保存创建时的收款身份快照:微信记录商户号,支付宝记录应用 ID,并保留支付方式与 `payment_config_id`,供后续导出对账。创建响应 MUST NOT 返回该内部收款身份快照。 + +第三方预下单失败时,系统 MUST 记录 Integration Log,并将本次支付单标记为失败、充值单标记为已关闭;不得返回缺少有效 `qr_content` 的成功响应。 + +#### Scenario: 代理创建微信 Native 扫码充值 +- **WHEN** 代理提交 `amount=10000`、`payment_method=wechat` 和新的 `request_id` +- **THEN** 系统从登录上下文确定当前店铺主钱包,创建充值单和支付单,调用微信 Native 预下单,并将 `code_url` 映射为 `qr_content` +- **THEN** 响应不得包含支付配置 ID、商户密钥或其他店铺信息 + +#### Scenario: 代理创建支付宝当面付扫码充值 +- **WHEN** 代理提交有效金额、`payment_method=alipay` 和新的 `request_id` +- **THEN** 系统创建充值单和支付单,调用 `alipay.trade.precreate`,并将 `qr_code` 映射为 `qr_content` + +#### Scenario: 区分平台代充与代理自充 +- **WHEN** 调用方查询充值订单列表、详情或在线支付状态 +- **THEN** 平台线下代充 MUST 返回 `recharge_source=platform_offline`,代理微信或支付宝在线自充 MUST 返回 `recharge_source=agent_online` + +#### Scenario: 在线充值金额边界 +- **WHEN** 代理提交的在线充值金额小于 `10000` 分或大于 `100000000` 分 +- **THEN** 系统 MUST 返回 `CodeInvalidParam`,且不得创建充值单、支付单或调用第三方支付 + +#### Scenario: 代理请求携带目标店铺 +- **WHEN** 代理在线请求携带 `shop_id` 或其他仅线下代充允许的字段 +- **THEN** 系统 MUST 拒绝请求,不得允许代理选择本店、下级店铺或其他店铺作为受益方 + +#### Scenario: 相同请求重放 +- **WHEN** 同一提交账号使用相同 `request_id` 和相同业务字段重试 +- **THEN** 系统 MUST 返回首次创建的充值单、支付单和付款内容,不得再次创建业务单或再次向第三方预下单 + +#### Scenario: 幂等请求载荷冲突 +- **WHEN** 同一提交账号使用已有 `request_id` 但改变金额或支付方式 +- **THEN** 系统 MUST 返回 `CodeConflict`,不得改变原充值单或创建新单 + +#### Scenario: 支付方式不可用 +- **WHEN** 所选支付方式缺少完整配置、扫码预下单能力或回调验签能力 +- **THEN** 系统 MUST 返回统一支付配置不可用错误,且不得创建只有本地记录而无法付款的待支付订单 + +### Requirement: 代理充值回调处理 + +现有微信和支付宝异步回调入口 MUST 在完成渠道验签后,按 `tb_payment.payment_no` 和 `order_type=agent_recharge` 分发到代理充值支付确认用例,不得仅依赖 `ARCH` 单号前缀判断业务类型。旧代理充值支付单可在迁移期保留受控兼容分支,但新支付单 MUST 走统一支付记录分发。 + +支付确认 MUST 校验支付方式、创建时的 `payment_config_id`、回调商户或应用身份、支付单金额、充值单金额、支付单与充值单关联以及第三方交易号唯一性。校验失败 MUST 不改变支付单、充值单或钱包事实,并写入中文安全日志和 Integration Log;日志不得记录密钥或完整敏感正文。 + +成功确认 MUST 在同一 GORM 事务中: + +1. 条件更新支付单为已支付并保存第三方交易号与支付时间; +2. 条件更新代理充值单从 `1=待支付` 为 `2=已支付`; +3. 写入稳定版本的代理充值入账 Outbox。 + +支付渠道成功响应 MUST 在上述事务提交后返回。钱包入账不得继续作为支付回调事务中的同步步骤。 + +Outbox 消费者 MUST 在独立事务中复用统一代理主钱包入账能力,锁定目标主钱包、增加余额、创建唯一成功流水、将充值单从 `2=已支付` 更新为 `3=已完成`,并写入现有钱包入账事件。消费者 MUST 以充值记录 ID 作为业务幂等引用;重复回调、重复 Outbox 或 Worker 重试不得重复增加余额。 + +#### Scenario: 微信支付成功回调 +- **WHEN** 微信回调验签通过,支付单类型为 `agent_recharge`,且金额、配置、商户身份和业务关联全部一致 +- **THEN** 系统 MUST 固化支付成功事实和入账 Outbox,并向微信返回渠道成功响应 +- **THEN** 钱包余额由独立消费者完成,不得在回调事务中同步增加 + +#### Scenario: 支付宝支付成功回调 +- **WHEN** 支付宝通知验签通过,交易状态为成功,支付单类型为 `agent_recharge`,且金额、配置、应用身份和业务关联全部一致 +- **THEN** 系统 MUST 固化支付成功事实和入账 Outbox,并向支付宝返回 `success` + +#### Scenario: 回调金额或关联不一致 +- **WHEN** 回调金额与支付单或充值单不一致,或支付单未关联该代理充值记录 +- **THEN** 系统 MUST 拒绝处理并保留原状态,钱包余额和流水 MUST NOT 改变 + +#### Scenario: 重复支付回调 +- **WHEN** 同一第三方交易号对同一已支付或已完成充值单重复回调 +- **THEN** 系统 MUST 幂等返回渠道成功响应,不得重复写支付事实、入账 Outbox或钱包流水 + +#### Scenario: 第三方交易号被其他支付单占用 +- **WHEN** 回调中的第三方交易号已绑定另一张支付单或代理充值单 +- **THEN** 系统 MUST 返回冲突并记录不含敏感信息的严重错误,任何钱包 MUST NOT 入账 + +#### Scenario: 钱包入账暂时失败 +- **WHEN** 支付事实已提交但钱包入账消费者因锁冲突或暂时性基础设施错误失败 +- **THEN** 充值单 MUST 保持 `2=已支付`,Outbox/Worker MUST 重试,且支付成功事实不得回滚或要求代理再次付款 + +#### Scenario: 重复执行钱包入账 +- **WHEN** 同一代理充值入账任务被重复消费 +- **THEN** 数据库唯一流水约束和状态条件更新 MUST 保证余额最多增加一次,并最终将充值单收敛为 `3=已完成` + +### Requirement: 权限控制 + +系统 MUST 使用以下权限边界: + +| 操作 | 平台/超级管理员 | 代理账号 | 企业账号 | +|------|-----------------|----------|----------| +| 创建在线扫码充值 | 禁止 | 仅当前所属店铺主钱包 | 禁止 | +| 创建线下代充 | 按现有权限指定目标店铺 | 禁止 | 禁止 | +| 查询可用在线支付方式 | 禁止 | 允许 | 禁止 | +| 查询充值列表与详情 | 按既有数据范围 | 按既有店铺层级与查看权限 | 禁止 | +| 查询在线支付状态 | 按既有数据范围 | 按既有店铺层级与查看权限 | 禁止 | + +路由层 MUST 对企业账号执行粗粒度拦截;Application/Query MUST 执行创建角色、当前店铺、资源归属和查看权限校验;GORM 数据范围过滤保持启用。越权与资源不存在 MUST 统一返回 `CodeForbidden` 和“无权限操作该资源或资源不存在”,不得泄露资源是否存在。 + +#### Scenario: 代理为当前店铺充值 +- **WHEN** 代理账号创建在线扫码充值且当前店铺主钱包可用 +- **THEN** 系统 MUST 以认证上下文中的店铺和主钱包作为唯一受益方 + +#### Scenario: 平台尝试创建在线扫码充值 +- **WHEN** 平台或超级管理员提交 `wechat` 或 `alipay` 代理充值请求 +- **THEN** 系统 MUST 返回 `CodeForbidden`,并引导其使用既有线下代充审批路径 + +#### Scenario: 无权读取支付状态 +- **WHEN** 登录账号查询其数据范围外充值单的支付状态 +- **THEN** 系统 MUST 返回统一禁止访问错误,不得返回支付状态、付款内容或钱包余额 + +## ADDED Requirements + +### Requirement: 查询代理在线充值可用支付方式 + +系统 SHALL 提供 `GET /api/admin/agent-recharges/payment-methods`,仅根据当前生效支付配置返回真正具备扫码预下单、回调验签和查单能力的在线支付方式。路由 MUST 注册在 `/:id` 动态路由之前。 + +成功响应 MUST 使用统一 `{code,msg,data,timestamp}` 格式;`data.methods` MUST 为按 `wechat`、`alipay` 固定顺序排列的字符串数组,并同时返回 `min_amount=10000` 与 `max_amount=100000000`。接口不得返回支付配置 ID、商户号、应用 ID、密钥或具体缺失的敏感配置。 + +#### Scenario: 微信与支付宝均可用 +- **WHEN** 当前支付配置完整支持微信 Native 和支付宝 PreCreate +- **THEN** 接口 MUST 返回 `methods=["wechat","alipay"]` 及在线金额上下限 + +#### Scenario: 没有可用扫码支付方式 +- **WHEN** 当前配置不支持任何已约定的扫码支付方式 +- **THEN** 接口 MUST 成功返回空数组,不得伪造可用渠道 + +### Requirement: 查询代理在线充值支付状态 + +系统 SHALL 提供 `GET /api/admin/agent-recharges/:id/payment-status` 供桌面端轮询本地事实。接口 MUST 仅查询 PostgreSQL 本地支付单、充值单及必要的钱包入账结果,不得在每次轮询时调用第三方支付渠道。 + +响应 MUST 使用统一 `{code,msg,data,timestamp}` 格式,并至少返回 `status`、`status_name`、`payment_status`、`payment_status_name`、`paid_at` 与 `completed_at`。`1=待支付` MUST 表示尚未确认收款,`2=已支付` MUST 表示第三方收款已确认但钱包仍在入账,`3=已完成` MUST 表示钱包余额和唯一流水已经提交。 + +接口 MUST NOT 返回 `qr_content`、支付密钥、签名参数、内部重试错误或其他店铺余额。第一期 MUST NOT 提供主动取消、支付方式切换、在线退款或本地推算的精确二维码倒计时。 + +#### Scenario: 等待扫码支付 +- **WHEN** 充值单仍为待支付且支付单未确认成功 +- **THEN** 接口 MUST 返回待支付状态,不得调用第三方查单 + +#### Scenario: 已支付等待钱包入账 +- **WHEN** 支付单已支付且充值单状态为 `2=已支付` +- **THEN** 接口 MUST 明确返回支付成功、入账处理中,不得显示支付失败 + +#### Scenario: 钱包已经到账 +- **WHEN** 充值单状态为 `3=已完成` 且唯一钱包流水存在 +- **THEN** 接口 MUST 返回已完成和完成时间 + +### Requirement: 待支付订单受控收敛 + +系统 MUST 通过后台受控任务查询长期待支付的代理在线充值支付单,以弥补第三方回调丢失。任务 MUST 使用创建支付单时记录的支付方式和 `payment_config_id` 调用对应查单 Adapter,并为每次外部尝试记录 Integration Log。 + +查单确认成功 MUST 复用与回调相同的支付确认用例;查单确认关闭或失效 MUST 条件关闭仍为待支付的支付单与充值单;未知、超时或渠道异常 MUST 保持原业务状态并按任务策略重试。迟到的真实成功通知经完整校验后 MUST 仍能固化支付事实并入账,不得因本地曾判断待支付或关闭而吞掉已收款资金。 + +#### Scenario: 回调丢失但查单成功 +- **WHEN** 待支付收敛任务从第三方查询到交易成功且金额与配置校验通过 +- **THEN** 系统 MUST 调用统一支付确认用例,写入支付事实和钱包入账 Outbox + +#### Scenario: 第三方明确订单关闭 +- **WHEN** 查单结果明确表示订单关闭或失效,且本地支付单仍为待支付 +- **THEN** 系统 MUST 条件更新支付单为失败并将充值单关闭,不得增加钱包余额 + +#### Scenario: 查单结果未知 +- **WHEN** 第三方超时、返回未知状态或暂时不可用 +- **THEN** 系统 MUST 保持本地待支付状态并重试,不得猜测支付失败 diff --git a/openspec/changes/add-agent-wallet-qr-recharge/tasks.md b/openspec/changes/add-agent-wallet-qr-recharge/tasks.md new file mode 100644 index 0000000..f2b6521 --- /dev/null +++ b/openspec/changes/add-agent-wallet-qr-recharge/tasks.md @@ -0,0 +1,26 @@ +## 1. 代理扫码创建纵向切片 + +- [x] 1.1 【主通道:复杂写;辅助:Infrastructure;完整边界:在线充值单、支付单、幂等事实、付款内容和收款身份快照;不迁移:线下代充、客户充值】添加 golang-migrate 上下迁移与 Model/常量:代理充值 `request_id/request_fingerprint`、支付单 `qr_content/merchant_identity`、提交账号幂等唯一索引、第三方交易号唯一索引、收款身份对账索引、待处理查询索引及 `PaymentOrderTypeAgentRecharge`;迁移前检测冲突资金事实,以 up/down/up 演练和 SQL 查询核对字段、索引及回滚结果,不编写或运行自动化测试。 +- [x] 1.2 【主通道:Application + Port/Adapter;完整边界:微信 Native 与支付宝 PreCreate/Query;不迁移:通用支付框架和 SDK 升级】用现有 PowerWeChat、smartwalle/alipay 与 Integration Log 实现两个薄 Adapter,通过结构体字段注入在线创建用例;使用编译、静态诊断和联调环境渠道请求核对返回与错误转换,不编写或运行自动化测试。 +- [x] 1.3 【主通道:复杂写;辅助:Domain、Infrastructure;完整边界:代理只为当前店铺创建全新扫码充值;不迁移:旧查询和线下审批】实现纯领域金额/状态规则与在线创建 Application:双重金额校验、认证上下文店铺、主钱包/配置校验、本地短事务、第三方预下单、成功保存付款内容、失败条件关闭以及持久化请求幂等;复用 `pkg/idempotency`,通过构建、接口调用和数据库前后状态人工核对 100 元边界、角色权限与同请求重放。 +- [x] 1.4 【主通道:Handler → Application;辅助:Query;完整边界:创建与可用方式 API;不迁移:其他代理充值 Handler 方法】更新 DTO、现有 AgentRecharge Handler 和路由,静态 `/payment-methods` 注册在 `/:id` 前,统一返回 `{code,msg,data,timestamp}` 且不接受代理 `shop_id`;同步 RouteSpec/OpenAPI,若实际新增 Handler 类型则更新 `cmd/api/docs.go` 与 `cmd/gendocs/main.go`;通过 OpenAPI 生成、构建、静态诊断和接口响应人工核对完成验收。 + +## 2. 支付确认与可靠钱包入账纵向切片 + +- [x] 2.1 【主通道:复杂写;辅助:Domain、Infrastructure;完整边界:第三方支付事实确认;不迁移:其他支付业务确认用例】实现代理充值支付确认领域转换与 Application,用支付单类型、渠道、创建配置身份、金额、业务关联和第三方交易号唯一性完成校验;同事务条件更新支付单/充值单并写 `agent_recharge.payment_confirmed.v1` Outbox;通过编译和静态诊断核对金额篡改、配置错配、交易号冲突与重复确认分支,不连接数据库或支付渠道。 +- [x] 2.2 【主通道:Application + Port/Adapter;完整边界:微信/支付宝回调分发;不迁移:既有验签算法和其他订单回调】扩展现有微信、支付宝回调按 `tb_payment.order_type=agent_recharge` 调用统一确认用例,保留有界存量 `ARCH` 兼容分支,复用渠道成功报文并记录入站 Integration Log;通过编译和静态诊断核对分发、配置身份、金额与幂等分支,不发送真实回调。 +- [x] 2.3 【主通道:复杂写;辅助:现有 Wallet Domain/Outbox;完整边界:支付成功后的代理主钱包一次性入账;不迁移:钱包扣款、退款和线下审批终态】实现并装配代理充值入账 Outbox 消费者,在独立事务复用 `wallet.PostingService`,以充值 ID 唯一引用增加余额、写流水、更新 `2→3` 并写既有钱包事件;注册 Worker,通过构建和静态诊断核对幂等分支,不连接数据库或运行 Worker 重放。 +- [x] 2.4 【主通道:复杂写;完整边界:支付回调不再同步入账;不迁移:旧线下 Service 入口】将新在线支付调用方切到统一确认/异步入账用例,封闭旧 `agent_recharge.Service.HandlePaymentCallback` 对新支付单的同步钱包写路径,确保业务不变量不在旧 Service 与 Application 两处并存;通过代码搜索、静态诊断和构建确认新单只走 `order_type=agent_recharge` 的统一确认与异步入账,旧方法仅保留无支付记录的 `ARCH` 存量兼容。 + +## 3. 状态读取与回调丢失收敛纵向切片 + +- [x] 3.1 【主通道:Query;完整边界:代理在线充值本地支付/到账状态;不迁移:既有列表、详情和导出】实现 `GET /api/admin/agent-recharges/:id/payment-status` Query/DTO/路由,复用店铺层级数据范围,仅返回来源、状态名称与支付/完成时间,不返回 `qr_content`、内部错误或其他钱包余额;按用户要求仅通过构建、OpenAPI 和静态代码检查验收,不连接数据库或调用接口。 +- [x] 3.2 【主通道:Infrastructure;辅助:Application + Port/Adapter;完整边界:长期待支付和待预下单收敛;不迁移:通用轮询框架】实现固定批次的受控查单/恢复任务,按创建方式和 `payment_config_id` 调用对应 Adapter,成功复用统一确认用例、明确关闭条件关单、未知状态保持并重试,每次外呼记录 Integration Log;按用户要求仅通过构建和静态代码检查验收,不运行 Worker、不调用支付渠道或查询数据库。 +- [x] 3.3 【主通道:Query + Infrastructure;完整边界:桌面轮询性能与终态收敛;不迁移:Redis 支付状态缓存】静态确认支付状态查询命中既有业务关联索引且无 N+1/外部请求,批量查单有固定批次和时间窗口,第三方预下单/查单耗时写入 Integration Log;按用户要求不执行 `EXPLAIN ANALYZE`、访问日志或数据库性能实测,并记录该验证边界。 + +## 4. 文档与发布验收 + +- [x] 4.1 【主通道:Infrastructure;完整边界:在线充值运行事实分类;不迁移:全仓审计治理】按用户决定将本 Change 的 Audit Event 明确为 N/A,不新增审计表、Writer、Adapter 或发布门禁;充值/支付/钱包/流水继续作为 Domain Ledger,预下单/回调/查单使用 Integration Log,支付确认/钱包事件使用 Outbox。 +- [x] 4.2 【主通道:文档;完整边界:代理桌面扫码充值交付说明;不迁移:手机支付与在线退款】在 `docs/feature-034-agent-wallet-qr-recharge/` 编写中文功能总结、API/前端对接、异常恢复、部署回滚与人工扫码验收说明,同步 README 和 OpenAPI;核对所有 DTO 枚举说明来自 `pkg/constants` 原文、导出符号/日志/错误为中文,运行文档生成、构建和静态检查。 +- [x] 4.3 【主通道:静态验收;完整边界:已确认扫码充值范围;不迁移:真实生产支付】不新增或运行自动化测试,不启动服务、不连接数据库/Redis、不调用支付渠道或发送回调;执行 `go build ./...`、`go vet ./...` 和 OpenSpec 严格校验,静态核对微信 Native、支付宝 PreCreate、100 元边界、权限隔离、幂等、回调分发、恢复查单与 Worker 注册,并记录未联调边界。 +- [x] 4.4 【主通道:发布门禁;完整边界:停机切换与资金单收敛;不迁移:自动退款】静态核对发布顺序、必要支付配置、回调地址、Outbox/Worker、待支付/已支付监控和回滚步骤;确认回滚只关闭新入口且继续处理已收款订单,不删除待处理支付事实,形成上线检查记录,不执行生产或联调环境操作。 diff --git a/pkg/constants/agent_recharge.go b/pkg/constants/agent_recharge.go new file mode 100644 index 0000000..cfcf699 --- /dev/null +++ b/pkg/constants/agent_recharge.go @@ -0,0 +1,32 @@ +package constants + +import "time" + +const ( + // OutboxEventTypeAgentRechargePaymentConfirmed 表示代理在线充值收款事实已经确认。 + OutboxEventTypeAgentRechargePaymentConfirmed = "agent_recharge.payment_confirmed.v1" + // AgentRechargePaymentConfirmedPayloadVersionV1 是代理充值支付确认事件载荷版本。 + AgentRechargePaymentConfirmedPayloadVersionV1 = 1 +) + +const ( + // AgentRechargeRecoveryBatchSize 是单次支付恢复扫描的最大支付单数。 + AgentRechargeRecoveryBatchSize = 50 + // AgentRechargeRecoveryMinimumAge 是支付单进入恢复扫描前的最短等待时间。 + AgentRechargeRecoveryMinimumAge = 2 * time.Minute +) + +const ( + // AgentRechargeSourcePlatformOffline 表示平台人员发起的线下代充。 + AgentRechargeSourcePlatformOffline = "platform_offline" + // AgentRechargeSourceAgentOnline 表示代理账号为当前店铺发起的在线自充。 + AgentRechargeSourceAgentOnline = "agent_online" +) + +// GetAgentRechargeSource 根据支付方式返回稳定的充值来源及中文名称。 +func GetAgentRechargeSource(paymentMethod string) (string, string) { + if paymentMethod == RechargeMethodWechat || paymentMethod == RechargeMethodAlipay { + return AgentRechargeSourceAgentOnline, "代理在线自充" + } + return AgentRechargeSourcePlatformOffline, "平台线下代充" +} diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index 745db9e..818d613 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -81,15 +81,16 @@ const ( TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge" // 充值后自动购包 // 定时任务类型(由 Asynq Scheduler 调度) - TaskTypeAlertCheck = "alert:check" // 告警检查 - TaskTypeDataCleanup = "data:cleanup" // 数据清理 - TaskTypeNotificationCleanup = "notification:cleanup" // 站内通知保留清理 - TaskTypePackageExpiryReminder = "package:expiry:reminder" // 套餐 15/7/3 天临期提醒 - TaskTypeDailyTrafficFlush = "traffic:daily:flush" // 每日流量落盘 - TaskTypeOutboxDeliver = "outbox:deliver" // 公共 Outbox 事件投递 - TaskTypeCardObservationSeries = "card_observation:series" // 卡观测事件序列尝试 - TaskTypeWeComApprovalSync = "wecom:approval:sync" // 企业微信审批详情异步同步 - TaskTypeWeComApprovalRecovery = "wecom:approval:recovery" // 企业微信审批主动恢复与轮询 + TaskTypeAlertCheck = "alert:check" // 告警检查 + TaskTypeDataCleanup = "data:cleanup" // 数据清理 + TaskTypeNotificationCleanup = "notification:cleanup" // 站内通知保留清理 + TaskTypePackageExpiryReminder = "package:expiry:reminder" // 套餐 15/7/3 天临期提醒 + TaskTypeDailyTrafficFlush = "traffic:daily:flush" // 每日流量落盘 + TaskTypeOutboxDeliver = "outbox:deliver" // 公共 Outbox 事件投递 + TaskTypeCardObservationSeries = "card_observation:series" // 卡观测事件序列尝试 + TaskTypeWeComApprovalSync = "wecom:approval:sync" // 企业微信审批详情异步同步 + TaskTypeWeComApprovalRecovery = "wecom:approval:recovery" // 企业微信审批主动恢复与轮询 + TaskTypeAgentRechargeRecovery = "agent_recharge:payment:recovery" // 代理在线充值支付恢复与查单 ) // 用户状态常量 @@ -303,6 +304,8 @@ func QueueForTaskType(taskType string) string { return QueueCardObservationSeries case TaskTypeWeComApprovalSync, TaskTypeWeComApprovalRecovery: return QueueWeComApproval + case TaskTypeAgentRechargeRecovery: + return QueueDefault default: return QueueDefault } diff --git a/pkg/constants/integration_log.go b/pkg/constants/integration_log.go index 559d947..7ff660d 100644 --- a/pkg/constants/integration_log.go +++ b/pkg/constants/integration_log.go @@ -26,6 +26,18 @@ const ( IntegrationOperationCUCCRealnameCallback = "realname_callback" // IntegrationInboundProcessingLease 表示入站回调 pending 记录允许恢复前的处理租约。 IntegrationInboundProcessingLease = time.Minute + // IntegrationProviderWechatPay 表示微信支付提供方。 + IntegrationProviderWechatPay = "wechat_pay" + // IntegrationProviderAlipay 表示支付宝提供方。 + IntegrationProviderAlipay = "alipay" + // IntegrationOperationPaymentPreCreate 表示扫码支付预下单。 + IntegrationOperationPaymentPreCreate = "payment_precreate" + // IntegrationOperationPaymentQuery 表示支付订单查询。 + IntegrationOperationPaymentQuery = "payment_query" + // IntegrationOperationPaymentCallback 表示支付渠道异步回调。 + IntegrationOperationPaymentCallback = "payment_callback" + // IntegrationResourceTypeAgentRechargePayment 表示代理充值支付单资源。 + IntegrationResourceTypeAgentRechargePayment = "agent_recharge_payment" ) const ( diff --git a/pkg/constants/payment.go b/pkg/constants/payment.go new file mode 100644 index 0000000..7ef3d48 --- /dev/null +++ b/pkg/constants/payment.go @@ -0,0 +1,28 @@ +package constants + +const ( + // PaymentRecordStatusPending 表示支付单待支付。 + PaymentRecordStatusPending = 0 + // PaymentRecordStatusPaid 表示支付单已支付。 + PaymentRecordStatusPaid = 1 + // PaymentRecordStatusFailed 表示支付单已失败。 + PaymentRecordStatusFailed = 2 + // PaymentRecordStatusRefunded 表示支付单已退款。 + PaymentRecordStatusRefunded = 3 +) + +// GetPaymentRecordStatusName 返回支付单状态中文名称。 +func GetPaymentRecordStatusName(status int) string { + switch status { + case PaymentRecordStatusPending: + return "待支付" + case PaymentRecordStatusPaid: + return "已支付" + case PaymentRecordStatusFailed: + return "已失败" + case PaymentRecordStatusRefunded: + return "已退款" + default: + return "未知" + } +} diff --git a/pkg/constants/wallet.go b/pkg/constants/wallet.go index 703306b..f28a505 100644 --- a/pkg/constants/wallet.go +++ b/pkg/constants/wallet.go @@ -80,8 +80,9 @@ const ( // 代理充值金额限制(单位:分) const ( - AgentRechargeMinAmount = 1 // 最小充值金额(1分) - AgentRechargeMaxAmount = 100000000 // 最大充值金额(1000000元) + AgentRechargeMinAmount = 1 // 线下最小充值金额(1分) + AgentOnlineRechargeMinAmount = 10000 // 在线扫码最小充值金额(100元) + AgentRechargeMaxAmount = 100000000 // 最大充值金额(1000000元) ) // ========== 资产钱包常量 ========== diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index b60a11f..1a659ab 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -53,7 +53,7 @@ func BuildDocHandlers() *bootstrap.Handlers { ShopSeriesGrant: admin.NewShopSeriesGrantHandler(nil), AdminOrder: admin.NewOrderHandler(nil, nil), AdminExchange: admin.NewExchangeHandler(nil, nil, nil), - PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil, nil, nil, nil, nil), + PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil), CTCCRealnameCallback: callback.NewCTCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil), CMCCRealnameCallback: callback.NewCMCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil), CUCCRealnameCallback: callback.NewCUCCRealnameHandler(nil, nil, nil, nil, nil, nil, nil), diff --git a/pkg/wechat/payment_v2.go b/pkg/wechat/payment_v2.go index 23fc3fe..87260ee 100644 --- a/pkg/wechat/payment_v2.go +++ b/pkg/wechat/payment_v2.go @@ -239,9 +239,10 @@ type V2CallbackResult struct { OutTradeNo string TransactionID string // TradeState 为 "SUCCESS" 表示支付成功(return_code 和 result_code 均为 SUCCESS) - TradeState string - TotalFee string - OpenID string + TradeState string + TotalFee string + OpenID string + SuccessTime string } // VerifyCallback 解析并验证 v2 回调 XML,返回解析结果 @@ -292,5 +293,6 @@ func (s *PaymentV2Service) VerifyCallback(body []byte) (*V2CallbackResult, error TradeState: tradeState, TotalFee: notify.TotalFee, OpenID: notify.OpenID, + SuccessTime: notify.TimeEnd, }, nil }