46 Commits

Author SHA1 Message Date
d2e08dbbec skill提交
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m22s
2026-07-11 15:32:56 +09:00
026d4908d8 删除
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 53s
2026-07-11 12:28:38 +09:00
31232ea899 优化迁移脚本速度
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 53s
2026-07-10 13:10:00 +09:00
b38df737e1 先短暂去除限制,上传迁移脚本
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m51s
2026-07-09 18:23:29 +09:00
346156ee9b 卡只允许支付宝支付,设备只允许微信支付,钱包充值也遵循这个规则
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m53s
2026-07-03 10:26:48 +09:00
0d79130e07 入参
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m46s
2026-07-02 17:02:13 +09:00
b3fb8c7a82 资产详情新增两个字段
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-07-02 16:49:50 +09:00
44fb21eb6a 修复导入重试的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m53s
2026-07-02 15:13:22 +09:00
8f738ffbe8 导入的问题修复
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m51s
2026-07-02 13:04:36 +09:00
6db152bb2f chore: 安装 ponytail lazy senior dev 规则 2026-07-01 12:26:19 +09:00
fc6af43baa 修复:溢出流量应优先记到主套餐而非加油包
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m47s
主套餐和加油包同时 Depleted 时,recordOverflowToDepletedPackage
原先取 id 最大的套餐,可能取到加油包,语义不对。

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

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

30
.agents/rules/ponytail.md Normal file
View File

@@ -0,0 +1,30 @@
# Ponytail, lazy senior dev mode
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
Before writing any code, stop at the first rung that holds:
1. Does this need to be built at all? (YAGNI)
2. Does it already exist in this codebase? Reuse the helper, util, or pattern that's already here, don't re-write it.
3. Does the standard library already do this? Use it.
4. Does a native platform feature cover it? Use it.
5. Does an already-installed dependency solve it? Use it.
6. Can this be one line? Make it one line.
7. Only then: write the minimum code that works.
The ladder runs after you understand the problem, not instead of it: read the task and the code it touches, trace the real flow end to end, then climb.
Bug fix = root cause, not symptom: a report names a symptom. Grep every caller of the function you touch and fix the shared function once — one guard there is a smaller diff than one per caller, and patching only the path the ticket names leaves a sibling caller still broken.
Rules:
- No abstractions that weren't explicitly requested.
- No new dependency if it can be avoided.
- No boilerplate nobody asked for.
- Deletion over addition. Boring over clever. Fewest files possible.
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
- Question complex requests: "Do you actually need X, or does Y cover it?"
- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm.
- Mark intentional simplifications with a `ponytail:` comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.

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,7 @@
---
name: grill-me
description: A relentless interview to sharpen a plan or design.
disable-model-invocation: true
---
Run a `/grilling` session.

View File

@@ -0,0 +1,7 @@
---
name: grill-with-docs
description: A relentless interview to sharpen a plan or design, which also creates docs (ADR's and glossary) as we go.
disable-model-invocation: true
---
Run a `/grilling` session, using the `/domain-modeling` skill.

View File

@@ -0,0 +1,16 @@
---
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?"
disable-model-invocation: true
---
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,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 (from the `/codebase-design` skill) 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 the `/codebase-design` skill. 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 the `/codebase-design` glossary, reach for one that is before inventing a new one.

View File

@@ -0,0 +1,66 @@
---
name: improve-codebase-architecture
description: Scan a codebase for deepening opportunities, present them as a visual HTML report, then grill through whichever one you pick.
disable-model-invocation: true
---
# 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.
This command is _informed_ by the project's domain model and built on a shared design vocabulary:
- Run the `/codebase-design` skill for the architecture vocabulary (**module**, **interface**, **depth**, **seam**, **adapter**, **leverage**, **locality**) and its principles (the deletion test, "the interface is the test surface", "one adapter = hypothetical seam, two = real"). Use these terms exactly in every suggestion — don't drift into "component," "service," "API," or "boundary."
- The domain language in `CONTEXT.md` gives names to good seams; ADRs in `docs/adr/` record decisions this command should not re-litigate.
## Process
### 1. Explore
Read the project's domain glossary (`CONTEXT.md`) 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, render a card with:
- **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 the `/codebase-design` 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, run the `/grilling` skill to 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 — run the `/domain-modeling` skill to keep the domain model current as you go:
- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.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.
- **Want to explore alternative interfaces for the deepened module?** Run the `/codebase-design` skill and use its design-it-twice parallel sub-agent pattern.

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 answer a design question. Use when the user wants to sanity-check whether a state model or logic feels right, or explore what a UI should look like.
---
# 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,127 @@
---
name: setup-matt-pocock-skills
description: Configure this repo for the engineering skills — set up its issue tracker, triage label vocabulary, and domain doc layout. Run once before first use of the other engineering skills.
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
If — and only if — the user picked **GitHub** or **GitLab**, ask one follow-up:
> Explainer: Open-source repos often receive feature requests as pull requests, not just issues — a PR is an issue with attached code. If you turn this on, `/triage` pulls *external* PRs into the same queue and runs them through the same labels and states as issues (collaborators' in-flight PRs are left alone). Leave it off if PRs aren't a request surface for you.
- **PRs as a request surface** — yes / no (default: no). Record the answer in `docs/agents/issue-tracker.md`. For local-markdown and other trackers, skip this question — there are no PRs.
**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`, `diagnosing-bugs`, `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, plus whether external PRs are a triage surface]. 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 `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) 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 `/domain-modeling`).
## 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,34 @@
# 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.
## Pull requests as a triage surface
**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_
When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents:
- **Read a PR**: `gh pr view <number> --comments` and `gh pr diff <number>` for the diff.
- **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`).
- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`.
GitHub shares one number space across issues and PRs, so a bare `#42` may be either — resolve with `gh pr view 42` and fall back to `gh issue view 42`.
## 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,35 @@
# 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.
## Merge requests as a triage surface
**MRs as a request surface: no.** _(Set to `yes` if this repo treats external merge requests as feature requests; `/triage` reads this flag.)_
When set to `yes`, MRs run through the same labels and states as issues, using the `glab mr` equivalents:
- **Read an MR**: `glab mr view <number> --comments` and `glab mr diff <number>` for the diff.
- **List external MRs for triage**: `glab mr list -F json`, then keep only MRs whose author is not a project member/owner (a contributor's MR, not a maintainer's in-flight work).
- **Comment / label / close**: `glab mr note`, `glab mr update --label`/`--unlabel`, `glab mr close`.
Unlike GitHub, GitLab numbers issues and MRs separately, so `#42` is unambiguous once you know which surface the maintainer means.
## 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.

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

