Compare commits

13 Commits

Author SHA1 Message Date
fc6af43baa 修复:溢出流量应优先记到主套餐而非加油包
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m47s
主套餐和加油包同时 Depleted 时,recordOverflowToDepletedPackage
原先取 id 最大的套餐,可能取到加油包,语义不对。

改为优先查 master_usage_id IS NULL(主套餐),找不到时
再回退取任意 Depleted 套餐。
2026-07-01 12:06:19 +09:00
52bdbbae25 修复:套餐耗尽后流量详单断档问题
问题:套餐 status=Depleted 后,queryActivePackages 查不到套餐,
DeductDataUsage 直接 return CodeNoAvailablePackage,导致上游
仍有真实流量时,tb_package_usage_daily_record 不再写入,详单断档。

修复:无 Active 套餐时,找最近一条 Depleted 套餐,将溢出流量
累加到 data_usage_mb 并写入当日 daily_record,保持详单连续性。
status 不变,不重复触发停机。
2026-07-01 12:03:39 +09:00
76f657c986 1. 套餐过期前再主动同步一次流量
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m55s
2. 生效套餐逻辑错误的问题
2026-06-30 15:54:55 +09:00
a2793f7bec 需要改造的地方 2026-06-29 18:17:06 +09:00
b5b7f9a41e 迁移
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m53s
2026-06-29 12:29:31 +09:00
b930662817 从乐观锁变成悲观锁 2026-06-29 12:29:21 +09:00
ab9da7bb33 修复
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m33s
2026-06-26 13:11:39 +09:00
781e82441d 环境变量
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 39s
2026-06-26 11:59:28 +08:00
f4b6a55b27 驳回
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 7m17s
2026-06-26 12:12:15 +09:00
8cf921f11c 驳回接口
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m55s
2026-06-25 17:10:24 +09:00
c5871b59a1 修复开放接口 wallet/package-orders 支持 IMEI 下单
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m48s
2026-06-23 11:46:02 +09:00
c0b9604515 完成
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-06-23 11:05:58 +09:00
5c2ef97c24 相关问题优化以及新功能开发
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m57s
迁移 155- 157
2026-06-22 17:21:13 +09:00
75 changed files with 2628 additions and 163 deletions

11
.env Normal file
View File

@@ -0,0 +1,11 @@
MIGRATIONS_DIR=migrations
DB_HOST=cxd.whcxd.cn
DB_PORT=16159
DB_USER=erp_pgsql
DB_PASSWORD=erp_2025
DB_NAME=junhong_cmp_test
DB_SSLMODE=disable
GOOGLE_GEMINI_BASE_URL="http://45.155.220.179:8317" # 根据实际填写你服务器的ip地址或者域名
GEMINI_API_KEY="sk-VoNbvr6aGpjvZX64rvhrwowrZrCgtGuX9oxykIy8F1DBg"
GOOGLE_GENAI_USE_GCA="true"
GEMINI_MODEL="gemini-3-pro-preview" # 如果你有gemini3权限可以填 gemini-3-pro-preview

93
.env.local Normal file
View File

@@ -0,0 +1,93 @@
# ============================================================================
# 君鸿卡管系统 - 本地开发环境变量
# 生成时间: 2026-01-30 17:45:14
# ============================================================================
# 使用方法:
# source .env.local && go run cmd/api/main.go
# 或者:
# ./scripts/run-local.sh
# ============================================================================
# ----------------------------------------------------------------------------
# 数据库配置(必填)
# ----------------------------------------------------------------------------
export JUNHONG_DATABASE_HOST="cxd.whcxd.cn"
export JUNHONG_DATABASE_PORT="16159"
export JUNHONG_DATABASE_USER="erp_pgsql"
export JUNHONG_DATABASE_PASSWORD="erp_2025"
export JUNHONG_DATABASE_DBNAME="junhong_cmp_test"
export JUNHONG_DATABASE_SSLMODE="disable"
# ----------------------------------------------------------------------------
# Redis 配置(必填)
# ----------------------------------------------------------------------------
export JUNHONG_REDIS_ADDRESS="cxd.whcxd.cn"
export JUNHONG_REDIS_PORT="16299"
export JUNHONG_REDIS_PASSWORD="cpNbWtAaqgo1YJmbMp3h"
export JUNHONG_REDIS_DB="6"
# ----------------------------------------------------------------------------
# JWT 配置(必填)
# ----------------------------------------------------------------------------
export JUNHONG_JWT_SECRET_KEY="dev-secret-key-for-testing-only-32chars!"
# ----------------------------------------------------------------------------
# 客户端配置(可选)
# ----------------------------------------------------------------------------
# 是否强制 C 端用户绑定手机号true: 强制false: 不强制)
export JUNHONG_CLIENT_REQUIRE_PHONE_BINDING="true"
# ----------------------------------------------------------------------------
# 服务器配置
# ----------------------------------------------------------------------------
export JUNHONG_SERVER_ADDRESS=":3000"
# ----------------------------------------------------------------------------
# 日志配置
# ----------------------------------------------------------------------------
export JUNHONG_LOGGING_LEVEL="debug"
export JUNHONG_LOGGING_DEVELOPMENT="true"
export JUNHONG_LOGGING_APP_LOG_FILENAME="logs/app.log"
export JUNHONG_LOGGING_ACCESS_LOG_FILENAME="logs/access.log"
# ----------------------------------------------------------------------------
# Gateway 服务配置
# ----------------------------------------------------------------------------
export JUNHONG_GATEWAY_BASE_URL="https://open.whjhft.com/openapi"
export JUNHONG_GATEWAY_APP_ID="LfjL0WjUqpwkItQ0"
export JUNHONG_GATEWAY_APP_SECRET="K0DYuWzbRE6zg5bX"
export JUNHONG_GATEWAY_TIMEOUT="30"
# ----------------------------------------------------------------------------
# 对象存储配置
# ----------------------------------------------------------------------------
export JUNHONG_STORAGE_PROVIDER="s3"
export JUNHONG_STORAGE_S3_ENDPOINT="http://obs-helf.cucloud.cn"
export JUNHONG_STORAGE_S3_REGION="cn-langfang-2"
export JUNHONG_STORAGE_S3_BUCKET="cmp"
export JUNHONG_STORAGE_S3_ACCESS_KEY_ID="598F558CF6FF46E79D1CFC607852378C9523"
export JUNHONG_STORAGE_S3_SECRET_ACCESS_KEY="8393425DCB2F48F1914FF39DCBC6C7B17325"
export JUNHONG_STORAGE_S3_USE_SSL="false"
export JUNHONG_STORAGE_S3_PATH_STYLE="true"
# ----------------------------------------------------------------------------
# 迁移工具兼容配置(用于 scripts/migrate.sh
# ----------------------------------------------------------------------------
export MIGRATIONS_DIR="migrations"
export DB_HOST="cxd.whcxd.cn"
export DB_PORT="16159"
export DB_USER="erp_pgsql"
export DB_PASSWORD="erp_2025"
export DB_NAME="junhong_cmp_test"
export DB_SSLMODE="disable"
# ----------------------------------------------------------------------------
# 短信服务配置
# ----------------------------------------------------------------------------
export JUNHONG_SMS_GATEWAY_URL="https://gateway.sms.whjhft.com:8443"
export JUNHONG_SMS_USERNAME="JH0001"
export JUNHONG_SMS_PASSWORD="wwR8E4qnL6F0"
export JUNHONG_SMS_SIGNATURE="【JHFTIOT】"
JUNHONG_QUEUE_CONCURRENCY=1000

236
.env.prod Normal file
View File

@@ -0,0 +1,236 @@
# ==========================================================================
# 君鸿卡管系统 - 本地开发环境变量
# 生成时间: 2026-05-07 11:56:19
# 配置依据: pkg/config/defaults/config.yaml + pkg/config/loader.go
# ==========================================================================
# 使用方法:
# source .env.prod && go run cmd/api/main.go
# 或者:
# ./scripts/run-local.sh
#
# 说明:
# - 未注释的 是本地启动常用或必填配置。
# - 注释掉的 是当前有默认值、按需启用或当前主流程未使用的配置。
# - 取消注释前请先阅读上方说明,避免覆盖嵌入配置默认值。
# ==========================================================================
# ----------------------------------------------------------------------------
# 数据库配置(必填)
# ----------------------------------------------------------------------------
# 数据库主机地址;应用启动必填。
export JUNHONG_DATABASE_HOST=cxd.whcxd.cn
# 数据库端口;默认 5432。
export JUNHONG_DATABASE_PORT=16159
# 数据库用户名;应用启动必填。
export JUNHONG_DATABASE_USER=junhong_cmp
# 数据库密码;应用启动必填,敏感信息请勿提交。
export JUNHONG_DATABASE_PASSWORD=CtCom1zBzPQbpVNf3rCNxH
# 数据库名称;应用启动必填。
export JUNHONG_DATABASE_DBNAME=junhong_cmp_prod
# 数据库 SSL 模式;本地通常使用 disable。
export JUNHONG_DATABASE_SSLMODE=disable
# ----------------------------------------------------------------------------
# Redis 配置(必填)
# ----------------------------------------------------------------------------
# Redis 主机地址;应用启动必填。
export JUNHONG_REDIS_ADDRESS=192.168.1.22
# Redis 端口;默认 6379。
export JUNHONG_REDIS_PORT=6379
# Redis 密码;无密码时留空。
export JUNHONG_REDIS_PASSWORD=Bm500vXEyLg0RUzw7YcyPjbRifhz
# Redis 数据库编号;默认 0。
export JUNHONG_REDIS_DB=0
# ----------------------------------------------------------------------------
# JWT 配置(必填)
# ----------------------------------------------------------------------------
# JWT 签名密钥;必须至少 32 字符,生产环境必须单独生成。
export JUNHONG_JWT_SECRET_KEY=a0xzpGhR3zB54JAiEJtoKnjwgAGMLmRw
# C 端 JWT Token 有效期;默认 24h通常无需覆盖。
export JUNHONG_JWT_TOKEN_DURATION=24h
# B 端访问令牌 TTL默认 24h。
export JUNHONG_JWT_ACCESS_TOKEN_TTL=24h
# B 端刷新令牌 TTL默认 168h。
export JUNHONG_JWT_REFRESH_TOKEN_TTL=168h
# ----------------------------------------------------------------------------
# 服务器配置
# ----------------------------------------------------------------------------
# 服务监听地址;本地默认 :3000。
export JUNHONG_SERVER_ADDRESS=:3527
# 读取请求超时时间;默认 30s通常无需覆盖。
export JUNHONG_SERVER_READ_TIMEOUT=30s
# 写响应超时时间;默认 30s通常无需覆盖。
export JUNHONG_SERVER_WRITE_TIMEOUT=30s
# 优雅关闭超时时间;默认 30s。
export JUNHONG_SERVER_SHUTDOWN_TIMEOUT=30s
# Fiber 预分叉模式;本地和 macOS 开发环境通常不要启用。
# JUNHONG_SERVER_PREFORK=false
# ----------------------------------------------------------------------------
# 日志配置
# ----------------------------------------------------------------------------
# 日志级别;可选 debug/info/warn/error。
export JUNHONG_LOGGING_LEVEL=debug
# 本地开发模式日志;生产环境应使用 false。
export JUNHONG_LOGGING_DEVELOPMENT=true
# 应用日志文件路径;本地写入项目 logs 目录。
export JUNHONG_LOGGING_APP_LOG_FILENAME=logs/app.log
# 访问日志文件路径;本地写入项目 logs 目录。
export JUNHONG_LOGGING_ACCESS_LOG_FILENAME=logs/access.log
# 应用日志单文件最大大小MB默认 100。
export JUNHONG_LOGGING_APP_LOG_MAX_SIZE=100
# 应用日志最多保留的旧文件数;默认 3。
export JUNHONG_LOGGING_APP_LOG_MAX_BACKUPS=15
# 应用日志最多保留天数;默认 7。
export JUNHONG_LOGGING_APP_LOG_MAX_AGE=14
# 是否压缩应用日志轮转文件;默认 true。
export JUNHONG_LOGGING_APP_LOG_COMPRESS=true
# 访问日志单文件最大大小MB默认 100。
export JUNHONG_LOGGING_ACCESS_LOG_MAX_SIZE=100
# 访问日志最多保留的旧文件数;默认 3。
export JUNHONG_LOGGING_ACCESS_LOG_MAX_BACKUPS=15
# 访问日志最多保留天数;默认 7。
export JUNHONG_LOGGING_ACCESS_LOG_MAX_AGE=14
# 是否压缩访问日志轮转文件;默认 true。
export JUNHONG_LOGGING_ACCESS_LOG_COMPRESS=true
# ----------------------------------------------------------------------------
# 客户端配置
# ----------------------------------------------------------------------------
# 是否强制 C 端用户绑定手机号;当前默认 true。
export JUNHONG_CLIENT_REQUIRE_PHONE_BINDING=true
# ----------------------------------------------------------------------------
# 数据库连接池配置(可选)
# ----------------------------------------------------------------------------
# 数据库最大打开连接数;默认 25本地通常不用改。
export JUNHONG_DATABASE_MAX_OPEN_CONNS=25
# 数据库最大空闲连接数;默认 10。
export JUNHONG_DATABASE_MAX_IDLE_CONNS=10
# 数据库连接最大生命周期;默认 5m。
export JUNHONG_DATABASE_CONN_MAX_LIFETIME=5m
# ----------------------------------------------------------------------------
# Redis 连接池配置(可选)
# ----------------------------------------------------------------------------
# Redis 连接池大小;默认 10。
export JUNHONG_REDIS_POOL_SIZE=20
# Redis 最小空闲连接数;默认 5。
export JUNHONG_REDIS_MIN_IDLE_CONNS=5
# Redis 建连超时;默认 5s。
export JUNHONG_REDIS_DIAL_TIMEOUT=5s
# Redis 读取超时;默认 3s。
export JUNHONG_REDIS_READ_TIMEOUT=3s
# Redis 写入超时;默认 3s。
export JUNHONG_REDIS_WRITE_TIMEOUT=3s
# ----------------------------------------------------------------------------
# 任务队列配置(可选)
# ----------------------------------------------------------------------------
# Worker 并发数;默认 10。
# JUNHONG_QUEUE_CONCURRENCY=10
# 任务最大重试次数;默认 5。
# JUNHONG_QUEUE_RETRY_MAX=5
# 单个任务超时时间;默认 10m。
# JUNHONG_QUEUE_TIMEOUT=10m
# 队列权重 critical/default/low 当前由嵌入配置 queue.queues 管理,未绑定为环境变量。
# ----------------------------------------------------------------------------
# 限流中间件配置(可选)
# ----------------------------------------------------------------------------
# 是否启用全局限流;默认关闭。
# JUNHONG_MIDDLEWARE_ENABLE_RATE_LIMITER=false
# 限流窗口内最大请求数;默认 100。
# JUNHONG_MIDDLEWARE_RATE_LIMITER_MAX=100
# 限流时间窗口;默认 1m。
# JUNHONG_MIDDLEWARE_RATE_LIMITER_EXPIRATION=1m
# 限流存储后端;可选 memory 或 redis。
# JUNHONG_MIDDLEWARE_RATE_LIMITER_STORAGE=memory
# ----------------------------------------------------------------------------
# 轮询配置(可选)
# ----------------------------------------------------------------------------
# 是否输出轮询详细日志;默认 false排查上游数据时再开启。
export JUNHONG_POLLING_VERBOSE_LOG=true
# 是否启用 C 端实名自动触发;默认 true。
# JUNHONG_POLLING_AUTO_TRIGGER_ENABLE_AUTO_TRIGGER=true
# 自动触发使用的系统用户 ID生产建议改成专用平台账号。
export JUNHONG_POLLING_AUTO_TRIGGER_AUTO_TRIGGER_SYSTEM_USER_ID=1
# ----------------------------------------------------------------------------
# Worker 运行配置(可选)
# ----------------------------------------------------------------------------
# Worker 角色;单实例默认 all多实例可用 leader/consumer。
# JUNHONG_WORKER_ROLE=all
# Worker 实例名称;多实例部署时用于区分日志。
# JUNHONG_WORKER_INSTANCE_NAME=''
# ----------------------------------------------------------------------------
# Gateway 服务配置
# ----------------------------------------------------------------------------
# Gateway API 基础 URL已按本次输入覆盖嵌入默认值。
export JUNHONG_GATEWAY_BASE_URL=https://open.whjhft.com/openapi
# Gateway 应用 ID敏感信息请勿提交。
export JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
# Gateway 应用密钥;敏感信息请勿提交。
export JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
# Gateway 请求超时时间(秒);有效范围 5-300。
export JUNHONG_GATEWAY_TIMEOUT=30
# ----------------------------------------------------------------------------
# 短信服务配置(可选)
# ----------------------------------------------------------------------------
# 短信服务未配置时系统会跳过短信客户端初始化;需要短信验证码时再取消注释。
# 短信网关地址;设置后 username/password/signature 也必须填写。
export JUNHONG_SMS_GATEWAY_URL='https://gateway.sms.whjhft.com:8443'
# 短信账号用户名。
export JUNHONG_SMS_USERNAME='JH0001'
# 短信账号密码;敏感信息请勿提交。
export JUNHONG_SMS_PASSWORD='wwR8E4qnL6F0'
# 短信签名,例如【君鸿】。
export JUNHONG_SMS_SIGNATURE='【JHFTIOT】'
# 短信请求超时时间;默认 10s。
export JUNHONG_SMS_TIMEOUT=10s
# ----------------------------------------------------------------------------
# 对象存储配置(可选)
# ----------------------------------------------------------------------------
# 对象存储提供商;当前仅支持 s3 兼容服务。
export JUNHONG_STORAGE_PROVIDER=s3
# 临时文件目录;用于导入导出等临时文件。
export JUNHONG_STORAGE_TEMP_DIR=/tmp/junhong-storage
# S3 端点;配置后会初始化对象存储服务。
export JUNHONG_STORAGE_S3_ENDPOINT=https://obs-helf.cucloud.cn
# S3 区域。
export JUNHONG_STORAGE_S3_REGION=cn-langfang-2
# S3 存储桶名称。
export JUNHONG_STORAGE_S3_BUCKET=cmp
# S3 Access Key ID敏感信息请勿提交。
export JUNHONG_STORAGE_S3_ACCESS_KEY_ID=598F558CF6FF46E79D1CFC607852378C9523
# S3 Secret Access Key敏感信息请勿提交。
export JUNHONG_STORAGE_S3_SECRET_ACCESS_KEY=8393425DCB2F48F1914FF39DCBC6C7B17325
# 是否使用 SSL本地兼容服务常用 false。
export JUNHONG_STORAGE_S3_USE_SSL=false
# 是否使用路径风格;兼容 S3 服务通常使用 true。
export JUNHONG_STORAGE_S3_PATH_STYLE=true
# 预签名上传 URL 有效期;默认 15m。
export JUNHONG_STORAGE_PRESIGN_UPLOAD_EXPIRES=15m
# 预签名下载 URL 有效期;默认 24h。
export JUNHONG_STORAGE_PRESIGN_DOWNLOAD_EXPIRES=24h
# ----------------------------------------------------------------------------
# 默认超级管理员配置(可选)
# ----------------------------------------------------------------------------
# 不配置时会使用 pkg/constants 中的默认超管账号;生产环境建议在首次启动前覆盖。
# 默认超级管理员用户名;留空则使用代码内置默认值 admin。
export JUNHONG_DEFAULT_ADMIN_USERNAME='admin'
# 默认超级管理员密码;留空则使用代码内置默认值 Admin@123456。
export JUNHONG_DEFAULT_ADMIN_PASSWORD='Admin@123456'
# 默认超级管理员手机号;留空则使用代码内置默认值 13800000000。
# JUNHONG_DEFAULT_ADMIN_PHONE=''
export JUNHONG_MIDDLEWARE_CORS_ENABLED=true
export JUNHONG_MIDDLEWARE_CORS_ALLOW_ORIGINS=https://cmp-admin.xm-iot.cn,https://cmp-agent.xm-iot.cn,https://cmp-c.xm-iot.cn
export JUNHONG_MIDDLEWARE_CORS_ALLOW_CREDENTIALS=true

