Compare commits

..

44 Commits

Author SHA1 Message Date
c10b70757f fix: 资产信息接口 device_realtime 字段返回固定假数据,避免前端因 nil 报错
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m58s
Gateway 同步接口尚未对接,临时为设备类型资产返回 mock 数据,
后续对接后搜索 buildMockDeviceRealtime 替换为真实数据
2026-03-21 14:42:48 +08:00
4d1e714366 fix: 补齐迁移 000076 遗漏的列名重命名(card_wallet_id → asset_wallet_id)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m52s
迁移 000076 只将表名从 card_wallet 改为 asset_wallet,但遗漏了表内
card_wallet_id 列的重命名,导致 Model 中 column:asset_wallet_id 与数据库
实际列名不匹配,所有涉及该字段的 INSERT/SELECT 均报错 2002。

影响范围:
- tb_asset_recharge_record.card_wallet_id → asset_wallet_id
- tb_asset_wallet_transaction.card_wallet_id → asset_wallet_id
2026-03-21 14:30:29 +08:00
d2b765327c 完整的字段返回
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m52s
2026-03-21 13:41:44 +08:00
7dfcf41b41 fix: 修复卡类型资产绑定键错误导致归属校验永远失败
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m48s
resolveAssetBindingKey 对卡类型错误地返回 card.ICCID 作为绑定键,
但归属校验 isCustomerOwnAsset 使用 card.VirtualNo 比对,二者不一致
导致所有卡资产的 C 端接口返回 403 无权限。

修复:卡类型绑定键改为 card.VirtualNo,与设计文档一致。
附带数据迁移修正已有的错误绑定记录。
2026-03-21 11:33:57 +08:00
ed334b946b refactor: 清理重构遗留的死代码
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- personal_customer.Service: 删除已迁移到 client_auth 的死方法
  (GetProfile/SendVerificationCode/VerifyCode),移除多余的
  verificationService/jwtManager 依赖
- 删除 internal/service/customer/ 整个目录(零引用的早期残留)
2026-03-21 11:33:06 +08:00
95b2334658 feat: 资产套餐历史接口新增 package_type 和 status 筛选条件
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m10s
GET /api/c/v1/asset/package-history 支持可选参数:
- package_type: formal(正式套餐) / addon(加油包)
- status: 0(待生效) / 1(生效中) / 2(已用完) / 3(已过期) / 4(已失效)
不传则返回全部,保持向后兼容。
2026-03-21 11:01:21 +08:00
da66e673fe feat: 接入短信服务,修复 SMS 客户端 API 路径
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- cmd/api/main.go: 新增 initSMS() 初始化短信客户端并注入 verificationService
- pkg/sms/client.go: 修复 API 路径缺少 /sms 前缀(/api/... → /sms/api/...)
- docker-compose.prod.yml: 添加线上短信服务环境变量
2026-03-21 10:51:43 +08:00
284f6c15c7 fix: 修复个人客户设备绑定查询使用已废弃的 device_no 列名
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m4s
数据库列已重命名为 virtual_no,但 Store 层 3 处原始 SQL 仍使用旧列名 device_no,
导致小程序登录时查询客户资产绑定关系报 column device_no does not exist。
2026-03-20 18:20:24 +08:00
55918a0b88 fix: 修复 C 端公开路由被认证中间件拦截的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m51s
Fiber 的 Group.Use() 在路由表中注册全局 USE 处理器,不区分 Group 对象。
原代码先调用 authProtectedGroup.Use() 再注册公开路由,导致 verify-asset、
wechat-login、miniapp-login、send-code 四个无需认证的接口被拦截返回 1004。

修复方式:公开路由直接注册在 router 上且在任何 Use() 之前,
利用 Fiber 按注册顺序匹配的机制确保公开路由优先命中。
2026-03-20 18:01:12 +08:00
d2494798aa fix: 修正停复机接口错误码,网关失败不再返回模糊的内部服务器错误
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m13s
- 单卡停复机:网关错误从 CodeInternalError(2001) 改为 CodeGatewayError(1110),前端可看到具体失败原因
- 单卡停复机:DB 更新裸返 GORM error 改为 CodeDatabaseError(2002) 包装
- 设备复机:全部卡失败时错误码从 CodeInternalError 改为 CodeGatewayError
2026-03-19 18:37:03 +08:00
b9733c4913 fix: 修正零售价架构错误 + 清理旧微信配置 + 归档提案 + 前端接口文档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m12s
1. 修正 retail_price 架构:
   - 删除 batch-pricing 接口的 pricing_target 字段和 retail_price 分支
     (上级只能改下级成本价,不能改零售价)
   - 新增 PATCH /api/admin/packages/:id/retail-price 接口
     (代理自己改自己的零售价,校验 retail_price >= cost_price)

2. 清理旧微信 YAML 配置(已全部迁移到数据库 tb_wechat_config):
   - 删除 config.yaml 中 wechat.official_account 配置节
   - 删除 NewOfficialAccountApp() 旧工厂函数
   - 清理 personal_customer service 中的死代码(旧登录/绑定微信方法)
   - 清理 docker-compose.prod.yml 中旧微信环境变量和证书挂载注释

3. 归档四个已完成提案到 openspec/changes/archive/

4. 新增前端接口变更说明文档(docs/前端接口变更说明.md)

5. 修正归档提案和 specs 中关于 pricing_target 的错误描述
2026-03-19 17:39:43 +08:00
9bd55a1695 feat: 实现客户端核心业务接口(client-core-business-api)
新增客户端资产、钱包、订单、实名、设备管理等核心业务 Handler 与 DTO:
- 客户端资产信息查询、套餐列表、套餐历史、资产刷新
- 客户端钱包详情、流水、充值校验、充值订单、充值记录
- 客户端订单创建、列表、详情
- 客户端实名认证链接获取
- 客户端设备卡列表、重启、恢复出厂、WiFi配置、切卡
- 客户端订单服务(含微信/支付宝支付流程)
- 强充自动代购异步任务处理
- 数据库迁移 000084:充值记录增加自动代购状态字段
2026-03-19 13:28:04 +08:00
e78f5794b9 feat: 实现客户端换货系统(client-exchange-system)
新增完整换货生命周期管理:后台发起 → 客户端填收货信息 → 后台发货 → 确认完成(含可选全量迁移) → 旧资产转新再销售

后台接口(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 旧表改名
2026-03-19 13:26:54 +08:00
df76e33105 feat: 实现 C 端完整认证系统(client-auth-system)
实现面向个人客户的 7 个认证接口(A1-A7),覆盖资产验证、
微信公众号/小程序登录、手机号绑定/换绑、退出登录完整流程。

主要变更:
- 新增 PersonalCustomerOpenID 模型,支持多 AppID 多 OpenID 管理
- 实现有状态 JWT(JWT + Redis 双重校验),支持服务端主动失效
- 扩展微信 SDK:小程序 Code2Session + 3 个 DB 动态工厂函数
- 实现 A1 资产验证 IP 限流(30/min)和 A4 三层验证码限流
- 新增 7 个错误码(1180-1186)和 6 个 Redis Key 函数
- 注册 /api/c/v1/auth/* 下 7 个端点并更新 OpenAPI 文档
- 数据库迁移 000083:新建 tb_personal_customer_openid 表
2026-03-19 11:33:41 +08:00
ec86dbf463 feat: 客户端接口数据模型基础准备
- 新增资产状态、订单来源、操作人类型、实名链接类型常量
- 8个模型新增字段(asset_status/generation/source/retail_price等)
- 数据库迁移000082:7张表15+字段,含存量retail_price回填
- BUG-1修复:代理零售价渠道隔离,cost_price分配锁定
- BUG-2修复:一次性佣金仅客户端订单触发
- BUG-4修复:充值回调Store操作纳入事务
- 新增资产手动停用接口(PATCH /iot-cards/:id/deactivate、/devices/:id/deactivate)
- Carrier管理新增实名链接配置
- 后台订单generation写时快照
- BatchUpdatePricing支持retail_price调价目标
- 清理全部H5旧接口和个人客户旧登录方法
2026-03-19 10:56:50 +08:00
817d0d6e04 更新openspec
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 46s
2026-03-17 14:22:01 +08:00
b44363b335 fix: 修复新建店铺未初始化代理钱包导致充值订单报错
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m6s
新建店铺时在 shop.Service.Create() 中自动初始化主钱包(main)和分佣钱包(commission),修复充值订单创建时「目标店铺主钱包不存在」错误

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-17 14:08:26 +08:00
3e8f613475 fix: 修复 OpenAPI 文档生成器启动 panic,路由缺少 path parameter 定义
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m2s
- 新增 UpdateWechatConfigParams/AgentOfflinePayParams 聚合结构体,嵌入 IDReq 提供 path:id 标签
- 修复 PUT /:id 和 POST /:id/offline-pay 路由的 Input 引用
- 修复 Makefile 构建路径从单文件改为包路径,解决多文件编译问题
- 标记 tasks.md 中 1.2.4 迁移任务为已完成
2026-03-17 09:45:51 +08:00
242e0b1f40 docs: 更新 AGENTS.md 和 CLAUDE.md
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 6m28s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:31:07 +08:00
060d8fd65e docs: 新增微信参数配置管理和代理预充值功能总结文档
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:30:56 +08:00
f3297f0529 docs: 归档 asset-wallet-interface OpenSpec 提案,更新卡钱包 spec
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:30:48 +08:00
63ca12393b docs: 新增 OpenSpec 提案 add-payment-config-management
包含 proposal.md、design.md、tasks.md 及各模块 spec 文件(微信配置管理、富友支付、代理充值、订单支付、资产充值适配、微信支付留桩)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:30:39 +08:00
429edf0d19 refactor: 注册微信配置和代理充值模块到 Bootstrap 和 OpenAPI 文档生成器
- bootstrap/types.go: 新增 WechatConfigStore/WechatConfigService/WechatConfigHandler/AgentRechargeService/AgentRechargeHandler 字段
- bootstrap/stores.go: 初始化 WechatConfigStore
- bootstrap/services.go: 初始化 WechatConfigService(注入 AuditService)和 AgentRechargeService
- bootstrap/handlers.go: 初始化 WechatConfigHandler 和 AgentRechargeHandler;PaymentHandler 新增 agentRechargeService 参数
- bootstrap/worker_services.go: 补充 WechatConfigService 注入
- routes/admin.go: 注册 WechatConfig 和 AgentRecharge 路由组
- openapi/handlers.go: 注册 WechatConfigHandler 和 AgentRechargeHandler 到文档生成器

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:30:30 +08:00
7c64e433e8 feat: 改造支付回调 Handler,支持富友回调和多订单类型按前缀分发
- payment.go: WechatPayCallback 改造为按订单号前缀分发(ORD→套餐订单、CRCH→资产充值、ARCH→代理充值);新增 FuiouPayCallback(GBK→UTF-8+XML解析+验签+分发);修复 RechargeOrderPrefix 废弃引用
- order.go: 注册 POST /api/callback/fuiou-pay 路由(无需认证)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:30:17 +08:00
269769bfe4 refactor: 改造订单和资产充值 Service,支持动态支付配置
- order/service.go: 注入 wechatConfigService,CreateH5Order/CreateAdminOrder 下单时查询 active 配置并记录 payment_config_id;无配置时拒绝第三方支付;WechatPayJSAPI/WechatPayH5/FuiouPayJSAPI/FuiouPayMiniApp 添加 TODO 留桩
- recharge/service.go: Create 方法记录 payment_config_id,HandlePaymentCallback 留桩

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:30:05 +08:00
1980c846f2 feat: 订单/资产充值/代理充值模型新增 PaymentConfigID 字段
- order.go: Order 模型新增 PaymentConfigID *uint(记录下单时使用的支付配置)
- asset_wallet.go: AssetRechargeRecord 新增 PaymentConfigID *uint
- agent_wallet.go: AgentRechargeRecord 新增 PaymentConfigID *uint
配置切换时旧订单仍按 payment_config_id 加载对应配置验签,解决竞态问题

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:29:52 +08:00
89f9875a97 feat: 新增代理预充值模块(DTO、Service、Handler、路由)
- agent_recharge_dto.go: 创建/列表/详情请求响应 DTO
- service.go: 权限验证(代理只能充自己店铺)、金额范围校验、查询 active 配置、创建订单、线下充值确认(乐观锁+审计日志)、回调幂等处理
- agent_recharge.go Handler: Create/List/Get/OfflinePay 共 4 个方法
- agent_recharge.go 路由: 注册到 /api/admin/agent-recharges/*,路由层拦截企业账号

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:29:42 +08:00
30c56e66dd feat: 新增微信参数配置管理 Handler 和路由(仅平台账号可访问)
- wechat_config.go Handler: Create/List/Get/Update/Delete/Activate/Deactivate/GetActive 共 8 个方法
- wechat_config.go 路由: 注册到 /api/admin/wechat-configs/*,路由层限制平台账号权限

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:29:31 +08:00
c86afbfa8f feat: 新增微信参数配置模块(Model、DTO、Store、Service)
- wechat_config.go: WechatConfig GORM 模型,含 ProviderTypeWechat/Fuiou 常量
- wechat_config_dto.go: Create/Update/List 请求 DTO,响应 DTO 含脱敏逻辑
- wechat_config_store.go: CRUD、GetActive、ActivateInTx(事务内唯一激活)、软删除保护查询
- service.go: 业务逻辑,按渠道校验必填字段、Redis 缓存管理(wechat:config:active)、删除保护、审计日志

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:29:11 +08:00
aa41a5ed5e feat: 新增支付配置管理相关数据库迁移(000078-000081)
- 000078: 创建 tb_wechat_config 表(支持微信直连和富友双渠道,含软删除)
- 000079: tb_order 新增 payment_config_id 字段(nullable,记录下单时使用的配置)
- 000080: tb_asset_recharge_record 新增 payment_config_id 字段
- 000081: tb_agent_recharge_record 新增 payment_config_id 字段

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:28:57 +08:00
a308ee228b feat: 新增富友支付 SDK(RSA 签名、GBK 编解码、XML 协议、回调验签)
- pkg/fuiou/types.go: WxPreCreateRequest/Response、NotifyRequest 等 XML 结构体
- pkg/fuiou/client.go: Client 结构体、NewClient、字典序+GBK+MD5+RSA 签名/验签、HTTP 请求
- pkg/fuiou/wxprecreate.go: WxPreCreate 方法,支持公众号 JSAPI(JSAPI)和小程序(LETPAY)
- pkg/fuiou/notify.go: VerifyNotify(GBK→UTF-8+XML 解析+RSA 验签)、BuildNotifyResponse

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:28:42 +08:00
b0da71bd25 refactor: 清理 YAML 支付配置遗留代码,重命名 Card* 常量为 Asset*,新增支付配置相关错误码
- 删除 PaymentConfig 结构体和 WechatConfig.Payment 字段(YAML 方案已废弃)
- 删除 wechat.payment 配置节和 NewPaymentApp() 函数
- 删除 validateWechatConfig 中所有 wechatCfg.Payment.* 校验代码
- pkg/constants/wallet.go: Card* 前缀统一重命名为 Asset*,旧名保留废弃别名
- pkg/constants/redis.go: 新增 RedisWechatConfigActiveKey()
- pkg/errors/codes.go: 新增错误码 1170-1175
- go.mod: 新增 golang.org/x/text 依赖(富友支付 GBK 编解码)

Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 23:28:29 +08:00
7f18765911 fix: IoT 卡列表查询补充 virtual_no 字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m58s
standaloneListColumns 是为性能优化而手写的列选择列表,
virtual_no 字段新增时只加了 model 和 DTO,遗漏了这里,
导致四条列表查询路径均未 SELECT virtual_no,字段始终为空。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 16:48:45 +08:00
876c92095c fix: 平台账号后台创建钱包订单时,绕过代理套餐分配检查
后台钱包支付下单时,原逻辑根据卡/设备所属代理店铺触发
套餐分配上架校验,导致平台账号无法为属于代理的卡购买
未被该代理分配的套餐(如 0 元赠送套餐)。

修复:在 CreateAdminOrder wallet 分支中,按买家类型区分:
- 代理账号:保留原有校验,确保卡所属代理已分配该套餐
- 平台/超管账号:跳过代理分配检查,仅验证套餐全局状态

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-16 15:51:01 +08:00
e45610661e docs: 更新 admin OpenAPI 文档,新增 asset_wallet 接口定义
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m57s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 15:44:02 +08:00
d85d7bffd6 refactor: 更新路由和 OpenAPI 注册以接入 AssetWallet
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 15:43:55 +08:00
fe77d9ca72 refactor: 注册 AssetWallet 组件到 Bootstrap
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 15:43:49 +08:00
9b83f92fb6 feat: 新增 AssetWallet Handler,实现资产钱包 API 接口
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 15:43:42 +08:00
2248558bd3 refactor: 适配 asset_wallet 更名,更新订单、充值和购买验证服务
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 15:43:37 +08:00
2aae31ac5f feat: 新增 AssetWallet Service,实现资产钱包业务逻辑
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 15:43:29 +08:00
5031bf15b9 refactor: 更新 wallet 常量和队列类型以适配 asset_wallet
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 15:43:22 +08:00
9c768e0719 refactor: 重命名 card_wallet store 为 asset_wallet,新增 transaction store
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 15:43:17 +08:00
b6c379265d refactor: 重命名 CardWallet 模型为 AssetWallet,新增 DTO
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 15:43:11 +08:00
4156bfc9dd feat: 新增 asset_wallet 和 reference_no 数据库迁移
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-opencode)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-03-16 15:42:52 +08:00
272 changed files with 30101 additions and 3425 deletions

13
.config/dbhub.toml Normal file
View File

@@ -0,0 +1,13 @@
[[sources]]
id = "main"
dsn = "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable"
[[tools]]
name = "search_objects"
source = "main"
[[tools]]
name = "execute_sql"
source = "main"
readonly = true # Only allow SELECT, SHOW, DESCRIBE, EXPLAIN
max_rows = 1000 # Limit query results

View File

@@ -38,6 +38,7 @@ handlers := &bootstrap.Handlers{
## 语言要求
**必须遵守:**
- 永远用中文交互
- 注释必须使用中文
- 文档必须使用中文
@@ -63,6 +64,7 @@ handlers := &bootstrap.Handlers{
| 缓存 | Redis 6.0+ |
**禁止:**
- 直接使用 `database/sql`(必须通过 GORM
- 使用 `net/http` 替代 Fiber
- 使用 `encoding/json` 替代 sonic除非必要
@@ -83,21 +85,25 @@ Handler → Service → Store → Model
## 核心原则
### 错误处理
- 所有错误必须在 `pkg/errors/` 中定义
- 使用统一错误码系统
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
#### 错误报错规范(必须遵守)
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()``err.Error()`
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)``errors.Wrap(...)`
- 约定用法:`errors.New(code[, msg])``errors.Wrap(code, err[, msg])`
### 响应格式
- 所有 API 响应使用 `pkg/response/` 的统一格式
- 格式: `{code, msg, data, timestamp}`
### 常量管理
- 所有常量定义在 `pkg/constants/`
- Redis key 使用函数生成: `Redis{Module}{Purpose}Key(params...)`
- 禁止硬编码字符串和 magic numbers
@@ -177,6 +183,7 @@ func (s *UsageService) ActivateByRealname(ctx context.Context, cardID uint) erro
#### 未导出符号的注释
未导出(小写)的函数/方法:
- **简单逻辑**< 15 行):可以不加注释
- **复杂逻辑**(≥ 15 行)或 **非显而易见的算法**:必须加注释
@@ -199,6 +206,7 @@ func (s *Service) buildPermissionTree(permissions []*model.Permission) []*dto.Pe
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
**✅ 好的内联注释(解释为什么)**
```go
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
@@ -212,6 +220,7 @@ if err := s.freezeCommission(ctx, tx, orderID); err != nil {
```
**❌ 废话注释(禁止)**
```go
// 获取用户ID ← 禁止:代码本身已经很清楚
userID := middleware.GetUserIDFromContext(ctx)
@@ -248,6 +257,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
```
### Go 代码风格
- 使用 `gofmt` 格式化
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
- 包名: 简短、小写、单数、无下划线
@@ -256,6 +266,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
## 数据库设计
**核心规则:**
- ❌ 禁止建立外键约束
- ❌ 禁止使用 GORM 关联关系标签foreignKey、hasMany、belongsTo
- ✅ 关联通过存储 ID 字段手动维护
@@ -264,6 +275,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
## Go 惯用法 vs Java 风格
### ✅ Go 风格(推荐)
- 扁平化包结构(最多 2-3 层)
- 小而专注的接口1-3 个方法)
- 直接访问导出字段(不用 getter/setter
@@ -271,6 +283,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
- 显式错误返回和检查
### ❌ Java 风格(禁止)
- 过度抽象(不必要的接口、工厂)
- Getter/Setter 方法
- 深层继承层次
@@ -282,6 +295,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
**本项目不使用任何形式的自动化测试代码。**
**绝对禁止:**
-**禁止编写单元测试** - 无论任何场景
-**禁止编写集成测试** - 无论任何场景
-**禁止编写验收测试** - 无论任何场景
@@ -292,15 +306,18 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
-**禁止在文档中提及测试要求** - 规范、设计文档均不讨论测试
**唯一例外:**
- ✅ **仅当用户明确要求**时才编写测试代码
- ✅ 用户必须主动说明"请写测试"或"需要测试"
**原因说明:**
- 业务系统的正确性通过人工验证和生产环境监控保证
- 测试代码的维护成本高于价值
- 快速迭代优先于测试覆盖率
**替代方案:**
- 使用 PostgreSQL MCP 工具手动验证数据
- 使用 Postman/curl 手动测试 API
- 依赖生产环境日志和监控发现问题
@@ -349,23 +366,27 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
## Code Review 检查清单
### 错误处理
- [ ] Service 层无 `fmt.Errorf` 对外返回
- [ ] Handler 层参数校验不泄露细节
- [ ] 错误码使用正确4xx vs 5xx
- [ ] 错误日志完整(包含上下文)
### 代码质量
- [ ] 遵循 Handler → Service → Store → Model 分层
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
- [ ] 常量定义在 `pkg/constants/`
- [ ] 使用 Go 惯用法(非 Java 风格)
### 文档和注释
- [ ] 所有注释使用中文
- [ ] 导出函数/类型有文档注释
- [ ] API 路径注释与真实路由一致
### 幂等性
- [ ] 创建类写操作有 Redis 业务键防重
- [ ] 状态变更使用条件更新(`WHERE status = expected`
- [ ] 余额/库存变更使用乐观锁version 字段)
@@ -381,6 +402,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
1. **路由层中间件**(粗粒度拦截)
- 用于明显的权限限制(如企业账号禁止访问账号管理)
- 示例:
```go
group.Use(func(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
@@ -404,6 +426,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
- 无需手动调用
**统一错误返回**
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
- 不区分"不存在"和"无权限",防止信息泄露
@@ -522,6 +545,7 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
**使用方式**
1. **Service 层集成审计日志**
```go
type Service struct {
store *Store
@@ -585,3 +609,18 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
# English Learning Mode
The user is learning English through practical use. Apply these rules in every conversation:
1. **Always respond in Chinese** — regardless of whether the user writes in English or Chinese.
2. **When the user writes in English**, append a one-line correction at the end of your response in this format:
→ `[natural version of what they wrote]`
No explanation needed — just the corrected phrase.
3. **When the user mixes Chinese into English** (e.g., "I want to 实现 dark mode"), translate the Chinese word/phrase inline and continue naturally. Do not make a
big deal of it.
4. **Never interrupt the flow** to give grammar lessons. Corrections are silent and brief — the user's focus is on the task, not the language.

View File

@@ -38,6 +38,7 @@ handlers := &bootstrap.Handlers{
## 语言要求
**必须遵守:**
- 永远用中文交互
- 注释必须使用中文
- 文档必须使用中文
@@ -63,6 +64,7 @@ handlers := &bootstrap.Handlers{
| 缓存 | Redis 6.0+ |
**禁止:**
- 直接使用 `database/sql`(必须通过 GORM
- 使用 `net/http` 替代 Fiber
- 使用 `encoding/json` 替代 sonic除非必要
@@ -83,21 +85,25 @@ Handler → Service → Store → Model
## 核心原则
### 错误处理
- 所有错误必须在 `pkg/errors/` 中定义
- 使用统一错误码系统
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
#### 错误报错规范(必须遵守)
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()``err.Error()`
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)``errors.Wrap(...)`
- 约定用法:`errors.New(code[, msg])``errors.Wrap(code, err[, msg])`
### 响应格式
- 所有 API 响应使用 `pkg/response/` 的统一格式
- 格式: `{code, msg, data, timestamp}`
### 常量管理
- 所有常量定义在 `pkg/constants/`
- Redis key 使用函数生成: `Redis{Module}{Purpose}Key(params...)`
- 禁止硬编码字符串和 magic numbers
@@ -177,6 +183,7 @@ func (s *UsageService) ActivateByRealname(ctx context.Context, cardID uint) erro
#### 未导出符号的注释
未导出(小写)的函数/方法:
- **简单逻辑**< 15 行):可以不加注释
- **复杂逻辑**(≥ 15 行)或 **非显而易见的算法**:必须加注释
@@ -199,6 +206,7 @@ func (s *Service) buildPermissionTree(permissions []*model.Permission) []*dto.Pe
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
**✅ 好的内联注释(解释为什么)**
```go
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
@@ -212,6 +220,7 @@ if err := s.freezeCommission(ctx, tx, orderID); err != nil {
```
**❌ 废话注释(禁止)**
```go
// 获取用户ID ← 禁止:代码本身已经很清楚
userID := middleware.GetUserIDFromContext(ctx)
@@ -248,6 +257,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
```
### Go 代码风格
- 使用 `gofmt` 格式化
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
- 包名: 简短、小写、单数、无下划线
@@ -256,6 +266,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
## 数据库设计
**核心规则:**
- ❌ 禁止建立外键约束
- ❌ 禁止使用 GORM 关联关系标签foreignKey、hasMany、belongsTo
- ✅ 关联通过存储 ID 字段手动维护
@@ -264,6 +275,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
## Go 惯用法 vs Java 风格
### ✅ Go 风格(推荐)
- 扁平化包结构(最多 2-3 层)
- 小而专注的接口1-3 个方法)
- 直接访问导出字段(不用 getter/setter
@@ -271,6 +283,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
- 显式错误返回和检查
### ❌ Java 风格(禁止)
- 过度抽象(不必要的接口、工厂)
- Getter/Setter 方法
- 深层继承层次
@@ -282,6 +295,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
**本项目不使用任何形式的自动化测试代码。**
**绝对禁止:**
-**禁止编写单元测试** - 无论任何场景
-**禁止编写集成测试** - 无论任何场景
-**禁止编写验收测试** - 无论任何场景
@@ -292,15 +306,18 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
-**禁止在文档中提及测试要求** - 规范、设计文档均不讨论测试
**唯一例外:**
- ✅ **仅当用户明确要求**时才编写测试代码
- ✅ 用户必须主动说明"请写测试"或"需要测试"
**原因说明:**
- 业务系统的正确性通过人工验证和生产环境监控保证
- 测试代码的维护成本高于价值
- 快速迭代优先于测试覆盖率
**替代方案:**
- 使用 PostgreSQL MCP 工具手动验证数据
- 使用 Postman/curl 手动测试 API
- 依赖生产环境日志和监控发现问题
@@ -349,23 +366,27 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
## Code Review 检查清单
### 错误处理
- [ ] Service 层无 `fmt.Errorf` 对外返回
- [ ] Handler 层参数校验不泄露细节
- [ ] 错误码使用正确4xx vs 5xx
- [ ] 错误日志完整(包含上下文)
### 代码质量
- [ ] 遵循 Handler → Service → Store → Model 分层
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
- [ ] 常量定义在 `pkg/constants/`
- [ ] 使用 Go 惯用法(非 Java 风格)
### 文档和注释
- [ ] 所有注释使用中文
- [ ] 导出函数/类型有文档注释
- [ ] API 路径注释与真实路由一致
### 幂等性
- [ ] 创建类写操作有 Redis 业务键防重
- [ ] 状态变更使用条件更新(`WHERE status = expected`
- [ ] 余额/库存变更使用乐观锁version 字段)
@@ -381,6 +402,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
1. **路由层中间件**(粗粒度拦截)
- 用于明显的权限限制(如企业账号禁止访问账号管理)
- 示例:
```go
group.Use(func(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
@@ -404,6 +426,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
- 无需手动调用
**统一错误返回**
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
- 不区分"不存在"和"无权限",防止信息泄露
@@ -522,6 +545,7 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
**使用方式**
1. **Service 层集成审计日志**
```go
type Service struct {
store *Store
@@ -585,3 +609,18 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
# English Learning Mode
The user is learning English through practical use. Apply these rules in every conversation:
1. **Always respond in Chinese** — regardless of whether the user writes in English or Chinese.
2. **When the user writes in English**, append a one-line correction at the end of your response in this format:
→ `[natural version of what they wrote]`
No explanation needed — just the corrected phrase.
3. **When the user mixes Chinese into English** (e.g., "I want to 实现 dark mode"), translate the Chinese word/phrase inline and continue naturally. Do not make a
big deal of it.
4. **Never interrupt the flow** to give grammar lessons. Corrections are silent and brief — the user's focus is on the task, not the language.

View File

@@ -7,8 +7,8 @@ GOCLEAN=$(GOCMD) clean
GOTEST=$(GOCMD) test
GOGET=$(GOCMD) get
BINARY_NAME=bin/junhong-cmp
MAIN_PATH=cmd/api/main.go
WORKER_PATH=cmd/worker/main.go
MAIN_PATH=./cmd/api
WORKER_PATH=./cmd/worker
WORKER_BINARY=bin/junhong-worker
# Database migration parameters

View File

@@ -5,6 +5,8 @@ import (
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
"github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
@@ -22,6 +24,15 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
// 3. 创建 Handler使用 nil 依赖,因为只需要路由结构)
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)

View File

@@ -27,6 +27,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/database"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/sms"
"github.com/break/junhong_cmp_fiber/pkg/storage"
)
@@ -42,8 +43,6 @@ func main() {
// 3. 初始化日志
appLogger := initLogger(cfg)
// 4. 验证微信配置
validateWechatConfig(cfg, appLogger)
defer func() {
_ = logger.Sync()
}()
@@ -247,14 +246,11 @@ func applyRateLimiterToBusinessRoutes(app *fiber.App, rateLimitMiddleware fiber.
adminGroup := app.Group("/api/admin")
adminGroup.Use(rateLimitMiddleware)
h5Group := app.Group("/api/h5")
h5Group.Use(rateLimitMiddleware)
personalGroup := app.Group("/api/c/v1")
personalGroup.Use(rateLimitMiddleware)
appLogger.Info("限流器已应用到业务路由组",
zap.Strings("paths", []string{"/api/admin", "/api/h5", "/api/c/v1"}),
zap.Strings("paths", []string{"/api/admin", "/api/c/v1"}),
)
}
@@ -311,11 +307,42 @@ func initAuthComponents(cfg *config.Config, redisClient *redis.Client, appLogger
refreshTTL := time.Duration(cfg.JWT.RefreshTokenTTL) * time.Second
tokenManager := auth.NewTokenManager(redisClient, accessTTL, refreshTTL)
verificationSvc := verification.NewService(redisClient, nil, appLogger)
smsClient := initSMS(cfg, appLogger)
verificationSvc := verification.NewService(redisClient, smsClient, appLogger)
return jwtManager, tokenManager, verificationSvc
}
func initSMS(cfg *config.Config, appLogger *zap.Logger) *sms.Client {
if cfg.SMS.GatewayURL == "" {
appLogger.Info("短信服务未配置,跳过初始化")
return nil
}
timeout := cfg.SMS.Timeout
if timeout == 0 {
timeout = 10 * time.Second
}
httpClient := sms.NewStandardHTTPClient(0)
client := sms.NewClient(
cfg.SMS.GatewayURL,
cfg.SMS.Username,
cfg.SMS.Password,
cfg.SMS.Signature,
timeout,
appLogger,
httpClient,
)
appLogger.Info("短信服务已初始化",
zap.String("gateway_url", cfg.SMS.GatewayURL),
zap.String("signature", cfg.SMS.Signature),
)
return client
}
func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service {
if cfg.Storage.Provider == "" || cfg.Storage.S3.Endpoint == "" {
appLogger.Info("对象存储未配置,跳过初始化")
@@ -355,64 +382,3 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
return client
}
func validateWechatConfig(cfg *config.Config, appLogger *zap.Logger) {
wechatCfg := cfg.Wechat
if wechatCfg.OfficialAccount.AppID == "" && wechatCfg.Payment.AppID == "" {
appLogger.Warn("微信配置未设置,微信相关功能将不可用")
return
}
if wechatCfg.OfficialAccount.AppID != "" {
if wechatCfg.OfficialAccount.AppSecret == "" {
appLogger.Fatal("微信公众号配置不完整",
zap.String("missing", "app_secret"),
zap.String("env", "JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET"))
}
appLogger.Info("微信公众号配置已验证",
zap.String("app_id", wechatCfg.OfficialAccount.AppID))
}
if wechatCfg.Payment.AppID != "" {
missingFields := []string{}
if wechatCfg.Payment.MchID == "" {
missingFields = append(missingFields, "mch_id (JUNHONG_WECHAT_PAYMENT_MCH_ID)")
}
if wechatCfg.Payment.APIV3Key == "" {
missingFields = append(missingFields, "api_v3_key (JUNHONG_WECHAT_PAYMENT_API_V3_KEY)")
}
if wechatCfg.Payment.CertPath == "" {
missingFields = append(missingFields, "cert_path (JUNHONG_WECHAT_PAYMENT_CERT_PATH)")
}
if wechatCfg.Payment.KeyPath == "" {
missingFields = append(missingFields, "key_path (JUNHONG_WECHAT_PAYMENT_KEY_PATH)")
}
if wechatCfg.Payment.SerialNo == "" {
missingFields = append(missingFields, "serial_no (JUNHONG_WECHAT_PAYMENT_SERIAL_NO)")
}
if wechatCfg.Payment.NotifyURL == "" {
missingFields = append(missingFields, "notify_url (JUNHONG_WECHAT_PAYMENT_NOTIFY_URL)")
}
if len(missingFields) > 0 {
appLogger.Fatal("微信支付配置不完整",
zap.Strings("missing_fields", missingFields))
}
if _, err := os.Stat(wechatCfg.Payment.CertPath); os.IsNotExist(err) {
appLogger.Fatal("微信支付证书文件不存在",
zap.String("cert_path", wechatCfg.Payment.CertPath))
}
if _, err := os.Stat(wechatCfg.Payment.KeyPath); os.IsNotExist(err) {
appLogger.Fatal("微信支付私钥文件不存在",
zap.String("key_path", wechatCfg.Payment.KeyPath))
}
appLogger.Info("微信支付配置已验证",
zap.String("app_id", wechatCfg.Payment.AppID),
zap.String("mch_id", wechatCfg.Payment.MchID))
}
}

View File

@@ -7,6 +7,8 @@ import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
"github.com/break/junhong_cmp_fiber/internal/routes"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
@@ -31,6 +33,15 @@ func generateAdminDocs(outputPath string) error {
// 3. 创建 Handler使用 nil 依赖,因为只需要路由结构)
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)

View File

@@ -22,9 +22,11 @@ version: '3.8'
#
# 可选配置(根据需要启用):
# - Gateway 服务配置JUNHONG_GATEWAY_*
# - 微信公众号配置JUNHONG_WECHAT_OFFICIAL_ACCOUNT_*
# - 微信支付配置JUNHONG_WECHAT_PAYMENT_*
# - 对象存储配置JUNHONG_STORAGE_*
# - 短信服务配置JUNHONG_SMS_*
#
# 微信公众号/小程序/支付配置已迁移至数据库tb_wechat_config 表),
# 不再需要环境变量和证书文件挂载。
services:
api:
@@ -65,28 +67,13 @@ services:
- JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
- JUNHONG_GATEWAY_TIMEOUT=30
# 微信公众号配置(可选)
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID=your_app_id
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET=your_app_secret
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN=your_token
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY=your_aes_key
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL=https://your-domain.com/callback
# 微信支付配置(可选)
# - JUNHONG_WECHAT_PAYMENT_APP_ID=your_app_id
# - JUNHONG_WECHAT_PAYMENT_MCH_ID=your_mch_id
# - JUNHONG_WECHAT_PAYMENT_API_V3_KEY=your_32_char_api_v3_key
# - JUNHONG_WECHAT_PAYMENT_API_V2_KEY=your_api_v2_key
# - JUNHONG_WECHAT_PAYMENT_CERT_PATH=/app/certs/apiclient_cert.pem
# - JUNHONG_WECHAT_PAYMENT_KEY_PATH=/app/certs/apiclient_key.pem
# - JUNHONG_WECHAT_PAYMENT_SERIAL_NO=your_serial_no
# - JUNHONG_WECHAT_PAYMENT_NOTIFY_URL=https://your-domain.com/api/callback/wechat-pay
# - JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG=false
# - JUNHONG_WECHAT_PAYMENT_TIMEOUT=30s
# 短信服务配置
- JUNHONG_SMS_GATEWAY_URL=https://gateway.sms.whjhft.com:8443
- JUNHONG_SMS_USERNAME=JH0001
- JUNHONG_SMS_PASSWORD=wwR8E4qnL6F0
- JUNHONG_SMS_SIGNATURE=【JHFTIOT】
volumes:
# 仅挂载日志目录(配置已嵌入二进制文件)
- ./logs:/app/logs
# 微信支付证书目录(如果使用微信支付,需要挂载证书)
# - ./certs:/app/certs:ro
networks:
- junhong-network
healthcheck:
@@ -137,27 +124,8 @@ services:
- JUNHONG_GATEWAY_APP_ID=60bgt1X8i7AvXqkd
- JUNHONG_GATEWAY_APP_SECRET=BZeQttaZQt0i73moF
- JUNHONG_GATEWAY_TIMEOUT=30
# 微信公众号配置(可选)
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID=your_app_id
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET=your_app_secret
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN=your_token
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY=your_aes_key
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL=https://your-domain.com/callback
# 微信支付配置(可选)
# - JUNHONG_WECHAT_PAYMENT_APP_ID=your_app_id
# - JUNHONG_WECHAT_PAYMENT_MCH_ID=your_mch_id
# - JUNHONG_WECHAT_PAYMENT_API_V3_KEY=your_32_char_api_v3_key
# - JUNHONG_WECHAT_PAYMENT_API_V2_KEY=your_api_v2_key
# - JUNHONG_WECHAT_PAYMENT_CERT_PATH=/app/certs/apiclient_cert.pem
# - JUNHONG_WECHAT_PAYMENT_KEY_PATH=/app/certs/apiclient_key.pem
# - JUNHONG_WECHAT_PAYMENT_SERIAL_NO=your_serial_no
# - JUNHONG_WECHAT_PAYMENT_NOTIFY_URL=https://your-domain.com/api/callback/wechat-pay
# - JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG=false
# - JUNHONG_WECHAT_PAYMENT_TIMEOUT=30s
volumes:
- ./logs:/app/logs
# 微信支付证书目录(如果使用微信支付,需要挂载证书)
# - ./certs:/app/certs:ro
networks:
- junhong-network
depends_on:

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,227 @@
# 代理预充值功能
## 功能概述
代理商(店铺)余额钱包的在线充值系统,支持微信在线支付和线下转账两种充值方式,具备完整的 Service/Handler/回调处理链路。充值仅针对余额钱包(`wallet_type=main`),佣金钱包通过分佣自动入账。
### 背景与动机
原有 `tb_agent_recharge_record` 表和 Store 层骨架已存在,但缺少 Service 层和 Handler 层,无法通过 API 发起充值。本次补全完整实现,并集成至支付配置管理体系(按 `payment_config_id` 动态路由至微信直连或富友通道)。
## 核心流程
### 在线充值流程(微信)
```
代理/平台 → POST /api/admin/agent-recharges
├─ 验证权限:代理只能充自己店铺,平台可指定任意店铺
├─ 验证金额范围100 元~100 万元)
├─ 查找目标店铺的 main 钱包
├─ 查询 active 支付配置 → 无配置则拒绝(返回 1175
├─ 记录 payment_config_id
└─ 创建充值订单status=1 待支付)
└─ 返回订单信息(客户端支付发起【留桩】)
支付成功 → POST /api/callback/wechat-pay 或 /api/callback/fuiou-pay
├─ 按订单号前缀 "ARCH" 识别为代理充值
├─ 查询充值记录,取 payment_config_id
├─ 按配置验签
└─ agentRechargeService.HandlePaymentCallback()
├─ 幂等检查WHERE status = 1
├─ 更新充值记录状态 → 2已完成
├─ 代理主钱包余额增加(乐观锁防并发)
└─ 创建钱包流水记录
```
### 线下充值流程(仅平台)
```
平台 → POST /api/admin/agent-recharges
└─ payment_method = "offline"
└─ 创建充值订单status=1 待支付)
平台确认 → POST /api/admin/agent-recharges/:id/offline-pay
├─ 验证操作密码(二次鉴权)
└─ 事务内:
├─ 更新充值记录状态 → 2已完成
├─ 记录 paid_at、completed_at
├─ 代理主钱包余额增加(乐观锁 version 字段)
├─ 创建钱包流水记录
└─ 记录审计日志
```
## 接口说明
### 基础路径
`/api/admin/agent-recharges`
**权限要求**:企业账号(`user_type=4`)在路由层被拦截,返回 `1005`
### 接口列表
| 方法 | 路径 | 说明 | 权限 |
|------|------|------|------|
| POST | `/api/admin/agent-recharges` | 创建充值订单 | 代理(自己店铺)/ 平台(任意店铺)|
| GET | `/api/admin/agent-recharges` | 查询充值记录列表 | 代理(自己店铺)/ 平台(全部)|
| GET | `/api/admin/agent-recharges/:id` | 查询充值记录详情 | 代理(自己店铺)/ 平台(全部)|
| POST | `/api/admin/agent-recharges/:id/offline-pay` | 确认线下充值到账 | 仅平台 |
### 创建充值订单
**请求体示例(在线充值)**
```json
{
"shop_id": 101,
"amount": 50000,
"payment_method": "wechat"
}
```
**请求体示例(线下充值)**
```json
{
"shop_id": 101,
"amount": 200000,
"payment_method": "offline"
}
```
**请求字段**
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| shop_id | integer | 是 | 目标店铺 ID代理只能填自己所属店铺|
| amount | integer | 是 | 充值金额(单位:分),范围 10000~100000000 |
| payment_method | string | 是 | `wechat`(在线)/ `offline`(线下,仅平台)|
**成功响应**
```json
{
"code": 0,
"msg": "success",
"data": {
"id": 88,
"recharge_no": "ARCH20260316100001",
"shop_id": 101,
"amount": 50000,
"payment_method": "wechat",
"payment_channel": "wechat_direct",
"payment_config_id": 3,
"status": 1,
"created_at": "2026-03-16T10:00:00+08:00"
},
"timestamp": "2026-03-16T10:00:00+08:00"
}
```
### 线下充值确认
**请求体**
```json
{
"operation_password": "Abc123456"
}
```
操作密码验证通过后,事务内同步完成:余额到账 + 钱包流水 + 审计日志。
## 权限控制矩阵
| 操作 | 平台账号 | 代理账号 | 企业账号 |
|------|----------|----------|----------|
| 创建充值(在线) | ✅ 任意店铺 | ✅ 仅自己店铺 | ❌ |
| 创建充值(线下) | ✅ 任意店铺 | ❌ | ❌ |
| 线下充值确认 | ✅ | ❌ | ❌ |
| 查询充值列表 | ✅ 全部 | ✅ 仅自己店铺 | ❌ |
| 查询充值详情 | ✅ 全部 | ✅ 仅自己店铺 | ❌ |
**越权统一响应**:代理访问他人店铺充值记录时,返回 `1121 CodeRechargeNotFound`(不区分不存在与无权限)
## 数据模型
### `tb_agent_recharge_record` 新增字段
| 字段 | 类型 | 可空 | 说明 |
|------|------|------|------|
| `payment_config_id` | bigint | 是 | 关联支付配置 ID线下充值为 NULL在线充值记录实际使用的配置|
### 充值订单状态枚举
| 值 | 含义 |
|----|------|
| 1 | 待支付 |
| 2 | 已完成 |
| 3 | 已取消 |
### 支付方式与通道
| payment_method | payment_channel | 说明 |
|---------------|----------------|------|
| wechat | wechat_direct | 微信直连通道provider_type=wechat|
| wechat | fuyou | 富友通道provider_type=fuiou|
| offline | offline | 线下转账 |
> 前端统一显示"微信支付",后端根据生效配置的 `provider_type` 自动路由,前端不感知具体通道。
### 充值单号规则
前缀 `ARCH`,全局唯一,用于回调时识别订单类型。
## 幂等性设计
- 回调处理使用状态条件更新:`WHERE status = 1`
- `RowsAffected == 0` 时说明已被处理,直接返回成功,不重复入账
- 钱包余额更新使用乐观锁(`version` 字段),并发冲突时最多重试 3 次
## 审计日志
线下充值确认(`OfflinePay`)操作记录审计日志,字段包括:
| 字段 | 值 |
|------|-----|
| `operator_id` | 当前操作人 ID |
| `operation_type` | `offline_recharge` |
| `operation_desc` | `确认代理充值到账:充值单号 {recharge_no},金额 {amount} 分` |
| `before_data` | 操作前余额和充值记录状态 |
| `after_data` | 操作后余额和充值记录状态 |
## 涉及文件
### 新增文件
| 层级 | 文件 | 说明 |
|------|------|------|
| DTO | `internal/model/dto/agent_recharge_dto.go` | 请求/响应 DTO |
| Service | `internal/service/agent_recharge/service.go` | 充值业务逻辑 |
| Handler | `internal/handler/admin/agent_recharge.go` | 4 个 Handler 方法 |
| 路由 | `internal/routes/agent_recharge.go` | 路由注册 |
### 修改文件
| 文件 | 变更说明 |
|------|---------|
| `internal/model/agent_wallet.go` | 新增 `PaymentConfigID *uint` 字段 |
| `internal/handler/callback/payment.go` | 新增 "ARCH" 前缀分发 → agentRechargeService.HandlePaymentCallback() |
| `internal/bootstrap/` 系列 | 注册 AgentRechargeService、AgentRechargeHandler |
| `cmd/api/docs.go` / `cmd/gendocs/main.go` | 注册 AgentRechargeHandler |
| `migrations/000081_add_payment_config_id_to_agent_recharge.up.sql` | tb_agent_recharge_record 新增 payment_config_id 列 |
## 常量定义
```go
// pkg/constants/wallet.go
AgentRechargeOrderPrefix = "ARCH" // 充值单号前缀
AgentRechargeMinAmount = 10000 // 最小充值100 元(单位:分)
AgentRechargeMaxAmount = 100000000 // 最大充值100 万元(单位:分)
```
## 已知限制(留桩)
**客户端支付发起未实现**:在线充值(`payment_method=wechat`)创建订单成功后,前端获取支付参数的接口本次未实现。充值回调处理已完整实现——等支付发起改造完成后,完整的充值支付闭环即可联通。

View File

@@ -0,0 +1,128 @@
# 客户端接口数据模型基础准备 - 功能总结
## 概述
本提案作为客户端接口系列的前置基础完成三类工作BUG 修复、基础字段准备、旧接口清理。
## 一、BUG 修复
### BUG-1代理零售价修复
**问题**`ShopPackageAllocation` 缺少 `retail_price` 字段,所有渠道统一使用 `Package.SuggestedRetailPrice`,代理无法设定自己的零售价。
**修复内容**
- `ShopPackageAllocation` 新增 `retail_price` 字段(迁移中存量数据批量回填为 `SuggestedRetailPrice`
- `GetPurchasePrice()` 改为按渠道取价:代理渠道返回 `allocation.RetailPrice`,平台渠道返回 `SuggestedRetailPrice`
- `validatePackages()` 价格累加同步修正,代理渠道额外校验 `RetailPrice >= CostPrice`
- 分配创建(`shop_package_batch_allocation``shop_series_grant`)时自动设置 `RetailPrice = SuggestedRetailPrice`
- 新增 cost_price 分配锁定:存在下级分配记录时禁止修改 `cost_price`
- `BatchUpdatePricing` 接口仅支持成本价批量调整(保留 cost_price 锁定规则)
- 新增独立接口 `PATCH /api/admin/packages/:id/retail-price`,代理可修改自己的套餐零售价
- `PackageResponse` 新增 `retail_price` 字段,利润计算修正为 `RetailPrice - CostPrice`
**涉及文件**
- `internal/model/shop_package_allocation.go`
- `internal/model/dto/shop_package_batch_pricing_dto.go`
- `internal/model/dto/package_dto.go`
- `internal/service/purchase_validation/service.go`
- `internal/service/shop_package_batch_allocation/service.go`
- `internal/service/shop_series_grant/service.go`
- `internal/service/shop_package_batch_pricing/service.go`
- `internal/service/package/service.go`
### BUG-2一次性佣金触发条件修复
**问题**:后台所有订单(包括代理自购)都可能触发一次性佣金。
**修复内容**
- `Order` 新增 `source` 字段(`admin`/`client`),默认 `admin`
- 佣金触发条件从 `!order.IsPurchaseOnBehalf` 改为 `!order.IsPurchaseOnBehalf && order.Source == "client"`
- `CreateAdminOrder()` 设置 `Source: constants.OrderSourceAdmin`
**涉及文件**
- `internal/model/order.go`
- `internal/service/commission_calculation/service.go`(两个方法)
- `internal/service/order/service.go`
### BUG-4充值回调事务一致性修复
**问题**`HandlePaymentCallback``UpdateStatusWithOptimisticLock``UpdatePaymentInfo` 使用 `s.db` 而非事务内 `tx`
**修复内容**
- `AssetRechargeStore` 新增 `UpdateStatusWithOptimisticLockDB``UpdatePaymentInfoWithDB` 方法(支持传入 `tx`
- 原方法保留(委托调用新方法),确保向后兼容
- `HandlePaymentCallback` 改用事务内 `tx` 调用
**涉及文件**
- `internal/store/postgres/asset_recharge_store.go`
- `internal/service/recharge/service.go`
## 二、基础字段准备
### 新增常量文件
| 文件 | 内容 |
|------|------|
| `pkg/constants/asset_status.go` | 资产业务状态(在库/已销售/已换货/已停用) |
| `pkg/constants/order_source.go` | 订单来源admin/client |
| `pkg/constants/operator_type.go` | 操作人类型admin_user/personal_customer |
| `pkg/constants/realname_link.go` | 实名链接类型none/template/gateway |
### 模型字段变更
| 模型 | 新增字段 | 说明 |
|------|---------|------|
| `IotCard` | `asset_status`, `generation` | 业务生命周期状态、资产世代编号 |
| `Device` | `asset_status`, `generation` | 同上 |
| `Order` | `source`, `generation` | 订单来源、资产世代快照 |
| `PackageUsage` | `generation` | 资产世代快照 |
| `AssetRechargeRecord` | `operator_type`, `generation`, `linked_package_ids`, `linked_order_type`, `linked_carrier_type`, `linked_carrier_id` | 操作人类型、世代、强充关联字段 |
| `Carrier` | `realname_link_type`, `realname_link_template` | 实名链接配置 |
| `ShopPackageAllocation` | `retail_price` | 代理零售价 |
| `PersonalCustomer` | `wx_open_id` 索引变更 | 唯一索引改为普通索引 |
### Carrier 管理 DTO 更新
- `CarrierCreateRequest``CarrierUpdateRequest` 新增 `realname_link_type``realname_link_template` 字段
- `CarrierResponse` 新增对应展示字段
- Carrier Service 的 Create/Update 方法同步处理Update 时 `template` 类型强制校验模板非空
### 资产手动停用
- 新增 `PATCH /api/admin/iot-cards/:id/deactivate``PATCH /api/admin/devices/:id/deactivate`
-`asset_status` 为 1在库或 2已销售时允许停用
- 使用条件更新确保幂等
## 三、旧接口清理
### H5 接口删除
- 删除 `internal/handler/h5/` 全部文件5 个)
- 删除 `internal/routes/h5*.go`3 个文件)
- 清理 `routes.go``order.go``recharge.go` 中的 H5 路由注册
- 清理 `bootstrap/` 中 H5 Handler 构造和字段
- 清理 `middlewares.go` 中 H5 认证中间件
- 清理 `pkg/openapi/handlers.go` 中 H5 文档生成引用
- 清理 `cmd/api/main.go` 中 H5 限流挂载
### 个人客户旧登录方法删除
- 删除 `internal/handler/app/personal_customer.go` 中 Login、SendCode、WechatOAuthLogin、BindWechat 方法
- 清理对应路由注册
- 保留 UpdateProfile 和 GetProfile
## 四、数据库迁移
- 迁移编号000082
- 涉及 7 张表、15+ 个字段变更
- 包含存量 `retail_price` 批量回填
- 包含 `wx_open_id` 索引从唯一改为普通
- 所有字段使用 `NOT NULL DEFAULT` 确保存量兼容
## 五、后台订单 generation 快照
- `CreateAdminOrder()` 创建订单时从资产IotCard/Device获取当前 `Generation` 值写入订单
- 不再依赖数据库默认值 1

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
# C 端认证系统功能总结
## 概述
本次实现了面向个人客户C 端)的完整认证体系,替代旧 H5 登录接口。支持微信公众号和小程序两种登录方式,基于「资产标识符验证 → 微信授权 → 自动绑定资产 → 可选绑定手机号」的流程。
## 接口列表
| 接口 | 路径 | 认证 | 说明 |
|------|------|------|------|
| A1 | `POST /api/c/v1/auth/verify-asset` | 否 | 资产标识符验证,返回 asset_token |
| A2 | `POST /api/c/v1/auth/wechat-login` | 否 | 微信公众号登录 |
| A3 | `POST /api/c/v1/auth/miniapp-login` | 否 | 微信小程序登录 |
| A4 | `POST /api/c/v1/auth/send-code` | 否 | 发送手机验证码 |
| A5 | `POST /api/c/v1/auth/bind-phone` | 是 | 首次绑定手机号 |
| A6 | `POST /api/c/v1/auth/change-phone` | 是 | 换绑手机号(双验证码) |
| A7 | `POST /api/c/v1/auth/logout` | 是 | 退出登录 |
## 登录流程
```
用户输入资产标识符SN/IMEI/ICCID
[A1] verify-asset → asset_token5分钟有效
微信授权(前端完成)
├── 公众号 → [A2] wechat-login (code + asset_token)
└── 小程序 → [A3] miniapp-login (code + asset_token)
解析 asset_token → 获取微信 openid
→ 查找/创建客户 → 绑定资产
→ 签发 JWT + Redis 存储
返回 { token, need_bind_phone, is_new_user }
need_bind_phone == true?
YES → [A4] 发送验证码 → [A5] 绑定手机号
NO → 进入主页面
```
## 核心设计
### 有状态 JWTJWT + Redis
- JWT payload 仅含 `customer_id` + `exp`
- 登录时将 token 写入 RedisTTL 与 JWT 一致
- 每次请求在中间件同时校验 JWT 签名和 Redis 有效状态
- 支持服务端主动失效(封禁、强制下线、退出登录)
- 单点登录:新登录覆盖旧 token
### OpenID 多记录管理
- 新增 `tb_personal_customer_openid`
- 同一客户可在多个 AppID公众号/小程序)下拥有不同 OpenID
- 唯一约束:`UNIQUE(app_id, open_id) WHERE deleted_at IS NULL`
- 客户查找逻辑openid 精确匹配 → unionid 回退合并 → 创建新客户
### 资产绑定
- 每次登录创建 `PersonalCustomerDevice` 绑定记录
- 同一资产允许被多个客户绑定(支持转手场景)
- 首次绑定时自动将资产状态从「在库(1)」更新为「已销售(2)」
### 微信配置动态加载
- 登录时从数据库 `tb_wechat_config` 动态读取激活配置
- 优先走 WechatConfigService 的 Redis 缓存
- 小程序登录直接 HTTP 调用微信 `jscode2session`(不依赖 PowerWeChat SDK
## 限流策略
| 接口 | 维度 | 限制 |
|------|------|------|
| A1 | IP | 30 次/分钟 |
| A4 | 手机号 | 60 秒冷却 |
| A4 | IP | 20 次/小时 |
| A4 | 手机号 | 10 次/天 |
## 新增/修改文件
### 新增文件
| 文件 | 说明 |
|------|------|
| `internal/model/personal_customer_openid.go` | OpenID 关联模型 |
| `internal/model/dto/client_auth_dto.go` | A1-A7 请求/响应 DTO |
| `internal/store/postgres/personal_customer_openid_store.go` | OpenID Store |
| `internal/service/client_auth/service.go` | 认证 Service核心业务逻辑 |
| `internal/handler/app/client_auth.go` | 认证 Handler7 个端点) |
| `pkg/wechat/miniapp.go` | 小程序 SDK 封装 |
| `migrations/000083_add_personal_customer_openid.up.sql` | 迁移文件 |
| `migrations/000083_add_personal_customer_openid.down.sql` | 回滚文件 |
### 修改文件
| 文件 | 说明 |
|------|------|
| `internal/middleware/personal_auth.go` | 增加 Redis 双重校验 |
| `pkg/constants/redis.go` | 新增 token 和限流 Redis Key |
| `pkg/errors/codes.go` | 新增错误码 1180-1186 |
| `pkg/config/defaults/config.yaml` | 新增 `client.require_phone_binding` |
| `pkg/wechat/wechat.go` | 新增 MiniAppServiceInterface |
| `pkg/wechat/config.go` | 新增 3 个 DB 动态工厂函数 |
| `internal/bootstrap/types.go` | 新增 ClientAuth Handler 字段 |
| `internal/bootstrap/handlers.go` | 实例化 ClientAuth Handler |
| `internal/bootstrap/services.go` | 初始化 ClientAuth Service |
| `internal/bootstrap/stores.go` | 初始化 OpenID Store |
| `internal/routes/personal.go` | 注册 7 个认证端点 |
| `cmd/api/docs.go` | 注册文档生成器 |
| `cmd/gendocs/main.go` | 注册文档生成器 |
## 错误码
| 码值 | 常量名 | 说明 |
|------|--------|------|
| 1180 | CodeAssetNotFound | 资产不存在 |
| 1181 | CodeWechatConfigUnavailable | 微信配置不可用 |
| 1182 | CodeSmsSendFailed | 短信发送失败 |
| 1183 | CodeVerificationCodeInvalid | 验证码错误或已过期 |
| 1184 | CodePhoneAlreadyBound | 手机号已被其他客户绑定 |
| 1185 | CodeAlreadyBoundPhone | 已绑定手机号不可重复绑定 |
| 1186 | CodeOldPhoneMismatch | 旧手机号与当前绑定不匹配 |
## 数据库变更
- 新建表 `tb_personal_customer_openid`(迁移 000083
- 唯一索引:`idx_pco_app_id_open_id` (app_id, open_id) 软删除条件
- 普通索引:`idx_pco_customer_id` (customer_id)
- 条件索引:`idx_pco_union_id` (union_id) WHERE union_id != ''
## 配置项
| 配置路径 | 环境变量 | 默认值 | 说明 |
|---------|---------|-------|------|
| `client.require_phone_binding` | `JUNHONG_CLIENT_REQUIRE_PHONE_BINDING` | `true` | 是否要求绑定手机号 |

View File

@@ -0,0 +1,122 @@
# 客户端核心业务 API — 功能总结
## 概述
本提案为客户端C 端个人客户)提供完整的业务接口,覆盖资产查询、钱包充值、套餐购买、实名跳转、设备操作 5 大模块共 18 个 API 端点,全部挂载在 `/api/c/v1/` 路径下。
**前置依赖**:提案 0数据模型修复、提案 1C 端认证系统)。
## API 端点一览
### 模块 B资产信息4 个接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/c/v1/asset/info` | B1 资产基本信息查询 |
| GET | `/api/c/v1/asset/packages` | B2 可购买套餐列表 |
| GET | `/api/c/v1/asset/package-history` | B3 历史套餐列表 |
| POST | `/api/c/v1/asset/refresh` | B4 手动刷新资产状态 |
### 模块 C钱包与充值5 个接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/c/v1/wallet/detail` | C1 钱包详情(不存在自动创建) |
| GET | `/api/c/v1/wallet/transactions` | C2 钱包流水列表 |
| GET | `/api/c/v1/wallet/recharge-check` | C3 充值预检(强充检查) |
| POST | `/api/c/v1/wallet/recharge` | C4 创建充值订单JSAPI 支付) |
| GET | `/api/c/v1/wallet/recharges` | C5 充值订单列表 |
### 模块 D套餐购买3 个接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/c/v1/orders/create` | D1 创建套餐购买订单(含强充分流) |
| GET | `/api/c/v1/orders` | D2 套餐订单列表 |
| GET | `/api/c/v1/orders/:id` | D3 套餐订单详情 |
### 模块 E实名认证1 个接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/c/v1/realname/link` | E1 获取实名跳转链接 |
### 模块 F设备能力5 个接口)
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/c/v1/device/cards` | F1 设备卡列表 |
| POST | `/api/c/v1/device/reboot` | F2 设备重启 |
| POST | `/api/c/v1/device/factory-reset` | F3 恢复出厂设置 |
| POST | `/api/c/v1/device/wifi` | F4 设置 WiFi |
| POST | `/api/c/v1/device/switch-card` | F5 切卡 |
## 核心设计决策
### 1. 数据权限绕过
客户端调用后台复用 Service 时,统一使用 `gorm.SkipDataPermission(ctx)` 绕过 shop_id 自动过滤,避免个人客户因非店铺主体被误拦截。
### 2. 归属校验
所有涉及资产操作的接口统一前置归属校验:查询 `PersonalCustomerDevice` 条件 `customer_id = 当前登录客户``virtual_no = 资产虚拟号`,未命中返回 403。
### 3. Generation 过滤
客户端历史查询统一附加 `WHERE generation = 资产当前 generation`,确保转手后数据隔离。
### 4. OpenID 安全规范
支付接口C4/D1所需 OpenID 由后端按 `customer_id + app_type` 查询,客户端禁止传入 OpenID。根据 `app_type` 选择对应的微信 AppID 创建支付实例。
### 5. 强充两阶段
- 第一阶段(同步):充值入账、更新状态
- 第二阶段(异步 Asynq钱包扣款 → 创建订单 → 激活套餐
`AssetRechargeRecord.auto_purchase_status` 字段追踪异步状态pending/success/failed
## 新增文件
```
internal/model/dto/client_asset_dto.go # 资产模块 DTO
internal/model/dto/client_wallet_dto.go # 钱包模块 DTO
internal/model/dto/client_order_dto.go # 订单模块 DTO
internal/model/dto/client_realname_device_dto.go # 实名+设备模块 DTO
internal/handler/app/client_asset.go # 资产 Handler
internal/handler/app/client_wallet.go # 钱包 Handler
internal/handler/app/client_order.go # 订单 Handler
internal/handler/app/client_realname.go # 实名 Handler
internal/handler/app/client_device.go # 设备 Handler
internal/service/client_order/service.go # 客户端订单编排 Service
internal/task/auto_purchase.go # 强充异步自动购买任务
migrations/000084_add_auto_purchase_status_*.sql # 数据库迁移
```
## 修改文件
```
pkg/constants/constants.go # 新增 auto_purchase_status 常量 + 任务类型
pkg/constants/redis.go # 新增客户端购买幂等键
pkg/errors/codes.go # 新增 NEED_REALNAME/OPENID_NOT_FOUND 错误码
internal/model/asset_wallet.go # AssetRechargeRecord 新增字段
internal/bootstrap/types.go # 5 个 Handler 字段
internal/bootstrap/handlers.go # Handler 实例化
internal/routes/personal.go # 18 个路由注册
pkg/openapi/handlers.go # 文档生成 Handler
cmd/api/docs.go # 文档注册
cmd/gendocs/main.go # 文档注册
```
## 新增错误码
| 错误码 | 常量名 | 消息 |
|--------|--------|------|
| 1187 | CodeNeedRealname | 该套餐需实名认证后购买 |
| 1188 | CodeOpenIDNotFound | 未找到微信授权信息,请先完成授权 |
## 数据库变更
- 表:`tb_asset_recharge_record`
- 新增字段:`auto_purchase_status VARCHAR(20) DEFAULT '' NOT NULL`
- 迁移版本000084

View File

@@ -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 级问题)。

View File

@@ -0,0 +1,239 @@
# 微信参数配置管理功能
## 功能概述
在管理后台支持多套微信支付配置的 CRUD 管理,每套配置代表一套完整的"微信身份"(公众号 OAuth + 小程序 OAuth + 支付凭证),支持全局唯一激活约束和秒级切换。同时集成富友支付 SDK作为微信直连的备选渠道。
### 背景与动机
原有微信相关参数(公众号 OAuth、小程序、支付凭证硬编码在环境变量中只有一套配置无法动态切换。业务上微信公众号/小程序随时可能被封禁,需要在管理后台**秒级切换**到备用配置恢复 OAuth 登录和支付能力。同时需要接入富友支付作为备选通道,降低对微信直连的单一依赖。
## 核心设计
### 配置切换流程
```
管理员激活新配置 POST /api/admin/wechat-configs/:id/activate
├─ ① BEGIN 事务
│ ├─ UPDATE tb_wechat_config SET is_active=false WHERE is_active=true
│ └─ UPDATE tb_wechat_config SET is_active=true WHERE id=:id
├─ ② COMMIT
├─ ③ DEL Redis "wechat:config:active"(即时生效)
└─ ④ 记录审计日志
├─ 新订单 → 使用新配置(记录新的 payment_config_id
└─ 旧订单(待支付)→ 回调时按 payment_config_id 加载旧配置验签
└─ 30 分钟超时自动取消
```
### 生效配置缓存策略
- **Redis Key**`wechat:config:active`(见 `pkg/constants/redis.go`
- **TTL**5 分钟(兜底,防 Redis 缓存与 DB 长期不一致)
- **主动失效**:激活、停用、更新生效配置、删除配置时主动 DEL 缓存
- **空标记**:无生效配置时缓存 `"none"`TTL 1 分钟,防止缓存穿透
- **读取流程**Redis GET → 命中返回 → MISS → 查 DB → SET 缓存
### 配置切换时在途订单处理
- `tb_order``tb_asset_recharge_record``tb_agent_recharge_record` 均新增 `payment_config_id` 字段nullable
- 下单时记录当前使用的配置 ID配置切换后旧订单仍按 `payment_config_id` 加载旧配置验签
- 旧待支付订单由现有 30 分钟超时自动取消机制清理
- **有待支付订单引用的配置不允许删除**(软删除后仍可用于验签)
### 支付回调统一分发
```
回调到达
├─ 微信回调 POST /api/callback/wechat-pay
│ └─ PowerWeChat SDK 解析 → 取 out_trade_no
└─ 富友回调 POST /api/callback/fuiou-pay
└─ GBK→UTF-8 → XML 解析 → 取 mchnt_order_no
└─ 按订单号前缀分发
├─ "ORD" → 套餐订单 → orderService.HandlePaymentCallback()
├─ "CRCH" → 资产充值 → rechargeService.HandlePaymentCallback()
└─ "ARCH" → 代理充值 → agentRechargeService.HandlePaymentCallback()
```
## 接口说明
### 基础路径
`/api/admin/wechat-configs`
**权限要求**:仅超级管理员(`user_type=1`)和平台用户(`user_type=2`)可访问,其他类型返回 `1005`
### 接口列表
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/api/admin/wechat-configs` | 创建配置 |
| GET | `/api/admin/wechat-configs` | 查询配置列表(分页+筛选) |
| GET | `/api/admin/wechat-configs/active` | 查询当前生效配置 |
| GET | `/api/admin/wechat-configs/:id` | 查询配置详情 |
| PUT | `/api/admin/wechat-configs/:id` | 更新配置 |
| DELETE | `/api/admin/wechat-configs/:id` | 软删除配置 |
| POST | `/api/admin/wechat-configs/:id/activate` | 激活配置 |
| POST | `/api/admin/wechat-configs/:id/deactivate` | 停用配置 |
| POST | `/api/callback/fuiou-pay` | 富友支付回调(无需认证) |
### 渠道类型provider_type
| 值 | 说明 | 必填支付字段 |
|----|------|-------------|
| `wechat` | 微信直连 | `wx_mch_id``wx_api_v3_key``wx_cert_content``wx_key_content``wx_serial_no``wx_notify_url` |
| `fuiou` | 富友聚合支付 | `fy_ins_cd``fy_mchnt_cd``fy_term_id``fy_private_key``fy_public_key``fy_api_url``fy_notify_url` |
### 敏感字段脱敏规则
接口响应中所有敏感字段均脱敏,数据库明文存储:
| 字段类型 | 脱敏规则 | 示例 |
|---------|---------|------|
| Secret/Key | 前4位 + `***` + 后4位 | `abcd***7890` |
| 证书/私钥(长) | 仅显示状态 | `[已配置]` / `[未配置]` |
**更新脱敏字段**:不传或传空字符串 = 保留原值;传新明文值 = 替换。
### 删除保护规则
| 条件 | 错误码 | 错误消息 |
|------|--------|---------|
| 配置 `is_active=true` | `1171` | 不能删除当前生效的支付配置,请先停用 |
| 存在待支付订单引用 | `1172` | 该配置存在未完成的支付订单,暂时无法删除 |
## 富友支付 SDK
**位置**`pkg/fuiou/`
| 文件 | 说明 |
|------|------|
| `types.go` | WxPreCreateRequest/Response、NotifyRequest 等 XML 结构体 |
| `client.go` | Client 结构体、NewClient、RSA 签名/验签、HTTP 请求XML+GBK|
| `wxprecreate.go` | WxPreCreate 方法(公众号 JSAPI + 小程序支付下单)|
| `notify.go` | VerifyNotifyGBK→UTF-8 + XML 解析 + RSA 验签、BuildNotifyResponse |
**签名算法**:字典序排列参数 → GBK 编码 → MD5 哈希 → RSA 签名 → Base64
**新增依赖**`golang.org/x/text`GBK 编解码)
## 数据库变更
### 新建表 `tb_wechat_config`(迁移 000078
| 字段组 | 字段 | 说明 |
|-------|------|------|
| 基础信息 | `id`, `name`, `description`, `provider_type`, `is_active` | 配置基础字段 |
| 公众号 OAuth | `oa_app_id`, `oa_app_secret`, `oa_token`, `oa_aes_key`, `oa_oauth_redirect_url` | 公众号相关 |
| 小程序 OAuth | `miniapp_app_id`, `miniapp_app_secret` | 小程序相关 |
| 微信直连 | `wx_mch_id`, `wx_api_v3_key`, `wx_api_v2_key`, `wx_cert_content`, `wx_key_content`, `wx_serial_no`, `wx_notify_url` | provider_type=wechat 时使用 |
| 富友 | `fy_ins_cd`, `fy_mchnt_cd`, `fy_term_id`, `fy_private_key`, `fy_public_key`, `fy_api_url`, `fy_notify_url` | provider_type=fuiou 时使用 |
| 审计 | `creator`, `updater`, `created_at`, `updated_at`, `deleted_at` | 标准审计字段 |
### 新增字段
| 表 | 字段 | 类型 | 迁移文件 |
|----|------|------|---------|
| `tb_order` | `payment_config_id` | bigint, nullable | 000079 |
| `tb_asset_recharge_record` | `payment_config_id` | bigint, nullable | 000080 |
| `tb_agent_recharge_record` | `payment_config_id` | bigint, nullable | 000081 |
## 新增错误码
| 错误码 | 常量 | 说明 |
|--------|------|------|
| 1170 | `CodeWechatConfigNotFound` | 微信支付配置不存在 |
| 1171 | `CodeWechatConfigActive` | 不能删除/操作当前生效的支付配置 |
| 1172 | `CodeWechatConfigHasPendingOrders` | 该配置存在未完成的支付订单 |
| 1173 | `CodeFuiouPayFailed` | 富友支付失败 |
| 1174 | `CodeFuiouCallbackInvalid` | 富友回调验签失败 |
| 1175 | `CodeNoPaymentConfig` | 当前无可用的支付配置 |
## 审计日志
以下操作均记录审计日志(异步写入,失败不影响业务):
| 操作 | operation_type | 说明 |
|------|---------------|------|
| 创建配置 | `create` | after_data 存脱敏后配置 |
| 更新配置 | `update` | before/after_data 均脱敏 |
| 删除配置 | `delete` | before_data 存脱敏后配置 |
| 激活配置 | `activate` | before_data=旧配置after_data=新配置 |
| 停用配置 | `deactivate` | before/after_data 存状态变更 |
## 涉及文件
### 新增文件
| 层级 | 文件 | 说明 |
|------|------|------|
| 模型 | `internal/model/wechat_config.go` | WechatConfig 模型、渠道类型常量 |
| DTO | `internal/model/dto/wechat_config_dto.go` | CRUD 请求/响应 DTO、脱敏方法 |
| Store | `internal/store/postgres/wechat_config_store.go` | CRUD + 激活/停用 + 统计 |
| Service | `internal/service/wechat_config/service.go` | 业务逻辑、缓存管理、删除保护 |
| Handler | `internal/handler/admin/wechat_config.go` | 8 个 Handler 方法 |
| 路由 | `internal/routes/wechat_config.go` | 路由注册(含平台权限中间件) |
| SDK | `pkg/fuiou/types.go` | 富友 XML 结构体 |
| SDK | `pkg/fuiou/client.go` | 富友 HTTP 客户端、签名/验签 |
| SDK | `pkg/fuiou/wxprecreate.go` | 富友支付下单 |
| SDK | `pkg/fuiou/notify.go` | 富友回调验签 |
| 迁移 | `migrations/000078_create_wechat_config_table.up.sql` | 创建 tb_wechat_config 表 |
| 迁移 | `migrations/000079_add_payment_config_id_to_order.up.sql` | tb_order 新增字段 |
| 迁移 | `migrations/000080_add_payment_config_id_to_asset_recharge.up.sql` | tb_asset_recharge_record 新增字段 |
| 迁移 | `migrations/000081_add_payment_config_id_to_agent_recharge.up.sql` | tb_agent_recharge_record 新增字段 |
### 修改文件
| 文件 | 变更说明 |
|------|---------|
| `internal/model/order.go` | 新增 `PaymentConfigID *uint` 字段 |
| `internal/model/asset_wallet.go` | 新增 `PaymentConfigID *uint` 字段 |
| `internal/handler/callback/payment.go` | 支持富友回调 + 按订单前缀分发 + 按 payment_config_id 验签 |
| `internal/routes/order.go` | 新增 `/api/callback/fuiou-pay` 路由 |
| `internal/service/order/service.go` | 注入 wechatConfigService、下单时记录 payment_config_id |
| `internal/bootstrap/` 系列 | 注册 WechatConfigStore/Service/Handler |
| `cmd/api/docs.go` / `cmd/gendocs/main.go` | 注册 WechatConfigHandler |
### 删除/精简文件YAML 支付方案遗留清理)
| 文件 | 变更说明 |
|------|---------|
| `pkg/config/config.go` | 删除 `PaymentConfig` 结构体 + `WechatConfig.Payment` 字段 |
| `pkg/config/defaults/config.yaml` | 删除 `wechat.payment:` 整个配置节 |
| `pkg/wechat/config.go` | 删除 `NewPaymentApp()` 函数YAML/CertPath 方式已被 DB Base64 方案替代) |
| `cmd/api/main.go` | 删除 `validateWechatConfig` 中所有 `wechatCfg.Payment.*` 相关校验代码 |
## 常量定义
```go
// pkg/constants/wallet.goCard* 重命名为 Asset*,旧名保留为废弃别名)
AssetWalletResourceTypeIotCard // 原 CardWalletResourceTypeIotCard
AssetWalletResourceTypeDevice // 原 CardWalletResourceTypeDevice
AssetRechargeOrderPrefix // "CRCH"(原 CardRechargeOrderPrefix
AssetRechargeMinAmount // 最小充值金额(分)
AssetRechargeMaxAmount // 最大充值金额(分)
// pkg/constants/redis.go
RedisWechatConfigActiveKey() // "wechat:config:active"
// internal/model/wechat_config.go
ProviderTypeWechat = "wechat" // 微信直连
ProviderTypeFuiou = "fuiou" // 富友
```
## 已知限制(留桩)
以下功能本次**未实现**,待后续会话补全:
- **客户端支付发起**`WechatPayJSAPI``WechatPayH5``FuiouPayJSAPI``FuiouPayMiniApp` 均为留桩(返回"暂未实现"错误或 TODO 注释),当前仍保留 `wechatPayment` 单例注入
- **OAuth 配置动态加载**`OfficialAccountService` 仍从环境变量读取,`tb_wechat_config` 中的 `oa_*` 字段仅存储,待 H5/小程序重构时切换
## 部署注意事项
1. 执行数据库迁移000078~000081现有数据不受影响新字段均为 nullable
2. 原环境变量 `JUNHONG_WECHAT_PAYMENT_*` 系列已不再读取,可清理
3. 首次上线后,需要在管理后台手动创建并激活一个微信配置,否则第三方支付功能处于禁用状态(系统自动降级为仅支持钱包/线下支付)

File diff suppressed because it is too large Load Diff

6
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/break/junhong_cmp_fiber
go 1.25
go 1.25.0
require (
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38
@@ -20,6 +20,7 @@ require (
github.com/xuri/excelize/v2 v2.8.1
go.uber.org/zap v1.27.1
golang.org/x/crypto v0.47.0
golang.org/x/text v0.35.0
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
gorm.io/datatypes v1.2.7
@@ -88,9 +89,8 @@ require (
go.yaml.in/yaml/v3 v3.0.4 // indirect
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
golang.org/x/net v0.48.0 // indirect
golang.org/x/sync v0.19.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.40.0 // indirect
golang.org/x/text v0.33.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect

8
go.sum
View File

@@ -298,15 +298,15 @@ golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=

View File

@@ -24,6 +24,5 @@ type Dependencies struct {
QueueClient *queue.Client // Asynq 任务队列客户端
StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil
WechatOfficialAccount wechat.OfficialAccountServiceInterface // 微信公众号服务(可选)
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
}

View File

@@ -5,12 +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"
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
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,17 +47,22 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
Role: admin.NewRoleHandler(svc.Role, validate),
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),
H5Auth: h5.NewAuthHandler(svc.Auth, validate),
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission),
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal),
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
EnterpriseCard: admin.NewEnterpriseCardHandler(svc.EnterpriseCard),
EnterpriseDevice: admin.NewEnterpriseDeviceHandler(svc.EnterpriseDevice),
EnterpriseDeviceH5: h5.NewEnterpriseDeviceHandler(svc.EnterpriseDevice),
Authorization: admin.NewAuthorizationHandler(svc.Authorization),
MyCommission: admin.NewMyCommissionHandler(svc.MyCommission),
IotCard: admin.NewIotCardHandler(svc.IotCard),
@@ -41,14 +75,12 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
Package: admin.NewPackageHandler(svc.Package),
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
H5PackageUsage: h5.NewPackageUsageHandler(deps.DB, svc.PackageCustomerView),
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
H5Order: h5.NewOrderHandler(svc.Order),
H5Recharge: h5.NewRechargeHandler(svc.Recharge),
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, deps.WechatPayment),
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),
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
@@ -56,5 +88,9 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
Asset: admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService),
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),
AssetWallet: admin.NewAssetWalletHandler(svc.AssetWallet),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge),
}
}

View File

@@ -22,7 +22,7 @@ func initMiddlewares(deps *Dependencies, stores *stores) *Middlewares {
jwtManager := pkgauth.NewJWTManager(cfg.JWT.SecretKey, cfg.JWT.TokenDuration)
// 创建个人客户认证中间件
personalAuthMiddleware := middleware.NewPersonalAuthMiddleware(jwtManager, deps.Logger)
personalAuthMiddleware := middleware.NewPersonalAuthMiddleware(jwtManager, deps.Redis, deps.Logger)
// 创建 Token Manager用于后台和H5认证
accessTTL := time.Duration(cfg.JWT.AccessTokenTTL) * time.Second
@@ -32,13 +32,9 @@ func initMiddlewares(deps *Dependencies, stores *stores) *Middlewares {
// 创建后台认证中间件(传入 ShopStore 以支持预计算下级店铺 ID
adminAuthMiddleware := createAdminAuthMiddleware(tokenManager, stores.Shop)
// 创建H5认证中间件传入 ShopStore 以支持预计算下级店铺 ID
h5AuthMiddleware := createH5AuthMiddleware(tokenManager, stores.Shop)
return &Middlewares{
PersonalAuth: personalAuthMiddleware,
AdminAuth: adminAuthMiddleware,
H5Auth: h5AuthMiddleware,
}
}
@@ -68,29 +64,3 @@ func createAdminAuthMiddleware(tokenManager *pkgauth.TokenManager, shopStore pkg
ShopStore: shopStore,
})
}
func createH5AuthMiddleware(tokenManager *pkgauth.TokenManager, shopStore pkgmiddleware.AuthShopStoreInterface) fiber.Handler {
return pkgmiddleware.Auth(pkgmiddleware.AuthConfig{
TokenValidator: func(token string) (*pkgmiddleware.UserContextInfo, error) {
tokenInfo, err := tokenManager.ValidateAccessToken(context.Background(), token)
if err != nil {
return nil, errors.New(errors.CodeInvalidToken, "认证令牌无效或已过期")
}
// 检查用户类型H5 允许 Agent(3), Enterprise(4)
if tokenInfo.UserType != constants.UserTypeAgent &&
tokenInfo.UserType != constants.UserTypeEnterprise {
return nil, errors.New(errors.CodeForbidden, "权限不足")
}
return &pkgmiddleware.UserContextInfo{
UserID: tokenInfo.UserID,
UserType: tokenInfo.UserType,
ShopID: tokenInfo.ShopID,
EnterpriseID: tokenInfo.EnterpriseID,
}, nil
},
SkipPaths: []string{"/api/h5/login", "/api/h5/refresh-token"},
ShopStore: shopStore,
})
}

View File

@@ -7,17 +7,20 @@ import (
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth"
carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier"
clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth"
commissionCalculationSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet"
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
deviceImportSvc "github.com/break/junhong_cmp_fiber/internal/service/device_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"
@@ -31,11 +34,13 @@ import (
roleSvc "github.com/break/junhong_cmp_fiber/internal/service/role"
shopSvc "github.com/break/junhong_cmp_fiber/internal/service/shop"
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
shopPackageBatchAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
shopPackageBatchPricingSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_pricing"
shopSeriesGrantSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_series_grant"
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
)
type services struct {
@@ -44,6 +49,7 @@ type services struct {
Role *roleSvc.Service
Permission *permissionSvc.Service
PersonalCustomer *personalCustomerSvc.Service
ClientAuth *clientAuthSvc.Service
Shop *shopSvc.Service
Auth *authSvc.Service
ShopCommission *shopCommissionSvc.Service
@@ -71,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
@@ -79,7 +86,11 @@ type services struct {
PollingCleanup *pollingSvc.CleanupService
PollingManualTrigger *pollingSvc.ManualTriggerService
Asset *assetSvc.Service
AssetLifecycle *assetSvc.LifecycleService
AssetWallet *assetWalletSvc.Service
StopResumeService *iotCardSvc.StopResumeService
WechatConfig *wechatConfigSvc.Service
AgentRecharge *agentRechargeSvc.Service
}
func initServices(s *stores, deps *Dependencies) *services {
@@ -91,13 +102,30 @@ func initServices(s *stores, deps *Dependencies) *services {
iotCard := iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient, deps.Logger)
iotCard.SetPollingCallback(polling.NewAPICallback(deps.Redis, deps.Logger))
// 创建支付配置服务Order 和 Recharge 依赖)
wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, accountAudit, deps.Redis, deps.Logger)
return &services{
Account: account,
AccountAudit: accountAudit,
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis),
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.WechatOfficialAccount, deps.Logger),
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole),
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.Logger),
ClientAuth: clientAuthSvc.New(
deps.DB,
s.PersonalCustomerOpenID,
s.PersonalCustomer,
s.PersonalCustomerDevice,
s.PersonalCustomerPhone,
s.IotCard,
s.Device,
wechatConfig,
deps.VerificationService,
deps.JWTManager,
deps.Redis,
deps.Logger,
),
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole, s.AgentWallet),
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionRecord),
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest),
@@ -140,8 +168,9 @@ func initServices(s *stores, deps *Dependencies) *services {
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger),
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation,
Order: orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.CardWallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, deps.WechatPayment, deps.QueueClient, deps.Logger),
Recharge: rechargeSvc.New(deps.DB, s.CardRecharge, s.CardWallet, s.CardWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, deps.Logger),
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),
PollingMonitoring: pollingSvc.NewMonitoringService(deps.Redis),
@@ -149,6 +178,21 @@ func initServices(s *stores, deps *Dependencies) *services {
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger),
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard),
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device),
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
StopResumeService: iotCardSvc.NewStopResumeService(deps.DB, deps.Redis, s.IotCard, s.DeviceSimBinding, deps.GatewayClient, deps.Logger),
WechatConfig: wechatConfig,
AgentRecharge: agentRechargeSvc.New(
deps.DB,
s.AgentRecharge,
s.AgentWallet,
s.AgentWalletTransaction,
s.Shop,
s.Account,
wechatConfig,
accountAudit,
deps.Redis,
deps.Logger,
),
}
}

View File

@@ -14,6 +14,8 @@ type stores struct {
ShopRole *postgres.ShopRoleStore
RolePermission *postgres.RolePermissionStore
PersonalCustomer *postgres.PersonalCustomerStore
PersonalCustomerOpenID *postgres.PersonalCustomerOpenIDStore
PersonalCustomerDevice *postgres.PersonalCustomerDeviceStore
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
CommissionWithdrawalRequest *postgres.CommissionWithdrawalRequestStore
CommissionRecord *postgres.CommissionRecordStore
@@ -38,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
@@ -49,10 +53,12 @@ type stores struct {
AgentWallet *postgres.AgentWalletStore
AgentWalletTransaction *postgres.AgentWalletTransactionStore
AgentRecharge *postgres.AgentRechargeStore
// 钱包系统
CardWallet *postgres.CardWalletStore
CardWalletTransaction *postgres.CardWalletTransactionStore
CardRecharge *postgres.CardRechargeStore
// 资产钱包系统
AssetWallet *postgres.AssetWalletStore
AssetWalletTransaction *postgres.AssetWalletTransactionStore
AssetRecharge *postgres.AssetRechargeStore
// 微信参数配置
WechatConfig *postgres.WechatConfigStore
}
func initStores(deps *Dependencies) *stores {
@@ -66,6 +72,8 @@ func initStores(deps *Dependencies) *stores {
ShopRole: postgres.NewShopRoleStore(deps.DB, deps.Redis),
RolePermission: postgres.NewRolePermissionStore(deps.DB, deps.Redis),
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
PersonalCustomerOpenID: postgres.NewPersonalCustomerOpenIDStore(deps.DB),
PersonalCustomerDevice: postgres.NewPersonalCustomerDeviceStore(deps.DB),
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
CommissionWithdrawalRequest: postgres.NewCommissionWithdrawalRequestStore(deps.DB, deps.Redis),
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
@@ -90,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),
@@ -101,9 +111,10 @@ func initStores(deps *Dependencies) *stores {
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
AgentRecharge: postgres.NewAgentRechargeStore(deps.DB, deps.Redis),
// 钱包系统
CardWallet: postgres.NewCardWalletStore(deps.DB, deps.Redis),
CardWalletTransaction: postgres.NewCardWalletTransactionStore(deps.DB, deps.Redis),
CardRecharge: postgres.NewCardRechargeStore(deps.DB, deps.Redis),
// 资产钱包系统
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
AssetRecharge: postgres.NewAssetRechargeStore(deps.DB, deps.Redis),
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
}
}

View File

@@ -5,7 +5,6 @@ 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"
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/gofiber/fiber/v2"
)
@@ -16,17 +15,22 @@ type Handlers struct {
Role *admin.RoleHandler
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
H5Auth *h5.AuthHandler
ShopCommission *admin.ShopCommissionHandler
CommissionWithdrawal *admin.CommissionWithdrawalHandler
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
Enterprise *admin.EnterpriseHandler
EnterpriseCard *admin.EnterpriseCardHandler
EnterpriseDevice *admin.EnterpriseDeviceHandler
EnterpriseDeviceH5 *h5.EnterpriseDeviceHandler
Authorization *admin.AuthorizationHandler
MyCommission *admin.MyCommissionHandler
IotCard *admin.IotCardHandler
@@ -39,13 +43,11 @@ type Handlers struct {
PackageSeries *admin.PackageSeriesHandler
Package *admin.PackageHandler
PackageUsage *admin.PackageUsageHandler
H5PackageUsage *h5.PackageUsageHandler
ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
ShopSeriesGrant *admin.ShopSeriesGrantHandler
AdminOrder *admin.OrderHandler
H5Order *h5.OrderHandler
H5Recharge *h5.RechargeHandler
AdminExchange *admin.ExchangeHandler
PaymentCallback *callback.PaymentHandler
PollingConfig *admin.PollingConfigHandler
PollingConcurrency *admin.PollingConcurrencyHandler
@@ -54,6 +56,10 @@ type Handlers struct {
PollingCleanup *admin.PollingCleanupHandler
PollingManualTrigger *admin.PollingManualTriggerHandler
Asset *admin.AssetHandler
AssetLifecycle *admin.AssetLifecycleHandler
AssetWallet *admin.AssetWalletHandler
WechatConfig *admin.WechatConfigHandler
AgentRecharge *admin.AgentRechargeHandler
}
// Middlewares 封装所有中间件
@@ -61,6 +67,5 @@ type Handlers struct {
type Middlewares struct {
PersonalAuth *middleware.PersonalAuthMiddleware
AdminAuth func(*fiber.Ctx) error
H5Auth func(*fiber.Ctx) error
// TODO: 新增 Middleware 在此添加字段
}

View File

@@ -85,7 +85,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.Order,
stores.OrderItem,
stores.AgentWallet,
stores.CardWallet,
stores.AssetWallet,
nil, // purchaseValidationService: 超时取消不需要
stores.ShopPackageAllocation,
stores.ShopSeriesAllocation,
@@ -94,6 +94,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
stores.PackageSeries,
stores.PackageUsage,
stores.Package,
nil, // wechatConfigService: 超时取消不需要
nil, // wechatPayment: 超时取消不需要
nil, // queueClient: 超时取消不触发分佣
deps.Logger,

View File

@@ -28,7 +28,7 @@ type workerStores struct {
DataCleanupLog *postgres.DataCleanupLogStore
AgentWallet *postgres.AgentWalletStore
AgentWalletTransaction *postgres.AgentWalletTransactionStore
CardWallet *postgres.CardWalletStore
AssetWallet *postgres.AssetWalletStore
}
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
@@ -55,7 +55,7 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
CardWallet: postgres.NewCardWalletStore(deps.DB, deps.Redis),
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
}
return &queue.WorkerStores{
@@ -81,6 +81,6 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
DataCleanupLog: stores.DataCleanupLog,
AgentWallet: stores.AgentWallet,
AgentWalletTransaction: stores.AgentWalletTransaction,
CardWallet: stores.CardWallet,
AssetWallet: stores.AssetWallet,
}
}

View File

@@ -0,0 +1,91 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// AgentRechargeHandler 代理预充值 Handler
type AgentRechargeHandler struct {
service *agentRechargeSvc.Service
}
// NewAgentRechargeHandler 创建代理预充值 Handler
func NewAgentRechargeHandler(service *agentRechargeSvc.Service) *AgentRechargeHandler {
return &AgentRechargeHandler{service: service}
}
// Create 创建代理充值订单
// POST /api/admin/agent-recharges
func (h *AgentRechargeHandler) Create(c *fiber.Ctx) error {
var req dto.CreateAgentRechargeRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// List 查询代理充值订单列表
// GET /api/admin/agent-recharges
func (h *AgentRechargeHandler) List(c *fiber.Ctx) error {
var req dto.AgentRechargeListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
list, total, err := h.service.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
}
// Get 查询代理充值订单详情
// GET /api/admin/agent-recharges/:id
func (h *AgentRechargeHandler) Get(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的充值记录ID")
}
result, err := h.service.GetByID(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// OfflinePay 确认线下充值
// POST /api/admin/agent-recharges/:id/offline-pay
func (h *AgentRechargeHandler) OfflinePay(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的充值记录ID")
}
var req dto.AgentOfflinePayRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.OfflinePay(c.UserContext(), uint(id), &req)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -0,0 +1,59 @@
package admin
import (
"context"
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// AssetLifecycleService 资产生命周期服务接口
type AssetLifecycleService interface {
// DeactivateIotCard 停用 IoT 卡
DeactivateIotCard(ctx context.Context, id uint) error
// DeactivateDevice 停用设备
DeactivateDevice(ctx context.Context, id uint) error
}
// AssetLifecycleHandler 资产生命周期处理器
type AssetLifecycleHandler struct {
service AssetLifecycleService
}
// NewAssetLifecycleHandler 创建资产生命周期处理器
func NewAssetLifecycleHandler(service AssetLifecycleService) *AssetLifecycleHandler {
return &AssetLifecycleHandler{service: service}
}
// DeactivateIotCard 手动停用 IoT 卡
// PATCH /api/admin/iot-cards/:id/deactivate
func (h *AssetLifecycleHandler) DeactivateIotCard(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的卡ID")
}
if err := h.service.DeactivateIotCard(c.UserContext(), uint(id)); err != nil {
return err
}
return response.Success(c, nil)
}
// DeactivateDevice 手动停用设备
// PATCH /api/admin/devices/:id/deactivate
func (h *AssetLifecycleHandler) DeactivateDevice(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
}
if err := h.service.DeactivateDevice(c.UserContext(), uint(id)); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -0,0 +1,88 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// AssetWalletHandler 资产钱包处理器
// 提供管理端资产(卡/设备)钱包概况和流水查询接口
type AssetWalletHandler struct {
service *assetWalletSvc.Service
}
// NewAssetWalletHandler 创建资产钱包处理器
func NewAssetWalletHandler(svc *assetWalletSvc.Service) *AssetWalletHandler {
return &AssetWalletHandler{service: svc}
}
// GetWallet 查询资产钱包概况
// GET /api/admin/assets/:asset_type/:id/wallet
func (h *AssetWalletHandler) GetWallet(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, "企业账号无权查看钱包信息")
}
assetType := c.Params("asset_type")
if assetType != "card" && assetType != "device" {
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
}
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
}
result, err := h.service.GetWallet(c.UserContext(), assetType, uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// ListTransactions 查询资产钱包流水列表
// GET /api/admin/assets/:asset_type/:id/wallet/transactions
func (h *AssetWalletHandler) ListTransactions(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, "企业账号无权查看钱包信息")
}
assetType := c.Params("asset_type")
if assetType != "card" && assetType != "device" {
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
}
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil || id == 0 {
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
}
var req dto.AssetWalletTransactionListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if req.PageSize > 100 {
return errors.New(errors.CodeInvalidParam, "每页数量不能超过100")
}
result, err := h.service.ListTransactions(c.UserContext(), assetType, uint(id), &req)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -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)
}

View File

@@ -128,3 +128,21 @@ func (h *PackageHandler) UpdateShelfStatus(c *fiber.Ctx) error {
return response.Success(c, nil)
}
func (h *PackageHandler) UpdateRetailPrice(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的套餐 ID")
}
var req dto.UpdateRetailPriceRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.service.UpdateRetailPrice(c.UserContext(), uint(id), req.RetailPrice); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -0,0 +1,153 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
wechatConfigService "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// WechatConfigHandler 微信参数配置 HTTP 处理器
type WechatConfigHandler struct {
service *wechatConfigService.Service
}
// NewWechatConfigHandler 创建微信参数配置处理器实例
func NewWechatConfigHandler(service *wechatConfigService.Service) *WechatConfigHandler {
return &WechatConfigHandler{service: service}
}
// Create 创建微信参数配置
// POST /api/admin/wechat-configs
func (h *WechatConfigHandler) Create(c *fiber.Ctx) error {
var req dto.CreateWechatConfigRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.Create(c.UserContext(), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// List 获取微信参数配置列表
// GET /api/admin/wechat-configs
func (h *WechatConfigHandler) List(c *fiber.Ctx) error {
var req dto.WechatConfigListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
configs, total, err := h.service.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, configs, total, req.Page, req.PageSize)
}
// Get 获取微信参数配置详情
// GET /api/admin/wechat-configs/:id
func (h *WechatConfigHandler) Get(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
}
result, err := h.service.Get(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// Update 更新微信参数配置
// PUT /api/admin/wechat-configs/:id
func (h *WechatConfigHandler) Update(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
}
var req dto.UpdateWechatConfigRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.Update(c.UserContext(), uint(id), &req)
if err != nil {
return err
}
return response.Success(c, result)
}
// Delete 删除微信参数配置
// DELETE /api/admin/wechat-configs/:id
func (h *WechatConfigHandler) Delete(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
}
if err := h.service.Delete(c.UserContext(), uint(id)); err != nil {
return err
}
return response.Success(c, nil)
}
// Activate 激活微信参数配置
// POST /api/admin/wechat-configs/:id/activate
func (h *WechatConfigHandler) Activate(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
}
result, err := h.service.Activate(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// Deactivate 停用微信参数配置
// POST /api/admin/wechat-configs/:id/deactivate
func (h *WechatConfigHandler) Deactivate(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
}
result, err := h.service.Deactivate(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, result)
}
// GetActive 获取当前生效的微信参数配置
// GET /api/admin/wechat-configs/active
func (h *WechatConfigHandler) GetActive(c *fiber.Ctx) error {
result, err := h.service.GetActiveConfigForAPI(c.UserContext())
if err != nil {
return err
}
if result == nil {
return response.SuccessWithMessage(c, nil, "当前无生效的支付配置,仅支持钱包支付")
}
return response.Success(c, result)
}

View File

@@ -0,0 +1,635 @@
package app
import (
"context"
"sort"
"strconv"
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
"gorm.io/gorm"
)
// ClientAssetHandler C 端资产信息处理器
// 提供 B1~B4 资产信息、可购套餐、套餐历史、手动刷新接口
type ClientAssetHandler struct {
assetService *asset.Service
personalDeviceStore *postgres.PersonalCustomerDeviceStore
assetWalletStore *postgres.AssetWalletStore
packageStore *postgres.PackageStore
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
db *gorm.DB
logger *zap.Logger
}
// NewClientAssetHandler 创建 C 端资产信息处理器
func NewClientAssetHandler(
assetService *asset.Service,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
assetWalletStore *postgres.AssetWalletStore,
packageStore *postgres.PackageStore,
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
db *gorm.DB,
logger *zap.Logger,
) *ClientAssetHandler {
return &ClientAssetHandler{
assetService: assetService,
personalDeviceStore: personalDeviceStore,
assetWalletStore: assetWalletStore,
packageStore: packageStore,
shopPackageAllocationStore: shopPackageAllocationStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
db: db,
logger: logger,
}
}
type resolvedAssetContext struct {
CustomerID uint
Identifier string
Asset *dto.AssetResolveResponse
Generation int
WalletBalance int64
SkipPermissionCtx context.Context
IsAgentChannel bool
SellerShopID uint
MainPackageActived bool
}
// resolveAssetFromIdentifier 统一执行资产解析与归属校验
// 处理流程:客户鉴权 -> 标识符解析 -> 资产解析 -> 归属校验 -> 世代与钱包信息补齐
func (h *ClientAssetHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedAssetContext, error) {
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
identifier = strings.TrimSpace(identifier)
if identifier == "" {
identifier = strings.TrimSpace(c.Query("identifier"))
}
if identifier == "" {
var req dto.AssetRefreshRequest
if err := c.BodyParser(&req); err == nil {
identifier = strings.TrimSpace(req.Identifier)
}
}
if identifier == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{})
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier)
if err != nil {
return nil, err
}
owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo)
if ownErr != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
}
if !owned {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
}
generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
if genErr != nil {
return nil, genErr
}
walletBalance, walletErr := h.getAssetWalletBalance(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
if walletErr != nil {
return nil, walletErr
}
ctxInfo := &resolvedAssetContext{
CustomerID: customerID,
Identifier: identifier,
Asset: assetInfo,
Generation: generation,
WalletBalance: walletBalance,
SkipPermissionCtx: skipPermissionCtx,
}
if assetInfo.ShopID != nil && *assetInfo.ShopID > 0 {
ctxInfo.IsAgentChannel = true
ctxInfo.SellerShopID = *assetInfo.ShopID
}
return ctxInfo, nil
}
// GetAssetInfo B1 资产信息
// GET /api/c/v1/asset/info
func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
var req dto.AssetInfoRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
resp := &dto.AssetInfoResponse{
AssetType: resolved.Asset.AssetType,
AssetID: resolved.Asset.AssetID,
Identifier: resolved.Identifier,
VirtualNo: resolved.Asset.VirtualNo,
Status: resolved.Asset.Status,
RealNameStatus: resolved.Asset.RealNameStatus,
CarrierName: resolved.Asset.CarrierName,
Generation: strconv.Itoa(resolved.Generation),
WalletBalance: resolved.WalletBalance,
ActivatedAt: resolved.Asset.ActivatedAt,
CurrentPackage: resolved.Asset.CurrentPackage,
PackageTotalMB: resolved.Asset.PackageTotalMB,
PackageUsedMB: resolved.Asset.PackageUsedMB,
PackageRemainMB: resolved.Asset.PackageRemainMB,
DeviceName: resolved.Asset.DeviceName,
IMEI: resolved.Asset.IMEI,
SN: resolved.Asset.SN,
DeviceModel: resolved.Asset.DeviceModel,
DeviceType: resolved.Asset.DeviceType,
Manufacturer: resolved.Asset.Manufacturer,
MaxSimSlots: resolved.Asset.MaxSimSlots,
BoundCardCount: resolved.Asset.BoundCardCount,
Cards: resolved.Asset.Cards,
DeviceProtectStatus: resolved.Asset.DeviceProtectStatus,
ICCID: resolved.Asset.ICCID,
MSISDN: resolved.Asset.MSISDN,
CarrierID: resolved.Asset.CarrierID,
CarrierType: resolved.Asset.CarrierType,
NetworkStatus: resolved.Asset.NetworkStatus,
ActivationStatus: resolved.Asset.ActivationStatus,
CardCategory: resolved.Asset.CardCategory,
BoundDeviceID: resolved.Asset.BoundDeviceID,
BoundDeviceNo: resolved.Asset.BoundDeviceNo,
BoundDeviceName: resolved.Asset.BoundDeviceName,
}
// TODO: Gateway 同步接口对接后,替换为真实设备实时数据
if resp.AssetType == "device" {
resp.DeviceRealtime = buildMockDeviceRealtime()
}
return response.Success(c, resp)
}
// GetAvailablePackages B2 资产可购套餐列表
// GET /api/c/v1/asset/packages
func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {
var req dto.AssetPackageListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
if resolved.Asset.SeriesID == nil || *resolved.Asset.SeriesID == 0 {
return errors.New(errors.CodeNoAvailablePackage, "当前无可购买套餐")
}
allUsages, err := h.assetService.GetPackages(resolved.SkipPermissionCtx, resolved.Asset.AssetType, resolved.Asset.AssetID)
if err != nil {
return err
}
resolved.MainPackageActived = hasActiveMainPackage(allUsages)
listCtx := resolved.SkipPermissionCtx
if resolved.IsAgentChannel {
listCtx = context.WithValue(listCtx, constants.ContextKeyUserType, constants.UserTypeAgent)
listCtx = context.WithValue(listCtx, constants.ContextKeyShopID, resolved.SellerShopID)
}
pkgs, _, err := h.packageStore.List(listCtx, &store.QueryOptions{
Page: 1,
PageSize: constants.MaxPageSize,
OrderBy: "id DESC",
}, map[string]any{
"series_id": *resolved.Asset.SeriesID,
"status": constants.StatusEnabled,
})
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询可购套餐失败")
}
allocationMap := make(map[uint]*model.ShopPackageAllocation)
if resolved.IsAgentChannel {
packageIDs := collectPackageIDs(pkgs)
allocations, allocErr := h.shopPackageAllocationStore.GetByShopAndPackages(
resolved.SkipPermissionCtx,
resolved.SellerShopID,
packageIDs,
)
if allocErr != nil {
return errors.Wrap(errors.CodeDatabaseError, allocErr, "查询套餐分配记录失败")
}
for _, allocation := range allocations {
allocationMap[allocation.PackageID] = allocation
}
}
items := make([]dto.ClientPackageItem, 0, len(pkgs))
for _, pkg := range pkgs {
item, ok := buildClientPackageItem(pkg, resolved, allocationMap)
if !ok {
continue
}
items = append(items, item)
}
if len(items) == 0 {
return errors.New(errors.CodeNoAvailablePackage, "当前无可购买套餐")
}
sort.Slice(items, func(i, j int) bool {
return items[i].RetailPrice < items[j].RetailPrice
})
return response.Success(c, &dto.AssetPackageListResponse{Packages: items})
}
// GetPackageHistory B3 资产套餐历史
// GET /api/c/v1/asset/package-history
func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
var req dto.AssetPackageHistoryRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if req.Page < 1 {
req.Page = 1
}
if req.PageSize < 1 {
req.PageSize = constants.DefaultPageSize
}
if req.PageSize > constants.MaxPageSize {
req.PageSize = constants.MaxPageSize
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
query := h.db.WithContext(resolved.SkipPermissionCtx).Model(&model.PackageUsage{}).
Where("generation = ?", resolved.Generation)
if resolved.Asset.AssetType == "card" {
query = query.Where("iot_card_id = ?", resolved.Asset.AssetID)
} else {
query = query.Where("device_id = ?", resolved.Asset.AssetID)
}
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
if req.PackageType != nil {
query = query.Where("package_id IN (?)",
h.db.Model(&model.Package{}).Select("id").Where("package_type = ?", *req.PackageType))
}
var total int64
if err := query.Count(&total).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史总数失败")
}
var usages []*model.PackageUsage
offset := (req.Page - 1) * req.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&usages).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史失败")
}
packageMap, err := h.loadPackageMap(resolved.SkipPermissionCtx, usages)
if err != nil {
return err
}
list := make([]dto.AssetPackageResponse, 0, len(usages))
for _, usage := range usages {
pkg := packageMap[usage.PackageID]
ratio := 1.0
pkgName := ""
pkgType := ""
if pkg != nil {
ratio = safeVirtualRatio(pkg.VirtualRatio)
pkgName = pkg.PackageName
pkgType = pkg.PackageType
}
list = append(list, dto.AssetPackageResponse{
PackageUsageID: usage.ID,
PackageID: usage.PackageID,
PackageName: pkgName,
PackageType: pkgType,
UsageType: usage.UsageType,
Status: usage.Status,
StatusName: packageStatusName(usage.Status),
DataLimitMB: usage.DataLimitMB,
VirtualLimitMB: int64(float64(usage.DataLimitMB) / ratio),
DataUsageMB: usage.DataUsageMB,
VirtualUsedMB: float64(usage.DataUsageMB) / ratio,
VirtualRemainMB: float64(usage.DataLimitMB-usage.DataUsageMB) / ratio,
VirtualRatio: ratio,
ActivatedAt: nonZeroTimePtr(usage.ActivatedAt),
ExpiresAt: nonZeroTimePtr(usage.ExpiresAt),
MasterUsageID: usage.MasterUsageID,
Priority: usage.Priority,
CreatedAt: usage.CreatedAt,
})
}
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
}
// RefreshAsset B4 资产刷新
// POST /api/c/v1/asset/refresh
func (h *ClientAssetHandler) RefreshAsset(c *fiber.Ctx) error {
var req dto.AssetRefreshRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
if _, err := h.assetService.Refresh(
resolved.SkipPermissionCtx,
resolved.Asset.AssetType,
resolved.Asset.AssetID,
); err != nil {
return err
}
resp := &dto.AssetRefreshResponse{
RefreshType: resolved.Asset.AssetType,
Accepted: true,
CooldownSeconds: 0,
}
if resolved.Asset.AssetType == constants.ResourceTypeDevice {
resp.CooldownSeconds = int(constants.DeviceRefreshCooldownDuration / time.Second)
}
return response.Success(c, resp)
}
func (h *ClientAssetHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) {
records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID)
if err != nil {
return false, err
}
for _, record := range records {
if record == nil {
continue
}
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
return true, nil
}
}
return false, nil
}
func (h *ClientAssetHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
switch assetType {
case "card":
card, err := h.iotCardStore.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, errors.New(errors.CodeAssetNotFound)
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
}
return card.Generation, nil
case "device":
device, err := h.deviceStore.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, errors.New(errors.CodeAssetNotFound)
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
}
return device.Generation, nil
default:
return 0, errors.New(errors.CodeInvalidParam)
}
}
func (h *ClientAssetHandler) getAssetWalletBalance(ctx context.Context, assetType string, assetID uint) (int64, error) {
resourceType := constants.AssetWalletResourceTypeIotCard
if assetType == constants.ResourceTypeDevice {
resourceType = constants.AssetWalletResourceTypeDevice
}
wallet, err := h.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, nil
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败")
}
return wallet.Balance, nil
}
func (h *ClientAssetHandler) loadPackageMap(ctx context.Context, usages []*model.PackageUsage) (map[uint]*model.Package, error) {
ids := make([]uint, 0, len(usages))
seen := make(map[uint]struct{}, len(usages))
for _, usage := range usages {
if usage == nil {
continue
}
if _, ok := seen[usage.PackageID]; ok {
continue
}
seen[usage.PackageID] = struct{}{}
ids = append(ids, usage.PackageID)
}
packages, err := h.packageStore.GetByIDsUnscoped(ctx, ids)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
}
result := make(map[uint]*model.Package, len(packages))
for _, pkg := range packages {
result[pkg.ID] = pkg
}
return result, nil
}
func collectPackageIDs(pkgs []*model.Package) []uint {
ids := make([]uint, 0, len(pkgs))
for _, pkg := range pkgs {
if pkg == nil {
continue
}
ids = append(ids, pkg.ID)
}
return ids
}
func hasActiveMainPackage(usages []*dto.AssetPackageResponse) bool {
for _, usage := range usages {
if usage == nil {
continue
}
if usage.PackageType == constants.PackageTypeFormal && usage.Status == constants.PackageUsageStatusActive {
return true
}
}
return false
}
func buildClientPackageItem(
pkg *model.Package,
resolved *resolvedAssetContext,
allocationMap map[uint]*model.ShopPackageAllocation,
) (dto.ClientPackageItem, bool) {
if pkg == nil || pkg.Status != constants.StatusEnabled {
return dto.ClientPackageItem{}, false
}
isAddon := pkg.PackageType == constants.PackageTypeAddon
if isAddon && !resolved.MainPackageActived {
return dto.ClientPackageItem{}, false
}
retailPrice := pkg.SuggestedRetailPrice
costPrice := pkg.CostPrice
if resolved.IsAgentChannel {
allocation, ok := allocationMap[pkg.ID]
if !ok || allocation == nil {
return dto.ClientPackageItem{}, false
}
if allocation.ShelfStatus != constants.ShelfStatusOn || allocation.Status != constants.StatusEnabled {
return dto.ClientPackageItem{}, false
}
retailPrice = allocation.RetailPrice
costPrice = allocation.CostPrice
} else if pkg.ShelfStatus != constants.ShelfStatusOn {
return dto.ClientPackageItem{}, false
}
if retailPrice < costPrice {
return dto.ClientPackageItem{}, false
}
validityDays := pkg.DurationDays
if validityDays <= 0 && pkg.DurationMonths > 0 {
validityDays = pkg.DurationMonths * 30
}
dataAllowance := pkg.VirtualDataMB
if dataAllowance <= 0 {
dataAllowance = pkg.RealDataMB
}
return dto.ClientPackageItem{
PackageID: pkg.ID,
PackageName: pkg.PackageName,
PackageType: pkg.PackageType,
RetailPrice: retailPrice,
CostPrice: costPrice,
ValidityDays: validityDays,
IsAddon: isAddon,
DataAllowance: dataAllowance,
DataUnit: "MB",
Description: pkg.PackageCode,
}, true
}
func nonZeroTimePtr(t time.Time) *time.Time {
if t.IsZero() {
return nil
}
return &t
}
func safeVirtualRatio(ratio float64) float64 {
if ratio <= 0 {
return 1.0
}
return ratio
}
func packageStatusName(status int) string {
switch status {
case constants.PackageUsageStatusPending:
return "待生效"
case constants.PackageUsageStatusActive:
return "生效中"
case constants.PackageUsageStatusDepleted:
return "已用完"
case constants.PackageUsageStatusExpired:
return "已过期"
case constants.PackageUsageStatusInvalidated:
return "已失效"
default:
return "未知"
}
}
// buildMockDeviceRealtime 构建设备实时状态假数据
// TODO: Gateway 同步接口对接后移除此函数,改为调用 Gateway 接口获取真实数据
func buildMockDeviceRealtime() *dto.DeviceRealtimeInfo {
onlineStatus := int64(1)
batteryLevel := int64(85)
deviceStatus := int64(1)
runTime := "3600"
connectTime := "3500"
rsrp := int64(-80)
rsrq := int64(-10)
rssi := "-65"
sinr := int64(15)
ssid := "JunHong-WiFi"
wifiEnabled := true
wifiPassword := "12345678"
ipAddress := "192.168.1.1"
lanIP := "192.168.1.1"
dailyUsage := "0"
maxClients := int64(32)
switchMode := 0
return &dto.DeviceRealtimeInfo{
OnlineStatus: &onlineStatus,
BatteryLevel: &batteryLevel,
Status: &deviceStatus,
RunTime: &runTime,
ConnectTime: &connectTime,
Rsrp: &rsrp,
Rsrq: &rsrq,
Rssi: &rssi,
Sinr: &sinr,
SSID: &ssid,
WifiEnabled: &wifiEnabled,
WifiPassword: &wifiPassword,
IPAddress: &ipAddress,
LANIP: &lanIP,
DailyUsage: &dailyUsage,
MaxClients: &maxClients,
SwitchMode: &switchMode,
}
}

View File

@@ -0,0 +1,165 @@
package app
import (
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
var clientAuthValidator = validator.New()
// ClientAuthHandler C 端认证处理器
type ClientAuthHandler struct {
service *clientAuthSvc.Service
logger *zap.Logger
}
// NewClientAuthHandler 创建 C 端认证处理器
func NewClientAuthHandler(service *clientAuthSvc.Service, logger *zap.Logger) *ClientAuthHandler {
return &ClientAuthHandler{service: service, logger: logger}
}
// VerifyAsset A1 资产验证
// POST /api/c/v1/auth/verify-asset
func (h *ClientAuthHandler) VerifyAsset(c *fiber.Ctx) error {
var req dto.VerifyAssetRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientAuthValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("资产验证参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
resp, err := h.service.VerifyAsset(c.UserContext(), &req, c.IP())
if err != nil {
return err
}
return response.Success(c, resp)
}
// WechatLogin A2 公众号登录
// POST /api/c/v1/auth/wechat-login
func (h *ClientAuthHandler) WechatLogin(c *fiber.Ctx) error {
var req dto.WechatLoginRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientAuthValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("公众号登录参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
resp, err := h.service.WechatLogin(c.UserContext(), &req, c.IP())
if err != nil {
return err
}
return response.Success(c, resp)
}
// MiniappLogin A3 小程序登录
// POST /api/c/v1/auth/miniapp-login
func (h *ClientAuthHandler) MiniappLogin(c *fiber.Ctx) error {
var req dto.MiniappLoginRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientAuthValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("小程序登录参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
resp, err := h.service.MiniappLogin(c.UserContext(), &req, c.IP())
if err != nil {
return err
}
return response.Success(c, resp)
}
// SendCode A4 发送验证码
// POST /api/c/v1/auth/send-code
func (h *ClientAuthHandler) SendCode(c *fiber.Ctx) error {
var req dto.ClientSendCodeRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientAuthValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("发送验证码参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
resp, err := h.service.SendCode(c.UserContext(), &req, c.IP())
if err != nil {
return err
}
return response.Success(c, resp)
}
// BindPhone A5 绑定手机号
// POST /api/c/v1/auth/bind-phone
func (h *ClientAuthHandler) BindPhone(c *fiber.Ctx) error {
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
var req dto.BindPhoneRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientAuthValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("绑定手机号参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
resp, err := h.service.BindPhone(c.UserContext(), customerID, &req)
if err != nil {
return err
}
return response.Success(c, resp)
}
// ChangePhone A6 更换手机号
// POST /api/c/v1/auth/change-phone
func (h *ClientAuthHandler) ChangePhone(c *fiber.Ctx) error {
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
var req dto.ChangePhoneRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientAuthValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("更换手机号参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
resp, err := h.service.ChangePhone(c.UserContext(), customerID, &req)
if err != nil {
return err
}
return response.Success(c, resp)
}
// Logout A7 退出登录
// POST /api/c/v1/auth/logout
func (h *ClientAuthHandler) Logout(c *fiber.Ctx) error {
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
resp, err := h.service.Logout(c.UserContext(), customerID)
if err != nil {
return err
}
return response.Success(c, resp)
}

View File

@@ -0,0 +1,317 @@
package app
import (
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
var clientDeviceValidator = validator.New()
// deviceAssetInfo validateDeviceAsset 解析后的设备资产信息
type deviceAssetInfo struct {
DeviceID uint // 设备数据库 ID
IMEI string // 设备 IMEI用于 Gateway API 调用)
VirtualNo string // 设备虚拟号(用于所有权校验)
}
// ClientDeviceHandler C 端设备能力处理器
// 提供设备卡列表、重启、恢复出厂、WiFi 配置、切卡等操作
type ClientDeviceHandler struct {
assetService *assetSvc.Service
personalDeviceStore *postgres.PersonalCustomerDeviceStore
deviceStore *postgres.DeviceStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
iotCardStore *postgres.IotCardStore
gatewayClient *gateway.Client
logger *zap.Logger
}
// NewClientDeviceHandler 创建 C 端设备能力处理器
func NewClientDeviceHandler(
assetService *assetSvc.Service,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
deviceStore *postgres.DeviceStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
iotCardStore *postgres.IotCardStore,
gatewayClient *gateway.Client,
logger *zap.Logger,
) *ClientDeviceHandler {
return &ClientDeviceHandler{
assetService: assetService,
personalDeviceStore: personalDeviceStore,
deviceStore: deviceStore,
deviceSimBindingStore: deviceSimBindingStore,
iotCardStore: iotCardStore,
gatewayClient: gatewayClient,
logger: logger,
}
}
// validateDeviceAsset 校验设备资产的所有权和有效性
// 流程:认证 → 资产解析 → 类型校验(仅设备)→ 所有权校验 → IMEI 校验
func (h *ClientDeviceHandler) validateDeviceAsset(c *fiber.Ctx, identifier string) (*deviceAssetInfo, error) {
// 获取当前登录的个人客户 ID
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
ctx := c.UserContext()
// 通过标识符解析资产
asset, err := h.assetService.Resolve(ctx, identifier)
if err != nil {
return nil, err
}
// 仅设备资产支持设备能力操作
if asset.AssetType != "device" {
return nil, errors.New(errors.CodeInvalidParam, "仅设备资产支持该操作")
}
// 校验个人客户对该设备的所有权
owns, err := h.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, asset.VirtualNo)
if err != nil {
h.logger.Error("校验设备所有权失败",
zap.Uint("customer_id", customerID),
zap.String("virtual_no", asset.VirtualNo),
zap.Error(err))
return nil, errors.New(errors.CodeInternalError)
}
if !owns {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
// 校验设备 IMEI 是否存在Gateway API 调用必需)
if asset.IMEI == "" {
return nil, errors.New(errors.CodeInvalidParam, "设备IMEI缺失")
}
return &deviceAssetInfo{
DeviceID: asset.AssetID,
IMEI: asset.IMEI,
VirtualNo: asset.VirtualNo,
}, nil
}
// GetDeviceCards F1 获取设备卡列表
// GET /api/c/v1/device/cards
func (h *ClientDeviceHandler) GetDeviceCards(c *fiber.Ctx) error {
var req dto.DeviceCardListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientDeviceValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("设备卡列表参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
info, err := h.validateDeviceAsset(c, req.Identifier)
if err != nil {
return err
}
ctx := c.UserContext()
// 查询设备绑定的所有 SIM 卡
bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, info.DeviceID)
if err != nil {
h.logger.Error("查询设备SIM绑定失败",
zap.Uint("device_id", info.DeviceID),
zap.Error(err))
return errors.New(errors.CodeInternalError)
}
// 无绑定卡时返回空列表
if len(bindings) == 0 {
return response.Success(c, &dto.DeviceCardListResponse{Cards: []dto.DeviceCardItem{}})
}
// 收集卡 ID 并记录插槽位置映射
cardIDs := make([]uint, 0, len(bindings))
slotMap := make(map[uint]int, len(bindings))
for _, b := range bindings {
cardIDs = append(cardIDs, b.IotCardID)
slotMap[b.IotCardID] = b.SlotPosition
}
// 批量查询卡详情
cards, err := h.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
h.logger.Error("批量查询IoT卡失败",
zap.Uints("card_ids", cardIDs),
zap.Error(err))
return errors.New(errors.CodeInternalError)
}
// 组装响应slot_position == 1 视为当前激活卡
items := make([]dto.DeviceCardItem, 0, len(cards))
for _, card := range cards {
slot := slotMap[card.ID]
items = append(items, dto.DeviceCardItem{
CardID: card.ID,
ICCID: card.ICCID,
MSISDN: card.MSISDN,
CarrierName: card.CarrierName,
NetworkStatus: networkStatusText(card.NetworkStatus),
RealNameStatus: card.RealNameStatus,
SlotPosition: slot,
IsActive: slot == 1,
})
}
return response.Success(c, &dto.DeviceCardListResponse{Cards: items})
}
// RebootDevice F2 设备重启
// POST /api/c/v1/device/reboot
func (h *ClientDeviceHandler) RebootDevice(c *fiber.Ctx) error {
var req dto.DeviceRebootRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientDeviceValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("设备重启参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
info, err := h.validateDeviceAsset(c, req.Identifier)
if err != nil {
return err
}
// 调用 Gateway 重启设备
if err := h.gatewayClient.RebootDevice(c.UserContext(), &gateway.DeviceOperationReq{
DeviceID: info.IMEI,
}); err != nil {
h.logger.Error("Gateway重启设备失败",
zap.String("imei", info.IMEI),
zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "设备重启失败")
}
return response.Success(c, &dto.DeviceOperationResponse{Accepted: true})
}
// FactoryResetDevice F3 恢复出厂设置
// POST /api/c/v1/device/factory-reset
func (h *ClientDeviceHandler) FactoryResetDevice(c *fiber.Ctx) error {
var req dto.DeviceFactoryResetRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientDeviceValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("恢复出厂设置参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
info, err := h.validateDeviceAsset(c, req.Identifier)
if err != nil {
return err
}
// 调用 Gateway 恢复出厂设置
if err := h.gatewayClient.ResetDevice(c.UserContext(), &gateway.DeviceOperationReq{
DeviceID: info.IMEI,
}); err != nil {
h.logger.Error("Gateway恢复出厂设置失败",
zap.String("imei", info.IMEI),
zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "恢复出厂设置失败")
}
return response.Success(c, &dto.DeviceOperationResponse{Accepted: true})
}
// SetWiFi F4 设备WiFi配置
// POST /api/c/v1/device/wifi
// 注意WiFiReq.CardNo 字段名具有误导性,实际传入的是设备 IMEI而非卡号
func (h *ClientDeviceHandler) SetWiFi(c *fiber.Ctx) error {
var req dto.DeviceWifiRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientDeviceValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("WiFi配置参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
info, err := h.validateDeviceAsset(c, req.Identifier)
if err != nil {
return err
}
// 调用 Gateway 配置 WiFi
// CardNo 字段虽名为"卡号",但 Gateway 实际要求传入设备 IMEI
if err := h.gatewayClient.SetWiFi(c.UserContext(), &gateway.WiFiReq{
CardNo: info.IMEI,
DeviceID: info.IMEI,
SSID: req.SSID,
Password: req.Password,
Enabled: req.Enabled,
}); err != nil {
h.logger.Error("Gateway配置WiFi失败",
zap.String("imei", info.IMEI),
zap.String("ssid", req.SSID),
zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "WiFi配置失败")
}
return response.Success(c, &dto.DeviceOperationResponse{Accepted: true})
}
// SwitchCard F5 设备切卡
// POST /api/c/v1/device/switch-card
func (h *ClientDeviceHandler) SwitchCard(c *fiber.Ctx) error {
var req dto.DeviceSwitchCardRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientDeviceValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("设备切卡参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
info, err := h.validateDeviceAsset(c, req.Identifier)
if err != nil {
return err
}
// 调用 Gateway 切卡CardNo 传设备 IMEI
if err := h.gatewayClient.SwitchCard(c.UserContext(), &gateway.SwitchCardReq{
CardNo: info.IMEI,
ICCID: req.TargetICCID,
}); err != nil {
h.logger.Error("Gateway切卡失败",
zap.String("imei", info.IMEI),
zap.String("target_iccid", req.TargetICCID),
zap.Error(err))
return errors.Wrap(errors.CodeGatewayError, err, "设备切卡失败")
}
return response.Success(c, &dto.DeviceSwitchCardResponse{
Accepted: true,
TargetICCID: req.TargetICCID,
})
}
// networkStatusText 将网络状态码转为文本描述
func networkStatusText(status int) string {
switch status {
case 0:
return "停机"
case 1:
return "开机"
default:
return "未知"
}
}

View File

@@ -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)
}

View File

@@ -0,0 +1,415 @@
package app
import (
"context"
"strconv"
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
clientorder "github.com/break/junhong_cmp_fiber/internal/service/client_order"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
"gorm.io/gorm"
)
// ClientOrderHandler C 端订单处理器
// 提供 D1~D3 下单、列表、详情接口。
type ClientOrderHandler struct {
clientOrderService *clientorder.Service
assetService *asset.Service
orderStore *postgres.OrderStore
personalDeviceStore *postgres.PersonalCustomerDeviceStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
logger *zap.Logger
db *gorm.DB
}
// NewClientOrderHandler 创建 C 端订单处理器。
func NewClientOrderHandler(
clientOrderService *clientorder.Service,
assetService *asset.Service,
orderStore *postgres.OrderStore,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
logger *zap.Logger,
db *gorm.DB,
) *ClientOrderHandler {
return &ClientOrderHandler{
clientOrderService: clientOrderService,
assetService: assetService,
orderStore: orderStore,
personalDeviceStore: personalDeviceStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
logger: logger,
db: db,
}
}
// CreateOrder D1 创建订单。
// POST /api/c/v1/orders/create
func (h *ClientOrderHandler) CreateOrder(c *fiber.Ctx) error {
var req dto.ClientCreateOrderRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
resp, err := h.clientOrderService.CreateOrder(c.UserContext(), customerID, &req)
if err != nil {
return err
}
return response.Success(c, resp)
}
// ListOrders D2 订单列表。
// GET /api/c/v1/orders
func (h *ClientOrderHandler) ListOrders(c *fiber.Ctx) error {
var req dto.ClientOrderListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if req.Page < 1 {
req.Page = 1
}
if req.PageSize < 1 {
req.PageSize = constants.DefaultPageSize
}
if req.PageSize > constants.MaxPageSize {
req.PageSize = constants.MaxPageSize
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
query := h.db.WithContext(resolved.SkipPermissionCtx).
Model(&model.Order{}).
Where("generation = ?", resolved.Generation)
if resolved.Asset.AssetType == constants.ResourceTypeDevice {
query = query.Where("order_type = ? AND device_id = ?", model.OrderTypeDevice, resolved.Asset.AssetID)
} else {
query = query.Where("order_type = ? AND iot_card_id = ?", model.OrderTypeSingleCard, resolved.Asset.AssetID)
}
if req.PaymentStatus != nil {
paymentStatus, ok := clientPaymentStatusToOrderStatus(*req.PaymentStatus)
if !ok {
return errors.New(errors.CodeInvalidParam)
}
query = query.Where("payment_status = ?", paymentStatus)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单总数失败")
}
var orders []*model.Order
offset := (req.Page - 1) * req.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&orders).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单列表失败")
}
orderIDs := make([]uint, 0, len(orders))
for _, order := range orders {
if order == nil {
continue
}
orderIDs = append(orderIDs, order.ID)
}
itemMap, err := h.loadOrderItemMap(resolved.SkipPermissionCtx, orderIDs)
if err != nil {
return err
}
list := make([]dto.ClientOrderListItem, 0, len(orders))
for _, order := range orders {
if order == nil {
continue
}
packageNames := make([]string, 0, len(itemMap[order.ID]))
for _, item := range itemMap[order.ID] {
if item == nil || item.PackageName == "" {
continue
}
packageNames = append(packageNames, item.PackageName)
}
list = append(list, dto.ClientOrderListItem{
OrderID: order.ID,
OrderNo: order.OrderNo,
TotalAmount: order.TotalAmount,
PaymentStatus: orderStatusToClientPaymentStatus(order.PaymentStatus),
CreatedAt: formatClientOrderTime(order.CreatedAt),
PackageNames: packageNames,
})
}
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
}
// GetOrderDetail D3 订单详情。
// GET /api/c/v1/orders/:id
func (h *ClientOrderHandler) GetOrderDetail(c *fiber.Ctx) error {
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
orderID, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil || orderID == 0 {
return errors.New(errors.CodeInvalidParam)
}
order, items, err := h.orderStore.GetByIDWithItems(c.UserContext(), uint(orderID))
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "订单不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单详情失败")
}
virtualNo, err := h.getOrderVirtualNo(c.UserContext(), order)
if err != nil {
return err
}
owned, ownErr := h.isCustomerOwnAsset(c.UserContext(), customerID, virtualNo)
if ownErr != nil {
return errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
}
if !owned {
return errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
}
packages := make([]dto.ClientOrderPackageItem, 0, len(items))
for _, item := range items {
if item == nil {
continue
}
packages = append(packages, dto.ClientOrderPackageItem{
PackageID: item.PackageID,
PackageName: item.PackageName,
Price: item.UnitPrice,
Quantity: item.Quantity,
})
}
resp := &dto.ClientOrderDetailResponse{
OrderID: order.ID,
OrderNo: order.OrderNo,
TotalAmount: order.TotalAmount,
PaymentStatus: orderStatusToClientPaymentStatus(order.PaymentStatus),
PaymentMethod: order.PaymentMethod,
CreatedAt: formatClientOrderTime(order.CreatedAt),
PaidAt: formatClientOrderTimePtr(order.PaidAt),
CompletedAt: nil,
Packages: packages,
}
return response.Success(c, resp)
}
func (h *ClientOrderHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedAssetContext, error) {
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
identifier = strings.TrimSpace(identifier)
if identifier == "" {
identifier = strings.TrimSpace(c.Query("identifier"))
}
if identifier == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{})
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier)
if err != nil {
return nil, err
}
owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo)
if ownErr != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
}
if !owned {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
}
generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
if genErr != nil {
return nil, genErr
}
return &resolvedAssetContext{
CustomerID: customerID,
Identifier: identifier,
Asset: assetInfo,
Generation: generation,
SkipPermissionCtx: skipPermissionCtx,
}, nil
}
func (h *ClientOrderHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) {
records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID)
if err != nil {
return false, err
}
for _, record := range records {
if record == nil {
continue
}
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
return true, nil
}
}
return false, nil
}
func (h *ClientOrderHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
switch assetType {
case "card":
card, err := h.iotCardStore.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, errors.New(errors.CodeAssetNotFound)
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
}
return card.Generation, nil
case constants.ResourceTypeDevice:
device, err := h.deviceStore.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, errors.New(errors.CodeAssetNotFound)
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
}
return device.Generation, nil
default:
return 0, errors.New(errors.CodeInvalidParam)
}
}
func (h *ClientOrderHandler) loadOrderItemMap(ctx context.Context, orderIDs []uint) (map[uint][]*model.OrderItem, error) {
result := make(map[uint][]*model.OrderItem)
if len(orderIDs) == 0 {
return result, nil
}
var items []*model.OrderItem
if err := h.db.WithContext(ctx).Where("order_id IN ?", orderIDs).Order("id ASC").Find(&items).Error; err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单明细失败")
}
for _, item := range items {
if item == nil {
continue
}
result[item.OrderID] = append(result[item.OrderID], item)
}
return result, nil
}
func (h *ClientOrderHandler) getOrderVirtualNo(ctx context.Context, order *model.Order) (string, error) {
if order == nil {
return "", errors.New(errors.CodeNotFound, "订单不存在")
}
switch order.OrderType {
case model.OrderTypeSingleCard:
if order.IotCardID == nil || *order.IotCardID == 0 {
return "", errors.New(errors.CodeInvalidParam)
}
card, err := h.iotCardStore.GetByID(ctx, *order.IotCardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return "", errors.New(errors.CodeAssetNotFound)
}
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
}
return card.VirtualNo, nil
case model.OrderTypeDevice:
if order.DeviceID == nil || *order.DeviceID == 0 {
return "", errors.New(errors.CodeInvalidParam)
}
device, err := h.deviceStore.GetByID(ctx, *order.DeviceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return "", errors.New(errors.CodeAssetNotFound)
}
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
}
return device.VirtualNo, nil
default:
return "", errors.New(errors.CodeInvalidParam)
}
}
func orderStatusToClientPaymentStatus(status int) int {
switch status {
case model.PaymentStatusPending:
return 0
case model.PaymentStatusPaid:
return 1
case model.PaymentStatusCancelled:
return 2
default:
return status
}
}
func clientPaymentStatusToOrderStatus(status int) (int, bool) {
switch status {
case 0:
return model.PaymentStatusPending, true
case 1:
return model.PaymentStatusPaid, true
case 2:
return model.PaymentStatusCancelled, true
default:
return 0, false
}
}
func formatClientOrderTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
func formatClientOrderTimePtr(t *time.Time) *string {
if t == nil || t.IsZero() {
return nil
}
formatted := formatClientOrderTime(*t)
return &formatted
}

View File

@@ -0,0 +1,249 @@
package app
import (
"strings"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
)
var clientRealnameValidator = validator.New()
// ClientRealnameHandler C 端实名认证处理器
type ClientRealnameHandler struct {
assetService *assetService.Service
personalDeviceStore *postgres.PersonalCustomerDeviceStore
iotCardStore *postgres.IotCardStore
deviceSimBindingStore *postgres.DeviceSimBindingStore
carrierStore *postgres.CarrierStore
gatewayClient *gateway.Client
logger *zap.Logger
}
// NewClientRealnameHandler 创建 C 端实名认证处理器
func NewClientRealnameHandler(
assetSvc *assetService.Service,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
iotCardStore *postgres.IotCardStore,
deviceSimBindingStore *postgres.DeviceSimBindingStore,
carrierStore *postgres.CarrierStore,
gatewayClient *gateway.Client,
logger *zap.Logger,
) *ClientRealnameHandler {
return &ClientRealnameHandler{
assetService: assetSvc,
personalDeviceStore: personalDeviceStore,
iotCardStore: iotCardStore,
deviceSimBindingStore: deviceSimBindingStore,
carrierStore: carrierStore,
gatewayClient: gatewayClient,
logger: logger,
}
}
// GetRealnameLink E1 获取实名认证链接
// GET /api/c/v1/realname/link
func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error {
// 1. 获取当前登录客户
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return errors.New(errors.CodeUnauthorized)
}
// 2. 解析请求参数
var req dto.RealnimeLinkRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if err := clientRealnameValidator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("实名链接参数校验失败", zap.Error(err))
return errors.New(errors.CodeInvalidParam)
}
ctx := c.UserContext()
// 3. 通过标识符解析资产
asset, err := h.assetService.Resolve(ctx, req.Identifier)
if err != nil {
return err
}
// 4. 验证资产归属(个人客户必须绑定过该资产)
owned, err := h.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, asset.VirtualNo)
if err != nil {
logger.GetAppLogger().Error("查询资产归属失败",
zap.Uint("customer_id", customerID),
zap.String("virtual_no", asset.VirtualNo),
zap.Error(err))
return errors.New(errors.CodeInternalError, "查询资产归属失败")
}
if !owned {
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
}
// 5. 定位目标卡3 条路径)
var targetCard *model.IotCard
switch {
case asset.AssetType == "card":
// 路径 1资产本身就是卡直接使用
card, cardErr := h.iotCardStore.GetByID(ctx, asset.AssetID)
if cardErr != nil {
return errors.New(errors.CodeIotCardNotFound, "卡信息查询失败")
}
targetCard = card
case asset.AssetType == "device" && req.ICCID != "":
// 路径 2资产是设备指定了 ICCID从设备绑定中查找该卡
card, cardErr := h.findCardInDeviceBindings(c, asset.AssetID, req.ICCID)
if cardErr != nil {
return cardErr
}
targetCard = card
case asset.AssetType == "device":
// 路径 3资产是设备未指定 ICCID取第一张绑定卡按插槽位置排序
card, cardErr := h.findFirstBoundCard(c, asset.AssetID)
if cardErr != nil {
return cardErr
}
targetCard = card
default:
return errors.New(errors.CodeInvalidParam, "不支持的资产类型")
}
// 6. 检查实名状态
if targetCard.RealNameStatus == 1 {
return errors.New(errors.CodeInvalidStatus, "该卡已完成实名")
}
// 7. 获取运营商信息,根据实名链接类型生成 URL
carrier, err := h.carrierStore.GetByID(ctx, targetCard.CarrierID)
if err != nil {
logger.GetAppLogger().Error("查询运营商失败",
zap.Uint("carrier_id", targetCard.CarrierID),
zap.Error(err))
return errors.New(errors.CodeCarrierNotFound, "运营商信息查询失败")
}
resp := &dto.RealnimeLinkResponse{
CardInfo: dto.CardInfoBrief{
ICCID: targetCard.ICCID,
MSISDN: targetCard.MSISDN,
VirtualNo: targetCard.VirtualNo,
},
}
switch carrier.RealnameLinkType {
case constants.RealnameLinkTypeNone:
// 该运营商不支持在线实名
return errors.New(errors.CodeInvalidStatus, "该运营商暂不支持在线实名")
case constants.RealnameLinkTypeTemplate:
// 模板模式:替换占位符生成实名链接
url := carrier.RealnameLinkTemplate
url = strings.ReplaceAll(url, "{iccid}", targetCard.ICCID)
url = strings.ReplaceAll(url, "{msisdn}", targetCard.MSISDN)
url = strings.ReplaceAll(url, "{virtual_no}", targetCard.VirtualNo)
resp.RealnameMode = constants.RealnameLinkTypeTemplate
resp.RealnameURL = url
case constants.RealnameLinkTypeGateway:
// 网关模式:调用 Gateway 接口获取实名链接
linkResp, gwErr := h.gatewayClient.GetRealnameLink(ctx, &gateway.CardStatusReq{
CardNo: targetCard.ICCID,
})
if gwErr != nil {
logger.GetAppLogger().Error("Gateway 获取实名链接失败",
zap.String("iccid", targetCard.ICCID),
zap.Error(gwErr))
return errors.Wrap(errors.CodeGatewayError, gwErr, "获取实名链接失败")
}
resp.RealnameMode = constants.RealnameLinkTypeGateway
resp.RealnameURL = linkResp.URL
default:
logger.GetAppLogger().Warn("未知的实名链接类型",
zap.Uint("carrier_id", carrier.ID),
zap.String("realname_link_type", carrier.RealnameLinkType))
return errors.New(errors.CodeInvalidStatus, "该运营商暂不支持在线实名")
}
return response.Success(c, resp)
}
// findCardInDeviceBindings 在设备绑定中查找指定 ICCID 的卡
func (h *ClientRealnameHandler) findCardInDeviceBindings(c *fiber.Ctx, deviceID uint, iccid string) (*model.IotCard, error) {
ctx := c.UserContext()
// 查询设备的所有有效绑定
bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
logger.GetAppLogger().Error("查询设备绑定失败",
zap.Uint("device_id", deviceID),
zap.Error(err))
return nil, errors.New(errors.CodeInternalError, "查询设备绑定失败")
}
// 收集所有绑定卡的 ID
cardIDs := make([]uint, 0, len(bindings))
for _, b := range bindings {
cardIDs = append(cardIDs, b.IotCardID)
}
if len(cardIDs) == 0 {
return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定任何卡")
}
// 批量查询卡,匹配指定的 ICCID
cards, err := h.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
return nil, errors.New(errors.CodeInternalError, "查询卡信息失败")
}
for _, card := range cards {
if card.ICCID == iccid {
return card, nil
}
}
return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定指定的 ICCID")
}
// findFirstBoundCard 获取设备第一张绑定卡(按插槽位置排序,取第一张)
func (h *ClientRealnameHandler) findFirstBoundCard(c *fiber.Ctx, deviceID uint) (*model.IotCard, error) {
ctx := c.UserContext()
// ListByDeviceID 返回 bind_status=1 的绑定,按 slot_position ASC 排序
bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
if err != nil {
logger.GetAppLogger().Error("查询设备绑定失败",
zap.Uint("device_id", deviceID),
zap.Error(err))
return nil, errors.New(errors.CodeInternalError, "查询设备绑定失败")
}
if len(bindings) == 0 {
return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定任何卡")
}
// 取第一张绑定卡(插槽位置最小的)
card, err := h.iotCardStore.GetByID(ctx, bindings[0].IotCardID)
if err != nil {
return nil, errors.New(errors.CodeIotCardNotFound, "卡信息查询失败")
}
return card, nil
}

View File

@@ -0,0 +1,660 @@
package app
import (
"context"
"fmt"
"math/rand"
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/middleware"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge"
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
)
// ClientWalletHandler C 端钱包处理器
// 提供 C1~C5 钱包详情、流水、充值前校验、充值下单、充值记录接口
type ClientWalletHandler struct {
assetService *asset.Service
personalDeviceStore *postgres.PersonalCustomerDeviceStore
walletStore *postgres.AssetWalletStore
transactionStore *postgres.AssetWalletTransactionStore
rechargeStore *postgres.AssetRechargeStore
rechargeService *rechargeSvc.Service
openIDStore *postgres.PersonalCustomerOpenIDStore
wechatConfigService *wechatConfigSvc.Service
redis *redis.Client
logger *zap.Logger
db *gorm.DB
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
}
// NewClientWalletHandler 创建 C 端钱包处理器
func NewClientWalletHandler(
assetService *asset.Service,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
walletStore *postgres.AssetWalletStore,
transactionStore *postgres.AssetWalletTransactionStore,
rechargeStore *postgres.AssetRechargeStore,
rechargeService *rechargeSvc.Service,
openIDStore *postgres.PersonalCustomerOpenIDStore,
wechatConfigService *wechatConfigSvc.Service,
redisClient *redis.Client,
logger *zap.Logger,
db *gorm.DB,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
) *ClientWalletHandler {
return &ClientWalletHandler{
assetService: assetService,
personalDeviceStore: personalDeviceStore,
walletStore: walletStore,
transactionStore: transactionStore,
rechargeStore: rechargeStore,
rechargeService: rechargeService,
openIDStore: openIDStore,
wechatConfigService: wechatConfigService,
redis: redisClient,
logger: logger,
db: db,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
}
}
type resolvedWalletAssetContext struct {
CustomerID uint
Identifier string
Asset *dto.AssetResolveResponse
Generation int
ResourceType string
SkipPermissionCtx context.Context
}
// GetWalletDetail C1 钱包详情
// GET /api/c/v1/wallet/detail
func (h *ClientWalletHandler) GetWalletDetail(c *fiber.Ctx) error {
var req dto.WalletDetailRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
wallet, err := h.getOrCreateWallet(resolved)
if err != nil {
return err
}
resp := &dto.WalletDetailResponse{
WalletID: wallet.ID,
ResourceType: wallet.ResourceType,
ResourceID: wallet.ResourceID,
Balance: wallet.Balance,
FrozenBalance: wallet.FrozenBalance,
UpdatedAt: wallet.UpdatedAt.Format(time.RFC3339),
}
return response.Success(c, resp)
}
// GetWalletTransactions C2 钱包流水列表
// GET /api/c/v1/wallet/transactions
func (h *ClientWalletHandler) GetWalletTransactions(c *fiber.Ctx) error {
var req dto.WalletTransactionListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if req.Page < 1 {
req.Page = 1
}
if req.PageSize < 1 {
req.PageSize = constants.DefaultPageSize
}
if req.PageSize > constants.MaxPageSize {
req.PageSize = constants.MaxPageSize
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
wallet, err := h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return response.SuccessWithPagination(c, []dto.WalletTransactionItem{}, 0, req.Page, req.PageSize)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
}
var txType *string
if strings.TrimSpace(req.TransactionType) != "" {
v := strings.TrimSpace(req.TransactionType)
txType = &v
}
startTime, err := parseOptionalTime(req.StartTime)
if err != nil {
return errors.New(errors.CodeInvalidParam)
}
endTime, err := parseOptionalTime(req.EndTime)
if err != nil {
return errors.New(errors.CodeInvalidParam)
}
if startTime != nil && endTime != nil && endTime.Before(*startTime) {
return errors.New(errors.CodeInvalidParam)
}
offset := (req.Page - 1) * req.PageSize
list, err := h.transactionStore.ListByResourceIDWithFilter(
resolved.SkipPermissionCtx,
wallet.ResourceType,
wallet.ResourceID,
txType,
startTime,
endTime,
offset,
req.PageSize,
)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包流水失败")
}
total, err := h.transactionStore.CountByResourceIDWithFilter(
resolved.SkipPermissionCtx,
wallet.ResourceType,
wallet.ResourceID,
txType,
startTime,
endTime,
)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包流水总数失败")
}
items := make([]dto.WalletTransactionItem, 0, len(list))
for _, tx := range list {
if tx == nil {
continue
}
remark := ""
if tx.Remark != nil {
remark = *tx.Remark
}
items = append(items, dto.WalletTransactionItem{
TransactionID: tx.ID,
Type: tx.TransactionType,
Amount: tx.Amount,
BalanceAfter: tx.BalanceAfter,
CreatedAt: tx.CreatedAt.Format(time.RFC3339),
Remark: remark,
})
}
return response.SuccessWithPagination(c, items, total, req.Page, req.PageSize)
}
// GetRechargeCheck C3 充值前校验
// GET /api/c/v1/wallet/recharge-check
func (h *ClientWalletHandler) GetRechargeCheck(c *fiber.Ctx) error {
var req dto.ClientRechargeCheckRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
check, err := h.rechargeService.GetRechargeCheck(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
if err != nil {
return err
}
resp := &dto.ClientRechargeCheckResponse{
NeedForceRecharge: check.NeedForceRecharge,
ForceRechargeAmount: check.ForceRechargeAmount,
TriggerType: check.TriggerType,
MinAmount: check.MinAmount,
MaxAmount: check.MaxAmount,
Message: check.Message,
}
return response.Success(c, resp)
}
// CreateRecharge C4 创建充值订单
// POST /api/c/v1/wallet/recharge
func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
var req dto.ClientCreateRechargeRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if req.PaymentMethod != constants.RechargeMethodWechat {
return errors.New(errors.CodeInvalidParam)
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
wallet, err := h.getOrCreateWallet(resolved)
if err != nil {
return err
}
config, err := h.wechatConfigService.GetActiveConfig(resolved.SkipPermissionCtx)
if err != nil {
return err
}
if config == nil {
return errors.New(errors.CodeWechatConfigUnavailable)
}
appID, err := pickAppIDByType(config, req.AppType)
if err != nil {
return err
}
openID, err := h.findOpenIDByCustomerAndAppID(resolved.SkipPermissionCtx, resolved.CustomerID, appID)
if err != nil {
return err
}
rechargeNo := generateClientRechargeNo()
recharge := &model.AssetRechargeRecord{
UserID: resolved.CustomerID,
AssetWalletID: wallet.ID,
ResourceType: resolved.ResourceType,
ResourceID: resolved.Asset.AssetID,
RechargeNo: rechargeNo,
Amount: req.Amount,
PaymentMethod: constants.RechargeMethodWechat,
PaymentConfigID: &config.ID,
Status: constants.RechargeStatusPending,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
OperatorType: constants.OperatorTypePersonalCustomer,
Generation: resolved.Generation,
}
if err := h.rechargeStore.Create(resolved.SkipPermissionCtx, recharge); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值记录失败")
}
cache := wechat.NewRedisCache(h.redis)
paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, h.logger)
if err != nil {
return errors.Wrap(errors.CodeWechatPayFailed, err, "初始化微信支付实例失败")
}
paymentService := wechat.NewPaymentService(paymentApp, h.logger)
payResult, err := paymentService.CreateJSAPIOrder(
resolved.SkipPermissionCtx,
recharge.RechargeNo,
"资产钱包充值",
openID,
int(req.Amount),
)
if err != nil {
return err
}
payConfig := buildClientRechargePayConfig(appID, payResult)
resp := &dto.ClientRechargeResponse{
Recharge: dto.ClientRechargeResult{
RechargeID: recharge.ID,
RechargeNo: recharge.RechargeNo,
Amount: recharge.Amount,
Status: recharge.Status,
},
PayConfig: payConfig,
}
return response.Success(c, resp)
}
// GetRechargeList C5 充值记录列表
// GET /api/c/v1/wallet/recharges
func (h *ClientWalletHandler) GetRechargeList(c *fiber.Ctx) error {
var req dto.ClientRechargeListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam)
}
if req.Page < 1 {
req.Page = 1
}
if req.PageSize < 1 {
req.PageSize = constants.DefaultPageSize
}
if req.PageSize > constants.MaxPageSize {
req.PageSize = constants.MaxPageSize
}
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
if err != nil {
return err
}
query := h.db.WithContext(resolved.SkipPermissionCtx).
Model(&model.AssetRechargeRecord{}).
Where("resource_type = ? AND resource_id = ? AND generation = ?", resolved.ResourceType, resolved.Asset.AssetID, resolved.Generation)
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录总数失败")
}
var records []*model.AssetRechargeRecord
offset := (req.Page - 1) * req.PageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&records).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败")
}
items := make([]dto.ClientRechargeListItem, 0, len(records))
for _, record := range records {
if record == nil {
continue
}
items = append(items, dto.ClientRechargeListItem{
RechargeID: record.ID,
RechargeNo: record.RechargeNo,
Amount: record.Amount,
Status: record.Status,
PaymentMethod: record.PaymentMethod,
CreatedAt: record.CreatedAt.Format(time.RFC3339),
AutoPurchaseStatus: record.AutoPurchaseStatus,
})
}
return response.SuccessWithPagination(c, items, total, req.Page, req.PageSize)
}
// resolveAssetFromIdentifier 统一执行资产解析与归属校验
func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedWalletAssetContext, error) {
customerID, ok := middleware.GetCustomerID(c)
if !ok || customerID == 0 {
return nil, errors.New(errors.CodeUnauthorized)
}
identifier = strings.TrimSpace(identifier)
if identifier == "" {
identifier = strings.TrimSpace(c.Query("identifier"))
}
if identifier == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{})
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier)
if err != nil {
return nil, err
}
owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo)
if ownErr != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
}
if !owned {
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
}
resourceType, mapErr := mapAssetTypeToWalletResource(assetInfo.AssetType)
if mapErr != nil {
return nil, mapErr
}
generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
if genErr != nil {
return nil, genErr
}
return &resolvedWalletAssetContext{
CustomerID: customerID,
Identifier: identifier,
Asset: assetInfo,
Generation: generation,
ResourceType: resourceType,
SkipPermissionCtx: skipPermissionCtx,
}, nil
}
func (h *ClientWalletHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) {
records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID)
if err != nil {
return false, err
}
for _, record := range records {
if record == nil {
continue
}
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
return true, nil
}
}
return false, nil
}
func (h *ClientWalletHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
switch assetType {
case "card":
card, err := h.iotCardStore.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, errors.New(errors.CodeAssetNotFound)
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
}
return card.Generation, nil
case "device":
device, err := h.deviceStore.GetByID(ctx, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return 0, errors.New(errors.CodeAssetNotFound)
}
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
}
return device.Generation, nil
default:
return 0, errors.New(errors.CodeInvalidParam)
}
}
func (h *ClientWalletHandler) getOrCreateWallet(resolved *resolvedWalletAssetContext) (*model.AssetWallet, error) {
wallet, err := h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
if err == nil {
return wallet, nil
}
if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
}
shopIDTag := uint(0)
if resolved.Asset.ShopID != nil {
shopIDTag = *resolved.Asset.ShopID
}
newWallet := &model.AssetWallet{
ResourceType: resolved.ResourceType,
ResourceID: resolved.Asset.AssetID,
Balance: 0,
FrozenBalance: 0,
Currency: "CNY",
Status: constants.AssetWalletStatusNormal,
Version: 0,
ShopIDTag: shopIDTag,
}
if createErr := h.walletStore.Create(resolved.SkipPermissionCtx, newWallet); createErr != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, createErr, "创建钱包失败")
}
wallet, err = h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
}
return wallet, nil
}
func (h *ClientWalletHandler) findOpenIDByCustomerAndAppID(ctx context.Context, customerID uint, appID string) (string, error) {
list, err := h.openIDStore.ListByCustomerID(ctx, customerID)
if err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权信息失败")
}
for _, item := range list {
if item == nil {
continue
}
if item.AppID == appID && strings.TrimSpace(item.OpenID) != "" {
return item.OpenID, nil
}
}
return "", errors.New(errors.CodeOpenIDNotFound)
}
func mapAssetTypeToWalletResource(assetType string) (string, error) {
switch assetType {
case "card":
return constants.AssetWalletResourceTypeIotCard, nil
case "device":
return constants.AssetWalletResourceTypeDevice, nil
default:
return "", errors.New(errors.CodeInvalidParam)
}
}
func parseOptionalTime(value string) (*time.Time, error) {
v := strings.TrimSpace(value)
if v == "" {
return nil, nil
}
layouts := []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"}
for _, layout := range layouts {
t, err := time.Parse(layout, v)
if err == nil {
return &t, nil
}
}
return nil, fmt.Errorf("invalid time format")
}
func pickAppIDByType(config *model.WechatConfig, appType string) (string, error) {
switch appType {
case "official_account":
if strings.TrimSpace(config.OaAppID) == "" {
return "", errors.New(errors.CodeWechatConfigUnavailable)
}
return config.OaAppID, nil
case "miniapp":
if strings.TrimSpace(config.MiniappAppID) == "" {
return "", errors.New(errors.CodeWechatConfigUnavailable)
}
return config.MiniappAppID, nil
default:
return "", errors.New(errors.CodeInvalidParam)
}
}
func generateClientRechargeNo() string {
timestamp := time.Now().Format("20060102150405")
randomNum := rand.Intn(1000000)
return fmt.Sprintf("%s%s%06d", constants.AssetRechargeOrderPrefix, timestamp, randomNum)
}
func buildClientRechargePayConfig(appID string, result *wechat.JSAPIPayResult) dto.ClientRechargePayConfig {
resp := dto.ClientRechargePayConfig{AppID: appID}
if result == nil || result.PayConfig == nil {
return resp
}
if cfg, ok := result.PayConfig.(map[string]any); ok {
resp.Timestamp = getStringFromAnyMap(cfg, "timeStamp", "timestamp")
resp.NonceStr = getStringFromAnyMap(cfg, "nonceStr", "nonce_str")
resp.PackageVal = getStringFromAnyMap(cfg, "package")
resp.SignType = getStringFromAnyMap(cfg, "signType", "sign_type")
resp.PaySign = getStringFromAnyMap(cfg, "paySign", "pay_sign")
if appIDVal := getStringFromAnyMap(cfg, "appId", "app_id"); appIDVal != "" {
resp.AppID = appIDVal
}
return resp
}
if cfg, ok := result.PayConfig.(map[string]string); ok {
resp.Timestamp = cfg["timeStamp"]
if resp.Timestamp == "" {
resp.Timestamp = cfg["timestamp"]
}
resp.NonceStr = cfg["nonceStr"]
if resp.NonceStr == "" {
resp.NonceStr = cfg["nonce_str"]
}
resp.PackageVal = cfg["package"]
resp.SignType = cfg["signType"]
if resp.SignType == "" {
resp.SignType = cfg["sign_type"]
}
resp.PaySign = cfg["paySign"]
if resp.PaySign == "" {
resp.PaySign = cfg["pay_sign"]
}
if cfg["appId"] != "" {
resp.AppID = cfg["appId"]
} else if cfg["app_id"] != "" {
resp.AppID = cfg["app_id"]
}
}
return resp
}
func getStringFromAnyMap(m map[string]any, keys ...string) string {
for _, key := range keys {
val, ok := m[key]
if !ok || val == nil {
continue
}
switch v := val.(type) {
case string:
if v != "" {
return v
}
case fmt.Stringer:
text := v.String()
if text != "" {
return text
}
default:
text := fmt.Sprintf("%v", v)
if text != "" && text != "<nil>" {
return text
}
}
}
return ""
}

View File

@@ -3,7 +3,6 @@
package app
import (
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/personal_customer"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
@@ -25,45 +24,6 @@ func NewPersonalCustomerHandler(service *personal_customer.Service, logger *zap.
}
}
// SendCodeRequest 发送验证码请求
type SendCodeRequest struct {
Phone string `json:"phone" validate:"required,len=11"` // 手机号11位
}
// SendCode 发送验证码
// POST /api/c/v1/login/send-code
func (h *PersonalCustomerHandler) SendCode(c *fiber.Ctx) error {
var req SendCodeRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// 发送验证码
if err := h.service.SendVerificationCode(c.Context(), req.Phone); err != nil {
h.logger.Error("发送验证码失败",
zap.String("phone", req.Phone),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "发送验证码失败")
}
return response.Success(c, fiber.Map{
"message": "验证码已发送",
})
}
// LoginRequest 登录请求
type LoginRequest struct {
Phone string `json:"phone" validate:"required,len=11"` // 手机号11位
Code string `json:"code" validate:"required,len=6"` // 验证码6位
}
// LoginResponse 登录响应
type LoginResponse struct {
Token string `json:"token"` // 访问令牌
Customer *PersonalCustomerDTO `json:"customer"` // 客户信息
}
// PersonalCustomerDTO 个人客户 DTO
type PersonalCustomerDTO struct {
ID uint `json:"id"`
@@ -74,87 +34,6 @@ type PersonalCustomerDTO struct {
Status int `json:"status"`
}
// Login 登录(手机号 + 验证码)
// POST /api/c/v1/login
func (h *PersonalCustomerHandler) Login(c *fiber.Ctx) error {
var req LoginRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// 登录
token, customer, err := h.service.LoginByPhone(c.Context(), req.Phone, req.Code)
if err != nil {
h.logger.Error("登录失败",
zap.String("phone", req.Phone),
zap.Error(err),
)
return errors.Wrap(errors.CodeInternalError, err, "登录失败")
}
// 构造响应
// 注意Phone 字段已从 PersonalCustomer 模型移除,需要从 PersonalCustomerPhone 表查询
resp := &LoginResponse{
Token: token,
Customer: &PersonalCustomerDTO{
ID: customer.ID,
Phone: req.Phone, // 使用请求中的手机号(临时方案)
Nickname: customer.Nickname,
AvatarURL: customer.AvatarURL,
WxOpenID: customer.WxOpenID,
Status: customer.Status,
},
}
return response.Success(c, resp)
}
// WechatOAuthLogin 微信 OAuth 登录
// POST /api/c/v1/wechat/auth
func (h *PersonalCustomerHandler) WechatOAuthLogin(c *fiber.Ctx) error {
var req dto.WechatOAuthRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.WechatOAuthLogin(c.Context(), req.Code)
if err != nil {
h.logger.Error("微信 OAuth 登录失败",
zap.String("code", req.Code),
zap.Error(err),
)
return err
}
return response.Success(c, result)
}
// BindWechat 绑定微信
// POST /api/c/v1/bind-wechat
func (h *PersonalCustomerHandler) BindWechat(c *fiber.Ctx) error {
var req dto.WechatOAuthRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
customerID, ok := c.Locals("customer_id").(uint)
if !ok {
return errors.New(errors.CodeUnauthorized, "未找到客户信息")
}
if err := h.service.BindWechatWithCode(c.Context(), customerID, req.Code); err != nil {
h.logger.Error("绑定微信失败",
zap.Uint("customer_id", customerID),
zap.Error(err),
)
return err
}
return response.Success(c, fiber.Map{
"message": "绑定成功",
})
}
// UpdateProfileRequest 更新个人资料请求
type UpdateProfileRequest struct {
Nickname string `json:"nickname"` // 昵称

View File

@@ -2,7 +2,10 @@ package callback
import (
"context"
"encoding/xml"
"fmt"
"net/http"
"net/url"
"strings"
"github.com/gofiber/fiber/v2"
@@ -13,20 +16,33 @@ import (
rechargeService "github.com/break/junhong_cmp_fiber/internal/service/recharge"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/fuiou"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
)
// AgentRechargeServiceInterface 代理充值服务接口
type AgentRechargeServiceInterface interface {
HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error
}
type PaymentHandler struct {
orderService *orderService.Service
rechargeService *rechargeService.Service
agentRechargeService AgentRechargeServiceInterface
wechatPayment wechat.PaymentServiceInterface
}
func NewPaymentHandler(orderService *orderService.Service, rechargeService *rechargeService.Service, wechatPayment wechat.PaymentServiceInterface) *PaymentHandler {
func NewPaymentHandler(
orderService *orderService.Service,
rechargeService *rechargeService.Service,
agentRechargeService AgentRechargeServiceInterface,
wechatPayment wechat.PaymentServiceInterface,
) *PaymentHandler {
return &PaymentHandler{
orderService: orderService,
rechargeService: rechargeService,
agentRechargeService: agentRechargeService,
wechatPayment: wechatPayment,
}
}
@@ -47,14 +63,23 @@ func (h *PaymentHandler) WechatPayCallback(c *fiber.Ctx) error {
return nil
}
// 根据订单号前缀判断订单类型
if strings.HasPrefix(result.OutTradeNo, constants.RechargeOrderPrefix) {
// 充值订单回调
return h.rechargeService.HandlePaymentCallback(ctx, result.OutTradeNo, model.PaymentMethodWechat, result.TransactionID)
}
// TODO: 按 payment_config_id 加载配置验签(当前留桩,仍用 wechatPayment 单例验签)
// 套餐订单回调
return h.orderService.HandlePaymentCallback(ctx, result.OutTradeNo, model.PaymentMethodWechat)
// 按订单号前缀分发
outTradeNo := result.OutTradeNo
switch {
case strings.HasPrefix(outTradeNo, "ORD"):
return h.orderService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat)
case strings.HasPrefix(outTradeNo, constants.AssetRechargeOrderPrefix):
return h.rechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, result.TransactionID)
case strings.HasPrefix(outTradeNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
return h.agentRechargeService.HandlePaymentCallback(ctx, outTradeNo, model.PaymentMethodWechat, result.TransactionID)
}
return fmt.Errorf("代理充值服务未配置,无法处理订单: %s", outTradeNo)
default:
return fmt.Errorf("未知订单号前缀: %s", outTradeNo)
}
})
if err != nil {
@@ -68,6 +93,8 @@ type AlipayCallbackRequest struct {
OrderNo string `json:"out_trade_no" form:"out_trade_no"`
}
// AlipayCallback 支付宝回调
// POST /api/callback/alipay
func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
var req AlipayCallbackRequest
if err := c.BodyParser(&req); err != nil {
@@ -80,18 +107,95 @@ func (h *PaymentHandler) AlipayCallback(c *fiber.Ctx) error {
ctx := c.UserContext()
// 根据订单号前缀判断订单类型
if strings.HasPrefix(req.OrderNo, constants.RechargeOrderPrefix) {
// 充值订单回调
if err := h.rechargeService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodAlipay, ""); err != nil {
return err
}
} else {
// 套餐订单回调
// 订单号前缀分发
switch {
case strings.HasPrefix(req.OrderNo, "ORD"):
if err := h.orderService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodAlipay); err != nil {
return err
}
case strings.HasPrefix(req.OrderNo, constants.AssetRechargeOrderPrefix):
if err := h.rechargeService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodAlipay, ""); err != nil {
return err
}
case strings.HasPrefix(req.OrderNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
if err := h.agentRechargeService.HandlePaymentCallback(ctx, req.OrderNo, model.PaymentMethodAlipay, ""); err != nil {
return err
}
}
default:
return errors.New(errors.CodeInvalidParam, "未知订单号前缀")
}
return c.SendString("success")
}
// FuiouPayCallback 富友支付回调
// POST /api/callback/fuiou-pay
func (h *PaymentHandler) FuiouPayCallback(c *fiber.Ctx) error {
body := c.Body()
if len(body) == 0 {
return errors.New(errors.CodeFuiouCallbackInvalid, "回调请求体为空")
}
ctx := c.UserContext()
// TODO: 按 payment_config_id 加载配置创建 fuiou.Client 验签
// 当前留桩:解析但不验签
// 解析 req= 参数
formValue := string(body)
if strings.HasPrefix(formValue, "req=") {
formValue = formValue[4:]
}
decoded, err := url.QueryUnescape(formValue)
if err != nil {
return errors.New(errors.CodeFuiouCallbackInvalid, "回调数据解码失败")
}
utf8Data, err := fuiou.GBKToUTF8([]byte(decoded))
if err != nil {
return errors.New(errors.CodeFuiouCallbackInvalid, "GBK 转 UTF-8 失败")
}
xmlStr := strings.Replace(string(utf8Data), `encoding="GBK"`, `encoding="UTF-8"`, 1)
var notify fuiou.NotifyRequest
if err := xml.Unmarshal([]byte(xmlStr), &notify); err != nil {
return errors.New(errors.CodeFuiouCallbackInvalid, "解析回调 XML 失败")
}
if notify.ResultCode != "000000" {
c.Set("Content-Type", "text/xml; charset=gbk")
return c.Send(fuiou.BuildNotifySuccessResponse())
}
// 按订单号前缀分发
orderNo := notify.MchntOrderNo
switch {
case strings.HasPrefix(orderNo, "ORD"):
if err := h.orderService.HandlePaymentCallback(ctx, orderNo, "fuiou"); err != nil {
c.Set("Content-Type", "text/xml; charset=gbk")
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
case strings.HasPrefix(orderNo, constants.AssetRechargeOrderPrefix):
if err := h.rechargeService.HandlePaymentCallback(ctx, orderNo, "fuiou", notify.TransactionId); err != nil {
c.Set("Content-Type", "text/xml; charset=gbk")
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
case strings.HasPrefix(orderNo, constants.AgentRechargeOrderPrefix):
if h.agentRechargeService != nil {
if err := h.agentRechargeService.HandlePaymentCallback(ctx, orderNo, "fuiou", notify.TransactionId); err != nil {
c.Set("Content-Type", "text/xml; charset=gbk")
return c.Send(fuiou.BuildNotifyFailResponse(err.Error()))
}
}
default:
c.Set("Content-Type", "text/xml; charset=gbk")
return c.Send(fuiou.BuildNotifyFailResponse("unknown order prefix"))
}
c.Set("Content-Type", "text/xml; charset=gbk")
return c.Send(fuiou.BuildNotifySuccessResponse())
}

View File

@@ -1,160 +0,0 @@
package h5
import (
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/auth"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"github.com/go-playground/validator/v10"
"github.com/gofiber/fiber/v2"
"go.uber.org/zap"
)
// AuthHandler H5认证处理器
type AuthHandler struct {
authService *auth.Service
validator *validator.Validate
}
// NewAuthHandler 创建H5认证处理器
func NewAuthHandler(authService *auth.Service, validator *validator.Validate) *AuthHandler {
return &AuthHandler{
authService: authService,
validator: validator,
}
}
// Login H5登录
func (h *AuthHandler) Login(c *fiber.Ctx) error {
var req dto.LoginRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("参数验证失败",
zap.String("path", c.Path()),
zap.String("method", c.Method()),
zap.Error(err),
)
return errors.New(errors.CodeInvalidParam)
}
clientIP := c.IP()
ctx := c.UserContext()
resp, err := h.authService.Login(ctx, &req, clientIP)
if err != nil {
return err
}
return response.Success(c, resp)
}
// Logout H5登出
func (h *AuthHandler) Logout(c *fiber.Ctx) error {
auth := c.Get("Authorization")
accessToken := ""
if len(auth) > 7 && auth[:7] == "Bearer " {
accessToken = auth[7:]
}
refreshToken := ""
var req dto.RefreshTokenRequest
if err := c.BodyParser(&req); err == nil {
refreshToken = req.RefreshToken
}
ctx := c.UserContext()
if err := h.authService.Logout(ctx, accessToken, refreshToken); err != nil {
return err
}
return response.Success(c, nil)
}
// RefreshToken 刷新访问令牌
func (h *AuthHandler) RefreshToken(c *fiber.Ctx) error {
var req dto.RefreshTokenRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("参数验证失败",
zap.String("path", c.Path()),
zap.String("method", c.Method()),
zap.Error(err),
)
return errors.New(errors.CodeInvalidParam)
}
ctx := c.UserContext()
newAccessToken, err := h.authService.RefreshToken(ctx, req.RefreshToken)
if err != nil {
return err
}
resp := &dto.RefreshTokenResponse{
AccessToken: newAccessToken,
ExpiresIn: 86400,
}
return response.Success(c, resp)
}
// GetMe 获取当前用户信息
func (h *AuthHandler) GetMe(c *fiber.Ctx) error {
userID := middleware.GetUserIDFromContext(c.UserContext())
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
ctx := c.UserContext()
userInfo, permissions, err := h.authService.GetCurrentUser(ctx, userID)
if err != nil {
return err
}
data := map[string]interface{}{
"user": userInfo,
"permissions": permissions,
}
return response.Success(c, data)
}
// ChangePassword 修改密码
func (h *AuthHandler) ChangePassword(c *fiber.Ctx) error {
userID := middleware.GetUserIDFromContext(c.UserContext())
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
var req dto.ChangePasswordRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if err := h.validator.Struct(&req); err != nil {
logger.GetAppLogger().Warn("参数验证失败",
zap.String("path", c.Path()),
zap.String("method", c.Method()),
zap.Error(err),
)
return errors.New(errors.CodeInvalidParam)
}
ctx := c.UserContext()
if err := h.authService.ChangePassword(ctx, userID, req.OldPassword, req.NewPassword); err != nil {
return err
}
return response.Success(c, nil)
}

View File

@@ -1,55 +0,0 @@
package h5
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
enterpriseDeviceService "github.com/break/junhong_cmp_fiber/internal/service/enterprise_device"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
type EnterpriseDeviceHandler struct {
service *enterpriseDeviceService.Service
}
func NewEnterpriseDeviceHandler(service *enterpriseDeviceService.Service) *EnterpriseDeviceHandler {
return &EnterpriseDeviceHandler{service: service}
}
func (h *EnterpriseDeviceHandler) ListDevices(c *fiber.Ctx) error {
var req dto.H5EnterpriseDeviceListReq
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
serviceReq := &dto.EnterpriseDeviceListReq{
Page: req.Page,
PageSize: req.PageSize,
VirtualNo: req.VirtualNo,
}
result, err := h.service.ListDevicesForEnterprise(c.UserContext(), serviceReq)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, req.Page, req.PageSize)
}
func (h *EnterpriseDeviceHandler) GetDeviceDetail(c *fiber.Ctx) error {
deviceIDStr := c.Params("device_id")
deviceID, err := strconv.ParseUint(deviceIDStr, 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "设备ID格式错误")
}
result, err := h.service.GetDeviceDetail(c.UserContext(), uint(deviceID))
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -1,211 +0,0 @@
package h5
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
orderService "github.com/break/junhong_cmp_fiber/internal/service/order"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
type OrderHandler struct {
service *orderService.Service
}
func NewOrderHandler(service *orderService.Service) *OrderHandler {
return &OrderHandler{service: service}
}
func (h *OrderHandler) Create(c *fiber.Ctx) error {
var req dto.CreateOrderRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if req.PaymentMethod != model.PaymentMethodWallet {
return errors.New(errors.CodeInvalidParam, "H5端只支持钱包支付")
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
var buyerType string
var buyerID uint
switch userType {
case constants.UserTypeAgent:
buyerType = model.BuyerTypeAgent
buyerID = middleware.GetShopIDFromContext(ctx)
case constants.UserTypeEnterprise:
return errors.New(errors.CodeForbidden, "企业账号不支持在线购买")
case constants.UserTypePersonalCustomer:
buyerType = model.BuyerTypePersonal
buyerID = middleware.GetCustomerIDFromContext(ctx)
default:
return errors.New(errors.CodeForbidden, "不支持的用户类型")
}
order, err := h.service.CreateH5Order(ctx, &req, buyerType, buyerID)
if err != nil {
return err
}
return response.Success(c, order)
}
func (h *OrderHandler) Get(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
}
order, err := h.service.Get(c.UserContext(), uint(id))
if err != nil {
return err
}
return response.Success(c, order)
}
func (h *OrderHandler) List(c *fiber.Ctx) error {
var req dto.OrderListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
var buyerType string
var buyerID uint
switch userType {
case constants.UserTypeAgent:
buyerType = model.BuyerTypeAgent
buyerID = middleware.GetShopIDFromContext(ctx)
case constants.UserTypePersonalCustomer:
buyerType = model.BuyerTypePersonal
buyerID = middleware.GetCustomerIDFromContext(ctx)
default:
return errors.New(errors.CodeForbidden, "不支持的用户类型")
}
orders, err := h.service.List(ctx, &req, buyerType, buyerID)
if err != nil {
return err
}
return response.Success(c, orders)
}
func (h *OrderHandler) WalletPay(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
var buyerType string
var buyerID uint
switch userType {
case constants.UserTypeAgent:
buyerType = model.BuyerTypeAgent
buyerID = middleware.GetShopIDFromContext(ctx)
case constants.UserTypePersonalCustomer:
buyerType = model.BuyerTypePersonal
buyerID = middleware.GetCustomerIDFromContext(ctx)
default:
return errors.New(errors.CodeForbidden, "不支持的用户类型")
}
if err := h.service.WalletPay(ctx, uint(id), buyerType, buyerID); err != nil {
return err
}
return response.Success(c, nil)
}
// WechatPayJSAPI 微信 JSAPI 支付
// POST /api/h5/orders/:id/wechat-pay/jsapi
func (h *OrderHandler) WechatPayJSAPI(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
}
var req dto.WechatPayJSAPIRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
var buyerType string
var buyerID uint
switch userType {
case constants.UserTypeAgent:
buyerType = model.BuyerTypeAgent
buyerID = middleware.GetShopIDFromContext(ctx)
case constants.UserTypePersonalCustomer:
buyerType = model.BuyerTypePersonal
buyerID = middleware.GetCustomerIDFromContext(ctx)
default:
return errors.New(errors.CodeForbidden, "不支持的用户类型")
}
result, err := h.service.WechatPayJSAPI(ctx, uint(id), req.OpenID, buyerType, buyerID)
if err != nil {
return err
}
return response.Success(c, result)
}
// WechatPayH5 微信 H5 支付
// POST /api/h5/orders/:id/wechat-pay/h5
func (h *OrderHandler) WechatPayH5(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的订单ID")
}
var req dto.WechatPayH5Request
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
userType := middleware.GetUserTypeFromContext(ctx)
var buyerType string
var buyerID uint
switch userType {
case constants.UserTypeAgent:
buyerType = model.BuyerTypeAgent
buyerID = middleware.GetShopIDFromContext(ctx)
case constants.UserTypePersonalCustomer:
buyerType = model.BuyerTypePersonal
buyerID = middleware.GetCustomerIDFromContext(ctx)
default:
return errors.New(errors.CodeForbidden, "不支持的用户类型")
}
result, err := h.service.WechatPayH5(ctx, uint(id), &req.SceneInfo, buyerType, buyerID)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -1,93 +0,0 @@
package h5
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model"
packageService "github.com/break/junhong_cmp_fiber/internal/service/package"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
"gorm.io/gorm"
)
// PackageUsageHandler H5 端套餐使用情况 Handler
type PackageUsageHandler struct {
db *gorm.DB
customerViewService *packageService.CustomerViewService
}
// NewPackageUsageHandler 创建 H5 端套餐使用情况 Handler
func NewPackageUsageHandler(db *gorm.DB, customerViewService *packageService.CustomerViewService) *PackageUsageHandler {
return &PackageUsageHandler{
db: db,
customerViewService: customerViewService,
}
}
// GetMyUsage 任务 15.2-15.5: 获取我的套餐使用情况
// GET /api/h5/packages/my-usage
func (h *PackageUsageHandler) GetMyUsage(c *fiber.Ctx) error {
ctx := c.UserContext()
// 任务 15.3: 从 JWT 上下文中提取用户信息
userType := middleware.GetUserTypeFromContext(ctx)
var carrierType string
var carrierID uint
// 根据用户类型获取载体信息
switch userType {
case constants.UserTypePersonalCustomer:
// 个人客户:查询其订单关联的 IoT 卡或设备
customerID := middleware.GetCustomerIDFromContext(ctx)
if customerID == 0 {
return errors.New(errors.CodeInvalidParam, "未找到客户信息")
}
// 查询该客户的套餐使用记录,获取载体信息
var usage model.PackageUsage
err := h.db.WithContext(ctx).
Joins("JOIN tb_order ON tb_order.id = tb_package_usage.order_id").
Where("tb_order.buyer_type = ? AND tb_order.buyer_id = ?", model.BuyerTypePersonal, customerID).
Where("tb_package_usage.status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Order("tb_package_usage.activated_at DESC").
First(&usage).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "未找到套餐使用记录")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐使用记录失败")
}
// 确定载体类型和 ID
if usage.IotCardID > 0 {
carrierType = "iot_card"
carrierID = usage.IotCardID
} else if usage.DeviceID > 0 {
carrierType = "device"
carrierID = usage.DeviceID
} else {
return errors.New(errors.CodeInvalidParam, "套餐使用记录未关联卡或设备")
}
case constants.UserTypeAgent, constants.UserTypeEnterprise:
// 代理和企业用户暂不支持通过此接口查询
// 他们应该使用后台管理接口查询指定卡/设备的套餐情况
return errors.New(errors.CodeForbidden, "此接口仅供个人客户使用")
default:
return errors.New(errors.CodeForbidden, "不支持的用户类型")
}
// 任务 15.4: 调用 CustomerViewService.GetMyUsage 获取流量数据
usageData, err := h.customerViewService.GetMyUsage(ctx, carrierType, carrierID)
if err != nil {
return err
}
// 任务 15.5: 返回 PackageUsageCustomerViewResponse 响应
return response.Success(c, usageData)
}

View File

@@ -1,169 +0,0 @@
package h5
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
rechargeService "github.com/break/junhong_cmp_fiber/internal/service/recharge"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/response"
)
// RechargeHandler 充值订单处理器
// 提供充值订单的创建、预检、查询等接口
type RechargeHandler struct {
service *rechargeService.Service
}
// NewRechargeHandler 创建充值订单处理器实例
// 参数:
// - service: 充值服务
//
// 返回:
// - *RechargeHandler: 充值订单处理器实例
func NewRechargeHandler(service *rechargeService.Service) *RechargeHandler {
return &RechargeHandler{service: service}
}
// Create 创建充值订单
// POST /api/h5/wallets/recharge
// 请求参数:
// - resource_type: 资源类型iot_card/device
// - resource_id: 资源ID卡ID或设备ID
// - amount: 充值金额(分)
// - payment_method: 支付方式wechat/alipay
//
// 响应:
// - 成功: 返回充值订单信息订单ID、订单号、金额、状态等
// - 失败: 返回错误信息
func (h *RechargeHandler) Create(c *fiber.Ctx) error {
var req dto.CreateRechargeRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
// 获取个人客户ID作为用户ID
userID := middleware.GetCustomerIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "用户未登录")
}
result, err := h.service.Create(ctx, &req, userID)
if err != nil {
return err
}
return response.Success(c, result)
}
// RechargeCheck 充值预检
// GET /api/h5/wallets/recharge-check
// 请求参数:
// - resource_type: 资源类型iot_card/device
// - resource_id: 资源ID卡ID或设备ID
//
// 响应:
// - 成功: 返回预检信息(是否需要强充、强充金额、最小/最大充值金额等)
// - 失败: 返回错误信息
func (h *RechargeHandler) RechargeCheck(c *fiber.Ctx) error {
var req dto.RechargeCheckRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
// 验证必填参数
if req.ResourceType == "" {
return errors.New(errors.CodeInvalidParam, "资源类型不能为空")
}
if req.ResourceID == 0 {
return errors.New(errors.CodeInvalidParam, "资源ID不能为空")
}
ctx := c.UserContext()
result, err := h.service.GetRechargeCheck(ctx, req.ResourceType, req.ResourceID)
if err != nil {
return err
}
// 转换为 DTO 响应
resp := &dto.RechargeCheckResponse{
NeedForceRecharge: result.NeedForceRecharge,
ForceRechargeAmount: result.ForceRechargeAmount,
TriggerType: result.TriggerType,
MinAmount: result.MinAmount,
MaxAmount: result.MaxAmount,
CurrentAccumulated: result.CurrentAccumulated,
Threshold: result.Threshold,
Message: result.Message,
FirstCommissionPaid: result.FirstCommissionPaid,
}
return response.Success(c, resp)
}
// List 查询充值订单列表
// GET /api/h5/wallets/recharges
// 请求参数:
// - page: 页码从1开始默认1
// - page_size: 每页数量默认20最大100
// - wallet_id: 钱包ID筛选可选
// - status: 状态筛选可选1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)
// - start_time: 开始时间筛选(可选)
// - end_time: 结束时间筛选(可选)
//
// 响应:
// - 成功: 返回充值订单列表(分页数据、总记录数、总页数)
// - 失败: 返回错误信息
func (h *RechargeHandler) List(c *fiber.Ctx) error {
var req dto.RechargeListRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
ctx := c.UserContext()
// 获取个人客户ID作为用户ID
userID := middleware.GetCustomerIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "用户未登录")
}
result, err := h.service.List(ctx, &req, userID)
if err != nil {
return err
}
return response.Success(c, result)
}
// Get 查询充值订单详情
// GET /api/h5/wallets/recharges/:id
// 路径参数:
// - id: 充值订单ID
//
// 响应:
// - 成功: 返回充值订单详情订单ID、订单号、金额、状态、支付信息等
// - 失败: 返回错误信息
func (h *RechargeHandler) Get(c *fiber.Ctx) error {
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
if err != nil {
return errors.New(errors.CodeInvalidParam, "无效的充值订单ID")
}
ctx := c.UserContext()
// 获取个人客户ID作为用户ID
userID := middleware.GetCustomerIDFromContext(ctx)
if userID == 0 {
return errors.New(errors.CodeUnauthorized, "用户未登录")
}
result, err := h.service.GetByID(ctx, uint(id), userID)
if err != nil {
return err
}
return response.Success(c, result)
}

View File

@@ -1,32 +1,37 @@
package middleware
import (
"context"
"strings"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/gofiber/fiber/v2"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
)
// PersonalAuthMiddleware 个人客户认证中间件
type PersonalAuthMiddleware struct {
jwtManager *auth.JWTManager
redis *redis.Client
logger *zap.Logger
}
// NewPersonalAuthMiddleware 创建个人客户认证中间件
func NewPersonalAuthMiddleware(jwtManager *auth.JWTManager, logger *zap.Logger) *PersonalAuthMiddleware {
func NewPersonalAuthMiddleware(jwtManager *auth.JWTManager, rdb *redis.Client, logger *zap.Logger) *PersonalAuthMiddleware {
return &PersonalAuthMiddleware{
jwtManager: jwtManager,
redis: rdb,
logger: logger,
}
}
// Authenticate 认证中间件
// JWT + Redis 双重校验:先验证 JWT 签名和有效期,再检查 Redis 中 token 是否存在
func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
return func(c *fiber.Ctx) error {
// 从 Authorization header 获取 token
authHeader := c.Get("Authorization")
if authHeader == "" {
m.logger.Warn("个人客户认证失败:缺少 Authorization header",
@@ -36,7 +41,6 @@ func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
return errors.New(errors.CodeUnauthorized, "未提供认证令牌")
}
// 检查 Bearer 前缀
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
m.logger.Warn("个人客户认证失败Authorization header 格式错误",
@@ -48,7 +52,6 @@ func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
token := parts[1]
// 验证 token
claims, err := m.jwtManager.VerifyPersonalCustomerToken(token)
if err != nil {
m.logger.Warn("个人客户认证失败token 验证失败",
@@ -58,12 +61,35 @@ func (m *PersonalAuthMiddleware) Authenticate() fiber.Handler {
return errors.New(errors.CodeUnauthorized, "认证令牌无效或已过期")
}
// 将客户信息存储到 context 中
// Redis 有效性检查token 必须在 Redis 中存在才视为有效
// 支持服务端主动失效(封禁/强制下线/退出登录)
redisKey := constants.RedisPersonalCustomerTokenKey(claims.CustomerID)
storedToken, redisErr := m.redis.Get(context.Background(), redisKey).Result()
if redisErr == redis.Nil {
m.logger.Warn("个人客户认证失败token 已被服务端失效",
zap.Uint("customer_id", claims.CustomerID),
zap.String("path", c.Path()),
)
return errors.New(errors.CodeUnauthorized, "认证令牌已失效,请重新登录")
}
if redisErr != nil {
m.logger.Error("个人客户认证Redis 查询异常",
zap.Uint("customer_id", claims.CustomerID),
zap.Error(redisErr),
)
return errors.New(errors.CodeUnauthorized, "认证服务异常,请稍后重试")
}
// 比对 Redis 中存储的 token 与当前请求 token 是否一致
if storedToken != token {
m.logger.Warn("个人客户认证失败token 不匹配(可能已在其他设备登录)",
zap.Uint("customer_id", claims.CustomerID),
zap.String("path", c.Path()),
)
return errors.New(errors.CodeUnauthorized, "认证令牌已失效,请重新登录")
}
c.Locals("customer_id", claims.CustomerID)
c.Locals("customer_phone", claims.Phone)
// 设置 SkipOwnerFilter 标记,跳过 B 端数据权限过滤
// 个人客户不参与 RBAC 权限体系,不需要 Owner 过滤
c.Locals("skip_owner_filter", true)
m.logger.Debug("个人客户认证成功",

View File

@@ -77,6 +77,7 @@ type AgentRechargeRecord struct {
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信 | bank-银行转账 | offline-线下)" json:"payment_method"`
PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"`
PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"`
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`

View File

@@ -3,12 +3,13 @@ package model
import (
"time"
"gorm.io/datatypes"
"gorm.io/gorm"
)
// CardWallet 钱包模型
// AssetWallet 资产钱包模型
// 管理物联网卡和设备级别的钱包
type CardWallet struct {
type AssetWallet struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
ResourceType string `gorm:"column:resource_type;type:varchar(20);not null;index;comment:资源类型(iot_card-物联网卡 | device-设备)" json:"resource_type"`
ResourceID uint `gorm:"column:resource_id;not null;index;comment:资源ID(关联tb_iot_card.id或tb_device.id)" json:"resource_id"`
@@ -25,20 +26,20 @@ type CardWallet struct {
}
// TableName 指定表名
func (CardWallet) TableName() string {
return "tb_card_wallet"
func (AssetWallet) TableName() string {
return "tb_asset_wallet"
}
// GetAvailableBalance 获取可用余额 = balance - frozen_balance
func (w *CardWallet) GetAvailableBalance() int64 {
func (w *AssetWallet) GetAvailableBalance() int64 {
return w.Balance - w.FrozenBalance
}
// CardWalletTransaction 钱包交易记录模型
// 记录所有钱包余额变动
type CardWalletTransaction struct {
// AssetWalletTransaction 资产钱包交易记录模型
// 记录所有资产钱包余额变动
type AssetWalletTransaction struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
CardWalletID uint `gorm:"column:card_wallet_id;not null;index;comment:钱包ID" json:"card_wallet_id"`
AssetWalletID uint `gorm:"column:asset_wallet_id;not null;index;comment:资产钱包ID" json:"asset_wallet_id"`
ResourceType string `gorm:"column:resource_type;type:varchar(20);not null;index;comment:资源类型(冗余字段,便于查询)" json:"resource_type"`
ResourceID uint `gorm:"column:resource_id;not null;index;comment:资源ID(冗余字段,便于查询)" json:"resource_id"`
UserID uint `gorm:"column:user_id;not null;comment:操作人用户ID" json:"user_id"`
@@ -47,8 +48,8 @@ type CardWalletTransaction struct {
BalanceBefore int64 `gorm:"column:balance_before;type:bigint;not null;comment:变动前余额(单位:分)" json:"balance_before"`
BalanceAfter int64 `gorm:"column:balance_after;type:bigint;not null;comment:变动后余额(单位:分)" json:"balance_after"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:交易状态(1-成功 2-失败 3-处理中)" json:"status"`
ReferenceType *string `gorm:"column:reference_type;type:varchar(50);comment:关联业务类型(order | topup)" json:"reference_type,omitempty"`
ReferenceID *uint `gorm:"column:reference_id;comment:关联业务ID" json:"reference_id,omitempty"`
ReferenceType *string `gorm:"column:reference_type;type:varchar(50);comment:关联业务类型(order | recharge)" json:"reference_type,omitempty"`
ReferenceNo *string `gorm:"column:reference_no;type:varchar(50);comment:关联业务编号(充值单号CRCH…或订单号ORD…)" json:"reference_no,omitempty"`
Remark *string `gorm:"column:remark;type:text;comment:备注" json:"remark,omitempty"`
Metadata *string `gorm:"column:metadata;type:jsonb;comment:扩展信息(如套餐信息、支付方式等)" json:"metadata,omitempty"`
Creator uint `gorm:"column:creator;not null;comment:创建人ID" json:"creator"`
@@ -60,16 +61,16 @@ type CardWalletTransaction struct {
}
// TableName 指定表名
func (CardWalletTransaction) TableName() string {
return "tb_card_wallet_transaction"
func (AssetWalletTransaction) TableName() string {
return "tb_asset_wallet_transaction"
}
// CardRechargeRecord 充值记录模型
// 记录所有充值操作
type CardRechargeRecord struct {
// AssetRechargeRecord 资产充值记录模型
// 记录所有资产钱包充值操作
type AssetRechargeRecord struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
UserID uint `gorm:"column:user_id;not null;index;comment:操作人用户ID" json:"user_id"`
CardWalletID uint `gorm:"column:card_wallet_id;not null;comment:钱包ID" json:"card_wallet_id"`
AssetWalletID uint `gorm:"column:asset_wallet_id;not null;comment:资产钱包ID" json:"asset_wallet_id"`
ResourceType string `gorm:"column:resource_type;type:varchar(20);not null;index;comment:资源类型(冗余字段)" json:"resource_type"`
ResourceID uint `gorm:"column:resource_id;not null;index;comment:资源ID(冗余字段)" json:"resource_id"`
RechargeNo string `gorm:"column:recharge_no;type:varchar(50);not null;uniqueIndex;comment:充值订单号(格式:CRCH+时间戳+随机数)" json:"recharge_no"`
@@ -77,6 +78,7 @@ type CardRechargeRecord struct {
PaymentMethod string `gorm:"column:payment_method;type:varchar(20);not null;comment:支付方式(alipay-支付宝 | wechat-微信)" json:"payment_method"`
PaymentChannel *string `gorm:"column:payment_channel;type:varchar(50);comment:支付渠道" json:"payment_channel,omitempty"`
PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"`
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
@@ -84,10 +86,17 @@ type CardRechargeRecord struct {
EnterpriseIDTag *uint `gorm:"column:enterprise_id_tag;index;comment:企业ID标签(多租户过滤)" json:"enterprise_id_tag,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
OperatorType string `gorm:"column:operator_type;type:varchar(20);not null;default:'admin_user';comment:操作人类型" json:"operator_type"`
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
LinkedPackageIDs datatypes.JSON `gorm:"column:linked_package_ids;type:jsonb;default:'[]';comment:强充关联套餐ID列表" json:"linked_package_ids,omitempty"`
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"`
}
// TableName 指定表名
func (CardRechargeRecord) TableName() string {
return "tb_card_recharge_record"
func (AssetRechargeRecord) TableName() string {
return "tb_asset_recharge_record"
}

View File

@@ -13,6 +13,8 @@ type Carrier struct {
Description string `gorm:"column:description;type:varchar(500);comment:运营商描述" json:"description"`
Status int `gorm:"column:status;type:int;default:1;comment:状态 1-启用 0-禁用" json:"status"`
BillingDay int `gorm:"column:billing_day;type:int;default:1;comment:运营商计费日(用于流量查询接口的计费周期计算,联通=27其他=1" json:"billing_day"`
RealnameLinkType string `gorm:"column:realname_link_type;type:varchar(20);not null;default:'none';comment:实名链接类型 none-不支持 template-模板URL gateway-Gateway接口" json:"realname_link_type"`
RealnameLinkTemplate string `gorm:"column:realname_link_template;type:varchar(500);default:'';comment:实名链接模板URL" json:"realname_link_template"`
}
// TableName 指定表名

View File

@@ -35,6 +35,8 @@ type Device struct {
AccumulatedRecharge int64 `gorm:"column:accumulated_recharge;type:bigint;default:0;comment:累计充值金额(分,废弃,使用按系列追踪)" json:"accumulated_recharge"`
AccumulatedRechargeBySeriesJSON string `gorm:"column:accumulated_recharge_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的累计充值金额" json:"-"`
FirstRechargeTriggeredBySeriesJSON string `gorm:"column:first_recharge_triggered_by_series;type:jsonb;default:'{}';comment:按套餐系列追踪的首充触发状态" json:"-"`
AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"`
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
}
// TableName 指定表名

View File

@@ -0,0 +1,56 @@
package dto
// CreateAgentRechargeRequest 创建代理充值请求
type CreateAgentRechargeRequest struct {
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"目标店铺ID代理只能填自己店铺"`
Amount int64 `json:"amount" validate:"required,min=10000,max=100000000" required:"true" minimum:"10000" maximum:"100000000" description:"充值金额范围100元~100万元"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat offline" required:"true" description:"支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用)"`
}
// AgentOfflinePayRequest 代理线下充值确认请求
type AgentOfflinePayRequest struct {
OperationPassword string `json:"operation_password" validate:"required" required:"true" description:"操作密码"`
}
// AgentOfflinePayParams 确认线下充值聚合参数 (用于文档生成)
type AgentOfflinePayParams struct {
IDReq
AgentOfflinePayRequest
}
// AgentRechargeResponse 代理充值记录响应
type AgentRechargeResponse struct {
ID uint `json:"id" description:"充值记录ID"`
RechargeNo string `json:"recharge_no" description:"充值单号(ARCH前缀)"`
ShopID uint `json:"shop_id" description:"店铺ID"`
ShopName string `json:"shop_name" description:"店铺名称"`
AgentWalletID uint `json:"agent_wallet_id" description:"代理钱包ID"`
Amount int64 `json:"amount" description:"充值金额(分)"`
PaymentMethod string `json:"payment_method" description:"支付方式 (wechat:微信在线支付, offline:线下转账)"`
PaymentChannel string `json:"payment_channel" description:"实际支付通道 (wechat_direct:微信直连, fuyou:富友, offline:线下转账)"`
PaymentConfigID *uint `json:"payment_config_id" description:"关联支付配置ID线下充值为null"`
PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号"`
Status int `json:"status" description:"状态 (1:待支付, 2:已完成, 3:已取消)"`
PaidAt *string `json:"paid_at" description:"支付时间"`
CompletedAt *string `json:"completed_at" description:"完成时间"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
// AgentRechargeListRequest 代理充值记录列表请求
type AgentRechargeListRequest struct {
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数默认20最大100"`
ShopID *uint `json:"shop_id" query:"shop_id" description:"按店铺ID过滤"`
Status *int `json:"status" query:"status" description:"按状态过滤 (1:待支付, 2:已完成, 3:已取消)"`
StartDate string `json:"start_date" query:"start_date" description:"创建时间起始日期(YYYY-MM-DD)"`
EndDate string `json:"end_date" query:"end_date" description:"创建时间截止日期(YYYY-MM-DD)"`
}
// AgentRechargeListResponse 代理充值记录列表响应
type AgentRechargeListResponse struct {
Total int64 `json:"total" description:"总记录数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"page_size" description:"每页条数"`
List []*AgentRechargeResponse `json:"list" description:"充值记录列表"`
}

View File

@@ -0,0 +1,52 @@
package dto
import "time"
// AssetWalletResponse 资产钱包概况响应
type AssetWalletResponse struct {
WalletID uint `json:"wallet_id" description:"钱包数据库ID"`
ResourceType string `json:"resource_type" description:"资源类型iot_card 或 device"`
ResourceID uint `json:"resource_id" description:"对应卡或设备的数据库ID"`
Balance int64 `json:"balance" description:"总余额(分)"`
FrozenBalance int64 `json:"frozen_balance" description:"冻结余额(分)"`
AvailableBalance int64 `json:"available_balance" description:"可用余额 = balance - frozen_balance"`
Currency string `json:"currency" description:"币种,目前固定 CNY"`
Status int `json:"status" description:"钱包状态1-正常 2-冻结 3-关闭"`
StatusText string `json:"status_text" description:"状态文本"`
CreatedAt time.Time `json:"created_at" description:"创建时间RFC3339"`
UpdatedAt time.Time `json:"updated_at" description:"更新时间RFC3339"`
}
// AssetWalletTransactionListRequest 资产钱包流水列表请求(路径参数 + 查询参数)
type AssetWalletTransactionListRequest struct {
AssetType string `path:"asset_type" description:"资产类型card 或 device" required:"true"`
ID uint `path:"id" description:"资产ID" required:"true"`
Page int `json:"page" query:"page" description:"页码默认1"`
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" description:"每页数量默认20最大100"`
TransactionType *string `json:"transaction_type" query:"transaction_type" validate:"omitempty,oneof=recharge deduct refund" description:"交易类型过滤recharge/deduct/refund"`
StartTime *time.Time `json:"start_time" query:"start_time" description:"开始时间RFC3339"`
EndTime *time.Time `json:"end_time" query:"end_time" description:"结束时间RFC3339"`
}
// AssetWalletTransactionItem 单条流水记录
type AssetWalletTransactionItem struct {
ID uint `json:"id" description:"流水记录ID"`
TransactionType string `json:"transaction_type" description:"交易类型recharge/deduct/refund"`
TransactionTypeText string `json:"transaction_type_text" description:"交易类型文本:充值/扣款/退款"`
Amount int64 `json:"amount" description:"变动金额(分),充值为正数,扣款/退款为负数"`
BalanceBefore int64 `json:"balance_before" description:"变动前余额(分)"`
BalanceAfter int64 `json:"balance_after" description:"变动后余额(分)"`
ReferenceType *string `json:"reference_type,omitempty" description:"关联业务类型recharge 或 order可空"`
ReferenceNo *string `json:"reference_no,omitempty" description:"关联业务编号充值单号CRCH…或订单号ORD…可空"`
Remark *string `json:"remark,omitempty" description:"备注(可空)"`
CreatedAt time.Time `json:"created_at" description:"流水创建时间RFC3339"`
}
// AssetWalletTransactionListResponse 资产钱包流水列表响应
type AssetWalletTransactionListResponse struct {
List []*AssetWalletTransactionItem `json:"list" description:"流水列表"`
Total int64 `json:"total" description:"总记录数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"page_size" description:"每页数量"`
TotalPages int `json:"total_pages" description:"总页数"`
}

View File

@@ -5,11 +5,15 @@ type CreateCarrierRequest struct {
CarrierName string `json:"carrier_name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"运营商名称"`
CarrierType string `json:"carrier_type" validate:"required,oneof=CMCC CUCC CTCC CBN" required:"true" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
RealnameLinkType *string `json:"realname_link_type" validate:"omitempty,oneof=none template gateway" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
RealnameLinkTemplate *string `json:"realname_link_template" validate:"omitempty,max=500" maxLength:"500" description:"实名链接模板URL支持 {iccid}/{msisdn}/{virtual_no} 占位符"`
}
type UpdateCarrierRequest struct {
CarrierName *string `json:"carrier_name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"运营商名称"`
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"运营商描述"`
RealnameLinkType *string `json:"realname_link_type" validate:"omitempty,oneof=none template gateway" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
RealnameLinkTemplate *string `json:"realname_link_template" validate:"omitempty,max=500" maxLength:"500" description:"实名链接模板URL支持 {iccid}/{msisdn}/{virtual_no} 占位符"`
}
type CarrierListRequest struct {
@@ -30,6 +34,8 @@ type CarrierResponse struct {
CarrierName string `json:"carrier_name" description:"运营商名称"`
CarrierType string `json:"carrier_type" description:"运营商类型 (CMCC:中国移动, CUCC:中国联通, CTCC:中国电信, CBN:中国广电)"`
Description string `json:"description" description:"运营商描述"`
RealnameLinkType string `json:"realname_link_type" description:"实名链接类型 none-不支持 template-模板URL gateway-Gateway接口"`
RealnameLinkTemplate string `json:"realname_link_template" description:"实名链接模板URL"`
Status int `json:"status" description:"状态 (1:启用, 0:禁用)"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`

View File

@@ -0,0 +1,181 @@
package dto
import "time"
// ========================================
// B1 资产信息
// ========================================
// AssetInfoRequest B1 资产信息请求
type AssetInfoRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// AssetInfoResponse B1 资产信息响应
// 根据 asset_type 不同,设备专属字段或卡专属字段会分别填充(另一侧为零值/omit
type AssetInfoResponse struct {
// === 基础信息(通用) ===
AssetType string `json:"asset_type" description:"资产类型card:卡, device:设备)"`
AssetID uint `json:"asset_id" description:"资产ID"`
Identifier string `json:"identifier" description:"资产标识符"`
VirtualNo string `json:"virtual_no" description:"虚拟号"`
Status int `json:"status" description:"状态1:在库, 2:已分销, 3:已激活, 4:已停用)"`
RealNameStatus int `json:"real_name_status" description:"实名状态0:未实名, 1:已实名)"`
CarrierName string `json:"carrier_name" description:"运营商名称"`
Generation string `json:"generation" description:"世代"`
WalletBalance int64 `json:"wallet_balance" description:"钱包余额(分)"`
ActivatedAt *time.Time `json:"activated_at,omitempty" description:"激活时间"`
// === 套餐信息(通用) ===
CurrentPackage string `json:"current_package" description:"当前套餐名称(无套餐时为空)"`
PackageTotalMB int64 `json:"package_total_mb" description:"当前套餐总虚流量(MB),已按虚流量比例换算"`
PackageUsedMB float64 `json:"package_used_mb" description:"当前已用虚流量(MB),已按虚流量比例换算"`
PackageRemainMB float64 `json:"package_remain_mb" description:"当前剩余虚流量(MB),已按虚流量比例换算"`
// === 设备专属字段asset_type=device 时有效) ===
DeviceName string `json:"device_name,omitempty" description:"设备名称"`
IMEI string `json:"imei,omitempty" description:"设备IMEI"`
SN string `json:"sn,omitempty" description:"设备序列号"`
DeviceModel string `json:"device_model,omitempty" description:"设备型号"`
DeviceType string `json:"device_type,omitempty" description:"设备类型"`
Manufacturer string `json:"manufacturer,omitempty" description:"制造商"`
MaxSimSlots int `json:"max_sim_slots,omitempty" description:"最大插槽数"`
BoundCardCount int `json:"bound_card_count,omitempty" description:"绑定卡数量"`
Cards []BoundCardInfo `json:"cards,omitempty" description:"绑定卡列表含每张卡的ICCID/MSISDN/网络状态/实名状态/插槽位置)"`
DeviceProtectStatus string `json:"device_protect_status,omitempty" description:"设备保护期状态none:无, stop:停机保护, start:开机保护)"`
// === 卡专属字段asset_type=card 时有效) ===
ICCID string `json:"iccid,omitempty" description:"卡ICCID"`
MSISDN string `json:"msisdn,omitempty" description:"手机号"`
CarrierID uint `json:"carrier_id,omitempty" description:"运营商ID"`
CarrierType string `json:"carrier_type,omitempty" description:"运营商类型CMCC/CUCC/CTCC/CBN"`
NetworkStatus int `json:"network_status,omitempty" description:"网络状态0:停机, 1:开机)"`
ActivationStatus int `json:"activation_status,omitempty" description:"激活状态0:未激活, 1:已激活)"`
CardCategory string `json:"card_category,omitempty" description:"卡业务类型normal:普通卡, industry:行业卡)"`
// === 卡绑定设备信息asset_type=card 且绑定了设备时有效) ===
BoundDeviceID *uint `json:"bound_device_id,omitempty" description:"绑定的设备ID"`
BoundDeviceNo string `json:"bound_device_no,omitempty" description:"绑定的设备虚拟号"`
BoundDeviceName string `json:"bound_device_name,omitempty" description:"绑定的设备名称"`
// === 设备实时状态(来自 Gateway同步接口对接后自动填充当前返回 null ===
DeviceRealtime *DeviceRealtimeInfo `json:"device_realtime,omitempty" description:"设备实时状态Gateway 同步接口对接后填充,当前为 null"`
}
// DeviceRealtimeInfo 设备实时状态信息
// 全量映射 Gateway DeviceInfoDetail 结构,所有字段均为可选
// 当前 Gateway 同步接口尚未对接,预留结构待后续填充
type DeviceRealtimeInfo struct {
// === 设备状态 ===
OnlineStatus *int64 `json:"online_status,omitempty" description:"在线状态1:在线, 2:离线)"`
BatteryLevel *int64 `json:"battery_level,omitempty" description:"电池电量百分比"`
Status *int64 `json:"status,omitempty" description:"设备状态1:正常, 0:禁用)"`
RunTime *string `json:"run_time,omitempty" description:"设备本次开机运行时间(秒)"`
ConnectTime *string `json:"connect_time,omitempty" description:"设备本次联网时间(秒)"`
LastOnlineTime *string `json:"last_online_time,omitempty" description:"设备最后在线时间"`
LastUpdateTime *string `json:"last_update_time,omitempty" description:"设备信息最后更新时间"`
// === 信号相关 ===
Rsrp *int64 `json:"rsrp,omitempty" description:"参考信号接收功率(dBm)"`
Rsrq *int64 `json:"rsrq,omitempty" description:"参考信号接收质量(dB)"`
Rssi *string `json:"rssi,omitempty" description:"接收信号强度"`
Sinr *int64 `json:"sinr,omitempty" description:"信噪比(dB)"`
// === WiFi 相关 ===
SSID *string `json:"ssid,omitempty" description:"WiFi热点名称"`
WifiEnabled *bool `json:"wifi_enabled,omitempty" description:"WiFi开关状态"`
WifiPassword *string `json:"wifi_password,omitempty" description:"WiFi密码"`
// === 网络相关 ===
IPAddress *string `json:"ip_address,omitempty" description:"IP地址"`
WANIP *string `json:"wan_ip,omitempty" description:"基站分配IPv4地址"`
LANIP *string `json:"lan_ip,omitempty" description:"局域网网关IP地址"`
MACAddress *string `json:"mac_address,omitempty" description:"MAC地址"`
// === 流量与速率 ===
DailyUsage *string `json:"daily_usage,omitempty" description:"日使用流量(字节)"`
DLStats *string `json:"dl_stats,omitempty" description:"本次开机下载流量(字节)"`
ULStats *string `json:"ul_stats,omitempty" description:"本次开机上传流量(字节)"`
LimitSpeed *int64 `json:"limit_speed,omitempty" description:"限速速率(KB/s)"`
// === 设备属性 ===
CurrentIccid *string `json:"current_iccid,omitempty" description:"当前使用的ICCID"`
MaxClients *int64 `json:"max_clients,omitempty" description:"最大连接客户端数"`
SoftwareVersion *string `json:"software_version,omitempty" description:"软件版本号"`
SwitchMode *int `json:"switch_mode,omitempty" description:"切卡模式0:自动, 1:手动)"`
SyncInterval *int64 `json:"sync_interval,omitempty" description:"信息上报周期(秒)"`
// === Gateway 原始标识字段 ===
DeviceID *string `json:"device_id,omitempty" description:"Gateway设备ID(IMEI/SN)"`
DeviceName *string `json:"device_name,omitempty" description:"Gateway返回的设备名称"`
DeviceType *string `json:"device_type,omitempty" description:"Gateway返回的设备型号"`
Imei *string `json:"imei,omitempty" description:"Gateway返回的IMEI号"`
Imsi *string `json:"imsi,omitempty" description:"Gateway返回的IMSI"`
CreatedAt *int64 `json:"created_at,omitempty" description:"Gateway创建时间(Unix时间戳)"`
UpdatedAt *int64 `json:"updated_at,omitempty" description:"Gateway更新时间(Unix时间戳)"`
}
// ========================================
// B2 资产可购套餐列表
// ========================================
// AssetPackageListRequest B2 资产可购套餐列表请求
type AssetPackageListRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// ClientPackageItem B2 客户端套餐项
type ClientPackageItem struct {
PackageID uint `json:"package_id" description:"套餐ID"`
PackageName string `json:"package_name" description:"套餐名称"`
PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:加油包)"`
RetailPrice int64 `json:"retail_price" description:"零售价(分)"`
CostPrice int64 `json:"cost_price" description:"成本价(分)"`
ValidityDays int `json:"validity_days" description:"有效天数"`
IsAddon bool `json:"is_addon" description:"是否加油包"`
DataAllowance int64 `json:"data_allowance" description:"流量额度"`
DataUnit string `json:"data_unit" description:"流量单位"`
Description string `json:"description" description:"套餐说明"`
}
// AssetPackageListResponse B2 资产可购套餐列表响应
type AssetPackageListResponse struct {
Packages []ClientPackageItem `json:"packages" description:"套餐列表"`
}
// ========================================
// B3 资产套餐历史
// ========================================
// AssetPackageHistoryRequest B3 资产套餐历史请求
type AssetPackageHistoryRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
PackageType *string `json:"package_type" query:"package_type" validate:"omitempty,oneof=formal addon" description:"套餐类型 (formal:正式套餐, addon:加油包)"`
Status *int `json:"status" query:"status" validate:"omitempty,min=0,max=4" minimum:"0" maximum:"4" description:"套餐状态 (0:待生效, 1:生效中, 2:已用完, 3:已过期, 4:已失效)"`
Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
}
// AssetPackageHistoryResponse B3 资产套餐历史响应
type AssetPackageHistoryResponse struct {
List []AssetPackageResponse `json:"list" description:"套餐历史列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"页码"`
PageSize int `json:"page_size" description:"每页数量"`
}
// ========================================
// B4 资产刷新
// ========================================
// AssetRefreshRequest B4 资产刷新请求
type AssetRefreshRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// AssetRefreshResponse B4 资产刷新响应
type AssetRefreshResponse struct {
RefreshType string `json:"refresh_type" description:"刷新类型 (card:卡, device:设备)"`
Accepted bool `json:"accepted" description:"是否已受理"`
CooldownSeconds int `json:"cooldown_seconds" description:"冷却秒数"`
}

View File

@@ -0,0 +1,103 @@
package dto
// ========================================
// A1 资产验证
// ========================================
// VerifyAssetRequest A1 资产验证请求
type VerifyAssetRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// VerifyAssetResponse A1 资产验证响应
type VerifyAssetResponse struct {
AssetToken string `json:"asset_token" description:"资产令牌5分钟有效"`
ExpiresIn int `json:"expires_in" description:"过期时间(秒)"`
}
// ========================================
// A2 公众号登录
// ========================================
// WechatLoginRequest A2 公众号登录请求
type WechatLoginRequest struct {
Code string `json:"code" validate:"required" required:"true" description:"微信OAuth授权码"`
AssetToken string `json:"asset_token" validate:"required" required:"true" description:"A1返回的资产令牌"`
}
// WechatLoginResponse A2/A3 登录统一响应
type WechatLoginResponse struct {
Token string `json:"token" description:"登录JWT令牌"`
NeedBindPhone bool `json:"need_bind_phone" description:"是否需要绑定手机号"`
IsNewUser bool `json:"is_new_user" description:"是否新创建用户"`
}
// ========================================
// A3 小程序登录
// ========================================
// MiniappLoginRequest A3 小程序登录请求
type MiniappLoginRequest struct {
Code string `json:"code" validate:"required" required:"true" description:"小程序登录凭证"`
AssetToken string `json:"asset_token" validate:"required" required:"true" description:"A1返回的资产令牌"`
Nickname string `json:"nickname" description:"用户昵称(前端授权后传入)"`
AvatarURL string `json:"avatar_url" description:"用户头像URL前端授权后传入"`
}
// ========================================
// A4 发送验证码
// ========================================
// ClientSendCodeRequest A4 发送验证码请求
type ClientSendCodeRequest struct {
Phone string `json:"phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"手机号"`
Scene string `json:"scene" validate:"required,oneof=bind_phone change_phone_old change_phone_new" required:"true" description:"业务场景 (bind_phone:绑定手机号, change_phone_old:换绑旧手机, change_phone_new:换绑新手机)"`
}
// ClientSendCodeResponse A4 发送验证码响应
type ClientSendCodeResponse struct {
CooldownSeconds int `json:"cooldown_seconds" description:"冷却秒数"`
}
// ========================================
// A5 绑定手机号
// ========================================
// BindPhoneRequest A5 绑定手机号请求
type BindPhoneRequest struct {
Phone string `json:"phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"手机号"`
Code string `json:"code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"验证码"`
}
// BindPhoneResponse A5 绑定手机号响应
type BindPhoneResponse struct {
Phone string `json:"phone" description:"已绑定手机号"`
BoundAt string `json:"bound_at" description:"绑定时间"`
}
// ========================================
// A6 换绑手机号
// ========================================
// ChangePhoneRequest A6 换绑手机号请求
type ChangePhoneRequest struct {
OldPhone string `json:"old_phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"旧手机号"`
OldCode string `json:"old_code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"旧手机号验证码"`
NewPhone string `json:"new_phone" validate:"required,len=11" required:"true" minLength:"11" maxLength:"11" description:"新手机号"`
NewCode string `json:"new_code" validate:"required,len=6" required:"true" minLength:"6" maxLength:"6" description:"新手机号验证码"`
}
// ChangePhoneResponse A6 换绑手机号响应
type ChangePhoneResponse struct {
Phone string `json:"phone" description:"换绑后手机号"`
ChangedAt string `json:"changed_at" description:"换绑时间"`
}
// ========================================
// A7 退出登录
// ========================================
// LogoutResponse A7 退出登录响应
type LogoutResponse struct {
Success bool `json:"success" description:"是否成功"`
}

View File

@@ -0,0 +1,113 @@
package dto
// ========================================
// D1 客户端创建订单
// ========================================
// ClientCreateOrderRequest D1 客户端创建订单请求
type ClientCreateOrderRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
PackageIDs []uint `json:"package_ids" validate:"required,min=1,dive,gt=0" required:"true" description:"套餐ID列表"`
AppType string `json:"app_type" validate:"required,oneof=official_account miniapp" required:"true" description:"应用类型 (official_account:公众号, miniapp:小程序)"`
}
// ClientCreateOrderResponse D1 客户端创建订单响应
type ClientCreateOrderResponse struct {
OrderType string `json:"order_type" description:"订单类型 (package:套餐订单, recharge:充值订单)"`
Order *ClientOrderInfo `json:"order,omitempty" description:"套餐订单信息"`
Recharge *ClientRechargeInfo `json:"recharge,omitempty" description:"充值订单信息"`
PayConfig *ClientPayConfig `json:"pay_config" description:"支付配置"`
LinkedPackageInfo *LinkedPackageInfo `json:"linked_package_info,omitempty" description:"关联套餐信息"`
}
// ClientOrderInfo D1 套餐订单信息
type ClientOrderInfo struct {
OrderID uint `json:"order_id" description:"订单ID"`
OrderNo string `json:"order_no" description:"订单号"`
TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"`
PaymentStatus int `json:"payment_status" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"`
CreatedAt string `json:"created_at" description:"创建时间"`
}
// ClientRechargeInfo D1 充值订单信息
type ClientRechargeInfo struct {
RechargeID uint `json:"recharge_id" description:"充值ID"`
RechargeNo string `json:"recharge_no" description:"充值单号"`
Amount int64 `json:"amount" description:"充值金额(分)"`
Status int `json:"status" description:"状态 (0:待支付, 1:已支付, 2:已关闭)"`
AutoPurchaseStatus string `json:"auto_purchase_status" description:"自动购包状态"`
}
// ClientPayConfig D1 支付配置
type ClientPayConfig struct {
AppID string `json:"app_id" description:"应用ID"`
Timestamp string `json:"timestamp" description:"时间戳"`
NonceStr string `json:"nonce_str" description:"随机字符串"`
PackageVal string `json:"package" description:"预支付参数"`
SignType string `json:"sign_type" description:"签名类型"`
PaySign string `json:"pay_sign" description:"支付签名"`
}
// LinkedPackageInfo D1 关联套餐信息
type LinkedPackageInfo struct {
PackageNames []string `json:"package_names" description:"套餐名称列表"`
TotalPackageAmount int64 `json:"total_package_amount" description:"套餐总金额(分)"`
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"`
WalletCredit int64 `json:"wallet_credit" description:"钱包抵扣金额(分)"`
}
// ========================================
// D2 客户端订单列表
// ========================================
// ClientOrderListRequest D2 客户端订单列表请求
type ClientOrderListRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
PaymentStatus *int `json:"payment_status" query:"payment_status" validate:"omitempty,min=0,max=2" minimum:"0" maximum:"2" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"`
Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
}
// ClientOrderListItem D2 客户端订单列表项
type ClientOrderListItem struct {
OrderID uint `json:"order_id" description:"订单ID"`
OrderNo string `json:"order_no" description:"订单号"`
TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"`
PaymentStatus int `json:"payment_status" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"`
CreatedAt string `json:"created_at" description:"创建时间"`
PackageNames []string `json:"package_names" description:"套餐名称列表"`
}
// ClientOrderListResponse D2 客户端订单列表响应
type ClientOrderListResponse struct {
List []ClientOrderListItem `json:"list" description:"订单列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"页码"`
PageSize int `json:"page_size" description:"每页数量"`
}
// ========================================
// D3 客户端订单详情
// ========================================
// ClientOrderDetailResponse D3 客户端订单详情响应
type ClientOrderDetailResponse struct {
OrderID uint `json:"order_id" description:"订单ID"`
OrderNo string `json:"order_no" description:"订单号"`
TotalAmount int64 `json:"total_amount" description:"订单总金额(分)"`
PaymentStatus int `json:"payment_status" description:"支付状态 (0:待支付, 1:已支付, 2:已取消)"`
PaymentMethod string `json:"payment_method" description:"支付方式"`
CreatedAt string `json:"created_at" description:"创建时间"`
PaidAt *string `json:"paid_at,omitempty" description:"支付时间"`
CompletedAt *string `json:"completed_at,omitempty" description:"完成时间"`
Packages []ClientOrderPackageItem `json:"packages" description:"订单套餐列表"`
}
// ClientOrderPackageItem D3 订单套餐项
type ClientOrderPackageItem struct {
PackageID uint `json:"package_id" description:"套餐ID"`
PackageName string `json:"package_name" description:"套餐名称"`
PackageType string `json:"package_type" description:"套餐类型 (formal:正式套餐, addon:加油包)"`
Price int64 `json:"price" description:"单价(分)"`
Quantity int `json:"quantity" description:"数量"`
}

View File

@@ -0,0 +1,104 @@
package dto
// ========================================
// E1 实名链接获取
// ========================================
// RealnimeLinkRequest E1 实名链接请求
type RealnimeLinkRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
ICCID string `json:"iccid" query:"iccid" validate:"omitempty,max=30" maxLength:"30" description:"物联网卡ICCID"`
}
// RealnimeLinkResponse E1 实名链接响应
type RealnimeLinkResponse struct {
RealnameMode string `json:"realname_mode" description:"实名模式 (none:无需实名, template:模板实名, gateway:网关实名)"`
RealnameURL string `json:"realname_url" description:"实名链接"`
CardInfo CardInfoBrief `json:"card_info" description:"卡片简要信息"`
ExpireAt *string `json:"expire_at,omitempty" description:"过期时间"`
}
// CardInfoBrief E1 卡片简要信息
type CardInfoBrief struct {
ICCID string `json:"iccid" description:"物联网卡ICCID"`
MSISDN string `json:"msisdn" description:"手机号"`
VirtualNo string `json:"virtual_no" description:"虚拟号"`
}
// ========================================
// F1 设备卡列表
// ========================================
// DeviceCardListRequest F1 设备卡列表请求
type DeviceCardListRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// DeviceCardItem F1 设备卡项
type DeviceCardItem struct {
CardID uint `json:"card_id" description:"卡ID"`
ICCID string `json:"iccid" description:"物联网卡ICCID"`
MSISDN string `json:"msisdn" description:"手机号"`
CarrierName string `json:"carrier_name" description:"运营商名称"`
NetworkStatus string `json:"network_status" description:"网络状态"`
RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"`
SlotPosition int `json:"slot_position" description:"插槽位置"`
IsActive bool `json:"is_active" description:"是否当前激活卡"`
}
// DeviceCardListResponse F1 设备卡列表响应
type DeviceCardListResponse struct {
Cards []DeviceCardItem `json:"cards" description:"设备卡列表"`
}
// ========================================
// F2 设备重启
// ========================================
// DeviceRebootRequest F2 设备重启请求
type DeviceRebootRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// DeviceOperationResponse F2/F3/F4 设备操作响应
type DeviceOperationResponse struct {
Accepted bool `json:"accepted" description:"是否已受理"`
RequestID string `json:"request_id,omitempty" description:"请求ID"`
}
// ========================================
// F3 恢复出厂设置
// ========================================
// DeviceFactoryResetRequest F3 恢复出厂设置请求
type DeviceFactoryResetRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// ========================================
// F4 设备WiFi配置
// ========================================
// DeviceWifiRequest F4 设备WiFi配置请求
type DeviceWifiRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
SSID string `json:"ssid" validate:"required,min=1,max=32" required:"true" minLength:"1" maxLength:"32" description:"WiFi名称"`
Password string `json:"password" validate:"required,min=1,max=64" required:"true" minLength:"1" maxLength:"64" description:"WiFi密码"`
Enabled bool `json:"enabled" validate:"required" required:"true" description:"是否启用WiFi"`
}
// ========================================
// F5 设备切卡
// ========================================
// DeviceSwitchCardRequest F5 设备切卡请求
type DeviceSwitchCardRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
TargetICCID string `json:"target_iccid" validate:"required,min=1,max=30" required:"true" minLength:"1" maxLength:"30" description:"目标ICCID"`
}
// DeviceSwitchCardResponse F5 设备切卡响应
type DeviceSwitchCardResponse struct {
Accepted bool `json:"accepted" description:"是否已受理"`
TargetICCID string `json:"target_iccid" description:"目标ICCID"`
}

View File

@@ -0,0 +1,138 @@
package dto
// ========================================
// C1 钱包详情
// ========================================
// WalletDetailRequest C1 钱包详情请求
type WalletDetailRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// WalletDetailResponse C1 钱包详情响应
type WalletDetailResponse struct {
WalletID uint `json:"wallet_id" description:"钱包ID"`
ResourceType string `json:"resource_type" description:"资源类型 (iot_card:物联网卡, device:设备)"`
ResourceID uint `json:"resource_id" description:"资源ID"`
Balance int64 `json:"balance" description:"可用余额(分)"`
FrozenBalance int64 `json:"frozen_balance" description:"冻结余额(分)"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
// ========================================
// C2 钱包流水列表
// ========================================
// WalletTransactionListRequest C2 钱包流水列表请求
type WalletTransactionListRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
TransactionType string `json:"transaction_type" query:"transaction_type" validate:"omitempty,max=50" maxLength:"50" description:"流水类型"`
StartTime string `json:"start_time" query:"start_time" validate:"omitempty,max=32" maxLength:"32" description:"开始时间"`
EndTime string `json:"end_time" query:"end_time" validate:"omitempty,max=32" maxLength:"32" description:"结束时间"`
Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
}
// WalletTransactionItem C2 钱包流水项
type WalletTransactionItem struct {
TransactionID uint `json:"transaction_id" description:"流水ID"`
Type string `json:"type" description:"流水类型"`
Amount int64 `json:"amount" description:"变动金额(分)"`
BalanceAfter int64 `json:"balance_after" description:"变动后余额(分)"`
CreatedAt string `json:"created_at" description:"创建时间"`
Remark string `json:"remark" description:"备注"`
}
// WalletTransactionListResponse C2 钱包流水列表响应
type WalletTransactionListResponse struct {
List []WalletTransactionItem `json:"list" description:"流水列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"页码"`
PageSize int `json:"page_size" description:"每页数量"`
}
// ========================================
// C3 充值前校验
// ========================================
// ClientRechargeCheckRequest C3 充值前校验请求
type ClientRechargeCheckRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
}
// ClientRechargeCheckResponse C3 充值前校验响应
type ClientRechargeCheckResponse struct {
NeedForceRecharge bool `json:"need_force_recharge" description:"是否需要强制充值"`
ForceRechargeAmount int64 `json:"force_recharge_amount" description:"强制充值金额(分)"`
TriggerType string `json:"trigger_type" description:"触发类型"`
MinAmount int64 `json:"min_amount" description:"最小充值金额(分)"`
MaxAmount int64 `json:"max_amount" description:"最大充值金额(分)"`
Message string `json:"message" description:"提示信息"`
}
// ========================================
// C4 创建充值订单
// ========================================
// ClientCreateRechargeRequest C4 创建充值订单请求
type ClientCreateRechargeRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
Amount int64 `json:"amount" validate:"required,min=100,max=10000000" required:"true" minimum:"100" maximum:"10000000" description:"充值金额(分)"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat" required:"true" description:"支付方式 (wechat:微信支付)"`
AppType string `json:"app_type" validate:"required,oneof=official_account miniapp" required:"true" description:"应用类型 (official_account:公众号, miniapp:小程序)"`
}
// ClientRechargeResponse C4 创建充值订单响应
type ClientRechargeResponse struct {
Recharge ClientRechargeResult `json:"recharge" description:"充值信息"`
PayConfig ClientRechargePayConfig `json:"pay_config" description:"支付配置"`
}
// ClientRechargeResult C4 充值信息
type ClientRechargeResult struct {
RechargeID uint `json:"recharge_id" description:"充值ID"`
RechargeNo string `json:"recharge_no" description:"充值单号"`
Amount int64 `json:"amount" description:"充值金额(分)"`
Status int `json:"status" description:"状态 (0:待支付, 1:已支付, 2:已关闭)"`
}
// ClientRechargePayConfig C4 支付配置
type ClientRechargePayConfig struct {
AppID string `json:"app_id" description:"应用ID"`
Timestamp string `json:"timestamp" description:"时间戳"`
NonceStr string `json:"nonce_str" description:"随机字符串"`
PackageVal string `json:"package" description:"预支付参数"`
SignType string `json:"sign_type" description:"签名类型"`
PaySign string `json:"pay_sign" description:"支付签名"`
}
// ========================================
// C5 充值记录列表
// ========================================
// ClientRechargeListRequest C5 充值记录列表请求
type ClientRechargeListRequest struct {
Identifier string `json:"identifier" query:"identifier" validate:"required,min=1,max=50" required:"true" minLength:"1" maxLength:"50" description:"资产标识符SN/IMEI/虚拟号/ICCID/MSISDN"`
Status *int `json:"status" query:"status" validate:"omitempty,min=0,max=2" minimum:"0" maximum:"2" description:"充值状态 (0:待支付, 1:已支付, 2:已关闭)"`
Page int `json:"page" query:"page" validate:"required,min=1" required:"true" minimum:"1" description:"页码"`
PageSize int `json:"page_size" query:"page_size" validate:"required,min=1,max=100" required:"true" minimum:"1" maximum:"100" description:"每页数量"`
}
// ClientRechargeListItem C5 充值记录项
type ClientRechargeListItem struct {
RechargeID uint `json:"recharge_id" description:"充值ID"`
RechargeNo string `json:"recharge_no" description:"充值单号"`
Amount int64 `json:"amount" description:"充值金额(分)"`
Status int `json:"status" description:"状态 (0:待支付, 1:已支付, 2:已关闭)"`
PaymentMethod string `json:"payment_method" description:"支付方式"`
CreatedAt string `json:"created_at" description:"创建时间"`
AutoPurchaseStatus string `json:"auto_purchase_status" description:"自动购包状态"`
}
// ClientRechargeListResponse C5 充值记录列表响应
type ClientRechargeListResponse struct {
List []ClientRechargeListItem `json:"list" description:"充值记录列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"页码"`
PageSize int `json:"page_size" description:"每页数量"`
}

View File

@@ -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:"创建时间"`
}

View File

@@ -56,6 +56,11 @@ type UpdatePackageShelfStatusRequest struct {
ShelfStatus int `json:"shelf_status" validate:"required,oneof=1 2" required:"true" description:"上架状态 (1:上架, 2:下架)"`
}
// UpdateRetailPriceRequest 更新零售价请求
type UpdateRetailPriceRequest struct {
RetailPrice int64 `json:"retail_price" validate:"required,min=0" required:"true" minimum:"0" description:"零售价(单位:分)"`
}
// CommissionTierInfo 返佣梯度信息
type CommissionTierInfo struct {
CurrentRate string `json:"current_rate" description:"当前返佣比例"`
@@ -83,6 +88,7 @@ type PackageResponse struct {
ShelfStatus int `json:"shelf_status" description:"上架状态 (1:上架, 2:下架)"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
RetailPrice *int64 `json:"retail_price,omitempty" description:"代理零售价(分),仅代理用户可见"`
ProfitMargin *int64 `json:"profit_margin,omitempty" description:"利润空间(分,仅代理用户可见)"`
CurrentCommissionRate string `json:"current_commission_rate,omitempty" description:"当前返佣比例(仅代理用户可见)"`
TierInfo *CommissionTierInfo `json:"tier_info,omitempty" description:"梯度返佣信息(仅代理用户可见)"`
@@ -110,6 +116,12 @@ type UpdatePackageShelfStatusParams struct {
UpdatePackageShelfStatusRequest
}
// UpdateRetailPriceParams 更新零售价聚合参数
type UpdateRetailPriceParams struct {
IDReq
UpdateRetailPriceRequest
}
// PackagePageResult 套餐分页结果
type PackagePageResult struct {
List []*PackageResponse `json:"list" description:"套餐列表"`

View File

@@ -12,4 +12,11 @@ type BatchUpdateCostPriceRequest struct {
type BatchUpdateCostPriceResponse struct {
UpdatedCount int `json:"updated_count" description:"更新数量"`
AffectedIDs []uint `json:"affected_ids" description:"受影响的分配ID列表"`
Skipped []BatchPricingSkipped `json:"skipped,omitempty" description:"跳过的记录"`
}
// BatchPricingSkipped 批量调价跳过记录
type BatchPricingSkipped struct {
AllocationID uint `json:"allocation_id" description:"分配ID"`
Reason string `json:"reason" description:"跳过原因"`
}

View File

@@ -0,0 +1,192 @@
package dto
import (
"fmt"
"github.com/break/junhong_cmp_fiber/internal/model"
)
// CreateWechatConfigRequest 创建微信参数配置请求
type CreateWechatConfigRequest struct {
Name string `json:"name" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"配置名称"`
Description string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置描述"`
ProviderType string `json:"provider_type" validate:"required,oneof=wechat fuiou" required:"true" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"`
OaAppID string `json:"oa_app_id" validate:"omitempty,max=100" maxLength:"100" description:"公众号AppID"`
OaAppSecret string `json:"oa_app_secret" validate:"omitempty,max=200" maxLength:"200" description:"公众号AppSecret"`
OaToken string `json:"oa_token" validate:"omitempty,max=200" maxLength:"200" description:"公众号Token"`
OaAesKey string `json:"oa_aes_key" validate:"omitempty,max=200" maxLength:"200" description:"公众号AES加密Key"`
OaOAuthRedirectURL string `json:"oa_oauth_redirect_url" validate:"omitempty,max=500" maxLength:"500" description:"OAuth回调地址"`
MiniappAppID string `json:"miniapp_app_id" validate:"omitempty,max=100" maxLength:"100" description:"小程序AppID"`
MiniappAppSecret string `json:"miniapp_app_secret" validate:"omitempty,max=200" maxLength:"200" description:"小程序AppSecret"`
WxMchID string `json:"wx_mch_id" validate:"omitempty,max=100" maxLength:"100" description:"微信商户号"`
WxAPIV3Key string `json:"wx_api_v3_key" validate:"omitempty,max=200" maxLength:"200" description:"微信APIv3密钥"`
WxAPIV2Key string `json:"wx_api_v2_key" validate:"omitempty,max=200" maxLength:"200" description:"微信APIv2密钥"`
WxCertContent string `json:"wx_cert_content" validate:"omitempty" description:"微信支付证书内容(PEM格式)"`
WxKeyContent string `json:"wx_key_content" validate:"omitempty" description:"微信支付密钥内容(PEM格式)"`
WxSerialNo string `json:"wx_serial_no" validate:"omitempty,max=200" maxLength:"200" description:"微信证书序列号"`
WxNotifyURL string `json:"wx_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"微信支付回调地址"`
FyInsCd string `json:"fy_ins_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友机构号"`
FyMchntCd string `json:"fy_mchnt_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友商户号"`
FyTermID string `json:"fy_term_id" validate:"omitempty,max=50" maxLength:"50" description:"富友终端号"`
FyPrivateKey string `json:"fy_private_key" validate:"omitempty" description:"富友私钥(PEM格式)"`
FyPublicKey string `json:"fy_public_key" validate:"omitempty" description:"富友公钥(PEM格式)"`
FyAPIURL string `json:"fy_api_url" validate:"omitempty,max=500" maxLength:"500" description:"富友API地址"`
FyNotifyURL string `json:"fy_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"富友支付回调地址"`
}
// UpdateWechatConfigRequest 更新微信参数配置请求
type UpdateWechatConfigRequest struct {
Name *string `json:"name" validate:"omitempty,min=1,max=100" minLength:"1" maxLength:"100" description:"配置名称"`
Description *string `json:"description" validate:"omitempty,max=500" maxLength:"500" description:"配置描述"`
ProviderType *string `json:"provider_type" validate:"omitempty,oneof=wechat fuiou" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"`
OaAppID *string `json:"oa_app_id" validate:"omitempty,max=100" maxLength:"100" description:"公众号AppID"`
OaAppSecret *string `json:"oa_app_secret" validate:"omitempty,max=200" maxLength:"200" description:"公众号AppSecret"`
OaToken *string `json:"oa_token" validate:"omitempty,max=200" maxLength:"200" description:"公众号Token"`
OaAesKey *string `json:"oa_aes_key" validate:"omitempty,max=200" maxLength:"200" description:"公众号AES加密Key"`
OaOAuthRedirectURL *string `json:"oa_oauth_redirect_url" validate:"omitempty,max=500" maxLength:"500" description:"OAuth回调地址"`
MiniappAppID *string `json:"miniapp_app_id" validate:"omitempty,max=100" maxLength:"100" description:"小程序AppID"`
MiniappAppSecret *string `json:"miniapp_app_secret" validate:"omitempty,max=200" maxLength:"200" description:"小程序AppSecret"`
WxMchID *string `json:"wx_mch_id" validate:"omitempty,max=100" maxLength:"100" description:"微信商户号"`
WxAPIV3Key *string `json:"wx_api_v3_key" validate:"omitempty,max=200" maxLength:"200" description:"微信APIv3密钥"`
WxAPIV2Key *string `json:"wx_api_v2_key" validate:"omitempty,max=200" maxLength:"200" description:"微信APIv2密钥"`
WxCertContent *string `json:"wx_cert_content" validate:"omitempty" description:"微信支付证书内容(PEM格式)"`
WxKeyContent *string `json:"wx_key_content" validate:"omitempty" description:"微信支付密钥内容(PEM格式)"`
WxSerialNo *string `json:"wx_serial_no" validate:"omitempty,max=200" maxLength:"200" description:"微信证书序列号"`
WxNotifyURL *string `json:"wx_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"微信支付回调地址"`
FyInsCd *string `json:"fy_ins_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友机构号"`
FyMchntCd *string `json:"fy_mchnt_cd" validate:"omitempty,max=50" maxLength:"50" description:"富友商户号"`
FyTermID *string `json:"fy_term_id" validate:"omitempty,max=50" maxLength:"50" description:"富友终端号"`
FyPrivateKey *string `json:"fy_private_key" validate:"omitempty" description:"富友私钥(PEM格式)"`
FyPublicKey *string `json:"fy_public_key" validate:"omitempty" description:"富友公钥(PEM格式)"`
FyAPIURL *string `json:"fy_api_url" validate:"omitempty,max=500" maxLength:"500" description:"富友API地址"`
FyNotifyURL *string `json:"fy_notify_url" validate:"omitempty,max=500" maxLength:"500" description:"富友支付回调地址"`
}
// UpdateWechatConfigParams 更新微信参数配置聚合参数 (用于文档生成)
type UpdateWechatConfigParams struct {
IDReq
UpdateWechatConfigRequest
}
// WechatConfigListRequest 微信参数配置列表查询请求
type WechatConfigListRequest 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:"每页数量"`
ProviderType *string `json:"provider_type" query:"provider_type" validate:"omitempty,oneof=wechat fuiou" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"`
IsActive *bool `json:"is_active" query:"is_active" description:"是否激活 (true:已激活, false:未激活)"`
}
// WechatConfigResponse 微信参数配置响应
type WechatConfigResponse struct {
ID uint `json:"id" description:"配置ID"`
Name string `json:"name" description:"配置名称"`
Description string `json:"description" description:"配置描述"`
ProviderType string `json:"provider_type" description:"支付渠道类型 (wechat:微信直连, fuiou:富友)"`
IsActive bool `json:"is_active" description:"是否激活"`
OaAppID string `json:"oa_app_id" description:"公众号AppID"`
OaAppSecret string `json:"oa_app_secret" description:"公众号AppSecret(已脱敏)"`
OaToken string `json:"oa_token" description:"公众号Token(已脱敏)"`
OaAesKey string `json:"oa_aes_key" description:"公众号AES加密Key(已脱敏)"`
OaOAuthRedirectURL string `json:"oa_oauth_redirect_url" description:"OAuth回调地址"`
MiniappAppID string `json:"miniapp_app_id" description:"小程序AppID"`
MiniappAppSecret string `json:"miniapp_app_secret" description:"小程序AppSecret(已脱敏)"`
WxMchID string `json:"wx_mch_id" description:"微信商户号"`
WxAPIV3Key string `json:"wx_api_v3_key" description:"微信APIv3密钥(已脱敏)"`
WxAPIV2Key string `json:"wx_api_v2_key" description:"微信APIv2密钥(已脱敏)"`
WxCertContent string `json:"wx_cert_content" description:"微信支付证书内容(配置状态)"`
WxKeyContent string `json:"wx_key_content" description:"微信支付密钥内容(配置状态)"`
WxSerialNo string `json:"wx_serial_no" description:"微信证书序列号"`
WxNotifyURL string `json:"wx_notify_url" description:"微信支付回调地址"`
FyInsCd string `json:"fy_ins_cd" description:"富友机构号"`
FyMchntCd string `json:"fy_mchnt_cd" description:"富友商户号"`
FyTermID string `json:"fy_term_id" description:"富友终端号"`
FyPrivateKey string `json:"fy_private_key" description:"富友私钥(配置状态)"`
FyPublicKey string `json:"fy_public_key" description:"富友公钥(配置状态)"`
FyAPIURL string `json:"fy_api_url" description:"富友API地址"`
FyNotifyURL string `json:"fy_notify_url" description:"富友支付回调地址"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
// WechatConfigListResponse 微信参数配置列表响应
type WechatConfigListResponse struct {
List []*WechatConfigResponse `json:"list" description:"配置列表"`
Total int64 `json:"total" description:"总数"`
Page int `json:"page" description:"当前页"`
PageSize int `json:"page_size" description:"每页数量"`
}
// MaskShortSecret 对短密钥进行脱敏处理
// 长度小于 8 返回 "***"否则保留前4位和后4位
func MaskShortSecret(val string) string {
if len(val) < 8 {
return "***"
}
return fmt.Sprintf("%s***%s", val[:4], val[len(val)-4:])
}
// MaskLongSecret 对长密钥/证书进行脱敏处理
// 空值返回 "[未配置]",否则返回 "[已配置]"
func MaskLongSecret(val string) string {
if val == "" {
return "[未配置]"
}
return "[已配置]"
}
// FromWechatConfigModel 从模型转换为响应 DTO敏感字段自动脱敏
func FromWechatConfigModel(m *model.WechatConfig) *WechatConfigResponse {
resp := &WechatConfigResponse{
ID: m.ID,
Name: m.Name,
ProviderType: m.ProviderType,
IsActive: m.IsActive,
OaAppID: m.OaAppID,
OaAppSecret: MaskShortSecret(m.OaAppSecret),
OaToken: MaskShortSecret(m.OaToken),
OaAesKey: MaskShortSecret(m.OaAesKey),
OaOAuthRedirectURL: m.OaOAuthRedirectURL,
MiniappAppID: m.MiniappAppID,
MiniappAppSecret: MaskShortSecret(m.MiniappAppSecret),
WxMchID: m.WxMchID,
WxAPIV3Key: MaskShortSecret(m.WxAPIV3Key),
WxAPIV2Key: MaskShortSecret(m.WxAPIV2Key),
WxCertContent: MaskLongSecret(m.WxCertContent),
WxKeyContent: MaskLongSecret(m.WxKeyContent),
WxSerialNo: m.WxSerialNo,
WxNotifyURL: m.WxNotifyURL,
FyInsCd: m.FyInsCd,
FyMchntCd: m.FyMchntCd,
FyTermID: m.FyTermID,
FyPrivateKey: MaskLongSecret(m.FyPrivateKey),
FyPublicKey: MaskLongSecret(m.FyPublicKey),
FyAPIURL: m.FyAPIURL,
FyNotifyURL: m.FyNotifyURL,
CreatedAt: m.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: m.UpdatedAt.Format("2006-01-02 15:04:05"),
}
if m.Description != nil {
resp.Description = *m.Description
}
return resp
}

View File

@@ -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)
}

View File

@@ -48,6 +48,8 @@ type IotCard struct {
StoppedAt *time.Time `gorm:"column:stopped_at;comment:停机时间" json:"stopped_at,omitempty"`
ResumedAt *time.Time `gorm:"column:resumed_at;comment:最近复机时间" json:"resumed_at,omitempty"`
StopReason string `gorm:"column:stop_reason;type:varchar(50);comment:停机原因(traffic_exhausted=流量耗尽,manual=手动停机,arrears=欠费)" json:"stop_reason,omitempty"`
AssetStatus int `gorm:"column:asset_status;type:int;not null;default:1;comment:业务状态 1-在库 2-已销售 3-已换货 4-已停用" json:"asset_status"`
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
IsStandalone bool `gorm:"column:is_standalone;type:boolean;default:true;not null;comment:是否为独立卡(未绑定设备) 由触发器自动维护" json:"is_standalone"`
VirtualNo string `gorm:"column:virtual_no;type:varchar(50);uniqueIndex:idx_iot_card_virtual_no,where:deleted_at IS NULL AND virtual_no IS NOT NULL AND virtual_no <> '';comment:虚拟号(可空,全局唯一)" json:"virtual_no,omitempty"`
}

View File

@@ -40,6 +40,10 @@ type Order struct {
SellerCostPrice int64 `gorm:"column:seller_cost_price;type:bigint;default:0;comment:销售成本价(分,用于计算利润)" json:"seller_cost_price"`
SeriesID *uint `gorm:"column:series_id;index;comment:系列ID用于查询分配配置" json:"series_id,omitempty"`
// 订单来源和世代
Source string `gorm:"column:source;type:varchar(20);not null;default:'admin';comment:订单来源 admin-后台 client-客户端" json:"source"`
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
// 代购信息
IsPurchaseOnBehalf bool `gorm:"column:is_purchase_on_behalf;type:boolean;default:false;comment:是否为代购订单" json:"is_purchase_on_behalf"`
@@ -55,6 +59,9 @@ type Order struct {
// 订单超时信息
ExpiresAt *time.Time `gorm:"column:expires_at;comment:订单过期时间(NULL表示不过期)" json:"expires_at,omitempty"`
// 支付配置
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
}
// TableName 指定表名

View File

@@ -79,6 +79,7 @@ type PackageUsage struct {
DataResetCycle string `gorm:"column:data_reset_cycle;type:varchar(20);comment:流量重置周期(从Package复制,用于历史记录)" json:"data_reset_cycle"`
LastResetAt *time.Time `gorm:"column:last_reset_at;comment:最后一次流量重置时间" json:"last_reset_at"`
NextResetAt *time.Time `gorm:"column:next_reset_at;index:idx_package_usage_next_reset_at;comment:下次流量重置时间(用于定时任务查询)" json:"next_reset_at"`
Generation int `gorm:"column:generation;type:int;not null;default:1;comment:资产世代编号" json:"generation"`
}
// TableName 指定表名

View File

@@ -9,7 +9,7 @@ import (
// 手机号、ICCID、设备号通过关联表存储
type PersonalCustomer struct {
gorm.Model
WxOpenID string `gorm:"column:wx_open_id;type:varchar(100);uniqueIndex:idx_personal_customer_wx_open_id,where:deleted_at IS NULL;not null;comment:微信OpenID唯一标识" json:"wx_open_id"`
WxOpenID string `gorm:"column:wx_open_id;type:varchar(100);index:idx_personal_customer_wx_open_id;not null;comment:微信OpenID唯一标识" json:"wx_open_id"`
WxUnionID string `gorm:"column:wx_union_id;type:varchar(100);index;not null;comment:微信UnionID" json:"wx_union_id"`
Nickname string `gorm:"column:nickname;type:varchar(100);comment:微信昵称" json:"nickname"`
AvatarURL string `gorm:"column:avatar_url;type:varchar(500);comment:微信头像URL" json:"avatar_url"`

View File

@@ -0,0 +1,23 @@
package model
import (
"gorm.io/gorm"
)
// PersonalCustomerOpenID 个人客户 OpenID 关联模型
// 保存客户在不同微信应用(公众号/小程序)下的 OpenID 记录
// 同一客户可在多个 AppID 下拥有不同的 OpenID
// 唯一约束UNIQUE(app_id, open_id) WHERE deleted_at IS NULL
type PersonalCustomerOpenID struct {
gorm.Model
CustomerID uint `gorm:"column:customer_id;type:bigint;not null;index:idx_pco_customer_id;comment:关联个人客户ID" json:"customer_id"`
AppID string `gorm:"column:app_id;type:varchar(100);not null;comment:微信应用标识公众号或小程序AppID" json:"app_id"`
OpenID string `gorm:"column:open_id;type:varchar(100);not null;comment:当前应用下的OpenID" json:"open_id"`
UnionID string `gorm:"column:union_id;type:varchar(100);not null;default:'';comment:微信开放平台统一标识(可选)" json:"union_id"`
AppType string `gorm:"column:app_type;type:varchar(20);not null;default:'';comment:应用类型official_account/miniapp" json:"app_type"`
}
// TableName 指定表名
func (PersonalCustomerOpenID) TableName() string {
return "tb_personal_customer_openid"
}

View File

@@ -14,6 +14,7 @@ type ShopPackageAllocation struct {
SeriesAllocationID *uint `gorm:"column:series_allocation_id;index;comment:关联的系列分配ID" json:"series_allocation_id"`
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1-启用 2-禁用" json:"status"`
ShelfStatus int `gorm:"column:shelf_status;type:int;default:1;not null;comment:上架状态 1-上架 2-下架" json:"shelf_status"`
RetailPrice int64 `gorm:"column:retail_price;type:bigint;not null;default:0;comment:代理面向终端客户的零售价(分)" json:"retail_price"`
}
// TableName 指定表名

View File

@@ -0,0 +1,56 @@
package model
import "gorm.io/gorm"
// 支付渠道类型常量
const (
ProviderTypeWechat = "wechat" // 微信直连
ProviderTypeFuiou = "fuiou" // 富友
)
// WechatConfig 微信参数配置模型
// 管理微信公众号 OAuth、小程序、微信直连支付、富友支付等多套配置
// 同一时间只有一条记录处于 is_active=true全局唯一生效配置
type WechatConfig struct {
gorm.Model
BaseModel `gorm:"embedded"`
Name string `gorm:"column:name;type:varchar(100);not null;comment:配置名称" json:"name"`
Description *string `gorm:"column:description;type:text;comment:配置描述" json:"description,omitempty"`
ProviderType string `gorm:"column:provider_type;type:varchar(20);not null;comment:支付渠道类型(wechat-微信直连,fuiou-富友)" json:"provider_type"`
IsActive bool `gorm:"column:is_active;type:boolean;not null;default:false;comment:是否激活(全局唯一)" json:"is_active"`
// OAuth 公众号
OaAppID string `gorm:"column:oa_app_id;type:varchar(100);not null;default:'';comment:公众号AppID" json:"oa_app_id"`
OaAppSecret string `gorm:"column:oa_app_secret;type:varchar(200);not null;default:'';comment:公众号AppSecret" json:"oa_app_secret"`
OaToken string `gorm:"column:oa_token;type:varchar(200);default:'';comment:公众号Token" json:"oa_token"`
OaAesKey string `gorm:"column:oa_aes_key;type:varchar(200);default:'';comment:公众号AES加密Key" json:"oa_aes_key"`
OaOAuthRedirectURL string `gorm:"column:oa_oauth_redirect_url;type:varchar(500);default:'';comment:OAuth回调地址" json:"oa_oauth_redirect_url"`
// OAuth 小程序
MiniappAppID string `gorm:"column:miniapp_app_id;type:varchar(100);default:'';comment:小程序AppID" json:"miniapp_app_id"`
MiniappAppSecret string `gorm:"column:miniapp_app_secret;type:varchar(200);default:'';comment:小程序AppSecret" json:"miniapp_app_secret"`
// 支付-微信直连
WxMchID string `gorm:"column:wx_mch_id;type:varchar(100);default:'';comment:微信商户号" json:"wx_mch_id"`
WxAPIV3Key string `gorm:"column:wx_api_v3_key;type:varchar(200);default:'';comment:微信APIv3密钥" json:"wx_api_v3_key"`
WxAPIV2Key string `gorm:"column:wx_api_v2_key;type:varchar(200);default:'';comment:微信APIv2密钥" json:"wx_api_v2_key"`
WxCertContent string `gorm:"column:wx_cert_content;type:text;default:'';comment:微信支付证书内容" json:"wx_cert_content"`
WxKeyContent string `gorm:"column:wx_key_content;type:text;default:'';comment:微信支付密钥内容" json:"wx_key_content"`
WxSerialNo string `gorm:"column:wx_serial_no;type:varchar(200);default:'';comment:微信证书序列号" json:"wx_serial_no"`
WxNotifyURL string `gorm:"column:wx_notify_url;type:varchar(500);default:'';comment:微信支付回调地址" json:"wx_notify_url"`
// 支付-富友
FyInsCd string `gorm:"column:fy_ins_cd;type:varchar(50);default:'';comment:富友机构号" json:"fy_ins_cd"`
FyMchntCd string `gorm:"column:fy_mchnt_cd;type:varchar(50);default:'';comment:富友商户号" json:"fy_mchnt_cd"`
FyTermID string `gorm:"column:fy_term_id;type:varchar(50);default:'';comment:富友终端号" json:"fy_term_id"`
FyPrivateKey string `gorm:"column:fy_private_key;type:text;default:'';comment:富友私钥" json:"fy_private_key"`
FyPublicKey string `gorm:"column:fy_public_key;type:text;default:'';comment:富友公钥" json:"fy_public_key"`
FyAPIURL string `gorm:"column:fy_api_url;type:varchar(500);default:'';comment:富友API地址" json:"fy_api_url"`
FyNotifyURL string `gorm:"column:fy_notify_url;type:varchar(500);default:'';comment:富友支付回调地址" json:"fy_notify_url"`
}
// TableName 指定表名
func (WechatConfig) TableName() string {
return "tb_wechat_config"
}

View File

@@ -59,6 +59,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.Device != nil {
registerDeviceRoutes(authGroup, handlers.Device, handlers.DeviceImport, doc, basePath)
}
if handlers.AssetLifecycle != nil {
registerAssetLifecycleRoutes(authGroup, handlers.AssetLifecycle, doc, basePath)
}
if handlers.AssetAllocationRecord != nil {
registerAssetAllocationRecordRoutes(authGroup, handlers.AssetAllocationRecord, doc, basePath)
}
@@ -89,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)
}
@@ -108,6 +114,12 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
registerPollingManualTriggerRoutes(authGroup, handlers.PollingManualTrigger, doc, basePath)
}
if handlers.Asset != nil {
registerAssetRoutes(authGroup, handlers.Asset, doc, basePath)
registerAssetRoutes(authGroup, handlers.Asset, handlers.AssetWallet, doc, basePath)
}
if handlers.WechatConfig != nil {
registerWechatConfigRoutes(authGroup, handlers.WechatConfig, doc, basePath)
}
if handlers.AgentRecharge != nil {
registerAgentRechargeRoutes(authGroup, handlers.AgentRecharge, doc, basePath)
}
}

View File

@@ -0,0 +1,55 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"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/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
func registerAgentRechargeRoutes(router fiber.Router, handler *admin.AgentRechargeHandler, doc *openapi.Generator, basePath string) {
group := router.Group("/agent-recharges", func(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType == constants.UserTypeEnterprise {
return errors.New(errors.CodeForbidden, "企业账号无权访问代理充值功能")
}
return c.Next()
})
groupPath := basePath + "/agent-recharges"
Register(group, doc, groupPath, "POST", "", handler.Create, RouteSpec{
Summary: "创建代理充值订单",
Tags: []string{"代理预充值"},
Input: new(dto.CreateAgentRechargeRequest),
Output: new(dto.AgentRechargeResponse),
Auth: true,
})
Register(group, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "查询代理充值订单列表",
Tags: []string{"代理预充值"},
Input: new(dto.AgentRechargeListRequest),
Output: new(dto.AgentRechargeListResponse),
Auth: true,
})
Register(group, doc, groupPath, "GET", "/:id", handler.Get, RouteSpec{
Summary: "查询代理充值订单详情",
Tags: []string{"代理预充值"},
Input: new(dto.IDReq),
Output: new(dto.AgentRechargeResponse),
Auth: true,
})
Register(group, doc, groupPath, "POST", "/:id/offline-pay", handler.OfflinePay, RouteSpec{
Summary: "确认线下充值",
Tags: []string{"代理预充值"},
Input: new(dto.AgentOfflinePayParams),
Output: new(dto.AgentRechargeResponse),
Auth: true,
})
}

View File

@@ -8,7 +8,7 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, doc *openapi.Generator, basePath string) {
func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, walletHandler *admin.AssetWalletHandler, doc *openapi.Generator, basePath string) {
assets := router.Group("/assets")
groupPath := basePath + "/assets"
@@ -91,4 +91,22 @@ func registerAssetRoutes(router fiber.Router, handler *admin.AssetHandler, doc *
Output: nil,
Auth: true,
})
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/wallet", walletHandler.GetWallet, RouteSpec{
Summary: "资产钱包概况",
Description: "查询指定卡或设备的钱包余额概况。企业账号禁止调用。",
Tags: []string{"资产管理"},
Input: new(dto.AssetTypeIDRequest),
Output: new(dto.AssetWalletResponse),
Auth: true,
})
Register(assets, doc, groupPath, "GET", "/:asset_type/:id/wallet/transactions", walletHandler.ListTransactions, RouteSpec{
Summary: "资产钱包流水列表",
Description: "分页查询指定资产的钱包收支流水,含充值/扣款来源编号。企业账号禁止调用。",
Tags: []string{"资产管理"},
Input: new(dto.AssetWalletTransactionListRequest),
Output: new(dto.AssetWalletTransactionListResponse),
Auth: true,
})
}

View File

@@ -0,0 +1,28 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"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"
)
// registerAssetLifecycleRoutes 注册资产手动停用路由
func registerAssetLifecycleRoutes(router fiber.Router, handler *admin.AssetLifecycleHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "PATCH", "/iot-cards/:id/deactivate", handler.DeactivateIotCard, RouteSpec{
Summary: "手动停用IoT卡",
Tags: []string{"IoT卡管理"},
Input: new(dto.IDReq),
Output: nil,
Auth: true,
})
Register(router, doc, basePath, "PATCH", "/devices/:id/deactivate", handler.DeactivateDevice, RouteSpec{
Summary: "手动停用设备",
Tags: []string{"设备管理"},
Input: new(dto.IDReq),
Output: nil,
Auth: true,
})
}

View File

@@ -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,
})
}

View File

@@ -1,27 +0,0 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// RegisterH5Routes 注册H5相关路由
func RegisterH5Routes(router fiber.Router, handlers *bootstrap.Handlers, middlewares *bootstrap.Middlewares, doc *openapi.Generator, basePath string) {
// 认证路由已迁移到 /api/auth参见 RegisterAuthRoutes
authGroup := router.Group("", middlewares.H5Auth)
if handlers.H5Order != nil {
registerH5OrderRoutes(authGroup, handlers.H5Order, doc, basePath)
}
if handlers.H5Recharge != nil {
registerH5RechargeRoutes(authGroup, handlers.H5Recharge, doc, basePath)
}
if handlers.EnterpriseDeviceH5 != nil {
registerH5EnterpriseDeviceRoutes(authGroup, handlers.EnterpriseDeviceH5, doc, basePath)
}
if handlers.H5PackageUsage != nil {
registerH5PackageUsageRoutes(authGroup, handlers.H5PackageUsage, doc, basePath)
}
}

View File

@@ -1,31 +0,0 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
func registerH5EnterpriseDeviceRoutes(router fiber.Router, handler *h5.EnterpriseDeviceHandler, doc *openapi.Generator, basePath string) {
devices := router.Group("/devices")
groupPath := basePath + "/devices"
Register(devices, doc, groupPath, "GET", "", handler.ListDevices, RouteSpec{
Summary: "企业设备列表H5",
Tags: []string{"H5-企业设备"},
Input: new(dto.H5EnterpriseDeviceListReq),
Output: new(dto.EnterpriseDeviceListResp),
Auth: true,
})
Register(devices, doc, groupPath, "GET", "/:device_id", handler.GetDeviceDetail, RouteSpec{
Summary: "获取设备详情H5",
Tags: []string{"H5-企业设备"},
Input: new(dto.DeviceDetailReq),
Output: new(dto.EnterpriseDeviceDetailResp),
Auth: true,
})
}

View File

@@ -1,23 +0,0 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerH5PackageUsageRoutes 注册 H5 端套餐使用情况路由
func registerH5PackageUsageRoutes(router fiber.Router, handler *h5.PackageUsageHandler, doc *openapi.Generator, basePath string) {
packages := router.Group("/packages")
groupPath := basePath + "/packages"
Register(packages, doc, groupPath, "GET", "/my-usage", handler.GetMyUsage, RouteSpec{
Summary: "获取我的套餐使用情况",
Tags: []string{"H5-套餐"},
Input: nil,
Output: new(dto.PackageUsageCustomerViewResponse),
Auth: true,
})
}

View File

@@ -5,7 +5,6 @@ import (
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
@@ -53,57 +52,6 @@ func registerAdminOrderRoutes(router fiber.Router, handler *admin.OrderHandler,
})
}
// registerH5OrderRoutes 注册H5订单路由
func registerH5OrderRoutes(router fiber.Router, handler *h5.OrderHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/orders", handler.Create, RouteSpec{
Summary: "创建订单",
Tags: []string{"H5 订单"},
Input: new(dto.CreateOrderRequest),
Output: new(dto.OrderResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/orders", handler.List, RouteSpec{
Summary: "获取订单列表",
Tags: []string{"H5 订单"},
Input: new(dto.OrderListRequest),
Output: new(dto.OrderListResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/orders/:id", handler.Get, RouteSpec{
Summary: "获取订单详情",
Tags: []string{"H5 订单"},
Input: new(dto.GetOrderRequest),
Output: new(dto.OrderResponse),
Auth: true,
})
Register(router, doc, basePath, "POST", "/orders/:id/wallet-pay", handler.WalletPay, RouteSpec{
Summary: "钱包支付",
Tags: []string{"H5 订单"},
Input: new(dto.CancelOrderRequest),
Output: nil,
Auth: true,
})
Register(router, doc, basePath, "POST", "/orders/:id/wechat-pay/jsapi", handler.WechatPayJSAPI, RouteSpec{
Summary: "微信 JSAPI 支付",
Tags: []string{"H5 订单"},
Input: new(dto.WechatPayJSAPIParams),
Output: new(dto.WechatPayJSAPIResponse),
Auth: true,
})
Register(router, doc, basePath, "POST", "/orders/:id/wechat-pay/h5", handler.WechatPayH5, RouteSpec{
Summary: "微信 H5 支付",
Tags: []string{"H5 订单"},
Input: new(dto.WechatPayH5Params),
Output: new(dto.WechatPayH5Response),
Auth: true,
})
}
// registerPaymentCallbackRoutes 注册支付回调路由
func registerPaymentCallbackRoutes(router fiber.Router, handler *callback.PaymentHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/wechat-pay", handler.WechatPayCallback, RouteSpec{
@@ -121,4 +69,12 @@ func registerPaymentCallbackRoutes(router fiber.Router, handler *callback.Paymen
Output: nil,
Auth: false,
})
Register(router, doc, basePath, "POST", "/fuiou-pay", handler.FuiouPayCallback, RouteSpec{
Summary: "富友支付回调",
Tags: []string{"支付回调"},
Input: nil,
Output: nil,
Auth: false,
})
}

View File

@@ -67,4 +67,12 @@ func registerPackageRoutes(router fiber.Router, handler *admin.PackageHandler, d
Output: nil,
Auth: true,
})
Register(packages, doc, groupPath, "PATCH", "/:id/retail-price", handler.UpdateRetailPrice, RouteSpec{
Summary: "修改零售价(代理)",
Tags: []string{"套餐管理"},
Input: new(dto.UpdateRetailPriceParams),
Output: nil,
Auth: true,
})
}

View File

@@ -12,54 +12,79 @@ import (
// RegisterPersonalCustomerRoutes 注册个人客户路由
// 路由挂载在 /api/c/v1 下
//
// 重要Fiber 的 Group.Use() 会在路由表中注册全局 USE 处理器,
// 匹配该前缀下的所有请求(不区分 Group 对象)。
// 因此公开路由必须在任何 Use() 调用之前注册,利用 Fiber 按注册顺序匹配的机制,
// 确保公开路由优先命中并直接返回,不会被后续的认证中间件拦截。
func RegisterPersonalCustomerRoutes(router fiber.Router, doc *openapi.Generator, basePath string, handlers *bootstrap.Handlers, personalAuthMiddleware *middleware.PersonalAuthMiddleware) {
// 公开路由(不需要认证)
publicGroup := router.Group("")
authBasePath := "/auth"
// 发送验证码
Register(publicGroup, doc, basePath, "POST", "/login/send-code", handlers.PersonalCustomer.SendCode, RouteSpec{
// === 公开路由(无需认证)===
Register(router, doc, basePath, "POST", authBasePath+"/verify-asset", handlers.ClientAuth.VerifyAsset, RouteSpec{
Summary: "资产验证",
Tags: []string{"个人客户 - 认证"},
Auth: false,
Input: &dto.VerifyAssetRequest{},
Output: &dto.VerifyAssetResponse{},
})
Register(router, doc, basePath, "POST", authBasePath+"/wechat-login", handlers.ClientAuth.WechatLogin, RouteSpec{
Summary: "公众号登录",
Tags: []string{"个人客户 - 认证"},
Auth: false,
Input: &dto.WechatLoginRequest{},
Output: &dto.WechatLoginResponse{},
})
Register(router, doc, basePath, "POST", authBasePath+"/miniapp-login", handlers.ClientAuth.MiniappLogin, RouteSpec{
Summary: "小程序登录",
Tags: []string{"个人客户 - 认证"},
Auth: false,
Input: &dto.MiniappLoginRequest{},
Output: &dto.WechatLoginResponse{},
})
Register(router, doc, basePath, "POST", authBasePath+"/send-code", handlers.ClientAuth.SendCode, RouteSpec{
Summary: "发送验证码",
Description: "向指定手机号发送登录验证码",
Tags: []string{"个人客户 - 认证"},
Auth: false,
Input: &apphandler.SendCodeRequest{},
Output: nil,
Input: &dto.ClientSendCodeRequest{},
Output: &dto.ClientSendCodeResponse{},
})
// 登录
Register(publicGroup, doc, basePath, "POST", "/login", handlers.PersonalCustomer.Login, RouteSpec{
Summary: "手机号登录",
Description: "使用手机号和验证码登录",
// === 需要认证的 auth 路由 ===
authProtectedGroup := router.Group(authBasePath)
authProtectedGroup.Use(personalAuthMiddleware.Authenticate())
Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/bind-phone", handlers.ClientAuth.BindPhone, RouteSpec{
Summary: "绑定手机号",
Tags: []string{"个人客户 - 认证"},
Auth: false,
Input: &apphandler.LoginRequest{},
Output: &apphandler.LoginResponse{},
Auth: true,
Input: &dto.BindPhoneRequest{},
Output: &dto.BindPhoneResponse{},
})
// 微信 OAuth 登录(公开)
Register(publicGroup, doc, basePath, "POST", "/wechat/auth", handlers.PersonalCustomer.WechatOAuthLogin, RouteSpec{
Summary: "微信授权登录",
Description: "使用微信授权码登录,自动创建或关联用户",
Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/change-phone", handlers.ClientAuth.ChangePhone, RouteSpec{
Summary: "更换手机号",
Tags: []string{"个人客户 - 认证"},
Auth: false,
Input: &dto.WechatOAuthRequest{},
Output: &dto.WechatOAuthResponse{},
Auth: true,
Input: &dto.ChangePhoneRequest{},
Output: &dto.ChangePhoneResponse{},
})
Register(authProtectedGroup, doc, basePath+authBasePath, "POST", "/logout", handlers.ClientAuth.Logout, RouteSpec{
Summary: "退出登录",
Tags: []string{"个人客户 - 认证"},
Auth: true,
Input: nil,
Output: &dto.LogoutResponse{},
})
// 需要认证的路由
authGroup := router.Group("")
authGroup.Use(personalAuthMiddleware.Authenticate())
// 绑定微信
Register(authGroup, doc, basePath, "POST", "/bind-wechat", handlers.PersonalCustomer.BindWechat, RouteSpec{
Summary: "绑定微信",
Description: "绑定微信账号到当前个人客户",
Tags: []string{"个人客户 - 账户"},
Auth: true,
Input: &dto.WechatOAuthRequest{},
Output: nil,
})
// 获取个人资料
Register(authGroup, doc, basePath, "GET", "/profile", handlers.PersonalCustomer.GetProfile, RouteSpec{
Summary: "获取个人资料",
@@ -79,4 +104,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{},
})
}

View File

@@ -1,44 +1 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
// registerH5RechargeRoutes 注册H5充值路由
func registerH5RechargeRoutes(router fiber.Router, handler *h5.RechargeHandler, doc *openapi.Generator, basePath string) {
Register(router, doc, basePath, "POST", "/wallets/recharge", handler.Create, RouteSpec{
Summary: "创建充值订单",
Tags: []string{"H5 充值"},
Input: new(dto.CreateRechargeRequest),
Output: new(dto.RechargeResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/wallets/recharge-check", handler.RechargeCheck, RouteSpec{
Summary: "充值预检",
Tags: []string{"H5 充值"},
Input: new(dto.RechargeCheckRequest),
Output: new(dto.RechargeCheckResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/wallets/recharges", handler.List, RouteSpec{
Summary: "获取充值订单列表",
Tags: []string{"H5 充值"},
Input: new(dto.RechargeListRequest),
Output: new(dto.RechargeListResponse),
Auth: true,
})
Register(router, doc, basePath, "GET", "/wallets/recharges/:id", handler.Get, RouteSpec{
Summary: "获取充值订单详情",
Tags: []string{"H5 充值"},
Input: new(dto.GetRechargeRequest),
Output: new(dto.RechargeResponse),
Auth: true,
})
}

View File

@@ -28,15 +28,11 @@ func RegisterRoutesWithDoc(app *fiber.App, handlers *bootstrap.Handlers, middlew
adminGroup := app.Group("/api/admin")
RegisterAdminRoutes(adminGroup, handlers, middlewares, doc, "/api/admin")
// 4. H5 域 (挂载在 /api/h5)
h5Group := app.Group("/api/h5")
RegisterH5Routes(h5Group, handlers, middlewares, doc, "/api/h5")
// 5. 个人客户路由 (挂载在 /api/c/v1)
// 4. 个人客户路由 (挂载在 /api/c/v1)
personalGroup := app.Group("/api/c/v1")
RegisterPersonalCustomerRoutes(personalGroup, doc, "/api/c/v1", handlers, middlewares.PersonalAuth)
// 6. 支付回调路由 (挂载在 /api/callback无需认证)
// 5. 支付回调路由 (挂载在 /api/callback无需认证)
if handlers.PaymentCallback != nil {
callbackGroup := app.Group("/api/callback")
registerPaymentCallbackRoutes(callbackGroup, handlers.PaymentCallback, doc, "/api/callback")

View File

@@ -0,0 +1,89 @@
package routes
import (
"github.com/gofiber/fiber/v2"
"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/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/openapi"
)
func registerWechatConfigRoutes(router fiber.Router, handler *admin.WechatConfigHandler, doc *openapi.Generator, basePath string) {
// 平台用户权限中间件:仅超级管理员和平台用户可访问支付配置管理
group := router.Group("/wechat-configs", func(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "无权限访问支付配置管理功能")
}
return c.Next()
})
groupPath := basePath + "/wechat-configs"
// active 路由必须在 /:id 之前注册,否则 "active" 会被当作 id 解析
Register(group, doc, groupPath, "GET", "/active", handler.GetActive, RouteSpec{
Summary: "获取当前生效的支付配置",
Tags: []string{"微信支付配置管理"},
Input: nil,
Output: new(dto.WechatConfigResponse),
Auth: true,
})
Register(group, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "获取支付配置列表",
Tags: []string{"微信支付配置管理"},
Input: new(dto.WechatConfigListRequest),
Output: new(dto.WechatConfigListResponse),
Auth: true,
})
Register(group, doc, groupPath, "POST", "", handler.Create, RouteSpec{
Summary: "创建支付配置",
Tags: []string{"微信支付配置管理"},
Input: new(dto.CreateWechatConfigRequest),
Output: new(dto.WechatConfigResponse),
Auth: true,
})
Register(group, doc, groupPath, "GET", "/:id", handler.Get, RouteSpec{
Summary: "获取支付配置详情",
Tags: []string{"微信支付配置管理"},
Input: new(dto.IDReq),
Output: new(dto.WechatConfigResponse),
Auth: true,
})
Register(group, doc, groupPath, "PUT", "/:id", handler.Update, RouteSpec{
Summary: "更新支付配置",
Tags: []string{"微信支付配置管理"},
Input: new(dto.UpdateWechatConfigParams),
Output: new(dto.WechatConfigResponse),
Auth: true,
})
Register(group, doc, groupPath, "DELETE", "/:id", handler.Delete, RouteSpec{
Summary: "删除支付配置",
Tags: []string{"微信支付配置管理"},
Input: new(dto.IDReq),
Output: nil,
Auth: true,
})
Register(group, doc, groupPath, "POST", "/:id/activate", handler.Activate, RouteSpec{
Summary: "激活支付配置",
Tags: []string{"微信支付配置管理"},
Input: new(dto.IDReq),
Output: new(dto.WechatConfigResponse),
Auth: true,
})
Register(group, doc, groupPath, "POST", "/:id/deactivate", handler.Deactivate, RouteSpec{
Summary: "停用支付配置",
Tags: []string{"微信支付配置管理"},
Input: new(dto.IDReq),
Output: new(dto.WechatConfigResponse),
Auth: true,
})
}

View File

@@ -0,0 +1,506 @@
// Package agent_recharge 提供代理预充值的业务逻辑服务
// 包含充值订单创建、线下确认、支付回调处理、列表查询等功能
package agent_recharge
import (
"context"
"fmt"
"math/rand"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/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"
)
// AuditServiceInterface 审计日志服务接口
type AuditServiceInterface interface {
LogOperation(ctx context.Context, log *model.AccountOperationLog)
}
// WechatConfigServiceInterface 支付配置服务接口
type WechatConfigServiceInterface interface {
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
}
// Service 代理预充值业务服务
// 负责代理钱包充值订单的创建、线下确认、回调处理等业务逻辑
type Service struct {
db *gorm.DB
agentRechargeStore *postgres.AgentRechargeStore
agentWalletStore *postgres.AgentWalletStore
agentWalletTxStore *postgres.AgentWalletTransactionStore
shopStore *postgres.ShopStore
accountStore *postgres.AccountStore
wechatConfigService WechatConfigServiceInterface
auditService AuditServiceInterface
redis *redis.Client
logger *zap.Logger
}
// New 创建代理预充值服务实例
func New(
db *gorm.DB,
agentRechargeStore *postgres.AgentRechargeStore,
agentWalletStore *postgres.AgentWalletStore,
agentWalletTxStore *postgres.AgentWalletTransactionStore,
shopStore *postgres.ShopStore,
accountStore *postgres.AccountStore,
wechatConfigService WechatConfigServiceInterface,
auditService AuditServiceInterface,
rdb *redis.Client,
logger *zap.Logger,
) *Service {
return &Service{
db: db,
agentRechargeStore: agentRechargeStore,
agentWalletStore: agentWalletStore,
agentWalletTxStore: agentWalletTxStore,
shopStore: shopStore,
accountStore: accountStore,
wechatConfigService: wechatConfigService,
auditService: auditService,
redis: rdb,
logger: logger,
}
}
// Create 创建代理充值订单
// POST /api/admin/agent-recharges
func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeRequest) (*dto.AgentRechargeResponse, error) {
userID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
userShopID := middleware.GetShopIDFromContext(ctx)
// 代理只能充自己店铺
if userType == constants.UserTypeAgent && req.ShopID != userShopID {
return nil, errors.New(errors.CodeForbidden, "代理只能为自己的店铺充值")
}
// 线下充值仅平台可用
if req.PaymentMethod == "offline" && userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden, "线下充值仅平台管理员可操作")
}
if req.Amount < constants.AgentRechargeMinAmount || req.Amount > constants.AgentRechargeMaxAmount {
return nil, errors.New(errors.CodeInvalidParam, "充值金额超出允许范围")
}
// 查找目标店铺的主钱包
wallet, err := s.agentWalletStore.GetMainWallet(ctx, req.ShopID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "目标店铺主钱包不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺主钱包失败")
}
// 查询店铺名称
shop, err := s.shopStore.GetByID(ctx, req.ShopID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "目标店铺不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询店铺失败")
}
// 在线支付需要查询生效的支付配置
var paymentConfigID *uint
var paymentChannel string
if req.PaymentMethod == "wechat" {
activeConfig, cfgErr := s.wechatConfigService.GetActiveConfig(ctx)
if cfgErr != nil || activeConfig == nil {
return nil, errors.New(errors.CodeNoPaymentConfig, "当前无可用的支付配置,请联系管理员")
}
paymentConfigID = &activeConfig.ID
paymentChannel = activeConfig.ProviderType
} else {
paymentChannel = "offline"
}
rechargeNo := s.generateRechargeNo()
record := &model.AgentRechargeRecord{
UserID: userID,
AgentWalletID: wallet.ID,
ShopID: req.ShopID,
RechargeNo: rechargeNo,
Amount: req.Amount,
PaymentMethod: req.PaymentMethod,
PaymentChannel: &paymentChannel,
PaymentConfigID: paymentConfigID,
Status: constants.RechargeStatusPending,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := s.agentRechargeStore.Create(ctx, record); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值订单失败")
}
s.logger.Info("创建代理充值订单成功",
zap.Uint("recharge_id", record.ID),
zap.String("recharge_no", rechargeNo),
zap.Int64("amount", req.Amount),
zap.Uint("shop_id", req.ShopID),
zap.Uint("user_id", userID),
)
return toResponse(record, shop.ShopName), nil
}
// OfflinePay 线下充值确认
// POST /api/admin/agent-recharges/:id/offline-pay
func (s *Service) OfflinePay(ctx context.Context, id uint, req *dto.AgentOfflinePayRequest) (*dto.AgentRechargeResponse, error) {
userID := middleware.GetUserIDFromContext(ctx)
userType := middleware.GetUserTypeFromContext(ctx)
// 仅平台账号可操作
if userType != constants.UserTypePlatform && userType != constants.UserTypeSuperAdmin {
return nil, errors.New(errors.CodeForbidden, "仅平台管理员可确认线下充值")
}
// 验证操作密码
account, err := s.accountStore.GetByID(ctx, userID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询操作人账号失败")
}
if err := bcrypt.CompareHashAndPassword([]byte(account.Password), []byte(req.OperationPassword)); err != nil {
return nil, errors.New(errors.CodeInvalidParam, "操作密码错误")
}
record, err := s.agentRechargeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "充值记录不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败")
}
if record.PaymentMethod != "offline" {
return nil, errors.New(errors.CodeInvalidParam, "该订单非线下充值,不支持此操作")
}
if record.Status != constants.RechargeStatusPending {
return nil, errors.New(errors.CodeInvalidParam, "该订单状态不允许确认支付")
}
// 查询钱包(事务内需要用到 version
wallet, err := s.agentWalletStore.GetByID(ctx, record.AgentWalletID)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询代理钱包失败")
}
now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error {
// 条件更新充值记录状态
result := tx.Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
Updates(map[string]interface{}{
"status": constants.RechargeStatusCompleted,
"paid_at": now,
"completed_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新充值记录状态失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInvalidParam, "充值记录状态已变更")
}
// 增加钱包余额(乐观锁)
balanceResult := tx.Model(&model.AgentWallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance + ?", record.Amount),
"version": gorm.Expr("version + 1"),
})
if balanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, balanceResult.Error, "更新钱包余额失败")
}
if balanceResult.RowsAffected == 0 {
return errors.New(errors.CodeInternalError, "钱包余额更新冲突,请重试")
}
// 创建钱包交易记录
remark := "线下充值确认"
refType := "topup"
txRecord := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID,
ShopID: record.ShopID,
UserID: userID,
TransactionType: constants.AgentTransactionTypeRecharge,
Amount: record.Amount,
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance + record.Amount,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceID: &record.ID,
Remark: &remark,
Creator: userID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := s.agentWalletTxStore.CreateWithTx(ctx, tx, txRecord); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
}
return nil
})
if err != nil {
return nil, err
}
// 异步记录审计日志
go s.auditService.LogOperation(ctx, &model.AccountOperationLog{
OperatorID: userID,
OperatorType: userType,
OperationType: "offline_recharge_confirm",
OperationDesc: fmt.Sprintf("确认线下充值,充值单号: %s金额: %d分", record.RechargeNo, record.Amount),
RequestID: middleware.GetRequestIDFromContext(ctx),
IPAddress: middleware.GetIPFromContext(ctx),
UserAgent: middleware.GetUserAgentFromContext(ctx),
})
shop, _ := s.shopStore.GetByID(ctx, record.ShopID)
shopName := ""
if shop != nil {
shopName = shop.ShopName
}
// 更新本地对象以反映最新状态
record.Status = constants.RechargeStatusCompleted
record.PaidAt = &now
record.CompletedAt = &now
return toResponse(record, shopName), nil
}
// HandlePaymentCallback 处理支付回调
// 幂等处理status != 待支付则直接返回成功
func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string, paymentMethod string, paymentTransactionID string) error {
record, err := s.agentRechargeStore.GetByRechargeNo(ctx, rechargeNo)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "充值订单不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值订单失败")
}
// 幂等检查
if record.Status != constants.RechargeStatusPending {
s.logger.Info("代理充值订单已处理,跳过",
zap.String("recharge_no", rechargeNo),
zap.Int("status", record.Status),
)
return nil
}
wallet, err := s.agentWalletStore.GetByID(ctx, record.AgentWalletID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询代理钱包失败")
}
now := time.Now()
err = s.db.Transaction(func(tx *gorm.DB) error {
// 条件更新WHERE status = 1
result := tx.Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", record.ID, constants.RechargeStatusPending).
Updates(map[string]interface{}{
"status": constants.RechargeStatusCompleted,
"payment_transaction_id": paymentTransactionID,
"paid_at": now,
"completed_at": now,
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "更新充值记录状态失败")
}
if result.RowsAffected == 0 {
return nil
}
// 增加钱包余额(乐观锁)
balanceResult := tx.Model(&model.AgentWallet{}).
Where("id = ? AND version = ?", wallet.ID, wallet.Version).
Updates(map[string]interface{}{
"balance": gorm.Expr("balance + ?", record.Amount),
"version": gorm.Expr("version + 1"),
})
if balanceResult.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, balanceResult.Error, "更新钱包余额失败")
}
if balanceResult.RowsAffected == 0 {
return errors.New(errors.CodeInternalError, "钱包余额更新冲突,请重试")
}
// 创建交易记录
remark := "在线支付充值"
refType := "topup"
txRecord := &model.AgentWalletTransaction{
AgentWalletID: wallet.ID,
ShopID: record.ShopID,
UserID: record.UserID,
TransactionType: constants.AgentTransactionTypeRecharge,
Amount: record.Amount,
BalanceBefore: wallet.Balance,
BalanceAfter: wallet.Balance + record.Amount,
Status: constants.TransactionStatusSuccess,
ReferenceType: &refType,
ReferenceID: &record.ID,
Remark: &remark,
Creator: record.UserID,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := s.agentWalletTxStore.CreateWithTx(ctx, tx, txRecord); err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建钱包交易记录失败")
}
return nil
})
if err != nil {
return err
}
s.logger.Info("代理充值支付回调处理成功",
zap.String("recharge_no", rechargeNo),
zap.Int64("amount", record.Amount),
zap.Uint("shop_id", record.ShopID),
)
return nil
}
// GetByID 根据ID查询充值订单详情
// GET /api/admin/agent-recharges/:id
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AgentRechargeResponse, error) {
record, err := s.agentRechargeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "充值记录不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败")
}
shop, _ := s.shopStore.GetByID(ctx, record.ShopID)
shopName := ""
if shop != nil {
shopName = shop.ShopName
}
return toResponse(record, shopName), nil
}
// List 分页查询充值订单列表
// GET /api/admin/agent-recharges
func (s *Service) List(ctx context.Context, req *dto.AgentRechargeListRequest) ([]*dto.AgentRechargeResponse, int64, error) {
page := req.Page
pageSize := req.PageSize
if page == 0 {
page = 1
}
if pageSize == 0 {
pageSize = constants.DefaultPageSize
}
query := s.db.WithContext(ctx).Model(&model.AgentRechargeRecord{})
if req.ShopID != nil {
query = query.Where("shop_id = ?", *req.ShopID)
}
if req.Status != nil {
query = query.Where("status = ?", *req.Status)
}
if req.StartDate != "" {
query = query.Where("created_at >= ?", req.StartDate+" 00:00:00")
}
if req.EndDate != "" {
query = query.Where("created_at <= ?", req.EndDate+" 23:59:59")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录总数失败")
}
var records []*model.AgentRechargeRecord
offset := (page - 1) * pageSize
if err := query.Order("created_at DESC").Offset(offset).Limit(pageSize).Find(&records).Error; err != nil {
return nil, 0, errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录列表失败")
}
// 批量查询店铺名称
shopIDs := make([]uint, 0, len(records))
for _, r := range records {
shopIDs = append(shopIDs, r.ShopID)
}
shopMap := make(map[uint]string)
if len(shopIDs) > 0 {
shops, err := s.shopStore.GetByIDs(ctx, shopIDs)
if err == nil {
for _, sh := range shops {
shopMap[sh.ID] = sh.ShopName
}
}
}
list := make([]*dto.AgentRechargeResponse, 0, len(records))
for _, r := range records {
list = append(list, toResponse(r, shopMap[r.ShopID]))
}
return list, total, nil
}
// generateRechargeNo 生成代理充值订单号
// 格式: ARCH + 14位时间戳 + 6位随机数
func (s *Service) generateRechargeNo() string {
timestamp := time.Now().Format("20060102150405")
randomNum := rand.Intn(1000000)
return fmt.Sprintf("%s%s%06d", constants.AgentRechargeOrderPrefix, timestamp, randomNum)
}
// toResponse 将模型转换为响应 DTO
func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRechargeResponse {
resp := &dto.AgentRechargeResponse{
ID: record.ID,
RechargeNo: record.RechargeNo,
ShopID: record.ShopID,
ShopName: shopName,
AgentWalletID: record.AgentWalletID,
Amount: record.Amount,
PaymentMethod: record.PaymentMethod,
Status: record.Status,
CreatedAt: record.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: record.UpdatedAt.Format("2006-01-02 15:04:05"),
}
if record.PaymentChannel != nil {
resp.PaymentChannel = *record.PaymentChannel
}
if record.PaymentConfigID != nil {
resp.PaymentConfigID = record.PaymentConfigID
}
if record.PaymentTransactionID != nil {
resp.PaymentTransactionID = *record.PaymentTransactionID
}
if record.PaidAt != nil {
t := record.PaidAt.Format("2006-01-02 15:04:05")
resp.PaidAt = &t
}
if record.CompletedAt != nil {
t := record.CompletedAt.Format("2006-01-02 15:04:05")
resp.CompletedAt = &t
}
return resp
}

View File

@@ -0,0 +1,88 @@
package asset
import (
"context"
stderrors "errors"
"github.com/break/junhong_cmp_fiber/internal/model"
"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"
"gorm.io/gorm"
)
var deactivatableAssetStatuses = []int{constants.AssetStatusInStock, constants.AssetStatusSold}
// LifecycleService 资产生命周期服务
type LifecycleService struct {
db *gorm.DB
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
}
// NewLifecycleService 创建资产生命周期服务
func NewLifecycleService(db *gorm.DB, iotCardStore *postgres.IotCardStore, deviceStore *postgres.DeviceStore) *LifecycleService {
return &LifecycleService{
db: db,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
}
}
// DeactivateIotCard 手动停用 IoT 卡
func (s *LifecycleService) DeactivateIotCard(ctx context.Context, id uint) error {
card, err := s.iotCardStore.GetByID(ctx, id)
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(errors.CodeIotCardNotFound)
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询IoT卡失败")
}
if !canDeactivateAsset(card.AssetStatus) {
return errors.New(errors.CodeForbidden, "当前状态不允许停用")
}
result := s.db.WithContext(ctx).Model(&model.IotCard{}).
Where("id = ? AND asset_status IN ?", id, deactivatableAssetStatuses).
Update("asset_status", constants.AssetStatusDeactivated)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "停用IoT卡失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeConflict, "状态已变更,请刷新后重试")
}
return nil
}
// DeactivateDevice 手动停用设备
func (s *LifecycleService) DeactivateDevice(ctx context.Context, id uint) error {
device, err := s.deviceStore.GetByID(ctx, id)
if err != nil {
if stderrors.Is(err, gorm.ErrRecordNotFound) {
return errors.New(errors.CodeNotFound, "设备不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询设备失败")
}
if !canDeactivateAsset(device.AssetStatus) {
return errors.New(errors.CodeForbidden, "当前状态不允许停用")
}
result := s.db.WithContext(ctx).Model(&model.Device{}).
Where("id = ? AND asset_status IN ?", id, deactivatableAssetStatuses).
Update("asset_status", constants.AssetStatusDeactivated)
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "停用设备失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeConflict, "状态已变更,请刷新后重试")
}
return nil
}
func canDeactivateAsset(assetStatus int) bool {
return assetStatus == constants.AssetStatusInStock || assetStatus == constants.AssetStatusSold
}

View File

@@ -0,0 +1,162 @@
package asset_wallet
import (
"context"
"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/errors"
"gorm.io/gorm"
)
// Service 资产钱包业务服务
// 负责管理端资产(卡/设备)钱包概况查询和流水列表查询
type Service struct {
assetWalletStore *postgres.AssetWalletStore
assetWalletTransactionStore *postgres.AssetWalletTransactionStore
}
// New 创建资产钱包服务实例
func New(assetWalletStore *postgres.AssetWalletStore, assetWalletTransactionStore *postgres.AssetWalletTransactionStore) *Service {
return &Service{
assetWalletStore: assetWalletStore,
assetWalletTransactionStore: assetWalletTransactionStore,
}
}
// GetWallet 查询资产钱包概况
// assetType 为 card 或 device映射到 resourceType = iot_card 或 device
func (s *Service) GetWallet(ctx context.Context, assetType string, assetID uint) (*dto.AssetWalletResponse, error) {
resourceType, err := mapAssetTypeToResourceType(assetType)
if err != nil {
return nil, err
}
wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, assetID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeNotFound, "该资产暂无钱包记录")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
}
statusText := walletStatusText(wallet.Status)
return &dto.AssetWalletResponse{
WalletID: wallet.ID,
ResourceType: wallet.ResourceType,
ResourceID: wallet.ResourceID,
Balance: wallet.Balance,
FrozenBalance: wallet.FrozenBalance,
AvailableBalance: wallet.Balance - wallet.FrozenBalance,
Currency: wallet.Currency,
Status: wallet.Status,
StatusText: statusText,
CreatedAt: wallet.CreatedAt,
UpdatedAt: wallet.UpdatedAt,
}, nil
}
// ListTransactions 查询资产钱包流水列表(分页)
func (s *Service) ListTransactions(ctx context.Context, assetType string, assetID uint, req *dto.AssetWalletTransactionListRequest) (*dto.AssetWalletTransactionListResponse, error) {
resourceType, err := mapAssetTypeToResourceType(assetType)
if err != nil {
return nil, err
}
page := req.Page
if page < 1 {
page = 1
}
pageSize := req.PageSize
if pageSize < 1 {
pageSize = 20
}
if pageSize > 100 {
pageSize = 100
}
offset := (page - 1) * pageSize
transactions, err := s.assetWalletTransactionStore.ListByResourceIDWithFilter(
ctx, resourceType, assetID, req.TransactionType, req.StartTime, req.EndTime, offset, pageSize,
)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询流水列表失败")
}
total, err := s.assetWalletTransactionStore.CountByResourceIDWithFilter(
ctx, resourceType, assetID, req.TransactionType, req.StartTime, req.EndTime,
)
if err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "统计流水数量失败")
}
list := make([]*dto.AssetWalletTransactionItem, 0, len(transactions))
for _, tx := range transactions {
list = append(list, &dto.AssetWalletTransactionItem{
ID: tx.ID,
TransactionType: tx.TransactionType,
TransactionTypeText: transactionTypeText(tx.TransactionType),
Amount: tx.Amount,
BalanceBefore: tx.BalanceBefore,
BalanceAfter: tx.BalanceAfter,
ReferenceType: tx.ReferenceType,
ReferenceNo: tx.ReferenceNo,
Remark: tx.Remark,
CreatedAt: tx.CreatedAt,
})
}
totalPages := int(total) / pageSize
if int(total)%pageSize > 0 {
totalPages++
}
return &dto.AssetWalletTransactionListResponse{
List: list,
Total: total,
Page: page,
PageSize: pageSize,
TotalPages: totalPages,
}, nil
}
// mapAssetTypeToResourceType 将路由参数 assetTypecard/device映射到数据库资源类型
func mapAssetTypeToResourceType(assetType string) (string, error) {
switch assetType {
case "card":
return "iot_card", nil
case "device":
return "device", nil
default:
return "", errors.New(errors.CodeInvalidParam, "无效的资产类型,仅支持 card 或 device")
}
}
// walletStatusText 翻译钱包状态文本
func walletStatusText(status int) string {
switch status {
case 1:
return "正常"
case 2:
return "冻结"
case 3:
return "关闭"
default:
return "未知"
}
}
// transactionTypeText 翻译交易类型文本
func transactionTypeText(transactionType string) string {
switch transactionType {
case "recharge":
return "充值"
case "deduct":
return "扣款"
case "refund":
return "退款"
default:
return transactionType
}
}

View File

@@ -41,6 +41,12 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateCarrierRequest) (*d
Description: req.Description,
Status: constants.StatusEnabled,
}
if req.RealnameLinkType != nil {
carrier.RealnameLinkType = *req.RealnameLinkType
}
if req.RealnameLinkTemplate != nil {
carrier.RealnameLinkTemplate = *req.RealnameLinkTemplate
}
carrier.Creator = currentUserID
if err := s.carrierStore.Create(ctx, carrier); err != nil {
@@ -81,6 +87,15 @@ func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdateCarrierReq
if req.Description != nil {
carrier.Description = *req.Description
}
if req.RealnameLinkType != nil {
carrier.RealnameLinkType = *req.RealnameLinkType
}
if req.RealnameLinkTemplate != nil {
carrier.RealnameLinkTemplate = *req.RealnameLinkTemplate
}
if carrier.RealnameLinkType == "template" && carrier.RealnameLinkTemplate == "" {
return nil, errors.New(errors.CodeInvalidParam, "模板URL类型必须提供实名链接模板")
}
carrier.Updater = currentUserID
if err := s.carrierStore.Update(ctx, carrier); err != nil {
@@ -174,6 +189,8 @@ func (s *Service) toResponse(c *model.Carrier) *dto.CarrierResponse {
CarrierName: c.CarrierName,
CarrierType: c.CarrierType,
Description: c.Description,
RealnameLinkType: c.RealnameLinkType,
RealnameLinkTemplate: c.RealnameLinkTemplate,
Status: c.Status,
CreatedAt: c.CreatedAt.Format(time.RFC3339),
UpdatedAt: c.UpdatedAt.Format(time.RFC3339),

View File

@@ -0,0 +1,761 @@
// Package client_auth 提供 C 端认证业务逻辑
// 包含资产验证、微信登录、手机号绑定与退出登录等能力
package client_auth
import (
"context"
"regexp"
"time"
"github.com/ArtisanCloud/PowerWeChat/v3/src/kernel"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/service/verification"
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/auth"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
"github.com/golang-jwt/jwt/v5"
"github.com/redis/go-redis/v9"
"github.com/spf13/viper"
"go.uber.org/zap"
"gorm.io/gorm"
)
const (
assetTypeIotCard = "iot_card"
assetTypeDevice = "device"
appTypeOfficialAccount = "official_account"
appTypeMiniapp = "miniapp"
assetTokenExpireSeconds = 300
)
var identifierRegex = regexp.MustCompile(`^[A-Za-z0-9-]{1,50}$`)
// Service C 端认证服务
type Service struct {
db *gorm.DB
openidStore *postgres.PersonalCustomerOpenIDStore
customerStore *postgres.PersonalCustomerStore
deviceBindStore *postgres.PersonalCustomerDeviceStore
phoneStore *postgres.PersonalCustomerPhoneStore
iotCardStore *postgres.IotCardStore
deviceStore *postgres.DeviceStore
wechatConfigService *wechatConfigSvc.Service
verificationService *verification.Service
jwtManager *auth.JWTManager
redis *redis.Client
logger *zap.Logger
wechatCache kernel.CacheInterface
}
// New 创建 C 端认证服务实例
func New(
db *gorm.DB,
openidStore *postgres.PersonalCustomerOpenIDStore,
customerStore *postgres.PersonalCustomerStore,
deviceBindStore *postgres.PersonalCustomerDeviceStore,
phoneStore *postgres.PersonalCustomerPhoneStore,
iotCardStore *postgres.IotCardStore,
deviceStore *postgres.DeviceStore,
wechatConfigService *wechatConfigSvc.Service,
verificationService *verification.Service,
jwtManager *auth.JWTManager,
redisClient *redis.Client,
logger *zap.Logger,
) *Service {
return &Service{
db: db,
openidStore: openidStore,
customerStore: customerStore,
deviceBindStore: deviceBindStore,
phoneStore: phoneStore,
iotCardStore: iotCardStore,
deviceStore: deviceStore,
wechatConfigService: wechatConfigService,
verificationService: verificationService,
jwtManager: jwtManager,
redis: redisClient,
logger: logger,
wechatCache: wechat.NewRedisCache(redisClient),
}
}
type assetTokenClaims struct {
AssetType string `json:"asset_type"`
AssetID uint `json:"asset_id"`
jwt.RegisteredClaims
}
// VerifyAsset A1 验证资产并签发短期资产令牌
func (s *Service) VerifyAsset(ctx context.Context, req *dto.VerifyAssetRequest, clientIP string) (*dto.VerifyAssetResponse, error) {
if req == nil || !identifierRegex.MatchString(req.Identifier) {
return nil, errors.New(errors.CodeInvalidParam)
}
if err := s.checkAssetVerifyRateLimit(ctx, clientIP); err != nil {
return nil, err
}
assetType, assetID, err := s.resolveAsset(ctx, req.Identifier)
if err != nil {
return nil, err
}
assetToken, err := s.signAssetToken(assetType, assetID)
if err != nil {
s.logger.Error("签发资产令牌失败", zap.Error(err))
return nil, errors.Wrap(errors.CodeInternalError, err, "签发资产令牌失败")
}
return &dto.VerifyAssetResponse{
AssetToken: assetToken,
ExpiresIn: assetTokenExpireSeconds,
}, nil
}
// WechatLogin A2 公众号登录
func (s *Service) WechatLogin(ctx context.Context, req *dto.WechatLoginRequest, clientIP string) (*dto.WechatLoginResponse, error) {
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
assetClaims, err := s.verifyAssetToken(req.AssetToken)
if err != nil {
return nil, err
}
wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
if err != nil {
return nil, err
}
if wechatConfig == nil {
return nil, errors.New(errors.CodeWechatConfigUnavailable)
}
oaApp, err := wechat.NewOfficialAccountAppFromConfig(wechatConfig, s.wechatCache, s.logger)
if err != nil {
s.logger.Error("创建公众号实例失败", zap.Error(err))
return nil, errors.Wrap(errors.CodeWechatConfigUnavailable, err, "微信公众号配置不可用")
}
oaService := wechat.NewOfficialAccountService(oaApp, s.logger)
userInfo, err := oaService.GetUserInfoDetailed(ctx, req.Code)
if err != nil {
return nil, err
}
customerID, isNewUser, err := s.loginByOpenID(
ctx,
assetClaims.AssetType,
assetClaims.AssetID,
wechatConfig.OaAppID,
userInfo.OpenID,
userInfo.UnionID,
userInfo.Nickname,
userInfo.Avatar,
appTypeOfficialAccount,
)
if err != nil {
return nil, err
}
token, needBindPhone, err := s.issueLoginToken(ctx, customerID)
if err != nil {
return nil, err
}
s.logger.Info("公众号登录成功",
zap.Uint("customer_id", customerID),
zap.String("client_ip", clientIP),
)
return &dto.WechatLoginResponse{
Token: token,
NeedBindPhone: needBindPhone,
IsNewUser: isNewUser,
}, nil
}
// MiniappLogin A3 小程序登录
func (s *Service) MiniappLogin(ctx context.Context, req *dto.MiniappLoginRequest, clientIP string) (*dto.WechatLoginResponse, error) {
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
assetClaims, err := s.verifyAssetToken(req.AssetToken)
if err != nil {
return nil, err
}
wechatConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
if err != nil {
return nil, err
}
if wechatConfig == nil {
return nil, errors.New(errors.CodeWechatConfigUnavailable)
}
miniService, err := wechat.NewMiniAppServiceFromConfig(wechatConfig, s.logger)
if err != nil {
s.logger.Error("创建小程序服务失败", zap.Error(err))
return nil, errors.Wrap(errors.CodeWechatConfigUnavailable, err, "小程序配置不可用")
}
openID, unionID, _, err := miniService.Code2Session(ctx, req.Code)
if err != nil {
return nil, err
}
customerID, isNewUser, err := s.loginByOpenID(
ctx,
assetClaims.AssetType,
assetClaims.AssetID,
wechatConfig.MiniappAppID,
openID,
unionID,
req.Nickname,
req.AvatarURL,
appTypeMiniapp,
)
if err != nil {
return nil, err
}
token, needBindPhone, err := s.issueLoginToken(ctx, customerID)
if err != nil {
return nil, err
}
s.logger.Info("小程序登录成功",
zap.Uint("customer_id", customerID),
zap.String("client_ip", clientIP),
)
return &dto.WechatLoginResponse{
Token: token,
NeedBindPhone: needBindPhone,
IsNewUser: isNewUser,
}, nil
}
// SendCode A4 发送验证码
func (s *Service) SendCode(ctx context.Context, req *dto.ClientSendCodeRequest, clientIP string) (*dto.ClientSendCodeResponse, error) {
if req == nil || req.Phone == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
if err := s.checkSendCodeRateLimit(ctx, req.Phone, clientIP); err != nil {
return nil, err
}
if err := s.verificationService.SendCode(ctx, req.Phone); err != nil {
s.logger.Error("发送验证码失败", zap.String("phone", req.Phone), zap.Error(err))
return nil, errors.Wrap(errors.CodeSmsSendFailed, err, "发送验证码失败")
}
cooldownKey := constants.RedisClientSendCodePhoneLimitKey(req.Phone)
if err := s.redis.Set(ctx, cooldownKey, "1", 60*time.Second).Err(); err != nil {
s.logger.Error("设置验证码冷却键失败", zap.String("phone", req.Phone), zap.Error(err))
return nil, errors.Wrap(errors.CodeRedisError, err, "设置验证码冷却失败")
}
return &dto.ClientSendCodeResponse{CooldownSeconds: 60}, nil
}
// BindPhone A5 绑定手机号
func (s *Service) BindPhone(ctx context.Context, customerID uint, req *dto.BindPhoneRequest) (*dto.BindPhoneResponse, error) {
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
if _, err := s.phoneStore.GetPrimaryPhone(ctx, customerID); err == nil {
return nil, errors.New(errors.CodeAlreadyBoundPhone)
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
}
if err := s.verificationService.VerifyCode(ctx, req.Phone, req.Code); err != nil {
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
}
if existed, err := s.phoneStore.GetByPhone(ctx, req.Phone); err == nil {
if existed.CustomerID != customerID {
return nil, errors.New(errors.CodePhoneAlreadyBound)
}
return nil, errors.New(errors.CodeAlreadyBoundPhone)
} else if err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
}
record := &model.PersonalCustomerPhone{
CustomerID: customerID,
Phone: req.Phone,
IsPrimary: true,
Status: 1,
}
if err := s.phoneStore.Create(ctx, record); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建手机号绑定记录失败")
}
return &dto.BindPhoneResponse{
Phone: req.Phone,
BoundAt: record.VerifiedAt.Format("2006-01-02 15:04:05"),
}, nil
}
// ChangePhone A6 换绑手机号
func (s *Service) ChangePhone(ctx context.Context, customerID uint, req *dto.ChangePhoneRequest) (*dto.ChangePhoneResponse, error) {
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
primary, err := s.phoneStore.GetPrimaryPhone(ctx, customerID)
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeOldPhoneMismatch)
}
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询主手机号失败")
}
if primary.Phone != req.OldPhone {
return nil, errors.New(errors.CodeOldPhoneMismatch)
}
if err := s.verificationService.VerifyCode(ctx, req.OldPhone, req.OldCode); err != nil {
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
}
if err := s.verificationService.VerifyCode(ctx, req.NewPhone, req.NewCode); err != nil {
return nil, errors.Wrap(errors.CodeVerificationCodeInvalid, err)
}
if existed, err := s.phoneStore.GetByPhone(ctx, req.NewPhone); err == nil && existed.CustomerID != customerID {
return nil, errors.New(errors.CodePhoneAlreadyBound)
} else if err != nil && err != gorm.ErrRecordNotFound {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询新手机号绑定关系失败")
}
now := time.Now()
if err := s.db.WithContext(ctx).Model(&model.PersonalCustomerPhone{}).
Where("id = ? AND customer_id = ?", primary.ID, customerID).
Updates(map[string]any{
"phone": req.NewPhone,
"verified_at": now,
"updated_at": now,
}).Error; err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "更新手机号失败")
}
return &dto.ChangePhoneResponse{
Phone: req.NewPhone,
ChangedAt: now.Format("2006-01-02 15:04:05"),
}, nil
}
// Logout A7 退出登录
func (s *Service) Logout(ctx context.Context, customerID uint) (*dto.LogoutResponse, error) {
redisKey := constants.RedisPersonalCustomerTokenKey(customerID)
if err := s.redis.Del(ctx, redisKey).Err(); err != nil {
return nil, errors.Wrap(errors.CodeRedisError, err, "退出登录失败")
}
return &dto.LogoutResponse{Success: true}, nil
}
func (s *Service) checkAssetVerifyRateLimit(ctx context.Context, clientIP string) error {
if clientIP == "" {
return nil
}
key := constants.RedisClientAuthRateLimitIPKey(clientIP)
count, err := s.redis.Incr(ctx, key).Result()
if err != nil {
return errors.Wrap(errors.CodeRedisError, err, "校验资产限流失败")
}
if count == 1 {
if expErr := s.redis.Expire(ctx, key, 60*time.Second).Err(); expErr != nil {
return errors.Wrap(errors.CodeRedisError, expErr, "设置资产限流过期时间失败")
}
}
if count > 30 {
return errors.New(errors.CodeTooManyRequests)
}
return nil
}
func (s *Service) resolveAsset(ctx context.Context, identifier string) (string, uint, error) {
var card model.IotCard
if err := s.db.WithContext(ctx).
Where("iccid = ?", identifier).
First(&card).Error; err == nil {
return assetTypeIotCard, card.ID, nil
} else if err != gorm.ErrRecordNotFound {
return "", 0, errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败")
}
var device model.Device
if err := s.db.WithContext(ctx).
Where("virtual_no = ? OR imei = ?", identifier, identifier).
First(&device).Error; err == nil {
return assetTypeDevice, device.ID, nil
} else if err != gorm.ErrRecordNotFound {
return "", 0, errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败")
}
return "", 0, errors.New(errors.CodeAssetNotFound)
}
func (s *Service) signAssetToken(assetType string, assetID uint) (string, error) {
now := time.Now()
claims := &assetTokenClaims{
AssetType: assetType,
AssetID: assetID,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(now.Add(5 * time.Minute)),
IssuedAt: jwt.NewNumericDate(now),
NotBefore: jwt.NewNumericDate(now),
},
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims)
return token.SignedString([]byte(viper.GetString("jwt.secret_key") + ":asset"))
}
func (s *Service) verifyAssetToken(assetToken string) (*assetTokenClaims, error) {
if assetToken == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
parsed, err := jwt.ParseWithClaims(assetToken, &assetTokenClaims{}, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, errors.New(errors.CodeInvalidToken)
}
return []byte(viper.GetString("jwt.secret_key") + ":asset"), nil
})
if err != nil {
return nil, errors.New(errors.CodeInvalidToken)
}
claims, ok := parsed.Claims.(*assetTokenClaims)
if !ok || !parsed.Valid || claims.AssetID == 0 || claims.AssetType == "" {
return nil, errors.New(errors.CodeInvalidToken)
}
return claims, nil
}
func (s *Service) loginByOpenID(
ctx context.Context,
assetType string,
assetID uint,
appID string,
openID string,
unionID string,
nickname string,
avatar string,
appType string,
) (uint, bool, error) {
var (
customerID uint
isNewUser bool
)
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
cid, created, findErr := s.findOrCreateCustomer(ctx, tx, appID, openID, unionID, nickname, avatar, appType)
if findErr != nil {
return findErr
}
if bindErr := s.bindAsset(ctx, tx, cid, assetType, assetID); bindErr != nil {
return bindErr
}
customerID = cid
isNewUser = created
return nil
})
if err != nil {
return 0, false, err
}
return customerID, isNewUser, nil
}
// findOrCreateCustomer 根据 OpenID/UnionID 查找或创建客户
func (s *Service) findOrCreateCustomer(
ctx context.Context,
tx *gorm.DB,
appID string,
openID string,
unionID string,
nickname string,
avatar string,
appType string,
) (uint, bool, error) {
openidStore := postgres.NewPersonalCustomerOpenIDStore(tx)
customerStore := postgres.NewPersonalCustomerStore(tx, s.redis)
if existed, err := openidStore.FindByAppIDAndOpenID(ctx, appID, openID); err == nil {
customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
if getErr != nil {
if getErr == gorm.ErrRecordNotFound {
return 0, false, errors.New(errors.CodeCustomerNotFound)
}
return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
}
if customer.Status == 0 {
return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用")
}
if nickname != "" && customer.Nickname != nickname {
customer.Nickname = nickname
}
if avatar != "" && customer.AvatarURL != avatar {
customer.AvatarURL = avatar
}
if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
}
return customer.ID, false, nil
} else if err != gorm.ErrRecordNotFound {
return 0, false, errors.Wrap(errors.CodeInternalError, err, "查询 OpenID 记录失败")
}
if unionID != "" {
if existed, err := openidStore.FindByUnionID(ctx, unionID); err == nil {
customer, getErr := customerStore.GetByID(ctx, existed.CustomerID)
if getErr != nil {
if getErr == gorm.ErrRecordNotFound {
return 0, false, errors.New(errors.CodeCustomerNotFound)
}
return 0, false, errors.Wrap(errors.CodeInternalError, getErr, "查询客户失败")
}
if customer.Status == 0 {
return 0, false, errors.New(errors.CodeForbidden, "账号已被禁用")
}
record := &model.PersonalCustomerOpenID{
CustomerID: customer.ID,
AppID: appID,
OpenID: openID,
UnionID: unionID,
AppType: appType,
}
if createErr := openidStore.Create(ctx, record); createErr != nil {
return 0, false, errors.Wrap(errors.CodeInternalError, createErr, "创建 OpenID 关联失败")
}
if nickname != "" && customer.Nickname != nickname {
customer.Nickname = nickname
}
if avatar != "" && customer.AvatarURL != avatar {
customer.AvatarURL = avatar
}
if saveErr := customerStore.Update(ctx, customer); saveErr != nil {
return 0, false, errors.Wrap(errors.CodeInternalError, saveErr, "更新客户信息失败")
}
return customer.ID, false, nil
} else if err != gorm.ErrRecordNotFound {
return 0, false, errors.Wrap(errors.CodeInternalError, err, "按 UnionID 查询失败")
}
}
newCustomer := &model.PersonalCustomer{
WxOpenID: openID,
WxUnionID: unionID,
Nickname: nickname,
AvatarURL: avatar,
Status: 1,
}
if err := customerStore.Create(ctx, newCustomer); err != nil {
return 0, false, errors.Wrap(errors.CodeInternalError, err, "创建客户失败")
}
record := &model.PersonalCustomerOpenID{
CustomerID: newCustomer.ID,
AppID: appID,
OpenID: openID,
UnionID: unionID,
AppType: appType,
}
if err := openidStore.Create(ctx, record); err != nil {
return 0, false, errors.Wrap(errors.CodeInternalError, err, "创建 OpenID 关联失败")
}
return newCustomer.ID, true, nil
}
// bindAsset 绑定客户与资产关系
func (s *Service) bindAsset(ctx context.Context, tx *gorm.DB, customerID uint, assetType string, assetID uint) error {
assetKey, err := s.resolveAssetBindingKey(ctx, tx, assetType, assetID)
if err != nil {
return err
}
var bindCount int64
if err := tx.WithContext(ctx).
Model(&model.PersonalCustomerDevice{}).
Where("virtual_no = ?", assetKey).
Count(&bindCount).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询资产绑定关系失败")
}
firstEverBind := bindCount == 0
bindStore := postgres.NewPersonalCustomerDeviceStore(tx)
exists, err := bindStore.ExistsByCustomerAndDevice(ctx, customerID, assetKey)
if err != nil {
return errors.Wrap(errors.CodeInternalError, err, "查询客户资产绑定关系失败")
}
if !exists {
record := &model.PersonalCustomerDevice{
CustomerID: customerID,
VirtualNo: assetKey,
Status: 1,
}
if err := bindStore.Create(ctx, record); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "创建资产绑定关系失败")
}
}
if firstEverBind {
if err := s.markAssetAsSold(ctx, tx, assetType, assetID); err != nil {
return err
}
}
return nil
}
func (s *Service) resolveAssetBindingKey(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) (string, error) {
if assetType == assetTypeIotCard {
var card model.IotCard
if err := tx.WithContext(ctx).First(&card, assetID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return "", errors.New(errors.CodeAssetNotFound)
}
return "", errors.Wrap(errors.CodeInternalError, err, "查询卡资产失败")
}
return card.VirtualNo, nil
}
if assetType == assetTypeDevice {
var device model.Device
if err := tx.WithContext(ctx).First(&device, assetID).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return "", errors.New(errors.CodeAssetNotFound)
}
return "", errors.Wrap(errors.CodeInternalError, err, "查询设备资产失败")
}
if device.VirtualNo != "" {
return device.VirtualNo, nil
}
return device.IMEI, nil
}
return "", errors.New(errors.CodeInvalidParam)
}
func (s *Service) markAssetAsSold(ctx context.Context, tx *gorm.DB, assetType string, assetID uint) error {
if assetType == assetTypeIotCard {
if err := tx.WithContext(ctx).
Model(&model.IotCard{}).
Where("id = ? AND asset_status = ?", assetID, 1).
Update("asset_status", 2).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新卡资产状态失败")
}
return nil
}
if assetType == assetTypeDevice {
if err := tx.WithContext(ctx).
Model(&model.Device{}).
Where("id = ? AND asset_status = ?", assetID, 1).
Update("asset_status", 2).Error; err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新设备资产状态失败")
}
return nil
}
return errors.New(errors.CodeInvalidParam)
}
func (s *Service) issueLoginToken(ctx context.Context, customerID uint) (string, bool, error) {
token, err := s.jwtManager.GeneratePersonalCustomerToken(customerID, "")
if err != nil {
return "", false, errors.Wrap(errors.CodeInternalError, err, "生成登录令牌失败")
}
claims, err := s.jwtManager.VerifyPersonalCustomerToken(token)
if err != nil {
return "", false, errors.Wrap(errors.CodeInternalError, err, "解析登录令牌失败")
}
ttl := time.Until(claims.ExpiresAt.Time)
if ttl <= 0 {
ttl = 24 * time.Hour
}
redisKey := constants.RedisPersonalCustomerTokenKey(customerID)
if err := s.redis.Set(ctx, redisKey, token, ttl).Err(); err != nil {
return "", false, errors.Wrap(errors.CodeRedisError, err, "保存登录状态失败")
}
needBindPhone := false
if viper.GetBool("client.require_phone_binding") {
if _, err := s.phoneStore.GetPrimaryPhone(ctx, customerID); err == gorm.ErrRecordNotFound {
needBindPhone = true
} else if err != nil {
return "", false, errors.Wrap(errors.CodeInternalError, err, "查询手机号绑定关系失败")
}
}
return token, needBindPhone, nil
}
func (s *Service) checkSendCodeRateLimit(ctx context.Context, phone, clientIP string) error {
phoneCooldownKey := constants.RedisClientSendCodePhoneLimitKey(phone)
exists, err := s.redis.Exists(ctx, phoneCooldownKey).Result()
if err != nil {
return errors.Wrap(errors.CodeRedisError, err, "检查手机号冷却失败")
}
if exists > 0 {
return errors.New(errors.CodeTooManyRequests, "验证码发送过于频繁,请稍后再试")
}
ipKey := constants.RedisClientSendCodeIPHourKey(clientIP)
ipCount, err := s.redis.Incr(ctx, ipKey).Result()
if err != nil {
return errors.Wrap(errors.CodeRedisError, err, "检查 IP 限流失败")
}
if ipCount == 1 {
if expErr := s.redis.Expire(ctx, ipKey, time.Hour).Err(); expErr != nil {
return errors.Wrap(errors.CodeRedisError, expErr, "设置 IP 限流过期时间失败")
}
}
if ipCount > 20 {
return errors.New(errors.CodeTooManyRequests)
}
phoneDayKey := constants.RedisClientSendCodePhoneDayKey(phone)
phoneDayCount, err := s.redis.Incr(ctx, phoneDayKey).Result()
if err != nil {
return errors.Wrap(errors.CodeRedisError, err, "检查手机号日限流失败")
}
if phoneDayCount == 1 {
nextDay := time.Now().Truncate(24 * time.Hour).Add(24 * time.Hour)
ttl := time.Until(nextDay)
if expErr := s.redis.Expire(ctx, phoneDayKey, ttl).Err(); expErr != nil {
return errors.Wrap(errors.CodeRedisError, expErr, "设置手机号日限流过期时间失败")
}
}
if phoneDayCount > 10 {
return errors.New(errors.CodeTooManyRequests)
}
return nil
}

View File

@@ -0,0 +1,701 @@
// Package client_order 提供 C 端订单下单服务。
package client_order
import (
"context"
"fmt"
"math/rand"
"slices"
"strconv"
"strings"
"time"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
"github.com/break/junhong_cmp_fiber/internal/service/purchase_validation"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
"github.com/break/junhong_cmp_fiber/pkg/wechat"
"github.com/bytedance/sonic"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/datatypes"
"gorm.io/gorm"
)
const (
clientPurchaseIdempotencyTTL = 5 * time.Minute
clientPurchaseLockTTL = 10 * time.Second
)
// WechatConfigServiceInterface 微信配置服务接口。
type WechatConfigServiceInterface interface {
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
}
// ForceRechargeRequirement 强充要求。
type ForceRechargeRequirement struct {
NeedForceRecharge bool
ForceRechargeAmount int64
}
// Service 客户端订单服务。
type Service struct {
assetService *asset.Service
purchaseValidationService *purchase_validation.Service
orderStore *postgres.OrderStore
rechargeRecordStore *postgres.AssetRechargeStore
walletStore *postgres.AssetWalletStore
personalDeviceStore *postgres.PersonalCustomerDeviceStore
openIDStore *postgres.PersonalCustomerOpenIDStore
wechatConfigService WechatConfigServiceInterface
packageSeriesStore *postgres.PackageSeriesStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
redis *redis.Client
logger *zap.Logger
}
// New 创建客户端订单服务。
func New(
assetService *asset.Service,
purchaseValidationService *purchase_validation.Service,
orderStore *postgres.OrderStore,
rechargeRecordStore *postgres.AssetRechargeStore,
walletStore *postgres.AssetWalletStore,
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
openIDStore *postgres.PersonalCustomerOpenIDStore,
wechatConfigService WechatConfigServiceInterface,
packageSeriesStore *postgres.PackageSeriesStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
redisClient *redis.Client,
logger *zap.Logger,
) *Service {
return &Service{
assetService: assetService,
purchaseValidationService: purchaseValidationService,
orderStore: orderStore,
rechargeRecordStore: rechargeRecordStore,
walletStore: walletStore,
personalDeviceStore: personalDeviceStore,
openIDStore: openIDStore,
wechatConfigService: wechatConfigService,
packageSeriesStore: packageSeriesStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
redis: redisClient,
logger: logger,
}
}
// CreateOrder 创建客户端订单。
func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.ClientCreateOrderRequest) (*dto.ClientCreateOrderResponse, error) {
if req == nil {
return nil, errors.New(errors.CodeInvalidParam)
}
if s.redis == nil {
return nil, errors.New(errors.CodeInternalError, "Redis 服务未配置")
}
skipPermissionCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{})
assetInfo, err := s.assetService.Resolve(skipPermissionCtx, strings.TrimSpace(req.Identifier))
if err != nil {
return nil, err
}
if err := s.checkAssetOwnership(skipPermissionCtx, customerID, assetInfo.VirtualNo); err != nil {
return nil, err
}
validationResult, err := s.validatePurchase(skipPermissionCtx, assetInfo, req.PackageIDs)
if err != nil {
return nil, err
}
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != 1 {
return nil, errors.New(errors.CodeNeedRealname)
}
activeConfig, appID, err := s.resolveWechatConfig(skipPermissionCtx, req.AppType)
if err != nil {
return nil, err
}
openID, err := s.resolveCustomerOpenID(skipPermissionCtx, customerID, appID)
if err != nil {
return nil, err
}
businessKey := buildClientPurchaseBusinessKey(customerID, assetInfo, req)
redisKey := constants.RedisClientPurchaseIdempotencyKey(businessKey)
lockKey := constants.RedisClientPurchaseLockKey(assetInfo.AssetType, assetInfo.AssetID)
lockAcquired, err := s.redis.SetNX(skipPermissionCtx, lockKey, time.Now().String(), clientPurchaseLockTTL).Result()
if err != nil {
s.logger.Warn("获取客户端购买分布式锁失败,继续尝试幂等标记",
zap.Error(err),
zap.String("lock_key", lockKey),
)
}
if err == nil && !lockAcquired {
return nil, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交")
}
claimed, err := s.redis.SetNX(skipPermissionCtx, redisKey, "processing", clientPurchaseIdempotencyTTL).Result()
if err != nil {
if lockAcquired {
_ = s.redis.Del(skipPermissionCtx, lockKey).Err()
}
return nil, errors.Wrap(errors.CodeInternalError, err, "设置客户端购买幂等标记失败")
}
if !claimed {
if lockAcquired {
_ = s.redis.Del(skipPermissionCtx, lockKey).Err()
}
return nil, errors.New(errors.CodeTooManyRequests, "订单正在创建中,请勿重复提交")
}
created := false
defer func() {
if lockAcquired {
_ = s.redis.Del(skipPermissionCtx, lockKey).Err()
}
if !created {
_ = s.redis.Del(skipPermissionCtx, redisKey).Err()
}
}()
paymentService, err := s.newPaymentService(activeConfig, appID)
if err != nil {
return nil, err
}
forceRecharge := s.checkForceRechargeRequirement(skipPermissionCtx, validationResult)
if forceRecharge.NeedForceRecharge {
return s.createForceRechargeOrder(skipPermissionCtx, customerID, appID, openID, assetInfo, validationResult, activeConfig, forceRecharge, redisKey, paymentService, &created)
}
return s.createPackageOrder(skipPermissionCtx, customerID, appID, openID, validationResult, activeConfig, redisKey, paymentService, &created)
}
func (s *Service) checkAssetOwnership(ctx context.Context, customerID uint, virtualNo string) error {
owned, err := s.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, virtualNo)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败")
}
if owned {
return nil
}
records, err := s.personalDeviceStore.GetByCustomerID(ctx, customerID)
if err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "查询资产归属失败")
}
for _, record := range records {
if record == nil {
continue
}
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
return nil
}
}
return errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
}
func (s *Service) validatePurchase(ctx context.Context, assetInfo *dto.AssetResolveResponse, packageIDs []uint) (*purchase_validation.PurchaseValidationResult, error) {
switch assetInfo.AssetType {
case "card":
return s.purchaseValidationService.ValidateCardPurchase(ctx, assetInfo.AssetID, packageIDs)
case constants.ResourceTypeDevice:
return s.purchaseValidationService.ValidateDevicePurchase(ctx, assetInfo.AssetID, packageIDs)
default:
return nil, errors.New(errors.CodeInvalidParam)
}
}
func (s *Service) resolveWechatConfig(ctx context.Context, appType string) (*model.WechatConfig, string, error) {
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
if err != nil {
return nil, "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信配置失败")
}
if activeConfig == nil {
return nil, "", errors.New(errors.CodeWechatPayFailed, "未找到生效的微信支付配置")
}
switch appType {
case "official_account":
if activeConfig.OaAppID == "" {
return nil, "", errors.New(errors.CodeWechatPayFailed, "公众号支付配置不完整")
}
return activeConfig, activeConfig.OaAppID, nil
case "miniapp":
if activeConfig.MiniappAppID == "" {
return nil, "", errors.New(errors.CodeWechatPayFailed, "小程序支付配置不完整")
}
return activeConfig, activeConfig.MiniappAppID, nil
default:
return nil, "", errors.New(errors.CodeInvalidParam)
}
}
func (s *Service) resolveCustomerOpenID(ctx context.Context, customerID uint, appID string) (string, error) {
records, err := s.openIDStore.ListByCustomerID(ctx, customerID)
if err != nil {
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权信息失败")
}
for _, record := range records {
if record == nil {
continue
}
if record.AppID == appID && strings.TrimSpace(record.OpenID) != "" {
return record.OpenID, nil
}
}
return "", errors.New(errors.CodeNotFound, "未找到当前应用的微信授权信息")
}
func (s *Service) newPaymentService(wechatConfig *model.WechatConfig, appID string) (*wechat.PaymentService, error) {
cache := wechat.NewRedisCache(s.redis)
paymentApp, err := wechat.NewPaymentAppFromConfig(wechatConfig, appID, cache, s.logger)
if err != nil {
return nil, errors.Wrap(errors.CodeWechatPayFailed, err, "创建微信支付应用失败")
}
return wechat.NewPaymentService(paymentApp, s.logger), nil
}
func (s *Service) createPackageOrder(
ctx context.Context,
customerID uint,
appID string,
openID string,
validationResult *purchase_validation.PurchaseValidationResult,
activeConfig *model.WechatConfig,
redisKey string,
paymentService *wechat.PaymentService,
created *bool,
) (*dto.ClientCreateOrderResponse, error) {
order, err := s.buildPendingOrder(customerID, validationResult, activeConfig)
if err != nil {
return nil, err
}
items, err := s.buildOrderItems(ctx, customerID, validationResult)
if err != nil {
return nil, err
}
if err := s.orderStore.Create(ctx, order, items); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
}
s.markClientPurchaseCreated(ctx, redisKey, order.OrderNo)
*created = true
description := "套餐购买"
if len(items) > 0 && items[0] != nil && items[0].PackageName != "" {
description = items[0].PackageName
}
payResult, err := paymentService.CreateJSAPIOrder(ctx, order.OrderNo, description, openID, int(order.TotalAmount))
if err != nil {
return nil, err
}
return &dto.ClientCreateOrderResponse{
OrderType: "package",
Order: &dto.ClientOrderInfo{
OrderID: order.ID,
OrderNo: order.OrderNo,
TotalAmount: order.TotalAmount,
PaymentStatus: orderStatusToClientStatus(order.PaymentStatus),
CreatedAt: formatClientServiceTime(order.CreatedAt),
},
PayConfig: buildClientPayConfig(appID, payResult.PayConfig),
}, nil
}
func (s *Service) createForceRechargeOrder(
ctx context.Context,
customerID uint,
appID string,
openID string,
assetInfo *dto.AssetResolveResponse,
validationResult *purchase_validation.PurchaseValidationResult,
activeConfig *model.WechatConfig,
forceRecharge *ForceRechargeRequirement,
redisKey string,
paymentService *wechat.PaymentService,
created *bool,
) (*dto.ClientCreateOrderResponse, error) {
resourceType, resourceID, err := resolveWalletResource(validationResult)
if err != nil {
return nil, err
}
wallet, err := s.walletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil, errors.New(errors.CodeWalletNotFound, "钱包不存在")
}
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败")
}
linkedPackageIDs, err := sonic.Marshal(extractPackageIDs(validationResult.Packages))
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化关联套餐失败")
}
carrierID := resourceID
recharge := &model.AssetRechargeRecord{
UserID: customerID,
AssetWalletID: wallet.ID,
ResourceType: resourceType,
ResourceID: resourceID,
RechargeNo: generateClientRechargeNo(),
Amount: forceRecharge.ForceRechargeAmount,
PaymentMethod: model.PaymentMethodWechat,
PaymentConfigID: &activeConfig.ID,
Status: 1,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
OperatorType: "personal_customer",
Generation: resolveGeneration(validationResult),
LinkedPackageIDs: datatypes.JSON(linkedPackageIDs),
LinkedOrderType: resolveOrderType(validationResult),
LinkedCarrierType: assetInfo.AssetType,
LinkedCarrierID: &carrierID,
AutoPurchaseStatus: constants.AutoPurchaseStatusPending,
}
if err := s.rechargeRecordStore.Create(ctx, recharge); err != nil {
return nil, errors.Wrap(errors.CodeDatabaseError, err, "创建充值记录失败")
}
s.markClientPurchaseCreated(ctx, redisKey, recharge.RechargeNo)
*created = true
payResult, err := paymentService.CreateJSAPIOrder(ctx, recharge.RechargeNo, "余额充值", openID, int(recharge.Amount))
if err != nil {
return nil, err
}
return &dto.ClientCreateOrderResponse{
OrderType: "recharge",
Recharge: &dto.ClientRechargeInfo{
RechargeID: recharge.ID,
RechargeNo: recharge.RechargeNo,
Amount: recharge.Amount,
Status: rechargeStatusToClientStatus(recharge.Status),
AutoPurchaseStatus: recharge.AutoPurchaseStatus,
},
PayConfig: buildClientPayConfig(appID, payResult.PayConfig),
LinkedPackageInfo: buildLinkedPackageInfo(validationResult, forceRecharge),
}, nil
}
func (s *Service) buildPendingOrder(customerID uint, result *purchase_validation.PurchaseValidationResult, activeConfig *model.WechatConfig) (*model.Order, error) {
orderType := resolveOrderType(result)
if orderType == "" {
return nil, errors.New(errors.CodeInvalidParam)
}
now := time.Now()
expiresAt := now.Add(constants.OrderExpireTimeout)
order := &model.Order{
BaseModel: model.BaseModel{
Creator: customerID,
Updater: customerID,
},
OrderNo: s.orderStore.GenerateOrderNo(),
OrderType: orderType,
BuyerType: model.BuyerTypePersonal,
BuyerID: customerID,
TotalAmount: result.TotalPrice,
PaymentMethod: model.PaymentMethodWechat,
PaymentStatus: model.PaymentStatusPending,
CommissionStatus: model.CommissionStatusPending,
CommissionConfigVersion: 0,
Source: constants.OrderSourceClient,
Generation: resolveGeneration(result),
ExpiresAt: &expiresAt,
PaymentConfigID: &activeConfig.ID,
}
if result.Card != nil {
order.IotCardID = &result.Card.ID
order.SeriesID = result.Card.SeriesID
order.SellerShopID = result.Card.ShopID
} else if result.Device != nil {
order.DeviceID = &result.Device.ID
order.SeriesID = result.Device.SeriesID
order.SellerShopID = result.Device.ShopID
}
return order, nil
}
func (s *Service) buildOrderItems(ctx context.Context, customerID uint, result *purchase_validation.PurchaseValidationResult) ([]*model.OrderItem, error) {
sellerShopID := resolveSellerShopID(result)
items := make([]*model.OrderItem, 0, len(result.Packages))
for _, pkg := range result.Packages {
if pkg == nil {
continue
}
unitPrice, err := s.purchaseValidationService.GetPurchasePrice(ctx, pkg, sellerShopID)
if err != nil {
return nil, err
}
items = append(items, &model.OrderItem{
BaseModel: model.BaseModel{
Creator: customerID,
Updater: customerID,
},
PackageID: pkg.ID,
PackageName: pkg.PackageName,
Quantity: 1,
UnitPrice: unitPrice,
Amount: unitPrice,
})
}
return items, nil
}
func (s *Service) checkForceRechargeRequirement(ctx context.Context, result *purchase_validation.PurchaseValidationResult) *ForceRechargeRequirement {
defaultResult := &ForceRechargeRequirement{NeedForceRecharge: false}
var seriesID *uint
var sellerShopID uint
if result.Card != nil {
seriesID = result.Card.SeriesID
if result.Card.ShopID != nil {
sellerShopID = *result.Card.ShopID
}
} else if result.Device != nil {
seriesID = result.Device.SeriesID
if result.Device.ShopID != nil {
sellerShopID = *result.Device.ShopID
}
}
if seriesID == nil || *seriesID == 0 {
return defaultResult
}
series, err := s.packageSeriesStore.GetByID(ctx, *seriesID)
if err != nil {
s.logger.Warn("查询套餐系列失败", zap.Uint("series_id", *seriesID), zap.Error(err))
return defaultResult
}
config, err := series.GetOneTimeCommissionConfig()
if err != nil || config == nil || !config.Enable {
return defaultResult
}
if config.TriggerType == model.OneTimeCommissionTriggerFirstRecharge {
return &ForceRechargeRequirement{
NeedForceRecharge: true,
ForceRechargeAmount: config.Threshold,
}
}
if config.EnableForceRecharge {
amount := config.ForceAmount
if amount == 0 {
amount = config.Threshold
}
return &ForceRechargeRequirement{
NeedForceRecharge: true,
ForceRechargeAmount: amount,
}
}
if sellerShopID > 0 {
allocation, allocErr := s.shopSeriesAllocationStore.GetByShopAndSeries(ctx, sellerShopID, *seriesID)
if allocErr == nil && allocation.EnableForceRecharge {
amount := allocation.ForceRechargeAmount
if amount == 0 {
amount = config.Threshold
}
return &ForceRechargeRequirement{
NeedForceRecharge: true,
ForceRechargeAmount: amount,
}
}
}
return defaultResult
}
func (s *Service) markClientPurchaseCreated(ctx context.Context, redisKey string, value string) {
if err := s.redis.Set(ctx, redisKey, value, clientPurchaseIdempotencyTTL).Err(); err != nil {
s.logger.Warn("设置客户端购买幂等标记失败",
zap.String("redis_key", redisKey),
zap.Error(err),
)
}
}
func buildLinkedPackageInfo(result *purchase_validation.PurchaseValidationResult, forceRecharge *ForceRechargeRequirement) *dto.LinkedPackageInfo {
packageNames := make([]string, 0, len(result.Packages))
for _, pkg := range result.Packages {
if pkg == nil || pkg.PackageName == "" {
continue
}
packageNames = append(packageNames, pkg.PackageName)
}
return &dto.LinkedPackageInfo{
PackageNames: packageNames,
TotalPackageAmount: result.TotalPrice,
ForceRechargeAmount: forceRecharge.ForceRechargeAmount,
WalletCredit: forceRecharge.ForceRechargeAmount,
}
}
func buildClientPayConfig(appID string, payConfig any) *dto.ClientPayConfig {
configMap, _ := payConfig.(map[string]any)
if configMap == nil {
configMap = map[string]any{}
}
return &dto.ClientPayConfig{
AppID: firstNonEmpty(stringFromAny(configMap["appId"]), appID),
Timestamp: firstNonEmpty(stringFromAny(configMap["timeStamp"]), stringFromAny(configMap["timestamp"])),
NonceStr: stringFromAny(configMap["nonceStr"]),
PackageVal: stringFromAny(configMap["package"]),
SignType: stringFromAny(configMap["signType"]),
PaySign: stringFromAny(configMap["paySign"]),
}
}
func resolveWalletResource(result *purchase_validation.PurchaseValidationResult) (string, uint, error) {
if result.Card != nil {
return constants.AssetWalletResourceTypeIotCard, result.Card.ID, nil
}
if result.Device != nil {
return constants.AssetWalletResourceTypeDevice, result.Device.ID, nil
}
return "", 0, errors.New(errors.CodeInvalidParam)
}
func resolveOrderType(result *purchase_validation.PurchaseValidationResult) string {
if result.Card != nil {
return model.OrderTypeSingleCard
}
if result.Device != nil {
return model.OrderTypeDevice
}
return ""
}
func resolveGeneration(result *purchase_validation.PurchaseValidationResult) int {
if result.Card != nil && result.Card.Generation > 0 {
return result.Card.Generation
}
if result.Device != nil && result.Device.Generation > 0 {
return result.Device.Generation
}
return 1
}
func resolveSellerShopID(result *purchase_validation.PurchaseValidationResult) uint {
if result.Card != nil && result.Card.ShopID != nil {
return *result.Card.ShopID
}
if result.Device != nil && result.Device.ShopID != nil {
return *result.Device.ShopID
}
return 0
}
func packagesNeedRealname(packages []*model.Package) bool {
for _, pkg := range packages {
if pkg != nil && pkg.EnableRealnameActivation {
return true
}
}
return false
}
func extractPackageIDs(packages []*model.Package) []uint {
ids := make([]uint, 0, len(packages))
for _, pkg := range packages {
if pkg == nil {
continue
}
ids = append(ids, pkg.ID)
}
return ids
}
func buildClientPurchaseBusinessKey(customerID uint, assetInfo *dto.AssetResolveResponse, req *dto.ClientCreateOrderRequest) string {
packageIDs := make([]uint, 0, len(req.PackageIDs))
packageIDs = append(packageIDs, req.PackageIDs...)
slices.Sort(packageIDs)
parts := make([]string, 0, len(packageIDs))
for _, packageID := range packageIDs {
parts = append(parts, strconv.FormatUint(uint64(packageID), 10))
}
return fmt.Sprintf("%d:%s:%d:%s:%s", customerID, assetInfo.AssetType, assetInfo.AssetID, req.AppType, strings.Join(parts, ","))
}
func orderStatusToClientStatus(status int) int {
switch status {
case model.PaymentStatusPending:
return 0
case model.PaymentStatusPaid:
return 1
case model.PaymentStatusCancelled:
return 2
default:
return status
}
}
func rechargeStatusToClientStatus(status int) int {
switch status {
case 1:
return 0
case 2, 3:
return 1
default:
return 2
}
}
func formatClientServiceTime(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
func generateClientRechargeNo() string {
return fmt.Sprintf("CRCH%d%06d", time.Now().UnixNano()/1e6, rand.Intn(1000000))
}
func stringFromAny(value any) string {
if value == nil {
return ""
}
return fmt.Sprint(value)
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}

View File

@@ -8,6 +8,7 @@ import (
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
"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"
"go.uber.org/zap"
"gorm.io/gorm"
@@ -201,7 +202,7 @@ func (s *Service) CalculateCostDiffCommission(ctx context.Context, order *model.
}
func (s *Service) triggerOneTimeCommissionForCardInTx(ctx context.Context, tx *gorm.DB, order *model.Order, cardID uint) error {
if order.IsPurchaseOnBehalf {
if order.IsPurchaseOnBehalf || order.Source != constants.OrderSourceClient {
return nil
}
@@ -285,7 +286,7 @@ func (s *Service) TriggerOneTimeCommissionForCard(ctx context.Context, order *mo
}
func (s *Service) triggerOneTimeCommissionForDeviceInTx(ctx context.Context, tx *gorm.DB, order *model.Order, deviceID uint) error {
if order.IsPurchaseOnBehalf {
if order.IsPurchaseOnBehalf || order.Source != constants.OrderSourceClient {
return nil
}

View File

@@ -1,126 +0,0 @@
// Package customer 提供客户管理的业务逻辑服务
// 包含客户信息管理、客户查询等功能
package customer
import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// Service 个人客户业务服务
type Service struct {
customerStore *postgres.PersonalCustomerStore
}
// New 创建个人客户服务
func New(customerStore *postgres.PersonalCustomerStore) *Service {
return &Service{
customerStore: customerStore,
}
}
// Create 创建个人客户
func (s *Service) Create(ctx context.Context, req *dto.CreatePersonalCustomerRequest) (*model.PersonalCustomer, error) {
// 检查手机号唯一性
if req.Phone != "" {
existing, err := s.customerStore.GetByPhone(ctx, req.Phone)
if err == nil && existing != nil {
return nil, errors.New(errors.CodeCustomerPhoneExists, "手机号已存在")
}
}
// 创建个人客户
// 注意:根据新的数据模型,手机号应该存储在 PersonalCustomerPhone 表中
// 这里暂时先创建客户记录,手机号的存储后续通过 PersonalCustomerPhoneStore 实现
customer := &model.PersonalCustomer{
Nickname: req.Nickname,
AvatarURL: req.AvatarURL,
WxOpenID: req.WxOpenID,
WxUnionID: req.WxUnionID,
Status: constants.StatusEnabled,
}
if err := s.customerStore.Create(ctx, customer); err != nil {
return nil, err
}
// TODO: 创建 PersonalCustomerPhone 记录,需要通过 PersonalCustomerPhoneStore 创建手机号关联
return customer, nil
}
// Update 更新个人客户信息
func (s *Service) Update(ctx context.Context, id uint, req *dto.UpdatePersonalCustomerRequest) (*model.PersonalCustomer, error) {
// 查询客户
customer, err := s.customerStore.GetByID(ctx, id)
if err != nil {
return nil, errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
}
// TODO: 手机号的更新逻辑需要通过 PersonalCustomerPhoneStore 更新或创建手机号记录
// 更新字段
if req.Nickname != nil {
customer.Nickname = *req.Nickname
}
if req.AvatarURL != nil {
customer.AvatarURL = *req.AvatarURL
}
if err := s.customerStore.Update(ctx, customer); err != nil {
return nil, err
}
return customer, nil
}
// BindWeChat 绑定微信信息
func (s *Service) BindWeChat(ctx context.Context, id uint, wxOpenID, wxUnionID string) error {
customer, err := s.customerStore.GetByID(ctx, id)
if err != nil {
return errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
}
customer.WxOpenID = wxOpenID
customer.WxUnionID = wxUnionID
return s.customerStore.Update(ctx, customer)
}
// GetByID 获取个人客户详情
func (s *Service) GetByID(ctx context.Context, id uint) (*model.PersonalCustomer, error) {
customer, err := s.customerStore.GetByID(ctx, id)
if err != nil {
return nil, errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
}
return customer, nil
}
// GetByPhone 根据手机号获取个人客户
func (s *Service) GetByPhone(ctx context.Context, phone string) (*model.PersonalCustomer, error) {
customer, err := s.customerStore.GetByPhone(ctx, phone)
if err != nil {
return nil, errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
}
return customer, nil
}
// GetByWxOpenID 根据微信 OpenID 获取个人客户
func (s *Service) GetByWxOpenID(ctx context.Context, wxOpenID string) (*model.PersonalCustomer, error) {
customer, err := s.customerStore.GetByWxOpenID(ctx, wxOpenID)
if err != nil {
return nil, errors.New(errors.CodeCustomerNotFound, "个人客户不存在")
}
return customer, nil
}
// List 查询个人客户列表
func (s *Service) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.PersonalCustomer, int64, error) {
return s.customerStore.List(ctx, opts, filters)
}

View File

@@ -973,7 +973,7 @@ func (s *Service) StartDevice(ctx context.Context, deviceID uint) error {
// 全部失败时返回 error
if successCount == 0 && lastErr != nil {
return errors.Wrap(errors.CodeInternalError, lastErr, "设备复机失败")
return errors.Wrap(errors.CodeGatewayError, lastErr, "设备复机失败,所有卡均复机失败")
}
return nil

View File

@@ -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
}

View File

@@ -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 "未知状态"
}
}

View File

@@ -264,15 +264,18 @@ func (s *StopResumeService) ManualStopCard(ctx context.Context, iccid string) er
}
if err := s.stopCardWithRetry(ctx, card); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "调网关停机失败")
return errors.Wrap(errors.CodeGatewayError, err, "调用运营商停机失败,请稍后重试")
}
now := time.Now()
return s.db.WithContext(ctx).Model(card).Updates(map[string]any{
if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
"network_status": constants.NetworkStatusOffline,
"stopped_at": now,
"stop_reason": constants.StopReasonManual,
}).Error
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
}
return nil
}
// ManualStartCard 手动复机单张卡通过ICCID
@@ -300,13 +303,16 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
}
if err := s.resumeCardWithRetry(ctx, card); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "调网关复机失败")
return errors.Wrap(errors.CodeGatewayError, err, "调用运营商复机失败,请稍后重试")
}
now := time.Now()
return s.db.WithContext(ctx).Model(card).Updates(map[string]any{
if err := s.db.WithContext(ctx).Model(card).Updates(map[string]any{
"network_status": constants.NetworkStatusOnline,
"resumed_at": now,
"stop_reason": "",
}).Error
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新卡状态失败")
}
return nil
}

View File

@@ -25,13 +25,19 @@ import (
"gorm.io/gorm"
)
// WechatConfigServiceInterface 支付配置服务接口
// 用于查询当前生效的支付配置
type WechatConfigServiceInterface interface {
GetActiveConfig(ctx context.Context) (*model.WechatConfig, error)
}
type Service struct {
db *gorm.DB
redis *redis.Client
orderStore *postgres.OrderStore
orderItemStore *postgres.OrderItemStore
agentWalletStore *postgres.AgentWalletStore
cardWalletStore *postgres.CardWalletStore
assetWalletStore *postgres.AssetWalletStore
purchaseValidationService *purchase_validation.Service
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore
@@ -40,6 +46,7 @@ type Service struct {
packageSeriesStore *postgres.PackageSeriesStore
packageUsageStore *postgres.PackageUsageStore
packageStore *postgres.PackageStore
wechatConfigService WechatConfigServiceInterface
wechatPayment wechat.PaymentServiceInterface
queueClient *queue.Client
logger *zap.Logger
@@ -51,7 +58,7 @@ func New(
orderStore *postgres.OrderStore,
orderItemStore *postgres.OrderItemStore,
agentWalletStore *postgres.AgentWalletStore,
cardWalletStore *postgres.CardWalletStore,
assetWalletStore *postgres.AssetWalletStore,
purchaseValidationService *purchase_validation.Service,
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
shopSeriesAllocationStore *postgres.ShopSeriesAllocationStore,
@@ -60,6 +67,7 @@ func New(
packageSeriesStore *postgres.PackageSeriesStore,
packageUsageStore *postgres.PackageUsageStore,
packageStore *postgres.PackageStore,
wechatConfigService WechatConfigServiceInterface,
wechatPayment wechat.PaymentServiceInterface,
queueClient *queue.Client,
logger *zap.Logger,
@@ -70,7 +78,7 @@ func New(
orderStore: orderStore,
orderItemStore: orderItemStore,
agentWalletStore: agentWalletStore,
cardWalletStore: cardWalletStore,
assetWalletStore: assetWalletStore,
purchaseValidationService: purchaseValidationService,
shopPackageAllocationStore: shopPackageAllocationStore,
shopSeriesAllocationStore: shopSeriesAllocationStore,
@@ -79,6 +87,7 @@ func New(
packageSeriesStore: packageSeriesStore,
packageUsageStore: packageUsageStore,
packageStore: packageStore,
wechatConfigService: wechatConfigService,
wechatPayment: wechatPayment,
queueClient: queueClient,
logger: logger,
@@ -326,19 +335,48 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
var validationResult *purchase_validation.PurchaseValidationResult
var err error
if req.PaymentMethod == model.PaymentMethodOffline {
// offline 订单:绕过代理 Allocation 上架检查,仅验证套餐全局状态
if req.OrderType == model.OrderTypeSingleCard {
if req.IotCardID == nil {
return nil, errors.New(errors.CodeInvalidParam, "单卡购买必须指定IoT卡ID")
}
validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, *req.IotCardID, req.PackageIDs)
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineCardPurchase(ctx, *req.IotCardID, req.PackageIDs)
} else if req.OrderType == model.OrderTypeDevice {
if req.DeviceID == nil {
return nil, errors.New(errors.CodeInvalidParam, "设备购买必须指定设备ID")
}
validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, *req.DeviceID, req.PackageIDs)
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineDevicePurchase(ctx, *req.DeviceID, req.PackageIDs)
} else {
return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型")
}
} else {
if req.OrderType == model.OrderTypeSingleCard {
if req.IotCardID == nil {
return nil, errors.New(errors.CodeInvalidParam, "单卡购买必须指定IoT卡ID")
}
// 平台账号代表平台直接下单,不受卡所属代理的套餐分配限制;
// 代理账号下单时,卡所属代理必须已将套餐上架分配
if buyerType == model.BuyerTypeAgent {
validationResult, err = s.purchaseValidationService.ValidateCardPurchase(ctx, *req.IotCardID, req.PackageIDs)
} else {
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineCardPurchase(ctx, *req.IotCardID, req.PackageIDs)
}
} else if req.OrderType == model.OrderTypeDevice {
if req.DeviceID == nil {
return nil, errors.New(errors.CodeInvalidParam, "设备购买必须指定设备ID")
}
// 平台账号代表平台直接下单,不受设备所属代理的套餐分配限制;
// 代理账号下单时,设备所属代理必须已将套餐上架分配
if buyerType == model.BuyerTypeAgent {
validationResult, err = s.purchaseValidationService.ValidateDevicePurchase(ctx, *req.DeviceID, req.PackageIDs)
} else {
validationResult, err = s.purchaseValidationService.ValidateAdminOfflineDevicePurchase(ctx, *req.DeviceID, req.PackageIDs)
}
} else {
return nil, errors.New(errors.CodeInvalidParam, "无效的订单类型")
}
}
if err != nil {
return nil, err
@@ -362,10 +400,13 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
lockKey := constants.RedisOrderCreateLockKey(carrierType, carrierID)
defer s.redis.Del(ctx, lockKey)
// offline 订单不检查强充:平台直接操作,不涉及消费者支付门槛,不产生一次性佣金触发条件
if req.PaymentMethod != model.PaymentMethodOffline {
forceRechargeCheck := s.checkForceRechargeRequirement(ctx, validationResult)
if forceRechargeCheck.NeedForceRecharge && validationResult.TotalPrice < forceRechargeCheck.ForceRechargeAmount {
return nil, errors.New(errors.CodeForceRechargeRequired, "首次购买需满足最低充值要求")
}
}
userID := middleware.GetUserIDFromContext(ctx)
@@ -511,6 +552,29 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
return nil, errors.New(errors.CodeInvalidParam, "后台仅支持钱包支付或线下支付")
}
// 查询当前生效的支付配置
var paymentConfigID *uint
if req.PaymentMethod == model.PaymentMethodWechat || req.PaymentMethod == model.PaymentMethodAlipay {
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
if err != nil {
s.logger.Warn("查询生效支付配置失败", zap.Error(err))
}
if activeConfig != nil {
paymentConfigID = &activeConfig.ID
}
}
// 从资产获取当前 generation用于订单快照
var assetGeneration int
if validationResult.Card != nil {
assetGeneration = validationResult.Card.Generation
} else if validationResult.Device != nil {
assetGeneration = validationResult.Device.Generation
}
if assetGeneration == 0 {
assetGeneration = 1
}
order := &model.Order{
BaseModel: model.BaseModel{
Creator: userID,
@@ -518,6 +582,8 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
},
OrderNo: s.orderStore.GenerateOrderNo(),
OrderType: req.OrderType,
Source: constants.OrderSourceAdmin,
Generation: assetGeneration,
BuyerType: orderBuyerType,
BuyerID: orderBuyerID,
IotCardID: req.IotCardID,
@@ -536,6 +602,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
OperatorType: operatorType,
ActualPaidAmount: actualPaidAmount,
PurchaseRole: purchaseRole,
PaymentConfigID: paymentConfigID,
}
items := s.buildOrderItems(userID, validationResult.Packages)
@@ -743,6 +810,18 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
}
}
// 查询当前生效的支付配置
var h5PaymentConfigID *uint
if req.PaymentMethod == model.PaymentMethodWechat || req.PaymentMethod == model.PaymentMethodAlipay {
activeConfig, err := s.wechatConfigService.GetActiveConfig(ctx)
if err != nil {
s.logger.Warn("查询生效支付配置失败", zap.Error(err))
}
if activeConfig != nil {
h5PaymentConfigID = &activeConfig.ID
}
}
order := &model.Order{
BaseModel: model.BaseModel{
Creator: userID,
@@ -769,6 +848,7 @@ func (s *Service) CreateH5Order(ctx context.Context, req *dto.CreateOrderRequest
ActualPaidAmount: actualPaidAmount,
PurchaseRole: purchaseRole,
ExpiresAt: expiresAt,
PaymentConfigID: h5PaymentConfigID,
}
items := s.buildOrderItems(userID, validationResult.Packages)
@@ -1271,12 +1351,12 @@ func (s *Service) unfreezeWalletForCancel(ctx context.Context, tx *gorm.DB, orde
} else {
return errors.New(errors.CodeInternalError, "无法确定钱包归属")
}
wallet, err := s.cardWalletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
if err != nil {
return errors.Wrap(errors.CodeWalletNotFound, err, "查询钱包失败")
return errors.Wrap(errors.CodeWalletNotFound, err, "查询资产钱包失败")
}
// 钱包解冻:直接减少冻结余额
result := tx.Model(&model.CardWallet{}).
// 资产钱包解冻:直接减少冻结余额
result := tx.Model(&model.AssetWallet{}).
Where("id = ? AND frozen_balance >= ?", wallet.ID, order.TotalAmount).
Updates(map[string]any{
"frozen_balance": gorm.Expr("frozen_balance - ?", order.TotalAmount),
@@ -1393,8 +1473,8 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
return s.activatePackage(ctx, tx, order)
})
} else {
// 钱包系统iot_card 或 device
wallet, err := s.cardWalletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
// 资产钱包系统iot_card 或 device
wallet, err := s.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, resourceID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
@@ -1437,7 +1517,10 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
}
}
walletResult := tx.Model(&model.CardWallet{}).
// 扣款前记录余额快照,用于写入流水
balanceBefore := wallet.Balance
walletResult := tx.Model(&model.AssetWallet{}).
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, order.TotalAmount, wallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", order.TotalAmount),
@@ -1450,6 +1533,27 @@ func (s *Service) WalletPay(ctx context.Context, orderID uint, buyerType string,
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
}
// 扣款成功后补写扣款流水,填补流水表中扣款记录缺失的问题
deductTx := &model.AssetWalletTransaction{
AssetWalletID: wallet.ID,
ResourceType: resourceType,
ResourceID: resourceID,
UserID: buyerID,
TransactionType: "deduct",
Amount: -order.TotalAmount,
BalanceBefore: balanceBefore,
BalanceAfter: balanceBefore - order.TotalAmount,
Status: 1,
ReferenceType: strPtr("order"),
ReferenceNo: &order.OrderNo,
Remark: strPtr("钱包支付套餐"),
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
}
if err := tx.Create(deductTx).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建扣款流水失败")
}
return s.activatePackage(ctx, tx, order)
})
}
@@ -2000,6 +2104,7 @@ func (s *Service) buildOrderResponse(order *model.Order, items []*model.OrderIte
}
// WechatPayJSAPI 发起微信 JSAPI 支付
// TODO: 从 payment_config_id 加载配置动态创建 Payment 实例,替代 s.wechatPayment 单例
func (s *Service) WechatPayJSAPI(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) (*dto.WechatPayJSAPIResponse, error) {
if s.wechatPayment == nil {
s.logger.Error("微信支付服务未配置")
@@ -2055,6 +2160,7 @@ func (s *Service) WechatPayJSAPI(ctx context.Context, orderID uint, openID strin
}
// WechatPayH5 发起微信 H5 支付
// TODO: 从 payment_config_id 加载配置动态创建 Payment 实例,替代 s.wechatPayment 单例
func (s *Service) WechatPayH5(ctx context.Context, orderID uint, sceneInfo *dto.WechatH5SceneInfo, buyerType string, buyerID uint) (*dto.WechatPayH5Response, error) {
if s.wechatPayment == nil {
s.logger.Error("微信支付服务未配置")
@@ -2245,3 +2351,15 @@ func (s *Service) GetPurchaseCheck(ctx context.Context, req *dto.PurchaseCheckRe
return response, nil
}
// FuiouPayJSAPI 富友公众号 JSAPI 支付发起(留桩)
// TODO: 实现富友支付发起逻辑
func (s *Service) FuiouPayJSAPI(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) error {
return errors.New(errors.CodeFuiouPayFailed, "富友支付发起暂未实现")
}
// FuiouPayMiniApp 富友小程序支付发起(留桩)
// TODO: 实现富友小程序支付发起逻辑
func (s *Service) FuiouPayMiniApp(ctx context.Context, orderID uint, openID string, buyerType string, buyerID uint) error {
return errors.New(errors.CodeFuiouPayFailed, "富友小程序支付发起暂未实现")
}

View File

@@ -456,6 +456,42 @@ func (s *Service) UpdateShelfStatus(ctx context.Context, id uint, shelfStatus in
return nil
}
// UpdateRetailPrice 代理修改自己店铺的套餐零售价
func (s *Service) UpdateRetailPrice(ctx context.Context, packageID uint, retailPrice int64) error {
currentUserID := middleware.GetUserIDFromContext(ctx)
if currentUserID == 0 {
return errors.New(errors.CodeUnauthorized, "未授权访问")
}
userType := middleware.GetUserTypeFromContext(ctx)
if userType != constants.UserTypeAgent {
return errors.New(errors.CodeForbidden, "仅代理用户可修改零售价")
}
shopID := middleware.GetShopIDFromContext(ctx)
if shopID == 0 {
return errors.New(errors.CodeUnauthorized, "当前用户不属于任何店铺")
}
allocation, err := s.packageAllocationStore.GetByShopAndPackage(ctx, shopID, packageID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "该套餐未分配给您")
}
return errors.Wrap(errors.CodeInternalError, err, "获取分配记录失败")
}
if retailPrice < allocation.CostPrice {
return errors.New(errors.CodeInvalidParam, "零售价不能低于成本价")
}
if err := s.packageAllocationStore.UpdateRetailPrice(ctx, allocation.ID, retailPrice, currentUserID); err != nil {
return errors.Wrap(errors.CodeInternalError, err, "更新零售价失败")
}
return nil
}
// updateAgentShelfStatus 代理上下架路径:更新分配记录的 shelf_status
func (s *Service) updateAgentShelfStatus(ctx context.Context, packageID uint, shelfStatus int, updaterID uint) error {
shopID := middleware.GetShopIDFromContext(ctx)
@@ -533,9 +569,9 @@ func (s *Service) toResponse(ctx context.Context, pkg *model.Package) *dto.Packa
allocation, err := s.packageAllocationStore.GetByShopAndPackage(ctx, shopID, pkg.ID)
if err == nil && allocation != nil {
resp.CostPrice = allocation.CostPrice
profitMargin := pkg.SuggestedRetailPrice - allocation.CostPrice
resp.RetailPrice = &allocation.RetailPrice
profitMargin := allocation.RetailPrice - allocation.CostPrice
resp.ProfitMargin = &profitMargin
// 代理查询时shelf_status 返回自己分配记录的值,而非平台全局值
resp.ShelfStatus = allocation.ShelfStatus
}
}
@@ -595,9 +631,9 @@ func (s *Service) toResponseWithAllocation(_ context.Context, pkg *model.Package
if allocationMap != nil {
if allocation, ok := allocationMap[pkg.ID]; ok {
resp.CostPrice = allocation.CostPrice
profitMargin := pkg.SuggestedRetailPrice - allocation.CostPrice
resp.RetailPrice = &allocation.RetailPrice
profitMargin := allocation.RetailPrice - allocation.CostPrice
resp.ProfitMargin = &profitMargin
// 代理查询时shelf_status 返回自己分配记录的值,而非平台全局值
resp.ShelfStatus = allocation.ShelfStatus
}
}

Some files were not shown because too many files have changed in this diff Show More