@@ -0,0 +1,111 @@
---
name: tdd
description: Test-driven development. Use when the user wants to build features or fix bugs test-first, mentions "red-green-refactor", or wants integration tests.
---
# 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.
**Tautological tests** restate the implementation inside the assertion, so they pass by construction and give zero confidence. When the expected value is computed the way the code computes it — `expect(add(a, b)).toBe(a + b)`, snapshotting a figure you derived by hand the same way the code does, asserting a constant equals itself — the test can never disagree with the code: break the code wrong and the assertion breaks wrong with it. The expected value must come from an independent source of truth — a known-good literal, a worked example, the spec.
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, read `CONTEXT.md` (if it exists) so that test names and interface vocabulary match the project's domain 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 (small interface, deep implementation) — run the `/codebase-design` skill for the vocabulary and the testability checks
- [ ] 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
[ ] Expected values are independent literals, not recomputed from the code
[ ] Code is minimal for this test
[ ] No speculative features added
```

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,77 @@
# 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");
});
```
**Tautological tests**: Expected value restates the implementation, so the test passes by construction.
```typescript
// BAD: Expected value is recomputed the way the code computes it
test("calculateTotal sums line items", () => {
const items = [{ price: 10 }, { price: 5 }];
const expected = items.reduce((sum, i) => sum + i.price, 0);
expect(calculateTotal(items)).toBe(expected);
});
// GOOD: Expected value is an independent, known literal
test("calculateTotal sums line items", () => {
expect(calculateTotal([{ price: 10 }, { price: 5 }])).toBe(15);
});
```

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,140 @@
---
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.
- `./assets/*`: Reusable **components** shared across lessons. See [Assets](#assets).
- `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.
## Assets
Lessons are built from reusable **components**, stored in `./assets/`: stylesheets, quiz widgets, simulators, diagram helpers — anything a second lesson could reuse.
Reuse is the default, not the exception. Before authoring a lesson, read `./assets/` and build from the components already there. When a lesson needs something new and reusable, write it as a component in `./assets/` and link to it — never inline code a future lesson would duplicate.
A shared stylesheet is the first component every workspace earns: every lesson links it, so the lessons look like one consistent course rather than a pile of one-offs. As the workspace grows, so should the component library.
## 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,84 @@
---
name: to-issues
description: Break a plan, spec, or PRD into independently-grabbable issues on the project issue tracker using tracer-bullet vertical slices.
disable-model-invocation: true
---
# 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.
Look for opportunities to prefactor the code to make the implementation easier. "Make the change easy, then make the easy change."
### 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.
<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
- Any prefactoring should be done first
</vertical-slice-rules>
### 4. Quiz the user
Present the proposed breakdown as a numbered list. For each slice, show:
- **Title**: short descriptive name
- **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?
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,75 @@
---
name: to-prd
description: Turn the current conversation into a PRD and publish it to the project issue tracker — no interview, just synthesis of what you've already discussed.
disable-model-invocation: true
---
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. The fewer seams across the codebase, the better - the ideal number is one.
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,207 @@
# Writing Agent Briefs
An agent brief is a structured comment posted on a GitHub issue or PR when it moves to `ready-for-agent`. It is the authoritative specification that an AFK agent will work from. The original body and discussion are context — the agent brief is the contract.
The brief states **what the agent should do**, which stretches to both surfaces: for an issue, that's building the change from nothing; for a PR, it's what's left to do *to the existing diff* — finish it, close gaps, address review points. Same principles either way; the PR example below shows the difference.
## 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/`)
```
### Good agent brief (PR)
For a PR, "Current behavior" describes the state of the diff, and the brief asks the agent to finish or fix it rather than build from scratch.
```markdown
## Agent Brief
**Category:** enhancement
**Summary:** Finish the contributor's `--json` output flag for `triage list`
**Current behavior:**
The PR adds a `--json` flag that serializes the issue list to JSON. The happy
path works and the diff matches the project's command structure. Two gaps
remain: errors are still printed as human text (not JSON), and the new flag has
no test coverage.
**Desired behavior:**
With `--json`, all output — including errors — is well-formed JSON on stdout,
and the command's exit codes are unchanged. The existing human-readable output
is untouched when the flag is absent.
**Key interfaces:**
- The command's error path should emit `{ "error": string }` under `--json`
instead of the plain-text error
- Reuse the existing serializer the PR already added; don't introduce a second
**Acceptance criteria:**
- [ ] `triage list --json` emits valid JSON for both success and error cases
- [ ] Exit codes match the non-JSON command
- [ ] A test covers the `--json` success output and one error case
- [ ] Default (non-JSON) output is byte-for-byte unchanged
**Out of scope:**
- Adding `--json` to any other command
- Changing the JSON shape of the success payload the PR already defined
```
### 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,105 @@
# 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`. This applies to enhancement PRs exactly as it does to issues — a rejected PR is recorded here so the same request doesn't return as fresh code.
Do **not** write here when something is closed as `wontfix` because it's **already implemented**. That's a built feature, not a rejected one; recording it would poison the dedup checks with false rejections. Instead, the closing comment points to where the feature already lives.
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,112 @@
---
name: triage
description: Move issues and external PRs through a state machine of triage roles — categorise, verify, grill if needed, and write agent-ready briefs.
disable-model-invocation: true
---
# Triage
Move issues on the project issue tracker through a small state machine of triage roles.
If this repo treats external pull requests as a request surface (see the issue-tracker config), triage covers them too: **a PR is an issue with attached code** — same roles, same states, same machine, with a few deltas marked "for a PR" below. Resolve a bare `#42` to an issue or PR per the tracker config.
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
For a PR, the same states read against the attached code: `ready-for-agent` means a brief is attached and an agent should take the next step on the diff; `ready-for-human` means it's ready for a human to merge.
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" (issue or PR)
- "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.
When PRs are in scope, include external PRs in these buckets and tag each line `[PR]` or `[issue]`. Discovery surfaces only *external* PRs (the tracker config defines who counts as external) — a collaborator's in-flight PR is not triage work. This filter is discovery-only; an explicitly named PR is always triaged regardless of author.
Show counts and a one-line summary per item. Let the maintainer pick.
## Triage a specific issue or PR
1. **Gather context.** Read the full issue or PR (body, comments, labels, author, dates; for a PR, the diff too). 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. Run two checks against the codebase: (a) **redundancy** — search for an existing implementation of the requested behavior by domain concept (not just the request's wording), and report where you looked. If found, it's an already-implemented `wontfix` (step 5). (b) **prior rejection** — read `.out-of-scope/*.md` and surface any that resembles this request.
2. **Recommend.** Tell the maintainer your category and state recommendation with reasoning, plus a brief codebase summary relevant to the request — including whether it's already implemented. Wait for direction.
3. **Verify the claim.** Before any grilling, check that the claim holds up. For a bug, reproduce it from the reporter's steps. For a PR, confirm the diff does what it claims — check it out, run the relevant tests or commands. Report what happened: confirmed (with code path), failed, or insufficient detail (a strong `needs-info` signal). A confirmed verification makes a much stronger agent brief.
4. **Grill (if needed).** If the request needs fleshing out, run the `/grilling` and `/domain-modeling` skills together — grill it into shape one question at a time, sharpening domain terms and updating `CONTEXT.md`/ADRs inline as decisions land.
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` — close, with the comment depending on *why*:
- **Already implemented** — the change already exists in the codebase. Point to where it lives; do **not** write to `.out-of-scope/` (that KB is for *rejected* requests, not built ones).
- **Rejected (bug)** — polite explanation, then close.
- **Rejected (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 or PR, 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

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;
```

11
.env Normal file
View File

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

93
.env.local Normal file
View File

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

236
.env.prod Normal file
View File

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

6
.gitignore vendored
View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -338,3 +338,17 @@ queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, payloadBytes)
**详细规范和 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

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

File diff suppressed because it is too large Load Diff

View File