3
.gitignore vendored
View File

@@ -26,8 +26,6 @@ vendor/
.cache/
# Environment variables
.env
.env.*
!.env.example
# Log files
@@ -75,7 +73,6 @@ __debug_bin1621385388
docs/admin-openapi.yaml
/api
/gendocs
.env.local
/worker
.opencode/skills/json-canvas
.opencode/skills/obsidian-bases

View File

@@ -0,0 +1,124 @@
# PRDIoT 卡风险状态限制 / 导入任务操作人与批量失效 / 凭证多图 / 预充值备注
Status: ready-for-agent
---
## Problem Statement
运营团队在日常工作中遇到四个独立痛点:
1. **风险停机/已销户卡无法有效拦截**:运营商侧将某些卡置为"风险停机"或"已销户"状态后,系统仍允许对这些卡发起复机操作,且轮询系统仍持续轮询这些卡,浪费资源并可能产生错误数据。
2. **导入任务无操作人信息**IoT 卡导入任务和设备导入任务的列表中没有"操作人"字段,无法追溯是谁发起的批量操作。同时缺少批量失效订单套餐的操作入口,运营只能逐条手动处理。
3. **凭证只能上传一张**:后台创建订单和发起退款时,只允许上传一张凭证图片,无法满足多张凭证的业务场景(如多笔转账记录、多页合同等)。
4. **代理预充值缺少备注字段**:代理预充值操作无法附加运营备注,导致事后无法追溯充值的业务原因。
---
## Solution
1. 在复机接口和轮询系统中增加对 `gateway_extend` 字段的检查,当独立卡处于"风险停机"或"已销户"状态时,拒绝复机并停止轮询。
2. 在所有导入任务中快照操作人姓名;新增"批量失效订单套餐"导入任务,支持通过 CSV 批量将订单下的套餐置为失效状态。
3. 将订单、退款、代理预充值的凭证字段改为支持最多5张的 jsonb 数组存储。
4. 在代理预充值记录上新增 `remark` 备注字段,供运营创建时填写,并在列表/详情接口中返回。
---
## User Stories
1. 作为运营人员,当我尝试对"风险停机"状态的独立卡发起复机时,我希望系统拒绝并提示原因,以免产生无效操作。
2. 作为运营人员,当我尝试对"已销户"状态的独立卡发起复机时,我希望系统拒绝并提示原因,以免对已注销的卡发起无意义请求。
3. 作为运营人员,我希望"风险停机"和"已销户"的独立卡不再参与系统轮询,以免浪费轮询资源并产生噪音数据。
4. 作为运营人员,当轮询系统检测到某张独立卡的网关状态变为"风险停机"或"已销户"时,我希望系统自动将该卡的轮询关闭,而不需要手动干预。
5. 作为运营人员,我希望在 IoT 卡导入任务列表中看到"操作人"姓名,以便追溯每次批量导入是谁发起的。
6. 作为运营人员,我希望在设备导入任务列表中看到"操作人"姓名,以便追溯每次批量导入是谁发起的。
7. 作为运营人员,我希望通过上传 CSV 文件批量将一批订单的套餐置为失效,以便快速处理异常订单。
8. 作为运营人员我希望在创建批量失效任务时能填写备注和上传凭证最多5张以便记录操作原因。
9. 作为运营人员,我希望批量失效任务完成后能看到成功数、失败数,以及失败原因(如订单号不存在),以便核查处理结果。
10. 作为运营人员,我希望批量失效套餐只影响套餐记录本身,不触发退款和其他业务流程,让轮询系统自行根据套餐状态处理停机。
11. 作为运营人员我希望在创建后台订单时可以上传最多5张凭证以便完整记录线下付款证明。
12. 作为运营人员我希望在发起退款申请时可以上传最多5张凭证以便完整记录退款证明材料。
13. 作为运营人员,我希望在代理预充值列表中看到备注内容,以便了解每笔预充值的业务背景。
14. 作为运营人员,我希望在创建代理预充值订单时填写备注,以便记录本次充值的原因或说明。
15. 作为开放 API 调用方,当我通过 Open API 对"风险停机"或"已销户"的独立卡调用复机接口时,我希望收到明确的错误响应。
---
## Implementation Decisions
### 需求一:风险停机/已销户卡限制
- **判断字段**`tb_iot_card.gateway_extend` 原文值 = `"风险停机"``"已销户"`。新增两个常量 `GatewayCardExtendRiskStop``GatewayCardExtendCancelled`
- **判断范围**:仅 `is_standalone = true` 的独立卡(未绑定设备的卡)。绑定设备的卡不受此限制。
- **复机拦截**:在 `ManualStartCard`(后台管理员入口)和 `ResumeCard`Open API 入口)两个函数中,调用网关前先读取 DB 中已落库的 `gateway_extend`,若命中则返回业务错误,不实时查询网关。
- **轮询停止**:轮询状态处理器(`PollingCardStatusHandler`)在将新的 `gateway_extend` 写入 DB 后,检查若命中风险状态且 `is_standalone=true`,则调用现有的 `UpdatePollingStatus(false)``enable_polling` 写为 `false`,并跳过 `requeueCard`,终止该卡的轮询循环。
- **错误码**:复机被拒绝时返回业务错误,提示信息明确说明原因("风险停机"/"已销户")。
### 需求二:导入任务操作人与批量失效
**操作人快照:**
- `tb_iot_card_import_task``tb_device_import_task` 表各新增 `creator_name varchar` 字段。
- 新的批量失效任务表同样包含此字段。
- 创建任务时从当前登录用户快照姓名写入,旧数据留空(不回填)。
- 列表响应 DTO 新增 `creator_name` 字段。
**批量失效订单套餐任务:**
- 新建模型、Store、Service、Handler参照现有 IoT 卡导入任务的结构CSV 上传 → 异步任务处理)。
- CSV 格式:单列 `order_no`(无表头行约定沿用现有导入任务的处理方式)。
- 创建接口额外支持:`remark`字符串备注和最多5张凭证jsonb 存储)。
- 处理逻辑:按行读取 `order_no` → 查询 `Order` → 找到 `PackageUsage WHERE order_id = ? AND status NOT IN (3, 4)` → 批量更新 `status = 4`(已失效)。
- 订单不存在记为失败fail继续处理后续行。
- 订单存在但旗下套餐全部已是终态status 3 或 4记为成功success
- 不触发退款,不直接操作 IoT 卡停机,由轮询系统根据套餐状态自行评估。
- 任务表命名:`tb_order_package_invalidate_task`
### 需求三:凭证多图改造
- `tb_order.payment_voucher_key`varchar 500`jsonb`,存储 `[]string`最多5个元素。
- `tb_refund.refund_voucher_key`varchar 500`jsonb`,存储 `[]string`最多5个元素。
- `tb_agent_recharge_record.payment_voucher_key`varchar 500`jsonb`,存储 `[]string`最多5个元素。
- **数据迁移**(同一 Migration 语句):非空旧值 `"some_key"``["some_key"]`;空字符串 → `[]`。生产环境由运营手动执行 Migration。
- 所有涉及凭证的请求 DTO 字段由 `string` 改为 `[]string`validate 标签限制 `max=5`,每项 `max=500`
- 所有涉及凭证的响应 DTO 字段改为 `[]string`
- 不限制文件类型(上传 URL 获取接口层面不变,仅存储 key 列表)。
### 需求四:代理预充值备注
- `tb_agent_recharge_record` 新增 `remark text` 字段(可为空)。
- `CreateAgentRechargeRequest` 新增 `remark` 字段(`omitempty`,无最大长度强限制,建议 1000 字符内)。
- `AgentRechargeResponse` 新增 `remark` 字段(列表和详情接口均返回)。
- 创建后不可修改,无需新增修改接口。
---
## Testing Decisions
本项目禁止自动化测试。验证方式:
- **PostgreSQL MCP**:核查 `tb_iot_card.enable_polling` 在风险状态卡被轮询后是否正确置为 `false`;核查 `tb_package_usage.status` 在批量失效任务执行后是否按预期更新;核查 jsonb 字段迁移结果。
- **curl / Postman**:对风险停机卡调用复机接口,验证返回正确错误码;上传多张凭证创建订单,验证存储和返回正确;创建代理预充值时附带备注,验证列表返回。
---
## Out of Scope
- 已绑定设备的卡(`is_standalone=false`)不受"风险停机/已销户"限制约束。
- 批量失效订单套餐不触发退款流程。
- 批量失效不直接调用网关停机,依赖轮询系统自行评估。
- 历史导入任务的 `creator_name` 不回填。
- 凭证文件类型校验(文件类型限制不在本次范围内)。
- 代理预充值备注创建后不可修改(无修改接口)。
---
## Further Notes
- 凭证字段改为 jsonb 后,前端需同步更新上传组件支持多选;本 PRD 只覆盖后端改造。
- 批量失效任务的凭证和备注字段,与代理预充值备注字段、以及需求三的多凭证设计保持一致的数据形态,便于后续统一处理。
- "风险停机"和"已销户"的 `gateway_extend` 原文值来自上游网关,若上游修改返回值需同步更新常量。

View File

@@ -0,0 +1,28 @@
Status: done
## Parent
`.scratch/iot-risk-import-voucher-remark/PRD.md`
## What to build
新增两个 gateway_extend 常量(`GatewayCardExtendRiskStop = "风险停机"``GatewayCardExtendCancelled = "已销户"`),然后在后台复机入口(`ManualStartCard`)和 Open API 复机入口(`ResumeCard`)中增加前置拦截:
- 仅对 `is_standalone = true` 的独立卡生效
- 读取 DB 中已落库的 `gateway_extend` 字段(不实时查网关)
- 若命中 `"风险停机"``"已销户"`,立即返回业务错误,不再继续调用网关
- 错误提示需明确说明被拒绝的原因(如"该卡已被运营商风险停机,不允许复机"
无 Schema 变更,不需要 Migration。
## Acceptance criteria
- [ ] `pkg/constants/iot.go` 新增 `GatewayCardExtendRiskStop``GatewayCardExtendCancelled` 两个常量
- [ ] 后台 `ManualStartCard` 在调用网关前检查独立卡 `gateway_extend`,命中风险值时返回业务错误
- [ ] Open API `ResumeCard` 在调用网关前检查独立卡 `gateway_extend`,命中风险值时返回业务错误
- [ ] 已绑定设备的卡(`is_standalone = false`)不受此限制,复机正常进行
- [ ] 返回的错误信息能区分"风险停机"和"已销户"两种情况
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,28 @@
Status: done
## Parent
`.scratch/iot-risk-import-voucher-remark/PRD.md`
## What to build
`PollingCardStatusHandler` 中,当轮询查询网关后将新的 `gateway_extend` 写入 DB若新值命中风险常量`GatewayCardExtendRiskStop``GatewayCardExtendCancelled`)且该卡 `is_standalone = true`,则:
1. 调用现有的 `UpdatePollingStatus(false)``enable_polling` 写为 `false`
2. 跳过末尾的 `requeueCard`,终止该卡的轮询循环
检查时机:在 `UpdateFields` 写入 `gateway_extend` 成功之后,`requeueCard` 调用之前。
不需要 Schema 变更,不需要 Migration。
## Acceptance criteria
- [ ] 轮询处理器在写入 `gateway_extend` 后检查风险状态
- [ ] 独立卡(`is_standalone=true`)命中风险值时调用 `UpdatePollingStatus(false)``tb_iot_card.enable_polling` 被置为 `false`
- [ ] 该卡不再入队(不调用 `requeueCard`),轮询循环终止
- [ ] 已绑定设备的卡(`is_standalone=false`)命中风险值时不触发此逻辑,轮询正常继续
- [ ] 可通过 PostgreSQL MCP 验证:风险状态卡处理后 `enable_polling = false`
## Blocked by
- `issues/01-risk-resume-block.md`(依赖 `GatewayCardExtendRiskStop``GatewayCardExtendCancelled` 常量)

View File

