Compare commits

235 Commits

Author SHA1 Message Date
07e55d9f38 修复开放接口 wallet/package-orders 支持 IMEI 下单 2026-06-23 11:36:51 +09:00
2885b503b3 一次重构修复 2026-06-22 12:15:40 +09:00
062f5a436f Merge branch 'main' of https://git.boss160.cn/csxj2026/junhong_cmp_fiber
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m17s
2026-06-22 11:10:58 +08:00
ba435dd6a6 关于绑定的问题
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-06-22 12:08:41 +09:00
3f21f7a7d6 让超时时间设置成60秒 2026-06-21 17:06:40 +08:00
5f960daf78 修改过期时间跟修改流量
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m10s
2026-06-18 15:53:43 +09:00
3b7b856e48 企业授权增强与资产列表扩展
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m9s
- 企业卡授权唯一约束:新增 DB 迁移(000154),卡级部分唯一索引防止同一张卡被多个企业同时持有,Service 层新增跨企业冲突检测
- 单卡列表新增 network_status 过滤参数
- 单卡/设备列表新增 asset_status、asset_status_name、generation 响应字段
- 单卡/设备列表新增企业维度过滤(authorized_enterprise_id、is_authorized_to_enterprise)及响应中企业授权信息(批量加载,无 N+1)
- 主钱包流水/退款列表新增 asset_identifier 精确过滤参数
- 企业卡授权/收回接口升级为三模式(list/range/filter),企业设备授权/收回升级为双模式(list/filter)
- 升级 sonic v1.14.2 → v1.15.2 以兼容 Go 1.26

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-17 16:23:03 +09:00
cf36f1447f 尝试某个skills
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m24s
2026-06-16 18:58:55 +09:00
Break
1f634eb465 init
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m3s
2026-06-16 15:15:50 +08:00
Break
84ab3bad99 order导出
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 3m11s
2026-06-16 09:38:20 +08:00
Break
6c8594633a 设备导出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m33s
2026-06-15 17:29:36 +08:00
Break
064961471b 设备导出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m42s
2026-06-15 16:43:50 +08:00
Break
e37aad9e1d 修复导出数据不全的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m41s
2026-06-15 15:55:32 +08:00
Break
7ec84fbc0f 导出系统
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m50s
2026-06-15 15:28:29 +08:00
Break
2f0b24ce88 迁移脚本的修复 2026-06-15 10:53:47 +08:00
Break
e56649e5be 还有例子
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 53s
2026-06-12 18:10:37 +08:00
Break
1c492fe8db 更新一版 2026-06-12 18:10:22 +08:00
Break
e139f5e227 管理员也可以
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m42s
2026-06-12 17:58:25 +08:00
Break
016e7bee79 修改过期时间,以及修改套餐已用量
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-06-12 10:41:24 +08:00
Break
c437593a8c 修复错误购买报错的问题,修复接口返回虚流量启动错误的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m48s
2026-06-11 14:51:26 +08:00
Break
134021c91e 修复错误的停止
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m51s
2026-06-11 10:01:35 +08:00
Break
5c779cb6e0 不允许购买第二个主套餐
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m50s
2026-06-10 15:24:26 +08:00
Break
32c9859b38 监控 2026-06-10 01:15:26 +08:00
Break
283e3e3eb3 迁移脚本的一些修复 2026-06-09 11:29:40 +08:00
Break
6a70713b30 加分布式锁
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m0s
2026-06-09 10:54:13 +08:00
Break
4ccbf13197 开放接口已用完流量查询错误的问题 2026-06-08 10:10:10 +08:00
Break
0ae8148689 审批金额
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m13s
2026-06-06 14:55:48 +08:00
Break
9a802c04e5 审计日志修复
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m20s
2026-06-05 17:22:05 +08:00
Break
4d419a1966 校验bug 2026-06-05 16:59:27 +08:00
Break
fe2041bcf5 机卡分离复机判断
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m13s
2026-06-05 14:20:58 +08:00
Break
6ac19d6565 支撑单卡
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m16s
2026-06-04 14:29:20 +08:00
Break
cce27975a1 设备关键词查询
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m7s
2026-06-04 10:34:58 +08:00
Break
d86ac46761 关键词查询
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-06-04 10:34:14 +08:00
Break
420f3d83f9 应当允许卡就算没有销售也能换货
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-06-04 10:26:47 +08:00
Break
27fba9160c 提案
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 56s
2026-06-04 09:39:20 +08:00
Break
5089a71764 返回错误原因
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m25s
2026-06-03 17:52:51 +08:00
Break
5f57429fb0 直接换货流程
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m28s
2026-06-03 16:55:32 +08:00
Break
46ede81aef 修复佣金错误计算的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m15s
2026-06-03 10:25:49 +08:00
Break
36e68e6672 提案 2026-06-02 16:56:18 +08:00
Break
a6cd550b94 1
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m18s
2026-06-02 16:56:09 +08:00
Break
c6e65fde9b 开放接口 2026-06-02 16:56:01 +08:00
Break
1b47fb8f32 佣金计算问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m23s
2026-06-02 16:19:42 +08:00
Break
c5126c583b 修复以前的老数据
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m51s
2026-06-01 11:49:24 +08:00
Break
918b9b4f25 退款也应该记录快照 2026-06-01 11:45:31 +08:00
Break
944526d9ef 修复订单金额落库不对的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m17s
2026-06-01 11:21:29 +08:00
Break
6c8352491c 修复
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 53s
2026-05-29 15:08:25 +08:00
Break
1ee554daec 钱包流水新增资产信息
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m59s
2026-05-29 10:52:12 +08:00
Break
34a7cf0f3c 修复一些问题 2026-05-29 10:30:19 +08:00
Break
6a3fffc46c 错误的套餐购买限制
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m48s
2026-05-29 09:44:48 +08:00
Break
872769e69e 迁移脚本的一些问题修复 2026-05-28 17:22:52 +08:00
Break
1550ef3cb1 修复超额流量不记录的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m18s
2026-05-28 15:19:54 +08:00
Break
58863e8ec5 数据迁移脚本
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 54s
2026-05-27 11:10:56 +08:00
Break
0d96a94e5f 关于上游流量同步覆盖修复,以及新增redis同步迁移
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m25s
2026-05-27 10:59:19 +08:00
Break
cf689beceb 修复问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m9s
2026-05-26 18:06:51 +08:00
Break
f4d92f99a2 新增退款凭证
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m41s
2026-05-26 17:16:11 +08:00
Break
e131d6ba77 imei
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 21m51s
2026-05-26 16:55:35 +08:00
Break
bcbc290bad 卡新增imei字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m10s
2026-05-26 16:30:12 +08:00
4c1e4f428a 一次补偿
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m2s
2026-05-25 14:40:34 +08:00
20b5d70af9 1. 清理预充值SQL
2. 查日志的脚本
2026-05-25 14:30:09 +08:00
226474b434 轮训有问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m7s
2026-05-22 16:21:46 +08:00
860f589b9a 错误提示有问题 2026-05-22 15:54:54 +08:00
2adbc87a52 Merge commit 'a03ba5d39645948f1c48ca0cfccc74c6edd1ccbf'
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m14s
2026-05-22 14:20:21 +08:00
6e564d1d1a Merge commit '599289a94ee0b8f36a09711122303a5bfacb265a' 2026-05-22 14:20:21 +08:00
3d28e29eaa Merge commit '58bdb2f18e5dd4eef84467961a1b55f3434b9ee5' 2026-05-22 14:20:20 +08:00
0948494b1c task: 修复支付宝C端入口构建失败
移除不存在的 alipay.CalcExpireAt 调用,改为按 cfg.AliPayExpireMinutes(默认 30 分钟)
内联计算 expireAt;将所有 BuildWapPayURL 调用从 5 参数改为 4 参数(移除 returnURL)。
修复范围:getOrBuildAlipayPaymentLink、createAlipayForceRechargeOrder、createAlipayRecharge。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:18:21 +08:00
599289a94e task: 修复支付宝C端入口构建失败
移除不存在的 alipay.CalcExpireAt 调用,改为按 cfg.AliPayExpireMinutes(默认 30 分钟)
内联计算 expireAt;将所有 BuildWapPayURL 调用从 5 参数改为 4 参数(移除 returnURL)。
修复范围:getOrBuildAlipayPaymentLink、createAlipayForceRechargeOrder、createAlipayRecharge。

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:13:46 +08:00
b988f99a95 task: 完成支付宝回调安全(验签、多维度校验、业务分发) 2026-05-22 14:07:20 +08:00
56adc30409 task: 完成支付宝C端入口
新增支付宝支付方式支持:
- DTO:ClientPaymentLink、ClientPayOrderRequest/Response 增加 alipay;ClientCreateOrderRequest 增加 payment_method;ClientCreateOrderResponse 增加 payment_link;ClientCreateRechargeRequest 允许 alipay,app_type 改为微信必填;ClientRechargeResponse PayConfig 改为可选指针,增加 payment_link
- Service(client_order):PayOrder 新增 alipay 分支(不需要 app_type/OpenID,复用或新建 pending payment,生成 WAP URL);CreateOrder 强充路径按 payment_method 分支,alipay 不需要 app_type/OpenID;幂等命中时支付宝强充返回同一充值单对应的 payment_link;新增 getOrBuildAlipayPaymentLink(过期 payment 自动续建);新增 createAlipayForceRechargeOrder(事务创建充值单+支付单,WAP URL 生成失败则标记 failed)
- Handler(client_wallet):CreateRecharge 移除微信唯一限制,按 payment_method 分发到 createWechatRecharge / createAlipayRecharge;alipay 分支事务内创建充值单+支付单,然后生成 WAP URL,失败标记 payment failed
- 依赖 worker-1 共享 API:pkg/alipay.BuildWapPayURL/CalcExpireAt、Payment.ExpireAt、WechatConfig.AliReturnURL/AliPayExpireMinutes、PaymentStore.FindLatestPendingByOrderAndMethod;本 worktree 暂不能独立构建,待 leader 集成

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-22 14:07:20 +08:00
53e95751b1 task: 完成支付宝共享基础(迁移/WechatConfig/PaymentStore/pkg/alipay) 2026-05-22 14:07:20 +08:00
58bdb2f18e task: 完成支付宝共享基础(迁移/WechatConfig/PaymentStore/pkg/alipay) 2026-05-22 12:09:55 +08:00
a03ba5d396 task: 完成支付宝回调安全(验签、多维度校验、业务分发) 2026-05-22 12:09:50 +08:00
2768deb0b6 设备新增搜索条件
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m8s
2026-05-20 10:27:30 +08:00
e2301d5f3b chore(集成验证): gofmt 修复 DTO 格式并重新生成 OpenAPI 文档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m3s
- 修复 ListStandaloneIotCardRequest 和 ListDeviceRequest 字段对齐格式
- 重新生成 docs/admin-openapi.yaml,两个列表接口均新增 has_active_package 参数
- go build ./cmd/api、go build ./cmd/worker 均通过验证

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 12:15:06 +08:00
9f5c5c3680 feat(卡列表): 新增 has_active_package 查询条件筛选生效中套餐
- DTO ListStandaloneIotCardRequest 增加 HasActivePackage *bool 字段,
  query/json 名 has_active_package,description 中文
- Service ListStandalone 将 HasActivePackage 映射为 filters["has_active_package"]
- Store applyStandaloneFilters 新增 EXISTS/NOT EXISTS 子查询,
  使用 constants.PackageUsageStatusActive,限制 master_usage_id IS NULL
  且 deleted_at IS NULL,所有查询路径(默认/两阶段/并行)均复用此函数

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 12:15:06 +08:00
5e7e68cce7 feat(设备列表): 新增 has_active_package 查询条件
- DTO ListDeviceRequest 增加 HasActivePackage *bool 字段(query/json: has_active_package)
- Service List 方法将 HasActivePackage 透传至 filters["has_active_package"]
- Store applyDeviceFilters 新增 applyHasActivePackageFilter:
  true  → EXISTS 子查询(生效中主套餐)
  false → NOT EXISTS 子查询
  均限制 master_usage_id IS NULL、deleted_at IS NULL,状态使用 constants.PackageUsageStatusActive

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-19 12:15:06 +08:00
ca939ff617 暂存一下 2026-05-19 11:57:02 +08:00
8de9c59b1c 查询
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m13s
2026-05-19 11:32:41 +08:00
631c5392b6 1
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m56s
2026-05-18 10:47:10 +08:00
af5a21ed1e 新增三个同步时间字段 2026-05-18 10:47:07 +08:00
480d4c4583 1
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m59s
2026-05-18 10:34:04 +08:00
ed071918ed 钱包订单退款时应当正确退回,充值时允许最低充值1分
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m7s
2026-05-18 09:51:59 +08:00
d597a25c69 机卡分离复机接口
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m1s
2026-05-14 09:50:48 +08:00
81aa3c2b11 新增字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m15s
2026-05-13 17:50:21 +08:00
a5388446d3 新增字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m7s
2026-05-12 14:10:48 +08:00
8f3a68a673 新增上游的停机原因
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m5s
2026-05-12 11:13:04 +08:00
95fc0b0a1b 修复一些问题,主要是生效套餐
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m16s
2026-05-12 10:32:38 +08:00
b7369a9c71 修复并发,暂时的,不够完整,后续还是需要重新设计,这个太乱了,傻逼AI
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m22s
2026-05-11 17:19:40 +08:00
05a5694a5f 修复bug
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m5s
2026-05-11 16:45:37 +08:00
1350a9ed79 批量
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m53s
2026-05-11 14:54:23 +08:00
93200a9074 开放接口完成
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m57s
2026-05-11 14:26:10 +08:00
02d522564f 删除hurl 2026-05-11 12:00:02 +08:00
b6d21b81e3 文档生成 2026-05-11 11:59:36 +08:00
98ff88d5c3 开放接口,修复上游同步不对的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m55s
2026-05-11 11:20:48 +08:00
a9eaf1d697 修复缓存没有更新的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m13s
2026-05-11 10:38:52 +08:00
97851c2595 修正代理主钱包余额快照口径
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m0s
代理主钱包扣款流水必须记录扣款前后的真实余额,避免资金概况与流水展示出现双重扣减错觉。

Constraint: 项目禁止自动化测试,使用构建和数据库对账验证。

Rejected: 在资金汇总接口按历史充值总量或流水末条余额展示 | 资金概况口径应来自主钱包剩余余额。

Confidence: high

Scope-risk: narrow

Directive: 修改代理钱包扣款链路时必须同时维护余额字段与流水快照一致性。

Tested: go build ./...;migrate up 已执行到 141;只读 SQL 对账确认主钱包余额、成功流水累计、最新流水余额一致且 mismatch_tx_count=0。

Not-tested: 未新增自动化测试(项目规范禁止)。
2026-05-11 10:22:48 +08:00
ebaf112c80 新增排查自己的逻辑
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m53s
2026-05-09 15:22:29 +08:00
09cf0be86e 联级,多选
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m52s
2026-05-09 15:06:27 +08:00
6a6672d0e4 允许代理看
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m11s
2026-05-09 11:03:32 +08:00
0b86720534 chore: add placeholder file
Co-authored-by: aider (openai/gpt-5.5) <aider@aider.chat>
2026-05-09 10:35:22 +08:00
a93acc0784 有效天数问题,充值单回调问题 2026-05-08 11:46:34 +08:00
7c85a8cf29 修复支付以及订单相关的问题
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Failing after 1s
2026-05-08 10:15:03 +08:00
348cb4e670 修复绑定的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m55s
2026-05-07 18:18:11 +08:00
e77bb0d09b 跨域
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m57s
2026-05-07 17:16:54 +08:00
73a3a04204 跨域
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m59s
2026-05-07 16:28:23 +08:00
573e887ca5 灰度脚本 2026-05-07 11:28:42 +08:00
29d3d48dbe 黑魔法
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m48s
2026-05-06 16:59:10 +08:00
d42fe2b0ca 补上参数
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m53s
2026-05-06 16:44:14 +08:00
eb74dd7849 补回是否启用虚流量
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m58s
2026-05-06 16:07:59 +08:00
3a2e3f2571 浅浅升级一下
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m8s
2026-05-06 14:41:14 +08:00
b0bd37ec12 尝试优化一下
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m0s
2026-05-06 10:21:38 +08:00
001f8caf35 修复当前卡不对的问题 2026-05-06 10:19:21 +08:00
21b73a750d c端当前套餐开始时间过期时间,以及excel导入字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m55s
2026-05-06 09:57:58 +08:00
ee7bfb81f0 修复解绑卡没有iccid的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m56s
2026-05-06 09:44:15 +08:00
b4a9e0c1c9 归档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m52s
2026-04-30 15:59:40 +08:00
948243b13d 修改 2026-04-30 15:49:40 +08:00
5425e2ce19 保留提案基线以便团队 worktree 按契约执行
Constraint: omx team 默认使用独立 worktree,要求 leader 工作区干净
Rejected: git stash 提案文件 | 会让 worker 无法读取当前提案契约
Confidence: high
Scope-risk: narrow
Directive: 后续实现必须继续严格按 tasks.md 顺序推进,不得擅自跳步
Tested: git status 仅剩未跟踪的 .omx 上下文文件
Not-tested: 未验证提案内容本身的实现正确性
2026-04-30 15:11:32 +08:00
70fb7dadef 归档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m53s
2026-04-30 11:58:33 +08:00
8a3c45c577 设备导入卡槽不对的问题 2026-04-30 11:56:09 +08:00
9238eeb56e 更新提案 2026-04-30 11:18:13 +08:00
9674e0d0d9 文档更新 2026-04-30 10:54:29 +08:00
248ed55f4e 获取当前生效的appid
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m46s
2026-04-30 10:52:33 +08:00
66e12e9629 订单相关以及佣金相关修复
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m57s
2026-04-30 09:52:39 +08:00
4f4e42f27e 应当返回真流量
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m1s
2026-04-29 16:19:15 +08:00
6b3b33b2f0 废除激活时间
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m57s
2026-04-29 12:04:23 +08:00
74c7b27327 切卡模式更新
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m58s
2026-04-29 10:18:15 +08:00
37b4483183 赠送套餐逻辑
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m55s
2026-04-28 17:40:06 +08:00
516a7f5bdf 优化后台创建订单的代理套餐下架提示
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m59s
2026-04-28 14:27:29 +08:00
fe4c545308 修正数据
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m55s
2026-04-27 18:10:08 +08:00
bb33232b1b 日志记录不全
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m48s
2026-04-27 15:32:17 +08:00
c3ae7fcfbc 修复设备WiFi上游请求参数
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m40s
2026-04-27 15:19:23 +08:00
66cec7515a 更新
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m18s
2026-04-27 14:52:17 +08:00
cb75b5668b 操作日志
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m10s
2026-04-27 12:16:38 +08:00
95104100c9 修复企业授权报错的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m57s
2026-04-27 10:30:10 +08:00
02ccf57aa3 平台应当要可以用代理的钱包支付
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m6s
2026-04-27 10:01:38 +08:00
60debb7505 统一导出任务
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m4s
2026-04-27 09:47:03 +08:00
531de3c760 已退回后的还是可以继续提交
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m49s
2026-04-27 09:29:54 +08:00
a887f91686 修复卡轮训的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 10m14s
2026-04-26 12:41:16 +08:00
7efcea2b54 修复错误报错
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m0s
2026-04-24 17:53:22 +08:00
85bef83752 跨级
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m7s
2026-04-24 16:33:13 +08:00
bc344f892c 新增日志输出
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m33s
2026-04-24 11:25:13 +08:00
41db5b56af 上级店铺名称
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-04-24 11:20:41 +08:00
15cd26c81a 强制手机绑定
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m1s
2026-04-24 10:26:32 +08:00
3f2cf31543 校验修复以及查询修复
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m55s
2026-04-24 09:41:51 +08:00
ba73913a41 当前卡的逻辑变更
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m53s
2026-04-24 09:25:44 +08:00
a44c88005d 手动实名
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m24s
2026-04-23 17:35:38 +08:00
f177c26016 快照订单号以及退款订单号
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m27s
2026-04-23 17:14:10 +08:00
4bde09837a 激活时间
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m35s
2026-04-23 17:02:11 +08:00
c250a47651 卡不激活的问题 2026-04-23 16:58:16 +08:00
a7f2c4480a 同步更新订单状态,校验退款金额小于等于订单实付金额
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m16s
2026-04-23 15:40:59 +08:00
37e113bbf4 新增查询字段
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-04-23 15:33:15 +08:00
5442dcbeda 时间支持
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m4s
2026-04-23 14:20:02 +08:00
b9fc828567 订单总价和订单明细单价在代购/成本价场景会不一致,已改为同一套价格来源并支持多套餐求和
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m24s
2026-04-23 12:11:48 +08:00
327c72ca74 修复查询问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m28s
2026-04-23 11:48:59 +08:00
32f03cd2c1 新增退款时应当快照当时的资产
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-04-23 11:43:15 +08:00
8d2e8faa84 修复退款bug
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m43s
2026-04-23 11:30:46 +08:00
1f1f31a7cb 退款列表快照订单号
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m30s
2026-04-23 10:59:34 +08:00
52d883d8a0 修复店铺名不存在的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m29s
2026-04-23 10:21:23 +08:00
6b1fbf4a92 修复金额的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m30s
2026-04-23 10:09:23 +08:00
10a35625a5 新增状态筛选条件,同时修复价格未正常写入的情况
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m35s
2026-04-23 09:57:39 +08:00
a207c51bfb 修复设备套餐流量不同步的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m32s
2026-04-22 18:04:53 +08:00
6eb6d381f7 日志
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m32s
2026-04-22 16:58:04 +08:00
e27758cce3 修复新卡导入时没有正确填写停机原因的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m45s
2026-04-22 16:41:33 +08:00
7137ed087f 修复序列化的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m29s
2026-04-22 15:24:39 +08:00
a9690a49db 提交
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m33s
2026-04-22 15:06:01 +08:00
0f58886454 增加日志,提交流程图
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m42s
2026-04-22 14:49:09 +08:00
9f8173e124 灰度上线流程图准备 2026-04-21 16:42:47 +08:00
1d930cc82d 运营商名称问题TODO
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m29s
2026-04-21 11:27:19 +08:00
1b21d4dafa 新增运营商名称模糊搜索 2026-04-21 11:24:47 +08:00
e573a82120 设备卡列表没有正确返回网络状态以及实名认证策略的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m34s
2026-04-21 11:11:03 +08:00
e049080f6c 分裂iccid长度
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m47s
2026-04-21 10:55:12 +08:00
9942bbc74e docs: 更新实名认证策略 DTO description 默认值说明
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m35s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-20 15:39:47 +08:00
fc9f819787 feat: 修改实名认证策略默认值为先充值后实名(after_order)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-20 15:39:01 +08:00
33a4d25ca9 chore: 新增数据库迁移,将实名认证策略默认值改为 after_order
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-20 15:29:25 +08:00
e9862a67ba fix: 修复资产卡列表未返回 realname_policy 字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m10s
buildDeviceResolveResponse 和 GetRealtimeStatus 中构建 BoundCardInfo 时
均遗漏了 RealnamePolicy 字段赋值,导致 resolve 和 realtime-status
两个接口的设备卡列表不返回实名认证策略。字段在 Model 和 DTO 中均
已定义,Service 层补充赋值即可。

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-20 14:43:09 +08:00
868f5a8ef3 fix: 修复轮询停机连坐、实名逆转防抖及carrier_stopped原因写入缺失三个逻辑缺陷
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 9m37s
2026-04-20 11:24:56 +08:00
fd795d8742 feat: 新增设备切卡模式接口 SetSwitchMode
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m32s
2026-04-18 16:22:30 +08:00
2b3a9cb33f feat: 新增套餐使用记录实付金额快照字段 paid_amount
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m19s
- 新增迁移 000129:tb_package_usage 添加 paid_amount BIGINT 字段,存量数据通过 JOIN tb_order 回填
- PackageUsage Model 新增 PaidAmount *int64 字段
- 4 个写入点(order service 主套餐/加油包、auto_purchase 主套餐/加油包)赋值 order.ActualPaidAmount
- AssetPackageResponse DTO 新增 paid_amount 字段
- GetCurrentPackage / GetPackages 填充 paid_amount,接口直接返回购买价格无需 JOIN 订单表
2026-04-18 10:19:21 +08:00
4b8784d5e7 docs: 更新 OpenAPI 文档,新增批量下载 URL 接口文档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m15s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-18 10:08:53 +08:00
2413c2fe14 feat: 新增批量获取文件下载预签名 URL 接口
新增 POST /api/admin/storage/batch-download-urls,支持一次传入最多 50 个 file_key,返回对应预签名下载 URL 映射。解决列表页批量展示图片需多次请求的问题。

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-18 10:08:45 +08:00
e52671eaed 归档提案 2026-04-18 09:35:27 +08:00
716e9f5971 chore: 更新 fix-realname-activation-logic OpenSpec 任务清单
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m15s
标记全部 13 个任务为已完成

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-18 09:30:56 +08:00
28ac58ea18 feat: PollingRealnameHandler 新增设备级套餐实名激活联动
- 结构体新增 deviceSimBindingStore 字段,构造函数参数列表末尾追加
- 新增 triggerDeviceRealnameActivation:卡首次实名(0→1)时查询所属设备,
  若有绑定则提交 carrier_type=device 的 TaskTypePackageFirstActivation 任务
- 独立卡(无绑定设备)静默跳过,任务提交失败仅记录 Warn 日志不阻断流程
- registerPollingHandlers 传入 h.workerResult.Stores.DeviceSimBinding
- 修复「购买后某张卡实名」场景下设备级套餐永不激活的缺陷

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-18 09:30:50 +08:00
8e61cb1a54 fix: 购买时检查载体实名状态,已实名跳过 pending 直接激活
- iot_card 载体:扩展查询字段同时读取 real_name_status,已实名则保持 Active 不切换 pending
- device 载体:通过 GORM 子查询统计绑定卡中已实名数量,count>0 则直接激活
- 查询出错时保守默认 false,不阻断购买流程
- 修复「先实名后购买」场景下套餐永久 pending 及购买后不自动复机的双重缺陷

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-18 09:30:40 +08:00
4aab0bcbf2 提案以及归档
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 4m39s
2026-04-18 09:10:29 +08:00
6caf0f6141 feat: 新增全局操作密码功能,将线下充值验证从登录密码改为统一操作密码
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
- 新增 RedisSystemOperationPasswordKey 常量函数(pkg/constants/redis.go)
- 新增 operation_password Service(Set/IsSet/Verify,bcrypt 哈希存 Redis)
- 新增 SuperAdminHandler 及两个接口:
  - POST /api/admin/super-admin/operation-password(设置/重置,仅超级管理员)
  - GET /api/admin/super-admin/operation-password/status(查询是否已设置)
- AgentRecharge.OfflinePay 操作密码验证从"查当前用户登录密码"改为"全局操作密码 Verify"
- Bootstrap/路由/文档生成器同步注册
2026-04-18 09:06:33 +08:00
6e15e1b853 docs: 更新 OpenAPI 文档及提案任务清单,新增 payment_voucher_key 字段
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m26s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 18:29:42 +08:00
ad30f5d41b feat: 代理充值支持线下支付凭证上传与存储
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 18:29:06 +08:00
b538e45bd9 feat: 新增数据库迁移,为 tb_agent_recharge_record 添加 payment_voucher_key 列
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 18:28:38 +08:00
1033d7666b 修复主账号问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 8m6s
2026-04-17 18:07:29 +08:00
0d2baabcf1 chore: 更新 asset-realname-policy 提案任务清单,勾选全部已完成项
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m24s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:33:52 +08:00
9d8e3926ff docs: 更新 OpenAPI 文档,新增 realname-mode 更新接口文档
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:33:07 +08:00
c5ed040359 feat: 后台管理新增 UpdateRealnameMode 接口并注册路由 PATCH /api/admin/assets/:identifier/realname-mode
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:32:18 +08:00
363e0efb34 feat: C 端充值、购买、实名链接接口接入实名策略拦截(before_order/after_order)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:31:21 +08:00
1363797378 feat: 导入 Service 和 Worker 传递 realname_policy 写入资产记录
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:31:01 +08:00
71498883b5 feat: iot_card/device Service 新增 UpdateRealnamePolicy 方法(含幂等检查)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:30:13 +08:00
1d75a4c31a feat: asset Service 新增 GetEffectiveRealnamePolicy 策略判断及 HasValidRechargeOrPaidOrder 检查逻辑
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:29:32 +08:00
89eec1406a feat: iot_card/device Store 新增 UpdateRealnamePolicy 方法
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:28:58 +08:00
9a625f51ef feat: C 端查询及设备导入接口 DTO 新增 realname_policy 字段
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:28:20 +08:00
2d6456c4a3 feat: 后台管理查询接口 DTO 新增 realname_policy 字段
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:27:10 +08:00
9bd1d60e85 feat: 四个 Model 新增 realname_policy 字段(含 gorm 标签和中文注释)
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:26:20 +08:00
908c5fa1de feat: 新增 RealnamePolicy 常量枚举及 CodeRealnameNotAvailable 错误码
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 17:25:02 +08:00
505b30aa8f feat: 实现资产实名认证策略功能 (asset-realname-policy)
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m44s
- 新增 realname_policy 字段到 tb_iot_card、tb_device、tb_iot_card_import_task、tb_device_import_task 四张表
- 策略枚举:none(无需实名)、before_order(先实名后充值/购买)、after_order(先充值/购买后实名)
- C端充值拦截:before_order 策略下未实名用户禁止充值
- C端订单拦截:before_order 策略下未实名用户禁止购买套餐
- C端实名链接拦截:after_order 策略下无充值/订单用户禁止实名认证
- Admin API:PATCH /api/admin/assets/:identifier/realname-mode
- 新增错误码 CodeRealnameNotAvailable=1189
2026-04-17 17:03:41 +08:00
e0a37f4a11 fix: verify-asset 接口增加卡绑定设备校验
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m29s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-17 14:49:11 +08:00
4c284c422c fix: 移除 IoT 卡导入 ICCID 长度限制
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m29s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 12:04:44 +08:00
44e4f03957 docs: 补充微信 JSSDK 配置接口 OpenAPI 文档
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-17 12:04:35 +08:00
0a2961ecd6 fix: 支付回调补写 actual_paid_amount 字段,从支付平台回调中提取实付金额
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m48s
- HandlePaymentCallback 新增 actualPaidAmount int64 参数,Updates 写入 actual_paid_amount
- 微信 V2 回调从 TotalFee 字符串解析分值;V3 直接使用 TotalAmount
- 支付宝回调优先取 buyer_pay_amount(实付),回退 total_amount,元转分使用 math.Round
- 富友回调从 OrderAmt 字符串解析分值
- dispatchWechatCallback 同步增加 paidAmount 参数透传
2026-04-17 11:12:35 +08:00
fa554c3930 feat: 注册 C 端微信 JSSDK 配置接口路由及文档生成
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m35s
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-16 18:38:43 +08:00
1125701329 feat: 新增 C 端微信 JSSDK 签名配置接口 Handler
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-16 18:38:20 +08:00
ed1facc1b8 feat: 公众号服务新增 GetJSSDKConfig 方法及 JSSDK 配置 DTO
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-16 18:37:59 +08:00
0cba6fd6d5 fix: 修复轮询缓存竞态导致流量增量重复计全量的问题
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
getCardWithCache 在缓存 miss 时将 writeCardToCache 由异步协程改为同步调用。
原实现中协程可能在 updateCardCache 之后才被调度执行,将
last_gateway_reading_mb 覆盖回 DB 旧值(0),导致下次轮询
increment = gatewayFlowMB - 0 = 全量,触发套餐流量重复扣减。

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-16 18:33:46 +08:00
94f20ce8c7 配置
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m12s
2026-04-16 17:50:49 +08:00
aef20d2ab1 fix: 修复禁用所有轮询配置重启后再启用无法工作的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m23s
- 新增 RedisPollingConfigChangedChannel 常量,用于跨进程配置变更通知
- ConfigService 注入 Redis,Create/Delete/UpdateStatus 后发布 Pub/Sub 事件
- PollingConfigManager 新增 WatchChanges() 方法,收到通知立即刷新内存缓存
- PollingInitializer 新增 Restart() 方法,支持初始化完成后安全重启(CAS 防并发)
- Worker 订阅配置变更事件,configs 从空变为非空时触发 Initializer.Restart()

根因:启动时所有配置禁用导致分片队列为空,Initializer 是一次性的,
之后启用配置只更新 DB 但不重建队列,调度器无卡可处理
2026-04-16 17:32:43 +08:00
0ec16d4afa fix: 实名轮询防止上游接口故障时误降级已实名状态
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m34s
Gateway 返回 data=null 时 sonic 解析为零值结构体(ICCID="" RealStatus=false),
导致已实名卡被误判为未实名并触发停机。

通过检查响应中 ICCID 是否回填来判断数据有效性:
- ICCID 为空:视为上游接口故障,重新入队,不更新实名状态
- ICCID 有值:响应有效,正常处理(含合法的未实名降级)
2026-04-16 16:48:14 +08:00
7e4c61e6e9 docs: 新增枚举状态字段规范及 Code Review 检查清单
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m28s
Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-16 15:58:28 +08:00
2d0b4e9bc0 fix: 充值订单列表按当前登录资产过滤,修复越权查看其他资产数据的漏洞
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-16 15:56:43 +08:00
520b126ecf feat: JWT Claims 新增资产字段,登录 token 携带当前资产上下文
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-16 15:55:41 +08:00
5d9be1d7e4 fix: 修正轮询配置多匹配逻辑,支持同卡匹配多个配置并按 priority 合并 interval
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m27s
核心变更:
- MatchConfig 改为 MatchConfigs,返回所有匹配配置
- MergedTaskIntervals 按 task type 合并各配置,选取最高优先级(非 nil 且最小 priority 值)
- hasAnyEnabledInterval 过滤所有 interval 均为 NULL 的配置
- calcInitialDelay 重构为纯函数,接收 interval 参数
- 移除 getEnabledTaskTypes 和 getIntervalByTaskType(被 MergedTaskIntervals 替代)
- scheduler.go 新增心跳 key + 顶层 panic recovery + Init 完成守卫
- initializer.go 批量失败日志升级为 Error,逐条检查 Pipeline 命令错误
- 数据迁移:禁用 id=29 的轮询配置(所有 interval 均为 NULL)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 14:27:47 +08:00
5065d925ad fix: 修正套餐激活和时间字段nullable问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m33s
核心变更:
1. Model层时间字段改为*time.Time并设为nullable
   - PackageUsage.ActivatedAt/ExpiresAt
   - PersonalCustomerDevice/ICCID/Phone.LastUsedAt/VerifiedAt

2. 数据库迁移:
   - activated_at/expires_at列移除NOT NULL约束
   - 清洗零值记录(status=0且activated_at<'2000-01-01')

3. 新增ActivateSpecificPackage方法:精准激活指定套餐,
   修复HandlePackageQueueActivation从"查找过期包"改为直接激活payload指定套餐

4. 新增孤儿套餐恢复扫描:Worker启动或每次套餐检查时,
   自动发现并恢复无status=1主套餐的孤儿载体

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-16 11:14:39 +08:00
97d6319b64 fix: 修正 HasValidByCarrier 有效套餐定义,仅 status=Active 才算有效
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m43s
修复Bug:原代码将 Pending(0) 也算作有效套餐,导致待生效套餐的卡
不会被触发停机指令。现改为仅生效中(Active)才算有效套餐。

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-04-15 18:14:50 +08:00
0a27a78d97 fix: 修正强充订单状态映射错误,0-based DB 常量正确映射为 1-based 客户端状态
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 48s
Ultraworked with [Sisyphus](https://github.com/code-yeongyu/oh-my-openagent)

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 14:58:37 +08:00
fc995187df 输出日志
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 49s
2026-04-15 14:11:42 +08:00
8bf1282378 归档: add-polling-card-status-with-verbose-log,同步主规范
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m24s
change 目录归档至 openspec/changes/archive/2026-04-15-add-polling-card-status-with-verbose-log/
新建主规范 openspec/specs/polling-card-status-task/spec.md(6个需求)
追加 verbose log 需求至 openspec/specs/polling-task-handlers/spec.md(4个新需求)
protect handler 规范更新字段名:network_status → network_status_at_check + action_taken

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:23:05 +08:00
854330a4b4 docs: 更新 OpenAPI 文档,新增卡状态检查间隔字段
PollingConfig 请求/响应结构中添加 card_status_check_interval 字段说明

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:55 +08:00
e40ad462e1 feat: 注册卡状态轮询 Worker Handler
registerPollingHandlers 创建并注册 PollingCardStatusHandler
日志更新为 realname/carddata/package/protect/card_status

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:49 +08:00
647d78309d feat: 将卡状态任务类型接入队列管理、生命周期和初始化器
queue_manager.go: allTaskTypes 追加 TaskTypePollingCardStatus,注释更正为5个队列
lifecycle_service.go: getEnabledTaskTypes 和 calcInitialDelay 新增 card_status 条件
initializer.go: initBatch 新增 CardStatusCheckInterval 块,以 LastCardStatusCheckAt 为基准写入分片队列

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:42 +08:00
c7f486ae09 feat: 新增 PollingCardStatusHandler 及工具函数卡状态支持
polling_cardstatus_handler.go: 新文件,实现卡开停机状态轮询
  - Gateway QueryCardStatus → 停机/0,其他/1
  - 状态变化时写 DB + 更新缓存 + 触发 EvaluateAndAct
  - verbose log 位于 gateway 块内
polling_utils.go: getIntervalByTaskType 新增 TaskTypePollingCardStatus case

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:34 +08:00
71559547b6 feat: 为四种现有轮询 Handler 添加 verbose log 输出
realname: verbose log 移入 gateway 块内,避免 gateway=nil 时输出零值
carddata: 添加 && h.gateway != nil 门卫,确保仅 gateway 查询成功时输出
package: verbose log 在 freshCard 加载后、EvaluateAndAct 之前输出
protect: 引入 actionTaken 变量,字段名 network_status → network_status_at_check,新增 action_taken

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:25 +08:00
6292443f69 feat: PollingBase 新增 verboseLog 支持,Worker 传入配置
polling_base.go: PollingBase.verboseLog bool 字段,NewPollingBase 新增参数
main.go: cfg.Polling.VerboseLog 作为第6个参数传入 NewPollingBase

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:16 +08:00
97c196b7c6 feat: PollingConfig DTO 和 Service 支持卡状态检查间隔
dto: 新增 CardStatusCheckInterval 字段(validate min=30,description 含枚举说明)
config_service.go: 创建/更新/响应映射均支持 CardStatusCheckInterval

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:09 +08:00
cb328f3ec2 feat: PollingConfig 和 IotCard 模型新增卡状态检查字段
polling.go: CardStatusCheckInterval *int(card_status_check_interval)
iot_card.go: LastCardStatusCheckAt *time.Time(last_card_status_check_at)

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:22:01 +08:00
9992e82a3c feat: 新增卡状态轮询任务类型常量及 verbose_log 配置
constants.go: 新增 TaskTypePollingCardStatus = "polling:card_status"
config.go: 新增 PollingConfig struct 含 VerboseLog bool
config.yaml: polling.verbose_log 默认 false

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:21:43 +08:00
e2003eb9e3 feat: 新增卡状态检查轮询数据库迁移
tb_polling_config 新增 card_status_check_interval INT NULL
tb_iot_card 新增 last_card_status_check_at TIMESTAMPTZ NULL
含 IF NOT EXISTS 保证幂等,含完整回滚文件

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

Co-authored-by: Sisyphus <clio-agent@sisyphuslabs.ai>
2026-04-15 12:21:21 +08:00
71048e31d4 更换新的上游域名
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m15s
2026-04-15 11:21:14 +08:00
4a1e44f9e1 提案
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-04-15 11:17:23 +08:00
dd408fcdca 日志输出 2026-04-15 11:17:09 +08:00
729 changed files with 73579 additions and 8157 deletions

View File

@@ -0,0 +1,49 @@
---
name: caveman
description: >
Ultra-compressed communication mode. Cuts token usage ~75% by dropping
filler, articles, and pleasantries while keeping full technical accuracy.
Use when user says "caveman mode", "talk like caveman", "use caveman",
"less tokens", "be brief", or invokes /caveman.
---
Respond terse like smart caveman. All technical substance stay. Only fluff die.
## Persistence
ACTIVE EVERY RESPONSE once triggered. No revert after many turns. No filler drift. Still active if unsure. Off only when user says "stop caveman" or "normal mode".
## Rules
Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). Abbreviate common terms (DB/auth/config/req/res/fn/impl). Strip conjunctions. Use arrows for causality (X -> Y). One word when one word enough.
Technical terms stay exact. Code blocks unchanged. Errors quoted exact.
Pattern: `[thing] [action] [reason]. [next step].`
Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..."
Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:"
### Examples
**"Why React component re-render?"**
> Inline obj prop -> new ref -> re-render. `useMemo`.
**"Explain database connection pooling."**
> Pool = reuse DB conn. Skip handshake -> fast under load.
## Auto-Clarity Exception
Drop caveman temporarily for: security warnings, irreversible action confirmations, multi-step sequences where fragment order risks misread, user asks to clarify or repeats question. Resume caveman after clear part done.
Example -- destructive op:
> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone.
>
> ```sql
> DROP TABLE users;
> ```
>
> Caveman resume. Verify backup exist first.

