From e78f5794b99d895588b23fa4c9d6bb3b161cfcec Mon Sep 17 00:00:00 2001 From: huang Date: Thu, 19 Mar 2026 13:26:54 +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=8D=A2=E8=B4=A7=E7=B3=BB=E7=BB=9F=EF=BC=88client-ex?= =?UTF-8?q?change-system=EF=BC=89?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增完整换货生命周期管理:后台发起 → 客户端填收货信息 → 后台发货 → 确认完成(含可选全量迁移) → 旧资产转新再销售 后台接口(7个): - POST /api/admin/exchanges(发起换货) - GET /api/admin/exchanges(换货列表) - GET /api/admin/exchanges/:id(换货详情) - POST /api/admin/exchanges/:id/ship(发货) - POST /api/admin/exchanges/:id/complete(确认完成+可选迁移) - POST /api/admin/exchanges/:id/cancel(取消) - POST /api/admin/exchanges/:id/renew(旧资产转新) 客户端接口(2个): - GET /api/c/v1/exchange/pending(查询换货通知) - POST /api/c/v1/exchange/:id/shipping-info(填写收货信息) 核心能力: - ExchangeOrder 模型与状态机(1待填写→2待发货→3已发货→4已完成,1/2可取消→5) - 全量迁移事务(11张表:钱包、套餐、标签、客户绑定等) - 旧资产转新(generation+1、状态重置、新钱包、历史隔离) - 旧 CardReplacementRecord 表改名为 legacy,is_replaced 过滤改为查新表 - 数据库迁移:000085 新建 tb_exchange_order,000086 旧表改名 --- cmd/api/docs.go | 7 + cmd/gendocs/main.go | 7 + docs/admin-openapi.yaml | 2731 +++++++++++++++++ docs/client-exchange-system/功能总结.md | 94 + internal/bootstrap/handlers.go | 37 + internal/bootstrap/services.go | 3 + internal/bootstrap/stores.go | 4 + internal/bootstrap/types.go | 7 + internal/handler/admin/exchange.go | 131 + internal/handler/app/client_exchange.go | 57 + internal/model/asset_wallet.go | 1 + internal/model/dto/exchange_dto.go | 104 + internal/model/exchange_order.go | 65 + internal/routes/admin.go | 3 + internal/routes/exchange.go | 66 + internal/routes/personal.go | 160 + internal/service/exchange/migration.go | 243 ++ internal/service/exchange/service.go | 487 +++ .../store/postgres/exchange_order_store.go | 106 + internal/store/postgres/iot_card_store.go | 18 +- internal/store/postgres/resource_tag_store.go | 30 + migrations/000085_add_exchange_order.down.sql | 1 + migrations/000085_add_exchange_order.up.sql | 75 + ...rename_card_replacement_to_legacy.down.sql | 1 + ...6_rename_card_replacement_to_legacy.up.sql | 1 + .../client-exchange-system/.openspec.yaml | 2 + .../changes/client-exchange-system/design.md | 149 + .../client-exchange-system/proposal.md | 162 + .../specs/card-replacement/spec.md | 31 + .../specs/device/spec.md | 24 + .../specs/exchange-admin-management/spec.md | 121 + .../exchange-client-notification/spec.md | 35 + .../specs/exchange-data-migration/spec.md | 60 + .../specs/exchange-order-model/spec.md | 69 + .../specs/iot-card/spec.md | 23 + .../specs/personal-customer/spec.md | 21 + .../changes/client-exchange-system/tasks.md | 37 + pkg/constants/constants.go | 25 +- pkg/constants/redis.go | 18 + pkg/errors/codes.go | 29 + pkg/openapi/handlers.go | 7 + 41 files changed, 5242 insertions(+), 10 deletions(-) create mode 100644 docs/client-exchange-system/功能总结.md create mode 100644 internal/handler/admin/exchange.go create mode 100644 internal/handler/app/client_exchange.go create mode 100644 internal/model/dto/exchange_dto.go create mode 100644 internal/model/exchange_order.go create mode 100644 internal/routes/exchange.go create mode 100644 internal/service/exchange/migration.go create mode 100644 internal/service/exchange/service.go create mode 100644 internal/store/postgres/exchange_order_store.go create mode 100644 internal/store/postgres/resource_tag_store.go create mode 100644 migrations/000085_add_exchange_order.down.sql create mode 100644 migrations/000085_add_exchange_order.up.sql create mode 100644 migrations/000086_rename_card_replacement_to_legacy.down.sql create mode 100644 migrations/000086_rename_card_replacement_to_legacy.up.sql create mode 100644 openspec/changes/client-exchange-system/.openspec.yaml create mode 100644 openspec/changes/client-exchange-system/design.md create mode 100644 openspec/changes/client-exchange-system/proposal.md create mode 100644 openspec/changes/client-exchange-system/specs/card-replacement/spec.md create mode 100644 openspec/changes/client-exchange-system/specs/device/spec.md create mode 100644 openspec/changes/client-exchange-system/specs/exchange-admin-management/spec.md create mode 100644 openspec/changes/client-exchange-system/specs/exchange-client-notification/spec.md create mode 100644 openspec/changes/client-exchange-system/specs/exchange-data-migration/spec.md create mode 100644 openspec/changes/client-exchange-system/specs/exchange-order-model/spec.md create mode 100644 openspec/changes/client-exchange-system/specs/iot-card/spec.md create mode 100644 openspec/changes/client-exchange-system/specs/personal-customer/spec.md create mode 100644 openspec/changes/client-exchange-system/tasks.md diff --git a/cmd/api/docs.go b/cmd/api/docs.go index 7828c8f..c3bb819 100644 --- a/cmd/api/docs.go +++ b/cmd/api/docs.go @@ -26,6 +26,13 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) { handlers := openapi.BuildDocHandlers() handlers.AssetLifecycle = admin.NewAssetLifecycleHandler(nil) handlers.ClientAuth = apphandler.NewClientAuthHandler(nil, nil) + handlers.ClientAsset = apphandler.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil) + handlers.ClientWallet = apphandler.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + handlers.ClientOrder = apphandler.NewClientOrderHandler(nil, nil, nil, nil, nil, nil, nil, nil) + handlers.ClientExchange = apphandler.NewClientExchangeHandler(nil) + handlers.ClientRealname = apphandler.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil) + handlers.ClientDevice = apphandler.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil) + handlers.AdminExchange = admin.NewExchangeHandler(nil, nil) // 4. 注册所有路由到文档生成器 routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc) diff --git a/cmd/gendocs/main.go b/cmd/gendocs/main.go index 13d9165..fcad934 100644 --- a/cmd/gendocs/main.go +++ b/cmd/gendocs/main.go @@ -35,6 +35,13 @@ func generateAdminDocs(outputPath string) error { handlers := openapi.BuildDocHandlers() handlers.AssetLifecycle = admin.NewAssetLifecycleHandler(nil) handlers.ClientAuth = apphandler.NewClientAuthHandler(nil, nil) + handlers.ClientAsset = apphandler.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil) + handlers.ClientWallet = apphandler.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil) + handlers.ClientOrder = apphandler.NewClientOrderHandler(nil, nil, nil, nil, nil, nil, nil, nil) + handlers.ClientExchange = apphandler.NewClientExchangeHandler(nil) + handlers.ClientRealname = apphandler.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil) + handlers.ClientDevice = apphandler.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil) + handlers.AdminExchange = admin.NewExchangeHandler(nil, nil) // 4. 注册所有路由到文档生成器 routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc) diff --git a/docs/admin-openapi.yaml b/docs/admin-openapi.yaml index f7e0970..936c40a 100644 --- a/docs/admin-openapi.yaml +++ b/docs/admin-openapi.yaml @@ -579,6 +579,64 @@ components: description: 目标所有者类型 type: string type: object + DtoAssetInfoResponse: + properties: + asset_id: + description: 资产ID + minimum: 0 + type: integer + asset_type: + description: 资产类型 (card:卡, device:设备) + type: string + carrier_name: + description: 运营商名称 + type: string + generation: + description: 制式 + type: string + identifier: + description: 资产标识符 + type: string + real_name_status: + description: 实名状态 (0:未实名, 1:已实名) + type: integer + status: + description: 状态 (0:禁用, 1:启用) + type: integer + virtual_no: + description: 虚拟号 + type: string + wallet_balance: + description: 钱包余额(分) + type: integer + type: object + DtoAssetPackageHistoryResponse: + properties: + list: + description: 套餐历史列表 + items: + $ref: '#/components/schemas/DtoAssetPackageResponse' + nullable: true + type: array + page: + description: 页码 + type: integer + page_size: + description: 每页数量 + type: integer + total: + description: 总数 + type: integer + type: object + DtoAssetPackageListResponse: + properties: + packages: + description: 套餐列表 + items: + $ref: '#/components/schemas/DtoClientPackageItem' + nullable: true + type: array + type: object DtoAssetPackageResponse: properties: activated_at: @@ -677,6 +735,28 @@ components: description: 实名状态(asset_type=card时有效) type: integer type: object + DtoAssetRefreshRequest: + properties: + identifier: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + required: + - identifier + type: object + DtoAssetRefreshResponse: + properties: + accepted: + description: 是否已受理 + type: boolean + cooldown_seconds: + description: 冷却秒数 + type: integer + refresh_type: + description: 刷新类型 (card:卡, device:设备) + type: string + type: object DtoAssetResolveResponse: properties: accumulated_recharge: @@ -1262,6 +1342,18 @@ components: minimum: 0 type: integer type: object + DtoCardInfoBrief: + properties: + iccid: + description: 物联网卡ICCID + type: string + msisdn: + description: 手机号 + type: string + virtual_no: + description: 虚拟号 + type: string + type: object DtoCardSeriesBindngFailedItem: properties: iccid: @@ -1370,6 +1462,388 @@ components: description: 换绑后手机号 type: string type: object + DtoClientCreateOrderRequest: + properties: + app_type: + description: 应用类型 (official_account:公众号, miniapp:小程序) + type: string + identifier: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + package_ids: + description: 套餐ID列表 + items: + minimum: 0 + type: integer + nullable: true + type: array + required: + - identifier + - package_ids + - app_type + type: object + DtoClientCreateOrderResponse: + properties: + linked_package_info: + $ref: '#/components/schemas/DtoLinkedPackageInfo' + order: + $ref: '#/components/schemas/DtoClientOrderInfo' + order_type: + description: 订单类型 (package:套餐订单, recharge:充值订单) + type: string + pay_config: + $ref: '#/components/schemas/DtoClientPayConfig' + recharge: + $ref: '#/components/schemas/DtoClientRechargeInfo' + type: object + DtoClientCreateRechargeRequest: + properties: + amount: + description: 充值金额(分) + maximum: 1e+07 + minimum: 100 + type: integer + app_type: + description: 应用类型 (official_account:公众号, miniapp:小程序) + type: string + identifier: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + payment_method: + description: 支付方式 (wechat:微信支付) + type: string + required: + - identifier + - amount + - payment_method + - app_type + type: object + DtoClientExchangePendingResponse: + properties: + created_at: + description: 创建时间 + format: date-time + type: string + exchange_no: + description: 换货单号 + type: string + exchange_reason: + description: 换货原因 + type: string + id: + description: 换货单ID + minimum: 0 + type: integer + status: + description: 换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消) + type: integer + status_text: + description: 换货状态文本 + type: string + type: object + DtoClientOrderDetailResponse: + properties: + completed_at: + description: 完成时间 + nullable: true + type: string + created_at: + description: 创建时间 + type: string + order_id: + description: 订单ID + minimum: 0 + type: integer + order_no: + description: 订单号 + type: string + packages: + description: 订单套餐列表 + items: + $ref: '#/components/schemas/DtoClientOrderPackageItem' + nullable: true + type: array + paid_at: + description: 支付时间 + nullable: true + type: string + payment_method: + description: 支付方式 + type: string + payment_status: + description: 支付状态 (0:待支付, 1:已支付, 2:已取消) + type: integer + total_amount: + description: 订单总金额(分) + type: integer + type: object + DtoClientOrderInfo: + properties: + created_at: + description: 创建时间 + type: string + order_id: + description: 订单ID + minimum: 0 + type: integer + order_no: + description: 订单号 + type: string + payment_status: + description: 支付状态 (0:待支付, 1:已支付, 2:已取消) + type: integer + total_amount: + description: 订单总金额(分) + type: integer + type: object + DtoClientOrderListItem: + properties: + created_at: + description: 创建时间 + type: string + order_id: + description: 订单ID + minimum: 0 + type: integer + order_no: + description: 订单号 + type: string + package_names: + description: 套餐名称列表 + items: + type: string + nullable: true + type: array + payment_status: + description: 支付状态 (0:待支付, 1:已支付, 2:已取消) + type: integer + total_amount: + description: 订单总金额(分) + type: integer + type: object + DtoClientOrderListResponse: + properties: + list: + description: 订单列表 + items: + $ref: '#/components/schemas/DtoClientOrderListItem' + nullable: true + type: array + page: + description: 页码 + type: integer + page_size: + description: 每页数量 + type: integer + total: + description: 总数 + type: integer + type: object + DtoClientOrderPackageItem: + properties: + package_id: + description: 套餐ID + minimum: 0 + type: integer + package_name: + description: 套餐名称 + type: string + package_type: + description: 套餐类型 (formal:正式套餐, addon:加油包) + type: string + price: + description: 单价(分) + type: integer + quantity: + description: 数量 + type: integer + type: object + DtoClientPackageItem: + properties: + cost_price: + description: 成本价(分) + type: integer + data_allowance: + description: 流量额度 + type: integer + data_unit: + description: 流量单位 + type: string + description: + description: 套餐说明 + type: string + is_addon: + description: 是否加油包 + type: boolean + package_id: + description: 套餐ID + minimum: 0 + type: integer + package_name: + description: 套餐名称 + type: string + package_type: + description: 套餐类型 (formal:正式套餐, addon:加油包) + type: string + retail_price: + description: 零售价(分) + type: integer + validity_days: + description: 有效天数 + type: integer + type: object + DtoClientPayConfig: + properties: + app_id: + description: 应用ID + type: string + nonce_str: + description: 随机字符串 + type: string + package: + description: 预支付参数 + type: string + pay_sign: + description: 支付签名 + type: string + sign_type: + description: 签名类型 + type: string + timestamp: + description: 时间戳 + type: string + type: object + DtoClientRechargeCheckResponse: + properties: + force_recharge_amount: + description: 强制充值金额(分) + type: integer + max_amount: + description: 最大充值金额(分) + type: integer + message: + description: 提示信息 + type: string + min_amount: + description: 最小充值金额(分) + type: integer + need_force_recharge: + description: 是否需要强制充值 + type: boolean + trigger_type: + description: 触发类型 + type: string + type: object + DtoClientRechargeInfo: + properties: + amount: + description: 充值金额(分) + type: integer + auto_purchase_status: + description: 自动购包状态 + type: string + recharge_id: + description: 充值ID + minimum: 0 + type: integer + recharge_no: + description: 充值单号 + type: string + status: + description: 状态 (0:待支付, 1:已支付, 2:已关闭) + type: integer + type: object + DtoClientRechargeListItem: + properties: + amount: + description: 充值金额(分) + type: integer + auto_purchase_status: + description: 自动购包状态 + type: string + created_at: + description: 创建时间 + type: string + payment_method: + description: 支付方式 + type: string + recharge_id: + description: 充值ID + minimum: 0 + type: integer + recharge_no: + description: 充值单号 + type: string + status: + description: 状态 (0:待支付, 1:已支付, 2:已关闭) + type: integer + type: object + DtoClientRechargeListResponse: + properties: + list: + description: 充值记录列表 + items: + $ref: '#/components/schemas/DtoClientRechargeListItem' + nullable: true + type: array + page: + description: 页码 + type: integer + page_size: + description: 每页数量 + type: integer + total: + description: 总数 + type: integer + type: object + DtoClientRechargePayConfig: + properties: + app_id: + description: 应用ID + type: string + nonce_str: + description: 随机字符串 + type: string + package: + description: 预支付参数 + type: string + pay_sign: + description: 支付签名 + type: string + sign_type: + description: 签名类型 + type: string + timestamp: + description: 时间戳 + type: string + type: object + DtoClientRechargeResponse: + properties: + pay_config: + $ref: '#/components/schemas/DtoClientRechargePayConfig' + recharge: + $ref: '#/components/schemas/DtoClientRechargeResult' + type: object + DtoClientRechargeResult: + properties: + amount: + description: 充值金额(分) + type: integer + recharge_id: + description: 充值ID + minimum: 0 + type: integer + recharge_no: + description: 充值单号 + type: string + status: + description: 状态 (0:待支付, 1:已支付, 2:已关闭) + type: integer + type: object DtoClientSendCodeRequest: properties: phone: @@ -1390,6 +1864,28 @@ components: description: 冷却秒数 type: integer type: object + DtoClientShippingInfoParams: + properties: + recipient_address: + description: 收货地址 + maxLength: 500 + minLength: 1 + type: string + recipient_name: + description: 收件人姓名 + maxLength: 50 + minLength: 1 + type: string + recipient_phone: + description: 收件人电话 + maxLength: 20 + minLength: 1 + type: string + required: + - recipient_name + - recipient_phone + - recipient_address + type: object DtoCommissionStatsResponse: properties: cost_diff_amount: @@ -1607,6 +2103,31 @@ components: enterprise: $ref: '#/components/schemas/DtoEnterpriseItem' type: object + DtoCreateExchangeRequest: + properties: + exchange_reason: + description: 换货原因 + maxLength: 100 + minLength: 1 + type: string + old_asset_type: + description: 旧资产类型 (iot_card:物联网卡, device:设备) + type: string + old_identifier: + description: 旧资产标识符(ICCID/虚拟号/IMEI/SN) + maxLength: 100 + minLength: 1 + type: string + remark: + description: 备注 + maxLength: 500 + nullable: true + type: string + required: + - old_asset_type + - old_identifier + - exchange_reason + type: object DtoCreateMyWithdrawalReq: properties: account_name: @@ -2344,6 +2865,53 @@ components: description: 卡状态 (1:在库, 2:已分销, 3:已激活, 4:已停用) type: integer type: object + DtoDeviceCardItem: + properties: + card_id: + description: 卡ID + minimum: 0 + type: integer + carrier_name: + description: 运营商名称 + type: string + iccid: + description: 物联网卡ICCID + type: string + is_active: + description: 是否当前激活卡 + type: boolean + msisdn: + description: 手机号 + type: string + network_status: + description: 网络状态 + type: string + real_name_status: + description: 实名状态 (0:未实名, 1:已实名) + type: integer + slot_position: + description: 插槽位置 + type: integer + type: object + DtoDeviceCardListResponse: + properties: + cards: + description: 设备卡列表 + items: + $ref: '#/components/schemas/DtoDeviceCardItem' + nullable: true + type: array + type: object + DtoDeviceFactoryResetRequest: + properties: + identifier: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + required: + - identifier + type: object DtoDeviceImportResultItemDTO: properties: line: @@ -2482,6 +3050,25 @@ components: description: 警告数(部分成功的设备数量) type: integer type: object + DtoDeviceOperationResponse: + properties: + accepted: + description: 是否已受理 + type: boolean + request_id: + description: 请求ID + type: string + type: object + DtoDeviceRebootRequest: + properties: + identifier: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + required: + - identifier + type: object DtoDeviceResponse: properties: accumulated_recharge: @@ -2599,6 +3186,57 @@ components: description: 成功停机卡数 type: integer type: object + DtoDeviceSwitchCardRequest: + properties: + identifier: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + target_iccid: + description: 目标ICCID + maxLength: 30 + minLength: 1 + type: string + required: + - identifier + - target_iccid + type: object + DtoDeviceSwitchCardResponse: + properties: + accepted: + description: 是否已受理 + type: boolean + target_iccid: + description: 目标ICCID + type: string + type: object + DtoDeviceWifiRequest: + properties: + enabled: + description: 是否启用WiFi + type: boolean + identifier: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + password: + description: WiFi密码 + maxLength: 64 + minLength: 1 + type: string + ssid: + description: WiFi名称 + maxLength: 32 + minLength: 1 + type: string + required: + - identifier + - ssid + - password + - enabled + type: object DtoEmptyResponse: properties: message: @@ -2781,6 +3419,152 @@ components: description: 总记录数 type: integer type: object + DtoExchangeCancelParams: + properties: + remark: + description: 取消备注 + maxLength: 500 + nullable: true + type: string + type: object + DtoExchangeListResponse: + properties: + list: + description: 换货单列表 + items: + $ref: '#/components/schemas/DtoExchangeOrderResponse' + nullable: true + type: array + page: + description: 当前页码 + type: integer + page_size: + description: 每页数量 + type: integer + total: + description: 总数 + type: integer + type: object + DtoExchangeOrderResponse: + properties: + created_at: + description: 创建时间 + format: date-time + type: string + creator: + description: 创建人ID + minimum: 0 + type: integer + deleted_at: + description: 删除时间 + format: date-time + nullable: true + type: string + exchange_no: + description: 换货单号 + type: string + exchange_reason: + description: 换货原因 + type: string + express_company: + description: 快递公司 + type: string + express_no: + description: 快递单号 + type: string + id: + description: 换货单ID + minimum: 0 + type: integer + migrate_data: + description: 是否执行全量迁移 + type: boolean + migration_balance: + description: 迁移转移金额(分) + type: integer + migration_completed: + description: 迁移是否已完成 + type: boolean + new_asset_id: + description: 新资产ID + minimum: 0 + nullable: true + type: integer + new_asset_identifier: + description: 新资产标识符 + type: string + new_asset_type: + description: 新资产类型 (iot_card:物联网卡, device:设备) + type: string + old_asset_id: + description: 旧资产ID + minimum: 0 + type: integer + old_asset_identifier: + description: 旧资产标识符 + type: string + old_asset_type: + description: 旧资产类型 (iot_card:物联网卡, device:设备) + type: string + recipient_address: + description: 收货地址 + type: string + recipient_name: + description: 收件人姓名 + type: string + recipient_phone: + description: 收件人电话 + type: string + remark: + description: 备注 + nullable: true + type: string + shop_id: + description: 所属店铺ID + minimum: 0 + nullable: true + type: integer + status: + description: 换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消) + type: integer + status_text: + description: 换货状态文本 + type: string + updated_at: + description: 更新时间 + format: date-time + type: string + updater: + description: 更新人ID + minimum: 0 + type: integer + type: object + DtoExchangeShipParams: + properties: + express_company: + description: 快递公司 + maxLength: 100 + minLength: 1 + type: string + express_no: + description: 快递单号 + maxLength: 100 + minLength: 1 + type: string + migrate_data: + description: 是否执行全量迁移 (true:执行, false:不执行) + type: boolean + new_identifier: + description: 新资产标识符(ICCID/虚拟号/IMEI/SN) + maxLength: 100 + minLength: 1 + type: string + required: + - express_company + - express_no + - new_identifier + - migrate_data + type: object DtoFailedDeviceItem: properties: reason: @@ -3069,6 +3853,24 @@ components: description: 总数 type: integer type: object + DtoLinkedPackageInfo: + properties: + force_recharge_amount: + description: 强制充值金额(分) + type: integer + package_names: + description: 套餐名称列表 + items: + type: string + nullable: true + type: array + total_package_amount: + description: 套餐总金额(分) + type: integer + wallet_credit: + description: 钱包抵扣金额(分) + type: integer + type: object DtoListAssetAllocationRecordResponse: properties: list: @@ -4284,6 +5086,21 @@ components: description: 钱包到账金额(分) type: integer type: object + DtoRealnimeLinkResponse: + properties: + card_info: + $ref: '#/components/schemas/DtoCardInfoBrief' + expire_at: + description: 过期时间 + nullable: true + type: string + realname_mode: + description: 实名模式 (none:无需实名, template:模板实名, gateway:网关实名) + type: string + realname_url: + description: 实名链接 + type: string + type: object DtoRecallCardsReq: properties: iccids: @@ -5947,6 +6764,69 @@ components: description: 过期时间(秒) type: integer type: object + DtoWalletDetailResponse: + properties: + balance: + description: 可用余额(分) + type: integer + frozen_balance: + description: 冻结余额(分) + type: integer + resource_id: + description: 资源ID + minimum: 0 + type: integer + resource_type: + description: 资源类型 (iot_card:物联网卡, device:设备) + type: string + updated_at: + description: 更新时间 + type: string + wallet_id: + description: 钱包ID + minimum: 0 + type: integer + type: object + DtoWalletTransactionItem: + properties: + amount: + description: 变动金额(分) + type: integer + balance_after: + description: 变动后余额(分) + type: integer + created_at: + description: 创建时间 + type: string + remark: + description: 备注 + type: string + transaction_id: + description: 流水ID + minimum: 0 + type: integer + type: + description: 流水类型 + type: string + type: object + DtoWalletTransactionListResponse: + properties: + list: + description: 流水列表 + items: + $ref: '#/components/schemas/DtoWalletTransactionItem' + nullable: true + type: array + page: + description: 页码 + type: integer + page_size: + description: 每页数量 + type: integer + total: + description: 总数 + type: integer + type: object DtoWechatConfigListResponse: properties: list: @@ -11971,6 +12851,441 @@ paths: summary: 启用/禁用企业 tags: - 企业客户管理 + /api/admin/exchanges: + get: + parameters: + - description: 页码 + in: query + name: page + schema: + description: 页码 + minimum: 1 + type: integer + - description: 每页数量 + in: query + name: page_size + schema: + description: 每页数量 + maximum: 100 + minimum: 1 + type: integer + - description: 换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消) + in: query + name: status + schema: + description: 换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消) + maximum: 5 + minimum: 1 + nullable: true + type: integer + - description: 资产标识符搜索(旧资产/新资产标识符模糊匹配) + in: query + name: identifier + schema: + description: 资产标识符搜索(旧资产/新资产标识符模糊匹配) + maxLength: 100 + type: string + - description: 创建时间起始 + in: query + name: created_at_start + schema: + description: 创建时间起始 + format: date-time + nullable: true + type: string + - description: 创建时间结束 + in: query + name: created_at_end + schema: + description: 创建时间结束 + format: date-time + nullable: true + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoExchangeListResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 获取换货单列表 + tags: + - 换货管理 + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoCreateExchangeRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoExchangeOrderResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 创建换货单 + tags: + - 换货管理 + /api/admin/exchanges/{id}: + get: + parameters: + - description: 换货单ID + in: path + name: id + required: true + schema: + description: 换货单ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoExchangeOrderResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 获取换货单详情 + tags: + - 换货管理 + /api/admin/exchanges/{id}/cancel: + post: + parameters: + - description: 换货单ID + in: path + name: id + required: true + schema: + description: 换货单ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoExchangeCancelParams' + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 取消换货 + tags: + - 换货管理 + /api/admin/exchanges/{id}/complete: + post: + parameters: + - description: 换货单ID + in: path + name: id + required: true + schema: + description: 换货单ID + minimum: 0 + type: integer + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 确认换货完成 + tags: + - 换货管理 + /api/admin/exchanges/{id}/renew: + post: + parameters: + - description: 换货单ID + in: path + name: id + required: true + schema: + description: 换货单ID + minimum: 0 + type: integer + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 旧资产转新 + tags: + - 换货管理 + /api/admin/exchanges/{id}/ship: + post: + parameters: + - description: 换货单ID + in: path + name: id + required: true + schema: + description: 换货单ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoExchangeShipParams' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoExchangeOrderResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 换货发货 + tags: + - 换货管理 /api/admin/iot-cards/{iccid}/realname-link: get: parameters: @@ -19179,6 +20494,290 @@ paths: summary: 刷新 Token tags: - 统一认证 + /api/c/v1/asset/info: + get: + parameters: + - description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + in: query + name: identifier + required: true + schema: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAssetInfoResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 资产信息 + tags: + - 个人客户 - 资产 + /api/c/v1/asset/package-history: + get: + parameters: + - description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + in: query + name: identifier + required: true + schema: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + - description: 页码 + in: query + name: page + required: true + schema: + description: 页码 + minimum: 1 + type: integer + - description: 每页数量 + in: query + name: page_size + required: true + schema: + description: 每页数量 + maximum: 100 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAssetPackageHistoryResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 资产套餐历史 + tags: + - 个人客户 - 资产 + /api/c/v1/asset/packages: + get: + parameters: + - description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + in: query + name: identifier + required: true + schema: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAssetPackageListResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 资产可购套餐列表 + tags: + - 个人客户 - 资产 + /api/c/v1/asset/refresh: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoAssetRefreshRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoAssetRefreshResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 资产刷新 + tags: + - 个人客户 - 资产 /api/c/v1/auth/bind-phone: post: requestBody: @@ -19559,6 +21158,664 @@ paths: summary: 公众号登录 tags: - 个人客户 - 认证 + /api/c/v1/device/cards: + get: + parameters: + - description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + in: query + name: identifier + required: true + schema: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoDeviceCardListResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 获取设备卡列表 + tags: + - 个人客户 - 设备 + /api/c/v1/device/factory-reset: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoDeviceFactoryResetRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoDeviceOperationResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 恢复出厂设置 + tags: + - 个人客户 - 设备 + /api/c/v1/device/reboot: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoDeviceRebootRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoDeviceOperationResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 设备重启 + tags: + - 个人客户 - 设备 + /api/c/v1/device/switch-card: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoDeviceSwitchCardRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoDeviceSwitchCardResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 设备切卡 + tags: + - 个人客户 - 设备 + /api/c/v1/device/wifi: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoDeviceWifiRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoDeviceOperationResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 设备WiFi配置 + tags: + - 个人客户 - 设备 + /api/c/v1/exchange/{id}/shipping-info: + post: + parameters: + - description: 换货单ID + in: path + name: id + required: true + schema: + description: 换货单ID + minimum: 0 + type: integer + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoClientShippingInfoParams' + responses: + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 提交收货信息 + tags: + - 个人客户 - 换货 + /api/c/v1/exchange/pending: + get: + parameters: + - description: 资产标识符(ICCID/虚拟号/IMEI/SN) + in: query + name: identifier + required: true + schema: + description: 资产标识符(ICCID/虚拟号/IMEI/SN) + maxLength: 100 + minLength: 1 + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoClientExchangePendingResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 查询待处理换货单 + tags: + - 个人客户 - 换货 + /api/c/v1/orders: + get: + parameters: + - description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + in: query + name: identifier + required: true + schema: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + - description: 支付状态 (0:待支付, 1:已支付, 2:已取消) + in: query + name: payment_status + schema: + description: 支付状态 (0:待支付, 1:已支付, 2:已取消) + maximum: 2 + minimum: 0 + nullable: true + type: integer + - description: 页码 + in: query + name: page + required: true + schema: + description: 页码 + minimum: 1 + type: integer + - description: 每页数量 + in: query + name: page_size + required: true + schema: + description: 每页数量 + maximum: 100 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoClientOrderListResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 订单列表 + tags: + - 个人客户 - 订单 + /api/c/v1/orders/{id}: + get: + parameters: + - description: ID + in: path + name: id + required: true + schema: + description: ID + minimum: 0 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoClientOrderDetailResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 订单详情 + tags: + - 个人客户 - 订单 + /api/c/v1/orders/create: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoClientCreateOrderRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoClientCreateOrderResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 创建订单 + tags: + - 个人客户 - 订单 /api/c/v1/profile: get: description: 获取当前登录客户的个人资料 @@ -19655,6 +21912,480 @@ paths: summary: 更新个人资料 tags: - 个人客户 - 账户 + /api/c/v1/realname/link: + get: + parameters: + - description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + in: query + name: identifier + required: true + schema: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + - description: 物联网卡ICCID + in: query + name: iccid + schema: + description: 物联网卡ICCID + maxLength: 30 + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoRealnimeLinkResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 获取实名认证链接 + tags: + - 个人客户 - 实名 + /api/c/v1/wallet/detail: + get: + parameters: + - description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + in: query + name: identifier + required: true + schema: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWalletDetailResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 钱包详情 + tags: + - 个人客户 - 钱包 + /api/c/v1/wallet/recharge: + post: + requestBody: + content: + application/json: + schema: + $ref: '#/components/schemas/DtoClientCreateRechargeRequest' + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoClientRechargeResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 创建充值订单 + tags: + - 个人客户 - 钱包 + /api/c/v1/wallet/recharge-check: + get: + parameters: + - description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + in: query + name: identifier + required: true + schema: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoClientRechargeCheckResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 充值前校验 + tags: + - 个人客户 - 钱包 + /api/c/v1/wallet/recharges: + get: + parameters: + - description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + in: query + name: identifier + required: true + schema: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + - description: 充值状态 (0:待支付, 1:已支付, 2:已关闭) + in: query + name: status + schema: + description: 充值状态 (0:待支付, 1:已支付, 2:已关闭) + maximum: 2 + minimum: 0 + nullable: true + type: integer + - description: 页码 + in: query + name: page + required: true + schema: + description: 页码 + minimum: 1 + type: integer + - description: 每页数量 + in: query + name: page_size + required: true + schema: + description: 每页数量 + maximum: 100 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoClientRechargeListResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 充值记录列表 + tags: + - 个人客户 - 钱包 + /api/c/v1/wallet/transactions: + get: + parameters: + - description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + in: query + name: identifier + required: true + schema: + description: 资产标识符(SN/IMEI/虚拟号/ICCID/MSISDN) + maxLength: 50 + minLength: 1 + type: string + - description: 流水类型 + in: query + name: transaction_type + schema: + description: 流水类型 + maxLength: 50 + type: string + - description: 开始时间 + in: query + name: start_time + schema: + description: 开始时间 + maxLength: 32 + type: string + - description: 结束时间 + in: query + name: end_time + schema: + description: 结束时间 + maxLength: 32 + type: string + - description: 页码 + in: query + name: page + required: true + schema: + description: 页码 + minimum: 1 + type: integer + - description: 每页数量 + in: query + name: page_size + required: true + schema: + description: 每页数量 + maximum: 100 + minimum: 1 + type: integer + responses: + "200": + content: + application/json: + schema: + properties: + code: + description: 响应码 + example: 0 + type: integer + data: + $ref: '#/components/schemas/DtoWalletTransactionListResponse' + msg: + description: 响应消息 + example: success + type: string + timestamp: + description: 时间戳 + format: date-time + type: string + required: + - code + - msg + - data + - timestamp + type: object + description: 成功 + "400": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 请求参数错误 + "401": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 未认证或认证已过期 + "403": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 无权访问 + "500": + content: + application/json: + schema: + $ref: '#/components/schemas/ErrorResponse' + description: 服务器内部错误 + security: + - BearerAuth: [] + summary: 钱包流水列表 + tags: + - 个人客户 - 钱包 /api/callback/alipay: post: responses: diff --git a/docs/client-exchange-system/功能总结.md b/docs/client-exchange-system/功能总结.md new file mode 100644 index 0000000..8b0d1aa --- /dev/null +++ b/docs/client-exchange-system/功能总结.md @@ -0,0 +1,94 @@ +# 客户端换货系统功能总结 + +## 1. 功能概述 + +本次实现完成了客户端换货系统的后台与客户端闭环能力,覆盖「后台建单 → 客户端填写收货信息 → 后台发货 → 后台确认完成(可选全量迁移) → 旧资产转新」完整流程。 + +## 2. 数据模型与迁移 + +- 新增 `tb_exchange_order` 表,承载换货生命周期全量字段:旧/新资产、收货信息、物流信息、迁移状态、业务状态、多租户字段。 +- 保留历史能力:将旧表 `tb_card_replacement_record` 重命名为 `tb_card_replacement_record_legacy`。 +- 新增迁移文件: + - `000085_add_exchange_order.up/down.sql` + - `000086_rename_card_replacement_to_legacy.up/down.sql` + +## 3. 后端实现 + +### 3.1 Store 层 + +- 新增 `ExchangeOrderStore`: + - 创建、按 ID 查询、分页列表查询 + - 条件状态流转更新(`WHERE status = fromStatus`) + - 按旧资产查询进行中换货单(状态 `1/2/3`) + +- 新增 `ResourceTagStore`:用于资源标签复制。 + +### 3.2 Service 层 + +- 新增 `internal/service/exchange/service.go`: + - H1 创建换货单(资产存在校验、进行中校验、单号生成、状态初始化) + - H2 列表查询 + - H3 详情查询 + - H4 发货(状态校验、同类型校验、新资产在库校验、物流与新资产快照写入) + - H5 确认完成(状态校验,可选全量迁移) + - H6 取消(仅允许 `1/2 -> 5`) + - H7 转新(校验已换货状态、`generation+1`、状态重置、清理绑定、创建新钱包) + - G1 查询待处理换货单 + - G2 提交收货信息(`1 -> 2`) + +- 新增 `internal/service/exchange/migration.go`: + - 单事务迁移实现 + - 钱包余额迁移并写入迁移流水 + - 套餐使用记录迁移(`tb_package_usage`) + - 套餐日记录联动更新(`tb_package_usage_daily_record`) + - 累计充值/首充字段复制(旧资产 -> 新资产) + - 标签复制(`tb_resource_tag`) + - 客户绑定 `virtual_no` 更新(`tb_personal_customer_device`) + - 旧资产状态置为已换货(`asset_status=3`) + - 换货单迁移结果回写(`migration_completed`、`migration_balance`) + +## 4. Handler 与路由 + +### 4.1 后台换货接口 + +- 新增 `internal/handler/admin/exchange.go` +- 新增 `internal/routes/exchange.go` +- 注册接口(标签:`换货管理`): + - `POST /api/admin/exchanges` + - `GET /api/admin/exchanges` + - `GET /api/admin/exchanges/:id` + - `POST /api/admin/exchanges/:id/ship` + - `POST /api/admin/exchanges/:id/complete` + - `POST /api/admin/exchanges/:id/cancel` + - `POST /api/admin/exchanges/:id/renew` + +### 4.2 客户端换货接口 + +- 新增 `internal/handler/app/client_exchange.go` +- 在 `internal/routes/personal.go` 注册: + - `GET /api/c/v1/exchange/pending` + - `POST /api/c/v1/exchange/:id/shipping-info` + +## 5. 兼容与替换 + +- `iot_card_store.go` 的 `is_replaced` 过滤逻辑已切换至 `tb_exchange_order`。 +- 业务主流程不再依赖旧换卡表(仅模型与 legacy 表保留用于历史数据)。 + +## 6. 启动装配与文档生成 + +已完成换货模块在以下位置的全链路接入: + +- `internal/bootstrap/types.go` +- `internal/bootstrap/stores.go` +- `internal/bootstrap/services.go` +- `internal/bootstrap/handlers.go` +- `internal/routes/admin.go` +- `pkg/openapi/handlers.go` +- `cmd/api/docs.go` +- `cmd/gendocs/main.go` + +## 7. 验证结果 + +- 已执行:`go build ./...`,编译通过。 +- 已执行:数据库迁移 `make migrate-up`,版本到 `86`。 +- 已完成:变更文件 LSP 诊断检查(无 error 级问题)。 diff --git a/internal/bootstrap/handlers.go b/internal/bootstrap/handlers.go index 75ddb09..5c36c9a 100644 --- a/internal/bootstrap/handlers.go +++ b/internal/bootstrap/handlers.go @@ -5,11 +5,41 @@ import ( "github.com/break/junhong_cmp_fiber/internal/handler/app" authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth" "github.com/break/junhong_cmp_fiber/internal/handler/callback" + clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order" + "github.com/break/junhong_cmp_fiber/internal/store/postgres" "github.com/go-playground/validator/v10" ) func initHandlers(svc *services, deps *Dependencies) *Handlers { validate := validator.New() + personalCustomerDeviceStore := postgres.NewPersonalCustomerDeviceStore(deps.DB) + assetWalletStore := postgres.NewAssetWalletStore(deps.DB, deps.Redis) + packageStore := postgres.NewPackageStore(deps.DB) + shopPackageAllocationStore := postgres.NewShopPackageAllocationStore(deps.DB) + iotCardStore := postgres.NewIotCardStore(deps.DB, deps.Redis) + deviceStore := postgres.NewDeviceStore(deps.DB, deps.Redis) + assetWalletTransactionStore := postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis) + assetRechargeStore := postgres.NewAssetRechargeStore(deps.DB, deps.Redis) + personalCustomerOpenIDStore := postgres.NewPersonalCustomerOpenIDStore(deps.DB) + orderStore := postgres.NewOrderStore(deps.DB, deps.Redis) + packageSeriesStore := postgres.NewPackageSeriesStore(deps.DB) + shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(deps.DB) + deviceSimBindingStore := postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis) + carrierStore := postgres.NewCarrierStore(deps.DB) + clientOrderService := clientOrderSvc.New( + svc.Asset, + svc.PurchaseValidation, + orderStore, + assetRechargeStore, + assetWalletStore, + personalCustomerDeviceStore, + personalCustomerOpenIDStore, + svc.WechatConfig, + packageSeriesStore, + shopSeriesAllocationStore, + deps.Redis, + deps.Logger, + ) return &Handlers{ Auth: authHandler.NewHandler(svc.Auth, validate), @@ -18,6 +48,12 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { Permission: admin.NewPermissionHandler(svc.Permission), PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger), ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger), + ClientAsset: app.NewClientAssetHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger), + ClientWallet: app.NewClientWalletHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, assetWalletTransactionStore, assetRechargeStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore), + ClientOrder: app.NewClientOrderHandler(clientOrderService, svc.Asset, orderStore, personalCustomerDeviceStore, iotCardStore, deviceStore, deps.Logger, deps.DB), + ClientExchange: app.NewClientExchangeHandler(svc.Exchange), + ClientRealname: app.NewClientRealnameHandler(svc.Asset, personalCustomerDeviceStore, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger), + ClientDevice: app.NewClientDeviceHandler(svc.Asset, personalCustomerDeviceStore, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger), Shop: admin.NewShopHandler(svc.Shop), ShopRole: admin.NewShopRoleHandler(svc.Shop), AdminAuth: admin.NewAuthHandler(svc.Auth, validate), @@ -43,6 +79,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers { ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing), ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant), AdminOrder: admin.NewOrderHandler(svc.Order, validate), + AdminExchange: admin.NewExchangeHandler(svc.Exchange, validate), PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, svc.AgentRecharge, deps.WechatPayment), PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig), PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency), diff --git a/internal/bootstrap/services.go b/internal/bootstrap/services.go index ada1996..dfe37cc 100644 --- a/internal/bootstrap/services.go +++ b/internal/bootstrap/services.go @@ -20,6 +20,7 @@ import ( enterpriseSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise" enterpriseCardSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise_card" enterpriseDeviceSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise_device" + exchangeSvc "github.com/break/junhong_cmp_fiber/internal/service/exchange" iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card" iotCardImportSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card_import" myCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/my_commission" @@ -76,6 +77,7 @@ type services struct { CommissionStats *commissionStatsSvc.Service PurchaseValidation *purchaseValidationSvc.Service Order *orderSvc.Service + Exchange *exchangeSvc.Service Recharge *rechargeSvc.Service PollingConfig *pollingSvc.ConfigService PollingConcurrency *pollingSvc.ConcurrencyService @@ -167,6 +169,7 @@ func initServices(s *stores, deps *Dependencies) *services { CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats), PurchaseValidation: purchaseValidation, Order: orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, deps.QueueClient, deps.Logger), + Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, s.PersonalCustomerDevice, deps.Logger), Recharge: rechargeSvc.New(deps.DB, s.AssetRecharge, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, deps.Logger), PollingConfig: pollingSvc.NewConfigService(s.PollingConfig), PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis), diff --git a/internal/bootstrap/stores.go b/internal/bootstrap/stores.go index 7ec87ce..7076d8d 100644 --- a/internal/bootstrap/stores.go +++ b/internal/bootstrap/stores.go @@ -40,6 +40,8 @@ type stores struct { ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore Order *postgres.OrderStore OrderItem *postgres.OrderItemStore + ExchangeOrder *postgres.ExchangeOrderStore + ResourceTag *postgres.ResourceTagStore PollingConfig *postgres.PollingConfigStore PollingConcurrencyConfig *postgres.PollingConcurrencyConfigStore PollingAlertRule *postgres.PollingAlertRuleStore @@ -96,6 +98,8 @@ func initStores(deps *Dependencies) *stores { ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB), Order: postgres.NewOrderStore(deps.DB, deps.Redis), OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis), + ExchangeOrder: postgres.NewExchangeOrderStore(deps.DB), + ResourceTag: postgres.NewResourceTagStore(deps.DB), PollingConfig: postgres.NewPollingConfigStore(deps.DB), PollingConcurrencyConfig: postgres.NewPollingConcurrencyConfigStore(deps.DB), PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB), diff --git a/internal/bootstrap/types.go b/internal/bootstrap/types.go index a1b8401..3f07091 100644 --- a/internal/bootstrap/types.go +++ b/internal/bootstrap/types.go @@ -16,6 +16,12 @@ type Handlers struct { Permission *admin.PermissionHandler PersonalCustomer *app.PersonalCustomerHandler ClientAuth *app.ClientAuthHandler + ClientAsset *app.ClientAssetHandler + ClientWallet *app.ClientWalletHandler + ClientOrder *app.ClientOrderHandler + ClientExchange *app.ClientExchangeHandler + ClientRealname *app.ClientRealnameHandler + ClientDevice *app.ClientDeviceHandler Shop *admin.ShopHandler ShopRole *admin.ShopRoleHandler AdminAuth *admin.AuthHandler @@ -41,6 +47,7 @@ type Handlers struct { ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler ShopSeriesGrant *admin.ShopSeriesGrantHandler AdminOrder *admin.OrderHandler + AdminExchange *admin.ExchangeHandler PaymentCallback *callback.PaymentHandler PollingConfig *admin.PollingConfigHandler PollingConcurrency *admin.PollingConcurrencyHandler diff --git a/internal/handler/admin/exchange.go b/internal/handler/admin/exchange.go new file mode 100644 index 0000000..4f9f206 --- /dev/null +++ b/internal/handler/admin/exchange.go @@ -0,0 +1,131 @@ +package admin + +import ( + "strconv" + + "github.com/break/junhong_cmp_fiber/internal/model/dto" + exchangeService "github.com/break/junhong_cmp_fiber/internal/service/exchange" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +type ExchangeHandler struct { + service *exchangeService.Service + validator *validator.Validate +} + +func NewExchangeHandler(service *exchangeService.Service, validator *validator.Validate) *ExchangeHandler { + return &ExchangeHandler{service: service, validator: validator} +} + +func (h *ExchangeHandler) Create(c *fiber.Ctx) error { + var req dto.CreateExchangeRequest + if err := c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + data, err := h.service.Create(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, data) +} + +func (h *ExchangeHandler) List(c *fiber.Ctx) error { + var req dto.ExchangeListRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + data, err := h.service.List(c.UserContext(), &req) + if err != nil { + return err + } + return response.Success(c, data) +} + +func (h *ExchangeHandler) Get(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "无效的换货单ID") + } + + data, err := h.service.Get(c.UserContext(), uint(id)) + if err != nil { + return err + } + return response.Success(c, data) +} + +func (h *ExchangeHandler) Ship(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "无效的换货单ID") + } + + var req dto.ExchangeShipRequest + if err = c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err = h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + data, err := h.service.Ship(c.UserContext(), uint(id), &req) + if err != nil { + return err + } + return response.Success(c, data) +} + +func (h *ExchangeHandler) Complete(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "无效的换货单ID") + } + + if err = h.service.Complete(c.UserContext(), uint(id)); err != nil { + return err + } + return response.Success(c, nil) +} + +func (h *ExchangeHandler) Cancel(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "无效的换货单ID") + } + + var req dto.ExchangeCancelRequest + if err = c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam, "请求参数解析失败") + } + if err = h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + if err = h.service.Cancel(c.UserContext(), uint(id), &req); err != nil { + return err + } + return response.Success(c, nil) +} + +func (h *ExchangeHandler) Renew(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam, "无效的换货单ID") + } + + if err = h.service.Renew(c.UserContext(), uint(id)); err != nil { + return err + } + return response.Success(c, nil) +} diff --git a/internal/handler/app/client_exchange.go b/internal/handler/app/client_exchange.go new file mode 100644 index 0000000..aa44a5a --- /dev/null +++ b/internal/handler/app/client_exchange.go @@ -0,0 +1,57 @@ +package app + +import ( + "strconv" + + "github.com/break/junhong_cmp_fiber/internal/model/dto" + exchangeService "github.com/break/junhong_cmp_fiber/internal/service/exchange" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/response" + "github.com/go-playground/validator/v10" + "github.com/gofiber/fiber/v2" +) + +type ClientExchangeHandler struct { + service *exchangeService.Service + validator *validator.Validate +} + +func NewClientExchangeHandler(service *exchangeService.Service) *ClientExchangeHandler { + return &ClientExchangeHandler{service: service, validator: validator.New()} +} + +func (h *ClientExchangeHandler) GetPending(c *fiber.Ctx) error { + var req dto.ClientExchangePendingRequest + if err := c.QueryParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err := h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + data, err := h.service.GetPending(c.UserContext(), req.Identifier) + if err != nil { + return err + } + return response.Success(c, data) +} + +func (h *ClientExchangeHandler) SubmitShippingInfo(c *fiber.Ctx) error { + id, err := strconv.ParseUint(c.Params("id"), 10, 64) + if err != nil || id == 0 { + return errors.New(errors.CodeInvalidParam) + } + + var req dto.ClientShippingInfoRequest + if err = c.BodyParser(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + if err = h.validator.Struct(&req); err != nil { + return errors.New(errors.CodeInvalidParam) + } + + if err = h.service.SubmitShippingInfo(c.UserContext(), uint(id), &req); err != nil { + return err + } + return response.Success(c, nil) +} diff --git a/internal/model/asset_wallet.go b/internal/model/asset_wallet.go index bd5cfa5..461d3e3 100644 --- a/internal/model/asset_wallet.go +++ b/internal/model/asset_wallet.go @@ -92,6 +92,7 @@ type AssetRechargeRecord struct { LinkedOrderType string `gorm:"column:linked_order_type;type:varchar(20);comment:关联订单类型" json:"linked_order_type,omitempty"` LinkedCarrierType string `gorm:"column:linked_carrier_type;type:varchar(20);comment:关联载体类型" json:"linked_carrier_type,omitempty"` LinkedCarrierID *uint `gorm:"column:linked_carrier_id;type:bigint;comment:关联载体ID" json:"linked_carrier_id,omitempty"` + AutoPurchaseStatus string `gorm:"column:auto_purchase_status;type:varchar(20);default:'';comment:强充自动代购状态(pending-待处理 success-成功 failed-失败)" json:"auto_purchase_status,omitempty"` DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"` } diff --git a/internal/model/dto/exchange_dto.go b/internal/model/dto/exchange_dto.go new file mode 100644 index 0000000..17862f2 --- /dev/null +++ b/internal/model/dto/exchange_dto.go @@ -0,0 +1,104 @@ +package dto + +import "time" + +type CreateExchangeRequest struct { + OldAssetType string `json:"old_asset_type" validate:"required,oneof=iot_card device" required:"true" description:"旧资产类型 (iot_card:物联网卡, device:设备)"` + OldIdentifier string `json:"old_identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"旧资产标识符(ICCID/虚拟号/IMEI/SN)"` + ExchangeReason string `json:"exchange_reason" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"换货原因"` + Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"备注"` +} + +type ExchangeListRequest struct { + Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"` + PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页数量"` + Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=5" minimum:"1" maximum:"5" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"` + Identifier string `json:"identifier" query:"identifier" validate:"omitempty,max=100" maxLength:"100" description:"资产标识符搜索(旧资产/新资产标识符模糊匹配)"` + CreatedAtStart *time.Time `json:"created_at_start" query:"created_at_start" description:"创建时间起始"` + CreatedAtEnd *time.Time `json:"created_at_end" query:"created_at_end" description:"创建时间结束"` +} + +type ExchangeShipRequest struct { + ExpressCompany string `json:"express_company" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"快递公司"` + ExpressNo string `json:"express_no" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"快递单号"` + NewIdentifier string `json:"new_identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"新资产标识符(ICCID/虚拟号/IMEI/SN)"` + MigrateData bool `json:"migrate_data" required:"true" description:"是否执行全量迁移 (true:执行, false:不执行)"` +} + +type ExchangeCancelRequest struct { + Remark *string `json:"remark" validate:"omitempty,max=500" maxLength:"500" description:"取消备注"` +} + +type ClientShippingInfoRequest struct { + RecipientName string `json:"recipient_name" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"收件人姓名"` + RecipientPhone string `json:"recipient_phone" validate:"required,min=1,max=20" required:"true" minLength:"1" maxLength:"20" description:"收件人电话"` + RecipientAddress string `json:"recipient_address" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"收货地址"` +} + +type ClientExchangePendingRequest struct { + Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符(ICCID/虚拟号/IMEI/SN)"` +} + +type ExchangeIDRequest struct { + ID uint `path:"id" required:"true" description:"换货单ID"` +} + +type ExchangeShipParams struct { + ID uint `path:"id" required:"true" description:"换货单ID"` + ExchangeShipRequest +} + +type ExchangeCancelParams struct { + ID uint `path:"id" required:"true" description:"换货单ID"` + ExchangeCancelRequest +} + +type ClientShippingInfoParams struct { + ID uint `path:"id" required:"true" description:"换货单ID"` + ClientShippingInfoRequest +} + +type ExchangeOrderResponse struct { + ID uint `json:"id" description:"换货单ID"` + ExchangeNo string `json:"exchange_no" description:"换货单号"` + OldAssetType string `json:"old_asset_type" description:"旧资产类型 (iot_card:物联网卡, device:设备)"` + OldAssetID uint `json:"old_asset_id" description:"旧资产ID"` + OldAssetIdentifier string `json:"old_asset_identifier" description:"旧资产标识符"` + NewAssetType string `json:"new_asset_type" description:"新资产类型 (iot_card:物联网卡, device:设备)"` + NewAssetID *uint `json:"new_asset_id,omitempty" description:"新资产ID"` + NewAssetIdentifier string `json:"new_asset_identifier" description:"新资产标识符"` + RecipientName string `json:"recipient_name" description:"收件人姓名"` + RecipientPhone string `json:"recipient_phone" description:"收件人电话"` + RecipientAddress string `json:"recipient_address" description:"收货地址"` + ExpressCompany string `json:"express_company" description:"快递公司"` + ExpressNo string `json:"express_no" description:"快递单号"` + MigrateData bool `json:"migrate_data" description:"是否执行全量迁移"` + MigrationCompleted bool `json:"migration_completed" description:"迁移是否已完成"` + MigrationBalance int64 `json:"migration_balance" description:"迁移转移金额(分)"` + ExchangeReason string `json:"exchange_reason" description:"换货原因"` + Remark *string `json:"remark,omitempty" description:"备注"` + Status int `json:"status" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"` + StatusText string `json:"status_text" description:"换货状态文本"` + ShopID *uint `json:"shop_id,omitempty" description:"所属店铺ID"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` + UpdatedAt time.Time `json:"updated_at" description:"更新时间"` + DeletedAt *time.Time `json:"deleted_at,omitempty" description:"删除时间"` + Creator uint `json:"creator" description:"创建人ID"` + Updater uint `json:"updater" description:"更新人ID"` +} + +type ExchangeListResponse struct { + List []*ExchangeOrderResponse `json:"list" description:"换货单列表"` + Total int64 `json:"total" description:"总数"` + Page int `json:"page" description:"当前页码"` + PageSize int `json:"page_size" description:"每页数量"` +} + +type ClientExchangePendingResponse struct { + ID uint `json:"id" description:"换货单ID"` + ExchangeNo string `json:"exchange_no" description:"换货单号"` + Status int `json:"status" description:"换货状态 (1:待填写信息, 2:待发货, 3:已发货待确认, 4:已完成, 5:已取消)"` + StatusText string `json:"status_text" description:"换货状态文本"` + ExchangeReason string `json:"exchange_reason" description:"换货原因"` + CreatedAt time.Time `json:"created_at" description:"创建时间"` +} diff --git a/internal/model/exchange_order.go b/internal/model/exchange_order.go new file mode 100644 index 0000000..c5b2ef3 --- /dev/null +++ b/internal/model/exchange_order.go @@ -0,0 +1,65 @@ +package model + +import ( + "fmt" + "math/rand" + "time" + + "gorm.io/gorm" +) + +// ExchangeOrder 换货单模型 +// 承载客户端换货的完整生命周期:后台发起 → 客户端填写收货信息 → 后台发货 → 确认完成(含可选全量迁移) → 旧资产可转新 +// 状态机:1-待填写信息 → 2-待发货 → 3-已发货待确认 → 4-已完成,1/2 时可取消 → 5-已取消 +type ExchangeOrder struct { + gorm.Model + BaseModel `gorm:"embedded"` + + // 单号 + ExchangeNo string `gorm:"column:exchange_no;type:varchar(50);not null;uniqueIndex:idx_exchange_order_no,where:deleted_at IS NULL;comment:换货单号(EXC+日期+随机数)" json:"exchange_no"` + + // 旧资产快照 + OldAssetType string `gorm:"column:old_asset_type;type:varchar(20);not null;comment:旧资产类型(iot_card/device)" json:"old_asset_type"` + OldAssetID uint `gorm:"column:old_asset_id;not null;index:idx_exchange_order_old_asset;comment:旧资产ID" json:"old_asset_id"` + OldAssetIdentifier string `gorm:"column:old_asset_identifier;type:varchar(100);not null;comment:旧资产标识符(ICCID/虚拟号)" json:"old_asset_identifier"` + + // 新资产快照(发货时填写) + NewAssetType string `gorm:"column:new_asset_type;type:varchar(20);comment:新资产类型(iot_card/device)" json:"new_asset_type"` + NewAssetID *uint `gorm:"column:new_asset_id;comment:新资产ID" json:"new_asset_id,omitempty"` + NewAssetIdentifier string `gorm:"column:new_asset_identifier;type:varchar(100);comment:新资产标识符(ICCID/虚拟号)" json:"new_asset_identifier"` + + // 收货信息(客户端填写) + RecipientName string `gorm:"column:recipient_name;type:varchar(50);comment:收件人姓名" json:"recipient_name"` + RecipientPhone string `gorm:"column:recipient_phone;type:varchar(20);comment:收件人电话" json:"recipient_phone"` + RecipientAddress string `gorm:"column:recipient_address;type:text;comment:收货地址" json:"recipient_address"` + + // 物流信息(后台发货时填写) + ExpressCompany string `gorm:"column:express_company;type:varchar(100);comment:快递公司" json:"express_company"` + ExpressNo string `gorm:"column:express_no;type:varchar(100);comment:快递单号" json:"express_no"` + + // 迁移相关 + MigrateData bool `gorm:"column:migrate_data;type:boolean;default:false;comment:是否执行全量迁移" json:"migrate_data"` + MigrationCompleted bool `gorm:"column:migration_completed;type:boolean;default:false;comment:迁移是否已完成" json:"migration_completed"` + MigrationBalance int64 `gorm:"column:migration_balance;type:bigint;default:0;comment:迁移转移金额(分)" json:"migration_balance"` + + // 业务信息 + ExchangeReason string `gorm:"column:exchange_reason;type:varchar(100);not null;comment:换货原因" json:"exchange_reason"` + Remark *string `gorm:"column:remark;type:text;comment:备注" json:"remark,omitempty"` + Status int `gorm:"column:status;type:int;not null;default:1;index:idx_exchange_order_status;comment:换货状态 1-待填写信息 2-待发货 3-已发货待确认 4-已完成 5-已取消" json:"status"` + + // 多租户 + ShopID *uint `gorm:"column:shop_id;index;comment:所属店铺ID" json:"shop_id,omitempty"` +} + +// TableName 指定表名 +func (ExchangeOrder) TableName() string { + return "tb_exchange_order" +} + +// GenerateExchangeNo 生成换货单号 +// 格式:EXC + 年月日时分秒 + 6位随机数,如 EXC20260319143052123456 +func GenerateExchangeNo() string { + now := time.Now() + randomNum := rand.Intn(1000000) + return fmt.Sprintf("EXC%s%06d", now.Format("20060102150405"), randomNum) +} diff --git a/internal/routes/admin.go b/internal/routes/admin.go index 756b842..6126830 100644 --- a/internal/routes/admin.go +++ b/internal/routes/admin.go @@ -92,6 +92,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd if handlers.AdminOrder != nil { registerAdminOrderRoutes(authGroup, handlers.AdminOrder, doc, basePath) } + if handlers.AdminExchange != nil { + registerAdminExchangeRoutes(authGroup, handlers.AdminExchange, doc, basePath) + } if handlers.PollingConfig != nil { registerPollingConfigRoutes(authGroup, handlers.PollingConfig, doc, basePath) } diff --git a/internal/routes/exchange.go b/internal/routes/exchange.go new file mode 100644 index 0000000..3139d7a --- /dev/null +++ b/internal/routes/exchange.go @@ -0,0 +1,66 @@ +package routes + +import ( + "github.com/break/junhong_cmp_fiber/internal/handler/admin" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "github.com/break/junhong_cmp_fiber/pkg/openapi" + "github.com/gofiber/fiber/v2" +) + +func registerAdminExchangeRoutes(router fiber.Router, handler *admin.ExchangeHandler, doc *openapi.Generator, basePath string) { + Register(router, doc, basePath, "POST", "/exchanges", handler.Create, RouteSpec{ + Summary: "创建换货单", + Tags: []string{"换货管理"}, + Input: new(dto.CreateExchangeRequest), + Output: new(dto.ExchangeOrderResponse), + Auth: true, + }) + + Register(router, doc, basePath, "GET", "/exchanges", handler.List, RouteSpec{ + Summary: "获取换货单列表", + Tags: []string{"换货管理"}, + Input: new(dto.ExchangeListRequest), + Output: new(dto.ExchangeListResponse), + Auth: true, + }) + + Register(router, doc, basePath, "GET", "/exchanges/:id", handler.Get, RouteSpec{ + Summary: "获取换货单详情", + Tags: []string{"换货管理"}, + Input: new(dto.ExchangeIDRequest), + Output: new(dto.ExchangeOrderResponse), + Auth: true, + }) + + Register(router, doc, basePath, "POST", "/exchanges/:id/ship", handler.Ship, RouteSpec{ + Summary: "换货发货", + Tags: []string{"换货管理"}, + Input: new(dto.ExchangeShipParams), + Output: new(dto.ExchangeOrderResponse), + Auth: true, + }) + + Register(router, doc, basePath, "POST", "/exchanges/:id/complete", handler.Complete, RouteSpec{ + Summary: "确认换货完成", + Tags: []string{"换货管理"}, + Input: new(dto.ExchangeIDRequest), + Output: nil, + Auth: true, + }) + + Register(router, doc, basePath, "POST", "/exchanges/:id/cancel", handler.Cancel, RouteSpec{ + Summary: "取消换货", + Tags: []string{"换货管理"}, + Input: new(dto.ExchangeCancelParams), + Output: nil, + Auth: true, + }) + + Register(router, doc, basePath, "POST", "/exchanges/:id/renew", handler.Renew, RouteSpec{ + Summary: "旧资产转新", + Tags: []string{"换货管理"}, + Input: new(dto.ExchangeIDRequest), + Output: nil, + Auth: true, + }) +} diff --git a/internal/routes/personal.go b/internal/routes/personal.go index 00f73a9..4c5a00b 100644 --- a/internal/routes/personal.go +++ b/internal/routes/personal.go @@ -97,4 +97,164 @@ func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, Input: &apphandler.UpdateProfileRequest{}, Output: nil, }) + + Register(authGroup, doc, basePath, "GET", "/asset/info", handlers.ClientAsset.GetAssetInfo, RouteSpec{ + Summary: "资产信息", + Tags: []string{"个人客户 - 资产"}, + Auth: true, + Input: &dto.AssetInfoRequest{}, + Output: &dto.AssetInfoResponse{}, + }) + + Register(authGroup, doc, basePath, "GET", "/asset/packages", handlers.ClientAsset.GetAvailablePackages, RouteSpec{ + Summary: "资产可购套餐列表", + Tags: []string{"个人客户 - 资产"}, + Auth: true, + Input: &dto.AssetPackageListRequest{}, + Output: &dto.AssetPackageListResponse{}, + }) + + Register(authGroup, doc, basePath, "GET", "/asset/package-history", handlers.ClientAsset.GetPackageHistory, RouteSpec{ + Summary: "资产套餐历史", + Tags: []string{"个人客户 - 资产"}, + Auth: true, + Input: &dto.AssetPackageHistoryRequest{}, + Output: &dto.AssetPackageHistoryResponse{}, + }) + + Register(authGroup, doc, basePath, "POST", "/asset/refresh", handlers.ClientAsset.RefreshAsset, RouteSpec{ + Summary: "资产刷新", + Tags: []string{"个人客户 - 资产"}, + Auth: true, + Input: &dto.AssetRefreshRequest{}, + Output: &dto.AssetRefreshResponse{}, + }) + + Register(authGroup, doc, basePath, "GET", "/wallet/detail", handlers.ClientWallet.GetWalletDetail, RouteSpec{ + Summary: "钱包详情", + Tags: []string{"个人客户 - 钱包"}, + Auth: true, + Input: &dto.WalletDetailRequest{}, + Output: &dto.WalletDetailResponse{}, + }) + + Register(authGroup, doc, basePath, "GET", "/wallet/transactions", handlers.ClientWallet.GetWalletTransactions, RouteSpec{ + Summary: "钱包流水列表", + Tags: []string{"个人客户 - 钱包"}, + Auth: true, + Input: &dto.WalletTransactionListRequest{}, + Output: &dto.WalletTransactionListResponse{}, + }) + + Register(authGroup, doc, basePath, "GET", "/wallet/recharge-check", handlers.ClientWallet.GetRechargeCheck, RouteSpec{ + Summary: "充值前校验", + Tags: []string{"个人客户 - 钱包"}, + Auth: true, + Input: &dto.ClientRechargeCheckRequest{}, + Output: &dto.ClientRechargeCheckResponse{}, + }) + + Register(authGroup, doc, basePath, "POST", "/wallet/recharge", handlers.ClientWallet.CreateRecharge, RouteSpec{ + Summary: "创建充值订单", + Tags: []string{"个人客户 - 钱包"}, + Auth: true, + Input: &dto.ClientCreateRechargeRequest{}, + Output: &dto.ClientRechargeResponse{}, + }) + + Register(authGroup, doc, basePath, "GET", "/wallet/recharges", handlers.ClientWallet.GetRechargeList, RouteSpec{ + Summary: "充值记录列表", + Tags: []string{"个人客户 - 钱包"}, + Auth: true, + Input: &dto.ClientRechargeListRequest{}, + Output: &dto.ClientRechargeListResponse{}, + }) + + Register(authGroup, doc, basePath, "POST", "/orders/create", handlers.ClientOrder.CreateOrder, RouteSpec{ + Summary: "创建订单", + Tags: []string{"个人客户 - 订单"}, + Auth: true, + Input: &dto.ClientCreateOrderRequest{}, + Output: &dto.ClientCreateOrderResponse{}, + }) + + Register(authGroup, doc, basePath, "GET", "/orders", handlers.ClientOrder.ListOrders, RouteSpec{ + Summary: "订单列表", + Tags: []string{"个人客户 - 订单"}, + Auth: true, + Input: &dto.ClientOrderListRequest{}, + Output: &dto.ClientOrderListResponse{}, + }) + + Register(authGroup, doc, basePath, "GET", "/orders/:id", handlers.ClientOrder.GetOrderDetail, RouteSpec{ + Summary: "订单详情", + Tags: []string{"个人客户 - 订单"}, + Auth: true, + Input: &dto.IDReq{}, + Output: &dto.ClientOrderDetailResponse{}, + }) + + Register(authGroup, doc, basePath, "GET", "/exchange/pending", handlers.ClientExchange.GetPending, RouteSpec{ + Summary: "查询待处理换货单", + Tags: []string{"个人客户 - 换货"}, + Auth: true, + Input: &dto.ClientExchangePendingRequest{}, + Output: &dto.ClientExchangePendingResponse{}, + }) + + Register(authGroup, doc, basePath, "POST", "/exchange/:id/shipping-info", handlers.ClientExchange.SubmitShippingInfo, RouteSpec{ + Summary: "提交收货信息", + Tags: []string{"个人客户 - 换货"}, + Auth: true, + Input: &dto.ClientShippingInfoParams{}, + Output: nil, + }) + + Register(authGroup, doc, basePath, "GET", "/realname/link", handlers.ClientRealname.GetRealnameLink, RouteSpec{ + Summary: "获取实名认证链接", + Tags: []string{"个人客户 - 实名"}, + Auth: true, + Input: &dto.RealnimeLinkRequest{}, + Output: &dto.RealnimeLinkResponse{}, + }) + + Register(authGroup, doc, basePath, "GET", "/device/cards", handlers.ClientDevice.GetDeviceCards, RouteSpec{ + Summary: "获取设备卡列表", + Tags: []string{"个人客户 - 设备"}, + Auth: true, + Input: &dto.DeviceCardListRequest{}, + Output: &dto.DeviceCardListResponse{}, + }) + + Register(authGroup, doc, basePath, "POST", "/device/reboot", handlers.ClientDevice.RebootDevice, RouteSpec{ + Summary: "设备重启", + Tags: []string{"个人客户 - 设备"}, + Auth: true, + Input: &dto.DeviceRebootRequest{}, + Output: &dto.DeviceOperationResponse{}, + }) + + Register(authGroup, doc, basePath, "POST", "/device/factory-reset", handlers.ClientDevice.FactoryResetDevice, RouteSpec{ + Summary: "恢复出厂设置", + Tags: []string{"个人客户 - 设备"}, + Auth: true, + Input: &dto.DeviceFactoryResetRequest{}, + Output: &dto.DeviceOperationResponse{}, + }) + + Register(authGroup, doc, basePath, "POST", "/device/wifi", handlers.ClientDevice.SetWiFi, RouteSpec{ + Summary: "设备WiFi配置", + Tags: []string{"个人客户 - 设备"}, + Auth: true, + Input: &dto.DeviceWifiRequest{}, + Output: &dto.DeviceOperationResponse{}, + }) + + Register(authGroup, doc, basePath, "POST", "/device/switch-card", handlers.ClientDevice.SwitchCard, RouteSpec{ + Summary: "设备切卡", + Tags: []string{"个人客户 - 设备"}, + Auth: true, + Input: &dto.DeviceSwitchCardRequest{}, + Output: &dto.DeviceSwitchCardResponse{}, + }) } diff --git a/internal/service/exchange/migration.go b/internal/service/exchange/migration.go new file mode 100644 index 0000000..37a9570 --- /dev/null +++ b/internal/service/exchange/migration.go @@ -0,0 +1,243 @@ +package exchange + +import ( + "context" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/errors" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "gorm.io/gorm" + "gorm.io/gorm/clause" +) + +func (s *Service) executeMigration(ctx context.Context, order *model.ExchangeOrder) (int64, error) { + var migrationBalance int64 + + err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if order.NewAssetID == nil || *order.NewAssetID == 0 { + return errors.New(errors.CodeInvalidParam, "新资产信息缺失") + } + + oldAsset, err := s.resolveAssetByIdentifier(ctx, order.OldAssetType, order.OldAssetIdentifier) + if err != nil { + return err + } + newAsset, err := s.resolveAssetByIdentifier(ctx, order.OldAssetType, order.NewAssetIdentifier) + if err != nil { + return err + } + + migrationBalance, err = s.transferWalletBalanceWithTx(ctx, tx, order, oldAsset, newAsset) + if err != nil { + return err + } + + if err = s.migratePackageUsageWithTx(ctx, tx, oldAsset, newAsset); err != nil { + return err + } + + if err = s.copyAccumulatedFieldsWithTx(tx, oldAsset, newAsset); err != nil { + return err + } + + if err = s.copyResourceTagsWithTx(ctx, tx, oldAsset, newAsset); err != nil { + return err + } + + if oldAsset.VirtualNo != "" && newAsset.VirtualNo != "" { + if err = tx.Model(&model.PersonalCustomerDevice{}). + Where("virtual_no = ?", oldAsset.VirtualNo). + Updates(map[string]any{"virtual_no": newAsset.VirtualNo, "updated_at": time.Now()}).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新客户绑定关系失败") + } + } + + if err = s.updateOldAssetStatusWithTx(tx, oldAsset); err != nil { + return err + } + + if err = tx.Model(&model.ExchangeOrder{}).Where("id = ?", order.ID).Updates(map[string]any{ + "migration_completed": true, + "migration_balance": migrationBalance, + "updater": middleware.GetUserIDFromContext(ctx), + "updated_at": time.Now(), + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新换货单迁移状态失败") + } + + return nil + }) + if err != nil { + return 0, errors.Wrap(errors.CodeExchangeMigrationFailed, err, "执行全量迁移失败") + } + + return migrationBalance, nil +} + +func (s *Service) transferWalletBalanceWithTx(ctx context.Context, tx *gorm.DB, order *model.ExchangeOrder, oldAsset, newAsset *resolvedExchangeAsset) (int64, error) { + var oldWallet model.AssetWallet + if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", oldAsset.AssetType, oldAsset.AssetID).First(&oldWallet).Error; err != nil { + if err != gorm.ErrRecordNotFound { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询旧资产钱包失败") + } + } + + var newWallet model.AssetWallet + if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", newAsset.AssetType, newAsset.AssetID).First(&newWallet).Error; err != nil { + if err != gorm.ErrRecordNotFound { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询新资产钱包失败") + } + + shopTag := uint(0) + if newAsset.ShopID != nil { + shopTag = *newAsset.ShopID + } + newWallet = model.AssetWallet{ResourceType: newAsset.AssetType, ResourceID: newAsset.AssetID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: 1, Version: 0, ShopIDTag: shopTag} + if err = tx.WithContext(ctx).Create(&newWallet).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "创建新资产钱包失败") + } + } + + migrationBalance := oldWallet.Balance + if migrationBalance <= 0 { + return 0, nil + } + + beforeBalance := newWallet.Balance + if err := tx.WithContext(ctx).Model(&model.AssetWallet{}).Where("id = ?", oldWallet.ID).Updates(map[string]any{"balance": 0, "updated_at": time.Now()}).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "清空旧资产钱包余额失败") + } + + if err := tx.WithContext(ctx).Model(&model.AssetWallet{}).Where("id = ?", newWallet.ID).Updates(map[string]any{"balance": gorm.Expr("balance + ?", migrationBalance), "updated_at": time.Now()}).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "增加新资产钱包余额失败") + } + + refType := "exchange" + if err := tx.WithContext(ctx).Create(&model.AssetWalletTransaction{ + AssetWalletID: newWallet.ID, + ResourceType: newAsset.AssetType, + ResourceID: newAsset.AssetID, + UserID: middleware.GetUserIDFromContext(ctx), + TransactionType: "refund", + Amount: migrationBalance, + BalanceBefore: beforeBalance, + BalanceAfter: beforeBalance + migrationBalance, + Status: 1, + ReferenceType: &refType, + ReferenceNo: &order.ExchangeNo, + Creator: middleware.GetUserIDFromContext(ctx), + ShopIDTag: newWallet.ShopIDTag, + EnterpriseIDTag: newWallet.EnterpriseIDTag, + }).Error; err != nil { + return 0, errors.Wrap(errors.CodeDatabaseError, err, "写入迁移钱包流水失败") + } + + return migrationBalance, nil +} + +func (s *Service) migratePackageUsageWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error { + query := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("status IN ?", []int{constants.PackageUsageStatusPending, constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}) + if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard { + query = query.Where("iot_card_id = ?", oldAsset.AssetID) + } else { + query = query.Where("device_id = ?", oldAsset.AssetID) + } + + var usageIDs []uint + if err := query.Pluck("id", &usageIDs).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败") + } + + if len(usageIDs) == 0 { + return nil + } + + updates := map[string]any{"updated_at": time.Now()} + if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard { + updates["iot_card_id"] = newAsset.AssetID + } else { + updates["device_id"] = newAsset.AssetID + } + + if err := tx.WithContext(ctx).Model(&model.PackageUsage{}).Where("id IN ?", usageIDs).Updates(updates).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐使用记录失败") + } + + if err := tx.WithContext(ctx).Model(&model.PackageUsageDailyRecord{}).Where("package_usage_id IN ?", usageIDs).Update("updated_at", gorm.Expr("updated_at")).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "迁移套餐日记录失败") + } + + return nil +} + +func (s *Service) copyAccumulatedFieldsWithTx(tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error { + if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard { + if oldAsset.Card == nil { + return errors.New(errors.CodeAssetNotFound) + } + if err := tx.Model(&model.IotCard{}).Where("id = ?", newAsset.AssetID).Updates(map[string]any{ + "accumulated_recharge": oldAsset.Card.AccumulatedRecharge, + "first_commission_paid": oldAsset.Card.FirstCommissionPaid, + "accumulated_recharge_by_series": oldAsset.Card.AccumulatedRechargeBySeriesJSON, + "first_recharge_triggered_by_series": oldAsset.Card.FirstRechargeTriggeredBySeriesJSON, + "updated_at": time.Now(), + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "复制旧卡累计字段失败") + } + return nil + } + + if oldAsset.Device == nil { + return errors.New(errors.CodeAssetNotFound) + } + if err := tx.Model(&model.Device{}).Where("id = ?", newAsset.AssetID).Updates(map[string]any{ + "accumulated_recharge": oldAsset.Device.AccumulatedRecharge, + "first_commission_paid": oldAsset.Device.FirstCommissionPaid, + "accumulated_recharge_by_series": oldAsset.Device.AccumulatedRechargeBySeriesJSON, + "first_recharge_triggered_by_series": oldAsset.Device.FirstRechargeTriggeredBySeriesJSON, + "updated_at": time.Now(), + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "复制旧设备累计字段失败") + } + return nil +} + +func (s *Service) copyResourceTagsWithTx(ctx context.Context, tx *gorm.DB, oldAsset, newAsset *resolvedExchangeAsset) error { + var tags []*model.ResourceTag + if err := tx.WithContext(ctx).Where("resource_type = ? AND resource_id = ?", oldAsset.AssetType, oldAsset.AssetID).Find(&tags).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "查询资源标签失败") + } + var creator = middleware.GetUserIDFromContext(ctx) + for _, item := range tags { + if item == nil { + continue + } + record := &model.ResourceTag{ + ResourceType: newAsset.AssetType, + ResourceID: newAsset.AssetID, + TagID: item.TagID, + EnterpriseID: item.EnterpriseID, + ShopID: item.ShopID, + BaseModel: model.BaseModel{Creator: creator, Updater: creator}, + } + if err := tx.WithContext(ctx).Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "resource_type"}, {Name: "resource_id"}, {Name: "tag_id"}}, DoNothing: true}).Create(record).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "复制资源标签失败") + } + } + return nil +} + +func (s *Service) updateOldAssetStatusWithTx(tx *gorm.DB, oldAsset *resolvedExchangeAsset) error { + if oldAsset.AssetType == constants.ExchangeAssetTypeIotCard { + if err := tx.Model(&model.IotCard{}).Where("id = ?", oldAsset.AssetID).Updates(map[string]any{"asset_status": 3, "updated_at": time.Now()}).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新旧卡状态失败") + } + return nil + } + if err := tx.Model(&model.Device{}).Where("id = ?", oldAsset.AssetID).Updates(map[string]any{"asset_status": 3, "updated_at": time.Now()}).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "更新旧设备状态失败") + } + return nil +} diff --git a/internal/service/exchange/service.go b/internal/service/exchange/service.go new file mode 100644 index 0000000..04837be --- /dev/null +++ b/internal/service/exchange/service.go @@ -0,0 +1,487 @@ +package exchange + +import ( + "context" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/internal/model/dto" + "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/middleware" + "go.uber.org/zap" + "gorm.io/gorm" +) + +type Service struct { + db *gorm.DB + exchangeStore *postgres.ExchangeOrderStore + iotCardStore *postgres.IotCardStore + deviceStore *postgres.DeviceStore + assetWalletStore *postgres.AssetWalletStore + assetWalletTransactionStore *postgres.AssetWalletTransactionStore + packageUsageStore *postgres.PackageUsageStore + packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore + resourceTagStore *postgres.ResourceTagStore + personalCustomerDeviceStore *postgres.PersonalCustomerDeviceStore + logger *zap.Logger +} + +func New( + db *gorm.DB, + exchangeStore *postgres.ExchangeOrderStore, + iotCardStore *postgres.IotCardStore, + deviceStore *postgres.DeviceStore, + assetWalletStore *postgres.AssetWalletStore, + assetWalletTransactionStore *postgres.AssetWalletTransactionStore, + packageUsageStore *postgres.PackageUsageStore, + packageUsageDailyRecordStore *postgres.PackageUsageDailyRecordStore, + resourceTagStore *postgres.ResourceTagStore, + personalCustomerDeviceStore *postgres.PersonalCustomerDeviceStore, + logger *zap.Logger, +) *Service { + return &Service{ + db: db, + exchangeStore: exchangeStore, + iotCardStore: iotCardStore, + deviceStore: deviceStore, + assetWalletStore: assetWalletStore, + assetWalletTransactionStore: assetWalletTransactionStore, + packageUsageStore: packageUsageStore, + packageUsageDailyRecordStore: packageUsageDailyRecordStore, + resourceTagStore: resourceTagStore, + personalCustomerDeviceStore: personalCustomerDeviceStore, + logger: logger, + } +} + +func (s *Service) Create(ctx context.Context, req *dto.CreateExchangeRequest) (*dto.ExchangeOrderResponse, error) { + asset, err := s.resolveAssetByIdentifier(ctx, req.OldAssetType, req.OldIdentifier) + if err != nil { + return nil, err + } + + if _, err = s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID); err == nil { + return nil, errors.New(errors.CodeExchangeInProgress) + } else if err != gorm.ErrRecordNotFound { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询进行中换货单失败") + } + + shopID := middleware.GetShopIDFromContext(ctx) + creator := middleware.GetUserIDFromContext(ctx) + order := &model.ExchangeOrder{ + ExchangeNo: model.GenerateExchangeNo(), + OldAssetType: asset.AssetType, + OldAssetID: asset.AssetID, + OldAssetIdentifier: asset.Identifier, + ExchangeReason: req.ExchangeReason, + Remark: req.Remark, + Status: constants.ExchangeStatusPendingInfo, + MigrationCompleted: false, + MigrationBalance: 0, + MigrateData: false, + BaseModel: model.BaseModel{Creator: creator, Updater: creator}, + } + if shopID > 0 { + order.ShopID = &shopID + } + + if err = s.exchangeStore.Create(ctx, order); err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建换货单失败") + } + + return s.toExchangeOrderResponse(order), nil +} + +func (s *Service) List(ctx context.Context, req *dto.ExchangeListRequest) (*dto.ExchangeListResponse, error) { + page := req.Page + page = max(page, 1) + pageSize := req.PageSize + if pageSize < 1 { + pageSize = constants.DefaultPageSize + } + if pageSize > constants.MaxPageSize { + pageSize = constants.MaxPageSize + } + + filters := make(map[string]any) + if req.Status != nil { + filters["status"] = *req.Status + } + if req.Identifier != "" { + filters["identifier"] = req.Identifier + } + if req.CreatedAtStart != nil { + filters["created_at_start"] = *req.CreatedAtStart + } + if req.CreatedAtEnd != nil { + filters["created_at_end"] = *req.CreatedAtEnd + } + + orders, total, err := s.exchangeStore.List(ctx, filters, page, pageSize) + if err != nil { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单列表失败") + } + + list := make([]*dto.ExchangeOrderResponse, 0, len(orders)) + for _, item := range orders { + list = append(list, s.toExchangeOrderResponse(item)) + } + + return &dto.ExchangeListResponse{List: list, Total: total, Page: page, PageSize: pageSize}, nil +} + +func (s *Service) Get(ctx context.Context, id uint) (*dto.ExchangeOrderResponse, error) { + order, err := s.exchangeStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeExchangeOrderNotFound) + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单详情失败") + } + return s.toExchangeOrderResponse(order), nil +} + +func (s *Service) Ship(ctx context.Context, id uint, req *dto.ExchangeShipRequest) (*dto.ExchangeOrderResponse, error) { + order, err := s.exchangeStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeExchangeOrderNotFound) + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败") + } + if order.Status != constants.ExchangeStatusPendingShip { + return nil, errors.New(errors.CodeExchangeStatusInvalid) + } + + newAsset, err := s.resolveAssetByIdentifier(ctx, order.OldAssetType, req.NewIdentifier) + if err != nil { + return nil, err + } + if newAsset.AssetType != order.OldAssetType { + return nil, errors.New(errors.CodeExchangeAssetTypeMismatch) + } + if newAsset.AssetStatus != 1 { + return nil, errors.New(errors.CodeExchangeNewAssetNotInStock) + } + + updates := map[string]any{ + "new_asset_type": newAsset.AssetType, + "new_asset_id": newAsset.AssetID, + "new_asset_identifier": newAsset.Identifier, + "express_company": req.ExpressCompany, + "express_no": req.ExpressNo, + "migrate_data": req.MigrateData, + "updater": middleware.GetUserIDFromContext(ctx), + "updated_at": time.Now(), + } + if err = s.exchangeStore.UpdateStatus(ctx, id, constants.ExchangeStatusPendingShip, constants.ExchangeStatusShipped, updates); err != nil { + if err == gorm.ErrRecordNotFound { + return nil, errors.New(errors.CodeExchangeStatusInvalid) + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "更新换货单发货状态失败") + } + + return s.Get(ctx, id) +} + +func (s *Service) Complete(ctx context.Context, id uint) error { + order, err := s.exchangeStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeExchangeOrderNotFound) + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败") + } + if order.Status != constants.ExchangeStatusShipped { + return errors.New(errors.CodeExchangeStatusInvalid) + } + + updates := map[string]any{ + "updater": middleware.GetUserIDFromContext(ctx), + "updated_at": time.Now(), + } + if order.MigrateData { + var migrationBalance int64 + migrationBalance, err = s.executeMigration(ctx, order) + if err != nil { + return err + } + updates["migration_completed"] = true + updates["migration_balance"] = migrationBalance + } + + if err = s.exchangeStore.UpdateStatus(ctx, id, constants.ExchangeStatusShipped, constants.ExchangeStatusCompleted, updates); err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeExchangeStatusInvalid) + } + return errors.Wrap(errors.CodeDatabaseError, err, "确认换货完成失败") + } + + return nil +} + +func (s *Service) Cancel(ctx context.Context, id uint, req *dto.ExchangeCancelRequest) error { + order, err := s.exchangeStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeExchangeOrderNotFound) + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败") + } + if order.Status != constants.ExchangeStatusPendingInfo && order.Status != constants.ExchangeStatusPendingShip { + return errors.New(errors.CodeExchangeStatusInvalid) + } + + updates := map[string]any{ + "updater": middleware.GetUserIDFromContext(ctx), + "updated_at": time.Now(), + } + if req != nil { + updates["remark"] = req.Remark + } + if err = s.exchangeStore.UpdateStatus(ctx, id, order.Status, constants.ExchangeStatusCancelled, updates); err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeExchangeStatusInvalid) + } + return errors.Wrap(errors.CodeDatabaseError, err, "取消换货失败") + } + return nil +} + +func (s *Service) Renew(ctx context.Context, id uint) error { + order, err := s.exchangeStore.GetByID(ctx, id) + if err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeExchangeOrderNotFound) + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询换货单失败") + } + if order.Status != constants.ExchangeStatusCompleted { + return errors.New(errors.CodeExchangeStatusInvalid) + } + + return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error { + if order.OldAssetType == constants.ExchangeAssetTypeIotCard { + var card model.IotCard + if err = tx.Where("id = ?", order.OldAssetID).First(&card).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeAssetNotFound) + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询旧卡失败") + } + if card.AssetStatus != 3 { + return errors.New(errors.CodeExchangeAssetNotExchanged) + } + + if err = tx.Model(&model.IotCard{}).Where("id = ?", card.ID).Updates(map[string]any{ + "generation": card.Generation + 1, + "asset_status": 1, + "accumulated_recharge": 0, + "first_commission_paid": false, + "accumulated_recharge_by_series": "{}", + "first_recharge_triggered_by_series": "{}", + "updater": middleware.GetUserIDFromContext(ctx), + "updated_at": time.Now(), + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "重置旧卡转新状态失败") + } + + if err = tx.Where("virtual_no = ?", card.VirtualNo).Delete(&model.PersonalCustomerDevice{}).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败") + } + + if err = tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeIotCard, card.ID).Delete(&model.AssetWallet{}).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "清理旧钱包失败") + } + + shopTag := uint(0) + if card.ShopID != nil { + shopTag = *card.ShopID + } + if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeIotCard, ResourceID: card.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: 1, Version: 0, ShopIDTag: shopTag}).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败") + } + return nil + } + + var device model.Device + if err = tx.Where("id = ?", order.OldAssetID).First(&device).Error; err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeAssetNotFound) + } + return errors.Wrap(errors.CodeDatabaseError, err, "查询旧设备失败") + } + if device.AssetStatus != 3 { + return errors.New(errors.CodeExchangeAssetNotExchanged) + } + + if err = tx.Model(&model.Device{}).Where("id = ?", device.ID).Updates(map[string]any{ + "generation": device.Generation + 1, + "asset_status": 1, + "accumulated_recharge": 0, + "first_commission_paid": false, + "accumulated_recharge_by_series": "{}", + "first_recharge_triggered_by_series": "{}", + "updater": middleware.GetUserIDFromContext(ctx), + "updated_at": time.Now(), + }).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "重置旧设备转新状态失败") + } + + if err = tx.Where("virtual_no = ?", device.VirtualNo).Delete(&model.PersonalCustomerDevice{}).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "清理个人客户绑定失败") + } + + if err = tx.Where("resource_type = ? AND resource_id = ?", constants.ExchangeAssetTypeDevice, device.ID).Delete(&model.AssetWallet{}).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "清理旧钱包失败") + } + + shopTag := uint(0) + if device.ShopID != nil { + shopTag = *device.ShopID + } + if err = tx.Create(&model.AssetWallet{ResourceType: constants.ExchangeAssetTypeDevice, ResourceID: device.ID, Balance: 0, FrozenBalance: 0, Currency: "CNY", Status: 1, Version: 0, ShopIDTag: shopTag}).Error; err != nil { + return errors.Wrap(errors.CodeDatabaseError, err, "创建新钱包失败") + } + return nil + }) +} + +func (s *Service) GetPending(ctx context.Context, identifier string) (*dto.ClientExchangePendingResponse, error) { + asset, err := s.resolveAssetByIdentifier(ctx, "", identifier) + if err != nil { + return nil, err + } + + order, err := s.exchangeStore.FindActiveByOldAsset(ctx, asset.AssetType, asset.AssetID) + if err != nil { + if err == gorm.ErrRecordNotFound { + return nil, nil + } + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询待处理换货单失败") + } + + return &dto.ClientExchangePendingResponse{ + ID: order.ID, + ExchangeNo: order.ExchangeNo, + Status: order.Status, + StatusText: exchangeStatusText(order.Status), + ExchangeReason: order.ExchangeReason, + CreatedAt: order.CreatedAt, + }, nil +} + +func (s *Service) SubmitShippingInfo(ctx context.Context, id uint, req *dto.ClientShippingInfoRequest) error { + updates := map[string]any{ + "recipient_name": req.RecipientName, + "recipient_phone": req.RecipientPhone, + "recipient_address": req.RecipientAddress, + "updated_at": time.Now(), + } + if err := s.exchangeStore.UpdateStatus(ctx, id, constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, updates); err != nil { + if err == gorm.ErrRecordNotFound { + return errors.New(errors.CodeExchangeStatusInvalid) + } + return errors.Wrap(errors.CodeDatabaseError, err, "提交收货信息失败") + } + return nil +} + +type resolvedExchangeAsset struct { + AssetType string + AssetID uint + Identifier string + VirtualNo string + AssetStatus int + ShopID *uint + Card *model.IotCard + Device *model.Device +} + +func (s *Service) resolveAssetByIdentifier(ctx context.Context, expectedAssetType, identifier string) (*resolvedExchangeAsset, error) { + if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeDevice { + device, err := s.deviceStore.GetByIdentifier(ctx, identifier) + if err == nil { + if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeDevice { + return nil, errors.New(errors.CodeExchangeAssetTypeMismatch) + } + return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeDevice, AssetID: device.ID, Identifier: identifier, VirtualNo: device.VirtualNo, AssetStatus: device.AssetStatus, ShopID: device.ShopID, Device: device}, nil + } + if err != gorm.ErrRecordNotFound { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败") + } + } + + if expectedAssetType == "" || expectedAssetType == constants.ExchangeAssetTypeIotCard { + var card model.IotCard + query := s.db.WithContext(ctx).Where("virtual_no = ? OR iccid = ? OR msisdn = ?", identifier, identifier, identifier) + query = middleware.ApplyShopFilter(ctx, query) + if err := query.First(&card).Error; err == nil { + if expectedAssetType != "" && expectedAssetType != constants.ExchangeAssetTypeIotCard { + return nil, errors.New(errors.CodeExchangeAssetTypeMismatch) + } + return &resolvedExchangeAsset{AssetType: constants.ExchangeAssetTypeIotCard, AssetID: card.ID, Identifier: identifier, VirtualNo: card.VirtualNo, AssetStatus: card.AssetStatus, ShopID: card.ShopID, Card: &card}, nil + } else if err != gorm.ErrRecordNotFound { + return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败") + } + } + + return nil, errors.New(errors.CodeAssetNotFound) +} + +func (s *Service) toExchangeOrderResponse(order *model.ExchangeOrder) *dto.ExchangeOrderResponse { + if order == nil { + return nil + } + var deletedAt *time.Time + if order.DeletedAt.Valid { + deletedAt = &order.DeletedAt.Time + } + return &dto.ExchangeOrderResponse{ + ID: order.ID, + ExchangeNo: order.ExchangeNo, + OldAssetType: order.OldAssetType, + OldAssetID: order.OldAssetID, + OldAssetIdentifier: order.OldAssetIdentifier, + NewAssetType: order.NewAssetType, + NewAssetID: order.NewAssetID, + NewAssetIdentifier: order.NewAssetIdentifier, + RecipientName: order.RecipientName, + RecipientPhone: order.RecipientPhone, + RecipientAddress: order.RecipientAddress, + ExpressCompany: order.ExpressCompany, + ExpressNo: order.ExpressNo, + MigrateData: order.MigrateData, + MigrationCompleted: order.MigrationCompleted, + MigrationBalance: order.MigrationBalance, + ExchangeReason: order.ExchangeReason, + Remark: order.Remark, + Status: order.Status, + StatusText: exchangeStatusText(order.Status), + ShopID: order.ShopID, + CreatedAt: order.CreatedAt, + UpdatedAt: order.UpdatedAt, + DeletedAt: deletedAt, + Creator: order.Creator, + Updater: order.Updater, + } +} + +func exchangeStatusText(status int) string { + switch status { + case constants.ExchangeStatusPendingInfo: + return "待填写信息" + case constants.ExchangeStatusPendingShip: + return "待发货" + case constants.ExchangeStatusShipped: + return "已发货待确认" + case constants.ExchangeStatusCompleted: + return "已完成" + case constants.ExchangeStatusCancelled: + return "已取消" + default: + return "未知状态" + } +} diff --git a/internal/store/postgres/exchange_order_store.go b/internal/store/postgres/exchange_order_store.go new file mode 100644 index 0000000..4accbbb --- /dev/null +++ b/internal/store/postgres/exchange_order_store.go @@ -0,0 +1,106 @@ +package postgres + +import ( + "context" + "maps" + "time" + + "github.com/break/junhong_cmp_fiber/internal/model" + "github.com/break/junhong_cmp_fiber/pkg/constants" + "github.com/break/junhong_cmp_fiber/pkg/middleware" + "gorm.io/gorm" +) + +type ExchangeOrderStore struct { + db *gorm.DB +} + +func NewExchangeOrderStore(db *gorm.DB) *ExchangeOrderStore { + return &ExchangeOrderStore{db: db} +} + +func (s *ExchangeOrderStore) Create(ctx context.Context, order *model.ExchangeOrder) error { + return s.db.WithContext(ctx).Create(order).Error +} + +func (s *ExchangeOrderStore) GetByID(ctx context.Context, id uint) (*model.ExchangeOrder, error) { + var order model.ExchangeOrder + query := s.db.WithContext(ctx).Where("id = ?", id) + query = middleware.ApplyShopFilter(ctx, query) + if err := query.First(&order).Error; err != nil { + return nil, err + } + return &order, nil +} + +func (s *ExchangeOrderStore) List(ctx context.Context, filters map[string]any, page, pageSize int) ([]*model.ExchangeOrder, int64, error) { + var orders []*model.ExchangeOrder + var total int64 + + query := s.db.WithContext(ctx).Model(&model.ExchangeOrder{}) + query = middleware.ApplyShopFilter(ctx, query) + + if status, ok := filters["status"].(int); ok && status > 0 { + query = query.Where("status = ?", status) + } + if identifier, ok := filters["identifier"].(string); ok && identifier != "" { + like := "%" + identifier + "%" + query = query.Where("old_asset_identifier LIKE ? OR new_asset_identifier LIKE ?", like, like) + } + if createdAtStart, ok := filters["created_at_start"].(time.Time); ok && !createdAtStart.IsZero() { + query = query.Where("created_at >= ?", createdAtStart) + } + if createdAtEnd, ok := filters["created_at_end"].(time.Time); ok && !createdAtEnd.IsZero() { + query = query.Where("created_at <= ?", createdAtEnd) + } + + if err := query.Count(&total).Error; err != nil { + return nil, 0, err + } + + if page < 1 { + page = 1 + } + if pageSize < 1 { + pageSize = constants.DefaultPageSize + } + if pageSize > constants.MaxPageSize { + pageSize = constants.MaxPageSize + } + + offset := (page - 1) * pageSize + if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&orders).Error; err != nil { + return nil, 0, err + } + + return orders, total, nil +} + +func (s *ExchangeOrderStore) UpdateStatus(ctx context.Context, id uint, fromStatus, toStatus int, updates map[string]any) error { + values := make(map[string]any, len(updates)+1) + maps.Copy(values, updates) + values["status"] = toStatus + + result := s.db.WithContext(ctx).Model(&model.ExchangeOrder{}). + Where("id = ? AND status = ?", id, fromStatus). + Updates(values) + if result.Error != nil { + return result.Error + } + if result.RowsAffected == 0 { + return gorm.ErrRecordNotFound + } + return nil +} + +func (s *ExchangeOrderStore) FindActiveByOldAsset(ctx context.Context, assetType string, assetID uint) (*model.ExchangeOrder, error) { + var order model.ExchangeOrder + query := s.db.WithContext(ctx). + Where("old_asset_type = ? AND old_asset_id = ?", assetType, assetID). + Where("status IN ?", []int{constants.ExchangeStatusPendingInfo, constants.ExchangeStatusPendingShip, constants.ExchangeStatusShipped}) + query = middleware.ApplyShopFilter(ctx, query) + if err := query.Order("id DESC").First(&order).Error; err != nil { + return nil, err + } + return &order, nil +} diff --git a/internal/store/postgres/iot_card_store.go b/internal/store/postgres/iot_card_store.go index 4d034a1..edb38ba 100644 --- a/internal/store/postgres/iot_card_store.go +++ b/internal/store/postgres/iot_card_store.go @@ -644,14 +644,14 @@ func (s *IotCardStore) applyStandaloneFilters(ctx context.Context, query *gorm.D if isReplaced, ok := filters["is_replaced"].(bool); ok { if isReplaced { query = query.Where("id IN (?)", - s.db.WithContext(ctx).Table("tb_card_replacement_record"). - Select("old_iot_card_id"). - Where("deleted_at IS NULL")) + s.db.WithContext(ctx).Table("tb_exchange_order"). + Select("old_asset_id"). + Where("old_asset_type = ? AND status IN ? AND deleted_at IS NULL", constants.ExchangeAssetTypeIotCard, []int{constants.ExchangeStatusShipped, constants.ExchangeStatusCompleted})) } else { query = query.Where("id NOT IN (?)", - s.db.WithContext(ctx).Table("tb_card_replacement_record"). - Select("old_iot_card_id"). - Where("deleted_at IS NULL")) + s.db.WithContext(ctx).Table("tb_exchange_order"). + Select("old_asset_id"). + Where("old_asset_type = ? AND status IN ? AND deleted_at IS NULL", constants.ExchangeAssetTypeIotCard, []int{constants.ExchangeStatusShipped, constants.ExchangeStatusCompleted})) } } if seriesID, ok := filters["series_id"].(uint); ok && seriesID > 0 { @@ -836,7 +836,7 @@ func (s *IotCardStore) ListBySeriesID(ctx context.Context, seriesID uint) ([]*mo func (s *IotCardStore) UpdateRechargeTrackingFields(ctx context.Context, cardID uint, accumulatedJSON, triggeredJSON string) error { return s.db.WithContext(ctx).Model(&model.IotCard{}). Where("id = ?", cardID). - Updates(map[string]interface{}{ + Updates(map[string]any{ "accumulated_recharge_by_series": accumulatedJSON, "first_recharge_triggered_by_series": triggeredJSON, }).Error @@ -961,8 +961,8 @@ func hashFilters(filters map[string]any) string { h := fnv.New32a() for _, k := range keys { - h.Write([]byte(k)) - h.Write([]byte(fmt.Sprint(filters[k]))) + _, _ = h.Write([]byte(k)) + _, _ = fmt.Fprint(h, filters[k]) } return fmt.Sprintf("%08x", h.Sum32()) } diff --git a/internal/store/postgres/resource_tag_store.go b/internal/store/postgres/resource_tag_store.go new file mode 100644 index 0000000..1951435 --- /dev/null +++ b/internal/store/postgres/resource_tag_store.go @@ -0,0 +1,30 @@ +package postgres + +import ( + "context" + + "github.com/break/junhong_cmp_fiber/internal/model" + "gorm.io/gorm" +) + +type ResourceTagStore struct { + db *gorm.DB +} + +func NewResourceTagStore(db *gorm.DB) *ResourceTagStore { + return &ResourceTagStore{db: db} +} + +func (s *ResourceTagStore) ListByResource(ctx context.Context, resourceType string, resourceID uint) ([]*model.ResourceTag, error) { + var list []*model.ResourceTag + if err := s.db.WithContext(ctx). + Where("resource_type = ? AND resource_id = ?", resourceType, resourceID). + Find(&list).Error; err != nil { + return nil, err + } + return list, nil +} + +func (s *ResourceTagStore) Create(ctx context.Context, item *model.ResourceTag) error { + return s.db.WithContext(ctx).Create(item).Error +} diff --git a/migrations/000085_add_exchange_order.down.sql b/migrations/000085_add_exchange_order.down.sql new file mode 100644 index 0000000..cd4142f --- /dev/null +++ b/migrations/000085_add_exchange_order.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS tb_exchange_order; diff --git a/migrations/000085_add_exchange_order.up.sql b/migrations/000085_add_exchange_order.up.sql new file mode 100644 index 0000000..0a73664 --- /dev/null +++ b/migrations/000085_add_exchange_order.up.sql @@ -0,0 +1,75 @@ +CREATE TABLE IF NOT EXISTS tb_exchange_order ( + id BIGSERIAL PRIMARY KEY, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + deleted_at TIMESTAMPTZ, + creator BIGINT NOT NULL DEFAULT 0, + updater BIGINT NOT NULL DEFAULT 0, + + exchange_no VARCHAR(50) NOT NULL, + + old_asset_type VARCHAR(20) NOT NULL, + old_asset_id BIGINT NOT NULL, + old_asset_identifier VARCHAR(100) NOT NULL, + + new_asset_type VARCHAR(20) DEFAULT '', + new_asset_id BIGINT, + new_asset_identifier VARCHAR(100) DEFAULT '', + + recipient_name VARCHAR(50) DEFAULT '', + recipient_phone VARCHAR(20) DEFAULT '', + recipient_address TEXT DEFAULT '', + + express_company VARCHAR(100) DEFAULT '', + express_no VARCHAR(100) DEFAULT '', + + migrate_data BOOLEAN NOT NULL DEFAULT FALSE, + migration_completed BOOLEAN NOT NULL DEFAULT FALSE, + migration_balance BIGINT NOT NULL DEFAULT 0, + + exchange_reason VARCHAR(100) NOT NULL, + remark TEXT, + status INT NOT NULL DEFAULT 1, + + shop_id BIGINT +); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_exchange_order_no + ON tb_exchange_order (exchange_no) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_exchange_order_old_asset + ON tb_exchange_order (old_asset_type, old_asset_id) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_exchange_order_status + ON tb_exchange_order (status) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_exchange_order_shop_id + ON tb_exchange_order (shop_id) + WHERE deleted_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_exchange_order_created_at + ON tb_exchange_order (created_at DESC); + +COMMENT ON TABLE tb_exchange_order IS '换货订单表'; +COMMENT ON COLUMN tb_exchange_order.exchange_no IS '换货单号(EXC+日期+随机数)'; +COMMENT ON COLUMN tb_exchange_order.old_asset_type IS '旧资产类型(iot_card/device)'; +COMMENT ON COLUMN tb_exchange_order.old_asset_id IS '旧资产ID'; +COMMENT ON COLUMN tb_exchange_order.old_asset_identifier IS '旧资产标识符(ICCID/虚拟号)'; +COMMENT ON COLUMN tb_exchange_order.new_asset_type IS '新资产类型(iot_card/device)'; +COMMENT ON COLUMN tb_exchange_order.new_asset_id IS '新资产ID'; +COMMENT ON COLUMN tb_exchange_order.new_asset_identifier IS '新资产标识符(ICCID/虚拟号)'; +COMMENT ON COLUMN tb_exchange_order.recipient_name IS '收件人姓名'; +COMMENT ON COLUMN tb_exchange_order.recipient_phone IS '收件人电话'; +COMMENT ON COLUMN tb_exchange_order.recipient_address IS '收货地址'; +COMMENT ON COLUMN tb_exchange_order.express_company IS '快递公司'; +COMMENT ON COLUMN tb_exchange_order.express_no IS '快递单号'; +COMMENT ON COLUMN tb_exchange_order.migrate_data IS '是否执行全量迁移'; +COMMENT ON COLUMN tb_exchange_order.migration_completed IS '迁移是否已完成'; +COMMENT ON COLUMN tb_exchange_order.migration_balance IS '迁移转移金额(分)'; +COMMENT ON COLUMN tb_exchange_order.exchange_reason IS '换货原因'; +COMMENT ON COLUMN tb_exchange_order.remark IS '备注'; +COMMENT ON COLUMN tb_exchange_order.status IS '换货状态 1-待填写信息 2-待发货 3-已发货待确认 4-已完成 5-已取消'; +COMMENT ON COLUMN tb_exchange_order.shop_id IS '所属店铺ID'; diff --git a/migrations/000086_rename_card_replacement_to_legacy.down.sql b/migrations/000086_rename_card_replacement_to_legacy.down.sql new file mode 100644 index 0000000..617ea9a --- /dev/null +++ b/migrations/000086_rename_card_replacement_to_legacy.down.sql @@ -0,0 +1 @@ +ALTER TABLE tb_card_replacement_record_legacy RENAME TO tb_card_replacement_record; diff --git a/migrations/000086_rename_card_replacement_to_legacy.up.sql b/migrations/000086_rename_card_replacement_to_legacy.up.sql new file mode 100644 index 0000000..9e09883 --- /dev/null +++ b/migrations/000086_rename_card_replacement_to_legacy.up.sql @@ -0,0 +1 @@ +ALTER TABLE tb_card_replacement_record RENAME TO tb_card_replacement_record_legacy; diff --git a/openspec/changes/client-exchange-system/.openspec.yaml b/openspec/changes/client-exchange-system/.openspec.yaml new file mode 100644 index 0000000..3c861dd --- /dev/null +++ b/openspec/changes/client-exchange-system/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-18 diff --git a/openspec/changes/client-exchange-system/design.md b/openspec/changes/client-exchange-system/design.md new file mode 100644 index 0000000..3e3f941 --- /dev/null +++ b/openspec/changes/client-exchange-system/design.md @@ -0,0 +1,149 @@ +# 设计文档:客户端换货系统(client-exchange-system) + +## 背景与上下文 + +现有换卡能力基于 `CardReplacementRecord`,仅覆盖“老卡→新卡”的窄场景,无法支撑本次目标中的完整换货闭环(后台发起、客户端填收货、后台发货、确认完成、可选全量迁移、旧资产转新再销售)。 + +当前主要问题: + +1. **模型能力不足**(`internal/model/card_replacement.go:11-71`) + - 只支持卡换卡,不支持设备换设备。 + - 缺少客户端收货地址、后台物流信息、迁移结果字段。 + - 状态机不匹配本次流程(待填写→待发货→已发货待确认→已完成/已取消)。 + +2. **历史代码字段不一致风险**(`internal/store/postgres/iot_card_store.go:644-655`) + - 现有查询使用 `old_iot_card_id` 维度过滤换卡记录,但旧模型字段命名是 `old_card_id`,存在语义/列名不一致隐患。 + - `is_replaced` 逻辑依赖旧表,不适配新换货单模型。 + +3. **旧模型未纳入统一迁移体系** + - `CardReplacementRecord` 没有持续参与当前主线 AutoMigrate 维护,演进风险高。 + +4. **资产迁移链路涉及多模型联动,旧方案无法表达** + - 钱包与流水:`internal/model/asset_wallet.go:9-35`(`resource_type + resource_id`) + - 套餐使用:`internal/model/package.go:57-87` + - 标签:`internal/model/tag.go:25-41` + - 客户设备绑定:`internal/model/personal_customer_device.go:9-23` + - 设备卡绑定:`internal/model/device_sim_binding.go:9-24` + - 分佣记录:`internal/model/commission.go:9-30` + - 流量明细:`internal/model/data_usage.go:7-23` + - 卡累计字段:`internal/model/iot_card.go:41-44`(`FirstCommissionPaid`、`AccumulatedRecharge`、`AccumulatedRechargeBySeriesJSON`、`FirstRechargeTriggeredBySeriesJSON`) + +5. **模块接入需遵循统一 Bootstrap 装配模式** + - 参考 `internal/bootstrap/handlers.go:12-62`、`internal/bootstrap/types.go:13-60`。 + +## 目标与非目标 + +### Goals + +1. 提供完整换货生命周期能力: + - 后台 7 个接口(H1~H7) + - 客户端 2 个接口(G1~G2) +2. 在 H5 确认完成时支持可选“全量迁移”(11 张表规则)。 +3. 支持旧资产“转新”再销售(generation+1、状态重置、历史隔离)。 +4. 替换旧换卡模型引用,统一到 ExchangeOrder。 + +### Non-Goals + +1. 不对接第三方物流轨迹查询(仅记录物流公司/单号)。 +2. 不实现主动消息推送(客户端通过 G1 轮询换货通知)。 + +## 关键设计决策 + +### 决策 1:ExchangeOrder 模型设计 + +引入新模型 `ExchangeOrder`,作为换货生命周期唯一事实来源,字段覆盖: + +- 基础字段:`gorm.Model + BaseModel` +- 单号:`exchange_no` +- 旧资产快照:`old_asset_type`、`old_asset_id`、`old_asset_identifier` +- 新资产快照:`new_asset_type`、`new_asset_id`、`new_asset_identifier` +- 收货信息:`recipient_name`、`recipient_phone`、`recipient_address` +- 物流信息:`express_company`、`express_no` +- 迁移结果:`migrate_data`、`migration_completed`、`migration_balance` +- 业务信息:`exchange_reason`、`remark`、`status` +- 多租户:`shop_id` + +换货单号生成规则:`EXC` + 日期 + 随机数(示例:`EXC20260319XXXXXX`)。 + +### 决策 2:状态机由 Service 层强校验 + +`status` 采用 int 常量: + +- 1 待填写信息 +- 2 待发货 +- 3 已发货待确认 +- 4 已完成 +- 5 已取消 + +状态流转在 Service 层校验,不使用数据库触发器。理由: + +1. 业务规则集中在 Go 代码,便于复用和审计。 +2. 避免跨环境数据库触发器差异。 +3. 更易与错误码体系、权限体系协同。 + +### 决策 3:发货时执行同类型资产校验 + +在 H4 发货阶段强制校验: + +1. `new_asset_type == old_asset_type`(卡换卡 / 设备换设备)。 +2. 新资产必须 `asset_status=1`(在库)。 + +该校验放在“发货”而非“创建”,因为创建时允许先立单、后备货。 + +### 决策 4:全量迁移使用单一大事务(11 张表) + +H5 在 `migrate_data=true` 时,使用**一个数据库事务**完成 11 张表相关操作。理由: + +1. 迁移一致性优先,必须保证“要么全成功,要么全失败”。 +2. 换货属于低频运营操作,非高并发核心交易路径。 +3. 单资产迁移涉及行数有限,可接受事务时间。 + +补充规则:设备换设备时,不迁移 `DeviceSimBinding`(新设备视为自带新卡体系)。 + +### 决策 5:转新采用 generation 隔离历史 + +H7 转新时: + +1. `generation = generation + 1` +2. 不删除旧代际历史数据(订单、充值、分佣、流量等) +3. 创建新空钱包(新 `wallet_id` 天然隔离流水) +4. 清除累计充值/首充触发状态 +5. 清除客户绑定关系 + +通过“新代际 + 新钱包”实现可回收再销售,同时不破坏历史可追溯。 + +### 决策 6:旧模型降级为 legacy,不回灌迁移 + +`CardReplacementRecord` 对应表改名为 `tb_card_replacement_record_legacy`,但**不迁移历史数据到新表**。理由: + +1. 旧数据量小,保留查询价值即可。 +2. 历史数据结构与新模型语义不完全一致,强行回灌成本高且收益低。 + +`iot_card_store.go` 中 `is_replaced` 过滤逻辑改为查询 `ExchangeOrder`,不再依赖旧表。 + +### 决策 7:依托现有多租户 Callback 自动过滤 + +`ExchangeOrder` 增加 `shop_id` 字段,直接接入现有 GORM 数据权限 Callback,避免重复实现权限 where 条件。 + +## 风险与权衡 + +1. **[风险] 全量迁移事务锁表时间增长** + - 权衡:换货低频,且单次仅操作单资产关联记录,影响可接受。 + +2. **[风险] 转新后旧客户仍持有旧虚拟号认知** + - 权衡:`PersonalCustomerDevice` 绑定会清除,旧客户再次登录会被要求重新绑定,避免继续访问新代际资产。 + +3. **[风险] 设备换设备不迁移 DeviceSimBinding 造成“看起来少迁移”** + - 权衡:这是显式设计决策;新设备按“新硬件+新卡”交付,旧设备卡绑定保留历史关系。 + +4. **[风险] 迁移期 CMP 与 Gateway 状态观测不一致** + - 权衡:本次迁移仅操作 CMP 数据库,不调用 Gateway;运营商侧状态由新资产实际使用逐步收敛。 + +## 迁移计划 + +1. 新建 `tb_exchange_order` 表。 +2. 将 `tb_card_replacement_record` 改名为 `tb_card_replacement_record_legacy`。 +3. 代码层替换: + - Store/Service/Handler 查询改用 ExchangeOrder + - `is_replaced` 等旧逻辑改为新表判定 +4. 在 bootstrap、routes、docs 生成器中注册新 Handler(含 `cmd/api/docs.go` 与 `cmd/gendocs/main.go`)。 diff --git a/openspec/changes/client-exchange-system/proposal.md b/openspec/changes/client-exchange-system/proposal.md new file mode 100644 index 0000000..9e5be2c --- /dev/null +++ b/openspec/changes/client-exchange-system/proposal.md @@ -0,0 +1,162 @@ +## Why + +现有 `CardReplacementRecord` 模型仅支持简单换卡,无法满足完整换货需求:缺少收货地址、快递信息、设备换货、全量数据迁移等功能。客户端换货场景中,后台发起换货 → 客户端收到通知填写收货信息 → 后台发货+确认完成(含全量迁移)→ 旧资产可"转新"重新销售,是一个跨后台/客户端的完整业务闭环。 + +**前置依赖**:提案 0(`asset_status`/`generation` 字段已就位)、提案 1(客户端认证)。 + +## What Changes + +### 新增模型 + +- **ExchangeOrder(换货单)**:完整的换货生命周期模型,包含旧/新资产信息、收货地址、物流信息、迁移状态。状态机:`1-待填写信息 → 2-待发货 → 3-已发货待确认 → 4-已完成`,1 或 2 时可取消(→5) + +### 删除旧模型 + +- **CardReplacementRecord**:表改名为 `tb_card_replacement_record_legacy`,代码引用替换为 ExchangeOrder + +### 后台换货管理(模块 H,7 个接口) + +- **H1 发起换货** `POST /api/admin/exchanges`:验证资产无进行中换货单,创建 status=1 +- **H2 换货列表** `GET /api/admin/exchanges`:支持状态筛选、资产标识符搜索、时间范围 +- **H3 换货详情** `GET /api/admin/exchanges/:id` +- **H4 发货** `POST /api/admin/exchanges/:id/ship`:填写物流信息+新资产标识符,验证 status=2、同类型资产、新资产 asset_status=1(在库) +- **H5 确认完成** `POST /api/admin/exchanges/:id/complete`:验证 status=3,如 migrate_data=true 则执行全量迁移(11 张表事务内操作),旧资产 asset_status→3 +- **H6 取消换货** `POST /api/admin/exchanges/:id/cancel`:验证 status IN (1,2),已发货不可取消 +- **H7 旧资产转新** `POST /api/admin/exchanges/:id/renew`:旧资产 asset_status 从 3→1,generation+1,清除客户绑定和累计状态,创建新空钱包 + +### 客户端换货(模块 G,2 个接口) + +- **G1 查询换货通知** `GET /api/c/v1/exchange/pending?identifier=xxx`:查是否有进行中的换货单 +- **G2 填写收货信息** `POST /api/c/v1/exchange/:id/shipping-info`:验证 status=1,更新收货信息,status→2 + +### 换货状态机 + +``` +后台发起换货 + │ + ▼ +┌─────────────────────┐ +│ 1-待填写信息 │ ←── ExchangeOrder 创建 +│ (等待客户端填写) │ +└──────────┬──────────┘ + │ + 客户端填写收货信息 [G2] + │ + ▼ +┌─────────────────────┐ +│ 2-待发货 │ +│ (等待后台填写物流) │ +└──────────┬──────────┘ + │ + 后台发货 [H4] (填物流+新资产) + │ + ▼ +┌─────────────────────┐ +│ 3-已发货待确认 │ +│ (等待后台确认完成) │ +└──────────┬──────────┘ + │ + 后台确认完成 [H5] + (可选: 全量迁移) + │ + ▼ +┌─────────────────────┐ +│ 4-已完成 │ +└─────────────────────┘ + +取消: status=1 或 2 时可取消 → 5-已取消 +已发货(status=3)后不可取消 +``` + +### 全量迁移流程(H5 确认完成时触发) + +``` +确认完成 (migrate_data=true) + │ + ▼ +事务开始 + │ + ├── 1. 钱包余额转移 + │ 旧 AssetWallet.Balance → 新 AssetWallet + │ 生成迁移流水 AssetWalletTransaction + │ + ├── 2. 生效中套餐关联新资产 + │ PackageUsage WHERE iot_card_id/device_id=旧 AND status IN (生效中) + │ → UPDATE iot_card_id/device_id = 新 + │ + ├── 3. 累计充值/首充状态迁移 + │ IotCard/Device 的 AccumulatedRecharge/FirstCommissionPaid + │ 等字段复制到新资产 + │ + ├── 4. 标签复制 + │ ResourceTag WHERE resource_type=? AND resource_id=旧 + │ → 为新资产创建相同标签 + │ + ├── 5. 个人客户绑定更新 + │ PersonalCustomerDevice WHERE virtual_no=旧虚拟号 + │ → UPDATE virtual_no = 新虚拟号 + │ + ├── 6. 旧资产标记 + │ 旧资产 asset_status → 3(已换货) + │ + └── 7. 记录迁移信息 + ExchangeOrder: migration_completed=true, + migration_balance=转移金额 + │ + ▼ +事务提交 + │ + ▼ +ExchangeOrder status → 4(已完成) + +注意: +- 设备换设备时不迁移 DeviceSimBinding(卡绑定关系) +- 新设备自带新的 SIM 卡,旧设备的卡绑定保持不变 +- 保留不修改的表: tb_order, tb_commission, tb_data_usage_record, + tb_asset_recharge_record(历史记录保留,通过 generation 隔离) +``` + +### 转新流程(H7) + +``` +旧资产 (asset_status=3 已换货) + │ + ▼ +POST /api/admin/exchanges/:id/renew + │ + ├── 1. asset_status: 3 → 1(在库) + ├── 2. generation: +1(进入新世代) + ├── 3. 清除: 累计充值状态、首充触发状态 + ├── 4. 清除: PersonalCustomerDevice 绑定 + ├── 5. 创建新空钱包(新 wallet_id) + └── 6. 不删除历史数据(通过 generation 隔离) + │ + ▼ +旧资产可重新销售给新客户 +新客户查询时按当前 generation 过滤 +看不到旧周期数据 +``` + +## Capabilities + +### New Capabilities + +- `exchange-order-model`:ExchangeOrder 模型定义、状态机、状态常量、换货单号生成规则 +- `exchange-admin-management`:后台换货管理 CRUD(H1~H3)、发货(H4,含同类型资产校验+新资产在库校验)、确认完成(H5,含全量迁移事务)、取消(H6)、转新(H7,含 generation 自增+状态重置) +- `exchange-data-migration`:全量迁移逻辑,11 张数据表的事务内操作规则,设备不迁移卡绑定的特殊规则 +- `exchange-client-notification`:客户端换货通知查询(G1)、收货信息填写(G2) + +### Modified Capabilities + +- `iot-card`:IotCard 新增换货相关行为——`asset_status=3` 标记、转新时 generation 自增+状态重置 +- `device`:Device 同上 +- `personal-customer`:PersonalCustomerDevice 绑定关系在换货迁移时更新虚拟号 +- `card-replacement`:**REMOVED** — CardReplacementRecord 模型废弃,表改名为 legacy,代码引用替换为 ExchangeOrder + +## Impact + +- **新增文件**:`internal/model/exchange_order.go`(模型);`internal/handler/admin/exchange.go`(后台 Handler);`internal/handler/app/client_exchange.go`(客户端 Handler);`internal/service/exchange/service.go`(Service,含迁移逻辑);`internal/store/postgres/exchange_order_store.go`(Store);DTO 文件;迁移文件;常量和错误码 +- **修改文件**:`internal/model/card_replacement.go`(删除或标记废弃);`internal/store/postgres/iot_card_store.go`(移除 `is_replaced` 过滤改为查新表);`internal/model/system.go`(AutoMigrate 移除旧模型+注册新模型);`internal/routes/`(新增后台+客户端路由);`internal/bootstrap/`(注册新模块);`cmd/api/docs.go` + `cmd/gendocs/main.go`(文档生成器) +- **新增 API 路由**:后台 `/api/admin/exchanges/` 下 7 个端点 + 客户端 `/api/c/v1/exchange/` 下 2 个端点 +- **数据库变更**:新建 `tb_exchange_order` 表;旧表 `tb_card_replacement_record` 改名为 `tb_card_replacement_record_legacy` +- **全量迁移涉及 11 张表**:`tb_asset_wallet`、`tb_asset_wallet_transaction`、`tb_asset_recharge_record`、`tb_package_usage`、`tb_package_usage_daily_record`、`tb_order`、`tb_commission`、`tb_data_usage_record`、`tb_resource_tag`、`tb_personal_customer_device`、`tb_iot_card`/`tb_device` diff --git a/openspec/changes/client-exchange-system/specs/card-replacement/spec.md b/openspec/changes/client-exchange-system/specs/card-replacement/spec.md new file mode 100644 index 0000000..479d661 --- /dev/null +++ b/openspec/changes/client-exchange-system/specs/card-replacement/spec.md @@ -0,0 +1,31 @@ +## MODIFIED Requirements + +### Requirement: 废弃旧换卡模型能力 + +系统 MUST 废弃 `CardReplacementRecord` 作为主业务能力,原因是其仅覆盖卡换卡且缺少收货信息、物流信息、设备换货与全量迁移能力,无法满足当前换货闭环需求。 + +#### Scenario: 新换货流程不再写入旧模型 +- **WHEN** 执行任意新换货流程(H1~H7、G1~G2) +- **THEN** 系统 MUST 仅读写 `ExchangeOrder`,不再创建 `CardReplacementRecord` 新记录 + +--- + +### Requirement: 旧表迁移为 legacy 保留查询 + +系统 SHALL 将 `tb_card_replacement_record` 改名为 `tb_card_replacement_record_legacy`,仅用于历史查询保留。 + +系统 MUST NOT 将 legacy 数据回灌到 `tb_exchange_order`。 + +#### Scenario: legacy 数据保留但不参与新流程 +- **WHEN** 运营查询历史老换卡记录 +- **THEN** 系统可从 legacy 表读取历史数据,但新换货流程 SHALL 不依赖该表 + +--- + +### Requirement: 旧代码引用替换 + +系统 MUST 将旧换卡引用替换为 `ExchangeOrder`,包括 `iot_card_store.go` 中 `is_replaced` 过滤逻辑。 + +#### Scenario: is_replaced 基于新换货单判定 +- **WHEN** 查询 IoT 卡并使用 `is_replaced=true` 过滤 +- **THEN** 系统 MUST 基于 `ExchangeOrder` 状态判定是否已发生换货,而非 legacy 表 diff --git a/openspec/changes/client-exchange-system/specs/device/spec.md b/openspec/changes/client-exchange-system/specs/device/spec.md new file mode 100644 index 0000000..39927ac --- /dev/null +++ b/openspec/changes/client-exchange-system/specs/device/spec.md @@ -0,0 +1,24 @@ +## MODIFIED Requirements + +### Requirement: 设备换货状态语义扩展 + +系统 SHALL 将 `asset_status=3` 定义为“已换货”,用于标记已被换出的旧设备资产。 + +#### Scenario: 换货完成后旧设备标记 +- **WHEN** H5 确认完成且旧资产为设备 +- **THEN** 系统 MUST 将旧设备 `asset_status` 更新为 `3` + +--- + +### Requirement: 设备转新重置规则 + +系统 SHALL 在 H7 转新时对设备执行以下重置: +- `generation = generation + 1` +- `asset_status = 1`(在库) +- 清空累计充值与首充触发相关状态 +- 清除个人客户绑定关系 +- 创建新空钱包并与新代际设备关联 + +#### Scenario: 转新后设备可重新销售 +- **WHEN** 对已换货设备执行转新 +- **THEN** 系统 MUST 使该设备进入新代际并恢复在库可售 diff --git a/openspec/changes/client-exchange-system/specs/exchange-admin-management/spec.md b/openspec/changes/client-exchange-system/specs/exchange-admin-management/spec.md new file mode 100644 index 0000000..b77372c --- /dev/null +++ b/openspec/changes/client-exchange-system/specs/exchange-admin-management/spec.md @@ -0,0 +1,121 @@ +## ADDED Requirements + +### Requirement: H1 发起换货单 + +系统 SHALL 提供 `POST /api/admin/exchanges`(需后台认证 `Auth=true`),用于发起换货单。 + +请求体 MUST 包含:`old_asset_type`、`old_identifier`、`exchange_reason`,可选 `remark`。 + +系统 MUST 校验: +- 旧资产存在且当前用户有权限 +- 同一资产不存在进行中的换货单(`status IN (1,2,3)`) + +成功响应 SHALL 返回新建换货单信息(含 `id`、`exchange_no`、`status=1`)。 + +错误响应 MUST 至少包含:参数错误、资产不存在或无权限、存在进行中换货单。 + +#### Scenario: 资产已有进行中换货单 +- **WHEN** 后台为同一资产重复发起换货 +- **THEN** 系统 MUST 拒绝创建并返回“存在进行中的换货单” + +--- + +### Requirement: H2 换货单列表 + +系统 SHALL 提供 `GET /api/admin/exchanges`(`Auth=true`),支持分页与条件查询。 + +查询条件 SHOULD 支持:`status`、`identifier`(资产标识搜索)、`created_at_start`、`created_at_end`、分页参数。 + +响应 SHALL 返回列表与分页元数据。 + +#### Scenario: 按状态查询待发货单 +- **WHEN** 运营查询 `status=2` +- **THEN** 系统返回所有待发货换货单并按创建时间倒序 + +--- + +### Requirement: H3 换货单详情 + +系统 SHALL 提供 `GET /api/admin/exchanges/:id`(`Auth=true`)查询换货单详情。 + +响应 MUST 返回旧/新资产信息、收货信息、物流信息、迁移状态信息。 + +错误响应 MUST 至少包含:换货单不存在或无权限。 + +#### Scenario: 查询不存在换货单 +- **WHEN** 查询不存在的换货单 ID +- **THEN** 系统 MUST 返回“资源不存在或无权限” + +--- + +### Requirement: H4 发货 + +系统 SHALL 提供 `POST /api/admin/exchanges/:id/ship`(`Auth=true`)。 + +请求体 MUST 包含:`express_company`、`express_no`、`new_identifier`、`migrate_data`。 + +系统 MUST 校验: +- 当前状态必须为 `2` +- 新旧资产类型必须一致(卡换卡/设备换设备) +- 新资产必须 `asset_status=1`(在库) + +成功后 SHALL 更新新资产信息、物流信息并将状态改为 `3`。 + +错误响应 MUST 至少包含:非法状态、资产类型不匹配、新资产非在库、资产不存在或无权限。 + +#### Scenario: 新资产类型不一致 +- **WHEN** 旧资产为 iot_card 且新资产为 device +- **THEN** 系统 MUST 拒绝发货并返回“换货资产类型必须一致” + +--- + +### Requirement: H5 确认完成 + +系统 SHALL 提供 `POST /api/admin/exchanges/:id/complete`(`Auth=true`)。 + +系统 MUST 校验当前状态为 `3`。当 `migrate_data=true` 时,系统 MUST 执行全量迁移事务(见 `exchange-data-migration` 能力)。 + +成功后 SHALL: +- `migration_completed=true`(若执行迁移) +- 换货单状态更新为 `4` + +错误响应 MUST 至少包含:非法状态、迁移失败、换货单不存在或无权限。 + +#### Scenario: 需要迁移并完成 +- **WHEN** 状态为 `3` 且 `migrate_data=true` +- **THEN** 系统 MUST 在事务成功后将状态变为 `4` 并记录迁移结果 + +--- + +### Requirement: H6 取消换货 + +系统 SHALL 提供 `POST /api/admin/exchanges/:id/cancel`(`Auth=true`)。 + +系统 MUST 仅允许在 `status IN (1,2)` 时取消,成功后状态更新为 `5`。 + +系统 MUST 禁止已发货单取消(`status=3`)。 + +#### Scenario: 已发货单取消失败 +- **WHEN** 换货单状态为 `3` 发起取消 +- **THEN** 系统 MUST 返回状态非法错误 + +--- + +### Requirement: H7 旧资产转新 + +系统 SHALL 提供 `POST /api/admin/exchanges/:id/renew`(`Auth=true`)。 + +系统 MUST 校验旧资产当前 `asset_status=3`(已换货),并执行: +- `generation + 1` +- `asset_status -> 1` +- 清除累计充值/首充相关状态 +- 清除个人客户绑定 +- 创建新空钱包 + +系统 MUST 保留历史数据,不执行历史删除。 + +错误响应 MUST 至少包含:资产状态不满足转新条件、换货单不存在或无权限。 + +#### Scenario: 旧资产未处于已换货状态 +- **WHEN** 旧资产 `asset_status != 3` 发起转新 +- **THEN** 系统 MUST 拒绝并返回“资产当前状态不允许转新” diff --git a/openspec/changes/client-exchange-system/specs/exchange-client-notification/spec.md b/openspec/changes/client-exchange-system/specs/exchange-client-notification/spec.md new file mode 100644 index 0000000..b3eac39 --- /dev/null +++ b/openspec/changes/client-exchange-system/specs/exchange-client-notification/spec.md @@ -0,0 +1,35 @@ +## ADDED Requirements + +### Requirement: G1 查询进行中换货通知 + +系统 SHALL 提供 `GET /api/c/v1/exchange/pending?identifier=xxx`(需个人客户认证 `Auth=true`)。 + +系统 MUST 根据资产标识查询当前客户可见的进行中换货单,仅返回 `status IN (1,2,3)` 的记录。 + +响应 SHALL 至少包含:换货单 ID、单号、状态、换货原因、创建时间。 + +错误响应 MUST 至少包含:参数错误、资产不存在或无权限。 + +#### Scenario: 命中进行中换货单 +- **WHEN** 客户按资产标识查询且存在状态为 2 的换货单 +- **THEN** 系统返回该换货单并标识当前状态为待发货 + +--- + +### Requirement: G2 填写收货信息 + +系统 SHALL 提供 `POST /api/c/v1/exchange/:id/shipping-info`(需个人客户认证 `Auth=true`)。 + +请求体 MUST 包含:`recipient_name`、`recipient_phone`、`recipient_address`。 + +系统 MUST 校验: +- 换货单存在且当前客户有权限 +- 当前状态必须为 `1` + +成功后 SHALL 写入收货信息并将状态更新为 `2`。 + +错误响应 MUST 至少包含:参数错误、状态非法、换货单不存在或无权限。 + +#### Scenario: 非待填写状态禁止更新收货信息 +- **WHEN** 换货单当前状态为 `2` 或 `3` +- **THEN** 系统 MUST 拒绝填写并返回状态非法错误 diff --git a/openspec/changes/client-exchange-system/specs/exchange-data-migration/spec.md b/openspec/changes/client-exchange-system/specs/exchange-data-migration/spec.md new file mode 100644 index 0000000..076b816 --- /dev/null +++ b/openspec/changes/client-exchange-system/specs/exchange-data-migration/spec.md @@ -0,0 +1,60 @@ +## ADDED Requirements + +### Requirement: 全量迁移事务边界 + +系统 MUST 在 H5 确认完成且 `migrate_data=true` 时,使用**单一数据库事务**执行全量迁移。 + +该事务 SHALL 覆盖资产钱包、套餐、标签、客户绑定及资产状态更新等所有步骤;任一步骤失败 MUST 回滚。 + +#### Scenario: 迁移中途失败回滚 +- **WHEN** 迁移第 N 步发生数据库错误 +- **THEN** 系统 MUST 回滚整个事务,换货单状态保持未完成 + +--- + +### Requirement: 11 张表迁移规则 + +系统 SHALL 按以下规则处理 11 张表: + +1. `tb_asset_wallet`:将旧资产钱包余额转移到新资产钱包。 +2. `tb_asset_wallet_transaction`:生成一条迁移流水记录(明确来源钱包、目标钱包、金额、业务类型)。 +3. `tb_asset_recharge_record`:历史充值记录保留,不做更新。 +4. `tb_package_usage`:将生效套餐关联到新资产(更新 `iot_card_id` 或 `device_id`)。 +5. `tb_package_usage_daily_record`:随 `tb_package_usage` 关系迁移(保持套餐日明细连续性)。 +6. `tb_order`:历史订单保留,不做更新。 +7. `tb_commission`:历史分佣记录保留,不做更新。 +8. `tb_data_usage_record`:历史流量记录保留,不做更新。 +9. `tb_resource_tag`:复制旧资产标签到新资产。 +10. `tb_personal_customer_device`:将绑定记录中的 `virtual_no` 更新为新资产虚拟号。 +11. `tb_iot_card`/`tb_device`:迁移累计充值与首充状态到新资产,并将旧资产 `asset_status -> 3`。 + +#### Scenario: 钱包余额转移并记录流水 +- **WHEN** 旧资产钱包余额为 5000 分 +- **THEN** 新资产钱包余额增加 5000 分,旧钱包余额按迁移策略清零,并写入迁移流水 + +--- + +### Requirement: 设备换设备特殊规则 + +设备换设备流程 MUST NOT 迁移 `DeviceSimBinding`。 + +系统 SHALL 视新设备为新硬件交付,新设备卡绑定由其自身体系决定,旧设备绑定关系保留历史。 + +#### Scenario: 设备换设备不复制绑定卡 +- **WHEN** 执行设备换设备全量迁移 +- **THEN** 系统 MUST 不创建或复制任何 `DeviceSimBinding` 记录到新设备 + +--- + +### Requirement: 转新规则 + +系统 SHALL 在 H7 转新时执行代际隔离策略: +- 资产 `generation + 1` +- 创建新空钱包(新 `wallet_id`) +- 清除累计充值状态与首充触发状态 +- 清除 `PersonalCustomerDevice` 绑定 +- 不删除历史业务数据 + +#### Scenario: 转新后历史数据保留 +- **WHEN** 资产转新完成 +- **THEN** 历史订单、充值、分佣、流量数据 MUST 仍可在旧代际查询链路中追溯 diff --git a/openspec/changes/client-exchange-system/specs/exchange-order-model/spec.md b/openspec/changes/client-exchange-system/specs/exchange-order-model/spec.md new file mode 100644 index 0000000..28a100a --- /dev/null +++ b/openspec/changes/client-exchange-system/specs/exchange-order-model/spec.md @@ -0,0 +1,69 @@ +## ADDED Requirements + +### Requirement: ExchangeOrder 换货单模型定义 + +系统 SHALL 定义 `ExchangeOrder` 模型并映射到 `tb_exchange_order`,用于承载客户端换货完整生命周期。 + +模型字段 MUST 至少包含: +- 基础:`id`、`created_at`、`updated_at`、`deleted_at`、`creator`、`updater` +- 单号:`exchange_no` +- 旧资产:`old_asset_type`、`old_asset_id`、`old_asset_identifier` +- 新资产:`new_asset_type`、`new_asset_id`、`new_asset_identifier` +- 收货:`recipient_name`、`recipient_phone`、`recipient_address` +- 物流:`express_company`、`express_no` +- 迁移:`migrate_data`、`migration_completed`、`migration_balance` +- 业务:`exchange_reason`、`remark`、`status` +- 多租户:`shop_id` + +`ExchangeOrder` SHALL 嵌入 `BaseModel` 并实现 `TableName() string`,返回 `tb_exchange_order`。 + +#### Scenario: 创建换货单模型实例 +- **WHEN** 系统创建新的换货单记录 +- **THEN** 记录 MUST 同时包含旧资产快照、收货信息占位、迁移状态字段和多租户字段 + +--- + +### Requirement: 换货状态常量定义 + +系统 MUST 使用 int 常量定义换货状态: +- `1` 待填写信息 +- `2` 待发货 +- `3` 已发货待确认 +- `4` 已完成 +- `5` 已取消 + +#### Scenario: 状态常量一致性 +- **WHEN** Service、Store、Handler 读取或更新换货状态 +- **THEN** 各层 MUST 使用统一常量值,禁止硬编码散落魔法数字 + +--- + +### Requirement: 换货状态机流转规则 + +系统 SHALL 执行以下状态机: +- 创建换货单后:`1` +- 客户填写收货信息后:`1 -> 2` +- 后台发货后:`2 -> 3` +- 后台确认完成后:`3 -> 4` +- 取消:仅允许 `1/2 -> 5` + +系统 MUST 禁止非法流转(如 `3 -> 5`、`4 -> 2`)。 + +#### Scenario: 已发货不可取消 +- **WHEN** 换货单状态为 `3` 且请求取消 +- **THEN** 系统 MUST 拒绝并返回状态流转非法错误 + +--- + +### Requirement: 换货单号生成规则 + +系统 MUST 为每个换货单生成全局可追踪单号,格式为:`EXC + 时间戳片段 + 随机数片段`。 + +生成规则 SHALL 满足: +- 前缀固定为 `EXC` +- 包含日期/时间信息用于人工排查 +- 包含随机片段降低并发冲突概率 + +#### Scenario: 生成换货单号 +- **WHEN** 后台发起换货并创建新单 +- **THEN** 系统 MUST 生成形如 `EXC20260319XXXXXX` 的单号并写入 `exchange_no` diff --git a/openspec/changes/client-exchange-system/specs/iot-card/spec.md b/openspec/changes/client-exchange-system/specs/iot-card/spec.md new file mode 100644 index 0000000..85a5209 --- /dev/null +++ b/openspec/changes/client-exchange-system/specs/iot-card/spec.md @@ -0,0 +1,23 @@ +## MODIFIED Requirements + +### Requirement: IoT 卡换货状态语义扩展 + +系统 SHALL 将 `asset_status=3` 定义为“已换货”,用于标记已被换出、不可继续作为当前代际在售资产的 IoT 卡。 + +#### Scenario: 换货完成后旧卡标记为已换货 +- **WHEN** H5 确认完成且旧资产为 IoT 卡 +- **THEN** 系统 MUST 将旧卡 `asset_status` 更新为 `3` + +--- + +### Requirement: IoT 卡转新重置规则 + +系统 SHALL 在 H7 转新时对 IoT 卡执行以下重置: +- `generation = generation + 1` +- `asset_status = 1`(在库) +- 清空累计充值与首充触发相关状态(含 `AccumulatedRecharge`、`FirstCommissionPaid`、系列首充/累计字段) +- 清除个人客户绑定关系 + +#### Scenario: 转新后进入新代际 +- **WHEN** 对旧卡执行转新 +- **THEN** 系统 MUST 使该卡进入新代际并以在库状态重新销售 diff --git a/openspec/changes/client-exchange-system/specs/personal-customer/spec.md b/openspec/changes/client-exchange-system/specs/personal-customer/spec.md new file mode 100644 index 0000000..82428dd --- /dev/null +++ b/openspec/changes/client-exchange-system/specs/personal-customer/spec.md @@ -0,0 +1,21 @@ +## MODIFIED Requirements + +### Requirement: 换货迁移时更新个人客户资产绑定 + +系统 SHALL 在 H5 全量迁移成功后,更新 `PersonalCustomerDevice` 的资产标识绑定关系: +- 若旧资产存在客户绑定,绑定中的 `virtual_no` MUST 更新为新资产 `virtual_no` +- 更新后客户对资产访问连续,不需重新登录即可看到新资产 + +#### Scenario: 迁移后客户绑定跟随新资产 +- **WHEN** 旧资产存在个人客户绑定且执行了 `migrate_data=true` +- **THEN** 系统 MUST 将绑定记录的 `virtual_no` 更新为新资产虚拟号 + +--- + +### Requirement: 转新时清除个人客户绑定 + +系统 SHALL 在 H7 转新时清除该资产在 `PersonalCustomerDevice` 中的绑定关系,避免旧客户继续访问新代际资产。 + +#### Scenario: 转新后旧客户需重新绑定 +- **WHEN** 资产转新完成 +- **THEN** 系统 MUST 删除或失效对应客户绑定,使旧客户再次访问时触发重新绑定流程 diff --git a/openspec/changes/client-exchange-system/tasks.md b/openspec/changes/client-exchange-system/tasks.md new file mode 100644 index 0000000..e1920e1 --- /dev/null +++ b/openspec/changes/client-exchange-system/tasks.md @@ -0,0 +1,37 @@ +- [x] 1.1 定义 ExchangeOrder 模型(含 BaseModel、旧/新资产字段、收货字段、物流字段、迁移字段、shop_id) +- [x] 1.2 新增换货状态常量(1待填写、2待发货、3已发货待确认、4已完成、5已取消) +- [x] 1.3 实现换货单号生成函数(EXC + 日期时间 + 随机数) +- [x] 1.4 新增后台/客户端换货相关 DTO(请求参数、响应结构、错误字段) + +- [x] 2.1 创建数据库迁移:新增 tb_exchange_order 表 +- [x] 2.2 创建数据库迁移:将 tb_card_replacement_record 改名为 tb_card_replacement_record_legacy + +- [x] 3.1 实现 ExchangeOrderStore:创建换货单、按ID查询、按条件分页查询 +- [x] 3.2 实现 ExchangeOrderStore:状态更新(含前置状态校验) +- [x] 3.3 实现 ExchangeOrderStore:按旧资产查询进行中换货单 + +- [x] 4.1 实现换货 Service:H1 创建换货单与重复进行中校验 +- [x] 4.2 实现换货 Service:状态流转校验(1->2->3->4、1/2->5) +- [x] 4.3 实现换货 Service:H4 发货同类型资产校验与新资产在库校验 +- [x] 4.4 实现换货 Service:H5 确认完成与可选全量迁移入口 +- [x] 4.5 实现换货 Service:全量迁移事务(11张表规则) +- [x] 4.6 实现换货 Service:H7 转新逻辑(generation+1、状态重置、清除绑定、新钱包) + +- [x] 5.1 新增后台 Exchange Handler(H1~H7) +- [x] 5.2 在 admin 路由注册 H1~H7(使用 Register() + RouteSpec 完整元数据) + +- [x] 6.1 新增客户端 Exchange Handler(G1~G2) +- [x] 6.2 在客户端路由注册 G1~G2(使用 Register() + RouteSpec 完整元数据) + +- [x] 7.1 清理旧模型引用:移除/停用 card_replacement.go 在业务流程中的使用 +- [x] 7.2 修改 iot_card_store.go 的 is_replaced 过滤逻辑,改为查询 ExchangeOrder + +- [x] 8.1 更新 bootstrap/types.go:新增后台与客户端换货 Handler 字段 +- [x] 8.2 更新 bootstrap/handlers.go:实例化换货相关 Handler +- [x] 8.3 更新 cmd/api/docs.go:注册换货 Handler 到文档生成器 +- [x] 8.4 更新 cmd/gendocs/main.go:注册换货 Handler 到文档生成器 + +- [x] 9.1 执行 go build 验证编译通过 +- [x] 9.2 执行 lsp_diagnostics 检查改动文件诊断信息 +- [x] 9.3 使用数据库验证流程核对 tb_exchange_order 与 legacy 表结构 +- [x] 9.4 在 docs/client-exchange-system/ 补充功能总结文档 diff --git a/pkg/constants/constants.go b/pkg/constants/constants.go index cf80f3f..9bab15a 100644 --- a/pkg/constants/constants.go +++ b/pkg/constants/constants.go @@ -64,7 +64,8 @@ const ( TaskTypePackageDataReset = "package:data:reset" // 套餐流量重置 // 订单超时任务类型 - TaskTypeOrderExpire = "order:expire" // 订单超时自动取消 + TaskTypeOrderExpire = "order:expire" // 订单超时自动取消 + TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge" // 充值后自动购包 // 定时任务类型(由 Asynq Scheduler 调度) TaskTypeAlertCheck = "alert:check" // 告警检查 @@ -205,8 +206,30 @@ const ( AuthorizerTypeAgent = UserTypeAgent // 代理账号授权(3) ) +// 自动代购状态常量(强充二阶段异步购买) +const ( + AutoPurchaseStatusPending = "pending" // 待处理 + AutoPurchaseStatusSuccess = "success" // 成功 + AutoPurchaseStatusFailed = "failed" // 失败 +) + // 设备保护期相关时长常量 const ( DeviceProtectPeriodDuration = 1 * time.Hour // 设备停/复机保护期时长(1小时) DeviceRefreshCooldownDuration = 30 * time.Second // 设备网关刷新冷却时长(30秒) ) + +// 换货单状态常量 +const ( + ExchangeStatusPendingInfo = 1 // 待填写信息(等待客户端填写收货地址) + ExchangeStatusPendingShip = 2 // 待发货(客户端已填写,等待后台发货) + ExchangeStatusShipped = 3 // 已发货待确认(后台已发货,等待确认完成) + ExchangeStatusCompleted = 4 // 已完成 + ExchangeStatusCancelled = 5 // 已取消 +) + +// 换货资产类型常量 +const ( + ExchangeAssetTypeIotCard = "iot_card" // 物联网卡 + ExchangeAssetTypeDevice = "device" // 设备 +) diff --git a/pkg/constants/redis.go b/pkg/constants/redis.go index ad6ff86..e4ff800 100644 --- a/pkg/constants/redis.go +++ b/pkg/constants/redis.go @@ -329,6 +329,24 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string { return fmt.Sprintf("order:create:lock:%s:%d", carrierType, carrierID) } +// ======================================== +// 客户端购买相关 Redis Key +// ======================================== + +// RedisClientPurchaseIdempotencyKey 生成客户端套餐购买幂等性检测的 Redis 键 +// 用途:防止同一个人客户对同一资产重复提交购买请求(SETNX 快速拒绝) +// 过期时间:3 分钟 +func RedisClientPurchaseIdempotencyKey(businessKey string) string { + return fmt.Sprintf("client:purchase:idempotency:%s", businessKey) +} + +// RedisClientPurchaseLockKey 生成客户端套餐购买分布式锁的 Redis 键 +// 用途:防止同一资产的购买请求并发执行 +// 过期时间:10 秒 +func RedisClientPurchaseLockKey(assetType string, assetID uint) string { + return fmt.Sprintf("client:purchase:lock:%s:%d", assetType, assetID) +} + // ======================================== // 设备保护期相关 Redis Key // ======================================== diff --git a/pkg/errors/codes.go b/pkg/errors/codes.go index 984544f..1e1ce07 100644 --- a/pkg/errors/codes.go +++ b/pkg/errors/codes.go @@ -149,6 +149,17 @@ const ( CodePhoneAlreadyBound = 1184 // 手机号已被其他客户绑定 CodeAlreadyBoundPhone = 1185 // 当前客户已绑定手机号,不可重复绑定 CodeOldPhoneMismatch = 1186 // 旧手机号与当前绑定不匹配 + CodeNeedRealname = 1187 // 该套餐需实名认证后购买 + CodeOpenIDNotFound = 1188 // 未找到微信授权信息,请先完成授权 + + // 换货相关错误 (1200-1209) + CodeExchangeOrderNotFound = 1200 // 换货单不存在 + CodeExchangeInProgress = 1201 // 存在进行中的换货单 + CodeExchangeStatusInvalid = 1202 // 换货单状态不允许此操作 + CodeExchangeAssetTypeMismatch = 1203 // 换货资产类型必须一致 + CodeExchangeNewAssetNotInStock = 1204 // 新资产非在库状态 + CodeExchangeAssetNotExchanged = 1205 // 资产未处于已换货状态,不允许转新 + CodeExchangeMigrationFailed = 1206 // 数据迁移失败 // 服务端错误 (2000-2999) -> 5xx HTTP 状态码 CodeInternalError = 2001 // 内部服务器错误 @@ -274,6 +285,15 @@ var allErrorCodes = []int{ CodePhoneAlreadyBound, CodeAlreadyBoundPhone, CodeOldPhoneMismatch, + CodeNeedRealname, + CodeOpenIDNotFound, + CodeExchangeOrderNotFound, + CodeExchangeInProgress, + CodeExchangeStatusInvalid, + CodeExchangeAssetTypeMismatch, + CodeExchangeNewAssetNotInStock, + CodeExchangeAssetNotExchanged, + CodeExchangeMigrationFailed, CodeInternalError, CodeDatabaseError, CodeRedisError, @@ -396,6 +416,15 @@ var errorMessages = map[int]string{ CodePhoneAlreadyBound: "手机号已被其他客户绑定", CodeAlreadyBoundPhone: "当前客户已绑定手机号,不可重复绑定", CodeOldPhoneMismatch: "旧手机号与当前绑定不匹配", + CodeNeedRealname: "该套餐需实名认证后购买", + CodeOpenIDNotFound: "未找到微信授权信息,请先完成授权", + CodeExchangeOrderNotFound: "换货单不存在或无权限", + CodeExchangeInProgress: "该资产存在进行中的换货单", + CodeExchangeStatusInvalid: "换货单当前状态不允许此操作", + CodeExchangeAssetTypeMismatch: "换货资产类型必须一致(卡换卡/设备换设备)", + CodeExchangeNewAssetNotInStock: "新资产非在库状态,不可用于换货", + CodeExchangeAssetNotExchanged: "资产当前状态不允许转新", + CodeExchangeMigrationFailed: "换货数据迁移失败", CodeInvalidCredentials: "用户名或密码错误", CodeAccountLocked: "账号已锁定", CodePasswordExpired: "密码已过期", diff --git a/pkg/openapi/handlers.go b/pkg/openapi/handlers.go index d19ca89..441f8fb 100644 --- a/pkg/openapi/handlers.go +++ b/pkg/openapi/handlers.go @@ -16,6 +16,12 @@ func BuildDocHandlers() *bootstrap.Handlers { Role: admin.NewRoleHandler(nil, nil), Permission: admin.NewPermissionHandler(nil), PersonalCustomer: app.NewPersonalCustomerHandler(nil, nil), + ClientAsset: app.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil), + ClientWallet: app.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil), + ClientOrder: app.NewClientOrderHandler(nil, nil, nil, nil, nil, nil, nil, nil), + ClientExchange: app.NewClientExchangeHandler(nil), + ClientRealname: app.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil), + ClientDevice: app.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil), Shop: admin.NewShopHandler(nil), ShopRole: admin.NewShopRoleHandler(nil), ShopCommission: admin.NewShopCommissionHandler(nil), @@ -40,6 +46,7 @@ func BuildDocHandlers() *bootstrap.Handlers { ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(nil), ShopSeriesGrant: admin.NewShopSeriesGrantHandler(nil), AdminOrder: admin.NewOrderHandler(nil, nil), + AdminExchange: admin.NewExchangeHandler(nil, nil), PaymentCallback: callback.NewPaymentHandler(nil, nil, nil, nil), PollingConfig: admin.NewPollingConfigHandler(nil), PollingConcurrency: admin.NewPollingConcurrencyHandler(nil),