@@ -0,0 +1,30 @@
Status: done
## Parent
`.scratch/iot-risk-import-voucher-remark/PRD.md`
## What to build
在现有两个导入任务表中新增操作人快照字段,并在列表接口返回:
- `tb_iot_card_import_task` 新增 `creator_name varchar(100)` 字段
- `tb_device_import_task` 新增 `creator_name varchar(100)` 字段
- 创建任务时从当前登录用户快照姓名写入(不可为 null旧数据留空字符串
- IoT 卡导入任务列表响应 DTO 新增 `creator_name` 字段
- 设备导入任务列表响应 DTO 新增 `creator_name` 字段
历史数据不回填,旧记录 `creator_name` 为空字符串。
## Acceptance criteria
- [ ] Migration 为两个现有任务表各添加 `creator_name varchar(100) not null default ''` 字段
- [ ] IoT 卡导入任务创建时写入当前用户姓名快照
- [ ] 设备导入任务创建时写入当前用户姓名快照
- [ ] IoT 卡导入任务列表接口返回 `creator_name` 字段
- [ ] 设备导入任务列表接口返回 `creator_name` 字段
- [ ] 旧记录 `creator_name` 为空字符串,不报错
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,43 @@
Status: done
## Parent
`.scratch/iot-risk-import-voucher-remark/PRD.md`
## What to build
新增"批量失效订单套餐"导入任务,完整实现 Model → Store → Service → Handler → Route → Worker 全链路,参照现有 IoT 卡导入任务的结构。
**数据模型**(新建 `tb_order_package_invalidate_task`
- 参照 `tb_iot_card_import_task` 的基础字段(任务编号、状态、计数、失败/跳过明细、文件存储 key、时间戳等
- 额外字段:`creator_name varchar(100)``remark text``voucher_keys jsonb`(存储 `[]string`最多5个元素
**上传接口**(创建任务):
- 接受 CSV 文件(单列 `order_no`
- 额外参数:`remark`(字符串,可选)和 `voucher_keys``[]string`最多5个可选
- 写入 `creator_name` 快照
**Worker 处理逻辑**
- 逐行读取 `order_no`
-`order_no` 查询 `tb_order` 获取 `order_id`;找不到则记为失败,继续处理
- 查询 `tb_package_usage WHERE order_id = ? AND status NOT IN (3, 4)`,批量更新 `status = 4`(已失效)
- 订单存在但套餐全部已是终态status 3 或 4记为成功
- 不触发退款,不调用网关,不直接停机
**列表/详情接口**:参照现有导入任务接口结构。
## Acceptance criteria
- [ ] Migration 创建 `tb_order_package_invalidate_task` 表,包含所有必要字段
- [ ] 创建任务接口:接受 CSV + remark + voucher_keys最多5个写入 creator_name 快照
- [ ] Worker 按行处理:`order_no` 不存在 → 失败并记录原因,继续下一行
- [ ] Worker 按行处理:订单存在且有可失效套餐 → 批量更新 `status=4` → 记为成功
- [ ] Worker 按行处理:订单存在但套餐全为终态 → 记为成功
- [ ] 处理过程中不调用退款接口、不操作网关
- [ ] 列表接口返回 `creator_name``remark`、任务状态和计数
- [ ] 详情接口返回失败明细(含 order_no 和原因)
- [ ] 已在路由和文档生成器中注册
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,36 @@
Status: done
## Parent
`.scratch/iot-risk-import-voucher-remark/PRD.md`
## What to build
一次 Migration 完成三个现有表的凭证字段类型变更,并同时新增代理预充值备注字段:
**字段类型变更**varchar(500) → jsonb
- `tb_order.payment_voucher_key`
- `tb_refund.refund_voucher_key`
- `tb_agent_recharge_record.payment_voucher_key`
**数据转换规则**(在同一 Migration 的 USING 子句中完成):
- 非空旧值 `"some_key"``["some_key"]`
- 空字符串 `""``[]`
- NULL → `[]`
**新增字段**
- `tb_agent_recharge_record.remark text`(可为空,默认 null
生产环境由运营手动执行Migration 文件需包含完整的 UP 和 DOWN 语句。
## Acceptance criteria
- [ ] Migration UP三个凭证字段成功从 varchar 转为 jsonb历史单值数据转换为单元素数组
- [ ] Migration UP空字符串/NULL 转换为空数组 `[]`
- [ ] Migration UP`tb_agent_recharge_record` 新增 `remark text` 字段
- [ ] Migration DOWN可回滚jsonb → varchar取数组第一个元素remark 字段删除)
- [ ] 迁移后可用 PostgreSQL MCP 验证数据格式正确
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,27 @@
Status: done
## Parent
`.scratch/iot-risk-import-voucher-remark/PRD.md`
## What to build
将后台创建订单接口的凭证字段从单个字符串改为最多5个字符串的列表
- `CreateAdminOrderRequest.payment_voucher_key``string``[]string`validate 限制 `max=5`,每项 `max=500`
- `OrderResponse.payment_voucher_key`(以及所有订单相关响应 DTO`string``[]string`
- Order Model 的 `PaymentVoucherKey` 字段类型改为适配 jsonb 的 `pq.StringArray` 或自定义 JSON 类型
- Order Service 中读写 `payment_voucher_key` 的逻辑同步更新
Schema 变更已由 `issues/05-voucher-multi-migration.md` 完成。
## Acceptance criteria
- [ ] 后台创建订单接口接受 `payment_voucher_key []string`0-5个元素offline 支付时至少需要1个
- [ ] 订单列表/详情接口返回 `payment_voucher_key []string`
- [ ] 传入超过5个凭证 key 时返回参数校验错误
- [ ] 现有无凭证的订单wallet 支付)返回空数组,不报错
## Blocked by
- `issues/05-voucher-multi-migration.md`

View File

@@ -0,0 +1,29 @@
Status: done
## Parent
`.scratch/iot-risk-import-voucher-remark/PRD.md`
## What to build
将退款申请接口的凭证字段从单个字符串改为最多5个字符串的列表
- `CreateRefundRequest.refund_voucher_key``string``[]string`validate 限制 `max=5`,每项 `max=500`required至少1个
- `ResubmitRefundRequest.refund_voucher_key``*string``*[]string`(重新提交时可选替换)
- `RefundResponse.refund_voucher_key`(以及所有退款相关响应 DTO`string``[]string`
- Refund Model 的 `RefundVoucherKey` 字段类型改为适配 jsonb 的类型
- Refund Service 中读写 `refund_voucher_key` 的逻辑同步更新
Schema 变更已由 `issues/05-voucher-multi-migration.md` 完成。
## Acceptance criteria
- [ ] 创建退款申请接口接受 `refund_voucher_key []string`1-5个元素必填
- [ ] 重新提交退款接口的 `refund_voucher_key` 可选,传入时替换全部凭证
- [ ] 退款列表/详情接口返回 `refund_voucher_key []string`
- [ ] 传入超过5个凭证 key 时返回参数校验错误
- [ ] 传入空数组时返回参数校验错误至少需要1个
## Blocked by
- `issues/05-voucher-multi-migration.md`

View File

@@ -0,0 +1,35 @@
Status: done
## Parent
`.scratch/iot-risk-import-voucher-remark/PRD.md`
## What to build
同时完成代理预充值的凭证多图改造和备注字段接入:
**凭证多图**
- `CreateAgentRechargeRequest.payment_voucher_key``string``[]string`validate 限制 `max=5`,每项 `max=500`offline 支付时至少1个微信支付时忽略
- `AgentRechargeResponse.payment_voucher_key``string``[]string`
- `AgentRechargeRecord.PaymentVoucherKey` Model 字段类型改为适配 jsonb 的类型
**备注字段**
- `CreateAgentRechargeRequest` 新增 `remark string`可选建议不超过1000字符
- `AgentRechargeResponse` 新增 `remark string`(列表和详情接口均返回)
- `AgentRechargeRecord.Remark` 在创建时写入,后续不可修改
AgentRecharge Service 中读写相关字段的逻辑同步更新。
Schema 变更payment_voucher_key 类型 + remark 字段)已由 `issues/05-voucher-multi-migration.md` 完成。
## Acceptance criteria
- [ ] 创建代理预充值接口接受 `payment_voucher_key []string`offline 时至少1个和可选 `remark`
- [ ] 代理预充值列表/详情接口返回 `payment_voucher_key []string``remark`
- [ ] 传入超过5个凭证 key 时返回参数校验错误
- [ ] 不新增 remark 修改接口,创建后备注字段只读
- [ ] 微信支付创建预充值时可不传凭证(与现有逻辑一致)
## Blocked by
- `issues/05-voucher-multi-migration.md`

View File

@@ -346,6 +346,19 @@ func startPollingScheduler(
appLogger,
)
// 注入套餐失效前流量同步器:最小化 iot_card.Service只需 gateway + store + redis + 流量扣减)
trafficSyncer := iot_card_svc.New(
runtime.db,
runtime.pollingIotCardStore,
nil, nil, nil, nil, nil,
runtime.gatewayClient,
appLogger,
nil,
)
trafficSyncer.SetRedisClient(runtime.redisClient)
trafficSyncer.SetDataDeductor(runtime.workerResult.Services.UsageService)
activationHandler.SetTrafficSyncer(trafficSyncer)
pollingScheduler := polling.NewScheduler(
runtime.redisClient,
runtime.asynqClient,

343
docs/改造方向.md Normal file
View File

@@ -0,0 +1,343 @@
# 系统改造方向
> 本文档记录当前系统的核心问题、改造原因、改造方向和预期收益。
> 用于在开发过程中明确"干什么、为什么干、干的好处"。
---
## 一、现状
### 1.1 项目规模
- 当前在线卡数:约 3 万张(生产)
- 待迁移卡数:约 67 万张(来自旧系统)
- 核心业务链路:代理购卡 → 销售分配 → 套餐购买 → 流量同步 → 停复机
### 1.2 轮询系统现状
轮询系统的作用是**定期向运营商查询卡的状态**(实名状态、流量用量、卡网络状态等),保持系统数据与运营商同步。
当前架构:
```
全量卡 ID → Redis Sorted Set分片score = 下次检查时间)
→ Scheduler 每秒扫描到期卡
→ Asynq 队列
→ Worker 执行检查(实名/流量/卡状态/保护期/套餐,共 5 种类型)
```
**问题清单:**
| # | 问题 | 具体表现 |
|---|------|---------|
| 1 | **看不见,无法排查** | 业务反馈"某张卡一小时了还没同步实名",没有任何结构化记录可以查,只能翻日志文件靠关键词过滤 |
| 2 | **配置改了不知道有没有用** | 轮询配置对哪些卡生效完全黑盒,改完后没有验证手段,全靠等和猜 |
| 3 | **大量无意义轮询** | 全量卡按固定频率轮询大多数卡状态根本没有变化这些查询全是浪费67 万张卡迁移后压力更大 |
| 4 | **业务事件不触发检查** | 代理通过开放接口买了套餐,系统不知道,要等下次定时轮询碰到这张卡(可能要等几分钟到几十分钟)|
| 5 | **两种不同职责混在一个调度器** | 卡状态同步(每 1 秒触发)和业务流程调度(套餐过期处理、流量重置,每 10 秒触发)混在同一个 Scheduler出问题难以定位是哪一层 |
| 6 | **调度状态全在 Redis没有持久化** | Redis 重启或清空后全量卡的调度状态丢失需要重跑全量初始化才能恢复67 万张卡的初始化需要相当长时间,期间所有卡停止轮询 |
| 7 | **单卡状态散在四处** | 上次检查时间在 IotCard 表;下次检查时间在 Redis Sorted Set卡状态快照在 Redis Hash执行结果在日志文件。没有一个地方能给完整答案 |
### 1.3 流量数据现状
运营商接口返回的是**当前账期内的累计已用流量**(不是增量,是总量)。
运营商账期重置日因运营商而异,在创建运营商时配置。
套餐周期可以是自然月或固定日期,在创建套餐时决定。
当前 IotCard 表上与流量相关的字段:
```
DataUsageMB 卡生命周期总用量(永不归零)
CurrentMonthUsageMB 系统自然月累计用量(展示用)
LastMonthTotalMB 上月结束时的流量总量(用于跨月计算)
CurrentMonthStartDate 系统自然月起始日期
LastGatewayReadingMB 上次轮询时运营商返回的累计读数
```
PackageUsage 表上:
```
DataUsageMB 套餐已用量(套餐周期到期时归零,用于判断是否耗尽)
```
**问题清单:**
| # | 问题 | 具体表现 |
|---|------|---------|
| 1 | **运营商重置靠猜,有漏检风险** | 只存上一次读数(`LastGatewayReadingMB`),靠"本次读数比上次小"来猜测运营商是否重置了账期。若轮询有空窗(例如系统故障几小时),重置后的新用量超过了上次读数,则增量为正,系统判断为"没有重置",漏掉了那段用量 |
| 2 | **无原始读数,无法审计** | 运营商告诉了什么、什么时候告诉的、算出了多少增量——计算完成后全部丢弃。流量统计不对时,没有任何数据可以追溯原因 |
| 3 | **重置边界的流量归属不准** | 套餐在午夜重置,但上次轮询在 23:59下次轮询在 00:01。这两分钟的增量包含昨天剩余用量全部算进今天日流量套餐受影响明显 |
| 4 | **三套"月"的概念同时存在** | 运营商账期(各自定义重置日)+ 套餐周期(自然月或固定日)+ 系统自然月(展示用)。三者不对齐,边界处理散落在代码各处,逻辑复杂且脆弱 |
| 5 | **`CurrentMonthUsageMB` 不按时重置** | 这个字段不由重置任务触发而是在下次轮询时内联检测跨月并重置。1 号凌晨用户看到的数据是上月的,要等到下次轮询才更新 |
| 6 | **两套日流量记录并存** | `CardDailyUsage`(按卡)和 `PackageUsageDailyRecord`(按套餐)同时存在,来源不同,权威性不明确,出现差异时不知道信谁 |
### 1.4 整体架构现状
当前分层:`Handler → Service → Store → Model`,分层存在但执行不彻底。
核心问题:**贫血模型 + 上帝 Service**
- Model 只是数据库映射,没有任何业务行为
- 所有业务逻辑全部堆在 Service导致 Service 极度臃肿
| Service | 行数 | 混杂的业务域 |
|---------|------|------------|
| order/service.go | 3031 行 | 订单创建、支付、佣金计算、代购、库存扣减 |
| iot_card/service.go | 2310 行 | 卡管理、网关调用、停复机、生命周期、流量同步 |
| device/service.go | 2078 行 | 设备管理、绑定、批量导入 |
没有领域事件:业务事件(套餐支付完成、卡实名成功)不产生任何可订阅的事件,所有下游动作全部通过直接方法调用耦合,新增联动逻辑必须修改原有代码。
---
## 二、改造方向(优先级一):轮询系统 + 流量数据
### 2.1 轮询系统改造
#### 改造 A建立单卡可查询的轮询状态
**要干什么:** 新增一张表 `card_polling_state`,每张卡一行,只更新不追加,记录每种检查类型的上次执行时间、结果、命中配置、下次预计时间。
**为什么干:** 现在"这张卡的轮询状态"散在四个地方,没有一处能完整回答问题。这张表就是单一的事实来源。
**好处:**
- 业务反馈某张卡没同步 → 查这张表30 秒给出答案(上次检查时间、结果是什么、下次什么时候)
- 不影响任何现有轮询逻辑,只需在执行检查后多写一次 UPDATE
**同时新增两个接口:**
```
GET /admin/polling/cards/:id/state
→ 返回这张卡的完整轮询状态
GET /admin/polling/preview?card_id=12345
→ 返回这张卡当前命中哪条配置、各检查类型的间隔是多少、下次检查时间
```
第二个接口解决"改了配置不知道有没有用"的问题——改完配置后,查任意一张卡,立刻知道新配置有没有生效。
---
#### 改造 B引入事件触发层
**要干什么:** 在业务动作完成时,主动安排针对性的延迟检查,而不是等定时轮询碰到这张卡。
**为什么干:** 现在买了套餐,系统要等下次轮询才知道套餐有没有激活。运营商激活需要时间,但我们不知道什么时候激活完成,只能等碰到。
**好处:**
- 套餐购买 → 5 分钟后主动检查 → 激活状态及时同步,不用等下次定时轮询
- 轮询有了"意义"——有事件发生才检查,而不是盲目扫全量
**触发规则:**
| 业务事件 | 触发检查 | 延迟 |
|---------|---------|------|
| 套餐订单支付完成 | 检查套餐激活状态 | 5 分钟 |
| 代理开放接口购套餐 | 检查套餐激活状态 | 5 分钟 |
| 卡首次分配给店铺 | 检查实名状态 | 30 分钟 |
| 手动触发刷新 | 立即检查 | 0 |
实现方式:利用 Asynq 的定时任务功能(`asynq.ProcessAt`),在现有 Worker 框架内完成,不需要新增基础设施。
---
#### 改造 C按卡状态分级调度
**要干什么:** 不同状态的卡,轮询频率不同,而不是所有卡一视同仁。
**为什么干:** 稳定运行了半年、没有套餐变动的卡,每 2 分钟轮询一次是纯粹的浪费。等实名的卡、刚购套餐的卡才需要频繁检查。
**好处:**
- 67 万张卡迁移后,无意义轮询量大幅下降(估计减少 60%-80%
- 轮询资源集中在真正需要关注的卡
**分级规则(通过现有 PollingConfig 机制配置):**
| 卡状态 | 实名检查间隔 | 流量检查间隔 |
|-------|------------|------------|
| 等待实名 | 10 分钟 | 30 分钟 |
| 活跃中(近期有套餐变动)| 15 分钟 | 30 分钟 |
| 稳定运行 | 2 小时 | 2 小时 |
| 停机 / 停用 | 每日一次 | 每日一次 |
---
#### 改造 D分离业务调度
**要干什么:** 把套餐过期处理、流量重置从 `Scheduler` 里拆出去,用独立的定时任务管理。
**为什么干:** 现在卡状态同步(每 1 秒)和业务逻辑调度(每 10 秒)混在同一个 Scheduler 里。出了问题不知道是哪一层,改动也容易互相影响。
**好处:**
- 各司其职,互不干扰
- 业务调度(套餐过期)出问题不影响卡状态同步
- 代码更清晰,新增调度任务不需要修改 Scheduler
---
### 2.2 流量数据改造
#### 改造 A保留原始读数
**要干什么:** 新增 `carrier_readings` 表,每次从运营商拿到读数都记录下来,永不修改。
```
carrier_readings:
card_id 卡ID
reading_mb 运营商返回的当期累计值
read_at 读取时间
cycle_start_date 本条读数所属的运营商账期开始日期
source 来源polling / manual / triggered
```
**为什么干:** 现在计算完增量后,原始读数就丢弃了。流量统计不对时,没有任何数据可以追溯。
**好处:**
- 流量统计有问题 → 查这张表,还原每次拿到了什么数据、算出了多少增量
- 不再是"只有最终结果,不知道过程"
- 可以离线重算历史数据,修复过去的错误
**存储量控制:** 保留 30 天原始记录,超过 30 天只保留"状态变化"事件(数量极少)。分区表实现,不影响查询性能。
---
#### 改造 B基于账期的重置检测
**要干什么:**`cycle_start_date` 字段判断运营商是否重置了账期,替代现在靠"增量为负"来猜测的方式。
**为什么干:** 现在的猜测方式有漏检风险——如果轮询空窗期间运营商重置且新用量超过了旧读数,增量为正,系统无法检测到重置,漏计那段用量。
**好处:**
- 重置检测从"猜"变成"判断",准确率 100%
- 彻底消除因轮询空窗导致的流量漏计
- 代码逻辑更简单,不再需要"重置窗口"时间判断
```go
// 改前:靠负增量猜测,有漏检风险
if increment < 0 && isRefreshResetWindow(now, resetDay) { ... }
// 改后:直接判断账期,准确无误
if prev.CycleStartDate != curr.CycleStartDate {
increment = curr.ReadingMB // 不同账期 = 运营商重置,当前读数即为增量
}
```
---
#### 改造 C清理 IotCard 上的流量计算字段
**要干什么:**`LastGatewayReadingMB``CurrentMonthStartDate``LastMonthTotalMB` 从 IotCard 表移除,这些信息改由 `carrier_readings` 表承载。
IotCard 只保留两个对外展示的汇总值:
- `CurrentMonthUsageMB`:展示用,由定时任务按自然月计算并主动更新(不再等轮询时内联检测)
- `DataUsageMB`:生命周期总量,由增量事件累加
**为什么干:** 现在 IotCard 同时承担了三个角色:卡的身份信息、流量计数器、流量计算中间状态。职责太多,一张表改动牵一发动全身。
**好处:**
- IotCard 回归本职:描述这张卡是谁、状态是什么
- 流量计算有了独立的数据层,不污染主体数据
- `CurrentMonthUsageMB` 在月初会被主动更新,不再有"1号凌晨数据是上月的"问题
---
## 三、改造方向优先级二DDD 整体方向
> 这部分是更长期的方向,在优先级一完成后推进。
### 3.1 核心思想
**积木本身是安全的,拼积木组成业务流程。**
- **积木** = 领域对象(`IotCard``Order``Wallet`):自己知道自己的规则,外部只能通过方法操作,不能随意改字段
- **拼积木** = 应用服务(用例):只负责"拿数据 → 调对象方法 → 保存 → 发事件",不含业务判断逻辑
**AI 辅助开发的约束价值:** 有了这个结构AI 生成代码时只能往"一个用例一个文件"的模式里填,不会生成新的上帝 Service业务规则在领域对象里AI 调用对象方法时规则自动生效。
### 3.2 目标目录结构
```
internal/
├── domain/ 领域层(纯业务逻辑,不依赖任何框架)
│ ├── order/
│ │ ├── order.go 聚合根Order 实体有方法Pay, Cancel, Reject
│ │ ├── events.go 领域事件OrderPaid, OrderCancelled
│ │ ├── repository.go 仓储接口interface不含实现
│ │ └── values.go 值对象OrderStatus, Amount
│ ├── asset/
│ │ ├── iot_card.go IotCard 聚合根
│ │ └── repository.go
│ └── wallet/
│ └── wallet.go Wallet 聚合根Debit, Credit
├── application/ 应用层(用例,只做编排)
│ ├── order/
│ │ ├── create_order.go 一个文件一个用例
│ │ ├── pay_order.go
│ │ └── cancel_order.go
│ └── asset/
├── infrastructure/ 基础设施层(实现接口)
│ ├── persistence/ 原来的 store/postgres/
│ └── event/ 事件总线
└── interface/ 接口层
├── http/ 原来的 handler/ + routes/
└── task/ Asynq task handlers
```
### 3.3 迁移策略(绞杀者模式)
不全量重写,新旧并行,逐步替换:
1. 建好 `domain/``application/` 目录和规范文件
2. **所有新功能按新结构写**,不往旧 Service 里加代码
3. 最痛的模块优先迁移:`order`3031 行)和 `iot_card`2310 行)
4. 每次迁移一个用例,验证跑通后再迁移下一个
---
## 四、实施顺序
```
第一阶段(当前)
├── 新增 card_polling_state 表(轮询状态持久化)
├── 新增轮询状态查询接口 + 配置预览接口(可观测性)
├── 新增 carrier_readings 表(保留原始读数)
├── 改造增量计算逻辑(基于账期判断重置)
└── 事件触发层(套餐购买 → 延迟检查)
第二阶段
├── 分级调度(按卡状态动态调整轮询频率)
├── 分离业务调度(套餐过期/流量重置从 Scheduler 独立)
└── 清理 IotCard 流量计算字段
第三阶段DDD 改造)
├── 建立目录骨架和规范示例
├── 新功能按新结构写
└── 逐步迁移最痛的模块
```
---
## 五、预期收益
### 轮询 + 流量改造后
| 场景 | 改造前 | 改造后 |
|------|--------|--------|
| 业务反馈"某张卡没同步" | 翻日志,靠运气,可能花几十分钟 | 查接口30 秒内给出答案 |
| 验证配置是否生效 | 完全黑盒,改完只能等和猜 | 配置预览接口,改完立即验证 |
| 流量统计不对 | 无法追溯,无从下手 | 查原始读数,还原每次增量计算 |
| 运营商重置漏计 | 有风险,轮询空窗时必现 | 账期判断100% 准确 |
| 套餐购买后同步延迟 | 等下次定时轮询,最多几十分钟 | 5 分钟内主动检查 |
| 67 万卡迁移后轮询压力 | 全量扫描,压力线性增长 | 分级 + 事件触发,压力可控 |
### DDD 改造后
| 场景 | 改造前 | 改造后 |
|------|--------|--------|
| AI 辅助开发新功能 | 容易生成新的上帝 Service | 遵循用例模式,每次只加一个文件 |
| 新增业务联动逻辑 | 必须修改原有 Service 代码 | 新增事件监听器,原有代码不动 |
| 定位业务规则在哪里 | 散在几千行 Service 里找 | 在对应的领域对象方法里 |
| 排查某个流程的问题 | 跨多个 Service 追调用链 | 用例文件即流程,一目了然 |

View File

@@ -139,7 +139,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
}(),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate),
Refund: admin.NewRefundHandler(svc.Refund),
Refund: admin.NewRefundHandler(svc.Refund),
OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(svc.OrderPackageInvalidate),
ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger),
SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword),
AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate),