View File

@@ -0,0 +1,117 @@
---
name: diagnose
description: Disciplined diagnosis loop for hard bugs and performance regressions. Reproduce → minimise → hypothesise → instrument → fix → regression-test. Use when user says "diagnose this" / "debug this", reports a bug, says something is broken/throwing/failing, or describes a performance regression.
---
# Diagnose
A discipline for hard bugs. Skip phases only when explicitly justified.
When exploring the codebase, use the project's domain glossary to get a clear mental model of the relevant modules, and check ADRs in the area you're touching.
## Phase 1 — Build a feedback loop
**This is the skill.** Everything else is mechanical. If you have a fast, deterministic, agent-runnable pass/fail signal for the bug, you will find the cause — bisection, hypothesis-testing, and instrumentation all just consume that signal. If you don't have one, no amount of staring at code will save you.
Spend disproportionate effort here. **Be aggressive. Be creative. Refuse to give up.**
### Ways to construct one — try them in roughly this order
1. **Failing test** at whatever seam reaches the bug — unit, integration, e2e.
2. **Curl / HTTP script** against a running dev server.
3. **CLI invocation** with a fixture input, diffing stdout against a known-good snapshot.
4. **Headless browser script** (Playwright / Puppeteer) — drives the UI, asserts on DOM/console/network.
5. **Replay a captured trace.** Save a real network request / payload / event log to disk; replay it through the code path in isolation.
6. **Throwaway harness.** Spin up a minimal subset of the system (one service, mocked deps) that exercises the bug code path with a single function call.
7. **Property / fuzz loop.** If the bug is "sometimes wrong output", run 1000 random inputs and look for the failure mode.
8. **Bisection harness.** If the bug appeared between two known states (commit, dataset, version), automate "boot at state X, check, repeat" so you can `git bisect run` it.
9. **Differential loop.** Run the same input through old-version vs new-version (or two configs) and diff outputs.
10. **HITL bash script.** Last resort. If a human must click, drive _them_ with `scripts/hitl-loop.template.sh` so the loop is still structured. Captured output feeds back to you.
Build the right feedback loop, and the bug is 90% fixed.
### Iterate on the loop itself
Treat the loop as a product. Once you have _a_ loop, ask:
- Can I make it faster? (Cache setup, skip unrelated init, narrow the test scope.)
- Can I make the signal sharper? (Assert on the specific symptom, not "didn't crash".)
- Can I make it more deterministic? (Pin time, seed RNG, isolate filesystem, freeze network.)
A 30-second flaky loop is barely better than no loop. A 2-second deterministic loop is a debugging superpower.
### Non-deterministic bugs
The goal is not a clean repro but a **higher reproduction rate**. Loop the trigger 100×, parallelise, add stress, narrow timing windows, inject sleeps. A 50%-flake bug is debuggable; 1% is not — keep raising the rate until it's debuggable.
### When you genuinely cannot build a loop
Stop and say so explicitly. List what you tried. Ask the user for: (a) access to whatever environment reproduces it, (b) a captured artifact (HAR file, log dump, core dump, screen recording with timestamps), or (c) permission to add temporary production instrumentation. Do **not** proceed to hypothesise without a loop.
Do not proceed to Phase 2 until you have a loop you believe in.
## Phase 2 — Reproduce
Run the loop. Watch the bug appear.
Confirm:
- [ ] The loop produces the failure mode the **user** described — not a different failure that happens to be nearby. Wrong bug = wrong fix.
- [ ] The failure is reproducible across multiple runs (or, for non-deterministic bugs, reproducible at a high enough rate to debug against).
- [ ] You have captured the exact symptom (error message, wrong output, slow timing) so later phases can verify the fix actually addresses it.
Do not proceed until you reproduce the bug.
## Phase 3 — Hypothesise
Generate **35 ranked hypotheses** before testing any of them. Single-hypothesis generation anchors on the first plausible idea.
Each hypothesis must be **falsifiable**: state the prediction it makes.
> Format: "If <X> is the cause, then <changing Y> will make the bug disappear / <changing Z> will make it worse."
If you cannot state the prediction, the hypothesis is a vibe — discard or sharpen it.
**Show the ranked list to the user before testing.** They often have domain knowledge that re-ranks instantly ("we just deployed a change to #3"), or know hypotheses they've already ruled out. Cheap checkpoint, big time saver. Don't block on it — proceed with your ranking if the user is AFK.
## Phase 4 — Instrument
Each probe must map to a specific prediction from Phase 3. **Change one variable at a time.**
Tool preference:
1. **Debugger / REPL inspection** if the env supports it. One breakpoint beats ten logs.
2. **Targeted logs** at the boundaries that distinguish hypotheses.
3. Never "log everything and grep".
**Tag every debug log** with a unique prefix, e.g. `[DEBUG-a4f2]`. Cleanup at the end becomes a single grep. Untagged logs survive; tagged logs die.
**Perf branch.** For performance regressions, logs are usually wrong. Instead: establish a baseline measurement (timing harness, `performance.now()`, profiler, query plan), then bisect. Measure first, fix second.
## Phase 5 — Fix + regression test
Write the regression test **before the fix** — but only if there is a **correct seam** for it.
A correct seam is one where the test exercises the **real bug pattern** as it occurs at the call site. If the only available seam is too shallow (single-caller test when the bug needs multiple callers, unit test that can't replicate the chain that triggered the bug), a regression test there gives false confidence.
**If no correct seam exists, that itself is the finding.** Note it. The codebase architecture is preventing the bug from being locked down. Flag this for the next phase.
If a correct seam exists:
1. Turn the minimised repro into a failing test at that seam.
2. Watch it fail.
3. Apply the fix.
4. Watch it pass.
5. Re-run the Phase 1 feedback loop against the original (un-minimised) scenario.
## Phase 6 — Cleanup + post-mortem
Required before declaring done:
- [ ] Original repro no longer reproduces (re-run the Phase 1 loop)
- [ ] Regression test passes (or absence of seam is documented)
- [ ] All `[DEBUG-...]` instrumentation removed (`grep` the prefix)
- [ ] Throwaway prototypes deleted (or moved to a clearly-marked debug location)
- [ ] The hypothesis that turned out correct is stated in the commit / PR message — so the next debugger learns
**Then ask: what would have prevented this bug?** If the answer involves architectural change (no good test seam, tangled callers, hidden coupling) hand off to the `/improve-codebase-architecture` skill with the specifics. Make the recommendation **after** the fix is in, not before — you have more information now than when you started.

View File

@@ -0,0 +1,41 @@
#!/usr/bin/env bash
# Human-in-the-loop reproduction loop.
# Copy this file, edit the steps below, and run it.
# The agent runs the script; the user follows prompts in their terminal.
#
# Usage:
# bash hitl-loop.template.sh
#
# Two helpers:
# step "<instruction>" → show instruction, wait for Enter
# capture VAR "<question>" → show question, read response into VAR
#
# At the end, captured values are printed as KEY=VALUE for the agent to parse.
set -euo pipefail
step() {
printf '\n>>> %s\n' "$1"
read -r -p " [Enter when done] " _
}
capture() {
local var="$1" question="$2" answer
printf '\n>>> %s\n' "$question"
read -r -p " > " answer
printf -v "$var" '%s' "$answer"
}
# --- edit below ---------------------------------------------------------
step "Open the app at http://localhost:3000 and sign in."
capture ERRORED "Click the 'Export' button. Did it throw an error? (y/n)"
capture ERROR_MSG "Paste the error message (or 'none'):"
# --- edit above ---------------------------------------------------------
printf '\n--- Captured ---\n'
printf 'ERRORED=%s\n' "$ERRORED"
printf 'ERROR_MSG=%s\n' "$ERROR_MSG"

View File

@@ -0,0 +1,10 @@
---
name: grill-me
description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me".
---
Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.
Ask the questions one at a time.
If a question can be answered by exploring the codebase, explore the codebase instead.

View File

@@ -0,0 +1,47 @@
# ADR Format
ADRs live in `docs/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc.
Create the `docs/adr/` directory lazily — only when the first ADR is needed.
## Template
```md
# {Short title of the decision}
{1-3 sentences: what's the context, what did we decide, and why.}
```
That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections.
## Optional sections
Only include these when they add genuine value. Most ADRs won't need them.
- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited
- **Considered Options** — only when the rejected alternatives are worth remembering
- **Consequences** — only when non-obvious downstream effects need to be called out
## Numbering
Scan `docs/adr/` for the highest existing number and increment by one.
## When to offer an ADR
All three of these must be true:
1. **Hard to reverse** — the cost of changing your mind later is meaningful
2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?"
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing."
### What qualifies
- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres."
- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP."
- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out.
- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s.
- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate.
- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract."
- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months.

View File

@@ -0,0 +1,60 @@
# CONTEXT.md Format
## Structure
```md
# {Context Name}
{One or two sentence description of what this context is and why it exists.}
## Language
**Order**:
{A one or two sentence description of the term}
_Avoid_: Purchase, transaction
**Invoice**:
A request for payment sent to a customer after delivery.
_Avoid_: Bill, payment request
**Customer**:
A person or organization that places orders.
_Avoid_: Client, buyer, account
```
## Rules
- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others under `_Avoid_`.
- **Keep definitions tight.** One or two sentences max. Define what it IS, not what it does.
- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs.
- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine.
## Single vs multi-context repos
**Single context (most repos):** One `CONTEXT.md` at the repo root.
**Multiple contexts:** A `CONTEXT-MAP.md` at the repo root lists the contexts, where they live, and how they relate to each other:
```md
# Context Map
## Contexts
- [Ordering](./src/ordering/CONTEXT.md) — receives and tracks customer orders
- [Billing](./src/billing/CONTEXT.md) — generates invoices and processes payments
- [Fulfillment](./src/fulfillment/CONTEXT.md) — manages warehouse picking and shipping
## Relationships
- **Ordering → Fulfillment**: Ordering emits `OrderPlaced` events; Fulfillment consumes them to start picking
- **Fulfillment → Billing**: Fulfillment emits `ShipmentDispatched` events; Billing consumes them to generate invoices
- **Ordering ↔ Billing**: Shared types for `CustomerId` and `Money`
```
The skill infers which structure applies:
- If `CONTEXT-MAP.md` exists, read it to find contexts
- If only a root `CONTEXT.md` exists, single context
- If neither exists, create a root `CONTEXT.md` lazily when the first term is resolved
When multiple contexts exist, infer which one the current topic relates to. If unclear, ask.

View File

@@ -0,0 +1,88 @@
---
name: grill-with-docs
description: Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise. Use when user wants to stress-test a plan against their project's language and documented decisions.
---
<what-to-do>
Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.
Ask the questions one at a time, waiting for feedback on each question before continuing.
If a question can be answered by exploring the codebase, explore the codebase instead.
</what-to-do>
<supporting-info>
## Domain awareness
During codebase exploration, also look for existing documentation:
### File structure
Most repos have a single context:
```
/
├── CONTEXT.md
├── docs/
│ └── adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives:
```
/
├── CONTEXT-MAP.md
├── docs/
│ └── adr/ ← system-wide decisions
├── src/
│ ├── ordering/
│ │ ├── CONTEXT.md
│ │ └── docs/adr/ ← context-specific decisions
│ └── billing/
│ ├── CONTEXT.md
│ └── docs/adr/
```
Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed.
## During the session
### Challenge against the glossary
When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?"
### Sharpen fuzzy language
When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things."
### Discuss concrete scenarios
When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts.
### Cross-reference with code
When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?"
### Update CONTEXT.md inline
When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md).
`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else.
### Offer ADRs sparingly
Only offer to create an ADR when all three are true:
1. **Hard to reverse** — the cost of changing your mind later is meaningful
2. **Surprising without context** — a future reader will wonder "why did they do it this way?"
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md).
</supporting-info>

View File

@@ -0,0 +1,15 @@
---
name: handoff
description: Compact the current conversation into a handoff document for another agent to pick up.
argument-hint: "What will the next session be used for?"
---
Write a handoff document summarising the current conversation so a fresh agent can continue the work. Save to the temporary directory of the user's OS - not the current workspace.
Include a "suggested skills" section in the document, which suggests skills that the agent should invoke.
Do not duplicate content already captured in other artifacts (PRDs, plans, ADRs, issues, commits, diffs). Reference them by path or URL instead.
Redact any sensitive information, such as API keys, passwords, or personally identifiable information.
If the user passed arguments, treat them as a description of what the next session will focus on and tailor the doc accordingly.

View File

@@ -0,0 +1,37 @@
# Deepening
How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**.
## Dependency categories
When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam.
### 1. In-process
Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed.
### 2. Local-substitutable
Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface.
### 3. Remote but owned (Ports & Adapters)
Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter.
Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."*
### 4. True external (Mock)
Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter.
## Seam discipline
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection.
- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them.
## Testing strategy: replace, don't layer
- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them.
- Write new tests at the deepened module's interface. The **interface is the test surface**.
- Tests assert on observable outcomes through the interface, not internal state.
- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface.

View File

@@ -0,0 +1,123 @@
# HTML Report Format
The architectural review is rendered as a single self-contained HTML file in the OS temp directory. Tailwind and Mermaid both come from CDNs. Mermaid handles graph-shaped diagrams reliably; hand-built divs and inline SVG handle the more editorial visuals (mass diagrams, cross-sections). Mix the two — don't lean on Mermaid for everything, it'll start to look generic.
## Scaffold
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>Architecture review — {{repo name}}</title>
<script src="https://cdn.tailwindcss.com"></script>
<script type="module">
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
mermaid.initialize({ startOnLoad: true, theme: "neutral", securityLevel: "loose" });
</script>
<style>
/* small custom layer for things Tailwind doesn't cover cleanly:
dashed seam lines, hand-drawn-feeling arrow heads, etc. */
.seam { stroke-dasharray: 4 4; }
.leak { stroke: #dc2626; }
.deep { background: linear-gradient(135deg, #0f172a, #1e293b); }
</style>
</head>
<body class="bg-stone-50 text-slate-900 font-sans">
<main class="max-w-5xl mx-auto px-6 py-12 space-y-12">
<header>...</header>
<section id="candidates" class="space-y-10">...</section>
<section id="top-recommendation">...</section>
</main>
</body>
</html>
```
## Header
Repo name, date, and a compact legend: solid box = module, dashed line = seam, red arrow = leakage, thick dark box = deep module. No introduction paragraph — straight into the candidates.
## Candidate card
The diagrams carry the weight. Prose is sparse, plain, and uses the glossary terms ([LANGUAGE.md](LANGUAGE.md)) without ceremony.
Each candidate is one `<article>`:
- **Title** — short, names the deepening (e.g. "Collapse the Order intake pipeline").
- **Badge row** — recommendation strength (`Strong` = emerald, `Worth exploring` = amber, `Speculative` = slate), plus a tag for the dependency category (`in-process`, `local-substitutable`, `ports & adapters`, `mock`).
- **Files** — monospaced list, `font-mono text-sm`.
- **Before / After diagram** — the centrepiece. Two columns, side by side. See patterns below.
- **Problem** — one sentence. What hurts.
- **Solution** — one sentence. What changes.
- **Wins** — bullets, ≤6 words each. e.g. "Tests hit one interface", "Pricing logic stops leaking", "Delete 4 shallow wrappers".
- **ADR callout** (if applicable) — one line in an amber-tinted box.
No paragraphs of explanation. If the diagram needs a paragraph to be understood, redraw the diagram.
## Diagram patterns
Pick the pattern that fits the candidate. Mix them. Don't make every diagram look the same — variety is part of the point.
### Mermaid graph (the workhorse for dependencies / call flow)
Use a Mermaid `flowchart` or `graph` when the point is "X calls Y calls Z, and look at the mess." Wrap it in a Tailwind-styled card so it doesn't feel parachuted in. Style with classDef to colour leakage edges red and the deep module dark. Sequence diagrams work well for "before: 6 round-trips; after: 1."
```html
<div class="rounded-lg border border-slate-200 bg-white p-4">
<pre class="mermaid">
flowchart LR
A[OrderHandler] --> B[OrderValidator]
B --> C[OrderRepo]
C -.leak.-> D[PricingClient]
classDef leak stroke:#dc2626,stroke-width:2px;
class C,D leak
</pre>
</div>
```
### Hand-built boxes-and-arrows (when Mermaid's layout fights you)
Modules as `<div>`s with borders and labels. Arrows as inline SVG `<line>` or `<path>` elements positioned absolutely over a relative container. Reach for this when you want the "after" diagram to feel like one thick-bordered deep module with greyed-out internals — Mermaid won't render that with the right weight.
### Cross-section (good for layered shallowness)
Stack horizontal bands (`h-12 border-l-4`) to show layers a call passes through. Before: 6 thin layers each doing nothing. After: 1 thick band labelled with the consolidated responsibility.
### Mass diagram (good for "interface as wide as implementation")
Two rectangles per module — one for interface surface area, one for implementation. Before: interface rectangle is nearly as tall as the implementation rectangle (shallow). After: interface rectangle is short, implementation rectangle is tall (deep).
### Call-graph collapse
Before: a tree of function calls rendered as nested boxes. After: the same tree collapsed into one box, with the now-internal calls shown faded inside it.
## Style guidance
- Lean editorial, not corporate-dashboard. Generous whitespace. Serif optional for headings (`font-serif` works well with stone/slate).
- Colour sparingly: one accent (emerald or indigo) plus red for leakage and amber for warnings.
- Keep diagrams ~320px tall so before/after sits comfortably side by side without scrolling.
- Use `text-xs uppercase tracking-wider` for module labels inside diagrams — they should read as schematic, not as UI.
- The only scripts are the Tailwind CDN and the Mermaid ESM import. The report is otherwise static — no app code, no interactivity beyond Mermaid's own rendering.
## Top recommendation section
One larger card. Candidate name, one sentence on why, anchor link to its card. That's it.
## Tone
Plain English, concise — but the architectural nouns and verbs come straight from [LANGUAGE.md](LANGUAGE.md). Concision is not an excuse to drift.
**Use exactly:** module, interface, implementation, depth, deep, shallow, seam, adapter, leverage, locality.
**Never substitute:** component, service, unit (for module) · API, signature (for interface) · boundary (for seam) · layer, wrapper (for module, when you mean module).
**Phrasings that fit the style:**
- "Order intake module is shallow — interface nearly matches the implementation."
- "Pricing leaks across the seam."
- "Deepen: one interface, one place to test."
- "Two adapters justify the seam: HTTP in prod, in-memory in tests."
**Wins bullets** name the gain in glossary terms: *"locality: bugs concentrate in one module"*, *"leverage: one interface, N call sites"*, *"interface shrinks; implementation absorbs the wrappers"*. Don't write *"easier to maintain"* or *"cleaner code"* — those terms aren't in the glossary and don't earn their place.
No hedging, no throat-clearing, no "it's worth noting that…". If a sentence could be a bullet, make it a bullet. If a bullet could be cut, cut it. If a term isn't in [LANGUAGE.md](LANGUAGE.md), reach for one that is before inventing a new one.

View File

@@ -0,0 +1,44 @@
# Interface Design
When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best.
Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**.
## Process
### 1. Frame the problem space
Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:
- The constraints any new interface would need to satisfy
- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md))
- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete
Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel.
### 2. Spawn sub-agents
Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module.
Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint:
- Agent 1: "Minimize the interface — aim for 13 entry points max. Maximise leverage per entry point."
- Agent 2: "Maximise flexibility — support many use cases and extension."
- Agent 3: "Optimise for the most common caller — make the default case trivial."
- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies."
Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language.
Each sub-agent outputs:
1. Interface (types, methods, params — plus invariants, ordering, error modes)
2. Usage example showing how callers use it
3. What the implementation hides behind the seam
4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md))
5. Trade-offs — where leverage is high, where it's thin
### 3. Present and compare
Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**.
After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu.

View File

@@ -0,0 +1,53 @@
# Language
Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point.
## Terms
**Module**
Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice.
_Avoid_: unit, component, service.
**Interface**
Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics.
_Avoid_: API, signature (too narrow — those refer only to the type-level surface).
**Implementation**
What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise.
**Depth**
Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation.
**Seam** _(from Michael Feathers)_
A place where you can alter behaviour without editing in that place. The *location* at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it.
_Avoid_: boundary (overloaded with DDD's bounded context).
**Adapter**
A concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside).
**Leverage**
What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests.
**Locality**
What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere.
## Principles
- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface.
- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep.
- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape.
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it.
## Relationships
- A **Module** has exactly one **Interface** (the surface it presents to callers and tests).
- **Depth** is a property of a **Module**, measured against its **Interface**.
- A **Seam** is where a **Module**'s **Interface** lives.
- An **Adapter** sits at a **Seam** and satisfies the **Interface**.
- **Depth** produces **Leverage** for callers and **Locality** for maintainers.
## Rejected framings
- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead.
- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know.
- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**.

View File

@@ -0,0 +1,81 @@
---
name: improve-codebase-architecture
description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable.
---
# Improve Codebase Architecture
Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability.
## Glossary
Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md).
- **Module** — anything with an interface and an implementation (function, class, package, slice).
- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature.
- **Implementation** — the code inside.
- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation.
- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.")
- **Adapter** — a concrete thing satisfying an interface at a seam.
- **Leverage** — what callers get from depth.
- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place.
Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list):
- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep.
- **The interface is the test surface.**
- **One adapter = hypothetical seam. Two adapters = real seam.**
This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate.
## Process
### 1. Explore
Read the project's domain glossary and any ADRs in the area you're touching first.
Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction:
- Where does understanding one concept require bouncing between many small modules?
- Where are modules **shallow** — interface nearly as complex as the implementation?
- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)?
- Where do tightly-coupled modules leak across their seams?
- Which parts of the codebase are untested, or hard to test through their current interface?
Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want.
### 2. Present candidates as an HTML report
Write a self-contained HTML file to the OS temp directory so nothing lands in the repo. Resolve the temp dir from `$TMPDIR`, falling back to `/tmp` (or `%TEMP%` on Windows), and write to `<tmpdir>/architecture-review-<timestamp>.html` so each run gets a fresh file. Open it for the user — `xdg-open <path>` on Linux, `open <path>` on macOS, `start <path>` on Windows — and tell them the absolute path.
The report uses **Tailwind via CDN** for layout and styling, and **Mermaid via CDN** for diagrams where a graph/flow/sequence reliably communicates the structure. Mix Mermaid with hand-crafted CSS/SVG visuals — use Mermaid when relationships are graph-shaped (call graphs, dependencies, sequences), and hand-built divs/SVG when you want something more editorial (mass diagrams, cross-sections, collapse animations). Each candidate gets a **before/after visualisation**. Be visual.
For each candidate, the same template as before, but rendered as a card:
- **Files** — which files/modules are involved
- **Problem** — why the current architecture is causing friction
- **Solution** — plain English description of what would change
- **Benefits** — explained in terms of locality and leverage, and how tests would improve
- **Before / After diagram** — side-by-side, custom-drawn, illustrating the shallowness and the deepening
- **Recommendation strength** — one of `Strong`, `Worth exploring`, `Speculative`, rendered as a badge
End the report with a **Top recommendation** section: which candidate you'd tackle first and why.
**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service."
**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly in the card (e.g. a warning callout: _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids.
See [HTML-REPORT.md](HTML-REPORT.md) for the full HTML scaffold, diagram patterns, and styling guidance.
Do NOT propose interfaces yet. After the file is written, ask the user: "Which of these would you like to explore?"
### 3. Grilling loop
Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive.
Side effects happen inline as decisions crystallize:
- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist.
- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there.
- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md).
- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md).

View File