@@ -250,7 +250,7 @@ curl -G "https://open.example.com/api/open/v1/cards/status" \
- `total_flow_mb`:套餐总流量,单位 MB。
- `valid_days`:套餐有效天数。
钱包购买接口一次只允许一个 `package_code``card_nos` 最多 100 张卡。每张卡独立创建钱包订单,允许部分成功,失败卡会返回独立错误码和原因。
钱包购买接口一次只允许一个 `package_code``card_nos` 最多 100 张卡。每张卡独立创建钱包订单,允许部分成功,失败卡会返回独立错误码和原因。任一卡购买失败时,外层 `code`/`msg` 返回首条失败卡的错误码和原因,同时 `data.failed_cards` 保留失败明细,`data.orders` 保留已成功订单;接入方应以 `success_count``failed_count``orders``failed_cards` 判断单卡结果,避免整批重试造成重复下单。
## 5. 错误码

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,94 @@
# 轮询配置二维条件重构计划
## 背景
当前轮询配置使用 `card_condition` 单字段表达卡的轮询条件,但业务上实际存在两个独立维度:
- 实名状态:未实名、已实名
- 卡状态:开机、停机
单字段枚举会把两个维度压成互斥状态,导致“未实名 + 停机”这类卡只能命中一个条件,配置语义不直观,也容易出现高低频策略互相覆盖。
## 目标
将轮询配置从单一 `card_condition` 改为二维条件匹配:
- `realname_condition``any``not_real_name``real_name`
- `network_condition``any``online``offline`
配置匹配规则调整为:
```text
实名条件匹配 AND 卡状态条件匹配 AND 卡类型/运营商等条件匹配
```
一张卡允许同时命中多条配置;每个任务类型仍按现有 `priority ASC` 选择第一条非空 interval。
## 推荐语义
| 条件字段 | 值 | 语义 |
| --- | --- | --- |
| `realname_condition` | `any` | 不限制实名状态 |
| `realname_condition` | `not_real_name` | `real_name_status != 1` |
| `realname_condition` | `real_name` | `real_name_status = 1` |
| `network_condition` | `any` | 不限制开停机状态 |
| `network_condition` | `online` | `network_status = 1` |
| `network_condition` | `offline` | `network_status = 0` |
## 迁移映射
为了平滑迁移旧配置,可按以下规则回填新字段:
| 旧 `card_condition` | 新 `realname_condition` | 新 `network_condition` |
| --- | --- | --- |
| 空 | `any` | `any` |
| `not_real_name` | `not_real_name` | `online` |
| `real_name` | `real_name` | `any` |
| `suspended` | `any` | `offline` |
| `activated` | `real_name` | `online` |
## 示例配置
未实名卡高频实名:
```text
realname_condition = not_real_name
network_condition = any
realname_check_interval = 120
其他 interval = NULL
```
停机卡高频套餐和卡状态:
```text
realname_condition = any
network_condition = offline
package_check_interval = 120
card_status_check_interval = 120
其他 interval = NULL
```
已实名开机卡低频实名、常规套餐和卡状态:
```text
realname_condition = real_name
network_condition = online
realname_check_interval = 86400
package_check_interval = 300
card_status_check_interval = 3600
```
## 实施步骤
1. 新增数据库字段 `realname_condition``network_condition`,保留旧字段用于迁移兼容。
2. 回填历史配置的新字段值。
3. 调整 DTO、Handler、Service、文档生成器和 OpenAPI 描述。
4. 调整 `PollingConfigManager` 的匹配逻辑,按二维条件筛选配置。
5. 验证典型组合:未实名开机、未实名停机、已实名开机、已实名停机。
6. 确认线上配置完成迁移后,再考虑废弃旧 `card_condition` 字段。
## 风险
- `real_name` 条件生效后,现有灰度配置可能覆盖大量已实名卡,需要上线前确认优先级和 interval。
- 多条配置同时命中是预期行为,但需要用 priority 明确每个任务类型的最终 interval。
- 迁移期间需要保持旧配置兼容,避免配置未更新时轮询队列为空。

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

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

View File

