Compare commits
25 Commits
hotfix/wal
...
2e130b98f5
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 |
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.
|
||||
@@ -1,10 +1,7 @@
|
||||
---
|
||||
name: grill-me
|
||||
description: Interview the user relentlessly about a plan or design until reaching shared understanding, resolving each branch of the decision tree. Use when user wants to stress-test a plan, get grilled on their design, or mentions "grill me".
|
||||
description: A relentless interview to sharpen a plan or design.
|
||||
disable-model-invocation: true
|
||||
---
|
||||
|
||||
Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.
|
||||
|
||||
Ask the questions one at a time.
|
||||
|
||||
If a question can be answered by exploring the codebase, explore the codebase instead.
|
||||
Run a `/grilling` session.
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# ADR Format
|
||||
|
||||
ADRs live in `docs/adr/` and use sequential numbering: `0001-slug.md`, `0002-slug.md`, etc.
|
||||
|
||||
Create the `docs/adr/` directory lazily — only when the first ADR is needed.
|
||||
|
||||
## Template
|
||||
|
||||
```md
|
||||
# {Short title of the decision}
|
||||
|
||||
{1-3 sentences: what's the context, what did we decide, and why.}
|
||||
```
|
||||
|
||||
That's it. An ADR can be a single paragraph. The value is in recording *that* a decision was made and *why* — not in filling out sections.
|
||||
|
||||
## Optional sections
|
||||
|
||||
Only include these when they add genuine value. Most ADRs won't need them.
|
||||
|
||||
- **Status** frontmatter (`proposed | accepted | deprecated | superseded by ADR-NNNN`) — useful when decisions are revisited
|
||||
- **Considered Options** — only when the rejected alternatives are worth remembering
|
||||
- **Consequences** — only when non-obvious downstream effects need to be called out
|
||||
|
||||
## Numbering
|
||||
|
||||
Scan `docs/adr/` for the highest existing number and increment by one.
|
||||
|
||||
## When to offer an ADR
|
||||
|
||||
All three of these must be true:
|
||||
|
||||
1. **Hard to reverse** — the cost of changing your mind later is meaningful
|
||||
2. **Surprising without context** — a future reader will look at the code and wonder "why on earth did they do it this way?"
|
||||
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
|
||||
|
||||
If a decision is easy to reverse, skip it — you'll just reverse it. If it's not surprising, nobody will wonder why. If there was no real alternative, there's nothing to record beyond "we did the obvious thing."
|
||||
|
||||
### What qualifies
|
||||
|
||||
- **Architectural shape.** "We're using a monorepo." "The write model is event-sourced, the read model is projected into Postgres."
|
||||
- **Integration patterns between contexts.** "Ordering and Billing communicate via domain events, not synchronous HTTP."
|
||||
- **Technology choices that carry lock-in.** Database, message bus, auth provider, deployment target. Not every library — just the ones that would take a quarter to swap out.
|
||||
- **Boundary and scope decisions.** "Customer data is owned by the Customer context; other contexts reference it by ID only." The explicit no-s are as valuable as the yes-s.
|
||||
- **Deliberate deviations from the obvious path.** "We're using manual SQL instead of an ORM because X." Anything where a reasonable reader would assume the opposite. These stop the next engineer from "fixing" something that was deliberate.
|
||||
- **Constraints not visible in the code.** "We can't use AWS because of compliance requirements." "Response times must be under 200ms because of the partner API contract."
|
||||
- **Rejected alternatives when the rejection is non-obvious.** If you considered GraphQL and picked REST for subtle reasons, record it — otherwise someone will suggest GraphQL again in six months.
|
||||
@@ -1,60 +0,0 @@
|
||||
# CONTEXT.md Format
|
||||
|
||||
## Structure
|
||||
|
||||
```md
|
||||
# {Context Name}
|
||||
|
||||
{One or two sentence description of what this context is and why it exists.}
|
||||
|
||||
## Language
|
||||
|
||||
**Order**:
|
||||
{A one or two sentence description of the term}
|
||||
_Avoid_: Purchase, transaction
|
||||
|
||||
**Invoice**:
|
||||
A request for payment sent to a customer after delivery.
|
||||
_Avoid_: Bill, payment request
|
||||
|
||||
**Customer**:
|
||||
A person or organization that places orders.
|
||||
_Avoid_: Client, buyer, account
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- **Be opinionated.** When multiple words exist for the same concept, pick the best one and list the others under `_Avoid_`.
|
||||
- **Keep definitions tight.** One or two sentences max. Define what it IS, not what it does.
|
||||
- **Only include terms specific to this project's context.** General programming concepts (timeouts, error types, utility patterns) don't belong even if the project uses them extensively. Before adding a term, ask: is this a concept unique to this context, or a general programming concept? Only the former belongs.
|
||||
- **Group terms under subheadings** when natural clusters emerge. If all terms belong to a single cohesive area, a flat list is fine.
|
||||
|
||||
## Single vs multi-context repos
|
||||
|
||||
**Single context (most repos):** One `CONTEXT.md` at the repo root.
|
||||
|
||||
**Multiple contexts:** A `CONTEXT-MAP.md` at the repo root lists the contexts, where they live, and how they relate to each other:
|
||||
|
||||
```md
|
||||
# Context Map
|
||||
|
||||
## Contexts
|
||||
|
||||
- [Ordering](./src/ordering/CONTEXT.md) — receives and tracks customer orders
|
||||
- [Billing](./src/billing/CONTEXT.md) — generates invoices and processes payments
|
||||
- [Fulfillment](./src/fulfillment/CONTEXT.md) — manages warehouse picking and shipping
|
||||
|
||||
## Relationships
|
||||
|
||||
- **Ordering → Fulfillment**: Ordering emits `OrderPlaced` events; Fulfillment consumes them to start picking
|
||||
- **Fulfillment → Billing**: Fulfillment emits `ShipmentDispatched` events; Billing consumes them to generate invoices
|
||||
- **Ordering ↔ Billing**: Shared types for `CustomerId` and `Money`
|
||||
```
|
||||
|
||||
The skill infers which structure applies:
|
||||
|
||||
- If `CONTEXT-MAP.md` exists, read it to find contexts
|
||||
- If only a root `CONTEXT.md` exists, single context
|
||||
- If neither exists, create a root `CONTEXT.md` lazily when the first term is resolved
|
||||
|
||||
When multiple contexts exist, infer which one the current topic relates to. If unclear, ask.
|
||||
@@ -1,88 +1,7 @@
|
||||
---
|
||||
name: grill-with-docs
|
||||
description: Grilling session that challenges your plan against the existing domain model, sharpens terminology, and updates documentation (CONTEXT.md, ADRs) inline as decisions crystallise. Use when user wants to stress-test a plan against their project's language and documented decisions.
|
||||
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
|
||||
---
|
||||
|
||||
<what-to-do>
|
||||
|
||||
Interview me relentlessly about every aspect of this plan until we reach a shared understanding. Walk down each branch of the design tree, resolving dependencies between decisions one-by-one. For each question, provide your recommended answer.
|
||||
|
||||
Ask the questions one at a time, waiting for feedback on each question before continuing.
|
||||
|
||||
If a question can be answered by exploring the codebase, explore the codebase instead.
|
||||
|
||||
</what-to-do>
|
||||
|
||||
<supporting-info>
|
||||
|
||||
## Domain awareness
|
||||
|
||||
During codebase exploration, also look for existing documentation:
|
||||
|
||||
### File structure
|
||||
|
||||
Most repos have a single context:
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT.md
|
||||
├── docs/
|
||||
│ └── adr/
|
||||
│ ├── 0001-event-sourced-orders.md
|
||||
│ └── 0002-postgres-for-write-model.md
|
||||
└── src/
|
||||
```
|
||||
|
||||
If a `CONTEXT-MAP.md` exists at the root, the repo has multiple contexts. The map points to where each one lives:
|
||||
|
||||
```
|
||||
/
|
||||
├── CONTEXT-MAP.md
|
||||
├── docs/
|
||||
│ └── adr/ ← system-wide decisions
|
||||
├── src/
|
||||
│ ├── ordering/
|
||||
│ │ ├── CONTEXT.md
|
||||
│ │ └── docs/adr/ ← context-specific decisions
|
||||
│ └── billing/
|
||||
│ ├── CONTEXT.md
|
||||
│ └── docs/adr/
|
||||
```
|
||||
|
||||
Create files lazily — only when you have something to write. If no `CONTEXT.md` exists, create one when the first term is resolved. If no `docs/adr/` exists, create it when the first ADR is needed.
|
||||
|
||||
## During the session
|
||||
|
||||
### Challenge against the glossary
|
||||
|
||||
When the user uses a term that conflicts with the existing language in `CONTEXT.md`, call it out immediately. "Your glossary defines 'cancellation' as X, but you seem to mean Y — which is it?"
|
||||
|
||||
### Sharpen fuzzy language
|
||||
|
||||
When the user uses vague or overloaded terms, propose a precise canonical term. "You're saying 'account' — do you mean the Customer or the User? Those are different things."
|
||||
|
||||
### Discuss concrete scenarios
|
||||
|
||||
When domain relationships are being discussed, stress-test them with specific scenarios. Invent scenarios that probe edge cases and force the user to be precise about the boundaries between concepts.
|
||||
|
||||
### Cross-reference with code
|
||||
|
||||
When the user states how something works, check whether the code agrees. If you find a contradiction, surface it: "Your code cancels entire Orders, but you just said partial cancellation is possible — which is right?"
|
||||
|
||||
### Update CONTEXT.md inline
|
||||
|
||||
When a term is resolved, update `CONTEXT.md` right there. Don't batch these up — capture them as they happen. Use the format in [CONTEXT-FORMAT.md](./CONTEXT-FORMAT.md).
|
||||
|
||||
`CONTEXT.md` should be totally devoid of implementation details. Do not treat `CONTEXT.md` as a spec, a scratch pad, or a repository for implementation decisions. It is a glossary and nothing else.
|
||||
|
||||
### Offer ADRs sparingly
|
||||
|
||||
Only offer to create an ADR when all three are true:
|
||||
|
||||
1. **Hard to reverse** — the cost of changing your mind later is meaningful
|
||||
2. **Surprising without context** — a future reader will wonder "why did they do it this way?"
|
||||
3. **The result of a real trade-off** — there were genuine alternatives and you picked one for specific reasons
|
||||
|
||||
If any of the three is missing, skip the ADR. Use the format in [ADR-FORMAT.md](./ADR-FORMAT.md).
|
||||
|
||||
</supporting-info>
|
||||
Run a `/grilling` session, using the `/domain-modeling` skill.
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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.
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
# Deepening
|
||||
|
||||
How to deepen a cluster of shallow modules safely, given its dependencies. Assumes the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**.
|
||||
|
||||
## Dependency categories
|
||||
|
||||
When assessing a candidate for deepening, classify its dependencies. The category determines how the deepened module is tested across its seam.
|
||||
|
||||
### 1. In-process
|
||||
|
||||
Pure computation, in-memory state, no I/O. Always deepenable — merge the modules and test through the new interface directly. No adapter needed.
|
||||
|
||||
### 2. Local-substitutable
|
||||
|
||||
Dependencies that have local test stand-ins (PGLite for Postgres, in-memory filesystem). Deepenable if the stand-in exists. The deepened module is tested with the stand-in running in the test suite. The seam is internal; no port at the module's external interface.
|
||||
|
||||
### 3. Remote but owned (Ports & Adapters)
|
||||
|
||||
Your own services across a network boundary (microservices, internal APIs). Define a **port** (interface) at the seam. The deep module owns the logic; the transport is injected as an **adapter**. Tests use an in-memory adapter. Production uses an HTTP/gRPC/queue adapter.
|
||||
|
||||
Recommendation shape: *"Define a port at the seam, implement an HTTP adapter for production and an in-memory adapter for testing, so the logic sits in one deep module even though it's deployed across a network."*
|
||||
|
||||
### 4. True external (Mock)
|
||||
|
||||
Third-party services (Stripe, Twilio, etc.) you don't control. The deepened module takes the external dependency as an injected port; tests provide a mock adapter.
|
||||
|
||||
## Seam discipline
|
||||
|
||||
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a port unless at least two adapters are justified (typically production + test). A single-adapter seam is just indirection.
|
||||
- **Internal seams vs external seams.** A deep module can have internal seams (private to its implementation, used by its own tests) as well as the external seam at its interface. Don't expose internal seams through the interface just because tests use them.
|
||||
|
||||
## Testing strategy: replace, don't layer
|
||||
|
||||
- Old unit tests on shallow modules become waste once tests at the deepened module's interface exist — delete them.
|
||||
- Write new tests at the deepened module's interface. The **interface is the test surface**.
|
||||
- Tests assert on observable outcomes through the interface, not internal state.
|
||||
- Tests should survive internal refactors — they describe behaviour, not implementation. If a test has to change when the implementation changes, it's testing past the interface.
|
||||
@@ -39,7 +39,7 @@ Repo name, date, and a compact legend: solid box = module, dashed line = seam, r
|
||||
|
||||
## Candidate card
|
||||
|
||||
The diagrams carry the weight. Prose is sparse, plain, and uses the glossary terms ([LANGUAGE.md](LANGUAGE.md)) without ceremony.
|
||||
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>`:
|
||||
|
||||
@@ -105,7 +105,7 @@ One larger card. Candidate name, one sentence on why, anchor link to its card. T
|
||||
|
||||
## Tone
|
||||
|
||||
Plain English, concise — but the architectural nouns and verbs come straight from [LANGUAGE.md](LANGUAGE.md). Concision is not an excuse to drift.
|
||||
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.
|
||||
|
||||
@@ -120,4 +120,4 @@ Plain English, concise — but the architectural nouns and verbs come straight f
|
||||
|
||||
**Wins bullets** name the gain in glossary terms: *"locality: bugs concentrate in one module"*, *"leverage: one interface, N call sites"*, *"interface shrinks; implementation absorbs the wrappers"*. Don't write *"easier to maintain"* or *"cleaner code"* — those terms aren't in the glossary and don't earn their place.
|
||||
|
||||
No hedging, no throat-clearing, no "it's worth noting that…". If a sentence could be a bullet, make it a bullet. If a bullet could be cut, cut it. If a term isn't in [LANGUAGE.md](LANGUAGE.md), reach for one that is before inventing a new one.
|
||||
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.
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
# Interface Design
|
||||
|
||||
When the user wants to explore alternative interfaces for a chosen deepening candidate, use this parallel sub-agent pattern. Based on "Design It Twice" (Ousterhout) — your first idea is unlikely to be the best.
|
||||
|
||||
Uses the vocabulary in [LANGUAGE.md](LANGUAGE.md) — **module**, **interface**, **seam**, **adapter**, **leverage**.
|
||||
|
||||
## Process
|
||||
|
||||
### 1. Frame the problem space
|
||||
|
||||
Before spawning sub-agents, write a user-facing explanation of the problem space for the chosen candidate:
|
||||
|
||||
- The constraints any new interface would need to satisfy
|
||||
- The dependencies it would rely on, and which category they fall into (see [DEEPENING.md](DEEPENING.md))
|
||||
- A rough illustrative code sketch to ground the constraints — not a proposal, just a way to make the constraints concrete
|
||||
|
||||
Show this to the user, then immediately proceed to Step 2. The user reads and thinks while the sub-agents work in parallel.
|
||||
|
||||
### 2. Spawn sub-agents
|
||||
|
||||
Spawn 3+ sub-agents in parallel using the Agent tool. Each must produce a **radically different** interface for the deepened module.
|
||||
|
||||
Prompt each sub-agent with a separate technical brief (file paths, coupling details, dependency category from [DEEPENING.md](DEEPENING.md), what sits behind the seam). The brief is independent of the user-facing problem-space explanation in Step 1. Give each agent a different design constraint:
|
||||
|
||||
- Agent 1: "Minimize the interface — aim for 1–3 entry points max. Maximise leverage per entry point."
|
||||
- Agent 2: "Maximise flexibility — support many use cases and extension."
|
||||
- Agent 3: "Optimise for the most common caller — make the default case trivial."
|
||||
- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies."
|
||||
|
||||
Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language.
|
||||
|
||||
Each sub-agent outputs:
|
||||
|
||||
1. Interface (types, methods, params — plus invariants, ordering, error modes)
|
||||
2. Usage example showing how callers use it
|
||||
3. What the implementation hides behind the seam
|
||||
4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md))
|
||||
5. Trade-offs — where leverage is high, where it's thin
|
||||
|
||||
### 3. Present and compare
|
||||
|
||||
Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**.
|
||||
|
||||
After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu.
|
||||
@@ -1,53 +0,0 @@
|
||||
# Language
|
||||
|
||||
Shared vocabulary for every suggestion this skill makes. Use these terms exactly — don't substitute "component," "service," "API," or "boundary." Consistent language is the whole point.
|
||||
|
||||
## Terms
|
||||
|
||||
**Module**
|
||||
Anything with an interface and an implementation. Deliberately scale-agnostic — applies equally to a function, class, package, or tier-spanning slice.
|
||||
_Avoid_: unit, component, service.
|
||||
|
||||
**Interface**
|
||||
Everything a caller must know to use the module correctly. Includes the type signature, but also invariants, ordering constraints, error modes, required configuration, and performance characteristics.
|
||||
_Avoid_: API, signature (too narrow — those refer only to the type-level surface).
|
||||
|
||||
**Implementation**
|
||||
What's inside a module — its body of code. Distinct from **Adapter**: a thing can be a small adapter with a large implementation (a Postgres repo) or a large adapter with a small implementation (an in-memory fake). Reach for "adapter" when the seam is the topic; "implementation" otherwise.
|
||||
|
||||
**Depth**
|
||||
Leverage at the interface — the amount of behaviour a caller (or test) can exercise per unit of interface they have to learn. A module is **deep** when a large amount of behaviour sits behind a small interface. A module is **shallow** when the interface is nearly as complex as the implementation.
|
||||
|
||||
**Seam** _(from Michael Feathers)_
|
||||
A place where you can alter behaviour without editing in that place. The *location* at which a module's interface lives. Choosing where to put the seam is its own design decision, distinct from what goes behind it.
|
||||
_Avoid_: boundary (overloaded with DDD's bounded context).
|
||||
|
||||
**Adapter**
|
||||
A concrete thing that satisfies an interface at a seam. Describes *role* (what slot it fills), not substance (what's inside).
|
||||
|
||||
**Leverage**
|
||||
What callers get from depth. More capability per unit of interface they have to learn. One implementation pays back across N call sites and M tests.
|
||||
|
||||
**Locality**
|
||||
What maintainers get from depth. Change, bugs, knowledge, and verification concentrate at one place rather than spreading across callers. Fix once, fixed everywhere.
|
||||
|
||||
## Principles
|
||||
|
||||
- **Depth is a property of the interface, not the implementation.** A deep module can be internally composed of small, mockable, swappable parts — they just aren't part of the interface. A module can have **internal seams** (private to its implementation, used by its own tests) as well as the **external seam** at its interface.
|
||||
- **The deletion test.** Imagine deleting the module. If complexity vanishes, the module wasn't hiding anything (it was a pass-through). If complexity reappears across N callers, the module was earning its keep.
|
||||
- **The interface is the test surface.** Callers and tests cross the same seam. If you want to test *past* the interface, the module is probably the wrong shape.
|
||||
- **One adapter means a hypothetical seam. Two adapters means a real one.** Don't introduce a seam unless something actually varies across it.
|
||||
|
||||
## Relationships
|
||||
|
||||
- A **Module** has exactly one **Interface** (the surface it presents to callers and tests).
|
||||
- **Depth** is a property of a **Module**, measured against its **Interface**.
|
||||
- A **Seam** is where a **Module**'s **Interface** lives.
|
||||
- An **Adapter** sits at a **Seam** and satisfies the **Interface**.
|
||||
- **Depth** produces **Leverage** for callers and **Locality** for maintainers.
|
||||
|
||||
## Rejected framings
|
||||
|
||||
- **Depth as ratio of implementation-lines to interface-lines** (Ousterhout): rewards padding the implementation. We use depth-as-leverage instead.
|
||||
- **"Interface" as the TypeScript `interface` keyword or a class's public methods**: too narrow — interface here includes every fact a caller must know.
|
||||
- **"Boundary"**: overloaded with DDD's bounded context. Say **seam** or **interface**.
|
||||
@@ -1,38 +1,23 @@
|
||||
---
|
||||
name: improve-codebase-architecture
|
||||
description: Find deepening opportunities in a codebase, informed by the domain language in CONTEXT.md and the decisions in docs/adr/. Use when the user wants to improve architecture, find refactoring opportunities, consolidate tightly-coupled modules, or make a codebase more testable and AI-navigable.
|
||||
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.
|
||||
|
||||
## Glossary
|
||||
This command is _informed_ by the project's domain model and built on a shared design vocabulary:
|
||||
|
||||
Use these terms exactly in every suggestion. Consistent language is the point — don't drift into "component," "service," "API," or "boundary." Full definitions in [LANGUAGE.md](LANGUAGE.md).
|
||||
|
||||
- **Module** — anything with an interface and an implementation (function, class, package, slice).
|
||||
- **Interface** — everything a caller must know to use the module: types, invariants, error modes, ordering, config. Not just the type signature.
|
||||
- **Implementation** — the code inside.
|
||||
- **Depth** — leverage at the interface: a lot of behaviour behind a small interface. **Deep** = high leverage. **Shallow** = interface nearly as complex as the implementation.
|
||||
- **Seam** — where an interface lives; a place behaviour can be altered without editing in place. (Use this, not "boundary.")
|
||||
- **Adapter** — a concrete thing satisfying an interface at a seam.
|
||||
- **Leverage** — what callers get from depth.
|
||||
- **Locality** — what maintainers get from depth: change, bugs, knowledge concentrated in one place.
|
||||
|
||||
Key principles (see [LANGUAGE.md](LANGUAGE.md) for the full list):
|
||||
|
||||
- **Deletion test**: imagine deleting the module. If complexity vanishes, it was a pass-through. If complexity reappears across N callers, it was earning its keep.
|
||||
- **The interface is the test surface.**
|
||||
- **One adapter = hypothetical seam. Two adapters = real seam.**
|
||||
|
||||
This skill is _informed_ by the project's domain model. The domain language gives names to good seams; ADRs record decisions the skill should not re-litigate.
|
||||
- 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 and any ADRs in the area you're touching first.
|
||||
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:
|
||||
|
||||
@@ -50,7 +35,7 @@ Write a self-contained HTML file to the OS temp directory so nothing lands in th
|
||||
|
||||
The report uses **Tailwind via CDN** for layout and styling, and **Mermaid via CDN** for diagrams where a graph/flow/sequence reliably communicates the structure. Mix Mermaid with hand-crafted CSS/SVG visuals — use Mermaid when relationships are graph-shaped (call graphs, dependencies, sequences), and hand-built divs/SVG when you want something more editorial (mass diagrams, cross-sections, collapse animations). Each candidate gets a **before/after visualisation**. Be visual.
|
||||
|
||||
For each candidate, the same template as before, but rendered as a card:
|
||||
For each candidate, render a card with:
|
||||
|
||||
- **Files** — which files/modules are involved
|
||||
- **Problem** — why the current architecture is causing friction
|
||||
@@ -61,7 +46,7 @@ For each candidate, the same template as before, but rendered as a card:
|
||||
|
||||
End the report with a **Top recommendation** section: which candidate you'd tackle first and why.
|
||||
|
||||
**Use CONTEXT.md vocabulary for the domain, and [LANGUAGE.md](LANGUAGE.md) vocabulary for the architecture.** If `CONTEXT.md` defines "Order," talk about "the Order intake module" — not "the FooBarHandler," and not "the Order service."
|
||||
**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.
|
||||
|
||||
@@ -71,11 +56,11 @@ Do NOT propose interfaces yet. After the file is written, ask the user: "Which o
|
||||
|
||||
### 3. Grilling loop
|
||||
|
||||
Once the user picks a candidate, drop into a grilling conversation. Walk the design tree with them — constraints, dependencies, the shape of the deepened module, what sits behind the seam, what tests survive.
|
||||
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:
|
||||
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` — same discipline as `/grill-with-docs` (see [CONTEXT-FORMAT.md](../grill-with-docs/CONTEXT-FORMAT.md)). Create the file lazily if it doesn't exist.
|
||||
- **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. See [ADR-FORMAT.md](../grill-with-docs/ADR-FORMAT.md).
|
||||
- **Want to explore alternative interfaces for the deepened module?** See [INTERFACE-DESIGN.md](INTERFACE-DESIGN.md).
|
||||
- **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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: prototype
|
||||
description: Build a throwaway prototype to flesh out a design before committing to it. Routes between two branches — a runnable terminal app for state/business-logic questions, or several radically different UI variations toggleable from one route. Use when the user wants to prototype, sanity-check a data model or state machine, mock up a UI, explore design options, or says "prototype this", "let me play with it", "try a few designs".
|
||||
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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: setup-matt-pocock-skills
|
||||
description: Sets up an `## Agent skills` block in AGENTS.md/CLAUDE.md and `docs/agents/` so the engineering skills know this repo's issue tracker (GitHub or local markdown), triage label vocabulary, and domain doc layout. Run before first use of `to-issues`, `to-prd`, `triage`, `diagnose`, `tdd`, `improve-codebase-architecture`, or `zoom-out` — or if those skills appear to be missing context about the issue tracker, triage labels, or domain docs.
|
||||
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
|
||||
---
|
||||
|
||||
@@ -44,6 +44,12 @@ Default posture: these skills were designed for GitHub. If a `git remote` points
|
||||
- **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.
|
||||
@@ -60,7 +66,7 @@ Default: each role's string equals its name. Ask the user if they want to overri
|
||||
|
||||
**Section C — Domain docs.**
|
||||
|
||||
> Explainer: Some skills (`improve-codebase-architecture`, `diagnose`, `tdd`) read a `CONTEXT.md` file to learn the project's domain language, and `docs/adr/` for past architectural decisions. They need to know whether the repo has one global context or multiple (e.g. a monorepo with separate frontend/backend contexts) so they look in the right place.
|
||||
> 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:
|
||||
|
||||
@@ -95,7 +101,7 @@ The block:
|
||||
|
||||
### Issue tracker
|
||||
|
||||
[one-line summary of where issues are tracked]. See `docs/agents/issue-tracker.md`.
|
||||
[one-line summary of where issues are tracked, plus whether external PRs are a triage surface]. See `docs/agents/issue-tracker.md`.
|
||||
|
||||
### Triage labels
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@ How the engineering skills should consume this repo's domain documentation when
|
||||
- **`CONTEXT-MAP.md`** at the repo root if it exists — it points at one `CONTEXT.md` per context. Read each one relevant to the topic.
|
||||
- **`docs/adr/`** — read ADRs that touch the area you're about to work in. In multi-context repos, also check `src/<context>/docs/adr/` for context-scoped decisions.
|
||||
|
||||
If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved.
|
||||
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
|
||||
|
||||
@@ -42,7 +42,7 @@ Multi-context repo (presence of `CONTEXT-MAP.md` at the root):
|
||||
|
||||
When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids.
|
||||
|
||||
If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`).
|
||||
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
|
||||
|
||||
|
||||
@@ -13,6 +13,18 @@ Issues and PRDs for this repo live as GitHub issues. Use the `gh` CLI for all op
|
||||
|
||||
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.
|
||||
|
||||
@@ -14,6 +14,18 @@ Issues and PRDs for this repo live as GitLab issues. Use the [`glab`](https://gi
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
---
|
||||
name: tdd
|
||||
description: Test-driven development with red-green-refactor loop. Use when user wants to build features or fix bugs using TDD, mentions "red-green-refactor", wants integration tests, or asks for test-first development.
|
||||
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
|
||||
@@ -13,6 +13,8 @@ description: Test-driven development with red-green-refactor loop. Use when user
|
||||
|
||||
**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
|
||||
@@ -44,14 +46,13 @@ RIGHT (vertical):
|
||||
|
||||
### 1. Planning
|
||||
|
||||
When exploring the codebase, use the project's domain glossary so that test names and interface vocabulary match the project's language, and respect ADRs in the area you're touching.
|
||||
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](deep-modules.md) (small interface, deep implementation)
|
||||
- [ ] Design interfaces for [testability](interface-design.md)
|
||||
- [ ] 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
|
||||
|
||||
@@ -104,6 +105,7 @@ After all tests pass, look for [refactor candidates](refactoring.md):
|
||||
[ ] 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
|
||||
```
|
||||
|
||||
@@ -1,33 +0,0 @@
|
||||
# Deep Modules
|
||||
|
||||
From "A Philosophy of Software Design":
|
||||
|
||||
**Deep module** = small interface + lots of implementation
|
||||
|
||||
```
|
||||
┌─────────────────────┐
|
||||
│ Small Interface │ ← Few methods, simple params
|
||||
├─────────────────────┤
|
||||
│ │
|
||||
│ │
|
||||
│ Deep Implementation│ ← Complex logic hidden
|
||||
│ │
|
||||
│ │
|
||||
└─────────────────────┘
|
||||
```
|
||||
|
||||
**Shallow module** = large interface + little implementation (avoid)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────┐
|
||||
│ Large Interface │ ← Many methods, complex params
|
||||
├─────────────────────────────────┤
|
||||
│ Thin Implementation │ ← Just passes through
|
||||
└─────────────────────────────────┘
|
||||
```
|
||||
|
||||
When designing interfaces, ask:
|
||||
|
||||
- Can I reduce the number of methods?
|
||||
- Can I simplify the parameters?
|
||||
- Can I hide more complexity inside?
|
||||
@@ -1,31 +0,0 @@
|
||||
# Interface Design for Testability
|
||||
|
||||
Good interfaces make testing natural:
|
||||
|
||||
1. **Accept dependencies, don't create them**
|
||||
|
||||
```typescript
|
||||
// Testable
|
||||
function processOrder(order, paymentGateway) {}
|
||||
|
||||
// Hard to test
|
||||
function processOrder(order) {
|
||||
const gateway = new StripeGateway();
|
||||
}
|
||||
```
|
||||
|
||||
2. **Return results, don't produce side effects**
|
||||
|
||||
```typescript
|
||||
// Testable
|
||||
function calculateDiscount(cart): Discount {}
|
||||
|
||||
// Hard to test
|
||||
function applyDiscount(cart): void {
|
||||
cart.total -= discount;
|
||||
}
|
||||
```
|
||||
|
||||
3. **Small surface area**
|
||||
- Fewer methods = fewer tests needed
|
||||
- Fewer params = simpler test setup
|
||||
@@ -59,3 +59,19 @@ test("createUser makes user retrievable", async () => {
|
||||
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);
|
||||
});
|
||||
```
|
||||
|
||||
@@ -16,6 +16,7 @@ Treat the current directory as a teaching workspace. The state of their learning
|
||||
- `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
|
||||
@@ -59,6 +60,14 @@ Each lesson should recommend a primary source for the user to read or watch. Thi
|
||||
|
||||
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.
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: to-issues
|
||||
description: Break a plan, spec, or PRD into independently-grabbable issues on the project issue tracker using tracer-bullet vertical slices. Use when user wants to convert a plan into issues, create implementation tickets, or break down work into issues.
|
||||
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
|
||||
@@ -19,16 +20,18 @@ Work from whatever is already in the conversation context. If the user passes an
|
||||
|
||||
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.
|
||||
|
||||
Slices may be 'HITL' or 'AFK'. HITL slices require human interaction, such as an architectural decision or a design review. AFK slices can be implemented and merged without human interaction. Prefer AFK over HITL where possible.
|
||||
|
||||
<vertical-slice-rules>
|
||||
|
||||
- Each slice delivers a narrow but COMPLETE path through every layer (schema, API, UI, tests)
|
||||
- A completed slice is demoable or verifiable on its own
|
||||
- Prefer many thin slices over few thick ones
|
||||
- Any prefactoring should be done first
|
||||
|
||||
</vertical-slice-rules>
|
||||
|
||||
### 4. Quiz the user
|
||||
@@ -36,7 +39,6 @@ Slices may be 'HITL' or 'AFK'. HITL slices require human interaction, such as an
|
||||
Present the proposed breakdown as a numbered list. For each slice, show:
|
||||
|
||||
- **Title**: short descriptive name
|
||||
- **Type**: HITL / AFK
|
||||
- **Blocked by**: which other slices (if any) must complete first
|
||||
- **User stories covered**: which user stories this addresses (if the source material has them)
|
||||
|
||||
@@ -45,7 +47,6 @@ Ask the user:
|
||||
- Does the granularity feel right? (too coarse / too fine)
|
||||
- Are the dependency relationships correct?
|
||||
- Should any slices be merged or split further?
|
||||
- Are the correct slices marked as HITL and AFK?
|
||||
|
||||
Iterate until the user approves the breakdown.
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
---
|
||||
name: to-prd
|
||||
description: Turn the current conversation context into a PRD and publish it to the project issue tracker. Use when user wants to create a PRD from the current context.
|
||||
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.
|
||||
@@ -11,7 +12,7 @@ The issue tracker and triage label vocabulary should have been provided to you
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
# Writing Agent Briefs
|
||||
|
||||
An agent brief is a structured comment posted on a GitHub issue when it moves to `ready-for-agent`. It is the authoritative specification that an AFK agent will work from. The original issue body and discussion are context — the agent brief is the contract.
|
||||
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
|
||||
|
||||
@@ -143,6 +145,43 @@ checked for matches.
|
||||
- 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
|
||||
|
||||
@@ -83,7 +83,11 @@ The maintainer may:
|
||||
|
||||
## When to write to `.out-of-scope/`
|
||||
|
||||
Only when an **enhancement** (not a bug) is rejected as `wontfix`. The flow:
|
||||
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
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
---
|
||||
name: triage
|
||||
description: Triage issues through a state machine driven by triage roles. Use when user wants to create an issue, triage issues, review incoming bugs or feature requests, prepare issues for an AFK agent, or manage issue workflow.
|
||||
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:
|
||||
|
||||
```
|
||||
@@ -33,6 +36,8 @@ Five **state** roles:
|
||||
- `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.
|
||||
@@ -44,7 +49,7 @@ State transitions: an unlabeled issue normally goes to `needs-triage` first; fro
|
||||
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"
|
||||
- "Let's look at #42" (issue or PR)
|
||||
- "Move #42 to ready-for-agent"
|
||||
- "What's ready for agents to pick up?"
|
||||
|
||||
@@ -56,24 +61,28 @@ Query the issue tracker and present three buckets, oldest first:
|
||||
2. **`needs-triage`** — evaluation in progress.
|
||||
3. **`needs-info` with reporter activity since the last triage notes** — needs re-evaluation.
|
||||
|
||||
Show counts and a one-line summary per issue. Let the maintainer pick.
|
||||
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.
|
||||
|
||||
## Triage a specific issue
|
||||
Show counts and a one-line summary per item. Let the maintainer pick.
|
||||
|
||||
1. **Gather context.** Read the full issue (body, comments, labels, reporter, dates). Parse any prior triage notes so you don't re-ask resolved questions. Explore the codebase using the project's domain glossary, respecting ADRs in the area. Read `.out-of-scope/*.md` and surface any prior rejection that resembles this issue.
|
||||
## Triage a specific issue or PR
|
||||
|
||||
2. **Recommend.** Tell the maintainer your category and state recommendation with reasoning, plus a brief codebase summary relevant to the issue. Wait for direction.
|
||||
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.
|
||||
|
||||
3. **Reproduce (bugs only).** Before any grilling, attempt reproduction: read the reporter's steps, trace the relevant code, run tests or commands. Report what happened — successful repro with code path, failed repro, or insufficient detail (a strong `needs-info` signal). A confirmed repro makes a much stronger agent brief.
|
||||
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.
|
||||
|
||||
4. **Grill (if needed).** If the issue needs fleshing out, run a `/grill-with-docs` session.
|
||||
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` (bug) — polite explanation, then close.
|
||||
- `wontfix` (enhancement) — write to `.out-of-scope/`, link to it from a comment, then close ([OUT-OF-SCOPE.md](OUT-OF-SCOPE.md)).
|
||||
- `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
|
||||
@@ -100,4 +109,4 @@ Capture everything resolved during grilling under "established so far" so the wo
|
||||
|
||||
## Resuming a previous session
|
||||
|
||||
If prior triage notes exist on the issue, read them, check whether the reporter has answered any outstanding questions, and present an updated picture before continuing. Don't re-ask resolved questions.
|
||||
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.
|
||||
|
||||
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
|
||||
3
.gitignore
vendored
3
.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
|
||||
|
||||
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`
|
||||
@@ -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,
|
||||
|
||||
91
docs/7月迭代/00-总览.md
Normal file
91
docs/7月迭代/00-总览.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# 7月迭代技术方案总览
|
||||
|
||||
> 讨论日期:2026-07-11
|
||||
> 分支:Iteration/7-11
|
||||
> 系统:junhong_cmp_fiber(已上线,迭代阶段)
|
||||
|
||||
---
|
||||
|
||||
## 阶段划分
|
||||
|
||||
| 阶段 | 范围 | 说明 |
|
||||
|------|------|------|
|
||||
| **Phase 1(本迭代)** | 需求 1-22(除企微对接) | Gateway 接口已有,全部可做 |
|
||||
| **Phase 2(下次迭代)** | 需求 18(APR-009)/22(EXP-003)的企微对接部分 | 仅依赖企业微信审批/推送 API |
|
||||
|
||||
---
|
||||
|
||||
## 需求总览
|
||||
|
||||
### 简单改动(无新表,影响范围小)
|
||||
|
||||
| # | 需求 | 文档 | 实现要点 |
|
||||
|---|------|------|---------|
|
||||
| 1 | 行业卡复机允许未实名 | [需求01](./需求01-复机实名规则.md) | `ManualStartCard` 换用 `isRealnameOK()` |
|
||||
| 3 | 店铺列表加联系电话搜索 | [需求03](./需求03-店铺搜索电话.md) | Shop Store 加 contact_phone 过滤 |
|
||||
| 4 | 退款中资产禁止换货 | [需求04](./需求04-退款拦截换货.md) | 换货前检查活跃退款单 |
|
||||
| 7 | IoT卡/设备加实名筛选 | [需求07](./需求07-实名筛选.md) | 列表接口加 real_name_status filter |
|
||||
| 11 | 资产详情-套餐到期时间高亮 | [需求11](./需求11-套餐到期字段高亮.md) | 接口新增字段 + 前端高亮 |
|
||||
| 12 | 换货管理显示问题修复 | [需求12](./需求12-换货管理修复.md) | EXC-001~004,字段显示对齐 |
|
||||
| 13 | 列表字段新增 | [需求13](./需求13-列表字段新增.md) | 退款/代理充值/换号管理加提交人审批人 |
|
||||
|
||||
### 中等复杂(有新字段或配置机制)
|
||||
|
||||
| # | 需求 | 文档 | 实现要点 |
|
||||
|---|------|------|---------|
|
||||
| 2 | H5流程顺序配置化 | [需求02](./需求02-H5流程配置.md) | 依赖[系统配置](./基础设施/系统配置.md) |
|
||||
| 5 | 套餐分配生效条件 | [需求05](./需求05-套餐分配生效条件.md) | ShopPackageAllocation 加 expiry_base_override |
|
||||
| 6 | 资产最后到期时间 | [需求06](./需求06-资产最后到期时间.md) | 查 PackageUsage 计算 max(expires_at) |
|
||||
| 8 | 设备批量分配 Excel | [需求08](./需求08-设备批量分配Excel.md) | 参考现有 Excel 导入体系 |
|
||||
| 9 | C端支付方式限制配置化 | [需求09](./需求09-C端支付限制配置化.md) | 恢复已注释代码 + 系统配置 |
|
||||
| 10 | 限速规则 | [需求10](./需求10-限速规则.md) | Gateway `SetSpeedLimit` 已有,套餐加限速字段 |
|
||||
| 14 | 导出功能 | [需求14](./需求14-导出功能.md) | 6 个模块导出,复用 ExportTask 体系 |
|
||||
| 15 | 下架套餐允许续费 | [需求15](./需求15-套餐下架续费.md) | ShelfStatus 判断 + 续费入口 |
|
||||
|
||||
### 复杂新模块(DDD 结构)
|
||||
|
||||
| # | 需求 | 文档 | 实现要点 |
|
||||
|---|------|------|---------|
|
||||
| 16 | 代理分销码与佣金提现 | [需求16](./需求16-代理分销码佣金提现.md) | 二维码注册 + 发展人体系 |
|
||||
| 17 | 信用额度 | [需求17](./需求17-信用额度.md) | Shop/角色信用字段 + 负余额支持 |
|
||||
| 18 | 多人审批 | [需求18](./需求18-多人审批.md) | 依赖[审批流](./基础设施/审批流.md) + [站内消息](./基础设施/站内消息.md) |
|
||||
| 19 | 批量订购套餐 | [需求19](./需求19-批量订购.md) | Excel 导入 + 代理余额扣款 |
|
||||
| 20 | 退款审批 | [需求20](./需求20-退款审批.md) | 依赖审批流 |
|
||||
| 21 | 充值审核流程 | [需求21](./需求21-充值审核流程.md) | 依赖审批流 |
|
||||
| 22 | 套餐临期提醒 | [需求22](./需求22-套餐临期提醒.md) | 定时任务 + 站内消息 |
|
||||
|
||||
---
|
||||
|
||||
## 基础设施文档
|
||||
|
||||
| 文档 | 说明 | 被哪些需求依赖 |
|
||||
|------|------|--------------|
|
||||
| [DDD规范](./DDD规范.md) | 领域模型设计规范、目录结构、代码示例 | 复杂新模块全部 |
|
||||
| [系统配置](./基础设施/系统配置.md) | tb_system_config + 管理接口 | 需求 2、9 |
|
||||
| [站内消息](./基础设施/站内消息.md) | tb_notification + Asynq 异步分发 | 需求 18、20、21、22 |
|
||||
| [审批流](./基础设施/审批流.md) | 通用多级审批引擎 | 需求 18、20、21 |
|
||||
|
||||
---
|
||||
|
||||
## 执行顺序建议
|
||||
|
||||
```
|
||||
第一周:基础设施
|
||||
1. tb_system_config + 管理接口
|
||||
2. tb_notification + 任务 handler
|
||||
3. 通用审批流引擎
|
||||
|
||||
第二周:简单改动批量完成
|
||||
4. 需求 1/3/4/7/11/12/13(均可并行)
|
||||
|
||||
第三周:中等复杂
|
||||
5. 需求 2/5/6/8/9(依赖基础设施)
|
||||
6. 需求 14/15
|
||||
|
||||
第四周:复杂新模块
|
||||
7. 需求 17(信用额度)
|
||||
8. 需求 16(分销码)
|
||||
9. 需求 18/20/21(审批流对接)
|
||||
10. 需求 19(批量订购)
|
||||
11. 需求 22(临期提醒)
|
||||
```
|
||||
357
docs/7月迭代/DDD规范.md
Normal file
357
docs/7月迭代/DDD规范.md
Normal file
@@ -0,0 +1,357 @@
|
||||
# 项目 DDD 设计规范
|
||||
|
||||
> 务实型 DDD——不是学院派,不强制 Event Sourcing,不追求完美,追求可维护、可扩展、AI 辅助友好。
|
||||
> 基准文档:`docs/改造方向.md`(绞杀者模式)
|
||||
|
||||
---
|
||||
|
||||
## 一、为什么用 DDD / 什么不是 DDD
|
||||
|
||||
### 现状问题
|
||||
|
||||
- `order/service.go` 3031 行:订单创建、支付、佣金计算、代购、库存扣减全混一起
|
||||
- Model 只是 GORM 映射,没有业务行为(贫血模型)
|
||||
- 新增业务联动必须修改原有 Service,牵一发动全身
|
||||
|
||||
### 目标
|
||||
|
||||
不是重写,是**渐进替换**:
|
||||
|
||||
1. **新功能按新结构写**,不往旧 Service 加代码
|
||||
2. **旧功能迁移**:每次迁移一个用例,跑通后再下一个
|
||||
3. **AI 辅助**:结构清晰,AI 生成代码时自动落入正确位置
|
||||
|
||||
---
|
||||
|
||||
## 二、目录结构
|
||||
|
||||
```
|
||||
internal/
|
||||
├── domain/ ← 领域层(纯业务逻辑,不依赖 Fiber/GORM)
|
||||
│ ├── wallet/
|
||||
│ │ ├── wallet.go ← 聚合根(含业务方法)
|
||||
│ │ ├── events.go ← 领域事件定义
|
||||
│ │ ├── repository.go ← 仓储接口(interface)
|
||||
│ │ └── vo.go ← 值对象(Money, CreditLimit)
|
||||
│ ├── approval/ ← 新:审批流领域
|
||||
│ ├── notification/ ← 新:通知领域
|
||||
│ ├── distribution/ ← 新:分销领域
|
||||
│ └── package/ ← 套餐领域(迁移中)
|
||||
│
|
||||
├── application/ ← 应用层(用例,只做编排,不含业务判断)
|
||||
│ ├── wallet/
|
||||
│ │ ├── debit_wallet.go ← 一个文件 = 一个用例
|
||||
│ │ ├── credit_wallet.go
|
||||
│ │ └── grant_credit.go ← 新:授信
|
||||
│ ├── approval/
|
||||
│ │ ├── submit_approval.go
|
||||
│ │ ├── advance_step.go
|
||||
│ │ └── reject_approval.go
|
||||
│ └── notification/
|
||||
│ └── send_notification.go
|
||||
│
|
||||
├── infrastructure/ ← 基础设施层(实现 domain 里的 interface)
|
||||
│ └── persistence/ ← 原来的 store/postgres/(保持不动)
|
||||
│
|
||||
├── handler/ ← 原有 handler(保持不动)
|
||||
├── service/ ← 原有 service(保持不动,新模块不在这里)
|
||||
└── store/ ← 原有 store(保持不动)
|
||||
```
|
||||
|
||||
**过渡期规则**:
|
||||
- 旧模块:`internal/service/xxx` + `internal/store/postgres/xxx`
|
||||
- 新模块:`internal/domain/xxx` + `internal/application/xxx`
|
||||
- Handler 层统一调 Application 层(新)或 Service 层(旧)
|
||||
|
||||
---
|
||||
|
||||
## 三、领域模块划分
|
||||
|
||||
| 领域 | 聚合根 | 核心业务规则 | 状态 |
|
||||
|------|--------|------------|------|
|
||||
| **Asset(资产)** | `IotCard`, `Device` | 停复机条件、实名策略 | 迁移中 |
|
||||
| **Order(订单)** | `Order` | 支付、退款、佣金触发 | 迁移中 |
|
||||
| **Package(套餐)** | `Package`, `PackageUsage` | 生效条件、临期计算 | 迁移中 |
|
||||
| **Wallet(钱包)** | `AgentWallet` | 余额扣减、信用额度、负余额 | **新功能用 DDD** |
|
||||
| **Shop(代理)** | `Shop` | 层级关系、信用策略 | 部分迁移 |
|
||||
| **Approval(审批)** | `ApprovalFlow` | 多级审批、推进、驳回 | **全新 DDD** |
|
||||
| **Notification(通知)** | `Notification` | 分发、已读状态 | **全新 DDD** |
|
||||
| **Distribution(分销)** | `DistributionRelation` | 发展人体系、二维码注册 | **全新 DDD** |
|
||||
|
||||
---
|
||||
|
||||
## 四、聚合根设计原则(富模型)
|
||||
|
||||
### 4.1 核心原则
|
||||
|
||||
业务规则住在聚合根里,外部只通过方法操作,不能直接改字段。
|
||||
|
||||
```go
|
||||
// ❌ 贫血模型(禁止)——所有判断在 Service 里
|
||||
func (s *WalletService) Debit(ctx context.Context, walletID uint, amount int64) error {
|
||||
wallet, _ := s.store.Get(ctx, walletID)
|
||||
if wallet.Balance-wallet.FrozenBalance < amount {
|
||||
return errors.New(errors.CodeInsufficientBalance)
|
||||
}
|
||||
wallet.Balance -= amount
|
||||
s.store.Update(ctx, wallet)
|
||||
}
|
||||
|
||||
// ✅ 富模型(推荐)——判断逻辑在聚合根里
|
||||
func (w *AgentWallet) Debit(amount Money) error {
|
||||
available := w.Balance - w.FrozenBalance + w.CreditLimit // 含信用额度
|
||||
if available < amount.Cents() {
|
||||
return ErrInsufficientBalance
|
||||
}
|
||||
w.Balance -= amount.Cents()
|
||||
w.recordEvent(WalletDebitedEvent{Amount: amount, BalanceAfter: w.Balance})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Application 层只做编排
|
||||
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
|
||||
wallet, err := uc.repo.GetByID(ctx, cmd.WalletID)
|
||||
if err != nil { return err }
|
||||
if err := wallet.Debit(cmd.Amount); err != nil { return err }
|
||||
return uc.repo.Save(ctx, wallet)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.2 聚合根必须包含
|
||||
|
||||
```go
|
||||
type AggregateRoot struct {
|
||||
// 内嵌 GORM model(过渡期)
|
||||
gorm.Model
|
||||
|
||||
// 业务字段...
|
||||
|
||||
// 未发布领域事件(内存中,Save 后由框架分发)
|
||||
domainEvents []DomainEvent `gorm:"-" json:"-"`
|
||||
}
|
||||
|
||||
// 获取并清空领域事件(由 Repository.Save 调用)
|
||||
func (a *AggregateRoot) PopEvents() []DomainEvent {
|
||||
events := a.domainEvents
|
||||
a.domainEvents = nil
|
||||
return events
|
||||
}
|
||||
|
||||
func (a *AggregateRoot) recordEvent(e DomainEvent) {
|
||||
a.domainEvents = append(a.domainEvents, e)
|
||||
}
|
||||
```
|
||||
|
||||
### 4.3 与现有 GORM 的共存
|
||||
|
||||
过渡期,聚合根 **就是** 现有的 GORM Model(加业务方法),不需要单独建领域对象:
|
||||
|
||||
```go
|
||||
// internal/domain/wallet/wallet.go
|
||||
// AgentWallet 钱包聚合根
|
||||
// 直接复用并扩展现有 model.AgentWallet
|
||||
type AgentWallet struct {
|
||||
model.AgentWallet // 内嵌原有 GORM 模型
|
||||
domainEvents []DomainEvent `gorm:"-"`
|
||||
}
|
||||
|
||||
// Debit 从钱包扣款(含信用额度)
|
||||
func (w *AgentWallet) Debit(amount Money) error {
|
||||
available := w.Balance - w.FrozenBalance + w.CreditLimit
|
||||
if available < amount.Cents() {
|
||||
return ErrInsufficientBalance
|
||||
}
|
||||
w.Balance -= amount.Cents()
|
||||
w.recordEvent(WalletDebitedEvent{...})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、值对象设计
|
||||
|
||||
值对象:没有 ID,靠值来判断相等,不可变。
|
||||
|
||||
```go
|
||||
// internal/domain/wallet/vo.go
|
||||
|
||||
// Money 金额值对象(分为单位,防止浮点数精度问题)
|
||||
type Money struct {
|
||||
cents int64
|
||||
}
|
||||
|
||||
func NewMoney(cents int64) (Money, error) {
|
||||
if cents < 0 {
|
||||
return Money{}, ErrNegativeMoney
|
||||
}
|
||||
return Money{cents: cents}, nil
|
||||
}
|
||||
|
||||
func (m Money) Cents() int64 { return m.cents }
|
||||
func (m Money) Yuan() float64 { return float64(m.cents) / 100 }
|
||||
func (m Money) Add(o Money) Money { return Money{cents: m.cents + o.cents} }
|
||||
|
||||
// CreditLimit 信用额度值对象
|
||||
type CreditLimit struct {
|
||||
maxCents int64 // 最大授信额度(分)
|
||||
allowNegative bool // 是否允许负余额
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、领域事件(用 Asynq 实现)
|
||||
|
||||
领域事件不用消息队列,直接复用现有 Asynq 体系。
|
||||
|
||||
```go
|
||||
// internal/domain/wallet/events.go
|
||||
|
||||
// DomainEvent 领域事件接口
|
||||
type DomainEvent interface {
|
||||
EventType() string
|
||||
OccurredAt() time.Time
|
||||
}
|
||||
|
||||
// WalletDebitedEvent 钱包扣款事件
|
||||
type WalletDebitedEvent struct {
|
||||
WalletID uint
|
||||
ShopID uint
|
||||
Amount Money
|
||||
BalanceBefore int64
|
||||
BalanceAfter int64
|
||||
RefType string
|
||||
RefID uint
|
||||
occurredAt time.Time
|
||||
}
|
||||
|
||||
func (e WalletDebitedEvent) EventType() string { return "wallet.debited" }
|
||||
func (e WalletDebitedEvent) OccurredAt() time.Time { return e.occurredAt }
|
||||
```
|
||||
|
||||
**Repository.Save 发布事件**:
|
||||
|
||||
```go
|
||||
// internal/infrastructure/persistence/wallet_repo.go
|
||||
func (r *WalletRepository) Save(ctx context.Context, wallet *domainWallet.AgentWallet) error {
|
||||
err := r.db.WithContext(ctx).Save(&wallet.AgentWallet).Error
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
// 发布领域事件
|
||||
for _, evt := range wallet.PopEvents() {
|
||||
if err := r.publishEvent(ctx, evt); err != nil {
|
||||
r.logger.Error("领域事件发布失败", zap.Error(err), zap.String("type", evt.EventType()))
|
||||
// 事件发布失败不回滚业务(异步,可补偿)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、仓储接口
|
||||
|
||||
```go
|
||||
// internal/domain/wallet/repository.go
|
||||
|
||||
// WalletRepository 钱包仓储接口
|
||||
type WalletRepository interface {
|
||||
GetByShopID(ctx context.Context, shopID uint, walletType string) (*AgentWallet, error)
|
||||
GetByID(ctx context.Context, id uint) (*AgentWallet, error)
|
||||
Save(ctx context.Context, wallet *AgentWallet) error
|
||||
SaveInTx(ctx context.Context, tx *gorm.DB, wallet *AgentWallet) error
|
||||
}
|
||||
```
|
||||
|
||||
实现在 `internal/infrastructure/persistence/wallet_repo.go`,复用现有 Store 逻辑。
|
||||
|
||||
---
|
||||
|
||||
## 八、应用服务(用例)
|
||||
|
||||
一个文件 = 一个用例。用例只做编排,不含业务判断。
|
||||
|
||||
```go
|
||||
// internal/application/wallet/debit_wallet.go
|
||||
|
||||
// DebitCommand 扣款命令
|
||||
type DebitCommand struct {
|
||||
WalletID uint
|
||||
Amount domainWallet.Money
|
||||
RefType string
|
||||
RefID uint
|
||||
OperatorID uint
|
||||
}
|
||||
|
||||
// DebitWalletUseCase 扣款用例
|
||||
type DebitWalletUseCase struct {
|
||||
walletRepo domainWallet.WalletRepository
|
||||
txManager TxManager
|
||||
}
|
||||
|
||||
// Execute 执行扣款
|
||||
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
|
||||
return uc.txManager.RunInTx(ctx, func(tx *gorm.DB) error {
|
||||
wallet, err := uc.walletRepo.GetByID(ctx, cmd.WalletID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := wallet.Debit(cmd.Amount); err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.walletRepo.SaveInTx(ctx, tx, wallet)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、Handler 层调用方式
|
||||
|
||||
新模块:Handler → Application UseCase(不经过 Service 层)
|
||||
|
||||
```go
|
||||
// internal/handler/admin/wallet_handler.go
|
||||
func (h *WalletHandler) GrantCredit(c *fiber.Ctx) error {
|
||||
var req dto.GrantCreditRequest
|
||||
// ... 参数解析
|
||||
|
||||
cmd := walletApp.GrantCreditCommand{
|
||||
ShopID: req.ShopID,
|
||||
MaxCredit: domainWallet.NewCreditLimit(req.MaxCreditCents),
|
||||
OperatorID: middleware.GetUserID(c),
|
||||
}
|
||||
if err := h.grantCreditUseCase.Execute(c.UserContext(), cmd); err != nil {
|
||||
return err
|
||||
}
|
||||
return response.OK(c, nil)
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 十、禁止事项
|
||||
|
||||
| 禁止 | 原因 |
|
||||
|------|------|
|
||||
| 在 Application 层做业务判断 | 业务规则必须在领域对象里 |
|
||||
| 跨聚合直接访问另一聚合的字段 | 只能通过 ID 引用,运行时通过 Repository 加载 |
|
||||
| 在领域层 import Fiber/GORM/Redis | 领域层不依赖基础设施 |
|
||||
| 在一个用例文件里实现多个业务流程 | 一文件一用例 |
|
||||
| 领域事件发布失败时回滚业务 | 事件是异步补偿,不是事务一部分 |
|
||||
| 在聚合根里调用 Repository | 聚合根不依赖仓储 |
|
||||
|
||||
---
|
||||
|
||||
## 十一、本次迭代 DDD 落地计划
|
||||
|
||||
| 新模块 | 领域路径 | 用例路径 |
|
||||
|--------|---------|---------|
|
||||
| 信用额度(需求17) | `internal/domain/wallet/` | `internal/application/wallet/` |
|
||||
| 审批流(需求18/20/21) | `internal/domain/approval/` | `internal/application/approval/` |
|
||||
| 站内消息(需求18/22) | `internal/domain/notification/` | `internal/application/notification/` |
|
||||
| 分销码(需求16) | `internal/domain/distribution/` | `internal/application/distribution/` |
|
||||
| 批量订购(需求19) | 复用 Order 领域 | `internal/application/order/bulk_purchase.go` |
|
||||
364
docs/7月迭代/业务需求.md
Normal file
364
docs/7月迭代/业务需求.md
Normal file
@@ -0,0 +1,364 @@
|
||||
其中有一些需要对接第三方系统的,除了gateway,都可以划分阶段
|
||||
|
||||
|
||||
|
||||
1.当前所有的卡在后台手动操作复机必须要实名,实际上行业卡应当允许未实名复机
|
||||
2. 用户进入H5后需要先绑定手机号后强制先充值后强制实名。这个功能能否进行后台设置,例如这一批设备需要强制先充值后实名,这一批资产可以先实名后充值?
|
||||
3.店铺列表搜索栏新增一项:联系电话,便于搜索
|
||||
4.操作拦截:若当前资产存在退款申请时(但为通过审批时),该资产不允许操作换货。且出现提示:该资产存在退款申请
|
||||
5. 套餐延续创建时选择购买即生效或实名即生效的条件,同时在套餐分配时提供修改条件的功能,并以变更后的条件为最终版本,已经分配出去的套餐不会被后续的宿主套餐修改影响,需要回收后重新分配才能生效
|
||||
6. 在资产详情页增加所有待生效套餐加上生效套餐加起来的最后到期时间
|
||||
7.lot卡管理和设备管理新增已实名/未实名的筛选查询条件
|
||||
8.设备批量分配代理和套餐系列:因设备号不是连号,故需要提供导入excel表的方式进行批量分配代理和套餐系列。excle表头为:设备号
|
||||
9.C端支付时,卡资产只允许支付宝支付以及钱包支付,如果用微信支付就拒绝,设备只允许微信支付以及钱包支付,如果用支付宝支付就拒绝
|
||||
10.限速规则:根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则,限速接口由gateway提供
|
||||
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. 代理分销码与佣金提现
|
||||
##### 员工可作为代理发展人进行标识。(在系统中如何体现?)
|
||||
|
||||
| 编号 | 需求 |
|
||||
| ------- | ---------------------------------------------------- |
|
||||
| DST-001 | 新建代理时自动建立分销归属关系。 |
|
||||
| DST-002 | 员工可作为代理发展人进行标识。(在系统中如何体现?) |
|
||||
| DST-003 | 佣金提现前,代理必须签署合同。(必填) |
|
||||
| DST-004 | 佣金提现需上传营业执照。(可选) |
|
||||
| DST-005 | 佣金提现需上传法人身份证。(必填) |
|
||||
| DST-006 | 佣金提现可选上传门头照。(可选) |
|
||||
| DST-007 | 佣金提现需上传发票,且公司主体需与合同一致。(可选) |
|
||||
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 | 审核流程需对接企业微信审批流程。 |
|
||||
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表导入字段修改为以下:
|
||||
|
||||
| 字段 |
|
||||
|
||||
| ----------------------------- |
|
||||
|
||||
| 资产类型 |
|
||||
|
||||
| 资产标识 |
|
||||
|
||||
| 套餐编码 |
|
||||
|
||||
| 套餐名称|
|
||||
|
||||
| 支付方式:线下支付/代理钱包支付 |
|
||||
|
||||
如果用批量订购应当上传对应凭证
|
||||
|
||||
20.退款审批
|
||||
(审批均可通过企微进行提醒和显示并将最新状态同步至卡管)
|
||||
1. 员工提交退款申请。
|
||||
2. 退款单进入多级审核流程。
|
||||
3. 按部门领导、财务顺序审批。
|
||||
4. 当前环节审批完成后,下一环节审批人收到消息提示。
|
||||
5. 审批通过或驳回后通知申请人。
|
||||
21. 充值审核流程
|
||||
代理自己充值:
|
||||
1. 代理在系统提交充值申请。
|
||||
2. 系统展示收款二维码。
|
||||
3. 代理扫码支付。
|
||||
|
||||
员工代充值:(审批均可通过企微进行提醒和显示并将最新状态同步至卡管)
|
||||
1. 充值单进入多级审核流程。
|
||||
2. 提交人部门领导先审批。
|
||||
3. 财务在上一审批人通过后收到待办提醒并审批。
|
||||
4. 审批通过后通知申请人。
|
||||
5. 审批驳回后通知申请人,并展示驳回原因。
|
||||
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天时,显示黄色;
|
||||
414
docs/7月迭代/基础设施/审批流.md
Normal file
414
docs/7月迭代/基础设施/审批流.md
Normal file
@@ -0,0 +1,414 @@
|
||||
# 基础设施:通用审批流引擎
|
||||
|
||||
> 被依赖:需求 18(多人审批)、需求 20(退款审批)、需求 21(充值审核)
|
||||
> 设计:DDD 结构,`internal/domain/approval/`
|
||||
|
||||
---
|
||||
|
||||
## 一、业务规则
|
||||
|
||||
- 审批顺序:**提交人部门领导 → 财务**(固定两级)
|
||||
- 每级只能有一个审批人(通过角色确定,不是指定个人)
|
||||
- 上一级审批通过后,下一级审批人收到站内消息
|
||||
- **驳回**:永久拒绝,流程终止,通知申请人含驳回原因
|
||||
- **退回**:退回给提交人修改,流程终止,提交人可修改后重新提交(新建审批流)
|
||||
- **通过**:最后一级通过后,通知申请人,触发业务回调
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库
|
||||
|
||||
```sql
|
||||
-- 审批流实例表
|
||||
CREATE TABLE tb_approval_flow (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
flow_no VARCHAR(30) NOT NULL UNIQUE,
|
||||
biz_type VARCHAR(50) NOT NULL, -- 业务类型:recharge | refund
|
||||
biz_id BIGINT NOT NULL,
|
||||
biz_no VARCHAR(50) NOT NULL DEFAULT '', -- 关联业务单号(快照)
|
||||
submitter_id BIGINT NOT NULL,
|
||||
submitter_name VARCHAR(50) NOT NULL DEFAULT '',
|
||||
current_step INT NOT NULL DEFAULT 1, -- 当前步骤(1=部门领导, 2=财务)
|
||||
total_steps INT NOT NULL DEFAULT 2,
|
||||
status INT NOT NULL DEFAULT 1, -- 1=审批中 2=已通过 3=已驳回 4=已退回
|
||||
reject_reason TEXT, -- 驳回/退回原因
|
||||
completed_at TIMESTAMPTZ,
|
||||
creator BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- 审批步骤记录表
|
||||
CREATE TABLE tb_approval_step_record (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
flow_id BIGINT NOT NULL,
|
||||
step INT NOT NULL,
|
||||
step_name VARCHAR(50) NOT NULL,
|
||||
approver_id BIGINT,
|
||||
approver_name VARCHAR(50),
|
||||
result INT NOT NULL DEFAULT 0, -- 0=待审批 1=通过 2=驳回 3=退回
|
||||
remark TEXT,
|
||||
approved_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_approval_flow_biz ON tb_approval_flow (biz_type, biz_id);
|
||||
CREATE INDEX idx_approval_step_flow ON tb_approval_step_record (flow_id, step);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、Model
|
||||
|
||||
```go
|
||||
// internal/model/approval.go
|
||||
|
||||
const (
|
||||
ApprovalBizTypeRecharge = "recharge"
|
||||
ApprovalBizTypeRefund = "refund"
|
||||
|
||||
ApprovalStatusPending = 1 // 审批中
|
||||
ApprovalStatusApproved = 2 // 已通过
|
||||
ApprovalStatusRejected = 3 // 已驳回(永久拒绝,流程终止)
|
||||
ApprovalStatusReturned = 4 // 已退回(退回给提交人修改,可重新提交)
|
||||
|
||||
ApprovalStepResultPending = 0 // 待审批
|
||||
ApprovalStepResultApproved = 1 // 通过
|
||||
ApprovalStepResultRejected = 2 // 驳回
|
||||
ApprovalStepResultReturned = 3 // 退回
|
||||
)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、领域层(DDD)
|
||||
|
||||
```go
|
||||
// internal/domain/approval/approval_flow.go
|
||||
|
||||
type ApprovalFlowAggregate struct {
|
||||
model.ApprovalFlow
|
||||
Steps []*model.ApprovalStepRecord
|
||||
domainEvents []DomainEvent `gorm:"-"`
|
||||
}
|
||||
|
||||
// CanAdvance 检查当前用户是否可以审批当前步骤
|
||||
func (f *ApprovalFlowAggregate) CanAdvance(approverRoles []string) bool {
|
||||
if f.Status != model.ApprovalStatusPending {
|
||||
return false
|
||||
}
|
||||
if f.CurrentStep == 1 {
|
||||
return contains(approverRoles, "department_leader")
|
||||
}
|
||||
if f.CurrentStep == 2 {
|
||||
return contains(approverRoles, "finance")
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// Approve 通过当前步骤
|
||||
func (f *ApprovalFlowAggregate) Approve(approverID uint, approverName string, remark string) error {
|
||||
if f.Status != model.ApprovalStatusPending {
|
||||
return ErrFlowNotPending
|
||||
}
|
||||
now := time.Now()
|
||||
step := f.currentStepRecord()
|
||||
step.ApproverID = &approverID
|
||||
step.ApproverName = &approverName
|
||||
step.Result = model.ApprovalStepResultApproved
|
||||
step.Remark = &remark
|
||||
step.ApprovedAt = &now
|
||||
|
||||
if f.CurrentStep >= f.TotalSteps {
|
||||
f.Status = model.ApprovalStatusApproved
|
||||
f.CompletedAt = &now
|
||||
f.recordEvent(ApprovalCompletedEvent{FlowID: f.ID, BizType: f.BizType, BizID: f.BizID})
|
||||
} else {
|
||||
f.CurrentStep++
|
||||
f.recordEvent(ApprovalStepAdvancedEvent{
|
||||
FlowID: f.ID,
|
||||
NextStep: f.CurrentStep,
|
||||
BizType: f.BizType,
|
||||
SubmitterID: f.SubmitterID,
|
||||
})
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject 驳回(永久拒绝,流程终止)
|
||||
func (f *ApprovalFlowAggregate) Reject(approverID uint, approverName string, reason string) error {
|
||||
if f.Status != model.ApprovalStatusPending {
|
||||
return ErrFlowNotPending
|
||||
}
|
||||
now := time.Now()
|
||||
step := f.currentStepRecord()
|
||||
step.ApproverID = &approverID
|
||||
step.ApproverName = &approverName
|
||||
step.Result = model.ApprovalStepResultRejected
|
||||
step.Remark = &reason
|
||||
step.ApprovedAt = &now
|
||||
|
||||
f.Status = model.ApprovalStatusRejected
|
||||
f.RejectReason = &reason
|
||||
f.CompletedAt = &now
|
||||
f.recordEvent(ApprovalRejectedEvent{
|
||||
FlowID: f.ID,
|
||||
BizType: f.BizType,
|
||||
BizID: f.BizID,
|
||||
SubmitterID: f.SubmitterID,
|
||||
Reason: reason,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
|
||||
// Return 退回给提交人修改(流程终止,提交人可重新提交)
|
||||
func (f *ApprovalFlowAggregate) Return(approverID uint, approverName string, reason string) error {
|
||||
if f.Status != model.ApprovalStatusPending {
|
||||
return ErrFlowNotPending
|
||||
}
|
||||
now := time.Now()
|
||||
step := f.currentStepRecord()
|
||||
step.ApproverID = &approverID
|
||||
step.ApproverName = &approverName
|
||||
step.Result = model.ApprovalStepResultReturned
|
||||
step.Remark = &reason
|
||||
step.ApprovedAt = &now
|
||||
|
||||
f.Status = model.ApprovalStatusReturned
|
||||
f.RejectReason = &reason
|
||||
f.CompletedAt = &now
|
||||
f.recordEvent(ApprovalReturnedEvent{
|
||||
FlowID: f.ID,
|
||||
BizType: f.BizType,
|
||||
BizID: f.BizID,
|
||||
SubmitterID: f.SubmitterID,
|
||||
Reason: reason,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、领域事件
|
||||
|
||||
```go
|
||||
// internal/domain/approval/events.go
|
||||
|
||||
// ApprovalStepAdvancedEvent 步骤推进(通知下一级审批人)
|
||||
type ApprovalStepAdvancedEvent struct {
|
||||
FlowID uint
|
||||
NextStep int
|
||||
BizType string
|
||||
SubmitterID uint
|
||||
}
|
||||
|
||||
// ApprovalCompletedEvent 审批完成(触发业务回调 + 通知申请人)
|
||||
type ApprovalCompletedEvent struct {
|
||||
FlowID uint
|
||||
BizType string
|
||||
BizID uint
|
||||
}
|
||||
|
||||
// ApprovalRejectedEvent 驳回(通知申请人,流程永久终止)
|
||||
type ApprovalRejectedEvent struct {
|
||||
FlowID uint
|
||||
BizType string
|
||||
BizID uint
|
||||
SubmitterID uint
|
||||
Reason string
|
||||
}
|
||||
|
||||
// ApprovalReturnedEvent 退回给提交人修改(通知申请人,可重新提交)
|
||||
type ApprovalReturnedEvent struct {
|
||||
FlowID uint
|
||||
BizType string
|
||||
BizID uint
|
||||
SubmitterID uint
|
||||
Reason string
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、接入协议(业务层如何接入审批流)
|
||||
|
||||
任何业务接入审批流需做以下四件事:
|
||||
|
||||
### 6.1 业务表新增字段
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_xxx ADD COLUMN approval_flow_id BIGINT DEFAULT NULL;
|
||||
```
|
||||
|
||||
### 6.2 提交时创建审批流
|
||||
|
||||
业务提交接口(如 `POST /admin/refund-requests`):
|
||||
|
||||
```go
|
||||
// 1. 创建业务单(status=待审批)
|
||||
bizRecord, _ := s.bizStore.Create(ctx, req)
|
||||
|
||||
// 2. 创建审批流
|
||||
flow, _ := submitApprovalUseCase.Execute(ctx, SubmitApprovalCommand{
|
||||
BizType: model.ApprovalBizTypeRefund,
|
||||
BizID: bizRecord.ID,
|
||||
BizNo: bizRecord.RefundNo,
|
||||
SubmitterID: middleware.GetUserID(ctx),
|
||||
SubmitterName: middleware.GetUsername(ctx),
|
||||
})
|
||||
|
||||
// 3. 把 flow_id 写回业务单
|
||||
s.bizStore.UpdateApprovalFlowID(ctx, bizRecord.ID, flow.ID)
|
||||
```
|
||||
|
||||
### 6.3 监听审批事件,更新业务状态
|
||||
|
||||
每个接入方在 `internal/task/` 注册对应的 Asynq 事件处理器:
|
||||
|
||||
```go
|
||||
// internal/task/refund_approval_handler.go
|
||||
|
||||
// OnApprovalCompleted 审批通过 → 退款单状态变"已通过",触发退款
|
||||
func (h *RefundApprovalHandler) OnApprovalCompleted(ctx context.Context, event ApprovalCompletedEvent) error {
|
||||
// 按 biz_type 过滤,只处理退款类型
|
||||
if event.BizType != model.ApprovalBizTypeRefund { return nil }
|
||||
return h.refundService.ApproveRefund(ctx, event.BizID)
|
||||
}
|
||||
|
||||
// OnApprovalRejected 审批驳回 → 退款单状态变"已拒绝"
|
||||
func (h *RefundApprovalHandler) OnApprovalRejected(ctx context.Context, event ApprovalRejectedEvent) error {
|
||||
if event.BizType != model.ApprovalBizTypeRefund { return nil }
|
||||
return h.refundService.RejectRefund(ctx, event.BizID, event.Reason)
|
||||
}
|
||||
|
||||
// OnApprovalReturned 审批退回 → 退款单状态变"已退回",提交人可修改重提
|
||||
func (h *RefundApprovalHandler) OnApprovalReturned(ctx context.Context, event ApprovalReturnedEvent) error {
|
||||
if event.BizType != model.ApprovalBizTypeRefund { return nil }
|
||||
return h.refundService.ReturnRefund(ctx, event.BizID, event.Reason)
|
||||
}
|
||||
```
|
||||
|
||||
充值单同理,注册 `RechargeApprovalHandler`。
|
||||
|
||||
### 6.4 退回后重新提交
|
||||
|
||||
业务层新增重新提交接口(如 `POST /admin/refund-requests/{id}/resubmit`):
|
||||
|
||||
```go
|
||||
// 1. 校验业务单 status=已退回
|
||||
if bizRecord.Status != model.RefundStatusReturned {
|
||||
return errors.New(errors.CodeForbidden, "当前状态不允许重新提交")
|
||||
}
|
||||
|
||||
// 2. 新建审批流(biz_type+biz_id 相同,旧的流程作为历史保留)
|
||||
flow, _ := submitApprovalUseCase.Execute(ctx, SubmitApprovalCommand{...})
|
||||
|
||||
// 3. 更新业务单:approval_flow_id = 新 flow.ID,status 回到"待审批"
|
||||
s.bizStore.Resubmit(ctx, bizRecord.ID, flow.ID)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、应用层(用例)
|
||||
|
||||
```go
|
||||
// internal/application/approval/submit_approval.go
|
||||
|
||||
type SubmitApprovalCommand struct {
|
||||
BizType string
|
||||
BizID uint
|
||||
BizNo string
|
||||
SubmitterID uint
|
||||
SubmitterName string
|
||||
}
|
||||
|
||||
type SubmitApprovalUseCase struct {
|
||||
approvalRepo approval.ApprovalRepository
|
||||
notifyPublisher *notification.NotificationPublisher
|
||||
userStore UserStore
|
||||
}
|
||||
|
||||
func (uc *SubmitApprovalUseCase) Execute(ctx context.Context, cmd SubmitApprovalCommand) (*model.ApprovalFlow, error) {
|
||||
flow := &domain_approval.ApprovalFlowAggregate{...}
|
||||
// 初始化两个步骤记录(均为待审批)
|
||||
if err := uc.approvalRepo.Create(ctx, flow); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 通知部门领导
|
||||
leaderIDs := uc.userStore.GetUsersByRole(ctx, "department_leader")
|
||||
uc.notifyPublisher.Publish(ctx, notification.SendPayload{
|
||||
RecipientIDs: leaderIDs,
|
||||
Type: constants.NotifyTypeApprovalPending,
|
||||
Title: "您有一条待审批记录",
|
||||
Body: fmt.Sprintf("「%s」提交了%s申请", cmd.SubmitterName, bizTypeLabel(cmd.BizType)),
|
||||
RefType: "approval",
|
||||
RefID: flow.ID,
|
||||
})
|
||||
return &flow.ApprovalFlow, nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 八、API 设计
|
||||
|
||||
### 8.1 待我审批列表
|
||||
|
||||
```
|
||||
GET /admin/approvals?status=1&biz_type=recharge&page=1&page_size=20
|
||||
```
|
||||
|
||||
### 8.2 审批通过
|
||||
|
||||
```
|
||||
POST /admin/approvals/{flow_id}/approve
|
||||
{ "remark": "审批通过" }
|
||||
```
|
||||
|
||||
### 8.3 审批驳回(永久拒绝)
|
||||
|
||||
```
|
||||
POST /admin/approvals/{flow_id}/reject
|
||||
{ "reason": "金额不符,请重新提交" }
|
||||
```
|
||||
|
||||
### 8.4 退回给提交人修改
|
||||
|
||||
```
|
||||
POST /admin/approvals/{flow_id}/return
|
||||
{ "reason": "请补充支付凭证后重新提交" }
|
||||
```
|
||||
|
||||
退回后,提交人修改业务单,再通过业务接口重新提交(如 `POST /admin/refund-requests/{id}/resubmit`)。
|
||||
|
||||
### 8.5 审批详情
|
||||
|
||||
```
|
||||
GET /admin/approvals/{flow_id}
|
||||
```
|
||||
|
||||
响应包含:flow 基本信息 + steps 列表(每步审批人、结果【通过/驳回/退回】、时间、备注)
|
||||
|
||||
---
|
||||
|
||||
## 九、前端对接
|
||||
|
||||
### 审批详情页操作区
|
||||
|
||||
当前登录用户是当前步骤审批人时,显示三个按钮:
|
||||
|
||||
```
|
||||
[审批通过] [退回修改] [驳回]
|
||||
备注/原因输入框
|
||||
```
|
||||
|
||||
- **审批通过** → `POST /admin/approvals/{id}/approve`
|
||||
- **退回修改** → `POST /admin/approvals/{id}/return`(需填退回原因)
|
||||
- **驳回** → `POST /admin/approvals/{id}/reject`(需填驳回原因)
|
||||
|
||||
### 权限控制
|
||||
|
||||
- `department_leader` 角色:步骤1为"待审批"时显示操作按钮
|
||||
- `finance` 角色:步骤2为"待审批"时显示操作按钮
|
||||
- 其他角色:只读
|
||||
340
docs/7月迭代/基础设施/站内消息.md
Normal file
340
docs/7月迭代/基础设施/站内消息.md
Normal file
@@ -0,0 +1,340 @@
|
||||
# 基础设施:站内消息(Notification)
|
||||
|
||||
> 被依赖:需求 18/20/21(审批流通知)、需求 22(临期提醒)
|
||||
> Phase 2 扩展:企业微信推送、短信通知(预留插拔接口)
|
||||
|
||||
---
|
||||
|
||||
## 一、设计原则
|
||||
|
||||
- **业务代码不直接写通知**:只发 Asynq 任务,异步处理
|
||||
- **不过度抽象**:不用 interface,用具体的 `NotificationPublisher`(后续加渠道 = 在 handler 里加代码)
|
||||
- **前端轮询**:30秒一次 `/admin/notifications/unread-count`,不用 WebSocket
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库
|
||||
|
||||
```sql
|
||||
-- 迁移文件:YYYYMMDD_create_tb_notification.sql
|
||||
CREATE TABLE tb_notification (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
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 '', -- 正文(支持简单 HTML)
|
||||
ref_type VARCHAR(50), -- 关联业务类型 approval | recharge | refund | package
|
||||
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);
|
||||
CREATE INDEX idx_notification_created ON tb_notification (created_at DESC);
|
||||
|
||||
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"`
|
||||
RecipientID uint `gorm:"column:recipient_id;not null;index" json:"recipient_id"`
|
||||
RecipientType string `gorm:"column:recipient_type;type:varchar(20);not null;default:'admin'" 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
|
||||
|
||||
业务代码调这个发布通知,内部异步入队:
|
||||
|
||||
```go
|
||||
// internal/service/notification/publisher.go
|
||||
|
||||
// SendPayload 发送通知的参数
|
||||
type SendPayload struct {
|
||||
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 发布通知(异步,不阻塞业务)
|
||||
// 失败只记录日志,不影响业务事务
|
||||
func (p *NotificationPublisher) Publish(ctx context.Context, payload SendPayload) {
|
||||
if err := p.queueClient.EnqueueTask(ctx, constants.TaskTypeNotification, payload); err != nil {
|
||||
p.logger.Error("通知入队失败", zap.Error(err), zap.String("type", payload.Type))
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
业务代码调用示例(审批推进后通知下一审批人):
|
||||
|
||||
```go
|
||||
p.notifyPublisher.Publish(ctx, notification.SendPayload{
|
||||
RecipientIDs: []uint{nextApproverID},
|
||||
RecipientType: constants.NotifyRecipientAdmin,
|
||||
Type: constants.NotifyTypeApprovalPending,
|
||||
Title: "您有一条待审批记录",
|
||||
Body: fmt.Sprintf("代理「%s」提交了充值申请,请及时审批。", shopName),
|
||||
RefType: "approval",
|
||||
RefID: approvalFlowID,
|
||||
})
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、消费侧: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{
|
||||
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).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 /admin/notifications/unread-count
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{ "code": 0, "data": { "count": 5 } }
|
||||
```
|
||||
|
||||
### 7.2 通知列表
|
||||
|
||||
```
|
||||
GET /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 /admin/notifications/{id}/read
|
||||
```
|
||||
|
||||
### 7.4 全部标记已读
|
||||
|
||||
```
|
||||
PUT /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 /admin/notifications/unread-count(30秒一次)
|
||||
→ count > 0 时铃铛显示红点 + 数字
|
||||
→ 点击铃铛 → 弹出通知抽屉 or 跳转 /notifications 页面
|
||||
→ 打开时调 GET /admin/notifications?is_read=false
|
||||
→ 点击某条通知 → PUT /admin/notifications/{id}/read → 根据 ref_type+ref_id 跳转对应业务页
|
||||
```
|
||||
|
||||
### 跳转逻辑(ref_type)
|
||||
|
||||
| ref_type | 跳转页面 |
|
||||
|----------|---------|
|
||||
| `approval` | `/approval/{ref_id}` 审批详情 |
|
||||
| `recharge` | `/recharge/{ref_id}` 充值单详情 |
|
||||
| `refund` | `/refund/{ref_id}` 退款单详情 |
|
||||
| `package` | `/assets/{ref_id}` 资产详情(临期提醒) |
|
||||
|
||||
### 通知列表页(/notifications)
|
||||
|
||||
筛选:通知类型(下拉)、已读状态
|
||||
操作:全部已读按钮
|
||||
列表字段:类型、标题、时间、已读状态
|
||||
点击行:跳转关联业务详情
|
||||
|
||||
---
|
||||
|
||||
## 九、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 里加一段即可。
|
||||
216
docs/7月迭代/基础设施/系统配置.md
Normal file
216
docs/7月迭代/基础设施/系统配置.md
Normal file
@@ -0,0 +1,216 @@
|
||||
# 基础设施:系统配置(tb_system_config)
|
||||
|
||||
> 被依赖:需求 02(H5流程配置)、需求 09(C端支付限制)
|
||||
|
||||
---
|
||||
|
||||
## 一、设计目标
|
||||
|
||||
将散落在代码里的"写死配置"提取到数据库,平台管理员可通过后台页面修改,无需重新部署。
|
||||
|
||||
---
|
||||
|
||||
## 二、数据库
|
||||
|
||||
```sql
|
||||
-- 迁移文件:YYYYMMDD_create_tb_system_config.sql
|
||||
CREATE TABLE tb_system_config (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
config_key VARCHAR(100) NOT NULL, -- 唯一键,格式:module.group.name
|
||||
config_value TEXT NOT NULL DEFAULT '', -- 值(string/number/json字符串)
|
||||
value_type VARCHAR(20) NOT NULL DEFAULT 'string', -- string | int | bool | json
|
||||
module VARCHAR(50) NOT NULL DEFAULT 'general', -- 所属模块(便于按模块查询)
|
||||
description TEXT, -- 中文说明(前端展示用)
|
||||
is_readonly BOOLEAN NOT NULL DEFAULT FALSE, -- 是否只读(代码内部,不允许后台改)
|
||||
creator BIGINT NOT NULL DEFAULT 0,
|
||||
updater BIGINT NOT NULL DEFAULT 0,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_system_config_key UNIQUE (config_key)
|
||||
);
|
||||
|
||||
COMMENT ON TABLE tb_system_config IS '系统全局配置表';
|
||||
|
||||
-- 初始化数据
|
||||
INSERT INTO tb_system_config (config_key, config_value, value_type, module, description) VALUES
|
||||
-- C端支付限制(需求9)
|
||||
('c2b.payment.card_allowed_methods', '["alipay","wallet"]', 'json', 'c2b.payment', '卡资产C端允许的支付方式(alipay/wechat/wallet)'),
|
||||
('c2b.payment.device_allowed_methods', '["wechat","wallet"]', 'json', 'c2b.payment', '设备C端允许的支付方式'),
|
||||
|
||||
-- H5流程顺序(需求2,per-asset 覆盖,此处是全局默认)
|
||||
-- 注意:per-asset 的 realname_policy 已在 iot_card.realname_policy 字段存储
|
||||
-- 此处仅存"H5绑定手机后的默认流程"
|
||||
('h5.flow.default_realname_policy', 'after_order', 'string', 'h5.flow', 'H5新用户默认实名策略(none/before_order/after_order)');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 三、Model
|
||||
|
||||
```go
|
||||
// internal/model/system_config.go
|
||||
|
||||
// SystemConfig 系统全局配置模型
|
||||
type SystemConfig struct {
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
ConfigKey string `gorm:"column:config_key;uniqueIndex;not null" json:"config_key"`
|
||||
ConfigValue string `gorm:"column:config_value;type:text;not null;default:''" json:"config_value"`
|
||||
ValueType string `gorm:"column:value_type;type:varchar(20);not null;default:'string'" json:"value_type"`
|
||||
Module string `gorm:"column:module;type:varchar(50);not null;default:'general';index" json:"module"`
|
||||
Description string `gorm:"column:description;type:text" json:"description"`
|
||||
IsReadonly bool `gorm:"column:is_readonly;not null;default:false" json:"is_readonly"`
|
||||
Creator uint `gorm:"column:creator;not null;default:0" json:"creator"`
|
||||
Updater uint `gorm:"column:updater;not null;default:0" json:"updater"`
|
||||
CreatedAt time.Time `gorm:"column:created_at" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at" json:"updated_at"`
|
||||
}
|
||||
|
||||
func (SystemConfig) TableName() string { return "tb_system_config" }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 四、Store
|
||||
|
||||
```go
|
||||
// internal/store/postgres/system_config_store.go
|
||||
|
||||
type SystemConfigStore struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func (s *SystemConfigStore) GetByKey(ctx context.Context, key string) (*model.SystemConfig, error)
|
||||
func (s *SystemConfigStore) GetByModule(ctx context.Context, module string) ([]model.SystemConfig, error)
|
||||
func (s *SystemConfigStore) UpdateValue(ctx context.Context, key string, value string, updaterID uint) error
|
||||
func (s *SystemConfigStore) BatchGet(ctx context.Context, keys []string) (map[string]*model.SystemConfig, error)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、配置读取辅助包
|
||||
|
||||
业务代码不直接操作 Store,通过辅助函数读取,带 Redis 缓存(5分钟TTL):
|
||||
|
||||
```go
|
||||
// pkg/sysconfig/config.go
|
||||
|
||||
// GetString 读取字符串配置,返回默认值
|
||||
func GetString(ctx context.Context, key string, defaultVal string) string
|
||||
|
||||
// GetStringSlice 读取 JSON 数组配置
|
||||
func GetStringSlice(ctx context.Context, key string) ([]string, error)
|
||||
|
||||
// GetBool 读取布尔配置
|
||||
func GetBool(ctx context.Context, key string, defaultVal bool) bool
|
||||
|
||||
// InvalidateCache 更新配置后清缓存(在 UpdateValue 后调用)
|
||||
func InvalidateCache(ctx context.Context, key string)
|
||||
```
|
||||
|
||||
Redis Key:`sys:config:{config_key}`,TTL 5分钟。
|
||||
|
||||
业务代码用法:
|
||||
```go
|
||||
// 读取卡的允许支付方式
|
||||
allowedMethods, _ := sysconfig.GetStringSlice(ctx, "c2b.payment.card_allowed_methods")
|
||||
// 返回 ["alipay","wallet"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 六、API 设计
|
||||
|
||||
### 6.1 获取配置列表
|
||||
|
||||
```
|
||||
GET /admin/system/config?module=c2b.payment
|
||||
```
|
||||
|
||||
响应:
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"data": {
|
||||
"list": [
|
||||
{
|
||||
"config_key": "c2b.payment.card_allowed_methods",
|
||||
"config_value": "[\"alipay\",\"wallet\"]",
|
||||
"value_type": "json",
|
||||
"description": "卡资产C端允许的支付方式",
|
||||
"is_readonly": false,
|
||||
"updated_at": "2026-07-11T10:00:00Z"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 更新配置
|
||||
|
||||
```
|
||||
PUT /admin/system/config/{config_key}
|
||||
```
|
||||
|
||||
请求体:
|
||||
```json
|
||||
{
|
||||
"config_value": "[\"alipay\",\"wallet\",\"wechat\"]"
|
||||
}
|
||||
```
|
||||
|
||||
权限:仅平台超管可操作。
|
||||
|
||||
### DTO
|
||||
|
||||
```go
|
||||
// internal/model/dto/system_config_dto.go
|
||||
|
||||
// SystemConfigListRequest 配置列表查询请求
|
||||
type SystemConfigListRequest struct {
|
||||
Module string `query:"module" description:"按模块过滤(可选)"`
|
||||
}
|
||||
|
||||
// SystemConfigItem 配置项响应
|
||||
type SystemConfigItem struct {
|
||||
ConfigKey string `json:"config_key"`
|
||||
ConfigValue string `json:"config_value"`
|
||||
ValueType string `json:"value_type" description:"值类型 (string/int/bool/json)"`
|
||||
Module string `json:"module"`
|
||||
Description string `json:"description"`
|
||||
IsReadonly bool `json:"is_readonly"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
// UpdateSystemConfigRequest 更新配置请求
|
||||
type UpdateSystemConfigRequest struct {
|
||||
ConfigValue string `json:"config_value" validate:"required" description:"新配置值"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 七、前端对接
|
||||
|
||||
### 页面:系统设置 > 系统配置
|
||||
|
||||
**初期可以做一个通用的 Key-Value 管理页面,按 module 分组展示。**
|
||||
|
||||
调用流程:
|
||||
1. 进入页面 → `GET /admin/system/config`(不传 module = 返回全部)
|
||||
2. 按 module 分组展示,`is_readonly=true` 的配置只读
|
||||
3. 修改某项 → `PUT /admin/system/config/{config_key}`,body: `{config_value}`
|
||||
4. 修改成功后提示"配置已更新,约5分钟后生效"(Redis 缓存 TTL)
|
||||
|
||||
**C端支付限制配置展示建议**(针对 `c2b.payment` 模块):
|
||||
|
||||
不要让后台用户手动填 JSON,前端渲染成 CheckboxGroup:
|
||||
|
||||
```
|
||||
卡资产允许支付方式:
|
||||
☑ 支付宝 ☑ 钱包 ☐ 微信
|
||||
|
||||
设备允许支付方式:
|
||||
☐ 支付宝 ☑ 钱包 ☑ 微信
|
||||
```
|
||||
|
||||
前端把选中项序列化成 `["alipay","wallet"]` 提交。
|
||||
54
docs/7月迭代/需求01-复机实名规则.md
Normal file
54
docs/7月迭代/需求01-复机实名规则.md
Normal file
@@ -0,0 +1,54 @@
|
||||
# 需求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, "卡未实名,无法操作")
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端对接
|
||||
|
||||
无需前端改动。复机操作界面不变,行业卡原先会报错"卡未实名,无法操作",修复后直接成功。
|
||||
211
docs/7月迭代/需求02-H5流程配置.md
Normal file
211
docs/7月迭代/需求02-H5流程配置.md
Normal file
@@ -0,0 +1,211 @@
|
||||
# 需求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 /admin/iot-cards/batch-update-realname-policy
|
||||
```
|
||||
|
||||
请求体:
|
||||
```go
|
||||
type BatchUpdateIotCardRealnamePolicy struct {
|
||||
IotCardIDs []uint `json:"iot_card_ids" validate:"required,min=1" description:"卡ID列表"`
|
||||
RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order"
|
||||
description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"`
|
||||
}
|
||||
```
|
||||
|
||||
Service:`UPDATE tb_iot_cards SET realname_policy = ? WHERE id IN (...)`,逐条写审计日志。
|
||||
|
||||
#### 2b. 批量修改设备实名策略
|
||||
|
||||
```
|
||||
POST /admin/devices/batch-update-realname-policy
|
||||
```
|
||||
|
||||
请求体:
|
||||
```go
|
||||
type BatchUpdateDeviceRealnamePolicy struct {
|
||||
DeviceIDs []uint `json:"device_ids" validate:"required,min=1" description:"设备ID列表"`
|
||||
RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order"
|
||||
description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"`
|
||||
}
|
||||
```
|
||||
|
||||
Service:`UPDATE tb_devices SET realname_policy = ? WHERE id IN (...)`,逐条写审计日志。
|
||||
|
||||
### 3. 全局默认值(兜底)
|
||||
|
||||
新建卡/设备时默认 `after_order`,通过 GORM default 标签保证,不需要读 `tb_system_config`。
|
||||
|
||||
---
|
||||
|
||||
## 前端对接
|
||||
|
||||
### 卡列表页
|
||||
|
||||
"操作"列或批量操作下拉增加"设置实名策略":
|
||||
|
||||
- **单条**:弹框选策略 → `PATCH /api/admin/assets/{iccid}/realname-mode`
|
||||
- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /admin/iot-cards/batch-update-realname-policy`
|
||||
|
||||
> 注意:设备下的卡即使在卡列表中修改了策略,对 H5 流程也无效(H5 取设备策略)。建议在卡列表展示"所属设备"列,提示运营该卡已属于某设备,实名策略需到设备处修改。
|
||||
|
||||
### 设备列表页
|
||||
|
||||
"操作"列或批量操作下拉增加"设置实名策略":
|
||||
|
||||
- **单条**:弹框选策略 → `PATCH /api/admin/assets/{sn}/realname-mode`
|
||||
- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /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 /admin/iot-cards/batch-update-realname-policy` |
|
||||
| 批量修改设备接口 | ❌ 待建 | `POST /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_devices` 新增字段:
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_devices ADD COLUMN real_name_status int NOT NULL DEFAULT 0
|
||||
COMMENT '实名状态快照(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:资产详情-套餐到期时间字段 + 15天高亮
|
||||
|
||||
### 后端
|
||||
|
||||
**无需改动。**
|
||||
|
||||
后台资产详情页实际调用的是:
|
||||
|
||||
```
|
||||
GET /api/admin/assets/resolve/:identifier
|
||||
```
|
||||
|
||||
该接口的 `AssetResolveResponse`(`internal/model/dto/asset_dto.go`)已包含:
|
||||
|
||||
```go
|
||||
CurrentPackage string `json:"current_package"` // 当前套餐名称
|
||||
CurrentPackageActivatedAt *time.Time `json:"current_package_activated_at"` // 开始时间
|
||||
CurrentPackageExpiresAt *time.Time `json:"current_package_expires_at"` // 到期时间(无套餐为 null)
|
||||
```
|
||||
|
||||
到期时间字段已有,前端直接读 `current_package_expires_at` 即可,不需要新增后端字段。
|
||||
|
||||
### 前端
|
||||
|
||||
资产详情页"套餐信息"板块展示到期时间,并在剩余 ≤15 天时高亮:
|
||||
|
||||
- 读取 `resolve` 接口返回的 `current_package_expires_at`
|
||||
- 若为 `null`:展示"暂无套餐"
|
||||
- 剩余天数由前端计算:`Math.ceil((expiresAt - now) / 86400000)`
|
||||
- 剩余 ≤15 天:**红色/高亮**展示(建议红色文字 + 标签)
|
||||
|
||||
---
|
||||
|
||||
## 需求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:列表字段新增
|
||||
|
||||
### 核心原则
|
||||
|
||||
提交人、审批人均在**写入时快照**到单据本身,读取时直接返回,不做额外查询。
|
||||
|
||||
---
|
||||
|
||||
### 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` 新增字段:
|
||||
```sql
|
||||
ALTER TABLE tb_refund ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT '';
|
||||
ALTER TABLE tb_refund ADD COLUMN dept_leader_name varchar(50) NOT NULL DEFAULT '';
|
||||
ALTER TABLE tb_refund ADD COLUMN finance_approver_name varchar(50) NOT NULL DEFAULT '';
|
||||
```
|
||||
|
||||
- `submitter_name`:创建退款单时快照操作人 username
|
||||
- `dept_leader_name` / `finance_approver_name`:审批流各环节通过时快照对应审批人 username
|
||||
|
||||
> **实施依赖**:`dept_leader_name` / `finance_approver_name` 在审批流(需求20)实现后才能写入,未实现前返回空字符串。`submitter_name` 本迭代即可实现。
|
||||
|
||||
**响应 DTO**(退款列表响应新增):
|
||||
```go
|
||||
SubmitterName string `json:"submitter_name" description:"提交人账号名"`
|
||||
DeptLeaderName string `json:"dept_leader_name" description:"部门领导审批人账号名"`
|
||||
FinanceApproverName string `json:"finance_approver_name" description:"财务审批人账号名"`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### COL-002:代理充值列表新增提交人、审批人(依赖审批流)
|
||||
|
||||
与 COL-001 同理,`tb_agent_recharge`(充值单表)新增同样三个字段,快照逻辑一致。
|
||||
|
||||
> **实施依赖**:`submitter_name` 本迭代可实现;审批人字段依赖需求21(充值审批流)。
|
||||
|
||||
---
|
||||
|
||||
### 前端
|
||||
|
||||
各列表页新增对应字段列,展示即可,无额外交互。
|
||||
183
docs/7月迭代/需求04-06-退款拦截与最后到期时间.md
Normal file
183
docs/7月迭代/需求04-06-退款拦截与最后到期时间.md
Normal file
@@ -0,0 +1,183 @@
|
||||
# 需求04:退款中资产禁止换货
|
||||
# 需求06:资产最后到期时间
|
||||
|
||||
---
|
||||
|
||||
## 需求04:退款中禁止换货
|
||||
|
||||
### 业务规则
|
||||
|
||||
资产存在**未结束**的退款申请时,不允许操作换货,提示"该资产存在退款申请,无法操作换货"。
|
||||
|
||||
未结束状态:1=待审批、4=已退回(退回给提交人修改中)。
|
||||
终态(不拦截):2=已通过、3=已拒绝。
|
||||
|
||||
### 数据模型
|
||||
|
||||
退款模型:`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 status IN (?, ?) AND deleted_at IS NULL",
|
||||
cardID,
|
||||
model.RefundStatusPending, // 1=待审批
|
||||
model.RefundStatusReturned, // 4=已退回(拦截)
|
||||
).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 status IN (?, ?) AND deleted_at IS NULL",
|
||||
deviceID,
|
||||
model.RefundStatusPending, // 1=待审批
|
||||
model.RefundStatusReturned, // 4=已退回(拦截)
|
||||
).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:资产详情-所有套餐的最后到期时间
|
||||
|
||||
### 业务规则
|
||||
|
||||
资产详情页展示:**当前生效套餐 + 所有待生效套餐**中最晚的到期时间。
|
||||
|
||||
(即所有排队套餐依次激活完毕后,资产最终什么时候到期)
|
||||
|
||||
### 接口
|
||||
|
||||
后台资产详情页实际调用的是:
|
||||
|
||||
```
|
||||
GET /api/admin/assets/resolve/:identifier
|
||||
```
|
||||
|
||||
响应 DTO:`AssetResolveResponse`(`internal/model/dto/asset_dto.go`)
|
||||
|
||||
该 DTO 目前**不含** `last_package_expires_at` 字段,需新增。
|
||||
|
||||
### 后端
|
||||
|
||||
**DTO 新增字段**(`internal/model/dto/asset_dto.go` → `AssetResolveResponse`):
|
||||
|
||||
```go
|
||||
// 所有套餐(生效中+待生效)中最晚的到期时间,无套餐时为 null
|
||||
LastPackageExpiresAt *time.Time `json:"last_package_expires_at" description:"所有套餐(生效中+待生效)的最后到期时间,无套餐时为 null"`
|
||||
```
|
||||
|
||||
**Store 新增方法**(`internal/store/postgres/package_usage_store.go`):
|
||||
|
||||
```go
|
||||
// GetActiveAndQueuedByCardID 获取卡的生效中和待生效套餐
|
||||
func (s *PackageUsageStore) GetActiveAndQueuedByCardID(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) {
|
||||
var usages []*model.PackageUsage
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("iot_card_id = ? AND status IN (?, ?) AND deleted_at IS NULL", cardID,
|
||||
constants.PackageUsageStatusActive, // 1=生效中
|
||||
constants.PackageUsageStatusPending, // 0=待生效
|
||||
).
|
||||
Order("created_at ASC").
|
||||
Find(&usages).Error
|
||||
return usages, err
|
||||
}
|
||||
|
||||
// GetActiveAndQueuedByDeviceID 获取设备的生效中和待生效套餐
|
||||
func (s *PackageUsageStore) GetActiveAndQueuedByDeviceID(ctx context.Context, deviceID uint) ([]*model.PackageUsage, error) {
|
||||
var usages []*model.PackageUsage
|
||||
err := s.db.WithContext(ctx).
|
||||
Where("device_id = ? AND status IN (?, ?) AND deleted_at IS NULL", deviceID,
|
||||
constants.PackageUsageStatusActive, // 1=生效中
|
||||
constants.PackageUsageStatusPending, // 0=待生效
|
||||
).
|
||||
Order("created_at ASC").
|
||||
Find(&usages).Error
|
||||
return usages, err
|
||||
}
|
||||
```
|
||||
|
||||
> 注:待生效套餐的 `expires_at` 可能为 null(实名即生效尚未实名),取已知 expires_at 中的最大值。
|
||||
|
||||
**Service 计算逻辑**(在 `ResolveAsset` 结果组装处添加):
|
||||
|
||||
```go
|
||||
var lastExpiry *time.Time
|
||||
var usages []*model.PackageUsage
|
||||
if card != nil {
|
||||
usages, _ = s.packageUsageStore.GetActiveAndQueuedByCardID(ctx, card.ID)
|
||||
} else if device != nil {
|
||||
usages, _ = s.packageUsageStore.GetActiveAndQueuedByDeviceID(ctx, device.ID)
|
||||
}
|
||||
for _, u := range usages {
|
||||
if u.ExpiresAt == nil {
|
||||
continue
|
||||
}
|
||||
if lastExpiry == nil || u.ExpiresAt.After(*lastExpiry) {
|
||||
lastExpiry = u.ExpiresAt
|
||||
}
|
||||
}
|
||||
resp.LastPackageExpiresAt = lastExpiry
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
资产详情"套餐信息"板块新增展示:
|
||||
|
||||
```
|
||||
最后到期时间:2027-01-01
|
||||
```
|
||||
|
||||
读取 `resolve` 接口返回的 `last_package_expires_at`,有值则展示,无值(无套餐)展示"—"。
|
||||
158
docs/7月迭代/需求05-套餐分配生效条件.md
Normal file
158
docs/7月迭代/需求05-套餐分配生效条件.md
Normal file
@@ -0,0 +1,158 @@
|
||||
# 需求05:套餐分配生效条件(ExpiryBase 覆盖)
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
`Package.ExpiryBase` 已存在(`from_activation` / `from_purchase`),在套餐创建时设定,控制套餐何时开始计时。
|
||||
|
||||
需求:分配套餐给代理时,可以对单条分配记录二次覆盖这个值。
|
||||
|
||||
---
|
||||
|
||||
## 快照链设计
|
||||
|
||||
```
|
||||
ShopPackageAllocation.expiry_base_override(分配时设置覆盖值)
|
||||
↓ 订单创建时读取,取有效值(override 优先,否则用套餐默认)
|
||||
PackageUsage.expiry_base_snapshot(快照进去)
|
||||
↓ 激活时读取
|
||||
activation_service 读 usage.ExpiryBaseSnapshot,不再直接读 pkg.ExpiryBase
|
||||
```
|
||||
|
||||
遗留数据兜底:`ExpiryBaseSnapshot` 为空(旧数据)时,回退读 `pkg.ExpiryBase`,行为不变。
|
||||
|
||||
---
|
||||
|
||||
## 数据库变更
|
||||
|
||||
### 1. ShopPackageAllocation 新增覆盖字段
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_shop_package_allocation
|
||||
ADD COLUMN expiry_base_override VARCHAR(30) DEFAULT NULL
|
||||
COMMENT '生效条件覆盖(NULL=使用套餐默认值, from_activation=实名即生效, from_purchase=购买即生效)';
|
||||
```
|
||||
|
||||
### 2. PackageUsage 新增快照字段
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_package_usage
|
||||
ADD COLUMN expiry_base_snapshot VARCHAR(30) NOT NULL DEFAULT ''
|
||||
COMMENT '生效条件快照(创建时从分配记录取有效值写入,空字符串=旧数据兜底读套餐原值)';
|
||||
```
|
||||
|
||||
旧数据不回填,默认空字符串,激活时自动兜底。
|
||||
|
||||
---
|
||||
|
||||
## 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"`
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 业务逻辑变更
|
||||
|
||||
### 1. 订单创建时快照(`internal/service/order/service.go`)
|
||||
|
||||
订单创建已通过 `GetByShopAndPackage` 查询分配记录(现有逻辑),在此基础上追加:
|
||||
|
||||
```go
|
||||
// 取生效条件有效值:分配覆盖 > 套餐默认
|
||||
expiryBase := pkg.ExpiryBase
|
||||
if allocation.ExpiryBaseOverride != nil && *allocation.ExpiryBaseOverride != "" {
|
||||
expiryBase = *allocation.ExpiryBaseOverride
|
||||
}
|
||||
// 创建 PackageUsage 时写入快照
|
||||
usage.ExpiryBaseSnapshot = expiryBase
|
||||
```
|
||||
|
||||
### 2. 激活时读快照(`internal/service/package/activation_service.go`)
|
||||
|
||||
```go
|
||||
// 修改前:直接读套餐原值
|
||||
expiryBase := pkg.ExpiryBase
|
||||
|
||||
// 修改后:优先读快照,旧数据兜底
|
||||
expiryBase := pkg.ExpiryBase // 兜底(旧数据 ExpiryBaseSnapshot 为空)
|
||||
if usage.ExpiryBaseSnapshot != "" {
|
||||
expiryBase = usage.ExpiryBaseSnapshot
|
||||
}
|
||||
```
|
||||
|
||||
同文件另一处(`activation_service.go:556`)同理修改。
|
||||
|
||||
`internal/service/order/service.go:2290` 中后台囤货路径的 `ExpiryBase` 判断也需同样改为读快照(兜底逻辑一致)。
|
||||
|
||||
---
|
||||
|
||||
## API 变更
|
||||
|
||||
### 1. 分配套餐接口(新增参数)
|
||||
|
||||
```
|
||||
POST /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 /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 /admin/shop-package-allocations/{id}/expiry-base`。
|
||||
154
docs/7月迭代/需求08-设备批量分配Excel.md
Normal file
154
docs/7月迭代/需求08-设备批量分配Excel.md
Normal file
@@ -0,0 +1,154 @@
|
||||
# 需求08:设备批量分配代理和套餐系列(Excel导入)
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
设备号不连续,无法通过号段批量分配。需要通过上传 Excel 表(表头:设备号)来批量指定分配目标。
|
||||
|
||||
`Device` 表已有 `shop_id *uint` 和 `series_id *uint` 字段,分配即更新这两个字段。
|
||||
|
||||
---
|
||||
|
||||
## 数据库变更
|
||||
|
||||
新建批量分配任务表(不复用 `DeviceImportTask`,业务语义不同):
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_device_batch_allocation_task (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
creator BIGINT NOT NULL DEFAULT 0,
|
||||
updater BIGINT NOT NULL DEFAULT 0,
|
||||
task_no VARCHAR(30) NOT NULL,
|
||||
shop_id BIGINT NOT NULL,
|
||||
series_id BIGINT,
|
||||
operator_id BIGINT NOT NULL,
|
||||
total_count INT NOT NULL DEFAULT 0,
|
||||
success_count INT NOT NULL DEFAULT 0,
|
||||
fail_count INT NOT NULL DEFAULT 0,
|
||||
status INT NOT NULL DEFAULT 1,
|
||||
failed_items JSONB,
|
||||
started_at TIMESTAMPTZ,
|
||||
completed_at TIMESTAMPTZ
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX idx_device_batch_allocation_task_no ON tb_device_batch_allocation_task(task_no) WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
状态常量:1=处理中,2=已完成,3=失败。
|
||||
|
||||
失败明细存 JSONB(`failed_items`),失败条数有限,无需单独明细表。
|
||||
|
||||
---
|
||||
|
||||
## Model(`internal/model/device_batch_allocation_task.go`)
|
||||
|
||||
```go
|
||||
// DeviceBatchAllocationTask 设备批量分配任务模型
|
||||
type DeviceBatchAllocationTask struct {
|
||||
gorm.Model
|
||||
BaseModel `gorm:"embedded"`
|
||||
TaskNo string `gorm:"column:task_no;type:varchar(30);uniqueIndex:idx_device_batch_allocation_task_no,where:deleted_at IS NULL;not null" json:"task_no"`
|
||||
ShopID uint `gorm:"column:shop_id;not null;comment:分配目标代理店铺ID" json:"shop_id"`
|
||||
SeriesID *uint `gorm:"column:series_id;comment:套餐系列ID(可选)" json:"series_id,omitempty"`
|
||||
OperatorID uint `gorm:"column:operator_id;not null;comment:操作人ID" json:"operator_id"`
|
||||
TotalCount int `gorm:"column:total_count;default:0;comment:总记录数" json:"total_count"`
|
||||
SuccessCount int `gorm:"column:success_count;default:0;comment:成功数" json:"success_count"`
|
||||
FailCount int `gorm:"column:fail_count;default:0;comment:失败数" json:"fail_count"`
|
||||
Status int `gorm:"column:status;type:int;default:1;not null;comment:状态 1=处理中 2=已完成 3=失败" json:"status"`
|
||||
FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;comment:失败记录详情" json:"failed_items"`
|
||||
StartedAt *time.Time `gorm:"column:started_at" json:"started_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at" json:"completed_at"`
|
||||
}
|
||||
|
||||
func (DeviceBatchAllocationTask) TableName() string {
|
||||
return "tb_device_batch_allocation_task"
|
||||
}
|
||||
```
|
||||
|
||||
> `ImportResultItems` 复用 `internal/model/device_import_task.go` 中已定义的类型。
|
||||
|
||||
---
|
||||
|
||||
## API 设计
|
||||
|
||||
### 1. 下载 Excel 模板
|
||||
|
||||
```
|
||||
GET /admin/devices/batch-allocation/template
|
||||
```
|
||||
|
||||
响应:Excel 文件,表头为"设备号"(一列)。
|
||||
|
||||
### 2. 上传并提交
|
||||
|
||||
```
|
||||
POST /admin/devices/batch-allocation
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
字段:
|
||||
shop_id: 123 (必填,分配给哪个代理)
|
||||
series_id: 45 (可选,同时分配套餐系列)
|
||||
file: <Excel文件>
|
||||
```
|
||||
|
||||
**处理逻辑(Asynq 异步)**:
|
||||
|
||||
1. 解析 Excel,读取"设备号"列
|
||||
2. 逐行查找 `tb_device` 中匹配的设备(按 `virtual_no` 或 `imei`)
|
||||
3. 对找到的设备:更新 `shop_id` + `series_id`
|
||||
4. 未找到的:记录失败原因"设备号不存在"
|
||||
5. 已分配给其他代理的:记录"该设备已分配,请先回收"
|
||||
|
||||
### 3. 查询任务状态
|
||||
|
||||
```
|
||||
GET /admin/devices/batch-allocation/{task_id}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"task_no": "DBA20240101001",
|
||||
"status": 2,
|
||||
"status_name": "已完成",
|
||||
"total_count": 100,
|
||||
"success_count": 95,
|
||||
"fail_count": 5,
|
||||
"failed_items": [
|
||||
{ "line": 3, "virtual_no": "12345", "reason": "设备号不存在" },
|
||||
{ "line": 7, "virtual_no": "67890", "reason": "该设备已分配,请先回收" }
|
||||
],
|
||||
"started_at": "2024-01-01T10:00:00Z",
|
||||
"completed_at": "2024-01-01T10:00:05Z"
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端对接
|
||||
|
||||
### 入口
|
||||
|
||||
设备管理页 > 批量操作 > "批量分配代理"
|
||||
|
||||
### 操作流程
|
||||
|
||||
1. 点击"批量分配代理"
|
||||
2. 弹框内:
|
||||
- 代理选择器(搜索+选择目标代理,必填)
|
||||
- 套餐系列选择器(可选)
|
||||
- Excel 上传区域(含模板下载链接)
|
||||
3. 上传后点击"提交" → `POST /admin/devices/batch-allocation`
|
||||
4. 提示"任务提交成功,正在处理..."
|
||||
5. 轮询 `GET /admin/devices/batch-allocation/{task_id}` 直到 `status != 1`
|
||||
6. 展示结果:成功X条,失败X条,失败明细在页面展示
|
||||
|
||||
### Excel 规范
|
||||
|
||||
- 表头:`设备号`
|
||||
- 每行一个设备号(支持虚拟号或IMEI)
|
||||
120
docs/7月迭代/需求09-C端支付限制配置化.md
Normal file
120
docs/7月迭代/需求09-C端支付限制配置化.md
Normal file
@@ -0,0 +1,120 @@
|
||||
# 需求09:C端支付方式限制配置化
|
||||
|
||||
> 依赖:[系统配置](./基础设施/系统配置.md)
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
代码已经写好但被注释,注释原因是**微信支付参数未申请下来**,临时注释。
|
||||
|
||||
注释位置:
|
||||
- `internal/handler/app/client_wallet.go`(钱包充值入口)
|
||||
- `internal/service/client_order/service.go`(订单支付入口)
|
||||
|
||||
两处均有注释:`// 第三方支付方式与资产类型必须匹配:单卡只允许支付宝,设备只允许微信(已暂时注释)`
|
||||
|
||||
---
|
||||
|
||||
## 业务规则
|
||||
|
||||
| 资产类型 | 允许的第三方支付 | 禁止的第三方支付 |
|
||||
|---------|----------------|----------------|
|
||||
| IoT 卡 | 支付宝、钱包 | 微信 |
|
||||
| 设备 | 微信、钱包 | 支付宝 |
|
||||
|
||||
**钱包支付对所有资产类型均允许。**
|
||||
|
||||
---
|
||||
|
||||
## 实现方案
|
||||
|
||||
### 1. 系统配置初始化(已在系统配置文档中定义)
|
||||
|
||||
```go
|
||||
// tb_system_config 初始数据
|
||||
config_key: "c2b.payment.card_allowed_methods" → ["alipay","wallet"]
|
||||
config_key: "c2b.payment.device_allowed_methods" → ["wechat","wallet"]
|
||||
```
|
||||
|
||||
### 2. 恢复注释代码,改为读取配置
|
||||
|
||||
**文件**:`internal/service/client_order/service.go`
|
||||
|
||||
```go
|
||||
// validatePaymentMethod 校验资产类型与支付方式是否匹配
|
||||
func (s *Service) validatePaymentMethod(ctx context.Context, assetType string, paymentMethod string) error {
|
||||
// 钱包支付始终允许
|
||||
if paymentMethod == model.PaymentMethodWallet {
|
||||
return nil
|
||||
}
|
||||
|
||||
var configKey string
|
||||
switch assetType {
|
||||
case model.AssetTypeIotCard: // "iot_card",定义在 internal/model/asset_identifier.go
|
||||
configKey = "c2b.payment.card_allowed_methods"
|
||||
case model.AssetTypeDevice: // "device",定义在 internal/model/asset_identifier.go
|
||||
configKey = "c2b.payment.device_allowed_methods"
|
||||
default:
|
||||
return nil // 未知资产类型不限制
|
||||
}
|
||||
|
||||
allowedMethods, err := sysconfig.GetStringSlice(ctx, configKey)
|
||||
if err != nil || len(allowedMethods) == 0 {
|
||||
// 配置读取失败,降级为允许(防止配置问题影响支付)
|
||||
s.logger.Warn("读取支付方式配置失败,降级为允许", zap.String("config_key", configKey))
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, m := range allowedMethods {
|
||||
if m == paymentMethod {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return errors.New(errors.CodeForbidden, "该资产类型不支持此支付方式")
|
||||
}
|
||||
```
|
||||
|
||||
在 `client_wallet.go`(充值)和 `client_order/service.go`(订单支付)的对应位置恢复调用。
|
||||
|
||||
### 3. 错误信息
|
||||
|
||||
用户端错误提示(友好文案):
|
||||
|
||||
```go
|
||||
// 根据资产类型给出具体提示
|
||||
switch assetType {
|
||||
case model.AssetTypeIotCard:
|
||||
return errors.New(errors.CodeForbidden, "卡资产仅支持支付宝或余额支付")
|
||||
case model.AssetTypeDevice:
|
||||
return errors.New(errors.CodeForbidden, "设备仅支持微信或余额支付")
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端对接
|
||||
|
||||
### C端支付页面
|
||||
|
||||
前端在展示支付方式时,根据资产类型**过滤不可用的支付方式**(前端主动隐藏,后端兜底拦截)。
|
||||
|
||||
前端需要从哪里知道"当前资产是卡还是设备"?登录/初始化时返回 `asset_type`,前端保存上下文。
|
||||
|
||||
```
|
||||
资产类型 = "iot_card" → 展示支付方式:支付宝、余额
|
||||
资产类型 = "device" → 展示支付方式:微信、余额
|
||||
```
|
||||
|
||||
### 后台配置页面
|
||||
|
||||
在系统配置(系统设置 > 系统配置 > `c2b.payment` 模块)中,用 CheckboxGroup 展示:
|
||||
|
||||
```
|
||||
卡资产允许支付方式:☑ 支付宝 ☑ 余额 ☐ 微信
|
||||
设备允许支付方式: ☐ 支付宝 ☑ 余额 ☑ 微信
|
||||
```
|
||||
|
||||
修改后调用 `PUT /admin/system/config/c2b.payment.card_allowed_methods`。
|
||||
|
||||
> 注意:**微信支付参数申请下来后**,直接在系统配置里把对应资产类型勾上微信即可生效,无需改代码。
|
||||
194
docs/7月迭代/需求10-限速规则.md
Normal file
194
docs/7月迭代/需求10-限速规则.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# 需求10:限速规则
|
||||
|
||||
---
|
||||
|
||||
## 背景
|
||||
|
||||
Gateway 已有 `SetSpeedLimit` 接口,支持卡和设备:
|
||||
|
||||
```go
|
||||
// internal/gateway/device.go
|
||||
func (c *Client) SetSpeedLimit(ctx context.Context, req *SpeedLimitReq) error
|
||||
|
||||
// internal/gateway/models.go
|
||||
type SpeedLimitReq struct {
|
||||
CardNo string `json:"cardNo,omitempty"` // 卡(与 DeviceID 二选一)
|
||||
DeviceID string `json:"deviceId,omitempty"` // 设备(与 CardNo 二选一)
|
||||
SpeedLimit int `json:"speedLimit"` // 限速值(KB/s),0=不限速
|
||||
Extend string `json:"extend,omitempty"`
|
||||
}
|
||||
```
|
||||
|
||||
**"根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则"**
|
||||
|
||||
设计思路:限速规则配置在**套餐**上(不同运营商、不同流量档位对应不同套餐,套餐本身就是差异载体)。套餐激活时自动调用 Gateway 设置限速。
|
||||
|
||||
---
|
||||
|
||||
## 数据库变更
|
||||
|
||||
```sql
|
||||
-- 套餐表增加限速字段
|
||||
ALTER TABLE tb_package
|
||||
ADD COLUMN speed_limit_kbps INT NOT NULL DEFAULT 0
|
||||
COMMENT '限速值(KB/s),0=不限速';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model 变更
|
||||
|
||||
```go
|
||||
// internal/model/package.go
|
||||
type Package struct {
|
||||
// ...原有字段...
|
||||
SpeedLimitKbps int `gorm:"column:speed_limit_kbps;type:int;not null;default:0;comment:限速值(KB/s),0=不限速" json:"speed_limit_kbps"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后端实现
|
||||
|
||||
### 1. 套餐激活时设置限速
|
||||
|
||||
在 `internal/service/package/activation_service.go` 中,套餐激活(`ActivatedAt` 赋值)时调用限速:
|
||||
|
||||
```go
|
||||
// activatePackageUsage 套餐激活核心逻辑(已有函数,追加限速调用)
|
||||
func (s *ActivationService) activatePackageUsage(ctx context.Context, usage *model.PackageUsage, pkg *model.Package) error {
|
||||
// ...现有激活逻辑...
|
||||
|
||||
// 套餐有限速配置时,调用 Gateway 设置
|
||||
if pkg.SpeedLimitKbps > 0 {
|
||||
if err := s.applySpeedLimit(ctx, usage, pkg.SpeedLimitKbps); err != nil {
|
||||
// 限速失败不阻断套餐激活,记录错误日志
|
||||
s.logger.Error("设置限速失败", zap.Error(err),
|
||||
zap.Uint("package_usage_id", usage.ID),
|
||||
zap.Int("speed_limit", pkg.SpeedLimitKbps))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// applySpeedLimit 调用 Gateway 设置限速
|
||||
func (s *ActivationService) applySpeedLimit(ctx context.Context, usage *model.PackageUsage, speedKbps int) error {
|
||||
req := &gateway.SpeedLimitReq{SpeedLimit: speedKbps}
|
||||
|
||||
switch usage.UsageType {
|
||||
case constants.PackageUsageTypeSingleCard: // "single_card",定义在 pkg/constants/iot.go
|
||||
// 查卡的 ICCID
|
||||
card, err := s.iotCardStore.GetByID(ctx, usage.IotCardID)
|
||||
if err != nil { return err }
|
||||
req.CardNo = card.ICCID
|
||||
|
||||
case constants.PackageUsageTypeDevice: // "device",定义在 pkg/constants/iot.go
|
||||
// 查设备的 IMEI
|
||||
device, err := s.deviceStore.GetByID(ctx, usage.DeviceID)
|
||||
if err != nil { return err }
|
||||
req.DeviceID = device.IMEI
|
||||
}
|
||||
|
||||
return s.gatewayClient.SetSpeedLimit(ctx, req)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. 套餐到期/失效时取消限速
|
||||
|
||||
当套餐状态变为 `已过期(3)` 或 `已失效(4)` 时,重置限速为 0(不限速)。
|
||||
|
||||
在套餐过期处理任务中追加:
|
||||
|
||||
```go
|
||||
// 套餐过期时取消限速(SpeedLimit=0 表示不限速)
|
||||
if expiredPkg.SpeedLimitKbps > 0 {
|
||||
s.applySpeedLimit(ctx, usage, 0) // 0 = 不限速
|
||||
}
|
||||
```
|
||||
|
||||
> **注意**:如果有续费的排队套餐立即生效,应以新套餐的限速为准,而不是先取消再设置。
|
||||
|
||||
### 3. 手动操作限速(后台管理)
|
||||
|
||||
```
|
||||
POST /admin/iot-cards/{id}/speed-limit
|
||||
POST /admin/devices/{id}/speed-limit
|
||||
```
|
||||
|
||||
请求体:
|
||||
```go
|
||||
type SetSpeedLimitRequest struct {
|
||||
SpeedLimitKbps int `json:"speed_limit_kbps" validate:"min=0" description:"限速值(KB/s),0=取消限速"`
|
||||
}
|
||||
```
|
||||
|
||||
直接调 Gateway,不更新套餐配置(临时操作)。
|
||||
|
||||
---
|
||||
|
||||
## API 变更(套餐管理)
|
||||
|
||||
### 创建/更新套餐新增字段
|
||||
|
||||
```
|
||||
POST /admin/packages
|
||||
PUT /admin/packages/{id}
|
||||
```
|
||||
|
||||
新增参数:
|
||||
```go
|
||||
SpeedLimitKbps int `json:"speed_limit_kbps" validate:"min=0" description:"限速值(KB/s),0=不限速"`
|
||||
```
|
||||
|
||||
### 套餐列表/详情响应新增字段
|
||||
|
||||
```go
|
||||
type PackageResponse struct {
|
||||
// ...原有字段...
|
||||
SpeedLimitKbps int `json:"speed_limit_kbps" description:"限速值(KB/s),0=不限速"`
|
||||
SpeedLimitDesc string `json:"speed_limit_desc" description:"限速描述(如 '512 KB/s',0时为'不限速')"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端对接
|
||||
|
||||
### 页面:套餐创建/编辑
|
||||
|
||||
在"套餐配置"区域新增限速项:
|
||||
|
||||
```
|
||||
限速配置:
|
||||
限速值:[____] KB/s (0 或留空 = 不限速)
|
||||
提示文案:留空或填 0 表示不限速;建议根据运营商规则填写
|
||||
```
|
||||
|
||||
前端换算提示(可选):512 KB/s ≈ 4 Mbps
|
||||
|
||||
### 页面:套餐列表
|
||||
|
||||
新增"限速"列:
|
||||
- `0` → 显示"不限速"
|
||||
- `>0` → 显示"XXX KB/s"
|
||||
|
||||
### 页面:资产详情 > 操作区
|
||||
|
||||
新增"手动设置限速"按钮(仅当有生效套餐时显示):
|
||||
|
||||
```
|
||||
弹框:
|
||||
当前限速:xxx KB/s(来自套餐配置)
|
||||
临时设置:[____] KB/s [0=取消限速]
|
||||
|
||||
[确认设置] → POST /admin/iot-cards/{id}/speed-limit
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 注意事项
|
||||
|
||||
1. **Gateway 调用失败不阻断业务**:限速设置失败只记录日志,套餐仍然激活。
|
||||
2. **限速精度**:Gateway `SpeedLimit` 单位是 KB/s,最小值为 1;填 0 表示不限速。
|
||||
3. **运营商差异**:不同运营商通过创建不同套餐来体现限速差异,不需要在套餐外额外维护运营商-限速映射表。
|
||||
4. **续费场景**:新套餐生效时重新设置限速,以新套餐的限速值为准。
|
||||
247
docs/7月迭代/需求14-导出功能.md
Normal file
247
docs/7月迭代/需求14-导出功能.md
Normal file
@@ -0,0 +1,247 @@
|
||||
# 需求14:导出功能
|
||||
|
||||
> 复用现有 ExportTask 体系(`tb_export_task` + Asynq)
|
||||
|
||||
---
|
||||
|
||||
## 导出模块总览
|
||||
|
||||
| 编号 | 模块 | 新增/修改 |
|
||||
|------|------|---------|
|
||||
| EXPD-001~003 | IoT卡导出 | 新增套餐名称、使用流量、剩余流量字段 |
|
||||
| 6.8.2 | 代理资金概况-预充值钱包流水导出 | **全新** |
|
||||
| 6.8.3 | 套餐列表导出 | **全新** |
|
||||
| 6.8.4 | 退款管理退款列表导出 | **全新** |
|
||||
| 6.8.5 | 换货管理导出 | **全新** |
|
||||
| 6.8.6 | 代理充值导出 | **全新**(去掉"支付通道"字段) |
|
||||
|
||||
---
|
||||
|
||||
## 现有导出体系说明
|
||||
|
||||
系统已有异步导出框架(`internal/exporter/`):
|
||||
- `tb_export_task` 表记录导出任务
|
||||
- 导出逻辑通过 `DataSource` 接口实现,每个场景一个文件(如 `iot_card_scene.go`)
|
||||
- `registry.go` 的 `NewDefaultRegistry()` 统一注册所有场景
|
||||
- Asynq Worker 根据任务里的 `scene` 字段,从 Registry 取对应 DataSource 执行
|
||||
- 前端轮询任务状态后下载
|
||||
|
||||
新增导出模块需要:
|
||||
1. 在 `pkg/constants/constants.go` 新增 `ExportTaskSceneXxx` 场景常量
|
||||
2. 在 `internal/exporter/` 新建 `xxx_scene.go`,实现 `DataSource` 接口(`Scene()`/`Count()`/`Headers()`/`Fetch()`)
|
||||
3. 在 `registry.go` 的 `NewDefaultRegistry()` 中注册,并更新 `IsSupportedScene()`
|
||||
4. 新增对应的 Export API(创建导出任务,传入 `scene` 字段)
|
||||
|
||||
---
|
||||
|
||||
## EXPD-001~003:IoT卡导出字段新增
|
||||
|
||||
**修改文件**:`internal/exporter/iot_card_scene.go`
|
||||
|
||||
在 `Headers()` 末尾追加三列,`Fetch()` 的 `Select` 追加字段,`iotCardExportRow` 追加字段:
|
||||
|
||||
```go
|
||||
// Headers() 新增
|
||||
"套餐名称", "使用流量(MB)", "剩余流量(MB)"
|
||||
|
||||
// baseQuery() 或 Fetch() 新增 JOIN
|
||||
LEFT JOIN LATERAL (
|
||||
SELECT pu.package_id, pu.data_usage_mb, pu.data_limit_mb, p.package_name
|
||||
FROM tb_package_usage pu
|
||||
JOIN tb_package p ON p.id = pu.package_id AND p.deleted_at IS NULL
|
||||
WHERE pu.iot_card_id = c.id AND pu.status = 1
|
||||
AND pu.master_usage_id IS NULL AND pu.deleted_at IS NULL
|
||||
LIMIT 1
|
||||
) AS pkg ON TRUE
|
||||
|
||||
// iotCardExportRow 新增
|
||||
PackageName string `gorm:"column:package_name"`
|
||||
DataUsageMB int64 `gorm:"column:data_usage_mb"`
|
||||
DataLimitMB int64 `gorm:"column:data_limit_mb"`
|
||||
```
|
||||
|
||||
剩余流量 = `DataLimitMB - DataUsageMB`(在行转换时计算)
|
||||
|
||||
---
|
||||
|
||||
## 6.8.2:代理资金概况-预充值钱包流水导出
|
||||
|
||||
**新增接口**:`POST /admin/agent-wallet-transactions/export`
|
||||
|
||||
支持与现有钱包流水列表相同的筛选条件,异步生成 Excel。
|
||||
|
||||
导出字段映射:
|
||||
|
||||
| 字段 | 数据来源 |
|
||||
|------|---------|
|
||||
| 店铺名称 | JOIN `tb_shop` |
|
||||
| 交易类型 | `transaction_type`(充值/扣款/退款等,中文化) |
|
||||
| 交易金额 | `amount / 100` 转元 |
|
||||
| 状态 | `status` 中文化 |
|
||||
| 资产类型 | `asset_type` 中文化 |
|
||||
| 资产标识 | `asset_identifier` |
|
||||
| 交易时间 | `created_at` |
|
||||
| 交易前金额 | `balance_before / 100` |
|
||||
| 交易后金额 | `balance_after / 100` |
|
||||
| 购买套餐名称 | `metadata` 中 JSON 字段 `package_name`,或 JOIN 订单 |
|
||||
| 操作人 | JOIN `tb_account`(`creator` 字段关联 `tb_account.id`,取 `username`) |
|
||||
| 交易 ID | `id` |
|
||||
| 关联业务订单号 | `reference_id` 对应的单号(JOIN 对应表) |
|
||||
| 交易渠道/支付方式 | `metadata` 中 `payment_method` 字段 |
|
||||
|
||||
---
|
||||
|
||||
## 6.8.3:套餐列表导出
|
||||
|
||||
**新增接口**:`POST /admin/packages/export`
|
||||
|
||||
支持现有套餐列表筛选条件。
|
||||
|
||||
导出字段(按需求文档 24 个字段):
|
||||
|
||||
```go
|
||||
type PackageExportRow struct {
|
||||
PackageCode string `xlsx:"套餐编码"`
|
||||
PackageName string `xlsx:"套餐名称"`
|
||||
SeriesName string `xlsx:"套餐系列名称"` // JOIN tb_package_series
|
||||
PackageType string `xlsx:"套餐类型"` // formal/addon 中文化
|
||||
DurationMonths int `xlsx:"套餐时长(月)"`
|
||||
DurationDaysDesc string `xlsx:"套餐时长说明"` // 剩余天数说明
|
||||
CalendarType string `xlsx:"套餐周期类型"`
|
||||
DurationDays int `xlsx:"套餐天数"`
|
||||
RealDataMB int64 `xlsx:"真流量额度(MB)"`
|
||||
VirtualDataMB int64 `xlsx:"虚流量额度(MB)"`
|
||||
EnableVirtualData string `xlsx:"是否启用虚流量"` // 是/否
|
||||
VirtualRatio float64 `xlsx:"虚流量比例"`
|
||||
DataResetCycle string `xlsx:"流量重置周期"`
|
||||
ExpiryBase string `xlsx:"到期时间基准"`
|
||||
CostPrice string `xlsx:"成本价(元)"` // 分→元
|
||||
SuggestedRetailPrice string `xlsx:"建议售价(元)"`
|
||||
PriceConfigStatus string `xlsx:"价格配置状态"`
|
||||
Status string `xlsx:"状态"`
|
||||
ShelfStatus string `xlsx:"上架状态"`
|
||||
IsGift string `xlsx:"是否赠送套餐"`
|
||||
CreatorID uint `xlsx:"创建人ID"`
|
||||
UpdaterID uint `xlsx:"更新人ID"`
|
||||
CreatedAt string `xlsx:"创建时间"`
|
||||
UpdatedAt string `xlsx:"更新时间"`
|
||||
DeletedAt string `xlsx:"删除时间"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6.8.4:退款管理退款列表导出
|
||||
|
||||
**新增接口**:`POST /admin/refund-orders/export`
|
||||
|
||||
导出字段(去掉"退款到账方式",保留其余字段,部门领导/财务审批人依赖审批流):
|
||||
|
||||
```go
|
||||
type RefundExportRow struct {
|
||||
RefundNo string `xlsx:"退款单号"`
|
||||
ShopName string `xlsx:"代理店铺名称"`
|
||||
PaymentOrderNo string `xlsx:"关联的支付订单号"`
|
||||
AssetType string `xlsx:"资产类型"`
|
||||
AssetIdentifier string `xlsx:"资产标识"`
|
||||
PackageName string `xlsx:"套餐名称"`
|
||||
OriginalAmount string `xlsx:"原订单金额"`
|
||||
ActualAmount string `xlsx:"实收金额"`
|
||||
RefundableAmount string `xlsx:"可退金额"`
|
||||
AppliedAmount string `xlsx:"申请退款金额"`
|
||||
ActualRefundAmount string `xlsx:"实际退款金额"`
|
||||
Status string `xlsx:"状态"`
|
||||
RefundReason string `xlsx:"退款原因"`
|
||||
Remark string `xlsx:"备注"`
|
||||
ApprovalRemark string `xlsx:"审批备注"`
|
||||
AppliedAt string `xlsx:"退款申请时间"`
|
||||
CompletedAt string `xlsx:"退款完成时间"`
|
||||
SubmitterName string `xlsx:"提交人"`
|
||||
DeptLeaderName string `xlsx:"部门领导审批人"`
|
||||
FinanceName string `xlsx:"财务审批人"`
|
||||
VoucherURLs string `xlsx:"退款凭证"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6.8.5:换货管理导出
|
||||
|
||||
**新增接口**:`POST /admin/exchange-orders/export`
|
||||
|
||||
```go
|
||||
type ExchangeExportRow struct {
|
||||
ExchangeNo string `xlsx:"换货单号"`
|
||||
ExchangeType string `xlsx:"换货类型"`
|
||||
ExchangeReason string `xlsx:"换货原因"`
|
||||
ProblemDesc string `xlsx:"问题描述"`
|
||||
OldAssetType string `xlsx:"旧资产类型"`
|
||||
OldAssetIdentifier string `xlsx:"旧资产标识符"`
|
||||
NewAssetIdentifier string `xlsx:"新资产标识符"`
|
||||
ReceiverName string `xlsx:"收货人姓名"`
|
||||
ReceiverPhone string `xlsx:"收货人电话"`
|
||||
ReceiverAddress string `xlsx:"收货地址"`
|
||||
ExpressCompany string `xlsx:"快递公司"`
|
||||
TrackingNo string `xlsx:"快递单号"`
|
||||
Status string `xlsx:"状态"`
|
||||
CreatorName string `xlsx:"创建人"`
|
||||
CreatedAt string `xlsx:"创建时间"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6.8.6:代理充值导出
|
||||
|
||||
**新增接口**:`POST /admin/agent-recharge-orders/export`
|
||||
|
||||
去掉"支付通道"字段(需求文档中明确去掉),保留其他字段:
|
||||
|
||||
```go
|
||||
type AgentRechargeExportRow struct {
|
||||
RechargeNo string `xlsx:"充值单号"`
|
||||
ShopName string `xlsx:"店铺名称"`
|
||||
RechargeType string `xlsx:"充值类型"`
|
||||
RechargeAmount string `xlsx:"充值金额"`
|
||||
ActualAmount string `xlsx:"实付金额"`
|
||||
BalanceBefore string `xlsx:"充值前余额"`
|
||||
BalanceAfter string `xlsx:"充值后余额"`
|
||||
Status string `xlsx:"状态"`
|
||||
PaymentMethod string `xlsx:"支付方式"`
|
||||
// 去掉支付通道
|
||||
OperationRemark string `xlsx:"运营备注"`
|
||||
RejectReason string `xlsx:"驳回原因"`
|
||||
CreatedAt string `xlsx:"创建时间"`
|
||||
PaidAt string `xlsx:"支付时间"`
|
||||
CompletedAt string `xlsx:"完成时间"`
|
||||
SubmitterName string `xlsx:"提交人"`
|
||||
DeptLeaderName string `xlsx:"部门领导审批人"`
|
||||
FinanceName string `xlsx:"财务审批人"`
|
||||
VoucherURLs string `xlsx:"支付凭证"`
|
||||
Remark string `xlsx:"备注"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端对接(通用模式)
|
||||
|
||||
各导出入口:对应列表页右上角"导出"按钮(与现有导出按钮样式一致)。
|
||||
|
||||
调用流程:
|
||||
1. 点击"导出" → 携带当前筛选条件 → `POST /admin/{module}/export`
|
||||
2. 返回 `export_task_id`
|
||||
3. 前端轮询 `GET /admin/export-tasks/{id}` 直到 `status=completed`
|
||||
4. 下载 `download_url`
|
||||
|
||||
(与现有导出体系完全一致,复用现有前端导出 Hook)
|
||||
|
||||
---
|
||||
|
||||
## 权限导出配置(预留)
|
||||
|
||||
需求提到"不同权限显示的字段不同,角色管理中新增导出字段配置"。
|
||||
|
||||
**本次迭代**:统一导出全量字段,不做权限差异化(复杂度高,单独排期)。
|
||||
|
||||
**预留方案**:在导出 Handler 里预留 `filterFieldsByRole(userRoles, rows)` 的调用点,本次返回全量,后续加权限配置后在此处过滤。
|
||||
348
docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md
Normal file
348
docs/7月迭代/需求15-16-18-19-20-21-复杂需求.md
Normal file
@@ -0,0 +1,348 @@
|
||||
# 需求15/16/18/19/20/21 技术方案
|
||||
|
||||
---
|
||||
|
||||
## 需求15:套餐下架后允许续费
|
||||
|
||||
### 业务规则
|
||||
|
||||
- 下架套餐(`shelf_status=2`)不可被**新购**
|
||||
- 下架套餐**可以续费**(已在使用该套餐的客户)
|
||||
- 续费仅支持**客户自己购买**(不允许代理代购下架套餐给新客户)
|
||||
|
||||
### 后端
|
||||
|
||||
**当前逻辑**:下架套餐在购买时被拦截。
|
||||
|
||||
修改:在订单创建校验中,区分"新购"和"续费"场景:
|
||||
|
||||
```go
|
||||
// internal/service/order/service.go 或 client_order/service.go
|
||||
func (s *Service) validatePackageAvailability(
|
||||
ctx context.Context,
|
||||
pkg *model.Package,
|
||||
assetID uint,
|
||||
isRenewal bool,
|
||||
) error {
|
||||
if pkg.Status == constants.StatusDisabled { // 0=禁用,定义在 pkg/constants/constants.go
|
||||
return errors.New(errors.CodeForbidden, "套餐已禁用")
|
||||
}
|
||||
if pkg.ShelfStatus == constants.ShelfStatusOff && !isRenewal { // 2=下架
|
||||
return errors.New(errors.CodeForbidden, "套餐已下架,不可新购")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
**isRenewal 判断**:查当前资产是否有该套餐的历史生效记录:
|
||||
|
||||
```go
|
||||
func (s *Service) isRenewal(ctx context.Context, assetType string, assetID uint, packageID uint) bool {
|
||||
count, _ := s.packageUsageStore.CountByAssetAndPackage(ctx, assetType, assetID, packageID)
|
||||
return count > 0
|
||||
}
|
||||
```
|
||||
|
||||
### 前端
|
||||
|
||||
C端续费页面:下架套餐不在"新购"列表中展示,但在"续费"入口中仍可展示。
|
||||
|
||||
后台代购时:下架套餐的"代购"按钮禁用,tooltip 提示"套餐已下架,不可代购"。
|
||||
|
||||
---
|
||||
|
||||
## 需求16:代理分销码与佣金提现
|
||||
|
||||
### 业务规则
|
||||
|
||||
**DST-001**:新建代理时自动建立分销归属关系(指定发展人)
|
||||
**DST-002**:员工可作为代理发展人进行标识
|
||||
**二维码**:代理或员工生成推广二维码 → 扫码进入H5填写信息 → 提交成为代理申请 → 平台审批
|
||||
|
||||
### 数据库变更
|
||||
|
||||
```sql
|
||||
-- 1. Shop 表新增发展人字段
|
||||
ALTER TABLE tb_shop
|
||||
ADD COLUMN referrer_type VARCHAR(20) DEFAULT NULL
|
||||
COMMENT '发展人类型 shop=代理介绍 admin=员工介绍',
|
||||
ADD COLUMN referrer_id BIGINT DEFAULT NULL
|
||||
COMMENT '发展人ID(shop_id 或 tb_account.id)',
|
||||
ADD COLUMN referrer_name VARCHAR(50) DEFAULT NULL
|
||||
COMMENT '发展人姓名快照';
|
||||
|
||||
-- 2. 分销码表
|
||||
CREATE TABLE tb_distribution_code (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
creator BIGINT NOT NULL DEFAULT 0,
|
||||
updater BIGINT NOT NULL DEFAULT 0,
|
||||
code VARCHAR(32) NOT NULL,
|
||||
owner_type VARCHAR(20) NOT NULL, -- shop=代理 | admin=员工
|
||||
owner_id BIGINT NOT NULL,
|
||||
owner_name VARCHAR(50) NOT NULL DEFAULT '',
|
||||
qr_code_url TEXT,
|
||||
use_count INT NOT NULL DEFAULT 0,
|
||||
status INT NOT NULL DEFAULT 1, -- 1=有效 0=禁用
|
||||
expires_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_distribution_code ON tb_distribution_code(code) WHERE deleted_at IS NULL;
|
||||
|
||||
-- 3. 代理申请表(H5扫码提交)
|
||||
CREATE TABLE tb_agent_application (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
creator BIGINT NOT NULL DEFAULT 0,
|
||||
updater BIGINT NOT NULL DEFAULT 0,
|
||||
apply_no VARCHAR(30) NOT NULL,
|
||||
distribution_code VARCHAR(32) NOT NULL,
|
||||
referrer_type VARCHAR(20) NOT NULL,
|
||||
referrer_id BIGINT NOT NULL,
|
||||
applicant_name VARCHAR(50) NOT NULL,
|
||||
applicant_phone VARCHAR(20) NOT NULL,
|
||||
shop_name VARCHAR(100) NOT NULL,
|
||||
province VARCHAR(50),
|
||||
city VARCHAR(50),
|
||||
district VARCHAR(50),
|
||||
address VARCHAR(255),
|
||||
status INT NOT NULL DEFAULT 1, -- 1=待审批 2=已通过 3=已拒绝
|
||||
reject_reason TEXT,
|
||||
reviewed_by BIGINT,
|
||||
reviewed_at TIMESTAMPTZ
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_agent_application_no ON tb_agent_application(apply_no) WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
### API 设计
|
||||
|
||||
**生成分销码**:
|
||||
```
|
||||
POST /admin/distribution-codes
|
||||
body: { owner_type: "admin"|"shop", owner_id: 123 }
|
||||
```
|
||||
|
||||
**H5 扫码获取分销码信息**:
|
||||
```
|
||||
GET /app/distribution-codes/{code}
|
||||
```
|
||||
|
||||
**H5 提交代理申请**:
|
||||
```
|
||||
POST /app/agent-applications
|
||||
body: { distribution_code, applicant_name, applicant_phone, shop_name, ... }
|
||||
```
|
||||
|
||||
**后台代理申请列表/审批**:
|
||||
```
|
||||
GET /admin/agent-applications?status=1
|
||||
POST /admin/agent-applications/{id}/approve
|
||||
POST /admin/agent-applications/{id}/reject
|
||||
body: { reject_reason: "..." }
|
||||
```
|
||||
|
||||
审批通过时:自动创建 `tb_shop` + `tb_account`,设置 `referrer_type` 和 `referrer_id`。
|
||||
|
||||
**佣金提现(DST-003~007)**:
|
||||
|
||||
现有 commission 框架基础上新增材料上传字段,提现申请时一次性上传所有材料(对象存储 Key 列表)。
|
||||
|
||||
---
|
||||
|
||||
## 需求18:多人审批(APR-001~009)
|
||||
|
||||
### 依赖
|
||||
|
||||
基于 [审批流基础设施](./基础设施/审批流.md) 和 [站内消息](./基础设施/站内消息.md)。
|
||||
|
||||
APR-009(企微审批对接)= Phase 2。
|
||||
|
||||
### 实现要点
|
||||
|
||||
| 编号 | 需求 | 实现 |
|
||||
|------|------|------|
|
||||
| APR-001~003 | 充值/退款多级审核 | 见需求20/21 |
|
||||
| APR-004 | 审核环节:部门领导→财务 | 审批流固定两步:step=1 部门领导,step=2 财务 |
|
||||
| APR-005 | 待审核有消息提示 | 站内消息 `NotifyTypeApprovalPending` |
|
||||
| APR-006 | 上一级完成后才提示下一级 | `ApprovalFlowAggregate.Approve()` 触发 `ApprovalStepAdvancedEvent` |
|
||||
| APR-007 | 通过后通知申请人 | `ApprovalCompletedEvent` handler |
|
||||
| APR-008 | 驳回/退回后通知申请人含原因 | `ApprovalRejectedEvent` / `ApprovalReturnedEvent` handler |
|
||||
| APR-009 | 对接企微 | **Phase 2** |
|
||||
|
||||
### 业务表与审批流的关联
|
||||
|
||||
充值单、退款单接入审批流,各自新增 `approval_flow_id` 字段:
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_refund_request ADD COLUMN approval_flow_id BIGINT DEFAULT NULL
|
||||
COMMENT '当前审批流ID(已退回后重新提交会更新为新流程ID)';
|
||||
|
||||
ALTER TABLE tb_agent_recharge_record ADD COLUMN approval_flow_id BIGINT DEFAULT NULL
|
||||
COMMENT '当前审批流ID(已退回后重新提交会更新为新流程ID)';
|
||||
```
|
||||
|
||||
接入协议详见 [审批流文档 - 接入协议](./基础设施/审批流.md#六接入协议业务层如何接入审批流)。
|
||||
|
||||
---
|
||||
|
||||
## 需求19:批量订购套餐(BPO-001~008)
|
||||
|
||||
### 业务流程
|
||||
|
||||
1. 员工进入批量订购页面
|
||||
2. 选择代理 + 上传 Excel(字段:资产类型/资产标识/套餐编码/套餐名称/支付方式)
|
||||
3. 系统解析并校验每一行
|
||||
4. 支付方式:`offline=线下支付(默认成功)` / `agent_wallet=代理钱包支付`
|
||||
5. 代理钱包支付时从代理余额扣款(含信用额度)
|
||||
6. 线下支付时标记为已支付(不走钱包)
|
||||
7. 校验失败的行展示在页面,带失败原因
|
||||
8. **线下支付需上传整批次的支付凭证**
|
||||
|
||||
### 数据库变更
|
||||
|
||||
```sql
|
||||
CREATE TABLE tb_bulk_purchase_task (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
deleted_at TIMESTAMPTZ,
|
||||
creator BIGINT NOT NULL DEFAULT 0,
|
||||
updater BIGINT NOT NULL DEFAULT 0,
|
||||
task_no VARCHAR(30) NOT NULL,
|
||||
shop_id BIGINT NOT NULL,
|
||||
operator_id BIGINT NOT NULL,
|
||||
payment_method VARCHAR(20) NOT NULL, -- offline | agent_wallet
|
||||
voucher_keys JSONB, -- 线下支付凭证列表
|
||||
total_count INT NOT NULL DEFAULT 0,
|
||||
success_count INT NOT NULL DEFAULT 0,
|
||||
fail_count INT NOT NULL DEFAULT 0,
|
||||
status INT NOT NULL DEFAULT 1, -- 1=处理中 2=已完成
|
||||
failed_items JSONB -- 失败明细
|
||||
);
|
||||
CREATE UNIQUE INDEX idx_bulk_purchase_task_no ON tb_bulk_purchase_task(task_no) WHERE deleted_at IS NULL;
|
||||
```
|
||||
|
||||
> 失败明细存 JSONB(`failed_items`),与现有 DeviceImportTask 模式一致,无需单独明细表。
|
||||
|
||||
### API 设计
|
||||
|
||||
**下载 Excel 模板**:
|
||||
```
|
||||
GET /admin/bulk-purchases/template
|
||||
```
|
||||
|
||||
**上传并提交**:
|
||||
```
|
||||
POST /admin/bulk-purchases
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
字段:
|
||||
shop_id: 123
|
||||
payment_method: offline | agent_wallet
|
||||
voucher_keys: ["key1","key2"] (offline 时必填)
|
||||
file: <Excel文件>
|
||||
```
|
||||
|
||||
**查询任务状态**:
|
||||
```
|
||||
GET /admin/bulk-purchases/{task_id}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 需求20:退款审批
|
||||
|
||||
### 依赖
|
||||
|
||||
基于 [审批流基础设施](./基础设施/审批流.md)。
|
||||
|
||||
### 退款单现有状态(不变)
|
||||
|
||||
```
|
||||
1=待审批 2=已通过 3=已拒绝 4=已退回
|
||||
```
|
||||
|
||||
退款单本身的状态含义不变,审批进度由 `tb_approval_flow` 管理,两者通过 `approval_flow_id` 关联。
|
||||
|
||||
### 流程
|
||||
|
||||
```
|
||||
提交退款申请(POST /admin/refund-requests)
|
||||
→ 创建 RefundRequest(status=1 待审批)
|
||||
→ 调 SubmitApprovalUseCase 创建 ApprovalFlow(biz_type=refund)
|
||||
→ 把 approval_flow_id 写入 RefundRequest
|
||||
|
||||
审批人操作(POST /admin/approvals/{flow_id}/approve|reject|return)
|
||||
→ ApprovalCompletedEvent → RefundRequest.status=2(已通过)→ 触发实际退款
|
||||
→ ApprovalRejectedEvent → RefundRequest.status=3(已拒绝)
|
||||
→ ApprovalReturnedEvent → RefundRequest.status=4(已退回)→ 通知提交人可修改
|
||||
```
|
||||
|
||||
### 退回后重新提交
|
||||
|
||||
```
|
||||
PUT /admin/refund-requests/{id} 仅 status=4 时可编辑退款单内容
|
||||
POST /admin/refund-requests/{id}/resubmit
|
||||
→ 校验 status=4
|
||||
→ 新建 ApprovalFlow
|
||||
→ 更新 approval_flow_id,status 回到 1(待审批)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 需求21:充值审核流程
|
||||
|
||||
### 充值单现有状态
|
||||
|
||||
```
|
||||
tb_agent_recharge_record:1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款
|
||||
```
|
||||
|
||||
员工线下充值走审批流,需在现有状态基础上**新增"已退回"状态**:
|
||||
|
||||
```sql
|
||||
-- status 说明更新(不改原有值语义,追加新状态)
|
||||
-- 6=已退回(审批人退回给提交人修改)
|
||||
```
|
||||
|
||||
充值业务的状态语义:
|
||||
- `1=待支付`:创建未支付(线下充值等待审批时也停在这里,由 approval_flow_id 判断是否在审批中)
|
||||
- `2=已支付`:支付成功(线下充值审批通过后跳到已完成,不经过此状态)
|
||||
- `3=已完成`:充值到账
|
||||
- `4=已关闭`:取消/超时
|
||||
- `5=已退款`:退款
|
||||
- `6=已退回`:审批人退回给提交人修改
|
||||
|
||||
### 流程
|
||||
|
||||
**代理自行充值(不走审批)**:
|
||||
|
||||
```
|
||||
代理提交充值申请 → 系统生成收款码 → 代理扫码支付 → 回调自动充值到钱包
|
||||
```
|
||||
|
||||
**员工线下代充值(走审批)**:
|
||||
|
||||
```
|
||||
POST /admin/agent-recharge-records(payment_method=offline)
|
||||
→ 创建 AgentRechargeRecord(status=1)
|
||||
→ 调 SubmitApprovalUseCase 创建 ApprovalFlow(biz_type=recharge)
|
||||
→ 把 approval_flow_id 写入记录
|
||||
|
||||
审批通过 → AgentRechargeRecord.status=3(已完成)→ 实际充值到钱包
|
||||
审批驳回 → AgentRechargeRecord.status=4(已关闭)
|
||||
审批退回 → AgentRechargeRecord.status=6(已退回)→ 通知提交人可修改
|
||||
```
|
||||
|
||||
### 退回后重新提交
|
||||
|
||||
```
|
||||
PUT /admin/agent-recharge-records/{id} 仅 status=6 时可编辑
|
||||
POST /admin/agent-recharge-records/{id}/resubmit
|
||||
→ 校验 status=6
|
||||
→ 新建 ApprovalFlow
|
||||
→ 更新 approval_flow_id,status 回到 1(待支付/待审批)
|
||||
```
|
||||
264
docs/7月迭代/需求17-信用额度.md
Normal file
264
docs/7月迭代/需求17-信用额度.md
Normal file
@@ -0,0 +1,264 @@
|
||||
# 需求17:信用额度
|
||||
|
||||
> DDD 结构:`internal/domain/wallet/`
|
||||
|
||||
---
|
||||
|
||||
## 业务规则
|
||||
|
||||
### 两类主体
|
||||
|
||||
| 主体 | 信用额度来源 | 修改条件 |
|
||||
|------|------------|---------|
|
||||
| 代理(店铺) | 创建店铺时设置 `credit_limit` | 修改前必须先清零欠款(`balance >= 0`)|
|
||||
| 平台员工 | 基于**角色**,不是账号 | 角色权限表里配置 |
|
||||
|
||||
### 可用余额公式
|
||||
|
||||
```
|
||||
可用额度 = balance + credit_limit - frozen_balance
|
||||
```
|
||||
|
||||
- `balance` 可以为负(表示欠款)
|
||||
- `balance < 0` 时表示已在使用信用额度
|
||||
- `balance < -credit_limit` 表示超额使用(理论上不可达,系统保护)
|
||||
|
||||
### BPO-009 "是否可授权额度"开关
|
||||
|
||||
- 代理创建时,新增 `enable_credit` 开关
|
||||
- 开关关闭 = `credit_limit` 强制为 0,不允许使用信用额度
|
||||
- 平台员工账号通过角色自动拥有额度,无需开关
|
||||
|
||||
### BPO-012 负余额显示
|
||||
|
||||
- 代理前台(AgentWallet):`balance` 正常展示(可为负)
|
||||
- 前端不做"不能为负"的前端校验,由后端控制
|
||||
|
||||
---
|
||||
|
||||
## 数据库变更
|
||||
|
||||
### 1. Shop 表加信用额度字段
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_shop
|
||||
ADD COLUMN enable_credit BOOLEAN NOT NULL DEFAULT FALSE
|
||||
COMMENT '是否可使用信用额度',
|
||||
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0
|
||||
COMMENT '授信额度上限(分)';
|
||||
```
|
||||
|
||||
### 2. AgentWallet 表加信用额度字段
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_agent_wallet
|
||||
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0
|
||||
COMMENT '信用额度(分),与 shop.credit_limit 同步,余额可到 -credit_limit';
|
||||
```
|
||||
|
||||
> 钱包上冗余一份,是为了避免每次扣款都要 join shop 表。通过 Shop 修改额度时同步更新钱包。
|
||||
|
||||
### 3. 角色信用额度配置(平台员工)
|
||||
|
||||
```sql
|
||||
ALTER TABLE tb_role
|
||||
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0
|
||||
COMMENT '该角色的信用额度(分),仅对平台员工角色有效';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Model 变更
|
||||
|
||||
```go
|
||||
// internal/model/shop.go
|
||||
type Shop struct {
|
||||
// ...原有字段...
|
||||
EnableCredit bool `gorm:"column:enable_credit;not null;default:false;comment:是否可使用信用额度" json:"enable_credit"`
|
||||
CreditLimit int64 `gorm:"column:credit_limit;type:bigint;not null;default:0;comment:授信额度上限(分)" json:"credit_limit"`
|
||||
}
|
||||
|
||||
// internal/model/agent_wallet.go
|
||||
type AgentWallet struct {
|
||||
// ...原有字段(Balance, FrozenBalance 已存在)...
|
||||
CreditLimit int64 `gorm:"column:credit_limit;type:bigint;not null;default:0;comment:信用额度(分)" json:"credit_limit"`
|
||||
}
|
||||
|
||||
// GetAvailableBalance 更新:可用余额 = 余额 - 冻结 + 信用额度
|
||||
func (w *AgentWallet) GetAvailableBalance() int64 {
|
||||
return w.Balance - w.FrozenBalance + w.CreditLimit
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 领域层(DDD)
|
||||
|
||||
```go
|
||||
// internal/domain/wallet/wallet.go
|
||||
|
||||
// AgentWallet 钱包聚合根(扩展现有模型)
|
||||
type AgentWalletAggregate struct {
|
||||
model.AgentWallet
|
||||
domainEvents []DomainEvent `gorm:"-"`
|
||||
}
|
||||
|
||||
// Debit 扣款(含信用额度)
|
||||
func (w *AgentWalletAggregate) Debit(amountCents int64, refType string, refID uint) error {
|
||||
available := w.Balance - w.FrozenBalance + w.CreditLimit
|
||||
if available < amountCents {
|
||||
return ErrInsufficientBalance
|
||||
}
|
||||
before := w.Balance
|
||||
w.Balance -= amountCents
|
||||
w.recordEvent(WalletDebitedEvent{
|
||||
WalletID: w.ID,
|
||||
ShopID: w.ShopID,
|
||||
Amount: amountCents,
|
||||
BalanceBefore: before,
|
||||
BalanceAfter: w.Balance,
|
||||
RefType: refType,
|
||||
RefID: refID,
|
||||
})
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 应用层
|
||||
|
||||
```go
|
||||
// internal/application/wallet/grant_credit.go
|
||||
|
||||
// GrantCreditCommand 授信命令
|
||||
type GrantCreditCommand struct {
|
||||
ShopID uint
|
||||
CreditLimit int64 // 分
|
||||
OperatorID uint
|
||||
}
|
||||
|
||||
// GrantCreditUseCase 设置代理信用额度
|
||||
type GrantCreditUseCase struct {
|
||||
shopStore ShopStore
|
||||
walletStore WalletStore
|
||||
txManager TxManager
|
||||
}
|
||||
|
||||
func (uc *GrantCreditUseCase) Execute(ctx context.Context, cmd GrantCreditCommand) error {
|
||||
return uc.txManager.RunInTx(ctx, func(tx *gorm.DB) error {
|
||||
shop, err := uc.shopStore.GetByID(ctx, cmd.ShopID)
|
||||
if err != nil { return err }
|
||||
|
||||
// 修改信用额度前,必须当前余额 >= 0(无欠款)
|
||||
wallet, err := uc.walletStore.GetMainWallet(ctx, cmd.ShopID)
|
||||
if err != nil { return err }
|
||||
if wallet.Balance < 0 {
|
||||
return errors.New(errors.CodeForbidden, "存在欠款,请先清零后再修改信用额度")
|
||||
}
|
||||
|
||||
// 同步更新 shop 和 wallet
|
||||
if err := uc.shopStore.UpdateCreditLimit(ctx, tx, cmd.ShopID, cmd.CreditLimit); err != nil {
|
||||
return err
|
||||
}
|
||||
return uc.walletStore.UpdateCreditLimit(ctx, tx, wallet.ID, cmd.CreditLimit)
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## API 设计
|
||||
|
||||
### 1. 创建店铺(新增字段)
|
||||
|
||||
```
|
||||
POST /admin/shops
|
||||
```
|
||||
|
||||
新增参数:
|
||||
```go
|
||||
EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"`
|
||||
CreditLimit int64 `json:"credit_limit" description:"授信额度(分),enable_credit=true 时有效"`
|
||||
```
|
||||
|
||||
### 2. 修改信用额度
|
||||
|
||||
```
|
||||
PUT /admin/shops/{id}/credit-limit
|
||||
```
|
||||
|
||||
```go
|
||||
type UpdateCreditLimitRequest struct {
|
||||
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"新授信额度(分),修改前需余额>=0"`
|
||||
}
|
||||
```
|
||||
|
||||
错误场景:余额为负时,返回 `CodeForbidden`,msg="存在欠款(-XX元),请先清零后再修改信用额度"
|
||||
|
||||
### 3. 角色信用额度配置(平台员工)
|
||||
|
||||
```
|
||||
PUT /admin/roles/{id}/credit-limit
|
||||
```
|
||||
|
||||
```go
|
||||
type UpdateRoleCreditLimitRequest struct {
|
||||
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"该角色的信用额度(分)"`
|
||||
}
|
||||
```
|
||||
|
||||
### 4. 查询钱包(余额展示更新)
|
||||
|
||||
现有 `GET /admin/agent-wallets/{shopID}` 响应新增字段:
|
||||
|
||||
```go
|
||||
type AgentWalletResponse struct {
|
||||
// ...原有字段...
|
||||
CreditLimit int64 `json:"credit_limit" description:"信用额度(分)"`
|
||||
AvailableBalance int64 `json:"available_balance" description:"可用余额 = balance - frozen + credit_limit(分),可为负"`
|
||||
IsInDebt bool `json:"is_in_debt" description:"是否有欠款(balance < 0)"`
|
||||
DebtAmount int64 `json:"debt_amount" description:"欠款金额(分),balance<0时有值"`
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端对接
|
||||
|
||||
### 页面:创建/编辑代理
|
||||
|
||||
新增"信用额度"区块:
|
||||
```
|
||||
信用额度:
|
||||
[☑ 开启信用额度]
|
||||
授信上限:[____] 元 (enable_credit=true 时展示)
|
||||
```
|
||||
|
||||
提交时:`enable_credit: true, credit_limit: 100000`(单位:分,前端转换)
|
||||
|
||||
### 页面:代理详情 > 钱包信息
|
||||
|
||||
```
|
||||
钱包余额:-¥200.00 (红色显示)
|
||||
信用额度:¥1,000.00
|
||||
可用余额:¥800.00
|
||||
```
|
||||
|
||||
当 `is_in_debt=true` 时,余额展示红色,并加"欠款"标签。
|
||||
|
||||
### 页面:修改信用额度
|
||||
|
||||
入口:代理详情 > 钱包 > "修改信用额度"按钮
|
||||
弹框:输入新额度 → `PUT /admin/shops/{id}/credit-limit`
|
||||
|
||||
如果当前有欠款(`is_in_debt=true`),禁用该按钮,展示"存在欠款,无法修改信用额度"。
|
||||
|
||||
### 页面:余额显示(全局)
|
||||
|
||||
代理管理列表的余额列:支持显示负数,负数红色标注。
|
||||
|
||||
### 平台员工余额(角色信用)
|
||||
|
||||
平台员工操作(如批量订购代理余额扣款)时,可用余额 = 当前余额 + 角色信用额度。
|
||||
在批量订购确认页展示可用额度。
|
||||
266
docs/7月迭代/需求22-套餐临期提醒.md
Normal file
266
docs/7月迭代/需求22-套餐临期提醒.md
Normal file
@@ -0,0 +1,266 @@
|
||||
# 需求22:套餐临期提醒
|
||||
|
||||
---
|
||||
|
||||
## 业务规则
|
||||
|
||||
### 临期定义
|
||||
|
||||
当资产的**当前生效套餐**(`PackageUsage.status=1`)的 `expires_at` ≤ 15天后,该资产进入临期状态。
|
||||
|
||||
**例外**:资产若有**排队中(status=0)的套餐**,不属于临期,不触发提醒。
|
||||
|
||||
### 颜色规则(Version 2)
|
||||
|
||||
| 剩余天数 | 颜色 |
|
||||
|---------|------|
|
||||
| ≤ 15天 | 粉红色 |
|
||||
| ≤ 7天 | 紫色 |
|
||||
| ≤ 3天 | 黄色 |
|
||||
|
||||
### 各端提醒规则
|
||||
|
||||
| 场景 | 提醒方式 | 触发节点 |
|
||||
|------|---------|---------|
|
||||
| **后台管理** | 列表加临期天数列 + 高亮;详情加临期字段(≤15天高亮) | 实时计算 |
|
||||
| **企业客户** | 企微推送(Phase 2);后台每日生成临期列表 | 15天/7天/3天节点 |
|
||||
| **代理端** | 首页展示临期卡/设备数量;列表高亮 | 实时计算 |
|
||||
| **C端公众号** | 套餐到期提醒模块(≤15天展示) | 实时展示 |
|
||||
|
||||
---
|
||||
|
||||
## 数据库变更
|
||||
|
||||
无新表。临期数据实时计算,不存储(避免数据陈旧)。
|
||||
|
||||
企业客户的**每日临期推送记录**需防重,用一个 key 记录已推送记录:
|
||||
|
||||
```sql
|
||||
-- 临期推送记录(防重)
|
||||
CREATE TABLE tb_expiry_push_record (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
asset_type VARCHAR(20) NOT NULL, -- iot_card | device
|
||||
asset_id BIGINT NOT NULL,
|
||||
push_node INT NOT NULL, -- 推送节点(3/7/15天)
|
||||
push_date DATE NOT NULL, -- 推送日期
|
||||
pushed_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
CONSTRAINT uq_expiry_push UNIQUE (asset_type, asset_id, push_node, push_date)
|
||||
);
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 后端实现
|
||||
|
||||
### 1. 现有接口新增临期字段
|
||||
|
||||
#### 资产列表接口(`GET /admin/iot-cards` / `GET /admin/devices`)
|
||||
|
||||
响应新增字段:
|
||||
```go
|
||||
type IotCardListItem struct {
|
||||
// ...原有字段...
|
||||
DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(无生效套餐为null)"`
|
||||
IsExpiring bool `json:"is_expiring" description:"是否临期(≤15天且无排队套餐)"`
|
||||
}
|
||||
```
|
||||
|
||||
**计算方式**(在 SQL 层计算,避免 N+1):
|
||||
|
||||
```sql
|
||||
-- 列表查询时 JOIN PackageUsage 获取剩余天数
|
||||
SELECT
|
||||
ic.*,
|
||||
CASE
|
||||
WHEN pu.expires_at IS NULL THEN NULL
|
||||
WHEN EXISTS (
|
||||
SELECT 1 FROM tb_package_usage q
|
||||
WHERE q.iot_card_id = ic.id AND q.status = 0 AND q.deleted_at IS NULL
|
||||
) THEN NULL -- 有排队套餐,不临期
|
||||
ELSE GREATEST(0, EXTRACT(DAY FROM (pu.expires_at - NOW()))::INT)
|
||||
END AS days_until_expiry
|
||||
FROM tb_iot_card ic
|
||||
LEFT JOIN tb_package_usage pu
|
||||
ON pu.iot_card_id = ic.id AND pu.status = 1 AND pu.deleted_at IS NULL
|
||||
```
|
||||
|
||||
#### 资产列表筛选条件新增
|
||||
|
||||
```go
|
||||
type IotCardListRequest struct {
|
||||
// ...原有字段...
|
||||
ExpiringWithinDays *int `query:"expiring_within_days" description:"临期筛选(值=15表示查剩余≤15天)"`
|
||||
}
|
||||
```
|
||||
|
||||
#### 资产详情接口
|
||||
|
||||
后台资产详情走 `GET /api/admin/assets/resolve/:identifier`,响应 DTO 为 `AssetResolveResponse`(`internal/model/dto/asset_dto.go`)。
|
||||
|
||||
新增字段:
|
||||
```go
|
||||
// AssetResolveResponse 追加
|
||||
DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(无生效套餐或有排队套餐时为null)"`
|
||||
IsExpiring bool `json:"is_expiring" description:"是否临期(≤15天且无排队套餐)"`
|
||||
```
|
||||
|
||||
### 2. 新增临期列表接口(独立页面)
|
||||
|
||||
```
|
||||
GET /admin/expiring-assets
|
||||
```
|
||||
|
||||
查询参数:
|
||||
```go
|
||||
type ExpiringAssetsRequest struct {
|
||||
AssetType string `query:"asset_type" description:"资产类型 (iot_card/device)"`
|
||||
AssetIdentifier string `query:"asset_identifier" description:"资产标识(ICCID/设备号)"`
|
||||
PackageName string `query:"package_name" description:"套餐名称"`
|
||||
ShopID *uint `query:"shop_id" description:"店铺ID"`
|
||||
ExpiresAtStart string `query:"expires_at_start" description:"到期时间起"`
|
||||
ExpiresAtEnd string `query:"expires_at_end" description:"到期时间止"`
|
||||
MaxDaysUntilExpiry *int `query:"max_days_until_expiry" description:"最大剩余天数(如15)"`
|
||||
Page int `query:"page" default:"1"`
|
||||
PageSize int `query:"page_size" default:"20"`
|
||||
}
|
||||
```
|
||||
|
||||
响应:
|
||||
```go
|
||||
type ExpiringAssetItem struct {
|
||||
AssetType string `json:"asset_type"`
|
||||
AssetIdentifier string `json:"asset_identifier"`
|
||||
AssetStatus int `json:"asset_status"`
|
||||
ShopName string `json:"shop_name"`
|
||||
PackageName string `json:"package_name"`
|
||||
DaysUntilExpiry int `json:"days_until_expiry"`
|
||||
ExpiresAt time.Time `json:"expires_at"`
|
||||
DataUsageMB int64 `json:"data_usage_mb"`
|
||||
RemainingDataMB int64 `json:"remaining_data_mb"`
|
||||
}
|
||||
```
|
||||
|
||||
### 3. 每日定时任务(企业客户临期通知 - Phase 1 本系统侧)
|
||||
|
||||
```go
|
||||
// internal/task/expiry_reminder_handler.go
|
||||
|
||||
// HandleExpiryReminder 每日03:00执行,生成临期数据 + 写站内消息
|
||||
func (h *ExpiryReminderHandler) HandleExpiryReminder(ctx context.Context, t *asynq.Task) error {
|
||||
nodes := []int{15, 7, 3}
|
||||
for _, node := range nodes {
|
||||
assets, err := h.packageUsageStore.GetExpiringAssets(ctx, node)
|
||||
if err != nil { return err }
|
||||
|
||||
for _, asset := range assets {
|
||||
// 防重检查(同一资产同一节点今天已推送过)
|
||||
today := time.Now().Format("2006-01-02")
|
||||
pushed, _ := h.expiryPushStore.AlreadyPushed(ctx, asset.AssetType, asset.AssetID, node, today)
|
||||
if pushed { continue }
|
||||
|
||||
// 获取对应业务员ID(企业客户场景)
|
||||
// 此处先写站内消息,Phase 2 改为企微推送
|
||||
h.notifyPublisher.Publish(ctx, notification.SendPayload{
|
||||
RecipientIDs: asset.SalesmanIDs,
|
||||
Type: constants.NotifyTypePackageExpiring,
|
||||
Title: fmt.Sprintf("套餐临期提醒(%d天)", node),
|
||||
Body: fmt.Sprintf("资产 %s 的套餐将在 %d 天后到期", asset.Identifier, node),
|
||||
RefType: "asset",
|
||||
RefID: asset.AssetID,
|
||||
})
|
||||
|
||||
// 记录推送防重
|
||||
h.expiryPushStore.Record(ctx, asset.AssetType, asset.AssetID, node, today)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 导出功能(临期列表)
|
||||
|
||||
```
|
||||
GET /admin/expiring-assets/export
|
||||
```
|
||||
|
||||
导出字段:
|
||||
|
||||
| 字段 | 说明 |
|
||||
|------|------|
|
||||
| 资产标识 | ICCID/设备号 |
|
||||
| 资产类型 | 卡/设备 |
|
||||
| 店铺名称 | |
|
||||
| 套餐名称 | |
|
||||
| 套餐到期时间 | |
|
||||
| 剩余天数 | |
|
||||
| 资产状态 | |
|
||||
| 已用流量(MB) | |
|
||||
| 剩余流量(MB) | |
|
||||
|
||||
---
|
||||
|
||||
## C端接口变更
|
||||
|
||||
### 公众号首页
|
||||
|
||||
现有接口(`GET /app/asset/info`,通过 query param 传资产标识)的响应 DTO `AssetInfoResponse`(`internal/model/dto/client_asset_dto.go`)新增字段:
|
||||
|
||||
```go
|
||||
DaysUntilExpiry *int `json:"days_until_expiry" description:"当前套餐剩余天数(≤15天时有值,有排队套餐时为null)"`
|
||||
IsExpiring bool `json:"is_expiring" description:"是否临期"`
|
||||
```
|
||||
|
||||
前端逻辑:`is_expiring=true` 时展示续费提醒模块:
|
||||
|
||||
```
|
||||
您的套餐即将到期
|
||||
|
||||
卡号/设备号:XXXX
|
||||
剩余有效期:XX 天
|
||||
|
||||
为避免到期后影响正常使用,请您提前完成续费。
|
||||
|
||||
[立即续费]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 前端对接(后台管理)
|
||||
|
||||
### 资产列表(IoT卡管理 / 设备管理)
|
||||
|
||||
1. 列表新增"剩余天数"列
|
||||
2. 根据 `days_until_expiry` 高亮行:
|
||||
- ≤ 15天:行背景粉红色
|
||||
- ≤ 7天:行背景紫色
|
||||
- ≤ 3天:行背景黄色
|
||||
3. 筛选条件新增"临期天数"(下拉:≤15天/≤7天/≤3天)
|
||||
- 选中后传 `expiring_within_days=15`
|
||||
|
||||
### 临期资产独立列表页
|
||||
|
||||
路由:`/expiring-assets`
|
||||
|
||||
```
|
||||
筛选栏:资产标识 | 资产类型(卡/设备) | 套餐名称 | 到期时间范围 | 剩余天数 | 店铺
|
||||
|
||||
列表:资产标识 | 资产类型 | 店铺 | 套餐名称 | 剩余天数 | 到期时间 | 资产状态 | 已用流量 | 剩余流量
|
||||
|
||||
操作:导出按钮 → GET /admin/expiring-assets/export
|
||||
```
|
||||
|
||||
### 代理端首页
|
||||
|
||||
在首页数据接口新增字段(需要确认代理端首页接口):
|
||||
|
||||
```go
|
||||
type AgentDashboardResponse struct {
|
||||
// ...原有字段...
|
||||
ExpiringCardCount int `json:"expiring_card_count" description:"临期卡数量(≤15天)"`
|
||||
ExpiringDeviceCount int `json:"expiring_device_count" description:"临期设备数量(≤15天)"`
|
||||
}
|
||||
```
|
||||
|
||||
前端展示快捷入口:"xx张卡即将到期" → 跳转资产列表并过滤 `expiring_within_days=15`
|
||||
343
docs/改造方向.md
Normal file
343
docs/改造方向.md
Normal file
@@ -0,0 +1,343 @@
|
||||
# 系统改造方向
|
||||
|
||||
> 本文档记录当前系统的核心问题、改造原因、改造方向和预期收益。
|
||||
> 用于在开发过程中明确"干什么、为什么干、干的好处"。
|
||||
|
||||
---
|
||||
|
||||
## 一、现状
|
||||
|
||||
### 1.1 项目规模
|
||||
|
||||
- 当前在线卡数:约 3 万张(生产)
|
||||
- 待迁移卡数:约 67 万张(来自旧系统)
|
||||
- 核心业务链路:代理购卡 → 销售分配 → 套餐购买 → 流量同步 → 停复机
|
||||
|
||||
### 1.2 轮询系统现状
|
||||
|
||||
轮询系统的作用是**定期向运营商查询卡的状态**(实名状态、流量用量、卡网络状态等),保持系统数据与运营商同步。
|
||||
|
||||
当前架构:
|
||||
|
||||
```
|
||||
全量卡 ID → Redis Sorted Set(分片,score = 下次检查时间)
|
||||
→ Scheduler 每秒扫描到期卡
|
||||
→ Asynq 队列
|
||||
→ Worker 执行检查(实名/流量/卡状态/保护期/套餐,共 5 种类型)
|
||||
```
|
||||
|
||||
**问题清单:**
|
||||
|
||||
| # | 问题 | 具体表现 |
|
||||
|---|------|---------|
|
||||
| 1 | **看不见,无法排查** | 业务反馈"某张卡一小时了还没同步实名",没有任何结构化记录可以查,只能翻日志文件靠关键词过滤 |
|
||||
| 2 | **配置改了不知道有没有用** | 轮询配置对哪些卡生效完全黑盒,改完后没有验证手段,全靠等和猜 |
|
||||
| 3 | **大量无意义轮询** | 全量卡按固定频率轮询,大多数卡状态根本没有变化,这些查询全是浪费;67 万张卡迁移后压力更大 |
|
||||
| 4 | **业务事件不触发检查** | 代理通过开放接口买了套餐,系统不知道,要等下次定时轮询碰到这张卡(可能要等几分钟到几十分钟)|
|
||||
| 5 | **两种不同职责混在一个调度器** | 卡状态同步(每 1 秒触发)和业务流程调度(套餐过期处理、流量重置,每 10 秒触发)混在同一个 Scheduler,出问题难以定位是哪一层 |
|
||||
| 6 | **调度状态全在 Redis,没有持久化** | Redis 重启或清空后,全量卡的调度状态丢失,需要重跑全量初始化才能恢复;67 万张卡的初始化需要相当长时间,期间所有卡停止轮询 |
|
||||
| 7 | **单卡状态散在四处** | 上次检查时间在 IotCard 表;下次检查时间在 Redis Sorted Set;卡状态快照在 Redis Hash;执行结果在日志文件。没有一个地方能给完整答案 |
|
||||
|
||||
### 1.3 流量数据现状
|
||||
|
||||
运营商接口返回的是**当前账期内的累计已用流量**(不是增量,是总量)。
|
||||
运营商账期重置日因运营商而异,在创建运营商时配置。
|
||||
套餐周期可以是自然月或固定日期,在创建套餐时决定。
|
||||
|
||||
当前 IotCard 表上与流量相关的字段:
|
||||
|
||||
```
|
||||
DataUsageMB 卡生命周期总用量(永不归零)
|
||||
CurrentMonthUsageMB 系统自然月累计用量(展示用)
|
||||
LastMonthTotalMB 上月结束时的流量总量(用于跨月计算)
|
||||
CurrentMonthStartDate 系统自然月起始日期
|
||||
LastGatewayReadingMB 上次轮询时运营商返回的累计读数
|
||||
```
|
||||
|
||||
PackageUsage 表上:
|
||||
|
||||
```
|
||||
DataUsageMB 套餐已用量(套餐周期到期时归零,用于判断是否耗尽)
|
||||
```
|
||||
|
||||
**问题清单:**
|
||||
|
||||
| # | 问题 | 具体表现 |
|
||||
|---|------|---------|
|
||||
| 1 | **运营商重置靠猜,有漏检风险** | 只存上一次读数(`LastGatewayReadingMB`),靠"本次读数比上次小"来猜测运营商是否重置了账期。若轮询有空窗(例如系统故障几小时),重置后的新用量超过了上次读数,则增量为正,系统判断为"没有重置",漏掉了那段用量 |
|
||||
| 2 | **无原始读数,无法审计** | 运营商告诉了什么、什么时候告诉的、算出了多少增量——计算完成后全部丢弃。流量统计不对时,没有任何数据可以追溯原因 |
|
||||
| 3 | **重置边界的流量归属不准** | 套餐在午夜重置,但上次轮询在 23:59,下次轮询在 00:01。这两分钟的增量(包含昨天剩余用量)全部算进今天,日流量套餐受影响明显 |
|
||||
| 4 | **三套"月"的概念同时存在** | 运营商账期(各自定义重置日)+ 套餐周期(自然月或固定日)+ 系统自然月(展示用)。三者不对齐,边界处理散落在代码各处,逻辑复杂且脆弱 |
|
||||
| 5 | **`CurrentMonthUsageMB` 不按时重置** | 这个字段不由重置任务触发,而是在下次轮询时内联检测跨月并重置。1 号凌晨用户看到的数据是上月的,要等到下次轮询才更新 |
|
||||
| 6 | **两套日流量记录并存** | `CardDailyUsage`(按卡)和 `PackageUsageDailyRecord`(按套餐)同时存在,来源不同,权威性不明确,出现差异时不知道信谁 |
|
||||
|
||||
### 1.4 整体架构现状
|
||||
|
||||
当前分层:`Handler → Service → Store → Model`,分层存在但执行不彻底。
|
||||
|
||||
核心问题:**贫血模型 + 上帝 Service**
|
||||
|
||||
- Model 只是数据库映射,没有任何业务行为
|
||||
- 所有业务逻辑全部堆在 Service,导致 Service 极度臃肿
|
||||
|
||||
| Service | 行数 | 混杂的业务域 |
|
||||
|---------|------|------------|
|
||||
| order/service.go | 3031 行 | 订单创建、支付、佣金计算、代购、库存扣减 |
|
||||
| iot_card/service.go | 2310 行 | 卡管理、网关调用、停复机、生命周期、流量同步 |
|
||||
| device/service.go | 2078 行 | 设备管理、绑定、批量导入 |
|
||||
|
||||
没有领域事件:业务事件(套餐支付完成、卡实名成功)不产生任何可订阅的事件,所有下游动作全部通过直接方法调用耦合,新增联动逻辑必须修改原有代码。
|
||||
|
||||
---
|
||||
|
||||
## 二、改造方向(优先级一):轮询系统 + 流量数据
|
||||
|
||||
### 2.1 轮询系统改造
|
||||
|
||||
#### 改造 A:建立单卡可查询的轮询状态
|
||||
|
||||
**要干什么:** 新增一张表 `card_polling_state`,每张卡一行,只更新不追加,记录每种检查类型的上次执行时间、结果、命中配置、下次预计时间。
|
||||
|
||||
**为什么干:** 现在"这张卡的轮询状态"散在四个地方,没有一处能完整回答问题。这张表就是单一的事实来源。
|
||||
|
||||
**好处:**
|
||||
- 业务反馈某张卡没同步 → 查这张表,30 秒给出答案(上次检查时间、结果是什么、下次什么时候)
|
||||
- 不影响任何现有轮询逻辑,只需在执行检查后多写一次 UPDATE
|
||||
|
||||
**同时新增两个接口:**
|
||||
|
||||
```
|
||||
GET /admin/polling/cards/:id/state
|
||||
→ 返回这张卡的完整轮询状态
|
||||
|
||||
GET /admin/polling/preview?card_id=12345
|
||||
→ 返回这张卡当前命中哪条配置、各检查类型的间隔是多少、下次检查时间
|
||||
```
|
||||
|
||||
第二个接口解决"改了配置不知道有没有用"的问题——改完配置后,查任意一张卡,立刻知道新配置有没有生效。
|
||||
|
||||
---
|
||||
|
||||
#### 改造 B:引入事件触发层
|
||||
|
||||
**要干什么:** 在业务动作完成时,主动安排针对性的延迟检查,而不是等定时轮询碰到这张卡。
|
||||
|
||||
**为什么干:** 现在买了套餐,系统要等下次轮询才知道套餐有没有激活。运营商激活需要时间,但我们不知道什么时候激活完成,只能等碰到。
|
||||
|
||||
**好处:**
|
||||
- 套餐购买 → 5 分钟后主动检查 → 激活状态及时同步,不用等下次定时轮询
|
||||
- 轮询有了"意义"——有事件发生才检查,而不是盲目扫全量
|
||||
|
||||
**触发规则:**
|
||||
|
||||
| 业务事件 | 触发检查 | 延迟 |
|
||||
|---------|---------|------|
|
||||
| 套餐订单支付完成 | 检查套餐激活状态 | 5 分钟 |
|
||||
| 代理开放接口购套餐 | 检查套餐激活状态 | 5 分钟 |
|
||||
| 卡首次分配给店铺 | 检查实名状态 | 30 分钟 |
|
||||
| 手动触发刷新 | 立即检查 | 0 |
|
||||
|
||||
实现方式:利用 Asynq 的定时任务功能(`asynq.ProcessAt`),在现有 Worker 框架内完成,不需要新增基础设施。
|
||||
|
||||
---
|
||||
|
||||
#### 改造 C:按卡状态分级调度
|
||||
|
||||
**要干什么:** 不同状态的卡,轮询频率不同,而不是所有卡一视同仁。
|
||||
|
||||
**为什么干:** 稳定运行了半年、没有套餐变动的卡,每 2 分钟轮询一次是纯粹的浪费。等实名的卡、刚购套餐的卡才需要频繁检查。
|
||||
|
||||
**好处:**
|
||||
- 67 万张卡迁移后,无意义轮询量大幅下降(估计减少 60%-80%)
|
||||
- 轮询资源集中在真正需要关注的卡
|
||||
|
||||
**分级规则(通过现有 PollingConfig 机制配置):**
|
||||
|
||||
| 卡状态 | 实名检查间隔 | 流量检查间隔 |
|
||||
|-------|------------|------------|
|
||||
| 等待实名 | 10 分钟 | 30 分钟 |
|
||||
| 活跃中(近期有套餐变动)| 15 分钟 | 30 分钟 |
|
||||
| 稳定运行 | 2 小时 | 2 小时 |
|
||||
| 停机 / 停用 | 每日一次 | 每日一次 |
|
||||
|
||||
---
|
||||
|
||||
#### 改造 D:分离业务调度
|
||||
|
||||
**要干什么:** 把套餐过期处理、流量重置从 `Scheduler` 里拆出去,用独立的定时任务管理。
|
||||
|
||||
**为什么干:** 现在卡状态同步(每 1 秒)和业务逻辑调度(每 10 秒)混在同一个 Scheduler 里。出了问题不知道是哪一层,改动也容易互相影响。
|
||||
|
||||
**好处:**
|
||||
- 各司其职,互不干扰
|
||||
- 业务调度(套餐过期)出问题不影响卡状态同步
|
||||
- 代码更清晰,新增调度任务不需要修改 Scheduler
|
||||
|
||||
---
|
||||
|
||||
### 2.2 流量数据改造
|
||||
|
||||
#### 改造 A:保留原始读数
|
||||
|
||||
**要干什么:** 新增 `carrier_readings` 表,每次从运营商拿到读数都记录下来,永不修改。
|
||||
|
||||
```
|
||||
carrier_readings:
|
||||
card_id 卡ID
|
||||
reading_mb 运营商返回的当期累计值
|
||||
read_at 读取时间
|
||||
cycle_start_date 本条读数所属的运营商账期开始日期
|
||||
source 来源(polling / manual / triggered)
|
||||
```
|
||||
|
||||
**为什么干:** 现在计算完增量后,原始读数就丢弃了。流量统计不对时,没有任何数据可以追溯。
|
||||
|
||||
**好处:**
|
||||
- 流量统计有问题 → 查这张表,还原每次拿到了什么数据、算出了多少增量
|
||||
- 不再是"只有最终结果,不知道过程"
|
||||
- 可以离线重算历史数据,修复过去的错误
|
||||
|
||||
**存储量控制:** 保留 30 天原始记录,超过 30 天只保留"状态变化"事件(数量极少)。分区表实现,不影响查询性能。
|
||||
|
||||
---
|
||||
|
||||
#### 改造 B:基于账期的重置检测
|
||||
|
||||
**要干什么:** 用 `cycle_start_date` 字段判断运营商是否重置了账期,替代现在靠"增量为负"来猜测的方式。
|
||||
|
||||
**为什么干:** 现在的猜测方式有漏检风险——如果轮询空窗期间运营商重置且新用量超过了旧读数,增量为正,系统无法检测到重置,漏计那段用量。
|
||||
|
||||
**好处:**
|
||||
- 重置检测从"猜"变成"判断",准确率 100%
|
||||
- 彻底消除因轮询空窗导致的流量漏计
|
||||
- 代码逻辑更简单,不再需要"重置窗口"时间判断
|
||||
|
||||
```go
|
||||
// 改前:靠负增量猜测,有漏检风险
|
||||
if increment < 0 && isRefreshResetWindow(now, resetDay) { ... }
|
||||
|
||||
// 改后:直接判断账期,准确无误
|
||||
if prev.CycleStartDate != curr.CycleStartDate {
|
||||
increment = curr.ReadingMB // 不同账期 = 运营商重置,当前读数即为增量
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
#### 改造 C:清理 IotCard 上的流量计算字段
|
||||
|
||||
**要干什么:** 将 `LastGatewayReadingMB`、`CurrentMonthStartDate`、`LastMonthTotalMB` 从 IotCard 表移除,这些信息改由 `carrier_readings` 表承载。
|
||||
|
||||
IotCard 只保留两个对外展示的汇总值:
|
||||
- `CurrentMonthUsageMB`:展示用,由定时任务按自然月计算并主动更新(不再等轮询时内联检测)
|
||||
- `DataUsageMB`:生命周期总量,由增量事件累加
|
||||
|
||||
**为什么干:** 现在 IotCard 同时承担了三个角色:卡的身份信息、流量计数器、流量计算中间状态。职责太多,一张表改动牵一发动全身。
|
||||
|
||||
**好处:**
|
||||
- IotCard 回归本职:描述这张卡是谁、状态是什么
|
||||
- 流量计算有了独立的数据层,不污染主体数据
|
||||
- `CurrentMonthUsageMB` 在月初会被主动更新,不再有"1号凌晨数据是上月的"问题
|
||||
|
||||
---
|
||||
|
||||
## 三、改造方向(优先级二):DDD 整体方向
|
||||
|
||||
> 这部分是更长期的方向,在优先级一完成后推进。
|
||||
|
||||
### 3.1 核心思想
|
||||
|
||||
**积木本身是安全的,拼积木组成业务流程。**
|
||||
|
||||
- **积木** = 领域对象(`IotCard`、`Order`、`Wallet`):自己知道自己的规则,外部只能通过方法操作,不能随意改字段
|
||||
- **拼积木** = 应用服务(用例):只负责"拿数据 → 调对象方法 → 保存 → 发事件",不含业务判断逻辑
|
||||
|
||||
**AI 辅助开发的约束价值:** 有了这个结构,AI 生成代码时只能往"一个用例一个文件"的模式里填,不会生成新的上帝 Service;业务规则在领域对象里,AI 调用对象方法时规则自动生效。
|
||||
|
||||
### 3.2 目标目录结构
|
||||
|
||||
```
|
||||
internal/
|
||||
├── domain/ 领域层(纯业务逻辑,不依赖任何框架)
|
||||
│ ├── order/
|
||||
│ │ ├── order.go 聚合根(Order 实体,有方法:Pay, Cancel, Reject)
|
||||
│ │ ├── events.go 领域事件(OrderPaid, OrderCancelled)
|
||||
│ │ ├── repository.go 仓储接口(interface,不含实现)
|
||||
│ │ └── values.go 值对象(OrderStatus, Amount)
|
||||
│ ├── asset/
|
||||
│ │ ├── iot_card.go IotCard 聚合根
|
||||
│ │ └── repository.go
|
||||
│ └── wallet/
|
||||
│ └── wallet.go Wallet 聚合根(Debit, Credit)
|
||||
│
|
||||
├── application/ 应用层(用例,只做编排)
|
||||
│ ├── order/
|
||||
│ │ ├── create_order.go 一个文件一个用例
|
||||
│ │ ├── pay_order.go
|
||||
│ │ └── cancel_order.go
|
||||
│ └── asset/
|
||||
│
|
||||
├── infrastructure/ 基础设施层(实现接口)
|
||||
│ ├── persistence/ 原来的 store/postgres/
|
||||
│ └── event/ 事件总线
|
||||
│
|
||||
└── interface/ 接口层
|
||||
├── http/ 原来的 handler/ + routes/
|
||||
└── task/ Asynq task handlers
|
||||
```
|
||||
|
||||
### 3.3 迁移策略(绞杀者模式)
|
||||
|
||||
不全量重写,新旧并行,逐步替换:
|
||||
|
||||
1. 建好 `domain/`、`application/` 目录和规范文件
|
||||
2. **所有新功能按新结构写**,不往旧 Service 里加代码
|
||||
3. 最痛的模块优先迁移:`order`(3031 行)和 `iot_card`(2310 行)
|
||||
4. 每次迁移一个用例,验证跑通后再迁移下一个
|
||||
|
||||
---
|
||||
|
||||
## 四、实施顺序
|
||||
|
||||
```
|
||||
第一阶段(当前)
|
||||
├── 新增 card_polling_state 表(轮询状态持久化)
|
||||
├── 新增轮询状态查询接口 + 配置预览接口(可观测性)
|
||||
├── 新增 carrier_readings 表(保留原始读数)
|
||||
├── 改造增量计算逻辑(基于账期判断重置)
|
||||
└── 事件触发层(套餐购买 → 延迟检查)
|
||||
|
||||
第二阶段
|
||||
├── 分级调度(按卡状态动态调整轮询频率)
|
||||
├── 分离业务调度(套餐过期/流量重置从 Scheduler 独立)
|
||||
└── 清理 IotCard 流量计算字段
|
||||
|
||||
第三阶段(DDD 改造)
|
||||
├── 建立目录骨架和规范示例
|
||||
├── 新功能按新结构写
|
||||
└── 逐步迁移最痛的模块
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 五、预期收益
|
||||
|
||||
### 轮询 + 流量改造后
|
||||
|
||||
| 场景 | 改造前 | 改造后 |
|
||||
|------|--------|--------|
|
||||
| 业务反馈"某张卡没同步" | 翻日志,靠运气,可能花几十分钟 | 查接口,30 秒内给出答案 |
|
||||
| 验证配置是否生效 | 完全黑盒,改完只能等和猜 | 配置预览接口,改完立即验证 |
|
||||
| 流量统计不对 | 无法追溯,无从下手 | 查原始读数,还原每次增量计算 |
|
||||
| 运营商重置漏计 | 有风险,轮询空窗时必现 | 账期判断,100% 准确 |
|
||||
| 套餐购买后同步延迟 | 等下次定时轮询,最多几十分钟 | 5 分钟内主动检查 |
|
||||
| 67 万卡迁移后轮询压力 | 全量扫描,压力线性增长 | 分级 + 事件触发,压力可控 |
|
||||
|
||||
### DDD 改造后
|
||||
|
||||
| 场景 | 改造前 | 改造后 |
|
||||
|------|--------|--------|
|
||||
| AI 辅助开发新功能 | 容易生成新的上帝 Service | 遵循用例模式,每次只加一个文件 |
|
||||
| 新增业务联动逻辑 | 必须修改原有 Service 代码 | 新增事件监听器,原有代码不动 |
|
||||
| 定位业务规则在哪里 | 散在几千行 Service 里找 | 在对应的领域对象方法里 |
|
||||
| 排查某个流程的问题 | 跨多个 Service 追调用链 | 用例文件即流程,一目了然 |
|
||||
@@ -139,7 +139,8 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||
}(),
|
||||
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
|
||||
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge, validate),
|
||||
Refund: admin.NewRefundHandler(svc.Refund),
|
||||
Refund: admin.NewRefundHandler(svc.Refund),
|
||||
OrderPackageInvalidate: admin.NewOrderPackageInvalidateHandler(svc.OrderPackageInvalidate),
|
||||
ClientWechat: app.NewClientWechatHandler(svc.WechatConfig, deps.Redis, deps.Logger),
|
||||
SuperAdmin: admin.NewSuperAdminHandler(svc.OperationPassword),
|
||||
AgentOpenAPI: openapiHandler.NewHandler(svc.AgentOpenAPI, validate),
|
||||
|
||||
@@ -46,6 +46,7 @@ import (
|
||||
|
||||
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
|
||||
operationPasswordSvc "github.com/break/junhong_cmp_fiber/internal/service/operation_password"
|
||||
orderPackageInvalidateSvc "github.com/break/junhong_cmp_fiber/internal/service/order_package_invalidate"
|
||||
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||
refundSvc "github.com/break/junhong_cmp_fiber/internal/service/refund"
|
||||
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
|
||||
@@ -111,6 +112,7 @@ type services struct {
|
||||
OperationPassword *operationPasswordSvc.Service
|
||||
AgentOpenAPI *agentOpenAPISvc.Service
|
||||
CustomerBinding *customerBindingSvc.Service
|
||||
OrderPackageInvalidate *orderPackageInvalidateSvc.Service
|
||||
}
|
||||
|
||||
func initServices(s *stores, deps *Dependencies) *services {
|
||||
@@ -313,6 +315,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
||||
s.AssetWallet,
|
||||
deps.Logger,
|
||||
),
|
||||
CustomerBinding: customerBinding,
|
||||
CustomerBinding: customerBinding,
|
||||
OrderPackageInvalidate: orderPackageInvalidateSvc.New(s.OrderPackageInvalidateTask, deps.QueueClient),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,6 +66,8 @@ type stores struct {
|
||||
WechatConfig *postgres.WechatConfigStore
|
||||
// 退款系统
|
||||
RefundRequest *postgres.RefundStore
|
||||
// 订单套餐失效任务
|
||||
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
|
||||
// 流量系统
|
||||
CardDailyUsage *postgres.CardDailyUsageStore
|
||||
// 资产标识符注册表
|
||||
@@ -131,8 +133,9 @@ func initStores(deps *Dependencies) *stores {
|
||||
RechargeOrder: postgres.NewRechargeOrderStore(deps.DB, deps.Redis),
|
||||
Payment: postgres.NewPaymentStore(deps.DB, deps.Redis),
|
||||
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
|
||||
RefundRequest: postgres.NewRefundStore(deps.DB),
|
||||
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
RefundRequest: postgres.NewRefundStore(deps.DB),
|
||||
CardDailyUsage: postgres.NewCardDailyUsageStore(deps.DB),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ type Handlers struct {
|
||||
WechatConfig *admin.WechatConfigHandler
|
||||
AgentRecharge *admin.AgentRechargeHandler
|
||||
Refund *admin.RefundHandler
|
||||
OrderPackageInvalidate *admin.OrderPackageInvalidateHandler
|
||||
ClientWechat *app.ClientWechatHandler
|
||||
SuperAdmin *admin.SuperAdminHandler
|
||||
AgentOpenAPI *openapiHandler.Handler
|
||||
|
||||
@@ -33,8 +33,9 @@ type workerStores struct {
|
||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||
AssetWallet *postgres.AssetWalletStore
|
||||
AssetIdentifier *postgres.AssetIdentifierStore
|
||||
PersonalCustomer *postgres.PersonalCustomerStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
PersonalCustomer *postgres.PersonalCustomerStore
|
||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||
OrderPackageInvalidateTask *postgres.OrderPackageInvalidateTaskStore
|
||||
}
|
||||
|
||||
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||
@@ -66,8 +67,9 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
||||
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||
AssetIdentifier: postgres.NewAssetIdentifierStore(deps.DB),
|
||||
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||
OrderPackageInvalidateTask: postgres.NewOrderPackageInvalidateTaskStore(deps.DB),
|
||||
}
|
||||
|
||||
return &queue.WorkerStores{
|
||||
@@ -98,7 +100,8 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||
AgentWalletTransaction: stores.AgentWalletTransaction,
|
||||
AssetWallet: stores.AssetWallet,
|
||||
AssetIdentifier: stores.AssetIdentifier,
|
||||
PersonalCustomer: stores.PersonalCustomer,
|
||||
PersonalCustomerPhone: stores.PersonalCustomerPhone,
|
||||
PersonalCustomer: stores.PersonalCustomer,
|
||||
PersonalCustomerPhone: stores.PersonalCustomerPhone,
|
||||
OrderPackageInvalidateTask: stores.OrderPackageInvalidateTask,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,6 +74,29 @@ func (h *AgentRechargeHandler) Get(c *fiber.Ctx) error {
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// Reject 驳回代理充值订单
|
||||
// POST /api/admin/agent-recharges/:id/reject
|
||||
func (h *AgentRechargeHandler) Reject(c *fiber.Ctx) error {
|
||||
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的充值记录ID")
|
||||
}
|
||||
|
||||
var req dto.AgentRechargeRejectRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
if err := h.validator.Struct(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
if err := h.service.Reject(c.UserContext(), uint(id), req.RejectionReason); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, nil)
|
||||
}
|
||||
|
||||
// OfflinePay 确认线下充值
|
||||
// POST /api/admin/agent-recharges/:id/offline-pay
|
||||
func (h *AgentRechargeHandler) OfflinePay(c *fiber.Ctx) error {
|
||||
|
||||
@@ -56,12 +56,13 @@ func (h *AssetHandler) SetLifecycleService(svc AssetLifecycleService) {
|
||||
}
|
||||
|
||||
// resolveByIdentifier 通过标识符解析资产,用于各处理器的公共逻辑
|
||||
// 不传递 include_usage_summary,始终为 false,不影响其他 Handler 性能
|
||||
func (h *AssetHandler) resolveByIdentifier(c *fiber.Ctx) (*dto.AssetResolveResponse, error) {
|
||||
identifier := c.Params("identifier")
|
||||
if identifier == "" {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "标识符不能为空")
|
||||
}
|
||||
return h.assetService.Resolve(c.UserContext(), identifier)
|
||||
return h.assetService.Resolve(c.UserContext(), identifier, false)
|
||||
}
|
||||
|
||||
// Resolve 通过任意标识符解析资产(设备或卡)
|
||||
@@ -72,7 +73,8 @@ func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
|
||||
return errors.New(errors.CodeInvalidParam, "标识符不能为空")
|
||||
}
|
||||
|
||||
result, err := h.assetService.Resolve(c.UserContext(), identifier)
|
||||
includeUsageSummary := c.QueryBool("include_usage_summary", false)
|
||||
result, err := h.assetService.Resolve(c.UserContext(), identifier, includeUsageSummary)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -378,7 +380,7 @@ func (h *AssetHandler) UpdatePollingStatus(c *fiber.Ctx) error {
|
||||
return errors.New(errors.CodeInvalidParam)
|
||||
}
|
||||
|
||||
asset, err := h.assetService.Resolve(c.UserContext(), identifier)
|
||||
asset, err := h.assetService.Resolve(c.UserContext(), identifier, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -424,7 +426,7 @@ func (h *AssetHandler) UpdateRealnamePolicy(c *fiber.Ctx) error {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的实名认证策略")
|
||||
}
|
||||
|
||||
asset, err := h.assetService.Resolve(c.UserContext(), identifier)
|
||||
asset, err := h.assetService.Resolve(c.UserContext(), identifier, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -467,7 +469,7 @@ func (h *AssetHandler) UpdateRealnameStatus(c *fiber.Ctx) error {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的实名状态")
|
||||
}
|
||||
|
||||
asset, err := h.assetService.Resolve(c.UserContext(), identifier)
|
||||
asset, err := h.assetService.Resolve(c.UserContext(), identifier, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -46,7 +46,7 @@ func (h *AssetWalletHandler) GetWallet(c *fiber.Ctx) error {
|
||||
return errors.New(errors.CodeInternalError, "资产服务未配置")
|
||||
}
|
||||
|
||||
asset, err := h.assetService.Resolve(c.UserContext(), identifier)
|
||||
asset, err := h.assetService.Resolve(c.UserContext(), identifier, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -76,7 +76,7 @@ func (h *AssetWalletHandler) ListTransactions(c *fiber.Ctx) error {
|
||||
return errors.New(errors.CodeInternalError, "资产服务未配置")
|
||||
}
|
||||
|
||||
asset, err := h.assetService.Resolve(c.UserContext(), identifier)
|
||||
asset, err := h.assetService.Resolve(c.UserContext(), identifier, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
92
internal/handler/admin/order_package_invalidate.go
Normal file
92
internal/handler/admin/order_package_invalidate.go
Normal file
@@ -0,0 +1,92 @@
|
||||
package admin
|
||||
|
||||
import (
|
||||
"strconv"
|
||||
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
invalidateSvc "github.com/break/junhong_cmp_fiber/internal/service/order_package_invalidate"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||
)
|
||||
|
||||
// OrderPackageInvalidateHandler 订单套餐批量失效任务 Handler
|
||||
type OrderPackageInvalidateHandler struct {
|
||||
service *invalidateSvc.Service
|
||||
}
|
||||
|
||||
// NewOrderPackageInvalidateHandler 创建 Handler 实例
|
||||
func NewOrderPackageInvalidateHandler(service *invalidateSvc.Service) *OrderPackageInvalidateHandler {
|
||||
return &OrderPackageInvalidateHandler{service: service}
|
||||
}
|
||||
|
||||
// Create 创建批量失效订单套餐任务
|
||||
// POST /api/admin/order-package-invalidate-tasks
|
||||
func (h *OrderPackageInvalidateHandler) Create(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "仅平台用户可创建套餐失效任务")
|
||||
}
|
||||
|
||||
var req dto.CreateOrderPackageInvalidateTaskRequest
|
||||
if err := c.BodyParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
if req.FileKey == "" {
|
||||
return errors.New(errors.CodeInvalidParam, "文件路径不能为空")
|
||||
}
|
||||
|
||||
result, err := h.service.Create(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
|
||||
// List 查询批量失效任务列表
|
||||
// GET /api/admin/order-package-invalidate-tasks
|
||||
func (h *OrderPackageInvalidateHandler) List(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "仅平台用户可查看套餐失效任务")
|
||||
}
|
||||
|
||||
var req dto.ListOrderPackageInvalidateTaskRequest
|
||||
if err := c.QueryParser(&req); err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||
}
|
||||
|
||||
result, err := h.service.List(c.UserContext(), &req)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
|
||||
}
|
||||
|
||||
// GetByID 查询批量失效任务详情
|
||||
// GET /api/admin/order-package-invalidate-tasks/:id
|
||||
func (h *OrderPackageInvalidateHandler) GetByID(c *fiber.Ctx) error {
|
||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||
return errors.New(errors.CodeForbidden, "仅平台用户可查看套餐失效任务详情")
|
||||
}
|
||||
|
||||
idStr := c.Params("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
return errors.New(errors.CodeInvalidParam, "无效的任务ID")
|
||||
}
|
||||
|
||||
result, err := h.service.GetByID(c.UserContext(), uint(id))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return response.Success(c, result)
|
||||
}
|
||||
@@ -98,7 +98,7 @@ func (h *ClientAssetHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier
|
||||
}
|
||||
|
||||
skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{})
|
||||
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier)
|
||||
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ func (h *ClientDeviceHandler) validateDeviceAsset(c *fiber.Ctx, identifier strin
|
||||
ctx := c.UserContext()
|
||||
|
||||
// 通过标识符解析资产
|
||||
asset, err := h.assetService.Resolve(ctx, identifier)
|
||||
asset, err := h.assetService.Resolve(ctx, identifier, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -84,7 +84,7 @@ func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error {
|
||||
ctx := c.UserContext()
|
||||
|
||||
// 3. 通过标识符解析资产
|
||||
asset, err := h.assetService.Resolve(ctx, req.Identifier)
|
||||
asset, err := h.assetService.Resolve(ctx, req.Identifier, false)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -279,6 +279,18 @@ func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
|
||||
return err
|
||||
}
|
||||
|
||||
// 第三方支付方式与资产类型必须匹配:单卡只允许支付宝,设备只允许微信(已暂时注释)
|
||||
// switch resolved.Asset.AssetType {
|
||||
// case "card":
|
||||
// if req.PaymentMethod != constants.RechargeMethodAlipay {
|
||||
// return errors.New(errors.CodeInvalidParam, "单卡资产只支持支付宝充值")
|
||||
// }
|
||||
// case "device":
|
||||
// if req.PaymentMethod != constants.RechargeMethodWechat {
|
||||
// return errors.New(errors.CodeInvalidParam, "设备资产只支持微信充值")
|
||||
// }
|
||||
// }
|
||||
|
||||
config, err := h.wechatConfigService.GetActiveConfig(resolved.SkipPermissionCtx)
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -540,7 +552,7 @@ func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifie
|
||||
}
|
||||
|
||||
skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{})
|
||||
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier)
|
||||
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -82,7 +82,9 @@ type AgentRechargeRecord struct {
|
||||
PaymentTransactionID *string `gorm:"column:payment_transaction_id;type:varchar(100);comment:第三方支付交易号" json:"payment_transaction_id,omitempty"`
|
||||
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:充值状态(1-待支付 2-已支付 3-已完成 4-已关闭 5-已退款)" json:"status"`
|
||||
PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500);comment:支付凭证对象存储Key(线下支付时必填,微信支付时为空)" json:"payment_voucher_key,omitempty"`
|
||||
PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:支付凭证对象存储Key列表(线下支付时必填,最多5个,微信支付时为空)" json:"payment_voucher_key"`
|
||||
Remark string `gorm:"column:remark;type:text;comment:运营备注(创建时填写,不可修改)" json:"remark,omitempty"`
|
||||
RejectionReason *string `gorm:"column:rejection_reason;type:varchar(500);comment:驳回原因,仅驳回时写入" json:"rejection_reason,omitempty"`
|
||||
PaidAt *time.Time `gorm:"column:paid_at;comment:支付时间" json:"paid_at,omitempty"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at,omitempty"`
|
||||
ShopIDTag uint `gorm:"column:shop_id_tag;not null;index;comment:店铺ID标签(多租户过滤)" json:"shop_id_tag"`
|
||||
|
||||
@@ -29,6 +29,7 @@ type DeviceImportTask struct {
|
||||
StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
|
||||
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:导入批次实名策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`
|
||||
CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:操作人姓名快照" json:"creator_name"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
@@ -5,7 +5,8 @@ type CreateAgentRechargeRequest struct {
|
||||
ShopID uint `json:"shop_id" validate:"required" required:"true" description:"目标店铺ID,代理只能填自己店铺"`
|
||||
Amount int64 `json:"amount" validate:"required,min=1,max=100000000" required:"true" minimum:"1" maximum:"100000000" description:"充值金额(分),范围1分~100万元"`
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wechat offline" required:"true" description:"支付方式 (wechat:微信在线支付, offline:线下转账仅平台可用)"`
|
||||
PaymentVoucherKey string `json:"payment_voucher_key" validate:"omitempty,max=500" description:"支付凭证对象存储Key(payment_method=offline 时必填,微信支付时忽略)"`
|
||||
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表(payment_method=offline 时至少1个,最多5个,微信支付时忽略)"`
|
||||
Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"`
|
||||
}
|
||||
|
||||
// AgentOfflinePayRequest 代理线下充值确认请求
|
||||
@@ -21,23 +22,36 @@ type AgentOfflinePayParams struct {
|
||||
|
||||
// AgentRechargeResponse 代理充值记录响应
|
||||
type AgentRechargeResponse struct {
|
||||
ID uint `json:"id" description:"充值记录ID"`
|
||||
RechargeNo string `json:"recharge_no" description:"充值单号(ARCH前缀)"`
|
||||
ShopID uint `json:"shop_id" description:"店铺ID"`
|
||||
ShopName string `json:"shop_name" description:"店铺名称"`
|
||||
AgentWalletID uint `json:"agent_wallet_id" description:"代理钱包ID"`
|
||||
Amount int64 `json:"amount" description:"充值金额(分)"`
|
||||
PaymentMethod string `json:"payment_method" description:"支付方式 (wechat:微信在线支付, offline:线下转账)"`
|
||||
PaymentChannel string `json:"payment_channel" description:"实际支付通道 (wechat_direct:微信直连, fuyou:富友, offline:线下转账)"`
|
||||
PaymentConfigID *uint `json:"payment_config_id" description:"关联支付配置ID,线下充值为null"`
|
||||
PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号"`
|
||||
PaymentVoucherKey string `json:"payment_voucher_key,omitempty" description:"支付凭证对象存储Key(线下支付时存在)"`
|
||||
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
PaidAt *string `json:"paid_at" description:"支付时间"`
|
||||
CompletedAt *string `json:"completed_at" description:"完成时间"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
ID uint `json:"id" description:"充值记录ID"`
|
||||
RechargeNo string `json:"recharge_no" description:"充值单号(ARCH前缀)"`
|
||||
ShopID uint `json:"shop_id" description:"店铺ID"`
|
||||
ShopName string `json:"shop_name" description:"店铺名称"`
|
||||
AgentWalletID uint `json:"agent_wallet_id" description:"代理钱包ID"`
|
||||
Amount int64 `json:"amount" description:"充值金额(分)"`
|
||||
PaymentMethod string `json:"payment_method" description:"支付方式 (wechat:微信在线支付, offline:线下转账)"`
|
||||
PaymentChannel string `json:"payment_channel" description:"实际支付通道 (wechat_direct:微信直连, fuyou:富友, offline:线下转账)"`
|
||||
PaymentConfigID *uint `json:"payment_config_id" description:"关联支付配置ID,线下充值为null"`
|
||||
PaymentTransactionID string `json:"payment_transaction_id" description:"第三方支付流水号"`
|
||||
PaymentVoucherKey []string `json:"payment_voucher_key" description:"支付凭证对象存储Key列表(线下支付时存在,最多5个)"`
|
||||
Remark string `json:"remark,omitempty" description:"运营备注"`
|
||||
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
RejectionReason *string `json:"rejection_reason,omitempty" description:"驳回原因,仅 status=6 时有值"`
|
||||
PaidAt *string `json:"paid_at" description:"支付时间"`
|
||||
CompletedAt *string `json:"completed_at" description:"完成时间"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// AgentRechargeRejectRequest 驳回代理充值订单请求
|
||||
type AgentRechargeRejectRequest struct {
|
||||
RejectionReason string `json:"rejection_reason" validate:"required,max=500" required:"true" description:"驳回原因(必填,最多500字)"`
|
||||
}
|
||||
|
||||
// AgentRechargeRejectParams 驳回代理充值订单聚合参数(用于文档生成)
|
||||
type AgentRechargeRejectParams struct {
|
||||
IDReq
|
||||
AgentRechargeRejectRequest
|
||||
}
|
||||
|
||||
// AgentRechargeListRequest 代理充值记录列表请求
|
||||
@@ -45,7 +59,7 @@ type AgentRechargeListRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码,默认1"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数,默认20,最大100"`
|
||||
ShopID *uint `json:"shop_id" query:"shop_id" description:"按店铺ID过滤"`
|
||||
Status *int `json:"status" query:"status" description:"按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)"`
|
||||
Status *int `json:"status" query:"status" description:"按状态过滤 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款, 6:已驳回)"`
|
||||
StartDate string `json:"start_date" query:"start_date" description:"创建时间起始日期(YYYY-MM-DD)"`
|
||||
EndDate string `json:"end_date" query:"end_date" description:"创建时间截止日期(YYYY-MM-DD)"`
|
||||
}
|
||||
|
||||
@@ -68,6 +68,9 @@ type AssetResolveResponse struct {
|
||||
NetworkStatus int `json:"network_status" description:"网络状态:0停机 1开机(asset_type=card时有效)"`
|
||||
GatewayExtend string `json:"gateway_extend" description:"Gateway 卡状态扩展字段,原样返回上游 extend,用于展示运营商侧实际停机原因"`
|
||||
GatewayCardIMEI string `json:"gateway_card_imei" description:"插拔卡业务 IMEI(由 Gateway 卡状态接口同步,非设备自身 IMEI,无业务含义,仅供查看)"`
|
||||
// 流量汇总字段(仅在 ?include_usage_summary=true 时返回非 null 值)
|
||||
TotalVirtualUsedMB *float64 `json:"total_virtual_used_mb" description:"当前世代所有套餐虚已用之和(MB),未请求时为 null"`
|
||||
TotalVirtualRemainingMB *float64 `json:"total_virtual_remaining_mb" description:"当前世代所有套餐虚剩余之和(MB),未请求时为 null"`
|
||||
}
|
||||
|
||||
// BoundCardInfo 设备绑定的卡信息
|
||||
@@ -148,9 +151,10 @@ type AssetPackagesResult struct {
|
||||
Items []*AssetPackageResponse `json:"items" description:"套餐列表"`
|
||||
}
|
||||
|
||||
// AssetResolveRequest 资产解析请求(路径参数)
|
||||
// AssetResolveRequest 资产解析请求
|
||||
type AssetResolveRequest struct {
|
||||
Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"`
|
||||
Identifier string `path:"identifier" description:"资产标识符(虚拟号/ICCID/IMEI/SN/MSISDN)" required:"true"`
|
||||
IncludeUsageSummary bool `query:"include_usage_summary" description:"是否返回当前世代流量汇总字段(total_virtual_used_mb / total_virtual_remaining_mb),默认 false"`
|
||||
}
|
||||
|
||||
// AssetIdentifierRequest 资产标识符路径参数请求
|
||||
|
||||
@@ -39,6 +39,7 @@ type DeviceImportTaskResponse struct {
|
||||
StartedAt *time.Time `json:"started_at,omitempty" description:"开始处理时间"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"`
|
||||
ErrorMessage string `json:"error_message,omitempty" description:"错误信息"`
|
||||
CreatorName string `json:"creator_name" description:"操作人姓名"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
}
|
||||
|
||||
|
||||
@@ -126,6 +126,7 @@ type ImportTaskResponse struct {
|
||||
StartedAt *time.Time `json:"started_at,omitempty" description:"开始处理时间"`
|
||||
CompletedAt *time.Time `json:"completed_at,omitempty" description:"完成时间"`
|
||||
ErrorMessage string `json:"error_message,omitempty" description:"错误信息"`
|
||||
CreatorName string `json:"creator_name" description:"操作人姓名"`
|
||||
CreatedAt time.Time `json:"created_at" description:"创建时间"`
|
||||
}
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ type CreateAdminOrderRequest struct {
|
||||
Identifier string `json:"identifier" validate:"required,min=1,max=100" required:"true" minLength:"1" maxLength:"100" description:"资产标识符(ICCID 或 VirtualNo)"`
|
||||
PackageIDs []uint `json:"package_ids" validate:"required,min=1,max=10,dive,min=1" required:"true" minItems:"1" maxItems:"10" description:"套餐ID列表"`
|
||||
PaymentMethod string `json:"payment_method" validate:"required,oneof=wallet offline" required:"true" description:"支付方式 (wallet:钱包支付, offline:线下支付)"`
|
||||
PaymentVoucherKey string `json:"payment_voucher_key" validate:"omitempty,max=500" maxLength:"500" description:"线下支付凭证对象存储file_key(payment_method=offline时必填,通过/storage/upload-url上传图片后获得)"`
|
||||
PaymentVoucherKey []string `json:"payment_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"线下支付凭证对象存储file_key列表(payment_method=offline时至少1个,最多5个,通过/storage/upload-url上传图片后获得)"`
|
||||
}
|
||||
|
||||
type OrderListRequest struct {
|
||||
@@ -63,7 +63,7 @@ type OrderResponse struct {
|
||||
PaymentStatus int `json:"payment_status" description:"支付状态 (1:待支付, 2:已支付, 3:已取消, 4:已退款)"`
|
||||
PaymentStatusText string `json:"payment_status_text" description:"支付状态文本"`
|
||||
PaidAt *time.Time `json:"paid_at,omitempty" description:"支付时间"`
|
||||
PaymentVoucherKey string `json:"payment_voucher_key,omitempty" description:"线下支付凭证对象存储file_key(仅线下支付订单有值)"`
|
||||
PaymentVoucherKey []string `json:"payment_voucher_key" description:"线下支付凭证对象存储file_key列表(线下支付订单有值,最多5个)"`
|
||||
IsPurchaseOnBehalf bool `json:"is_purchase_on_behalf" description:"是否为代购订单"`
|
||||
CommissionStatus int `json:"commission_status" description:"佣金流程状态 (1:待计算, 2:已完成, 3:待人工处理)"`
|
||||
CommissionStatusName string `json:"commission_status_name" description:"佣金流程状态名称(中文)"`
|
||||
|
||||
56
internal/model/dto/order_package_invalidate_dto.go
Normal file
56
internal/model/dto/order_package_invalidate_dto.go
Normal file
@@ -0,0 +1,56 @@
|
||||
package dto
|
||||
|
||||
// CreateOrderPackageInvalidateTaskRequest 创建订单套餐失效任务请求
|
||||
type CreateOrderPackageInvalidateTaskRequest struct {
|
||||
FileKey string `json:"file_key" validate:"required,max=500" required:"true" maxLength:"500" description:"CSV文件对象存储Key(通过/storage/upload-url上传后获得;CSV单列 order_no)"`
|
||||
VoucherKeys []string `json:"voucher_keys" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"支付凭证对象存储Key列表(可选,最多5个)"`
|
||||
Remark string `json:"remark" validate:"omitempty,max=1000" maxLength:"1000" description:"运营备注(可选,创建后只读)"`
|
||||
}
|
||||
|
||||
// OrderPackageInvalidateTaskResponse 任务详情响应
|
||||
type OrderPackageInvalidateTaskResponse struct {
|
||||
ID uint `json:"id" description:"任务ID"`
|
||||
TaskNo string `json:"task_no" description:"任务编号"`
|
||||
Status int `json:"status" description:"任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
TotalCount int `json:"total_count" description:"总行数"`
|
||||
SuccessCount int `json:"success_count" description:"成功数"`
|
||||
FailCount int `json:"fail_count" description:"失败数"`
|
||||
FileName string `json:"file_name" description:"原始文件名"`
|
||||
VoucherKeys []string `json:"voucher_keys" description:"支付凭证Key列表"`
|
||||
Remark string `json:"remark,omitempty" description:"运营备注"`
|
||||
CreatorName string `json:"creator_name" description:"操作人姓名"`
|
||||
ErrorMessage string `json:"error_message,omitempty" description:"任务级错误信息"`
|
||||
StartedAt *string `json:"started_at,omitempty" description:"开始处理时间"`
|
||||
CompletedAt *string `json:"completed_at,omitempty" description:"完成时间"`
|
||||
CreatedAt string `json:"created_at" description:"创建时间"`
|
||||
UpdatedAt string `json:"updated_at" description:"更新时间"`
|
||||
}
|
||||
|
||||
// OrderPackageInvalidateTaskDetailResponse 任务详情(含失败明细)
|
||||
type OrderPackageInvalidateTaskDetailResponse struct {
|
||||
OrderPackageInvalidateTaskResponse
|
||||
FailedItems []InvalidateFailedItem `json:"failed_items" description:"失败记录列表"`
|
||||
}
|
||||
|
||||
// InvalidateFailedItem 失效失败记录
|
||||
type InvalidateFailedItem struct {
|
||||
Line int `json:"line" description:"CSV 行号"`
|
||||
OrderNo string `json:"order_no" description:"订单号"`
|
||||
Reason string `json:"reason" description:"失败原因"`
|
||||
}
|
||||
|
||||
// ListOrderPackageInvalidateTaskRequest 任务列表查询请求
|
||||
type ListOrderPackageInvalidateTaskRequest struct {
|
||||
Page int `json:"page" query:"page" validate:"omitempty,min=1" minimum:"1" description:"页码(默认1)"`
|
||||
PageSize int `json:"page_size" query:"page_size" validate:"omitempty,min=1,max=100" minimum:"1" maximum:"100" description:"每页条数(默认20,最大100)"`
|
||||
Status *int `json:"status" query:"status" validate:"omitempty,min=1,max=4" minimum:"1" maximum:"4" description:"按状态过滤 (1:待处理, 2:处理中, 3:已完成, 4:失败)"`
|
||||
}
|
||||
|
||||
// OrderPackageInvalidateTaskListResponse 任务列表响应
|
||||
type OrderPackageInvalidateTaskListResponse struct {
|
||||
Total int64 `json:"total" description:"总记录数"`
|
||||
Page int `json:"page" description:"当前页码"`
|
||||
PageSize int `json:"size" description:"每页条数"`
|
||||
List []*OrderPackageInvalidateTaskResponse `json:"items" description:"任务列表"`
|
||||
}
|
||||
@@ -5,7 +5,7 @@ type CreateRefundRequest struct {
|
||||
OrderID uint `json:"order_id" validate:"required" required:"true" description:"关联订单ID"`
|
||||
ActualReceivedAmount int64 `json:"actual_received_amount" validate:"required,min=1" required:"true" minimum:"1" description:"实收金额(分)"`
|
||||
RequestedRefundAmount int64 `json:"requested_refund_amount" validate:"required,min=1" required:"true" minimum:"1" description:"申请退款金额(分)"`
|
||||
RefundVoucherKey string `json:"refund_voucher_key" validate:"required,min=1,max=500" required:"true" minLength:"1" maxLength:"500" description:"退款凭证对象存储file_key(通过/storage/upload-url上传图片后获得)"`
|
||||
RefundVoucherKey []string `json:"refund_voucher_key" validate:"required,min=1,max=5,dive,max=500" required:"true" minItems:"1" maxItems:"5" description:"退款凭证对象存储file_key列表(至少1个,最多5个,通过/storage/upload-url上传图片后获得)"`
|
||||
RefundReason string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
|
||||
PackageUsageID *uint `json:"package_usage_id" validate:"omitempty" description:"关联套餐使用记录ID(可选)"`
|
||||
}
|
||||
@@ -27,7 +27,7 @@ type ResubmitRefundRequest struct {
|
||||
ID uint `json:"-" params:"id" path:"id" validate:"required" description:"退款申请ID"`
|
||||
ActualReceivedAmount *int64 `json:"actual_received_amount" validate:"omitempty,min=1" minimum:"1" description:"实收金额(分)"`
|
||||
RequestedRefundAmount *int64 `json:"requested_refund_amount" validate:"omitempty,min=1" minimum:"1" description:"申请退款金额(分)"`
|
||||
RefundVoucherKey *string `json:"refund_voucher_key" validate:"omitempty,max=500" maxLength:"500" description:"退款凭证对象存储file_key(重新提交时可替换;历史记录缺失时必填)"`
|
||||
RefundVoucherKey *[]string `json:"refund_voucher_key" validate:"omitempty,max=5,dive,max=500" maxItems:"5" description:"退款凭证对象存储file_key列表(重新提交时可替换;历史记录缺失时必填,最多5个)"`
|
||||
RefundReason *string `json:"refund_reason" validate:"omitempty,max=1000" maxLength:"1000" description:"退款原因"`
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ type RefundResponse struct {
|
||||
ActualReceivedAmount int64 `json:"actual_received_amount" description:"实收金额(分)"`
|
||||
RequestedRefundAmount int64 `json:"requested_refund_amount" description:"申请退款金额(分)"`
|
||||
ApprovedRefundAmount *int64 `json:"approved_refund_amount,omitempty" description:"审批实际退款金额(分)"`
|
||||
RefundVoucherKey string `json:"refund_voucher_key,omitempty" description:"退款凭证对象存储file_key"`
|
||||
RefundVoucherKey []string `json:"refund_voucher_key" description:"退款凭证对象存储file_key列表(最多5个)"`
|
||||
RefundReason string `json:"refund_reason" description:"退款原因"`
|
||||
Status int `json:"status" description:"状态 (1:待审批, 2:已通过, 3:已拒绝, 4:已退回)"`
|
||||
StatusName string `json:"status_name" description:"状态名称(中文)"`
|
||||
|
||||
@@ -33,6 +33,7 @@ type IotCardImportTask struct {
|
||||
StorageKey string `gorm:"column:storage_key;type:varchar(500);comment:对象存储文件路径" json:"storage_key,omitempty"`
|
||||
CardCategory string `gorm:"column:card_category;type:varchar(20);default:normal;not null;comment:卡业务类型(normal/industry)" json:"card_category"`
|
||||
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;comment:导入批次实名策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)" json:"realname_policy"`
|
||||
CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:操作人姓名快照" json:"creator_name"`
|
||||
}
|
||||
|
||||
// CardItem 卡信息(ICCID + MSISDN + VirtualNo)
|
||||
|
||||
@@ -70,8 +70,8 @@ type Order struct {
|
||||
// 支付配置
|
||||
PaymentConfigID *uint `gorm:"column:payment_config_id;index;comment:支付配置ID(关联tb_wechat_config.id)" json:"payment_config_id,omitempty"`
|
||||
|
||||
// 线下支付凭证对象存储 file_key(仅线下支付订单必填)
|
||||
PaymentVoucherKey string `gorm:"column:payment_voucher_key;type:varchar(500);comment:线下支付凭证对象存储file_key" json:"payment_voucher_key,omitempty"`
|
||||
// 线下支付凭证对象存储 file_key 列表(最多5个,仅线下支付订单必填)
|
||||
PaymentVoucherKey StringJSONBArray `gorm:"column:payment_voucher_key;type:jsonb;comment:线下支付凭证对象存储file_key列表(jsonb存储[]string)" json:"payment_voucher_key"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
|
||||
36
internal/model/order_package_invalidate_task.go
Normal file
36
internal/model/order_package_invalidate_task.go
Normal file
@@ -0,0 +1,36 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// OrderPackageInvalidateTask 订单套餐批量失效任务模型
|
||||
type OrderPackageInvalidateTask struct {
|
||||
ID uint `gorm:"column:id;primaryKey" json:"id"`
|
||||
TaskNo string `gorm:"column:task_no;type:varchar(50);uniqueIndex;not null;comment:任务编号" json:"task_no"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;comment:任务状态 (1:待处理, 2:处理中, 3:已完成, 4:失败)" json:"status"`
|
||||
TotalCount int `gorm:"column:total_count;type:int;not null;default:0;comment:总行数" json:"total_count"`
|
||||
SuccessCount int `gorm:"column:success_count;type:int;not null;default:0;comment:成功数" json:"success_count"`
|
||||
FailCount int `gorm:"column:fail_count;type:int;not null;default:0;comment:失败数" json:"fail_count"`
|
||||
FailedItems ImportResultItems `gorm:"column:failed_items;type:jsonb;not null;default:'[]';comment:失败记录详情(jsonb)" json:"failed_items"`
|
||||
FileName string `gorm:"column:file_name;type:varchar(255);not null;default:'';comment:原始文件名" json:"file_name"`
|
||||
StorageKey string `gorm:"column:storage_key;type:varchar(500);not null;default:'';comment:CSV 文件对象存储路径" json:"storage_key"`
|
||||
VoucherKeys StringJSONBArray `gorm:"column:voucher_keys;type:jsonb;not null;default:'[]';comment:支付凭证对象存储Key列表(最多5个)" json:"voucher_keys"`
|
||||
Remark string `gorm:"column:remark;type:text;not null;default:'';comment:运营备注(创建时填写,只读)" json:"remark"`
|
||||
CreatorName string `gorm:"column:creator_name;type:varchar(100);not null;default:'';comment:操作人姓名快照" json:"creator_name"`
|
||||
ErrorMessage string `gorm:"column:error_message;type:text;not null;default:'';comment:任务级错误信息" json:"error_message"`
|
||||
StartedAt *time.Time `gorm:"column:started_at;comment:开始处理时间" json:"started_at"`
|
||||
CompletedAt *time.Time `gorm:"column:completed_at;comment:完成时间" json:"completed_at"`
|
||||
Creator uint `gorm:"column:creator;not null;default:0;comment:创建人ID" json:"creator"`
|
||||
Updater uint `gorm:"column:updater;not null;default:0;comment:更新人ID" json:"updater"`
|
||||
CreatedAt time.Time `gorm:"column:created_at;not null;default:CURRENT_TIMESTAMP" json:"created_at"`
|
||||
UpdatedAt time.Time `gorm:"column:updated_at;not null;default:CURRENT_TIMESTAMP" json:"updated_at"`
|
||||
DeletedAt gorm.DeletedAt `gorm:"column:deleted_at;index" json:"deleted_at,omitempty"`
|
||||
}
|
||||
|
||||
// TableName 指定表名
|
||||
func (OrderPackageInvalidateTask) TableName() string {
|
||||
return "tb_order_package_invalidate_task"
|
||||
}
|
||||
@@ -31,7 +31,7 @@ type RefundRequest struct {
|
||||
ApprovedRefundAmount *int64 `gorm:"column:approved_refund_amount;type:bigint;comment:审批实际退款金额(分)" json:"approved_refund_amount,omitempty"`
|
||||
|
||||
// 退款凭证、原因和状态
|
||||
RefundVoucherKey string `gorm:"column:refund_voucher_key;type:varchar(500);not null;default:'';comment:退款凭证对象存储file_key" json:"refund_voucher_key,omitempty"`
|
||||
RefundVoucherKey StringJSONBArray `gorm:"column:refund_voucher_key;type:jsonb;not null;comment:退款凭证对象存储file_key列表(jsonb存储[]string)" json:"refund_voucher_key"`
|
||||
RefundReason string `gorm:"column:refund_reason;type:text;comment:退款原因" json:"refund_reason"`
|
||||
Status int `gorm:"column:status;type:int;not null;default:1;index;comment:状态 1-待审批 2-已通过 3-已拒绝 4-已退回" json:"status"`
|
||||
|
||||
|
||||
32
internal/model/types.go
Normal file
32
internal/model/types.go
Normal file
@@ -0,0 +1,32 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"database/sql/driver"
|
||||
"encoding/json"
|
||||
)
|
||||
|
||||
// StringJSONBArray 用于将 []string 与 PostgreSQL jsonb 列互转
|
||||
// 读写时通过 Scan/Value 完成序列化,空切片序列化为 []
|
||||
type StringJSONBArray []string
|
||||
|
||||
// Value 写入数据库时序列化为 JSON
|
||||
func (a StringJSONBArray) Value() (driver.Value, error) {
|
||||
if a == nil {
|
||||
return "[]", nil
|
||||
}
|
||||
return json.Marshal(a)
|
||||
}
|
||||
|
||||
// Scan 从数据库读取时反序列化
|
||||
func (a *StringJSONBArray) Scan(value any) error {
|
||||
if value == nil {
|
||||
*a = StringJSONBArray{}
|
||||
return nil
|
||||
}
|
||||
b, ok := value.([]byte)
|
||||
if !ok {
|
||||
*a = StringJSONBArray{}
|
||||
return nil
|
||||
}
|
||||
return json.Unmarshal(b, a)
|
||||
}
|
||||
@@ -81,6 +81,30 @@ func (s *PollingLifecycleService) OnCardStatusChanged(ctx context.Context, cardI
|
||||
s.enqueueCard(ctx, card)
|
||||
}
|
||||
|
||||
// OnBatchCardsStatusChanged 批量卡状态变化:单次 Redis pipeline 清理 + 单次 DB 批量查询
|
||||
// 替代 N 次 OnCardStatusChanged 调用,避免批量分配/回收时打满 DB 连接池
|
||||
func (s *PollingLifecycleService) OnBatchCardsStatusChanged(ctx context.Context, cardIDs []uint) {
|
||||
if len(cardIDs) == 0 {
|
||||
return
|
||||
}
|
||||
// 单次 pipeline:批量从当前分片队列移除并清理缓存
|
||||
if err := s.queueMgr.RemoveBatchFromCurrentShardQueues(ctx, cardIDs); err != nil {
|
||||
s.logger.Warn("批量卡状态变化:从队列移除失败", zap.Int("count", len(cardIDs)), zap.Error(err))
|
||||
}
|
||||
// 单次 DB 查询:批量加载所有卡信息
|
||||
cards, err := s.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||
if err != nil {
|
||||
s.logger.Error("批量卡状态变化:加载卡信息失败", zap.Int("count", len(cardIDs)), zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, card := range cards {
|
||||
if !s.shouldEnqueue(ctx, card) {
|
||||
continue
|
||||
}
|
||||
s.enqueueCard(ctx, card)
|
||||
}
|
||||
}
|
||||
|
||||
// OnCardDeleted 卡删除后移除所有队列并清理缓存
|
||||
func (s *PollingLifecycleService) OnCardDeleted(ctx context.Context, cardID uint) {
|
||||
if err := s.queueMgr.OnCardDeleted(ctx, cardID); err != nil {
|
||||
|
||||
@@ -17,6 +17,12 @@ import (
|
||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||
)
|
||||
|
||||
// TrafficSyncer 套餐失效前流量同步接口
|
||||
// 在主套餐标记为已过期之前,从 Gateway 拉取最新流量写入 DB
|
||||
type TrafficSyncer interface {
|
||||
RefreshCardDataByID(ctx context.Context, cardID uint) error
|
||||
}
|
||||
|
||||
// PackageActivationHandler 套餐激活检查处理器
|
||||
// 任务 19: 处理主套餐过期、加油包级联失效、待生效主套餐激活
|
||||
type PackageActivationHandler struct {
|
||||
@@ -27,6 +33,7 @@ type PackageActivationHandler struct {
|
||||
deviceSimBinding *postgres.DeviceSimBindingStore
|
||||
activationService *packagepkg.ActivationService
|
||||
stopResumeCallback packagepkg.StopResumeCallback // 停复机回调,用于套餐过期后主动停机
|
||||
trafficSyncer TrafficSyncer // 套餐失效前流量同步,可选
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
@@ -60,6 +67,48 @@ func NewPackageActivationHandler(
|
||||
}
|
||||
}
|
||||
|
||||
// SetTrafficSyncer 注入套餐失效前流量同步器(在 Start 前调用)
|
||||
func (h *PackageActivationHandler) SetTrafficSyncer(syncer TrafficSyncer) {
|
||||
h.trafficSyncer = syncer
|
||||
}
|
||||
|
||||
// syncTrafficBeforeExpiry 套餐失效前从 Gateway 同步一次最新流量
|
||||
// iot_card 直接同步;device 遍历所有绑定卡同步
|
||||
// 失败只记录警告,不阻断失效流程
|
||||
func (h *PackageActivationHandler) syncTrafficBeforeExpiry(ctx context.Context, carrierType string, carrierID uint) {
|
||||
if h.trafficSyncer == nil {
|
||||
return
|
||||
}
|
||||
|
||||
if carrierType == constants.AssetTypeIotCard {
|
||||
if err := h.trafficSyncer.RefreshCardDataByID(ctx, carrierID); err != nil {
|
||||
h.logger.Warn("套餐失效前流量同步失败",
|
||||
zap.String("carrier_type", carrierType),
|
||||
zap.Uint("carrier_id", carrierID),
|
||||
zap.Error(err))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if carrierType == constants.AssetTypeDevice {
|
||||
bindings, err := h.deviceSimBinding.ListByDeviceID(ctx, carrierID)
|
||||
if err != nil {
|
||||
h.logger.Warn("套餐失效前查询设备绑定卡失败",
|
||||
zap.Uint("device_id", carrierID),
|
||||
zap.Error(err))
|
||||
return
|
||||
}
|
||||
for _, b := range bindings {
|
||||
if err := h.trafficSyncer.RefreshCardDataByID(ctx, b.IotCardID); err != nil {
|
||||
h.logger.Warn("套餐失效前流量同步失败(设备绑定卡)",
|
||||
zap.Uint("device_id", carrierID),
|
||||
zap.Uint("card_id", b.IotCardID),
|
||||
zap.Error(err))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// HandlePackageActivationCheck 任务 19.2-19.5: 处理套餐激活检查
|
||||
// 每 10 秒调度一次,检查过期主套餐并激活下一个待生效主套餐
|
||||
func (h *PackageActivationHandler) HandlePackageActivationCheck(ctx context.Context) error {
|
||||
@@ -145,20 +194,21 @@ func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Con
|
||||
continue
|
||||
}
|
||||
|
||||
// 检查该载体是否已有生效主套餐
|
||||
// 检查该载体是否已有占位主套餐(生效中或已用完均视为占位,不允许激活待生效套餐)
|
||||
var activeCount int64
|
||||
var countErr error
|
||||
occupiedStatuses := []int{constants.PackageUsageStatusActive, constants.PackageUsageStatusDepleted}
|
||||
if key.carrierType == "iot_card" {
|
||||
countErr = h.db.WithContext(ctx).
|
||||
Model(&model.PackageUsage{}).
|
||||
Where("status = ?", constants.PackageUsageStatusActive).
|
||||
Where("status IN ?", occupiedStatuses).
|
||||
Where("master_usage_id IS NULL").
|
||||
Where("iot_card_id = ?", key.carrierID).
|
||||
Count(&activeCount).Error
|
||||
} else {
|
||||
countErr = h.db.WithContext(ctx).
|
||||
Model(&model.PackageUsage{}).
|
||||
Where("status = ?", constants.PackageUsageStatusActive).
|
||||
Where("status IN ?", occupiedStatuses).
|
||||
Where("master_usage_id IS NULL").
|
||||
Where("device_id = ?", key.carrierID).
|
||||
Count(&activeCount).Error
|
||||
@@ -171,7 +221,7 @@ func (h *PackageActivationHandler) findAndActivateOrphanPackages(ctx context.Con
|
||||
continue
|
||||
}
|
||||
|
||||
// 已有生效套餐,跳过
|
||||
// 已有生效或已用完(占位)套餐,跳过
|
||||
if activeCount > 0 {
|
||||
continue
|
||||
}
|
||||
@@ -219,10 +269,15 @@ func (h *PackageActivationHandler) findExpiredMainPackages(ctx context.Context)
|
||||
}
|
||||
|
||||
// processExpiredPackage 处理单个过期套餐
|
||||
// triggerStopAfterExpiry 在事务提交后才调用,避免 goroutine 在 TX 提交前读到脏数据
|
||||
// 流程:先同步最新流量 → 事务内标记过期/失效/激活下一个 → 事务提交后触发停机
|
||||
func (h *PackageActivationHandler) processExpiredPackage(ctx context.Context, pkg *model.PackageUsage) error {
|
||||
carrierType, carrierID := h.getCarrierInfo(pkg)
|
||||
|
||||
// 失效前先从 Gateway 同步一次最新流量,确保 data_usage_mb 精确
|
||||
if carrierType != "" && carrierID > 0 {
|
||||
h.syncTrafficBeforeExpiry(ctx, carrierType, carrierID)
|
||||
}
|
||||
|
||||
err := h.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
// 任务 19.3: 更新过期主套餐状态为 Expired (status=3)
|
||||
if err := tx.Model(pkg).Update("status", constants.PackageUsageStatusExpired).Error; err != nil {
|
||||
|
||||
@@ -125,6 +125,9 @@ func RegisterAdminRoutes(router fiber.Router, handlers *bootstrap.Handlers, midd
|
||||
if handlers.Refund != nil {
|
||||
registerRefundRoutes(authGroup, handlers.Refund, doc, basePath)
|
||||
}
|
||||
if handlers.OrderPackageInvalidate != nil {
|
||||
registerOrderPackageInvalidateRoutes(authGroup, handlers.OrderPackageInvalidate, doc, basePath)
|
||||
}
|
||||
if handlers.SuperAdmin != nil {
|
||||
registerSuperAdminRoutes(authGroup, handlers.SuperAdmin, doc, basePath)
|
||||
}
|
||||
|
||||
@@ -52,4 +52,12 @@ func registerAgentRechargeRoutes(router fiber.Router, handler *admin.AgentRechar
|
||||
Output: new(dto.AgentRechargeResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(group, doc, groupPath, "POST", "/:id/reject", handler.Reject, RouteSpec{
|
||||
Summary: "驳回代理充值订单",
|
||||
Tags: []string{"代理预充值"},
|
||||
Input: new(dto.AgentRechargeRejectParams),
|
||||
Output: nil,
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
|
||||
40
internal/routes/order_package_invalidate.go
Normal file
40
internal/routes/order_package_invalidate.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package routes
|
||||
|
||||
import (
|
||||
"github.com/gofiber/fiber/v2"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||
)
|
||||
|
||||
// registerOrderPackageInvalidateRoutes 注册订单套餐批量失效任务路由
|
||||
func registerOrderPackageInvalidateRoutes(router fiber.Router, handler *admin.OrderPackageInvalidateHandler, doc *openapi.Generator, basePath string) {
|
||||
group := router.Group("/order-package-invalidate-tasks")
|
||||
groupPath := basePath + "/order-package-invalidate-tasks"
|
||||
|
||||
Register(group, doc, groupPath, "POST", "", handler.Create, RouteSpec{
|
||||
Summary: "创建订单套餐批量失效任务",
|
||||
Description: "上传单列 CSV 文件(列名 order_no),批量将订单下的非终态套餐标记为已失效(status=4)。",
|
||||
Tags: []string{"订单套餐失效"},
|
||||
Input: new(dto.CreateOrderPackageInvalidateTaskRequest),
|
||||
Output: new(dto.OrderPackageInvalidateTaskResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(group, doc, groupPath, "GET", "", handler.List, RouteSpec{
|
||||
Summary: "查询订单套餐失效任务列表",
|
||||
Tags: []string{"订单套餐失效"},
|
||||
Input: new(dto.ListOrderPackageInvalidateTaskRequest),
|
||||
Output: new(dto.OrderPackageInvalidateTaskListResponse),
|
||||
Auth: true,
|
||||
})
|
||||
|
||||
Register(group, doc, groupPath, "GET", "/:id", handler.GetByID, RouteSpec{
|
||||
Summary: "查询订单套餐失效任务详情",
|
||||
Tags: []string{"订单套餐失效"},
|
||||
Input: new(dto.IDReq),
|
||||
Output: new(dto.OrderPackageInvalidateTaskDetailResponse),
|
||||
Auth: true,
|
||||
})
|
||||
}
|
||||
@@ -484,7 +484,7 @@ func (s *Service) resolveOpenAPICard(ctx context.Context, cardNo string) (*model
|
||||
return nil, errors.New(errors.CodeInternalError, "开放接口资产解析依赖未配置")
|
||||
}
|
||||
|
||||
resolved, err := s.assetService.Resolve(ctx, identifier)
|
||||
resolved, err := s.assetService.Resolve(ctx, identifier, false)
|
||||
if err != nil {
|
||||
if appErr, ok := err.(*errors.AppError); ok && appErr.Code == errors.CodeNotFound {
|
||||
return s.resolveOpenAPICardByIdentifier(ctx, identifier)
|
||||
|
||||
@@ -6,7 +6,6 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
"math/rand"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/redis/go-redis/v9"
|
||||
@@ -96,7 +95,7 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques
|
||||
}
|
||||
|
||||
// 线下充值必须上传支付凭证
|
||||
if req.PaymentMethod == "offline" && strings.TrimSpace(req.PaymentVoucherKey) == "" {
|
||||
if req.PaymentMethod == "offline" && len(req.PaymentVoucherKey) == 0 {
|
||||
return nil, errors.New(errors.CodeInvalidParam, "线下充值必须上传支付凭证")
|
||||
}
|
||||
|
||||
@@ -147,7 +146,8 @@ func (s *Service) Create(ctx context.Context, req *dto.CreateAgentRechargeReques
|
||||
PaymentMethod: req.PaymentMethod,
|
||||
PaymentChannel: &paymentChannel,
|
||||
PaymentConfigID: paymentConfigID,
|
||||
PaymentVoucherKey: strings.TrimSpace(req.PaymentVoucherKey),
|
||||
PaymentVoucherKey: model.StringJSONBArray(req.PaymentVoucherKey),
|
||||
Remark: req.Remark,
|
||||
Status: constants.RechargeStatusPending,
|
||||
ShopIDTag: wallet.ShopIDTag,
|
||||
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||
@@ -387,6 +387,35 @@ func (s *Service) HandlePaymentCallback(ctx context.Context, rechargeNo string,
|
||||
return nil
|
||||
}
|
||||
|
||||
// Reject 驳回代理充值订单
|
||||
// 仅 status=1(待支付)的订单可驳回,驳回后状态变为 6(已驳回),为终态
|
||||
func (s *Service) Reject(ctx context.Context, id uint, rejectionReason string) error {
|
||||
record, err := s.agentRechargeStore.GetByID(ctx, id)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeNotFound, "充值记录不存在")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败")
|
||||
}
|
||||
|
||||
if record.Status != constants.RechargeStatusPending {
|
||||
return errors.New(errors.CodeInvalidStatus, "仅待支付订单可驳回")
|
||||
}
|
||||
|
||||
if err := s.agentRechargeStore.UpdateStatusWithRejection(ctx, id, rejectionReason); err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return errors.New(errors.CodeInvalidStatus, "仅待支付订单可驳回")
|
||||
}
|
||||
return errors.Wrap(errors.CodeDatabaseError, err, "驳回充值订单失败")
|
||||
}
|
||||
|
||||
s.logger.Info("代理充值订单驳回成功",
|
||||
zap.Uint("record_id", id),
|
||||
zap.String("rejection_reason", rejectionReason),
|
||||
)
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetByID 根据ID查询充值订单详情
|
||||
// GET /api/admin/agent-recharges/:id
|
||||
func (s *Service) GetByID(ctx context.Context, id uint) (*dto.AgentRechargeResponse, error) {
|
||||
@@ -486,9 +515,11 @@ func toResponse(record *model.AgentRechargeRecord, shopName string) *dto.AgentRe
|
||||
AgentWalletID: record.AgentWalletID,
|
||||
Amount: record.Amount,
|
||||
PaymentMethod: record.PaymentMethod,
|
||||
PaymentVoucherKey: record.PaymentVoucherKey,
|
||||
PaymentVoucherKey: []string(record.PaymentVoucherKey),
|
||||
Remark: record.Remark,
|
||||
Status: record.Status,
|
||||
StatusName: constants.GetRechargeStatusName(record.Status),
|
||||
RejectionReason: record.RejectionReason,
|
||||
CreatedAt: record.CreatedAt.Format("2006-01-02 15:04:05"),
|
||||
UpdatedAt: record.UpdatedAt.Format("2006-01-02 15:04:05"),
|
||||
}
|
||||
|
||||
@@ -91,7 +91,8 @@ func New(
|
||||
// Resolve 通过任意标识符解析资产
|
||||
// 主路径:查注册表(精确匹配 ICCID 或 VirtualNo)
|
||||
// Fallback:原有跨表 OR 查询(处理 IMEI/SN/MSISDN 等非注册标识符)
|
||||
func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetResolveResponse, error) {
|
||||
// includeUsageSummary 为 true 时额外填充当前世代流量汇总字段
|
||||
func (s *Service) Resolve(ctx context.Context, identifier string, includeUsageSummary bool) (*dto.AssetResolveResponse, error) {
|
||||
if s.assetIdentifierStore != nil {
|
||||
regRecord, regErr := s.assetIdentifierStore.FindByIdentifier(ctx, identifier)
|
||||
if regErr == nil && regRecord != nil {
|
||||
@@ -102,6 +103,9 @@ func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetRes
|
||||
resp, buildErr := s.buildDeviceResolveResponse(ctx, device)
|
||||
if buildErr == nil {
|
||||
resp.Identifier = identifier
|
||||
if includeUsageSummary {
|
||||
s.fillUsageSummary(ctx, resp, "device", device.ID)
|
||||
}
|
||||
}
|
||||
return resp, buildErr
|
||||
}
|
||||
@@ -111,6 +115,9 @@ func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetRes
|
||||
resp, buildErr := s.buildCardResolveResponse(ctx, card)
|
||||
if buildErr == nil {
|
||||
resp.Identifier = identifier
|
||||
if includeUsageSummary {
|
||||
s.fillUsageSummary(ctx, resp, "iot_card", card.ID)
|
||||
}
|
||||
}
|
||||
return resp, buildErr
|
||||
}
|
||||
@@ -123,6 +130,9 @@ func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetRes
|
||||
resp, buildErr := s.buildDeviceResolveResponse(ctx, device)
|
||||
if buildErr == nil {
|
||||
resp.Identifier = identifier
|
||||
if includeUsageSummary {
|
||||
s.fillUsageSummary(ctx, resp, "device", device.ID)
|
||||
}
|
||||
}
|
||||
return resp, buildErr
|
||||
}
|
||||
@@ -135,6 +145,9 @@ func (s *Service) Resolve(ctx context.Context, identifier string) (*dto.AssetRes
|
||||
resp, buildErr := s.buildCardResolveResponse(ctx, &card)
|
||||
if buildErr == nil {
|
||||
resp.Identifier = identifier
|
||||
if includeUsageSummary {
|
||||
s.fillUsageSummary(ctx, resp, "iot_card", card.ID)
|
||||
}
|
||||
}
|
||||
return resp, buildErr
|
||||
}
|
||||
@@ -336,6 +349,19 @@ func (s *Service) fillPackageInfo(ctx context.Context, resp *dto.AssetResolveRes
|
||||
resp.EnableVirtualData = usage.EnableVirtualDataSnapshot
|
||||
}
|
||||
|
||||
// fillUsageSummary 填充当前世代流量汇总字段。
|
||||
// 仅在 includeUsageSummary=true 时被调用,无套餐时返回 0.0(非 null)。
|
||||
func (s *Service) fillUsageSummary(ctx context.Context, resp *dto.AssetResolveResponse, carrierType string, carrierID uint) {
|
||||
usages, err := s.packageUsageStore.GetCurrentGenerationUsagesForSummary(ctx, carrierType, carrierID)
|
||||
if err != nil {
|
||||
logger.GetAppLogger().Error("查询流量汇总失败", zap.String("carrierType", carrierType), zap.Uint("carrierID", carrierID), zap.Error(err))
|
||||
return
|
||||
}
|
||||
totalUsed, totalRemaining := computeUsageSummary(usages)
|
||||
resp.TotalVirtualUsedMB = &totalUsed
|
||||
resp.TotalVirtualRemainingMB = &totalRemaining
|
||||
}
|
||||
|
||||
// fillShopName 填充店铺名称
|
||||
func (s *Service) fillShopName(ctx context.Context, resp *dto.AssetResolveResponse) {
|
||||
if resp.ShopID == nil || *resp.ShopID == 0 {
|
||||
@@ -971,7 +997,7 @@ func (s *Service) GetOrders(ctx context.Context, identifier string, page, pageSi
|
||||
pageSize = 100
|
||||
}
|
||||
|
||||
asset, err := s.Resolve(ctx, identifier)
|
||||
asset, err := s.Resolve(ctx, identifier, false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
16
internal/service/asset/usage_summary.go
Normal file
16
internal/service/asset/usage_summary.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package asset
|
||||
|
||||
import "github.com/break/junhong_cmp_fiber/internal/model"
|
||||
|
||||
// computeUsageSummary 汇总当前世代套餐的虚已用量和虚剩余量。
|
||||
// 对每条记录调用 BuildTrafficMetrics() 获取虚流量字段,累加后计算剩余。
|
||||
func computeUsageSummary(usages []*model.PackageUsage) (totalUsedMB float64, totalRemainingMB float64) {
|
||||
var totalVirtualTotal float64
|
||||
for _, u := range usages {
|
||||
m := u.BuildTrafficMetrics()
|
||||
totalUsedMB += m.VirtualUsedMB
|
||||
totalVirtualTotal += float64(m.VirtualTotalMB)
|
||||
}
|
||||
totalRemainingMB = totalVirtualTotal - totalUsedMB
|
||||
return
|
||||
}
|
||||
81
internal/service/asset/usage_summary_test.go
Normal file
81
internal/service/asset/usage_summary_test.go
Normal file
@@ -0,0 +1,81 @@
|
||||
package asset
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||
)
|
||||
|
||||
func TestComputeUsageSummary_EmptyList(t *testing.T) {
|
||||
totalUsed, totalRemaining := computeUsageSummary(nil)
|
||||
if totalUsed != 0.0 {
|
||||
t.Errorf("期望 totalUsed=0.0,实际=%v", totalUsed)
|
||||
}
|
||||
if totalRemaining != 0.0 {
|
||||
t.Errorf("期望 totalRemaining=0.0,实际=%v", totalRemaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeUsageSummary_SingleVirtualPackage(t *testing.T) {
|
||||
// 启用虚流量:真实总量=100MB,虚拟总量=200MB,倍率=0.5
|
||||
// 真实已用=40MB → 虚拟已用=min(40*0.5, 100)=20MB
|
||||
// 虚拟剩余=200-20=180MB
|
||||
usage := &model.PackageUsage{
|
||||
DataLimitMB: 100,
|
||||
DataUsageMB: 40,
|
||||
VirtualTotalMBSnapshot: 200,
|
||||
DisplayGainRatioSnapshot: 0.5,
|
||||
EnableVirtualDataSnapshot: true,
|
||||
}
|
||||
totalUsed, totalRemaining := computeUsageSummary([]*model.PackageUsage{usage})
|
||||
if totalUsed != 20.0 {
|
||||
t.Errorf("期望 totalUsed=20.0,实际=%v", totalUsed)
|
||||
}
|
||||
if totalRemaining != 180.0 {
|
||||
t.Errorf("期望 totalRemaining=180.0,实际=%v", totalRemaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeUsageSummary_SingleNonVirtualPackage(t *testing.T) {
|
||||
// 未启用虚流量:退化为真实值
|
||||
// 真实总量=100MB,真实已用=40MB → 虚拟已用=40MB,虚拟剩余=60MB
|
||||
usage := &model.PackageUsage{
|
||||
DataLimitMB: 100,
|
||||
DataUsageMB: 40,
|
||||
EnableVirtualDataSnapshot: false,
|
||||
}
|
||||
totalUsed, totalRemaining := computeUsageSummary([]*model.PackageUsage{usage})
|
||||
if totalUsed != 40.0 {
|
||||
t.Errorf("期望 totalUsed=40.0,实际=%v", totalUsed)
|
||||
}
|
||||
if totalRemaining != 60.0 {
|
||||
t.Errorf("期望 totalRemaining=60.0,实际=%v", totalRemaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComputeUsageSummary_MultiplePackages(t *testing.T) {
|
||||
// 套餐1(启用虚流量):VirtualUsed=20, VirtualTotal=200
|
||||
// 套餐2(未启用虚流量):VirtualUsed=10, VirtualTotal=50
|
||||
// 汇总:totalUsed=30, totalRemaining=(200+50)-30=220
|
||||
usages := []*model.PackageUsage{
|
||||
{
|
||||
DataLimitMB: 100,
|
||||
DataUsageMB: 40,
|
||||
VirtualTotalMBSnapshot: 200,
|
||||
DisplayGainRatioSnapshot: 0.5,
|
||||
EnableVirtualDataSnapshot: true,
|
||||
},
|
||||
{
|
||||
DataLimitMB: 50,
|
||||
DataUsageMB: 10,
|
||||
EnableVirtualDataSnapshot: false,
|
||||
},
|
||||
}
|
||||
totalUsed, totalRemaining := computeUsageSummary(usages)
|
||||
if totalUsed != 30.0 {
|
||||
t.Errorf("期望 totalUsed=30.0,实际=%v", totalUsed)
|
||||
}
|
||||
if totalRemaining != 220.0 {
|
||||
t.Errorf("期望 totalRemaining=220.0,实际=%v", totalRemaining)
|
||||
}
|
||||
}
|
||||
@@ -128,7 +128,7 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
|
||||
}
|
||||
|
||||
skipCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{})
|
||||
assetInfo, err := s.assetService.Resolve(skipCtx, strings.TrimSpace(req.Identifier))
|
||||
assetInfo, err := s.assetService.Resolve(skipCtx, strings.TrimSpace(req.Identifier), false)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -229,6 +229,18 @@ func (s *Service) CreateOrder(ctx context.Context, customerID uint, req *dto.Cli
|
||||
}()
|
||||
|
||||
if forceRecharge.NeedForceRecharge {
|
||||
// 强充第三方支付方式与资产类型必须匹配:单卡只允许支付宝,设备只允许微信(已暂时注释)
|
||||
// switch assetInfo.AssetType {
|
||||
// case "card":
|
||||
// if req.PaymentMethod != model.PaymentMethodAlipay {
|
||||
// return nil, errors.New(errors.CodeInvalidParam, "单卡资产强充只支持支付宝支付")
|
||||
// }
|
||||
// case constants.ResourceTypeDevice:
|
||||
// if req.PaymentMethod == model.PaymentMethodAlipay {
|
||||
// return nil, errors.New(errors.CodeInvalidParam, "设备资产强充只支持微信支付")
|
||||
// }
|
||||
// }
|
||||
|
||||
if req.PaymentMethod == model.PaymentMethodAlipay {
|
||||
// 支付宝强充:不需要 app_type / OpenID
|
||||
activeConfig, err := s.wechatConfigService.GetActiveConfig(skipCtx)
|
||||
@@ -1060,7 +1072,7 @@ func (s *Service) createAlipayForceRechargeOrder(
|
||||
func (s *Service) ListOrders(ctx context.Context, customerID uint, req *dto.ClientOrderListRequest) ([]dto.ClientOrderListItem, int64, error) {
|
||||
skipCtx := context.WithValue(ctx, constants.ContextKeySubordinateShopIDs, []uint{})
|
||||
|
||||
assetInfo, err := s.assetService.Resolve(skipCtx, strings.TrimSpace(req.Identifier))
|
||||
assetInfo, err := s.assetService.Resolve(skipCtx, strings.TrimSpace(req.Identifier), false)
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
@@ -1231,7 +1243,7 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
|
||||
if err == nil && device != nil {
|
||||
assetRealnamePolicy = device.RealnamePolicy
|
||||
// 设备的 RealNameStatus 需要通过 Resolve 获取(从当前卡派生)
|
||||
resolved, resolveErr := s.assetService.Resolve(skipCtx, device.VirtualNo)
|
||||
resolved, resolveErr := s.assetService.Resolve(skipCtx, device.VirtualNo, false)
|
||||
if resolveErr == nil && resolved != nil {
|
||||
assetRealnameStatus = resolved.RealNameStatus
|
||||
}
|
||||
@@ -1241,6 +1253,20 @@ func (s *Service) PayOrder(ctx context.Context, customerID uint, orderID uint, r
|
||||
return nil, errors.New(errors.CodeNeedRealname)
|
||||
}
|
||||
|
||||
// 第三方支付方式与资产类型必须匹配:单卡只允许支付宝,设备只允许微信(已暂时注释)
|
||||
// if req.PaymentMethod == model.PaymentMethodWechat || req.PaymentMethod == model.PaymentMethodAlipay {
|
||||
// switch order.OrderType {
|
||||
// case model.OrderTypeSingleCard:
|
||||
// if req.PaymentMethod != model.PaymentMethodAlipay {
|
||||
// return nil, errors.New(errors.CodeInvalidParam, "单卡资产只支持支付宝支付")
|
||||
// }
|
||||
// case model.OrderTypeDevice:
|
||||
// if req.PaymentMethod != model.PaymentMethodWechat {
|
||||
// return nil, errors.New(errors.CodeInvalidParam, "设备资产只支持微信支付")
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
switch req.PaymentMethod {
|
||||
case model.PaymentMethodWallet:
|
||||
if err := s.orderPaymentService.WalletPay(skipCtx, orderID, model.BuyerTypePersonal, customerID); err != nil {
|
||||
|
||||
@@ -60,6 +60,7 @@ func (s *Service) CreateImportTask(ctx context.Context, req *dto.ImportDeviceReq
|
||||
FileName: fileName,
|
||||
StorageKey: req.FileKey,
|
||||
RealnamePolicy: req.RealnamePolicy,
|
||||
CreatorName: middleware.GetUsernameFromContext(ctx),
|
||||
}
|
||||
task.Creator = userID
|
||||
task.Updater = userID
|
||||
@@ -190,21 +191,23 @@ func (s *Service) toTaskResponse(task *model.DeviceImportTask) *dto.DeviceImport
|
||||
}
|
||||
|
||||
return &dto.DeviceImportTaskResponse{
|
||||
ID: task.ID,
|
||||
TaskNo: task.TaskNo,
|
||||
Status: task.Status,
|
||||
StatusText: getStatusText(task.Status),
|
||||
BatchNo: task.BatchNo,
|
||||
FileName: task.FileName,
|
||||
TotalCount: task.TotalCount,
|
||||
SuccessCount: task.SuccessCount,
|
||||
SkipCount: task.SkipCount,
|
||||
FailCount: task.FailCount,
|
||||
WarningCount: task.WarningCount,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: completedAt,
|
||||
ErrorMessage: task.ErrorMessage,
|
||||
CreatedAt: task.CreatedAt,
|
||||
ID: task.ID,
|
||||
TaskNo: task.TaskNo,
|
||||
Status: task.Status,
|
||||
StatusText: getStatusText(task.Status),
|
||||
BatchNo: task.BatchNo,
|
||||
RealnamePolicy: task.RealnamePolicy,
|
||||
FileName: task.FileName,
|
||||
TotalCount: task.TotalCount,
|
||||
SuccessCount: task.SuccessCount,
|
||||
SkipCount: task.SkipCount,
|
||||
FailCount: task.FailCount,
|
||||
WarningCount: task.WarningCount,
|
||||
StartedAt: startedAt,
|
||||
CompletedAt: completedAt,
|
||||
ErrorMessage: task.ErrorMessage,
|
||||
CreatorName: task.CreatorName,
|
||||
CreatedAt: task.CreatedAt,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,6 +29,8 @@ type PollingCallback interface {
|
||||
OnCardCreated(ctx context.Context, card *model.IotCard)
|
||||
// OnCardStatusChanged 卡状态变化时的回调
|
||||
OnCardStatusChanged(ctx context.Context, cardID uint)
|
||||
// OnBatchCardsStatusChanged 批量卡状态变化时的回调(批量分配/回收场景)
|
||||
OnBatchCardsStatusChanged(ctx context.Context, cardIDs []uint)
|
||||
// OnCardDeleted 卡删除时的回调
|
||||
OnCardDeleted(ctx context.Context, cardID uint)
|
||||
// OnCardEnabled 卡启用轮询时的回调
|
||||
@@ -597,6 +599,7 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone
|
||||
|
||||
newStatus := constants.IotCardStatusDistributed
|
||||
toShopID := req.ToShopID
|
||||
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
|
||||
|
||||
err = s.db.Transaction(func(tx *gorm.DB) error {
|
||||
txIotCardStore := postgres.NewIotCardStore(tx, nil)
|
||||
@@ -606,7 +609,6 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone
|
||||
return err
|
||||
}
|
||||
|
||||
allocationNo := s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate)
|
||||
records := s.buildAllocationRecords(cards, cardIDs, operatorShopID, toShopID, operatorID, allocationNo, req.Remark)
|
||||
return txRecordStore.BatchCreate(ctx, records)
|
||||
})
|
||||
@@ -631,11 +633,14 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone
|
||||
}
|
||||
s.iotCardStore.InvalidateListCountCache(ctx)
|
||||
|
||||
// 通知轮询调度器状态变化(卡被分配后可能需要重新匹配配置)
|
||||
// 通知轮询调度器状态变化(异步执行 + 批量操作,避免 N 次单卡 DB 查询打满连接池)
|
||||
if s.pollingCallback != nil && len(cardIDs) > 0 {
|
||||
for _, cardID := range cardIDs {
|
||||
s.pollingCallback.OnCardStatusChanged(ctx, cardID)
|
||||
}
|
||||
cardIDsCopy := make([]uint, len(cardIDs))
|
||||
copy(cardIDsCopy, cardIDs)
|
||||
cb := s.pollingCallback
|
||||
go func() {
|
||||
cb.OnBatchCardsStatusChanged(context.Background(), cardIDsCopy)
|
||||
}()
|
||||
}
|
||||
|
||||
shopMap := s.loadShopNames(ctx, cards)
|
||||
@@ -672,7 +677,7 @@ func (s *Service) AllocateCards(ctx context.Context, req *dto.AllocateStandalone
|
||||
TotalCount: len(cards),
|
||||
SuccessCount: len(cardIDs),
|
||||
FailCount: len(failedItems),
|
||||
AllocationNo: s.assetAllocationRecordStore.GenerateAllocationNo(ctx, constants.AssetAllocationTypeAllocate),
|
||||
AllocationNo: allocationNo,
|
||||
FailedItems: failedItems,
|
||||
}, nil
|
||||
}
|
||||
@@ -844,11 +849,14 @@ func (s *Service) RecallCards(ctx context.Context, req *dto.RecallStandaloneCard
|
||||
}
|
||||
s.iotCardStore.InvalidateListCountCache(ctx)
|
||||
|
||||
// 通知轮询调度器状态变化(卡被回收后可能需要重新匹配配置)
|
||||
// 通知轮询调度器状态变化(异步批量执行,避免回收大批卡时打满 DB 连接池)
|
||||
if s.pollingCallback != nil && len(cardIDs) > 0 {
|
||||
for _, cardID := range cardIDs {
|
||||
s.pollingCallback.OnCardStatusChanged(ctx, cardID)
|
||||
}
|
||||
cardIDsCopy := make([]uint, len(cardIDs))
|
||||
copy(cardIDsCopy, cardIDs)
|
||||
cb := s.pollingCallback
|
||||
go func() {
|
||||
cb.OnBatchCardsStatusChanged(context.Background(), cardIDsCopy)
|
||||
}()
|
||||
}
|
||||
|
||||
shopMap := s.loadShopNames(ctx, successCards)
|
||||
@@ -1486,6 +1494,19 @@ func (s *Service) buildCardNotFoundFailedItems(iccids []string) []dto.CardSeries
|
||||
return items
|
||||
}
|
||||
|
||||
// RefreshCardDataByID 通过卡 ID 从 Gateway 同步卡数据
|
||||
// 内部查询 ICCID 后委托给 RefreshCardDataFromGateway,供套餐失效前流量同步使用
|
||||
func (s *Service) RefreshCardDataByID(ctx context.Context, cardID uint) error {
|
||||
card, err := s.iotCardStore.GetByID(ctx, cardID)
|
||||
if err != nil {
|
||||
if err == gorm.ErrRecordNotFound {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
return s.RefreshCardDataFromGateway(ctx, card.ICCID)
|
||||
}
|
||||
|
||||
// RefreshCardDataFromGateway 从 Gateway 完整同步卡数据
|
||||
// 调用网关查询网络状态、实名状态、本月流量,并写回数据库
|
||||
// 流量采用增量计算(与轮询逻辑一致):increment = 当前网关读数 - 上次网关读数
|
||||
|
||||
@@ -132,6 +132,14 @@ func (s *StopResumeService) EvaluateAndAct(ctx context.Context, card *model.IotC
|
||||
}
|
||||
}
|
||||
|
||||
// isRiskGatewayExtend 判断网关扩展状态是否为风险状态(风险停机或已销户)
|
||||
// 独立卡命中此状态时禁止发起复机
|
||||
func isRiskGatewayExtend(extend string) bool {
|
||||
trimmed := strings.TrimSpace(extend)
|
||||
return trimmed == constants.GatewayCardExtendRiskStop ||
|
||||
trimmed == constants.GatewayCardExtendCancelled
|
||||
}
|
||||
|
||||
// isPollingStopReason 判断停机原因是否为轮询系统引起(可自动复机)
|
||||
func isPollingStopReason(reason string) bool {
|
||||
switch reason {
|
||||
@@ -692,6 +700,30 @@ func (s *StopResumeService) StartMachineSeparatedCard(ctx context.Context, card
|
||||
}
|
||||
|
||||
gatewayExtend := strings.TrimSpace(card.GatewayExtend)
|
||||
|
||||
// 独立卡处于风险停机或已销户状态时,优先返回明确的拒绝原因
|
||||
if card.IsStandalone && isRiskGatewayExtend(gatewayExtend) {
|
||||
var denyMsg string
|
||||
if gatewayExtend == constants.GatewayCardExtendRiskStop {
|
||||
denyMsg = "该卡已被运营商风险停机,不允许复机"
|
||||
} else {
|
||||
denyMsg = "该卡已被运营商销户,不允许复机"
|
||||
}
|
||||
denyErr := errors.New(errors.CodeForbidden, denyMsg)
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "机卡分离复机被拒绝(风险状态)",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
})
|
||||
return denyErr
|
||||
}
|
||||
|
||||
if gatewayExtend != constants.GatewayCardExtendMachineSeparated {
|
||||
denyErr := errors.New(errors.CodeForbidden, constants.AgentOpenAPIResumeOnlyMachineSeparatedMessage)
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
@@ -880,6 +912,29 @@ func (s *StopResumeService) ManualStartCard(ctx context.Context, iccid string) e
|
||||
return denyErr
|
||||
}
|
||||
|
||||
// 独立卡处于风险停机或已销户状态时,拒绝复机
|
||||
if card.IsStandalone && isRiskGatewayExtend(card.GatewayExtend) {
|
||||
var denyMsg string
|
||||
if strings.TrimSpace(card.GatewayExtend) == constants.GatewayCardExtendRiskStop {
|
||||
denyMsg = "该卡已被运营商风险停机,不允许复机"
|
||||
} else {
|
||||
denyMsg = "该卡已被运营商销户,不允许复机"
|
||||
}
|
||||
denyErr := errors.New(errors.CodeForbidden, denyMsg)
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
s.logCardAudit(ctx, assetAuditSvc.BuildLogParams{
|
||||
OperationType: constants.AssetAuditOpCardManualStart,
|
||||
OperationDesc: "手动复机被拒绝",
|
||||
ResultStatus: constants.AssetAuditResultDenied,
|
||||
ErrorCode: errorCode,
|
||||
ErrorMsg: errorMsg,
|
||||
AssetID: card.ID,
|
||||
AssetIdentifier: card.ICCID,
|
||||
BeforeData: cardSnapshot(card),
|
||||
})
|
||||
return denyErr
|
||||
}
|
||||
|
||||
if card.RealNameStatus != constants.RealNameStatusVerified {
|
||||
denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作")
|
||||
errorCode, errorMsg := assetAuditSvc.BuildErrorInfo(denyErr)
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user