@@ -0,0 +1,79 @@
# Logic Prototype
A tiny interactive terminal app that lets the user drive a state model by hand. Use this when the question is about **business logic, state transitions, or data shape** — the kind of thing that looks reasonable on paper but only feels wrong once you push it through real cases.
## When this is the right shape
- "I'm not sure if this state machine handles the edge case where X then Y."
- "Does this data model actually let me represent the case where..."
- "I want to feel out what the API should look like before writing it."
- Anything where the user wants to **press buttons and watch state change**.
If the question is "what should this look like" — wrong branch. Use [UI.md](UI.md).
## Process
### 1. State the question
Before writing code, write down what state model and what question you're prototyping. One paragraph, in the prototype's README or a comment at the top of the file. A logic prototype that answers the wrong question is pure waste — make the question explicit so it can be checked later, whether the user is watching now or returning to it AFK.
### 2. Pick the language
Use whatever the host project uses. If the project has no obvious runtime (e.g. a docs repo), ask.
Match the project's existing conventions for tooling — don't add a new package manager or runtime just for the prototype.
### 3. Isolate the logic in a portable module
Put the actual logic — the bit that's answering the question — behind a small, pure interface that could be lifted out and dropped into the real codebase later. The TUI around it is throwaway; the logic module shouldn't be.
The right shape depends on the question:
- **A pure reducer** — `(state, action) => state`. Good when actions are discrete events and state is a single value.
- **A state machine** — explicit states and transitions. Good when "which actions are even legal right now" is part of the question.
- **A small set of pure functions** over a plain data type. Good when there's no implicit current state — just transformations.
- **A class or module with a clear method surface** when the logic genuinely owns ongoing internal state.
Pick whichever shape best fits the question being asked, *not* whichever is easiest to wire to a TUI. Keep it pure: no I/O, no terminal code, no `console.log` for control flow. The TUI imports it and calls into it; nothing flows the other direction.
This is what makes the prototype useful past its own lifetime. When the question's been answered, the validated reducer / machine / function set can be lifted into the real module — the TUI shell gets deleted.
### 4. Build the smallest TUI that exposes the state
Build it as a **lightweight TUI** — on every tick, clear the screen (`console.clear()` / `print("\033[2J\033[H")` / equivalent) and re-render the whole frame. The user should always see one stable view, not an ever-growing scrollback.
Each frame has two parts, in this order:
1. **Current state**, pretty-printed and diff-friendly (one field per line, or formatted JSON). Use **bold** for field names or section headers and **dim** for less important context (timestamps, IDs, derived values). Native ANSI escape codes are fine — `\x1b[1m` bold, `\x1b[2m` dim, `\x1b[0m` reset. No need to pull in a styling library unless one is already in the project.
2. **Keyboard shortcuts**, listed at the bottom: `[a] add user [d] delete user [t] tick clock [q] quit`. Bold the key, dim the description, or vice-versa — whatever reads cleanly.
Behaviour:
1. **Initialise state** — a single in-memory object/struct. Render the first frame on start.
2. **Read one keystroke (or one line)** at a time, dispatch to a handler that mutates state.
3. **Re-render** the full frame after every action — don't append, replace.
4. **Loop until quit.**
The whole frame should fit on one screen.
### 5. Make it runnable in one command
Add a script to the project's existing task runner (`package.json` scripts, `Makefile`, `justfile`, `pyproject.toml`). The user should run `pnpm run <prototype-name>` or equivalent — never need to remember a path.
If the host project has no task runner, just put the command at the top of the prototype's README.
### 6. Hand it over
Give the user the run command. They'll drive it themselves; the interesting moments are when they say "wait, that shouldn't be possible" or "huh, I assumed X would be different" — those are the bugs in the _idea_, which is the whole point. If they want new actions added, add them. Prototypes evolve.
### 7. Capture the answer
When the prototype has done its job, the answer to the question is the only thing worth keeping. If the user is around, ask what it taught them. If not, leave a `NOTES.md` next to the prototype so the answer can be filled in (or filled in by you, if you've watched the session) before the prototype gets deleted.
## Anti-patterns
- **Don't add tests.** A prototype that needs tests is no longer a prototype.
- **Don't wire it to the real database.** Use an in-memory store unless the question is specifically about persistence.
- **Don't generalise.** No "what if we wanted to support X later." The prototype answers one question.
- **Don't blur the logic and the TUI together.** If the reducer / state machine references `console.log`, prompts, or terminal escape codes, it's no longer portable. Keep the TUI as a thin shell over a pure module.
- **Don't ship the TUI shell into production.** The shell is optimised for being driven by hand from a terminal. The logic module behind it is the bit worth keeping.

View File

@@ -0,0 +1,30 @@
---
name: prototype
description: Build a throwaway prototype to flesh out a design before committing to it. Routes between two branches — a runnable terminal app for state/business-logic questions, or several radically different UI variations toggleable from one route. Use when the user wants to prototype, sanity-check a data model or state machine, mock up a UI, explore design options, or says "prototype this", "let me play with it", "try a few designs".
---
# Prototype
A prototype is **throwaway code that answers a question**. The question decides the shape.
## Pick a branch
Identify which question is being answered — from the user's prompt, the surrounding code, or by asking if the user is around:
- **"Does this logic / state model feel right?"** → [LOGIC.md](LOGIC.md). Build a tiny interactive terminal app that pushes the state machine through cases that are hard to reason about on paper.
- **"What should this look like?"** → [UI.md](UI.md). Generate several radically different UI variations on a single route, switchable via a URL search param and a floating bottom bar.
The two branches produce very different artifacts — getting this wrong wastes the whole prototype. If the question is genuinely ambiguous and the user isn't reachable, default to whichever branch better matches the surrounding code (a backend module → logic; a page or component → UI) and state the assumption at the top of the prototype.
## Rules that apply to both
1. **Throwaway from day one, and clearly marked as such.** Locate the prototype code close to where it will actually be used (next to the module or page it's prototyping for) so context is obvious — but name it so a casual reader can see it's a prototype, not production. For throwaway UI routes, obey whatever routing convention the project already uses; don't invent a new top-level structure.
2. **One command to run.** Whatever the project's existing task runner supports — `pnpm <name>`, `python <path>`, `bun <path>`, etc. The user must be able to start it without thinking.
3. **No persistence by default.** State lives in memory. Persistence is the thing the prototype is _checking_, not something it should depend on. If the question explicitly involves a database, hit a scratch DB or a local file with a clear "PROTOTYPE — wipe me" name.
4. **Skip the polish.** No tests, no error handling beyond what makes the prototype _runnable_, no abstractions. The point is to learn something fast and then delete it.
5. **Surface the state.** After every action (logic) or on every variant switch (UI), print or render the full relevant state so the user can see what changed.
6. **Delete or absorb when done.** When the prototype has answered its question, either delete it or fold the validated decision into the real code — don't leave it rotting in the repo.
## When done
The _answer_ is the only thing worth keeping from a prototype. Capture it somewhere durable (commit message, ADR, issue, or a `NOTES.md` next to the prototype) along with the question it was answering. If the user is around, that capture is a quick conversation; if not, leave the placeholder so they (or you, on the next pass) can fill in the verdict before deleting the prototype.

View File

@@ -0,0 +1,112 @@
# UI Prototype
Generate **several radically different UI variations** on a single route, switchable from a floating bottom bar. The user flips between variants in the browser, picks one (or steals bits from each), then throws the rest away.
If the question is about logic/state rather than what something looks like — wrong branch. Use [LOGIC.md](LOGIC.md).
## When this is the right shape
- "What should this page look like?"
- "I want to see a few options for this dashboard before committing."
- "Try a different layout for the settings screen."
- Any time the user would otherwise spend a day picking between three vague mockups in their head.
## Two sub-shapes — strongly prefer sub-shape A
A UI prototype is much easier to judge when it's **butting up against the rest of the app** — real header, real sidebar, real data, real density. A throwaway route on its own is a vacuum: every variant looks fine in isolation. Default to sub-shape A whenever there's a plausible existing page to host the variants. Only reach for sub-shape B if the prototype genuinely has no nearby home.
### Sub-shape A — adjustment to an existing page (preferred)
The route already exists. Variants are rendered **on the same route**, gated by a `?variant=` URL search param. The existing data fetching, params, and auth all stay — only the rendering swaps. This is the default; pick it unless there's a specific reason not to.
If the prototype is for something that doesn't yet have a page but *would naturally live inside one* (a new section of the dashboard, a new card on the settings screen, a new step in an existing flow) — that's still sub-shape A. Mount the variants inside the host page.
### Sub-shape B — a new page (last resort)
Only use this when the thing being prototyped genuinely has no existing page to live inside — e.g. an entirely new top-level surface, or a flow that can't be embedded anywhere sensible.
Create a **throwaway route** following whatever routing convention the project already uses — don't invent a new top-level structure. Name it so it's obviously a prototype (e.g. include the word `prototype` in the path or filename). Same `?variant=` pattern.
Before committing to sub-shape B, sanity-check: is there really no existing page this could be embedded in? An empty route hides design problems that a populated one would expose.
In both sub-shapes the floating bottom bar is identical.
## Process
### 1. State the question and pick N
Default to **3 variants**. More than 5 stops being radically different and starts being noise — cap there.
Write down the plan in one line, in the prototype's location or a top-of-file comment:
> "Three variants of the settings page, switchable via `?variant=`, on the existing `/settings` route."
This works whether the user is here to push back or not.
### 2. Generate radically different variants
Draft each variant. Hold each one to:
- The page's purpose and the data it has access to.
- The project's component library / styling system (TailwindCSS, shadcn, MUI, plain CSS, whatever).
- A clear exported component name, e.g. `VariantA`, `VariantB`, `VariantC`.
Variants must be **structurally different** — different layout, different information hierarchy, different primary affordance, not just different colours. Three slightly-tweaked card grids isn't a UI prototype, it's wallpaper. If two drafts come out too similar, redo one with explicit "do not use a card grid" guidance.
### 3. Wire them together
Create a single switcher component on the route:
```tsx
// pseudo-code — adapt to the project's framework
const variant = searchParams.get('variant') ?? 'A';
return (
<>
{variant === 'A' && <VariantA {...data} />}
{variant === 'B' && <VariantB {...data} />}
{variant === 'C' && <VariantC {...data} />}
<PrototypeSwitcher variants={['A','B','C']} current={variant} />
</>
);
```
For sub-shape A (existing page): keep all the existing data fetching above the switcher; only the rendered subtree changes per variant.
For sub-shape B (new page): the throwaway route under `/prototype/<name>` mounts the same switcher.
### 4. Build the floating switcher
A small fixed-position bar at the bottom-centre of the screen with three pieces:
- **Left arrow** — cycles to the previous variant (wraps around).
- **Variant label** — shows the current variant key and, if the variant exports a name, that name too. e.g. `B — Sidebar layout`.
- **Right arrow** — cycles forward (wraps around).
Behaviour:
- Clicking an arrow updates the URL search param (use the framework's router — `router.replace` on Next, `navigate` on React Router, etc) so the variant is shareable and reload-stable.
- Keyboard: `←` and `→` arrow keys also cycle. Don't intercept arrow keys when an `<input>`, `<textarea>`, or `[contenteditable]` is focused.
- Visually distinct from the page (e.g. high-contrast pill, subtle shadow) so it's obviously not part of the design being evaluated.
- Hidden in production builds — gate on `process.env.NODE_ENV !== 'production'` or an equivalent check, so a stray prototype merge can't ship the bar to users.
Put the switcher in a single shared component so both sub-shapes can reuse it. Locate it wherever shared UI lives in the project.
### 5. Hand it over
Surface the URL (and the `?variant=` keys). The user will flip through whenever they get to it. The interesting feedback is usually **"I want the header from B with the sidebar from C"** — that's the actual design they want.
### 6. Capture the answer and clean up
Once a variant has won, write down which one and why (commit message, ADR, issue, or a `NOTES.md` next to the prototype if running AFK and the user hasn't responded yet). Then:
- **Sub-shape A** — delete the losing variants and the switcher; fold the winner into the existing page.
- **Sub-shape B** — promote the winning variant to a real route, delete the throwaway route and the switcher.
Don't leave variant components or the switcher lying around. They rot fast and confuse the next reader.
## Anti-patterns
- **Variants that differ only in colour or copy.** That's a tweak, not a prototype. Real variants disagree about structure.
- **Sharing too much code between variants.** A shared `<Header>` is fine; a shared `<Layout>` defeats the point. Each variant should be free to throw out the layout.
- **Wiring variants to real mutations.** Read-only prototypes are fine. If a variant needs to mutate, point it at a stub — the question is "what should this look like", not "does the backend work".
- **Promoting the prototype directly to production.** The variant code was written under prototype constraints (no tests, minimal error handling). Rewrite it properly when you fold it in.

View File

@@ -0,0 +1,121 @@
---
name: setup-matt-pocock-skills
description: Sets up an `## Agent skills` block in AGENTS.md/CLAUDE.md and `docs/agents/` so the engineering skills know this repo's issue tracker (GitHub or local markdown), triage label vocabulary, and domain doc layout. Run before first use of `to-issues`, `to-prd`, `triage`, `diagnose`, `tdd`, `improve-codebase-architecture`, or `zoom-out` — or if those skills appear to be missing context about the issue tracker, triage labels, or domain docs.
disable-model-invocation: true
---
# Setup Matt Pocock's Skills
Scaffold the per-repo configuration that the engineering skills assume:
- **Issue tracker** — where issues live (GitHub by default; local markdown is also supported out of the box)
- **Triage labels** — the strings used for the five canonical triage roles
- **Domain docs** — where `CONTEXT.md` and ADRs live, and the consumer rules for reading them
This is a prompt-driven skill, not a deterministic script. Explore, present what you found, confirm with the user, then write.
## Process
### 1. Explore
Look at the current repo to understand its starting state. Read whatever exists; don't assume:
- `git remote -v` and `.git/config` — is this a GitHub repo? Which one?
- `AGENTS.md` and `CLAUDE.md` at the repo root — does either exist? Is there already an `## Agent skills` section in either?
- `CONTEXT.md` and `CONTEXT-MAP.md` at the repo root
- `docs/adr/` and any `src/*/docs/adr/` directories
- `docs/agents/` — does this skill's prior output already exist?
- `.scratch/` — sign that a local-markdown issue tracker convention is already in use
### 2. Present findings and ask
Summarise what's present and what's missing. Then walk the user through the three decisions **one at a time** — present a section, get the user's answer, then move to the next. Don't dump all three at once.
Assume the user does not know what these terms mean. Each section starts with a short explainer (what it is, why these skills need it, what changes if they pick differently). Then show the choices and the default.
**Section A — Issue tracker.**
> Explainer: The "issue tracker" is where issues live for this repo. Skills like `to-issues`, `triage`, `to-prd`, and `qa` read from and write to it — they need to know whether to call `gh issue create`, write a markdown file under `.scratch/`, or follow some other workflow you describe. Pick the place you actually track work for this repo.
Default posture: these skills were designed for GitHub. If a `git remote` points at GitHub, propose that. If a `git remote` points at GitLab (`gitlab.com` or a self-hosted host), propose GitLab. Otherwise (or if the user prefers), offer:
- **GitHub** — issues live in the repo's GitHub Issues (uses the `gh` CLI)
- **GitLab** — issues live in the repo's GitLab Issues (uses the [`glab`](https://gitlab.com/gitlab-org/cli) CLI)
- **Local markdown** — issues live as files under `.scratch/<feature>/` in this repo (good for solo projects or repos without a remote)
- **Other** (Jira, Linear, etc.) — ask the user to describe the workflow in one paragraph; the skill will record it as freeform prose
**Section B — Triage label vocabulary.**
> Explainer: When the `triage` skill processes an incoming issue, it moves it through a state machine — needs evaluation, waiting on reporter, ready for an AFK agent to pick up, ready for a human, or won't fix. To do that, it needs to apply labels (or the equivalent in your issue tracker) that match strings *you've actually configured*. If your repo already uses different label names (e.g. `bug:triage` instead of `needs-triage`), map them here so the skill applies the right ones instead of creating duplicates.
The five canonical roles:
- `needs-triage` — maintainer needs to evaluate
- `needs-info` — waiting on reporter
- `ready-for-agent` — fully specified, AFK-ready (an agent can pick it up with no human context)
- `ready-for-human` — needs human implementation
- `wontfix` — will not be actioned
Default: each role's string equals its name. Ask the user if they want to override any. If their issue tracker has no existing labels, the defaults are fine.
**Section C — Domain docs.**
> Explainer: Some skills (`improve-codebase-architecture`, `diagnose`, `tdd`) read a `CONTEXT.md` file to learn the project's domain language, and `docs/adr/` for past architectural decisions. They need to know whether the repo has one global context or multiple (e.g. a monorepo with separate frontend/backend contexts) so they look in the right place.
Confirm the layout:
- **Single-context** — one `CONTEXT.md` + `docs/adr/` at the repo root. Most repos are this.
- **Multi-context** — `CONTEXT-MAP.md` at the root pointing to per-context `CONTEXT.md` files (typically a monorepo).
### 3. Confirm and edit
Show the user a draft of:
- The `## Agent skills` block to add to whichever of `CLAUDE.md` / `AGENTS.md` is being edited (see step 4 for selection rules)
- The contents of `docs/agents/issue-tracker.md`, `docs/agents/triage-labels.md`, `docs/agents/domain.md`
Let them edit before writing.
### 4. Write
**Pick the file to edit:**
- If `CLAUDE.md` exists, edit it.
- Else if `AGENTS.md` exists, edit it.
- If neither exists, ask the user which one to create — don't pick for them.
Never create `AGENTS.md` when `CLAUDE.md` already exists (or vice versa) — always edit the one that's already there.
If an `## Agent skills` block already exists in the chosen file, update its contents in-place rather than appending a duplicate. Don't overwrite user edits to the surrounding sections.
The block:
```markdown
## Agent skills
### Issue tracker
[one-line summary of where issues are tracked]. See `docs/agents/issue-tracker.md`.
### Triage labels
[one-line summary of the label vocabulary]. See `docs/agents/triage-labels.md`.
### Domain docs
[one-line summary of layout — "single-context" or "multi-context"]. See `docs/agents/domain.md`.
```
Then write the three docs files using the seed templates in this skill folder as a starting point:
- [issue-tracker-github.md](./issue-tracker-github.md) — GitHub issue tracker
- [issue-tracker-gitlab.md](./issue-tracker-gitlab.md) — GitLab issue tracker
- [issue-tracker-local.md](./issue-tracker-local.md) — local-markdown issue tracker
- [triage-labels.md](./triage-labels.md) — label mapping
- [domain.md](./domain.md) — domain doc consumer rules + layout
For "other" issue trackers, write `docs/agents/issue-tracker.md` from scratch using the user's description.
### 5. Done
Tell the user the setup is complete and which engineering skills will now read from these files. Mention they can edit `docs/agents/*.md` directly later — re-running this skill is only necessary if they want to switch issue trackers or restart from scratch.

View File

@@ -0,0 +1,51 @@
# Domain Docs
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
## Before exploring, read these
- **`CONTEXT.md`** at the repo root, or
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.
## File structure
Single-context repo (most repos):
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-event-sourced-orders.md
│ └── 0002-postgres-for-write-model.md
└── src/
```
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
```
/
├── CONTEXT-MAP.md
├── docs/adr/ ← system-wide decisions
└── src/
├── ordering/
│ ├── CONTEXT.md
│ └── docs/adr/ ← context-specific decisions
└── billing/
├── CONTEXT.md
└── docs/adr/
```
## Use the glossary's vocabulary
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
## Flag ADR conflicts
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_

View File

@@ -0,0 +1,22 @@
# Issue tracker: GitHub
Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
## Conventions
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
- **Comment on an issue**: `gh issue comment <number> --body "..."`
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
- **Close**: `gh issue close <number> --comment "..."`
Infer the repo from `git remote -v``gh` does this automatically when run inside a clone.
## When a skill says "publish to the issue tracker"
Create a GitHub issue.
## When a skill says "fetch the relevant ticket"
Run `gh issue view <number> --comments`.

View File

@@ -0,0 +1,23 @@
# Issue tracker: GitLab
Issues and PRDs for this repo live as GitLab issues. Use the [`glab`](https://gitlab.com/gitlab-org/cli) CLI for all operations.
## Conventions
- **Create an issue**: `glab issue create --title "..." --description "..."`. Use a heredoc for multi-line descriptions. Pass `--description -` to open an editor.
- **Read an issue**: `glab issue view <number> --comments`. Use `-F json` for machine-readable output.
- **List issues**: `glab issue list -F json` with appropriate `--label` filters.
- **Comment on an issue**: `glab issue note <number> --message "..."`. GitLab calls comments "notes".
- **Apply / remove labels**: `glab issue update <number> --label "..."` / `--unlabel "..."`. Multiple labels can be comma-separated or by repeating the flag.
- **Close**: `glab issue close <number>`. `glab issue close` does not accept a closing comment, so post the explanation first with `glab issue note <number> --message "..."`, then close.
- **Merge requests**: GitLab calls PRs "merge requests". Use `glab mr create`, `glab mr view`, `glab mr note`, etc. — the same shape as `gh pr ...` with `mr` in place of `pr` and `note`/`--message` in place of `comment`/`--body`.
Infer the repo from `git remote -v``glab` does this automatically when run inside a clone.
## When a skill says "publish to the issue tracker"
Create a GitLab issue.
## When a skill says "fetch the relevant ticket"
Run `glab issue view <number> --comments`.

View File

@@ -0,0 +1,19 @@
# Issue tracker: Local Markdown
Issues and PRDs for this repo live as markdown files in `.scratch/`.
## Conventions
- One feature per directory: `.scratch/<feature-slug>/`
- The PRD is `.scratch/<feature-slug>/PRD.md`
- Implementation issues are `.scratch/<feature-slug>/issues/<NN>-<slug>.md`, numbered from `01`
- Triage state is recorded as a `Status:` line near the top of each issue file (see `triage-labels.md` for the role strings)
- Comments and conversation history append to the bottom of the file under a `## Comments` heading
## When a skill says "publish to the issue tracker"
Create a new file under `.scratch/<feature-slug>/` (creating the directory if needed).
## When a skill says "fetch the relevant ticket"
Read the file at the referenced path. The user will normally pass the path or the issue number directly.

View File

@@ -0,0 +1,15 @@
# Triage Labels
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
| Label in mattpocock/skills | Label in our tracker | Meaning |
| -------------------------- | -------------------- | ---------------------------------------- |
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
| `needs-info` | `needs-info` | Waiting on reporter for more information |
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
| `ready-for-human` | `ready-for-human` | Requires human implementation |
| `wontfix` | `wontfix` | Will not be actioned |
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
Edit the right-hand column to match whatever vocabulary you actually use.

109
.agents/skills/tdd/SKILL.md Normal file
View File

@@ -0,0 +1,109 @@
---
name: tdd
description: Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions "red-green-refactor", wants integration tests, or asks for test-first development.
---
# Test-Driven Development
## Philosophy
**Core principle**: Tests should verify behavior through public interfaces, not implementation details. Code can change entirely; tests shouldn't.
**Good tests** are integration-style: they exercise real code paths through public APIs. They describe _what_ the system does, not _how_ it does it. A good test reads like a specification - "user can checkout with valid cart" tells you exactly what capability exists. These tests survive refactors because they don't care about internal structure.
**Bad tests** are coupled to implementation. They mock internal collaborators, test private methods, or verify through external means (like querying a database directly instead of using the interface). The warning sign: your test breaks when you refactor, but behavior hasn't changed. If you rename an internal function and tests fail, those tests were testing implementation, not behavior.
See [tests.md](tests.md) for examples and [mocking.md](mocking.md) for mocking guidelines.
## Anti-Pattern: Horizontal Slices
**DO NOT write all tests first, then all implementation.** This is "horizontal slicing" - treating RED as "write all tests" and GREEN as "write all code."
This produces **crap tests**:
- Tests written in bulk test _imagined_ behavior, not _actual_ behavior
- You end up testing the _shape_ of things (data structures, function signatures) rather than user-facing behavior
- Tests become insensitive to real changes - they pass when behavior breaks, fail when behavior is fine
- You outrun your headlights, committing to test structure before understanding the implementation
**Correct approach**: Vertical slices via tracer bullets. One test → one implementation → repeat. Each test responds to what you learned from the previous cycle. Because you just wrote the code, you know exactly what behavior matters and how to verify it.
```
WRONG (horizontal):
RED: test1, test2, test3, test4, test5
GREEN: impl1, impl2, impl3, impl4, impl5
RIGHT (vertical):
RED→GREEN: test1→impl1
RED→GREEN: test2→impl2
RED→GREEN: test3→impl3
...
```
## Workflow
### 1. Planning
When exploring the codebase, use the project's domain glossary so that test names and interface vocabulary match the project's language, and respect ADRs in the area you're touching.
Before writing any code:
- [ ] Confirm with user what interface changes are needed
- [ ] Confirm with user which behaviors to test (prioritize)
- [ ] Identify opportunities for [deep modules](deep-modules.md) (small interface, deep implementation)
- [ ] Design interfaces for [testability](interface-design.md)
- [ ] List the behaviors to test (not implementation steps)
- [ ] Get user approval on the plan
Ask: "What should the public interface look like? Which behaviors are most important to test?"
**You can't test everything.** Confirm with the user exactly which behaviors matter most. Focus testing effort on critical paths and complex logic, not every possible edge case.
### 2. Tracer Bullet
Write ONE test that confirms ONE thing about the system:
```
RED: Write test for first behavior → test fails
GREEN: Write minimal code to pass → test passes
```
This is your tracer bullet - proves the path works end-to-end.
### 3. Incremental Loop
For each remaining behavior:
```
RED: Write next test → fails
GREEN: Minimal code to pass → passes
```
Rules:
- One test at a time
- Only enough code to pass current test
- Don't anticipate future tests
- Keep tests focused on observable behavior
### 4. Refactor
After all tests pass, look for [refactor candidates](refactoring.md):
- [ ] Extract duplication
- [ ] Deepen modules (move complexity behind simple interfaces)
- [ ] Apply SOLID principles where natural
- [ ] Consider what new code reveals about existing code
- [ ] Run tests after each refactor step
**Never refactor while RED.** Get to GREEN first.
## Checklist Per Cycle
```
[ ] Test describes behavior, not implementation
[ ] Test uses public interface only
[ ] Test would survive internal refactor
[ ] Code is minimal for this test
[ ] No speculative features added
```

View File

@@ -0,0 +1,33 @@
# Deep Modules
From "A Philosophy of Software Design":
**Deep module** = small interface + lots of implementation
```
┌─────────────────────┐
│ Small Interface │ ← Few methods, simple params
├─────────────────────┤
│ │
│ │
│ Deep Implementation│ ← Complex logic hidden
│ │
│ │
└─────────────────────┘
```
**Shallow module** = large interface + little implementation (avoid)
```
┌─────────────────────────────────┐
│ Large Interface │ ← Many methods, complex params
├─────────────────────────────────┤
│ Thin Implementation │ ← Just passes through
└─────────────────────────────────┘
```
When designing interfaces, ask:
- Can I reduce the number of methods?
- Can I simplify the parameters?
- Can I hide more complexity inside?

View File

@@ -0,0 +1,31 @@
# Interface Design for Testability
Good interfaces make testing natural:
1. **Accept dependencies, don't create them**
```typescript
// Testable
function processOrder(order, paymentGateway) {}
// Hard to test
function processOrder(order) {
const gateway = new StripeGateway();
}
```
2. **Return results, don't produce side effects**
```typescript
// Testable
function calculateDiscount(cart): Discount {}
// Hard to test
function applyDiscount(cart): void {
cart.total -= discount;
}
```
3. **Small surface area**
- Fewer methods = fewer tests needed
- Fewer params = simpler test setup

View File

@@ -0,0 +1,59 @@
# When to Mock
Mock at **system boundaries** only:
- External APIs (payment, email, etc.)
- Databases (sometimes - prefer test DB)
- Time/randomness
- File system (sometimes)
Don't mock:
- Your own classes/modules
- Internal collaborators
- Anything you control
## Designing for Mockability
At system boundaries, design interfaces that are easy to mock:
**1. Use dependency injection**
Pass external dependencies in rather than creating them internally:
```typescript
// Easy to mock
function processPayment(order, paymentClient) {
return paymentClient.charge(order.total);
}
// Hard to mock
function processPayment(order) {
const client = new StripeClient(process.env.STRIPE_KEY);
return client.charge(order.total);
}
```
**2. Prefer SDK-style interfaces over generic fetchers**
Create specific functions for each external operation instead of one generic function with conditional logic:
```typescript
// GOOD: Each function is independently mockable
const api = {
getUser: (id) => fetch(`/users/${id}`),
getOrders: (userId) => fetch(`/users/${userId}/orders`),
createOrder: (data) => fetch('/orders', { method: 'POST', body: data }),
};
// BAD: Mocking requires conditional logic inside the mock
const api = {
fetch: (endpoint, options) => fetch(endpoint, options),
};
```
The SDK approach means:
- Each mock returns one specific shape
- No conditional logic in test setup
- Easier to see which endpoints a test exercises
- Type safety per endpoint

View File

@@ -0,0 +1,10 @@
# Refactor Candidates
After TDD cycle, look for:
- **Duplication** → Extract function/class
- **Long methods** → Break into private helpers (keep tests on public interface)
- **Shallow modules** → Combine or deepen
- **Feature envy** → Move logic to where data lives
- **Primitive obsession** → Introduce value objects
- **Existing code** the new code reveals as problematic

View File

@@ -0,0 +1,61 @@
# Good and Bad Tests
## Good Tests
**Integration-style**: Test through real interfaces, not mocks of internal parts.
```typescript
// GOOD: Tests observable behavior
test("user can checkout with valid cart", async () => {
const cart = createCart();
cart.add(product);
const result = await checkout(cart, paymentMethod);
expect(result.status).toBe("confirmed");
});
```
Characteristics:
- Tests behavior users/callers care about
- Uses public API only
- Survives internal refactors
- Describes WHAT, not HOW
- One logical assertion per test
## Bad Tests
**Implementation-detail tests**: Coupled to internal structure.
```typescript
// BAD: Tests implementation details
test("checkout calls paymentService.process", async () => {
const mockPayment = jest.mock(paymentService);
await checkout(cart, payment);
expect(mockPayment.process).toHaveBeenCalledWith(cart.total);
});
```
Red flags:
- Mocking internal collaborators
- Testing private methods
- Asserting on call counts/order
- Test breaks when refactoring without behavior change
- Test name describes HOW not WHAT
- Verifying through external means instead of interface
```typescript
// BAD: Bypasses interface to verify
test("createUser saves to database", async () => {
await createUser({ name: "Alice" });
const row = await db.query("SELECT * FROM users WHERE name = ?", ["Alice"]);
expect(row).toBeDefined();
});
// GOOD: Verifies through interface
test("createUser makes user retrievable", async () => {
const user = await createUser({ name: "Alice" });
const retrieved = await getUser(user.id);
expect(retrieved.name).toBe("Alice");
});
```

View File

@@ -0,0 +1,35 @@
# GLOSSARY.md Format
`GLOSSARY.md` is the canonical language for this teaching workspace. All explainers, exercises, and learning records should adhere to its terminology. Building it is itself part of learning: compressing a concept into a tight definition is evidence the user understands it.
## Structure
```md
# {Topic} Glossary
{One or two sentence description of the topic this glossary covers.}
## Terms
**Hypertrophy**:
Muscle growth driven by mechanical tension and metabolic stress over repeated training sessions.
_Avoid_: Bulking, getting big
**Progressive overload**:
Systematically increasing the demand on a muscle over time — via load, volume, or intensity.
_Avoid_: Pushing harder, levelling up
**RPE (Rate of Perceived Exertion)**:
A 110 self-rating of how hard a set felt, where 10 is failure and 8 means two reps left in the tank.
_Avoid_: Effort score, intensity rating
```
## Rules
- **Add a term only when the user understands it.** The glossary is a record of compressed knowledge, not a dictionary the user reads to learn. If the user has just been introduced to a concept, wait until they can use it correctly before promoting it here.
- **Be opinionated.** When several words exist for the same concept, pick the best one and list the rest as aliases to avoid. This is how language compresses.
- **Keep definitions tight.** One or two sentences. Define what the term IS, not what it does or how to do it.
- **Use the glossary's own terms inside definitions.** Once a term is in the glossary, prefer it everywhere — including inside other definitions. This is what makes complex terms easier to grasp later.
- **Group under subheadings** when natural clusters emerge (e.g. `## Anatomy`, `## Programming`). A flat list is fine when terms cohere.
- **Flag ambiguities explicitly.** If a term is used loosely in the wider field, note the resolution: "In this workspace, 'set' always means a working set — warm-ups are tracked separately."
- **Revise as understanding deepens.** A definition the user wrote in week one may be wrong by week six. Update in place; do not leave stale entries.

View File

@@ -0,0 +1,46 @@
# Learning Record Format
Learning records live in `./learning-records/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc. Create the directory lazily — only when the first record is written.
They are the teaching equivalent of ADRs: they capture non-obvious lessons, key insights, and stated prior knowledge that will steer future sessions. They are used to calculate the zone of proximal development.
## Template
```md
# {Short title of what was learned or established}
{1-3 sentences: what was learned (or what prior knowledge was established), and why it matters for future sessions.}
```
That is the whole format. A learning record can be a single paragraph. The value is recording _that_ this is now known and _why_ it changes what to teach next — not in filling out sections.
## Optional sections
Only include these when they add genuine value. Most records won't need them.
- **Status** frontmatter (`active | superseded by LR-NNNN`) — useful when an earlier understanding turns out to be wrong and is replaced.
- **Evidence** — how the user demonstrated the understanding (a question answered, an exercise completed, prior experience cited). Useful when the claim might be revisited.
- **Implications** — what this unlocks or rules out for future sessions. Worth recording when non-obvious.
## Numbering
Scan `./learning-records/` for the highest existing number and increment by one.
## When to write a learning record
Write one when any of these is true:
1. **The user demonstrated genuine understanding of something non-trivial** — not just exposure, but evidence they can use the concept correctly. This sets a new floor for what to teach next.
2. **The user disclosed prior knowledge** — "I already know X." Record it so future sessions don't re-teach it. Also record the _depth_ claimed.
3. **A misconception was corrected** — the user previously believed something wrong and now sees why. These are high-value: they predict future stumbling blocks for related topics.
4. **The mission shifted in response to learning** — the user discovered they cared about something different than they thought. Cross-link to [[MISSION.md]] and update it.
### What does _not_ qualify
- Material that was merely covered. Coverage is not learning. Wait for evidence.
- Anything already captured tersely in [[GLOSSARY.md]] as a term definition. Don't duplicate.
- Session-by-session activity logs. Learning records are not a journal — they are decision-grade insights.
## Supersession
When a later record contradicts an earlier one (the user's understanding deepened or corrected), mark the old record `Status: superseded by LR-NNNN` rather than deleting it. The history of how understanding evolved is itself useful signal.

View File

@@ -0,0 +1,31 @@
# MISSION.md Format
`MISSION.md` lives at the workspace root. It captures the _reason_ the user is learning this topic. Every teaching decision — what to teach next, which resources to surface, which exercises to design — should trace back to this document.
## Template
```md
# Mission: {Topic}
## Why
{1-3 sentences. The concrete real-world goal the user is chasing. What changes in their life or work when they have this skill? Avoid abstract framings like "to understand X" — push for the underlying outcome.}
## Success looks like
- {A specific, observable thing the user will be able to do}
- {Another specific thing}
- {…}
## Constraints
- {Time, budget, prior commitments, learning preferences, anything that bounds the approach}
## Out of scope
- {Adjacent topics the user explicitly does not want to chase right now — protects the zone of proximal development}
```
## Rules
- **One mission per workspace.** If the user wants to learn two unrelated things, that is two workspaces.
- **Concrete over abstract.** "Run a half marathon by October" beats "get fitter." "Ship a Rust CLI to my team" beats "learn Rust."
- **Push back on vagueness.** If the user cannot articulate why, interview them before writing anything. A bad mission is worse than no mission.
- **Revise when reality shifts.** Missions change. When the user's goal moves, update this file — don't leave a stale mission steering future sessions.
- **Keep it short.** If `MISSION.md` runs past a screen, it has stopped being a compass and started being a plan.

View File

@@ -0,0 +1,32 @@
# RESOURCES.md Format
`RESOURCES.md` is the curated set of trusted sources for this topic. Knowledge for explainers should be drawn from here, not from parametric guesses. Wisdom comes from the communities listed here.
## Structure
```md
# {Topic} Resources
## Knowledge
- [Book: _The Science and Practice of Strength Training_ — Zatsiorsky & Kraemer](https://example.com)
Foundational text on programming and adaptation. Use for: anything to do with periodisation, recovery, intensity zones.
- [Article: "How Much Should I Train?" — Greg Nuckols (Stronger By Science)](https://example.com)
Evidence-based review of volume landmarks. Use for: weekly set targets per muscle group.
## Wisdom (Communities)
- [r/weightroom](https://reddit.com/r/weightroom)
High-signal subreddit, moderated against bro-science. Use for: programme critique, plateau troubleshooting.
- Local: Tuesday strength class at {gym name}
Use for: real-time coaching feedback on lifts.
```
## Rules
- **High-trust only.** Prefer primary sources, recognised experts, peer-reviewed work, and communities with strong moderation. If a resource is marketing dressed as education, leave it out.
- **Annotate every entry.** A bare link is useless in three months. Add one line: what it covers and when to reach for it.
- **Group by Knowledge / Wisdom.** Mirrors the philosophy in [SKILL.md](./SKILL.md). It is fine for a resource to appear in only one group.
- **Surface gaps explicitly.** If no good resource exists for an area the mission needs, write a `## Gaps` section listing what is missing. This drives future search.
- **Prune ruthlessly.** A resource that turned out to be wrong, shallow, or off-mission should be removed, not buried. Better five sharp sources than thirty mediocre ones.
- **Record community preferences.** If the user has opted out of joining communities, note it here so future sessions don't keep proposing them.

View File

@@ -0,0 +1,131 @@
---
name: teach
description: Teach the user a new skill or concept, within this workspace.
disable-model-invocation: true
argument-hint: "What would you like to learn about?"
---
The user has asked you to teach them something. This is a stateful request - they intend to learn the topic over multiple sessions.
## Teaching Workspace
Treat the current directory as a teaching workspace. The state of their learning is captured in this directory in several files:
- `MISSION.md`: A document capturing the _reason_ the user is interested in the topic. This should be used to ground all teaching. Use the format in [MISSION-FORMAT.md](./MISSION-FORMAT.md).
- `./reference/*.html`: A directory of reference materials. These are the compressed learnings from the lessons - cheat sheets, reference algorithms, syntax, yoga poses, glossaries. They are the raw units of learning. They should be beautiful documents which print out well, and are designed for quick reference.
- `RESOURCES.md`: A list of resources which can be explored to ground your teaching in contextual knowledge, or to acquire knowledge and wisdom. Use the format in [RESOURCES-FORMAT.md](./RESOURCES-FORMAT.md).
- `./learning-records/*.md`: A directory of learning records, which capture what the user has learned. These are loosely equivalent to architectural decision records in software development - they capture non-obvious lessons and key insights that may need to be revised later, or drive future sessions. These should be used to calculate the zone of proximal development. They are titled `0001-<dash-case-name>.md`, where the number increments each time. Use the format in [LEARNING-RECORD-FORMAT.md](./LEARNING-RECORD-FORMAT.md).
- `./lessons/*.html`: A directory of lessons. A **lesson** is a single, self-contained HTML output that teaches one tightly-scoped thing tied to the mission. This is the primary unit of teaching in this workspace.
- `NOTES.md`: A scratchpad for you to jot down user preferences, or working notes.
## Philosophy
To learn at a deep level, the user needs three things:
- **Knowledge**, captured from high-quality, high-trust resources
- **Skills**, acquired through highly-relevant interactive lessons devised by you, based on the knowledge
- **Wisdom**, which comes from interacting with other learners and practitioners
Before the `RESOURCES.md` is well-populated, your focus should be to find high-quality resources which will help the user acquire knowledge. Never trust your parametric knowledge.
Some topics may require more skills than knowledge. Learning more about theoretical physics might be more knowledge-based. For yoga, more skills-based.
### Fluency vs Storage Strength
You should be careful to split between two types of learning:
- **Fluency strength**: in-the-moment retrieval of knowledge
- **Storage strength**: long-term retention of knowledge
Fluency can give the user an illusory sense of mastery, but storage strength is the real goal. Try to design lessons which build long-term retention by desirable difficulty:
- Using retrieval practice (recall from memory)
- Spacing (distributing practice over time)
- Interleaving (mixing up different but related topics in practice - for skills practice only)
## Lessons
A lesson is the main thing you produce — the unit in which knowledge and skills reach the user. Each lesson is one self-contained HTML file, saved to `./lessons/` and titled `0001-<dash-case-name>.html` where the number increments each time.
A lesson should be **beautiful** — clean, readable typography and layout — since the user will return to these later to review. Think Tufte.
The lesson should be short, and completable very quickly. Learners' working memory is very small, and we need to stay within it. But each lesson should give the user a single tangible win that they can build on. It should be directly tied to the mission, and should be in the user's zone of proximal development.
If possible, open the lesson file for the user by running a CLI command.
Each lesson should link via HTML anchors to other lessons and reference documents.
Each lesson should recommend a primary source for the user to read or watch. This should be the most high-quality, high-trust resource you found on the topic.
Each lesson should contain a reminder to ask followup questions to the agent. The agent is their teacher, and can assist with anything that's unclear.
## The Mission
Every lesson should be tied into the mission - the reason that the user is interested in learning about the topic.
If the user is unclear about the mission, or the `MISSION.md` is not populated, your first job should be to question the user on why they want to learn this.
Failing to understand the mission will mean knowledge acquisition is not grounded in real-world goals. Lessons will feel too abstract. You will have no way of judging what the user should do next.
Missions may change as the user develops more skills and knowledge. This is normal - make sure to update the `MISSION.md` and add a learning record to capture the change. Confirm with the user before changing the mission.
## Zone Of Proximal Development
Each lesson, the user should always feel as if they are being challenged 'just enough'.
The user may specify an exact thing they want to learn. If they don't, figure out their zone of proximal development by:
- Reading their `learning-records`
- Figuring out the right thing to teach them based on their mission
- Teach the most relevant thing that fits in their zone of proximal development
## Knowledge
Lessons should be designed around a skill the user is going to learn. The knowledge in the lesson should be only what's required to acquire that skill. You teach the knowledge first, then get the user to practice the skills via an interactive feedback loop.
Knowledge should first be gathered from trusted resources. Use `RESOURCES.md` to keep track of them. Lessons should be littered with citations - links to external resources to back up any claim made. This increases the trustworthiness of the lesson.
For acquiring knowledge, difficulty is the enemy. It eats working memory you need for understanding.
## Skills
If knowledge is all about acquisition, skills are about durability and flexibility. Make the knowledge stick.
For skill acquisition, difficulty is the tool. Effortful retrieval is what builds storage strength. Skills should be taught through interactive lessons. There are several tools at your disposal:
- Interactive lessons, using quizzes and light in-browser tasks
- Lessons which guide the user through a list of real-world steps to take (for instance, yoga poses)
Each of these should be based on a **feedback loop**, where the user receives feedback on their performance. This feedback loop should be as tight as possible, giving feedback immediately - and ideally automatically.
For quizzes, each answer should be exactly the same number of words (and characters, if possible). Don't give the user any clues about the answer through formatting.
## Acquiring Wisdom
Wisdom comes from true real-world interaction - testing your skills outside the learning environment.
When the user asks a question that appears to require wisdom, your default posture should be to attempt to answer - but to ultimately delegate to a **community**.
A community is a place (online or offline) where the user can test their skills in the real world. This might be a forum, a subreddit, a real-world class (budget permitting) or a local interest group.
You should attempt to find high-reputation communities the user can join. If the user expresses a preference that they don't want to join a community, respect it.
## Reference Documents
While creating lessons, you should also create reference documents. Lessons can reference these documents - they are useful for tracking raw units of knowledge useful across lessons.
Lessons will rarely be revisited later - reference documents will be. They should be the compressed essence of the lesson, in a format designed for quick reference.
Some learning topics lend themselves to reference:
- Syntax and code snippets for programming
- Algorithms and flowcharts for processes
- Yoga poses and sequences for yoga
- Exercises and routines for fitness
- Glossaries for any topic with its own nomenclature
Glossaries, in particular, are an essential reference. Once one is created, it should be adhered to in every lesson.
## `NOTES.md`
The user will sometimes express preferences of how they want to be taught, or things you should keep in mind. This is the place to record those preferences, so you can refer back to them when designing lessons or working with the user.

View File

@@ -0,0 +1,83 @@
---
name: to-issues
description: Break a plan, spec, or PRD into independently-grabbable issues on the project issue tracker using tracer-bullet vertical slices. Use when user wants to convert a plan into issues, create implementation tickets, or break down work into issues.
---
# To Issues
Break a plan into independently-grabbable issues using vertical slices (tracer bullets).
The issue tracker and triage label vocabulary should have been provided to you — run `/setup-matt-pocock-skills` if not.
## Process
### 1. Gather context
Work from whatever is already in the conversation context. If the user passes an issue reference (issue number, URL, or path) as an argument, fetch it from the issue tracker and read its full body and comments.
### 2. Explore the codebase (optional)
If you have not already explored the codebase, do so to understand the current state of the code. Issue titles and descriptions should use the project's domain glossary vocabulary, and respect ADRs in the area you're touching.
### 3. Draft vertical slices
Break the plan into **tracer bullet** issues. Each issue is a thin vertical slice that cuts through ALL integration layers end-to-end, NOT a horizontal slice of one layer.
Slices may be 'HITL' or 'AFK'. HITL slices require human interaction, such as an architectural decision or a design review. AFK slices can be implemented and merged without human interaction. Prefer AFK over HITL where possible.
<vertical-slice-rules>
- Each slice delivers a narrow but COMPLETE path through every layer (schema, API, UI, tests)
- A completed slice is demoable or verifiable on its own
- Prefer many thin slices over few thick ones
</vertical-slice-rules>
### 4. Quiz the user
Present the proposed breakdown as a numbered list. For each slice, show:
- **Title**: short descriptive name
- **Type**: HITL / AFK
- **Blocked by**: which other slices (if any) must complete first
- **User stories covered**: which user stories this addresses (if the source material has them)
Ask the user:
- Does the granularity feel right? (too coarse / too fine)
- Are the dependency relationships correct?
- Should any slices be merged or split further?
- Are the correct slices marked as HITL and AFK?
Iterate until the user approves the breakdown.
### 5. Publish the issues to the issue tracker
For each approved slice, publish a new issue to the issue tracker. Use the issue body template below. These issues are considered ready for AFK agents, so publish them with the correct triage label unless instructed otherwise.
Publish issues in dependency order (blockers first) so you can reference real issue identifiers in the "Blocked by" field.
<issue-template>
## Parent
A reference to the parent issue on the issue tracker (if the source was an existing issue, otherwise omit this section).
## What to build
A concise description of this vertical slice. Describe the end-to-end behavior, not layer-by-layer implementation.
Avoid specific file paths or code snippets — they go stale fast. Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it here and note briefly that it came from a prototype. Trim to the decision-rich parts — not a working demo, just the important bits.
## Acceptance criteria
- [ ] Criterion 1
- [ ] Criterion 2
- [ ] Criterion 3
## Blocked by
- A reference to the blocking ticket (if any)
Or "None - can start immediately" if no blockers.
</issue-template>
Do NOT close or modify any parent issue.

View File

@@ -0,0 +1,74 @@
---
name: to-prd
description: Turn the current conversation context into a PRD and publish it to the project issue tracker. Use when user wants to create a PRD from the current context.
---
This skill takes the current conversation context and codebase understanding and produces a PRD. Do NOT interview the user — just synthesize what you already know.
The issue tracker and triage label vocabulary should have been provided to you — run `/setup-matt-pocock-skills` if not.
## Process
1. Explore the repo to understand the current state of the codebase, if you haven't already. Use the project's domain glossary vocabulary throughout the PRD, and respect any ADRs in the area you're touching.
2. Sketch out the seams at which you're going to test the feature. Existing seams should be preferred to new ones. Use the highest seam possible. If new seams are needed, propose them at the highest point you can.
Check with the user that these seams match their expectations.
3. Write the PRD using the template below, then publish it to the project issue tracker. Apply the `ready-for-agent` triage label - no need for additional triage.
<prd-template>
## Problem Statement
The problem that the user is facing, from the user's perspective.
## Solution
The solution to the problem, from the user's perspective.
## User Stories
A LONG, numbered list of user stories. Each user story should be in the format of:
1. As an <actor>, I want a <feature>, so that <benefit>
<user-story-example>
1. As a mobile bank customer, I want to see balance on my accounts, so that I can make better informed decisions about my spending
</user-story-example>
This list of user stories should be extremely extensive and cover all aspects of the feature.
## Implementation Decisions
A list of implementation decisions that were made. This can include:
- The modules that will be built/modified
- The interfaces of those modules that will be modified
- Technical clarifications from the developer
- Architectural decisions
- Schema changes
- API contracts
- Specific interactions
Do NOT include specific file paths or code snippets. They may end up being outdated very quickly.
Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it within the relevant decision and note briefly that it came from a prototype. Trim to the decision-rich parts — not a working demo, just the important bits.
## Testing Decisions
A list of testing decisions that were made. Include:
- A description of what makes a good test (only test external behavior, not implementation details)
- Which modules will be tested
- Prior art for the tests (i.e. similar types of tests in the codebase)
## Out of Scope
A description of the things that are out of scope for this PRD.
## Further Notes
Any further notes about the feature.
</prd-template>

View File

@@ -0,0 +1,168 @@
# Writing Agent Briefs
An agent brief is a structured comment posted on a GitHub issue when it moves to `ready-for-agent`. It is the authoritative specification that an AFK agent will work from. The original issue body and discussion are context — the agent brief is the contract.
## Principles
### Durability over precision
The issue may sit in `ready-for-agent` for days or weeks. The codebase will change in the meantime. Write the brief so it stays useful even as files are renamed, moved, or refactored.
- **Do** describe interfaces, types, and behavioral contracts
- **Do** name specific types, function signatures, or config shapes that the agent should look for or modify
- **Don't** reference file paths — they go stale
- **Don't** reference line numbers
- **Don't** assume the current implementation structure will remain the same
### Behavioral, not procedural
Describe **what** the system should do, not **how** to implement it. The agent will explore the codebase fresh and make its own implementation decisions.
- **Good:** "The `SkillConfig` type should accept an optional `schedule` field of type `CronExpression`"
- **Bad:** "Open src/types/skill.ts and add a schedule field on line 42"
- **Good:** "When a user runs `/triage` with no arguments, they should see a summary of issues needing attention"
- **Bad:** "Add a switch statement in the main handler function"
### Complete acceptance criteria
The agent needs to know when it's done. Every agent brief must have concrete, testable acceptance criteria. Each criterion should be independently verifiable.
- **Good:** "Running `gh issue list --label needs-triage` returns issues that have been through initial classification"
- **Bad:** "Triage should work correctly"
### Explicit scope boundaries
State what is out of scope. This prevents the agent from gold-plating or making assumptions about adjacent features.
## Template
```markdown
## Agent Brief
**Category:** bug / enhancement
**Summary:** one-line description of what needs to happen
**Current behavior:**
Describe what happens now. For bugs, this is the broken behavior.
For enhancements, this is the status quo the feature builds on.
**Desired behavior:**
Describe what should happen after the agent's work is complete.
Be specific about edge cases and error conditions.
**Key interfaces:**
- `TypeName` — what needs to change and why
- `functionName()` return type — what it currently returns vs what it should return
- Config shape — any new configuration options needed
**Acceptance criteria:**
- [ ] Specific, testable criterion 1
- [ ] Specific, testable criterion 2
- [ ] Specific, testable criterion 3
**Out of scope:**
- Thing that should NOT be changed or addressed in this issue
- Adjacent feature that might seem related but is separate
```
## Examples
### Good agent brief (bug)
```markdown
## Agent Brief
**Category:** bug
**Summary:** Skill description truncation drops mid-word, producing broken output
**Current behavior:**
When a skill description exceeds 1024 characters, it is truncated at exactly
1024 characters regardless of word boundaries. This produces descriptions
that end mid-word (e.g. "Use when the user wants to confi").
**Desired behavior:**
Truncation should break at the last word boundary before 1024 characters
and append "..." to indicate truncation.
**Key interfaces:**
- The `SkillMetadata` type's `description` field — no type change needed,
but the validation/processing logic that populates it needs to respect
word boundaries
- Any function that reads SKILL.md frontmatter and extracts the description
**Acceptance criteria:**
- [ ] Descriptions under 1024 chars are unchanged
- [ ] Descriptions over 1024 chars are truncated at the last word boundary
before 1024 chars
- [ ] Truncated descriptions end with "..."
- [ ] The total length including "..." does not exceed 1024 chars
**Out of scope:**
- Changing the 1024 char limit itself
- Multi-line description support
```
### Good agent brief (enhancement)
```markdown
## Agent Brief
**Category:** enhancement
**Summary:** Add `.out-of-scope/` directory support for tracking rejected feature requests
**Current behavior:**
When a feature request is rejected, the issue is closed with a `wontfix` label
and a comment. There is no persistent record of the decision or reasoning.
Future similar requests require the maintainer to recall or search for the
prior discussion.
**Desired behavior:**
Rejected feature requests should be documented in `.out-of-scope/<concept>.md`
files that capture the decision, reasoning, and links to all issues that
requested the feature. When triaging new issues, these files should be
checked for matches.
**Key interfaces:**
- Markdown file format in `.out-of-scope/` — each file should have a
`# Concept Name` heading, a `**Decision:**` line, a `**Reason:**` line,
and a `**Prior requests:**` list with issue links
- The triage workflow should read all `.out-of-scope/*.md` files early
and match incoming issues against them by concept similarity
**Acceptance criteria:**
- [ ] Closing a feature as wontfix creates/updates a file in `.out-of-scope/`
- [ ] The file includes the decision, reasoning, and link to the closed issue
- [ ] If a matching `.out-of-scope/` file already exists, the new issue is
appended to its "Prior requests" list rather than creating a duplicate
- [ ] During triage, existing `.out-of-scope/` files are checked and surfaced
when a new issue matches a prior rejection
**Out of scope:**
- Automated matching (human confirms the match)
- Reopening previously rejected features
- Bug reports (only enhancement rejections go to `.out-of-scope/`)
```
### Bad agent brief
```markdown
## Agent Brief
**Summary:** Fix the triage bug
**What to do:**
The triage thing is broken. Look at the main file and fix it.
The function around line 150 has the issue.
**Files to change:**
- src/triage/handler.ts (line 150)
- src/types.ts (line 42)
```
This is bad because:
- No category
- Vague description ("the triage thing is broken")
- References file paths and line numbers that will go stale
- No acceptance criteria
- No scope boundaries
- No description of current vs desired behavior

View File

@@ -0,0 +1,101 @@
# Out-of-Scope Knowledge Base
The `.out-of-scope/` directory in a repo stores persistent records of rejected feature requests. It serves two purposes:
1. **Institutional memory** — why a feature was rejected, so the reasoning isn't lost when the issue is closed
2. **Deduplication** — when a new issue comes in that matches a prior rejection, the skill can surface the previous decision instead of re-litigating it
## Directory structure
```
.out-of-scope/
├── dark-mode.md
├── plugin-system.md
└── graphql-api.md
```
One file per **concept**, not per issue. Multiple issues requesting the same thing are grouped under one file.
## File format
The file should be written in a relaxed, readable style — more like a short design document than a database entry. Use paragraphs, code samples, and examples to make the reasoning clear and useful to someone encountering it for the first time.
```markdown
# Dark Mode
This project does not support dark mode or user-facing theming.
## Why this is out of scope
The rendering pipeline assumes a single color palette defined in
`ThemeConfig`. Supporting multiple themes would require:
- A theme context provider wrapping the entire component tree
- Per-component theme-aware style resolution
- A persistence layer for user theme preferences
This is a significant architectural change that doesn't align with the
project's focus on content authoring. Theming is a concern for downstream
consumers who embed or redistribute the output.
```ts
// The current ThemeConfig interface is not designed for runtime switching:
interface ThemeConfig {
colors: ColorPalette; // single palette, resolved at build time
fonts: FontStack;
}
```
## Prior requests
- #42 — "Add dark mode support"
- #87 — "Night theme for accessibility"
- #134 — "Dark theme option"
```
### Naming the file
Use a short, descriptive kebab-case name for the concept: `dark-mode.md`, `plugin-system.md`, `graphql-api.md`. The name should be recognizable enough that someone browsing the directory understands what was rejected without opening the file.
### Writing the reason
The reason should be substantive — not "we don't want this" but why. Good reasons reference:
- Project scope or philosophy ("This project focuses on X; theming is a downstream concern")
- Technical constraints ("Supporting this would require Y, which conflicts with our Z architecture")
- Strategic decisions ("We chose to use A instead of B because...")
The reason should be durable. Avoid referencing temporary circumstances ("we're too busy right now") — those aren't real rejections, they're deferrals.
## When to check `.out-of-scope/`
During triage (Step 1: Gather context), read all files in `.out-of-scope/`. When evaluating a new issue:
- Check if the request matches an existing out-of-scope concept
- Matching is by concept similarity, not keyword — "night theme" matches `dark-mode.md`
- If there's a match, surface it to the maintainer: "This is similar to `.out-of-scope/dark-mode.md` — we rejected this before because [reason]. Do you still feel the same way?"
The maintainer may:
- **Confirm** — the new issue gets added to the existing file's "Prior requests" list, then closed
- **Reconsider** — the out-of-scope file gets deleted or updated, and the issue proceeds through normal triage
- **Disagree** — the issues are related but distinct, proceed with normal triage
## When to write to `.out-of-scope/`
Only when an **enhancement** (not a bug) is rejected as `wontfix`. The flow:
1. Maintainer decides a feature request is out of scope
2. Check if a matching `.out-of-scope/` file already exists
3. If yes: append the new issue to the "Prior requests" list
4. If no: create a new file with the concept name, decision, reason, and first prior request
5. Post a comment on the issue explaining the decision and mentioning the `.out-of-scope/` file
6. Close the issue with the `wontfix` label
## Updating or removing out-of-scope files
If the maintainer changes their mind about a previously rejected concept:
- Delete the `.out-of-scope/` file
- The skill does not need to reopen old issues — they're historical records
- The new issue that triggered the reconsideration proceeds through normal triage

View File

@@ -0,0 +1,103 @@
---
name: triage
description: Triage issues through a state machine driven by triage roles. Use when user wants to create an issue, triage issues, review incoming bugs or feature requests, prepare issues for an AFK agent, or manage issue workflow.
---
# Triage
Move issues on the project issue tracker through a small state machine of triage roles.
Every comment or issue posted to the issue tracker during triage **must** start with this disclaimer:
```
> *This was generated by AI during triage.*
```
## Reference docs
- [AGENT-BRIEF.md](AGENT-BRIEF.md) — how to write durable agent briefs
- [OUT-OF-SCOPE.md](OUT-OF-SCOPE.md) — how the `.out-of-scope/` knowledge base works
## Roles
Two **category** roles:
- `bug` — something is broken
- `enhancement` — new feature or improvement
Five **state** roles:
- `needs-triage` — maintainer needs to evaluate
- `needs-info` — waiting on reporter for more information
- `ready-for-agent` — fully specified, ready for an AFK agent
- `ready-for-human` — needs human implementation
- `wontfix` — will not be actioned
Every triaged issue should carry exactly one category role and one state role. If state roles conflict, flag it and ask the maintainer before doing anything else.
These are canonical role names — the actual label strings used in the issue tracker may differ. The mapping should have been provided to you - run `/setup-matt-pocock-skills` if not.
State transitions: an unlabeled issue normally goes to `needs-triage` first; from there it moves to `needs-info`, `ready-for-agent`, `ready-for-human`, or `wontfix`. `needs-info` returns to `needs-triage` once the reporter replies. The maintainer can override at any time — flag transitions that look unusual and ask before proceeding.
## Invocation
The maintainer invokes `/triage` and describes what they want in natural language. Interpret the request and act. Examples:
- "Show me anything that needs my attention"
- "Let's look at #42"
- "Move #42 to ready-for-agent"
- "What's ready for agents to pick up?"
## Show what needs attention
Query the issue tracker and present three buckets, oldest first:
1. **Unlabeled** — never triaged.
2. **`needs-triage`** — evaluation in progress.
3. **`needs-info` with reporter activity since the last triage notes** — needs re-evaluation.
Show counts and a one-line summary per issue. Let the maintainer pick.
## Triage a specific issue
1. **Gather context.** Read the full issue (body, comments, labels, reporter, dates). Parse any prior triage notes so you don't re-ask resolved questions. Explore the codebase using the project's domain glossary, respecting ADRs in the area. Read `.out-of-scope/*.md` and surface any prior rejection that resembles this issue.
2. **Recommend.** Tell the maintainer your category and state recommendation with reasoning, plus a brief codebase summary relevant to the issue. Wait for direction.
3. **Reproduce (bugs only).** Before any grilling, attempt reproduction: read the reporter's steps, trace the relevant code, run tests or commands. Report what happened — successful repro with code path, failed repro, or insufficient detail (a strong `needs-info` signal). A confirmed repro makes a much stronger agent brief.
4. **Grill (if needed).** If the issue needs fleshing out, run a `/grill-with-docs` session.
5. **Apply the outcome:**
- `ready-for-agent` — post an agent brief comment ([AGENT-BRIEF.md](AGENT-BRIEF.md)).
- `ready-for-human` — same structure as an agent brief, but note why it can't be delegated (judgment calls, external access, design decisions, manual testing).
- `needs-info` — post triage notes (template below).
- `wontfix` (bug) — polite explanation, then close.
- `wontfix` (enhancement) — write to `.out-of-scope/`, link to it from a comment, then close ([OUT-OF-SCOPE.md](OUT-OF-SCOPE.md)).
- `needs-triage` — apply the role. Optional comment if there's partial progress.
## Quick state override
If the maintainer says "move #42 to ready-for-agent", trust them and apply the role directly. Confirm what you're about to do (role changes, comment, close), then act. Skip grilling. If moving to `ready-for-agent` without a grilling session, ask whether they want to write an agent brief.
## Needs-info template
```markdown
## Triage Notes
**What we've established so far:**
- point 1
- point 2
**What we still need from you (@reporter):**
- question 1
- question 2
```
Capture everything resolved during grilling under "established so far" so the work isn't lost. Questions must be specific and actionable, not "please provide more info".
## Resuming a previous session
If prior triage notes exist on the issue, read them, check whether the reporter has answered any outstanding questions, and present an updated picture before continuing. Don't re-ask resolved questions.

View File

@@ -0,0 +1,117 @@
---
name: write-a-skill
description: Create new agent skills with proper structure, progressive disclosure, and bundled resources. Use when user wants to create, write, or build a new skill.
---
# Writing Skills
## Process
1. **Gather requirements** - ask user about:
- What task/domain does the skill cover?
- What specific use cases should it handle?
- Does it need executable scripts or just instructions?
- Any reference materials to include?
2. **Draft the skill** - create:
- SKILL.md with concise instructions
- Additional reference files if content exceeds 500 lines
- Utility scripts if deterministic operations needed
3. **Review with user** - present draft and ask:
- Does this cover your use cases?
- Anything missing or unclear?
- Should any section be more/less detailed?
## Skill Structure
```
skill-name/
├── SKILL.md # Main instructions (required)
├── REFERENCE.md # Detailed docs (if needed)
├── EXAMPLES.md # Usage examples (if needed)
└── scripts/ # Utility scripts (if needed)
└── helper.js
```
## SKILL.md Template
```md
---
name: skill-name
description: Brief description of capability. Use when [specific triggers].
---
# Skill Name
## Quick start
[Minimal working example]
## Workflows
[Step-by-step processes with checklists for complex tasks]
## Advanced features
[Link to separate files: See [REFERENCE.md](REFERENCE.md)]
```
## Description Requirements
The description is **the only thing your agent sees** when deciding which skill to load. It's surfaced in the system prompt alongside all other installed skills. Your agent reads these descriptions and picks the relevant skill based on the user's request.
**Goal**: Give your agent just enough info to know:
1. What capability this skill provides
2. When/why to trigger it (specific keywords, contexts, file types)
**Format**:
- Max 1024 chars
- Write in third person
- First sentence: what it does
- Second sentence: "Use when [specific triggers]"
**Good example**:
```
Extract text and tables from PDF files, fill forms, merge documents. Use when working with PDF files or when user mentions PDFs, forms, or document extraction.
```
**Bad example**:
```
Helps with documents.
```
The bad example gives your agent no way to distinguish this from other document skills.
## When to Add Scripts
Add utility scripts when:
- Operation is deterministic (validation, formatting)
- Same code would be generated repeatedly
- Errors need explicit handling
Scripts save tokens and improve reliability vs generated code.
## When to Split Files
Split into separate files when:
- SKILL.md exceeds 100 lines
- Content has distinct domains (finance vs sales schemas)
- Advanced features are rarely needed
## Review Checklist
After drafting, verify:
- [ ] Description includes triggers ("Use when...")
- [ ] SKILL.md under 100 lines
- [ ] No time-sensitive info
- [ ] Consistent terminology
- [ ] Concrete examples included
- [ ] References one level deep

View File

@@ -0,0 +1,7 @@
---
name: zoom-out
description: Tell the agent to zoom out and give broader context or a higher-level perspective. Use when you're unfamiliar with a section of code or need to understand how it fits into the bigger picture.
disable-model-invocation: true
---
I don't know this area of code well. Go up a layer of abstraction. Give me a map of all the relevant modules and callers, using the project's domain glossary vocabulary.

1
.claude/skills/caveman Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/caveman

1
.claude/skills/diagnose Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/diagnose

1
.claude/skills/grill-me Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/grill-me

View File

@@ -0,0 +1 @@
../../.agents/skills/grill-with-docs

1
.claude/skills/handoff Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/handoff

View File

@@ -0,0 +1 @@
../../.agents/skills/improve-codebase-architecture

1
.claude/skills/prototype Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/prototype

View File

@@ -0,0 +1 @@
../../.agents/skills/setup-matt-pocock-skills

View File

@@ -1,260 +0,0 @@
---
name: systematic-debugging
description: 遇到任何 bug、异常行为、报错时必须使用。在提出任何修复方案之前强制执行根因分析流程。适用于 API 报错、数据异常、业务逻辑错误、性能问题等所有技术问题。
---
# 系统化调试方法论
## 铁律
```
没有找到根因,禁止提出任何修复方案。
```
改之前先搞懂为什么坏了。猜测不是调试,验证假设才是。
---
## 什么时候用
**所有技术问题都用这个流程**
- API 接口报错4xx / 5xx
- 业务数据异常(金额不对、状态流转错误)
- 性能问题(接口慢、数据库慢查询)
- 异步任务失败Asynq 任务报错/卡住)
- 构建失败、启动失败
**尤其是以下场景**
- 时间紧迫(越急越不能瞎猜)
- "很简单的问题"(简单问题也有根因)
- 已经试了一次修复但没解决
- 不完全理解为什么出问题
---
## 四阶段流程
必须按顺序完成每个阶段,不可跳过。
### 阶段一:根因调查
**这是最重要的阶段,占整个调试时间的 60%。没完成本阶段,禁止进入阶段二。**
#### 1. 仔细阅读错误信息
- 完整阅读 stack trace不要跳过
- 注意行号、文件路径、错误码
- 很多时候答案就在错误信息里
- 检查 `logs/app.log``logs/access.log` 中的上下文
#### 2. 稳定复现
- 能稳定触发吗?精确的请求参数是什么?
- 用 curl 或 Postman 复现,记录完整的请求和响应
- 不能复现 → 收集更多数据检查日志、Redis 状态、数据库记录),**不要瞎猜**
#### 3. 检查最近改动
- `git diff` / `git log --oneline -10` 看最近改了什么
- 新加了什么依赖?改了什么配置?改了什么 SQL
- 对比改动前后的行为差异
#### 4. 逐层诊断(针对本项目架构)
本项目有明确的分层架构,问题一定出在某一层的边界:
```
请求 → Fiber Middleware → Handler → Service → Store → PostgreSQL/Redis
↑ ↑ ↑ ↑ ↑
认证/限流 参数解析 业务逻辑 SQL/缓存 数据本身
```
**在每个层边界确认数据是否正确**
```go
// Handler 层 — 请求进来的参数对不对?
logger.Info("Handler 收到请求",
zap.Any("params", req),
zap.String("request_id", requestID),
)
// Service 层 — 传给业务逻辑的数据对不对?
logger.Info("Service 开始处理",
zap.Uint("user_id", userID),
zap.Any("input", input),
)
// Store 层 — SQL 查询/写入的数据对不对?
// 开启 GORM Debug 模式查看实际 SQL
db.Debug().Where(...).Find(&result)
// Redis 层 — 缓存的数据对不对?
// 用 redis-cli 直接检查 key 的值
// GET auth:token:{token}
// GET sim:status:{iccid}
```
**跑一次 → 看日志 → 找到断裂的那一层 → 再深入该层排查。**
#### 5. 追踪数据流
如果错误深藏在调用链中:
- 坏数据从哪来的?
- 谁调用了这个函数,传了什么参数?
- 一直往上追,直到找到数据变坏的源头
- **修源头,不修症状**
---
### 阶段二:模式分析
**找到参照物,对比差异。**
#### 1. 找能用的参照
项目里有没有类似的、能正常工作的代码?
| 如果问题在... | 参照物在... |
|-------------|-----------|
| Handler 参数解析 | 其他 Handler 的相同模式 |
| Service 业务逻辑 | 同模块其他方法的实现 |
| Store SQL 查询 | 同 Store 文件中类似的查询 |
| Redis 操作 | `pkg/constants/redis.go` 中的 Key 定义 |
| 异步任务 | `internal/task/` 中其他任务处理器 |
| GORM Callback | `pkg/database/` 中的 callback 实现 |
#### 2. 逐行对比
完整阅读参考代码,不要跳读。列出每一处差异。
#### 3. 不要假设"这个不重要"
小差异经常是 bug 的根因:
- 字段标签 `gorm:"column:xxx"` 拼写不对
- `errors.New()` 用了错误的错误码
- Redis Key 函数参数传反了
- Context 里的 UserID 没取到(中间件没配)
---
### 阶段三:假设和验证
**科学方法:一次只验证一个假设。**
#### 1. 形成单一假设
明确写下:
> "我认为根因是 X因为 Y。验证方法是 Z。"
#### 2. 最小化验证
- 只改一个地方
- 一次只验证一个变量
- 不要同时修多处
#### 3. 验证结果
- 假设成立 → 进入阶段四
- 假设不成立 → 回到阶段一,用新信息重新分析
- **绝对不能在失败的修复上再叠加修复**
#### 4. 三次失败 → 停下来
如果连续 3 次假设都不成立:
**这不是 bug是架构问题。**
- 停止一切修复尝试
- 整理已知信息
- 向用户说明情况,讨论是否需要重构
- 不要再试第 4 次
---
### 阶段四:实施修复
**确认根因后,一次性修好。**
#### 1. 修根因,不修症状
```
❌ 症状修复:在 Handler 里加个 if 把坏数据过滤掉
✅ 根因修复:修 Service 层生成坏数据的逻辑
```
#### 2. 一次只改一个地方
- 不搞"顺手优化"
- 不在修 bug 的同时重构代码
- 修完 bug 就停
#### 3. 验证修复
- `go build ./...` 编译通过
- `lsp_diagnostics` 无新增错误
- 用原来复现 bug 的请求再跑一次,确认修好了
- 用 PostgreSQL MCP 工具检查数据库中的数据状态
#### 4. 清理诊断代码
- 删除阶段一加的临时诊断日志(除非它们本身就该保留)
- 确保没有 `db.Debug()` 残留在代码里
---
## 本项目常见调试场景速查
| 场景 | 首先检查 |
|------|---------|
| API 返回 401 | `logs/access.log` 中该请求的 token → Redis 中 `auth:token:{token}` 是否存在 |
| API 返回 403 | 用户类型是什么 → GORM Callback 自动过滤的条件对不对 → `middleware.CanManageShop()` 的参数 |
| 数据查不到 | GORM 数据权限过滤有没有生效 → `shop_id` / `enterprise_id` 是否正确 → 是否需要 `SkipDataPermission` |
| 金额/余额不对 | 乐观锁 version 字段 → `RowsAffected` 是否为 0 → 并发场景下的锁竞争 |
| 状态流转错误 | `WHERE status = expected` 条件更新 → 状态机是否有遗漏的路径 |
| 异步任务不执行 | Asynq Dashboard → `RedisTaskLockKey` 有没有残留 → Worker 日志 |
| 异步任务重复执行 | `RedisTaskLockKey` 的 TTL → 任务幂等性检查 |
| 分佣计算错误 | 佣金类型(差价/一次性) → 套餐级别的佣金率 → 设备级防重复分佣 |
| 套餐激活异常 | 卡状态 → 实名状态 → 主套餐排队逻辑 → 加油包绑定关系 |
| Redis 缓存不一致 | Key 的 TTL → 缓存更新时机 → 是否有手动 `Del` 清除 |
| 微信支付回调失败 | 签名验证 → 幂等性处理 → 回调 URL 是否可达 |
| GORM 查询慢 | `db.Debug()` 看实际 SQL → 是否 N+1 → 是否缺少索引 |
---
## 红线规则
如果你发现自己在想以下任何一条,**立刻停下来,回到阶段一**
| 想法 | 为什么是错的 |
|------|------------|
| "先快速修一下,回头再查" | 快速修 = 猜测。猜测 = 浪费时间。 |
| "试试改这个看看行不行" | 一次只验证一个假设,不是随机改。 |
| "大概是 X 的问题,我直接改了" | "大概"不是根因。先验证再改。 |
| "这个很简单,不用走流程" | 简单问题走流程只需要 5 分钟。不走流程可能浪费 2 小时。 |
| "我不完全理解但这应该行" | 不理解 = 没找到根因。回阶段一。 |
| "再试一次"(已经失败 2 次) | 3 次失败 = 架构问题。停下来讨论。 |
| "同时改这几个地方应该能修好" | 改多处 = 无法确认哪个是根因。一次只改一处。 |
---
## 常见借口和真相
| 借口 | 真相 |
|------|------|
| "问题很简单,不需要走流程" | 简单问题也有根因。走流程对简单问题只花 5 分钟。 |
| "太紧急了,没时间分析" | 系统化调试比乱猜快 3-5 倍。越急越要走流程。 |
| "先改了验证一下" | 这叫猜测,不叫验证。先确认根因再改。 |
| "我看到问题了,直接修" | 看到症状 ≠ 理解根因。症状修复是技术债。 |
| "改了好几个地方,反正能用了" | 不知道哪个改动修的,下次还会出问题。 |
---
## 快速参考
| 阶段 | 核心动作 | 完成标准 |
|------|---------|---------|
| **一、根因调查** | 读错误日志、复现、检查改动、逐层诊断、追踪数据流 | 能说清楚"因为 X 所以 Y" |
| **二、模式分析** | 找参照代码、逐行对比、列出差异 | 知道正确的应该长什么样 |
| **三、假设验证** | 写下假设、最小改动、单变量验证 | 假设被证实或推翻 |
| **四、实施修复** | 修根因、编译检查、请求验证、清理诊断代码 | bug 消失,无新增问题 |

1
.claude/skills/tdd Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/tdd

1
.claude/skills/teach Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/teach

1
.claude/skills/to-issues Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/to-issues

1
.claude/skills/to-prd Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/to-prd

1
.claude/skills/triage Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/triage

View File

@@ -0,0 +1 @@
../../.agents/skills/write-a-skill

1
.claude/skills/zoom-out Symbolic link
View File

@@ -0,0 +1 @@
../../.agents/skills/zoom-out

View File

@@ -0,0 +1,198 @@
---
name: export-datasource
description: Project-specific guide for implementing, modifying, or reviewing export data sources in junhong_cmp_fiber. Use when Codex needs to add a new export scene, extend filters or dynamic columns, register an export scene, change export task query behavior, or explain/debug the DataSource-based export system.
---
# Export Datasource
Use this skill to work on this project's DataSource-based export system. It covers developer-facing export scene implementation, not one-off manual export operations.
## First Reads
Before editing export code, read the relevant current files:
- `internal/exporter/datasource.go`
- `internal/exporter/query_params.go`
- `internal/exporter/filter_helpers.go`
- `internal/exporter/registry.go`
- Existing scene closest to the new scene:
- `internal/exporter/device_scene.go`
- `internal/exporter/iot_card_scene.go`
- For request/scene validation:
- `internal/model/dto/export_task_dto.go`
- `pkg/constants/constants.go`
- For behavior across worker stages:
- `internal/task/export_dispatch.go`
- `internal/task/export_shard.go`
- `internal/task/export_finalize.go`
Read `references/export-scene-template.md` when adding a new scene or when a concrete code skeleton is useful.
## Mental Model
The framework owns async execution, sharding, file generation, OSS upload, and download URLs. A scene implementation owns only data semantics:
```go
type DataSource interface {
Scene() string
Count(ctx context.Context, params ExportParams) (int, error)
Headers(ctx context.Context, params ExportParams) ([]string, error)
Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error)
}
```
Execution flow:
1. Admin API creates `tb_export_task` and enqueues `export:dispatch`.
2. Dispatch parses `query_json.filters` plus permission snapshot into `ExportParams`.
3. Dispatch calls `Headers` once and stores `query_json.resolved_headers`.
4. Dispatch calls `Count` and creates `tb_export_shard_task` rows with `shard_offset` and `shard_limit`.
5. Shard calls `Fetch`, writes headerless CSV shard files, and uploads them.
6. Finalize downloads shard CSV files in shard order, writes one header row, uploads final CSV or converts CSV to XLSX.
Do not reintroduce keyset cursor logic. New scenes must use offset/limit through `Fetch`.
## Implementation Workflow
1. Add a scene constant in `pkg/constants/constants.go`.
2. Update `internal/model/dto/export_task_dto.go` validation and descriptions for `scene`.
3. Implement `internal/exporter/<scene>_scene.go` with `DataSource`.
4. Register the source in `NewDefaultRegistry`.
5. Update `IsSupportedScene` if it is used by the current code path.
6. Add or update migrations only if the exported domain needs schema/index changes. Do not change export task tables unless the framework contract changes.
7. Build and manually verify. This repository forbids automated tests unless the user explicitly requests them.
## DataSource Rules
- `Scene` must return the constant, not a string literal.
- `Count` and `Fetch` must apply the same filters and permission scope.
- `Headers` defines the exact column contract for the whole task. Dispatch stores it once; shards and finalize reuse it.
- `Fetch` must return rows aligned to `Headers`; the framework pads/truncates as a fallback, but the source should be correct.
- `Fetch` must use stable ordering, normally `ORDER BY id ASC`.
- Return string values only. Format time as `2006-01-02 15:04:05` unless the surrounding scene establishes another convention.
- Use GORM only. Do not use `database/sql`.
- Keep SQL parameters bound through GORM placeholders. Do not concatenate user-controlled values into SQL.
- Keep comments, logs, errors, and documentation in Chinese per project rules.
## Filters And Permissions
Incoming task query shape:
```json
{
"filters": {
"status": 1,
"shop_id": 1
}
}
```
`ParseExportParams` exposes:
- `Filters`: `query_json.filters`
- `ScopeShopIDs`: shop permission snapshot captured at task creation
- `UserType`: creator user type snapshot
Apply permission scope inside each DataSource:
```go
query = applyExportShopScope(query, params, "shop_id")
```
For aliased queries:
```go
query = applyExportShopScope(query, params, "o.shop_id")
```
Use helpers from `filter_helpers.go`:
- `filterInt`
- `filterUint`
- `filterString`
- `filterBool`
- `filterTime`
- `formatOptionalUint`
- `formatOptionalTime`
Do not read live permissions from context inside a DataSource. Export tasks must use the permission snapshot stored at creation time.
## Dynamic Columns
Use dynamic headers only when the task parameters or data require it. If `Headers` changes based on filters, `Fetch` must produce the same shape for every shard under the same `ExportParams`.
Example pattern:
```go
headers := []string{"ID", "ICCID", "状态"}
if filterBool(params.Filters, "with_package") {
headers = append(headers, "套餐名称", "套餐状态")
}
return headers, nil
```
## Join Queries
JOIN-based exports are allowed. Keep these constraints:
- Preserve one output row per intended exported entity unless the scene explicitly exports detail rows.
- If a JOIN can multiply rows, make `Count` match the exported row semantics exactly.
- Use table aliases consistently in filters and scope columns.
- Prefer explicit `Select` into a local row struct for multi-table exports.
- Keep `Order`, `Limit`, and `Offset` on the final query used by `Fetch`.
## User-Facing API Notes
Current API group:
- `POST /api/admin/export-tasks`
- `GET /api/admin/export-tasks`
- `GET /api/admin/export-tasks/:id`
- `POST /api/admin/export-tasks/:id/cancel`
Creation request:
```json
{
"scene": "iot_card",
"format": "csv",
"query": {
"filters": {
"shop_id": 1,
"with_package": true
}
}
}
```
Formats are `csv` and `xlsx`. Final download URLs are returned from task detail after completion.
## Verification
This project forbids automated tests and `_test.go` files unless the user explicitly asks for tests. Use manual verification:
```bash
go build ./internal/exporter/...
go build ./internal/task/...
go build ./...
```
Then create an export task through the API and inspect:
- `tb_export_task.query_json` contains original `filters` and generated `resolved_headers`.
- `tb_export_task.total_rows` matches the filtered query.
- `tb_export_shard_task.shard_offset` and `shard_limit` are filled.
- Shards reach success and final task reaches completed status.
- Downloaded file has one header row, expected row count, correct filtering, and valid CSV/XLSX format.
Use PostgreSQL MCP/manual SQL for data validation when needed.
## Common Mistakes
- Updating only `Fetch` and forgetting `Count`, causing wrong shard planning.
- Returning dynamic rows whose column count does not match `Headers`.
- Filtering by requested `shop_id` without also applying `ScopeShopIDs`.
- Using context/user middleware inside worker DataSource logic.
- Registering the source but forgetting DTO `oneof`, so API rejects the new scene.
- Writing XLSX shard files. Shards should be CSV; finalize handles final format.
- Adding automated tests despite the repository ban.

View File

@@ -0,0 +1,4 @@
interface:
display_name: "导出数据源开发"
short_description: "指导新增和维护项目导出数据源场景"
default_prompt: "Use $export-datasource to add a new export scene for this project."

View File

@@ -0,0 +1,222 @@
# Export Scene Template
Use this reference when adding a new export scene.
## Minimal Checklist
- Add `ExportTaskSceneXxx` in `pkg/constants/constants.go`.
- Add the scene to `CreateExportTaskRequest.Scene` and `ListExportTaskRequest.Scene` validation/description.
- Create `internal/exporter/<scene>_scene.go`.
- Register `NewXxxDataSource(db)` in `internal/exporter/registry.go`.
- Update `IsSupportedScene`.
- Run `gofmt` on changed Go files.
- Run `go build ./internal/exporter/...`, `go build ./internal/task/...`, and `go build ./...`.
- Manually create a task and validate database rows plus downloaded file.
## Skeleton
```go
package exporter
import (
"context"
"strconv"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// XxxDataSource Xxx 导出数据源。
type XxxDataSource struct {
db *gorm.DB
}
// NewXxxDataSource 创建 Xxx 导出数据源。
func NewXxxDataSource(db *gorm.DB) *XxxDataSource {
return &XxxDataSource{db: db}
}
// Scene 返回导出场景编码。
func (s *XxxDataSource) Scene() string {
return constants.ExportTaskSceneXxx
}
// Count 统计 Xxx 导出行数。
func (s *XxxDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.db.WithContext(ctx).Model(&model.Xxx{}), params)
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// Headers 返回 Xxx 导出表头。
func (s *XxxDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
return []string{"ID", "名称", "状态", "店铺ID", "创建时间"}, nil
}
// Fetch 按 offset/limit 查询 Xxx 导出数据。
func (s *XxxDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
var items []model.Xxx
query := s.applyFilters(s.db.WithContext(ctx).Model(&model.Xxx{}), params).
Order("id ASC").
Limit(limit).
Offset(offset)
if err := query.Find(&items).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, []string{
strconv.FormatUint(uint64(item.ID), 10),
item.Name,
strconv.Itoa(item.Status),
formatOptionalUint(item.ShopID),
item.CreatedAt.Format(exportTimeLayout),
})
}
return rows, nil
}
func (s *XxxDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
query = applyExportShopScope(query, params, "shop_id")
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where("status = ?", status)
}
if shopID, ok := filterUint(params.Filters, "shop_id"); ok {
query = query.Where("shop_id = ?", shopID)
}
if start, ok := filterTime(params.Filters, "created_at_start"); ok {
query = query.Where("created_at >= ?", start)
}
if end, ok := filterTime(params.Filters, "created_at_end"); ok {
query = query.Where("created_at <= ?", end)
}
return query
}
```
## JOIN Skeleton
Use this shape for multi-table exports:
```go
type xxxExportRow struct {
ID uint
Name string
Status int
ShopID *uint
ExtraName string
CreatedAt time.Time
}
func (s *XxxDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
var items []xxxExportRow
query := s.applyFilters(s.db.WithContext(ctx).Table("tb_xxx AS x"), params, "x.").
Select(`
x.id,
x.name,
x.status,
x.shop_id,
COALESCE(e.name, '') AS extra_name,
x.created_at
`).
Joins("LEFT JOIN tb_extra AS e ON e.xxx_id = x.id AND e.deleted_at IS NULL").
Where("x.deleted_at IS NULL").
Order("x.id ASC").
Limit(limit).
Offset(offset)
if err := query.Scan(&items).Error; err != nil {
return nil, err
}
rows := make([][]string, 0, len(items))
for _, item := range items {
rows = append(rows, []string{
strconv.FormatUint(uint64(item.ID), 10),
item.Name,
strconv.Itoa(item.Status),
formatOptionalUint(item.ShopID),
item.ExtraName,
item.CreatedAt.Format(exportTimeLayout),
})
}
return rows, nil
}
func (s *XxxDataSource) applyFilters(query *gorm.DB, params ExportParams, prefix string) *gorm.DB {
query = applyExportShopScope(query, params, prefix+"shop_id")
if status, ok := filterInt(params.Filters, "status"); ok {
query = query.Where(prefix+"status = ?", status)
}
return query
}
```
If the JOIN multiplies rows, update `Count` to count the same exported row set. Do not count only the base table unless `Fetch` also returns one row per base record.
## Registry Patch
```go
func NewDefaultRegistry(db *gorm.DB) *Registry {
return NewRegistry(
NewDeviceDataSource(db),
NewIotCardDataSource(db),
NewXxxDataSource(db),
)
}
func IsSupportedScene(scene string) bool {
switch scene {
case constants.ExportTaskSceneDevice,
constants.ExportTaskSceneIotCard,
constants.ExportTaskSceneXxx:
return true
default:
return false
}
}
```
## Manual API Smoke
```bash
curl -X POST 'http://localhost:端口/api/admin/export-tasks' \
-H 'Authorization: Bearer <token>' \
-H 'Content-Type: application/json' \
-d '{
"scene": "xxx",
"format": "csv",
"query": {
"filters": {
"status": 1
}
}
}'
```
Check database:
```sql
SELECT id, scene, format, status, total_rows, total_shards, query_json
FROM tb_export_task
WHERE id = <task_id>;
SELECT shard_no, status, shard_offset, shard_limit, row_count, file_key
FROM tb_export_shard_task
WHERE task_id = <task_id>
ORDER BY shard_no;
```

View File

@@ -1,7 +1,20 @@
[[sources]]
id = "main"
description = "当前项目库"
dsn = "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable"
[[sources]]
id = "legacy_mysql"
description = "老系统 MySQL 库,仅用于 AI 查询和迁移脚本准备"
type = "mysql"
host = "120.79.89.216"
port = 3306
database = "kyhl"
user = "root"
password = "XiWaJ9Eu%go9-2>g!xGp"
sslmode = "disable"
lazy = true
[[tools]]
name = "search_objects"
source = "main"
@@ -11,3 +24,13 @@ name = "execute_sql"
source = "main"
readonly = true # Only allow SELECT, SHOW, DESCRIBE, EXPLAIN
max_rows = 1000 # Limit query results
[[tools]]
name = "search_objects"
source = "legacy_mysql"
[[tools]]
name = "execute_sql"
source = "legacy_mysql"
readonly = true # Only allow SELECT, SHOW, DESCRIBE, EXPLAIN
max_rows = 5000 # Limit query results for migration exploration

25
.gitignore vendored
View File

@@ -85,3 +85,28 @@ docs/admin-openapi.yaml
.opencode/skills/kb-sync
.gitignore
.opencode/skills/defuddle
# CocoIndex Code (ccc)
/.cocoindex_code/
.omx/hud-config.json
.omx/metrics.json
.omx/setup-scope.json
.omx/cache/codebase-map.json
.omx/state/current-task-baseline.json
.omx/state/notify-fallback-authority-owner.json
.omx/state/notify-fallback-state.json
.omx/state/notify-fallback.pid
.omx/state/session.json
.omx/state/subagent-tracking.json
.omx/state/team-leader-nudge.json
.omx/state/tmux-hook-state.json
.omx/state/update-check.json
.omx/state/sessions/omx-1777517355305-tzivrd/AGENTS.md
.omx/state/sessions/omx-1777517355305-tzivrd/hud-state.json
.omx/state/sessions/omx-1777517489966-oo3g37/AGENTS.md
.omx/state/sessions/omx-1777517489966-oo3g37/hud-state.json
.omx/state/sessions/omx-1777517489966-oo3g37/notify-hook-state.json
.omx/state/tmux-extended-keys/private-tmp-tmux-501-default.json
.aider*
# /teach skill 的个人学习工作区,不进入项目提交历史
.claude/teach-workspace/

View File

@@ -1,265 +0,0 @@
---
name: systematic-debugging
description: 遇到任何 bug、异常行为、报错时必须使用。在提出任何修复方案之前强制执行根因分析流程。适用于 API 报错、数据异常、业务逻辑错误、性能问题等所有技术问题。
license: MIT
metadata:
author: junhong
version: "1.0"
source: "adapted from obra/superpowers systematic-debugging"
---
# 系统化调试方法论
## 铁律
```
没有找到根因,禁止提出任何修复方案。
```
改之前先搞懂为什么坏了。猜测不是调试,验证假设才是。
---
## 什么时候用
**所有技术问题都用这个流程**
- API 接口报错4xx / 5xx
- 业务数据异常(金额不对、状态流转错误)
- 性能问题(接口慢、数据库慢查询)
- 异步任务失败Asynq 任务报错/卡住)
- 构建失败、启动失败
**尤其是以下场景**
- 时间紧迫(越急越不能瞎猜)
- "很简单的问题"(简单问题也有根因)
- 已经试了一次修复但没解决
- 不完全理解为什么出问题
---
## 四阶段流程
必须按顺序完成每个阶段,不可跳过。
### 阶段一:根因调查
**这是最重要的阶段,占整个调试时间的 60%。没完成本阶段,禁止进入阶段二。**
#### 1. 仔细阅读错误信息
- 完整阅读 stack trace不要跳过
- 注意行号、文件路径、错误码
- 很多时候答案就在错误信息里
- 检查 `logs/app.log``logs/access.log` 中的上下文
#### 2. 稳定复现
- 能稳定触发吗?精确的请求参数是什么?
- 用 curl 或 Postman 复现,记录完整的请求和响应
- 不能复现 → 收集更多数据检查日志、Redis 状态、数据库记录),**不要瞎猜**
#### 3. 检查最近改动
- `git diff` / `git log --oneline -10` 看最近改了什么
- 新加了什么依赖?改了什么配置?改了什么 SQL
- 对比改动前后的行为差异
#### 4. 逐层诊断(针对本项目架构)
本项目有明确的分层架构,问题一定出在某一层的边界:
```
请求 → Fiber Middleware → Handler → Service → Store → PostgreSQL/Redis
↑ ↑ ↑ ↑ ↑
认证/限流 参数解析 业务逻辑 SQL/缓存 数据本身
```
**在每个层边界确认数据是否正确**
```go
// Handler 层 — 请求进来的参数对不对?
logger.Info("Handler 收到请求",
zap.Any("params", req),
zap.String("request_id", requestID),
)
// Service 层 — 传给业务逻辑的数据对不对?
logger.Info("Service 开始处理",
zap.Uint("user_id", userID),
zap.Any("input", input),
)
// Store 层 — SQL 查询/写入的数据对不对?
// 开启 GORM Debug 模式查看实际 SQL
db.Debug().Where(...).Find(&result)
// Redis 层 — 缓存的数据对不对?
// 用 redis-cli 直接检查 key 的值
// GET auth:token:{token}
// GET sim:status:{iccid}
```
**跑一次 → 看日志 → 找到断裂的那一层 → 再深入该层排查。**
#### 5. 追踪数据流
如果错误深藏在调用链中:
- 坏数据从哪来的?
- 谁调用了这个函数,传了什么参数?
- 一直往上追,直到找到数据变坏的源头
- **修源头,不修症状**
---
### 阶段二:模式分析
**找到参照物,对比差异。**
#### 1. 找能用的参照
项目里有没有类似的、能正常工作的代码?
| 如果问题在... | 参照物在... |
|-------------|-----------|
| Handler 参数解析 | 其他 Handler 的相同模式 |
| Service 业务逻辑 | 同模块其他方法的实现 |
| Store SQL 查询 | 同 Store 文件中类似的查询 |
| Redis 操作 | `pkg/constants/redis.go` 中的 Key 定义 |
| 异步任务 | `internal/task/` 中其他任务处理器 |
| GORM Callback | `pkg/database/` 中的 callback 实现 |
#### 2. 逐行对比
完整阅读参考代码,不要跳读。列出每一处差异。
#### 3. 不要假设"这个不重要"
小差异经常是 bug 的根因:
- 字段标签 `gorm:"column:xxx"` 拼写不对
- `errors.New()` 用了错误的错误码
- Redis Key 函数参数传反了
- Context 里的 UserID 没取到(中间件没配)
---
### 阶段三:假设和验证
**科学方法:一次只验证一个假设。**
#### 1. 形成单一假设
明确写下:
> "我认为根因是 X因为 Y。验证方法是 Z。"
#### 2. 最小化验证
- 只改一个地方
- 一次只验证一个变量
- 不要同时修多处
#### 3. 验证结果
- 假设成立 → 进入阶段四
- 假设不成立 → 回到阶段一,用新信息重新分析
- **绝对不能在失败的修复上再叠加修复**
#### 4. 三次失败 → 停下来
如果连续 3 次假设都不成立:
**这不是 bug是架构问题。**
- 停止一切修复尝试
- 整理已知信息
- 向用户说明情况,讨论是否需要重构
- 不要再试第 4 次
---
### 阶段四:实施修复
**确认根因后,一次性修好。**
#### 1. 修根因,不修症状
```
❌ 症状修复:在 Handler 里加个 if 把坏数据过滤掉
✅ 根因修复:修 Service 层生成坏数据的逻辑
```
#### 2. 一次只改一个地方
- 不搞"顺手优化"
- 不在修 bug 的同时重构代码
- 修完 bug 就停
#### 3. 验证修复
- `go build ./...` 编译通过
- `lsp_diagnostics` 无新增错误
- 用原来复现 bug 的请求再跑一次,确认修好了
- 用 PostgreSQL MCP 工具检查数据库中的数据状态
#### 4. 清理诊断代码
- 删除阶段一加的临时诊断日志(除非它们本身就该保留)
- 确保没有 `db.Debug()` 残留在代码里
---
## 本项目常见调试场景速查
| 场景 | 首先检查 |
|------|---------|
| API 返回 401 | `logs/access.log` 中该请求的 token → Redis 中 `auth:token:{token}` 是否存在 |
| API 返回 403 | 用户类型是什么 → GORM Callback 自动过滤的条件对不对 → `middleware.CanManageShop()` 的参数 |
| 数据查不到 | GORM 数据权限过滤有没有生效 → `shop_id` / `enterprise_id` 是否正确 → 是否需要 `SkipDataPermission` |
| 金额/余额不对 | 乐观锁 version 字段 → `RowsAffected` 是否为 0 → 并发场景下的锁竞争 |
| 状态流转错误 | `WHERE status = expected` 条件更新 → 状态机是否有遗漏的路径 |
| 异步任务不执行 | Asynq Dashboard → `RedisTaskLockKey` 有没有残留 → Worker 日志 |
| 异步任务重复执行 | `RedisTaskLockKey` 的 TTL → 任务幂等性检查 |
| 分佣计算错误 | 佣金类型(差价/一次性) → 套餐级别的佣金率 → 设备级防重复分佣 |
| 套餐激活异常 | 卡状态 → 实名状态 → 主套餐排队逻辑 → 加油包绑定关系 |
| Redis 缓存不一致 | Key 的 TTL → 缓存更新时机 → 是否有手动 `Del` 清除 |
| 微信支付回调失败 | 签名验证 → 幂等性处理 → 回调 URL 是否可达 |
| GORM 查询慢 | `db.Debug()` 看实际 SQL → 是否 N+1 → 是否缺少索引 |
---
## 红线规则
如果你发现自己在想以下任何一条,**立刻停下来,回到阶段一**
| 想法 | 为什么是错的 |
|------|------------|
| "先快速修一下,回头再查" | 快速修 = 猜测。猜测 = 浪费时间。 |
| "试试改这个看看行不行" | 一次只验证一个假设,不是随机改。 |
| "大概是 X 的问题,我直接改了" | "大概"不是根因。先验证再改。 |
| "这个很简单,不用走流程" | 简单问题走流程只需要 5 分钟。不走流程可能浪费 2 小时。 |
| "我不完全理解但这应该行" | 不理解 = 没找到根因。回阶段一。 |
| "再试一次"(已经失败 2 次) | 3 次失败 = 架构问题。停下来讨论。 |
| "同时改这几个地方应该能修好" | 改多处 = 无法确认哪个是根因。一次只改一处。 |
---
## 常见借口和真相
| 借口 | 真相 |
|------|------|
| "问题很简单,不需要走流程" | 简单问题也有根因。走流程对简单问题只花 5 分钟。 |
| "太紧急了,没时间分析" | 系统化调试比乱猜快 3-5 倍。越急越要走流程。 |
| "先改了验证一下" | 这叫猜测,不叫验证。先确认根因再改。 |
| "我看到问题了,直接修" | 看到症状 ≠ 理解根因。症状修复是技术债。 |
| "改了好几个地方,反正能用了" | 不知道哪个改动修的,下次还会出问题。 |
---
## 快速参考
| 阶段 | 核心动作 | 完成标准 |
|------|---------|---------|
| **一、根因调查** | 读错误日志、复现、检查改动、逐层诊断、追踪数据流 | 能说清楚"因为 X 所以 Y" |
| **二、模式分析** | 找参照代码、逐行对比、列出差异 | 知道正确的应该长什么样 |
| **三、假设验证** | 写下假设、最小改动、单变量验证 | 假设被证实或推翻 |
| **四、实施修复** | 修根因、编译检查、请求验证、清理诊断代码 | bug 消失,无新增问题 |

View File

@@ -0,0 +1,126 @@
Status: ready-for-agent
# PRD客户绑定体系重构与资产归属验证统一
## Problem Statement
C 端用户在使用没有虚拟号的 IoT 卡时,整个业务流程完全失效:登录后无法实名认证、无法购买套餐、无法操作设备。根本原因是系统的客户绑定机制依赖虚拟号(`virtual_no`)作为唯一标识,而虚拟号在 IoT 卡上是可选字段,线上有 12,000+ 张无虚拟号的卡且确认会流向 C 端用户。
与此同时,换货流程在旧卡有虚拟号、新卡无虚拟号时会被错误拦截,即使旧卡实际上没有任何客户绑定记录,换货依然报错"新资产无法承接客户绑定"。
从设计层面看,`tb_personal_customer_iccid` 表(按 ICCID 绑定早已建好Store 层也实现完整,但从未被任何 Service 或 Handler 接入。同时,"判断某张卡/设备是否属于某个客户"这个核心安全规则,散落在 6 个文件中以 4 种不同方式实现,部分实现缺少对已禁用绑定记录的过滤,存在安全缺口。
## Solution
建立统一的 **CustomerBinding 模块**,封装客户与资产之间的绑定创建和归属验证逻辑,屏蔽"有虚拟号走 `tb_personal_customer_device` / 无虚拟号走 `tb_personal_customer_iccid`"的路由细节。所有调用方只需传入资产信息,不感知底层用了哪张表。
在此基础上,修复换货服务中校验与执行逻辑混淆的 bug并将资产解析和 IoT 卡状态字段整理为后续阶段任务。
## User Stories
### C 端用户(无虚拟号的卡)
1. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想在首次登录时成功绑定我的卡,以便后续能正常使用所有 C 端功能。
2. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想在绑定卡后能正常提交实名认证,以便激活卡的完整功能。
3. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想购买流量套餐,以便为我的卡充值续费。
4. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想在换货后仍能访问新卡并正常操作,以便换货不影响我的使用体验。
### C 端用户(有虚拟号的卡)
5. 作为一个持有有虚拟号 IoT 卡的 C 端用户,我的所有现有功能不受本次改动影响,以便升级对我透明无感知。
6. 作为一个持有有虚拟号 IoT 卡的 C 端用户,当我的绑定关系被禁用后,我不能通过该绑定关系访问资产,以便系统安全边界得到保障。
### 后台运营人员(换货)
7. 作为后台运营人员,我想将一张有虚拟号的旧卡换货为一张无虚拟号的新卡,并且换货能正常完成,以便不再因无虚拟号而被系统错误拦截。
8. 作为后台运营人员,当旧卡有客户绑定记录、新卡无虚拟号时,我希望系统在换货完成后自动将客户绑定迁移到新卡的 ICCID以便客户换货后仍能访问新卡。
9. 作为后台运营人员,当旧卡没有任何客户绑定记录时,无论旧卡是否有虚拟号,换货都应该正常完成,以便换货流程不被无意义的条件拦截。
### 系统(数据一致性)
10. 作为系统,当客户首次绑定一张无虚拟号的卡时,应正确将该卡的 `asset_status` 从在库更新为已销售,以便轮询系统能感知到该卡有真实用户在使用。
11. 作为系统,当客户的绑定关系被禁用(`status=0`)后,归属验证应拒绝该客户访问对应资产,以便被撤销的绑定不再赋予访问权限。
## Implementation Decisions
### 1. 新增 CustomerBinding 模块
`internal/service/` 下建立 `customer_binding` 包,对外暴露两个核心接口:
- `Bind(ctx, tx, customerID, assetType, assetID)` — 创建或更新客户与资产的绑定关系;对于卡资产,有虚拟号写 `tb_personal_customer_device`,无虚拟号写 `tb_personal_customer_iccid`
- `OwnsAsset(ctx, customerID, assetType, assetID) bool` — 验证客户是否持有该资产的有效绑定(`status=1`);按资产类型路由到对应绑定表查询
- `Migrate(ctx, tx, oldAsset, newAsset)` — 换货时迁移绑定关系;处理四种组合(有→有、有→无、无→有、无→无)
`OwnsAsset` 内部统一强制 `status=1` 过滤,解决当前部分实现缺少此过滤的安全缺口。
### 2. 绑定路由规则(卡资产)
| 旧卡 | 新卡 | Migrate 行为 |
|------|------|-------------|
| 有虚拟号,有 pcd 绑定 | 有虚拟号 | 更新 pcd 记录的 virtual_no |
| 有虚拟号,有 pcd 绑定 | 无虚拟号 | 禁用旧 pcd 记录 + 创建 pci ICCID 绑定 |
| 有虚拟号,无绑定 | 无虚拟号 | 跳过(无需迁移) |
| 无虚拟号 | 任意 | 迁移 pci 记录(若存在)到新卡 ICCID |
### 3. 接入 CustomerBinding 的调用点
以下调用点需要替换为 CustomerBinding 模块:
- `client_auth/service.go: bindAsset()` — 使用 `CustomerBinding.Bind()`
- `client_auth/service.go: resolveAssetBindingKey()` — 废弃,逻辑内聚到 CustomerBinding
- `handler/app/client_realname.go: ExistsByCustomerAndDevice()` — 替换为 `CustomerBinding.OwnsAsset()`
- `handler/app/client_device.go: ExistsByCustomerAndDevice()` — 替换为 `CustomerBinding.OwnsAsset()`
- `handler/app/client_wallet.go: isCustomerOwnAsset()` — 替换为 `CustomerBinding.OwnsAsset()`
- `handler/app/client_asset.go: isCustomerOwnAsset()` — 替换为 `CustomerBinding.OwnsAsset()`
- `service/client_order/service.go: checkAssetOwnership()` — 替换为 `CustomerBinding.OwnsAsset()`
- `service/exchange/service.go: switchCustomerBindingWithTx()` — 替换为 `CustomerBinding.Migrate()`
### 4. 修复 switchCustomerBindingWithTx 的校验混淆
当前 `switchCustomerBindingWithTx`(执行函数)在执行阶段重复做了 `ensureNewAssetBindingAvailableWithTx`(校验函数)的判断,且条件更严(不查实际记录数,只看 key 是否为空)。
修复方式:将 `switchCustomerBindingWithTx` 改为调用 `CustomerBinding.Migrate()`,移除函数内的 `newKey==""` 拦截逻辑。`ensureNewAssetBindingAvailableWithTx` 同步更新:当 `newKey==""` 时,不再拦截,因为 `Migrate()` 已能正确处理此情形。
### 5. asset_status 首销标记修复
`bindAsset` 中通过 `firstEverBind`(查 `virtual_no=""` 的记录数)来判断是否首次绑定并触发 `markAssetAsSold()`。无虚拟号的卡因为 key 为空,导致此逻辑失效,`asset_status` 永远停在在库。
修复方式:在 `CustomerBinding.Bind()` 内,对 IoT 卡分别按各自绑定表查询首绑状态,再触发 `markAssetAsSold()`
### 6. 不引入新的数据库表或字段
`tb_personal_customer_iccid` 表和 `PersonalCustomerICCIDStore` 已经存在且完整,本次只是接入,不需要迁移或 schema 变更。
### 7. 现有 personal_customer_device 数据不迁移
有虚拟号的卡现有绑定数据保持不变,继续走 `tb_personal_customer_device` 路径。只有无虚拟号的卡新登录时才写 `tb_personal_customer_iccid`
## Testing Decisions
本项目禁止自动化测试,使用 PostgreSQL MCP 和 Postman/curl 手动验证。
**验证重点场景:**
1. **无虚拟号卡首次登录**:用无虚拟号卡的 ICCID 登录后,`tb_personal_customer_iccid` 应出现绑定记录,`tb_iot_card.asset_status` 应变为 2已销售
2. **无虚拟号卡归属验证**:登录后调用实名认证接口,应返回成功而非"无权限"
3. **无虚拟号卡购买套餐**:购买套餐接口应正常通过归属验证
4. **有虚拟号换无虚拟号(旧卡有绑定)**:换货完成后,`tb_personal_customer_device` 旧记录 status 应为 0`tb_personal_customer_iccid` 应出现新卡 ICCID 的绑定记录
5. **有虚拟号换无虚拟号(旧卡无绑定)**:换货应正常完成,无报错,不写入任何绑定记录
6. **有虚拟号换有虚拟号(回归)**现有流程不受影响pcd 表记录的 virtual_no 正确更新到新卡
7. **禁用绑定后归属验证**:将某条 pcd 或 pci 记录 status 设为 0对应客户调用归属验证应返回无权限
8. **有虚拟号卡回归(全流程)**:确认有虚拟号卡的登录、实名、购买套餐流程无任何变化
## Out of Scope
- **IoT 卡状态字段整理**`status` / `asset_status` / `activation_status` 语义重叠问题):影响面广,单独规划
- **资产解析函数统一**12 个 resolve 变体合并):不影响当前 bug单独规划
- **`tb_personal_customer_iccid` 换货迁移的历史数据回填**:历史上无虚拟号卡的用户无绑定记录,不做回填,用户需重新登录
- **`tb_personal_customer_device``virtual_no=""` 的历史垃圾数据清理**需先确认实际数量SQL 查询),作为独立数据修复任务
## Further Notes
- 线上有 12,000+ 张无虚拟号的 IoT 卡,这是合法业务数据,不是数据质量问题
- `tb_personal_customer_iccid` 的 Store 已有完整的 CRUD 实现Create、ExistsByCustomerAndICCID、GetByCustomerID、CreateOrUpdateLastUsed 等),无需重写
- 换货 bug 的直接触发路径:`completeExchangeWithTx → switchCustomerBindingWithTx`,在执行阶段因 `newKey==""` 报错,即使旧卡实际无任何客户绑定记录
- 建议在上线前执行:`SELECT COUNT(*) FROM tb_personal_customer_device WHERE (virtual_no IS NULL OR virtual_no = '') AND deleted_at IS NULL` 确认是否有历史脏数据需要清理
- **临时线上补丁(知悉)**2026-06-18 已将线上 12,000+ 张无虚拟号的卡手动补充了虚拟号作为应急措施,以保障线上可用性。数据回滚由业务方自行处理,不在本次实现范围内。

View File

@@ -0,0 +1,50 @@
Status: done
# CustomerBinding 模块骨架 + 有虚拟号路径替换(纯重构)
## What to build
建立 `internal/service/customer_binding` 包,对外暴露 `Bind()``OwnsAsset()` 两个接口,将现有有虚拟号路径的逻辑迁入。同时将代码库中 6 处归属验证调用点统一替换为新接口。
**本切片是纯重构,不改变任何现有行为。**
### Bind(ctx, tx, customerID, assetType, assetID)
封装创建客户与资产绑定记录的逻辑,当前只实现有虚拟号路径:
- IoT 卡有虚拟号 → 写 `tb_personal_customer_device`(与现在 `bindAsset` 行为一致)
- 设备 → 写 `tb_personal_customer_device`(设备必然有虚拟号)
- 首次绑定时触发 `markAssetAsSold()`firstEverBind 逻辑保持不变)
### OwnsAsset(ctx, customerID, assetType, assetID) bool
封装归属验证逻辑,当前只实现有虚拟号路径:
-`tb_personal_customer_device WHERE virtual_no = ? AND status = 1`
- 内部统一强制 `status = 1` 过滤(修复现有部分实现缺少此过滤的安全缺口)
### 替换 6 处调用点
以下调用点替换为 `CustomerBinding` 的方法:
| 调用点 | 替换目标 |
|--------|---------|
| `client_auth/service.go: bindAsset()` | `CustomerBinding.Bind()` |
| `handler/app/client_realname.go:92` | `CustomerBinding.OwnsAsset()` |
| `handler/app/client_device.go:82` | `CustomerBinding.OwnsAsset()` |
| `handler/app/client_wallet.go: isCustomerOwnAsset()` | `CustomerBinding.OwnsAsset()` |
| `handler/app/client_asset.go: isCustomerOwnAsset()` | `CustomerBinding.OwnsAsset()` |
| `service/client_order/service.go: checkAssetOwnership()` | `CustomerBinding.OwnsAsset()` |
exchange service 的 `customerOwnsAsset()` 在切片 3 中处理。
## Acceptance criteria
- [ ] `internal/service/customer_binding` 包存在,对外暴露 `Bind``OwnsAsset`
- [ ] 有虚拟号的 IoT 卡登录后,`tb_personal_customer_device` 正常写入绑定记录(与现在一致)
- [ ] 设备登录后,`tb_personal_customer_device` 正常写入绑定记录(与现在一致)
- [ ] 被禁用status=0的绑定记录不能通过 `OwnsAsset` 验证(修复安全缺口)
- [ ] 6 处调用点全部替换完毕原有私有方法resolveAssetBindingKey、isCustomerOwnAsset 等)可删除
- [ ] 有虚拟号卡的实名认证、购买套餐、设备操作全链路与现在行为一致
## Blocked by
None - 可立即开始

View File

@@ -0,0 +1,52 @@
Status: done
# 无虚拟号单卡 C 端完整链路
## Parent
`.scratch/customer-binding-architecture/PRD.md`
## What to build
扩展 `CustomerBinding` 模块,使无虚拟号的独立 IoT 卡(单卡)能够完整走通 C 端业务流程:登录绑定、实名认证、购买套餐。
**仅影响 IoT 卡(单卡)。设备资产必然有虚拟号,不在本切片处理范围内。**
### 扩展 Bind()
当资产为 IoT 卡且 `virtual_no` 为空时:
-`tb_personal_customer_iccid`(按 ICCID 绑定),而非 `tb_personal_customer_device`
- `PersonalCustomerICCIDStore` 已有完整实现,直接注入使用
当资产为 IoT 卡且 `virtual_no` 不为空时:行为与切片 1 一致,不变。
### 修复 firstEverBind + markAssetAsSold
当前 `firstEverBind` 通过查 `tb_personal_customer_device WHERE virtual_no = ""` 来判断是否首次绑定,对无虚拟号的卡完全失效(所有无虚拟号的卡共用同一个空 key
修复方式:在 `Bind()` 内,按路径分别判断首绑:
- 有虚拟号路径:查 `tb_personal_customer_device WHERE virtual_no = ?`
- 无虚拟号路径:查 `tb_personal_customer_iccid WHERE iccid = ?`
首次绑定后正常触发 `markAssetAsSold()`,将卡的 `asset_status` 从在库1更新为已销售2
### 扩展 OwnsAsset()
当资产为 IoT 卡时:
- 若卡有 `virtual_no`:查 `tb_personal_customer_device`(与切片 1 一致)
- 若卡无 `virtual_no`:查 `tb_personal_customer_iccid WHERE iccid = ? AND status = 1`
当资产为设备时:行为不变,只查 `tb_personal_customer_device`
## Acceptance criteria
- [ ] 无虚拟号的单卡 C 端登录后,`tb_personal_customer_iccid` 中出现对应的绑定记录
- [ ] 无虚拟号单卡首次登录后,`tb_iot_card.asset_status` 变为 2已销售
- [ ] 无虚拟号单卡登录后,实名认证接口返回成功(不报"无权限"
- [ ] 无虚拟号单卡登录后,购买套餐接口归属验证通过
- [ ] 有虚拟号卡的全部行为与切片 1 完成后保持一致(无回归)
- [ ] 设备资产的全部行为不受影响
## Blocked by
`.scratch/customer-binding-architecture/issues/01-customer-binding-module-refactor.md`

View File

@@ -0,0 +1,45 @@
Status: done
# 换货绑定迁移修复
## Parent
`.scratch/customer-binding-architecture/PRD.md`
## What to build
`CustomerBinding` 模块上实现 `Migrate(ctx, tx, oldAsset, newAsset)` 方法,替换换货服务中现有的 `switchCustomerBindingWithTx``ensureNewAssetBindingAvailableWithTx` 逻辑,修复有虚拟号旧卡换无虚拟号新卡时被误拦截的 bug。
### Migrate(ctx, tx, oldAsset, newAsset)
处理四种虚拟号组合,仅涉及 IoT 卡(设备必然有虚拟号,只有有→有一种情形):
| 旧卡 | 新卡 | 行为 |
|------|------|------|
| 有虚拟号pcd 有绑定 | 有虚拟号 | 更新 pcd 记录的 virtual_no 为新卡虚拟号 |
| 有虚拟号pcd 有绑定 | 无虚拟号 | 禁用旧 pcd 记录status=0+ 创建新 pci ICCID 绑定 |
| 有虚拟号pcd 无绑定 | 无虚拟号 | 跳过,无需迁移 |
| 无虚拟号pci 有绑定 | 任意 | 迁移 pci 记录到新卡 ICCID或新卡虚拟号路径 |
| 任意 | 任意(无绑定) | 跳过 |
### 修复换货服务
- `switchCustomerBindingWithTx` 替换为调用 `CustomerBinding.Migrate()`,移除函数内 `newKey == ""` 的误拦截逻辑
- `ensureNewAssetBindingAvailableWithTx` 更新:当新卡无虚拟号时,不再拦截换货,因为 `Migrate()` 已能正确处理此情形(无绑定时跳过,有绑定时迁移到 pci
### 根本 bug 说明
`switchCustomerBindingWithTx` 在执行阶段做了 `if newKey == "" { return error }` 的检查,但没有先确认旧资产是否实际存在绑定记录。旧卡有虚拟号但无任何客户绑定时,也会被误拦截报错"新资产无法承接客户绑定"。
## Acceptance criteria
- [ ] 旧卡有虚拟号 + 有客户绑定换货为有虚拟号新卡pcd 记录的 virtual_no 正确更新为新卡虚拟号
- [ ] 旧卡有虚拟号 + 有客户绑定,换货为无虚拟号新卡:旧 pcd 记录 status 变为 0pci 表出现新卡 ICCID 的绑定记录
- [ ] 旧卡有虚拟号 + 无客户绑定,换货为无虚拟号新卡:换货正常完成,不报错,不写入任何绑定记录
- [ ] 旧卡无虚拟号,换货为任意新卡:换货正常完成
- [ ] 有虚拟号换有虚拟号(原有场景):行为与修复前一致,无回归
- [ ] 换货完成后,客户通过新卡(无论有无虚拟号)能正常通过归属验证
## Blocked by
`.scratch/customer-binding-architecture/issues/02-no-virtual-no-card-cend-flow.md`

View File

@@ -0,0 +1,143 @@
Status: ready-for-agent
# PRD企业授权增强 & 资产列表扩展
## Problem Statement
平台运营人员在管理企业授权和查看资产状态时面临以下痛点:
1. **企业维度检索缺失**:卡列表和设备列表无法按企业维度过滤,也无法在列表中直接看到某张卡/设备被授权给了哪个企业,需要跨页面跳转查询。
2. **企业授权操作繁琐**:授权和收回接口只接受精确 ICCID/虚拟号列表,但前端列表有分页,量大时需一个个选,无法按批次号、号段等条件一次性批量操作。
3. **换货历史不可见**:经历过换货的旧卡/设备,在列表和详情中没有任何标记,运营无法判断当前资产是否为"换货遗留"状态或曾经历过换货后被重新激活(转新)的资产。
4. **主钱包流水和退款列表检索维度不足**无法直接通过资产标识ICCID 或虚拟号)快速定位某个资产相关的流水和退款记录。
5. **卡网络状态过滤缺失**standalone 卡列表无法按网络状态(开机/停机)过滤,批量运维操作不便。
## Solution
对六个业务场景进行针对性扩展:
1. 在 standalone 卡列表和设备列表中新增企业维度过滤及企业信息返回字段
2. 将企业授权/收回接口升级为支持 list/range/filter 三(两)模式选资产
3. 在 standalone 卡列表和设备列表响应中暴露换货业务状态和资产世代编号
4. 主钱包流水列表新增资产标识精确检索
5. 退款列表新增资产标识精确检索
6. standalone 卡列表新增网络状态过滤
同时修复卡企业授权表的数据一致性问题(补唯一约束,与设备授权保持一致)。
## User Stories
1. 作为平台运营,我希望在 standalone 卡列表中输入企业 ID 进行过滤,以便快速找到已授权给该企业的所有卡
2. 作为平台运营,我希望在 standalone 卡列表中使用"是否授权企业"开关过滤,以便快速区分已授权和未授权的卡库存
3. 作为平台运营,我希望在 standalone 卡列表中直接看到每张卡被授权给哪个企业企业ID和名称以便不用跳转到企业详情页
4. 作为平台运营,我希望在设备列表中输入企业 ID 进行过滤,以便快速找到已授权给该企业的所有设备
5. 作为平台运营,我希望在设备列表中使用"是否授权企业"开关过滤,以便区分已授权和未授权的设备
6. 作为平台运营,我希望在设备列表中直接看到每台设备被授权给哪个企业,以便快速掌握设备归属
7. 作为平台运营,我希望在授权卡给企业时能通过 ICCID 号段范围一次性选取,以便高效授权大批量卡
8. 作为平台运营,我希望在授权卡给企业时能通过批次号、运营商等筛选条件选取,以便按业务维度批量授权
9. 作为平台运营,我希望在收回企业卡授权时同样支持 list/range/filter 三种模式,以便与授权操作保持一致的操作体验
10. 作为平台运营,我希望在授权设备给企业时能通过虚拟号模糊搜索和批次号筛选一次性选取,以便批量操作设备
11. 作为平台运营,我希望在收回企业设备授权时支持 list/filter 两种模式,以便批量收回
12. 作为平台运营,我希望在 standalone 卡列表中看到每张卡的业务状态(`asset_status`),以便识别已换货但尚未转新的"死档"卡
13. 作为平台运营,我希望在 standalone 卡列表中看到每张卡的世代编号(`generation`),以便识别曾换货后转新重入库的卡
14. 作为平台运营,我希望在设备列表中看到每台设备的业务状态和世代编号,以便同样掌握设备的换货历史
15. 作为平台运营,我希望在主钱包流水列表中输入 ICCID 或虚拟号精确检索,以便快速定位某资产的所有充值/扣款记录
16. 作为平台运营,我希望在退款列表中输入 ICCID 或虚拟号精确检索,以便快速找到某资产相关的所有退款申请
17. 作为平台运营,我希望在 standalone 卡列表中按网络状态(开机/停机)过滤,以便批量处理特定网络状态的卡
## Implementation Decisions
### 数据一致性修复(前置)
- **卡企业授权唯一约束**:在 `tb_enterprise_card_authorization` 表上补充部分唯一索引,约束同一张卡在同一时间只能有一条有效授权记录(`WHERE revoked_at IS NULL AND deleted_at IS NULL`),与设备授权表的 `uq_active_device_auth` 约束保持一致
- **service 层校验补充**:卡授权 service 在执行授权前,需增加"卡是否已授权给其他企业"的校验,目前只校验同一企业重复授权
### 需求1standalone 卡列表 + 设备列表企业字段
**Request 新增过滤条件**`ListStandaloneIotCardRequest``ListDeviceRequest`
- `authorized_enterprise_id *uint`按企业ID过滤只匹配当前有效授权`revoked_at IS NULL`
- `is_authorized_to_enterprise *bool`true=已授权给某企业false=未授权任何企业
**Response 新增字段**`StandaloneIotCardResponse``DeviceResponse`
- `authorized_enterprise_id *uint`:未授权时为 null
- `authorized_enterprise_name string`:未授权时为空字符串
Store 层需通过 JOIN 或子查询 `tb_enterprise_card_authorization` / `tb_enterprise_device_authorization` 表获取企业ID再批量查企业名称
### 需求2企业授权/收回接口升级为多模式
参照 `AllocateStandaloneCardsRequest` / `RecallStandaloneCardsRequest``selection_type` 三模式设计:
**allocate-cards / recall-cards**三模式list / range / filter
- `list``ICCIDs []string`,精确 ICCID 列表
- `range``ICCIDStart string` + `ICCIDEnd string`,号段范围
- `filter`:筛选条件
- allocate-cards filter 字段:`ICCID`(模糊)、`BatchNo``CarrierID``ShopID`/`ShopIDs`
- recall-cards filter 字段:`ICCID`(模糊)、`BatchNo``CarrierID`
**allocate-devices / recall-devices**两模式list / filter不支持 range
- `list``DeviceNos []string`,设备虚拟号列表
- `filter`:筛选条件
- allocate-devices filter 字段:`VirtualNo`(模糊)、`BatchNo``ShopID`
- recall-devices filter 字段:`VirtualNo`(模糊)、`BatchNo`
原有接口的请求结构需要破坏性变更DTO 重构),需与前端联调确认过渡方案
### 需求3换货状态字段
**`StandaloneIotCardResponse``DeviceResponse` 新增字段**
- `asset_status int`业务状态1=在库, 2=已销售, 3=已换货, 4=已停用)
- `asset_status_name string`:对应中文名称
- `generation int`资产世代编号初始值1每次换货+转新后+1
语义约定:
- `asset_status = 3`:该资产已换货且尚未执行"旧资产转新",是永久性死档标记
- `generation > 1`:该资产历史上曾执行过换货+转新,目前仍在流通
### 需求4主钱包流水资产标识检索
`MainWalletTransactionListRequest` 新增:
- `AssetIdentifier string`:精确匹配 `asset_identifier` 快照字段ICCID 或虚拟号),空字符串时不过滤
### 需求5退款列表资产标识检索
`RefundListRequest` 新增:
- `AssetIdentifier string`:精确匹配 `asset_identifier` 快照字段ICCID 或虚拟号),空字符串时不过滤
### 需求6standalone 卡列表网络状态过滤
`ListStandaloneIotCardRequest` 新增:
- `NetworkStatus *int`网络状态过滤0=停机, 1=开机),不传则不过滤
Store 层 `applyStandaloneFilters` 函数中补充 `network_status = ?` 条件
## Testing Decisions
本项目禁止自动化测试,验证方式为:
- **数据库验证**:通过 PostgreSQL MCP 工具验证授权表唯一约束是否生效、字段值是否正确写入
- **接口验证**:通过 curl/Postman 对各接口进行手动联调,重点覆盖以下场景:
- 企业 ID 过滤确认只返回有效授权revoked_at IS NULL的卡/设备
- 是否授权企业过滤true/false 结果集互补且无交集
- 授权时尝试将同一张卡授权给第二个企业,确认被拒绝
- allocate-cards 三种 selection_type 均能正确选取目标卡
- allocate-devices 两种 selection_type 均能正确选取目标设备
- asset_status=3 的卡在列表中可见且字段正确
- generation 字段在换货+转新后正确递增
- 主钱包流水 asset_identifier 精确匹配不漏不多
- 退款 asset_identifier 精确匹配不漏不多
- 网络状态过滤 0/1 结果集互补
## Out of Scope
- 企业卡/设备授权列表接口本身的改造(本次只改授权/收回操作接口)
- `AllocateCardsPreviewReq`(预览接口)是否同步升级为多模式(待后续评估)
- standalone 卡列表和设备列表的导出功能是否同步支持新增过滤条件
- 换货历史的完整时间线展示(仅暴露字段,不新增详情接口)
- `generation` 字段的过滤能力(暂只返回,不支持作为检索条件)
## Further Notes
- 需求1 的企业名称需要 N+1 防护:通过授权查询批量拿到 enterprise_id 后,用 IN 一次性查企业名称,不能逐条查
- 需求2 的 filter 模式对于 allocate-cards 场景卡的范围天然受操作者权限约束代理用户只能授权自己店铺的卡Store 层已有 `middleware.ApplyShopFilter` 处理filter 模式需要同样受此约束
- 卡唯一约束迁移执行前,需确认现有数据中是否存在一张卡同时有多条 `revoked_at IS NULL` 的记录,若有需先清理

View File

@@ -0,0 +1,28 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
修复卡企业授权的数据一致性问题,使其与设备授权保持一致:一张卡在同一时间只能授权给一个企业。
分两步:
**第一步:数据库迁移**
`tb_enterprise_card_authorization` 表上新增部分唯一索引,约束同一张卡不能同时存在两条有效授权记录(`revoked_at IS NULL AND deleted_at IS NULL`)。迁移执行前需先查询是否存在脏数据(同一 card_id 有多条 revoked_at IS NULL 的记录),若有需在迁移脚本中先行清理。
**第二步service 层校验补充**
卡授权 service`BatchAuthorize`)在执行授权前,增加"目标卡是否已授权给其他企业"的校验。目前代码只跳过已授权给同一企业的卡,不阻止授权给第二个企业。新逻辑:若目标卡已存在有效授权(`revoked_at IS NULL`)且授权对象不是当前企业,则将该卡加入失败列表并给出明确错误原因(如"卡已授权给其他企业,请先收回")。
## Acceptance criteria
- [x] `tb_enterprise_card_authorization` 表存在部分唯一索引,约束 `(card_id) WHERE revoked_at IS NULL AND deleted_at IS NULL`
- [x] 尝试将同一张卡授权给第二个企业时,接口返回失败,错误信息明确说明"已授权给其他企业"
- [x] 已撤回revoked_at 有值)的历史记录不受唯一约束影响,可正常查询
- [x] 同一张卡授权给同一企业时仍返回"已授权给该企业"的原有错误,行为不变
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,22 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
`/api/admin/iot-cards/standalone` 接口的查询条件中新增网络状态过滤。
Request DTO 新增可选字段 `network_status *int`0=停机1=开机不传时不过滤。Store 层 `applyStandaloneFilters` 函数补充对应的 `network_status = ?` WHERE 条件。
## Acceptance criteria
- [x] 传入 `network_status=0` 时只返回停机的卡
- [x] 传入 `network_status=1` 时只返回开机的卡
- [x] 不传 `network_status` 时返回全部(与改动前行为一致)
- [x] 网络状态过滤可与现有其他过滤条件ICCID、运营商等叠加使用
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,34 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
在 standalone 卡列表响应和设备列表响应中暴露换货相关字段,使运营人员能直接在列表中识别资产的换货历史。
**`StandaloneIotCardResponse` 新增三个字段:**
- `asset_status int`业务状态1=在库, 2=已销售, 3=已换货, 4=已停用)
- `asset_status_name string`:对应中文名称
- `generation int`资产世代编号初始值1每次换货+旧资产转新后 +1
**`DeviceResponse` 同步新增相同三个字段。**
语义约定(写入注释和 description
- `asset_status = 3`:该资产已换货且尚未执行"旧资产转新",不再流通
- `generation > 1`:该资产历史上曾执行过换货后转新,目前仍在使用
Service 层组装响应时直接从 Model 取值,`asset_status_name` 通过 constants 中的方法转换。
## Acceptance criteria
- [x] `/api/admin/iot-cards/standalone` 响应中每条记录包含 `asset_status``asset_status_name``generation` 三个字段
- [x] `/api/admin/devices` 响应中每条记录包含相同三个字段
- [x] 已换货未转新的卡/设备返回 `asset_status=3``asset_status_name="已换货"`
- [x] 经过换货转新重入库的卡/设备返回 `generation=2`(或更高)且 `asset_status=1`
- [x] 普通未经历换货的资产返回 `generation=1`
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,23 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
`/api/admin/shops/{shop_id}/main-wallet/transactions` 接口的查询条件中新增资产标识精确检索。
`MainWalletTransactionListRequest` 新增可选字段 `asset_identifier string`,非空时对 `asset_identifier` 列做精确匹配(`= ?`,不做模糊匹配)。`asset_identifier` 字段存储的是下单时资产的标识符快照ICCID 或虚拟号),空字符串时不过滤。
## Acceptance criteria
- [x] 传入有效的 ICCID 时只返回该卡相关的主钱包流水
- [x] 传入有效的设备虚拟号时只返回该设备相关的主钱包流水
- [x] 传入不存在的标识符时返回空列表total=0不报错
- [x] 不传 `asset_identifier` 时行为与改动前完全一致
- [x] 可与 `transaction_type``start_date``end_date` 等现有条件叠加使用
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,23 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
`/api/admin/refunds` 接口的查询条件中新增资产标识精确检索。
`RefundListRequest` 新增可选字段 `asset_identifier string`,非空时对退款记录的 `asset_identifier` 列做精确匹配(`= ?`。该字段存储的是下单时资产的标识符快照ICCID 或虚拟号),空字符串时不过滤。
## Acceptance criteria
- [x] 传入有效的 ICCID 时只返回该卡相关的退款申请
- [x] 传入有效的设备虚拟号时只返回该设备相关的退款申请
- [x] 传入不存在的标识符时返回空列表total=0不报错
- [x] 不传 `asset_identifier` 时行为与改动前完全一致
- [x] 可与 `status``order_id``shop_id` 等现有条件叠加使用
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,33 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
`/api/admin/iot-cards/standalone` 接口中新增企业维度过滤条件,并在响应中返回当前有效授权的企业信息。
**Request 新增过滤条件(`ListStandaloneIotCardRequest`**
- `authorized_enterprise_id *uint`按企业ID过滤只匹配 `tb_enterprise_card_authorization``revoked_at IS NULL` 的有效授权
- `is_authorized_to_enterprise *bool`true=只返回当前已授权给某企业的卡false=只返回未授权任何企业的卡
**Response 新增字段(`StandaloneIotCardResponse`**
- `authorized_enterprise_id *uint`当前有效授权的企业ID未授权时为 null
- `authorized_enterprise_name string`:对应企业名称,未授权时为空字符串
Store 层在 `applyStandaloneFilters` 中增加对 `authorized_enterprise_id``is_authorized_to_enterprise` 的处理,通过子查询 `tb_enterprise_card_authorization``revoked_at IS NULL AND deleted_at IS NULL`实现过滤。响应组装时批量查询企业名称IN 查询),不逐条查询。
## Acceptance criteria
- [x] 传入 `authorized_enterprise_id` 时只返回当前有效授权给该企业的卡
- [x] 传入 `is_authorized_to_enterprise=true` 时只返回已授权给某企业的卡
- [x] 传入 `is_authorized_to_enterprise=false` 时只返回未授权任何企业的卡
- [x] 已撤回的历史授权不影响过滤结果(已撤回视为未授权)
- [x] 响应中每张卡包含 `authorized_enterprise_id``authorized_enterprise_name`,未授权卡对应字段为 null/空字符串
- [x] 企业名称通过批量查询获取,不触发 N+1 查询
- [x] 新增过滤条件可与现有条件ICCID、运营商、店铺等叠加使用
## Blocked by
- `issues/01-card-enterprise-auth-unique-constraint.md`

View File

@@ -0,0 +1,32 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
`/api/admin/devices` 接口中新增企业维度过滤条件,并在响应中返回当前有效授权的企业信息。
**Request 新增过滤条件(`ListDeviceRequest`**
- `authorized_enterprise_id *uint`按企业ID过滤只匹配 `tb_enterprise_device_authorization``revoked_at IS NULL` 的有效授权
- `is_authorized_to_enterprise *bool`true=只返回当前已授权给某企业的设备false=只返回未授权任何企业的设备
**Response 新增字段(`DeviceResponse`**
- `authorized_enterprise_id *uint`当前有效授权的企业ID未授权时为 null
- `authorized_enterprise_name string`:对应企业名称,未授权时为空字符串
Store 层通过子查询或 JOIN `tb_enterprise_device_authorization` 实现过滤。响应组装时批量查询企业名称IN 查询),不逐条查询。
## Acceptance criteria
- [x] 传入 `authorized_enterprise_id` 时只返回当前有效授权给该企业的设备
- [x] 传入 `is_authorized_to_enterprise=true` 时只返回已授权给某企业的设备
- [x] 传入 `is_authorized_to_enterprise=false` 时只返回未授权任何企业的设备
- [x] 已撤回的历史授权不影响过滤结果(已撤回视为未授权)
- [x] 响应中每台设备包含 `authorized_enterprise_id``authorized_enterprise_name`,未授权设备对应字段为 null/空字符串
- [x] 企业名称通过批量查询获取,不触发 N+1 查询
## Blocked by
None - can start immediately

View File

@@ -0,0 +1,42 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
将企业卡授权和收回接口升级为支持 list/range/filter 三种模式批量选取卡,替代原来只接受精确 ICCID 列表的方式。
**涉及接口:**
- `POST /api/admin/enterprises/{id}/allocate-cards`
- `POST /api/admin/enterprises/{id}/recall-cards`
**`AllocateCardsReq` 重构为:**
- `selection_type string`(必填,`list``range``filter`
- list 模式:`iccids []string`ICCID 列表最多1000个
- range 模式:`iccid_start string` + `iccid_end string`(号段范围)
- filter 模式过滤字段:`iccid string`(模糊)、`batch_no string``carrier_id *uint``shop_id *uint``shop_ids []uint`
- `remark string`(备注,所有模式均可选)
**`RecallCardsReq` 重构为:**
- `selection_type string`(必填,`list``range``filter`
- list 模式:`iccids []string`
- range 模式:`iccid_start string` + `iccid_end string`
- filter 模式过滤字段:`iccid string`(模糊)、`batch_no string``carrier_id *uint`
filter 和 range 模式下allocate 操作的候选卡集受操作者权限约束(代理用户只能授权自己店铺的卡),与 `applyStandaloneFilters` 中的 `ApplyShopFilter` 逻辑保持一致。
## Acceptance criteria
- [x] `allocate-cards` 接口接受 `selection_type=list` + `iccids`,行为与改动前一致
- [x] `allocate-cards` 接口接受 `selection_type=range` + 号段,批量授权号段内所有匹配的卡
- [x] `allocate-cards` 接口接受 `selection_type=filter` + 过滤条件,批量授权所有匹配的卡
- [x] `recall-cards` 接口同样支持三种模式,分别正确收回对应卡的企业授权
- [x] filter/range 模式下代理用户只能操作自己店铺的卡,超出范围的卡进入失败列表
- [x] 任何模式下尝试授权"已授权给其他企业"的卡,该卡进入失败列表并附带原因
- [x] 响应中 `success_count``fail_count``failed_items` 准确反映实际执行结果
## Blocked by
- `issues/01-card-enterprise-auth-unique-constraint.md`

View File

@@ -0,0 +1,38 @@
Status: ready-for-human
## Parent
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
## What to build
将企业设备授权和收回接口升级为支持 list/filter 两种模式批量选取设备,替代原来只接受精确设备号列表的方式。
**涉及接口:**
- `POST /api/admin/enterprises/{id}/allocate-devices`
- `POST /api/admin/enterprises/{id}/recall-devices`
**`AllocateDevicesReq` 重构为:**
- `selection_type string`(必填,`list``filter`
- list 模式:`device_nos []string`(设备虚拟号列表)
- filter 模式过滤字段:`virtual_no string`(模糊)、`batch_no string``shop_id *uint`
**`RecallDevicesReq` 重构为:**
- `selection_type string`(必填,`list``filter`
- list 模式:`device_nos []string`
- filter 模式过滤字段:`virtual_no string`(模糊)、`batch_no string`
filter 模式下allocate 操作的候选设备集受操作者权限约束代理用户只能授权自己店铺的设备。filter 模式命中的设备数量无硬性上限,但单次事务处理建议分批,超大批次需记录日志。
## Acceptance criteria
- [x] `allocate-devices` 接口接受 `selection_type=list` + `device_nos` 列表,行为与改动前一致
- [x] `allocate-devices` 接口接受 `selection_type=filter` + 过滤条件,批量授权所有匹配设备
- [x] `recall-devices` 接口接受 `selection_type=list` + `device_nos` 列表,行为与改动前一致
- [x] `recall-devices` 接口接受 `selection_type=filter` + 过滤条件,批量收回所有匹配设备
- [x] filter 模式下代理用户只能操作自己店铺的设备,超出范围的设备进入失败列表
- [x] 响应中 `success_count``fail_count``failed_items` 准确反映实际执行结果
## Blocked by
None - can start immediately

View File

@@ -17,7 +17,6 @@
| 测试接口/验证数据 | `db-validation` | PostgreSQL MCP 使用方法和验证示例 |
| 数据库迁移 | `db-migration` | 迁移命令、文件规范、执行流程、失败处理 |
| 维护规范文档 | `doc-management` | 规范文档流程和维护规则 |
| 调试 bug / 排查异常 | `systematic-debugging` | 四阶段根因分析流程、逐层诊断、场景速查表 |
| 编写 Go 代码注释/文档注释 | `comment-standards` | 包/结构体/接口/函数/内联注释完整规范与示例 |
| 创建涉及接口的 OpenSpec 提案 | `openspec-api-contract` | 探索阶段引导清单、提案必填章节与完成标准 |
@@ -334,4 +333,7 @@ queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, payloadBytes)
> "我注意到任务 2.1 和 2.2 可以合并为一步完成,是否可以这样优化?"
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
---
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`

View File

@@ -0,0 +1,522 @@
# AI 系统级规划提示词规范
用途:在需要 AI 生成大功能、复杂系统或跨模块改造计划时,先粘贴本文的提示词,再补充业务背景。它的目标是让 AI 先收敛目标、边界、事实源、状态机、失败路径和验收标准,再生成不容易漂移的系统级 plan。
建议使用方式:
1. 先把下面完整提示词复制给 AI。
2. 再贴业务背景、已有代码约束、用户旅程或参考材料。
3. 第一轮只让 AI 提澄清问题和共识摘要,不要直接进入最终 plan。
4. 共识确认后,再要求 AI 生成完整系统级 plan。
5. 最后要求 AI 进行反向审查,并把缺口合并回最终版。
## 完整提示词
````text
你现在不是代码执行者,而是“资深系统架构师 + 产品技术负责人 + 交付负责人”。
请帮我为一个功能/项目生成系统级实现计划。你的目标不是简单列任务,而是产出一份能约束后续 AI 执行、不容易漂移、能覆盖业务闭环的完整 plan。
在我明确说“开始实现”之前,不要写代码,不要改文件,不要进入实现。
# 一、工作方式
你必须按以下顺序工作:
1. 先理解我提供的背景。
2. 如果信息不足,不要直接生成最终 plan先提出“关键澄清问题”。
3. 澄清问题必须按优先级排序,最多分三组:
- 必须确认,否则 plan 会错
- 建议确认,否则执行中可能漂移
- 可以暂时由 AI 假设
4. 当信息足够后,先输出“共识摘要”,让我确认。
5. 共识确认后,再生成完整系统级 plan。
6. 生成 plan 后,必须进行一次“反向审查”,找缺口、矛盾、漂移风险。
7. 最后把审查发现合并进最终版 plan。
# 二、计划的核心原则
生成计划时必须遵守:
- 不要只按 Model / Store / Service / Handler 拆任务。
- 必须按“业务闭环”组织阶段。
- 每个阶段都要回答:这个阶段让哪个用户、哪个业务流程真正可用?
- 所有计划必须明确:
- 做什么
- 不做什么
- 为什么现在做
- 为什么不是以后做
- 依赖什么
- 如何验证完成
- 对不确定的内容必须显式标记为“假设”或“待确认”,不能悄悄编造。
- 不允许 scope creep。任何超出 MVP 的想法必须放到 V2 / 后置增强。
- 必须区分:
- 事实源
- 缓存
- 派生数据
- 日志/对象存储/审计材料
- 涉及资金、权限、状态流转、异步任务、外部接口时,必须单独成节。
# 三、计划必须包含的章节
最终 plan 必须包含以下章节,不得省略。
## 0. 目标与边界
说明:
- 项目/功能目标
- 当前版本 MVP 范围
- 明确不做什么
- V2 / 后置增强
- 关键原则
- 核心业务闭环
必须写成清晰约束,例如:
- “A 是事实源B 只是缓存”
- “系统不保存 XXX 明文”
- “本阶段不做 XXX”
- “失败时必须 XXX”
- “用户可见结果必须 XXX”
## 1. 角色与用户旅程
列出所有角色,例如:
- 普通用户
- 运营人员
- 管理员
- 第三方系统
- 异步 worker
- 外部服务
每个角色都要有完整旅程:
```text
角色
-> 入口
-> 操作
-> 系统处理
-> 状态变化
-> 用户看到的结果
-> 异常时如何处理
```
如果某个角色没有闭环,要指出缺口。
## 2. 系统边界与模块职责
说明每个模块负责什么、不负责什么。
必须包含:
- 前端职责
- Handler/API 职责
- Service 职责
- Store/DB 职责
- Worker/异步任务职责
- 外部服务职责
- Admin/运营后台职责
禁止把业务逻辑模糊地写成“后端处理”。
## 3. 当前代码与现状依据
如果你能访问代码库,必须先阅读相关文件,再写本节。
本节要列出:
- 已有能力
- 已有表/模型
- 已有 API
- 已有服务/Store/Worker
- 可以复用的实现
- 已知坑点
- 与现有规范冲突的地方
每条重要判断都要带文件路径或明确依据。
如果不能访问代码库,必须说明“以下为基于用户描述的假设”。
## 4. 数据模型与事实源
必须写清楚:
- 新增表
- 修改表
- 字段含义
- 枚举值
- 唯一约束
- 索引
- 软删除策略
- 迁移策略
- 回滚策略
- 历史数据兼容
- 哪些数据是事实源
- 哪些数据只是缓存/快照/投影
涉及金额时必须写:
- 金额单位
- 精度策略
- 是否允许负数
- 四舍五入/截断规则
- 资金流水是否可追溯
- 是否允许直接改余额缓存
## 5. API 契约
列出所有接口:
```text
METHOD /path
权限:
入参:
出参:
错误码:
状态变化:
幂等规则:
审计/日志:
```
必须包含:
- 用户侧 API
- Admin API
- Webhook/API callback
- Worker 触发入口
- 内部接口或复用点
新增 Handler 时必须说明文档生成器/路由注册同步要求。
## 6. 状态机
凡是有状态字段,必须写状态机。
格式:
```text
状态:
- pending
- processing
- success
- failed
- cancelled
允许流转:
pending -> processing
processing -> success
processing -> failed
禁止流转:
success -> pending
failed -> processing
并发规则:
- 同一资源同时只能有一个 pending
- 状态更新必须使用 WHERE status = expected
```
必须覆盖:
- 正常路径
- 失败路径
- 取消路径
- 重试路径
- 人工处理路径
- 状态不可逆规则
## 7. 权限与越权防护
必须说明:
- 谁能访问
- 如何识别身份
- 如何判断资源归属
- Handler 层做什么
- Service 层做什么
- Store 层是否需要过滤
- Admin 是否有二次校验
- 错误信息是否防止泄露资源存在性
要求:
- 不得信任前端传入的用户身份、角色、权限字段。
- 无权限和资源不存在是否统一返回,需要明确。
- 所有敏感操作必须有审计。
## 8. 幂等、并发与事务
必须单独说明:
- 创建类操作如何防重复
- 状态流转如何防重复
- 金额/库存/余额如何防并发错误
- 异步任务是否可重复消费
- Webhook 是否幂等
- 请求重试会发生什么
- 事务边界在哪里
- 事务内禁止做什么
- 事务提交后才做什么
必须写出关键策略,例如:
```text
状态流转使用 WHERE id = ? AND status = ?
余额变更必须在同一事务内完成
异步任务在事务提交后入队
Webhook event_id 必须幂等
```
## 9. 异步任务与外部服务
如果涉及外部服务或 Worker必须写
- 任务类型
- payload 结构
- 入队时机
- 消费逻辑
- 重试策略
- 幂等键
- 失败后状态
- 日志关键字
- 外部服务超时策略
- 外部服务返回未知状态时如何处理
禁止只写“调用第三方接口”。
## 10. 失败路径与异常处理矩阵
必须提供失败矩阵:
| 场景 | 系统行为 | 状态变化 | 是否重试 | 用户可见结果 | Admin 如何处理 |
|---|---|---|---|---|---|
至少覆盖:
- 参数错误
- 无权限
- 资源不存在
- 并发冲突
- 重复请求
- 外部接口失败
- 外部接口超时
- 结果未知
- 事务失败
- 异步任务失败
- 数据不一致
- 人工介入
## 11. Admin / 运营闭环
必须回答:
- Admin 在哪里看到这件事?
- Admin 能筛选什么?
- Admin 能处理什么?
- Admin 处理后状态如何变化?
- 是否需要备注、原因、凭证?
- 是否写审计日志?
- 用户能否看到处理结果?
如果没有 Admin 入口,必须说明为什么不需要。
## 12. 日志、审计与可观测性
必须列出:
- 关键日志
- 审计事件
- 操作人
- request_id / trace_id
- 重要状态变化
- 外部请求/响应是否保存
- 敏感字段如何脱敏
- 排查问题时查哪些表、哪些日志
## 13. 前端 / 页面 / 交互契约
如果涉及前端,必须说明:
- 页面入口
- Tab / 弹窗 / 表单
- 空态
- 加载态
- 错误态
- 提交前确认
- 成功后刷新哪些数据
- 权限不足时如何展示
- 移动端是否需要特殊处理
不要只写“新增页面”。
## 14. 分阶段实现计划
阶段必须按业务闭环拆,不要只按技术层拆。
每个 Phase 必须包含:
```text
Phase N - 名称
目标:
本阶段完成后,哪个业务闭环可用:
包含需求:
不包含:
依赖:
涉及模块:
关键表:
关键接口:
关键状态机:
关键风险:
验证方式:
完成标准:
```
阶段顺序必须解释为什么这样排。
如果有破坏性变更,必须单独阶段、单独部署、单独回滚。
## 15. 任务拆分
在系统级 plan 完成后,再拆 tasks。
每个 task 必须包含:
- 任务目标
- 修改文件范围
- 输入依赖
- 输出产物
- 验证方式
- 不允许做什么
- 完成标准
不要把多个无关业务目标塞进同一个 task。
## 16. 验收标准
验收必须是可观察、可执行、可判定的。
格式:
```text
1. 当用户执行 XXX 时,系统应 XXX
2. DB 中应出现 XXX
3. 日志中应出现 XXX
4. Admin 页面应能看到 XXX
5. 重复提交时不会 XXX
6. 外部服务失败时状态为 XXX
```
验收必须覆盖:
- 用户主链路
- Admin 处理链路
- 权限拒绝
- 幂等重复
- 并发边界
- 外部失败
- 数据一致性
- 文档/路由注册
如果项目不写自动化测试,则验收方式使用:
- `go build`
- `rg` / 静态搜索
- 数据库查询
- curl / Postman 手工接口验证
- Worker/API 日志
- OpenAPI 文档生成检查
不要生成 `*_test.go`,除非我明确要求。
## 17. 风险与后置增强
必须分成:
- 当前必须解决的风险
- 可以接受但要记录的风险
- V2 后置增强
- 不再讨论的 rejected 方案
每个 rejected 方案都要写拒绝原因,防止后续 AI 反复重新考虑。
## 18. 反向审查
生成初稿后,必须审查以下问题:
1. 是否有用户旅程断点?
2. 是否有数据事实源不清?
3. 是否有状态机缺口?
4. 是否有权限/越权风险?
5. 是否有幂等和并发风险?
6. 是否有外部服务失败路径?
7. 是否有 Admin/运营处理缺口?
8. 是否有资金/余额/库存类一致性风险?
9. 是否有接口文档/路由注册遗漏?
10. 是否有验收标准不可执行?
11. 是否有和项目规范冲突?
12. 是否有 scope creep
13. 是否有“写入完成但读取侧没规划”的问题?
14. 是否有“后端完成但前端/运营入口缺失”的问题?
审查后输出:
```text
发现的问题:
影响:
修正方式:
是否已合并进最终 plan
```
# 四、输出要求
输出必须使用中文。
最终输出结构:
1. 关键澄清问题(如需要)
2. 共识摘要
3. 完整系统级 plan
4. 分阶段计划
5. 任务拆分
6. MVP 验收清单
7. 风险与后置增强
8. 反向审查结果
不要空泛,不要只写原则。必须具体到业务状态、接口、数据、失败路径和验收证据。
# 五、我的项目特殊规范
以下规范必须遵守:
- 使用中文交互、中文文档、中文注释、中文日志、中文用户错误消息。
- 代码命名使用英文。
- 严格遵守项目现有技术栈,不主动引入新框架或新依赖。
- 遵守 Handler → Service → Store → Model 分层。
- Handler 不写业务逻辑。
- Service 承载业务规则。
- Store 负责数据访问和事务。
- 所有错误使用项目统一错误码。
- 新增 Handler 必须同步路由注册和文档生成器。
- 状态类字段用 int类型/方式类字段用 string。
- 所有枚举描述必须与 constants 保持一致。
- 数据库不使用外键约束,不使用 GORM 关联标签。
- 默认不写自动化测试,除非我明确要求。
- 验证默认使用go build、静态搜索、数据库查询、curl/Postman、日志、OpenAPI 文档生成检查。
# 六、现在请先做第一步
我接下来会提供功能背景。
你收到背景后,先不要生成最终 plan。
请先输出:
1. 你理解的目标
2. 你理解的非目标
3. 你认为必须确认的问题
4. 你建议我补充的上下文
5. 哪些地方可以先用假设推进
````

View File

@@ -17,7 +17,6 @@
| 测试接口/验证数据 | `db-validation` | PostgreSQL MCP 使用方法和验证示例 |
| 数据库迁移 | `db-migration` | 迁移命令、文件规范、执行流程、失败处理 |
| 维护规范文档 | `doc-management` | 规范文档流程和维护规则 |
| 调试 bug / 排查异常 | `systematic-debugging` | 四阶段根因分析流程、逐层诊断、场景速查表 |
| 编写 Go 代码注释/文档注释 | `comment-standards` | 包/结构体/接口/函数/内联注释完整规范与示例 |
| 创建涉及接口的 OpenSpec 提案 | `openspec-api-contract` | 探索阶段引导清单、提案必填章节与完成标准 |
@@ -113,6 +112,24 @@ Handler → Service → Store → Model
- 禁止硬编码字符串和 magic numbers
- **必须为所有常量添加中文注释**
### 枚举与状态字段(必须遵守)
**两个强制规则**
1. **int vs string**:状态类(生命周期)用 `int`,类型/方式类用 `string`
2. **DTO description 必须从 constants 原文抄写**,禁止凭记忆填写枚举值(历史上已有 description 与 constants 不一致导致前端显示错误的案例)
```go
// ✅ description 从 constants 原文抄,格式统一用冒号+逗号
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)"`
StatusName string `json:"status_name" description:"状态名称(中文)"` // Response DTO 必须加
// ❌ 禁止:启用/禁用用 1=启用 2=禁用(全局约定是 0=禁用, 1=启用)
// ❌ 禁止description 枚举值与 constants 不一致
```
**完整规范**: 参见 [`docs/enum-status-standards.md`](docs/enum-status-standards.md)
### 注释规范
- **所有注释使用中文**,导出符号必须有文档注释(包、函数、类型、接口、常量)
@@ -220,6 +237,13 @@ Handler → Service → Store → Model
- [ ] 常量定义在 `pkg/constants/`
- [ ] 使用 Go 惯用法(非 Java 风格)
### 枚举与状态
- [ ] 状态类字段用 `int`,类型/方式类字段用 `string`
- [ ] 禁用/启用使用 `0=禁用, 1=启用`(禁止 `1=启用, 2=禁用`
- [ ] DTO description 枚举列表已从 `pkg/constants/` 原文抄写,无遗漏、无错误
- [ ] Response DTO 的 int 状态字段有对应的 `_name` 文字字段
### 文档和注释
- [ ] 所有注释使用中文
@@ -309,4 +333,22 @@ queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, payloadBytes)
> "我注意到任务 2.1 和 2.2 可以合并为一步完成,是否可以这样优化?"
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
---
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
## Agent skills
### Issue tracker
Issue 和 PRD 以本地 Markdown 文件形式存放在 `.scratch/<feature-slug>/` 下。详见 `docs/agents/issue-tracker.md`
### Triage labels
使用默认的英文 Triage 标签词汇(`needs-triage` / `needs-info` / `ready-for-agent` / `ready-for-human` / `wontfix`)。详见 `docs/agents/triage-labels.md`
### Domain docs
单 Context 布局——根目录的 `CONTEXT.md` + `docs/adr/`(按需懒创建),与现有的 `openspec/` 提案工作流并存。详见 `docs/agents/domain.md`

19
CONTEXT.md Normal file
View File

@@ -0,0 +1,19 @@
# 领域术语表
## 资产Asset
**IoT 卡 / IotCard**:物联网流量卡,以 ICCID 为唯一标识。分为独立卡(`is_standalone=true`,未绑定设备)和设备卡(绑定在设备上)。
**设备 / Device**硬件设备以虚拟号VirtualNo为业务标识可插卡使用。
**资产世代 / Generation**`generation` 字段记录卡/设备经历"换货+旧资产转新"操作的次数。初始值为 1每完成一次换货且旧资产执行"转新"后加 1。`generation > 1` 说明该资产曾作为旧资产经历过换货并被重新投入使用。
**资产业务状态 / AssetStatus**`asset_status` 字段表示资产在 CMP 内部的业务生命周期1=在库, 2=已销售, 3=已换货, 4=已停用)。换货完成时旧资产置为 3执行"旧资产转新"后重置为 1同时 generation+1。
## 企业授权Enterprise Authorization
**卡企业授权**:将独立卡授权给企业使用的操作。**业务约束:一张卡在同一时间只能授权给一个企业**(与设备授权保持一致,数据库通过部分唯一索引强制约束)。
**有效授权**`revoked_at IS NULL AND deleted_at IS NULL` 的授权记录。撤回授权后(`revoked_at` 有值)视为历史记录,不计入当前授权关系。
**设备企业授权**:将设备(含其绑定卡)授权给企业使用的操作。同一台设备同一时间只能授权给一个企业(`uq_active_device_auth` 唯一约束)。

View File

@@ -16,6 +16,16 @@
君鸿卡管系统是一个物联网卡和号卡的全生命周期管理平台,支持三种客户类型和两种组织实体的多租户管理。
### 后台运营批量能力
后台卡/设备套餐系列绑定接口支持按显式列表、号段范围、筛选条件三种方式批量选择资源。运营人员可按完整筛选结果批量设置或清除套餐系列绑定,不受列表分页勾选限制。
### 设备状态口径
设备 `status` 仅表示归属状态(在库/已分销),业务激活状态通过 `activation_status` 返回;设备需同时具备生效中主套餐和任意已实名绑定卡才视为已激活。
后台设备列表 `GET /api/admin/devices` 支持按 `virtual_no``imei`、设备名称、归属状态、激活状态、店铺、套餐系列等条件筛选。
### 三种客户类型
| 客户类型 | 业务特点 | 典型场景 | 钱包归属 |
@@ -210,20 +220,39 @@ default:
- **统一错误处理**:全局 ErrorHandler 统一处理所有 API 错误,返回一致的 JSON 格式包含错误码、消息、时间戳Panic 自动恢复防止服务崩溃;错误分类处理(客户端 4xx、服务端 5xx和日志级别控制敏感信息自动脱敏保护
- **数据持久化**GORM + PostgreSQL 集成,提供完整的 CRUD 操作、事务支持和数据库迁移能力
- **异步任务处理**Asynq 任务队列集成,支持任务提交、后台执行、自动重试和幂等性保障,实现邮件发送、数据同步等异步任务
- **统一导出任务系统**:新增全局导出任务入口(`/api/admin/export-tasks`),支持 `scene=device/iot_card``format=xlsx/csv`、异步分片执行、任务取消、详情直出 24 小时下载链接;详见 [功能总结](docs/unified-export-task-system/功能总结.md) 与 [验收记录](docs/unified-export-task-system/验收记录.md)
- **资产操作审计日志**:新增 `tb_asset_operation_log`,统一覆盖卡/设备敏感写操作与统一资产入口,记录 `success/failed/denied`、前后镜像、请求上下文、批量统计并支持敏感字段脱敏;详见 [功能总结](docs/add-asset-operation-audit-log/功能总结.md)、[接口回放示例](docs/add-asset-operation-audit-log/接口回放示例.md) 与 [SQL 验收脚本](docs/add-asset-operation-audit-log/手工验收脚本.sql)
- **RBAC 权限系统**:完整的基于角色的访问控制,支持账号、角色、权限的多对多关联和层级关系;基于店铺层级的自动数据权限过滤,实现多租户数据隔离;使用 PostgreSQL WITH RECURSIVE 查询下级店铺并通过 Redis 缓存优化性能完整的权限检查功能支持路由级别的细粒度权限控制支持平台过滤web/h5/all和超级管理员自动跳过详见 [功能总结](docs/004-rbac-data-permission/功能总结.md)、[使用指南](docs/004-rbac-data-permission/使用指南.md) 和 [权限检查使用指南](docs/permission-check-usage.md)
- **商户管理**完整的商户Shop和商户账号管理功能支持商户创建时自动创建初始坐席账号、删除商户时批量禁用关联账号、账号密码重置等功能详见 [使用指南](docs/shop-management/使用指南.md) 和 [API 文档](docs/shop-management/API文档.md)
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制**登录响应包含菜单树和按钮权限**menus/buttons前端无需二次处理直接渲染侧边栏和控制按钮显示详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md)、[架构说明](docs/auth-architecture.md) 和 [菜单权限使用指南](docs/login-menu-button-response/使用指南.md)
- **B 端认证系统**:完整的后台和 H5 认证功能,支持基于 Redis 的 Token 管理和双令牌机制Access Token 24h + Refresh Token 7天包含登录、登出、Token 刷新、用户信息查询和密码修改功能通过用户类型隔离确保后台SuperAdmin、Platform、Agent和 H5Agent、Enterprise的访问控制详见 [API 文档](docs/api/auth.md)、[使用指南](docs/auth-usage-guide.md) 和 [架构说明](docs/auth-architecture.md)
- **生命周期管理**:物联网卡/号卡的开卡、激活、停机、复机、销户
- **代理商体系**:层级管理和分佣结算,支持差价佣金和一次性佣金两种佣金类型,详见 [套餐与佣金业务模型](docs/commission-package-model.md)
- **代理开放接口**:新增 `/api/open/v1` 签名接口,代理店铺第三方系统可调用卡流量、卡状态、实名状态、套餐列表、预充值钱包余额/流水和钱包套餐购买能力。详见 [对接说明](docs/agent-open-api/功能总结.md) 与 [误发差价佣金修复说明](docs/agent-open-api/开放接口误发差价佣金修复说明.md)
- **批量同步**:卡状态、实名状态、流量使用情况
- **轮询系统**IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md)
- **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/)
- **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 [功能总结](docs/package-price-fallback-and-platform-gift-policy/功能总结.md) 与 [最终验收清单](docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md)
- **分佣验证指引**:对代理分佣的冻结、解冻、提现校验流程进行了结构化说明与流程图,详见 [分佣逻辑正确与否验证](docs/优化说明/分佣逻辑正确与否验证.md)
- **对象存储**S3 兼容的对象存储服务集成(联通云 OSS支持预签名 URL 上传、文件下载、临时文件处理;用于 ICCID 批量导入、数据导出等场景;详见 [使用指南](docs/object-storage/使用指南.md) 和 [前端接入指南](docs/object-storage/前端接入指南.md)
- **微信集成**:完整的微信公众号 OAuth 认证和微信支付功能JSAPI + H5使用 PowerWeChat v3 SDK支持个人客户微信授权登录、账号绑定、微信内支付和浏览器 H5 支付;支付回调自动验证签名和幂等性处理;详见 [使用指南](docs/wechat-integration/使用指南.md) 和 [API 文档](docs/wechat-integration/API文档.md)
- **C 端微信 AppID 获取接口**:新增免登录接口 `GET /api/c/v1/wechat/appid`,仅返回当前生效微信配置中的公众号 `app_id`,供前端在拉起微信授权或初始化微信能力前动态获取;详见 [功能总结](docs/client-wechat-appid/功能总结.md)
- **订单超时自动取消**:待支付订单(微信/支付宝30 分钟超时自动取消,支持钱包余额解冻;使用 Asynq Scheduler 每分钟扫描,取代原有 time.Ticker 实现;同时将告警检查和数据清理迁移至 Asynq Scheduler 统一调度;详见 [功能总结](docs/order-expiration/功能总结.md)
### 导出任务接口示例
```bash
# 创建导出任务scene 支持 device / iot_card
curl -X POST '/api/admin/export-tasks' \\
-H 'Content-Type: application/json' \\
-H 'Authorization: Bearer <token>' \\
-d '{\"scene\":\"device\",\"format\":\"xlsx\"}'
# 查询导出任务列表(支持 scene/status/time 过滤)
curl '/api/admin/export-tasks?page=1&page_size=20&scene=device' \\
-H 'Authorization: Bearer <token>'
```
## 用户体系设计
系统支持四种用户类型和两种组织实体,实现分层级的多租户管理:
@@ -471,17 +500,22 @@ junhong_cmp_fiber/
└────────────┬────────────┘
┌────────────▼────────────┐
│ 4. 认证中间件
│ 4. CORS 中间件 │
│ (跨域预检) │
└────────────┬────────────┘
┌────────────▼────────────┐
│ 5. 认证中间件 │
│ (按路由组配置) │ ─── 模块化路由注册
└────────────┬────────────┘
┌────────────▼────────────┐
5. RateLimiter 中间件 │
6. RateLimiter 中间件 │
│ (限流) │ ─── 可选 (config: enable_rate_limiter)
└────────────┬────────────┘
┌────────────▼────────────┐
6. 路由处理器 │
7. 路由处理器 │
│ (业务逻辑) │
└────────────┬────────────┘
@@ -520,12 +554,22 @@ junhong_cmp_fiber/
- **始终激活**:是
- **日志格式**:包含字段的 JSONtimestamp、level、method、path、status、duration_ms、request_id、ip、user_agent、user_id
#### 4. 认证中间件pkg/middleware/auth.go 和 internal/middleware/
#### 4. CORS 中间件Fiber cors
- **用途**:允许已配置前端域名跨域访问 API
- **行为**
- 对浏览器预检 `OPTIONS` 请求返回 204
- 添加 `Access-Control-Allow-Origin``Access-Control-Allow-Methods``Access-Control-Allow-Headers`
- 必须在业务路由和认证中间件之前注册,避免预检请求被 401 拦截
- **配置**`middleware.cors.*`(默认启用)
- **默认来源**`*`(全部来源)。使用通配来源时 `allow_credentials` 必须为 `false`
#### 5. 认证中间件pkg/middleware/auth.go 和 internal/middleware/
- **用途**:使用 Token 验证对请求进行认证
- **行为**
-`Authorization: Bearer {token}` 请求头提取 token
- 通过 TokenValidator 函数验证 token支持 JWT 和 Redis Token
- 如果缺失/无效 token 返回 401
- `OPTIONS` 预检请求直接返回 204不做 Token 校验
- 成功时将用户信息存储在上下文中UserID、UserType、ShopID、EnterpriseID
- **实现方式**:模块化路由注册(无全局配置)
- `/api/admin/*`后台认证SuperAdmin、Platform、Agent
@@ -537,7 +581,7 @@ junhong_cmp_fiber/
- 1002无效或过期 token
- 1003权限不足
#### 5. RateLimiter 中间件internal/middleware/ratelimit.go
#### 6. RateLimiter 中间件internal/middleware/ratelimit.go
- **用途**:通过限制请求速率保护 API 免受滥用
- **行为**
- 按客户端 IP 地址追踪请求
@@ -550,7 +594,7 @@ junhong_cmp_fiber/
- `redis`:基于 Redis分布式持久化
- **错误码**1003请求过于频繁
#### 6. 路由处理器
#### 7. 路由处理器
- **用途**:执行端点的业务逻辑
- **可用上下文数据**
- 请求 ID`c.Locals(constants.ContextKeyRequestID)`
@@ -564,6 +608,12 @@ junhong_cmp_fiber/
app.Use(recover.New())
app.Use(addRequestID())
app.Use(loggerMiddleware())
app.Use(cors.New(cors.Config{
AllowOrigins: config.GetConfig().Middleware.CORS.AllowOrigins,
AllowMethods: config.GetConfig().Middleware.CORS.AllowMethods,
AllowHeaders: config.GetConfig().Middleware.CORS.AllowHeaders,
AllowCredentials: config.GetConfig().Middleware.CORS.AllowCredentials,
}))
// 模块化路由注册(认证中间件按路由组配置)
routes.RegisterRoutes(app, handlers, middlewares)
@@ -873,6 +923,7 @@ rdb.Set(ctx, key, status, time.Hour)
- **[限流指南](docs/rate-limiting.md)**:全面的限流配置和使用
- **[错误处理使用指南](docs/003-error-handling/使用指南.md)**错误码参考、Handler 使用、客户端处理、最佳实践
- **[错误处理架构说明](docs/003-error-handling/架构说明.md)**:架构设计、性能优化、扩展性说明
- **[订单操作者与产业链佣金语义修复](docs/feature-001-order-operator-and-chain-commission-semantics/功能总结.md)**:说明订单操作者账号化、佣金流程状态与业务结果拆分、以及手工验收口径
### 架构设计

View File

@@ -21,6 +21,7 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
app := fiber.New()
// 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构)
// 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。
handlers := openapi.BuildDocHandlers()
// 4. 注册所有路由到文档生成器

View File

@@ -1,15 +1,19 @@
package main
import (
stdErrors "errors"
"os"
"os/signal"
"reflect"
"strconv"
"strings"
"syscall"
"time"
"github.com/bytedance/sonic"
"github.com/gofiber/fiber/v2"
"github.com/gofiber/fiber/v2/middleware/compress"
"github.com/gofiber/fiber/v2/middleware/cors"
"github.com/gofiber/fiber/v2/middleware/requestid"
"github.com/google/uuid"
"github.com/redis/go-redis/v9"
@@ -197,19 +201,76 @@ func closeQueue(queueClient *queue.Client, appLogger *zap.Logger) {
// createFiberApp 创建 Fiber 应用
func createFiberApp(cfg *config.Config, appLogger *zap.Logger) *fiber.App {
registerTimeParserCompat()
return fiber.New(fiber.Config{
AppName: "君鸿卡管系统 v1.0.0",
StrictRouting: true,
CaseSensitive: true,
JSONEncoder: sonic.Marshal,
JSONDecoder: sonic.Unmarshal,
Prefork: cfg.Server.Prefork,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
ErrorHandler: internalMiddleware.ErrorHandler(appLogger),
AppName: "君鸿卡管系统 v1.0.0",
StrictRouting: true,
CaseSensitive: true,
JSONEncoder: sonic.Marshal,
JSONDecoder: sonic.Unmarshal,
EnableSplittingOnParsers: true,
Prefork: cfg.Server.Prefork,
ReadTimeout: cfg.Server.ReadTimeout,
WriteTimeout: cfg.Server.WriteTimeout,
ErrorHandler: internalMiddleware.ErrorHandler(appLogger),
})
}
// registerTimeParserCompat 注册时间参数解析兼容器
// 兼容格式RFC3339、2006-01-02、2006-01-02 15:04:05、2006-01-02T15:04:05
func registerTimeParserCompat() {
fiber.SetParserDecoder(fiber.ParserConfig{
IgnoreUnknownKeys: true,
ZeroEmpty: true,
ParserType: []fiber.ParserType{
{
Customtype: time.Time{},
Converter: func(value string) reflect.Value {
parsedTime, err := parseFlexibleTime(value)
if err != nil {
return reflect.Value{}
}
return reflect.ValueOf(parsedTime)
},
},
},
})
}
// parseFlexibleTime 解析多种常见时间格式
func parseFlexibleTime(value string) (time.Time, error) {
trimmed := strings.TrimSpace(value)
if trimmed == "" {
return time.Time{}, nil
}
// 优先支持带时区的标准时间格式。
if parsed, err := time.Parse(time.RFC3339, trimmed); err == nil {
return parsed, nil
}
// 兼容无时区格式,按服务器本地时区解析。
layouts := []string{
"2006-01-02 15:04:05",
"2006-01-02T15:04:05",
"2006-01-02",
}
var parseErr error
for _, layout := range layouts {
if parsed, err := time.ParseInLocation(layout, trimmed, time.Local); err == nil {
return parsed, nil
} else {
parseErr = err
}
}
if parseErr == nil {
parseErr = stdErrors.New("时间格式不支持")
}
return time.Time{}, parseErr
}
// initMiddleware 注册中间件
func initMiddleware(app *fiber.App, cfg *config.Config, appLogger *zap.Logger) {
// 1. Recover - 必须第一个,捕获所有 panic
@@ -229,6 +290,23 @@ func initMiddleware(app *fiber.App, cfg *config.Config, appLogger *zap.Logger) {
// 4. Logger - 记录所有请求(在 Compress 之后注册,读到的是未压缩的原始响应体)
app.Use(logger.Middleware())
// 5. CORS - 必须在业务路由和认证中间件之前注册,确保预检请求不被认证拦截
if cfg.Middleware.CORS.Enabled {
app.Use(cors.New(cors.Config{
AllowOrigins: cfg.Middleware.CORS.AllowOrigins,
AllowMethods: cfg.Middleware.CORS.AllowMethods,
AllowHeaders: cfg.Middleware.CORS.AllowHeaders,
ExposeHeaders: cfg.Middleware.CORS.ExposeHeaders,
AllowCredentials: cfg.Middleware.CORS.AllowCredentials,
MaxAge: cfg.Middleware.CORS.MaxAge,
}))
appLogger.Info("CORS 中间件已启用",
zap.String("allow_origins", cfg.Middleware.CORS.AllowOrigins),
zap.String("allow_methods", cfg.Middleware.CORS.AllowMethods),
zap.Bool("allow_credentials", cfg.Middleware.CORS.AllowCredentials),
)
}
}
// initRoutes 注册路由

View File

@@ -30,6 +30,7 @@ func generateAdminDocs(outputPath string) error {
app := fiber.New()
// 3. 创建所有 Handler使用 nil 依赖,因为只需要路由结构)
// 新增 Handler 必须注册到 openapi.BuildDocHandlers代理开放接口也从该入口进入文档生成器。
handlers := openapi.BuildDocHandlers()
// 4. 注册所有路由到文档生成器

View File

@@ -0,0 +1,293 @@
package main
import (
"context"
"flag"
"fmt"
"os"
"sort"
"strconv"
"strings"
"time"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/polling"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
pkgbootstrap "github.com/break/junhong_cmp_fiber/pkg/bootstrap"
"github.com/break/junhong_cmp_fiber/pkg/config"
"github.com/break/junhong_cmp_fiber/pkg/constants"
"github.com/break/junhong_cmp_fiber/pkg/database"
"github.com/break/junhong_cmp_fiber/pkg/logger"
)
type finalizeOptions struct {
batchNo string
apply bool
batchSize int
timeout time.Duration
}
type finalizeStats struct {
importedTotal int64
pollingCards int
enqueuedCards int
skippedNoConfig int
firstID uint
lastID uint
taskCounts map[string]int
}
func main() {
if err := run(); err != nil {
fmt.Fprintf(os.Stderr, "迁移收尾失败: %v\n", err)
os.Exit(1)
}
}
func run() error {
opts := parseOptions()
if strings.TrimSpace(opts.batchNo) == "" {
return fmt.Errorf("batch-no 不能为空")
}
if opts.batchSize <= 0 {
return fmt.Errorf("batch-size 必须大于 0")
}
cfg, err := config.Load()
if err != nil {
return fmt.Errorf("加载配置失败: %w", err)
}
if _, err := pkgbootstrap.EnsureDirectories(cfg, nil); err != nil {
return fmt.Errorf("初始化目录失败: %w", err)
}
if err := initLogger(cfg); err != nil {
return err
}
defer func() { _ = logger.Sync() }()
appLogger := logger.GetAppLogger()
ctx, cancel := context.WithTimeout(context.Background(), opts.timeout)
defer cancel()
db, err := database.InitPostgreSQL(&cfg.Database, appLogger)
if err != nil {
return fmt.Errorf("初始化 PostgreSQL 失败: %w", err)
}
defer closeDB(db, appLogger)
var redisClient *redis.Client
if opts.apply {
redisClient, err = initRedis(cfg, appLogger)
if err != nil {
return err
}
defer func() {
if closeErr := redisClient.Close(); closeErr != nil {
appLogger.Error("关闭 Redis 连接失败", zap.Error(closeErr))
}
}()
}
configStore := postgres.NewPollingConfigStore(db)
configMgr := polling.NewPollingConfigManager(configStore, redisClient, appLogger)
if opts.apply {
if err := configMgr.Load(ctx); err != nil {
return fmt.Errorf("加载轮询配置失败: %w", err)
}
} else {
if err := configMgr.LoadForInspection(ctx); err != nil {
return fmt.Errorf("加载轮询配置失败: %w", err)
}
}
var queueMgr *polling.PollingQueueManager
var initializer *polling.PollingInitializer
if opts.apply {
queueMgr = polling.NewPollingQueueManager(redisClient, constants.PollingShardCount, appLogger)
cardStore := postgres.NewIotCardStore(db, redisClient)
initializer = polling.NewPollingInitializer(cardStore, redisClient, configMgr, queueMgr, appLogger)
}
stats, err := finalizePolling(ctx, db, configMgr, queueMgr, initializer, opts)
if err != nil {
return err
}
printStats(stats, opts)
return nil
}
func parseOptions() finalizeOptions {
opts := finalizeOptions{}
flag.StringVar(&opts.batchNo, "batch-no", "MIGRATION-QICHENG", "迁移批次号")
flag.BoolVar(&opts.apply, "apply", false, "实际写入 Redis 轮询队列和卡缓存;默认只 dry-run")
flag.IntVar(&opts.batchSize, "batch-size", 500, "每批处理卡数量")
flag.DurationVar(&opts.timeout, "timeout", 10*time.Minute, "迁移收尾超时时间")
flag.Parse()
return opts
}
func initLogger(cfg *config.Config) error {
if err := logger.InitLoggers(
cfg.Logging.Level,
cfg.Logging.Development,
logger.LogRotationConfig{
Filename: cfg.Logging.AppLog.Filename,
MaxSize: cfg.Logging.AppLog.MaxSize,
MaxBackups: cfg.Logging.AppLog.MaxBackups,
MaxAge: cfg.Logging.AppLog.MaxAge,
Compress: cfg.Logging.AppLog.Compress,
},
logger.LogRotationConfig{
Filename: cfg.Logging.AccessLog.Filename,
MaxSize: cfg.Logging.AccessLog.MaxSize,
MaxBackups: cfg.Logging.AccessLog.MaxBackups,
MaxAge: cfg.Logging.AccessLog.MaxAge,
Compress: cfg.Logging.AccessLog.Compress,
},
); err != nil {
return fmt.Errorf("初始化日志失败: %w", err)
}
return nil
}
func initRedis(cfg *config.Config, appLogger *zap.Logger) (*redis.Client, error) {
redisAddr := cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port)
client, err := database.NewRedisClient(database.RedisConfig{
Address: redisAddr,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
PoolSize: cfg.Redis.PoolSize,
MinIdleConns: cfg.Redis.MinIdleConns,
DialTimeout: cfg.Redis.DialTimeout,
ReadTimeout: cfg.Redis.ReadTimeout,
WriteTimeout: cfg.Redis.WriteTimeout,
}, appLogger)
if err != nil {
return nil, fmt.Errorf("初始化 Redis 失败: %w", err)
}
return client, nil
}
func closeDB(db *gorm.DB, appLogger *zap.Logger) {
sqlDB, _ := db.DB()
if sqlDB == nil {
return
}
if err := sqlDB.Close(); err != nil {
appLogger.Error("关闭 PostgreSQL 连接失败", zap.Error(err))
}
}
func finalizePolling(
ctx context.Context,
db *gorm.DB,
configMgr *polling.PollingConfigManager,
queueMgr *polling.PollingQueueManager,
initializer *polling.PollingInitializer,
opts finalizeOptions,
) (finalizeStats, error) {
stats := finalizeStats{taskCounts: map[string]int{}}
if err := db.WithContext(ctx).Model(&model.IotCard{}).
Where("batch_no = ?", opts.batchNo).
Count(&stats.importedTotal).Error; err != nil {
return stats, fmt.Errorf("统计迁移卡失败: %w", err)
}
var lastID uint
for {
cards, err := loadPollingCards(ctx, db, opts.batchNo, lastID, opts.batchSize)
if err != nil {
return stats, err
}
if len(cards) == 0 {
break
}
updateStats(&stats, configMgr, cards)
if opts.apply {
cardIDs := collectCardIDs(cards)
if err := queueMgr.RemoveBatchFromCurrentShardQueues(ctx, cardIDs); err != nil {
return stats, fmt.Errorf("清理轮询旧队列失败: %w", err)
}
if err := initializer.InitCards(ctx, cards); err != nil {
return stats, fmt.Errorf("重建轮询队列失败: %w", err)
}
}
lastID = cards[len(cards)-1].ID
}
return stats, nil
}
func loadPollingCards(ctx context.Context, db *gorm.DB, batchNo string, lastID uint, limit int) ([]*model.IotCard, error) {
var cards []*model.IotCard
err := db.WithContext(ctx).
Where("id > ? AND batch_no = ? AND enable_polling = true", lastID, batchNo).
Order("id ASC").
Limit(limit).
Find(&cards).Error
if err != nil {
return nil, fmt.Errorf("查询迁移卡失败: %w", err)
}
return cards, nil
}
func updateStats(stats *finalizeStats, configMgr *polling.PollingConfigManager, cards []*model.IotCard) {
for _, card := range cards {
stats.pollingCards++
if stats.firstID == 0 {
stats.firstID = card.ID
}
stats.lastID = card.ID
intervals := configMgr.MergedTaskIntervals(card)
if len(intervals) == 0 {
stats.skippedNoConfig++
continue
}
stats.enqueuedCards++
for taskType := range intervals {
stats.taskCounts[taskType]++
}
}
}
func collectCardIDs(cards []*model.IotCard) []uint {
ids := make([]uint, 0, len(cards))
for _, card := range cards {
ids = append(ids, card.ID)
}
return ids
}
func printStats(stats finalizeStats, opts finalizeOptions) {
mode := "dry-run"
if opts.apply {
mode = "apply"
}
fmt.Printf("迁移收尾完成: mode=%s batch_no=%s\n", mode, opts.batchNo)
fmt.Printf("导入卡总数: %d\n", stats.importedTotal)
fmt.Printf("启用轮询卡: %d\n", stats.pollingCards)
fmt.Printf("可入队卡: %d\n", stats.enqueuedCards)
fmt.Printf("无匹配轮询配置: %d\n", stats.skippedNoConfig)
if stats.firstID > 0 {
fmt.Printf("处理 ID 范围: %d-%d\n", stats.firstID, stats.lastID)
}
taskTypes := make([]string, 0, len(stats.taskCounts))
for taskType := range stats.taskCounts {
taskTypes = append(taskTypes, taskType)
}
sort.Strings(taskTypes)
for _, taskType := range taskTypes {
fmt.Printf("任务入队预估: %s=%d\n", taskType, stats.taskCounts[taskType])
}
if !opts.apply {
fmt.Println("当前为 dry-run,没有写入 Redis确认无误后增加 --apply 执行。")
}
}

View File

@@ -2,18 +2,21 @@ package main
import (
"context"
"fmt"
"os"
"os/signal"
"strconv"
"syscall"
"time"
"github.com/bytedance/sonic"
"github.com/hibiken/asynq"
"github.com/redis/go-redis/v9"
"go.uber.org/zap"
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
"github.com/break/junhong_cmp_fiber/internal/gateway"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/internal/polling"
iot_card_svc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
@@ -25,14 +28,51 @@ import (
"github.com/break/junhong_cmp_fiber/pkg/logger"
"github.com/break/junhong_cmp_fiber/pkg/queue"
"github.com/break/junhong_cmp_fiber/pkg/storage"
"gorm.io/gorm"
)
const (
workerModuleQueueServer = "queue_server"
workerModulePollingInitializer = "polling_initializer"
workerModulePollingScheduler = "polling_scheduler"
workerModuleAsynqScheduler = "asynq_scheduler"
importRescueLimit = 500 // 启动补偿单次扫描的最大导入任务数
)
// workerModuleStatus 描述当前 Worker 角色下的模块启停计划。
type workerModuleStatus struct {
enabled []string
disabled []string
}
// workerRuntime 保存所有角色共享的 Worker 运行时依赖。
type workerRuntime struct {
redisAddr string
redisClient *redis.Client
db *gorm.DB
storageSvc *storage.Service
gatewayClient *gateway.Client
asynqClient *asynq.Client
workerResult *bootstrap.WorkerBootstrapResult
workerServer *queue.Server
pollingConfigMgr *polling.PollingConfigManager
pollingQueueMgr *polling.PollingQueueManager
pollingIotCardStore *postgres.IotCardStore
pollingBase *task.PollingBase
lifecycleSvc *polling.PollingLifecycleService
}
func main() {
cfg, err := config.Load()
if err != nil {
panic("加载配置失败: " + err.Error())
}
runWorker(cfg)
}
// runWorker 编排 Worker 进程启动、模块启停和优雅关闭流程。
func runWorker(cfg *config.Config) {
if _, err := pkgBootstrap.EnsureDirectories(cfg, nil); err != nil {
panic("初始化目录失败: " + err.Error())
}
@@ -62,9 +102,91 @@ func main() {
}()
appLogger := logger.GetAppLogger()
appLogger.Info("Worker 服务启动中...")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// 连接 Redis
moduleStatus := buildWorkerModuleStatus(cfg.Worker.Role)
appLogger.Info("Worker 服务启动中...")
logWorkerRole(appLogger, cfg.Worker.Role, cfg.Worker.InstanceName, moduleStatus)
runtime := initWorkerRuntime(ctx, cfg, appLogger)
defer runtime.close(appLogger)
var pollingInitializer *polling.PollingInitializer
var pollingScheduler *polling.Scheduler
var asynqScheduler *asynq.Scheduler
if runsSingletonModules(cfg.Worker.Role) {
pollingInitializer = startPollingInitializer(ctx, runtime, appLogger)
pollingScheduler = startPollingScheduler(ctx, runtime, pollingInitializer, appLogger)
asynqScheduler = startAsynqScheduler(cfg, runtime.redisAddr, appLogger)
}
taskHandler := createTaskHandler(runtime, appLogger)
taskHandler.RegisterHandlers()
rescuePendingImportTasks(ctx, runtime, appLogger)
appLogger.Info("Worker 服务器配置完成",
zap.Int("concurrency", cfg.Queue.Concurrency),
zap.Any("queues", cfg.Queue.Queues))
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(quit)
go func() {
if err := runtime.workerServer.Run(taskHandler.GetMux()); err != nil {
appLogger.Fatal("Worker 服务器运行失败", zap.Error(err))
}
}()
appLogger.Info("Worker 服务器已启动")
<-quit
shutdownWorker(cancel, appLogger, runtime.workerServer, pollingInitializer, pollingScheduler, asynqScheduler)
}
// buildWorkerModuleStatus 根据角色生成启用/禁用模块列表,供启动日志和人工验收使用。
func buildWorkerModuleStatus(role string) workerModuleStatus {
status := workerModuleStatus{
enabled: []string{workerModuleQueueServer},
}
if runsSingletonModules(role) {
status.enabled = append(
status.enabled,
workerModulePollingInitializer,
workerModulePollingScheduler,
workerModuleAsynqScheduler,
)
return status
}
status.disabled = []string{
workerModulePollingInitializer,
workerModulePollingScheduler,
workerModuleAsynqScheduler,
}
return status
}
// logWorkerRole 输出当前实例角色、实例名以及模块启停计划。
func logWorkerRole(appLogger *zap.Logger, role string, instanceName string, moduleStatus workerModuleStatus) {
appLogger.Info("Worker 角色已确认",
zap.String("role", role),
zap.String("instance_name", instanceName))
appLogger.Info("Worker 模块启停计划",
zap.Strings("enabled_modules", moduleStatus.enabled),
zap.Strings("disabled_modules", moduleStatus.disabled))
}
// runsSingletonModules 判断当前角色是否承担主动调度和初始化职责。
func runsSingletonModules(role string) bool {
return role == constants.WorkerRoleAll || role == constants.WorkerRoleLeader
}
// initWorkerRuntime 初始化所有角色共享的外部依赖和轮询运行时对象。
func initWorkerRuntime(ctx context.Context, cfg *config.Config, appLogger *zap.Logger) *workerRuntime {
redisAddr := cfg.Redis.Address + ":" + strconv.Itoa(cfg.Redis.Port)
redisClient := redis.NewClient(&redis.Options{
Addr: redisAddr,
@@ -76,52 +198,26 @@ func main() {
ReadTimeout: cfg.Redis.ReadTimeout,
WriteTimeout: cfg.Redis.WriteTimeout,
})
defer func() {
if err := redisClient.Close(); err != nil {
appLogger.Error("关闭 Redis 客户端失败", zap.Error(err))
}
}()
// 测试 Redis 连接
ctx := context.Background()
if err := redisClient.Ping(ctx).Err(); err != nil {
appLogger.Fatal("连接 Redis 失败", zap.Error(err))
}
appLogger.Info("Redis 已连接", zap.String("address", redisAddr))
// 初始化 PostgreSQL 连接
db, err := database.InitPostgreSQL(&cfg.Database, appLogger)
if err != nil {
appLogger.Fatal("初始化 PostgreSQL 失败", zap.Error(err))
}
defer func() {
sqlDB, _ := db.DB()
if sqlDB != nil {
if err := sqlDB.Close(); err != nil {
appLogger.Error("关闭 PostgreSQL 连接失败", zap.Error(err))
}
}
}()
// 初始化对象存储服务(可选)
storageSvc := initStorage(cfg, appLogger)
// 初始化 Gateway 客户端(可选,用于轮询任务)
gatewayClient := initGateway(cfg, appLogger)
// 创建 Asynq 客户端(用于调度器提交任务)
asynqClient := asynq.NewClient(asynq.RedisClientOpt{
Addr: redisAddr,
Password: cfg.Redis.Password,
DB: cfg.Redis.DB,
})
defer func() {
if err := asynqClient.Close(); err != nil {
appLogger.Error("关闭 Asynq 客户端失败", zap.Error(err))
}
}()
// 创建 Worker 依赖
workerDeps := &bootstrap.WorkerDependencies{
DB: db,
Redis: redisClient,
@@ -131,16 +227,13 @@ func main() {
GatewayClient: gatewayClient,
}
// Bootstrap Worker 组件
workerResult, err := bootstrap.BootstrapWorker(workerDeps)
if err != nil {
appLogger.Fatal("Worker Bootstrap 失败", zap.Error(err))
}
// 创建 Asynq Worker 服务器
workerServer := queue.NewServer(redisClient, &cfg.Queue, appLogger)
// 初始化轮询系统新组件Phase 5.7
pollingConfigStore := postgres.NewPollingConfigStore(db)
pollingConfigMgr := polling.NewPollingConfigManager(pollingConfigStore, redisClient, appLogger)
if err := pollingConfigMgr.Load(ctx); err != nil {
@@ -149,46 +242,131 @@ func main() {
pollingConfigMgr.Start(ctx)
pollingQueueMgr := polling.NewPollingQueueManager(redisClient, constants.PollingShardCount, appLogger)
pollingIotCardStore := postgres.NewIotCardStore(db, redisClient)
pollingBase := task.NewPollingBase(redisClient, pollingQueueMgr, pollingConfigMgr, pollingIotCardStore, appLogger)
pollingBase := task.NewPollingBase(
redisClient,
pollingQueueMgr,
pollingConfigMgr,
pollingIotCardStore,
appLogger,
cfg.Polling.VerboseLog,
)
// 后台渐进式初始化(将全量卡数据写入分片 Sorted Set
pollingInitializer := polling.NewPollingInitializer(pollingIotCardStore, redisClient, pollingConfigMgr, pollingQueueMgr, appLogger)
pollingInitializer.StartBackground(ctx)
// 创建生命周期服务Worker 进程用,功能更完整)
pollingDeviceSimBindingStore := postgres.NewDeviceSimBindingStore(db, redisClient)
pollingDeviceStore := postgres.NewDeviceStore(db, redisClient)
lifecycleSvc := polling.NewPollingLifecycleService(pollingQueueMgr, pollingConfigMgr, pollingIotCardStore, pollingDeviceSimBindingStore, pollingDeviceStore, appLogger)
// 初始化调度器(激活/重置 Handler 通过构造函数注入,防止遗漏)
dataResetHandler := polling.NewDataResetHandler(workerResult.Services.ResetService, appLogger)
activationHandler := polling.NewPackageActivationHandler(
db, redisClient, asynqClient,
workerResult.Services.ActivationService,
workerResult.Services.StopResumeService,
lifecycleSvc := polling.NewPollingLifecycleService(
pollingQueueMgr,
pollingConfigMgr,
pollingIotCardStore,
pollingDeviceSimBindingStore,
pollingDeviceStore,
appLogger,
)
scheduler := polling.NewScheduler(redisClient, asynqClient, pollingQueueMgr, pollingConfigMgr, appLogger, activationHandler, dataResetHandler)
if err := scheduler.Start(ctx); err != nil {
appLogger.Error("启动轮询调度器失败", zap.Error(err))
} else {
appLogger.Info("轮询调度器已启动")
if stopResumeSvc, ok := workerResult.Services.StopResumeService.(*iot_card_svc.StopResumeService); ok {
stopResumeSvc.SetPollingCallback(lifecycleSvc)
}
// 类型断言StopResumeService 实际是 *iotCardSvc.StopResumeService实现了 EvaluateAndAct 接口
stopResumeSvc, _ := workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface)
// 创建任务处理器并注册
taskHandler := queue.NewHandler(db, redisClient, storageSvc, gatewayClient, lifecycleSvc, workerResult, asynqClient, appLogger, pollingBase, stopResumeSvc)
taskHandler.RegisterHandlers()
return &workerRuntime{
redisAddr: redisAddr,
redisClient: redisClient,
db: db,
storageSvc: storageSvc,
gatewayClient: gatewayClient,
asynqClient: asynqClient,
workerResult: workerResult,
workerServer: workerServer,
pollingConfigMgr: pollingConfigMgr,
pollingQueueMgr: pollingQueueMgr,
pollingIotCardStore: pollingIotCardStore,
pollingBase: pollingBase,
lifecycleSvc: lifecycleSvc,
}
}
appLogger.Info("Worker 服务器配置完成",
zap.Int("concurrency", cfg.Queue.Concurrency),
zap.Any("queues", cfg.Queue.Queues))
// close 在 Worker 退出时关闭共享客户端与数据库连接。
func (r *workerRuntime) close(appLogger *zap.Logger) {
if r.asynqClient != nil {
if err := r.asynqClient.Close(); err != nil {
appLogger.Error("关闭 Asynq 客户端失败", zap.Error(err))
}
}
if r.db != nil {
sqlDB, _ := r.db.DB()
if sqlDB != nil {
if err := sqlDB.Close(); err != nil {
appLogger.Error("关闭 PostgreSQL 连接失败", zap.Error(err))
}
}
}
if r.redisClient != nil {
if err := r.redisClient.Close(); err != nil {
appLogger.Error("关闭 Redis 客户端失败", zap.Error(err))
}
}
}
// 创建 Asynq Scheduler定时任务调度器订单超时、告警检查、数据清理
// startPollingInitializer 为 leader/all 启动渐进式初始化器,并保留配置变更重启逻辑。
func startPollingInitializer(ctx context.Context, runtime *workerRuntime, appLogger *zap.Logger) *polling.PollingInitializer {
pollingInitializer := polling.NewPollingInitializer(
runtime.pollingIotCardStore,
runtime.redisClient,
runtime.pollingConfigMgr,
runtime.pollingQueueMgr,
appLogger,
)
pollingInitializer.StartBackground(ctx)
runtime.pollingConfigMgr.WatchChanges(ctx, func(hadConfigs, hasConfigs bool) {
if hasConfigs {
appLogger.Info("轮询配置已变更,触发队列重新初始化",
zap.Bool("had_configs", hadConfigs))
pollingInitializer.Restart(ctx)
return
}
appLogger.Info("轮询配置已清空,跳过队列重新初始化")
})
return pollingInitializer
}
// startPollingScheduler 为 leader/all 启动轮询调度器。
func startPollingScheduler(
ctx context.Context,
runtime *workerRuntime,
pollingInitializer *polling.PollingInitializer,
appLogger *zap.Logger,
) *polling.Scheduler {
dataResetHandler := polling.NewDataResetHandler(runtime.workerResult.Services.ResetService, appLogger)
activationHandler := polling.NewPackageActivationHandler(
runtime.db,
runtime.redisClient,
runtime.asynqClient,
runtime.workerResult.Services.ActivationService,
runtime.workerResult.Services.StopResumeService,
appLogger,
)
pollingScheduler := polling.NewScheduler(
runtime.redisClient,
runtime.asynqClient,
runtime.pollingQueueMgr,
runtime.pollingConfigMgr,
appLogger,
activationHandler,
dataResetHandler,
)
pollingScheduler.SetInitializer(pollingInitializer)
if err := pollingScheduler.Start(ctx); err != nil {
appLogger.Error("启动轮询调度器失败", zap.Error(err))
return nil
}
return pollingScheduler
}
// startAsynqScheduler 为 leader/all 创建并启动 Asynq Scheduler。
func startAsynqScheduler(cfg *config.Config, redisAddr string, appLogger *zap.Logger) *asynq.Scheduler {
asynqScheduler := asynq.NewScheduler(
asynq.RedisClientOpt{
Addr: redisAddr,
@@ -198,57 +376,174 @@ func main() {
&asynq.SchedulerOpts{Location: time.Local},
)
// 注册定时任务:订单超时检查(每分钟)
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(constants.TaskTypeOrderExpire, nil)); err != nil {
appLogger.Fatal("注册订单超时定时任务失败", zap.Error(err))
}
// 注册定时任务:告警检查(每分钟)
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(constants.TaskTypeAlertCheck, nil)); err != nil {
appLogger.Fatal("注册告警检查定时任务失败", zap.Error(err))
}
// 注册定时任务:数据清理(每天凌晨 2 点)
if _, err := asynqScheduler.Register("0 2 * * *", asynq.NewTask(constants.TaskTypeDataCleanup, nil)); err != nil {
appLogger.Fatal("注册数据清理定时任务失败", zap.Error(err))
}
// 注册定时任务:每日流量落盘(每天凌晨 2 点,超时 5 分钟)
if _, err := asynqScheduler.Register("0 2 * * *", asynq.NewTask(constants.TaskTypeDailyTrafficFlush, nil, asynq.MaxRetry(3), asynq.Timeout(5*time.Minute))); err != nil {
appLogger.Fatal("注册每日流量落盘定时任务失败", zap.Error(err))
if err := registerAsynqScheduleTasks(asynqScheduler); err != nil {
appLogger.Fatal("注册 Asynq 定时任务失败", zap.Error(err))
}
// 启动 Asynq Scheduler
go func() {
if err := asynqScheduler.Run(); err != nil {
appLogger.Fatal("Asynq Scheduler 启动失败", zap.Error(err))
}
}()
appLogger.Info("Asynq Scheduler 已启动(订单超时: @every 1m, 告警检查: @every 1m, 数据清理: 0 2 * * *")
// 优雅关闭
quit := make(chan os.Signal, 1)
signal.Notify(quit, os.Interrupt, syscall.SIGTERM)
appLogger.Info("Asynq Scheduler 已启动(订单超时: @every 1m, 告警检查: @every 1m, 数据清理: 0 2 * * *, 每日流量落盘: 0 2 * * *")
return asynqScheduler
}
// 启动 Worker 服务器(阻塞运行)
go func() {
if err := workerServer.Run(taskHandler.GetMux()); err != nil {
appLogger.Fatal("Worker 服务器运行失败", zap.Error(err))
}
}()
// registerAsynqScheduleTasks 注册 Worker 入口需要的全部定时任务。
func registerAsynqScheduleTasks(asynqScheduler *asynq.Scheduler) error {
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypeOrderExpire,
nil,
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeOrderExpire)),
)); err != nil {
return fmt.Errorf("注册订单超时定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("@every 1m", asynq.NewTask(
constants.TaskTypeAlertCheck,
nil,
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeAlertCheck)),
)); err != nil {
return fmt.Errorf("注册告警检查定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register("0 2 * * *", asynq.NewTask(
constants.TaskTypeDataCleanup,
nil,
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeDataCleanup)),
)); err != nil {
return fmt.Errorf("注册数据清理定时任务失败: %w", err)
}
if _, err := asynqScheduler.Register(
"0 2 * * *",
asynq.NewTask(
constants.TaskTypeDailyTrafficFlush,
nil,
asynq.MaxRetry(3),
asynq.Timeout(5*time.Minute),
asynq.Queue(constants.QueueForTaskType(constants.TaskTypeDailyTrafficFlush)),
),
); err != nil {
return fmt.Errorf("注册每日流量落盘定时任务失败: %w", err)
}
return nil
}
appLogger.Info("Worker 服务器已启动")
// createTaskHandler 创建并返回包含全部任务处理器的 Asynq Handler。
func createTaskHandler(runtime *workerRuntime, appLogger *zap.Logger) *queue.Handler {
stopResumeSvc, _ := runtime.workerResult.Services.StopResumeService.(iot_card_svc.StopResumeServiceInterface)
return queue.NewHandler(
runtime.db,
runtime.redisClient,
runtime.storageSvc,
runtime.gatewayClient,
runtime.lifecycleSvc,
runtime.workerResult,
runtime.asynqClient,
appLogger,
runtime.pollingBase,
stopResumeSvc,
)
}
// 等待关闭信号
<-quit
// rescuePendingImportTasks 将历史遗留的待处理导入任务重新投递到独立导入队列。
func rescuePendingImportTasks(ctx context.Context, runtime *workerRuntime, appLogger *zap.Logger) {
rescuePendingIotCardImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
rescuePendingDeviceImportTasks(ctx, runtime.db, runtime.asynqClient, appLogger)
}
// rescuePendingIotCardImportTasks 补偿仍停留在待处理状态的 IoT 卡导入任务。
func rescuePendingIotCardImportTasks(ctx context.Context, db *gorm.DB, asynqClient *asynq.Client, appLogger *zap.Logger) {
var importTasks []model.IotCardImportTask
if err := db.WithContext(ctx).
Where("status = ?", model.ImportTaskStatusPending).
Limit(importRescueLimit).
Find(&importTasks).Error; err != nil {
appLogger.Warn("扫描待补偿 IoT 卡导入任务失败", zap.Error(err))
return
}
for _, importTask := range importTasks {
payload := task.IotCardImportPayload{TaskID: importTask.ID}
enqueueImportRescueTask(ctx, asynqClient, constants.TaskTypeIotCardImport, payload, importTask.ID, appLogger)
}
}
// rescuePendingDeviceImportTasks 补偿仍停留在待处理状态的设备导入任务。
func rescuePendingDeviceImportTasks(ctx context.Context, db *gorm.DB, asynqClient *asynq.Client, appLogger *zap.Logger) {
var importTasks []model.DeviceImportTask
if err := db.WithContext(ctx).
Where("status = ?", model.ImportTaskStatusPending).
Limit(importRescueLimit).
Find(&importTasks).Error; err != nil {
appLogger.Warn("扫描待补偿设备导入任务失败", zap.Error(err))
return
}
for _, importTask := range importTasks {
payload := task.DeviceImportPayload{TaskID: importTask.ID}
enqueueImportRescueTask(ctx, asynqClient, constants.TaskTypeDeviceImport, payload, importTask.ID, appLogger)
}
}
// enqueueImportRescueTask 将补偿任务提交到任务类型对应的独立队列。
func enqueueImportRescueTask(ctx context.Context, asynqClient *asynq.Client, taskType string, payload any, taskID uint, appLogger *zap.Logger) {
payloadBytes, err := sonic.Marshal(payload)
if err != nil {
appLogger.Warn("序列化导入补偿任务载荷失败",
zap.String("task_type", taskType),
zap.Uint("task_id", taskID),
zap.Error(err))
return
}
queueName := constants.QueueForTaskType(taskType)
taskMessage := asynq.NewTask(
taskType,
payloadBytes,
asynq.Queue(queueName),
asynq.TaskID(fmt.Sprintf("import-rescue:%s:%d", taskType, taskID)),
asynq.Unique(30*time.Minute),
)
if _, err := asynqClient.EnqueueContext(ctx, taskMessage); err != nil {
appLogger.Warn("提交导入补偿任务失败",
zap.String("task_type", taskType),
zap.String("queue", queueName),
zap.Uint("task_id", taskID),
zap.Error(err))
return
}
appLogger.Info("导入补偿任务已提交",
zap.String("task_type", taskType),
zap.String("queue", queueName),
zap.Uint("task_id", taskID))
}
// shutdownWorker 按当前实例实际启动过的模块执行优雅关闭。
func shutdownWorker(
cancel context.CancelFunc,
appLogger *zap.Logger,
workerServer *queue.Server,
pollingInitializer *polling.PollingInitializer,
pollingScheduler *polling.Scheduler,
asynqScheduler *asynq.Scheduler,
) {
appLogger.Info("正在关闭 Worker 服务器...")
// 停止 Asynq Scheduler
asynqScheduler.Shutdown()
if asynqScheduler != nil {
asynqScheduler.Shutdown()
}
if pollingScheduler != nil {
pollingScheduler.Stop()
}
// 停止轮询调度器
scheduler.Stop()
cancel()
if pollingInitializer != nil {
pollingInitializer.Stop()
}
// 优雅关闭 Worker 服务器(等待正在执行的任务完成)
workerServer.Shutdown()
appLogger.Info("Worker 服务器已停止")
}

View File

@@ -24,6 +24,7 @@ version: '3.8'
# - Gateway 服务配置JUNHONG_GATEWAY_*
# - 对象存储配置JUNHONG_STORAGE_*
# - 短信服务配置JUNHONG_SMS_*
# - 客户端手机号绑定开关JUNHONG_CLIENT_REQUIRE_PHONE_BINDING默认 true
#
# 微信公众号/小程序/支付配置已迁移至数据库tb_wechat_config 表),
# 不再需要环境变量和证书文件挂载。
@@ -50,9 +51,15 @@ services:
- JUNHONG_REDIS_DB=6
# JWT 配置(必填)
- JUNHONG_JWT_SECRET_KEY=dev-secret-key-for-testing-only-32chars!
# 客户端配置(可选,默认 truefalse=不强制绑定手机号)
- JUNHONG_CLIENT_REQUIRE_PHONE_BINDING=false
# 日志配置
- JUNHONG_LOGGING_LEVEL=info
- JUNHONG_LOGGING_DEVELOPMENT=false
# 跨域配置
- JUNHONG_MIDDLEWARE_CORS_ENABLED=true
- JUNHONG_MIDDLEWARE_CORS_ALLOW_ORIGINS=*
- JUNHONG_MIDDLEWARE_CORS_ALLOW_CREDENTIALS=false
# 对象存储配置
- JUNHONG_STORAGE_PROVIDER=s3
- JUNHONG_STORAGE_S3_ENDPOINT=https://obs-helf.cucloud.cn
@@ -63,7 +70,7 @@ services:
- JUNHONG_STORAGE_S3_USE_SSL=false
- JUNHONG_STORAGE_S3_PATH_STYLE=true
# Gateway 配置(可选)
- JUNHONG_GATEWAY_BASE_URL=https://lplan.whjhft.com/openapi
- JUNHONG_GATEWAY_BASE_URL=https://open.whjhft.com/openapi
- JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
- JUNHONG_GATEWAY_TIMEOUT=30
@@ -72,6 +79,9 @@ services:
- JUNHONG_SMS_USERNAME=JH0001
- JUNHONG_SMS_PASSWORD=wwR8E4qnL6F0
- JUNHONG_SMS_SIGNATURE=【JHFTIOT】
- JUNHONG_MIDDLEWARE_CORS_ENABLED=true
- JUNHONG_MIDDLEWARE_CORS_ALLOW_ORIGINS=https://cmp-admin.boss160.cn,https://cmp-agent.boss160.cn,https://cmp-c.boss160.cn
- JUNHONG_MIDDLEWARE_CORS_ALLOW_CREDENTIALS=true
volumes:
- ./logs:/app/logs
networks:
@@ -107,6 +117,8 @@ services:
- JUNHONG_REDIS_DB=6
# JWT 配置(必填)
- JUNHONG_JWT_SECRET_KEY=dev-secret-key-for-testing-only-32chars!
# 客户端配置(可选,默认 truefalse=不强制绑定手机号)
- JUNHONG_CLIENT_REQUIRE_PHONE_BINDING=false
# 日志配置
- JUNHONG_LOGGING_LEVEL=info
- JUNHONG_LOGGING_DEVELOPMENT=false
@@ -120,10 +132,11 @@ services:
- JUNHONG_STORAGE_S3_USE_SSL=false
- JUNHONG_STORAGE_S3_PATH_STYLE=true
# Gateway 配置(可选)
- JUNHONG_GATEWAY_BASE_URL=https://lplan.whjhft.com/openapi
- JUNHONG_GATEWAY_BASE_URL=https://open.whjhft.com/openapi
- JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
- JUNHONG_GATEWAY_TIMEOUT=30
- JUNHONG_POLLING_VERBOSE_LOG=true
volumes:
- ./logs:/app/logs
networks:

View File

@@ -0,0 +1,98 @@
# 资产操作审计日志功能总结
## 1. 功能目标
为资产域IoT 卡/设备)补齐统一审计落库能力,解决“谁在什么时间对哪个资产做了什么变更、结果如何”的追溯问题。
## 2. 数据模型与落库
- 新增表:`tb_asset_operation_log`
- 关键字段:
- 操作人:`operator_id/operator_type/operator_name`
- 资产:`asset_type/asset_id/asset_identifier`
- 操作:`operation_type/operation_desc`
- 镜像:`before_data/after_data`JSONB
- 请求上下文:`request_id/ip_address/user_agent/request_path/request_method`
- 结果:`result_status/error_code/error_msg`
- 批量统计:`batch_total/success_count/fail_count`
- 索引:
- `idx_asset_log_asset_created`
- `idx_asset_log_identifier_created`
- `idx_asset_log_operator_created`
- `idx_asset_log_operation_created`
- `idx_asset_log_result_created`
## 3. 统一封装设计
### 3.1 基础设施统一入口
- `internal/service/asset_audit/service.go`
- 统一 `LogOperation(ctx, log)` 异步写入
- 写入失败只打 Error 日志,不阻断业务
- `internal/service/asset_audit/builder.go`
- 统一组装 `before/after`、请求上下文、错误码摘要
- 统一脱敏与裁剪
### 3.2 业务侧统一 helper
- IoT 卡:`internal/service/iot_card/audit.go`
- 设备:`internal/service/device/audit.go`
- 统一资产入口轮询:`internal/service/polling/asset_polling_service.go` 内部 helper
- 资产停用:`internal/service/asset/lifecycle_service.go` 内部 helper
- 导入任务:
- `internal/service/device_import/audit.go`
- `internal/service/iot_card_import/audit.go`
业务函数只传结构化参数,避免日志拼装散落在各分支。
## 4. 覆盖范围
### 4.1 IoT 卡
- 分配/回收、系列绑定、轮询开关、实名策略、手动实名状态、删除/批量删除
- 自动停复机 + 手动停复机(含 denied/failed
### 4.2 设备
- 删除、分配/回收、系列绑定、绑卡/解绑
- 设备停机/复机(含成功数、失败数、失败摘要)
- 远程控制限速、WiFi、切卡、切卡模式、重启、恢复出厂
### 4.3 统一资产入口与导入
- 统一入口:停用、轮询状态、实名策略
- 导入任务创建:设备导入、卡导入
## 5. 脱敏与体积控制
- 敏感键脱敏:`password/passwd/pwd/secret/token/wifi_password/wifipwd`
- WiFi 场景使用 `password` 键传参,落库前统一掩码处理
- `before_data/after_data` 超过阈值时裁剪并标记 `_truncated=true`
## 6. 查询与排障示例
### 6.1 审计日志查询 API前端
- 路径:`GET /api/admin/assets/:identifier/operation-logs`
- 操作人相关字段:
- `operator_type` 返回中文标准文案(如平台用户、代理账号、企业账号、个人客户、系统)
- `operator_type_code` 保留底层枚举编码(如 `admin_user``agent_user`
- `operator_name` 优先返回可读名称,历史 `类型#ID` 占位值会在查询时尽量补全
- 建议前端优先使用:
- `operation_content_before`(操作内容变更前)
- `operation_content_after`(操作内容变更后)
- `operation_fields_desc`(字段中文说明)
- 原始字段 `before_data/after_data` 建议仅用于调试回放。
- 字段明细文档:`docs/add-asset-operation-audit-log/操作内容字段说明.md`
### 6.2 SQL 排障示例
完整 SQL 见:
- `docs/add-asset-operation-audit-log/手工验收脚本.sql`
常用排障问题:
1. 某资产最近操作:按 `asset_type + asset_id` 查询
2. 某操作人近期高风险动作:按 `operator_id` + `result_status in ('failed','denied')`
3. WiFi 明文泄露检查:检索 `after_data::text ILIKE '%\"password\":\"%'` 并人工确认是否掩码

View File

@@ -0,0 +1,89 @@
-- 资产操作审计日志手工验收 SQL
-- 表tb_asset_operation_log
-- 1) 表结构与索引核验
SELECT table_name
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'tb_asset_operation_log';
SELECT indexname, indexdef
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'tb_asset_operation_log'
ORDER BY indexname;
-- 2) 最近 50 条审计日志
SELECT id,
created_at,
operator_type,
operator_id,
asset_type,
asset_id,
asset_identifier,
operation_type,
result_status,
batch_total,
success_count,
fail_count
FROM tb_asset_operation_log
ORDER BY id DESC
LIMIT 50;
-- 3) success / failed / denied 三态分布
SELECT operation_type, result_status, COUNT(*) AS cnt
FROM tb_asset_operation_log
GROUP BY operation_type, result_status
ORDER BY operation_type, result_status;
-- 4) before/after 镜像完整性核验(近 200 条)
SELECT id,
operation_type,
result_status,
(before_data IS NOT NULL) AS has_before,
(after_data IS NOT NULL) AS has_after
FROM tb_asset_operation_log
ORDER BY id DESC
LIMIT 200;
-- 5) 批量聚合字段核验batch_total/success_count/fail_count
SELECT id,
operation_type,
batch_total,
success_count,
fail_count,
(batch_total >= success_count + fail_count) AS counter_ok
FROM tb_asset_operation_log
WHERE batch_total > 0
ORDER BY id DESC
LIMIT 200;
-- 6) 系统触发语义核验
SELECT id,
operation_type,
operator_type,
operator_id,
operator_name,
result_status
FROM tb_asset_operation_log
WHERE operation_type IN ('card_auto_stop', 'card_auto_start')
ORDER BY id DESC
LIMIT 100;
-- 7) WiFi 脱敏核验(不应出现明文密码)
SELECT id,
operation_type,
after_data
FROM tb_asset_operation_log
WHERE operation_type = 'device_set_wifi'
ORDER BY id DESC
LIMIT 20;
-- 8) 拒绝与失败热点排查
SELECT operation_type,
result_status,
COUNT(*) AS cnt
FROM tb_asset_operation_log
WHERE result_status IN ('failed', 'denied')
GROUP BY operation_type, result_status
ORDER BY cnt DESC, operation_type;

View File

@@ -0,0 +1,69 @@
# 资产审计日志接口回放示例
以下示例用于构造 success / failed / denied 三类日志数据。请替换 `<token>``<identifier>``<device_id>``<card_id>` 等占位符。
## 1. success 示例
### 1.1 设备限速(`device_speed_limit`
```bash
curl -X POST "http://127.0.0.1:8080/api/admin/devices/<identifier>/speed-limit" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"speed_limit":1024}'
```
### 1.2 资产轮询开关(`asset_polling_status`
```bash
curl -X PATCH "http://127.0.0.1:8080/api/admin/assets/<identifier>/polling-status" \
-H "Authorization: Bearer <token>" \
-H "Content-Type: application/json" \
-d '{"enable_polling":false}'
```
## 2. denied 示例
### 2.1 保护期内停机(`device_stop` / `card_manual_stop`
```bash
curl -X POST "http://127.0.0.1:8080/api/admin/assets/<identifier>/stop" \
-H "Authorization: Bearer <token>"
```
若命中保护期规则,应返回拒绝并写入 `result_status=denied`
### 2.2 越权分配设备(`device_allocate`
```bash
curl -X POST "http://127.0.0.1:8080/api/admin/devices/allocate" \
-H "Authorization: Bearer <agent-token>" \
-H "Content-Type: application/json" \
-d '{"target_shop_id":999999,"device_ids":[1,2,3],"remark":"审计验收"}'
```
## 3. failed 示例
### 3.1 无效设备标识触发远程控制失败(`device_reboot`
```bash
curl -X POST "http://127.0.0.1:8080/api/admin/devices/<invalid-identifier>/reboot" \
-H "Authorization: Bearer <token>"
```
### 3.2 导入任务入队失败(`device_import_task_create` / `iot_card_import_task_create`
通过临时关闭队列依赖或传入无效参数触发失败,确认写入 `result_status=failed` 和错误摘要。
## 4. 结果核对
执行完回放后,使用:
- `docs/add-asset-operation-audit-log/手工验收脚本.sql`
重点核对:
1. 同一操作存在 success/failed/denied
2. `before_data``after_data` 有效
3. WiFi 密码字段为掩码
4. 批量字段 `batch_total/success_count/fail_count` 合理

View File

@@ -0,0 +1,102 @@
# 资产审计日志操作内容字段说明
## 1. 查询接口
- 路径:`GET /api/admin/assets/:identifier/operation-logs`
- 说明:先按资产标识符解析资产,再查询该资产的审计日志。
## 2. 前端应优先使用的字段
为避免历史数据格式差异,前端应优先使用以下三个字段:
1. `operation_content_before`:操作内容(变更前)
2. `operation_content_after`:操作内容(变更后)
3. `operation_fields_desc`:字段中文说明(`key=字段名``value=中文解释`
`before_data/after_data` 保留为原始审计数据,建议仅作为调试或回放使用。
## 3. 通用字段
| 字段 | 说明 |
|---|---|
| `operator_type` | 操作人类型中文名称(如平台用户、代理账号、企业账号、个人客户、系统) |
| `operator_type_code` | 操作人类型编码(如 `admin_user``agent_user` |
| `operator_name` | 操作人可读名称,优先返回用户名/店铺名/企业名/客户昵称 |
| `batch_total` | 批量总数 |
| `success_count` | 成功数量 |
| `fail_count` | 失败数量 |
| `failed_items` | 失败明细 |
| `reason` | 原因说明 |
## 4. 常见操作字段
### 4.1 分配/回收
| 字段 | 说明 |
|---|---|
| `target_shop_id` / `to_shop_id` | 目标店铺ID |
| `source_shop_id` / `from_shop_id` | 来源店铺ID |
| `device_ids` | 设备ID列表 |
| `card_ids` | 卡ID列表 |
| `selection_type` | 选择方式 |
| `new_status` | 变更后状态 |
### 4.2 设备绑卡/解绑
| 字段 | 说明 |
|---|---|
| `binding_id` | 绑定记录ID |
| `slot_position` | 插槽位置 |
| `iot_card_id` | 卡ID |
| `iccid` | 卡ICCID |
| `unbind` | 是否执行解绑 |
### 4.3 设备停复机
| 字段 | 说明 |
|---|---|
| `success_count` | 成功数量 |
| `fail_count` | 失败数量 |
| `skip_count` | 跳过数量 |
| `failed_items` | 失败明细含ICCID和原因 |
### 4.4 远程控制
| 字段 | 说明 |
|---|---|
| `speed_limit` | 限速值KB/s |
| `ssid` | WiFi 名称 |
| `password` | WiFi 密码(已脱敏) |
| `enabled` | WiFi 开关状态 |
| `target_iccid` | 目标ICCID |
| `switch_mode` | 切卡模式0自动/1手动 |
### 4.5 轮询与实名
| 字段 | 说明 |
|---|---|
| `enable_polling` | 轮询开关 |
| `realname_policy` | 实名策略none/before_order/after_order |
| `real_name_status` | 实名状态0未实名/1已实名 |
## 5. 兼容性说明
历史日志中 `before_data` 可能是资产快照而不是操作内容。接口已做归一化处理:
- 自动抽取并返回 `operation_content_before/after`
- 当历史数据缺少“变更前操作内容”时,会按“变更后字段”与资产快照尽量回填
- 历史日志中的 `后台用户#ID``代理用户#ID` 等占位名称,接口会尽量按当前账号资料补全为可读名称
因此,前端无需再自行解析 `before_data/after_data` 的多种历史格式。
## 6. 前端处理建议
建议前端按以下优先级渲染变更明细:
1. 遍历 `operation_content_after` 的字段键作为“变更项集合”
2. 字段名展示优先使用 `operation_fields_desc[key]`,没有则回退为 `key`
3. 变更前值读取 `operation_content_before[key]`
4. 变更后值读取 `operation_content_after[key]`
5. 当前值为 `null` 时按“未知/未记录”展示,不要直接当空字符串
`before_data/after_data` 建议只用于问题排查,不参与主界面渲染。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,265 @@
# 代理开放接口对接说明
## 1. 接入说明
代理开放接口挂载在 `/api/open/v1`,面向代理店铺第三方系统调用。接入方使用代理账号登录信息进行签名认证,通过认证后即可调用卡查询、套餐查询、预充值钱包查询和套餐购买接口。
请通过 HTTPS 调用开放接口。接入方不要在客户端日志、浏览器控制台或异常信息中记录账号密码、签名、随机串等敏感信息。
## 2. 认证 Header
每个请求必须携带以下 Header
| Header | 是什么 | 怎么来 | 示例 | 注意事项 |
| --- | --- | --- | --- | --- |
| `X-Agent-Account` | 代理账号标识 | 填写代理登录平台使用的用户名或手机号,由平台开通代理账号时提供 | `agent001``13800000000` | 必须与签名原文最后一行 `account` 完全一致 |
| `X-Agent-Password` | 代理账号当前登录密码 | 由代理账号持有人提供,和登录平台的密码一致 | `Passw0rd123` | 同时作为 HMAC-SHA256 签名密钥;密码变更后必须使用新密码签名;不要写入日志 |
| `X-Agent-Timestamp` | 请求发起时间 | 调用方在每次请求前生成 | `1715400000``1715400000000``2024-05-11T10:00:00+08:00` | 支持 Unix 秒、Unix 毫秒、RFC3339必须与签名原文 `timestamp` 行完全一致;客户端服务器时间需同步 |
| `X-Agent-Nonce` | 请求随机串 | 调用方每次请求自行生成,不需要平台提前分配 | `n-1715400000-001` 或 UUID | 同一账号 5 分钟内不可重复,重复会被判定为重放请求 |
| `X-Agent-Sign` | 请求签名 | 调用方按下方签名规则实时计算 | `5f2d...` | 不是平台分配的固定值;只要 method、path、query、body、timestamp、nonce、account 或 password 任意一个变化,签名都会变化 |
Header 生成顺序建议:
1. 先确定 `X-Agent-Account``X-Agent-Password`
2. 每次请求生成新的 `X-Agent-Timestamp``X-Agent-Nonce`
3. 按下方规则拼出签名原文。
4.`X-Agent-Password` 对签名原文做 HMAC-SHA256得到 `X-Agent-Sign`
5. 带上 5 个 Header 发起请求。
签名原文:
```text
METHOD
PATH
canonical_query
body_sha256
timestamp
nonce
account
```
拼接规则:
- 使用换行符 `\n` 连接 7 行,最后一行后不追加换行。
- `METHOD` 使用大写 HTTP 方法,例如 `GET``POST`
- `PATH` 只取请求路径,例如 `/api/open/v1/cards/status`,不包含域名和 query。
- `canonical_query` 为 query 参数规范化结果:排除 `sign` 字段参数名按升序排列同名多值按值升序排列key 和 value 使用 URL QueryEscape 编码后按 `key=value` 拼接;多个参数用 `&` 连接;无 query 时为空字符串。
- `body_sha256` 为原始请求 body 字节的 SHA256 小写十六进制值。GET 或空 body 使用空字符串的 SHA256`e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855`
- POST JSON 请求必须先确定最终发送的 body 字符串,再用同一个字符串计算 `body_sha256` 并发送请求;签名后不要再重新格式化 JSON、调整字段顺序或改变空格。
- `timestamp``nonce``account` 必须与 Header 中的值完全一致。
签名值为:
```text
lowercase_hex(HMAC-SHA256(password, sign_payload))
```
其中 `password``X-Agent-Password` 的原始值,`sign_payload` 为上面 7 行拼接后的字符串。
### 2.1 GET 签名示例
请求:
```text
GET /api/open/v1/cards/status?card_no=89860000000000000001
X-Agent-Account: agent001
X-Agent-Password: Passw0rd123
X-Agent-Timestamp: 1715400000
X-Agent-Nonce: n-1715400000-001
```
签名原文:
```text
GET
/api/open/v1/cards/status
card_no=89860000000000000001
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
1715400000
n-1715400000-001
agent001
```
Node.js 示例:
```javascript
const crypto = require("crypto");
function sha256Hex(input) {
return crypto.createHash("sha256").update(input).digest("hex");
}
function queryEscape(value) {
return new URLSearchParams({ v: String(value) }).toString().slice(2);
}
function canonicalQuery(params) {
return Object.entries(params)
.filter(([key]) => key.toLowerCase() !== "sign")
.flatMap(([key, value]) => {
const values = Array.isArray(value) ? value : [value];
return values.sort().map((item) => [key, item]);
})
.sort(([aKey, aValue], [bKey, bValue]) => {
if (aKey === bKey) return String(aValue).localeCompare(String(bValue));
return aKey.localeCompare(bKey);
})
.map(([key, value]) => `${queryEscape(key)}=${queryEscape(value)}`)
.join("&");
}
function sign({ method, path, query, body, timestamp, nonce, account, password }) {
const payload = [
method.toUpperCase(),
path,
canonicalQuery(query),
sha256Hex(body || ""),
timestamp,
nonce,
account,
].join("\n");
return crypto.createHmac("sha256", password).update(payload).digest("hex");
}
```
完整 GET 调用示例:
```javascript
const https = require("https");
const account = "agent001";
const password = "Passw0rd123";
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = `nonce-${Date.now()}`;
const method = "GET";
const path = "/api/open/v1/cards/status";
const query = { card_no: "89860000000000000001" };
const body = "";
const requestQuery = canonicalQuery(query);
const requestPath = `${path}?${requestQuery}`;
const signature = sign({ method, path, query, body, timestamp, nonce, account, password });
const req = https.request(
{
hostname: "open.example.com",
path: requestPath,
method,
headers: {
"X-Agent-Account": account,
"X-Agent-Password": password,
"X-Agent-Timestamp": timestamp,
"X-Agent-Nonce": nonce,
"X-Agent-Sign": signature,
},
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => console.log(data));
}
);
req.end();
```
完整 POST 调用示例:
```javascript
const https = require("https");
const account = "agent001";
const password = "Passw0rd123";
const timestamp = Math.floor(Date.now() / 1000).toString();
const nonce = `nonce-${Date.now()}`;
const method = "POST";
const path = "/api/open/v1/wallet/package-orders";
const query = {};
const body = JSON.stringify({
card_nos: ["89860000000000000001", "89860000000000000002"],
package_code: "PKG001",
});
const signature = sign({ method, path, query, body, timestamp, nonce, account, password });
const req = https.request(
{
hostname: "open.example.com",
path,
method,
headers: {
"Content-Type": "application/json",
"Content-Length": Buffer.byteLength(body),
"X-Agent-Account": account,
"X-Agent-Password": password,
"X-Agent-Timestamp": timestamp,
"X-Agent-Nonce": nonce,
"X-Agent-Sign": signature,
},
},
(res) => {
let data = "";
res.on("data", (chunk) => (data += chunk));
res.on("end", () => console.log(data));
}
);
req.write(body);
req.end();
```
curl 调用示例:
```bash
curl -G "https://open.example.com/api/open/v1/cards/status" \
--data-urlencode "card_no=89860000000000000001" \
-H "X-Agent-Account: agent001" \
-H "X-Agent-Password: Passw0rd123" \
-H "X-Agent-Timestamp: 1715400000" \
-H "X-Agent-Nonce: n-1715400000-001" \
-H "X-Agent-Sign: 上面代码计算出的签名"
```
## 3. 接口列表
| 方法 | 路径 | 说明 |
| --- | --- | --- |
| `GET` | `/api/open/v1/cards/traffic` | 按 `card_no` 查询单卡流量和套餐 |
| `GET` | `/api/open/v1/cards/status` | 按 `card_no` 查询单卡正常/停机状态 |
| `GET` | `/api/open/v1/cards/realname-status` | 按 `card_no` 查询实名状态 |
| `GET` | `/api/open/v1/packages` | 查询可购买套餐列表 |
| `POST` | `/api/open/v1/wallet/package-orders` | 使用预充值钱包给多张卡购买同一个套餐 |
| `GET` | `/api/open/v1/wallet/balance` | 查询预充值钱包余额 |
| `GET` | `/api/open/v1/wallet/transactions` | 查询预充值钱包流水 |
`card_no` 支持 ICCID、虚拟号、MSISDN。开放接口只支持单卡设备标识会返回参数错误。
## 4. 字段口径
卡流量字段:
- `total_flow_mb`:套餐总流量,单位 MB。
- `used_flow_mb`:已用流量,单位 MB。
- `remaining_flow_mb`:剩余流量,单位 MB。
套餐列表字段:
- `cost_price`:套餐结算价,单位分。
- `retail_price`:套餐销售价,单位分。
- `total_flow_mb`:套餐总流量,单位 MB。
- `valid_days`:套餐有效天数。
钱包购买接口一次只允许一个 `package_code``card_nos` 最多 100 张卡。每张卡独立创建钱包订单,允许部分成功,失败卡会返回独立错误码和原因。任一卡购买失败时,外层 `code`/`msg` 返回首条失败卡的错误码和原因,同时 `data.failed_cards` 保留失败明细,`data.orders` 保留已成功订单;接入方应以 `success_count``failed_count``orders``failed_cards` 判断单卡结果,避免整批重试造成重复下单。
## 5. 错误码
| 错误码 | 说明 |
| --- | --- |
| `1048` | 开放接口认证失败 |
| `1049` | 开放接口签名无效 |
| `1190` | 开放接口时间戳无效 |
| `1191` | 重复请求,请勿重放 |
| `1005` | 无权限操作该资源或资源不存在 |
参数校验失败统一返回 `1001`。认证类失败只返回统一错误码和消息。

View File

@@ -16,7 +16,7 @@
代理/平台 → POST /api/admin/agent-recharges
├─ 验证权限:代理只能充自己店铺,平台可指定任意店铺
├─ 验证金额范围100 元~100 万元)
├─ 验证金额范围1~100 万元)
├─ 查找目标店铺的 main 钱包
├─ 查询 active 支付配置 → 无配置则拒绝(返回 1175
├─ 记录 payment_config_id
@@ -96,7 +96,7 @@
| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| shop_id | integer | 是 | 目标店铺 ID代理只能填自己所属店铺|
| amount | integer | 是 | 充值金额(单位:分),范围 10000~100000000 |
| amount | integer | 是 | 充值金额(单位:分),范围 1~100000000 |
| payment_method | string | 是 | `wechat`(在线)/ `offline`(线下,仅平台)|
**成功响应**
@@ -218,7 +218,7 @@
```go
// pkg/constants/wallet.go
AgentRechargeOrderPrefix = "ARCH" // 充值单号前缀
AgentRechargeMinAmount = 10000 // 最小充值100 元(单位:分)
AgentRechargeMinAmount = 1 // 最小充值1
AgentRechargeMaxAmount = 100000000 // 最大充值100 万元(单位:分)
```

40
docs/agents/domain.md Normal file
View File

@@ -0,0 +1,40 @@
# 领域文档
工程类 Skill`improve-codebase-architecture``diagnose``tdd` 等)在探索代码库前应如何消费本仓库的领域文档。
## 探索前请先阅读
- 根目录的 **`CONTEXT.md`**(单 Context 布局)
- 根目录的 **`docs/adr/`** —— 阅读与当前改动相关的 ADR
如果这些文件还不存在,**直接跳过,不要提示缺失,也不要主动建议现在就创建它们**。这两个文件由 `/grill-with-docs` 在术语或决定真正确定下来时按需创建。
## 与 openspec/ 的关系
本仓库已经使用 `openspec/``openspec/specs/` 存放规范、`openspec/changes/` 存放变更提案)作为正式的提案与规范工作流,详见根目录 `CLAUDE.md` 的「OpenSpec 工作流」一节。`CONTEXT.md` / `docs/adr/` 是补充性质的轻量级领域词汇表和架构决定记录,不会替代 openspec 流程:
- 涉及接口/数据库等实际变更 → 走 openspec 提案
- 记录领域词汇、命名约定、历史架构权衡 → 写入 `CONTEXT.md` / `docs/adr/`
## 文件结构(单 Context
```
/
├── CONTEXT.md
├── docs/adr/
│ ├── 0001-xxx.md
│ └── 0002-xxx.md
└── openspec/
```
## 使用词汇表中的术语
当输出内容涉及某个领域概念时issue 标题、重构提案、假设、测试名称),使用 `CONTEXT.md` 中定义的术语,不要漂移到词汇表明确避免使用的同义词。
如果你需要的概念还不在词汇表中,这是一个信号——可能是你在发明项目并未使用的语言(应重新考虑),也可能是真实存在的空白(记录下来留给 `/grill-with-docs`)。
## 标记与 ADR 的冲突
如果你的输出与某条已有 ADR 冲突,必须显式指出,而不是悄悄覆盖:
> 与 ADR-0007事件溯源订单冲突——但值得重新讨论原因是……

View File

@@ -0,0 +1,19 @@
# Issue 追踪方式:本地 Markdown
本仓库的 Issue 和 PRD 以 Markdown 文件形式存放在 `.scratch/` 目录下。
## 约定
- 每个功能一个目录:`.scratch/<feature-slug>/`
- PRD 文件:`.scratch/<feature-slug>/PRD.md`
- 实现类 Issue`.scratch/<feature-slug>/issues/<NN>-<slug>.md`,从 `01` 开始编号
- Triage 状态记录在每个 Issue 文件顶部的 `Status:` 行(状态字符串见 `triage-labels.md`
- 评论和讨论记录追加在文件底部的 `## Comments` 标题下
## 当某个 Skill 说"发布到 issue tracker"时
`.scratch/<feature-slug>/` 下创建一个新文件(目录不存在则先创建)。
## 当某个 Skill 说"获取对应的 ticket"时
读取所引用路径对应的文件。用户通常会直接给出文件路径或 Issue 编号。

View File

@@ -0,0 +1,15 @@
# Triage 标签
这些 Skill 用五个统一的 Triage 角色来表达状态。本文件将这些角色映射到本仓库实际使用的标签字符串。
| Skill 中的角色 | 本仓库使用的标签 | 含义 |
| ----------------- | ------------------ | -------------------------------- |
| `needs-triage` | `needs-triage` | 需要维护者评估该 issue |
| `needs-info` | `needs-info` | 等待提交者补充信息 |
| `ready-for-agent` | `ready-for-agent` | 已完整描述AFK Agent 可直接处理 |
| `ready-for-human` | `ready-for-human` | 需要人工实现 |
| `wontfix` | `wontfix` | 不予处理 |
由于本仓库使用本地 Markdown 作为 issue tracker这些标签以每个 issue 文件顶部的 `Status: <label>` 行体现(见 `issue-tracker.md`)。
当某个 Skill 提到某个角色时(例如"打上 AFK-ready 的 triage 标签"),使用本表中对应的标签字符串。

View File

@@ -0,0 +1,155 @@
# 支付宝支付链接前端对接说明
## 1. 对接范围
本次新增的是 **C 端支付宝支付链接能力**。前端无需接入支付宝 SDK后端会返回已签名的支付宝 WAP 支付链接。
覆盖场景:
1. 套餐订单支付
2. 钱包充值
3. 强制充值购包场景
微信支付原逻辑保持不变:微信返回 `pay_config`,支付宝返回 `payment_link`
具体接口路径、完整请求字段、响应结构、错误码请以前端接口文档 / OpenAPI 文档为准。
## 2. 支付方式取值
前端统一使用 `payment_method` 区分支付方式:
| 值 | 含义 | 前端处理 |
| --- | --- | --- |
| `wallet` | 钱包支付 | 仅订单支付支持,无第三方跳转 |
| `wechat` | 微信支付 | 使用返回的 `pay_config` 调起微信支付 |
| `alipay` | 支付宝支付 | 使用返回的 `payment_link` 打开或展示支付链接 |
注意事项:
- `app_type` 只在微信支付时需要。
- 支付宝支付时不需要传 `app_type`
- 支付宝支付返回的是支付链接,不是 JSAPI 参数。
## 3. 前端需要关注的响应字段
当选择支付宝支付时,接口响应中会返回 `payment_link`
```json
{
"payment_link": {
"payment_no": "支付单号",
"qr_link": "支付宝支付链接,可用于生成二维码",
"copy_link": "支付宝支付链接,可复制或直接打开",
"pay_expire_at": "支付链接过期时间RFC3339 格式"
}
}
```
字段说明:
| 字段 | 用途 |
| --- | --- |
| `payment_no` | 支付单号,问题排查 / 客服查询使用 |
| `qr_link` | 前端可用该链接生成二维码 |
| `copy_link` | 用户复制或浏览器打开的支付链接,当前与 `qr_link` 一致 |
| `pay_expire_at` | 支付链接过期时间,前端可用于倒计时展示 |
## 4. 推荐交互流程
### 4.1 支付宝支付流程
前端拿到 `payment_link` 后:
1. H5 / 浏览器场景:直接跳转 `copy_link`
2. PC 场景:使用 `qr_link` 生成二维码。
3. App / WebView 场景:在外部浏览器或支付宝可识别环境中打开 `copy_link`
支付成功后:
- 不要只依赖支付宝返回页判断支付成功。
- 最终支付状态以服务端订单 / 充值记录状态为准。
- 用户返回页面后,前端应轮询订单详情、订单列表或充值记录接口。
## 5. 各业务场景说明
### 5.1 套餐订单支付
普通套餐下单后,如果需要支付,前端调用订单支付接口,并传:
```json
{
"payment_method": "alipay"
}
```
支付宝支付响应示例:
```json
{
"payment_method": "alipay",
"payment_link": {
"payment_no": "...",
"qr_link": "...",
"copy_link": "...",
"pay_expire_at": "..."
}
}
```
前端根据 `payment_link` 发起支付宝支付。
### 5.2 钱包充值
创建钱包充值单时传:
```json
{
"payment_method": "alipay"
}
```
支付宝场景返回 `payment_link`;微信场景返回 `pay_config`
### 5.3 强制充值购包
创建订单时,如果后端判断需要强制充值,前端可指定:
```json
{
"payment_method": "alipay"
}
```
如果是支付宝强充,响应中会返回:
```json
{
"order_type": "recharge",
"recharge": {},
"payment_link": {}
}
```
前端按支付宝支付链接流程处理。
## 6. 前端展示建议
支付宝支付页面建议展示:
- 支付金额
- 支付宝二维码或“去支付宝支付”按钮
- 支付链接过期倒计时
- “我已完成支付”按钮
- “重新获取支付链接”按钮
点击“我已完成支付”时,不直接判定成功,应查询服务端状态。
## 7. 注意事项
1. 金额单位仍然是 **分**
2. 支付宝支付不需要 `app_type`
3. `pay_config` 只用于微信支付。
4. `payment_link` 只用于支付宝支付。
5. 支付状态以服务端为准,不以前端跳转结果为准。
6. 支付链接过期后,前端应重新调用对应支付 / 充值接口获取新链接。
7. 具体接口路径、完整字段、错误码请以前端接口文档 / OpenAPI 文档为准。

View File

@@ -48,7 +48,7 @@
### 基础说明
- 路径参数 `asset_type` 取值:`card`(卡)或 `device`(设备
- 资产相关路由已统一为 `:identifier` 口径(虚拟号 / ICCID / IMEI / SN / MSISDN
- 企业账号调用 `resolve` 接口会返回 403
---
@@ -73,11 +73,15 @@
| `real_name_status` | int | 实名状态0 未实名 / 1 实名中 / 2 已实名 |
| `network_status` | int | 网络状态0 停机 / 1 开机(仅 card |
| `current_package` | string | 当前套餐名称(无则空) |
| `package_total_mb` | int64 | 当前套餐总虚流量 MB |
| `package_used_mb` | float64 | 已用虚流量 MB |
| `package_remain_mb` | float64 | 剩余虚流量 MB |
| `current_package_usage_id` | uint | 当前套餐的套餐使用记录 ID无主套餐时为 null |
| `real_total_mb` | int64 | 当前主套餐真实总量 MB |
| `real_used_mb` | int64 | 当前主套餐真实已用量 MB |
| `virtual_total_mb` | int64 | 当前主套餐业务停机阈值 MB |
| `virtual_used_mb` | float64 | 当前主套餐展示已用量 MB |
| `reduction_pct` | float64 | 展示增幅比例,公式为 `(real_total_mb / virtual_total_mb) - 1` |
| `enable_virtual_data` | bool | 当前主套餐是否启用虚流量(按套餐使用记录快照返回) |
| `device_protect_status` | string | 保护期状态:`none` / `stop` / `start`(仅 device |
| `activated_at` | time | 激活时间 |
| `activated_at` | time | 激活时间(未来准备移除,请勿依赖) |
| `created_at` | time | 创建时间 |
| `updated_at` | time | 更新时间 |
| **绑定关系card 时)** | | |
@@ -108,7 +112,7 @@
---
### `GET /api/admin/assets/:asset_type/:id/realtime-status`
### `GET /api/admin/assets/:identifier/realtime-status`
读取资产实时状态(直接读 DB/Redis不调网关
@@ -122,12 +126,15 @@
| `real_name_status` | int | 实名状态(仅 card |
| `current_month_usage_mb` | float64 | 本月已用流量 MB仅 card |
| `last_sync_time` | time | 最后同步时间(仅 card |
| `last_data_check_at` | time | 最后一次流量检查时间(仅 card |
| `last_real_name_check_at` | time | 最后一次实名检查时间(仅 card |
| `last_card_status_check_at` | time | 最后一次卡状态检查时间(仅 card |
| `device_protect_status` | string | 保护期:`none` / `stop` / `start`(仅 device |
| `cards[]` | array | 所有绑定卡的状态(仅 device同 resolve 的 cards 结构 |
---
### `POST /api/admin/assets/:asset_type/:id/refresh`
### `POST /api/admin/assets/:identifier/refresh`
主动调网关拉取最新数据后返回,响应结构与 `realtime-status` 完全相同。
@@ -135,7 +142,7 @@
---
### `GET /api/admin/assets/:asset_type/:id/packages`
### `GET /api/admin/assets/:identifier/packages`
查询该资产所有套餐记录,含虚流量换算字段。
@@ -149,12 +156,12 @@
| `package_type` | string | `formal`(正式套餐)/ `addon`(加油包) |
| `status` | int | 0 待生效 / 1 生效中 / 2 已用完 / 3 已过期 / 4 已失效 |
| `status_name` | string | 状态中文名 |
| `data_limit_mb` | int64 | 真流量总量 MB |
| `virtual_limit_mb` | int64 | 虚流量总量 MB已按 virtual_ratio 换算) |
| `data_usage_mb` | int64 | 已用真流量 MB |
| `virtual_used_mb` | float64 | 已用虚流量 MB |
| `virtual_remain_mb` | float64 | 剩余虚流量 MB |
| `virtual_ratio` | float64 | 虚流量比例 |
| `real_total_mb` | int64 | 套餐真实总量 MB |
| `real_used_mb` | int64 | 套餐真实已用量 MB |
| `virtual_total_mb` | int64 | 套餐业务停机阈值 MB |
| `virtual_used_mb` | float64 | 套餐展示已用量 MB |
| `reduction_pct` | float64 | 展示增幅比例,公式为 `(real_total_mb / virtual_total_mb) - 1` |
| `enable_virtual_data` | bool | 是否启用虚流量(按套餐使用记录快照返回) |
| `activated_at` | time | 激活时间 |
| `expires_at` | time | 到期时间 |
| `master_usage_id` | uint | 主套餐 ID加油包时有值 |
@@ -163,9 +170,9 @@
---
### `GET /api/admin/assets/:asset_type/:id/current-package`
### `GET /api/admin/assets/:identifier/current-package`
查询当前生效中的主套餐,响应结构同 `packages` 数组的单项。无生效套餐时返回 404
查询当前生效中的主套餐,响应结构同 `packages` 数组的单项。无生效套餐时返回 `200 + null`
---

View File

@@ -0,0 +1,30 @@
# C 端微信 AppID 获取接口功能总结
## 功能概述
新增 C 端免登录接口 `GET /api/c/v1/wechat/appid`,用于返回当前生效微信配置中的公众号 `AppID`
## 接口行为
- 无需登录即可访问
- 统一从当前生效微信配置读取 `oa_app_id`
- 响应 `data` 中仅返回 `app_id` 字段
- 当不存在生效配置或公众号 `AppID` 为空时,返回 `微信配置不可用`
## 适用场景
- 前端在发起公众号授权前,先获取当前环境应使用的 `AppID`
- 前端在初始化微信相关能力时,避免硬编码 `AppID`
## 返回示例
```json
{
"code": 0,
"msg": "success",
"data": {
"app_id": "wx1234567890abcdef"
},
"timestamp": 1745999999
}
```

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