Compare commits
45 Commits
e139f5e227
...
Iteration/
| Author | SHA1 | Date | |
|---|---|---|---|
| d022cc8788 | |||
| bcf3e31db6 | |||
| c4f430ccb3 | |||
| 1a9db9328e | |||
| 2e130b98f5 | |||
| 5cdcdad534 | |||
| d2e08dbbec | |||
| 026d4908d8 | |||
| 31232ea899 | |||
| b38df737e1 | |||
| 346156ee9b | |||
| 0d79130e07 | |||
| b3fb8c7a82 | |||
| 44fb21eb6a | |||
| 8f738ffbe8 | |||
| 6db152bb2f | |||
| fc6af43baa | |||
| 52bdbbae25 | |||
| 76f657c986 | |||
| a2793f7bec | |||
| b5b7f9a41e | |||
| b930662817 | |||
| ab9da7bb33 | |||
| 781e82441d | |||
| f4b6a55b27 | |||
| 8cf921f11c | |||
| c5871b59a1 | |||
| c0b9604515 | |||
| 5c2ef97c24 | |||
| 2885b503b3 | |||
| 062f5a436f | |||
| ba435dd6a6 | |||
| 3f21f7a7d6 | |||
| 5f960daf78 | |||
| 3b7b856e48 | |||
| cf36f1447f | |||
|
|
1f634eb465 | ||
|
|
84ab3bad99 | ||
|
|
6c8594633a | ||
|
|
064961471b | ||
|
|
e37aad9e1d | ||
|
|
7ec84fbc0f | ||
|
|
2f0b24ce88 | ||
|
|
e56649e5be | ||
|
|
1c492fe8db |
30
.agents/rules/ponytail.md
Normal file
30
.agents/rules/ponytail.md
Normal file
@@ -0,0 +1,30 @@
|
||||
# Ponytail, lazy senior dev mode
|
||||
|
||||
You are a lazy senior developer. Lazy means efficient, not careless. The best code is the code never written.
|
||||
|
||||
Before writing any code, stop at the first rung that holds:
|
||||
|
||||
1. Does this need to be built at all? (YAGNI)
|
||||
2. Does it already exist in this codebase? Reuse the helper, util, or pattern that's already here, don't re-write it.
|
||||
3. Does the standard library already do this? Use it.
|
||||
4. Does a native platform feature cover it? Use it.
|
||||
5. Does an already-installed dependency solve it? Use it.
|
||||
6. Can this be one line? Make it one line.
|
||||
7. Only then: write the minimum code that works.
|
||||
|
||||
The ladder runs after you understand the problem, not instead of it: read the task and the code it touches, trace the real flow end to end, then climb.
|
||||
|
||||
Bug fix = root cause, not symptom: a report names a symptom. Grep every caller of the function you touch and fix the shared function once — one guard there is a smaller diff than one per caller, and patching only the path the ticket names leaves a sibling caller still broken.
|
||||
|
||||
Rules:
|
||||
|
||||
- No abstractions that weren't explicitly requested.
|
||||
- No new dependency if it can be avoided.
|
||||
- No boilerplate nobody asked for.
|
||||
- Deletion over addition. Boring over clever. Fewest files possible.
|
||||
- Shortest working diff wins, but only once you understand the problem. The smallest change in the wrong place isn't lazy, it's a second bug.
|
||||
- Question complex requests: "Do you actually need X, or does Y cover it?"
|
||||
- Pick the edge-case-correct option when two stdlib approaches are the same size, lazy means less code, not the flimsier algorithm.
|
||||
- Mark intentional simplifications with a `ponytail:` comment. If the shortcut has a known ceiling (global lock, O(n²) scan, naive heuristic), the comment names the ceiling and the upgrade path.
|
||||
|
||||
Not lazy about: understanding the problem (read it fully and trace the real flow before picking a rung, a small diff you don't understand is just laziness dressed up as efficiency), input validation at trust boundaries, error handling that prevents data loss, security, accessibility, the calibration real hardware needs (the platform is never the spec ideal, a clock drifts, a sensor reads off), anything explicitly requested. Lazy code without its check is unfinished: non-trivial logic leaves ONE runnable check behind, the smallest thing that fails if the logic breaks (an assert-based demo/self-check or one small test file; no frameworks, no fixtures). Trivial one-liners need no test.
|
||||
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"
|
||||
7
.agents/skills/grill-me/SKILL.md
Normal file
7
.agents/skills/grill-me/SKILL.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: A relentless interview to sharpen a plan or design.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Run a `/grilling` session.
|
||||
7
.agents/skills/grill-with-docs/SKILL.md
Normal file
7
.agents/skills/grill-with-docs/SKILL.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
name: grill-with-docs
|
||||
description: A relentless interview to sharpen a plan or design, which also creates docs (ADR's and glossary) as we go.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Run a `/grilling` session, using the `/domain-modeling` skill.
|
||||
16
.agents/skills/handoff/SKILL.md
Normal file
16
.agents/skills/handoff/SKILL.md
Normal file
@@ -0,0 +1,16 @@
|
||||
---
|
||||
name: handoff
|
||||
description: Compact the current conversation into a handoff document for another agent to pick up.
|
||||
argument-hint: "What will the next session be used for?"
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Write a handoff document summarising the current conversation so a fresh agent can continue the work. Save to the temporary directory of the user's OS - not the current workspace.
|
||||
|
||||
Include a "suggested skills" section in the document, which suggests skills that the agent should invoke.
|
||||
|
||||
Do not duplicate content already captured in other artifacts (PRDs, plans, ADRs, issues, commits, diffs). Reference them by path or URL instead.
|
||||
|
||||
Redact any sensitive information, such as API keys, passwords, or personally identifiable information.
|
||||
|
||||
If the user passed arguments, treat them as a description of what the next session will focus on and tailor the doc accordingly.
|
||||
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 (from the `/codebase-design` skill) without ceremony.
|
||||
|
||||
Each candidate is one `<article>`:
|
||||
|
||||
- **Title** — short, names the deepening (e.g. "Collapse the Order intake pipeline").
|
||||
- **Badge row** — recommendation strength (`Strong` = emerald, `Worth exploring` = amber, `Speculative` = slate), plus a tag for the dependency category (`in-process`, `local-substitutable`, `ports & adapters`, `mock`).
|
||||
- **Files** — monospaced list, `font-mono text-sm`.
|
||||
- **Before / After diagram** — the centrepiece. Two columns, side by side. See patterns below.
|
||||
- **Problem** — one sentence. What hurts.
|
||||
- **Solution** — one sentence. What changes.
|
||||
- **Wins** — bullets, ≤6 words each. e.g. "Tests hit one interface", "Pricing logic stops leaking", "Delete 4 shallow wrappers".
|
||||
- **ADR callout** (if applicable) — one line in an amber-tinted box.
|
||||
|
||||
No paragraphs of explanation. If the diagram needs a paragraph to be understood, redraw the diagram.
|
||||
|
||||
## Diagram patterns
|
||||
|
||||
Pick the pattern that fits the candidate. Mix them. Don't make every diagram look the same — variety is part of the point.
|
||||
|
||||
### Mermaid graph (the workhorse for dependencies / call flow)
|
||||
|
||||
Use a Mermaid `flowchart` or `graph` when the point is "X calls Y calls Z, and look at the mess." Wrap it in a Tailwind-styled card so it doesn't feel parachuted in. Style with classDef to colour leakage edges red and the deep module dark. Sequence diagrams work well for "before: 6 round-trips; after: 1."
|
||||
|
||||
```html
|
||||
<div class="rounded-lg border border-slate-200 bg-white p-4">
|
||||
<pre class="mermaid">
|
||||
flowchart LR
|
||||
A[OrderHandler] --> B[OrderValidator]
|
||||
B --> C[OrderRepo]
|
||||
C -.leak.-> D[PricingClient]
|
||||
classDef leak stroke:#dc2626,stroke-width:2px;
|
||||
class C,D leak
|
||||
</pre>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Hand-built boxes-and-arrows (when Mermaid's layout fights you)
|
||||
|
||||
Modules as `<div>`s with borders and labels. Arrows as inline SVG `<line>` or `<path>` elements positioned absolutely over a relative container. Reach for this when you want the "after" diagram to feel like one thick-bordered deep module with greyed-out internals — Mermaid won't render that with the right weight.
|
||||
|
||||
### Cross-section (good for layered shallowness)
|
||||
|
||||
Stack horizontal bands (`h-12 border-l-4`) to show layers a call passes through. Before: 6 thin layers each doing nothing. After: 1 thick band labelled with the consolidated responsibility.
|
||||
|
||||
### Mass diagram (good for "interface as wide as implementation")
|
||||
|
||||
Two rectangles per module — one for interface surface area, one for implementation. Before: interface rectangle is nearly as tall as the implementation rectangle (shallow). After: interface rectangle is short, implementation rectangle is tall (deep).
|
||||
|
||||
### Call-graph collapse
|
||||
|
||||
Before: a tree of function calls rendered as nested boxes. After: the same tree collapsed into one box, with the now-internal calls shown faded inside it.
|
||||
|
||||
## Style guidance
|
||||
|
||||
- Lean editorial, not corporate-dashboard. Generous whitespace. Serif optional for headings (`font-serif` works well with stone/slate).
|
||||
- Colour sparingly: one accent (emerald or indigo) plus red for leakage and amber for warnings.
|
||||
- Keep diagrams ~320px tall so before/after sits comfortably side by side without scrolling.
|
||||
- Use `text-xs uppercase tracking-wider` for module labels inside diagrams — they should read as schematic, not as UI.
|
||||
- The only scripts are the Tailwind CDN and the Mermaid ESM import. The report is otherwise static — no app code, no interactivity beyond Mermaid's own rendering.
|
||||
|
||||
## Top recommendation section
|
||||
|
||||
One larger card. Candidate name, one sentence on why, anchor link to its card. That's it.
|
||||
|
||||
## Tone
|
||||
|
||||
Plain English, concise — but the architectural nouns and verbs come straight from the `/codebase-design` skill. Concision is not an excuse to drift.
|
||||
|
||||
**Use exactly:** module, interface, implementation, depth, deep, shallow, seam, adapter, leverage, locality.
|
||||
|
||||
**Never substitute:** component, service, unit (for module) · API, signature (for interface) · boundary (for seam) · layer, wrapper (for module, when you mean module).
|
||||
|
||||
**Phrasings that fit the style:**
|
||||
|
||||
- "Order intake module is shallow — interface nearly matches the implementation."
|
||||
- "Pricing leaks across the seam."
|
||||
- "Deepen: one interface, one place to test."
|
||||
- "Two adapters justify the seam: HTTP in prod, in-memory in tests."
|
||||
|
||||
**Wins bullets** name the gain in glossary terms: *"locality: bugs concentrate in one module"*, *"leverage: one interface, N call sites"*, *"interface shrinks; implementation absorbs the wrappers"*. Don't write *"easier to maintain"* or *"cleaner code"* — those terms aren't in the glossary and don't earn their place.
|
||||
|
||||
No hedging, no throat-clearing, no "it's worth noting that…". If a sentence could be a bullet, make it a bullet. If a bullet could be cut, cut it. If a term isn't in the `/codebase-design` glossary, reach for one that is before inventing a new one.
|
||||
66
.agents/skills/improve-codebase-architecture/SKILL.md
Normal file
66
.agents/skills/improve-codebase-architecture/SKILL.md
Normal file
@@ -0,0 +1,66 @@
|
||||
---
|
||||
name: improve-codebase-architecture
|
||||
description: Scan a codebase for deepening opportunities, present them as a visual HTML report, then grill through whichever one you pick.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Improve Codebase Architecture
|
||||
|
||||
Surface architectural friction and propose **deepening opportunities** — refactors that turn shallow modules into deep ones. The aim is testability and AI-navigability.
|
||||
|
||||
This command is _informed_ by the project's domain model and built on a shared design vocabulary:
|
||||
|
||||
- Run the `/codebase-design` skill for the architecture vocabulary (**module**, **interface**, **depth**, **seam**, **adapter**, **leverage**, **locality**) and its principles (the deletion test, "the interface is the test surface", "one adapter = hypothetical seam, two = real"). Use these terms exactly in every suggestion — don't drift into "component," "service," "API," or "boundary."
|
||||
- The domain language in `CONTEXT.md` gives names to good seams; ADRs in `docs/adr/` record decisions this command should not re-litigate.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Explore
|
||||
|
||||
Read the project's domain glossary (`CONTEXT.md`) and any ADRs in the area you're touching first.
|
||||
|
||||
Then use the Agent tool with `subagent_type=Explore` to walk the codebase. Don't follow rigid heuristics — explore organically and note where you experience friction:
|
||||
|
||||
- Where does understanding one concept require bouncing between many small modules?
|
||||
- Where are modules **shallow** — interface nearly as complex as the implementation?
|
||||
- Where have pure functions been extracted just for testability, but the real bugs hide in how they're called (no **locality**)?
|
||||
- Where do tightly-coupled modules leak across their seams?
|
||||
- Which parts of the codebase are untested, or hard to test through their current interface?
|
||||
|
||||
Apply the **deletion test** to anything you suspect is shallow: would deleting it concentrate complexity, or just move it? A "yes, concentrates" is the signal you want.
|
||||
|
||||
### 2. Present candidates as an HTML report
|
||||
|
||||
Write a self-contained HTML file to the OS temp directory so nothing lands in the repo. Resolve the temp dir from `$TMPDIR`, falling back to `/tmp` (or `%TEMP%` on Windows), and write to `<tmpdir>/architecture-review-<timestamp>.html` so each run gets a fresh file. Open it for the user — `xdg-open <path>` on Linux, `open <path>` on macOS, `start <path>` on Windows — and tell them the absolute path.
|
||||
|
||||
The report uses **Tailwind via CDN** for layout and styling, and **Mermaid via CDN** for diagrams where a graph/flow/sequence reliably communicates the structure. Mix Mermaid with hand-crafted CSS/SVG visuals — use Mermaid when relationships are graph-shaped (call graphs, dependencies, sequences), and hand-built divs/SVG when you want something more editorial (mass diagrams, cross-sections, collapse animations). Each candidate gets a **before/after visualisation**. Be visual.
|
||||
|
||||
For each candidate, render a card with:
|
||||
|
||||
- **Files** — which files/modules are involved
|
||||
- **Problem** — why the current architecture is causing friction
|
||||
- **Solution** — plain English description of what would change
|
||||
- **Benefits** — explained in terms of locality and leverage, and how tests would improve
|
||||
- **Before / After diagram** — side-by-side, custom-drawn, illustrating the shallowness and the deepening
|
||||
- **Recommendation strength** — one of `Strong`, `Worth exploring`, `Speculative`, rendered as a badge
|
||||
|
||||
End the report with a **Top recommendation** section: which candidate you'd tackle first and why.
|
||||
|
||||
**Use CONTEXT.md vocabulary for the domain, and the `/codebase-design` vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service."
|
||||
|
||||
**ADR conflicts**: if a candidate contradicts an existing ADR, only surface it when the friction is real enough to warrant revisiting the ADR. Mark it clearly in the card (e.g. a warning callout: _"contradicts ADR-0007 — but worth reopening because…"_). Don't list every theoretical refactor an ADR forbids.
|
||||
|
||||
See [HTML-REPORT.md](HTML-REPORT.md) for the full HTML scaffold, diagram patterns, and styling guidance.
|
||||
|
||||
Do NOT propose interfaces yet. After the file is written, ask the user: "Which of these would you like to explore?"
|
||||
|
||||
### 3. Grilling loop
|
||||
|
||||
Once the user picks a candidate, run the `/grilling` skill to walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive.
|
||||
|
||||
Side effects happen inline as decisions crystallize — run the `/domain-modeling` skill to keep the domain model current as you go:
|
||||
|
||||
- **Naming a deepened module after a concept not in `CONTEXT.md`?** Add the term to `CONTEXT.md`. Create the file lazily if it doesn't exist.
|
||||
- **Sharpening a fuzzy term during the conversation?** Update `CONTEXT.md` right there.
|
||||
- **User rejects the candidate with a load-bearing reason?** Offer an ADR, framed as: _"Want me to record this as an ADR so future architecture reviews don't re-suggest it?"_ Only offer when the reason would actually be needed by a future explorer to avoid re-suggesting the same thing — skip ephemeral reasons ("not worth it right now") and self-evident ones.
|
||||
- **Want to explore alternative interfaces for the deepened module?** Run the `/codebase-design` skill and use its design-it-twice parallel sub-agent pattern.
|
||||
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 answer a design question. Use when the user wants to sanity-check whether a state model or logic feels right, or explore what a UI should look like.
|
||||
---
|
||||
|
||||
# Prototype
|
||||
|
||||
A prototype is **throwaway code that answers a question**. The question decides the shape.
|
||||
|
||||
## Pick a branch
|
||||
|
||||
Identify which question is being answered — from the user's prompt, the surrounding code, or by asking if the user is around:
|
||||
|
||||
- **"Does this logic / state model feel right?"** → [LOGIC.md](LOGIC.md). Build a tiny interactive terminal app that pushes the state machine through cases that are hard to reason about on paper.
|
||||
- **"What should this look like?"** → [UI.md](UI.md). Generate several radically different UI variations on a single route, switchable via a URL search param and a floating bottom bar.
|
||||
|
||||
The two branches produce very different artifacts — getting this wrong wastes the whole prototype. If the question is genuinely ambiguous and the user isn't reachable, default to whichever branch better matches the surrounding code (a backend module → logic; a page or component → UI) and state the assumption at the top of the prototype.
|
||||
|
||||
## Rules that apply to both
|
||||
|
||||
1. **Throwaway from day one, and clearly marked as such.** Locate the prototype code close to where it will actually be used (next to the module or page it's prototyping for) so context is obvious — but name it so a casual reader can see it's a prototype, not production. For throwaway UI routes, obey whatever routing convention the project already uses; don't invent a new top-level structure.
|
||||
2. **One command to run.** Whatever the project's existing task runner supports — `pnpm <name>`, `python <path>`, `bun <path>`, etc. The user must be able to start it without thinking.
|
||||
3. **No persistence by default.** State lives in memory. Persistence is the thing the prototype is _checking_, not something it should depend on. If the question explicitly involves a database, hit a scratch DB or a local file with a clear "PROTOTYPE — wipe me" name.
|
||||
4. **Skip the polish.** No tests, no error handling beyond what makes the prototype _runnable_, no abstractions. The point is to learn something fast and then delete it.
|
||||
5. **Surface the state.** After every action (logic) or on every variant switch (UI), print or render the full relevant state so the user can see what changed.
|
||||
6. **Delete or absorb when done.** When the prototype has answered its question, either delete it or fold the validated decision into the real code — don't leave it rotting in the repo.
|
||||
|
||||
## When done
|
||||
|
||||
The _answer_ is the only thing worth keeping from a prototype. Capture it somewhere durable (commit message, ADR, issue, or a `NOTES.md` next to the prototype) along with the question it was answering. If the user is around, that capture is a quick conversation; if not, leave the placeholder so they (or you, on the next pass) can fill in the verdict before deleting the prototype.
|
||||
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.
|
||||
127
.agents/skills/setup-matt-pocock-skills/SKILL.md
Normal file
127
.agents/skills/setup-matt-pocock-skills/SKILL.md
Normal file
@@ -0,0 +1,127 @@
|
||||
---
|
||||
name: setup-matt-pocock-skills
|
||||
description: Configure this repo for the engineering skills — set up its issue tracker, triage label vocabulary, and domain doc layout. Run once before first use of the other engineering skills.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Setup Matt Pocock's Skills
|
||||
|
||||
Scaffold the per-repo configuration that the engineering skills assume:
|
||||
|
||||
- **Issue tracker** — where issues live (GitHub by default; local markdown is also supported out of the box)
|
||||
- **Triage labels** — the strings used for the five canonical triage roles
|
||||
- **Domain docs** — where `CONTEXT.md` and ADRs live, and the consumer rules for reading them
|
||||
|
||||
This is a prompt-driven skill, not a deterministic script. Explore, present what you found, confirm with the user, then write.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Explore
|
||||
|
||||
Look at the current repo to understand its starting state. Read whatever exists; don't assume:
|
||||
|
||||
- `git remote -v` and `.git/config` — is this a GitHub repo? Which one?
|
||||
- `AGENTS.md` and `CLAUDE.md` at the repo root — does either exist? Is there already an `## Agent skills` section in either?
|
||||
- `CONTEXT.md` and `CONTEXT-MAP.md` at the repo root
|
||||
- `docs/adr/` and any `src/*/docs/adr/` directories
|
||||
- `docs/agents/` — does this skill's prior output already exist?
|
||||
- `.scratch/` — sign that a local-markdown issue tracker convention is already in use
|
||||
|
||||
### 2. Present findings and ask
|
||||
|
||||
Summarise what's present and what's missing. Then walk the user through the three decisions **one at a time** — present a section, get the user's answer, then move to the next. Don't dump all three at once.
|
||||
|
||||
Assume the user does not know what these terms mean. Each section starts with a short explainer (what it is, why these skills need it, what changes if they pick differently). Then show the choices and the default.
|
||||
|
||||
**Section A — Issue tracker.**
|
||||
|
||||
> Explainer: The "issue tracker" is where issues live for this repo. Skills like `to-issues`, `triage`, `to-prd`, and `qa` read from and write to it — they need to know whether to call `gh issue create`, write a markdown file under `.scratch/`, or follow some other workflow you describe. Pick the place you actually track work for this repo.
|
||||
|
||||
Default posture: these skills were designed for GitHub. If a `git remote` points at GitHub, propose that. If a `git remote` points at GitLab (`gitlab.com` or a self-hosted host), propose GitLab. Otherwise (or if the user prefers), offer:
|
||||
|
||||
- **GitHub** — issues live in the repo's GitHub Issues (uses the `gh` CLI)
|
||||
- **GitLab** — issues live in the repo's GitLab Issues (uses the [`glab`](https://gitlab.com/gitlab-org/cli) CLI)
|
||||
- **Local markdown** — issues live as files under `.scratch/<feature>/` in this repo (good for solo projects or repos without a remote)
|
||||
- **Other** (Jira, Linear, etc.) — ask the user to describe the workflow in one paragraph; the skill will record it as freeform prose
|
||||
|
||||
If — and only if — the user picked **GitHub** or **GitLab**, ask one follow-up:
|
||||
|
||||
> Explainer: Open-source repos often receive feature requests as pull requests, not just issues — a PR is an issue with attached code. If you turn this on, `/triage` pulls *external* PRs into the same queue and runs them through the same labels and states as issues (collaborators' in-flight PRs are left alone). Leave it off if PRs aren't a request surface for you.
|
||||
|
||||
- **PRs as a request surface** — yes / no (default: no). Record the answer in `docs/agents/issue-tracker.md`. For local-markdown and other trackers, skip this question — there are no PRs.
|
||||
|
||||
**Section B — Triage label vocabulary.**
|
||||
|
||||
> Explainer: When the `triage` skill processes an incoming issue, it moves it through a state machine — needs evaluation, waiting on reporter, ready for an AFK agent to pick up, ready for a human, or won't fix. To do that, it needs to apply labels (or the equivalent in your issue tracker) that match strings *you've actually configured*. If your repo already uses different label names (e.g. `bug:triage` instead of `needs-triage`), map them here so the skill applies the right ones instead of creating duplicates.
|
||||
|
||||
The five canonical roles:
|
||||
|
||||
- `needs-triage` — maintainer needs to evaluate
|
||||
- `needs-info` — waiting on reporter
|
||||
- `ready-for-agent` — fully specified, AFK-ready (an agent can pick it up with no human context)
|
||||
- `ready-for-human` — needs human implementation
|
||||
- `wontfix` — will not be actioned
|
||||
|
||||
Default: each role's string equals its name. Ask the user if they want to override any. If their issue tracker has no existing labels, the defaults are fine.
|
||||
|
||||
**Section C — Domain docs.**
|
||||
|
||||
> Explainer: Some skills (`improve-codebase-architecture`, `diagnosing-bugs`, `tdd`) read a `CONTEXT.md` file to learn the project's domain language, and `docs/adr/` for past architectural decisions. They need to know whether the repo has one global context or multiple (e.g. a monorepo with separate frontend/backend contexts) so they look in the right place.
|
||||
|
||||
Confirm the layout:
|
||||
|
||||
- **Single-context** — one `CONTEXT.md` + `docs/adr/` at the repo root. Most repos are this.
|
||||
- **Multi-context** — `CONTEXT-MAP.md` at the root pointing to per-context `CONTEXT.md` files (typically a monorepo).
|
||||
|
||||
### 3. Confirm and edit
|
||||
|
||||
Show the user a draft of:
|
||||
|
||||
- The `## Agent skills` block to add to whichever of `CLAUDE.md` / `AGENTS.md` is being edited (see step 4 for selection rules)
|
||||
- The contents of `docs/agents/issue-tracker.md`, `docs/agents/triage-labels.md`, `docs/agents/domain.md`
|
||||
|
||||
Let them edit before writing.
|
||||
|
||||
### 4. Write
|
||||
|
||||
**Pick the file to edit:**
|
||||
|
||||
- If `CLAUDE.md` exists, edit it.
|
||||
- Else if `AGENTS.md` exists, edit it.
|
||||
- If neither exists, ask the user which one to create — don't pick for them.
|
||||
|
||||
Never create `AGENTS.md` when `CLAUDE.md` already exists (or vice versa) — always edit the one that's already there.
|
||||
|
||||
If an `## Agent skills` block already exists in the chosen file, update its contents in-place rather than appending a duplicate. Don't overwrite user edits to the surrounding sections.
|
||||
|
||||
The block:
|
||||
|
||||
```markdown
|
||||
## Agent skills
|
||||
|
||||
### Issue tracker
|
||||
|
||||
[one-line summary of where issues are tracked, plus whether external PRs are a triage surface]. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
[one-line summary of the label vocabulary]. See `docs/agents/triage-labels.md`.
|
||||
|
||||
### Domain docs
|
||||
|
||||
[one-line summary of layout — "single-context" or "multi-context"]. See `docs/agents/domain.md`.
|
||||
```
|
||||
|
||||
Then write the three docs files using the seed templates in this skill folder as a starting point:
|
||||
|
||||
- [issue-tracker-github.md](./issue-tracker-github.md) — GitHub issue tracker
|
||||
- [issue-tracker-gitlab.md](./issue-tracker-gitlab.md) — GitLab issue tracker
|
||||
- [issue-tracker-local.md](./issue-tracker-local.md) — local-markdown issue tracker
|
||||
- [triage-labels.md](./triage-labels.md) — label mapping
|
||||
- [domain.md](./domain.md) — domain doc consumer rules + layout
|
||||
|
||||
For "other" issue trackers, write `docs/agents/issue-tracker.md` from scratch using the user's description.
|
||||
|
||||
### 5. Done
|
||||
|
||||
Tell the user the setup is complete and which engineering skills will now read from these files. Mention they can edit `docs/agents/*.md` directly later — re-running this skill is only necessary if they want to switch issue trackers or restart from scratch.
|
||||
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 `/domain-modeling` skill (reached via `/grill-with-docs` and `/improve-codebase-architecture`) creates them lazily when terms or decisions actually get resolved.
|
||||
|
||||
## File structure
|
||||
|
||||
Single-context repo (most repos):
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT.md
|
||||
├── docs/adr/
|
||||
│ ├── 0001-event-sourced-orders.md
|
||||
│ └── 0002-postgres-for-write-model.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT-MAP.md
|
||||
├── docs/adr/ ← system-wide decisions
|
||||
└── src/
|
||||
├── ordering/
|
||||
│ ├── CONTEXT.md
|
||||
│ └── docs/adr/ ← context-specific decisions
|
||||
└── billing/
|
||||
├── CONTEXT.md
|
||||
└── docs/adr/
|
||||
```
|
||||
|
||||
## Use the glossary's vocabulary
|
||||
|
||||
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
||||
|
||||
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/domain-modeling`).
|
||||
|
||||
## Flag ADR conflicts
|
||||
|
||||
If your output contradicts an existing ADR, surface it explicitly rather than silently overriding:
|
||||
|
||||
> _Contradicts ADR-0007 (event-sourced orders) — but worth reopening because…_
|
||||
@@ -0,0 +1,34 @@
|
||||
# Issue tracker: GitHub
|
||||
|
||||
Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all operations.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies.
|
||||
- **Read an issue**: `gh issue view <number> --comments`, filtering comments by `jq` and also fetching labels.
|
||||
- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters.
|
||||
- **Comment on an issue**: `gh issue comment <number> --body "..."`
|
||||
- **Apply / remove labels**: `gh issue edit <number> --add-label "..."` / `--remove-label "..."`
|
||||
- **Close**: `gh issue close <number> --comment "..."`
|
||||
|
||||
Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone.
|
||||
|
||||
## Pull requests as a triage surface
|
||||
|
||||
**PRs as a request surface: no.** _(Set to `yes` if this repo treats external PRs as feature requests; `/triage` reads this flag.)_
|
||||
|
||||
When set to `yes`, PRs run through the same labels and states as issues, using the `gh pr` equivalents:
|
||||
|
||||
- **Read a PR**: `gh pr view <number> --comments` and `gh pr diff <number>` for the diff.
|
||||
- **List external PRs for triage**: `gh pr list --state open --json number,title,body,labels,author,authorAssociation,comments` then keep only `authorAssociation` of `CONTRIBUTOR`, `FIRST_TIME_CONTRIBUTOR`, or `NONE` (drop `OWNER`/`MEMBER`/`COLLABORATOR`).
|
||||
- **Comment / label / close**: `gh pr comment`, `gh pr edit --add-label`/`--remove-label`, `gh pr close`.
|
||||
|
||||
GitHub shares one number space across issues and PRs, so a bare `#42` may be either — resolve with `gh pr view 42` and fall back to `gh issue view 42`.
|
||||
|
||||
## When a skill says "publish to the issue tracker"
|
||||
|
||||
Create a GitHub issue.
|
||||
|
||||
## When a skill says "fetch the relevant ticket"
|
||||
|
||||
Run `gh issue view <number> --comments`.
|
||||
@@ -0,0 +1,35 @@
|
||||
# Issue tracker: GitLab
|
||||
|
||||
Issues and PRDs for this repo live as GitLab issues. Use the [`glab`](https://gitlab.com/gitlab-org/cli) CLI for all operations.
|
||||
|
||||
## Conventions
|
||||
|
||||
- **Create an issue**: `glab issue create --title "..." --description "..."`. Use a heredoc for multi-line descriptions. Pass `--description -` to open an editor.
|
||||
- **Read an issue**: `glab issue view <number> --comments`. Use `-F json` for machine-readable output.
|
||||
- **List issues**: `glab issue list -F json` with appropriate `--label` filters.
|
||||
- **Comment on an issue**: `glab issue note <number> --message "..."`. GitLab calls comments "notes".
|
||||
- **Apply / remove labels**: `glab issue update <number> --label "..."` / `--unlabel "..."`. Multiple labels can be comma-separated or by repeating the flag.
|
||||
- **Close**: `glab issue close <number>`. `glab issue close` does not accept a closing comment, so post the explanation first with `glab issue note <number> --message "..."`, then close.
|
||||
- **Merge requests**: GitLab calls PRs "merge requests". Use `glab mr create`, `glab mr view`, `glab mr note`, etc. — the same shape as `gh pr ...` with `mr` in place of `pr` and `note`/`--message` in place of `comment`/`--body`.
|
||||
|
||||
Infer the repo from `git remote -v` — `glab` does this automatically when run inside a clone.
|
||||
|
||||
## Merge requests as a triage surface
|
||||
|
||||
**MRs as a request surface: no.** _(Set to `yes` if this repo treats external merge requests as feature requests; `/triage` reads this flag.)_
|
||||
|
||||
When set to `yes`, MRs run through the same labels and states as issues, using the `glab mr` equivalents:
|
||||
|
||||
- **Read an MR**: `glab mr view <number> --comments` and `glab mr diff <number>` for the diff.
|
||||
- **List external MRs for triage**: `glab mr list -F json`, then keep only MRs whose author is not a project member/owner (a contributor's MR, not a maintainer's in-flight work).
|
||||
- **Comment / label / close**: `glab mr note`, `glab mr update --label`/`--unlabel`, `glab mr close`.
|
||||
|
||||
Unlike GitHub, GitLab numbers issues and MRs separately, so `#42` is unambiguous once you know which surface the maintainer means.
|
||||
|
||||
## When a skill says "publish to the issue tracker"
|
||||
|
||||
Create a GitLab issue.
|
||||
|
||||
## When a skill says "fetch the relevant ticket"
|
||||
|
||||
Run `glab issue view <number> --comments`.
|
||||
@@ -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.
|
||||
111
.agents/skills/tdd/SKILL.md
Normal file
111
.agents/skills/tdd/SKILL.md
Normal file
@@ -0,0 +1,111 @@
|
||||
---
|
||||
name: tdd
|
||||
description: Test-driven development. Use when the user wants to build features or fix bugs test-first, mentions "red-green-refactor", or wants integration tests.
|
||||
---
|
||||
|
||||
# Test-Driven Development
|
||||
|
||||
## Philosophy
|
||||
|
||||
**Core principle**: Tests should verify behavior through public interfaces, not implementation details. Code can change entirely; tests shouldn't.
|
||||
|
||||
**Good tests** are integration-style: they exercise real code paths through public APIs. They describe _what_ the system does, not _how_ it does it. A good test reads like a specification - "user can checkout with valid cart" tells you exactly what capability exists. These tests survive refactors because they don't care about internal structure.
|
||||
|
||||
**Bad tests** are coupled to implementation. They mock internal collaborators, test private methods, or verify through external means (like querying a database directly instead of using the interface). The warning sign: your test breaks when you refactor, but behavior hasn't changed. If you rename an internal function and tests fail, those tests were testing implementation, not behavior.
|
||||
|
||||
**Tautological tests** restate the implementation inside the assertion, so they pass by construction and give zero confidence. When the expected value is computed the way the code computes it — `expect(add(a, b)).toBe(a + b)`, snapshotting a figure you derived by hand the same way the code does, asserting a constant equals itself — the test can never disagree with the code: break the code wrong and the assertion breaks wrong with it. The expected value must come from an independent source of truth — a known-good literal, a worked example, the spec.
|
||||
|
||||
See [tests.md](tests.md) for examples and [mocking.md](mocking.md) for mocking guidelines.
|
||||
|
||||
## Anti-Pattern: Horizontal Slices
|
||||
|
||||
**DO NOT write all tests first, then all implementation.** This is "horizontal slicing" - treating RED as "write all tests" and GREEN as "write all code."
|
||||
|
||||
This produces **crap tests**:
|
||||
|
||||
- Tests written in bulk test _imagined_ behavior, not _actual_ behavior
|
||||
- You end up testing the _shape_ of things (data structures, function signatures) rather than user-facing behavior
|
||||
- Tests become insensitive to real changes - they pass when behavior breaks, fail when behavior is fine
|
||||
- You outrun your headlights, committing to test structure before understanding the implementation
|
||||
|
||||
**Correct approach**: Vertical slices via tracer bullets. One test → one implementation → repeat. Each test responds to what you learned from the previous cycle. Because you just wrote the code, you know exactly what behavior matters and how to verify it.
|
||||
|
||||
```
|
||||
WRONG (horizontal):
|
||||
RED: test1, test2, test3, test4, test5
|
||||
GREEN: impl1, impl2, impl3, impl4, impl5
|
||||
|
||||
RIGHT (vertical):
|
||||
RED→GREEN: test1→impl1
|
||||
RED→GREEN: test2→impl2
|
||||
RED→GREEN: test3→impl3
|
||||
...
|
||||
```
|
||||
|
||||
## Workflow
|
||||
|
||||
### 1. Planning
|
||||
|
||||
When exploring the codebase, read `CONTEXT.md` (if it exists) so that test names and interface vocabulary match the project's domain language, and respect ADRs in the area you're touching.
|
||||
|
||||
Before writing any code:
|
||||
|
||||
- [ ] Confirm with user what interface changes are needed
|
||||
- [ ] Confirm with user which behaviors to test (prioritize)
|
||||
- [ ] Identify opportunities for deep modules (small interface, deep implementation) — run the `/codebase-design` skill for the vocabulary and the testability checks
|
||||
- [ ] List the behaviors to test (not implementation steps)
|
||||
- [ ] Get user approval on the plan
|
||||
|
||||
Ask: "What should the public interface look like? Which behaviors are most important to test?"
|
||||
|
||||
**You can't test everything.** Confirm with the user exactly which behaviors matter most. Focus testing effort on critical paths and complex logic, not every possible edge case.
|
||||
|
||||
### 2. Tracer Bullet
|
||||
|
||||
Write ONE test that confirms ONE thing about the system:
|
||||
|
||||
```
|
||||
RED: Write test for first behavior → test fails
|
||||
GREEN: Write minimal code to pass → test passes
|
||||
```
|
||||
|
||||
This is your tracer bullet - proves the path works end-to-end.
|
||||
|
||||
### 3. Incremental Loop
|
||||
|
||||
For each remaining behavior:
|
||||
|
||||
```
|
||||
RED: Write next test → fails
|
||||
GREEN: Minimal code to pass → passes
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- One test at a time
|
||||
- Only enough code to pass current test
|
||||
- Don't anticipate future tests
|
||||
- Keep tests focused on observable behavior
|
||||
|
||||
### 4. Refactor
|
||||
|
||||
After all tests pass, look for [refactor candidates](refactoring.md):
|
||||
|
||||
- [ ] Extract duplication
|
||||
- [ ] Deepen modules (move complexity behind simple interfaces)
|
||||
- [ ] Apply SOLID principles where natural
|
||||
- [ ] Consider what new code reveals about existing code
|
||||
- [ ] Run tests after each refactor step
|
||||
|
||||
**Never refactor while RED.** Get to GREEN first.
|
||||
|
||||
## Checklist Per Cycle
|
||||
|
||||
```
|
||||
[ ] Test describes behavior, not implementation
|
||||
[ ] Test uses public interface only
|
||||
[ ] Test would survive internal refactor
|
||||
[ ] Expected values are independent literals, not recomputed from the code
|
||||
[ ] Code is minimal for this test
|
||||
[ ] No speculative features added
|
||||
```
|
||||
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
|
||||
77
.agents/skills/tdd/tests.md
Normal file
77
.agents/skills/tdd/tests.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Good and Bad Tests
|
||||
|
||||
## Good Tests
|
||||
|
||||
**Integration-style**: Test through real interfaces, not mocks of internal parts.
|
||||
|
||||
```typescript
|
||||
// GOOD: Tests observable behavior
|
||||
test("user can checkout with valid cart", async () => {
|
||||
const cart = createCart();
|
||||
cart.add(product);
|
||||
const result = await checkout(cart, paymentMethod);
|
||||
expect(result.status).toBe("confirmed");
|
||||
});
|
||||
```
|
||||
|
||||
Characteristics:
|
||||
|
||||
- Tests behavior users/callers care about
|
||||
- Uses public API only
|
||||
- Survives internal refactors
|
||||
- Describes WHAT, not HOW
|
||||
- One logical assertion per test
|
||||
|
||||
## Bad Tests
|
||||
|
||||
**Implementation-detail tests**: Coupled to internal structure.
|
||||
|
||||
```typescript
|
||||
// BAD: Tests implementation details
|
||||
test("checkout calls paymentService.process", async () => {
|
||||
const mockPayment = jest.mock(paymentService);
|
||||
await checkout(cart, payment);
|
||||
expect(mockPayment.process).toHaveBeenCalledWith(cart.total);
|
||||
});
|
||||
```
|
||||
|
||||
Red flags:
|
||||
|
||||
- Mocking internal collaborators
|
||||
- Testing private methods
|
||||
- Asserting on call counts/order
|
||||
- Test breaks when refactoring without behavior change
|
||||
- Test name describes HOW not WHAT
|
||||
- Verifying through external means instead of interface
|
||||
|
||||
```typescript
|
||||
// BAD: Bypasses interface to verify
|
||||
test("createUser saves to database", async () => {
|
||||
await createUser({ name: "Alice" });
|
||||
const row = await db.query("SELECT * FROM users WHERE name = ?", ["Alice"]);
|
||||
expect(row).toBeDefined();
|
||||
});
|
||||
|
||||
// GOOD: Verifies through interface
|
||||
test("createUser makes user retrievable", async () => {
|
||||
const user = await createUser({ name: "Alice" });
|
||||
const retrieved = await getUser(user.id);
|
||||
expect(retrieved.name).toBe("Alice");
|
||||
});
|
||||
```
|
||||
|
||||
**Tautological tests**: Expected value restates the implementation, so the test passes by construction.
|
||||
|
||||
```typescript
|
||||
// BAD: Expected value is recomputed the way the code computes it
|
||||
test("calculateTotal sums line items", () => {
|
||||
const items = [{ price: 10 }, { price: 5 }];
|
||||
const expected = items.reduce((sum, i) => sum + i.price, 0);
|
||||
expect(calculateTotal(items)).toBe(expected);
|
||||
});
|
||||
|
||||
// GOOD: Expected value is an independent, known literal
|
||||
test("calculateTotal sums line items", () => {
|
||||
expect(calculateTotal([{ price: 10 }, { price: 5 }])).toBe(15);
|
||||
});
|
||||
```
|
||||
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.
|
||||
140
.agents/skills/teach/SKILL.md
Normal file
140
.agents/skills/teach/SKILL.md
Normal file
@@ -0,0 +1,140 @@
|
||||
---
|
||||
name: teach
|
||||
description: Teach the user a new skill or concept, within this workspace.
|
||||
disable-model-invocation: true
|
||||
argument-hint: "What would you like to learn about?"
|
||||
---
|
||||
|
||||
The user has asked you to teach them something. This is a stateful request - they intend to learn the topic over multiple sessions.
|
||||
|
||||
## Teaching Workspace
|
||||
|
||||
Treat the current directory as a teaching workspace. The state of their learning is captured in this directory in several files:
|
||||
|
||||
- `MISSION.md`: A document capturing the _reason_ the user is interested in the topic. This should be used to ground all teaching. Use the format in [MISSION-FORMAT.md](./MISSION-FORMAT.md).
|
||||
- `./reference/*.html`: A directory of reference materials. These are the compressed learnings from the lessons - cheat sheets, reference algorithms, syntax, yoga poses, glossaries. They are the raw units of learning. They should be beautiful documents which print out well, and are designed for quick reference.
|
||||
- `RESOURCES.md`: A list of resources which can be explored to ground your teaching in contextual knowledge, or to acquire knowledge and wisdom. Use the format in [RESOURCES-FORMAT.md](./RESOURCES-FORMAT.md).
|
||||
- `./learning-records/*.md`: A directory of learning records, which capture what the user has learned. These are loosely equivalent to architectural decision records in software development - they capture non-obvious lessons and key insights that may need to be revised later, or drive future sessions. These should be used to calculate the zone of proximal development. They are titled `0001-<dash-case-name>.md`, where the number increments each time. Use the format in [LEARNING-RECORD-FORMAT.md](./LEARNING-RECORD-FORMAT.md).
|
||||
- `./lessons/*.html`: A directory of lessons. A **lesson** is a single, self-contained HTML output that teaches one tightly-scoped thing tied to the mission. This is the primary unit of teaching in this workspace.
|
||||
- `./assets/*`: Reusable **components** shared across lessons. See [Assets](#assets).
|
||||
- `NOTES.md`: A scratchpad for you to jot down user preferences, or working notes.
|
||||
|
||||
## Philosophy
|
||||
|
||||
To learn at a deep level, the user needs three things:
|
||||
|
||||
- **Knowledge**, captured from high-quality, high-trust resources
|
||||
- **Skills**, acquired through highly-relevant interactive lessons devised by you, based on the knowledge
|
||||
- **Wisdom**, which comes from interacting with other learners and practitioners
|
||||
|
||||
Before the `RESOURCES.md` is well-populated, your focus should be to find high-quality resources which will help the user acquire knowledge. Never trust your parametric knowledge.
|
||||
|
||||
Some topics may require more skills than knowledge. Learning more about theoretical physics might be more knowledge-based. For yoga, more skills-based.
|
||||
|
||||
### Fluency vs Storage Strength
|
||||
|
||||
You should be careful to split between two types of learning:
|
||||
|
||||
- **Fluency strength**: in-the-moment retrieval of knowledge
|
||||
- **Storage strength**: long-term retention of knowledge
|
||||
|
||||
Fluency can give the user an illusory sense of mastery, but storage strength is the real goal. Try to design lessons which build long-term retention by desirable difficulty:
|
||||
|
||||
- Using retrieval practice (recall from memory)
|
||||
- Spacing (distributing practice over time)
|
||||
- Interleaving (mixing up different but related topics in practice - for skills practice only)
|
||||
|
||||
## Lessons
|
||||
|
||||
A lesson is the main thing you produce — the unit in which knowledge and skills reach the user. Each lesson is one self-contained HTML file, saved to `./lessons/` and titled `0001-<dash-case-name>.html` where the number increments each time.
|
||||
|
||||
A lesson should be **beautiful** — clean, readable typography and layout — since the user will return to these later to review. Think Tufte.
|
||||
|
||||
The lesson should be short, and completable very quickly. Learners' working memory is very small, and we need to stay within it. But each lesson should give the user a single tangible win that they can build on. It should be directly tied to the mission, and should be in the user's zone of proximal development.
|
||||
|
||||
If possible, open the lesson file for the user by running a CLI command.
|
||||
|
||||
Each lesson should link via HTML anchors to other lessons and reference documents.
|
||||
|
||||
Each lesson should recommend a primary source for the user to read or watch. This should be the most high-quality, high-trust resource you found on the topic.
|
||||
|
||||
Each lesson should contain a reminder to ask followup questions to the agent. The agent is their teacher, and can assist with anything that's unclear.
|
||||
|
||||
## Assets
|
||||
|
||||
Lessons are built from reusable **components**, stored in `./assets/`: stylesheets, quiz widgets, simulators, diagram helpers — anything a second lesson could reuse.
|
||||
|
||||
Reuse is the default, not the exception. Before authoring a lesson, read `./assets/` and build from the components already there. When a lesson needs something new and reusable, write it as a component in `./assets/` and link to it — never inline code a future lesson would duplicate.
|
||||
|
||||
A shared stylesheet is the first component every workspace earns: every lesson links it, so the lessons look like one consistent course rather than a pile of one-offs. As the workspace grows, so should the component library.
|
||||
|
||||
## The Mission
|
||||
|
||||
Every lesson should be tied into the mission - the reason that the user is interested in learning about the topic.
|
||||
|
||||
If the user is unclear about the mission, or the `MISSION.md` is not populated, your first job should be to question the user on why they want to learn this.
|
||||
|
||||
Failing to understand the mission will mean knowledge acquisition is not grounded in real-world goals. Lessons will feel too abstract. You will have no way of judging what the user should do next.
|
||||
|
||||
Missions may change as the user develops more skills and knowledge. This is normal - make sure to update the `MISSION.md` and add a learning record to capture the change. Confirm with the user before changing the mission.
|
||||
|
||||
## Zone Of Proximal Development
|
||||
|
||||
Each lesson, the user should always feel as if they are being challenged 'just enough'.
|
||||
|
||||
The user may specify an exact thing they want to learn. If they don't, figure out their zone of proximal development by:
|
||||
|
||||
- Reading their `learning-records`
|
||||
- Figuring out the right thing to teach them based on their mission
|
||||
- Teach the most relevant thing that fits in their zone of proximal development
|
||||
|
||||
## Knowledge
|
||||
|
||||
Lessons should be designed around a skill the user is going to learn. The knowledge in the lesson should be only what's required to acquire that skill. You teach the knowledge first, then get the user to practice the skills via an interactive feedback loop.
|
||||
|
||||
Knowledge should first be gathered from trusted resources. Use `RESOURCES.md` to keep track of them. Lessons should be littered with citations - links to external resources to back up any claim made. This increases the trustworthiness of the lesson.
|
||||
|
||||
For acquiring knowledge, difficulty is the enemy. It eats working memory you need for understanding.
|
||||
|
||||
## Skills
|
||||
|
||||
If knowledge is all about acquisition, skills are about durability and flexibility. Make the knowledge stick.
|
||||
|
||||
For skill acquisition, difficulty is the tool. Effortful retrieval is what builds storage strength. Skills should be taught through interactive lessons. There are several tools at your disposal:
|
||||
|
||||
- Interactive lessons, using quizzes and light in-browser tasks
|
||||
- Lessons which guide the user through a list of real-world steps to take (for instance, yoga poses)
|
||||
|
||||
Each of these should be based on a **feedback loop**, where the user receives feedback on their performance. This feedback loop should be as tight as possible, giving feedback immediately - and ideally automatically.
|
||||
|
||||
For quizzes, each answer should be exactly the same number of words (and characters, if possible). Don't give the user any clues about the answer through formatting.
|
||||
|
||||
## Acquiring Wisdom
|
||||
|
||||
Wisdom comes from true real-world interaction - testing your skills outside the learning environment.
|
||||
|
||||
When the user asks a question that appears to require wisdom, your default posture should be to attempt to answer - but to ultimately delegate to a **community**.
|
||||
|
||||
A community is a place (online or offline) where the user can test their skills in the real world. This might be a forum, a subreddit, a real-world class (budget permitting) or a local interest group.
|
||||
|
||||
You should attempt to find high-reputation communities the user can join. If the user expresses a preference that they don't want to join a community, respect it.
|
||||
|
||||
## Reference Documents
|
||||
|
||||
While creating lessons, you should also create reference documents. Lessons can reference these documents - they are useful for tracking raw units of knowledge useful across lessons.
|
||||
|
||||
Lessons will rarely be revisited later - reference documents will be. They should be the compressed essence of the lesson, in a format designed for quick reference.
|
||||
|
||||
Some learning topics lend themselves to reference:
|
||||
|
||||
- Syntax and code snippets for programming
|
||||
- Algorithms and flowcharts for processes
|
||||
- Yoga poses and sequences for yoga
|
||||
- Exercises and routines for fitness
|
||||
- Glossaries for any topic with its own nomenclature
|
||||
|
||||
Glossaries, in particular, are an essential reference. Once one is created, it should be adhered to in every lesson.
|
||||
|
||||
## `NOTES.md`
|
||||
|
||||
The user will sometimes express preferences of how they want to be taught, or things you should keep in mind. This is the place to record those preferences, so you can refer back to them when designing lessons or working with the user.
|
||||
84
.agents/skills/to-issues/SKILL.md
Normal file
84
.agents/skills/to-issues/SKILL.md
Normal file
@@ -0,0 +1,84 @@
|
||||
---
|
||||
name: to-issues
|
||||
description: Break a plan, spec, or PRD into independently-grabbable issues on the project issue tracker using tracer-bullet vertical slices.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# To Issues
|
||||
|
||||
Break a plan into independently-grabbable issues using vertical slices (tracer bullets).
|
||||
|
||||
The issue tracker and triage label vocabulary should have been provided to you — run `/setup-matt-pocock-skills` if not.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Gather context
|
||||
|
||||
Work from whatever is already in the conversation context. If the user passes an issue reference (issue number, URL, or path) as an argument, fetch it from the issue tracker and read its full body and comments.
|
||||
|
||||
### 2. Explore the codebase (optional)
|
||||
|
||||
If you have not already explored the codebase, do so to understand the current state of the code. Issue titles and descriptions should use the project's domain glossary vocabulary, and respect ADRs in the area you're touching.
|
||||
|
||||
Look for opportunities to prefactor the code to make the implementation easier. "Make the change easy, then make the easy change."
|
||||
|
||||
### 3. Draft vertical slices
|
||||
|
||||
Break the plan into **tracer bullet** issues. Each issue is a thin vertical slice that cuts through ALL integration layers end-to-end, NOT a horizontal slice of one layer.
|
||||
|
||||
<vertical-slice-rules>
|
||||
|
||||
- Each slice delivers a narrow but COMPLETE path through every layer (schema, API, UI, tests)
|
||||
- A completed slice is demoable or verifiable on its own
|
||||
- Any prefactoring should be done first
|
||||
|
||||
</vertical-slice-rules>
|
||||
|
||||
### 4. Quiz the user
|
||||
|
||||
Present the proposed breakdown as a numbered list. For each slice, show:
|
||||
|
||||
- **Title**: short descriptive name
|
||||
- **Blocked by**: which other slices (if any) must complete first
|
||||
- **User stories covered**: which user stories this addresses (if the source material has them)
|
||||
|
||||
Ask the user:
|
||||
|
||||
- Does the granularity feel right? (too coarse / too fine)
|
||||
- Are the dependency relationships correct?
|
||||
- Should any slices be merged or split further?
|
||||
|
||||
Iterate until the user approves the breakdown.
|
||||
|
||||
### 5. Publish the issues to the issue tracker
|
||||
|
||||
For each approved slice, publish a new issue to the issue tracker. Use the issue body template below. These issues are considered ready for AFK agents, so publish them with the correct triage label unless instructed otherwise.
|
||||
|
||||
Publish issues in dependency order (blockers first) so you can reference real issue identifiers in the "Blocked by" field.
|
||||
|
||||
<issue-template>
|
||||
## Parent
|
||||
|
||||
A reference to the parent issue on the issue tracker (if the source was an existing issue, otherwise omit this section).
|
||||
|
||||
## What to build
|
||||
|
||||
A concise description of this vertical slice. Describe the end-to-end behavior, not layer-by-layer implementation.
|
||||
|
||||
Avoid specific file paths or code snippets — they go stale fast. Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it here and note briefly that it came from a prototype. Trim to the decision-rich parts — not a working demo, just the important bits.
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Criterion 1
|
||||
- [ ] Criterion 2
|
||||
- [ ] Criterion 3
|
||||
|
||||
## Blocked by
|
||||
|
||||
- A reference to the blocking ticket (if any)
|
||||
|
||||
Or "None - can start immediately" if no blockers.
|
||||
|
||||
</issue-template>
|
||||
|
||||
Do NOT close or modify any parent issue.
|
||||
75
.agents/skills/to-prd/SKILL.md
Normal file
75
.agents/skills/to-prd/SKILL.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
name: to-prd
|
||||
description: Turn the current conversation into a PRD and publish it to the project issue tracker — no interview, just synthesis of what you've already discussed.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
This skill takes the current conversation context and codebase understanding and produces a PRD. Do NOT interview the user — just synthesize what you already know.
|
||||
|
||||
The issue tracker and triage label vocabulary should have been provided to you — run `/setup-matt-pocock-skills` if not.
|
||||
|
||||
## Process
|
||||
|
||||
1. Explore the repo to understand the current state of the codebase, if you haven't already. Use the project's domain glossary vocabulary throughout the PRD, and respect any ADRs in the area you're touching.
|
||||
|
||||
2. Sketch out the seams at which you're going to test the feature. Existing seams should be preferred to new ones. Use the highest seam possible. If new seams are needed, propose them at the highest point you can. The fewer seams across the codebase, the better - the ideal number is one.
|
||||
|
||||
Check with the user that these seams match their expectations.
|
||||
|
||||
3. Write the PRD using the template below, then publish it to the project issue tracker. Apply the `ready-for-agent` triage label - no need for additional triage.
|
||||
|
||||
<prd-template>
|
||||
|
||||
## Problem Statement
|
||||
|
||||
The problem that the user is facing, from the user's perspective.
|
||||
|
||||
## Solution
|
||||
|
||||
The solution to the problem, from the user's perspective.
|
||||
|
||||
## User Stories
|
||||
|
||||
A LONG, numbered list of user stories. Each user story should be in the format of:
|
||||
|
||||
1. As an <actor>, I want a <feature>, so that <benefit>
|
||||
|
||||
<user-story-example>
|
||||
1. As a mobile bank customer, I want to see balance on my accounts, so that I can make better informed decisions about my spending
|
||||
</user-story-example>
|
||||
|
||||
This list of user stories should be extremely extensive and cover all aspects of the feature.
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
A list of implementation decisions that were made. This can include:
|
||||
|
||||
- The modules that will be built/modified
|
||||
- The interfaces of those modules that will be modified
|
||||
- Technical clarifications from the developer
|
||||
- Architectural decisions
|
||||
- Schema changes
|
||||
- API contracts
|
||||
- Specific interactions
|
||||
|
||||
Do NOT include specific file paths or code snippets. They may end up being outdated very quickly.
|
||||
|
||||
Exception: if a prototype produced a snippet that encodes a decision more precisely than prose can (state machine, reducer, schema, type shape), inline it within the relevant decision and note briefly that it came from a prototype. Trim to the decision-rich parts — not a working demo, just the important bits.
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
A list of testing decisions that were made. Include:
|
||||
|
||||
- A description of what makes a good test (only test external behavior, not implementation details)
|
||||
- Which modules will be tested
|
||||
- Prior art for the tests (i.e. similar types of tests in the codebase)
|
||||
|
||||
## Out of Scope
|
||||
|
||||
A description of the things that are out of scope for this PRD.
|
||||
|
||||
## Further Notes
|
||||
|
||||
Any further notes about the feature.
|
||||
|
||||
</prd-template>
|
||||
207
.agents/skills/triage/AGENT-BRIEF.md
Normal file
207
.agents/skills/triage/AGENT-BRIEF.md
Normal file
@@ -0,0 +1,207 @@
|
||||
# Writing Agent Briefs
|
||||
|
||||
An agent brief is a structured comment posted on a GitHub issue or PR when it moves to `ready-for-agent`. It is the authoritative specification that an AFK agent will work from. The original body and discussion are context — the agent brief is the contract.
|
||||
|
||||
The brief states **what the agent should do**, which stretches to both surfaces: for an issue, that's building the change from nothing; for a PR, it's what's left to do *to the existing diff* — finish it, close gaps, address review points. Same principles either way; the PR example below shows the difference.
|
||||
|
||||
## Principles
|
||||
|
||||
### Durability over precision
|
||||
|
||||
The issue may sit in `ready-for-agent` for days or weeks. The codebase will change in the meantime. Write the brief so it stays useful even as files are renamed, moved, or refactored.
|
||||
|
||||
- **Do** describe interfaces, types, and behavioral contracts
|
||||
- **Do** name specific types, function signatures, or config shapes that the agent should look for or modify
|
||||
- **Don't** reference file paths — they go stale
|
||||
- **Don't** reference line numbers
|
||||
- **Don't** assume the current implementation structure will remain the same
|
||||
|
||||
### Behavioral, not procedural
|
||||
|
||||
Describe **what** the system should do, not **how** to implement it. The agent will explore the codebase fresh and make its own implementation decisions.
|
||||
|
||||
- **Good:** "The `SkillConfig` type should accept an optional `schedule` field of type `CronExpression`"
|
||||
- **Bad:** "Open src/types/skill.ts and add a schedule field on line 42"
|
||||
- **Good:** "When a user runs `/triage` with no arguments, they should see a summary of issues needing attention"
|
||||
- **Bad:** "Add a switch statement in the main handler function"
|
||||
|
||||
### Complete acceptance criteria
|
||||
|
||||
The agent needs to know when it's done. Every agent brief must have concrete, testable acceptance criteria. Each criterion should be independently verifiable.
|
||||
|
||||
- **Good:** "Running `gh issue list --label needs-triage` returns issues that have been through initial classification"
|
||||
- **Bad:** "Triage should work correctly"
|
||||
|
||||
### Explicit scope boundaries
|
||||
|
||||
State what is out of scope. This prevents the agent from gold-plating or making assumptions about adjacent features.
|
||||
|
||||
## Template
|
||||
|
||||
```markdown
|
||||
## Agent Brief
|
||||
|
||||
**Category:** bug / enhancement
|
||||
**Summary:** one-line description of what needs to happen
|
||||
|
||||
**Current behavior:**
|
||||
Describe what happens now. For bugs, this is the broken behavior.
|
||||
For enhancements, this is the status quo the feature builds on.
|
||||
|
||||
**Desired behavior:**
|
||||
Describe what should happen after the agent's work is complete.
|
||||
Be specific about edge cases and error conditions.
|
||||
|
||||
**Key interfaces:**
|
||||
- `TypeName` — what needs to change and why
|
||||
- `functionName()` return type — what it currently returns vs what it should return
|
||||
- Config shape — any new configuration options needed
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Specific, testable criterion 1
|
||||
- [ ] Specific, testable criterion 2
|
||||
- [ ] Specific, testable criterion 3
|
||||
|
||||
**Out of scope:**
|
||||
- Thing that should NOT be changed or addressed in this issue
|
||||
- Adjacent feature that might seem related but is separate
|
||||
```
|
||||
|
||||
## Examples
|
||||
|
||||
### Good agent brief (bug)
|
||||
|
||||
```markdown
|
||||
## Agent Brief
|
||||
|
||||
**Category:** bug
|
||||
**Summary:** Skill description truncation drops mid-word, producing broken output
|
||||
|
||||
**Current behavior:**
|
||||
When a skill description exceeds 1024 characters, it is truncated at exactly
|
||||
1024 characters regardless of word boundaries. This produces descriptions
|
||||
that end mid-word (e.g. "Use when the user wants to confi").
|
||||
|
||||
**Desired behavior:**
|
||||
Truncation should break at the last word boundary before 1024 characters
|
||||
and append "..." to indicate truncation.
|
||||
|
||||
**Key interfaces:**
|
||||
- The `SkillMetadata` type's `description` field — no type change needed,
|
||||
but the validation/processing logic that populates it needs to respect
|
||||
word boundaries
|
||||
- Any function that reads SKILL.md frontmatter and extracts the description
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Descriptions under 1024 chars are unchanged
|
||||
- [ ] Descriptions over 1024 chars are truncated at the last word boundary
|
||||
before 1024 chars
|
||||
- [ ] Truncated descriptions end with "..."
|
||||
- [ ] The total length including "..." does not exceed 1024 chars
|
||||
|
||||
**Out of scope:**
|
||||
- Changing the 1024 char limit itself
|
||||
- Multi-line description support
|
||||
```
|
||||
|
||||
### Good agent brief (enhancement)
|
||||
|
||||
```markdown
|
||||
## Agent Brief
|
||||
|
||||
**Category:** enhancement
|
||||
**Summary:** Add `.out-of-scope/` directory support for tracking rejected feature requests
|
||||
|
||||
**Current behavior:**
|
||||
When a feature request is rejected, the issue is closed with a `wontfix` label
|
||||
and a comment. There is no persistent record of the decision or reasoning.
|
||||
Future similar requests require the maintainer to recall or search for the
|
||||
prior discussion.
|
||||
|
||||
**Desired behavior:**
|
||||
Rejected feature requests should be documented in `.out-of-scope/<concept>.md`
|
||||
files that capture the decision, reasoning, and links to all issues that
|
||||
requested the feature. When triaging new issues, these files should be
|
||||
checked for matches.
|
||||
|
||||
**Key interfaces:**
|
||||
- Markdown file format in `.out-of-scope/` — each file should have a
|
||||
`# Concept Name` heading, a `**Decision:**` line, a `**Reason:**` line,
|
||||
and a `**Prior requests:**` list with issue links
|
||||
- The triage workflow should read all `.out-of-scope/*.md` files early
|
||||
and match incoming issues against them by concept similarity
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] Closing a feature as wontfix creates/updates a file in `.out-of-scope/`
|
||||
- [ ] The file includes the decision, reasoning, and link to the closed issue
|
||||
- [ ] If a matching `.out-of-scope/` file already exists, the new issue is
|
||||
appended to its "Prior requests" list rather than creating a duplicate
|
||||
- [ ] During triage, existing `.out-of-scope/` files are checked and surfaced
|
||||
when a new issue matches a prior rejection
|
||||
|
||||
**Out of scope:**
|
||||
- Automated matching (human confirms the match)
|
||||
- Reopening previously rejected features
|
||||
- Bug reports (only enhancement rejections go to `.out-of-scope/`)
|
||||
```
|
||||
|
||||
### Good agent brief (PR)
|
||||
|
||||
For a PR, "Current behavior" describes the state of the diff, and the brief asks the agent to finish or fix it rather than build from scratch.
|
||||
|
||||
```markdown
|
||||
## Agent Brief
|
||||
|
||||
**Category:** enhancement
|
||||
**Summary:** Finish the contributor's `--json` output flag for `triage list`
|
||||
|
||||
**Current behavior:**
|
||||
The PR adds a `--json` flag that serializes the issue list to JSON. The happy
|
||||
path works and the diff matches the project's command structure. Two gaps
|
||||
remain: errors are still printed as human text (not JSON), and the new flag has
|
||||
no test coverage.
|
||||
|
||||
**Desired behavior:**
|
||||
With `--json`, all output — including errors — is well-formed JSON on stdout,
|
||||
and the command's exit codes are unchanged. The existing human-readable output
|
||||
is untouched when the flag is absent.
|
||||
|
||||
**Key interfaces:**
|
||||
- The command's error path should emit `{ "error": string }` under `--json`
|
||||
instead of the plain-text error
|
||||
- Reuse the existing serializer the PR already added; don't introduce a second
|
||||
|
||||
**Acceptance criteria:**
|
||||
- [ ] `triage list --json` emits valid JSON for both success and error cases
|
||||
- [ ] Exit codes match the non-JSON command
|
||||
- [ ] A test covers the `--json` success output and one error case
|
||||
- [ ] Default (non-JSON) output is byte-for-byte unchanged
|
||||
|
||||
**Out of scope:**
|
||||
- Adding `--json` to any other command
|
||||
- Changing the JSON shape of the success payload the PR already defined
|
||||
```
|
||||
|
||||
### Bad agent brief
|
||||
|
||||
```markdown
|
||||
## Agent Brief
|
||||
|
||||
**Summary:** Fix the triage bug
|
||||
|
||||
**What to do:**
|
||||
The triage thing is broken. Look at the main file and fix it.
|
||||
The function around line 150 has the issue.
|
||||
|
||||
**Files to change:**
|
||||
- src/triage/handler.ts (line 150)
|
||||
- src/types.ts (line 42)
|
||||
```
|
||||
|
||||
This is bad because:
|
||||
- No category
|
||||
- Vague description ("the triage thing is broken")
|
||||
- References file paths and line numbers that will go stale
|
||||
- No acceptance criteria
|
||||
- No scope boundaries
|
||||
- No description of current vs desired behavior
|
||||
105
.agents/skills/triage/OUT-OF-SCOPE.md
Normal file
105
.agents/skills/triage/OUT-OF-SCOPE.md
Normal file
@@ -0,0 +1,105 @@
|
||||
# Out-of-Scope Knowledge Base
|
||||
|
||||
The `.out-of-scope/` directory in a repo stores persistent records of rejected feature requests. It serves two purposes:
|
||||
|
||||
1. **Institutional memory** — why a feature was rejected, so the reasoning isn't lost when the issue is closed
|
||||
2. **Deduplication** — when a new issue comes in that matches a prior rejection, the skill can surface the previous decision instead of re-litigating it
|
||||
|
||||
## Directory structure
|
||||
|
||||
```
|
||||
.out-of-scope/
|
||||
├── dark-mode.md
|
||||
├── plugin-system.md
|
||||
└── graphql-api.md
|
||||
```
|
||||
|
||||
One file per **concept**, not per issue. Multiple issues requesting the same thing are grouped under one file.
|
||||
|
||||
## File format
|
||||
|
||||
The file should be written in a relaxed, readable style — more like a short design document than a database entry. Use paragraphs, code samples, and examples to make the reasoning clear and useful to someone encountering it for the first time.
|
||||
|
||||
```markdown
|
||||
# Dark Mode
|
||||
|
||||
This project does not support dark mode or user-facing theming.
|
||||
|
||||
## Why this is out of scope
|
||||
|
||||
The rendering pipeline assumes a single color palette defined in
|
||||
`ThemeConfig`. Supporting multiple themes would require:
|
||||
|
||||
- A theme context provider wrapping the entire component tree
|
||||
- Per-component theme-aware style resolution
|
||||
- A persistence layer for user theme preferences
|
||||
|
||||
This is a significant architectural change that doesn't align with the
|
||||
project's focus on content authoring. Theming is a concern for downstream
|
||||
consumers who embed or redistribute the output.
|
||||
|
||||
```ts
|
||||
// The current ThemeConfig interface is not designed for runtime switching:
|
||||
interface ThemeConfig {
|
||||
colors: ColorPalette; // single palette, resolved at build time
|
||||
fonts: FontStack;
|
||||
}
|
||||
```
|
||||
|
||||
## Prior requests
|
||||
|
||||
- #42 — "Add dark mode support"
|
||||
- #87 — "Night theme for accessibility"
|
||||
- #134 — "Dark theme option"
|
||||
```
|
||||
|
||||
### Naming the file
|
||||
|
||||
Use a short, descriptive kebab-case name for the concept: `dark-mode.md`, `plugin-system.md`, `graphql-api.md`. The name should be recognizable enough that someone browsing the directory understands what was rejected without opening the file.
|
||||
|
||||
### Writing the reason
|
||||
|
||||
The reason should be substantive — not "we don't want this" but why. Good reasons reference:
|
||||
|
||||
- Project scope or philosophy ("This project focuses on X; theming is a downstream concern")
|
||||
- Technical constraints ("Supporting this would require Y, which conflicts with our Z architecture")
|
||||
- Strategic decisions ("We chose to use A instead of B because...")
|
||||
|
||||
The reason should be durable. Avoid referencing temporary circumstances ("we're too busy right now") — those aren't real rejections, they're deferrals.
|
||||
|
||||
## When to check `.out-of-scope/`
|
||||
|
||||
During triage (Step 1: Gather context), read all files in `.out-of-scope/`. When evaluating a new issue:
|
||||
|
||||
- Check if the request matches an existing out-of-scope concept
|
||||
- Matching is by concept similarity, not keyword — "night theme" matches `dark-mode.md`
|
||||
- If there's a match, surface it to the maintainer: "This is similar to `.out-of-scope/dark-mode.md` — we rejected this before because [reason]. Do you still feel the same way?"
|
||||
|
||||
The maintainer may:
|
||||
|
||||
- **Confirm** — the new issue gets added to the existing file's "Prior requests" list, then closed
|
||||
- **Reconsider** — the out-of-scope file gets deleted or updated, and the issue proceeds through normal triage
|
||||
- **Disagree** — the issues are related but distinct, proceed with normal triage
|
||||
|
||||
## When to write to `.out-of-scope/`
|
||||
|
||||
Only when an **enhancement** (not a bug) is *rejected* as `wontfix`. This applies to enhancement PRs exactly as it does to issues — a rejected PR is recorded here so the same request doesn't return as fresh code.
|
||||
|
||||
Do **not** write here when something is closed as `wontfix` because it's **already implemented**. That's a built feature, not a rejected one; recording it would poison the dedup checks with false rejections. Instead, the closing comment points to where the feature already lives.
|
||||
|
||||
The flow:
|
||||
|
||||
1. Maintainer decides a feature request is out of scope
|
||||
2. Check if a matching `.out-of-scope/` file already exists
|
||||
3. If yes: append the new issue to the "Prior requests" list
|
||||
4. If no: create a new file with the concept name, decision, reason, and first prior request
|
||||
5. Post a comment on the issue explaining the decision and mentioning the `.out-of-scope/` file
|
||||
6. Close the issue with the `wontfix` label
|
||||
|
||||
## Updating or removing out-of-scope files
|
||||
|
||||
If the maintainer changes their mind about a previously rejected concept:
|
||||
|
||||
- Delete the `.out-of-scope/` file
|
||||
- The skill does not need to reopen old issues — they're historical records
|
||||
- The new issue that triggered the reconsideration proceeds through normal triage
|
||||
112
.agents/skills/triage/SKILL.md
Normal file
112
.agents/skills/triage/SKILL.md
Normal file
@@ -0,0 +1,112 @@
|
||||
---
|
||||
name: triage
|
||||
description: Move issues and external PRs through a state machine of triage roles — categorise, verify, grill if needed, and write agent-ready briefs.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
# Triage
|
||||
|
||||
Move issues on the project issue tracker through a small state machine of triage roles.
|
||||
|
||||
If this repo treats external pull requests as a request surface (see the issue-tracker config), triage covers them too: **a PR is an issue with attached code** — same roles, same states, same machine, with a few deltas marked "for a PR" below. Resolve a bare `#42` to an issue or PR per the tracker config.
|
||||
|
||||
Every comment or issue posted to the issue tracker during triage **must** start with this disclaimer:
|
||||
|
||||
```
|
||||
> *This was generated by AI during triage.*
|
||||
```
|
||||
|
||||
## Reference docs
|
||||
|
||||
- [AGENT-BRIEF.md](AGENT-BRIEF.md) — how to write durable agent briefs
|
||||
- [OUT-OF-SCOPE.md](OUT-OF-SCOPE.md) — how the `.out-of-scope/` knowledge base works
|
||||
|
||||
## Roles
|
||||
|
||||
Two **category** roles:
|
||||
|
||||
- `bug` — something is broken
|
||||
- `enhancement` — new feature or improvement
|
||||
|
||||
Five **state** roles:
|
||||
|
||||
- `needs-triage` — maintainer needs to evaluate
|
||||
- `needs-info` — waiting on reporter for more information
|
||||
- `ready-for-agent` — fully specified, ready for an AFK agent
|
||||
- `ready-for-human` — needs human implementation
|
||||
- `wontfix` — will not be actioned
|
||||
|
||||
For a PR, the same states read against the attached code: `ready-for-agent` means a brief is attached and an agent should take the next step on the diff; `ready-for-human` means it's ready for a human to merge.
|
||||
|
||||
Every triaged issue should carry exactly one category role and one state role. If state roles conflict, flag it and ask the maintainer before doing anything else.
|
||||
|
||||
These are canonical role names — the actual label strings used in the issue tracker may differ. The mapping should have been provided to you - run `/setup-matt-pocock-skills` if not.
|
||||
|
||||
State transitions: an unlabeled issue normally goes to `needs-triage` first; from there it moves to `needs-info`, `ready-for-agent`, `ready-for-human`, or `wontfix`. `needs-info` returns to `needs-triage` once the reporter replies. The maintainer can override at any time — flag transitions that look unusual and ask before proceeding.
|
||||
|
||||
## Invocation
|
||||
|
||||
The maintainer invokes `/triage` and describes what they want in natural language. Interpret the request and act. Examples:
|
||||
|
||||
- "Show me anything that needs my attention"
|
||||
- "Let's look at #42" (issue or PR)
|
||||
- "Move #42 to ready-for-agent"
|
||||
- "What's ready for agents to pick up?"
|
||||
|
||||
## Show what needs attention
|
||||
|
||||
Query the issue tracker and present three buckets, oldest first:
|
||||
|
||||
1. **Unlabeled** — never triaged.
|
||||
2. **`needs-triage`** — evaluation in progress.
|
||||
3. **`needs-info` with reporter activity since the last triage notes** — needs re-evaluation.
|
||||
|
||||
When PRs are in scope, include external PRs in these buckets and tag each line `[PR]` or `[issue]`. Discovery surfaces only *external* PRs (the tracker config defines who counts as external) — a collaborator's in-flight PR is not triage work. This filter is discovery-only; an explicitly named PR is always triaged regardless of author.
|
||||
|
||||
Show counts and a one-line summary per item. Let the maintainer pick.
|
||||
|
||||
## Triage a specific issue or PR
|
||||
|
||||
1. **Gather context.** Read the full issue or PR (body, comments, labels, author, dates; for a PR, the diff too). Parse any prior triage notes so you don't re-ask resolved questions. Explore the codebase using the project's domain glossary, respecting ADRs in the area. Run two checks against the codebase: (a) **redundancy** — search for an existing implementation of the requested behavior by domain concept (not just the request's wording), and report where you looked. If found, it's an already-implemented `wontfix` (step 5). (b) **prior rejection** — read `.out-of-scope/*.md` and surface any that resembles this request.
|
||||
|
||||
2. **Recommend.** Tell the maintainer your category and state recommendation with reasoning, plus a brief codebase summary relevant to the request — including whether it's already implemented. Wait for direction.
|
||||
|
||||
3. **Verify the claim.** Before any grilling, check that the claim holds up. For a bug, reproduce it from the reporter's steps. For a PR, confirm the diff does what it claims — check it out, run the relevant tests or commands. Report what happened: confirmed (with code path), failed, or insufficient detail (a strong `needs-info` signal). A confirmed verification makes a much stronger agent brief.
|
||||
|
||||
4. **Grill (if needed).** If the request needs fleshing out, run the `/grilling` and `/domain-modeling` skills together — grill it into shape one question at a time, sharpening domain terms and updating `CONTEXT.md`/ADRs inline as decisions land.
|
||||
|
||||
5. **Apply the outcome:**
|
||||
- `ready-for-agent` — post an agent brief comment ([AGENT-BRIEF.md](AGENT-BRIEF.md)).
|
||||
- `ready-for-human` — same structure as an agent brief, but note why it can't be delegated (judgment calls, external access, design decisions, manual testing).
|
||||
- `needs-info` — post triage notes (template below).
|
||||
- `wontfix` — close, with the comment depending on *why*:
|
||||
- **Already implemented** — the change already exists in the codebase. Point to where it lives; do **not** write to `.out-of-scope/` (that KB is for *rejected* requests, not built ones).
|
||||
- **Rejected (bug)** — polite explanation, then close.
|
||||
- **Rejected (enhancement)** — write to `.out-of-scope/`, link to it from a comment, then close ([OUT-OF-SCOPE.md](OUT-OF-SCOPE.md)).
|
||||
- `needs-triage` — apply the role. Optional comment if there's partial progress.
|
||||
|
||||
## Quick state override
|
||||
|
||||
If the maintainer says "move #42 to ready-for-agent", trust them and apply the role directly. Confirm what you're about to do (role changes, comment, close), then act. Skip grilling. If moving to `ready-for-agent` without a grilling session, ask whether they want to write an agent brief.
|
||||
|
||||
## Needs-info template
|
||||
|
||||
```markdown
|
||||
## Triage Notes
|
||||
|
||||
**What we've established so far:**
|
||||
|
||||
- point 1
|
||||
- point 2
|
||||
|
||||
**What we still need from you (@reporter):**
|
||||
|
||||
- question 1
|
||||
- question 2
|
||||
```
|
||||
|
||||
Capture everything resolved during grilling under "established so far" so the work isn't lost. Questions must be specific and actionable, not "please provide more info".
|
||||
|
||||
## Resuming a previous session
|
||||
|
||||
If prior triage notes exist on the issue or PR, read them, check whether the reporter has answered any outstanding questions, and present an updated picture before continuing. Don't re-ask resolved questions.
|
||||
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
|
||||
1
.claude/skills/diagnose
Symbolic link
1
.claude/skills/diagnose
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/diagnose
|
||||
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
|
||||
1
.claude/skills/improve-codebase-architecture
Symbolic link
1
.claude/skills/improve-codebase-architecture
Symbolic link
@@ -0,0 +1 @@
|
||||
../../.agents/skills/improve-codebase-architecture
|
||||
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
.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;
|
||||
```
|
||||
11
.env
Normal file
11
.env
Normal file
@@ -0,0 +1,11 @@
|
||||
MIGRATIONS_DIR=migrations
|
||||
DB_HOST=cxd.whcxd.cn
|
||||
DB_PORT=16159
|
||||
DB_USER=erp_pgsql
|
||||
DB_PASSWORD=erp_2025
|
||||
DB_NAME=junhong_cmp_test
|
||||
DB_SSLMODE=disable
|
||||
GOOGLE_GEMINI_BASE_URL="http://45.155.220.179:8317" # 根据实际填写你服务器的ip地址或者域名
|
||||
GEMINI_API_KEY="sk-VoNbvr6aGpjvZX64rvhrwowrZrCgtGuX9oxykIy8F1DBg"
|
||||
GOOGLE_GENAI_USE_GCA="true"
|
||||
GEMINI_MODEL="gemini-3-pro-preview" # 如果你有gemini3权限可以填: gemini-3-pro-preview
|
||||
93
.env.local
Normal file
93
.env.local
Normal file
@@ -0,0 +1,93 @@
|
||||
# ============================================================================
|
||||
# 君鸿卡管系统 - 本地开发环境变量
|
||||
# 生成时间: 2026-01-30 17:45:14
|
||||
# ============================================================================
|
||||
# 使用方法:
|
||||
# source .env.local && go run cmd/api/main.go
|
||||
# 或者:
|
||||
# ./scripts/run-local.sh
|
||||
# ============================================================================
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 数据库配置(必填)
|
||||
# ----------------------------------------------------------------------------
|
||||
export JUNHONG_DATABASE_HOST="cxd.whcxd.cn"
|
||||
export JUNHONG_DATABASE_PORT="16159"
|
||||
export JUNHONG_DATABASE_USER="erp_pgsql"
|
||||
export JUNHONG_DATABASE_PASSWORD="erp_2025"
|
||||
export JUNHONG_DATABASE_DBNAME="junhong_cmp_test"
|
||||
export JUNHONG_DATABASE_SSLMODE="disable"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Redis 配置(必填)
|
||||
# ----------------------------------------------------------------------------
|
||||
export JUNHONG_REDIS_ADDRESS="cxd.whcxd.cn"
|
||||
export JUNHONG_REDIS_PORT="16299"
|
||||
export JUNHONG_REDIS_PASSWORD="cpNbWtAaqgo1YJmbMp3h"
|
||||
export JUNHONG_REDIS_DB="6"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# JWT 配置(必填)
|
||||
# ----------------------------------------------------------------------------
|
||||
export JUNHONG_JWT_SECRET_KEY="dev-secret-key-for-testing-only-32chars!"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 客户端配置(可选)
|
||||
# ----------------------------------------------------------------------------
|
||||
# 是否强制 C 端用户绑定手机号(true: 强制,false: 不强制)
|
||||
export JUNHONG_CLIENT_REQUIRE_PHONE_BINDING="true"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 服务器配置
|
||||
# ----------------------------------------------------------------------------
|
||||
export JUNHONG_SERVER_ADDRESS=":3000"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 日志配置
|
||||
# ----------------------------------------------------------------------------
|
||||
export JUNHONG_LOGGING_LEVEL="debug"
|
||||
export JUNHONG_LOGGING_DEVELOPMENT="true"
|
||||
export JUNHONG_LOGGING_APP_LOG_FILENAME="logs/app.log"
|
||||
export JUNHONG_LOGGING_ACCESS_LOG_FILENAME="logs/access.log"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Gateway 服务配置
|
||||
# ----------------------------------------------------------------------------
|
||||
export JUNHONG_GATEWAY_BASE_URL="https://open.whjhft.com/openapi"
|
||||
export JUNHONG_GATEWAY_APP_ID="LfjL0WjUqpwkItQ0"
|
||||
export JUNHONG_GATEWAY_APP_SECRET="K0DYuWzbRE6zg5bX"
|
||||
export JUNHONG_GATEWAY_TIMEOUT="30"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 对象存储配置
|
||||
# ----------------------------------------------------------------------------
|
||||
export JUNHONG_STORAGE_PROVIDER="s3"
|
||||
export JUNHONG_STORAGE_S3_ENDPOINT="http://obs-helf.cucloud.cn"
|
||||
export JUNHONG_STORAGE_S3_REGION="cn-langfang-2"
|
||||
export JUNHONG_STORAGE_S3_BUCKET="cmp"
|
||||
export JUNHONG_STORAGE_S3_ACCESS_KEY_ID="598F558CF6FF46E79D1CFC607852378C9523"
|
||||
export JUNHONG_STORAGE_S3_SECRET_ACCESS_KEY="8393425DCB2F48F1914FF39DCBC6C7B17325"
|
||||
export JUNHONG_STORAGE_S3_USE_SSL="false"
|
||||
export JUNHONG_STORAGE_S3_PATH_STYLE="true"
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 迁移工具兼容配置(用于 scripts/migrate.sh)
|
||||
# ----------------------------------------------------------------------------
|
||||
export MIGRATIONS_DIR="migrations"
|
||||
export DB_HOST="cxd.whcxd.cn"
|
||||
export DB_PORT="16159"
|
||||
export DB_USER="erp_pgsql"
|
||||
export DB_PASSWORD="erp_2025"
|
||||
export DB_NAME="junhong_cmp_test"
|
||||
export DB_SSLMODE="disable"
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 短信服务配置
|
||||
# ----------------------------------------------------------------------------
|
||||
export JUNHONG_SMS_GATEWAY_URL="https://gateway.sms.whjhft.com:8443"
|
||||
export JUNHONG_SMS_USERNAME="JH0001"
|
||||
export JUNHONG_SMS_PASSWORD="wwR8E4qnL6F0"
|
||||
export JUNHONG_SMS_SIGNATURE="【JHFTIOT】"
|
||||
|
||||
JUNHONG_QUEUE_CONCURRENCY=1000
|
||||
236
.env.prod
Normal file
236
.env.prod
Normal file
@@ -0,0 +1,236 @@
|
||||
# ==========================================================================
|
||||
# 君鸿卡管系统 - 本地开发环境变量
|
||||
# 生成时间: 2026-05-07 11:56:19
|
||||
# 配置依据: pkg/config/defaults/config.yaml + pkg/config/loader.go
|
||||
# ==========================================================================
|
||||
# 使用方法:
|
||||
# source .env.prod && go run cmd/api/main.go
|
||||
# 或者:
|
||||
# ./scripts/run-local.sh
|
||||
#
|
||||
# 说明:
|
||||
# - 未注释的 是本地启动常用或必填配置。
|
||||
# - 注释掉的 是当前有默认值、按需启用或当前主流程未使用的配置。
|
||||
# - 取消注释前请先阅读上方说明,避免覆盖嵌入配置默认值。
|
||||
# ==========================================================================
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 数据库配置(必填)
|
||||
# ----------------------------------------------------------------------------
|
||||
# 数据库主机地址;应用启动必填。
|
||||
export JUNHONG_DATABASE_HOST=cxd.whcxd.cn
|
||||
# 数据库端口;默认 5432。
|
||||
export JUNHONG_DATABASE_PORT=16159
|
||||
# 数据库用户名;应用启动必填。
|
||||
export JUNHONG_DATABASE_USER=junhong_cmp
|
||||
# 数据库密码;应用启动必填,敏感信息请勿提交。
|
||||
export JUNHONG_DATABASE_PASSWORD=CtCom1zBzPQbpVNf3rCNxH
|
||||
# 数据库名称;应用启动必填。
|
||||
export JUNHONG_DATABASE_DBNAME=junhong_cmp_prod
|
||||
# 数据库 SSL 模式;本地通常使用 disable。
|
||||
export JUNHONG_DATABASE_SSLMODE=disable
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Redis 配置(必填)
|
||||
# ----------------------------------------------------------------------------
|
||||
# Redis 主机地址;应用启动必填。
|
||||
export JUNHONG_REDIS_ADDRESS=192.168.1.22
|
||||
# Redis 端口;默认 6379。
|
||||
export JUNHONG_REDIS_PORT=6379
|
||||
# Redis 密码;无密码时留空。
|
||||
export JUNHONG_REDIS_PASSWORD=Bm500vXEyLg0RUzw7YcyPjbRifhz
|
||||
# Redis 数据库编号;默认 0。
|
||||
export JUNHONG_REDIS_DB=0
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# JWT 配置(必填)
|
||||
# ----------------------------------------------------------------------------
|
||||
# JWT 签名密钥;必须至少 32 字符,生产环境必须单独生成。
|
||||
export JUNHONG_JWT_SECRET_KEY=a0xzpGhR3zB54JAiEJtoKnjwgAGMLmRw
|
||||
# C 端 JWT Token 有效期;默认 24h,通常无需覆盖。
|
||||
export JUNHONG_JWT_TOKEN_DURATION=24h
|
||||
# B 端访问令牌 TTL;默认 24h。
|
||||
export JUNHONG_JWT_ACCESS_TOKEN_TTL=24h
|
||||
# B 端刷新令牌 TTL;默认 168h。
|
||||
export JUNHONG_JWT_REFRESH_TOKEN_TTL=168h
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 服务器配置
|
||||
# ----------------------------------------------------------------------------
|
||||
# 服务监听地址;本地默认 :3000。
|
||||
export JUNHONG_SERVER_ADDRESS=:3527
|
||||
# 读取请求超时时间;默认 30s,通常无需覆盖。
|
||||
export JUNHONG_SERVER_READ_TIMEOUT=30s
|
||||
# 写响应超时时间;默认 30s,通常无需覆盖。
|
||||
export JUNHONG_SERVER_WRITE_TIMEOUT=30s
|
||||
# 优雅关闭超时时间;默认 30s。
|
||||
export JUNHONG_SERVER_SHUTDOWN_TIMEOUT=30s
|
||||
# Fiber 预分叉模式;本地和 macOS 开发环境通常不要启用。
|
||||
# JUNHONG_SERVER_PREFORK=false
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 日志配置
|
||||
# ----------------------------------------------------------------------------
|
||||
# 日志级别;可选 debug/info/warn/error。
|
||||
export JUNHONG_LOGGING_LEVEL=debug
|
||||
# 本地开发模式日志;生产环境应使用 false。
|
||||
export JUNHONG_LOGGING_DEVELOPMENT=true
|
||||
# 应用日志文件路径;本地写入项目 logs 目录。
|
||||
export JUNHONG_LOGGING_APP_LOG_FILENAME=logs/app.log
|
||||
# 访问日志文件路径;本地写入项目 logs 目录。
|
||||
export JUNHONG_LOGGING_ACCESS_LOG_FILENAME=logs/access.log
|
||||
# 应用日志单文件最大大小(MB);默认 100。
|
||||
export JUNHONG_LOGGING_APP_LOG_MAX_SIZE=100
|
||||
# 应用日志最多保留的旧文件数;默认 3。
|
||||
export JUNHONG_LOGGING_APP_LOG_MAX_BACKUPS=15
|
||||
# 应用日志最多保留天数;默认 7。
|
||||
export JUNHONG_LOGGING_APP_LOG_MAX_AGE=14
|
||||
# 是否压缩应用日志轮转文件;默认 true。
|
||||
export JUNHONG_LOGGING_APP_LOG_COMPRESS=true
|
||||
# 访问日志单文件最大大小(MB);默认 100。
|
||||
export JUNHONG_LOGGING_ACCESS_LOG_MAX_SIZE=100
|
||||
# 访问日志最多保留的旧文件数;默认 3。
|
||||
export JUNHONG_LOGGING_ACCESS_LOG_MAX_BACKUPS=15
|
||||
# 访问日志最多保留天数;默认 7。
|
||||
export JUNHONG_LOGGING_ACCESS_LOG_MAX_AGE=14
|
||||
# 是否压缩访问日志轮转文件;默认 true。
|
||||
export JUNHONG_LOGGING_ACCESS_LOG_COMPRESS=true
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 客户端配置
|
||||
# ----------------------------------------------------------------------------
|
||||
# 是否强制 C 端用户绑定手机号;当前默认 true。
|
||||
export JUNHONG_CLIENT_REQUIRE_PHONE_BINDING=true
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 数据库连接池配置(可选)
|
||||
# ----------------------------------------------------------------------------
|
||||
# 数据库最大打开连接数;默认 25,本地通常不用改。
|
||||
export JUNHONG_DATABASE_MAX_OPEN_CONNS=25
|
||||
# 数据库最大空闲连接数;默认 10。
|
||||
export JUNHONG_DATABASE_MAX_IDLE_CONNS=10
|
||||
# 数据库连接最大生命周期;默认 5m。
|
||||
export JUNHONG_DATABASE_CONN_MAX_LIFETIME=5m
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Redis 连接池配置(可选)
|
||||
# ----------------------------------------------------------------------------
|
||||
# Redis 连接池大小;默认 10。
|
||||
export JUNHONG_REDIS_POOL_SIZE=20
|
||||
# Redis 最小空闲连接数;默认 5。
|
||||
export JUNHONG_REDIS_MIN_IDLE_CONNS=5
|
||||
# Redis 建连超时;默认 5s。
|
||||
export JUNHONG_REDIS_DIAL_TIMEOUT=5s
|
||||
# Redis 读取超时;默认 3s。
|
||||
export JUNHONG_REDIS_READ_TIMEOUT=3s
|
||||
# Redis 写入超时;默认 3s。
|
||||
export JUNHONG_REDIS_WRITE_TIMEOUT=3s
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 任务队列配置(可选)
|
||||
# ----------------------------------------------------------------------------
|
||||
# Worker 并发数;默认 10。
|
||||
# JUNHONG_QUEUE_CONCURRENCY=10
|
||||
# 任务最大重试次数;默认 5。
|
||||
# JUNHONG_QUEUE_RETRY_MAX=5
|
||||
# 单个任务超时时间;默认 10m。
|
||||
# JUNHONG_QUEUE_TIMEOUT=10m
|
||||
# 队列权重 critical/default/low 当前由嵌入配置 queue.queues 管理,未绑定为环境变量。
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 限流中间件配置(可选)
|
||||
# ----------------------------------------------------------------------------
|
||||
# 是否启用全局限流;默认关闭。
|
||||
# JUNHONG_MIDDLEWARE_ENABLE_RATE_LIMITER=false
|
||||
# 限流窗口内最大请求数;默认 100。
|
||||
# JUNHONG_MIDDLEWARE_RATE_LIMITER_MAX=100
|
||||
# 限流时间窗口;默认 1m。
|
||||
# JUNHONG_MIDDLEWARE_RATE_LIMITER_EXPIRATION=1m
|
||||
# 限流存储后端;可选 memory 或 redis。
|
||||
# JUNHONG_MIDDLEWARE_RATE_LIMITER_STORAGE=memory
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 轮询配置(可选)
|
||||
# ----------------------------------------------------------------------------
|
||||
# 是否输出轮询详细日志;默认 false,排查上游数据时再开启。
|
||||
export JUNHONG_POLLING_VERBOSE_LOG=true
|
||||
# 是否启用 C 端实名自动触发;默认 true。
|
||||
# JUNHONG_POLLING_AUTO_TRIGGER_ENABLE_AUTO_TRIGGER=true
|
||||
# 自动触发使用的系统用户 ID;生产建议改成专用平台账号。
|
||||
export JUNHONG_POLLING_AUTO_TRIGGER_AUTO_TRIGGER_SYSTEM_USER_ID=1
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Worker 运行配置(可选)
|
||||
# ----------------------------------------------------------------------------
|
||||
# Worker 角色;单实例默认 all,多实例可用 leader/consumer。
|
||||
# JUNHONG_WORKER_ROLE=all
|
||||
# Worker 实例名称;多实例部署时用于区分日志。
|
||||
# JUNHONG_WORKER_INSTANCE_NAME=''
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# Gateway 服务配置
|
||||
# ----------------------------------------------------------------------------
|
||||
# Gateway API 基础 URL;已按本次输入覆盖嵌入默认值。
|
||||
export JUNHONG_GATEWAY_BASE_URL=https://open.whjhft.com/openapi
|
||||
# Gateway 应用 ID;敏感信息请勿提交。
|
||||
export JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
|
||||
# Gateway 应用密钥;敏感信息请勿提交。
|
||||
export JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
|
||||
# Gateway 请求超时时间(秒);有效范围 5-300。
|
||||
export JUNHONG_GATEWAY_TIMEOUT=30
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 短信服务配置(可选)
|
||||
# ----------------------------------------------------------------------------
|
||||
# 短信服务未配置时系统会跳过短信客户端初始化;需要短信验证码时再取消注释。
|
||||
# 短信网关地址;设置后 username/password/signature 也必须填写。
|
||||
export JUNHONG_SMS_GATEWAY_URL='https://gateway.sms.whjhft.com:8443'
|
||||
# 短信账号用户名。
|
||||
export JUNHONG_SMS_USERNAME='JH0001'
|
||||
# 短信账号密码;敏感信息请勿提交。
|
||||
export JUNHONG_SMS_PASSWORD='wwR8E4qnL6F0'
|
||||
# 短信签名,例如【君鸿】。
|
||||
export JUNHONG_SMS_SIGNATURE='【JHFTIOT】'
|
||||
# 短信请求超时时间;默认 10s。
|
||||
export JUNHONG_SMS_TIMEOUT=10s
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 对象存储配置(可选)
|
||||
# ----------------------------------------------------------------------------
|
||||
# 对象存储提供商;当前仅支持 s3 兼容服务。
|
||||
export JUNHONG_STORAGE_PROVIDER=s3
|
||||
# 临时文件目录;用于导入导出等临时文件。
|
||||
export JUNHONG_STORAGE_TEMP_DIR=/tmp/junhong-storage
|
||||
# S3 端点;配置后会初始化对象存储服务。
|
||||
export JUNHONG_STORAGE_S3_ENDPOINT=https://obs-helf.cucloud.cn
|
||||
# S3 区域。
|
||||
export JUNHONG_STORAGE_S3_REGION=cn-langfang-2
|
||||
# S3 存储桶名称。
|
||||
export JUNHONG_STORAGE_S3_BUCKET=cmp
|
||||
# S3 Access Key ID;敏感信息请勿提交。
|
||||
export JUNHONG_STORAGE_S3_ACCESS_KEY_ID=598F558CF6FF46E79D1CFC607852378C9523
|
||||
# S3 Secret Access Key;敏感信息请勿提交。
|
||||
export JUNHONG_STORAGE_S3_SECRET_ACCESS_KEY=8393425DCB2F48F1914FF39DCBC6C7B17325
|
||||
# 是否使用 SSL;本地兼容服务常用 false。
|
||||
export JUNHONG_STORAGE_S3_USE_SSL=false
|
||||
# 是否使用路径风格;兼容 S3 服务通常使用 true。
|
||||
export JUNHONG_STORAGE_S3_PATH_STYLE=true
|
||||
# 预签名上传 URL 有效期;默认 15m。
|
||||
export JUNHONG_STORAGE_PRESIGN_UPLOAD_EXPIRES=15m
|
||||
# 预签名下载 URL 有效期;默认 24h。
|
||||
export JUNHONG_STORAGE_PRESIGN_DOWNLOAD_EXPIRES=24h
|
||||
|
||||
# ----------------------------------------------------------------------------
|
||||
# 默认超级管理员配置(可选)
|
||||
# ----------------------------------------------------------------------------
|
||||
# 不配置时会使用 pkg/constants 中的默认超管账号;生产环境建议在首次启动前覆盖。
|
||||
# 默认超级管理员用户名;留空则使用代码内置默认值 admin。
|
||||
export JUNHONG_DEFAULT_ADMIN_USERNAME='admin'
|
||||
# 默认超级管理员密码;留空则使用代码内置默认值 Admin@123456。
|
||||
export JUNHONG_DEFAULT_ADMIN_PASSWORD='Admin@123456'
|
||||
# 默认超级管理员手机号;留空则使用代码内置默认值 13800000000。
|
||||
# JUNHONG_DEFAULT_ADMIN_PHONE=''
|
||||
|
||||
export JUNHONG_MIDDLEWARE_CORS_ENABLED=true
|
||||
export JUNHONG_MIDDLEWARE_CORS_ALLOW_ORIGINS=https://cmp-admin.xm-iot.cn,https://cmp-agent.xm-iot.cn,https://cmp-c.xm-iot.cn
|
||||
export JUNHONG_MIDDLEWARE_CORS_ALLOW_CREDENTIALS=true
|
||||
8
.gitignore
vendored
8
.gitignore
vendored
@@ -26,8 +26,6 @@ vendor/
|
||||
.cache/
|
||||
|
||||
# Environment variables
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Log files
|
||||
@@ -75,7 +73,6 @@ __debug_bin1621385388
|
||||
docs/admin-openapi.yaml
|
||||
/api
|
||||
/gendocs
|
||||
.env.local
|
||||
/worker
|
||||
.opencode/skills/json-canvas
|
||||
.opencode/skills/obsidian-bases
|
||||
@@ -107,3 +104,8 @@ docs/admin-openapi.yaml
|
||||
.omx/state/sessions/omx-1777517489966-oo3g37/notify-hook-state.json
|
||||
.omx/state/tmux-extended-keys/private-tmp-tmux-501-default.json
|
||||
.aider*
|
||||
|
||||
# /teach skill 的个人学习工作区,不进入项目提交历史
|
||||
.claude/teach-workspace/
|
||||
scripts/batch_package_purchase/assets.example_购买结果_20260715_115804.csv
|
||||
scripts/batch_package_purchase/assets.example_购买结果_20260715_115814.csv
|
||||
|
||||
126
.scratch/customer-binding-architecture/PRD.md
Normal file
126
.scratch/customer-binding-architecture/PRD.md
Normal file
@@ -0,0 +1,126 @@
|
||||
Status: ready-for-agent
|
||||
|
||||
# PRD:客户绑定体系重构与资产归属验证统一
|
||||
|
||||
## Problem Statement
|
||||
|
||||
C 端用户在使用没有虚拟号的 IoT 卡时,整个业务流程完全失效:登录后无法实名认证、无法购买套餐、无法操作设备。根本原因是系统的客户绑定机制依赖虚拟号(`virtual_no`)作为唯一标识,而虚拟号在 IoT 卡上是可选字段,线上有 12,000+ 张无虚拟号的卡且确认会流向 C 端用户。
|
||||
|
||||
与此同时,换货流程在旧卡有虚拟号、新卡无虚拟号时会被错误拦截,即使旧卡实际上没有任何客户绑定记录,换货依然报错"新资产无法承接客户绑定"。
|
||||
|
||||
从设计层面看,`tb_personal_customer_iccid` 表(按 ICCID 绑定)早已建好,Store 层也实现完整,但从未被任何 Service 或 Handler 接入。同时,"判断某张卡/设备是否属于某个客户"这个核心安全规则,散落在 6 个文件中以 4 种不同方式实现,部分实现缺少对已禁用绑定记录的过滤,存在安全缺口。
|
||||
|
||||
## Solution
|
||||
|
||||
建立统一的 **CustomerBinding 模块**,封装客户与资产之间的绑定创建和归属验证逻辑,屏蔽"有虚拟号走 `tb_personal_customer_device` / 无虚拟号走 `tb_personal_customer_iccid`"的路由细节。所有调用方只需传入资产信息,不感知底层用了哪张表。
|
||||
|
||||
在此基础上,修复换货服务中校验与执行逻辑混淆的 bug,并将资产解析和 IoT 卡状态字段整理为后续阶段任务。
|
||||
|
||||
## User Stories
|
||||
|
||||
### C 端用户(无虚拟号的卡)
|
||||
|
||||
1. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想在首次登录时成功绑定我的卡,以便后续能正常使用所有 C 端功能。
|
||||
2. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想在绑定卡后能正常提交实名认证,以便激活卡的完整功能。
|
||||
3. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想购买流量套餐,以便为我的卡充值续费。
|
||||
4. 作为一个持有无虚拟号 IoT 卡的 C 端用户,我想在换货后仍能访问新卡并正常操作,以便换货不影响我的使用体验。
|
||||
|
||||
### C 端用户(有虚拟号的卡)
|
||||
|
||||
5. 作为一个持有有虚拟号 IoT 卡的 C 端用户,我的所有现有功能不受本次改动影响,以便升级对我透明无感知。
|
||||
6. 作为一个持有有虚拟号 IoT 卡的 C 端用户,当我的绑定关系被禁用后,我不能通过该绑定关系访问资产,以便系统安全边界得到保障。
|
||||
|
||||
### 后台运营人员(换货)
|
||||
|
||||
7. 作为后台运营人员,我想将一张有虚拟号的旧卡换货为一张无虚拟号的新卡,并且换货能正常完成,以便不再因无虚拟号而被系统错误拦截。
|
||||
8. 作为后台运营人员,当旧卡有客户绑定记录、新卡无虚拟号时,我希望系统在换货完成后自动将客户绑定迁移到新卡的 ICCID,以便客户换货后仍能访问新卡。
|
||||
9. 作为后台运营人员,当旧卡没有任何客户绑定记录时,无论旧卡是否有虚拟号,换货都应该正常完成,以便换货流程不被无意义的条件拦截。
|
||||
|
||||
### 系统(数据一致性)
|
||||
|
||||
10. 作为系统,当客户首次绑定一张无虚拟号的卡时,应正确将该卡的 `asset_status` 从在库更新为已销售,以便轮询系统能感知到该卡有真实用户在使用。
|
||||
11. 作为系统,当客户的绑定关系被禁用(`status=0`)后,归属验证应拒绝该客户访问对应资产,以便被撤销的绑定不再赋予访问权限。
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### 1. 新增 CustomerBinding 模块
|
||||
|
||||
在 `internal/service/` 下建立 `customer_binding` 包,对外暴露两个核心接口:
|
||||
|
||||
- `Bind(ctx, tx, customerID, assetType, assetID)` — 创建或更新客户与资产的绑定关系;对于卡资产,有虚拟号写 `tb_personal_customer_device`,无虚拟号写 `tb_personal_customer_iccid`
|
||||
- `OwnsAsset(ctx, customerID, assetType, assetID) bool` — 验证客户是否持有该资产的有效绑定(`status=1`);按资产类型路由到对应绑定表查询
|
||||
- `Migrate(ctx, tx, oldAsset, newAsset)` — 换货时迁移绑定关系;处理四种组合(有→有、有→无、无→有、无→无)
|
||||
|
||||
`OwnsAsset` 内部统一强制 `status=1` 过滤,解决当前部分实现缺少此过滤的安全缺口。
|
||||
|
||||
### 2. 绑定路由规则(卡资产)
|
||||
|
||||
| 旧卡 | 新卡 | Migrate 行为 |
|
||||
|------|------|-------------|
|
||||
| 有虚拟号,有 pcd 绑定 | 有虚拟号 | 更新 pcd 记录的 virtual_no |
|
||||
| 有虚拟号,有 pcd 绑定 | 无虚拟号 | 禁用旧 pcd 记录 + 创建 pci ICCID 绑定 |
|
||||
| 有虚拟号,无绑定 | 无虚拟号 | 跳过(无需迁移) |
|
||||
| 无虚拟号 | 任意 | 迁移 pci 记录(若存在)到新卡 ICCID |
|
||||
|
||||
### 3. 接入 CustomerBinding 的调用点
|
||||
|
||||
以下调用点需要替换为 CustomerBinding 模块:
|
||||
|
||||
- `client_auth/service.go: bindAsset()` — 使用 `CustomerBinding.Bind()`
|
||||
- `client_auth/service.go: resolveAssetBindingKey()` — 废弃,逻辑内聚到 CustomerBinding
|
||||
- `handler/app/client_realname.go: ExistsByCustomerAndDevice()` — 替换为 `CustomerBinding.OwnsAsset()`
|
||||
- `handler/app/client_device.go: ExistsByCustomerAndDevice()` — 替换为 `CustomerBinding.OwnsAsset()`
|
||||
- `handler/app/client_wallet.go: isCustomerOwnAsset()` — 替换为 `CustomerBinding.OwnsAsset()`
|
||||
- `handler/app/client_asset.go: isCustomerOwnAsset()` — 替换为 `CustomerBinding.OwnsAsset()`
|
||||
- `service/client_order/service.go: checkAssetOwnership()` — 替换为 `CustomerBinding.OwnsAsset()`
|
||||
- `service/exchange/service.go: switchCustomerBindingWithTx()` — 替换为 `CustomerBinding.Migrate()`
|
||||
|
||||
### 4. 修复 switchCustomerBindingWithTx 的校验混淆
|
||||
|
||||
当前 `switchCustomerBindingWithTx`(执行函数)在执行阶段重复做了 `ensureNewAssetBindingAvailableWithTx`(校验函数)的判断,且条件更严(不查实际记录数,只看 key 是否为空)。
|
||||
|
||||
修复方式:将 `switchCustomerBindingWithTx` 改为调用 `CustomerBinding.Migrate()`,移除函数内的 `newKey==""` 拦截逻辑。`ensureNewAssetBindingAvailableWithTx` 同步更新:当 `newKey==""` 时,不再拦截,因为 `Migrate()` 已能正确处理此情形。
|
||||
|
||||
### 5. asset_status 首销标记修复
|
||||
|
||||
`bindAsset` 中通过 `firstEverBind`(查 `virtual_no=""` 的记录数)来判断是否首次绑定并触发 `markAssetAsSold()`。无虚拟号的卡因为 key 为空,导致此逻辑失效,`asset_status` 永远停在在库。
|
||||
|
||||
修复方式:在 `CustomerBinding.Bind()` 内,对 IoT 卡分别按各自绑定表查询首绑状态,再触发 `markAssetAsSold()`。
|
||||
|
||||
### 6. 不引入新的数据库表或字段
|
||||
|
||||
`tb_personal_customer_iccid` 表和 `PersonalCustomerICCIDStore` 已经存在且完整,本次只是接入,不需要迁移或 schema 变更。
|
||||
|
||||
### 7. 现有 personal_customer_device 数据不迁移
|
||||
|
||||
有虚拟号的卡现有绑定数据保持不变,继续走 `tb_personal_customer_device` 路径。只有无虚拟号的卡新登录时才写 `tb_personal_customer_iccid`。
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
本项目禁止自动化测试,使用 PostgreSQL MCP 和 Postman/curl 手动验证。
|
||||
|
||||
**验证重点场景:**
|
||||
|
||||
1. **无虚拟号卡首次登录**:用无虚拟号卡的 ICCID 登录后,`tb_personal_customer_iccid` 应出现绑定记录,`tb_iot_card.asset_status` 应变为 2(已销售)
|
||||
2. **无虚拟号卡归属验证**:登录后调用实名认证接口,应返回成功而非"无权限"
|
||||
3. **无虚拟号卡购买套餐**:购买套餐接口应正常通过归属验证
|
||||
4. **有虚拟号换无虚拟号(旧卡有绑定)**:换货完成后,`tb_personal_customer_device` 旧记录 status 应为 0,`tb_personal_customer_iccid` 应出现新卡 ICCID 的绑定记录
|
||||
5. **有虚拟号换无虚拟号(旧卡无绑定)**:换货应正常完成,无报错,不写入任何绑定记录
|
||||
6. **有虚拟号换有虚拟号(回归)**:现有流程不受影响,pcd 表记录的 virtual_no 正确更新到新卡
|
||||
7. **禁用绑定后归属验证**:将某条 pcd 或 pci 记录 status 设为 0,对应客户调用归属验证应返回无权限
|
||||
8. **有虚拟号卡回归(全流程)**:确认有虚拟号卡的登录、实名、购买套餐流程无任何变化
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- **IoT 卡状态字段整理**(`status` / `asset_status` / `activation_status` 语义重叠问题):影响面广,单独规划
|
||||
- **资产解析函数统一**(12 个 resolve 变体合并):不影响当前 bug,单独规划
|
||||
- **`tb_personal_customer_iccid` 换货迁移的历史数据回填**:历史上无虚拟号卡的用户无绑定记录,不做回填,用户需重新登录
|
||||
- **`tb_personal_customer_device` 中 `virtual_no=""` 的历史垃圾数据清理**:需先确认实际数量(SQL 查询),作为独立数据修复任务
|
||||
|
||||
## Further Notes
|
||||
|
||||
- 线上有 12,000+ 张无虚拟号的 IoT 卡,这是合法业务数据,不是数据质量问题
|
||||
- `tb_personal_customer_iccid` 的 Store 已有完整的 CRUD 实现(Create、ExistsByCustomerAndICCID、GetByCustomerID、CreateOrUpdateLastUsed 等),无需重写
|
||||
- 换货 bug 的直接触发路径:`completeExchangeWithTx → switchCustomerBindingWithTx`,在执行阶段因 `newKey==""` 报错,即使旧卡实际无任何客户绑定记录
|
||||
- 建议在上线前执行:`SELECT COUNT(*) FROM tb_personal_customer_device WHERE (virtual_no IS NULL OR virtual_no = '') AND deleted_at IS NULL` 确认是否有历史脏数据需要清理
|
||||
- **临时线上补丁(知悉)**:2026-06-18 已将线上 12,000+ 张无虚拟号的卡手动补充了虚拟号作为应急措施,以保障线上可用性。数据回滚由业务方自行处理,不在本次实现范围内。
|
||||
@@ -0,0 +1,50 @@
|
||||
Status: done
|
||||
|
||||
# CustomerBinding 模块骨架 + 有虚拟号路径替换(纯重构)
|
||||
|
||||
## What to build
|
||||
|
||||
建立 `internal/service/customer_binding` 包,对外暴露 `Bind()` 和 `OwnsAsset()` 两个接口,将现有有虚拟号路径的逻辑迁入。同时将代码库中 6 处归属验证调用点统一替换为新接口。
|
||||
|
||||
**本切片是纯重构,不改变任何现有行为。**
|
||||
|
||||
### Bind(ctx, tx, customerID, assetType, assetID)
|
||||
|
||||
封装创建客户与资产绑定记录的逻辑,当前只实现有虚拟号路径:
|
||||
- IoT 卡有虚拟号 → 写 `tb_personal_customer_device`(与现在 `bindAsset` 行为一致)
|
||||
- 设备 → 写 `tb_personal_customer_device`(设备必然有虚拟号)
|
||||
- 首次绑定时触发 `markAssetAsSold()`(firstEverBind 逻辑保持不变)
|
||||
|
||||
### OwnsAsset(ctx, customerID, assetType, assetID) bool
|
||||
|
||||
封装归属验证逻辑,当前只实现有虚拟号路径:
|
||||
- 查 `tb_personal_customer_device WHERE virtual_no = ? AND status = 1`
|
||||
- 内部统一强制 `status = 1` 过滤(修复现有部分实现缺少此过滤的安全缺口)
|
||||
|
||||
### 替换 6 处调用点
|
||||
|
||||
以下调用点替换为 `CustomerBinding` 的方法:
|
||||
|
||||
| 调用点 | 替换目标 |
|
||||
|--------|---------|
|
||||
| `client_auth/service.go: bindAsset()` | `CustomerBinding.Bind()` |
|
||||
| `handler/app/client_realname.go:92` | `CustomerBinding.OwnsAsset()` |
|
||||
| `handler/app/client_device.go:82` | `CustomerBinding.OwnsAsset()` |
|
||||
| `handler/app/client_wallet.go: isCustomerOwnAsset()` | `CustomerBinding.OwnsAsset()` |
|
||||
| `handler/app/client_asset.go: isCustomerOwnAsset()` | `CustomerBinding.OwnsAsset()` |
|
||||
| `service/client_order/service.go: checkAssetOwnership()` | `CustomerBinding.OwnsAsset()` |
|
||||
|
||||
exchange service 的 `customerOwnsAsset()` 在切片 3 中处理。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `internal/service/customer_binding` 包存在,对外暴露 `Bind` 和 `OwnsAsset`
|
||||
- [ ] 有虚拟号的 IoT 卡登录后,`tb_personal_customer_device` 正常写入绑定记录(与现在一致)
|
||||
- [ ] 设备登录后,`tb_personal_customer_device` 正常写入绑定记录(与现在一致)
|
||||
- [ ] 被禁用(status=0)的绑定记录不能通过 `OwnsAsset` 验证(修复安全缺口)
|
||||
- [ ] 6 处调用点全部替换完毕,原有私有方法(resolveAssetBindingKey、isCustomerOwnAsset 等)可删除
|
||||
- [ ] 有虚拟号卡的实名认证、购买套餐、设备操作全链路与现在行为一致
|
||||
|
||||
## Blocked by
|
||||
|
||||
None - 可立即开始
|
||||
@@ -0,0 +1,52 @@
|
||||
Status: done
|
||||
|
||||
# 无虚拟号单卡 C 端完整链路
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/customer-binding-architecture/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
扩展 `CustomerBinding` 模块,使无虚拟号的独立 IoT 卡(单卡)能够完整走通 C 端业务流程:登录绑定、实名认证、购买套餐。
|
||||
|
||||
**仅影响 IoT 卡(单卡)。设备资产必然有虚拟号,不在本切片处理范围内。**
|
||||
|
||||
### 扩展 Bind()
|
||||
|
||||
当资产为 IoT 卡且 `virtual_no` 为空时:
|
||||
- 写 `tb_personal_customer_iccid`(按 ICCID 绑定),而非 `tb_personal_customer_device`
|
||||
- `PersonalCustomerICCIDStore` 已有完整实现,直接注入使用
|
||||
|
||||
当资产为 IoT 卡且 `virtual_no` 不为空时:行为与切片 1 一致,不变。
|
||||
|
||||
### 修复 firstEverBind + markAssetAsSold
|
||||
|
||||
当前 `firstEverBind` 通过查 `tb_personal_customer_device WHERE virtual_no = ""` 来判断是否首次绑定,对无虚拟号的卡完全失效(所有无虚拟号的卡共用同一个空 key)。
|
||||
|
||||
修复方式:在 `Bind()` 内,按路径分别判断首绑:
|
||||
- 有虚拟号路径:查 `tb_personal_customer_device WHERE virtual_no = ?`
|
||||
- 无虚拟号路径:查 `tb_personal_customer_iccid WHERE iccid = ?`
|
||||
|
||||
首次绑定后正常触发 `markAssetAsSold()`,将卡的 `asset_status` 从在库(1)更新为已销售(2)。
|
||||
|
||||
### 扩展 OwnsAsset()
|
||||
|
||||
当资产为 IoT 卡时:
|
||||
- 若卡有 `virtual_no`:查 `tb_personal_customer_device`(与切片 1 一致)
|
||||
- 若卡无 `virtual_no`:查 `tb_personal_customer_iccid WHERE iccid = ? AND status = 1`
|
||||
|
||||
当资产为设备时:行为不变,只查 `tb_personal_customer_device`。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] 无虚拟号的单卡 C 端登录后,`tb_personal_customer_iccid` 中出现对应的绑定记录
|
||||
- [ ] 无虚拟号单卡首次登录后,`tb_iot_card.asset_status` 变为 2(已销售)
|
||||
- [ ] 无虚拟号单卡登录后,实名认证接口返回成功(不报"无权限")
|
||||
- [ ] 无虚拟号单卡登录后,购买套餐接口归属验证通过
|
||||
- [ ] 有虚拟号卡的全部行为与切片 1 完成后保持一致(无回归)
|
||||
- [ ] 设备资产的全部行为不受影响
|
||||
|
||||
## Blocked by
|
||||
|
||||
`.scratch/customer-binding-architecture/issues/01-customer-binding-module-refactor.md`
|
||||
@@ -0,0 +1,45 @@
|
||||
Status: done
|
||||
|
||||
# 换货绑定迁移修复
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/customer-binding-architecture/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
在 `CustomerBinding` 模块上实现 `Migrate(ctx, tx, oldAsset, newAsset)` 方法,替换换货服务中现有的 `switchCustomerBindingWithTx` 和 `ensureNewAssetBindingAvailableWithTx` 逻辑,修复有虚拟号旧卡换无虚拟号新卡时被误拦截的 bug。
|
||||
|
||||
### Migrate(ctx, tx, oldAsset, newAsset)
|
||||
|
||||
处理四种虚拟号组合,仅涉及 IoT 卡(设备必然有虚拟号,只有有→有一种情形):
|
||||
|
||||
| 旧卡 | 新卡 | 行为 |
|
||||
|------|------|------|
|
||||
| 有虚拟号,pcd 有绑定 | 有虚拟号 | 更新 pcd 记录的 virtual_no 为新卡虚拟号 |
|
||||
| 有虚拟号,pcd 有绑定 | 无虚拟号 | 禁用旧 pcd 记录(status=0)+ 创建新 pci ICCID 绑定 |
|
||||
| 有虚拟号,pcd 无绑定 | 无虚拟号 | 跳过,无需迁移 |
|
||||
| 无虚拟号,pci 有绑定 | 任意 | 迁移 pci 记录到新卡 ICCID(或新卡虚拟号路径) |
|
||||
| 任意 | 任意(无绑定) | 跳过 |
|
||||
|
||||
### 修复换货服务
|
||||
|
||||
- `switchCustomerBindingWithTx` 替换为调用 `CustomerBinding.Migrate()`,移除函数内 `newKey == ""` 的误拦截逻辑
|
||||
- `ensureNewAssetBindingAvailableWithTx` 更新:当新卡无虚拟号时,不再拦截换货,因为 `Migrate()` 已能正确处理此情形(无绑定时跳过,有绑定时迁移到 pci)
|
||||
|
||||
### 根本 bug 说明
|
||||
|
||||
原 `switchCustomerBindingWithTx` 在执行阶段做了 `if newKey == "" { return error }` 的检查,但没有先确认旧资产是否实际存在绑定记录。旧卡有虚拟号但无任何客户绑定时,也会被误拦截报错"新资产无法承接客户绑定"。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] 旧卡有虚拟号 + 有客户绑定,换货为有虚拟号新卡:pcd 记录的 virtual_no 正确更新为新卡虚拟号
|
||||
- [ ] 旧卡有虚拟号 + 有客户绑定,换货为无虚拟号新卡:旧 pcd 记录 status 变为 0,pci 表出现新卡 ICCID 的绑定记录
|
||||
- [ ] 旧卡有虚拟号 + 无客户绑定,换货为无虚拟号新卡:换货正常完成,不报错,不写入任何绑定记录
|
||||
- [ ] 旧卡无虚拟号,换货为任意新卡:换货正常完成
|
||||
- [ ] 有虚拟号换有虚拟号(原有场景):行为与修复前一致,无回归
|
||||
- [ ] 换货完成后,客户通过新卡(无论有无虚拟号)能正常通过归属验证
|
||||
|
||||
## Blocked by
|
||||
|
||||
`.scratch/customer-binding-architecture/issues/02-no-virtual-no-card-cend-flow.md`
|
||||
143
.scratch/enterprise-auth-and-asset-enhancements/PRD.md
Normal file
143
.scratch/enterprise-auth-and-asset-enhancements/PRD.md
Normal file
@@ -0,0 +1,143 @@
|
||||
Status: ready-for-agent
|
||||
|
||||
# PRD:企业授权增强 & 资产列表扩展
|
||||
|
||||
## Problem Statement
|
||||
|
||||
平台运营人员在管理企业授权和查看资产状态时面临以下痛点:
|
||||
|
||||
1. **企业维度检索缺失**:卡列表和设备列表无法按企业维度过滤,也无法在列表中直接看到某张卡/设备被授权给了哪个企业,需要跨页面跳转查询。
|
||||
2. **企业授权操作繁琐**:授权和收回接口只接受精确 ICCID/虚拟号列表,但前端列表有分页,量大时需一个个选,无法按批次号、号段等条件一次性批量操作。
|
||||
3. **换货历史不可见**:经历过换货的旧卡/设备,在列表和详情中没有任何标记,运营无法判断当前资产是否为"换货遗留"状态或曾经历过换货后被重新激活(转新)的资产。
|
||||
4. **主钱包流水和退款列表检索维度不足**:无法直接通过资产标识(ICCID 或虚拟号)快速定位某个资产相关的流水和退款记录。
|
||||
5. **卡网络状态过滤缺失**:standalone 卡列表无法按网络状态(开机/停机)过滤,批量运维操作不便。
|
||||
|
||||
## Solution
|
||||
|
||||
对六个业务场景进行针对性扩展:
|
||||
|
||||
1. 在 standalone 卡列表和设备列表中新增企业维度过滤及企业信息返回字段
|
||||
2. 将企业授权/收回接口升级为支持 list/range/filter 三(两)模式选资产
|
||||
3. 在 standalone 卡列表和设备列表响应中暴露换货业务状态和资产世代编号
|
||||
4. 主钱包流水列表新增资产标识精确检索
|
||||
5. 退款列表新增资产标识精确检索
|
||||
6. standalone 卡列表新增网络状态过滤
|
||||
|
||||
同时修复卡企业授权表的数据一致性问题(补唯一约束,与设备授权保持一致)。
|
||||
|
||||
## User Stories
|
||||
|
||||
1. 作为平台运营,我希望在 standalone 卡列表中输入企业 ID 进行过滤,以便快速找到已授权给该企业的所有卡
|
||||
2. 作为平台运营,我希望在 standalone 卡列表中使用"是否授权企业"开关过滤,以便快速区分已授权和未授权的卡库存
|
||||
3. 作为平台运营,我希望在 standalone 卡列表中直接看到每张卡被授权给哪个企业(企业ID和名称),以便不用跳转到企业详情页
|
||||
4. 作为平台运营,我希望在设备列表中输入企业 ID 进行过滤,以便快速找到已授权给该企业的所有设备
|
||||
5. 作为平台运营,我希望在设备列表中使用"是否授权企业"开关过滤,以便区分已授权和未授权的设备
|
||||
6. 作为平台运营,我希望在设备列表中直接看到每台设备被授权给哪个企业,以便快速掌握设备归属
|
||||
7. 作为平台运营,我希望在授权卡给企业时能通过 ICCID 号段范围一次性选取,以便高效授权大批量卡
|
||||
8. 作为平台运营,我希望在授权卡给企业时能通过批次号、运营商等筛选条件选取,以便按业务维度批量授权
|
||||
9. 作为平台运营,我希望在收回企业卡授权时同样支持 list/range/filter 三种模式,以便与授权操作保持一致的操作体验
|
||||
10. 作为平台运营,我希望在授权设备给企业时能通过虚拟号模糊搜索和批次号筛选一次性选取,以便批量操作设备
|
||||
11. 作为平台运营,我希望在收回企业设备授权时支持 list/filter 两种模式,以便批量收回
|
||||
12. 作为平台运营,我希望在 standalone 卡列表中看到每张卡的业务状态(`asset_status`),以便识别已换货但尚未转新的"死档"卡
|
||||
13. 作为平台运营,我希望在 standalone 卡列表中看到每张卡的世代编号(`generation`),以便识别曾换货后转新重入库的卡
|
||||
14. 作为平台运营,我希望在设备列表中看到每台设备的业务状态和世代编号,以便同样掌握设备的换货历史
|
||||
15. 作为平台运营,我希望在主钱包流水列表中输入 ICCID 或虚拟号精确检索,以便快速定位某资产的所有充值/扣款记录
|
||||
16. 作为平台运营,我希望在退款列表中输入 ICCID 或虚拟号精确检索,以便快速找到某资产相关的所有退款申请
|
||||
17. 作为平台运营,我希望在 standalone 卡列表中按网络状态(开机/停机)过滤,以便批量处理特定网络状态的卡
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### 数据一致性修复(前置)
|
||||
|
||||
- **卡企业授权唯一约束**:在 `tb_enterprise_card_authorization` 表上补充部分唯一索引,约束同一张卡在同一时间只能有一条有效授权记录(`WHERE revoked_at IS NULL AND deleted_at IS NULL`),与设备授权表的 `uq_active_device_auth` 约束保持一致
|
||||
- **service 层校验补充**:卡授权 service 在执行授权前,需增加"卡是否已授权给其他企业"的校验,目前只校验同一企业重复授权
|
||||
|
||||
### 需求1:standalone 卡列表 + 设备列表企业字段
|
||||
|
||||
**Request 新增过滤条件**(`ListStandaloneIotCardRequest` 和 `ListDeviceRequest`):
|
||||
- `authorized_enterprise_id *uint`:按企业ID过滤,只匹配当前有效授权(`revoked_at IS NULL`)
|
||||
- `is_authorized_to_enterprise *bool`:true=已授权给某企业,false=未授权任何企业
|
||||
|
||||
**Response 新增字段**(`StandaloneIotCardResponse` 和 `DeviceResponse`):
|
||||
- `authorized_enterprise_id *uint`:未授权时为 null
|
||||
- `authorized_enterprise_name string`:未授权时为空字符串
|
||||
|
||||
Store 层需通过 JOIN 或子查询 `tb_enterprise_card_authorization` / `tb_enterprise_device_authorization` 表获取企业ID,再批量查企业名称
|
||||
|
||||
### 需求2:企业授权/收回接口升级为多模式
|
||||
|
||||
参照 `AllocateStandaloneCardsRequest` / `RecallStandaloneCardsRequest` 的 `selection_type` 三模式设计:
|
||||
|
||||
**allocate-cards / recall-cards**(三模式:list / range / filter):
|
||||
- `list`:`ICCIDs []string`,精确 ICCID 列表
|
||||
- `range`:`ICCIDStart string` + `ICCIDEnd string`,号段范围
|
||||
- `filter`:筛选条件
|
||||
- allocate-cards filter 字段:`ICCID`(模糊)、`BatchNo`、`CarrierID`、`ShopID`/`ShopIDs`
|
||||
- recall-cards filter 字段:`ICCID`(模糊)、`BatchNo`、`CarrierID`
|
||||
|
||||
**allocate-devices / recall-devices**(两模式:list / filter,不支持 range):
|
||||
- `list`:`DeviceNos []string`,设备虚拟号列表
|
||||
- `filter`:筛选条件
|
||||
- allocate-devices filter 字段:`VirtualNo`(模糊)、`BatchNo`、`ShopID`
|
||||
- recall-devices filter 字段:`VirtualNo`(模糊)、`BatchNo`
|
||||
|
||||
原有接口的请求结构需要破坏性变更(DTO 重构),需与前端联调确认过渡方案
|
||||
|
||||
### 需求3:换货状态字段
|
||||
|
||||
**`StandaloneIotCardResponse` 和 `DeviceResponse` 新增字段**:
|
||||
- `asset_status int`:业务状态(1=在库, 2=已销售, 3=已换货, 4=已停用)
|
||||
- `asset_status_name string`:对应中文名称
|
||||
- `generation int`:资产世代编号(初始值1,每次换货+转新后+1)
|
||||
|
||||
语义约定:
|
||||
- `asset_status = 3`:该资产已换货且尚未执行"旧资产转新",是永久性死档标记
|
||||
- `generation > 1`:该资产历史上曾执行过换货+转新,目前仍在流通
|
||||
|
||||
### 需求4:主钱包流水资产标识检索
|
||||
|
||||
`MainWalletTransactionListRequest` 新增:
|
||||
- `AssetIdentifier string`:精确匹配 `asset_identifier` 快照字段(ICCID 或虚拟号),空字符串时不过滤
|
||||
|
||||
### 需求5:退款列表资产标识检索
|
||||
|
||||
`RefundListRequest` 新增:
|
||||
- `AssetIdentifier string`:精确匹配 `asset_identifier` 快照字段(ICCID 或虚拟号),空字符串时不过滤
|
||||
|
||||
### 需求6:standalone 卡列表网络状态过滤
|
||||
|
||||
`ListStandaloneIotCardRequest` 新增:
|
||||
- `NetworkStatus *int`:网络状态过滤(0=停机, 1=开机),不传则不过滤
|
||||
|
||||
Store 层 `applyStandaloneFilters` 函数中补充 `network_status = ?` 条件
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
本项目禁止自动化测试,验证方式为:
|
||||
|
||||
- **数据库验证**:通过 PostgreSQL MCP 工具验证授权表唯一约束是否生效、字段值是否正确写入
|
||||
- **接口验证**:通过 curl/Postman 对各接口进行手动联调,重点覆盖以下场景:
|
||||
- 企业 ID 过滤:确认只返回有效授权(revoked_at IS NULL)的卡/设备
|
||||
- 是否授权企业过滤:true/false 结果集互补且无交集
|
||||
- 授权时尝试将同一张卡授权给第二个企业,确认被拒绝
|
||||
- allocate-cards 三种 selection_type 均能正确选取目标卡
|
||||
- allocate-devices 两种 selection_type 均能正确选取目标设备
|
||||
- asset_status=3 的卡在列表中可见且字段正确
|
||||
- generation 字段在换货+转新后正确递增
|
||||
- 主钱包流水 asset_identifier 精确匹配不漏不多
|
||||
- 退款 asset_identifier 精确匹配不漏不多
|
||||
- 网络状态过滤 0/1 结果集互补
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 企业卡/设备授权列表接口本身的改造(本次只改授权/收回操作接口)
|
||||
- `AllocateCardsPreviewReq`(预览接口)是否同步升级为多模式(待后续评估)
|
||||
- standalone 卡列表和设备列表的导出功能是否同步支持新增过滤条件
|
||||
- 换货历史的完整时间线展示(仅暴露字段,不新增详情接口)
|
||||
- `generation` 字段的过滤能力(暂只返回,不支持作为检索条件)
|
||||
|
||||
## Further Notes
|
||||
|
||||
- 需求1 的企业名称需要 N+1 防护:通过授权查询批量拿到 enterprise_id 后,用 IN 一次性查企业名称,不能逐条查
|
||||
- 需求2 的 filter 模式对于 allocate-cards 场景,卡的范围天然受操作者权限约束(代理用户只能授权自己店铺的卡),Store 层已有 `middleware.ApplyShopFilter` 处理,filter 模式需要同样受此约束
|
||||
- 卡唯一约束迁移执行前,需确认现有数据中是否存在一张卡同时有多条 `revoked_at IS NULL` 的记录,若有需先清理
|
||||
@@ -0,0 +1,28 @@
|
||||
Status: ready-for-human
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
修复卡企业授权的数据一致性问题,使其与设备授权保持一致:一张卡在同一时间只能授权给一个企业。
|
||||
|
||||
分两步:
|
||||
|
||||
**第一步:数据库迁移**
|
||||
在 `tb_enterprise_card_authorization` 表上新增部分唯一索引,约束同一张卡不能同时存在两条有效授权记录(`revoked_at IS NULL AND deleted_at IS NULL`)。迁移执行前需先查询是否存在脏数据(同一 card_id 有多条 revoked_at IS NULL 的记录),若有需在迁移脚本中先行清理。
|
||||
|
||||
**第二步:service 层校验补充**
|
||||
卡授权 service(`BatchAuthorize`)在执行授权前,增加"目标卡是否已授权给其他企业"的校验。目前代码只跳过已授权给同一企业的卡,不阻止授权给第二个企业。新逻辑:若目标卡已存在有效授权(`revoked_at IS NULL`)且授权对象不是当前企业,则将该卡加入失败列表并给出明确错误原因(如"卡已授权给其他企业,请先收回")。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `tb_enterprise_card_authorization` 表存在部分唯一索引,约束 `(card_id) WHERE revoked_at IS NULL AND deleted_at IS NULL`
|
||||
- [x] 尝试将同一张卡授权给第二个企业时,接口返回失败,错误信息明确说明"已授权给其他企业"
|
||||
- [x] 已撤回(revoked_at 有值)的历史记录不受唯一约束影响,可正常查询
|
||||
- [x] 同一张卡授权给同一企业时仍返回"已授权给该企业"的原有错误,行为不变
|
||||
|
||||
## Blocked by
|
||||
|
||||
None - can start immediately
|
||||
@@ -0,0 +1,22 @@
|
||||
Status: ready-for-human
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
在 `/api/admin/iot-cards/standalone` 接口的查询条件中新增网络状态过滤。
|
||||
|
||||
Request DTO 新增可选字段 `network_status *int`(0=停机,1=开机),不传时不过滤。Store 层 `applyStandaloneFilters` 函数补充对应的 `network_status = ?` WHERE 条件。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] 传入 `network_status=0` 时只返回停机的卡
|
||||
- [x] 传入 `network_status=1` 时只返回开机的卡
|
||||
- [x] 不传 `network_status` 时返回全部(与改动前行为一致)
|
||||
- [x] 网络状态过滤可与现有其他过滤条件(ICCID、运营商等)叠加使用
|
||||
|
||||
## Blocked by
|
||||
|
||||
None - can start immediately
|
||||
@@ -0,0 +1,34 @@
|
||||
Status: ready-for-human
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
在 standalone 卡列表响应和设备列表响应中暴露换货相关字段,使运营人员能直接在列表中识别资产的换货历史。
|
||||
|
||||
**`StandaloneIotCardResponse` 新增三个字段:**
|
||||
- `asset_status int`:业务状态(1=在库, 2=已销售, 3=已换货, 4=已停用)
|
||||
- `asset_status_name string`:对应中文名称
|
||||
- `generation int`:资产世代编号(初始值1,每次换货+旧资产转新后 +1)
|
||||
|
||||
**`DeviceResponse` 同步新增相同三个字段。**
|
||||
|
||||
语义约定(写入注释和 description):
|
||||
- `asset_status = 3`:该资产已换货且尚未执行"旧资产转新",不再流通
|
||||
- `generation > 1`:该资产历史上曾执行过换货后转新,目前仍在使用
|
||||
|
||||
Service 层组装响应时直接从 Model 取值,`asset_status_name` 通过 constants 中的方法转换。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `/api/admin/iot-cards/standalone` 响应中每条记录包含 `asset_status`、`asset_status_name`、`generation` 三个字段
|
||||
- [x] `/api/admin/devices` 响应中每条记录包含相同三个字段
|
||||
- [x] 已换货未转新的卡/设备返回 `asset_status=3`、`asset_status_name="已换货"`
|
||||
- [x] 经过换货转新重入库的卡/设备返回 `generation=2`(或更高)且 `asset_status=1`
|
||||
- [x] 普通未经历换货的资产返回 `generation=1`
|
||||
|
||||
## Blocked by
|
||||
|
||||
None - can start immediately
|
||||
@@ -0,0 +1,23 @@
|
||||
Status: ready-for-human
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
在 `/api/admin/shops/{shop_id}/main-wallet/transactions` 接口的查询条件中新增资产标识精确检索。
|
||||
|
||||
`MainWalletTransactionListRequest` 新增可选字段 `asset_identifier string`,非空时对 `asset_identifier` 列做精确匹配(`= ?`,不做模糊匹配)。`asset_identifier` 字段存储的是下单时资产的标识符快照(ICCID 或虚拟号),空字符串时不过滤。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] 传入有效的 ICCID 时只返回该卡相关的主钱包流水
|
||||
- [x] 传入有效的设备虚拟号时只返回该设备相关的主钱包流水
|
||||
- [x] 传入不存在的标识符时返回空列表(total=0),不报错
|
||||
- [x] 不传 `asset_identifier` 时行为与改动前完全一致
|
||||
- [x] 可与 `transaction_type`、`start_date`、`end_date` 等现有条件叠加使用
|
||||
|
||||
## Blocked by
|
||||
|
||||
None - can start immediately
|
||||
@@ -0,0 +1,23 @@
|
||||
Status: ready-for-human
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
在 `/api/admin/refunds` 接口的查询条件中新增资产标识精确检索。
|
||||
|
||||
`RefundListRequest` 新增可选字段 `asset_identifier string`,非空时对退款记录的 `asset_identifier` 列做精确匹配(`= ?`)。该字段存储的是下单时资产的标识符快照(ICCID 或虚拟号),空字符串时不过滤。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] 传入有效的 ICCID 时只返回该卡相关的退款申请
|
||||
- [x] 传入有效的设备虚拟号时只返回该设备相关的退款申请
|
||||
- [x] 传入不存在的标识符时返回空列表(total=0),不报错
|
||||
- [x] 不传 `asset_identifier` 时行为与改动前完全一致
|
||||
- [x] 可与 `status`、`order_id`、`shop_id` 等现有条件叠加使用
|
||||
|
||||
## Blocked by
|
||||
|
||||
None - can start immediately
|
||||
@@ -0,0 +1,33 @@
|
||||
Status: ready-for-human
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
在 `/api/admin/iot-cards/standalone` 接口中新增企业维度过滤条件,并在响应中返回当前有效授权的企业信息。
|
||||
|
||||
**Request 新增过滤条件(`ListStandaloneIotCardRequest`):**
|
||||
- `authorized_enterprise_id *uint`:按企业ID过滤,只匹配 `tb_enterprise_card_authorization` 中 `revoked_at IS NULL` 的有效授权
|
||||
- `is_authorized_to_enterprise *bool`:true=只返回当前已授权给某企业的卡,false=只返回未授权任何企业的卡
|
||||
|
||||
**Response 新增字段(`StandaloneIotCardResponse`):**
|
||||
- `authorized_enterprise_id *uint`:当前有效授权的企业ID,未授权时为 null
|
||||
- `authorized_enterprise_name string`:对应企业名称,未授权时为空字符串
|
||||
|
||||
Store 层在 `applyStandaloneFilters` 中增加对 `authorized_enterprise_id` 和 `is_authorized_to_enterprise` 的处理,通过子查询 `tb_enterprise_card_authorization`(`revoked_at IS NULL AND deleted_at IS NULL`)实现过滤。响应组装时批量查询企业名称(IN 查询),不逐条查询。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] 传入 `authorized_enterprise_id` 时只返回当前有效授权给该企业的卡
|
||||
- [x] 传入 `is_authorized_to_enterprise=true` 时只返回已授权给某企业的卡
|
||||
- [x] 传入 `is_authorized_to_enterprise=false` 时只返回未授权任何企业的卡
|
||||
- [x] 已撤回的历史授权不影响过滤结果(已撤回视为未授权)
|
||||
- [x] 响应中每张卡包含 `authorized_enterprise_id` 和 `authorized_enterprise_name`,未授权卡对应字段为 null/空字符串
|
||||
- [x] 企业名称通过批量查询获取,不触发 N+1 查询
|
||||
- [x] 新增过滤条件可与现有条件(ICCID、运营商、店铺等)叠加使用
|
||||
|
||||
## Blocked by
|
||||
|
||||
- `issues/01-card-enterprise-auth-unique-constraint.md`
|
||||
@@ -0,0 +1,32 @@
|
||||
Status: ready-for-human
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
在 `/api/admin/devices` 接口中新增企业维度过滤条件,并在响应中返回当前有效授权的企业信息。
|
||||
|
||||
**Request 新增过滤条件(`ListDeviceRequest`):**
|
||||
- `authorized_enterprise_id *uint`:按企业ID过滤,只匹配 `tb_enterprise_device_authorization` 中 `revoked_at IS NULL` 的有效授权
|
||||
- `is_authorized_to_enterprise *bool`:true=只返回当前已授权给某企业的设备,false=只返回未授权任何企业的设备
|
||||
|
||||
**Response 新增字段(`DeviceResponse`):**
|
||||
- `authorized_enterprise_id *uint`:当前有效授权的企业ID,未授权时为 null
|
||||
- `authorized_enterprise_name string`:对应企业名称,未授权时为空字符串
|
||||
|
||||
Store 层通过子查询或 JOIN `tb_enterprise_device_authorization` 实现过滤。响应组装时批量查询企业名称(IN 查询),不逐条查询。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] 传入 `authorized_enterprise_id` 时只返回当前有效授权给该企业的设备
|
||||
- [x] 传入 `is_authorized_to_enterprise=true` 时只返回已授权给某企业的设备
|
||||
- [x] 传入 `is_authorized_to_enterprise=false` 时只返回未授权任何企业的设备
|
||||
- [x] 已撤回的历史授权不影响过滤结果(已撤回视为未授权)
|
||||
- [x] 响应中每台设备包含 `authorized_enterprise_id` 和 `authorized_enterprise_name`,未授权设备对应字段为 null/空字符串
|
||||
- [x] 企业名称通过批量查询获取,不触发 N+1 查询
|
||||
|
||||
## Blocked by
|
||||
|
||||
None - can start immediately
|
||||
@@ -0,0 +1,42 @@
|
||||
Status: ready-for-human
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
将企业卡授权和收回接口升级为支持 list/range/filter 三种模式批量选取卡,替代原来只接受精确 ICCID 列表的方式。
|
||||
|
||||
**涉及接口:**
|
||||
- `POST /api/admin/enterprises/{id}/allocate-cards`
|
||||
- `POST /api/admin/enterprises/{id}/recall-cards`
|
||||
|
||||
**`AllocateCardsReq` 重构为:**
|
||||
- `selection_type string`(必填,`list`、`range` 或 `filter`)
|
||||
- list 模式:`iccids []string`(ICCID 列表,最多1000个)
|
||||
- range 模式:`iccid_start string` + `iccid_end string`(号段范围)
|
||||
- filter 模式过滤字段:`iccid string`(模糊)、`batch_no string`、`carrier_id *uint`、`shop_id *uint`、`shop_ids []uint`
|
||||
- `remark string`(备注,所有模式均可选)
|
||||
|
||||
**`RecallCardsReq` 重构为:**
|
||||
- `selection_type string`(必填,`list`、`range` 或 `filter`)
|
||||
- list 模式:`iccids []string`
|
||||
- range 模式:`iccid_start string` + `iccid_end string`
|
||||
- filter 模式过滤字段:`iccid string`(模糊)、`batch_no string`、`carrier_id *uint`
|
||||
|
||||
filter 和 range 模式下,allocate 操作的候选卡集受操作者权限约束(代理用户只能授权自己店铺的卡),与 `applyStandaloneFilters` 中的 `ApplyShopFilter` 逻辑保持一致。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `allocate-cards` 接口接受 `selection_type=list` + `iccids`,行为与改动前一致
|
||||
- [x] `allocate-cards` 接口接受 `selection_type=range` + 号段,批量授权号段内所有匹配的卡
|
||||
- [x] `allocate-cards` 接口接受 `selection_type=filter` + 过滤条件,批量授权所有匹配的卡
|
||||
- [x] `recall-cards` 接口同样支持三种模式,分别正确收回对应卡的企业授权
|
||||
- [x] filter/range 模式下代理用户只能操作自己店铺的卡,超出范围的卡进入失败列表
|
||||
- [x] 任何模式下尝试授权"已授权给其他企业"的卡,该卡进入失败列表并附带原因
|
||||
- [x] 响应中 `success_count`、`fail_count`、`failed_items` 准确反映实际执行结果
|
||||
|
||||
## Blocked by
|
||||
|
||||
- `issues/01-card-enterprise-auth-unique-constraint.md`
|
||||
@@ -0,0 +1,38 @@
|
||||
Status: ready-for-human
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/enterprise-auth-and-asset-enhancements/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
将企业设备授权和收回接口升级为支持 list/filter 两种模式批量选取设备,替代原来只接受精确设备号列表的方式。
|
||||
|
||||
**涉及接口:**
|
||||
- `POST /api/admin/enterprises/{id}/allocate-devices`
|
||||
- `POST /api/admin/enterprises/{id}/recall-devices`
|
||||
|
||||
**`AllocateDevicesReq` 重构为:**
|
||||
- `selection_type string`(必填,`list` 或 `filter`)
|
||||
- list 模式:`device_nos []string`(设备虚拟号列表)
|
||||
- filter 模式过滤字段:`virtual_no string`(模糊)、`batch_no string`、`shop_id *uint`
|
||||
|
||||
**`RecallDevicesReq` 重构为:**
|
||||
- `selection_type string`(必填,`list` 或 `filter`)
|
||||
- list 模式:`device_nos []string`
|
||||
- filter 模式过滤字段:`virtual_no string`(模糊)、`batch_no string`
|
||||
|
||||
filter 模式下,allocate 操作的候选设备集受操作者权限约束(代理用户只能授权自己店铺的设备)。filter 模式命中的设备数量无硬性上限,但单次事务处理建议分批,超大批次需记录日志。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [x] `allocate-devices` 接口接受 `selection_type=list` + `device_nos` 列表,行为与改动前一致
|
||||
- [x] `allocate-devices` 接口接受 `selection_type=filter` + 过滤条件,批量授权所有匹配设备
|
||||
- [x] `recall-devices` 接口接受 `selection_type=list` + `device_nos` 列表,行为与改动前一致
|
||||
- [x] `recall-devices` 接口接受 `selection_type=filter` + 过滤条件,批量收回所有匹配设备
|
||||
- [x] filter 模式下代理用户只能操作自己店铺的设备,超出范围的设备进入失败列表
|
||||
- [x] 响应中 `success_count`、`fail_count`、`failed_items` 准确反映实际执行结果
|
||||
|
||||
## Blocked by
|
||||
|
||||
None - can start immediately
|
||||
124
.scratch/iot-risk-import-voucher-remark/PRD.md
Normal file
124
.scratch/iot-risk-import-voucher-remark/PRD.md
Normal file
@@ -0,0 +1,124 @@
|
||||
# PRD:IoT 卡风险状态限制 / 导入任务操作人与批量失效 / 凭证多图 / 预充值备注
|
||||
|
||||
Status: ready-for-agent
|
||||
|
||||
---
|
||||
|
||||
## Problem Statement
|
||||
|
||||
运营团队在日常工作中遇到四个独立痛点:
|
||||
|
||||
1. **风险停机/已销户卡无法有效拦截**:运营商侧将某些卡置为"风险停机"或"已销户"状态后,系统仍允许对这些卡发起复机操作,且轮询系统仍持续轮询这些卡,浪费资源并可能产生错误数据。
|
||||
|
||||
2. **导入任务无操作人信息**:IoT 卡导入任务和设备导入任务的列表中没有"操作人"字段,无法追溯是谁发起的批量操作。同时缺少批量失效订单套餐的操作入口,运营只能逐条手动处理。
|
||||
|
||||
3. **凭证只能上传一张**:后台创建订单和发起退款时,只允许上传一张凭证图片,无法满足多张凭证的业务场景(如多笔转账记录、多页合同等)。
|
||||
|
||||
4. **代理预充值缺少备注字段**:代理预充值操作无法附加运营备注,导致事后无法追溯充值的业务原因。
|
||||
|
||||
---
|
||||
|
||||
## Solution
|
||||
|
||||
1. 在复机接口和轮询系统中增加对 `gateway_extend` 字段的检查,当独立卡处于"风险停机"或"已销户"状态时,拒绝复机并停止轮询。
|
||||
|
||||
2. 在所有导入任务中快照操作人姓名;新增"批量失效订单套餐"导入任务,支持通过 CSV 批量将订单下的套餐置为失效状态。
|
||||
|
||||
3. 将订单、退款、代理预充值的凭证字段改为支持最多5张的 jsonb 数组存储。
|
||||
|
||||
4. 在代理预充值记录上新增 `remark` 备注字段,供运营创建时填写,并在列表/详情接口中返回。
|
||||
|
||||
---
|
||||
|
||||
## User Stories
|
||||
|
||||
1. 作为运营人员,当我尝试对"风险停机"状态的独立卡发起复机时,我希望系统拒绝并提示原因,以免产生无效操作。
|
||||
2. 作为运营人员,当我尝试对"已销户"状态的独立卡发起复机时,我希望系统拒绝并提示原因,以免对已注销的卡发起无意义请求。
|
||||
3. 作为运营人员,我希望"风险停机"和"已销户"的独立卡不再参与系统轮询,以免浪费轮询资源并产生噪音数据。
|
||||
4. 作为运营人员,当轮询系统检测到某张独立卡的网关状态变为"风险停机"或"已销户"时,我希望系统自动将该卡的轮询关闭,而不需要手动干预。
|
||||
5. 作为运营人员,我希望在 IoT 卡导入任务列表中看到"操作人"姓名,以便追溯每次批量导入是谁发起的。
|
||||
6. 作为运营人员,我希望在设备导入任务列表中看到"操作人"姓名,以便追溯每次批量导入是谁发起的。
|
||||
7. 作为运营人员,我希望通过上传 CSV 文件批量将一批订单的套餐置为失效,以便快速处理异常订单。
|
||||
8. 作为运营人员,我希望在创建批量失效任务时能填写备注和上传凭证(最多5张),以便记录操作原因。
|
||||
9. 作为运营人员,我希望批量失效任务完成后能看到成功数、失败数,以及失败原因(如订单号不存在),以便核查处理结果。
|
||||
10. 作为运营人员,我希望批量失效套餐只影响套餐记录本身,不触发退款和其他业务流程,让轮询系统自行根据套餐状态处理停机。
|
||||
11. 作为运营人员,我希望在创建后台订单时可以上传最多5张凭证,以便完整记录线下付款证明。
|
||||
12. 作为运营人员,我希望在发起退款申请时可以上传最多5张凭证,以便完整记录退款证明材料。
|
||||
13. 作为运营人员,我希望在代理预充值列表中看到备注内容,以便了解每笔预充值的业务背景。
|
||||
14. 作为运营人员,我希望在创建代理预充值订单时填写备注,以便记录本次充值的原因或说明。
|
||||
15. 作为开放 API 调用方,当我通过 Open API 对"风险停机"或"已销户"的独立卡调用复机接口时,我希望收到明确的错误响应。
|
||||
|
||||
---
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### 需求一:风险停机/已销户卡限制
|
||||
|
||||
- **判断字段**:`tb_iot_card.gateway_extend` 原文值 = `"风险停机"` 或 `"已销户"`。新增两个常量 `GatewayCardExtendRiskStop`、`GatewayCardExtendCancelled`。
|
||||
- **判断范围**:仅 `is_standalone = true` 的独立卡(未绑定设备的卡)。绑定设备的卡不受此限制。
|
||||
- **复机拦截**:在 `ManualStartCard`(后台管理员入口)和 `ResumeCard`(Open API 入口)两个函数中,调用网关前先读取 DB 中已落库的 `gateway_extend`,若命中则返回业务错误,不实时查询网关。
|
||||
- **轮询停止**:轮询状态处理器(`PollingCardStatusHandler`)在将新的 `gateway_extend` 写入 DB 后,检查若命中风险状态且 `is_standalone=true`,则调用现有的 `UpdatePollingStatus(false)` 将 `enable_polling` 写为 `false`,并跳过 `requeueCard`,终止该卡的轮询循环。
|
||||
- **错误码**:复机被拒绝时返回业务错误,提示信息明确说明原因("风险停机"/"已销户")。
|
||||
|
||||
### 需求二:导入任务操作人与批量失效
|
||||
|
||||
**操作人快照:**
|
||||
- `tb_iot_card_import_task` 和 `tb_device_import_task` 表各新增 `creator_name varchar` 字段。
|
||||
- 新的批量失效任务表同样包含此字段。
|
||||
- 创建任务时从当前登录用户快照姓名写入,旧数据留空(不回填)。
|
||||
- 列表响应 DTO 新增 `creator_name` 字段。
|
||||
|
||||
**批量失效订单套餐任务:**
|
||||
- 新建模型、Store、Service、Handler,参照现有 IoT 卡导入任务的结构(CSV 上传 → 异步任务处理)。
|
||||
- CSV 格式:单列 `order_no`(无表头行约定沿用现有导入任务的处理方式)。
|
||||
- 创建接口额外支持:`remark`(字符串备注)和最多5张凭证(jsonb 存储)。
|
||||
- 处理逻辑:按行读取 `order_no` → 查询 `Order` → 找到 `PackageUsage WHERE order_id = ? AND status NOT IN (3, 4)` → 批量更新 `status = 4`(已失效)。
|
||||
- 订单不存在:记为失败(fail),继续处理后续行。
|
||||
- 订单存在但旗下套餐全部已是终态(status 3 或 4):记为成功(success)。
|
||||
- 不触发退款,不直接操作 IoT 卡停机,由轮询系统根据套餐状态自行评估。
|
||||
- 任务表命名:`tb_order_package_invalidate_task`。
|
||||
|
||||
### 需求三:凭证多图改造
|
||||
|
||||
- `tb_order.payment_voucher_key`(varchar 500)→ `jsonb`,存储 `[]string`,最多5个元素。
|
||||
- `tb_refund.refund_voucher_key`(varchar 500)→ `jsonb`,存储 `[]string`,最多5个元素。
|
||||
- `tb_agent_recharge_record.payment_voucher_key`(varchar 500)→ `jsonb`,存储 `[]string`,最多5个元素。
|
||||
- **数据迁移**(同一 Migration 语句):非空旧值 `"some_key"` → `["some_key"]`;空字符串 → `[]`。生产环境由运营手动执行 Migration。
|
||||
- 所有涉及凭证的请求 DTO 字段由 `string` 改为 `[]string`,validate 标签限制 `max=5`,每项 `max=500`。
|
||||
- 所有涉及凭证的响应 DTO 字段改为 `[]string`。
|
||||
- 不限制文件类型(上传 URL 获取接口层面不变,仅存储 key 列表)。
|
||||
|
||||
### 需求四:代理预充值备注
|
||||
|
||||
- `tb_agent_recharge_record` 新增 `remark text` 字段(可为空)。
|
||||
- `CreateAgentRechargeRequest` 新增 `remark` 字段(`omitempty`,无最大长度强限制,建议 1000 字符内)。
|
||||
- `AgentRechargeResponse` 新增 `remark` 字段(列表和详情接口均返回)。
|
||||
- 创建后不可修改,无需新增修改接口。
|
||||
|
||||
---
|
||||
|
||||
## Testing Decisions
|
||||
|
||||
本项目禁止自动化测试。验证方式:
|
||||
|
||||
- **PostgreSQL MCP**:核查 `tb_iot_card.enable_polling` 在风险状态卡被轮询后是否正确置为 `false`;核查 `tb_package_usage.status` 在批量失效任务执行后是否按预期更新;核查 jsonb 字段迁移结果。
|
||||
- **curl / Postman**:对风险停机卡调用复机接口,验证返回正确错误码;上传多张凭证创建订单,验证存储和返回正确;创建代理预充值时附带备注,验证列表返回。
|
||||
|
||||
---
|
||||
|
||||
## Out of Scope
|
||||
|
||||
- 已绑定设备的卡(`is_standalone=false`)不受"风险停机/已销户"限制约束。
|
||||
- 批量失效订单套餐不触发退款流程。
|
||||
- 批量失效不直接调用网关停机,依赖轮询系统自行评估。
|
||||
- 历史导入任务的 `creator_name` 不回填。
|
||||
- 凭证文件类型校验(文件类型限制不在本次范围内)。
|
||||
- 代理预充值备注创建后不可修改(无修改接口)。
|
||||
|
||||
---
|
||||
|
||||
## Further Notes
|
||||
|
||||
- 凭证字段改为 jsonb 后,前端需同步更新上传组件支持多选;本 PRD 只覆盖后端改造。
|
||||
- 批量失效任务的凭证和备注字段,与代理预充值备注字段、以及需求三的多凭证设计保持一致的数据形态,便于后续统一处理。
|
||||
- "风险停机"和"已销户"的 `gateway_extend` 原文值来自上游网关,若上游修改返回值需同步更新常量。
|
||||
@@ -0,0 +1,28 @@
|
||||
Status: done
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/iot-risk-import-voucher-remark/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
新增两个 gateway_extend 常量(`GatewayCardExtendRiskStop = "风险停机"`、`GatewayCardExtendCancelled = "已销户"`),然后在后台复机入口(`ManualStartCard`)和 Open API 复机入口(`ResumeCard`)中增加前置拦截:
|
||||
|
||||
- 仅对 `is_standalone = true` 的独立卡生效
|
||||
- 读取 DB 中已落库的 `gateway_extend` 字段(不实时查网关)
|
||||
- 若命中 `"风险停机"` 或 `"已销户"`,立即返回业务错误,不再继续调用网关
|
||||
- 错误提示需明确说明被拒绝的原因(如"该卡已被运营商风险停机,不允许复机")
|
||||
|
||||
无 Schema 变更,不需要 Migration。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] `pkg/constants/iot.go` 新增 `GatewayCardExtendRiskStop`、`GatewayCardExtendCancelled` 两个常量
|
||||
- [ ] 后台 `ManualStartCard` 在调用网关前检查独立卡 `gateway_extend`,命中风险值时返回业务错误
|
||||
- [ ] Open API `ResumeCard` 在调用网关前检查独立卡 `gateway_extend`,命中风险值时返回业务错误
|
||||
- [ ] 已绑定设备的卡(`is_standalone = false`)不受此限制,复机正常进行
|
||||
- [ ] 返回的错误信息能区分"风险停机"和"已销户"两种情况
|
||||
|
||||
## Blocked by
|
||||
|
||||
None - can start immediately
|
||||
@@ -0,0 +1,28 @@
|
||||
Status: done
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/iot-risk-import-voucher-remark/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
在 `PollingCardStatusHandler` 中,当轮询查询网关后将新的 `gateway_extend` 写入 DB,若新值命中风险常量(`GatewayCardExtendRiskStop` 或 `GatewayCardExtendCancelled`)且该卡 `is_standalone = true`,则:
|
||||
|
||||
1. 调用现有的 `UpdatePollingStatus(false)` 将 `enable_polling` 写为 `false`
|
||||
2. 跳过末尾的 `requeueCard`,终止该卡的轮询循环
|
||||
|
||||
检查时机:在 `UpdateFields` 写入 `gateway_extend` 成功之后,`requeueCard` 调用之前。
|
||||
|
||||
不需要 Schema 变更,不需要 Migration。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] 轮询处理器在写入 `gateway_extend` 后检查风险状态
|
||||
- [ ] 独立卡(`is_standalone=true`)命中风险值时调用 `UpdatePollingStatus(false)`,`tb_iot_card.enable_polling` 被置为 `false`
|
||||
- [ ] 该卡不再入队(不调用 `requeueCard`),轮询循环终止
|
||||
- [ ] 已绑定设备的卡(`is_standalone=false`)命中风险值时不触发此逻辑,轮询正常继续
|
||||
- [ ] 可通过 PostgreSQL MCP 验证:风险状态卡处理后 `enable_polling = false`
|
||||
|
||||
## Blocked by
|
||||
|
||||
- `issues/01-risk-resume-block.md`(依赖 `GatewayCardExtendRiskStop`、`GatewayCardExtendCancelled` 常量)
|
||||
@@ -0,0 +1,30 @@
|
||||
Status: done
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/iot-risk-import-voucher-remark/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
在现有两个导入任务表中新增操作人快照字段,并在列表接口返回:
|
||||
|
||||
- `tb_iot_card_import_task` 新增 `creator_name varchar(100)` 字段
|
||||
- `tb_device_import_task` 新增 `creator_name varchar(100)` 字段
|
||||
- 创建任务时从当前登录用户快照姓名写入(不可为 null,旧数据留空字符串)
|
||||
- IoT 卡导入任务列表响应 DTO 新增 `creator_name` 字段
|
||||
- 设备导入任务列表响应 DTO 新增 `creator_name` 字段
|
||||
|
||||
历史数据不回填,旧记录 `creator_name` 为空字符串。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Migration 为两个现有任务表各添加 `creator_name varchar(100) not null default ''` 字段
|
||||
- [ ] IoT 卡导入任务创建时写入当前用户姓名快照
|
||||
- [ ] 设备导入任务创建时写入当前用户姓名快照
|
||||
- [ ] IoT 卡导入任务列表接口返回 `creator_name` 字段
|
||||
- [ ] 设备导入任务列表接口返回 `creator_name` 字段
|
||||
- [ ] 旧记录 `creator_name` 为空字符串,不报错
|
||||
|
||||
## Blocked by
|
||||
|
||||
None - can start immediately
|
||||
@@ -0,0 +1,43 @@
|
||||
Status: done
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/iot-risk-import-voucher-remark/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
新增"批量失效订单套餐"导入任务,完整实现 Model → Store → Service → Handler → Route → Worker 全链路,参照现有 IoT 卡导入任务的结构。
|
||||
|
||||
**数据模型**(新建 `tb_order_package_invalidate_task`):
|
||||
- 参照 `tb_iot_card_import_task` 的基础字段(任务编号、状态、计数、失败/跳过明细、文件存储 key、时间戳等)
|
||||
- 额外字段:`creator_name varchar(100)`、`remark text`、`voucher_keys jsonb`(存储 `[]string`,最多5个元素)
|
||||
|
||||
**上传接口**(创建任务):
|
||||
- 接受 CSV 文件(单列 `order_no`)
|
||||
- 额外参数:`remark`(字符串,可选)和 `voucher_keys`(`[]string`,最多5个,可选)
|
||||
- 写入 `creator_name` 快照
|
||||
|
||||
**Worker 处理逻辑**:
|
||||
- 逐行读取 `order_no`
|
||||
- 按 `order_no` 查询 `tb_order` 获取 `order_id`;找不到则记为失败,继续处理
|
||||
- 查询 `tb_package_usage WHERE order_id = ? AND status NOT IN (3, 4)`,批量更新 `status = 4`(已失效)
|
||||
- 订单存在但套餐全部已是终态(status 3 或 4):记为成功
|
||||
- 不触发退款,不调用网关,不直接停机
|
||||
|
||||
**列表/详情接口**:参照现有导入任务接口结构。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Migration 创建 `tb_order_package_invalidate_task` 表,包含所有必要字段
|
||||
- [ ] 创建任务接口:接受 CSV + remark + voucher_keys(最多5个),写入 creator_name 快照
|
||||
- [ ] Worker 按行处理:`order_no` 不存在 → 失败并记录原因,继续下一行
|
||||
- [ ] Worker 按行处理:订单存在且有可失效套餐 → 批量更新 `status=4` → 记为成功
|
||||
- [ ] Worker 按行处理:订单存在但套餐全为终态 → 记为成功
|
||||
- [ ] 处理过程中不调用退款接口、不操作网关
|
||||
- [ ] 列表接口返回 `creator_name`、`remark`、任务状态和计数
|
||||
- [ ] 详情接口返回失败明细(含 order_no 和原因)
|
||||
- [ ] 已在路由和文档生成器中注册
|
||||
|
||||
## Blocked by
|
||||
|
||||
None - can start immediately
|
||||
@@ -0,0 +1,36 @@
|
||||
Status: done
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/iot-risk-import-voucher-remark/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
一次 Migration 完成三个现有表的凭证字段类型变更,并同时新增代理预充值备注字段:
|
||||
|
||||
**字段类型变更**(varchar(500) → jsonb):
|
||||
- `tb_order.payment_voucher_key`
|
||||
- `tb_refund.refund_voucher_key`
|
||||
- `tb_agent_recharge_record.payment_voucher_key`
|
||||
|
||||
**数据转换规则**(在同一 Migration 的 USING 子句中完成):
|
||||
- 非空旧值 `"some_key"` → `["some_key"]`
|
||||
- 空字符串 `""` → `[]`
|
||||
- NULL → `[]`
|
||||
|
||||
**新增字段**:
|
||||
- `tb_agent_recharge_record.remark text`(可为空,默认 null)
|
||||
|
||||
生产环境由运营手动执行,Migration 文件需包含完整的 UP 和 DOWN 语句。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] Migration UP:三个凭证字段成功从 varchar 转为 jsonb,历史单值数据转换为单元素数组
|
||||
- [ ] Migration UP:空字符串/NULL 转换为空数组 `[]`
|
||||
- [ ] Migration UP:`tb_agent_recharge_record` 新增 `remark text` 字段
|
||||
- [ ] Migration DOWN:可回滚(jsonb → varchar,取数组第一个元素;remark 字段删除)
|
||||
- [ ] 迁移后可用 PostgreSQL MCP 验证数据格式正确
|
||||
|
||||
## Blocked by
|
||||
|
||||
None - can start immediately
|
||||
@@ -0,0 +1,27 @@
|
||||
Status: done
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/iot-risk-import-voucher-remark/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
将后台创建订单接口的凭证字段从单个字符串改为最多5个字符串的列表:
|
||||
|
||||
- `CreateAdminOrderRequest.payment_voucher_key`:`string` → `[]string`,validate 限制 `max=5`,每项 `max=500`
|
||||
- `OrderResponse.payment_voucher_key`(以及所有订单相关响应 DTO):`string` → `[]string`
|
||||
- Order Model 的 `PaymentVoucherKey` 字段类型改为适配 jsonb 的 `pq.StringArray` 或自定义 JSON 类型
|
||||
- Order Service 中读写 `payment_voucher_key` 的逻辑同步更新
|
||||
|
||||
Schema 变更已由 `issues/05-voucher-multi-migration.md` 完成。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] 后台创建订单接口接受 `payment_voucher_key []string`(0-5个元素),offline 支付时至少需要1个
|
||||
- [ ] 订单列表/详情接口返回 `payment_voucher_key []string`
|
||||
- [ ] 传入超过5个凭证 key 时返回参数校验错误
|
||||
- [ ] 现有无凭证的订单(wallet 支付)返回空数组,不报错
|
||||
|
||||
## Blocked by
|
||||
|
||||
- `issues/05-voucher-multi-migration.md`
|
||||
@@ -0,0 +1,29 @@
|
||||
Status: done
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/iot-risk-import-voucher-remark/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
将退款申请接口的凭证字段从单个字符串改为最多5个字符串的列表:
|
||||
|
||||
- `CreateRefundRequest.refund_voucher_key`:`string` → `[]string`,validate 限制 `max=5`,每项 `max=500`,required(至少1个)
|
||||
- `ResubmitRefundRequest.refund_voucher_key`:`*string` → `*[]string`(重新提交时可选替换)
|
||||
- `RefundResponse.refund_voucher_key`(以及所有退款相关响应 DTO):`string` → `[]string`
|
||||
- Refund Model 的 `RefundVoucherKey` 字段类型改为适配 jsonb 的类型
|
||||
- Refund Service 中读写 `refund_voucher_key` 的逻辑同步更新
|
||||
|
||||
Schema 变更已由 `issues/05-voucher-multi-migration.md` 完成。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] 创建退款申请接口接受 `refund_voucher_key []string`(1-5个元素,必填)
|
||||
- [ ] 重新提交退款接口的 `refund_voucher_key` 可选,传入时替换全部凭证
|
||||
- [ ] 退款列表/详情接口返回 `refund_voucher_key []string`
|
||||
- [ ] 传入超过5个凭证 key 时返回参数校验错误
|
||||
- [ ] 传入空数组时返回参数校验错误(至少需要1个)
|
||||
|
||||
## Blocked by
|
||||
|
||||
- `issues/05-voucher-multi-migration.md`
|
||||
@@ -0,0 +1,35 @@
|
||||
Status: done
|
||||
|
||||
## Parent
|
||||
|
||||
`.scratch/iot-risk-import-voucher-remark/PRD.md`
|
||||
|
||||
## What to build
|
||||
|
||||
同时完成代理预充值的凭证多图改造和备注字段接入:
|
||||
|
||||
**凭证多图**:
|
||||
- `CreateAgentRechargeRequest.payment_voucher_key`:`string` → `[]string`,validate 限制 `max=5`,每项 `max=500`(offline 支付时至少1个,微信支付时忽略)
|
||||
- `AgentRechargeResponse.payment_voucher_key`:`string` → `[]string`
|
||||
- `AgentRechargeRecord.PaymentVoucherKey` Model 字段类型改为适配 jsonb 的类型
|
||||
|
||||
**备注字段**:
|
||||
- `CreateAgentRechargeRequest` 新增 `remark string`(可选,建议不超过1000字符)
|
||||
- `AgentRechargeResponse` 新增 `remark string`(列表和详情接口均返回)
|
||||
- `AgentRechargeRecord.Remark` 在创建时写入,后续不可修改
|
||||
|
||||
AgentRecharge Service 中读写相关字段的逻辑同步更新。
|
||||
|
||||
Schema 变更(payment_voucher_key 类型 + remark 字段)已由 `issues/05-voucher-multi-migration.md` 完成。
|
||||
|
||||
## Acceptance criteria
|
||||
|
||||
- [ ] 创建代理预充值接口接受 `payment_voucher_key []string`(offline 时至少1个)和可选 `remark`
|
||||
- [ ] 代理预充值列表/详情接口返回 `payment_voucher_key []string` 和 `remark`
|
||||
- [ ] 传入超过5个凭证 key 时返回参数校验错误
|
||||
- [ ] 不新增 remark 修改接口,创建后备注字段只读
|
||||
- [ ] 微信支付创建预充值时可不传凭证(与现有逻辑一致)
|
||||
|
||||
## Blocked by
|
||||
|
||||
- `issues/05-voucher-multi-migration.md`
|
||||
34
AGENTS.md
34
AGENTS.md
@@ -72,18 +72,23 @@ handlers := &bootstrap.Handlers{
|
||||
- 使用 `net/http` 替代 Fiber
|
||||
- 使用 `encoding/json` 替代 sonic(除非必要)
|
||||
|
||||
## 架构分层
|
||||
## 架构演进:MVC 与 DDD 共存
|
||||
|
||||
必须遵循以下分层架构:
|
||||
项目采用**触碰式渐进迁移**,不安排一次性全仓库重构。详细规则见 `docs/7月迭代/独立方案/基础规范/DDD规范.md`。
|
||||
|
||||
```
|
||||
Handler → Service → Store → Model
|
||||
```
|
||||
### 三条执行通道
|
||||
|
||||
- **Handler**: 只处理 HTTP 请求/响应,不包含业务逻辑
|
||||
- **Service**: 包含所有业务逻辑,支持跨模块调用
|
||||
- **Store**: 统一管理所有数据访问,支持事务处理
|
||||
- **Model**: 定义数据结构和 DTO
|
||||
- **复杂写操作**:`Handler → Application UseCase → Domain → Repository/Infrastructure`。适用于状态机、金额、库存、并发不变量、策略和可靠事件。
|
||||
- **简单写操作**:`Handler → Application 事务脚本 → Persistence`。单表 CRUD 不强行创建聚合。
|
||||
- **读取操作**:`Handler → Query → GORM/DTO`。列表、详情、联表、统计、报表和导出不经过聚合根。
|
||||
|
||||
### 触碰式迁移约束
|
||||
|
||||
1. 禁止主动迁移当前需求未触碰的旧模块;简单字段、筛选和局部修复默认沿用旧结构。
|
||||
2. 迁移单位是**完整用例**,不是整个模块、文件或数据表;一旦迁移,相关业务不变量必须完整收口,禁止一半留在旧 Service、一半放在 Domain。
|
||||
3. 当前需求触碰复杂写逻辑时,只迁移完成该需求所需的最小完整业务边界;旧 Service 可暂时作为内部代码迁移门面调用新 UseCase,但这不等于必须保留旧 HTTP 接口。
|
||||
4. 当前需求触碰复杂只读逻辑时,只迁移该查询到 `internal/query/<context>`;Query 可直接使用 GORM 做联表、聚合、权限过滤和 DTO 投影。
|
||||
5. 不为追求 DDD 形式创建无业务价值的接口、工厂和目录;架构选择不明确时先阅读 DDD 规范并写明判断依据。
|
||||
|
||||
## 核心原则
|
||||
|
||||
@@ -97,7 +102,7 @@ Handler → Service → Store → Model
|
||||
|
||||
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()`、`err.Error()`)
|
||||
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
|
||||
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)` 或 `errors.Wrap(...)`
|
||||
- Service/Application/Domain/Query 层禁止向接口调用方直接返回 `fmt.Errorf(...)`,必须转换为 `errors.New(...)` 或 `errors.Wrap(...)`
|
||||
- 约定用法:`errors.New(code[, msg])`、`errors.Wrap(code, err[, msg])`
|
||||
|
||||
### 响应格式
|
||||
@@ -194,6 +199,7 @@ StatusName string `json:"status_name" description:"状态名称(中文)"` //
|
||||
- 文档文件名和内容使用中文
|
||||
- 同步更新 README.md
|
||||
- 为导出的函数、类型编写文档注释
|
||||
- 需要进入评审的标准/完整技术方案必须覆盖关键流程、前后端契约、异常闭环、发布回滚和待决策项;简单改动不强行画图。详细要求见 `docs/技术方案评审规范.md`
|
||||
|
||||
## 函数复杂度
|
||||
|
||||
@@ -225,14 +231,16 @@ StatusName string `json:"status_name" description:"状态名称(中文)"` //
|
||||
|
||||
### 错误处理
|
||||
|
||||
- [ ] Service 层无 `fmt.Errorf` 对外返回
|
||||
- [ ] Service/Application/Domain/Query 层无 `fmt.Errorf` 对外返回
|
||||
- [ ] Handler 层参数校验不泄露细节
|
||||
- [ ] 错误码使用正确(4xx vs 5xx)
|
||||
- [ ] 错误日志完整(包含上下文)
|
||||
|
||||
### 代码质量
|
||||
|
||||
- [ ] 遵循 Handler → Service → Store → Model 分层
|
||||
- [ ] 已按判断标准选择复杂写、简单写或 Query 通道,未扩大迁移范围
|
||||
- [ ] 复杂写规则收口 Domain;简单写使用 Application 事务脚本;只读逻辑不经过聚合根
|
||||
- [ ] Query 负责读取权限、分页和 DTO 投影,写操作仍在 Domain 重新校验
|
||||
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
|
||||
- [ ] 常量定义在 `pkg/constants/`
|
||||
- [ ] 使用 Go 惯用法(非 Java 风格)
|
||||
@@ -309,7 +317,7 @@ queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, payloadBytes)
|
||||
|
||||
**适用场景**:任何敏感操作(账号管理、权限变更、数据删除等)
|
||||
|
||||
- Service 层注入 `auditService AuditServiceInterface`,操作成功后调用 `LogOperation()`
|
||||
- 旧模块在 Service 层、新 DDD 模块在 Application UseCase 中注入审计能力,操作成功后调用 `LogOperation()`
|
||||
- 必填字段:`OperatorID`、`OperationType`、`OperationDesc`、`BeforeData`、`AfterData`
|
||||
- 异步写入(Goroutine),写入失败不影响业务,失败时记录 Error 日志
|
||||
|
||||
|
||||
341
CLAUDE.md
341
CLAUDE.md
@@ -1,340 +1,19 @@
|
||||
# Claude 项目规则
|
||||
|
||||
---
|
||||
@AGENTS.md
|
||||
|
||||
# junhong_cmp_fiber 项目开发规范
|
||||
通用项目规范、触碰式 DDD 迁移规则和审批流约束统一维护在 `AGENTS.md`,本文件只保留 Claude 专用补充,禁止复制两份规则正文。
|
||||
|
||||
**重要**: 本文件包含核心规范。详细规范已提取为 Skills,在特定任务时按需加载。
|
||||
## Agent skills
|
||||
|
||||
## 专项规范 Skills(按需加载)
|
||||
### Issue tracker
|
||||
|
||||
以下规范在相关任务时**自动触发**,无需手动加载:
|
||||
Issue 和 PRD 以本地 Markdown 文件形式存放在 `.scratch/<feature-slug>/` 下。详见 `docs/agents/issue-tracker.md`。
|
||||
|
||||
| 任务类型 | 触发 Skill | 说明 |
|
||||
|---------|-----------|------|
|
||||
| 创建/修改 DTO 文件 | `dto-standards` | description 标签、枚举字段、验证标签规范 |
|
||||
| 创建/修改 Model 模型 | `model-standards` | GORM 模型结构、字段标签、TableName 规范 |
|
||||
| 注册 API 路由 / **新增 Handler** | `api-routing` | Register() 函数、RouteSpec、**文档生成器更新** |
|
||||
| 测试接口/验证数据 | `db-validation` | PostgreSQL MCP 使用方法和验证示例 |
|
||||
| 数据库迁移 | `db-migration` | 迁移命令、文件规范、执行流程、失败处理 |
|
||||
| 维护规范文档 | `doc-management` | 规范文档流程和维护规则 |
|
||||
| 编写 Go 代码注释/文档注释 | `comment-standards` | 包/结构体/接口/函数/内联注释完整规范与示例 |
|
||||
| 创建涉及接口的 OpenSpec 提案 | `openspec-api-contract` | 探索阶段引导清单、提案必填章节与完成标准 |
|
||||
### Triage labels
|
||||
|
||||
---
|
||||
使用默认的英文 Triage 标签词汇(`needs-triage` / `needs-info` / `ready-for-agent` / `ready-for-human` / `wontfix`)。详见 `docs/agents/triage-labels.md`。
|
||||
|
||||
### ⚠️ 新增 Handler 时必须同步更新文档生成器
|
||||
### Domain docs
|
||||
|
||||
新增 Handler 后,接口不会自动出现在 OpenAPI 文档中。**必须手动更新以下两个文件**:
|
||||
|
||||
```go
|
||||
// cmd/api/docs.go 和 cmd/gendocs/main.go
|
||||
handlers := &bootstrap.Handlers{
|
||||
// ... 添加新 Handler
|
||||
NewHandler: admin.NewXxxHandler(nil),
|
||||
}
|
||||
```
|
||||
|
||||
**完整检查清单**: 参见 [`docs/api-documentation-guide.md`](docs/api-documentation-guide.md#新增-handler-检查清单)
|
||||
|
||||
---
|
||||
|
||||
## 语言要求
|
||||
|
||||
**必须遵守:**
|
||||
|
||||
- 永远用中文交互
|
||||
- 注释必须使用中文
|
||||
- 文档必须使用中文
|
||||
- 日志消息必须使用中文
|
||||
- 用户可见的错误消息必须使用中文
|
||||
- 变量名、函数名、类型名必须使用英文(遵循 Go 命名规范)
|
||||
- GIT提交的commit必须使用中文
|
||||
|
||||
## 技术栈
|
||||
|
||||
**必须严格遵守,禁止替代方案:**
|
||||
|
||||
| 类型 | 技术 |
|
||||
|------|------|
|
||||
| HTTP 框架 | Fiber v2.x |
|
||||
| ORM | GORM v1.25.x |
|
||||
| 配置管理 | Viper |
|
||||
| 日志 | Zap + Lumberjack.v2 |
|
||||
| JSON 序列化 | sonic(优先),encoding/json(必要时) |
|
||||
| 验证 | Validator |
|
||||
| 任务队列 | Asynq v0.24.x |
|
||||
| 数据库 | PostgreSQL 14+ |
|
||||
| 缓存 | Redis 6.0+ |
|
||||
|
||||
**禁止:**
|
||||
|
||||
- 直接使用 `database/sql`(必须通过 GORM)
|
||||
- 使用 `net/http` 替代 Fiber
|
||||
- 使用 `encoding/json` 替代 sonic(除非必要)
|
||||
|
||||
## 架构分层
|
||||
|
||||
必须遵循以下分层架构:
|
||||
|
||||
```
|
||||
Handler → Service → Store → Model
|
||||
```
|
||||
|
||||
- **Handler**: 只处理 HTTP 请求/响应,不包含业务逻辑
|
||||
- **Service**: 包含所有业务逻辑,支持跨模块调用
|
||||
- **Store**: 统一管理所有数据访问,支持事务处理
|
||||
- **Model**: 定义数据结构和 DTO
|
||||
|
||||
## 核心原则
|
||||
|
||||
### 错误处理
|
||||
|
||||
- 所有错误必须在 `pkg/errors/` 中定义
|
||||
- 使用统一错误码系统
|
||||
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
|
||||
|
||||
#### 错误报错规范(必须遵守)
|
||||
|
||||
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()`、`err.Error()`)
|
||||
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
|
||||
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)` 或 `errors.Wrap(...)`
|
||||
- 约定用法:`errors.New(code[, msg])`、`errors.Wrap(code, err[, msg])`
|
||||
|
||||
### 响应格式
|
||||
|
||||
- 所有 API 响应使用 `pkg/response/` 的统一格式
|
||||
- 格式: `{code, msg, data, timestamp}`
|
||||
|
||||
### 常量管理
|
||||
|
||||
- 所有常量定义在 `pkg/constants/`
|
||||
- Redis key 使用函数生成: `Redis{Module}{Purpose}Key(params...)`
|
||||
- 禁止硬编码字符串和 magic numbers
|
||||
- **必须为所有常量添加中文注释**
|
||||
|
||||
### 枚举与状态字段(必须遵守)
|
||||
|
||||
**两个强制规则**:
|
||||
|
||||
1. **int vs string**:状态类(生命周期)用 `int`,类型/方式类用 `string`
|
||||
2. **DTO description 必须从 constants 原文抄写**,禁止凭记忆填写枚举值(历史上已有 description 与 constants 不一致导致前端显示错误的案例)
|
||||
|
||||
```go
|
||||
// ✅ description 从 constants 原文抄,格式统一用冒号+逗号
|
||||
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"` // Response DTO 必须加
|
||||
|
||||
// ❌ 禁止:启用/禁用用 1=启用 2=禁用(全局约定是 0=禁用, 1=启用)
|
||||
// ❌ 禁止:description 枚举值与 constants 不一致
|
||||
```
|
||||
|
||||
**完整规范**: 参见 [`docs/enum-status-standards.md`](docs/enum-status-standards.md)
|
||||
|
||||
### 注释规范
|
||||
|
||||
- **所有注释使用中文**,导出符号必须有文档注释(包、函数、类型、接口、常量)
|
||||
- 复杂逻辑解释"为什么"而非"做了什么",禁止废话注释(复述代码本身)
|
||||
- Handler 方法注释必须包含 HTTP 方法和路径(`// Create 创建账号` + `// POST /api/admin/accounts`)
|
||||
- 未导出函数:< 15 行可省略,≥ 15 行或非显而易见算法必须注释
|
||||
- 修改代码时必须同步更新注释,过时注释比没有注释更有害
|
||||
|
||||
**详细规范与示例**:见 `comment-standards` skill
|
||||
|
||||
### Go 代码风格
|
||||
|
||||
- 使用 `gofmt` 格式化
|
||||
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
|
||||
- 包名: 简短、小写、单数、无下划线
|
||||
- 接口命名: 使用 `-er` 后缀(Reader、Writer、Logger)
|
||||
|
||||
## 数据库设计
|
||||
|
||||
**核心规则:**
|
||||
|
||||
- ❌ 禁止建立外键约束
|
||||
- ❌ 禁止使用 GORM 关联关系标签(foreignKey、hasMany、belongsTo)
|
||||
- ✅ 关联通过存储 ID 字段手动维护
|
||||
- ✅ 关联数据在代码层面显式查询
|
||||
|
||||
## Go 惯用法 vs Java 风格
|
||||
|
||||
### ✅ Go 风格(推荐)
|
||||
|
||||
- 扁平化包结构(最多 2-3 层)
|
||||
- 小而专注的接口(1-3 个方法)
|
||||
- 直接访问导出字段(不用 getter/setter)
|
||||
- 组合优于继承
|
||||
- 显式错误返回和检查
|
||||
|
||||
### ❌ Java 风格(禁止)
|
||||
|
||||
- 过度抽象(不必要的接口、工厂)
|
||||
- Getter/Setter 方法
|
||||
- 深层继承层次
|
||||
- 异常处理(panic/recover)
|
||||
- 类型前缀(IService、AbstractBase、ServiceImpl)
|
||||
|
||||
## ⚠️ 测试禁令(强制执行)
|
||||
|
||||
**本项目禁止任何形式的自动化测试**(单元/集成/E2E/`*_test.go` 文件),规划和文档中也不讨论测试。
|
||||
**唯一例外**:用户明确说"请写测试"时。
|
||||
**替代验证**:PostgreSQL MCP 手动验证数据、Postman/curl 手动测试 API。
|
||||
|
||||
## 性能要求
|
||||
|
||||
- API P95 响应时间 < 200ms
|
||||
- API P99 响应时间 < 500ms
|
||||
- 数据库查询 < 50ms
|
||||
- 列表查询必须分页(默认 20,最大 100)
|
||||
- 避免 N+1 查询,使用批量操作
|
||||
|
||||
## 文档要求
|
||||
|
||||
- 每个功能在 `docs/{feature-id}/` 创建总结文档
|
||||
- 文档文件名和内容使用中文
|
||||
- 同步更新 README.md
|
||||
- 为导出的函数、类型编写文档注释
|
||||
|
||||
## 函数复杂度
|
||||
|
||||
- 函数长度 ≤ 100 行(核心逻辑建议 ≤ 50 行)
|
||||
- `main()` 函数只做编排,不含具体实现
|
||||
- 遵循单一职责原则
|
||||
|
||||
## 访问日志
|
||||
|
||||
- 所有 HTTP 请求记录到 `access.log`
|
||||
- 记录完整的请求/响应(限制 50KB)
|
||||
- 包含: method, path, query, status, duration, request_id, ip, user_agent, user_id, bodies
|
||||
- 使用 JSON 格式,配置自动轮转
|
||||
|
||||
## OpenSpec 工作流
|
||||
|
||||
创建提案前的检查清单:
|
||||
|
||||
1. ✅ 技术栈合规
|
||||
2. ✅ 架构分层正确
|
||||
3. ✅ 使用统一错误处理
|
||||
4. ✅ 常量定义在 pkg/constants/
|
||||
5. ✅ Go 惯用法(非 Java 风格)
|
||||
6. ✅ 性能考虑
|
||||
7. ✅ 文档更新计划
|
||||
8. ✅ 中文优先
|
||||
|
||||
## Code Review 检查清单
|
||||
|
||||
### 错误处理
|
||||
|
||||
- [ ] Service 层无 `fmt.Errorf` 对外返回
|
||||
- [ ] Handler 层参数校验不泄露细节
|
||||
- [ ] 错误码使用正确(4xx vs 5xx)
|
||||
- [ ] 错误日志完整(包含上下文)
|
||||
|
||||
### 代码质量
|
||||
|
||||
- [ ] 遵循 Handler → Service → Store → Model 分层
|
||||
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
|
||||
- [ ] 常量定义在 `pkg/constants/`
|
||||
- [ ] 使用 Go 惯用法(非 Java 风格)
|
||||
|
||||
### 枚举与状态
|
||||
|
||||
- [ ] 状态类字段用 `int`,类型/方式类字段用 `string`
|
||||
- [ ] 禁用/启用使用 `0=禁用, 1=启用`(禁止 `1=启用, 2=禁用`)
|
||||
- [ ] DTO description 枚举列表已从 `pkg/constants/` 原文抄写,无遗漏、无错误
|
||||
- [ ] Response DTO 的 int 状态字段有对应的 `_name` 文字字段
|
||||
|
||||
### 文档和注释
|
||||
|
||||
- [ ] 所有注释使用中文
|
||||
- [ ] 导出函数/类型有文档注释
|
||||
- [ ] API 路径注释与真实路由一致
|
||||
|
||||
### 幂等性
|
||||
|
||||
- [ ] 创建类写操作有 Redis 业务键防重
|
||||
- [ ] 状态变更使用条件更新(`WHERE status = expected`)
|
||||
- [ ] 余额/库存变更使用乐观锁(version 字段)
|
||||
- [ ] 分布式锁使用 `defer` 确保释放
|
||||
- [ ] Redis Key 定义在 `pkg/constants/redis.go`
|
||||
|
||||
### 越权防护规范
|
||||
|
||||
**三层防护机制**(基础设施已就绪,无需自建):
|
||||
1. **路由层中间件**:粗粒度拦截(如企业账号禁止访问账号管理)
|
||||
2. **Service 层业务检查**:`middleware.CanManageShop()` / `middleware.CanManageEnterprise()` 细粒度验证;示例见 `internal/service/account/service.go`
|
||||
3. **GORM Callback 自动过滤**:代理按店铺层级、企业按企业ID,已自动应用无需手动调用
|
||||
|
||||
统一错误返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`(不区分"不存在"与"无权限",防止信息泄露)
|
||||
|
||||
### 幂等性规范
|
||||
|
||||
写操作按场景选择策略:
|
||||
- **状态流转操作** → 状态条件更新(首选)
|
||||
- **创建类操作(无状态可依赖)** → Redis 业务键防重 + 分布式锁(三层检测)
|
||||
- **余额/库存数值更新** → 乐观锁(`version` 字段)
|
||||
|
||||
```go
|
||||
// 策略1示例(首选):通过 WHERE 条件确保幂等,RowsAffected=0 说明已被处理
|
||||
result := tx.Model(&model.Order{}).
|
||||
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending).
|
||||
Updates(map[string]any{"payment_status": model.PaymentStatusPaid})
|
||||
if result.RowsAffected == 0 { /* 已处理,检查当前状态 */ }
|
||||
```
|
||||
|
||||
- Redis Key 定义在 `pkg/constants/redis.go`,分布式锁必须用 `defer` 释放
|
||||
- 参考实现:`internal/service/order/service.go`
|
||||
|
||||
### 异步任务载荷规范(Asynq)
|
||||
|
||||
**必须遵守:**
|
||||
|
||||
- ✅ 调用 `queueClient.EnqueueTask()` 时,`payload` 必须传入 **struct 或 map**
|
||||
- ❌ **禁止传入 `[]byte`**:`EnqueueTask` 内部统一调用 `sonic.Marshal`,若传入 `[]byte` 会被 base64 编码成字符串,Handler 反序列化时类型不匹配直接崩溃
|
||||
|
||||
```go
|
||||
// ✅ 正确:传 struct
|
||||
queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, MyPayload{OrderID: id})
|
||||
|
||||
// ❌ 错误:预先序列化后传 []byte(二次 Marshal → base64 编码)
|
||||
payloadBytes, _ := sonic.Marshal(MyPayload{OrderID: id})
|
||||
queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, payloadBytes)
|
||||
```
|
||||
|
||||
直接用 `asynq.NewTask` 入队时(绕过 `EnqueueTask`)才需要自己 Marshal。
|
||||
|
||||
---
|
||||
|
||||
### 审计日志规范
|
||||
|
||||
**适用场景**:任何敏感操作(账号管理、权限变更、数据删除等)
|
||||
|
||||
- Service 层注入 `auditService AuditServiceInterface`,操作成功后调用 `LogOperation()`
|
||||
- 必填字段:`OperatorID`、`OperationType`、`OperationDesc`、`BeforeData`、`AfterData`
|
||||
- 异步写入(Goroutine),写入失败不影响业务,失败时记录 Error 日志
|
||||
|
||||
**示例参考**:`internal/service/account/service.go`
|
||||
|
||||
---
|
||||
|
||||
### ⚠️ 任务执行规范(必须遵守)
|
||||
|
||||
**提案中的 tasks.md 是契约,不可擅自变更:**
|
||||
|
||||
| 规则 | 说明 |
|
||||
|------|------|
|
||||
| ❌ 禁止跳过任务 | 每个任务都是经过规划的,不能因为"简单"或"显而易见"而跳过 |
|
||||
| ❌ 禁止简化任务 | 不能将多个任务合并或简化执行,除非获得明确许可 |
|
||||
| ❌ 禁止自作主张优化 | 发现可以优化的地方,必须先询问是否可以调整 |
|
||||
| ✅ 必须逐项完成 | 按照 tasks.md 中的顺序逐一执行并标记完成 |
|
||||
| ✅ 必须询问后变更 | 如需调整任务(简化/跳过/合并/优化),先询问用户确认 |
|
||||
|
||||
**询问示例**:
|
||||
> "我注意到任务 2.1 和 2.2 可以合并为一步完成,是否可以这样优化?"
|
||||
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
|
||||
|
||||
---
|
||||
|
||||
|
||||
|
||||
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
|
||||
单 Context 布局:根目录的 `CONTEXT.md` + `docs/adr/`(按需懒创建),与现有 `openspec/` 提案工作流并存。详见 `docs/agents/domain.md`。
|
||||
|
||||
19
CONTEXT.md
Normal file
19
CONTEXT.md
Normal file
@@ -0,0 +1,19 @@
|
||||
# 领域术语表
|
||||
|
||||
## 资产(Asset)
|
||||
|
||||
**IoT 卡 / IotCard**:物联网流量卡,以 ICCID 为唯一标识。分为独立卡(`is_standalone=true`,未绑定设备)和设备卡(绑定在设备上)。
|
||||
|
||||
**设备 / Device**:硬件设备,以虚拟号(VirtualNo)为业务标识,可插卡使用。
|
||||
|
||||
**资产世代 / Generation**:`generation` 字段记录卡/设备经历"换货+旧资产转新"操作的次数。初始值为 1;每完成一次换货且旧资产执行"转新"后加 1。`generation > 1` 说明该资产曾作为旧资产经历过换货并被重新投入使用。
|
||||
|
||||
**资产业务状态 / AssetStatus**:`asset_status` 字段表示资产在 CMP 内部的业务生命周期(1=在库, 2=已销售, 3=已换货, 4=已停用)。换货完成时旧资产置为 3;执行"旧资产转新"后重置为 1,同时 generation+1。
|
||||
|
||||
## 企业授权(Enterprise Authorization)
|
||||
|
||||
**卡企业授权**:将独立卡授权给企业使用的操作。**业务约束:一张卡在同一时间只能授权给一个企业**(与设备授权保持一致,数据库通过部分唯一索引强制约束)。
|
||||
|
||||
**有效授权**:`revoked_at IS NULL AND deleted_at IS NULL` 的授权记录。撤回授权后(`revoked_at` 有值)视为历史记录,不计入当前授权关系。
|
||||
|
||||
**设备企业授权**:将设备(含其绑定卡)授权给企业使用的操作。同一台设备同一时间只能授权给一个企业(`uq_active_device_auth` 唯一约束)。
|
||||
@@ -230,6 +230,7 @@ default:
|
||||
- **代理商体系**:层级管理和分佣结算,支持差价佣金和一次性佣金两种佣金类型,详见 [套餐与佣金业务模型](docs/commission-package-model.md)
|
||||
- **代理开放接口**:新增 `/api/open/v1` 签名接口,代理店铺第三方系统可调用卡流量、卡状态、实名状态、套餐列表、预充值钱包余额/流水和钱包套餐购买能力。详见 [对接说明](docs/agent-open-api/功能总结.md) 与 [误发差价佣金修复说明](docs/agent-open-api/开放接口误发差价佣金修复说明.md)
|
||||
- **批量同步**:卡状态、实名状态、流量使用情况
|
||||
- **批量购买套餐脚本**:支持从单列 CSV 读取 ICCID/虚拟号,逐资产调用后台订单接口购买统一套餐,提供预演、重复拦截和逐条结果落盘能力。详见 [使用说明](scripts/batch_package_purchase/README.md) 与 [功能总结](docs/批量购买套餐脚本/功能总结.md)
|
||||
- **轮询系统**:IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md)
|
||||
- **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/)
|
||||
- **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 [功能总结](docs/package-price-fallback-and-platform-gift-policy/功能总结.md) 与 [最终验收清单](docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md)
|
||||
|
||||
@@ -346,6 +346,19 @@ func startPollingScheduler(
|
||||
appLogger,
|
||||
)
|
||||
|
||||
// 注入套餐失效前流量同步器:最小化 iot_card.Service(只需 gateway + store + redis + 流量扣减)
|
||||
trafficSyncer := iot_card_svc.New(
|
||||
runtime.db,
|
||||
runtime.pollingIotCardStore,
|
||||
nil, nil, nil, nil, nil,
|
||||
runtime.gatewayClient,
|
||||
appLogger,
|
||||
nil,
|
||||
)
|
||||
trafficSyncer.SetRedisClient(runtime.redisClient)
|
||||
trafficSyncer.SetDataDeductor(runtime.workerResult.Services.UsageService)
|
||||
activationHandler.SetTrafficSyncer(trafficSyncer)
|
||||
|
||||
pollingScheduler := polling.NewScheduler(
|
||||
runtime.redisClient,
|
||||
runtime.asynqClient,
|
||||
|
||||
57
docs/7月迭代/7月迭代前后端任务工时表.md
Normal file
57
docs/7月迭代/7月迭代前后端任务工时表.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# 7月迭代前后端任务工时表
|
||||
|
||||
> 估算口径:实现工作全权交由 AI 编码代理执行,前后端可并行推进。
|
||||
> 工时单位:人时,1 人日按 8 小时计算。
|
||||
> 包含:代码实现、迁移、接口调整、页面交互、联调修复、手工接口验证和发布准备。
|
||||
> 不包含:等待企微/支付配置、产品临时改需求、外部接口申请审核和生产历史脏数据人工处理时间。
|
||||
> 前提:提供后端仓库、前端仓库、可用数据库、Gateway、企微和支付联调配置;需求范围以标准评审稿为准。
|
||||
>
|
||||
> 禅道逐条录入时,使用[7月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)中的拆分工时;本表用于核对总量,逐条录入稿用于填写每条研发需求的预计工时。
|
||||
|
||||
|需求|前端任务|后端任务|前端任务工时|后端任务工时|相关风险可能导致的工时延长|
|
||||
|---|---|---|---:|---:|---|
|
||||
|公共基础|补齐公共状态、错误展示和异步任务交互组件|增量迁移、Outbox、DDD 目录和公共幂等能力|1~2小时|4~5小时|现有迁移冲突、Outbox 基础与文档不一致会增加 2~4 小时|
|
||||
|需求01:复机实名规则|调整复机失败提示,不再按行业卡写死文案|按 `realname_link_type` 判断实名要求,删除行业卡统一放行逻辑|0.5~1小时|1小时|运营商历史配置错误会增加数据修复时间|
|
||||
|需求02:H5 流程配置|按有效实名策略渲染先实名/先购买流程,补批量配置交互|统一运营商实名能力和资产顺序策略,补批量更新接口|2~3小时|3~4小时|前端现有 H5 状态机分散、冲突数据较多会增加 2~4 小时|
|
||||
|需求03:联系电话搜索|店铺列表增加联系电话搜索框|店铺 Query 增加 11 位联系电话精确过滤|0.5~1小时|0.5~1小时|现有列表参数命名不统一会增加约 1 小时|
|
||||
|需求04:退款中禁止换货|直接展示后端拦截原因|创建换货前批量校验活跃退款状态|0.5~1小时|1~1.5小时|历史退款状态语义不一致会增加 1~2 小时|
|
||||
|需求05:套餐分配生效条件|增加默认/购买生效/实名生效三选一及恢复默认|增加代理覆盖值和使用记录快照,统一生效条件计算|1.5~2.5小时|2.5~3.5小时|旧使用记录缺快照、套餐周期数据异常会增加 2~4 小时|
|
||||
|需求06/11:预计最终到期时间|资产层只展示预计最终到期时间、推算状态和统一高亮|Query 按当前及排队主套餐时长快照推算,供详情、临期和导出复用|1.5~2.5小时|3~4小时|旧套餐缺购买时长快照、等待实名激活无法确定起点会增加兼容处理时间|
|
||||
|需求07:实名筛选|卡和设备列表增加实名状态筛选|卡按自身状态,设备按有效绑定卡维护/查询实名状态|1~2小时|2~3小时|设备多当前卡、绑定历史异常会增加 2~3 小时|
|
||||
|需求08:设备批量分配|拆分“分配代理”和“分配套餐系列”两个入口,展示任务结果|复用 Excel/Asynq,新增两个独立批量命令和失败明细|2~3小时|3~4小时|前端无现成上传任务组件或生产文件格式不统一会增加 2~4 小时|
|
||||
|需求09:C 端支付限制|支付页按接口返回方式展示,后台增加配置控件|实现受控系统配置、缓存和订单支付二次校验|1.5~2.5小时|2~3小时|多端支付入口未复用同一接口会增加逐端排查时间|
|
||||
|需求10:Gateway 限速|卡/设备详情增加设置和取消入口|统一 `speed_kbps` 接口,设备解析当前卡后调用 Gateway|1~2小时|2~3小时|Gateway 取消参数不明确或联调不稳定会增加 2~4 小时|
|
||||
|需求11:当前套餐到期高亮|并入需求06/22,不单独建设第二个资产汇总字段|并入预计最终到期 Query|0小时|0小时|无独立工时|
|
||||
|需求12:换货显示与搜索|拆分新旧资产搜索并修正字段展示|修正新建换货快照,增加新旧资产关键词过滤|1~2小时|1~2小时|历史数据不回填导致验收口径混淆会增加沟通时间|
|
||||
|需求13:列表字段新增|退款、充值、换货列表增加提交人和企微审批摘要|补提交人快照,批量查询企微状态和审批人摘要|1~2小时|2~3小时|旧记录缺提交人或企微实例会增加兼容展示时间|
|
||||
|需求14:导出与字段权限|角色页配置导出字段,各列表接入统一导出入口|扩展 DataSource 场景、角色字段权限和字段快照|3~4小时|4~6小时|导出字段口径变更、现有数据源 N+1 会增加 3~5 小时|
|
||||
|需求15:下架套餐续费|当前套餐旁增加续费按钮,复用购买流程|统一可售策略,校验资产所有人及历史使用资格|1.5~2.5小时|2~3小时|C 端多个购买入口绕过统一策略会增加排查时间|
|
||||
|需求16:代理分销码与佣金提现|不实施|不实施|0小时|0小时|需求重新加入时必须单独评估,不计入本表|
|
||||
|需求17:代理主钱包信用额度|角色页配置新建店铺默认额度,店铺资金页单独调额并明确不追溯|增加角色默认模板、钱包实际额度、约束、扣款不变量和资金审计|2.5~3.5小时|4~5小时|现有负余额、CHECK 约束或创建店铺事务分散会增加 2~5 小时|
|
||||
|需求18:多人审批映射|不建设本地待办,只展示企微审批摘要|退款/充值场景映射到企微模板,复用企微状态同步|1~1.5小时|1~1.5小时|企微模板审批人配置不完整会阻塞联调|
|
||||
|需求19:批量订购套餐|批量上传、统一支付方式、任务进度和失败明细|任务/明细表、逐行幂等下单、钱包或线下支付处理|3~4小时|4~6小时|订单服务复用困难、钱包并发冲突会增加 3~6 小时|
|
||||
|需求20:退款审批|创建和详情展示企微状态、意见附件及处理结果|退款接入企微、代理钱包回溯、人工退款终态和撤销异常|2.5~3.5小时|4~5小时|存量退款买家类型混乱、通过后撤销场景会增加 3~5 小时|
|
||||
|需求21:平台员工线下充值|创建后展示企微审批状态,删除本地确认/驳回按钮|企微通过后幂等增加代理主钱包,旧接口下线|2~3小时|3~4小时|旧充值状态和钱包流水不一致会增加 2~4 小时|
|
||||
|需求22:套餐临期提醒|资产列表、详情、临期页、代理首页和 C 端展示;仅临期页将3天内置顶|复用预计最终到期 Query、15/7/3 天站内通知防重和导出接入|3~4.5小时|4~6小时|时区、排队套餐快照和多接收人数据异常会增加 2~4 小时|
|
||||
|禅道#98/#86:换货归属与资产标识|换货确认展示继承店铺,资产详情展示前代/后代标签和跳转|新资产继承旧资产店铺、保留旧资产归属、补换货链 Query 和索引|1~2小时|2~3小时|换货迁移涉及的租户标签或资产分配记录不完整会增加 2~4 小时|
|
||||
|禅道#96/#97:业务员与余额提醒|店铺业务员选择/筛选、资金不足100元状态和通知跳转|增加店铺业务员字段,钱包跨越固定100元阈值时向主账号和业务员发站内通知|1~2小时|2~3小时|店铺主账号异常、业务员停用或钱包写入口未统一会增加 2~4 小时|
|
||||
|禅道#43:系列套餐批量授权|套餐表格多选、已授权置灰、三类价格展示|复用现有批量写接口,新增套餐候选 Query 和重复授权幂等|1~2小时|0.5~1小时|前端旧页面结构不支持表格多选或成本价权限不完整会增加 1~3 小时|
|
||||
|新增01:数据同步触发与轮询优化|展示轮询活跃状态和审计跳转|卡状态领域收口、活跃调频、0/3/5 任务、回调防腐层|2~3小时|7~9小时|旧同步入口数量超出预期、Gateway 超频和 ICCID 重复会增加 4~8 小时|
|
||||
|新增02:企业微信审批接入|企微配置、模板映射、扫码绑定、审批运行和业务详情|Token、模板版本、上传提交、回调解密、轮询补偿和异常恢复|5~7小时|8~10小时|企微可信域名、模板 ID 变化、回调网络和真实账号权限会增加 4~8 小时|
|
||||
|新增03:站内通知|顶部铃铛、通知抽屉、通知中心和受控跳转|通知表、模板注册、接收人解析、未读/已读 API|3~4小时|3~4小时|前端多端布局差异、接收人关系不完整会增加 2~3 小时|
|
||||
|新增04:全局多视角审计|审计中心七个视角、详情抽屉和敏感字段展示|Audit Event、Integration Log、旧写入口切换、历史投影和脱敏|6~8小时|8~11小时|旧审计调用点遗漏、查询性能和历史字段差异会增加 5~10 小时|
|
||||
|新增05:代理钱包扫码充值|支付方式选择、二维码、倒计时和支付状态轮询|微信 Native、支付宝 PreCreate、支付单分发和钱包入账恢复|3~4小时|5~7小时|支付渠道配置、真实回调、微信 v2/富友差异会增加 3~6 小时|
|
||||
|全链路联调与发布|联调全部页面状态、修复交互、准备发布版本|数据核对、存量回填、旧入口清理、停机发布和恢复检查|3~4小时|4~5小时|生产数据与预期差异、外部回调不可达会增加 4~8 小时|
|
||||
|**合计**|**前端约 59~89 小时**|**后端约 93~128 小时**|**约 8~12 人日**|**约 12~16 人日**|**1 后端 + 1 前端并行时,正常目标 12~15 个工作日;历史数据或换货迁移超预期时可能到 16 个工作日**|
|
||||
|
||||
## 并行交付口径
|
||||
|
||||
| 项目 | 估算 |
|
||||
|------|------|
|
||||
| 前端投入 | 59~89 小时,约 8~12 人日 |
|
||||
| 后端投入 | 93~128 小时,约 12~16 人日 |
|
||||
| 合计投入 | 152~217 小时,约 19~28 人日 |
|
||||
| 1 后端 + 1 前端并行周期 | 正常 12~15 个工作日,风险上限 16 个工作日 |
|
||||
| 建议对外排期基线 | 14 个工作日 |
|
||||
|
||||
达到第 16 个工作日的主要条件:前端旧授权页面无法复用、换货归属需要补齐多张租户标签表、生产历史数据需要大规模人工修复、企微或支付联调配置不可用。
|
||||
1458
docs/7月迭代/7月迭代技术方案-标准评审稿.md
Normal file
1458
docs/7月迭代/7月迭代技术方案-标准评审稿.md
Normal file
File diff suppressed because it is too large
Load Diff
242
docs/7月迭代/7月迭代禅道研发需求拆分表.md
Normal file
242
docs/7月迭代/7月迭代禅道研发需求拆分表.md
Normal file
@@ -0,0 +1,242 @@
|
||||
# 7月迭代禅道研发需求拆分表
|
||||
|
||||
> 来源:禅道用户需求导出与7月迭代标准评审稿。
|
||||
> 范围:只包含激活需求;草稿 #41/#51 和已关闭重复需求 #54 不创建研发需求。
|
||||
>
|
||||
> 本文件用于总览、排期和关联关系,不用于逐条复制。实际录入禅道请使用:[7月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)。录入稿中每条 FE/BE/INT 都有可独立复制的标题和完整描述。
|
||||
|
||||
## 一、拆分规则
|
||||
|
||||
每条用户需求下创建两条研发需求:
|
||||
|
||||
```text
|
||||
[FE][UR#编号] 前端交付名称
|
||||
[BE][UR#编号] 后端交付名称
|
||||
```
|
||||
|
||||
- FE、BE 研发需求分别指派给前端和后端,可并行进入开发。
|
||||
- 研发需求必须描述可独立交付的页面或接口能力,不能只写“配合前端”“配合后端”。
|
||||
- 联调属于研发需求完成后的交付活动,不为每条用户需求机械创建第三条研发需求。
|
||||
- 跨页面、跨模块、异步任务或第三方系统场景,统一创建链路级联调任务。
|
||||
- 简单搜索、字段展示等需求在 FE/BE 研发需求中完成自验,不单独创建联调任务。
|
||||
|
||||
推荐状态依赖:
|
||||
|
||||
```text
|
||||
FE/BE 研发需求开发完成
|
||||
-> 接口契约冻结
|
||||
-> 进入对应联调任务
|
||||
-> 问题回到原 FE/BE 研发需求修复
|
||||
-> 联调任务验收通过
|
||||
```
|
||||
|
||||
## 二、用户需求到研发需求映射
|
||||
|
||||
| 用户需求 | 前端研发需求 | 后端研发需求 | 联调归属 |
|
||||
|---|---|---|---|
|
||||
| #98 换货管理新资产归属 | `[FE][UR#98] 换货新资产归属继承提示`:选择新资产后展示将继承的店铺 | `[BE][UR#98] 换货新资产继承旧资产店铺`:事务内迁移归属、租户标签和分配记录 | INT-02 换货链路 |
|
||||
| #97 代理钱包阈值提醒 | `[FE][UR#97] 代理现金余额不足100元展示与通知跳转` | `[BE][UR#97] 主钱包固定100元余额预警`:跨越阈值、重新布防、站内通知 | INT-05 钱包支付 |
|
||||
| #96 员工作为发展人进行标识 | `[FE][UR#96] 店铺业务员选择、展示和筛选` | `[BE][UR#96] 店铺业务员关联与查询`:只绑定启用平台账号 | INT-05 钱包支付 |
|
||||
| #94 系统状态同步优化以及回调处理 | `[FE][UR#94] 资产同步状态与审计入口展示` | `[BE][UR#94] 资产状态同步DDD收口`:轮询、0/3/5事件、回调防腐层 | INT-01 实名与同步 |
|
||||
| #86 资产详情中换货标识 | `[FE][UR#86] 资产详情前代/后代换货标识与跳转` | `[BE][UR#86] 资产换货链Query`:返回 previous/next asset并校验权限 | INT-02 换货链路 |
|
||||
| #73 行业卡操作停复机 | `[FE][UR#73] 复机实名校验结果与错误提示适配` | `[BE][UR#73] 按运营商实名能力控制复机` | INT-01 实名与同步 |
|
||||
| #62 H5设置先充值后实名 | `[FE][UR#62] H5实名与购买顺序流程`:先实名/先购买/无需实名 | `[BE][UR#62] 资产实名顺序策略与批量配置接口` | INT-01 实名与同步 |
|
||||
| #60 店铺联系电话检索 | `[FE][UR#60] 店铺联系电话搜索控件` | `[BE][UR#60] 店铺联系电话精确查询` | 不单建,FE/BE自验 |
|
||||
| #57 退款中禁止换货 | `[FE][UR#57] 换货退款拦截错误展示` | `[BE][UR#57] 换货前活跃退款校验` | INT-02 换货链路 |
|
||||
| #55 套餐生效条件 | `[FE][UR#55] 套餐分配生效条件选择与恢复默认` | `[BE][UR#55] 套餐分配生效条件覆盖与购买快照` | INT-03 套餐生命周期 |
|
||||
| #53 资产实名状态筛选 | `[FE][UR#53] 卡和设备实名状态筛选` | `[BE][UR#53] 卡/设备实名状态查询与设备快照维护` | INT-01 实名与同步 |
|
||||
| #49 设备批量分配代理和套餐系列 | `[FE][UR#49] 设备批量分配代理/套餐系列双入口` | `[BE][UR#49] 两类设备批量分配任务与失败明细` | INT-04 批量与导出 |
|
||||
| #48 不同资产使用不同支付方式 | `[FE][UR#48] C端按资产展示允许支付方式` | `[BE][UR#48] 支付方式配置与订单二次校验` | INT-05 钱包支付 |
|
||||
| #47 限速规则 | `[FE][UR#47] 卡/设备手动设置与取消限速` | `[BE][UR#47] Gateway按cardNo限速统一接口` | INT-07 Gateway限速 |
|
||||
| #46 资产信息详情字段新增 | `[FE][UR#46] 预计最终到期时间与临期高亮` | `[BE][UR#46] 当前及排队套餐最终到期Query` | INT-03 套餐生命周期 |
|
||||
| #45 换货管理 | `[FE][UR#45] 换货新旧资产展示与独立搜索` | `[BE][UR#45] 换货标识快照修正与新旧资产查询` | INT-02 换货链路 |
|
||||
| #44 列表字段新增 | `[FE][UR#44] 退款/充值/换货提交人与审批摘要展示` | `[BE][UR#44] 提交人快照与企微审批摘要批量查询` | INT-06 企微审批 |
|
||||
| #43 代理系列授权 | `[FE][UR#43] 系列套餐批量选择与已授权置灰` | `[BE][UR#43] 系列套餐候选Query与重复授权幂等` | INT-03 套餐生命周期 |
|
||||
| #42 导出功能 | `[FE][UR#42] 导出字段选择、权限展示和任务进度` | `[BE][UR#42] 导出场景、角色字段权限与30天到期筛选` | INT-04 批量与导出 |
|
||||
| #40 套餐设计 | `[FE][UR#40] C端下架套餐续费入口` | `[BE][UR#40] 下架套餐历史用户续费资格校验` | INT-03 套餐生命周期 |
|
||||
| #38 不同渠道额度处理 | `[FE][UR#38] 角色默认信用和店铺实际额度管理` | `[BE][UR#38] 代理主钱包信用额度与并发资金不变量` | INT-05 钱包支付 |
|
||||
| #37 审核流转 | `[FE][UR#37] 企微审批状态、意见附件和结果通知展示` | `[BE][UR#37] 企微审批模板、账号绑定、回调和轮询补偿` | INT-06 企微审批 |
|
||||
| #36 批量订购套餐 | `[FE][UR#36] 批量订购上传、支付方式、进度和失败明细` | `[BE][UR#36] 批量订购任务、逐行幂等下单和钱包扣款` | INT-04 批量与导出、INT-05 钱包支付 |
|
||||
| #35 退款审核 | `[FE][UR#35] 退款企微审批详情与业务处理状态` | `[BE][UR#35] 退款企微终态、人工退款和代理钱包回溯` | INT-06 企微审批 |
|
||||
| #34 充值审核流程 | `[FE][UR#34] 代理扫码充值与员工线下审批状态页面` | `[BE][UR#34] 微信/支付宝充值入账和线下充值企微终态` | INT-05 钱包支付、INT-06 企微审批 |
|
||||
| #33 套餐临期提醒 | `[FE][UR#33] 临期列表、各端高亮、3天置顶和续费入口` | `[BE][UR#33] 最终到期临期Query与15/7/3站内通知` | INT-03 套餐生命周期 |
|
||||
|
||||
## 三、前后端并行约定
|
||||
|
||||
研发需求创建前先冻结本表中的接口契约。前端按 Mock 数据开发页面,后端按同一契约实现接口。
|
||||
|
||||
- 响应统一为 `{code,msg,data,timestamp}`,下表只描述 `data`。
|
||||
- 分页统一返回 `{items,total,page,size}`。
|
||||
- 金额入参和返回均使用分;前端显示元。
|
||||
- 生命周期判断使用 `status`,展示使用 `status_name`。
|
||||
- 字段允许增加但不能改名或改变类型;确需调整时,必须同步修改 FE、BE 两条研发需求。
|
||||
- 前端先完成页面、交互、Mock、加载/空/错误状态;后端完成后只替换数据源并进入联调。
|
||||
|
||||
每组需求按以下顺序启动:
|
||||
|
||||
```text
|
||||
1. FE/BE共同确认路径、方法、入参、返回和枚举
|
||||
2. 后端先补DTO/OpenAPI契约或提供固定JSON示例
|
||||
3. 前端按JSON示例完成页面和Mock请求
|
||||
4. 后端实现真实查询、写入、权限、幂等和审计
|
||||
5. 前端切换真实接口,进入对应INT联调任务
|
||||
```
|
||||
|
||||
契约确认时必须标记字段是必返、可空还是仅特定状态返回,避免前端通过猜测补默认值。
|
||||
|
||||
## 四、研发需求压缩总览
|
||||
|
||||
> 下表仅用于快速核对,不建议复制到禅道。前端页面结构、交互状态、接口入参和返回字段以[逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)为准。
|
||||
|
||||
| 用户需求 | 前端研发需求说明 | 后端研发需求与接口契约 |
|
||||
|---|---|---|
|
||||
| #98 换货新资产归属 | **标题:**换货新资产归属继承提示。<br>**页面:**换货创建、发货确认。选择新资产后展示“完成后归属到某店铺”,提交中禁止重复操作,失败展示后端原因。 | **标题:**换货新资产继承旧资产店铺。<br>**接口:**复用 `POST /api/admin/exchanges`、`POST /api/admin/exchanges/{id}/ship`、`POST /api/admin/exchanges/{id}/complete`。<br>**入参:**旧/新资产标识、换货类型、是否迁移资料。<br>**返回:**换货单及 `inherited_shop_id/inherited_shop_name`。<br>**规则:**平台库存新资产继承旧店铺;其他店铺资产拒绝。 |
|
||||
| #97 钱包100元预警 | **标题:**代理现金余额不足100元展示。<br>**页面:**资金概况显示红色预警;顶部通知和通知中心支持跳转店铺资金页,不提供阈值配置。 | **标题:**代理主钱包固定100元余额预警。<br>**接口:**`GET /api/admin/shops/fund-summary` 增加 `cash_available/low_balance_warning`;通知复用 `/api/admin/notifications`。<br>**规则:**`balance-frozen_balance<=10000`,不含信用额度;只在跨越阈值时通知,回升后重新布防。 |
|
||||
| #96 店铺业务员 | **标题:**店铺业务员选择、展示与筛选。<br>**页面:**店铺新建/编辑增加平台业务员下拉,列表和详情展示业务员,筛选栏支持按业务员查询。 | **标题:**店铺业务员关联与查询。<br>**接口:**`POST /api/admin/shops`、`PUT /api/admin/shops/{id}` 入参增加 `business_owner_account_id`;`GET /api/admin/shops` 增加同名筛选。<br>**返回:**`business_owner_account_id/business_owner_name`。只允许启用的平台账号。 |
|
||||
| #94 状态同步优化 | **标题:**资产同步状态与审计入口。<br>**页面:**资产详情展示活跃级别、最后活跃、下次轮询;保留原刷新按钮,增加“查看同步轨迹”跳转。 | **标题:**资产状态同步DDD收口。<br>**接口:**不新增刷新入口,复用 `POST /api/admin/assets/{identifier}/refresh`;`GET /api/admin/assets/resolve/{identifier}` 返回 `polling`;轨迹使用 `GET /api/admin/audit/integrations`。<br>**规则:**轮询、0/3/5事件和回调统一应用状态。 |
|
||||
| #86 资产换货标识 | **标题:**资产详情换货前代/后代展示。<br>**页面:**显示“换货新资产”“已换出旧资产”标签,可查看前代和后代;无权限时不可点击。 | **标题:**资产换货链Query。<br>**接口:**`GET /api/admin/assets/resolve/{identifier}` 增加 `exchange_trace.previous_asset/next_asset`。<br>**返回:**资产类型、ID、标识、换货单号和 `can_view`。 |
|
||||
| #73 行业卡复机 | **标题:**复机实名结果和错误提示。<br>**页面:**沿用资产详情复机按钮;未满足实名条件时展示后端中文原因。 | **标题:**按运营商实名能力控制复机。<br>**接口:**复用 `POST /api/admin/assets/{identifier}/start`。<br>**返回:**最新资产状态。<br>**规则:**只看运营商 `realname_link_type`,不按行业卡类型统一放行。 |
|
||||
| #62 H5实名顺序 | **标题:**H5先实名/先购买流程及后台批量配置。<br>**页面:**C端按接口策略跳转;后台卡和设备列表提供批量修改入口。 | **标题:**资产实名顺序策略。<br>**接口:**`PATCH /api/admin/assets/{identifier}/realname-mode`;`POST /api/admin/iot-cards/batch-update-realname-policy`;`POST /api/admin/devices/batch-update-realname-policy`。<br>**入参:**资产标识列表、`policy=none/before_order/after_order`。<br>**返回:**生效策略及成功数量。 |
|
||||
| #60 联系电话搜索 | **标题:**店铺联系电话搜索。<br>**页面:**店铺列表新增11位联系电话输入框,支持清空和重新查询。 | **标题:**店铺联系电话精确查询。<br>**接口:**`GET /api/admin/shops?contact_phone=`。<br>**入参:**11位手机号。<br>**返回:**原店铺分页结构。 |
|
||||
| #57 退款中禁止换货 | **标题:**换货退款拦截提示。<br>**页面:**创建换货失败时原地展示“该资产存在退款申请”,不自行判断退款状态。 | **标题:**换货前活跃退款校验。<br>**接口:**复用 `POST /api/admin/exchanges`。<br>**规则:**审批中、已退回或退款处理未完成时返回业务错误;已拒绝、撤销或处理完成后放行。 |
|
||||
| #55 套餐生效条件 | **标题:**套餐分配生效条件选择。<br>**页面:**授权/分配弹框提供跟随默认、购买生效、实名生效三选一;已分配记录支持修改和恢复默认。 | **标题:**套餐分配生效条件覆盖与快照。<br>**接口:**`POST /api/admin/shop-package-allocations`;`PATCH /api/admin/shop-package-allocations/{id}/expiry-base`。<br>**入参:**`expiry_base_override`,`null` 表示恢复默认。<br>**返回:**默认值、覆盖值和最终生效值。 |
|
||||
| #53 实名状态筛选 | **标题:**卡和设备实名状态筛选。<br>**页面:**两个资产列表增加全部/已实名/未实名筛选和状态列。 | **标题:**卡和设备实名状态查询。<br>**接口:**`GET /api/admin/iot-cards?real_name_status=0\|1`;`GET /api/admin/devices?real_name_status=0\|1`。<br>**返回:**`real_name_status/real_name_status_name`。 |
|
||||
| #49 设备批量分配 | **标题:**设备批量分配代理和套餐系列双入口。<br>**页面:**两个独立上传弹框,展示总数、成功数、失败数和失败原因。 | **标题:**设备两类批量分配任务。<br>**接口:**`POST /api/admin/devices/batch-assign-shop`、`POST /api/admin/devices/batch-assign-series`、`GET /api/admin/devices/batch-allocation/{task_id}`。<br>**入参:**文件和目标ID。<br>**返回:**任务状态及失败明细。 |
|
||||
| #48 支付方式限制 | **标题:**C端按资产展示支付方式。<br>**页面:**支付页只展示接口返回的支付方式;无可用方式时禁止提交。 | **标题:**资产支付方式配置与订单校验。<br>**接口:**`GET /api/c/v1/asset/info` 返回 `allowed_payment_methods`;`POST /api/c/v1/orders/create`、`POST /api/c/v1/orders/{id}/pay` 再次校验。<br>**后台:**`PUT /api/admin/system/config/{config_key}`。 |
|
||||
| #47 Gateway限速 | **标题:**卡和设备手动限速。<br>**页面:**详情页提供设置、取消入口;设备显示最终使用的当前卡ICCID。 | **标题:**Gateway按cardNo统一限速。<br>**接口:**`POST /api/admin/assets/{identifier}/speed-limit`。<br>**入参:**`speed_kbps`,0表示取消。<br>**返回:**资产标识、最终 `card_no`、目标值和执行结果。 |
|
||||
| #46 预计最终到期 | **标题:**预计套餐到期时间和临期高亮。<br>**页面:**资产详情只显示一个最终到期字段;不可预计时显示“待激活后起算”。 | **标题:**当前及排队套餐最终到期Query。<br>**接口:**`GET /api/admin/assets/resolve/{identifier}`。<br>**返回:**`estimated_final_expires_at/days_until_final_expiry/expiry_estimate_status/is_expiring`。 |
|
||||
| #45 换货管理 | **标题:**换货新旧资产展示和独立搜索。<br>**页面:**列表分别搜索旧资产、新资产,展示统一卡ICCID或设备号。 | **标题:**换货标识快照和查询。<br>**接口:**`GET /api/admin/exchanges?old_asset_keyword=&new_asset_keyword=`。<br>**返回:**新旧资产类型、ID、标识及换货状态。新建换货统一保存规范化标识。 |
|
||||
| #44 列表字段新增 | **标题:**退款、充值、换货列表提交人与审批摘要。<br>**页面:**增加提交人、企微状态、当前审批人摘要和业务处理状态。 | **标题:**提交人快照与企微摘要查询。<br>**接口:**复用 `GET /api/admin/refunds`、`GET /api/admin/agent-recharges`、`GET /api/admin/exchanges`。<br>**返回:**`submitter_name/approval_status_name/current_approver_summary/processing_status_name`。 |
|
||||
| #43 系列批量授权 | **标题:**系列套餐批量选择与已授权置灰。<br>**页面:**首次和后续授权共用多选表格;已授权套餐置灰,展示公司成本、授权成本和建议售价。 | **标题:**系列套餐候选和批量授权。<br>**接口:**`GET /api/admin/shop-series-grants/{id}/package-options`;`PUT /api/admin/shop-series-grants/{id}/packages`。<br>**返回:**三类价格、`is_authorized`;重复授权幂等。 |
|
||||
| #42 导出功能 | **标题:**导出字段选择、权限和任务进度。<br>**页面:**导出弹框加载可选字段,提交后展示进度、失败和下载。 | **标题:**统一导出场景和字段权限。<br>**接口:**`GET /api/admin/export-fields?scene=`、`POST /api/admin/export-tasks`、`GET /api/admin/export-tasks/{id}`。<br>**入参:**`scene/format/query/fields`。<br>**返回:**任务进度和下载地址。 |
|
||||
| #40 下架套餐续费 | **标题:**C端当前套餐续费入口。<br>**页面:**当前套餐旁显示续费;下架套餐不出现在新购列表。 | **标题:**下架套餐续费资格。<br>**接口:**`GET /api/c/v1/asset/packages` 返回 `can_purchase/purchase_mode/disabled_reason`;`POST /api/c/v1/orders/create` 强校验资产所有人和历史使用记录。 |
|
||||
| #38 代理信用额度 | **标题:**角色默认信用和店铺实际额度管理。<br>**页面:**客户角色配置新建默认额度并提示不影响存量;店铺资金页单独调整实际额度。 | **标题:**代理主钱包信用额度。<br>**接口:**`PUT /api/admin/roles/{id}/default-credit`、`PUT /api/admin/shops/{id}/credit-limit`、`GET /api/admin/shops/fund-summary`。<br>**入参:**开关、额度、钱包版本。<br>**返回:**额度、可用金额、欠款和版本。 |
|
||||
| #37 企微审核流转 | **标题:**企微配置、账号绑定和审批详情。<br>**页面:**企微配置页、个人扫码绑定、审批运行列表;业务详情只读展示意见和附件。 | **标题:**企业微信审批接入。<br>**接口:**`GET /api/admin/wecom/status`、`POST /api/admin/wecom/account-binding/sessions`、`GET /api/admin/wecom/approvals`、`POST /api/admin/wecom/approvals/{id}/sync`。<br>**业务详情:**统一返回 `approval` 对象。 |
|
||||
| #36 批量订购套餐 | **标题:**批量订购上传、支付、进度和失败明细。<br>**页面:**选择代理和整批支付方式,上传Excel及线下凭证,展示部分成功。 | **标题:**批量订购任务。<br>**接口:**`POST /api/admin/bulk-purchases`、`GET /api/admin/bulk-purchases/{task_id}`、`GET /api/admin/bulk-purchases/{task_id}/items`。<br>**入参:**代理、支付方式、文件、凭证。<br>**返回:**任务和逐行结果。 |
|
||||
| #35 退款审核 | **标题:**退款企微审批和退款处理状态。<br>**页面:**创建时上传备注附件;详情展示审批、人工退款说明和业务处理结果。 | **标题:**退款企微终态处理。<br>**接口:**`POST /api/admin/refunds`、`GET /api/admin/refunds/{id}`、`POST /api/admin/refunds/{id}/resubmit`。<br>**返回:**退款数据、`approval` 和 `processing_status`。代理钱包通过后幂等回溯。 |
|
||||
| #34 充值审核流程 | **标题:**代理扫码充值与员工线下充值审批。<br>**页面:**在线充值展示支付方式、二维码和支付状态;线下充值展示只读企微审批状态。 | **标题:**代理在线充值和员工线下审批。<br>**接口:**`GET /api/admin/agent-recharges/payment-methods`、`POST /api/admin/agent-recharges`、`GET /api/admin/agent-recharges/{id}/payment-status`、`GET /api/admin/agent-recharges/{id}`。<br>**返回:**二维码、过期时间、支付/审批/入账状态。 |
|
||||
| #33 套餐临期提醒 | **标题:**临期列表、各端高亮和续费入口。<br>**页面:**临期页3天内置顶;普通资产列表只高亮;代理首页显示数量;C端显示续费按钮。 | **标题:**预计最终到期临期Query和站内通知。<br>**接口:**`GET /api/admin/expiring-assets`、资产列表/详情增加临期字段、`GET /api/c/v1/asset/info`、通知接口。<br>**返回:**最终到期、剩余天数、颜色节点;15/7/3天通知防重。 |
|
||||
|
||||
## 五、前端Mock公共样例
|
||||
|
||||
资产详情扩展字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"estimated_final_expires_at": "2026-08-01T23:59:59+08:00",
|
||||
"days_until_final_expiry": 15,
|
||||
"expiry_estimate_status": "exact",
|
||||
"is_expiring": true,
|
||||
"allowed_payment_methods": ["alipay", "wallet"],
|
||||
"polling": {
|
||||
"enabled": true,
|
||||
"activity_level": "active",
|
||||
"last_activity_at": "2026-07-17T10:00:00+08:00",
|
||||
"next_poll_at": "2026-07-17T10:03:00+08:00"
|
||||
},
|
||||
"exchange_trace": {
|
||||
"previous_asset": null,
|
||||
"next_asset": null
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
统一异步任务:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_id": 1001,
|
||||
"status": 2,
|
||||
"status_name": "处理中",
|
||||
"total_count": 100,
|
||||
"success_count": 60,
|
||||
"failed_count": 3,
|
||||
"failed_items": []
|
||||
}
|
||||
```
|
||||
|
||||
代理资金概况:
|
||||
|
||||
```json
|
||||
{
|
||||
"balance": 8000,
|
||||
"frozen_balance": 0,
|
||||
"cash_available": 8000,
|
||||
"credit_enabled": true,
|
||||
"credit_limit": 100000,
|
||||
"available_balance": 108000,
|
||||
"low_balance_warning": true,
|
||||
"version": 3
|
||||
}
|
||||
```
|
||||
|
||||
企微审批摘要:
|
||||
|
||||
```json
|
||||
{
|
||||
"source": "wecom",
|
||||
"instance_id": 123,
|
||||
"sp_no": "202607170001",
|
||||
"status": 1,
|
||||
"status_name": "审批中",
|
||||
"current_approver_summary": "财务审批",
|
||||
"approvers": [],
|
||||
"attachments": [],
|
||||
"business_process_result": "pending"
|
||||
}
|
||||
```
|
||||
|
||||
## 六、联调任务拆分
|
||||
|
||||
联调项建议创建为项目执行任务,而不是研发需求。每个任务可关联多条 FE/BE 研发需求和用户需求。
|
||||
|
||||
如果团队流程强制要求联调也必须使用“研发需求”类型,则先新增一条技术用户需求“7月迭代跨模块联调与发布验收”,再将下列 INT-01~08 建成它的子研发需求。不要把同一条联调需求重复挂到每个业务用户需求下。
|
||||
|
||||
| 编号 | 联调任务名称 | 关联用户需求 | 参与工时 | 进入条件 | 主要验收链路 |
|
||||
|---|---|---|---|---|---|
|
||||
| INT-01 | 资产实名、复机与状态同步联调 | #94、#73、#62、#53 | FE 1~1.5h / BE 1~1.5h,已含 | 实名策略接口、同步任务和H5页面完成 | 不同运营商实名能力、先实名/先购买、0/3/5同步、回调后状态和筛选一致 |
|
||||
| INT-02 | 换货完整链路联调 | #98、#86、#57、#45 | FE 0.5~1h / BE 0.5~1h,已含 | 换货接口、详情和列表页面完成 | 退款拦截、新资产继承店铺、完成换货、列表搜索、前代/后代跳转 |
|
||||
| INT-03 | 套餐授权、购买、续费、到期与临期联调 | #55、#46、#43、#40、#33 | FE 1~1.5h / BE 1~1.5h,已含 | 套餐快照、授权候选和各端到期字段完成 | 批量授权、购买生效条件、下架续费、排队套餐最终到期、临期高亮和通知 |
|
||||
| INT-04 | Excel批量任务与导出联调 | #49、#36、#42 | FE 1~1.5h / BE 1~1.5h,已含 | 上传、任务详情、Worker和导出场景完成 | 模板校验、部分成功、失败明细、任务恢复、字段权限和文件下载 |
|
||||
| INT-05 | 支付、代理钱包、信用和余额预警联调 | #48、#38、#34、#36、#96、#97 | FE 1~1.5h / BE 1~1.5h,已含 | 支付配置、钱包领域和业务员接口完成 | 支付方式限制、扫码充值、信用扣款、角色默认额度、店铺调额、100元预警 |
|
||||
| INT-06 | 企业微信审批、退款和线下充值联调 | #37、#35、#34、#44 | FE 1.5~2h / BE 1.5~2h,已含 | 企微模板、绑定、回调、轮询和业务详情完成 | 扫码绑定、发起审批、意见附件、通过/驳回/撤销、退款/充值终态和列表摘要 |
|
||||
| INT-07 | Gateway卡限速联调 | #47 | FE 0.5~1h / BE 0.5~1h,已含 | Gateway联调配置和卡/设备入口完成 | 单卡限速、设备解析当前卡、取消限速、无当前卡、失败审计 |
|
||||
| INT-08 | 七月迭代全链路与停机发布验收 | 全部激活需求 | FE 3~4h / BE 4~5h,额外 | INT-01~07完成 | 权限、通知、审计、历史数据、旧入口关闭、Worker恢复和发布检查 |
|
||||
|
||||
## 七、联调任务责任方式
|
||||
|
||||
- 涉及企微、支付、Gateway、运营商回调和异步 Worker 的联调任务由后端主责,前端参与。
|
||||
- 主要是页面与接口契约的批量、导出、套餐和换货联调可由前端主责,后端参与。
|
||||
- 禅道只有一个指派人时,指派给主责人;另一人写入抄送和参与人,不拆成两条重复联调任务。
|
||||
- 联调发现的代码问题回到对应 FE/BE 研发需求处理;联调任务只记录场景、阻塞和最终结论。
|
||||
|
||||
## 八、研发需求描述模板
|
||||
|
||||
前端研发需求至少填写:页面入口、交互状态、调用接口、权限、异常状态、完成标准。
|
||||
|
||||
后端研发需求至少填写:业务规则、数据变更、API、权限、幂等/并发、事件与审计、完成标准。
|
||||
|
||||
联调任务至少填写:关联研发需求、环境和账号、测试数据、完整操作步骤、预期结果、阻塞项和最终结论。
|
||||
|
||||
## 九、额外禅道项
|
||||
|
||||
当前用户需求 CSV 没有覆盖公共开发基础、公共站内通知和全局审计。建议补建三条技术用户需求:
|
||||
|
||||
```text
|
||||
用户需求:七月迭代公共开发基础
|
||||
用户需求:公共站内通知
|
||||
用户需求:全局多视角审计与外部集成追踪
|
||||
```
|
||||
|
||||
再分别拆分:
|
||||
|
||||
```text
|
||||
[FE] 七月迭代公共状态与异步任务交互
|
||||
[BE] 七月迭代公共迁移幂等与异步任务基础
|
||||
[FE] 顶部通知铃铛与站内通知中心
|
||||
[BE] 站内通知基础设施与受控跳转
|
||||
[FE] 全局多视角审计中心
|
||||
[BE] Audit Event与Integration Log统一审计
|
||||
```
|
||||
|
||||
具体描述和工时直接使用[逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)。
|
||||
1889
docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md
Normal file
1889
docs/7月迭代/7月迭代禅道研发需求逐条录入稿.md
Normal file
File diff suppressed because it is too large
Load Diff
62
docs/7月迭代/README.md
Normal file
62
docs/7月迭代/README.md
Normal file
@@ -0,0 +1,62 @@
|
||||
# 7月迭代文档索引
|
||||
|
||||
本目录只保留一份当前有效的评审结论和可追溯的独立来源稿。
|
||||
|
||||
## 当前有效方案
|
||||
|
||||
- [7月迭代技术方案(标准评审稿)](./7月迭代技术方案-标准评审稿.md)
|
||||
- [7月迭代前后端任务工时表](./7月迭代前后端任务工时表.md)
|
||||
- [7月迭代禅道研发需求拆分表](./7月迭代禅道研发需求拆分表.md)
|
||||
- [7月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)
|
||||
|
||||
评审、开发和验收均以标准评审稿为准。独立稿用于解释方案来源;与标准稿冲突时,标准稿优先。
|
||||
|
||||
## 原需求独立稿
|
||||
|
||||
| 需求 | 来源文件 |
|
||||
|------|----------|
|
||||
| 01 | [复机实名规则](./独立方案/原需求/需求01-复机实名规则.md) |
|
||||
| 02 | [H5 流程配置](./独立方案/原需求/需求02-H5流程配置.md) |
|
||||
| 03、07、11、12、13 | [简单改动合集](./独立方案/原需求/需求03-07-11-12-13-简单改动.md) |
|
||||
| 04、06 | [退款拦截与最后到期时间](./独立方案/原需求/需求04-06-退款拦截与最后到期时间.md) |
|
||||
| 05 | [套餐分配生效条件](./独立方案/原需求/需求05-套餐分配生效条件.md) |
|
||||
| 08 | [设备批量分配 Excel](./独立方案/原需求/需求08-设备批量分配Excel.md) |
|
||||
| 09 | [C 端支付限制配置化](./独立方案/原需求/需求09-C端支付限制配置化.md) |
|
||||
| 10 | [Gateway 限速规则](./独立方案/原需求/需求10-限速规则.md) |
|
||||
| 14 | [导出功能](./独立方案/原需求/需求14-导出功能.md) |
|
||||
| 15、16、18、19、20、21 | [复杂需求来源稿](./独立方案/原需求/需求15-16-18-19-20-21-复杂需求.md) |
|
||||
| 17 | [信用额度](./独立方案/原需求/需求17-信用额度.md) |
|
||||
| 22 | [套餐临期提醒](./独立方案/原需求/需求22-套餐临期提醒.md) |
|
||||
|
||||
需求16已经移出本期,仅在来源稿中保留讨论记录。
|
||||
|
||||
## 新增需求独立稿
|
||||
|
||||
| 编号 | 来源文件 |
|
||||
|------|----------|
|
||||
| 新增01 | [数据同步触发与轮询优化](./独立方案/新增需求/01-数据同步触发与轮询优化.md) |
|
||||
| 新增02 | [企业微信审批接入](./独立方案/新增需求/02-企业微信审批接入.md) |
|
||||
| 新增03 | [站内通知详细方案](./独立方案/新增需求/03-站内通知详细方案.md) |
|
||||
| 新增04 | [全局多视角审计方案](./独立方案/新增需求/04-全局多视角审计方案.md) |
|
||||
| 新增05 | [代理钱包扫码充值](./独立方案/新增需求/05-代理钱包扫码充值.md) |
|
||||
| 禅道 #98/#86 | [换货归属与资产换货标识](./独立方案/新增需求/06-换货归属与资产换货标识.md) |
|
||||
| 禅道 #96/#97 | [店铺业务员与钱包余额预警](./独立方案/新增需求/07-店铺业务员与钱包余额预警.md) |
|
||||
| 禅道 #43 | [代理系列套餐批量授权](./独立方案/新增需求/08-代理系列套餐批量授权.md) |
|
||||
|
||||
## 基础规范与历史方案
|
||||
|
||||
- [DDD 规范](./独立方案/基础规范/DDD规范.md)
|
||||
- [系统配置独立方案](./独立方案/基础规范/系统配置.md)
|
||||
- [本地通用审批流(已废弃)](./独立方案/历史方案/本地通用审批流-已废弃方案.md)
|
||||
- [站内消息初版(已被详细方案替代)](./独立方案/历史方案/站内消息-初版.md)
|
||||
- [前端共性方案历史稿](./独立方案/历史方案/前端共性方案-历史稿.md)
|
||||
|
||||
原始讨论材料:
|
||||
|
||||
- [原始业务需求](./来源材料/业务需求.md)
|
||||
- [新增需求讨论与示例代码](./来源材料/新需求.md)
|
||||
- [禅道用户需求导出](./物联网卡管系统-需求.csv)
|
||||
|
||||
这些文件只用于追溯,不是技术实施结论。
|
||||
|
||||
已删除的 `00-总览.md` 和 `7月迭代完整技术方案-评审稿.md` 都是重复汇编,不包含独立来源信息。
|
||||
396
docs/7月迭代/来源材料/业务需求.md
Normal file
396
docs/7月迭代/来源材料/业务需求.md
Normal file
@@ -0,0 +1,396 @@
|
||||
> 本文只保留原始业务需求措辞,不作为技术实施结论。最终口径以 [标准评审稿](../7月迭代技术方案-标准评审稿.md) 为准。
|
||||
|
||||
其中有一些需要对接第三方系统的,除了gateway,都可以划分阶段
|
||||
|
||||
|
||||
|
||||
1.当前所有的卡在后台手动操作复机必须要实名,实际上行业卡应当允许未实名复机
|
||||
2. 用户进入H5后需要先绑定手机号后强制先充值后强制实名。这个功能能否进行后台设置,例如这一批设备需要强制先充值后实名,这一批资产可以先实名后充值?
|
||||
3.店铺列表搜索栏新增一项:联系电话,便于搜索
|
||||
4.操作拦截:若当前资产存在退款申请时(但为通过审批时),该资产不允许操作换货。且出现提示:该资产存在退款申请
|
||||
5. 套餐延续创建时选择购买即生效或实名即生效的条件,同时在套餐分配时提供修改条件的功能,并以变更后的条件为最终版本,已经分配出去的套餐不会被后续的宿主套餐修改影响,需要回收后重新分配才能生效
|
||||
6. 在资产详情页增加所有待生效套餐加上生效套餐加起来的最后到期时间
|
||||
7.lot卡管理和设备管理新增已实名/未实名的筛选查询条件
|
||||
8.设备批量分配代理和套餐系列:因设备号不是连号,故需要提供导入excel表的方式进行批量分配代理和套餐系列。excle表头为:设备号
|
||||
|
||||
> 评审结论:拆成“批量分配代理”和“批量分配套餐系列”两个独立命令、两个前端入口和两个任务类型;可复用 Excel 解析与任务基础设施,但单个任务不得同时修改两个字段。
|
||||
9.C端支付时,卡资产只允许支付宝支付以及钱包支付,如果用微信支付就拒绝,设备只允许微信支付以及钱包支付,如果用支付宝支付就拒绝
|
||||
10.限速规则:根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则,限速接口由gateway提供
|
||||
|
||||
> 技术口径说明:Gateway 只支持按 `cardNo` 限速,不存在设备级限速。单卡直接使用 ICCID;设备场景先查 `tb_device_sim_binding.is_current=true` 的当前卡,再使用该卡 ICCID。取消限速仍重复调用同一个限速接口,只是发送取消参数。本期仅提供后台手动设置/取消,内部单位为 `kbps`;不做套餐字段和自动限速规则。
|
||||
|
||||
11. 资产信息详情字段新增:资产信息页面卡信息/设备信息板块,将当前生效套餐的过期时间作为一个字段显示且套餐还剩15天到期时该字段高亮显示。
|
||||
12. 换货管理:
|
||||
| 编号 | 需求 |
|
||||
| ------- | ---------------------------------------------------- |
|
||||
| EXC-001 | 修正换货列表旧资产标识和新资产标识显示混乱问题。 |
|
||||
| EXC-002 | 换货列表中旧资产标识符和新资产标识符均显示为 ICCID。 |
|
||||
| EXC-003 | 旧资产查询支持 ICCID、接入号、虚拟号。 |
|
||||
| EXC-004 | 新资产查询支持 ICCID、接入号、虚拟号。 |
|
||||
13.列表字段新增:
|
||||
| 编号 | 模块 | 新增字段 |
|
||||
| ------- | ------------ | -------------- |
|
||||
| COL-001 | 退款管理列表 | 提交人、审批人 |
|
||||
| COL-002 | 代理充值列表 | 提交人、审批人 |
|
||||
| COL-003 | 换号管理列表 | 提交人 |
|
||||
14. 导出功能:
|
||||
## 6.8.1 lot 卡导出
|
||||
|
||||
### 支持套餐临期30天内所有资产的导出。字段按照卡/设备的导出表进行导出。
|
||||
|
||||
| 编号 | 需求 |
|
||||
| -------- | ---------------------------- |
|
||||
| EXPD-001 | lot 卡导出字段新增套餐名称。 |
|
||||
| EXPD-002 | lot 卡导出字段新增使用流量。 |
|
||||
| EXPD-003 | lot 卡导出字段新增剩余流量。 |
|
||||
|
||||
## 6.8.2 代理资金概况-预充值钱包流水导出
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ------------------- | ------------------------------------------------------------ |
|
||||
| 店铺名称 | 代理店铺名称 |
|
||||
| 交易类型 | 充值、扣款、退款等 |
|
||||
| 交易金额 | 以元为单位 |
|
||||
| 状态 | 交易状态 |
|
||||
| 资产类型 | 卡/设备等 |
|
||||
| 资产标识 | ICCID/设备号等 |
|
||||
| 交易时间 | 流水生成时间 |
|
||||
| 交易前金额 | 交易前余额 |
|
||||
| 交易后金额 | 交易后余额 |
|
||||
| 购买套餐名称 | 资产此条扣款记录对应的套餐名称 |
|
||||
| 操作人 | 明确交易执行主体:代理账号 / 平台账号(明确账号名称) |
|
||||
| 交易 ID | 每一笔流水的全局唯一主键,彻底避免重复流水、对账串号、精准定位单条交易 |
|
||||
| 关联业务订单号 | 充值、扣款、退款等交易对应的原始业务订单编号 |
|
||||
| 交易渠道 / 支付方式 | 明确交易来源:余额支付等 |
|
||||
|
||||
## 6.8.3 套餐列表导出
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 |
|
||||
| -------------- |
|
||||
| 套餐编码 |
|
||||
| 套餐名称 |
|
||||
| 套餐系列名称 |
|
||||
| 套餐类型 |
|
||||
| 套餐时长(月) |
|
||||
| 套餐时长说明 |
|
||||
| 套餐周期类型 |
|
||||
| 套餐天数 |
|
||||
| 真流量额度(MB) |
|
||||
| 虚流量额度(MB) |
|
||||
| 是否启用虚流量 |
|
||||
| 虚流量比例 |
|
||||
| 流量重置周期 |
|
||||
| 到期时间基准 |
|
||||
| 成本价(元) |
|
||||
| 建议售价(元) |
|
||||
| 价格配置状态 |
|
||||
| 状态 |
|
||||
| 上架状态 |
|
||||
| 是否赠送套餐 |
|
||||
| 创建人ID |
|
||||
| 更新人ID |
|
||||
| 创建时间 |
|
||||
| 更新时间 |
|
||||
| 删除时间 |
|
||||
|
||||
## 6.8.4 退款管理退款列表导出
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 |
|
||||
| ---------------- |
|
||||
| 退款单号 |
|
||||
| 代理店铺名称 |
|
||||
| 关联的支付订单号 |
|
||||
| 资产类型 |
|
||||
| 资产标识 |
|
||||
| 套餐名称 |
|
||||
| 原订单金额 |
|
||||
| 实收金额 |
|
||||
| 可退金额 |
|
||||
| 申请退款金额 |
|
||||
| 实际退款金额 |
|
||||
| 退款到账方式 |
|
||||
| 状态 |
|
||||
| 退款原因 |
|
||||
| 备注 |
|
||||
| 审批备注 |
|
||||
| 退款申请时间 |
|
||||
| 退款完成时间 |
|
||||
| 提交人 |
|
||||
| 部门领导审批人 |
|
||||
| 财务审批人 |
|
||||
| 退款凭证 |
|
||||
|
||||
## 6.8.5 换货管理导出
|
||||
|
||||
### C端客户有自己的唯一标识码。换货管理可以针对该C端客户记录该客户换过几次设备或卡。同时资产本身也做换货标识。
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 |
|
||||
| ------------ |
|
||||
| 换货单号 |
|
||||
| 换货类型 |
|
||||
| 换货原因 |
|
||||
| 问题描述 |
|
||||
| 旧资产类型 |
|
||||
| 旧资产标识符 |
|
||||
| 新资产标识符 |
|
||||
| 收货人姓名 |
|
||||
| 收货人电话 |
|
||||
| 收货地址 |
|
||||
| 快递公司 |
|
||||
| 快递单号 |
|
||||
| 状态 |
|
||||
| 创建人 |
|
||||
| 创建时间 |
|
||||
|
||||
## 6.8.6 代理充值导出
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 |
|
||||
| -------------- |
|
||||
| 充值单号 |
|
||||
| 店铺名称 |
|
||||
| 充值类型 |
|
||||
| 充值金额 |
|
||||
| 实付金额 |
|
||||
| 充值前余额 |
|
||||
| 充值后余额 |
|
||||
| 状态 |
|
||||
| 支付方式 |
|
||||
| 支付通道 |
|
||||
| 运营备注 |
|
||||
| 驳回原因 |
|
||||
| 创建时间 |
|
||||
| 支付时间 |
|
||||
| 完成时间 |
|
||||
| 提交人 |
|
||||
| 部门领导审批人 |
|
||||
| 财务审批人 |
|
||||
| 支付凭证 |
|
||||
| 备注 |
|
||||
|
||||
| 套餐时长说明 | :这是剩余天数
|
||||
|
||||
| 退款到账方式 |
|
||||
|
||||
这个好像没有 :
|
||||
|
||||
### 之后是否需要增加?因加入财务审批操作退款,可能有原路退回或不同的退款方式如支付宝退款或微信退款?
|
||||
|
||||
| 部门领导审批人 |
|
||||
|
||||
| 财务审批人 |
|
||||
|
||||
这两个好像也没有
|
||||
|
||||
### 目前是没有呀,但是之前不是说了审批流程需要加上吗?列表字段同时需要新增
|
||||
|
||||
| 支付通道 |
|
||||
|
||||
这是啥玩意
|
||||
|
||||
### 去掉
|
||||
|
||||
"导出功能可以根据不同权限显示的字段不同且可以自己选择需要导出的字段。像现在这样全量导出,大家都可以看到虚流量等不想让所有人都看到的字段。" 什么叫不同权限,这个不同权限显示字段不同是固定的吗,平台就是固定的,代理就是固定的等等,如果是这样的话需要标记对应的导出字段的权限划分
|
||||
|
||||
### 因为不同的角色,权限不一致,列表的字段不能给每个用户看到类似真流量这样的字段,比如客服只能看到虚流量跟客户看到的一样,应当在角色管理中新增一个导出字段配置
|
||||
|
||||
> 评审结论:角色级导出字段配置属于本期范围。后端返回当前角色允许导出的字段,最终导出字段取“角色授权字段”与“用户本次选择字段”的交集,前端不能绕过服务端授权。
|
||||
|
||||
|
||||
15. 套餐相关: 套餐下架后,正在使用该套餐的客户仍可续费。,下架套餐续费仅支持客户自己购买。 ,下架套餐不可被新购买。
|
||||
16. 代理分销码与佣金提现
|
||||
|
||||
> 迭代范围变更(2026-07-14):需求 16 整体移出 7 月迭代,后续独立立项和评审。以下内容仅保留原始需求记录,本期不开发、不迁移、不发布。
|
||||
|
||||
### 员工可作为代理发展人进行标识。(在系统中如何体现?)
|
||||
|
||||
| 编号 | 需求 |
|
||||
| ------- | ---------------------------------------------------- |
|
||||
| DST-001 | 新建代理时自动建立分销归属关系。 |
|
||||
| DST-002 | 员工可作为代理发展人进行标识。(在系统中如何体现?) |
|
||||
| DST-003 | 佣金提现前,代理必须签署合同。(必填) |
|
||||
| DST-004 | 佣金提现需上传营业执照。(可选) |
|
||||
| DST-005 | 佣金提现需上传法人身份证。(必填) |
|
||||
| DST-006 | 佣金提现可选上传门头照。(可选) |
|
||||
| DST-007 | 佣金提现需上传发票,且公司主体需与合同一致。(可选) |
|
||||
|
||||
> 当前结论:原技术方案不再属于 7 月迭代范围。后续重新立项时再评审分销关系、代理申请审批、开店幂等和提现材料。
|
||||
|
||||
17. 不同渠道信用额度,钱包支持信用额度支付
|
||||
| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| BPO-009 | 新建代理时新增“是否可授权额度”开关。平台用户账号默认拥有授权额度。 |
|
||||
| BPO-010 | 不同代理可设置不同额度下限。平台用户可根据不同的角色设置不同的额度下限。 |
|
||||
| BPO-011 | 授权额度用于订购套餐、代理余额充值等需要涉及金额的所有模块。 |
|
||||
| BPO-012 | 授权额度代理可显示负数余额。平台用户也可显示负数余额。 |
|
||||
|
||||
> 评审结论:信用额度只属于代理主钱包。平台员工是操作主体而不是结算主体,不建立员工钱包、不配置员工信用额度,也不展示员工负余额。客户角色可以配置以后新建店铺的默认额度;修改角色不更新已有店铺,已有店铺只能在店铺资金页面直接调整实际额度。
|
||||
|
||||
18. 系统原先对于审核都是单人审核,现在希望增加多人审批以及相关设置
|
||||
| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| APR-001 | 代理可在系统提交充值申请。 |
|
||||
| APR-002 | 提交充值申请后系统展示收款二维码,代理扫码支付。 |
|
||||
| APR-003 | 充值和退款均支持多级审核。 |
|
||||
| APR-004 | 审核环节包括提交人部门领导审核和财务审核。 |
|
||||
| APR-005 | 待审核订单需有消息提示。 |
|
||||
| APR-006 | 审核提醒按流程环节触发,上一审批人完成审批后才提示下一审批人。 |
|
||||
| APR-007 | 审核通过后通知申请人。 |
|
||||
| APR-008 | 审核驳回后通知申请人,并附带驳回原因。 |
|
||||
| APR-009 | 审核流程需对接企业微信审批流程。 |
|
||||
|
||||
> 技术口径说明:当前系统没有部门组织模型。“部门领导审核、财务审核”作为默认流程节点名称处理,实际审批人由流程定义配置的角色或指定账号产生,不根据提交人部门自动推导,也不在代码中固定角色名称。每次通过、驳回、退回都形成不可修改的审批意见记录,并可附带最多 5 个审批附件;驳回和退回意见必填。
|
||||
|
||||
> 审批详情必须展示业务单号、提交人、审批关键字段、业务资料、此前审批人的意见和审批附件。业务资料与审批附件分开存储和展示;流程启动时固化业务快照,实时业务页仍按原业务数据范围校验。
|
||||
|
||||
19.批量订购套餐
|
||||
内部员工进入批量订购页面。
|
||||
2. 选择代理、ICCID号段/设备号、订购套餐。或上传 Excel 文件,资产标识支持 ICCID/设备号
|
||||
3. Excel表头:跳转至6.3会显示。
|
||||
4. 系统校验导入文件和资产状态。
|
||||
5. 校验通过的资产从代理余额扣款并完成订购。
|
||||
6. 校验失败的明细在页面展示失败原因。
|
||||
7. 系统记录操作员、导入明细、导入数量和导入时间。
|
||||
|
||||
| 编号 | 需求 |
|
||||
| ------- | ---------------------------------------------------- |
|
||||
| BPO-001 | 批量订购由内部员工操作。 |
|
||||
| BPO-002 | 支持代理自行充值钱包后,由员工批量订购并从余额扣款。 |
|
||||
| BPO-003 | 支持员工代充值至代理余额后,再批量订购并从余额扣款。 |
|
||||
| BPO-004 | 批量订购无需审核。 |
|
||||
| BPO-005 | 资产标识支持 ICCID/设备号。 |
|
||||
| BPO-006 | 支持 Excel 模板导入。 |
|
||||
| BPO-007 | 需记录操作员、导入明细、导入数量和导入时间。 |
|
||||
| BPO-008 | 导入失败明细需展示在页面,并显示失败原因。 |
|
||||
|
||||
excel表导入字段:
|
||||
| 字段 |
|
||||
| ----------------------------- |
|
||||
| 资产类型 |
|
||||
| 资产标识 |
|
||||
| 套餐系列名称 |
|
||||
| 套餐名称 |
|
||||
| 代理名称 |
|
||||
| 支付方式:代理商账户/员工账户 |
|
||||
|
||||
excel表导入字段修改为以下:
|
||||
|
||||
| 字段 |
|
||||
|
||||
| ----------------------------- |
|
||||
|
||||
| 资产类型 |
|
||||
|
||||
| 资产标识 |
|
||||
|
||||
| 套餐编码 |
|
||||
|
||||
| 套餐名称|
|
||||
|
||||
| 支付方式:线下支付/代理钱包支付 |
|
||||
|
||||
如果用批量订购应当上传对应凭证
|
||||
|
||||
> 评审结论:支付方式按整批统一。页面选择“线下支付”或“代理钱包支付”,Excel 不再包含支付方式列;线下支付按整批上传凭证,混合支付必须拆成不同批次。
|
||||
|
||||
20.退款审批
|
||||
(审批均可通过企微进行提醒和显示并将最新状态同步至卡管)
|
||||
1. 员工提交退款申请。
|
||||
2. 退款单进入多级审核流程。
|
||||
3. 按部门领导、财务顺序审批。
|
||||
4. 当前环节审批完成后,下一环节审批人收到消息提示。
|
||||
5. 审批通过或驳回后通知申请人。
|
||||
|
||||
> 评审结论:微信、支付宝和线下退款由财务在系统外人工完成后确认;本期不接入第三方自动退款。代理钱包支付的退款仅自动回退原扣款代理主钱包,客户资产钱包不在本期自动回退范围。
|
||||
21. 充值审核流程
|
||||
代理自己充值:
|
||||
1. 代理在系统提交充值申请。
|
||||
2. 系统展示收款二维码。
|
||||
3. 代理扫码支付。
|
||||
|
||||
员工代充值:(审批均可通过企微进行提醒和显示并将最新状态同步至卡管)
|
||||
1. 充值单进入多级审核流程。
|
||||
2. 提交人部门领导先审批。
|
||||
3. 财务在上一审批人通过后收到待办提醒并审批。
|
||||
4. 审批通过后通知申请人。
|
||||
5. 审批驳回后通知申请人,并展示驳回原因。
|
||||
|
||||
> 技术口径说明:审批通过只表示审批结论成立,不表示退款已经到账或充值已经入账。退款、充值分别维护业务处理状态并支持异步重试。此次采用停机发布,旧退款业务单审批接口和线下充值 `offline-pay/reject` 不保留兼容窗口。
|
||||
|
||||
22. 套餐临期提醒
|
||||
1. 系统每日计算卡/设备套餐剩余有效期。
|
||||
2. 命中临期规则后生成临期数据。临期提醒规则:按 15 天、7 天、3 天节点分别进行不同方式的提醒。
|
||||
3. 企业客户场景:按 15 天、7 天、3 天节点生成临期列表,并通过企业微信推送给对应业务员。
|
||||
4. 代理端场景(展示):首页展示卡、设备临期数量;资产列表按临期天数高亮。
|
||||
5. C 端场景(展示):公众号首页在套餐剩余有效期小于或等于 15天时展示续费提醒,并显示立即续费按钮。
|
||||
6. 当资产续费成功或不再满足临期条件时,临期提醒自动取消。
|
||||
## 6.1.1 企业客户临期提醒
|
||||
| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| EXP-001 | 后台每日生成企业客户临期列表。 |
|
||||
| EXP-002 | 临期节点包括 15 天、7 天、3 天。 |
|
||||
| EXP-003 | 系统需对接企业微信,将临期列表推送给对应业务员。 |
|
||||
| EXP-004 | 同一资产在不同临期节点可重复触发对应节点提醒,但同一节点每日不可重复推送给同一业务员。 |
|
||||
|
||||
## 6.1.2 代理端临期提醒
|
||||
|
||||
| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| EXP-005 | 代理端首页分别展示临期卡数量和临期设备数量。 |
|
||||
| EXP-006 | 代理端资产列表对临期资产进行颜色标记。 |
|
||||
| EXP-007 | 剩余 15 天标记为粉色,剩余 7 天标记为紫色,剩余 3 天标记为红色。 |
|
||||
| EXP-008 | 剩余 3 天资产在列表中置顶优先展示。 |
|
||||
|
||||
## 6.1.3 C 端公众号首页提醒
|
||||
|
||||
| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| EXP-009 | 当客户套餐剩余有效期小于或等于 15 天时,公众号首页展示套餐到期提醒。 |
|
||||
| EXP-010 | 提醒展示卡号/设备号、剩余有效期和续费引导文案。 |
|
||||
| EXP-011 | 按钮文案为“立即续费”。 |
|
||||
| EXP-012 | 剩余天数需要按日期每天自动更新。 |
|
||||
| EXP-013 | 当套餐已续费或不再满足临期条件时,提醒不再展示。 |
|
||||
推荐展示文案:
|
||||
您的套餐即将到期
|
||||
|
||||
卡号/设备号:xxx
|
||||
剩余有效期:xx 天
|
||||
|
||||
为避免到期后影响正常使用,请您提前完成续费。
|
||||
|
||||
当一个资产拥有排队中的套餐时不属于临期,不参与临期提醒
|
||||
|
||||
后台管理只需要在列表检索中新增临期时间字段 条件为小于等于15天的
|
||||
|
||||
后台管理中资产详情需要新增一个字段临期时间从15天开始显示,前端应当高亮或者变红
|
||||
|
||||
后台管理中资产列表需要新增返回字段,套餐还有多少天过期
|
||||
|
||||
临期列表页面需要确认列表展示什么,检索有什么,导出要什么
|
||||
|
||||
##### 展示:资产标识、资产类型、店铺、套餐名称、剩余天数、到期时间、资产状态、已用流量、剩余流量
|
||||
|
||||
##### 检索条件:资产标识、资产类型、套餐名称、到期时间范围、剩余天数、店铺
|
||||
|
||||
##### 导出:资产标识、资产类型、店铺名称、套餐名称、套餐到期时间、剩余天数、资产状态、已用流量、剩余流量
|
||||
|
||||
临期规则
|
||||
企业客户 15,7,3天
|
||||
代理 15,7,3天
|
||||
C端公众号 小于等于15天
|
||||
|
||||
颜色规则
|
||||
临期天数小于等于15天时,显示粉红色;
|
||||
临期天数小于等于7天时,显示紫色;
|
||||
临期天数小于等于3天时,显示黄色;
|
||||
|
||||
> 最终评审口径:资产临期按当前及排队主套餐的预计最终到期时间计算;3天内改为红色,只在临期独立列表置顶。七月迭代不发送企业微信临期消息,只发送站内通知。
|
||||
729
docs/7月迭代/来源材料/新需求.md
Normal file
729
docs/7月迭代/来源材料/新需求.md
Normal file
@@ -0,0 +1,729 @@
|
||||
> 本文保留新增需求讨论和示例代码,不作为技术实施结论。最终口径以 [标准评审稿](../7月迭代技术方案-标准评审稿.md) 及对应新增需求独立稿为准。
|
||||
|
||||
处理回调的代码
|
||||
```golang
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/ioutil"
|
||||
"log"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
ranNumLib "math/rand"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// XML请求体结构
|
||||
type ContractRoot struct {
|
||||
XMLName xml.Name `xml:"ContractRoot"`
|
||||
Type string `xml:"TYPE"`
|
||||
GroupTransactionID string `xml:"GROUP_TRANSACTIONID"`
|
||||
StatusInfo string `xml:"STATUSINFO"`
|
||||
AccNbr string `xml:"ACCNBR"`
|
||||
ICCID string `xml:"ICCID"`
|
||||
SendDt string `xml:"SENDDT"`
|
||||
AcceptType string `xml:"ACCEPTTYPE"`
|
||||
AcceptMsg string `xml:"ACCEPTMSG"`
|
||||
StatusDt string `xml:"STATUSDT"`
|
||||
ResultMsg string `xml:"RESULTMSG"`
|
||||
}
|
||||
|
||||
// 第三方推送数据结构
|
||||
type PushData struct {
|
||||
Msg string `json:"msg"`
|
||||
Code int `json:"code"`
|
||||
Data struct {
|
||||
RequestID string `json:"requestId"`
|
||||
RealStatus bool `json:"realStatus"`
|
||||
ICCID string `json:"iccid"`
|
||||
} `json:"data"`
|
||||
}
|
||||
|
||||
func parseCallback(jsonStr []byte) (string, string, error) {
|
||||
// 定义匿名结构体用于解析外层JSON
|
||||
var cb struct {
|
||||
Data string `json:"data"`
|
||||
}
|
||||
err := json.Unmarshal(jsonStr, &cb)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to unmarshal outer JSON: %v", err)
|
||||
}
|
||||
|
||||
// 定义匿名结构体用于解析内层数据
|
||||
var inner struct {
|
||||
DateChanged string `json:"dateChanged"`
|
||||
ICCID string `json:"iccid"`
|
||||
}
|
||||
err = json.Unmarshal([]byte(cb.Data), &inner)
|
||||
if err != nil {
|
||||
return "", "", fmt.Errorf("failed to unmarshal inner data JSON: %v", err)
|
||||
}
|
||||
|
||||
return inner.ICCID, inner.DateChanged, nil
|
||||
}
|
||||
|
||||
// 5GCMP实名后推送到第三方平台
|
||||
func pushToThirdParty(iccid string) (string, error) {
|
||||
// 构建推送数据
|
||||
pushData := PushData{
|
||||
Msg: "查询成功",
|
||||
Code: 200,
|
||||
}
|
||||
pushData.Data.RequestID = uuid.New().String()
|
||||
pushData.Data.RealStatus = true
|
||||
pushData.Data.ICCID = iccid
|
||||
|
||||
// 序列化为JSON
|
||||
jsonData, err := json.Marshal(pushData)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("序列化推送数据失败: %v", err)
|
||||
}
|
||||
|
||||
// 发送HTTP POST请求
|
||||
pushURL := "http://jh.whjhft.com/gswlpushapi/recv.do?type=3"
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
resp, err := client.Post(pushURL, "application/json", bytes.NewBuffer(jsonData))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("HTTP请求失败: %v", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取响应失败: %v", err)
|
||||
}
|
||||
|
||||
return string(respBody), nil
|
||||
}
|
||||
|
||||
// 获取日志文件路径
|
||||
func getLogFilePath() string {
|
||||
// 创建logs目录
|
||||
logsDir := "logs"
|
||||
if err := os.MkdirAll(logsDir, 0755); err != nil {
|
||||
log.Printf("创建日志目录失败: %v", err)
|
||||
}
|
||||
|
||||
// 按日期生成文件名
|
||||
dateStr := time.Now().Format("2006-01-02")
|
||||
fileName := fmt.Sprintf("5gcmp_callback_%s.log", dateStr)
|
||||
return filepath.Join(logsDir, fileName)
|
||||
}
|
||||
|
||||
func GetQcRandNum() (ranNum string) {
|
||||
randomFloat := ranNumLib.Float64()
|
||||
if randomFloat < 0.5 {
|
||||
randomFloat = 1 - randomFloat
|
||||
}
|
||||
ranNum = fmt.Sprintf("%.16f", randomFloat)
|
||||
return
|
||||
}
|
||||
|
||||
// 写入日志
|
||||
func writeLog(content string) {
|
||||
logFile := getLogFilePath()
|
||||
|
||||
// 打开或创建日志文件
|
||||
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
log.Printf("打开日志文件失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
// 写入日志内容
|
||||
if _, err := file.WriteString(content); err != nil {
|
||||
log.Printf("写入日志失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 向管理平台发送删除实名请求
|
||||
func DelRealName(iccid string) (res string) {
|
||||
account := Account{UserName: "18627991016", Password: "y123456"}
|
||||
sessionid := account.Login()
|
||||
realnameId := getRealnameIdByIccid(iccid, sessionid)
|
||||
if realnameId == "" {
|
||||
return
|
||||
}
|
||||
log.Printf("sessinid:%s,realnameId:%s", sessionid, realnameId)
|
||||
url := "http://jh.whjhft.com/realnamerecord/deleteById.do?responseFunction=initUpdate&id=" + realnameId + "&rfm=" + GetQcRandNum()
|
||||
data := fmt.Sprintf("status=1&iccidMark=%s", iccid)
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(data))
|
||||
if err != nil {
|
||||
log.Printf("创建请求失败: %v", err)
|
||||
return ""
|
||||
}
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("HTTP请求失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("读取响应失败: %v", err)
|
||||
return
|
||||
}
|
||||
log.Printf("删除实名响应: %s", respBody)
|
||||
res = string(respBody)
|
||||
return
|
||||
}
|
||||
|
||||
func getRealnameIdByIccid(iccid, sessionid string) (realnameId string) {
|
||||
url := "http://jh.whjhft.com/realnamerecord/grid.do?responseFunction=grid&pageSize=15&pageNo=1&rfm=0." + GetQcRandNum()
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
data := fmt.Sprintf("status=1&iccidMark=%s", iccid)
|
||||
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(data))
|
||||
if err != nil {
|
||||
log.Printf("创建请求失败: %v", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("HTTP请求失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("读取响应失败: %v", err)
|
||||
return
|
||||
}
|
||||
|
||||
var JSONData struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
PageNo int `json:"pageNo"`
|
||||
PageCount int `json:"pageCount"`
|
||||
PageSize int `json:"pageSize"`
|
||||
PageStartOffset int `json:"pageStartOffset"`
|
||||
Total int `json:"total"`
|
||||
Rows []struct {
|
||||
MybatisRecordCount int `json:"mybatisRecordCount"`
|
||||
OrderNo string `json:"orderNo"`
|
||||
JSONUpdateFlag string `json:"jsonUpdateFlag"`
|
||||
ID string `json:"id"`
|
||||
IccidMark string `json:"iccidMark"`
|
||||
Phone string `json:"phone"`
|
||||
AccountID string `json:"accountId"`
|
||||
AccountName string `json:"accountName"`
|
||||
Status int `json:"status"`
|
||||
CreateName string `json:"createName"`
|
||||
CreateDate string `json:"createDate"`
|
||||
StatusStr string `json:"statusStr"`
|
||||
} `json:"rows"`
|
||||
Framework string `json:"framework"`
|
||||
Data string `json:"data"`
|
||||
Count int `json:"count"`
|
||||
Limit int `json:"limit"`
|
||||
Page int `json:"page"`
|
||||
Layui bool `json:"layui"`
|
||||
} `json:"data"`
|
||||
CurrentSessionUserResourceIdsIndex []string `json:"current_session_user_resource_ids_index"`
|
||||
AppResultKey string `json:"app_result_key"`
|
||||
SystemResultKey string `json:"system_result_key"`
|
||||
}
|
||||
json.Unmarshal(respBody, &JSONData)
|
||||
if JSONData.AppResultKey == "0" && JSONData.SystemResultKey == "0" && JSONData.Data.Count > 0 {
|
||||
realnameId = JSONData.Data.Rows[0].ID
|
||||
}
|
||||
|
||||
log.Printf("响应体: %s", respBody)
|
||||
return
|
||||
}
|
||||
|
||||
func getIccidByMsisdn(msisdn, sessionid string) (iccid string) {
|
||||
url := "http://jh.whjhft.com/realnamerecord/grid.do?responseFunction=grid&pageSize=15&pageNo=1&rfm=" + GetQcRandNum()
|
||||
client := &http.Client{
|
||||
Timeout: 10 * time.Second,
|
||||
}
|
||||
|
||||
data := fmt.Sprintf("status=1&phone=%s", msisdn)
|
||||
|
||||
req, err := http.NewRequest("POST", url, strings.NewReader(data))
|
||||
if err != nil {
|
||||
log.Printf("创建请求失败: %v", err)
|
||||
return
|
||||
}
|
||||
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
|
||||
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
|
||||
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
log.Printf("HTTP请求失败: %v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
log.Printf("读取响应失败: %v", err)
|
||||
return
|
||||
}
|
||||
var JSONData struct {
|
||||
Code string `json:"code"`
|
||||
Data struct {
|
||||
Rows []struct {
|
||||
IccidMark string `json:"iccidMark"`
|
||||
} `json:"rows"`
|
||||
Count int `json:"count"`
|
||||
} `json:"data"`
|
||||
AppResultKey string `json:"app_result_key"`
|
||||
SystemResultKey string `json:"system_result_key"`
|
||||
}
|
||||
json.Unmarshal(respBody, &JSONData)
|
||||
if JSONData.AppResultKey == "0" && JSONData.SystemResultKey == "0" && JSONData.Data.Count > 0 {
|
||||
iccid = JSONData.Data.Rows[0].IccidMark
|
||||
}
|
||||
log.Printf("响应体: %s", respBody)
|
||||
return
|
||||
}
|
||||
|
||||
func ModifyDate(iccid, dateChanged string) {
|
||||
var jsonData = map[string]interface{}{
|
||||
"iccid": iccid,
|
||||
"dateChanged": dateChanged,
|
||||
}
|
||||
jsonDataBs, _ := json.Marshal(jsonData)
|
||||
|
||||
// 创建请求
|
||||
req, err := http.NewRequest("POST", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", bytes.NewReader(jsonDataBs))
|
||||
if err != nil {
|
||||
writeLog(fmt.Sprintf("创建请求失败:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
|
||||
return
|
||||
}
|
||||
|
||||
// 设置Content-Type为x-www-form-urlencoded
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
// 发送请求
|
||||
client := &http.Client{}
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 读取响应内容
|
||||
respBody, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
|
||||
return
|
||||
}
|
||||
|
||||
// 检查响应状态
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
fmt.Printf("请求失败,状态码: %d, 响应: %s\n", resp.StatusCode, string(respBody))
|
||||
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
|
||||
return
|
||||
}
|
||||
|
||||
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]成功,[%s]\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged, string(respBody)))
|
||||
|
||||
}
|
||||
|
||||
// 处理回调请求
|
||||
func handleCallback(w http.ResponseWriter, r *http.Request) {
|
||||
// 记录请求开始时间
|
||||
startTime := time.Now()
|
||||
timestamp := startTime.Format("2006-01-02 15:04:05")
|
||||
|
||||
// 构建日志内容
|
||||
var logBuilder strings.Builder
|
||||
logBuilder.WriteString("\n========================================\n")
|
||||
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
|
||||
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
|
||||
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
|
||||
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
|
||||
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
|
||||
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
|
||||
|
||||
// 记录请求头
|
||||
logBuilder.WriteString("--- 请求头 ---\n")
|
||||
for name, values := range r.Header {
|
||||
for _, value := range values {
|
||||
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
|
||||
}
|
||||
}
|
||||
|
||||
// 记录请求体
|
||||
logBuilder.WriteString("--- 请求体 ---\n")
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
|
||||
} else {
|
||||
if len(body) > 0 {
|
||||
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
|
||||
} else {
|
||||
logBuilder.WriteString("(空请求体)\n")
|
||||
}
|
||||
}
|
||||
|
||||
// 处理XML请求体和第三方推送
|
||||
var pushResponse string
|
||||
|
||||
log.Printf("body:%s\r\n", string(body))
|
||||
|
||||
if len(body) > 0 {
|
||||
// 尝试解析XML
|
||||
var contractRoot ContractRoot
|
||||
if err := xml.Unmarshal(body, &contractRoot); err == nil {
|
||||
logBuilder.WriteString("--- XML解析结果 ---\n")
|
||||
logBuilder.WriteString(fmt.Sprintf("TYPE: %s\n", contractRoot.Type))
|
||||
logBuilder.WriteString(fmt.Sprintf("ICCID: %s\n", contractRoot.ICCID))
|
||||
logBuilder.WriteString(fmt.Sprintf("STATUSINFO: %s\n", contractRoot.StatusInfo))
|
||||
|
||||
// 如果TYPE=1,表示实名认证成功,需要推送到第三方
|
||||
if strings.Contains(contractRoot.AcceptMsg, "已完成实名信息补录") && contractRoot.ICCID != "" && contractRoot.ResultMsg == "成功" {
|
||||
logBuilder.WriteString("--- 第三方推送 ---\n")
|
||||
logBuilder.WriteString(fmt.Sprintf("触发条件: TYPE=%s (实名认证成功)\n", contractRoot.Type))
|
||||
logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", contractRoot.ICCID))
|
||||
logBuilder.WriteString("推送地址: http://jh.whjhft.com/gswlpushapi/recv.do?type=3\n")
|
||||
|
||||
timestampStr := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
ModifyDate(contractRoot.ICCID, timestampStr)
|
||||
|
||||
// 执行推送
|
||||
if resp, err := pushToThirdParty(contractRoot.ICCID); err != nil {
|
||||
logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err))
|
||||
pushResponse = fmt.Sprintf("推送失败: %v", err)
|
||||
} else {
|
||||
logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp))
|
||||
pushResponse = resp
|
||||
}
|
||||
} else if strings.Contains(contractRoot.AcceptMsg, "已完成实名信息清除") && contractRoot.ICCID != "" && contractRoot.ResultMsg == "成功" {
|
||||
logBuilder.WriteString("--- 第三方推送删除实名 ---\n")
|
||||
res := DelRealName(contractRoot.ICCID)
|
||||
logBuilder.WriteString(fmt.Sprintf("删除实名响应: %s\n", res))
|
||||
} else {
|
||||
logBuilder.WriteString("--- 第三方推送 ---\n")
|
||||
logBuilder.WriteString(fmt.Sprintf("跳过推送: TYPE=%s,%s,%s (非实名认证成功)\n", contractRoot.Type, contractRoot.AcceptMsg, contractRoot.ResultMsg))
|
||||
}
|
||||
} else {
|
||||
logBuilder.WriteString(fmt.Sprintf("XML解析失败: %v\n", err))
|
||||
}
|
||||
}
|
||||
|
||||
// 记录处理时间
|
||||
processTime := time.Since(startTime)
|
||||
logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime))
|
||||
logBuilder.WriteString("========================================\n")
|
||||
|
||||
// 写入日志文件
|
||||
writeLog(logBuilder.String())
|
||||
|
||||
// 同时输出到控制台
|
||||
fmt.Print(logBuilder.String())
|
||||
|
||||
// 返回成功响应
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// 构建响应数据
|
||||
responseData := map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
|
||||
// 如果有推送响应,添加到响应中
|
||||
if pushResponse != "" {
|
||||
responseData["pushResponse"] = pushResponse
|
||||
}
|
||||
|
||||
respJSON, _ := json.Marshal(responseData)
|
||||
w.Write(respJSON)
|
||||
}
|
||||
|
||||
// 获取管理平台登录凭证
|
||||
func (ac Account) Login() (sessionid string) {
|
||||
var password string
|
||||
password = ac.Password
|
||||
var requestBody bytes.Buffer
|
||||
multipartWriter := multipart.NewWriter(&requestBody)
|
||||
multipartWriter.WriteField("username", ac.UserName)
|
||||
multipartWriter.WriteField("password", password)
|
||||
multipartWriter.Close()
|
||||
req, _ := http.NewRequest("POST", "http://jh.whjhft.com/pages/login.do", &requestBody)
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
|
||||
req.Header.Set("Content-Type", multipartWriter.FormDataContentType())
|
||||
//client1 := http.DefaultClient
|
||||
client1 := &http.Client{
|
||||
CheckRedirect: func(req1 *http.Request, via []*http.Request) error {
|
||||
|
||||
strs := strings.Split(req1.URL.Path, ";")
|
||||
log.Printf("%s\r\n", req1.URL.Path)
|
||||
if len(strs) == 2 {
|
||||
strs1 := strings.Split(strs[1], "=")
|
||||
if len(strs1) == 2 {
|
||||
sessionid = strs1[1]
|
||||
}
|
||||
}
|
||||
// fmt.Printf("Redirect from '%s' to '%s'\n", via[0].URL, req1.URL.Path)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
resp, err := client1.Do(req)
|
||||
if err != nil {
|
||||
fmt.Println("Failed to send request:", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
// 处理响应
|
||||
_, err = ioutil.ReadAll(resp.Body)
|
||||
|
||||
if err != nil {
|
||||
fmt.Println("Failed to read response:", err)
|
||||
return
|
||||
}
|
||||
//fmt.Println("Response:", string(respBody))
|
||||
return
|
||||
}
|
||||
|
||||
// 移动实名回调
|
||||
func ChinaMobileCallback(w http.ResponseWriter, r *http.Request) {
|
||||
// 记录请求开始时间
|
||||
startTime := time.Now()
|
||||
timestamp := startTime.Format("2006-01-02 15:04:05")
|
||||
|
||||
// 构建日志内容
|
||||
var logBuilder strings.Builder
|
||||
logBuilder.WriteString("\n========================================\n")
|
||||
logBuilder.WriteString("【移动实名回调】\n")
|
||||
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
|
||||
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
|
||||
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
|
||||
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
|
||||
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
|
||||
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
|
||||
|
||||
// 记录请求头
|
||||
logBuilder.WriteString("--- 请求头 ---\n")
|
||||
for name, values := range r.Header {
|
||||
for _, value := range values {
|
||||
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
|
||||
}
|
||||
}
|
||||
|
||||
// 记录请求体
|
||||
logBuilder.WriteString("--- 请求体 ---\n")
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
|
||||
} else {
|
||||
if len(body) > 0 {
|
||||
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
|
||||
var JSONData struct {
|
||||
Status string `json:"status"`
|
||||
Message string `json:"message"`
|
||||
Result []struct {
|
||||
RegStatus string `json:"regStatus"`
|
||||
BusiSeq string `json:"busiSeq"`
|
||||
Msisdn string `json:"msisdn"`
|
||||
Iccid string `json:"iccid"`
|
||||
} `json:"result"`
|
||||
}
|
||||
json.Unmarshal(body, &JSONData)
|
||||
if JSONData.Status == "0" && JSONData.Message == "正确" && len(JSONData.Result) > 0 && JSONData.Result[0].RegStatus == "00000" {
|
||||
iccid := JSONData.Result[0].Iccid
|
||||
if iccid == "" {
|
||||
msisdn := JSONData.Result[0].Msisdn //接入号
|
||||
account := Account{UserName: "18627991016", Password: "y123456"}
|
||||
sessionid := account.Login()
|
||||
iccid = getIccidByMsisdn(msisdn, sessionid)
|
||||
}
|
||||
if iccid == "" {
|
||||
return
|
||||
}
|
||||
|
||||
//推送修改过期时间
|
||||
timestampStr := strconv.FormatInt(time.Now().Unix(), 10)
|
||||
ModifyDate(iccid, timestampStr)
|
||||
|
||||
logBuilder.WriteString("--- 第三方推送 ---\n")
|
||||
logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", iccid))
|
||||
logBuilder.WriteString("推送地址: http://jh.whjhft.com/gswlpushapi/recv.do?type=3\n")
|
||||
|
||||
// 执行推送实名状态
|
||||
if resp, err := pushToThirdParty(iccid); err != nil {
|
||||
logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err))
|
||||
} else {
|
||||
logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp))
|
||||
}
|
||||
logBuilder.WriteString("--- 第三方推送 ---\n")
|
||||
|
||||
} else {
|
||||
logBuilder.WriteString("(实名认证失败)\n")
|
||||
}
|
||||
|
||||
} else {
|
||||
logBuilder.WriteString("(空请求体)\n")
|
||||
}
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// 记录处理时间
|
||||
processTime := time.Since(startTime)
|
||||
logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime))
|
||||
logBuilder.WriteString("========================================\n")
|
||||
|
||||
// 写入日志文件
|
||||
writeLog(logBuilder.String())
|
||||
|
||||
// 同时输出到控制台
|
||||
fmt.Print(logBuilder.String())
|
||||
|
||||
// 返回200 OK响应
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusOK)
|
||||
|
||||
// 构建响应数据
|
||||
responseData := map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
|
||||
respJSON, _ := json.Marshal(responseData)
|
||||
w.Write(respJSON)
|
||||
}
|
||||
|
||||
// 联通解除实名回调
|
||||
func UniRealnameRemove(w http.ResponseWriter, r *http.Request) {
|
||||
// 记录请求开始时间
|
||||
startTime := time.Now()
|
||||
timestamp := startTime.Format("2006-01-02 15:04:05")
|
||||
|
||||
// 构建日志内容
|
||||
var logBuilder strings.Builder
|
||||
logBuilder.WriteString("\n========================================\n")
|
||||
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
|
||||
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
|
||||
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
|
||||
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
|
||||
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
|
||||
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
|
||||
|
||||
// 记录请求头
|
||||
logBuilder.WriteString("--- 请求头 ---\n")
|
||||
for name, values := range r.Header {
|
||||
for _, value := range values {
|
||||
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
|
||||
}
|
||||
}
|
||||
|
||||
// 记录请求体
|
||||
logBuilder.WriteString("--- 请求体 ---\n")
|
||||
body, err := io.ReadAll(r.Body)
|
||||
if err != nil {
|
||||
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
|
||||
} else {
|
||||
if len(body) > 0 {
|
||||
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
|
||||
} else {
|
||||
logBuilder.WriteString("(空请求体)\n")
|
||||
}
|
||||
}
|
||||
defer r.Body.Close()
|
||||
|
||||
// 解析回调数据
|
||||
iccid, dateChanged, err := parseCallback(body)
|
||||
if err != nil {
|
||||
logBuilder.WriteString(fmt.Sprintf("解析回调数据失败: %v\n", err))
|
||||
} else {
|
||||
if len(iccid) == 20 {
|
||||
//取前面19位
|
||||
iccid = iccid[:19]
|
||||
//删除实名
|
||||
res := DelRealName(iccid)
|
||||
logBuilder.WriteString(fmt.Sprintf("删除实名响应: %s\n", res))
|
||||
}
|
||||
logBuilder.WriteString(fmt.Sprintf("解析回调数据成功: ICCID=%s, DateChanged=%s\n", iccid, dateChanged))
|
||||
}
|
||||
|
||||
// 写入日志文件
|
||||
writeLog(logBuilder.String())
|
||||
|
||||
// 同时输出到控制台
|
||||
fmt.Print(logBuilder.String())
|
||||
|
||||
// 构建响应数据
|
||||
responseData := map[string]interface{}{
|
||||
"code": 200,
|
||||
"msg": "success",
|
||||
"timestamp": timestamp,
|
||||
}
|
||||
|
||||
respJSON, _ := json.Marshal(responseData)
|
||||
w.Write(respJSON)
|
||||
}
|
||||
|
||||
func main() {
|
||||
// 注册路由
|
||||
// res := DelRealName("8986112422108176397")
|
||||
// log.Printf("删除实名响应: %s", res)
|
||||
|
||||
http.HandleFunc("/5gcmp/callback/realname", handleCallback)
|
||||
http.HandleFunc("/unicom/callback/realname/remove", UniRealnameRemove)
|
||||
http.HandleFunc("/mobile/callback/realname", ChinaMobileCallback)
|
||||
|
||||
// 启动服务器
|
||||
port := ":16159"
|
||||
fmt.Printf("5GCMP回调服务器启动成功\n")
|
||||
fmt.Printf("监听端口: %s\n", port)
|
||||
fmt.Printf("电信回调地址: %s/5gcmp/callback/realname\n", port)
|
||||
fmt.Printf("联通回调地址: %s/unicom/callback/realname/remove\n", port)
|
||||
fmt.Printf("移动回调地址: %s/mobile/callback/realname\n", port)
|
||||
fmt.Printf("日志目录: logs/\n")
|
||||
fmt.Printf("按 Ctrl+C 停止服务器\n\n")
|
||||
|
||||
if err := http.ListenAndServe(port, nil); err != nil {
|
||||
log.Fatalf("启动服务器失败: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type Account struct {
|
||||
UserName string
|
||||
Password string
|
||||
}
|
||||
|
||||
```
|
||||
|
||||
|
||||
优化轮询以及添加事件/触发式 数据同步
|
||||
|
||||
目前我们系统过于依赖轮询系统,且轮询系统的黑盒属性过于严重
|
||||
所以现在想加入事件触发以及实名回调,目前已知的可配置实名回调只有移动,联通,电信三个运营商,广电是没有实名回调的,上方是之前已经实现过的实名回调
|
||||
|
||||
需求差不多是这么个需求,主要就是想让数据同步这一块的及时率达到一种很快的地步,看是怎么埋点,而且开放接口的埋点还需要特殊处理,不然的话代理拿着我们的开放接口乱调用的话就等于外置了一个轮询系统了
|
||||
329
docs/7月迭代/物联网卡管系统-需求.csv
Normal file
329
docs/7月迭代/物联网卡管系统-需求.csv
Normal file
@@ -0,0 +1,329 @@
|
||||
"编号","所属产品","所属模块","所属计划","来源","来源备注","用户需求名称","描述","验收标准","需求层级","父需求","关键词","优先级","预计工时","当前状态","所处阶段","类别","T","B","C","由谁创建","创建日期","指派给","指派日期","抄送给","已评审人","评审时间","由谁关闭","关闭日期","关闭原因","最后修改","最后修改日期","反馈者","重复需求","附件"
|
||||
"98 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","换过管理新资产归属","默认换货把旧资产的所属权一起划过去
|
||||
","","1 ","0 ","","3(#3)","0.50 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-15 14:08:57","黄燚麒","2026-07-16 11:51:00","","
|
||||
黄燚麒(#huang)","2026-07-16 11:50:00","","","","黄燚麒","2026-07-16 11:51:00","","","",""
|
||||
"97 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理钱包阈值提醒","代理资金概况板块新增余额预警。不同代理可设置不同的预警额度。达到阈值时可提醒代理和其发展人。
|
||||
","","1 ","0 ","","3(#3)","0.50 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-15 11:23:35","黄燚麒","2026-07-16 15:40:38","","
|
||||
黄燚麒(#huang)","2026-07-16 15:40:00","","","","黄燚麒","2026-07-16 15:40:38","","","",""
|
||||
"96 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","员工作为发展人进行标识","新建店铺添加业务员字段,可以进行指定相应业务员。非必填。同时店铺列表以及详情中新增发展人字段。发展人也可作为检索条件,检索发展人名下的相关店铺。
|
||||
","","1 ","0 ","","2(#2)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-15 11:22:00","黄燚麒","2026-07-15 11:23:15","","
|
||||
黄燚麒(#huang)","2026-07-15 11:23:00","","","","李昕娉","2026-07-15 11:23:42","","","",""
|
||||
"94 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","系统状态同步优化以及回调处理","因资产迁移需要涉及回调和状态同步,故进行相关优化
|
||||
","","1 ","0 ","","1(#1)","40.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-14 10:53:29","黄燚麒","2026-07-14 11:00:14","","
|
||||
黄燚麒(#huang)","2026-07-14 10:59:00","","","","李昕娉","2026-07-14 11:01:12","","","",""
|
||||
"86 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产详情中换货标识","在资产详情页卡信息或设备信息列表中需要显示是否发生过换货或是换货标识,标注新资产或旧资产。旧资产需要可以链接至新资产。<img src="{128.png}" alt="index.php?m=file&f=read&t=png&fileID=128" /><img src="{129.png}" alt="index.php?m=file&f=read&t=png&fileID=129" />
|
||||
","","1 ","0 ","","1(#1)","2.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-13 11:50:17","黄燚麒","2026-07-13 11:54:36","","
|
||||
黄燚麒(#huang)","2026-07-13 11:54:00","","","","黄燚麒","2026-07-13 11:54:36","","","",""
|
||||
"73 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","行业卡操作停复机","行业卡允许未实名复机
|
||||
","","1 ","0 ","","3(#3)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-11 10:02:33","黄燚麒","2026-07-11 14:22:26","","
|
||||
黄燚麒(#huang)","2026-07-11 14:22:00","","","","黄燚麒","2026-07-11 14:22:26","","","",""
|
||||
"62 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","新卡管H5需要设置先充值后实名","用户进入H5后需要先绑定手机号后强制先充值后强制实名。这个功能能否进行后台设置,例如这一批设备需要强制先充值后实名,这一批资产可以先实名后充值?
|
||||
","","1 ","0 ","","3(#3)","2.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 17:04:27","李昕娉","2026-07-09 18:13:07","","
|
||||
黄燚麒(#huang)","2026-07-11 14:23:00","","","","黄燚麒","2026-07-11 14:23:36","","","",""
|
||||
"60 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","店铺管理新增检索条件","店铺列表搜索栏新增一项:联系电话,便于搜索
|
||||
","","1 ","0 ","","3(#3)","0.10 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 16:00:56","黄燚麒","2026-07-09 17:37:57","","
|
||||
黄燚麒(#huang)","2026-07-09 17:37:00","","","","黄燚麒","2026-07-09 17:37:57","","","",""
|
||||
"57 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","操作拦截","若当前资产存在退款申请时(但为通过审批时),该资产不允许操作换货。且出现提示:该资产存在退款申请
|
||||
","","1 ","0 ","","3(#3)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 15:07:17","黄燚麒","2026-07-09 17:32:56","","
|
||||
黄燚麒(#huang)","2026-07-09 17:32:00","","","","李昕娉","2026-07-09 17:33:21","","","",""
|
||||
"55 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","套餐生效条件","套餐延续创建时选择购买即生效或实名即生效的条件,同时在套餐分配时提供修改条件的功能,并以变更后的条件为最终版本
|
||||
","","1 ","0 ","","1(#1)","4.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 11:57:51","黄燚麒","2026-07-09 16:54:53","","
|
||||
黄燚麒(#huang)","2026-07-09 16:54:00","","","","黄燚麒","2026-07-09 16:54:53","","","",""
|
||||
"54 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产详情页面新增套餐到期时间显示","增加所有待生效套餐加上生效套餐加起来的最后到期时间
|
||||
","","1 ","0 ","","2(#2)","","已关闭(#closed)","已关闭(#closed)","功能(#feature)","","","","李昕娉","2026-07-09 11:56:50","closed","2026-07-09 17:26:38","","
|
||||
黄燚麒(#huang)","2026-07-09 17:26:00","黄燚麒","2026-07-09 17:26:38","重复(#duplicate)","黄燚麒","2026-07-09 17:26:38","","#46 资产信息详情字段新增","",""
|
||||
"53 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产管理查询字段新增","lot卡管理和设备管理新增已实名/未实名的筛选查询条件
|
||||
","","1 ","0 ","","1(#1)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 09:39:53","黄燚麒","2026-07-09 16:53:29","","
|
||||
黄燚麒(#huang)","2026-07-09 16:53:00","","","","黄燚麒","2026-07-09 16:53:29","","","",""
|
||||
"51 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","不同品类资产的换货","因换货存在不同资产间的换货,且存在补差价进行卡换设备的操作。但卡的套餐和设备套餐不同会存在补差价等操作。且原卡套餐需失效,新资产设备需要使用设备套餐。直接操作换货,套餐会同步卡套餐,对公司来说成本增加。
|
||||
","","1 ","0 ","","1(#1)","","草稿(#draft)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 17:54:32","李昕娉","2026-07-09 16:53:05","","","2026-07-09 16:52:00","","","","黄燚麒","2026-07-09 16:53:05","","","",""
|
||||
"49 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","设备批量分配代理和套餐系列","因设备号不是连号,故需要提供导入excel表的方式进行批量分配代理和套餐系列。excle表头为:设备号
|
||||
","","1 ","0 ","","1(#1)","2.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 17:43:58","黄燚麒","2026-07-08 17:43:58","","
|
||||
黄燚麒(#huang)","2026-07-09 16:45:00","","","","黄燚麒","2026-07-09 16:46:08","","","",""
|
||||
"48 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","根据不同的资产使用不同的支付方式","C端支付时,卡资产只允许支付宝支付以及钱包支付,如果用微信支付就拒绝,设备只允许微信支付以及钱包支付,如果用支付宝支付就拒绝","","1 ","0 ","","1(#1)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:56:52","黄燚麒","2026-07-09 16:45:21","","
|
||||
黄燚麒(#huang)","2026-07-09 16:45:00","","","","黄燚麒","2026-07-09 16:45:21","","","",""
|
||||
"47 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","限速规则","根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则","","1 ","0 ","","3(#3)","4.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:54:39","黄燚麒","2026-07-09 17:30:42","","
|
||||
黄燚麒(#huang)","2026-07-09 17:27:00","","","","黄燚麒","2026-07-09 17:30:42","","","",""
|
||||
"46 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产信息详情字段新增","资产信息页面卡信息/设备信息板块,将当前生效套餐的过期时间作为一个字段显示且套餐还剩15天到期时该字段高亮显示。","","1 ","0 ","","2(#2)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:52:36","黄燚麒","2026-07-09 17:25:53","","
|
||||
黄燚麒(#huang)","2026-07-09 17:25:00","","","","黄燚麒","2026-07-09 17:25:53","","","",""
|
||||
"45 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","换货管理","| 编号 | 需求 |
|
||||
| ------- | ---------------------------------------------------- |
|
||||
| EXC-001 | 修正换货列表旧资产标识和新资产标识显示混乱问题。 |
|
||||
| EXC-002 | 换货列表中旧资产标识符和新资产标识符均显示为 ICCID。 |
|
||||
| EXC-003 | 旧资产查询支持 ICCID、接入号、虚拟号。 |
|
||||
| EXC-004 | 新资产查询支持 ICCID、接入号、虚拟号。 |","","1 ","0 ","","1(#1)","3.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:51:13","黄燚麒","2026-07-09 16:44:46","","
|
||||
黄燚麒(#huang)","2026-07-09 16:44:00","","","","黄燚麒","2026-07-09 16:44:46","","","",""
|
||||
"44 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","列表字段新增","| 编号 | 模块 | 新增字段 |
|
||||
| ------- | ------------ | -------------- |
|
||||
| COL-001 | 退款管理列表 | 提交人、审批人 |
|
||||
| COL-002 | 代理充值列表 | 提交人、审批人 |
|
||||
| COL-003 | 换号管理列表 | 提交人 |","","1 ","0 ","","1(#1)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:51:13","黄燚麒","2026-07-09 16:44:20","","
|
||||
黄燚麒(#huang)","2026-07-09 16:44:00","","","","黄燚麒","2026-07-09 16:44:20","","","",""
|
||||
"43 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理系列授权","| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| AUT-001 | 代理系列授权-套餐列表中,添加授权套餐支持一次性多选分套餐。 |
|
||||
| AUT-002 | 授权套餐时展示建议售价。 |
|
||||
| AUT-003 | 授权套餐时展示公司成本价。 |
|
||||
| AUT-004 | 已分配套餐和未分配套餐需要明显区分。 |
|
||||
| AUT-005 | 新增代理系列授权时,选择套餐需明确显示当前代理已经被分配过的套餐。 |","","1 ","0 ","","2(#2)","5.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:51:13","黄燚麒","2026-07-09 17:25:42","","
|
||||
黄燚麒(#huang)","2026-07-09 17:12:00","","","","黄燚麒","2026-07-09 17:25:42","","","",""
|
||||
"42 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","导出功能","#### 6.8.1 lot 卡导出
|
||||
|
||||
##### 支持套餐临期30天内所有资产的导出。字段按照卡/设备的导出表进行导出。
|
||||
|
||||
| 编号 | 需求 |
|
||||
| -------- | ---------------------------- |
|
||||
| EXPD-001 | lot 卡导出字段新增套餐名称。 |
|
||||
| EXPD-002 | lot 卡导出字段新增使用流量。 |
|
||||
| EXPD-003 | lot 卡导出字段新增剩余流量。 |
|
||||
|
||||
#### 6.8.2 代理资金概况-预充值钱包流水导出
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
| ------------------- | ------------------------------------------------------------ |
|
||||
| 店铺名称 | 代理店铺名称 |
|
||||
| 交易类型 | 充值、扣款、退款等 |
|
||||
| 交易金额 | 以元为单位 |
|
||||
| 状态 | 交易状态 |
|
||||
| 资产类型 | 卡/设备等 |
|
||||
| 资产标识 | ICCID/设备号等 |
|
||||
| 交易时间 | 流水生成时间 |
|
||||
| 交易前金额 | 交易前余额 |
|
||||
| 交易后金额 | 交易后余额 |
|
||||
| 购买套餐名称 | 资产此条扣款记录对应的套餐名称 |
|
||||
| 操作人 | 明确交易执行主体:代理账号 / 平台账号(明确账号名称) |
|
||||
| 交易 ID | 每一笔流水的全局唯一主键,彻底避免重复流水、对账串号、精准定位单条交易 |
|
||||
| 关联业务订单号 | 充值、扣款、退款等交易对应的原始业务订单编号 |
|
||||
| 交易渠道 / 支付方式 | 明确交易来源:余额支付等 |
|
||||
|
||||
#### 6.8.3 套餐列表导出
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 |
|
||||
| -------------- |
|
||||
| 套餐编码 |
|
||||
| 套餐名称 |
|
||||
| 套餐系列名称 |
|
||||
| 套餐类型 |
|
||||
| 套餐时长(月) |
|
||||
| 套餐时长说明 |
|
||||
| 套餐周期类型 |
|
||||
| 套餐天数 |
|
||||
| 真流量额度(MB) |
|
||||
| 虚流量额度(MB) |
|
||||
| 是否启用虚流量 |
|
||||
| 虚流量比例 |
|
||||
| 流量重置周期 |
|
||||
| 到期时间基准 |
|
||||
| 成本价(元) |
|
||||
| 建议售价(元) |
|
||||
| 价格配置状态 |
|
||||
| 状态 |
|
||||
| 上架状态 |
|
||||
| 是否赠送套餐 |
|
||||
| 创建人ID |
|
||||
| 更新人ID |
|
||||
| 创建时间 |
|
||||
| 更新时间 |
|
||||
| 删除时间 |
|
||||
|
||||
#### 6.8.4 退款管理退款列表导出
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 |
|
||||
| ---------------- |
|
||||
| 退款单号 |
|
||||
| 代理店铺名称 |
|
||||
| 关联的支付订单号 |
|
||||
| 资产类型 |
|
||||
| 资产标识 |
|
||||
| 套餐名称 |
|
||||
| 原订单金额 |
|
||||
| 实收金额 |
|
||||
| 可退金额 |
|
||||
| 申请退款金额 |
|
||||
| 实际退款金额 |
|
||||
| 退款到账方式 |
|
||||
| 状态 |
|
||||
| 退款原因 |
|
||||
| 备注 |
|
||||
| 审批备注 |
|
||||
| 退款申请时间 |
|
||||
| 退款完成时间 |
|
||||
| 提交人 |
|
||||
| 部门领导审批人 |
|
||||
| 财务审批人 |
|
||||
| 退款凭证 |
|
||||
|
||||
#### 6.8.5 换货管理导出
|
||||
|
||||
##### C端客户有自己的唯一标识码。换货管理可以针对该C端客户记录该客户换过几次设备或卡。同时资产本身也做换货标识。
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 |
|
||||
| ------------ |
|
||||
| 换货单号 |
|
||||
| 换货类型 |
|
||||
| 换货原因 |
|
||||
| 问题描述 |
|
||||
| 旧资产类型 |
|
||||
| 旧资产标识符 |
|
||||
| 新资产标识符 |
|
||||
| 收货人姓名 |
|
||||
| 收货人电话 |
|
||||
| 收货地址 |
|
||||
| 快递公司 |
|
||||
| 快递单号 |
|
||||
| 状态 |
|
||||
| 创建人 |
|
||||
| 创建时间 |
|
||||
|
||||
#### 6.8.6 代理充值导出
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 |
|
||||
| -------------- |
|
||||
| 充值单号 |
|
||||
| 店铺名称 |
|
||||
| 充值类型 |
|
||||
| 充值金额 |
|
||||
| 实付金额 |
|
||||
| 充值前余额 |
|
||||
| 充值后余额 |
|
||||
| 状态 |
|
||||
| 支付方式 |
|
||||
| 支付通道 |
|
||||
| 运营备注 |
|
||||
| 驳回原因 |
|
||||
| 创建时间 |
|
||||
| 支付时间 |
|
||||
| 完成时间 |
|
||||
| 提交人 |
|
||||
| 部门领导审批人 |
|
||||
| 财务审批人 |
|
||||
| 支付凭证 |
|
||||
| 备注 |","","1 ","0 ","","1(#1)","16.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:42:07","","
|
||||
黄燚麒(#huang)","2026-07-09 16:40:00","","","","黄燚麒","2026-07-09 16:42:07","","","",""
|
||||
"41 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理查询限制(类似酷蛙这种客户)","下级只能看到上级给的信息。api对接客户无法通过他的上级代理查到我们公司的信息。
|
||||
| 编号 | 需求 | | ------- | -------------------------------------------------------- | | API-001 | 支持配置代理 API 对接查询限制。下级客户/代理无法跨级查询 |
|
||||
","","1 ","0 ","","1(#1)","","草稿(#draft)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","李昕娉","2026-07-09 16:42:55","","","2026-07-11 14:24:00","","","","黄燚麒","2026-07-11 14:25:30","","","",""
|
||||
"40 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","套餐设计","| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------ |
|
||||
| PKG-001 | 套餐需标准化管理。 |
|
||||
| PKG-002 | 套餐下架后,正在使用该套餐的客户仍可续费。 |
|
||||
| PKG-003 | 下架套餐续费仅支持客户自己购买。 |
|
||||
| PKG-004 | 下架套餐不可被新购买。 |","","1 ","0 ","","2(#2)","3.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 17:11:34","","
|
||||
黄燚麒(#huang)","2026-07-09 17:10:00","","","","黄燚麒","2026-07-09 17:11:34","","","",""
|
||||
"38 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","不同渠道额度处理","| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| BPO-009 | 新建代理时新增“是否可授权额度”开关。平台用户账号默认拥有授权额度。 |
|
||||
| BPO-010 | 不同代理可设置不同额度下限。平台用户可根据不同的角色设置不同的额度下限。 |
|
||||
| BPO-011 | 授权额度用于订购套餐、代理余额充值等需要涉及金额的所有模块。 |
|
||||
| BPO-012 | 授权额度代理可显示负数余额。平台用户也可显示负数余额。 |","","1 ","0 ","","2(#2)","16.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:58:16","","
|
||||
黄燚麒(#huang)","2026-07-09 16:57:00","","","","黄燚麒","2026-07-09 16:58:16","","","",""
|
||||
"37 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","审核流转","| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| APR-001 | 代理可在系统提交充值申请。 |
|
||||
| APR-002 | 提交充值申请后系统展示收款二维码,代理扫码支付。 |
|
||||
| APR-003 | 充值和退款均支持多级审核。 |
|
||||
| APR-004 | 审核环节包括提交人部门领导审核和财务审核。 |
|
||||
| APR-005 | 待审核订单需有消息提示。 |
|
||||
| APR-006 | 审核提醒按流程环节触发,上一审批人完成审批后才提示下一审批人。 |
|
||||
| APR-007 | 审核通过后通知申请人。 |
|
||||
| APR-008 | 审核驳回后通知申请人,并附带驳回原因。 |
|
||||
| APR-009 | 审核流程需对接企业微信审批流程。 |","","1 ","0 ","","2(#2)","24.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:57:35","","
|
||||
黄燚麒(#huang)","2026-07-09 16:57:00","","","","黄燚麒","2026-07-09 16:57:35","","","",""
|
||||
"36 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","批量订购套餐","1. 内部员工进入批量订购页面。
|
||||
2. 选择代理、ICCID号段/设备号、订购套餐。或上传 Excel 文件,资产标识支持 ICCID/设备号
|
||||
3. Excel表头:跳转至6.3会显示。
|
||||
4. 系统校验导入文件和资产状态。
|
||||
5. 校验通过的资产从代理余额扣款并完成订购。
|
||||
6. 校验失败的明细在页面展示失败原因。
|
||||
7. 系统记录操作员、导入明细、导入数量和导入时间。
|
||||
|
||||
| 编号 | 需求 |
|
||||
| ------- | ---------------------------------------------------- |
|
||||
| BPO-001 | 批量订购由内部员工操作。 |
|
||||
| BPO-002 | 支持代理自行充值钱包后,由员工批量订购并从余额扣款。 |
|
||||
| BPO-003 | 支持员工代充值至代理余额后,再批量订购并从余额扣款。 |
|
||||
| BPO-004 | 批量订购无需审核。 |
|
||||
| BPO-005 | 资产标识支持 ICCID/设备号。 |
|
||||
| BPO-006 | 支持 Excel 模板导入。 |
|
||||
| BPO-007 | 需记录操作员、导入明细、导入数量和导入时间。 |
|
||||
| BPO-008 | 导入失败明细需展示在页面,并显示失败原因。 |
|
||||
|
||||
excel表导入字段:
|
||||
| 字段 |
|
||||
| ----------------------------- |
|
||||
| 资产类型 |
|
||||
| 资产标识 |
|
||||
| 套餐系列名称 |
|
||||
| 套餐名称 |
|
||||
| 代理名称 |
|
||||
| 支付方式:代理商账户/员工账户 |","","1 ","0 ","","1(#1)","4.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:31:53","","
|
||||
黄燚麒(#huang)","2026-07-09 16:31:00","","","","黄燚麒","2026-07-09 16:31:53","","","",""
|
||||
"35 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","退款审核","(审批均可通过企微进行提醒和显示并将最新状态同步至卡管)
|
||||
1. 员工提交退款申请。
|
||||
2. 退款单进入多级审核流程。
|
||||
3. 按部门领导、财务顺序审批。
|
||||
4. 当前环节审批完成后,下一环节审批人收到消息提示。
|
||||
5. 审批通过或驳回后通知申请人。","","1 ","0 ","","2(#2)","24.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:56:31","","
|
||||
黄燚麒(#huang)","2026-07-09 16:55:00","","","","黄燚麒","2026-07-09 16:56:31","","","",""
|
||||
"34 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","充值审核流程","代理自己充值:
|
||||
1. 代理在系统提交充值申请。
|
||||
2. 系统展示收款二维码。
|
||||
3. 代理扫码支付。
|
||||
|
||||
员工代充值:(审批均可通过企微进行提醒和显示并将最新状态同步至卡管)
|
||||
1. 充值单进入多级审核流程。
|
||||
2. 提交人部门领导先审批。
|
||||
3. 财务在上一审批人通过后收到待办提醒并审批。
|
||||
4. 审批通过后通知申请人。
|
||||
5. 审批驳回后通知申请人,并展示驳回原因。","","1 ","0 ","","2(#2)","32.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:55:42","","
|
||||
黄燚麒(#huang)","2026-07-09 16:55:00","","","","黄燚麒","2026-07-09 16:55:42","","","",""
|
||||
"33 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","套餐临期提醒","1. 系统每日计算卡/设备套餐剩余有效期。
|
||||
2. 命中临期规则后生成临期数据。临期提醒规则:按 15 天、7 天、3 天节点分别进行不同方式的提醒。
|
||||
3. 企业客户场景:按 15 天、7 天、3 天节点生成临期列表,并通过企业微信推送给对应业务员。
|
||||
4. 代理端场景(展示):首页展示卡、设备临期数量;资产列表按临期天数高亮。
|
||||
5. C 端场景(展示):公众号首页在套餐剩余有效期小于或等于 15天时展示续费提醒,并显示立即续费按钮。
|
||||
6. 当资产续费成功或不再满足临期条件时,临期提醒自动取消。
|
||||
#### 6.1.1 企业客户临期提醒
|
||||
| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| EXP-001 | 后台每日生成企业客户临期列表。 |
|
||||
| EXP-002 | 临期节点包括 15 天、7 天、3 天。 |
|
||||
| EXP-003 | 系统需对接企业微信,将临期列表推送给对应业务员。 |
|
||||
| EXP-004 | 同一资产在不同临期节点可重复触发对应节点提醒,但同一节点每日不可重复推送给同一业务员。 |
|
||||
|
||||
#### 6.1.2 代理端临期提醒
|
||||
|
||||
| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| EXP-005 | 代理端首页分别展示临期卡数量和临期设备数量。 |
|
||||
| EXP-006 | 代理端资产列表对临期资产进行颜色标记。 |
|
||||
| EXP-007 | 剩余 15 天标记为粉色,剩余 7 天标记为紫色,剩余 3 天标记为红色。 |
|
||||
| EXP-008 | 剩余 3 天资产在列表中置顶优先展示。 |
|
||||
|
||||
#### 6.1.3 C 端公众号首页提醒
|
||||
|
||||
| 编号 | 需求 |
|
||||
| ------- | ------------------------------------------------------------ |
|
||||
| EXP-009 | 当客户套餐剩余有效期小于或等于 15 天时,公众号首页展示套餐到期提醒。 |
|
||||
| EXP-010 | 提醒展示卡号/设备号、剩余有效期和续费引导文案。 |
|
||||
| EXP-011 | 按钮文案为“立即续费”。 |
|
||||
| EXP-012 | 剩余天数需要按日期每天自动更新。 |
|
||||
| EXP-013 | 当套餐已续费或不再满足临期条件时,提醒不再展示。 |
|
||||
推荐展示文案:
|
||||
您的套餐即将到期
|
||||
|
||||
卡号/设备号:xxx
|
||||
剩余有效期:xx 天
|
||||
|
||||
为避免到期后影响正常使用,请您提前完成续费。","","1 ","0 ","","1(#1)","","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:30:06","","
|
||||
黄燚麒(#huang)","2026-07-09 16:29:00","","","","黄燚麒","2026-07-09 16:30:06","","","",""
|
||||
|
271
docs/7月迭代/独立方案/历史方案/前端共性方案-历史稿.md
Normal file
271
docs/7月迭代/独立方案/历史方案/前端共性方案-历史稿.md
Normal file
@@ -0,0 +1,271 @@
|
||||
# 7月迭代前端技术方案
|
||||
|
||||
> 历史状态:共性前端来源稿;本地审批交互已废弃,最终以前端标准章节和各独立方案为准。
|
||||
> 当前方案:[标准评审稿](../../7月迭代技术方案-标准评审稿.md)。
|
||||
> 适用端:后台管理端、代理端、C 端 H5/公众号
|
||||
> 最后更新:2026-07-14
|
||||
> 说明:当前仓库不包含前端源码,本文定义页面、交互和接口契约;实际目录、状态库和组件名称由前端仓库现状映射,禁止据此凭空更换前端技术栈。
|
||||
|
||||
---
|
||||
|
||||
## 一、目标与边界
|
||||
|
||||
本方案解决七月迭代中跨需求的前端共性问题:
|
||||
|
||||
- 审批任务、退款和充值使用同一套动态审批展示。
|
||||
- Excel 导入、批量订购和导出使用统一的异步任务交互。
|
||||
- 站内消息统一未读数、列表、已读和业务跳转。
|
||||
- 配置类页面不让用户直接编辑 JSON 或依赖前端自行校验业务规则。
|
||||
- 金额、状态、时间、权限和错误展示使用统一口径。
|
||||
|
||||
本文不指定 Vue、React、Pinia、Redux 或具体 UI 组件库。实现时必须优先复用前端仓库现有的请求封装、权限指令、上传组件、表格和轮询 Hook。
|
||||
|
||||
---
|
||||
|
||||
## 二、系统上下文
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
AdminUser[平台/代理后台用户] --> AdminWeb[后台管理前端]
|
||||
Customer[C端客户] --> ClientWeb[C端 H5/公众号]
|
||||
AdminWeb -->|/api/admin/*| API[Junhong API]
|
||||
ClientWeb -->|/api/c/v1/*| API
|
||||
API --> DB[(PostgreSQL)]
|
||||
API --> Redis[(Redis)]
|
||||
API --> Queue[Asynq]
|
||||
Queue --> Worker[Worker]
|
||||
Worker --> APIData[业务数据/对象存储/Gateway]
|
||||
AdminWeb -->|轮询未读数、任务状态| API
|
||||
```
|
||||
|
||||
前端只根据 API 返回的权限、状态和可操作项渲染,不自行推导“谁能审批”“是否允许扣款”或“当前流程下一步是谁”。
|
||||
|
||||
---
|
||||
|
||||
## 三、模块边界
|
||||
|
||||
建议在现有前端目录结构中映射以下模块,不要求创建新的全局架构:
|
||||
|
||||
| 模块 | 负责内容 | 不负责内容 |
|
||||
|------|----------|------------|
|
||||
| Approval | 待办列表、流程详情、流程定义配置、审批动作 | 退款或充值的业务表单 |
|
||||
| Notification | 未读数、通知列表、标记已读、业务跳转 | 审批状态计算 |
|
||||
| AsyncTask | 导入、批量订购、导出的轮询与结果展示 | 各业务文件解析 |
|
||||
| Refund | 退款申请、编辑、重新提交、业务处理状态 | 动态审批节点渲染的内部规则 |
|
||||
| Recharge | 代理在线充值、员工线下代充值、重新提交 | 钱包入账和审批人判断 |
|
||||
| SystemConfig | 配置表单和版本刷新 | 直接编辑任意 JSON |
|
||||
|
||||
全局状态只保留跨页面共享的数据:登录用户、菜单/按钮权限、通知未读数。列表数据、详情数据和表单草稿默认留在页面或模块级状态,避免把服务端状态复制到全局 Store 后长期失真。
|
||||
|
||||
---
|
||||
|
||||
## 四、接口与类型约定
|
||||
|
||||
### 4.1 路径前缀
|
||||
|
||||
- 后台管理:`/api/admin/*`
|
||||
- C 端:`/api/c/v1/*`
|
||||
- 回调接口不由前端调用。
|
||||
|
||||
专项文档出现省略 `/api` 的路径时,以本节和真实路由注册为准,并应在评审前修正。
|
||||
|
||||
### 4.2 枚举和显示
|
||||
|
||||
- 生命周期状态使用后端返回的 `status` 做逻辑判断,使用 `status_name` 做中文展示。
|
||||
- 前端不得维护另一份与后端重复的中文状态映射;只有颜色、图标等纯展示映射可以留在前端。
|
||||
- 金额 API 统一使用“分”,输入组件展示“元”,提交前做整数转换,禁止浮点数直接乘除后提交。
|
||||
- 时间统一使用后端 ISO 8601 值,展示层按现有项目时区和格式化工具处理。
|
||||
|
||||
### 4.3 请求幂等
|
||||
|
||||
审批等敏感写操作由前端生成 `request_id`。一次用户操作从首次提交到网络重试必须复用同一个值;用户明确重新发起操作时才生成新值。
|
||||
|
||||
按钮提交后进入 loading 并禁止重复点击。前端防重只是体验控制,后端仍必须执行状态条件更新和唯一约束。
|
||||
|
||||
---
|
||||
|
||||
## 五、统一异步任务交互
|
||||
|
||||
适用:设备批量分配、批量订购、导出任务。
|
||||
|
||||
不适用于临期状态:临期列表、详情和首页数量由接口实时 SQL 计算;每日任务只生成 15/7/3 天通知,前端不轮询或维护临期快照。
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Editing: 填写参数/选择文件
|
||||
Editing --> Submitting: 提交
|
||||
Submitting --> Processing: 创建任务成功
|
||||
Submitting --> Editing: 参数或上传失败
|
||||
Processing --> Processing: 轮询进度
|
||||
Processing --> Completed: 全部或部分完成
|
||||
Processing --> Failed: 任务失败
|
||||
Processing --> Cancelled: 用户取消且后端确认
|
||||
Completed --> [*]
|
||||
Failed --> Editing: 修正后重新提交
|
||||
Cancelled --> [*]
|
||||
```
|
||||
|
||||
### 5.1 轮询规则
|
||||
|
||||
- 创建成功后立即请求一次详情,再按 2 秒、3 秒、5 秒逐步退避,最大间隔 10 秒。
|
||||
- 页面不可见时暂停轮询,恢复可见时立即刷新。
|
||||
- 达到终态、离开页面或组件销毁时停止轮询。
|
||||
- 连续网络失败不把业务任务标记为失败,展示“状态获取失败,点击重试”。
|
||||
- 服务端返回 `retry_after_seconds` 时优先采用服务端建议。
|
||||
|
||||
### 5.2 结果展示
|
||||
|
||||
- 必须同时展示总数、成功数、失败数和任务状态。
|
||||
- 部分成功不能只弹一个成功 Toast;失败明细要留在页面,并支持下载或复制,具体能力按专项方案。
|
||||
- 导出任务完成后展示下载按钮和链接过期时间;链接过期时重新获取任务详情,不重新创建导出任务。
|
||||
|
||||
### 5.3 Excel 模板
|
||||
|
||||
- 设备批量分配、批量订购等 Excel 模板由前端作为静态资源维护,后端不提供模板下载 API。
|
||||
- 模板文件名包含版本号,下载入口与对应上传表单放在同一页面。
|
||||
- 后端仍必须严格校验表头和内容,不能因为模板由前端提供就信任文件结构。
|
||||
- 模板字段变更需要前后端同批发布,并保留对用户本地旧模板的可理解错误提示。
|
||||
|
||||
---
|
||||
|
||||
## 六、统一审批交互
|
||||
|
||||
```mermaid
|
||||
stateDiagram-v2
|
||||
[*] --> Loading
|
||||
Loading --> ReadOnly: 当前用户无待处理资格
|
||||
Loading --> Actionable: 当前用户是待处理审批人
|
||||
Actionable --> EditingAction: 打开通过/驳回/退回弹框
|
||||
EditingAction --> Uploading: 上传附件
|
||||
Uploading --> EditingAction: 上传成功或失败后返回
|
||||
EditingAction --> Submitting: 意见和附件校验通过
|
||||
Submitting --> EditingAction: 请求失败且任务仍可操作
|
||||
Submitting --> ReadOnly: 操作成功或状态已被他人改变
|
||||
ReadOnly --> [*]
|
||||
```
|
||||
|
||||
### 6.1 页面建议
|
||||
|
||||
| 页面 | 建议路由 | 主要能力 |
|
||||
|------|----------|----------|
|
||||
| 待我审批 | `/approvals/tasks` | 状态、业务类型、提交人、当前节点、提交时间筛选 |
|
||||
| 审批详情 | `/approvals/instances/:instance_id` | 业务快照、业务资料、动态节点时间线、审批人和意见、当前可操作按钮 |
|
||||
| 流程定义 | `/settings/approval-flows` | 草稿、发布版本、停用、业务绑定 |
|
||||
| 流程定义编辑 | `/settings/approval-flows/:id` | 串行节点配置、角色/账号选择、或签/会签、发布前校验 |
|
||||
|
||||
第一版只支持串行节点,前端使用“有序节点列表编辑器”,不建设拖拽 DAG/BPMN 设计器。节点可上移、下移、增加和删除;开始、结束节点由系统生成且不可删除。
|
||||
|
||||
### 6.2 动态详情
|
||||
|
||||
审批详情按接口返回的 `tasks[]` 和 `assignees[]` 渲染,禁止写死“部门领导”和“财务”两个字段。
|
||||
|
||||
详情首屏必须先渲染 `business_snapshot`:业务标题、业务单号、提交人、审批关键字段和业务资料。业务资料来自发起时快照,审批附件来自某位审批人的操作记录,页面用两个区域展示;审批人不需要跳转到实时业务页才能知道正在审批什么。
|
||||
|
||||
操作按钮显示条件同时满足:
|
||||
|
||||
- 流程状态为审批中。
|
||||
- 当前任务状态为待审批。
|
||||
- 当前账号对应的审批人状态为待处理。
|
||||
- 接口返回相应操作权限。
|
||||
|
||||
操作成功后重新请求审批实例和关联业务详情,不做乐观状态推进。
|
||||
|
||||
审批详情可返回 `action_form.fields`。前端只渲染后端声明的受控字段类型;退款金额和操作密码均由流程节点配置决定,且只有当前动作会完成该节点时才出现。金额展示元、提交分;操作密码不写入全局 Store、本地存储或重试缓存,请求结束立即清空。节点没有动作字段时不显示额外表单,禁止根据“财务审核”等节点名称自行添加输入项。
|
||||
|
||||
每个通过、驳回、退回弹框统一包含“审批意见”和“附件”:
|
||||
|
||||
- 通过意见可选;驳回、退回意见必填,最多 1000 字。
|
||||
- 意见使用普通多行文本框,不提供富文本编辑器。
|
||||
- 附件最多 5 个、单个默认不超过 20MB,允许图片、PDF、Word、Excel;打开动作弹框时先生成 `request_id`,申请 `purpose=approval_attachment` 且绑定该 `request_id` 的上传凭证,上传完成后提交 `file_key + file_name`。前端校验只用于体验,最终以后端对象元数据和上传归属校验为准。
|
||||
- 文件仍在上传时禁止提交审批;删除尚未提交的附件只影响本地表单,不调用删除历史审批附件。
|
||||
- 网络重试复用原 `request_id`、意见和附件集合;操作成功后清空本地表单。
|
||||
- 时间线按审批人展示各自意见和附件。审批附件和业务资料分别通过审批实例权限校验接口换取短期 URL,不直接拼对象存储地址。
|
||||
|
||||
### 6.3 业务处理中的展示
|
||||
|
||||
审批通过与退款到账、钱包入账不是同一时刻。退款和充值详情必须同时展示:
|
||||
|
||||
- 审批状态。
|
||||
- 业务处理状态。
|
||||
- 业务处理失败原因和“系统重试中/联系管理员”的提示;非代理钱包退款审批通过后显示“待人工退款”,仅财务确认权限可见确认完成入口。
|
||||
|
||||
不能仅根据业务单原状态显示“待审批”。
|
||||
|
||||
### 6.4 存量历史记录
|
||||
|
||||
业务详情接口增加 `approval_source`:
|
||||
|
||||
- `none`:当前业务不需要审批,例如代理在线充值;隐藏审批区域。
|
||||
- `workflow`:存在通用审批实例,展示动态时间线和 `available_actions`。
|
||||
- `legacy`:发布前已经结束的历史记录,没有完整流程实例;只读展示原业务状态、旧审批摘要和审计信息,不生成虚假节点。
|
||||
|
||||
如果一条仍需审批的退款或员工线下充值返回 `approval_instance_id=null`,前端按数据迁移异常展示并禁止任何审批操作,不能退回旧业务单审批接口。
|
||||
|
||||
---
|
||||
|
||||
## 七、页面与需求映射
|
||||
|
||||
| 需求 | 端 | 页面/入口 | 关键交互 |
|
||||
|------|----|-----------|----------|
|
||||
| 01 | 后台 | 资产详情复机操作 | 界面不变,展示后端真实结果 |
|
||||
| 02 | 后台 + C端 | 卡/设备列表实名策略;C端流程页 | 单条/批量设置,C端按接口策略跳转 |
|
||||
| 03/07/11/12/13 | 后台 | 原有列表和详情 | 新筛选、新字段、动态审批摘要 |
|
||||
| 05 | 后台 | 套餐分配弹框、已分配列表 | 生效条件覆盖和修改提示 |
|
||||
| 08 | 后台 | 设备管理批量操作 | “批量分配代理”和“批量分配套餐系列”两个入口,上传、任务轮询、失败明细 |
|
||||
| 09 | 后台 + C端 | 系统配置;支付页 | 后台配置支付方式,C端隐藏并由后端兜底拦截 |
|
||||
| 10 | 后台 | 卡/设备资产详情 | 手动设置或取消当前卡限速,单位 `kbps`,不提供套餐限速配置 |
|
||||
| 14 | 后台 | 各业务列表导出 | 字段选择、权限过滤、统一导出任务 |
|
||||
| 15 | C端 + 后台 | 当前套餐卡片、后台代购 | 当前套餐旁“续费”复用购买流程;下架套餐仅在合法续费入口展示 |
|
||||
| 16 | - | 已移出 7 月迭代 | 不建设分销码、代理申请、提现材料和相关审批页面 |
|
||||
| 17 | 后台/代理端 | 代理信用、钱包详情 | 元/分转换、欠款状态、额度权限 |
|
||||
| 18/20/21 | 后台 | 待办、退款、充值详情 | 动态审批时间线、处理状态、退回重提 |
|
||||
| 19 | 后台 | 批量订购 | 参数确认、上传、逐行结果和金额汇总 |
|
||||
| 22 | 后台 + 代理端 + C端 | 临期列表、首页提醒 | 15/7/3 天分级、续费跳转、到期后自动消失 |
|
||||
|
||||
---
|
||||
|
||||
## 八、站内消息
|
||||
|
||||
- 登录后和进入后台布局时立即获取未读数,之后每 30 秒轮询。
|
||||
- 页面不可见时暂停,恢复时立即刷新。
|
||||
- 铃铛数字超过 99 显示 `99+`。
|
||||
- 通知点击只按受控的 `ref_type + ref_id` 路由表跳转,不接受后端返回任意 URL。
|
||||
- 标记已读失败不阻止查看业务详情,但需要在下次轮询时恢复真实未读状态。
|
||||
- 通知正文按纯文本展示;若未来支持富文本,必须使用受控模板和统一净化,不直接渲染任意 HTML。
|
||||
|
||||
---
|
||||
|
||||
## 九、权限与敏感数据
|
||||
|
||||
- 菜单和按钮根据登录返回的权限控制可见性,但后端权限校验是最终依据。
|
||||
- 审批人资格由任务接口返回,前端不根据角色名称推导。
|
||||
- 导出字段由后端返回允许字段集合;前端只能在允许集合中选择。
|
||||
- 退款凭证、充值凭证、身份证、营业执照和审批附件使用现有对象存储上传流程,不提交本地路径或长期公开 URL。审批附件额外提交清理后的原文件名作为展示快照。
|
||||
- 列表和详情对无权限与不存在统一展示,避免通过前端文案泄露资源存在性。
|
||||
|
||||
---
|
||||
|
||||
## 十、停机发布
|
||||
|
||||
1. 发布前进入维护模式,前端统一展示维护页并停止提交写请求。
|
||||
2. 维护窗口内执行数据库迁移,同时发布 API、Worker/Relay 和前端静态资源。
|
||||
3. 初始化并启用退款、充值流程定义和业务绑定,执行存量待审批单回填。
|
||||
4. 前端只调用任务级审批 API,不保留退款或线下充值的业务单级通过/驳回按钮,也不保留线下充值“确认入账”按钮。
|
||||
5. 人工验证登录、菜单权限、流程发起、存量历史展示、待办、审批详情、退回重提和业务处理状态。
|
||||
6. 验证通过后解除维护模式;失败则在开放访问前回滚整套应用版本。
|
||||
|
||||
前端必须容忍新增响应字段且不依赖字段顺序,但本次不要求支持旧后端与新前端或新后端与旧前端交叉运行。
|
||||
|
||||
---
|
||||
|
||||
## 十一、待前端仓库确认
|
||||
|
||||
以下内容不影响当前接口和交互评审,但实施前必须在前端仓库确认:
|
||||
|
||||
- 后台、代理端和 C 端分别使用的框架版本与目录结构。
|
||||
- 现有权限指令、请求封装、上传组件和导出 Hook 的真实名称。
|
||||
- 是否已有通用任务轮询组件和流程时间线组件。
|
||||
- 实际菜单路由和按钮权限编码。
|
||||
- 表格是否支持服务端返回的动态导出字段配置。
|
||||
|
||||
确认后只补充实现映射,不改变本文已经评审通过的业务状态和 API 契约。
|
||||
1174
docs/7月迭代/独立方案/历史方案/本地通用审批流-已废弃方案.md
Normal file
1174
docs/7月迭代/独立方案/历史方案/本地通用审批流-已废弃方案.md
Normal file
File diff suppressed because it is too large
Load Diff
383
docs/7月迭代/独立方案/历史方案/站内消息-初版.md
Normal file
383
docs/7月迭代/独立方案/历史方案/站内消息-初版.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# 基础设施:站内消息(Notification)
|
||||
|
||||
> 历史状态:初版方案,已被站内通知详细方案替代。
|
||||
> 替代方案:[站内通知详细方案](../新增需求/03-站内通知详细方案.md) 和 [标准评审稿](../../7月迭代技术方案-标准评审稿.md)。
|
||||
> 被依赖:需求 18/20/21(审批流通知)、需求 22(临期提醒)
|
||||
> Phase 2 扩展:企业微信推送、短信通知(预留插拔接口)
|
||||
|
||||
---
|
||||
|
||||
## 一、设计原则
|
||||
|
||||
- **业务代码不直接写通知表**:统一通过通知发布器或领域事件异步处理
|
||||
- **关键领域事件先写 Outbox**:审批、退款、充值等事务内事件由 Outbox Relay 投递 Asynq,禁止事务提交后直接入队
|
||||
- **通知消费必须幂等**:使用稳定的 `event_id` 和接收人唯一约束,Asynq 重试不得重复生成站内消息
|
||||
- **不过度抽象**:不用 interface,用具体的 `NotificationPublisher`(后续加渠道 = 在 handler 里加代码)
|
||||
- **前端轮询**:30秒一次 `/api/admin/notifications/unread-count`,不用 WebSocket
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Biz as 业务事务
|
||||
participant Outbox as Outbox
|
||||
participant Relay as Relay
|
||||
participant Worker as Notification Worker
|
||||
participant DB as tb_notification
|
||||
participant Web as 后台前端
|
||||
|
||||
Biz->>Outbox: 同事务写领域事件
|
||||
Relay->>Outbox: 拉取待投递事件
|
||||
Relay->>Worker: 至少一次投递
|
||||
Worker->>DB: 按 event_id + recipient 幂等插入
|
||||
Web->>DB: 经 API 轮询未读数/通知列表
|
||||
Web->>DB: 经 API 标记已读
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库
|
||||
|
||||
```sql
|
||||
-- 迁移文件:YYYYMMDD_create_tb_notification.sql
|
||||
CREATE TABLE tb_notification (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
event_id VARCHAR(64) NOT NULL, -- 领域事件ID或调用方请求ID,用于幂等
|
||||
recipient_id BIGINT NOT NULL, -- 用户ID(admin user 或 agent user)
|
||||
recipient_type VARCHAR(20) NOT NULL DEFAULT 'admin', -- admin | agent
|
||||
type VARCHAR(50) NOT NULL, -- 通知类型(见常量定义)
|
||||
title VARCHAR(200) NOT NULL, -- 标题
|
||||
body TEXT NOT NULL DEFAULT '', -- 纯文本正文
|
||||
ref_type VARCHAR(50), -- 关联业务类型 approval | recharge | refund | iot_card | device
|
||||
ref_id BIGINT, -- 关联业务ID
|
||||
is_read BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
read_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
expires_at TIMESTAMPTZ -- 过期自动不展示(可选,临期提醒用)
|
||||
);
|
||||
|
||||
CREATE INDEX idx_notification_recipient
|
||||
ON tb_notification (recipient_id, recipient_type, is_read, created_at DESC);
|
||||
CREATE INDEX idx_notification_created ON tb_notification (created_at DESC);
|
||||
CREATE UNIQUE INDEX uq_notification_event_recipient
|
||||
ON tb_notification (event_id, recipient_id, recipient_type);
|
||||
|
||||
COMMENT ON TABLE tb_notification IS '站内消息通知表';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、常量定义
|
||||
|
||||
```go
|
||||
// pkg/constants/notification.go
|
||||
|
||||
// 通知类型
|
||||
const (
|
||||
NotifyTypeApprovalPending = "approval.pending" // 待我审批
|
||||
NotifyTypeApprovalDone = "approval.done" // 审批完成(申请人收)
|
||||
NotifyTypeApprovalRejected = "approval.rejected" // 审批驳回(申请人收,流程终止)
|
||||
NotifyTypeApprovalReturned = "approval.returned" // 审批退回(申请人收,可修改后重新提交)
|
||||
NotifyTypePackageExpiring = "package.expiring" // 套餐临期
|
||||
NotifyTypeSystemAlert = "system.alert" // 系统通知
|
||||
)
|
||||
|
||||
// 通知接收者类型
|
||||
const (
|
||||
NotifyRecipientAdmin = "admin" // 平台用户
|
||||
NotifyRecipientAgent = "agent" // 代理用户(预留)
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Model
|
||||
|
||||
```go
|
||||
// internal/model/notification.go
|
||||
|
||||
// Notification 站内消息模型
|
||||
type Notification struct {
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
EventID string `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex:uq_notification_event_recipient,priority:1" json:"event_id"`
|
||||
RecipientID uint `gorm:"column:recipient_id;not null;index;uniqueIndex:uq_notification_event_recipient,priority:2" json:"recipient_id"`
|
||||
RecipientType string `gorm:"column:recipient_type;type:varchar(20);not null;default:'admin';uniqueIndex:uq_notification_event_recipient,priority:3" json:"recipient_type"`
|
||||
Type string `gorm:"column:type;type:varchar(50);not null" json:"type"`
|
||||
Title string `gorm:"column:title;type:varchar(200);not null" json:"title"`
|
||||
Body string `gorm:"column:body;type:text;not null;default:''" json:"body"`
|
||||
RefType *string `gorm:"column:ref_type;type:varchar(50)" json:"ref_type,omitempty"`
|
||||
RefID *uint `gorm:"column:ref_id" json:"ref_id,omitempty"`
|
||||
IsRead bool `gorm:"column:is_read;not null;default:false" json:"is_read"`
|
||||
ReadAt *time.Time `gorm:"column:read_at" json:"read_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"`
|
||||
ExpiresAt *time.Time `gorm:"column:expires_at" json:"expires_at,omitempty"`
|
||||
}
|
||||
|
||||
func (Notification) TableName() string { return "tb_notification" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、发布侧:NotificationPublisher
|
||||
|
||||
非事务性、允许调用方直接发起的通知使用发布器异步入队。审批等领域事件不直接调用该发布器,而由 `TaskCreated`、`ProcessApproved` 等事件处理器转换为通知载荷。
|
||||
|
||||
领域事件通知使用领域事件自身的 `event_id`;非领域事件调用方使用稳定的业务请求 ID。网络重试或 Asynq 重试时禁止重新生成 ID。
|
||||
|
||||
```go
|
||||
// internal/infrastructure/messaging/notification_publisher.go
|
||||
|
||||
// SendPayload 发送通知的参数
|
||||
type SendPayload struct {
|
||||
EventID string // 领域事件ID或调用方请求ID,同一次重试必须保持不变
|
||||
RecipientIDs []uint // 接收人ID列表
|
||||
RecipientType string // admin | agent
|
||||
Type string // 通知类型常量
|
||||
Title string // 标题
|
||||
Body string // 正文
|
||||
RefType string // 关联业务类型(可选)
|
||||
RefID uint // 关联业务ID(可选)
|
||||
ExpiresAt *time.Time // 过期时间(可选)
|
||||
}
|
||||
|
||||
// NotificationPublisher 通知发布器(非接口,简单具体实现)
|
||||
type NotificationPublisher struct {
|
||||
queueClient *queue.Client
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// Publish 发布通知(异步)
|
||||
// 返回错误供调用方或 Asynq Handler 决定重试,禁止吞掉关键通知错误。
|
||||
func (p *NotificationPublisher) Publish(ctx context.Context, payload SendPayload) error {
|
||||
if payload.EventID == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "通知事件ID不能为空")
|
||||
}
|
||||
if err := p.queueClient.EnqueueTask(ctx, constants.TaskTypeNotification, payload); err != nil {
|
||||
p.logger.Error("通知入队失败", zap.Error(err), zap.String("type", payload.Type))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
审批事件处理器调用示例(节点激活后通知候选审批人):
|
||||
|
||||
```go
|
||||
return h.notifyPublisher.Publish(ctx, notification.SendPayload{
|
||||
EventID: event.EventID,
|
||||
RecipientIDs: []uint{nextApproverID},
|
||||
RecipientType: constants.NotifyRecipientAdmin,
|
||||
Type: constants.NotifyTypeApprovalPending,
|
||||
Title: "您有一条待审批记录",
|
||||
Body: fmt.Sprintf("代理「%s」提交了充值申请,请及时审批。", shopName),
|
||||
RefType: "approval",
|
||||
RefID: processInstanceID,
|
||||
})
|
||||
```
|
||||
|
||||
审批事务中只写 `TaskCreated` Outbox 事件。即使 Redis/Asynq 暂时不可用,事件仍保留在数据库,由 Relay 重试;通知处理失败则由 Asynq 重试当前任务。
|
||||
|
||||
---
|
||||
|
||||
## 六、消费侧:Asynq Task Handler
|
||||
|
||||
```go
|
||||
// internal/task/notification_handler.go
|
||||
|
||||
// HandleNotification 处理通知发送任务
|
||||
func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.Task) error {
|
||||
var payload notification.SendPayload
|
||||
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
|
||||
return fmt.Errorf("反序列化通知载荷失败: %w", err)
|
||||
}
|
||||
|
||||
// 批量写入 tb_notification
|
||||
records := make([]model.Notification, 0, len(payload.RecipientIDs))
|
||||
for _, uid := range payload.RecipientIDs {
|
||||
ref_type := (*string)(nil)
|
||||
ref_id := (*uint)(nil)
|
||||
if payload.RefType != "" {
|
||||
ref_type = &payload.RefType
|
||||
}
|
||||
if payload.RefID != 0 {
|
||||
ref_id = &payload.RefID
|
||||
}
|
||||
records = append(records, model.Notification{
|
||||
EventID: payload.EventID,
|
||||
RecipientID: uid,
|
||||
RecipientType: payload.RecipientType,
|
||||
Type: payload.Type,
|
||||
Title: payload.Title,
|
||||
Body: payload.Body,
|
||||
RefType: ref_type,
|
||||
RefID: ref_id,
|
||||
ExpiresAt: payload.ExpiresAt,
|
||||
})
|
||||
}
|
||||
|
||||
if err := h.db.WithContext(ctx).
|
||||
Clauses(clause.OnConflict{DoNothing: true}).
|
||||
CreateInBatches(records, 100).Error; err != nil {
|
||||
return fmt.Errorf("批量写入通知失败: %w", err)
|
||||
}
|
||||
|
||||
// Phase 2:在此处加企微/短信调用
|
||||
// for _, sender := range h.extraSenders { sender.Send(ctx, payload) }
|
||||
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、API 设计
|
||||
|
||||
### 7.1 未读数量(前端轮询用,30秒一次)
|
||||
|
||||
```
|
||||
GET /api/admin/notifications/unread-count
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{ "code": 0, "data": { "count": 5 } }
|
||||
```
|
||||
|
||||
### 7.2 通知列表
|
||||
|
||||
```
|
||||
GET /api/admin/notifications?is_read=false&type=approval.pending&page=1&page_size=20
|
||||
```
|
||||
|
||||
请求参数:
|
||||
```go
|
||||
type NotificationListRequest struct {
|
||||
IsRead *bool `query:"is_read" description:"是否已读(不传=全部)"`
|
||||
Type string `query:"type" description:"通知类型过滤(可选)"`
|
||||
Page int `query:"page" description:"页码"`
|
||||
PageSize int `query:"page_size" description:"每页数量(最大50)"`
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"id": 1,
|
||||
"type": "approval.pending",
|
||||
"title": "您有一条待审批记录",
|
||||
"body": "代理「XX店」提交了充值申请,请及时审批。",
|
||||
"ref_type": "approval",
|
||||
"ref_id": 42,
|
||||
"is_read": false,
|
||||
"created_at": "2026-07-11T10:00:00Z"
|
||||
}
|
||||
],
|
||||
"total": 3,
|
||||
"page": 1,
|
||||
"page_size": 20
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 7.3 标记已读
|
||||
|
||||
```
|
||||
PUT /api/admin/notifications/{id}/read
|
||||
```
|
||||
|
||||
### 7.4 全部标记已读
|
||||
|
||||
```
|
||||
PUT /api/admin/notifications/read-all
|
||||
```
|
||||
|
||||
可传 `type` 过滤(只把某类型全部已读):
|
||||
```json
|
||||
{ "type": "approval.pending" }
|
||||
```
|
||||
|
||||
### DTO
|
||||
|
||||
```go
|
||||
// internal/model/dto/notification_dto.go
|
||||
|
||||
type NotificationListRequest struct {
|
||||
IsRead *bool `query:"is_read"`
|
||||
Type string `query:"type"`
|
||||
Page int `query:"page" default:"1"`
|
||||
PageSize int `query:"page_size" default:"20"`
|
||||
}
|
||||
|
||||
type NotificationItem struct {
|
||||
ID uint `json:"id"`
|
||||
Type string `json:"type" description:"通知类型"`
|
||||
Title string `json:"title"`
|
||||
Body string `json:"body"`
|
||||
RefType *string `json:"ref_type,omitempty"`
|
||||
RefID *uint `json:"ref_id,omitempty"`
|
||||
IsRead bool `json:"is_read"`
|
||||
ReadAt *time.Time `json:"read_at,omitempty"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
}
|
||||
|
||||
type UnreadCountResponse struct {
|
||||
Count int64 `json:"count"`
|
||||
}
|
||||
|
||||
type ReadAllRequest struct {
|
||||
Type string `json:"type" description:"通知类型(为空则全部已读)"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、前端对接
|
||||
|
||||
### 顶部导航栏铃铛
|
||||
|
||||
```
|
||||
组件挂载 → 轮询 GET /api/admin/notifications/unread-count(30秒一次)
|
||||
→ count > 0 时铃铛显示红点 + 数字
|
||||
→ 点击铃铛 → 弹出通知抽屉 or 跳转 /notifications 页面
|
||||
→ 打开时调 GET /api/admin/notifications?is_read=false
|
||||
→ 点击某条通知 → PUT /api/admin/notifications/{id}/read → 根据 ref_type+ref_id 跳转对应业务页
|
||||
```
|
||||
|
||||
### 跳转逻辑(ref_type)
|
||||
|
||||
| ref_type | 跳转页面 |
|
||||
|----------|---------|
|
||||
| `approval` | `/approvals/instances/{ref_id}` 审批详情 |
|
||||
| `recharge` | `/agent-recharges/{ref_id}` 充值单详情 |
|
||||
| `refund` | `/refunds/{ref_id}` 退款单详情 |
|
||||
| `iot_card` | `/iot-cards/{ref_id}` IoT卡详情(临期提醒) |
|
||||
| `device` | `/devices/{ref_id}` 设备详情(临期提醒) |
|
||||
|
||||
### 通知列表页(/notifications)
|
||||
|
||||
筛选:通知类型(下拉)、已读状态
|
||||
操作:全部已读按钮
|
||||
列表字段:类型、标题、时间、已读状态
|
||||
点击行:跳转关联业务详情
|
||||
|
||||
前端页面不可见时暂停未读数轮询,恢复可见时立即刷新。通知正文按纯文本渲染;未来需要富文本时使用受控模板和统一净化,禁止直接渲染业务方提交的 HTML。
|
||||
|
||||
---
|
||||
|
||||
## 九、Phase 2 扩展预留
|
||||
|
||||
当需要接入企微通知时,只需在 `HandleNotification` 里追加:
|
||||
|
||||
```go
|
||||
// 企微通知(Phase 2)
|
||||
if h.wecomClient != nil {
|
||||
for _, uid := range payload.RecipientIDs {
|
||||
wecomOpenID := h.userStore.GetWecomOpenID(ctx, uid)
|
||||
h.wecomClient.SendMessage(wecomOpenID, payload.Title, payload.Body)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
业务代码零修改,Handler 里加一段即可。
|
||||
56
docs/7月迭代/独立方案/原需求/需求01-复机实名规则.md
Normal file
56
docs/7月迭代/独立方案/原需求/需求01-复机实名规则.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# 需求01:行业卡后台手动复机允许未实名
|
||||
|
||||
> 状态:原需求来源稿;实名最终规则已由数据同步方案和标准评审稿修正。
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
**当前代码**:`internal/service/iot_card/stop_resume_service.go:938`
|
||||
|
||||
```go
|
||||
// ManualStartCard - 当前写法(错误)
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
return errors.New(errors.CodeForbidden, "卡未实名,无法操作")
|
||||
}
|
||||
```
|
||||
|
||||
`isRealnameOK()` 已经正确处理行业卡豁免:
|
||||
|
||||
```go
|
||||
// 第234行:行业卡无需实名
|
||||
func (s *StopResumeService) isRealnameOK(card *model.IotCard) bool {
|
||||
return card.CardCategory == constants.CardCategoryIndustry ||
|
||||
card.RealNameStatus == constants.RealNameStatusVerified
|
||||
}
|
||||
```
|
||||
|
||||
但 `ManualStartCard` 没有走这个函数,直接判断了 `RealNameStatus`,导致行业卡手动复机也被拦截。
|
||||
|
||||
---
|
||||
|
||||
## 修改范围
|
||||
|
||||
**只改一行**,影响范围极小。
|
||||
|
||||
**文件**:`internal/service/iot_card/stop_resume_service.go`
|
||||
|
||||
```go
|
||||
// 修改前(第938行)
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作")
|
||||
...
|
||||
}
|
||||
|
||||
// 修改后
|
||||
if !s.isRealnameOK(card) {
|
||||
denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作")
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端对接
|
||||
|
||||
无需前端改动。复机操作界面不变,行业卡原先会报错"卡未实名,无法操作",修复后直接成功。
|
||||
213
docs/7月迭代/独立方案/原需求/需求02-H5流程配置.md
Normal file
213
docs/7月迭代/独立方案/原需求/需求02-H5流程配置.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# 需求02:H5 流程顺序配置化
|
||||
|
||||
> 状态:原需求独立稿;最终口径以标准评审稿为准。
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
H5 用户进入后:绑定手机号 → 充值 → 实名(当前顺序写死)
|
||||
|
||||
需求:允许**按资产个体**配置充值和实名的顺序,支持后台单条或批量改。
|
||||
|
||||
---
|
||||
|
||||
## 流程图
|
||||
|
||||
### 图一:H5 登录后资产视角判断与策略读取
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[用户扫码 / 输入虚拟号] --> B[解析 identifier]
|
||||
B --> C{资产类型}
|
||||
|
||||
C -->|card| D{该卡是否绑定设备?}
|
||||
D -->|否 独立卡| E[卡视角\n读 IotCard.realname_policy]
|
||||
D -->|是| F[设备视角\n读 Device.realname_policy\n卡自身策略忽略]
|
||||
|
||||
C -->|device| F
|
||||
|
||||
E --> G{realname_policy}
|
||||
F --> G
|
||||
|
||||
G -->|none| H[无需实名\n直接进充值页]
|
||||
G -->|before_order| I[先进实名页\n实名完成后才能充值]
|
||||
G -->|after_order| J[先进充值页\n充值完成后提示实名]
|
||||
```
|
||||
|
||||
### 图二:H5 充值/购买前的策略拦截逻辑
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[用户发起充值/购买] --> B[读取 resolved.Asset.RealnamePolicy]
|
||||
B --> C{策略是 before_order?}
|
||||
C -->|否| D[放行,正常创建订单]
|
||||
C -->|是| E{当前资产 RealNameStatus == 1?}
|
||||
E -->|已实名| D
|
||||
E -->|未实名| F[返回 CodeNeedRealname\nH5 跳转实名页]
|
||||
```
|
||||
|
||||
### 图三:GetEffectiveRealnamePolicy 取值逻辑
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A[GetEffectiveRealnamePolicy\ncard, device] --> B{device != nil?}
|
||||
B -->|是| C[返回 device.RealnamePolicy]
|
||||
B -->|否| D{card != nil?}
|
||||
D -->|是| E[返回 card.RealnamePolicy]
|
||||
D -->|否| F[返回 none]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 资产类型与策略归属
|
||||
|
||||
系统有两类资产:**独立卡** 和 **设备**。
|
||||
|
||||
| 资产类型 | realname_policy 归属 | H5 视角 |
|
||||
|---------|---------------------|---------|
|
||||
| 独立卡(无设备绑定) | 卡自身的 `realname_policy` | 卡视角 |
|
||||
| 设备 | 设备自身的 `realname_policy` | 设备视角 |
|
||||
| 设备下的卡 | **设备**的 `realname_policy`(卡自身策略无效) | 设备视角 |
|
||||
|
||||
**关键规则**:设备下的卡无法以卡视角独立登录 H5,登录后自动进入设备视角,因此实名流程策略由设备决定,卡自身的 `realname_policy` 字段对 H5 流程无影响(但字段保留,仅作记录)。
|
||||
|
||||
该逻辑已在 `internal/service/asset/service.go:GetEffectiveRealnamePolicy()` 实现:设备不为 nil 时取设备策略,否则取卡策略。
|
||||
|
||||
---
|
||||
|
||||
## 当前字段状态
|
||||
|
||||
两个模型都已有 `realname_policy` 字段,无需迁移:
|
||||
|
||||
```go
|
||||
// internal/model/iot_card.go
|
||||
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;
|
||||
comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)"`
|
||||
|
||||
// internal/model/device.go
|
||||
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;
|
||||
comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)"`
|
||||
```
|
||||
|
||||
值含义:
|
||||
- `none` — 无需实名
|
||||
- `before_order` — 先实名后充值
|
||||
- `after_order` — 先充值后实名(**当前默认**)
|
||||
|
||||
---
|
||||
|
||||
## 后端实现
|
||||
|
||||
### 1. 单条修改接口(已有,无需新建)
|
||||
|
||||
```
|
||||
PATCH /api/admin/assets/:identifier/realname-mode
|
||||
```
|
||||
|
||||
该接口已在 `internal/handler/admin/asset.go:UpdateRealnamePolicy()` 实现,通过 identifier 自动解析资产类型,卡和设备都走这里,**不需要再建卡专属或设备专属路由**。
|
||||
|
||||
请求体(已有 DTO):
|
||||
```go
|
||||
type UpdateAssetRealnamePolicyRequest struct {
|
||||
RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order"
|
||||
description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"`
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 新增批量修改接口(需新建)
|
||||
|
||||
卡和设备分开批量接口,因为两者在后台是不同的列表页。
|
||||
|
||||
#### 2a. 批量修改卡实名策略
|
||||
|
||||
```
|
||||
POST /api/admin/iot-cards/batch-update-realname-policy
|
||||
```
|
||||
|
||||
请求体:
|
||||
```go
|
||||
type BatchUpdateIotCardRealnamePolicy struct {
|
||||
IotCardIDs []uint `json:"iot_card_ids" validate:"required,min=1,max=500,dive,gt=0" description:"卡ID列表(最多500条)"`
|
||||
RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order"
|
||||
description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"`
|
||||
}
|
||||
```
|
||||
|
||||
Service:在一个事务中校验最多 500 条 ID 均存在且均在当前账号数据范围内,再执行 `UPDATE tb_iot_card SET realname_policy = ? WHERE id IN (...)`。任一记录不合法则整批回滚,并写一条包含目标策略和 ID 数量的批量审计日志。
|
||||
|
||||
#### 2b. 批量修改设备实名策略
|
||||
|
||||
```
|
||||
POST /api/admin/devices/batch-update-realname-policy
|
||||
```
|
||||
|
||||
请求体:
|
||||
```go
|
||||
type BatchUpdateDeviceRealnamePolicy struct {
|
||||
DeviceIDs []uint `json:"device_ids" validate:"required,min=1,max=500,dive,gt=0" description:"设备ID列表(最多500条)"`
|
||||
RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order"
|
||||
description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"`
|
||||
}
|
||||
```
|
||||
|
||||
Service:与卡批量接口相同,单次最多 500 条、事务内全成全败;任一设备不存在或越权则不更新任何记录。
|
||||
|
||||
### 3. 全局默认值(兜底)
|
||||
|
||||
新建卡/设备时默认 `after_order`,通过 GORM default 标签保证,不需要读 `tb_system_config`。
|
||||
|
||||
---
|
||||
|
||||
## 前端对接
|
||||
|
||||
### 卡列表页
|
||||
|
||||
"操作"列或批量操作下拉增加"设置实名策略":
|
||||
|
||||
- **单条**:弹框选策略 → `PATCH /api/admin/assets/{iccid}/realname-mode`
|
||||
- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /api/admin/iot-cards/batch-update-realname-policy`
|
||||
|
||||
> 注意:设备下的卡即使在卡列表中修改了策略,对 H5 流程也无效(H5 取设备策略)。建议在卡列表展示"所属设备"列,提示运营该卡已属于某设备,实名策略需到设备处修改。
|
||||
|
||||
### 设备列表页
|
||||
|
||||
"操作"列或批量操作下拉增加"设置实名策略":
|
||||
|
||||
- **单条**:弹框选策略 → `PATCH /api/admin/assets/{sn}/realname-mode`
|
||||
- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /api/admin/devices/batch-update-realname-policy`
|
||||
|
||||
### 字段展示
|
||||
|
||||
卡列表/详情、设备列表/详情均展示"实名策略"字段:
|
||||
|
||||
| realname_policy | 展示文案 |
|
||||
|----------------|---------|
|
||||
| `none` | 无需实名 |
|
||||
| `before_order` | 先实名后充值 |
|
||||
| `after_order` | 先充值后实名 |
|
||||
|
||||
### H5 侧(C端)
|
||||
|
||||
H5 读取资产初始化接口返回的 `realname_policy` 字段,决定先跳充值页还是先跳实名页。
|
||||
|
||||
- 独立卡登录 → 读卡的 `realname_policy`
|
||||
- 设备/设备下的卡登录 → 读设备的 `realname_policy`
|
||||
|
||||
该逻辑由 `GetEffectiveRealnamePolicy()` 统一处理,H5 无需区分资产类型,直接用接口返回值即可。
|
||||
|
||||
---
|
||||
|
||||
## 实施范围汇总
|
||||
|
||||
| 项目 | 状态 | 说明 |
|
||||
|------|------|------|
|
||||
| `IotCard.realname_policy` 字段 | ✅ 已有 | 无需迁移 |
|
||||
| `Device.realname_policy` 字段 | ✅ 已有 | 无需迁移 |
|
||||
| 单条修改接口 | ✅ 已有 | `PATCH /api/admin/assets/:identifier/realname-mode` |
|
||||
| `GetEffectiveRealnamePolicy()` | ✅ 已有 | 设备视角取设备策略 |
|
||||
| H5 充值前校验 | ✅ 已有 | `client_wallet.go` 已正确读取 |
|
||||
| 批量修改卡接口 | ❌ 待建 | `POST /api/admin/iot-cards/batch-update-realname-policy` |
|
||||
| 批量修改设备接口 | ❌ 待建 | `POST /api/admin/devices/batch-update-realname-policy` |
|
||||
| 后台卡列表操作入口 | ❌ 待建(前端) | 单条+批量 |
|
||||
| 后台设备列表操作入口 | ❌ 待建(前端) | 单条+批量 |
|
||||
321
docs/7月迭代/独立方案/原需求/需求03-07-11-12-13-简单改动.md
Normal file
321
docs/7月迭代/独立方案/原需求/需求03-07-11-12-13-简单改动.md
Normal file
@@ -0,0 +1,321 @@
|
||||
# 需求03/07/11/12/13:简单改动合集
|
||||
|
||||
> 状态:原需求独立稿;最终口径以标准评审稿为准。
|
||||
|
||||
---
|
||||
|
||||
## 需求03:店铺列表搜索新增联系电话
|
||||
|
||||
### 后端
|
||||
|
||||
`Shop` 表已有 `contact_phone` 字段。仅需在列表查询接口新增过滤条件。
|
||||
|
||||
**文件**:`internal/store/postgres/shop_store.go`(列表查询 Store 方法)
|
||||
|
||||
```go
|
||||
// 现有过滤条件基础上追加
|
||||
if req.ContactPhone != "" {
|
||||
query = query.Where("contact_phone = ?", req.ContactPhone)
|
||||
}
|
||||
```
|
||||
|
||||
**DTO 变更**:`internal/model/dto/shop_dto.go` 的 `ShopListRequest` 新增:
|
||||
|
||||
```go
|
||||
ContactPhone string `json:"contact_phone" query:"contact_phone" validate:"omitempty,len=11" minLength:"11" maxLength:"11" description:"联系人电话(精确匹配,11位)"`
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
店铺列表搜索栏新增"联系电话"输入框,填入后带入 `contact_phone` 参数请求。
|
||||
|
||||
---
|
||||
|
||||
## 需求07:IoT卡/设备管理新增已实名/未实名筛选
|
||||
|
||||
### IoT 卡
|
||||
|
||||
`IotCard.real_name_status` 已有(0=未实名, 1=已实名),`ListStandaloneIotCardRequest` 无该过滤字段,需新增。
|
||||
|
||||
**DTO 变更**(`internal/model/dto/iot_card_dto.go` → `ListStandaloneIotCardRequest` 新增):
|
||||
|
||||
```go
|
||||
RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,oneof=0 1" description:"实名状态 (0:未实名, 1:已实名)"`
|
||||
```
|
||||
|
||||
**Store 追加**(`internal/store/postgres/iot_card_store.go`):
|
||||
```go
|
||||
if req.RealNameStatus != nil {
|
||||
query = query.Where("real_name_status = ?", *req.RealNameStatus)
|
||||
}
|
||||
```
|
||||
|
||||
### 设备
|
||||
|
||||
设备本身目前无 `real_name_status` 字段。语义为:任意一张绑定卡已实名 = 设备已实名。
|
||||
|
||||
为避免列表查询时走 EXISTS 子查询,改为**快照方案**:在 `Device` 表落盘,轮询时维护。
|
||||
|
||||
#### 迁移
|
||||
|
||||
`tb_device` 新增字段:
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_device
|
||||
ADD COLUMN real_name_status INT NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN tb_device.real_name_status
|
||||
IS '实名状态快照(0=未实名,1=已实名),任意绑定卡已实名则为1,由轮询异步维护';
|
||||
```
|
||||
|
||||
**Model**(`internal/model/device.go`):
|
||||
```go
|
||||
RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态快照(0=未实名,1=已实名),任意绑定卡已实名则为1" json:"real_name_status"`
|
||||
```
|
||||
|
||||
#### 快照更新时机
|
||||
|
||||
以下两处卡实名状态变化时,需同步更新所属设备的快照:
|
||||
|
||||
**1. 轮询实名处理**(`internal/task/polling_realname_handler.go`)
|
||||
|
||||
卡状态变化后,已有 `triggerDeviceRealnameActivation` 查出 `deviceID`,在此同步更新设备快照:
|
||||
|
||||
```go
|
||||
// statusChanged 时,如果卡属于某设备,重新计算并写入设备快照
|
||||
if statusChanged {
|
||||
if binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, cardID); err == nil {
|
||||
h.deviceStore.RefreshRealnameSnapshot(ctx, binding.DeviceID)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**2. 管理员手动修改卡实名状态**(`internal/service/iot_card/service.go:ManualUpdateRealnameStatus`)
|
||||
|
||||
更新卡状态成功后,查所属设备并更新快照(同上逻辑)。
|
||||
|
||||
#### 快照计算
|
||||
|
||||
`DeviceStore.RefreshRealnameSnapshot`:
|
||||
|
||||
```go
|
||||
// RefreshRealnameSnapshot 重新计算并写入设备实名状态快照
|
||||
func (s *DeviceStore) RefreshRealnameSnapshot(ctx context.Context, deviceID uint) error {
|
||||
var count int64
|
||||
s.db.WithContext(ctx).Raw(`
|
||||
SELECT COUNT(*) FROM tb_device_sim_binding dsb
|
||||
JOIN tb_iot_card ic ON ic.id = dsb.iot_card_id
|
||||
WHERE dsb.device_id = ? AND dsb.deleted_at IS NULL
|
||||
AND ic.real_name_status = 1 AND ic.deleted_at IS NULL
|
||||
`, deviceID).Scan(&count)
|
||||
status := 0
|
||||
if count > 0 {
|
||||
status = 1
|
||||
}
|
||||
return s.db.WithContext(ctx).Model(&model.Device{}).
|
||||
Where("id = ?", deviceID).
|
||||
Update("real_name_status", status).Error
|
||||
}
|
||||
```
|
||||
|
||||
#### DTO 变更
|
||||
|
||||
**请求**(`internal/model/dto/device_dto.go` → `ListDeviceRequest` 新增):
|
||||
```go
|
||||
RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,oneof=0 1" description:"实名状态 (0:未实名, 1:已实名)"`
|
||||
```
|
||||
|
||||
**响应**(`DeviceResponse` 新增):
|
||||
```go
|
||||
RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"`
|
||||
RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称(中文)"`
|
||||
```
|
||||
|
||||
**Store 过滤**(直接 WHERE,无需 EXISTS):
|
||||
```go
|
||||
if req.RealNameStatus != nil {
|
||||
query = query.Where("real_name_status = ?", *req.RealNameStatus)
|
||||
}
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
IoT卡管理筛选栏新增"实名状态"下拉(全部/已实名/未实名)→ 传 `real_name_status=0|1`。
|
||||
设备管理同上,列表展示 `real_name_status_name` 字段。
|
||||
|
||||
---
|
||||
|
||||
## 需求11:资产详情套餐到期时间(与需求06合并)
|
||||
|
||||
需求11和需求06属于同一业务需求:资产层只展示当前及排队主套餐连续使用后的预计最终到期时间。详细计算、DTO 和前端规则统一见[需求04/06独立稿](./需求04-06-退款拦截与最后到期时间.md)。
|
||||
|
||||
现有 `current_package_expires_at` 只表示当前套餐结束时间,不能代表资产服务最终结束时间,因此不得继续作为资产详情汇总到期时间或临期判断依据。
|
||||
|
||||
统一使用:
|
||||
|
||||
```text
|
||||
estimated_final_expires_at
|
||||
days_until_final_expiry
|
||||
expiry_estimate_status
|
||||
```
|
||||
|
||||
前端不自行计算天数;高亮颜色和临期状态直接使用需求22统一返回字段。
|
||||
|
||||
---
|
||||
|
||||
## 需求12:换货管理显示修复
|
||||
|
||||
### 背景
|
||||
|
||||
换货单表 `tb_exchange_order`:
|
||||
- `old_asset_identifier` — 旧资产标识符快照
|
||||
- `new_asset_identifier` — 新资产标识符快照
|
||||
- `old_asset_id` / `new_asset_id` — 旧/新资产主键
|
||||
|
||||
### EXC-001/EXC-002:旧/新资产标识显示不一致
|
||||
|
||||
**根本原因**:后端创建换货单时快照逻辑有误(`internal/service/exchange/service.go`)。
|
||||
|
||||
- 卡的旧资产:快照了 `card.VirtualNo`(虚拟号),**应为 `card.ICCID`**
|
||||
- 卡的新资产:快照了操作员输入的 identifier 原值,未规范化,**应统一为 `card.ICCID`**
|
||||
- 设备:快照 `VirtualNo` 优先,没有则 `IMEI`,**逻辑正确,无需改动**
|
||||
|
||||
**修复**(`internal/service/exchange/service.go`):
|
||||
|
||||
`resolveAssetByIdentifierWithTx` 及锁定资产路径中,卡的 `Identifier` 改为 `card.ICCID`:
|
||||
|
||||
```go
|
||||
// 修复前
|
||||
return &resolvedExchangeAsset{..., Identifier: card.VirtualNo, ...}
|
||||
|
||||
// 修复后
|
||||
return &resolvedExchangeAsset{..., Identifier: card.ICCID, ...}
|
||||
```
|
||||
|
||||
历史数据不回填,仅修正后续新建换货单的快照行为。
|
||||
|
||||
### EXC-003/EXC-004:旧/新资产搜索支持 ICCID/接入号/虚拟号
|
||||
|
||||
**方案**:拆分为独立的旧资产和新资产搜索,搜索逻辑用**两步查询**,不用 JOIN。
|
||||
|
||||
**DTO 变更**(`internal/model/dto/exchange_dto.go` → `ExchangeListRequest`):
|
||||
|
||||
废弃原有 `Identifier` 字段,改为:
|
||||
```go
|
||||
OldAssetKeyword string `json:"old_asset_keyword" query:"old_asset_keyword" validate:"omitempty,max=100" description:"旧资产搜索(ICCID/接入号/虚拟号)"`
|
||||
NewAssetKeyword string `json:"new_asset_keyword" query:"new_asset_keyword" validate:"omitempty,max=100" description:"新资产搜索(ICCID/接入号/虚拟号)"`
|
||||
```
|
||||
|
||||
**Store 修改**(`internal/store/postgres/exchange_order_store.go`):
|
||||
|
||||
两步查询——先在资产表搜出 ID,再过滤换货表:
|
||||
|
||||
```go
|
||||
// 步骤1:旧资产关键词搜索
|
||||
if req.OldAssetKeyword != "" {
|
||||
kw := "%" + req.OldAssetKeyword + "%"
|
||||
var cardIDs []uint
|
||||
s.db.WithContext(ctx).Table("tb_iot_card").
|
||||
Where("(iccid LIKE ? OR virtual_no LIKE ? OR msisdn LIKE ?) AND deleted_at IS NULL", kw, kw, kw).
|
||||
Pluck("id", &cardIDs)
|
||||
var deviceIDs []uint
|
||||
s.db.WithContext(ctx).Table("tb_device").
|
||||
Where("(virtual_no LIKE ? OR imei LIKE ?) AND deleted_at IS NULL", kw, kw).
|
||||
Pluck("id", &deviceIDs)
|
||||
|
||||
if len(cardIDs) == 0 && len(deviceIDs) == 0 {
|
||||
return &ExchangeListResult{}, nil // 无匹配,直接返回空
|
||||
}
|
||||
query = query.Where(
|
||||
"(old_asset_type = 'iot_card' AND old_asset_id IN ?) OR (old_asset_type = 'device' AND old_asset_id IN ?)",
|
||||
cardIDs, deviceIDs,
|
||||
)
|
||||
}
|
||||
// new_asset_keyword 同理,过滤 new_asset_id
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
- EXC-001/002:后端修复后,`old_asset_identifier` 和 `new_asset_identifier` 均为 ICCID(卡)或设备号(设备),展示直接读这两个字段即可
|
||||
- EXC-003/004:搜索栏拆分为"旧资产"和"新资产"两个独立输入框,分别传 `old_asset_keyword` 和 `new_asset_keyword`
|
||||
|
||||
---
|
||||
|
||||
## 需求13:列表字段新增
|
||||
|
||||
### 核心原则
|
||||
|
||||
- 提交人账号名在业务单创建时快照到业务表。
|
||||
- 审批节点、候选审批人和实际操作人快照统一保存在审批流任务表,不在业务表写死具体节点字段。
|
||||
- 列表查询审批信息时,根据本页全部 `approval_instance_id` 批量查询并在内存分组,禁止逐条查询造成 N+1。
|
||||
|
||||
---
|
||||
|
||||
### COL-003:换货管理列表新增提交人(待建)
|
||||
|
||||
> 需求文档原写"换号管理",确认为"换货管理"(系统无"换号"概念)。
|
||||
|
||||
**迁移**:`tb_exchange_order` 新增字段:
|
||||
```sql
|
||||
ALTER TABLE tb_exchange_order ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT '';
|
||||
```
|
||||
|
||||
**Model**(`internal/model/exchange_order.go`):
|
||||
```go
|
||||
SubmitterName string `gorm:"column:submitter_name;type:varchar(50);not null;default:'';comment:提交人账号名快照" json:"submitter_name"`
|
||||
```
|
||||
|
||||
**创建换货单时**(`internal/service/exchange/service.go`)快照当前操作人 username:
|
||||
```go
|
||||
SubmitterName: middleware.GetUsername(ctx), // 从 ctx 取当前登录账号的 username
|
||||
```
|
||||
|
||||
**响应 DTO**(`internal/model/dto/exchange_dto.go` → `ExchangeOrderResponse` 新增):
|
||||
```go
|
||||
SubmitterName string `json:"submitter_name" description:"提交人账号名"`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### COL-001:退款管理列表新增提交人、审批人(依赖审批流)
|
||||
|
||||
**迁移**:`tb_refund_request` 新增提交人快照字段;`approval_instance_id` 由需求18/20统一增加:
|
||||
```sql
|
||||
ALTER TABLE tb_refund_request
|
||||
ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT '';
|
||||
```
|
||||
|
||||
- `submitter_name`:创建退款单时快照操作人 username
|
||||
- 审批状态、当前节点和审批记录:从审批实例、任务和任务审批人快照批量读取
|
||||
|
||||
> **实施依赖**:动态审批摘要依赖审批流(需求20);`submitter_name` 可独立实现。
|
||||
|
||||
**响应 DTO**(退款列表响应新增):
|
||||
```go
|
||||
SubmitterName string `json:"submitter_name" description:"提交人账号名"`
|
||||
ApprovalSource string `json:"approval_source" description:"审批来源 (none:无需审批, workflow:通用审批流, legacy:历史业务审批)"`
|
||||
ApprovalStatus int `json:"approval_status" description:"审批状态 (1:审批中, 2:已通过, 3:已驳回, 4:已退回)"`
|
||||
ApprovalStatusName string `json:"approval_status_name" description:"审批状态名称(中文)"`
|
||||
CurrentApprovalNode string `json:"current_approval_node" description:"当前审批节点名称"`
|
||||
ApprovalRecords []ApprovalRecordSummary `json:"approval_records" description:"审批节点和审批人摘要"`
|
||||
ProcessingStatus int `json:"processing_status" description:"审批通过后的业务处理状态"`
|
||||
ProcessingStatusName string `json:"processing_status_name" description:"业务处理状态名称(中文)"`
|
||||
```
|
||||
|
||||
`ApprovalRecordSummary` 动态返回 `node_name`、`approval_mode`、`status` 和审批人列表;每位已操作审批人包含动作、审批意见和 `attachment_count`,但列表接口不返回完整附件元数据。不假设固定存在“部门领导”或“财务”节点。
|
||||
|
||||
停机发布前已经结束且没有流程实例的退款记录返回 `approval_source=legacy`。这类记录可以使用原 `processor_id`、`processed_at` 和审计日志组成只读历史摘要,但不得伪造多节点审批时间线;发布时仍待审批的记录必须先回填通用审批实例。
|
||||
|
||||
---
|
||||
|
||||
### COL-002:代理充值列表新增提交人、审批人(依赖审批流)
|
||||
|
||||
与 COL-001 同理,`tb_agent_recharge_record` 仅新增 `submitter_name` 快照字段;`approval_instance_id` 由需求18/21统一增加。审批摘要从审批流批量读取。历史终态充值返回 `approval_source=legacy` 并只读展示原状态和审计信息。
|
||||
|
||||
> **实施依赖**:`submitter_name` 本迭代可实现;动态审批摘要依赖需求21(充值审批流)。
|
||||
|
||||
---
|
||||
|
||||
### 前端
|
||||
|
||||
退款和充值列表增加“审批状态 / 当前节点 / 业务处理状态 / 审批记录”展示。审批记录按节点动态渲染,不能固定绑定两个审批人字段;审批已通过后的代理钱包退款可显示“回退处理中”,其他支付方式显示“待人工退款”,都不能显示成“待审批”。`approval_source=legacy` 时显示“历史审批”标识且不提供操作按钮。
|
||||
206
docs/7月迭代/独立方案/原需求/需求04-06-退款拦截与最后到期时间.md
Normal file
206
docs/7月迭代/独立方案/原需求/需求04-06-退款拦截与最后到期时间.md
Normal file
@@ -0,0 +1,206 @@
|
||||
# 需求04:退款中资产禁止换货
|
||||
# 需求06/11:资产预计最终到期时间
|
||||
|
||||
> 状态:原需求独立稿;最终口径以标准评审稿为准。
|
||||
|
||||
---
|
||||
|
||||
## 需求04:退款中禁止换货
|
||||
|
||||
### 业务规则
|
||||
|
||||
资产存在**未结束**的退款申请时,不允许操作换货,提示"该资产存在退款申请,无法操作换货"。
|
||||
|
||||
拦截范围:
|
||||
|
||||
- `status=1` 待审批。
|
||||
- `status=4` 已退回,等待提交人修改。
|
||||
- `status=2` 已通过但 `processing_status!=2`,实际退款仍在处理、等待处理或失败重试。
|
||||
|
||||
不拦截:`status=3` 已拒绝,或 `status=2 AND processing_status=2` 已完成实际退款。`processing_status` 由需求20新增。
|
||||
|
||||
### 数据模型
|
||||
|
||||
退款模型:`RefundRequest`(表 `tb_refund_request`)
|
||||
|
||||
资产字段为两个独立字段(无 asset_type/asset_id):
|
||||
- `iot_card_id *uint`:IoT卡ID(卡类资产)
|
||||
- `device_id *uint`:设备ID(设备类资产)
|
||||
|
||||
状态常量(`internal/model/refund.go`):
|
||||
```go
|
||||
RefundStatusPending = 1 // 待审批
|
||||
RefundStatusApproved = 2 // 已通过
|
||||
RefundStatusRejected = 3 // 已拒绝
|
||||
RefundStatusReturned = 4 // 已退回(退回给提交人,仍拦截换货)
|
||||
```
|
||||
|
||||
### 实现位置
|
||||
|
||||
换货单创建入口:`internal/service/exchange/service.go` 创建前校验。
|
||||
|
||||
### 后端
|
||||
|
||||
**Store 新增方法**(`internal/store/postgres/refund_store.go`):
|
||||
|
||||
```go
|
||||
// HasActiveRefundByCard 检查指定IoT卡是否存在未结束的退款申请
|
||||
func (s *RefundStore) HasActiveRefundByCard(ctx context.Context, cardID uint) (bool, error) {
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where(`iot_card_id = ?
|
||||
AND deleted_at IS NULL
|
||||
AND (
|
||||
status IN (?, ?)
|
||||
OR (status = ? AND processing_status <> ?)
|
||||
)`,
|
||||
cardID,
|
||||
model.RefundStatusPending, // 1=待审批
|
||||
model.RefundStatusReturned, // 4=已退回(拦截)
|
||||
model.RefundStatusApproved, // 2=审批已通过
|
||||
constants.ProcessingStatusSucceeded,
|
||||
).Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
|
||||
// HasActiveRefundByDevice 检查指定设备是否存在未结束的退款申请
|
||||
func (s *RefundStore) HasActiveRefundByDevice(ctx context.Context, deviceID uint) (bool, error) {
|
||||
var count int64
|
||||
err := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
|
||||
Where(`device_id = ?
|
||||
AND deleted_at IS NULL
|
||||
AND (
|
||||
status IN (?, ?)
|
||||
OR (status = ? AND processing_status <> ?)
|
||||
)`,
|
||||
deviceID,
|
||||
model.RefundStatusPending, // 1=待审批
|
||||
model.RefundStatusReturned, // 4=已退回(拦截)
|
||||
model.RefundStatusApproved, // 2=审批已通过
|
||||
constants.ProcessingStatusSucceeded,
|
||||
).Count(&count).Error
|
||||
return count > 0, err
|
||||
}
|
||||
```
|
||||
|
||||
**Service 校验**(`internal/service/exchange/service.go` 创建换货单前调用):
|
||||
|
||||
```go
|
||||
// validateNoActiveRefund 校验资产是否有未结束的退款申请
|
||||
func (s *ExchangeService) validateNoActiveRefund(ctx context.Context, asset *resolvedExchangeAsset) error {
|
||||
var hasActive bool
|
||||
var err error
|
||||
if asset.CardID != nil {
|
||||
hasActive, err = s.refundStore.HasActiveRefundByCard(ctx, *asset.CardID)
|
||||
} else if asset.DeviceID != nil {
|
||||
hasActive, err = s.refundStore.HasActiveRefundByDevice(ctx, *asset.DeviceID)
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if hasActive {
|
||||
return errors.New(errors.CodeForbidden, "该资产存在退款申请,无法操作换货")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
无需改动。换货申请时后端返回错误,前端展示错误信息即可。
|
||||
|
||||
---
|
||||
|
||||
## 需求06/11:资产详情-预计最终到期时间
|
||||
|
||||
### 业务规则
|
||||
|
||||
需求06与需求11是同一个业务需求的两种描述,不再拆成“当前套餐到期”和“所有套餐最后到期”两个资产汇总字段。资产详情页只展示:**当前生效主套餐 + 所有待生效主套餐按队列接续后的预计最终到期时间**。
|
||||
|
||||
不能只取已经写入 `expires_at` 的最大值。排队套餐通常尚未激活,`expires_at` 为空,但其购买时的周期和时长已经确定,正常情况下仍可推算最终到期时间。
|
||||
|
||||
加油包不延长主套餐服务周期,不参与本字段计算。已失效、已退款或已过期的使用记录不参与。
|
||||
|
||||
### 接口
|
||||
|
||||
后台资产详情页实际调用的是:
|
||||
|
||||
```
|
||||
GET /api/admin/assets/resolve/:identifier
|
||||
```
|
||||
|
||||
响应 DTO:`AssetResolveResponse`(`internal/model/dto/asset_dto.go`)
|
||||
|
||||
该 DTO 目前只有当前套餐到期时间,需新增统一的最终到期响应,并由前端替代原资产汇总展示。
|
||||
|
||||
### 后端
|
||||
|
||||
**DTO 新增字段**(`internal/model/dto/asset_dto.go` → `AssetResolveResponse`):
|
||||
|
||||
```go
|
||||
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at" description:"当前及排队主套餐接续后的预计最终到期时间"`
|
||||
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余自然日"`
|
||||
ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态 (exact:可推算, waiting_activation:等待未知激活时间, none:无套餐)"`
|
||||
```
|
||||
|
||||
**Store 新增方法**(`internal/store/postgres/package_usage_store.go`):
|
||||
|
||||
```go
|
||||
// GetProjectableMainPackagesByCardID 获取可推算的当前/排队主套餐。
|
||||
func (s *PackageUsageStore) GetProjectableMainPackagesByCardID(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) {
|
||||
var usages []*model.PackageUsage
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("iot_card_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", cardID,
|
||||
constants.PackageUsageStatusActive, // 1=生效中
|
||||
constants.PackageUsageStatusPending, // 0=待生效
|
||||
constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期
|
||||
).
|
||||
Order("priority ASC, created_at ASC, id ASC").
|
||||
Find(&usages).Error
|
||||
return usages, err
|
||||
}
|
||||
|
||||
// GetProjectableMainPackagesByDeviceID 获取可推算的当前/排队主套餐。
|
||||
func (s *PackageUsageStore) GetProjectableMainPackagesByDeviceID(ctx context.Context, deviceID uint) ([]*model.PackageUsage, error) {
|
||||
var usages []*model.PackageUsage
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("device_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", deviceID,
|
||||
constants.PackageUsageStatusActive, // 1=生效中
|
||||
constants.PackageUsageStatusPending, // 0=待生效
|
||||
constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期
|
||||
).
|
||||
Order("priority ASC, created_at ASC, id ASC").
|
||||
Find(&usages).Error
|
||||
return usages, err
|
||||
}
|
||||
```
|
||||
|
||||
新建 `PackageUsage` 必须快照 `calendar_type_snapshot`、`duration_months_snapshot`、`duration_days_snapshot`,与需求05的 `expiry_base_snapshot` 一起在购买时写入。旧记录没有时长快照时才回退读取当前套餐,且仅作为历史兼容。
|
||||
|
||||
**Service 计算逻辑**(在 `ResolveAsset` 结果组装处添加):
|
||||
|
||||
```go
|
||||
// 1. 找当前主套餐的 expires_at 作为 cursor。
|
||||
// 2. 按 priority、created_at、id 遍历排队主套餐。
|
||||
// 3. 每个排队套餐以 cursor 为预计激活点,使用其购买时长快照计算新的 cursor。
|
||||
// 4. cursor 即预计最后到期时间。
|
||||
lastExpiry, err := s.packageUsageStore.ProjectLastMainPackageExpiry(ctx, assetType, assetID, now)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.EstimatedFinalExpiresAt = lastExpiry
|
||||
```
|
||||
|
||||
`ProjectLastMainPackageExpiry` 是 Query 计算,不写快照表:当前主套餐到期时间变化、排队套餐新增/退款失效后,下一次详情查询立即反映。若资产没有当前套餐且队首套餐仍等待无法预测的外部前置条件(例如尚未实名),返回 `null`,前端显示“—”,不伪造日期。
|
||||
|
||||
### 前端
|
||||
|
||||
资产详情“套餐信息”板块只保留一个资产汇总展示:
|
||||
|
||||
```
|
||||
预计套餐到期时间:2027-01-01
|
||||
```
|
||||
|
||||
读取 `estimated_final_expires_at`。`exact` 时展示日期;`waiting_activation` 时展示“待激活后起算”;`none` 时展示“—”。当前套餐自身的 `expires_at` 仅在套餐明细列表展示,不再作为第二个资产汇总字段。
|
||||
|
||||
高亮和临期提醒统一使用 `days_until_final_expiry`,前端不得再根据 `current_package_expires_at` 自行计算另一套剩余天数。
|
||||
195
docs/7月迭代/独立方案/原需求/需求05-套餐分配生效条件.md
Normal file
195
docs/7月迭代/独立方案/原需求/需求05-套餐分配生效条件.md
Normal file
@@ -0,0 +1,195 @@
|
||||
# 需求05:套餐分配生效条件(ExpiryBase 覆盖)
|
||||
|
||||
> 状态:原需求独立稿;最终口径以标准评审稿为准。
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
`Package.ExpiryBase` 已存在(`from_activation` / `from_purchase`),在套餐创建时设定,控制套餐何时开始计时。
|
||||
|
||||
需求:分配套餐给代理时,可以对单条分配记录二次覆盖这个值。
|
||||
|
||||
---
|
||||
|
||||
## 快照链设计
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
Package[套餐默认 ExpiryBase] --> Effective{分配记录是否覆盖?}
|
||||
Allocation[ShopPackageAllocation.expiry_base_override] --> Effective
|
||||
Effective -->|有覆盖| Override[使用分配覆盖值]
|
||||
Effective -->|无覆盖| Default[使用套餐默认值]
|
||||
Override --> Snapshot[订单创建时写入 PackageUsage.expiry_base_snapshot]
|
||||
Default --> Snapshot
|
||||
Snapshot --> Activation[套餐激活只读快照]
|
||||
Legacy[旧数据快照为空] --> Fallback[兜底读取套餐默认值]
|
||||
Fallback --> Activation
|
||||
```
|
||||
|
||||
遗留数据兜底:`ExpiryBaseSnapshot` 为空(旧数据)时,回退读 `pkg.ExpiryBase`,行为不变。
|
||||
|
||||
---
|
||||
|
||||
## 数据库变更
|
||||
|
||||
### 1. ShopPackageAllocation 新增覆盖字段
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
ADD COLUMN expiry_base_override VARCHAR(30);
|
||||
|
||||
COMMENT ON COLUMN tb_shop_package_allocation.expiry_base_override
|
||||
IS '生效条件覆盖(NULL=使用套餐默认值, from_activation=实名即生效, from_purchase=购买即生效)';
|
||||
```
|
||||
|
||||
### 2. PackageUsage 新增快照字段
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_package_usage
|
||||
ADD COLUMN expiry_base_snapshot VARCHAR(30) NOT NULL DEFAULT '',
|
||||
ADD COLUMN calendar_type_snapshot VARCHAR(20) NOT NULL DEFAULT '',
|
||||
ADD COLUMN duration_months_snapshot INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN duration_days_snapshot INT NOT NULL DEFAULT 0;
|
||||
|
||||
COMMENT ON COLUMN tb_package_usage.expiry_base_snapshot
|
||||
IS '生效条件快照(创建时从分配记录取有效值写入,空字符串=旧数据兜底读套餐原值)';
|
||||
COMMENT ON COLUMN tb_package_usage.calendar_type_snapshot
|
||||
IS '周期类型快照(空字符串=旧数据兜底读套餐原值)';
|
||||
COMMENT ON COLUMN tb_package_usage.duration_months_snapshot
|
||||
IS '月数快照(0=旧数据兜底读套餐原值)';
|
||||
COMMENT ON COLUMN tb_package_usage.duration_days_snapshot
|
||||
IS '天数快照(0=旧数据兜底读套餐原值)';
|
||||
```
|
||||
|
||||
旧数据不回填,默认空字符串,激活时自动兜底。
|
||||
|
||||
---
|
||||
|
||||
## Model 变更
|
||||
|
||||
### ShopPackageAllocation(`internal/model/shop_package_allocation.go`)
|
||||
|
||||
```go
|
||||
// ExpiryBaseOverride 生效条件覆盖
|
||||
// NULL = 使用宿主套餐的 ExpiryBase;有值 = 分配时指定,不受套餐后续修改影响
|
||||
ExpiryBaseOverride *string `gorm:"column:expiry_base_override;type:varchar(30);comment:生效条件覆盖 NULL=使用套餐默认 from_activation=实名即生效 from_purchase=购买即生效" json:"expiry_base_override"`
|
||||
```
|
||||
|
||||
### PackageUsage(`internal/model/package.go`)
|
||||
|
||||
```go
|
||||
// ExpiryBaseSnapshot 生效条件快照(创建订单时写入,空字符串=旧数据兜底读套餐原值)
|
||||
ExpiryBaseSnapshot string `gorm:"column:expiry_base_snapshot;type:varchar(30);not null;default:'';comment:生效条件快照 创建时从分配记录取有效值" json:"expiry_base_snapshot"`
|
||||
|
||||
// 以下三个字段和 ExpiryBaseSnapshot 一起固化,供激活和排队最终到期时间计算使用。
|
||||
CalendarTypeSnapshot string `gorm:"column:calendar_type_snapshot;type:varchar(20);not null;default:'';comment:套餐周期类型快照" json:"calendar_type_snapshot"`
|
||||
DurationMonthsSnapshot int `gorm:"column:duration_months_snapshot;not null;default:0;comment:套餐月数快照" json:"duration_months_snapshot"`
|
||||
DurationDaysSnapshot int `gorm:"column:duration_days_snapshot;not null;default:0;comment:套餐天数快照" json:"duration_days_snapshot"`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 业务逻辑变更
|
||||
|
||||
### 1. 订单创建时快照(`internal/service/order/service.go`)
|
||||
|
||||
订单创建已通过 `GetByShopAndPackage` 查询分配记录(现有逻辑),在此基础上追加:
|
||||
|
||||
```go
|
||||
// 取生效条件有效值:分配覆盖 > 套餐默认
|
||||
expiryBase := pkg.ExpiryBase
|
||||
if allocation.ExpiryBaseOverride != nil && *allocation.ExpiryBaseOverride != "" {
|
||||
expiryBase = *allocation.ExpiryBaseOverride
|
||||
}
|
||||
// 创建 PackageUsage 时一次性写入计时快照
|
||||
usage.ExpiryBaseSnapshot = expiryBase
|
||||
usage.CalendarTypeSnapshot = pkg.CalendarType
|
||||
usage.DurationMonthsSnapshot = pkg.DurationMonths
|
||||
usage.DurationDaysSnapshot = pkg.DurationDays
|
||||
```
|
||||
|
||||
### 2. 激活时读快照(`internal/service/package/activation_service.go`)
|
||||
|
||||
```go
|
||||
// 新订单只读购买快照;旧记录兼容回退套餐当前值。
|
||||
expiryBase := usage.ExpiryBaseSnapshot
|
||||
if expiryBase == "" {
|
||||
expiryBase = pkg.ExpiryBase
|
||||
}
|
||||
calendarType := usage.CalendarTypeSnapshot
|
||||
if calendarType == "" {
|
||||
calendarType = pkg.CalendarType
|
||||
}
|
||||
durationMonths := usage.DurationMonthsSnapshot
|
||||
if durationMonths == 0 {
|
||||
durationMonths = pkg.DurationMonths
|
||||
}
|
||||
durationDays := usage.DurationDaysSnapshot
|
||||
if durationDays == 0 {
|
||||
durationDays = pkg.DurationDays
|
||||
}
|
||||
```
|
||||
|
||||
同文件所有激活和排队接续位置都使用同一快照解析函数,禁止某一处重新读取可修改的 `Package` 字段。
|
||||
|
||||
`internal/service/order/service.go` 中后台囤货路径的 `ExpiryBase` 判断也使用已创建的使用记录快照;需求06的“预计最后到期时间”同样只读这组快照,保证购买后套餐配置变更不会改写历史预测。
|
||||
|
||||
---
|
||||
|
||||
## API 变更
|
||||
|
||||
### 1. 分配套餐接口(新增参数)
|
||||
|
||||
```
|
||||
POST /api/admin/shop-package-allocations
|
||||
```
|
||||
|
||||
请求 DTO 新增字段:
|
||||
|
||||
```go
|
||||
ExpiryBaseOverride *string `json:"expiry_base_override" validate:"omitempty,oneof=from_activation from_purchase" description:"生效条件覆盖(不传=使用套餐默认, from_activation=实名即生效, from_purchase=购买即生效)"`
|
||||
```
|
||||
|
||||
### 2. 修改已分配套餐的生效条件(新接口)
|
||||
|
||||
```
|
||||
PATCH /api/admin/shop-package-allocations/{id}/expiry-base
|
||||
```
|
||||
|
||||
请求 DTO:
|
||||
|
||||
```go
|
||||
type UpdateAllocationExpiryBaseRequest struct {
|
||||
ExpiryBaseOverride *string `json:"expiry_base_override" validate:"omitempty,oneof=from_activation from_purchase" description:"生效条件(null=恢复套餐默认, from_activation=实名即生效, from_purchase=购买即生效)"`
|
||||
}
|
||||
```
|
||||
|
||||
> 注意:修改已有分配记录的覆盖值,**不影响**已创建的 PackageUsage(快照已定),只影响后续新建的订单。
|
||||
|
||||
---
|
||||
|
||||
## 前端对接
|
||||
|
||||
### 套餐分配弹框
|
||||
|
||||
新增"生效条件"选择项:
|
||||
|
||||
```
|
||||
生效条件:
|
||||
○ 跟随套餐默认(默认选中,不传 expiry_base_override)
|
||||
○ 购买即生效(from_purchase)
|
||||
○ 实名即生效(from_activation)
|
||||
```
|
||||
|
||||
### 已分配套餐列表
|
||||
|
||||
列表新增"生效条件"列:
|
||||
|
||||
| 值 | 展示 |
|
||||
|----|------|
|
||||
| NULL | 套餐默认 |
|
||||
| `from_activation` | 实名即生效(已覆盖) |
|
||||
| `from_purchase` | 购买即生效(已覆盖) |
|
||||
|
||||
操作列增加"修改生效条件"按钮,调用 `PATCH /api/admin/shop-package-allocations/{id}/expiry-base`。
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user