From f339fb198786860cdb1c1d3bb6d649ae1a65a656 Mon Sep 17 00:00:00 2001 From: huang Date: Mon, 30 Mar 2026 11:37:41 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E8=B5=84=E4=BA=A7=E9=92=B1=E5=8C=85?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E5=88=9B=E5=BB=BA=E6=9C=BA=E5=88=B6=20?= =?UTF-8?q?=E2=80=94=20=E4=BF=AE=E5=A4=8DC=E7=AB=AF=E8=B4=AD=E4=B9=B0?= =?UTF-8?q?=E6=97=B6=E9=92=B1=E5=8C=85=E4=B8=8D=E5=AD=98=E5=9C=A8=E6=8A=A5?= =?UTF-8?q?=E9=94=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - client_order: 新增 getOrCreateWallet 兜底,钱包不存在时自动创建 - device_import: 设备导入事务内同步创建设备钱包 - iot_card_import: IoT卡批量导入后批量创建卡钱包 - queue/handler: 传递 AssetWalletStore 给两个导入 handler - migration 000098: 为存量IoT卡和设备补建资产钱包 --- docs/admin-openapi.yaml | 663 +++++++++++++++++- internal/service/client_order/service.go | 47 +- internal/task/device_import.go | 18 + internal/task/iot_card_import.go | 68 +- .../000098_backfill_asset_wallets.down.sql | 8 + .../000098_backfill_asset_wallets.up.sql | 22 + .../.openspec.yaml | 2 + .../2026-03-28-add-refund-system/design.md | 126 ++++ .../2026-03-28-add-refund-system/proposal.md | 32 + .../specs/refund-api/spec.md | 56 ++ .../specs/refund-asset-reset/spec.md | 49 ++ .../specs/refund-commission-deduct/spec.md | 26 + .../specs/refund-request/spec.md | 40 ++ .../2026-03-28-add-refund-system/tasks.md | 63 ++ .../.openspec.yaml | 2 + .../design.md | 65 ++ .../proposal.md | 33 + .../specs/admin-order-creation/spec.md | 12 + .../specs/asset-lifecycle-status/spec.md | 27 + .../specs/client-order-purchase/spec.md | 12 + .../specs/commission-pending-review/spec.md | 15 + .../specs/fuiou-payment/spec.md | 19 + .../tasks.md | 46 ++ .../.openspec.yaml | 2 + .../design.md | 85 +++ .../proposal.md | 32 + .../specs/admin-order-creation/spec.md | 12 + .../specs/asset-queries/spec.md | 8 + .../specs/asset-suspend-resume/spec.md | 12 + .../specs/client-order-purchase/spec.md | 14 + .../tasks.md | 48 ++ openspec/specs/refund-api/spec.md | 56 ++ openspec/specs/refund-asset-reset/spec.md | 49 ++ .../specs/refund-commission-deduct/spec.md | 26 + openspec/specs/refund-request/spec.md | 40 ++ pkg/queue/handler.go | 2 + 36 files changed, 1811 insertions(+), 26 deletions(-) create mode 100644 migrations/000098_backfill_asset_wallets.down.sql create mode 100644 migrations/000098_backfill_asset_wallets.up.sql create mode 100644 openspec/changes/archive/2026-03-28-add-refund-system/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-28-add-refund-system/design.md create mode 100644 openspec/changes/archive/2026-03-28-add-refund-system/proposal.md create mode 100644 openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-api/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-asset-reset/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-commission-deduct/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-request/spec.md create mode 100644 openspec/changes/archive/2026-03-28-add-refund-system/tasks.md create mode 100644 openspec/changes/archive/2026-03-28-fix-business-completion/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-28-fix-business-completion/design.md create mode 100644 openspec/changes/archive/2026-03-28-fix-business-completion/proposal.md create mode 100644 openspec/changes/archive/2026-03-28-fix-business-completion/specs/admin-order-creation/spec.md create mode 100644 openspec/changes/archive/2026-03-28-fix-business-completion/specs/asset-lifecycle-status/spec.md create mode 100644 openspec/changes/archive/2026-03-28-fix-business-completion/specs/client-order-purchase/spec.md create mode 100644 openspec/changes/archive/2026-03-28-fix-business-completion/specs/commission-pending-review/spec.md create mode 100644 openspec/changes/archive/2026-03-28-fix-business-completion/specs/fuiou-payment/spec.md create mode 100644 openspec/changes/archive/2026-03-28-fix-business-completion/tasks.md create mode 100644 openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/design.md create mode 100644 openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/proposal.md create mode 100644 openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/admin-order-creation/spec.md create mode 100644 openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/asset-queries/spec.md create mode 100644 openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/asset-suspend-resume/spec.md create mode 100644 openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/client-order-purchase/spec.md create mode 100644 openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/tasks.md create mode 100644 openspec/specs/refund-api/spec.md create mode 100644 openspec/specs/refund-asset-reset/spec.md create mode 100644 openspec/specs/refund-commission-deduct/spec.md create mode 100644 openspec/specs/refund-request/spec.md diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index 7fbc866..86b538f 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -398,6 +398,18 @@ components: description: 失败原因 type: string type: object + DtoApproveRefundRequest: + properties: + approved_refund_amount: + description: 审批实际退款金额(分),不填则使用申请金额 + minimum: 1 + nullable: true + type: integer + remark: + description: 审批备注 + maxLength: 500 + type: string + type: object DtoApproveWithdrawalReq: properties: account_name: @@ -1698,7 +1710,7 @@ components: description: 支付方式 type: string payment_status: - description: 支付状态 (0:待支付, 1:已支付, 2:已取消) + description: 支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款) type: integer total_amount: description: 订单总金额(分) @@ -1717,7 +1729,7 @@ components: description: 订单号 type: string payment_status: - description: 支付状态 (0:待支付, 1:已支付, 2:已取消) + description: 支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款) type: integer total_amount: description: 订单总金额(分) @@ -1742,7 +1754,7 @@ components: nullable: true type: array payment_status: - description: 支付状态 (0:待支付, 1:已支付, 2:已取消) + description: 支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款) type: integer total_amount: description: 订单总金额(分) @@ -2009,6 +2021,23 @@ components: - recipient_phone - recipient_address type: object + DtoCommissionRecordResolveRequest: + properties: + action: + description: 操作 (release:入账, invalidate:作废) + type: string + amount: + description: 佣金金额(分),release 时必填 + minimum: 1 + nullable: true + type: integer + remark: + description: 处理备注 + maxLength: 500 + type: string + required: + - action + type: object DtoCommissionStatsResponse: properties: cost_diff_amount: @@ -2548,6 +2577,34 @@ components: - config_name - priority type: object + DtoCreateRefundRequest: + properties: + actual_received_amount: + description: 实收金额(分) + minimum: 1 + type: integer + order_id: + description: 关联订单ID + minimum: 0 + type: integer + package_usage_id: + description: 关联套餐使用记录ID(可选) + minimum: 0 + nullable: true + type: integer + refund_reason: + description: 退款原因 + maxLength: 1000 + type: string + requested_refund_amount: + description: 申请退款金额(分) + minimum: 1 + type: integer + required: + - order_id + - actual_received_amount + - requested_refund_amount + type: object DtoCreateRoleRequest: properties: role_desc: @@ -3072,6 +3129,10 @@ components: description: 本次开机下载流量(字节) nullable: true type: string + gateway_msg: + description: Gateway 上游消息(成功时为 null,失败时为错误信息) + nullable: true + type: string imei: description: IMEI号 nullable: true @@ -3354,6 +3415,10 @@ components: description: 本次开机下载流量(字节) nullable: true type: string + gateway_msg: + description: Gateway 上游消息(成功时为 null,失败时为错误信息) + nullable: true + type: string imei: description: Gateway返回的IMEI号 nullable: true @@ -5696,6 +5761,107 @@ components: expires_in: type: integer type: object + DtoRefundListResponse: + properties: + items: + description: 退款申请列表 + items: + $ref: '#/components/schemas/DtoRefundResponse' + nullable: true + type: array + page: + description: 当前页码 + type: integer + size: + description: 每页数量 + type: integer + total: + description: 总记录数 + type: integer + type: object + DtoRefundResponse: + properties: + actual_received_amount: + description: 实收金额(分) + type: integer + approved_refund_amount: + description: 审批实际退款金额(分) + nullable: true + type: integer + asset_reset: + description: 资产是否已重置 + type: boolean + commission_deducted: + description: 佣金是否已回扣 + type: boolean + created_at: + description: 创建时间 + type: string + creator: + description: 创建人ID + minimum: 0 + type: integer + id: + description: 退款申请ID + minimum: 0 + type: integer + order_id: + description: 关联订单ID + minimum: 0 + type: integer + package_usage_id: + description: 关联套餐使用记录ID + minimum: 0 + nullable: true + type: integer + processed_at: + description: 审批时间 + type: string + processor_id: + description: 审批人ID + minimum: 0 + nullable: true + type: integer + refund_no: + description: 退款单号 + type: string + refund_reason: + description: 退款原因 + type: string + reject_reason: + description: 拒绝原因 + type: string + remark: + description: 审批备注 + type: string + requested_refund_amount: + description: 申请退款金额(分) + type: integer + shop_id: + description: 店铺ID + minimum: 0 + nullable: true + type: integer + status: + description: 状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回) + type: integer + updated_at: + description: 更新时间 + type: string + updater: + description: 更新人ID + minimum: 0 + type: integer + type: object + DtoRejectRefundRequest: + properties: + reject_reason: + description: 拒绝原因(必填) + maxLength: 500 + type: string + required: + - reject_reason + type: object DtoRejectWithdrawalReq: properties: remark: @@ -5711,6 +5877,31 @@ components: description: 任务类型 type: string type: object + DtoResubmitRefundRequest: + properties: + actual_received_amount: + description: 实收金额(分) + minimum: 1 + nullable: true + type: integer + refund_reason: + description: 退款原因 + maxLength: 1000 + nullable: true + type: string + requested_refund_amount: + description: 申请退款金额(分) + minimum: 1 + nullable: true + type: integer + type: object + DtoReturnRefundRequest: + properties: + remark: + description: 退回备注 + maxLength: 500 + type: string + type: object DtoRolePageResult: properties: items: @@ -10208,6 +10399,52 @@ paths: summary: 更新运营商状态 tags: - 运营商管理 + /api/admin/commission-records/{id}/resolve: + post: + parameters: + - description: 佣金记录ID + in: path + name: id + required: true + schema: + description: 佣金记录ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoCommissionRecordResolveRequest' + responses: + "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/commission/withdrawal-requests: get: parameters: @@ -18283,6 +18520,418 @@ paths: summary: 获取轮询任务统计 tags: - 轮询管理-监控 + /api/admin/refunds/: + get: + parameters: + - description: 页码(默认1) + in: query + name: page + schema: + description: 页码(默认1) + minimum: 1 + type: integer + - description: 每页数量(默认20,最大100) + in: query + name: page_size + schema: + description: 每页数量(默认20,最大100) + maximum: 100 + minimum: 1 + type: integer + - description: 状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回) + in: query + name: status + schema: + description: 状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回) + maximum: 4 + minimum: 1 + nullable: true + type: integer + - description: 关联订单ID + in: query + name: order_id + schema: + description: 关联订单ID + minimum: 0 + nullable: true + type: integer + - description: 店铺ID + in: query + name: shop_id + schema: + description: 店铺ID + minimum: 0 + nullable: true + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoRefundListResponse' + 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: + - 退款管理 + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoCreateRefundRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoRefundResponse' + 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/refunds/{id}: + 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/DtoRefundResponse' + 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/refunds/{id}/approve: + post: + parameters: + - description: 退款申请ID + in: path + name: id + required: true + schema: + description: 退款申请ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoApproveRefundRequest' + responses: + "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/refunds/{id}/reject: + post: + parameters: + - description: 退款申请ID + in: path + name: id + required: true + schema: + description: 退款申请ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoRejectRefundRequest' + responses: + "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/refunds/{id}/resubmit: + post: + parameters: + - description: 退款申请ID + in: path + name: id + required: true + schema: + description: 退款申请ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoResubmitRefundRequest' + responses: + "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/refunds/{id}/return: + post: + parameters: + - description: 退款申请ID + in: path + name: id + required: true + schema: + description: 退款申请ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoReturnRefundRequest' + responses: + "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/roles: get: parameters: @@ -22103,13 +22752,13 @@ paths: maxLength: 50 minLength: 1 type: string - - description: 支付状态 (0:待支付, 1:已支付, 2:已取消) + - description: 支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款) in: query name: payment_status schema: - description: 支付状态 (0:待支付, 1:已支付, 2:已取消) - maximum: 2 - minimum: 0 + description: 支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款) + maximum: 4 + minimum: 1 nullable: true type: integer - description: 页码 diff --git a/internal/service/client_order/service.go b/internal/service/client_order/service.go index 637ec41..18cd722 100644 --- a/internal/service/client_order/service.go +++ b/internal/service/client_order/service.go @@ -333,12 +333,9 @@ func (s *Service) createForceRechargeOrder( return nil, err } - wallet, err := s.walletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID) + wallet, err := s.getOrCreateWallet(ctx, resourceType, resourceID, resolveSellerShopID(validationResult)) if err != nil { - if err == gorm.ErrRecordNotFound { - return nil, errors.New(errors.CodeWalletNotFound, "钱包不存在") - } - return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败") + return nil, err } linkedPackageIDs, err := sonic.Marshal(extractPackageIDs(validationResult.Packages)) @@ -558,6 +555,46 @@ func buildLinkedPackageInfo(result *purchase_validation.PurchaseValidationResult } } +// getOrCreateWallet 获取或自动创建资产钱包 +// 兜底机制:当钱包不存在时自动创建,避免因钱包未提前创建导致业务中断 +func (s *Service) getOrCreateWallet(ctx context.Context, resourceType string, resourceID uint, shopIDTag uint) (*model.AssetWallet, error) { + wallet, err := s.walletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID) + if err == nil { + return wallet, nil + } + if err != gorm.ErrRecordNotFound { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败") + } + + // 钱包不存在,自动创建空钱包 + s.logger.Info("资产钱包不存在,自动创建", + zap.String("resource_type", resourceType), + zap.Uint("resource_id", resourceID), + zap.Uint("shop_id_tag", shopIDTag), + ) + + newWallet := &model.AssetWallet{ + ResourceType: resourceType, + ResourceID: resourceID, + Balance: 0, + FrozenBalance: 0, + Currency: "CNY", + Status: constants.AssetWalletStatusNormal, + Version: 0, + ShopIDTag: shopIDTag, + } + if createErr := s.walletStore.Create(ctx, newWallet); createErr != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, createErr, "自动创建资产钱包失败") + } + + // 重新查询获取完整数据(含数据库生成的字段) + wallet, err = s.walletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败") + } + return wallet, nil +} + func resolveWalletResource(result *purchase_validation.PurchaseValidationResult) (string, uint, error) { if result.Card != nil { return constants.AssetWalletResourceTypeIotCard, result.Card.ID, nil diff --git a/internal/task/device_import.go b/internal/task/device_import.go index 2111e44..a04c4d5 100644 --- a/internal/task/device_import.go +++ b/internal/task/device_import.go @@ -34,6 +34,7 @@ type DeviceImportHandler struct { deviceStore *postgres.DeviceStore deviceSimBindingStore *postgres.DeviceSimBindingStore iotCardStore *postgres.IotCardStore + assetWalletStore *postgres.AssetWalletStore storageService *storage.Service logger *zap.Logger } @@ -45,6 +46,7 @@ func NewDeviceImportHandler( deviceStore *postgres.DeviceStore, deviceSimBindingStore *postgres.DeviceSimBindingStore, iotCardStore *postgres.IotCardStore, + assetWalletStore *postgres.AssetWalletStore, storageSvc *storage.Service, logger *zap.Logger, ) *DeviceImportHandler { @@ -55,6 +57,7 @@ func NewDeviceImportHandler( deviceStore: deviceStore, deviceSimBindingStore: deviceSimBindingStore, iotCardStore: iotCardStore, + assetWalletStore: assetWalletStore, storageService: storageSvc, logger: logger, } @@ -294,6 +297,21 @@ func (h *DeviceImportHandler) processBatch(ctx context.Context, task *model.Devi } } + // 创建设备资产钱包(设备存在即应有钱包,避免购买时才发现缺失) + txWalletStore := postgres.NewAssetWalletStore(tx, nil) + deviceWallet := &model.AssetWallet{ + ResourceType: constants.AssetWalletResourceTypeDevice, + ResourceID: device.ID, + Balance: 0, + FrozenBalance: 0, + Currency: "CNY", + Status: constants.AssetWalletStatusNormal, + Version: 0, + } + if err := txWalletStore.Create(ctx, deviceWallet); err != nil { + return err + } + return nil }) diff --git a/internal/task/iot_card_import.go b/internal/task/iot_card_import.go index c4573fd..2a8039b 100644 --- a/internal/task/iot_card_import.go +++ b/internal/task/iot_card_import.go @@ -41,13 +41,14 @@ type PollingCallback interface { } type IotCardImportHandler struct { - db *gorm.DB - redis *redis.Client - importTaskStore *postgres.IotCardImportTaskStore - iotCardStore *postgres.IotCardStore - storageService *storage.Service - pollingCallback PollingCallback - logger *zap.Logger + db *gorm.DB + redis *redis.Client + importTaskStore *postgres.IotCardImportTaskStore + iotCardStore *postgres.IotCardStore + assetWalletStore *postgres.AssetWalletStore + storageService *storage.Service + pollingCallback PollingCallback + logger *zap.Logger } func NewIotCardImportHandler( @@ -55,18 +56,20 @@ func NewIotCardImportHandler( redis *redis.Client, importTaskStore *postgres.IotCardImportTaskStore, iotCardStore *postgres.IotCardStore, + assetWalletStore *postgres.AssetWalletStore, storageSvc *storage.Service, pollingCallback PollingCallback, logger *zap.Logger, ) *IotCardImportHandler { return &IotCardImportHandler{ - db: db, - redis: redis, - importTaskStore: importTaskStore, - iotCardStore: iotCardStore, - storageService: storageSvc, - pollingCallback: pollingCallback, - logger: logger, + db: db, + redis: redis, + importTaskStore: importTaskStore, + iotCardStore: iotCardStore, + assetWalletStore: assetWalletStore, + storageService: storageSvc, + pollingCallback: pollingCallback, + logger: logger, } } @@ -369,7 +372,44 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot result.successCount += len(finalCards) + // 为导入的卡批量创建资产钱包(钱包创建失败不影响卡导入结果,后续访问时 getOrCreateWallet 兜底) + h.batchCreateWallets(ctx, iotCards) + if h.pollingCallback != nil { h.pollingCallback.OnBatchCardsCreated(ctx, iotCards) } } + +// batchCreateWallets 批量为 IoT 卡创建资产钱包 +func (h *IotCardImportHandler) batchCreateWallets(ctx context.Context, cards []*model.IotCard) { + if h.assetWalletStore == nil { + return + } + + wallets := make([]*model.AssetWallet, 0, len(cards)) + for _, card := range cards { + if card.ID == 0 { + continue + } + wallets = append(wallets, &model.AssetWallet{ + ResourceType: constants.AssetWalletResourceTypeIotCard, + ResourceID: card.ID, + Balance: 0, + FrozenBalance: 0, + Currency: "CNY", + Status: constants.AssetWalletStatusNormal, + Version: 0, + }) + } + + if len(wallets) == 0 { + return + } + + if err := h.db.WithContext(ctx).Create(&wallets).Error; err != nil { + h.logger.Warn("批量创建 IoT 卡资产钱包失败,后续访问时将自动创建", + zap.Int("count", len(wallets)), + zap.Error(err), + ) + } +} diff --git a/migrations/000098_backfill_asset_wallets.down.sql b/migrations/000098_backfill_asset_wallets.down.sql new file mode 100644 index 0000000..0ab18ac --- /dev/null +++ b/migrations/000098_backfill_asset_wallets.down.sql @@ -0,0 +1,8 @@ +-- 回滚:删除补建的空钱包(仅删除余额为0且无交易记录的钱包) +DELETE FROM tb_asset_wallet +WHERE balance = 0 + AND frozen_balance = 0 + AND deleted_at IS NULL + AND id NOT IN ( + SELECT DISTINCT asset_wallet_id FROM tb_asset_wallet_transaction WHERE deleted_at IS NULL + ); diff --git a/migrations/000098_backfill_asset_wallets.up.sql b/migrations/000098_backfill_asset_wallets.up.sql new file mode 100644 index 0000000..6717d41 --- /dev/null +++ b/migrations/000098_backfill_asset_wallets.up.sql @@ -0,0 +1,22 @@ +-- 为已有存量资产补建资产钱包 +-- IoT 卡和设备导入时未创建钱包,需要补建以确保 C 端购买流程正常 + +-- 为没有钱包的 IoT 卡创建钱包 +INSERT INTO tb_asset_wallet (resource_type, resource_id, balance, frozen_balance, currency, status, version, shop_id_tag, created_at, updated_at) +SELECT 'iot_card', c.id, 0, 0, 'CNY', 1, 0, COALESCE(c.shop_id, 0), NOW(), NOW() +FROM tb_iot_card c +WHERE c.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM tb_asset_wallet w + WHERE w.resource_type = 'iot_card' AND w.resource_id = c.id AND w.deleted_at IS NULL + ); + +-- 为没有钱包的设备创建钱包 +INSERT INTO tb_asset_wallet (resource_type, resource_id, balance, frozen_balance, currency, status, version, shop_id_tag, created_at, updated_at) +SELECT 'device', d.id, 0, 0, 'CNY', 1, 0, COALESCE(d.shop_id, 0), NOW(), NOW() +FROM tb_device d +WHERE d.deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM tb_asset_wallet w + WHERE w.resource_type = 'device' AND w.resource_id = d.id AND w.deleted_at IS NULL + ); diff --git a/openspec/changes/archive/2026-03-28-add-refund-system/.openspec.yaml b/openspec/changes/archive/2026-03-28-add-refund-system/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-refund-system/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/archive/2026-03-28-add-refund-system/design.md b/openspec/changes/archive/2026-03-28-add-refund-system/design.md new file mode 100644 index 0000000..0667811 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-refund-system/design.md @@ -0,0 +1,126 @@ +## Context + +系统支持代理/平台给资产购买套餐,购买产生订单并触发佣金分配。目前没有退款机制,退款只能线下处理。需要新建一个完整的退款模块,参考现有 `commission_withdrawal`(提现)的实现模式。 + +关键约束: +- 退款不走自动化打款,由人工填写金额并线下打款 +- 审批通过后需全额回扣该订单产生的所有佣金(允许佣金钱包余额为负) +- 审批通过后需停掉套餐、停机、世代重置资产(参照换货模块) +- 支持"退回 → 重提"的灵活流程 +- 仅针对已支付的套餐购买订单,不含充值订单 +- 仅平台账号可发起和审批退款 + +## Goals / Non-Goals + +**Goals:** +- 退款仅针对 `payment_status=2`(已支付)的套餐购买订单(`tb_order`),不含充值订单 +- 退款审批通过后自动执行:停掉套餐 → 停机 → 资产世代重置 → 佣金全额回扣 → 更新订单状态为已退款 +- 完整的退款申请 → 审批 → 资产处理 → 佣金回扣闭环 +- 参考 `commission_withdrawal` 模块的代码结构和模式 +- 所有操作记录审计日志 + +**Non-Goals:** +- 不做自动退款(不对接支付渠道退款接口,全部线下退钱) +- 不做部分退款拆分(一次退款对应一笔订单) +- 不涉及企业客户授权分配的退款(企业不走订单流程) +- 不涉及充值订单退款 +- 不新增自动化测试 + +## Decisions + +### 1. 表设计 + +参考 `tb_commission_withdrawal_request` 的模式,核心字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `refund_no` | VARCHAR(50) UNIQUE | 退款单号,系统生成 | +| `order_id` | BIGINT NOT NULL | 关联订单 | +| `package_usage_id` | BIGINT | 关联套餐使用记录(可选) | +| `shop_id` | BIGINT | 店铺ID(冗余字段,从订单获取,用于数据权限过滤) | +| `actual_received_amount` | BIGINT NOT NULL | 实收金额(分,即进入系统的金额,对应订单的 total_amount) | +| `requested_refund_amount` | BIGINT NOT NULL | 申请退款金额(分) | +| `approved_refund_amount` | BIGINT | 审批实际退款金额(分) | +| `refund_reason` | TEXT | 退款原因 | +| `status` | INT NOT NULL DEFAULT 1 | 1-待审批 2-已通过 3-已拒绝 4-已退回 | +| `commission_deducted` | BOOLEAN DEFAULT FALSE | 佣金是否已回扣 | +| `asset_reset` | BOOLEAN DEFAULT FALSE | 资产是否已重置(套餐停用+停机+世代重置) | +| `processor_id` | BIGINT | 审批人ID | +| `processed_at` | TIMESTAMP | 审批时间 | +| `reject_reason` | TEXT | 拒绝原因 | +| `remark` | TEXT | 审批备注 | + +使用软删除(`deleted_at`),遵循项目 Model 规范(`gorm.Model` + `BaseModel{creator, updater}`)。 + +### 2. 状态流转 + +``` +1(待审批) ──Approve──▶ 2(已通过) ──▶ 触发资产处理 + 佣金回扣 + 订单状态更新 +1(待审批) ──Reject───▶ 3(已拒绝) +1(待审批) ──Return───▶ 4(已退回) ──Resubmit──▶ 1(待审批) +``` + +拒绝后不可重提(终态)。退回后可重提(修改金额/原因后重新进入审批)。 + +### 3. 重复退款防护 + +不在 `order_id` 上加唯一约束,而是在 Service 层检查:创建退款时,如果该订单已存在 `status IN (1,2,4)` 的退款记录(待审批/已通过/已退回),则拒绝创建。只有 `status=3`(已拒绝)的订单允许重新发起退款。 + +### 4. 佣金全额回扣策略 + +审批通过后,查找该订单产生的所有已入账佣金记录(`tb_commission_record WHERE order_id=? AND status=1`,使用 `CommissionRecord` 模型的 `CommissionStatusReleased=1`),每条佣金**全额**扣回(`deductAmount = commission.Amount`)。 + +在 `Approve()` 事务提交成功后,通过 Goroutine 异步执行佣金回扣。回扣失败记 Error 日志但不影响审批结果。 + +回扣操作在单独事务中: +1. 遍历该订单所有已入账佣金记录 +2. 对每条记录:找到对应代理的**佣金钱包**(`wallet_type="commission"`) +3. 全额扣减余额(允许负数):`UPDATE tb_agent_wallet SET balance = balance - ?, version = version + 1 WHERE id = ? AND version = ?` +4. 创建交易流水:`transaction_type="commission_deduct"`, `reference_type="refund"`, `reference_id=退款单ID`, `amount=-commission.Amount` +5. 全部完成后标记 `commission_deducted=true` + +**佣金钱包为负时的影响**:代理不能发起提现(提现 Service 校验余额),后续新佣金入账会逐步补填负数。不管退款时是否有正在审批的提现申请,正常扣佣金。 + +### 5. 资产处理策略(审批通过后异步执行) + +参照换货模块 `internal/service/exchange/service.go` 的资产重置逻辑。 + +**步骤 1:失效所有套餐** +查询该订单关联资产(`iot_card_id` 或 `device_id`)的全部套餐使用记录(`status IN (0,1,2)` 待生效/生效中/已用完),批量更新为 `status=4`(已失效)。 + +**步骤 2:停机** +- 单卡订单:调用 `StopResumeService.ManualStopCard(iccid)` — 调运营商停机接口 + 更新 `network_status=offline` +- 设备订单:调用 `DeviceService.StopDevice(deviceID)` — 内部遍历所有绑定卡逐一停机 + +**步骤 3:世代重置** +与换货完成后重置逻辑一致(`exchange/service.go` 第 278-289 行): +- `generation = generation + 1`(世代递增,使一次性佣金可重新触发) +- `asset_status = 1`(回到"在库") +- `accumulated_recharge = 0`(清零累计充值) +- `first_commission_paid = false`(重置一次性佣金标记) +- `accumulated_recharge_by_series = "{}"`(清零系列充值) +- `first_recharge_triggered_by_series = "{}"`(清零系列触发标记) +- 清理个人客户绑定(`tb_personal_customer_device`) +- 清理资产钱包(删除旧 `tb_asset_wallet`,创建新空钱包) + +**步骤 4:更新订单状态** +`UPDATE tb_order SET payment_status=4 WHERE id=?`(已退款) + +失败处理:`asset_reset` 保持 `false`,记 Error 日志(含 refund_id 和 error),不影响审批结果。管理员可通过 `asset_reset` 字段识别需要手动处理的退款单。 + +### 6. 路由设计 + +挂载到 `/api/admin/refunds`,使用 AdminAuth 中间件。路由中间件限制仅平台用户(`user_type IN (1,2)`,超级管理员和平台用户)可访问。同一人可申请+审批(无需审批分离)。 + +### 7. 退款单号生成 + +格式:`RF` + 年月日时分秒 + 6位随机数,如 `RF20260328143052123456`。参考现有 `GenerateOrderNo` 的实现方式(使用 `rand.Intn` 生成随机数)。 + +## Risks / Trade-offs + +- **[资产处理异步失败]** Goroutine 内执行失败不自动重试 → 通过 `asset_reset` 字段标记,管理员可识别需手动处理的记录(或后续改为 Asynq 任务) +- **[佣金回扣异步失败]** 同上 → 通过 `commission_deducted` 字段标记 +- **[佣金余额为负]** 允许扣成负数可能导致代理不满 → 这是业务决策(退款本身就是扣钱),管理员应提前沟通 +- **[并发审批]** 两人同时审批同一笔退款 → 使用状态条件更新 `WHERE status = 1` 保证幂等 +- **[停机失败]** 运营商接口不可用导致停机失败 → 记日志,管理员手动处理 +- **[跨模块依赖]** 退款 Service 依赖 package、iot_card、device、asset 等多个模块 → 通过依赖注入管理,各步骤独立失败不影响审批结果 diff --git a/openspec/changes/archive/2026-03-28-add-refund-system/proposal.md b/openspec/changes/archive/2026-03-28-add-refund-system/proposal.md new file mode 100644 index 0000000..61b22f2 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-refund-system/proposal.md @@ -0,0 +1,32 @@ +## Why + +系统目前没有退款功能。当已支付的套餐购买订单需要退款时,只能线下处理,无法追踪退款状态,也无法自动回扣已发放的佣金、停掉已激活的套餐和资产。需要一套完整的退款审批流程:人工申请 → 审批/拒绝/退回 → 审批通过后自动停掉套餐和资产、全额回扣佣金、更新订单状态。 + +## What Changes + +- **I-1 退款数据模型**:新建 `tb_refund_request` 表,定义退款单的完整生命周期字段(退款单号、关联订单、店铺ID、实收金额、申请退款金额、审批金额、状态流转、审批人/审批时间、佣金回扣标记、资产重置标记)。新建 `internal/model/refund.go` GORM Model。 +- **I-2 退款接口设计**:新建完整的 Handler + Service + Store 三层实现,提供 7 个 API 接口:发起申请、列表查询、详情查询、审批通过、审批拒绝、退回申请、重新提交。仅平台账号可操作。状态流转:1(待审批) → 2(已通过)/3(已拒绝)/4(已退回),4(已退回) → 1(待审批)。创建时检查同一订单不能重复退款(已拒绝的除外)。 +- **I-3 审批通过后的佣金全额回扣**:审批通过后,自动查找该订单产生的所有已入账佣金记录,全额从各代理的佣金钱包中扣减(允许余额为负),写入交易流水。 +- **I-4 审批通过后的资产处理**:审批通过后,自动失效该资产所有套餐、停机(单卡调停机接口、设备调停设备接口停所有绑定卡)、世代重置(generation+1、累计充值清零、asset_status 回到"在库"、清理客户绑定和资产钱包),参照换货模块逻辑。最后更新订单状态为已退款。 + +## Capabilities + +### New Capabilities + +- `refund-request`: 退款申请数据模型和状态流转 +- `refund-api`: 退款审批流程的 7 个 API 接口(仅平台账号可操作) +- `refund-commission-deduct`: 退款审批通过后的佣金全额回扣机制 +- `refund-asset-reset`: 退款审批通过后的资产处理(套餐失效 + 停机 + 世代重置 + 订单状态更新) + +### Modified Capabilities + +_无需修改现有能力的接口签名。需在 `package/activation_service.go` 中新增 `InvalidateAllPackagesByAsset` 方法。_ + +## Impact + +- **新增文件**:`internal/model/refund.go`、`internal/model/dto/refund_dto.go`、`internal/store/postgres/refund_store.go`、`internal/service/refund/service.go`、`internal/handler/admin/refund.go`、路由注册文件 `internal/routes/refund.go` +- **修改文件**:`internal/bootstrap/stores.go`、`internal/bootstrap/services.go`、`internal/bootstrap/handlers.go`、`internal/routes/routes.go`、`cmd/api/docs.go`、`cmd/gendocs/main.go`、`internal/service/package/activation_service.go`(新增方法) +- **DB 迁移**:新建 `tb_refund_request` 表 +- **新增常量**:退款状态常量(RefundStatusPending=1, Approved=2, Rejected=3, Returned=4)、交易类型 `AgentTransactionTypeCommissionDeduct`、关联业务类型 `ReferenceTypeRefund` +- **跨模块依赖**:退款 Service 需注入 orderStore、commissionRecordStore、agentWalletStore、agentWalletTransactionStore、stopResumeService、deviceService、packageActivationService、iotCardStore、deviceStore、assetWalletStore(参照换货模块的资产重置) +- **前端影响**:管理端需新增退款管理页面;佣金明细需展示 `commission_deduct` 类型流水 diff --git a/openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-api/spec.md b/openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-api/spec.md new file mode 100644 index 0000000..b969c2f --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-api/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: 发起退款申请接口 +`POST /api/admin/refunds` SHALL 创建退款申请。请求体 MUST 包含 `order_id`、`actual_received_amount`、`requested_refund_amount`、`refund_reason`。可选 `package_usage_id`。仅平台用户(user_type IN 1,2)可调用。 + +#### Scenario: 成功创建退款申请 +- **WHEN** 平台管理员提交合法的退款申请(关联已支付套餐订单且无进行中的退款记录) +- **THEN** 系统 SHALL 返回创建成功的退款记录,状态为待审批 + +#### Scenario: 订单不可退款 +- **WHEN** 管理员对非已支付订单、充值订单或已有进行中退款记录的订单发起退款 +- **THEN** 系统 SHALL 返回对应错误 + +### Requirement: 退款列表查询接口 +`GET /api/admin/refunds` SHALL 返回分页退款列表,支持按 `status`、`order_id`、`shop_id` 筛选。自动应用数据权限过滤(通过 `shop_id` 字段)。 + +#### Scenario: 按状态筛选退款列表 +- **WHEN** 管理员查询 `status=1` 的退款列表 +- **THEN** 系统 SHALL 返回所有待审批的退款记录,支持分页 + +### Requirement: 退款详情查询接口 +`GET /api/admin/refunds/:id` SHALL 返回退款单详情,包含关联订单信息。 + +#### Scenario: 查询退款详情 +- **WHEN** 管理员查询某退款单详情 +- **THEN** 系统 SHALL 返回退款单完整信息(含 `commission_deducted` 和 `asset_reset` 标记状态) + +### Requirement: 审批通过接口 +`POST /api/admin/refunds/:id/approve` SHALL 审批通过退款。请求体可选 `approved_refund_amount`(不填则等于 `requested_refund_amount`)和 `remark`。审批通过后异步触发资产处理和佣金全额回扣。 + +#### Scenario: 审批通过并触发副作用 +- **WHEN** 审批人通过退款申请 +- **THEN** 退款状态 SHALL 变为已通过,记录审批人和审批时间 +- **AND** 系统 SHALL 异步执行:失效所有套餐 → 停机 → 世代重置资产 → 更新订单状态为已退款 → 标记 `asset_reset=true` +- **AND** 系统 SHALL 异步执行:全额回扣该订单所有佣金 → 标记 `commission_deducted=true` + +### Requirement: 审批拒绝接口 +`POST /api/admin/refunds/:id/reject` SHALL 拒绝退款。请求体 MUST 包含 `reject_reason`。 + +#### Scenario: 拒绝退款 +- **WHEN** 审批人拒绝退款且填写了拒绝原因 +- **THEN** 退款状态 SHALL 变为已拒绝 + +### Requirement: 退回申请接口 +`POST /api/admin/refunds/:id/return` SHALL 退回退款申请。请求体可选 `remark`。 + +#### Scenario: 退回退款申请 +- **WHEN** 审批人退回退款申请 +- **THEN** 退款状态 SHALL 变为已退回,申请人可重新提交 + +### Requirement: 重新提交接口 +`POST /api/admin/refunds/:id/resubmit` SHALL 重新提交被退回的退款申请。请求体可修改 `actual_received_amount`、`requested_refund_amount`、`refund_reason`。 + +#### Scenario: 重新提交退款申请 +- **WHEN** 申请人重新提交被退回的退款 +- **THEN** 退款状态 SHALL 从已退回变回待审批 diff --git a/openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-asset-reset/spec.md b/openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-asset-reset/spec.md new file mode 100644 index 0000000..9810bd7 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-asset-reset/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: 退款后套餐失效 +退款审批通过后,系统 SHALL 查找该订单关联资产(`iot_card_id` 或 `device_id`)的全部套餐使用记录(`status IN (0,1,2)` 待生效/生效中/已用完),批量更新为 `status=4`(已失效)。 + +#### Scenario: 失效单卡套餐 +- **WHEN** 退款订单为单卡购买,关联 iot_card_id +- **THEN** 系统 SHALL 将该卡所有 `status IN (0,1,2)` 的套餐使用记录更新为 `status=4` + +#### Scenario: 失效设备套餐 +- **WHEN** 退款订单为设备购买,关联 device_id +- **THEN** 系统 SHALL 将该设备所有 `status IN (0,1,2)` 的套餐使用记录更新为 `status=4` + +### Requirement: 退款后停机 +套餐失效后,系统 SHALL 对资产执行停机操作。 + +#### Scenario: 单卡停机 +- **WHEN** 退款订单为单卡购买 +- **THEN** 系统 SHALL 调用 `StopResumeService.ManualStopCard(iccid)` 停机 + +#### Scenario: 设备停机(停所有绑定卡) +- **WHEN** 退款订单为设备购买 +- **THEN** 系统 SHALL 调用 `DeviceService.StopDevice(deviceID)`,内部遍历所有绑定卡逐一停机 + +### Requirement: 退款后资产世代重置 +停机后,系统 SHALL 对资产执行世代重置(参照换货模块 `exchange/service.go` 的重置逻辑),使资产回到可重新销售状态,一次性佣金可重新触发。 + +#### Scenario: 单卡世代重置 +- **WHEN** 退款订单为单卡购买 +- **THEN** 系统 SHALL 更新 IoT 卡:`generation+1`、`asset_status=1(在库)`、`accumulated_recharge=0`、`first_commission_paid=false`、清零系列充值和触发标记,清理个人客户绑定(`tb_personal_customer_device`),删除旧资产钱包并创建新空钱包 + +#### Scenario: 设备世代重置 +- **WHEN** 退款订单为设备购买 +- **THEN** 系统 SHALL 更新设备:`generation+1`、`asset_status=1(在库)`、`accumulated_recharge=0`、`first_commission_paid=false`、清零系列充值和触发标记,清理个人客户绑定,删除旧资产钱包并创建新空钱包 + +### Requirement: 退款后订单状态更新 +资产处理完成后,系统 SHALL 将订单 `payment_status` 更新为 4(已退款)。 + +#### Scenario: 订单标记已退款 +- **WHEN** 资产处理全部成功 +- **THEN** 订单 `payment_status` SHALL 变为 4(已退款),退款单 `asset_reset` 标记为 `true` + +### Requirement: 资产处理失败容错 +资产处理 SHALL 在独立 Goroutine 中执行,失败不影响审批结果。 + +#### Scenario: 资产处理失败 +- **WHEN** 套餐失效/停机/世代重置过程中发生错误 +- **THEN** 审批结果 SHALL NOT 受影响(已通过),`asset_reset` 保持 `false`,记录 Error 日志(含 refund_id 和 error) +- **AND** 管理员可通过 `asset_reset=false` 筛选需手动处理的退款单 diff --git a/openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-commission-deduct/spec.md b/openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-commission-deduct/spec.md new file mode 100644 index 0000000..7e66cf4 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-commission-deduct/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: 退款佣金全额回扣 +退款审批通过后,系统 SHALL 查找该订单产生的所有已入账佣金记录(`tb_commission_record WHERE order_id=? AND status=1`,使用 `CommissionRecord` 模型的 `CommissionStatusReleased=1` 常量),全额从各代理佣金钱包(`wallet_type="commission"`)中扣减。扣减允许余额为负。 + +#### Scenario: 正常佣金全额回扣 +- **WHEN** 退款审批通过,该订单有 N 条已入账佣金记录 +- **THEN** 系统 SHALL 对每条佣金记录全额扣减 `deductAmount = commission.Amount`,从对应代理的佣金钱包扣减(使用 `GetCommissionWallet`,乐观锁更新),创建 `transaction_type="commission_deduct"` 的交易流水,全部完成后标记退款单 `commission_deducted=true` + +#### Scenario: 无佣金记录 +- **WHEN** 退款审批通过,但该订单无已入账佣金记录 +- **THEN** 系统 SHALL 直接标记 `commission_deducted=true`,不执行扣减 + +#### Scenario: 回扣执行失败 +- **WHEN** 佣金回扣过程中发生错误 +- **THEN** 审批结果 SHALL NOT 受影响(已通过),`commission_deducted` 保持 `false`,记录 Error 日志 + +### Requirement: 佣金回扣交易流水 +每次佣金扣减 SHALL 创建交易流水记录:`transaction_type` 为 `commission_deduct`,`reference_type` 为 `refund`,`reference_id` 为退款单 ID,金额为负值(`-commission.Amount`)。 + +#### Scenario: 交易流水格式 +- **WHEN** 佣金回扣成功执行 +- **THEN** `tb_agent_wallet_transaction` SHALL 新增记录:`shop_id=佣金所属代理, transaction_type=commission_deduct, amount=-commission.Amount, reference_type=refund, reference_id=退款单ID, remark=退款佣金回扣` + +### Requirement: 佣金钱包为负对提现的影响 +佣金钱包余额为负时,代理 SHALL NOT 能发起新的提现申请。后续新佣金入账会逐步补填负数。退款回扣时不考虑是否有正在审批的提现申请,正常扣减佣金钱包。 diff --git a/openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-request/spec.md b/openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-request/spec.md new file mode 100644 index 0000000..29f2606 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-refund-system/specs/refund-request/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: 退款申请数据模型 +系统 SHALL 提供 `tb_refund_request` 表存储退款申请,包含退款单号(唯一)、关联订单 ID、店铺 ID(数据权限过滤)、实收金额、申请退款金额、审批退款金额、状态、审批人、审批时间、佣金回扣标记、资产重置标记等字段。 + +#### Scenario: 创建退款申请 +- **WHEN** 平台管理员提交退款申请,填写订单 ID、实收金额、申请退款金额、退款原因 +- **THEN** 系统 SHALL 生成唯一退款单号(格式 RF+日期时间+随机数),创建 `status=1(待审批)` 的退款记录,`shop_id` 从关联订单自动读取 + +#### Scenario: 仅针对已支付套餐订单 +- **WHEN** 管理员对非已支付订单或充值订单发起退款 +- **THEN** 系统 SHALL 返回错误,拒绝创建 + +#### Scenario: 重复退款防护 +- **WHEN** 管理员对已有待审批/已通过/已退回退款记录的订单再次发起退款 +- **THEN** 系统 SHALL 返回错误,拒绝创建 +- **WHEN** 管理员对仅有已拒绝退款记录的订单重新发起退款 +- **THEN** 系统 SHALL 允许创建新的退款申请 + +### Requirement: 退款状态流转 +退款单状态 SHALL 按以下规则流转:待审批(1) → 已通过(2)/已拒绝(3)/已退回(4);已退回(4) → 待审批(1)。已通过和已拒绝为终态。 + +#### Scenario: 审批通过 +- **WHEN** 审批人对待审批退款单执行通过操作 +- **THEN** 状态 SHALL 变为 2(已通过),记录审批人和审批时间,异步触发资产处理和佣金回扣 + +#### Scenario: 审批拒绝 +- **WHEN** 审批人对待审批退款单执行拒绝操作,填写拒绝原因 +- **THEN** 状态 SHALL 变为 3(已拒绝),记录拒绝原因 + +#### Scenario: 退回重提 +- **WHEN** 审批人退回退款申请,申请人修改后重新提交 +- **THEN** 状态 SHALL 从 4(已退回)变回 1(待审批) + +#### Scenario: 非法状态变更被拒绝 +- **WHEN** 对已通过或已拒绝的退款单执行任何状态变更 +- **THEN** 系统 SHALL 返回错误,状态不变 + +### Requirement: 仅平台账号可操作 +退款的发起和审批 SHALL 限制为平台用户(user_type IN 1,2)。同一人可以既发起又审批。 diff --git a/openspec/changes/archive/2026-03-28-add-refund-system/tasks.md b/openspec/changes/archive/2026-03-28-add-refund-system/tasks.md new file mode 100644 index 0000000..324178c --- /dev/null +++ b/openspec/changes/archive/2026-03-28-add-refund-system/tasks.md @@ -0,0 +1,63 @@ +# 任务清单:add-refund-system + +> 退款功能从零建设,严格串行:数据模型 → Store → Service → Handler → 路由注册 → 文档。 +> 审批通过后需自动执行资产处理和佣金回扣,涉及跨模块依赖。 + +## 任务组 1:I-1 数据模型 + +- [x] 1.1 新建迁移文件 `migrations/000XXX_create_refund_request.up.sql`(编号接续现有最大值),创建 `tb_refund_request` 表,包含所有字段(参考 design.md 表设计,含 shop_id、processor_id、processed_at、reject_reason、remark、asset_reset 等) +- [x] 1.2 新建对应 `.down.sql`:`DROP TABLE IF EXISTS tb_refund_request;` +- [x] 1.3 新建 `internal/model/refund.go`,定义 `RefundRequest` GORM Model(使用 `gorm.Model` + `BaseModel` 嵌入),实现 `TableName()` 返回 `tb_refund_request`,所有字段显式指定 `gorm:"column:xxx"` 标签,注释使用中文 +- [x] 1.4 在 `pkg/constants/` 中新增以下常量: + - 退款状态:`RefundStatusPending=1, RefundStatusApproved=2, RefundStatusRejected=3, RefundStatusReturned=4` + - 交易类型:`AgentTransactionTypeCommissionDeduct = "commission_deduct"`(在 `pkg/constants/wallet.go` 的代理钱包交易类型分组中新增) + - 关联业务类型:`ReferenceTypeRefund = "refund"`(在 `pkg/constants/wallet.go` 的关联业务类型分组中新增) +- [x] 1.5 新建 `internal/model/dto/refund_dto.go`,定义请求/响应 DTO:`CreateRefundRequest`、`RejectRefundRequest`、`ResubmitRefundRequest`、`ApproveRefundRequest`、`ReturnRefundRequest`、`RefundListRequest`、`RefundResponse`、`RefundListResponse`。所有 DTO 字段需含 `description` 标签 +- [x] 1.6 执行迁移,通过 DBHub 确认表已创建 +- [x] 1.7 验证:`go build ./...` 编译通过 + +## 任务组 2:I-2 接口实现 + +- [x] 2.1 新建 `internal/store/postgres/refund_store.go`,实现 `Create`、`GetByID`、`List`(分页+筛选,含 `ApplyShopFilter` 数据权限过滤)、`Update` 方法 +- [x] 2.2 新建 `internal/service/refund/service.go`,实现 7 个业务方法:`Create`、`List`、`GetByID`、`Approve`、`Reject`、`Return`、`Resubmit`。每个状态变更方法 MUST 使用条件更新 `WHERE status = expected` 保证幂等。`Create` 方法 MUST 检查同一订单不能重复退款(已存在 status IN (1,2,4) 的退款记录时拒绝) +- [x] 2.3 退款单号生成:`generateRefundNo()` 格式 `RF` + 年月日时分秒 + 6位随机数,参考 `GenerateOrderNo` 实现 +- [x] 2.4 `Create` 方法中从关联订单读取 `seller_shop_id`(或根据 buyer_type/buyer_id 确定)填入退款单的 `shop_id` 字段 +- [x] 2.5 新建 `internal/handler/admin/refund.go`,实现 7 个 Handler 方法,遵循 Handler 只做参数解析+响应格式化的原则 +- [x] 2.6 新建 `internal/routes/refund.go`,注册 `/api/admin/refunds` 路由组(参考 `routes/commission.go` 的 Register 模式),路由中间件限制仅平台用户可访问 +- [x] 2.7 在 `internal/bootstrap/stores.go`、`services.go`、`handlers.go` 中注册 refund 模块: + - Store: `RefundStore` + - Service 依赖注入: `db`, `refundStore`, `orderStore`, `commissionRecordStore`, `agentWalletStore`, `agentWalletTransactionStore`, `stopResumeService`, `deviceService`, `packageActivationService`, `iotCardStore`, `deviceStore`, `assetWalletStore` + - Handler: `RefundHandler` +- [x] 2.8 在 `internal/routes/routes.go` 中调用 `registerRefundRoutes` +- [x] 2.9 更新 `cmd/api/docs.go` 和 `cmd/gendocs/main.go`,注册退款 Handler +- [x] 2.10 验证:`go build ./...` 编译通过 + +## 任务组 3:I-3 佣金全额回扣 + +- [x] 3.1 在 `internal/service/refund/service.go` 中新增私有方法 `deductAllCommission(ctx, refundID)` +- [x] 3.2 实现回扣逻辑:查退款单 → 查该订单所有已入账佣金记录(`WHERE order_id=? AND status=1`,使用 `CommissionStatusReleased` 常量)→ 对每条记录全额扣减对应代理的佣金钱包(`wallet_type="commission"`,使用 `GetCommissionWallet`)→ 使用乐观锁更新余额(`WHERE version=?`)→ 创建交易流水(`transaction_type="commission_deduct"`, `reference_type="refund"`, `reference_id=退款单ID`)→ 全部完成后标记 `commission_deducted=true` +- [x] 3.3 在 `Approve()` 方法中,事务提交成功后通过 Goroutine 调用 `deductAllCommission` +- [x] 3.4 回扣失败记录 Error 日志(含 refund_id 和 error),不影响审批结果 +- [x] 3.5 验证:`go build ./...` 编译通过 + +## 任务组 4:I-4 资产处理 + +- [x] 4.1 在 `internal/service/package/activation_service.go` 中新增公开方法 `InvalidateAllPackagesByAsset(ctx, assetType string, assetID uint) error`:查询 `status IN (0,1,2)` 的套餐使用记录(按 `iot_card_id` 或 `device_id` 查),批量更新为 `status=4`(已失效) +- [x] 4.2 在 `internal/service/refund/service.go` 中新增私有方法 `handleAssetReset(ctx, refundID)`,包含: + - a. 查退款单和关联订单,确定资产类型(单卡/设备)和资产ID + - b. 调用 `InvalidateAllPackagesByAsset` 失效所有套餐 + - c. 停机:单卡调 `stopResumeService.ManualStopCard(iccid)` / 设备调 `deviceService.StopDevice(deviceID)` + - d. 世代重置(参照 `exchange/service.go` 第 278-305 行):`generation+1`、`asset_status=1`、清零累计充值字段、清理个人客户绑定、删除旧资产钱包并创建新空钱包 + - e. 更新订单 `payment_status=4`(已退款) + - f. 标记退款单 `asset_reset=true` +- [x] 4.3 在 `Approve()` 方法中,事务提交成功后通过 Goroutine 调用 `handleAssetReset`(与佣金回扣独立执行) +- [x] 4.4 资产处理失败记录 Error 日志(含 refund_id 和 error),`asset_reset` 保持 `false`,不影响审批结果 +- [x] 4.5 验证:`go build ./...` 编译通过 + +## 收尾验证 + +- [x] 5.1 执行 `go build ./...`,确认全量编译通过 +- [x] 5.2 通过 DBHub 确认 `tb_refund_request` 表结构正确 +- [x] 5.3 通过 `rg "refund" internal/bootstrap/` 确认模块已注册 +- [x] 5.4 通过 `rg "commission_deduct" pkg/constants/` 确认新常量已添加 +- [x] 5.5 通过 `rg "InvalidateAllPackagesByAsset" internal/service/package/` 确认新方法已添加 diff --git a/openspec/changes/archive/2026-03-28-fix-business-completion/.openspec.yaml b/openspec/changes/archive/2026-03-28-fix-business-completion/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-business-completion/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/archive/2026-03-28-fix-business-completion/design.md b/openspec/changes/archive/2026-03-28-fix-business-completion/design.md new file mode 100644 index 0000000..046fec0 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-business-completion/design.md @@ -0,0 +1,65 @@ +## Context + +修正业务方案 v5 中 5 项业务逻辑补全,涉及佣金计算、订单查询、支付对接、订单创建、资产状态管理共 5 个模块。各项之间无强依赖,但 J-2(平台钱包代购)与 J-1(富友支付)涉及同一个文件 `order/service.go`,建议 J-2 先做避免合并冲突。 + +## Goals / Non-Goals + +**Goals:** +- 佣金链断裂可被平台发现(F-1) +- C端订单查询符合 Handler → Service 分层(F-4) +- 富友支付 JSAPI/MiniApp 可正常预下单(J-1) +- 平台可代用代理钱包购包(J-2) +- 卡/设备状态反映真实使用情况(J-3) + +**Non-Goals:** +- 不修改佣金计算核心算法 +- 不新增自动化测试 +- 不改动回调链路(已存在且正常工作) +- 不新建 DB 表(仅插入配置数据) + +## Decisions + +### 1. F-1 零额待审记录的触发点 + +**决策**:在 `calculateChainOneTimeCommission` 和类似链式计算函数中,当 `getCostPrice` 返回零/无配置时,`break` 之前创建记录。 + +**原因**:这是佣金链唯一的断裂点——上游代理缺少套餐系列授权。在此处插入最精准。 + +记录写入失败不阻断主流程(`_ = store.Create()`),仅记 Error 日志。 + +### 2. F-4 迁移范围 + +**决策**:仅迁移 `ListOrders` 和 `GetOrderDetail` 两个查询方法到 Service 层。不重构其他正常工作的 Handler 方法。 + +**原因**:最小改动原则。其他 Handler 方法如果没有绕层问题则不动。 + +迁移后 Handler 中删除 `h.db` 字段引用和 `SkipPermissionCtx` 用法。 + +### 3. J-1 富友客户端构造方式 + +**决策**:每次调用时从 `wechatConfigStore.GetActive()` 读配置并构造 `fuiou.Client`,不做全局单例缓存。 + +**原因**:配置可以在管理端切换(activate/deactivate),缓存可能导致读到旧配置。构造成本很低(纯内存操作),无需优化。 + +**替代方案**:全局单例 + 配置变更时刷新 → 增加复杂度,收益不大,拒绝。 + +### 4. J-2 平台代购的身份伪装 + +**决策**:平台代购时,`orderBuyerType` 设为 `agent`,`orderBuyerID` 设为 `*resourceShopID`。成本价和钱包都使用资产所属代理的。 + +**原因**:从订单归属和佣金计算角度,这笔订单等效于"该代理用自己钱包买的",只是操作人是平台。 + +### 5. J-3 状态更新时机 + +**决策**: +- `status → 3`:在 `PackageActivationHandler` / `ActivateByRealname` 激活套餐成功后,检查 `card.Status < 3` 则更新为 3 +- `status → 4`:在套餐到期处理逻辑中(`OrderExpireHandler` 或类似),当卡的最后一个 active 套餐过期时更新为 4 + +**原因**:与套餐生命周期绑定最自然——有活跃套餐就是"已激活",无活跃套餐就是"已停用"。 + +## Risks / Trade-offs + +- **[F-1 零额记录堆积]** 如果某代理长期缺配置,会产生大量 status=99 记录 → 平台应定期处理待审记录(这本身就是 F-1 的目的) +- **[F-4 SkipPermissionCtx 移除]** 迁移后 Service 层查询会经过数据权限过滤 → 需确保 C端用户的数据权限配置正确,否则可能查不到数据 +- **[J-1 凭证安全]** 富友私钥存在 DB → 当前 `wechat_config` 表已有类似敏感数据(微信支付密钥),风险等级一致 +- **[J-3 并发更新]** 多个套餐同时激活可能并发更新 status → 使用条件更新 `WHERE status < 3` 避免覆盖 diff --git a/openspec/changes/archive/2026-03-28-fix-business-completion/proposal.md b/openspec/changes/archive/2026-03-28-fix-business-completion/proposal.md new file mode 100644 index 0000000..46c51f2 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-business-completion/proposal.md @@ -0,0 +1,33 @@ +## Why + +修正业务方案审查后,确认 5 项业务逻辑补全仍未完成:佣金链断裂静默跳过、C端订单 Handler 绕过 Service 层直接查 DB、富友支付留桩未实现、平台无法代用代理钱包购包、卡/设备激活后状态字段未更新。这些问题直接影响业务正确性:佣金链断裂导致平台无法发现缺配置的代理、Handler 绕层违反架构约束、富友支付无法上线、平台运营受限、资产状态不反映真实使用情况。 + +## What Changes + +- **F-1 佣金链断裂改为零额待审记录**:`commission_calculation/service.go` 中佣金链断裂(代理缺少套餐系列授权)时,不再静默 `break`,改为创建 `amount=0, status=99(待人工修正)` 的佣金记录并记录告警日志,让平台管理员能发现并处理。新增常量 `CommissionStatusPendingReview = 99`。 +- **F-4 C端订单 Handler 迁移到 Service 层**:`internal/handler/app/client_order.go` 中直接 `h.db.WithContext(resolved.SkipPermissionCtx)` 查询数据库的代码,迁移到 `internal/service/client_order/service.go` 新增 `ListOrders` / `GetOrderDetail` 方法,Handler 改为调用 Service。 +- **J-1 富友支付实现**:`order/service.go` 中 `FuiouPayJSAPI` 和 `FuiouPayMiniApp` 从留桩(返回"暂未实现")改为真实调用 `pkg/fuiou/` SDK 完成预下单。回调链路已存在,无需修改。需在 DB 插入富友支付凭证配置。 +- **J-2 平台钱包代购**:`order/service.go:CreateLegacy` 的钱包支付分支,新增支持平台操作人用某代理钱包给该代理名下资产购包的场景(`buyerType="" && resourceShopID != nil`)。 +- **J-3 卡/设备 status 3/4 业务触发**:套餐激活成功后自动将 `iot_card.status` / `device.status` 更新为 3(已激活);最后一个 active 套餐到期后更新为 4(已停用)。 + +## Capabilities + +### New Capabilities + +- `commission-pending-review`: 佣金链断裂时创建零额待审记录,支持按 status=99 筛选 + +### Modified Capabilities + +- `client-order-purchase`: C端订单查询迁移到 Service 层,删除 Handler 直接 DB 访问 +- `fuiou-payment`: 从留桩改为真实实现 JSAPI 和 MiniApp 预下单 +- `admin-order-creation`: 平台钱包代购场景支持 +- `asset-lifecycle-status`: 卡/设备 status 字段 3(已激活)和 4(已停用)的自动触发 + +## Impact + +- **新增文件**:无(`commission-pending-review` 的逻辑内嵌到现有 `commission_calculation/service.go`) +- **修改文件**:`internal/service/commission_calculation/service.go`、`internal/handler/app/client_order.go`、`internal/service/client_order/service.go`、`internal/service/order/service.go`、`internal/service/package/activation_service.go`(或套餐激活相关文件) +- **新增常量**:`CommissionStatusPendingReview = 99` +- **DB 操作**:需插入富友支付凭证到 `tb_wechat_config`(J-1) +- **无 DB 迁移**(schema 不变) +- **前端影响**:佣金记录列表需支持 `status=99` 筛选(F-1) diff --git a/openspec/changes/archive/2026-03-28-fix-business-completion/specs/admin-order-creation/spec.md b/openspec/changes/archive/2026-03-28-fix-business-completion/specs/admin-order-creation/spec.md new file mode 100644 index 0000000..07f4f2f --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-business-completion/specs/admin-order-creation/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: 平台钱包代购支持 +后台创建订单时,平台操作人 SHALL 可以使用某代理的钱包,以该代理的成本价,给该代理名下的资产购买套餐。条件:`buyerType == ""(平台)`且 `resourceShopID != nil`(资产归属代理)。 + +#### Scenario: 平台代用代理钱包购包 +- **WHEN** 平台操作人提交订单,`payment_method=wallet`,资产归属代理 A +- **THEN** 系统 SHALL 以代理 A 的成本价作为订单金额,从代理 A 的钱包扣款,订单 `buyer_type` 记录为 `agent`,`buyer_id` 为代理 A 的 shop_id + +#### Scenario: 无归属代理时拒绝 +- **WHEN** 平台操作人提交钱包支付订单,但资产无归属代理(`resourceShopID == nil`) +- **THEN** 系统 SHALL 返回 `CodeInvalidParam` 错误 diff --git a/openspec/changes/archive/2026-03-28-fix-business-completion/specs/asset-lifecycle-status/spec.md b/openspec/changes/archive/2026-03-28-fix-business-completion/specs/asset-lifecycle-status/spec.md new file mode 100644 index 0000000..03b0d50 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-business-completion/specs/asset-lifecycle-status/spec.md @@ -0,0 +1,27 @@ +## MODIFIED Requirements + +### Requirement: 激活后状态更新为已激活 +套餐激活成功后,系统 SHALL 将对应 IoT 卡或设备的 `status` 更新为 3(已激活),前提是当前 `status < 3`。 + +#### Scenario: 首次套餐激活触发状态变更 +- **WHEN** 某卡的套餐首次激活成功,且 `iot_card.status < 3` +- **THEN** 系统 SHALL 更新 `iot_card.status = 3` + +#### Scenario: 设备套餐激活触发状态变更 +- **WHEN** 某设备的套餐首次激活成功,且 `device.status < 3` +- **THEN** 系统 SHALL 更新 `device.status = 3` + +#### Scenario: 已激活状态不被覆盖 +- **WHEN** 套餐激活成功,但 `status` 已经 >= 3 +- **THEN** 系统 SHALL NOT 修改 `status` 字段 + +### Requirement: 最后套餐过期后状态更新为已停用 +当某卡/设备的最后一个 active 套餐过期后,系统 SHALL 将 `status` 更新为 4(已停用)。 + +#### Scenario: 最后套餐过期 +- **WHEN** 套餐到期处理后,该卡/设备名下无任何 `status=active` 的 `package_usage` 记录 +- **THEN** 系统 SHALL 更新 `iot_card.status = 4` 或 `device.status = 4` + +#### Scenario: 还有其他活跃套餐 +- **WHEN** 某套餐过期,但该卡/设备名下仍有其他 `status=active` 的套餐 +- **THEN** 系统 SHALL NOT 修改 `status` 字段 diff --git a/openspec/changes/archive/2026-03-28-fix-business-completion/specs/client-order-purchase/spec.md b/openspec/changes/archive/2026-03-28-fix-business-completion/specs/client-order-purchase/spec.md new file mode 100644 index 0000000..7ceeca2 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-business-completion/specs/client-order-purchase/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: C端订单查询通过 Service 层 +C端订单列表和订单详情查询 SHALL 通过 `client_order/service.go` 的 Service 方法执行,Handler 层 SHALL NOT 直接访问数据库或使用 `SkipPermissionCtx`。 + +#### Scenario: C端查询订单列表 +- **WHEN** C端客户请求订单列表 +- **THEN** Handler SHALL 调用 `service.ListOrders(ctx, req)`,Service 内部执行数据库查询并应用数据权限过滤 + +#### Scenario: C端查询订单详情 +- **WHEN** C端客户请求订单详情 +- **THEN** Handler SHALL 调用 `service.GetOrderDetail(ctx, orderID)`,Service 内部验证权限并返回数据 diff --git a/openspec/changes/archive/2026-03-28-fix-business-completion/specs/commission-pending-review/spec.md b/openspec/changes/archive/2026-03-28-fix-business-completion/specs/commission-pending-review/spec.md new file mode 100644 index 0000000..6d8dce5 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-business-completion/specs/commission-pending-review/spec.md @@ -0,0 +1,15 @@ +## ADDED Requirements + +### Requirement: 佣金链断裂创建待审记录 +当佣金链式计算过程中,某代理缺少套餐系列授权导致链路断裂时,系统 SHALL 创建一条 `amount=0, status=99` 的佣金记录,而非静默跳过。该记录的 `remark` SHALL 包含断裂原因(套餐系列 ID + 代理 ID)。 + +#### Scenario: 代理缺少套餐系列授权 +- **WHEN** 佣金链计算到某代理,该代理未被分配对应套餐系列的成本价 +- **THEN** 系统 SHALL 创建 `CommissionRecord{amount: 0, status: CommissionStatusPendingReview(99), remark: "套餐系列[{id}]未分配给该代理,请人工核查"}`,记录 Warn 日志,然后 break 链路 + +### Requirement: 待审佣金记录可筛选 +佣金记录列表接口 SHALL 支持按 `status=99` 筛选待人工修正的记录。 + +#### Scenario: 平台管理员筛选待审记录 +- **WHEN** 管理员查询佣金记录列表且传入 `status=99` +- **THEN** 返回所有待人工修正的佣金记录 diff --git a/openspec/changes/archive/2026-03-28-fix-business-completion/specs/fuiou-payment/spec.md b/openspec/changes/archive/2026-03-28-fix-business-completion/specs/fuiou-payment/spec.md new file mode 100644 index 0000000..1e63a0a --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-business-completion/specs/fuiou-payment/spec.md @@ -0,0 +1,19 @@ +## MODIFIED Requirements + +### Requirement: 富友 JSAPI 预下单 +`FuiouPayJSAPI` SHALL 使用 `pkg/fuiou/` SDK 完成真实预下单,返回前端调起微信支付所需的参数(appId, timeStamp, nonceStr, package, signType, paySign)。SHALL NOT 再返回"暂未实现"错误。 + +#### Scenario: 公众号内富友支付 +- **WHEN** C端用户在微信公众号内发起订单支付,支付通道为富友 +- **THEN** 系统 SHALL 从 `tb_wechat_config` 读取激活的富友配置,调用 `fuiou.Client.WxPreCreate` 完成预下单,返回 JS-SDK 调起参数 + +#### Scenario: 富友配置未激活 +- **WHEN** 发起富友支付但 `tb_wechat_config` 中无 `provider_type='fuiou' AND is_active=true` 的配置 +- **THEN** 系统 SHALL 返回 `CodeFuiouPayFailed` 错误 + +### Requirement: 富友 MiniApp 预下单 +`FuiouPayMiniApp` SHALL 使用与 JSAPI 相同的流程,但 `tradeType` 传 `LETPAY`,`subAppid` 使用小程序 AppID。 + +#### Scenario: 小程序内富友支付 +- **WHEN** C端用户在微信小程序内发起订单支付,支付通道为富友 +- **THEN** 系统 SHALL 使用小程序 AppID 和 `LETPAY` 类型完成预下单 diff --git a/openspec/changes/archive/2026-03-28-fix-business-completion/tasks.md b/openspec/changes/archive/2026-03-28-fix-business-completion/tasks.md new file mode 100644 index 0000000..d737f89 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-business-completion/tasks.md @@ -0,0 +1,46 @@ +# 任务清单:fix-business-completion + +> 5 项业务逻辑补全。F-1、F-4、J-3 各自独立;J-1 和 J-2 涉及同一文件,建议 J-2 先做。 + +## 任务组 1:F-1 佣金链断裂改为零额待审记录 + +- [x] 1.1 在 `pkg/constants/` 中新增常量 `CommissionStatusPendingReview = 99`,注释"待人工修正(链路断裂,需平台处理)" +- [x] 1.2 在 `internal/service/commission_calculation/service.go` 中,找到佣金链断裂处(`getCostPrice` 返回无配置后 `break` 的位置),在 `break` 前创建零额待审记录:`CommissionRecord{OrderID, ShopID, SeriesID, Amount: 0, Status: CommissionStatusPendingReview, Remark: "套餐系列[%d]未分配给该代理,请人工核查"}` +- [x] 1.3 记录失败不阻断主流程(`_ = store.Create()`),同时记录 Warn 日志 +- [x] 1.4 确认佣金记录列表查询(`commission_record` 相关 Store/Service)已支持按 status 筛选(如已有 status 过滤则无需改动) +- [x] 1.5 验证:`go build ./...` 编译通过 + +## 任务组 2:F-4 C端订单 Handler 迁移 Service 层 + +- [x] 2.1 在 `internal/service/client_order/service.go` 中新增 `ListOrders(ctx context.Context, req *dto.ClientOrderListRequest) (*dto.ClientOrderListResponse, error)` 方法,将 Handler 中的 DB 查询逻辑迁移过来,使用正常数据权限(不用 SkipPermissionCtx) +- [x] 2.2 在 `internal/service/client_order/service.go` 中新增 `GetOrderDetail(ctx context.Context, orderID uint) (*dto.ClientOrderDetailResponse, error)` 方法 +- [x] 2.3 修改 `internal/handler/app/client_order.go`,将直接 DB 查询替换为 Service 方法调用,删除 `h.db` 字段引用和 `SkipPermissionCtx` 用法 +- [x] 2.4 如果 Handler struct 中 `db` 字段因此变为未使用,从 struct 和构造函数中移除 +- [x] 2.5 验证:`rg "SkipPermissionCtx" internal/handler/app/client_order.go` 无残留,`go build ./...` 编译通过 + +## 任务组 3:J-2 平台钱包代购 + +- [x] 3.1 在 `internal/service/order/service.go:CreateLegacy` 的钱包支付分支中,新增 `buyerType == "" && resourceShopID != nil` 的条件分支 +- [x] 3.2 该分支内:`orderBuyerType = model.BuyerTypeAgent`,`orderBuyerID = *resourceShopID`,成本价和钱包都使用 `*resourceShopID` 对应的代理 +- [x] 3.3 无归属代理时(`resourceShopID == nil`)返回 `errors.New(errors.CodeInvalidParam, "不支持的钱包支付场景")` +- [x] 3.4 验证:`go build ./...` 编译通过 + +## 任务组 4:J-1 富友支付实现 + +- [x] 4.1 在 `internal/model/dto/` 中新增(或确认已存在)`FuiouPayJSAPIResponse` DTO:`AppID, TimeStamp, NonceStr, Package, SignType, PaySign` +- [x] 4.2 修改 `internal/service/order/service.go:FuiouPayJSAPI`,替换留桩为真实实现:查订单 → 校验状态 → 加载富友配置 → 构造 `fuiou.Client` → 调用 `WxPreCreate(JSAPI)` → 返回 JS-SDK 参数 +- [x] 4.3 修改 `internal/service/order/service.go:FuiouPayMiniApp`,与 JSAPI 相同,`tradeType` 改为 `LETPAY`,`subAppid` 改为 `config.MiniappAppID` +- [ ] 4.4 通过 DBHub 向 `tb_wechat_config` 插入富友支付凭证数据(`provider_type='fuiou'`,凭证见方案文档 J-1 节) +- [x] 4.5 验证:`rg "暂未实现" internal/service/order/service.go` 确认富友相关留桩已全部替换,`go build ./...` 编译通过 + +## 任务组 5:J-3 卡/设备 status 3/4 触发 + +- [x] 5.1 确认 `pkg/constants/iot.go` 中存在 `IotCardStatusActivated = 3` 和 `IotCardStatusDeactivated = 4`(或类似常量),不存在则新增 +- [x] 5.2 在套餐激活成功后(`activation_service.go` 或 `PackageActivationHandler`),追加条件更新:`UPDATE tb_iot_card SET status = 3 WHERE id = ? AND status < 3` +- [x] 5.3 如果是设备套餐,同时更新 `tb_device`:`UPDATE tb_device SET status = 3 WHERE id = ? AND status < 3` +- [x] 5.4 在套餐到期处理逻辑中(`OrderExpireHandler` 或类似),套餐过期后检查该卡/设备是否还有其他 active 套餐,无则更新 `status = 4` +- [x] 5.5 验证:`go build ./...` 编译通过 + +## 收尾验证 + +- [x] 6.1 执行 `go build ./...`,确认全量编译通过 diff --git a/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/.openspec.yaml b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/.openspec.yaml new file mode 100644 index 0000000..65bf7c9 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-28 diff --git a/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/design.md b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/design.md new file mode 100644 index 0000000..e04220e --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/design.md @@ -0,0 +1,85 @@ +## Context + +修正业务方案 v5 审查后,确认 5 处代码质量问题仍存在。这些问题分散在不同模块,互不依赖,可以并行修复。每个修复点改动面小(1-3 个文件),无需 DB 迁移,无需新增依赖。 + +当前代码现状: +- `internal/task/sim.go`:废弃的 SIM 状态同步任务,消费者核心是 `time.Sleep`,生产者全局无调用 +- `internal/service/asset/service.go`:至少 2 处 `cards, _ :=` 吞掉错误 +- `internal/handler/admin/order.go`:钱包支付未校验用户类型,Service 层有校验但 Handler 层没有 +- `internal/bootstrap/services.go`:`SetStopResumeCallback` / `SetResumeCallback` 未被调用 +- `internal/service/client_order/service.go`:`orderStatusToClientStatus()` 做了不必要的枚举映射 + +## Goals / Non-Goals + +**Goals:** +- 清除已确认的废弃代码(F-2) +- 修复错误吞没,提升可观测性(F-3) +- 统一 Handler/Service 权限校验(F-5) +- 激活停复机回调链路(F-6) +- 统一 C端/管理端支付状态枚举(J-4) +- 所有修改通过 `go build ./...` + +**Non-Goals:** +- 不做任何业务逻辑变更 +- 不新增自动化测试(遵循项目测试禁令) +- 不触碰已正常工作的代码路径 +- 不做 DB 迁移 + +## Decisions + +### 1. F-2 废弃代码删除顺序 + +**决策**:按依赖倒序删除——先删注册行,再删常量,最后删文件。 + +**原因**:如果先删文件,`go build` 会在注册行报错。倒序删除确保每步都能编译通过。 + +删除清单: +1. `pkg/queue/handler.go` — 删除 `HandleSIMStatusSync` 注册行和相关 import +2. `pkg/constants/constants.go` — 删除 `TaskTypeSIMStatusSync` 常量 +3. `internal/task/sim.go` — 删除整个文件 +4. `internal/service/sync/service.go` — 检查是否存在,存在则删除 + +### 2. F-3 错误处理策略 + +**决策**:记录 Warn 日志但不中断主流程。 + +**原因**:这些查询是补充信息(获取绑定卡详情),失败不应阻断资产查询主链路。但吞掉错误会导致排查困难,日志记录是最小侵入的改进。 + +```go +// 修改前 +cards, _ := s.iotCardStore.GetByIDs(ctx, cardIDs) + +// 修改后 +cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs) +if err != nil { + s.logger.Warn("查询绑定卡信息失败,结果可能不完整", + zap.Uints("card_ids", cardIDs), + zap.Error(err)) +} +``` + +### 3. F-5 Handler 层权限校验位置 + +**决策**:在 Handler 的 `BodyParser` 之后、调用 Service 之前添加校验。 + +**原因**:保持 Handler 层"参数验证 + 权限前置"的职责边界,与项目其他 Handler 一致。 + +### 4. F-6 回调注入时机 + +**决策**:在 `bootstrap/services.go` 所有 Service 初始化完成后,统一执行回调注入。 + +**原因**:回调注入需要 `usageService`、`stopResumeService`、`activationService` 都已创建。放在初始化最后一步最安全。 + +### 5. J-4 枚举统一方向 + +**决策**:C端直接使用管理端枚举(1/2/3/4),删除映射函数。 + +**替代方案**:管理端改用 C端枚举(0/1/2) — 拒绝,因为管理端已有大量数据使用 1/2/3/4,改动面更大。 + +**前端影响**:C端前端需更新状态解析,这是 BREAKING CHANGE,需通知前端团队。 + +## Risks / Trade-offs + +- **[F-2 遗漏引用]** 删除代码后可能有隐藏引用 → 通过 `go build ./...` 和 `rg TaskTypeSIMStatusSync` 双重验证 +- **[J-4 前端不同步]** C端前端未及时更新枚举 → 需在发布前通知前端,建议同版本发布 +- **[F-6 回调循环依赖]** Service 之间互相注入回调可能产生循环 → 当前 `SetXxxCallback` 是单向注入(UsageService → StopResumeService),不存在循环 diff --git a/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/proposal.md b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/proposal.md new file mode 100644 index 0000000..edaed76 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/proposal.md @@ -0,0 +1,32 @@ +## Why + +修正业务方案审查后,发现 5 处代码质量问题仍未修复:废弃代码残留、错误被吞、权限前置校验缺失、停复机回调未注入、C端状态枚举映射冗余。这些问题不影响核心链路运行,但会导致:排查困难(错误被吞)、文档/代码不一致(废弃代码)、潜在越权(权限缺失)、联动功能失效(回调未注入)。趁当前无大功能并行,集中清理。 + +## What Changes + +- **F-2 删除废弃 SIM 状态同步代码**:删除 `internal/task/sim.go` 整个文件、`pkg/queue/handler.go` 中的注册行、`pkg/constants/constants.go` 中的 `TaskTypeSIMStatusSync` 常量。该功能已被轮询系统完全覆盖,消费者核心是 `time.Sleep`,生产者全局零调用。 +- **F-3 修复资产服务错误吞没**:`internal/service/asset/service.go` 中至少 2 处 `cards, _ := s.iotCardStore.GetByIDs(...)` 吞掉了数据库错误,改为记录日志但不中断主流程。 +- **F-5 后台订单 Handler 钱包权限补齐**:`internal/handler/admin/order.go` 创建订单时,钱包支付缺少"仅代理可用"的前置校验,与 Service 层的校验不一致,补齐 Handler 层拦截。 +- **F-6 停复机回调注入**:`internal/bootstrap/` 初始化时未调用 `SetStopResumeCallback` / `SetResumeCallback`,导致停复机后套餐联动回调不触发。在 bootstrap 初始化完成后补充调用。 +- **J-4 删除 C端支付状态映射函数**:`internal/service/client_order/service.go` 中 `orderStatusToClientStatus()` 将管理端 1/2/3/4 映射为 C端 0/1/2,造成前后端枚举不一致。删除该函数,C端直接使用管理端枚举。**BREAKING**:C端前端需同步更新状态枚举解析(1=待支付, 2=已支付, 3=已取消, 4=已退款)。 + +## Capabilities + +### New Capabilities + +_无新增能力,全部为现有代码的修正和清理。_ + +### Modified Capabilities + +- `asset-queries`: 修复错误吞没,查询失败时记录日志 +- `asset-suspend-resume`: 注入停复机回调,停复机后套餐联动生效 +- `admin-order-creation`: Handler 层补齐钱包支付权限前置校验 +- `client-order-purchase`: 移除 C端支付状态映射,统一枚举 + +## Impact + +- **代码删除**:`internal/task/sim.go`、`internal/service/sync/service.go`(如存在)、相关常量和注册行 +- **修改文件**:`internal/service/asset/service.go`、`internal/handler/admin/order.go`、`internal/bootstrap/services.go`、`internal/service/client_order/service.go`、`pkg/queue/handler.go`、`pkg/constants/constants.go` +- **前端影响**:C端需将支付状态枚举从 0/1/2 改为 1/2/3/4(J-4,BREAKING) +- **无 DB 迁移** +- **无新增依赖** diff --git a/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/admin-order-creation/spec.md b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/admin-order-creation/spec.md new file mode 100644 index 0000000..8525f4d --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/admin-order-creation/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: 后台订单钱包支付权限校验 +后台创建订单时,若支付方式为钱包支付,Handler 层 SHALL 校验当前操作人为代理账号(`user_type = agent`)。非代理账号使用钱包支付 SHALL 在 Handler 层即被拦截,返回参数错误。 + +#### Scenario: 非代理账号使用钱包支付被拦截 +- **WHEN** 平台用户或企业用户提交订单且 `payment_method = wallet` +- **THEN** Handler SHALL 返回 `CodeInvalidParam` 错误,提示"仅代理账号可使用钱包支付",请求不会到达 Service 层 + +#### Scenario: 代理账号使用钱包支付正常通过 +- **WHEN** 代理账号提交订单且 `payment_method = wallet` +- **THEN** Handler SHALL 放行,由 Service 层继续处理钱包扣款逻辑 diff --git a/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/asset-queries/spec.md b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/asset-queries/spec.md new file mode 100644 index 0000000..e372ba6 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/asset-queries/spec.md @@ -0,0 +1,8 @@ +## MODIFIED Requirements + +### Requirement: 资产查询错误处理 +资产查询 Service 在获取绑定卡信息时,数据库查询错误 SHALL 被记录到日志而非被静默忽略。查询失败 SHALL NOT 中断主查询流程,但 SHALL 记录 Warn 级别日志,包含失败的卡 ID 列表和错误详情。 + +#### Scenario: 绑定卡查询失败时记录日志 +- **WHEN** `iotCardStore.GetByIDs()` 返回错误 +- **THEN** 系统 SHALL 记录 Warn 日志(含 card_ids 和 error),继续返回已有数据,不返回错误给调用方 diff --git a/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/asset-suspend-resume/spec.md b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/asset-suspend-resume/spec.md new file mode 100644 index 0000000..fb976bb --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/asset-suspend-resume/spec.md @@ -0,0 +1,12 @@ +## MODIFIED Requirements + +### Requirement: 停复机回调注入 +系统启动时 SHALL 在 bootstrap 阶段注入停复机回调,确保停机/复机操作完成后自动触发套餐联动逻辑(暂停/恢复套餐计时)。 + +#### Scenario: 系统启动后回调已注入 +- **WHEN** API 或 Worker 服务完成 bootstrap 初始化 +- **THEN** `usageService.SetStopResumeCallback(stopResumeService)` 和 `activationService.SetResumeCallback(stopResumeService)` SHALL 已被调用 + +#### Scenario: 停机触发套餐暂停 +- **WHEN** 管理员对卡执行停机操作且回调已注入 +- **THEN** 停机成功后 SHALL 自动触发套餐暂停联动逻辑 diff --git a/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/client-order-purchase/spec.md b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/client-order-purchase/spec.md new file mode 100644 index 0000000..11f0908 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/specs/client-order-purchase/spec.md @@ -0,0 +1,14 @@ +## MODIFIED Requirements + +### Requirement: C端支付状态枚举统一 +C端订单查询接口返回的 `payment_status` SHALL 直接使用管理端统一枚举值(1=待支付, 2=已支付, 3=已取消, 4=已退款),不再通过映射函数转换为 0/1/2。 + +#### Scenario: C端查询订单返回统一枚举 +- **WHEN** C端客户查询订单列表或订单详情 +- **THEN** 返回的 `payment_status` SHALL 为 1/2/3/4 之一,与管理端一致 + +## REMOVED Requirements + +### Requirement: C端支付状态映射 +**Reason**: `orderStatusToClientStatus()` 函数将管理端 1/2/3/4 映射为 C端 0/1/2,造成前后端枚举不一致,增加维护负担。 +**Migration**: C端前端需更新支付状态枚举解析:1=待支付, 2=已支付, 3=已取消, 4=已退款。 diff --git a/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/tasks.md b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/tasks.md new file mode 100644 index 0000000..6c0ab94 --- /dev/null +++ b/openspec/changes/archive/2026-03-28-fix-code-quality-cleanup/tasks.md @@ -0,0 +1,48 @@ +# 任务清单:fix-code-quality-cleanup + +> 5 个独立修复项,互不依赖,可并行执行。每项完成后独立验证。 + +## 任务组 1:F-2 删除废弃 SIM 状态同步代码 + +- [x] 1.1 删除 `pkg/queue/handler.go` 中 `HandleSIMStatusSync` 的注册行和相关日志行 +- [x] 1.2 删除 `pkg/queue/handler.go` 中因 1.1 产生的无用 import(如有) +- [x] 1.3 删除 `pkg/constants/constants.go` 中 `TaskTypeSIMStatusSync` 常量定义 +- [x] 1.4 删除 `internal/task/sim.go` 整个文件 +- [x] 1.5 检查 `internal/service/sync/service.go` 是否存在,存在则删除 +- [x] 1.6 验证:`rg TaskTypeSIMStatusSync` 无残留引用,`rg HandleSIMStatusSync` 无残留引用 +- [x] 1.7 验证:`go build ./...` 编译通过 + +## 任务组 2:F-3 修复资产服务错误吞没 + +- [x] 2.1 修改 `internal/service/asset/service.go` 第 136 行附近:`cards, _ :=` 改为 `cards, err :=`,错误时记录 Warn 日志(含 `card_ids` 和 `error`),不中断主流程 +- [x] 2.2 修改 `internal/service/asset/service.go` 第 288 行附近:同上处理 +- [x] 2.3 全局搜索 `internal/service/asset/service.go` 中其他 `_, _ :=` 或 `, _ :=` 模式,确认无遗漏(额外修复了第 546 行 `packages, _ :=`) +- [x] 2.4 验证:`lsp_diagnostics` 无新增错误,`go build ./...` 编译通过 + +## 任务组 3:F-5 后台订单 Handler 钱包权限校验 + +- [x] 3.1 ~~新增钱包支付权限校验~~ **已回滚**:提案描述有误,平台可使用代理钱包帮代理客户购买套餐(场景2),原代码已正确,无需修改 +- [x] 3.2 验证:`go build ./...` 编译通过 + +## 任务组 4:F-6 停复机回调注入 + +- [x] 4.1 在 `internal/bootstrap/services.go` 中定位 `usageService`、`stopResumeService`、`activationService` 的初始化位置 +- [x] 4.2 在三者都初始化完成后,追加回调注入调用: + ```go + usageService.SetStopResumeCallback(stopResumeService) + activationService.SetResumeCallback(stopResumeService) + ``` +- [x] 4.3 如果是 Worker 服务也有独立初始化(`worker_services.go`),同样补充回调注入 +- [x] 4.4 验证:`rg SetStopResumeCallback internal/bootstrap/` 有命中,`go build ./...` 编译通过 + +## 任务组 5:J-4 删除 C端支付状态映射函数 + +- [x] 5.1 在 `internal/service/client_order/service.go` 中找到 `orderStatusToClientStatus()` 函数,记录其所有调用点 +- [x] 5.2 将所有调用点改为直接使用原始 `payment_status` 值(不再经过映射) +- [x] 5.3 删除 `orderStatusToClientStatus()` 函数定义 +- [x] 5.4 验证:`rg orderStatusToClientStatus` 无残留引用,`go build ./...` 编译通过 + +## 收尾验证 + +- [x] 6.1 执行 `go build ./...`,确认全量编译通过 +- [x] 6.2 执行 `rg "TaskTypeSIMStatusSync\|HandleSIMStatusSync\|orderStatusToClientStatus"` 确认无残留 diff --git a/openspec/specs/refund-api/spec.md b/openspec/specs/refund-api/spec.md new file mode 100644 index 0000000..b969c2f --- /dev/null +++ b/openspec/specs/refund-api/spec.md @@ -0,0 +1,56 @@ +## ADDED Requirements + +### Requirement: 发起退款申请接口 +`POST /api/admin/refunds` SHALL 创建退款申请。请求体 MUST 包含 `order_id`、`actual_received_amount`、`requested_refund_amount`、`refund_reason`。可选 `package_usage_id`。仅平台用户(user_type IN 1,2)可调用。 + +#### Scenario: 成功创建退款申请 +- **WHEN** 平台管理员提交合法的退款申请(关联已支付套餐订单且无进行中的退款记录) +- **THEN** 系统 SHALL 返回创建成功的退款记录,状态为待审批 + +#### Scenario: 订单不可退款 +- **WHEN** 管理员对非已支付订单、充值订单或已有进行中退款记录的订单发起退款 +- **THEN** 系统 SHALL 返回对应错误 + +### Requirement: 退款列表查询接口 +`GET /api/admin/refunds` SHALL 返回分页退款列表,支持按 `status`、`order_id`、`shop_id` 筛选。自动应用数据权限过滤(通过 `shop_id` 字段)。 + +#### Scenario: 按状态筛选退款列表 +- **WHEN** 管理员查询 `status=1` 的退款列表 +- **THEN** 系统 SHALL 返回所有待审批的退款记录,支持分页 + +### Requirement: 退款详情查询接口 +`GET /api/admin/refunds/:id` SHALL 返回退款单详情,包含关联订单信息。 + +#### Scenario: 查询退款详情 +- **WHEN** 管理员查询某退款单详情 +- **THEN** 系统 SHALL 返回退款单完整信息(含 `commission_deducted` 和 `asset_reset` 标记状态) + +### Requirement: 审批通过接口 +`POST /api/admin/refunds/:id/approve` SHALL 审批通过退款。请求体可选 `approved_refund_amount`(不填则等于 `requested_refund_amount`)和 `remark`。审批通过后异步触发资产处理和佣金全额回扣。 + +#### Scenario: 审批通过并触发副作用 +- **WHEN** 审批人通过退款申请 +- **THEN** 退款状态 SHALL 变为已通过,记录审批人和审批时间 +- **AND** 系统 SHALL 异步执行:失效所有套餐 → 停机 → 世代重置资产 → 更新订单状态为已退款 → 标记 `asset_reset=true` +- **AND** 系统 SHALL 异步执行:全额回扣该订单所有佣金 → 标记 `commission_deducted=true` + +### Requirement: 审批拒绝接口 +`POST /api/admin/refunds/:id/reject` SHALL 拒绝退款。请求体 MUST 包含 `reject_reason`。 + +#### Scenario: 拒绝退款 +- **WHEN** 审批人拒绝退款且填写了拒绝原因 +- **THEN** 退款状态 SHALL 变为已拒绝 + +### Requirement: 退回申请接口 +`POST /api/admin/refunds/:id/return` SHALL 退回退款申请。请求体可选 `remark`。 + +#### Scenario: 退回退款申请 +- **WHEN** 审批人退回退款申请 +- **THEN** 退款状态 SHALL 变为已退回,申请人可重新提交 + +### Requirement: 重新提交接口 +`POST /api/admin/refunds/:id/resubmit` SHALL 重新提交被退回的退款申请。请求体可修改 `actual_received_amount`、`requested_refund_amount`、`refund_reason`。 + +#### Scenario: 重新提交退款申请 +- **WHEN** 申请人重新提交被退回的退款 +- **THEN** 退款状态 SHALL 从已退回变回待审批 diff --git a/openspec/specs/refund-asset-reset/spec.md b/openspec/specs/refund-asset-reset/spec.md new file mode 100644 index 0000000..9810bd7 --- /dev/null +++ b/openspec/specs/refund-asset-reset/spec.md @@ -0,0 +1,49 @@ +## ADDED Requirements + +### Requirement: 退款后套餐失效 +退款审批通过后,系统 SHALL 查找该订单关联资产(`iot_card_id` 或 `device_id`)的全部套餐使用记录(`status IN (0,1,2)` 待生效/生效中/已用完),批量更新为 `status=4`(已失效)。 + +#### Scenario: 失效单卡套餐 +- **WHEN** 退款订单为单卡购买,关联 iot_card_id +- **THEN** 系统 SHALL 将该卡所有 `status IN (0,1,2)` 的套餐使用记录更新为 `status=4` + +#### Scenario: 失效设备套餐 +- **WHEN** 退款订单为设备购买,关联 device_id +- **THEN** 系统 SHALL 将该设备所有 `status IN (0,1,2)` 的套餐使用记录更新为 `status=4` + +### Requirement: 退款后停机 +套餐失效后,系统 SHALL 对资产执行停机操作。 + +#### Scenario: 单卡停机 +- **WHEN** 退款订单为单卡购买 +- **THEN** 系统 SHALL 调用 `StopResumeService.ManualStopCard(iccid)` 停机 + +#### Scenario: 设备停机(停所有绑定卡) +- **WHEN** 退款订单为设备购买 +- **THEN** 系统 SHALL 调用 `DeviceService.StopDevice(deviceID)`,内部遍历所有绑定卡逐一停机 + +### Requirement: 退款后资产世代重置 +停机后,系统 SHALL 对资产执行世代重置(参照换货模块 `exchange/service.go` 的重置逻辑),使资产回到可重新销售状态,一次性佣金可重新触发。 + +#### Scenario: 单卡世代重置 +- **WHEN** 退款订单为单卡购买 +- **THEN** 系统 SHALL 更新 IoT 卡:`generation+1`、`asset_status=1(在库)`、`accumulated_recharge=0`、`first_commission_paid=false`、清零系列充值和触发标记,清理个人客户绑定(`tb_personal_customer_device`),删除旧资产钱包并创建新空钱包 + +#### Scenario: 设备世代重置 +- **WHEN** 退款订单为设备购买 +- **THEN** 系统 SHALL 更新设备:`generation+1`、`asset_status=1(在库)`、`accumulated_recharge=0`、`first_commission_paid=false`、清零系列充值和触发标记,清理个人客户绑定,删除旧资产钱包并创建新空钱包 + +### Requirement: 退款后订单状态更新 +资产处理完成后,系统 SHALL 将订单 `payment_status` 更新为 4(已退款)。 + +#### Scenario: 订单标记已退款 +- **WHEN** 资产处理全部成功 +- **THEN** 订单 `payment_status` SHALL 变为 4(已退款),退款单 `asset_reset` 标记为 `true` + +### Requirement: 资产处理失败容错 +资产处理 SHALL 在独立 Goroutine 中执行,失败不影响审批结果。 + +#### Scenario: 资产处理失败 +- **WHEN** 套餐失效/停机/世代重置过程中发生错误 +- **THEN** 审批结果 SHALL NOT 受影响(已通过),`asset_reset` 保持 `false`,记录 Error 日志(含 refund_id 和 error) +- **AND** 管理员可通过 `asset_reset=false` 筛选需手动处理的退款单 diff --git a/openspec/specs/refund-commission-deduct/spec.md b/openspec/specs/refund-commission-deduct/spec.md new file mode 100644 index 0000000..7e66cf4 --- /dev/null +++ b/openspec/specs/refund-commission-deduct/spec.md @@ -0,0 +1,26 @@ +## ADDED Requirements + +### Requirement: 退款佣金全额回扣 +退款审批通过后,系统 SHALL 查找该订单产生的所有已入账佣金记录(`tb_commission_record WHERE order_id=? AND status=1`,使用 `CommissionRecord` 模型的 `CommissionStatusReleased=1` 常量),全额从各代理佣金钱包(`wallet_type="commission"`)中扣减。扣减允许余额为负。 + +#### Scenario: 正常佣金全额回扣 +- **WHEN** 退款审批通过,该订单有 N 条已入账佣金记录 +- **THEN** 系统 SHALL 对每条佣金记录全额扣减 `deductAmount = commission.Amount`,从对应代理的佣金钱包扣减(使用 `GetCommissionWallet`,乐观锁更新),创建 `transaction_type="commission_deduct"` 的交易流水,全部完成后标记退款单 `commission_deducted=true` + +#### Scenario: 无佣金记录 +- **WHEN** 退款审批通过,但该订单无已入账佣金记录 +- **THEN** 系统 SHALL 直接标记 `commission_deducted=true`,不执行扣减 + +#### Scenario: 回扣执行失败 +- **WHEN** 佣金回扣过程中发生错误 +- **THEN** 审批结果 SHALL NOT 受影响(已通过),`commission_deducted` 保持 `false`,记录 Error 日志 + +### Requirement: 佣金回扣交易流水 +每次佣金扣减 SHALL 创建交易流水记录:`transaction_type` 为 `commission_deduct`,`reference_type` 为 `refund`,`reference_id` 为退款单 ID,金额为负值(`-commission.Amount`)。 + +#### Scenario: 交易流水格式 +- **WHEN** 佣金回扣成功执行 +- **THEN** `tb_agent_wallet_transaction` SHALL 新增记录:`shop_id=佣金所属代理, transaction_type=commission_deduct, amount=-commission.Amount, reference_type=refund, reference_id=退款单ID, remark=退款佣金回扣` + +### Requirement: 佣金钱包为负对提现的影响 +佣金钱包余额为负时,代理 SHALL NOT 能发起新的提现申请。后续新佣金入账会逐步补填负数。退款回扣时不考虑是否有正在审批的提现申请,正常扣减佣金钱包。 diff --git a/openspec/specs/refund-request/spec.md b/openspec/specs/refund-request/spec.md new file mode 100644 index 0000000..29f2606 --- /dev/null +++ b/openspec/specs/refund-request/spec.md @@ -0,0 +1,40 @@ +## ADDED Requirements + +### Requirement: 退款申请数据模型 +系统 SHALL 提供 `tb_refund_request` 表存储退款申请,包含退款单号(唯一)、关联订单 ID、店铺 ID(数据权限过滤)、实收金额、申请退款金额、审批退款金额、状态、审批人、审批时间、佣金回扣标记、资产重置标记等字段。 + +#### Scenario: 创建退款申请 +- **WHEN** 平台管理员提交退款申请,填写订单 ID、实收金额、申请退款金额、退款原因 +- **THEN** 系统 SHALL 生成唯一退款单号(格式 RF+日期时间+随机数),创建 `status=1(待审批)` 的退款记录,`shop_id` 从关联订单自动读取 + +#### Scenario: 仅针对已支付套餐订单 +- **WHEN** 管理员对非已支付订单或充值订单发起退款 +- **THEN** 系统 SHALL 返回错误,拒绝创建 + +#### Scenario: 重复退款防护 +- **WHEN** 管理员对已有待审批/已通过/已退回退款记录的订单再次发起退款 +- **THEN** 系统 SHALL 返回错误,拒绝创建 +- **WHEN** 管理员对仅有已拒绝退款记录的订单重新发起退款 +- **THEN** 系统 SHALL 允许创建新的退款申请 + +### Requirement: 退款状态流转 +退款单状态 SHALL 按以下规则流转:待审批(1) → 已通过(2)/已拒绝(3)/已退回(4);已退回(4) → 待审批(1)。已通过和已拒绝为终态。 + +#### Scenario: 审批通过 +- **WHEN** 审批人对待审批退款单执行通过操作 +- **THEN** 状态 SHALL 变为 2(已通过),记录审批人和审批时间,异步触发资产处理和佣金回扣 + +#### Scenario: 审批拒绝 +- **WHEN** 审批人对待审批退款单执行拒绝操作,填写拒绝原因 +- **THEN** 状态 SHALL 变为 3(已拒绝),记录拒绝原因 + +#### Scenario: 退回重提 +- **WHEN** 审批人退回退款申请,申请人修改后重新提交 +- **THEN** 状态 SHALL 从 4(已退回)变回 1(待审批) + +#### Scenario: 非法状态变更被拒绝 +- **WHEN** 对已通过或已拒绝的退款单执行任何状态变更 +- **THEN** 系统 SHALL 返回错误,状态不变 + +### Requirement: 仅平台账号可操作 +退款的发起和审批 SHALL 限制为平台用户(user_type IN 1,2)。同一人可以既发起又审批。 diff --git a/pkg/queue/handler.go b/pkg/queue/handler.go index 09b11ec..0a3288c 100644 --- a/pkg/queue/handler.go +++ b/pkg/queue/handler.go @@ -81,6 +81,7 @@ func (h *Handler) registerIotCardImportHandler() { h.redis, h.workerResult.Stores.IotCardImportTask, h.workerResult.Stores.IotCard, + h.workerResult.Stores.AssetWallet, h.storage, h.pollingCallback, h.logger, @@ -98,6 +99,7 @@ func (h *Handler) registerDeviceImportHandler() { h.workerResult.Stores.Device, h.workerResult.Stores.DeviceSimBinding, h.workerResult.Stores.IotCard, + h.workerResult.Stores.AssetWallet, h.storage, h.logger, )