View File

@@ -46,6 +46,7 @@ import (
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
operationPasswordSvc "github.com/break/junhong_cmp_fiber/internal/service/operation_password"
orderPackageInvalidateSvc "github.com/break/junhong_cmp_fiber/internal/service/order_package_invalidate"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund"
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
@@ -111,6 +112,7 @@ type services struct {
OperationPassword *operationPasswordSvc.Service
AgentOpenAPI *agentOpenAPISvc.Service
CustomerBinding *customerBindingSvc.Service
OrderPackageInvalidate *orderPackageInvalidateSvc.Service
}
func initServices(s *stores, deps *Dependencies) *services {
@@ -313,6 +315,7 @@ func initServices(s *stores, deps *Dependencies) *services {
s.AssetWallet,
deps.Logger,
),
CustomerBinding: customerBinding,
CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
}
}

View File

@@ -66,6 +66,8 @@ type stores struct {
WechatConfig *postgres.WechatConfigStore
// 退款系统
RefundRequest *postgres.RefundStore
// 订单套餐失效任务
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
// 流量系统
CardDailyUsage *postgres.CardDailyUsageStore
// 资产标识符注册表
@@ -131,8 +133,9 @@ func initStores(deps *Dependencies) *stores {
RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis),
Payment: postgres.NewPaymentStore(deps.DB, deps.Redis),
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
RefundRequest: postgres.NewRefundStore(deps.DB),
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
RefundRequest: postgres.NewRefundStore(deps.DB),
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
}
}

View File

@@ -63,6 +63,7 @@ type Handlers struct {
WechatConfig *admin.WechatConfigHandler
AgentRecharge *admin.AgentRechargeHandler
Refund *admin.RefundHandler
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler
ClientWechat *app.ClientWechatHandler
SuperAdmin *admin.SuperAdminHandler
AgentOpenAPI *openapiHandler.Handler

View File

@@ -33,8 +33,9 @@ type workerStores struct {
AgentWalletTransaction *postgres.AgentWalletTransactionStore
AssetWallet *postgres.AssetWalletStore
AssetIdentifier *postgres.AssetIdentifierStore
PersonalCustomer *postgres.PersonalCustomerStore
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
PersonalCustomer *postgres.PersonalCustomerStore
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
}
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
@@ -66,8 +67,9 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
}
return &queue.WorkerStores{
@@ -98,7 +100,8 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
AgentWalletTransaction: stores.AgentWalletTransaction,
AssetWallet: stores.AssetWallet,
AssetIdentifier: stores.AssetIdentifier,
PersonalCustomer: stores.PersonalCustomer,
PersonalCustomerPhone: stores.PersonalCustomerPhone,
PersonalCustomer: stores.PersonalCustomer,
PersonalCustomerPhone: stores.PersonalCustomerPhone,
OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask,
}
}

View File

@@ -74,6 +74,29 @@ func (h *AgentRechargeHandler) Get(c *fiber.Ctx) error {
return response.Success(c, result)
}
// Reject 驳回代理充值订单
// POST /api/admin/agent-recharges/:id/reject
func (h *AgentRechargeHandler) Reject(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.AgentRechargeRejectRequest
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.Reject(c.UserContext(), uint(id), req.RejectionReason); err != nil {
return err
}
return response.Success(c, nil)
}
// OfflinePay 确认线下充值
// POST /api/admin/agent-recharges/:id/offline-pay
func (h *AgentRechargeHandler) OfflinePay(c *fiber.Ctx) error {

View File

@@ -0,0 +1,92 @@
package admin
import (
"strconv"
"github.com/gofiber/fiber/v2"
"github.com/break/junhong_cmp_fiber/internal/model/dto"
invalidateSvc "github.com/break/junhong_cmp_fiber/internal/service/order_package_invalidate"
"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"
)
// OrderPackageInvalidateHandler 订单套餐批量失效任务 Handler
type OrderPackageInvalidateHandler struct {
service *invalidateSvc.Service
}
// NewOrderPackageInvalidateHandler 创建 Handler 实例
func NewOrderPackageInvalidateHandler(service *invalidateSvc.Service) *OrderPackageInvalidateHandler {
return &OrderPackageInvalidateHandler{service: service}
}
// Create 创建批量失效订单套餐任务
// POST /api/admin/order-package-invalidate-tasks
func (h *OrderPackageInvalidateHandler) Create(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "仅平台用户可创建套餐失效任务")
}
var req dto.CreateOrderPackageInvalidateTaskRequest
if err := c.BodyParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
if req.FileKey == "" {
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/order-package-invalidate-tasks
func (h *OrderPackageInvalidateHandler) List(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "仅平台用户可查看套餐失效任务")
}
var req dto.ListOrderPackageInvalidateTaskRequest
if err := c.QueryParser(&req); err != nil {
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
}
result, err := h.service.List(c.UserContext(), &req)
if err != nil {
return err
}
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
}
// GetByID 查询批量失效任务详情
// GET /api/admin/order-package-invalidate-tasks/:id
func (h *OrderPackageInvalidateHandler) GetByID(c *fiber.Ctx) error {
userType := middleware.GetUserTypeFromContext(c.UserContext())
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
return errors.New(errors.CodeForbidden, "仅平台用户可查看套餐失效任务详情")
}
idStr := c.Params("id")
id, err := strconv.ParseUint(idStr, 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)
}

View File

@@ -82,7 +82,9 @@ type AgentRechargeRecord struct {
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"`
PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500);comment:支付凭证对象存储Key线下支付时必填微信支付时为空" json:"payment_voucher_key,omitempty"`
PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:支付凭证对象存储Key列表(线下支付时必填,最多5个微信支付时为空)" json:"payment_voucher_key"`
Remark string `gorm:"column:remark;type:text;comment:运营备注(创建时填写,不可修改)" json:"remark,omitempty"`
RejectionReason *string `gorm:"column:rejection_reason;type:varchar(500);comment:驳回原因,仅驳回时写入" json:"rejection_reason,omitempty"`
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"`

View File

@@ -29,6 +29,7 @@ type DeviceImportTask struct {
StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:导入批次实名策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`
CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:操作人姓名快照" json:"creator_name"`
}
// TableName 指定表名

View File

@@ -106,7 +106,7 @@ type AgentOpenAPIWalletTransactionListRequest struct {
// AgentOpenAPIWalletPackageOrderRequest 开放接口钱包套餐购买请求
type AgentOpenAPIWalletPackageOrderRequest struct {
CardNos []string `json:"card_nos" validate:"required,min=1,max=100,dive,min=1,max=100" required:"true" minItems:"1" maxItems:"100" description:"卡标识列表(支持 ICCID、虚拟号、MSISDN"`
CardNos []string `json:"card_nos" validate:"required,min=1,max=100,dive,min=1,max=100" required:"true" minItems:"1" maxItems:"100" description:"卡标识列表(支持 ICCID、虚拟号、MSISDN、设备 IMEI传入设备 IMEI 时自动转为设备维度购买"`
PackageCode string `json:"package_code" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"套餐编码(一次只能购买一个套餐)"`
}

View File

@@ -5,7 +5,8 @@ type CreateAgentRechargeRequest struct {
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"目标店铺ID代理只能填自己店铺"`
Amount int64 `json:"amount" validate:"required,min=1,max=100000000" required:"true" minimum:"1" maximum:"100000000" description:"充值金额范围1分~100万元"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat offline" required:"true" description:"支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用)"`
PaymentVoucherKey string `json:"payment_voucher_key" validate:"omitempty,max=500" description:"支付凭证对象存储Keypayment_method=offline 时必填,微信支付时忽略)"`
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表payment_method=offline 时至少1个最多5个,微信支付时忽略)"`
Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"`
}
// AgentOfflinePayRequest 代理线下充值确认请求
@@ -21,23 +22,36 @@ type AgentOfflinePayParams struct {
// 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:"第三方支付流水号"`
PaymentVoucherKey string `json:"payment_voucher_key,omitempty" description:"支付凭证对象存储Key线下支付时存在"`
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
PaidAt *string `json:"paid_at" description:"支付时间"`
CompletedAt *string `json:"completed_at" description:"完成时间"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
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:"第三方支付流水号"`
PaymentVoucherKey []string `json:"payment_voucher_key" description:"支付凭证对象存储Key列表(线下支付时存在最多5个"`
Remark string `json:"remark,omitempty" description:"运营备注"`
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
RejectionReason *string `json:"rejection_reason,omitempty" description:"驳回原因,仅 status=6 时有值"`
PaidAt *string `json:"paid_at" description:"支付时间"`
CompletedAt *string `json:"completed_at" description:"完成时间"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
// AgentRechargeRejectRequest 驳回代理充值订单请求
type AgentRechargeRejectRequest struct {
RejectionReason string `json:"rejection_reason" validate:"required,max=500" required:"true" description:"驳回原因必填最多500字"`
}
// AgentRechargeRejectParams 驳回代理充值订单聚合参数(用于文档生成)
type AgentRechargeRejectParams struct {
IDReq
AgentRechargeRejectRequest
}
// AgentRechargeListRequest 代理充值记录列表请求
@@ -45,7 +59,7 @@ 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:已完成, 4:已关闭, 5:已退款)"`
Status *int `json:"status" query:"status" description:"按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"`
StartDate string `json:"start_date" query:"start_date" description:"创建时间起始日期(YYYY-MM-DD)"`
EndDate string `json:"end_date" query:"end_date" description:"创建时间截止日期(YYYY-MM-DD)"`
}

View File

@@ -39,6 +39,7 @@ type DeviceImportTaskResponse struct {
StartedAt *time.Time `json:"started_at,omitempty" description:"开始处理时间"`
CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"`
ErrorMessage string `json:"error_message,omitempty" description:"错误信息"`
CreatorName string `json:"creator_name" description:"操作人姓名"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}

View File

@@ -126,6 +126,7 @@ type ImportTaskResponse struct {
StartedAt *time.Time `json:"started_at,omitempty" description:"开始处理时间"`
CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"`
ErrorMessage string `json:"error_message,omitempty" description:"错误信息"`
CreatorName string `json:"creator_name" description:"操作人姓名"`
CreatedAt time.Time `json:"created_at" description:"创建时间"`
}

View File

