diff --git a/cmd/api/main.go b/cmd/api/main.go index f4de4ac..4e9aa68 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -42,8 +42,6 @@ func main() { // 3. 初始化日志 appLogger := initLogger(cfg) - // 4. 验证微信配置 - validateWechatConfig(cfg, appLogger) defer func() { _ = logger.Sync() }() @@ -352,20 +350,3 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client { return client } - -func validateWechatConfig(cfg *config.Config, appLogger *zap.Logger) { - wechatCfg := cfg.Wechat - - if wechatCfg.OfficialAccount.AppID == "" { - appLogger.Warn("微信公众号配置未设置,OAuth 相关功能将不可用") - return - } - - if wechatCfg.OfficialAccount.AppSecret == "" { - appLogger.Fatal("微信公众号配置不完整", - zap.String("missing", "app_secret"), - zap.String("env", "JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET")) - } - appLogger.Info("微信公众号配置已验证", - zap.String("app_id", wechatCfg.OfficialAccount.AppID)) -} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 5714b2d..b4bd065 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -22,9 +22,11 @@ version: '3.8' # # 可选配置(根据需要启用): # - Gateway 服务配置(JUNHONG_GATEWAY_*) -# - 微信公众号配置(JUNHONG_WECHAT_OFFICIAL_ACCOUNT_*) -# - 微信支付配置(JUNHONG_WECHAT_PAYMENT_*) # - 对象存储配置(JUNHONG_STORAGE_*) +# - 短信服务配置(JUNHONG_SMS_*) +# +# 微信公众号/小程序/支付配置已迁移至数据库(tb_wechat_config 表), +# 不再需要环境变量和证书文件挂载。 services: api: @@ -65,28 +67,8 @@ services: - JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0 - JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX - JUNHONG_GATEWAY_TIMEOUT=30 - # 微信公众号配置(可选) - # - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID=your_app_id - # - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET=your_app_secret - # - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN=your_token - # - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY=your_aes_key - # - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL=https://your-domain.com/callback - # 微信支付配置(可选) - # - JUNHONG_WECHAT_PAYMENT_APP_ID=your_app_id - # - JUNHONG_WECHAT_PAYMENT_MCH_ID=your_mch_id - # - JUNHONG_WECHAT_PAYMENT_API_V3_KEY=your_32_char_api_v3_key - # - JUNHONG_WECHAT_PAYMENT_API_V2_KEY=your_api_v2_key - # - JUNHONG_WECHAT_PAYMENT_CERT_PATH=/app/certs/apiclient_cert.pem - # - JUNHONG_WECHAT_PAYMENT_KEY_PATH=/app/certs/apiclient_key.pem - # - JUNHONG_WECHAT_PAYMENT_SERIAL_NO=your_serial_no - # - JUNHONG_WECHAT_PAYMENT_NOTIFY_URL=https://your-domain.com/api/callback/wechat-pay - # - JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG=false - # - JUNHONG_WECHAT_PAYMENT_TIMEOUT=30s volumes: - # 仅挂载日志目录(配置已嵌入二进制文件) - ./logs:/app/logs - # 微信支付证书目录(如果使用微信支付,需要挂载证书) - # - ./certs:/app/certs:ro networks: - junhong-network healthcheck: @@ -137,27 +119,8 @@ services: - JUNHONG_GATEWAY_APP_ID=60bgt1X8i7AvXqkd - JUNHONG_GATEWAY_APP_SECRET=BZeQttaZQt0i73moF - JUNHONG_GATEWAY_TIMEOUT=30 - # 微信公众号配置(可选) - # - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID=your_app_id - # - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET=your_app_secret - # - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN=your_token - # - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY=your_aes_key - # - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL=https://your-domain.com/callback - # 微信支付配置(可选) - # - JUNHONG_WECHAT_PAYMENT_APP_ID=your_app_id - # - JUNHONG_WECHAT_PAYMENT_MCH_ID=your_mch_id - # - JUNHONG_WECHAT_PAYMENT_API_V3_KEY=your_32_char_api_v3_key - # - JUNHONG_WECHAT_PAYMENT_API_V2_KEY=your_api_v2_key - # - JUNHONG_WECHAT_PAYMENT_CERT_PATH=/app/certs/apiclient_cert.pem - # - JUNHONG_WECHAT_PAYMENT_KEY_PATH=/app/certs/apiclient_key.pem - # - JUNHONG_WECHAT_PAYMENT_SERIAL_NO=your_serial_no - # - JUNHONG_WECHAT_PAYMENT_NOTIFY_URL=https://your-domain.com/api/callback/wechat-pay - # - JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG=false - # - JUNHONG_WECHAT_PAYMENT_TIMEOUT=30s volumes: - ./logs:/app/logs - # 微信支付证书目录(如果使用微信支付,需要挂载证书) - # - ./certs:/app/certs:ro networks: - junhong-network depends_on: diff --git a/docs/client-api-data-model-fixes/功能总结.md b/docs/client-api-data-model-fixes/功能总结.md index 3fb6daa..8c274af 100644 --- a/docs/client-api-data-model-fixes/功能总结.md +++ b/docs/client-api-data-model-fixes/功能总结.md @@ -17,7 +17,8 @@ - `validatePackages()` 价格累加同步修正,代理渠道额外校验 `RetailPrice >= CostPrice` - 分配创建(`shop_package_batch_allocation`、`shop_series_grant`)时自动设置 `RetailPrice = SuggestedRetailPrice` - 新增 cost_price 分配锁定:存在下级分配记录时禁止修改 `cost_price` -- `BatchUpdatePricing` 接口扩展支持 `pricing_target` 参数(`cost_price`/`retail_price`),默认 `cost_price` 保持向后兼容 +- `BatchUpdatePricing` 接口仅支持成本价批量调整(保留 cost_price 锁定规则) +- 新增独立接口 `PATCH /api/admin/packages/:id/retail-price`,代理可修改自己的套餐零售价 - `PackageResponse` 新增 `retail_price` 字段,利润计算修正为 `RetailPrice - CostPrice` **涉及文件**: diff --git a/docs/前端接口变更说明.md b/docs/前端接口变更说明.md new file mode 100644 index 0000000..d9f41ec --- /dev/null +++ b/docs/前端接口变更说明.md @@ -0,0 +1,1205 @@ +# 前端接口变更说明 + +> 最后更新:2026-03-19 + +--- + +## 目录 + +1. [接口变更概览](#一接口变更概览) +2. [删除的接口](#二删除的接口) +3. [修改的后台接口](#三修改的后台接口) +4. [新增的后台接口](#四新增的后台接口) +5. [新增的 C 端接口](#五新增的-c-端接口) +6. [认证方式说明](#六认证方式说明) +7. [统一响应格式](#七统一响应格式) + +--- + +## 一、接口变更概览 + +**C 端接口统一前缀**:`/api/c/v1/` +**后台接口统一前缀**:`/api/admin/` + +### 删除 + +| 接口 | 说明 | +|------|------| +| `POST /api/personal/login` | 旧个人客户登录,已下线 | +| `POST /api/personal/send-code` | 旧验证码发送,已下线 | +| `POST /api/personal/wechat-oauth-login` | 旧微信登录,已下线 | +| `POST /api/personal/bind-wechat` | 旧微信绑定,已下线 | +| `GET /api/personal/profile` | 旧个人信息,已下线 | +| `/api/h5/*` 下所有路由 | 旧 H5 接口全部下线 | + +### 修改(后台) + +| 接口 | 变更内容 | +|------|----------| +| `POST /api/admin/shop-package-batch-pricing/batch-update` | 仅支持批量调整成本价(移除 `pricing_target`) | +| 所有返回 `PackageResponse` 的套餐列表接口 | 响应新增 `retail_price` 字段 | +| 运营商创建/编辑接口 | 新增 `realname_link_type` 和 `realname_link_template` 字段 | + +### 新增(后台) + +| 接口 | 说明 | +|------|------| +| `PATCH /api/admin/packages/:id/retail-price` | 代理修改自己的套餐零售价 | +| `PATCH /api/admin/iot-cards/:id/deactivate` | 手动停用 IoT 卡 | +| `PATCH /api/admin/devices/:id/deactivate` | 手动停用设备 | +| `POST /api/admin/exchanges` | 发起换货单 | +| `GET /api/admin/exchanges` | 换货单列表 | +| `GET /api/admin/exchanges/:id` | 换货单详情 | +| `POST /api/admin/exchanges/:id/ship` | 发货 | +| `POST /api/admin/exchanges/:id/complete` | 确认完成 | +| `POST /api/admin/exchanges/:id/cancel` | 取消换货 | +| `POST /api/admin/exchanges/:id/renew` | 旧资产转新(generation+1) | + +### 新增(C 端) + +| 接口 | 说明 | +|------|------| +| `POST /api/c/v1/auth/verify-asset` | 资产验证 | +| `POST /api/c/v1/auth/wechat-login` | 公众号登录 | +| `POST /api/c/v1/auth/miniapp-login` | 小程序登录 | +| `POST /api/c/v1/auth/send-code` | 发送验证码 | +| `POST /api/c/v1/auth/bind-phone` | 绑定手机号 | +| `POST /api/c/v1/auth/change-phone` | 换绑手机号 | +| `POST /api/c/v1/auth/logout` | 退出登录 | +| `GET /api/c/v1/asset/info` | 资产基本信息 | +| `GET /api/c/v1/asset/packages` | 可购套餐列表 | +| `GET /api/c/v1/asset/package-history` | 历史套餐列表 | +| `POST /api/c/v1/asset/refresh` | 手动刷新资产状态 | +| `GET /api/c/v1/wallet/detail` | 钱包详情 | +| `GET /api/c/v1/wallet/transactions` | 钱包流水列表 | +| `GET /api/c/v1/wallet/recharge-check` | 充值预检 | +| `POST /api/c/v1/wallet/recharge` | 创建充值订单 | +| `GET /api/c/v1/wallet/recharges` | 充值记录列表 | +| `POST /api/c/v1/orders/create` | 创建套餐购买订单 | +| `GET /api/c/v1/orders` | 订单列表 | +| `GET /api/c/v1/orders/:id` | 订单详情 | +| `GET /api/c/v1/realname/link` | 获取实名跳转链接 | +| `GET /api/c/v1/device/cards` | 设备卡列表 | +| `POST /api/c/v1/device/reboot` | 设备重启 | +| `POST /api/c/v1/device/factory-reset` | 恢复出厂设置 | +| `POST /api/c/v1/device/wifi` | WiFi 配置 | +| `POST /api/c/v1/device/switch-card` | 切卡 | +| `GET /api/c/v1/exchange/pending` | 查询进行中的换货通知 | +| `POST /api/c/v1/exchange/:id/shipping-info` | 填写收货信息 | + +--- + +## 二、删除的接口 + +以下接口已全部下线,请停止调用: + +**旧 H5 接口(`/api/h5/` 下所有路由)** + +**旧个人客户接口(`/api/personal/` 下):** +- `POST /api/personal/login` +- `POST /api/personal/send-code` +- `POST /api/personal/wechat-oauth-login` +- `POST /api/personal/bind-wechat` +- `GET /api/personal/profile` + +以上接口由新 C 端认证接口(`/api/c/v1/auth/`)替代。 + +--- + +## 三、修改的后台接口 + +### 批量调价接口移除 `pricing_target` 字段 + +``` +POST /api/admin/shop-package-batch-pricing/batch-update +``` + +**移除请求字段:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `pricing_target` | string | 否 | 已移除,不再支持通过该接口调整零售价 | + +**变更说明**:该接口现仅支持批量调整成本价,零售价调整改为独立接口 `PATCH /api/admin/packages/:id/retail-price`。 + +--- + +### 套餐列表响应新增 `retail_price` 字段 + +所有返回 `PackageResponse` 的套餐列表接口,响应体新增字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `retail_price` | int64 | 零售价(单位:分),代理商可见 | + +--- + +### 运营商管理 DTO 新增实名相关字段 + +运营商创建/编辑接口新增以下字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `realname_link_type` | string | 实名链接类型:`none`(无需实名)/ `template`(模板实名)/ `gateway`(网关实名) | +| `realname_link_template` | string | 实名链接模板(`realname_link_type` 为 `template` 时使用) | + +--- + +## 四、新增的后台接口 + +### 资产停用 + +#### 手动停用 IoT 卡 + +``` +PATCH /api/admin/iot-cards/:id/deactivate +``` + +- **认证**:需要后台 Bearer Token +- **路径参数**:`id`(IoT 卡 ID) +- **说明**:将卡的 `asset_status` 设为 4(已停用) + +--- + +#### 手动停用设备 + +``` +PATCH /api/admin/devices/:id/deactivate +``` + +- **认证**:需要后台 Bearer Token +- **路径参数**:`id`(设备 ID) +- **说明**:将设备的 `asset_status` 设为 4(已停用) + +--- + +### 换货管理 + +#### H1 发起换货单 + +``` +POST /api/admin/exchanges +``` + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `old_asset_type` | string | 是 | 旧资产类型:`iot_card`(物联网卡)/ `device`(设备) | +| `old_identifier` | string | 是 | 旧资产标识符(ICCID/虚拟号/IMEI/SN),1~100 字符 | +| `exchange_reason` | string | 是 | 换货原因,1~100 字符 | +| `remark` | string | 否 | 备注,最多 500 字符 | + +--- + +#### H2 换货单列表 + +``` +GET /api/admin/exchanges +``` + +**Query 参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `page` | int | 否 | 页码,最小 1 | +| `page_size` | int | 否 | 每页数量,1~100 | +| `status` | int | 否 | 换货状态:1(待填写信息)/ 2(待发货)/ 3(已发货待确认)/ 4(已完成)/ 5(已取消) | +| `identifier` | string | 否 | 资产标识符模糊搜索(旧资产/新资产均可) | +| `created_at_start` | string | 否 | 创建时间起始 | +| `created_at_end` | string | 否 | 创建时间结束 | + +**响应体(list 中每项)**:见下方 `ExchangeOrderResponse` 字段说明。 + +--- + +#### H3 换货单详情 + +``` +GET /api/admin/exchanges/:id +``` + +**路径参数**:`id`(换货单 ID) + +**响应体(`ExchangeOrderResponse`):** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | uint | 换货单 ID | +| `exchange_no` | string | 换货单号 | +| `old_asset_type` | string | 旧资产类型 | +| `old_asset_id` | uint | 旧资产 ID | +| `old_asset_identifier` | string | 旧资产标识符 | +| `new_asset_type` | string | 新资产类型 | +| `new_asset_id` | uint | 新资产 ID(可为空) | +| `new_asset_identifier` | string | 新资产标识符 | +| `recipient_name` | string | 收件人姓名 | +| `recipient_phone` | string | 收件人电话 | +| `recipient_address` | string | 收货地址 | +| `express_company` | string | 快递公司 | +| `express_no` | string | 快递单号 | +| `migrate_data` | bool | 是否执行全量迁移 | +| `migration_completed` | bool | 迁移是否已完成 | +| `migration_balance` | int64 | 迁移转移金额(分) | +| `exchange_reason` | string | 换货原因 | +| `remark` | string | 备注(可为空) | +| `status` | int | 换货状态(1~5) | +| `status_text` | string | 换货状态文本 | +| `shop_id` | uint | 所属店铺 ID(可为空) | +| `created_at` | string | 创建时间 | +| `updated_at` | string | 更新时间 | +| `creator` | uint | 创建人 ID | +| `updater` | uint | 更新人 ID | + +--- + +#### H4 发货 + +``` +POST /api/admin/exchanges/:id/ship +``` + +**路径参数**:`id`(换货单 ID) + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `express_company` | string | 是 | 快递公司,1~100 字符 | +| `express_no` | string | 是 | 快递单号,1~100 字符 | +| `new_identifier` | string | 是 | 新资产标识符(ICCID/虚拟号/IMEI/SN),1~100 字符 | +| `migrate_data` | bool | 是 | 是否执行全量迁移(true:执行,false:不执行) | + +--- + +#### H5 确认完成 + +``` +POST /api/admin/exchanges/:id/complete +``` + +**路径参数**:`id`(换货单 ID) + +**请求体**:无 + +**说明**:若发货时 `migrate_data=true`,确认完成时会执行全量数据迁移(钱包余额、套餐记录等从旧资产迁移到新资产)。 + +--- + +#### H6 取消换货 + +``` +POST /api/admin/exchanges/:id/cancel +``` + +**路径参数**:`id`(换货单 ID) + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `remark` | string | 否 | 取消备注,最多 500 字符 | + +**限制**:仅 `status=1`(待填写信息)或 `status=2`(待发货)时可取消。 + +--- + +#### H7 旧资产转新(generation+1) + +``` +POST /api/admin/exchanges/:id/renew +``` + +**路径参数**:`id`(换货单 ID) + +**请求体**:无 + +**说明**:将旧资产的 `generation` 字段加 1,使其可重新销售。 + +--- + +### 换货业务流程 + +```mermaid +flowchart TD + A["后台发起换货\nH1 POST /admin/exchanges"] --> B[换货单创建\nstatus=1 待填写信息] + B --> C["客户端轮询通知\nG1 GET /exchange/pending"] + C --> D["客户端填写收货信息\nG2 POST /exchange/:id/shipping-info"] + D --> E[status=2 待发货] + E --> F["后台发货\nH4 POST /exchanges/:id/ship\n填写快递信息和新资产标识符"] + F --> G[status=3 已发货待确认] + G --> H["后台确认完成\nH5 POST /exchanges/:id/complete"] + H --> I{migrate_data=true?} + I -->|是| J[执行全量数据迁移\n钱包余额、套餐记录迁移到新资产] + I -->|否| K[直接完成] + J --> L[status=4 已完成] + K --> L + L --> M{需要回收旧资产?} + M -->|是| N["后台转新\nH7 POST /exchanges/:id/renew\n旧资产 generation+1 可重新销售"] + M -->|否| O[流程结束] + N --> O +``` + +**取消流程:** + +```mermaid +flowchart TD + A[status=1 待填写信息] -->|H6 取消| C[status=5 已取消] + B[status=2 待发货] -->|H6 取消| C +``` + +### 换货状态机 + +| 状态值 | 状态名 | 可执行操作 | +|--------|--------|-----------| +| 1 | 待填写信息 | 客户端填写收货信息(G2)、后台取消(H6) | +| 2 | 待发货 | 后台发货(H4)、后台取消(H6) | +| 3 | 已发货待确认 | 后台确认完成(H5) | +| 4 | 已完成 | 后台转新(H7,可选) | +| 5 | 已取消 | 无 | + +--- + +## 五、新增的 C 端接口 + +所有接口位于 `/api/c/v1/` 下,**认证接口(`/auth/`)无需登录,其余全部需要 JWT 认证**(`Authorization: Bearer `)。 + +--- + +### 认证(/api/c/v1/auth/) + +#### A1 资产验证(无需认证) + +``` +POST /api/c/v1/auth/verify-asset +``` + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `identifier` | string | 是 | 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN),1~50 字符 | + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `asset_token` | string | 资产令牌,5 分钟有效,用于后续登录接口 | +| `expires_in` | int | 过期时间(秒),固定 300 | + +--- + +#### A2 公众号登录(无需认证) + +``` +POST /api/c/v1/auth/wechat-login +``` + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `code` | string | 是 | 微信 OAuth 授权码 | +| `asset_token` | string | 是 | A1 返回的资产令牌 | + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `token` | string | 登录 JWT 令牌 | +| `need_bind_phone` | bool | 是否需要绑定手机号(true 时引导用户完成 A4+A5) | +| `is_new_user` | bool | 是否新注册用户 | + +--- + +#### A3 小程序登录(无需认证) + +``` +POST /api/c/v1/auth/miniapp-login +``` + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `code` | string | 是 | 小程序登录凭证 | +| `asset_token` | string | 是 | A1 返回的资产令牌 | +| `nickname` | string | 否 | 用户昵称(前端授权后传入) | +| `avatar_url` | string | 否 | 用户头像 URL(前端授权后传入) | + +**响应体**:同 A2(`token` / `need_bind_phone` / `is_new_user`) + +--- + +#### A4 发送验证码(无需认证) + +``` +POST /api/c/v1/auth/send-code +``` + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `phone` | string | 是 | 手机号,固定 11 位 | +| `scene` | string | 是 | 业务场景:`bind_phone`(绑定手机)/ `change_phone_old`(换绑旧手机验证)/ `change_phone_new`(换绑新手机验证) | + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `cooldown_seconds` | int | 冷却秒数,期间不可重复发送 | + +--- + +#### A5 绑定手机号(需 JWT 认证) + +``` +POST /api/c/v1/auth/bind-phone +``` + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `phone` | string | 是 | 手机号,固定 11 位 | +| `code` | string | 是 | 验证码,固定 6 位 | + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `phone` | string | 已绑定手机号 | +| `bound_at` | string | 绑定时间 | + +--- + +#### A6 换绑手机号(需 JWT 认证) + +``` +POST /api/c/v1/auth/change-phone +``` + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `old_phone` | string | 是 | 旧手机号,固定 11 位 | +| `old_code` | string | 是 | 旧手机号验证码,固定 6 位 | +| `new_phone` | string | 是 | 新手机号,固定 11 位 | +| `new_code` | string | 是 | 新手机号验证码,固定 6 位 | + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `phone` | string | 换绑后手机号 | +| `changed_at` | string | 换绑时间 | + +--- + +#### A7 退出登录(需 JWT 认证) + +``` +POST /api/c/v1/auth/logout +``` + +**请求体**:无 + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `success` | bool | 是否成功 | + +--- + +### 认证登录完整流程 + +```mermaid +flowchart TD + A[用户打开客户端] --> B[输入资产标识符\nSN/IMEI/ICCID/虚拟号] + B --> C["A1 POST /auth/verify-asset\n获得 asset_token(5分钟有效)"] + C --> D{选择登录方式} + D -->|公众号| E["A2 POST /auth/wechat-login\n传入 code + asset_token"] + D -->|小程序| F["A3 POST /auth/miniapp-login\n传入 code + asset_token"] + E --> G{need_bind_phone?} + F --> G + G -->|true 需要绑定| H["A4 POST /auth/send-code\nscene=bind_phone"] + H --> I["A5 POST /auth/bind-phone\n传入 phone + code"] + I --> J[进入主页面] + G -->|false 已绑定| J +``` + +**换绑手机号流程:** + +```mermaid +flowchart TD + A[用户申请换绑] --> B["A4 POST /auth/send-code\nscene=change_phone_old\n发送旧手机验证码"] + B --> C["A4 POST /auth/send-code\nscene=change_phone_new\n发送新手机验证码"] + C --> D["A6 POST /auth/change-phone\n传入 old_phone+old_code+new_phone+new_code"] + D --> E[换绑成功] +``` + +--- + +### 资产(/api/c/v1/asset/) + +#### B1 资产基本信息 + +``` +GET /api/c/v1/asset/info?identifier=xxx +``` + +**Query 参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `identifier` | string | 是 | 资产标识符,1~50 字符 | + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `asset_type` | string | 资产类型:`card`(卡)/ `device`(设备) | +| `asset_id` | uint | 资产 ID | +| `identifier` | string | 资产标识符 | +| `virtual_no` | string | 虚拟号 | +| `status` | int | 状态:0(禁用)/ 1(启用) | +| `real_name_status` | int | 实名状态:0(未实名)/ 1(已实名) | +| `carrier_name` | string | 运营商名称 | +| `generation` | string | 制式 | +| `wallet_balance` | int64 | 钱包余额(分) | + +--- + +#### B2 可购套餐列表 + +``` +GET /api/c/v1/asset/packages?identifier=xxx +``` + +**Query 参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `identifier` | string | 是 | 资产标识符,1~50 字符 | + +**响应体:** + +```json +{ + "packages": [ + { + "package_id": 1, + "package_name": "月套餐30G", + "package_type": "formal", + "retail_price": 2900, + "cost_price": 2000, + "validity_days": 30, + "is_addon": false, + "data_allowance": 30720, + "data_unit": "MB", + "description": "每月30G流量" + } + ] +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `package_id` | uint | 套餐 ID | +| `package_name` | string | 套餐名称 | +| `package_type` | string | 套餐类型:`formal`(正式套餐)/ `addon`(加油包) | +| `retail_price` | int64 | 零售价(分) | +| `cost_price` | int64 | 成本价(分) | +| `validity_days` | int | 有效天数 | +| `is_addon` | bool | 是否加油包 | +| `data_allowance` | int64 | 流量额度 | +| `data_unit` | string | 流量单位 | +| `description` | string | 套餐说明 | + +--- + +#### B3 历史套餐列表 + +``` +GET /api/c/v1/asset/package-history?identifier=xxx&page=1&page_size=20 +``` + +**Query 参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `identifier` | string | 是 | 资产标识符 | +| `page` | int | 是 | 页码,最小 1 | +| `page_size` | int | 是 | 每页数量,1~100 | + +**响应体**:分页列表,包含 `list` / `total` / `page` / `page_size`。 + +--- + +#### B4 手动刷新资产状态 + +``` +POST /api/c/v1/asset/refresh +``` + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `identifier` | string | 是 | 资产标识符,1~50 字符 | + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `refresh_type` | string | 刷新类型:`card`(卡)/ `device`(设备) | +| `accepted` | bool | 是否已受理 | +| `cooldown_seconds` | int | 冷却秒数(期间不可重复刷新) | + +--- + +### 钱包(/api/c/v1/wallet/) + +#### C1 钱包详情 + +``` +GET /api/c/v1/wallet/detail?identifier=xxx +``` + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `wallet_id` | uint | 钱包 ID | +| `resource_type` | string | 资源类型:`iot_card`(物联网卡)/ `device`(设备) | +| `resource_id` | uint | 资源 ID | +| `balance` | int64 | 可用余额(分) | +| `frozen_balance` | int64 | 冻结余额(分) | +| `updated_at` | string | 更新时间 | + +--- + +#### C2 钱包流水列表 + +``` +GET /api/c/v1/wallet/transactions?identifier=xxx&page=1&page_size=20 +``` + +**Query 参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `identifier` | string | 是 | 资产标识符 | +| `transaction_type` | string | 否 | 流水类型筛选 | +| `start_time` | string | 否 | 开始时间 | +| `end_time` | string | 否 | 结束时间 | +| `page` | int | 是 | 页码 | +| `page_size` | int | 是 | 每页数量,1~100 | + +**响应体(list 中每项):** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `transaction_id` | uint | 流水 ID | +| `type` | string | 流水类型 | +| `amount` | int64 | 变动金额(分) | +| `balance_after` | int64 | 变动后余额(分) | +| `created_at` | string | 创建时间 | +| `remark` | string | 备注 | + +--- + +#### C3 充值预检 + +``` +GET /api/c/v1/wallet/recharge-check?identifier=xxx +``` + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `need_force_recharge` | bool | 是否需要强制充值 | +| `force_recharge_amount` | int64 | 强制充值金额(分) | +| `trigger_type` | string | 触发类型 | +| `min_amount` | int64 | 最小充值金额(分) | +| `max_amount` | int64 | 最大充值金额(分) | +| `message` | string | 提示信息 | + +--- + +#### C4 创建充值订单 + +``` +POST /api/c/v1/wallet/recharge +``` + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `identifier` | string | 是 | 资产标识符,1~50 字符 | +| `amount` | int64 | 是 | 充值金额(分),100~10000000 | +| `payment_method` | string | 是 | 支付方式,目前仅支持 `wechat` | +| `app_type` | string | 是 | 应用类型:`official_account`(公众号)/ `miniapp`(小程序) | + +**响应体:** + +```json +{ + "recharge": { + "recharge_id": 1, + "recharge_no": "RC20260319001", + "amount": 10000, + "status": 0 + }, + "pay_config": { + "app_id": "wx...", + "timestamp": "1710000000", + "nonce_str": "abc123", + "package": "prepay_id=wx...", + "sign_type": "RSA", + "pay_sign": "..." + } +} +``` + +`pay_config` 字段直接传给微信 JSAPI 调起支付。 + +--- + +#### C5 充值记录列表 + +``` +GET /api/c/v1/wallet/recharges?identifier=xxx&page=1&page_size=20 +``` + +**Query 参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `identifier` | string | 是 | 资产标识符 | +| `status` | int | 否 | 充值状态:0(待支付)/ 1(已支付)/ 2(已关闭) | +| `page` | int | 是 | 页码 | +| `page_size` | int | 是 | 每页数量,1~100 | + +**响应体(list 中每项):** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `recharge_id` | uint | 充值 ID | +| `recharge_no` | string | 充值单号 | +| `amount` | int64 | 充值金额(分) | +| `status` | int | 状态:0(待支付)/ 1(已支付)/ 2(已关闭) | +| `payment_method` | string | 支付方式 | +| `created_at` | string | 创建时间 | +| `auto_purchase_status` | string | 自动购包状态 | + +--- + +### 钱包充值流程 + +```mermaid +flowchart TD + A[用户进入充值页] --> B["C3 GET /wallet/recharge-check\n预检是否需要强充"] + B --> C{need_force_recharge} + C -->|true 需要强充| D[展示强制充值金额\n用户确认] + C -->|false 自由充值| E[用户输入充值金额] + D --> F["C4 POST /wallet/recharge\n创建充值订单"] + E --> F + F --> G[调起微信支付\n使用 pay_config] + G --> H[支付成功] + H --> I[后端回调处理\n余额增加] + I --> J[充值完成] +``` + +--- + +### 订单(/api/c/v1/orders/) + +#### D1 创建套餐购买订单(核心接口) + +``` +POST /api/c/v1/orders/create +``` + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `identifier` | string | 是 | 资产标识符,1~50 字符 | +| `package_ids` | []uint | 是 | 套餐 ID 列表,至少 1 个 | +| `app_type` | string | 是 | 应用类型:`official_account`(公众号)/ `miniapp`(小程序) | + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `order_type` | string | 订单类型:`package`(套餐订单)/ `recharge`(充值订单) | +| `order` | object | 套餐订单信息(`order_type=package` 时有值) | +| `recharge` | object | 充值订单信息(`order_type=recharge` 时有值) | +| `pay_config` | object | 微信支付配置,直接传给 JSAPI | +| `linked_package_info` | object | 关联套餐信息(强充场景下有值) | + +`order` 字段结构: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `order_id` | uint | 订单 ID | +| `order_no` | string | 订单号 | +| `total_amount` | int64 | 订单总金额(分) | +| `payment_status` | int | 支付状态:0(待支付)/ 1(已支付)/ 2(已取消) | +| `created_at` | string | 创建时间 | + +`recharge` 字段结构: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `recharge_id` | uint | 充值 ID | +| `recharge_no` | string | 充值单号 | +| `amount` | int64 | 充值金额(分) | +| `status` | int | 状态:0(待支付)/ 1(已支付)/ 2(已关闭) | +| `auto_purchase_status` | string | 自动购包状态 | + +`linked_package_info` 字段结构(强充场景): + +| 字段 | 类型 | 说明 | +|------|------|------| +| `package_names` | []string | 套餐名称列表 | +| `total_package_amount` | int64 | 套餐总金额(分) | +| `force_recharge_amount` | int64 | 强制充值金额(分) | +| `wallet_credit` | int64 | 钱包抵扣金额(分) | + +> **注意**:当余额不足时,后端会自动创建充值订单(`order_type=recharge`),用户支付充值后系统自动购买套餐,前端无需二次调用购买接口。 + +--- + +#### D2 订单列表 + +``` +GET /api/c/v1/orders?identifier=xxx&page=1&page_size=20 +``` + +**Query 参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `identifier` | string | 是 | 资产标识符 | +| `payment_status` | int | 否 | 支付状态:0(待支付)/ 1(已支付)/ 2(已取消) | +| `page` | int | 是 | 页码 | +| `page_size` | int | 是 | 每页数量,1~100 | + +**响应体(list 中每项):** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `order_id` | uint | 订单 ID | +| `order_no` | string | 订单号 | +| `total_amount` | int64 | 订单总金额(分) | +| `payment_status` | int | 支付状态 | +| `created_at` | string | 创建时间 | +| `package_names` | []string | 套餐名称列表 | + +--- + +#### D3 订单详情 + +``` +GET /api/c/v1/orders/:id +``` + +**路径参数**:`id`(订单 ID) + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `order_id` | uint | 订单 ID | +| `order_no` | string | 订单号 | +| `total_amount` | int64 | 订单总金额(分) | +| `payment_status` | int | 支付状态:0(待支付)/ 1(已支付)/ 2(已取消) | +| `payment_method` | string | 支付方式 | +| `created_at` | string | 创建时间 | +| `paid_at` | string | 支付时间(可为空) | +| `completed_at` | string | 完成时间(可为空) | +| `packages` | array | 订单套餐列表 | + +`packages` 中每项: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `package_id` | uint | 套餐 ID | +| `package_name` | string | 套餐名称 | +| `package_type` | string | 套餐类型:`formal`(正式套餐)/ `addon`(加油包) | +| `price` | int64 | 单价(分) | +| `quantity` | int | 数量 | + +--- + +### 套餐购买流程 + +```mermaid +flowchart TD + A[用户选择套餐] --> B["D1 POST /orders/create\n传入 identifier + package_ids + app_type"] + B --> C{响应 order_type} + C -->|package 套餐订单| D[调起微信支付\n使用 pay_config] + C -->|recharge 充值订单| E[提示用户需要充值\n显示 linked_package_info 中的金额说明] + E --> F[调起微信支付\n使用 pay_config] + D --> G[支付成功] + F --> G + G --> H[后端支付回调处理] + H -->|套餐订单| I[直接激活套餐] + H -->|充值订单| J[余额到账后自动购买套餐] + I --> K[套餐激活完成] + J --> K +``` + +> **强充两阶段说明**:当用户钱包余额不足时,后端自动创建充值订单(`order_type=recharge`)。用户支付充值金额后,系统自动扣款购买套餐,前端无需再次调用购买接口。`linked_package_info` 字段包含套餐名称和金额明细,可用于向用户展示说明。 + +--- + +### 实名(/api/c/v1/realname/) + +#### E1 获取实名跳转链接 + +``` +GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx +``` + +**Query 参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `identifier` | string | 是 | 资产标识符,1~50 字符 | +| `iccid` | string | 否 | 物联网卡 ICCID(设备场景下指定具体卡) | + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `realname_mode` | string | 实名模式:`none`(无需实名)/ `template`(模板实名)/ `gateway`(网关实名) | +| `realname_url` | string | 实名跳转链接 | +| `card_info` | object | 卡片简要信息 | +| `expire_at` | string | 链接过期时间(可为空) | + +`card_info` 字段: + +| 字段 | 类型 | 说明 | +|------|------|------| +| `iccid` | string | 物联网卡 ICCID | +| `msisdn` | string | 手机号 | +| `virtual_no` | string | 虚拟号 | + +--- + +### 设备(/api/c/v1/device/) + +#### F1 设备卡列表 + +``` +GET /api/c/v1/device/cards?identifier=xxx +``` + +**响应体:** + +```json +{ + "cards": [ + { + "card_id": 1, + "iccid": "898600...", + "msisdn": "1380000...", + "carrier_name": "中国移动", + "network_status": "online", + "real_name_status": 1, + "slot_position": 1, + "is_active": true + } + ] +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `card_id` | uint | 卡 ID | +| `iccid` | string | 物联网卡 ICCID | +| `msisdn` | string | 手机号 | +| `carrier_name` | string | 运营商名称 | +| `network_status` | string | 网络状态 | +| `real_name_status` | int | 实名状态:0(未实名)/ 1(已实名) | +| `slot_position` | int | 插槽位置 | +| `is_active` | bool | 是否当前激活卡 | + +--- + +#### F2 设备重启 + +``` +POST /api/c/v1/device/reboot +``` + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `identifier` | string | 是 | 资产标识符,1~50 字符 | + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `accepted` | bool | 是否已受理 | +| `request_id` | string | 请求 ID | + +--- + +#### F3 恢复出厂设置 + +``` +POST /api/c/v1/device/factory-reset +``` + +**请求体**:同 F2(`identifier` 字段) + +**响应体**:同 F2(`accepted` / `request_id`) + +--- + +#### F4 WiFi 配置 + +``` +POST /api/c/v1/device/wifi +``` + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `identifier` | string | 是 | 资产标识符,1~50 字符 | +| `ssid` | string | 是 | WiFi 名称,1~32 字符 | +| `password` | string | 是 | WiFi 密码,1~64 字符 | +| `enabled` | bool | 是 | 是否启用 WiFi | + +**响应体**:同 F2(`accepted` / `request_id`) + +--- + +#### F5 切卡 + +``` +POST /api/c/v1/device/switch-card +``` + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `identifier` | string | 是 | 资产标识符,1~50 字符 | +| `target_iccid` | string | 是 | 目标 ICCID,1~30 字符 | + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `accepted` | bool | 是否已受理 | +| `target_iccid` | string | 目标 ICCID | + +--- + +### 换货(/api/c/v1/exchange/) + +#### G1 查询进行中的换货通知 + +``` +GET /api/c/v1/exchange/pending?identifier=xxx +``` + +**Query 参数:** + +| 参数 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `identifier` | string | 是 | 资产标识符,1~100 字符 | + +**响应体:** + +| 字段 | 类型 | 说明 | +|------|------|------| +| `id` | uint | 换货单 ID | +| `exchange_no` | string | 换货单号 | +| `status` | int | 换货状态(1~5) | +| `status_text` | string | 换货状态文本 | +| `exchange_reason` | string | 换货原因 | +| `created_at` | string | 创建时间 | + +> 若无进行中的换货单,返回空数据(非报错)。 + +--- + +#### G2 填写收货信息 + +``` +POST /api/c/v1/exchange/:id/shipping-info +``` + +**路径参数**:`id`(换货单 ID) + +**请求体:** + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `recipient_name` | string | 是 | 收件人姓名,1~50 字符 | +| `recipient_phone` | string | 是 | 收件人电话,1~20 字符 | +| `recipient_address` | string | 是 | 收货地址,1~500 字符 | + +--- + +## 六、认证方式说明 + +| 端 | 认证方式 | Header 格式 | +|----|----------|-------------| +| 后台(`/api/admin/`) | Bearer Token(Redis 存储) | `Authorization: Bearer ` | +| C 端(`/api/c/v1/`) | JWT | `Authorization: Bearer ` | + +**C 端 JWT 获取方式**:通过 A2 公众号登录或 A3 小程序登录接口获取,有效期请参考接口返回。 + +--- + +## 七、统一响应格式 + +所有接口均返回以下格式: + +```json +{ + "code": 0, + "msg": "success", + "data": { ... }, + "timestamp": 1710000000 +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `code` | int | 业务状态码,0 表示成功 | +| `msg` | string | 提示信息 | +| `data` | object | 业务数据 | +| `timestamp` | int64 | 服务器时间戳(秒) | + +**常见错误码:** + +| 错误码 | 说明 | +|--------|------| +| 1001 | 缺失认证令牌 | +| 1002 | 无效或过期令牌 | +| 1003 | 权限不足 | +| 1200 | 换货单不存在 | +| 1201 | 换货单状态不允许此操作 | +| 1202 | 旧资产不存在或已停用 | +| 1203 | 新资产标识符无效 | +| 1204 | 换货单已取消,无法操作 | +| 1205 | 数据迁移失败 | +| 1206 | 收货信息填写超时 | +| 4000 | 参数错误 | +| 5000 | 服务器内部错误 | diff --git a/internal/bootstrap/dependencies.go b/internal/bootstrap/dependencies.go index 669f5f4..4644e03 100644 --- a/internal/bootstrap/dependencies.go +++ b/internal/bootstrap/dependencies.go @@ -15,15 +15,14 @@ import ( // Dependencies 封装所有基础依赖 // 这些是应用启动时初始化的核心组件 type Dependencies struct { - DB *gorm.DB // PostgreSQL 数据库连接 - Redis *redis.Client // Redis 客户端 - Logger *zap.Logger // 应用日志器 - JWTManager *auth.JWTManager // JWT 管理器(个人客户认证) - TokenManager *auth.TokenManager // Token 管理器(后台和H5认证) - VerificationService *verification.Service // 验证码服务 - QueueClient *queue.Client // Asynq 任务队列客户端 - StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil) - GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil) - WechatOfficialAccount wechat.OfficialAccountServiceInterface // 微信公众号服务(可选) - WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选) + DB *gorm.DB // PostgreSQL 数据库连接 + Redis *redis.Client // Redis 客户端 + Logger *zap.Logger // 应用日志器 + JWTManager *auth.JWTManager // JWT 管理器(个人客户认证) + TokenManager *auth.TokenManager // Token 管理器(后台和H5认证) + VerificationService *verification.Service // 验证码服务 + QueueClient *queue.Client // Asynq 任务队列客户端 + StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil) + GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil) + WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选) } diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index dfe37cc..af24da4 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -110,7 +110,7 @@ func initServices(s *stores, deps *Dependencies) *services { AccountAudit: accountAudit, Role: roleSvc.New(s.Role, s.Permission, s.RolePermission), Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis), - PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.WechatOfficialAccount, deps.Logger), + PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.Logger), ClientAuth: clientAuthSvc.New( deps.DB, s.PersonalCustomerOpenID, diff --git a/internal/handler/admin/package.go b/internal/handler/admin/package.go index b94d8df..921d3b8 100644 --- a/internal/handler/admin/package.go +++ b/internal/handler/admin/package.go @@ -128,3 +128,21 @@ func (h *PackageHandler) UpdateShelfStatus(c *fiber.Ctx) error { return response.Success(c, nil) } + +func (h *PackageHandler) UpdateRetailPrice(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil { + return errors.New(errors.CodeInvalidParam, "无效的套餐 ID") + } + + var req dto.UpdateRetailPriceRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + + if err := h.service.UpdateRetailPrice(c.UserContext(), uint(id), req.RetailPrice); err != nil { + return err + } + + return response.Success(c, nil) +} diff --git a/internal/model/dto/package_dto.go b/internal/model/dto/package_dto.go index bfe0bef..810b501 100644 --- a/internal/model/dto/package_dto.go +++ b/internal/model/dto/package_dto.go @@ -56,6 +56,11 @@ type UpdatePackageShelfStatusRequest struct { ShelfStatus int `json:"shelf_status" validate:"required,oneof=1 2" required:"true" description:"上架状态 (1:上架, 2:下架)"` } +// UpdateRetailPriceRequest 更新零售价请求 +type UpdateRetailPriceRequest struct { + RetailPrice int64 `json:"retail_price" validate:"required,min=0" required:"true" minimum:"0" description:"零售价(单位:分)"` +} + // CommissionTierInfo 返佣梯度信息 type CommissionTierInfo struct { CurrentRate string `json:"current_rate" description:"当前返佣比例"` @@ -111,6 +116,12 @@ type UpdatePackageShelfStatusParams struct { UpdatePackageShelfStatusRequest } +// UpdateRetailPriceParams 更新零售价聚合参数 +type UpdateRetailPriceParams struct { + IDReq + UpdateRetailPriceRequest +} + // PackagePageResult 套餐分页结果 type PackagePageResult struct { List []*PackageResponse `json:"list" description:"套餐列表"` diff --git a/internal/model/dto/shop_package_batch_pricing_dto.go b/internal/model/dto/shop_package_batch_pricing_dto.go index d9bc9b3..bfd1461 100644 --- a/internal/model/dto/shop_package_batch_pricing_dto.go +++ b/internal/model/dto/shop_package_batch_pricing_dto.go @@ -4,7 +4,6 @@ package dto type BatchUpdateCostPriceRequest struct { ShopID uint `json:"shop_id" validate:"required" required:"true" description:"店铺ID"` SeriesID *uint `json:"series_id" validate:"omitempty" description:"套餐系列ID(可选,不填则调整所有)"` - PricingTarget string `json:"pricing_target" validate:"omitempty,oneof=cost_price retail_price" description:"调价目标 cost_price-成本价(默认) retail_price-零售价"` PriceAdjustment PriceAdjustment `json:"price_adjustment" validate:"required" required:"true" description:"价格调整配置"` ChangeReason string `json:"change_reason" validate:"omitempty,max=255" maxLength:"255" description:"变更原因"` } diff --git a/internal/routes/package.go b/internal/routes/package.go index d38879d..d799542 100644 --- a/internal/routes/package.go +++ b/internal/routes/package.go @@ -67,4 +67,12 @@ func registerPackageRoutes(router fiber.Router, handler *admin.PackageHandler, d Output: nil, Auth: true, }) + + Register(packages, doc, groupPath, "PATCH", "/:id/retail-price", handler.UpdateRetailPrice, RouteSpec{ + Summary: "修改零售价(代理)", + Tags: []string{"套餐管理"}, + Input: new(dto.UpdateRetailPriceParams), + Output: nil, + Auth: true, + }) } diff --git a/internal/service/package/service.go b/internal/service/package/service.go index c8b6708..3c2eff3 100644 --- a/internal/service/package/service.go +++ b/internal/service/package/service.go @@ -456,6 +456,42 @@ func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus in return nil } +// UpdateRetailPrice 代理修改自己店铺的套餐零售价 +func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailPrice int64) error { + currentUserID := middleware.GetUserIDFromContext(ctx) + if currentUserID == 0 { + return errors.New(errors.CodeUnauthorized, "未授权访问") + } + + userType := middleware.GetUserTypeFromContext(ctx) + if userType != constants.UserTypeAgent { + return errors.New(errors.CodeForbidden, "仅代理用户可修改零售价") + } + + shopID := middleware.GetShopIDFromContext(ctx) + if shopID == 0 { + return errors.New(errors.CodeUnauthorized, "当前用户不属于任何店铺") + } + + allocation, err := s.packageAllocationStore.GetByShopAndPackage(ctx, shopID, packageID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "该套餐未分配给您") + } + return errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败") + } + + if retailPrice < allocation.CostPrice { + return errors.New(errors.CodeInvalidParam, "零售价不能低于成本价") + } + + if err := s.packageAllocationStore.UpdateRetailPrice(ctx, allocation.ID, retailPrice, currentUserID); err != nil { + return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败") + } + + return nil +} + // updateAgentShelfStatus 代理上下架路径:更新分配记录的 shelf_status func (s *Service) updateAgentShelfStatus(ctx context.Context, packageID uint, shelfStatus int, updaterID uint) error { shopID := middleware.GetShopIDFromContext(ctx) diff --git a/internal/service/personal_customer/service.go b/internal/service/personal_customer/service.go index 181ad1c..e74dacb 100644 --- a/internal/service/personal_customer/service.go +++ b/internal/service/personal_customer/service.go @@ -6,24 +6,21 @@ import ( "context" "github.com/break/junhong_cmp_fiber/internal/model" - "github.com/break/junhong_cmp_fiber/internal/model/dto" "github.com/break/junhong_cmp_fiber/internal/service/verification" "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/break/junhong_cmp_fiber/pkg/auth" "github.com/break/junhong_cmp_fiber/pkg/errors" - "github.com/break/junhong_cmp_fiber/pkg/wechat" "go.uber.org/zap" "gorm.io/gorm" ) // Service 个人客户服务 type Service struct { - store *postgres.PersonalCustomerStore - phoneStore *postgres.PersonalCustomerPhoneStore - verificationService *verification.Service - jwtManager *auth.JWTManager - wechatOfficialAccount wechat.OfficialAccountServiceInterface - logger *zap.Logger + store *postgres.PersonalCustomerStore + phoneStore *postgres.PersonalCustomerPhoneStore + verificationService *verification.Service + jwtManager *auth.JWTManager + logger *zap.Logger } // NewService 创建个人客户服务实例 @@ -32,16 +29,14 @@ func NewService( phoneStore *postgres.PersonalCustomerPhoneStore, verificationService *verification.Service, jwtManager *auth.JWTManager, - wechatOfficialAccount wechat.OfficialAccountServiceInterface, logger *zap.Logger, ) *Service { return &Service{ - store: store, - phoneStore: phoneStore, - verificationService: verificationService, - jwtManager: jwtManager, - wechatOfficialAccount: wechatOfficialAccount, - logger: logger, + store: store, + phoneStore: phoneStore, + verificationService: verificationService, + jwtManager: jwtManager, + logger: logger, } } @@ -55,115 +50,6 @@ func (s *Service) VerifyCode(ctx context.Context, phone string, code string) err return s.verificationService.VerifyCode(ctx, phone, code) } -// LoginByPhone 通过手机号登录 -// 如果手机号不存在,自动创建新的个人客户 -// 注意:此方法是临时实现,完整的登录流程应该是先微信授权,再绑定手机号 -func (s *Service) LoginByPhone(ctx context.Context, phone string, code string) (string, *model.PersonalCustomer, error) { - // 验证验证码 - if err := s.verificationService.VerifyCode(ctx, phone, code); err != nil { - s.logger.Warn("验证码验证失败", - zap.String("phone", phone), - zap.Error(err), - ) - return "", nil, err - } - - // 查找或创建个人客户 - customer, err := s.store.GetByPhone(ctx, phone) - if err != nil { - if err == gorm.ErrRecordNotFound { - // 客户不存在,创建新客户 - // 注意:临时实现,使用空的微信信息(正式应该先微信授权) - customer = &model.PersonalCustomer{ - WxOpenID: "", // 临时为空,后续需绑定微信 - WxUnionID: "", // 临时为空,后续需绑定微信 - Status: 1, // 默认启用 - } - if err := s.store.Create(ctx, customer); err != nil { - s.logger.Error("创建个人客户失败", - zap.String("phone", phone), - zap.Error(err), - ) - return "", nil, errors.Wrap(errors.CodeInternalError, err, "创建个人客户失败") - } - - // 创建手机号绑定记录 - // TODO: 这里需要通过 PersonalCustomerPhoneStore 来创建 - // 暂时跳过,等待 PersonalCustomerPhoneStore 实现 - - s.logger.Info("创建新个人客户", - zap.Uint("customer_id", customer.ID), - zap.String("phone", phone), - ) - } else { - s.logger.Error("查询个人客户失败", - zap.String("phone", phone), - zap.Error(err), - ) - return "", nil, errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败") - } - } - - // 检查客户状态 - if customer.Status == 0 { - s.logger.Warn("个人客户已被禁用", - zap.Uint("customer_id", customer.ID), - zap.String("phone", phone), - ) - return "", nil, errors.New(errors.CodeForbidden, "账号已被禁用") - } - - // 生成 Token(临时传递 phone,后续应该从 Token 中移除 phone 字段) - token, err := s.jwtManager.GeneratePersonalCustomerToken(customer.ID, phone) - if err != nil { - s.logger.Error("生成 Token 失败", - zap.Uint("customer_id", customer.ID), - zap.String("phone", phone), - zap.Error(err), - ) - return "", nil, errors.Wrap(errors.CodeInternalError, err, "生成 Token 失败") - } - - s.logger.Info("个人客户登录成功", - zap.Uint("customer_id", customer.ID), - zap.String("phone", phone), - ) - - return token, customer, nil -} - -// BindWechat 绑定微信信息 -func (s *Service) BindWechat(ctx context.Context, customerID uint, wxOpenID, wxUnionID string) error { - // 获取客户 - customer, err := s.store.GetByID(ctx, customerID) - if err != nil { - s.logger.Error("查询个人客户失败", - zap.Uint("customer_id", customerID), - zap.Error(err), - ) - return errors.Wrap(errors.CodeInternalError, err, "查询个人客户失败") - } - - // 更新微信信息 - customer.WxOpenID = wxOpenID - customer.WxUnionID = wxUnionID - - if err := s.store.Update(ctx, customer); err != nil { - s.logger.Error("更新微信信息失败", - zap.Uint("customer_id", customerID), - zap.Error(err), - ) - return errors.Wrap(errors.CodeInternalError, err, "更新微信信息失败") - } - - s.logger.Info("绑定微信信息成功", - zap.Uint("customer_id", customerID), - zap.String("wx_open_id", wxOpenID), - ) - - return nil -} - // UpdateProfile 更新个人资料 func (s *Service) UpdateProfile(ctx context.Context, customerID uint, nickname, avatarURL string) error { customer, err := s.store.GetByID(ctx, customerID) @@ -241,190 +127,3 @@ func (s *Service) GetProfileWithPhone(ctx context.Context, customerID uint) (*mo return customer, phone, nil } - -// WechatOAuthLogin 微信 OAuth 登录 -// 通过微信授权码登录,如果用户不存在则自动创建 -func (s *Service) WechatOAuthLogin(ctx context.Context, code string) (*dto.WechatOAuthResponse, error) { - // 检查微信服务是否已配置 - if s.wechatOfficialAccount == nil { - s.logger.Error("微信公众号服务未配置") - return nil, errors.New(errors.CodeWechatOAuthFailed, "微信服务未配置") - } - - // 通过授权码获取用户详细信息 - userInfo, err := s.wechatOfficialAccount.GetUserInfoDetailed(ctx, code) - if err != nil { - s.logger.Error("获取微信用户信息失败", - zap.String("code", code), - zap.Error(err), - ) - return nil, err - } - - // 通过 OpenID 查找现有客户 - customer, err := s.store.GetByWxOpenID(ctx, userInfo.OpenID) - if err != nil { - if err == gorm.ErrRecordNotFound { - // 客户不存在,创建新客户 - customer = &model.PersonalCustomer{ - WxOpenID: userInfo.OpenID, - WxUnionID: userInfo.UnionID, - Nickname: userInfo.Nickname, - AvatarURL: userInfo.Avatar, - Status: 1, // 默认启用 - } - if err := s.store.Create(ctx, customer); err != nil { - s.logger.Error("创建微信用户失败", - zap.String("open_id", userInfo.OpenID), - zap.Error(err), - ) - return nil, errors.Wrap(errors.CodeInternalError, err, "创建用户失败") - } - s.logger.Info("通过微信创建新用户", - zap.Uint("customer_id", customer.ID), - zap.String("open_id", userInfo.OpenID), - ) - } else { - s.logger.Error("查询微信用户失败", - zap.String("open_id", userInfo.OpenID), - zap.Error(err), - ) - return nil, errors.Wrap(errors.CodeInternalError, err, "查询用户失败") - } - } else { - // 客户已存在,更新昵称和头像(如果有变化) - needUpdate := false - if userInfo.Nickname != "" && customer.Nickname != userInfo.Nickname { - customer.Nickname = userInfo.Nickname - needUpdate = true - } - if userInfo.Avatar != "" && customer.AvatarURL != userInfo.Avatar { - customer.AvatarURL = userInfo.Avatar - needUpdate = true - } - if needUpdate { - if err := s.store.Update(ctx, customer); err != nil { - s.logger.Warn("更新微信用户信息失败", - zap.Uint("customer_id", customer.ID), - zap.Error(err), - ) - // 不阻断登录流程 - } - } - } - - // 检查客户状态 - if customer.Status == 0 { - s.logger.Warn("微信用户已被禁用", - zap.Uint("customer_id", customer.ID), - zap.String("open_id", userInfo.OpenID), - ) - return nil, errors.New(errors.CodeForbidden, "账号已被禁用") - } - - // 生成 JWT Token - token, err := s.jwtManager.GeneratePersonalCustomerToken(customer.ID, "") - if err != nil { - s.logger.Error("生成 Token 失败", - zap.Uint("customer_id", customer.ID), - zap.Error(err), - ) - return nil, errors.Wrap(errors.CodeInternalError, err, "生成 Token 失败") - } - - // 获取主手机号(如果有) - phone := "" - primaryPhone, err := s.phoneStore.GetPrimaryPhone(ctx, customer.ID) - if err == nil { - phone = primaryPhone.Phone - } - - s.logger.Info("微信 OAuth 登录成功", - zap.Uint("customer_id", customer.ID), - zap.String("open_id", userInfo.OpenID), - ) - - return &dto.WechatOAuthResponse{ - AccessToken: token, - ExpiresIn: 24 * 60 * 60, // 24 小时 - Customer: &dto.PersonalCustomerResponse{ - ID: customer.ID, - Phone: phone, - Nickname: customer.Nickname, - AvatarURL: customer.AvatarURL, - WxOpenID: customer.WxOpenID, - WxUnionID: customer.WxUnionID, - Status: customer.Status, - CreatedAt: customer.CreatedAt.Format("2006-01-02 15:04:05"), - UpdatedAt: customer.UpdatedAt.Format("2006-01-02 15:04:05"), - }, - }, nil -} - -// BindWechatWithCode 通过微信授权码绑定微信 -// customerID: 当前登录的客户 ID -// code: 微信授权码 -func (s *Service) BindWechatWithCode(ctx context.Context, customerID uint, code string) error { - // 检查微信服务是否已配置 - if s.wechatOfficialAccount == nil { - s.logger.Error("微信公众号服务未配置") - return errors.New(errors.CodeWechatOAuthFailed, "微信服务未配置") - } - - // 获取客户信息 - customer, err := s.store.GetByID(ctx, customerID) - if err != nil { - s.logger.Error("查询个人客户失败", - zap.Uint("customer_id", customerID), - zap.Error(err), - ) - return errors.Wrap(errors.CodeInternalError, err, "查询客户失败") - } - - // 获取微信用户信息 - userInfo, err := s.wechatOfficialAccount.GetUserInfoDetailed(ctx, code) - if err != nil { - s.logger.Error("获取微信用户信息失败", - zap.Uint("customer_id", customerID), - zap.String("code", code), - zap.Error(err), - ) - return err - } - - // 检查该 OpenID 是否已被其他用户绑定 - existingCustomer, err := s.store.GetByWxOpenID(ctx, userInfo.OpenID) - if err == nil && existingCustomer.ID != customerID { - s.logger.Warn("微信账号已被其他用户绑定", - zap.Uint("customer_id", customerID), - zap.Uint("existing_customer_id", existingCustomer.ID), - zap.String("open_id", userInfo.OpenID), - ) - return errors.New(errors.CodeConflict, "该微信账号已被其他用户绑定") - } - - // 更新微信信息 - customer.WxOpenID = userInfo.OpenID - customer.WxUnionID = userInfo.UnionID - if userInfo.Nickname != "" { - customer.Nickname = userInfo.Nickname - } - if userInfo.Avatar != "" { - customer.AvatarURL = userInfo.Avatar - } - - if err := s.store.Update(ctx, customer); err != nil { - s.logger.Error("绑定微信信息失败", - zap.Uint("customer_id", customerID), - zap.Error(err), - ) - return errors.Wrap(errors.CodeInternalError, err, "绑定微信失败") - } - - s.logger.Info("绑定微信成功", - zap.Uint("customer_id", customerID), - zap.String("open_id", userInfo.OpenID), - ) - - return nil -} diff --git a/internal/service/shop_package_batch_pricing/service.go b/internal/service/shop_package_batch_pricing/service.go index e9791f4..7f7dbcd 100644 --- a/internal/service/shop_package_batch_pricing/service.go +++ b/internal/service/shop_package_batch_pricing/service.go @@ -65,11 +65,6 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo return nil, errors.New(errors.CodeInvalidParam, "没有找到符合条件的分配记录") } - pricingTarget := req.PricingTarget - if pricingTarget == "" { - pricingTarget = "cost_price" - } - updatedCount := 0 now := time.Now() affectedIDs := make([]uint, 0) @@ -77,74 +72,41 @@ func (s *Service) BatchUpdatePricing(ctx context.Context, req *dto.BatchUpdateCo err = s.db.Transaction(func(tx *gorm.DB) error { for _, allocation := range allocations { - if pricingTarget == "retail_price" { - oldRetailPrice := allocation.RetailPrice - newRetailPrice := s.calculateAdjustedPrice(oldRetailPrice, &req.PriceAdjustment) - if newRetailPrice == oldRetailPrice { - continue - } - if newRetailPrice < allocation.CostPrice { - skipped = append(skipped, dto.BatchPricingSkipped{ - AllocationID: allocation.ID, - Reason: "零售价不能低于成本价", - }) - continue - } + oldPrice := allocation.CostPrice + newPrice := s.calculateAdjustedPrice(oldPrice, &req.PriceAdjustment) + if newPrice == oldPrice { + continue + } - history := &model.ShopPackageAllocationPriceHistory{ - AllocationID: allocation.ID, - OldCostPrice: oldRetailPrice, - NewCostPrice: newRetailPrice, - ChangeReason: req.ChangeReason + "(零售价调整)", - ChangedBy: currentUserID, - EffectiveFrom: now, - } - if err := tx.Create(history).Error; err != nil { - return errors.Wrap(errors.CodeInternalError, err, "创建价格历史失败") - } + // cost_price 锁定检查:存在下级分配记录时跳过 + var subCount int64 + tx.Model(&model.ShopPackageAllocation{}). + Where("allocator_shop_id = ? AND package_id = ? AND deleted_at IS NULL", allocation.ShopID, allocation.PackageID). + Count(&subCount) + if subCount > 0 { + skipped = append(skipped, dto.BatchPricingSkipped{ + AllocationID: allocation.ID, + Reason: "存在下级分配记录,请先回收后再修改成本价", + }) + continue + } - allocation.RetailPrice = newRetailPrice - allocation.Updater = currentUserID - if err := tx.Save(allocation).Error; err != nil { - return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败") - } - } else { - oldPrice := allocation.CostPrice - newPrice := s.calculateAdjustedPrice(oldPrice, &req.PriceAdjustment) - if newPrice == oldPrice { - continue - } + history := &model.ShopPackageAllocationPriceHistory{ + AllocationID: allocation.ID, + OldCostPrice: oldPrice, + NewCostPrice: newPrice, + ChangeReason: req.ChangeReason, + ChangedBy: currentUserID, + EffectiveFrom: now, + } + if err := tx.Create(history).Error; err != nil { + return errors.Wrap(errors.CodeInternalError, err, "创建价格历史失败") + } - // cost_price 锁定检查:存在下级分配记录时跳过 - var subCount int64 - tx.Model(&model.ShopPackageAllocation{}). - Where("allocator_shop_id = ? AND package_id = ? AND deleted_at IS NULL", allocation.ShopID, allocation.PackageID). - Count(&subCount) - if subCount > 0 { - skipped = append(skipped, dto.BatchPricingSkipped{ - AllocationID: allocation.ID, - Reason: "存在下级分配记录,请先回收后再修改成本价", - }) - continue - } - - history := &model.ShopPackageAllocationPriceHistory{ - AllocationID: allocation.ID, - OldCostPrice: oldPrice, - NewCostPrice: newPrice, - ChangeReason: req.ChangeReason, - ChangedBy: currentUserID, - EffectiveFrom: now, - } - if err := tx.Create(history).Error; err != nil { - return errors.Wrap(errors.CodeInternalError, err, "创建价格历史失败") - } - - allocation.CostPrice = newPrice - allocation.Updater = currentUserID - if err := tx.Save(allocation).Error; err != nil { - return errors.Wrap(errors.CodeInternalError, err, "更新成本价失败") - } + allocation.CostPrice = newPrice + allocation.Updater = currentUserID + if err := tx.Save(allocation).Error; err != nil { + return errors.Wrap(errors.CodeInternalError, err, "更新成本价失败") } affectedIDs = append(affectedIDs, allocation.ID) diff --git a/internal/store/postgres/shop_package_allocation_store.go b/internal/store/postgres/shop_package_allocation_store.go index 7b04858..fe0c540 100644 --- a/internal/store/postgres/shop_package_allocation_store.go +++ b/internal/store/postgres/shop_package_allocation_store.go @@ -139,6 +139,16 @@ func (s *ShopPackageAllocationStore) UpdateShelfStatus(ctx context.Context, id u }).Error } +func (s *ShopPackageAllocationStore) UpdateRetailPrice(ctx context.Context, id uint, retailPrice int64, updater uint) error { + return s.db.WithContext(ctx). + Model(&model.ShopPackageAllocation{}). + Where("id = ?", id). + Updates(map[string]interface{}{ + "retail_price": retailPrice, + "updater": updater, + }).Error +} + func (s *ShopPackageAllocationStore) GetByShopID(ctx context.Context, shopID uint) ([]*model.ShopPackageAllocation, error) { var allocations []*model.ShopPackageAllocation query := s.db.WithContext(ctx).Where("shop_id = ? AND status = 1", shopID) diff --git a/openspec/changes/client-api-data-model-fixes/.openspec.yaml b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/.openspec.yaml similarity index 100% rename from openspec/changes/client-api-data-model-fixes/.openspec.yaml rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/.openspec.yaml diff --git a/openspec/changes/client-api-data-model-fixes/design.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/design.md similarity index 97% rename from openspec/changes/client-api-data-model-fixes/design.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/design.md index da85f17..1ae7bed 100644 --- a/openspec/changes/client-api-data-model-fixes/design.md +++ b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/design.md @@ -15,7 +15,7 @@ - 所有字段新增使用 `NOT NULL DEFAULT` 确保存量数据兼容 - 数据库迁移可在线执行,不需停机 - 旧接口删除后 bootstrap、路由注册、文档生成器必须同步清理,否则编译失败 -- 本提案不涉及任何新 API 接口,纯粹是模型/字段/BUG 修复 +- 本提案新增 1 个后台接口:`PATCH /api/admin/packages/:id/retail-price`(代理修改自己的零售价) ## Goals / Non-Goals @@ -36,8 +36,8 @@ - 不实现任何客户端 API 接口(属于提案 1~3) - 不实现 ExchangeOrder 换货模型(属于提案 3) - 不实现 PersonalCustomerOpenID 模型(属于提案 1) -- 不修改后台管理界面或 Admin API -- 不新增 API 路由 +- 不修改后台管理界面 +- 除 `PATCH /api/admin/packages/:id/retail-price` 外,不新增其他 API 路由 - 不实现 asset_status 的状态流转逻辑(仅新增字段,流转逻辑在后续提案中实现) ## Decisions diff --git a/openspec/changes/client-api-data-model-fixes/proposal.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/proposal.md similarity index 82% rename from openspec/changes/client-api-data-model-fixes/proposal.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/proposal.md index 17056cd..5c96d4b 100644 --- a/openspec/changes/client-api-data-model-fixes/proposal.md +++ b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/proposal.md @@ -10,7 +10,7 @@ ### BUG 修复 -- **BUG-1 代理零售价修复**:`ShopPackageAllocation` 新增 `retail_price` 字段;`GetPurchasePrice()` 改为代理渠道查 `allocation.retail_price`、平台渠道用 `Package.SuggestedRetailPrice`;`validatePackages()` 内部价格累加同步修正;新增 cost_price 分配锁定规则(存在下级分配时禁止修改 cost_price);**扩展现有 `BatchUpdatePricing` 接口支持 `retail_price` 调整**(新增 `pricing_target` 字段区分调 cost_price 还是 retail_price,默认 `cost_price` 保持向后兼容);**代理套餐列表(`PackageResponse`)新增 `retail_price` 字段**,代理可查看自己的零售价;**利润计算修正**为 `RetailPrice - CostPrice`(代理实际利润 = 零售价 - 成本价,而非建议售价 - 成本价) +- **BUG-1 代理零售价修复**:`ShopPackageAllocation` 新增 `retail_price` 字段;`GetPurchasePrice()` 改为代理渠道查 `allocation.retail_price`、平台渠道用 `Package.SuggestedRetailPrice`;`validatePackages()` 内部价格累加同步修正;新增 cost_price 分配锁定规则(存在下级分配时禁止修改 cost_price);`BatchUpdatePricing` 接口仅支持成本价批量调整;新增独立接口 `PATCH /api/admin/packages/:id/retail-price` 供代理修改自己的零售价;**代理套餐列表(`PackageResponse`)新增 `retail_price` 字段**,代理可查看自己的零售价;**利润计算修正**为 `RetailPrice - CostPrice`(代理实际利润 = 零售价 - 成本价,而非建议售价 - 成本价) - **BUG-2 一次性佣金触发修复**:`Order` 新增 `source` 字段(`admin`/`client`);佣金触发条件从 `!order.IsPurchaseOnBehalf` 改为 `!order.IsPurchaseOnBehalf && order.Source == "client"`,确保只有客户端个人客户购买才触发 - **BUG-4 充值回调事务修复**:`HandlePaymentCallback` 中 `UpdateStatusWithOptimisticLock` 和 `UpdatePaymentInfo` 从 `s.db.WithContext(ctx)` 改为事务内 `tx`,确保充值单状态变更和钱包入账原子完成 @@ -37,7 +37,7 @@ - `asset-lifecycle-status`:资产业务生命周期状态管理。IotCard/Device 新增 `asset_status` 字段(在库→已销售→已换货→已停用),定义状态流转规则,与运营商 `network_status` 完全独立 - `asset-generation`:资产世代机制。IotCard/Device 的 `generation` 字段,关联表(Order/PackageUsage/AssetRechargeRecord)的 generation 写时快照规则,客户端按世代过滤、后台不过滤的查询规则 - `carrier-realname-config`:运营商实名链接配置。Carrier 模型新增 `realname_link_type`/`realname_link_template` 字段,支持 none/template/gateway 三种模式,URL 模板占位符替换。**Carrier admin DTO 同步更新**,后台可通过现有运营商管理接口配置实名链接 -- `agent-retail-price`:代理零售价管理。ShopPackageAllocation 新增 `retail_price` 字段,支持代理设定面向终端客户的零售价,约束 `retail_price >= cost_price`,cost_price 分配锁定规则。**扩展 `BatchUpdatePricing` 接口**支持 `pricing_target=retail_price` 批量调整零售价;**代理套餐列表展示 retail_price**;**利润计算修正**为 `RetailPrice - CostPrice` +- `agent-retail-price`:代理零售价管理。ShopPackageAllocation 新增 `retail_price` 字段,支持代理设定面向终端客户的零售价,约束 `retail_price >= cost_price`,cost_price 分配锁定规则。新增独立接口 `PATCH /api/admin/packages/:id/retail-price` 供代理修改自己的零售价;**代理套餐列表展示 retail_price**;**利润计算修正**为 `RetailPrice - CostPrice` - `asset-manual-deactivation`:资产手动停用。新增后台接口 `PATCH /api/admin/iot-cards/:id/deactivate` 和 `PATCH /api/admin/devices/:id/deactivate`,将 `asset_status` 设为 4(已停用),仅 `asset_status=1`(在库)或 `asset_status=2`(已销售)时可操作 - `h5-legacy-cleanup`:旧 H5 接口和旧登录接口的完整删除,包括 handler、route、bootstrap 注册、文档生成器引用的清理 @@ -45,7 +45,7 @@ - `package-purchase-validation`:`GetPurchasePrice()` 价格来源改为按渠道区分(代理→retail_price,平台→SuggestedRetailPrice);`validatePackages()` 价格累加逻辑同步修正 - `package-list`:代理查询套餐列表时,`PackageResponse` 新增 `retail_price` 字段;`ProfitMargin` 计算从 `SuggestedRetailPrice - CostPrice` 改为 `RetailPrice - CostPrice` -- `batch-pricing`:`BatchUpdatePricing` 接口扩展支持 `pricing_target` 参数(`cost_price`/`retail_price`),默认 `cost_price` 保持向后兼容;retail_price 调整时校验 `>= cost_price` +- `batch-pricing`:`BatchUpdatePricing` 接口仅支持 `cost_price` 批量调整;保留 `cost_price` 锁定校验(存在下级分配时不可修改) - `one-time-commission-trigger`:触发条件增加 `order.Source == "client"` 判断,确保仅客户端个人客户购买才触发 - `wallet-recharge`:`HandlePaymentCallback` 事务一致性修复,Store 方法支持传入事务 `tx` - `iot-order`:Order 模型新增 `source`(订单来源)和 `generation`(世代)字段;`CreateAdminOrder()` 创建订单时从资产快照当前 `generation` 写入订单(而非依赖默认值 1) @@ -57,8 +57,8 @@ ## Impact - **模型文件**:`shop_package_allocation.go`、`carrier.go`、`order.go`、`iot_card.go`、`device.go`、`package_usage.go`、`asset_recharge_record.go`、`personal_customer.go` -- **Service 文件**:`purchase_validation/service.go`(价格计算)、`commission_calculation/service.go`(佣金触发)、`recharge/service.go`(回调事务)、`shop_package_batch_pricing/service.go`(扩展支持 retail_price + cost_price 锁定)、`shop_series_grant/service.go`(cost_price 锁定)、`order/service.go`(source 设置 + generation 快照)、`package/service.go`(利润计算修正 + PackageResponse 新增 retail_price) -- **Handler/DTO 文件**:`shop_package_batch_pricing.go` Handler(扩展)、`shop_package_batch_pricing_dto.go`(新增 `pricing_target` 字段)、`package_dto.go`(`PackageResponse` 新增 `retail_price`)、`carrier_dto.go`(新增实名链接字段) +- **Service 文件**:`purchase_validation/service.go`(价格计算)、`commission_calculation/service.go`(佣金触发)、`recharge/service.go`(回调事务)、`shop_package_batch_pricing/service.go`(仅成本价批量调价 + cost_price 锁定)、`shop_series_grant/service.go`(cost_price 锁定)、`order/service.go`(source 设置 + generation 快照)、`package/service.go`(新增代理改零售价接口逻辑 + 利润计算修正 + PackageResponse 新增 retail_price) +- **Handler/DTO 文件**:`shop_package_batch_pricing.go` Handler(仅保留成本价批量调价)、`shop_package_batch_pricing_dto.go`(移除 `pricing_target` 字段)、`package.go` Handler(新增 `PATCH /packages/:id/retail-price`)、`package_dto.go`(`PackageResponse` 新增 `retail_price` + 新增更新零售价请求 DTO)、`carrier_dto.go`(新增实名链接字段) - **Store 文件**:`asset_recharge_store.go`(支持事务传入) - **删除文件**:`internal/handler/h5/` 全部(5 个文件)、`internal/routes/h5*.go`(3 个文件)、`internal/handler/app/personal_customer.go` 中旧方法 - **数据库迁移**:7 张表共 15+ 个字段变更,1 个索引变更 diff --git a/openspec/changes/client-api-data-model-fixes/specs/agent-retail-price/spec.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/agent-retail-price/spec.md similarity index 76% rename from openspec/changes/client-api-data-model-fixes/specs/agent-retail-price/spec.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/agent-retail-price/spec.md index 859acb7..fb4a616 100644 --- a/openspec/changes/client-api-data-model-fixes/specs/agent-retail-price/spec.md +++ b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/agent-retail-price/spec.md @@ -22,18 +22,12 @@ ### Requirement: 零售价约束规则 -系统 MUST 强制校验:`retail_price >= cost_price` 且 `retail_price <= suggested_retail_price * 2`。 +系统 MUST 强制校验:`retail_price >= cost_price`。 #### Scenario: 零售价低于成本价 - **WHEN** 代理设置 `retail_price < cost_price` - **THEN** 系统 MUST 拒绝保存并返回价格约束错误 -#### Scenario: 零售价超过建议价两倍 -- **WHEN** 代理设置 `retail_price > suggested_retail_price * 2` -- **THEN** 系统 MUST 拒绝保存并返回价格约束错误 - ---- - ### Requirement: 成本价分配锁定规则 当某分配存在下级分配记录时,系统 MUST 禁止修改该分配的 `cost_price`。 @@ -46,7 +40,7 @@ ### Requirement: 代理零售价可调与存量迁移 -系统 MUST 允许代理修改自己分配记录的 `retail_price`(在约束范围内);系统 MUST 对存量数据执行迁移:将 `retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`。 +系统 MUST 提供独立接口 `PATCH /api/admin/packages/:id/retail-price` 供代理修改自己分配记录的 `retail_price`(在约束范围内);系统 MUST 对存量数据执行迁移:将 `retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`。 #### Scenario: 代理调整自己的零售价 - **WHEN** 代理修改自己分配记录的 `retail_price` 且满足价格约束 diff --git a/openspec/changes/client-api-data-model-fixes/specs/asset-generation/spec.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/asset-generation/spec.md similarity index 100% rename from openspec/changes/client-api-data-model-fixes/specs/asset-generation/spec.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/asset-generation/spec.md diff --git a/openspec/changes/client-api-data-model-fixes/specs/asset-lifecycle-status/spec.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/asset-lifecycle-status/spec.md similarity index 100% rename from openspec/changes/client-api-data-model-fixes/specs/asset-lifecycle-status/spec.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/asset-lifecycle-status/spec.md diff --git a/openspec/changes/client-api-data-model-fixes/specs/asset-recharge-adaptation/spec.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/asset-recharge-adaptation/spec.md similarity index 92% rename from openspec/changes/client-api-data-model-fixes/specs/asset-recharge-adaptation/spec.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/asset-recharge-adaptation/spec.md index d88f2d3..eb47d35 100644 --- a/openspec/changes/client-api-data-model-fixes/specs/asset-recharge-adaptation/spec.md +++ b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/asset-recharge-adaptation/spec.md @@ -1,6 +1,6 @@ -## MODIFIED Requirements +## ADDED Requirements -### Requirement: 资产充值表结构变更 +### Requirement: 资产充值记录扩展字段(操作人与代际) 系统 MUST 在 `tb_asset_recharge_record` 新增以下字段: diff --git a/openspec/changes/client-api-data-model-fixes/specs/carrier-realname-config/spec.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/carrier-realname-config/spec.md similarity index 100% rename from openspec/changes/client-api-data-model-fixes/specs/carrier-realname-config/spec.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/carrier-realname-config/spec.md diff --git a/openspec/changes/client-api-data-model-fixes/specs/device/spec.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/device/spec.md similarity index 94% rename from openspec/changes/client-api-data-model-fixes/specs/device/spec.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/device/spec.md index 49f1fd5..590d5ec 100644 --- a/openspec/changes/client-api-data-model-fixes/specs/device/spec.md +++ b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/device/spec.md @@ -1,4 +1,4 @@ -## MODIFIED Requirements +## ADDED Requirements ### Requirement: 设备实体定义 diff --git a/openspec/changes/client-api-data-model-fixes/specs/h5-legacy-cleanup/spec.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/h5-legacy-cleanup/spec.md similarity index 100% rename from openspec/changes/client-api-data-model-fixes/specs/h5-legacy-cleanup/spec.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/h5-legacy-cleanup/spec.md diff --git a/openspec/changes/client-api-data-model-fixes/specs/iot-card/spec.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/iot-card/spec.md similarity index 69% rename from openspec/changes/client-api-data-model-fixes/specs/iot-card/spec.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/iot-card/spec.md index 1af2df5..79ad7fd 100644 --- a/openspec/changes/client-api-data-model-fixes/specs/iot-card/spec.md +++ b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/iot-card/spec.md @@ -1,8 +1,8 @@ -## MODIFIED Requirements +## ADDED Requirements -### Requirement: IoT 卡实体定义 +### Requirement: IoT 卡资产生命周期字段 -系统 SHALL 在 `IotCard` 模型新增以下字段: +系统 SHALL 在 `IotCard` 模型新增以下资产生命周期追踪字段: - `asset_status int NOT NULL DEFAULT 1` - `generation int NOT NULL DEFAULT 1` diff --git a/openspec/changes/client-api-data-model-fixes/specs/iot-order/spec.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/iot-order/spec.md similarity index 79% rename from openspec/changes/client-api-data-model-fixes/specs/iot-order/spec.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/iot-order/spec.md index a4c63a0..d75b1d8 100644 --- a/openspec/changes/client-api-data-model-fixes/specs/iot-order/spec.md +++ b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/iot-order/spec.md @@ -1,8 +1,8 @@ -## MODIFIED Requirements +## ADDED Requirements -### Requirement: 订单实体定义 +### Requirement: 订单来源与代际字段 -系统 SHALL 定义订单(Order)实体并新增来源与代际字段: +系统 SHALL 在订单(Order)实体新增来源与代际字段: - `source varchar(20) NOT NULL DEFAULT 'admin'`,取值 `admin/client` - `generation int NOT NULL DEFAULT 1` diff --git a/openspec/changes/client-api-data-model-fixes/specs/one-time-commission-trigger/spec.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/one-time-commission-trigger/spec.md similarity index 96% rename from openspec/changes/client-api-data-model-fixes/specs/one-time-commission-trigger/spec.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/one-time-commission-trigger/spec.md index d0692b0..a5bf060 100644 --- a/openspec/changes/client-api-data-model-fixes/specs/one-time-commission-trigger/spec.md +++ b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/one-time-commission-trigger/spec.md @@ -1,4 +1,4 @@ -## MODIFIED Requirements +## ADDED Requirements ### Requirement: 一次性佣金触发条件 diff --git a/openspec/changes/client-api-data-model-fixes/specs/package-purchase-validation/spec.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/package-purchase-validation/spec.md similarity index 83% rename from openspec/changes/client-api-data-model-fixes/specs/package-purchase-validation/spec.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/package-purchase-validation/spec.md index 968497c..56b2acc 100644 --- a/openspec/changes/client-api-data-model-fixes/specs/package-purchase-validation/spec.md +++ b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/package-purchase-validation/spec.md @@ -1,8 +1,8 @@ -## MODIFIED Requirements +## ADDED Requirements -### Requirement: 获取购买价格 +### Requirement: 代理渠道购买价格规则 -系统 MUST 根据购买渠道返回正确的购买价格。 +系统 MUST 根据购买渠道返回正确的购买价格:代理渠道使用 `allocation.retail_price`,平台渠道使用 `Package.SuggestedRetailPrice`。 #### Scenario: 代理渠道使用分配零售价 - **WHEN** 客户通过代理渠道购买套餐 diff --git a/openspec/changes/client-api-data-model-fixes/specs/personal-customer/spec.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/personal-customer/spec.md similarity index 95% rename from openspec/changes/client-api-data-model-fixes/specs/personal-customer/spec.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/personal-customer/spec.md index 17e7e22..e29ca72 100644 --- a/openspec/changes/client-api-data-model-fixes/specs/personal-customer/spec.md +++ b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/personal-customer/spec.md @@ -1,4 +1,4 @@ -## MODIFIED Requirements +## ADDED Requirements ### Requirement: 微信标识索引策略 diff --git a/openspec/changes/client-api-data-model-fixes/specs/wallet-recharge/spec.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/wallet-recharge/spec.md similarity index 71% rename from openspec/changes/client-api-data-model-fixes/specs/wallet-recharge/spec.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/wallet-recharge/spec.md index 6d095d2..24f2234 100644 --- a/openspec/changes/client-api-data-model-fixes/specs/wallet-recharge/spec.md +++ b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/specs/wallet-recharge/spec.md @@ -1,10 +1,8 @@ -## MODIFIED Requirements +## ADDED Requirements -### Requirement: 充值支付回调处理 +### Requirement: 充值回调事务一致性 -系统 SHALL 处理微信和支付宝的支付回调,验证签名,更新充值订单状态,增加钱包余额。 - -关键一致性修复:`HandlePaymentCallback` 内的 `UpdateStatusWithOptimisticLock` 与 `UpdatePaymentInfo` MUST 使用同一个事务内 `tx` 执行。 +`HandlePaymentCallback` 内的 `UpdateStatusWithOptimisticLock` 与 `UpdatePaymentInfo` MUST 使用同一个事务内 `tx` 执行,保证充值状态与支付信息的原子性。 #### Scenario: 回调处理中状态更新与支付信息更新同事务 - **WHEN** 收到支付成功回调并进入 `HandlePaymentCallback` diff --git a/openspec/changes/client-api-data-model-fixes/tasks.md b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/tasks.md similarity index 91% rename from openspec/changes/client-api-data-model-fixes/tasks.md rename to openspec/changes/archive/2026-03-19-client-api-data-model-fixes/tasks.md index 4f7b2cd..41de076 100644 --- a/openspec/changes/client-api-data-model-fixes/tasks.md +++ b/openspec/changes/archive/2026-03-19-client-api-data-model-fixes/tasks.md @@ -42,11 +42,15 @@ ## 5. 代理零售价后台管理 -- [x] 5.1 修改 `internal/model/dto/shop_package_batch_pricing_dto.go` 的 `BatchUpdateCostPriceRequest`:新增 `PricingTarget string` 字段(`json:"pricing_target" validate:"omitempty,oneof=cost_price retail_price" description:"调价目标 cost_price-成本价(默认) retail_price-零售价"`),不传时默认为 `cost_price` 保持向后兼容 -- [x] 5.2 修改 `internal/service/shop_package_batch_pricing/service.go` 的 `BatchUpdatePricing()` 方法:根据 `req.PricingTarget` 分流——`cost_price` 走现有逻辑(含 4.5 的锁定检查),`retail_price` 走新逻辑:计算新零售价、校验 `newRetailPrice >= allocation.CostPrice`(不满足则跳过并报错"零售价不能低于成本价")、更新 `allocation.RetailPrice`、记录价格历史(`ShopPackageAllocationPriceHistory` 增加 `old_retail_price`/`new_retail_price` 字段或复用 `OldCostPrice`/`NewCostPrice` 字段并新增 `price_type` 标识) -- [x] 5.3 修改 `internal/model/dto/package_dto.go` 的 `PackageResponse` 结构体:新增 `RetailPrice *int64` 字段(`json:"retail_price,omitempty" description:"代理零售价(分),仅代理用户可见"`) -- [x] 5.4 修改 `internal/service/package/service.go` 的 `toResponse()` 方法(约第 530-541 行):代理用户查询时,从 allocation 读取 `RetailPrice` 设入 `resp.RetailPrice`;同时修正 `ProfitMargin` 计算:从 `pkg.SuggestedRetailPrice - allocation.CostPrice` 改为 `allocation.RetailPrice - allocation.CostPrice` -- [x] 5.5 修改 `internal/service/package/service.go` 的 `toResponseWithAllocation()` 方法(约第 595-603 行):同 5.4,从 allocation 读取 `RetailPrice`、修正 `ProfitMargin` 计算 +- [x] 5.1 修改 `internal/model/dto/shop_package_batch_pricing_dto.go` 的 `BatchUpdateCostPriceRequest`:删除 `PricingTarget` 字段,批量调价接口仅保留成本价路径 +- [x] 5.2 修改 `internal/service/shop_package_batch_pricing/service.go` 的 `BatchUpdatePricing()` 方法:删除 `retail_price` 分支与默认分流逻辑,仅保留 `cost_price` 调整(包含 4.5 的锁定检查) +- [x] 5.3 在 `internal/model/dto/package_dto.go` 新增 `UpdateRetailPriceRequest` 与 `UpdateRetailPriceParams`,用于代理修改自己零售价 +- [x] 5.4 在 `internal/store/postgres/shop_package_allocation_store.go` 新增 `UpdateRetailPrice(ctx context.Context, id uint, retailPrice int64, updater uint) error` +- [x] 5.5 在 `internal/service/package/service.go` 新增 `UpdateRetailPrice(ctx context.Context, packageID uint, retailPrice int64) error`:仅代理可调用、校验 `retail_price >= cost_price` +- [x] 5.6 在 `internal/handler/admin/package.go` 新增 `UpdateRetailPrice`,并在 `internal/routes/package.go` 注册 `PATCH /api/admin/packages/:id/retail-price` +- [x] 5.7 修改 `internal/model/dto/package_dto.go` 的 `PackageResponse` 结构体:新增 `RetailPrice *int64` 字段(`json:"retail_price,omitempty" description:"代理零售价(分),仅代理用户可见"`) +- [x] 5.8 修改 `internal/service/package/service.go` 的 `toResponse()` 方法(约第 530-541 行):代理用户查询时,从 allocation 读取 `RetailPrice` 设入 `resp.RetailPrice`;同时修正 `ProfitMargin` 计算:从 `pkg.SuggestedRetailPrice - allocation.CostPrice` 改为 `allocation.RetailPrice - allocation.CostPrice` +- [x] 5.9 修改 `internal/service/package/service.go` 的 `toResponseWithAllocation()` 方法(约第 595-603 行):同 5.8,从 allocation 读取 `RetailPrice`、修正 `ProfitMargin` 计算 ## 6. Carrier 管理 DTO 更新 @@ -102,6 +106,6 @@ - [x] 13.2 对所有修改的文件执行 `lsp_diagnostics` 确认无错误和警告 - [x] 13.3 使用 PostgreSQL MCP 工具验证数据库:确认 7 张表的新字段存在、默认值正确、存量 `retail_price` 已填充、`wx_open_id` 索引已变更 - [x] 13.4 验证删除的 H5 路由不再注册:检查代码中无 `/api/h5` 相关路由残留 -- [x] 13.5 验证 `BatchUpdatePricing` 接口扩展:确认 `pricing_target=retail_price` 参数可正常使用,不传时默认走 `cost_price` 逻辑(向后兼容) +- [x] 13.5 验证 `BatchUpdatePricing` 接口仅支持成本价调整;并验证 `PATCH /api/admin/packages/:id/retail-price` 可供代理修改自己的零售价 - [x] 13.6 验证代理套餐列表:确认 `PackageResponse` 包含 `retail_price` 字段,`profit_margin` 计算基于 `retail_price - cost_price` - [x] 13.7 撰写功能总结文档 `docs/client-api-data-model-fixes/功能总结.md`,记录所有变更内容 diff --git a/openspec/changes/client-auth-system/.openspec.yaml b/openspec/changes/archive/2026-03-19-client-auth-system/.openspec.yaml similarity index 100% rename from openspec/changes/client-auth-system/.openspec.yaml rename to openspec/changes/archive/2026-03-19-client-auth-system/.openspec.yaml diff --git a/openspec/changes/client-auth-system/design.md b/openspec/changes/archive/2026-03-19-client-auth-system/design.md similarity index 100% rename from openspec/changes/client-auth-system/design.md rename to openspec/changes/archive/2026-03-19-client-auth-system/design.md diff --git a/openspec/changes/client-auth-system/proposal.md b/openspec/changes/archive/2026-03-19-client-auth-system/proposal.md similarity index 100% rename from openspec/changes/client-auth-system/proposal.md rename to openspec/changes/archive/2026-03-19-client-auth-system/proposal.md diff --git a/openspec/changes/client-auth-system/specs/client-asset-token/spec.md b/openspec/changes/archive/2026-03-19-client-auth-system/specs/client-asset-token/spec.md similarity index 100% rename from openspec/changes/client-auth-system/specs/client-asset-token/spec.md rename to openspec/changes/archive/2026-03-19-client-auth-system/specs/client-asset-token/spec.md diff --git a/openspec/changes/client-auth-system/specs/client-phone-binding/spec.md b/openspec/changes/archive/2026-03-19-client-auth-system/specs/client-phone-binding/spec.md similarity index 100% rename from openspec/changes/client-auth-system/specs/client-phone-binding/spec.md rename to openspec/changes/archive/2026-03-19-client-auth-system/specs/client-phone-binding/spec.md diff --git a/openspec/changes/client-auth-system/specs/client-token-management/spec.md b/openspec/changes/archive/2026-03-19-client-auth-system/specs/client-token-management/spec.md similarity index 100% rename from openspec/changes/client-auth-system/specs/client-token-management/spec.md rename to openspec/changes/archive/2026-03-19-client-auth-system/specs/client-token-management/spec.md diff --git a/openspec/changes/client-auth-system/specs/client-wechat-login/spec.md b/openspec/changes/archive/2026-03-19-client-auth-system/specs/client-wechat-login/spec.md similarity index 100% rename from openspec/changes/client-auth-system/specs/client-wechat-login/spec.md rename to openspec/changes/archive/2026-03-19-client-auth-system/specs/client-wechat-login/spec.md diff --git a/openspec/changes/client-auth-system/specs/personal-customer-openid/spec.md b/openspec/changes/archive/2026-03-19-client-auth-system/specs/personal-customer-openid/spec.md similarity index 100% rename from openspec/changes/client-auth-system/specs/personal-customer-openid/spec.md rename to openspec/changes/archive/2026-03-19-client-auth-system/specs/personal-customer-openid/spec.md diff --git a/openspec/changes/client-auth-system/specs/personal-customer/spec.md b/openspec/changes/archive/2026-03-19-client-auth-system/specs/personal-customer/spec.md similarity index 98% rename from openspec/changes/client-auth-system/specs/personal-customer/spec.md rename to openspec/changes/archive/2026-03-19-client-auth-system/specs/personal-customer/spec.md index 850b9f7..70b8e6f 100644 --- a/openspec/changes/client-auth-system/specs/personal-customer/spec.md +++ b/openspec/changes/archive/2026-03-19-client-auth-system/specs/personal-customer/spec.md @@ -1,6 +1,6 @@ # personal-customer Specification -## MODIFIED Requirements +## ADDED Requirements ### Requirement: 个人客户登录主流程改为微信授权 diff --git a/openspec/changes/client-auth-system/specs/wechat-official-account/spec.md b/openspec/changes/archive/2026-03-19-client-auth-system/specs/wechat-official-account/spec.md similarity index 98% rename from openspec/changes/client-auth-system/specs/wechat-official-account/spec.md rename to openspec/changes/archive/2026-03-19-client-auth-system/specs/wechat-official-account/spec.md index ca8f87f..ec8da84 100644 --- a/openspec/changes/client-auth-system/specs/wechat-official-account/spec.md +++ b/openspec/changes/archive/2026-03-19-client-auth-system/specs/wechat-official-account/spec.md @@ -1,6 +1,6 @@ # wechat-official-account Specification -## MODIFIED Requirements +## ADDED Requirements ### Requirement: 微信配置源从 YAML 改为数据库动态读取 diff --git a/openspec/changes/client-auth-system/tasks.md b/openspec/changes/archive/2026-03-19-client-auth-system/tasks.md similarity index 100% rename from openspec/changes/client-auth-system/tasks.md rename to openspec/changes/archive/2026-03-19-client-auth-system/tasks.md diff --git a/openspec/changes/client-exchange-system/.openspec.yaml b/openspec/changes/archive/2026-03-19-client-core-business-api/.openspec.yaml similarity index 100% rename from openspec/changes/client-exchange-system/.openspec.yaml rename to openspec/changes/archive/2026-03-19-client-core-business-api/.openspec.yaml diff --git a/openspec/changes/archive/2026-03-19-client-core-business-api/design.md b/openspec/changes/archive/2026-03-19-client-core-business-api/design.md new file mode 100644 index 0000000..03144a7 --- /dev/null +++ b/openspec/changes/archive/2026-03-19-client-core-business-api/design.md @@ -0,0 +1,177 @@ +# 设计文档:client-core-business-api + +## Context + +认证系统(提案 1)就绪后,客户端需要一套完整业务接口以覆盖资产查询、钱包充值、套餐购买、实名跳转与设备操作。当前后台接口的 Service 层大部分能力可复用,但客户端场景存在以下关键差异: + +1. 个人客户访问资源不应受 shop_id 数据权限过滤影响,需要在调用链中显式绕过。 +2. 资产操作必须先做归属校验(绑定关系),避免跨客户操作。 +3. 历史数据查询需要按资产当前 generation 过滤,避免展示转手前历史。 +4. 支付 OpenID 必须后端查表获取,禁止客户端传入,降低伪造风险。 + +现有代码参考(复用/改造基线): + +- `asset.Service.Resolve()`:`internal/service/asset/service.go:71` +- `asset.Service.Refresh()`:`internal/service/asset/service.go:295` +- `asset.Service.GetPackages()`:`internal/service/asset/service.go:347` +- `recharge.Service.GetRechargeCheck()`:`internal/service/recharge/service.go:168` +- `recharge.Service.Create()`:`internal/service/recharge/service.go:83` +- `order.Service.CreateH5Order()`:`internal/service/order/service.go:632` +- `order.Service.checkForceRechargeRequirement()`:`internal/service/order/service.go:2216` +- `order.Service.WechatPayJSAPI()`:`internal/service/order/service.go:2095` +- `purchaseValidation.ValidateCardPurchase()`:`internal/service/purchase_validation/service.go:44` +- Gateway 设备能力:`internal/gateway/device.go:41-67` +- Gateway 实名链接:`internal/gateway/flow_card.go:44` + +## Goals + +本次变更目标是交付 `/api/c/v1/` 下 18 个客户端业务端点,覆盖 5 个模块: + +- 模块 B(资产信息):B1~B4 +- 模块 C(钱包与充值):C1~C5 +- 模块 D(套餐购买):D1~D3 +- 模块 E(实名跳转):E1 +- 模块 F(设备能力):F1~F5 + +## Non-Goals + +- 不改动后台管理端 API 行为与路由。 +- 不引入或改造 exchange(交易所)体系。 +- 不重做既有支付网关对接协议,仅在客户端入口补齐调用与安全约束。 + +## Decisions + +### 1) Handler 组织 + +客户端 Handler 按模块拆分为 5 个文件,统一放在 `internal/handler/app/`: + +- `client_asset.go` +- `client_wallet.go` +- `client_order.go` +- `client_realname.go` +- `client_device.go` + +这样可保持模块边界清晰,减少单文件复杂度,便于后续迭代。 + +### 2) Service 复用策略 + +- 直接复用:B1/B3/B4/C1/C2/C3 复用现有 Service 能力(补充客户端上下文约束)。 +- 新增逻辑:B2 需新增“渠道价 + 加油包前置校验 + 不可售过滤 + 价格排序”逻辑。 +- 新建 `client_order` Service:C4/D1 引入客户端订单编排,复用 `order/recharge` 的底层能力但增加客户端专属流程控制。 + +### 3) 数据权限绕过 + +客户端调用 `asset/wallet` 等后台复用 Service 时,统一使用 `gorm.SkipDataPermission(ctx)`,绕过 shop_id 自动过滤,避免个人客户因非店铺主体被误拦截。 + +### 4) 归属校验方案 + +所有涉及资产操作接口统一前置: + +- 查询 `PersonalCustomerDevice` 条件:`customer_id = 当前登录客户` 且 `virtual_no = 资产虚拟号` +- 未命中即返回 403:`无权限操作该资产或资源不存在` + +该规则覆盖 B/C/D/E/F 全模块写操作与敏感读操作。 + +### 5) Generation 过滤 + +客户端历史查询统一附加条件:`WHERE generation = 资产当前 generation`,适用于订单、充值、套餐历史。 + +- 客户端:必须过滤 +- 后台:不加该过滤(保留全量视图) + +### 6) OpenID 安全规范 + 微信支付 SDK 实例选择 + +支付相关接口(C4/D1)所需 OpenID 必须由后端按 `customer_id + app_type` 查询 `PersonalCustomerOpenID`。 + +- 客户端请求体禁止携带 `openid`,仅传 `app_type`(`official_account` 或 `miniapp`) +- 缺失时返回 `OPENID_NOT_FOUND` + +**微信支付 SDK 实例选择逻辑**: + +支付时需根据 `app_type` 创建不同的 `PaymentService` 实例,因为微信 JSAPI 支付绑定的 AppID 必须与用户 OpenID 所属的应用一致: + +``` +客户端传入 app_type + │ + ├─ "official_account" → 用 WechatConfig.oa_app_id 创建 Payment 实例 + │ → 查 PersonalCustomerOpenID WHERE app_id=oa_app_id 获取 openid + │ + └─ "miniapp" → 用 WechatConfig.miniapp_app_id 创建 Payment 实例 + → 查 PersonalCustomerOpenID WHERE app_id=miniapp_app_id 获取 openid +``` + +**使用的现有 SDK 方法**(`pkg/wechat/payment.go`,不需要修改): + +| 方法 | 签名 | 用途 | +|------|------|------| +| `CreateJSAPIOrder` | `(ctx, orderNo, description, openID string, amount int) (*JSAPIPayResult, error)` | 公众号/小程序内拉起支付,返回 `prepay_id` + `PayConfig`(可直接传给前端 `wx.requestPayment`) | +| `HandlePaymentNotify` | `(r *http.Request, callback PaymentNotifyCallback) (*http.Response, error)` | 支付回调验签+解密,回调函数接收 `*PaymentNotifyResult` | +| `QueryOrder` | `(ctx, orderNo string) (*OrderInfo, error)` | 主动查询订单状态 | +| `CloseOrder` | `(ctx, orderNo string) error` | 关闭未支付订单 | + +**SDK 实例创建**(使用提案 1 新增的工厂函数): + +```go +// 在 client_order Service 中: +config, _ := s.wechatConfigService.GetActiveConfig(ctx) // 从 DB/Redis 缓存 +appID := config.OaAppID +if req.AppType == "miniapp" { + appID = config.MiniappAppID +} +paymentApp, _ := wechat.NewPaymentAppFromConfig(config, appID, cache, logger) +paymentService := wechat.NewPaymentService(paymentApp, logger) +// 调用 paymentService.CreateJSAPIOrder(ctx, orderNo, desc, openID, amount) +``` + +**注意**:`CreateH5Order`(外部浏览器 H5 支付)在客户端场景中**不使用**——客户端始终在微信内(公众号 H5 或小程序),一律走 JSAPI 支付。 + +### 7) 强充两阶段设计 + +强充场景采用“同步入账 + 异步自动购买”两阶段: + +- 第一阶段(同步事务内): + 1. 钱入钱包 + 2. 更新充值记录状态 + 3. 更新累计充值/首充状态 +- 第二阶段(异步 Asynq): + 1. 从钱包扣款 + 2. 创建套餐订单 + 3. 激活套餐 + +`AssetRechargeRecord` 新增 `auto_purchase_status` 字段追踪异步状态(pending/success/failed)。 + +### 8) D1 返回结构分流 + +`POST /api/c/v1/orders/create` 根据是否触发强充返回不同结构: + +- `order_type = "package"`:直接返回 `order + pay_config` +- `order_type = "recharge"`:返回 `recharge + pay_config + linked_package_info` + +前端据 `order_type` 决定支付结果页与文案。 + +### 9) 实名闭环说明 + +运营商实名为外部流程,无平台回调。用户完成实名后需主动触发 B4 刷新资产状态,再重新发起购买流程。 + +## Risks / Trade-offs + +1. **强充异步失败风险**:第二阶段失败会导致“钱已到账、套餐未生效”的中间态。权衡后采用可重试 + `auto_purchase_status=failed` + 用户可手动购买的降级方案。 +2. **Gateway 超时风险**:B4/F2/F3/F4/F5/E1(gateway) 依赖外部网关,网络抖动可能放大请求延迟。需统一超时、重试与可观测日志。 +3. **OpenID 缺失风险**:用户未完成公众号授权时无法拉起支付。需明确错误码 `OPENID_NOT_FOUND` 并引导重新授权。 +4. **Generation 不一致风险**:资产转手或切换后,若查询未按 generation 过滤会出现历史串数据。客户端侧强制过滤会增加查询条件复杂度,但可换取数据隔离正确性。 +5. **服务复用边界风险**:复用后台 Service 可加速交付,但若遗漏客户端前置条件(归属、权限绕过),会造成逻辑缺口。需在 Handler 层统一封装公共校验。 + +## Migration Plan + +数据库迁移新增字段: + +- 表:`tb_asset_recharge_record` +- 字段:`auto_purchase_status`(建议 `varchar(20)`,默认 `pending`) +- 用途:记录强充回调后二阶段自动购买状态 + +迁移步骤: + +1. 新增迁移文件(up/down)。 +2. 执行迁移并确认版本无 dirty。 +3. 更新对应 Model 与常量枚举。 +4. 回调逻辑写入状态流转:`pending -> success/failed`。 diff --git a/openspec/changes/archive/2026-03-19-client-core-business-api/proposal.md b/openspec/changes/archive/2026-03-19-client-core-business-api/proposal.md new file mode 100644 index 0000000..16fc693 --- /dev/null +++ b/openspec/changes/archive/2026-03-19-client-core-business-api/proposal.md @@ -0,0 +1,175 @@ +## Why + +认证系统就绪后(提案 1),客户端需要完整的业务接口来支撑核心使用场景:查看资产信息、购买套餐、钱包充值、查看订单、实名认证跳转、设备操作。本提案覆盖客户端**全部 15 个业务接口**,是 C 端体验的核心支撑。 + +其中**套餐购买含强充两阶段处理**是最复杂的接口,涉及强充判断 → 微信支付 → 充值回调 → 异步套餐购买的完整链路,需要特别严谨的设计。 + +**前置依赖**:提案 0(数据模型修复)、提案 1(认证系统)。 + +## What Changes + +### 模块 B:资产信息(4 个接口) + +- **B1 资产基本信息** `GET /api/c/v1/asset/info?identifier=xxx`:复用 `asset.Service.Resolve()`,个人客户调用不走 shop_id 数据权限过滤 +- **B2 可购买套餐列表** `GET /api/c/v1/asset/packages?identifier=xxx`:按渠道区分价格(代理→`allocation.retail_price`,平台→`SuggestedRetailPrice`);过滤条件包含 `Package.status`、`shelf_status`、加油包前置校验;按价格升序排序 +- **B3 历史套餐列表** `GET /api/c/v1/asset/package-history?identifier=xxx`:按资产当前 `generation` 过滤,复用 `dto.AssetPackageResponse` +- **B4 手动刷新** `POST /api/c/v1/asset/refresh`:卡类型调 Gateway 刷新;设备类型有 Redis 冷却时间 + +### 模块 C:钱包与充值(5 个接口) + +- **C1 钱包详情** `GET /api/c/v1/wallet/detail?identifier=xxx`:不存在则自动创建空钱包 +- **C2 钱包流水列表** `GET /api/c/v1/wallet/transactions?identifier=xxx`:通过 wallet_id 天然隔离(不需 generation 过滤),支持 transaction_type / 时间范围筛选 +- **C3 充值预检** `GET /api/c/v1/wallet/recharge-check?identifier=xxx`:复用 `recharge.Service.GetRechargeCheck()`,返回是否需要强充、强充金额、触发类型 +- **C4 创建充值订单** `POST /api/c/v1/wallet/recharge`:客户端仅支持微信支付;OpenID 由后端查表获取(安全规范);`operator_type=personal_customer`;`generation` 写时快照;拉起 JSAPI 支付 +- **C5 充值订单列表** `GET /api/c/v1/wallet/recharges?identifier=xxx`:按 `generation` 过滤 + +### 模块 D:套餐购买(3 个接口,含核心强充流程) + +- **D1 创建套餐购买订单** `POST /api/c/v1/orders/create`:**核心接口**。含资产归属校验、套餐校验、实名校验、强充两阶段处理、幂等性保证 +- **D2 套餐订单列表** `GET /api/c/v1/orders?identifier=xxx`:按 `generation` 过滤 +- **D3 套餐订单详情** `GET /api/c/v1/orders/:id`:归属校验(通过资产虚拟号匹配 PersonalCustomerDevice) + +### 模块 E:实名认证(1 个接口) + +- **E1 获取实名跳转链接** `GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx`:两个入口(购买拦截 / 设备卡列表主动选择);三种模式(none/template/gateway) + +### 模块 F:设备能力(5 个接口) + +- **F1 设备卡列表** `GET /api/c/v1/device/cards?identifier=xxx`:从 CMP 数据库查,不调 Gateway +- **F2 设备重启** `POST /api/c/v1/device/reboot` +- **F3 恢复出厂** `POST /api/c/v1/device/factory-reset` +- **F4 设置 WiFi** `POST /api/c/v1/device/wifi`:注意 Gateway WiFiReq 的 cardNo 字段实际传入设备 IMEI +- **F5 切卡** `POST /api/c/v1/device/switch-card` + +### D1 套餐购买核心流程(含强充两阶段) + +``` +客户端发起 POST /api/c/v1/orders/create + │ + ▼ +① 解析标识符 → card/device + asset_type + asset_id + │ + ▼ +② 资产归属校验 + 查 PersonalCustomerDevice WHERE customer_id=? AND virtual_no=? + 未绑定 → 403 "无权操作该资产" + │ + ▼ +③ 套餐购买校验 + 调 purchaseValidationService.ValidateCardPurchase/ValidateDevicePurchase + 检查: series_id、Package.status、shelf_status、加油包前置条件 + │ + ▼ +④ 实名校验 + 套餐 enable_realname_activation=true 且卡 real_name_status=0 + → 返回 { code: NEED_REALNAME, need_realname: true } + │ + ▼ +⑤ OpenID 查询 + 根据 customer_id + app_type 从 PersonalCustomerOpenID 查询 openid + 找不到 → 返回 { code: OPENID_NOT_FOUND } + │ + ▼ +⑥ 幂等性检查(Redis 业务键 + 分布式锁) + │ + ▼ +⑦ 强充检查 + 调 checkForceRechargeRequirement() + │ + ├─── 不需要强充 ──────────────────────────────┐ + │ │ + │ ⑧A 创建 Order │ + │ (source="client", generation=当前) │ + │ 拉起微信 JSAPI 支付 │ + │ → 返回 { order_type: "package", │ + │ order, pay_config } │ + │ │ + └─── 需要强充 ────────────────────────┐ │ + │ │ │ + ▼ │ │ + pay_amount = max(force_amount, │ │ + package_total_price) │ │ + │ │ │ + ▼ │ │ + ⑧B 创建 AssetRechargeRecord │ │ + (linked_package_ids=package_ids, │ │ + generation=当前) │ │ + 拉起微信 JSAPI 支付 │ │ + → 返回 { order_type: "recharge", │ │ + recharge, pay_config, │ │ + linked_package_info } │ │ + │ │ +═══════════════════════════════════════════╧════════╧═══ + +强充支付成功后的两阶段回调处理: + +微信支付成功 → 充值回调 + │ + ▼ +第一阶段(同步,事务内): +├── 1. 钱入钱包(余额增加) +├── 2. 更新充值单状态为已完成 +├── 3. 更新累计/首充状态 +└── 4. 检查一次性佣金触发 + │ + ▼ +第二阶段(异步,Asynq 任务): +├── 5. 入队 AutoPurchaseAfterRecharge(recharge_record_id) +├── 6. 异步执行: +│ ├── a. 从钱包扣款(payment_method=wallet) +│ ├── b. 创建 Order(source="client", generation=当前) +│ └── c. 激活套餐 +└── 7. 失败处理: + ├── a. Asynq 自动重试(最多 3 次) + ├── b. 全部失败 → 标记 auto_purchase_status="failed" + └── c. 钱已在钱包中,用户可手动操作 +``` + +### 实名跳转流程 + +``` +前端调用 GET /api/c/v1/realname/link?identifier=xxx[&iccid=yyy] + │ + ▼ +解析标识符 → 确定目标卡: +├── 直接是卡 → 用该卡 +├── 是设备 + 传了 iccid → 查该 iccid 对应的卡 +└── 是设备 + 没传 iccid → 查 DeviceSimBinding 中 isActive=1 的卡 + │ + ▼ +检查 card.real_name_status == 1? +├── YES → "该卡已完成实名" +└── NO + │ + ▼ +查 Carrier WHERE id=card.carrier_id → 获取 realname_link_type: +├── 'none' → "该运营商暂不支持在线实名" +├── 'template' → 替换占位符 {iccid}/{msisdn}/{virtual_no} → 返回 URL +└── 'gateway' → 调 gateway.GetRealnameLink(card.ICCID) → 返回 URL +``` + +## Capabilities + +### New Capabilities + +- `client-asset-info`:客户端资产信息查询(B1)、可购买套餐列表(B2,含渠道价格、加油包校验、上下架过滤)、历史套餐列表(B3,含 generation 过滤)、手动刷新(B4) +- `client-wallet-recharge`:客户端钱包详情(C1)、流水列表(C2)、充值预检(C3)、创建充值订单(C4,含 OpenID 安全规范、operator_type、generation 快照)、充值订单列表(C5) +- `client-order-purchase`:套餐购买订单创建(D1,含归属校验、实名校验、强充两阶段、幂等性)、订单列表(D2)、订单详情(D3)、强充回调异步购买(AutoPurchaseAfterRecharge Asynq 任务) +- `client-realname-link`:实名跳转链接(E1),三种模式、两个入口、设备多卡选择 +- `client-device-capability`:设备卡列表(F1)、重启(F2)、恢复出厂(F3)、WiFi 设置(F4)、切卡(F5) + +### Modified Capabilities + +- `asset-resolve`:Resolve 方法增加"无数据权限过滤"的客户端调用入口 +- `wallet-recharge`:充值回调增加两阶段处理——同步入账 + 异步自动购买;AssetRechargeRecord 新增 `auto_purchase_status` 字段跟踪异步购买状态 +- `package-purchase-validation`:增加客户端场景的归属校验(PersonalCustomerDevice)和实名校验拦截 +- `iot-order`:Order 新增客户端创建路径(source="client"),支持 generation 写入 +- `force-recharge-check`:强充检查结果输出给客户端,支持前端提示强充金额和套餐价格拆分 + +## Impact + +- **新增文件**:`internal/handler/app/client_asset.go`、`client_wallet.go`、`client_order.go`、`client_realname.go`、`client_device.go`(5 个 Handler);`internal/service/client_order/service.go`(客户端订单 Service);新增 Asynq 任务 `AutoPurchaseAfterRecharge`;新增 DTO 文件;常量和错误码 +- **修改文件**:`internal/service/order/service.go`(提取强充逻辑供客户端复用);`internal/service/recharge/service.go`(充值回调增加两阶段处理);`internal/service/asset/service.go`(增加无数据权限调用方式);`internal/routes/personal.go`(新增客户端业务路由);`internal/bootstrap/`(注册新模块);`cmd/api/docs.go` + `cmd/gendocs/main.go`(文档生成器) +- **新增 API 路由**:`/api/c/v1/` 下 18 个端点 +- **数据库变更**:AssetRechargeRecord 新增 `auto_purchase_status` 字段 +- **新增 Asynq 任务类型**:`task:auto_purchase_after_recharge` diff --git a/openspec/changes/archive/2026-03-19-client-core-business-api/specs/client-asset-info/spec.md b/openspec/changes/archive/2026-03-19-client-core-business-api/specs/client-asset-info/spec.md new file mode 100644 index 0000000..ca7d8bc --- /dev/null +++ b/openspec/changes/archive/2026-03-19-client-core-business-api/specs/client-asset-info/spec.md @@ -0,0 +1,41 @@ +# Capability: 客户端资产信息 + +## ADDED Requirements + +### Requirement: B1 资产基本信息查询接口 + +系统 SHALL 提供 `GET /api/c/v1/asset/info?identifier=xxx`,并且 MUST 要求个人客户认证(C 端 Token)。接口 MUST 复用 `asset.Service.Resolve()` 解析标识符,并在调用时使用 `gorm.SkipDataPermission(ctx)` 以绕过 shop_id 数据权限过滤。请求参数 MUST 包含 `identifier`(ICCID、虚拟号、设备号之一)。响应体 SHALL 返回 `asset_type`、`asset_id`、`identifier`、`virtual_no`、`status`、`real_name_status`、`carrier`、`generation`、`wallet_balance`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ASSET_NOT_FOUND/资产不存在`。 + +#### Scenario: 个人客户查询已绑定资产 +- **WHEN** 客户携带有效 Token 调用 `GET /api/c/v1/asset/info?identifier=8986xxxx` 且资产已绑定到本人 +- **THEN** 系统返回 200,包含资产基础信息与当前 generation + +--- + +### Requirement: B2 可购买套餐列表接口 + +系统 SHALL 提供 `GET /api/c/v1/asset/packages?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验通过后返回可购买套餐列表。价格规则 MUST 为:代理渠道取 `allocation.retail_price`,平台渠道取 `Package.SuggestedRetailPrice`。过滤规则 MUST 同时满足:`Package.status=1`、`shelf_status` 可售、加油包前置主套餐条件成立、`retail_price >= cost_price`。结果 MUST 按展示价格升序。响应体 SHALL 包含 `packages[]`,每项至少含 `package_id`、`package_name`、`package_type`、`retail_price`、`cost_price`、`validity`、`is_addon`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`PACKAGE_NOT_AVAILABLE/当前无可购买套餐`。 + +#### Scenario: 代理渠道价格与过滤生效 +- **WHEN** 客户查询可购套餐且其销售链路为代理渠道,部分套餐存在 `retail_price < cost_price` +- **THEN** 系统仅返回可售且满足价格约束的套餐,并按价格升序输出 + +--- + +### Requirement: B3 历史套餐列表接口 + +系统 SHALL 提供 `GET /api/c/v1/asset/package-history?identifier=xxx&page=1&page_size=20`,并且 MUST 要求个人客户认证。接口 MUST 基于标识符解析资产并进行归属校验。查询条件 MUST 自动追加 `generation = 资产当前generation`。请求参数 SHALL 支持 `page`、`page_size`(默认 20,最大 100)。响应体 SHALL 返回 `list[]`、`total`、`page`、`page_size`,列表项复用 `dto.AssetPackageResponse` 结构。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`。 + +#### Scenario: 转手后历史隔离 +- **WHEN** 资产已发生转手且存在历史套餐记录 +- **THEN** 系统只返回当前 generation 的记录,不返回旧 generation 数据 + +--- + +### Requirement: B4 手动刷新接口 + +系统 SHALL 提供 `POST /api/c/v1/asset/refresh`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。当资产为卡时 MUST 调用 Gateway 刷新卡信息;当资产为设备时 MUST 先检查 Redis 冷却窗口,再对设备下卡执行批量刷新。响应体 SHALL 返回 `refresh_type`(`card`/`device`)、`accepted`、`cooldown_seconds`(设备场景)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`TOO_MANY_REQUESTS/刷新过于频繁,请稍后重试`、`GATEWAY_ERROR/网关调用失败`。 + +#### Scenario: 设备刷新冷却拦截 +- **WHEN** 客户在冷却时间内重复调用设备刷新 +- **THEN** 系统返回频率限制错误并告知剩余冷却时间 diff --git a/openspec/changes/archive/2026-03-19-client-core-business-api/specs/client-device-capability/spec.md b/openspec/changes/archive/2026-03-19-client-core-business-api/specs/client-device-capability/spec.md new file mode 100644 index 0000000..b7de90d --- /dev/null +++ b/openspec/changes/archive/2026-03-19-client-core-business-api/specs/client-device-capability/spec.md @@ -0,0 +1,51 @@ +# Capability: 客户端设备能力 + +## ADDED Requirements + +### Requirement: F1 设备卡列表接口 + +系统 SHALL 提供 `GET /api/c/v1/device/cards?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 仅允许设备类型资产调用,且设备 MUST 具备 IMEI。响应体 SHALL 返回 `cards[]`,每项至少包含:`card_id`、`iccid`、`msisdn`、`carrier_name`、`network_status`、`real_name_status`、`slot_position`、`is_active`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ASSET_TYPE_INVALID/仅设备资产支持该操作`、`DEVICE_IMEI_REQUIRED/设备IMEI缺失`。 + +#### Scenario: 返回设备绑定卡列表 +- **WHEN** 客户查询已绑定设备卡列表 +- **THEN** 系统返回设备下全部卡及活跃标记 + +--- + +### Requirement: F2 设备重启接口 + +系统 SHALL 提供 `POST /api/c/v1/device/reboot`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.RebootDevice(imei)`。响应体 SHALL 返回 `accepted=true` 与 `request_id`(如网关返回)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ASSET_TYPE_INVALID/仅设备资产支持该操作`、`DEVICE_IMEI_REQUIRED/设备IMEI缺失`、`GATEWAY_ERROR/网关调用失败`。 + +#### Scenario: 设备重启成功受理 +- **WHEN** 客户对合法设备发起重启 +- **THEN** 系统调用网关成功并返回受理结果 + +--- + +### Requirement: F3 设备恢复出厂接口 + +系统 SHALL 提供 `POST /api/c/v1/device/factory-reset`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.ResetDevice(imei)`。响应体 SHALL 返回 `accepted=true`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ASSET_TYPE_INVALID/仅设备资产支持该操作`、`DEVICE_IMEI_REQUIRED/设备IMEI缺失`、`GATEWAY_ERROR/网关调用失败`。 + +#### Scenario: 恢复出厂失败返回网关错误 +- **WHEN** 网关返回失败 +- **THEN** 系统返回网关调用失败错误 + +--- + +### Requirement: F4 设备 WiFi 设置接口 + +系统 SHALL 提供 `POST /api/c/v1/device/wifi`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`、`ssid`、`password`、`enabled`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.SetWiFi(imei, ssid, password, enabled)`。实现 MUST 将 Gateway 的 `WiFiReq.cardNo` 填充为设备 IMEI。响应体 SHALL 返回 `accepted=true`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ASSET_TYPE_INVALID/仅设备资产支持该操作`、`DEVICE_IMEI_REQUIRED/设备IMEI缺失`、`GATEWAY_ERROR/网关调用失败`。 + +#### Scenario: WiFi 请求 cardNo 使用 IMEI +- **WHEN** 客户调用设备 WiFi 设置 +- **THEN** 系统向网关发送的 `cardNo` 字段值为设备 IMEI + +--- + +### Requirement: F5 设备切卡接口 + +系统 SHALL 提供 `POST /api/c/v1/device/switch-card`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`、`target_iccid`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.SwitchCard(imei, target_iccid)`。响应体 SHALL 返回 `accepted=true`、`target_iccid`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ASSET_TYPE_INVALID/仅设备资产支持该操作`、`DEVICE_IMEI_REQUIRED/设备IMEI缺失`、`GATEWAY_ERROR/网关调用失败`。 + +#### Scenario: 切卡成功返回目标卡号 +- **WHEN** 客户请求切换到目标 ICCID 且网关执行成功 +- **THEN** 系统返回 `accepted=true` 与目标 ICCID diff --git a/openspec/changes/archive/2026-03-19-client-core-business-api/specs/client-order-purchase/spec.md b/openspec/changes/archive/2026-03-19-client-core-business-api/specs/client-order-purchase/spec.md new file mode 100644 index 0000000..a9db784 --- /dev/null +++ b/openspec/changes/archive/2026-03-19-client-core-business-api/specs/client-order-purchase/spec.md @@ -0,0 +1,46 @@ +# Capability: 客户端套餐购买 + +## ADDED Requirements + +### Requirement: D1 创建套餐购买订单接口 + +系统 SHALL 提供 `POST /api/c/v1/orders/create`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`、`package_ids[]`、`app_type`。接口流程 MUST 按顺序执行:归属校验 → 套餐校验(含加油包前置)→ 实名校验 → OpenID 查询 → 幂等检查 → 强充检查 → 分流创建。实名不满足时 MUST 返回 `NEED_REALNAME`。OpenID 缺失时 MUST 返回 `OPENID_NOT_FOUND`。幂等 MUST 使用 Redis 业务键 + 分布式锁。分流规则 MUST 为: + +- 无强充:创建套餐订单并返回 `order_type="package"`、`order`、`pay_config` +- 需强充:创建充值单并返回 `order_type="recharge"`、`recharge`、`pay_config`、`linked_package_info` + +响应体 MUST 包含前端可直接渲染字段。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`NEED_REALNAME/该套餐需实名认证后购买`、`OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权`、`IDEMPOTENT_CONFLICT/请求处理中,请勿重复提交`、`PACKAGE_NOT_AVAILABLE/套餐不可购买`。 + +#### Scenario: 命中强充返回 recharge 结构 +- **WHEN** 客户购买套餐触发强充要求 +- **THEN** 系统返回 `order_type="recharge"`,包含充值单与关联套餐信息 + +--- + +### Requirement: D2 套餐订单列表接口 + +系统 SHALL 提供 `GET /api/c/v1/orders?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 做归属校验并按资产当前 generation 过滤订单。请求参数 SHALL 支持 `payment_status`、`page`、`page_size`。响应体 SHALL 返回 `list[]`、`total`、`page`、`page_size`,列表项至少含 `order_id`、`order_no`、`total_amount`、`payment_status`、`created_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`。 + +#### Scenario: 支持支付状态筛选 +- **WHEN** 客户带 `payment_status=paid` 查询订单 +- **THEN** 系统仅返回当前 generation 且支付状态匹配的订单 + +--- + +### Requirement: D3 套餐订单详情接口 + +系统 SHALL 提供 `GET /api/c/v1/orders/:id`,并且 MUST 要求个人客户认证。接口 MUST 基于订单关联资产执行归属校验(通过资产虚拟号匹配 `PersonalCustomerDevice`)。响应体 SHALL 返回订单详情、套餐明细、支付信息、状态流转时间。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ORDER_NOT_FOUND/订单不存在`。 + +#### Scenario: 查询他人订单被拦截 +- **WHEN** 客户请求不属于本人资产的订单详情 +- **THEN** 系统返回 403,错误消息为无权限操作该资产或资源不存在 + +--- + +### Requirement: AutoPurchaseAfterRecharge 异步任务 + +系统 SHALL 增加 `AutoPurchaseAfterRecharge` Asynq 任务处理强充二阶段。任务输入 MUST 包含 `recharge_record_id`。处理流程 MUST 为:从钱包扣款(`payment_method=wallet`)→ 创建套餐订单(`source="client"`、写入当前 generation)→ 激活套餐。任务失败 MUST 自动重试,最大 3 次。全部失败后 MUST 将 `auto_purchase_status` 标记为 `failed`,并保留钱包余额供用户手动购买。成功时 MUST 标记为 `success`。 + +#### Scenario: 异步任务连续失败 +- **WHEN** AutoPurchaseAfterRecharge 连续执行失败且达到最大重试次数 +- **THEN** 系统将充值记录 `auto_purchase_status` 更新为 `failed` diff --git a/openspec/changes/archive/2026-03-19-client-core-business-api/specs/client-realname-link/spec.md b/openspec/changes/archive/2026-03-19-client-core-business-api/specs/client-realname-link/spec.md new file mode 100644 index 0000000..37176cf --- /dev/null +++ b/openspec/changes/archive/2026-03-19-client-core-business-api/specs/client-realname-link/spec.md @@ -0,0 +1,23 @@ +# Capability: 客户端实名跳转 + +## ADDED Requirements + +### Requirement: E1 获取实名跳转链接接口 + +系统 SHALL 提供 `GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx`,并且 MUST 要求个人客户认证。该接口 MUST 支持两类入口:购买拦截入口与设备卡列表主动入口。目标卡定位 MUST 支持三种路径: + +1. 标识符直达卡:直接使用该卡 +2. 标识符为设备且传 `iccid`:定位对应设备下卡 +3. 标识符为设备且未传 `iccid`:定位设备当前活跃卡 + +当 `real_name_status=1` 时 MUST 返回“该卡已完成实名”错误。运营商实名模式 MUST 支持: + +- `none`:不支持在线实名,直接报错 +- `template`:按模板替换占位符 `{iccid}` `{msisdn}` `{virtual_no}` 返回 URL +- `gateway`:调用网关获取实名链接 + +响应体 SHALL 至少包含 `realname_mode`、`realname_url`、`card_info{iccid,msisdn,virtual_no}`、`expire_at`(可空)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`REALNAME_ALREADY_DONE/该卡已完成实名`、`REALNAME_NOT_SUPPORTED/该运营商暂不支持在线实名`、`GATEWAY_ERROR/获取实名链接失败`。 + +#### Scenario: 设备未传 iccid 自动选活跃卡 +- **WHEN** 客户传入设备标识符且不传 `iccid` +- **THEN** 系统自动选择设备活跃卡并返回实名跳转链接 diff --git a/openspec/changes/archive/2026-03-19-client-core-business-api/specs/client-wallet-recharge/spec.md b/openspec/changes/archive/2026-03-19-client-core-business-api/specs/client-wallet-recharge/spec.md new file mode 100644 index 0000000..e45ba00 --- /dev/null +++ b/openspec/changes/archive/2026-03-19-client-core-business-api/specs/client-wallet-recharge/spec.md @@ -0,0 +1,51 @@ +# Capability: 客户端钱包与充值 + +## ADDED Requirements + +### Requirement: C1 钱包详情接口 + +系统 SHALL 提供 `GET /api/c/v1/wallet/detail?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 先完成资产解析与归属校验;钱包不存在时 MUST 自动创建空钱包。响应体 SHALL 包含 `wallet_id`、`resource_type`、`resource_id`、`balance`、`frozen_balance`、`updated_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`。 + +#### Scenario: 首次访问自动建钱包 +- **WHEN** 客户查询资产钱包详情且钱包记录不存在 +- **THEN** 系统自动创建钱包并返回余额 0 + +--- + +### Requirement: C2 钱包流水列表接口 + +系统 SHALL 提供 `GET /api/c/v1/wallet/transactions?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 通过归属校验解析出唯一 `wallet_id` 后查询流水,实现天然隔离。请求参数 SHALL 支持 `transaction_type`、`start_time`、`end_time`、`page`、`page_size`。响应体 SHALL 包含 `list[]`、`total`、`page`、`page_size`,每条记录至少含 `transaction_id`、`type`、`amount`、`balance_after`、`created_at`、`remark`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`。 + +#### Scenario: wallet_id 隔离生效 +- **WHEN** 客户查询某资产流水 +- **THEN** 系统仅返回该资产钱包对应流水,不返回其他钱包数据 + +--- + +### Requirement: C3 充值预检接口 + +系统 SHALL 提供 `GET /api/c/v1/wallet/recharge-check?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 复用 `recharge.Service.GetRechargeCheck()` 计算强充规则。响应体 SHALL 包含 `need_force_recharge`、`force_recharge_amount`、`trigger_type`、`min_amount`、`max_amount`、`message`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`。 + +#### Scenario: 返回强充预检结果 +- **WHEN** 资产命中强充规则 +- **THEN** 系统返回 `need_force_recharge=true` 与对应强充金额和触发类型 + +--- + +### Requirement: C4 创建充值订单接口 + +系统 SHALL 提供 `POST /api/c/v1/wallet/recharge`,并且 MUST 要求个人客户认证。请求体 MUST 包含:`identifier`、`amount`(100~10000000 分)、`payment_method=wechat`、`app_type`。接口 MUST 禁止客户端传入 OpenID,并由后端按 `customer_id + app_type` 查询 OpenID。订单创建时 MUST 写入:`operator_type=personal_customer` 与资产当前 `generation` 快照。响应体 SHALL 返回 `recharge` 与 `pay_config`,其中 `recharge` 至少含 `recharge_id`、`recharge_no`、`amount`、`status`,`pay_config` 为微信 JSAPI 拉起参数。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权`、`FORBIDDEN/无权限操作该资产或资源不存在`、`PAYMENT_NOT_SUPPORTED/仅支持微信支付`。 + +#### Scenario: 后端查 OpenID 并返回支付参数 +- **WHEN** 客户传入合法参数且后端成功查询到 OpenID +- **THEN** 系统创建充值单并返回 `recharge + pay_config` + +--- + +### Requirement: C5 充值订单列表接口 + +系统 SHALL 提供 `GET /api/c/v1/wallet/recharges?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验后按资产当前 generation 过滤充值记录。请求参数 SHALL 支持 `status`、`page`、`page_size`。响应体 SHALL 返回 `list[]`、`total`、`page`、`page_size`,每项至少含 `recharge_id`、`recharge_no`、`amount`、`status`、`payment_method`、`created_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`。 + +#### Scenario: generation 过滤充值历史 +- **WHEN** 资产存在多代充值记录 +- **THEN** 系统仅返回当前 generation 对应的充值记录 diff --git a/openspec/changes/archive/2026-03-19-client-core-business-api/specs/force-recharge-check/spec.md b/openspec/changes/archive/2026-03-19-client-core-business-api/specs/force-recharge-check/spec.md new file mode 100644 index 0000000..707beef --- /dev/null +++ b/openspec/changes/archive/2026-03-19-client-core-business-api/specs/force-recharge-check/spec.md @@ -0,0 +1,21 @@ +# Capability: 强充预检 + +## MODIFIED Requirements + +### Requirement: 强充检查结果对客户端透出 + +系统 MUST 将强充检查结果输出给客户端接口(充值预检与购买预检),用于前端明确展示支付拆分。输出字段 SHALL 至少包含:`need_force_recharge`、`force_recharge_amount`、`trigger_type`、`total_package_amount`、`actual_payment`、`wallet_credit`、`message`。若无强充,`need_force_recharge=false` 且 `actual_payment=total_package_amount`。 + +#### Scenario: 客户端购买预检命中强充 +- **WHEN** 客户端调用购买预检且命中强充规则 +- **THEN** 系统返回强充金额、实际支付金额和钱包入账金额 + +--- + +### Requirement: 前端展示套餐价与强充金额拆分 + +系统 SHALL 在强充场景提供可直接渲染的拆分语义:套餐总价、需支付金额、充值入钱包金额,并给出中文提示文案。当前端调用客户端下单接口(D1)时,若命中强充 MUST 返回 `order_type="recharge"` 与 `linked_package_info`,以便前端保持与预检展示一致。 + +#### Scenario: 套餐价低于强充金额 +- **WHEN** 套餐总价 5000 分,强充金额 10000 分 +- **THEN** 预检返回 `actual_payment=10000`、`wallet_credit=5000`、提示文案可用于前端直接展示 diff --git a/openspec/changes/archive/2026-03-19-client-core-business-api/specs/wallet-recharge/spec.md b/openspec/changes/archive/2026-03-19-client-core-business-api/specs/wallet-recharge/spec.md new file mode 100644 index 0000000..33f2d0e --- /dev/null +++ b/openspec/changes/archive/2026-03-19-client-core-business-api/specs/wallet-recharge/spec.md @@ -0,0 +1,33 @@ +# Capability: 钱包充值 + +## MODIFIED Requirements + +### Requirement: 充值回调采用两阶段处理 + +系统 MUST 将强充场景的充值回调改为两阶段:第一阶段同步事务内完成入账与状态更新,第二阶段异步执行自动购买。第一阶段 SHALL 包含:更新充值状态、钱包加款、累计充值更新、首充佣金判断。第二阶段 SHALL 通过 Asynq 任务执行钱包扣款、创建套餐订单、激活套餐。该改造适用于客户端触发的强充路径,且不影响非强充充值主流程。 + +#### Scenario: 强充回调同步入账成功并触发异步任务 +- **WHEN** 强充充值支付回调验签成功 +- **THEN** 系统在事务内完成钱包入账与充值单状态更新 +- **AND** 入队 `AutoPurchaseAfterRecharge` 异步任务 + +--- + +### Requirement: 充值记录新增 auto_purchase_status 状态追踪 + +系统 MUST 在 `AssetRechargeRecord` 增加 `auto_purchase_status` 字段,用于追踪强充后二阶段自动购买状态。状态集 SHALL 至少包括:`pending`、`success`、`failed`。创建强充充值单时 MUST 初始化为 `pending`;异步购买成功后 MUST 更新为 `success`;重试耗尽后 MUST 更新为 `failed`。 + +#### Scenario: 强充充值单创建时默认 pending +- **WHEN** 系统创建与套餐联动的强充充值单 +- **THEN** 充值记录 `auto_purchase_status` 初始化为 `pending` + +--- + +### Requirement: 异步自动购买失败处理规范 + +系统 SHALL 对 `AutoPurchaseAfterRecharge` 失败场景执行统一处理:任务 MUST 自动重试(最多 3 次);全部失败后 MUST 记录错误日志并将 `auto_purchase_status` 置为 `failed`;用户资金 SHALL 保留在钱包中,允许后续手动购买,不得回滚已成功的充值入账。 + +#### Scenario: 异步任务最终失败 +- **WHEN** 自动购买任务连续失败并达到最大重试次数 +- **THEN** 系统将 `auto_purchase_status` 标记为 `failed` +- **AND** 钱包余额保持可用,用户可手动下单 diff --git a/openspec/changes/archive/2026-03-19-client-core-business-api/tasks.md b/openspec/changes/archive/2026-03-19-client-core-business-api/tasks.md new file mode 100644 index 0000000..0338475 --- /dev/null +++ b/openspec/changes/archive/2026-03-19-client-core-business-api/tasks.md @@ -0,0 +1,70 @@ +## 1. 常量与错误码 + +- [x] 1.1 在 `pkg/constants/constants.go` 增加 `auto_purchase_status` 状态常量(pending/success/failed) +- [x] 1.2 在 `pkg/errors/codes.go` 增加 `NEED_REALNAME` 与 `OPENID_NOT_FOUND` 错误码并补充中文消息 +- [x] 1.3 在 `pkg/constants/redis.go` 增加客户端购买幂等键与锁键生成函数 + +## 2. DTO 定义 + +- [x] 2.1 新增资产模块 DTO:B1/B2/B3/B4 请求与响应结构(含 description/validate 标签) +- [x] 2.2 新增钱包充值模块 DTO:C1~C5 请求与响应结构(含支付返回 `pay_config`) +- [x] 2.3 新增订单模块 DTO:D1~D3 请求与响应结构(含 `order_type` 分流结构) +- [x] 2.4 新增实名与设备模块 DTO:E1、F1~F5 请求与响应结构 + +## 3. 模型变更与迁移 + +- [x] 3.1 在 `internal/model/asset_recharge_record.go` 增加 `auto_purchase_status` 字段与中文注释 +- [x] 3.2 创建迁移文件为 `tb_asset_recharge_record` 添加 `auto_purchase_status` 字段(up/down) +- [x] 3.3 执行迁移并确认版本状态正常(非 dirty) + +## 4. 资产信息模块(B1~B4) + +- [x] 4.1 新建 `internal/handler/app/client_asset.go` 并实现 B1~B4 路由处理 +- [x] 4.2 抽取公共方法 `resolveAssetFromIdentifier`(解析标识符 + 归属校验 + 权限绕过上下文) +- [x] 4.3 实现 B2 渠道价格计算、加油包前置校验、上下架过滤与价格升序 +- [x] 4.4 实现 B4 刷新分流:卡走 Gateway、设备走 Redis 冷却 + 批量刷新 + +## 5. 钱包与充值模块(C1~C5) + +- [x] 5.1 新建 `internal/handler/app/client_wallet.go` 并实现 C1~C5 路由处理 +- [x] 5.2 实现 C1 钱包不存在自动创建逻辑与 C2 wallet_id 隔离查询 +- [x] 5.3 复用并接入 C3 强充预检返回结构 +- [x] 5.4 实现 C4 创建充值订单:根据 `app_type` 查 PersonalCustomerOpenID 获取 openid → 根据 `app_type` 选择 AppID(`official_account` 用 `oa_app_id`,`miniapp` 用 `miniapp_app_id`)→ 调用提案 1 新增的 `wechat.NewPaymentAppFromConfig(config, appID)` 创建支付实例 → 调用现有 `PaymentService.CreateJSAPIOrder(orderNo, desc, openID, amount)` 拉起支付 → 设置 `operator_type=personal_customer`、写入 `generation` 快照 +- [x] 5.5 实现 C5 充值记录 generation 过滤查询 + +## 6. 套餐购买模块(D1~D3) + +- [x] 6.1 新建 `internal/handler/app/client_order.go` 并实现 D1~D3 路由处理 +- [x] 6.2 新建 `internal/service/client_order/service.go` 编排 D1 全流程(归属/套餐/实名/OpenID/幂等/强充分流)。支付调用链:根据 `app_type` 选择 AppID → `wechat.NewPaymentAppFromConfig(config, appID)` → `PaymentService.CreateJSAPIOrder()` → 返回 `pay_config` 给前端。**客户端一律走 JSAPI 支付(微信内环境),不使用 H5 支付** +- [x] 6.3 实现强充两阶段:同步入账 + 异步自动购买(Asynq 入队)。注意第二阶段自动购买创建的订单使用 `payment_method=wallet`(钱包扣款),不涉及微信支付 +- [x] 6.4 新增 `AutoPurchaseAfterRecharge` 任务处理器(钱包扣款→创建订单→激活套餐,失败重试 3 次) +- [x] 6.5 实现 D1 `order_type` 双结构返回(package/recharge) +- [x] 6.6 实现 D2/D3 generation 与归属校验约束 + +## 7. 实名跳转(E1) + +- [x] 7.1 新建 `internal/handler/app/client_realname.go` 并实现 E1 接口 +- [x] 7.2 实现目标卡定位三路径(直接卡/设备+iccid/设备活跃卡) +- [x] 7.3 实现实名模式三分支(none/template/gateway)与模板占位符替换 + +## 8. 设备能力(F1~F5) + +- [x] 8.1 新建 `internal/handler/app/client_device.go` 并实现 F1~F5 路由处理 +- [x] 8.2 实现通用前置校验(必须设备类型且 IMEI 非空) +- [x] 8.3 对接 Gateway:`RebootDevice`、`ResetDevice`、`SetWiFi`、`SwitchCard` +- [x] 8.4 实现 F4 特殊映射:`WiFiReq.cardNo = 设备IMEI` + +## 9. 路由注册与文档 + +- [x] 9.1 在 `internal/bootstrap/types.go` 增加客户端业务 Handler 字段 +- [x] 9.2 在 `internal/bootstrap/handlers.go` 完成客户端业务 Handler 实例化 +- [x] 9.3 在 `internal/routes/personal.go` 注册 `/api/c/v1/` 18 个端点(使用 `Register()`) +- [x] 9.4 在 `cmd/api/docs.go` 注册新增 Handler 供文档生成器使用 +- [x] 9.5 在 `cmd/gendocs/main.go` 注册新增 Handler 并生成 OpenAPI 文档 + +## 10. 验证 + +- [x] 10.1 运行 `go build ./...` 确认构建通过 +- [x] 10.2 运行 LSP diagnostics,确保改动文件无错误 +- [x] 10.3 使用数据库验证流程确认 `auto_purchase_status` 字段已生效 +- [x] 10.4 补充 `docs/client-core-business-api/功能总结.md` 并更新相关索引文档 diff --git a/openspec/changes/archive/2026-03-19-client-exchange-system/.openspec.yaml b/openspec/changes/archive/2026-03-19-client-exchange-system/.openspec.yaml new file mode 100644 index 0000000..3c861dd --- /dev/null +++ b/openspec/changes/archive/2026-03-19-client-exchange-system/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-18 diff --git a/openspec/changes/client-exchange-system/design.md b/openspec/changes/archive/2026-03-19-client-exchange-system/design.md similarity index 100% rename from openspec/changes/client-exchange-system/design.md rename to openspec/changes/archive/2026-03-19-client-exchange-system/design.md diff --git a/openspec/changes/client-exchange-system/proposal.md b/openspec/changes/archive/2026-03-19-client-exchange-system/proposal.md similarity index 100% rename from openspec/changes/client-exchange-system/proposal.md rename to openspec/changes/archive/2026-03-19-client-exchange-system/proposal.md diff --git a/openspec/changes/client-exchange-system/specs/card-replacement/spec.md b/openspec/changes/archive/2026-03-19-client-exchange-system/specs/card-replacement/spec.md similarity index 100% rename from openspec/changes/client-exchange-system/specs/card-replacement/spec.md rename to openspec/changes/archive/2026-03-19-client-exchange-system/specs/card-replacement/spec.md diff --git a/openspec/changes/client-exchange-system/specs/device/spec.md b/openspec/changes/archive/2026-03-19-client-exchange-system/specs/device/spec.md similarity index 100% rename from openspec/changes/client-exchange-system/specs/device/spec.md rename to openspec/changes/archive/2026-03-19-client-exchange-system/specs/device/spec.md diff --git a/openspec/changes/client-exchange-system/specs/exchange-admin-management/spec.md b/openspec/changes/archive/2026-03-19-client-exchange-system/specs/exchange-admin-management/spec.md similarity index 100% rename from openspec/changes/client-exchange-system/specs/exchange-admin-management/spec.md rename to openspec/changes/archive/2026-03-19-client-exchange-system/specs/exchange-admin-management/spec.md diff --git a/openspec/changes/client-exchange-system/specs/exchange-client-notification/spec.md b/openspec/changes/archive/2026-03-19-client-exchange-system/specs/exchange-client-notification/spec.md similarity index 100% rename from openspec/changes/client-exchange-system/specs/exchange-client-notification/spec.md rename to openspec/changes/archive/2026-03-19-client-exchange-system/specs/exchange-client-notification/spec.md diff --git a/openspec/changes/client-exchange-system/specs/exchange-data-migration/spec.md b/openspec/changes/archive/2026-03-19-client-exchange-system/specs/exchange-data-migration/spec.md similarity index 100% rename from openspec/changes/client-exchange-system/specs/exchange-data-migration/spec.md rename to openspec/changes/archive/2026-03-19-client-exchange-system/specs/exchange-data-migration/spec.md diff --git a/openspec/changes/client-exchange-system/specs/exchange-order-model/spec.md b/openspec/changes/archive/2026-03-19-client-exchange-system/specs/exchange-order-model/spec.md similarity index 100% rename from openspec/changes/client-exchange-system/specs/exchange-order-model/spec.md rename to openspec/changes/archive/2026-03-19-client-exchange-system/specs/exchange-order-model/spec.md diff --git a/openspec/changes/client-exchange-system/specs/iot-card/spec.md b/openspec/changes/archive/2026-03-19-client-exchange-system/specs/iot-card/spec.md similarity index 100% rename from openspec/changes/client-exchange-system/specs/iot-card/spec.md rename to openspec/changes/archive/2026-03-19-client-exchange-system/specs/iot-card/spec.md diff --git a/openspec/changes/client-exchange-system/specs/personal-customer/spec.md b/openspec/changes/archive/2026-03-19-client-exchange-system/specs/personal-customer/spec.md similarity index 100% rename from openspec/changes/client-exchange-system/specs/personal-customer/spec.md rename to openspec/changes/archive/2026-03-19-client-exchange-system/specs/personal-customer/spec.md diff --git a/openspec/changes/client-exchange-system/tasks.md b/openspec/changes/archive/2026-03-19-client-exchange-system/tasks.md similarity index 100% rename from openspec/changes/client-exchange-system/tasks.md rename to openspec/changes/archive/2026-03-19-client-exchange-system/tasks.md diff --git a/openspec/specs/agent-retail-price/spec.md b/openspec/specs/agent-retail-price/spec.md new file mode 100644 index 0000000..2fddc07 --- /dev/null +++ b/openspec/specs/agent-retail-price/spec.md @@ -0,0 +1,54 @@ +# agent-retail-price Specification + +## Purpose +TBD - created by archiving change client-api-data-model-fixes. Update Purpose after archive. +## Requirements +### Requirement: 分配零售价字段定义 + +系统 MUST 在 `ShopPackageAllocation` 新增 `retail_price bigint NOT NULL DEFAULT 0` 字段。 + +#### Scenario: 新字段存在且非空 +- **WHEN** 执行分配记录建表或迁移 +- **THEN** `retail_price` MUST 为非空整型字段,默认值为 `0` + +--- + +### Requirement: 分配创建默认零售价规则 + +系统 MUST 在创建分配记录时将 `retail_price` 自动设置为对应 `Package.SuggestedRetailPrice`。 + +#### Scenario: 创建分配自动带出建议零售价 +- **WHEN** 平台给代理创建套餐分配记录 +- **THEN** 新记录的 `retail_price` MUST 等于该套餐的 `suggested_retail_price` + +--- + +### Requirement: 零售价约束规则 + +系统 MUST 强制校验:`retail_price >= cost_price`。 + +#### Scenario: 零售价低于成本价 +- **WHEN** 代理设置 `retail_price < cost_price` +- **THEN** 系统 MUST 拒绝保存并返回价格约束错误 + +### Requirement: 成本价分配锁定规则 + +当某分配存在下级分配记录时,系统 MUST 禁止修改该分配的 `cost_price`。 + +#### Scenario: 存在下级分配时修改成本价 +- **WHEN** 上级分配记录已被继续分配到下级店铺 +- **THEN** 系统 MUST 拒绝对该记录的 `cost_price` 修改 + +--- + +### Requirement: 代理零售价可调与存量迁移 + +系统 MUST 提供独立接口 `PATCH /api/admin/packages/:id/retail-price` 供代理修改自己分配记录的 `retail_price`(在约束范围内);系统 MUST 对存量数据执行迁移:将 `retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice`。 + +#### Scenario: 代理调整自己的零售价 +- **WHEN** 代理修改自己分配记录的 `retail_price` 且满足价格约束 +- **THEN** 系统 MUST 允许更新 + +#### Scenario: 存量数据回填零售价 +- **WHEN** 执行本次数据迁移 +- **THEN** 系统 MUST 将历史 `ShopPackageAllocation.retail_price` 批量更新为对应套餐的 `SuggestedRetailPrice` diff --git a/openspec/specs/asset-generation/spec.md b/openspec/specs/asset-generation/spec.md new file mode 100644 index 0000000..5eea4ae --- /dev/null +++ b/openspec/specs/asset-generation/spec.md @@ -0,0 +1,59 @@ +# asset-generation Specification + +## Purpose +TBD - created by archiving change client-api-data-model-fixes. Update Purpose after archive. +## Requirements +### Requirement: 资产表新增代际字段 + +系统 MUST 在资产主表新增 `generation int NOT NULL DEFAULT 1` 字段,覆盖 `IotCard` 与 `Device`。 + +#### Scenario: 新资产默认代际为 1 +- **WHEN** 创建新的 IoT 卡或设备 +- **THEN** 系统 MUST 将 `generation` 初始化为 `1` + +--- + +### Requirement: 关联业务表新增代际字段 + +系统 MUST 在以下关联业务表新增 `generation int NOT NULL DEFAULT 1` 字段:`Order`、`PackageUsage`、`AssetRechargeRecord`。 + +#### Scenario: 新关联记录默认代际为 1 +- **WHEN** 创建订单、套餐使用记录或资产充值记录 +- **THEN** 系统 MUST 将记录的 `generation` 默认为 `1` + +--- + +### Requirement: 写时快照代际规则 + +系统 MUST 在创建关联记录时执行代际写时快照:从当前资产(IoT 卡/设备)的 `generation` 复制到新建的 `Order`、`PackageUsage`、`AssetRechargeRecord` 记录。 + +#### Scenario: 创建订单时复制资产代际 +- **WHEN** 某资产当前 `generation=3`,并基于该资产创建订单 +- **THEN** 该订单记录的 `generation` MUST 写入为 `3` + +--- + +### Requirement: 查询过滤规则 + +系统 MUST 支持客户端按 `generation` 过滤历史数据;后台管理侧 MUST 不默认按 `generation` 过滤。 + +本提案阶段 MUST 仅新增字段定义,具体过滤逻辑在后续提案实现。 + +#### Scenario: 客户端按代际查看历史 +- **WHEN** 客户端请求携带指定 `generation` +- **THEN** 系统 MUST 仅返回该代际的数据(在后续提案中实现) + +#### Scenario: 后台查询不按代际裁剪 +- **WHEN** 管理端查询订单或充值记录且未显式指定 `generation` +- **THEN** 系统 MUST 返回全部代际数据 + +--- + +### Requirement: 钱包流水不引入代际字段 + +系统 MUST NOT 在钱包流水相关表新增 `generation` 字段,因为钱包流水已通过 `wallet_id` 天然隔离。 + +#### Scenario: 钱包流水按钱包隔离 +- **WHEN** 查询某资产钱包流水 +- **THEN** 系统 MUST 仅依赖 `wallet_id` 完成数据隔离,不新增 `generation` 参与过滤 + diff --git a/openspec/specs/asset-lifecycle-status/spec.md b/openspec/specs/asset-lifecycle-status/spec.md new file mode 100644 index 0000000..6903a0a --- /dev/null +++ b/openspec/specs/asset-lifecycle-status/spec.md @@ -0,0 +1,45 @@ +# asset-lifecycle-status Specification + +## Purpose +TBD - created by archiving change client-api-data-model-fixes. Update Purpose after archive. +## Requirements +### Requirement: 资产生命周期状态字段定义 + +系统 MUST 在 `IotCard` 与 `Device` 数据模型中新增 `asset_status int NOT NULL DEFAULT 1` 字段,用于表达资产生命周期状态。 + +状态值域 MUST 固定为:`1-在库`、`2-已销售`、`3-已换货`、`4-已停用`。 + +#### Scenario: 新建资产默认在库 +- **WHEN** 系统创建新的 IoT 卡或设备记录 +- **THEN** `asset_status` MUST 默认为 `1`(在库) + +#### Scenario: 非法状态值被拒绝 +- **WHEN** 写入 `asset_status` 为 `0`、`5` 或其他非约定值 +- **THEN** 系统 MUST 拒绝该写入并提示状态值不合法 + +--- + +### Requirement: 资产生命周期状态常量定义 + +系统 MUST 在 `pkg/constants/` 中定义资产生命周期状态常量,并统一由业务层引用,禁止在业务代码中硬编码状态值。 + +#### Scenario: 业务代码引用常量 +- **WHEN** Service 层执行资产状态判断或赋值 +- **THEN** 代码 MUST 使用 `pkg/constants/` 中定义的资产状态常量而不是硬编码数字 + +--- + +### Requirement: 资产状态与网络状态独立 + +系统 MUST 保证 `asset_status` 与运营商侧 `network_status` 完全独立,二者不互相推导、不互相覆盖。 + +本提案阶段 MUST 仅新增字段与常量定义,状态流转逻辑(导入→在库、首次绑定/分配→已销售、换货完成→已换货、转新→在库且代际+1、手动停用→已停用)在后续提案实现。 + +#### Scenario: 网络状态变化不影响资产状态 +- **WHEN** Gateway 同步将 `network_status` 从开机改为停机 +- **THEN** 系统 MUST 保持 `asset_status` 不变 + +#### Scenario: 资产状态变化不强制修改网络状态 +- **WHEN** 管理端将资产手动停用(`asset_status=4`) +- **THEN** 系统 MUST 不自动改写 `network_status` + diff --git a/openspec/specs/asset-recharge-adaptation/spec.md b/openspec/specs/asset-recharge-adaptation/spec.md index 39395da..7a54ef9 100644 --- a/openspec/specs/asset-recharge-adaptation/spec.md +++ b/openspec/specs/asset-recharge-adaptation/spec.md @@ -1,5 +1,8 @@ -## MODIFIED Requirements +# asset-recharge-adaptation Specification +## Purpose +定义资产充值(IoT 卡/设备钱包充值)的完整规范:支付配置关联、充值记录表结构变更、回调验签流程及钱包常量从 Card 前缀统一重命名为 Asset 前缀。 +## Requirements ### Requirement: 资产充值关联支付配置 系统 SHALL 在创建资产充值订单时记录当前生效的支付配置 ID,用于回调处理时加载正确的配置验签。 @@ -76,28 +79,33 @@ Content-Type: application/json ### Requirement: 资产充值表结构变更 -`tb_asset_recharge_record` 新增字段: +系统 MUST 在 `tb_asset_recharge_record` 新增以下字段,用于关联支付配置。 | 字段 | 类型 | 必填 | 说明 | |------|------|------|------| | `payment_config_id` | bigint | ❌ | 创建充值订单时使用的微信参数配置 ID(支付宝支付时为 NULL) | +#### Scenario: 新建充值记录含 payment_config_id 字段 +- **WHEN** 个人客户创建微信充值订单 +- **THEN** 系统 MUST 将当前生效的微信参数配置 ID 写入 `payment_config_id` 字段 + --- ### Requirement: 资产充值回调按配置验签 -- **WHEN** 收到支付回调(微信或富友),订单号前缀为 `CRCH` -- **THEN** 系统查询 `tb_asset_recharge_record`,通过 `payment_config_id` 加载对应配置 -- **THEN** 使用该配置的凭证验签 -- **THEN** 验签通过后调用 `rechargeService.HandlePaymentCallback()` +系统 MUST 在处理资产充值支付回调时,通过 `payment_config_id` 加载对应配置并使用该配置验签。 -> **注意**:当前代码中 `callback/payment.go` 使用废弃的 `RechargeOrderPrefix = "RCH"` 进行前缀匹配,需修复为 `AssetRechargeOrderPrefix = "CRCH"`。 +#### Scenario: 收到充值回调按配置验签 +- **WHEN** 收到支付回调(微信或富友),订单号前缀为 `CRCH` +- **THEN** 系统 MUST 查询 `tb_asset_recharge_record`,通过 `payment_config_id` 加载对应配置 +- **THEN** 系统 MUST 使用该配置的凭证验签 +- **THEN** 验签通过后调用 `rechargeService.HandlePaymentCallback()` --- ### Requirement: 常量重命名(Card → Asset) -`pkg/constants/wallet.go` 中以下常量从 `Card` 前缀重命名为 `Asset` 前缀: +系统 MUST 将 `pkg/constants/wallet.go` 中以下常量从 `Card` 前缀重命名为 `Asset` 前缀,旧常量保留为废弃别名。 | 旧名称 | 新名称 | |--------|--------| @@ -113,4 +121,30 @@ Content-Type: application/json | `CardRechargeMinAmount` | `AssetRechargeMinAmount` | | `CardRechargeMaxAmount` | `AssetRechargeMaxAmount` | -旧 `Card*` 常量保留为废弃别名,添加 `Deprecated` 注释。段落标题 `卡钱包常量` → `资产钱包常量`。 +#### Scenario: 新代码使用 Asset 前缀常量 +- **WHEN** 业务代码引用钱包资源类型或充值相关常量 +- **THEN** 系统 MUST 使用 `Asset*` 前缀常量,`Card*` 常量标注 `Deprecated` + +### Requirement: 资产充值记录扩展字段(操作人与代际) + +系统 MUST 在 `tb_asset_recharge_record` 新增以下字段: + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `operator_type` | varchar(20) | ✅ | 操作人类型,枚举 `admin_user` / `personal_customer`,默认 `admin_user` | +| `generation` | int | ✅ | 资产代际,默认 `1` | +| `linked_package_ids` | jsonb | ❌ | 关联套餐 ID 列表,默认 `'[]'` | +| `linked_order_type` | varchar(20) | ❌ | 关联订单类型 | +| `linked_carrier_type` | varchar(20) | ❌ | 关联载体类型(如 iot_card/device) | +| `linked_carrier_id` | bigint | ❌ | 关联载体 ID | + +#### Scenario: 新建充值记录默认字段值 +- **WHEN** 系统创建新的资产充值记录且未显式传入新增字段 +- **THEN** `operator_type` MUST 默认为 `admin_user` +- **THEN** `generation` MUST 默认为 `1` +- **THEN** `linked_package_ids` MUST 默认为空数组 `[]` + +#### Scenario: 写入关联上下文信息 +- **WHEN** 充值记录由订单或套餐联动产生 +- **THEN** 系统 MUST 可写入 `linked_order_type`、`linked_carrier_type`、`linked_carrier_id` 作为关联上下文 + diff --git a/openspec/specs/card-replacement/spec.md b/openspec/specs/card-replacement/spec.md index c9a66d0..11f1e13 100644 --- a/openspec/specs/card-replacement/spec.md +++ b/openspec/specs/card-replacement/spec.md @@ -185,3 +185,35 @@ TBD - created by archiving change add-wallet-transfer-tag-models. Update Purpose - **WHEN** 创建换卡记录,`old_card_id` 为 99999(不存在的 IoT 卡) - **THEN** 系统拒绝创建,返回错误信息"老卡不存在" +--- + +### Requirement: 废弃旧换卡模型能力 + +系统 MUST 废弃 `CardReplacementRecord` 作为主业务能力,原因是其仅覆盖卡换卡且缺少收货信息、物流信息、设备换货与全量迁移能力,无法满足当前换货闭环需求。 + +#### Scenario: 新换货流程不再写入旧模型 +- **WHEN** 执行任意新换货流程(H1~H7、G1~G2) +- **THEN** 系统 MUST 仅读写 `ExchangeOrder`,不再创建 `CardReplacementRecord` 新记录 + +--- + +### Requirement: 旧表迁移为 legacy 保留查询 + +系统 SHALL 将 `tb_card_replacement_record` 改名为 `tb_card_replacement_record_legacy`,仅用于历史查询保留。 + +系统 MUST NOT 将 legacy 数据回灌到 `tb_exchange_order`。 + +#### Scenario: legacy 数据保留但不参与新流程 +- **WHEN** 运营查询历史老换卡记录 +- **THEN** 系统可从 legacy 表读取历史数据,但新换货流程 SHALL 不依赖该表 + +--- + +### Requirement: 旧代码引用替换 + +系统 MUST 将旧换卡引用替换为 `ExchangeOrder`,包括 `iot_card_store.go` 中 `is_replaced` 过滤逻辑。 + +#### Scenario: is_replaced 基于新换货单判定 +- **WHEN** 查询 IoT 卡并使用 `is_replaced=true` 过滤 +- **THEN** 系统 MUST 基于 `ExchangeOrder` 状态判定是否已发生换货,而非 legacy 表 + diff --git a/openspec/specs/carrier-realname-config/spec.md b/openspec/specs/carrier-realname-config/spec.md new file mode 100644 index 0000000..2d5155d --- /dev/null +++ b/openspec/specs/carrier-realname-config/spec.md @@ -0,0 +1,48 @@ +# carrier-realname-config Specification + +## Purpose +TBD - created by archiving change client-api-data-model-fixes. Update Purpose after archive. +## Requirements +### Requirement: 运营商实名链接配置字段定义 + +系统 MUST 在 Carrier 模型新增以下字段: +- `realname_link_type varchar(20) NOT NULL DEFAULT 'none'` +- `realname_link_template varchar(500) DEFAULT ''` + +#### Scenario: 默认配置为不支持在线实名 +- **WHEN** 创建新的运营商记录且未显式设置实名链接配置 +- **THEN** 系统 MUST 将 `realname_link_type` 设为 `none`,`realname_link_template` 设为空字符串 + +--- + +### Requirement: 实名链接三种模式 + +系统 MUST 支持并仅支持以下实名链接模式: +- `none`:不支持在线实名 +- `template`:使用模板 URL 生成实名链接 +- `gateway`:通过 Gateway 接口动态获取实名链接 + +#### Scenario: none 模式 +- **WHEN** `realname_link_type=none` +- **THEN** 系统 MUST 视为不支持在线实名跳转 + +#### Scenario: template 模式 +- **WHEN** `realname_link_type=template` +- **THEN** 系统 MUST 使用 `realname_link_template` 作为实名链接模板 + +#### Scenario: gateway 模式 +- **WHEN** `realname_link_type=gateway` +- **THEN** 系统 MUST 通过 Gateway 能力获取实名链接 + +--- + +### Requirement: 模板占位符规则 + +当 `realname_link_type=template` 时,系统 MUST 支持模板中的占位符 `{iccid}`、`{msisdn}`、`{virtual_no}`。 + +本提案阶段 MUST 仅新增字段,不实现实名跳转接口逻辑。 + +#### Scenario: 模板占位符可被解析 +- **WHEN** 模板 URL 包含 `{iccid}`、`{msisdn}` 或 `{virtual_no}` +- **THEN** 系统 MUST 在后续实名跳转实现中按占位符语义进行参数替换 + diff --git a/openspec/specs/client-asset-info/spec.md b/openspec/specs/client-asset-info/spec.md new file mode 100644 index 0000000..ca7d8bc --- /dev/null +++ b/openspec/specs/client-asset-info/spec.md @@ -0,0 +1,41 @@ +# Capability: 客户端资产信息 + +## ADDED Requirements + +### Requirement: B1 资产基本信息查询接口 + +系统 SHALL 提供 `GET /api/c/v1/asset/info?identifier=xxx`,并且 MUST 要求个人客户认证(C 端 Token)。接口 MUST 复用 `asset.Service.Resolve()` 解析标识符,并在调用时使用 `gorm.SkipDataPermission(ctx)` 以绕过 shop_id 数据权限过滤。请求参数 MUST 包含 `identifier`(ICCID、虚拟号、设备号之一)。响应体 SHALL 返回 `asset_type`、`asset_id`、`identifier`、`virtual_no`、`status`、`real_name_status`、`carrier`、`generation`、`wallet_balance`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ASSET_NOT_FOUND/资产不存在`。 + +#### Scenario: 个人客户查询已绑定资产 +- **WHEN** 客户携带有效 Token 调用 `GET /api/c/v1/asset/info?identifier=8986xxxx` 且资产已绑定到本人 +- **THEN** 系统返回 200,包含资产基础信息与当前 generation + +--- + +### Requirement: B2 可购买套餐列表接口 + +系统 SHALL 提供 `GET /api/c/v1/asset/packages?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验通过后返回可购买套餐列表。价格规则 MUST 为:代理渠道取 `allocation.retail_price`,平台渠道取 `Package.SuggestedRetailPrice`。过滤规则 MUST 同时满足:`Package.status=1`、`shelf_status` 可售、加油包前置主套餐条件成立、`retail_price >= cost_price`。结果 MUST 按展示价格升序。响应体 SHALL 包含 `packages[]`,每项至少含 `package_id`、`package_name`、`package_type`、`retail_price`、`cost_price`、`validity`、`is_addon`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`PACKAGE_NOT_AVAILABLE/当前无可购买套餐`。 + +#### Scenario: 代理渠道价格与过滤生效 +- **WHEN** 客户查询可购套餐且其销售链路为代理渠道,部分套餐存在 `retail_price < cost_price` +- **THEN** 系统仅返回可售且满足价格约束的套餐,并按价格升序输出 + +--- + +### Requirement: B3 历史套餐列表接口 + +系统 SHALL 提供 `GET /api/c/v1/asset/package-history?identifier=xxx&page=1&page_size=20`,并且 MUST 要求个人客户认证。接口 MUST 基于标识符解析资产并进行归属校验。查询条件 MUST 自动追加 `generation = 资产当前generation`。请求参数 SHALL 支持 `page`、`page_size`(默认 20,最大 100)。响应体 SHALL 返回 `list[]`、`total`、`page`、`page_size`,列表项复用 `dto.AssetPackageResponse` 结构。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`。 + +#### Scenario: 转手后历史隔离 +- **WHEN** 资产已发生转手且存在历史套餐记录 +- **THEN** 系统只返回当前 generation 的记录,不返回旧 generation 数据 + +--- + +### Requirement: B4 手动刷新接口 + +系统 SHALL 提供 `POST /api/c/v1/asset/refresh`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。当资产为卡时 MUST 调用 Gateway 刷新卡信息;当资产为设备时 MUST 先检查 Redis 冷却窗口,再对设备下卡执行批量刷新。响应体 SHALL 返回 `refresh_type`(`card`/`device`)、`accepted`、`cooldown_seconds`(设备场景)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`TOO_MANY_REQUESTS/刷新过于频繁,请稍后重试`、`GATEWAY_ERROR/网关调用失败`。 + +#### Scenario: 设备刷新冷却拦截 +- **WHEN** 客户在冷却时间内重复调用设备刷新 +- **THEN** 系统返回频率限制错误并告知剩余冷却时间 diff --git a/openspec/specs/client-asset-token/spec.md b/openspec/specs/client-asset-token/spec.md new file mode 100644 index 0000000..1de426e --- /dev/null +++ b/openspec/specs/client-asset-token/spec.md @@ -0,0 +1,73 @@ +# client-asset-token Specification + +## Purpose +TBD - created by archiving change client-auth-system. Update Purpose after archive. +## Requirements +### Requirement: A1 资产标识符验证接口 + +系统 MUST 提供无认证资产验证接口 `POST /api/c/v1/auth/verify-asset`,用于将外部资产标识符兑换为短时效 `asset_token`。 + +- HTTP Method + Path: `POST /api/c/v1/auth/verify-asset` +- 请求体字段: + - `identifier` string,MUST,资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) +- 响应体字段: + - `asset_token` string,MUST,5 分钟有效 + - `expires_in` int,MUST,单位秒 +- 错误码: + - `1006` 参数错误(标识符为空或格式非法) + - `1404` 资产不存在 + - `1003` 请求过于频繁 + +#### Scenario: 资产验证成功并返回 asset_token +- **WHEN** 客户端提交合法且存在的资产标识符 +- **THEN** 系统 SHALL 解析并定位资产 +- **THEN** 系统 SHALL 签发 5 分钟有效的 `asset_token` +- **THEN** 系统 SHALL 返回 `{asset_token, expires_in}` + +#### Scenario: 输入参数非法 +- **WHEN** 客户端提交空字符串或不支持格式的标识符 +- **THEN** 系统 MUST 返回参数错误码 `1006` + +### Requirement: A1 输入校验与安全约束 + +系统 SHALL 对标识符进行白名单校验,并在 A1 响应中禁止暴露内部 `asset_id`。 + +- 输入校验规则: + - MUST 去除前后空格并做长度限制 + - MUST 仅允许预定义字符集(数字、字母、必要分隔符) + - MUST 拒绝 SQL 片段/控制字符 +- 输出安全规则: + - MUST NOT 返回 `asset_id` + - MUST NOT 返回内部表名/字段名 + +#### Scenario: 防止内部主键泄露 +- **WHEN** A1 接口返回成功响应 +- **THEN** 返回体 MUST 只包含 `asset_token` 与有效期信息 +- **THEN** 返回体 MUST NOT 包含 `asset_id` + +### Requirement: A1 资产令牌签发规范 + +`asset_token` SHALL 使用独立签名密钥签发,且 payload 仅包含 `asset_type` 与 `asset_id`。 + +- JWT 约束: + - `exp` = 当前时间 + 5 分钟 + - payload MUST 包含 `asset_type`、`asset_id` + - payload MUST NOT 包含手机号、OpenID 等敏感信息 + +#### Scenario: token 结构与时效符合规范 +- **WHEN** 服务端签发 `asset_token` +- **THEN** token MUST 使用资产令牌专用签名密钥 +- **THEN** token MUST 在 5 分钟后过期 + +### Requirement: A1 IP 级限频 + +系统 SHALL 对 A1 实施 IP 维度限频:`30 次/分钟`。 + +#### Scenario: 限频内请求通过 +- **WHEN** 同一 IP 在 1 分钟内请求次数不超过 30 次 +- **THEN** 系统 SHALL 正常处理请求 + +#### Scenario: 超过限频阈值 +- **WHEN** 同一 IP 在 1 分钟内请求次数超过 30 次 +- **THEN** 系统 MUST 返回错误码 `1003` + diff --git a/openspec/specs/client-device-capability/spec.md b/openspec/specs/client-device-capability/spec.md new file mode 100644 index 0000000..b7de90d --- /dev/null +++ b/openspec/specs/client-device-capability/spec.md @@ -0,0 +1,51 @@ +# Capability: 客户端设备能力 + +## ADDED Requirements + +### Requirement: F1 设备卡列表接口 + +系统 SHALL 提供 `GET /api/c/v1/device/cards?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 仅允许设备类型资产调用,且设备 MUST 具备 IMEI。响应体 SHALL 返回 `cards[]`,每项至少包含:`card_id`、`iccid`、`msisdn`、`carrier_name`、`network_status`、`real_name_status`、`slot_position`、`is_active`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ASSET_TYPE_INVALID/仅设备资产支持该操作`、`DEVICE_IMEI_REQUIRED/设备IMEI缺失`。 + +#### Scenario: 返回设备绑定卡列表 +- **WHEN** 客户查询已绑定设备卡列表 +- **THEN** 系统返回设备下全部卡及活跃标记 + +--- + +### Requirement: F2 设备重启接口 + +系统 SHALL 提供 `POST /api/c/v1/device/reboot`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.RebootDevice(imei)`。响应体 SHALL 返回 `accepted=true` 与 `request_id`(如网关返回)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ASSET_TYPE_INVALID/仅设备资产支持该操作`、`DEVICE_IMEI_REQUIRED/设备IMEI缺失`、`GATEWAY_ERROR/网关调用失败`。 + +#### Scenario: 设备重启成功受理 +- **WHEN** 客户对合法设备发起重启 +- **THEN** 系统调用网关成功并返回受理结果 + +--- + +### Requirement: F3 设备恢复出厂接口 + +系统 SHALL 提供 `POST /api/c/v1/device/factory-reset`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.ResetDevice(imei)`。响应体 SHALL 返回 `accepted=true`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ASSET_TYPE_INVALID/仅设备资产支持该操作`、`DEVICE_IMEI_REQUIRED/设备IMEI缺失`、`GATEWAY_ERROR/网关调用失败`。 + +#### Scenario: 恢复出厂失败返回网关错误 +- **WHEN** 网关返回失败 +- **THEN** 系统返回网关调用失败错误 + +--- + +### Requirement: F4 设备 WiFi 设置接口 + +系统 SHALL 提供 `POST /api/c/v1/device/wifi`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`、`ssid`、`password`、`enabled`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.SetWiFi(imei, ssid, password, enabled)`。实现 MUST 将 Gateway 的 `WiFiReq.cardNo` 填充为设备 IMEI。响应体 SHALL 返回 `accepted=true`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ASSET_TYPE_INVALID/仅设备资产支持该操作`、`DEVICE_IMEI_REQUIRED/设备IMEI缺失`、`GATEWAY_ERROR/网关调用失败`。 + +#### Scenario: WiFi 请求 cardNo 使用 IMEI +- **WHEN** 客户调用设备 WiFi 设置 +- **THEN** 系统向网关发送的 `cardNo` 字段值为设备 IMEI + +--- + +### Requirement: F5 设备切卡接口 + +系统 SHALL 提供 `POST /api/c/v1/device/switch-card`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`、`target_iccid`。接口 MUST 仅允许设备类型且 IMEI 存在,并调用 `gateway.SwitchCard(imei, target_iccid)`。响应体 SHALL 返回 `accepted=true`、`target_iccid`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ASSET_TYPE_INVALID/仅设备资产支持该操作`、`DEVICE_IMEI_REQUIRED/设备IMEI缺失`、`GATEWAY_ERROR/网关调用失败`。 + +#### Scenario: 切卡成功返回目标卡号 +- **WHEN** 客户请求切换到目标 ICCID 且网关执行成功 +- **THEN** 系统返回 `accepted=true` 与目标 ICCID diff --git a/openspec/specs/client-order-purchase/spec.md b/openspec/specs/client-order-purchase/spec.md new file mode 100644 index 0000000..a9db784 --- /dev/null +++ b/openspec/specs/client-order-purchase/spec.md @@ -0,0 +1,46 @@ +# Capability: 客户端套餐购买 + +## ADDED Requirements + +### Requirement: D1 创建套餐购买订单接口 + +系统 SHALL 提供 `POST /api/c/v1/orders/create`,并且 MUST 要求个人客户认证。请求体 MUST 包含 `identifier`、`package_ids[]`、`app_type`。接口流程 MUST 按顺序执行:归属校验 → 套餐校验(含加油包前置)→ 实名校验 → OpenID 查询 → 幂等检查 → 强充检查 → 分流创建。实名不满足时 MUST 返回 `NEED_REALNAME`。OpenID 缺失时 MUST 返回 `OPENID_NOT_FOUND`。幂等 MUST 使用 Redis 业务键 + 分布式锁。分流规则 MUST 为: + +- 无强充:创建套餐订单并返回 `order_type="package"`、`order`、`pay_config` +- 需强充:创建充值单并返回 `order_type="recharge"`、`recharge`、`pay_config`、`linked_package_info` + +响应体 MUST 包含前端可直接渲染字段。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`NEED_REALNAME/该套餐需实名认证后购买`、`OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权`、`IDEMPOTENT_CONFLICT/请求处理中,请勿重复提交`、`PACKAGE_NOT_AVAILABLE/套餐不可购买`。 + +#### Scenario: 命中强充返回 recharge 结构 +- **WHEN** 客户购买套餐触发强充要求 +- **THEN** 系统返回 `order_type="recharge"`,包含充值单与关联套餐信息 + +--- + +### Requirement: D2 套餐订单列表接口 + +系统 SHALL 提供 `GET /api/c/v1/orders?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 做归属校验并按资产当前 generation 过滤订单。请求参数 SHALL 支持 `payment_status`、`page`、`page_size`。响应体 SHALL 返回 `list[]`、`total`、`page`、`page_size`,列表项至少含 `order_id`、`order_no`、`total_amount`、`payment_status`、`created_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`。 + +#### Scenario: 支持支付状态筛选 +- **WHEN** 客户带 `payment_status=paid` 查询订单 +- **THEN** 系统仅返回当前 generation 且支付状态匹配的订单 + +--- + +### Requirement: D3 套餐订单详情接口 + +系统 SHALL 提供 `GET /api/c/v1/orders/:id`,并且 MUST 要求个人客户认证。接口 MUST 基于订单关联资产执行归属校验(通过资产虚拟号匹配 `PersonalCustomerDevice`)。响应体 SHALL 返回订单详情、套餐明细、支付信息、状态流转时间。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`ORDER_NOT_FOUND/订单不存在`。 + +#### Scenario: 查询他人订单被拦截 +- **WHEN** 客户请求不属于本人资产的订单详情 +- **THEN** 系统返回 403,错误消息为无权限操作该资产或资源不存在 + +--- + +### Requirement: AutoPurchaseAfterRecharge 异步任务 + +系统 SHALL 增加 `AutoPurchaseAfterRecharge` Asynq 任务处理强充二阶段。任务输入 MUST 包含 `recharge_record_id`。处理流程 MUST 为:从钱包扣款(`payment_method=wallet`)→ 创建套餐订单(`source="client"`、写入当前 generation)→ 激活套餐。任务失败 MUST 自动重试,最大 3 次。全部失败后 MUST 将 `auto_purchase_status` 标记为 `failed`,并保留钱包余额供用户手动购买。成功时 MUST 标记为 `success`。 + +#### Scenario: 异步任务连续失败 +- **WHEN** AutoPurchaseAfterRecharge 连续执行失败且达到最大重试次数 +- **THEN** 系统将充值记录 `auto_purchase_status` 更新为 `failed` diff --git a/openspec/specs/client-phone-binding/spec.md b/openspec/specs/client-phone-binding/spec.md new file mode 100644 index 0000000..5780366 --- /dev/null +++ b/openspec/specs/client-phone-binding/spec.md @@ -0,0 +1,96 @@ +# client-phone-binding Specification + +## Purpose +TBD - created by archiving change client-auth-system. Update Purpose after archive. +## Requirements +### Requirement: A4 发送验证码接口 + +系统 MUST 提供无认证验证码接口 `POST /api/c/v1/auth/send-code`,并复用现有验证码服务。 + +- HTTP Method + Path: `POST /api/c/v1/auth/send-code` +- 请求体字段: + - `phone` string,MUST,手机号 + - `scene` string,MUST,业务场景(`bind_phone` / `change_phone_old` / `change_phone_new`) +- 响应体字段: + - `cooldown_seconds` int,MUST,本次发送后的冷却秒数 +- 错误码: + - `1006` 参数错误 + - `1003` 请求过于频繁(触发任一限流) + - `1050` 短信发送失败 + +#### Scenario: 发送成功 +- **WHEN** 手机号格式合法且未触发限流 +- **THEN** 系统 SHALL 发送验证码并返回冷却时间 + +### Requirement: A4 限频规则 + +系统 SHALL 对 A4 实施三层限频:手机号 60 秒冷却、同 IP 每小时 20 次、同手机号每日 10 次。 + +#### Scenario: 60 秒内重复发送 +- **WHEN** 同一手机号在 60 秒冷却内再次请求 +- **THEN** 系统 MUST 返回 `1003` + +#### Scenario: 同 IP 超过小时阈值 +- **WHEN** 同一 IP 在 1 小时内发送次数超过 20 +- **THEN** 系统 MUST 返回 `1003` + +#### Scenario: 同手机号超过日阈值 +- **WHEN** 同一手机号在当日发送次数超过 10 +- **THEN** 系统 MUST 返回 `1003` + +### Requirement: A5 首次绑定手机号接口 + +系统 MUST 提供需认证接口 `POST /api/c/v1/auth/bind-phone`,仅允许首次绑定。 + +- HTTP Method + Path: `POST /api/c/v1/auth/bind-phone` +- 请求体字段: + - `phone` string,MUST,新手机号 + - `code` string,MUST,验证码 +- 响应体字段: + - `phone` string,MUST,已绑定手机号 + - `bound_at` string,MUST,绑定时间 +- 错误码: + - `1001` 缺失认证令牌 + - `1002` 认证令牌无效 + - `1006` 参数错误 + - `1035` 验证码错误或过期 + - `1037` 手机号已被绑定 + - `1038` 已绑定手机号不可重复绑定 + +#### Scenario: 首次绑定成功 +- **WHEN** 客户已登录、验证码正确且手机号未被占用 +- **THEN** 系统 SHALL 完成手机号首次绑定并返回绑定信息 + +#### Scenario: 已绑定用户再次调用绑定 +- **WHEN** 当前客户已存在绑定手机号 +- **THEN** 系统 MUST 返回 `1038` + +### Requirement: A6 换绑手机号接口 + +系统 MUST 提供需认证接口 `POST /api/c/v1/auth/change-phone`,并执行旧手机号与新手机号双验证码校验。 + +- HTTP Method + Path: `POST /api/c/v1/auth/change-phone` +- 请求体字段: + - `old_phone` string,MUST,旧手机号 + - `old_code` string,MUST,旧手机号验证码 + - `new_phone` string,MUST,新手机号 + - `new_code` string,MUST,新手机号验证码 +- 响应体字段: + - `phone` string,MUST,换绑后的手机号 + - `changed_at` string,MUST,换绑时间 +- 错误码: + - `1001` 缺失认证令牌 + - `1002` 认证令牌无效 + - `1006` 参数错误 + - `1035` 验证码错误或过期 + - `1037` 新手机号已被绑定 + - `1039` 旧手机号不匹配 + +#### Scenario: 换绑成功 +- **WHEN** 登录客户提交正确旧/新验证码且新手机号未占用 +- **THEN** 系统 SHALL 更新绑定手机号为新手机号 + +#### Scenario: 旧手机号校验失败 +- **WHEN** `old_phone` 与当前客户绑定手机号不一致或 `old_code` 错误 +- **THEN** 系统 MUST 拒绝换绑并返回对应错误码 + diff --git a/openspec/specs/client-realname-link/spec.md b/openspec/specs/client-realname-link/spec.md new file mode 100644 index 0000000..37176cf --- /dev/null +++ b/openspec/specs/client-realname-link/spec.md @@ -0,0 +1,23 @@ +# Capability: 客户端实名跳转 + +## ADDED Requirements + +### Requirement: E1 获取实名跳转链接接口 + +系统 SHALL 提供 `GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx`,并且 MUST 要求个人客户认证。该接口 MUST 支持两类入口:购买拦截入口与设备卡列表主动入口。目标卡定位 MUST 支持三种路径: + +1. 标识符直达卡:直接使用该卡 +2. 标识符为设备且传 `iccid`:定位对应设备下卡 +3. 标识符为设备且未传 `iccid`:定位设备当前活跃卡 + +当 `real_name_status=1` 时 MUST 返回“该卡已完成实名”错误。运营商实名模式 MUST 支持: + +- `none`:不支持在线实名,直接报错 +- `template`:按模板替换占位符 `{iccid}` `{msisdn}` `{virtual_no}` 返回 URL +- `gateway`:调用网关获取实名链接 + +响应体 SHALL 至少包含 `realname_mode`、`realname_url`、`card_info{iccid,msisdn,virtual_no}`、`expire_at`(可空)。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`、`REALNAME_ALREADY_DONE/该卡已完成实名`、`REALNAME_NOT_SUPPORTED/该运营商暂不支持在线实名`、`GATEWAY_ERROR/获取实名链接失败`。 + +#### Scenario: 设备未传 iccid 自动选活跃卡 +- **WHEN** 客户传入设备标识符且不传 `iccid` +- **THEN** 系统自动选择设备活跃卡并返回实名跳转链接 diff --git a/openspec/specs/client-token-management/spec.md b/openspec/specs/client-token-management/spec.md new file mode 100644 index 0000000..e87e6ac --- /dev/null +++ b/openspec/specs/client-token-management/spec.md @@ -0,0 +1,59 @@ +# client-token-management Specification + +## Purpose +TBD - created by archiving change client-auth-system. Update Purpose after archive. +## Requirements +### Requirement: 登录 JWT 签发与 Redis 状态存储 + +系统 MUST 在 A2/A3 登录成功后签发个人客户 JWT,并将 token 状态写入 Redis。 + +- JWT payload 字段: + - `customer_id` uint,MUST + - `exp` int64,MUST +- Redis Key:`RedisPersonalCustomerTokenKey(customerID)` +- Redis Value:当前有效 token(或 token 集合,取决于实现) +- TTL:MUST 与 JWT 过期时间一致 + +#### Scenario: 登录成功写入 Redis +- **WHEN** 客户完成微信登录 +- **THEN** 系统 SHALL 签发 JWT +- **THEN** 系统 SHALL 将 token 写入 Redis 并设置 TTL + +### Requirement: PersonalAuthMiddleware 双重校验 + +系统 SHALL 在个人客户认证中间件执行双重校验:JWT 解析校验 + Redis 状态校验。 + +#### Scenario: JWT 与 Redis 均有效 +- **WHEN** 请求携带有效 JWT 且 Redis 中存在有效状态 +- **THEN** 中间件 SHALL 放行并写入 `customer_id` 到上下文 + +#### Scenario: JWT 有效但 Redis 不存在 +- **WHEN** JWT 仍在有效期但 Redis 中不存在该客户 token 状态 +- **THEN** 中间件 MUST 返回未认证错误 `1002` + +### Requirement: A7 退出登录接口 + +系统 MUST 提供需认证接口 `POST /api/c/v1/auth/logout`,用于删除 Redis token 状态。 + +- HTTP Method + Path: `POST /api/c/v1/auth/logout` +- 请求体字段:无 +- 响应体字段: + - `success` bool,MUST +- 错误码: + - `1001` 缺失认证令牌 + - `1002` 认证令牌无效 + +#### Scenario: 退出登录成功 +- **WHEN** 登录客户调用 A7 +- **THEN** 系统 SHALL 删除 `RedisPersonalCustomerTokenKey(customerID)` +- **THEN** 系统 SHALL 返回成功 + +### Requirement: 服务端主动失效能力 + +系统 MUST 支持服务端主动使 token 失效(如封禁/强制下线),且无需等待 JWT 自然过期。 + +#### Scenario: 服务端主动踢出 +- **WHEN** 管理动作触发客户强制下线 +- **THEN** 系统 SHALL 删除对应 Redis token 状态 +- **THEN** 该客户后续请求 MUST 被中间件拒绝 + diff --git a/openspec/specs/client-wallet-recharge/spec.md b/openspec/specs/client-wallet-recharge/spec.md new file mode 100644 index 0000000..e45ba00 --- /dev/null +++ b/openspec/specs/client-wallet-recharge/spec.md @@ -0,0 +1,51 @@ +# Capability: 客户端钱包与充值 + +## ADDED Requirements + +### Requirement: C1 钱包详情接口 + +系统 SHALL 提供 `GET /api/c/v1/wallet/detail?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 先完成资产解析与归属校验;钱包不存在时 MUST 自动创建空钱包。响应体 SHALL 包含 `wallet_id`、`resource_type`、`resource_id`、`balance`、`frozen_balance`、`updated_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`。 + +#### Scenario: 首次访问自动建钱包 +- **WHEN** 客户查询资产钱包详情且钱包记录不存在 +- **THEN** 系统自动创建钱包并返回余额 0 + +--- + +### Requirement: C2 钱包流水列表接口 + +系统 SHALL 提供 `GET /api/c/v1/wallet/transactions?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 通过归属校验解析出唯一 `wallet_id` 后查询流水,实现天然隔离。请求参数 SHALL 支持 `transaction_type`、`start_time`、`end_time`、`page`、`page_size`。响应体 SHALL 包含 `list[]`、`total`、`page`、`page_size`,每条记录至少含 `transaction_id`、`type`、`amount`、`balance_after`、`created_at`、`remark`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`。 + +#### Scenario: wallet_id 隔离生效 +- **WHEN** 客户查询某资产流水 +- **THEN** 系统仅返回该资产钱包对应流水,不返回其他钱包数据 + +--- + +### Requirement: C3 充值预检接口 + +系统 SHALL 提供 `GET /api/c/v1/wallet/recharge-check?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 复用 `recharge.Service.GetRechargeCheck()` 计算强充规则。响应体 SHALL 包含 `need_force_recharge`、`force_recharge_amount`、`trigger_type`、`min_amount`、`max_amount`、`message`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`。 + +#### Scenario: 返回强充预检结果 +- **WHEN** 资产命中强充规则 +- **THEN** 系统返回 `need_force_recharge=true` 与对应强充金额和触发类型 + +--- + +### Requirement: C4 创建充值订单接口 + +系统 SHALL 提供 `POST /api/c/v1/wallet/recharge`,并且 MUST 要求个人客户认证。请求体 MUST 包含:`identifier`、`amount`(100~10000000 分)、`payment_method=wechat`、`app_type`。接口 MUST 禁止客户端传入 OpenID,并由后端按 `customer_id + app_type` 查询 OpenID。订单创建时 MUST 写入:`operator_type=personal_customer` 与资产当前 `generation` 快照。响应体 SHALL 返回 `recharge` 与 `pay_config`,其中 `recharge` 至少含 `recharge_id`、`recharge_no`、`amount`、`status`,`pay_config` 为微信 JSAPI 拉起参数。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`OPENID_NOT_FOUND/未找到微信授权信息,请先完成授权`、`FORBIDDEN/无权限操作该资产或资源不存在`、`PAYMENT_NOT_SUPPORTED/仅支持微信支付`。 + +#### Scenario: 后端查 OpenID 并返回支付参数 +- **WHEN** 客户传入合法参数且后端成功查询到 OpenID +- **THEN** 系统创建充值单并返回 `recharge + pay_config` + +--- + +### Requirement: C5 充值订单列表接口 + +系统 SHALL 提供 `GET /api/c/v1/wallet/recharges?identifier=xxx`,并且 MUST 要求个人客户认证。接口 MUST 在归属校验后按资产当前 generation 过滤充值记录。请求参数 SHALL 支持 `status`、`page`、`page_size`。响应体 SHALL 返回 `list[]`、`total`、`page`、`page_size`,每项至少含 `recharge_id`、`recharge_no`、`amount`、`status`、`payment_method`、`created_at`。错误码/消息 MUST 至少包含:`INVALID_PARAM/参数错误`、`FORBIDDEN/无权限操作该资产或资源不存在`。 + +#### Scenario: generation 过滤充值历史 +- **WHEN** 资产存在多代充值记录 +- **THEN** 系统仅返回当前 generation 对应的充值记录 diff --git a/openspec/specs/client-wechat-login/spec.md b/openspec/specs/client-wechat-login/spec.md new file mode 100644 index 0000000..a3f2c63 --- /dev/null +++ b/openspec/specs/client-wechat-login/spec.md @@ -0,0 +1,107 @@ +# client-wechat-login Specification + +## Purpose +TBD - created by archiving change client-auth-system. Update Purpose after archive. +## Requirements +### Requirement: A2 微信公众号登录接口 + +系统 MUST 提供 `POST /api/c/v1/auth/wechat-login`,使用公众号 OAuth code + `asset_token` 完成登录。 + +- HTTP Method + Path: `POST /api/c/v1/auth/wechat-login` +- 请求体字段: + - `code` string,MUST,微信 OAuth 授权码 + - `asset_token` string,MUST,A1 返回的资产令牌 +- 响应体字段: + - `token` string,MUST,登录 JWT + - `need_bind_phone` bool,MUST,是否需要绑定手机号 + - `is_new_user` bool,MUST,是否新创建用户 +- 错误码: + - `1002` token 无效或过期(asset_token/JWT) + - `1040` 微信授权失败 + - `1006` 参数错误 + +#### Scenario: 公众号登录成功 +- **WHEN** 客户端提交有效 `code` 与有效 `asset_token` +- **THEN** 系统 SHALL 调用公众号 OAuth 获取 `openid` 与可选 `unionid` +- **THEN** 系统 SHALL 执行客户查找/创建/合并逻辑 +- **THEN** 系统 SHALL 绑定资产并签发登录 token + +### Requirement: A3 微信小程序登录接口 + +系统 MUST 提供 `POST /api/c/v1/auth/miniapp-login`,使用小程序 `jscode2session` + `asset_token` 完成登录。 + +- HTTP Method + Path: `POST /api/c/v1/auth/miniapp-login` +- 请求体字段: + - `code` string,MUST,小程序登录凭证 + - `asset_token` string,MUST,A1 返回的资产令牌 +- 响应体字段: + - `token` string,MUST,登录 JWT + - `need_bind_phone` bool,MUST + - `is_new_user` bool,MUST +- 错误码: + - `1002` token 无效或过期 + - `1040` 微信授权失败 + - `1006` 参数错误 + +#### Scenario: 小程序登录成功 +- **WHEN** 客户端提交有效小程序 `code` 与有效 `asset_token` +- **THEN** 系统 SHALL 调用 `jscode2session` 获取 `openid` 与可选 `unionid` +- **THEN** 系统 SHALL 执行与 A2 一致的客户查找/创建/合并、资产绑定与签发逻辑 + +### Requirement: asset_token 校验与资产解析 + +系统 SHALL 在 A2/A3 登录前强制校验 `asset_token`,并解析出 `asset_type` + `asset_id`。 + +#### Scenario: asset_token 无效 +- **WHEN** `asset_token` 签名不合法或已过期 +- **THEN** 系统 MUST 拒绝登录并返回 `1002` + +#### Scenario: asset_token 有效 +- **WHEN** `asset_token` 可被成功解析 +- **THEN** 系统 SHALL 使用解析出的资产信息继续登录流程 + +### Requirement: 客户查找/创建/合并逻辑 + +系统 MUST 按以下顺序处理客户归属: + +1. 先查 `PersonalCustomerOpenID`:`(app_id, open_id)`; +2. 未命中且存在 `unionid` 时按 `unionid` 回查并复用客户; +3. 仍未命中时创建新 `PersonalCustomer` 与 OpenID 记录。 + +#### Scenario: openid 命中既有客户 +- **WHEN** `(app_id, open_id)` 已存在 +- **THEN** 系统 SHALL 直接复用对应 `customer_id` + +#### Scenario: openid 未命中但 unionid 命中 +- **WHEN** `(app_id, open_id)` 不存在且 `unionid` 命中历史记录 +- **THEN** 系统 SHALL 复用已存在客户 +- **THEN** 系统 SHALL 新增当前 `app_id + open_id` 记录 + +#### Scenario: openid/unionid 均未命中 +- **WHEN** 无任何匹配记录 +- **THEN** 系统 SHALL 创建新客户并写入 OpenID 记录 + +### Requirement: 登录后资产绑定 + +系统 SHALL 在 A2/A3 每次登录时创建一条 `PersonalCustomerDevice` 绑定记录,且 MUST 允许同一资产被多个客户绑定。 + +#### Scenario: 已有绑定时再次登录 +- **WHEN** 同一客户再次登录同一资产 +- **THEN** 系统 SHALL 记录本次登录绑定关系(按实现可去重或追加历史) + +#### Scenario: 不同客户绑定同一资产 +- **WHEN** 资产已被其他客户绑定 +- **THEN** 系统 MUST 允许新增绑定,不得覆盖已有客户绑定关系 + +### Requirement: 登录响应与手机号绑定开关 + +系统 MUST 在登录响应中返回 `need_bind_phone`,该值由 `client.require_phone_binding` 与客户手机号绑定状态共同决定。 + +#### Scenario: 要求手机号绑定且未绑定 +- **WHEN** 配置 `client.require_phone_binding=true` 且客户未绑定手机号 +- **THEN** 登录响应 MUST 返回 `need_bind_phone=true` + +#### Scenario: 已绑定手机号或配置关闭 +- **WHEN** 客户已绑定手机号或 `client.require_phone_binding=false` +- **THEN** 登录响应 MUST 返回 `need_bind_phone=false` + diff --git a/openspec/specs/device/spec.md b/openspec/specs/device/spec.md index 4b0c98c..345d07b 100644 --- a/openspec/specs/device/spec.md +++ b/openspec/specs/device/spec.md @@ -356,3 +356,42 @@ ALTER TABLE tb_personal_customer_device RENAME COLUMN device_no TO virtual_no; - **WHEN** 前端调用设备列表或详情接口 - **THEN** 响应 JSON 中 key 为 `virtual_no`,不再有 `device_no` +### Requirement: 设备实体定义 + +系统 SHALL 在 `Device` 模型新增以下字段: +- `asset_status int NOT NULL DEFAULT 1` +- `generation int NOT NULL DEFAULT 1` + +#### Scenario: 新建设备默认资产状态 +- **WHEN** 创建新的设备记录 +- **THEN** `asset_status` MUST 默认为 `1`(在库) + +#### Scenario: 新建设备默认代际 +- **WHEN** 创建新的设备记录 +- **THEN** `generation` MUST 默认为 `1` + +--- + +### Requirement: 设备换货状态语义扩展 + +系统 SHALL 将 `asset_status=3` 定义为"已换货",用于标记已被换出的旧设备资产。 + +#### Scenario: 换货完成后旧设备标记 +- **WHEN** H5 确认完成且旧资产为设备 +- **THEN** 系统 MUST 将旧设备 `asset_status` 更新为 `3` + +--- + +### Requirement: 设备转新重置规则 + +系统 SHALL 在 H7 转新时对设备执行以下重置: +- `generation = generation + 1` +- `asset_status = 1`(在库) +- 清空累计充值与首充触发相关状态 +- 清除个人客户绑定关系 +- 创建新空钱包并与新代际设备关联 + +#### Scenario: 转新后设备可重新销售 +- **WHEN** 对已换货设备执行转新 +- **THEN** 系统 MUST 使该设备进入新代际并恢复在库可售 + diff --git a/openspec/specs/exchange-admin-management/spec.md b/openspec/specs/exchange-admin-management/spec.md new file mode 100644 index 0000000..1e33645 --- /dev/null +++ b/openspec/specs/exchange-admin-management/spec.md @@ -0,0 +1,127 @@ +# exchange-admin-management Specification + +## Purpose + +提供后台换货单管理能力,涵盖换货单的发起、列表查询、详情查看、发货、确认完成、取消及旧资产转新等完整生命周期管理。 + +## Requirements + +### Requirement: H1 发起换货单 + +系统 SHALL 提供 `POST /api/admin/exchanges`(需后台认证 `Auth=true`),用于发起换货单。 + +请求体 MUST 包含:`old_asset_type`、`old_identifier`、`exchange_reason`,可选 `remark`。 + +系统 MUST 校验: +- 旧资产存在且当前用户有权限 +- 同一资产不存在进行中的换货单(`status IN (1,2,3)`) + +成功响应 SHALL 返回新建换货单信息(含 `id`、`exchange_no`、`status=1`)。 + +错误响应 MUST 至少包含:参数错误、资产不存在或无权限、存在进行中换货单。 + +#### Scenario: 资产已有进行中换货单 +- **WHEN** 后台为同一资产重复发起换货 +- **THEN** 系统 MUST 拒绝创建并返回"存在进行中的换货单" + +--- + +### Requirement: H2 换货单列表 + +系统 SHALL 提供 `GET /api/admin/exchanges`(`Auth=true`),支持分页与条件查询。 + +查询条件 SHOULD 支持:`status`、`identifier`(资产标识搜索)、`created_at_start`、`created_at_end`、分页参数。 + +响应 SHALL 返回列表与分页元数据。 + +#### Scenario: 按状态查询待发货单 +- **WHEN** 运营查询 `status=2` +- **THEN** 系统返回所有待发货换货单并按创建时间倒序 + +--- + +### Requirement: H3 换货单详情 + +系统 SHALL 提供 `GET /api/admin/exchanges/:id`(`Auth=true`)查询换货单详情。 + +响应 MUST 返回旧/新资产信息、收货信息、物流信息、迁移状态信息。 + +错误响应 MUST 至少包含:换货单不存在或无权限。 + +#### Scenario: 查询不存在换货单 +- **WHEN** 查询不存在的换货单 ID +- **THEN** 系统 MUST 返回"资源不存在或无权限" + +--- + +### Requirement: H4 发货 + +系统 SHALL 提供 `POST /api/admin/exchanges/:id/ship`(`Auth=true`)。 + +请求体 MUST 包含:`express_company`、`express_no`、`new_identifier`、`migrate_data`。 + +系统 MUST 校验: +- 当前状态必须为 `2` +- 新旧资产类型必须一致(卡换卡/设备换设备) +- 新资产必须 `asset_status=1`(在库) + +成功后 SHALL 更新新资产信息、物流信息并将状态改为 `3`。 + +错误响应 MUST 至少包含:非法状态、资产类型不匹配、新资产非在库、资产不存在或无权限。 + +#### Scenario: 新资产类型不一致 +- **WHEN** 旧资产为 iot_card 且新资产为 device +- **THEN** 系统 MUST 拒绝发货并返回"换货资产类型必须一致" + +--- + +### Requirement: H5 确认完成 + +系统 SHALL 提供 `POST /api/admin/exchanges/:id/complete`(`Auth=true`)。 + +系统 MUST 校验当前状态为 `3`。当 `migrate_data=true` 时,系统 MUST 执行全量迁移事务(见 `exchange-data-migration` 能力)。 + +成功后 SHALL: +- `migration_completed=true`(若执行迁移) +- 换货单状态更新为 `4` + +错误响应 MUST 至少包含:非法状态、迁移失败、换货单不存在或无权限。 + +#### Scenario: 需要迁移并完成 +- **WHEN** 状态为 `3` 且 `migrate_data=true` +- **THEN** 系统 MUST 在事务成功后将状态变为 `4` 并记录迁移结果 + +--- + +### Requirement: H6 取消换货 + +系统 SHALL 提供 `POST /api/admin/exchanges/:id/cancel`(`Auth=true`)。 + +系统 MUST 仅允许在 `status IN (1,2)` 时取消,成功后状态更新为 `5`。 + +系统 MUST 禁止已发货单取消(`status=3`)。 + +#### Scenario: 已发货单取消失败 +- **WHEN** 换货单状态为 `3` 发起取消 +- **THEN** 系统 MUST 返回状态非法错误 + +--- + +### Requirement: H7 旧资产转新 + +系统 SHALL 提供 `POST /api/admin/exchanges/:id/renew`(`Auth=true`)。 + +系统 MUST 校验旧资产当前 `asset_status=3`(已换货),并执行: +- `generation + 1` +- `asset_status -> 1` +- 清除累计充值/首充相关状态 +- 清除个人客户绑定 +- 创建新空钱包 + +系统 MUST 保留历史数据,不执行历史删除。 + +错误响应 MUST 至少包含:资产状态不满足转新条件、换货单不存在或无权限。 + +#### Scenario: 旧资产未处于已换货状态 +- **WHEN** 旧资产 `asset_status != 3` 发起转新 +- **THEN** 系统 MUST 拒绝并返回"资产当前状态不允许转新" diff --git a/openspec/specs/exchange-client-notification/spec.md b/openspec/specs/exchange-client-notification/spec.md new file mode 100644 index 0000000..bc405c4 --- /dev/null +++ b/openspec/specs/exchange-client-notification/spec.md @@ -0,0 +1,41 @@ +# exchange-client-notification Specification + +## Purpose + +提供个人客户端换货通知与收货信息填写能力,支持客户查询进行中的换货单状态并提交收货地址。 + +## Requirements + +### Requirement: G1 查询进行中换货通知 + +系统 SHALL 提供 `GET /api/c/v1/exchange/pending?identifier=xxx`(需个人客户认证 `Auth=true`)。 + +系统 MUST 根据资产标识查询当前客户可见的进行中换货单,仅返回 `status IN (1,2,3)` 的记录。 + +响应 SHALL 至少包含:换货单 ID、单号、状态、换货原因、创建时间。 + +错误响应 MUST 至少包含:参数错误、资产不存在或无权限。 + +#### Scenario: 命中进行中换货单 +- **WHEN** 客户按资产标识查询且存在状态为 2 的换货单 +- **THEN** 系统返回该换货单并标识当前状态为待发货 + +--- + +### Requirement: G2 填写收货信息 + +系统 SHALL 提供 `POST /api/c/v1/exchange/:id/shipping-info`(需个人客户认证 `Auth=true`)。 + +请求体 MUST 包含:`recipient_name`、`recipient_phone`、`recipient_address`。 + +系统 MUST 校验: +- 换货单存在且当前客户有权限 +- 当前状态必须为 `1` + +成功后 SHALL 写入收货信息并将状态更新为 `2`。 + +错误响应 MUST 至少包含:参数错误、状态非法、换货单不存在或无权限。 + +#### Scenario: 非待填写状态禁止更新收货信息 +- **WHEN** 换货单当前状态为 `2` 或 `3` +- **THEN** 系统 MUST 拒绝填写并返回状态非法错误 diff --git a/openspec/specs/exchange-data-migration/spec.md b/openspec/specs/exchange-data-migration/spec.md new file mode 100644 index 0000000..a168b72 --- /dev/null +++ b/openspec/specs/exchange-data-migration/spec.md @@ -0,0 +1,66 @@ +# exchange-data-migration Specification + +## Purpose + +定义换货全量迁移事务规则,包括 11 张表的迁移策略、设备换设备特殊规则及旧资产转新的代际隔离策略。 + +## Requirements + +### Requirement: 全量迁移事务边界 + +系统 MUST 在 H5 确认完成且 `migrate_data=true` 时,使用**单一数据库事务**执行全量迁移。 + +该事务 SHALL 覆盖资产钱包、套餐、标签、客户绑定及资产状态更新等所有步骤;任一步骤失败 MUST 回滚。 + +#### Scenario: 迁移中途失败回滚 +- **WHEN** 迁移第 N 步发生数据库错误 +- **THEN** 系统 MUST 回滚整个事务,换货单状态保持未完成 + +--- + +### Requirement: 11 张表迁移规则 + +系统 SHALL 按以下规则处理 11 张表: + +1. `tb_asset_wallet`:将旧资产钱包余额转移到新资产钱包。 +2. `tb_asset_wallet_transaction`:生成一条迁移流水记录(明确来源钱包、目标钱包、金额、业务类型)。 +3. `tb_asset_recharge_record`:历史充值记录保留,不做更新。 +4. `tb_package_usage`:将生效套餐关联到新资产(更新 `iot_card_id` 或 `device_id`)。 +5. `tb_package_usage_daily_record`:随 `tb_package_usage` 关系迁移(保持套餐日明细连续性)。 +6. `tb_order`:历史订单保留,不做更新。 +7. `tb_commission`:历史分佣记录保留,不做更新。 +8. `tb_data_usage_record`:历史流量记录保留,不做更新。 +9. `tb_resource_tag`:复制旧资产标签到新资产。 +10. `tb_personal_customer_device`:将绑定记录中的 `virtual_no` 更新为新资产虚拟号。 +11. `tb_iot_card`/`tb_device`:迁移累计充值与首充状态到新资产,并将旧资产 `asset_status -> 3`。 + +#### Scenario: 钱包余额转移并记录流水 +- **WHEN** 旧资产钱包余额为 5000 分 +- **THEN** 新资产钱包余额增加 5000 分,旧钱包余额按迁移策略清零,并写入迁移流水 + +--- + +### Requirement: 设备换设备特殊规则 + +设备换设备流程 MUST NOT 迁移 `DeviceSimBinding`。 + +系统 SHALL 视新设备为新硬件交付,新设备卡绑定由其自身体系决定,旧设备绑定关系保留历史。 + +#### Scenario: 设备换设备不复制绑定卡 +- **WHEN** 执行设备换设备全量迁移 +- **THEN** 系统 MUST 不创建或复制任何 `DeviceSimBinding` 记录到新设备 + +--- + +### Requirement: 转新规则 + +系统 SHALL 在 H7 转新时执行代际隔离策略: +- 资产 `generation + 1` +- 创建新空钱包(新 `wallet_id`) +- 清除累计充值状态与首充触发状态 +- 清除 `PersonalCustomerDevice` 绑定 +- 不删除历史业务数据 + +#### Scenario: 转新后历史数据保留 +- **WHEN** 资产转新完成 +- **THEN** 历史订单、充值、分佣、流量数据 MUST 仍可在旧代际查询链路中追溯 diff --git a/openspec/specs/exchange-order-model/spec.md b/openspec/specs/exchange-order-model/spec.md new file mode 100644 index 0000000..1667334 --- /dev/null +++ b/openspec/specs/exchange-order-model/spec.md @@ -0,0 +1,75 @@ +# exchange-order-model Specification + +## Purpose + +定义换货单(ExchangeOrder)数据模型、状态常量、状态机流转规则及换货单号生成规则,作为换货系统的核心数据基础。 + +## Requirements + +### Requirement: ExchangeOrder 换货单模型定义 + +系统 SHALL 定义 `ExchangeOrder` 模型并映射到 `tb_exchange_order`,用于承载客户端换货完整生命周期。 + +模型字段 MUST 至少包含: +- 基础:`id`、`created_at`、`updated_at`、`deleted_at`、`creator`、`updater` +- 单号:`exchange_no` +- 旧资产:`old_asset_type`、`old_asset_id`、`old_asset_identifier` +- 新资产:`new_asset_type`、`new_asset_id`、`new_asset_identifier` +- 收货:`recipient_name`、`recipient_phone`、`recipient_address` +- 物流:`express_company`、`express_no` +- 迁移:`migrate_data`、`migration_completed`、`migration_balance` +- 业务:`exchange_reason`、`remark`、`status` +- 多租户:`shop_id` + +`ExchangeOrder` SHALL 嵌入 `BaseModel` 并实现 `TableName() string`,返回 `tb_exchange_order`。 + +#### Scenario: 创建换货单模型实例 +- **WHEN** 系统创建新的换货单记录 +- **THEN** 记录 MUST 同时包含旧资产快照、收货信息占位、迁移状态字段和多租户字段 + +--- + +### Requirement: 换货状态常量定义 + +系统 MUST 使用 int 常量定义换货状态: +- `1` 待填写信息 +- `2` 待发货 +- `3` 已发货待确认 +- `4` 已完成 +- `5` 已取消 + +#### Scenario: 状态常量一致性 +- **WHEN** Service、Store、Handler 读取或更新换货状态 +- **THEN** 各层 MUST 使用统一常量值,禁止硬编码散落魔法数字 + +--- + +### Requirement: 换货状态机流转规则 + +系统 SHALL 执行以下状态机: +- 创建换货单后:`1` +- 客户填写收货信息后:`1 -> 2` +- 后台发货后:`2 -> 3` +- 后台确认完成后:`3 -> 4` +- 取消:仅允许 `1/2 -> 5` + +系统 MUST 禁止非法流转(如 `3 -> 5`、`4 -> 2`)。 + +#### Scenario: 已发货不可取消 +- **WHEN** 换货单状态为 `3` 且请求取消 +- **THEN** 系统 MUST 拒绝并返回状态流转非法错误 + +--- + +### Requirement: 换货单号生成规则 + +系统 MUST 为每个换货单生成全局可追踪单号,格式为:`EXC + 时间戳片段 + 随机数片段`。 + +生成规则 SHALL 满足: +- 前缀固定为 `EXC` +- 包含日期/时间信息用于人工排查 +- 包含随机片段降低并发冲突概率 + +#### Scenario: 生成换货单号 +- **WHEN** 后台发起换货并创建新单 +- **THEN** 系统 MUST 生成形如 `EXC20260319XXXXXX` 的单号并写入 `exchange_no` diff --git a/openspec/specs/force-recharge-check/spec.md b/openspec/specs/force-recharge-check/spec.md index dac65e1..2732452 100644 --- a/openspec/specs/force-recharge-check/spec.md +++ b/openspec/specs/force-recharge-check/spec.md @@ -143,3 +143,23 @@ #### Scenario: 套餐不存在 - **WHEN** 套餐购买预检时,套餐 ID 不存在 - **THEN** 系统返回错误 "套餐不存在" + +--- + +### Requirement: 强充检查结果对客户端透出 + +系统 MUST 将强充检查结果输出给客户端接口(充值预检与购买预检),用于前端明确展示支付拆分。输出字段 SHALL 至少包含:`need_force_recharge`、`force_recharge_amount`、`trigger_type`、`total_package_amount`、`actual_payment`、`wallet_credit`、`message`。若无强充,`need_force_recharge=false` 且 `actual_payment=total_package_amount`。 + +#### Scenario: 客户端购买预检命中强充 +- **WHEN** 客户端调用购买预检且命中强充规则 +- **THEN** 系统返回强充金额、实际支付金额和钱包入账金额 + +--- + +### Requirement: 前端展示套餐价与强充金额拆分 + +系统 SHALL 在强充场景提供可直接渲染的拆分语义:套餐总价、需支付金额、充值入钱包金额,并给出中文提示文案。当前端调用客户端下单接口(D1)时,若命中强充 MUST 返回 `order_type="recharge"` 与 `linked_package_info`,以便前端保持与预检展示一致。 + +#### Scenario: 套餐价低于强充金额 +- **WHEN** 套餐总价 5000 分,强充金额 10000 分 +- **THEN** 预检返回 `actual_payment=10000`、`wallet_credit=5000`、提示文案可用于前端直接展示 diff --git a/openspec/specs/h5-legacy-cleanup/spec.md b/openspec/specs/h5-legacy-cleanup/spec.md new file mode 100644 index 0000000..728c995 --- /dev/null +++ b/openspec/specs/h5-legacy-cleanup/spec.md @@ -0,0 +1,51 @@ +# h5-legacy-cleanup Specification + +## Purpose +TBD - created by archiving change client-api-data-model-fixes. Update Purpose after archive. +## Requirements +### Requirement: 旧 H5 接口文件删除清单 + +系统 MUST 完整删除以下旧 H5 文件: +- `internal/handler/h5/auth.go` +- `internal/handler/h5/order.go` +- `internal/handler/h5/recharge.go` +- `internal/handler/h5/package_usage.go` +- `internal/handler/h5/enterprise_device.go` +- `internal/routes/h5.go` +- `internal/routes/h5_enterprise_device.go` +- `internal/routes/h5_package_usage.go` + +#### Scenario: 旧 H5 文件不存在 +- **WHEN** 执行本提案改造完成后检查仓库 +- **THEN** 上述文件 MUST 全部不存在 + +--- + +### Requirement: 旧 H5 与旧登录引用清理清单 + +系统 MUST 清理以下代码引用: +- bootstrap:`handlers.go` 中 `H5Auth`、`EnterpriseDeviceH5`、`H5PackageUsage`、`H5Order`、`H5Recharge` +- bootstrap:`types.go` 对应字段 +- bootstrap:`middlewares.go` 中 `createH5AuthMiddleware` +- 路由:`routes.go` 的 `/api/h5` 挂载 +- 路由:`order.go` 的 `registerH5OrderRoutes` +- 路由:`recharge.go` 的 `registerH5RechargeRoutes` +- 文档:`pkg/openapi/handlers.go` 中 H5 Handler 构造 +- 限流:`cmd/api/main.go` 中 `/api/h5` 限流配置 +- 旧登录方法:`internal/handler/app/personal_customer.go` 中 `Login`、`SendCode`、`WechatOAuthLogin`、`BindWechat` +- 旧登录路由:`internal/routes/personal.go` 中指向已删除方法的路由 + +#### Scenario: 编译期无已删除符号引用 +- **WHEN** 清理完成后执行编译 +- **THEN** 系统 MUST 不再出现对上述已删除 Handler、路由或方法的引用 + +--- + +### Requirement: 清理后编译通过 + +系统 MUST 在完成文件删除与引用清理后保持工程可编译。 + +#### Scenario: 全量编译验证通过 +- **WHEN** 执行构建命令 +- **THEN** 工程 MUST 编译通过且无 H5 旧接口残留导致的编译错误 + diff --git a/openspec/specs/iot-card/spec.md b/openspec/specs/iot-card/spec.md index 3bf09d0..d1c386a 100644 --- a/openspec/specs/iot-card/spec.md +++ b/openspec/specs/iot-card/spec.md @@ -624,7 +624,6 @@ This capability supports: - **WHEN** 系统时间到达每月1号 00:00:00 - **THEN** 系统重置所有 data_reset_cycle=monthly 的套餐 data_usage_mb=0 - --- ### Requirement: IotCard Handler 分层修复 @@ -749,3 +748,42 @@ IotCard Service SHALL 提供 Gateway API 的代理方法,封装权限检查和 - **WHEN** 系统中有历史导入的卡,没有 virtual_no - **THEN** 这些卡的 virtual_no = NULL,不影响唯一索引(部分索引跳过 NULL 值) + +### Requirement: IoT 卡资产生命周期字段 + +系统 SHALL 在 `IotCard` 模型新增以下资产生命周期追踪字段: +- `asset_status int NOT NULL DEFAULT 1` +- `generation int NOT NULL DEFAULT 1` + +#### Scenario: 新建 IoT 卡默认资产状态 +- **WHEN** 创建新的 IoT 卡记录 +- **THEN** `asset_status` MUST 默认为 `1`(在库) + +#### Scenario: 新建 IoT 卡默认代际 +- **WHEN** 创建新的 IoT 卡记录 +- **THEN** `generation` MUST 默认为 `1` + +--- + +### Requirement: IoT 卡换货状态语义扩展 + +系统 SHALL 将 `asset_status=3` 定义为"已换货",用于标记已被换出、不可继续作为当前代际在售资产的 IoT 卡。 + +#### Scenario: 换货完成后旧卡标记为已换货 +- **WHEN** H5 确认完成且旧资产为 IoT 卡 +- **THEN** 系统 MUST 将旧卡 `asset_status` 更新为 `3` + +--- + +### Requirement: IoT 卡转新重置规则 + +系统 SHALL 在 H7 转新时对 IoT 卡执行以下重置: +- `generation = generation + 1` +- `asset_status = 1`(在库) +- 清空累计充值与首充触发相关状态(含 `AccumulatedRecharge`、`FirstCommissionPaid`、系列首充/累计字段) +- 清除个人客户绑定关系 + +#### Scenario: 转新后进入新代际 +- **WHEN** 对旧卡执行转新 +- **THEN** 系统 MUST 使该卡进入新代际并以在库状态重新销售 + diff --git a/openspec/specs/iot-order/spec.md b/openspec/specs/iot-order/spec.md index 99047e0..a37fe4f 100644 --- a/openspec/specs/iot-order/spec.md +++ b/openspec/specs/iot-order/spec.md @@ -292,3 +292,21 @@ This capability supports: --- +### Requirement: 订单来源与代际字段 + +系统 SHALL 在订单(Order)实体新增来源与代际字段: +- `source varchar(20) NOT NULL DEFAULT 'admin'`,取值 `admin/client` +- `generation int NOT NULL DEFAULT 1` + +#### Scenario: 新建订单默认后台来源 +- **WHEN** 系统创建订单且未显式指定来源 +- **THEN** `source` MUST 默认为 `admin` + +#### Scenario: 客户端下单写入客户端来源 +- **WHEN** 客户端入口创建订单 +- **THEN** `source` MUST 写入为 `client` + +#### Scenario: 新建订单默认代际为 1 +- **WHEN** 系统创建订单且未显式指定代际 +- **THEN** `generation` MUST 默认为 `1` + diff --git a/openspec/specs/one-time-commission-trigger/spec.md b/openspec/specs/one-time-commission-trigger/spec.md index 3ec5c03..fd1dc58 100644 --- a/openspec/specs/one-time-commission-trigger/spec.md +++ b/openspec/specs/one-time-commission-trigger/spec.md @@ -1,5 +1,8 @@ -## ADDED Requirements +# one-time-commission-trigger Specification +## Purpose +一次性佣金触发机制 - 定义单次充值和累计充值两种触发条件、佣金发放规则、配置获取和幂等性保障。 +## Requirements ### Requirement: 一次性充值触发佣金 系统 SHALL 支持"一次性充值"触发条件:当单笔订单金额 ≥ 配置阈值时触发一次性佣金。 @@ -138,3 +141,22 @@ - **WHEN** 提供的梯度档位缺少必填字段(threshold_value、commission_mode、commission_value) - **THEN** 系统返回错误:梯度佣金档位配置无效(错误码 40105) + +### Requirement: 一次性佣金触发条件 + +系统 SHALL 在满足一次性佣金阈值规则的前提下,仅对客户端订单触发一次性佣金。 + +完整触发判断 MUST 为:`!order.IsPurchaseOnBehalf && order.Source == "client"`。 + +#### Scenario: 客户端自购订单触发 +- **WHEN** 订单满足阈值条件,且 `order.IsPurchaseOnBehalf=false`,`order.Source="client"` +- **THEN** 系统 SHALL 触发一次性佣金计算 + +#### Scenario: 代购订单不触发 +- **WHEN** 订单满足阈值条件,但 `order.IsPurchaseOnBehalf=true` +- **THEN** 系统 SHALL 不触发一次性佣金 + +#### Scenario: 后台订单不触发 +- **WHEN** 订单满足阈值条件,且 `order.Source="admin"` +- **THEN** 系统 SHALL 不触发一次性佣金 + diff --git a/openspec/specs/package-purchase-validation/spec.md b/openspec/specs/package-purchase-validation/spec.md index d5997c9..20455fe 100644 --- a/openspec/specs/package-purchase-validation/spec.md +++ b/openspec/specs/package-purchase-validation/spec.md @@ -1,5 +1,8 @@ -## ADDED Requirements +# package-purchase-validation Specification +## Purpose +套餐购买验证 - 定义客户端购买套餐前的权限、状态、价格及设备卡验证规则。 +## Requirements ### Requirement: 验证卡/设备的套餐购买权限 创建订单前系统 MUST 验证卡/设备是否有权购买指定套餐。 @@ -83,3 +86,34 @@ #### Scenario: 设备无系列关联 - **WHEN** 设备的 series_allocation_id 为空 - **THEN** 验证失败,返回 "该设备未关联套餐系列" + +### Requirement: 代理渠道购买价格规则 + +系统 MUST 根据购买渠道返回正确的购买价格:代理渠道使用 `allocation.retail_price`,平台渠道使用 `Package.SuggestedRetailPrice`。 + +#### Scenario: 代理渠道使用分配零售价 +- **WHEN** 客户通过代理渠道购买套餐 +- **THEN** 系统 MUST 使用 `allocation.retail_price` 作为支付金额 + +#### Scenario: 平台渠道使用套餐建议零售价 +- **WHEN** 客户通过平台自营渠道购买套餐 +- **THEN** 系统 MUST 使用 `Package.SuggestedRetailPrice` 作为支付金额 + +--- + +### Requirement: validatePackages 价格累加与展示校验 + +系统 MUST 在 `validatePackages()` 中按渠道来源使用一致的价格来源进行累加计算,并在代理渠道增加价格展示可见性校验。 + +#### Scenario: 代理渠道累加使用 retail_price +- **WHEN** `validatePackages()` 处理代理渠道的多套餐下单 +- **THEN** 总价累加 MUST 基于各套餐的 `allocation.retail_price` + +#### Scenario: 平台渠道累加使用 SuggestedRetailPrice +- **WHEN** `validatePackages()` 处理平台渠道的多套餐下单 +- **THEN** 总价累加 MUST 基于各套餐的 `Package.SuggestedRetailPrice` + +#### Scenario: 代理渠道过滤异常零售价 +- **WHEN** 代理渠道某套餐存在 `retail_price < cost_price` +- **THEN** 系统 MUST 不展示该套餐,且不允许该套餐进入下单校验 + diff --git a/openspec/specs/personal-customer-openid/spec.md b/openspec/specs/personal-customer-openid/spec.md new file mode 100644 index 0000000..163ad70 --- /dev/null +++ b/openspec/specs/personal-customer-openid/spec.md @@ -0,0 +1,39 @@ +# personal-customer-openid Specification + +## Purpose +TBD - created by archiving change client-auth-system. Update Purpose after archive. +## Requirements +### Requirement: PersonalCustomerOpenID 模型定义 + +系统 MUST 新增 `PersonalCustomerOpenID` 模型与数据表 `tb_personal_customer_openid`,用于保存客户在不同 AppID 下的 OpenID 记录。 + +- 关键字段: + - `id` uint,主键 + - `customer_id` uint,MUST,关联个人客户 ID + - `app_id` string,MUST,微信应用标识 + - `open_id` string,MUST,当前应用下 OpenID + - `union_id` string,可选,开放平台统一标识 + - `created_at`/`updated_at`/`deleted_at` +- 索引约束: + - MUST 存在唯一索引 `UNIQUE(app_id, open_id)`(软删条件下唯一) + +#### Scenario: 新增 OpenID 记录成功 +- **WHEN** 登录流程创建新 OpenID 关系 +- **THEN** 系统 SHALL 插入一条包含 `customer_id/app_id/open_id` 的记录 + +#### Scenario: 重复 app_id + open_id 被拒绝 +- **WHEN** 试图插入已存在的 `(app_id, open_id)` 组合 +- **THEN** 系统 MUST 触发唯一约束并拒绝写入 + +### Requirement: 与 PersonalCustomer 的关系约束 + +系统 SHALL 通过 `customer_id` 与 `PersonalCustomer` 建立逻辑关联(不使用数据库外键约束)。 + +#### Scenario: 根据 customer_id 查询 OpenID 列表 +- **WHEN** 业务根据 `customer_id` 查询 OpenID +- **THEN** 系统 SHALL 返回该客户在多 AppID 下的全部有效记录 + +#### Scenario: 软删除客户后的记录处理 +- **WHEN** 客户逻辑删除或状态失效 +- **THEN** 系统 MUST 支持按业务策略同步停用或软删除 OpenID 记录 + diff --git a/openspec/specs/personal-customer/spec.md b/openspec/specs/personal-customer/spec.md index 37ca8d8..2b4033e 100644 --- a/openspec/specs/personal-customer/spec.md +++ b/openspec/specs/personal-customer/spec.md @@ -363,3 +363,86 @@ sms: --- +### Requirement: 微信标识索引策略 + +系统 MUST 将 `tb_personal_customer.wx_open_id` 的索引从唯一索引调整为普通索引:删除 `uniqueIndex`,改为 `index`。 + +#### Scenario: 多条记录允许相同 wx_open_id +- **WHEN** 数据库中写入两条具有相同 `wx_open_id` 的个人客户记录 +- **THEN** 数据库层 MUST 不再因唯一约束报错 + +#### Scenario: 查询性能仍受索引保障 +- **WHEN** 按 `wx_open_id` 执行查询 +- **THEN** 系统 MUST 继续命中普通索引以保障查询性能 + +### Requirement: 个人客户登录主流程改为微信授权 + +系统 SHALL 将个人客户登录主流程从“手机号 + 验证码登录”调整为“资产验证 + 微信授权登录”。 + +- 新登录入口: + - `POST /api/c/v1/auth/verify-asset`(A1,无认证) + - `POST /api/c/v1/auth/wechat-login`(A2,无认证) + - `POST /api/c/v1/auth/miniapp-login`(A3,无认证) +- 请求与响应要点: + - A2/A3 请求体 MUST 包含 `code` 与 `asset_token` + - A2/A3 响应体 MUST 包含 `token`、`need_bind_phone`、`is_new_user` +- 错误码: + - `1006` 参数错误 + - `1002` token 无效或过期 + - `1040` 微信授权失败 + +#### Scenario: 通过微信授权完成登录 +- **WHEN** 用户先完成 A1,再提交 A2 或 A3 +- **THEN** 系统 SHALL 完成客户识别/创建、资产绑定并返回登录 token + +#### Scenario: 不再支持旧手机号直登入口 +- **WHEN** 客户端调用旧手机号登录路径(如 `/api/c/v1/login`) +- **THEN** 系统 MUST 按新路由规范拒绝或迁移提示,不再作为主登录路径 + +### Requirement: 手机号从“登录凭据”调整为“登录后补充资料” + +系统 MUST 将手机号能力调整为登录后绑定/换绑,而非登录入口。 + +- 相关接口: + - `POST /api/c/v1/auth/send-code`(A4,无认证) + - `POST /api/c/v1/auth/bind-phone`(A5,需认证) + - `POST /api/c/v1/auth/change-phone`(A6,需认证) +- 响应字段: + - A5/A6 MUST 返回绑定后的 `phone` + +#### Scenario: 首次登录后要求绑定手机号 +- **WHEN** `client.require_phone_binding=true` 且用户未绑定手机号 +- **THEN** 登录响应 MUST 返回 `need_bind_phone=true` +- **THEN** 用户通过 A4+A5 完成绑定后进入业务页面 + +### Requirement: 微信身份字段迁移到 OpenID 关联能力 + +系统 SHALL 保留 `PersonalCustomer.wx_open_id` 与 `wx_union_id` 字段的兼容性,但新登录链路 MUST 以 `PersonalCustomerOpenID` 为主。 + +#### Scenario: 读取用户微信身份 +- **WHEN** 登录流程需要按微信身份识别客户 +- **THEN** 系统 MUST 优先查询 `PersonalCustomerOpenID` +- **THEN** 不再依赖 `PersonalCustomer` 单字段承载多 AppID 场景 + +--- + +### Requirement: 换货迁移时更新个人客户资产绑定 + +系统 SHALL 在 H5 全量迁移成功后,更新 `PersonalCustomerDevice` 的资产标识绑定关系: +- 若旧资产存在客户绑定,绑定中的 `virtual_no` MUST 更新为新资产 `virtual_no` +- 更新后客户对资产访问连续,不需重新登录即可看到新资产 + +#### Scenario: 迁移后客户绑定跟随新资产 +- **WHEN** 旧资产存在个人客户绑定且执行了 `migrate_data=true` +- **THEN** 系统 MUST 将绑定记录的 `virtual_no` 更新为新资产虚拟号 + +--- + +### Requirement: 转新时清除个人客户绑定 + +系统 SHALL 在 H7 转新时清除该资产在 `PersonalCustomerDevice` 中的绑定关系,避免旧客户继续访问新代际资产。 + +#### Scenario: 转新后旧客户需重新绑定 +- **WHEN** 资产转新完成 +- **THEN** 系统 MUST 删除或失效对应客户绑定,使旧客户再次访问时触发重新绑定流程 + diff --git a/openspec/specs/wallet-recharge/spec.md b/openspec/specs/wallet-recharge/spec.md index f5f292b..50f8e5f 100644 --- a/openspec/specs/wallet-recharge/spec.md +++ b/openspec/specs/wallet-recharge/spec.md @@ -3,9 +3,7 @@ ## Purpose 本 capability 定义钱包充值功能,允许个人客户为卡/设备钱包充值,支持强充验证、第三方支付和充值后的累计充值更新与一次性佣金触发。 - ## Requirements - ### Requirement: 创建钱包充值订单 系统 SHALL 允许个人客户创建钱包充值订单。创建前 MUST 验证强充要求,强充场景下充值金额必须等于要求的强充金额。 @@ -186,3 +184,59 @@ #### Scenario: 充值金额过大 - **WHEN** 客户尝试充值 200000 元 - **THEN** 系统返回错误 "单次充值金额不能超过100000元" + +### Requirement: 充值回调事务一致性 + +`HandlePaymentCallback` 内的 `UpdateStatusWithOptimisticLock` 与 `UpdatePaymentInfo` MUST 使用同一个事务内 `tx` 执行,保证充值状态与支付信息的原子性。 + +#### Scenario: 回调处理中状态更新与支付信息更新同事务 +- **WHEN** 收到支付成功回调并进入 `HandlePaymentCallback` +- **THEN** 系统 MUST 在同一事务 `tx` 内执行 `UpdateStatusWithOptimisticLock` +- **THEN** 系统 MUST 在同一事务 `tx` 内执行 `UpdatePaymentInfo` + +#### Scenario: 事务失败整体回滚 +- **WHEN** 回调处理中任一步骤失败 +- **THEN** 系统 MUST 回滚该事务,保证订单状态与支付信息不出现部分成功 + +--- + +### Requirement: Store 方法签名支持事务参数 + +系统 MUST 调整充值相关 Store 方法签名,支持显式传入 `*gorm.DB tx` 参数,以保证事务边界可控。 + +#### Scenario: Service 传入事务句柄 +- **WHEN** Service 在事务上下文调用 Store 更新充值记录 +- **THEN** Store 方法 MUST 接收并使用传入的 `tx` 执行数据库操作 + +--- + +### Requirement: 充值回调采用两阶段处理 + +系统 MUST 将强充场景的充值回调改为两阶段:第一阶段同步事务内完成入账与状态更新,第二阶段异步执行自动购买。第一阶段 SHALL 包含:更新充值状态、钱包加款、累计充值更新、首充佣金判断。第二阶段 SHALL 通过 Asynq 任务执行钱包扣款、创建套餐订单、激活套餐。该改造适用于客户端触发的强充路径,且不影响非强充充值主流程。 + +#### Scenario: 强充回调同步入账成功并触发异步任务 +- **WHEN** 强充充值支付回调验签成功 +- **THEN** 系统在事务内完成钱包入账与充值单状态更新 +- **AND** 入队 `AutoPurchaseAfterRecharge` 异步任务 + +--- + +### Requirement: 充值记录新增 auto_purchase_status 状态追踪 + +系统 MUST 在 `AssetRechargeRecord` 增加 `auto_purchase_status` 字段,用于追踪强充后二阶段自动购买状态。状态集 SHALL 至少包括:`pending`、`success`、`failed`。创建强充充值单时 MUST 初始化为 `pending`;异步购买成功后 MUST 更新为 `success`;重试耗尽后 MUST 更新为 `failed`。 + +#### Scenario: 强充充值单创建时默认 pending +- **WHEN** 系统创建与套餐联动的强充充值单 +- **THEN** 充值记录 `auto_purchase_status` 初始化为 `pending` + +--- + +### Requirement: 异步自动购买失败处理规范 + +系统 SHALL 对 `AutoPurchaseAfterRecharge` 失败场景执行统一处理:任务 MUST 自动重试(最多 3 次);全部失败后 MUST 记录错误日志并将 `auto_purchase_status` 置为 `failed`;用户资金 SHALL 保留在钱包中,允许后续手动购买,不得回滚已成功的充值入账。 + +#### Scenario: 异步任务最终失败 +- **WHEN** 自动购买任务连续失败并达到最大重试次数 +- **THEN** 系统将 `auto_purchase_status` 标记为 `failed` +- **AND** 钱包余额保持可用,用户可手动下单 + diff --git a/openspec/specs/wechat-official-account/spec.md b/openspec/specs/wechat-official-account/spec.md index 9dbcace..71c059c 100644 --- a/openspec/specs/wechat-official-account/spec.md +++ b/openspec/specs/wechat-official-account/spec.md @@ -1,7 +1,8 @@ -# 微信公众号能力规格说明 - -## ADDED Requirements +# wechat-official-account Specification +## Purpose +微信公众号能力规范,定义微信 OAuth 2.0 授权登录、账号绑定、OpenID/UnionID 查询、Access Token 中控及配置管理。 +## Requirements ### Requirement: 系统必须支持微信 OAuth 2.0 授权登录 系统 SHALL 实现微信公众号 OAuth 2.0 授权流程,允许个人客户通过微信授权获取用户身份信息。 @@ -145,3 +146,48 @@ - **WHEN** 必填配置项(AppID、AppSecret)缺失 - **THEN** 系统记录 FATAL 级别日志 - **THEN** 系统启动失败并退出 + +### Requirement: 微信配置源从 YAML 改为数据库动态读取 + +系统 MUST 将公众号/小程序授权配置源从 YAML 静态配置切换为数据库 `tb_wechat_config` 动态读取(`is_active=true`)。 + +- 配置读取规则: + - 公众号登录(A2)使用 `app_id` + `app_secret` + - 小程序登录(A3)使用 `miniapp_app_id` + `miniapp_app_secret` +- 适配接口: + - `POST /api/c/v1/auth/wechat-login` + - `POST /api/c/v1/auth/miniapp-login` + +#### Scenario: 公众号登录读取数据库配置 +- **WHEN** 调用 A2 执行 OAuth code 换取 OpenID +- **THEN** 系统 SHALL 从 `tb_wechat_config` 读取当前激活公众号配置 + +#### Scenario: 小程序登录读取数据库配置 +- **WHEN** 调用 A3 执行 jscode2session +- **THEN** 系统 SHALL 从 `tb_wechat_config` 读取当前激活小程序配置 + +### Requirement: 配置缺失或无激活记录时失败 + +系统 MUST 在缺少有效数据库配置时拒绝微信登录请求,并返回统一错误。 + +- 错误码: + - `1041` 微信配置不可用 + - `1040` 微信授权失败(第三方调用失败) + +#### Scenario: 无激活配置 +- **WHEN** `tb_wechat_config` 中不存在 `is_active=true` 记录 +- **THEN** 系统 MUST 返回 `1041` + +#### Scenario: 配置存在但第三方调用失败 +- **WHEN** 已获取数据库配置但调用微信接口失败 +- **THEN** 系统 MUST 返回 `1040` + +### Requirement: 旧 YAML 配置不再作为登录凭据来源 + +系统 SHALL 停止在登录链路中使用 `wechat.official_account.*` 静态配置作为 AppID/AppSecret 来源。 + +#### Scenario: 配置切换后行为一致 +- **WHEN** 运维在数据库中更新激活配置 +- **THEN** 后续登录请求 SHALL 使用新配置生效 +- **THEN** 无需重启服务加载 YAML + diff --git a/pkg/config/config.go b/pkg/config/config.go index b45f34d..8e669f7 100644 --- a/pkg/config/config.go +++ b/pkg/config/config.go @@ -24,7 +24,6 @@ type Config struct { DefaultAdmin DefaultAdminConfig `mapstructure:"default_admin"` Storage StorageConfig `mapstructure:"storage"` Gateway GatewayConfig `mapstructure:"gateway"` - Wechat WechatConfig `mapstructure:"wechat"` } // ServerConfig HTTP 服务器配置 @@ -157,20 +156,6 @@ type PresignConfig struct { DownloadExpires time.Duration `mapstructure:"download_expires"` // 下载 URL 有效期(默认:24h) } -// WechatConfig 微信配置 -type WechatConfig struct { - OfficialAccount OfficialAccountConfig `mapstructure:"official_account"` -} - -// OfficialAccountConfig 微信公众号配置 -type OfficialAccountConfig struct { - AppID string `mapstructure:"app_id"` - AppSecret string `mapstructure:"app_secret"` - Token string `mapstructure:"token"` - AESKey string `mapstructure:"aes_key"` - OAuthRedirectURL string `mapstructure:"oauth_redirect_url"` -} - type requiredField struct { value string name string diff --git a/pkg/config/defaults/config.yaml b/pkg/config/defaults/config.yaml index 3f0cf8c..3eac6dd 100644 --- a/pkg/config/defaults/config.yaml +++ b/pkg/config/defaults/config.yaml @@ -116,12 +116,3 @@ gateway: app_secret: "BZeQttaZQt0i73moF" timeout: 30 -# 微信配置(必填项需通过环境变量设置) -wechat: - official_account: - app_id: "" # 必填:JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID - app_secret: "" # 必填:JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET(敏感) - token: "" # 可选:JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN - aes_key: "" # 可选:JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY(敏感) - oauth_redirect_url: "" # 可选:JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL - diff --git a/pkg/wechat/config.go b/pkg/wechat/config.go index 3de5090..6107395 100644 --- a/pkg/wechat/config.go +++ b/pkg/wechat/config.go @@ -8,7 +8,6 @@ import ( "github.com/ArtisanCloud/PowerWeChat/v3/src/officialAccount" "github.com/ArtisanCloud/PowerWeChat/v3/src/payment" "github.com/break/junhong_cmp_fiber/internal/model" - "github.com/break/junhong_cmp_fiber/pkg/config" "github.com/redis/go-redis/v9" "go.uber.org/zap" ) @@ -22,37 +21,6 @@ func NewRedisCache(rdb *redis.Client) kernel.CacheInterface { }) } -// NewOfficialAccountApp 创建微信公众号应用实例 -func NewOfficialAccountApp(cfg *config.Config, cache kernel.CacheInterface, logger *zap.Logger) (*officialAccount.OfficialAccount, error) { - oaCfg := cfg.Wechat.OfficialAccount - if oaCfg.AppID == "" || oaCfg.AppSecret == "" { - return nil, fmt.Errorf("微信公众号配置不完整:缺少 AppID 或 AppSecret") - } - - userConfig := &officialAccount.UserConfig{ - AppID: oaCfg.AppID, - Secret: oaCfg.AppSecret, - Cache: cache, - } - - // 可选配置:消息验证 Token 和 AESKey - if oaCfg.Token != "" { - userConfig.Token = oaCfg.Token - } - if oaCfg.AESKey != "" { - userConfig.AESKey = oaCfg.AESKey - } - - app, err := officialAccount.NewOfficialAccount(userConfig) - if err != nil { - logger.Error("创建微信公众号应用失败", zap.Error(err)) - return nil, fmt.Errorf("创建微信公众号应用失败: %w", err) - } - - logger.Info("微信公众号应用初始化成功", zap.String("app_id", oaCfg.AppID)) - return app, nil -} - // NewOfficialAccountAppFromConfig 从数据库配置创建微信公众号应用实例 func NewOfficialAccountAppFromConfig(wechatConfig *model.WechatConfig, cache kernel.CacheInterface, logger *zap.Logger) (*officialAccount.OfficialAccount, error) { if wechatConfig == nil {