@@ -1,7 +1,7 @@
# 奇成数据迁移方案
> 目标:把奇成(旧 MySQL `kyhl` 库的存量卡和设备迁移到新系统PostgreSQL输出 SQL 脚本供线上手动执行。
> 范围:当前态资产 + 当前生效套餐 + 累计已用流量 + 代理佣金总值。**不迁移历史明细**。
> 范围:当前态资产 + 当前生效正式套餐 + 未生效待生效正式套餐 + 累计已用流量 + 代理佣金总值。**不迁移历史明细、已过期套餐和加油包**。
---
@@ -14,12 +14,10 @@
│ resources/devices.csv│
└──────────┬───────────┘
我们维护映射配置
我们维护唯一决策配置
┌──────────▼───────────┐
│ config/carrier.yaml │
config/series.yaml
│ config/package.yaml │
│ config/shop.yaml │
│ config/mapping.yaml │
归属/槽位/套餐映射
└──────────┬───────────┘
┌──────────▼───────────┐
@@ -45,9 +43,11 @@
```
**关键约束**
- 两个脚本都**不直接写入**线上库,只生成 `.sql` 文件
- 两个脚本都**不直接写入**线上库,只生成 `.sql` 文件和审核 CSV
- `config/mapping.yaml` 是归属、设备当前槽位、套餐来源槽位和套餐迁移范围的决策源;奇成只提供历史事实
- 脚本对奇成线上库**只 SELECT**DB 连接层强制只读事务(`SET TRANSACTION READ ONLY`
- 所有 INSERT 使用 `ON CONFLICT DO NOTHING`,支持重跑
- `errors.csv` 非空时不执行 SQL先修正配置/输入后重跑
---
@@ -84,13 +84,14 @@
| `sim_iccid_2` | 否 | 插槽2的 iccid | `89860623...` |
| `sim_iccid_3` | 否 | 插槽3的 iccid | |
| `sim_iccid_4` | 否 | 插槽4的 iccid | |
| `current_slot` | 否 | 当前使用的插槽位置1-4用于 `tb_device_sim_binding.is_current` | `1` |
| `target_shop_code` | 否 | 目标店铺编码。**留空 = 进平台库存** | `SHOP20260428103424CZZQ` |
| `current_slot` | 否 | 当前使用的插槽位置1-4用于 `tb_device_sim_binding.is_current`。空值按 `mapping.yaml` 批量规则或覆盖项填充 | `2` |
| `package_source_slot` | 否 | 设备套餐来源槽位1-4。只迁该槽位卡的奇成正式套餐为设备套餐 | `2` |
**注意**
- 设备上的卡,其 `cards.csv``target_shop_code` 必须留空(一致性校验由脚本检查)
- `cards.csv` 必须先到位,`devices.csv``sim_iccid_*` 才能被校验
- 业务方分别准备这两份是合理的,奇成 `tbl_card_relate`(主卡+副卡1+副卡2的关系业务方应已掌握
- 槽位解析优先级:`devices.csv` 行级配置 > `mapping.yaml.overrides.devices` > `mapping.yaml.ownership_rules.device` 批量默认值
- `current_slot``package_source_slot` 对应槽位无卡时写入 `errors.csv` 并阻断该设备相关 SQL
---
@@ -123,9 +124,37 @@
## 四、我们维护的映射配置
迁移前**必须**在新系统建好店铺、运营商、套餐系列、套餐,然后填好以下映射
迁移前**必须**在新系统建好店铺、运营商、套餐系列、套餐,然后填好 `config/mapping.yaml`。该文件是本次迁移的唯一决策源
### 4.1 `config/carrier_mapping.yaml`
### 4.1 归属、槽位和套餐规则
```yaml
ownership_rules:
default_target_shop_code: KWTX
device:
mode: default_shop
current_slot: 2
package_source_slot: 2
standalone_card:
mode: default_shop
package_rules:
migrate_statuses: [active, pending]
overrides:
devices:
- virtual_no: "862639073940258"
target_shop_code: OTHER
current_slot: 1
package_source_slot: 1
cards:
- iccid: "89861590172420360956"
target_shop_code: OTHER
```
归属规则:独立卡按 `overrides.cards``ownership_rules.standalone_card` 决定;设备按 `overrides.devices``ownership_rules.device` 决定;设备上的卡跟随设备。奇成 `agent_id` 不再作为默认归属来源。
### 4.2 `config/mapping.yaml` 中的 carriers
```yaml
# 奇成的运营商名称(业务方在 cards.csv 中填的)→ 新系统 carrier_id + type
@@ -141,7 +170,7 @@ carriers:
# ... 业务方提供的所有运营商都要列出
```
### 4.2 `config/series_mapping.yaml`
### 4.3 `config/mapping.yaml` 中的 series
```yaml
# 奇成套餐系列名 → 新系统 series_id
@@ -153,7 +182,7 @@ series:
target_series_id: 6
```
### 4.3 `config/package_mapping.yaml`
### 4.4 `config/mapping.yaml` 中的 packages
```yaml
# 奇成套餐名 → 新系统 package_idcards.csv 的 package_name 直接对照这里)
@@ -166,7 +195,7 @@ packages:
> 注:奇成的 `tbl_set_meal` 套餐表非常大,**不需要全部映射**,只映射本次迁移涉及的套餐(脚本预扫描 cards.csv 收集 package_name 集合,缺失的列出来让我们补)。
### 4.4 `config/shop_mapping.yaml`(可选别名)
### 4.5 店铺编码
```yaml
# 如果业务方愿意填 shop_code本文件可以省略
@@ -318,16 +347,15 @@ FROM c;
```
1. 读 cards.csv 得到 iccid 列表
2. 连奇成(只读事务)批量查:
- tbl_card_life WHERE iccid_mark IN (...) AND status=1 ORDER BY expire_date DESC
→ 每张卡取最新生效的套餐meal_id, meal_name, start_date, expire_date
- tbl_card WHERE iccid_mark IN (...) → total_bytes_cnt (GB)
- tbl_agent_commission_account → 按 agent_id 聚合 can_draw_amount, total_commision_amount
- tbl_agent根据 cards.csv 中 agent 信息映射出 shop_id
3. 对每张卡:
a. 套餐映射legacy_meal_name → package_id从 yaml 或业务方 csv
b. 生成伪订单 SQLorder_no = "MIG-CARD-" + iccid 后8位 + 时间戳
c. 生成 tb_package_usage SQL关联伪订单 + 计算 snapshot 字段
d. 生成 UPDATE tb_iot_card SET data_usage_mb = ... (把 GB 转 MB
- `tbl_card_life` 当前生效正式套餐和 `tbl_next_month_card_life` 次月待生效套餐,保留 legacy 套餐 ID、套餐名、类型、状态、开始时间、到期时间和稳定排序键
- `tbl_card.total_bytes_cnt` → 反算新系统真用量 MB
- `tbl_agent_commission_account` / `tbl_agent` → 仅用于代理钱包初始化
3. 对每个独立卡或设备套餐来源槽位:
a. 套餐映射:`legacy_meal_id` → `target_package_id`(来自 `mapping.yaml.packages`
b. active 套餐写 `tb_package_usage.status=1`pending 套餐写 `status=0` 并按稳定顺序写 `priority`
c. 生成伪订单 SQLorder_no = `MIG-<ICCID>-<priority>`
d. 生成 `tb_package_usage` SQL关联伪订单 + 计算 snapshot 字段
e. 生成 `UPDATE tb_iot_card SET data_usage_mb = ...`(真用量 MB
4. 对每个 shop按代理映射出来的
a. 生成 INSERT/UPDATE tb_agent_wallet总值
b. 生成一条 tb_agent_wallet_transactiontype=initial_migration, amount=总值, remark=源奇成代理ID
@@ -343,7 +371,10 @@ output/
step2_03_card_data_usage_update.sql # 卡累计流量 UPDATE
step2_04_agent_wallet_init.sql # 代理钱包总值
step2_05_agent_wallet_transactions.sql # 迁移流水
warnings.csv # 警告:找不到当前生效套餐、无法映射的代理等
step2_06_asset_series_update.sql # 资产套餐系列回填
package_resolution.csv # 套餐迁移审核文件
errors.csv # 套餐映射缺失、多 active、槽位缺失等阻断错误
warnings.csv # 用量反算和代理钱包警告
summary.txt
```
@@ -357,7 +388,7 @@ WITH new_order AS (
total_amount, payment_method, payment_status, paid_at,
source, generation, creator, updater, created_at, updated_at
)
SELECT 'MIG-CARD-' || RIGHT('89852000263338772439', 8) || '-' || TO_CHAR(NOW(), 'YYYYMMDDHH24MISS'),
SELECT 'MIG-89852000263338772439-1',
'single_card', 'personal', 0, c.id,
0, 'offline', 2, NOW(),
'migration', 1, 0, 0, NOW(), NOW()
@@ -489,9 +520,9 @@ scripts/migration/
| 1 | 业务方填的 carrier_name / package_name 与奇成不一致 → 映射失败 | 脚本预扫描,把所有未匹配项写到 unmatched.csv 让业务方修正 |
| 2 | 奇成的 iccid 有 18/21/22 位脏数据 | 脚本第三节规则剔除,进 errors.csv |
| 3 | 同一 iccid 被业务方填多次 | 脚本去重 + 警告 |
| 4 | 设备上的卡在 cards.csv 里又写了 target_shop_code | 一致性校验,进 errors.csv |
| 5 | 奇成 tbl_card_life 一张卡有多条 status=1 记录 | 取 expire_date 最大的那条warnings.csv 记录 |
| 6 | 套餐 ID 映射不全 | 脚本预扫描,缺的列出来,业务方/我们补全 yaml |
| 4 | 设备 `current_slot``package_source_slot` 指向空槽位 | 写入 errors.csv阻断该设备资产或套餐 SQL |
| 5 | 同一资产存在多个 active 正式套餐 | 写入 errors.csv阻断该资产套餐 SQL人工裁决后修正 |
| 6 | 套餐 ID 映射不全 | `package_resolution.csv` 记录errors.csv 阻断对应套餐 SQL |
| 7 | tb_iot_card.is_standalone 由触发器维护 | 脚本写入时设 true触发器会因为后续 binding 自动转 false |
| 8 | 代理→shop 映射不明确 | 需要业务方提供 agent_id → shop_code 映射表,作为 config/agent_shop_mapping.yaml |
| 9 | 线上库 sequence 与测试库不同 | 用 CTE + RETURNING 模式,不硬编码 id |
@@ -526,7 +557,7 @@ mappings:
| 字段 | 值 | 说明 |
|------|-----|------|
| `order_no` | `MIG-CARD-<iccid后8位>-<时间戳>` / `MIG-DEV-<vno>-<时间戳>` | 前缀就是迁移数据的唯一识别标记 |
| `order_no` | `MIG-<ICCID>-<priority>` | 前缀就是迁移数据的唯一识别标记 |
| `source` | `'admin'`(复用现有) | 不新增 `migration` 取值 |
| `buyer_type` | `'personal'` | 与 buyer_id=0 配对 |
| `buyer_id` | `0` | 与赠送/平台自营一致 |

8
go.mod
View File

@@ -5,7 +5,7 @@ go 1.25.0
require (
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38
github.com/aws/aws-sdk-go v1.55.5
github.com/bytedance/sonic v1.14.2
github.com/bytedance/sonic v1.15.2
github.com/go-playground/validator/v10 v10.28.0
github.com/gofiber/fiber/v2 v2.52.9
github.com/gofiber/storage/redis/v3 v3.4.1
@@ -14,6 +14,7 @@ require (
github.com/hibiken/asynq v0.25.1
github.com/jackc/pgx/v5 v5.7.6
github.com/redis/go-redis/v9 v9.17.3
github.com/smartwalle/alipay/v3 v3.2.29
github.com/spf13/viper v1.21.0
github.com/swaggest/openapi-go v0.2.60
github.com/valyala/fasthttp v1.66.0
@@ -24,6 +25,7 @@ require (
gopkg.in/natefinch/lumberjack.v2 v2.2.1
gopkg.in/yaml.v3 v3.0.1
gorm.io/datatypes v1.2.7
gorm.io/driver/mysql v1.5.6
gorm.io/driver/postgres v1.6.0
gorm.io/gorm v1.31.1
)
@@ -35,7 +37,7 @@ require (
github.com/Azure/go-ansiterm v0.0.0-20230124172434-306776ec8161 // indirect
github.com/andybalholm/brotli v1.2.0 // indirect
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic/loader v0.4.0 // indirect
github.com/bytedance/sonic/loader v0.5.1 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/clbanning/mxj/v2 v2.7.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
@@ -69,7 +71,6 @@ require (
github.com/robfig/cron/v3 v3.0.1 // indirect
github.com/rogpeppe/go-internal v1.13.1 // indirect
github.com/sagikazarmark/locafero v0.11.0 // indirect
github.com/smartwalle/alipay/v3 v3.2.29 // indirect
github.com/smartwalle/ncrypto v1.0.4 // indirect
github.com/smartwalle/ngx v1.1.0 // indirect
github.com/smartwalle/nsign v1.0.9 // indirect
@@ -98,5 +99,4 @@ require (
golang.org/x/time v0.14.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
gopkg.in/yaml.v2 v2.4.0 // indirect
gorm.io/driver/mysql v1.5.6 // indirect
)

4
go.sum
View File

@@ -28,8 +28,12 @@ github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.14.2 h1:k1twIoe97C1DtYUo+fZQy865IuHia4PR5RPiuGPPIIE=
github.com/bytedance/sonic v1.14.2/go.mod h1:T80iDELeHiHKSc0C9tubFygiuXoGzrkjKzX2quAx980=
github.com/bytedance/sonic v1.15.2 h1:90H+rcF/FwLXwfB1cudOLq/je83n683Utf4Cbp0xHCo=
github.com/bytedance/sonic v1.15.2/go.mod h1:mT2NbXunuaEbnZ+mRIX/vYqKISmgEuHFDI4UzmKx2SA=
github.com/bytedance/sonic/loader v0.4.0 h1:olZ7lEqcxtZygCK9EKYKADnpQoYkRQxaeY2NYzevs+o=
github.com/bytedance/sonic/loader v0.4.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/bytedance/sonic/loader v0.5.1 h1:Ygpfa9zwRCCKSlrp5bBP/b/Xzc3VxsAW+5NIYXrOOpI=
github.com/bytedance/sonic/loader v0.5.1/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=

View File

@@ -17,7 +17,6 @@ import (
func initHandlers(svc *services, deps *Dependencies) *Handlers {
validate := validator.New()
personalCustomerDeviceStore := postgres.NewPersonalCustomerDeviceStore(deps.DB)
assetWalletStore := postgres.NewAssetWalletStore(deps.DB, deps.Redis)
packageStore := postgres.NewPackageStore(deps.DB)
shopPackageAllocationStore := postgres.NewShopPackageAllocationStore(deps.DB)
@@ -56,7 +55,7 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
rechargeOrderStore,
paymentStore,
assetWalletStore,
personalCustomerDeviceStore,
svc.CustomerBinding,
personalCustomerOpenIDStore,
personalCustomerStore,
personalCustomerPhoneStore,
@@ -78,12 +77,12 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
Permission: admin.NewPermissionHandler(svc.Permission),
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger),
ClientAsset: app.NewClientAssetHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger),
ClientWallet: app.NewClientWalletHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore),
ClientAsset: app.NewClientAssetHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger),
ClientWallet: app.NewClientWalletHandler(svc.Asset, svc.CustomerBinding, assetWalletStore, assetWalletTransactionStore, rechargeOrderStore, paymentStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore),
ClientOrder: app.NewClientOrderHandler(clientOrderService, deps.Logger),
ClientExchange: app.NewClientExchangeHandler(svc.Exchange),
ClientRealname: app.NewClientRealnameHandler(svc.Asset, personalCustomerDeviceStore, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger),
ClientDevice: app.NewClientDeviceHandler(svc.Asset, personalCustomerDeviceStore, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger),
ClientRealname: app.NewClientRealnameHandler(svc.Asset, svc.CustomerBinding, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger, svc.PollingManualTrigger),
ClientDevice: app.NewClientDeviceHandler(svc.Asset, svc.CustomerBinding, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger),
ClientRechargeOrder: app.NewClientRechargeOrderHandler(rechargeOrderStore, paymentStore, deps.Logger),
Shop: admin.NewShopHandler(svc.Shop),
ShopRole: admin.NewShopRoleHandler(svc.Shop),
@@ -140,7 +139,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
}(),
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate),
Refund: admin.NewRefundHandler(svc.Refund),
Refund: admin.NewRefundHandler(svc.Refund),
OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(svc.OrderPackageInvalidate),
ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger),
SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword),
AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate),

View File

@@ -14,6 +14,7 @@ import (
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth"
carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier"
clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth"
customerBindingSvc "github.com/break/junhong_cmp_fiber/internal/service/customer_binding"
commissionCalculationSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
@@ -45,6 +46,7 @@ import (
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
operationPasswordSvc "github.com/break/junhong_cmp_fiber/internal/service/operation_password"
orderPackageInvalidateSvc "github.com/break/junhong_cmp_fiber/internal/service/order_package_invalidate"
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund"
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
@@ -109,9 +111,14 @@ type services struct {
TrafficQuery *trafficSvc.QueryService
OperationPassword *operationPasswordSvc.Service
AgentOpenAPI *agentOpenAPISvc.Service
CustomerBinding *customerBindingSvc.Service
OrderPackageInvalidate *orderPackageInvalidateSvc.Service
}
func initServices(s *stores, deps *Dependencies) *services {
// CustomerBinding 模块依赖最少,最先初始化
customerBinding := customerBindingSvc.New(deps.DB, s.IotCard, s.Device)
purchaseValidation := purchaseValidationSvc.New(deps.DB, s.IotCard, s.Device, s.Package, s.ShopPackageAllocation)
accountAudit := accountAuditSvc.NewService(s.AccountOperationLog)
assetAudit := assetAuditSvc.NewService(s.AssetOperationLog, deps.DB)
@@ -172,6 +179,8 @@ func initServices(s *stores, deps *Dependencies) *services {
iotCard.SetRealnameActivator(packageActivation)
iotCard.SetStopResumeService(stopResumeService)
iotCard.SetDeviceSimBindingStore(s.DeviceSimBinding)
iotCard.SetEnterpriseCardAuthStore(s.EnterpriseCardAuthorization)
iotCard.SetEnterpriseStore(s.Enterprise)
iotCard.SetRedisClient(deps.Redis)
device := deviceSvc.New(
deps.DB,
@@ -187,12 +196,14 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.GatewayClient,
s.AssetIdentifier,
assetAudit,
s.EnterpriseDeviceAuthorization,
s.Enterprise,
)
operationPassword := operationPasswordSvc.New(deps.Redis)
shopCommission := shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction, deps.DB, deps.Logger)
packageService := packageSvc.New(s.Package, s.PackageSeries, s.ShopPackageAllocation, s.ShopSeriesAllocation)
orderService := orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, s.Payment, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, paymentLoader, deps.QueueClient, deps.Logger, s.AssetIdentifier, s.PersonalCustomer, s.PersonalCustomerPhone)
assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder)
assetService := assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient, s.AssetIdentifier, s.Order, s.OrderItem, s.ExchangeOrder, assetAudit)
agentOpenAPI := agentOpenAPISvc.New(assetService, packageService, orderService, shopCommission, stopResumeService, device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.AgentWallet, s.DeviceSimBinding, s.Device)
return &services{
@@ -206,7 +217,6 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.DB,
s.PersonalCustomerOpenID,
s.PersonalCustomer,
s.PersonalCustomerDevice,
s.PersonalCustomerPhone,
s.IotCard,
s.Device,
@@ -215,6 +225,7 @@ func initServices(s *stores, deps *Dependencies) *services {
deps.JWTManager,
deps.Redis,
deps.Logger,
customerBinding,
),
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole, s.AgentWallet),
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
@@ -260,11 +271,11 @@ func initServices(s *stores, deps *Dependencies) *services {
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
PurchaseValidation: purchaseValidation,
Order: orderService,
Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, s.PersonalCustomerDevice, deps.Logger),
Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, customerBinding, deps.Logger),
Recharge: rechargeSvc.New(deps.DB, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, paymentLoader, deps.Logger),
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig, deps.Redis, deps.Logger),
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
PollingMonitoring: pollingSvc.NewMonitoringService(deps.Redis),
PollingMonitoring: pollingSvc.NewMonitoringServiceWithQueueMgr(deps.Redis, pollingQueueMgr, deps.Logger),
PollingAlert: pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger),
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger),
@@ -304,5 +315,7 @@ func initServices(s *stores, deps *Dependencies) *services {
s.AssetWallet,
deps.Logger,
),
CustomerBinding: customerBinding,
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
}
}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,22 @@
package exporter
import "context"
// DataSource 导出场景数据源接口。
// 场景实现只负责统计、表头和按 offset/limit 拉取数据,分片调度由导出框架统一处理。
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)
}
// ExportParams 导出任务执行参数。
// Filters 来自任务 query_json.filters权限字段来自任务创建时的快照。
type ExportParams struct {
Filters map[string]any
ScopeShopIDs []uint
UserType int
CreatorShopID *uint
ResolvedHeaders []string
}

View File

@@ -2,97 +2,448 @@ package exporter
import (
"context"
"fmt"
"strconv"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/internal/model"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
// DeviceSceneStrategy 设备导出场景策略。
type DeviceSceneStrategy struct {
const (
deviceExportBaseHeaderCount = 6
deviceExportCardGroupSize = 5
deviceExportTailHeaderCount = 5
deviceExportMinCardGroups = 1
)
// DeviceDataSource 设备导出数据源。
type DeviceDataSource struct {
db *gorm.DB
}
// NewDeviceSceneStrategy 创建设备场景策略
func NewDeviceSceneStrategy(db *gorm.DB) *DeviceSceneStrategy {
return &DeviceSceneStrategy{db: db}
// NewDeviceDataSource 创建设备导出数据源
func NewDeviceDataSource(db *gorm.DB) *DeviceDataSource {
return &DeviceDataSource{db: db}
}
// Scene 场景编码。
func (s *DeviceSceneStrategy) Scene() string {
// Scene 返回导出场景编码。
func (s *DeviceDataSource) Scene() string {
return constants.ExportTaskSceneDevice
}
// Headers 导出表头
func (s *DeviceSceneStrategy) Headers() []string {
return []string{"ID", "设备虚拟号", "设备名称", "设备型号", "设备类型", "状态", "店铺ID", "创建时间"}
// Count 统计设备导出行数
func (s *DeviceDataSource) Count(ctx context.Context, params ExportParams) (int, error) {
var total int64
query := s.applyFilters(s.baseQuery(ctx), params)
if err := query.Count(&total).Error; err != nil {
return 0, err
}
return int(total), nil
}
// NextShardBoundary 获取下一段分片边界
func (s *DeviceSceneStrategy) NextShardBoundary(ctx context.Context, task *model.ExportTask, afterID uint64, limit int) (*ShardBoundary, error) {
if limit <= 0 {
limit = constants.ExportDefaultShardSize
}
var ids []uint64
query := s.db.WithContext(ctx).
Model(&model.Device{}).
Where("id > ?", afterID).
Order("id ASC").
Limit(limit)
query = applyShopScopeForTask(query, task)
if err := query.Pluck("id", &ids).Error; err != nil {
// Headers 返回设备导出表头
func (s *DeviceDataSource) Headers(ctx context.Context, params ExportParams) ([]string, error) {
cardGroups, err := s.resolveCardGroupCount(ctx, params)
if err != nil {
return nil, err
}
if len(ids) == 0 {
return nil, nil
}
return &ShardBoundary{
StartID: afterID,
EndID: ids[len(ids)-1],
RowCount: len(ids),
}, nil
return buildDeviceExportHeaders(cardGroups), nil
}
// QueryRows 查询分片内数据
func (s *DeviceSceneStrategy) QueryRows(ctx context.Context, task *model.ExportTask, startID, endID uint64) ([][]string, error) {
if endID == 0 {
// Fetch 按 offset/limit 查询设备导出数据。
func (s *DeviceDataSource) Fetch(ctx context.Context, params ExportParams, offset, limit int) ([][]string, error) {
if limit <= 0 {
return [][]string{}, nil
}
var devices []model.Device
query := s.db.WithContext(ctx).
Model(&model.Device{}).
Where("id > ? AND id <= ?", startID, endID).
Order("id ASC")
query = applyShopScopeForTask(query, task)
cardGroups := cardGroupCountFromHeaders(params.ResolvedHeaders)
if cardGroups <= 0 {
resolved, err := s.resolveCardGroupCount(ctx, params)
if err != nil {
return nil, err
}
cardGroups = resolved
}
if err := query.Find(&devices).Error; err != nil {
devices, err := s.fetchDevices(ctx, params, offset, limit)
if err != nil {
return nil, err
}
if len(devices) == 0 {
return [][]string{}, nil
}
deviceIDs := make([]uint, 0, len(devices))
for _, item := range devices {
deviceIDs = append(deviceIDs, item.ID)
}
cardMap, err := s.fetchBoundCards(ctx, deviceIDs)
if err != nil {
return nil, err
}
packageMap, err := s.fetchActivePackages(ctx, deviceIDs)
if err != nil {
return nil, err
}
rows := make([][]string, 0, len(devices))
for _, item := range devices {
shopID := ""
if item.ShopID != nil {
shopID = strconv.FormatUint(uint64(*item.ShopID), 10)
}
rows = append(rows, []string{
strconv.FormatUint(uint64(item.ID), 10),
item.VirtualNo,
item.DeviceName,
item.DeviceModel,
item.DeviceType,
strconv.Itoa(item.Status),
shopID,
item.CreatedAt.Format("2006-01-02 15:04:05"),
})
rows = append(rows, buildDeviceExportRow(item, cardMap[item.ID], packageMap[item.ID], cardGroups))
}
return rows, nil
}
func (s *DeviceDataSource) baseQuery(ctx context.Context) *gorm.DB {
return s.db.WithContext(ctx).
Table("tb_device AS d").
Where("d.deleted_at IS NULL")
}
// applyFilters 应用与 /api/admin/devices 一致的筛选条件。
// 导出任务运行在 Worker 中,权限只能使用任务创建时保存的店铺范围快照。
func (s *DeviceDataSource) applyFilters(query *gorm.DB, params ExportParams) *gorm.DB {
query = applyExportShopScope(query, params, "d.shop_id")
if virtualNo, ok := filterString(params.Filters, "virtual_no"); ok {
query = query.Where("d.virtual_no LIKE ?", "%"+virtualNo+"%")
}
if imei, ok := filterString(params.Filters, "imei"); ok {
query = query.Where("d.imei LIKE ?", "%"+imei+"%")
}
if keyword, ok := filterString(params.Filters, "keyword"); ok {
like := "%" + keyword + "%"
query = query.Where("d.virtual_no LIKE ? OR d.imei LIKE ?", like, like)
}
if deviceName, ok := filterString(params.Filters, "device_name"); ok {
query = query.Where("d.device_name LIKE ?", "%"+deviceName+"%")
}
if status, ok := filterInt(params.Filters, "status"); ok && status > 0 {
query = query.Where("d.status = ?", status)
}
if activationStatus, ok := filterInt(params.Filters, "activation_status"); ok {
query = s.applyActivationStatusFilter(query, activationStatus)
}
if shopIDs, ok := filterUintSlice(params.Filters, "shop_ids"); ok {
if len(shopIDs) == 0 {
query = query.Where("1 = 0")
} else {
query = query.Where("d.shop_id IN ?", shopIDs)
}
} else if shopID, ok := filterInt(params.Filters, "shop_id"); ok {
if shopID == 0 {
query = query.Where("1 = 0")
} else if shopID > 0 {
query = query.Where("d.shop_id = ?", shopID)
}
}
if batchNo, ok := filterString(params.Filters, "batch_no"); ok {
query = query.Where("d.batch_no = ?", batchNo)
}
if deviceType, ok := filterString(params.Filters, "device_type"); ok {
query = query.Where("d.device_type = ?", deviceType)
}
if manufacturer, ok := filterString(params.Filters, "manufacturer"); ok {
query = query.Where("d.manufacturer LIKE ?", "%"+manufacturer+"%")
}
if start, ok := filterTime(params.Filters, "created_at_start"); ok {
query = query.Where("d.created_at >= ?", start)
}
if end, ok := filterTime(params.Filters, "created_at_end"); ok {
query = query.Where("d.created_at <= ?", end)
}
if seriesID, ok := filterUint(params.Filters, "series_id"); ok {
query = query.Where("d.series_id = ?", seriesID)
}
if hasActivePackage, ok := filterOptionalBool(params.Filters, "has_active_package"); ok {
query = s.applyHasActivePackageFilter(query, hasActivePackage)
}
return query
}
// applyHasActivePackageFilter 按设备是否有生效中的主套餐过滤。
func (s *DeviceDataSource) applyHasActivePackageFilter(query *gorm.DB, hasActive bool) *gorm.DB {
subQuery := `SELECT 1 FROM tb_package_usage AS pu
WHERE pu.device_id = d.id
AND pu.status = ?
AND pu.master_usage_id IS NULL
AND pu.deleted_at IS NULL`
if hasActive {
return query.Where("EXISTS ("+subQuery+")", constants.PackageUsageStatusActive)
}
return query.Where("NOT EXISTS ("+subQuery+")", constants.PackageUsageStatusActive)
}
func (s *DeviceDataSource) applyActivationStatusFilter(query *gorm.DB, activationStatus int) *gorm.DB {
activeCondition := `EXISTS (
SELECT 1
FROM tb_package_usage AS pu
JOIN tb_device_sim_binding AS b
ON b.device_id = pu.device_id
AND b.bind_status = ?
AND b.deleted_at IS NULL
JOIN tb_iot_card AS c
ON c.id = b.iot_card_id
AND c.real_name_status = ?
AND c.deleted_at IS NULL
WHERE pu.device_id = d.id
AND pu.status = ?
AND pu.master_usage_id IS NULL
AND pu.deleted_at IS NULL
)`
args := []any{
constants.BindStatusBound,
constants.RealNameStatusVerified,
constants.PackageUsageStatusActive,
}
if activationStatus == constants.ActivationStatusActive {
return query.Where(activeCondition, args...)
}
if activationStatus == constants.ActivationStatusInactive {
return query.Where("NOT "+activeCondition, args...)
}
return query
}
func (s *DeviceDataSource) resolveCardGroupCount(ctx context.Context, params ExportParams) (int, error) {
var count int64
query := s.applyFilters(s.baseQuery(ctx), params).
Select("COALESCE(MAX(bound_cards.card_count), 0)").
Joins(`
LEFT JOIN LATERAL (
SELECT COUNT(*) AS card_count
FROM tb_device_sim_binding AS b
WHERE b.device_id = d.id
AND b.bind_status = ?
AND b.deleted_at IS NULL
) AS bound_cards ON TRUE
`, constants.BindStatusBound)
if err := query.Scan(&count).Error; err != nil {
return 0, err
}
if count < deviceExportMinCardGroups {
return deviceExportMinCardGroups, nil
}
return int(count), nil
}
func (s *DeviceDataSource) fetchDevices(ctx context.Context, params ExportParams, offset, limit int) ([]deviceExportRow, error) {
var devices []deviceExportRow
query := s.applyFilters(s.baseQuery(ctx), params).
Joins("LEFT JOIN tb_shop AS sh ON sh.id = d.shop_id").
Joins("LEFT JOIN tb_package_series AS ps ON ps.id = d.series_id AND ps.deleted_at IS NULL").
Joins(`
LEFT JOIN tb_asset_wallet AS aw
ON aw.resource_type = ?
AND aw.resource_id = d.id
AND aw.deleted_at IS NULL
`, constants.AssetWalletResourceTypeDevice).
Select(`
d.id,
d.virtual_no,
d.imei,
d.device_name,
d.device_model,
COALESCE(sh.shop_name, '') AS shop_name,
COALESCE(ps.series_name, '') AS series_name,
COALESCE(aw.balance, 0) AS wallet_balance
`).
Order("d.id ASC").
Limit(limit).
Offset(offset)
if err := query.Scan(&devices).Error; err != nil {
return nil, err
}
return devices, nil
}
func (s *DeviceDataSource) fetchBoundCards(ctx context.Context, deviceIDs []uint) (map[uint][]deviceExportCardRow, error) {
result := make(map[uint][]deviceExportCardRow, len(deviceIDs))
if len(deviceIDs) == 0 {
return result, nil
}
var cards []deviceExportCardRow
if err := s.db.WithContext(ctx).
Table("tb_device_sim_binding AS b").
Select(`
b.device_id,
b.slot_position,
c.iccid,
c.msisdn,
c.carrier_name,
c.current_month_usage_mb,
c.network_status
`).
Joins("JOIN tb_iot_card AS c ON c.id = b.iot_card_id AND c.deleted_at IS NULL").
Where("b.device_id IN ?", deviceIDs).
Where("b.bind_status = ?", constants.BindStatusBound).
Where("b.deleted_at IS NULL").
Order("b.device_id ASC, b.slot_position ASC, b.id ASC").
Scan(&cards).Error; err != nil {
return nil, err
}
for _, card := range cards {
result[card.DeviceID] = append(result[card.DeviceID], card)
}
return result, nil
}
func (s *DeviceDataSource) fetchActivePackages(ctx context.Context, deviceIDs []uint) (map[uint]deviceExportPackageRow, error) {
result := make(map[uint]deviceExportPackageRow, len(deviceIDs))
if len(deviceIDs) == 0 {
return result, nil
}
var packages []deviceExportPackageRow
if err := s.db.WithContext(ctx).
Table("tb_package_usage AS pu").
Select(`
pu.device_id,
COALESCE(NULLIF(pu.package_name, ''), p.package_name, '') AS package_name,
pu.activated_at,
pu.expires_at
`).
Joins("LEFT JOIN tb_package AS p ON p.id = pu.package_id AND p.deleted_at IS NULL").
Where("pu.device_id IN ?", deviceIDs).
Where("pu.status = ?", constants.PackageUsageStatusActive).
Where("pu.master_usage_id IS NULL").
Where("pu.deleted_at IS NULL").
Order("pu.device_id ASC, pu.priority ASC, pu.activated_at ASC, pu.id ASC").
Scan(&packages).Error; err != nil {
return nil, err
}
for _, item := range packages {
if _, exists := result[item.DeviceID]; exists {
continue
}
result[item.DeviceID] = item
}
return result, nil
}
type deviceExportRow struct {
ID uint `gorm:"column:id"`
VirtualNo string `gorm:"column:virtual_no"`
IMEI string `gorm:"column:imei"`
DeviceName string `gorm:"column:device_name"`
DeviceModel string `gorm:"column:device_model"`
ShopName string `gorm:"column:shop_name"`
SeriesName string `gorm:"column:series_name"`
WalletBalance int64 `gorm:"column:wallet_balance"`
}
type deviceExportCardRow struct {
DeviceID uint `gorm:"column:device_id"`
SlotPosition int `gorm:"column:slot_position"`
ICCID string `gorm:"column:iccid"`
MSISDN string `gorm:"column:msisdn"`
CarrierName string `gorm:"column:carrier_name"`
CurrentMonthUsageMB float64 `gorm:"column:current_month_usage_mb"`
NetworkStatus int `gorm:"column:network_status"`
}
type deviceExportPackageRow struct {
DeviceID uint `gorm:"column:device_id"`
PackageName string `gorm:"column:package_name"`
ActivatedAt *time.Time `gorm:"column:activated_at"`
ExpiresAt *time.Time `gorm:"column:expires_at"`
}
func buildDeviceExportHeaders(cardGroups int) []string {
if cardGroups < deviceExportMinCardGroups {
cardGroups = deviceExportMinCardGroups
}
headers := []string{"设备号", "IMEI", "设备名称", "店铺名称", "套餐系列", "设备型号"}
for i := 1; i <= cardGroups; i++ {
headers = append(headers,
fmt.Sprintf("ICCID%d", i),
fmt.Sprintf("接入号%d", i),
fmt.Sprintf("ICCID%d对应的运营商", i),
fmt.Sprintf("ICCID%d使用的流量", i),
fmt.Sprintf("卡状态%d", i),
)
}
headers = append(headers,
"设备合计",
"套餐的生效时间",
"套餐的到期时间",
"当前套餐",
"钱包余额",
)
return headers
}
func buildDeviceExportRow(item deviceExportRow, cards []deviceExportCardRow, pkg deviceExportPackageRow, cardGroups int) []string {
if cardGroups < deviceExportMinCardGroups {
cardGroups = deviceExportMinCardGroups
}
row := []string{
item.VirtualNo,
item.IMEI,
item.DeviceName,
item.ShopName,
item.SeriesName,
item.DeviceModel,
}
totalUsage := 0.0
for i := 0; i < cardGroups; i++ {
if i >= len(cards) {
row = append(row, "", "", "", "", "")
continue
}
card := cards[i]
totalUsage += card.CurrentMonthUsageMB
row = append(row,
card.ICCID,
card.MSISDN,
card.CarrierName,
formatMB(card.CurrentMonthUsageMB),
constants.GetNetworkStatusName(card.NetworkStatus),
)
}
// 如果表头列数被历史任务固定得比当前绑定卡少,仍保证合计覆盖当前查到的全部卡。
for i := cardGroups; i < len(cards); i++ {
totalUsage += cards[i].CurrentMonthUsageMB
}
row = append(row,
formatMB(totalUsage),
formatOptionalTime(pkg.ActivatedAt),
formatOptionalTime(pkg.ExpiresAt),
pkg.PackageName,
formatMoneyYuan(item.WalletBalance),
)
return row
}
func cardGroupCountFromHeaders(headers []string) int {
if len(headers) < deviceExportBaseHeaderCount+deviceExportTailHeaderCount {
return 0
}
cardColumnCount := len(headers) - deviceExportBaseHeaderCount - deviceExportTailHeaderCount
if cardColumnCount <= 0 || cardColumnCount%deviceExportCardGroupSize != 0 {
return 0
}
return cardColumnCount / deviceExportCardGroupSize
}
func formatMB(value float64) string {
return strconv.FormatFloat(value, 'f', 2, 64)
}
func formatMoneyYuan(cents int64) string {
sign := ""
if cents < 0 {
sign = "-"
cents = -cents
}
return fmt.Sprintf("%s%d.%02d", sign, cents/100, cents%100)
}

View File

@@ -0,0 +1,270 @@
package exporter
import (
"fmt"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"github.com/break/junhong_cmp_fiber/pkg/constants"
)
const exportTimeLayout = "2006-01-02 15:04:05"
func applyExportShopScope(query *gorm.DB, params ExportParams, column string) *gorm.DB {
switch params.UserType {
case constants.UserTypeSuperAdmin, constants.UserTypePlatform:
return query
case constants.UserTypeAgent:
if len(params.ScopeShopIDs) == 0 {
return query.Where("1 = 0")
}
return query.Where(column+" IN ?", params.ScopeShopIDs)
default:
return query.Where("1 = 0")
}
}
func filterInt(filters map[string]any, key string) (int, bool) {
value, ok := filters[key]
if !ok || value == nil {
return 0, false
}
switch v := value.(type) {
case int:
return v, true
case int64:
return int(v), true
case uint:
return int(v), true
case uint64:
return int(v), true
case float64:
return int(v), true
case string:
if strings.TrimSpace(v) == "" {
return 0, false
}
parsed, err := strconv.Atoi(v)
if err != nil {
return 0, false
}
return parsed, true
default:
return 0, false
}
}
func filterUint(filters map[string]any, key string) (uint, bool) {
value, ok := filterInt(filters, key)
if !ok || value <= 0 {
return 0, false
}
return uint(value), true
}
func filterString(filters map[string]any, key string) (string, bool) {
value, ok := filters[key]
if !ok || value == nil {
return "", false
}
text := strings.TrimSpace(fmt.Sprint(value))
if text == "" {
return "", false
}
return text, true
}
func filterBool(filters map[string]any, key string) bool {
value, ok := filters[key]
if !ok || value == nil {
return false
}
switch v := value.(type) {
case bool:
return v
case string:
parsed, err := strconv.ParseBool(strings.TrimSpace(v))
return err == nil && parsed
default:
return false
}
}
// filterOptionalBool 解析可选布尔筛选项。
// 返回第二个值标识调用方是否明确传入该筛选项,避免 false 被误判为未传。
func filterOptionalBool(filters map[string]any, key string) (bool, bool) {
value, ok := filters[key]
if !ok || value == nil {
return false, false
}
switch v := value.(type) {
case bool:
return v, true
case string:
if strings.TrimSpace(v) == "" {
return false, false
}
parsed, err := strconv.ParseBool(strings.TrimSpace(v))
if err != nil {
return false, false
}
return parsed, true
case int:
return v != 0, true
case int64:
return v != 0, true
case uint:
return v != 0, true
case uint64:
return v != 0, true
case float64:
return v != 0, true
default:
return false, false
}
}
// filterUintSlice 解析前端 JSON 或查询参数中的 uint 数组筛选项。
// 空数组按未传处理;数组存在但过滤后为空时由调用方决定是否返回空结果。
func filterUintSlice(filters map[string]any, key string) ([]uint, bool) {
value, ok := filters[key]
if !ok || value == nil {
return nil, false
}
switch v := value.(type) {
case []uint:
if len(v) == 0 {
return nil, false
}
return normalizeUintSlice(v), true
case []int:
if len(v) == 0 {
return nil, false
}
result := make([]uint, 0, len(v))
for _, item := range v {
if item > 0 {
result = append(result, uint(item))
}
}
return normalizeUintSlice(result), true
case []float64:
if len(v) == 0 {
return nil, false
}
result := make([]uint, 0, len(v))
for _, item := range v {
if item > 0 {
result = append(result, uint(item))
}
}
return normalizeUintSlice(result), true
case []any:
if len(v) == 0 {
return nil, false
}
result := make([]uint, 0, len(v))
for _, item := range v {
switch typed := item.(type) {
case int:
if typed > 0 {
result = append(result, uint(typed))
}
case int64:
if typed > 0 {
result = append(result, uint(typed))
}
case uint:
if typed > 0 {
result = append(result, typed)
}
case uint64:
if typed > 0 {
result = append(result, uint(typed))
}
case float64:
if typed > 0 {
result = append(result, uint(typed))
}
case string:
if parsed, err := strconv.ParseUint(strings.TrimSpace(typed), 10, 64); err == nil && parsed > 0 {
result = append(result, uint(parsed))
}
}
}
return normalizeUintSlice(result), true
case string:
if strings.TrimSpace(v) == "" {
return nil, false
}
parts := strings.Split(v, ",")
result := make([]uint, 0, len(parts))
for _, part := range parts {
parsed, err := strconv.ParseUint(strings.TrimSpace(part), 10, 64)
if err == nil && parsed > 0 {
result = append(result, uint(parsed))
}
}
return normalizeUintSlice(result), true
default:
return nil, false
}
}
// normalizeUintSlice 去除无效值和重复 ID保留原始顺序。
func normalizeUintSlice(values []uint) []uint {
seen := make(map[uint]struct{}, len(values))
result := make([]uint, 0, len(values))
for _, value := range values {
if value == 0 {
continue
}
if _, ok := seen[value]; ok {
continue
}
seen[value] = struct{}{}
result = append(result, value)
}
return result
}
func filterTime(filters map[string]any, key string) (time.Time, bool) {
text, ok := filterString(filters, key)
if !ok {
return time.Time{}, false
}
layouts := []string{
time.RFC3339,
"2006-01-02 15:04:05",
"2006-01-02",
}
for _, layout := range layouts {
parsed, err := time.ParseInLocation(layout, text, time.Local)
if err == nil {
return parsed, true
}
}
return time.Time{}, false
}
func formatOptionalUint(value *uint) string {
if value == nil {
return ""
}
return strconv.FormatUint(uint64(*value), 10)
}
func formatOptionalTime(value *time.Time) string {
if value == nil {
return ""
}
return value.Format(exportTimeLayout)
}

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