@@ -15,7 +15,7 @@ type CreateAdminOrderRequest struct {
Identifier string `json:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符ICCID 或 VirtualNo"`
PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"`
PaymentVoucherKey string `json:"payment_voucher_key" validate:"omitempty,max=500" maxLength:"500" description:"线下支付凭证对象存储file_keypayment_method=offline时必填,通过/storage/upload-url上传图片后获得"`
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"线下支付凭证对象存储file_key列表payment_method=offline时至少1个最多5个,通过/storage/upload-url上传图片后获得"`
}
type OrderListRequest struct {
@@ -63,7 +63,7 @@ type OrderResponse struct {
PaymentStatus int `json:"payment_status" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"`
PaymentStatusText string `json:"payment_status_text" description:"支付状态文本"`
PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"`
PaymentVoucherKey string `json:"payment_voucher_key,omitempty" description:"线下支付凭证对象存储file_key线下支付订单有值)"`
PaymentVoucherKey []string `json:"payment_voucher_key" description:"线下支付凭证对象存储file_key列表(线下支付订单有值最多5个"`
IsPurchaseOnBehalf bool `json:"is_purchase_on_behalf" description:"是否为代购订单"`
CommissionStatus int `json:"commission_status" description:"佣金流程状态 (1:待计算, 2:已完成, 3:待人工处理)"`
CommissionStatusName string `json:"commission_status_name" description:"佣金流程状态名称(中文)"`

View File

@@ -0,0 +1,56 @@
package dto
// CreateOrderPackageInvalidateTaskRequest 创建订单套餐失效任务请求
type CreateOrderPackageInvalidateTaskRequest struct {
FileKey string `json:"file_key" validate:"required,max=500" required:"true" maxLength:"500" description:"CSV文件对象存储Key通过/storage/upload-url上传后获得CSV单列 order_no"`
VoucherKeys []string `json:"voucher_keys" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表可选最多5个"`
Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"`
}
// OrderPackageInvalidateTaskResponse 任务详情响应
type OrderPackageInvalidateTaskResponse struct {
ID uint `json:"id" description:"任务ID"`
TaskNo string `json:"task_no" description:"任务编号"`
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`
TotalCount int `json:"total_count" description:"总行数"`
SuccessCount int `json:"success_count" description:"成功数"`
FailCount int `json:"fail_count" description:"失败数"`
FileName string `json:"file_name" description:"原始文件名"`
VoucherKeys []string `json:"voucher_keys" description:"支付凭证Key列表"`
Remark string `json:"remark,omitempty" description:"运营备注"`
CreatorName string `json:"creator_name" description:"操作人姓名"`
ErrorMessage string `json:"error_message,omitempty" description:"任务级错误信息"`
StartedAt *string `json:"started_at,omitempty" description:"开始处理时间"`
CompletedAt *string `json:"completed_at,omitempty" description:"完成时间"`
CreatedAt string `json:"created_at" description:"创建时间"`
UpdatedAt string `json:"updated_at" description:"更新时间"`
}
// OrderPackageInvalidateTaskDetailResponse 任务详情(含失败明细)
type OrderPackageInvalidateTaskDetailResponse struct {
OrderPackageInvalidateTaskResponse
FailedItems []InvalidateFailedItem `json:"failed_items" description:"失败记录列表"`
}
// InvalidateFailedItem 失效失败记录
type InvalidateFailedItem struct {
Line int `json:"line" description:"CSV 行号"`
OrderNo string `json:"order_no" description:"订单号"`
Reason string `json:"reason" description:"失败原因"`
}
// ListOrderPackageInvalidateTaskRequest 任务列表查询请求
type ListOrderPackageInvalidateTaskRequest 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"`
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"按状态过滤 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
}
// OrderPackageInvalidateTaskListResponse 任务列表响应
type OrderPackageInvalidateTaskListResponse struct {
Total int64 `json:"total" description:"总记录数"`
Page int `json:"page" description:"当前页码"`
PageSize int `json:"size" description:"每页条数"`
List []*OrderPackageInvalidateTaskResponse `json:"items" description:"任务列表"`
}

View File

@@ -5,7 +5,7 @@ type CreateRefundRequest struct {
OrderID uint `json:"order_id" validate:"required" required:"true" description:"关联订单ID"`
ActualReceivedAmount int64 `json:"actual_received_amount" validate:"required,min=1" required:"true" minimum:"1" description:"实收金额(分)"`
RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"required,min=1" required:"true" minimum:"1" description:"申请退款金额(分)"`
RefundVoucherKey string `json:"refund_voucher_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"退款凭证对象存储file_key通过/storage/upload-url上传图片后获得"`
RefundVoucherKey []string `json:"refund_voucher_key" validate:"required,min=1,max=5,dive,max=500" required:"true" minItems:"1" maxItems:"5" description:"退款凭证对象存储file_key列表至少1个最多5个通过/storage/upload-url上传图片后获得"`
RefundReason string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
PackageUsageID *uint `json:"package_usage_id" validate:"omitempty" description:"关联套餐使用记录ID可选"`
}
@@ -27,7 +27,7 @@ type ResubmitRefundRequest struct {
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"退款申请ID"`
ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"实收金额(分)"`
RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=1" minimum:"1" description:"申请退款金额(分)"`
RefundVoucherKey *string `json:"refund_voucher_key" validate:"omitempty,max=500" maxLength:"500" description:"退款凭证对象存储file_key重新提交时可替换历史记录缺失时必填"`
RefundVoucherKey *[]string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表(重新提交时可替换;历史记录缺失时必填最多5个"`
RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
}
@@ -70,7 +70,7 @@ type RefundResponse struct {
ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"`
RequestedRefundAmount int64 `json:"requested_refund_amount" description:"申请退款金额(分)"`
ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"`
RefundVoucherKey string `json:"refund_voucher_key,omitempty" description:"退款凭证对象存储file_key"`
RefundVoucherKey []string `json:"refund_voucher_key" description:"退款凭证对象存储file_key列表最多5个"`
RefundReason string `json:"refund_reason" description:"退款原因"`
Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"`
StatusName string `json:"status_name" description:"状态名称(中文)"`

View File

@@ -33,6 +33,7 @@ type IotCardImportTask struct {
StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key,omitempty"`
CardCategory string `gorm:"column:card_category;type:varchar(20);default:normal;not null;comment:卡业务类型(normal/industry)" json:"card_category"`
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:导入批次实名策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`
CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:操作人姓名快照" json:"creator_name"`
}
// CardItem 卡信息ICCID + MSISDN + VirtualNo

View File

@@ -70,8 +70,8 @@ type Order struct {
// 支付配置
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
// 线下支付凭证对象存储 file_key仅线下支付订单必填)
PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500);comment:线下支付凭证对象存储file_key" json:"payment_voucher_key,omitempty"`
// 线下支付凭证对象存储 file_key 列表最多5个仅线下支付订单必填)
PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:线下支付凭证对象存储file_key列表jsonb存储[]string" json:"payment_voucher_key"`
}
// TableName 指定表名

View File

@@ -0,0 +1,36 @@
package model
import (
"time"
"gorm.io/gorm"
)
// OrderPackageInvalidateTask 订单套餐批量失效任务模型
type OrderPackageInvalidateTask struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex;not null;comment:任务编号" json:"task_no"`
Status int `gorm:"column:status;type:int;not null;default:1;comment:任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)" json:"status"`
TotalCount int `gorm:"column:total_count;type:int;not null;default:0;comment:总行数" json:"total_count"`
SuccessCount int `gorm:"column:success_count;type:int;not null;default:0;comment:成功数" json:"success_count"`
FailCount int `gorm:"column:fail_count;type:int;not null;default:0;comment:失败数" json:"fail_count"`
FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;not null;default:'[]';comment:失败记录详情(jsonb)" json:"failed_items"`
FileName string `gorm:"column:file_name;type:varchar(255);not null;default:'';comment:原始文件名" json:"file_name"`
StorageKey string `gorm:"column:storage_key;type:varchar(500);not null;default:'';comment:CSV 文件对象存储路径" json:"storage_key"`
VoucherKeys StringJSONBArray `gorm:"column:voucher_keys;type:jsonb;not null;default:'[]';comment:支付凭证对象存储Key列表最多5个" json:"voucher_keys"`
Remark string `gorm:"column:remark;type:text;not null;default:'';comment:运营备注(创建时填写,只读)" json:"remark"`
CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:操作人姓名快照" json:"creator_name"`
ErrorMessage string `gorm:"column:error_message;type:text;not null;default:'';comment:任务级错误信息" json:"error_message"`
StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"`
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
Creator uint `gorm:"column:creator;not null;default:0;comment:创建人ID" json:"creator"`
Updater uint `gorm:"column:updater;not null;default:0;comment:更新人ID" json:"updater"`
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"`
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
}
// TableName 指定表名
func (OrderPackageInvalidateTask) TableName() string {
return "tb_order_package_invalidate_task"
}

View File

@@ -31,7 +31,7 @@ type RefundRequest struct {
ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount;type:bigint;comment:审批实际退款金额(分)" json:"approved_refund_amount,omitempty"`
// 退款凭证、原因和状态
RefundVoucherKey string `gorm:"column:refund_voucher_key;type:varchar(500);not null;default:'';comment:退款凭证对象存储file_key" json:"refund_voucher_key,omitempty"`
RefundVoucherKey StringJSONBArray `gorm:"column:refund_voucher_key;type:jsonb;not null;comment:退款凭证对象存储file_key列表jsonb存储[]string" json:"refund_voucher_key"`
RefundReason string `gorm:"column:refund_reason;type:text;comment:退款原因" json:"refund_reason"`
Status int `gorm:"column:status;type:int;not null;default:1;index;comment:状态 1-待审批 2-已通过 3-已拒绝 4-已退回" json:"status"`

32
internal/model/types.go Normal file
View File

@@ -0,0 +1,32 @@
package model
import (
"database/sql/driver"
"encoding/json"
)
// StringJSONBArray 用于将 []string 与 PostgreSQL jsonb 列互转
// 读写时通过 Scan/Value 完成序列化,空切片序列化为 []
type StringJSONBArray []string
// Value 写入数据库时序列化为 JSON
func (a StringJSONBArray) Value() (driver.Value, error) {
if a == nil {
return "[]", nil
}
return json.Marshal(a)
}
// Scan 从数据库读取时反序列化
func (a *StringJSONBArray) Scan(value any) error {
if value == nil {
*a = StringJSONBArray{}
return nil
}
b, ok := value.([]byte)
if !ok {
*a = StringJSONBArray{}
return nil
}
return json.Unmarshal(b, a)
}

View File

@@ -81,6 +81,30 @@ func (s *PollingLifecycleService) OnCardStatusChanged(ctx context.Context, cardI
s.enqueueCard(ctx, card)
}
// OnBatchCardsStatusChanged 批量卡状态变化:单次 Redis pipeline 清理 + 单次 DB 批量查询
// 替代 N 次 OnCardStatusChanged 调用,避免批量分配/回收时打满 DB 连接池
func (s *PollingLifecycleService) OnBatchCardsStatusChanged(ctx context.Context, cardIDs []uint) {
if len(cardIDs) == 0 {
return
}
// 单次 pipeline批量从当前分片队列移除并清理缓存
if err := s.queueMgr.RemoveBatchFromCurrentShardQueues(ctx, cardIDs); err != nil {
s.logger.Warn("批量卡状态变化:从队列移除失败", zap.Int("count", len(cardIDs)), zap.Error(err))
}
// 单次 DB 查询:批量加载所有卡信息
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
if err != nil {
s.logger.Error("批量卡状态变化:加载卡信息失败", zap.Int("count", len(cardIDs)), zap.Error(err))
return
}
for _, card := range cards {
if !s.shouldEnqueue(ctx, card) {
continue
}
s.enqueueCard(ctx, card)
}
}
// OnCardDeleted 卡删除后移除所有队列并清理缓存
func (s *PollingLifecycleService) OnCardDeleted(ctx context.Context, cardID uint) {
if err := s.queueMgr.OnCardDeleted(ctx, cardID); err != nil {

View File

@@ -17,6 +17,12 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/errors"
)
// TrafficSyncer 套餐失效前流量同步接口
// 在主套餐标记为已过期之前,从 Gateway 拉取最新流量写入 DB
type TrafficSyncer interface {
RefreshCardDataByID(ctx context.Context, cardID uint) error
}
// PackageActivationHandler 套餐激活检查处理器
// 任务 19: 处理主套餐过期、加油包级联失效、待生效主套餐激活
type PackageActivationHandler struct {
@@ -27,6 +33,7 @@ type PackageActivationHandler struct {
deviceSimBinding *postgres.DeviceSimBindingStore
activationService *packagepkg.ActivationService
stopResumeCallback packagepkg.StopResumeCallback // 停复机回调,用于套餐过期后主动停机
trafficSyncer TrafficSyncer // 套餐失效前流量同步,可选
logger *zap.Logger
}
@@ -60,6 +67,48 @@ func NewPackageActivationHandler(
}
}
// SetTrafficSyncer 注入套餐失效前流量同步器(在 Start 前调用)
func (h *PackageActivationHandler) SetTrafficSyncer(syncer TrafficSyncer) {
h.trafficSyncer = syncer
}
// syncTrafficBeforeExpiry 套餐失效前从 Gateway 同步一次最新流量
// iot_card 直接同步device 遍历所有绑定卡同步
// 失败只记录警告,不阻断失效流程
func (h *PackageActivationHandler) syncTrafficBeforeExpiry(ctx context.Context, carrierType string, carrierID uint) {
if h.trafficSyncer == nil {
return
}
if carrierType == constants.AssetTypeIotCard {
if err := h.trafficSyncer.RefreshCardDataByID(ctx, carrierID); err != nil {
h.logger.Warn("套餐失效前流量同步失败",
zap.String("carrier_type", carrierType),
zap.Uint("carrier_id", carrierID),
zap.Error(err))
}
return
}
if carrierType == constants.AssetTypeDevice {
bindings, err := h.deviceSimBinding.ListByDeviceID(ctx, carrierID)
if err != nil {
h.logger.Warn("套餐失效前查询设备绑定卡失败",
zap.Uint("device_id", carrierID),
zap.Error(err))
return
}
for _, b := range bindings {
if err := h.trafficSyncer.RefreshCardDataByID(ctx, b.IotCardID); err != nil {
h.logger.Warn("套餐失效前流量同步失败(设备绑定卡)",
zap.Uint("device_id", carrierID),
zap.Uint("card_id", b.IotCardID),
zap.Error(err))
}
}
}
}
// HandlePackageActivationCheck 任务 19.2-19.5: 处理套餐激活检查
// 每 10 秒调度一次,检查过期主套餐并激活下一个待生效主套餐
func (h *PackageActivationHandler) HandlePackageActivationCheck(ctx context.Context) error {
@@ -145,20 +194,21 @@ func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Con
continue
}
// 检查该载体是否已有生效套餐
// 检查该载体是否已有占位主套餐(生效中或已用完均视为占位,不允许激活待生效套餐
var activeCount int64
var countErr error
occupiedStatuses := []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}
if key.carrierType == "iot_card" {
countErr = h.db.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("status = ?", constants.PackageUsageStatusActive).
Where("status IN ?", occupiedStatuses).
Where("master_usage_id IS NULL").
Where("iot_card_id = ?", key.carrierID).
Count(&activeCount).Error
} else {
countErr = h.db.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("status = ?", constants.PackageUsageStatusActive).
Where("status IN ?", occupiedStatuses).
Where("master_usage_id IS NULL").
Where("device_id = ?", key.carrierID).
Count(&activeCount).Error
@@ -171,7 +221,7 @@ func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Con
continue
}
// 已有生效套餐,跳过
// 已有生效或已用完(占位)套餐,跳过
if activeCount > 0 {
continue
}
@@ -219,10 +269,15 @@ func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context)
}
// processExpiredPackage 处理单个过期套餐
// triggerStopAfterExpiry 在事务提交后才调用,避免 goroutine 在 TX 提交前读到脏数据
// 流程:先同步最新流量 → 事务内标记过期/失效/激活下一个 → 事务提交后触发停机
func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pkg *model.PackageUsage) error {
carrierType, carrierID := h.getCarrierInfo(pkg)
// 失效前先从 Gateway 同步一次最新流量,确保 data_usage_mb 精确
if carrierType != "" && carrierID > 0 {
h.syncTrafficBeforeExpiry(ctx, carrierType, carrierID)
}
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 任务 19.3: 更新过期主套餐状态为 Expired (status=3)
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {

View File

@@ -125,6 +125,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
if handlers.Refund != nil {
registerRefundRoutes(authGroup, handlers.Refund, doc, basePath)
}
if handlers.OrderPackageInvalidate != nil {
registerOrderPackageInvalidateRoutes(authGroup, handlers.OrderPackageInvalidate, doc, basePath)
}
if handlers.SuperAdmin != nil {
registerSuperAdminRoutes(authGroup, handlers.SuperAdmin, doc, basePath)
}

View File

@@ -52,4 +52,12 @@ func registerAgentRechargeRoutes(router fiber.Router, handler *admin.AgentRechar
Output: new(dto.AgentRechargeResponse),
Auth: true,
})
Register(group, doc, groupPath, "POST", "/:id/reject", handler.Reject, RouteSpec{
Summary: "驳回代理充值订单",
Tags: []string{"代理预充值"},
Input: new(dto.AgentRechargeRejectParams),
Output: nil,
Auth: true,
})
}

View File

@@ -0,0 +1,40 @@
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"
)
// registerOrderPackageInvalidateRoutes 注册订单套餐批量失效任务路由
func registerOrderPackageInvalidateRoutes(router fiber.Router, handler *admin.OrderPackageInvalidateHandler, doc *openapi.Generator, basePath string) {
group := router.Group("/order-package-invalidate-tasks")
groupPath := basePath + "/order-package-invalidate-tasks"
Register(group, doc, groupPath, "POST", "", handler.Create, RouteSpec{
Summary: "创建订单套餐批量失效任务",
Description: "上传单列 CSV 文件(列名 order_no批量将订单下的非终态套餐标记为已失效status=4。",
Tags: []string{"订单套餐失效"},
Input: new(dto.CreateOrderPackageInvalidateTaskRequest),
Output: new(dto.OrderPackageInvalidateTaskResponse),
Auth: true,
})
Register(group, doc, groupPath, "GET", "", handler.List, RouteSpec{
Summary: "查询订单套餐失效任务列表",
Tags: []string{"订单套餐失效"},
Input: new(dto.ListOrderPackageInvalidateTaskRequest),
Output: new(dto.OrderPackageInvalidateTaskListResponse),
Auth: true,
})
Register(group, doc, groupPath, "GET", "/:id", handler.GetByID, RouteSpec{
Summary: "查询订单套餐失效任务详情",
Tags: []string{"订单套餐失效"},
Input: new(dto.IDReq),
Output: new(dto.OrderPackageInvalidateTaskDetailResponse),
Auth: true,
})
}

View File

@@ -320,15 +320,18 @@ func (s *Service) CreateWalletPackageOrders(ctx context.Context, req *dto.AgentO
resp.FailedCards = append(resp.FailedCards, buildFailedCard(rawCardNo, errors.New(errors.CodeInvalidParam, "卡标识不能为空")))
continue
}
card, err := s.resolveOpenAPICard(ctx, cardNo)
if err != nil {
resp.FailedCards = append(resp.FailedCards, buildFailedCard(cardNo, err))
continue
}
// 卡已绑定设备时,自动转为设备维度购买
identifier := card.ICCID
if !card.IsStandalone {
var identifier string
card, cardErr := s.resolveOpenAPICard(ctx, cardNo)
if cardErr != nil {
// 兜底:尝试解析为设备标识(支持 IMEI/虚拟号)
device, devErr := s.resolveOpenAPIDevice(ctx, cardNo)
if devErr != nil {
resp.FailedCards = append(resp.FailedCards, buildFailedCard(cardNo, cardErr))
continue
}
identifier = device.VirtualNo
} else if !card.IsStandalone {
// 卡已绑定设备时,自动转为设备维度购买
binding, bindErr := s.deviceSimBindingStore.GetActiveBindingByCardID(ctx, card.ID)
if bindErr != nil {
resp.FailedCards = append(resp.FailedCards, buildFailedCard(cardNo, errors.Wrap(errors.CodeInternalError, bindErr, "查询卡绑定设备失败")))
@@ -340,6 +343,8 @@ func (s *Service) CreateWalletPackageOrders(ctx context.Context, req *dto.AgentO
continue
}
identifier = device.VirtualNo
} else {
identifier = card.ICCID
}
orderResp, err := s.orderService.CreateAdminOrder(ctx, &dto.CreateAdminOrderRequest{

View File

@@ -6,7 +6,6 @@ import (
"context"
"fmt"
"math/rand"
"strings"
"time"
"github.com/redis/go-redis/v9"
@@ -96,7 +95,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques
}
// 线下充值必须上传支付凭证
if req.PaymentMethod == "offline" && strings.TrimSpace(req.PaymentVoucherKey) == "" {
if req.PaymentMethod == "offline" && len(req.PaymentVoucherKey) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "线下充值必须上传支付凭证")
}
@@ -147,7 +146,8 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques
PaymentMethod: req.PaymentMethod,
PaymentChannel: &paymentChannel,
PaymentConfigID: paymentConfigID,
PaymentVoucherKey: strings.TrimSpace(req.PaymentVoucherKey),
PaymentVoucherKey: model.StringJSONBArray(req.PaymentVoucherKey),
Remark: req.Remark,
Status: constants.RechargeStatusPending,
ShopIDTag: wallet.ShopIDTag,
EnterpriseIDTag: wallet.EnterpriseIDTag,
@@ -387,6 +387,35 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
return nil
}
// Reject 驳回代理充值订单
// 仅 status=1待支付的订单可驳回驳回后状态变为 6已驳回为终态
func (s *Service) Reject(ctx context.Context, id uint, rejectionReason string) error {
record, err := s.agentRechargeStore.GetByID(ctx, id)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeNotFound, "充值记录不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败")
}
if record.Status != constants.RechargeStatusPending {
return errors.New(errors.CodeInvalidStatus, "仅待支付订单可驳回")
}
if err := s.agentRechargeStore.UpdateStatusWithRejection(ctx, id, rejectionReason); err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeInvalidStatus, "仅待支付订单可驳回")
}
return errors.Wrap(errors.CodeDatabaseError, err, "驳回充值订单失败")
}
s.logger.Info("代理充值订单驳回成功",
zap.Uint("record_id", id),
zap.String("rejection_reason", rejectionReason),
)
return nil
}
// GetByID 根据ID查询充值订单详情
// GET /api/admin/agent-recharges/:id
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AgentRechargeResponse, error) {
@@ -486,9 +515,11 @@ func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRe
AgentWalletID: record.AgentWalletID,
Amount: record.Amount,
PaymentMethod: record.PaymentMethod,
PaymentVoucherKey: record.PaymentVoucherKey,
PaymentVoucherKey: []string(record.PaymentVoucherKey),
Remark: record.Remark,
Status: record.Status,
StatusName: constants.GetRechargeStatusName(record.Status),
RejectionReason: record.RejectionReason,
CreatedAt: record.CreatedAt.Format("2006-01-02 15:04:05"),
UpdatedAt: record.UpdatedAt.Format("2006-01-02 15:04:05"),
}

View File

@@ -60,6 +60,7 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq
FileName: fileName,
StorageKey: req.FileKey,
RealnamePolicy: req.RealnamePolicy,
CreatorName: middleware.GetUsernameFromContext(ctx),
}
task.Creator = userID
task.Updater = userID
@@ -190,21 +191,23 @@ func (s *Service) toTaskResponse(task *model.DeviceImportTask) *dto.DeviceImport
}
return &dto.DeviceImportTaskResponse{
ID: task.ID,
TaskNo: task.TaskNo,
Status: task.Status,
StatusText: getStatusText(task.Status),
BatchNo: task.BatchNo,
FileName: task.FileName,
TotalCount: task.TotalCount,
SuccessCount: task.SuccessCount,
SkipCount: task.SkipCount,
FailCount: task.FailCount,
WarningCount: task.WarningCount,
StartedAt: startedAt,
CompletedAt: completedAt,
ErrorMessage: task.ErrorMessage,
CreatedAt: task.CreatedAt,
ID: task.ID,
TaskNo: task.TaskNo,
Status: task.Status,
StatusText: getStatusText(task.Status),
BatchNo: task.BatchNo,
RealnamePolicy: task.RealnamePolicy,
FileName: task.FileName,
TotalCount: task.TotalCount,
SuccessCount: task.SuccessCount,
SkipCount: task.SkipCount,
FailCount: task.FailCount,
WarningCount: task.WarningCount,
StartedAt: startedAt,
CompletedAt: completedAt,
ErrorMessage: task.ErrorMessage,
CreatorName: task.CreatorName,
CreatedAt: task.CreatedAt,
}
}

