From 9bd55a169531565f95c289637fbe89ea7365bd1e Mon Sep 17 00:00:00 2001 From: huang Date: Thu, 19 Mar 2026 13:28:04 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E6=A0=B8=E5=BF=83=E4=B8=9A=E5=8A=A1=E6=8E=A5=E5=8F=A3?= =?UTF-8?q?=EF=BC=88client-core-business-api=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增客户端资产、钱包、订单、实名、设备管理等核心业务 Handler 与 DTO: - 客户端资产信息查询、套餐列表、套餐历史、资产刷新 - 客户端钱包详情、流水、充值校验、充值订单、充值记录 - 客户端订单创建、列表、详情 - 客户端实名认证链接获取 - 客户端设备卡列表、重启、恢复出厂、WiFi配置、切卡 - 客户端订单服务(含微信/支付宝支付流程) - 强充自动代购异步任务处理 - 数据库迁移 000084:充值记录增加自动代购状态字段 --- .claude/settings.json | 2 +- .config/dbhub.toml | 13 + docs/client-api-requirements/需求说明.md | 1214 +++++++++++++++++ docs/client-core-business-api/功能总结.md | 122 ++ internal/handler/app/client_asset.go | 556 ++++++++ internal/handler/app/client_device.go | 317 +++++ internal/handler/app/client_order.go | 415 ++++++ internal/handler/app/client_realname.go | 249 ++++ internal/handler/app/client_wallet.go | 660 +++++++++ internal/model/dto/client_asset_dto.go | 86 ++ internal/model/dto/client_order_dto.go | 113 ++ .../model/dto/client_realname_device_dto.go | 104 ++ internal/model/dto/client_wallet_dto.go | 138 ++ internal/service/client_order/service.go | 701 ++++++++++ internal/task/auto_purchase.go | 556 ++++++++ ...e_status_to_asset_recharge_record.down.sql | 3 + ...ase_status_to_asset_recharge_record.up.sql | 5 + opencode.json | 20 +- 18 files changed, 5260 insertions(+), 14 deletions(-) create mode 100644 .config/dbhub.toml create mode 100644 docs/client-api-requirements/需求说明.md create mode 100644 docs/client-core-business-api/功能总结.md create mode 100644 internal/handler/app/client_asset.go create mode 100644 internal/handler/app/client_device.go create mode 100644 internal/handler/app/client_order.go create mode 100644 internal/handler/app/client_realname.go create mode 100644 internal/handler/app/client_wallet.go create mode 100644 internal/model/dto/client_asset_dto.go create mode 100644 internal/model/dto/client_order_dto.go create mode 100644 internal/model/dto/client_realname_device_dto.go create mode 100644 internal/model/dto/client_wallet_dto.go create mode 100644 internal/service/client_order/service.go create mode 100644 internal/task/auto_purchase.go create mode 100644 migrations/000084_add_auto_purchase_status_to_asset_recharge_record.down.sql create mode 100644 migrations/000084_add_auto_purchase_status_to_asset_recharge_record.up.sql diff --git a/.claude/settings.json b/.claude/settings.json index 7725b8a..a38dfa1 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -1,5 +1,5 @@ { "enabledPlugins": { "ralph-loop@claude-plugins-official": true - } + } } diff --git a/.config/dbhub.toml b/.config/dbhub.toml new file mode 100644 index 0000000..5cf957b --- /dev/null +++ b/.config/dbhub.toml @@ -0,0 +1,13 @@ +[[sources]] +id = "main" +dsn = "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable" + +[[tools]] +name = "search_objects" +source = "main" + +[[tools]] +name = "execute_sql" +source = "main" +readonly = true # Only allow SELECT, SHOW, DESCRIBE, EXPLAIN +max_rows = 1000 # Limit query results diff --git a/docs/client-api-requirements/需求说明.md b/docs/client-api-requirements/需求说明.md new file mode 100644 index 0000000..ebf8187 --- /dev/null +++ b/docs/client-api-requirements/需求说明.md @@ -0,0 +1,1214 @@ +# 客户端(H5/小程序)接口需求完整说明 + +> **文档目的**: 本文档是客户端接口开发的**唯一权威参考**。新的 AI session 或开发者应基于此文档进行实现,无需重新探索需求。 + +--- + +## 〇、原始需求(用户原话摘要) + +### 背景 +系统之前由于疏忽在提案中做了几个 H5 接口(`/api/h5`),这些接口使用的是 B 端认证体系,不适用于个人客户场景。现在需要**删除这些旧接口**,重新设计一套面向个人客户(C 端)的完整客户端接口体系。 + +### 客户端形态 +- 微信公众号 H5 +- 微信小程序 +- 两者可能使用不同的 AppID,不一定绑定同一个微信开放平台 + +### 登录流程 +用户有两种路径进入客户端: +1. **扫码进入**:通过微信扫一扫扫描特定二维码,带参数跳转进前端(扫码是前端行为,后端无感) +2. **手动输入**:直接打开客户端,输入 SN/IMEI/虚拟号/ICCID 等资产标识符 + +登录是一种"假登录",**基于资产**而非基于用户身份。任何人拿到资产标识符都可以登录,即使该资产已被别人绑定过。 + +**登录流程顺序**:先输入资产标识符 → 再走微信授权 → 检查是否需要绑定资产 → 检查是否需要绑定手机号(是否强制绑定手机号在代码中可配置) + +### 功能需求清单 + +1. **登录注册**:资产标识符输入 → 微信授权(公众号/小程序)→ 资产绑定 → 手机号绑定(可配置是否强制) +2. **资产信息展示**:基本信息、套餐信息、流量信息(支持卡资产和设备资产两种类型) +3. **可购买套餐列表**:按价格排序。代理渠道展示代理设定的零售价,平台渠道展示建议零售价。平台禁用的套餐全渠道不展示,代理下架仅影响该代理 +4. **钱包详情 + 流水列表** +5. **充值接口**:用户输入金额 → 微信支付充值 +6. **充值订单列表** +7. **套餐购买**:拉起微信支付。购买前必须实名。有强充逻辑(不应让前端决定创建充值还是套餐订单,后端统一处理)。涉及一次性佣金的首充/累计充值/梯度模式 +8. **套餐订单列表 + 详情** +9. **历史套餐列表** +10. **实名跳转**:两个入口——购买套餐被拦截后引导、设备卡列表主动选择。运营商实名链接两种方式:模板 URL 和 Gateway 接口 +11. **手动刷新卡状态/实名状态** +12. **设备卡列表**:含卡状态、实名状态、运营商名称 +13. **设备能力**:重启/恢复出厂/设置 WiFi/切卡 +14. **换货系统**:后台发起换货 → 客户端收到通知填写收货信息 → 后台发货+全量迁移 → 手动确认完成。旧资产可"转新"重新销售 +15. **换绑手机号** + +### 关键设计决策 + +- 所有接口挂载在 `/api/c/v1` 下,使用 PersonalCustomer JWT 认证 +- JWT 只含 `customer_id`,资产信息由客户端每次请求传递 +- **所有涉及资产的接口统一使用标识符(identifier)入参**,而非内部 ID(方案 A) +- 换货不是简单换卡,是全量迁移(钱包、套餐、累计充值状态、标签等),旧资产数据保留但标记为"已换货" +- 后台代理自购不应触发一次性佣金(只有个人客户充值才触发) +- 代理需要能设置自己的零售价(现有 ShopPackageAllocation 缺少 retail_price) + +--- + +## 一、现有系统需要修改/修复的问题 + +### BUG-1:代理零售价缺失 + +**现状**:`ShopPackageAllocation` 只有 `cost_price`(上级给的成本价),没有代理面向终端客户的零售价。`GetPurchasePrice()` 始终返回 `Package.SuggestedRetailPrice`。 + +**修复**:`tb_shop_package_allocation` 新增 `retail_price` 字段。 + +``` +retail_price (bigint, NOT NULL, DEFAULT 0) +说明: 代理面向终端客户的实际零售价(分) +默认值: 分配时自动设为 Package.SuggestedRetailPrice +约束: retail_price >= cost_price AND retail_price <= suggested_retail_price * 2 +``` + +**成本价锁定规则**:当套餐存在下级分配记录(`ShopPackageAllocation`)时,上级**禁止修改该套餐的 `cost_price`**。必须先回收所有下级分配后才能调整成本价。这避免了 cost_price 调高后下级 retail_price 违反约束导致亏损售卖的问题。 + +**影响文件**: +- `internal/model/shop_package_allocation.go` — 加字段 +- `internal/service/purchase_validation/service.go` → `GetPurchasePrice()` 改为:代理渠道查 `allocation.retail_price`,平台渠道用 `Package.SuggestedRetailPrice` +- `internal/service/shop_package_batch_allocation/service.go` — 创建分配时设置 `retail_price` 默认值;**修改 cost_price 前检查是否存在下级分配,存在则拒绝修改** +- 代理套餐管理接口 — 支持修改 `retail_price` +- 需要一个数据库迁移:新增字段 + +**补充说明**: `validatePackages()` 第 148 行的 `totalPrice += pkg.SuggestedRetailPrice` 也需要修复。代理渠道应查 `allocation.retail_price` 计算订单总价,而非始终使用建议零售价。修复范围不仅限于 `GetPurchasePrice()`,还包括 `validatePackages()` 内部的价格累加逻辑。 + +### BUG-2:后台代理自购触发一次性佣金 + +**现状**:`CreateAdminOrder` 中代理自购(`isPurchaseOnBehalf=false`)会调用 `enqueueCommissionCalculation`,而佣金计算中 `!order.IsPurchaseOnBehalf` 为 true 时会触发一次性佣金。 + +**修复**:后台所有订单都不应触发一次性佣金。一次性佣金只能由个人客户的**充值操作**触发。 + +**方案**:给 Order 模型新增 `source` 字段标识订单来源(`admin`/`client`)。修改 `commission_calculation/service.go` 的一次性佣金触发条件为**完整的双重判断**:`!order.IsPurchaseOnBehalf && order.Source == "client"`——只有客户端来源且非代购的订单才触发一次性佣金。 + +**完整判断矩阵**: +| 场景 | IsPurchaseOnBehalf | Source | 触发一次性佣金? | +|------|-------------------|--------|----------------| +| 后台代理自购 | false | admin | ❌ source != client | +| 后台代理代购 | true | admin | ❌ IsPurchaseOnBehalf | +| 后台平台代购 | true | admin | ❌ 两条都拦截 | +| 客户端个人客户购买 | false | client | ✅ 唯一触发路径 | + +**影响文件**: +- `internal/model/order.go` — 新增 `Source` 字段 +- `internal/service/commission_calculation/service.go` — 将 `!order.IsPurchaseOnBehalf` 改为 `!order.IsPurchaseOnBehalf && order.Source == "client"` +- `internal/service/order/service.go` — `CreateAdminOrder` 设置 `Source: "admin"`,客户端订单创建设置 `Source: "client"` + +### BUG-4:充值回调事务一致性问题 + +**现状**:`recharge/service.go:308-321` 的 `HandlePaymentCallback` 中,`UpdateStatusWithOptimisticLock` 和 `UpdatePaymentInfo` 使用 `s.db.WithContext(ctx)` 而非事务内的 `tx`。导致可能出现「充值单已标记为已支付,但钱包余额未增加」的半提交。 + +**修复**:将状态更新和支付信息更新改为使用事务内的 `tx`,确保充值单状态变更和钱包入账在同一事务内原子完成。 + +**影响文件**: +- `internal/service/recharge/service.go` — HandlePaymentCallback 事务内统一使用 tx +- `internal/store/postgres/asset_recharge_store.go` — UpdateStatusWithOptimisticLock / UpdatePaymentInfo 支持传入 tx + +### BUG-3:换卡模型不满足换货需求 + +**现状**:`CardReplacementRecord` 仅支持卡换卡,缺少收货地址、快递信息、设备换货、全量迁移等功能。 + +**修复**:新建 `ExchangeOrder` 模型替代,删除旧模型。 + +**当前引用旧模型的文件**(仅 3 处,替换成本低): +- `internal/model/card_replacement.go` — 删除 +- `internal/model/system.go` — 移除 AutoMigrate +- `internal/store/postgres/iot_card_store.go` — `is_replaced` 过滤改为查新表 + +--- + +## 二、数据库变更 + +### 2.1 新增模型 + +#### 系统配置 — 使用配置文件(非数据库表) + +客户端行为开关(如 `require_phone_binding`)通过 Viper 配置文件管理,不单独建表。 + +**配置项**(在 `config.yaml` 中): + +```yaml +client: + require_phone_binding: true # 客户端是否强制绑定手机号 +``` + +**设计原因**:当前仅一个配置项,使用配置文件足够。后续若需要多个动态配置项(如需运行时热更新),再考虑引入数据库 KV 表。 + +#### tb_personal_customer_openid — 个人客户 OpenID 关联表 + +```go +type PersonalCustomerOpenID struct { + gorm.Model + CustomerID uint `gorm:"column:customer_id;not null;index;comment:关联个人客户ID"` + AppType string `gorm:"column:app_type;type:varchar(20);not null;comment:应用类型 official_account-公众号 miniapp-小程序"` + AppID string `gorm:"column:app_id;type:varchar(100);not null;comment:微信AppID"` + OpenID string `gorm:"column:open_id;type:varchar(100);not null;comment:该AppID下的OpenID"` + UnionID string `gorm:"column:union_id;type:varchar(100);comment:UnionID(有开放平台时才有)"` + // 唯一索引: UNIQUE(app_id, open_id) WHERE deleted_at IS NULL +} +func (PersonalCustomerOpenID) TableName() string { return "tb_personal_customer_openid" } +``` + +**设计原因**:公众号和小程序可能使用不同 AppID 且不一定绑定同一微信开放平台。同一用户在不同 AppID 下有不同 OpenID。原 `PersonalCustomer.wx_open_id` 保留兼容,逻辑主体迁移到新表。 + +#### tb_exchange_order — 换货单模型 + +```go +type ExchangeOrder struct { + gorm.Model + BaseModel `gorm:"embedded"` + + ExchangeNo string `gorm:"column:exchange_no;type:varchar(50);uniqueIndex:...,where:deleted_at IS NULL;not null;comment:换货单号"` + + // 旧资产 + OldAssetType string `gorm:"column:old_asset_type;type:varchar(20);not null;comment:旧资产类型 iot_card/device"` + OldAssetID uint `gorm:"column:old_asset_id;not null;index;comment:旧资产ID"` + OldIdentifier string `gorm:"column:old_identifier;type:varchar(100);not null;comment:旧标识符"` + + // 新资产(发货时填写) + NewAssetType string `gorm:"column:new_asset_type;type:varchar(20);comment:新资产类型"` + NewAssetID *uint `gorm:"column:new_asset_id;index;comment:新资产ID"` + NewIdentifier string `gorm:"column:new_identifier;type:varchar(100);comment:新标识符"` + + // 收货信息(客户端填写) + RecipientName string `gorm:"column:recipient_name;type:varchar(50);comment:收货人姓名"` + RecipientPhone string `gorm:"column:recipient_phone;type:varchar(20);comment:收货人手机号"` + RecipientAddress string `gorm:"column:recipient_address;type:text;comment:收货地址"` + + // 物流(后台发货时填写) + ExpressCompany string `gorm:"column:express_company;type:varchar(100);comment:快递公司"` + ExpressNo string `gorm:"column:express_no;type:varchar(100);comment:快递单号"` + + // 状态: 1-待填写信息 2-待发货 3-已发货待确认 4-已完成 5-已取消 + Status int `gorm:"column:status;type:int;not null;default:1;comment:状态"` + ExchangeReason string `gorm:"column:exchange_reason;type:varchar(200);comment:换货原因"` + Remark *string `gorm:"column:remark;type:text;comment:备注"` + + // 迁移 + MigrateData bool `gorm:"column:migrate_data;type:boolean;default:false;comment:是否迁移数据"` + MigrationCompleted bool `gorm:"column:migration_completed;type:boolean;default:false;comment:是否完成迁移"` + MigrationBalance int64 `gorm:"column:migration_balance;type:bigint;default:0;comment:迁移余额(分)"` + + // 时间 + ShippedAt *time.Time `gorm:"column:shipped_at;comment:发货时间"` + CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间"` + InitiatorID uint `gorm:"column:initiator_id;not null;comment:发起人ID"` + ShopID *uint `gorm:"column:shop_id;index;comment:多租户过滤"` +} +func (ExchangeOrder) TableName() string { return "tb_exchange_order" } +``` + +**状态机**:`1-待填写信息 → 2-待发货 → 3-已发货待确认 → 4-已完成`,status=1 或 2 时可取消(→5),已发货(status=3)后不可取消 + +### 2.2 修改现有模型 + +| 模型 | 字段变更 | 说明 | +|------|---------|------| +| `ShopPackageAllocation` | +`retail_price bigint NOT NULL DEFAULT 0` | 代理零售价 | +| `Carrier` | +`realname_link_type varchar(20) NOT NULL DEFAULT 'none'` | 实名链接类型: none/template/gateway | +| `Carrier` | +`realname_link_template varchar(500) DEFAULT ''` | 模板 URL,支持 `{iccid}` `{msisdn}` `{virtual_no}` 占位符 | +| `AssetRechargeRecord` | +`linked_package_ids jsonb DEFAULT '[]'` | 强充关联套餐ID列表 | +| `AssetRechargeRecord` | +`linked_order_type varchar(20)` | 关联订单类型 | +| `AssetRechargeRecord` | +`linked_carrier_type varchar(20)` | 关联载体类型 | +| `AssetRechargeRecord` | +`linked_carrier_id bigint` | 关联载体ID | +| `AssetRechargeRecord` | +`operator_type varchar(20) NOT NULL DEFAULT 'admin_user'` | 操作人类型: admin_user-后台用户 personal_customer-个人客户。与 user_id 配合区分不同 ID 体系 | +| `AssetRechargeRecord` | +`generation int NOT NULL DEFAULT 1` | 资产世代编号,创建时从资产当前 generation 复制 | +| `Order` | +`source varchar(20) NOT NULL DEFAULT 'admin'` | 订单来源: admin-后台 client-客户端。一次性佣金仅 client 来源且非代购时触发 | +| `Order` | +`generation int NOT NULL DEFAULT 1` | 资产世代编号,创建时从资产当前 generation 复制 | +| `PackageUsage` | +`generation int NOT NULL DEFAULT 1` | 资产世代编号,创建时从资产当前 generation 复制 | +| `IotCard` | +`generation int NOT NULL DEFAULT 1` | 资产世代编号,转新时 +1,客户端按当前 generation 过滤历史数据 | +| `IotCard` | +`asset_status int NOT NULL DEFAULT 1` | 业务生命周期状态: 1-在库 2-已销售 3-已换货 4-已停用。与 network_status(运营商网络状态)独立 | +| `Device` | +`generation int NOT NULL DEFAULT 1` | 同上 | +| `Device` | +`asset_status int NOT NULL DEFAULT 1` | 同上 | +| `PersonalCustomer` | 删除 `wx_open_id` 的唯一索引,改为普通索引 | 支持多 OpenID 方案后,唯一约束迁移到 PersonalCustomerOpenID 表 | + +### 2.3 删除模型 + +| 模型 | 处理 | +|------|------| +| `CardReplacementRecord` | 表改名 `tb_card_replacement_record_legacy`,代码引用全部替换为 `ExchangeOrder` | + +--- + +## 三、统一设计原则 + +### 3.1 标识符统一入参 + +**所有涉及资产的客户端接口**使用 `identifier` 参数而非 `asset_id`。 + +**已有可复用方法**: +- `asset.Service.Resolve(ctx, identifier)` — 位于 `internal/service/asset/service.go` 第 71 行 +- 先查 Device(virtual_no/imei/sn),再查 IotCard(virtual_no/iccid/msisdn) +- 返回 `*dto.AssetResolveResponse`(定义在 `internal/model/dto/asset_dto.go`) + +**客户端 Handler 需提取的公共方法**(放在 `internal/handler/app/` 或 `pkg/` 中): + +```go +// resolveAssetFromIdentifier 统一资产解析 +// 注意: 此方法不能添加数据权限过滤(个人客户不受 shop_id 限制) +func resolveAssetFromIdentifier(ctx context.Context, assetService *asset.Service, identifier string) (assetType string, assetID uint, err error) +``` + +### 3.2 认证方式 + +- 路由域:`/api/c/v1` +- 中间件:`PersonalAuthMiddleware`(JWT) +- JWT Payload:`{ customer_id, exp }` +- 现有实现:`internal/middleware/personal_auth.go`,存储 `c.Locals("customer_id")` + +### 3.3 响应格式 + +统一 `pkg/response/`:`{ code, msg, data, timestamp }` + +### 3.4 个人客户认证体系 + +- JWT Payload: `{ customer_id, exp }` +- **有状态管理**: Redis 存储 token 状态,支持服务端主动失效(如封禁用户、强制下线) +- Token 过期时间: 可配置(建议 7 天) +- 退出登录时清除 Redis 中的 token 记录 +- 认证中间件: `PersonalAuthMiddleware` 需增加 Redis 有效性检查 +- 是否强制绑定手机号: 通过 Viper 配置文件 `client.require_phone_binding` 读取(非数据库表) + +### 3.5 openid 安全规范 + +**所有需要微信 OpenID 的接口(支付、充值等),OpenID 一律由后端查询获取,禁止客户端传入。** + +- 客户端传 `app_type`(`official_account` 或 `miniapp`)标识当前使用的应用类型 +- 后端根据 `customer_id` + `app_type` 从 `PersonalCustomerOpenID` 表查询对应的 OpenID +- 防止客户端伪造 OpenID 拉起他人支付 + +### 3.6 资产世代(generation)机制 + +用于换货转新场景的数据隔离: + +- `IotCard` 和 `Device` 模型均有 `generation` 字段(默认 1) +- 换货完成后旧资产执行"转新"操作时,`generation` 自增 1 +- **客户端查询规则**: 订单、充值记录、套餐历史等仅展示**当前 generation** 的数据 +- **后台查询规则**: 不受 generation 过滤,可查看全部历史数据 + +#### 实现方案:关联表冗余 generation 字段 + +在创建记录时,从资产的当前 generation 复制到关联记录上(写时快照): + +```go +// 创建订单时 +order.Generation = card.Generation + +// 创建充值记录时 +rechargeRecord.Generation = card.Generation + +// 创建套餐使用记录时 +packageUsage.Generation = card.Generation +``` + +客户端查询时按 generation 过滤: +```sql +-- 示例: 查询当前世代的订单 +SELECT * FROM tb_order +WHERE iot_card_id = ? AND generation = ? +ORDER BY created_at DESC +``` + +#### 需要 generation 字段的关联表 + +| 表 | 过滤方式 | 说明 | +|----|---------|------| +| `tb_order` | `generation` 字段过滤 | 创建订单时快照资产 generation | +| `tb_asset_recharge_record` | `generation` 字段过滤 | 创建充值记录时快照资产 generation | +| `tb_package_usage` | `generation` 字段过滤 | 创建套餐使用记录时快照资产 generation | +| `tb_asset_wallet_transaction` | **通过 wallet_id 隔离** | 转新时创建新钱包,流水天然属于新钱包,无需 generation 字段 | + +#### 需要 generation 过滤的客户端查询 + +- C5 充值订单列表 → `WHERE resource_id=? AND generation=?` +- D2 套餐订单列表 → `WHERE iot_card_id=? AND generation=?` +- B3 历史套餐列表 → `WHERE iot_card_id=? AND generation=?` +- C2 钱包流水列表 → **通过 wallet_id 隔离**(转新后新钱包有新 wallet_id) + +#### 不需要 generation 过滤的查询 + +- B1 资产基本信息(实时状态) +- C1 钱包详情(转新时余额已迁移到新钱包) +- B2 可购买套餐列表(实时可购买) + +### 3.7 资产业务状态(asset_status) + +`IotCard` 和 `Device` 新增 `asset_status` 字段,表示 CMP 内部业务生命周期,与运营商 `network_status`(网络状态: 开机/停机)完全独立。 + +``` +asset_status int NOT NULL DEFAULT 1 + 1 = 在库(可销售、可分配) + 2 = 已销售(正常使用中,已绑定客户或代理) + 3 = 已换货(换货完成,数据保留,不可操作) + 4 = 已停用(手动停用) +``` + +**状态流转**: +- 导入 → 1(在库) +- 首次绑定客户/分配代理 → 2(已销售) +- 换货完成 → 3(已换货) +- 转新 → 1(在库),同时 generation +1 +- 手动停用 → 4(已停用) + +**与 network_status 的关系**: +- `network_status` 由 Gateway 同步,反映运营商侧真实网络状态 +- `asset_status` 由 CMP 业务操作控制,反映内部生命周期 +- 两者互不干扰。例如:转新后 `asset_status=1`(在库),但 `network_status` 仍由运营商决定 + +--- + +## 四、接口详细规格 + +### 4.1 模块 A:认证与登录 + +#### A1: 验证资产标识符 + +``` +POST /api/c/v1/auth/verify-asset +认证: 不需要 +限频: 同一 IP 每分钟最多 30 次 + +请求体: +{ + "identifier": "string" // 必填。SN/IMEI/虚拟号/ICCID/MSISDN +} + +响应体: +{ + "asset_token": "eyJhbGciOi...", // 短时效令牌(5分钟过期),替代明文 asset_id + "asset_type": "card", // card 或 device + "carrier_name": "中国联通", // 卡时有效 + "device_name": "GPS追踪器" // 设备时有效 +} + +实现逻辑: +1. 调用 asset.Service.Resolve(ctx, identifier) +2. 注意: 不检查归属/权限,任何人输入任何标识符都可以查到 +3. 资产不存在返回 404 +4. 只返回最小必要信息(asset_type, 名称),不暴露内部 asset_id 和详细信息 +5. 生成短时效 asset_token(JWT,payload: { asset_type, asset_id, exp: 5min }),供后续 A2/A3 登录使用 +6. IP 级别限频防止暴力枚举 + +安全说明: 不直接返回 asset_id(数据库内部 ID),避免无认证接口暴露内部标识。 +A2/A3 登录接口使用 asset_token 验证,而非客户端直传 asset_type + asset_id。 + +参考: internal/service/asset/service.go:71 的 Resolve 方法 +``` + +#### A2: 微信公众号登录 + +``` +POST /api/c/v1/auth/wechat-login +认证: 不需要 + +请求体: +{ + "code": "string", // 必填。微信 OAuth 授权码(scope=snsapi_userinfo 以获取用户信息) + "asset_token": "string" // 必填。A1 返回的短时效令牌 +} + +响应体: +{ + "token": "jwt_token_string", + "expires_in": 86400, + "customer": { + "id": 1, + "nickname": "微信昵称", + "avatar_url": "https://...", + "phone": "138****0001", // 有绑定则返回脱敏手机号,无则为空 + "status": 1 + }, + "need_bind_phone": true, // 是否需要绑定手机号 + "is_new_user": false // 是否新创建的用户 +} + +实现逻辑: +1. 解析并验证 asset_token(校验签名、过期时间),提取 asset_type + asset_id +2. 从当前生效的 WechatConfig(tb_wechat_config WHERE is_active=true)获取公众号 AppID/AppSecret +3. 用 code 调用微信 OAuth 接口获取 openid + (可选)unionid + userinfo + 注意: 需使用 scope=snsapi_userinfo 的授权码才能获取 userinfo +4. 查找个人客户: + a. 查 tb_personal_customer_openid WHERE app_id=公众号AppID AND open_id=openid → 找到则获取客户 + b. 没找到,有 unionid → 查 WHERE union_id=unionid → 找到则合并(新增 openid 记录) + c. 都没找到 → 创建新 PersonalCustomer + openid 记录 +5. 创建 PersonalCustomerDevice (customer_id, virtual_no, bind_at=now) + 注意: 允许多人绑定同一资产(不覆盖旧绑定),一个资产可被多个客户绑定。 + 订单跟着资产走,同时记录 customer_id 便于后续改版区分归属。 +6. 检查手机号绑定: 读 Viper 配置 client.require_phone_binding,查 PersonalCustomerPhone +7. 签发 JWT Token (payload: customer_id),同时写入 Redis(有状态 Token) + +参考: +- 现有微信 OAuth: internal/service/personal_customer/service.go WechatOAuthLogin +- 现有 WechatOAuthRequest/Response: internal/model/dto/wechat_dto.go +- PersonalAuthMiddleware: internal/middleware/personal_auth.go +``` + +#### A3: 微信小程序登录 + +``` +POST /api/c/v1/auth/miniapp-login +认证: 不需要 + +请求体: +{ + "code": "string", // 必填。小程序 login 获取的 code + "asset_token": "string", // 必填。A1 返回的短时效令牌 + "nickname": "string", // 可选。前端授权后传入的用户昵称 + "avatar_url": "string" // 可选。前端授权后传入的用户头像 +} + +响应体: 同 A2 + +实现逻辑: +1. 解析并验证 asset_token,提取 asset_type + asset_id +2. 从 WechatConfig 获取小程序 MiniappAppID/MiniappAppSecret +3. 调用微信 jscode2session 接口获取 openid + (可选)unionid + 注意: 小程序不能直接获取 userinfo,昵称/头像由前端授权后通过请求体传入 +4. 后续逻辑同 A2 步骤 4-7(如传入了 nickname/avatar_url 则更新客户资料) +``` + +#### A4: 发送验证码 + +``` +POST /api/c/v1/auth/send-code +认证: 不需要 + +请求体: +{ + "phone": "13800138000" // 必填,11位手机号 +} + +响应体: +{ "message": "验证码已发送" } + +实现逻辑: +1. 验证手机号格式(11位数字) +2. Redis 限频: 同一手机号 60 秒内不可重发 +3. 生成 6 位随机验证码,Redis 存储 5 分钟 (KEY: sms:code:{phone}) +4. 调用短信发送服务 +5. IP 级别限频: 同一 IP 每小时最多发送 20 次验证码(防止批量刷不同手机号) +6. 每个手机号每日最多发送 10 次 + +参考: 现有实现 internal/handler/app/personal_customer.go:35 SendCode +``` + +#### A5: 绑定手机号 + +``` +POST /api/c/v1/auth/bind-phone +认证: 需要 JWT + +请求体: +{ + "phone": "13800138000", + "code": "123456" +} + +响应体: +{ "message": "绑定成功" } + +实现逻辑: +1. 从 JWT 获取 customer_id +2. 验证码校验(Redis 中查找比对) +3. 检查该客户是否已绑定主手机号 → 已绑定则报错"已绑定手机号,如需更换请使用换绑功能" +4. 检查该手机号是否已被其他客户绑定为主手机号 → 已被绑定则报错"该手机号已被其他用户绑定" +5. 创建 PersonalCustomerPhone (customer_id, phone, is_primary=true, verified_at=now) +``` + +#### A6: 换绑手机号 + +``` +POST /api/c/v1/auth/change-phone +认证: 需要 JWT + +请求体: +{ + "old_phone": "13800138000", + "old_code": "123456", // 旧手机验证码 + "new_phone": "13900139000", + "new_code": "654321" // 新手机验证码 +} + +响应体: +{ "message": "换绑成功" } + +实现逻辑: +1. 验证旧手机号: 查 PersonalCustomerPhone WHERE customer_id=? AND phone=old_phone AND is_primary=true +2. 验证旧手机验证码 +3. 验证 new_phone != old_phone +4. 验证新手机验证码 +5. 检查新手机号未被其他客户绑定 +6. 旧记录 is_primary → false,新增记录 phone=new_phone, is_primary=true +``` + +#### A7: 退出登录 + +``` +POST /api/c/v1/auth/logout +认证: 需要 JWT + +响应体: +{ "message": "已退出登录" } + +实现逻辑: +1. 从 JWT 获取 token +2. 删除 Redis 中该 token 的记录(使 token 立即失效) +3. 返回成功(即使 token 在 Redis 中不存在也返回成功,保持幂等) +``` + +### 4.2 模块 B:资产信息 + +#### B1: 资产基本信息 + +``` +GET /api/c/v1/asset/info?identifier=xxx +认证: 需要 JWT + +查询参数: + identifier: string // 必填。资产标识符 + +响应体: 复用 dto.AssetResolveResponse(见 internal/model/dto/asset_dto.go) +包含: 虚拟号、状态、套餐信息、流量信息、实名状态、绑定关系等全部字段 + +实现逻辑: +1. resolveAssetFromIdentifier → asset_type + asset_id +2. 调用 asset.Service.Resolve(ctx, identifier) +3. 注意: 个人客户调用不走 shop_id 数据权限过滤 + +参考: internal/handler/admin/asset.go Resolve 方法, internal/service/asset/service.go +``` + +#### B2: 可购买套餐列表 + +``` +GET /api/c/v1/asset/packages?identifier=xxx +认证: 需要 JWT + +查询参数: + identifier: string // 必填 + +响应体: +{ + "list": [ + { + "package_id": 1, + "package_code": "PKG001", + "package_name": "月套餐30G", + "package_type": "formal", // formal-正式套餐 addon-加油包 + "duration_months": 1, + "real_data_mb": 30720, + "virtual_data_mb": 24576, // 虚流量(如果启用) + "enable_virtual_data": true, + "price": 3000, // 售价(分)— 代理渠道=allocation.retail_price,平台=SuggestedRetailPrice + "calendar_type": "natural_month", + "data_reset_cycle": "monthly", + "enable_realname_activation": true // 是否需要实名才能买 + } + ] +} + +实现逻辑: +1. 解析标识符 → 获取 card/device +2. 检查 series_id 不为空 +3. 确定卖家渠道: card.shop_id > 0 → 代理渠道,否则平台渠道 +4. 查询该 series 下所有套餐 +5. 过滤: + - Package.status == 1(启用)← 全局开关 + - 代理渠道: 查 ShopPackageAllocation.shelf_status == 1 + - 平台渠道: 查 Package.shelf_status == 1 +6. 价格: + - 代理渠道: ShopPackageAllocation.retail_price + - 平台渠道: Package.SuggestedRetailPrice + - 代理渠道额外校验: retail_price >= cost_price,不满足则该套餐不展示(防止亏损售卖) +7. 加油包前置校验: package_type=addon 的套餐,仅在该资产有至少一个生效中的正式套餐(formal)时才展示 +8. 按价格升序排序 + +参考: +- 上下架逻辑: internal/service/purchase_validation/service.go validatePackages (第109行) +- 代理分配: internal/model/shop_package_allocation.go +- 上下架提案: openspec/changes/archive/2026-03-02-agent-allocation-shelf-status/proposal.md +``` + +#### B3: 历史套餐列表 + +``` +GET /api/c/v1/asset/package-history?identifier=xxx&page=1&page_size=20 +认证: 需要 JWT + +响应体: 复用 []dto.AssetPackageResponse(见 internal/model/dto/asset_dto.go:79) +包含所有状态的套餐使用记录 +自动按资产当前 generation 过滤(仅展示当前世代套餐历史) + +参考: internal/service/asset/service.go GetPackages 方法 +``` + +#### B4: 手动刷新 + +``` +POST /api/c/v1/asset/refresh +认证: 需要 JWT + +请求体: +{ "identifier": "xxx" } + +响应体: +{ "message": "刷新成功" } + +实现逻辑: +1. 解析标识符 +2. asset_type=card: 调用 iotCardService.RefreshCardDataFromGateway(iccid) — 同步 Gateway 写回 DB +3. asset_type=device: 设备有 Redis 限频(冷却时间),调用设备下所有卡的刷新 + +参考: internal/service/asset/service.go Refresh 方法, internal/handler/admin/asset.go Refresh +``` + +### 4.3 模块 C:钱包与充值 + +#### C1: 钱包详情 + +``` +GET /api/c/v1/wallet/detail?identifier=xxx +认证: 需要 JWT + +响应体: 复用 dto.AssetWalletResponse(见 internal/model/dto/asset_wallet_dto.go:6) + +实现逻辑: +1. 解析标识符 → resource_type + resource_id +2. 查 AssetWallet WHERE resource_type=? AND resource_id=? +3. 不存在则自动创建空钱包 + +参考: internal/handler/admin/asset_wallet.go GetWallet +``` + +#### C2: 钱包流水列表 + +``` +GET /api/c/v1/wallet/transactions?identifier=xxx&page=1&page_size=20&transaction_type=recharge +认证: 需要 JWT + +查询参数: + identifier: string // 必填 + page: int // 默认1 + page_size: int // 默认20,最大100 + transaction_type: string // 可选: recharge/deduct/refund + start_time: RFC3339 // 可选 + end_time: RFC3339 // 可选 + +响应体: 复用 dto.AssetWalletTransactionListResponse(见 asset_wallet_dto.go:46) + +参考: internal/handler/admin/asset_wallet.go ListTransactions +``` + +#### C3: 充值预检 + +``` +GET /api/c/v1/wallet/recharge-check?identifier=xxx +认证: 需要 JWT + +响应体: 复用 dto.RechargeCheckResponse(见 internal/model/dto/recharge.go:88) +{ + "need_force_recharge": true, + "force_recharge_amount": 20000, // 分 + "trigger_type": "first_recharge", + "min_amount": 100, // 最低1元 + "max_amount": 10000000, // 最高10万 + "current_accumulated": 5000, // 当前累计 + "threshold": 20000, // 触发阈值 + "message": "首次购买需充值200元", + "first_commission_paid": false +} + +参考: internal/handler/h5/recharge.go RechargeCheck (将删除,需重建) + internal/service/recharge/service.go GetRechargeCheck +``` + +#### C4: 创建充值订单 + +``` +POST /api/c/v1/wallet/recharge +认证: 需要 JWT + +请求体: +{ + "identifier": "xxx", // 资产标识符 + "amount": 20000, // 充值金额(分) + "payment_method": "wechat", // 固定 wechat(客户端只支持微信支付) + "app_type": "official_account" // 应用类型: official_account-公众号 miniapp-小程序 +} + +响应体: +{ + "recharge": { // dto.RechargeResponse + "id": 1, + "recharge_no": "CRCH20260318...", + "amount": 20000, + "status": 1, + ... + }, + "pay_config": { ... } // 微信 JSAPI 支付配置(可直接拉起支付) +} + +实现逻辑: +1. 解析标识符 → resource_type + resource_id +2. 验证金额范围 (100 ~ 10000000 分) +3. 查找/创建 AssetWallet +4. 创建 AssetRechargeRecord: + - status=1 待支付 + - payment_config_id=当前生效配置 + - user_id=customer_id, operator_type="personal_customer"(区分后台用户 operator_type="admin_user") + - generation=资产当前 generation(写时快照) +5. 根据 customer_id + app_type 从 PersonalCustomerOpenID 查询 openid + - 如果找不到对应 app_type 的 OpenID 记录 → 返回错误 { code: OPENID_NOT_FOUND, message: "请在对应平台重新授权登录" } +6. 调用微信支付创建 JSAPI 订单,返回 pay_config + +注意: AssetRechargeRecord.user_id + operator_type 配合使用区分操作人身份。 +个人客户充值: user_id=customer_id, operator_type="personal_customer" +后台用户充值: user_id=admin_user_id, operator_type="admin_user" + +前端提示: 如果是强充场景(充值金额 > 套餐价格),前端应明确提示用户"充值 XX 元,其中 XX 元用于购买套餐,剩余 XX 元将存入钱包余额" + +参考: +- 现有 H5 充值: internal/handler/h5/recharge.go Create (将删除) +- 充值服务: internal/service/recharge/service.go +- DTO: internal/model/dto/recharge.go +``` + +#### C5: 充值订单列表 + +``` +GET /api/c/v1/wallet/recharges?identifier=xxx&page=1&page_size=20 +认证: 需要 JWT + +响应体: 复用 dto.RechargeListResponse(见 recharge.go:66) + +实现逻辑: +1. 解析标识符 → resource_type + resource_id,获取资产当前 generation +2. 查 AssetRechargeRecord WHERE resource_type=? AND resource_id=? AND generation=? ORDER BY created_at DESC +``` + +### 4.4 模块 D:套餐购买 + +#### D1: 创建套餐购买订单(核心接口,含强充两阶段处理) + +``` +POST /api/c/v1/orders/create +认证: 需要 JWT + +请求体: +{ + "identifier": "xxx", // 资产标识符 + "package_ids": [1, 2], // 套餐ID列表 + "app_type": "official_account" // 应用类型: official_account-公众号 miniapp-小程序(JSAPI 支付需要) +} + +响应体(两种情况): +// 情况 A: 不需要强充,直接购买 +{ + "order_type": "package", + "order": { ...dto.OrderResponse }, + "pay_amount": 3000, + "pay_config": { ... } // 微信 JSAPI 支付配置 +} + +// 情况 B: 需要强充,创建充值订单 +{ + "order_type": "recharge", + "recharge": { ...dto.RechargeResponse }, + "pay_amount": 20000, // 强充金额 + "linked_package_info": { // 关联的套餐信息(前端展示用) + "package_names": ["月套餐30G"], + "package_price": 3000 + }, + "pay_config": { ... } +} + +实现逻辑(关键流程): +1. 解析标识符 → card/device + asset_type + asset_id +2. 资产归属校验: 查 PersonalCustomerDevice WHERE customer_id=? AND virtual_no=资产虚拟号 + 未绑定 → 返回 403 "无权操作该资产" +3. 调用 purchaseValidationService.ValidateCardPurchase / ValidateDevicePurchase + - 检查 series_id、Package.status、shelf_status + - 加油包校验: package_type=addon 时,检查该资产是否有生效中的正式套餐 +4. 检查实名: 如果套餐 enable_realname_activation=true 且卡 real_name_status=0 + → 返回错误 { code: NEED_REALNAME, need_realname: true } +5. 根据 customer_id + app_type 从 PersonalCustomerOpenID 查询 openid + 找不到 → 返回错误 { code: OPENID_NOT_FOUND, message: "请在对应平台重新授权登录" } +6. 幂等性检查(Redis 业务键 + 分布式锁) +7. 检查强充 checkForceRechargeRequirement(见 order/service.go:2216) +8. 分流: + a. 不需要强充 → 创建 Order(generation=资产当前generation, source="client") + 拉起微信支付 + b. 需要强充: + pay_amount = max(force_amount, package_total_price) + 创建 AssetRechargeRecord(linked_package_ids=package_ids, generation=资产当前generation) + 拉起微信支付 + +强充触发条件(详见 order/service.go:2216 checkForceRechargeRequirement): +- 一次性佣金未启用 → 不强充 +- 首充模式 + 该系列已触发 → 不强充 +- 首充模式 + 未触发 → 强充额 = threshold +- 累计模式 + 该系列已触发 → 不强充 +- 累计模式 + 平台启用强充 → 强充额 = config.ForceAmount (fixed) 或 threshold-已累计 (dynamic) +- 累计模式 + 平台未启用 + 代理启用 → 用代理的 ForceRechargeAmount + +强充回调两阶段处理(重要设计变更): +充值支付成功后,采用「先入账,再异步购买」的两阶段方案: + +**第一阶段(同步,在支付回调事务内)**: +1. 钱入钱包(余额增加) +2. 更新充值单状态为已完成 +3. 更新累计/首充状态 +4. 检查一次性佣金触发(固定模式或梯度模式) +→ 保证用户资金安全:即使后续步骤失败,钱已在钱包中 + +**第二阶段(异步,通过 Asynq 任务)**: +5. 入队异步任务: AutoPurchaseAfterRecharge(recharge_record_id) +6. 异步任务执行: + a. 从钱包扣款(payment_method=wallet,扣除套餐总价) + b. 创建套餐购买订单(Order,source="client", generation=资产当前generation) + c. 激活套餐 +7. 如果异步任务失败(如 Gateway 超时): + a. 自动重试(Asynq 默认重试机制,最多 3 次) + b. 重试全部失败后:标记 AssetRechargeRecord.auto_purchase_status = "failed" + c. 客户端查询充值详情时提示"自动购买失败,请手动购买套餐" + d. 钱已在钱包中,用户可手动操作 + +设计原因: 原方案将充值入账和套餐购买放在同一事务中,如果套餐激活失败(Gateway 超时等), +会导致整个事务回滚,充值也不入账。微信回调重试次数有限(约 15 次),持续失败会导致 +用户资金丢失。两阶段方案保证资金安全,异步购买失败后用户可手动操作。 + +注意: 充值回调存在已知事务一致性 BUG(状态更新在事务外执行),需在本次开发中一并修复。 +详见 recharge/service.go:308-321,需将 UpdateStatusWithOptimisticLock 和 UpdatePaymentInfo +改为使用事务内的 tx 而非 s.db。 + +幂等性保证: 充值回调可能被第三方重复通知。通过 AssetRechargeRecord 的状态条件更新 +(WHERE status = 待支付)确保重复回调不会重复执行。 + +一次性佣金梯度模式(已实现于 commission_calculation/service.go:451): +- config.CommissionType == "tiered" && len(config.Tiers) > 0 +- 按 sales_count/sales_amount 维度匹配阈值 +- 支持 self/self_and_sub 统计范围 +- 按链式分配规则分佣 + +参考: +- 现有 H5 创建订单: internal/service/order/service.go:632 CreateH5Order +- 强充检查: internal/service/order/service.go:2216 checkForceRechargeRequirement +- 梯度佣金: internal/service/commission_calculation/service.go:451 +- 购买验证: internal/service/purchase_validation/service.go +``` + +#### D2: 套餐订单列表 + +``` +GET /api/c/v1/orders?identifier=xxx&page=1&page_size=20&payment_status=2 +认证: 需要 JWT + +查询参数: 参考 dto.OrderListRequest (order_dto.go:22) + 增加 identifier 筛选(转为 iot_card_id/device_id 查询) + 自动按资产当前 generation 过滤(仅展示当前世代订单) + +响应体: dto.OrderListResponse (order_dto.go:84) +``` + +#### D3: 套餐订单详情 + +``` +GET /api/c/v1/orders/:id +认证: 需要 JWT + +响应体: dto.OrderResponse (order_dto.go:47) + +实现逻辑: +1. 从 JWT 获取 customer_id +2. 查询 Order WHERE id = :id +3. 归属校验: 通过 order 的 iot_card_id/device_id 查找资产,验证当前客户是否绑定了该资产 + (查 PersonalCustomerDevice WHERE customer_id=? AND virtual_no=资产虚拟号) +4. 未绑定则返回 403 +``` + +### 4.5 模块 E:实名 + +#### E1: 获取实名跳转链接 + +``` +GET /api/c/v1/realname/link?identifier=xxx&iccid=xxx +认证: 需要 JWT + +查询参数: + identifier: string // 必填。资产标识符 + iccid: string // 可选。设备资产时指定具体哪张卡去实名 + +响应体: +{ + "url": "https://realname.carrier.com/xxx", + "carrier_name": "中国联通" +} + +两个入口: +A. 购买套餐被拦截(NEED_REALNAME) → 前端弹窗引导 → 调此接口 +B. 设备卡列表主动选择某张未实名的卡 → 传 iccid 参数 + +实现逻辑: +1. 解析标识符 → 确定目标卡: + - 直接是卡 → 用该卡 + - 是设备 + 传了 iccid → 查该 iccid 对应的卡 + - 是设备 + 没传 iccid → 查 DeviceSimBinding 中 isActive=1 的卡 +2. 检查 card.real_name_status == 1 → "该卡已完成实名" +3. 查 Carrier WHERE id=card.carrier_id → 获取 realname_link_type: + - 'none' → "该运营商暂不支持在线实名" + - 'template' → 替换占位符 {iccid}/{msisdn}/{virtual_no} → 返回 URL + - 'gateway' → 调 gateway.GetRealnameLink(card.ICCID) → 返回 URL + +参考: internal/routes/iot_card.go:111 GetRealnameLink 路由 + internal/gateway/flow_card.go:44 GetRealnameLink +``` + +### 4.6 模块 F:设备能力 + +**通用前置**: 解析标识符 → 必须是设备类型 → 设备必须有 IMEI + +#### F1: 设备卡列表 + +``` +GET /api/c/v1/device/cards?identifier=xxx +认证: 需要 JWT + +响应体: +{ + "device_id": 1, + "device_name": "GPS追踪器", + "max_sim_slots": 4, + "cards": [ + { + "card_id": 101, + "iccid": "89860...", + "msisdn": "1064...", + "carrier_name": "中国联通", + "network_status": 1, // 0-停机 1-开机 + "real_name_status": 0, // 0-未实名 1-已实名 + "slot_position": 1, + "is_active": true + } + ] +} + +实现逻辑: +1. 解析标识符 → 设备 +2. 查 DeviceSimBinding WHERE device_id=? AND bind_status=1 → 获取所有卡ID +3. 批量查 IotCard → 获取 iccid, msisdn, network_status, real_name_status +4. 关联 Carrier → 获取 carrier_name +5. 全部从 CMP 数据库查,不调 Gateway + +参考: internal/model/device_sim_binding.go +``` + +#### F2-F5: 设备能力接口 + +``` +POST /api/c/v1/device/reboot → gateway.RebootDevice(imei) +POST /api/c/v1/device/factory-reset → gateway.ResetDevice(imei) +POST /api/c/v1/device/wifi → gateway.SetWiFi(imei, ssid, password, enabled) +// 注: Gateway WiFiReq 的 cardNo 字段实际传入设备 IMEI,由后端从设备信息获取 +POST /api/c/v1/device/switch-card → gateway.SwitchCard(imei, target_iccid) + +请求体统一: +{ + "identifier": "xxx", // 资产标识符(设备) + // WiFi 额外参数: + "ssid": "MyWiFi", + "password": "12345678", + "enabled": true, + // 切卡额外参数: + "target_iccid": "89860..." +} + +参考: internal/gateway/device.go(所有 Gateway 方法),internal/gateway/models.go(DTO) +``` + +### 4.7 模块 G:换货(客户端) + +#### G1: 查询换货通知 + +``` +GET /api/c/v1/exchange/pending?identifier=xxx +认证: 需要 JWT + +响应体: +{ + "has_pending": true, + "exchange": { + "id": 1, + "exchange_no": "EXC20260318...", + "old_identifier": "V001", + "status": 1, // 1-待填写信息 3-已完成等 + "status_text": "待填写收货信息", + "exchange_reason": "设备故障", + "express_company": "", // 发货后才有 + "express_no": "", + "created_at": "..." + } +} + +实现逻辑: +1. 解析标识符 → asset_type + asset_id +2. 查 ExchangeOrder WHERE old_asset_type=? AND old_asset_id=? AND status IN (1,2,3) ORDER BY created_at DESC LIMIT 1 +``` + +#### G2: 填写收货信息 + +``` +POST /api/c/v1/exchange/:id/shipping-info +认证: 需要 JWT + +请求体: +{ + "recipient_name": "张三", + "recipient_phone": "13800138000", + "recipient_address": "北京市朝阳区XXX" +} + +实现逻辑: +1. 查 ExchangeOrder,验证 status == 1 +2. 更新收货信息,status → 2(待发货) +``` + +### 4.8 模块 H:后台换货管理 + +#### H1: 发起换货 + +``` +POST /api/admin/exchanges +请求体: { old_asset_type, old_asset_id (或 old_identifier), exchange_reason, remark } +验证: 该资产无进行中的换货单 +创建 ExchangeOrder status=1 +``` + +#### H2: 换货列表 + +``` +GET /api/admin/exchanges?status=1&page=1&page_size=20 +支持状态筛选、资产标识符搜索、时间范围 +``` + +#### H3: 换货详情 + +``` +GET /api/admin/exchanges/:id +``` + +#### H4: 发货 + +``` +POST /api/admin/exchanges/:id/ship +请求体: { express_company, express_no, new_identifier, migrate_data: true/false } +验证: status == 2 +验证: new_asset_type == old_asset_type(只能同类型资产更换,卡换卡/设备换设备) +解析 new_identifier → 新资产(新资产需已导入系统,asset_status=1 在库状态) +记录物流信息, shipped_at=now +status → 3(已发货待确认) +``` + +#### H5: 确认完成 + +``` +POST /api/admin/exchanges/:id/complete +验证: status == 3(已发货待确认状态) + +如果 migrate_data == true,执行全量迁移(事务内): +1. 钱包余额转移: 旧 AssetWallet → 新 AssetWallet +2. 生效中套餐关联到新资产 (PackageUsage.iot_card_id/device_id) +3. 累计充值/首充触发状态迁移到新资产 +4. 标签复制 (ResourceTag) +5. 设备卡绑定迁移 (DeviceSimBinding) +6. 个人客户设备绑定更新 (PersonalCustomerDevice) +7. 旧资产 asset_status → 3(已换货) + +全量迁移涉及的 11 张数据表: +| 表 | 操作 | +|----|------| +| tb_asset_wallet | 余额转移 | +| tb_asset_wallet_transaction | 新增迁移流水 | +| tb_asset_recharge_record | 保留原记录 | +| tb_package_usage | 生效套餐关联新资产 | +| tb_package_usage_daily_record | 跟随 PackageUsage | +| tb_order | 保留原记录不修改 | +| tb_commission | 保留原记录不修改 | +| tb_data_usage_record | 保留原记录不修改 | +| tb_resource_tag | 复制到新资产 | +| tb_personal_customer_device | 更新 virtual_no | +| tb_iot_card/tb_device | 累计充值/首充状态迁移 | + +**注意**: 设备换设备时不迁移 `DeviceSimBinding`(卡绑定关系)。新设备自带新的 SIM 卡,旧设备的卡绑定关系保持不变(随旧资产保留)。 + +status → 4(已完成) +``` + +#### H6: 取消换货 + +``` +POST /api/admin/exchanges/:id/cancel +验证: status IN (1, 2)(已发货后不可取消) +status → 5(已取消) +``` + +#### H7: 旧资产转新 + +``` +POST /api/admin/exchanges/:id/renew +说明: 换货完成后,旧资产被标记为"已换货"。转新后可重新销售给新客户。 + +实现: +1. 验证旧资产 asset_status == 3(已换货) +2. 重置 asset_status = 1(在库),不修改 network_status(运营商网络状态由 Gateway 决定) +3. 旧资产 generation 字段自增 1(generation = generation + 1) +4. 清除: 累计充值状态、首充触发状态、个人客户绑定(PersonalCustomerDevice) +5. 创建新的空钱包(新 generation 对应新 wallet,通过 wallet_id 天然隔离流水) +6. 不删除历史数据(订单、充值、流水均保留,通过 generation 字段隔离) +7. 新客户使用该资产时,客户端查询自动按当前 generation 过滤,看不到旧周期数据 +8. 后台管理可查看全部历史(不受 generation 过滤) +``` + +--- + +## 五、删除的旧接口 + +| 文件 | 路由 | 处理 | +|------|------|------| +| `internal/handler/h5/auth.go` | `/api/h5/login` 等 | 删除 | +| `internal/handler/h5/order.go` | `/api/h5/orders/*` | 删除 | +| `internal/handler/h5/recharge.go` | `/api/h5/wallets/*` | 删除 | +| `internal/handler/h5/package_usage.go` | `/api/h5/package-usage/*` | 删除 | +| `internal/handler/h5/enterprise_device.go` | `/api/h5/enterprise-devices/*` | 删除 | +| `internal/routes/h5.go` | H5 路由注册 | 删除 | +| `internal/routes/h5_enterprise_device.go` | | 删除 | +| `internal/routes/h5_package_usage.go` | | 删除 | +| `internal/handler/app/personal_customer.go` | `/api/c/v1/login` `/api/c/v1/login/send-code` 等 | 删除旧手机号+验证码登录(Login, SendCode)、旧微信绑定(WechatOAuthLogin, BindWechat)、旧 Profile 接口。新体系在模块 A 重新设计 | + +同步清理: bootstrap 中 H5 Handler 注册、docs.go/gendocs 中引用、handler/app/personal_customer.go 中旧登录/绑定接口。 + +--- + +## 六、完整业务流程图 + +(流程图同之前版本,已包含在第四节各接口的实现逻辑中。关键流程图参见: +- 登录流程: 见 A2 实现逻辑 +- 套餐购买含强充: 见 D1 实现逻辑 +- 换货全量迁移: 见 H5 实现逻辑 +- 实名跳转: 见 E1 实现逻辑 +- 设备能力: 见 F1-F5) +- 实名闭环说明: 运营商实名为外部流程,完成后不会回调本系统。用户需通过 B4 手动刷新确认实名状态后重新提交购买。部分运营商实名为异步生效(几分钟到几小时),前端应提示用户"实名可能需要一定时间生效,请稍后刷新重试"。 + +--- + +## 七、提案拆分建议 + +| 提案 | 内容 | 涉及主要文件 | +|------|------|-------------| +| **提案 0: 数据模型修复 + 基础字段** | BUG-1 代理零售价修复(含 validatePackages 价格计算 + cost_price 分配锁定)、BUG-2 一次性佣金修复(Order.source + 双重判断)、BUG-4 充值回调事务修复、Carrier 实名链接配置(realname_link_type/template)、IotCard/Device 新增 asset_status 字段、Order/PackageUsage/AssetRechargeRecord 新增 generation 字段、AssetRechargeRecord 新增 operator_type 字段、删除旧 H5 接口和旧登录接口 | model/shop_package_allocation.go, model/carrier.go, model/order.go, model/iot_card.go, model/device.go, service/purchase_validation/, service/commission_calculation/, service/recharge/, handler/h5/, routes/h5*.go | +| **提案 1: 认证系统** | PersonalCustomerOpenID 模型、PersonalCustomer.wx_open_id 索引变更、asset_token 机制(A1)、公众号/小程序登录(A2/A3 含 asset_token 验证)、手机号绑定/换绑(A5/A6 含重复绑定校验)、退出登录(有状态 JWT + Redis)、统一标识符入参公共方法、openid 安全规范(后端查询)、require_phone_binding 配置文件 | model/personal_customer_openid.go, handler/app/, service/personal_customer/, middleware/personal_auth.go | +| **提案 2: 客户端核心业务接口** | 资产信息(B1)、可购买套餐(B2 含加油包校验 + 零售价倒挂下架)、钱包/充值(C4 含 operator_type + OpenID 校验 + generation 快照)、套餐购买(D1 含两阶段强充 + 归属校验 + generation)、订单列表/详情(D2/D3 含 generation 过滤 + 归属校验)、实名跳转、设备能力(WiFi cardNo=IMEI)、手动刷新 | handler/app/, service/order/, service/recharge/, service/asset/, gateway/ | +| **提案 3: 换货系统** | ExchangeOrder 模型(状态机: 1→2→3→4,含 3-已发货待确认)、后台换货管理(CRUD+发货→status=3+确认验证status=3+取消+转新用 asset_status)、客户端换货通知/填写收货、全量迁移逻辑(设备不迁移卡绑定)、同类型资产校验、generation 自增(转新时)、旧 CardReplacementRecord 替换 | model/exchange_order.go, handler/admin/exchange.go, handler/app/exchange.go, service/exchange/, model/iot_card.go, model/device.go | diff --git a/docs/client-core-business-api/功能总结.md b/docs/client-core-business-api/功能总结.md new file mode 100644 index 0000000..f2670e6 --- /dev/null +++ b/docs/client-core-business-api/功能总结.md @@ -0,0 +1,122 @@ +# 客户端核心业务 API — 功能总结 + +## 概述 + +本提案为客户端(C 端个人客户)提供完整的业务接口,覆盖资产查询、钱包充值、套餐购买、实名跳转、设备操作 5 大模块共 18 个 API 端点,全部挂载在 `/api/c/v1/` 路径下。 + +**前置依赖**:提案 0(数据模型修复)、提案 1(C 端认证系统)。 + +## API 端点一览 + +### 模块 B:资产信息(4 个接口) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/c/v1/asset/info` | B1 资产基本信息查询 | +| GET | `/api/c/v1/asset/packages` | B2 可购买套餐列表 | +| GET | `/api/c/v1/asset/package-history` | B3 历史套餐列表 | +| POST | `/api/c/v1/asset/refresh` | B4 手动刷新资产状态 | + +### 模块 C:钱包与充值(5 个接口) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/c/v1/wallet/detail` | C1 钱包详情(不存在自动创建) | +| GET | `/api/c/v1/wallet/transactions` | C2 钱包流水列表 | +| GET | `/api/c/v1/wallet/recharge-check` | C3 充值预检(强充检查) | +| POST | `/api/c/v1/wallet/recharge` | C4 创建充值订单(JSAPI 支付) | +| GET | `/api/c/v1/wallet/recharges` | C5 充值订单列表 | + +### 模块 D:套餐购买(3 个接口) + +| 方法 | 路径 | 说明 | +|------|------|------| +| POST | `/api/c/v1/orders/create` | D1 创建套餐购买订单(含强充分流) | +| GET | `/api/c/v1/orders` | D2 套餐订单列表 | +| GET | `/api/c/v1/orders/:id` | D3 套餐订单详情 | + +### 模块 E:实名认证(1 个接口) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/c/v1/realname/link` | E1 获取实名跳转链接 | + +### 模块 F:设备能力(5 个接口) + +| 方法 | 路径 | 说明 | +|------|------|------| +| GET | `/api/c/v1/device/cards` | F1 设备卡列表 | +| POST | `/api/c/v1/device/reboot` | F2 设备重启 | +| POST | `/api/c/v1/device/factory-reset` | F3 恢复出厂设置 | +| POST | `/api/c/v1/device/wifi` | F4 设置 WiFi | +| POST | `/api/c/v1/device/switch-card` | F5 切卡 | + +## 核心设计决策 + +### 1. 数据权限绕过 + +客户端调用后台复用 Service 时,统一使用 `gorm.SkipDataPermission(ctx)` 绕过 shop_id 自动过滤,避免个人客户因非店铺主体被误拦截。 + +### 2. 归属校验 + +所有涉及资产操作的接口统一前置归属校验:查询 `PersonalCustomerDevice` 条件 `customer_id = 当前登录客户` 且 `virtual_no = 资产虚拟号`,未命中返回 403。 + +### 3. Generation 过滤 + +客户端历史查询统一附加 `WHERE generation = 资产当前 generation`,确保转手后数据隔离。 + +### 4. OpenID 安全规范 + +支付接口(C4/D1)所需 OpenID 由后端按 `customer_id + app_type` 查询,客户端禁止传入 OpenID。根据 `app_type` 选择对应的微信 AppID 创建支付实例。 + +### 5. 强充两阶段 + +- 第一阶段(同步):充值入账、更新状态 +- 第二阶段(异步 Asynq):钱包扣款 → 创建订单 → 激活套餐 + +`AssetRechargeRecord.auto_purchase_status` 字段追踪异步状态(pending/success/failed)。 + +## 新增文件 + +``` +internal/model/dto/client_asset_dto.go # 资产模块 DTO +internal/model/dto/client_wallet_dto.go # 钱包模块 DTO +internal/model/dto/client_order_dto.go # 订单模块 DTO +internal/model/dto/client_realname_device_dto.go # 实名+设备模块 DTO +internal/handler/app/client_asset.go # 资产 Handler +internal/handler/app/client_wallet.go # 钱包 Handler +internal/handler/app/client_order.go # 订单 Handler +internal/handler/app/client_realname.go # 实名 Handler +internal/handler/app/client_device.go # 设备 Handler +internal/service/client_order/service.go # 客户端订单编排 Service +internal/task/auto_purchase.go # 强充异步自动购买任务 +migrations/000084_add_auto_purchase_status_*.sql # 数据库迁移 +``` + +## 修改文件 + +``` +pkg/constants/constants.go # 新增 auto_purchase_status 常量 + 任务类型 +pkg/constants/redis.go # 新增客户端购买幂等键 +pkg/errors/codes.go # 新增 NEED_REALNAME/OPENID_NOT_FOUND 错误码 +internal/model/asset_wallet.go # AssetRechargeRecord 新增字段 +internal/bootstrap/types.go # 5 个 Handler 字段 +internal/bootstrap/handlers.go # Handler 实例化 +internal/routes/personal.go # 18 个路由注册 +pkg/openapi/handlers.go # 文档生成 Handler +cmd/api/docs.go # 文档注册 +cmd/gendocs/main.go # 文档注册 +``` + +## 新增错误码 + +| 错误码 | 常量名 | 消息 | +|--------|--------|------| +| 1187 | CodeNeedRealname | 该套餐需实名认证后购买 | +| 1188 | CodeOpenIDNotFound | 未找到微信授权信息,请先完成授权 | + +## 数据库变更 + +- 表:`tb_asset_recharge_record` +- 新增字段:`auto_purchase_status VARCHAR(20) DEFAULT '' NOT NULL` +- 迁移版本:000084 diff --git a/internal/handler/app/client_asset.go b/internal/handler/app/client_asset.go new file mode 100644 index 0000000..62a7600 --- /dev/null +++ b/internal/handler/app/client_asset.go @@ -0,0 +1,556 @@ +package app + +import ( + "context" + "sort" + "strconv" + "strings" + "time" + + "github.com/break/junhong_cmp_fiber/internal/middleware" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + asset "github.com/break/junhong_cmp_fiber/internal/service/asset" + "github.com/break/junhong_cmp_fiber/internal/store" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// ClientAssetHandler C 端资产信息处理器 +// 提供 B1~B4 资产信息、可购套餐、套餐历史、手动刷新接口 +type ClientAssetHandler struct { + assetService *asset.Service + personalDeviceStore *postgres.PersonalCustomerDeviceStore + assetWalletStore *postgres.AssetWalletStore + packageStore *postgres.PackageStore + shopPackageAllocationStore *postgres.ShopPackageAllocationStore + iotCardStore *postgres.IotCardStore + deviceStore *postgres.DeviceStore + db *gorm.DB + logger *zap.Logger +} + +// NewClientAssetHandler 创建 C 端资产信息处理器 +func NewClientAssetHandler( + assetService *asset.Service, + personalDeviceStore *postgres.PersonalCustomerDeviceStore, + assetWalletStore *postgres.AssetWalletStore, + packageStore *postgres.PackageStore, + shopPackageAllocationStore *postgres.ShopPackageAllocationStore, + iotCardStore *postgres.IotCardStore, + deviceStore *postgres.DeviceStore, + db *gorm.DB, + logger *zap.Logger, +) *ClientAssetHandler { + return &ClientAssetHandler{ + assetService: assetService, + personalDeviceStore: personalDeviceStore, + assetWalletStore: assetWalletStore, + packageStore: packageStore, + shopPackageAllocationStore: shopPackageAllocationStore, + iotCardStore: iotCardStore, + deviceStore: deviceStore, + db: db, + logger: logger, + } +} + +type resolvedAssetContext struct { + CustomerID uint + Identifier string + Asset *dto.AssetResolveResponse + Generation int + WalletBalance int64 + SkipPermissionCtx context.Context + IsAgentChannel bool + SellerShopID uint + MainPackageActived bool +} + +// resolveAssetFromIdentifier 统一执行资产解析与归属校验 +// 处理流程:客户鉴权 -> 标识符解析 -> 资产解析 -> 归属校验 -> 世代与钱包信息补齐 +func (h *ClientAssetHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedAssetContext, error) { + customerID, ok := middleware.GetCustomerID(c) + if !ok || customerID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + + identifier = strings.TrimSpace(identifier) + if identifier == "" { + identifier = strings.TrimSpace(c.Query("identifier")) + } + if identifier == "" { + var req dto.AssetRefreshRequest + if err := c.BodyParser(&req); err == nil { + identifier = strings.TrimSpace(req.Identifier) + } + } + if identifier == "" { + return nil, errors.New(errors.CodeInvalidParam) + } + + skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{}) + assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier) + if err != nil { + return nil, err + } + + owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo) + if ownErr != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败") + } + if !owned { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在") + } + + generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID) + if genErr != nil { + return nil, genErr + } + + walletBalance, walletErr := h.getAssetWalletBalance(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID) + if walletErr != nil { + return nil, walletErr + } + + ctxInfo := &resolvedAssetContext{ + CustomerID: customerID, + Identifier: identifier, + Asset: assetInfo, + Generation: generation, + WalletBalance: walletBalance, + SkipPermissionCtx: skipPermissionCtx, + } + + if assetInfo.ShopID != nil && *assetInfo.ShopID > 0 { + ctxInfo.IsAgentChannel = true + ctxInfo.SellerShopID = *assetInfo.ShopID + } + + return ctxInfo, nil +} + +// GetAssetInfo B1 资产信息 +// GET /api/c/v1/asset/info +func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error { + var req dto.AssetInfoRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) + if err != nil { + return err + } + + resp := &dto.AssetInfoResponse{ + AssetType: resolved.Asset.AssetType, + AssetID: resolved.Asset.AssetID, + Identifier: resolved.Identifier, + VirtualNo: resolved.Asset.VirtualNo, + Status: resolved.Asset.Status, + RealNameStatus: resolved.Asset.RealNameStatus, + CarrierName: resolved.Asset.CarrierName, + Generation: strconv.Itoa(resolved.Generation), + WalletBalance: resolved.WalletBalance, + } + + return response.Success(c, resp) +} + +// GetAvailablePackages B2 资产可购套餐列表 +// GET /api/c/v1/asset/packages +func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error { + var req dto.AssetPackageListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) + if err != nil { + return err + } + + if resolved.Asset.SeriesID == nil || *resolved.Asset.SeriesID == 0 { + return errors.New(errors.CodeNoAvailablePackage, "当前无可购买套餐") + } + + allUsages, err := h.assetService.GetPackages(resolved.SkipPermissionCtx, resolved.Asset.AssetType, resolved.Asset.AssetID) + if err != nil { + return err + } + resolved.MainPackageActived = hasActiveMainPackage(allUsages) + + listCtx := resolved.SkipPermissionCtx + if resolved.IsAgentChannel { + listCtx = context.WithValue(listCtx, constants.ContextKeyUserType, constants.UserTypeAgent) + listCtx = context.WithValue(listCtx, constants.ContextKeyShopID, resolved.SellerShopID) + } + + pkgs, _, err := h.packageStore.List(listCtx, &store.QueryOptions{ + Page: 1, + PageSize: constants.MaxPageSize, + OrderBy: "id DESC", + }, map[string]any{ + "series_id": *resolved.Asset.SeriesID, + "status": constants.StatusEnabled, + }) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询可购套餐失败") + } + + allocationMap := make(map[uint]*model.ShopPackageAllocation) + if resolved.IsAgentChannel { + packageIDs := collectPackageIDs(pkgs) + allocations, allocErr := h.shopPackageAllocationStore.GetByShopAndPackages( + resolved.SkipPermissionCtx, + resolved.SellerShopID, + packageIDs, + ) + if allocErr != nil { + return errors.Wrap(errors.CodeDatabaseError, allocErr, "查询套餐分配记录失败") + } + for _, allocation := range allocations { + allocationMap[allocation.PackageID] = allocation + } + } + + items := make([]dto.ClientPackageItem, 0, len(pkgs)) + for _, pkg := range pkgs { + item, ok := buildClientPackageItem(pkg, resolved, allocationMap) + if !ok { + continue + } + items = append(items, item) + } + + if len(items) == 0 { + return errors.New(errors.CodeNoAvailablePackage, "当前无可购买套餐") + } + + sort.Slice(items, func(i, j int) bool { + return items[i].RetailPrice < items[j].RetailPrice + }) + + return response.Success(c, &dto.AssetPackageListResponse{Packages: items}) +} + +// GetPackageHistory B3 资产套餐历史 +// GET /api/c/v1/asset/package-history +func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error { + var req dto.AssetPackageHistoryRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = constants.DefaultPageSize + } + if req.PageSize > constants.MaxPageSize { + req.PageSize = constants.MaxPageSize + } + + resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) + if err != nil { + return err + } + + query := h.db.WithContext(resolved.SkipPermissionCtx).Model(&model.PackageUsage{}). + Where("generation = ?", resolved.Generation) + if resolved.Asset.AssetType == "card" { + query = query.Where("iot_card_id = ?", resolved.Asset.AssetID) + } else { + query = query.Where("device_id = ?", resolved.Asset.AssetID) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史总数失败") + } + + var usages []*model.PackageUsage + offset := (req.Page - 1) * req.PageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&usages).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史失败") + } + + packageMap, err := h.loadPackageMap(resolved.SkipPermissionCtx, usages) + if err != nil { + return err + } + + list := make([]dto.AssetPackageResponse, 0, len(usages)) + for _, usage := range usages { + pkg := packageMap[usage.PackageID] + ratio := 1.0 + pkgName := "" + pkgType := "" + if pkg != nil { + ratio = safeVirtualRatio(pkg.VirtualRatio) + pkgName = pkg.PackageName + pkgType = pkg.PackageType + } + + list = append(list, dto.AssetPackageResponse{ + PackageUsageID: usage.ID, + PackageID: usage.PackageID, + PackageName: pkgName, + PackageType: pkgType, + UsageType: usage.UsageType, + Status: usage.Status, + StatusName: packageStatusName(usage.Status), + DataLimitMB: usage.DataLimitMB, + VirtualLimitMB: int64(float64(usage.DataLimitMB) / ratio), + DataUsageMB: usage.DataUsageMB, + VirtualUsedMB: float64(usage.DataUsageMB) / ratio, + VirtualRemainMB: float64(usage.DataLimitMB-usage.DataUsageMB) / ratio, + VirtualRatio: ratio, + ActivatedAt: nonZeroTimePtr(usage.ActivatedAt), + ExpiresAt: nonZeroTimePtr(usage.ExpiresAt), + MasterUsageID: usage.MasterUsageID, + Priority: usage.Priority, + CreatedAt: usage.CreatedAt, + }) + } + + return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize) +} + +// RefreshAsset B4 资产刷新 +// POST /api/c/v1/asset/refresh +func (h *ClientAssetHandler) RefreshAsset(c *fiber.Ctx) error { + var req dto.AssetRefreshRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) + if err != nil { + return err + } + + if _, err := h.assetService.Refresh( + resolved.SkipPermissionCtx, + resolved.Asset.AssetType, + resolved.Asset.AssetID, + ); err != nil { + return err + } + + resp := &dto.AssetRefreshResponse{ + RefreshType: resolved.Asset.AssetType, + Accepted: true, + CooldownSeconds: 0, + } + if resolved.Asset.AssetType == constants.ResourceTypeDevice { + resp.CooldownSeconds = int(constants.DeviceRefreshCooldownDuration / time.Second) + } + + return response.Success(c, resp) +} + +func (h *ClientAssetHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) { + records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID) + if err != nil { + return false, err + } + for _, record := range records { + if record == nil { + continue + } + if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo { + return true, nil + } + } + return false, nil +} + +func (h *ClientAssetHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) { + switch assetType { + case "card": + card, err := h.iotCardStore.GetByID(ctx, assetID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return 0, errors.New(errors.CodeAssetNotFound) + } + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败") + } + return card.Generation, nil + case "device": + device, err := h.deviceStore.GetByID(ctx, assetID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return 0, errors.New(errors.CodeAssetNotFound) + } + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败") + } + return device.Generation, nil + default: + return 0, errors.New(errors.CodeInvalidParam) + } +} + +func (h *ClientAssetHandler) getAssetWalletBalance(ctx context.Context, assetType string, assetID uint) (int64, error) { + resourceType := constants.AssetWalletResourceTypeIotCard + if assetType == constants.ResourceTypeDevice { + resourceType = constants.AssetWalletResourceTypeDevice + } + + wallet, err := h.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, assetID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return 0, nil + } + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败") + } + + return wallet.Balance, nil +} + +func (h *ClientAssetHandler) loadPackageMap(ctx context.Context, usages []*model.PackageUsage) (map[uint]*model.Package, error) { + ids := make([]uint, 0, len(usages)) + seen := make(map[uint]struct{}, len(usages)) + for _, usage := range usages { + if usage == nil { + continue + } + if _, ok := seen[usage.PackageID]; ok { + continue + } + seen[usage.PackageID] = struct{}{} + ids = append(ids, usage.PackageID) + } + + packages, err := h.packageStore.GetByIDsUnscoped(ctx, ids) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败") + } + + result := make(map[uint]*model.Package, len(packages)) + for _, pkg := range packages { + result[pkg.ID] = pkg + } + + return result, nil +} + +func collectPackageIDs(pkgs []*model.Package) []uint { + ids := make([]uint, 0, len(pkgs)) + for _, pkg := range pkgs { + if pkg == nil { + continue + } + ids = append(ids, pkg.ID) + } + return ids +} + +func hasActiveMainPackage(usages []*dto.AssetPackageResponse) bool { + for _, usage := range usages { + if usage == nil { + continue + } + if usage.PackageType == constants.PackageTypeFormal && usage.Status == constants.PackageUsageStatusActive { + return true + } + } + return false +} + +func buildClientPackageItem( + pkg *model.Package, + resolved *resolvedAssetContext, + allocationMap map[uint]*model.ShopPackageAllocation, +) (dto.ClientPackageItem, bool) { + if pkg == nil || pkg.Status != constants.StatusEnabled { + return dto.ClientPackageItem{}, false + } + + isAddon := pkg.PackageType == constants.PackageTypeAddon + if isAddon && !resolved.MainPackageActived { + return dto.ClientPackageItem{}, false + } + + retailPrice := pkg.SuggestedRetailPrice + costPrice := pkg.CostPrice + + if resolved.IsAgentChannel { + allocation, ok := allocationMap[pkg.ID] + if !ok || allocation == nil { + return dto.ClientPackageItem{}, false + } + if allocation.ShelfStatus != constants.ShelfStatusOn || allocation.Status != constants.StatusEnabled { + return dto.ClientPackageItem{}, false + } + retailPrice = allocation.RetailPrice + costPrice = allocation.CostPrice + } else if pkg.ShelfStatus != constants.ShelfStatusOn { + return dto.ClientPackageItem{}, false + } + + if retailPrice < costPrice { + return dto.ClientPackageItem{}, false + } + + validityDays := pkg.DurationDays + if validityDays <= 0 && pkg.DurationMonths > 0 { + validityDays = pkg.DurationMonths * 30 + } + + dataAllowance := pkg.VirtualDataMB + if dataAllowance <= 0 { + dataAllowance = pkg.RealDataMB + } + + return dto.ClientPackageItem{ + PackageID: pkg.ID, + PackageName: pkg.PackageName, + PackageType: pkg.PackageType, + RetailPrice: retailPrice, + CostPrice: costPrice, + ValidityDays: validityDays, + IsAddon: isAddon, + DataAllowance: dataAllowance, + DataUnit: "MB", + Description: pkg.PackageCode, + }, true +} + +func nonZeroTimePtr(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + return &t +} + +func safeVirtualRatio(ratio float64) float64 { + if ratio <= 0 { + return 1.0 + } + return ratio +} + +func packageStatusName(status int) string { + switch status { + case constants.PackageUsageStatusPending: + return "待生效" + case constants.PackageUsageStatusActive: + return "生效中" + case constants.PackageUsageStatusDepleted: + return "已用完" + case constants.PackageUsageStatusExpired: + return "已过期" + case constants.PackageUsageStatusInvalidated: + return "已失效" + default: + return "未知" + } +} diff --git a/internal/handler/app/client_device.go b/internal/handler/app/client_device.go new file mode 100644 index 0000000..4713d26 --- /dev/null +++ b/internal/handler/app/client_device.go @@ -0,0 +1,317 @@ +package app + +import ( + "github.com/break/junhong_cmp_fiber/internal/gateway" + "github.com/break/junhong_cmp_fiber/internal/middleware" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/logger" + "github.com/break/junhong_cmp_fiber/pkg/response" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" +) + +var clientDeviceValidator = validator.New() + +// deviceAssetInfo validateDeviceAsset 解析后的设备资产信息 +type deviceAssetInfo struct { + DeviceID uint // 设备数据库 ID + IMEI string // 设备 IMEI(用于 Gateway API 调用) + VirtualNo string // 设备虚拟号(用于所有权校验) +} + +// ClientDeviceHandler C 端设备能力处理器 +// 提供设备卡列表、重启、恢复出厂、WiFi 配置、切卡等操作 +type ClientDeviceHandler struct { + assetService *assetSvc.Service + personalDeviceStore *postgres.PersonalCustomerDeviceStore + deviceStore *postgres.DeviceStore + deviceSimBindingStore *postgres.DeviceSimBindingStore + iotCardStore *postgres.IotCardStore + gatewayClient *gateway.Client + logger *zap.Logger +} + +// NewClientDeviceHandler 创建 C 端设备能力处理器 +func NewClientDeviceHandler( + assetService *assetSvc.Service, + personalDeviceStore *postgres.PersonalCustomerDeviceStore, + deviceStore *postgres.DeviceStore, + deviceSimBindingStore *postgres.DeviceSimBindingStore, + iotCardStore *postgres.IotCardStore, + gatewayClient *gateway.Client, + logger *zap.Logger, +) *ClientDeviceHandler { + return &ClientDeviceHandler{ + assetService: assetService, + personalDeviceStore: personalDeviceStore, + deviceStore: deviceStore, + deviceSimBindingStore: deviceSimBindingStore, + iotCardStore: iotCardStore, + gatewayClient: gatewayClient, + logger: logger, + } +} + +// validateDeviceAsset 校验设备资产的所有权和有效性 +// 流程:认证 → 资产解析 → 类型校验(仅设备)→ 所有权校验 → IMEI 校验 +func (h *ClientDeviceHandler) validateDeviceAsset(c *fiber.Ctx, identifier string) (*deviceAssetInfo, error) { + // 获取当前登录的个人客户 ID + customerID, ok := middleware.GetCustomerID(c) + if !ok || customerID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + + ctx := c.UserContext() + + // 通过标识符解析资产 + asset, err := h.assetService.Resolve(ctx, identifier) + if err != nil { + return nil, err + } + + // 仅设备资产支持设备能力操作 + if asset.AssetType != "device" { + return nil, errors.New(errors.CodeInvalidParam, "仅设备资产支持该操作") + } + + // 校验个人客户对该设备的所有权 + owns, err := h.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, asset.VirtualNo) + if err != nil { + h.logger.Error("校验设备所有权失败", + zap.Uint("customer_id", customerID), + zap.String("virtual_no", asset.VirtualNo), + zap.Error(err)) + return nil, errors.New(errors.CodeInternalError) + } + if !owns { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + + // 校验设备 IMEI 是否存在(Gateway API 调用必需) + if asset.IMEI == "" { + return nil, errors.New(errors.CodeInvalidParam, "设备IMEI缺失") + } + + return &deviceAssetInfo{ + DeviceID: asset.AssetID, + IMEI: asset.IMEI, + VirtualNo: asset.VirtualNo, + }, nil +} + +// GetDeviceCards F1 获取设备卡列表 +// GET /api/c/v1/device/cards +func (h *ClientDeviceHandler) GetDeviceCards(c *fiber.Ctx) error { + var req dto.DeviceCardListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := clientDeviceValidator.Struct(&req); err != nil { + logger.GetAppLogger().Warn("设备卡列表参数校验失败", zap.Error(err)) + return errors.New(errors.CodeInvalidParam) + } + + info, err := h.validateDeviceAsset(c, req.Identifier) + if err != nil { + return err + } + + ctx := c.UserContext() + + // 查询设备绑定的所有 SIM 卡 + bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, info.DeviceID) + if err != nil { + h.logger.Error("查询设备SIM绑定失败", + zap.Uint("device_id", info.DeviceID), + zap.Error(err)) + return errors.New(errors.CodeInternalError) + } + + // 无绑定卡时返回空列表 + if len(bindings) == 0 { + return response.Success(c, &dto.DeviceCardListResponse{Cards: []dto.DeviceCardItem{}}) + } + + // 收集卡 ID 并记录插槽位置映射 + cardIDs := make([]uint, 0, len(bindings)) + slotMap := make(map[uint]int, len(bindings)) + for _, b := range bindings { + cardIDs = append(cardIDs, b.IotCardID) + slotMap[b.IotCardID] = b.SlotPosition + } + + // 批量查询卡详情 + cards, err := h.iotCardStore.GetByIDs(ctx, cardIDs) + if err != nil { + h.logger.Error("批量查询IoT卡失败", + zap.Uints("card_ids", cardIDs), + zap.Error(err)) + return errors.New(errors.CodeInternalError) + } + + // 组装响应,slot_position == 1 视为当前激活卡 + items := make([]dto.DeviceCardItem, 0, len(cards)) + for _, card := range cards { + slot := slotMap[card.ID] + items = append(items, dto.DeviceCardItem{ + CardID: card.ID, + ICCID: card.ICCID, + MSISDN: card.MSISDN, + CarrierName: card.CarrierName, + NetworkStatus: networkStatusText(card.NetworkStatus), + RealNameStatus: card.RealNameStatus, + SlotPosition: slot, + IsActive: slot == 1, + }) + } + + return response.Success(c, &dto.DeviceCardListResponse{Cards: items}) +} + +// RebootDevice F2 设备重启 +// POST /api/c/v1/device/reboot +func (h *ClientDeviceHandler) RebootDevice(c *fiber.Ctx) error { + var req dto.DeviceRebootRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := clientDeviceValidator.Struct(&req); err != nil { + logger.GetAppLogger().Warn("设备重启参数校验失败", zap.Error(err)) + return errors.New(errors.CodeInvalidParam) + } + + info, err := h.validateDeviceAsset(c, req.Identifier) + if err != nil { + return err + } + + // 调用 Gateway 重启设备 + if err := h.gatewayClient.RebootDevice(c.UserContext(), &gateway.DeviceOperationReq{ + DeviceID: info.IMEI, + }); err != nil { + h.logger.Error("Gateway重启设备失败", + zap.String("imei", info.IMEI), + zap.Error(err)) + return errors.Wrap(errors.CodeGatewayError, err, "设备重启失败") + } + + return response.Success(c, &dto.DeviceOperationResponse{Accepted: true}) +} + +// FactoryResetDevice F3 恢复出厂设置 +// POST /api/c/v1/device/factory-reset +func (h *ClientDeviceHandler) FactoryResetDevice(c *fiber.Ctx) error { + var req dto.DeviceFactoryResetRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := clientDeviceValidator.Struct(&req); err != nil { + logger.GetAppLogger().Warn("恢复出厂设置参数校验失败", zap.Error(err)) + return errors.New(errors.CodeInvalidParam) + } + + info, err := h.validateDeviceAsset(c, req.Identifier) + if err != nil { + return err + } + + // 调用 Gateway 恢复出厂设置 + if err := h.gatewayClient.ResetDevice(c.UserContext(), &gateway.DeviceOperationReq{ + DeviceID: info.IMEI, + }); err != nil { + h.logger.Error("Gateway恢复出厂设置失败", + zap.String("imei", info.IMEI), + zap.Error(err)) + return errors.Wrap(errors.CodeGatewayError, err, "恢复出厂设置失败") + } + + return response.Success(c, &dto.DeviceOperationResponse{Accepted: true}) +} + +// SetWiFi F4 设备WiFi配置 +// POST /api/c/v1/device/wifi +// 注意:WiFiReq.CardNo 字段名具有误导性,实际传入的是设备 IMEI,而非卡号 +func (h *ClientDeviceHandler) SetWiFi(c *fiber.Ctx) error { + var req dto.DeviceWifiRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := clientDeviceValidator.Struct(&req); err != nil { + logger.GetAppLogger().Warn("WiFi配置参数校验失败", zap.Error(err)) + return errors.New(errors.CodeInvalidParam) + } + + info, err := h.validateDeviceAsset(c, req.Identifier) + if err != nil { + return err + } + + // 调用 Gateway 配置 WiFi + // CardNo 字段虽名为"卡号",但 Gateway 实际要求传入设备 IMEI + if err := h.gatewayClient.SetWiFi(c.UserContext(), &gateway.WiFiReq{ + CardNo: info.IMEI, + DeviceID: info.IMEI, + SSID: req.SSID, + Password: req.Password, + Enabled: req.Enabled, + }); err != nil { + h.logger.Error("Gateway配置WiFi失败", + zap.String("imei", info.IMEI), + zap.String("ssid", req.SSID), + zap.Error(err)) + return errors.Wrap(errors.CodeGatewayError, err, "WiFi配置失败") + } + + return response.Success(c, &dto.DeviceOperationResponse{Accepted: true}) +} + +// SwitchCard F5 设备切卡 +// POST /api/c/v1/device/switch-card +func (h *ClientDeviceHandler) SwitchCard(c *fiber.Ctx) error { + var req dto.DeviceSwitchCardRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := clientDeviceValidator.Struct(&req); err != nil { + logger.GetAppLogger().Warn("设备切卡参数校验失败", zap.Error(err)) + return errors.New(errors.CodeInvalidParam) + } + + info, err := h.validateDeviceAsset(c, req.Identifier) + if err != nil { + return err + } + + // 调用 Gateway 切卡,CardNo 传设备 IMEI + if err := h.gatewayClient.SwitchCard(c.UserContext(), &gateway.SwitchCardReq{ + CardNo: info.IMEI, + ICCID: req.TargetICCID, + }); err != nil { + h.logger.Error("Gateway切卡失败", + zap.String("imei", info.IMEI), + zap.String("target_iccid", req.TargetICCID), + zap.Error(err)) + return errors.Wrap(errors.CodeGatewayError, err, "设备切卡失败") + } + + return response.Success(c, &dto.DeviceSwitchCardResponse{ + Accepted: true, + TargetICCID: req.TargetICCID, + }) +} + +// networkStatusText 将网络状态码转为文本描述 +func networkStatusText(status int) string { + switch status { + case 0: + return "停机" + case 1: + return "开机" + default: + return "未知" + } +} diff --git a/internal/handler/app/client_order.go b/internal/handler/app/client_order.go new file mode 100644 index 0000000..d742489 --- /dev/null +++ b/internal/handler/app/client_order.go @@ -0,0 +1,415 @@ +package app + +import ( + "context" + "strconv" + "strings" + "time" + + "github.com/break/junhong_cmp_fiber/internal/middleware" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + asset "github.com/break/junhong_cmp_fiber/internal/service/asset" + clientorder "github.com/break/junhong_cmp_fiber/internal/service/client_order" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// ClientOrderHandler C 端订单处理器 +// 提供 D1~D3 下单、列表、详情接口。 +type ClientOrderHandler struct { + clientOrderService *clientorder.Service + assetService *asset.Service + orderStore *postgres.OrderStore + personalDeviceStore *postgres.PersonalCustomerDeviceStore + iotCardStore *postgres.IotCardStore + deviceStore *postgres.DeviceStore + logger *zap.Logger + db *gorm.DB +} + +// NewClientOrderHandler 创建 C 端订单处理器。 +func NewClientOrderHandler( + clientOrderService *clientorder.Service, + assetService *asset.Service, + orderStore *postgres.OrderStore, + personalDeviceStore *postgres.PersonalCustomerDeviceStore, + iotCardStore *postgres.IotCardStore, + deviceStore *postgres.DeviceStore, + logger *zap.Logger, + db *gorm.DB, +) *ClientOrderHandler { + return &ClientOrderHandler{ + clientOrderService: clientOrderService, + assetService: assetService, + orderStore: orderStore, + personalDeviceStore: personalDeviceStore, + iotCardStore: iotCardStore, + deviceStore: deviceStore, + logger: logger, + db: db, + } +} + +// CreateOrder D1 创建订单。 +// POST /api/c/v1/orders/create +func (h *ClientOrderHandler) CreateOrder(c *fiber.Ctx) error { + var req dto.ClientCreateOrderRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + customerID, ok := middleware.GetCustomerID(c) + if !ok || customerID == 0 { + return errors.New(errors.CodeUnauthorized) + } + + resp, err := h.clientOrderService.CreateOrder(c.UserContext(), customerID, &req) + if err != nil { + return err + } + + return response.Success(c, resp) +} + +// ListOrders D2 订单列表。 +// GET /api/c/v1/orders +func (h *ClientOrderHandler) ListOrders(c *fiber.Ctx) error { + var req dto.ClientOrderListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = constants.DefaultPageSize + } + if req.PageSize > constants.MaxPageSize { + req.PageSize = constants.MaxPageSize + } + + resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) + if err != nil { + return err + } + + query := h.db.WithContext(resolved.SkipPermissionCtx). + Model(&model.Order{}). + Where("generation = ?", resolved.Generation) + + if resolved.Asset.AssetType == constants.ResourceTypeDevice { + query = query.Where("order_type = ? AND device_id = ?", model.OrderTypeDevice, resolved.Asset.AssetID) + } else { + query = query.Where("order_type = ? AND iot_card_id = ?", model.OrderTypeSingleCard, resolved.Asset.AssetID) + } + + if req.PaymentStatus != nil { + paymentStatus, ok := clientPaymentStatusToOrderStatus(*req.PaymentStatus) + if !ok { + return errors.New(errors.CodeInvalidParam) + } + query = query.Where("payment_status = ?", paymentStatus) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询订单总数失败") + } + + var orders []*model.Order + offset := (req.Page - 1) * req.PageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&orders).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询订单列表失败") + } + + orderIDs := make([]uint, 0, len(orders)) + for _, order := range orders { + if order == nil { + continue + } + orderIDs = append(orderIDs, order.ID) + } + + itemMap, err := h.loadOrderItemMap(resolved.SkipPermissionCtx, orderIDs) + if err != nil { + return err + } + + list := make([]dto.ClientOrderListItem, 0, len(orders)) + for _, order := range orders { + if order == nil { + continue + } + + packageNames := make([]string, 0, len(itemMap[order.ID])) + for _, item := range itemMap[order.ID] { + if item == nil || item.PackageName == "" { + continue + } + packageNames = append(packageNames, item.PackageName) + } + + list = append(list, dto.ClientOrderListItem{ + OrderID: order.ID, + OrderNo: order.OrderNo, + TotalAmount: order.TotalAmount, + PaymentStatus: orderStatusToClientPaymentStatus(order.PaymentStatus), + CreatedAt: formatClientOrderTime(order.CreatedAt), + PackageNames: packageNames, + }) + } + + return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize) +} + +// GetOrderDetail D3 订单详情。 +// GET /api/c/v1/orders/:id +func (h *ClientOrderHandler) GetOrderDetail(c *fiber.Ctx) error { + customerID, ok := middleware.GetCustomerID(c) + if !ok || customerID == 0 { + return errors.New(errors.CodeUnauthorized) + } + + orderID, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || orderID == 0 { + return errors.New(errors.CodeInvalidParam) + } + + order, items, err := h.orderStore.GetByIDWithItems(c.UserContext(), uint(orderID)) + if err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeNotFound, "订单不存在") + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询订单详情失败") + } + + virtualNo, err := h.getOrderVirtualNo(c.UserContext(), order) + if err != nil { + return err + } + + owned, ownErr := h.isCustomerOwnAsset(c.UserContext(), customerID, virtualNo) + if ownErr != nil { + return errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败") + } + if !owned { + return errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在") + } + + packages := make([]dto.ClientOrderPackageItem, 0, len(items)) + for _, item := range items { + if item == nil { + continue + } + + packages = append(packages, dto.ClientOrderPackageItem{ + PackageID: item.PackageID, + PackageName: item.PackageName, + Price: item.UnitPrice, + Quantity: item.Quantity, + }) + } + + resp := &dto.ClientOrderDetailResponse{ + OrderID: order.ID, + OrderNo: order.OrderNo, + TotalAmount: order.TotalAmount, + PaymentStatus: orderStatusToClientPaymentStatus(order.PaymentStatus), + PaymentMethod: order.PaymentMethod, + CreatedAt: formatClientOrderTime(order.CreatedAt), + PaidAt: formatClientOrderTimePtr(order.PaidAt), + CompletedAt: nil, + Packages: packages, + } + + return response.Success(c, resp) +} + +func (h *ClientOrderHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedAssetContext, error) { + customerID, ok := middleware.GetCustomerID(c) + if !ok || customerID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + + identifier = strings.TrimSpace(identifier) + if identifier == "" { + identifier = strings.TrimSpace(c.Query("identifier")) + } + if identifier == "" { + return nil, errors.New(errors.CodeInvalidParam) + } + + skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{}) + assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier) + if err != nil { + return nil, err + } + + owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo) + if ownErr != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败") + } + if !owned { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在") + } + + generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID) + if genErr != nil { + return nil, genErr + } + + return &resolvedAssetContext{ + CustomerID: customerID, + Identifier: identifier, + Asset: assetInfo, + Generation: generation, + SkipPermissionCtx: skipPermissionCtx, + }, nil +} + +func (h *ClientOrderHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) { + records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID) + if err != nil { + return false, err + } + + for _, record := range records { + if record == nil { + continue + } + if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo { + return true, nil + } + } + + return false, nil +} + +func (h *ClientOrderHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) { + switch assetType { + case "card": + card, err := h.iotCardStore.GetByID(ctx, assetID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return 0, errors.New(errors.CodeAssetNotFound) + } + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败") + } + return card.Generation, nil + case constants.ResourceTypeDevice: + device, err := h.deviceStore.GetByID(ctx, assetID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return 0, errors.New(errors.CodeAssetNotFound) + } + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败") + } + return device.Generation, nil + default: + return 0, errors.New(errors.CodeInvalidParam) + } +} + +func (h *ClientOrderHandler) loadOrderItemMap(ctx context.Context, orderIDs []uint) (map[uint][]*model.OrderItem, error) { + result := make(map[uint][]*model.OrderItem) + if len(orderIDs) == 0 { + return result, nil + } + + var items []*model.OrderItem + if err := h.db.WithContext(ctx).Where("order_id IN ?", orderIDs).Order("id ASC").Find(&items).Error; err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单明细失败") + } + + for _, item := range items { + if item == nil { + continue + } + result[item.OrderID] = append(result[item.OrderID], item) + } + + return result, nil +} + +func (h *ClientOrderHandler) getOrderVirtualNo(ctx context.Context, order *model.Order) (string, error) { + if order == nil { + return "", errors.New(errors.CodeNotFound, "订单不存在") + } + + switch order.OrderType { + case model.OrderTypeSingleCard: + if order.IotCardID == nil || *order.IotCardID == 0 { + return "", errors.New(errors.CodeInvalidParam) + } + card, err := h.iotCardStore.GetByID(ctx, *order.IotCardID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return "", errors.New(errors.CodeAssetNotFound) + } + return "", errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败") + } + return card.VirtualNo, nil + case model.OrderTypeDevice: + if order.DeviceID == nil || *order.DeviceID == 0 { + return "", errors.New(errors.CodeInvalidParam) + } + device, err := h.deviceStore.GetByID(ctx, *order.DeviceID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return "", errors.New(errors.CodeAssetNotFound) + } + return "", errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败") + } + return device.VirtualNo, nil + default: + return "", errors.New(errors.CodeInvalidParam) + } +} + +func orderStatusToClientPaymentStatus(status int) int { + switch status { + case model.PaymentStatusPending: + return 0 + case model.PaymentStatusPaid: + return 1 + case model.PaymentStatusCancelled: + return 2 + default: + return status + } +} + +func clientPaymentStatusToOrderStatus(status int) (int, bool) { + switch status { + case 0: + return model.PaymentStatusPending, true + case 1: + return model.PaymentStatusPaid, true + case 2: + return model.PaymentStatusCancelled, true + default: + return 0, false + } +} + +func formatClientOrderTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + +func formatClientOrderTimePtr(t *time.Time) *string { + if t == nil || t.IsZero() { + return nil + } + formatted := formatClientOrderTime(*t) + return &formatted +} diff --git a/internal/handler/app/client_realname.go b/internal/handler/app/client_realname.go new file mode 100644 index 0000000..7d4b2fe --- /dev/null +++ b/internal/handler/app/client_realname.go @@ -0,0 +1,249 @@ +package app + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + "go.uber.org/zap" + + "github.com/break/junhong_cmp_fiber/internal/gateway" + "github.com/break/junhong_cmp_fiber/internal/middleware" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + assetService "github.com/break/junhong_cmp_fiber/internal/service/asset" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/logger" + "github.com/break/junhong_cmp_fiber/pkg/response" + "github.com/go-playground/validator/v10" +) + +var clientRealnameValidator = validator.New() + +// ClientRealnameHandler C 端实名认证处理器 +type ClientRealnameHandler struct { + assetService *assetService.Service + personalDeviceStore *postgres.PersonalCustomerDeviceStore + iotCardStore *postgres.IotCardStore + deviceSimBindingStore *postgres.DeviceSimBindingStore + carrierStore *postgres.CarrierStore + gatewayClient *gateway.Client + logger *zap.Logger +} + +// NewClientRealnameHandler 创建 C 端实名认证处理器 +func NewClientRealnameHandler( + assetSvc *assetService.Service, + personalDeviceStore *postgres.PersonalCustomerDeviceStore, + iotCardStore *postgres.IotCardStore, + deviceSimBindingStore *postgres.DeviceSimBindingStore, + carrierStore *postgres.CarrierStore, + gatewayClient *gateway.Client, + logger *zap.Logger, +) *ClientRealnameHandler { + return &ClientRealnameHandler{ + assetService: assetSvc, + personalDeviceStore: personalDeviceStore, + iotCardStore: iotCardStore, + deviceSimBindingStore: deviceSimBindingStore, + carrierStore: carrierStore, + gatewayClient: gatewayClient, + logger: logger, + } +} + +// GetRealnameLink E1 获取实名认证链接 +// GET /api/c/v1/realname/link +func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error { + // 1. 获取当前登录客户 + customerID, ok := middleware.GetCustomerID(c) + if !ok || customerID == 0 { + return errors.New(errors.CodeUnauthorized) + } + + // 2. 解析请求参数 + var req dto.RealnimeLinkRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := clientRealnameValidator.Struct(&req); err != nil { + logger.GetAppLogger().Warn("实名链接参数校验失败", zap.Error(err)) + return errors.New(errors.CodeInvalidParam) + } + + ctx := c.UserContext() + + // 3. 通过标识符解析资产 + asset, err := h.assetService.Resolve(ctx, req.Identifier) + if err != nil { + return err + } + + // 4. 验证资产归属(个人客户必须绑定过该资产) + owned, err := h.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, asset.VirtualNo) + if err != nil { + logger.GetAppLogger().Error("查询资产归属失败", + zap.Uint("customer_id", customerID), + zap.String("virtual_no", asset.VirtualNo), + zap.Error(err)) + return errors.New(errors.CodeInternalError, "查询资产归属失败") + } + if !owned { + return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在") + } + + // 5. 定位目标卡(3 条路径) + var targetCard *model.IotCard + switch { + case asset.AssetType == "card": + // 路径 1:资产本身就是卡,直接使用 + card, cardErr := h.iotCardStore.GetByID(ctx, asset.AssetID) + if cardErr != nil { + return errors.New(errors.CodeIotCardNotFound, "卡信息查询失败") + } + targetCard = card + + case asset.AssetType == "device" && req.ICCID != "": + // 路径 2:资产是设备,指定了 ICCID,从设备绑定中查找该卡 + card, cardErr := h.findCardInDeviceBindings(c, asset.AssetID, req.ICCID) + if cardErr != nil { + return cardErr + } + targetCard = card + + case asset.AssetType == "device": + // 路径 3:资产是设备,未指定 ICCID,取第一张绑定卡(按插槽位置排序) + card, cardErr := h.findFirstBoundCard(c, asset.AssetID) + if cardErr != nil { + return cardErr + } + targetCard = card + + default: + return errors.New(errors.CodeInvalidParam, "不支持的资产类型") + } + + // 6. 检查实名状态 + if targetCard.RealNameStatus == 1 { + return errors.New(errors.CodeInvalidStatus, "该卡已完成实名") + } + + // 7. 获取运营商信息,根据实名链接类型生成 URL + carrier, err := h.carrierStore.GetByID(ctx, targetCard.CarrierID) + if err != nil { + logger.GetAppLogger().Error("查询运营商失败", + zap.Uint("carrier_id", targetCard.CarrierID), + zap.Error(err)) + return errors.New(errors.CodeCarrierNotFound, "运营商信息查询失败") + } + + resp := &dto.RealnimeLinkResponse{ + CardInfo: dto.CardInfoBrief{ + ICCID: targetCard.ICCID, + MSISDN: targetCard.MSISDN, + VirtualNo: targetCard.VirtualNo, + }, + } + + switch carrier.RealnameLinkType { + case constants.RealnameLinkTypeNone: + // 该运营商不支持在线实名 + return errors.New(errors.CodeInvalidStatus, "该运营商暂不支持在线实名") + + case constants.RealnameLinkTypeTemplate: + // 模板模式:替换占位符生成实名链接 + url := carrier.RealnameLinkTemplate + url = strings.ReplaceAll(url, "{iccid}", targetCard.ICCID) + url = strings.ReplaceAll(url, "{msisdn}", targetCard.MSISDN) + url = strings.ReplaceAll(url, "{virtual_no}", targetCard.VirtualNo) + resp.RealnameMode = constants.RealnameLinkTypeTemplate + resp.RealnameURL = url + + case constants.RealnameLinkTypeGateway: + // 网关模式:调用 Gateway 接口获取实名链接 + linkResp, gwErr := h.gatewayClient.GetRealnameLink(ctx, &gateway.CardStatusReq{ + CardNo: targetCard.ICCID, + }) + if gwErr != nil { + logger.GetAppLogger().Error("Gateway 获取实名链接失败", + zap.String("iccid", targetCard.ICCID), + zap.Error(gwErr)) + return errors.Wrap(errors.CodeGatewayError, gwErr, "获取实名链接失败") + } + resp.RealnameMode = constants.RealnameLinkTypeGateway + resp.RealnameURL = linkResp.URL + + default: + logger.GetAppLogger().Warn("未知的实名链接类型", + zap.Uint("carrier_id", carrier.ID), + zap.String("realname_link_type", carrier.RealnameLinkType)) + return errors.New(errors.CodeInvalidStatus, "该运营商暂不支持在线实名") + } + + return response.Success(c, resp) +} + +// findCardInDeviceBindings 在设备绑定中查找指定 ICCID 的卡 +func (h *ClientRealnameHandler) findCardInDeviceBindings(c *fiber.Ctx, deviceID uint, iccid string) (*model.IotCard, error) { + ctx := c.UserContext() + + // 查询设备的所有有效绑定 + bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, deviceID) + if err != nil { + logger.GetAppLogger().Error("查询设备绑定失败", + zap.Uint("device_id", deviceID), + zap.Error(err)) + return nil, errors.New(errors.CodeInternalError, "查询设备绑定失败") + } + + // 收集所有绑定卡的 ID + cardIDs := make([]uint, 0, len(bindings)) + for _, b := range bindings { + cardIDs = append(cardIDs, b.IotCardID) + } + + if len(cardIDs) == 0 { + return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定任何卡") + } + + // 批量查询卡,匹配指定的 ICCID + cards, err := h.iotCardStore.GetByIDs(ctx, cardIDs) + if err != nil { + return nil, errors.New(errors.CodeInternalError, "查询卡信息失败") + } + + for _, card := range cards { + if card.ICCID == iccid { + return card, nil + } + } + + return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定指定的 ICCID") +} + +// findFirstBoundCard 获取设备第一张绑定卡(按插槽位置排序,取第一张) +func (h *ClientRealnameHandler) findFirstBoundCard(c *fiber.Ctx, deviceID uint) (*model.IotCard, error) { + ctx := c.UserContext() + + // ListByDeviceID 返回 bind_status=1 的绑定,按 slot_position ASC 排序 + bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, deviceID) + if err != nil { + logger.GetAppLogger().Error("查询设备绑定失败", + zap.Uint("device_id", deviceID), + zap.Error(err)) + return nil, errors.New(errors.CodeInternalError, "查询设备绑定失败") + } + + if len(bindings) == 0 { + return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定任何卡") + } + + // 取第一张绑定卡(插槽位置最小的) + card, err := h.iotCardStore.GetByID(ctx, bindings[0].IotCardID) + if err != nil { + return nil, errors.New(errors.CodeIotCardNotFound, "卡信息查询失败") + } + + return card, nil +} diff --git a/internal/handler/app/client_wallet.go b/internal/handler/app/client_wallet.go new file mode 100644 index 0000000..7676764 --- /dev/null +++ b/internal/handler/app/client_wallet.go @@ -0,0 +1,660 @@ +package app + +import ( + "context" + "fmt" + "math/rand" + "strings" + "time" + + "github.com/break/junhong_cmp_fiber/internal/middleware" + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + asset "github.com/break/junhong_cmp_fiber/internal/service/asset" + rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge" + wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" + "github.com/break/junhong_cmp_fiber/pkg/wechat" + "github.com/gofiber/fiber/v2" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "gorm.io/gorm" +) + +// ClientWalletHandler C 端钱包处理器 +// 提供 C1~C5 钱包详情、流水、充值前校验、充值下单、充值记录接口 +type ClientWalletHandler struct { + assetService *asset.Service + personalDeviceStore *postgres.PersonalCustomerDeviceStore + walletStore *postgres.AssetWalletStore + transactionStore *postgres.AssetWalletTransactionStore + rechargeStore *postgres.AssetRechargeStore + rechargeService *rechargeSvc.Service + openIDStore *postgres.PersonalCustomerOpenIDStore + wechatConfigService *wechatConfigSvc.Service + redis *redis.Client + logger *zap.Logger + db *gorm.DB + iotCardStore *postgres.IotCardStore + deviceStore *postgres.DeviceStore +} + +// NewClientWalletHandler 创建 C 端钱包处理器 +func NewClientWalletHandler( + assetService *asset.Service, + personalDeviceStore *postgres.PersonalCustomerDeviceStore, + walletStore *postgres.AssetWalletStore, + transactionStore *postgres.AssetWalletTransactionStore, + rechargeStore *postgres.AssetRechargeStore, + rechargeService *rechargeSvc.Service, + openIDStore *postgres.PersonalCustomerOpenIDStore, + wechatConfigService *wechatConfigSvc.Service, + redisClient *redis.Client, + logger *zap.Logger, + db *gorm.DB, + iotCardStore *postgres.IotCardStore, + deviceStore *postgres.DeviceStore, +) *ClientWalletHandler { + return &ClientWalletHandler{ + assetService: assetService, + personalDeviceStore: personalDeviceStore, + walletStore: walletStore, + transactionStore: transactionStore, + rechargeStore: rechargeStore, + rechargeService: rechargeService, + openIDStore: openIDStore, + wechatConfigService: wechatConfigService, + redis: redisClient, + logger: logger, + db: db, + iotCardStore: iotCardStore, + deviceStore: deviceStore, + } +} + +type resolvedWalletAssetContext struct { + CustomerID uint + Identifier string + Asset *dto.AssetResolveResponse + Generation int + ResourceType string + SkipPermissionCtx context.Context +} + +// GetWalletDetail C1 钱包详情 +// GET /api/c/v1/wallet/detail +func (h *ClientWalletHandler) GetWalletDetail(c *fiber.Ctx) error { + var req dto.WalletDetailRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) + if err != nil { + return err + } + + wallet, err := h.getOrCreateWallet(resolved) + if err != nil { + return err + } + + resp := &dto.WalletDetailResponse{ + WalletID: wallet.ID, + ResourceType: wallet.ResourceType, + ResourceID: wallet.ResourceID, + Balance: wallet.Balance, + FrozenBalance: wallet.FrozenBalance, + UpdatedAt: wallet.UpdatedAt.Format(time.RFC3339), + } + + return response.Success(c, resp) +} + +// GetWalletTransactions C2 钱包流水列表 +// GET /api/c/v1/wallet/transactions +func (h *ClientWalletHandler) GetWalletTransactions(c *fiber.Ctx) error { + var req dto.WalletTransactionListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = constants.DefaultPageSize + } + if req.PageSize > constants.MaxPageSize { + req.PageSize = constants.MaxPageSize + } + + resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) + if err != nil { + return err + } + + wallet, err := h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return response.SuccessWithPagination(c, []dto.WalletTransactionItem{}, 0, req.Page, req.PageSize) + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败") + } + + var txType *string + if strings.TrimSpace(req.TransactionType) != "" { + v := strings.TrimSpace(req.TransactionType) + txType = &v + } + + startTime, err := parseOptionalTime(req.StartTime) + if err != nil { + return errors.New(errors.CodeInvalidParam) + } + endTime, err := parseOptionalTime(req.EndTime) + if err != nil { + return errors.New(errors.CodeInvalidParam) + } + if startTime != nil && endTime != nil && endTime.Before(*startTime) { + return errors.New(errors.CodeInvalidParam) + } + + offset := (req.Page - 1) * req.PageSize + list, err := h.transactionStore.ListByResourceIDWithFilter( + resolved.SkipPermissionCtx, + wallet.ResourceType, + wallet.ResourceID, + txType, + startTime, + endTime, + offset, + req.PageSize, + ) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包流水失败") + } + + total, err := h.transactionStore.CountByResourceIDWithFilter( + resolved.SkipPermissionCtx, + wallet.ResourceType, + wallet.ResourceID, + txType, + startTime, + endTime, + ) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包流水总数失败") + } + + items := make([]dto.WalletTransactionItem, 0, len(list)) + for _, tx := range list { + if tx == nil { + continue + } + + remark := "" + if tx.Remark != nil { + remark = *tx.Remark + } + + items = append(items, dto.WalletTransactionItem{ + TransactionID: tx.ID, + Type: tx.TransactionType, + Amount: tx.Amount, + BalanceAfter: tx.BalanceAfter, + CreatedAt: tx.CreatedAt.Format(time.RFC3339), + Remark: remark, + }) + } + + return response.SuccessWithPagination(c, items, total, req.Page, req.PageSize) +} + +// GetRechargeCheck C3 充值前校验 +// GET /api/c/v1/wallet/recharge-check +func (h *ClientWalletHandler) GetRechargeCheck(c *fiber.Ctx) error { + var req dto.ClientRechargeCheckRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) + if err != nil { + return err + } + + check, err := h.rechargeService.GetRechargeCheck(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID) + if err != nil { + return err + } + + resp := &dto.ClientRechargeCheckResponse{ + NeedForceRecharge: check.NeedForceRecharge, + ForceRechargeAmount: check.ForceRechargeAmount, + TriggerType: check.TriggerType, + MinAmount: check.MinAmount, + MaxAmount: check.MaxAmount, + Message: check.Message, + } + + return response.Success(c, resp) +} + +// CreateRecharge C4 创建充值订单 +// POST /api/c/v1/wallet/recharge +func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error { + var req dto.ClientCreateRechargeRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + if req.PaymentMethod != constants.RechargeMethodWechat { + return errors.New(errors.CodeInvalidParam) + } + + resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) + if err != nil { + return err + } + + wallet, err := h.getOrCreateWallet(resolved) + if err != nil { + return err + } + + config, err := h.wechatConfigService.GetActiveConfig(resolved.SkipPermissionCtx) + if err != nil { + return err + } + if config == nil { + return errors.New(errors.CodeWechatConfigUnavailable) + } + + appID, err := pickAppIDByType(config, req.AppType) + if err != nil { + return err + } + + openID, err := h.findOpenIDByCustomerAndAppID(resolved.SkipPermissionCtx, resolved.CustomerID, appID) + if err != nil { + return err + } + + rechargeNo := generateClientRechargeNo() + recharge := &model.AssetRechargeRecord{ + UserID: resolved.CustomerID, + AssetWalletID: wallet.ID, + ResourceType: resolved.ResourceType, + ResourceID: resolved.Asset.AssetID, + RechargeNo: rechargeNo, + Amount: req.Amount, + PaymentMethod: constants.RechargeMethodWechat, + PaymentConfigID: &config.ID, + Status: constants.RechargeStatusPending, + ShopIDTag: wallet.ShopIDTag, + EnterpriseIDTag: wallet.EnterpriseIDTag, + OperatorType: constants.OperatorTypePersonalCustomer, + Generation: resolved.Generation, + } + if err := h.rechargeStore.Create(resolved.SkipPermissionCtx, recharge); err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建充值记录失败") + } + + cache := wechat.NewRedisCache(h.redis) + paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, h.logger) + if err != nil { + return errors.Wrap(errors.CodeWechatPayFailed, err, "初始化微信支付实例失败") + } + paymentService := wechat.NewPaymentService(paymentApp, h.logger) + payResult, err := paymentService.CreateJSAPIOrder( + resolved.SkipPermissionCtx, + recharge.RechargeNo, + "资产钱包充值", + openID, + int(req.Amount), + ) + if err != nil { + return err + } + + payConfig := buildClientRechargePayConfig(appID, payResult) + resp := &dto.ClientRechargeResponse{ + Recharge: dto.ClientRechargeResult{ + RechargeID: recharge.ID, + RechargeNo: recharge.RechargeNo, + Amount: recharge.Amount, + Status: recharge.Status, + }, + PayConfig: payConfig, + } + + return response.Success(c, resp) +} + +// GetRechargeList C5 充值记录列表 +// GET /api/c/v1/wallet/recharges +func (h *ClientWalletHandler) GetRechargeList(c *fiber.Ctx) error { + var req dto.ClientRechargeListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + if req.Page < 1 { + req.Page = 1 + } + if req.PageSize < 1 { + req.PageSize = constants.DefaultPageSize + } + if req.PageSize > constants.MaxPageSize { + req.PageSize = constants.MaxPageSize + } + + resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier) + if err != nil { + return err + } + + query := h.db.WithContext(resolved.SkipPermissionCtx). + Model(&model.AssetRechargeRecord{}). + Where("resource_type = ? AND resource_id = ? AND generation = ?", resolved.ResourceType, resolved.Asset.AssetID, resolved.Generation) + if req.Status != nil { + query = query.Where("status = ?", *req.Status) + } + + var total int64 + if err := query.Count(&total).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录总数失败") + } + + var records []*model.AssetRechargeRecord + offset := (req.Page - 1) * req.PageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&records).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败") + } + + items := make([]dto.ClientRechargeListItem, 0, len(records)) + for _, record := range records { + if record == nil { + continue + } + items = append(items, dto.ClientRechargeListItem{ + RechargeID: record.ID, + RechargeNo: record.RechargeNo, + Amount: record.Amount, + Status: record.Status, + PaymentMethod: record.PaymentMethod, + CreatedAt: record.CreatedAt.Format(time.RFC3339), + AutoPurchaseStatus: record.AutoPurchaseStatus, + }) + } + + return response.SuccessWithPagination(c, items, total, req.Page, req.PageSize) +} + +// resolveAssetFromIdentifier 统一执行资产解析与归属校验 +func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedWalletAssetContext, error) { + customerID, ok := middleware.GetCustomerID(c) + if !ok || customerID == 0 { + return nil, errors.New(errors.CodeUnauthorized) + } + + identifier = strings.TrimSpace(identifier) + if identifier == "" { + identifier = strings.TrimSpace(c.Query("identifier")) + } + if identifier == "" { + return nil, errors.New(errors.CodeInvalidParam) + } + + skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{}) + assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier) + if err != nil { + return nil, err + } + + owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo) + if ownErr != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败") + } + if !owned { + return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在") + } + + resourceType, mapErr := mapAssetTypeToWalletResource(assetInfo.AssetType) + if mapErr != nil { + return nil, mapErr + } + + generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID) + if genErr != nil { + return nil, genErr + } + + return &resolvedWalletAssetContext{ + CustomerID: customerID, + Identifier: identifier, + Asset: assetInfo, + Generation: generation, + ResourceType: resourceType, + SkipPermissionCtx: skipPermissionCtx, + }, nil +} + +func (h *ClientWalletHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) { + records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID) + if err != nil { + return false, err + } + for _, record := range records { + if record == nil { + continue + } + if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo { + return true, nil + } + } + return false, nil +} + +func (h *ClientWalletHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) { + switch assetType { + case "card": + card, err := h.iotCardStore.GetByID(ctx, assetID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return 0, errors.New(errors.CodeAssetNotFound) + } + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败") + } + return card.Generation, nil + case "device": + device, err := h.deviceStore.GetByID(ctx, assetID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return 0, errors.New(errors.CodeAssetNotFound) + } + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败") + } + return device.Generation, nil + default: + return 0, errors.New(errors.CodeInvalidParam) + } +} + +func (h *ClientWalletHandler) getOrCreateWallet(resolved *resolvedWalletAssetContext) (*model.AssetWallet, error) { + wallet, err := h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID) + if err == nil { + return wallet, nil + } + if err != gorm.ErrRecordNotFound { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败") + } + + shopIDTag := uint(0) + if resolved.Asset.ShopID != nil { + shopIDTag = *resolved.Asset.ShopID + } + + newWallet := &model.AssetWallet{ + ResourceType: resolved.ResourceType, + ResourceID: resolved.Asset.AssetID, + Balance: 0, + FrozenBalance: 0, + Currency: "CNY", + Status: constants.AssetWalletStatusNormal, + Version: 0, + ShopIDTag: shopIDTag, + } + if createErr := h.walletStore.Create(resolved.SkipPermissionCtx, newWallet); createErr != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, createErr, "创建钱包失败") + } + + wallet, err = h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败") + } + return wallet, nil +} + +func (h *ClientWalletHandler) findOpenIDByCustomerAndAppID(ctx context.Context, customerID uint, appID string) (string, error) { + list, err := h.openIDStore.ListByCustomerID(ctx, customerID) + if err != nil { + return "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权信息失败") + } + for _, item := range list { + if item == nil { + continue + } + if item.AppID == appID && strings.TrimSpace(item.OpenID) != "" { + return item.OpenID, nil + } + } + return "", errors.New(errors.CodeOpenIDNotFound) +} + +func mapAssetTypeToWalletResource(assetType string) (string, error) { + switch assetType { + case "card": + return constants.AssetWalletResourceTypeIotCard, nil + case "device": + return constants.AssetWalletResourceTypeDevice, nil + default: + return "", errors.New(errors.CodeInvalidParam) + } +} + +func parseOptionalTime(value string) (*time.Time, error) { + v := strings.TrimSpace(value) + if v == "" { + return nil, nil + } + + layouts := []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} + for _, layout := range layouts { + t, err := time.Parse(layout, v) + if err == nil { + return &t, nil + } + } + + return nil, fmt.Errorf("invalid time format") +} + +func pickAppIDByType(config *model.WechatConfig, appType string) (string, error) { + switch appType { + case "official_account": + if strings.TrimSpace(config.OaAppID) == "" { + return "", errors.New(errors.CodeWechatConfigUnavailable) + } + return config.OaAppID, nil + case "miniapp": + if strings.TrimSpace(config.MiniappAppID) == "" { + return "", errors.New(errors.CodeWechatConfigUnavailable) + } + return config.MiniappAppID, nil + default: + return "", errors.New(errors.CodeInvalidParam) + } +} + +func generateClientRechargeNo() string { + timestamp := time.Now().Format("20060102150405") + randomNum := rand.Intn(1000000) + return fmt.Sprintf("%s%s%06d", constants.AssetRechargeOrderPrefix, timestamp, randomNum) +} + +func buildClientRechargePayConfig(appID string, result *wechat.JSAPIPayResult) dto.ClientRechargePayConfig { + resp := dto.ClientRechargePayConfig{AppID: appID} + if result == nil || result.PayConfig == nil { + return resp + } + + if cfg, ok := result.PayConfig.(map[string]any); ok { + resp.Timestamp = getStringFromAnyMap(cfg, "timeStamp", "timestamp") + resp.NonceStr = getStringFromAnyMap(cfg, "nonceStr", "nonce_str") + resp.PackageVal = getStringFromAnyMap(cfg, "package") + resp.SignType = getStringFromAnyMap(cfg, "signType", "sign_type") + resp.PaySign = getStringFromAnyMap(cfg, "paySign", "pay_sign") + if appIDVal := getStringFromAnyMap(cfg, "appId", "app_id"); appIDVal != "" { + resp.AppID = appIDVal + } + return resp + } + + if cfg, ok := result.PayConfig.(map[string]string); ok { + resp.Timestamp = cfg["timeStamp"] + if resp.Timestamp == "" { + resp.Timestamp = cfg["timestamp"] + } + resp.NonceStr = cfg["nonceStr"] + if resp.NonceStr == "" { + resp.NonceStr = cfg["nonce_str"] + } + resp.PackageVal = cfg["package"] + resp.SignType = cfg["signType"] + if resp.SignType == "" { + resp.SignType = cfg["sign_type"] + } + resp.PaySign = cfg["paySign"] + if resp.PaySign == "" { + resp.PaySign = cfg["pay_sign"] + } + if cfg["appId"] != "" { + resp.AppID = cfg["appId"] + } else if cfg["app_id"] != "" { + resp.AppID = cfg["app_id"] + } + } + + return resp +} + +func getStringFromAnyMap(m map[string]any, keys ...string) string { + for _, key := range keys { + val, ok := m[key] + if !ok || val == nil { + continue + } + switch v := val.(type) { + case string: + if v != "" { + return v + } + case fmt.Stringer: + text := v.String() + if text != "" { + return text + } + default: + text := fmt.Sprintf("%v", v) + if text != "" && text != "" { + return text + } + } + } + return "" +} diff --git a/internal/model/dto/client_asset_dto.go b/internal/model/dto/client_asset_dto.go new file mode 100644 index 0000000..2faf067 --- /dev/null +++ b/internal/model/dto/client_asset_dto.go @@ -0,0 +1,86 @@ +package dto + +// ======================================== +// B1 资产信息 +// ======================================== + +// AssetInfoRequest B1 资产信息请求 +type AssetInfoRequest struct { + Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` +} + +// AssetInfoResponse B1 资产信息响应 +type AssetInfoResponse struct { + AssetType string `json:"asset_type" description:"资产类型 (card:卡, device:设备)"` + AssetID uint `json:"asset_id" description:"资产ID"` + Identifier string `json:"identifier" description:"资产标识符"` + VirtualNo string `json:"virtual_no" description:"虚拟号"` + Status int `json:"status" description:"状态 (0:禁用, 1:启用)"` + RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"` + CarrierName string `json:"carrier_name" description:"运营商名称"` + Generation string `json:"generation" description:"制式"` + WalletBalance int64 `json:"wallet_balance" description:"钱包余额(分)"` +} + +// ======================================== +// B2 资产可购套餐列表 +// ======================================== + +// AssetPackageListRequest B2 资产可购套餐列表请求 +type AssetPackageListRequest struct { + Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` +} + +// ClientPackageItem B2 客户端套餐项 +type ClientPackageItem struct { + PackageID uint `json:"package_id" description:"套餐ID"` + PackageName string `json:"package_name" description:"套餐名称"` + PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:加油包)"` + RetailPrice int64 `json:"retail_price" description:"零售价(分)"` + CostPrice int64 `json:"cost_price" description:"成本价(分)"` + ValidityDays int `json:"validity_days" description:"有效天数"` + IsAddon bool `json:"is_addon" description:"是否加油包"` + DataAllowance int64 `json:"data_allowance" description:"流量额度"` + DataUnit string `json:"data_unit" description:"流量单位"` + Description string `json:"description" description:"套餐说明"` +} + +// AssetPackageListResponse B2 资产可购套餐列表响应 +type AssetPackageListResponse struct { + Packages []ClientPackageItem `json:"packages" description:"套餐列表"` +} + +// ======================================== +// B3 资产套餐历史 +// ======================================== + +// AssetPackageHistoryRequest B3 资产套餐历史请求 +type AssetPackageHistoryRequest struct { + Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` + Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"` +} + +// AssetPackageHistoryResponse B3 资产套餐历史响应 +type AssetPackageHistoryResponse struct { + List []AssetPackageResponse `json:"list" description:"套餐历史列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"页码"` + PageSize int `json:"page_size" description:"每页数量"` +} + +// ======================================== +// B4 资产刷新 +// ======================================== + +// AssetRefreshRequest B4 资产刷新请求 +type AssetRefreshRequest struct { + Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` +} + +// AssetRefreshResponse B4 资产刷新响应 +type AssetRefreshResponse struct { + RefreshType string `json:"refresh_type" description:"刷新类型 (card:卡, device:设备)"` + Accepted bool `json:"accepted" description:"是否已受理"` + CooldownSeconds int `json:"cooldown_seconds" description:"冷却秒数"` +} diff --git a/internal/model/dto/client_order_dto.go b/internal/model/dto/client_order_dto.go new file mode 100644 index 0000000..6d2163d --- /dev/null +++ b/internal/model/dto/client_order_dto.go @@ -0,0 +1,113 @@ +package dto + +// ======================================== +// D1 客户端创建订单 +// ======================================== + +// ClientCreateOrderRequest D1 客户端创建订单请求 +type ClientCreateOrderRequest struct { + Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` + PackageIDs []uint `json:"package_ids" validate:"required,min=1,dive,gt=0" required:"true" description:"套餐ID列表"` + AppType string `json:"app_type" validate:"required,oneof=official_account miniapp" required:"true" description:"应用类型 (official_account:公众号, miniapp:小程序)"` +} + +// ClientCreateOrderResponse D1 客户端创建订单响应 +type ClientCreateOrderResponse struct { + OrderType string `json:"order_type" description:"订单类型 (package:套餐订单, recharge:充值订单)"` + Order *ClientOrderInfo `json:"order,omitempty" description:"套餐订单信息"` + Recharge *ClientRechargeInfo `json:"recharge,omitempty" description:"充值订单信息"` + PayConfig *ClientPayConfig `json:"pay_config" description:"支付配置"` + LinkedPackageInfo *LinkedPackageInfo `json:"linked_package_info,omitempty" description:"关联套餐信息"` +} + +// ClientOrderInfo D1 套餐订单信息 +type ClientOrderInfo struct { + OrderID uint `json:"order_id" description:"订单ID"` + OrderNo string `json:"order_no" description:"订单号"` + TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"` + PaymentStatus int `json:"payment_status" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"` + CreatedAt string `json:"created_at" description:"创建时间"` +} + +// ClientRechargeInfo D1 充值订单信息 +type ClientRechargeInfo struct { + RechargeID uint `json:"recharge_id" description:"充值ID"` + RechargeNo string `json:"recharge_no" description:"充值单号"` + Amount int64 `json:"amount" description:"充值金额(分)"` + Status int `json:"status" description:"状态 (0:待支付, 1:已支付, 2:已关闭)"` + AutoPurchaseStatus string `json:"auto_purchase_status" description:"自动购包状态"` +} + +// ClientPayConfig D1 支付配置 +type ClientPayConfig struct { + AppID string `json:"app_id" description:"应用ID"` + Timestamp string `json:"timestamp" description:"时间戳"` + NonceStr string `json:"nonce_str" description:"随机字符串"` + PackageVal string `json:"package" description:"预支付参数"` + SignType string `json:"sign_type" description:"签名类型"` + PaySign string `json:"pay_sign" description:"支付签名"` +} + +// LinkedPackageInfo D1 关联套餐信息 +type LinkedPackageInfo struct { + PackageNames []string `json:"package_names" description:"套餐名称列表"` + TotalPackageAmount int64 `json:"total_package_amount" description:"套餐总金额(分)"` + ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"` + WalletCredit int64 `json:"wallet_credit" description:"钱包抵扣金额(分)"` +} + +// ======================================== +// D2 客户端订单列表 +// ======================================== + +// ClientOrderListRequest D2 客户端订单列表请求 +type ClientOrderListRequest struct { + Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` + PaymentStatus *int `json:"payment_status" query:"payment_status" validate:"omitempty,min=0,max=2" minimum:"0" maximum:"2" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"` + Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"` +} + +// ClientOrderListItem D2 客户端订单列表项 +type ClientOrderListItem struct { + OrderID uint `json:"order_id" description:"订单ID"` + OrderNo string `json:"order_no" description:"订单号"` + TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"` + PaymentStatus int `json:"payment_status" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"` + CreatedAt string `json:"created_at" description:"创建时间"` + PackageNames []string `json:"package_names" description:"套餐名称列表"` +} + +// ClientOrderListResponse D2 客户端订单列表响应 +type ClientOrderListResponse struct { + List []ClientOrderListItem `json:"list" description:"订单列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"页码"` + PageSize int `json:"page_size" description:"每页数量"` +} + +// ======================================== +// D3 客户端订单详情 +// ======================================== + +// ClientOrderDetailResponse D3 客户端订单详情响应 +type ClientOrderDetailResponse struct { + OrderID uint `json:"order_id" description:"订单ID"` + OrderNo string `json:"order_no" description:"订单号"` + TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"` + PaymentStatus int `json:"payment_status" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"` + PaymentMethod string `json:"payment_method" description:"支付方式"` + CreatedAt string `json:"created_at" description:"创建时间"` + PaidAt *string `json:"paid_at,omitempty" description:"支付时间"` + CompletedAt *string `json:"completed_at,omitempty" description:"完成时间"` + Packages []ClientOrderPackageItem `json:"packages" description:"订单套餐列表"` +} + +// ClientOrderPackageItem D3 订单套餐项 +type ClientOrderPackageItem struct { + PackageID uint `json:"package_id" description:"套餐ID"` + PackageName string `json:"package_name" description:"套餐名称"` + PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:加油包)"` + Price int64 `json:"price" description:"单价(分)"` + Quantity int `json:"quantity" description:"数量"` +} diff --git a/internal/model/dto/client_realname_device_dto.go b/internal/model/dto/client_realname_device_dto.go new file mode 100644 index 0000000..e2750ac --- /dev/null +++ b/internal/model/dto/client_realname_device_dto.go @@ -0,0 +1,104 @@ +package dto + +// ======================================== +// E1 实名链接获取 +// ======================================== + +// RealnimeLinkRequest E1 实名链接请求 +type RealnimeLinkRequest struct { + Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` + ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=30" maxLength:"30" description:"物联网卡ICCID"` +} + +// RealnimeLinkResponse E1 实名链接响应 +type RealnimeLinkResponse struct { + RealnameMode string `json:"realname_mode" description:"实名模式 (none:无需实名, template:模板实名, gateway:网关实名)"` + RealnameURL string `json:"realname_url" description:"实名链接"` + CardInfo CardInfoBrief `json:"card_info" description:"卡片简要信息"` + ExpireAt *string `json:"expire_at,omitempty" description:"过期时间"` +} + +// CardInfoBrief E1 卡片简要信息 +type CardInfoBrief struct { + ICCID string `json:"iccid" description:"物联网卡ICCID"` + MSISDN string `json:"msisdn" description:"手机号"` + VirtualNo string `json:"virtual_no" description:"虚拟号"` +} + +// ======================================== +// F1 设备卡列表 +// ======================================== + +// DeviceCardListRequest F1 设备卡列表请求 +type DeviceCardListRequest struct { + Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` +} + +// DeviceCardItem F1 设备卡项 +type DeviceCardItem struct { + CardID uint `json:"card_id" description:"卡ID"` + ICCID string `json:"iccid" description:"物联网卡ICCID"` + MSISDN string `json:"msisdn" description:"手机号"` + CarrierName string `json:"carrier_name" description:"运营商名称"` + NetworkStatus string `json:"network_status" description:"网络状态"` + RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"` + SlotPosition int `json:"slot_position" description:"插槽位置"` + IsActive bool `json:"is_active" description:"是否当前激活卡"` +} + +// DeviceCardListResponse F1 设备卡列表响应 +type DeviceCardListResponse struct { + Cards []DeviceCardItem `json:"cards" description:"设备卡列表"` +} + +// ======================================== +// F2 设备重启 +// ======================================== + +// DeviceRebootRequest F2 设备重启请求 +type DeviceRebootRequest struct { + Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` +} + +// DeviceOperationResponse F2/F3/F4 设备操作响应 +type DeviceOperationResponse struct { + Accepted bool `json:"accepted" description:"是否已受理"` + RequestID string `json:"request_id,omitempty" description:"请求ID"` +} + +// ======================================== +// F3 恢复出厂设置 +// ======================================== + +// DeviceFactoryResetRequest F3 恢复出厂设置请求 +type DeviceFactoryResetRequest struct { + Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` +} + +// ======================================== +// F4 设备WiFi配置 +// ======================================== + +// DeviceWifiRequest F4 设备WiFi配置请求 +type DeviceWifiRequest struct { + Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` + SSID string `json:"ssid" validate:"required,min=1,max=32" required:"true" minLength:"1" maxLength:"32" description:"WiFi名称"` + Password string `json:"password" validate:"required,min=1,max=64" required:"true" minLength:"1" maxLength:"64" description:"WiFi密码"` + Enabled bool `json:"enabled" validate:"required" required:"true" description:"是否启用WiFi"` +} + +// ======================================== +// F5 设备切卡 +// ======================================== + +// DeviceSwitchCardRequest F5 设备切卡请求 +type DeviceSwitchCardRequest struct { + Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` + TargetICCID string `json:"target_iccid" validate:"required,min=1,max=30" required:"true" minLength:"1" maxLength:"30" description:"目标ICCID"` +} + +// DeviceSwitchCardResponse F5 设备切卡响应 +type DeviceSwitchCardResponse struct { + Accepted bool `json:"accepted" description:"是否已受理"` + TargetICCID string `json:"target_iccid" description:"目标ICCID"` +} diff --git a/internal/model/dto/client_wallet_dto.go b/internal/model/dto/client_wallet_dto.go new file mode 100644 index 0000000..25c5c95 --- /dev/null +++ b/internal/model/dto/client_wallet_dto.go @@ -0,0 +1,138 @@ +package dto + +// ======================================== +// C1 钱包详情 +// ======================================== + +// WalletDetailRequest C1 钱包详情请求 +type WalletDetailRequest struct { + Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` +} + +// WalletDetailResponse C1 钱包详情响应 +type WalletDetailResponse struct { + WalletID uint `json:"wallet_id" description:"钱包ID"` + ResourceType string `json:"resource_type" description:"资源类型 (iot_card:物联网卡, device:设备)"` + ResourceID uint `json:"resource_id" description:"资源ID"` + Balance int64 `json:"balance" description:"可用余额(分)"` + FrozenBalance int64 `json:"frozen_balance" description:"冻结余额(分)"` + UpdatedAt string `json:"updated_at" description:"更新时间"` +} + +// ======================================== +// C2 钱包流水列表 +// ======================================== + +// WalletTransactionListRequest C2 钱包流水列表请求 +type WalletTransactionListRequest struct { + Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` + TransactionType string `json:"transaction_type" query:"transaction_type" validate:"omitempty,max=50" maxLength:"50" description:"流水类型"` + StartTime string `json:"start_time" query:"start_time" validate:"omitempty,max=32" maxLength:"32" description:"开始时间"` + EndTime string `json:"end_time" query:"end_time" validate:"omitempty,max=32" maxLength:"32" description:"结束时间"` + Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"` +} + +// WalletTransactionItem C2 钱包流水项 +type WalletTransactionItem struct { + TransactionID uint `json:"transaction_id" description:"流水ID"` + Type string `json:"type" description:"流水类型"` + Amount int64 `json:"amount" description:"变动金额(分)"` + BalanceAfter int64 `json:"balance_after" description:"变动后余额(分)"` + CreatedAt string `json:"created_at" description:"创建时间"` + Remark string `json:"remark" description:"备注"` +} + +// WalletTransactionListResponse C2 钱包流水列表响应 +type WalletTransactionListResponse struct { + List []WalletTransactionItem `json:"list" description:"流水列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"页码"` + PageSize int `json:"page_size" description:"每页数量"` +} + +// ======================================== +// C3 充值前校验 +// ======================================== + +// ClientRechargeCheckRequest C3 充值前校验请求 +type ClientRechargeCheckRequest struct { + Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` +} + +// ClientRechargeCheckResponse C3 充值前校验响应 +type ClientRechargeCheckResponse struct { + NeedForceRecharge bool `json:"need_force_recharge" description:"是否需要强制充值"` + ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"` + TriggerType string `json:"trigger_type" description:"触发类型"` + MinAmount int64 `json:"min_amount" description:"最小充值金额(分)"` + MaxAmount int64 `json:"max_amount" description:"最大充值金额(分)"` + Message string `json:"message" description:"提示信息"` +} + +// ======================================== +// C4 创建充值订单 +// ======================================== + +// ClientCreateRechargeRequest C4 创建充值订单请求 +type ClientCreateRechargeRequest struct { + Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` + Amount int64 `json:"amount" validate:"required,min=100,max=10000000" required:"true" minimum:"100" maximum:"10000000" description:"充值金额(分)"` + PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat" required:"true" description:"支付方式 (wechat:微信支付)"` + AppType string `json:"app_type" validate:"required,oneof=official_account miniapp" required:"true" description:"应用类型 (official_account:公众号, miniapp:小程序)"` +} + +// ClientRechargeResponse C4 创建充值订单响应 +type ClientRechargeResponse struct { + Recharge ClientRechargeResult `json:"recharge" description:"充值信息"` + PayConfig ClientRechargePayConfig `json:"pay_config" description:"支付配置"` +} + +// ClientRechargeResult C4 充值信息 +type ClientRechargeResult struct { + RechargeID uint `json:"recharge_id" description:"充值ID"` + RechargeNo string `json:"recharge_no" description:"充值单号"` + Amount int64 `json:"amount" description:"充值金额(分)"` + Status int `json:"status" description:"状态 (0:待支付, 1:已支付, 2:已关闭)"` +} + +// ClientRechargePayConfig C4 支付配置 +type ClientRechargePayConfig struct { + AppID string `json:"app_id" description:"应用ID"` + Timestamp string `json:"timestamp" description:"时间戳"` + NonceStr string `json:"nonce_str" description:"随机字符串"` + PackageVal string `json:"package" description:"预支付参数"` + SignType string `json:"sign_type" description:"签名类型"` + PaySign string `json:"pay_sign" description:"支付签名"` +} + +// ======================================== +// C5 充值记录列表 +// ======================================== + +// ClientRechargeListRequest C5 充值记录列表请求 +type ClientRechargeListRequest struct { + Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN)"` + Status *int `json:"status" query:"status" validate:"omitempty,min=0,max=2" minimum:"0" maximum:"2" description:"充值状态 (0:待支付, 1:已支付, 2:已关闭)"` + Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"` +} + +// ClientRechargeListItem C5 充值记录项 +type ClientRechargeListItem struct { + RechargeID uint `json:"recharge_id" description:"充值ID"` + RechargeNo string `json:"recharge_no" description:"充值单号"` + Amount int64 `json:"amount" description:"充值金额(分)"` + Status int `json:"status" description:"状态 (0:待支付, 1:已支付, 2:已关闭)"` + PaymentMethod string `json:"payment_method" description:"支付方式"` + CreatedAt string `json:"created_at" description:"创建时间"` + AutoPurchaseStatus string `json:"auto_purchase_status" description:"自动购包状态"` +} + +// ClientRechargeListResponse C5 充值记录列表响应 +type ClientRechargeListResponse struct { + List []ClientRechargeListItem `json:"list" description:"充值记录列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"页码"` + PageSize int `json:"page_size" description:"每页数量"` +} diff --git a/internal/service/client_order/service.go b/internal/service/client_order/service.go new file mode 100644 index 0000000..94a7085 --- /dev/null +++ b/internal/service/client_order/service.go @@ -0,0 +1,701 @@ +// Package client_order 提供 C 端订单下单服务。 +package client_order + +import ( + "context" + "fmt" + "math/rand" + "slices" + "strconv" + "strings" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + asset "github.com/break/junhong_cmp_fiber/internal/service/asset" + "github.com/break/junhong_cmp_fiber/internal/service/purchase_validation" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/wechat" + "github.com/bytedance/sonic" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "gorm.io/datatypes" + "gorm.io/gorm" +) + +const ( + clientPurchaseIdempotencyTTL = 5 * time.Minute + clientPurchaseLockTTL = 10 * time.Second +) + +// WechatConfigServiceInterface 微信配置服务接口。 +type WechatConfigServiceInterface interface { + GetActiveConfig(ctx context.Context) (*model.WechatConfig, error) +} + +// ForceRechargeRequirement 强充要求。 +type ForceRechargeRequirement struct { + NeedForceRecharge bool + ForceRechargeAmount int64 +} + +// Service 客户端订单服务。 +type Service struct { + assetService *asset.Service + purchaseValidationService *purchase_validation.Service + orderStore *postgres.OrderStore + rechargeRecordStore *postgres.AssetRechargeStore + walletStore *postgres.AssetWalletStore + personalDeviceStore *postgres.PersonalCustomerDeviceStore + openIDStore *postgres.PersonalCustomerOpenIDStore + wechatConfigService WechatConfigServiceInterface + packageSeriesStore *postgres.PackageSeriesStore + shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore + redis *redis.Client + logger *zap.Logger +} + +// New 创建客户端订单服务。 +func New( + assetService *asset.Service, + purchaseValidationService *purchase_validation.Service, + orderStore *postgres.OrderStore, + rechargeRecordStore *postgres.AssetRechargeStore, + walletStore *postgres.AssetWalletStore, + personalDeviceStore *postgres.PersonalCustomerDeviceStore, + openIDStore *postgres.PersonalCustomerOpenIDStore, + wechatConfigService WechatConfigServiceInterface, + packageSeriesStore *postgres.PackageSeriesStore, + shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore, + redisClient *redis.Client, + logger *zap.Logger, +) *Service { + return &Service{ + assetService: assetService, + purchaseValidationService: purchaseValidationService, + orderStore: orderStore, + rechargeRecordStore: rechargeRecordStore, + walletStore: walletStore, + personalDeviceStore: personalDeviceStore, + openIDStore: openIDStore, + wechatConfigService: wechatConfigService, + packageSeriesStore: packageSeriesStore, + shopSeriesAllocationStore: shopSeriesAllocationStore, + redis: redisClient, + logger: logger, + } +} + +// CreateOrder 创建客户端订单。 +func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.ClientCreateOrderRequest) (*dto.ClientCreateOrderResponse, error) { + if req == nil { + return nil, errors.New(errors.CodeInvalidParam) + } + if s.redis == nil { + return nil, errors.New(errors.CodeInternalError, "Redis 服务未配置") + } + + skipPermissionCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{}) + assetInfo, err := s.assetService.Resolve(skipPermissionCtx, strings.TrimSpace(req.Identifier)) + if err != nil { + return nil, err + } + + if err := s.checkAssetOwnership(skipPermissionCtx, customerID, assetInfo.VirtualNo); err != nil { + return nil, err + } + + validationResult, err := s.validatePurchase(skipPermissionCtx, assetInfo, req.PackageIDs) + if err != nil { + return nil, err + } + + if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != 1 { + return nil, errors.New(errors.CodeNeedRealname) + } + + activeConfig, appID, err := s.resolveWechatConfig(skipPermissionCtx, req.AppType) + if err != nil { + return nil, err + } + + openID, err := s.resolveCustomerOpenID(skipPermissionCtx, customerID, appID) + if err != nil { + return nil, err + } + + businessKey := buildClientPurchaseBusinessKey(customerID, assetInfo, req) + redisKey := constants.RedisClientPurchaseIdempotencyKey(businessKey) + lockKey := constants.RedisClientPurchaseLockKey(assetInfo.AssetType, assetInfo.AssetID) + + lockAcquired, err := s.redis.SetNX(skipPermissionCtx, lockKey, time.Now().String(), clientPurchaseLockTTL).Result() + if err != nil { + s.logger.Warn("获取客户端购买分布式锁失败,继续尝试幂等标记", + zap.Error(err), + zap.String("lock_key", lockKey), + ) + } + if err == nil && !lockAcquired { + return nil, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交") + } + + claimed, err := s.redis.SetNX(skipPermissionCtx, redisKey, "processing", clientPurchaseIdempotencyTTL).Result() + if err != nil { + if lockAcquired { + _ = s.redis.Del(skipPermissionCtx, lockKey).Err() + } + return nil, errors.Wrap(errors.CodeInternalError, err, "设置客户端购买幂等标记失败") + } + if !claimed { + if lockAcquired { + _ = s.redis.Del(skipPermissionCtx, lockKey).Err() + } + return nil, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交") + } + + created := false + defer func() { + if lockAcquired { + _ = s.redis.Del(skipPermissionCtx, lockKey).Err() + } + if !created { + _ = s.redis.Del(skipPermissionCtx, redisKey).Err() + } + }() + + paymentService, err := s.newPaymentService(activeConfig, appID) + if err != nil { + return nil, err + } + + forceRecharge := s.checkForceRechargeRequirement(skipPermissionCtx, validationResult) + if forceRecharge.NeedForceRecharge { + return s.createForceRechargeOrder(skipPermissionCtx, customerID, appID, openID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, paymentService, &created) + } + + return s.createPackageOrder(skipPermissionCtx, customerID, appID, openID, validationResult, activeConfig, redisKey, paymentService, &created) +} + +func (s *Service) checkAssetOwnership(ctx context.Context, customerID uint, virtualNo string) error { + owned, err := s.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, virtualNo) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败") + } + if owned { + return nil + } + + records, err := s.personalDeviceStore.GetByCustomerID(ctx, customerID) + if err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败") + } + for _, record := range records { + if record == nil { + continue + } + if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo { + return nil + } + } + + return errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在") +} + +func (s *Service) validatePurchase(ctx context.Context, assetInfo *dto.AssetResolveResponse, packageIDs []uint) (*purchase_validation.PurchaseValidationResult, error) { + switch assetInfo.AssetType { + case "card": + return s.purchaseValidationService.ValidateCardPurchase(ctx, assetInfo.AssetID, packageIDs) + case constants.ResourceTypeDevice: + return s.purchaseValidationService.ValidateDevicePurchase(ctx, assetInfo.AssetID, packageIDs) + default: + return nil, errors.New(errors.CodeInvalidParam) + } +} + +func (s *Service) resolveWechatConfig(ctx context.Context, appType string) (*model.WechatConfig, string, error) { + activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx) + if err != nil { + return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信配置失败") + } + if activeConfig == nil { + return nil, "", errors.New(errors.CodeWechatPayFailed, "未找到生效的微信支付配置") + } + + switch appType { + case "official_account": + if activeConfig.OaAppID == "" { + return nil, "", errors.New(errors.CodeWechatPayFailed, "公众号支付配置不完整") + } + return activeConfig, activeConfig.OaAppID, nil + case "miniapp": + if activeConfig.MiniappAppID == "" { + return nil, "", errors.New(errors.CodeWechatPayFailed, "小程序支付配置不完整") + } + return activeConfig, activeConfig.MiniappAppID, nil + default: + return nil, "", errors.New(errors.CodeInvalidParam) + } +} + +func (s *Service) resolveCustomerOpenID(ctx context.Context, customerID uint, appID string) (string, error) { + records, err := s.openIDStore.ListByCustomerID(ctx, customerID) + if err != nil { + return "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权信息失败") + } + + for _, record := range records { + if record == nil { + continue + } + if record.AppID == appID && strings.TrimSpace(record.OpenID) != "" { + return record.OpenID, nil + } + } + + return "", errors.New(errors.CodeNotFound, "未找到当前应用的微信授权信息") +} + +func (s *Service) newPaymentService(wechatConfig *model.WechatConfig, appID string) (*wechat.PaymentService, error) { + cache := wechat.NewRedisCache(s.redis) + paymentApp, err := wechat.NewPaymentAppFromConfig(wechatConfig, appID, cache, s.logger) + if err != nil { + return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "创建微信支付应用失败") + } + return wechat.NewPaymentService(paymentApp, s.logger), nil +} + +func (s *Service) createPackageOrder( + ctx context.Context, + customerID uint, + appID string, + openID string, + validationResult *purchase_validation.PurchaseValidationResult, + activeConfig *model.WechatConfig, + redisKey string, + paymentService *wechat.PaymentService, + created *bool, +) (*dto.ClientCreateOrderResponse, error) { + order, err := s.buildPendingOrder(customerID, validationResult, activeConfig) + if err != nil { + return nil, err + } + + items, err := s.buildOrderItems(ctx, customerID, validationResult) + if err != nil { + return nil, err + } + + if err := s.orderStore.Create(ctx, order, items); err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败") + } + + s.markClientPurchaseCreated(ctx, redisKey, order.OrderNo) + *created = true + + description := "套餐购买" + if len(items) > 0 && items[0] != nil && items[0].PackageName != "" { + description = items[0].PackageName + } + + payResult, err := paymentService.CreateJSAPIOrder(ctx, order.OrderNo, description, openID, int(order.TotalAmount)) + if err != nil { + return nil, err + } + + return &dto.ClientCreateOrderResponse{ + OrderType: "package", + Order: &dto.ClientOrderInfo{ + OrderID: order.ID, + OrderNo: order.OrderNo, + TotalAmount: order.TotalAmount, + PaymentStatus: orderStatusToClientStatus(order.PaymentStatus), + CreatedAt: formatClientServiceTime(order.CreatedAt), + }, + PayConfig: buildClientPayConfig(appID, payResult.PayConfig), + }, nil +} + +func (s *Service) createForceRechargeOrder( + ctx context.Context, + customerID uint, + appID string, + openID string, + assetInfo *dto.AssetResolveResponse, + validationResult *purchase_validation.PurchaseValidationResult, + activeConfig *model.WechatConfig, + forceRecharge *ForceRechargeRequirement, + redisKey string, + paymentService *wechat.PaymentService, + created *bool, +) (*dto.ClientCreateOrderResponse, error) { + resourceType, resourceID, err := resolveWalletResource(validationResult) + if err != nil { + return nil, err + } + + wallet, err := s.walletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeWalletNotFound, "钱包不存在") + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败") + } + + linkedPackageIDs, err := sonic.Marshal(extractPackageIDs(validationResult.Packages)) + if err != nil { + return nil, errors.Wrap(errors.CodeInternalError, err, "序列化关联套餐失败") + } + + carrierID := resourceID + recharge := &model.AssetRechargeRecord{ + UserID: customerID, + AssetWalletID: wallet.ID, + ResourceType: resourceType, + ResourceID: resourceID, + RechargeNo: generateClientRechargeNo(), + Amount: forceRecharge.ForceRechargeAmount, + PaymentMethod: model.PaymentMethodWechat, + PaymentConfigID: &activeConfig.ID, + Status: 1, + ShopIDTag: wallet.ShopIDTag, + EnterpriseIDTag: wallet.EnterpriseIDTag, + OperatorType: "personal_customer", + Generation: resolveGeneration(validationResult), + LinkedPackageIDs: datatypes.JSON(linkedPackageIDs), + LinkedOrderType: resolveOrderType(validationResult), + LinkedCarrierType: assetInfo.AssetType, + LinkedCarrierID: &carrierID, + AutoPurchaseStatus: constants.AutoPurchaseStatusPending, + } + + if err := s.rechargeRecordStore.Create(ctx, recharge); err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值记录失败") + } + + s.markClientPurchaseCreated(ctx, redisKey, recharge.RechargeNo) + *created = true + + payResult, err := paymentService.CreateJSAPIOrder(ctx, recharge.RechargeNo, "余额充值", openID, int(recharge.Amount)) + if err != nil { + return nil, err + } + + return &dto.ClientCreateOrderResponse{ + OrderType: "recharge", + Recharge: &dto.ClientRechargeInfo{ + RechargeID: recharge.ID, + RechargeNo: recharge.RechargeNo, + Amount: recharge.Amount, + Status: rechargeStatusToClientStatus(recharge.Status), + AutoPurchaseStatus: recharge.AutoPurchaseStatus, + }, + PayConfig: buildClientPayConfig(appID, payResult.PayConfig), + LinkedPackageInfo: buildLinkedPackageInfo(validationResult, forceRecharge), + }, nil +} + +func (s *Service) buildPendingOrder(customerID uint, result *purchase_validation.PurchaseValidationResult, activeConfig *model.WechatConfig) (*model.Order, error) { + orderType := resolveOrderType(result) + if orderType == "" { + return nil, errors.New(errors.CodeInvalidParam) + } + + now := time.Now() + expiresAt := now.Add(constants.OrderExpireTimeout) + order := &model.Order{ + BaseModel: model.BaseModel{ + Creator: customerID, + Updater: customerID, + }, + OrderNo: s.orderStore.GenerateOrderNo(), + OrderType: orderType, + BuyerType: model.BuyerTypePersonal, + BuyerID: customerID, + TotalAmount: result.TotalPrice, + PaymentMethod: model.PaymentMethodWechat, + PaymentStatus: model.PaymentStatusPending, + CommissionStatus: model.CommissionStatusPending, + CommissionConfigVersion: 0, + Source: constants.OrderSourceClient, + Generation: resolveGeneration(result), + ExpiresAt: &expiresAt, + PaymentConfigID: &activeConfig.ID, + } + + if result.Card != nil { + order.IotCardID = &result.Card.ID + order.SeriesID = result.Card.SeriesID + order.SellerShopID = result.Card.ShopID + } else if result.Device != nil { + order.DeviceID = &result.Device.ID + order.SeriesID = result.Device.SeriesID + order.SellerShopID = result.Device.ShopID + } + + return order, nil +} + +func (s *Service) buildOrderItems(ctx context.Context, customerID uint, result *purchase_validation.PurchaseValidationResult) ([]*model.OrderItem, error) { + sellerShopID := resolveSellerShopID(result) + items := make([]*model.OrderItem, 0, len(result.Packages)) + + for _, pkg := range result.Packages { + if pkg == nil { + continue + } + + unitPrice, err := s.purchaseValidationService.GetPurchasePrice(ctx, pkg, sellerShopID) + if err != nil { + return nil, err + } + + items = append(items, &model.OrderItem{ + BaseModel: model.BaseModel{ + Creator: customerID, + Updater: customerID, + }, + PackageID: pkg.ID, + PackageName: pkg.PackageName, + Quantity: 1, + UnitPrice: unitPrice, + Amount: unitPrice, + }) + } + + return items, nil +} + +func (s *Service) checkForceRechargeRequirement(ctx context.Context, result *purchase_validation.PurchaseValidationResult) *ForceRechargeRequirement { + defaultResult := &ForceRechargeRequirement{NeedForceRecharge: false} + + var seriesID *uint + var sellerShopID uint + if result.Card != nil { + seriesID = result.Card.SeriesID + if result.Card.ShopID != nil { + sellerShopID = *result.Card.ShopID + } + } else if result.Device != nil { + seriesID = result.Device.SeriesID + if result.Device.ShopID != nil { + sellerShopID = *result.Device.ShopID + } + } + + if seriesID == nil || *seriesID == 0 { + return defaultResult + } + + series, err := s.packageSeriesStore.GetByID(ctx, *seriesID) + if err != nil { + s.logger.Warn("查询套餐系列失败", zap.Uint("series_id", *seriesID), zap.Error(err)) + return defaultResult + } + + config, err := series.GetOneTimeCommissionConfig() + if err != nil || config == nil || !config.Enable { + return defaultResult + } + + if config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge { + return &ForceRechargeRequirement{ + NeedForceRecharge: true, + ForceRechargeAmount: config.Threshold, + } + } + + if config.EnableForceRecharge { + amount := config.ForceAmount + if amount == 0 { + amount = config.Threshold + } + return &ForceRechargeRequirement{ + NeedForceRecharge: true, + ForceRechargeAmount: amount, + } + } + + if sellerShopID > 0 { + allocation, allocErr := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, sellerShopID, *seriesID) + if allocErr == nil && allocation.EnableForceRecharge { + amount := allocation.ForceRechargeAmount + if amount == 0 { + amount = config.Threshold + } + return &ForceRechargeRequirement{ + NeedForceRecharge: true, + ForceRechargeAmount: amount, + } + } + } + + return defaultResult +} + +func (s *Service) markClientPurchaseCreated(ctx context.Context, redisKey string, value string) { + if err := s.redis.Set(ctx, redisKey, value, clientPurchaseIdempotencyTTL).Err(); err != nil { + s.logger.Warn("设置客户端购买幂等标记失败", + zap.String("redis_key", redisKey), + zap.Error(err), + ) + } +} + +func buildLinkedPackageInfo(result *purchase_validation.PurchaseValidationResult, forceRecharge *ForceRechargeRequirement) *dto.LinkedPackageInfo { + packageNames := make([]string, 0, len(result.Packages)) + for _, pkg := range result.Packages { + if pkg == nil || pkg.PackageName == "" { + continue + } + packageNames = append(packageNames, pkg.PackageName) + } + + return &dto.LinkedPackageInfo{ + PackageNames: packageNames, + TotalPackageAmount: result.TotalPrice, + ForceRechargeAmount: forceRecharge.ForceRechargeAmount, + WalletCredit: forceRecharge.ForceRechargeAmount, + } +} + +func buildClientPayConfig(appID string, payConfig any) *dto.ClientPayConfig { + configMap, _ := payConfig.(map[string]any) + if configMap == nil { + configMap = map[string]any{} + } + + return &dto.ClientPayConfig{ + AppID: firstNonEmpty(stringFromAny(configMap["appId"]), appID), + Timestamp: firstNonEmpty(stringFromAny(configMap["timeStamp"]), stringFromAny(configMap["timestamp"])), + NonceStr: stringFromAny(configMap["nonceStr"]), + PackageVal: stringFromAny(configMap["package"]), + SignType: stringFromAny(configMap["signType"]), + PaySign: stringFromAny(configMap["paySign"]), + } +} + +func resolveWalletResource(result *purchase_validation.PurchaseValidationResult) (string, uint, error) { + if result.Card != nil { + return constants.AssetWalletResourceTypeIotCard, result.Card.ID, nil + } + if result.Device != nil { + return constants.AssetWalletResourceTypeDevice, result.Device.ID, nil + } + return "", 0, errors.New(errors.CodeInvalidParam) +} + +func resolveOrderType(result *purchase_validation.PurchaseValidationResult) string { + if result.Card != nil { + return model.OrderTypeSingleCard + } + if result.Device != nil { + return model.OrderTypeDevice + } + return "" +} + +func resolveGeneration(result *purchase_validation.PurchaseValidationResult) int { + if result.Card != nil && result.Card.Generation > 0 { + return result.Card.Generation + } + if result.Device != nil && result.Device.Generation > 0 { + return result.Device.Generation + } + return 1 +} + +func resolveSellerShopID(result *purchase_validation.PurchaseValidationResult) uint { + if result.Card != nil && result.Card.ShopID != nil { + return *result.Card.ShopID + } + if result.Device != nil && result.Device.ShopID != nil { + return *result.Device.ShopID + } + return 0 +} + +func packagesNeedRealname(packages []*model.Package) bool { + for _, pkg := range packages { + if pkg != nil && pkg.EnableRealnameActivation { + return true + } + } + return false +} + +func extractPackageIDs(packages []*model.Package) []uint { + ids := make([]uint, 0, len(packages)) + for _, pkg := range packages { + if pkg == nil { + continue + } + ids = append(ids, pkg.ID) + } + return ids +} + +func buildClientPurchaseBusinessKey(customerID uint, assetInfo *dto.AssetResolveResponse, req *dto.ClientCreateOrderRequest) string { + packageIDs := make([]uint, 0, len(req.PackageIDs)) + packageIDs = append(packageIDs, req.PackageIDs...) + slices.Sort(packageIDs) + + parts := make([]string, 0, len(packageIDs)) + for _, packageID := range packageIDs { + parts = append(parts, strconv.FormatUint(uint64(packageID), 10)) + } + + return fmt.Sprintf("%d:%s:%d:%s:%s", customerID, assetInfo.AssetType, assetInfo.AssetID, req.AppType, strings.Join(parts, ",")) +} + +func orderStatusToClientStatus(status int) int { + switch status { + case model.PaymentStatusPending: + return 0 + case model.PaymentStatusPaid: + return 1 + case model.PaymentStatusCancelled: + return 2 + default: + return status + } +} + +func rechargeStatusToClientStatus(status int) int { + switch status { + case 1: + return 0 + case 2, 3: + return 1 + default: + return 2 + } +} + +func formatClientServiceTime(t time.Time) string { + if t.IsZero() { + return "" + } + return t.Format(time.RFC3339) +} + +func generateClientRechargeNo() string { + return fmt.Sprintf("CRCH%d%06d", time.Now().UnixNano()/1e6, rand.Intn(1000000)) +} + +func stringFromAny(value any) string { + if value == nil { + return "" + } + return fmt.Sprint(value) +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return value + } + } + return "" +} diff --git a/internal/task/auto_purchase.go b/internal/task/auto_purchase.go new file mode 100644 index 0000000..5792678 --- /dev/null +++ b/internal/task/auto_purchase.go @@ -0,0 +1,556 @@ +package task + +import ( + "context" + "errors" + "time" + + "github.com/bytedance/sonic" + "github.com/hibiken/asynq" + "github.com/redis/go-redis/v9" + "go.uber.org/zap" + "gorm.io/gorm" + + "github.com/break/junhong_cmp_fiber/internal/model" + packagepkg "github.com/break/junhong_cmp_fiber/internal/service/package" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" + "github.com/break/junhong_cmp_fiber/pkg/constants" +) + +// AutoPurchasePayload 充值后自动购包任务载荷 +type AutoPurchasePayload struct { + RechargeRecordID uint `json:"recharge_record_id"` +} + +// AutoPurchaseHandler 充值后自动购包任务处理器 +type AutoPurchaseHandler struct { + db *gorm.DB + orderStore *postgres.OrderStore + rechargeRecordStore *postgres.AssetRechargeStore + walletStore *postgres.AssetWalletStore + walletTransactionStore *postgres.AssetWalletTransactionStore + packageUsageStore *postgres.PackageUsageStore + redis *redis.Client + logger *zap.Logger +} + +// NewAutoPurchaseHandler 创建充值后自动购包处理器 +func NewAutoPurchaseHandler( + db *gorm.DB, + orderStore *postgres.OrderStore, + rechargeRecordStore *postgres.AssetRechargeStore, + walletStore *postgres.AssetWalletStore, + walletTransactionStore *postgres.AssetWalletTransactionStore, + packageUsageStore *postgres.PackageUsageStore, + redisClient *redis.Client, + logger *zap.Logger, +) *AutoPurchaseHandler { + if orderStore == nil { + orderStore = postgres.NewOrderStore(db, redisClient) + } + if rechargeRecordStore == nil { + rechargeRecordStore = postgres.NewAssetRechargeStore(db, redisClient) + } + if walletStore == nil { + walletStore = postgres.NewAssetWalletStore(db, redisClient) + } + if walletTransactionStore == nil { + walletTransactionStore = postgres.NewAssetWalletTransactionStore(db, redisClient) + } + if packageUsageStore == nil { + packageUsageStore = postgres.NewPackageUsageStore(db, redisClient) + } + + return &AutoPurchaseHandler{ + db: db, + orderStore: orderStore, + rechargeRecordStore: rechargeRecordStore, + walletStore: walletStore, + walletTransactionStore: walletTransactionStore, + packageUsageStore: packageUsageStore, + redis: redisClient, + logger: logger, + } +} + +// ProcessTask 处理充值后自动购包任务 +func (h *AutoPurchaseHandler) ProcessTask(ctx context.Context, task *asynq.Task) error { + var payload AutoPurchasePayload + if err := sonic.Unmarshal(task.Payload(), &payload); err != nil { + h.logger.Error("解析自动购包任务载荷失败", zap.Error(err)) + return asynq.SkipRetry + } + if payload.RechargeRecordID == 0 { + h.logger.Error("自动购包任务载荷无效", zap.Uint("recharge_record_id", payload.RechargeRecordID)) + return asynq.SkipRetry + } + + rechargeRecord, err := h.rechargeRecordStore.GetByID(ctx, payload.RechargeRecordID) + if err != nil { + if err == gorm.ErrRecordNotFound { + h.logger.Warn("充值记录不存在,跳过自动购包", zap.Uint("recharge_record_id", payload.RechargeRecordID)) + return asynq.SkipRetry + } + h.logger.Error("查询充值记录失败", zap.Uint("recharge_record_id", payload.RechargeRecordID), zap.Error(err)) + return err + } + + if rechargeRecord.AutoPurchaseStatus == constants.AutoPurchaseStatusSuccess { + return nil + } + if rechargeRecord.AutoPurchaseStatus == constants.AutoPurchaseStatusFailed { + return nil + } + + packageIDs, err := parseLinkedPackageIDs(rechargeRecord.LinkedPackageIDs) + if err != nil { + h.logger.Error("解析关联套餐ID失败", zap.Uint("recharge_record_id", rechargeRecord.ID), zap.Error(err)) + h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeRecord.ID) + return asynq.SkipRetry + } + if len(packageIDs) == 0 { + h.logger.Error("关联套餐ID为空,无法自动购包", zap.Uint("recharge_record_id", rechargeRecord.ID)) + h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeRecord.ID) + return asynq.SkipRetry + } + + packages, totalAmount, err := h.loadPackages(ctx, packageIDs) + if err != nil { + h.logger.Error("加载关联套餐失败", zap.Uint("recharge_record_id", rechargeRecord.ID), zap.Error(err)) + h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeRecord.ID) + return err + } + + if err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + wallet, walletErr := h.walletStore.GetByID(ctx, rechargeRecord.AssetWalletID) + if walletErr != nil { + if walletErr == gorm.ErrRecordNotFound { + return errors.New("资产钱包不存在") + } + return walletErr + } + if wallet.GetAvailableBalance() < totalAmount { + return errors.New("钱包余额不足") + } + + if err = h.walletStore.DeductBalanceWithTx(ctx, tx, wallet.ID, totalAmount, wallet.Version); err != nil { + return err + } + + now := time.Now() + order, orderItems, buildErr := h.buildOrderAndItems(rechargeRecord, packages, totalAmount, now) + if buildErr != nil { + return buildErr + } + + if err = tx.Create(order).Error; err != nil { + return err + } + + for _, item := range orderItems { + item.OrderID = order.ID + } + if err = tx.CreateInBatches(orderItems, 100).Error; err != nil { + return err + } + + refType := constants.ReferenceTypeOrder + walletTx := &model.AssetWalletTransaction{ + AssetWalletID: wallet.ID, + ResourceType: wallet.ResourceType, + ResourceID: wallet.ResourceID, + UserID: rechargeRecord.UserID, + TransactionType: constants.AssetTransactionTypeDeduct, + Amount: -totalAmount, + BalanceBefore: wallet.Balance, + BalanceAfter: wallet.Balance - totalAmount, + Status: constants.TransactionStatusSuccess, + ReferenceType: &refType, + ReferenceNo: &order.OrderNo, + Creator: rechargeRecord.UserID, + ShopIDTag: wallet.ShopIDTag, + EnterpriseIDTag: wallet.EnterpriseIDTag, + } + if err = h.walletTransactionStore.CreateWithTx(ctx, tx, walletTx); err != nil { + return err + } + + if err = h.activatePackages(ctx, tx, order, packages, now); err != nil { + return err + } + + if err = tx.Model(&model.AssetRechargeRecord{}). + Where("id = ?", rechargeRecord.ID). + Update("auto_purchase_status", constants.AutoPurchaseStatusSuccess).Error; err != nil { + return err + } + + return nil + }); err != nil { + h.logger.Error("自动购包任务执行失败", + zap.Uint("recharge_record_id", rechargeRecord.ID), + zap.Error(err), + ) + h.markAutoPurchaseFailedIfFinalRetry(ctx, rechargeRecord.ID) + return err + } + + h.logger.Info("自动购包任务执行成功", zap.Uint("recharge_record_id", rechargeRecord.ID)) + return nil +} + +// NewAutoPurchaseTask 创建充值后自动购包任务 +func NewAutoPurchaseTask(rechargeRecordID uint) (*asynq.Task, error) { + payloadBytes, err := sonic.Marshal(AutoPurchasePayload{RechargeRecordID: rechargeRecordID}) + if err != nil { + return nil, err + } + + return asynq.NewTask(constants.TaskTypeAutoPurchaseAfterRecharge, payloadBytes, + asynq.MaxRetry(3), + asynq.Timeout(2*time.Minute), + asynq.Queue(constants.QueueDefault), + ), nil +} + +func (h *AutoPurchaseHandler) markAutoPurchaseFailedIfFinalRetry(ctx context.Context, rechargeRecordID uint) { + retryCount, ok := asynq.GetRetryCount(ctx) + if !ok { + return + } + maxRetry, ok := asynq.GetMaxRetry(ctx) + if !ok { + return + } + if retryCount < maxRetry-1 { + return + } + + if err := h.db.WithContext(ctx). + Model(&model.AssetRechargeRecord{}). + Where("id = ?", rechargeRecordID). + Update("auto_purchase_status", constants.AutoPurchaseStatusFailed).Error; err != nil { + h.logger.Error("更新自动购包失败状态失败", + zap.Uint("recharge_record_id", rechargeRecordID), + zap.Error(err), + ) + return + } + + h.logger.Warn("自动购包达到最大重试次数,已标记失败", zap.Uint("recharge_record_id", rechargeRecordID)) +} + +func (h *AutoPurchaseHandler) loadPackages(ctx context.Context, packageIDs []uint) ([]*model.Package, int64, error) { + packages := make([]*model.Package, 0, len(packageIDs)) + if err := h.db.WithContext(ctx).Where("id IN ?", packageIDs).Find(&packages).Error; err != nil { + return nil, 0, err + } + if len(packages) != len(packageIDs) { + return nil, 0, gorm.ErrRecordNotFound + } + + totalAmount := int64(0) + for _, pkg := range packages { + totalAmount += pkg.SuggestedRetailPrice + } + + if err := validatePackageTypeMix(packages); err != nil { + return nil, 0, err + } + + return packages, totalAmount, nil +} + +func (h *AutoPurchaseHandler) buildOrderAndItems( + rechargeRecord *model.AssetRechargeRecord, + packages []*model.Package, + totalAmount int64, + now time.Time, +) (*model.Order, []*model.OrderItem, error) { + orderType, iotCardID, deviceID, err := parseLinkedCarrier(rechargeRecord.LinkedOrderType, rechargeRecord.LinkedCarrierType, rechargeRecord.LinkedCarrierID) + if err != nil { + return nil, nil, err + } + + generation := rechargeRecord.Generation + if generation <= 0 { + generation = 1 + } + + paidAmount := totalAmount + order := &model.Order{ + BaseModel: model.BaseModel{ + Creator: rechargeRecord.UserID, + Updater: rechargeRecord.UserID, + }, + OrderNo: h.orderStore.GenerateOrderNo(), + OrderType: orderType, + BuyerType: model.BuyerTypePersonal, + BuyerID: rechargeRecord.UserID, + IotCardID: iotCardID, + DeviceID: deviceID, + TotalAmount: totalAmount, + PaymentMethod: model.PaymentMethodWallet, + PaymentStatus: model.PaymentStatusPaid, + PaidAt: &now, + CommissionStatus: model.CommissionStatusPending, + CommissionConfigVersion: 0, + Source: constants.OrderSourceClient, + Generation: generation, + ActualPaidAmount: &paidAmount, + SellerShopID: &rechargeRecord.ShopIDTag, + } + + items := make([]*model.OrderItem, 0, len(packages)) + for _, pkg := range packages { + items = append(items, &model.OrderItem{ + BaseModel: model.BaseModel{ + Creator: rechargeRecord.UserID, + Updater: rechargeRecord.UserID, + }, + PackageID: pkg.ID, + PackageName: pkg.PackageName, + Quantity: 1, + UnitPrice: pkg.SuggestedRetailPrice, + Amount: pkg.SuggestedRetailPrice, + }) + } + + return order, items, nil +} + +func (h *AutoPurchaseHandler) activatePackages( + ctx context.Context, + tx *gorm.DB, + order *model.Order, + packages []*model.Package, + now time.Time, +) error { + carrierType := constants.AssetWalletResourceTypeIotCard + carrierID := uint(0) + if order.OrderType == model.OrderTypeSingleCard && order.IotCardID != nil { + carrierID = *order.IotCardID + } else if order.OrderType == model.OrderTypeDevice && order.DeviceID != nil { + carrierType = constants.AssetWalletResourceTypeDevice + carrierID = *order.DeviceID + } else { + return errors.New("无效的订单载体") + } + + for _, pkg := range packages { + var existingUsage model.PackageUsage + err := tx.Where("order_id = ? AND package_id = ?", order.ID, pkg.ID).First(&existingUsage).Error + if err == nil { + continue + } + if err != gorm.ErrRecordNotFound { + return err + } + + if pkg.PackageType == constants.PackageTypeFormal { + if err = h.activateMainPackage(ctx, tx, order, pkg, carrierType, carrierID, now); err != nil { + return err + } + continue + } + if pkg.PackageType == constants.PackageTypeAddon { + if err = h.activateAddonPackage(ctx, tx, order, pkg, carrierType, carrierID, now); err != nil { + return err + } + } + } + + return nil +} + +func (h *AutoPurchaseHandler) activateMainPackage( + ctx context.Context, + tx *gorm.DB, + order *model.Order, + pkg *model.Package, + carrierType string, + carrierID uint, + now time.Time, +) error { + _ = ctx + var activeMainPackage model.PackageUsage + err := tx.Where("status = ?", constants.PackageUsageStatusActive). + Where("master_usage_id IS NULL"). + Where(carrierType+"_id = ?", carrierID). + Order("priority ASC"). + First(&activeMainPackage).Error + + hasActiveMain := err == nil + + var status int + var priority int + var activatedAt time.Time + var expiresAt time.Time + var nextResetAt *time.Time + var pendingRealnameActivation bool + + if hasActiveMain { + status = constants.PackageUsageStatusPending + var maxPriority int + tx.Model(&model.PackageUsage{}). + Where(carrierType+"_id = ?", carrierID). + Select("COALESCE(MAX(priority), 0)"). + Scan(&maxPriority) + priority = maxPriority + 1 + } else { + status = constants.PackageUsageStatusActive + priority = 1 + activatedAt = now + expiresAt = packagepkg.CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays) + nextResetAt = packagepkg.CalculateNextResetTime(pkg.DataResetCycle, pkg.CalendarType, now, activatedAt) + } + + if pkg.EnableRealnameActivation { + status = constants.PackageUsageStatusPending + pendingRealnameActivation = true + } + + usage := &model.PackageUsage{ + BaseModel: model.BaseModel{ + Creator: order.Creator, + Updater: order.Creator, + }, + OrderID: order.ID, + PackageID: pkg.ID, + UsageType: order.OrderType, + DataLimitMB: pkg.RealDataMB, + Status: status, + Priority: priority, + DataResetCycle: pkg.DataResetCycle, + PendingRealnameActivation: pendingRealnameActivation, + Generation: order.Generation, + } + + if carrierType == constants.AssetWalletResourceTypeIotCard { + usage.IotCardID = carrierID + } else { + usage.DeviceID = carrierID + } + + if status == constants.PackageUsageStatusActive { + usage.ActivatedAt = activatedAt + usage.ExpiresAt = expiresAt + usage.NextResetAt = nextResetAt + } + + if err = tx.Omit("status", "pending_realname_activation").Create(usage).Error; err != nil { + return err + } + + return tx.Model(usage).Updates(map[string]any{ + "status": usage.Status, + "pending_realname_activation": usage.PendingRealnameActivation, + }).Error +} + +func (h *AutoPurchaseHandler) activateAddonPackage( + ctx context.Context, + tx *gorm.DB, + order *model.Order, + pkg *model.Package, + carrierType string, + carrierID uint, + now time.Time, +) error { + _ = ctx + var mainPackage model.PackageUsage + err := tx.Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive}). + Where("master_usage_id IS NULL"). + Where(carrierType+"_id = ?", carrierID). + Order("priority ASC"). + First(&mainPackage).Error + if err == gorm.ErrRecordNotFound { + return errors.New("必须有主套餐才能购买加油包") + } + if err != nil { + return err + } + + var maxPriority int + tx.Model(&model.PackageUsage{}). + Where(carrierType+"_id = ?", carrierID). + Select("COALESCE(MAX(priority), 0)"). + Scan(&maxPriority) + + priority := maxPriority + 1 + expiresAt := mainPackage.ExpiresAt + + usage := &model.PackageUsage{ + BaseModel: model.BaseModel{ + Creator: order.Creator, + Updater: order.Creator, + }, + OrderID: order.ID, + PackageID: pkg.ID, + UsageType: order.OrderType, + DataLimitMB: pkg.RealDataMB, + Status: constants.PackageUsageStatusActive, + Priority: priority, + MasterUsageID: &mainPackage.ID, + ActivatedAt: now, + ExpiresAt: expiresAt, + DataResetCycle: pkg.DataResetCycle, + Generation: order.Generation, + } + + if carrierType == constants.AssetWalletResourceTypeIotCard { + usage.IotCardID = carrierID + } else { + usage.DeviceID = carrierID + } + + return tx.Create(usage).Error +} + +func parseLinkedPackageIDs(raw []byte) ([]uint, error) { + var packageIDs []uint + if len(raw) == 0 { + return nil, nil + } + if err := sonic.Unmarshal(raw, &packageIDs); err != nil { + return nil, err + } + return packageIDs, nil +} + +func parseLinkedCarrier(linkedOrderType string, linkedCarrierType string, linkedCarrierID *uint) (string, *uint, *uint, error) { + if linkedCarrierID == nil || *linkedCarrierID == 0 { + return "", nil, nil, errors.New("关联载体ID为空") + } + + if linkedOrderType == model.OrderTypeSingleCard || linkedCarrierType == "card" || linkedCarrierType == constants.AssetWalletResourceTypeIotCard { + id := *linkedCarrierID + return model.OrderTypeSingleCard, &id, nil, nil + } + if linkedOrderType == model.OrderTypeDevice || linkedCarrierType == "device" || linkedCarrierType == constants.AssetWalletResourceTypeDevice { + id := *linkedCarrierID + return model.OrderTypeDevice, nil, &id, nil + } + + return "", nil, nil, errors.New("关联载体类型无效") +} + +func validatePackageTypeMix(packages []*model.Package) error { + hasFormal := false + hasAddon := false + + for _, pkg := range packages { + switch pkg.PackageType { + case constants.PackageTypeFormal: + hasFormal = true + case constants.PackageTypeAddon: + hasAddon = true + } + + if hasFormal && hasAddon { + return errors.New("不允许在同一订单中同时购买正式套餐和加油包") + } + } + + return nil +} diff --git a/migrations/000084_add_auto_purchase_status_to_asset_recharge_record.down.sql b/migrations/000084_add_auto_purchase_status_to_asset_recharge_record.down.sql new file mode 100644 index 0000000..5eaaa8e --- /dev/null +++ b/migrations/000084_add_auto_purchase_status_to_asset_recharge_record.down.sql @@ -0,0 +1,3 @@ +-- 回滚 tb_asset_recharge_record 的 auto_purchase_status 列 +ALTER TABLE tb_asset_recharge_record +DROP COLUMN IF EXISTS auto_purchase_status; diff --git a/migrations/000084_add_auto_purchase_status_to_asset_recharge_record.up.sql b/migrations/000084_add_auto_purchase_status_to_asset_recharge_record.up.sql new file mode 100644 index 0000000..e97da18 --- /dev/null +++ b/migrations/000084_add_auto_purchase_status_to_asset_recharge_record.up.sql @@ -0,0 +1,5 @@ +-- tb_asset_recharge_record 新增 auto_purchase_status 列 +ALTER TABLE tb_asset_recharge_record +ADD COLUMN auto_purchase_status VARCHAR(20) DEFAULT '' NOT NULL; + +COMMENT ON COLUMN tb_asset_recharge_record.auto_purchase_status IS '强充自动代购状态(pending-待处理 success-成功 failed-失败)'; diff --git a/opencode.json b/opencode.json index 1e8450d..e4c0434 100644 --- a/opencode.json +++ b/opencode.json @@ -21,21 +21,15 @@ "enabled": true, "timeout": 10000 }, - "postgres": { + "dbhub": { "type": "local", "command": [ - "docker", - "run", - "-i", - "--rm", - "-e", - "DATABASE_URI", - "crystaldba/postgres-mcp", - "--access-mode=restricted" - ], - "environment": { - "DATABASE_URI": "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable" - } + "npx", + "-y", + "@bytebase/dbhub@latest", + "--transport", "stdio", + "--config", ".config/dbhub.toml" + ] } } }