Compare commits
406 Commits
c083596ef6
...
hotfix/wal
| Author | SHA1 | Date | |
|---|---|---|---|
| 07e55d9f38 | |||
| 2885b503b3 | |||
| 062f5a436f | |||
| ba435dd6a6 | |||
| 3f21f7a7d6 | |||
| 5f960daf78 | |||
| 3b7b856e48 | |||
| cf36f1447f | |||
|
|
1f634eb465 | ||
|
|
84ab3bad99 | ||
|
|
6c8594633a | ||
|
|
064961471b | ||
|
|
e37aad9e1d | ||
|
|
7ec84fbc0f | ||
|
|
2f0b24ce88 | ||
|
|
e56649e5be | ||
|
|
1c492fe8db | ||
|
|
e139f5e227 | ||
|
|
016e7bee79 | ||
|
|
c437593a8c | ||
|
|
134021c91e | ||
|
|
5c779cb6e0 | ||
|
|
32c9859b38 | ||
|
|
283e3e3eb3 | ||
|
|
6a70713b30 | ||
|
|
4ccbf13197 | ||
|
|
0ae8148689 | ||
|
|
9a802c04e5 | ||
|
|
4d419a1966 | ||
|
|
fe2041bcf5 | ||
|
|
6ac19d6565 | ||
|
|
cce27975a1 | ||
|
|
d86ac46761 | ||
|
|
420f3d83f9 | ||
|
|
27fba9160c | ||
|
|
5089a71764 | ||
|
|
5f57429fb0 | ||
|
|
46ede81aef | ||
|
|
36e68e6672 | ||
|
|
a6cd550b94 | ||
|
|
c6e65fde9b | ||
|
|
1b47fb8f32 | ||
|
|
c5126c583b | ||
|
|
918b9b4f25 | ||
|
|
944526d9ef | ||
|
|
6c8352491c | ||
|
|
1ee554daec | ||
|
|
34a7cf0f3c | ||
|
|
6a3fffc46c | ||
|
|
872769e69e | ||
|
|
1550ef3cb1 | ||
|
|
58863e8ec5 | ||
|
|
0d96a94e5f | ||
|
|
cf689beceb | ||
|
|
f4d92f99a2 | ||
|
|
e131d6ba77 | ||
|
|
bcbc290bad | ||
| 4c1e4f428a | |||
| 20b5d70af9 | |||
| 226474b434 | |||
| 860f589b9a | |||
| 2adbc87a52 | |||
| 6e564d1d1a | |||
| 3d28e29eaa | |||
| 0948494b1c | |||
| 599289a94e | |||
| b988f99a95 | |||
| 56adc30409 | |||
| 53e95751b1 | |||
| 58bdb2f18e | |||
| a03ba5d396 | |||
| 2768deb0b6 | |||
| e2301d5f3b | |||
| 9f5c5c3680 | |||
| 5e7e68cce7 | |||
| ca939ff617 | |||
| 8de9c59b1c | |||
| 631c5392b6 | |||
| af5a21ed1e | |||
| 480d4c4583 | |||
| ed071918ed | |||
| d597a25c69 | |||
| 81aa3c2b11 | |||
| a5388446d3 | |||
| 8f3a68a673 | |||
| 95fc0b0a1b | |||
| b7369a9c71 | |||
| 05a5694a5f | |||
| 1350a9ed79 | |||
| 93200a9074 | |||
| 02d522564f | |||
| b6d21b81e3 | |||
| 98ff88d5c3 | |||
| a9eaf1d697 | |||
| 97851c2595 | |||
| ebaf112c80 | |||
| 09cf0be86e | |||
| 6a6672d0e4 | |||
| 0b86720534 | |||
| a93acc0784 | |||
| 7c85a8cf29 | |||
| 348cb4e670 | |||
| e77bb0d09b | |||
| 73a3a04204 | |||
| 573e887ca5 | |||
| 29d3d48dbe | |||
| d42fe2b0ca | |||
| eb74dd7849 | |||
| 3a2e3f2571 | |||
| b0bd37ec12 | |||
| 001f8caf35 | |||
| 21b73a750d | |||
| ee7bfb81f0 | |||
| b4a9e0c1c9 | |||
| 948243b13d | |||
| 5425e2ce19 | |||
| 70fb7dadef | |||
| 8a3c45c577 | |||
| 9238eeb56e | |||
| 9674e0d0d9 | |||
| 248ed55f4e | |||
| 66e12e9629 | |||
| 4f4e42f27e | |||
| 6b3b33b2f0 | |||
| 74c7b27327 | |||
| 37b4483183 | |||
| 516a7f5bdf | |||
| fe4c545308 | |||
| bb33232b1b | |||
| c3ae7fcfbc | |||
| 66cec7515a | |||
| cb75b5668b | |||
| 95104100c9 | |||
| 02ccf57aa3 | |||
| 60debb7505 | |||
| 531de3c760 | |||
| a887f91686 | |||
| 7efcea2b54 | |||
| 85bef83752 | |||
| bc344f892c | |||
| 41db5b56af | |||
| 15cd26c81a | |||
| 3f2cf31543 | |||
| ba73913a41 | |||
| a44c88005d | |||
| f177c26016 | |||
| 4bde09837a | |||
| c250a47651 | |||
| a7f2c4480a | |||
| 37e113bbf4 | |||
| 5442dcbeda | |||
| b9fc828567 | |||
| 327c72ca74 | |||
| 32f03cd2c1 | |||
| 8d2e8faa84 | |||
| 1f1f31a7cb | |||
| 52d883d8a0 | |||
| 6b1fbf4a92 | |||
| 10a35625a5 | |||
| a207c51bfb | |||
| 6eb6d381f7 | |||
| e27758cce3 | |||
| 7137ed087f | |||
| a9690a49db | |||
| 0f58886454 | |||
| 9f8173e124 | |||
| 1d930cc82d | |||
| 1b21d4dafa | |||
| e573a82120 | |||
| e049080f6c | |||
| 9942bbc74e | |||
| fc9f819787 | |||
| 33a4d25ca9 | |||
| e9862a67ba | |||
| 868f5a8ef3 | |||
| fd795d8742 | |||
| 2b3a9cb33f | |||
| 4b8784d5e7 | |||
| 2413c2fe14 | |||
| e52671eaed | |||
| 716e9f5971 | |||
| 28ac58ea18 | |||
| 8e61cb1a54 | |||
| 4aab0bcbf2 | |||
| 6caf0f6141 | |||
| 6e15e1b853 | |||
| ad30f5d41b | |||
| b538e45bd9 | |||
| 1033d7666b | |||
| 0d2baabcf1 | |||
| 9d8e3926ff | |||
| c5ed040359 | |||
| 363e0efb34 | |||
| 1363797378 | |||
| 71498883b5 | |||
| 1d75a4c31a | |||
| 89eec1406a | |||
| 9a625f51ef | |||
| 2d6456c4a3 | |||
| 9bd1d60e85 | |||
| 908c5fa1de | |||
| 505b30aa8f | |||
| e0a37f4a11 | |||
| 4c284c422c | |||
| 44e4f03957 | |||
| 0a2961ecd6 | |||
| fa554c3930 | |||
| 1125701329 | |||
| ed1facc1b8 | |||
| 0cba6fd6d5 | |||
| 94f20ce8c7 | |||
| aef20d2ab1 | |||
| 0ec16d4afa | |||
| 7e4c61e6e9 | |||
| 2d0b4e9bc0 | |||
| 520b126ecf | |||
| 5d9be1d7e4 | |||
| 5065d925ad | |||
| 97d6319b64 | |||
| 0a27a78d97 | |||
| fc995187df | |||
| 8bf1282378 | |||
| 854330a4b4 | |||
| e40ad462e1 | |||
| 647d78309d | |||
| c7f486ae09 | |||
| 71559547b6 | |||
| 6292443f69 | |||
| 97c196b7c6 | |||
| cb328f3ec2 | |||
| 9992e82a3c | |||
| e2003eb9e3 | |||
| 71048e31d4 | |||
| 4a1e44f9e1 | |||
| dd408fcdca | |||
| 516bb92a16 | |||
| 023a4309eb | |||
| b972a776d9 | |||
| d26010b29d | |||
| 8706247436 | |||
| 8154a61453 | |||
| d9de704d73 | |||
| 4ea4a1e53f | |||
| 041856dc8c | |||
| 7cdb66cbe4 | |||
| 5410181e77 | |||
| 548ec0cd6b | |||
| 5e9e41db21 | |||
| c35542f911 | |||
| 42c5ec912f | |||
| c0b64c9e30 | |||
| 37706bc7ce | |||
| 7308afe801 | |||
| c1456f3c54 | |||
| 640ea8ec99 | |||
| 8569873690 | |||
| 5196472d7e | |||
| 814a2956b3 | |||
| 4879d9bf07 | |||
| 8d8d426e2e | |||
| 246ae5e123 | |||
| 7e613a0d2b | |||
| 510a5bfb21 | |||
| decb7a10ec | |||
| 5588c7c762 | |||
| eebc7194d8 | |||
| e945a672ed | |||
| 41d3667df2 | |||
| 8f66ca7bcb | |||
| e9ff14df0e | |||
| c807b99a91 | |||
| 677d6239ce | |||
| d0989c66bb | |||
| 67f3286e09 | |||
| a8b52f8ae1 | |||
| 5496cb58aa | |||
| 3cb16804a4 | |||
| 2a7ac3f86e | |||
| ef8ec025bd | |||
| 23e12d8a73 | |||
| 4a18aac6ce | |||
| 51fa027eb8 | |||
| afc6d7fc76 | |||
| cd807e58e3 | |||
| ff7e749bf2 | |||
| 0627ffec42 | |||
| 1d6535b81b | |||
| 0f6fd42aff | |||
| c1cec0aede | |||
| 6dfc5c0301 | |||
| fce35e8a2d | |||
| 81ba84cf05 | |||
| 384a54164b | |||
| a7dfe858c7 | |||
| e9df1e7ded | |||
| cc56e27c01 | |||
| 78d6aded9b | |||
| 14a8ea5a2c | |||
| 38ef73fd7f | |||
| 303bffbe84 | |||
| 80c6f6c756 | |||
| 7e489a19fb | |||
| 434a8b0349 | |||
| 10fcc0b3c9 | |||
| c0c32c62f4 | |||
| a8a91fe04e | |||
| 8980e6d999 | |||
| 322ded0012 | |||
| ecfa417c15 | |||
| 4770e053ee | |||
| 138f08ddd7 | |||
| 950c0bfb8f | |||
| 724967b8b8 | |||
| 1c6f8ed07b | |||
| 58eb047433 | |||
| 045aa4fa3a | |||
| 69ee754c19 | |||
| 9d50424edc | |||
| 302664404b | |||
| 619f029a5a | |||
| 31e147495c | |||
| ad095d73f3 | |||
| 8b5cd50576 | |||
| 8227ab2f20 | |||
| 6457ffa6f9 | |||
| 8d69858413 | |||
| 49e8ebed60 | |||
| bb5a5ec6ef | |||
| cf602ab5be | |||
| d7ade821f4 | |||
| 7f42d198eb | |||
| ebc3e78ba9 | |||
| 00cae60fe1 | |||
| 67e0840695 | |||
| bc2dbcb416 | |||
| dc4f3cb7ba | |||
| 121462c00f | |||
| 5b283285a2 | |||
| 4e65cf95d1 | |||
| 2b408230a6 | |||
| 48ad0844ae | |||
| 2ef8b8a705 | |||
| 33e7f99fdc | |||
| 06ee422e34 | |||
| 30d6743310 | |||
| a97462dd69 | |||
| d56b841443 | |||
| 8296742cce | |||
| 0c82ae2b73 | |||
| f339fb1987 | |||
| 40809d11c5 | |||
| 50dbc52432 | |||
| cebcada950 | |||
| f5dd2ce4ab | |||
| 02b10f87cf | |||
| 5e64c64ce3 | |||
| a2fd1dfd62 | |||
| 623a622298 | |||
| 65e461eff7 | |||
| e81d3a58ad | |||
| da8aedc96b | |||
| 9995dcd0a3 | |||
| f0f21e52f1 | |||
| a8becf55ff | |||
| f9da1a937f | |||
| a202b2da8a | |||
| 3b54850f1b | |||
| 00dba42a63 | |||
| 3450c20236 | |||
| debe8f6edc | |||
| d0ef3bb712 | |||
| 8826d95516 | |||
| 8af5d1a045 | |||
| ff62ea457a | |||
| 71b948f22b | |||
| 129a38f942 | |||
| 15dbf8dc66 | |||
| ba1886c314 | |||
| 8819c94322 | |||
| 825def88a4 | |||
| 42b884aaf8 | |||
| 8a4c6f3cee | |||
| ce6aa62fd6 | |||
| 4ff5dcf55e | |||
| d3eaac0eb0 | |||
| 8ace759200 | |||
| 5432fb0513 | |||
| e1ec024904 | |||
| 754bbcdf3b | |||
| 47aa5f8d25 | |||
| facc766993 | |||
| 8553a46143 | |||
| 4be473c248 | |||
| 8a531237c3 | |||
| 922a3d0b49 | |||
| 60b43bb29e | |||
| bdac4ab7b2 | |||
| 0ebb3d75f3 | |||
| 7dd55630dc | |||
| 9ee2f0b466 | |||
| 244a2742c1 | |||
| 8f62496250 | |||
| 344850fe1d | |||
| 2a92ab64af | |||
| 62eb43a1cf | |||
| 84aca2fd63 |
49
.agents/skills/caveman/SKILL.md
Normal file
49
.agents/skills/caveman/SKILL.md
Normal 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.
|
||||
117
.agents/skills/diagnose/SKILL.md
Normal file
117
.agents/skills/diagnose/SKILL.md
Normal 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 **3–5 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.
|
||||
41
.agents/skills/diagnose/scripts/hitl-loop.template.sh
Normal file
41
.agents/skills/diagnose/scripts/hitl-loop.template.sh
Normal 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"
|
||||
10
.agents/skills/grill-me/SKILL.md
Normal file
10
.agents/skills/grill-me/SKILL.md
Normal file
@@ -0,0 +1,10 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me".
|
||||
---
|
||||
|
||||
Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.
|
||||
|
||||
Ask the questions one at a time.
|
||||
|
||||
If a question can be answered by exploring the codebase, explore the codebase instead.
|
||||
47
.agents/skills/grill-with-docs/ADR-FORMAT.md
Normal file
47
.agents/skills/grill-with-docs/ADR-FORMAT.md
Normal file
@@ -0,0 +1,47 @@
|
||||
# ADR Format
|
||||
|
||||
ADRs live in `docs/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc.
|
||||
|
||||
Create the `docs/adr/` directory lazily — only when the first ADR is needed.
|
||||
|
||||
## Template
|
||||
|
||||
```md
|
||||
# {Short title of the decision}
|
||||
|
||||
{1-3 sentences: what's the context, what did we decide, and why.}
|
||||
```
|
||||
|
||||
That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections.
|
||||
|
||||
## Optional sections
|
||||
|
||||
Only include these when they add genuine value. Most ADRs won't need them.
|
||||
|
||||
- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited
|
||||
- **Considered Options** — only when the rejected alternatives are worth remembering
|
||||
- **Consequences** — only when non-obvious downstream effects need to be called out
|
||||
|
||||
## Numbering
|
||||
|
||||
Scan `docs/adr/` for the highest existing number and increment by one.
|
||||
|
||||
## When to offer an ADR
|
||||
|
||||
All three of these must be true:
|
||||
|
||||
1. **Hard to reverse** — the cost of changing your mind later is meaningful
|
||||
2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?"
|
||||
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
|
||||
|
||||
If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing."
|
||||
|
||||
### What qualifies
|
||||
|
||||
- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres."
|
||||
- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP."
|
||||
- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out.
|
||||
- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s.
|
||||
- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate.
|
||||
- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract."
|
||||
- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months.
|
||||
60
.agents/skills/grill-with-docs/CONTEXT-FORMAT.md
Normal file
60
.agents/skills/grill-with-docs/CONTEXT-FORMAT.md
Normal file
@@ -0,0 +1,60 @@
|
||||
# CONTEXT.md Format
|
||||
|
||||
## Structure
|
||||
|
||||
```md
|
||||
# {Context Name}
|
||||
|
||||
{One or two sentence description of what this context is and why it exists.}
|
||||
|
||||
## Language
|
||||
|
||||
**Order**:
|
||||
{A one or two sentence description of the term}
|
||||
_Avoid_: Purchase, transaction
|
||||
|
||||
**Invoice**:
|
||||
A request for payment sent to a customer after delivery.
|
||||
_Avoid_: Bill, payment request
|
||||
|
||||
**Customer**:
|
||||
A person or organization that places orders.
|
||||
_Avoid_: Client, buyer, account
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others under `_Avoid_`.
|
||||
- **Keep definitions tight.** One or two sentences max. Define what it IS, not what it does.
|
||||
- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs.
|
||||
- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine.
|
||||
|
||||
## Single vs multi-context repos
|
||||
|
||||
**Single context (most repos):** One `CONTEXT.md` at the repo root.
|
||||
|
||||
**Multiple contexts:** A `CONTEXT-MAP.md` at the repo root lists the contexts, where they live, and how they relate to each other:
|
||||
|
||||
```md
|
||||
# Context Map
|
||||
|
||||
## Contexts
|
||||
|
||||
- [Ordering](./src/ordering/CONTEXT.md) — receives and tracks customer orders
|
||||
- [Billing](./src/billing/CONTEXT.md) — generates invoices and processes payments
|
||||
- [Fulfillment](./src/fulfillment/CONTEXT.md) — manages warehouse picking and shipping
|
||||
|
||||
## Relationships
|
||||
|
||||
- **Ordering → Fulfillment**: Ordering emits `OrderPlaced` events; Fulfillment consumes them to start picking
|
||||
- **Fulfillment → Billing**: Fulfillment emits `ShipmentDispatched` events; Billing consumes them to generate invoices
|
||||
- **Ordering ↔ Billing**: Shared types for `CustomerId` and `Money`
|
||||
```
|
||||
|
||||
The skill infers which structure applies:
|
||||
|
||||
- If `CONTEXT-MAP.md` exists, read it to find contexts
|
||||
- If only a root `CONTEXT.md` exists, single context
|
||||
- If neither exists, create a root `CONTEXT.md` lazily when the first term is resolved
|
||||
|
||||
When multiple contexts exist, infer which one the current topic relates to. If unclear, ask.
|
||||
88
.agents/skills/grill-with-docs/SKILL.md
Normal file
88
.agents/skills/grill-with-docs/SKILL.md
Normal file
@@ -0,0 +1,88 @@
|
||||
---
|
||||
name: grill-with-docs
|
||||
description: Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise. Use when user wants to stress-test a plan against their project's language and documented decisions.
|
||||
---
|
||||
|
||||
<what-to-do>
|
||||
|
||||
Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.
|
||||
|
||||
Ask the questions one at a time, waiting for feedback on each question before continuing.
|
||||
|
||||
If a question can be answered by exploring the codebase, explore the codebase instead.
|
||||
|
||||
</what-to-do>
|
||||
|
||||
<supporting-info>
|
||||
|
||||
## Domain awareness
|
||||
|
||||
During codebase exploration, also look for existing documentation:
|
||||
|
||||
### File structure
|
||||
|
||||
Most repos have a single context:
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT.md
|
||||
├── docs/
|
||||
│ └── adr/
|
||||
│ ├── 0001-event-sourced-orders.md
|
||||
│ └── 0002-postgres-for-write-model.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives:
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT-MAP.md
|
||||
├── docs/
|
||||
│ └── adr/ ← system-wide decisions
|
||||
├── src/
|
||||
│ ├── ordering/
|
||||
│ │ ├── CONTEXT.md
|
||||
│ │ └── docs/adr/ ← context-specific decisions
|
||||
│ └── billing/
|
||||
│ ├── CONTEXT.md
|
||||
│ └── docs/adr/
|
||||
```
|
||||
|
||||
Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed.
|
||||
|
||||
## During the session
|
||||
|
||||
### Challenge against the glossary
|
||||
|
||||
When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?"
|
||||
|
||||
### Sharpen fuzzy language
|
||||
|
||||
When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things."
|
||||
|
||||
### Discuss concrete scenarios
|
||||
|
||||
When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts.
|
||||
|
||||
### Cross-reference with code
|
||||
|
||||
When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?"
|
||||
|
||||
### Update CONTEXT.md inline
|
||||
|
||||
When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md).
|
||||
|
||||
`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else.
|
||||
|
||||
### Offer ADRs sparingly
|
||||
|
||||
Only offer to create an ADR when all three are true:
|
||||
|
||||
1. **Hard to reverse** — the cost of changing your mind later is meaningful
|
||||
2. **Surprising without context** — a future reader will wonder "why did they do it this way?"
|
||||
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
|
||||
|
||||
If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md).
|
||||
|
||||
</supporting-info>
|
||||
15
.agents/skills/handoff/SKILL.md
Normal file
15
.agents/skills/handoff/SKILL.md
Normal file
@@ -0,0 +1,15 @@
|
||||
---
|
||||
name: handoff
|
||||
description: Compact the current conversation into a handoff document for another agent to pick up.
|
||||
argument-hint: "What will the next session be used for?"
|
||||
---
|
||||
|
||||
Write a handoff document summarising the current conversation so a fresh agent can continue the work. Save to the temporary directory of the user's OS - not the current workspace.
|
||||
|
||||
Include a "suggested skills" section in the document, which suggests skills that the agent should invoke.
|
||||
|
||||
Do not duplicate content already captured in other artifacts (PRDs, plans, ADRs, issues, commits, diffs). Reference them by path or URL instead.
|
||||
|
||||
Redact any sensitive information, such as API keys, passwords, or personally identifiable information.
|
||||
|
||||
If the user passed arguments, treat them as a description of what the next session will focus on and tailor the doc accordingly.
|
||||
37
.agents/skills/improve-codebase-architecture/DEEPENING.md
Normal file
37
.agents/skills/improve-codebase-architecture/DEEPENING.md
Normal file
@@ -0,0 +1,37 @@
|
||||
# Deepening
|
||||
|
||||
How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**.
|
||||
|
||||
## Dependency categories
|
||||
|
||||
When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam.
|
||||
|
||||
### 1. In-process
|
||||
|
||||
Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed.
|
||||
|
||||
### 2. Local-substitutable
|
||||
|
||||
Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface.
|
||||
|
||||
### 3. Remote but owned (Ports & Adapters)
|
||||
|
||||
Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter.
|
||||
|
||||
Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."*
|
||||
|
||||
### 4. True external (Mock)
|
||||
|
||||
Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter.
|
||||
|
||||
## Seam discipline
|
||||
|
||||
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection.
|
||||
- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them.
|
||||
|
||||
## Testing strategy: replace, don't layer
|
||||
|
||||
- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them.
|
||||
- Write new tests at the deepened module's interface. The **interface is the test surface**.
|
||||
- Tests assert on observable outcomes through the interface, not internal state.
|
||||
- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface.
|
||||
123
.agents/skills/improve-codebase-architecture/HTML-REPORT.md
Normal file
123
.agents/skills/improve-codebase-architecture/HTML-REPORT.md
Normal file
@@ -0,0 +1,123 @@
|
||||
# HTML Report Format
|
||||
|
||||
The architectural review is rendered as a single self-contained HTML file in the OS temp directory. Tailwind and Mermaid both come from CDNs. Mermaid handles graph-shaped diagrams reliably; hand-built divs and inline SVG handle the more editorial visuals (mass diagrams, cross-sections). Mix the two — don't lean on Mermaid for everything, it'll start to look generic.
|
||||
|
||||
## Scaffold
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Architecture review — {{repo name}}</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script type="module">
|
||||
import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
|
||||
mermaid.initialize({ startOnLoad: true, theme: "neutral", securityLevel: "loose" });
|
||||
</script>
|
||||
<style>
|
||||
/* small custom layer for things Tailwind doesn't cover cleanly:
|
||||
dashed seam lines, hand-drawn-feeling arrow heads, etc. */
|
||||
.seam { stroke-dasharray: 4 4; }
|
||||
.leak { stroke: #dc2626; }
|
||||
.deep { background: linear-gradient(135deg, #0f172a, #1e293b); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="bg-stone-50 text-slate-900 font-sans">
|
||||
<main class="max-w-5xl mx-auto px-6 py-12 space-y-12">
|
||||
<header>...</header>
|
||||
<section id="candidates" class="space-y-10">...</section>
|
||||
<section id="top-recommendation">...</section>
|
||||
</main>
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
## Header
|
||||
|
||||
Repo name, date, and a compact legend: solid box = module, dashed line = seam, red arrow = leakage, thick dark box = deep module. No introduction paragraph — straight into the candidates.
|
||||
|
||||
## Candidate card
|
||||
|
||||
The diagrams carry the weight. Prose is sparse, plain, and uses the glossary terms ([LANGUAGE.md](LANGUAGE.md)) without ceremony.
|
||||
|
||||
Each candidate is one `<article>`:
|
||||
|
||||
- **Title** — short, names the deepening (e.g. "Collapse the Order intake pipeline").
|
||||
- **Badge row** — recommendation strength (`Strong` = emerald, `Worth exploring` = amber, `Speculative` = slate), plus a tag for the dependency category (`in-process`, `local-substitutable`, `ports & adapters`, `mock`).
|
||||
- **Files** — monospaced list, `font-mono text-sm`.
|
||||
- **Before / After diagram** — the centrepiece. Two columns, side by side. See patterns below.
|
||||
- **Problem** — one sentence. What hurts.
|
||||
- **Solution** — one sentence. What changes.
|
||||
- **Wins** — bullets, ≤6 words each. e.g. "Tests hit one interface", "Pricing logic stops leaking", "Delete 4 shallow wrappers".
|
||||
- **ADR callout** (if applicable) — one line in an amber-tinted box.
|
||||
|
||||
No paragraphs of explanation. If the diagram needs a paragraph to be understood, redraw the diagram.
|
||||
|
||||
## Diagram patterns
|
||||
|
||||
Pick the pattern that fits the candidate. Mix them. Don't make every diagram look the same — variety is part of the point.
|
||||
|
||||
### Mermaid graph (the workhorse for dependencies / call flow)
|
||||
|
||||
Use a Mermaid `flowchart` or `graph` when the point is "X calls Y calls Z, and look at the mess." Wrap it in a Tailwind-styled card so it doesn't feel parachuted in. Style with classDef to colour leakage edges red and the deep module dark. Sequence diagrams work well for "before: 6 round-trips; after: 1."
|
||||
|
||||
```html
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<pre class="mermaid">
|
||||
flowchart LR
|
||||
A[OrderHandler] --> B[OrderValidator]
|
||||
B --> C[OrderRepo]
|
||||
C -.leak.-> D[PricingClient]
|
||||
classDef leak stroke:#dc2626,stroke-width:2px;
|
||||
class C,D leak
|
||||
</pre>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Hand-built boxes-and-arrows (when Mermaid's layout fights you)
|
||||
|
||||
Modules as `<div>`s with borders and labels. Arrows as inline SVG `<line>` or `<path>` elements positioned absolutely over a relative container. Reach for this when you want the "after" diagram to feel like one thick-bordered deep module with greyed-out internals — Mermaid won't render that with the right weight.
|
||||
|
||||
### Cross-section (good for layered shallowness)
|
||||
|
||||
Stack horizontal bands (`h-12 border-l-4`) to show layers a call passes through. Before: 6 thin layers each doing nothing. After: 1 thick band labelled with the consolidated responsibility.
|
||||
|
||||
### Mass diagram (good for "interface as wide as implementation")
|
||||
|
||||
Two rectangles per module — one for interface surface area, one for implementation. Before: interface rectangle is nearly as tall as the implementation rectangle (shallow). After: interface rectangle is short, implementation rectangle is tall (deep).
|
||||
|
||||
### Call-graph collapse
|
||||
|
||||
Before: a tree of function calls rendered as nested boxes. After: the same tree collapsed into one box, with the now-internal calls shown faded inside it.
|
||||
|
||||
## Style guidance
|
||||
|
||||
- Lean editorial, not corporate-dashboard. Generous whitespace. Serif optional for headings (`font-serif` works well with stone/slate).
|
||||
- Colour sparingly: one accent (emerald or indigo) plus red for leakage and amber for warnings.
|
||||
- Keep diagrams ~320px tall so before/after sits comfortably side by side without scrolling.
|
||||
- Use `text-xs uppercase tracking-wider` for module labels inside diagrams — they should read as schematic, not as UI.
|
||||
- The only scripts are the Tailwind CDN and the Mermaid ESM import. The report is otherwise static — no app code, no interactivity beyond Mermaid's own rendering.
|
||||
|
||||
## Top recommendation section
|
||||
|
||||
One larger card. Candidate name, one sentence on why, anchor link to its card. That's it.
|
||||
|
||||
## Tone
|
||||
|
||||
Plain English, concise — but the architectural nouns and verbs come straight from [LANGUAGE.md](LANGUAGE.md). Concision is not an excuse to drift.
|
||||
|
||||
**Use exactly:** module, interface, implementation, depth, deep, shallow, seam, adapter, leverage, locality.
|
||||
|
||||
**Never substitute:** component, service, unit (for module) · API, signature (for interface) · boundary (for seam) · layer, wrapper (for module, when you mean module).
|
||||
|
||||
**Phrasings that fit the style:**
|
||||
|
||||
- "Order intake module is shallow — interface nearly matches the implementation."
|
||||
- "Pricing leaks across the seam."
|
||||
- "Deepen: one interface, one place to test."
|
||||
- "Two adapters justify the seam: HTTP in prod, in-memory in tests."
|
||||
|
||||
**Wins bullets** name the gain in glossary terms: *"locality: bugs concentrate in one module"*, *"leverage: one interface, N call sites"*, *"interface shrinks; implementation absorbs the wrappers"*. Don't write *"easier to maintain"* or *"cleaner code"* — those terms aren't in the glossary and don't earn their place.
|
||||
|
||||
No hedging, no throat-clearing, no "it's worth noting that…". If a sentence could be a bullet, make it a bullet. If a bullet could be cut, cut it. If a term isn't in [LANGUAGE.md](LANGUAGE.md), reach for one that is before inventing a new one.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Interface Design
|
||||
|
||||
When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best.
|
||||
|
||||
Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Frame the problem space
|
||||
|
||||
Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:
|
||||
|
||||
- The constraints any new interface would need to satisfy
|
||||
- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md))
|
||||
- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete
|
||||
|
||||
Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel.
|
||||
|
||||
### 2. Spawn sub-agents
|
||||
|
||||
Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module.
|
||||
|
||||
Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint:
|
||||
|
||||
- Agent 1: "Minimize the interface — aim for 1–3 entry points max. Maximise leverage per entry point."
|
||||
- Agent 2: "Maximise flexibility — support many use cases and extension."
|
||||
- Agent 3: "Optimise for the most common caller — make the default case trivial."
|
||||
- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies."
|
||||
|
||||
Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language.
|
||||
|
||||
Each sub-agent outputs:
|
||||
|
||||
1. Interface (types, methods, params — plus invariants, ordering, error modes)
|
||||
2. Usage example showing how callers use it
|
||||
3. What the implementation hides behind the seam
|
||||
4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md))
|
||||
5. Trade-offs — where leverage is high, where it's thin
|
||||
|
||||
### 3. Present and compare
|
||||
|
||||
Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**.
|
||||
|
||||
After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu.
|
||||
53
.agents/skills/improve-codebase-architecture/LANGUAGE.md
Normal file
53
.agents/skills/improve-codebase-architecture/LANGUAGE.md
Normal file
@@ -0,0 +1,53 @@
|
||||
# Language
|
||||
|
||||
Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point.
|
||||
|
||||
## Terms
|
||||
|
||||
**Module**
|
||||
Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice.
|
||||
_Avoid_: unit, component, service.
|
||||
|
||||
**Interface**
|
||||
Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics.
|
||||
_Avoid_: API, signature (too narrow — those refer only to the type-level surface).
|
||||
|
||||
**Implementation**
|
||||
What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise.
|
||||
|
||||
**Depth**
|
||||
Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation.
|
||||
|
||||
**Seam** _(from Michael Feathers)_
|
||||
A place where you can alter behaviour without editing in that place. The *location* at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it.
|
||||
_Avoid_: boundary (overloaded with DDD's bounded context).
|
||||
|
||||
**Adapter**
|
||||
A concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside).
|
||||
|
||||
**Leverage**
|
||||
What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests.
|
||||
|
||||
**Locality**
|
||||
What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere.
|
||||
|
||||
## Principles
|
||||
|
||||
- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface.
|
||||
- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep.
|
||||
- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape.
|
||||
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **Module** has exactly one **Interface** (the surface it presents to callers and tests).
|
||||
- **Depth** is a property of a **Module**, measured against its **Interface**.
|
||||
- A **Seam** is where a **Module**'s **Interface** lives.
|
||||
- An **Adapter** sits at a **Seam** and satisfies the **Interface**.
|
||||
- **Depth** produces **Leverage** for callers and **Locality** for maintainers.
|
||||
|
||||
## Rejected framings
|
||||
|
||||
- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead.
|
||||
- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know.
|
||||
- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**.
|
||||
81
.agents/skills/improve-codebase-architecture/SKILL.md
Normal file
81
.agents/skills/improve-codebase-architecture/SKILL.md
Normal file
@@ -0,0 +1,81 @@
|
||||
---
|
||||
name: improve-codebase-architecture
|
||||
description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable.
|
||||
---
|
||||
|
||||
# Improve Codebase Architecture
|
||||
|
||||
Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability.
|
||||
|
||||
## Glossary
|
||||
|
||||
Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md).
|
||||
|
||||
- **Module** — anything with an interface and an implementation (function, class, package, slice).
|
||||
- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature.
|
||||
- **Implementation** — the code inside.
|
||||
- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation.
|
||||
- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.")
|
||||
- **Adapter** — a concrete thing satisfying an interface at a seam.
|
||||
- **Leverage** — what callers get from depth.
|
||||
- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place.
|
||||
|
||||
Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list):
|
||||
|
||||
- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep.
|
||||
- **The interface is the test surface.**
|
||||
- **One adapter = hypothetical seam. Two adapters = real seam.**
|
||||
|
||||
This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Explore
|
||||
|
||||
Read the project's domain glossary and any ADRs in the area you're touching first.
|
||||
|
||||
Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction:
|
||||
|
||||
- Where does understanding one concept require bouncing between many small modules?
|
||||
- Where are modules **shallow** — interface nearly as complex as the implementation?
|
||||
- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)?
|
||||
- Where do tightly-coupled modules leak across their seams?
|
||||
- Which parts of the codebase are untested, or hard to test through their current interface?
|
||||
|
||||
Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want.
|
||||
|
||||
### 2. Present candidates as an HTML report
|
||||
|
||||
Write a self-contained HTML file to the OS temp directory so nothing lands in the repo. Resolve the temp dir from `$TMPDIR`, falling back to `/tmp` (or `%TEMP%` on Windows), and write to `<tmpdir>/architecture-review-<timestamp>.html` so each run gets a fresh file. Open it for the user — `xdg-open <path>` on Linux, `open <path>` on macOS, `start <path>` on Windows — and tell them the absolute path.
|
||||
|
||||
The report uses **Tailwind via CDN** for layout and styling, and **Mermaid via CDN** for diagrams where a graph/flow/sequence reliably communicates the structure. Mix Mermaid with hand-crafted CSS/SVG visuals — use Mermaid when relationships are graph-shaped (call graphs, dependencies, sequences), and hand-built divs/SVG when you want something more editorial (mass diagrams, cross-sections, collapse animations). Each candidate gets a **before/after visualisation**. Be visual.
|
||||
|
||||
For each candidate, the same template as before, but rendered as a card:
|
||||
|
||||
- **Files** — which files/modules are involved
|
||||
- **Problem** — why the current architecture is causing friction
|
||||
- **Solution** — plain English description of what would change
|
||||
- **Benefits** — explained in terms of locality and leverage, and how tests would improve
|
||||
- **Before / After diagram** — side-by-side, custom-drawn, illustrating the shallowness and the deepening
|
||||
- **Recommendation strength** — one of `Strong`, `Worth exploring`, `Speculative`, rendered as a badge
|
||||
|
||||
End the report with a **Top recommendation** section: which candidate you'd tackle first and why.
|
||||
|
||||
**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service."
|
||||
|
||||
**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly in the card (e.g. a warning callout: _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids.
|
||||
|
||||
See [HTML-REPORT.md](HTML-REPORT.md) for the full HTML scaffold, diagram patterns, and styling guidance.
|
||||
|
||||
Do NOT propose interfaces yet. After the file is written, ask the user: "Which of these would you like to explore?"
|
||||
|
||||
### 3. Grilling loop
|
||||
|
||||
Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive.
|
||||
|
||||
Side effects happen inline as decisions crystallize:
|
||||
|
||||
- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist.
|
||||
- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there.
|
||||
- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md).
|
||||
- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md).
|
||||
79
.agents/skills/prototype/LOGIC.md
Normal file
79
.agents/skills/prototype/LOGIC.md
Normal 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.
|
||||
30
.agents/skills/prototype/SKILL.md
Normal file
30
.agents/skills/prototype/SKILL.md
Normal file
@@ -0,0 +1,30 @@
|
||||
---
|
||||
name: prototype
|
||||
description: Build a throwaway prototype to flesh out a design before committing to it. Routes between two branches — a runnable terminal app for state/business-logic questions, or several radically different UI variations toggleable from one route. Use when the user wants to prototype, sanity-check a data model or state machine, mock up a UI, explore design options, or says "prototype this", "let me play with it", "try a few designs".
|
||||
---
|
||||
|
||||
# Prototype
|
||||
|
||||
A prototype is **throwaway code that answers a question**. The question decides the shape.
|
||||
|
||||
## Pick a branch
|
||||
|
||||
Identify which question is being answered — from the user's prompt, the surrounding code, or by asking if the user is around:
|
||||
|
||||
- **"Does this logic / state model feel right?"** → [LOGIC.md](LOGIC.md). Build a tiny interactive terminal app that pushes the state machine through cases that are hard to reason about on paper.
|
||||
- **"What should this look like?"** → [UI.md](UI.md). Generate several radically different UI variations on a single route, switchable via a URL search param and a floating bottom bar.
|
||||
|
||||
The two branches produce very different artifacts — getting this wrong wastes the whole prototype. If the question is genuinely ambiguous and the user isn't reachable, default to whichever branch better matches the surrounding code (a backend module → logic; a page or component → UI) and state the assumption at the top of the prototype.
|
||||
|
||||
## Rules that apply to both
|
||||
|
||||
1. **Throwaway from day one, and clearly marked as such.** Locate the prototype code close to where it will actually be used (next to the module or page it's prototyping for) so context is obvious — but name it so a casual reader can see it's a prototype, not production. For throwaway UI routes, obey whatever routing convention the project already uses; don't invent a new top-level structure.
|
||||
2. **One command to run.** Whatever the project's existing task runner supports — `pnpm <name>`, `python <path>`, `bun <path>`, etc. The user must be able to start it without thinking.
|
||||
3. **No persistence by default.** State lives in memory. Persistence is the thing the prototype is _checking_, not something it should depend on. If the question explicitly involves a database, hit a scratch DB or a local file with a clear "PROTOTYPE — wipe me" name.
|
||||
4. **Skip the polish.** No tests, no error handling beyond what makes the prototype _runnable_, no abstractions. The point is to learn something fast and then delete it.
|
||||
5. **Surface the state.** After every action (logic) or on every variant switch (UI), print or render the full relevant state so the user can see what changed.
|
||||
6. **Delete or absorb when done.** When the prototype has answered its question, either delete it or fold the validated decision into the real code — don't leave it rotting in the repo.
|
||||
|
||||
## When done
|
||||
|
||||
The _answer_ is the only thing worth keeping from a prototype. Capture it somewhere durable (commit message, ADR, issue, or a `NOTES.md` next to the prototype) along with the question it was answering. If the user is around, that capture is a quick conversation; if not, leave the placeholder so they (or you, on the next pass) can fill in the verdict before deleting the prototype.
|
||||
112
.agents/skills/prototype/UI.md
Normal file
112
.agents/skills/prototype/UI.md
Normal 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.
|
||||
121
.agents/skills/setup-matt-pocock-skills/SKILL.md
Normal file
121
.agents/skills/setup-matt-pocock-skills/SKILL.md
Normal file
@@ -0,0 +1,121 @@
|
||||
---
|
||||
name: setup-matt-pocock-skills
|
||||
description: Sets up an `## Agent skills` block in AGENTS.md/CLAUDE.md and `docs/agents/` so the engineering skills know this repo's issue tracker (GitHub or local markdown), triage label vocabulary, and domain doc layout. Run before first use of `to-issues`, `to-prd`, `triage`, `diagnose`, `tdd`, `improve-codebase-architecture`, or `zoom-out` — or if those skills appear to be missing context about the issue tracker, triage labels, or domain docs.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Setup Matt Pocock's Skills
|
||||
|
||||
Scaffold the per-repo configuration that the engineering skills assume:
|
||||
|
||||
- **Issue tracker** — where issues live (GitHub by default; local markdown is also supported out of the box)
|
||||
- **Triage labels** — the strings used for the five canonical triage roles
|
||||
- **Domain docs** — where `CONTEXT.md` and ADRs live, and the consumer rules for reading them
|
||||
|
||||
This is a prompt-driven skill, not a deterministic script. Explore, present what you found, confirm with the user, then write.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Explore
|
||||
|
||||
Look at the current repo to understand its starting state. Read whatever exists; don't assume:
|
||||
|
||||
- `git remote -v` and `.git/config` — is this a GitHub repo? Which one?
|
||||
- `AGENTS.md` and `CLAUDE.md` at the repo root — does either exist? Is there already an `## Agent skills` section in either?
|
||||
- `CONTEXT.md` and `CONTEXT-MAP.md` at the repo root
|
||||
- `docs/adr/` and any `src/*/docs/adr/` directories
|
||||
- `docs/agents/` — does this skill's prior output already exist?
|
||||
- `.scratch/` — sign that a local-markdown issue tracker convention is already in use
|
||||
|
||||
### 2. Present findings and ask
|
||||
|
||||
Summarise what's present and what's missing. Then walk the user through the three decisions **one at a time** — present a section, get the user's answer, then move to the next. Don't dump all three at once.
|
||||
|
||||
Assume the user does not know what these terms mean. Each section starts with a short explainer (what it is, why these skills need it, what changes if they pick differently). Then show the choices and the default.
|
||||
|
||||
**Section A — Issue tracker.**
|
||||
|
||||
> Explainer: The "issue tracker" is where issues live for this repo. Skills like `to-issues`, `triage`, `to-prd`, and `qa` read from and write to it — they need to know whether to call `gh issue create`, write a markdown file under `.scratch/`, or follow some other workflow you describe. Pick the place you actually track work for this repo.
|
||||
|
||||
Default posture: these skills were designed for GitHub. If a `git remote` points at GitHub, propose that. If a `git remote` points at GitLab (`gitlab.com` or a self-hosted host), propose GitLab. Otherwise (or if the user prefers), offer:
|
||||
|
||||
- **GitHub** — issues live in the repo's GitHub Issues (uses the `gh` CLI)
|
||||
- **GitLab** — issues live in the repo's GitLab Issues (uses the [`glab`](https://gitlab.com/gitlab-org/cli) CLI)
|
||||
- **Local markdown** — issues live as files under `.scratch/<feature>/` in this repo (good for solo projects or repos without a remote)
|
||||
- **Other** (Jira, Linear, etc.) — ask the user to describe the workflow in one paragraph; the skill will record it as freeform prose
|
||||
|
||||
**Section B — Triage label vocabulary.**
|
||||
|
||||
> Explainer: When the `triage` skill processes an incoming issue, it moves it through a state machine — needs evaluation, waiting on reporter, ready for an AFK agent to pick up, ready for a human, or won't fix. To do that, it needs to apply labels (or the equivalent in your issue tracker) that match strings *you've actually configured*. If your repo already uses different label names (e.g. `bug:triage` instead of `needs-triage`), map them here so the skill applies the right ones instead of creating duplicates.
|
||||
|
||||
The five canonical roles:
|
||||
|
||||
- `needs-triage` — maintainer needs to evaluate
|
||||
- `needs-info` — waiting on reporter
|
||||
- `ready-for-agent` — fully specified, AFK-ready (an agent can pick it up with no human context)
|
||||
- `ready-for-human` — needs human implementation
|
||||
- `wontfix` — will not be actioned
|
||||
|
||||
Default: each role's string equals its name. Ask the user if they want to override any. If their issue tracker has no existing labels, the defaults are fine.
|
||||
|
||||
**Section C — Domain docs.**
|
||||
|
||||
> Explainer: Some skills (`improve-codebase-architecture`, `diagnose`, `tdd`) read a `CONTEXT.md` file to learn the project's domain language, and `docs/adr/` for past architectural decisions. They need to know whether the repo has one global context or multiple (e.g. a monorepo with separate frontend/backend contexts) so they look in the right place.
|
||||
|
||||
Confirm the layout:
|
||||
|
||||
- **Single-context** — one `CONTEXT.md` + `docs/adr/` at the repo root. Most repos are this.
|
||||
- **Multi-context** — `CONTEXT-MAP.md` at the root pointing to per-context `CONTEXT.md` files (typically a monorepo).
|
||||
|
||||
### 3. Confirm and edit
|
||||
|
||||
Show the user a draft of:
|
||||
|
||||
- The `## Agent skills` block to add to whichever of `CLAUDE.md` / `AGENTS.md` is being edited (see step 4 for selection rules)
|
||||
- The contents of `docs/agents/issue-tracker.md`, `docs/agents/triage-labels.md`, `docs/agents/domain.md`
|
||||
|
||||
Let them edit before writing.
|
||||
|
||||
### 4. Write
|
||||
|
||||
**Pick the file to edit:**
|
||||
|
||||
- If `CLAUDE.md` exists, edit it.
|
||||
- Else if `AGENTS.md` exists, edit it.
|
||||
- If neither exists, ask the user which one to create — don't pick for them.
|
||||
|
||||
Never create `AGENTS.md` when `CLAUDE.md` already exists (or vice versa) — always edit the one that's already there.
|
||||
|
||||
If an `## Agent skills` block already exists in the chosen file, update its contents in-place rather than appending a duplicate. Don't overwrite user edits to the surrounding sections.
|
||||
|
||||
The block:
|
||||
|
||||
```markdown
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
[one-line summary of where issues are tracked]. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
[one-line summary of the label vocabulary]. See `docs/agents/triage-labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
[one-line summary of layout — "single-context" or "multi-context"]. See `docs/agents/domain.md`.
|
||||
```
|
||||
|
||||
Then write the three docs files using the seed templates in this skill folder as a starting point:
|
||||
|
||||
- [issue-tracker-github.md](./issue-tracker-github.md) — GitHub issue tracker
|
||||
- [issue-tracker-gitlab.md](./issue-tracker-gitlab.md) — GitLab issue tracker
|
||||
- [issue-tracker-local.md](./issue-tracker-local.md) — local-markdown issue tracker
|
||||
- [triage-labels.md](./triage-labels.md) — label mapping
|
||||
- [domain.md](./domain.md) — domain doc consumer rules + layout
|
||||
|
||||
For "other" issue trackers, write `docs/agents/issue-tracker.md` from scratch using the user's description.
|
||||
|
||||
### 5. Done
|
||||
|
||||
Tell the user the setup is complete and which engineering skills will now read from these files. Mention they can edit `docs/agents/*.md` directly later — re-running this skill is only necessary if they want to switch issue trackers or restart from scratch.
|
||||
51
.agents/skills/setup-matt-pocock-skills/domain.md
Normal file
51
.agents/skills/setup-matt-pocock-skills/domain.md
Normal file
@@ -0,0 +1,51 @@
|
||||
# Domain Docs
|
||||
|
||||
How the engineering skills should consume this repo's domain documentation when exploring the codebase.
|
||||
|
||||
## Before exploring, read these
|
||||
|
||||
- **`CONTEXT.md`** at the repo root, or
|
||||
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
|
||||
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
|
||||
|
||||
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.
|
||||
|
||||
## File structure
|
||||
|
||||
Single-context repo (most repos):
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT.md
|
||||
├── docs/adr/
|
||||
│ ├── 0001-event-sourced-orders.md
|
||||
│ └── 0002-postgres-for-write-model.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT-MAP.md
|
||||
├── docs/adr/ ← system-wide decisions
|
||||
└── src/
|
||||
├── ordering/
|
||||
│ ├── CONTEXT.md
|
||||
│ └── docs/adr/ ← context-specific decisions
|
||||
└── billing/
|
||||
├── CONTEXT.md
|
||||
└── docs/adr/
|
||||
```
|
||||
|
||||
## Use the glossary's vocabulary
|
||||
|
||||
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
||||
|
||||
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
|
||||
|
||||
## Flag ADR conflicts
|
||||
|
||||
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
||||
|
||||
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|
||||
@@ -0,0 +1,22 @@
|
||||
# Issue tracker: GitHub
|
||||
|
||||
Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
|
||||
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
|
||||
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
|
||||
- **Comment on an issue**: `gh issue comment <number> --body "..."`
|
||||
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
|
||||
- **Close**: `gh issue close <number> --comment "..."`
|
||||
|
||||
Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone.
|
||||
|
||||
## When a skill says "publish to the issue tracker"
|
||||
|
||||
Create a GitHub issue.
|
||||
|
||||
## When a skill says "fetch the relevant ticket"
|
||||
|
||||
Run `gh issue view <number> --comments`.
|
||||
@@ -0,0 +1,23 @@
|
||||
# Issue tracker: GitLab
|
||||
|
||||
Issues and PRDs for this repo live as GitLab issues. Use the [`glab`](https://gitlab.com/gitlab-org/cli) CLI for all operations.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Create an issue**: `glab issue create --title "..." --description "..."`. Use a heredoc for multi-line descriptions. Pass `--description -` to open an editor.
|
||||
- **Read an issue**: `glab issue view <number> --comments`. Use `-F json` for machine-readable output.
|
||||
- **List issues**: `glab issue list -F json` with appropriate `--label` filters.
|
||||
- **Comment on an issue**: `glab issue note <number> --message "..."`. GitLab calls comments "notes".
|
||||
- **Apply / remove labels**: `glab issue update <number> --label "..."` / `--unlabel "..."`. Multiple labels can be comma-separated or by repeating the flag.
|
||||
- **Close**: `glab issue close <number>`. `glab issue close` does not accept a closing comment, so post the explanation first with `glab issue note <number> --message "..."`, then close.
|
||||
- **Merge requests**: GitLab calls PRs "merge requests". Use `glab mr create`, `glab mr view`, `glab mr note`, etc. — the same shape as `gh pr ...` with `mr` in place of `pr` and `note`/`--message` in place of `comment`/`--body`.
|
||||
|
||||
Infer the repo from `git remote -v` — `glab` does this automatically when run inside a clone.
|
||||
|
||||
## When a skill says "publish to the issue tracker"
|
||||
|
||||
Create a GitLab issue.
|
||||
|
||||
## When a skill says "fetch the relevant ticket"
|
||||
|
||||
Run `glab issue view <number> --comments`.
|
||||
@@ -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.
|
||||
15
.agents/skills/setup-matt-pocock-skills/triage-labels.md
Normal file
15
.agents/skills/setup-matt-pocock-skills/triage-labels.md
Normal file
@@ -0,0 +1,15 @@
|
||||
# Triage Labels
|
||||
|
||||
The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker.
|
||||
|
||||
| Label in mattpocock/skills | Label in our tracker | Meaning |
|
||||
| -------------------------- | -------------------- | ---------------------------------------- |
|
||||
| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue |
|
||||
| `needs-info` | `needs-info` | Waiting on reporter for more information |
|
||||
| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent |
|
||||
| `ready-for-human` | `ready-for-human` | Requires human implementation |
|
||||
| `wontfix` | `wontfix` | Will not be actioned |
|
||||
|
||||
When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table.
|
||||
|
||||
Edit the right-hand column to match whatever vocabulary you actually use.
|
||||
109
.agents/skills/tdd/SKILL.md
Normal file
109
.agents/skills/tdd/SKILL.md
Normal file
@@ -0,0 +1,109 @@
|
||||
---
|
||||
name: tdd
|
||||
description: Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions "red-green-refactor", wants integration tests, or asks for test-first development.
|
||||
---
|
||||
|
||||
# Test-Driven Development
|
||||
|
||||
## Philosophy
|
||||
|
||||
**Core principle**: Tests should verify behavior through public interfaces, not implementation details. Code can change entirely; tests shouldn't.
|
||||
|
||||
**Good tests** are integration-style: they exercise real code paths through public APIs. They describe _what_ the system does, not _how_ it does it. A good test reads like a specification - "user can checkout with valid cart" tells you exactly what capability exists. These tests survive refactors because they don't care about internal structure.
|
||||
|
||||
**Bad tests** are coupled to implementation. They mock internal collaborators, test private methods, or verify through external means (like querying a database directly instead of using the interface). The warning sign: your test breaks when you refactor, but behavior hasn't changed. If you rename an internal function and tests fail, those tests were testing implementation, not behavior.
|
||||
|
||||
See [tests.md](tests.md) for examples and [mocking.md](mocking.md) for mocking guidelines.
|
||||
|
||||
## Anti-Pattern: Horizontal Slices
|
||||
|
||||
**DO NOT write all tests first, then all implementation.** This is "horizontal slicing" - treating RED as "write all tests" and GREEN as "write all code."
|
||||
|
||||
This produces **crap tests**:
|
||||
|
||||
- Tests written in bulk test _imagined_ behavior, not _actual_ behavior
|
||||
- You end up testing the _shape_ of things (data structures, function signatures) rather than user-facing behavior
|
||||
- Tests become insensitive to real changes - they pass when behavior breaks, fail when behavior is fine
|
||||
- You outrun your headlights, committing to test structure before understanding the implementation
|
||||
|
||||
**Correct approach**: Vertical slices via tracer bullets. One test → one implementation → repeat. Each test responds to what you learned from the previous cycle. Because you just wrote the code, you know exactly what behavior matters and how to verify it.
|
||||
|
||||
```
|
||||
WRONG (horizontal):
|
||||
RED: test1, test2, test3, test4, test5
|
||||
GREEN: impl1, impl2, impl3, impl4, impl5
|
||||
|
||||
RIGHT (vertical):
|
||||
RED→GREEN: test1→impl1
|
||||
RED→GREEN: test2→impl2
|
||||
RED→GREEN: test3→impl3
|
||||
...
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Planning
|
||||
|
||||
When exploring the codebase, use the project's domain glossary so that test names and interface vocabulary match the project's language, and respect ADRs in the area you're touching.
|
||||
|
||||
Before writing any code:
|
||||
|
||||
- [ ] Confirm with user what interface changes are needed
|
||||
- [ ] Confirm with user which behaviors to test (prioritize)
|
||||
- [ ] Identify opportunities for [deep modules](deep-modules.md) (small interface, deep implementation)
|
||||
- [ ] Design interfaces for [testability](interface-design.md)
|
||||
- [ ] List the behaviors to test (not implementation steps)
|
||||
- [ ] Get user approval on the plan
|
||||
|
||||
Ask: "What should the public interface look like? Which behaviors are most important to test?"
|
||||
|
||||
**You can't test everything.** Confirm with the user exactly which behaviors matter most. Focus testing effort on critical paths and complex logic, not every possible edge case.
|
||||
|
||||
### 2. Tracer Bullet
|
||||
|
||||
Write ONE test that confirms ONE thing about the system:
|
||||
|
||||
```
|
||||
RED: Write test for first behavior → test fails
|
||||
GREEN: Write minimal code to pass → test passes
|
||||
```
|
||||
|
||||
This is your tracer bullet - proves the path works end-to-end.
|
||||
|
||||
### 3. Incremental Loop
|
||||
|
||||
For each remaining behavior:
|
||||
|
||||
```
|
||||
RED: Write next test → fails
|
||||
GREEN: Minimal code to pass → passes
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- One test at a time
|
||||
- Only enough code to pass current test
|
||||
- Don't anticipate future tests
|
||||
- Keep tests focused on observable behavior
|
||||
|
||||
### 4. Refactor
|
||||
|
||||
After all tests pass, look for [refactor candidates](refactoring.md):
|
||||
|
||||
- [ ] Extract duplication
|
||||
- [ ] Deepen modules (move complexity behind simple interfaces)
|
||||
- [ ] Apply SOLID principles where natural
|
||||
- [ ] Consider what new code reveals about existing code
|
||||
- [ ] Run tests after each refactor step
|
||||
|
||||
**Never refactor while RED.** Get to GREEN first.
|
||||
|
||||
## Checklist Per Cycle
|
||||
|
||||
```
|
||||
[ ] Test describes behavior, not implementation
|
||||
[ ] Test uses public interface only
|
||||
[ ] Test would survive internal refactor
|
||||
[ ] Code is minimal for this test
|
||||
[ ] No speculative features added
|
||||
```
|
||||
33
.agents/skills/tdd/deep-modules.md
Normal file
33
.agents/skills/tdd/deep-modules.md
Normal file
@@ -0,0 +1,33 @@
|
||||
# Deep Modules
|
||||
|
||||
From "A Philosophy of Software Design":
|
||||
|
||||
**Deep module** = small interface + lots of implementation
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Small Interface │ ← Few methods, simple params
|
||||
├─────────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ Deep Implementation│ ← Complex logic hidden
|
||||
│ │
|
||||
│ │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
**Shallow module** = large interface + little implementation (avoid)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ Large Interface │ ← Many methods, complex params
|
||||
├─────────────────────────────────┤
|
||||
│ Thin Implementation │ ← Just passes through
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
When designing interfaces, ask:
|
||||
|
||||
- Can I reduce the number of methods?
|
||||
- Can I simplify the parameters?
|
||||
- Can I hide more complexity inside?
|
||||
31
.agents/skills/tdd/interface-design.md
Normal file
31
.agents/skills/tdd/interface-design.md
Normal file
@@ -0,0 +1,31 @@
|
||||
# Interface Design for Testability
|
||||
|
||||
Good interfaces make testing natural:
|
||||
|
||||
1. **Accept dependencies, don't create them**
|
||||
|
||||
```typescript
|
||||
// Testable
|
||||
function processOrder(order, paymentGateway) {}
|
||||
|
||||
// Hard to test
|
||||
function processOrder(order) {
|
||||
const gateway = new StripeGateway();
|
||||
}
|
||||
```
|
||||
|
||||
2. **Return results, don't produce side effects**
|
||||
|
||||
```typescript
|
||||
// Testable
|
||||
function calculateDiscount(cart): Discount {}
|
||||
|
||||
// Hard to test
|
||||
function applyDiscount(cart): void {
|
||||
cart.total -= discount;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Small surface area**
|
||||
- Fewer methods = fewer tests needed
|
||||
- Fewer params = simpler test setup
|
||||
59
.agents/skills/tdd/mocking.md
Normal file
59
.agents/skills/tdd/mocking.md
Normal 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
|
||||
10
.agents/skills/tdd/refactoring.md
Normal file
10
.agents/skills/tdd/refactoring.md
Normal 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
|
||||
61
.agents/skills/tdd/tests.md
Normal file
61
.agents/skills/tdd/tests.md
Normal file
@@ -0,0 +1,61 @@
|
||||
# Good and Bad Tests
|
||||
|
||||
## Good Tests
|
||||
|
||||
**Integration-style**: Test through real interfaces, not mocks of internal parts.
|
||||
|
||||
```typescript
|
||||
// GOOD: Tests observable behavior
|
||||
test("user can checkout with valid cart", async () => {
|
||||
const cart = createCart();
|
||||
cart.add(product);
|
||||
const result = await checkout(cart, paymentMethod);
|
||||
expect(result.status).toBe("confirmed");
|
||||
});
|
||||
```
|
||||
|
||||
Characteristics:
|
||||
|
||||
- Tests behavior users/callers care about
|
||||
- Uses public API only
|
||||
- Survives internal refactors
|
||||
- Describes WHAT, not HOW
|
||||
- One logical assertion per test
|
||||
|
||||
## Bad Tests
|
||||
|
||||
**Implementation-detail tests**: Coupled to internal structure.
|
||||
|
||||
```typescript
|
||||
// BAD: Tests implementation details
|
||||
test("checkout calls paymentService.process", async () => {
|
||||
const mockPayment = jest.mock(paymentService);
|
||||
await checkout(cart, payment);
|
||||
expect(mockPayment.process).toHaveBeenCalledWith(cart.total);
|
||||
});
|
||||
```
|
||||
|
||||
Red flags:
|
||||
|
||||
- Mocking internal collaborators
|
||||
- Testing private methods
|
||||
- Asserting on call counts/order
|
||||
- Test breaks when refactoring without behavior change
|
||||
- Test name describes HOW not WHAT
|
||||
- Verifying through external means instead of interface
|
||||
|
||||
```typescript
|
||||
// BAD: Bypasses interface to verify
|
||||
test("createUser saves to database", async () => {
|
||||
await createUser({ name: "Alice" });
|
||||
const row = await db.query("SELECT * FROM users WHERE name = ?", ["Alice"]);
|
||||
expect(row).toBeDefined();
|
||||
});
|
||||
|
||||
// GOOD: Verifies through interface
|
||||
test("createUser makes user retrievable", async () => {
|
||||
const user = await createUser({ name: "Alice" });
|
||||
const retrieved = await getUser(user.id);
|
||||
expect(retrieved.name).toBe("Alice");
|
||||
});
|
||||
```
|
||||
35
.agents/skills/teach/GLOSSARY-FORMAT.md
Normal file
35
.agents/skills/teach/GLOSSARY-FORMAT.md
Normal 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 1–10 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.
|
||||
46
.agents/skills/teach/LEARNING-RECORD-FORMAT.md
Normal file
46
.agents/skills/teach/LEARNING-RECORD-FORMAT.md
Normal 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.
|
||||
31
.agents/skills/teach/MISSION-FORMAT.md
Normal file
31
.agents/skills/teach/MISSION-FORMAT.md
Normal 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.
|
||||
32
.agents/skills/teach/RESOURCES-FORMAT.md
Normal file
32
.agents/skills/teach/RESOURCES-FORMAT.md
Normal 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.
|
||||
131
.agents/skills/teach/SKILL.md
Normal file
131
.agents/skills/teach/SKILL.md
Normal file
@@ -0,0 +1,131 @@
|
||||
---
|
||||
name: teach
|
||||
description: Teach the user a new skill or concept, within this workspace.
|
||||
disable-model-invocation: true
|
||||
argument-hint: "What would you like to learn about?"
|
||||
---
|
||||
|
||||
The user has asked you to teach them something. This is a stateful request - they intend to learn the topic over multiple sessions.
|
||||
|
||||
## Teaching Workspace
|
||||
|
||||
Treat the current directory as a teaching workspace. The state of their learning is captured in this directory in several files:
|
||||
|
||||
- `MISSION.md`: A document capturing the _reason_ the user is interested in the topic. This should be used to ground all teaching. Use the format in [MISSION-FORMAT.md](./MISSION-FORMAT.md).
|
||||
- `./reference/*.html`: A directory of reference materials. These are the compressed learnings from the lessons - cheat sheets, reference algorithms, syntax, yoga poses, glossaries. They are the raw units of learning. They should be beautiful documents which print out well, and are designed for quick reference.
|
||||
- `RESOURCES.md`: A list of resources which can be explored to ground your teaching in contextual knowledge, or to acquire knowledge and wisdom. Use the format in [RESOURCES-FORMAT.md](./RESOURCES-FORMAT.md).
|
||||
- `./learning-records/*.md`: A directory of learning records, which capture what the user has learned. These are loosely equivalent to architectural decision records in software development - they capture non-obvious lessons and key insights that may need to be revised later, or drive future sessions. These should be used to calculate the zone of proximal development. They are titled `0001-<dash-case-name>.md`, where the number increments each time. Use the format in [LEARNING-RECORD-FORMAT.md](./LEARNING-RECORD-FORMAT.md).
|
||||
- `./lessons/*.html`: A directory of lessons. A **lesson** is a single, self-contained HTML output that teaches one tightly-scoped thing tied to the mission. This is the primary unit of teaching in this workspace.
|
||||
- `NOTES.md`: A scratchpad for you to jot down user preferences, or working notes.
|
||||
|
||||
## Philosophy
|
||||
|
||||
To learn at a deep level, the user needs three things:
|
||||
|
||||
- **Knowledge**, captured from high-quality, high-trust resources
|
||||
- **Skills**, acquired through highly-relevant interactive lessons devised by you, based on the knowledge
|
||||
- **Wisdom**, which comes from interacting with other learners and practitioners
|
||||
|
||||
Before the `RESOURCES.md` is well-populated, your focus should be to find high-quality resources which will help the user acquire knowledge. Never trust your parametric knowledge.
|
||||
|
||||
Some topics may require more skills than knowledge. Learning more about theoretical physics might be more knowledge-based. For yoga, more skills-based.
|
||||
|
||||
### Fluency vs Storage Strength
|
||||
|
||||
You should be careful to split between two types of learning:
|
||||
|
||||
- **Fluency strength**: in-the-moment retrieval of knowledge
|
||||
- **Storage strength**: long-term retention of knowledge
|
||||
|
||||
Fluency can give the user an illusory sense of mastery, but storage strength is the real goal. Try to design lessons which build long-term retention by desirable difficulty:
|
||||
|
||||
- Using retrieval practice (recall from memory)
|
||||
- Spacing (distributing practice over time)
|
||||
- Interleaving (mixing up different but related topics in practice - for skills practice only)
|
||||
|
||||
## Lessons
|
||||
|
||||
A lesson is the main thing you produce — the unit in which knowledge and skills reach the user. Each lesson is one self-contained HTML file, saved to `./lessons/` and titled `0001-<dash-case-name>.html` where the number increments each time.
|
||||
|
||||
A lesson should be **beautiful** — clean, readable typography and layout — since the user will return to these later to review. Think Tufte.
|
||||
|
||||
The lesson should be short, and completable very quickly. Learners' working memory is very small, and we need to stay within it. But each lesson should give the user a single tangible win that they can build on. It should be directly tied to the mission, and should be in the user's zone of proximal development.
|
||||
|
||||
If possible, open the lesson file for the user by running a CLI command.
|
||||
|
||||
Each lesson should link via HTML anchors to other lessons and reference documents.
|
||||
|
||||
Each lesson should recommend a primary source for the user to read or watch. This should be the most high-quality, high-trust resource you found on the topic.
|
||||
|
||||
Each lesson should contain a reminder to ask followup questions to the agent. The agent is their teacher, and can assist with anything that's unclear.
|
||||
|
||||
## The Mission
|
||||
|
||||
Every lesson should be tied into the mission - the reason that the user is interested in learning about the topic.
|
||||
|
||||
If the user is unclear about the mission, or the `MISSION.md` is not populated, your first job should be to question the user on why they want to learn this.
|
||||
|
||||
Failing to understand the mission will mean knowledge acquisition is not grounded in real-world goals. Lessons will feel too abstract. You will have no way of judging what the user should do next.
|
||||
|
||||
Missions may change as the user develops more skills and knowledge. This is normal - make sure to update the `MISSION.md` and add a learning record to capture the change. Confirm with the user before changing the mission.
|
||||
|
||||
## Zone Of Proximal Development
|
||||
|
||||
Each lesson, the user should always feel as if they are being challenged 'just enough'.
|
||||
|
||||
The user may specify an exact thing they want to learn. If they don't, figure out their zone of proximal development by:
|
||||
|
||||
- Reading their `learning-records`
|
||||
- Figuring out the right thing to teach them based on their mission
|
||||
- Teach the most relevant thing that fits in their zone of proximal development
|
||||
|
||||
## Knowledge
|
||||
|
||||
Lessons should be designed around a skill the user is going to learn. The knowledge in the lesson should be only what's required to acquire that skill. You teach the knowledge first, then get the user to practice the skills via an interactive feedback loop.
|
||||
|
||||
Knowledge should first be gathered from trusted resources. Use `RESOURCES.md` to keep track of them. Lessons should be littered with citations - links to external resources to back up any claim made. This increases the trustworthiness of the lesson.
|
||||
|
||||
For acquiring knowledge, difficulty is the enemy. It eats working memory you need for understanding.
|
||||
|
||||
## Skills
|
||||
|
||||
If knowledge is all about acquisition, skills are about durability and flexibility. Make the knowledge stick.
|
||||
|
||||
For skill acquisition, difficulty is the tool. Effortful retrieval is what builds storage strength. Skills should be taught through interactive lessons. There are several tools at your disposal:
|
||||
|
||||
- Interactive lessons, using quizzes and light in-browser tasks
|
||||
- Lessons which guide the user through a list of real-world steps to take (for instance, yoga poses)
|
||||
|
||||
Each of these should be based on a **feedback loop**, where the user receives feedback on their performance. This feedback loop should be as tight as possible, giving feedback immediately - and ideally automatically.
|
||||
|
||||
For quizzes, each answer should be exactly the same number of words (and characters, if possible). Don't give the user any clues about the answer through formatting.
|
||||
|
||||
## Acquiring Wisdom
|
||||
|
||||
Wisdom comes from true real-world interaction - testing your skills outside the learning environment.
|
||||
|
||||
When the user asks a question that appears to require wisdom, your default posture should be to attempt to answer - but to ultimately delegate to a **community**.
|
||||
|
||||
A community is a place (online or offline) where the user can test their skills in the real world. This might be a forum, a subreddit, a real-world class (budget permitting) or a local interest group.
|
||||
|
||||
You should attempt to find high-reputation communities the user can join. If the user expresses a preference that they don't want to join a community, respect it.
|
||||
|
||||
## Reference Documents
|
||||
|
||||
While creating lessons, you should also create reference documents. Lessons can reference these documents - they are useful for tracking raw units of knowledge useful across lessons.
|
||||
|
||||
Lessons will rarely be revisited later - reference documents will be. They should be the compressed essence of the lesson, in a format designed for quick reference.
|
||||
|
||||
Some learning topics lend themselves to reference:
|
||||
|
||||
- Syntax and code snippets for programming
|
||||
- Algorithms and flowcharts for processes
|
||||
- Yoga poses and sequences for yoga
|
||||
- Exercises and routines for fitness
|
||||
- Glossaries for any topic with its own nomenclature
|
||||
|
||||
Glossaries, in particular, are an essential reference. Once one is created, it should be adhered to in every lesson.
|
||||
|
||||
## `NOTES.md`
|
||||
|
||||
The user will sometimes express preferences of how they want to be taught, or things you should keep in mind. This is the place to record those preferences, so you can refer back to them when designing lessons or working with the user.
|
||||
83
.agents/skills/to-issues/SKILL.md
Normal file
83
.agents/skills/to-issues/SKILL.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
name: to-issues
|
||||
description: Break a plan, spec, or PRD into independently-grabbable issues on the project issue tracker using tracer-bullet vertical slices. Use when user wants to convert a plan into issues, create implementation tickets, or break down work into issues.
|
||||
---
|
||||
|
||||
# To Issues
|
||||
|
||||
Break a plan into independently-grabbable issues using vertical slices (tracer bullets).
|
||||
|
||||
The issue tracker and triage label vocabulary should have been provided to you — run `/setup-matt-pocock-skills` if not.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Gather context
|
||||
|
||||
Work from whatever is already in the conversation context. If the user passes an issue reference (issue number, URL, or path) as an argument, fetch it from the issue tracker and read its full body and comments.
|
||||
|
||||
### 2. Explore the codebase (optional)
|
||||
|
||||
If you have not already explored the codebase, do so to understand the current state of the code. Issue titles and descriptions should use the project's domain glossary vocabulary, and respect ADRs in the area you're touching.
|
||||
|
||||
### 3. Draft vertical slices
|
||||
|
||||
Break the plan into **tracer bullet** issues. Each issue is a thin vertical slice that cuts through ALL integration layers end-to-end, NOT a horizontal slice of one layer.
|
||||
|
||||
Slices may be 'HITL' or 'AFK'. HITL slices require human interaction, such as an architectural decision or a design review. AFK slices can be implemented and merged without human interaction. Prefer AFK over HITL where possible.
|
||||
|
||||
<vertical-slice-rules>
|
||||
- Each slice delivers a narrow but COMPLETE path through every layer (schema, API, UI, tests)
|
||||
- A completed slice is demoable or verifiable on its own
|
||||
- Prefer many thin slices over few thick ones
|
||||
</vertical-slice-rules>
|
||||
|
||||
### 4. Quiz the user
|
||||
|
||||
Present the proposed breakdown as a numbered list. For each slice, show:
|
||||
|
||||
- **Title**: short descriptive name
|
||||
- **Type**: HITL / AFK
|
||||
- **Blocked by**: which other slices (if any) must complete first
|
||||
- **User stories covered**: which user stories this addresses (if the source material has them)
|
||||
|
||||
Ask the user:
|
||||
|
||||
- Does the granularity feel right? (too coarse / too fine)
|
||||
- Are the dependency relationships correct?
|
||||
- Should any slices be merged or split further?
|
||||
- Are the correct slices marked as HITL and AFK?
|
||||
|
||||
Iterate until the user approves the breakdown.
|
||||
|
||||
### 5. Publish the issues to the issue tracker
|
||||
|
||||
For each approved slice, publish a new issue to the issue tracker. Use the issue body template below. These issues are considered ready for AFK agents, so publish them with the correct triage label unless instructed otherwise.
|
||||
|
||||
Publish issues in dependency order (blockers first) so you can reference real issue identifiers in the "Blocked by" field.
|
||||
|
||||
<issue-template>
|
||||
## Parent
|
||||
|
||||
A reference to the parent issue on the issue tracker (if the source was an existing issue, otherwise omit this section).
|
||||
|
||||
## What to build
|
||||
|
||||
A concise description of this vertical slice. Describe the end-to-end behavior, not layer-by-layer implementation.
|
||||
|
||||
Avoid specific file paths or code snippets — they go stale fast. Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it here and note briefly that it came from a prototype. Trim to the decision-rich parts — not a working demo, just the important bits.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Criterion 1
|
||||
- [ ] Criterion 2
|
||||
- [ ] Criterion 3
|
||||
|
||||
## Blocked by
|
||||
|
||||
- A reference to the blocking ticket (if any)
|
||||
|
||||
Or "None - can start immediately" if no blockers.
|
||||
|
||||
</issue-template>
|
||||
|
||||
Do NOT close or modify any parent issue.
|
||||
74
.agents/skills/to-prd/SKILL.md
Normal file
74
.agents/skills/to-prd/SKILL.md
Normal file
@@ -0,0 +1,74 @@
|
||||
---
|
||||
name: to-prd
|
||||
description: Turn the current conversation context into a PRD and publish it to the project issue tracker. Use when user wants to create a PRD from the current context.
|
||||
---
|
||||
|
||||
This skill takes the current conversation context and codebase understanding and produces a PRD. Do NOT interview the user — just synthesize what you already know.
|
||||
|
||||
The issue tracker and triage label vocabulary should have been provided to you — run `/setup-matt-pocock-skills` if not.
|
||||
|
||||
## Process
|
||||
|
||||
1. Explore the repo to understand the current state of the codebase, if you haven't already. Use the project's domain glossary vocabulary throughout the PRD, and respect any ADRs in the area you're touching.
|
||||
|
||||
2. Sketch out the seams at which you're going to test the feature. Existing seams should be preferred to new ones. Use the highest seam possible. If new seams are needed, propose them at the highest point you can.
|
||||
|
||||
Check with the user that these seams match their expectations.
|
||||
|
||||
3. Write the PRD using the template below, then publish it to the project issue tracker. Apply the `ready-for-agent` triage label - no need for additional triage.
|
||||
|
||||
<prd-template>
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The problem that the user is facing, from the user's perspective.
|
||||
|
||||
## Solution
|
||||
|
||||
The solution to the problem, from the user's perspective.
|
||||
|
||||
## User Stories
|
||||
|
||||
A LONG, numbered list of user stories. Each user story should be in the format of:
|
||||
|
||||
1. As an <actor>, I want a <feature>, so that <benefit>
|
||||
|
||||
<user-story-example>
|
||||
1. As a mobile bank customer, I want to see balance on my accounts, so that I can make better informed decisions about my spending
|
||||
</user-story-example>
|
||||
|
||||
This list of user stories should be extremely extensive and cover all aspects of the feature.
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
A list of implementation decisions that were made. This can include:
|
||||
|
||||
- The modules that will be built/modified
|
||||
- The interfaces of those modules that will be modified
|
||||
- Technical clarifications from the developer
|
||||
- Architectural decisions
|
||||
- Schema changes
|
||||
- API contracts
|
||||
- Specific interactions
|
||||
|
||||
Do NOT include specific file paths or code snippets. They may end up being outdated very quickly.
|
||||
|
||||
Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it within the relevant decision and note briefly that it came from a prototype. Trim to the decision-rich parts — not a working demo, just the important bits.
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
A list of testing decisions that were made. Include:
|
||||
|
||||
- A description of what makes a good test (only test external behavior, not implementation details)
|
||||
- Which modules will be tested
|
||||
- Prior art for the tests (i.e. similar types of tests in the codebase)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
A description of the things that are out of scope for this PRD.
|
||||
|
||||
## Further Notes
|
||||
|
||||
Any further notes about the feature.
|
||||
|
||||
</prd-template>
|
||||
168
.agents/skills/triage/AGENT-BRIEF.md
Normal file
168
.agents/skills/triage/AGENT-BRIEF.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Writing Agent Briefs
|
||||
|
||||
An agent brief is a structured comment posted on a GitHub issue when it moves to `ready-for-agent`. It is the authoritative specification that an AFK agent will work from. The original issue body and discussion are context — the agent brief is the contract.
|
||||
|
||||
## Principles
|
||||
|
||||
### Durability over precision
|
||||
|
||||
The issue may sit in `ready-for-agent` for days or weeks. The codebase will change in the meantime. Write the brief so it stays useful even as files are renamed, moved, or refactored.
|
||||
|
||||
- **Do** describe interfaces, types, and behavioral contracts
|
||||
- **Do** name specific types, function signatures, or config shapes that the agent should look for or modify
|
||||
- **Don't** reference file paths — they go stale
|
||||
- **Don't** reference line numbers
|
||||
- **Don't** assume the current implementation structure will remain the same
|
||||
|
||||
### Behavioral, not procedural
|
||||
|
||||
Describe **what** the system should do, not **how** to implement it. The agent will explore the codebase fresh and make its own implementation decisions.
|
||||
|
||||
- **Good:** "The `SkillConfig` type should accept an optional `schedule` field of type `CronExpression`"
|
||||
- **Bad:** "Open src/types/skill.ts and add a schedule field on line 42"
|
||||
- **Good:** "When a user runs `/triage` with no arguments, they should see a summary of issues needing attention"
|
||||
- **Bad:** "Add a switch statement in the main handler function"
|
||||
|
||||
### Complete acceptance criteria
|
||||
|
||||
The agent needs to know when it's done. Every agent brief must have concrete, testable acceptance criteria. Each criterion should be independently verifiable.
|
||||
|
||||
- **Good:** "Running `gh issue list --label needs-triage` returns issues that have been through initial classification"
|
||||
- **Bad:** "Triage should work correctly"
|
||||
|
||||
### Explicit scope boundaries
|
||||
|
||||
State what is out of scope. This prevents the agent from gold-plating or making assumptions about adjacent features.
|
||||
|
||||
## Template
|
||||
|
||||
```markdown
|
||||
## Agent Brief
|
||||
|
||||
**Category:** bug / enhancement
|
||||
**Summary:** one-line description of what needs to happen
|
||||
|
||||
**Current behavior:**
|
||||
Describe what happens now. For bugs, this is the broken behavior.
|
||||
For enhancements, this is the status quo the feature builds on.
|
||||
|
||||
**Desired behavior:**
|
||||
Describe what should happen after the agent's work is complete.
|
||||
Be specific about edge cases and error conditions.
|
||||
|
||||
**Key interfaces:**
|
||||
- `TypeName` — what needs to change and why
|
||||
- `functionName()` return type — what it currently returns vs what it should return
|
||||
- Config shape — any new configuration options needed
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Specific, testable criterion 1
|
||||
- [ ] Specific, testable criterion 2
|
||||
- [ ] Specific, testable criterion 3
|
||||
|
||||
**Out of scope:**
|
||||
- Thing that should NOT be changed or addressed in this issue
|
||||
- Adjacent feature that might seem related but is separate
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Good agent brief (bug)
|
||||
|
||||
```markdown
|
||||
## Agent Brief
|
||||
|
||||
**Category:** bug
|
||||
**Summary:** Skill description truncation drops mid-word, producing broken output
|
||||
|
||||
**Current behavior:**
|
||||
When a skill description exceeds 1024 characters, it is truncated at exactly
|
||||
1024 characters regardless of word boundaries. This produces descriptions
|
||||
that end mid-word (e.g. "Use when the user wants to confi").
|
||||
|
||||
**Desired behavior:**
|
||||
Truncation should break at the last word boundary before 1024 characters
|
||||
and append "..." to indicate truncation.
|
||||
|
||||
**Key interfaces:**
|
||||
- The `SkillMetadata` type's `description` field — no type change needed,
|
||||
but the validation/processing logic that populates it needs to respect
|
||||
word boundaries
|
||||
- Any function that reads SKILL.md frontmatter and extracts the description
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Descriptions under 1024 chars are unchanged
|
||||
- [ ] Descriptions over 1024 chars are truncated at the last word boundary
|
||||
before 1024 chars
|
||||
- [ ] Truncated descriptions end with "..."
|
||||
- [ ] The total length including "..." does not exceed 1024 chars
|
||||
|
||||
**Out of scope:**
|
||||
- Changing the 1024 char limit itself
|
||||
- Multi-line description support
|
||||
```
|
||||
|
||||
### Good agent brief (enhancement)
|
||||
|
||||
```markdown
|
||||
## Agent Brief
|
||||
|
||||
**Category:** enhancement
|
||||
**Summary:** Add `.out-of-scope/` directory support for tracking rejected feature requests
|
||||
|
||||
**Current behavior:**
|
||||
When a feature request is rejected, the issue is closed with a `wontfix` label
|
||||
and a comment. There is no persistent record of the decision or reasoning.
|
||||
Future similar requests require the maintainer to recall or search for the
|
||||
prior discussion.
|
||||
|
||||
**Desired behavior:**
|
||||
Rejected feature requests should be documented in `.out-of-scope/<concept>.md`
|
||||
files that capture the decision, reasoning, and links to all issues that
|
||||
requested the feature. When triaging new issues, these files should be
|
||||
checked for matches.
|
||||
|
||||
**Key interfaces:**
|
||||
- Markdown file format in `.out-of-scope/` — each file should have a
|
||||
`# Concept Name` heading, a `**Decision:**` line, a `**Reason:**` line,
|
||||
and a `**Prior requests:**` list with issue links
|
||||
- The triage workflow should read all `.out-of-scope/*.md` files early
|
||||
and match incoming issues against them by concept similarity
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Closing a feature as wontfix creates/updates a file in `.out-of-scope/`
|
||||
- [ ] The file includes the decision, reasoning, and link to the closed issue
|
||||
- [ ] If a matching `.out-of-scope/` file already exists, the new issue is
|
||||
appended to its "Prior requests" list rather than creating a duplicate
|
||||
- [ ] During triage, existing `.out-of-scope/` files are checked and surfaced
|
||||
when a new issue matches a prior rejection
|
||||
|
||||
**Out of scope:**
|
||||
- Automated matching (human confirms the match)
|
||||
- Reopening previously rejected features
|
||||
- Bug reports (only enhancement rejections go to `.out-of-scope/`)
|
||||
```
|
||||
|
||||
### Bad agent brief
|
||||
|
||||
```markdown
|
||||
## Agent Brief
|
||||
|
||||
**Summary:** Fix the triage bug
|
||||
|
||||
**What to do:**
|
||||
The triage thing is broken. Look at the main file and fix it.
|
||||
The function around line 150 has the issue.
|
||||
|
||||
**Files to change:**
|
||||
- src/triage/handler.ts (line 150)
|
||||
- src/types.ts (line 42)
|
||||
```
|
||||
|
||||
This is bad because:
|
||||
- No category
|
||||
- Vague description ("the triage thing is broken")
|
||||
- References file paths and line numbers that will go stale
|
||||
- No acceptance criteria
|
||||
- No scope boundaries
|
||||
- No description of current vs desired behavior
|
||||
101
.agents/skills/triage/OUT-OF-SCOPE.md
Normal file
101
.agents/skills/triage/OUT-OF-SCOPE.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Out-of-Scope Knowledge Base
|
||||
|
||||
The `.out-of-scope/` directory in a repo stores persistent records of rejected feature requests. It serves two purposes:
|
||||
|
||||
1. **Institutional memory** — why a feature was rejected, so the reasoning isn't lost when the issue is closed
|
||||
2. **Deduplication** — when a new issue comes in that matches a prior rejection, the skill can surface the previous decision instead of re-litigating it
|
||||
|
||||
## Directory structure
|
||||
|
||||
```
|
||||
.out-of-scope/
|
||||
├── dark-mode.md
|
||||
├── plugin-system.md
|
||||
└── graphql-api.md
|
||||
```
|
||||
|
||||
One file per **concept**, not per issue. Multiple issues requesting the same thing are grouped under one file.
|
||||
|
||||
## File format
|
||||
|
||||
The file should be written in a relaxed, readable style — more like a short design document than a database entry. Use paragraphs, code samples, and examples to make the reasoning clear and useful to someone encountering it for the first time.
|
||||
|
||||
```markdown
|
||||
# Dark Mode
|
||||
|
||||
This project does not support dark mode or user-facing theming.
|
||||
|
||||
## Why this is out of scope
|
||||
|
||||
The rendering pipeline assumes a single color palette defined in
|
||||
`ThemeConfig`. Supporting multiple themes would require:
|
||||
|
||||
- A theme context provider wrapping the entire component tree
|
||||
- Per-component theme-aware style resolution
|
||||
- A persistence layer for user theme preferences
|
||||
|
||||
This is a significant architectural change that doesn't align with the
|
||||
project's focus on content authoring. Theming is a concern for downstream
|
||||
consumers who embed or redistribute the output.
|
||||
|
||||
```ts
|
||||
// The current ThemeConfig interface is not designed for runtime switching:
|
||||
interface ThemeConfig {
|
||||
colors: ColorPalette; // single palette, resolved at build time
|
||||
fonts: FontStack;
|
||||
}
|
||||
```
|
||||
|
||||
## Prior requests
|
||||
|
||||
- #42 — "Add dark mode support"
|
||||
- #87 — "Night theme for accessibility"
|
||||
- #134 — "Dark theme option"
|
||||
```
|
||||
|
||||
### Naming the file
|
||||
|
||||
Use a short, descriptive kebab-case name for the concept: `dark-mode.md`, `plugin-system.md`, `graphql-api.md`. The name should be recognizable enough that someone browsing the directory understands what was rejected without opening the file.
|
||||
|
||||
### Writing the reason
|
||||
|
||||
The reason should be substantive — not "we don't want this" but why. Good reasons reference:
|
||||
|
||||
- Project scope or philosophy ("This project focuses on X; theming is a downstream concern")
|
||||
- Technical constraints ("Supporting this would require Y, which conflicts with our Z architecture")
|
||||
- Strategic decisions ("We chose to use A instead of B because...")
|
||||
|
||||
The reason should be durable. Avoid referencing temporary circumstances ("we're too busy right now") — those aren't real rejections, they're deferrals.
|
||||
|
||||
## When to check `.out-of-scope/`
|
||||
|
||||
During triage (Step 1: Gather context), read all files in `.out-of-scope/`. When evaluating a new issue:
|
||||
|
||||
- Check if the request matches an existing out-of-scope concept
|
||||
- Matching is by concept similarity, not keyword — "night theme" matches `dark-mode.md`
|
||||
- If there's a match, surface it to the maintainer: "This is similar to `.out-of-scope/dark-mode.md` — we rejected this before because [reason]. Do you still feel the same way?"
|
||||
|
||||
The maintainer may:
|
||||
|
||||
- **Confirm** — the new issue gets added to the existing file's "Prior requests" list, then closed
|
||||
- **Reconsider** — the out-of-scope file gets deleted or updated, and the issue proceeds through normal triage
|
||||
- **Disagree** — the issues are related but distinct, proceed with normal triage
|
||||
|
||||
## When to write to `.out-of-scope/`
|
||||
|
||||
Only when an **enhancement** (not a bug) is rejected as `wontfix`. The flow:
|
||||
|
||||
1. Maintainer decides a feature request is out of scope
|
||||
2. Check if a matching `.out-of-scope/` file already exists
|
||||
3. If yes: append the new issue to the "Prior requests" list
|
||||
4. If no: create a new file with the concept name, decision, reason, and first prior request
|
||||
5. Post a comment on the issue explaining the decision and mentioning the `.out-of-scope/` file
|
||||
6. Close the issue with the `wontfix` label
|
||||
|
||||
## Updating or removing out-of-scope files
|
||||
|
||||
If the maintainer changes their mind about a previously rejected concept:
|
||||
|
||||
- Delete the `.out-of-scope/` file
|
||||
- The skill does not need to reopen old issues — they're historical records
|
||||
- The new issue that triggered the reconsideration proceeds through normal triage
|
||||
103
.agents/skills/triage/SKILL.md
Normal file
103
.agents/skills/triage/SKILL.md
Normal file
@@ -0,0 +1,103 @@
|
||||
---
|
||||
name: triage
|
||||
description: Triage issues through a state machine driven by triage roles. Use when user wants to create an issue, triage issues, review incoming bugs or feature requests, prepare issues for an AFK agent, or manage issue workflow.
|
||||
---
|
||||
|
||||
# Triage
|
||||
|
||||
Move issues on the project issue tracker through a small state machine of triage roles.
|
||||
|
||||
Every comment or issue posted to the issue tracker during triage **must** start with this disclaimer:
|
||||
|
||||
```
|
||||
> *This was generated by AI during triage.*
|
||||
```
|
||||
|
||||
## Reference docs
|
||||
|
||||
- [AGENT-BRIEF.md](AGENT-BRIEF.md) — how to write durable agent briefs
|
||||
- [OUT-OF-SCOPE.md](OUT-OF-SCOPE.md) — how the `.out-of-scope/` knowledge base works
|
||||
|
||||
## Roles
|
||||
|
||||
Two **category** roles:
|
||||
|
||||
- `bug` — something is broken
|
||||
- `enhancement` — new feature or improvement
|
||||
|
||||
Five **state** roles:
|
||||
|
||||
- `needs-triage` — maintainer needs to evaluate
|
||||
- `needs-info` — waiting on reporter for more information
|
||||
- `ready-for-agent` — fully specified, ready for an AFK agent
|
||||
- `ready-for-human` — needs human implementation
|
||||
- `wontfix` — will not be actioned
|
||||
|
||||
Every triaged issue should carry exactly one category role and one state role. If state roles conflict, flag it and ask the maintainer before doing anything else.
|
||||
|
||||
These are canonical role names — the actual label strings used in the issue tracker may differ. The mapping should have been provided to you - run `/setup-matt-pocock-skills` if not.
|
||||
|
||||
State transitions: an unlabeled issue normally goes to `needs-triage` first; from there it moves to `needs-info`, `ready-for-agent`, `ready-for-human`, or `wontfix`. `needs-info` returns to `needs-triage` once the reporter replies. The maintainer can override at any time — flag transitions that look unusual and ask before proceeding.
|
||||
|
||||
## Invocation
|
||||
|
||||
The maintainer invokes `/triage` and describes what they want in natural language. Interpret the request and act. Examples:
|
||||
|
||||
- "Show me anything that needs my attention"
|
||||
- "Let's look at #42"
|
||||
- "Move #42 to ready-for-agent"
|
||||
- "What's ready for agents to pick up?"
|
||||
|
||||
## Show what needs attention
|
||||
|
||||
Query the issue tracker and present three buckets, oldest first:
|
||||
|
||||
1. **Unlabeled** — never triaged.
|
||||
2. **`needs-triage`** — evaluation in progress.
|
||||
3. **`needs-info` with reporter activity since the last triage notes** — needs re-evaluation.
|
||||
|
||||
Show counts and a one-line summary per issue. Let the maintainer pick.
|
||||
|
||||
## Triage a specific issue
|
||||
|
||||
1. **Gather context.** Read the full issue (body, comments, labels, reporter, dates). Parse any prior triage notes so you don't re-ask resolved questions. Explore the codebase using the project's domain glossary, respecting ADRs in the area. Read `.out-of-scope/*.md` and surface any prior rejection that resembles this issue.
|
||||
|
||||
2. **Recommend.** Tell the maintainer your category and state recommendation with reasoning, plus a brief codebase summary relevant to the issue. Wait for direction.
|
||||
|
||||
3. **Reproduce (bugs only).** Before any grilling, attempt reproduction: read the reporter's steps, trace the relevant code, run tests or commands. Report what happened — successful repro with code path, failed repro, or insufficient detail (a strong `needs-info` signal). A confirmed repro makes a much stronger agent brief.
|
||||
|
||||
4. **Grill (if needed).** If the issue needs fleshing out, run a `/grill-with-docs` session.
|
||||
|
||||
5. **Apply the outcome:**
|
||||
- `ready-for-agent` — post an agent brief comment ([AGENT-BRIEF.md](AGENT-BRIEF.md)).
|
||||
- `ready-for-human` — same structure as an agent brief, but note why it can't be delegated (judgment calls, external access, design decisions, manual testing).
|
||||
- `needs-info` — post triage notes (template below).
|
||||
- `wontfix` (bug) — polite explanation, then close.
|
||||
- `wontfix` (enhancement) — write to `.out-of-scope/`, link to it from a comment, then close ([OUT-OF-SCOPE.md](OUT-OF-SCOPE.md)).
|
||||
- `needs-triage` — apply the role. Optional comment if there's partial progress.
|
||||
|
||||
## Quick state override
|
||||
|
||||
If the maintainer says "move #42 to ready-for-agent", trust them and apply the role directly. Confirm what you're about to do (role changes, comment, close), then act. Skip grilling. If moving to `ready-for-agent` without a grilling session, ask whether they want to write an agent brief.
|
||||
|
||||
## Needs-info template
|
||||
|
||||
```markdown
|
||||
## Triage Notes
|
||||
|
||||
**What we've established so far:**
|
||||
|
||||
- point 1
|
||||
- point 2
|
||||
|
||||
**What we still need from you (@reporter):**
|
||||
|
||||
- question 1
|
||||
- question 2
|
||||
```
|
||||
|
||||
Capture everything resolved during grilling under "established so far" so the work isn't lost. Questions must be specific and actionable, not "please provide more info".
|
||||
|
||||
## Resuming a previous session
|
||||
|
||||
If prior triage notes exist on the issue, read them, check whether the reporter has answered any outstanding questions, and present an updated picture before continuing. Don't re-ask resolved questions.
|
||||
117
.agents/skills/write-a-skill/SKILL.md
Normal file
117
.agents/skills/write-a-skill/SKILL.md
Normal 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
|
||||
7
.agents/skills/zoom-out/SKILL.md
Normal file
7
.agents/skills/zoom-out/SKILL.md
Normal 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
1
.claude/skills/caveman
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/caveman
|
||||
139
.claude/skills/comment-standards/SKILL.md
Normal file
139
.claude/skills/comment-standards/SKILL.md
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
name: comment-standards
|
||||
description: Go 注释规范。编写 Go 代码注释、文档注释时使用。包含包注释、结构体注释、接口注释、函数注释、内联注释的完整规范与示例。
|
||||
---
|
||||
|
||||
# Go 注释规范
|
||||
|
||||
**基本原则**:
|
||||
- **所有注释使用中文**
|
||||
- **导出符号必须有文档注释**(包、函数、方法、类型、接口、常量、变量)
|
||||
- **复杂逻辑必须有实现注释**(解释"为什么",而不是"做了什么")
|
||||
- **禁止废话注释**(不要用注释复述代码本身)
|
||||
- **修改代码时必须同步更新注释**
|
||||
|
||||
---
|
||||
|
||||
## 包注释
|
||||
|
||||
每个包的入口文件(通常是主文件或 `doc.go`)必须有包注释:
|
||||
|
||||
```go
|
||||
// Package account 提供账号管理的业务逻辑服务
|
||||
// 包含账号创建、修改、删除、权限分配等功能
|
||||
package account
|
||||
```
|
||||
|
||||
## 结构体注释
|
||||
|
||||
所有导出结构体必须有文档注释,说明该结构体代表什么:
|
||||
|
||||
```go
|
||||
// Service 账号业务服务
|
||||
// 负责账号的 CRUD、角色分配、密码管理等业务逻辑
|
||||
type Service struct {
|
||||
store *Store
|
||||
auditService AuditServiceInterface
|
||||
}
|
||||
```
|
||||
|
||||
## 接口注释
|
||||
|
||||
导出接口必须注释接口用途,每个方法必须说明契约:
|
||||
|
||||
```go
|
||||
// PermissionChecker 权限检查器接口
|
||||
// 用于查询用户的权限列表
|
||||
type PermissionChecker interface {
|
||||
// CheckPermission 检查用户是否拥有指定权限
|
||||
// userID: 用户ID
|
||||
// permCode: 权限编码(格式: module:action)
|
||||
// platform: 端口类型 (all/web/h5)
|
||||
CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error)
|
||||
}
|
||||
```
|
||||
|
||||
## 函数和方法注释
|
||||
|
||||
**导出函数/方法**必须以函数名开头,说明功能:
|
||||
|
||||
```go
|
||||
// Create 创建账号
|
||||
// POST /api/admin/accounts
|
||||
func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
||||
```
|
||||
|
||||
**复杂方法**(超过 30 行或包含复杂业务逻辑)必须额外说明实现思路:
|
||||
|
||||
```go
|
||||
// ActivateByRealname 首次实名激活套餐
|
||||
// 当用户完成实名认证后,自动激活处于"囤货待实名"状态的套餐:
|
||||
// 1. 查找该卡所有 status=3(待实名激活)的套餐
|
||||
// 2. 按创建时间排序,第一个主套餐立即激活(status=1)
|
||||
// 3. 其余主套餐进入排队状态(status=4)
|
||||
// 4. 加油包如果绑定了已激活的主套餐则一并激活
|
||||
func (s *UsageService) ActivateByRealname(ctx context.Context, cardID uint) error {
|
||||
```
|
||||
|
||||
**未导出函数/方法**:
|
||||
- 简单逻辑(< 15 行):可以不加注释
|
||||
- 复杂逻辑(≥ 15 行)或非显而易见的算法:必须加注释
|
||||
|
||||
```go
|
||||
// buildPermissionTree 递归构建权限树
|
||||
// 采用 map 索引 + 单次遍历算法,时间复杂度 O(n)
|
||||
func (s *Service) buildPermissionTree(permissions []*model.Permission) []*dto.PermissionTreeNode {
|
||||
```
|
||||
|
||||
## 常量和枚举注释
|
||||
|
||||
分组常量必须有组注释,每个值必须有行内注释:
|
||||
|
||||
```go
|
||||
// 用户类型常量
|
||||
const (
|
||||
UserTypeSuperAdmin = 1 // 超级管理员
|
||||
UserTypePlatform = 2 // 平台用户
|
||||
UserTypeAgent = 3 // 代理账号
|
||||
UserTypeEnterprise = 4 // 企业账号
|
||||
)
|
||||
```
|
||||
|
||||
## 内联注释规范
|
||||
|
||||
**必须添加内联注释的场景**:
|
||||
|
||||
| 场景 | 要求 |
|
||||
|------|------|
|
||||
| 复杂条件判断 | 解释判断的业务含义 |
|
||||
| 多步骤业务流程 | 用编号注释标明每一步 |
|
||||
| 非显而易见的设计决策 | 解释"为什么这样做"而不是"做了什么" |
|
||||
| 缓存/事务/并发处理 | 说明策略和原因 |
|
||||
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
|
||||
|
||||
**✅ 好的内联注释(解释为什么)**:
|
||||
|
||||
```go
|
||||
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
|
||||
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
|
||||
return errors.New(errors.CodeTooManyRequests, "操作过于频繁,请稍后重试")
|
||||
}
|
||||
|
||||
// 先冻结佣金再扣款,保证资金安全(失败时佣金自动解冻)
|
||||
if err := s.freezeCommission(ctx, tx, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
**❌ 废话注释(禁止)**:
|
||||
|
||||
```go
|
||||
// 获取用户ID ← 禁止:代码本身已经很清楚
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
|
||||
// 创建账号 ← 禁止:变量名已说明意图
|
||||
account := &model.Account{}
|
||||
|
||||
// 返回错误 ← 禁止:return err 不需要注释
|
||||
return err
|
||||
```
|
||||
1
.claude/skills/diagnose
Symbolic link
1
.claude/skills/diagnose
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/diagnose
|
||||
@@ -14,6 +14,8 @@ description: DTO 数据传输对象规范。创建或修改 DTO 文件、请求/
|
||||
- 创建 `XXXRequest`、`XXXResponse`、`XXXReq`、`XXXResp` 结构体
|
||||
- 添加或修改 API 接口的输入输出参数
|
||||
|
||||
---
|
||||
|
||||
## 必须项(MUST)
|
||||
|
||||
### 1. Description 标签规范
|
||||
@@ -36,59 +38,150 @@ type CreateUserRequest struct {
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 枚举字段必须列出所有可能值(中文)
|
||||
### 2. 枚举字段:int vs string 选择
|
||||
|
||||
**所有枚举类型字段必须在 `description` 中列出所有可能值和对应的中文含义**
|
||||
**必须按以下规则选择类型,禁止混用:**
|
||||
|
||||
| 场景 | 类型 | 示例 |
|
||||
|------|------|------|
|
||||
| 状态类(生命周期阶段) | `int` | 待支付→已完成→已关闭 |
|
||||
| 布尔状态(启用/禁用) | `int` | `0=禁用, 1=启用` |
|
||||
| 类型/方式类(种类) | `string` | `"wechat"`, `"single_card"` |
|
||||
| 平台/标识符类 | `string` | `"web"`, `"h5"`, `"all"` |
|
||||
|
||||
```go
|
||||
// 用户类型
|
||||
UserType int `json:"user_type" description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"`
|
||||
// ✅ 状态 → int
|
||||
Status int `json:"status"`
|
||||
PaymentStatus int `json:"payment_status"`
|
||||
|
||||
// 角色类型
|
||||
RoleType int `json:"role_type" description:"角色类型 (1:平台角色, 2:客户角色)"`
|
||||
|
||||
// 权限类型
|
||||
PermType int `json:"perm_type" description:"权限类型 (1:菜单, 2:按钮)"`
|
||||
|
||||
// 状态字段
|
||||
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
|
||||
|
||||
// 适用端口
|
||||
Platform string `json:"platform" description:"适用端口 (all:全部, web:Web后台, h5:H5端)"`
|
||||
// ✅ 类型/方式 → string
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat offline"`
|
||||
OrderType string `json:"order_type" validate:"required,oneof=single_card device"`
|
||||
```
|
||||
|
||||
❌ **禁止使用英文枚举值**:
|
||||
### 3. Int 状态值约定
|
||||
|
||||
#### 3.1 通用禁用/启用
|
||||
|
||||
**必须用全局常量,禁止自定义(尤其禁止 1=启用 2=禁用 这种反向写法)**:
|
||||
|
||||
```go
|
||||
UserType int `json:"user_type" description:"用户类型 (1:SuperAdmin, 2:Platform)"` // 错误!
|
||||
// pkg/constants/constants.go 已定义,直接使用
|
||||
StatusDisabled = 0 // 禁用
|
||||
StatusEnabled = 1 // 启用
|
||||
```
|
||||
|
||||
### 3. 验证标签与 OpenAPI 标签一致
|
||||
✅ 正确:`description:"状态 (0:禁用, 1:启用)"`
|
||||
❌ 禁止:`description:"状态 (1:启用, 2:禁用)"`
|
||||
|
||||
**所有验证约束必须同时在 `validate` 和 OpenAPI 标签中声明**
|
||||
#### 3.2 生命周期状态
|
||||
|
||||
从 **1** 开始递增,0 不使用(避免与 Go 零值混淆):
|
||||
|
||||
```go
|
||||
const (
|
||||
RechargeStatusPending = 1 // 待支付
|
||||
RechargeStatusPaid = 2 // 已支付
|
||||
RechargeStatusCompleted = 3 // 已完成
|
||||
RechargeStatusClosed = 4 // 已关闭
|
||||
)
|
||||
```
|
||||
|
||||
### 4. 枚举列表必须从 constants 原文抄写
|
||||
|
||||
**DTO description 的枚举列表必须与 `pkg/constants/` 定义完全一致,不可凭记忆填写。**
|
||||
|
||||
操作步骤:
|
||||
1. 先查/定义 `pkg/constants/` 中的枚举常量
|
||||
2. 将常量注释**原文抄写**到 description
|
||||
|
||||
```go
|
||||
// constants.go 中:
|
||||
RechargeStatusPending = 1 // 待支付
|
||||
RechargeStatusPaid = 2 // 已支付
|
||||
RechargeStatusCompleted = 3 // 已完成
|
||||
RechargeStatusClosed = 4 // 已关闭
|
||||
RechargeStatusRefunded = 5 // 已退款
|
||||
|
||||
// DTO description 从上面抄:
|
||||
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)"`
|
||||
```
|
||||
|
||||
❌ 禁止(description 与 constants 不一致,是历史 bug 的根因):
|
||||
```go
|
||||
// constants 说 3=已完成,description 却写 3:已取消
|
||||
Status int `json:"status" description:"状态 (1:待支付, 2:已完成, 3:已取消)"`
|
||||
```
|
||||
|
||||
### 5. description 格式标准
|
||||
|
||||
**统一格式**:`字段含义 (值1:中文含义1, 值2:中文含义2)`
|
||||
|
||||
- 值与含义之间用**冒号** `:`(禁止用等号 `=`)
|
||||
- 多个值之间用**逗号加空格** `, `
|
||||
- 含义必须是**中文**
|
||||
|
||||
```go
|
||||
// ✅ 统一格式
|
||||
Status int `description:"状态 (1:待支付, 2:已支付, 3:已完成)"`
|
||||
Platform string `description:"适用端口 (all:全部, web:Web后台, h5:H5端)"`
|
||||
|
||||
// ❌ 格式混乱
|
||||
Status int `description:"状态 (0=禁用, 1=启用)"` // 用等号
|
||||
Status int `description:"0=禁用 1=启用"` // 无括号无逗号
|
||||
```
|
||||
|
||||
### 6. Response DTO 的状态字段必须同时返回 int 和 text
|
||||
|
||||
**所有 Response DTO 中的 int 状态字段,必须同时提供对应的 `_name` 文字字段。**
|
||||
|
||||
原因:防止前端维护映射表出错(历史上已有因此产生 bug 的案例)。
|
||||
|
||||
```go
|
||||
// ✅ Response DTO 标准写法
|
||||
type XxxResponse struct {
|
||||
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
}
|
||||
|
||||
// toResponse 函数中赋值
|
||||
func rechargeStatusName(status int) string {
|
||||
switch status {
|
||||
case constants.RechargeStatusPending:
|
||||
return "待支付"
|
||||
case constants.RechargeStatusCompleted:
|
||||
return "已完成"
|
||||
// ...
|
||||
default:
|
||||
return "未知"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
字段命名约定:`status` → `status_name`,`payment_status` → `payment_status_name`
|
||||
|
||||
**例外**:Request DTO(查询过滤、创建请求)不需要 `_name` 字段。
|
||||
|
||||
### 7. 验证标签与 OpenAPI 标签一致
|
||||
|
||||
```go
|
||||
Username string `json:"username" validate:"required,min=3,max=50" required:"true" minLength:"3" maxLength:"50" description:"用户名"`
|
||||
```
|
||||
|
||||
**标签对照表**:
|
||||
| validate 标签 | OpenAPI 标签 |
|
||||
|--------------|--------------|
|
||||
| `required` | `required:"true"` |
|
||||
| `min=N,max=M`(数值) | `minimum:"N" maximum:"M"` |
|
||||
| `min=N,max=M`(字符串) | `minLength:"N" maxLength:"M"` |
|
||||
| `oneof=A B C` | description 中说明枚举值 |
|
||||
|
||||
| validate 标签 | OpenAPI 标签 | 说明 |
|
||||
|--------------|--------------|------|
|
||||
| `required` | `required:"true"` | 必填字段 |
|
||||
| `min=N,max=M` | `minimum:"N" maximum:"M"` | 数值范围 |
|
||||
| `min=N,max=M` (字符串) | `minLength:"N" maxLength:"M"` | 字符串长度 |
|
||||
| `len=N` | `minLength:"N" maxLength:"N"` | 固定长度 |
|
||||
| `oneof=A B C` | `description` 中说明 | 枚举值 |
|
||||
|
||||
### 4. 请求参数类型标签
|
||||
|
||||
**Query 参数和 Path 参数必须添加对应标签**
|
||||
### 8. 请求参数类型标签
|
||||
|
||||
```go
|
||||
// Query 参数
|
||||
type ListRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
UserType *int `json:"user_type" query:"user_type" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"`
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭)"`
|
||||
}
|
||||
|
||||
// Path 参数
|
||||
@@ -97,43 +190,50 @@ type IDReq struct {
|
||||
}
|
||||
```
|
||||
|
||||
### 5. 响应 DTO 完整性
|
||||
|
||||
**所有响应 DTO 的字段都必须有完整的 `description` 标签**
|
||||
### 9. 响应 DTO 完整性
|
||||
|
||||
```go
|
||||
type AccountResponse struct {
|
||||
ID uint `json:"id" description:"账号ID"`
|
||||
Username string `json:"username" description:"用户名"`
|
||||
UserType int `json:"user_type" description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"`
|
||||
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
ID uint `json:"id" description:"账号ID"`
|
||||
Username string `json:"username" description:"用户名"`
|
||||
UserType int `json:"user_type" description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"`
|
||||
Status int `json:"status" description:"状态 (0:禁用, 1:启用)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## AI 助手必须执行的检查
|
||||
|
||||
**在创建或修改任何 DTO 文件后,必须执行以下检查:**
|
||||
|
||||
1. ✅ 检查所有字段是否有 `description` 标签
|
||||
2. ✅ 检查枚举字段是否列出了所有可能值(中文)
|
||||
3. ✅ 检查状态字段是否说明了 0 和 1 的含义
|
||||
4. ✅ 检查 validate 标签与 OpenAPI 标签是否一致
|
||||
5. ✅ 检查是否禁止使用行内注释替代 description
|
||||
6. ✅ 检查枚举值是否使用中文而非英文
|
||||
7. ✅ 重新生成 OpenAPI 文档验证:`go run cmd/gendocs/main.go`
|
||||
1. ✅ 所有字段有 `description` 标签(无行内注释)
|
||||
2. ✅ 枚举类型选择正确(状态用 int,类型/方式用 string)
|
||||
3. ✅ 禁用/启用使用 `0=禁用, 1=启用`(禁止 1=启用 2=禁用)
|
||||
4. ✅ description 枚举列表已从 `pkg/constants/` 原文抄写,无遗漏
|
||||
5. ✅ description 格式统一(冒号 `:`,括号,逗号)
|
||||
6. ✅ Response DTO 有 `_name` 伴生字段
|
||||
7. ✅ validate 标签与 OpenAPI 标签一致
|
||||
8. ✅ 重新生成 OpenAPI 文档验证:`go run cmd/gendocs/main.go`
|
||||
|
||||
**详细检查清单**: 参见 `docs/code-review-checklist.md`
|
||||
**完整枚举规范**: 参见 [`docs/enum-status-standards.md`](../../docs/enum-status-standards.md)
|
||||
|
||||
---
|
||||
|
||||
## 常见枚举字段标准值
|
||||
|
||||
```go
|
||||
// 用户类型
|
||||
// 用户类型(从 constants.UserType* 抄)
|
||||
description:"用户类型 (1:超级管理员, 2:平台用户, 3:代理账号, 4:企业账号)"
|
||||
|
||||
// 角色类型
|
||||
description:"角色类型 (1:平台角色, 2:客户角色)"
|
||||
// 通用启用/禁用(从 constants.StatusEnabled/Disabled 抄)
|
||||
description:"状态 (0:禁用, 1:启用)"
|
||||
|
||||
// 充值状态(从 constants.RechargeStatus* 抄)
|
||||
description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)"
|
||||
|
||||
// 权限类型
|
||||
description:"权限类型 (1:菜单, 2:按钮)"
|
||||
@@ -141,9 +241,6 @@ description:"权限类型 (1:菜单, 2:按钮)"
|
||||
// 适用端口
|
||||
description:"适用端口 (all:全部, web:Web后台, h5:H5端)"
|
||||
|
||||
// 状态
|
||||
description:"状态 (0:禁用, 1:启用)"
|
||||
|
||||
// 店铺层级
|
||||
description:"店铺层级 (1-7级)"
|
||||
```
|
||||
|
||||
1
.claude/skills/grill-me
Symbolic link
1
.claude/skills/grill-me
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/grill-me
|
||||
1
.claude/skills/grill-with-docs
Symbolic link
1
.claude/skills/grill-with-docs
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/grill-with-docs
|
||||
1
.claude/skills/handoff
Symbolic link
1
.claude/skills/handoff
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/handoff
|
||||
777
.claude/skills/hurl-test/SKILL.md
Normal file
777
.claude/skills/hurl-test/SKILL.md
Normal file
@@ -0,0 +1,777 @@
|
||||
---
|
||||
name: hurl-test
|
||||
description: Hurl 接口测试生成器。用户描述要测试的接口或业务流程,自动探索代码、确认需求、生成完整的 .hurl 测试文件(含 DTO 驱动的字段完整性断言)。触发词:测试、hurl、写测试、接口测试。
|
||||
---
|
||||
|
||||
# Hurl 接口测试生成器
|
||||
|
||||
**用户描述要测试什么,你来读代码、问确认、生成 .hurl 文件。**
|
||||
|
||||
适用于任何后端项目(Go / Python / Node / Java 等),不预设框架和目录结构。
|
||||
|
||||
---
|
||||
|
||||
## 触发条件
|
||||
|
||||
以下情况必须使用本 Skill:
|
||||
- 用户说"测试 XX 接口"、"写 hurl 测试"、"给 XX 加测试"
|
||||
- 用户说"测试 XX 流程"、"测试 XX 的业务逻辑"
|
||||
- 用户说"验证 XX 接口的字段"、"测试接口契约"
|
||||
- 用户提到 hurl、.hurl、接口测试、集成测试、冒烟测试
|
||||
|
||||
---
|
||||
|
||||
## 四阶段工作流(必须按顺序执行)
|
||||
|
||||
```
|
||||
Phase 1: 探索 → Phase 2: 确认 → Phase 3: 生成 → Phase 4: 验证
|
||||
读代码搞清楚 展示给用户确认 输出 .hurl 文件 语法检查 + 试跑
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: 探索(Explore)
|
||||
|
||||
**目标:读代码,搞清楚项目约定 + 涉及的接口 + 字段 + 依赖。**
|
||||
|
||||
#### 1.1 项目画像(首次使用时必须执行,后续复用)
|
||||
|
||||
首次为项目生成 Hurl 测试时,先回答以下问题(通过读代码,不要猜):
|
||||
|
||||
| 问题 | 怎么找 |
|
||||
|------|--------|
|
||||
| **语言/框架** | 看 go.mod / package.json / requirements.txt / pom.xml |
|
||||
| **路由注册在哪** | 搜索 `router`、`app.Get`、`@GetMapping`、`@app.route` 等关键词 |
|
||||
| **请求/响应 schema 定义在哪** | 搜索 DTO / schema / serializer / model 目录,看 json tag 或装饰器 |
|
||||
| **统一响应格式是什么** | 找 response helper 文件(如 `response.go`、`response.py`),记录 JSON 结构 |
|
||||
| **认证方式是什么** | 找 auth middleware,确定是 Bearer Token / Cookie / API Key / Basic Auth |
|
||||
| **登录接口是什么** | 找登录 handler,记录路径、请求体、响应中 token 的位置 |
|
||||
| **分页格式是什么** | 找列表接口的响应结构,记录 items/total/page 等字段名 |
|
||||
| **已有 hurl 测试吗** | 搜索 `*.hurl` 文件,复用已有的约定 |
|
||||
|
||||
将画像结果**写入 `tests/hurl/.project-profile.md` 文件持久化保存**。
|
||||
|
||||
#### 画像持久化(关键机制)
|
||||
|
||||
**首次使用时**:完成 1.1 探索后,将画像写入 `tests/hurl/.project-profile.md`,格式如下:
|
||||
|
||||
```markdown
|
||||
# 项目画像(Hurl 测试自动生成用)
|
||||
<!-- 由 hurl-test skill 自动生成,请勿手动修改 -->
|
||||
<!-- 如需刷新,删除此文件后重新运行 skill -->
|
||||
|
||||
## 技术栈
|
||||
- 语言: Go 1.25
|
||||
- 框架: Fiber v2
|
||||
- ORM: GORM
|
||||
|
||||
## 路由定义位置
|
||||
- 路由注册入口: internal/routes/routes.go
|
||||
- 按模块拆分: internal/routes/{module}.go
|
||||
- 路由注册函数: Register(router, doc, basePath, method, path, handler, spec)
|
||||
|
||||
## Schema 定义位置
|
||||
- DTO 目录: internal/model/dto/
|
||||
- 命名规则: {module}_dto.go
|
||||
- 字段标签: json / validate / description
|
||||
|
||||
## 统一响应格式
|
||||
{code: int, msg: string, data: any, timestamp: string(RFC3339)}
|
||||
- 成功: code=0, msg="success"
|
||||
- 错误: code!=0
|
||||
|
||||
## 分页格式
|
||||
{items: [], total: int, page: int, size: int}
|
||||
- 包裹在 data 字段内: $.data.items / $.data.total
|
||||
|
||||
## 认证方式
|
||||
- 后台: POST /api/auth/admin-login → $.data.access_token → Authorization: Bearer {token}
|
||||
- C端: JWT → Authorization: Bearer {token}
|
||||
|
||||
## 默认测试账号
|
||||
- 用户名: admin
|
||||
- 密码: Admin@123456
|
||||
|
||||
## 服务端口
|
||||
- 默认: 3000
|
||||
```
|
||||
|
||||
**后续使用时**:检查 `tests/hurl/.project-profile.md` 是否存在:
|
||||
- **存在** → 直接读取,跳过 1.1 的探索步骤,节省时间
|
||||
- **不存在** → 执行 1.1 完整探索,然后生成此文件
|
||||
- **用户说"刷新画像"** → 删除旧文件,重新执行 1.1
|
||||
|
||||
#### 1.2 找接口定义
|
||||
|
||||
根据用户要测的模块,定位路由注册代码,提取:
|
||||
|
||||
- **HTTP 方法**(GET / POST / PUT / DELETE / PATCH)
|
||||
- **路由路径**(含路径参数格式,如 `/users/:id` 或 `/users/{id}`)
|
||||
- **接口说明**(注释、Summary、装饰器描述)
|
||||
- **是否需要认证**
|
||||
- **请求 schema 类型名**(Input / Request DTO)
|
||||
- **响应 schema 类型名**(Output / Response DTO)
|
||||
|
||||
#### 1.3 读 schema 定义(DTO / struct / class / type)
|
||||
|
||||
定位请求和响应的 schema 定义文件,提取每个字段的:
|
||||
|
||||
- **字段名**:JSON 序列化后的名称(json tag / @JsonProperty / serializer field)
|
||||
- **语言类型**:string / int / bool / 数组 / 嵌套对象 / 可空等
|
||||
- **是否必填**:validate tag / required 装饰器 / 非空标注
|
||||
- **是否可空**:指针类型 / Optional / nullable
|
||||
- **是否参与序列化**:`json:"-"` / @JsonIgnore / exclude
|
||||
- **是否 omitempty**:`json:",omitempty"` / 条件序列化
|
||||
- **字段描述**:description tag / docstring / 注释
|
||||
|
||||
#### 1.4 识别业务依赖
|
||||
|
||||
读 service / business logic 层,识别:
|
||||
|
||||
- 创建操作需要哪些前置数据(如创建订单需要先有商品和用户)
|
||||
- 是否有唯一性约束(如用户名不能重复)
|
||||
- 是否依赖外部服务(支付网关、短信、OAuth 等)
|
||||
- 业务流转逻辑(状态机、级联操作)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 确认(Clarify)
|
||||
|
||||
**目标:向用户展示发现的内容,确认模糊点。不要闷头生成。**
|
||||
|
||||
#### 2.1 必须展示的内容
|
||||
|
||||
```
|
||||
我梳理了相关代码,发现以下信息:
|
||||
|
||||
📋 涉及接口:
|
||||
- [方法] [路径] - [说明](认证: 是/否)
|
||||
- ...
|
||||
|
||||
📦 响应字段(基于 {SchemaName}):
|
||||
- [字段名]: [类型] - [说明]
|
||||
- ...(共 N 个字段,将全部生成断言)
|
||||
|
||||
🔗 依赖关系:
|
||||
- [创建 X 需要先创建 Y]
|
||||
- ...
|
||||
|
||||
⚠️ 特殊情况:
|
||||
- [涉及外部服务 / 文件上传 / 特殊认证等]
|
||||
```
|
||||
|
||||
#### 2.2 按需确认(只问有歧义的)
|
||||
|
||||
| 场景 | 要问的 |
|
||||
|------|--------|
|
||||
| 流程范围不明确 | "要测到哪一步?" |
|
||||
| 多种用户角色 | "用哪种身份测?" |
|
||||
| 是否测异常 | "需要包含异常 case 吗?(参数校验失败、权限不足等)" |
|
||||
| 是否测数据隔离 | "需要验证不同用户间数据不可见吗?" |
|
||||
| 涉及第三方 | "XX 部分怎么处理?绕过 / 模拟回调 / 跳过?" |
|
||||
| 前置数据来源 | "XX 依赖数据是通过 API 创建还是假设已存在?" |
|
||||
|
||||
**如果用户说"越完整越好"或"都要"→ 默认全部包含,不再追问。**
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 生成(Generate)
|
||||
|
||||
**目标:生成完整的 .hurl 文件,字段断言基于 schema 代码,不能编造。**
|
||||
|
||||
#### 3.1 文件头注释
|
||||
|
||||
```hurl
|
||||
# ============================================================
|
||||
# 测试:{测试名称}
|
||||
# 生成时间:{日期}
|
||||
# 涉及模块:{module1, module2, ...}
|
||||
# 涉及接口:{N} 个
|
||||
# 断言数量:{N} 条
|
||||
# 前置条件:{服务运行 + 必要的前置条件}
|
||||
# ============================================================
|
||||
# 流程:
|
||||
# 1. {步骤描述}
|
||||
# 2. {步骤描述}
|
||||
# ...
|
||||
# ============================================================
|
||||
```
|
||||
|
||||
#### 3.2 请求生成规则
|
||||
|
||||
**认证**:
|
||||
|
||||
- 根据 Phase 1 画像中的登录接口和 token 位置生成
|
||||
- token 必须通过 `[Captures]` 捕获,后续请求引用
|
||||
- 如果是 Cookie 认证,用 `[Cookies]` 或 cookie capture
|
||||
|
||||
**CRUD 标准模式**:
|
||||
|
||||
| 操作 | 生成要求 |
|
||||
|------|---------|
|
||||
| **创建(POST)** | capture 返回的 ID;唯一字段用 `{{newUuid}}` 防冲突 |
|
||||
| **查询详情(GET)** | **逐字段断言**(类型 + 值,见 3.3) |
|
||||
| **查询列表(GET)** | 分页结构断言 + items[0] 逐字段断言 |
|
||||
| **修改(PUT/PATCH)** | 修改后**紧跟一个 GET 验证修改生效** |
|
||||
| **删除(DELETE)** | 删除后**紧跟一个 GET 验证已删除** |
|
||||
|
||||
**业务流程模式**:
|
||||
|
||||
- 按用户描述的流程顺序编排请求
|
||||
- 上一步的输出(ID、状态等)通过 `[Captures]` 传给下一步
|
||||
- 关键步骤加中间状态验证(如创建订单后验证状态为"待支付")
|
||||
|
||||
#### 3.3 schema 到断言的映射
|
||||
|
||||
读到 schema 字段后,按以下规则生成 jsonpath 断言:
|
||||
|
||||
**通用类型映射(所有语言)**:
|
||||
|
||||
| Schema 类型特征 | Hurl 断言 |
|
||||
|----------------|-----------|
|
||||
| 字符串(string / str / String) | `isString` |
|
||||
| 整数(int / integer / long / Int) | `isInteger` |
|
||||
| 浮点(float / double / decimal / Float) | `isNumber` |
|
||||
| 布尔(bool / boolean / Boolean) | `isBoolean` |
|
||||
| 数组 / 列表([] / List / Array) | `isList` |
|
||||
| 嵌套对象(struct / class / dict / object) | `isObject`,并递归检查子字段 |
|
||||
| 可空类型(指针 / Optional / nullable) | `exists`(不强制类型,因为可能是 null) |
|
||||
| 不参与序列化(json:"-" / @JsonIgnore / exclude=True) | **跳过,不生成断言** |
|
||||
| 条件序列化(omitempty / if not None) | `exists` 或不生成(取决于场景) |
|
||||
|
||||
**Go 特定映射**:
|
||||
|
||||
| Go 类型 | Hurl 断言 |
|
||||
|---------|-----------|
|
||||
| `string` | `isString` |
|
||||
| `int`, `int8/16/32/64`, `uint`, `uint8/16/32/64` | `isInteger` |
|
||||
| `float32`, `float64` | `isNumber` |
|
||||
| `bool` | `isBoolean` |
|
||||
| `[]T` | `isList` |
|
||||
| `*string`, `*int`, `*uint` 等指针 | `exists` |
|
||||
| `time.Time` | `isString`(通常序列化为字符串) |
|
||||
| `map[string]any` | `isObject` |
|
||||
|
||||
**Python 特定映射(Pydantic / Django / FastAPI)**:
|
||||
|
||||
| Python 类型 | Hurl 断言 |
|
||||
|------------|-----------|
|
||||
| `str` | `isString` |
|
||||
| `int` | `isInteger` |
|
||||
| `float`, `Decimal` | `isNumber` |
|
||||
| `bool` | `isBoolean` |
|
||||
| `list[T]`, `List[T]` | `isList` |
|
||||
| `Optional[T]`, `T | None` | `exists` |
|
||||
| `dict`, `Dict` | `isObject` |
|
||||
| `datetime`, `date` | `isString` |
|
||||
|
||||
**TypeScript/JavaScript 特定映射**:
|
||||
|
||||
| TS/JS 类型 | Hurl 断言 |
|
||||
|-----------|-----------|
|
||||
| `string` | `isString` |
|
||||
| `number`(整数上下文) | `isInteger` |
|
||||
| `number`(通用) | `isNumber` |
|
||||
| `boolean` | `isBoolean` |
|
||||
| `T[]`, `Array<T>` | `isList` |
|
||||
| `T \| null`, `T \| undefined` | `exists` |
|
||||
| `object`, `Record<>` | `isObject` |
|
||||
| `Date` | `isString` |
|
||||
|
||||
**Java 特定映射**:
|
||||
|
||||
| Java 类型 | Hurl 断言 |
|
||||
|----------|-----------|
|
||||
| `String` | `isString` |
|
||||
| `Integer`, `Long`, `int`, `long` | `isInteger` |
|
||||
| `Double`, `Float`, `BigDecimal` | `isNumber` |
|
||||
| `Boolean`, `boolean` | `isBoolean` |
|
||||
| `List<T>` | `isList` |
|
||||
| `@Nullable`, `Optional<T>` | `exists` |
|
||||
| `Map<K,V>` | `isObject` |
|
||||
| `LocalDateTime`, `Instant` | `isString` |
|
||||
|
||||
#### 3.4 统一响应格式断言
|
||||
|
||||
根据 Phase 1 画像中发现的统一响应格式,为**每个成功响应**添加格式断言。
|
||||
|
||||
示例:如果项目的统一格式是 `{code, msg, data, timestamp}`:
|
||||
|
||||
```hurl
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.msg" == "success"
|
||||
jsonpath "$.timestamp" isIsoDate
|
||||
```
|
||||
|
||||
示例:如果项目的格式是 `{status, message, result}`:
|
||||
|
||||
```hurl
|
||||
[Asserts]
|
||||
jsonpath "$.status" == "ok"
|
||||
jsonpath "$.message" isString
|
||||
```
|
||||
|
||||
示例:如果项目无统一包装,直接返回数据:
|
||||
|
||||
```hurl
|
||||
[Asserts]
|
||||
# 直接断言业务字段
|
||||
jsonpath "$.id" isInteger
|
||||
jsonpath "$.name" isString
|
||||
```
|
||||
|
||||
**不要假设响应格式,必须从代码中确认。**
|
||||
|
||||
#### 3.5 分页断言
|
||||
|
||||
根据 Phase 1 画像中发现的分页结构生成。
|
||||
|
||||
示例:如果是 `{items, total, page, size}` 格式:
|
||||
|
||||
```hurl
|
||||
jsonpath "$.data.items" isList
|
||||
jsonpath "$.data.total" isInteger
|
||||
jsonpath "$.data.total" >= 1
|
||||
jsonpath "$.data.page" isInteger
|
||||
jsonpath "$.data.size" isInteger
|
||||
# items 内元素逐字段断言
|
||||
jsonpath "$.data.items[0].{field}" {type_assert}
|
||||
```
|
||||
|
||||
示例:如果是 `{results, count, next, previous}` 格式(Django 风格):
|
||||
|
||||
```hurl
|
||||
jsonpath "$.results" isList
|
||||
jsonpath "$.count" isInteger
|
||||
jsonpath "$.count" >= 1
|
||||
# results 内元素逐字段断言
|
||||
jsonpath "$.results[0].{field}" {type_assert}
|
||||
```
|
||||
|
||||
**根据实际代码调整字段名,不硬编码。**
|
||||
|
||||
#### 3.6 异常 Case 模板
|
||||
|
||||
**参数校验失败**:
|
||||
|
||||
```hurl
|
||||
# ── 异常:参数校验失败 ──
|
||||
POST {{base_url}}/{path}
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"required_field": ""
|
||||
}
|
||||
HTTP {expected_error_status}
|
||||
[Asserts]
|
||||
# 断言错误响应格式(根据项目约定调整)
|
||||
```
|
||||
|
||||
> HTTP 状态码根据项目实际返回确定:有的项目错误也返回 200 + 业务错误码,有的返回 400/422。
|
||||
|
||||
**未认证访问**:
|
||||
|
||||
```hurl
|
||||
# ── 异常:未认证访问 ──
|
||||
GET {{base_url}}/{protected_path}
|
||||
HTTP {expected_unauth_status}
|
||||
```
|
||||
|
||||
**越权访问**(如果用户要求):
|
||||
|
||||
```hurl
|
||||
# ── 异常:用户 B 不能访问用户 A 的资源 ──
|
||||
GET {{base_url}}/{path}/{{user_a_resource_id}}
|
||||
Authorization: Bearer {{user_b_token}}
|
||||
HTTP {expected_forbidden_status}
|
||||
```
|
||||
|
||||
#### 3.7 特殊场景处理
|
||||
|
||||
| 场景 | 处理策略 |
|
||||
|------|---------|
|
||||
| **短信/邮件验证码** | 建议服务端加 test_mode 开关,固定验证码写入 env 文件;注释提醒用户 |
|
||||
| **第三方支付** | 优先用项目内部支付方式(如钱包支付);如需测回调,直接 POST 回调接口模拟 |
|
||||
| **OAuth 登录(微信/Google/GitHub)** | 建议服务端加 test_mode 支持直接传 openid/email;注释提醒用户 |
|
||||
| **文件上传** | 用 Hurl 的 `[Multipart]` 语法 + testdata 目录下的样本文件 |
|
||||
| **外部 API 依赖** | 只测参数校验和错误响应格式,不断言业务结果;注释说明依赖 |
|
||||
| **WebSocket** | Hurl 不支持,注释说明跳过 |
|
||||
| **异步任务结果** | 用 Hurl 的 `retry` + `retry-interval` 轮询直到状态变更 |
|
||||
|
||||
异步轮询示例:
|
||||
|
||||
```hurl
|
||||
# 等待异步任务完成(最多重试 10 次,间隔 500ms)
|
||||
GET {{base_url}}/{path}/{{task_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
[Options]
|
||||
retry: 10
|
||||
retry-interval: 500ms
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.data.status" == "completed"
|
||||
```
|
||||
|
||||
#### 3.8 文件输出
|
||||
|
||||
**目录结构**(首次使用时创建,如不存在):
|
||||
|
||||
```
|
||||
tests/hurl/
|
||||
├── env/
|
||||
│ └── dev.env # 环境变量
|
||||
├── testdata/ # 测试用的样本文件
|
||||
├── flows/ # 业务流程测试
|
||||
├── modules/ # 按模块的接口测试
|
||||
│ └── {module}/
|
||||
│ └── 01-crud.hurl
|
||||
├── negative/ # 异常/边界测试
|
||||
├── contract/ # 接口契约验证
|
||||
└── Makefile # 快捷命令
|
||||
```
|
||||
|
||||
文件放置规则:
|
||||
|
||||
| 用户描述 | 输出路径 |
|
||||
|---------|---------|
|
||||
| 测试 XX 流程 / 业务流程 | `tests/hurl/flows/{flow-name}.hurl` |
|
||||
| 测试 XX 模块的 CRUD / 接口 | `tests/hurl/modules/{module}/01-crud.hurl` |
|
||||
| 测试异常/边界/权限 | `tests/hurl/negative/{name}.hurl` |
|
||||
| 测试接口契约/字段对齐 | `tests/hurl/contract/{name}.hurl` |
|
||||
|
||||
**如果项目已有 hurl 测试目录结构,沿用已有约定,不要另起炉灶。**
|
||||
|
||||
#### 3.9 env 和 Makefile
|
||||
|
||||
**env/dev.env**(首次创建时生成,内容基于 Phase 1 画像):
|
||||
|
||||
```properties
|
||||
# 服务地址
|
||||
base_url=http://localhost:{port}
|
||||
|
||||
# 认证信息(根据项目实际填写)
|
||||
admin_username={默认用户名}
|
||||
admin_password={默认密码}
|
||||
|
||||
# 测试模式变量(如果有特殊场景)
|
||||
# test_sms_code=888888
|
||||
# test_openid=test_openid_001
|
||||
```
|
||||
|
||||
**Makefile**(首次创建时生成):
|
||||
|
||||
```makefile
|
||||
SHELL := /bin/bash
|
||||
ENV ?= dev
|
||||
HURL_OPTS := --variables-file env/$(ENV).env --test
|
||||
|
||||
.PHONY: test test-flows test-modules test-negative report
|
||||
|
||||
test: ## 运行所有测试
|
||||
hurl $(HURL_OPTS) .
|
||||
|
||||
test-flows: ## 运行业务流程测试
|
||||
hurl $(HURL_OPTS) flows/
|
||||
|
||||
test-modules: ## 运行模块接口测试
|
||||
hurl $(HURL_OPTS) modules/
|
||||
|
||||
test-negative: ## 运行异常测试
|
||||
hurl $(HURL_OPTS) negative/
|
||||
|
||||
report: ## 生成 HTML 报告
|
||||
hurl $(HURL_OPTS) --report-html build/report/ .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 验证(Verify)
|
||||
|
||||
**目标:确保生成的 .hurl 文件语法正确、可运行。**
|
||||
|
||||
#### 4.1 语法自检
|
||||
|
||||
- [ ] 每个请求之间有空行分隔
|
||||
- [ ] `[Captures]` 和 `[Asserts]` 拼写正确(大小写敏感)
|
||||
- [ ] 所有 `{{变量}}` 引用都有来源(env 文件定义 或 上游 `[Captures]`)
|
||||
- [ ] JSON body 无尾逗号、格式正确
|
||||
- [ ] `HTTP {status}` 在请求之后、`[Captures]` / `[Asserts]` 之前
|
||||
- [ ] 请求和断言之间没有多余空行(`HTTP` 行必须紧跟请求)
|
||||
- [ ] 文件上传路径相对于 hurl 文件位置正确
|
||||
|
||||
#### 4.2 运行测试
|
||||
|
||||
```bash
|
||||
hurl --variables-file tests/hurl/env/dev.env --test tests/hurl/{生成的文件}
|
||||
```
|
||||
|
||||
- 服务在跑 → 执行,如有失败分析修正
|
||||
- 服务没跑 → 跳过,告知用户手动验证命令
|
||||
|
||||
---
|
||||
|
||||
## 红线规则
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| **不跳过 Phase 1** | 必须读代码确认接口路径和字段,不能凭记忆或猜测 |
|
||||
| **不跳过 Phase 2** | 必须向用户展示发现的接口和字段,确认后再生成 |
|
||||
| **不编造字段** | 所有断言的字段名必须来自实际 schema 代码 |
|
||||
| **不编造路径** | 所有接口路径必须来自实际路由代码 |
|
||||
| **不遗漏字段** | schema 中每个参与序列化的字段都必须有对应断言 |
|
||||
| **不硬编码 ID** | 所有依赖的 ID 通过 `[Captures]` 从上游请求获取 |
|
||||
| **不假设响应格式** | 统一响应结构必须从代码中确认,不同项目格式不同 |
|
||||
| **唯一值防冲突** | 创建类请求的唯一字段使用 `{{newUuid}}` 或 `{{newDate}}` |
|
||||
| **自给自足** | 每个 .hurl 文件自己创建测试数据,不依赖外部数据准备 |
|
||||
|
||||
---
|
||||
|
||||
## AI 助手检查清单
|
||||
|
||||
生成 .hurl 文件后自检:
|
||||
|
||||
1. ✅ 文件头注释包含流程描述和前置条件
|
||||
2. ✅ 认证步骤正确 capture 了 token / cookie
|
||||
3. ✅ 所有依赖数据通过 API 链式创建(自给自足)
|
||||
4. ✅ 每个成功响应断言了项目的统一响应格式
|
||||
5. ✅ 查询详情接口**逐字段断言**(类型 + 值,基于 schema)
|
||||
6. ✅ 分页接口断言了分页结构 + 第一条记录的字段
|
||||
7. ✅ 修改操作后紧跟 GET 验证修改生效
|
||||
8. ✅ 删除操作后紧跟 GET 验证已删除
|
||||
9. ✅ 唯一字段使用了 `{{newUuid}}`
|
||||
10. ✅ `{{变量}}` 引用无悬空(都有 env 或 capture 来源)
|
||||
11. ✅ 文件放在了正确的目录位置
|
||||
12. ✅ 特殊场景有明确的处理策略和注释提醒
|
||||
|
||||
---
|
||||
|
||||
## 附录:Hurl 语法速查
|
||||
|
||||
**生成 .hurl 文件时必须参照本速查,不可凭记忆编造语法。**
|
||||
|
||||
### 文件结构
|
||||
|
||||
一个 .hurl 文件由多个 entry 组成,每个 entry = 请求 + 可选响应:
|
||||
|
||||
```
|
||||
请求1
|
||||
响应1(可选)
|
||||
|
||||
请求2
|
||||
响应2(可选)
|
||||
```
|
||||
|
||||
entry 之间用空行分隔。
|
||||
|
||||
### 请求格式
|
||||
|
||||
```hurl
|
||||
METHOD URL
|
||||
Header1: value1
|
||||
Header2: value2
|
||||
[Options]
|
||||
key: value
|
||||
[Query]
|
||||
param1: value1
|
||||
[Form]
|
||||
field1: value1
|
||||
[Multipart]
|
||||
file1: file,path/to/file;
|
||||
[BasicAuth]
|
||||
username: password
|
||||
[Cookies]
|
||||
name: value
|
||||
BODY(JSON / XML / multiline string / file)
|
||||
```
|
||||
|
||||
**规则**:
|
||||
- Method + URL 是第一行,必须
|
||||
- Headers 紧跟 URL 之后(无 section 标记)
|
||||
- Sections(`[Query]`、`[Form]`、`[Options]` 等)顺序任意
|
||||
- Body 必须在最后
|
||||
- JSON body 直接写 `{ }` 即可,自动设置 Content-Type: application/json
|
||||
|
||||
### 响应格式
|
||||
|
||||
```hurl
|
||||
HTTP {status_code}
|
||||
Header1: expected_value1
|
||||
[Captures]
|
||||
var_name: jsonpath "$.path"
|
||||
[Asserts]
|
||||
jsonpath "$.field" == "value"
|
||||
```
|
||||
|
||||
**规则**:
|
||||
- `HTTP {status}` 紧跟请求之后(中间不能有空行)
|
||||
- `HTTP *` 表示不检查状态码
|
||||
- Headers 检查紧跟 HTTP 行之后
|
||||
- `[Captures]` 和 `[Asserts]` 顺序任意
|
||||
|
||||
### 变量和模板
|
||||
|
||||
```hurl
|
||||
# 引用变量(从 env 文件、命令行或上游 capture 获取)
|
||||
GET {{base_url}}/api/users/{{user_id}}
|
||||
|
||||
# 内置函数
|
||||
POST {{base_url}}/api/users
|
||||
{
|
||||
"email": "{{newUuid}}@test.com",
|
||||
"created_at": "{{newDate}}"
|
||||
}
|
||||
```
|
||||
|
||||
可用函数:
|
||||
- `{{newUuid}}` — 生成 UUID v4
|
||||
- `{{newDate}}` — 生成 RFC 3339 UTC 时间戳
|
||||
|
||||
### Capture 语法
|
||||
|
||||
```hurl
|
||||
[Captures]
|
||||
# JSONPath
|
||||
token: jsonpath "$.data.access_token"
|
||||
user_id: jsonpath "$.data.id"
|
||||
first_item: jsonpath "$.items[0].name"
|
||||
|
||||
# Header
|
||||
location: header "Location"
|
||||
|
||||
# Cookie
|
||||
session: cookie "SESSIONID"
|
||||
|
||||
# Body(整个响应体作为字符串)
|
||||
full_body: body
|
||||
|
||||
# Status code
|
||||
code: status
|
||||
|
||||
# 正则表达式
|
||||
csrf: regex "name=\"csrf\" value=\"([^\"]+)\""
|
||||
|
||||
# 响应时间(毫秒)
|
||||
response_time: duration
|
||||
```
|
||||
|
||||
### Assert 语法
|
||||
|
||||
```hurl
|
||||
[Asserts]
|
||||
# ── 状态码 ──
|
||||
status == 200
|
||||
status >= 200
|
||||
status < 300
|
||||
|
||||
# ── JSONPath 断言 ──
|
||||
jsonpath "$.name" == "Alice" # 等于
|
||||
jsonpath "$.name" != "Bob" # 不等于
|
||||
jsonpath "$.age" > 18 # 大于
|
||||
jsonpath "$.age" >= 18 # 大于等于
|
||||
jsonpath "$.count" < 100 # 小于
|
||||
jsonpath "$.items" count == 5 # 集合长度
|
||||
jsonpath "$.name" startsWith "Al" # 前缀
|
||||
jsonpath "$.name" endsWith "ce" # 后缀
|
||||
jsonpath "$.name" contains "lic" # 包含
|
||||
jsonpath "$.date" matches /\\d{4}-\\d{2}-\\d{2}/ # 正则
|
||||
|
||||
# ── 类型断言 ──
|
||||
jsonpath "$.name" isString
|
||||
jsonpath "$.age" isInteger
|
||||
jsonpath "$.score" isFloat
|
||||
jsonpath "$.count" isNumber # 整数或浮点
|
||||
jsonpath "$.active" isBoolean
|
||||
jsonpath "$.items" isList
|
||||
jsonpath "$.meta" isObject
|
||||
jsonpath "$.id" isUuid
|
||||
jsonpath "$.created_at" isIsoDate # RFC 3339 格式
|
||||
jsonpath "$.field" isEmpty # 空集合
|
||||
|
||||
# ── 存在性 ──
|
||||
jsonpath "$.field" exists
|
||||
jsonpath "$.field" not exists
|
||||
|
||||
# ── 否定 ──
|
||||
jsonpath "$.name" not contains "Bob"
|
||||
jsonpath "$.status" not == "deleted"
|
||||
|
||||
# ── Header 断言 ──
|
||||
header "Content-Type" contains "application/json"
|
||||
header "X-Request-Id" exists
|
||||
|
||||
# ── 性能 ──
|
||||
duration < 1000 # 响应时间(毫秒)
|
||||
|
||||
# ── Body 断言 ──
|
||||
body contains "Hello"
|
||||
bytes count == 1024
|
||||
```
|
||||
|
||||
### Options(逐请求配置)
|
||||
|
||||
```hurl
|
||||
GET {{base_url}}/api/task/{{task_id}}
|
||||
[Options]
|
||||
retry: 10 # 最大重试次数(-1 = 无限)
|
||||
retry-interval: 500ms # 重试间隔
|
||||
delay: 2s # 请求前等待
|
||||
location: true # 跟随重定向
|
||||
insecure: true # 允许不安全 SSL
|
||||
verbose: true # 输出详细日志
|
||||
very-verbose: true # 输出更详细日志
|
||||
skip: true # 跳过此请求
|
||||
variable: key=value # 定义变量
|
||||
HTTP 200
|
||||
```
|
||||
|
||||
### Multipart 文件上传
|
||||
|
||||
```hurl
|
||||
POST {{base_url}}/api/upload
|
||||
[Multipart]
|
||||
file: file,testdata/sample.xlsx;
|
||||
field1: value1
|
||||
# 指定 Content-Type
|
||||
file2: file,testdata/data.bin; application/octet-stream
|
||||
```
|
||||
|
||||
### 运行命令
|
||||
|
||||
```bash
|
||||
# 运行单个文件
|
||||
hurl --test file.hurl
|
||||
|
||||
# 带变量文件
|
||||
hurl --variables-file env/dev.env --test file.hurl
|
||||
|
||||
# 运行目录下所有 .hurl
|
||||
hurl --test tests/hurl/
|
||||
|
||||
# 生成 HTML 报告
|
||||
hurl --test --report-html build/report/ tests/hurl/
|
||||
|
||||
# 生成 JUnit 报告(CI 用)
|
||||
hurl --test --report-junit build/report.xml tests/hurl/
|
||||
|
||||
# 并行执行(--test 默认并行,同文件内串行)
|
||||
hurl --test --jobs 4 tests/hurl/
|
||||
|
||||
# 指定单个变量
|
||||
hurl --variable base_url=http://localhost:3000 --test file.hurl
|
||||
|
||||
# 失败后继续执行
|
||||
hurl --test --continue-on-error tests/hurl/
|
||||
```
|
||||
|
||||
### 常见错误
|
||||
|
||||
| 错误 | 原因 | 修正 |
|
||||
|------|------|------|
|
||||
| `HTTP 200` 和请求之间有空行 | 空行会被当作 entry 分隔符 | 删除空行,HTTP 行紧跟请求 |
|
||||
| JSON body 有尾逗号 | Hurl 严格解析 JSON | 删除最后一个逗号 |
|
||||
| `jsonpath` 写成 `json_path` 或 `JsonPath` | 关键字大小写敏感 | 必须小写 `jsonpath` |
|
||||
| `[Captures]` 写成 `[Capture]` | 必须是复数 | `[Captures]`、`[Asserts]`、`[Options]` |
|
||||
| 变量 `{{ var }}` 有空格 | 允许,但建议统一 | `{{var}}` 或 `{{ var }}` 都可以 |
|
||||
| `isIsoDate` 用在非 RFC 3339 格式 | 只认 `YYYY-MM-DDTHH:mm:ss` 格式 | 如果是其他格式用 `matches` |
|
||||
| `file,path;` 路径含 `..` | Hurl 禁止相对父目录 | 用 `--file-root` 或调整路径 |
|
||||
1
.claude/skills/improve-codebase-architecture
Symbolic link
1
.claude/skills/improve-codebase-architecture
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/improve-codebase-architecture
|
||||
97
.claude/skills/openspec-api-contract/SKILL.md
Normal file
97
.claude/skills/openspec-api-contract/SKILL.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: openspec-api-contract
|
||||
description: OpenSpec API 契约规范。创建涉及接口的 OpenSpec 提案时使用。探索阶段提供业务与契约引导清单,提案文档要求 API 契约设计、错误码与完成标准等必填章节。
|
||||
---
|
||||
|
||||
# OpenSpec API 契约规范
|
||||
|
||||
**适用场景**:创建涉及 API/接口的 OpenSpec 提案时,探索和提案两个阶段均须遵守本规范。
|
||||
|
||||
---
|
||||
|
||||
## 一、探索阶段引导清单
|
||||
|
||||
在 `openspec-explore` 阶段,当内容涉及接口时,讨论必须覆盖以下所有维度。
|
||||
|
||||
### 业务与契约确认
|
||||
|
||||
**输入与输出**
|
||||
- 请求方是谁?用户类型(SuperAdmin/Platform/Agent/Enterprise/Personal)?
|
||||
- 输入参数:必填/选填字段、格式约束、参数来源(路径/查询/Body)?
|
||||
- 输出结构:哪些字段必须返回?是否需要分页?
|
||||
|
||||
**业务规则**
|
||||
- 核心业务规则与边界条件?
|
||||
- 是否涉及状态流转?状态机的完整定义?
|
||||
- 依赖外部服务时,外部异常的降级行为?
|
||||
|
||||
**权限与资源所有权**
|
||||
- 哪些用户类型可以访问?
|
||||
- 是否涉及跨用户/跨店铺/跨企业的资源访问?(需三层越权防护)
|
||||
- 资源所有权校验方式:`CanManageShop` / `CanManageEnterprise` / 自有资源?
|
||||
|
||||
**幂等性**
|
||||
- 操作类型:查询(天然幂等)/ 创建 / 更新 / 删除?
|
||||
- 写操作幂等策略:状态条件更新 / Redis 业务键防重 + 分布式锁 / 乐观锁(version)?
|
||||
- 异步任务是否需要任务锁?
|
||||
|
||||
**数据模型变更**
|
||||
- 是否需要新建表、修改现有表或数据回填?
|
||||
- 迁移策略:上线顺序、兼容旧数据的方式?
|
||||
- 是否影响 GORM Callback 自动数据权限过滤?
|
||||
|
||||
**错误码与异常语义**
|
||||
- 预期错误场景及对应错误码?
|
||||
- 错误响应是否泄露敏感信息?(参数校验失败统一返回 `CodeInvalidParam`)
|
||||
|
||||
---
|
||||
|
||||
## 二、提案文档必填章节
|
||||
|
||||
在 `openspec-propose` 生成的提案(`proposal.md` / `design.md`)中,涉及接口时以下内容**不可缺失**。
|
||||
|
||||
### API 契约设计
|
||||
|
||||
**接口定义**
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| Endpoint | `METHOD /api/{scope}/{resource}[/:id]` |
|
||||
| 请求参数 | 字段名、类型、必填/选填、说明 |
|
||||
| 响应结构 | `data` 字段的完整结构定义 |
|
||||
| 鉴权要求 | 允许的用户类型 |
|
||||
| 资源所有权 | 所有权校验方式 |
|
||||
|
||||
**列表接口额外要求**
|
||||
- 分页:`page` + `page_size`(默认 20,最大 100)
|
||||
- 排序:默认排序字段与方向
|
||||
- 过滤:支持的过滤条件列表
|
||||
|
||||
**错误码清单**
|
||||
|
||||
| 场景 | 错误码 | 说明 |
|
||||
|---|---|---|
|
||||
| 参数校验失败 | `CodeInvalidParam` | 统一返回,不泄露细节 |
|
||||
| 资源不存在/越权 | `CodeForbidden` | 不区分两者,防止信息泄露 |
|
||||
| (业务错误场景...) | (对应错误码) | (说明) |
|
||||
|
||||
### 完成标准
|
||||
|
||||
**最小验证步骤**(按顺序列出可操作的验证步骤)
|
||||
|
||||
1. (例:调用创建接口,验证返回 `code=0`)
|
||||
2. (例:查询接口确认数据存在且字段正确)
|
||||
3. (例:PostgreSQL MCP 查询确认数据库记录符合预期)
|
||||
|
||||
**影响范围说明**
|
||||
- 新增/修改的表:
|
||||
- 影响的现有接口:
|
||||
- 影响的权限与数据过滤范围:
|
||||
|
||||
---
|
||||
|
||||
## 约束(必须遵守)
|
||||
|
||||
- **优先复用现有架构与库**:不引入新依赖,错误码优先复用已有定义
|
||||
- **不做顺手重构**:提案范围严格限定在目标功能;发现可优化点,记录到 backlog 但不执行
|
||||
- **数据库设计**:禁止外键约束,禁止 GORM 关联标签,关联通过 ID 字段手动维护
|
||||
1
.claude/skills/prototype
Symbolic link
1
.claude/skills/prototype
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/prototype
|
||||
1
.claude/skills/setup-matt-pocock-skills
Symbolic link
1
.claude/skills/setup-matt-pocock-skills
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/setup-matt-pocock-skills
|
||||
@@ -1,260 +0,0 @@
|
||||
---
|
||||
name: systematic-debugging
|
||||
description: 遇到任何 bug、异常行为、报错时必须使用。在提出任何修复方案之前,强制执行根因分析流程。适用于 API 报错、数据异常、业务逻辑错误、性能问题等所有技术问题。
|
||||
---
|
||||
|
||||
# 系统化调试方法论
|
||||
|
||||
## 铁律
|
||||
|
||||
```
|
||||
没有找到根因,禁止提出任何修复方案。
|
||||
```
|
||||
|
||||
改之前先搞懂为什么坏了。猜测不是调试,验证假设才是。
|
||||
|
||||
---
|
||||
|
||||
## 什么时候用
|
||||
|
||||
**所有技术问题都用这个流程**:
|
||||
- API 接口报错(4xx / 5xx)
|
||||
- 业务数据异常(金额不对、状态流转错误)
|
||||
- 性能问题(接口慢、数据库慢查询)
|
||||
- 异步任务失败(Asynq 任务报错/卡住)
|
||||
- 构建失败、启动失败
|
||||
|
||||
**尤其是以下场景**:
|
||||
- 时间紧迫(越急越不能瞎猜)
|
||||
- "很简单的问题"(简单问题也有根因)
|
||||
- 已经试了一次修复但没解决
|
||||
- 不完全理解为什么出问题
|
||||
|
||||
---
|
||||
|
||||
## 四阶段流程
|
||||
|
||||
必须按顺序完成每个阶段,不可跳过。
|
||||
|
||||
### 阶段一:根因调查
|
||||
|
||||
**这是最重要的阶段,占整个调试时间的 60%。没完成本阶段,禁止进入阶段二。**
|
||||
|
||||
#### 1. 仔细阅读错误信息
|
||||
|
||||
- 完整阅读 stack trace,不要跳过
|
||||
- 注意行号、文件路径、错误码
|
||||
- 很多时候答案就在错误信息里
|
||||
- 检查 `logs/app.log` 和 `logs/access.log` 中的上下文
|
||||
|
||||
#### 2. 稳定复现
|
||||
|
||||
- 能稳定触发吗?精确的请求参数是什么?
|
||||
- 用 curl 或 Postman 复现,记录完整的请求和响应
|
||||
- 不能复现 → 收集更多数据(检查日志、Redis 状态、数据库记录),**不要瞎猜**
|
||||
|
||||
#### 3. 检查最近改动
|
||||
|
||||
- `git diff` / `git log --oneline -10` 看最近改了什么
|
||||
- 新加了什么依赖?改了什么配置?改了什么 SQL?
|
||||
- 对比改动前后的行为差异
|
||||
|
||||
#### 4. 逐层诊断(针对本项目架构)
|
||||
|
||||
本项目有明确的分层架构,问题一定出在某一层的边界:
|
||||
|
||||
```
|
||||
请求 → Fiber Middleware → Handler → Service → Store → PostgreSQL/Redis
|
||||
↑ ↑ ↑ ↑ ↑
|
||||
认证/限流 参数解析 业务逻辑 SQL/缓存 数据本身
|
||||
```
|
||||
|
||||
**在每个层边界确认数据是否正确**:
|
||||
|
||||
```go
|
||||
// Handler 层 — 请求进来的参数对不对?
|
||||
logger.Info("Handler 收到请求",
|
||||
zap.Any("params", req),
|
||||
zap.String("request_id", requestID),
|
||||
)
|
||||
|
||||
// Service 层 — 传给业务逻辑的数据对不对?
|
||||
logger.Info("Service 开始处理",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Any("input", input),
|
||||
)
|
||||
|
||||
// Store 层 — SQL 查询/写入的数据对不对?
|
||||
// 开启 GORM Debug 模式查看实际 SQL
|
||||
db.Debug().Where(...).Find(&result)
|
||||
|
||||
// Redis 层 — 缓存的数据对不对?
|
||||
// 用 redis-cli 直接检查 key 的值
|
||||
// GET auth:token:{token}
|
||||
// GET sim:status:{iccid}
|
||||
```
|
||||
|
||||
**跑一次 → 看日志 → 找到断裂的那一层 → 再深入该层排查。**
|
||||
|
||||
#### 5. 追踪数据流
|
||||
|
||||
如果错误深藏在调用链中:
|
||||
- 坏数据从哪来的?
|
||||
- 谁调用了这个函数,传了什么参数?
|
||||
- 一直往上追,直到找到数据变坏的源头
|
||||
- **修源头,不修症状**
|
||||
|
||||
---
|
||||
|
||||
### 阶段二:模式分析
|
||||
|
||||
**找到参照物,对比差异。**
|
||||
|
||||
#### 1. 找能用的参照
|
||||
|
||||
项目里有没有类似的、能正常工作的代码?
|
||||
|
||||
| 如果问题在... | 参照物在... |
|
||||
|-------------|-----------|
|
||||
| Handler 参数解析 | 其他 Handler 的相同模式 |
|
||||
| Service 业务逻辑 | 同模块其他方法的实现 |
|
||||
| Store SQL 查询 | 同 Store 文件中类似的查询 |
|
||||
| Redis 操作 | `pkg/constants/redis.go` 中的 Key 定义 |
|
||||
| 异步任务 | `internal/task/` 中其他任务处理器 |
|
||||
| GORM Callback | `pkg/database/` 中的 callback 实现 |
|
||||
|
||||
#### 2. 逐行对比
|
||||
|
||||
完整阅读参考代码,不要跳读。列出每一处差异。
|
||||
|
||||
#### 3. 不要假设"这个不重要"
|
||||
|
||||
小差异经常是 bug 的根因:
|
||||
- 字段标签 `gorm:"column:xxx"` 拼写不对
|
||||
- `errors.New()` 用了错误的错误码
|
||||
- Redis Key 函数参数传反了
|
||||
- Context 里的 UserID 没取到(中间件没配)
|
||||
|
||||
---
|
||||
|
||||
### 阶段三:假设和验证
|
||||
|
||||
**科学方法:一次只验证一个假设。**
|
||||
|
||||
#### 1. 形成单一假设
|
||||
|
||||
明确写下:
|
||||
|
||||
> "我认为根因是 X,因为 Y。验证方法是 Z。"
|
||||
|
||||
#### 2. 最小化验证
|
||||
|
||||
- 只改一个地方
|
||||
- 一次只验证一个变量
|
||||
- 不要同时修多处
|
||||
|
||||
#### 3. 验证结果
|
||||
|
||||
- 假设成立 → 进入阶段四
|
||||
- 假设不成立 → 回到阶段一,用新信息重新分析
|
||||
- **绝对不能在失败的修复上再叠加修复**
|
||||
|
||||
#### 4. 三次失败 → 停下来
|
||||
|
||||
如果连续 3 次假设都不成立:
|
||||
|
||||
**这不是 bug,是架构问题。**
|
||||
|
||||
- 停止一切修复尝试
|
||||
- 整理已知信息
|
||||
- 向用户说明情况,讨论是否需要重构
|
||||
- 不要再试第 4 次
|
||||
|
||||
---
|
||||
|
||||
### 阶段四:实施修复
|
||||
|
||||
**确认根因后,一次性修好。**
|
||||
|
||||
#### 1. 修根因,不修症状
|
||||
|
||||
```
|
||||
❌ 症状修复:在 Handler 里加个 if 把坏数据过滤掉
|
||||
✅ 根因修复:修 Service 层生成坏数据的逻辑
|
||||
```
|
||||
|
||||
#### 2. 一次只改一个地方
|
||||
|
||||
- 不搞"顺手优化"
|
||||
- 不在修 bug 的同时重构代码
|
||||
- 修完 bug 就停
|
||||
|
||||
#### 3. 验证修复
|
||||
|
||||
- `go build ./...` 编译通过
|
||||
- `lsp_diagnostics` 无新增错误
|
||||
- 用原来复现 bug 的请求再跑一次,确认修好了
|
||||
- 用 PostgreSQL MCP 工具检查数据库中的数据状态
|
||||
|
||||
#### 4. 清理诊断代码
|
||||
|
||||
- 删除阶段一加的临时诊断日志(除非它们本身就该保留)
|
||||
- 确保没有 `db.Debug()` 残留在代码里
|
||||
|
||||
---
|
||||
|
||||
## 本项目常见调试场景速查
|
||||
|
||||
| 场景 | 首先检查 |
|
||||
|------|---------|
|
||||
| API 返回 401 | `logs/access.log` 中该请求的 token → Redis 中 `auth:token:{token}` 是否存在 |
|
||||
| API 返回 403 | 用户类型是什么 → GORM Callback 自动过滤的条件对不对 → `middleware.CanManageShop()` 的参数 |
|
||||
| 数据查不到 | GORM 数据权限过滤有没有生效 → `shop_id` / `enterprise_id` 是否正确 → 是否需要 `SkipDataPermission` |
|
||||
| 金额/余额不对 | 乐观锁 version 字段 → `RowsAffected` 是否为 0 → 并发场景下的锁竞争 |
|
||||
| 状态流转错误 | `WHERE status = expected` 条件更新 → 状态机是否有遗漏的路径 |
|
||||
| 异步任务不执行 | Asynq Dashboard → `RedisTaskLockKey` 有没有残留 → Worker 日志 |
|
||||
| 异步任务重复执行 | `RedisTaskLockKey` 的 TTL → 任务幂等性检查 |
|
||||
| 分佣计算错误 | 佣金类型(差价/一次性) → 套餐级别的佣金率 → 设备级防重复分佣 |
|
||||
| 套餐激活异常 | 卡状态 → 实名状态 → 主套餐排队逻辑 → 加油包绑定关系 |
|
||||
| Redis 缓存不一致 | Key 的 TTL → 缓存更新时机 → 是否有手动 `Del` 清除 |
|
||||
| 微信支付回调失败 | 签名验证 → 幂等性处理 → 回调 URL 是否可达 |
|
||||
| GORM 查询慢 | `db.Debug()` 看实际 SQL → 是否 N+1 → 是否缺少索引 |
|
||||
|
||||
---
|
||||
|
||||
## 红线规则
|
||||
|
||||
如果你发现自己在想以下任何一条,**立刻停下来,回到阶段一**:
|
||||
|
||||
| 想法 | 为什么是错的 |
|
||||
|------|------------|
|
||||
| "先快速修一下,回头再查" | 快速修 = 猜测。猜测 = 浪费时间。 |
|
||||
| "试试改这个看看行不行" | 一次只验证一个假设,不是随机改。 |
|
||||
| "大概是 X 的问题,我直接改了" | "大概"不是根因。先验证再改。 |
|
||||
| "这个很简单,不用走流程" | 简单问题走流程只需要 5 分钟。不走流程可能浪费 2 小时。 |
|
||||
| "我不完全理解但这应该行" | 不理解 = 没找到根因。回阶段一。 |
|
||||
| "再试一次"(已经失败 2 次) | 3 次失败 = 架构问题。停下来讨论。 |
|
||||
| "同时改这几个地方应该能修好" | 改多处 = 无法确认哪个是根因。一次只改一处。 |
|
||||
|
||||
---
|
||||
|
||||
## 常见借口和真相
|
||||
|
||||
| 借口 | 真相 |
|
||||
|------|------|
|
||||
| "问题很简单,不需要走流程" | 简单问题也有根因。走流程对简单问题只花 5 分钟。 |
|
||||
| "太紧急了,没时间分析" | 系统化调试比乱猜快 3-5 倍。越急越要走流程。 |
|
||||
| "先改了验证一下" | 这叫猜测,不叫验证。先确认根因再改。 |
|
||||
| "我看到问题了,直接修" | 看到症状 ≠ 理解根因。症状修复是技术债。 |
|
||||
| "改了好几个地方,反正能用了" | 不知道哪个改动修的,下次还会出问题。 |
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 阶段 | 核心动作 | 完成标准 |
|
||||
|------|---------|---------|
|
||||
| **一、根因调查** | 读错误日志、复现、检查改动、逐层诊断、追踪数据流 | 能说清楚"因为 X 所以 Y" |
|
||||
| **二、模式分析** | 找参照代码、逐行对比、列出差异 | 知道正确的应该长什么样 |
|
||||
| **三、假设验证** | 写下假设、最小改动、单变量验证 | 假设被证实或推翻 |
|
||||
| **四、实施修复** | 修根因、编译检查、请求验证、清理诊断代码 | bug 消失,无新增问题 |
|
||||
1
.claude/skills/tdd
Symbolic link
1
.claude/skills/tdd
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/tdd
|
||||
1
.claude/skills/teach
Symbolic link
1
.claude/skills/teach
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/teach
|
||||
1
.claude/skills/to-issues
Symbolic link
1
.claude/skills/to-issues
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/to-issues
|
||||
1
.claude/skills/to-prd
Symbolic link
1
.claude/skills/to-prd
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/to-prd
|
||||
1
.claude/skills/triage
Symbolic link
1
.claude/skills/triage
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/triage
|
||||
1
.claude/skills/write-a-skill
Symbolic link
1
.claude/skills/write-a-skill
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/write-a-skill
|
||||
1
.claude/skills/zoom-out
Symbolic link
1
.claude/skills/zoom-out
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/zoom-out
|
||||
198
.codex/skills/export-datasource/SKILL.md
Normal file
198
.codex/skills/export-datasource/SKILL.md
Normal 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.
|
||||
4
.codex/skills/export-datasource/agents/openai.yaml
Normal file
4
.codex/skills/export-datasource/agents/openai.yaml
Normal file
@@ -0,0 +1,4 @@
|
||||
interface:
|
||||
display_name: "导出数据源开发"
|
||||
short_description: "指导新增和维护项目导出数据源场景"
|
||||
default_prompt: "Use $export-datasource to add a new export scene for this project."
|
||||
@@ -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;
|
||||
```
|
||||
@@ -1,7 +1,20 @@
|
||||
[[sources]]
|
||||
id = "main"
|
||||
description = "当前项目库"
|
||||
dsn = "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable"
|
||||
|
||||
[[sources]]
|
||||
id = "legacy_mysql"
|
||||
description = "老系统 MySQL 库,仅用于 AI 查询和迁移脚本准备"
|
||||
type = "mysql"
|
||||
host = "120.79.89.216"
|
||||
port = 3306
|
||||
database = "kyhl"
|
||||
user = "root"
|
||||
password = "XiWaJ9Eu%go9-2>g!xGp"
|
||||
sslmode = "disable"
|
||||
lazy = true
|
||||
|
||||
[[tools]]
|
||||
name = "search_objects"
|
||||
source = "main"
|
||||
@@ -11,3 +24,13 @@ name = "execute_sql"
|
||||
source = "main"
|
||||
readonly = true # Only allow SELECT, SHOW, DESCRIBE, EXPLAIN
|
||||
max_rows = 1000 # Limit query results
|
||||
|
||||
[[tools]]
|
||||
name = "search_objects"
|
||||
source = "legacy_mysql"
|
||||
|
||||
[[tools]]
|
||||
name = "execute_sql"
|
||||
source = "legacy_mysql"
|
||||
readonly = true # Only allow SELECT, SHOW, DESCRIBE, EXPLAIN
|
||||
max_rows = 5000 # Limit query results for migration exploration
|
||||
|
||||
35
.gitignore
vendored
35
.gitignore
vendored
@@ -76,4 +76,37 @@ docs/admin-openapi.yaml
|
||||
/api
|
||||
/gendocs
|
||||
.env.local
|
||||
/worker
|
||||
/worker
|
||||
.opencode/skills/json-canvas
|
||||
.opencode/skills/obsidian-bases
|
||||
.gitignore
|
||||
.opencode/skills/obsidian-cli
|
||||
.opencode/skills/obsidian-markdown
|
||||
.opencode/skills/kb-sync
|
||||
.gitignore
|
||||
.opencode/skills/defuddle
|
||||
# CocoIndex Code (ccc)
|
||||
/.cocoindex_code/
|
||||
.omx/hud-config.json
|
||||
.omx/metrics.json
|
||||
.omx/setup-scope.json
|
||||
.omx/cache/codebase-map.json
|
||||
.omx/state/current-task-baseline.json
|
||||
.omx/state/notify-fallback-authority-owner.json
|
||||
.omx/state/notify-fallback-state.json
|
||||
.omx/state/notify-fallback.pid
|
||||
.omx/state/session.json
|
||||
.omx/state/subagent-tracking.json
|
||||
.omx/state/team-leader-nudge.json
|
||||
.omx/state/tmux-hook-state.json
|
||||
.omx/state/update-check.json
|
||||
.omx/state/sessions/omx-1777517355305-tzivrd/AGENTS.md
|
||||
.omx/state/sessions/omx-1777517355305-tzivrd/hud-state.json
|
||||
.omx/state/sessions/omx-1777517489966-oo3g37/AGENTS.md
|
||||
.omx/state/sessions/omx-1777517489966-oo3g37/hud-state.json
|
||||
.omx/state/sessions/omx-1777517489966-oo3g37/notify-hook-state.json
|
||||
.omx/state/tmux-extended-keys/private-tmp-tmux-501-default.json
|
||||
.aider*
|
||||
|
||||
# /teach skill 的个人学习工作区,不进入项目提交历史
|
||||
.claude/teach-workspace/
|
||||
|
||||
20
.mcp.json
20
.mcp.json
@@ -1,19 +1,15 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"postgres": {
|
||||
"command": "docker",
|
||||
"command": "npx",
|
||||
"args": [
|
||||
"run",
|
||||
"-i",
|
||||
"--rm",
|
||||
"-e",
|
||||
"DATABASE_URI",
|
||||
"crystaldba/postgres-mcp",
|
||||
"--access-mode=restricted"
|
||||
],
|
||||
"env": {
|
||||
"DATABASE_URI": "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable"
|
||||
}
|
||||
"-y",
|
||||
"@bytebase/dbhub@latest",
|
||||
"--transport",
|
||||
"stdio",
|
||||
"--config",
|
||||
".config/dbhub.toml"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
115
.opencode/package-lock.json
generated
Normal file
115
.opencode/package-lock.json
generated
Normal file
@@ -0,0 +1,115 @@
|
||||
{
|
||||
"name": ".opencode",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"dependencies": {
|
||||
"@opencode-ai/plugin": "1.3.17"
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/plugin": {
|
||||
"version": "1.3.17",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/plugin/-/plugin-1.3.17.tgz",
|
||||
"integrity": "sha512-N5lckFtYvEu2R8K1um//MIOTHsJHniF2kHoPIWPCrxKG5Jpismt1ISGzIiU3aKI2ht/9VgcqKPC5oZFLdmpxPw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opencode-ai/sdk": "1.3.17",
|
||||
"zod": "4.1.8"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentui/core": ">=0.1.96",
|
||||
"@opentui/solid": ">=0.1.96"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@opentui/core": {
|
||||
"optional": true
|
||||
},
|
||||
"@opentui/solid": {
|
||||
"optional": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@opencode-ai/sdk": {
|
||||
"version": "1.3.17",
|
||||
"resolved": "https://registry.npmjs.org/@opencode-ai/sdk/-/sdk-1.3.17.tgz",
|
||||
"integrity": "sha512-2+MGgu7wynqTBwxezR01VAGhILXlpcHDY/pF7SWB87WOgLt3kD55HjKHNj6PWxyY8n575AZolR95VUC3gtwfmA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"cross-spawn": "7.0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"path-key": "^3.1.0",
|
||||
"shebang-command": "^2.0.0",
|
||||
"which": "^2.0.1"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/isexe": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
|
||||
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/path-key": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
|
||||
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-command": {
|
||||
"version": "2.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
|
||||
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"shebang-regex": "^3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/shebang-regex": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
|
||||
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=8"
|
||||
}
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "2.0.2",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
|
||||
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"isexe": "^2.0.0"
|
||||
},
|
||||
"bin": {
|
||||
"node-which": "bin/node-which"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">= 8"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.1.8",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
139
.opencode/skills/comment-standards/SKILL.md
Normal file
139
.opencode/skills/comment-standards/SKILL.md
Normal file
@@ -0,0 +1,139 @@
|
||||
---
|
||||
name: comment-standards
|
||||
description: Go 注释规范。编写 Go 代码注释、文档注释时使用。包含包注释、结构体注释、接口注释、函数注释、内联注释的完整规范与示例。
|
||||
---
|
||||
|
||||
# Go 注释规范
|
||||
|
||||
**基本原则**:
|
||||
- **所有注释使用中文**
|
||||
- **导出符号必须有文档注释**(包、函数、方法、类型、接口、常量、变量)
|
||||
- **复杂逻辑必须有实现注释**(解释"为什么",而不是"做了什么")
|
||||
- **禁止废话注释**(不要用注释复述代码本身)
|
||||
- **修改代码时必须同步更新注释**
|
||||
|
||||
---
|
||||
|
||||
## 包注释
|
||||
|
||||
每个包的入口文件(通常是主文件或 `doc.go`)必须有包注释:
|
||||
|
||||
```go
|
||||
// Package account 提供账号管理的业务逻辑服务
|
||||
// 包含账号创建、修改、删除、权限分配等功能
|
||||
package account
|
||||
```
|
||||
|
||||
## 结构体注释
|
||||
|
||||
所有导出结构体必须有文档注释,说明该结构体代表什么:
|
||||
|
||||
```go
|
||||
// Service 账号业务服务
|
||||
// 负责账号的 CRUD、角色分配、密码管理等业务逻辑
|
||||
type Service struct {
|
||||
store *Store
|
||||
auditService AuditServiceInterface
|
||||
}
|
||||
```
|
||||
|
||||
## 接口注释
|
||||
|
||||
导出接口必须注释接口用途,每个方法必须说明契约:
|
||||
|
||||
```go
|
||||
// PermissionChecker 权限检查器接口
|
||||
// 用于查询用户的权限列表
|
||||
type PermissionChecker interface {
|
||||
// CheckPermission 检查用户是否拥有指定权限
|
||||
// userID: 用户ID
|
||||
// permCode: 权限编码(格式: module:action)
|
||||
// platform: 端口类型 (all/web/h5)
|
||||
CheckPermission(ctx context.Context, userID uint, permCode string, platform string) (bool, error)
|
||||
}
|
||||
```
|
||||
|
||||
## 函数和方法注释
|
||||
|
||||
**导出函数/方法**必须以函数名开头,说明功能:
|
||||
|
||||
```go
|
||||
// Create 创建账号
|
||||
// POST /api/admin/accounts
|
||||
func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
||||
```
|
||||
|
||||
**复杂方法**(超过 30 行或包含复杂业务逻辑)必须额外说明实现思路:
|
||||
|
||||
```go
|
||||
// ActivateByRealname 首次实名激活套餐
|
||||
// 当用户完成实名认证后,自动激活处于"囤货待实名"状态的套餐:
|
||||
// 1. 查找该卡所有 status=3(待实名激活)的套餐
|
||||
// 2. 按创建时间排序,第一个主套餐立即激活(status=1)
|
||||
// 3. 其余主套餐进入排队状态(status=4)
|
||||
// 4. 加油包如果绑定了已激活的主套餐则一并激活
|
||||
func (s *UsageService) ActivateByRealname(ctx context.Context, cardID uint) error {
|
||||
```
|
||||
|
||||
**未导出函数/方法**:
|
||||
- 简单逻辑(< 15 行):可以不加注释
|
||||
- 复杂逻辑(≥ 15 行)或非显而易见的算法:必须加注释
|
||||
|
||||
```go
|
||||
// buildPermissionTree 递归构建权限树
|
||||
// 采用 map 索引 + 单次遍历算法,时间复杂度 O(n)
|
||||
func (s *Service) buildPermissionTree(permissions []*model.Permission) []*dto.PermissionTreeNode {
|
||||
```
|
||||
|
||||
## 常量和枚举注释
|
||||
|
||||
分组常量必须有组注释,每个值必须有行内注释:
|
||||
|
||||
```go
|
||||
// 用户类型常量
|
||||
const (
|
||||
UserTypeSuperAdmin = 1 // 超级管理员
|
||||
UserTypePlatform = 2 // 平台用户
|
||||
UserTypeAgent = 3 // 代理账号
|
||||
UserTypeEnterprise = 4 // 企业账号
|
||||
)
|
||||
```
|
||||
|
||||
## 内联注释规范
|
||||
|
||||
**必须添加内联注释的场景**:
|
||||
|
||||
| 场景 | 要求 |
|
||||
|------|------|
|
||||
| 复杂条件判断 | 解释判断的业务含义 |
|
||||
| 多步骤业务流程 | 用编号注释标明每一步 |
|
||||
| 非显而易见的设计决策 | 解释"为什么这样做"而不是"做了什么" |
|
||||
| 缓存/事务/并发处理 | 说明策略和原因 |
|
||||
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
|
||||
|
||||
**✅ 好的内联注释(解释为什么)**:
|
||||
|
||||
```go
|
||||
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
|
||||
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
|
||||
return errors.New(errors.CodeTooManyRequests, "操作过于频繁,请稍后重试")
|
||||
}
|
||||
|
||||
// 先冻结佣金再扣款,保证资金安全(失败时佣金自动解冻)
|
||||
if err := s.freezeCommission(ctx, tx, orderID); err != nil {
|
||||
return err
|
||||
}
|
||||
```
|
||||
|
||||
**❌ 废话注释(禁止)**:
|
||||
|
||||
```go
|
||||
// 获取用户ID ← 禁止:代码本身已经很清楚
|
||||
userID := middleware.GetUserIDFromContext(ctx)
|
||||
|
||||
// 创建账号 ← 禁止:变量名已说明意图
|
||||
account := &model.Account{}
|
||||
|
||||
// 返回错误 ← 禁止:return err 不需要注释
|
||||
return err
|
||||
```
|
||||
952
.opencode/skills/hurl-test/SKILL.md
Normal file
952
.opencode/skills/hurl-test/SKILL.md
Normal file
@@ -0,0 +1,952 @@
|
||||
---
|
||||
name: hurl-test
|
||||
description: Hurl 接口测试生成器。用户描述要测试的接口或业务流程,自动探索代码、确认需求、生成完整的 .hurl 测试文件(含 DTO 驱动的字段完整性断言)。触发词:测试、hurl、写测试、接口测试。
|
||||
---
|
||||
|
||||
# Hurl 接口测试生成器
|
||||
|
||||
**用户描述要测试什么,你来读代码、问确认、生成 .hurl 文件。**
|
||||
|
||||
适用于任何后端项目(Go / Python / Node / Java 等),不预设框架和目录结构。
|
||||
|
||||
---
|
||||
|
||||
## 触发条件
|
||||
|
||||
以下情况必须使用本 Skill:
|
||||
- 用户说"测试 XX 接口"、"写 hurl 测试"、"给 XX 加测试"
|
||||
- 用户说"测试 XX 流程"、"测试 XX 的业务逻辑"
|
||||
- 用户说"验证 XX 接口的字段"、"测试接口契约"
|
||||
- 用户提到 hurl、.hurl、接口测试、集成测试、冒烟测试
|
||||
|
||||
---
|
||||
|
||||
## 五阶段工作流(必须按顺序执行)
|
||||
|
||||
```
|
||||
Phase 1: 探索 → Phase 2: 确认 → Phase 3: 生成 → Phase 4: 验证 → Phase 5: 研判
|
||||
读代码搞清楚 展示给用户确认 输出 .hurl 文件 语法检查+试跑 分析失败/报告发现
|
||||
```
|
||||
|
||||
> **核心原则:测试是用来发现问题的,不是用来全部通过的。**
|
||||
> 断言基于代码定义(Handler + DTO)生成,与实际行为的差异是"发现",不是要修掉的"错误"。
|
||||
|
||||
---
|
||||
|
||||
### Phase 1: 探索(Explore)
|
||||
|
||||
**目标:读代码,搞清楚项目约定 + 涉及的接口 + 字段 + 依赖。要求准确,不求快。**
|
||||
|
||||
#### 真理源层级(必须遵守)
|
||||
|
||||
断言的字段名和类型基于以下优先级确定,**高优先级推翻低优先级**:
|
||||
|
||||
| 优先级 | 来源 | 说明 | 怎么找 |
|
||||
|-------|------|------|--------|
|
||||
| **1(最高)** | Handler 实际实现 | Handler 返回什么类型、调用哪个 response 方法、是否做了 model→DTO 转换 | 读 handler 函数体,看 `return response.Success(c, xxx)` 中 xxx 的类型 |
|
||||
| **2** | DTO / Response 结构体 | Handler 引用的 DTO 的 json tag、字段类型 | 读 DTO struct 定义 |
|
||||
| **3(最低)** | 路由注册的 Output 声明 | RouteSpec 中声明的 Output 类型,仅作为文档参考,可能过期 | 读路由注册代码 |
|
||||
|
||||
**关键规则**:
|
||||
- **不能只读 DTO 就生成断言**。必须追溯到 Handler 确认它确实用了这个 DTO
|
||||
- 如果 Handler 直接返回 GORM model 而非 DTO,断言用 model 的字段名(可能是大写 `ID` 而非小写 `id`)
|
||||
- 如果 Handler 用了 `response.SuccessWithPagination()`,分页字段以该函数的结构为准,而非 DTO 定义的分页结构
|
||||
- 当 Handler 实现与 DTO 定义不一致时,**以 Handler 为准生成断言,同时在 Phase 5 中将不一致标记为发现**
|
||||
|
||||
#### 1.1 项目画像(首次使用时必须执行,后续复用)
|
||||
|
||||
首次为项目生成 Hurl 测试时,先回答以下问题(通过读代码,不要猜):
|
||||
|
||||
| 问题 | 怎么找 |
|
||||
|------|--------|
|
||||
| **语言/框架** | 看 go.mod / package.json / requirements.txt / pom.xml |
|
||||
| **路由注册在哪** | 搜索 `router`、`app.Get`、`@GetMapping`、`@app.route` 等关键词 |
|
||||
| **请求/响应 schema 定义在哪** | 搜索 DTO / schema / serializer / model 目录,看 json tag 或装饰器 |
|
||||
| **统一响应格式是什么** | 找 response helper 文件(如 `response.go`、`response.py`),记录 JSON 结构 |
|
||||
| **认证方式是什么** | 找 auth middleware,确定是 Bearer Token / Cookie / API Key / Basic Auth |
|
||||
| **登录接口是什么** | 找登录 handler,记录路径、请求体、响应中 token 的位置 |
|
||||
| **分页格式是什么** | 找列表接口的响应结构,记录 items/total/page 等字段名 |
|
||||
| **已有 hurl 测试吗** | 搜索 `*.hurl` 文件,复用已有的约定 |
|
||||
|
||||
将画像结果**写入 `tests/hurl/.project-profile.md` 文件持久化保存**。
|
||||
|
||||
#### 画像持久化(关键机制)
|
||||
|
||||
**首次使用时**:完成 1.1 探索后,将画像写入 `tests/hurl/.project-profile.md`,格式如下:
|
||||
|
||||
```markdown
|
||||
# 项目画像(Hurl 测试自动生成用)
|
||||
<!-- 由 hurl-test skill 自动生成,请勿手动修改 -->
|
||||
<!-- 如需刷新,删除此文件后重新运行 skill -->
|
||||
|
||||
## 技术栈
|
||||
- 语言: Go 1.25
|
||||
- 框架: Fiber v2
|
||||
- ORM: GORM
|
||||
|
||||
## 路由定义位置
|
||||
- 路由注册入口: internal/routes/routes.go
|
||||
- 按模块拆分: internal/routes/{module}.go
|
||||
- 路由注册函数: Register(router, doc, basePath, method, path, handler, spec)
|
||||
|
||||
## Schema 定义位置
|
||||
- DTO 目录: internal/model/dto/
|
||||
- 命名规则: {module}_dto.go
|
||||
- 字段标签: json / validate / description
|
||||
|
||||
## 统一响应格式
|
||||
{code: int, msg: string, data: any, timestamp: string(RFC3339)}
|
||||
- 成功: code=0, msg="success"
|
||||
- 错误: code!=0
|
||||
|
||||
## 分页格式
|
||||
{items: [], total: int, page: int, size: int}
|
||||
- 包裹在 data 字段内: $.data.items / $.data.total
|
||||
|
||||
## 认证方式
|
||||
- 后台: POST /api/auth/admin-login → $.data.access_token → Authorization: Bearer {token}
|
||||
- C端: JWT → Authorization: Bearer {token}
|
||||
|
||||
## 默认测试账号
|
||||
- 用户名: admin
|
||||
- 密码: Admin@123456
|
||||
|
||||
## 服务端口
|
||||
- 默认: 3000
|
||||
```
|
||||
|
||||
**后续使用时**:检查 `tests/hurl/.project-profile.md` 是否存在:
|
||||
- **存在** → 直接读取,跳过 1.1 的探索步骤,节省时间
|
||||
- **不存在** → 执行 1.1 完整探索,然后生成此文件
|
||||
- **用户说"刷新画像"** → 删除旧文件,重新执行 1.1
|
||||
|
||||
#### 1.2 找接口定义
|
||||
|
||||
根据用户要测的模块,定位路由注册代码,提取:
|
||||
|
||||
- **HTTP 方法**(GET / POST / PUT / DELETE / PATCH)
|
||||
- **路由路径**(含路径参数格式,如 `/users/:id` 或 `/users/{id}`)
|
||||
- **接口说明**(注释、Summary、装饰器描述)
|
||||
- **是否需要认证**
|
||||
- **请求 schema 类型名**(Input / Request DTO)
|
||||
- **响应 schema 类型名**(Output / Response DTO)
|
||||
|
||||
#### 1.3 读 schema 定义(DTO / struct / class / type)
|
||||
|
||||
定位请求和响应的 schema 定义文件,提取每个字段的:
|
||||
|
||||
- **字段名**:JSON 序列化后的名称(json tag / @JsonProperty / serializer field)
|
||||
- **语言类型**:string / int / bool / 数组 / 嵌套对象 / 可空等
|
||||
- **是否必填**:validate tag / required 装饰器 / 非空标注
|
||||
- **是否可空**:指针类型 / Optional / nullable
|
||||
- **是否参与序列化**:`json:"-"` / @JsonIgnore / exclude
|
||||
- **是否 omitempty**:`json:",omitempty"` / 条件序列化
|
||||
- **字段描述**:description tag / docstring / 注释
|
||||
|
||||
#### 1.4 识别业务依赖
|
||||
|
||||
读 service / business logic 层,识别:
|
||||
|
||||
- 创建操作需要哪些前置数据(如创建订单需要先有商品和用户)
|
||||
- 是否有唯一性约束(如用户名不能重复)
|
||||
- 是否依赖外部服务(支付网关、短信、OAuth 等)
|
||||
- 业务流转逻辑(状态机、级联操作)
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 确认(Clarify)
|
||||
|
||||
**目标:向用户展示发现的内容,确认模糊点。不要闷头生成。**
|
||||
|
||||
#### 2.1 必须展示的内容
|
||||
|
||||
```
|
||||
我梳理了相关代码,发现以下信息:
|
||||
|
||||
📋 涉及接口:
|
||||
- [方法] [路径] - [说明](认证: 是/否)
|
||||
- ...
|
||||
|
||||
📦 响应字段(基于 {SchemaName}):
|
||||
- [字段名]: [类型] - [说明]
|
||||
- ...(共 N 个字段,将全部生成断言)
|
||||
|
||||
🔗 依赖关系:
|
||||
- [创建 X 需要先创建 Y]
|
||||
- ...
|
||||
|
||||
⚠️ 特殊情况:
|
||||
- [涉及外部服务 / 文件上传 / 特殊认证等]
|
||||
```
|
||||
|
||||
#### 2.2 按需确认(只问有歧义的)
|
||||
|
||||
| 场景 | 要问的 |
|
||||
|------|--------|
|
||||
| 流程范围不明确 | "要测到哪一步?" |
|
||||
| 多种用户角色 | "用哪种身份测?" |
|
||||
| 是否测异常 | "需要包含异常 case 吗?(参数校验失败、权限不足等)" |
|
||||
| 是否测数据隔离 | "需要验证不同用户间数据不可见吗?" |
|
||||
| 涉及第三方 | "XX 部分怎么处理?绕过 / 模拟回调 / 跳过?" |
|
||||
| 前置数据来源 | "XX 依赖数据是通过 API 创建还是假设已存在?" |
|
||||
|
||||
**如果用户说"越完整越好"或"都要"→ 默认全部包含,不再追问。**
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 生成(Generate)
|
||||
|
||||
**目标:生成完整的 .hurl 文件,字段断言基于 schema 代码,不能编造。**
|
||||
|
||||
#### 3.1 文件头注释
|
||||
|
||||
```hurl
|
||||
# ============================================================
|
||||
# 测试:{测试名称}
|
||||
# 生成时间:{日期}
|
||||
# 涉及模块:{module1, module2, ...}
|
||||
# 涉及接口:{N} 个
|
||||
# 断言数量:{N} 条
|
||||
# 前置条件:{服务运行 + 必要的前置条件}
|
||||
# ============================================================
|
||||
# 流程:
|
||||
# 1. {步骤描述}
|
||||
# 2. {步骤描述}
|
||||
# ...
|
||||
# ============================================================
|
||||
```
|
||||
|
||||
#### 3.2 请求生成规则
|
||||
|
||||
**认证**:
|
||||
|
||||
- 根据 Phase 1 画像中的登录接口和 token 位置生成
|
||||
- token 必须通过 `[Captures]` 捕获,后续请求引用
|
||||
- 如果是 Cookie 认证,用 `[Cookies]` 或 cookie capture
|
||||
|
||||
**CRUD 标准模式**:
|
||||
|
||||
| 操作 | 生成要求 |
|
||||
|------|---------|
|
||||
| **创建(POST)** | capture 返回的 ID;**所有**唯一约束字段用 `{{newUuid}}` 防冲突 |
|
||||
| **查询详情(GET)** | **逐字段断言**(类型 + 值,见 3.3) |
|
||||
| **查询列表(GET)** | 分页结构断言 + items[0] 逐字段断言 |
|
||||
| **修改(PUT/PATCH)** | 修改后**紧跟一个 GET 验证修改生效** |
|
||||
| **删除(DELETE)** | 删除后**紧跟一个 GET 验证已删除** |
|
||||
|
||||
#### 唯一值防冲突规范(强制执行)
|
||||
|
||||
**所有创建类请求(POST)中,凡是有唯一约束的字段,必须使用 `{{newUuid}}` 或包含 UUID 后缀**,确保测试可重复运行。
|
||||
|
||||
| 场景 | 正确做法 | 错误做法 |
|
||||
|------|---------|---------|
|
||||
| 编码/Code 字段 | `"code": "TEST-{{newUuid}}"` | `"code": "TEST-001"` |
|
||||
| 名称(有唯一约束) | `"name": "测试-{{newUuid}}"` | `"name": "测试角色"` |
|
||||
| 用户名 | `"username": "hurl_{{newUuid}}"` | `"username": "test_user"` |
|
||||
| 手机号 | `"phone": "189{{newUuid}}"` 取前11位,或放在 env 文件 | `"phone": "18899990001"` |
|
||||
| 邮箱 | `"email": "{{newUuid}}@test.com"` | `"email": "test@test.com"` |
|
||||
|
||||
**后续步骤需要引用的值(如登录用的 username/password)**:
|
||||
- 必须定义在 `env/dev.env` 中作为变量
|
||||
- 在创建请求和后续引用处都通过 `{{变量名}}` 引用,保持一致
|
||||
- env 文件中的值也应该带有测试标识前缀(如 `hurl_test_`),避免与真实数据冲突
|
||||
- **env 中有唯一约束的变量值每次运行前可能需要更新**(文档中注明)
|
||||
|
||||
**业务流程模式**:
|
||||
|
||||
- 按用户描述的流程顺序编排请求
|
||||
- 上一步的输出(ID、状态等)通过 `[Captures]` 传给下一步
|
||||
- 关键步骤加中间状态验证(如创建订单后验证状态为"待支付")
|
||||
|
||||
#### 3.3 schema 到断言的映射
|
||||
|
||||
读到 schema 字段后,按以下规则生成 jsonpath 断言:
|
||||
|
||||
**通用类型映射(所有语言)**:
|
||||
|
||||
| Schema 类型特征 | Hurl 断言 |
|
||||
|----------------|-----------|
|
||||
| 字符串(string / str / String) | `isString` |
|
||||
| 整数(int / integer / long / Int) | `isInteger` |
|
||||
| 浮点(float / double / decimal / Float) | `isNumber` |
|
||||
| 布尔(bool / boolean / Boolean) | `isBoolean` |
|
||||
| 数组 / 列表([] / List / Array) | `isList` |
|
||||
| 嵌套对象(struct / class / dict / object) | `isObject`,并递归检查子字段 |
|
||||
| 可空类型(指针 / Optional / nullable) | `exists`(不强制类型,因为可能是 null) |
|
||||
| 不参与序列化(json:"-" / @JsonIgnore / exclude=True) | **跳过,不生成断言** |
|
||||
| 条件序列化(omitempty / if not None) | `exists` 或不生成(取决于场景) |
|
||||
|
||||
**Go 特定映射**:
|
||||
|
||||
| Go 类型 | Hurl 断言 |
|
||||
|---------|-----------|
|
||||
| `string` | `isString` |
|
||||
| `int`, `int8/16/32/64`, `uint`, `uint8/16/32/64` | `isInteger` |
|
||||
| `float32`, `float64` | `isNumber` |
|
||||
| `bool` | `isBoolean` |
|
||||
| `[]T` | `isList` |
|
||||
| `*string`, `*int`, `*uint` 等指针 | `exists` |
|
||||
| `time.Time` | `isString`(通常序列化为字符串) |
|
||||
| `map[string]any` | `isObject` |
|
||||
|
||||
**Python 特定映射(Pydantic / Django / FastAPI)**:
|
||||
|
||||
| Python 类型 | Hurl 断言 |
|
||||
|------------|-----------|
|
||||
| `str` | `isString` |
|
||||
| `int` | `isInteger` |
|
||||
| `float`, `Decimal` | `isNumber` |
|
||||
| `bool` | `isBoolean` |
|
||||
| `list[T]`, `List[T]` | `isList` |
|
||||
| `Optional[T]`, `T | None` | `exists` |
|
||||
| `dict`, `Dict` | `isObject` |
|
||||
| `datetime`, `date` | `isString` |
|
||||
|
||||
**TypeScript/JavaScript 特定映射**:
|
||||
|
||||
| TS/JS 类型 | Hurl 断言 |
|
||||
|-----------|-----------|
|
||||
| `string` | `isString` |
|
||||
| `number`(整数上下文) | `isInteger` |
|
||||
| `number`(通用) | `isNumber` |
|
||||
| `boolean` | `isBoolean` |
|
||||
| `T[]`, `Array<T>` | `isList` |
|
||||
| `T \| null`, `T \| undefined` | `exists` |
|
||||
| `object`, `Record<>` | `isObject` |
|
||||
| `Date` | `isString` |
|
||||
|
||||
**Java 特定映射**:
|
||||
|
||||
| Java 类型 | Hurl 断言 |
|
||||
|----------|-----------|
|
||||
| `String` | `isString` |
|
||||
| `Integer`, `Long`, `int`, `long` | `isInteger` |
|
||||
| `Double`, `Float`, `BigDecimal` | `isNumber` |
|
||||
| `Boolean`, `boolean` | `isBoolean` |
|
||||
| `List<T>` | `isList` |
|
||||
| `@Nullable`, `Optional<T>` | `exists` |
|
||||
| `Map<K,V>` | `isObject` |
|
||||
| `LocalDateTime`, `Instant` | `isString` |
|
||||
|
||||
#### 3.4 统一响应格式断言
|
||||
|
||||
根据 Phase 1 画像中发现的统一响应格式,为**每个成功响应**添加格式断言。
|
||||
|
||||
示例:如果项目的统一格式是 `{code, msg, data, timestamp}`:
|
||||
|
||||
```hurl
|
||||
[Asserts]
|
||||
jsonpath "$.code" == 0
|
||||
jsonpath "$.msg" == "success"
|
||||
jsonpath "$.timestamp" isIsoDate
|
||||
```
|
||||
|
||||
示例:如果项目的格式是 `{status, message, result}`:
|
||||
|
||||
```hurl
|
||||
[Asserts]
|
||||
jsonpath "$.status" == "ok"
|
||||
jsonpath "$.message" isString
|
||||
```
|
||||
|
||||
示例:如果项目无统一包装,直接返回数据:
|
||||
|
||||
```hurl
|
||||
[Asserts]
|
||||
# 直接断言业务字段
|
||||
jsonpath "$.id" isInteger
|
||||
jsonpath "$.name" isString
|
||||
```
|
||||
|
||||
**不要假设响应格式,必须从代码中确认。**
|
||||
|
||||
#### 3.5 分页断言
|
||||
|
||||
根据 Phase 1 画像中发现的分页结构生成。
|
||||
|
||||
示例:如果是 `{items, total, page, size}` 格式:
|
||||
|
||||
```hurl
|
||||
jsonpath "$.data.items" isList
|
||||
jsonpath "$.data.total" isInteger
|
||||
jsonpath "$.data.total" >= 1
|
||||
jsonpath "$.data.page" isInteger
|
||||
jsonpath "$.data.size" isInteger
|
||||
# items 内元素逐字段断言
|
||||
jsonpath "$.data.items[0].{field}" {type_assert}
|
||||
```
|
||||
|
||||
示例:如果是 `{results, count, next, previous}` 格式(Django 风格):
|
||||
|
||||
```hurl
|
||||
jsonpath "$.results" isList
|
||||
jsonpath "$.count" isInteger
|
||||
jsonpath "$.count" >= 1
|
||||
# results 内元素逐字段断言
|
||||
jsonpath "$.results[0].{field}" {type_assert}
|
||||
```
|
||||
|
||||
**根据实际代码调整字段名,不硬编码。**
|
||||
|
||||
#### 3.6 异常 Case 模板
|
||||
|
||||
**参数校验失败**:
|
||||
|
||||
```hurl
|
||||
# ── 异常:参数校验失败 ──
|
||||
POST {{base_url}}/{path}
|
||||
Authorization: Bearer {{token}}
|
||||
Content-Type: application/json
|
||||
{
|
||||
"required_field": ""
|
||||
}
|
||||
HTTP {expected_error_status}
|
||||
[Asserts]
|
||||
# 断言错误响应格式(根据项目约定调整)
|
||||
```
|
||||
|
||||
> HTTP 状态码根据项目实际返回确定:有的项目错误也返回 200 + 业务错误码,有的返回 400/422。
|
||||
|
||||
**未认证访问**:
|
||||
|
||||
```hurl
|
||||
# ── 异常:未认证访问 ──
|
||||
GET {{base_url}}/{protected_path}
|
||||
HTTP {expected_unauth_status}
|
||||
```
|
||||
|
||||
**越权访问**(如果用户要求):
|
||||
|
||||
```hurl
|
||||
# ── 异常:用户 B 不能访问用户 A 的资源 ──
|
||||
GET {{base_url}}/{path}/{{user_a_resource_id}}
|
||||
Authorization: Bearer {{user_b_token}}
|
||||
HTTP {expected_forbidden_status}
|
||||
```
|
||||
|
||||
#### 3.7 特殊场景处理
|
||||
|
||||
| 场景 | 处理策略 |
|
||||
|------|---------|
|
||||
| **短信/邮件验证码** | 建议服务端加 test_mode 开关,固定验证码写入 env 文件;注释提醒用户 |
|
||||
| **第三方支付** | 优先用项目内部支付方式(如钱包支付);如需测回调,直接 POST 回调接口模拟 |
|
||||
| **OAuth 登录(微信/Google/GitHub)** | 建议服务端加 test_mode 支持直接传 openid/email;注释提醒用户 |
|
||||
| **文件上传** | 用 Hurl 的 `[Multipart]` 语法 + testdata 目录下的样本文件 |
|
||||
| **外部 API 依赖** | 只测参数校验和错误响应格式,不断言业务结果;注释说明依赖 |
|
||||
| **WebSocket** | Hurl 不支持,注释说明跳过 |
|
||||
| **异步任务结果** | 用 Hurl 的 `retry` + `retry-interval` 轮询直到状态变更 |
|
||||
|
||||
异步轮询示例:
|
||||
|
||||
```hurl
|
||||
# 等待异步任务完成(最多重试 10 次,间隔 500ms)
|
||||
GET {{base_url}}/{path}/{{task_id}}
|
||||
Authorization: Bearer {{token}}
|
||||
[Options]
|
||||
retry: 10
|
||||
retry-interval: 500ms
|
||||
HTTP 200
|
||||
[Asserts]
|
||||
jsonpath "$.data.status" == "completed"
|
||||
```
|
||||
|
||||
#### 3.8 文件输出
|
||||
|
||||
**目录结构**(首次使用时创建,如不存在):
|
||||
|
||||
```
|
||||
tests/hurl/
|
||||
├── env/
|
||||
│ └── dev.env # 环境变量
|
||||
├── testdata/ # 测试用的样本文件
|
||||
├── flows/ # 业务流程测试
|
||||
├── modules/ # 按模块的接口测试
|
||||
│ └── {module}/
|
||||
│ └── 01-crud.hurl
|
||||
├── negative/ # 异常/边界测试
|
||||
├── contract/ # 接口契约验证
|
||||
├── reports/ # Phase 5 发现报告(自动生成)
|
||||
│ └── findings-{name}-{date}.md
|
||||
└── Makefile # 快捷命令
|
||||
```
|
||||
|
||||
文件放置规则:
|
||||
|
||||
| 用户描述 | 输出路径 |
|
||||
|---------|---------|
|
||||
| 测试 XX 流程 / 业务流程 | `tests/hurl/flows/{flow-name}.hurl` |
|
||||
| 测试 XX 模块的 CRUD / 接口 | `tests/hurl/modules/{module}/01-crud.hurl` |
|
||||
| 测试异常/边界/权限 | `tests/hurl/negative/{name}.hurl` |
|
||||
| 测试接口契约/字段对齐 | `tests/hurl/contract/{name}.hurl` |
|
||||
|
||||
**如果项目已有 hurl 测试目录结构,沿用已有约定,不要另起炉灶。**
|
||||
|
||||
#### 3.9 env 和 Makefile
|
||||
|
||||
**env/dev.env**(首次创建时生成,内容基于 Phase 1 画像):
|
||||
|
||||
```properties
|
||||
# 服务地址
|
||||
base_url=http://localhost:{port}
|
||||
|
||||
# 认证信息(根据项目实际填写)
|
||||
admin_username={默认用户名}
|
||||
admin_password={默认密码}
|
||||
|
||||
# 测试模式变量(如果有特殊场景)
|
||||
# test_sms_code=888888
|
||||
# test_openid=test_openid_001
|
||||
```
|
||||
|
||||
**Makefile**(首次创建时生成):
|
||||
|
||||
```makefile
|
||||
SHELL := /bin/bash
|
||||
ENV ?= dev
|
||||
HURL_OPTS := --variables-file env/$(ENV).env --test
|
||||
|
||||
.PHONY: test test-flows test-modules test-negative report
|
||||
|
||||
test: ## 运行所有测试
|
||||
hurl $(HURL_OPTS) .
|
||||
|
||||
test-flows: ## 运行业务流程测试
|
||||
hurl $(HURL_OPTS) flows/
|
||||
|
||||
test-modules: ## 运行模块接口测试
|
||||
hurl $(HURL_OPTS) modules/
|
||||
|
||||
test-negative: ## 运行异常测试
|
||||
hurl $(HURL_OPTS) negative/
|
||||
|
||||
report: ## 生成 HTML 报告
|
||||
hurl $(HURL_OPTS) --report-html build/report/ .
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 验证(Verify)
|
||||
|
||||
**目标:确保生成的 .hurl 文件语法正确、可运行。**
|
||||
|
||||
#### 4.1 语法自检
|
||||
|
||||
- [ ] 每个请求之间有空行分隔
|
||||
- [ ] `[Captures]` 和 `[Asserts]` 拼写正确(大小写敏感)
|
||||
- [ ] 所有 `{{变量}}` 引用都有来源(env 文件定义 或 上游 `[Captures]`)
|
||||
- [ ] JSON body 无尾逗号、格式正确
|
||||
- [ ] `HTTP {status}` 在请求之后、`[Captures]` / `[Asserts]` 之前
|
||||
- [ ] 请求和断言之间没有多余空行(`HTTP` 行必须紧跟请求)
|
||||
- [ ] 文件上传路径相对于 hurl 文件位置正确
|
||||
|
||||
#### 4.2 运行测试
|
||||
|
||||
```bash
|
||||
hurl --variables-file tests/hurl/env/dev.env --test tests/hurl/{生成的文件}
|
||||
```
|
||||
|
||||
- 服务在跑 → 执行,如有失败分析修正
|
||||
- 服务没跑 → 跳过,告知用户手动验证命令
|
||||
|
||||
---
|
||||
|
||||
### Phase 5: 研判(Triage)
|
||||
|
||||
**目标:分析测试失败原因,区分"测试自身问题"和"接口问题",生成发现报告。**
|
||||
|
||||
> **核心原则:不因实际返回与预期不符而盲目修改断言。差异是"发现",需要分类研判。**
|
||||
|
||||
#### 5.1 失败分类(A/B/C 三类)
|
||||
|
||||
每个断言失败必须归入以下三类之一:
|
||||
|
||||
| 类型 | 含义 | 判断标准 | 处置方式 |
|
||||
|------|------|---------|---------|
|
||||
| **A 类 — 测试自身错误** | JSON path 写错、请求参数遗漏、env 变量缺失、前置步骤失败导致 ID 为空 | 改正后 DTO/Handler 代码能对应上 | ✅ **自动修正测试**,不打扰用户 |
|
||||
| **B 类 — 代码一致性问题** | DTO 定义和 Handler 实际返回不一致(如 DTO 写 `json:"id"` 但 Handler 返回 `"ID"`;DTO 定义了分页结构但 Handler 用了另一种) | DTO 说的是 A,Handler 做的是 B,两个都是代码 | ⚠️ **以 Handler 为准修正断言** + **写入发现报告** |
|
||||
| **C 类 — 接口行为 Bug** | 接口行为不符合合理预期(如详情接口缺少列表接口有的字段;创建接口返回 500;同一个 DTO 在不同接口表现不一致) | Handler 的行为本身不合理,不是 DTO 过期的问题 | 🐛 **保留原始断言(测试会失败)** + **写入发现报告** |
|
||||
|
||||
#### 5.2 分类判断流程
|
||||
|
||||
```
|
||||
断言失败
|
||||
│
|
||||
├─ jsonpath 写错 / 变量未定义 / 前置步骤失败?
|
||||
│ → A 类:修正测试
|
||||
│
|
||||
├─ DTO 定义字段名 ≠ 实际返回字段名?(如 id vs ID, list vs items)
|
||||
│ │
|
||||
│ ├─ Handler 代码确实返回了不同的结构?
|
||||
│ │ → B 类:以 Handler 为准,报告不一致
|
||||
│ │
|
||||
│ └─ Handler 代码引用了 DTO 但行为不符?
|
||||
│ → C 类:接口 Bug
|
||||
│
|
||||
├─ 某个字段在接口 A 有、接口 B 没有?(如列表有但详情没有)
|
||||
│ → C 类:接口一致性 Bug
|
||||
│
|
||||
├─ 接口返回 5xx 错误?
|
||||
│ → C 类:服务端 Bug
|
||||
│
|
||||
└─ 业务逻辑不符预期?(如已传 packages 但响应中 packages 为空)
|
||||
→ C 类:业务逻辑 Bug
|
||||
```
|
||||
|
||||
#### 5.3 发现报告格式
|
||||
|
||||
报告输出到 **`tests/hurl/reports/findings-{测试文件名}-{日期}.md`**,格式如下:
|
||||
|
||||
```markdown
|
||||
# 🔍 Hurl 测试发现报告
|
||||
|
||||
- 测试文件:`flows/package-resource-full-flow.hurl`
|
||||
- 生成时间:2026-03-30
|
||||
- 总请求数:25
|
||||
- 通过:20
|
||||
- 失败:5(A类: 2, B类: 1, C类: 2)
|
||||
|
||||
---
|
||||
|
||||
## A 类(测试自身错误)— 已自动修正
|
||||
|
||||
### A-1: 角色名冲突导致创建失败
|
||||
- **位置**:第 2 步 POST /api/admin/roles
|
||||
- **原因**:角色名使用固定值,重复运行时 409 冲突
|
||||
- **修正**:角色名改为 `hurl测试角色-{{newUuid}}`
|
||||
|
||||
---
|
||||
|
||||
## B 类(代码一致性问题)— 已按 Handler 修正断言
|
||||
|
||||
### B-1: 角色接口返回原始 GORM 模型而非 DTO
|
||||
- **位置**:POST /api/admin/roles → 响应
|
||||
- **DTO 定义**:`RoleResponse` 有 `json:"id"`(小写)
|
||||
- **Handler 实际**:直接返回 `model.Role`,字段为 `ID`(大写 GORM 默认)
|
||||
- **影响**:前端按 API 文档对接会取不到 `id` 字段
|
||||
- **建议**:Handler 添加 model → DTO 转换
|
||||
|
||||
---
|
||||
|
||||
## C 类(接口行为 Bug)— 断言保留,测试会失败
|
||||
|
||||
### C-1: 套餐详情接口缺少 one_time_commission_amount
|
||||
- **位置**:GET /api/admin/packages/:id(代理视角)
|
||||
- **预期**:`PackageResponse` DTO 定义了 `one_time_commission_amount` 字段
|
||||
- **实际**:列表接口 GET /packages 返回该字段,详情接口不返回
|
||||
- **影响**:代理端查看单个套餐时看不到佣金信息
|
||||
- **建议**:Detail handler 增加佣金信息增强逻辑
|
||||
|
||||
### C-2: 批量分配对已授权店铺返回 500
|
||||
- **位置**:POST /api/admin/shop-package-batch-allocations
|
||||
- **预期**:对已有授权的店铺,应返回业务错误码(4xx),而非 500
|
||||
- **实际**:返回 `{code: 2001, msg: "内部服务器错误"}`
|
||||
- **影响**:前端无法区分是参数错误还是服务异常
|
||||
- **建议**:添加"已存在授权"的前置检查,返回明确的业务错误
|
||||
```
|
||||
|
||||
#### 5.4 研判完成后的交付物
|
||||
|
||||
| 交付物 | 说明 |
|
||||
|-------|------|
|
||||
| **修正后的 .hurl 文件** | A 类已自动修正;B 类按 Handler 实际修正;C 类**保留原始断言**(预期失败) |
|
||||
| **发现报告** | `tests/hurl/reports/findings-{name}-{date}.md` |
|
||||
| **终端摘要** | 向用户输出发现数量和关键 C 类问题摘要 |
|
||||
|
||||
#### 5.5 用户交互
|
||||
|
||||
研判完成后,向用户展示摘要并询问:
|
||||
|
||||
```
|
||||
## 研判结果
|
||||
|
||||
- A 类(测试错误):N 个,已自动修正
|
||||
- B 类(一致性问题):N 个,已按实际修正并记录
|
||||
- C 类(接口 Bug):N 个,断言保留(测试会失败直到代码修复)
|
||||
|
||||
### C 类发现摘要:
|
||||
1. [C-1] 套餐详情缺少 one_time_commission_amount
|
||||
2. [C-2] 批量分配对已授权店铺返回 500
|
||||
|
||||
详细报告已写入: tests/hurl/reports/findings-xxx.md
|
||||
|
||||
是否需要对某个 C 类发现调整处置?(如确认为已知行为,改为 B 类)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 红线规则
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| **不跳过 Phase 1** | 必须读代码确认接口路径和字段,不能凭记忆或猜测 |
|
||||
| **不跳过 Phase 2** | 必须向用户展示发现的接口和字段,确认后再生成 |
|
||||
| **不编造字段** | 所有断言的字段名必须来自实际 schema 代码 |
|
||||
| **不编造路径** | 所有接口路径必须来自实际路由代码 |
|
||||
| **不遗漏字段** | schema 中每个参与序列化的字段都必须有对应断言 |
|
||||
| **不硬编码 ID** | 所有依赖的 ID 通过 `[Captures]` 从上游请求获取 |
|
||||
| **不假设响应格式** | 统一响应结构必须从代码中确认,不同项目格式不同 |
|
||||
| **唯一值防冲突** | 创建类请求的**所有**唯一约束字段必须使用 `{{newUuid}}`(详见 3.2 规范) |
|
||||
| **自给自足** | 每个 .hurl 文件自己创建测试数据,不依赖外部数据准备 |
|
||||
| **不为通过而改断言** | 断言失败时走 Phase 5 研判流程,C 类 Bug 必须保留断言并写入发现报告 |
|
||||
| **Handler 追溯** | 断言字段名必须追溯到 Handler 实际返回的类型,不能只看 DTO 定义(详见 Phase 1 真理源层级) |
|
||||
|
||||
---
|
||||
|
||||
## AI 助手检查清单
|
||||
|
||||
### 生成阶段自检(Phase 3 完成后)
|
||||
|
||||
1. ✅ 文件头注释包含流程描述和前置条件
|
||||
2. ✅ 认证步骤正确 capture 了 token / cookie
|
||||
3. ✅ 所有依赖数据通过 API 链式创建(自给自足)
|
||||
4. ✅ 每个成功响应断言了项目的统一响应格式
|
||||
5. ✅ 查询详情接口**逐字段断言**(类型 + 值,基于 Handler 实际返回)
|
||||
6. ✅ 分页接口断言了分页结构 + 第一条记录的字段
|
||||
7. ✅ 修改操作后紧跟 GET 验证修改生效
|
||||
8. ✅ 删除操作后紧跟 GET 验证已删除
|
||||
9. ✅ **所有**创建请求的唯一约束字段使用了 `{{newUuid}}`
|
||||
10. ✅ `{{变量}}` 引用无悬空(都有 env 或 capture 来源)
|
||||
11. ✅ 文件放在了正确的目录位置
|
||||
12. ✅ 特殊场景有明确的处理策略和注释提醒
|
||||
13. ✅ 断言字段名已追溯到 Handler 实际返回类型(非仅依赖 DTO)
|
||||
|
||||
### 研判阶段自检(Phase 5 完成后)
|
||||
|
||||
14. ✅ 每个断言失败都已分类(A/B/C)
|
||||
15. ✅ A 类错误已自动修正
|
||||
16. ✅ B 类不一致已按 Handler 修正断言,并写入发现报告
|
||||
17. ✅ C 类 Bug 保留了原始断言(测试预期失败),并写入发现报告
|
||||
18. ✅ 发现报告已输出到 `tests/hurl/reports/findings-{name}-{date}.md`
|
||||
19. ✅ 向用户展示了研判结果摘要
|
||||
|
||||
---
|
||||
|
||||
## 附录:Hurl 语法速查
|
||||
|
||||
**生成 .hurl 文件时必须参照本速查,不可凭记忆编造语法。**
|
||||
|
||||
### 文件结构
|
||||
|
||||
一个 .hurl 文件由多个 entry 组成,每个 entry = 请求 + 可选响应:
|
||||
|
||||
```
|
||||
请求1
|
||||
响应1(可选)
|
||||
|
||||
请求2
|
||||
响应2(可选)
|
||||
```
|
||||
|
||||
entry 之间用空行分隔。
|
||||
|
||||
### 请求格式
|
||||
|
||||
```hurl
|
||||
METHOD URL
|
||||
Header1: value1
|
||||
Header2: value2
|
||||
[Options]
|
||||
key: value
|
||||
[Query]
|
||||
param1: value1
|
||||
[Form]
|
||||
field1: value1
|
||||
[Multipart]
|
||||
file1: file,path/to/file;
|
||||
[BasicAuth]
|
||||
username: password
|
||||
[Cookies]
|
||||
name: value
|
||||
BODY(JSON / XML / multiline string / file)
|
||||
```
|
||||
|
||||
**规则**:
|
||||
- Method + URL 是第一行,必须
|
||||
- Headers 紧跟 URL 之后(无 section 标记)
|
||||
- Sections(`[Query]`、`[Form]`、`[Options]` 等)顺序任意
|
||||
- Body 必须在最后
|
||||
- JSON body 直接写 `{ }` 即可,自动设置 Content-Type: application/json
|
||||
|
||||
### 响应格式
|
||||
|
||||
```hurl
|
||||
HTTP {status_code}
|
||||
Header1: expected_value1
|
||||
[Captures]
|
||||
var_name: jsonpath "$.path"
|
||||
[Asserts]
|
||||
jsonpath "$.field" == "value"
|
||||
```
|
||||
|
||||
**规则**:
|
||||
- `HTTP {status}` 紧跟请求之后(中间不能有空行)
|
||||
- `HTTP *` 表示不检查状态码
|
||||
- Headers 检查紧跟 HTTP 行之后
|
||||
- `[Captures]` 和 `[Asserts]` 顺序任意
|
||||
|
||||
### 变量和模板
|
||||
|
||||
```hurl
|
||||
# 引用变量(从 env 文件、命令行或上游 capture 获取)
|
||||
GET {{base_url}}/api/users/{{user_id}}
|
||||
|
||||
# 内置函数
|
||||
POST {{base_url}}/api/users
|
||||
{
|
||||
"email": "{{newUuid}}@test.com",
|
||||
"created_at": "{{newDate}}"
|
||||
}
|
||||
```
|
||||
|
||||
可用函数:
|
||||
- `{{newUuid}}` — 生成 UUID v4
|
||||
- `{{newDate}}` — 生成 RFC 3339 UTC 时间戳
|
||||
|
||||
### Capture 语法
|
||||
|
||||
```hurl
|
||||
[Captures]
|
||||
# JSONPath
|
||||
token: jsonpath "$.data.access_token"
|
||||
user_id: jsonpath "$.data.id"
|
||||
first_item: jsonpath "$.items[0].name"
|
||||
|
||||
# Header
|
||||
location: header "Location"
|
||||
|
||||
# Cookie
|
||||
session: cookie "SESSIONID"
|
||||
|
||||
# Body(整个响应体作为字符串)
|
||||
full_body: body
|
||||
|
||||
# Status code
|
||||
code: status
|
||||
|
||||
# 正则表达式
|
||||
csrf: regex "name=\"csrf\" value=\"([^\"]+)\""
|
||||
|
||||
# 响应时间(毫秒)
|
||||
response_time: duration
|
||||
```
|
||||
|
||||
### Assert 语法
|
||||
|
||||
```hurl
|
||||
[Asserts]
|
||||
# ── 状态码 ──
|
||||
status == 200
|
||||
status >= 200
|
||||
status < 300
|
||||
|
||||
# ── JSONPath 断言 ──
|
||||
jsonpath "$.name" == "Alice" # 等于
|
||||
jsonpath "$.name" != "Bob" # 不等于
|
||||
jsonpath "$.age" > 18 # 大于
|
||||
jsonpath "$.age" >= 18 # 大于等于
|
||||
jsonpath "$.count" < 100 # 小于
|
||||
jsonpath "$.items" count == 5 # 集合长度
|
||||
jsonpath "$.name" startsWith "Al" # 前缀
|
||||
jsonpath "$.name" endsWith "ce" # 后缀
|
||||
jsonpath "$.name" contains "lic" # 包含
|
||||
jsonpath "$.date" matches /\\d{4}-\\d{2}-\\d{2}/ # 正则
|
||||
|
||||
# ── 类型断言 ──
|
||||
jsonpath "$.name" isString
|
||||
jsonpath "$.age" isInteger
|
||||
jsonpath "$.score" isFloat
|
||||
jsonpath "$.count" isNumber # 整数或浮点
|
||||
jsonpath "$.active" isBoolean
|
||||
jsonpath "$.items" isList
|
||||
jsonpath "$.meta" isObject
|
||||
jsonpath "$.id" isUuid
|
||||
jsonpath "$.created_at" isIsoDate # RFC 3339 格式
|
||||
jsonpath "$.field" isEmpty # 空集合
|
||||
|
||||
# ── 存在性 ──
|
||||
jsonpath "$.field" exists
|
||||
jsonpath "$.field" not exists
|
||||
|
||||
# ── 否定 ──
|
||||
jsonpath "$.name" not contains "Bob"
|
||||
jsonpath "$.status" not == "deleted"
|
||||
|
||||
# ── Header 断言 ──
|
||||
header "Content-Type" contains "application/json"
|
||||
header "X-Request-Id" exists
|
||||
|
||||
# ── 性能 ──
|
||||
duration < 1000 # 响应时间(毫秒)
|
||||
|
||||
# ── Body 断言 ──
|
||||
body contains "Hello"
|
||||
bytes count == 1024
|
||||
```
|
||||
|
||||
### Options(逐请求配置)
|
||||
|
||||
```hurl
|
||||
GET {{base_url}}/api/task/{{task_id}}
|
||||
[Options]
|
||||
retry: 10 # 最大重试次数(-1 = 无限)
|
||||
retry-interval: 500ms # 重试间隔
|
||||
delay: 2s # 请求前等待
|
||||
location: true # 跟随重定向
|
||||
insecure: true # 允许不安全 SSL
|
||||
verbose: true # 输出详细日志
|
||||
very-verbose: true # 输出更详细日志
|
||||
skip: true # 跳过此请求
|
||||
variable: key=value # 定义变量
|
||||
HTTP 200
|
||||
```
|
||||
|
||||
### Multipart 文件上传
|
||||
|
||||
```hurl
|
||||
POST {{base_url}}/api/upload
|
||||
[Multipart]
|
||||
file: file,testdata/sample.xlsx;
|
||||
field1: value1
|
||||
# 指定 Content-Type
|
||||
file2: file,testdata/data.bin; application/octet-stream
|
||||
```
|
||||
|
||||
### 运行命令
|
||||
|
||||
```bash
|
||||
# 运行单个文件
|
||||
hurl --test file.hurl
|
||||
|
||||
# 带变量文件
|
||||
hurl --variables-file env/dev.env --test file.hurl
|
||||
|
||||
# 运行目录下所有 .hurl
|
||||
hurl --test tests/hurl/
|
||||
|
||||
# 生成 HTML 报告
|
||||
hurl --test --report-html build/report/ tests/hurl/
|
||||
|
||||
# 生成 JUnit 报告(CI 用)
|
||||
hurl --test --report-junit build/report.xml tests/hurl/
|
||||
|
||||
# 并行执行(--test 默认并行,同文件内串行)
|
||||
hurl --test --jobs 4 tests/hurl/
|
||||
|
||||
# 指定单个变量
|
||||
hurl --variable base_url=http://localhost:3000 --test file.hurl
|
||||
|
||||
# 失败后继续执行
|
||||
hurl --test --continue-on-error tests/hurl/
|
||||
```
|
||||
|
||||
### 常见错误
|
||||
|
||||
| 错误 | 原因 | 修正 |
|
||||
|------|------|------|
|
||||
| `HTTP 200` 和请求之间有空行 | 空行会被当作 entry 分隔符 | 删除空行,HTTP 行紧跟请求 |
|
||||
| JSON body 有尾逗号 | Hurl 严格解析 JSON | 删除最后一个逗号 |
|
||||
| `jsonpath` 写成 `json_path` 或 `JsonPath` | 关键字大小写敏感 | 必须小写 `jsonpath` |
|
||||
| `[Captures]` 写成 `[Capture]` | 必须是复数 | `[Captures]`、`[Asserts]`、`[Options]` |
|
||||
| 变量 `{{ var }}` 有空格 | 允许,但建议统一 | `{{var}}` 或 `{{ var }}` 都可以 |
|
||||
| `isIsoDate` 用在非 RFC 3339 格式 | 只认 `YYYY-MM-DDTHH:mm:ss` 格式 | 如果是其他格式用 `matches` |
|
||||
| `file,path;` 路径含 `..` | Hurl 禁止相对父目录 | 用 `--file-root` 或调整路径 |
|
||||
97
.opencode/skills/openspec-api-contract/SKILL.md
Normal file
97
.opencode/skills/openspec-api-contract/SKILL.md
Normal file
@@ -0,0 +1,97 @@
|
||||
---
|
||||
name: openspec-api-contract
|
||||
description: OpenSpec API 契约规范。创建涉及接口的 OpenSpec 提案时使用。探索阶段提供业务与契约引导清单,提案文档要求 API 契约设计、错误码与完成标准等必填章节。
|
||||
---
|
||||
|
||||
# OpenSpec API 契约规范
|
||||
|
||||
**适用场景**:创建涉及 API/接口的 OpenSpec 提案时,探索和提案两个阶段均须遵守本规范。
|
||||
|
||||
---
|
||||
|
||||
## 一、探索阶段引导清单
|
||||
|
||||
在 `openspec-explore` 阶段,当内容涉及接口时,讨论必须覆盖以下所有维度。
|
||||
|
||||
### 业务与契约确认
|
||||
|
||||
**输入与输出**
|
||||
- 请求方是谁?用户类型(SuperAdmin/Platform/Agent/Enterprise/Personal)?
|
||||
- 输入参数:必填/选填字段、格式约束、参数来源(路径/查询/Body)?
|
||||
- 输出结构:哪些字段必须返回?是否需要分页?
|
||||
|
||||
**业务规则**
|
||||
- 核心业务规则与边界条件?
|
||||
- 是否涉及状态流转?状态机的完整定义?
|
||||
- 依赖外部服务时,外部异常的降级行为?
|
||||
|
||||
**权限与资源所有权**
|
||||
- 哪些用户类型可以访问?
|
||||
- 是否涉及跨用户/跨店铺/跨企业的资源访问?(需三层越权防护)
|
||||
- 资源所有权校验方式:`CanManageShop` / `CanManageEnterprise` / 自有资源?
|
||||
|
||||
**幂等性**
|
||||
- 操作类型:查询(天然幂等)/ 创建 / 更新 / 删除?
|
||||
- 写操作幂等策略:状态条件更新 / Redis 业务键防重 + 分布式锁 / 乐观锁(version)?
|
||||
- 异步任务是否需要任务锁?
|
||||
|
||||
**数据模型变更**
|
||||
- 是否需要新建表、修改现有表或数据回填?
|
||||
- 迁移策略:上线顺序、兼容旧数据的方式?
|
||||
- 是否影响 GORM Callback 自动数据权限过滤?
|
||||
|
||||
**错误码与异常语义**
|
||||
- 预期错误场景及对应错误码?
|
||||
- 错误响应是否泄露敏感信息?(参数校验失败统一返回 `CodeInvalidParam`)
|
||||
|
||||
---
|
||||
|
||||
## 二、提案文档必填章节
|
||||
|
||||
在 `openspec-propose` 生成的提案(`proposal.md` / `design.md`)中,涉及接口时以下内容**不可缺失**。
|
||||
|
||||
### API 契约设计
|
||||
|
||||
**接口定义**
|
||||
|
||||
| 项 | 内容 |
|
||||
|---|---|
|
||||
| Endpoint | `METHOD /api/{scope}/{resource}[/:id]` |
|
||||
| 请求参数 | 字段名、类型、必填/选填、说明 |
|
||||
| 响应结构 | `data` 字段的完整结构定义 |
|
||||
| 鉴权要求 | 允许的用户类型 |
|
||||
| 资源所有权 | 所有权校验方式 |
|
||||
|
||||
**列表接口额外要求**
|
||||
- 分页:`page` + `page_size`(默认 20,最大 100)
|
||||
- 排序:默认排序字段与方向
|
||||
- 过滤:支持的过滤条件列表
|
||||
|
||||
**错误码清单**
|
||||
|
||||
| 场景 | 错误码 | 说明 |
|
||||
|---|---|---|
|
||||
| 参数校验失败 | `CodeInvalidParam` | 统一返回,不泄露细节 |
|
||||
| 资源不存在/越权 | `CodeForbidden` | 不区分两者,防止信息泄露 |
|
||||
| (业务错误场景...) | (对应错误码) | (说明) |
|
||||
|
||||
### 完成标准
|
||||
|
||||
**最小验证步骤**(按顺序列出可操作的验证步骤)
|
||||
|
||||
1. (例:调用创建接口,验证返回 `code=0`)
|
||||
2. (例:查询接口确认数据存在且字段正确)
|
||||
3. (例:PostgreSQL MCP 查询确认数据库记录符合预期)
|
||||
|
||||
**影响范围说明**
|
||||
- 新增/修改的表:
|
||||
- 影响的现有接口:
|
||||
- 影响的权限与数据过滤范围:
|
||||
|
||||
---
|
||||
|
||||
## 约束(必须遵守)
|
||||
|
||||
- **优先复用现有架构与库**:不引入新依赖,错误码优先复用已有定义
|
||||
- **不做顺手重构**:提案范围严格限定在目标功能;发现可优化点,记录到 backlog 但不执行
|
||||
- **数据库设计**:禁止外键约束,禁止 GORM 关联标签,关联通过 ID 字段手动维护
|
||||
@@ -1,265 +0,0 @@
|
||||
---
|
||||
name: systematic-debugging
|
||||
description: 遇到任何 bug、异常行为、报错时必须使用。在提出任何修复方案之前,强制执行根因分析流程。适用于 API 报错、数据异常、业务逻辑错误、性能问题等所有技术问题。
|
||||
license: MIT
|
||||
metadata:
|
||||
author: junhong
|
||||
version: "1.0"
|
||||
source: "adapted from obra/superpowers systematic-debugging"
|
||||
---
|
||||
|
||||
# 系统化调试方法论
|
||||
|
||||
## 铁律
|
||||
|
||||
```
|
||||
没有找到根因,禁止提出任何修复方案。
|
||||
```
|
||||
|
||||
改之前先搞懂为什么坏了。猜测不是调试,验证假设才是。
|
||||
|
||||
---
|
||||
|
||||
## 什么时候用
|
||||
|
||||
**所有技术问题都用这个流程**:
|
||||
- API 接口报错(4xx / 5xx)
|
||||
- 业务数据异常(金额不对、状态流转错误)
|
||||
- 性能问题(接口慢、数据库慢查询)
|
||||
- 异步任务失败(Asynq 任务报错/卡住)
|
||||
- 构建失败、启动失败
|
||||
|
||||
**尤其是以下场景**:
|
||||
- 时间紧迫(越急越不能瞎猜)
|
||||
- "很简单的问题"(简单问题也有根因)
|
||||
- 已经试了一次修复但没解决
|
||||
- 不完全理解为什么出问题
|
||||
|
||||
---
|
||||
|
||||
## 四阶段流程
|
||||
|
||||
必须按顺序完成每个阶段,不可跳过。
|
||||
|
||||
### 阶段一:根因调查
|
||||
|
||||
**这是最重要的阶段,占整个调试时间的 60%。没完成本阶段,禁止进入阶段二。**
|
||||
|
||||
#### 1. 仔细阅读错误信息
|
||||
|
||||
- 完整阅读 stack trace,不要跳过
|
||||
- 注意行号、文件路径、错误码
|
||||
- 很多时候答案就在错误信息里
|
||||
- 检查 `logs/app.log` 和 `logs/access.log` 中的上下文
|
||||
|
||||
#### 2. 稳定复现
|
||||
|
||||
- 能稳定触发吗?精确的请求参数是什么?
|
||||
- 用 curl 或 Postman 复现,记录完整的请求和响应
|
||||
- 不能复现 → 收集更多数据(检查日志、Redis 状态、数据库记录),**不要瞎猜**
|
||||
|
||||
#### 3. 检查最近改动
|
||||
|
||||
- `git diff` / `git log --oneline -10` 看最近改了什么
|
||||
- 新加了什么依赖?改了什么配置?改了什么 SQL?
|
||||
- 对比改动前后的行为差异
|
||||
|
||||
#### 4. 逐层诊断(针对本项目架构)
|
||||
|
||||
本项目有明确的分层架构,问题一定出在某一层的边界:
|
||||
|
||||
```
|
||||
请求 → Fiber Middleware → Handler → Service → Store → PostgreSQL/Redis
|
||||
↑ ↑ ↑ ↑ ↑
|
||||
认证/限流 参数解析 业务逻辑 SQL/缓存 数据本身
|
||||
```
|
||||
|
||||
**在每个层边界确认数据是否正确**:
|
||||
|
||||
```go
|
||||
// Handler 层 — 请求进来的参数对不对?
|
||||
logger.Info("Handler 收到请求",
|
||||
zap.Any("params", req),
|
||||
zap.String("request_id", requestID),
|
||||
)
|
||||
|
||||
// Service 层 — 传给业务逻辑的数据对不对?
|
||||
logger.Info("Service 开始处理",
|
||||
zap.Uint("user_id", userID),
|
||||
zap.Any("input", input),
|
||||
)
|
||||
|
||||
// Store 层 — SQL 查询/写入的数据对不对?
|
||||
// 开启 GORM Debug 模式查看实际 SQL
|
||||
db.Debug().Where(...).Find(&result)
|
||||
|
||||
// Redis 层 — 缓存的数据对不对?
|
||||
// 用 redis-cli 直接检查 key 的值
|
||||
// GET auth:token:{token}
|
||||
// GET sim:status:{iccid}
|
||||
```
|
||||
|
||||
**跑一次 → 看日志 → 找到断裂的那一层 → 再深入该层排查。**
|
||||
|
||||
#### 5. 追踪数据流
|
||||
|
||||
如果错误深藏在调用链中:
|
||||
- 坏数据从哪来的?
|
||||
- 谁调用了这个函数,传了什么参数?
|
||||
- 一直往上追,直到找到数据变坏的源头
|
||||
- **修源头,不修症状**
|
||||
|
||||
---
|
||||
|
||||
### 阶段二:模式分析
|
||||
|
||||
**找到参照物,对比差异。**
|
||||
|
||||
#### 1. 找能用的参照
|
||||
|
||||
项目里有没有类似的、能正常工作的代码?
|
||||
|
||||
| 如果问题在... | 参照物在... |
|
||||
|-------------|-----------|
|
||||
| Handler 参数解析 | 其他 Handler 的相同模式 |
|
||||
| Service 业务逻辑 | 同模块其他方法的实现 |
|
||||
| Store SQL 查询 | 同 Store 文件中类似的查询 |
|
||||
| Redis 操作 | `pkg/constants/redis.go` 中的 Key 定义 |
|
||||
| 异步任务 | `internal/task/` 中其他任务处理器 |
|
||||
| GORM Callback | `pkg/database/` 中的 callback 实现 |
|
||||
|
||||
#### 2. 逐行对比
|
||||
|
||||
完整阅读参考代码,不要跳读。列出每一处差异。
|
||||
|
||||
#### 3. 不要假设"这个不重要"
|
||||
|
||||
小差异经常是 bug 的根因:
|
||||
- 字段标签 `gorm:"column:xxx"` 拼写不对
|
||||
- `errors.New()` 用了错误的错误码
|
||||
- Redis Key 函数参数传反了
|
||||
- Context 里的 UserID 没取到(中间件没配)
|
||||
|
||||
---
|
||||
|
||||
### 阶段三:假设和验证
|
||||
|
||||
**科学方法:一次只验证一个假设。**
|
||||
|
||||
#### 1. 形成单一假设
|
||||
|
||||
明确写下:
|
||||
|
||||
> "我认为根因是 X,因为 Y。验证方法是 Z。"
|
||||
|
||||
#### 2. 最小化验证
|
||||
|
||||
- 只改一个地方
|
||||
- 一次只验证一个变量
|
||||
- 不要同时修多处
|
||||
|
||||
#### 3. 验证结果
|
||||
|
||||
- 假设成立 → 进入阶段四
|
||||
- 假设不成立 → 回到阶段一,用新信息重新分析
|
||||
- **绝对不能在失败的修复上再叠加修复**
|
||||
|
||||
#### 4. 三次失败 → 停下来
|
||||
|
||||
如果连续 3 次假设都不成立:
|
||||
|
||||
**这不是 bug,是架构问题。**
|
||||
|
||||
- 停止一切修复尝试
|
||||
- 整理已知信息
|
||||
- 向用户说明情况,讨论是否需要重构
|
||||
- 不要再试第 4 次
|
||||
|
||||
---
|
||||
|
||||
### 阶段四:实施修复
|
||||
|
||||
**确认根因后,一次性修好。**
|
||||
|
||||
#### 1. 修根因,不修症状
|
||||
|
||||
```
|
||||
❌ 症状修复:在 Handler 里加个 if 把坏数据过滤掉
|
||||
✅ 根因修复:修 Service 层生成坏数据的逻辑
|
||||
```
|
||||
|
||||
#### 2. 一次只改一个地方
|
||||
|
||||
- 不搞"顺手优化"
|
||||
- 不在修 bug 的同时重构代码
|
||||
- 修完 bug 就停
|
||||
|
||||
#### 3. 验证修复
|
||||
|
||||
- `go build ./...` 编译通过
|
||||
- `lsp_diagnostics` 无新增错误
|
||||
- 用原来复现 bug 的请求再跑一次,确认修好了
|
||||
- 用 PostgreSQL MCP 工具检查数据库中的数据状态
|
||||
|
||||
#### 4. 清理诊断代码
|
||||
|
||||
- 删除阶段一加的临时诊断日志(除非它们本身就该保留)
|
||||
- 确保没有 `db.Debug()` 残留在代码里
|
||||
|
||||
---
|
||||
|
||||
## 本项目常见调试场景速查
|
||||
|
||||
| 场景 | 首先检查 |
|
||||
|------|---------|
|
||||
| API 返回 401 | `logs/access.log` 中该请求的 token → Redis 中 `auth:token:{token}` 是否存在 |
|
||||
| API 返回 403 | 用户类型是什么 → GORM Callback 自动过滤的条件对不对 → `middleware.CanManageShop()` 的参数 |
|
||||
| 数据查不到 | GORM 数据权限过滤有没有生效 → `shop_id` / `enterprise_id` 是否正确 → 是否需要 `SkipDataPermission` |
|
||||
| 金额/余额不对 | 乐观锁 version 字段 → `RowsAffected` 是否为 0 → 并发场景下的锁竞争 |
|
||||
| 状态流转错误 | `WHERE status = expected` 条件更新 → 状态机是否有遗漏的路径 |
|
||||
| 异步任务不执行 | Asynq Dashboard → `RedisTaskLockKey` 有没有残留 → Worker 日志 |
|
||||
| 异步任务重复执行 | `RedisTaskLockKey` 的 TTL → 任务幂等性检查 |
|
||||
| 分佣计算错误 | 佣金类型(差价/一次性) → 套餐级别的佣金率 → 设备级防重复分佣 |
|
||||
| 套餐激活异常 | 卡状态 → 实名状态 → 主套餐排队逻辑 → 加油包绑定关系 |
|
||||
| Redis 缓存不一致 | Key 的 TTL → 缓存更新时机 → 是否有手动 `Del` 清除 |
|
||||
| 微信支付回调失败 | 签名验证 → 幂等性处理 → 回调 URL 是否可达 |
|
||||
| GORM 查询慢 | `db.Debug()` 看实际 SQL → 是否 N+1 → 是否缺少索引 |
|
||||
|
||||
---
|
||||
|
||||
## 红线规则
|
||||
|
||||
如果你发现自己在想以下任何一条,**立刻停下来,回到阶段一**:
|
||||
|
||||
| 想法 | 为什么是错的 |
|
||||
|------|------------|
|
||||
| "先快速修一下,回头再查" | 快速修 = 猜测。猜测 = 浪费时间。 |
|
||||
| "试试改这个看看行不行" | 一次只验证一个假设,不是随机改。 |
|
||||
| "大概是 X 的问题,我直接改了" | "大概"不是根因。先验证再改。 |
|
||||
| "这个很简单,不用走流程" | 简单问题走流程只需要 5 分钟。不走流程可能浪费 2 小时。 |
|
||||
| "我不完全理解但这应该行" | 不理解 = 没找到根因。回阶段一。 |
|
||||
| "再试一次"(已经失败 2 次) | 3 次失败 = 架构问题。停下来讨论。 |
|
||||
| "同时改这几个地方应该能修好" | 改多处 = 无法确认哪个是根因。一次只改一处。 |
|
||||
|
||||
---
|
||||
|
||||
## 常见借口和真相
|
||||
|
||||
| 借口 | 真相 |
|
||||
|------|------|
|
||||
| "问题很简单,不需要走流程" | 简单问题也有根因。走流程对简单问题只花 5 分钟。 |
|
||||
| "太紧急了,没时间分析" | 系统化调试比乱猜快 3-5 倍。越急越要走流程。 |
|
||||
| "先改了验证一下" | 这叫猜测,不叫验证。先确认根因再改。 |
|
||||
| "我看到问题了,直接修" | 看到症状 ≠ 理解根因。症状修复是技术债。 |
|
||||
| "改了好几个地方,反正能用了" | 不知道哪个改动修的,下次还会出问题。 |
|
||||
|
||||
---
|
||||
|
||||
## 快速参考
|
||||
|
||||
| 阶段 | 核心动作 | 完成标准 |
|
||||
|------|---------|---------|
|
||||
| **一、根因调查** | 读错误日志、复现、检查改动、逐层诊断、追踪数据流 | 能说清楚"因为 X 所以 Y" |
|
||||
| **二、模式分析** | 找参照代码、逐行对比、列出差异 | 知道正确的应该长什么样 |
|
||||
| **三、假设验证** | 写下假设、最小改动、单变量验证 | 假设被证实或推翻 |
|
||||
| **四、实施修复** | 修根因、编译检查、请求验证、清理诊断代码 | bug 消失,无新增问题 |
|
||||
@@ -14,6 +14,26 @@
|
||||
|
||||
*以下功能已在代码库中实现,视为已验证的基础能力:*
|
||||
|
||||
**Validated in Phase 03.1: 设备 sync-info 读取链路修复**
|
||||
- ✓ 设备管理列表/详情返回 online_status/last_online_time/software_version/switch_mode/last_gateway_sync_at — DEVICE-02/03
|
||||
- ✓ 设备绑卡列表(ListBindings)返回 is_current 字段 — DEVICE-03
|
||||
- ✓ 资产 Resolve/Refresh BoundCardInfo 包含 is_current(isCurrentMap 模式) — DEVICE-03
|
||||
- ✓ admin 资产解析接口(AssetResolveResponse)返回 5 个 DB 缓存字段 — DEVICE-02/03
|
||||
- ✓ GetRealtimeStatus device 类型实时调用 Gateway sync-info,返回 DeviceGatewayInfo — DEVICE-04
|
||||
- ✓ C 端 GetAssetInfo device_realtime 从真实 Gateway 数据填充(删除 buildMockDeviceRealtime)— DEVICE-04
|
||||
- ✓ Gateway 失败降级:IMEI 为空不发起调用,失败只记 Warn,device_realtime 返回 null — DEVICE-04
|
||||
|
||||
**Validated in Phase 2: PERM/FIN/REALNAME/POLL 修复**
|
||||
- ✓ 企业账号 Resolve 接口移除错误拦截,Refresh 接口新增拦截(只读)— PERM-01/02
|
||||
- ✓ 提现审批人字段补全(approved_by/approved_at)— FIN-01
|
||||
- ✓ 提现拒绝 remark 必填校验(Reject 补 Validate 调用)— FIN-02
|
||||
- ✓ 激活配置并发行锁保护(clause.Locking FOR UPDATE)— FIN-03
|
||||
- ✓ enable_realname_activation → expiry_base 全链路重构,三维激活决策(卡类型+购买路径+expiry_base)— REALNAME-01~05
|
||||
- ✓ polling:protect 接入调度器(processManualQueue + processTimedQueue)— POLL-01
|
||||
- ✓ gatewayClient=nil 时停复机返回业务错误而非 nil — POLL-02
|
||||
- ✓ GetPackages 接口支持分页(page/pageSize/total)— POLL-03
|
||||
- ✓ PackageSeries 删除前检查关联套餐(CountBySeriesID)— POLL-04
|
||||
|
||||
**Validated in Phase 1: P0 紧急修复**
|
||||
- ✓ 实名状态常量统一(DB 写入值统一为 constants.RealNameStatusVerified=1)— CRITICAL-01
|
||||
- ✓ C 端实名校验修复(依赖常量统一)— CRITICAL-02
|
||||
@@ -57,20 +77,21 @@
|
||||
- [x] A-6:设备导入补充 IMEI 字段(DeviceRow 缺 IMEI,导入后 Gateway 无法调用)
|
||||
- [x] A-7:提现冻结并发校验(只检查 Error 不检查 RowsAffected,并发创建重复提现单)
|
||||
|
||||
**方案 B — 企业端权限完善**
|
||||
- [ ] B-1:Resolve 接口错误拦截企业账号(应可查,需移除拦截)
|
||||
- [ ] B-2:Refresh 接口缺少企业账号拦截(应只读)
|
||||
**方案 B — 企业端权限完善** ✅ *Validated in Phase 2*
|
||||
- [x] B-1:Resolve 接口错误拦截企业账号(应可查,需移除拦截)
|
||||
- [x] B-2:Refresh 接口缺少企业账号拦截(应只读)
|
||||
|
||||
**方案 C — 提现/佣金流程完善**
|
||||
- [ ] C-1:审批人字段补全(approved_by/approved_at 永远为空)
|
||||
- [ ] C-2:提现拒绝 remark 必填校验(Reject 缺少 Validate 调用)
|
||||
- [ ] C-3:激活配置并发保护(两步 UPDATE 无锁,可出现两条 active=true)
|
||||
**方案 C — 提现/佣金流程完善** ✅ *Validated in Phase 2*
|
||||
- [x] C-1:审批人字段补全(approved_by/approved_at 永远为空)
|
||||
- [x] C-2:提现拒绝 remark 必填校验(Reject 缺少 Validate 调用)
|
||||
- [x] C-3:激活配置并发保护(两步 UPDATE 无锁,可出现两条 active=true)
|
||||
|
||||
**方案 D — 设备体系完善**
|
||||
- [ ] D-0:设备模型字段扩展(online_status / last_online_time / software_version 等)
|
||||
- [ ] D-1:Gateway sync-info 同步接口对接(CurrentIccid 预留结构待填充)
|
||||
- [ ] D-2:device_sim_binding 新增 is_current 字段(当前使用卡标识)
|
||||
- [ ] D-3:设备 Refresh + 详情接入 sync-info(在线状态/固件版本/当前卡实时更新)
|
||||
**方案 D — 设备体系完善** ✅ *Validated in Phase 3 + Phase 03.1*
|
||||
- [x] D-0:设备模型字段扩展(online_status / last_online_time / software_version 等)
|
||||
- [x] D-1:Gateway sync-info 同步接口对接(CurrentIccid 预留结构待填充)
|
||||
- [x] D-2:device_sim_binding 新增 is_current 字段(当前使用卡标识)
|
||||
- [x] D-3:设备 Refresh + 详情接入 sync-info(在线状态/固件版本/当前卡实时更新)
|
||||
- [x] D-读取侧:DTO 字段映射补全(5个DB缓存字段 + 2处IsCurrent)+ GetRealtimeStatus Gateway 实时调用 + C端 mock 替换
|
||||
|
||||
**方案 E — 流量体系改革(破坏性,低峰期)**
|
||||
- [ ] E-1:流量详单改为日粒度缓冲(新建 tb_card_daily_usage,Redis 增量缓存)
|
||||
@@ -85,14 +106,14 @@
|
||||
- [ ] F-5:Handler/Service 权限不一致修复
|
||||
- [ ] F-6:StopResumeCallback / ResumeCallback 注入(bootstrap 未调用)
|
||||
|
||||
**方案 G — 实名激活架构重构**
|
||||
- [ ] G:enable_realname_activation 字段拆分为 expiry_base(from_activation/from_purchase)
|
||||
**方案 G — 实名激活架构重构** ✅ *Validated in Phase 2*
|
||||
- [x] G:enable_realname_activation 字段拆分为 expiry_base(from_activation/from_purchase)
|
||||
|
||||
**方案 H — 轮询系统小修**
|
||||
- [ ] H-1:polling:protect 接入调度(已注册未调度)
|
||||
- [ ] H-2:gatewayClient=nil 时停复机应返回错误(而非假装成功)
|
||||
- [ ] H-3:packages 接口加分页
|
||||
- [ ] H-4:Series 删除前检查关联套餐
|
||||
**方案 H — 轮询系统小修** ✅ *Validated in Phase 2*
|
||||
- [x] H-1:polling:protect 接入调度(已注册未调度)
|
||||
- [x] H-2:gatewayClient=nil 时停复机应返回错误(而非假装成功)
|
||||
- [x] H-3:packages 接口加分页
|
||||
- [x] H-4:Series 删除前检查关联套餐
|
||||
|
||||
**方案 I — 退款完整功能(新功能)**
|
||||
- [ ] I-1:新建 tb_refund_request 数据模型
|
||||
@@ -146,7 +167,7 @@
|
||||
| 流量体系改革独立发布 | 破坏性变更(DB 迁移 + Redis 架构),风险隔离 | — Pending |
|
||||
|
||||
---
|
||||
*Last updated: 2026-03-28 after Phase 1 (P0 紧急修复) completion — CRITICAL-01~08 all validated*
|
||||
*Last updated: 2026-03-28 after Phase 03.1 (sync-info-phase-3-dto) completion — device reading-side gap fully closed*
|
||||
|
||||
## Evolution
|
||||
|
||||
|
||||
@@ -21,36 +21,36 @@
|
||||
|
||||
### PERM — 企业端权限完善(方案 B)
|
||||
|
||||
- [ ] **PERM-01**: 移除 Resolve 接口对企业账号的错误拦截 — 企业账号应能查询被授权的资产
|
||||
- [ ] **PERM-02**: Refresh 接口新增企业账号拦截 — 企业只读,不允许主动触发运营商刷新
|
||||
- [x] **PERM-01**: 移除 Resolve 接口对企业账号的错误拦截 — 企业账号应能查询被授权的资产
|
||||
- [x] **PERM-02**: Refresh 接口新增企业账号拦截 — 企业只读,不允许主动触发运营商刷新
|
||||
|
||||
### FIN — 财务/提现流程完善(方案 C)
|
||||
|
||||
- [ ] **FIN-01**: 提现审批人字段补全 — `Approve()` 写入 `approved_by` / `approved_at`(当前永远为空)
|
||||
- [ ] **FIN-02**: 提现拒绝 remark 必填校验 — `Reject()` 在 BodyParser 后补充 `validator.Validate()`
|
||||
- [ ] **FIN-03**: 激活配置并发保护 — 提现配置和微信配置激活时,两步 UPDATE 前加 `FOR UPDATE` 行锁
|
||||
- [x] **FIN-01**: 提现审批人字段补全 — `Approve()` 写入 `approved_by` / `approved_at`(当前永远为空)
|
||||
- [x] **FIN-02**: 提现拒绝 remark 必填校验 — `Reject()` 在 BodyParser 后补充 `validator.Validate()`
|
||||
- [x] **FIN-03**: 激活配置并发保护 — 提现配置和微信配置激活时,两步 UPDATE 前加 `FOR UPDATE` 行锁
|
||||
|
||||
### REALNAME — 实名激活架构重构(方案 G)
|
||||
|
||||
- [ ] **REALNAME-01**: 移除 `tb_package.enable_realname_activation` 字段,新增 `expiry_base VARCHAR(30)` 字段(`from_activation` / `from_purchase`)
|
||||
- [ ] **REALNAME-02**: `activateMainPackage` 改写激活决策逻辑 — 按卡类型(industry/normal)+ 购买路径(C端/后台囤货)+ `expiry_base` 三维决策
|
||||
- [ ] **REALNAME-03**: `client_order/service.go` 实名检查改为按卡类型判断(`card_category == "normal"`),删除 `packagesNeedRealname()` 函数
|
||||
- [ ] **REALNAME-04**: `ActivateByRealname()` 按 `ExpiryBase` 选择激活时间基准(`from_purchase` 用 CreatedAt,`from_activation` 用当前时刻)
|
||||
- [ ] **REALNAME-05**: 套餐管理 API DTO 更新 — 移除 `enable_realname_activation`,新增 `expiry_base` 枚举字段
|
||||
- [x] **REALNAME-01**: 移除 `tb_package.enable_realname_activation` 字段,新增 `expiry_base VARCHAR(30)` 字段(`from_activation` / `from_purchase`)
|
||||
- [x] **REALNAME-02**: `activateMainPackage` 改写激活决策逻辑 — 按卡类型(industry/normal)+ 购买路径(C端/后台囤货)+ `expiry_base` 三维决策
|
||||
- [x] **REALNAME-03**: `client_order/service.go` 实名检查改为按卡类型判断(`card_category == "normal"`),删除 `packagesNeedRealname()` 函数
|
||||
- [x] **REALNAME-04**: `ActivateByRealname()` 按 `ExpiryBase` 选择激活时间基准(`from_purchase` 用 CreatedAt,`from_activation` 用当前时刻)
|
||||
- [x] **REALNAME-05**: 套餐管理 API DTO 更新 — 移除 `enable_realname_activation`,新增 `expiry_base` 枚举字段
|
||||
|
||||
### POLL — 轮询系统小修(方案 H)
|
||||
|
||||
- [ ] **POLL-01**: `polling:protect` 接入调度 — `scheduler.go` 补充 processManualQueue + processTimedQueue 对 protect 任务的调度
|
||||
- [ ] **POLL-02**: gatewayClient=nil 时停复机返回错误 — `stopCardWithRetry` 和 `resumeCardWithRetry` 两处改为返回业务错误而非 nil
|
||||
- [ ] **POLL-03**: packages 接口新增分页 — `GetPackages()` 默认 page=1, pageSize=50,最大 100
|
||||
- [ ] **POLL-04**: Series 删除前检查关联套餐 — `package_series/service.go:Delete()` 前置 `CountBySeriesID()` 检查
|
||||
- [x] **POLL-01**: `polling:protect` 接入调度 — `scheduler.go` 补充 processManualQueue + processTimedQueue 对 protect 任务的调度
|
||||
- [x] **POLL-02**: gatewayClient=nil 时停复机返回错误 — `stopCardWithRetry` 和 `resumeCardWithRetry` 两处改为返回业务错误而非 nil
|
||||
- [x] **POLL-03**: packages 接口新增分页 — `GetPackages()` 默认 page=1, pageSize=50,最大 100
|
||||
- [x] **POLL-04**: Series 删除前检查关联套餐 — `package_series/service.go:Delete()` 前置 `CountBySeriesID()` 检查
|
||||
|
||||
### DEVICE — 设备体系完善(方案 D)
|
||||
|
||||
- [ ] **DEVICE-01**: 设备模型字段扩展 — DB 迁移新增 `online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at`
|
||||
- [ ] **DEVICE-02**: Gateway sync-info 同步接口对接 — `internal/gateway/device.go` 新增 `SyncDeviceInfo()` 方法
|
||||
- [ ] **DEVICE-03**: `tb_device_sim_binding` 新增 `is_current` 字段 — DB 迁移 + Model + 更新逻辑
|
||||
- [ ] **DEVICE-04**: 设备 Refresh + 详情接入 sync-info — `RefreshDevice()` 在刷新卡数据后调用 `SyncDeviceInfo()`,更新设备在线状态/固件版本/当前卡标识
|
||||
- [x] **DEVICE-01**: 设备模型字段扩展 — DB 迁移新增 `online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at`
|
||||
- [x] **DEVICE-02**: Gateway sync-info 同步接口对接 — `internal/gateway/device.go` 新增 `SyncDeviceInfo()` 方法
|
||||
- [x] **DEVICE-03**: `tb_device_sim_binding` 新增 `is_current` 字段 — DB 迁移 + Model + 更新逻辑
|
||||
- [x] **DEVICE-04**: 设备 Refresh + 详情接入 sync-info — `RefreshDevice()` 在刷新卡数据后调用 `SyncDeviceInfo()`,更新设备在线状态/固件版本/当前卡标识
|
||||
|
||||
### REFUND — 退款完整功能(方案 I)
|
||||
|
||||
@@ -128,24 +128,24 @@
|
||||
| CRITICAL-06 | Phase 1 | Complete |
|
||||
| CRITICAL-07 | Phase 1 | Complete |
|
||||
| CRITICAL-08 | Phase 1 | Complete |
|
||||
| PERM-01 | Phase 2 | Pending |
|
||||
| PERM-02 | Phase 2 | Pending |
|
||||
| FIN-01 | Phase 2 | Pending |
|
||||
| FIN-02 | Phase 2 | Pending |
|
||||
| FIN-03 | Phase 2 | Pending |
|
||||
| REALNAME-01 | Phase 2 | Pending |
|
||||
| REALNAME-02 | Phase 2 | Pending |
|
||||
| REALNAME-03 | Phase 2 | Pending |
|
||||
| REALNAME-04 | Phase 2 | Pending |
|
||||
| REALNAME-05 | Phase 2 | Pending |
|
||||
| POLL-01 | Phase 2 | Pending |
|
||||
| POLL-02 | Phase 2 | Pending |
|
||||
| POLL-03 | Phase 2 | Pending |
|
||||
| POLL-04 | Phase 2 | Pending |
|
||||
| DEVICE-01 | Phase 3 | Pending |
|
||||
| DEVICE-02 | Phase 3 | Pending |
|
||||
| DEVICE-03 | Phase 3 | Pending |
|
||||
| DEVICE-04 | Phase 3 | Pending |
|
||||
| PERM-01 | Phase 2 | Complete |
|
||||
| PERM-02 | Phase 2 | Complete |
|
||||
| FIN-01 | Phase 2 | Complete |
|
||||
| FIN-02 | Phase 2 | Complete |
|
||||
| FIN-03 | Phase 2 | Complete |
|
||||
| REALNAME-01 | Phase 2 | Complete |
|
||||
| REALNAME-02 | Phase 2 | Complete |
|
||||
| REALNAME-03 | Phase 2 | Complete |
|
||||
| REALNAME-04 | Phase 2 | Complete |
|
||||
| REALNAME-05 | Phase 2 | Complete |
|
||||
| POLL-01 | Phase 2 | Complete |
|
||||
| POLL-02 | Phase 2 | Complete |
|
||||
| POLL-03 | Phase 2 | Complete |
|
||||
| POLL-04 | Phase 2 | Complete |
|
||||
| DEVICE-01 | Phase 3 | Complete |
|
||||
| DEVICE-02 | Phase 3 | Complete |
|
||||
| DEVICE-03 | Phase 3 | Complete |
|
||||
| DEVICE-04 | Phase 3 | Complete |
|
||||
| REFUND-01 | Phase 4 | Pending |
|
||||
| REFUND-02 | Phase 4 | Pending |
|
||||
| REFUND-03 | Phase 4 | Pending |
|
||||
|
||||
@@ -11,8 +11,8 @@
|
||||
## Phases
|
||||
|
||||
- [x] **Phase 1: P0 紧急修复** — 修复所有阻塞主链路的 P0 Bug,使充值→购包→佣金→实名激活全链路可跑通 (completed 2026-03-27)
|
||||
- [ ] **Phase 2: 权限/财务/实名/轮询修复** — 补全企业权限、提现财务流程、实名激活架构重构、轮询系统小修
|
||||
- [ ] **Phase 3: 设备体系完善** — 扩展设备模型字段、对接 Gateway sync-info、完善绑卡标识
|
||||
- [x] **Phase 2: 权限/财务/实名/轮询修复** — 补全企业权限、提现财务流程、实名激活架构重构、轮询系统小修 (completed 2026-03-28)
|
||||
- [x] **Phase 3: 设备体系完善** — 扩展设备模型字段、对接 Gateway sync-info、完善绑卡标识 (completed 2026-03-28)
|
||||
- [ ] **Phase 4: 退款 + 支付 + 运营修复** — 实现退款完整流程、补全富友支付接口、修复运营逻辑
|
||||
- [ ] **Phase 5: 代码质量清理** — 消除技术债务:佣金断链可观测、删废弃代码、修复权限不一致、迁移分层
|
||||
- [ ] **Phase 6: 流量体系改革(低峰期)** — 破坏性变更:日粒度流量表 + Redis 增量缓存 + 每日落盘任务
|
||||
@@ -53,7 +53,11 @@ Plans:
|
||||
4. 数据库中 tb_package 表不再有 enable_realname_activation 字段,改为 expiry_base 字段(值为 from_activation 或 from_purchase)
|
||||
5. C 端购买普通号卡套餐时,实名检查按卡类型(card_category == "normal")判断,逻辑简洁正确
|
||||
6. polling:protect 任务正确进入调度器,停复机时若 gatewayClient 为 nil 返回业务错误而非静默成功
|
||||
**Plans**: TBD
|
||||
**Plans**: 2 plans
|
||||
|
||||
Plans:
|
||||
- [x] 02-01-PLAN.md — PERM-01/02 + FIN-01/02/03 + POLL-01/02/03/04:权限/财务/轮询小修合并(Wave 1)
|
||||
- [x] 02-02-PLAN.md — REALNAME-01~05:实名激活架构重构,原子执行(Wave 1,parallel)
|
||||
|
||||
---
|
||||
|
||||
@@ -66,10 +70,24 @@ Plans:
|
||||
2. 调用设备刷新接口后,设备的在线状态、固件版本、当前使用卡(is_current)从 Gateway 实时更新
|
||||
3. tb_device_sim_binding 表含 is_current 字段,同一设备同一时刻只有一条 is_current=true 的绑定记录
|
||||
4. 设备详情接口返回数据包含 online_status、software_version 等字段,值与 Gateway 同步结果一致
|
||||
**Plans**: TBD
|
||||
**Plans**: 2 plans
|
||||
|
||||
Plans:
|
||||
- [x] 03-01-PLAN.md — DEVICE-01/02/03:DB 迁移 + 模型扩展 + Gateway SyncDeviceInfo + DTO 字段(Wave 1)
|
||||
- [x] 03-02-PLAN.md — DEVICE-04:Asset Service 接入 sync-info,Refresh 追加设备状态同步(Wave 2)
|
||||
|
||||
---
|
||||
|
||||
### Phase 03.1: 设备 sync-info 读取链路修复:补全 Phase 3 新增字段在查询接口的 DTO 映射缺口 (INSERTED)
|
||||
|
||||
**Goal:** 补全 Phase 3 完成的设备数据写入链路在读取侧的缺口:修复 DTO 字段映射遗漏(5个 DB 缓存字段 + 2处 IsCurrent)+ 建立设备实时状态的 Gateway 查询链路(替换 mock → 真实 Gateway 数据)
|
||||
**Requirements**: DEVICE-02, DEVICE-03, DEVICE-04
|
||||
**Depends on:** Phase 3
|
||||
**Plans:** 1/1 plans complete
|
||||
|
||||
Plans:
|
||||
- [ ] 03.1-01-PLAN.md — 补全静态 DTO 映射缺口 + 建立 GetRealtimeStatus Gateway 实时调用 + C 端 stub 替换(Wave 1)
|
||||
|
||||
### Phase 4: 退款 + 支付 + 运营修复
|
||||
**Goal**: 实现完整退款流程(申请→审批→佣金回扣),补全富友支付 JSAPI/小程序接口(替换留桩),修复运营逻辑断点(卡状态 3/4 触发、payment_status 枚举统一)
|
||||
**Depends on**: Phase 2(佣金链路稳定后再做退款佣金回扣)
|
||||
@@ -119,11 +137,11 @@ Plans:
|
||||
| Phase | Plans Complete | Status | Completed |
|
||||
|-------|----------------|--------|-----------|
|
||||
| 1. P0 紧急修复 | 5/5 | Complete | 2026-03-27 |
|
||||
| 2. 权限/财务/实名/轮询修复 | 0/1 | Not started | - |
|
||||
| 3. 设备体系完善 | 0/1 | Not started | - |
|
||||
| 4. 退款 + 支付 + 运营修复 | 0/1 | Not started | - |
|
||||
| 5. 代码质量清理 | 0/1 | Not started | - |
|
||||
| 6. 流量体系改革(低峰期)| 0/1 | Not started | - |
|
||||
| 2. 权限/财务/实名/轮询修复 | 2/2 | Complete | 2026-03-28 |
|
||||
| 3. 设备体系完善 | 2/2 | Complete | 2026-03-28 |
|
||||
| 4. 退款 + 支付 + 运营修复 | 0/TBD | Not started | - |
|
||||
| 5. 代码质量清理 | 0/TBD | Not started | - |
|
||||
| 6. 流量体系改革(低峰期)| 0/TBD | Not started | - |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3,37 +3,37 @@ gsd_state_version: 1.0
|
||||
milestone: v1.0
|
||||
milestone_name: milestone
|
||||
status: unknown
|
||||
last_updated: "2026-03-28T01:47:47.024Z"
|
||||
last_updated: "2026-03-28T05:41:21.983Z"
|
||||
progress:
|
||||
total_phases: 6
|
||||
completed_phases: 1
|
||||
total_plans: 5
|
||||
completed_plans: 5
|
||||
total_phases: 7
|
||||
completed_phases: 4
|
||||
total_plans: 10
|
||||
completed_plans: 10
|
||||
---
|
||||
|
||||
# Project State — 君鸿卡管系统
|
||||
|
||||
**Project:** 君鸿卡管系统 v1.0
|
||||
**Milestone:** v1.0 — MVP 上线准备
|
||||
**Last Updated:** 2026-03-27
|
||||
**Last Updated:** 2026-03-28
|
||||
|
||||
---
|
||||
|
||||
## Current Position
|
||||
|
||||
Phase: 2
|
||||
Phase: 4
|
||||
Plan: Not started
|
||||
| Field | Value |
|
||||
|-------|-------|
|
||||
| **Current Phase** | 1 — P0 紧急修复 |
|
||||
| **Current Plan** | None (planning phase) |
|
||||
| **Status** | Planning |
|
||||
| **Phase Progress** | 0/8 requirements complete |
|
||||
| **Current Phase** | 3 — 设备体系完善 |
|
||||
| **Current Plan** | None (awaiting planning) |
|
||||
| **Status** | Ready to plan Phase 3 |
|
||||
| **Phase Progress** | 0/4 requirements complete |
|
||||
|
||||
```
|
||||
Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
▲
|
||||
Planning
|
||||
✓ ✓ ▲
|
||||
Next
|
||||
```
|
||||
|
||||
---
|
||||
@@ -42,7 +42,7 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
|
||||
**Core Value:** 业务链路完整可用——代理能采购分配卡/设备,C端客户能充值购包激活套餐,佣金能正确计算并可提现。这条主链路必须端到端跑通。
|
||||
|
||||
**Current Focus:** Phase 01 — p0
|
||||
**Current Focus:** Phase 03 — device-system
|
||||
|
||||
---
|
||||
|
||||
@@ -50,12 +50,13 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
|
||||
| Phase | Name | Requirements | Status |
|
||||
|-------|------|--------------|--------|
|
||||
| 1 | P0 紧急修复 | CRITICAL-01~08(8项) | Not started |
|
||||
| 2 | 权限/财务/实名/轮询修复 | PERM-01~02, FIN-01~03, REALNAME-01~05, POLL-01~04(14项) | Not started |
|
||||
| 3 | 设备体系完善 | DEVICE-01~04(4项) | Not started |
|
||||
| 4 | 退款 + 支付 + 运营修复 | REFUND-01~03, PAY-01~04, OPS-01~02(9项) | Not started |
|
||||
| 5 | 代码质量清理 | CLEAN-01~06(6项) | Not started |
|
||||
| 6 | 流量体系改革(低峰期)| TRAFFIC-01~05(5项) | Not started |
|
||||
| 1 | P0 紧急修复 | CRITICAL-01~08(8项) | ✓ Complete (2026-03-28) |
|
||||
| 2 | 权限/财务/实名/轮询修复 | PERM-01~02, FIN-01~03, REALNAME-01~05, POLL-01~04(14项) | ✓ Complete (2026-03-28) |
|
||||
| 3 | 设备体系完善 | DEVICE-01~04(4项) | ✓ Complete (2026-03-28) |
|
||||
| 3.1 | 设备 sync-info 读取链路修复 | DEVICE-02~04 读取侧 | ✓ Complete (2026-03-28) |
|
||||
| 4 | 退款 + 支付 + 运营修复 | REFUND-01~03, PAY-01~04, OPS-01~02(9项) | ○ Not started |
|
||||
| 5 | 代码质量清理 | CLEAN-01~06(6项) | ○ Not started |
|
||||
| 6 | 流量体系改革(低峰期)| TRAFFIC-01~05(5项) | ○ Not started |
|
||||
|
||||
---
|
||||
|
||||
@@ -66,16 +67,29 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
| Phases total | 6 |
|
||||
| Requirements v1 | 46 |
|
||||
| Requirements mapped | 46/46 (100%) |
|
||||
| Plans created | 0 |
|
||||
| Plans complete | 0 |
|
||||
| Phases complete | 0/6 |
|
||||
| Plans created | 7 |
|
||||
| Plans complete | 7/7 |
|
||||
| Phases complete | 2/6 |
|
||||
|
||||
---
|
||||
| Phase 03-device-system P01 | 8min | 2 tasks | 10 files |
|
||||
| Phase 03-device-system P02 | 10min | 2 tasks | 3 files |
|
||||
| Phase 03.1-sync-info-phase-3-dto P01 | 5min | 3 tasks | 6 files |
|
||||
|
||||
## Execution Log
|
||||
|
||||
| Plan | Duration | Tasks | Files |
|
||||
|------|----------|-------|-------|
|
||||
| Phase 01 P01 | 15min | 2 tasks | 7 files |
|
||||
| Phase 01 P02 | 3min | 1 task | 2 files |
|
||||
| Phase 01 P03 | 8min | 2 tasks | 4 files |
|
||||
| Phase 01 P04 | 4min | 1 task | 1 file |
|
||||
| Phase 01 P05 | 8min | 2 tasks | 3 files |
|
||||
| Phase 02 P01 | 10min | 2 tasks | 14 files |
|
||||
| Phase 02 P02 | 25min | 2 tasks | 8 files |
|
||||
| Phase 03.1 P01 | 5min | 3 tasks | 6 files |
|
||||
|
||||
---
|
||||
| Phase 01-p0 P05 | 8 | 2 tasks | 3 files |
|
||||
| Phase 01-p0 P01 | 15 | 2 tasks | 7 files |
|
||||
| Phase 01-p0 P04 | 4min | 1 tasks | 1 files |
|
||||
| Phase 01-p0 P02 | 3min | 1 tasks | 2 files |
|
||||
| Phase 01-p0 P03 | 8min | 2 tasks | 4 files |
|
||||
|
||||
## Accumulated Context
|
||||
|
||||
@@ -85,27 +99,36 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
- REALNAME-01~05 是原子重构,必须在一个 plan 内完整执行,不可拆分
|
||||
- REFUND 退款佣金回扣(REFUND-03)必须使用整数算术,禁用 float64
|
||||
- CRITICAL-02 依赖 CRITICAL-01,CRITICAL-05 依赖 CRITICAL-04
|
||||
- DEVICE-02/03/04 依赖 DEVICE-01(字段扩展先行)
|
||||
- DEVICE-02/03/04 依赖 DEVICE-01(字段扩展先行)✓ Phase 03 完成
|
||||
- REFUND-02/03 依赖 REFUND-01(数据模型先行)
|
||||
- DEVICE-04: gatewayClient nil guard 可选注入,sync-info 失败仅记 Warn 不阻断 Refresh 主流程
|
||||
- UpdateIsCurrentByDeviceID 事务两步原子更新 is_current,保证同一设备唯一 is_current=true
|
||||
- DeviceGatewayInfo(B端)与 DeviceRealtimeInfo(C端)独立结构体,SwitchMode 字符串→整型转换(Phase 03.1 决策)
|
||||
|
||||
### Architecture Notes
|
||||
|
||||
- 双进程(API + Worker),依赖集中在 bootstrap 装配
|
||||
- PollingHandler 缺 asynqClient(CRITICAL-03 修复),AutoPurchaseHandler 未注册(CRITICAL-04 修复)
|
||||
- StopResumeCallback / ResumeCallback 未注入(CLEAN-06 修复)
|
||||
- 数据权限:Store 层显式调用 ApplyShopFilter / ApplyEnterpriseFilter(非 GORM Callback)
|
||||
- StopResumeCallback / ResumeCallback 未注入 bootstrap(CLEAN-06 修复)
|
||||
- 流量体系当前用直接覆盖写,Phase 6 改为增量累加 + 日粒度 DB
|
||||
- expiry_base 枚举已替换 enable_realname_activation,激活决策为三维(卡类型+购买路径+expiry_base)
|
||||
|
||||
### Roadmap Evolution
|
||||
|
||||
- Phase 03.1 inserted after Phase 3: 设备 sync-info 读取链路修复:补全 Phase 3 新增字段在查询接口的 DTO 映射缺口 (URGENT)
|
||||
|
||||
### Known Risks
|
||||
|
||||
- sellerCostPrice 错误赋值可能不止场景 5(CRITICAL-06 需逐一检查 6 处)
|
||||
- sellerCostPrice 错误赋值可能不止场景 5(CRITICAL-06 已处理 6 处)
|
||||
- 分佣树形遍历无循环检测(不在当前 scope,v2 考量)
|
||||
- 店铺软删除无级联检查(v2 考量)
|
||||
- pkg/openapi/handlers.go CommissionWithdrawalHandler 签名已在 02-01 中同步修复
|
||||
|
||||
---
|
||||
|
||||
## Session Continuity
|
||||
|
||||
**Next Action:** Run `/gsd-plan-phase 1` to create detailed plan for Phase 1 (P0 紧急修复)
|
||||
**Next Action:** Run `/gsd-plan-phase 4` to create detailed plan for Phase 4 (退款 + 支付 + 运营修复)
|
||||
|
||||
**Context Files:**
|
||||
|
||||
@@ -116,4 +139,4 @@ Progress: [ 1 ]──[ 2 ]──[ 3 ]──[ 4 ]──[ 5 ]──[ 6 ]
|
||||
- `.sisyphus/plans/修正业务-完整方案.md` — 每个 Bug 的完整修复规格(含代码片段)
|
||||
|
||||
---
|
||||
*State initialized: 2026-03-27*
|
||||
*State initialized: 2026-03-27 | Last phase completed: Phase 03.1 (2026-03-28)*
|
||||
|
||||
179
.planning/codebase/ARCHITECTURE.md
Normal file
179
.planning/codebase/ARCHITECTURE.md
Normal file
@@ -0,0 +1,179 @@
|
||||
# Architecture
|
||||
|
||||
**Analysis Date:** 2026-03-27
|
||||
|
||||
## Pattern Overview
|
||||
|
||||
**Overall:** 双进程分层单体(API + Worker),核心代码按 `Handler → Service → Store → Model` 组织。
|
||||
|
||||
**Key Characteristics:**
|
||||
- HTTP 入口与异步任务入口分离:`cmd/api/main.go` 负责 Fiber API,`cmd/worker/main.go` 负责 Asynq Worker 与轮询调度。
|
||||
- 依赖集中在 `internal/bootstrap/` 组装,再分发给 `internal/routes/`、`internal/handler/`、`internal/task/`。
|
||||
- 数据访问主要走 `internal/store/postgres/*.go`,模型与 DTO 分别位于 `internal/model/*.go` 与 `internal/model/dto/*.go`。
|
||||
- 路由注册统一经过 `internal/routes/registry.go` 的 `Register()`,同时驱动 Fiber 路由与 OpenAPI 元数据。
|
||||
- 多租户与权限控制主要依赖认证上下文 + Store 层显式过滤函数,而不是数据库外键或 ORM 关联。
|
||||
|
||||
## Layers
|
||||
|
||||
**Entrypoint / Composition Layer:**
|
||||
- Purpose: 进程启动、基础设施初始化、依赖装配、生命周期管理。
|
||||
- Location: `cmd/api/main.go`, `cmd/worker/main.go`, `internal/bootstrap/*.go`
|
||||
- Contains: 配置加载、日志、数据库、Redis、队列、对象存储、Gateway 客户端、Bootstrap 编排。
|
||||
- Depends on: `pkg/config`, `pkg/database`, `pkg/logger`, `pkg/queue`, `pkg/storage`, `internal/bootstrap`
|
||||
- Used by: 整个应用进程。
|
||||
|
||||
**Routing / Transport Layer:**
|
||||
- Purpose: 路由域划分、认证挂载、HTTP 入参与响应封装、OpenAPI 文档注册。
|
||||
- Location: `internal/routes/*.go`, `internal/handler/**/*.go`
|
||||
- Contains: `RegisterRoutesWithDoc()` 总入口、Admin/Auth/Personal 域路由、Fiber Handler。
|
||||
- Depends on: `internal/bootstrap.Handlers`, `internal/bootstrap.Middlewares`, `pkg/openapi`, `pkg/response`
|
||||
- Used by: `cmd/api/main.go`
|
||||
|
||||
**Service Layer:**
|
||||
- Purpose: 业务规则、权限判断、事务编排、幂等、异步任务提交、跨模块协作。
|
||||
- Location: `internal/service/**/*.go`
|
||||
- Contains: 账号、订单、套餐、轮询、设备、卡、认证、充值、佣金等服务。
|
||||
- Depends on: `internal/store/postgres`, `pkg/errors`, `pkg/middleware`, `pkg/queue`, `pkg/wechat`, `gorm.DB`, `redis.Client`
|
||||
- Used by: Handler 层、Worker Bootstrap、Task Handler。
|
||||
|
||||
**Store Layer:**
|
||||
- Purpose: SQL 查询、分页、显式数据权限过滤、缓存辅助、事务基础能力。
|
||||
- Location: `internal/store/store.go`, `internal/store/options.go`, `internal/store/postgres/*.go`
|
||||
- Contains: 每个聚合的 PostgreSQL Store,例如 `internal/store/postgres/account_store.go`, `internal/store/postgres/order_store.go`。
|
||||
- Depends on: `gorm`, `redis`, `pkg/middleware/data_scope.go`
|
||||
- Used by: Service 层、部分 Handler 组装期专用读模型。
|
||||
|
||||
**Model Layer:**
|
||||
- Purpose: 持久化模型、DTO、常量化状态值、表名映射。
|
||||
- Location: `internal/model/*.go`, `internal/model/dto/*.go`
|
||||
- Contains: `model.Account`, `model.Order`, `model.Shop`, `model.PollingConfig` 以及请求/响应 DTO。
|
||||
- Depends on: GORM tags、标准库类型。
|
||||
- Used by: Store、Service、Handler、OpenAPI 生成。
|
||||
|
||||
**Task / Worker Layer:**
|
||||
- Purpose: Asynq 任务处理、轮询调度、定时任务、批处理导入。
|
||||
- Location: `internal/task/*.go`, `internal/polling/*.go`, `pkg/queue/*.go`
|
||||
- Contains: `pkg/queue/handler.go` 任务注册器、`internal/task/polling_handler.go`、`internal/task/order_expire.go`、`internal/polling/scheduler.go`。
|
||||
- Depends on: Worker bootstrap 产出的 stores/services、Redis、Asynq、Gateway。
|
||||
- Used by: `cmd/worker/main.go`
|
||||
|
||||
## Data Flow
|
||||
|
||||
**API Request Flow:**
|
||||
|
||||
1. `cmd/api/main.go` 创建 Fiber 应用并注册 Recover、RequestID、访问日志、压缩中间件。
|
||||
2. `internal/routes/routes.go` 将请求分发到 `/api/auth`、`/api/admin`、`/api/c/v1`、`/api/callback` 四个域。
|
||||
3. `pkg/middleware/auth.go` 或 `internal/middleware/personal_auth` 将用户信息写入 Fiber `Locals` 和标准 `context.Context`。
|
||||
4. Handler 解析 DTO 并调用 Service,例如 `internal/handler/admin/account.go` → `internal/service/account/service.go`。
|
||||
5. Service 调用一个或多个 Store,必要时使用 `db.WithContext(ctx).Transaction(...)` 编排事务,例如 `internal/service/order/service.go`。
|
||||
6. Store 在查询中显式调用 `pkg/middleware/data_scope.go` 的过滤函数,例如 `internal/store/postgres/account_store.go`、`internal/store/postgres/order_store.go`。
|
||||
7. Handler 通过 `pkg/response/response.go` 返回统一响应;异常交由 `internal/middleware.ErrorHandler` 处理。
|
||||
|
||||
**Worker Flow:**
|
||||
|
||||
1. `cmd/worker/main.go` 初始化 PostgreSQL、Redis、Asynq Client、对象存储、Gateway。
|
||||
2. `internal/bootstrap/worker.go` 调用 `initWorkerStores()` 与 `initWorkerServices()`,生成 `queue.WorkerBootstrapResult`。
|
||||
3. `pkg/queue/handler.go` 基于 Bootstrap 结果注册导入、轮询、佣金、超时取消、告警、清理等任务处理器。
|
||||
4. `internal/polling/scheduler.go` 独立运行轮询调度循环:从 Redis Sorted Set / List 取卡片,再投递到 Asynq。
|
||||
5. `cmd/worker/main.go` 还额外创建 Asynq Scheduler,注册 `TaskTypeOrderExpire`、`TaskTypeAlertCheck`、`TaskTypeDataCleanup`。
|
||||
6. 具体任务处理器调用 Service 或直接使用 Store/DB 更新状态,例如 `internal/task/order_expire.go` → `order.Service.CancelExpiredOrders()`。
|
||||
|
||||
**State Management:**
|
||||
- HTTP 侧状态主要放在 PostgreSQL;请求态身份与数据权限范围放在 `context.Context`。
|
||||
- 高速状态、Token、轮询队列、并发信号量、下级店铺缓存放在 Redis,如 `pkg/auth/token.go`、`internal/polling/scheduler.go`、`internal/store/postgres/shop_store.go`。
|
||||
- 异步处理采用 Redis-backed Asynq 队列,由 `pkg/queue/client.go` 提交、`pkg/queue/server.go` 消费。
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
**BootstrapResult / WorkerBootstrapResult:**
|
||||
- Purpose: 启动期依赖编排结果。
|
||||
- Examples: `internal/bootstrap/bootstrap.go`, `internal/bootstrap/worker.go`
|
||||
- Pattern: 显式依赖注入,不使用外部 DI 框架。
|
||||
|
||||
**Handlers / Middlewares 容器:**
|
||||
- Purpose: 路由注册时的装配清单。
|
||||
- Examples: `internal/bootstrap/types.go`, `internal/bootstrap/handlers.go`, `internal/bootstrap/middlewares.go`
|
||||
- Pattern: 结构体聚合所有已构造的 Handler 与 Middleware。
|
||||
|
||||
**RouteSpec + Register():**
|
||||
- Purpose: 单次声明同时完成真实路由注册和 OpenAPI 元数据注册。
|
||||
- Examples: `internal/routes/registry.go`, `internal/routes/account.go`, `internal/routes/personal.go`
|
||||
- Pattern: 代码即文档,DTO 驱动文档生成。
|
||||
|
||||
**QueryOptions:**
|
||||
- Purpose: Store 通用分页与排序选项。
|
||||
- Examples: `internal/store/options.go`, `internal/service/account/service.go`, `internal/service/order/service.go`
|
||||
- Pattern: Service 先构造查询选项,再下传到 Store。
|
||||
|
||||
**UserContextInfo + Data Scope Filters:**
|
||||
- Purpose: 承载用户身份与可管理店铺范围,并在 Store 查询时执行过滤。
|
||||
- Examples: `pkg/middleware/auth.go`, `pkg/middleware/data_scope.go`, `internal/store/postgres/shop_store.go`
|
||||
- Pattern: 中间件预计算 `SubordinateShopIDs`,Store 按字段类型显式调用 `ApplyShopFilter` / `ApplyEnterpriseFilter` / `ApplySellerShopFilter`。
|
||||
|
||||
## Entry Points
|
||||
|
||||
**API Main:**
|
||||
- Location: `cmd/api/main.go`
|
||||
- Triggers: `go run cmd/api/main.go`、API 容器启动。
|
||||
- Responsibilities: 加载配置、初始化基础设施、执行 `bootstrap.Bootstrap()`、创建 Fiber、注册中间件和路由、生成运行时 OpenAPI 文件 `logs/openapi.yaml`。
|
||||
|
||||
**Worker Main:**
|
||||
- Location: `cmd/worker/main.go`
|
||||
- Triggers: `go run cmd/worker/main.go`、Worker 容器启动。
|
||||
- Responsibilities: 初始化 Worker 依赖、执行 `bootstrap.BootstrapWorker()`、启动 Asynq Worker、启动轮询调度器和 Asynq Scheduler、优雅关闭。
|
||||
|
||||
**OpenAPI Generation CLI:**
|
||||
- Location: `cmd/gendocs/main.go`
|
||||
- Triggers: 手动执行文档生成。
|
||||
- Responsibilities: 通过 `openapi.BuildDocHandlers()` + `routes.RegisterRoutesWithDoc()` 输出 `docs/admin-openapi.yaml`。
|
||||
|
||||
## Routing Strategy
|
||||
|
||||
**Domain Split:**
|
||||
- `/api/auth`:统一后台/H5 认证,见 `internal/routes/auth.go`
|
||||
- `/api/admin`:管理后台业务域,见 `internal/routes/admin.go`
|
||||
- `/api/c/v1`:个人客户端域,见 `internal/routes/personal.go`
|
||||
- `/api/callback`:支付回调,无认证,见 `internal/routes/order.go` 与 `internal/handler/callback/payment.go`
|
||||
|
||||
**Registration Style:**
|
||||
- 所有 HTTP 接口使用 `internal/routes/registry.go` 的 `Register()`,文档规范见 `docs/api-documentation-guide.md`。
|
||||
- `internal/routes/admin.go` 以 handler 是否为 `nil` 决定是否挂载模块,便于文档生成和裁剪。
|
||||
- `internal/routes/personal.go` 明确区分公开路由与认证路由,并依赖注册顺序确保公开接口不被 `Use()` 拦截。
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Strategy:** 统一错误码 + 全局 Fiber ErrorHandler。
|
||||
|
||||
**Patterns:**
|
||||
- Handler 层只做参数解析与转发,返回 `pkg/errors` 中的 `AppError`,如 `internal/handler/admin/account.go`。
|
||||
- Service 层封装业务错误,不直接向外泄露底层错误文本,如 `internal/service/account/service.go`、`internal/service/order/service.go`。
|
||||
- Worker 任务记录结构化日志,并将可重试与不可重试逻辑写在处理器内部,如 `internal/task/order_expire.go`、`internal/task/polling_handler.go`。
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
**Logging:**
|
||||
- `pkg/logger/logger.go` 初始化 App/Access 日志;`pkg/logger/middleware.go` 记录完整请求/响应摘要。
|
||||
|
||||
**Validation:**
|
||||
- Fiber `BodyParser` / `QueryParser` + `validator.v10`,示例见 `internal/handler/auth/handler.go`。
|
||||
|
||||
**Authentication:**
|
||||
- 后台/H5 使用 Redis Token + `pkg/middleware/auth.go`;个人客户使用单独中间件,由 `internal/bootstrap/middlewares.go` 创建。
|
||||
|
||||
**OpenAPI:**
|
||||
- 路由元数据写在 `RouteSpec`;文档生成器依赖 `cmd/api/docs.go`、`cmd/gendocs/main.go`、`pkg/openapi/handlers.go` 三处手工维护 Handler 清单。
|
||||
|
||||
**Persistence Rules:**
|
||||
- 模型显式声明 `TableName()` 与字段列名,如 `internal/model/account.go`, `internal/model/order.go`, `internal/model/shop.go`。
|
||||
- 数据库结构通过 `migrations/*.sql` 管理,`pkg/database/postgres.go` 明确禁用自动建表。
|
||||
|
||||
## Architectural Deviations
|
||||
|
||||
**README / 规范与当前实现存在的偏差:**
|
||||
- `README.md` 与 `openspec/config.yaml` 多处描述“GORM Callback 自动注入数据权限过滤”,但当前 `internal/bootstrap/bootstrap.go` 第 67 行明确写明“数据权限过滤已移至 Store 层显式调用 ApplyXxxFilter 函数”。当前保留的 GORM Callback 只有 `pkg/gorm/callback.go` 中的创建人/更新人自动填充。
|
||||
- `internal/bootstrap/handlers.go` 为客户端场景直接新建多个 Store 和一个 `client_order` Service,而不是完全只消费 `services` 聚合;这说明 Handler 装配阶段存在读模型/适配层级的额外拼装。
|
||||
- `internal/task/polling_handler.go` 在首次实名激活流程中先构造 Asynq 任务,但实际通过 Redis List `RPush` 提交,文件内注释明确写出“实际应该通过依赖注入 asynq.Client”;这是 Worker 流程中的特殊机制与待收敛点。
|
||||
|
||||
---
|
||||
|
||||
*Architecture analysis: 2026-03-27*
|
||||
173
.planning/codebase/CONCERNS.md
Normal file
173
.planning/codebase/CONCERNS.md
Normal file
@@ -0,0 +1,173 @@
|
||||
# Codebase Concerns
|
||||
|
||||
**Analysis Date:** 2026-03-27
|
||||
|
||||
## Tech Debt
|
||||
|
||||
**配置与凭据管理(严重级别:Critical):**
|
||||
- Issue: 仓库内存在已提交的生产/测试环境敏感配置与直连外部基础设施的写法,配置边界没有被代码、脚本和部署文件统一约束。
|
||||
- Files: `docker-compose.prod.yml`, `Makefile`, `pkg/config/defaults/config.yaml`, `scripts/verify_migration/main.go`, `scripts/verify_indexes/main.go`, `docs/deployment/deployment-guide.md`
|
||||
- Impact: 凭据轮换成本高;泄漏后影响数据库、Redis、对象存储、短信网关、Gateway 与部署链路;开发、测试、生产边界容易串用。
|
||||
- Supporting evidence:
|
||||
- `docker-compose.prod.yml` 直接写入数据库、Redis、JWT、对象存储、Gateway、短信服务配置。
|
||||
- `Makefile` 的 `DB_URL` 内嵌远程数据库连接串。
|
||||
- `pkg/config/defaults/config.yaml` 为 `gateway.app_id`、`gateway.app_secret` 提供非空默认值,服务在未显式覆盖时仍会初始化 Gateway 客户端,见 `cmd/api/main.go:366-383`。
|
||||
- `scripts/verify_migration/main.go` 与 `scripts/verify_indexes/main.go` 直接写死远程 PostgreSQL DSN。
|
||||
- `docs/deployment/deployment-guide.md` 把 Registry 凭据与数据库连接示例直接写进文档。
|
||||
- Fix approach: 把所有密钥迁移到部署平台 Secret/环境注入;默认配置只保留空值;脚本统一改为读取环境变量;把示例文档改成占位符而不是真实值。
|
||||
- Suggested follow-up investigation topics:
|
||||
- 盘点 `openspec/`, `docs/`, `scripts/` 中所有历史凭据残留。
|
||||
- 审查 Gitea Secrets、Docker Registry、数据库、Redis、对象存储、短信网关、Gateway 的轮换计划。
|
||||
- 检查 `.env`、`.env.local` 是否已在团队机器与 Runner 上扩散。
|
||||
|
||||
**支付与微信配置迁移未闭环(严重级别:High):**
|
||||
- Issue: 设计已经切到 `tb_wechat_config` + `payment_config_id`,但代码与文档仍保留环境变量和单例支付实现,处于半迁移状态。
|
||||
- Files: `docs/wechat-config-management/功能总结.md`, `internal/service/order/service.go`, `internal/service/recharge/service.go`, `internal/handler/callback/payment.go`, `docs/environment-variables.md`, `scripts/verify-wechat.sh`, `docker-compose.prod.yml`
|
||||
- Impact: 多支付配置切换、旧订单验签、客户端支付发起、OAuth 配置一致性存在行为不一致风险;运维很难判断哪些配置源仍然生效。
|
||||
- Supporting evidence:
|
||||
- `docs/wechat-config-management/功能总结.md:232-238` 明确写着客户端支付发起仍是留桩,OAuth 仍从环境变量读取,且旧 `JUNHONG_WECHAT_PAYMENT_*` “可清理”。
|
||||
- `internal/service/order/service.go:2107,2163,2356,2362` 与 `internal/service/recharge/service.go:271`、`internal/handler/callback/payment.go:66,143` 仍保留 TODO/留桩。
|
||||
- `docs/environment-variables.md:36-69` 与 `scripts/verify-wechat.sh` 仍把微信支付环境变量当成必填启动前置条件。
|
||||
- `docker-compose.prod.yml` 注释称“微信配置已迁移至数据库”,但部署文件仍维持部分旧式环境变量与其他支付相关外部配置模式。
|
||||
- Fix approach: 明确单一配置源;把“已迁移”“留桩”“仍依赖环境变量”的边界写入运维文档;在代码层补齐动态加载或删除旧入口。
|
||||
- Suggested follow-up investigation topics:
|
||||
- 核实当前线上 OAuth 与支付分别读哪一套配置。
|
||||
- 核查 `payment_config_id` 相关回调链路是否覆盖订单、资产充值、代理充值三条路径。
|
||||
- 为未实现的客户端支付入口建立显式禁用清单。
|
||||
|
||||
**核心服务体量过大(严重级别:High):**
|
||||
- Issue: 部分核心服务文件已经远超团队规范中的函数/文件规模要求,维护成本和回归风险高。
|
||||
- Files: `internal/service/order/service.go`, `AGENTS.md`
|
||||
- Impact: 改动难以局部验证;支付、幂等、库存/余额、回调逻辑耦合;新需求更容易引入回归。
|
||||
- Supporting evidence:
|
||||
- `AGENTS.md:371-375` 要求函数长度 ≤ 100 行,核心逻辑建议 ≤ 50 行。
|
||||
- `internal/service/order/service.go` 的 TODO 已出现在 2107、2163、2356、2362 行,说明文件至少超过 2362 行。
|
||||
- Fix approach: 按支付发起、支付回调、订单状态流转、幂等与锁、钱包扣款等职责拆分子服务/辅助组件。
|
||||
- Suggested follow-up investigation topics:
|
||||
- 统计 `internal/service/` 中超长文件与超长函数。
|
||||
- 识别最常改动的热点段落与共享依赖。
|
||||
|
||||
## Known Bugs
|
||||
|
||||
**健康检查接口对故障返回 200 成功包裹(严重级别:High):**
|
||||
- Symptoms: PostgreSQL 或 Redis 故障时,`/health` 仍通过 `response.Success` 返回成功包裹,只在 `data.status` 中标记 `degraded`。
|
||||
- Files: `internal/handler/health.go`, `docker-compose.prod.yml`, `Dockerfile.api`, `debug-deployment.sh`
|
||||
- Trigger: 数据库或 Redis Ping 失败。
|
||||
- Workaround: 监控端必须解析响应体里的 `data.status` 与 `services.*.status`,不能只看 HTTP 状态码。
|
||||
- Supporting evidence:
|
||||
- `internal/handler/health.go:101-111` 注释与实现都明确“端点本身能响应即视为成功”。
|
||||
- `docker-compose.prod.yml` 与 `Dockerfile.api` 的健康检查只执行 `wget --spider http://127.0.0.1:3000/health`,不会校验 JSON 内容。
|
||||
- Suggested follow-up investigation topics:
|
||||
- 确认当前容器健康检查、反向代理、告警系统是否只看 200。
|
||||
- 评估是否需要新增 `readyz`/`livez` 区分活性与依赖可用性。
|
||||
|
||||
**API 路径文档与实际路由不一致(严重级别:Medium):**
|
||||
- Symptoms: 文档仍混用 `/api/v1`、`/api/auth`、`/api/c/v1`,读者很难判断当前真实入口。
|
||||
- Files: `README.md`, `internal/routes/routes.go`, `scripts/check-comment-paths.sh`
|
||||
- Trigger: 新人按 README 试运行、按旧注释实现客户端、或依据文档回归接口。
|
||||
- Workaround: 以 `internal/routes/routes.go` 为准核对真实挂载路径。
|
||||
- Supporting evidence:
|
||||
- `internal/routes/routes.go:21-39` 当前真实路由为 `/api/auth`、`/api/admin`、`/api/c/v1`、`/api/callback`。
|
||||
- `README.md:577-583` 仍展示 `v1 := app.Group("/api/v1")` 的旧限流示例;`README.md:588-624` 仍以 `/api/v1/users` 讲解请求流。
|
||||
- `scripts/check-comment-paths.sh` 只检查 `internal/handler/` 中残留 `/api/v1`,并不覆盖 `README.md`、`docs/`、`openspec/`。
|
||||
- Suggested follow-up investigation topics:
|
||||
- 扫描所有 `docs/`, `README.md`, `openspec/`, `specs/` 中的旧路径。
|
||||
- 明确哪些旧路径仍需要兼容,哪些应统一下线。
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**部署流水线绕过 TLS 校验(严重级别:High):**
|
||||
- Risk: 工作流检出步骤通过 `GIT_SSL_NO_VERIFY=1` 跳过证书校验,供应链完整性依赖内网信任而不是证书验证。
|
||||
- Files: `.gitea/workflows/deploy.yaml`
|
||||
- Current mitigation: 注释说明是“内网自签名证书”。
|
||||
- Recommendations: 为 Gitea/Registry 配置受信 CA;移除跳过校验;至少把跳过范围限制在明确的单一主机与受控 Runner。
|
||||
- Supporting evidence:
|
||||
- `.gitea/workflows/deploy.yaml:23-27` 设置 `GIT_SSL_NO_VERIFY=1` 后执行 `git clone`。
|
||||
|
||||
**对外/回调错误信息泄漏底层细节(严重级别:Medium):**
|
||||
- Risk: 健康检查与富友回调把底层错误或内部状态直接返回给调用方,不符合统一错误暴露规范。
|
||||
- Files: `internal/handler/health.go`, `internal/handler/callback/payment.go`, `AGENTS.md`
|
||||
- Current mitigation: 有统一错误处理规范,但没有覆盖这两个特殊出口。
|
||||
- Recommendations: 对外返回统一错误码/固定消息,把底层错误仅写日志。
|
||||
- Supporting evidence:
|
||||
- `AGENTS.md:124-129` 明确禁止直接暴露底层错误。
|
||||
- `internal/handler/health.go:50-60,82-85` 直接返回 `err.Error()`。
|
||||
- `internal/handler/callback/payment.go:178-191` 直接把 `err.Error()` 拼入富友回调失败响应。
|
||||
|
||||
## Performance Bottlenecks
|
||||
|
||||
**默认限流仍使用内存存储且默认关闭(严重级别:Medium):**
|
||||
- Problem: 默认配置既不开启限流,也默认使用 `memory` 存储;多实例部署时无法形成统一配额。
|
||||
- Files: `pkg/config/defaults/config.yaml`, `cmd/api/main.go`, `README.md`
|
||||
- Cause: 配置更偏向本地开发便利,缺少生产默认值与环境级约束。
|
||||
- Improvement path: 明确生产必须启用 Redis 存储限流;在部署文档里给出环境变量模板;增加启动日志或自检提示当前限流模式。
|
||||
- Supporting evidence:
|
||||
- `pkg/config/defaults/config.yaml:87-93` 默认 `enable_rate_limiter: false`、`storage: memory`。
|
||||
- `cmd/api/main.go:236-278` 只有显式开启时才挂载限流,并根据配置选择 Redis 或内存存储。
|
||||
|
||||
## Fragile Areas
|
||||
|
||||
**OpenAPI 文档生成依赖手工双点维护(严重级别:Medium):**
|
||||
- Files: `AGENTS.md`, `docs/api-documentation-guide.md`, `cmd/api/docs.go`, `cmd/gendocs/main.go`
|
||||
- Why fragile: 新增 Handler 必须同时更新两个文件;流程靠人工记忆,历史上已经出现清单不一致并专门做过修复。
|
||||
- Safe modification: 每次新增 Handler 时同时检查 `cmd/api/docs.go`、`cmd/gendocs/main.go` 与生成产物 `docs/admin-openapi.yaml`/`logs/openapi.yaml`。
|
||||
- Test coverage: 未发现自动化校验这两份清单一致性的 CI 步骤。
|
||||
- Supporting evidence:
|
||||
- `AGENTS.md:53-65` 明确要求手工更新两个文件。
|
||||
- `docs/api-documentation-guide.md` 多处把“忘记在两个文件中添加新 Handler”列为最常见问题。
|
||||
- `.gitea/workflows/deploy.yaml` 中未见 `go run cmd/gendocs/main.go`、diff 校验或文档一致性检查。
|
||||
|
||||
**部署脚本与运行时约定已漂移(严重级别:Medium):**
|
||||
- Files: `debug-deployment.sh`, `docker-compose.prod.yml`, `README.md`, `docs/deployment/deployment-guide.md`
|
||||
- Why fragile: 诊断脚本仍假定旧环境变量名与外置配置文件存在,文档与当前嵌入式配置实现不完全一致。
|
||||
- Safe modification: 先以 `docker-compose.prod.yml`、`pkg/config/defaults/config.yaml`、`cmd/api/main.go` 为准修正运维脚本,再统一更新部署文档。
|
||||
- Test coverage: 未发现对诊断脚本、部署文档、健康检查语义的自动验证。
|
||||
- Supporting evidence:
|
||||
- `debug-deployment.sh:52-57` 仍 grep `DB_|CONFIG_ENV`,并尝试读取 `/app/configs/config.yaml`;当前配置前缀实际是 `JUNHONG_`,配置默认嵌入二进制,见 `README.md:651-689`、`pkg/config/defaults/config.yaml`。
|
||||
- `docs/deployment/deployment-guide.md:302-305` 仍建议从 `/app/.env` 拼接迁移命令,但容器实际入口用环境变量构造 DSN,见 `docker/entrypoint-api.sh:8-17`。
|
||||
|
||||
## Scaling Limits
|
||||
|
||||
**CI/CD 质量闸门过窄(严重级别:High):**
|
||||
- Current capacity: 当前工作流只做 clone、build、push、main 分支部署。
|
||||
- Limit: 文档一致性、脚本检查、编译前规范、迁移安全、健康语义等问题都可能直接进入镜像与部署环境。
|
||||
- Scaling path: 在 `.gitea/workflows/deploy.yaml` 前置独立质量作业,至少执行 `bash scripts/check-all.sh`、`go build ./...`、OpenAPI 生成/校验,并在部署前验证 compose 文件与镜像健康策略。
|
||||
- Supporting evidence:
|
||||
- `README.md:941-957` 声称脚本检查会在 CI/CD 自动执行。
|
||||
- `.gitea/workflows/deploy.yaml` 未出现 `scripts/check-all.sh`、`go test`、`go vet`、`go build ./...`、OpenAPI 校验等步骤。
|
||||
|
||||
## Dependencies at Risk
|
||||
|
||||
**Go 版本与运行基础不统一(严重级别:Medium):**
|
||||
- Risk: 版本声明分散在多个位置,升级与排障时容易出现“本地能过、容器不一致”的问题。
|
||||
- Impact: 构建环境复现、依赖升级与 bug 排查成本上升。
|
||||
- Migration plan: 约定单一权威版本源(如 `go.mod` + `.tool-versions`/`.go-version`),并让 Dockerfile、README、部署文档同步引用。
|
||||
- Supporting evidence:
|
||||
- `go.mod:3` 使用 `go 1.25.0`。
|
||||
- `Dockerfile.api:4` 与 `Dockerfile.worker:4` 使用 `golang:1.25.6-alpine`。
|
||||
- `README.md:884` 写的是 `Go 1.25.1`。
|
||||
|
||||
## Missing Critical Features
|
||||
|
||||
**缺少可执行的自动化质量/回归入口(严重级别:High):**
|
||||
- Problem: README、历史设计与测试文档大量提到 `go test`、`IntegrationTestEnv`、`tests/integration/`,但仓库当前未发现对应测试文件或 `tests/` 目录。
|
||||
- Blocks: 无法快速验证重构、支付回调、权限过滤、部署脚本变更;文档中的测试命令无法直接落地。
|
||||
- Supporting evidence:
|
||||
- `AGENTS.md:324-354` 当前规范明确禁止自动化测试,和 `README.md:712-756`、`docs/testing/test-connection-guide.md` 的测试要求形成正面冲突。
|
||||
- 读取仓库根目录未发现 `tests/` 目录;`glob` 搜索 `tests/**/*_test.go` 与 `internal/**/*_test.go` 未返回结果。
|
||||
- `docs/testing/test-connection-guide.md` 仍宣称“204 个测试总耗时 ~10.5 秒”并要求所有新测试遵循该规范。
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
**部署、配置迁移、运维脚本与文档一致性未见自动验证(优先级:High):**
|
||||
- What's not tested: `docker-compose.prod.yml`、`debug-deployment.sh`、`scripts/migrate.sh`、OpenAPI 文档生成器双清单、README/部署文档与代码的同步性。
|
||||
- Files: `.gitea/workflows/deploy.yaml`, `docker-compose.prod.yml`, `debug-deployment.sh`, `scripts/migrate.sh`, `cmd/api/docs.go`, `cmd/gendocs/main.go`, `README.md`
|
||||
- Risk: 配置漂移、凭据泄漏、文档错误、健康检查误报、迁移行为变化都可能在上线后才暴露。
|
||||
- Priority: High
|
||||
- Suggested follow-up investigation topics:
|
||||
- 设计最小化“文档/脚本一致性检查”而不是恢复完整自动化测试体系。
|
||||
- 评估是否要为部署文件与文档生成引入静态检查或 smoke check。
|
||||
|
||||
---
|
||||
|
||||
*Concerns audit: 2026-03-27*
|
||||
168
.planning/codebase/CONVENTIONS.md
Normal file
168
.planning/codebase/CONVENTIONS.md
Normal file
@@ -0,0 +1,168 @@
|
||||
# Coding Conventions
|
||||
|
||||
**Analysis Date:** 2026-03-27
|
||||
|
||||
## Naming Patterns
|
||||
|
||||
**Files:**
|
||||
- Go 源码使用 `snake_case.go` 或按领域拆分的普通小写命名;路由文件按模块命名放在 `internal/routes/*.go`,Handler 按域放在 `internal/handler/admin/*.go`、`internal/handler/auth/*.go`、`internal/handler/app/*.go`。
|
||||
- OpenAPI 与规范文档使用明确用途命名,例如 `docs/api-documentation-guide.md`、`docs/admin-openapi.yaml`、`cmd/api/docs.go`、`cmd/gendocs/main.go`。
|
||||
- 规范脚本使用动词前缀命名,例如 `scripts/check-all.sh`、`scripts/check-service-errors.sh`、`scripts/check-comment-paths.sh`、`scripts/verify_migration/main.go`。
|
||||
|
||||
**Functions:**
|
||||
- 导出函数/方法使用 Go 的 `PascalCase`,例如 `NewAccountHandler()`、`Create()`、`Success()`、`RedisOrderIdempotencyKey()`,证据见 `internal/handler/admin/account.go`、`pkg/response/response.go`、`pkg/constants/redis.go`。
|
||||
- 未导出帮助函数使用 `camelCase`,例如 `generateOpenAPIDocs()`、`generateAdminDocs()`、`handleError()`、`safeLogWithLevel()`,证据见 `cmd/api/docs.go`、`cmd/gendocs/main.go`、`pkg/errors/handler.go`。
|
||||
|
||||
**Variables:**
|
||||
- 局部变量使用英文 `camelCase`,例如 `outputPath`、`httpStatus`、`roleID`、`groupPath`,证据见 `cmd/api/docs.go`、`pkg/errors/handler.go`、`internal/handler/admin/account.go`。
|
||||
- 错误变量统一使用 `err`,成功返回数据常用业务名,例如 `account`、`accounts`、`roles`,证据见 `internal/handler/admin/account.go`。
|
||||
|
||||
**Types:**
|
||||
- 结构体和接口使用 `PascalCase`,接口语义化命名,配合 `-er` 后缀规则;项目规范明确要求接口名使用 `-er`,见 `AGENTS.md`。
|
||||
- 路由文档元数据类型使用语义名,如 `RouteSpec`、`FileUploadField`,证据见 `internal/routes/registry.go`。
|
||||
|
||||
## Code Style
|
||||
|
||||
**Formatting:**
|
||||
- 使用 `gofmt`;项目在 `AGENTS.md` 明确要求“使用 gofmt 格式化”。
|
||||
- 代码风格遵循 Go 惯用法,避免 Java 式过度抽象;证据见 `AGENTS.md` 的“Go 惯用法 vs Java 风格”。
|
||||
|
||||
**Linting / Scripted Checks:**
|
||||
- 仓库未检测到 `.golangci.yml`、`golangci-lint`、`eslint`、`prettier` 之类自动 lint 配置;当前质量门主要依赖 shell 脚本和人工审查。
|
||||
- `scripts/check-service-errors.sh`:扫描 `internal/service/**/*.go` 中的 `fmt.Errorf`,要求改用 `errors.New()` / `errors.Wrap()`。
|
||||
- `scripts/check-comment-paths.sh`:扫描 `internal/handler/` 中残留的 `/api/v1` 注释,要求改成真实路径 `/api/admin`、`/api/h5`、`/api/c/v1`。
|
||||
- `scripts/check-all.sh`:串行执行以上两个检查。
|
||||
- 当前仓库与脚本规则存在冲突:`internal/service/polling/alert_service.go` 仍存在 4 处 `fmt.Errorf(...)`,按 `scripts/check-service-errors.sh` 的规则属于违规现状。
|
||||
|
||||
## Language & Comment Requirements
|
||||
|
||||
**语言要求:**
|
||||
- 用户可见内容、日志、注释、文档、提交信息都使用中文;变量名、函数名、类型名使用英文,证据见 `AGENTS.md` 的“语言要求”。
|
||||
|
||||
**注释要求:**
|
||||
- 导出包、结构体、接口、函数、方法、常量、变量必须有中文文档注释,证据见 `AGENTS.md`。
|
||||
- Handler 方法注释必须包含 HTTP 方法与真实路径;实际示例见 `internal/handler/admin/account.go`、`internal/handler/callback/payment.go`。
|
||||
- 注释解释“为什么”,不要复述代码;复杂逻辑必须有实现注释,证据见 `AGENTS.md`。
|
||||
- 常量必须带中文注释;实际示例见 `pkg/constants/constants.go`、`pkg/constants/redis.go`。
|
||||
|
||||
## Import Organization
|
||||
|
||||
**Order:**
|
||||
1. Go 标准库,例如 `strconv`、`time`、`regexp`。
|
||||
2. 第三方库,例如 `github.com/gofiber/fiber/v2`、`go.uber.org/zap`。
|
||||
3. 项目内包,例如 `github.com/break/junhong_cmp_fiber/pkg/errors`、`internal/service/account`。
|
||||
|
||||
**Pattern:**
|
||||
- 多数组件按“标准库 → 项目包/第三方包”分组,并保留空行分隔,证据见 `internal/handler/admin/account.go`、`pkg/errors/handler.go`、`cmd/gendocs/main.go`。
|
||||
- 项目使用完整 module import path,未体现短别名路径;必要时用语义别名,例如 `apphandler`、`accountService`,证据见 `cmd/gendocs/main.go`、`internal/handler/admin/account.go`。
|
||||
|
||||
**Path Aliases:**
|
||||
- 未检测到 TS/JS 式路径别名;Go 代码通过 module path `github.com/break/junhong_cmp_fiber/...` 引用。
|
||||
|
||||
## Layering Rules
|
||||
|
||||
**Required Architecture:**
|
||||
- 必须遵循 `Handler → Service → Store → Model`,证据见 `AGENTS.md` 与 `README.md`。
|
||||
|
||||
**How to apply it:**
|
||||
- Handler 只做参数解析、上下文读取、调用 service、返回统一响应;示例见 `internal/handler/admin/account.go`。
|
||||
- Service 承载业务逻辑,并向下调用 Store;项目规范在 `AGENTS.md` 明确禁止在 Handler 中写业务逻辑。
|
||||
- Store 负责数据访问和事务;规范说明见 `AGENTS.md`、`README.md`。
|
||||
- Model/DTO 定义请求、响应和持久化结构;OpenAPI 文档也依赖 DTO 元数据,证据见 `docs/api-documentation-guide.md`。
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Centralized package:**
|
||||
- 所有错误码与应用错误类型集中在 `pkg/errors/`,证据见 `pkg/errors/codes.go`、`pkg/errors/errors.go`、`pkg/errors/handler.go`、`AGENTS.md`。
|
||||
|
||||
**Rules to follow:**
|
||||
- Handler 层不要把底层错误直接暴露给客户端;参数校验失败统一返回 `errors.New(errors.CodeInvalidParam)` 或其中文变体,证据见 `AGENTS.md`、`internal/handler/admin/account.go`。
|
||||
- Service 层对外不要返回 `fmt.Errorf(...)`,要返回 `errors.New(...)` 或 `errors.Wrap(...)`;脚本 `scripts/check-service-errors.sh` 专门检查这一点。
|
||||
- 全局错误处理使用 `pkg/errors/handler.go` 的 `SafeErrorHandler()` / `handleError()`,统一输出 `{code, data, msg, timestamp}`,并按错误码映射 HTTP 状态码与日志级别。
|
||||
- 5xx 响应统一走通用中文消息,避免泄露敏感信息,证据见 `pkg/errors/handler.go`。
|
||||
|
||||
**Error code system:**
|
||||
- `pkg/errors/codes.go` 定义 1000-1999 客户端错误、2000-2999 服务端错误。
|
||||
- `pkg/errors/codes.go` 在 `init()` 校验全部错误码都必须映射消息,新增错误码时要同步维护 `allErrorCodes` 和 `errorMessages`。
|
||||
|
||||
## Response Format
|
||||
|
||||
**Envelope:**
|
||||
- 所有 API 成功响应统一使用 `pkg/response/response.go`:`{code, data, msg, timestamp}`。
|
||||
- `Success()` 返回 `msg: "success"`;`SuccessWithMessage()` 允许覆盖消息;`SuccessWithPagination()` 将分页数据包进 `PaginationData`。
|
||||
|
||||
**How handlers should respond:**
|
||||
- Handler 成功路径直接调用 `response.Success()` / `response.SuccessWithPagination()`;实际示例见 `internal/handler/admin/account.go`、`internal/handler/admin/iot_card.go`、`internal/handler/admin/shop.go`。
|
||||
- 错误路径直接 `return err` 交给全局 ErrorHandler,不在 Handler 内手动拼接 JSON,证据见 `internal/handler/admin/account.go`、`pkg/errors/handler.go`。
|
||||
|
||||
## Constant Management
|
||||
|
||||
**Location:**
|
||||
- 所有常量统一放在 `pkg/constants/`,证据见 `AGENTS.md` 与 `pkg/constants/constants.go`、`pkg/constants/redis.go`。
|
||||
|
||||
**Rules to follow:**
|
||||
- 禁止硬编码字符串与 magic numbers;公共业务值放进 `pkg/constants/constants.go`。
|
||||
- Redis Key 必须通过函数生成,而不是手写字符串,命名格式使用 `Redis{Module}{Purpose}Key(...)`,证据见 `pkg/constants/redis.go` 与 `AGENTS.md`。
|
||||
- 常量要配中文注释;`pkg/constants/constants.go` 中的用户类型、任务类型、分页上限、默认管理员信息都按此方式组织。
|
||||
|
||||
## API / OpenAPI Conventions
|
||||
|
||||
**Route registration:**
|
||||
- 所有 HTTP 路由都应在 `internal/routes/*.go` 中通过 `Register()` 注册,不要直接 `router.Get/Post/...`,证据见 `docs/api-documentation-guide.md`、`internal/routes/registry.go`。
|
||||
- `Register()` 同时负责 Fiber 路由注册和 OpenAPI 生成;当 `doc != nil` 时,会把 `/:id` 转为 OpenAPI 的 `/{id}`,证据见 `internal/routes/registry.go`。
|
||||
|
||||
**RouteSpec requirements:**
|
||||
- 每个接口需要提供中文 `Summary`,可选 Markdown `Description`,并声明 `Input`、`Output`、`Tags`、`Auth`;证据见 `internal/routes/registry.go`、`docs/api-documentation-guide.md`。
|
||||
- DTO 字段必须使用 `description` 标签,不依赖行尾注释;枚举字段要在 `description` 中列出可选值,证据见 `docs/api-documentation-guide.md`。
|
||||
|
||||
**Handler + docs generator sync:**
|
||||
- 新增 Handler 时,除业务接线外,还必须同步更新 `internal/bootstrap/types.go`、`internal/bootstrap/handlers.go`、`internal/routes/admin.go`、`cmd/api/docs.go`、`cmd/gendocs/main.go`,证据见 `docs/api-documentation-guide.md` 与 `AGENTS.md`。
|
||||
- 文档生成命令使用 `go run cmd/gendocs/main.go` 或 `make docs`;代码证据见 `cmd/gendocs/main.go`、`Makefile`。
|
||||
|
||||
## Documentation Expectations
|
||||
|
||||
**Required docs workflow:**
|
||||
- 每个功能应在 `docs/{feature-id}/` 创建总结文档,并同步更新 `README.md`,证据见 `AGENTS.md`。
|
||||
- 文档和说明统一使用中文。
|
||||
- API 文档规范集中在 `docs/api-documentation-guide.md`;开发总规范集中在 `AGENTS.md`。
|
||||
|
||||
**Repository reality:**
|
||||
- `README.md` 仍保留旧的自动化测试与覆盖率章节、旧项目结构中的 `tests/` 目录描述、以及“CI/CD 自动执行检查”的表述。
|
||||
- 当前仓库未发现任何 `*_test.go` 文件,也未发现 `tests/` 目录内容;因此后续文档和执行应优先遵循 `AGENTS.md` 的现行规则,而不是 `README.md` 中这些过期段落。
|
||||
|
||||
## Operational Scripts
|
||||
|
||||
**Quality / convention scripts:**
|
||||
- `bash scripts/check-service-errors.sh`:检查 Service 层是否违规使用 `fmt.Errorf`。
|
||||
- `bash scripts/check-comment-paths.sh`:检查 Handler 注释路径是否残留 `/api/v1`。
|
||||
- `bash scripts/check-all.sh`:运行上述两个检查。
|
||||
|
||||
**Documentation script:**
|
||||
- `make docs` → `go run cmd/gendocs/main.go`,用于生成 `docs/admin-openapi.yaml`,证据见 `Makefile`、`cmd/gendocs/main.go`。
|
||||
|
||||
**Migration verification helper:**
|
||||
- `go run scripts/verify_migration/main.go`:这是一个面向数据库迁移结果的人工验证脚本,会直接连接数据库并查询字段,不是自动测试框架,证据见 `scripts/verify_migration/main.go`。
|
||||
|
||||
**Caution:**
|
||||
- `Makefile` 的 `test` 目标仍定义为 `go test -v ./...`,但这反映的是旧工具入口,不代表当前团队允许自动化测试。
|
||||
|
||||
## Module Design
|
||||
|
||||
**Exports:**
|
||||
- 构造函数使用 `NewXxx...`,例如 `NewAccountHandler()`、`NewGenerator()`。
|
||||
- Handler、response、errors、constants 都以小包单职责方式导出少量明确入口,证据见 `internal/handler/admin/account.go`、`pkg/response/response.go`、`pkg/errors/errors.go`。
|
||||
|
||||
**Barrel Files:**
|
||||
- 未检测到 JS/TS 式 barrel file;Go 通过包目录与导出符号组织模块。
|
||||
|
||||
## Prescriptive Summary
|
||||
|
||||
- 写新 Handler 时:在 `internal/handler/...` 保持中文注释 + 英文标识符,只解析请求并调用 Service,成功统一走 `pkg/response/response.go`,失败直接返回 `error`。
|
||||
- 写新业务错误时:先在 `pkg/errors/codes.go` 注册错误码与中文消息,再用 `errors.New()` / `errors.Wrap()`。
|
||||
- 写新常量或 Redis Key 时:放入 `pkg/constants/`,并补中文注释与 Key 生成函数。
|
||||
- 写新 API 时:在 `internal/routes/*.go` 用 `Register()` + `RouteSpec` 注册,并同步更新 `cmd/api/docs.go` 与 `cmd/gendocs/main.go` 的文档 Handler 装配。
|
||||
- 运行规范检查时:优先使用 `scripts/check-*.sh` 与 `make docs`;不要把 `README.md` 中的测试/覆盖率描述当成当前执行标准。
|
||||
|
||||
---
|
||||
|
||||
*Convention analysis: 2026-03-27*
|
||||
215
.planning/codebase/INTEGRATIONS.md
Normal file
215
.planning/codebase/INTEGRATIONS.md
Normal file
@@ -0,0 +1,215 @@
|
||||
# External Integrations
|
||||
|
||||
**Analysis Date:** 2026-03-27
|
||||
|
||||
## APIs & External Services
|
||||
|
||||
**Database / Cache Infrastructure:**
|
||||
- PostgreSQL - 主业务数据库,GORM 通过 DSN 建连并配置连接池,见 `pkg/database/postgres.go`
|
||||
- Client: `gorm.io/gorm` + `gorm.io/driver/postgres`
|
||||
- Config: `pkg/config/config.go` 的 `database.*`
|
||||
- Redis - Token、缓存、Asynq 后端、限流存储、微信配置缓存,见 `pkg/database/redis.go`、`pkg/queue/client.go`、`cmd/api/main.go`、`internal/service/wechat_config/service.go`
|
||||
- Client: `github.com/redis/go-redis/v9`
|
||||
- Config: `pkg/config/config.go` 的 `redis.*`
|
||||
|
||||
**Object Storage:**
|
||||
- S3-compatible object storage - 文件直传、下载、导入任务临时落盘,见 `pkg/storage/s3.go`、`pkg/storage/service.go`
|
||||
- SDK/Client: `github.com/aws/aws-sdk-go`
|
||||
- Config: `storage.provider`、`storage.s3.*`、`storage.presign.*` in `pkg/config/config.go`
|
||||
- Used by: `internal/handler/admin/storage.go`、`internal/routes/storage.go`、`internal/task/iot_card_import.go`、`internal/task/device_import.go`
|
||||
|
||||
**WeChat ecosystem:**
|
||||
- WeChat Official Account OAuth - 公众号登录与用户信息拉取,见 `pkg/wechat/official_account.go`、`internal/service/client_auth/service.go`
|
||||
- SDK/Client: `github.com/ArtisanCloud/PowerWeChat/v3`
|
||||
- Config source: `tb_wechat_config` via `internal/model/wechat_config.go`
|
||||
- WeChat Mini Program login - `code2session` 换取 openid/session_key,见 `pkg/wechat/miniapp.go`、`internal/service/client_auth/service.go`
|
||||
- Transport: 标准 `net/http`
|
||||
- Config source: `tb_wechat_config`
|
||||
- WeChat Pay - JSAPI/H5 下单、查单、关单、支付回调验签,见 `pkg/wechat/payment.go`、`pkg/wechat/config.go`、`internal/handler/callback/payment.go`
|
||||
- SDK/Client: `github.com/ArtisanCloud/PowerWeChat/v3`
|
||||
- Config source: `tb_wechat_config`
|
||||
|
||||
**Payment gateway:**
|
||||
- Fuiou - 预下单、回调解析、签名与验签 SDK 已存在,见 `pkg/fuiou/client.go`、`pkg/fuiou/wxprecreate.go`、`pkg/fuiou/notify.go`
|
||||
- SDK/Client: repo-local `pkg/fuiou`
|
||||
- Config source: `tb_wechat_config` provider=`fuiou`,字段位于 `internal/model/wechat_config.go`
|
||||
- Callback endpoint: `internal/handler/callback/payment.go`
|
||||
- Current state: 回调处理存在 `TODO`,当前路径解析 XML 但未按配置创建客户端做验签,见 `internal/handler/callback/payment.go`
|
||||
|
||||
**SMS gateway:**
|
||||
- SMS HTTP gateway - 验证码/消息发送,见 `pkg/sms/client.go`、`cmd/api/main.go`
|
||||
- Client: repo-local `pkg/sms`
|
||||
- Config: `sms.gateway_url`、`sms.username`、`sms.password`、`sms.signature` in `pkg/config/config.go`
|
||||
|
||||
**Business Gateway / IoT upstream:**
|
||||
- Gateway API - 设备信息、卡状态、流量、实名链接、设备限速/切卡等外部接口,见 `internal/gateway/client.go`、`internal/gateway/device.go`、`internal/gateway/flow_card.go`
|
||||
- Auth: appId + appSecret,自定义 AES-128-ECB 加密 + MD5 签名,见 `internal/gateway/client.go`
|
||||
- Config: `gateway.base_url`、`gateway.app_id`、`gateway.app_secret`、`gateway.timeout` in `pkg/config/config.go`
|
||||
- Used by: `internal/service/iot_card/gateway_service.go`、`internal/service/device/gateway_service.go`、`internal/task/polling_handler.go`
|
||||
|
||||
## Data Storage
|
||||
|
||||
**Databases:**
|
||||
- PostgreSQL
|
||||
- Connection keys: `JUNHONG_DATABASE_HOST`, `JUNHONG_DATABASE_PORT`, `JUNHONG_DATABASE_USER`, `JUNHONG_DATABASE_PASSWORD`, `JUNHONG_DATABASE_DBNAME`, `JUNHONG_DATABASE_SSLMODE`
|
||||
- Connection code: `pkg/database/postgres.go`
|
||||
- Runtime wiring: `cmd/api/main.go`, `cmd/worker/main.go`, `docker-compose.prod.yml`
|
||||
|
||||
**Redis:**
|
||||
- Redis is shared across auth, cache, rate limiting, WeChat config cache, and Asynq
|
||||
- Connection keys: `JUNHONG_REDIS_ADDRESS`, `JUNHONG_REDIS_PORT`, `JUNHONG_REDIS_PASSWORD`, `JUNHONG_REDIS_DB`
|
||||
- Connection code: `pkg/database/redis.go`
|
||||
- Asynq reuse: `pkg/queue/client.go`, `pkg/queue/server.go`
|
||||
- Config cache key example: `wechat:config:active` in `internal/service/wechat_config/service.go`
|
||||
|
||||
**File Storage:**
|
||||
- S3-compatible bucket storage
|
||||
- Provider implementation: `pkg/storage/s3.go`
|
||||
- Upload URL API: `internal/routes/storage.go`
|
||||
- Import consumers download to temp files before parsing: `internal/task/iot_card_import.go`, `internal/task/device_import.go`
|
||||
|
||||
**Local filesystem:**
|
||||
- Logs are persisted to `/app/logs` inside containers and mounted by Compose, see `pkg/config/defaults/config.yaml`, `Dockerfile.api`, `Dockerfile.worker`, `docker-compose.prod.yml`
|
||||
- OpenAPI runtime export writes to `logs/openapi.yaml`, see `cmd/api/main.go`, `cmd/api/docs.go`
|
||||
|
||||
## Authentication & Identity
|
||||
|
||||
**B-side auth:**
|
||||
- Redis-backed token management and JWT manager initialization occur in `cmd/api/main.go`
|
||||
- Components: `pkg/auth`, Redis token manager, JWT manager
|
||||
|
||||
**C-side identity via WeChat:**
|
||||
- 公众号 OAuth login path uses active config from `tb_wechat_config`, see `internal/service/client_auth/service.go`
|
||||
- 小程序 login path uses WeChat `code2session`, see `internal/service/client_auth/service.go`, `pkg/wechat/miniapp.go`
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
**Application logs:**
|
||||
- Zap + Lumberjack app/access loggers, see `pkg/logger/logger.go`
|
||||
|
||||
**Request tracing:**
|
||||
- Access logger captures request metadata and request/response bodies, see `pkg/logger/middleware.go`
|
||||
|
||||
**SQL observability:**
|
||||
- GORM logger sends query errors and slow queries to Zap, see `pkg/database/postgres.go`
|
||||
|
||||
**Container health:**
|
||||
- API health endpoint is used by Docker health checks and Compose dependency ordering, see `Dockerfile.api`, `docker-compose.prod.yml`
|
||||
|
||||
## Queue, Scheduling, and Runtime Integrations
|
||||
|
||||
**Asynq runtime:**
|
||||
- Worker server consumes Redis-backed queues configured in `queue.*`, see `pkg/queue/server.go`, `cmd/worker/main.go`
|
||||
- Task submission is wrapped in `pkg/queue/client.go`
|
||||
|
||||
**Registered task domains:**
|
||||
- Email, data sync, SIM status sync, IoT card import, device import, commission stats, commission calculation, polling, package activation, order expiration, alert check, data cleanup, see `pkg/queue/handler.go`
|
||||
|
||||
**Schedulers:**
|
||||
- Worker process starts an internal polling scheduler plus an Asynq Scheduler for recurring jobs, see `cmd/worker/main.go`
|
||||
- Recurring jobs include order expiration, alert checks, and nightly cleanup, see `cmd/worker/main.go`
|
||||
|
||||
## Docs Generation Integrations
|
||||
|
||||
**OpenAPI generation:**
|
||||
- Repo-local generator wraps `swaggest/openapi-go` and exports YAML, see `pkg/openapi/generator.go`
|
||||
- Runtime generation during API boot writes `logs/openapi.yaml`, see `cmd/api/main.go`, `cmd/api/docs.go`
|
||||
- Manual generation command writes `docs/admin-openapi.yaml`, see `Makefile`, `cmd/gendocs/main.go`
|
||||
- Route registration for docs reuses production route registration, see `internal/routes/routes.go`, `pkg/openapi/handlers.go`
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
**Hosting / Runtime:**
|
||||
- Dockerized API and Worker services are the deployment target, see `Dockerfile.api`, `Dockerfile.worker`, `docker-compose.prod.yml`
|
||||
|
||||
**Registry:**
|
||||
- Images are pushed to a private registry declared in `.gitea/workflows/deploy.yaml`
|
||||
|
||||
**CI Pipeline:**
|
||||
- Gitea workflow builds both images, pushes tag + SHA tags, copies `docker-compose.prod.yml`, and executes `docker compose pull/up -d`, see `.gitea/workflows/deploy.yaml`
|
||||
|
||||
**Migration tooling:**
|
||||
- API image bundles `golang-migrate` and migration files, see `Dockerfile.api`
|
||||
- Local migration shortcuts are defined in `Makefile`
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**Required env vars:**
|
||||
- Database: `JUNHONG_DATABASE_HOST`, `JUNHONG_DATABASE_PORT`, `JUNHONG_DATABASE_USER`, `JUNHONG_DATABASE_PASSWORD`, `JUNHONG_DATABASE_DBNAME`
|
||||
- Redis: `JUNHONG_REDIS_ADDRESS` plus optional Redis port/password/db tuning vars
|
||||
- JWT: `JUNHONG_JWT_SECRET_KEY`
|
||||
|
||||
**Optional env vars by integration:**
|
||||
- Storage: `JUNHONG_STORAGE_PROVIDER`, `JUNHONG_STORAGE_S3_*`
|
||||
- SMS: `JUNHONG_SMS_*`
|
||||
- Gateway: `JUNHONG_GATEWAY_*`
|
||||
- Logging: `JUNHONG_LOGGING_*`
|
||||
|
||||
**Secrets location:**
|
||||
- Default app config is embedded from `pkg/config/defaults/config.yaml`
|
||||
- Most runtime secrets are expected through environment variables bound in `pkg/config/loader.go`
|
||||
- WeChat / payment provider secrets are stored in database table `tb_wechat_config`, see `internal/model/wechat_config.go`
|
||||
- Deployment wiring exists in `docker-compose.prod.yml` and `.gitea/workflows/deploy.yaml`; these files should be treated as sensitive and are not reproduced here
|
||||
|
||||
## Webhooks & Callbacks
|
||||
|
||||
**Incoming:**
|
||||
- WeChat Pay callback: `POST /api/callback/wechat-pay`, handler in `internal/handler/callback/payment.go`
|
||||
- Fuiou Pay callback: `POST /api/callback/fuiou-pay`, handler in `internal/handler/callback/payment.go`
|
||||
- Alipay callback stub: `POST /api/callback/alipay`, handler in `internal/handler/callback/payment.go`
|
||||
|
||||
**Outgoing:**
|
||||
- WeChat OAuth and payment API calls from `pkg/wechat/*.go`
|
||||
- Gateway API calls from `internal/gateway/*.go`
|
||||
- SMS API calls from `pkg/sms/client.go`
|
||||
- S3 object storage calls from `pkg/storage/s3.go`
|
||||
|
||||
## Evidence
|
||||
|
||||
- `go.mod`
|
||||
- `README.md`
|
||||
- `cmd/api/main.go`
|
||||
- `cmd/api/docs.go`
|
||||
- `cmd/worker/main.go`
|
||||
- `cmd/gendocs/main.go`
|
||||
- `pkg/config/config.go`
|
||||
- `pkg/config/loader.go`
|
||||
- `pkg/config/defaults/config.yaml`
|
||||
- `pkg/database/postgres.go`
|
||||
- `pkg/database/redis.go`
|
||||
- `pkg/queue/client.go`
|
||||
- `pkg/queue/server.go`
|
||||
- `pkg/queue/handler.go`
|
||||
- `pkg/logger/logger.go`
|
||||
- `pkg/openapi/generator.go`
|
||||
- `pkg/openapi/handlers.go`
|
||||
- `pkg/storage/s3.go`
|
||||
- `pkg/storage/service.go`
|
||||
- `pkg/wechat/config.go`
|
||||
- `pkg/wechat/official_account.go`
|
||||
- `pkg/wechat/miniapp.go`
|
||||
- `pkg/wechat/payment.go`
|
||||
- `pkg/fuiou/client.go`
|
||||
- `pkg/fuiou/wxprecreate.go`
|
||||
- `pkg/fuiou/notify.go`
|
||||
- `pkg/sms/client.go`
|
||||
- `internal/gateway/client.go`
|
||||
- `internal/gateway/device.go`
|
||||
- `internal/gateway/flow_card.go`
|
||||
- `internal/model/wechat_config.go`
|
||||
- `internal/service/wechat_config/service.go`
|
||||
- `internal/service/client_auth/service.go`
|
||||
- `internal/service/client_order/service.go`
|
||||
- `internal/routes/storage.go`
|
||||
- `internal/handler/callback/payment.go`
|
||||
- `internal/task/iot_card_import.go`
|
||||
- `internal/task/device_import.go`
|
||||
- `Makefile`
|
||||
- `Dockerfile.api`
|
||||
- `Dockerfile.worker`
|
||||
- `docker-compose.prod.yml`
|
||||
- `.gitea/workflows/deploy.yaml`
|
||||
|
||||
---
|
||||
|
||||
*Integration audit: 2026-03-27*
|
||||
177
.planning/codebase/STACK.md
Normal file
177
.planning/codebase/STACK.md
Normal file
@@ -0,0 +1,177 @@
|
||||
# Technology Stack
|
||||
|
||||
**Analysis Date:** 2026-03-27
|
||||
|
||||
## Languages
|
||||
|
||||
**Primary:**
|
||||
- Go 1.25.x - 应用代码、API、Worker、OpenAPI 生成和基础设施脚本,依据 `go.mod`、`cmd/api/main.go`、`cmd/worker/main.go`、`cmd/gendocs/main.go`
|
||||
|
||||
**Secondary:**
|
||||
- YAML - 配置与部署编排,见 `pkg/config/defaults/config.yaml`、`docker-compose.prod.yml`、`.gitea/workflows/deploy.yaml`
|
||||
- Dockerfile - API/Worker 容器构建,见 `Dockerfile.api`、`Dockerfile.worker`
|
||||
- Makefile - 本地构建、文档生成、迁移命令,见 `Makefile`
|
||||
|
||||
## Runtime
|
||||
|
||||
**Environment:**
|
||||
- Go runtime;API 服务基于 Fiber HTTP 服务器,见 `cmd/api/main.go`
|
||||
- 独立 Worker 进程处理异步任务与调度,见 `cmd/worker/main.go`
|
||||
- 生产容器基础镜像为 Alpine,构建镜像使用 Go 1.25.6 Alpine,见 `Dockerfile.api`、`Dockerfile.worker`
|
||||
|
||||
**Package Manager:**
|
||||
- Go Modules,见 `go.mod`
|
||||
- Lockfile: `go.sum` present
|
||||
|
||||
## Frameworks
|
||||
|
||||
**Core:**
|
||||
- Fiber v2.52.9 - HTTP 框架与路由宿主,见 `go.mod`、`cmd/api/main.go`
|
||||
- GORM v1.31.1 + `gorm.io/driver/postgres` v1.6.0 - PostgreSQL ORM 与连接层,见 `go.mod`、`pkg/database/postgres.go`
|
||||
- Viper v1.21.0 - 嵌入式默认配置 + 环境变量覆盖,见 `go.mod`、`pkg/config/loader.go`
|
||||
|
||||
**Queue / Scheduling:**
|
||||
- Asynq v0.25.1 - 任务提交、Worker 消费、定时任务,见 `go.mod`、`pkg/queue/client.go`、`pkg/queue/server.go`、`cmd/worker/main.go`
|
||||
|
||||
**Serialization / Validation:**
|
||||
- sonic v1.14.2 - Fiber JSON 编解码与任务载荷序列化,见 `go.mod`、`cmd/api/main.go`、`pkg/queue/client.go`
|
||||
- validator/v10 v10.28.0 - DTO 校验依赖,见 `go.mod`
|
||||
|
||||
**Logging:**
|
||||
- zap v1.27.1 - 应用日志与 SQL 日志,见 `go.mod`、`pkg/logger/logger.go`、`pkg/database/postgres.go`
|
||||
- lumberjack.v2 v2.2.1 - 日志轮转,见 `go.mod`、`pkg/logger/logger.go`
|
||||
|
||||
**Docs tooling:**
|
||||
- swaggest/openapi-go v0.2.60 - OpenAPI 3.0.3 文档生成,见 `go.mod`、`pkg/openapi/generator.go`
|
||||
|
||||
## Build / Dev / Deploy
|
||||
|
||||
**Build:**
|
||||
- `Makefile` 定义 `build`、`run`、`run-worker`、`docs`、`migrate-*` 目标,见 `Makefile`
|
||||
- API 与 Worker 使用多阶段 Docker 构建,编译产物分别来自 `./cmd/api` 与 `./cmd/worker`,见 `Dockerfile.api`、`Dockerfile.worker`
|
||||
|
||||
**Deploy:**
|
||||
- 生产编排使用 Docker Compose,运行 `api` 与 `worker` 两个服务,见 `docker-compose.prod.yml`
|
||||
- Gitea Actions 工作流构建镜像、推送私有镜像仓库并在主分支执行 `docker compose up -d`,见 `.gitea/workflows/deploy.yaml`
|
||||
|
||||
**Docs generation:**
|
||||
- API 启动时会生成 `logs/openapi.yaml`,见 `cmd/api/main.go`、`cmd/api/docs.go`
|
||||
- 手动文档命令 `make docs` 运行 `cmd/gendocs/main.go`,输出 `docs/admin-openapi.yaml`,见 `Makefile`、`cmd/gendocs/main.go`
|
||||
|
||||
## Storage, Data, Messaging
|
||||
|
||||
**Database:**
|
||||
- PostgreSQL 是唯一检测到的主数据库;连接、连接池、GORM logger、Ping 校验位于 `pkg/database/postgres.go`
|
||||
|
||||
**Cache / KV:**
|
||||
- Redis 用于认证、缓存、队列后端、限流存储和配置缓存,见 `pkg/database/redis.go`、`cmd/api/main.go`、`internal/service/wechat_config/service.go`
|
||||
|
||||
**Messaging / Queue:**
|
||||
- Asynq 直接复用 Redis 连接配置作为消息后端,见 `pkg/queue/client.go`、`pkg/queue/server.go`
|
||||
- Worker 内注册订单超时、告警检查、数据清理等调度任务,见 `cmd/worker/main.go`
|
||||
|
||||
**Object storage:**
|
||||
- S3 兼容对象存储通过 AWS SDK v1 实现,支持上传、下载、预签名 URL、临时文件下载,见 `pkg/storage/s3.go`、`pkg/storage/service.go`
|
||||
|
||||
## Configuration
|
||||
|
||||
**Loading model:**
|
||||
- 默认配置从嵌入文件 `pkg/config/defaults/config.yaml` 读取,见 `pkg/config/embedded.go`
|
||||
- 运行时使用 `JUNHONG_` 前缀环境变量覆盖,见 `pkg/config/loader.go`
|
||||
- 必填配置校验包括数据库、Redis、JWT,见 `pkg/config/config.go`
|
||||
|
||||
**Key config areas:**
|
||||
- 服务与超时:`server.*`,见 `pkg/config/config.go`
|
||||
- PostgreSQL:`database.*`,见 `pkg/config/config.go`
|
||||
- Redis:`redis.*`,见 `pkg/config/config.go`
|
||||
- Asynq:`queue.*`,见 `pkg/config/config.go`
|
||||
- 日志:`logging.*`,见 `pkg/config/config.go`
|
||||
- 短信:`sms.*`,见 `pkg/config/config.go`
|
||||
- 对象存储:`storage.*`,见 `pkg/config/config.go`
|
||||
- 外部 Gateway:`gateway.*`,见 `pkg/config/config.go`
|
||||
|
||||
**WeChat config exception:**
|
||||
- 微信公众号/小程序/支付配置不走 Viper 主配置,业务侧从数据库表 `tb_wechat_config` 读取并缓存到 Redis,见 `internal/model/wechat_config.go`、`internal/service/wechat_config/service.go`、`pkg/wechat/config.go`
|
||||
|
||||
## Logging & Observability
|
||||
|
||||
**Application logs:**
|
||||
- `pkg/logger/logger.go` 初始化 app/access 双 logger;生产模式写 JSON,开发模式 app logger 同时输出控制台
|
||||
|
||||
**Access logs:**
|
||||
- `pkg/logger/middleware.go` 记录 method、path、query、status、duration、request_id、ip、user_agent、user_id、请求/响应 body
|
||||
|
||||
**SQL logs:**
|
||||
- `pkg/database/postgres.go` 自定义 GORM logger,记录错误、慢查询和普通 SQL trace
|
||||
|
||||
**Health checks:**
|
||||
- `Dockerfile.api` 与 `docker-compose.prod.yml` 均通过 `GET /health` 做容器健康检查
|
||||
|
||||
## Notable Dependencies
|
||||
|
||||
**HTTP / App:**
|
||||
- `github.com/gofiber/fiber/v2` - API 服务核心框架,见 `go.mod`、`cmd/api/main.go`
|
||||
- `github.com/google/uuid` - 请求 ID 生成,见 `cmd/api/main.go`
|
||||
|
||||
**Persistence / Cache:**
|
||||
- `gorm.io/gorm`、`gorm.io/driver/postgres` - ORM 与 PostgreSQL 驱动,见 `go.mod`、`pkg/database/postgres.go`
|
||||
- `github.com/redis/go-redis/v9` - Redis 客户端,见 `go.mod`、`pkg/database/redis.go`
|
||||
|
||||
**Queue / Async:**
|
||||
- `github.com/hibiken/asynq` - 队列与调度器,见 `go.mod`、`pkg/queue/*.go`、`cmd/worker/main.go`
|
||||
|
||||
**Serialization / File processing:**
|
||||
- `github.com/bytedance/sonic` - JSON 编解码,见 `cmd/api/main.go`、`pkg/queue/client.go`
|
||||
- `github.com/xuri/excelize/v2` - Excel 导入处理依赖,见 `go.mod`; 任务消费方在 `internal/task/iot_card_import.go`、`internal/task/device_import.go` 调用 Excel 解析
|
||||
|
||||
**External SDKs:**
|
||||
- `github.com/ArtisanCloud/PowerWeChat/v3` - 微信公众号与微信支付 SDK,见 `go.mod`、`pkg/wechat/official_account.go`、`pkg/wechat/payment.go`
|
||||
- `github.com/aws/aws-sdk-go` - S3 兼容对象存储客户端,见 `go.mod`、`pkg/storage/s3.go`
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
**Development:**
|
||||
- 需要 PostgreSQL、Redis、JWT 密钥与 `JUNHONG_` 环境变量;`config.Load()` 在启动时强校验,见 `pkg/config/config.go`
|
||||
- `make docs` 需要 Go 环境;`migrate-*` 目标需要系统安装 `migrate` 命令,见 `Makefile`
|
||||
|
||||
**Production:**
|
||||
- 目标运行形态是 Docker 化 API + Worker 双进程部署,见 `Dockerfile.api`、`Dockerfile.worker`、`docker-compose.prod.yml`
|
||||
- 镜像发布到私有仓库 `registry.boss160.cn`,见 `.gitea/workflows/deploy.yaml`
|
||||
|
||||
## Evidence
|
||||
|
||||
- `go.mod`
|
||||
- `README.md`
|
||||
- `cmd/api/main.go`
|
||||
- `cmd/api/docs.go`
|
||||
- `cmd/worker/main.go`
|
||||
- `cmd/gendocs/main.go`
|
||||
- `pkg/config/config.go`
|
||||
- `pkg/config/loader.go`
|
||||
- `pkg/config/embedded.go`
|
||||
- `pkg/config/defaults/config.yaml`
|
||||
- `pkg/database/postgres.go`
|
||||
- `pkg/database/redis.go`
|
||||
- `pkg/queue/client.go`
|
||||
- `pkg/queue/server.go`
|
||||
- `pkg/queue/handler.go`
|
||||
- `pkg/logger/logger.go`
|
||||
- `pkg/logger/middleware.go`
|
||||
- `pkg/openapi/generator.go`
|
||||
- `pkg/openapi/handlers.go`
|
||||
- `pkg/storage/s3.go`
|
||||
- `pkg/storage/service.go`
|
||||
- `pkg/wechat/config.go`
|
||||
- `pkg/wechat/official_account.go`
|
||||
- `pkg/wechat/payment.go`
|
||||
- `pkg/sms/client.go`
|
||||
- `internal/gateway/client.go`
|
||||
- `Makefile`
|
||||
- `Dockerfile.api`
|
||||
- `Dockerfile.worker`
|
||||
- `docker-compose.prod.yml`
|
||||
- `.gitea/workflows/deploy.yaml`
|
||||
|
||||
---
|
||||
|
||||
*Stack analysis: 2026-03-27*
|
||||
250
.planning/codebase/STRUCTURE.md
Normal file
250
.planning/codebase/STRUCTURE.md
Normal file
@@ -0,0 +1,250 @@
|
||||
# Codebase Structure
|
||||
|
||||
**Analysis Date:** 2026-03-27
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```text
|
||||
junhong_cmp_fiber/
|
||||
├── cmd/ # API、Worker、文档生成入口
|
||||
├── internal/ # 核心业务代码(handler/service/store/model/routes/bootstrap/task)
|
||||
├── pkg/ # 可复用基础设施与跨模块工具
|
||||
├── migrations/ # SQL 迁移脚本
|
||||
├── docs/ # 业务文档、接入指南、架构说明
|
||||
├── openspec/ # 当前能力规格与变更档案
|
||||
├── specs/ # 早期 Speckit 规格文档
|
||||
├── scripts/ # 检查与初始化脚本
|
||||
├── docker/ # 部署相关辅助文件
|
||||
├── logs/ # 运行期日志输出目录
|
||||
└── .planning/codebase/ # 当前代码地图产物
|
||||
```
|
||||
|
||||
## Directory Purposes
|
||||
|
||||
**`cmd/`:**
|
||||
- Purpose: 进程级入口和独立命令。
|
||||
- Contains: `cmd/api/main.go`, `cmd/api/docs.go`, `cmd/worker/main.go`, `cmd/gendocs/main.go`
|
||||
- Key files: `cmd/api/main.go`, `cmd/worker/main.go`
|
||||
|
||||
**`internal/bootstrap/`:**
|
||||
- Purpose: 依赖装配中心。
|
||||
- Contains: API/Worker 的 stores、services、handlers、middlewares 初始化。
|
||||
- Key files: `internal/bootstrap/bootstrap.go`, `internal/bootstrap/services.go`, `internal/bootstrap/worker.go`, `internal/bootstrap/types.go`
|
||||
|
||||
**`internal/routes/`:**
|
||||
- Purpose: 路由域划分与 OpenAPI 绑定。
|
||||
- Contains: 域入口 `admin.go`, `auth.go`, `personal.go` 以及各模块路由文件。
|
||||
- Key files: `internal/routes/routes.go`, `internal/routes/registry.go`, `internal/routes/admin.go`, `internal/routes/personal.go`
|
||||
|
||||
**`internal/handler/`:**
|
||||
- Purpose: HTTP Handler 层。
|
||||
- Contains: `admin/`、`app/`、`auth/`、`callback/` 四个子域。
|
||||
- Key files: `internal/handler/admin/account.go`, `internal/handler/auth/handler.go`, `internal/handler/callback/payment.go`
|
||||
|
||||
**`internal/service/`:**
|
||||
- Purpose: 业务逻辑层。
|
||||
- Contains: 按业务模块拆分的子目录,每个模块通常有一个 `service.go`。
|
||||
- Key files: `internal/service/account/service.go`, `internal/service/order/service.go`, `internal/service/package/service.go`, `internal/service/polling/*.go`
|
||||
|
||||
**`internal/store/`:**
|
||||
- Purpose: 数据访问层。
|
||||
- Contains: 基础抽象 `store.go`、查询选项 `options.go`、以及 `postgres/` 下的具体实现。
|
||||
- Key files: `internal/store/store.go`, `internal/store/options.go`, `internal/store/postgres/account_store.go`, `internal/store/postgres/order_store.go`
|
||||
|
||||
**`internal/model/`:**
|
||||
- Purpose: 持久化模型与 DTO。
|
||||
- Contains: 业务实体模型、基础模型、DTO 子目录。
|
||||
- Key files: `internal/model/account.go`, `internal/model/order.go`, `internal/model/shop.go`, `internal/model/dto/account_dto.go`
|
||||
|
||||
**`internal/task/`:**
|
||||
- Purpose: Asynq 任务处理逻辑。
|
||||
- Contains: 轮询、导入、订单超时、邮件、佣金等任务处理器。
|
||||
- Key files: `internal/task/polling_handler.go`, `internal/task/order_expire.go`, `internal/task/device_import.go`
|
||||
|
||||
**`internal/polling/`:**
|
||||
- Purpose: 轮询调度器与专用运行时组件。
|
||||
- Contains: `Scheduler`、激活处理器、流量重置处理器。
|
||||
- Key files: `internal/polling/scheduler.go`
|
||||
|
||||
**`internal/gateway/`:**
|
||||
- Purpose: 对外 Gateway HTTP 客户端封装。
|
||||
- Contains: 请求签名、设备/卡查询与操作客户端。
|
||||
- Key files: `internal/gateway/client.go`(由 `cmd/api/main.go`、`cmd/worker/main.go` 初始化)
|
||||
|
||||
**`pkg/`:**
|
||||
- Purpose: 通用基础设施。
|
||||
- Contains: 配置、数据库、日志、认证、错误、响应、OpenAPI、队列、对象存储、微信、短信、中间件辅助。
|
||||
- Key files: `pkg/config/config.go`, `pkg/config/loader.go`, `pkg/database/postgres.go`, `pkg/middleware/auth.go`, `pkg/middleware/data_scope.go`, `pkg/openapi/generator.go`
|
||||
|
||||
**`migrations/`:**
|
||||
- Purpose: 数据库结构演进。
|
||||
- Contains: 成对的 `*.up.sql` / `*.down.sql` 与少量补数 SQL。
|
||||
- Key files: `migrations/000000_create_legacy_tables.up.sql`, `migrations/000055_package_system_upgrade.up.sql`, `migrations/000088_rename_card_wallet_id_to_asset_wallet_id.up.sql`
|
||||
|
||||
**`docs/`:**
|
||||
- Purpose: 面向开发和业务的说明文档。
|
||||
- Contains: 功能总结、架构说明、接入指南、运维说明、第三方资料。
|
||||
- Key files: `docs/api-documentation-guide.md`, `docs/auth-architecture.md`, `docs/polling-system/README.md`
|
||||
|
||||
**`openspec/`:**
|
||||
- Purpose: 规范驱动开发资产。
|
||||
- Contains: `config.yaml`、当前 specs、历史归档变更。
|
||||
- Key files: `openspec/config.yaml`, `openspec/specs/**/spec.md`, `openspec/changes/archive/**`
|
||||
|
||||
## Key File Locations
|
||||
|
||||
**Entry Points:**
|
||||
- `cmd/api/main.go`: API 服务主入口。
|
||||
- `cmd/worker/main.go`: Worker 服务主入口。
|
||||
- `cmd/gendocs/main.go`: 离线 OpenAPI 生成命令。
|
||||
|
||||
**Configuration:**
|
||||
- `pkg/config/config.go`: 配置结构与校验规则。
|
||||
- `pkg/config/loader.go`: 嵌入配置 + 环境变量覆盖加载。
|
||||
- `openspec/config.yaml`: 规范生成器上下文与规则。
|
||||
|
||||
**Core Logic:**
|
||||
- `internal/service/`: 所有业务服务主实现。
|
||||
- `internal/store/postgres/`: 所有 PostgreSQL 访问实现。
|
||||
- `internal/bootstrap/`: 依赖注入和装配。
|
||||
- `internal/polling/scheduler.go`: 轮询核心调度逻辑。
|
||||
|
||||
**API Surface:**
|
||||
- `internal/routes/`: 路由分组与注册。
|
||||
- `internal/handler/admin/`, `internal/handler/app/`, `internal/handler/auth/`, `internal/handler/callback/`: 传输层处理器。
|
||||
|
||||
**Data Definitions:**
|
||||
- `internal/model/*.go`: 表模型。
|
||||
- `internal/model/dto/*.go`: 请求/响应 DTO。
|
||||
|
||||
**Documentation / Planning:**
|
||||
- `docs/`: 功能文档。
|
||||
- `openspec/specs/`: 现行规格。
|
||||
- `openspec/changes/archive/`: 历史变更记录。
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
**Files:**
|
||||
- 模块按业务名命名:`internal/service/account/service.go`, `internal/handler/admin/account.go`
|
||||
- 大部分 Store 文件采用 `{resource}_store.go`:`internal/store/postgres/account_store.go`
|
||||
- DTO 文件采用 `{module}_dto.go`:`internal/model/dto/account_dto.go`
|
||||
- 路由文件按模块命名:`internal/routes/account.go`, `internal/routes/polling_config.go`
|
||||
|
||||
**Directories:**
|
||||
- 分层目录使用小写单数语义:`internal/service/`, `internal/store/`, `internal/model/`
|
||||
- 传输域用子目录表达边界:`internal/handler/admin/`, `internal/handler/app/`, `internal/handler/callback/`
|
||||
- 规格目录按能力名组织:`openspec/specs/account-management/`, `openspec/specs/order-payment/`
|
||||
|
||||
## Ownership / Responsibility Guide
|
||||
|
||||
**如果改接口路径或加新 API:**
|
||||
- 先看 `internal/routes/registry.go` 与对应模块路由文件。
|
||||
- 再同步检查 `docs/api-documentation-guide.md`、`cmd/api/docs.go`、`cmd/gendocs/main.go`、`pkg/openapi/handlers.go`。
|
||||
|
||||
**如果改业务规则:**
|
||||
- 主要落点在 `internal/service/{module}/service.go`。
|
||||
- 如涉及跨模块,先看 `internal/bootstrap/services.go` 中该服务依赖谁。
|
||||
|
||||
**如果改查询或权限过滤:**
|
||||
- 主要落点在 `internal/store/postgres/*.go`。
|
||||
- 数据范围辅助逻辑集中在 `pkg/middleware/data_scope.go` 与 `pkg/middleware/permission_helper.go`。
|
||||
|
||||
**如果改表结构或模型:**
|
||||
- SQL 先改 `migrations/`。
|
||||
- Go 模型同步改 `internal/model/*.go`。
|
||||
- DTO 变更再改 `internal/model/dto/*.go`。
|
||||
|
||||
**如果改异步任务:**
|
||||
- 任务实现放 `internal/task/*.go`。
|
||||
- 注册入口在 `pkg/queue/handler.go`。
|
||||
- Worker 依赖拼装在 `internal/bootstrap/worker*.go`。
|
||||
|
||||
**如果改轮询系统:**
|
||||
- 调度逻辑看 `internal/polling/scheduler.go`。
|
||||
- 执行逻辑看 `internal/task/polling_handler.go`。
|
||||
- 配置/监控/清理相关服务看 `internal/service/polling/*.go`。
|
||||
|
||||
## Where to Add New Code
|
||||
|
||||
**New Admin API Feature:**
|
||||
- Primary code: `internal/handler/admin/{module}.go`, `internal/service/{module}/service.go`, `internal/store/postgres/{module}_store.go`, `internal/routes/{module}.go`
|
||||
- Models / DTOs: `internal/model/{module}.go`, `internal/model/dto/{module}_dto.go`
|
||||
- Wiring: `internal/bootstrap/services.go`, `internal/bootstrap/handlers.go`, `internal/bootstrap/types.go`, `internal/routes/admin.go`
|
||||
|
||||
**New Personal Client Feature:**
|
||||
- Primary code: `internal/handler/app/{feature}.go`, `internal/service/{feature}/service.go` 或复用现有服务
|
||||
- Routes: `internal/routes/personal.go`
|
||||
- Auth-sensitive DTOs: `internal/model/dto/` 下新增对应 DTO 文件
|
||||
|
||||
**New Worker Task:**
|
||||
- Implementation: `internal/task/{task_name}.go`
|
||||
- Registration: `pkg/queue/handler.go`
|
||||
- Dependencies: `internal/bootstrap/worker_services.go` 或 `internal/bootstrap/worker_stores.go`
|
||||
- Constants: `pkg/constants/` 中对应任务类型定义
|
||||
|
||||
**New Shared Utility:**
|
||||
- Shared helpers: `pkg/{subsystem}/`
|
||||
- 仅限跨多个业务模块复用的基础能力,不要把业务逻辑塞进 `pkg/`
|
||||
|
||||
**New Business Spec / Plan Artifact:**
|
||||
- Current capability spec: `openspec/specs/{capability}/spec.md`
|
||||
- Historical change proposal: `openspec/changes/...`
|
||||
- Developer-facing long-form doc: `docs/{feature-id}/` 或现有专题目录
|
||||
|
||||
## Practical Navigation Guide
|
||||
|
||||
**从请求入口追代码:**
|
||||
1. 在 `internal/routes/*.go` 找路径。
|
||||
2. 跳到对应 `internal/handler/...` 方法。
|
||||
3. 看该 Handler 注入了哪个 Service。
|
||||
4. 到 `internal/service/...` 看业务编排。
|
||||
5. 再进入 `internal/store/postgres/...` 看 SQL 过滤与分页。
|
||||
6. 最后回到 `internal/model/` / `internal/model/dto/` 看字段定义。
|
||||
|
||||
**从一个模块反查装配方式:**
|
||||
1. 先找 `internal/bootstrap/services.go` 是否实例化该 Service。
|
||||
2. 再看 `internal/bootstrap/handlers.go` 是否把它交给 Handler。
|
||||
3. 再看 `internal/routes/admin.go` 或 `internal/routes/personal.go` 是否挂载了该模块。
|
||||
|
||||
**从一个异步任务反查来源:**
|
||||
1. 看 `pkg/queue/handler.go` 的 `registerXxxHandler()`。
|
||||
2. 跳到 `internal/task/{task}.go`。
|
||||
3. 若任务由调度器触发,再看 `internal/polling/scheduler.go` 或 `cmd/worker/main.go` 的 Asynq Scheduler 注册。
|
||||
|
||||
## Special Directories
|
||||
|
||||
**`logs/`:**
|
||||
- Purpose: 运行日志输出目录。
|
||||
- Generated: Yes
|
||||
- Committed: No(运行期产物)
|
||||
|
||||
**`migrations/`:**
|
||||
- Purpose: 数据库迁移脚本。
|
||||
- Generated: No
|
||||
- Committed: Yes
|
||||
|
||||
**`docs/第三方文档/`:**
|
||||
- Purpose: 外部供应商或接口资料归档。
|
||||
- Generated: No
|
||||
- Committed: Yes
|
||||
|
||||
**`openspec/changes/archive/`:**
|
||||
- Purpose: 已归档历史变更。
|
||||
- Generated: No
|
||||
- Committed: Yes
|
||||
|
||||
**`.planning/codebase/`:**
|
||||
- Purpose: 供规划/执行命令消费的代码地图文档。
|
||||
- Generated: Yes
|
||||
- Committed: Yes
|
||||
|
||||
## Structural Notes for Contributors
|
||||
|
||||
- 这个仓库的“物理结构”严格围绕 Go 后端单体展开;新增代码优先放进现有层级,不要新造平行架构。
|
||||
- `internal/bootstrap/handlers.go` 当前会为客户端能力额外 new 一批 Store,这意味着某些接口不是纯 Service 注入;新增客户端功能前先确认是否要复用现有 `services` 聚合,还是遵循当前适配方式。
|
||||
- OpenAPI 不是完全自动发现;新增 Handler、Route 之后,必须同步维护 `internal/bootstrap/types.go`、`internal/bootstrap/handlers.go`、`internal/routes/*.go`、`cmd/api/docs.go`、`cmd/gendocs/main.go`、`pkg/openapi/handlers.go`。
|
||||
- 数据权限过滤不在统一 GORM 查询回调中自动生效;新增 Store 查询时,要主动选对 `ApplyShopFilter`、`ApplyEnterpriseFilter`、`ApplySellerShopFilter`、`ApplyOwnerShopFilter` 等函数。
|
||||
|
||||
---
|
||||
|
||||
*Structure analysis: 2026-03-27*
|
||||
182
.planning/codebase/TESTING.md
Normal file
182
.planning/codebase/TESTING.md
Normal file
@@ -0,0 +1,182 @@
|
||||
# Testing Patterns
|
||||
|
||||
**Analysis Date:** 2026-03-27
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Runner:**
|
||||
- 当前仓库未检测到有效的 Go 自动化测试文件:`glob("**/*_test.go")` 返回空,`glob("tests/**")` 返回空。
|
||||
- 因此,仓库当前状态下不存在可执行的项目内单元测试、集成测试或 E2E 测试代码。
|
||||
|
||||
**Assertion Library:**
|
||||
- 未检测到;因为当前仓库没有 `*_test.go` 文件。
|
||||
|
||||
**Configured / Documented commands:**
|
||||
```bash
|
||||
go test ./... # `README.md` 文档命令;当前仓库没有测试文件
|
||||
go test -cover ./... # `README.md` 文档命令;覆盖率描述与仓库现状不符
|
||||
go test -v ./... # `Makefile` 的 `test` 目标与 `README.md` 一致
|
||||
go test ./pkg/... # `README.md` 文档命令;当前没有对应测试文件
|
||||
go test ./tests/integration/... # `README.md` 文档命令;当前仓库不存在 `tests/` 目录内容
|
||||
go test -v ./internal/middleware -run TestKeyAuth # `README.md` 文档命令;当前未检测到对应测试
|
||||
```
|
||||
|
||||
## Policy Reality
|
||||
|
||||
**Current project rule (authoritative):**
|
||||
- `AGENTS.md` 明确规定:本项目不使用任何形式的自动化测试代码。
|
||||
- `AGENTS.md` 明确禁止单元测试、集成测试、验收测试、流程测试、E2E 测试,以及创建 `*_test.go` 文件;唯一例外是用户明确要求“请写测试”。
|
||||
|
||||
**Repository reality:**
|
||||
- 当前仓库与该规则一致:未发现任何 `*_test.go` 文件,也未发现 `tests/` 目录内容。
|
||||
|
||||
**Stale documentation conflicts:**
|
||||
- `README.md` 仍包含完整“测试”章节,列出 `go test ./...`、覆盖率、集成测试、`tests/integration/`、以及 `docs/testing/test-connection-guide.md` 的说明。
|
||||
- `README.md` 的项目结构段仍展示 `tests/integration/auth_test.go`、`tests/integration/ratelimit_test.go`。
|
||||
- `README.md` 的 “Speckit / 宪章” 段仍写有 “70%+ 测试覆盖率,核心业务 90%+”。
|
||||
- 这些内容与 `AGENTS.md` 的现行禁令、以及仓库中真实文件状态冲突。后续执行应以 `AGENTS.md` + 实际文件现状为准。
|
||||
|
||||
## Verification Approach Actually Used
|
||||
|
||||
**Primary approach:**
|
||||
- 人工验证 + 脚本检查 + 文档生成检查 + 生产日志/监控。
|
||||
|
||||
**Evidence from policy:**
|
||||
- `AGENTS.md` 将替代方案明确写为:
|
||||
- 使用 PostgreSQL MCP 工具手动验证数据
|
||||
- 使用 Postman/curl 手动测试 API
|
||||
- 依赖生产环境日志和监控发现问题
|
||||
|
||||
**Repository scripts that support this model:**
|
||||
- `scripts/check-service-errors.sh`:静态扫描 Service 层错误处理规则。
|
||||
- `scripts/check-comment-paths.sh`:静态扫描 Handler 注释中的 API 路径是否过时。
|
||||
- `scripts/check-all.sh`:聚合上述脚本。
|
||||
- `scripts/verify_migration/main.go`:直接连接数据库查询表和字段,验证迁移结果;这是一种手工校验辅助程序,不是自动测试框架。
|
||||
- `cmd/gendocs/main.go` / `make docs`:通过重新生成 OpenAPI 文档来验证路由注册和文档接线是否完整。
|
||||
|
||||
## Test File Organization
|
||||
|
||||
**Location:**
|
||||
- 当前无自动化测试文件。
|
||||
|
||||
**Naming:**
|
||||
- 按 Go 常规应为 `*_test.go`,但本项目现行规则禁止新增,除非用户明确要求。
|
||||
|
||||
**Structure:**
|
||||
```text
|
||||
自动化测试目录:未检测到
|
||||
README 中声明的 `tests/integration/`:当前不存在实际文件
|
||||
```
|
||||
|
||||
## Manual Validation Patterns
|
||||
|
||||
**API / behavior validation:**
|
||||
- 使用 Postman、curl 或 Swagger/OpenAPI 文档进行接口人工验证,依据见 `AGENTS.md` 与 `docs/api-documentation-guide.md`。
|
||||
- 生成 OpenAPI 文档后检查路径是否出现:`go run cmd/gendocs/main.go`,再检查 `docs/admin-openapi.yaml`,依据见 `docs/api-documentation-guide.md`。
|
||||
|
||||
**Database validation:**
|
||||
- 涉及数据正确性时,使用 PostgreSQL MCP 工具做手动查询验证,依据见 `AGENTS.md` 的 `db-validation` 说明。
|
||||
- 涉及迁移结果时,可运行 `go run scripts/verify_migration/main.go` 检查表与字段;该脚本读取真实数据库 schema,不产出测试报告。
|
||||
|
||||
**Convention validation:**
|
||||
- 改动 Service 层错误处理后运行 `bash scripts/check-service-errors.sh`。
|
||||
- 改动 Handler 注释或路由后运行 `bash scripts/check-comment-paths.sh`。
|
||||
- 需要一次性检查时运行 `bash scripts/check-all.sh`。
|
||||
|
||||
**Documentation / OpenAPI validation:**
|
||||
- 新增 Handler 后必须同步更新 `cmd/api/docs.go` 与 `cmd/gendocs/main.go`,然后重新生成文档并检查目标路径,依据见 `docs/api-documentation-guide.md`。
|
||||
|
||||
## Mocking
|
||||
|
||||
**Framework:**
|
||||
- Not applicable;当前没有自动化测试代码。
|
||||
|
||||
**Patterns:**
|
||||
```text
|
||||
未检测到 mock 框架、测试替身、fixture helper 或 testutil 包的实际使用。
|
||||
```
|
||||
|
||||
**What to Mock:**
|
||||
- 当前仓库没有现行自动化测试实践可供复用。
|
||||
|
||||
**What NOT to Mock:**
|
||||
- 当前仓库没有现行自动化测试实践可供复用。
|
||||
|
||||
## Fixtures and Factories
|
||||
|
||||
**Test Data:**
|
||||
```text
|
||||
未检测到 fixture/factory 测试数据目录或 `_test.go` 中的构造模式。
|
||||
```
|
||||
|
||||
**Reality check:**
|
||||
- `README.md` 仍引用 `docs/testing/test-connection-guide.md` 和 `testutils.NewTestTransaction(...)` 示例,但当前仓库没有对应测试文件作为实际落点。
|
||||
|
||||
## Coverage
|
||||
|
||||
**Requirements:**
|
||||
- 当前现行项目规则不要求覆盖率,且原则上禁止自动化测试,依据见 `AGENTS.md`。
|
||||
|
||||
**Conflict to document clearly:**
|
||||
- `README.md` 和其中引用的 Speckit 宪章仍声称需要覆盖率目标;这与 `AGENTS.md` 的测试禁令冲突,且与当前“零测试文件”现状不符。
|
||||
|
||||
**View Coverage:**
|
||||
```bash
|
||||
go test -cover ./... # 仅为 README 中遗留命令,不代表现行流程
|
||||
```
|
||||
|
||||
## Test Types
|
||||
|
||||
**Unit Tests:**
|
||||
- 当前未使用。
|
||||
|
||||
**Integration Tests:**
|
||||
- 当前未使用。
|
||||
- `README.md` 里提到的 `tests/integration/` 是过期描述,不是当前仓库事实。
|
||||
|
||||
**E2E Tests:**
|
||||
- 当前未使用。
|
||||
|
||||
## CI Reality
|
||||
|
||||
**Actual workflow present:**
|
||||
- `.gitea/workflows/deploy.yaml` 只有检出、Docker 构建、推送镜像、部署到测试环境的步骤。
|
||||
|
||||
**What is not in CI:**
|
||||
- 未发现 `go test`。
|
||||
- 未发现 `bash scripts/check-all.sh`。
|
||||
- 未发现 `bash scripts/check-service-errors.sh`。
|
||||
- 未发现 `bash scripts/check-comment-paths.sh`。
|
||||
- 未发现独立的质量门或覆盖率上传步骤。
|
||||
|
||||
**Conflict to document clearly:**
|
||||
- `README.md` 写着“这些检查会在 CI/CD 流程中自动执行”,但 `.gitea/workflows/deploy.yaml` 中没有相应步骤。当前 CI 现实是“构建/部署优先,无显式质量校验门”。
|
||||
|
||||
## Common Verification Commands
|
||||
|
||||
```bash
|
||||
bash scripts/check-service-errors.sh # 校验 Service 错误处理规则
|
||||
bash scripts/check-comment-paths.sh # 校验 Handler 注释路径是否过时
|
||||
bash scripts/check-all.sh # 运行当前已存在的全部静态脚本检查
|
||||
go run cmd/gendocs/main.go # 重新生成 OpenAPI 文档,验证路由接线
|
||||
make docs # 等价于生成 OpenAPI 文档
|
||||
go run scripts/verify_migration/main.go # 人工验证数据库迁移结果
|
||||
```
|
||||
|
||||
## Current Gaps and Caveats
|
||||
|
||||
- `Makefile` 仍保留 `test:` 目标并执行 `go test -v ./...`,这属于历史遗留入口,不应被误解为现行团队要求。
|
||||
- `README.md` 的测试章节、目录结构和覆盖率目标明显滞后于当前 `AGENTS.md` 规则。
|
||||
- `scripts/check-service-errors.sh` 虽然存在,但当前仓库 `internal/service/polling/alert_service.go` 仍有 `fmt.Errorf(...)` 违规点,说明现有脚本检查并未通过 CI 强制执行。
|
||||
- `scripts/verify_migration/main.go` 内含直接数据库连接字符串,反映的是临时人工验证脚本,而不是可复用、无环境依赖的测试体系。
|
||||
|
||||
## Prescriptive Summary
|
||||
|
||||
- 讨论“测试”时,先区分三件事:`README.md` 的历史文档、`AGENTS.md` 的现行规则、仓库当前真实文件状态。
|
||||
- 在当前仓库中,默认不要新增 `*_test.go`;只有用户明确要求时才编写测试代码。
|
||||
- 日常验证优先使用:静态检查脚本、OpenAPI 文档再生成、数据库手查、Postman/curl、人肉回归。
|
||||
- 判断 CI 是否有质量门时,以 `.gitea/workflows/deploy.yaml` 为准,而不是 `README.md` 的说明。
|
||||
|
||||
---
|
||||
|
||||
*Testing analysis: 2026-03-27*
|
||||
36
.planning/config.json
Normal file
36
.planning/config.json
Normal file
@@ -0,0 +1,36 @@
|
||||
{
|
||||
"model_profile": "balanced",
|
||||
"commit_docs": true,
|
||||
"parallelization": true,
|
||||
"search_gitignored": false,
|
||||
"brave_search": false,
|
||||
"firecrawl": false,
|
||||
"exa_search": false,
|
||||
"git": {
|
||||
"branching_strategy": "none",
|
||||
"phase_branch_template": "gsd/phase-{phase}-{slug}",
|
||||
"milestone_branch_template": "gsd/{milestone}-{slug}",
|
||||
"quick_branch_template": null
|
||||
},
|
||||
"workflow": {
|
||||
"research": true,
|
||||
"plan_check": true,
|
||||
"verifier": true,
|
||||
"nyquist_validation": true,
|
||||
"auto_advance": false,
|
||||
"node_repair": true,
|
||||
"node_repair_budget": 2,
|
||||
"ui_phase": false,
|
||||
"ui_safety_gate": false,
|
||||
"text_mode": false,
|
||||
"research_before_questions": false,
|
||||
"discuss_mode": "discuss",
|
||||
"skip_discuss": false,
|
||||
"_auto_chain_active": false
|
||||
},
|
||||
"hooks": {
|
||||
"context_warnings": true
|
||||
},
|
||||
"agent_skills": {},
|
||||
"resolve_model_ids": "omit"
|
||||
}
|
||||
434
.planning/phases/02-perm-fin-realname-poll/02-01-PLAN.md
Normal file
434
.planning/phases/02-perm-fin-realname-poll/02-01-PLAN.md
Normal file
@@ -0,0 +1,434 @@
|
||||
---
|
||||
phase: 02-perm-fin-realname-poll
|
||||
plan: 01
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- internal/handler/admin/asset.go
|
||||
- internal/handler/admin/commission_withdrawal.go
|
||||
- internal/service/commission_withdrawal/service.go
|
||||
- internal/service/commission_withdrawal_setting/service.go
|
||||
- internal/store/postgres/wechat_config_store.go
|
||||
- internal/polling/scheduler.go
|
||||
- internal/task/polling_handler.go
|
||||
- internal/service/iot_card/stop_resume_service.go
|
||||
- internal/service/asset/service.go
|
||||
- internal/store/postgres/package_store.go
|
||||
- internal/service/package_series/service.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- PERM-01
|
||||
- PERM-02
|
||||
- FIN-01
|
||||
- FIN-02
|
||||
- FIN-03
|
||||
- POLL-01
|
||||
- POLL-02
|
||||
- POLL-03
|
||||
- POLL-04
|
||||
must_haves:
|
||||
truths:
|
||||
- "企业账号可调用 Resolve 接口查询被授权资产,不再被 403 拦截"
|
||||
- "企业账号调用 Refresh 接口时返回 403 业务错误"
|
||||
- "提现审批通过后,approved_by 和 approved_at 字段正确填充"
|
||||
- "拒绝提现时 remark 为空返回参数校验错误,申请状态不变"
|
||||
- "同时激活两个提现配置时,行锁生效,任何时刻最多一条 is_active=true"
|
||||
- "polling:protect 任务进入调度器,日志可见 protect 队列被处理"
|
||||
- "stopCardWithRetry/resumeCardWithRetry 在 gatewayClient==nil 时返回业务错误而非 nil"
|
||||
- "GetPackages 接口响应中包含 page/page_size/total 分页字段,默认 page=1 pageSize=50"
|
||||
- "删除有关联套餐的 PackageSeries 时返回错误提示而非成功"
|
||||
artifacts:
|
||||
- path: "internal/handler/admin/asset.go"
|
||||
provides: "PERM-01 删除 Resolve 企业拦截;PERM-02 新增 Refresh 企业拦截"
|
||||
- path: "internal/service/commission_withdrawal/service.go"
|
||||
provides: "FIN-01 Approve 补 approved_by/approved_at"
|
||||
- path: "internal/handler/admin/commission_withdrawal.go"
|
||||
provides: "FIN-02 Reject 补 validator.Validate"
|
||||
- path: "internal/service/commission_withdrawal_setting/service.go"
|
||||
provides: "FIN-03 激活配置前加 FOR UPDATE 行锁"
|
||||
- path: "internal/store/postgres/wechat_config_store.go"
|
||||
provides: "FIN-03 微信配置激活前加 FOR UPDATE 行锁"
|
||||
- path: "internal/polling/scheduler.go"
|
||||
provides: "POLL-01 补 polling:protect 调度"
|
||||
- path: "internal/service/iot_card/stop_resume_service.go"
|
||||
provides: "POLL-02 gatewayClient==nil 返回业务错误"
|
||||
- path: "internal/service/asset/service.go"
|
||||
provides: "POLL-03 GetPackages 支持分页"
|
||||
- path: "internal/service/package_series/service.go"
|
||||
provides: "POLL-04 Delete 前检查关联套餐"
|
||||
- path: "internal/store/postgres/package_store.go"
|
||||
provides: "POLL-04 新增 CountBySeriesID 方法"
|
||||
key_links:
|
||||
- from: "internal/handler/admin/asset.go:Resolve"
|
||||
to: "constants.UserTypeEnterprise"
|
||||
via: "middleware.GetUserTypeFromContext"
|
||||
pattern: "UserTypeEnterprise"
|
||||
- from: "internal/service/commission_withdrawal/service.go:Approve"
|
||||
to: "tb_commission_withdrawal_request.approved_by/approved_at"
|
||||
via: "updates map"
|
||||
pattern: "approved_by.*approved_at"
|
||||
- from: "internal/polling/scheduler.go"
|
||||
to: "constants.TaskTypePollingProtect"
|
||||
via: "processManualQueue+processTimedQueue"
|
||||
pattern: "TaskTypePollingProtect"
|
||||
---
|
||||
|
||||
<objective>
|
||||
修复 PERM(企业权限)+ FIN(财务流程)+ POLL(轮询小修)三个域共 9 个需求。
|
||||
|
||||
Purpose: 补全企业账号权限边界(可查不可刷新)、提现流程数据完整性(审批人/remark 必填/激活并发锁)、轮询调度和安全修复,全部为独立小修,无跨域依赖。
|
||||
Output: 9 个文件修改,每个独立 Bug 一个 commit。
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-perm-fin-realname-poll/02-CONTEXT.md
|
||||
|
||||
# 修复规格(核心参考)
|
||||
@.sisyphus/plans/修正业务-完整方案.md(方案 B 766-863 行,方案 C 864-944 行,方案 H 1612-1711 行)
|
||||
|
||||
<interfaces>
|
||||
<!-- 本 plan 执行时所需的关键接口,直接使用,无需探索 -->
|
||||
|
||||
**asset.go 当前 Resolve 企业拦截(第 40-44 行,PERM-01 要删除)**:
|
||||
```go
|
||||
func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType == constants.UserTypeEnterprise {
|
||||
return errors.New(errors.CodeForbidden, "企业账号暂不支持此接口")
|
||||
}
|
||||
// ...
|
||||
```
|
||||
|
||||
**asset.go Refresh(第 78 行附近,PERM-02 在此处新增企业拦截)**:
|
||||
```go
|
||||
func (h *AssetHandler) Refresh(c *fiber.Ctx) error {
|
||||
assetType := c.Params("asset_type")
|
||||
// 需在此处开头插入企业拦截
|
||||
```
|
||||
|
||||
**commission_withdrawal/service.go:Approve updates map(FIN-01)**:
|
||||
```go
|
||||
updates := map[string]interface{}{
|
||||
"status": constants.WithdrawalStatusApproved,
|
||||
"processor_id": currentUserID,
|
||||
"processed_at": now,
|
||||
"payment_type": req.PaymentType,
|
||||
"remark": req.Remark,
|
||||
// 需补充:
|
||||
// "approved_by": currentUserID,
|
||||
// "approved_at": now,
|
||||
```
|
||||
|
||||
**commission_withdrawal.go:Reject(FIN-02)**:
|
||||
```go
|
||||
// 当前仅有 BodyParser,缺少 Validate:
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
// 需补充 validator.Validate(&req)
|
||||
```
|
||||
|
||||
**scheduler.go 当前调度(第 243-250 行,POLL-01 新增 protect)**:
|
||||
```go
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingRealname)
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingCarddata)
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingPackage)
|
||||
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueueRealnameKey(), constants.TaskTypePollingRealname, now)
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueueCarddataKey(), constants.TaskTypePollingCarddata, now)
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueuePackageKey(), constants.TaskTypePollingPackage, now)
|
||||
// 需新增 TaskTypePollingProtect 两行
|
||||
```
|
||||
|
||||
**stop_resume_service.go:stopCardWithRetry(第 178 行,POLL-02)**:
|
||||
```go
|
||||
if s.gatewayClient == nil {
|
||||
s.logger.Warn("Gateway 客户端未配置,跳过调用运营商接口", zap.Uint("card_id", card.ID))
|
||||
return nil // ← 改为返回业务错误
|
||||
}
|
||||
```
|
||||
|
||||
**asset/service.go:GetPackages 当前签名(POLL-03 需改)**:
|
||||
```go
|
||||
func (s *Service) GetPackages(ctx context.Context, assetType string, id uint) ([]*dto.AssetPackageResponse, error)
|
||||
// 需新增 page, pageSize 参数,并在响应中返回分页信息
|
||||
```
|
||||
|
||||
**package_series/service.go:Delete(POLL-04 需在首行前添加检查)**:
|
||||
```go
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
// 需新增 CountBySeriesID 检查
|
||||
return s.packageSeriesStore.Delete(ctx, id)
|
||||
}
|
||||
```
|
||||
|
||||
**FIN-03 GORM v2 行锁写法(参考项目 clause 用法)**:
|
||||
```go
|
||||
import "gorm.io/gorm/clause"
|
||||
|
||||
tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("is_active = ?", true).
|
||||
First(¤t)
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: PERM + FIN 修复(权限/财务流程)</name>
|
||||
<files>
|
||||
internal/handler/admin/asset.go,
|
||||
internal/service/commission_withdrawal/service.go,
|
||||
internal/handler/admin/commission_withdrawal.go,
|
||||
internal/service/commission_withdrawal_setting/service.go,
|
||||
internal/store/postgres/wechat_config_store.go
|
||||
</files>
|
||||
<action>
|
||||
**PERM-01(per D-04)**:`internal/handler/admin/asset.go:Resolve`
|
||||
|
||||
删除第 40-44 行的企业拦截代码块(共 3 行):
|
||||
```go
|
||||
// 删除以下代码:
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType == constants.UserTypeEnterprise {
|
||||
return errors.New(errors.CodeForbidden, "企业账号暂不支持此接口")
|
||||
}
|
||||
```
|
||||
注意:若 `userType` 变量在删除后此函数不再使用,同步删除导入的 `constants` 包引用(如其他方法仍在用则保留)。
|
||||
|
||||
**PERM-02(per D-05)**:`internal/handler/admin/asset.go:Refresh`
|
||||
|
||||
在 `Refresh` 方法开头(`assetType := c.Params("asset_type")` 之前)插入企业拦截:
|
||||
```go
|
||||
// 企业账号只读,不允许主动触发运营商刷新
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType == constants.UserTypeEnterprise {
|
||||
return errors.New(errors.CodeForbidden, "企业账号无权主动刷新资产状态")
|
||||
}
|
||||
```
|
||||
|
||||
完成后 commit:`fix(perm): 修复企业账号资产权限边界 PERM-01/02`
|
||||
|
||||
**FIN-01(per D-07)**:`internal/service/commission_withdrawal/service.go:Approve`
|
||||
|
||||
在 `updates` map 中补充审批人字段(紧跟 `"processed_at": now` 之后):
|
||||
```go
|
||||
"approved_by": currentUserID,
|
||||
"approved_at": now,
|
||||
```
|
||||
|
||||
完成后 commit:`fix(fin): 补全提现审批人字段 FIN-01`
|
||||
|
||||
**FIN-02(per D-08)**:`internal/handler/admin/commission_withdrawal.go:Reject`
|
||||
|
||||
在 `BodyParser` 后补充参数验证:
|
||||
```go
|
||||
if err := validator.Validate(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
```
|
||||
`validator` 包引用参考同文件或同目录其他 Handler 的 import 写法。
|
||||
|
||||
完成后 commit:`fix(fin): 提现拒绝补 remark 必填校验 FIN-02`
|
||||
|
||||
**FIN-03(per D-09)**:`internal/service/commission_withdrawal_setting/service.go`
|
||||
|
||||
找到激活配置的事务逻辑(先全部置 false,再置目标为 true),在事务内第一步 UPDATE 前加行锁:
|
||||
```go
|
||||
// 先锁定当前活跃记录,防止并发激活
|
||||
var current model.CommissionWithdrawalSetting
|
||||
tx.Clauses(clause.Locking{Strength: "UPDATE"}).
|
||||
Where("is_active = ?", true).
|
||||
First(¤t) // 不管 err,只是获取锁
|
||||
|
||||
// 继续原有的 deactivate → activate 逻辑
|
||||
```
|
||||
同理修改 `internal/store/postgres/wechat_config_store.go` 微信配置激活逻辑(同一模式)。
|
||||
|
||||
需要导入:`"gorm.io/gorm/clause"`(检查项目是否已有此导入,有则直接用)。
|
||||
|
||||
完成后 commit:`fix(fin): 激活配置并发行锁保护 FIN-03`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./... 2>&1 | head -30</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- Resolve 方法中企业拦截代码块已删除,Refresh 方法开头已新增企业拦截
|
||||
- commission_withdrawal/service.go Approve updates map 包含 approved_by/approved_at
|
||||
- commission_withdrawal.go Reject 有 validator.Validate 调用
|
||||
- commission_withdrawal_setting/service.go 和 wechat_config_store.go 激活事务内有 clause.Locking
|
||||
- go build ./... 无编译错误
|
||||
- 已产出 3 个独立 commit(PERM、FIN-01/02、FIN-03)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: POLL 修复(轮询调度/安全/分页/系列删除保护)</name>
|
||||
<files>
|
||||
internal/polling/scheduler.go,
|
||||
internal/service/iot_card/stop_resume_service.go,
|
||||
internal/service/asset/service.go,
|
||||
internal/store/postgres/package_store.go,
|
||||
internal/service/package_series/service.go
|
||||
</files>
|
||||
<action>
|
||||
**POLL-01(per D-14)**:`internal/polling/scheduler.go`
|
||||
|
||||
在主调度循环第 243-250 行,紧跟现有 3 个 polling 类型之后新增 protect 调度:
|
||||
```go
|
||||
// 新增:保护期一致性检查调度
|
||||
s.processManualQueue(ctx, constants.TaskTypePollingProtect)
|
||||
s.processTimedQueue(ctx, constants.RedisPollingQueueProtectKey(), constants.TaskTypePollingProtect, now)
|
||||
```
|
||||
确认 `constants.RedisPollingQueueProtectKey()` 已在 `pkg/constants/redis.go:374` 定义(已存在)。
|
||||
如有 `initCardPolling()` 或类似初始化 protect 队列的函数不存在,参考 realname/carddata/package 的初始化方式补充。
|
||||
|
||||
完成后 commit:`fix(poll): polling:protect 接入调度器 POLL-01`
|
||||
|
||||
**POLL-02(per D-15)**:`internal/service/iot_card/stop_resume_service.go`
|
||||
|
||||
**两处都修改**:`stopCardWithRetry`(第 178 行)和 `resumeCardWithRetry`(第 211 行)
|
||||
|
||||
将 `return nil` 改为返回业务错误:
|
||||
```go
|
||||
// 修改前:
|
||||
if s.gatewayClient == nil {
|
||||
s.logger.Warn("Gateway 客户端未配置,跳过调用运营商接口", ...)
|
||||
return nil
|
||||
}
|
||||
|
||||
// 修改后:
|
||||
if s.gatewayClient == nil {
|
||||
return errors.New(errors.CodeInternalError, "Gateway 未配置,停复机操作不可用")
|
||||
}
|
||||
```
|
||||
删除原有 `s.logger.Warn` 日志行(不再是"跳过",而是直接拒绝)。
|
||||
|
||||
完成后 commit:`fix(poll): gatewayClient=nil 时停复机返回错误而非成功 POLL-02`
|
||||
|
||||
**POLL-03(per D-16)**:`internal/service/asset/service.go:GetPackages`
|
||||
|
||||
当前签名为 `GetPackages(ctx, assetType, id)` 返回 `([]*dto.AssetPackageResponse, error)`。
|
||||
|
||||
改写为支持分页:
|
||||
1. 函数签名改为 `GetPackages(ctx context.Context, assetType string, id uint, page, pageSize int) (*dto.AssetPackagesResult, error)`
|
||||
2. 在函数开头处理参数边界:page 默认 1,pageSize 默认 50,pageSize 最大 100
|
||||
3. 新建 `dto.AssetPackagesResult` 结构体(在 `internal/model/dto/asset_dto.go`):
|
||||
```go
|
||||
// AssetPackagesResult 套餐列表分页结果
|
||||
type AssetPackagesResult struct {
|
||||
Total int64 `json:"total" description:"总条数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"page_size" description:"每页条数"`
|
||||
Items []*AssetPackageResponse `json:"items" description:"套餐列表"`
|
||||
}
|
||||
```
|
||||
4. 在 DB 查询时添加 `.Count(&total).Offset((page-1)*pageSize).Limit(pageSize)`
|
||||
5. 更新 Handler 层 `asset.go:Packages` 从 Query 参数读取 page/pageSize(若 Query 参数为空使用默认值),Handler 调用改为传入分页参数
|
||||
|
||||
检查 `handler/app/client_asset.go` 中是否也调用了 `GetPackages`,若有则同步更新调用签名,传入默认分页参数(page=1, pageSize=50)。
|
||||
|
||||
完成后 commit:`fix(poll): GetPackages 接口新增分页 POLL-03`
|
||||
|
||||
**POLL-04(per D-17)**:`internal/service/package_series/service.go:Delete` + `internal/store/postgres/package_store.go`
|
||||
|
||||
第一步:在 `package_store.go` 中新增 `CountBySeriesID` 方法(参考同文件其他 Count 方法的写法):
|
||||
```go
|
||||
// CountBySeriesID 统计指定系列下的套餐数量(含软删除前的活跃记录)
|
||||
func (s *PackageStore) CountBySeriesID(ctx context.Context, seriesID uint) (int64, error) {
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).Model(&model.Package{}).
|
||||
Where("series_id = ? AND deleted_at IS NULL", seriesID).
|
||||
Count(&count).Error
|
||||
if err != nil {
|
||||
return 0, errors.Wrap(errors.CodeDatabaseError, err, "统计关联套餐失败")
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
```
|
||||
|
||||
第二步:在 `package_series/service.go:Delete` 中注入并调用 `packageStore.CountBySeriesID`:
|
||||
```go
|
||||
func (s *Service) Delete(ctx context.Context, id uint) error {
|
||||
// 前置检查:有关联套餐则拒绝删除
|
||||
count, err := s.packageStore.CountBySeriesID(ctx, id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return errors.New(errors.CodeInvalidParam, fmt.Sprintf("该系列下有 %d 个关联套餐,请先处理后再删除", count))
|
||||
}
|
||||
return s.packageSeriesStore.Delete(ctx, id)
|
||||
}
|
||||
```
|
||||
|
||||
需确认 `Service` 结构体中有 `packageStore` 字段(若无则注入)。
|
||||
|
||||
完成后 commit:`fix(poll): Series 删除前检查关联套餐 POLL-04`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./... 2>&1 | head -30</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- scheduler.go 在第 245-250 行区域新增了 protect 的 processManualQueue 和 processTimedQueue 调用
|
||||
- stop_resume_service.go 两处 gatewayClient==nil 均返回 errors.New 而非 nil
|
||||
- GetPackages 函数签名包含 page/pageSize 参数,响应结构体含 total/page/page_size/items
|
||||
- package_store.go 新增 CountBySeriesID 方法;package_series/service.go:Delete 首行检查关联套餐
|
||||
- go build ./... 无编译错误
|
||||
- 已产出 4 个独立 commit(POLL-01/02/03/04)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
所有 PERM + FIN + POLL 修复完成后执行最终验收:
|
||||
|
||||
```bash
|
||||
# 编译验证
|
||||
go build ./...
|
||||
|
||||
# 搜索验证:PERM
|
||||
grep -n "UserTypeEnterprise" internal/handler/admin/asset.go
|
||||
# 预期:Resolve 中无企业拦截;Refresh 中有企业拦截
|
||||
|
||||
# 搜索验证:FIN-01
|
||||
grep -n "approved_by\|approved_at" internal/service/commission_withdrawal/service.go
|
||||
# 预期:updates map 中包含两个字段
|
||||
|
||||
# 搜索验证:POLL-01
|
||||
grep -n "TaskTypePollingProtect\|RedisPollingQueueProtectKey" internal/polling/scheduler.go
|
||||
# 预期:出现两行新增调用
|
||||
|
||||
# 搜索验证:POLL-02
|
||||
grep -n "return nil" internal/service/iot_card/stop_resume_service.go
|
||||
# 预期:stopCardWithRetry 和 resumeCardWithRetry 中 gatewayClient==nil 分支不再有 return nil
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- 所有 9 个需求(PERM-01/02, FIN-01/02/03, POLL-01/02/03/04)均有对应代码变更
|
||||
- go build ./... 零错误
|
||||
- 每个独立 Bug 均有独立 commit(共 7 个 commit:PERM, FIN-01, FIN-02, FIN-03, POLL-01, POLL-02, POLL-03, POLL-04 → 实际按修复内容分组为 7 次提交)
|
||||
- Resolve 方法企业拦截已删除;Refresh 方法企业拦截已添加
|
||||
- commission_withdrawal:Approve 包含 approved_by/approved_at;Reject 包含 validator.Validate
|
||||
- 激活配置事务中包含 clause.Locking{Strength: "UPDATE"}
|
||||
- scheduler.go 包含 protect 的 processManualQueue+processTimedQueue 调用
|
||||
- 停复机函数 gatewayClient==nil 返回错误
|
||||
- GetPackages 签名含分页参数,响应含分页字段
|
||||
- 系列删除前有 CountBySeriesID 检查
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
完成后创建:`.planning/phases/02-perm-fin-realname-poll/02-01-SUMMARY.md`
|
||||
</output>
|
||||
148
.planning/phases/02-perm-fin-realname-poll/02-01-SUMMARY.md
Normal file
148
.planning/phases/02-perm-fin-realname-poll/02-01-SUMMARY.md
Normal file
@@ -0,0 +1,148 @@
|
||||
---
|
||||
phase: 02-perm-fin-realname-poll
|
||||
plan: "01"
|
||||
subsystem: perm-fin-poll
|
||||
tags:
|
||||
- permissions
|
||||
- finance
|
||||
- polling
|
||||
- enterprise
|
||||
- concurrency
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides:
|
||||
- PERM-01
|
||||
- PERM-02
|
||||
- FIN-01
|
||||
- FIN-02
|
||||
- FIN-03
|
||||
- POLL-01
|
||||
- POLL-02
|
||||
- POLL-03
|
||||
- POLL-04
|
||||
affects:
|
||||
- internal/handler/admin/asset.go
|
||||
- internal/service/commission_withdrawal/service.go
|
||||
- internal/handler/admin/commission_withdrawal.go
|
||||
- internal/service/commission_withdrawal_setting/service.go
|
||||
- internal/store/postgres/wechat_config_store.go
|
||||
- internal/polling/scheduler.go
|
||||
- internal/service/iot_card/stop_resume_service.go
|
||||
- internal/service/asset/service.go
|
||||
- internal/store/postgres/package_store.go
|
||||
- internal/service/package_series/service.go
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- gorm.io/gorm/clause FOR UPDATE 行锁防并发
|
||||
- go-playground/validator 参数校验
|
||||
- 函数签名扩展(分页参数)
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- internal/handler/admin/asset.go
|
||||
- internal/service/commission_withdrawal/service.go
|
||||
- internal/handler/admin/commission_withdrawal.go
|
||||
- internal/service/commission_withdrawal_setting/service.go
|
||||
- internal/store/postgres/wechat_config_store.go
|
||||
- internal/polling/scheduler.go
|
||||
- internal/service/iot_card/stop_resume_service.go
|
||||
- internal/service/asset/service.go
|
||||
- internal/model/dto/asset_dto.go
|
||||
- internal/store/postgres/package_store.go
|
||||
- internal/service/package_series/service.go
|
||||
- internal/bootstrap/handlers.go
|
||||
- internal/bootstrap/services.go
|
||||
- pkg/openapi/handlers.go
|
||||
decisions:
|
||||
- 企业账号权限边界:Resolve 可查(删除拦截),Refresh 不可主动刷新(新增拦截)
|
||||
- GetPackages 分页在内存中排序后切片,避免改动 Store 层查询逻辑
|
||||
- Series 删除检查在 Service 层实现(非 DB 约束),符合项目禁止外键规范
|
||||
- CommissionWithdrawalHandler 新增 validator 字段注入,与其他 Handler 保持一致
|
||||
metrics:
|
||||
duration: "约 10 分钟"
|
||||
completed_date: "2026-03-28"
|
||||
tasks: 2
|
||||
files: 14
|
||||
---
|
||||
|
||||
# Phase 02 Plan 01: PERM + FIN + POLL 9项独立修复 Summary
|
||||
|
||||
企业账号权限边界(Resolve 可查/Refresh 只读)、提现流程数据完整性(审批人字段/remark 必填/激活并发锁)、轮询调度和停复机安全修复,全部为独立小修,共 9 个需求 8 个 commit。
|
||||
|
||||
## 执行结果
|
||||
|
||||
所有 9 个需求(PERM-01/02, FIN-01/02/03, POLL-01/02/03/04)均已修复,`go build ./...` 零新增错误(已有两个预存在的 `service/package` 字段缺失错误不在本 plan 范围内)。
|
||||
|
||||
## Task 1: PERM + FIN 修复
|
||||
|
||||
### PERM-01 (commit: 2a92ab6)
|
||||
- **文件**: `internal/handler/admin/asset.go`
|
||||
- **变更**: 删除 `Resolve` 方法中对企业账号的错误拦截(企业账号应可查询资产)
|
||||
|
||||
### PERM-02 (commit: 2a92ab6)
|
||||
- **文件**: `internal/handler/admin/asset.go`
|
||||
- **变更**: `Refresh` 方法开头新增企业账号拦截(企业账号只读,不允许主动触发运营商刷新)
|
||||
|
||||
### FIN-01 (commit: 8f62496)
|
||||
- **文件**: `internal/service/commission_withdrawal/service.go`
|
||||
- **变更**: `Approve` 的 `updates` map 补充 `approved_by` 和 `approved_at` 字段
|
||||
|
||||
### FIN-02 (commit: 9ee2f0b)
|
||||
- **文件**: `internal/handler/admin/commission_withdrawal.go`, `internal/bootstrap/handlers.go`, `pkg/openapi/handlers.go`
|
||||
- **变更**: `CommissionWithdrawalHandler` 新增 `validator` 字段;`RejectWithdrawal` 补充 `validator.Struct` 参数验证,确保 `remark` 必填
|
||||
|
||||
### FIN-03 (commit: bdac4ab)
|
||||
- **文件**: `internal/service/commission_withdrawal_setting/service.go`, `internal/store/postgres/wechat_config_store.go`
|
||||
- **变更**: 激活配置事务内首步加 `clause.Locking{Strength: "UPDATE"}` 行锁,防止并发时两个事务同时置 `is_active=true`
|
||||
|
||||
## Task 2: POLL 修复
|
||||
|
||||
### POLL-01 (commit: 922a3d0)
|
||||
- **文件**: `internal/polling/scheduler.go`
|
||||
- **变更**: `processSchedule` 新增 `TaskTypePollingProtect` 的 `processManualQueue` 和 `processTimedQueue` 调用
|
||||
|
||||
### POLL-02 (commit: 8a53123)
|
||||
- **文件**: `internal/service/iot_card/stop_resume_service.go`
|
||||
- **变更**: `stopCardWithRetry` 和 `resumeCardWithRetry` 中 `gatewayClient==nil` 时返回 `errors.New(CodeInternalError, ...)` 而非 `nil`(原来假装成功会误更新卡状态)
|
||||
|
||||
### POLL-03 (commit: 8553a46)
|
||||
- **文件**: `internal/model/dto/asset_dto.go`, `internal/service/asset/service.go`, `internal/handler/admin/asset.go`, `internal/handler/app/client_asset.go`
|
||||
- **变更**: 新增 `AssetPackagesResult` DTO;`GetPackages` 签名新增 `page/pageSize` 参数(默认 page=1, pageSize=50, max=100);admin handler 从 Query 参数读取;client_asset.go 调用处同步更新
|
||||
|
||||
### POLL-04 (commit: 47aa5f8)
|
||||
- **文件**: `internal/store/postgres/package_store.go`, `internal/service/package_series/service.go`, `internal/bootstrap/services.go`
|
||||
- **变更**: `PackageStore` 新增 `CountBySeriesID` 方法;`PackageSeries.Service` 注入 `packageStore` 字段;`Delete` 前检查关联套餐数量,有则拒绝删除
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 3 - Blocking] pkg/openapi/handlers.go 需同步更新签名**
|
||||
- **Found during**: Task 1 FIN-02 完成后最终编译验证
|
||||
- **Issue**: `CommissionWithdrawalHandler` 构造函数新增了 `validator` 参数,`pkg/openapi/handlers.go` 中文档生成器调用处仍使用旧签名导致编译失败
|
||||
- **Fix**: `admin.NewCommissionWithdrawalHandler(nil)` 改为 `admin.NewCommissionWithdrawalHandler(nil, nil)`
|
||||
- **Files modified**: `pkg/openapi/handlers.go`
|
||||
- **Commit**: 754bbcd
|
||||
|
||||
## Known Stubs
|
||||
|
||||
无 — 所有修复均为实际业务逻辑,无留桩或占位符。
|
||||
|
||||
## Commits
|
||||
|
||||
| Commit | Message |
|
||||
|--------|---------|
|
||||
| 2a92ab6 | fix(perm): 修复企业账号资产权限边界 PERM-01/02 |
|
||||
| 8f62496 | fix(fin): 补全提现审批人字段 FIN-01 |
|
||||
| 9ee2f0b | fix(fin): 提现拒绝补 remark 必填校验 FIN-02 |
|
||||
| bdac4ab | fix(fin): 激活配置并发行锁保护 FIN-03 |
|
||||
| 922a3d0 | fix(poll): polling:protect 接入调度器 POLL-01 |
|
||||
| 8a53123 | fix(poll): gatewayClient=nil 时停复机返回错误而非成功 POLL-02 |
|
||||
| 8553a46 | fix(poll): GetPackages 接口新增分页 POLL-03 |
|
||||
| 47aa5f8 | fix(poll): Series 删除前检查关联套餐 POLL-04 |
|
||||
| 754bbcd | fix(fin): 同步更新 openapi handlers 中 CommissionWithdrawalHandler 签名 |
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
所有关键文件存在,所有 commits 均可在 git log 中查到。
|
||||
444
.planning/phases/02-perm-fin-realname-poll/02-02-PLAN.md
Normal file
444
.planning/phases/02-perm-fin-realname-poll/02-02-PLAN.md
Normal file
@@ -0,0 +1,444 @@
|
||||
---
|
||||
phase: 02-perm-fin-realname-poll
|
||||
plan: 02
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- migrations/000089_realname_expiry_base.up.sql
|
||||
- migrations/000089_realname_expiry_base.down.sql
|
||||
- internal/model/package.go
|
||||
- internal/model/dto/package_dto.go
|
||||
- internal/store/postgres/package_store.go
|
||||
- internal/service/order/service.go
|
||||
- internal/service/client_order/service.go
|
||||
- internal/service/package/activation_service.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- REALNAME-01
|
||||
- REALNAME-02
|
||||
- REALNAME-03
|
||||
- REALNAME-04
|
||||
- REALNAME-05
|
||||
must_haves:
|
||||
truths:
|
||||
- "数据库 tb_package 表不再有 enable_realname_activation 字段,已有 expiry_base 字段默认 from_activation"
|
||||
- "行业卡(card_category=industry)套餐永远直接激活,无视 expiry_base"
|
||||
- "C 端购买普通卡套餐前检查实名状态(card_category=normal),删除 packagesNeedRealname 函数"
|
||||
- "后台囤货 expiry_base=from_purchase 时直接激活,from_activation 时等实名"
|
||||
- "ActivateByRealname 按 ExpiryBase 选择计时基准(from_purchase 用 CreatedAt,from_activation 用当前时刻)"
|
||||
- "套餐管理 API 的 DTO 不再有 enable_realname_activation,改为 expiry_base 枚举字段"
|
||||
- "全仓库搜索 EnableRealnameActivation 零引用"
|
||||
artifacts:
|
||||
- path: "migrations/000089_realname_expiry_base.up.sql"
|
||||
provides: "REALNAME-01 DB 迁移:DROP enable_realname_activation + ADD expiry_base"
|
||||
- path: "migrations/000089_realname_expiry_base.down.sql"
|
||||
provides: "REALNAME-01 回滚迁移"
|
||||
- path: "internal/model/package.go"
|
||||
provides: "REALNAME-01 Model 同步:删旧字段,加 ExpiryBase"
|
||||
- path: "internal/model/dto/package_dto.go"
|
||||
provides: "REALNAME-05 DTO 更新:移除 EnableRealnameActivation,新增 ExpiryBase"
|
||||
- path: "internal/store/postgres/package_store.go"
|
||||
provides: "REALNAME-01 删除两步写入特殊处理"
|
||||
- path: "internal/service/order/service.go"
|
||||
provides: "REALNAME-02 activateMainPackage 三维决策重写"
|
||||
- path: "internal/service/client_order/service.go"
|
||||
provides: "REALNAME-03 实名检查改按卡类型,删 packagesNeedRealname"
|
||||
- path: "internal/service/package/activation_service.go"
|
||||
provides: "REALNAME-04 ActivateByRealname 按 ExpiryBase 选择激活基准"
|
||||
key_links:
|
||||
- from: "internal/service/order/service.go:activateMainPackage"
|
||||
to: "model.Package.ExpiryBase"
|
||||
via: "pkg.ExpiryBase == from_activation"
|
||||
pattern: "ExpiryBase.*from_activation"
|
||||
- from: "internal/service/client_order/service.go"
|
||||
to: "assetInfo.CardCategory"
|
||||
via: "card_category == normal"
|
||||
pattern: "CardCategory.*normal"
|
||||
- from: "internal/service/package/activation_service.go:ActivateByRealname"
|
||||
to: "packageUsage.CreatedAt"
|
||||
via: "pkg.ExpiryBase == from_purchase"
|
||||
pattern: "ExpiryBase.*from_purchase.*CreatedAt"
|
||||
---
|
||||
|
||||
<objective>
|
||||
原子性重构实名激活架构(REALNAME-01~05),将 enable_realname_activation 布尔字段替换为 expiry_base 枚举,重构激活决策为三维判断(卡类型+购买路径+expiry_base),修复 C 端实名检查按卡类型判断。
|
||||
|
||||
Purpose: 当前 enable_realname_activation 字段混淆了"是否等实名"和"计时起点"两个独立语义,架构重构后逻辑清晰分离,生产套餐全部默认 from_activation(运营后续按需手动调整)。
|
||||
Output: DB 迁移 + Model + Store + Service + DTO 全链路变更,原子完成,不可拆分。
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/02-perm-fin-realname-poll/02-CONTEXT.md
|
||||
|
||||
# 修复规格(核心参考)
|
||||
@.sisyphus/plans/修正业务-完整方案.md(方案 G 第 1492-1611 行)
|
||||
|
||||
<interfaces>
|
||||
<!-- 本 plan 执行时所需的关键接口,直接使用,无需探索 -->
|
||||
|
||||
**当前 Package Model(内含 EnableRealnameActivation,需替换)**:
|
||||
```go
|
||||
// internal/model/package.go
|
||||
EnableRealnameActivation bool `gorm:"column:enable_realname_activation;type:boolean;default:true;comment:是否启用实名激活"`
|
||||
// 改为:
|
||||
ExpiryBase string `gorm:"column:expiry_base;type:varchar(30);not null;default:'from_activation';comment:到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算"`
|
||||
```
|
||||
|
||||
**当前 package_store.go 两步写入(REALNAME-01 后需删除整个特殊处理块)**:
|
||||
```go
|
||||
// internal/store/postgres/package_store.go:23-28
|
||||
// GORM 对零值字段有特殊处理,先创建然后立即更新 enable_realname_activation 字段确保正确设置
|
||||
if err := s.db.WithContext(ctx).Omit("enable_realname_activation").Create(pkg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 明确更新 enable_realname_activation 字段(包括零值 false)
|
||||
return s.db.WithContext(ctx).Model(pkg).Update("enable_realname_activation", pkg.EnableRealnameActivation).Error
|
||||
```
|
||||
删除后改为直接 `s.db.WithContext(ctx).Create(pkg).Error`(ExpiryBase 是 string,无零值布尔问题)。
|
||||
|
||||
**当前 package_dto.go EnableRealnameActivation 三处(REALNAME-05)**:
|
||||
```go
|
||||
// 第 18 行(CreatePackageRequest)
|
||||
EnableRealnameActivation *bool `json:"enable_realname_activation" description:"是否启用实名激活"`
|
||||
// 第 35 行(UpdatePackageRequest)
|
||||
EnableRealnameActivation *bool `json:"enable_realname_activation" description:"是否启用实名激活"`
|
||||
// 第 98 行(PackageResponse)
|
||||
EnableRealnameActivation bool `json:"enable_realname_activation" description:"是否启用实名激活"`
|
||||
// 全部改为 ExpiryBase:
|
||||
ExpiryBase string `json:"expiry_base" validate:"omitempty,oneof=from_activation from_purchase" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)"`
|
||||
```
|
||||
|
||||
**当前 activateMainPackage 激活决策(第 1873 行,REALNAME-02)**:
|
||||
```go
|
||||
// 任务 8.9: 后台囤货场景
|
||||
if pkg.EnableRealnameActivation {
|
||||
// 需要实名后才能激活
|
||||
status = constants.PackageUsageStatusPending
|
||||
pendingRealnameActivation = true
|
||||
}
|
||||
```
|
||||
改写为三维决策(见 action 中详细说明)。
|
||||
|
||||
**当前 client_order/service.go 实名检查(第 115 行,REALNAME-03)**:
|
||||
```go
|
||||
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return nil, errors.New(errors.CodeNeedRealname)
|
||||
}
|
||||
```
|
||||
改为:
|
||||
```go
|
||||
if assetInfo.CardCategory == "normal" && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return nil, errors.New(errors.CodeNeedRealname)
|
||||
}
|
||||
```
|
||||
然后删除 `packagesNeedRealname` 函数(第 619-626 行)。
|
||||
|
||||
**当前 ActivateByRealname 激活时间固定用 now(第 113 行附近,REALNAME-04)**:
|
||||
```go
|
||||
activatedAt := now
|
||||
expiresAt := CalculateExpiryTime(pkg.CalendarType, activatedAt, pkg.DurationMonths, pkg.DurationDays)
|
||||
```
|
||||
改为:
|
||||
```go
|
||||
var activatedAt time.Time
|
||||
if pkg.ExpiryBase == "from_purchase" {
|
||||
activatedAt = usage.CreatedAt // 用记录创建时间(购买时刻)
|
||||
} else {
|
||||
activatedAt = now // 实名被触发的当前时刻
|
||||
}
|
||||
```
|
||||
|
||||
**迁移命名参考(现有最新为 000088)**:
|
||||
新建 `migrations/000089_realname_expiry_base.up.sql` 和 `000089_realname_expiry_base.down.sql`
|
||||
|
||||
**card_category 访问路径(已确认)**:
|
||||
- `assetInfo.CardCategory` 来自 `dto.AssetResolveResponse.CardCategory`(第 49 行),值由 `asset/service.go:175` 填充
|
||||
- `activateMainPackage` 中通过 order 可以获取 `carrierType`/`carrierID`,card_category 需要从 `IotCard` 查询
|
||||
- 建议在 `activateMainPackage` 函数中通过 `tx.First(&card, carrierID)` 获取卡信息,再读 `card.CardCategory`
|
||||
|
||||
**BuyerTypePersonal 常量**:
|
||||
`model.BuyerTypePersonal` 已存在(参考 service.go 第 450 行 `orderBuyerType = model.BuyerTypePersonal`)
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: DB 迁移 + Model + Store 清理(REALNAME-01)</name>
|
||||
<files>
|
||||
migrations/000089_realname_expiry_base.up.sql,
|
||||
migrations/000089_realname_expiry_base.down.sql,
|
||||
internal/model/package.go,
|
||||
internal/store/postgres/package_store.go
|
||||
</files>
|
||||
<action>
|
||||
**步骤 1:创建迁移文件(per D-10)**
|
||||
|
||||
`migrations/000089_realname_expiry_base.up.sql`:
|
||||
```sql
|
||||
-- REALNAME-01: 实名激活架构重构
|
||||
-- 移除旧的布尔字段 enable_realname_activation,
|
||||
-- 新增 expiry_base 枚举字段控制套餐计时起点
|
||||
-- 注意:不做旧字段到新字段的数据迁移,现有记录全部默认 from_activation
|
||||
|
||||
-- 移除旧字段
|
||||
ALTER TABLE tb_package DROP COLUMN IF EXISTS enable_realname_activation;
|
||||
|
||||
-- 新增到期时间基准字段
|
||||
ALTER TABLE tb_package
|
||||
ADD COLUMN IF NOT EXISTS expiry_base VARCHAR(30) NOT NULL DEFAULT 'from_activation';
|
||||
|
||||
COMMENT ON COLUMN tb_package.expiry_base IS '到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算';
|
||||
```
|
||||
|
||||
`migrations/000089_realname_expiry_base.down.sql`:
|
||||
```sql
|
||||
-- 回滚:恢复旧字段,移除新字段
|
||||
ALTER TABLE tb_package DROP COLUMN IF EXISTS expiry_base;
|
||||
|
||||
ALTER TABLE tb_package
|
||||
ADD COLUMN IF NOT EXISTS enable_realname_activation BOOLEAN NOT NULL DEFAULT TRUE;
|
||||
|
||||
COMMENT ON COLUMN tb_package.enable_realname_activation IS '是否启用实名激活 true-需实名后激活 false-立即激活';
|
||||
```
|
||||
|
||||
执行迁移并验证:
|
||||
```bash
|
||||
make migrate-up
|
||||
make migrate-version
|
||||
```
|
||||
|
||||
**步骤 2:更新 Model(`internal/model/package.go`)**
|
||||
|
||||
删除 `EnableRealnameActivation` 字段:
|
||||
```go
|
||||
EnableRealnameActivation bool `gorm:"column:enable_realname_activation;type:boolean;..."`
|
||||
```
|
||||
|
||||
新增 `ExpiryBase` 字段(放在原来的位置附近):
|
||||
```go
|
||||
// ExpiryBase 到期时间基准:from_activation-实名激活时起算,from_purchase-购买时起算
|
||||
ExpiryBase string `gorm:"column:expiry_base;type:varchar(30);not null;default:'from_activation';comment:到期时间基准 from_activation-实名激活时起算 from_purchase-购买时起算" json:"expiry_base"`
|
||||
```
|
||||
|
||||
**步骤 3:清理 package_store.go 两步写入**
|
||||
|
||||
找到 `internal/store/postgres/package_store.go:23-28` 的特殊处理块:
|
||||
```go
|
||||
// GORM 对零值字段有特殊处理,先创建然后立即更新 enable_realname_activation 字段确保正确设置
|
||||
if err := s.db.WithContext(ctx).Omit("enable_realname_activation").Create(pkg).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
// 明确更新 enable_realname_activation 字段(包括零值 false)
|
||||
return s.db.WithContext(ctx).Model(pkg).Update("enable_realname_activation", pkg.EnableRealnameActivation).Error
|
||||
```
|
||||
|
||||
替换为直接创建(ExpiryBase 是 string,无零值问题):
|
||||
```go
|
||||
return s.db.WithContext(ctx).Create(pkg).Error
|
||||
```
|
||||
同时删除其上方的注释说明(第 22-23 行注释块)。
|
||||
|
||||
完成后 commit:`fix(realname): DB 迁移 + Model + Store 清理 REALNAME-01`
|
||||
</action>
|
||||
<verify>
|
||||
<automated>make migrate-version && go build ./... 2>&1 | head -30</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- make migrate-version 显示版本 89,dirty=false
|
||||
- tb_package 不再有 enable_realname_activation,已有 expiry_base 字段
|
||||
- package.go Package 结构体有 ExpiryBase 字段,无 EnableRealnameActivation 字段
|
||||
- package_store.go Create 函数已是直接一步 Create
|
||||
- go build ./... 无编译错误(此时 service 层还有旧引用,编译会报错,先记录,Task 2 修复)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Service + DTO 重构(REALNAME-02/03/04/05)</name>
|
||||
<files>
|
||||
internal/model/dto/package_dto.go,
|
||||
internal/service/order/service.go,
|
||||
internal/service/client_order/service.go,
|
||||
internal/service/package/activation_service.go
|
||||
</files>
|
||||
<action>
|
||||
**REALNAME-05(per D-10)**:`internal/model/dto/package_dto.go`
|
||||
|
||||
替换三处 `EnableRealnameActivation` 为 `ExpiryBase`:
|
||||
|
||||
1. 第 18 行(CreatePackageRequest):
|
||||
```go
|
||||
// 删除:EnableRealnameActivation *bool `json:"enable_realname_activation" description:"是否启用实名激活 (true:需实名后激活, false:立即激活)"`
|
||||
// 新增:
|
||||
ExpiryBase string `json:"expiry_base" validate:"omitempty,oneof=from_activation from_purchase" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)" example:"from_activation"`
|
||||
```
|
||||
|
||||
2. 第 35 行(UpdatePackageRequest):同上写法
|
||||
|
||||
3. 第 98 行(PackageResponse):
|
||||
```go
|
||||
// 删除:EnableRealnameActivation bool `json:"enable_realname_activation" description:"是否启用实名激活 (true:需实名后激活, false:立即激活)"`
|
||||
// 新增:
|
||||
ExpiryBase string `json:"expiry_base" description:"到期时间基准 (from_activation:实名激活时起算, from_purchase:购买时起算)"`
|
||||
```
|
||||
|
||||
完成后 commit:`fix(realname): DTO 更新 expiry_base 替换 enable_realname_activation REALNAME-05`
|
||||
|
||||
**REALNAME-02(per D-11)**:`internal/service/order/service.go:activateMainPackage`
|
||||
|
||||
找到第 1873 行 `if pkg.EnableRealnameActivation` 块,替换为三维决策逻辑:
|
||||
|
||||
```go
|
||||
// REALNAME-02: 按卡类型 + 购买路径 + expiry_base 三维决策
|
||||
// 需要在 else 分支(无生效中主套餐,立即激活)之后插入以下逻辑:
|
||||
|
||||
// 获取卡类型(行业卡永远直接激活,不等实名)
|
||||
var cardCategory string
|
||||
if carrierType == "iot_card" {
|
||||
var card model.IotCard
|
||||
if err := tx.Select("card_category").First(&card, carrierID).Error; err == nil {
|
||||
cardCategory = card.CardCategory
|
||||
}
|
||||
}
|
||||
|
||||
// 判断是否 C 端购买(C 端购买前已做实名检查,直接激活)
|
||||
isCEndPurchase := order.BuyerType == model.BuyerTypePersonal
|
||||
|
||||
// 三维决策:覆盖 activateMainPackage 已有的 status 赋值
|
||||
if cardCategory == "industry" {
|
||||
// 行业卡:永远直接激活,不等实名
|
||||
// status 已在 else 分支设为 Active,保持不变
|
||||
} else if isCEndPurchase {
|
||||
// C 端购买:前置实名检查已完成,直接激活
|
||||
// status 已在 else 分支设为 Active,保持不变
|
||||
} else {
|
||||
// 后台囤货:按 expiry_base 决定
|
||||
if pkg.ExpiryBase == "from_purchase" {
|
||||
// 购买即激活,立即开始计时,status 保持 Active(已设置)
|
||||
} else {
|
||||
// from_activation(默认):等实名后激活
|
||||
status = constants.PackageUsageStatusPending
|
||||
pendingRealnameActivation = true
|
||||
// 清空激活时间(排队状态不设置)
|
||||
activatedAt = time.Time{}
|
||||
expiresAt = time.Time{}
|
||||
nextResetAt = nil
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
注意:上述三维决策逻辑用于替换原来第 1873-1876 行的 `if pkg.EnableRealnameActivation` 块。放置位置:在 `hasActiveMain` 分支结束之后、`usage := &model.PackageUsage` 创建之前(即原 8.9 任务注释区域)。
|
||||
|
||||
完成后 commit:`fix(realname): activateMainPackage 三维决策重构 REALNAME-02`
|
||||
|
||||
**REALNAME-03(per D-12)**:`internal/service/client_order/service.go`
|
||||
|
||||
第 115 行,替换实名检查逻辑:
|
||||
```go
|
||||
// 删除:
|
||||
if packagesNeedRealname(validationResult.Packages) && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
|
||||
|
||||
// 替换为:
|
||||
// REALNAME-03: 按卡类型判断实名需求,普通卡需要实名,行业卡不需要
|
||||
if assetInfo.CardCategory == "normal" && assetInfo.RealNameStatus != constants.RealNameStatusVerified {
|
||||
```
|
||||
|
||||
然后删除 `packagesNeedRealname` 函数(第 619-626 行)。
|
||||
|
||||
检查 `validationResult.Packages` 是否仍被其他逻辑使用;若不再使用,保留 `validationResult` 结构但不必删除(其他字段如 `Packages` 本身可能仍用于后续订单创建)。
|
||||
|
||||
完成后 commit:`fix(realname): C 端实名检查改为按卡类型判断 REALNAME-03`
|
||||
|
||||
**REALNAME-04(per D-13)**:`internal/service/package/activation_service.go:ActivateByRealname`
|
||||
|
||||
找到循环内 `activatedAt := now` 的赋值(约第 113 行),替换为:
|
||||
```go
|
||||
// REALNAME-04: 按 ExpiryBase 选择激活计时基准
|
||||
var activatedAt time.Time
|
||||
if pkg.ExpiryBase == "from_purchase" {
|
||||
// 购买时起算:用 PackageUsage 记录的创建时间
|
||||
activatedAt = usage.CreatedAt
|
||||
} else {
|
||||
// 实名激活时起算(from_activation 或默认):用当前时刻
|
||||
activatedAt = now
|
||||
}
|
||||
```
|
||||
|
||||
注意:`usage` 变量在循环中已有(为 `*model.PackageUsage`),直接访问 `.CreatedAt` 即可。
|
||||
|
||||
完成后 commit:`fix(realname): ActivateByRealname 按 ExpiryBase 选择计时基准 REALNAME-04`
|
||||
|
||||
最终检查所有旧引用清零:
|
||||
```bash
|
||||
grep -rn "EnableRealnameActivation\|enable_realname_activation\|packagesNeedRealname" \
|
||||
internal/ --include="*.go"
|
||||
# 预期:零结果(DTO、Model、Service 全部清理完成)
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./... 2>&1 | head -30</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- package_dto.go 三处 EnableRealnameActivation 已替换为 ExpiryBase,description 使用中文枚举说明
|
||||
- order/service.go:activateMainPackage 已有三维决策逻辑(industry/C端/后台囤货+expiry_base)
|
||||
- client_order/service.go 实名检查用 CardCategory==normal;packagesNeedRealname 函数已删除
|
||||
- activation_service.go:ActivateByRealname 用 ExpiryBase 分支选择激活时间
|
||||
- grep 搜索 EnableRealnameActivation/enable_realname_activation/packagesNeedRealname 零结果
|
||||
- go build ./... 无编译错误
|
||||
- 已产出 4 个独立 commit(REALNAME-05、02、03、04)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
REALNAME 重构完成后执行最终验收:
|
||||
|
||||
```bash
|
||||
# 编译验证
|
||||
go build ./...
|
||||
|
||||
# DB 迁移验证
|
||||
make migrate-version
|
||||
# 预期:version=89, dirty=false
|
||||
|
||||
# 旧字段引用清零
|
||||
grep -rn "EnableRealnameActivation\|enable_realname_activation\|packagesNeedRealname" \
|
||||
internal/ --include="*.go"
|
||||
# 预期:零结果
|
||||
|
||||
# 新字段引用确认
|
||||
grep -rn "ExpiryBase\|expiry_base" internal/ --include="*.go" | grep -v "_test.go"
|
||||
# 预期:至少在 package.go, package_dto.go, order/service.go,
|
||||
# client_order/service.go, activation_service.go 中出现
|
||||
|
||||
# Model 验证
|
||||
grep -n "ExpiryBase" internal/model/package.go
|
||||
# 预期:有 gorm column:expiry_base 标签的字段定义
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- go build ./... 零错误
|
||||
- make migrate-version 显示 version=89, dirty=false
|
||||
- 全仓库 EnableRealnameActivation/enable_realname_activation/packagesNeedRealname 零引用
|
||||
- package.go 有 ExpiryBase 字段(gorm tag: expiry_base, varchar(30), default:from_activation)
|
||||
- package_store.go Create 函数为直接一步创建(无两步写入特殊处理)
|
||||
- activateMainPackage 包含三维决策(行业卡/C端/后台囤货+expiry_base)
|
||||
- client_order 实名检查用 CardCategory=="normal"
|
||||
- ActivateByRealname 按 ExpiryBase 分支选择 activatedAt 基准
|
||||
- 已产出 5 个独立 commit(REALNAME-01、05、02、03、04)
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
完成后创建:`.planning/phases/02-perm-fin-realname-poll/02-02-SUMMARY.md`
|
||||
</output>
|
||||
95
.planning/phases/02-perm-fin-realname-poll/02-02-SUMMARY.md
Normal file
95
.planning/phases/02-perm-fin-realname-poll/02-02-SUMMARY.md
Normal file
@@ -0,0 +1,95 @@
|
||||
---
|
||||
phase: 02-perm-fin-realname-poll
|
||||
plan: "02"
|
||||
subsystem: realname-activation
|
||||
tags: [realname, package, migration, refactor]
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides: [REALNAME-01, REALNAME-02, REALNAME-03, REALNAME-04, REALNAME-05]
|
||||
affects: [package, order, client_order, activation_service]
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns: [three-dimensional-decision, expiry-base-enum]
|
||||
key_files:
|
||||
created:
|
||||
- migrations/000089_realname_expiry_base.up.sql
|
||||
- migrations/000089_realname_expiry_base.down.sql
|
||||
modified:
|
||||
- internal/model/package.go
|
||||
- internal/model/dto/package_dto.go
|
||||
- internal/store/postgres/package_store.go
|
||||
- internal/service/order/service.go
|
||||
- internal/service/client_order/service.go
|
||||
- internal/service/package/service.go
|
||||
- internal/service/package/activation_service.go
|
||||
- internal/task/auto_purchase.go
|
||||
decisions:
|
||||
- "expiry_base 枚举替代 enable_realname_activation 布尔字段,清晰分离等实名语义与计时起点语义"
|
||||
- "三维决策:行业卡永远直接激活,C 端购买直接激活,后台囤货按 expiry_base 决定"
|
||||
- "auto_purchase.go 被认定为 C 端路径,不适用 expiry_base 三维决策"
|
||||
metrics:
|
||||
duration: "~25min"
|
||||
completed_date: "2026-03-28"
|
||||
tasks: 2
|
||||
files: 8
|
||||
---
|
||||
|
||||
# Phase 02 Plan 02: 实名激活架构重构 Summary
|
||||
|
||||
将 `enable_realname_activation` 布尔字段替换为 `expiry_base` 枚举(from_activation/from_purchase),重构激活决策为三维判断(卡类型+购买路径+expiry_base),全链路原子完成。
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Description | Commits |
|
||||
|------|-------------|---------|
|
||||
| 1 | DB 迁移 + Model + Store 清理(REALNAME-01) | `344850f` |
|
||||
| 2a | DTO 更新(REALNAME-05) | `244a274` |
|
||||
| 2b | activateMainPackage 三维决策(REALNAME-02) | `7dd5563` |
|
||||
| 2c | C 端实名检查按卡类型(REALNAME-03) | `0ebb3d7` |
|
||||
| 2d | ActivateByRealname 按 ExpiryBase 选时间基准(REALNAME-04) | `60b43bb` |
|
||||
| 2e | auto_purchase.go 废弃引用清除(偏差修复) | `4be473c` |
|
||||
|
||||
## Verification
|
||||
|
||||
```
|
||||
migrate-version: 89, dirty=false ✓
|
||||
EnableRealnameActivation/enable_realname_activation/packagesNeedRealname 零引用 ✓
|
||||
ExpiryBase 引用出现在 package.go、package_dto.go、order/service.go、
|
||||
client_order/service.go、activation_service.go ✓
|
||||
go build ./internal/... 零错误 ✓
|
||||
```
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] auto_purchase.go 含废弃 EnableRealnameActivation 引用**
|
||||
- **发现于:** Task 2 完成后编译验证
|
||||
- **问题:** `internal/task/auto_purchase.go:433` 引用已删除的 `pkg.EnableRealnameActivation` 字段,导致编译失败
|
||||
- **修复:** 识别 auto_purchase 为 C 端充值触发路径,不适用 expiry_base 三维决策,删除该分支并添加注释说明
|
||||
- **文件:** `internal/task/auto_purchase.go`
|
||||
- **Commit:** `4be473c`
|
||||
|
||||
**2. [Rule 1 - Bug] package/service.go DTO 映射引用 EnableRealnameActivation**
|
||||
- **发现于:** Task 2 DTO 替换后 LSP 报错
|
||||
- **问题:** `service.go` 的 Create/Update/toResponse 函数仍映射旧字段
|
||||
- **修复:** 同步更新三处映射为 `ExpiryBase`
|
||||
- **文件:** `internal/service/package/service.go`
|
||||
- **Commit:** 含于 `244a274`
|
||||
|
||||
## Known Stubs
|
||||
|
||||
无已知留桩。全链路变更已完整实现。
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
- `migrations/000089_realname_expiry_base.up.sql` — FOUND ✓
|
||||
- `migrations/000089_realname_expiry_base.down.sql` — FOUND ✓
|
||||
- `internal/model/package.go` ExpiryBase 字段 — FOUND ✓
|
||||
- `internal/model/dto/package_dto.go` ExpiryBase 三处 — FOUND ✓
|
||||
- `internal/store/postgres/package_store.go` 直接一步 Create — FOUND ✓
|
||||
- Commit `344850f` (REALNAME-01) — FOUND ✓
|
||||
- Commit `244a274` (REALNAME-05) — FOUND ✓
|
||||
- Commit `7dd5563` (REALNAME-02) — FOUND ✓
|
||||
- Commit `0ebb3d7` (REALNAME-03) — FOUND ✓
|
||||
- Commit `60b43bb` (REALNAME-04) — FOUND ✓
|
||||
143
.planning/phases/02-perm-fin-realname-poll/02-CONTEXT.md
Normal file
143
.planning/phases/02-perm-fin-realname-poll/02-CONTEXT.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# Phase 2: 权限/财务/实名/轮询修复 - Context
|
||||
|
||||
**Gathered:** 2026-03-28
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
修复和补全四个独立业务域的已知断点:企业账号权限边界(PERM)、提现财务流程完整性(FIN)、实名激活架构重构(REALNAME/G)、轮询调度小修(POLL)。共 14 个需求,全部为修复性变更,无新功能模块,REALNAME 涉及破坏性 DB 迁移(DROP 旧字段 + ADD 新字段)。
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### 执行分组策略
|
||||
|
||||
- **D-01:** 拆分为 **2 个 Plan**,而非 1 个或 4 个:
|
||||
- **Plan 1(小修合并)**:B(PERM-01/02)+ H(POLL-01/02/03/04)+ C(FIN-01/02/03)
|
||||
- **Plan 2(REALNAME 独立)**:G(REALNAME-01~05)—— 单独 Plan,独立可回滚
|
||||
- **D-02:** Plan 1 内部执行顺序:B → H → C(权限修复最小化先上,轮询小修次之,财务流程收尾)
|
||||
- **D-03:** Plan 2(REALNAME)必须原子执行,不可拆分 —— 5 个需求在一个 Plan 的同一事务性推进中完成
|
||||
|
||||
### 企业权限(PERM)
|
||||
|
||||
- **D-04:** `asset.go:Resolve` —— **移除**企业账号拦截(企业应可查询被授权资产)
|
||||
- **D-05:** `asset.go:Refresh` —— **新增**企业账号拦截(企业只读,不可触发运营商刷新)
|
||||
- **D-06:** 其他 8 个 AssetHandler 方法维持现状(无拦截/已拦截均不动)
|
||||
|
||||
### 财务流程(FIN)
|
||||
|
||||
- **D-07:** FIN-01:`commission_withdrawal/service.go:Approve()` 补填 `approved_by` / `approved_at` 字段
|
||||
- **D-08:** FIN-02:`commission_withdrawal.go:Reject()` 在 BodyParser 后补 `validator.Validate(&req)`
|
||||
- **D-09:** FIN-03:激活配置并发保护使用 GORM v2 语法 `tx.Clauses(clause.Locking{Strength: "UPDATE"})` —— 规格书写的是 GORM v1 的 `gorm:query_option`,项目实际用 GORM v2,agent 使用正确语法
|
||||
|
||||
### REALNAME 架构重构(G)
|
||||
|
||||
- **D-10:** DB 迁移策略:**直接 ADD DEFAULT 'from_activation',旧字段 DROP**
|
||||
- 不做 `enable_realname_activation=false → from_purchase` 的数据迁移
|
||||
- 原因:旧字段控制的是"是否等实名",新字段 `expiry_base` 控制的是"计时起点",两者语义不同,无法直接映射;旧字段已无存在意义,直接清理
|
||||
- 现有生产套餐记录全部默认 `from_activation`,后续如需 `from_purchase` 由运营手动修改
|
||||
- **D-11:** `activateMainPackage` 激活决策逻辑改为按三维判断:
|
||||
- 行业卡(`card_category == "industry"`)→ 永远直接激活
|
||||
- C 端购买(`buyerType == "personal"` 或 `order.OrderType` 标记 C 端)→ 直接激活(已前置实名检查)
|
||||
- 后台囤货 → 按 `pkg.ExpiryBase`:`from_purchase` 直接激活,`from_activation` 进入等实名队列
|
||||
- **D-12:** C 端实名检查改为按卡类型(`card_category == "normal"`)判断,删除 `packagesNeedRealname()` 函数
|
||||
- **D-13:** `ActivateByRealname` 按 `ExpiryBase` 选择计时基准:`from_purchase` 用 `CreatedAt`,`from_activation` 用当前时刻
|
||||
|
||||
### 轮询系统小修(POLL)
|
||||
|
||||
- **D-14:** POLL-01:在 `scheduler.go` 主调度循环中,复用已有的 `processManualQueue` / `processTimedQueue`,补充 `polling:protect` 的调度接入
|
||||
- **D-15:** POLL-02:`stopCardWithRetry` / `resumeCardWithRetry` 两处,`gatewayClient == nil` 时改为返回明确业务错误
|
||||
- **D-16:** POLL-03:`GetPackages()` 接口新增分页参数,默认 `page=1, pageSize=50`,最大 100
|
||||
- **D-17:** POLL-04:`package_series/service.go:Delete()` 前置 `CountBySeriesID()` 检查,有关联套餐则拒绝删除
|
||||
|
||||
### Agent's Discretion
|
||||
|
||||
- FIN-03 事务内 `FOR UPDATE` 的具体 GORM v2 `clause.Locking` 写法,agent 参考项目现有 `clause.OnConflict` 用法对齐风格
|
||||
- `isCEndPurchase` 的具体推断字段(`buyerType == "personal"` 或 order 字段),agent 阅读 `activateMainPackage` 上下文确认最准确的判断路径
|
||||
- 每个子修复独立一个 commit(沿用 Phase 1 的粒度规范)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### 修复规格(核心参考)
|
||||
- `.sisyphus/plans/修正业务-完整方案.md` — 完整修复规格书,含每个需求的文件路径、代码片段、人工验收清单:
|
||||
- 方案 B(PERM-01/02):第 766-863 行
|
||||
- 方案 C(FIN-01/02/03):第 864-944 行
|
||||
- 方案 G(REALNAME-01~05):第 1492-1611 行
|
||||
- 方案 H(POLL-01~04):第 1612-1711 行
|
||||
|
||||
### 核心修改文件
|
||||
- `internal/handler/admin/asset.go` — PERM-01(删 Resolve 企业拦截)、PERM-02(加 Refresh 企业拦截)
|
||||
- `internal/service/commission_withdrawal/service.go` — FIN-01(Approve 补审批人字段)
|
||||
- `internal/handler/admin/commission_withdrawal.go` — FIN-02(Reject 加 Validate)
|
||||
- `internal/service/commission_withdrawal_setting/service.go` — FIN-03(并发行锁)
|
||||
- `internal/store/postgres/wechat_config_store.go` — FIN-03(微信配置并发行锁)
|
||||
- `internal/model/package.go` — REALNAME-01(drop EnableRealnameActivation,add ExpiryBase)
|
||||
- `internal/model/dto/package_dto.go` — REALNAME-05(DTO 更新 expiry_base 字段)
|
||||
- `internal/service/order/service.go` — REALNAME-02(activateMainPackage 三维决策)
|
||||
- `internal/service/client_order/service.go` — REALNAME-03(按卡类型检查实名)
|
||||
- `internal/service/package/activation_service.go` — REALNAME-04(ActivateByRealname 计时基准)
|
||||
- `internal/store/postgres/package_store.go` — REALNAME-01(删 enable_realname_activation 两步写入特殊处理)
|
||||
- `internal/polling/scheduler.go` — POLL-01(补 protect 调度)
|
||||
- `internal/task/polling_handler.go` — POLL-02(nil gateway 改返错误)
|
||||
- `internal/service/asset/service.go` 或相关 package 接口 — POLL-03(GetPackages 分页)
|
||||
- `internal/service/package_series/service.go` — POLL-04(Delete 前检查关联套餐)
|
||||
|
||||
### DB 迁移
|
||||
- `migrations/` 目录 — 遵循现有迁移文件命名规范,REALNAME 迁移需新建一个 .sql 文件
|
||||
|
||||
### 项目规范
|
||||
- `AGENTS.md` — 语言要求、错误处理、分层规范
|
||||
- `docs/api-documentation-guide.md` — 若有 DTO 变更需同步更新文档
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### 关键现状确认
|
||||
- `asset.go:Resolve`(第 41-44 行):企业拦截代码已确认存在,直接删除即可
|
||||
- `asset.go:Refresh`(第 78 行附近):当前无企业检查,需在方法开头新增
|
||||
- GORM v2 locking 语法:项目已有 `clause.OnConflict` 使用(`package_usage_daily_record_store.go`),FIN-03 应参照同样风格使用 `clause.Locking`
|
||||
- `enable_realname_activation`:在 `package.go`、`package_dto.go`(3处)、`package_store.go`(2处两步写入特殊处理)均有引用,全部需清理
|
||||
- `card_category`:在 `polling_handler.go:108` 已有 `if card.CardCategory == "industry"` 判断,REALNAME-02 决策逻辑与此对齐
|
||||
- `processManualQueue` / `processTimedQueue`:`scheduler.go:243-250` 已有 3 个 polling 类型,补 protect 参照同一模式
|
||||
|
||||
### Established Patterns
|
||||
- 行锁:`tx.Clauses(clause.Locking{...})` — 参考 `clause.OnConflict` 已有用法
|
||||
- 错误返回:Service 层用 `errors.New(code, msg)` / `errors.Wrap(code, err, msg)`
|
||||
- 分页:`QueryOptions{Page: page, PageSize: pageSize}` — 参考已有 List 接口
|
||||
- Commit 粒度:每个独立 Bug 修复一个 commit(沿用 Phase 1 规范)
|
||||
|
||||
### Integration Points
|
||||
- REALNAME:`package_store.go` 的两步写入特殊处理在 REALNAME-01 后需删除(字段删除后特殊处理失去意义)
|
||||
- POLL-03 分页:若 `GetPackages` 上层调用点(`handler/admin/asset.go:102`、`handler/app/client_asset.go:213`)无分页参数传入,需同时更新 Handler 层
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- Plan 2(REALNAME)原子执行——规格书明确指出"REALNAME-01~05 是原子重构,必须在一个 plan 内完整执行,不可拆分"(STATE.md Accumulated Context)
|
||||
- 旧字段 `enable_realname_activation` 直接清理,不做 false→from_purchase 映射,现有生产套餐全部默认 from_activation,后续运营按需手动调整
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
None — discussion stayed within phase scope
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 02-perm-fin-realname-poll*
|
||||
*Context gathered: 2026-03-28*
|
||||
@@ -0,0 +1,58 @@
|
||||
# Phase 2: 权限/财务/实名/轮询修复 - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-03-28
|
||||
**Phase:** 02-perm-fin-realname-poll
|
||||
**Areas discussed:** 执行分组策略, REALNAME 数据迁移风险
|
||||
|
||||
---
|
||||
|
||||
## 执行分组策略
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| B+C+H 合一、G 独立 | 2 个 Plan:小修删掉合成一个,REALNAME 单独一个。风险隔离好,G 可以独立回滚 | ✓ |
|
||||
| B、C、G、H 各独立 | 4 个 Plan:最细粒度,但总费用较高(尤其 B 和 H 都很小) | |
|
||||
| 全部合并成一个 | 1 个 Plan:最快完成,但如果 G 失败就不知道是哪个需求的问题 | |
|
||||
|
||||
**User's choice:** B+C+H 合一、G 独立
|
||||
**Notes:** Plan 1 内部执行顺序确认为 B → H → C
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序(Plan 1 内部)
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| B → H → C | 先权限修复(最小化验证),然后轮询小修,最后收尾财务流程。财务类改动放最后更安全 | ✓ |
|
||||
| H → B → C | 先轮询,再权限,最后财务 | |
|
||||
| Agent 自行决定 | 顺序不重要,由 agent 按依赖关系安排 | |
|
||||
|
||||
**User's choice:** B → H → C
|
||||
|
||||
---
|
||||
|
||||
## REALNAME 数据迁移风险
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 直接默认 from_activation | 新字段 DEFAULT 'from_activation'。旧的 false 记录不做映射。expiry_base 控制计时基准,语义与旧字段不同,无法直接映射 | ✓ |
|
||||
| 脚本映射 false → from_purchase | UPDATE tb_package SET expiry_base='from_purchase' WHERE enable_realname_activation=false。但实际上旧字段的语义与新字段不完全对应 | |
|
||||
|
||||
**User's choice:** 直接默认 from_activation,旧字段清理掉
|
||||
**Notes:** "如果旧的字段没有用了就清理掉吧"
|
||||
|
||||
---
|
||||
|
||||
## Agent's Discretion
|
||||
|
||||
- FIN-03 GORM v2 行锁语法(`clause.Locking`)
|
||||
- `isCEndPurchase` 的具体推断字段
|
||||
- 每个子修复独立 commit 粒度
|
||||
- POLL-03 Handler 层分页参数传入方式
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
None mentioned during discussion.
|
||||
203
.planning/phases/02-perm-fin-realname-poll/02-VERIFICATION.md
Normal file
203
.planning/phases/02-perm-fin-realname-poll/02-VERIFICATION.md
Normal file
@@ -0,0 +1,203 @@
|
||||
---
|
||||
phase: 02-perm-fin-realname-poll
|
||||
verified: 2026-03-28T10:00:00Z
|
||||
status: passed
|
||||
score: 14/14 must-haves verified
|
||||
re_verification: null
|
||||
gaps: []
|
||||
human_verification: []
|
||||
---
|
||||
|
||||
# Phase 02: 权限/财务/实名/轮询修复 Verification Report
|
||||
|
||||
**Phase Goal:** 修复 PERM(企业权限)+ FIN(财务流程)+ REALNAME(实名激活架构重构)+ POLL(轮询小修)四个域共 14 个需求缺陷。
|
||||
**Verified:** 2026-03-28T10:00:00Z
|
||||
**Status:** ✅ PASSED
|
||||
**Re-verification:** No — initial verification
|
||||
|
||||
---
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|---|-------|--------|---------|
|
||||
| 1 | 企业账号可调用 Resolve 接口查询被授权资产,不再被 403 拦截 | ✓ VERIFIED | `asset.go:Resolve` 方法中已删除企业账号拦截代码块(commit 2a92ab6) |
|
||||
| 2 | 企业账号调用 Refresh 接口时返回 403 业务错误 | ✓ VERIFIED | `asset.go:Refresh` 方法开头已插入企业拦截(L74-78,CodeForbidden) |
|
||||
| 3 | 提现审批通过后,approved_by 和 approved_at 字段正确填充 | ✓ VERIFIED | `commission_withdrawal/service.go:209-210` maps 包含两个字段 |
|
||||
| 4 | 拒绝提现时 remark 为空返回参数校验错误,申请状态不变 | ✓ VERIFIED | `commission_withdrawal.go:RejectWithdrawal` L69-71 有 `validator.Struct(&req)` 调用 |
|
||||
| 5 | 同时激活两个提现配置时,行锁生效,任何时刻最多一条 is_active=true | ✓ VERIFIED | `commission_withdrawal_setting/service.go:53-55` 有 `clause.Locking{Strength: "UPDATE"}`;`wechat_config_store.go:106` 同理 |
|
||||
| 6 | polling:protect 任务进入调度器,日志可见 protect 队列被处理 | ✓ VERIFIED | `scheduler.go:246,252` 新增了 `processManualQueue` 和 `processTimedQueue` 对 `TaskTypePollingProtect` 的调用 |
|
||||
| 7 | stopCardWithRetry/resumeCardWithRetry 在 gatewayClient==nil 时返回业务错误而非 nil | ✓ VERIFIED | `stop_resume_service.go:178-180` 和 `209-211` 均返回 `errors.New(CodeInternalError, "Gateway 未配置...")` |
|
||||
| 8 | GetPackages 接口响应中包含 page/page_size/total 分页字段,默认 page=1 pageSize=50 | ✓ VERIFIED | `asset/service.go:348` 签名含 page/pageSize,`asset_dto.go:101-106` 含 Total/Page/PageSize/Items |
|
||||
| 9 | 删除有关联套餐的 PackageSeries 时返回错误提示而非成功 | ✓ VERIFIED | `package_series/service.go:147-153` 先 CountBySeriesID,count>0 返回 CodeInvalidParam |
|
||||
| 10 | 数据库 tb_package 表不再有 enable_realname_activation 字段,已有 expiry_base 字段默认 from_activation | ✓ VERIFIED | `000089_realname_expiry_base.up.sql` DROP+ADD,`package.go:49` ExpiryBase 字段定义 |
|
||||
| 11 | 行业卡套餐永远直接激活,无视 expiry_base | ✓ VERIFIED | `order/service.go:1887` `if cardCategory != "industry" && !isCEndPurchase` 逻辑 |
|
||||
| 12 | C 端购买普通卡套餐前检查实名状态,删除 packagesNeedRealname 函数 | ✓ VERIFIED | `client_order/service.go:116` 用 `CardCategory == "normal"`;全仓库 `packagesNeedRealname` 零引用 |
|
||||
| 13 | ActivateByRealname 按 ExpiryBase 选择计时基准(from_purchase 用 CreatedAt,from_activation 用当前时刻)| ✓ VERIFIED | `activation_service.go:119-123` 按 ExpiryBase 分支选择 activatedAt |
|
||||
| 14 | 全仓库搜索 EnableRealnameActivation 零引用 | ✓ VERIFIED | grep 搜索 `EnableRealnameActivation\|enable_realname_activation\|packagesNeedRealname` 在 `internal/` 下零结果 |
|
||||
|
||||
**Score:** 14/14 truths verified
|
||||
|
||||
---
|
||||
|
||||
### Required Artifacts
|
||||
|
||||
#### Plan 01 Artifacts
|
||||
|
||||
| Artifact | Expected | Level 1 (Exists) | Level 2 (Substantive) | Level 3 (Wired) | Status |
|
||||
|----------|----------|------------------|-----------------------|-----------------|--------|
|
||||
| `internal/handler/admin/asset.go` | PERM-01 删除 Resolve 企业拦截;PERM-02 新增 Refresh 企业拦截 | ✓ | ✓ Resolve 无拦截,Refresh L74-78 有拦截 | ✓ 已注册路由 | ✓ VERIFIED |
|
||||
| `internal/service/commission_withdrawal/service.go` | FIN-01 Approve 补 approved_by/approved_at | ✓ | ✓ L209-210 更新字段 | ✓ Handler 调用 Approve | ✓ VERIFIED |
|
||||
| `internal/handler/admin/commission_withdrawal.go` | FIN-02 Reject 补 validator.Validate | ✓ | ✓ L69-71 validator.Struct 调用 | ✓ 路由已注册 | ✓ VERIFIED |
|
||||
| `internal/service/commission_withdrawal_setting/service.go` | FIN-03 激活配置前加 FOR UPDATE 行锁 | ✓ | ✓ L53-55 clause.Locking | ✓ Service 被 Handler 调用 | ✓ VERIFIED |
|
||||
| `internal/store/postgres/wechat_config_store.go` | FIN-03 微信配置激活前加 FOR UPDATE 行锁 | ✓ | ✓ L106 clause.Locking | ✓ Store 被 Service 调用 | ✓ VERIFIED |
|
||||
| `internal/polling/scheduler.go` | POLL-01 补 polling:protect 调度 | ✓ | ✓ L246,252 两行调度 | ✓ Scheduler 运行时执行 | ✓ VERIFIED |
|
||||
| `internal/service/iot_card/stop_resume_service.go` | POLL-02 gatewayClient==nil 返回业务错误 | ✓ | ✓ 两处均返回 errors.New | ✓ 被 StopCard/ResumeCard 调用 | ✓ VERIFIED |
|
||||
| `internal/service/asset/service.go` | POLL-03 GetPackages 支持分页 | ✓ | ✓ 函数签名含 page/pageSize,返回 AssetPackagesResult | ✓ Handler 传入 page/pageSize | ✓ VERIFIED |
|
||||
| `internal/service/package_series/service.go` | POLL-04 Delete 前检查关联套餐 | ✓ | ✓ L147-153 CountBySeriesID 检查 | ✓ 被 Handler 调用 | ✓ VERIFIED |
|
||||
| `internal/store/postgres/package_store.go` | POLL-04 新增 CountBySeriesID 方法 | ✓ | ✓ L140-141 方法定义 | ✓ 被 package_series.Service 调用 | ✓ VERIFIED |
|
||||
|
||||
#### Plan 02 Artifacts
|
||||
|
||||
| Artifact | Expected | Level 1 (Exists) | Level 2 (Substantive) | Level 3 (Wired) | Status |
|
||||
|----------|----------|------------------|-----------------------|-----------------|--------|
|
||||
| `migrations/000089_realname_expiry_base.up.sql` | REALNAME-01 DB 迁移:DROP enable_realname_activation + ADD expiry_base | ✓ | ✓ SQL 完整:DROP+ADD+COMMENT | ✓ 迁移文件可执行 | ✓ VERIFIED |
|
||||
| `migrations/000089_realname_expiry_base.down.sql` | REALNAME-01 回滚迁移 | ✓ | ✓ 逆向 DROP+ADD | ✓ 对应 up 文件 | ✓ VERIFIED |
|
||||
| `internal/model/package.go` | REALNAME-01 Model 同步:删旧字段,加 ExpiryBase | ✓ | ✓ L49 ExpiryBase string gorm 标签 | ✓ Model 被全仓库引用 | ✓ VERIFIED |
|
||||
| `internal/model/dto/package_dto.go` | REALNAME-05 DTO 更新:移除 EnableRealnameActivation,新增 ExpiryBase | ✓ | ✓ 三处 ExpiryBase(Create/Update/Response),无 EnableRealnameActivation | ✓ Handler 使用 DTO | ✓ VERIFIED |
|
||||
| `internal/store/postgres/package_store.go` | REALNAME-01 删除两步写入特殊处理 | ✓ | ✓ L24 直接 `Create(pkg).Error` | ✓ 被 package service 调用 | ✓ VERIFIED |
|
||||
| `internal/service/order/service.go` | REALNAME-02 activateMainPackage 三维决策重写 | ✓ | ✓ L1885-1898 行业卡/C端/后台囤货+ExpiryBase 逻辑 | ✓ 在订单流程中被调用 | ✓ VERIFIED |
|
||||
| `internal/service/client_order/service.go` | REALNAME-03 实名检查改按卡类型,删 packagesNeedRealname | ✓ | ✓ L116 `CardCategory == "normal"`,packagesNeedRealname 已删 | ✓ 在 C 端购买流程中被调用 | ✓ VERIFIED |
|
||||
| `internal/service/package/activation_service.go` | REALNAME-04 ActivateByRealname 按 ExpiryBase 选择激活基准 | ✓ | ✓ L119-123 ExpiryBase 分支 | ✓ 在实名激活流程中被调用 | ✓ VERIFIED |
|
||||
|
||||
---
|
||||
|
||||
### Key Link Verification
|
||||
|
||||
#### Plan 01 Key Links
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `asset.go:Resolve` | `constants.UserTypeEnterprise` | `middleware.GetUserTypeFromContext` | ✓ VERIFIED — 已删除,Resolve 中无企业拦截 | L40-51 直接跳过拦截 |
|
||||
| `asset.go:Refresh` | `constants.UserTypeEnterprise` | `middleware.GetUserTypeFromContext` | ✓ VERIFIED | L74-78 有 `UserTypeEnterprise` 检查 |
|
||||
| `commission_withdrawal/service.go:Approve` | `tb_commission_withdrawal_request.approved_by/approved_at` | updates map | ✓ VERIFIED | L209-210 有字段 |
|
||||
| `scheduler.go` | `constants.TaskTypePollingProtect` | processManualQueue+processTimedQueue | ✓ VERIFIED | L246,252 两行调用 |
|
||||
|
||||
#### Plan 02 Key Links
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|------|----|-----|--------|---------|
|
||||
| `order/service.go:activateMainPackage` | `model.Package.ExpiryBase` | `pkg.ExpiryBase == "from_activation"` | ✓ VERIFIED | L1889 `pkg.ExpiryBase != "from_purchase"` 即 from_activation |
|
||||
| `client_order/service.go` | `assetInfo.CardCategory` | `card_category == "normal"` | ✓ VERIFIED | L116 直接比较 `"normal"` |
|
||||
| `activation_service.go:ActivateByRealname` | `packageUsage.CreatedAt` | `pkg.ExpiryBase == "from_purchase"` | ✓ VERIFIED | L119-120 用 `usage.CreatedAt` |
|
||||
|
||||
---
|
||||
|
||||
### Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
|----------|---------------|--------|-------------------|--------|
|
||||
| `GetPackages` | `AssetPackagesResult` | `packageUsageStore.ListByCarrier` → DB 查询 | ✓ | ✓ FLOWING |
|
||||
| `activateMainPackage` 三维决策 | `status/pendingRealnameActivation` | `cardCategory`(DB first)、`order.BuyerType`、`pkg.ExpiryBase`(DB) | ✓ | ✓ FLOWING |
|
||||
| `ActivateByRealname` | `activatedAt` | `usage.CreatedAt`(DB PackageUsage.CreatedAt)或 `now` | ✓ | ✓ FLOWING |
|
||||
|
||||
---
|
||||
|
||||
### Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Check | Result | Status |
|
||||
|----------|-------|--------|--------|
|
||||
| 编译通过 | `go build ./...` | 零输出(无错误) | ✓ PASS |
|
||||
| PERM-01 Resolve 无企业拦截 | `grep "UserTypeEnterprise" asset.go:Resolve` | 仅出现在 Refresh 中 | ✓ PASS |
|
||||
| PERM-02 Refresh 有企业拦截 | `grep "UserTypeEnterprise" asset.go:Refresh` | L76 存在 | ✓ PASS |
|
||||
| FIN-01 approved_by/at 字段 | `grep "approved_by\|approved_at" commission_withdrawal/service.go` | L209-210 存在 | ✓ PASS |
|
||||
| POLL-01 protect 调度 | `grep "TaskTypePollingProtect" scheduler.go` | L246,252 两行 | ✓ PASS |
|
||||
| REALNAME 旧字段零引用 | `grep "EnableRealnameActivation" internal/` | 零结果 | ✓ PASS |
|
||||
| 迁移文件存在 | `ls migrations/ \| grep 000089` | 两个文件存在 | ✓ PASS |
|
||||
| 所有 commits 存在 | `git log` 核验 13 个 commit hash | 全部找到 | ✓ PASS |
|
||||
|
||||
---
|
||||
|
||||
### Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|------------|-------------|--------|---------|
|
||||
| PERM-01 | 02-01 | 移除 Resolve 接口对企业账号的错误拦截 | ✓ SATISFIED | `asset.go:Resolve` L40-51,无企业拦截,commit 2a92ab6 |
|
||||
| PERM-02 | 02-01 | Refresh 接口新增企业账号拦截 | ✓ SATISFIED | `asset.go:Refresh` L74-78,CodeForbidden,commit 2a92ab6 |
|
||||
| FIN-01 | 02-01 | 提现审批人字段补全 | ✓ SATISFIED | `commission_withdrawal/service.go` L209-210,commit 8f62496 |
|
||||
| FIN-02 | 02-01 | 提现拒绝 remark 必填校验 | ✓ SATISFIED | `commission_withdrawal.go:RejectWithdrawal` L69-71,commit 9ee2f0b |
|
||||
| FIN-03 | 02-01 | 激活配置并发保护(FOR UPDATE 行锁) | ✓ SATISFIED | `commission_withdrawal_setting/service.go` L53-55 + `wechat_config_store.go` L106,commit bdac4ab |
|
||||
| REALNAME-01 | 02-02 | 移除 tb_package.enable_realname_activation,新增 expiry_base | ✓ SATISFIED | `000089_realname_expiry_base.up.sql` + `package.go` L49,commit 344850f |
|
||||
| REALNAME-02 | 02-02 | activateMainPackage 三维决策重写 | ✓ SATISFIED | `order/service.go` L1885-1898,commit 7dd5563 |
|
||||
| REALNAME-03 | 02-02 | C 端实名检查按卡类型,删 packagesNeedRealname | ✓ SATISFIED | `client_order/service.go` L116,commit 0ebb3d7 |
|
||||
| REALNAME-04 | 02-02 | ActivateByRealname 按 ExpiryBase 选择激活时间基准 | ✓ SATISFIED | `activation_service.go` L119-123,commit 60b43bb |
|
||||
| REALNAME-05 | 02-02 | 套餐管理 API DTO 更新(expiry_base 替换 enable_realname_activation) | ✓ SATISFIED | `package_dto.go` 三处 ExpiryBase,commit 244a274 |
|
||||
| POLL-01 | 02-01 | polling:protect 接入调度器 | ✓ SATISFIED | `scheduler.go` L246,252,commit 922a3d0 |
|
||||
| POLL-02 | 02-01 | gatewayClient=nil 时停复机返回错误 | ✓ SATISFIED | `stop_resume_service.go` L178-180,209-211,commit 8a53123 |
|
||||
| POLL-03 | 02-01 | packages 接口新增分页(page=1,pageSize=50,max=100) | ✓ SATISFIED | `asset/service.go` L348,`asset_dto.go` AssetPackagesResult,commit 8553a46 |
|
||||
| POLL-04 | 02-01 | Series 删除前检查关联套餐 | ✓ SATISFIED | `package_series/service.go` L147-153 + `package_store.go` L141,commit 47aa5f8 |
|
||||
|
||||
**所有 14 个 Phase 2 需求均已满足,无孤立需求。**
|
||||
|
||||
---
|
||||
|
||||
### Anti-Patterns Found
|
||||
|
||||
| File | Pattern | Severity | Impact |
|
||||
|------|---------|----------|--------|
|
||||
| 无 | — | — | — |
|
||||
|
||||
扫描结果:无 TODO/FIXME/PLACEHOLDER 留桩,无 `return null/[]` 空响应桩,无硬编码空数据流,所有修复均为实质性业务逻辑。
|
||||
|
||||
---
|
||||
|
||||
### Human Verification Required
|
||||
|
||||
**无** — 所有关键逻辑均可通过代码静态分析验证。以下为可选的线上联调验证(非阻塞):
|
||||
|
||||
1. **企业账号权限测试(PERM-01/02)**
|
||||
- **Test:** 用企业账号 Token 调用 `GET /api/admin/assets/resolve/:identifier`,验证返回资产数据而非 403
|
||||
- **Expected:** HTTP 200,返回资产信息
|
||||
- **Why human:** 需要有效的企业账号 Token 和真实资产数据,无法通过静态分析模拟
|
||||
|
||||
2. **并发行锁测试(FIN-03)**
|
||||
- **Test:** 并发同时调用两个提现配置激活接口,验证数据库中只有一条 is_active=true 记录
|
||||
- **Expected:** 只有一条 is_active=true
|
||||
- **Why human:** 并发场景需要实际数据库事务,无法静态验证
|
||||
|
||||
---
|
||||
|
||||
### Gaps Summary
|
||||
|
||||
**无 Gap** — Phase 2 所有 14 个需求均已完整实现,代码通过编译,关键模式全部可在代码中验证。
|
||||
|
||||
---
|
||||
|
||||
## Commit Inventory
|
||||
|
||||
| Commit | Message | Requirement |
|
||||
|--------|---------|-------------|
|
||||
| `2a92ab6` | fix(perm): 修复企业账号资产权限边界 PERM-01/02 | PERM-01, PERM-02 |
|
||||
| `8f62496` | fix(fin): 补全提现审批人字段 FIN-01 | FIN-01 |
|
||||
| `9ee2f0b` | fix(fin): 提现拒绝补 remark 必填校验 FIN-02 | FIN-02 |
|
||||
| `bdac4ab` | fix(fin): 激活配置并发行锁保护 FIN-03 | FIN-03 |
|
||||
| `344850f` | fix(realname): DB 迁移 + Model + Store 清理 REALNAME-01 | REALNAME-01 |
|
||||
| `244a274` | fix(realname): DTO 更新 expiry_base 替换 enable_realname_activation REALNAME-05 | REALNAME-05 |
|
||||
| `7dd5563` | fix(realname): activateMainPackage 三维决策重构 REALNAME-02 | REALNAME-02 |
|
||||
| `0ebb3d7` | fix(realname): C 端实名检查改为按卡类型判断 REALNAME-03 | REALNAME-03 |
|
||||
| `60b43bb` | fix(realname): ActivateByRealname 按 ExpiryBase 选择计时基准 REALNAME-04 | REALNAME-04 |
|
||||
| `922a3d0` | fix(poll): polling:protect 接入调度器 POLL-01 | POLL-01 |
|
||||
| `8a53123` | fix(poll): gatewayClient=nil 时停复机返回错误而非成功 POLL-02 | POLL-02 |
|
||||
| `8553a46` | fix(poll): GetPackages 接口新增分页 POLL-03 | POLL-03 |
|
||||
| `47aa5f8` | fix(poll): Series 删除前检查关联套餐 POLL-04 | POLL-04 |
|
||||
| `754bbcd` | fix(fin): 同步更新 openapi handlers 中 CommissionWithdrawalHandler 签名 | FIN-02(偏差修复) |
|
||||
| `4be473c` | fix(realname): 移除 auto_purchase.go 中废弃的 EnableRealnameActivation 引用 | REALNAME-01(偏差修复) |
|
||||
|
||||
所有 commit hash 均在 `git log` 中验证存在。
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-03-28T10:00:00Z_
|
||||
_Verifier: the agent (gsd-verifier)_
|
||||
403
.planning/phases/03-device-system/03-01-PLAN.md
Normal file
403
.planning/phases/03-device-system/03-01-PLAN.md
Normal file
@@ -0,0 +1,403 @@
|
||||
---
|
||||
phase: 03-device-system
|
||||
plan: "01"
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- migrations/000090_device_fields_extension.up.sql
|
||||
- migrations/000090_device_fields_extension.down.sql
|
||||
- migrations/000091_device_sim_binding_is_current.up.sql
|
||||
- migrations/000091_device_sim_binding_is_current.down.sql
|
||||
- internal/model/device.go
|
||||
- internal/model/device_sim_binding.go
|
||||
- internal/gateway/models.go
|
||||
- internal/gateway/device.go
|
||||
- internal/model/dto/device_dto.go
|
||||
- internal/model/dto/asset_dto.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- DEVICE-01
|
||||
- DEVICE-02
|
||||
- DEVICE-03
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "tb_device 表含 online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at 字段"
|
||||
- "tb_device_sim_binding 表含 is_current 字段"
|
||||
- "Gateway Client 具备 SyncDeviceInfo() 方法"
|
||||
- "DeviceResponse DTO 新增 5 个字段;BoundCardInfo + DeviceCardBindingResponse 新增 is_current"
|
||||
- "make migrate-up 执行成功,go build ./... 编译通过"
|
||||
artifacts:
|
||||
- path: "migrations/000090_device_fields_extension.up.sql"
|
||||
provides: "tb_device 字段扩展迁移"
|
||||
- path: "migrations/000091_device_sim_binding_is_current.up.sql"
|
||||
provides: "tb_device_sim_binding.is_current 迁移"
|
||||
- path: "internal/model/device.go"
|
||||
provides: "Device 模型新增 5 字段"
|
||||
- path: "internal/model/device_sim_binding.go"
|
||||
provides: "DeviceSimBinding 模型新增 is_current"
|
||||
- path: "internal/gateway/models.go"
|
||||
provides: "SyncDeviceInfoReq / SyncDeviceInfoResp 结构定义"
|
||||
- path: "internal/gateway/device.go"
|
||||
provides: "SyncDeviceInfo() 方法"
|
||||
- path: "internal/model/dto/device_dto.go"
|
||||
provides: "DeviceResponse + DeviceCardBindingResponse 字段扩展"
|
||||
- path: "internal/model/dto/asset_dto.go"
|
||||
provides: "BoundCardInfo.IsCurrent 字段扩展"
|
||||
key_links:
|
||||
- from: "internal/gateway/device.go:SyncDeviceInfo"
|
||||
to: "internal/gateway/client.go:doRequestWithResponse"
|
||||
via: "泛型调用"
|
||||
pattern: "doRequestWithResponse\\[SyncDeviceInfoResp\\]"
|
||||
- from: "internal/model/device.go:Device"
|
||||
to: "migrations/000090"
|
||||
via: "字段对应迁移"
|
||||
pattern: "OnlineStatus.*online_status"
|
||||
---
|
||||
|
||||
<objective>
|
||||
建立设备体系的数据基础层:扩展 DB 字段(DEVICE-01)、新增 is_current 绑定标识(DEVICE-03)、对接 Gateway sync-info 接口(DEVICE-02)。
|
||||
|
||||
Purpose: Plan 2 的 Refresh 接入依赖本 Plan 的全部产出(模型字段 + Gateway 方法 + DTO 定义)
|
||||
Output: 2 个迁移对、Device/DeviceSimBinding 模型更新、Gateway SyncDeviceInfo 方法、DTO 扩展
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/03-device-system/03-CONTEXT.md
|
||||
|
||||
<interfaces>
|
||||
<!-- 现有代码契约,executor 直接使用,无需探索代码库 -->
|
||||
|
||||
**internal/gateway/device.go(现有方法):**
|
||||
```go
|
||||
func (c *Client) GetDeviceInfo(ctx context.Context, req *DeviceInfoReq) (*DeviceInfoResp, error)
|
||||
func (c *Client) GetSlotInfo(ctx context.Context, req *DeviceInfoReq) (*SlotInfoResp, error)
|
||||
// 其他方法略
|
||||
```
|
||||
|
||||
**internal/gateway/client.go(泛型请求方法):**
|
||||
```go
|
||||
// 签名:
|
||||
func doRequestWithResponse[T any](c *Client, ctx context.Context, path string, params interface{}) (*T, error)
|
||||
// 用法(参考 GetDeviceInfo):
|
||||
return doRequestWithResponse[DeviceInfoResp](c, ctx, "/device/info", req)
|
||||
```
|
||||
|
||||
**internal/gateway/models.go 中已有的设备 DTO(参考风格):**
|
||||
```go
|
||||
type DeviceInfoReq struct {
|
||||
CardNo string `json:"cardNo,omitempty" description:"..."`
|
||||
DeviceID string `json:"deviceId,omitempty" description:"..."`
|
||||
}
|
||||
type DeviceInfoResp struct {
|
||||
IMEI string `json:"imei" description:"..."`
|
||||
OnlineStatus int `json:"onlineStatus" description:"..."`
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
**internal/model/device.go 现有字段(末尾追加新字段):**
|
||||
```go
|
||||
type Device struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
VirtualNo string `gorm:"column:virtual_no;..."`
|
||||
IMEI string `gorm:"column:imei;type:varchar(20);..."`
|
||||
SN string `gorm:"column:sn;type:varchar(100);..."`
|
||||
// ... 其余字段 ...
|
||||
Generation int `gorm:"column:generation;type:int;not null;default:1;..."`
|
||||
// 在 Generation 之后追加 5 个新字段
|
||||
}
|
||||
```
|
||||
|
||||
**internal/model/device_sim_binding.go 现有字段(末尾追加 is_current):**
|
||||
```go
|
||||
type DeviceSimBinding struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
DeviceID uint `gorm:"column:device_id;..."`
|
||||
IotCardID uint `gorm:"column:iot_card_id;..."`
|
||||
SlotPosition int `gorm:"column:slot_position;..."`
|
||||
BindStatus int `gorm:"column:bind_status;..."`
|
||||
BindTime *time.Time `gorm:"column:bind_time;..."`
|
||||
UnbindTime *time.Time `gorm:"column:unbind_time;..."`
|
||||
// 追加 IsCurrent
|
||||
}
|
||||
```
|
||||
|
||||
**internal/model/dto/device_dto.go 中 DeviceResponse 和 DeviceCardBindingResponse:**
|
||||
```go
|
||||
type DeviceResponse struct {
|
||||
// ... 现有字段 ...
|
||||
UpdatedAt time.Time `json:"updated_at" description:"更新时间"`
|
||||
// 在 UpdatedAt 之后追加 5 个新字段
|
||||
}
|
||||
|
||||
type DeviceCardBindingResponse struct {
|
||||
// ... 现有字段(末尾是 BindTime)...
|
||||
BindTime *time.Time `json:"bind_time,omitempty" description:"绑定时间"`
|
||||
// 追加 IsCurrent
|
||||
}
|
||||
```
|
||||
|
||||
**internal/model/dto/asset_dto.go 中 BoundCardInfo:**
|
||||
```go
|
||||
type BoundCardInfo struct {
|
||||
CardID uint `json:"card_id" description:"卡ID"`
|
||||
ICCID string `json:"iccid" description:"ICCID"`
|
||||
MSISDN string `json:"msisdn,omitempty" description:"手机号"`
|
||||
NetworkStatus int `json:"network_status" description:"网络状态:0停机 1开机"`
|
||||
RealNameStatus int `json:"real_name_status" description:"实名状态"`
|
||||
SlotPosition int `json:"slot_position,omitempty" description:"插槽位置"`
|
||||
// 追加 IsCurrent
|
||||
}
|
||||
```
|
||||
|
||||
**Gateway sync-info 接口:**
|
||||
- URL:`POST /v1/iot/openApi/device/sync-info`(业务路径参数传 `/device/sync-info`)
|
||||
- cardNo:ICCID(>15位)/ IMEI(=15位)/ SN(=11位)
|
||||
- 关键响应字段:current_iccid, software_version, switch_mode, online_status, last_online_time(ISO 8601 string), last_update_time
|
||||
|
||||
**最新迁移编号**:`000089_realname_expiry_base`,新迁移从 `000090` 开始
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: DB 迁移 + 模型字段扩展(D-0 + D-2 DB 层)</name>
|
||||
<files>
|
||||
migrations/000090_device_fields_extension.up.sql,
|
||||
migrations/000090_device_fields_extension.down.sql,
|
||||
migrations/000091_device_sim_binding_is_current.up.sql,
|
||||
migrations/000091_device_sim_binding_is_current.down.sql,
|
||||
internal/model/device.go,
|
||||
internal/model/device_sim_binding.go
|
||||
</files>
|
||||
<action>
|
||||
## 迁移文件 000090(tb_device 字段扩展)
|
||||
|
||||
创建 `migrations/000090_device_fields_extension.up.sql`:
|
||||
```sql
|
||||
-- 扩展 tb_device 设备表,新增 Gateway sync-info 同步的存储字段
|
||||
-- 这些字段有"离线意义"(设备下线后仍需保留最后状态),非实时字段(rssi/battery 等)不入库
|
||||
ALTER TABLE tb_device
|
||||
ADD COLUMN online_status INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN last_online_time TIMESTAMP NULL,
|
||||
ADD COLUMN software_version VARCHAR(100) NOT NULL DEFAULT '',
|
||||
ADD COLUMN switch_mode VARCHAR(10) NOT NULL DEFAULT '0',
|
||||
ADD COLUMN last_gateway_sync_at TIMESTAMP NULL;
|
||||
|
||||
COMMENT ON COLUMN tb_device.online_status IS '在线状态:0未知,1在线,2离线';
|
||||
COMMENT ON COLUMN tb_device.last_online_time IS '最后在线时间(来自 Gateway sync-info)';
|
||||
COMMENT ON COLUMN tb_device.software_version IS '固件版本号';
|
||||
COMMENT ON COLUMN tb_device.switch_mode IS '切卡模式:0=自动,1=手动';
|
||||
COMMENT ON COLUMN tb_device.last_gateway_sync_at IS '最后一次 sync-info 同步时间';
|
||||
```
|
||||
|
||||
创建 `migrations/000090_device_fields_extension.down.sql`:
|
||||
```sql
|
||||
ALTER TABLE tb_device
|
||||
DROP COLUMN IF EXISTS online_status,
|
||||
DROP COLUMN IF EXISTS last_online_time,
|
||||
DROP COLUMN IF EXISTS software_version,
|
||||
DROP COLUMN IF EXISTS switch_mode,
|
||||
DROP COLUMN IF EXISTS last_gateway_sync_at;
|
||||
```
|
||||
|
||||
## 迁移文件 000091(tb_device_sim_binding 新增 is_current)
|
||||
|
||||
创建 `migrations/000091_device_sim_binding_is_current.up.sql`:
|
||||
```sql
|
||||
-- 新增 is_current 字段,标识设备当前正在使用的卡(由 sync-info current_iccid 字段决定)
|
||||
ALTER TABLE tb_device_sim_binding
|
||||
ADD COLUMN is_current BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
COMMENT ON COLUMN tb_device_sim_binding.is_current IS '是否为当前使用的卡(同一设备同一时刻最多一条为 true)';
|
||||
```
|
||||
|
||||
创建 `migrations/000091_device_sim_binding_is_current.down.sql`:
|
||||
```sql
|
||||
ALTER TABLE tb_device_sim_binding
|
||||
DROP COLUMN IF EXISTS is_current;
|
||||
```
|
||||
|
||||
## 执行迁移
|
||||
|
||||
执行 `make migrate-up` 并确认:
|
||||
- `make migrate-version` 输出版本为 91,dirty=false
|
||||
- 使用 PostgreSQL MCP 验证 tb_device 新增 5 列、tb_device_sim_binding 新增 is_current
|
||||
|
||||
## 更新 internal/model/device.go
|
||||
|
||||
在 `Generation` 字段之后追加 5 个字段(保持与其他字段相同的标签格式):
|
||||
```go
|
||||
// 以下字段由 Gateway sync-info 接口同步,有离线存储意义
|
||||
OnlineStatus int `gorm:"column:online_status;type:int;not null;default:0;comment:在线状态:0未知 1在线 2离线" json:"online_status"`
|
||||
LastOnlineTime *time.Time `gorm:"column:last_online_time;comment:最后在线时间(来自 Gateway sync-info)" json:"last_online_time,omitempty"`
|
||||
SoftwareVersion string `gorm:"column:software_version;type:varchar(100);not null;default:'';comment:固件版本号" json:"software_version"`
|
||||
SwitchMode string `gorm:"column:switch_mode;type:varchar(10);not null;default:'0';comment:切卡模式:0=自动 1=手动" json:"switch_mode"`
|
||||
LastGatewaySyncAt *time.Time `gorm:"column:last_gateway_sync_at;comment:最后一次 sync-info 同步时间" json:"last_gateway_sync_at,omitempty"`
|
||||
```
|
||||
|
||||
## 更新 internal/model/device_sim_binding.go
|
||||
|
||||
在 `UnbindTime` 字段之后追加:
|
||||
```go
|
||||
IsCurrent bool `gorm:"column:is_current;type:boolean;not null;default:false;comment:是否为当前使用的卡" json:"is_current"`
|
||||
```
|
||||
</action>
|
||||
<verify>
|
||||
<automated>make migrate-up && make migrate-version && go build ./...</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- migrate-version 显示版本 91,dirty=false
|
||||
- go build ./... 编译无新增错误
|
||||
- tb_device 含 5 个新字段,tb_device_sim_binding 含 is_current(可用 MCP dbhub_search_objects 验证)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Gateway SyncDeviceInfo 方法 + DTO 字段扩展(D-1 + D-2 DTO 层)</name>
|
||||
<files>
|
||||
internal/gateway/models.go,
|
||||
internal/gateway/device.go,
|
||||
internal/model/dto/device_dto.go,
|
||||
internal/model/dto/asset_dto.go
|
||||
</files>
|
||||
<action>
|
||||
## internal/gateway/models.go — 新增 SyncDeviceInfo 请求/响应结构
|
||||
|
||||
在文件末尾(`// ============ 结束 ============` 之前或文件末)追加,放在「设备相关 DTO」分组下:
|
||||
|
||||
```go
|
||||
// SyncDeviceInfoReq sync-info 同步查询设备信息请求
|
||||
// cardNo 规则:>15 位用 ICCID,=15 位用 IMEI,=11 位用 SN
|
||||
type SyncDeviceInfoReq struct {
|
||||
CardNo string `json:"card_no" description:"设备标识(ICCID/IMEI/SN)"`
|
||||
}
|
||||
|
||||
// SyncDeviceInfoResp sync-info 同步查询设备信息响应(对应 Gateway data 字段)
|
||||
// 注意:所有字段均可能为 null/零值,表示暂无数据
|
||||
type SyncDeviceInfoResp struct {
|
||||
DeviceID string `json:"device_id" description:"设备ID(IMEI/SN)"`
|
||||
DeviceName string `json:"device_name" description:"设备名称"`
|
||||
IMEI string `json:"imei" description:"IMEI号"`
|
||||
CurrentIccid string `json:"current_iccid" description:"当前使用的ICCID"`
|
||||
DeviceType string `json:"device_type" description:"设备类型:1=诺行,2=玺龙,3=迎势达"`
|
||||
SoftwareVersion string `json:"software_version" description:"软件版本号"`
|
||||
MacAddress string `json:"mac_address" description:"MAC地址"`
|
||||
SSID string `json:"ssid" description:"WiFi热点名称"`
|
||||
WifiEnabled bool `json:"wifi_enabled" description:"WiFi开关状态"`
|
||||
SwitchMode string `json:"switch_mode" description:"切卡模式:0=自动,1=手动"`
|
||||
RSSI string `json:"rssi" description:"接收信号强度"`
|
||||
BatteryLevel *int `json:"battery_level" description:"电池电量百分比(无电池时为null)"`
|
||||
OnlineStatus int `json:"online_status" description:"在线状态:1=在线,2=离线"`
|
||||
LastUpdateTime string `json:"last_update_time" description:"设备信息最后更新时间(ISO 8601)"`
|
||||
LastOnlineTime string `json:"last_online_time" description:"设备最后在线时间(ISO 8601)"`
|
||||
RunTime string `json:"run_time" description:"本次开机运行时间(秒)"`
|
||||
DailyUsage string `json:"daily_usage" description:"日使用流量(字节)"`
|
||||
}
|
||||
```
|
||||
|
||||
## internal/gateway/device.go — 新增 SyncDeviceInfo 方法
|
||||
|
||||
在文件末尾追加:
|
||||
|
||||
```go
|
||||
// SyncDeviceInfo 同步查询设备信息(直接返回,无需回调)
|
||||
// 与异步 /device/info 的区别:直接返回全量数据,无需 callbackUrl
|
||||
// POST /device/sync-info
|
||||
func (c *Client) SyncDeviceInfo(ctx context.Context, req *SyncDeviceInfoReq) (*SyncDeviceInfoResp, error) {
|
||||
if req.CardNo == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "cardNo 不能为空")
|
||||
}
|
||||
return doRequestWithResponse[SyncDeviceInfoResp](c, ctx, "/device/sync-info", req)
|
||||
}
|
||||
```
|
||||
|
||||
更新文件顶部包注释:「提供设备相关的 8 个 API 方法封装」(从 7 改为 8)
|
||||
|
||||
## internal/model/dto/device_dto.go — DeviceResponse 和 DeviceCardBindingResponse 字段扩展
|
||||
|
||||
**DeviceResponse** — 在 `UpdatedAt` 字段之后追加(per D-0):
|
||||
```go
|
||||
// 设备实时同步字段(由 sync-info 接口写入)
|
||||
OnlineStatus int `json:"online_status" description:"在线状态:0未知 1在线 2离线"`
|
||||
LastOnlineTime *time.Time `json:"last_online_time,omitempty" description:"最后在线时间"`
|
||||
SoftwareVersion string `json:"software_version" description:"固件版本号"`
|
||||
SwitchMode string `json:"switch_mode" description:"切卡模式:0=自动,1=手动"`
|
||||
LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at,omitempty" description:"最后 sync-info 同步时间"`
|
||||
```
|
||||
|
||||
**DeviceCardBindingResponse** — 在 `BindTime` 字段之后追加(per D-2):
|
||||
```go
|
||||
IsCurrent bool `json:"is_current" description:"是否为当前使用的卡"`
|
||||
```
|
||||
|
||||
## internal/model/dto/asset_dto.go — BoundCardInfo 字段扩展
|
||||
|
||||
在 `SlotPosition` 字段之后追加(per D-2,D-11 决策):
|
||||
```go
|
||||
IsCurrent bool `json:"is_current" description:"是否为当前使用的卡"`
|
||||
```
|
||||
|
||||
## 验证
|
||||
|
||||
执行 `go build ./...`,确认无编译错误。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./...</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- SyncDeviceInfoReq / SyncDeviceInfoResp 在 internal/gateway/models.go 中定义
|
||||
- SyncDeviceInfo() 方法在 internal/gateway/device.go 中实现
|
||||
- DeviceResponse 含 5 个新字段,DeviceCardBindingResponse 含 is_current
|
||||
- BoundCardInfo 含 is_current
|
||||
- go build ./... 零新增错误
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
# 1. 迁移正常
|
||||
make migrate-version
|
||||
# 期望:version=91, dirty=false
|
||||
|
||||
# 2. 编译通过
|
||||
go build ./...
|
||||
|
||||
# 3. 字段验证(通过 PostgreSQL MCP)
|
||||
# dbhub_search_objects: object_type=column, table=tb_device
|
||||
# 确认:online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at
|
||||
# dbhub_search_objects: object_type=column, table=tb_device_sim_binding
|
||||
# 确认:is_current
|
||||
|
||||
# 4. Gateway 方法搜索
|
||||
grep -r "SyncDeviceInfo" internal/gateway/
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] `make migrate-version` 输出 version=91, dirty=false
|
||||
- [ ] `go build ./...` 零新增编译错误
|
||||
- [ ] `internal/gateway/device.go` 含 `SyncDeviceInfo()` 方法
|
||||
- [ ] `internal/gateway/models.go` 含 `SyncDeviceInfoReq` / `SyncDeviceInfoResp`
|
||||
- [ ] `DeviceResponse` 含 5 个新字段,`DeviceCardBindingResponse` 含 `is_current`
|
||||
- [ ] `BoundCardInfo` 含 `is_current`
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
完成后创建 `.planning/phases/03-device-system/03-01-SUMMARY.md`
|
||||
</output>
|
||||
93
.planning/phases/03-device-system/03-01-SUMMARY.md
Normal file
93
.planning/phases/03-device-system/03-01-SUMMARY.md
Normal file
@@ -0,0 +1,93 @@
|
||||
---
|
||||
phase: 03-device-system
|
||||
plan: "01"
|
||||
subsystem: device
|
||||
tags: [migration, model, gateway, dto]
|
||||
dependency_graph:
|
||||
requires: []
|
||||
provides:
|
||||
- tb_device 5个 sync-info 存储字段
|
||||
- tb_device_sim_binding.is_current 字段
|
||||
- Gateway SyncDeviceInfo() 方法
|
||||
- DeviceResponse 5个新字段
|
||||
- DeviceCardBindingResponse.IsCurrent
|
||||
- BoundCardInfo.IsCurrent
|
||||
affects:
|
||||
- Plan 03-02(依赖本 Plan 全部产出:模型字段 + Gateway 方法 + DTO 定义)
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- golang-migrate 迁移文件(up/down 对)
|
||||
- GORM 模型字段追加(保持 gorm tag 格式一致性)
|
||||
- Gateway 泛型 doRequestWithResponse[T] 调用模式
|
||||
key_files:
|
||||
created:
|
||||
- migrations/000090_device_fields_extension.up.sql
|
||||
- migrations/000090_device_fields_extension.down.sql
|
||||
- migrations/000091_device_sim_binding_is_current.up.sql
|
||||
- migrations/000091_device_sim_binding_is_current.down.sql
|
||||
modified:
|
||||
- internal/model/device.go
|
||||
- internal/model/device_sim_binding.go
|
||||
- internal/gateway/models.go
|
||||
- internal/gateway/device.go
|
||||
- internal/model/dto/device_dto.go
|
||||
- internal/model/dto/asset_dto.go
|
||||
decisions:
|
||||
- "5 个 sync-info 字段有离线存储意义(设备下线后保留最后状态),非实时字段(rssi/battery)不入库"
|
||||
- "SyncDeviceInfo 使用同步 doRequestWithResponse[T] 泛型调用,与现有 GetDeviceInfo 区分(async vs sync)"
|
||||
- "is_current 字段位于 tb_device_sim_binding,由 sync-info current_iccid 决定"
|
||||
metrics:
|
||||
duration: "8min"
|
||||
completed_date: "2026-03-28"
|
||||
tasks: 2
|
||||
files: 10
|
||||
---
|
||||
|
||||
# Phase 03 Plan 01: 设备体系数据基础层 Summary
|
||||
|
||||
**一句话总结:** 新增 DB 迁移扩展 tb_device(5字段)和 tb_device_sim_binding(is_current),对接 Gateway sync-info 接口,并同步扩展 Device/DeviceSimBinding 模型及 DTO。
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | DB 迁移 + 模型字段扩展(D-0 + D-2 DB 层) | `42b884a` | 4 迁移文件 + 2 模型文件 |
|
||||
| 2 | Gateway SyncDeviceInfo 方法 + DTO 字段扩展(D-1 + D-2 DTO 层) | `825def8` | gateway/models.go, gateway/device.go, device_dto.go, asset_dto.go |
|
||||
|
||||
## Artifacts Delivered
|
||||
|
||||
### 数据库迁移
|
||||
|
||||
- **000090_device_fields_extension**:tb_device 新增 `online_status`(INT DEFAULT 0)、`last_online_time`(TIMESTAMP NULL)、`software_version`(VARCHAR(100) DEFAULT '')、`switch_mode`(VARCHAR(10) DEFAULT '0')、`last_gateway_sync_at`(TIMESTAMP NULL)
|
||||
- **000091_device_sim_binding_is_current**:tb_device_sim_binding 新增 `is_current`(BOOLEAN DEFAULT FALSE)
|
||||
|
||||
### 模型更新
|
||||
|
||||
- **Device 模型**:追加 5 个字段(OnlineStatus / LastOnlineTime / SoftwareVersion / SwitchMode / LastGatewaySyncAt)
|
||||
- **DeviceSimBinding 模型**:追加 IsCurrent 字段
|
||||
|
||||
### Gateway 层
|
||||
|
||||
- **SyncDeviceInfoReq / SyncDeviceInfoResp**:18 字段的完整响应结构,支持 current_iccid / online_status / software_version / switch_mode / last_online_time 等关键字段
|
||||
- **SyncDeviceInfo() 方法**:POST /device/sync-info,同步返回全量数据(区别于异步 /device/info)
|
||||
|
||||
### DTO 扩展
|
||||
|
||||
- **DeviceResponse**:新增 5 个 sync-info 字段(与模型一一对应)
|
||||
- **DeviceCardBindingResponse**:新增 `IsCurrent bool`
|
||||
- **BoundCardInfo**:新增 `IsCurrent bool`
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — 计划完全按预期执行。
|
||||
|
||||
## Verification
|
||||
|
||||
- ✅ `make migrate-version` 输出版本 91,dirty=false
|
||||
- ✅ `go build ./...` 零新增编译错误
|
||||
- ✅ PostgreSQL MCP 验证 tb_device 含 5 个新列
|
||||
- ✅ PostgreSQL MCP 验证 tb_device_sim_binding 含 is_current
|
||||
- ✅ `SyncDeviceInfo` 方法在 internal/gateway/device.go 中实现
|
||||
|
||||
## Self-Check: PASSED
|
||||
405
.planning/phases/03-device-system/03-02-PLAN.md
Normal file
405
.planning/phases/03-device-system/03-02-PLAN.md
Normal file
@@ -0,0 +1,405 @@
|
||||
---
|
||||
phase: 03-device-system
|
||||
plan: "02"
|
||||
type: execute
|
||||
wave: 2
|
||||
depends_on:
|
||||
- "03-01"
|
||||
files_modified:
|
||||
- internal/service/asset/service.go
|
||||
- internal/bootstrap/services.go
|
||||
- internal/store/postgres/device_sim_binding_store.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- DEVICE-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "调用设备刷新接口后,设备的在线状态、固件版本、当前使用卡从 Gateway 实时更新"
|
||||
- "同一设备同一时刻只有一条 is_current=true 的绑定记录(事务原子更新)"
|
||||
- "sync-info 调用失败时不阻断刷新流程,仅记录 Warn 日志"
|
||||
- "go build ./... 编译通过"
|
||||
artifacts:
|
||||
- path: "internal/service/asset/service.go"
|
||||
provides: "updateDeviceFromSyncInfo 私有函数 + Refresh device 分支 sync-info 调用"
|
||||
contains: "updateDeviceFromSyncInfo"
|
||||
- path: "internal/bootstrap/services.go"
|
||||
provides: "asset.Service 注入 gatewayClient"
|
||||
- path: "internal/store/postgres/device_sim_binding_store.go"
|
||||
provides: "UpdateIsCurrentByDeviceID Store 方法(事务两步更新)"
|
||||
key_links:
|
||||
- from: "internal/service/asset/service.go:Refresh"
|
||||
to: "internal/gateway/device.go:SyncDeviceInfo"
|
||||
via: "s.gatewayClient.SyncDeviceInfo()"
|
||||
pattern: "gatewayClient\\.SyncDeviceInfo"
|
||||
- from: "internal/service/asset/service.go:updateDeviceFromSyncInfo"
|
||||
to: "internal/store/postgres/device_sim_binding_store.go:UpdateIsCurrentByDeviceID"
|
||||
via: "事务原子更新 is_current"
|
||||
pattern: "UpdateIsCurrentByDeviceID"
|
||||
---
|
||||
|
||||
<objective>
|
||||
将 Gateway sync-info 接口接入设备 Refresh 流程(DEVICE-04):在设备刷新完成绑定卡后,调用 SyncDeviceInfo 同步设备自身状态(在线状态、固件版本、is_current 当前卡标识)。
|
||||
|
||||
Purpose: 使设备详情页展示真实的 online_status、software_version、is_current 等字段(Phase 3 成功标准 2、4)
|
||||
Output: updateDeviceFromSyncInfo 私有函数 + Refresh device 分支追加 sync-info 调用
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/STATE.md
|
||||
@.planning/phases/03-device-system/03-CONTEXT.md
|
||||
@.planning/phases/03-device-system/03-01-SUMMARY.md
|
||||
|
||||
<interfaces>
|
||||
<!-- 本 Plan 依赖 Plan 01 产出的接口,executor 直接使用 -->
|
||||
|
||||
**Plan 01 产出的类型(internal/gateway/models.go):**
|
||||
```go
|
||||
type SyncDeviceInfoReq struct {
|
||||
CardNo string `json:"card_no"`
|
||||
}
|
||||
type SyncDeviceInfoResp struct {
|
||||
CurrentIccid string `json:"current_iccid"`
|
||||
SoftwareVersion string `json:"software_version"`
|
||||
SwitchMode string `json:"switch_mode"`
|
||||
OnlineStatus int `json:"online_status"` // 1=在线, 2=离线
|
||||
LastOnlineTime string `json:"last_online_time"` // ISO 8601 字符串
|
||||
LastUpdateTime string `json:"last_update_time"` // ISO 8601 字符串
|
||||
// ... 其他字段
|
||||
}
|
||||
```
|
||||
|
||||
**Plan 01 产出的方法(internal/gateway/device.go):**
|
||||
```go
|
||||
func (c *Client) SyncDeviceInfo(ctx context.Context, req *SyncDeviceInfoReq) (*SyncDeviceInfoResp, error)
|
||||
```
|
||||
|
||||
**Plan 01 产出的模型字段(internal/model/device.go):**
|
||||
```go
|
||||
OnlineStatus int // gorm:"column:online_status"
|
||||
LastOnlineTime *time.Time // gorm:"column:last_online_time"
|
||||
SoftwareVersion string // gorm:"column:software_version"
|
||||
SwitchMode string // gorm:"column:switch_mode"
|
||||
LastGatewaySyncAt *time.Time // gorm:"column:last_gateway_sync_at"
|
||||
```
|
||||
|
||||
**Plan 01 产出的模型字段(internal/model/device_sim_binding.go):**
|
||||
```go
|
||||
IsCurrent bool // gorm:"column:is_current"
|
||||
```
|
||||
|
||||
**internal/service/asset/service.go 现有 Refresh device 分支(约 295-340 行):**
|
||||
```go
|
||||
case "device":
|
||||
// 检查冷却期
|
||||
cooldownKey := constants.RedisDeviceRefreshCooldownKey(id)
|
||||
if s.redis.Exists(ctx, cooldownKey).Val() > 0 {
|
||||
return nil, errors.New(errors.CodeTooManyRequests, "刷新过于频繁,请30秒后再试")
|
||||
}
|
||||
|
||||
// 查所有绑定卡,逐一刷新
|
||||
bindings, err := s.deviceSimBindingStore.ListByDeviceID(ctx, id)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(errors.CodeInternalError, err, "查询绑定卡失败")
|
||||
}
|
||||
for _, b := range bindings {
|
||||
card, cardErr := s.iotCardStore.GetByID(ctx, b.IotCardID)
|
||||
if cardErr != nil {
|
||||
logger.GetAppLogger().Warn("刷新设备绑定卡失败:查卡失败", ...)
|
||||
continue
|
||||
}
|
||||
if refreshErr := s.iotCardService.RefreshCardDataFromGateway(ctx, card.ICCID); refreshErr != nil {
|
||||
logger.GetAppLogger().Warn("刷新设备绑定卡失败:网关调用失败", ...)
|
||||
}
|
||||
}
|
||||
|
||||
// 设置冷却 Key
|
||||
s.redis.Set(ctx, cooldownKey, 1, constants.DeviceRefreshCooldownDuration)
|
||||
|
||||
return s.GetRealtimeStatus(ctx, "device", id)
|
||||
```
|
||||
|
||||
**internal/service/asset/service.go 现有 Service 结构体(需要追加 gatewayClient):**
|
||||
```go
|
||||
type Service struct {
|
||||
db *gorm.DB
|
||||
deviceStore *postgres.DeviceStore
|
||||
iotCardStore *postgres.IotCardStore
|
||||
packageUsageStore *postgres.PackageUsageStore
|
||||
packageStore *postgres.PackageStore
|
||||
packageSeriesStore *postgres.PackageSeriesStore
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||
shopStore *postgres.ShopStore
|
||||
redis *redis.Client
|
||||
iotCardService IotCardRefresher
|
||||
// 追加:
|
||||
// gatewayClient *gateway.Client
|
||||
}
|
||||
|
||||
// New() 函数目前签名(需要追加 gatewayClient 参数):
|
||||
func New(
|
||||
db *gorm.DB,
|
||||
deviceStore *postgres.DeviceStore,
|
||||
iotCardStore *postgres.IotCardStore,
|
||||
packageUsageStore *postgres.PackageUsageStore,
|
||||
packageStore *postgres.PackageStore,
|
||||
packageSeriesStore *postgres.PackageSeriesStore,
|
||||
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||||
shopStore *postgres.ShopStore,
|
||||
redisClient *redis.Client,
|
||||
iotCardService IotCardRefresher,
|
||||
) *Service
|
||||
```
|
||||
|
||||
**internal/bootstrap/services.go 现有 asset.New 调用(需要追加 deps.GatewayClient):**
|
||||
```go
|
||||
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard),
|
||||
```
|
||||
|
||||
**内置 import 路径参考:**
|
||||
- `"github.com/break/junhong_cmp_fiber/internal/gateway"`
|
||||
- `"time"` 包用于 Parse ISO 8601 字符串
|
||||
|
||||
**ISO 8601 时间格式参考(来自 Gateway 文档示例):**
|
||||
- `last_online_time: "2026-03-23T16:53:18+08:00"`
|
||||
- Parse 用 `time.Parse(time.RFC3339, str)` 即可处理带时区的 ISO 8601
|
||||
|
||||
**DeviceSimBindingStore 现有方法(store 层,需新增 UpdateIsCurrentByDeviceID):**
|
||||
```go
|
||||
func (s *DeviceSimBindingStore) ListByDeviceID(ctx context.Context, deviceID uint) ([]*model.DeviceSimBinding, error)
|
||||
// 其他方法:Create, GetByID, GetByDeviceAndCard, Unbind, CountByDeviceID...
|
||||
// 无 UpdateIsCurrentByDeviceID,需要新增
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: DeviceSimBindingStore 新增 UpdateIsCurrentByDeviceID 方法</name>
|
||||
<files>
|
||||
internal/store/postgres/device_sim_binding_store.go
|
||||
</files>
|
||||
<action>
|
||||
在 `internal/store/postgres/device_sim_binding_store.go` 末尾追加以下方法:
|
||||
|
||||
```go
|
||||
// UpdateIsCurrentByDeviceID 原子更新设备当前使用的卡标识
|
||||
// 使用事务两步操作:先全部设为 false,再将 currentIccid 对应行设为 true
|
||||
// currentIccid 为空字符串时,仅执行全部清空(无当前卡的情况)
|
||||
func (s *DeviceSimBindingStore) UpdateIsCurrentByDeviceID(ctx context.Context, deviceID uint, currentIccid string) error {
|
||||
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 第一步:将该设备所有活跃绑定的 is_current 设为 false
|
||||
if err := tx.Model(&model.DeviceSimBinding{}).
|
||||
Where("device_id = ? AND bind_status = 1", deviceID).
|
||||
Update("is_current", false).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 第二步:将当前 ICCID 对应的绑定记录设为 is_current = true
|
||||
if currentIccid == "" {
|
||||
return nil
|
||||
}
|
||||
// 通过子查询找到对应的 iot_card_id(避免跨表 JOIN 更新)
|
||||
var iotCardID uint
|
||||
if err := tx.Table("tb_iot_card").
|
||||
Select("id").
|
||||
Where("iccid = ? AND deleted_at IS NULL", currentIccid).
|
||||
Scan(&iotCardID).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if iotCardID == 0 {
|
||||
// 未找到对应卡记录,不更新(可能卡尚未入库)
|
||||
return nil
|
||||
}
|
||||
return tx.Model(&model.DeviceSimBinding{}).
|
||||
Where("device_id = ? AND iot_card_id = ? AND bind_status = 1", deviceID, iotCardID).
|
||||
Update("is_current", true).Error
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
确保文件顶部已有 `"gorm.io/gorm"` import(已有,无需新增)。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./internal/store/postgres/...</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- `UpdateIsCurrentByDeviceID` 方法存在于 device_sim_binding_store.go
|
||||
- 事务内两步操作:先全清 false,再按 ICCID 设 true
|
||||
- go build 编译通过
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: Asset Service 注入 gatewayClient + 实现 updateDeviceFromSyncInfo + Refresh 接入 sync-info</name>
|
||||
<files>
|
||||
internal/service/asset/service.go,
|
||||
internal/bootstrap/services.go
|
||||
</files>
|
||||
<action>
|
||||
## 步骤 1:internal/service/asset/service.go — 结构体 + 构造函数 + Refresh 分支 + 新函数
|
||||
|
||||
**① import 块追加**(在现有 import 中添加):
|
||||
```go
|
||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||
"time"
|
||||
```
|
||||
注意:`time` 可能已有,检查后选择性添加。
|
||||
|
||||
**② Service 结构体追加字段**(per D-13,D-15 决策):
|
||||
在 `iotCardService IotCardRefresher` 行之后追加:
|
||||
```go
|
||||
gatewayClient *gateway.Client // 用于调用 sync-info 同步设备信息(可为 nil,失败时仅记录日志)
|
||||
```
|
||||
|
||||
**③ New() 函数签名追加参数**:
|
||||
在 `iotCardService IotCardRefresher,` 参数之后追加:
|
||||
```go
|
||||
gatewayClient *gateway.Client,
|
||||
```
|
||||
并在 return 的结构体字面量中追加:
|
||||
```go
|
||||
gatewayClient: gatewayClient,
|
||||
```
|
||||
|
||||
**④ Refresh device 分支追加 sync-info 调用**(per D-3,D-15 决策):
|
||||
在 `s.redis.Set(ctx, cooldownKey, 1, constants.DeviceRefreshCooldownDuration)` 之后、`return s.GetRealtimeStatus(ctx, "device", id)` 之前插入:
|
||||
|
||||
```go
|
||||
// 刷新设备自身信息(在线状态、当前卡、固件版本等),失败不阻断刷新流程(per D-15)
|
||||
if s.gatewayClient != nil {
|
||||
device, devErr := s.deviceStore.GetByID(ctx, id)
|
||||
if devErr == nil {
|
||||
// IMEI 优先,无则用 SN(per D-3 注释说明)
|
||||
cardNo := device.IMEI
|
||||
if cardNo == "" {
|
||||
cardNo = device.SN
|
||||
}
|
||||
if cardNo != "" {
|
||||
if syncResp, syncErr := s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{
|
||||
CardNo: cardNo,
|
||||
}); syncErr == nil {
|
||||
s.updateDeviceFromSyncInfo(ctx, id, syncResp)
|
||||
} else {
|
||||
logger.GetAppLogger().Warn("sync-info 调用失败",
|
||||
zap.Uint("device_id", id),
|
||||
zap.Error(syncErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**⑤ 新增 updateDeviceFromSyncInfo 私有函数**(per D-13,D-14 决策):
|
||||
在 `Refresh` 函数之后追加:
|
||||
|
||||
```go
|
||||
// updateDeviceFromSyncInfo 根据 Gateway sync-info 响应更新设备状态字段和当前卡标识
|
||||
// 失败时只记录日志,不返回错误(非关键步骤)
|
||||
func (s *Service) updateDeviceFromSyncInfo(ctx context.Context, deviceID uint, resp *gateway.SyncDeviceInfoResp) {
|
||||
now := time.Now()
|
||||
|
||||
// 解析 LastOnlineTime(ISO 8601 字符串 → *time.Time)
|
||||
var lastOnlineTime *time.Time
|
||||
if resp.LastOnlineTime != "" {
|
||||
if t, err := time.Parse(time.RFC3339, resp.LastOnlineTime); err == nil {
|
||||
lastOnlineTime = &t
|
||||
}
|
||||
}
|
||||
|
||||
// 更新设备 5 个存储字段
|
||||
updates := map[string]any{
|
||||
"online_status": resp.OnlineStatus,
|
||||
"software_version": resp.SoftwareVersion,
|
||||
"switch_mode": resp.SwitchMode,
|
||||
"last_gateway_sync_at": now,
|
||||
}
|
||||
if lastOnlineTime != nil {
|
||||
updates["last_online_time"] = lastOnlineTime
|
||||
}
|
||||
|
||||
if err := s.db.WithContext(ctx).
|
||||
Model(&model.Device{}).
|
||||
Where("id = ?", deviceID).
|
||||
Updates(updates).Error; err != nil {
|
||||
logger.GetAppLogger().Warn("sync-info 更新设备字段失败",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
// 更新 is_current 标识(事务原子操作,per D-14 决策)
|
||||
if err := s.deviceSimBindingStore.UpdateIsCurrentByDeviceID(ctx, deviceID, resp.CurrentIccid); err != nil {
|
||||
logger.GetAppLogger().Warn("sync-info 更新 is_current 失败",
|
||||
zap.Uint("device_id", deviceID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 步骤 2:internal/bootstrap/services.go — Asset.New 追加 deps.GatewayClient
|
||||
|
||||
找到以下行:
|
||||
```go
|
||||
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard),
|
||||
```
|
||||
|
||||
替换为:
|
||||
```go
|
||||
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard, deps.GatewayClient),
|
||||
```
|
||||
|
||||
## 最终验证
|
||||
|
||||
执行 `go build ./...` 确认编译无错误。
|
||||
</action>
|
||||
<verify>
|
||||
<automated>go build ./...</automated>
|
||||
</verify>
|
||||
<done>
|
||||
- `asset.Service` 含 `gatewayClient *gateway.Client` 字段
|
||||
- `asset.New()` 新增 `gatewayClient` 参数,bootstrap 调用处已追加 `deps.GatewayClient`
|
||||
- `updateDeviceFromSyncInfo` 函数存在:更新 5 个 device 字段 + 调用 UpdateIsCurrentByDeviceID
|
||||
- `Refresh` device 分支在绑定卡刷新后追加 sync-info 调用(nil guard + Warn 日志)
|
||||
- `go build ./...` 零新增编译错误
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
# 1. 编译验证
|
||||
go build ./...
|
||||
|
||||
# 2. Gateway 方法确认
|
||||
grep -n "SyncDeviceInfo\|updateDeviceFromSyncInfo" internal/service/asset/service.go
|
||||
|
||||
# 3. Store 方法确认
|
||||
grep -n "UpdateIsCurrentByDeviceID" internal/store/postgres/device_sim_binding_store.go
|
||||
|
||||
# 4. Bootstrap 注入确认
|
||||
grep -n "GatewayClient" internal/bootstrap/services.go | grep -i asset
|
||||
```
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- [ ] `go build ./...` 零新增编译错误
|
||||
- [ ] `internal/service/asset/service.go` 含 `updateDeviceFromSyncInfo` 函数
|
||||
- [ ] `internal/service/asset/service.go` Refresh device 分支含 `gatewayClient.SyncDeviceInfo()` 调用
|
||||
- [ ] `internal/store/postgres/device_sim_binding_store.go` 含 `UpdateIsCurrentByDeviceID` 方法
|
||||
- [ ] `internal/bootstrap/services.go` asset.New() 调用传入了 `deps.GatewayClient`
|
||||
- [ ] sync-info 失败时只记录 Warn,不影响 Refresh 主流程
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
完成后创建 `.planning/phases/03-device-system/03-02-SUMMARY.md`
|
||||
</output>
|
||||
83
.planning/phases/03-device-system/03-02-SUMMARY.md
Normal file
83
.planning/phases/03-device-system/03-02-SUMMARY.md
Normal file
@@ -0,0 +1,83 @@
|
||||
---
|
||||
phase: 03-device-system
|
||||
plan: "02"
|
||||
subsystem: device
|
||||
tags: [service, gateway, sync-info, store, bootstrap]
|
||||
dependency_graph:
|
||||
requires:
|
||||
- Plan 03-01(tb_device 5字段 + tb_device_sim_binding.is_current + Gateway SyncDeviceInfo 方法)
|
||||
provides:
|
||||
- DeviceSimBindingStore.UpdateIsCurrentByDeviceID(事务原子更新 is_current)
|
||||
- asset.Service.updateDeviceFromSyncInfo(私有函数,同步 5 个设备字段 + is_current)
|
||||
- asset.Service.Refresh device 分支接入 SyncDeviceInfo(nil guard + Warn 日志)
|
||||
- asset.Service 注入 gatewayClient
|
||||
affects:
|
||||
- DEVICE-04 需求(设备 Refresh + 详情接入 sync-info)
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- GORM Transaction 两步原子更新模式
|
||||
- Service nil guard 可选依赖注入模式
|
||||
- ISO 8601 字符串 → time.Time(time.RFC3339)
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- internal/store/postgres/device_sim_binding_store.go
|
||||
- internal/service/asset/service.go
|
||||
- internal/bootstrap/services.go
|
||||
decisions:
|
||||
- "gatewayClient 使用 nil guard(而非断言非 nil),允许 bootstrap 传入 nil 依然安全运行"
|
||||
- "sync-info 失败只记 Warn 日志,不阻断 Refresh 主流程(非关键步骤,D-15 决策)"
|
||||
- "UpdateIsCurrentByDeviceID 事务两步:先全清 false,再按 ICCID 设 true(原子保证唯一 is_current)"
|
||||
metrics:
|
||||
duration: "10min"
|
||||
completed_date: "2026-03-28"
|
||||
tasks: 2
|
||||
files: 3
|
||||
---
|
||||
|
||||
# Phase 03 Plan 02: Gateway sync-info 接入 Refresh 流程 Summary
|
||||
|
||||
**一句话总结:** 在设备 Refresh 流程末尾接入 Gateway SyncDeviceInfo,通过 updateDeviceFromSyncInfo 原子更新在线状态、固件版本等 5 个设备字段及 is_current 当前卡标识(DEVICE-04)。
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 1 | DeviceSimBindingStore 新增 UpdateIsCurrentByDeviceID 方法 | `ba1886c` | device_sim_binding_store.go |
|
||||
| 2 | Asset Service 注入 gatewayClient + 实现 updateDeviceFromSyncInfo + Refresh 接入 sync-info | `15dbf8d` | service.go, services.go |
|
||||
|
||||
## Artifacts Delivered
|
||||
|
||||
### Store 层
|
||||
|
||||
- **UpdateIsCurrentByDeviceID**:事务两步原子更新。第一步将设备所有活跃绑定的 `is_current` 清为 false;第二步通过子查询定位 `current_iccid` 对应的 `iot_card_id`,将该绑定设为 `is_current = true`。`currentIccid` 为空时仅清空。
|
||||
|
||||
### Service 层
|
||||
|
||||
- **Service 结构体**:新增 `gatewayClient *gateway.Client` 字段(允许 nil)
|
||||
- **New() 构造函数**:新增 `gatewayClient *gateway.Client` 参数
|
||||
- **Refresh device 分支**:在设置冷却 Key 之后、`GetRealtimeStatus` 返回之前,增加 sync-info 调用逻辑(nil guard 保护,失败只记 Warn 日志)
|
||||
- **updateDeviceFromSyncInfo 私有函数**:
|
||||
1. 解析 `last_online_time`(ISO 8601 → `*time.Time`,`time.RFC3339`)
|
||||
2. GORM Updates 更新 5 个字段:`online_status`、`software_version`、`switch_mode`、`last_gateway_sync_at`、`last_online_time`(非空时)
|
||||
3. 调用 `UpdateIsCurrentByDeviceID` 原子更新 `is_current`
|
||||
|
||||
### Bootstrap 层
|
||||
|
||||
- **services.go**:`asset.New()` 调用末尾追加 `deps.GatewayClient` 参数
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
None — 计划完全按预期执行。
|
||||
|
||||
## Verification
|
||||
|
||||
- ✅ `go build ./...` 零新增编译错误
|
||||
- ✅ `updateDeviceFromSyncInfo` 方法存在于 `internal/service/asset/service.go`
|
||||
- ✅ `Refresh` device 分支含 `gatewayClient.SyncDeviceInfo()` 调用(nil guard + Warn 日志)
|
||||
- ✅ `UpdateIsCurrentByDeviceID` 存在于 `internal/store/postgres/device_sim_binding_store.go`
|
||||
- ✅ `internal/bootstrap/services.go` asset.New() 传入了 `deps.GatewayClient`
|
||||
- ✅ sync-info 失败时只记录 Warn,不影响 Refresh 主流程
|
||||
|
||||
## Self-Check: PASSED
|
||||
132
.planning/phases/03-device-system/03-CONTEXT.md
Normal file
132
.planning/phases/03-device-system/03-CONTEXT.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Phase 3: 设备体系完善 - Context
|
||||
|
||||
**Gathered:** 2026-03-28
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
扩展设备数据模型(D-0)、对接 Gateway sync-info 同步接口(D-1)、建立"当前使用卡"标识(D-2)、将刷新接口接入 sync-info 完成端到端数据更新(D-3)。
|
||||
|
||||
全部为修复性/完善性变更,无新业务模块。仅影响设备相关模型、Gateway 客户端、资产刷新服务。
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Plan 分组策略
|
||||
|
||||
- **D-01:** 拆分为 **2 个 Plan**:
|
||||
- **Plan 1(基础层)**:D-0(迁移+模型+DTO)+ D-1(Gateway SyncDeviceInfo 方法)+ D-2(is_current 字段)
|
||||
- **Plan 2(功能层)**:D-3(Refresh 接入 sync-info + updateDeviceFromSyncInfo)
|
||||
- **D-02:** Plan 1 内部执行顺序:D-0 先行(DB 迁移、Device Model、DeviceSimBinding Model、DTO 全部到位)→ D-1 + D-2 并行(都依赖 D-0,互不依赖)
|
||||
- **D-03:** 每个子任务独立一个 commit(延续 Phase 1/2 粒度规范)
|
||||
|
||||
### DB 迁移
|
||||
|
||||
- **D-04:** 迁移编号从 `000090` 起,拆为 2 个迁移文件:
|
||||
- `000090_device_fields_extension`:tb_device 新增 5 个字段
|
||||
- `000091_device_sim_binding_is_current`:tb_device_sim_binding 新增 is_current 字段
|
||||
- **D-05:** 使用 `.up.sql` / `.down.sql` 双文件形式(与现有迁移规范一致)
|
||||
|
||||
### Gateway 类型定义位置
|
||||
|
||||
- **D-06:** `SyncDeviceInfoReq` / `SyncDeviceInfoResp` 放在 **`internal/gateway/models.go`**(与现有 DeviceInfoReq / SlotInfoResp 等结构保持风格一致)
|
||||
- **D-07:** `SyncDeviceInfo()` 方法放在 `internal/gateway/device.go`(与现有 GetDeviceInfo / GetSlotInfo 等方法一起)
|
||||
- 修正文档中将类型嵌入 device.go 代码片段属于示意,不代表最终位置
|
||||
|
||||
### 实时字段暴露范围
|
||||
|
||||
- **D-08:** Refresh 响应中**不暴露**非存储实时字段(rssi / battery_level / ssid / wifi_enabled 等)
|
||||
- **D-09:** 实时字段仅在调用 sync-info 时**写入 DB 存储字段**(online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at)
|
||||
- **D-10:** 这满足所有成功标准(标准 2、4 只要求存储字段同步)
|
||||
|
||||
### is_current 字段的 DTO 同步范围
|
||||
|
||||
- **D-11:** `is_current` 同步到**两处 DTO**:
|
||||
- `BoundCardInfo`(`internal/model/dto/asset_dto.go`)—— 资产实时状态 Refresh 响应中的绑定卡列表
|
||||
- `DeviceCardBindingResponse`(`internal/model/dto/device_dto.go`)—— 设备管理页 ListDeviceCards 接口
|
||||
- **D-12:** 两处同步,保持前端两个场景下的数据一致
|
||||
|
||||
### updateDeviceFromSyncInfo 实现细节
|
||||
|
||||
- **D-13:** `updateDeviceFromSyncInfo` 为私有函数,位于 `internal/service/asset/service.go`
|
||||
- **D-14:** is_current 更新使用**事务**:先全部设为 false,再把 current_iccid 对应行设为 true(原子操作,防止中间状态)
|
||||
- **D-15:** sync-info 调用失败时**不阻断刷新流程**,仅记录 Warn 日志(`sync-info 调用失败`)
|
||||
|
||||
### Agent's Discretion
|
||||
|
||||
- `last_online_time` / `last_gateway_sync_at` 字段 parsing(sync-info 返回字符串,需转换为 time.Time)的具体格式,agent 参考 Gateway 文档中的 `last_update_time` 示例格式
|
||||
- `DeviceRealtimeInfo` 独立 DTO 结构**不创建**(不暴露实时字段,无需此结构)
|
||||
- `updateDeviceFromSyncInfo` 的 DB 更新使用 GORM `Updates(map[string]any{...})`,不用 `Save()`(避免全字段覆写)
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### 修复规格(核心参考)
|
||||
- `.sisyphus/plans/修正业务-完整方案.md` — 完整修复规格书,含每个需求的代码片段和人工验收清单:
|
||||
- 方案 D(DEVICE-01~04):约第 955-1130 行(D-0 字段策略 + D-1 Gateway 接口 + D-2 is_current + D-3 Refresh 接入)
|
||||
|
||||
### Gateway 接口文档
|
||||
- `docs/第三方文档/gateway设备详情同步接口.md` — sync-info 接口完整字段说明(data 字段映射表、cardNo 规则、认证方式)
|
||||
|
||||
### 需要修改的核心文件
|
||||
- `internal/model/device.go` — D-0:新增 5 个 DB 字段
|
||||
- `internal/model/device_sim_binding.go` — D-2:新增 is_current 字段
|
||||
- `internal/model/dto/asset_dto.go` — D-2:BoundCardInfo 新增 is_current
|
||||
- `internal/model/dto/device_dto.go` — D-0:DeviceResponse 新增 5 个字段;D-2:DeviceCardBindingResponse 新增 is_current
|
||||
- `internal/gateway/models.go` — D-1:新增 SyncDeviceInfoReq / SyncDeviceInfoResp
|
||||
- `internal/gateway/device.go` — D-1:新增 SyncDeviceInfo() 方法
|
||||
- `internal/service/asset/service.go` — D-3:Refresh device 分支追加 sync-info 调用;新增 updateDeviceFromSyncInfo()
|
||||
- `migrations/` — D-0:000090_device_fields_extension;D-2:000091_device_sim_binding_is_current
|
||||
|
||||
### 现有参考实现
|
||||
- `internal/service/asset/service.go:Refresh()` — 约第 295 行,现有 device 刷新分支(追加 sync-info 调用入口)
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `internal/gateway/client.go:doRequestWithResponse[T]()` — 泛型请求方法,SyncDeviceInfo 直接复用(与 GetDeviceInfo 相同调用模式)
|
||||
- `internal/service/asset/service.go:Refresh()` — 已有 device 冷却 Key + 绑定卡遍历框架,D-3 在此追加 sync-info 调用
|
||||
- `internal/service/asset/service.go:deviceSimBindingStore` — 已注入,D-3 的 updateDeviceFromSyncInfo 可直接使用
|
||||
|
||||
### Established Patterns
|
||||
- Gateway 类型分离:结构体在 `models.go`,方法在对应领域文件(device.go / flow_card.go 等)
|
||||
- DB 更新:使用 `Updates(map[string]any{...})` + `Model(&T{})` + `Where`,不用 `Save()`
|
||||
- 错误处理:非关键步骤失败用 `logger.Warn`,关键步骤用 `errors.Wrap`
|
||||
|
||||
### Integration Points
|
||||
- `internal/gateway/device.go` — 新增 SyncDeviceInfo 方法
|
||||
- `internal/service/asset/service.go:Refresh()` device 分支 —— sync-info 调用入口(冷却 Key 设置之前,已遍历完绑定卡之后)
|
||||
- `BoundCardInfo` 在 `asset_dto.go`(被 AssetRealtimeStatusResponse 引用,多个 C 端和 B 端接口共用)
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- 修正文档中 D-3 的代码片段提供了 `updateDeviceFromSyncInfo` 的函数骨架,agent 应直接参考(文件:`.sisyphus/plans/修正业务-完整方案.md` 方案 D-3 段落)
|
||||
- `SyncDeviceInfoResp.LastOnlineTime` 字段类型用 `string`(Gateway 返回字符串),在 updateDeviceFromSyncInfo 内按需 Parse
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- 设备实时字段(rssi / battery_level / ssid 等)暴露到 Refresh 响应 —— 超出当前成功标准,考虑 Phase 4+ 或按需补充
|
||||
- DeviceRealtimeInfo 独立 DTO 结构 —— 若未来暴露实时字段时再创建
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 03-device-system*
|
||||
*Context gathered: 2026-03-28*
|
||||
70
.planning/phases/03-device-system/03-DISCUSSION-LOG.md
Normal file
70
.planning/phases/03-device-system/03-DISCUSSION-LOG.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# Phase 3: 设备体系完善 - Discussion Log
|
||||
|
||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||
|
||||
**Date:** 2026-03-28
|
||||
**Phase:** 03-device-system
|
||||
**Areas discussed:** Plan 分组策略, 实时字段暴露范围, is_current DTO 同步范围, Gateway 类型定义位置
|
||||
|
||||
---
|
||||
|
||||
## Plan 分组策略
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 2 个 Plan(推荐) | Plan 1: D-0+D-1+D-2(基础层),Plan 2: D-3(功能层)—— 与 Phase 2 分法类似 | ✓ |
|
||||
| 1 个 Plan | D-0~D-3 全在一个 Plan 里,顶序执行,简单直接但回滚粒度较粗 | |
|
||||
| 3 个 Plan | Plan 1: D-0,Plan 2: D-1+D-2(并行),Plan 3: D-3 —— 粒度最细 | |
|
||||
|
||||
**User's choice:** 2 个 Plan(推荐)
|
||||
**Notes:** 与 Phase 2 的"小修合并 + REALNAME 独立"分法保持风格一致
|
||||
|
||||
---
|
||||
|
||||
## 实时字段暴露范围
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 不暴露(推荐) | 实时字段仅写入 DB,响应只返回存储字段,满足所有成功标准 | ✓ |
|
||||
| 暴露实时字段 | 在 AssetRealtimeStatusResponse 新增嵌套 DeviceRealtimeInfo 结构(rssi / battery / ssid 等),需较大 DTO 改动 | |
|
||||
|
||||
**User's choice:** 不暴露(推荐)
|
||||
**Notes:** 成功标准 2/4 只要求 online_status / software_version 等存储字段同步,不特别要求实时字段。实时字段暴露推迟到后续阶段按需决策。
|
||||
|
||||
---
|
||||
|
||||
## is_current DTO 同步范围
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 两处都加(推荐) | BoundCardInfo(asset_dto.go)+ DeviceCardBindingResponse(device_dto.go)同时新增 is_current | ✓ |
|
||||
| 仅 BoundCardInfo | 只改 BoundCardInfo,设备管理页绑定列表看不到当前卡标识 | |
|
||||
|
||||
**User's choice:** 两处都加(推荐)
|
||||
**Notes:** 保持前端两个场景下的数据一致性
|
||||
|
||||
---
|
||||
|
||||
## Gateway 类型定义位置
|
||||
|
||||
| Option | Description | Selected |
|
||||
|--------|-------------|----------|
|
||||
| 放 models.go(推荐) | 与现有 DeviceInfoReq / SlotInfoResp 等结构保持一致,Gateway 类型集中在一个文件 | ✓ |
|
||||
| 放 device.go | 跟修正文档建议,类型和方法在同一文件,但与其他 Gateway 结构不一致 | |
|
||||
|
||||
**User's choice:** 放 models.go(推荐)
|
||||
**Notes:** 修正文档将类型嵌入 device.go 代码片段属于示意用法,不代表最终放置位置,遵循现有项目分离风格
|
||||
|
||||
---
|
||||
|
||||
## Agent's Discretion
|
||||
|
||||
- `updateDeviceFromSyncInfo` 内 is_current 更新的事务策略(先全 false 再设 true)
|
||||
- `last_online_time` 字符串 → time.Time 的 Parse 格式
|
||||
- GORM Updates 而非 Save 的具体写法
|
||||
|
||||
## Deferred Ideas
|
||||
|
||||
- 设备实时字段(rssi / battery / ssid 等)暴露到 Refresh 响应 —— 超出当前成功标准
|
||||
- DeviceRealtimeInfo 独立 DTO 结构 —— 等实时字段暴露需求时再建
|
||||
141
.planning/phases/03-device-system/03-VERIFICATION.md
Normal file
141
.planning/phases/03-device-system/03-VERIFICATION.md
Normal file
@@ -0,0 +1,141 @@
|
||||
---
|
||||
phase: 03-device-system
|
||||
verified: 2026-03-28T10:00:00Z
|
||||
status: passed
|
||||
score: 9/9 must-haves verified
|
||||
re_verification: false
|
||||
---
|
||||
|
||||
# Phase 03: 设备体系完善 Verification Report
|
||||
|
||||
**Phase Goal:** 扩展设备体系,接入 Gateway sync-info 接口,实现设备实时状态同步(在线状态、固件版本、当前卡标识)
|
||||
**Verified:** 2026-03-28
|
||||
**Status:** ✅ PASSED
|
||||
**Re-verification:** No — 初始验证
|
||||
|
||||
---
|
||||
|
||||
## Goal Achievement
|
||||
|
||||
### Observable Truths
|
||||
|
||||
| # | Truth | Status | Evidence |
|
||||
|----|-----------------------------------------------------------------------------------------------|-------------|-------------------------------------------------------------------------------------------|
|
||||
| 1 | tb_device 表含 online_status / last_online_time / software_version / switch_mode / last_gateway_sync_at 字段 | ✓ VERIFIED | PostgreSQL MCP 确认 5 列全部存在;migration 000090 执行;DB version=92(>91) |
|
||||
| 2 | tb_device_sim_binding 表含 is_current 字段 | ✓ VERIFIED | PostgreSQL MCP 确认 is_current 列存在;migration 000091 执行 |
|
||||
| 3 | Gateway Client 具备 SyncDeviceInfo() 方法 | ✓ VERIFIED | internal/gateway/device.go:75 实现完整方法,调用 doRequestWithResponse[SyncDeviceInfoResp] |
|
||||
| 4 | DeviceResponse DTO 新增 5 个字段;BoundCardInfo + DeviceCardBindingResponse 新增 is_current | ✓ VERIFIED | device_dto.go:44-48 含5字段,device_dto.go:85 含IsCurrent,asset_dto.go:64 含IsCurrent |
|
||||
| 5 | make migrate-up 执行成功,go build ./... 编译通过 | ✓ VERIFIED | migrate-version=92, go build ./... 零输出(无错误) |
|
||||
| 6 | 调用设备刷新接口后,设备状态从 Gateway 实时更新 | ✓ VERIFIED | service.go:343-364:nil guard + SyncDeviceInfo 调用 + updateDeviceFromSyncInfo 写库 |
|
||||
| 7 | 同一设备同一时刻只有一条 is_current=true(事务原子更新) | ✓ VERIFIED | device_sim_binding_store.go:208-235:Transaction 两步操作(先全清 false,再按 ICCID 设 true) |
|
||||
| 8 | sync-info 调用失败时不阻断刷新流程,仅记录 Warn 日志 | ✓ VERIFIED | service.go:357-362:syncErr 走 Warn 分支,不 return error,流程继续到 GetRealtimeStatus |
|
||||
| 9 | go build ./... 编译通过 | ✓ VERIFIED | go build ./... 零错误输出 |
|
||||
|
||||
**Score:** 9/9 truths verified
|
||||
|
||||
---
|
||||
|
||||
## Required Artifacts
|
||||
|
||||
| Artifact | Expected | Status | Details |
|
||||
|------------------------------------------------------------------|---------------------------------------------------|-------------|--------------------------------------------------------------------------|
|
||||
| `migrations/000090_device_fields_extension.up.sql` | tb_device 字段扩展迁移 | ✓ VERIFIED | 文件存在,含 5 个 ALTER TABLE ADD COLUMN + COMMENT 语句 |
|
||||
| `migrations/000091_device_sim_binding_is_current.up.sql` | tb_device_sim_binding.is_current 迁移 | ✓ VERIFIED | 文件存在,含 BOOLEAN NOT NULL DEFAULT FALSE + COMMENT |
|
||||
| `internal/model/device.go` | Device 模型新增 5 字段 | ✓ VERIFIED | 第 41-45 行含 OnlineStatus/LastOnlineTime/SoftwareVersion/SwitchMode/LastGatewaySyncAt |
|
||||
| `internal/model/device_sim_binding.go` | DeviceSimBinding 模型新增 is_current | ✓ VERIFIED | 第 20 行含 IsCurrent bool,带完整 gorm tag |
|
||||
| `internal/gateway/models.go` | SyncDeviceInfoReq / SyncDeviceInfoResp 结构定义 | ✓ VERIFIED | 第 135-160 行,SyncDeviceInfoResp 含 18 个字段(包含关键的 CurrentIccid) |
|
||||
| `internal/gateway/device.go` | SyncDeviceInfo() 方法 | ✓ VERIFIED | 第 72-79 行,有 cardNo 空值检查,调用泛型 doRequestWithResponse[SyncDeviceInfoResp] |
|
||||
| `internal/model/dto/device_dto.go` | DeviceResponse + DeviceCardBindingResponse 字段扩展 | ✓ VERIFIED | DeviceResponse 第 44-48 行含5字段,DeviceCardBindingResponse 第 85 行含 IsCurrent |
|
||||
| `internal/model/dto/asset_dto.go` | BoundCardInfo.IsCurrent 字段扩展 | ✓ VERIFIED | 第 64 行含 IsCurrent bool + description tag |
|
||||
| `internal/store/postgres/device_sim_binding_store.go` | UpdateIsCurrentByDeviceID 方法(事务两步更新) | ✓ VERIFIED | 第 204-235 行,Transaction 两步原子操作,currentIccid 为空时安全返回 nil |
|
||||
| `internal/service/asset/service.go` | gatewayClient 字段 + updateDeviceFromSyncInfo + Refresh 接入 | ✓ VERIFIED | gatewayClient 字段第 41 行,updateDeviceFromSyncInfo 第 373 行,Refresh 接入第 343-364 行 |
|
||||
| `internal/bootstrap/services.go` | asset.New() 传入 deps.GatewayClient | ✓ VERIFIED | 第 180 行 asset.New 末尾参数为 deps.GatewayClient |
|
||||
|
||||
---
|
||||
|
||||
## Key Link Verification
|
||||
|
||||
| From | To | Via | Status | Details |
|
||||
|-------------------------------------------------|-----------------------------------------------------------|----------------------------------------|------------|------------------------------------------------------------------|
|
||||
| `internal/gateway/device.go:SyncDeviceInfo` | `internal/gateway/client.go:doRequestWithResponse` | 泛型调用 | ✓ WIRED | device.go:79 `doRequestWithResponse[SyncDeviceInfoResp](c, ctx, "/device/sync-info", req)` |
|
||||
| `internal/model/device.go:Device` | `migrations/000090` | 字段对应迁移 | ✓ WIRED | OnlineStatus gorm:"column:online_status" ↔ 迁移 ADD COLUMN online_status |
|
||||
| `internal/service/asset/service.go:Refresh` | `internal/gateway/device.go:SyncDeviceInfo` | s.gatewayClient.SyncDeviceInfo() | ✓ WIRED | service.go:353 `s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{CardNo: cardNo})` |
|
||||
| `internal/service/asset/service.go:updateDeviceFromSyncInfo` | `device_sim_binding_store.go:UpdateIsCurrentByDeviceID` | 事务原子更新 is_current | ✓ WIRED | service.go:408 `s.deviceSimBindingStore.UpdateIsCurrentByDeviceID(ctx, deviceID, resp.CurrentIccid)` |
|
||||
| `internal/bootstrap/services.go` | `internal/service/asset/service.go:New` | deps.GatewayClient 注入 | ✓ WIRED | services.go:180 asset.New(... deps.Redis, iotCard, deps.GatewayClient) |
|
||||
|
||||
---
|
||||
|
||||
## Data-Flow Trace (Level 4)
|
||||
|
||||
| Artifact | Data Variable | Source | Produces Real Data | Status |
|
||||
|---------------------------------------|------------------------|------------------------------------|--------------------|-------------|
|
||||
| `internal/service/asset/service.go` | syncResp (SyncDeviceInfoResp) | s.gatewayClient.SyncDeviceInfo() | ✓ 真实 Gateway 调用 | ✓ FLOWING |
|
||||
| `device_sim_binding_store.go` | UpdateIsCurrentByDeviceID | GORM 事务 UPDATE 语句 | ✓ 实际 DB 写入 | ✓ FLOWING |
|
||||
| `internal/model/device.go` | updates (map[string]any) | GORM Model.Updates() | ✓ 实际 DB 写入 | ✓ FLOWING |
|
||||
|
||||
---
|
||||
|
||||
## Behavioral Spot-Checks
|
||||
|
||||
| Behavior | Command | Result | Status |
|
||||
|-------------------------------------------|---------------------------------------------------|-------------|----------|
|
||||
| 代码编译无错误 | `go build ./...` | 零输出 | ✓ PASS |
|
||||
| SyncDeviceInfo 方法存在 | grep -n "SyncDeviceInfo" internal/gateway/device.go | 第 72、79 行 | ✓ PASS |
|
||||
| updateDeviceFromSyncInfo 存在于 service | grep -n "updateDeviceFromSyncInfo" service.go | 第 373、375 行 | ✓ PASS |
|
||||
| UpdateIsCurrentByDeviceID 存在于 store | grep -n "UpdateIsCurrentByDeviceID" device_sim_binding_store.go | 第 204、207 行 | ✓ PASS |
|
||||
| bootstrap 注入 GatewayClient | grep -n "GatewayClient" services.go | 第 180 行 | ✓ PASS |
|
||||
| DB 迁移版本 ≥ 91 | make migrate-version | 92 | ✓ PASS |
|
||||
| tb_device.online_status 字段存在 | PostgreSQL MCP dbhub_search_objects | 1 result | ✓ PASS |
|
||||
| tb_device_sim_binding.is_current 字段存在 | PostgreSQL MCP dbhub_search_objects | 1 result | ✓ PASS |
|
||||
|
||||
---
|
||||
|
||||
## Requirements Coverage
|
||||
|
||||
| Requirement | Source Plan | Description | Status | Evidence |
|
||||
|-------------|-------------|------------------------------------------------------------------------------|-------------|-------------------------------------------------------------------|
|
||||
| DEVICE-01 | 03-01 | 设备模型字段扩展 — DB 迁移新增 5 个 sync-info 字段 | ✓ SATISFIED | migration 000090 执行,5 个 DB 列存在,model/device.go 5 个新字段 |
|
||||
| DEVICE-02 | 03-01 | Gateway sync-info 同步接口对接 — SyncDeviceInfo() 方法 | ✓ SATISFIED | internal/gateway/device.go:75 完整实现,泛型调用 doRequestWithResponse |
|
||||
| DEVICE-03 | 03-01 | tb_device_sim_binding 新增 is_current 字段 — DB 迁移 + Model + 更新逻辑 | ✓ SATISFIED | migration 000091,model is_current 字段,UpdateIsCurrentByDeviceID 事务更新 |
|
||||
| DEVICE-04 | 03-02 | 设备 Refresh + 详情接入 sync-info — 刷新卡数据后调用 SyncDeviceInfo | ✓ SATISFIED | service.go Refresh device 分支 343-364 行,updateDeviceFromSyncInfo 完整实现 |
|
||||
|
||||
**Requirements 追踪表状态(REQUIREMENTS.md):** DEVICE-01 ~ DEVICE-04 均标记为 Complete ✅
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns Found
|
||||
|
||||
| File | Line | Pattern | Severity | Impact |
|
||||
|------|------|---------|----------|--------|
|
||||
| — | — | — | — | 无发现 |
|
||||
|
||||
扫描了以下文件的 TODO/FIXME/PLACEHOLDER/空返回模式:
|
||||
- `internal/service/asset/service.go` — 无问题(`return nil` 均为合法的辅助函数返回)
|
||||
- `internal/store/postgres/device_sim_binding_store.go` — 无问题(`return nil` 为事务内幂等安全分支)
|
||||
- `internal/gateway/device.go` — 无问题
|
||||
- `internal/gateway/models.go` — 无问题
|
||||
- `internal/bootstrap/services.go` — 无问题
|
||||
|
||||
---
|
||||
|
||||
## Human Verification Required
|
||||
|
||||
无需人工验证。所有行为均可通过代码检查 + DB 验证 + 编译检查自动确认。
|
||||
|
||||
以下场景为运行时行为,在实际调用时自然验证:
|
||||
1. **实际 Gateway 连通性** — sync-info 调用需真实 Gateway 服务,本次编译验证代码路径正确,运行时验证留给集成测试
|
||||
2. **is_current 原子性** — 并发场景下的唯一性由 GORM Transaction 保证,代码逻辑已验证
|
||||
|
||||
---
|
||||
|
||||
## Gaps Summary
|
||||
|
||||
无 gaps。Phase 03 全部产出均已实现并验证:
|
||||
|
||||
- **Plan 01**(数据基础层):2 个迁移对(000090/000091)已执行,DB 列已确认存在;Device/DeviceSimBinding 模型更新完整;Gateway SyncDeviceInfo 方法实现正确;DTO 扩展(DeviceResponse 5字段 + IsCurrent 2处)完整
|
||||
- **Plan 02**(Service 层接入):UpdateIsCurrentByDeviceID 事务两步原子更新已实现;asset.Service 注入 gatewayClient;updateDeviceFromSyncInfo 私有函数完整(5字段更新 + is_current 原子更新);Refresh device 分支接入 sync-info(nil guard + Warn 日志,不阻断主流程);bootstrap 正确传入 deps.GatewayClient
|
||||
|
||||
---
|
||||
|
||||
_Verified: 2026-03-28_
|
||||
_Verifier: gsd-verifier (claude-sonnet-4-6)_
|
||||
637
.planning/phases/03.1-sync-info-phase-3-dto/03.1-01-PLAN.md
Normal file
637
.planning/phases/03.1-sync-info-phase-3-dto/03.1-01-PLAN.md
Normal file
@@ -0,0 +1,637 @@
|
||||
---
|
||||
phase: 03.1-sync-info-phase-3-dto
|
||||
plan: "01"
|
||||
type: execute
|
||||
wave: 1
|
||||
depends_on: []
|
||||
files_modified:
|
||||
- internal/gateway/models.go
|
||||
- internal/model/dto/asset_dto.go
|
||||
- internal/service/device/service.go
|
||||
- internal/service/device/binding.go
|
||||
- internal/service/asset/service.go
|
||||
- internal/handler/app/client_asset.go
|
||||
autonomous: true
|
||||
requirements:
|
||||
- DEVICE-02
|
||||
- DEVICE-03
|
||||
- DEVICE-04
|
||||
|
||||
must_haves:
|
||||
truths:
|
||||
- "设备管理列表/详情接口返回 online_status、last_online_time、software_version、switch_mode、last_gateway_sync_at 字段(Phase 3 已写入 DB,现在需要映射到 DTO)"
|
||||
- "设备绑卡列表接口(ListBindings)返回 is_current 字段,标识当前使用的卡"
|
||||
- "资产 Resolve/Refresh 接口的 BoundCardInfo 包含 is_current 字段"
|
||||
- "admin 资产解析接口(AssetResolveResponse)返回 5 个 DB 缓存字段:online_status/last_online_time/software_version/switch_mode/last_gateway_sync_at"
|
||||
- "资产实时状态接口(GetRealtimeStatus)对 device 类型实时调用 Gateway sync-info,返回 DeviceRealtime 字段"
|
||||
- "C 端 GetAsset 接口的 device_realtime 字段从真实 Gateway 数据填充,不再是 mock 数据"
|
||||
- "Gateway 调用失败时(IMEI 为空/超时/离线)device_realtime 返回 null,资产基础信息正常返回"
|
||||
artifacts:
|
||||
- path: "internal/gateway/models.go"
|
||||
provides: "SyncDeviceInfoResp 补全缺失字段(Rsrp/Rsrq/Sinr/WifiPassword/IMSI/LANIP/WANIP/MaxClients/ConnectTime/ULStats/DLStats/LimitSpeed/SyncInterval/MACAddress/Status)"
|
||||
- path: "internal/model/dto/asset_dto.go"
|
||||
provides: "DeviceGatewayInfo 新 B 端实时状态结构体;AssetRealtimeStatusResponse.DeviceRealtime 字段;AssetResolveResponse 5个 DB 缓存字段"
|
||||
- path: "internal/service/device/service.go"
|
||||
provides: "toDeviceResponse() 补全 OnlineStatus/LastOnlineTime/SoftwareVersion/SwitchMode/LastGatewaySyncAt 5个字段映射"
|
||||
- path: "internal/service/device/binding.go"
|
||||
provides: "ListBindings() 补全 IsCurrent 字段映射"
|
||||
- path: "internal/service/asset/service.go"
|
||||
provides: "buildDeviceResolveResponse() 补全 IsCurrent + 5个DB缓存字段;GetRealtimeStatus() device case 追加 Gateway 实时调用"
|
||||
- path: "internal/handler/app/client_asset.go"
|
||||
provides: "GetAsset() 删除 buildMockDeviceRealtime(),改为调用 assetService.GetRealtimeStatus() 并映射到 DeviceRealtimeInfo"
|
||||
key_links:
|
||||
- from: "internal/service/asset/service.go:GetRealtimeStatus()"
|
||||
to: "internal/gateway/device.go:SyncDeviceInfo()"
|
||||
via: "s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{CardNo: device.IMEI})"
|
||||
pattern: "gatewayClient.*SyncDeviceInfo"
|
||||
- from: "internal/handler/app/client_asset.go:GetAsset()"
|
||||
to: "internal/service/asset/service.go:GetRealtimeStatus()"
|
||||
via: "h.assetService.GetRealtimeStatus(\"device\", deviceID)"
|
||||
pattern: "assetService.GetRealtimeStatus"
|
||||
- from: "internal/service/asset/service.go:buildDeviceResolveResponse()"
|
||||
to: "dto.BoundCardInfo.IsCurrent"
|
||||
via: "isCurrentMap[b.IotCardID] = b.IsCurrent"
|
||||
pattern: "isCurrentMap"
|
||||
---
|
||||
|
||||
<objective>
|
||||
补全 Phase 3 完成的设备数据写入链路在读取侧的缺口:修复 DTO 字段映射遗漏(静态缺口)+ 建立设备实时状态的 Gateway 查询链路(架构对齐)。
|
||||
|
||||
Purpose: Phase 3 已实现 sync-info 调用和 DB 字段写入,但 API 接口未返回这些字段,且 C 端设备实时状态仍是 mock 数据。本 Phase 完成"读取侧"闭环。
|
||||
Output: 6 个文件修改,设备管理/资产解析/C 端资产接口全部返回真实数据。
|
||||
</objective>
|
||||
|
||||
<execution_context>
|
||||
@$HOME/.config/opencode/get-shit-done/workflows/execute-plan.md
|
||||
@$HOME/.config/opencode/get-shit-done/templates/summary.md
|
||||
</execution_context>
|
||||
|
||||
<context>
|
||||
@.planning/PROJECT.md
|
||||
@.planning/ROADMAP.md
|
||||
@.planning/phases/03.1-sync-info-phase-3-dto/03.1-CONTEXT.md
|
||||
|
||||
@internal/gateway/models.go
|
||||
@internal/model/dto/asset_dto.go
|
||||
@internal/model/dto/device_dto.go
|
||||
@internal/model/dto/client_asset_dto.go
|
||||
@internal/service/asset/service.go
|
||||
@internal/service/device/service.go
|
||||
@internal/service/device/binding.go
|
||||
@internal/handler/app/client_asset.go
|
||||
|
||||
<interfaces>
|
||||
<!-- 执行本 Plan 前必须读取以下接口定义,无需探索代码库 -->
|
||||
|
||||
<!-- gateway/models.go 当前 SyncDeviceInfoResp(缺失字段需补全)-->
|
||||
```go
|
||||
type SyncDeviceInfoResp struct {
|
||||
DeviceID string `json:"device_id"`
|
||||
DeviceName string `json:"device_name"`
|
||||
IMEI string `json:"imei"`
|
||||
CurrentIccid string `json:"current_iccid"`
|
||||
DeviceType string `json:"device_type"`
|
||||
SoftwareVersion string `json:"software_version"`
|
||||
MacAddress string `json:"mac_address"`
|
||||
SSID string `json:"ssid"`
|
||||
WifiEnabled bool `json:"wifi_enabled"`
|
||||
SwitchMode string `json:"switch_mode"`
|
||||
RSSI string `json:"rssi"`
|
||||
BatteryLevel *int `json:"battery_level"`
|
||||
OnlineStatus int `json:"online_status"`
|
||||
LastUpdateTime string `json:"last_update_time"`
|
||||
LastOnlineTime string `json:"last_online_time"`
|
||||
RunTime string `json:"run_time"`
|
||||
DailyUsage string `json:"daily_usage"`
|
||||
// 以下字段 D-10 要求补全:
|
||||
// Rsrp int, Rsrq int, Sinr int, WifiPassword string
|
||||
// IPAddress string, WANIP string, LANIP string
|
||||
// MaxClients int, ConnectTime string, Status int
|
||||
// IMSI string, ULStats string, DLStats string
|
||||
// LimitSpeed int, SyncInterval int, MACAddress string(已有 MacAddress,需确认 json tag)
|
||||
}
|
||||
```
|
||||
|
||||
<!-- asset_dto.go 当前 BoundCardInfo(已有 IsCurrent,但 buildDeviceResolveResponse 未填充)-->
|
||||
```go
|
||||
type BoundCardInfo struct {
|
||||
CardID uint `json:"card_id"`
|
||||
ICCID string `json:"iccid"`
|
||||
MSISDN string `json:"msisdn,omitempty"`
|
||||
NetworkStatus int `json:"network_status"`
|
||||
RealNameStatus int `json:"real_name_status"`
|
||||
SlotPosition int `json:"slot_position,omitempty"`
|
||||
IsCurrent bool `json:"is_current"` // ← 已定义,但 buildDeviceResolveResponse 未赋值
|
||||
}
|
||||
```
|
||||
|
||||
<!-- asset_dto.go 当前 AssetRealtimeStatusResponse(需新增 DeviceRealtime 字段)-->
|
||||
```go
|
||||
type AssetRealtimeStatusResponse struct {
|
||||
AssetType string `json:"asset_type"`
|
||||
AssetID uint `json:"asset_id"`
|
||||
NetworkStatus int `json:"network_status,omitempty"`
|
||||
RealNameStatus int `json:"real_name_status,omitempty"`
|
||||
CurrentMonthUsageMB float64 `json:"current_month_usage_mb,omitempty"`
|
||||
LastSyncTime *time.Time `json:"last_sync_time,omitempty"`
|
||||
DeviceProtectStatus string `json:"device_protect_status,omitempty"`
|
||||
Cards []BoundCardInfo `json:"cards,omitempty"`
|
||||
// 需新增:DeviceRealtime *DeviceGatewayInfo `json:"device_realtime,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
<!-- device_dto.go 当前 DeviceResponse(已有字段,但 toDeviceResponse 未映射)-->
|
||||
```go
|
||||
type DeviceResponse struct {
|
||||
// ... 其他字段已有
|
||||
OnlineStatus int `json:"online_status"`
|
||||
LastOnlineTime *time.Time `json:"last_online_time,omitempty"`
|
||||
SoftwareVersion string `json:"software_version"`
|
||||
SwitchMode string `json:"switch_mode"`
|
||||
LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at,omitempty"`
|
||||
// ↑ 以上字段已在 device_dto.go 中定义(Phase 3 完成),但 toDeviceResponse() 未赋值
|
||||
}
|
||||
```
|
||||
|
||||
<!-- device/binding.go DeviceCardBindingResponse(已有 IsCurrent,但 ListBindings 未填充)-->
|
||||
```go
|
||||
type DeviceCardBindingResponse struct {
|
||||
ID uint
|
||||
SlotPosition int
|
||||
IotCardID uint
|
||||
ICCID string
|
||||
MSISDN string
|
||||
CarrierName string
|
||||
Status int
|
||||
BindTime *time.Time
|
||||
IsCurrent bool // ← 已定义,但 ListBindings() 未赋值
|
||||
}
|
||||
```
|
||||
|
||||
<!-- asset/service.go 关键模式(isCurrentMap 构建方式)-->
|
||||
```go
|
||||
// buildDeviceResolveResponse 中现有 slotMap 构建模式,isCurrentMap 同构:
|
||||
slotMap := make(map[uint]int, len(bindings))
|
||||
for _, b := range bindings {
|
||||
cardIDs = append(cardIDs, b.IotCardID)
|
||||
slotMap[b.IotCardID] = b.SlotPosition
|
||||
}
|
||||
// 新增同构的 isCurrentMap:
|
||||
// isCurrentMap := make(map[uint]bool, len(bindings))
|
||||
// isCurrentMap[b.IotCardID] = b.IsCurrent
|
||||
|
||||
// GetRealtimeStatus device case 中现有 slotMap 构建模式,isCurrentMap 同构补充
|
||||
```
|
||||
|
||||
<!-- client_asset_dto.go DeviceRealtimeInfo 完整结构(B 端 DeviceGatewayInfo 的字段参考)-->
|
||||
```go
|
||||
type DeviceRealtimeInfo struct {
|
||||
OnlineStatus *int64; BatteryLevel *int64; Status *int64
|
||||
RunTime *string; ConnectTime *string; LastOnlineTime *string; LastUpdateTime *string
|
||||
Rsrp *int64; Rsrq *int64; Rssi *string; Sinr *int64
|
||||
SSID *string; WifiEnabled *bool; WifiPassword *string
|
||||
IPAddress *string; WANIP *string; LANIP *string; MACAddress *string
|
||||
DailyUsage *string; DLStats *string; ULStats *string; LimitSpeed *int64
|
||||
CurrentIccid *string; MaxClients *int64; SoftwareVersion *string
|
||||
SwitchMode *int; SyncInterval *int64
|
||||
DeviceID *string; DeviceName *string; DeviceType *string
|
||||
Imei *string; Imsi *string; CreatedAt *int64; UpdatedAt *int64
|
||||
}
|
||||
```
|
||||
</interfaces>
|
||||
</context>
|
||||
|
||||
<tasks>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 1: 补全 Gateway 模型和 B 端 DTO 契约</name>
|
||||
<files>internal/gateway/models.go, internal/model/dto/asset_dto.go</files>
|
||||
<action>
|
||||
**A. 补全 `internal/gateway/models.go` 的 `SyncDeviceInfoResp`(per D-10):**
|
||||
|
||||
在现有 `DailyUsage string` 字段之后,追加以下缺失字段(参考 Gateway 接口文档 `docs/第三方文档/gateway设备详情同步接口.md`):
|
||||
|
||||
```go
|
||||
// 信号相关(补全)
|
||||
Rsrp int `json:"rsrp" description:"参考信号接收功率(dBm)"`
|
||||
Rsrq int `json:"rsrq" description:"参考信号接收质量(dB)"`
|
||||
Sinr int `json:"sinr" description:"信噪比(dB)"`
|
||||
|
||||
// WiFi 相关(补全)
|
||||
WifiPassword string `json:"wifi_password" description:"WiFi密码"`
|
||||
|
||||
// 网络相关(补全)
|
||||
IPAddress string `json:"ip_address" description:"IP地址"`
|
||||
WANIP string `json:"wan_ip" description:"基站分配IPv4地址"`
|
||||
LANIP string `json:"lan_ip" description:"局域网网关IP地址"`
|
||||
|
||||
// 连接相关(补全)
|
||||
MaxClients int `json:"max_clients" description:"最大连接客户端数"`
|
||||
ConnectTime string `json:"connect_time" description:"设备本次联网时间(秒)"`
|
||||
|
||||
// 设备属性(补全)
|
||||
Status int `json:"status" description:"设备状态:1=正常,0=禁用"`
|
||||
IMSI string `json:"imsi" description:"IMSI用户标识码"`
|
||||
ULStats string `json:"ul_stats" description:"本次开机上传流量(字节)"`
|
||||
DLStats string `json:"dl_stats" description:"本次开机下载流量(字节)"`
|
||||
LimitSpeed int `json:"limit_speed" description:"限速速率(KB/s)"`
|
||||
SyncInterval int `json:"sync_interval" description:"信息上报周期(秒)"`
|
||||
```
|
||||
|
||||
注意:`MacAddress` 字段已存在(json tag 为 `mac_address`),**不要重复添加**。
|
||||
|
||||
**B. 在 `internal/model/dto/asset_dto.go` 中新增 `DeviceGatewayInfo` 结构体(per D-08/D-09):**
|
||||
|
||||
在文件末尾追加(在 `CardICCIDRequest` 之后),B 端 DTO 独立结构,全字段带 `description` tag:
|
||||
|
||||
```go
|
||||
// DeviceGatewayInfo B端设备实时状态信息(来自 Gateway sync-info)
|
||||
// 与 C 端 DeviceRealtimeInfo 独立,未来可展示更多技术字段
|
||||
// 所有字段均为可选,Gateway 调用失败时整体为 null
|
||||
type DeviceGatewayInfo struct {
|
||||
// 设备状态
|
||||
OnlineStatus *int `json:"online_status,omitempty" description:"在线状态:1=在线,2=离线"`
|
||||
BatteryLevel *int `json:"battery_level,omitempty" description:"电池电量百分比(无电池时为null)"`
|
||||
Status *int `json:"status,omitempty" description:"设备状态:1=正常,0=禁用"`
|
||||
RunTime *string `json:"run_time,omitempty" description:"本次开机运行时间(秒)"`
|
||||
ConnectTime *string `json:"connect_time,omitempty" description:"本次联网时间(秒)"`
|
||||
LastOnlineTime *string `json:"last_online_time,omitempty" description:"设备最后在线时间"`
|
||||
LastUpdateTime *string `json:"last_update_time,omitempty" description:"设备信息最后更新时间"`
|
||||
|
||||
// 信号相关
|
||||
Rsrp *int `json:"rsrp,omitempty" description:"参考信号接收功率(dBm)"`
|
||||
Rsrq *int `json:"rsrq,omitempty" description:"参考信号接收质量(dB)"`
|
||||
Rssi *string `json:"rssi,omitempty" description:"接收信号强度"`
|
||||
Sinr *int `json:"sinr,omitempty" description:"信噪比(dB)"`
|
||||
|
||||
// WiFi 相关
|
||||
SSID *string `json:"ssid,omitempty" description:"WiFi热点名称"`
|
||||
WifiEnabled *bool `json:"wifi_enabled,omitempty" description:"WiFi开关状态"`
|
||||
WifiPassword *string `json:"wifi_password,omitempty" description:"WiFi密码"`
|
||||
|
||||
// 网络相关
|
||||
IPAddress *string `json:"ip_address,omitempty" description:"IP地址"`
|
||||
WANIP *string `json:"wan_ip,omitempty" description:"基站分配IPv4地址"`
|
||||
LANIP *string `json:"lan_ip,omitempty" description:"局域网网关IP地址"`
|
||||
MACAddress *string `json:"mac_address,omitempty" description:"MAC地址"`
|
||||
|
||||
// 流量与速率
|
||||
DailyUsage *string `json:"daily_usage,omitempty" description:"日使用流量(字节)"`
|
||||
DLStats *string `json:"dl_stats,omitempty" description:"本次开机下载流量(字节)"`
|
||||
ULStats *string `json:"ul_stats,omitempty" description:"本次开机上传流量(字节)"`
|
||||
LimitSpeed *int `json:"limit_speed,omitempty" description:"限速速率(KB/s)"`
|
||||
|
||||
// 设备属性
|
||||
CurrentIccid *string `json:"current_iccid,omitempty" description:"当前使用的ICCID"`
|
||||
MaxClients *int `json:"max_clients,omitempty" description:"最大连接客户端数"`
|
||||
SoftwareVersion *string `json:"software_version,omitempty" description:"软件版本号"`
|
||||
SwitchMode *string `json:"switch_mode,omitempty" description:"切卡模式:0=自动,1=手动"`
|
||||
SyncInterval *int `json:"sync_interval,omitempty" description:"信息上报周期(秒)"`
|
||||
|
||||
// Gateway 标识字段
|
||||
DeviceID *string `json:"device_id,omitempty" description:"Gateway设备ID(IMEI/SN)"`
|
||||
DeviceName *string `json:"device_name,omitempty" description:"Gateway返回的设备名称"`
|
||||
DeviceType *string `json:"device_type,omitempty" description:"Gateway返回的设备类型"`
|
||||
IMEI *string `json:"imei,omitempty" description:"IMEI号"`
|
||||
IMSI *string `json:"imsi,omitempty" description:"IMSI用户标识码"`
|
||||
}
|
||||
```
|
||||
|
||||
**C. 修改 `AssetRealtimeStatusResponse`(per D-07/D-08):**
|
||||
|
||||
在 `Cards []BoundCardInfo` 字段之后追加:
|
||||
```go
|
||||
DeviceRealtime *DeviceGatewayInfo `json:"device_realtime,omitempty" description:"设备实时状态(来自 Gateway sync-info,Gateway 失败时为 null)"`
|
||||
```
|
||||
|
||||
**D. 修改 `AssetResolveResponse`(per D-12):**
|
||||
|
||||
在 `Manufacturer string` 字段之后、`// 卡专属字段` 注释之前,追加 5 个 DB 缓存字段:
|
||||
```go
|
||||
// 设备 DB 缓存字段(Phase 3 sync-info 写入,展示上次同步快照)
|
||||
OnlineStatus int `json:"online_status,omitempty" description:"在线状态:0未知 1在线 2离线(设备类型时有效)"`
|
||||
LastOnlineTime *time.Time `json:"last_online_time,omitempty" description:"最后在线时间(设备类型时有效)"`
|
||||
SoftwareVersion string `json:"software_version,omitempty" description:"固件版本号(设备类型时有效)"`
|
||||
SwitchMode string `json:"switch_mode,omitempty" description:"切卡模式:0=自动,1=手动(设备类型时有效)"`
|
||||
LastGatewaySyncAt *time.Time `json:"last_gateway_sync_at,omitempty" description:"最后 sync-info 同步时间(设备类型时有效)"`
|
||||
```
|
||||
</action>
|
||||
<verify>go build ./internal/gateway/... && go build ./internal/model/dto/...</verify>
|
||||
<done>
|
||||
- `SyncDeviceInfoResp` 包含所有 Gateway 文档字段(包括 Rsrp/Rsrq/Sinr/WifiPassword/IMSI/LANIP/WANIP/MaxClients/ConnectTime/Status/ULStats/DLStats/LimitSpeed/SyncInterval)
|
||||
- `asset_dto.go` 中 `DeviceGatewayInfo` 结构体存在,字段带 `description` tag
|
||||
- `AssetRealtimeStatusResponse` 含 `DeviceRealtime *DeviceGatewayInfo`
|
||||
- `AssetResolveResponse` 含 5 个 DB 缓存设备字段
|
||||
- `go build` 编译通过
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 2: 修复静态 DTO 映射缺口(5 个文件映射补全)</name>
|
||||
<files>internal/service/device/service.go, internal/service/device/binding.go, internal/service/asset/service.go</files>
|
||||
<action>
|
||||
**A. `internal/service/device/service.go:toDeviceResponse()`(per D-02):**
|
||||
|
||||
在现有 `resp := &dto.DeviceResponse{...}` 初始化块中,追加 5 个 DB 缓存字段映射:
|
||||
```go
|
||||
OnlineStatus: device.OnlineStatus,
|
||||
LastOnlineTime: device.LastOnlineTime,
|
||||
SoftwareVersion: device.SoftwareVersion,
|
||||
SwitchMode: device.SwitchMode,
|
||||
LastGatewaySyncAt: device.LastGatewaySyncAt,
|
||||
```
|
||||
|
||||
**B. `internal/service/device/binding.go:ListBindings()`(per D-03):**
|
||||
|
||||
在构建 `resp := &dto.DeviceCardBindingResponse{...}` 时,追加:
|
||||
```go
|
||||
IsCurrent: binding.IsCurrent,
|
||||
```
|
||||
|
||||
**C. `internal/service/asset/service.go:buildDeviceResolveResponse()`(per D-04 + D-12):**
|
||||
|
||||
1. **补全 `BoundCardInfo.IsCurrent`(per D-04):**
|
||||
|
||||
在现有 `slotMap` 构建循环中,同构添加 `isCurrentMap`:
|
||||
```go
|
||||
slotMap := make(map[uint]int, len(bindings))
|
||||
isCurrentMap := make(map[uint]bool, len(bindings)) // 新增
|
||||
for _, b := range bindings {
|
||||
cardIDs = append(cardIDs, b.IotCardID)
|
||||
slotMap[b.IotCardID] = b.SlotPosition
|
||||
isCurrentMap[b.IotCardID] = b.IsCurrent // 新增
|
||||
}
|
||||
```
|
||||
|
||||
在构建 `dto.BoundCardInfo{...}` 时追加:
|
||||
```go
|
||||
IsCurrent: isCurrentMap[c.ID], // 新增
|
||||
```
|
||||
|
||||
2. **补全 AssetResolveResponse 的 5 个 DB 缓存字段(per D-12):**
|
||||
|
||||
在 `resp := &dto.AssetResolveResponse{...}` 初始化块中追加:
|
||||
```go
|
||||
OnlineStatus: device.OnlineStatus,
|
||||
LastOnlineTime: device.LastOnlineTime,
|
||||
SoftwareVersion: device.SoftwareVersion,
|
||||
SwitchMode: device.SwitchMode,
|
||||
LastGatewaySyncAt: device.LastGatewaySyncAt,
|
||||
```
|
||||
|
||||
**D. `internal/service/asset/service.go:GetRealtimeStatus()` device case 中 `BoundCardInfo`(per D-04):**
|
||||
|
||||
同 C 步骤,在 `GetRealtimeStatus` device case 中也需补全 `isCurrentMap`:
|
||||
|
||||
在 `slotMap` 构建循环中追加 `isCurrentMap`,在 `dto.BoundCardInfo{...}` 中追加 `IsCurrent: isCurrentMap[c.ID]`。
|
||||
|
||||
注意:`Refresh()` 内部会调用 `GetRealtimeStatus()`,`buildDeviceResolveResponse()` 是独立的两处代码路径,都需要修复。
|
||||
</action>
|
||||
<verify>go build ./internal/service/device/... && go build ./internal/service/asset/...</verify>
|
||||
<done>
|
||||
- `toDeviceResponse()` 初始化块中含 5 个新字段赋值
|
||||
- `ListBindings()` 的 `DeviceCardBindingResponse` 含 `IsCurrent: binding.IsCurrent`
|
||||
- `buildDeviceResolveResponse()` 中含 `isCurrentMap` + `BoundCardInfo.IsCurrent` + 5 个 DB 缓存字段
|
||||
- `GetRealtimeStatus()` device case 中含 `isCurrentMap` + `BoundCardInfo.IsCurrent`
|
||||
- `go build` 编译通过
|
||||
</done>
|
||||
</task>
|
||||
|
||||
<task type="auto">
|
||||
<name>Task 3: 建立设备实时 Gateway 调用链路(GetRealtimeStatus + C 端 stub 替换)</name>
|
||||
<files>internal/service/asset/service.go, internal/handler/app/client_asset.go</files>
|
||||
<action>
|
||||
**A. `internal/service/asset/service.go:GetRealtimeStatus()` device case 追加 Gateway 实时调用(per D-07):**
|
||||
|
||||
在现有 `resp.DeviceProtectStatus = s.getDeviceProtectStatus(ctx, id)` 之后追加:
|
||||
|
||||
```go
|
||||
// 实时查询 Gateway 设备状态(in-line,不缓存,per D-05)
|
||||
// Gateway 失败不阻断主流程,DeviceRealtime 返回 null(per D-06)
|
||||
if s.gatewayClient != nil {
|
||||
device, devErr := s.deviceStore.GetByID(ctx, id)
|
||||
if devErr == nil {
|
||||
// IMEI 优先,无则用 SN(与 Refresh 中 updateDeviceFromSyncInfo 保持一致)
|
||||
cardNo := device.IMEI
|
||||
if cardNo == "" {
|
||||
cardNo = device.SN
|
||||
}
|
||||
if cardNo != "" {
|
||||
if syncResp, syncErr := s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{
|
||||
CardNo: cardNo,
|
||||
}); syncErr == nil {
|
||||
resp.DeviceRealtime = mapSyncRespToDeviceGatewayInfo(syncResp)
|
||||
} else {
|
||||
logger.GetAppLogger().Warn("GetRealtimeStatus: sync-info 调用失败,DeviceRealtime 返回 null",
|
||||
zap.Uint("device_id", id),
|
||||
zap.Error(syncErr))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在 `GetRealtimeStatus()` 函数之后(同一文件,`service.go` 中),新增私有转换函数:
|
||||
|
||||
```go
|
||||
// mapSyncRespToDeviceGatewayInfo 将 Gateway SyncDeviceInfoResp 映射为 B 端 DeviceGatewayInfo DTO
|
||||
// 使用指针字段,未赋值的字段保持 nil(omitempty)
|
||||
func mapSyncRespToDeviceGatewayInfo(r *gateway.SyncDeviceInfoResp) *dto.DeviceGatewayInfo {
|
||||
info := &dto.DeviceGatewayInfo{
|
||||
OnlineStatus: &r.OnlineStatus,
|
||||
RunTime: strPtr(r.RunTime),
|
||||
ConnectTime: strPtr(r.ConnectTime),
|
||||
LastOnlineTime: strPtr(r.LastOnlineTime),
|
||||
LastUpdateTime: strPtr(r.LastUpdateTime),
|
||||
Rsrp: &r.Rsrp,
|
||||
Rsrq: &r.Rsrq,
|
||||
Rssi: strPtr(r.RSSI),
|
||||
Sinr: &r.Sinr,
|
||||
SSID: strPtr(r.SSID),
|
||||
WifiEnabled: &r.WifiEnabled,
|
||||
WifiPassword: strPtr(r.WifiPassword),
|
||||
IPAddress: strPtr(r.IPAddress),
|
||||
WANIP: strPtr(r.WANIP),
|
||||
LANIP: strPtr(r.LANIP),
|
||||
MACAddress: strPtr(r.MacAddress),
|
||||
DailyUsage: strPtr(r.DailyUsage),
|
||||
DLStats: strPtr(r.DLStats),
|
||||
ULStats: strPtr(r.ULStats),
|
||||
LimitSpeed: &r.LimitSpeed,
|
||||
CurrentIccid: strPtr(r.CurrentIccid),
|
||||
MaxClients: &r.MaxClients,
|
||||
SoftwareVersion: strPtr(r.SoftwareVersion),
|
||||
SwitchMode: strPtr(r.SwitchMode),
|
||||
SyncInterval: &r.SyncInterval,
|
||||
DeviceID: strPtr(r.DeviceID),
|
||||
DeviceName: strPtr(r.DeviceName),
|
||||
DeviceType: strPtr(r.DeviceType),
|
||||
IMEI: strPtr(r.IMEI),
|
||||
IMSI: strPtr(r.IMSI),
|
||||
Status: &r.Status,
|
||||
}
|
||||
if r.BatteryLevel != nil {
|
||||
info.BatteryLevel = r.BatteryLevel
|
||||
}
|
||||
return info
|
||||
}
|
||||
|
||||
// strPtr 将空字符串转为 nil,非空字符串返回指针
|
||||
// 避免 JSON 响应中出现大量空字符串字段
|
||||
func strPtr(s string) *string {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return &s
|
||||
}
|
||||
```
|
||||
|
||||
注意:若 `strPtr` 函数已在其他地方定义,复用现有实现,不要重复定义。
|
||||
|
||||
**B. `internal/handler/app/client_asset.go:GetAsset()` 替换 mock(per D-11):**
|
||||
|
||||
定位 `handler/app/client_asset.go` 约 187-191 行的 mock 调用:
|
||||
```go
|
||||
// 旧代码(删除):
|
||||
if resp.AssetType == "device" {
|
||||
resp.DeviceRealtime = buildMockDeviceRealtime()
|
||||
}
|
||||
```
|
||||
|
||||
替换为:
|
||||
```go
|
||||
// 调用服务层获取设备实时 Gateway 数据(per D-11)
|
||||
// Gateway 失败时 DeviceRealtime 为 null,不阻断主流程(per D-06)
|
||||
if resp.AssetType == "device" {
|
||||
realtimeResp, realtimeErr := h.assetService.GetRealtimeStatus(
|
||||
c.UserContext(), "device", resp.AssetID,
|
||||
)
|
||||
if realtimeErr == nil && realtimeResp.DeviceRealtime != nil {
|
||||
// 将 B 端 DeviceGatewayInfo 映射到 C 端 DeviceRealtimeInfo
|
||||
resp.DeviceRealtime = mapDeviceGatewayInfoToClientInfo(realtimeResp.DeviceRealtime)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
在同一文件中,新增私有映射函数(替换被删除的 `buildMockDeviceRealtime()`):
|
||||
|
||||
```go
|
||||
// mapDeviceGatewayInfoToClientInfo 将 B 端 DeviceGatewayInfo 映射为 C 端 DeviceRealtimeInfo
|
||||
// B 端和 C 端结构独立,通过映射函数转换(per D-08)
|
||||
func mapDeviceGatewayInfoToClientInfo(g *dto.DeviceGatewayInfo) *dto.DeviceRealtimeInfo {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
info := &dto.DeviceRealtimeInfo{}
|
||||
if g.OnlineStatus != nil {
|
||||
v := int64(*g.OnlineStatus)
|
||||
info.OnlineStatus = &v
|
||||
}
|
||||
if g.BatteryLevel != nil {
|
||||
v := int64(*g.BatteryLevel)
|
||||
info.BatteryLevel = &v
|
||||
}
|
||||
if g.Status != nil {
|
||||
v := int64(*g.Status)
|
||||
info.Status = &v
|
||||
}
|
||||
info.RunTime = g.RunTime
|
||||
info.ConnectTime = g.ConnectTime
|
||||
info.LastOnlineTime = g.LastOnlineTime
|
||||
info.LastUpdateTime = g.LastUpdateTime
|
||||
if g.Rsrp != nil {
|
||||
v := int64(*g.Rsrp)
|
||||
info.Rsrp = &v
|
||||
}
|
||||
if g.Rsrq != nil {
|
||||
v := int64(*g.Rsrq)
|
||||
info.Rsrq = &v
|
||||
}
|
||||
info.Rssi = g.Rssi
|
||||
if g.Sinr != nil {
|
||||
v := int64(*g.Sinr)
|
||||
info.Sinr = &v
|
||||
}
|
||||
info.SSID = g.SSID
|
||||
info.WifiEnabled = g.WifiEnabled
|
||||
info.WifiPassword = g.WifiPassword
|
||||
info.IPAddress = g.IPAddress
|
||||
info.WANIP = g.WANIP
|
||||
info.LANIP = g.LANIP
|
||||
info.MACAddress = g.MACAddress
|
||||
info.DailyUsage = g.DailyUsage
|
||||
info.DLStats = g.DLStats
|
||||
info.ULStats = g.ULStats
|
||||
if g.LimitSpeed != nil {
|
||||
v := int64(*g.LimitSpeed)
|
||||
info.LimitSpeed = &v
|
||||
}
|
||||
info.CurrentIccid = g.CurrentIccid
|
||||
if g.MaxClients != nil {
|
||||
v := int64(*g.MaxClients)
|
||||
info.MaxClients = &v
|
||||
}
|
||||
info.SoftwareVersion = g.SoftwareVersion
|
||||
if g.SwitchMode != nil {
|
||||
v := int(*g.SwitchMode) // C端 SwitchMode 是 *int
|
||||
info.SwitchMode = &v
|
||||
}
|
||||
if g.SyncInterval != nil {
|
||||
v := int64(*g.SyncInterval)
|
||||
info.SyncInterval = &v
|
||||
}
|
||||
info.DeviceID = g.DeviceID
|
||||
info.DeviceName = g.DeviceName
|
||||
info.DeviceType = g.DeviceType
|
||||
info.Imei = g.IMEI
|
||||
info.Imsi = g.IMSI
|
||||
return info
|
||||
}
|
||||
```
|
||||
|
||||
最后,**删除** `buildMockDeviceRealtime()` 函数(约 595-640 行),该函数不再需要。
|
||||
</action>
|
||||
<verify>go build ./internal/service/asset/... && go build ./internal/handler/app/...</verify>
|
||||
<done>
|
||||
- `GetRealtimeStatus()` device case 末尾有 Gateway 调用 + `mapSyncRespToDeviceGatewayInfo` 函数存在
|
||||
- `GetAsset()` 中 `buildMockDeviceRealtime()` 调用已删除,改为调用 `h.assetService.GetRealtimeStatus()` + `mapDeviceGatewayInfoToClientInfo()`
|
||||
- `buildMockDeviceRealtime()` 函数已删除
|
||||
- `go build ./...` 全量编译通过(无编译错误)
|
||||
</done>
|
||||
</task>
|
||||
|
||||
</tasks>
|
||||
|
||||
<verification>
|
||||
```bash
|
||||
# 全量编译验证(必须通过)
|
||||
go build ./...
|
||||
|
||||
# 静态检查(无新增错误)
|
||||
go vet ./internal/gateway/... ./internal/model/dto/... ./internal/service/asset/... ./internal/service/device/... ./internal/handler/app/...
|
||||
```
|
||||
|
||||
**手动验证清单(通过 DB 和 API 确认):**
|
||||
|
||||
1. 设备管理列表接口(GET /api/admin/devices)— 响应中含 `online_status`、`software_version` 等字段
|
||||
2. 设备绑卡列表接口(GET /api/admin/devices/:id/cards)— 响应中 `bindings[].is_current` 有值
|
||||
3. 资产解析接口(GET /api/admin/assets/resolve/:identifier,标识符为设备号)— 响应中含 `online_status`、`last_gateway_sync_at` 等字段,`cards[].is_current` 有值
|
||||
4. 资产实时状态接口(GET /api/admin/assets/:type/:id/realtime,type=device)— 响应中含 `device_realtime`(若 Gateway 可达则有值,不可达则为 null);`cards[].is_current` 有值
|
||||
5. C 端资产信息接口(GET /api/c/v1/asset,identifier=设备IMEI/SN)— 响应中 `device_realtime` 非 mock 数据(含真实 `online_status`/`battery_level` 等字段)
|
||||
</verification>
|
||||
|
||||
<success_criteria>
|
||||
- `go build ./...` 编译通过,无错误无 warning
|
||||
- 所有 DTO 静态映射缺口已补全(5 处字段 + 2 处 IsCurrent + 5 处 DB 缓存字段)
|
||||
- `GetRealtimeStatus("device", id)` 调用 Gateway sync-info,失败时 `DeviceRealtime` 为 null
|
||||
- C 端 `GetAsset` device 类型不再返回 mock 数据
|
||||
- 代码符合项目规范:中文注释、分层架构、错误只记 Warn 不阻断
|
||||
</success_criteria>
|
||||
|
||||
<output>
|
||||
完成后创建 `.planning/phases/03.1-sync-info-phase-3-dto/03.1-01-SUMMARY.md`,记录:
|
||||
- 实际修改的文件和行号
|
||||
- `mapSyncRespToDeviceGatewayInfo` 和 `mapDeviceGatewayInfoToClientInfo` 的实现位置
|
||||
- 编译通过的确认
|
||||
- 任何与计划不符的实现调整(并说明原因)
|
||||
</output>
|
||||
185
.planning/phases/03.1-sync-info-phase-3-dto/03.1-01-SUMMARY.md
Normal file
185
.planning/phases/03.1-sync-info-phase-3-dto/03.1-01-SUMMARY.md
Normal file
@@ -0,0 +1,185 @@
|
||||
---
|
||||
phase: 03.1-sync-info-phase-3-dto
|
||||
plan: "01"
|
||||
subsystem: asset/device
|
||||
tags:
|
||||
- gateway
|
||||
- dto
|
||||
- device
|
||||
- realtime
|
||||
dependency_graph:
|
||||
requires:
|
||||
- Phase 3 DEVICE-01~04(DB 字段写入已完成)
|
||||
provides:
|
||||
- 设备管理列表/详情返回 5 个 DB 缓存字段
|
||||
- 设备绑卡列表返回 is_current 字段
|
||||
- 资产 Resolve/Refresh BoundCardInfo 含 is_current
|
||||
- admin 资产解析返回 5 个 DB 缓存字段
|
||||
- GetRealtimeStatus device 类型实时调用 Gateway
|
||||
- C 端 GetAssetInfo device_realtime 为真实 Gateway 数据
|
||||
affects:
|
||||
- GET /api/admin/devices(新增 5 字段)
|
||||
- GET /api/admin/devices/:id/cards(新增 is_current)
|
||||
- GET /api/admin/assets/resolve/:identifier(新增 5 字段 + is_current)
|
||||
- GET /api/admin/assets/:type/:id/realtime(新增 device_realtime)
|
||||
- GET /api/c/v1/asset/info(device_realtime 从 mock 改为真实数据)
|
||||
tech_stack:
|
||||
added: []
|
||||
patterns:
|
||||
- isCurrentMap 与 slotMap 同构模式(asset/service.go)
|
||||
- strPtr() 辅助函数(空字符串→nil,防止 JSON 冗余字段)
|
||||
- mapSyncRespToDeviceGatewayInfo() Gateway DTO → B端 DTO 映射
|
||||
- mapDeviceGatewayInfoToClientInfo() B端 DTO → C端 DTO 映射
|
||||
key_files:
|
||||
created: []
|
||||
modified:
|
||||
- internal/gateway/models.go(+16 行:SyncDeviceInfoResp 补全 15 个字段)
|
||||
- internal/model/dto/asset_dto.go(+77 行:DeviceGatewayInfo 新增;AssetRealtimeStatusResponse.DeviceRealtime 新增;AssetResolveResponse 5 字段新增)
|
||||
- internal/service/device/service.go(+5 行:toDeviceResponse 补全 5 字段)
|
||||
- internal/service/device/binding.go(+1 行:ListBindings IsCurrent 补全)
|
||||
- internal/service/asset/service.go(+84 行:isCurrentMap 两处补全;Gateway 实时调用;mapSyncRespToDeviceGatewayInfo;strPtr)
|
||||
- internal/handler/app/client_asset.go(+113/-41 行:mock 替换为真实 Gateway 调用;mapDeviceGatewayInfoToClientInfo)
|
||||
decisions:
|
||||
- DeviceGatewayInfo(B端)与 DeviceRealtimeInfo(C端)独立结构,通过映射函数转换(per D-08)
|
||||
- SwitchMode 在 B端为 *string(Gateway 原始),C端为 *int,映射时使用 strconv.Atoi 转换
|
||||
- strPtr() 在 asset/service.go 包内独立定义(order/service.go 已有同名函数但不同包)
|
||||
metrics:
|
||||
duration: 5min
|
||||
completed_date: "2026-03-28"
|
||||
tasks: 3
|
||||
files_modified: 6
|
||||
---
|
||||
|
||||
# Phase 03.1 Plan 01: 设备读取链路修复 + DTO 映射补全 Summary
|
||||
|
||||
**一句话总结:** 补全 Phase 3 设备数据写入链路的读取侧缺口——6 个字段映射缺口修复 + Gateway sync-info 实时调用链路建立 + C端 mock 数据替换。
|
||||
|
||||
---
|
||||
|
||||
## 完成的任务
|
||||
|
||||
### Task 1:补全 Gateway 模型和 B 端 DTO 契约
|
||||
|
||||
**`internal/gateway/models.go`(第 162-183 行):**
|
||||
|
||||
`SyncDeviceInfoResp` 新增 15 个缺失字段:
|
||||
- 信号:`Rsrp`、`Rsrq`、`Sinr`
|
||||
- WiFi:`WifiPassword`
|
||||
- 网络:`IPAddress`、`WANIP`、`LANIP`
|
||||
- 连接:`MaxClients`、`ConnectTime`
|
||||
- 设备:`Status`、`IMSI`、`ULStats`、`DLStats`、`LimitSpeed`、`SyncInterval`
|
||||
|
||||
**`internal/model/dto/asset_dto.go`:**
|
||||
|
||||
1. 新增 `DeviceGatewayInfo` 结构体(第 138-188 行),49 字段,全部含 `description` tag(per D-08/D-09)
|
||||
2. `AssetRealtimeStatusResponse` 新增 `DeviceRealtime *DeviceGatewayInfo` 字段(第 77 行)(per D-07/D-08)
|
||||
3. `AssetResolveResponse` 新增 5 个设备 DB 缓存字段(第 44-49 行)(per D-12):
|
||||
- `OnlineStatus`、`LastOnlineTime`、`SoftwareVersion`、`SwitchMode`、`LastGatewaySyncAt`
|
||||
|
||||
---
|
||||
|
||||
### Task 2:修复静态 DTO 映射缺口
|
||||
|
||||
**`internal/service/device/service.go`(`toDeviceResponse()`,第 559-563 行):**
|
||||
|
||||
补全 5 个 DB 缓存字段赋值:
|
||||
```go
|
||||
OnlineStatus: device.OnlineStatus,
|
||||
LastOnlineTime: device.LastOnlineTime,
|
||||
SoftwareVersion: device.SoftwareVersion,
|
||||
SwitchMode: device.SwitchMode,
|
||||
LastGatewaySyncAt: device.LastGatewaySyncAt,
|
||||
```
|
||||
|
||||
**`internal/service/device/binding.go`(`ListBindings()`,第 60 行):**
|
||||
|
||||
```go
|
||||
IsCurrent: binding.IsCurrent,
|
||||
```
|
||||
|
||||
**`internal/service/asset/service.go`(两处修复):**
|
||||
|
||||
1. `buildDeviceResolveResponse()`(第 124-128 行):新增 `isCurrentMap` + `IsCurrent` + 5 个 DB 缓存字段
|
||||
2. `GetRealtimeStatus()` device case(第 274-279 行):新增 `isCurrentMap` + `IsCurrent`
|
||||
|
||||
两处均采用与现有 `slotMap` 同构的 `isCurrentMap` 模式:
|
||||
```go
|
||||
isCurrentMap := make(map[uint]bool, len(bindings))
|
||||
// ...
|
||||
isCurrentMap[b.IotCardID] = b.IsCurrent
|
||||
// ...
|
||||
IsCurrent: isCurrentMap[c.ID],
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 3:建立设备实时 Gateway 调用链路
|
||||
|
||||
**`internal/service/asset/service.go`:**
|
||||
|
||||
1. `GetRealtimeStatus()` device case 末尾(第 291-312 行)新增 Gateway 实时调用:
|
||||
- `gatewayClient != nil` 安全检查
|
||||
- IMEI 优先,无则用 SN(与 Refresh 逻辑一致)
|
||||
- 调用 `s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{CardNo: cardNo})`
|
||||
- 成功时调用 `mapSyncRespToDeviceGatewayInfo(syncResp)` 填充 `resp.DeviceRealtime`
|
||||
- 失败时仅记录 Warn 日志,`DeviceRealtime` 保持 nil(per D-06)
|
||||
|
||||
2. 新增 `mapSyncRespToDeviceGatewayInfo()`(第 330-369 行):Gateway resp → B端 DTO 映射
|
||||
|
||||
3. 新增 `strPtr()`(第 371-378 行):辅助函数,将空字符串转为 nil 指针
|
||||
|
||||
**`internal/handler/app/client_asset.go`:**
|
||||
|
||||
1. `GetAssetInfo()`(第 188-197 行):删除 `buildMockDeviceRealtime()` 调用,替换为:
|
||||
```go
|
||||
realtimeResp, realtimeErr := h.assetService.GetRealtimeStatus(
|
||||
c.UserContext(), "device", resp.AssetID,
|
||||
)
|
||||
if realtimeErr == nil && realtimeResp.DeviceRealtime != nil {
|
||||
resp.DeviceRealtime = mapDeviceGatewayInfoToClientInfo(realtimeResp.DeviceRealtime)
|
||||
}
|
||||
```
|
||||
|
||||
2. 删除 `buildMockDeviceRealtime()` 函数(原第 595-635 行)
|
||||
|
||||
3. 新增 `mapDeviceGatewayInfoToClientInfo()`(第 595-660 行):B端 DeviceGatewayInfo → C端 DeviceRealtimeInfo 映射
|
||||
|
||||
---
|
||||
|
||||
## 编译验证
|
||||
|
||||
```
|
||||
go build ./... → 全量编译通过
|
||||
go vet ./internal/gateway/... ./internal/model/dto/... ./internal/service/asset/... ./internal/service/device/... ./internal/handler/app/... → 无新增错误
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
### Auto-fixed Issues
|
||||
|
||||
**1. [Rule 1 - Bug] SwitchMode 类型不匹配**
|
||||
|
||||
- **Found during:** Task 3
|
||||
- **Issue:** 计划中 `mapDeviceGatewayInfoToClientInfo` 的 `SwitchMode` 转换代码为 `v := int(*g.SwitchMode)`,但 `g.SwitchMode` 是 `*string`(B端 Gateway DTO 定义),`DeviceRealtimeInfo.SwitchMode` 是 `*int`(C端 DTO),直接转换会编译报错
|
||||
- **Fix:** 使用 `strconv.Atoi(*g.SwitchMode)` 进行字符串→整型安全转换,解析失败时忽略(`SwitchMode` 保持 nil)
|
||||
- **Files modified:** `internal/handler/app/client_asset.go`
|
||||
- **Commit:** `a202b2d`
|
||||
|
||||
---
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| `internal/gateway/models.go` 存在 | ✅ FOUND |
|
||||
| `internal/model/dto/asset_dto.go` 存在 | ✅ FOUND |
|
||||
| `internal/service/device/service.go` 存在 | ✅ FOUND |
|
||||
| `internal/service/device/binding.go` 存在 | ✅ FOUND |
|
||||
| `internal/service/asset/service.go` 存在 | ✅ FOUND |
|
||||
| `internal/handler/app/client_asset.go` 存在 | ✅ FOUND |
|
||||
| 提交 `00dba42` 存在 | ✅ FOUND |
|
||||
| 提交 `3b54850` 存在 | ✅ FOUND |
|
||||
| 提交 `a202b2d` 存在 | ✅ FOUND |
|
||||
| `go build ./...` 编译通过 | ✅ 通过 |
|
||||
170
.planning/phases/03.1-sync-info-phase-3-dto/03.1-CONTEXT.md
Normal file
170
.planning/phases/03.1-sync-info-phase-3-dto/03.1-CONTEXT.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Phase 03.1: 设备 sync-info 读取链路修复 - Context
|
||||
|
||||
**Gathered:** 2026-03-28
|
||||
**Status:** Ready for planning
|
||||
|
||||
<domain>
|
||||
## Phase Boundary
|
||||
|
||||
Phase 3 完成了设备数据的**写入侧**(sync-info 调用 + DB 字段写入 + is_current 更新),但遗漏了**读取侧**的 DTO 映射和设备实时数据的架构对齐。
|
||||
|
||||
本 Phase 修复两类缺口:
|
||||
|
||||
1. **静态 DTO 映射缺口**(DB 字段已存储但未通过 API 返回)
|
||||
- `toDeviceResponse` 5 个字段未映射 → 影响设备管理列表/详情
|
||||
- `ListBindings` IsCurrent 未映射 → 影响设备绑卡列表接口
|
||||
- `BoundCardInfo` IsCurrent 未映射(2 处)→ 影响资产 Resolve/Refresh 响应
|
||||
- `AssetResolveResponse` 缺 5 个 DB 缓存字段 → 影响 admin 资产解析接口
|
||||
|
||||
2. **设备实时数据架构对齐**(统一视图的上游属性必须实时查询)
|
||||
- 设备上游属性(在线状态/信号/电量/WiFi/当前卡)具有实时特性,存 DB 无意义,必须实时从 Gateway 拉取
|
||||
- `GetRealtimeStatus` 对 device 类型实时调用 Gateway sync-info
|
||||
- C 端 `GetAsset` 的 `DeviceRealtimeInfo` stub 替换为真实 Gateway 数据
|
||||
|
||||
**不在本 Phase 范围:**
|
||||
- 卡资产的上游属性(走轮询系统,已由 DB 存储,无需改动)
|
||||
- 设备管理列表(展示 DB 缓存字段即可,不需要实时 Gateway 调用)
|
||||
- 设备 Refresh 流程(已有 updateDeviceFromSyncInfo,保持现状)
|
||||
|
||||
</domain>
|
||||
|
||||
<decisions>
|
||||
## Implementation Decisions
|
||||
|
||||
### Plan 分组
|
||||
- **D-01:** 全部修复合并为 **1 个 Plan**。所有变更同属"DTO 映射缺口 + 实时数据架构对齐",无依赖关系需拆分。
|
||||
|
||||
### 静态 DTO 缺口修复
|
||||
|
||||
- **D-02:** `device/service.go:toDeviceResponse()` 补全 5 个 DB 缓存字段:
|
||||
- `OnlineStatus`、`LastOnlineTime`、`SoftwareVersion`、`SwitchMode`、`LastGatewaySyncAt`
|
||||
- 这些字段对设备管理列表(List)仍有意义(展示 DB 缓存值),保留此映射
|
||||
|
||||
- **D-03:** `device/binding.go:ListBindings()` 补全 `IsCurrent` 字段映射
|
||||
- `DeviceCardBindingResponse.IsCurrent` 直接从 `binding.IsCurrent` 取值
|
||||
|
||||
- **D-04:** `asset/service.go` 中 2 处 `BoundCardInfo` 构建(Resolve path + Refresh path)补全 `IsCurrent`
|
||||
- 实现方式:在 `bindings` 遍历中同时构建 `isCurrentMap[b.IotCardID] = b.IsCurrent`(与 `slotMap` 同构)
|
||||
- 构建 `BoundCardInfo` 时追加 `IsCurrent: isCurrentMap[c.ID]`
|
||||
|
||||
- **D-12:** `asset_dto.go:AssetResolveResponse` 新增 5 个 DB 缓存字段,同步更新 `asset/service.go:buildDeviceResolveResponse()` 映射
|
||||
- 新增字段:`OnlineStatus int`、`LastOnlineTime *time.Time`、`SoftwareVersion string`、`SwitchMode string`、`LastGatewaySyncAt *time.Time`
|
||||
- 这些是 DB 存储值(Phase 3 写入,上次 sync-info 的快照),不触发 Gateway 调用
|
||||
- 适用场景:admin 资产 Resolve 接口(`/api/admin/assets/resolve/:identifier`)快速展示设备上次已知状态
|
||||
- 与 D-07(GetRealtimeStatus 实时 Gateway 调用)互补:Resolve = 快速快照,Realtime = 精确实时
|
||||
|
||||
### 设备实时数据架构原则
|
||||
|
||||
- **D-05:** 设备上游属性(在线/信号/电量/WiFi/当前卡)**不缓存,必须实时查询 Gateway**。原因:设备具有在线/离线实时特性,存 DB 的值在设备离线后立即过时,无法代表真实状态。
|
||||
- 对比:卡资产的上游属性通过轮询系统有规律地同步到 DB,查询 DB 有意义。
|
||||
|
||||
- **D-06:** Gateway 调用失败时(设备离线/超时/IMEI 为空)**返回部分数据**:`DeviceRealtime` 字段为 null,资产基础信息正常返回。不抛错误,与卡资产处理保持一致。
|
||||
|
||||
### GetRealtimeStatus 改造
|
||||
|
||||
- **D-07:** `asset/service.go:GetRealtimeStatus()` 对 device 类型新增 Gateway 实时调用:
|
||||
- 调用 `s.gatewayClient.SyncDeviceInfo(ctx, &gateway.SyncDeviceInfoReq{IMEI: device.IMEI})`(gatewayClient 已注入)
|
||||
- 成功:将响应映射到新增的 `AssetRealtimeStatusResponse.DeviceRealtime *DeviceGatewayInfo` 字段
|
||||
- 失败:仅记录 Warn 日志,`DeviceRealtime` 返回 null
|
||||
|
||||
### DTO 结构
|
||||
|
||||
- **D-08:** B 端和 C 端使用**独立结构体**,不共用。原因:未来管理端可能展示 C 端不展示的字段(如 IMSI、DL/UL stats、LAN/WAN IP 等技术字段)。
|
||||
- **B 端**:新建 `DeviceGatewayInfo` struct,位于 `internal/model/dto/asset_dto.go`
|
||||
- **C 端**:保留现有 `DeviceRealtimeInfo` struct,位于 `internal/model/dto/client_asset_dto.go`
|
||||
- `AssetRealtimeStatusResponse` 新增 `DeviceRealtime *DeviceGatewayInfo` 字段
|
||||
|
||||
- **D-09:** `DeviceGatewayInfo`(B 端)至少包含以下字段(参考 Gateway sync-info 文档完整映射):
|
||||
- 关键:`OnlineStatus`、`BatteryLevel`、`Rsrp/Rsrq/Rssi/Sinr`(信号)、`MaxClients`(连接数)
|
||||
- WiFi:`SSID`、`WifiPassword`
|
||||
- 当前卡:`CurrentIccid`
|
||||
- 附加:`SoftwareVersion`、`SwitchMode`、`RunTime`、`DailyUsage`、`LastOnlineTime`
|
||||
|
||||
- **D-10:** `gateway.SyncDeviceInfoResp` 当前缺少字段,需补全:
|
||||
- 补充:`Rsrp int`、`Rsrq int`、`Sinr int`、`WifiPassword string`、`IPAddress string`
|
||||
- 补充:`WANIP string`、`LANIP string`、`MaxClients int`、`ConnectTime string`
|
||||
- 补充:`Status int`(设备状态)、`IMSI string`、`ULStats string`、`DLStats string`
|
||||
- 补充:`LimitSpeed int`、`SyncInterval int`、`MACAddress string`
|
||||
|
||||
### C 端 DeviceRealtime stub 替换
|
||||
|
||||
- **D-11:** C 端 `client_asset.go:GetAsset()` 中对 device 类型的处理:
|
||||
- 删除 `buildMockDeviceRealtime()` 调用
|
||||
- 改为调用 `h.assetService.GetRealtimeStatus("device", deviceID)` 获取实时数据
|
||||
- 将返回的 `AssetRealtimeStatusResponse.DeviceRealtime (*DeviceGatewayInfo)` 映射到 `ClientAssetResponse.DeviceRealtime (*DeviceRealtimeInfo)`
|
||||
- Gateway 失败时 `resp.DeviceRealtime = nil`(已处理,不额外报错)
|
||||
|
||||
### Agent's Discretion
|
||||
|
||||
- `DeviceGatewayInfo` B 端结构体的字段是否包含所有 Gateway 字段或只包含关键字段,agent 可参考 `DeviceRealtimeInfo`(C 端)的完整字段集合决定
|
||||
- `toDeviceResponse` 中 `GetRealtimeStatus` 调用的冷却 Key 策略(是否需要防止高频调用)由 agent 参考现有 Refresh 冷却 Key 逻辑自行决定
|
||||
|
||||
</decisions>
|
||||
|
||||
<canonical_refs>
|
||||
## Canonical References
|
||||
|
||||
**Downstream agents MUST read these before planning or implementing.**
|
||||
|
||||
### Gateway 接口文档
|
||||
- `docs/第三方文档/gateway设备详情同步接口.md` — sync-info 完整字段说明(data 字段映射表,包含 rsrp/rsrq/sinr/wifi_password 等当前 SyncDeviceInfoResp 缺失的字段)
|
||||
|
||||
### Phase 3 上下文(继承决策)
|
||||
- `.planning/phases/03-device-system/03-CONTEXT.md` — Phase 3 所有决策(D-06:结构体位置;D-13/14/15:updateDeviceFromSyncInfo 实现细节;D-08/09:存储字段范围)
|
||||
|
||||
### 核心修改文件
|
||||
- `internal/service/device/service.go` — `toDeviceResponse()`:补全 5 个字段
|
||||
- `internal/service/device/binding.go` — `ListBindings()`:补全 IsCurrent
|
||||
- `internal/service/asset/service.go` — `GetRealtimeStatus()`:新增 Gateway 调用;Resolve/Refresh 两处 BoundCardInfo:补全 IsCurrent
|
||||
- `internal/model/dto/asset_dto.go` — 新增 `DeviceGatewayInfo`;`AssetRealtimeStatusResponse` 新增 `DeviceRealtime` 字段;`AssetResolveResponse` 新增 5 个 DB 缓存字段(D-12)
|
||||
- `internal/model/dto/device_dto.go` — 已有字段,无需新增(确认映射即可)
|
||||
- `internal/gateway/models.go` — `SyncDeviceInfoResp` 补全缺失字段
|
||||
- `internal/handler/app/client_asset.go` — 删除 `buildMockDeviceRealtime()` stub,改为调用服务 + 映射
|
||||
|
||||
### 当前 SyncDeviceInfoResp 位置
|
||||
- `internal/gateway/models.go:147` — 当前结构(缺 Rsrp/Rsrq/Sinr/WifiPassword/LANIP/MaxClients 等)
|
||||
|
||||
</canonical_refs>
|
||||
|
||||
<code_context>
|
||||
## Existing Code Insights
|
||||
|
||||
### Reusable Assets
|
||||
- `internal/service/asset/service.go:gatewayClient` — 已注入,`GetRealtimeStatus` 可直接使用
|
||||
- `internal/service/asset/service.go:updateDeviceFromSyncInfo()` — Phase 3 已实现,展示了 SyncDeviceInfoResp → DB 的映射模式,可参考作为 Gateway 响应 → DTO 映射的参考
|
||||
- `internal/gateway/device.go:SyncDeviceInfo()` — 已实现(Phase 3),直接复用
|
||||
- `internal/model/dto/client_asset_dto.go:DeviceRealtimeInfo` — 完整的 C 端实时信息结构(46 字段),对应所有 Gateway 字段,作为 `DeviceGatewayInfo` 的参考
|
||||
|
||||
### Established Patterns
|
||||
- isCurrentMap 模式:参考 `slotMap` 在 `asset/service.go` 的构建方式,`isCurrentMap[b.IotCardID] = b.IsCurrent`
|
||||
- Gateway 失败不阻断主流程:`logger.Warn("...")`,继续返回部分数据
|
||||
- B 端 DTO 字段均需 `description` tag(参考 `asset_dto.go` 现有字段)
|
||||
|
||||
### Integration Points
|
||||
- `asset/service.go:GetRealtimeStatus()` — device case 追加 Gateway 调用入口(现只有 `getDeviceProtectStatus` 和 bindings 查询)
|
||||
- `handler/app/client_asset.go:GetAsset()` — 约第 187-191 行,device 类型的 `resp.DeviceRealtime = buildMockDeviceRealtime()` 替换入口
|
||||
- `internal/model/dto/asset_dto.go:AssetRealtimeStatusResponse` — 新增 `DeviceRealtime *DeviceGatewayInfo` 字段位置
|
||||
|
||||
</code_context>
|
||||
|
||||
<specifics>
|
||||
## Specific Ideas
|
||||
|
||||
- 用户明确:设备上游属性(在线/信号/电量/WiFi/当前卡)**不应存 DB**,必须实时查询。现有的 `online_status` 等 DB 字段对于设备管理列表展示仍有价值(缓存上次同步值)。
|
||||
- 用户明确:关键实时字段优先级 — 在线状态 > 信号强度 > 连接数 > 电量 > WiFi 名称 + WiFi 密码 > 当前使用卡 ICCID
|
||||
- `DeviceGatewayInfo`(B 端)与 `DeviceRealtimeInfo`(C 端)结构独立,为未来管理端展示更多技术字段(IMSI、LAN/WAN IP、DL/UL stats 等)预留空间
|
||||
|
||||
</specifics>
|
||||
|
||||
<deferred>
|
||||
## Deferred Ideas
|
||||
|
||||
- 设备 Refresh 接口的语义重新评估(用户提到"刷新接口对设备而言意义不大")——当前 Refresh 仍保留(写回 DB 供管理列表使用),但未来可能弃用 —— Phase 4+ 评估
|
||||
- `GetRealtimeStatus` 的防高频调用冷却 Key(若 C 端每次进入详情页都触发 Gateway 调用,需评估频率)—— 留待 Phase 4 运营优化
|
||||
|
||||
</deferred>
|
||||
|
||||
---
|
||||
|
||||
*Phase: 03.1-sync-info-phase-3-dto*
|
||||
*Context gathered: 2026-03-28 (updated: 2026-03-28, added D-12)*
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user