View File

@@ -29,6 +29,8 @@ type PollingCallback interface {
OnCardCreated(ctx context.Context, card *model.IotCard)
// OnCardStatusChanged 卡状态变化时的回调
OnCardStatusChanged(ctx context.Context, cardID uint)
// OnBatchCardsStatusChanged 批量卡状态变化时的回调(批量分配/回收场景)
OnBatchCardsStatusChanged(ctx context.Context, cardIDs []uint)
// OnCardDeleted 卡删除时的回调
OnCardDeleted(ctx context.Context, cardID uint)
// OnCardEnabled 卡启用轮询时的回调
@@ -597,6 +599,7 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone
newStatus := constants.IotCardStatusDistributed
toShopID := req.ToShopID
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
err = s.db.Transaction(func(tx *gorm.DB) error {
txIotCardStore := postgres.NewIotCardStore(tx, nil)
@@ -606,7 +609,6 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone
return err
}
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
records := s.buildAllocationRecords(cards, cardIDs, operatorShopID, toShopID, operatorID, allocationNo, req.Remark)
return txRecordStore.BatchCreate(ctx, records)
})
@@ -631,11 +633,14 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone
}
s.iotCardStore.InvalidateListCountCache(ctx)
// 通知轮询调度器状态变化(卡被分配后可能需要重新匹配配置
// 通知轮询调度器状态变化(异步执行 + 批量操作,避免 N 次单卡 DB 查询打满连接池
if s.pollingCallback != nil && len(cardIDs) > 0 {
for _, cardID := range cardIDs {
s.pollingCallback.OnCardStatusChanged(ctx, cardID)
}
cardIDsCopy := make([]uint, len(cardIDs))
copy(cardIDsCopy, cardIDs)
cb := s.pollingCallback
go func() {
cb.OnBatchCardsStatusChanged(context.Background(), cardIDsCopy)
}()
}
shopMap := s.loadShopNames(ctx, cards)
@@ -672,7 +677,7 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone
TotalCount: len(cards),
SuccessCount: len(cardIDs),
FailCount: len(failedItems),
AllocationNo: s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate),
AllocationNo: allocationNo,
FailedItems: failedItems,
}, nil
}
@@ -844,11 +849,14 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard
}
s.iotCardStore.InvalidateListCountCache(ctx)
// 通知轮询调度器状态变化(卡被回收后可能需要重新匹配配置
// 通知轮询调度器状态变化(异步批量执行,避免回收大批卡时打满 DB 连接池
if s.pollingCallback != nil && len(cardIDs) > 0 {
for _, cardID := range cardIDs {
s.pollingCallback.OnCardStatusChanged(ctx, cardID)
}
cardIDsCopy := make([]uint, len(cardIDs))
copy(cardIDsCopy, cardIDs)
cb := s.pollingCallback
go func() {
cb.OnBatchCardsStatusChanged(context.Background(), cardIDsCopy)
}()
}
shopMap := s.loadShopNames(ctx, successCards)
@@ -1486,6 +1494,19 @@ func (s *Service) buildCardNotFoundFailedItems(iccids []string) []dto.CardSeries
return items
}
// RefreshCardDataByID 通过卡 ID 从 Gateway 同步卡数据
// 内部查询 ICCID 后委托给 RefreshCardDataFromGateway供套餐失效前流量同步使用
func (s *Service) RefreshCardDataByID(ctx context.Context, cardID uint) error {
card, err := s.iotCardStore.GetByID(ctx, cardID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return nil
}
return err
}
return s.RefreshCardDataFromGateway(ctx, card.ICCID)
}
// RefreshCardDataFromGateway 从 Gateway 完整同步卡数据
// 调用网关查询网络状态、实名状态、本月流量,并写回数据库
// 流量采用增量计算与轮询逻辑一致increment = 当前网关读数 - 上次网关读数

View File

@@ -132,6 +132,14 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC
}
}
// isRiskGatewayExtend 判断网关扩展状态是否为风险状态(风险停机或已销户)
// 独立卡命中此状态时禁止发起复机
func isRiskGatewayExtend(extend string) bool {
trimmed := strings.TrimSpace(extend)
return trimmed == constants.GatewayCardExtendRiskStop ||
trimmed == constants.GatewayCardExtendCancelled
}
// isPollingStopReason 判断停机原因是否为轮询系统引起(可自动复机)
func isPollingStopReason(reason string) bool {
switch reason {
@@ -692,6 +700,30 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
}
gatewayExtend := strings.TrimSpace(card.GatewayExtend)
// 独立卡处于风险停机或已销户状态时,优先返回明确的拒绝原因
if card.IsStandalone && isRiskGatewayExtend(gatewayExtend) {
var denyMsg string
if gatewayExtend == constants.GatewayCardExtendRiskStop {
denyMsg = "该卡已被运营商风险停机,不允许复机"
} else {
denyMsg = "该卡已被运营商销户,不允许复机"
}
denyErr := errors.New(errors.CodeForbidden, denyMsg)
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStart,
OperationDesc: "机卡分离复机被拒绝(风险状态)",
ResultStatus: constants.AssetAuditResultDenied,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: cardSnapshot(card),
})
return denyErr
}
if gatewayExtend != constants.GatewayCardExtendMachineSeparated {
denyErr := errors.New(errors.CodeForbidden, constants.AgentOpenAPIResumeOnlyMachineSeparatedMessage)
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
@@ -880,6 +912,29 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
return denyErr
}
// 独立卡处于风险停机或已销户状态时,拒绝复机
if card.IsStandalone && isRiskGatewayExtend(card.GatewayExtend) {
var denyMsg string
if strings.TrimSpace(card.GatewayExtend) == constants.GatewayCardExtendRiskStop {
denyMsg = "该卡已被运营商风险停机,不允许复机"
} else {
denyMsg = "该卡已被运营商销户,不允许复机"
}
denyErr := errors.New(errors.CodeForbidden, denyMsg)
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
OperationType: constants.AssetAuditOpCardManualStart,
OperationDesc: "手动复机被拒绝",
ResultStatus: constants.AssetAuditResultDenied,
ErrorCode: errorCode,
ErrorMsg: errorMsg,
AssetID: card.ID,
AssetIdentifier: card.ICCID,
BeforeData: cardSnapshot(card),
})
return denyErr
}
if card.RealNameStatus != constants.RealNameStatusVerified {
denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作")
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)

View File

@@ -0,0 +1,26 @@
package iot_card
import "testing"
// TestIsRiskGatewayExtend 验证网关扩展状态的风险判断逻辑
func TestIsRiskGatewayExtend(t *testing.T) {
cases := []struct {
extend string
want bool
}{
{"风险停机", true},
{"已销户", true},
{"机卡分离停机", false},
{"待激活", false},
{"", false},
{" 风险停机 ", true}, // 含空白字符
{"已注销", false}, // 非目标状态
}
for _, tc := range cases {
got := isRiskGatewayExtend(tc.extend)
if got != tc.want {
t.Errorf("isRiskGatewayExtend(%q) = %v, 期望 %v", tc.extend, got, tc.want)
}
}
}

View File

@@ -98,6 +98,7 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportIotCardRe
StorageKey: req.FileKey,
CardCategory: cardCategory,
RealnamePolicy: req.RealnamePolicy,
CreatorName: middleware.GetUsernameFromContext(ctx),
}
task.Creator = userID
task.Updater = userID
@@ -224,24 +225,26 @@ func (s *Service) toTaskResponse(task *model.IotCardImportTask) *dto.ImportTaskR
}
return &dto.ImportTaskResponse{
ID: task.ID,
TaskNo: task.TaskNo,
Status: task.Status,
StatusText: getStatusText(task.Status),
CarrierID: task.CarrierID,
CarrierType: task.CarrierType,
CarrierName: task.CarrierName,
BatchNo: task.BatchNo,
CardCategory: task.CardCategory,
FileName: task.FileName,
TotalCount: task.TotalCount,
SuccessCount: task.SuccessCount,
SkipCount: task.SkipCount,
FailCount: task.FailCount,
StartedAt: startedAt,
CompletedAt: completedAt,
ErrorMessage: task.ErrorMessage,
CreatedAt: task.CreatedAt,
ID: task.ID,
TaskNo: task.TaskNo,
Status: task.Status,
StatusText: getStatusText(task.Status),
CarrierID: task.CarrierID,
CarrierType: task.CarrierType,
CarrierName: task.CarrierName,
BatchNo: task.BatchNo,
CardCategory: task.CardCategory,
RealnamePolicy: task.RealnamePolicy,
FileName: task.FileName,
TotalCount: task.TotalCount,
SuccessCount: task.SuccessCount,
SkipCount: task.SkipCount,
FailCount: task.FailCount,
StartedAt: startedAt,
CompletedAt: completedAt,
ErrorMessage: task.ErrorMessage,
CreatorName: task.CreatorName,
CreatedAt: task.CreatedAt,
}
}

View File

@@ -182,7 +182,7 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
return nil, err
}
}
if req.PaymentMethod == model.PaymentMethodOffline && strings.TrimSpace(req.PaymentVoucherKey) == "" {
if req.PaymentMethod == model.PaymentMethodOffline && len(req.PaymentVoucherKey) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "线下支付必须上传支付凭证")
}
@@ -453,9 +453,9 @@ func (s *Service) CreateAdminOrder(ctx context.Context, req *dto.CreateAdminOrde
PaymentConfigID: paymentConfigID,
}
// 线下支付订单写入支付凭证 file_key
// 线下支付订单写入支付凭证 file_key 列表
if req.PaymentMethod == model.PaymentMethodOffline {
order.PaymentVoucherKey = strings.TrimSpace(req.PaymentVoucherKey)
order.PaymentVoucherKey = model.StringJSONBArray(req.PaymentVoucherKey)
}
if orderBuyerType == model.BuyerTypePersonal {
@@ -1120,26 +1120,31 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model
}
actualAmount := *order.ActualPaidAmount
// 1. 事务外:检查钱包余额(快速失败)
wallet, err := s.agentWalletStore.GetMainWallet(ctx, operatorShopID)
if err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
// 事务内:加锁读取钱包 + 余额检查 + 创建订单 + 扣款 + 创建流水 + 激活套餐
// 使用 FOR UPDATE 悲观锁替代乐观锁,避免并发请求因 version 过期导致误报余额不足
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 1. 加锁读取钱包,并发请求排队等待,不会冲突
var wallet model.AgentWallet
if err := tx.Set("gorm:query_option", "FOR UPDATE").
Where("shop_id = ? AND wallet_type = ?", operatorShopID, constants.AgentWalletTypeMain).
First(&wallet).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return errors.New(errors.CodeWalletNotFound, "钱包不存在")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
}
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
}
if wallet.Balance < actualAmount {
return errors.New(errors.CodeInsufficientBalance, "余额不足")
}
// 2. 事务内:创建订单 + 扣款 + 创建流水 + 激活套餐
err = s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
// 2.1 创建订单
// 2. 余额检查(加锁后读到的是最新余额,结果准确)
if wallet.Balance < actualAmount {
return errors.New(errors.CodeInsufficientBalance, "余额不足")
}
// 3. 创建订单
if err := tx.Create(order).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单失败")
}
// 2.2 创建订单明细
// 4. 创建订单明细
for _, item := range items {
item.OrderID = order.ID
}
@@ -1147,30 +1152,24 @@ func (s *Service) createOrderWithWalletPayment(ctx context.Context, order *model
return errors.Wrap(errors.CodeDatabaseError, err, "创建订单明细失败")
}
// 2.3 扣减钱包余额(乐观锁
result := tx.Model(&model.AgentWallet{}).
Where("id = ? AND balance >= ? AND version = ?", wallet.ID, actualAmount, wallet.Version).
Updates(map[string]any{
"balance": gorm.Expr("balance - ?", actualAmount),
"version": gorm.Expr("version + 1"),
})
if result.Error != nil {
return errors.Wrap(errors.CodeDatabaseError, result.Error, "扣减钱包余额失败")
}
if result.RowsAffected == 0 {
return errors.New(errors.CodeInsufficientBalance, "余额不足或并发冲突")
// 5. 扣减钱包余额(FOR UPDATE 已保证串行,无需 version 条件
if err := tx.Model(&wallet).Updates(map[string]any{
"balance": gorm.Expr("balance - ?", actualAmount),
"version": gorm.Expr("version + 1"),
}).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "扣减钱包余额失败")
}
// 2.4 创建钱包流水
// 6. 创建钱包流水
var relatedShopID *uint
if order.PurchaseRole == model.PurchaseRolePurchaseForSubordinate {
relatedShopID = &buyerShopID
}
if err := s.createWalletTransaction(ctx, tx, wallet, order, actualAmount, order.PurchaseRole, relatedShopID); err != nil {
if err := s.createWalletTransaction(ctx, tx, &wallet, order, actualAmount, order.PurchaseRole, relatedShopID); err != nil {
return err
}
// 2.5 激活套餐
// 7. 激活套餐
if err := s.activatePackage(ctx, tx, order); err != nil {
return err
}
@@ -2533,7 +2532,7 @@ func (s *Service) buildOrderResponse(ctx context.Context, order *model.Order, it
PaymentStatus: order.PaymentStatus,
PaymentStatusText: statusText,
PaidAt: order.PaidAt,
PaymentVoucherKey: order.PaymentVoucherKey,
PaymentVoucherKey: []string(order.PaymentVoucherKey),
IsPurchaseOnBehalf: order.IsPurchaseOnBehalf,
CommissionStatus: order.CommissionStatus,
CommissionStatusName: constants.GetOrderCommissionStatusName(order.CommissionStatus),

View File

@@ -0,0 +1,185 @@
package order_package_invalidate
import (
"context"
"path/filepath"
"time"
"github.com/hibiken/asynq"
"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"
"github.com/break/junhong_cmp_fiber/pkg/middleware"
"github.com/break/junhong_cmp_fiber/pkg/queue"
)
// Service 订单套餐失效任务业务逻辑层
type Service struct {
taskStore *postgres.OrderPackageInvalidateTaskStore
queueClient *queue.Client
}
// New 创建 Service 实例
func New(
taskStore *postgres.OrderPackageInvalidateTaskStore,
queueClient *queue.Client,
) *Service {
return &Service{
taskStore: taskStore,
queueClient: queueClient,
}
}
// InvalidateTaskPayload Worker 任务载荷
type InvalidateTaskPayload struct {
TaskID uint `json:"task_id"`
}
// Create 创建订单套餐失效任务
func (s *Service) Create(ctx context.Context, req *dto.CreateOrderPackageInvalidateTaskRequest) (*dto.OrderPackageInvalidateTaskResponse, error) {
userID := middleware.GetUserIDFromContext(ctx)
if userID == 0 {
return nil, errors.New(errors.CodeUnauthorized, "未授权访问")
}
task := &model.OrderPackageInvalidateTask{
TaskNo: s.taskStore.GenerateTaskNo(),
Status: model.ImportTaskStatusPending,
FileName: filepath.Base(req.FileKey),
StorageKey: req.FileKey,
VoucherKeys: model.StringJSONBArray(req.VoucherKeys),
Remark: req.Remark,
CreatorName: middleware.GetUsernameFromContext(ctx),
FailedItems: model.ImportResultItems{},
Creator: userID,
Updater: userID,
}
if err := s.taskStore.Create(ctx, task); err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "创建任务失败")
}
payload := InvalidateTaskPayload{TaskID: task.ID}
err := s.queueClient.EnqueueTask(
ctx,
constants.TaskTypeOrderPackageInvalidate,
payload,
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeOrderPackageInvalidate)),
)
if err != nil {
s.taskStore.UpdateStatus(ctx, task.ID, model.ImportTaskStatusFailed, "任务入队失败: "+err.Error())
}
return s.toResponse(task), nil
}
// List 分页查询任务列表
func (s *Service) List(ctx context.Context, req *dto.ListOrderPackageInvalidateTaskRequest) (*dto.OrderPackageInvalidateTaskListResponse, error) {
if req.Page <= 0 {
req.Page = 1
}
if req.PageSize <= 0 {
req.PageSize = constants.DefaultPageSize
}
opts := &store.QueryOptions{
Page: req.Page,
PageSize: req.PageSize,
}
filters := map[string]interface{}{}
if req.Status != nil {
filters["status"] = *req.Status
}
tasks, total, err := s.taskStore.List(ctx, opts, filters)
if err != nil {
return nil, errors.Wrap(errors.CodeInternalError, err, "查询任务列表失败")
}
list := make([]*dto.OrderPackageInvalidateTaskResponse, 0, len(tasks))
for _, t := range tasks {
list = append(list, s.toResponse(t))
}
return &dto.OrderPackageInvalidateTaskListResponse{
Total: total,
Page: req.Page,
PageSize: req.PageSize,
List: list,
}, nil
}
// GetByID 查询任务详情(含失败明细)
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.OrderPackageInvalidateTaskDetailResponse, error) {
task, err := s.taskStore.GetByID(ctx, id)
if err != nil {
return nil, errors.New(errors.CodeNotFound, "任务不存在")
}
failedItems := make([]dto.InvalidateFailedItem, 0, len(task.FailedItems))
for _, item := range task.FailedItems {
failedItems = append(failedItems, dto.InvalidateFailedItem{
Line: item.Line,
OrderNo: item.ICCID, // 复用 ImportResultItem.ICCID 存储 order_no
Reason: item.Reason,
})
}
return &dto.OrderPackageInvalidateTaskDetailResponse{
OrderPackageInvalidateTaskResponse: *s.toResponse(task),
FailedItems: failedItems,
}, nil
}
// toResponse 转换为响应 DTO
func (s *Service) toResponse(task *model.OrderPackageInvalidateTask) *dto.OrderPackageInvalidateTaskResponse {
resp := &dto.OrderPackageInvalidateTaskResponse{
ID: task.ID,
TaskNo: task.TaskNo,
Status: task.Status,
StatusName: invalidateTaskStatusName(task.Status),
TotalCount: task.TotalCount,
SuccessCount: task.SuccessCount,
FailCount: task.FailCount,
FileName: task.FileName,
VoucherKeys: []string(task.VoucherKeys),
Remark: task.Remark,
CreatorName: task.CreatorName,
ErrorMessage: task.ErrorMessage,
CreatedAt: task.CreatedAt.Format(time.RFC3339),
UpdatedAt: task.UpdatedAt.Format(time.RFC3339),
}
if task.StartedAt != nil {
t := task.StartedAt.Format(time.RFC3339)
resp.StartedAt = &t
}
if task.CompletedAt != nil {
t := task.CompletedAt.Format(time.RFC3339)
resp.CompletedAt = &t
}
if resp.VoucherKeys == nil {
resp.VoucherKeys = []string{}
}
return resp
}
// invalidateTaskStatusName 状态码转中文
func invalidateTaskStatusName(status int) string {
switch status {
case model.ImportTaskStatusPending:
return "待处理"
case model.ImportTaskStatusProcessing:
return "处理中"
case model.ImportTaskStatusCompleted:
return "已完成"
case model.ImportTaskStatusFailed:
return "失败"
default:
return "未知"
}
}

View File

@@ -92,19 +92,20 @@ func (s *ActivationService) ActivateByRealname(ctx context.Context, carrierType
// 检查是否是主套餐
if usage.MasterUsageID == nil {
// 主套餐:需要检查是否有已激活的主套餐
// 主套餐:需要检查是否有已激活或已用完(仍在有效期)的主套餐
var activeMain model.PackageUsage
err := tx.Where("status = ?", constants.PackageUsageStatusActive).
err := tx.Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Where("master_usage_id IS NULL").
Where(carrierType+"_id = ?", carrierID).
Order("priority ASC").
First(&activeMain).Error
if err == nil {
// 已有激活的主套餐,保持排队状态
s.logger.Warn("已有激活主套餐,跳过激活",
// 已有生效中或已用完(占位)的主套餐,保持排队状态
s.logger.Warn("已有占位主套餐,跳过激活",
zap.Uint("usage_id", usage.ID),
zap.Uint("active_main_id", activeMain.ID))
zap.Uint("active_main_id", activeMain.ID),
zap.Int("active_main_status", activeMain.Status))
continue
}
if err != gorm.ErrRecordNotFound {
@@ -500,9 +501,10 @@ func (s *ActivationService) getNextPendingMainPackage(ctx context.Context, tx *g
func (s *ActivationService) hasActiveMainPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint) (bool, error) {
var count int64
// 生效中1和已用完2都属于"占位"状态:已用完的套餐仍在有效期内,不允许提前激活下一个主套餐
if err := tx.WithContext(ctx).
Model(&model.PackageUsage{}).
Where("status = ?", constants.PackageUsageStatusActive).
Where("status IN ?", []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}).
Where("master_usage_id IS NULL").
Where(carrierType+"_id = ?", carrierID).
Count(&count).Error; err != nil {

View File

@@ -88,13 +88,22 @@ func (s *UsageService) DeductDataUsage(ctx context.Context, carrierType string,
return err
}
today := time.Now().Format("2006-01-02")
if len(packages) == 0 {
// 无生效套餐:尝试找 Depleted 套餐记录溢出流量详单,避免详单断档
if recordErr := s.recordOverflowToDepletedPackage(ctx, tx, targetCarrierType, targetCarrierID, deductUsageMB, today); recordErr != nil {
s.logger.Warn("记录溢出流量到已耗尽套餐失败",
zap.String("carrier_type", targetCarrierType),
zap.Uint("carrier_id", targetCarrierID),
zap.Int64("usage_mb", deductUsageMB),
zap.Error(recordErr))
}
return errors.New(errors.CodeNoAvailablePackage, "没有可用套餐")
}
// 按优先级扣减流量
remainingUsage := deductUsageMB
today := time.Now().Format("2006-01-02")
for index, pkg := range packages {
if remainingUsage <= 0 {
@@ -446,3 +455,45 @@ func (s *UsageService) triggerSuspensionAfterCommit(carrierType string, carrierI
}(cardID)
}
}
// recordOverflowToDepletedPackage 在所有套餐已耗尽时,将溢出流量写入最近的 Depleted 套餐日记录。
// 目的:保持流量详单连续性,即便套餐用完期间上游仍有流量使用,详单不会断档。
// 只写日记录和累加 data_usage_mb不改变套餐 status不触发停机。
func (s *UsageService) recordOverflowToDepletedPackage(ctx context.Context, tx *gorm.DB, carrierType string, carrierID uint, overflowMB int64, today string) error {
query := tx.Where("status = ?", constants.PackageUsageStatusDepleted)
switch carrierType {
case constants.AssetTypeIotCard:
query = query.Where("iot_card_id = ?", carrierID)
case constants.AssetTypeDevice:
query = query.Where("device_id = ?", carrierID)
default:
return nil
}
// 优先选主套餐master_usage_id IS NULL主套餐不存在时再退而求其次取加油包
var pkg model.PackageUsage
err := query.Where("master_usage_id IS NULL").Order("id DESC").First(&pkg).Error
if err == gorm.ErrRecordNotFound {
err = query.Order("id DESC").First(&pkg).Error
}
if err != nil {
// 找不到任何 Depleted 套餐,静默忽略
return nil
}
newUsage := pkg.DataUsageMB + overflowMB
if err := tx.Model(&pkg).Update("data_usage_mb", newUsage).Error; err != nil {
return errors.Wrap(errors.CodeDatabaseError, err, "更新溢出流量失败")
}
if err := s.updateDailyRecord(ctx, tx, pkg.ID, today, overflowMB, newUsage); err != nil {
return err
}
s.logger.Info("记录溢出流量到已耗尽套餐",
zap.Uint("package_usage_id", pkg.ID),
zap.Int64("overflow_mb", overflowMB),
zap.Int64("new_usage_mb", newUsage))
return nil
}

View File

@@ -7,7 +7,6 @@ import (
"context"
"fmt"
"math/rand"
"strings"
"time"
"go.uber.org/zap"
@@ -619,11 +618,11 @@ func (s *Service) Resubmit(ctx context.Context, id uint, req *dto.ResubmitRefund
}
refundVoucherKey := refund.RefundVoucherKey
if req.RefundVoucherKey != nil {
refundVoucherKey = *req.RefundVoucherKey
}
refundVoucherKey, err = normalizeRefundVoucherKey(refundVoucherKey)
if err != nil {
return err
normalized, normErr := normalizeRefundVoucherKey(*req.RefundVoucherKey)
if normErr != nil {
return normErr
}
refundVoucherKey = normalized
}
order, err := s.orderStore.GetByID(ctx, refund.OrderID)
@@ -914,15 +913,14 @@ func validateApprovedRefundAmount(approvedAmount int64, requestedRefundAmount in
return validateRequestedRefundAmountByOrder(approvedAmount, order)
}
func normalizeRefundVoucherKey(voucherKey string) (string, error) {
normalized := strings.TrimSpace(voucherKey)
if normalized == "" {
return "", errors.New(errors.CodeInvalidParam, "退款申请必须上传退款凭证")
func normalizeRefundVoucherKey(keys []string) (model.StringJSONBArray, error) {
if len(keys) == 0 {
return nil, errors.New(errors.CodeInvalidParam, "退款申请必须上传退款凭证")
}
if len(normalized) > 500 {
return "", errors.New(errors.CodeInvalidParam, "退款凭证长度不能超过500")
if len(keys) > 5 {
return nil, errors.New(errors.CodeInvalidParam, "退款凭证最多上传5个")
}
return normalized, nil
return model.StringJSONBArray(keys), nil
}
// buildRefundResponse 将退款 Model 转换为 DTO 响应
@@ -949,7 +947,7 @@ func buildRefundResponse(r *model.RefundRequest) *dto.RefundResponse {
ActualReceivedAmount: r.ActualReceivedAmount,
RequestedRefundAmount: r.RequestedRefundAmount,
ApprovedRefundAmount: r.ApprovedRefundAmount,
RefundVoucherKey: r.RefundVoucherKey,
RefundVoucherKey: []string(r.RefundVoucherKey),
RefundReason: r.RefundReason,
Status: r.Status,
StatusName: constants.GetRefundStatusName(r.Status),

View File

@@ -4,6 +4,7 @@ import (
"context"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/redis/go-redis/v9"
"gorm.io/gorm"
)
@@ -74,6 +75,25 @@ func (s *AgentRechargeStore) Update(ctx context.Context, record *model.AgentRech
return s.db.WithContext(ctx).Save(record).Error
}
// UpdateStatusWithRejection 条件更新状态为已驳回并写入驳回原因
// 仅当 status = 1待支付时更新RowsAffected=0 说明订单不存在或状态已变更
func (s *AgentRechargeStore) UpdateStatusWithRejection(ctx context.Context, id uint, rejectionReason string) error {
result := s.db.WithContext(ctx).
Model(&model.AgentRechargeRecord{}).
Where("id = ? AND status = ?", id, constants.RechargeStatusPending).
Updates(map[string]interface{}{
"status": constants.RechargeStatusRejected,
"rejection_reason": rejectionReason,
})
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return nil
}
// UpdateWithTx 更新充值记录(带事务)
func (s *AgentRechargeStore) UpdateWithTx(ctx context.Context, tx *gorm.DB, record *model.AgentRechargeRecord) error {
return tx.WithContext(ctx).Save(record).Error

View File

@@ -0,0 +1,106 @@
package postgres
import (
"context"
"fmt"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/store"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// OrderPackageInvalidateTaskStore 订单套餐失效任务数据访问层
type OrderPackageInvalidateTaskStore struct {
db *gorm.DB
}
// NewOrderPackageInvalidateTaskStore 创建 OrderPackageInvalidateTaskStore 实例
func NewOrderPackageInvalidateTaskStore(db *gorm.DB) *OrderPackageInvalidateTaskStore {
return &OrderPackageInvalidateTaskStore{db: db}
}
// Create 创建任务
func (s *OrderPackageInvalidateTaskStore) Create(ctx context.Context, task *model.OrderPackageInvalidateTask) error {
return s.db.WithContext(ctx).Create(task).Error
}
// GetByID 按主键查询任务Worker 直接查询,不过滤租户)
func (s *OrderPackageInvalidateTaskStore) GetByID(ctx context.Context, id uint) (*model.OrderPackageInvalidateTask, error) {
var task model.OrderPackageInvalidateTask
if err := s.db.WithContext(ctx).Where("id = ?", id).First(&task).Error; err != nil {
return nil, err
}
return &task, nil
}
// List 分页查询任务列表
func (s *OrderPackageInvalidateTaskStore) List(ctx context.Context, opts *store.QueryOptions, filters map[string]interface{}) ([]*model.OrderPackageInvalidateTask, int64, error) {
var tasks []*model.OrderPackageInvalidateTask
var total int64
query := s.db.WithContext(ctx).Model(&model.OrderPackageInvalidateTask{})
if status, ok := filters["status"].(int); ok && status > 0 {
query = query.Where("status = ?", status)
}
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
if opts == nil {
opts = &store.QueryOptions{
Page: 1,
PageSize: constants.DefaultPageSize,
}
}
offset := (opts.Page - 1) * opts.PageSize
query = query.Offset(offset).Limit(opts.PageSize).Order("created_at DESC")
if err := query.Find(&tasks).Error; err != nil {
return nil, 0, err
}
return tasks, total, nil
}
// UpdateStatus 更新任务状态
func (s *OrderPackageInvalidateTaskStore) UpdateStatus(ctx context.Context, id uint, status int, errorMessage string) error {
updates := map[string]interface{}{
"status": status,
"updated_at": time.Now(),
}
if status == model.ImportTaskStatusProcessing {
updates["started_at"] = time.Now()
}
if status == model.ImportTaskStatusCompleted || status == model.ImportTaskStatusFailed {
updates["completed_at"] = time.Now()
}
if errorMessage != "" {
updates["error_message"] = errorMessage
}
return s.db.WithContext(ctx).Model(&model.OrderPackageInvalidateTask{}).Where("id = ?", id).Updates(updates).Error
}
// UpdateResult 更新任务计数和失败明细
func (s *OrderPackageInvalidateTaskStore) UpdateResult(ctx context.Context, id uint, totalCount, successCount, failCount int, failedItems model.ImportResultItems) error {
updates := map[string]interface{}{
"total_count": totalCount,
"success_count": successCount,
"fail_count": failCount,
"failed_items": failedItems,
"updated_at": time.Now(),
}
return s.db.WithContext(ctx).Model(&model.OrderPackageInvalidateTask{}).Where("id = ?", id).Updates(updates).Error
}
// GenerateTaskNo 生成任务编号OPINV-YYYYMMDD-XXXXXX
func (s *OrderPackageInvalidateTaskStore) GenerateTaskNo() string {
now := time.Now()
dateStr := now.Format("20060102")
seq := now.UnixNano() % 1000000
return fmt.Sprintf("OPINV-%s-%06d", dateStr, seq)
}

View File

@@ -131,6 +131,15 @@ func (s *PackageUsageStore) GetAddonsByMasterID(ctx context.Context, masterUsage
return usages, nil
}
// ListActiveByOrderID 查询订单下所有非终态套餐记录status NOT IN 已过期=3, 已失效=4
func (s *PackageUsageStore) ListActiveByOrderID(ctx context.Context, orderID uint) ([]*model.PackageUsage, error) {
var usages []*model.PackageUsage
err := s.db.WithContext(ctx).
Where("order_id = ? AND status NOT IN ?", orderID, []int{3, 4}).
Find(&usages).Error
return usages, err
}
// BatchUpdateStatus 批量更新加油包状态
func (s *PackageUsageStore) BatchUpdateStatus(ctx context.Context, ids []uint, status int) error {
if len(ids) == 0 {

View File

@@ -0,0 +1,257 @@
package task
import (
"context"
"encoding/csv"
"io"
"os"
"strings"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"go.uber.org/zap"
"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/storage"
)
// OrderPackageInvalidatePayload 批量失效订单套餐任务载荷
type OrderPackageInvalidatePayload struct {
TaskID uint `json:"task_id"`
}
// OrderPackageInvalidateHandler 批量失效订单套餐任务处理器
type OrderPackageInvalidateHandler struct {
taskStore *postgres.OrderPackageInvalidateTaskStore
orderStore *postgres.OrderStore
packageUsageStore *postgres.PackageUsageStore
storageService *storage.Service
logger *zap.Logger
}
// NewOrderPackageInvalidateHandler 创建处理器实例
func NewOrderPackageInvalidateHandler(
taskStore *postgres.OrderPackageInvalidateTaskStore,
orderStore *postgres.OrderStore,
packageUsageStore *postgres.PackageUsageStore,
storageSvc *storage.Service,
logger *zap.Logger,
) *OrderPackageInvalidateHandler {
return &OrderPackageInvalidateHandler{
taskStore: taskStore,
orderStore: orderStore,
packageUsageStore: packageUsageStore,
storageService: storageSvc,
logger: logger,
}
}
// Handle 处理批量失效订单套餐任务
// POST /api/admin/order-package-invalidate-tasks
func (h *OrderPackageInvalidateHandler) Handle(ctx context.Context, t *asynq.Task) error {
var payload OrderPackageInvalidatePayload
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
h.logger.Error("解析批量失效任务载荷失败",
zap.Error(err),
zap.String("task_id", t.ResultWriter().TaskID()),
)
return asynq.SkipRetry
}
importTask, err := h.taskStore.GetByID(ctx, payload.TaskID)
if err != nil {
h.logger.Error("获取批量失效任务失败",
zap.Uint("task_id", payload.TaskID),
zap.Error(err),
)
return asynq.SkipRetry
}
if importTask.Status != model.ImportTaskStatusPending {
h.logger.Info("批量失效任务已处理,跳过",
zap.Uint("task_id", payload.TaskID),
zap.Int("status", importTask.Status),
)
return nil
}
h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusProcessing, "")
h.logger.Info("开始处理批量失效订单套餐任务",
zap.Uint("task_id", importTask.ID),
zap.String("task_no", importTask.TaskNo),
)
orderNos, err := h.downloadAndParseCSV(ctx, importTask)
if err != nil {
h.logger.Error("下载或解析 CSV 失败",
zap.Uint("task_id", importTask.ID),
zap.Error(err),
)
h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, err.Error())
return asynq.SkipRetry
}
successCount, failedItems := h.processRows(ctx, orderNos)
failCount := len(failedItems)
totalCount := len(orderNos)
h.taskStore.UpdateResult(ctx, importTask.ID, totalCount, successCount, failCount,
model.ImportResultItems(toImportResultItems(failedItems)))
if failCount > 0 && successCount == 0 {
h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusFailed, "所有行均处理失败")
} else {
h.taskStore.UpdateStatus(ctx, importTask.ID, model.ImportTaskStatusCompleted, "")
}
h.logger.Info("批量失效订单套餐任务完成",
zap.Uint("task_id", importTask.ID),
zap.Int("total", totalCount),
zap.Int("success", successCount),
zap.Int("fail", failCount),
)
return nil
}
// invalidateRow 单行处理结果
type invalidateRow struct {
line int
orderNo string
reason string
}
// processRows 逐行处理订单号,返回成功数和失败列表
func (h *OrderPackageInvalidateHandler) processRows(ctx context.Context, rows []string) (int, []invalidateRow) {
successCount := 0
var failed []invalidateRow
for i, orderNo := range rows {
line := i + 2 // 第1行为表头数据从第2行开始
if err := h.processOneOrder(ctx, orderNo); err != nil {
failed = append(failed, invalidateRow{line: line, orderNo: orderNo, reason: err.Error()})
} else {
successCount++
}
}
return successCount, failed
}
// processOneOrder 处理单个订单号:查订单 → 查套餐 → 批量更新状态=4
func (h *OrderPackageInvalidateHandler) processOneOrder(ctx context.Context, orderNo string) error {
order, err := h.orderStore.GetByOrderNo(ctx, orderNo)
if err != nil {
return errOrderNotFound(orderNo)
}
usages, err := h.packageUsageStore.ListActiveByOrderID(ctx, order.ID)
if err != nil {
return errQueryFailed(orderNo)
}
if len(usages) == 0 {
// 套餐全部已是终态,视为成功
return nil
}
ids := make([]uint, 0, len(usages))
for _, u := range usages {
ids = append(ids, u.ID)
}
if err := h.packageUsageStore.BatchUpdateStatus(ctx, ids, constants.PackageUsageStatusInvalidated); err != nil {
return errUpdateFailed(orderNo)
}
return nil
}
// downloadAndParseCSV 从对象存储下载 CSV 并解析 order_no 列
func (h *OrderPackageInvalidateHandler) downloadAndParseCSV(ctx context.Context, task *model.OrderPackageInvalidateTask) ([]string, error) {
if h.storageService == nil {
return nil, ErrStorageNotConfigured
}
if task.StorageKey == "" {
return nil, ErrStorageKeyEmpty
}
localPath, cleanup, err := h.storageService.DownloadToTemp(ctx, task.StorageKey)
if err != nil {
return nil, err
}
defer cleanup()
return parseOrderNoCSV(localPath)
}
// parseOrderNoCSV 解析单列 CSV第一列为 order_no首行为表头
func parseOrderNoCSV(filePath string) ([]string, error) {
f, err := os.Open(filePath)
if err != nil {
return nil, err
}
defer f.Close()
reader := csv.NewReader(f)
reader.TrimLeadingSpace = true
var orderNos []string
firstRow := true
for {
record, err := reader.Read()
if err == io.EOF {
break
}
if err != nil {
return nil, err
}
if firstRow {
firstRow = false
continue // 跳过表头
}
if len(record) == 0 {
continue
}
orderNo := strings.TrimSpace(record[0])
if orderNo == "" {
continue
}
orderNos = append(orderNos, orderNo)
}
return orderNos, nil
}
// toImportResultItems 将内部失败记录转换为通用失败明细格式
// 复用 ImportResultItemICCID 字段存储 order_no
func toImportResultItems(rows []invalidateRow) []model.ImportResultItem {
items := make([]model.ImportResultItem, 0, len(rows))
for _, r := range rows {
items = append(items, model.ImportResultItem{
Line: r.line,
ICCID: r.orderNo,
Reason: r.reason,
})
}
return items
}
func errOrderNotFound(orderNo string) error {
return orderInvalidateError("订单不存在: " + orderNo)
}
func errQueryFailed(orderNo string) error {
return orderInvalidateError("查询套餐失败: " + orderNo)
}
func errUpdateFailed(orderNo string) error {
return orderInvalidateError("更新套餐状态失败: " + orderNo)
}
type orderInvalidateError string
func (e orderInvalidateError) Error() string { return string(e) }

View File

@@ -9,11 +9,23 @@ import (
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// shouldStopPollingForRisk 判断轮询到风险 gateway_extend 时是否应停止该卡的轮询
// 仅独立卡is_standalone=true命中风险状态时才停止绑定设备的卡不受此逻辑约束
func shouldStopPollingForRisk(card *model.IotCard, newGatewayExtend string) bool {
if !card.IsStandalone {
return false
}
extend := strings.TrimSpace(newGatewayExtend)
return extend == constants.GatewayCardExtendRiskStop ||
extend == constants.GatewayCardExtendCancelled
}
// PollingCardStatusHandler 卡开停机状态轮询任务处理器
// 职责:调 Gateway 查卡状态(正常/停机/准备)→ 映射为 network_status → 写 DB → 触发停复机评估
// 不做:直接停复机决策,委托给 StopResumeService.EvaluateAndAct
@@ -141,6 +153,19 @@ func (h *PollingCardStatusHandler) Handle(ctx context.Context, t *asynq.Task) er
h.base.updateCardCache(ctx, cardID, cacheUpdates)
}
// 独立卡写入风险 gateway_extend 后:关闭轮询,终止循环
if statusQueried && shouldStopPollingForRisk(card, gatewayExtend) {
if updateErr := h.iotCardStore.UpdatePollingStatus(ctx, cardID, false); updateErr != nil {
h.base.logger.Warn("关闭风险卡轮询状态失败",
zap.Uint("card_id", cardID), zap.String("gateway_extend", gatewayExtend), zap.Error(updateErr))
} else {
h.base.logger.Info("独立卡命中风险状态,已关闭轮询",
zap.Uint("card_id", cardID), zap.String("gateway_extend", gatewayExtend))
}
h.base.updateStats(ctx, constants.TaskTypePollingCardStatus, true, time.Since(startTime))
return nil // 不再入队,终止轮询循环
}
if statusChanged {
h.base.logger.Info("卡状态已变化",
zap.Uint("card_id", cardID),

View File

@@ -0,0 +1,63 @@
package task
import (
"testing"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// TestShouldStopPolling 验证风险状态独立卡的轮询停止判断逻辑
func TestShouldStopPolling(t *testing.T) {
cases := []struct {
name string
isStandalone bool
gatewayExtend string
want bool
}{
{
name: "独立卡+风险停机 -> 停止轮询",
isStandalone: true,
gatewayExtend: constants.GatewayCardExtendRiskStop,
want: true,
},
{
name: "独立卡+已销户 -> 停止轮询",
isStandalone: true,
gatewayExtend: constants.GatewayCardExtendCancelled,
want: true,
},
{
name: "非独立卡+风险停机 -> 不停止轮询",
isStandalone: false,
gatewayExtend: constants.GatewayCardExtendRiskStop,
want: false,
},
{
name: "独立卡+机卡分离停机 -> 不停止轮询",
isStandalone: true,
gatewayExtend: constants.GatewayCardExtendMachineSeparated,
want: false,
},
{
name: "独立卡+空扩展 -> 不停止轮询",
isStandalone: true,
gatewayExtend: "",
want: false,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
card := &model.IotCard{
IsStandalone: tc.isStandalone,
GatewayExtend: tc.gatewayExtend,
}
got := shouldStopPollingForRisk(card, tc.gatewayExtend)
if got != tc.want {
t.Errorf("shouldStopPollingForRisk(standalone=%v, extend=%q) = %v, 期望 %v",
tc.isStandalone, tc.gatewayExtend, got, tc.want)
}
})
}
}

View File

@@ -0,0 +1,2 @@
ALTER TABLE tb_iot_card_import_task DROP COLUMN IF EXISTS creator_name;
ALTER TABLE tb_device_import_task DROP COLUMN IF EXISTS creator_name;

View File

@@ -0,0 +1,7 @@
-- 为 IoT 卡导入任务表和设备导入任务表添加操作人快照字段
-- 旧记录 creator_name 留空字符串,新任务创建时从登录用户快照姓名
ALTER TABLE tb_iot_card_import_task
ADD COLUMN IF NOT EXISTS creator_name varchar(100) NOT NULL DEFAULT '';
ALTER TABLE tb_device_import_task
ADD COLUMN IF NOT EXISTS creator_name varchar(100) NOT NULL DEFAULT '';

View File

@@ -0,0 +1,14 @@
-- 回滚jsonb 数组 → varchar(500),取第一个元素;空数组 → ''
ALTER TABLE tb_order
ALTER COLUMN payment_voucher_key TYPE varchar(500)
USING COALESCE(payment_voucher_key->>0, '');
ALTER TABLE tb_refund_request
ALTER COLUMN refund_voucher_key TYPE varchar(500)
USING COALESCE(refund_voucher_key->>0, '');
ALTER TABLE tb_agent_recharge_record
ALTER COLUMN payment_voucher_key TYPE varchar(500)
USING COALESCE(payment_voucher_key->>0, '');
ALTER TABLE tb_agent_recharge_record DROP COLUMN IF EXISTS remark;

View File

@@ -0,0 +1,38 @@
-- 将三个表的凭证字段从 varchar(500) 改为 jsonb 数组存储最多5个元素
-- 同时为代理预充值记录新增 remark 备注字段
-- 注意:旧数据可能为逗号分隔多路径,用 string_to_array 拆分为数组元素
-- USING 子句中必须显式 ::text 转换,避免 PostgreSQL 隐式将空字符串转 jsonb 报错
-- tb_order.payment_voucher_key: 空/NULL → [],非空 → 按逗号拆分为数组
ALTER TABLE tb_order
ALTER COLUMN payment_voucher_key TYPE jsonb
USING CASE
WHEN payment_voucher_key IS NULL OR payment_voucher_key::text = '' THEN '[]'::jsonb
ELSE to_jsonb(string_to_array(payment_voucher_key::text, ','))
END;
-- tb_refund_request.refund_voucher_key: 先删除旧 varchar DEFAULT 再转类型
ALTER TABLE tb_refund_request
ALTER COLUMN refund_voucher_key DROP DEFAULT;
ALTER TABLE tb_refund_request
ALTER COLUMN refund_voucher_key TYPE jsonb
USING CASE
WHEN refund_voucher_key IS NULL OR refund_voucher_key::text = '' THEN '[]'::jsonb
ELSE to_jsonb(string_to_array(refund_voucher_key::text, ','))
END;
ALTER TABLE tb_refund_request
ALTER COLUMN refund_voucher_key SET DEFAULT '[]'::jsonb;
-- tb_agent_recharge_record.payment_voucher_key: 同 tb_order
ALTER TABLE tb_agent_recharge_record
ALTER COLUMN payment_voucher_key TYPE jsonb
USING CASE
WHEN payment_voucher_key IS NULL OR payment_voucher_key::text = '' THEN '[]'::jsonb
ELSE to_jsonb(string_to_array(payment_voucher_key::text, ','))
END;
-- tb_agent_recharge_record 新增 remark 备注字段创建后只读NULL 表示未填写)
ALTER TABLE tb_agent_recharge_record
ADD COLUMN IF NOT EXISTS remark text;

View File

@@ -0,0 +1 @@
DROP TABLE IF EXISTS tb_order_package_invalidate_task;

View File

@@ -0,0 +1,25 @@
CREATE TABLE IF NOT EXISTS tb_order_package_invalidate_task (
id bigserial PRIMARY KEY,
task_no varchar(50) NOT NULL UNIQUE,
status int NOT NULL DEFAULT 1,
total_count int NOT NULL DEFAULT 0,
success_count int NOT NULL DEFAULT 0,
fail_count int NOT NULL DEFAULT 0,
failed_items jsonb NOT NULL DEFAULT '[]',
file_name varchar(255) NOT NULL DEFAULT '',
storage_key varchar(500) NOT NULL DEFAULT '',
voucher_keys jsonb NOT NULL DEFAULT '[]',
remark text NOT NULL DEFAULT '',
creator_name varchar(100) NOT NULL DEFAULT '',
error_message text NOT NULL DEFAULT '',
started_at timestamptz,
completed_at timestamptz,
creator bigint NOT NULL DEFAULT 0,
updater bigint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
deleted_at timestamptz
);
CREATE INDEX IF NOT EXISTS idx_order_pkg_inv_task_status ON tb_order_package_invalidate_task (status) WHERE deleted_at IS NULL;
CREATE INDEX IF NOT EXISTS idx_order_pkg_inv_task_created_at ON tb_order_package_invalidate_task (created_at DESC) WHERE deleted_at IS NULL;

View File

@@ -0,0 +1,2 @@
ALTER TABLE tb_agent_recharge_record
DROP COLUMN IF EXISTS rejection_reason;

View File

@@ -0,0 +1,5 @@
-- 为代理预充值表新增驳回原因字段,支持管理员驳回待支付订单
ALTER TABLE tb_agent_recharge_record
ADD COLUMN IF NOT EXISTS rejection_reason VARCHAR(500) NULL;
COMMENT ON COLUMN tb_agent_recharge_record.rejection_reason IS '驳回原因,仅驳回时写入';

View File

@@ -0,0 +1,6 @@
-- 回滚将状态约束恢复为不包含驳回状态6
ALTER TABLE tb_agent_recharge_record
DROP CONSTRAINT IF EXISTS chk_agent_recharge_status;
ALTER TABLE tb_agent_recharge_record
ADD CONSTRAINT chk_agent_recharge_status CHECK (status IN (1, 2, 3, 4, 5));

View File

@@ -0,0 +1,6 @@
-- 更新代理预充值状态检查约束新增驳回状态6
ALTER TABLE tb_agent_recharge_record
DROP CONSTRAINT IF EXISTS chk_agent_recharge_status;
ALTER TABLE tb_agent_recharge_record
ADD CONSTRAINT chk_agent_recharge_status CHECK (status IN (1, 2, 3, 4, 5, 6));

View File

@@ -67,6 +67,9 @@ const (
TaskTypePackageQueueActivation = "package:queue:activation" // 主套餐排队激活
TaskTypePackageDataReset = "package:data:reset" // 套餐流量重置
// 订单套餐失效任务类型
TaskTypeOrderPackageInvalidate = "order:package:invalidate" // 批量失效订单套餐
// 订单超时任务类型
TaskTypeOrderExpire = "order:expire" // 订单超时自动取消
TaskTypeAutoPurchaseAfterRecharge = "task:auto_purchase_after_recharge" // 充值后自动购包
@@ -194,6 +197,7 @@ const (
QueuePackageFirstActivation = TaskTypePackageFirstActivation // 首次实名激活任务队列
QueuePackageQueueActivation = TaskTypePackageQueueActivation // 主套餐排队激活任务队列
QueuePackageDataReset = TaskTypePackageDataReset // 套餐流量重置任务队列
QueueOrderPackageInvalidate = TaskTypeOrderPackageInvalidate // 批量失效订单套餐任务队列
QueueOrderExpire = TaskTypeOrderExpire // 订单超时取消任务队列
QueueAutoPurchase = TaskTypeAutoPurchaseAfterRecharge // 充值后自动购包任务队列
QueueAlertCheck = TaskTypeAlertCheck // 告警检查任务队列
@@ -244,6 +248,8 @@ func QueueForTaskType(taskType string) string {
return QueuePackageQueueActivation
case TaskTypePackageDataReset:
return QueuePackageDataReset
case TaskTypeOrderPackageInvalidate:
return QueueOrderPackageInvalidate
case TaskTypeOrderExpire:
return QueueOrderExpire
case TaskTypeAutoPurchaseAfterRecharge:
@@ -268,6 +274,7 @@ func DefaultTaskQueueWeights() map[string]int {
QueueAutoPurchase: 5,
QueuePackageFirstActivation: 5,
QueuePackageQueueActivation: 5,
QueueOrderPackageInvalidate: 5,
QueueOrderExpire: 4,
QueueEmailSend: 4,
QueueExportDispatch: 4,

View File

@@ -60,9 +60,11 @@ const (
GatewayCardStatusReady = "准备" // 网关卡状态:准备
GatewayCardStatusNormal = "正常" // 网关卡状态:正常
GatewayCardStatusStopped = "停机" // 网关卡状态:停机
GatewayCardExtendPendingActivation = "待激活" // 网关扩展状态:待激活
GatewayCardExtendMachineSeparated = "机卡分离停机" // 网关扩展状态:机卡分离停机
GatewayCardExtendPendingActivation = "待激活" // 网关扩展状态:待激活
GatewayCardExtendMachineSeparated = "机卡分离停机" // 网关扩展状态:机卡分离停机
GatewayCardStartExtendMachineSeparated = "2" // 机卡分离复机时上传给运营商的 extend 值
GatewayCardExtendRiskStop = "风险停机" // 网关扩展状态:运营商风险停机(独立卡不允许复机)
GatewayCardExtendCancelled = "已销户" // 网关扩展状态:已销户(独立卡不允许复机)
)
// IoT 卡停机原因

View File

@@ -98,6 +98,7 @@ const (
RechargeStatusCompleted = 3 // 已完成
RechargeStatusClosed = 4 // 已关闭
RechargeStatusRefunded = 5 // 已退款
RechargeStatusRejected = 6 // 已驳回
)
// 充值支付方式
@@ -121,7 +122,7 @@ const (
// ========== Redis Key 生成函数 ==========
// GetRechargeStatusName 获取充值状态名称
// 对应 RechargeStatus* 常量1=待支付, 2=已支付, 3=已完成, 4=已关闭, 5=已退款
// 对应 RechargeStatus* 常量1=待支付, 2=已支付, 3=已完成, 4=已关闭, 5=已退款, 6=已驳回
func GetRechargeStatusName(status int) string {
switch status {
case RechargeStatusPending:
@@ -134,6 +135,8 @@ func GetRechargeStatusName(status int) string {
return "已关闭"
case RechargeStatusRefunded:
return "已退款"
case RechargeStatusRejected:
return "已驳回"
default:
return "未知"
}

View File

@@ -64,6 +64,7 @@ func BuildDocHandlers() *bootstrap.Handlers {
WechatConfig: admin.NewWechatConfigHandler(nil),
AgentRecharge: admin.NewAgentRechargeHandler(nil, nil),
Refund: admin.NewRefundHandler(nil),
OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(nil),
ClientWechat: app.NewClientWechatHandler(nil, nil, nil),
SuperAdmin: admin.NewSuperAdminHandler(nil),
AgentOpenAPI: openapiHandler.NewHandler(nil, nil),

View File

@@ -65,6 +65,7 @@ func (h *Handler) RegisterHandlers() *asynq.ServeMux {
h.registerIotCardImportHandler()
h.registerDeviceImportHandler()
h.registerOrderPackageInvalidateHandler()
h.registerExportHandlers()
h.registerCommissionStatsHandlers()
h.registerCommissionCalculationHandler()
@@ -97,6 +98,18 @@ func (h *Handler) registerIotCardImportHandler() {
h.logger.Info("注册 IoT 卡导入任务处理器", zap.String("task_type", constants.TaskTypeIotCardImport))
}
func (h *Handler) registerOrderPackageInvalidateHandler() {
orderPkgHandler := task.NewOrderPackageInvalidateHandler(
h.workerResult.Stores.OrderPackageInvalidateTask,
h.workerResult.Stores.Order,
h.workerResult.Stores.PackageUsage,
h.storage,
h.logger,
)
h.mux.HandleFunc(constants.TaskTypeOrderPackageInvalidate, orderPkgHandler.Handle)
h.logger.Info("注册订单套餐批量失效任务处理器", zap.String("task_type", constants.TaskTypeOrderPackageInvalidate))
}
func (h *Handler) registerDeviceImportHandler() {
deviceImportHandler := task.NewDeviceImportHandler(
h.db,

View File

@@ -46,8 +46,9 @@ type WorkerStores struct {
AgentWalletTransaction *postgres.AgentWalletTransactionStore
AssetWallet *postgres.AssetWalletStore
AssetIdentifier *postgres.AssetIdentifierStore
PersonalCustomer *postgres.PersonalCustomerStore
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
PersonalCustomer *postgres.PersonalCustomerStore
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
}
// WorkerServices Worker 侧所有 Service 的集合