16 Commits

Author SHA1 Message Date
d022cc8788 迭代方案确认 2026-07-17 16:39:41 +08:00
bcf3e31db6 迭代计划准备 2026-07-16 15:08:07 +08:00
c4f430ccb3 迭代计划准备 2026-07-16 15:07:59 +08:00
1a9db9328e 批量购买 2026-07-15 12:00:05 +08:00
2e130b98f5 临时备份一次 2026-07-13 12:01:18 +09:00
5cdcdad534 Create 业务需求.md 2026-07-11 15:10:24 +08:00
d2e08dbbec skill提交
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 6m22s
2026-07-11 15:32:56 +09:00
026d4908d8 删除
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 53s
2026-07-11 12:28:38 +09:00
31232ea899 优化迁移脚本速度
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 53s
2026-07-10 13:10:00 +09:00
b38df737e1 先短暂去除限制,上传迁移脚本
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m51s
2026-07-09 18:23:29 +09:00
346156ee9b 卡只允许支付宝支付,设备只允许微信支付,钱包充值也遵循这个规则
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m53s
2026-07-03 10:26:48 +09:00
0d79130e07 入参
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m46s
2026-07-02 17:02:13 +09:00
b3fb8c7a82 资产详情新增两个字段
Some checks failed
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Has been cancelled
2026-07-02 16:49:50 +09:00
44fb21eb6a 修复导入重试的问题
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m53s
2026-07-02 15:13:22 +09:00
8f738ffbe8 导入的问题修复
All checks were successful
构建并部署到测试环境(无 SSH) / build-and-deploy (push) Successful in 7m51s
2026-07-02 13:04:36 +09:00
6db152bb2f chore: 安装 ponytail lazy senior dev 规则 2026-07-01 12:26:19 +09:00
92 changed files with 16257 additions and 1066 deletions

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 13 entry points max. Maximise leverage per entry point."
- Agent 2: "Maximise flexibility — support many use cases and extension."
- Agent 3: "Optimise for the most common caller — make the default case trivial."
- Agent 4 (if applicable): "Design around ports & adapters for cross-seam dependencies."
Include both [LANGUAGE.md](LANGUAGE.md) vocabulary and CONTEXT.md vocabulary in the brief so each sub-agent names things consistently with the architecture language and the project's domain language.
Each sub-agent outputs:
1. Interface (types, methods, params — plus invariants, ordering, error modes)
2. Usage example showing how callers use it
3. What the implementation hides behind the seam
4. Dependency strategy and adapters (see [DEEPENING.md](DEEPENING.md))
5. Trade-offs — where leverage is high, where it's thin
### 3. Present and compare
Present designs sequentially so the user can absorb each one, then compare them in prose. Contrast by **depth** (leverage at the interface), **locality** (where change concentrates), and **seam placement**.
After comparing, give your own recommendation: which design you think is strongest and why. If elements from different designs would combine well, propose a hybrid. Be opinionated — the user wants a strong read, not a menu.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
});
```

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

2
.gitignore vendored
View File

@@ -107,3 +107,5 @@ docs/admin-openapi.yaml
# /teach skill 的个人学习工作区,不进入项目提交历史
.claude/teach-workspace/
scripts/batch_package_purchase/assets.example_购买结果_20260715_115804.csv
scripts/batch_package_purchase/assets.example_购买结果_20260715_115814.csv

View File

@@ -72,18 +72,23 @@ handlers := &bootstrap.Handlers{
- 使用 `net/http` 替代 Fiber
- 使用 `encoding/json` 替代 sonic除非必要
## 架构分层
## 架构演进MVC 与 DDD 共存
必须遵循以下分层架构:
项目采用**触碰式渐进迁移**,不安排一次性全仓库重构。详细规则见 `docs/7月迭代/独立方案/基础规范/DDD规范.md`
```
Handler → Service → Store → Model
```
### 三条执行通道
- **Handler**: 只处理 HTTP 请求/响应,不包含业务逻辑
- **Service**: 包含所有业务逻辑,支持跨模块调用
- **Store**: 统一管理所有数据访问,支持事务处理
- **Model**: 定义数据结构和 DTO
- **复杂写操作**`Handler → Application UseCase → Domain → Repository/Infrastructure`。适用于状态机、金额、库存、并发不变量、策略和可靠事件。
- **简单写操作**`Handler → Application 事务脚本 → Persistence`。单表 CRUD 不强行创建聚合。
- **读取操作**`Handler → Query → GORM/DTO`。列表、详情、联表、统计、报表和导出不经过聚合根。
### 触碰式迁移约束
1. 禁止主动迁移当前需求未触碰的旧模块;简单字段、筛选和局部修复默认沿用旧结构。
2. 迁移单位是**完整用例**,不是整个模块、文件或数据表;一旦迁移,相关业务不变量必须完整收口,禁止一半留在旧 Service、一半放在 Domain。
3. 当前需求触碰复杂写逻辑时,只迁移完成该需求所需的最小完整业务边界;旧 Service 可暂时作为内部代码迁移门面调用新 UseCase但这不等于必须保留旧 HTTP 接口。
4. 当前需求触碰复杂只读逻辑时,只迁移该查询到 `internal/query/<context>`Query 可直接使用 GORM 做联表、聚合、权限过滤和 DTO 投影。
5. 不为追求 DDD 形式创建无业务价值的接口、工厂和目录;架构选择不明确时先阅读 DDD 规范并写明判断依据。
## 核心原则
@@ -97,7 +102,7 @@ Handler → Service → Store → Model
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()``err.Error()`
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)``errors.Wrap(...)`
- Service/Application/Domain/Query 层禁止向接口调用方直接返回 `fmt.Errorf(...)`,必须转换为 `errors.New(...)``errors.Wrap(...)`
- 约定用法:`errors.New(code[, msg])``errors.Wrap(code, err[, msg])`
### 响应格式
@@ -194,6 +199,7 @@ StatusName string `json:"status_name" description:"状态名称(中文)"` //
- 文档文件名和内容使用中文
- 同步更新 README.md
- 为导出的函数、类型编写文档注释
- 需要进入评审的标准/完整技术方案必须覆盖关键流程、前后端契约、异常闭环、发布回滚和待决策项;简单改动不强行画图。详细要求见 `docs/技术方案评审规范.md`
## 函数复杂度
@@ -225,14 +231,16 @@ StatusName string `json:"status_name" description:"状态名称(中文)"` //
### 错误处理
- [ ] Service 层无 `fmt.Errorf` 对外返回
- [ ] Service/Application/Domain/Query 层无 `fmt.Errorf` 对外返回
- [ ] Handler 层参数校验不泄露细节
- [ ] 错误码使用正确4xx vs 5xx
- [ ] 错误日志完整(包含上下文)
### 代码质量
- [ ] 遵循 Handler → Service → Store → Model 分层
- [ ] 已按判断标准选择复杂写、简单写或 Query 通道,未扩大迁移范围
- [ ] 复杂写规则收口 Domain简单写使用 Application 事务脚本;只读逻辑不经过聚合根
- [ ] Query 负责读取权限、分页和 DTO 投影,写操作仍在 Domain 重新校验
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
- [ ] 常量定义在 `pkg/constants/`
- [ ] 使用 Go 惯用法(非 Java 风格)
@@ -309,7 +317,7 @@ queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, payloadBytes)
**适用场景**:任何敏感操作(账号管理、权限变更、数据删除等)
- Service 层注入 `auditService AuditServiceInterface`,操作成功后调用 `LogOperation()`
- 旧模块在 Service 层、新 DDD 模块在 Application UseCase 中注入审计能力,操作成功后调用 `LogOperation()`
- 必填字段:`OperatorID``OperationType``OperationDesc``BeforeData``AfterData`
- 异步写入Goroutine写入失败不影响业务失败时记录 Error 日志

343
CLAUDE.md
View File

@@ -1,343 +1,8 @@
# Claude 项目规则
---
@AGENTS.md
# junhong_cmp_fiber 项目开发规范
**重要**: 本文件包含核心规范。详细规范已提取为 Skills在特定任务时按需加载。
## 专项规范 Skills按需加载
以下规范在相关任务时**自动触发**,无需手动加载:
| 任务类型 | 触发 Skill | 说明 |
|---------|-----------|------|
| 创建/修改 DTO 文件 | `dto-standards` | description 标签、枚举字段、验证标签规范 |
| 创建/修改 Model 模型 | `model-standards` | GORM 模型结构、字段标签、TableName 规范 |
| 注册 API 路由 / **新增 Handler** | `api-routing` | Register() 函数、RouteSpec、**文档生成器更新** |
| 测试接口/验证数据 | `db-validation` | PostgreSQL MCP 使用方法和验证示例 |
| 数据库迁移 | `db-migration` | 迁移命令、文件规范、执行流程、失败处理 |
| 维护规范文档 | `doc-management` | 规范文档流程和维护规则 |
| 编写 Go 代码注释/文档注释 | `comment-standards` | 包/结构体/接口/函数/内联注释完整规范与示例 |
| 创建涉及接口的 OpenSpec 提案 | `openspec-api-contract` | 探索阶段引导清单、提案必填章节与完成标准 |
---
### ⚠️ 新增 Handler 时必须同步更新文档生成器
新增 Handler 后,接口不会自动出现在 OpenAPI 文档中。**必须手动更新以下两个文件**
```go
// cmd/api/docs.go 和 cmd/gendocs/main.go
handlers := &bootstrap.Handlers{
// ... 添加新 Handler
NewHandler: admin.NewXxxHandler(nil),
}
```
**完整检查清单**: 参见 [`docs/api-documentation-guide.md`](docs/api-documentation-guide.md#新增-handler-检查清单)
---
## 语言要求
**必须遵守:**
- 永远用中文交互
- 注释必须使用中文
- 文档必须使用中文
- 日志消息必须使用中文
- 用户可见的错误消息必须使用中文
- 变量名、函数名、类型名必须使用英文(遵循 Go 命名规范)
- GIT提交的commit必须使用中文
## 技术栈
**必须严格遵守,禁止替代方案:**
| 类型 | 技术 |
|------|------|
| HTTP 框架 | Fiber v2.x |
| ORM | GORM v1.25.x |
| 配置管理 | Viper |
| 日志 | Zap + Lumberjack.v2 |
| JSON 序列化 | sonic优先encoding/json必要时 |
| 验证 | Validator |
| 任务队列 | Asynq v0.24.x |
| 数据库 | PostgreSQL 14+ |
| 缓存 | Redis 6.0+ |
**禁止:**
- 直接使用 `database/sql`(必须通过 GORM
- 使用 `net/http` 替代 Fiber
- 使用 `encoding/json` 替代 sonic除非必要
## 架构分层
必须遵循以下分层架构:
```
Handler → Service → Store → Model
```
- **Handler**: 只处理 HTTP 请求/响应,不包含业务逻辑
- **Service**: 包含所有业务逻辑,支持跨模块调用
- **Store**: 统一管理所有数据访问,支持事务处理
- **Model**: 定义数据结构和 DTO
## 核心原则
### 错误处理
- 所有错误必须在 `pkg/errors/` 中定义
- 使用统一错误码系统
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
#### 错误报错规范(必须遵守)
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()``err.Error()`
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)``errors.Wrap(...)`
- 约定用法:`errors.New(code[, msg])``errors.Wrap(code, err[, msg])`
### 响应格式
- 所有 API 响应使用 `pkg/response/` 的统一格式
- 格式: `{code, msg, data, timestamp}`
### 常量管理
- 所有常量定义在 `pkg/constants/`
- Redis key 使用函数生成: `Redis{Module}{Purpose}Key(params...)`
- 禁止硬编码字符串和 magic numbers
- **必须为所有常量添加中文注释**
### 枚举与状态字段(必须遵守)
**两个强制规则**
1. **int vs string**:状态类(生命周期)用 `int`,类型/方式类用 `string`
2. **DTO description 必须从 constants 原文抄写**,禁止凭记忆填写枚举值(历史上已有 description 与 constants 不一致导致前端显示错误的案例)
```go
// ✅ description 从 constants 原文抄,格式统一用冒号+逗号
Status int `json:"status" description:"状态 (1:待支付, 2:已支付, 3:已完成, 4:已关闭, 5:已退款)"`
StatusName string `json:"status_name" description:"状态名称(中文)"` // Response DTO 必须加
// ❌ 禁止:启用/禁用用 1=启用 2=禁用(全局约定是 0=禁用, 1=启用)
// ❌ 禁止description 枚举值与 constants 不一致
```
**完整规范**: 参见 [`docs/enum-status-standards.md`](docs/enum-status-standards.md)
### 注释规范
- **所有注释使用中文**,导出符号必须有文档注释(包、函数、类型、接口、常量)
- 复杂逻辑解释"为什么"而非"做了什么",禁止废话注释(复述代码本身)
- Handler 方法注释必须包含 HTTP 方法和路径(`// Create 创建账号` + `// POST /api/admin/accounts`
- 未导出函数:< 15 行可省略,≥ 15 行或非显而易见算法必须注释
- 修改代码时必须同步更新注释,过时注释比没有注释更有害
**详细规范与示例**:见 `comment-standards` skill
### Go 代码风格
- 使用 `gofmt` 格式化
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
- 包名: 简短、小写、单数、无下划线
- 接口命名: 使用 `-er` 后缀Reader、Writer、Logger
## 数据库设计
**核心规则:**
- ❌ 禁止建立外键约束
- ❌ 禁止使用 GORM 关联关系标签foreignKey、hasMany、belongsTo
- ✅ 关联通过存储 ID 字段手动维护
- ✅ 关联数据在代码层面显式查询
## Go 惯用法 vs Java 风格
### ✅ Go 风格(推荐)
- 扁平化包结构(最多 2-3 层)
- 小而专注的接口1-3 个方法)
- 直接访问导出字段(不用 getter/setter
- 组合优于继承
- 显式错误返回和检查
### ❌ Java 风格(禁止)
- 过度抽象(不必要的接口、工厂)
- Getter/Setter 方法
- 深层继承层次
- 异常处理panic/recover
- 类型前缀IService、AbstractBase、ServiceImpl
## ⚠️ 测试禁令(强制执行)
**本项目禁止任何形式的自动化测试**(单元/集成/E2E/`*_test.go` 文件),规划和文档中也不讨论测试。
**唯一例外**:用户明确说"请写测试"时。
**替代验证**PostgreSQL MCP 手动验证数据、Postman/curl 手动测试 API。
## 性能要求
- API P95 响应时间 < 200ms
- API P99 响应时间 < 500ms
- 数据库查询 < 50ms
- 列表查询必须分页(默认 20最大 100
- 避免 N+1 查询,使用批量操作
## 文档要求
- 每个功能在 `docs/{feature-id}/` 创建总结文档
- 文档文件名和内容使用中文
- 同步更新 README.md
- 为导出的函数、类型编写文档注释
## 函数复杂度
- 函数长度 ≤ 100 行(核心逻辑建议 ≤ 50 行)
- `main()` 函数只做编排,不含具体实现
- 遵循单一职责原则
## 访问日志
- 所有 HTTP 请求记录到 `access.log`
- 记录完整的请求/响应(限制 50KB
- 包含: method, path, query, status, duration, request_id, ip, user_agent, user_id, bodies
- 使用 JSON 格式,配置自动轮转
## OpenSpec 工作流
创建提案前的检查清单:
1. ✅ 技术栈合规
2. ✅ 架构分层正确
3. ✅ 使用统一错误处理
4. ✅ 常量定义在 pkg/constants/
5. ✅ Go 惯用法(非 Java 风格)
6. ✅ 性能考虑
7. ✅ 文档更新计划
8. ✅ 中文优先
## Code Review 检查清单
### 错误处理
- [ ] Service 层无 `fmt.Errorf` 对外返回
- [ ] Handler 层参数校验不泄露细节
- [ ] 错误码使用正确4xx vs 5xx
- [ ] 错误日志完整(包含上下文)
### 代码质量
- [ ] 遵循 Handler → Service → Store → Model 分层
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
- [ ] 常量定义在 `pkg/constants/`
- [ ] 使用 Go 惯用法(非 Java 风格)
### 枚举与状态
- [ ] 状态类字段用 `int`,类型/方式类字段用 `string`
- [ ] 禁用/启用使用 `0=禁用, 1=启用`(禁止 `1=启用, 2=禁用`
- [ ] DTO description 枚举列表已从 `pkg/constants/` 原文抄写,无遗漏、无错误
- [ ] Response DTO 的 int 状态字段有对应的 `_name` 文字字段
### 文档和注释
- [ ] 所有注释使用中文
- [ ] 导出函数/类型有文档注释
- [ ] API 路径注释与真实路由一致
### 幂等性
- [ ] 创建类写操作有 Redis 业务键防重
- [ ] 状态变更使用条件更新(`WHERE status = expected`
- [ ] 余额/库存变更使用乐观锁version 字段)
- [ ] 分布式锁使用 `defer` 确保释放
- [ ] Redis Key 定义在 `pkg/constants/redis.go`
### 越权防护规范
**三层防护机制**(基础设施已就绪,无需自建):
1. **路由层中间件**:粗粒度拦截(如企业账号禁止访问账号管理)
2. **Service 层业务检查**`middleware.CanManageShop()` / `middleware.CanManageEnterprise()` 细粒度验证;示例见 `internal/service/account/service.go`
3. **GORM Callback 自动过滤**代理按店铺层级、企业按企业ID已自动应用无需手动调用
统一错误返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`(不区分"不存在"与"无权限",防止信息泄露)
### 幂等性规范
写操作按场景选择策略:
- **状态流转操作** → 状态条件更新(首选)
- **创建类操作(无状态可依赖)** → Redis 业务键防重 + 分布式锁(三层检测)
- **余额/库存数值更新** → 乐观锁(`version` 字段)
```go
// 策略1示例首选通过 WHERE 条件确保幂等RowsAffected=0 说明已被处理
result := tx.Model(&model.Order{}).
Where("id = ? AND payment_status = ?", orderID, model.PaymentStatusPending).
Updates(map[string]any{"payment_status": model.PaymentStatusPaid})
if result.RowsAffected == 0 { /* 已处理,检查当前状态 */ }
```
- Redis Key 定义在 `pkg/constants/redis.go`,分布式锁必须用 `defer` 释放
- 参考实现:`internal/service/order/service.go`
### 异步任务载荷规范Asynq
**必须遵守:**
- ✅ 调用 `queueClient.EnqueueTask()` 时,`payload` 必须传入 **struct 或 map**
-**禁止传入 `[]byte`**`EnqueueTask` 内部统一调用 `sonic.Marshal`,若传入 `[]byte` 会被 base64 编码成字符串Handler 反序列化时类型不匹配直接崩溃
```go
// ✅ 正确:传 struct
queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, MyPayload{OrderID: id})
// ❌ 错误:预先序列化后传 []byte二次 Marshal → base64 编码)
payloadBytes, _ := sonic.Marshal(MyPayload{OrderID: id})
queueClient.EnqueueTask(ctx, constants.TaskTypeXxx, payloadBytes)
```
直接用 `asynq.NewTask` 入队时(绕过 `EnqueueTask`)才需要自己 Marshal。
---
### 审计日志规范
**适用场景**:任何敏感操作(账号管理、权限变更、数据删除等)
- Service 层注入 `auditService AuditServiceInterface`,操作成功后调用 `LogOperation()`
- 必填字段:`OperatorID``OperationType``OperationDesc``BeforeData``AfterData`
- 异步写入Goroutine写入失败不影响业务失败时记录 Error 日志
**示例参考**`internal/service/account/service.go`
---
### ⚠️ 任务执行规范(必须遵守)
**提案中的 tasks.md 是契约,不可擅自变更:**
| 规则 | 说明 |
|------|------|
| ❌ 禁止跳过任务 | 每个任务都是经过规划的,不能因为"简单"或"显而易见"而跳过 |
| ❌ 禁止简化任务 | 不能将多个任务合并或简化执行,除非获得明确许可 |
| ❌ 禁止自作主张优化 | 发现可以优化的地方,必须先询问是否可以调整 |
| ✅ 必须逐项完成 | 按照 tasks.md 中的顺序逐一执行并标记完成 |
| ✅ 必须询问后变更 | 如需调整任务(简化/跳过/合并/优化),先询问用户确认 |
**询问示例**
> "我注意到任务 2.1 和 2.2 可以合并为一步完成,是否可以这样优化?"
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
---
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
通用项目规范、触碰式 DDD 迁移规则和审批流约束统一维护在 `AGENTS.md`,本文件只保留 Claude 专用补充,禁止复制两份规则正文。
## Agent skills
@@ -351,4 +16,4 @@ Issue 和 PRD 以本地 Markdown 文件形式存放在 `.scratch/<feature-slug>/
### Domain docs
单 Context 布局——根目录的 `CONTEXT.md` + `docs/adr/`(按需懒创建),与现有 `openspec/` 提案工作流并存。详见 `docs/agents/domain.md`
单 Context 布局根目录的 `CONTEXT.md` + `docs/adr/`(按需懒创建),与现有 `openspec/` 提案工作流并存。详见 `docs/agents/domain.md`

View File

@@ -230,6 +230,7 @@ default:
- **代理商体系**:层级管理和分佣结算,支持差价佣金和一次性佣金两种佣金类型,详见 [套餐与佣金业务模型](docs/commission-package-model.md)
- **代理开放接口**:新增 `/api/open/v1` 签名接口,代理店铺第三方系统可调用卡流量、卡状态、实名状态、套餐列表、预充值钱包余额/流水和钱包套餐购买能力。详见 [对接说明](docs/agent-open-api/功能总结.md) 与 [误发差价佣金修复说明](docs/agent-open-api/开放接口误发差价佣金修复说明.md)
- **批量同步**:卡状态、实名状态、流量使用情况
- **批量购买套餐脚本**:支持从单列 CSV 读取 ICCID/虚拟号,逐资产调用后台订单接口购买统一套餐,提供预演、重复拦截和逐条结果落盘能力。详见 [使用说明](scripts/batch_package_purchase/README.md) 与 [功能总结](docs/批量购买套餐脚本/功能总结.md)
- **轮询系统**IoT 卡实名状态、流量使用、套餐余额的定时轮询检查;支持配置化轮询策略、动态并发控制、告警系统、数据清理和手动触发功能;详见 [轮询系统文档](docs/polling-system/README.md)
- **套餐系统升级**:完整的套餐生命周期管理,支持主套餐排队激活、加油包绑定主套餐、囤货待实名激活、流量按优先级扣减、自然月/按天有效期计算、日/月/年流量重置、客户端流量查询和套餐流量详单;详见 [套餐系统升级文档](docs/package-system-upgrade/)
- **套餐价格回退与平台赠送策略**:新增价格配置状态、普通套餐成本价回退、赠送套餐独立语义、平台后台赠送订单发放和历史 0 价复核清单;详见 [功能总结](docs/package-price-fallback-and-platform-gift-policy/功能总结.md) 与 [最终验收清单](docs/package-price-fallback-and-platform-gift-policy/最终验收清单.md)

View File

@@ -0,0 +1,57 @@
# 7月迭代前后端任务工时表
> 估算口径:实现工作全权交由 AI 编码代理执行,前后端可并行推进。
> 工时单位人时1 人日按 8 小时计算。
> 包含:代码实现、迁移、接口调整、页面交互、联调修复、手工接口验证和发布准备。
> 不包含:等待企微/支付配置、产品临时改需求、外部接口申请审核和生产历史脏数据人工处理时间。
> 前提提供后端仓库、前端仓库、可用数据库、Gateway、企微和支付联调配置需求范围以标准评审稿为准。
>
> 禅道逐条录入时,使用[7月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)中的拆分工时;本表用于核对总量,逐条录入稿用于填写每条研发需求的预计工时。
|需求|前端任务|后端任务|前端任务工时|后端任务工时|相关风险可能导致的工时延长|
|---|---|---|---:|---:|---|
|公共基础|补齐公共状态、错误展示和异步任务交互组件|增量迁移、Outbox、DDD 目录和公共幂等能力|12小时|45小时|现有迁移冲突、Outbox 基础与文档不一致会增加 24 小时|
|需求01复机实名规则|调整复机失败提示,不再按行业卡写死文案|按 `realname_link_type` 判断实名要求,删除行业卡统一放行逻辑|0.51小时|1小时|运营商历史配置错误会增加数据修复时间|
|需求02H5 流程配置|按有效实名策略渲染先实名/先购买流程,补批量配置交互|统一运营商实名能力和资产顺序策略,补批量更新接口|23小时|34小时|前端现有 H5 状态机分散、冲突数据较多会增加 24 小时|
|需求03联系电话搜索|店铺列表增加联系电话搜索框|店铺 Query 增加 11 位联系电话精确过滤|0.51小时|0.51小时|现有列表参数命名不统一会增加约 1 小时|
|需求04退款中禁止换货|直接展示后端拦截原因|创建换货前批量校验活跃退款状态|0.51小时|11.5小时|历史退款状态语义不一致会增加 12 小时|
|需求05套餐分配生效条件|增加默认/购买生效/实名生效三选一及恢复默认|增加代理覆盖值和使用记录快照,统一生效条件计算|1.52.5小时|2.53.5小时|旧使用记录缺快照、套餐周期数据异常会增加 24 小时|
|需求06/11预计最终到期时间|资产层只展示预计最终到期时间、推算状态和统一高亮|Query 按当前及排队主套餐时长快照推算,供详情、临期和导出复用|1.52.5小时|34小时|旧套餐缺购买时长快照、等待实名激活无法确定起点会增加兼容处理时间|
|需求07实名筛选|卡和设备列表增加实名状态筛选|卡按自身状态,设备按有效绑定卡维护/查询实名状态|12小时|23小时|设备多当前卡、绑定历史异常会增加 23 小时|
|需求08设备批量分配|拆分“分配代理”和“分配套餐系列”两个入口,展示任务结果|复用 Excel/Asynq新增两个独立批量命令和失败明细|23小时|34小时|前端无现成上传任务组件或生产文件格式不统一会增加 24 小时|
|需求09C 端支付限制|支付页按接口返回方式展示,后台增加配置控件|实现受控系统配置、缓存和订单支付二次校验|1.52.5小时|23小时|多端支付入口未复用同一接口会增加逐端排查时间|
|需求10Gateway 限速|卡/设备详情增加设置和取消入口|统一 `speed_kbps` 接口,设备解析当前卡后调用 Gateway|12小时|23小时|Gateway 取消参数不明确或联调不稳定会增加 24 小时|
|需求11当前套餐到期高亮|并入需求06/22不单独建设第二个资产汇总字段|并入预计最终到期 Query|0小时|0小时|无独立工时|
|需求12换货显示与搜索|拆分新旧资产搜索并修正字段展示|修正新建换货快照,增加新旧资产关键词过滤|12小时|12小时|历史数据不回填导致验收口径混淆会增加沟通时间|
|需求13列表字段新增|退款、充值、换货列表增加提交人和企微审批摘要|补提交人快照,批量查询企微状态和审批人摘要|12小时|23小时|旧记录缺提交人或企微实例会增加兼容展示时间|
|需求14导出与字段权限|角色页配置导出字段,各列表接入统一导出入口|扩展 DataSource 场景、角色字段权限和字段快照|34小时|46小时|导出字段口径变更、现有数据源 N+1 会增加 35 小时|
|需求15下架套餐续费|当前套餐旁增加续费按钮,复用购买流程|统一可售策略,校验资产所有人及历史使用资格|1.52.5小时|23小时|C 端多个购买入口绕过统一策略会增加排查时间|
|需求16代理分销码与佣金提现|不实施|不实施|0小时|0小时|需求重新加入时必须单独评估,不计入本表|
|需求17代理主钱包信用额度|角色页配置新建店铺默认额度,店铺资金页单独调额并明确不追溯|增加角色默认模板、钱包实际额度、约束、扣款不变量和资金审计|2.53.5小时|45小时|现有负余额、CHECK 约束或创建店铺事务分散会增加 25 小时|
|需求18多人审批映射|不建设本地待办,只展示企微审批摘要|退款/充值场景映射到企微模板,复用企微状态同步|11.5小时|11.5小时|企微模板审批人配置不完整会阻塞联调|
|需求19批量订购套餐|批量上传、统一支付方式、任务进度和失败明细|任务/明细表、逐行幂等下单、钱包或线下支付处理|34小时|46小时|订单服务复用困难、钱包并发冲突会增加 36 小时|
|需求20退款审批|创建和详情展示企微状态、意见附件及处理结果|退款接入企微、代理钱包回溯、人工退款终态和撤销异常|2.53.5小时|45小时|存量退款买家类型混乱、通过后撤销场景会增加 35 小时|
|需求21平台员工线下充值|创建后展示企微审批状态,删除本地确认/驳回按钮|企微通过后幂等增加代理主钱包,旧接口下线|23小时|34小时|旧充值状态和钱包流水不一致会增加 24 小时|
|需求22套餐临期提醒|资产列表、详情、临期页、代理首页和 C 端展示仅临期页将3天内置顶|复用预计最终到期 Query、15/7/3 天站内通知防重和导出接入|34.5小时|46小时|时区、排队套餐快照和多接收人数据异常会增加 24 小时|
|禅道#98/#86:换货归属与资产标识|换货确认展示继承店铺,资产详情展示前代/后代标签和跳转|新资产继承旧资产店铺、保留旧资产归属、补换货链 Query 和索引|12小时|23小时|换货迁移涉及的租户标签或资产分配记录不完整会增加 24 小时|
|禅道#96/#97:业务员与余额提醒|店铺业务员选择/筛选、资金不足100元状态和通知跳转|增加店铺业务员字段钱包跨越固定100元阈值时向主账号和业务员发站内通知|12小时|23小时|店铺主账号异常、业务员停用或钱包写入口未统一会增加 24 小时|
|禅道#43:系列套餐批量授权|套餐表格多选、已授权置灰、三类价格展示|复用现有批量写接口,新增套餐候选 Query 和重复授权幂等|12小时|0.51小时|前端旧页面结构不支持表格多选或成本价权限不完整会增加 13 小时|
|新增01数据同步触发与轮询优化|展示轮询活跃状态和审计跳转|卡状态领域收口、活跃调频、0/3/5 任务、回调防腐层|23小时|79小时|旧同步入口数量超出预期、Gateway 超频和 ICCID 重复会增加 48 小时|
|新增02企业微信审批接入|企微配置、模板映射、扫码绑定、审批运行和业务详情|Token、模板版本、上传提交、回调解密、轮询补偿和异常恢复|57小时|810小时|企微可信域名、模板 ID 变化、回调网络和真实账号权限会增加 48 小时|
|新增03站内通知|顶部铃铛、通知抽屉、通知中心和受控跳转|通知表、模板注册、接收人解析、未读/已读 API|34小时|34小时|前端多端布局差异、接收人关系不完整会增加 23 小时|
|新增04全局多视角审计|审计中心七个视角、详情抽屉和敏感字段展示|Audit Event、Integration Log、旧写入口切换、历史投影和脱敏|68小时|811小时|旧审计调用点遗漏、查询性能和历史字段差异会增加 510 小时|
|新增05代理钱包扫码充值|支付方式选择、二维码、倒计时和支付状态轮询|微信 Native、支付宝 PreCreate、支付单分发和钱包入账恢复|34小时|57小时|支付渠道配置、真实回调、微信 v2/富友差异会增加 36 小时|
|全链路联调与发布|联调全部页面状态、修复交互、准备发布版本|数据核对、存量回填、旧入口清理、停机发布和恢复检查|34小时|45小时|生产数据与预期差异、外部回调不可达会增加 48 小时|
|**合计**|**前端约 5989 小时**|**后端约 93128 小时**|**约 812 人日**|**约 1216 人日**|**1 后端 + 1 前端并行时,正常目标 1215 个工作日;历史数据或换货迁移超预期时可能到 16 个工作日**|
## 并行交付口径
| 项目 | 估算 |
|------|------|
| 前端投入 | 5989 小时,约 812 人日 |
| 后端投入 | 93128 小时,约 1216 人日 |
| 合计投入 | 152217 小时,约 1928 人日 |
| 1 后端 + 1 前端并行周期 | 正常 1215 个工作日,风险上限 16 个工作日 |
| 建议对外排期基线 | 14 个工作日 |
达到第 16 个工作日的主要条件:前端旧授权页面无法复用、换货归属需要补齐多张租户标签表、生产历史数据需要大规模人工修复、企微或支付联调配置不可用。

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,242 @@
# 7月迭代禅道研发需求拆分表
> 来源禅道用户需求导出与7月迭代标准评审稿。
> 范围:只包含激活需求;草稿 #41/#51 和已关闭重复需求 #54 不创建研发需求。
>
> 本文件用于总览、排期和关联关系,不用于逐条复制。实际录入禅道请使用:[7月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)。录入稿中每条 FE/BE/INT 都有可独立复制的标题和完整描述。
## 一、拆分规则
每条用户需求下创建两条研发需求:
```text
[FE][UR#编号] 前端交付名称
[BE][UR#编号] 后端交付名称
```
- FE、BE 研发需求分别指派给前端和后端,可并行进入开发。
- 研发需求必须描述可独立交付的页面或接口能力,不能只写“配合前端”“配合后端”。
- 联调属于研发需求完成后的交付活动,不为每条用户需求机械创建第三条研发需求。
- 跨页面、跨模块、异步任务或第三方系统场景,统一创建链路级联调任务。
- 简单搜索、字段展示等需求在 FE/BE 研发需求中完成自验,不单独创建联调任务。
推荐状态依赖:
```text
FE/BE 研发需求开发完成
-> 接口契约冻结
-> 进入对应联调任务
-> 问题回到原 FE/BE 研发需求修复
-> 联调任务验收通过
```
## 二、用户需求到研发需求映射
| 用户需求 | 前端研发需求 | 后端研发需求 | 联调归属 |
|---|---|---|---|
| #98 换货管理新资产归属 | `[FE][UR#98] 换货新资产归属继承提示`:选择新资产后展示将继承的店铺 | `[BE][UR#98] 换货新资产继承旧资产店铺`:事务内迁移归属、租户标签和分配记录 | INT-02 换货链路 |
| #97 代理钱包阈值提醒 | `[FE][UR#97] 代理现金余额不足100元展示与通知跳转` | `[BE][UR#97] 主钱包固定100元余额预警`:跨越阈值、重新布防、站内通知 | INT-05 钱包支付 |
| #96 员工作为发展人进行标识 | `[FE][UR#96] 店铺业务员选择、展示和筛选` | `[BE][UR#96] 店铺业务员关联与查询`:只绑定启用平台账号 | INT-05 钱包支付 |
| #94 系统状态同步优化以及回调处理 | `[FE][UR#94] 资产同步状态与审计入口展示` | `[BE][UR#94] 资产状态同步DDD收口`轮询、0/3/5事件、回调防腐层 | INT-01 实名与同步 |
| #86 资产详情中换货标识 | `[FE][UR#86] 资产详情前代/后代换货标识与跳转` | `[BE][UR#86] 资产换货链Query`:返回 previous/next asset并校验权限 | INT-02 换货链路 |
| #73 行业卡操作停复机 | `[FE][UR#73] 复机实名校验结果与错误提示适配` | `[BE][UR#73] 按运营商实名能力控制复机` | INT-01 实名与同步 |
| #62 H5设置先充值后实名 | `[FE][UR#62] H5实名与购买顺序流程`:先实名/先购买/无需实名 | `[BE][UR#62] 资产实名顺序策略与批量配置接口` | INT-01 实名与同步 |
| #60 店铺联系电话检索 | `[FE][UR#60] 店铺联系电话搜索控件` | `[BE][UR#60] 店铺联系电话精确查询` | 不单建FE/BE自验 |
| #57 退款中禁止换货 | `[FE][UR#57] 换货退款拦截错误展示` | `[BE][UR#57] 换货前活跃退款校验` | INT-02 换货链路 |
| #55 套餐生效条件 | `[FE][UR#55] 套餐分配生效条件选择与恢复默认` | `[BE][UR#55] 套餐分配生效条件覆盖与购买快照` | INT-03 套餐生命周期 |
| #53 资产实名状态筛选 | `[FE][UR#53] 卡和设备实名状态筛选` | `[BE][UR#53] 卡/设备实名状态查询与设备快照维护` | INT-01 实名与同步 |
| #49 设备批量分配代理和套餐系列 | `[FE][UR#49] 设备批量分配代理/套餐系列双入口` | `[BE][UR#49] 两类设备批量分配任务与失败明细` | INT-04 批量与导出 |
| #48 不同资产使用不同支付方式 | `[FE][UR#48] C端按资产展示允许支付方式` | `[BE][UR#48] 支付方式配置与订单二次校验` | INT-05 钱包支付 |
| #47 限速规则 | `[FE][UR#47] 卡/设备手动设置与取消限速` | `[BE][UR#47] Gateway按cardNo限速统一接口` | INT-07 Gateway限速 |
| #46 资产信息详情字段新增 | `[FE][UR#46] 预计最终到期时间与临期高亮` | `[BE][UR#46] 当前及排队套餐最终到期Query` | INT-03 套餐生命周期 |
| #45 换货管理 | `[FE][UR#45] 换货新旧资产展示与独立搜索` | `[BE][UR#45] 换货标识快照修正与新旧资产查询` | INT-02 换货链路 |
| #44 列表字段新增 | `[FE][UR#44] 退款/充值/换货提交人与审批摘要展示` | `[BE][UR#44] 提交人快照与企微审批摘要批量查询` | INT-06 企微审批 |
| #43 代理系列授权 | `[FE][UR#43] 系列套餐批量选择与已授权置灰` | `[BE][UR#43] 系列套餐候选Query与重复授权幂等` | INT-03 套餐生命周期 |
| #42 导出功能 | `[FE][UR#42] 导出字段选择、权限展示和任务进度` | `[BE][UR#42] 导出场景、角色字段权限与30天到期筛选` | INT-04 批量与导出 |
| #40 套餐设计 | `[FE][UR#40] C端下架套餐续费入口` | `[BE][UR#40] 下架套餐历史用户续费资格校验` | INT-03 套餐生命周期 |
| #38 不同渠道额度处理 | `[FE][UR#38] 角色默认信用和店铺实际额度管理` | `[BE][UR#38] 代理主钱包信用额度与并发资金不变量` | INT-05 钱包支付 |
| #37 审核流转 | `[FE][UR#37] 企微审批状态、意见附件和结果通知展示` | `[BE][UR#37] 企微审批模板、账号绑定、回调和轮询补偿` | INT-06 企微审批 |
| #36 批量订购套餐 | `[FE][UR#36] 批量订购上传、支付方式、进度和失败明细` | `[BE][UR#36] 批量订购任务、逐行幂等下单和钱包扣款` | INT-04 批量与导出、INT-05 钱包支付 |
| #35 退款审核 | `[FE][UR#35] 退款企微审批详情与业务处理状态` | `[BE][UR#35] 退款企微终态、人工退款和代理钱包回溯` | INT-06 企微审批 |
| #34 充值审核流程 | `[FE][UR#34] 代理扫码充值与员工线下审批状态页面` | `[BE][UR#34] 微信/支付宝充值入账和线下充值企微终态` | INT-05 钱包支付、INT-06 企微审批 |
| #33 套餐临期提醒 | `[FE][UR#33] 临期列表、各端高亮、3天置顶和续费入口` | `[BE][UR#33] 最终到期临期Query与15/7/3站内通知` | INT-03 套餐生命周期 |
## 三、前后端并行约定
研发需求创建前先冻结本表中的接口契约。前端按 Mock 数据开发页面,后端按同一契约实现接口。
- 响应统一为 `{code,msg,data,timestamp}`,下表只描述 `data`
- 分页统一返回 `{items,total,page,size}`
- 金额入参和返回均使用分;前端显示元。
- 生命周期判断使用 `status`,展示使用 `status_name`
- 字段允许增加但不能改名或改变类型;确需调整时,必须同步修改 FE、BE 两条研发需求。
- 前端先完成页面、交互、Mock、加载/空/错误状态;后端完成后只替换数据源并进入联调。
每组需求按以下顺序启动:
```text
1. FE/BE共同确认路径、方法、入参、返回和枚举
2. 后端先补DTO/OpenAPI契约或提供固定JSON示例
3. 前端按JSON示例完成页面和Mock请求
4. 后端实现真实查询、写入、权限、幂等和审计
5. 前端切换真实接口进入对应INT联调任务
```
契约确认时必须标记字段是必返、可空还是仅特定状态返回,避免前端通过猜测补默认值。
## 四、研发需求压缩总览
> 下表仅用于快速核对,不建议复制到禅道。前端页面结构、交互状态、接口入参和返回字段以[逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)为准。
| 用户需求 | 前端研发需求说明 | 后端研发需求与接口契约 |
|---|---|---|
| #98 换货新资产归属 | **标题:**换货新资产归属继承提示。<br>**页面:**换货创建、发货确认。选择新资产后展示“完成后归属到某店铺”,提交中禁止重复操作,失败展示后端原因。 | **标题:**换货新资产继承旧资产店铺。<br>**接口:**复用 `POST /api/admin/exchanges``POST /api/admin/exchanges/{id}/ship``POST /api/admin/exchanges/{id}/complete`。<br>**入参:**旧/新资产标识、换货类型、是否迁移资料。<br>**返回:**换货单及 `inherited_shop_id/inherited_shop_name`。<br>**规则:**平台库存新资产继承旧店铺;其他店铺资产拒绝。 |
| #97 钱包100元预警 | **标题:**代理现金余额不足100元展示。<br>**页面:**资金概况显示红色预警;顶部通知和通知中心支持跳转店铺资金页,不提供阈值配置。 | **标题:**代理主钱包固定100元余额预警。<br>**接口:**`GET /api/admin/shops/fund-summary` 增加 `cash_available/low_balance_warning`;通知复用 `/api/admin/notifications`。<br>**规则:**`balance-frozen_balance<=10000`,不含信用额度;只在跨越阈值时通知,回升后重新布防。 |
| #96 店铺业务员 | **标题:**店铺业务员选择、展示与筛选。<br>**页面:**店铺新建/编辑增加平台业务员下拉,列表和详情展示业务员,筛选栏支持按业务员查询。 | **标题:**店铺业务员关联与查询。<br>**接口:**`POST /api/admin/shops``PUT /api/admin/shops/{id}` 入参增加 `business_owner_account_id``GET /api/admin/shops` 增加同名筛选。<br>**返回:**`business_owner_account_id/business_owner_name`。只允许启用的平台账号。 |
| #94 状态同步优化 | **标题:**资产同步状态与审计入口。<br>**页面:**资产详情展示活跃级别、最后活跃、下次轮询;保留原刷新按钮,增加“查看同步轨迹”跳转。 | **标题:**资产状态同步DDD收口。<br>**接口:**不新增刷新入口,复用 `POST /api/admin/assets/{identifier}/refresh``GET /api/admin/assets/resolve/{identifier}` 返回 `polling`;轨迹使用 `GET /api/admin/audit/integrations`。<br>**规则:**轮询、0/3/5事件和回调统一应用状态。 |
| #86 资产换货标识 | **标题:**资产详情换货前代/后代展示。<br>**页面:**显示“换货新资产”“已换出旧资产”标签,可查看前代和后代;无权限时不可点击。 | **标题:**资产换货链Query。<br>**接口:**`GET /api/admin/assets/resolve/{identifier}` 增加 `exchange_trace.previous_asset/next_asset`。<br>**返回:**资产类型、ID、标识、换货单号和 `can_view`。 |
| #73 行业卡复机 | **标题:**复机实名结果和错误提示。<br>**页面:**沿用资产详情复机按钮;未满足实名条件时展示后端中文原因。 | **标题:**按运营商实名能力控制复机。<br>**接口:**复用 `POST /api/admin/assets/{identifier}/start`。<br>**返回:**最新资产状态。<br>**规则:**只看运营商 `realname_link_type`,不按行业卡类型统一放行。 |
| #62 H5实名顺序 | **标题:**H5先实名/先购买流程及后台批量配置。<br>**页面:**C端按接口策略跳转后台卡和设备列表提供批量修改入口。 | **标题:**资产实名顺序策略。<br>**接口:**`PATCH /api/admin/assets/{identifier}/realname-mode``POST /api/admin/iot-cards/batch-update-realname-policy``POST /api/admin/devices/batch-update-realname-policy`。<br>**入参:**资产标识列表、`policy=none/before_order/after_order`。<br>**返回:**生效策略及成功数量。 |
| #60 联系电话搜索 | **标题:**店铺联系电话搜索。<br>**页面:**店铺列表新增11位联系电话输入框支持清空和重新查询。 | **标题:**店铺联系电话精确查询。<br>**接口:**`GET /api/admin/shops?contact_phone=`。<br>**入参:**11位手机号。<br>**返回:**原店铺分页结构。 |
| #57 退款中禁止换货 | **标题:**换货退款拦截提示。<br>**页面:**创建换货失败时原地展示“该资产存在退款申请”,不自行判断退款状态。 | **标题:**换货前活跃退款校验。<br>**接口:**复用 `POST /api/admin/exchanges`。<br>**规则:**审批中、已退回或退款处理未完成时返回业务错误;已拒绝、撤销或处理完成后放行。 |
| #55 套餐生效条件 | **标题:**套餐分配生效条件选择。<br>**页面:**授权/分配弹框提供跟随默认、购买生效、实名生效三选一;已分配记录支持修改和恢复默认。 | **标题:**套餐分配生效条件覆盖与快照。<br>**接口:**`POST /api/admin/shop-package-allocations``PATCH /api/admin/shop-package-allocations/{id}/expiry-base`。<br>**入参:**`expiry_base_override``null` 表示恢复默认。<br>**返回:**默认值、覆盖值和最终生效值。 |
| #53 实名状态筛选 | **标题:**卡和设备实名状态筛选。<br>**页面:**两个资产列表增加全部/已实名/未实名筛选和状态列。 | **标题:**卡和设备实名状态查询。<br>**接口:**`GET /api/admin/iot-cards?real_name_status=0\|1``GET /api/admin/devices?real_name_status=0\|1`。<br>**返回:**`real_name_status/real_name_status_name`。 |
| #49 设备批量分配 | **标题:**设备批量分配代理和套餐系列双入口。<br>**页面:**两个独立上传弹框,展示总数、成功数、失败数和失败原因。 | **标题:**设备两类批量分配任务。<br>**接口:**`POST /api/admin/devices/batch-assign-shop``POST /api/admin/devices/batch-assign-series``GET /api/admin/devices/batch-allocation/{task_id}`。<br>**入参:**文件和目标ID。<br>**返回:**任务状态及失败明细。 |
| #48 支付方式限制 | **标题:**C端按资产展示支付方式。<br>**页面:**支付页只展示接口返回的支付方式;无可用方式时禁止提交。 | **标题:**资产支付方式配置与订单校验。<br>**接口:**`GET /api/c/v1/asset/info` 返回 `allowed_payment_methods``POST /api/c/v1/orders/create``POST /api/c/v1/orders/{id}/pay` 再次校验。<br>**后台:**`PUT /api/admin/system/config/{config_key}`。 |
| #47 Gateway限速 | **标题:**卡和设备手动限速。<br>**页面:**详情页提供设置、取消入口设备显示最终使用的当前卡ICCID。 | **标题:**Gateway按cardNo统一限速。<br>**接口:**`POST /api/admin/assets/{identifier}/speed-limit`。<br>**入参:**`speed_kbps`0表示取消。<br>**返回:**资产标识、最终 `card_no`、目标值和执行结果。 |
| #46 预计最终到期 | **标题:**预计套餐到期时间和临期高亮。<br>**页面:**资产详情只显示一个最终到期字段;不可预计时显示“待激活后起算”。 | **标题:**当前及排队套餐最终到期Query。<br>**接口:**`GET /api/admin/assets/resolve/{identifier}`。<br>**返回:**`estimated_final_expires_at/days_until_final_expiry/expiry_estimate_status/is_expiring`。 |
| #45 换货管理 | **标题:**换货新旧资产展示和独立搜索。<br>**页面:**列表分别搜索旧资产、新资产展示统一卡ICCID或设备号。 | **标题:**换货标识快照和查询。<br>**接口:**`GET /api/admin/exchanges?old_asset_keyword=&new_asset_keyword=`。<br>**返回:**新旧资产类型、ID、标识及换货状态。新建换货统一保存规范化标识。 |
| #44 列表字段新增 | **标题:**退款、充值、换货列表提交人与审批摘要。<br>**页面:**增加提交人、企微状态、当前审批人摘要和业务处理状态。 | **标题:**提交人快照与企微摘要查询。<br>**接口:**复用 `GET /api/admin/refunds`、`GET /api/admin/agent-recharges`、`GET /api/admin/exchanges`。<br>**返回:**`submitter_name/approval_status_name/current_approver_summary/processing_status_name`。 |
| #43 系列批量授权 | **标题:**系列套餐批量选择与已授权置灰。<br>**页面:**首次和后续授权共用多选表格;已授权套餐置灰,展示公司成本、授权成本和建议售价。 | **标题:**系列套餐候选和批量授权。<br>**接口:**`GET /api/admin/shop-series-grants/{id}/package-options``PUT /api/admin/shop-series-grants/{id}/packages`。<br>**返回:**三类价格、`is_authorized`;重复授权幂等。 |
| #42 导出功能 | **标题:**导出字段选择、权限和任务进度。<br>**页面:**导出弹框加载可选字段,提交后展示进度、失败和下载。 | **标题:**统一导出场景和字段权限。<br>**接口:**`GET /api/admin/export-fields?scene=``POST /api/admin/export-tasks``GET /api/admin/export-tasks/{id}`。<br>**入参:**`scene/format/query/fields`。<br>**返回:**任务进度和下载地址。 |
| #40 下架套餐续费 | **标题:**C端当前套餐续费入口。<br>**页面:**当前套餐旁显示续费;下架套餐不出现在新购列表。 | **标题:**下架套餐续费资格。<br>**接口:**`GET /api/c/v1/asset/packages` 返回 `can_purchase/purchase_mode/disabled_reason``POST /api/c/v1/orders/create` 强校验资产所有人和历史使用记录。 |
| #38 代理信用额度 | **标题:**角色默认信用和店铺实际额度管理。<br>**页面:**客户角色配置新建默认额度并提示不影响存量;店铺资金页单独调整实际额度。 | **标题:**代理主钱包信用额度。<br>**接口:**`PUT /api/admin/roles/{id}/default-credit``PUT /api/admin/shops/{id}/credit-limit``GET /api/admin/shops/fund-summary`。<br>**入参:**开关、额度、钱包版本。<br>**返回:**额度、可用金额、欠款和版本。 |
| #37 企微审核流转 | **标题:**企微配置、账号绑定和审批详情。<br>**页面:**企微配置页、个人扫码绑定、审批运行列表;业务详情只读展示意见和附件。 | **标题:**企业微信审批接入。<br>**接口:**`GET /api/admin/wecom/status``POST /api/admin/wecom/account-binding/sessions``GET /api/admin/wecom/approvals``POST /api/admin/wecom/approvals/{id}/sync`。<br>**业务详情:**统一返回 `approval` 对象。 |
| #36 批量订购套餐 | **标题:**批量订购上传、支付、进度和失败明细。<br>**页面:**选择代理和整批支付方式上传Excel及线下凭证展示部分成功。 | **标题:**批量订购任务。<br>**接口:**`POST /api/admin/bulk-purchases``GET /api/admin/bulk-purchases/{task_id}``GET /api/admin/bulk-purchases/{task_id}/items`。<br>**入参:**代理、支付方式、文件、凭证。<br>**返回:**任务和逐行结果。 |
| #35 退款审核 | **标题:**退款企微审批和退款处理状态。<br>**页面:**创建时上传备注附件;详情展示审批、人工退款说明和业务处理结果。 | **标题:**退款企微终态处理。<br>**接口:**`POST /api/admin/refunds``GET /api/admin/refunds/{id}``POST /api/admin/refunds/{id}/resubmit`。<br>**返回:**退款数据、`approval``processing_status`。代理钱包通过后幂等回溯。 |
| #34 充值审核流程 | **标题:**代理扫码充值与员工线下充值审批。<br>**页面:**在线充值展示支付方式、二维码和支付状态;线下充值展示只读企微审批状态。 | **标题:**代理在线充值和员工线下审批。<br>**接口:**`GET /api/admin/agent-recharges/payment-methods``POST /api/admin/agent-recharges``GET /api/admin/agent-recharges/{id}/payment-status``GET /api/admin/agent-recharges/{id}`。<br>**返回:**二维码、过期时间、支付/审批/入账状态。 |
| #33 套餐临期提醒 | **标题:**临期列表、各端高亮和续费入口。<br>**页面:**临期页3天内置顶普通资产列表只高亮代理首页显示数量C端显示续费按钮。 | **标题:**预计最终到期临期Query和站内通知。<br>**接口:**`GET /api/admin/expiring-assets`、资产列表/详情增加临期字段、`GET /api/c/v1/asset/info`、通知接口。<br>**返回:**最终到期、剩余天数、颜色节点15/7/3天通知防重。 |
## 五、前端Mock公共样例
资产详情扩展字段:
```json
{
"estimated_final_expires_at": "2026-08-01T23:59:59+08:00",
"days_until_final_expiry": 15,
"expiry_estimate_status": "exact",
"is_expiring": true,
"allowed_payment_methods": ["alipay", "wallet"],
"polling": {
"enabled": true,
"activity_level": "active",
"last_activity_at": "2026-07-17T10:00:00+08:00",
"next_poll_at": "2026-07-17T10:03:00+08:00"
},
"exchange_trace": {
"previous_asset": null,
"next_asset": null
}
}
```
统一异步任务:
```json
{
"task_id": 1001,
"status": 2,
"status_name": "处理中",
"total_count": 100,
"success_count": 60,
"failed_count": 3,
"failed_items": []
}
```
代理资金概况:
```json
{
"balance": 8000,
"frozen_balance": 0,
"cash_available": 8000,
"credit_enabled": true,
"credit_limit": 100000,
"available_balance": 108000,
"low_balance_warning": true,
"version": 3
}
```
企微审批摘要:
```json
{
"source": "wecom",
"instance_id": 123,
"sp_no": "202607170001",
"status": 1,
"status_name": "审批中",
"current_approver_summary": "财务审批",
"approvers": [],
"attachments": [],
"business_process_result": "pending"
}
```
## 六、联调任务拆分
联调项建议创建为项目执行任务,而不是研发需求。每个任务可关联多条 FE/BE 研发需求和用户需求。
如果团队流程强制要求联调也必须使用“研发需求”类型则先新增一条技术用户需求“7月迭代跨模块联调与发布验收”再将下列 INT-0108 建成它的子研发需求。不要把同一条联调需求重复挂到每个业务用户需求下。
| 编号 | 联调任务名称 | 关联用户需求 | 参与工时 | 进入条件 | 主要验收链路 |
|---|---|---|---|---|---|
| INT-01 | 资产实名、复机与状态同步联调 | #94#73#62#53 | FE 11.5h / BE 11.5h,已含 | 实名策略接口、同步任务和H5页面完成 | 不同运营商实名能力、先实名/先购买、0/3/5同步、回调后状态和筛选一致 |
| INT-02 | 换货完整链路联调 | #98#86#57#45 | FE 0.51h / BE 0.51h已含 | 换货接口、详情和列表页面完成 | 退款拦截、新资产继承店铺、完成换货、列表搜索、前代/后代跳转 |
| INT-03 | 套餐授权、购买、续费、到期与临期联调 | #55#46#43#40#33 | FE 11.5h / BE 11.5h,已含 | 套餐快照、授权候选和各端到期字段完成 | 批量授权、购买生效条件、下架续费、排队套餐最终到期、临期高亮和通知 |
| INT-04 | Excel批量任务与导出联调 | #49#36#42 | FE 11.5h / BE 11.5h,已含 | 上传、任务详情、Worker和导出场景完成 | 模板校验、部分成功、失败明细、任务恢复、字段权限和文件下载 |
| INT-05 | 支付、代理钱包、信用和余额预警联调 | #48#38#34#36#96#97 | FE 11.5h / BE 11.5h,已含 | 支付配置、钱包领域和业务员接口完成 | 支付方式限制、扫码充值、信用扣款、角色默认额度、店铺调额、100元预警 |
| INT-06 | 企业微信审批、退款和线下充值联调 | #37#35#34#44 | FE 1.52h / BE 1.52h已含 | 企微模板、绑定、回调、轮询和业务详情完成 | 扫码绑定、发起审批、意见附件、通过/驳回/撤销、退款/充值终态和列表摘要 |
| INT-07 | Gateway卡限速联调 | #47 | FE 0.51h / BE 0.51h已含 | Gateway联调配置和卡/设备入口完成 | 单卡限速、设备解析当前卡、取消限速、无当前卡、失败审计 |
| INT-08 | 七月迭代全链路与停机发布验收 | 全部激活需求 | FE 34h / BE 45h额外 | INT-0107完成 | 权限、通知、审计、历史数据、旧入口关闭、Worker恢复和发布检查 |
## 七、联调任务责任方式
- 涉及企微、支付、Gateway、运营商回调和异步 Worker 的联调任务由后端主责,前端参与。
- 主要是页面与接口契约的批量、导出、套餐和换货联调可由前端主责,后端参与。
- 禅道只有一个指派人时,指派给主责人;另一人写入抄送和参与人,不拆成两条重复联调任务。
- 联调发现的代码问题回到对应 FE/BE 研发需求处理;联调任务只记录场景、阻塞和最终结论。
## 八、研发需求描述模板
前端研发需求至少填写:页面入口、交互状态、调用接口、权限、异常状态、完成标准。
后端研发需求至少填写业务规则、数据变更、API、权限、幂等/并发、事件与审计、完成标准。
联调任务至少填写:关联研发需求、环境和账号、测试数据、完整操作步骤、预期结果、阻塞项和最终结论。
## 九、额外禅道项
当前用户需求 CSV 没有覆盖公共开发基础、公共站内通知和全局审计。建议补建三条技术用户需求:
```text
用户需求:七月迭代公共开发基础
用户需求:公共站内通知
用户需求:全局多视角审计与外部集成追踪
```
再分别拆分:
```text
[FE] 七月迭代公共状态与异步任务交互
[BE] 七月迭代公共迁移幂等与异步任务基础
[FE] 顶部通知铃铛与站内通知中心
[BE] 站内通知基础设施与受控跳转
[FE] 全局多视角审计中心
[BE] Audit Event与Integration Log统一审计
```
具体描述和工时直接使用[逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)。

File diff suppressed because it is too large Load Diff

62
docs/7月迭代/README.md Normal file
View File

@@ -0,0 +1,62 @@
# 7月迭代文档索引
本目录只保留一份当前有效的评审结论和可追溯的独立来源稿。
## 当前有效方案
- [7月迭代技术方案标准评审稿](./7月迭代技术方案-标准评审稿.md)
- [7月迭代前后端任务工时表](./7月迭代前后端任务工时表.md)
- [7月迭代禅道研发需求拆分表](./7月迭代禅道研发需求拆分表.md)
- [7月迭代禅道研发需求逐条录入稿](./7月迭代禅道研发需求逐条录入稿.md)
评审、开发和验收均以标准评审稿为准。独立稿用于解释方案来源;与标准稿冲突时,标准稿优先。
## 原需求独立稿
| 需求 | 来源文件 |
|------|----------|
| 01 | [复机实名规则](./独立方案/原需求/需求01-复机实名规则.md) |
| 02 | [H5 流程配置](./独立方案/原需求/需求02-H5流程配置.md) |
| 03、07、11、12、13 | [简单改动合集](./独立方案/原需求/需求03-07-11-12-13-简单改动.md) |
| 04、06 | [退款拦截与最后到期时间](./独立方案/原需求/需求04-06-退款拦截与最后到期时间.md) |
| 05 | [套餐分配生效条件](./独立方案/原需求/需求05-套餐分配生效条件.md) |
| 08 | [设备批量分配 Excel](./独立方案/原需求/需求08-设备批量分配Excel.md) |
| 09 | [C 端支付限制配置化](./独立方案/原需求/需求09-C端支付限制配置化.md) |
| 10 | [Gateway 限速规则](./独立方案/原需求/需求10-限速规则.md) |
| 14 | [导出功能](./独立方案/原需求/需求14-导出功能.md) |
| 15、16、18、19、20、21 | [复杂需求来源稿](./独立方案/原需求/需求15-16-18-19-20-21-复杂需求.md) |
| 17 | [信用额度](./独立方案/原需求/需求17-信用额度.md) |
| 22 | [套餐临期提醒](./独立方案/原需求/需求22-套餐临期提醒.md) |
需求16已经移出本期仅在来源稿中保留讨论记录。
## 新增需求独立稿
| 编号 | 来源文件 |
|------|----------|
| 新增01 | [数据同步触发与轮询优化](./独立方案/新增需求/01-数据同步触发与轮询优化.md) |
| 新增02 | [企业微信审批接入](./独立方案/新增需求/02-企业微信审批接入.md) |
| 新增03 | [站内通知详细方案](./独立方案/新增需求/03-站内通知详细方案.md) |
| 新增04 | [全局多视角审计方案](./独立方案/新增需求/04-全局多视角审计方案.md) |
| 新增05 | [代理钱包扫码充值](./独立方案/新增需求/05-代理钱包扫码充值.md) |
| 禅道 #98/#86 | [换货归属与资产换货标识](./独立方案/新增需求/06-换货归属与资产换货标识.md) |
| 禅道 #96/#97 | [店铺业务员与钱包余额预警](./独立方案/新增需求/07-店铺业务员与钱包余额预警.md) |
| 禅道 #43 | [代理系列套餐批量授权](./独立方案/新增需求/08-代理系列套餐批量授权.md) |
## 基础规范与历史方案
- [DDD 规范](./独立方案/基础规范/DDD规范.md)
- [系统配置独立方案](./独立方案/基础规范/系统配置.md)
- [本地通用审批流(已废弃)](./独立方案/历史方案/本地通用审批流-已废弃方案.md)
- [站内消息初版(已被详细方案替代)](./独立方案/历史方案/站内消息-初版.md)
- [前端共性方案历史稿](./独立方案/历史方案/前端共性方案-历史稿.md)
原始讨论材料:
- [原始业务需求](./来源材料/业务需求.md)
- [新增需求讨论与示例代码](./来源材料/新需求.md)
- [禅道用户需求导出](./物联网卡管系统-需求.csv)
这些文件只用于追溯,不是技术实施结论。
已删除的 `00-总览.md``7月迭代完整技术方案-评审稿.md` 都是重复汇编,不包含独立来源信息。

View File

@@ -0,0 +1,396 @@
> 本文只保留原始业务需求措辞,不作为技术实施结论。最终口径以 [标准评审稿](../7月迭代技术方案-标准评审稿.md) 为准。
其中有一些需要对接第三方系统的,除了gateway,都可以划分阶段
1.当前所有的卡在后台手动操作复机必须要实名,实际上行业卡应当允许未实名复机
2. 用户进入H5后需要先绑定手机号后强制先充值后强制实名。这个功能能否进行后台设置例如这一批设备需要强制先充值后实名这一批资产可以先实名后充值
3.店铺列表搜索栏新增一项:联系电话,便于搜索
4.操作拦截:若当前资产存在退款申请时(但为通过审批时),该资产不允许操作换货。且出现提示:该资产存在退款申请
5. 套餐延续创建时选择购买即生效或实名即生效的条件,同时在套餐分配时提供修改条件的功能,并以变更后的条件为最终版本,已经分配出去的套餐不会被后续的宿主套餐修改影响,需要回收后重新分配才能生效
6. 在资产详情页增加所有待生效套餐加上生效套餐加起来的最后到期时间
7.lot卡管理和设备管理新增已实名/未实名的筛选查询条件
8.设备批量分配代理和套餐系列:因设备号不是连号故需要提供导入excel表的方式进行批量分配代理和套餐系列。excle表头为设备号
> 评审结论:拆成“批量分配代理”和“批量分配套餐系列”两个独立命令、两个前端入口和两个任务类型;可复用 Excel 解析与任务基础设施,但单个任务不得同时修改两个字段。
9.C端支付时,卡资产只允许支付宝支付以及钱包支付,如果用微信支付就拒绝,设备只允许微信支付以及钱包支付,如果用支付宝支付就拒绝
10.限速规则:根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则,限速接口由gateway提供
> 技术口径说明Gateway 只支持按 `cardNo` 限速,不存在设备级限速。单卡直接使用 ICCID设备场景先查 `tb_device_sim_binding.is_current=true` 的当前卡,再使用该卡 ICCID。取消限速仍重复调用同一个限速接口只是发送取消参数。本期仅提供后台手动设置/取消,内部单位为 `kbps`;不做套餐字段和自动限速规则。
11. 资产信息详情字段新增:资产信息页面卡信息/设备信息板块将当前生效套餐的过期时间作为一个字段显示且套餐还剩15天到期时该字段高亮显示。
12. 换货管理:
| 编号 | 需求 |
| ------- | ---------------------------------------------------- |
| EXC-001 | 修正换货列表旧资产标识和新资产标识显示混乱问题。 |
| EXC-002 | 换货列表中旧资产标识符和新资产标识符均显示为 ICCID。 |
| EXC-003 | 旧资产查询支持 ICCID、接入号、虚拟号。 |
| EXC-004 | 新资产查询支持 ICCID、接入号、虚拟号。 |
13.列表字段新增:
| 编号 | 模块 | 新增字段 |
| ------- | ------------ | -------------- |
| COL-001 | 退款管理列表 | 提交人、审批人 |
| COL-002 | 代理充值列表 | 提交人、审批人 |
| COL-003 | 换号管理列表 | 提交人 |
14. 导出功能:
## 6.8.1 lot 卡导出
### 支持套餐临期30天内所有资产的导出。字段按照卡/设备的导出表进行导出。
| 编号 | 需求 |
| -------- | ---------------------------- |
| EXPD-001 | lot 卡导出字段新增套餐名称。 |
| EXPD-002 | lot 卡导出字段新增使用流量。 |
| EXPD-003 | lot 卡导出字段新增剩余流量。 |
## 6.8.2 代理资金概况-预充值钱包流水导出
导出字段:
| 字段 | 说明 |
| ------------------- | ------------------------------------------------------------ |
| 店铺名称 | 代理店铺名称 |
| 交易类型 | 充值、扣款、退款等 |
| 交易金额 | 以元为单位 |
| 状态 | 交易状态 |
| 资产类型 | 卡/设备等 |
| 资产标识 | ICCID/设备号等 |
| 交易时间 | 流水生成时间 |
| 交易前金额 | 交易前余额 |
| 交易后金额 | 交易后余额 |
| 购买套餐名称 | 资产此条扣款记录对应的套餐名称 |
| 操作人 | 明确交易执行主体:代理账号 / 平台账号(明确账号名称) |
| 交易 ID | 每一笔流水的全局唯一主键,彻底避免重复流水、对账串号、精准定位单条交易 |
| 关联业务订单号 | 充值、扣款、退款等交易对应的原始业务订单编号 |
| 交易渠道 / 支付方式 | 明确交易来源:余额支付等 |
## 6.8.3 套餐列表导出
导出字段:
| 字段 |
| -------------- |
| 套餐编码 |
| 套餐名称 |
| 套餐系列名称 |
| 套餐类型 |
| 套餐时长(月) |
| 套餐时长说明 |
| 套餐周期类型 |
| 套餐天数 |
| 真流量额度(MB) |
| 虚流量额度(MB) |
| 是否启用虚流量 |
| 虚流量比例 |
| 流量重置周期 |
| 到期时间基准 |
| 成本价(元) |
| 建议售价(元) |
| 价格配置状态 |
| 状态 |
| 上架状态 |
| 是否赠送套餐 |
| 创建人ID |
| 更新人ID |
| 创建时间 |
| 更新时间 |
| 删除时间 |
## 6.8.4 退款管理退款列表导出
导出字段:
| 字段 |
| ---------------- |
| 退款单号 |
| 代理店铺名称 |
| 关联的支付订单号 |
| 资产类型 |
| 资产标识 |
| 套餐名称 |
| 原订单金额 |
| 实收金额 |
| 可退金额 |
| 申请退款金额 |
| 实际退款金额 |
| 退款到账方式 |
| 状态 |
| 退款原因 |
| 备注 |
| 审批备注 |
| 退款申请时间 |
| 退款完成时间 |
| 提交人 |
| 部门领导审批人 |
| 财务审批人 |
| 退款凭证 |
## 6.8.5 换货管理导出
### C端客户有自己的唯一标识码。换货管理可以针对该C端客户记录该客户换过几次设备或卡。同时资产本身也做换货标识。
导出字段:
| 字段 |
| ------------ |
| 换货单号 |
| 换货类型 |
| 换货原因 |
| 问题描述 |
| 旧资产类型 |
| 旧资产标识符 |
| 新资产标识符 |
| 收货人姓名 |
| 收货人电话 |
| 收货地址 |
| 快递公司 |
| 快递单号 |
| 状态 |
| 创建人 |
| 创建时间 |
## 6.8.6 代理充值导出
导出字段:
| 字段 |
| -------------- |
| 充值单号 |
| 店铺名称 |
| 充值类型 |
| 充值金额 |
| 实付金额 |
| 充值前余额 |
| 充值后余额 |
| 状态 |
| 支付方式 |
| 支付通道 |
| 运营备注 |
| 驳回原因 |
| 创建时间 |
| 支付时间 |
| 完成时间 |
| 提交人 |
| 部门领导审批人 |
| 财务审批人 |
| 支付凭证 |
| 备注 |
| 套餐时长说明 | :这是剩余天数
| 退款到账方式 |
这个好像没有
### 之后是否需要增加?因加入财务审批操作退款,可能有原路退回或不同的退款方式如支付宝退款或微信退款?
| 部门领导审批人 |
| 财务审批人 |
这两个好像也没有
### 目前是没有呀,但是之前不是说了审批流程需要加上吗?列表字段同时需要新增
| 支付通道 |
这是啥玩意
### 去掉
"导出功能可以根据不同权限显示的字段不同且可以自己选择需要导出的字段。像现在这样全量导出,大家都可以看到虚流量等不想让所有人都看到的字段。" 什么叫不同权限,这个不同权限显示字段不同是固定的吗,平台就是固定的,代理就是固定的等等,如果是这样的话需要标记对应的导出字段的权限划分
### 因为不同的角色,权限不一致,列表的字段不能给每个用户看到类似真流量这样的字段,比如客服只能看到虚流量跟客户看到的一样,应当在角色管理中新增一个导出字段配置
> 评审结论:角色级导出字段配置属于本期范围。后端返回当前角色允许导出的字段,最终导出字段取“角色授权字段”与“用户本次选择字段”的交集,前端不能绕过服务端授权。
15. 套餐相关: 套餐下架后,正在使用该套餐的客户仍可续费。,下架套餐续费仅支持客户自己购买。 ,下架套餐不可被新购买。
16. 代理分销码与佣金提现
> 迭代范围变更2026-07-14需求 16 整体移出 7 月迭代,后续独立立项和评审。以下内容仅保留原始需求记录,本期不开发、不迁移、不发布。
### 员工可作为代理发展人进行标识。(在系统中如何体现?)
| 编号 | 需求 |
| ------- | ---------------------------------------------------- |
| DST-001 | 新建代理时自动建立分销归属关系。 |
| DST-002 | 员工可作为代理发展人进行标识。(在系统中如何体现?) |
| DST-003 | 佣金提现前,代理必须签署合同。(必填) |
| DST-004 | 佣金提现需上传营业执照。(可选) |
| DST-005 | 佣金提现需上传法人身份证。(必填) |
| DST-006 | 佣金提现可选上传门头照。(可选) |
| DST-007 | 佣金提现需上传发票,且公司主体需与合同一致。(可选) |
> 当前结论:原技术方案不再属于 7 月迭代范围。后续重新立项时再评审分销关系、代理申请审批、开店幂等和提现材料。
17. 不同渠道信用额度,钱包支持信用额度支付
| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |
| BPO-009 | 新建代理时新增“是否可授权额度”开关。平台用户账号默认拥有授权额度。 |
| BPO-010 | 不同代理可设置不同额度下限。平台用户可根据不同的角色设置不同的额度下限。 |
| BPO-011 | 授权额度用于订购套餐、代理余额充值等需要涉及金额的所有模块。 |
| BPO-012 | 授权额度代理可显示负数余额。平台用户也可显示负数余额。 |
> 评审结论:信用额度只属于代理主钱包。平台员工是操作主体而不是结算主体,不建立员工钱包、不配置员工信用额度,也不展示员工负余额。客户角色可以配置以后新建店铺的默认额度;修改角色不更新已有店铺,已有店铺只能在店铺资金页面直接调整实际额度。
18. 系统原先对于审核都是单人审核,现在希望增加多人审批以及相关设置
| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |
| APR-001 | 代理可在系统提交充值申请。 |
| APR-002 | 提交充值申请后系统展示收款二维码,代理扫码支付。 |
| APR-003 | 充值和退款均支持多级审核。 |
| APR-004 | 审核环节包括提交人部门领导审核和财务审核。 |
| APR-005 | 待审核订单需有消息提示。 |
| APR-006 | 审核提醒按流程环节触发,上一审批人完成审批后才提示下一审批人。 |
| APR-007 | 审核通过后通知申请人。 |
| APR-008 | 审核驳回后通知申请人,并附带驳回原因。 |
| APR-009 | 审核流程需对接企业微信审批流程。 |
> 技术口径说明:当前系统没有部门组织模型。“部门领导审核、财务审核”作为默认流程节点名称处理,实际审批人由流程定义配置的角色或指定账号产生,不根据提交人部门自动推导,也不在代码中固定角色名称。每次通过、驳回、退回都形成不可修改的审批意见记录,并可附带最多 5 个审批附件;驳回和退回意见必填。
> 审批详情必须展示业务单号、提交人、审批关键字段、业务资料、此前审批人的意见和审批附件。业务资料与审批附件分开存储和展示;流程启动时固化业务快照,实时业务页仍按原业务数据范围校验。
19.批量订购套餐
内部员工进入批量订购页面。
2. 选择代理、ICCID号段/设备号、订购套餐。或上传 Excel 文件,资产标识支持 ICCID/设备号
3. Excel表头跳转至6.3会显示。
4. 系统校验导入文件和资产状态。
5. 校验通过的资产从代理余额扣款并完成订购。
6. 校验失败的明细在页面展示失败原因。
7. 系统记录操作员、导入明细、导入数量和导入时间。
| 编号 | 需求 |
| ------- | ---------------------------------------------------- |
| BPO-001 | 批量订购由内部员工操作。 |
| BPO-002 | 支持代理自行充值钱包后,由员工批量订购并从余额扣款。 |
| BPO-003 | 支持员工代充值至代理余额后,再批量订购并从余额扣款。 |
| BPO-004 | 批量订购无需审核。 |
| BPO-005 | 资产标识支持 ICCID/设备号。 |
| BPO-006 | 支持 Excel 模板导入。 |
| BPO-007 | 需记录操作员、导入明细、导入数量和导入时间。 |
| BPO-008 | 导入失败明细需展示在页面,并显示失败原因。 |
excel表导入字段
| 字段 |
| ----------------------------- |
| 资产类型 |
| 资产标识 |
| 套餐系列名称 |
| 套餐名称 |
| 代理名称 |
| 支付方式:代理商账户/员工账户 |
excel表导入字段修改为以下
| 字段 |
| ----------------------------- |
| 资产类型 |
| 资产标识 |
| 套餐编码 |
| 套餐名称|
| 支付方式:线下支付/代理钱包支付 |
如果用批量订购应当上传对应凭证
> 评审结论支付方式按整批统一。页面选择“线下支付”或“代理钱包支付”Excel 不再包含支付方式列;线下支付按整批上传凭证,混合支付必须拆成不同批次。
20.退款审批
(审批均可通过企微进行提醒和显示并将最新状态同步至卡管)
1. 员工提交退款申请。
2. 退款单进入多级审核流程。
3. 按部门领导、财务顺序审批。
4. 当前环节审批完成后,下一环节审批人收到消息提示。
5. 审批通过或驳回后通知申请人。
> 评审结论:微信、支付宝和线下退款由财务在系统外人工完成后确认;本期不接入第三方自动退款。代理钱包支付的退款仅自动回退原扣款代理主钱包,客户资产钱包不在本期自动回退范围。
21. 充值审核流程
代理自己充值:
1. 代理在系统提交充值申请。
2. 系统展示收款二维码。
3. 代理扫码支付。
员工代充值:(审批均可通过企微进行提醒和显示并将最新状态同步至卡管)
1. 充值单进入多级审核流程。
2. 提交人部门领导先审批。
3. 财务在上一审批人通过后收到待办提醒并审批。
4. 审批通过后通知申请人。
5. 审批驳回后通知申请人,并展示驳回原因。
> 技术口径说明:审批通过只表示审批结论成立,不表示退款已经到账或充值已经入账。退款、充值分别维护业务处理状态并支持异步重试。此次采用停机发布,旧退款业务单审批接口和线下充值 `offline-pay/reject` 不保留兼容窗口。
22. 套餐临期提醒
1. 系统每日计算卡/设备套餐剩余有效期。
2. 命中临期规则后生成临期数据。临期提醒规则:按 15 天、7 天、3 天节点分别进行不同方式的提醒。
3. 企业客户场景:按 15 天、7 天、3 天节点生成临期列表,并通过企业微信推送给对应业务员。
4. 代理端场景(展示):首页展示卡、设备临期数量;资产列表按临期天数高亮。
5. C 端场景(展示):公众号首页在套餐剩余有效期小于或等于 15天时展示续费提醒并显示立即续费按钮。
6. 当资产续费成功或不再满足临期条件时,临期提醒自动取消。
## 6.1.1 企业客户临期提醒
| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |
| EXP-001 | 后台每日生成企业客户临期列表。 |
| EXP-002 | 临期节点包括 15 天、7 天、3 天。 |
| EXP-003 | 系统需对接企业微信,将临期列表推送给对应业务员。 |
| EXP-004 | 同一资产在不同临期节点可重复触发对应节点提醒,但同一节点每日不可重复推送给同一业务员。 |
## 6.1.2 代理端临期提醒
| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |
| EXP-005 | 代理端首页分别展示临期卡数量和临期设备数量。 |
| EXP-006 | 代理端资产列表对临期资产进行颜色标记。 |
| EXP-007 | 剩余 15 天标记为粉色,剩余 7 天标记为紫色,剩余 3 天标记为红色。 |
| EXP-008 | 剩余 3 天资产在列表中置顶优先展示。 |
## 6.1.3 C 端公众号首页提醒
| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |
| EXP-009 | 当客户套餐剩余有效期小于或等于 15 天时,公众号首页展示套餐到期提醒。 |
| EXP-010 | 提醒展示卡号/设备号、剩余有效期和续费引导文案。 |
| EXP-011 | 按钮文案为“立即续费”。 |
| EXP-012 | 剩余天数需要按日期每天自动更新。 |
| EXP-013 | 当套餐已续费或不再满足临期条件时,提醒不再展示。 |
推荐展示文案:
您的套餐即将到期
卡号/设备号xxx
剩余有效期xx 天
为避免到期后影响正常使用,请您提前完成续费。
当一个资产拥有排队中的套餐时不属于临期,不参与临期提醒
后台管理只需要在列表检索中新增临期时间字段 条件为小于等于15天的
后台管理中资产详情需要新增一个字段临期时间从15天开始显示,前端应当高亮或者变红
后台管理中资产列表需要新增返回字段,套餐还有多少天过期
临期列表页面需要确认列表展示什么,检索有什么,导出要什么
##### 展示:资产标识、资产类型、店铺、套餐名称、剩余天数、到期时间、资产状态、已用流量、剩余流量
##### 检索条件:资产标识、资产类型、套餐名称、到期时间范围、剩余天数、店铺
##### 导出:资产标识、资产类型、店铺名称、套餐名称、套餐到期时间、剩余天数、资产状态、已用流量、剩余流量
临期规则
企业客户 15,7,3天
代理 15,7,3天
C端公众号 小于等于15天
颜色规则
临期天数小于等于15天时显示粉红色
临期天数小于等于7天时显示紫色
临期天数小于等于3天时显示黄色
> 最终评审口径资产临期按当前及排队主套餐的预计最终到期时间计算3天内改为红色只在临期独立列表置顶。七月迭代不发送企业微信临期消息只发送站内通知。

View File

@@ -0,0 +1,729 @@
> 本文保留新增需求讨论和示例代码,不作为技术实施结论。最终口径以 [标准评审稿](../7月迭代技术方案-标准评审稿.md) 及对应新增需求独立稿为准。
处理回调的代码
```golang
package main
import (
"bytes"
"encoding/json"
"encoding/xml"
"fmt"
"io"
"io/ioutil"
"log"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"time"
ranNumLib "math/rand"
"github.com/google/uuid"
)
// XML请求体结构
type ContractRoot struct {
XMLName xml.Name `xml:"ContractRoot"`
Type string `xml:"TYPE"`
GroupTransactionID string `xml:"GROUP_TRANSACTIONID"`
StatusInfo string `xml:"STATUSINFO"`
AccNbr string `xml:"ACCNBR"`
ICCID string `xml:"ICCID"`
SendDt string `xml:"SENDDT"`
AcceptType string `xml:"ACCEPTTYPE"`
AcceptMsg string `xml:"ACCEPTMSG"`
StatusDt string `xml:"STATUSDT"`
ResultMsg string `xml:"RESULTMSG"`
}
// 第三方推送数据结构
type PushData struct {
Msg string `json:"msg"`
Code int `json:"code"`
Data struct {
RequestID string `json:"requestId"`
RealStatus bool `json:"realStatus"`
ICCID string `json:"iccid"`
} `json:"data"`
}
func parseCallback(jsonStr []byte) (string, string, error) {
// 定义匿名结构体用于解析外层JSON
var cb struct {
Data string `json:"data"`
}
err := json.Unmarshal(jsonStr, &cb)
if err != nil {
return "", "", fmt.Errorf("failed to unmarshal outer JSON: %v", err)
}
// 定义匿名结构体用于解析内层数据
var inner struct {
DateChanged string `json:"dateChanged"`
ICCID string `json:"iccid"`
}
err = json.Unmarshal([]byte(cb.Data), &inner)
if err != nil {
return "", "", fmt.Errorf("failed to unmarshal inner data JSON: %v", err)
}
return inner.ICCID, inner.DateChanged, nil
}
// 5GCMP实名后推送到第三方平台
func pushToThirdParty(iccid string) (string, error) {
// 构建推送数据
pushData := PushData{
Msg: "查询成功",
Code: 200,
}
pushData.Data.RequestID = uuid.New().String()
pushData.Data.RealStatus = true
pushData.Data.ICCID = iccid
// 序列化为JSON
jsonData, err := json.Marshal(pushData)
if err != nil {
return "", fmt.Errorf("序列化推送数据失败: %v", err)
}
// 发送HTTP POST请求
pushURL := "http://jh.whjhft.com/gswlpushapi/recv.do?type=3"
client := &http.Client{
Timeout: 10 * time.Second,
}
resp, err := client.Post(pushURL, "application/json", bytes.NewBuffer(jsonData))
if err != nil {
return "", fmt.Errorf("HTTP请求失败: %v", err)
}
defer resp.Body.Close()
// 读取响应
respBody, err := io.ReadAll(resp.Body)
if err != nil {
return "", fmt.Errorf("读取响应失败: %v", err)
}
return string(respBody), nil
}
// 获取日志文件路径
func getLogFilePath() string {
// 创建logs目录
logsDir := "logs"
if err := os.MkdirAll(logsDir, 0755); err != nil {
log.Printf("创建日志目录失败: %v", err)
}
// 按日期生成文件名
dateStr := time.Now().Format("2006-01-02")
fileName := fmt.Sprintf("5gcmp_callback_%s.log", dateStr)
return filepath.Join(logsDir, fileName)
}
func GetQcRandNum() (ranNum string) {
randomFloat := ranNumLib.Float64()
if randomFloat < 0.5 {
randomFloat = 1 - randomFloat
}
ranNum = fmt.Sprintf("%.16f", randomFloat)
return
}
// 写入日志
func writeLog(content string) {
logFile := getLogFilePath()
// 打开或创建日志文件
file, err := os.OpenFile(logFile, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0644)
if err != nil {
log.Printf("打开日志文件失败: %v", err)
return
}
defer file.Close()
// 写入日志内容
if _, err := file.WriteString(content); err != nil {
log.Printf("写入日志失败: %v", err)
}
}
// 向管理平台发送删除实名请求
func DelRealName(iccid string) (res string) {
account := Account{UserName: "18627991016", Password: "y123456"}
sessionid := account.Login()
realnameId := getRealnameIdByIccid(iccid, sessionid)
if realnameId == "" {
return
}
log.Printf("sessinid:%s,realnameId:%s", sessionid, realnameId)
url := "http://jh.whjhft.com/realnamerecord/deleteById.do?responseFunction=initUpdate&id=" + realnameId + "&rfm=" + GetQcRandNum()
data := fmt.Sprintf("status=1&iccidMark=%s", iccid)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
log.Printf("创建请求失败: %v", err)
return ""
}
client := &http.Client{
Timeout: 10 * time.Second,
}
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
resp, err := client.Do(req)
if err != nil {
log.Printf("HTTP请求失败: %v", err)
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("读取响应失败: %v", err)
return
}
log.Printf("删除实名响应: %s", respBody)
res = string(respBody)
return
}
func getRealnameIdByIccid(iccid, sessionid string) (realnameId string) {
url := "http://jh.whjhft.com/realnamerecord/grid.do?responseFunction=grid&pageSize=15&pageNo=1&rfm=0." + GetQcRandNum()
client := &http.Client{
Timeout: 10 * time.Second,
}
data := fmt.Sprintf("status=1&iccidMark=%s", iccid)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
log.Printf("创建请求失败: %v", err)
return
}
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
resp, err := client.Do(req)
if err != nil {
log.Printf("HTTP请求失败: %v", err)
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("读取响应失败: %v", err)
return
}
var JSONData struct {
Code string `json:"code"`
Data struct {
PageNo int `json:"pageNo"`
PageCount int `json:"pageCount"`
PageSize int `json:"pageSize"`
PageStartOffset int `json:"pageStartOffset"`
Total int `json:"total"`
Rows []struct {
MybatisRecordCount int `json:"mybatisRecordCount"`
OrderNo string `json:"orderNo"`
JSONUpdateFlag string `json:"jsonUpdateFlag"`
ID string `json:"id"`
IccidMark string `json:"iccidMark"`
Phone string `json:"phone"`
AccountID string `json:"accountId"`
AccountName string `json:"accountName"`
Status int `json:"status"`
CreateName string `json:"createName"`
CreateDate string `json:"createDate"`
StatusStr string `json:"statusStr"`
} `json:"rows"`
Framework string `json:"framework"`
Data string `json:"data"`
Count int `json:"count"`
Limit int `json:"limit"`
Page int `json:"page"`
Layui bool `json:"layui"`
} `json:"data"`
CurrentSessionUserResourceIdsIndex []string `json:"current_session_user_resource_ids_index"`
AppResultKey string `json:"app_result_key"`
SystemResultKey string `json:"system_result_key"`
}
json.Unmarshal(respBody, &JSONData)
if JSONData.AppResultKey == "0" && JSONData.SystemResultKey == "0" && JSONData.Data.Count > 0 {
realnameId = JSONData.Data.Rows[0].ID
}
log.Printf("响应体: %s", respBody)
return
}
func getIccidByMsisdn(msisdn, sessionid string) (iccid string) {
url := "http://jh.whjhft.com/realnamerecord/grid.do?responseFunction=grid&pageSize=15&pageNo=1&rfm=" + GetQcRandNum()
client := &http.Client{
Timeout: 10 * time.Second,
}
data := fmt.Sprintf("status=1&phone=%s", msisdn)
req, err := http.NewRequest("POST", url, strings.NewReader(data))
if err != nil {
log.Printf("创建请求失败: %v", err)
return
}
req.Header.Set("Cookie", fmt.Sprintf("JSESSIONID=%s", sessionid))
req.Header.Set("Content-Type", "application/x-www-form-urlencoded; charset=UTF-8")
req.Header.Set("user-agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.37 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
resp, err := client.Do(req)
if err != nil {
log.Printf("HTTP请求失败: %v", err)
return
}
defer resp.Body.Close()
respBody, err := io.ReadAll(resp.Body)
if err != nil {
log.Printf("读取响应失败: %v", err)
return
}
var JSONData struct {
Code string `json:"code"`
Data struct {
Rows []struct {
IccidMark string `json:"iccidMark"`
} `json:"rows"`
Count int `json:"count"`
} `json:"data"`
AppResultKey string `json:"app_result_key"`
SystemResultKey string `json:"system_result_key"`
}
json.Unmarshal(respBody, &JSONData)
if JSONData.AppResultKey == "0" && JSONData.SystemResultKey == "0" && JSONData.Data.Count > 0 {
iccid = JSONData.Data.Rows[0].IccidMark
}
log.Printf("响应体: %s", respBody)
return
}
func ModifyDate(iccid, dateChanged string) {
var jsonData = map[string]interface{}{
"iccid": iccid,
"dateChanged": dateChanged,
}
jsonDataBs, _ := json.Marshal(jsonData)
// 创建请求
req, err := http.NewRequest("POST", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", bytes.NewReader(jsonDataBs))
if err != nil {
writeLog(fmt.Sprintf("创建请求失败:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
// 设置Content-Type为x-www-form-urlencoded
req.Header.Set("Content-Type", "application/json")
// 发送请求
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
defer resp.Body.Close()
// 读取响应内容
respBody, err := io.ReadAll(resp.Body)
if err != nil {
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
// 检查响应状态
if resp.StatusCode != http.StatusOK {
fmt.Printf("请求失败,状态码: %d, 响应: %s\n", resp.StatusCode, string(respBody))
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]失败\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged))
return
}
writeLog(fmt.Sprintf("请求接口:[%s] [%s]实名时间[%s]成功,[%s]\r\n", "http://127.0.0.1:3000/api/v1/inventory/realname/inner_callback", iccid, dateChanged, string(respBody)))
}
// 处理回调请求
func handleCallback(w http.ResponseWriter, r *http.Request) {
// 记录请求开始时间
startTime := time.Now()
timestamp := startTime.Format("2006-01-02 15:04:05")
// 构建日志内容
var logBuilder strings.Builder
logBuilder.WriteString("\n========================================\n")
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
// 记录请求头
logBuilder.WriteString("--- 请求头 ---\n")
for name, values := range r.Header {
for _, value := range values {
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
}
}
// 记录请求体
logBuilder.WriteString("--- 请求体 ---\n")
body, err := io.ReadAll(r.Body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
} else {
if len(body) > 0 {
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
} else {
logBuilder.WriteString("(空请求体)\n")
}
}
// 处理XML请求体和第三方推送
var pushResponse string
log.Printf("body:%s\r\n", string(body))
if len(body) > 0 {
// 尝试解析XML
var contractRoot ContractRoot
if err := xml.Unmarshal(body, &contractRoot); err == nil {
logBuilder.WriteString("--- XML解析结果 ---\n")
logBuilder.WriteString(fmt.Sprintf("TYPE: %s\n", contractRoot.Type))
logBuilder.WriteString(fmt.Sprintf("ICCID: %s\n", contractRoot.ICCID))
logBuilder.WriteString(fmt.Sprintf("STATUSINFO: %s\n", contractRoot.StatusInfo))
// 如果TYPE=1表示实名认证成功需要推送到第三方
if strings.Contains(contractRoot.AcceptMsg, "已完成实名信息补录") && contractRoot.ICCID != "" && contractRoot.ResultMsg == "成功" {
logBuilder.WriteString("--- 第三方推送 ---\n")
logBuilder.WriteString(fmt.Sprintf("触发条件: TYPE=%s (实名认证成功)\n", contractRoot.Type))
logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", contractRoot.ICCID))
logBuilder.WriteString("推送地址: http://jh.whjhft.com/gswlpushapi/recv.do?type=3\n")
timestampStr := strconv.FormatInt(time.Now().Unix(), 10)
ModifyDate(contractRoot.ICCID, timestampStr)
// 执行推送
if resp, err := pushToThirdParty(contractRoot.ICCID); err != nil {
logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err))
pushResponse = fmt.Sprintf("推送失败: %v", err)
} else {
logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp))
pushResponse = resp
}
} else if strings.Contains(contractRoot.AcceptMsg, "已完成实名信息清除") && contractRoot.ICCID != "" && contractRoot.ResultMsg == "成功" {
logBuilder.WriteString("--- 第三方推送删除实名 ---\n")
res := DelRealName(contractRoot.ICCID)
logBuilder.WriteString(fmt.Sprintf("删除实名响应: %s\n", res))
} else {
logBuilder.WriteString("--- 第三方推送 ---\n")
logBuilder.WriteString(fmt.Sprintf("跳过推送: TYPE=%s,%s,%s (非实名认证成功)\n", contractRoot.Type, contractRoot.AcceptMsg, contractRoot.ResultMsg))
}
} else {
logBuilder.WriteString(fmt.Sprintf("XML解析失败: %v\n", err))
}
}
// 记录处理时间
processTime := time.Since(startTime)
logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime))
logBuilder.WriteString("========================================\n")
// 写入日志文件
writeLog(logBuilder.String())
// 同时输出到控制台
fmt.Print(logBuilder.String())
// 返回成功响应
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// 构建响应数据
responseData := map[string]interface{}{
"code": 200,
"msg": "success",
"timestamp": timestamp,
}
// 如果有推送响应,添加到响应中
if pushResponse != "" {
responseData["pushResponse"] = pushResponse
}
respJSON, _ := json.Marshal(responseData)
w.Write(respJSON)
}
// 获取管理平台登录凭证
func (ac Account) Login() (sessionid string) {
var password string
password = ac.Password
var requestBody bytes.Buffer
multipartWriter := multipart.NewWriter(&requestBody)
multipartWriter.WriteField("username", ac.UserName)
multipartWriter.WriteField("password", password)
multipartWriter.Close()
req, _ := http.NewRequest("POST", "http://jh.whjhft.com/pages/login.do", &requestBody)
req.Header.Set("User-Agent", "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36 Edg/123.0.0.0")
req.Header.Set("Content-Type", multipartWriter.FormDataContentType())
//client1 := http.DefaultClient
client1 := &http.Client{
CheckRedirect: func(req1 *http.Request, via []*http.Request) error {
strs := strings.Split(req1.URL.Path, ";")
log.Printf("%s\r\n", req1.URL.Path)
if len(strs) == 2 {
strs1 := strings.Split(strs[1], "=")
if len(strs1) == 2 {
sessionid = strs1[1]
}
}
// fmt.Printf("Redirect from '%s' to '%s'\n", via[0].URL, req1.URL.Path)
return nil
},
}
resp, err := client1.Do(req)
if err != nil {
fmt.Println("Failed to send request:", err)
return
}
defer resp.Body.Close()
// 处理响应
_, err = ioutil.ReadAll(resp.Body)
if err != nil {
fmt.Println("Failed to read response:", err)
return
}
//fmt.Println("Response:", string(respBody))
return
}
// 移动实名回调
func ChinaMobileCallback(w http.ResponseWriter, r *http.Request) {
// 记录请求开始时间
startTime := time.Now()
timestamp := startTime.Format("2006-01-02 15:04:05")
// 构建日志内容
var logBuilder strings.Builder
logBuilder.WriteString("\n========================================\n")
logBuilder.WriteString("【移动实名回调】\n")
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
// 记录请求头
logBuilder.WriteString("--- 请求头 ---\n")
for name, values := range r.Header {
for _, value := range values {
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
}
}
// 记录请求体
logBuilder.WriteString("--- 请求体 ---\n")
body, err := io.ReadAll(r.Body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
} else {
if len(body) > 0 {
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
var JSONData struct {
Status string `json:"status"`
Message string `json:"message"`
Result []struct {
RegStatus string `json:"regStatus"`
BusiSeq string `json:"busiSeq"`
Msisdn string `json:"msisdn"`
Iccid string `json:"iccid"`
} `json:"result"`
}
json.Unmarshal(body, &JSONData)
if JSONData.Status == "0" && JSONData.Message == "正确" && len(JSONData.Result) > 0 && JSONData.Result[0].RegStatus == "00000" {
iccid := JSONData.Result[0].Iccid
if iccid == "" {
msisdn := JSONData.Result[0].Msisdn //接入号
account := Account{UserName: "18627991016", Password: "y123456"}
sessionid := account.Login()
iccid = getIccidByMsisdn(msisdn, sessionid)
}
if iccid == "" {
return
}
//推送修改过期时间
timestampStr := strconv.FormatInt(time.Now().Unix(), 10)
ModifyDate(iccid, timestampStr)
logBuilder.WriteString("--- 第三方推送 ---\n")
logBuilder.WriteString(fmt.Sprintf("推送ICCID: %s\n", iccid))
logBuilder.WriteString("推送地址: http://jh.whjhft.com/gswlpushapi/recv.do?type=3\n")
// 执行推送实名状态
if resp, err := pushToThirdParty(iccid); err != nil {
logBuilder.WriteString(fmt.Sprintf("推送失败: %v\n", err))
} else {
logBuilder.WriteString(fmt.Sprintf("推送成功,响应: %s\n", resp))
}
logBuilder.WriteString("--- 第三方推送 ---\n")
} else {
logBuilder.WriteString("(实名认证失败)\n")
}
} else {
logBuilder.WriteString("(空请求体)\n")
}
}
defer r.Body.Close()
// 记录处理时间
processTime := time.Since(startTime)
logBuilder.WriteString(fmt.Sprintf("处理耗时: %v\n", processTime))
logBuilder.WriteString("========================================\n")
// 写入日志文件
writeLog(logBuilder.String())
// 同时输出到控制台
fmt.Print(logBuilder.String())
// 返回200 OK响应
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
// 构建响应数据
responseData := map[string]interface{}{
"code": 200,
"msg": "success",
"timestamp": timestamp,
}
respJSON, _ := json.Marshal(responseData)
w.Write(respJSON)
}
// 联通解除实名回调
func UniRealnameRemove(w http.ResponseWriter, r *http.Request) {
// 记录请求开始时间
startTime := time.Now()
timestamp := startTime.Format("2006-01-02 15:04:05")
// 构建日志内容
var logBuilder strings.Builder
logBuilder.WriteString("\n========================================\n")
logBuilder.WriteString(fmt.Sprintf("请求时间: %s\n", timestamp))
logBuilder.WriteString(fmt.Sprintf("请求方法: %s\n", r.Method))
logBuilder.WriteString(fmt.Sprintf("完整URL: %s\n", r.URL.String()))
logBuilder.WriteString(fmt.Sprintf("请求路径: %s\n", r.URL.Path))
logBuilder.WriteString(fmt.Sprintf("查询参数: %s\n", r.URL.RawQuery))
logBuilder.WriteString(fmt.Sprintf("客户端IP: %s\n", r.RemoteAddr))
// 记录请求头
logBuilder.WriteString("--- 请求头 ---\n")
for name, values := range r.Header {
for _, value := range values {
logBuilder.WriteString(fmt.Sprintf("%s: %s\n", name, value))
}
}
// 记录请求体
logBuilder.WriteString("--- 请求体 ---\n")
body, err := io.ReadAll(r.Body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("读取请求体失败: %v\n", err))
} else {
if len(body) > 0 {
logBuilder.WriteString(fmt.Sprintf("%s\n", string(body)))
} else {
logBuilder.WriteString("(空请求体)\n")
}
}
defer r.Body.Close()
// 解析回调数据
iccid, dateChanged, err := parseCallback(body)
if err != nil {
logBuilder.WriteString(fmt.Sprintf("解析回调数据失败: %v\n", err))
} else {
if len(iccid) == 20 {
//取前面19位
iccid = iccid[:19]
//删除实名
res := DelRealName(iccid)
logBuilder.WriteString(fmt.Sprintf("删除实名响应: %s\n", res))
}
logBuilder.WriteString(fmt.Sprintf("解析回调数据成功: ICCID=%s, DateChanged=%s\n", iccid, dateChanged))
}
// 写入日志文件
writeLog(logBuilder.String())
// 同时输出到控制台
fmt.Print(logBuilder.String())
// 构建响应数据
responseData := map[string]interface{}{
"code": 200,
"msg": "success",
"timestamp": timestamp,
}
respJSON, _ := json.Marshal(responseData)
w.Write(respJSON)
}
func main() {
// 注册路由
// res := DelRealName("8986112422108176397")
// log.Printf("删除实名响应: %s", res)
http.HandleFunc("/5gcmp/callback/realname", handleCallback)
http.HandleFunc("/unicom/callback/realname/remove", UniRealnameRemove)
http.HandleFunc("/mobile/callback/realname", ChinaMobileCallback)
// 启动服务器
port := ":16159"
fmt.Printf("5GCMP回调服务器启动成功\n")
fmt.Printf("监听端口: %s\n", port)
fmt.Printf("电信回调地址: %s/5gcmp/callback/realname\n", port)
fmt.Printf("联通回调地址: %s/unicom/callback/realname/remove\n", port)
fmt.Printf("移动回调地址: %s/mobile/callback/realname\n", port)
fmt.Printf("日志目录: logs/\n")
fmt.Printf("按 Ctrl+C 停止服务器\n\n")
if err := http.ListenAndServe(port, nil); err != nil {
log.Fatalf("启动服务器失败: %v", err)
}
}
type Account struct {
UserName string
Password string
}
```
优化轮询以及添加事件/触发式 数据同步
目前我们系统过于依赖轮询系统,且轮询系统的黑盒属性过于严重
所以现在想加入事件触发以及实名回调,目前已知的可配置实名回调只有移动,联通,电信三个运营商,广电是没有实名回调的,上方是之前已经实现过的实名回调
需求差不多是这么个需求,主要就是想让数据同步这一块的及时率达到一种很快的地步,看是怎么埋点,而且开放接口的埋点还需要特殊处理,不然的话代理拿着我们的开放接口乱调用的话就等于外置了一个轮询系统了

View File

@@ -0,0 +1,329 @@
"编号","所属产品","所属模块","所属计划","来源","来源备注","用户需求名称","描述","验收标准","需求层级","父需求","关键词","优先级","预计工时","当前状态","所处阶段","类别","T","B","C","由谁创建","创建日期","指派给","指派日期","抄送给","已评审人","评审时间","由谁关闭","关闭日期","关闭原因","最后修改","最后修改日期","反馈者","重复需求","附件"
"98 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","换过管理新资产归属","默认换货把旧资产的所属权一起划过去
","","1 ","0 ","","3(#3)","0.50 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-15 14:08:57","黄燚麒","2026-07-16 11:51:00","","
黄燚麒(#huang)","2026-07-16 11:50:00","","","","黄燚麒","2026-07-16 11:51:00","","","",""
"97 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理钱包阈值提醒","代理资金概况板块新增余额预警。不同代理可设置不同的预警额度。达到阈值时可提醒代理和其发展人。
","","1 ","0 ","","3(#3)","0.50 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-15 11:23:35","黄燚麒","2026-07-16 15:40:38","","
黄燚麒(#huang)","2026-07-16 15:40:00","","","","黄燚麒","2026-07-16 15:40:38","","","",""
"96 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","员工作为发展人进行标识","新建店铺添加业务员字段,可以进行指定相应业务员。非必填。同时店铺列表以及详情中新增发展人字段。发展人也可作为检索条件,检索发展人名下的相关店铺。
","","1 ","0 ","","2(#2)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-15 11:22:00","黄燚麒","2026-07-15 11:23:15","","
黄燚麒(#huang)","2026-07-15 11:23:00","","","","李昕娉","2026-07-15 11:23:42","","","",""
"94 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","系统状态同步优化以及回调处理","因资产迁移需要涉及回调和状态同步,故进行相关优化
","","1 ","0 ","","1(#1)","40.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-14 10:53:29","黄燚麒","2026-07-14 11:00:14","","
黄燚麒(#huang)","2026-07-14 10:59:00","","","","李昕娉","2026-07-14 11:01:12","","","",""
"86 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产详情中换货标识","在资产详情页卡信息或设备信息列表中需要显示是否发生过换货或是换货标识,标注新资产或旧资产。旧资产需要可以链接至新资产。<img src=&quot;{128.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=128&quot; /><img src=&quot;{129.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=129&quot; />
","","1 ","0 ","","1(#1)","2.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-13 11:50:17","黄燚麒","2026-07-13 11:54:36","","
黄燚麒(#huang)","2026-07-13 11:54:00","","","","黄燚麒","2026-07-13 11:54:36","","","",""
"73 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","行业卡操作停复机","行业卡允许未实名复机
","","1 ","0 ","","3(#3)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-11 10:02:33","黄燚麒","2026-07-11 14:22:26","","
黄燚麒(#huang)","2026-07-11 14:22:00","","","","黄燚麒","2026-07-11 14:22:26","","","",""
"62 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","新卡管H5需要设置先充值后实名","用户进入H5后需要先绑定手机号后强制先充值后强制实名。这个功能能否进行后台设置例如这一批设备需要强制先充值后实名这一批资产可以先实名后充值
","","1 ","0 ","","3(#3)","2.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 17:04:27","李昕娉","2026-07-09 18:13:07","","
黄燚麒(#huang)","2026-07-11 14:23:00","","","","黄燚麒","2026-07-11 14:23:36","","","",""
"60 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","店铺管理新增检索条件","店铺列表搜索栏新增一项:联系电话,便于搜索
","","1 ","0 ","","3(#3)","0.10 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 16:00:56","黄燚麒","2026-07-09 17:37:57","","
黄燚麒(#huang)","2026-07-09 17:37:00","","","","黄燚麒","2026-07-09 17:37:57","","","",""
"57 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","操作拦截","若当前资产存在退款申请时(但为通过审批时),该资产不允许操作换货。且出现提示:该资产存在退款申请
","","1 ","0 ","","3(#3)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 15:07:17","黄燚麒","2026-07-09 17:32:56","","
黄燚麒(#huang)","2026-07-09 17:32:00","","","","李昕娉","2026-07-09 17:33:21","","","",""
"55 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","套餐生效条件","套餐延续创建时选择购买即生效或实名即生效的条件,同时在套餐分配时提供修改条件的功能,并以变更后的条件为最终版本
","","1 ","0 ","","1(#1)","4.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 11:57:51","黄燚麒","2026-07-09 16:54:53","","
黄燚麒(#huang)","2026-07-09 16:54:00","","","","黄燚麒","2026-07-09 16:54:53","","","",""
"54 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产详情页面新增套餐到期时间显示","增加所有待生效套餐加上生效套餐加起来的最后到期时间
","","1 ","0 ","","2(#2)","","已关闭(#closed)","已关闭(#closed)","功能(#feature)","","","","李昕娉","2026-07-09 11:56:50","closed","2026-07-09 17:26:38","","
黄燚麒(#huang)","2026-07-09 17:26:00","黄燚麒","2026-07-09 17:26:38","重复(#duplicate)","黄燚麒","2026-07-09 17:26:38","","#46 资产信息详情字段新增","",""
"53 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产管理查询字段新增","lot卡管理和设备管理新增已实名/未实名的筛选查询条件
","","1 ","0 ","","1(#1)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-09 09:39:53","黄燚麒","2026-07-09 16:53:29","","
黄燚麒(#huang)","2026-07-09 16:53:00","","","","黄燚麒","2026-07-09 16:53:29","","","",""
"51 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","不同品类资产的换货","因换货存在不同资产间的换货,且存在补差价进行卡换设备的操作。但卡的套餐和设备套餐不同会存在补差价等操作。且原卡套餐需失效,新资产设备需要使用设备套餐。直接操作换货,套餐会同步卡套餐,对公司来说成本增加。
","","1 ","0 ","","1(#1)","","草稿(#draft)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 17:54:32","李昕娉","2026-07-09 16:53:05","","","2026-07-09 16:52:00","","","","黄燚麒","2026-07-09 16:53:05","","","",""
"49 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","设备批量分配代理和套餐系列","因设备号不是连号故需要提供导入excel表的方式进行批量分配代理和套餐系列。excle表头为设备号
","","1 ","0 ","","1(#1)","2.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 17:43:58","黄燚麒","2026-07-08 17:43:58","","
黄燚麒(#huang)","2026-07-09 16:45:00","","","","黄燚麒","2026-07-09 16:46:08","","","",""
"48 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","根据不同的资产使用不同的支付方式","C端支付时,卡资产只允许支付宝支付以及钱包支付,如果用微信支付就拒绝,设备只允许微信支付以及钱包支付,如果用支付宝支付就拒绝","","1 ","0 ","","1(#1)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:56:52","黄燚麒","2026-07-09 16:45:21","","
黄燚麒(#huang)","2026-07-09 16:45:00","","","","黄燚麒","2026-07-09 16:45:21","","","",""
"47 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","限速规则","根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则","","1 ","0 ","","3(#3)","4.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:54:39","黄燚麒","2026-07-09 17:30:42","","
黄燚麒(#huang)","2026-07-09 17:27:00","","","","黄燚麒","2026-07-09 17:30:42","","","",""
"46 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","资产信息详情字段新增","资产信息页面卡信息/设备信息板块将当前生效套餐的过期时间作为一个字段显示且套餐还剩15天到期时该字段高亮显示。","","1 ","0 ","","2(#2)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:52:36","黄燚麒","2026-07-09 17:25:53","","
黄燚麒(#huang)","2026-07-09 17:25:00","","","","黄燚麒","2026-07-09 17:25:53","","","",""
"45 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","换货管理","| 编号 | 需求 |
| ------- | ---------------------------------------------------- |
| EXC-001 | 修正换货列表旧资产标识和新资产标识显示混乱问题。 |
| EXC-002 | 换货列表中旧资产标识符和新资产标识符均显示为 ICCID。 |
| EXC-003 | 旧资产查询支持 ICCID、接入号、虚拟号。 |
| EXC-004 | 新资产查询支持 ICCID、接入号、虚拟号。 |","","1 ","0 ","","1(#1)","3.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:51:13","黄燚麒","2026-07-09 16:44:46","","
黄燚麒(#huang)","2026-07-09 16:44:00","","","","黄燚麒","2026-07-09 16:44:46","","","",""
"44 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","列表字段新增","| 编号 | 模块 | 新增字段 |
| ------- | ------------ | -------------- |
| COL-001 | 退款管理列表 | 提交人、审批人 |
| COL-002 | 代理充值列表 | 提交人、审批人 |
| COL-003 | 换号管理列表 | 提交人 |","","1 ","0 ","","1(#1)","1.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:51:13","黄燚麒","2026-07-09 16:44:20","","
黄燚麒(#huang)","2026-07-09 16:44:00","","","","黄燚麒","2026-07-09 16:44:20","","","",""
"43 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理系列授权","| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |
| AUT-001 | 代理系列授权-套餐列表中,添加授权套餐支持一次性多选分套餐。 |
| AUT-002 | 授权套餐时展示建议售价。 |
| AUT-003 | 授权套餐时展示公司成本价。 |
| AUT-004 | 已分配套餐和未分配套餐需要明显区分。 |
| AUT-005 | 新增代理系列授权时,选择套餐需明确显示当前代理已经被分配过的套餐。 |","","1 ","0 ","","2(#2)","5.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:51:13","黄燚麒","2026-07-09 17:25:42","","
黄燚麒(#huang)","2026-07-09 17:12:00","","","","黄燚麒","2026-07-09 17:25:42","","","",""
"42 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","导出功能","#### 6.8.1 lot 卡导出
##### 支持套餐临期30天内所有资产的导出。字段按照卡/设备的导出表进行导出。
| 编号 | 需求 |
| -------- | ---------------------------- |
| EXPD-001 | lot 卡导出字段新增套餐名称。 |
| EXPD-002 | lot 卡导出字段新增使用流量。 |
| EXPD-003 | lot 卡导出字段新增剩余流量。 |
#### 6.8.2 代理资金概况-预充值钱包流水导出
导出字段:
| 字段 | 说明 |
| ------------------- | ------------------------------------------------------------ |
| 店铺名称 | 代理店铺名称 |
| 交易类型 | 充值、扣款、退款等 |
| 交易金额 | 以元为单位 |
| 状态 | 交易状态 |
| 资产类型 | 卡/设备等 |
| 资产标识 | ICCID/设备号等 |
| 交易时间 | 流水生成时间 |
| 交易前金额 | 交易前余额 |
| 交易后金额 | 交易后余额 |
| 购买套餐名称 | 资产此条扣款记录对应的套餐名称 |
| 操作人 | 明确交易执行主体:代理账号 / 平台账号(明确账号名称) |
| 交易 ID | 每一笔流水的全局唯一主键,彻底避免重复流水、对账串号、精准定位单条交易 |
| 关联业务订单号 | 充值、扣款、退款等交易对应的原始业务订单编号 |
| 交易渠道 / 支付方式 | 明确交易来源:余额支付等 |
#### 6.8.3 套餐列表导出
导出字段:
| 字段 |
| -------------- |
| 套餐编码 |
| 套餐名称 |
| 套餐系列名称 |
| 套餐类型 |
| 套餐时长(月) |
| 套餐时长说明 |
| 套餐周期类型 |
| 套餐天数 |
| 真流量额度(MB) |
| 虚流量额度(MB) |
| 是否启用虚流量 |
| 虚流量比例 |
| 流量重置周期 |
| 到期时间基准 |
| 成本价(元) |
| 建议售价(元) |
| 价格配置状态 |
| 状态 |
| 上架状态 |
| 是否赠送套餐 |
| 创建人ID |
| 更新人ID |
| 创建时间 |
| 更新时间 |
| 删除时间 |
#### 6.8.4 退款管理退款列表导出
导出字段:
| 字段 |
| ---------------- |
| 退款单号 |
| 代理店铺名称 |
| 关联的支付订单号 |
| 资产类型 |
| 资产标识 |
| 套餐名称 |
| 原订单金额 |
| 实收金额 |
| 可退金额 |
| 申请退款金额 |
| 实际退款金额 |
| 退款到账方式 |
| 状态 |
| 退款原因 |
| 备注 |
| 审批备注 |
| 退款申请时间 |
| 退款完成时间 |
| 提交人 |
| 部门领导审批人 |
| 财务审批人 |
| 退款凭证 |
#### 6.8.5 换货管理导出
##### C端客户有自己的唯一标识码。换货管理可以针对该C端客户记录该客户换过几次设备或卡。同时资产本身也做换货标识。
导出字段:
| 字段 |
| ------------ |
| 换货单号 |
| 换货类型 |
| 换货原因 |
| 问题描述 |
| 旧资产类型 |
| 旧资产标识符 |
| 新资产标识符 |
| 收货人姓名 |
| 收货人电话 |
| 收货地址 |
| 快递公司 |
| 快递单号 |
| 状态 |
| 创建人 |
| 创建时间 |
#### 6.8.6 代理充值导出
导出字段:
| 字段 |
| -------------- |
| 充值单号 |
| 店铺名称 |
| 充值类型 |
| 充值金额 |
| 实付金额 |
| 充值前余额 |
| 充值后余额 |
| 状态 |
| 支付方式 |
| 支付通道 |
| 运营备注 |
| 驳回原因 |
| 创建时间 |
| 支付时间 |
| 完成时间 |
| 提交人 |
| 部门领导审批人 |
| 财务审批人 |
| 支付凭证 |
| 备注 |","","1 ","0 ","","1(#1)","16.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:42:07","","
黄燚麒(#huang)","2026-07-09 16:40:00","","","","黄燚麒","2026-07-09 16:42:07","","","",""
"41 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","代理查询限制(类似酷蛙这种客户)","下级只能看到上级给的信息。api对接客户无法通过他的上级代理查到我们公司的信息。
| 编号 | 需求 | | ------- | -------------------------------------------------------- | | API-001 | 支持配置代理 API 对接查询限制。下级客户/代理无法跨级查询 |
","","1 ","0 ","","1(#1)","","草稿(#draft)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","李昕娉","2026-07-09 16:42:55","","","2026-07-11 14:24:00","","","","黄燚麒","2026-07-11 14:25:30","","","",""
"40 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","套餐设计","| 编号 | 需求 |
| ------- | ------------------------------------------ |
| PKG-001 | 套餐需标准化管理。 |
| PKG-002 | 套餐下架后,正在使用该套餐的客户仍可续费。 |
| PKG-003 | 下架套餐续费仅支持客户自己购买。 |
| PKG-004 | 下架套餐不可被新购买。 |","","1 ","0 ","","2(#2)","3.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 17:11:34","","
黄燚麒(#huang)","2026-07-09 17:10:00","","","","黄燚麒","2026-07-09 17:11:34","","","",""
"38 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","不同渠道额度处理","| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |
| BPO-009 | 新建代理时新增“是否可授权额度”开关。平台用户账号默认拥有授权额度。 |
| BPO-010 | 不同代理可设置不同额度下限。平台用户可根据不同的角色设置不同的额度下限。 |
| BPO-011 | 授权额度用于订购套餐、代理余额充值等需要涉及金额的所有模块。 |
| BPO-012 | 授权额度代理可显示负数余额。平台用户也可显示负数余额。 |","","1 ","0 ","","2(#2)","16.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:58:16","","
黄燚麒(#huang)","2026-07-09 16:57:00","","","","黄燚麒","2026-07-09 16:58:16","","","",""
"37 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","审核流转","| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |
| APR-001 | 代理可在系统提交充值申请。 |
| APR-002 | 提交充值申请后系统展示收款二维码,代理扫码支付。 |
| APR-003 | 充值和退款均支持多级审核。 |
| APR-004 | 审核环节包括提交人部门领导审核和财务审核。 |
| APR-005 | 待审核订单需有消息提示。 |
| APR-006 | 审核提醒按流程环节触发,上一审批人完成审批后才提示下一审批人。 |
| APR-007 | 审核通过后通知申请人。 |
| APR-008 | 审核驳回后通知申请人,并附带驳回原因。 |
| APR-009 | 审核流程需对接企业微信审批流程。 |","","1 ","0 ","","2(#2)","24.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:57:35","","
黄燚麒(#huang)","2026-07-09 16:57:00","","","","黄燚麒","2026-07-09 16:57:35","","","",""
"36 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","批量订购套餐","1. 内部员工进入批量订购页面。
2. 选择代理、ICCID号段/设备号、订购套餐。或上传 Excel 文件,资产标识支持 ICCID/设备号
3. Excel表头跳转至6.3会显示。
4. 系统校验导入文件和资产状态。
5. 校验通过的资产从代理余额扣款并完成订购。
6. 校验失败的明细在页面展示失败原因。
7. 系统记录操作员、导入明细、导入数量和导入时间。
| 编号 | 需求 |
| ------- | ---------------------------------------------------- |
| BPO-001 | 批量订购由内部员工操作。 |
| BPO-002 | 支持代理自行充值钱包后,由员工批量订购并从余额扣款。 |
| BPO-003 | 支持员工代充值至代理余额后,再批量订购并从余额扣款。 |
| BPO-004 | 批量订购无需审核。 |
| BPO-005 | 资产标识支持 ICCID/设备号。 |
| BPO-006 | 支持 Excel 模板导入。 |
| BPO-007 | 需记录操作员、导入明细、导入数量和导入时间。 |
| BPO-008 | 导入失败明细需展示在页面,并显示失败原因。 |
excel表导入字段
| 字段 |
| ----------------------------- |
| 资产类型 |
| 资产标识 |
| 套餐系列名称 |
| 套餐名称 |
| 代理名称 |
| 支付方式:代理商账户/员工账户 |","","1 ","0 ","","1(#1)","4.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:31:53","","
黄燚麒(#huang)","2026-07-09 16:31:00","","","","黄燚麒","2026-07-09 16:31:53","","","",""
"35 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","退款审核","(审批均可通过企微进行提醒和显示并将最新状态同步至卡管)
1. 员工提交退款申请。
2. 退款单进入多级审核流程。
3. 按部门领导、财务顺序审批。
4. 当前环节审批完成后,下一环节审批人收到消息提示。
5. 审批通过或驳回后通知申请人。","","1 ","0 ","","2(#2)","24.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:56:31","","
黄燚麒(#huang)","2026-07-09 16:55:00","","","","黄燚麒","2026-07-09 16:56:31","","","",""
"34 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","充值审核流程","代理自己充值:
1. 代理在系统提交充值申请。
2. 系统展示收款二维码。
3. 代理扫码支付。
员工代充值:(审批均可通过企微进行提醒和显示并将最新状态同步至卡管)
1. 充值单进入多级审核流程。
2. 提交人部门领导先审批。
3. 财务在上一审批人通过后收到待办提醒并审批。
4. 审批通过后通知申请人。
5. 审批驳回后通知申请人,并展示驳回原因。","","1 ","0 ","","2(#2)","32.00 ","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:55:42","","
黄燚麒(#huang)","2026-07-09 16:55:00","","","","黄燚麒","2026-07-09 16:55:42","","","",""
"33 ","物联网卡管系统(#2)","/(#0)","7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1)","","","套餐临期提醒","1. 系统每日计算卡/设备套餐剩余有效期。
2. 命中临期规则后生成临期数据。临期提醒规则:按 15 天、7 天、3 天节点分别进行不同方式的提醒。
3. 企业客户场景:按 15 天、7 天、3 天节点生成临期列表,并通过企业微信推送给对应业务员。
4. 代理端场景(展示):首页展示卡、设备临期数量;资产列表按临期天数高亮。
5. C 端场景(展示):公众号首页在套餐剩余有效期小于或等于 15天时展示续费提醒并显示立即续费按钮。
6. 当资产续费成功或不再满足临期条件时,临期提醒自动取消。
#### 6.1.1 企业客户临期提醒
| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |
| EXP-001 | 后台每日生成企业客户临期列表。 |
| EXP-002 | 临期节点包括 15 天、7 天、3 天。 |
| EXP-003 | 系统需对接企业微信,将临期列表推送给对应业务员。 |
| EXP-004 | 同一资产在不同临期节点可重复触发对应节点提醒,但同一节点每日不可重复推送给同一业务员。 |
#### 6.1.2 代理端临期提醒
| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |
| EXP-005 | 代理端首页分别展示临期卡数量和临期设备数量。 |
| EXP-006 | 代理端资产列表对临期资产进行颜色标记。 |
| EXP-007 | 剩余 15 天标记为粉色,剩余 7 天标记为紫色,剩余 3 天标记为红色。 |
| EXP-008 | 剩余 3 天资产在列表中置顶优先展示。 |
#### 6.1.3 C 端公众号首页提醒
| 编号 | 需求 |
| ------- | ------------------------------------------------------------ |
| EXP-009 | 当客户套餐剩余有效期小于或等于 15 天时,公众号首页展示套餐到期提醒。 |
| EXP-010 | 提醒展示卡号/设备号、剩余有效期和续费引导文案。 |
| EXP-011 | 按钮文案为“立即续费”。 |
| EXP-012 | 剩余天数需要按日期每天自动更新。 |
| EXP-013 | 当套餐已续费或不再满足临期条件时,提醒不再展示。 |
推荐展示文案:
您的套餐即将到期
卡号/设备号xxx
剩余有效期xx 天
为避免到期后影响正常使用,请您提前完成续费。","","1 ","0 ","","1(#1)","","激活(#active)","已计划(#planned)","功能(#feature)","","","","李昕娉","2026-07-08 15:49:25","黄燚麒","2026-07-09 16:30:06","","
黄燚麒(#huang)","2026-07-09 16:29:00","","","","黄燚麒","2026-07-09 16:30:06","","","",""
1 编号 所属产品 所属模块 所属计划 来源 来源备注 用户需求名称 描述 验收标准 需求层级 父需求 关键词 优先级 预计工时 当前状态 所处阶段 类别 T B C 由谁创建 创建日期 指派给 指派日期 抄送给 已评审人 评审时间 由谁关闭 关闭日期 关闭原因 最后修改 最后修改日期 反馈者 重复需求 附件
2 98 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 换过管理新资产归属 默认换货把旧资产的所属权一起划过去 1 0 3(#3) 0.50 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-15 14:08:57 黄燚麒 2026-07-16 11:51:00 黄燚麒(#huang) 2026-07-16 11:50:00 黄燚麒 2026-07-16 11:51:00
3 97 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 代理钱包阈值提醒 代理资金概况板块新增余额预警。不同代理可设置不同的预警额度。达到阈值时可提醒代理和其发展人。 1 0 3(#3) 0.50 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-15 11:23:35 黄燚麒 2026-07-16 15:40:38 黄燚麒(#huang) 2026-07-16 15:40:00 黄燚麒 2026-07-16 15:40:38
4 96 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 员工作为发展人进行标识 新建店铺添加业务员字段,可以进行指定相应业务员。非必填。同时店铺列表以及详情中新增发展人字段。发展人也可作为检索条件,检索发展人名下的相关店铺。 1 0 2(#2) 1.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-15 11:22:00 黄燚麒 2026-07-15 11:23:15 黄燚麒(#huang) 2026-07-15 11:23:00 李昕娉 2026-07-15 11:23:42
5 94 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 系统状态同步优化以及回调处理 因资产迁移需要涉及回调和状态同步,故进行相关优化 1 0 1(#1) 40.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-14 10:53:29 黄燚麒 2026-07-14 11:00:14 黄燚麒(#huang) 2026-07-14 10:59:00 李昕娉 2026-07-14 11:01:12
6 86 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 资产详情中换货标识 在资产详情页卡信息或设备信息列表中需要显示是否发生过换货或是换货标识,标注新资产或旧资产。旧资产需要可以链接至新资产。<img src=&quot;{128.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=128&quot; /><img src=&quot;{129.png}&quot; alt=&quot;index.php?m=file&f=read&t=png&fileID=129&quot; /> 1 0 1(#1) 2.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-13 11:50:17 黄燚麒 2026-07-13 11:54:36 黄燚麒(#huang) 2026-07-13 11:54:00 黄燚麒 2026-07-13 11:54:36
7 73 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 行业卡操作停复机 行业卡允许未实名复机 1 0 3(#3) 1.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-11 10:02:33 黄燚麒 2026-07-11 14:22:26 黄燚麒(#huang) 2026-07-11 14:22:00 黄燚麒 2026-07-11 14:22:26
8 62 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 新卡管H5需要设置先充值后实名 用户进入H5后需要先绑定手机号后强制先充值后强制实名。这个功能能否进行后台设置,例如这一批设备需要强制先充值后实名,这一批资产可以先实名后充值? 1 0 3(#3) 2.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-09 17:04:27 李昕娉 2026-07-09 18:13:07 黄燚麒(#huang) 2026-07-11 14:23:00 黄燚麒 2026-07-11 14:23:36
9 60 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 店铺管理新增检索条件 店铺列表搜索栏新增一项:联系电话,便于搜索 1 0 3(#3) 0.10 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-09 16:00:56 黄燚麒 2026-07-09 17:37:57 黄燚麒(#huang) 2026-07-09 17:37:00 黄燚麒 2026-07-09 17:37:57
10 57 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 操作拦截 若当前资产存在退款申请时(但为通过审批时),该资产不允许操作换货。且出现提示:该资产存在退款申请 1 0 3(#3) 1.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-09 15:07:17 黄燚麒 2026-07-09 17:32:56 黄燚麒(#huang) 2026-07-09 17:32:00 李昕娉 2026-07-09 17:33:21
11 55 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 套餐生效条件 套餐延续创建时选择购买即生效或实名即生效的条件,同时在套餐分配时提供修改条件的功能,并以变更后的条件为最终版本 1 0 1(#1) 4.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-09 11:57:51 黄燚麒 2026-07-09 16:54:53 黄燚麒(#huang) 2026-07-09 16:54:00 黄燚麒 2026-07-09 16:54:53
12 54 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 资产详情页面新增套餐到期时间显示 增加所有待生效套餐加上生效套餐加起来的最后到期时间 1 0 2(#2) 已关闭(#closed) 已关闭(#closed) 功能(#feature) 李昕娉 2026-07-09 11:56:50 closed 2026-07-09 17:26:38 黄燚麒(#huang) 2026-07-09 17:26:00 黄燚麒 2026-07-09 17:26:38 重复(#duplicate) 黄燚麒 2026-07-09 17:26:38 #46 资产信息详情字段新增
13 53 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 资产管理查询字段新增 lot卡管理和设备管理新增已实名/未实名的筛选查询条件 1 0 1(#1) 1.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-09 09:39:53 黄燚麒 2026-07-09 16:53:29 黄燚麒(#huang) 2026-07-09 16:53:00 黄燚麒 2026-07-09 16:53:29
14 51 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 不同品类资产的换货 因换货存在不同资产间的换货,且存在补差价进行卡换设备的操作。但卡的套餐和设备套餐不同会存在补差价等操作。且原卡套餐需失效,新资产设备需要使用设备套餐。直接操作换货,套餐会同步卡套餐,对公司来说成本增加。 1 0 1(#1) 草稿(#draft) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 17:54:32 李昕娉 2026-07-09 16:53:05 2026-07-09 16:52:00 黄燚麒 2026-07-09 16:53:05
15 49 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 设备批量分配代理和套餐系列 因设备号不是连号,故需要提供导入excel表的方式进行批量分配代理和套餐系列。excle表头为:设备号 1 0 1(#1) 2.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 17:43:58 黄燚麒 2026-07-08 17:43:58 黄燚麒(#huang) 2026-07-09 16:45:00 黄燚麒 2026-07-09 16:46:08
16 48 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 根据不同的资产使用不同的支付方式 C端支付时,卡资产只允许支付宝支付以及钱包支付,如果用微信支付就拒绝,设备只允许微信支付以及钱包支付,如果用支付宝支付就拒绝 1 0 1(#1) 1.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:56:52 黄燚麒 2026-07-09 16:45:21 黄燚麒(#huang) 2026-07-09 16:45:00 黄燚麒 2026-07-09 16:45:21
17 47 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 限速规则 根据不同运营商限速规则,基于套餐流量设置不同的卡/设备的限速规则 1 0 3(#3) 4.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:54:39 黄燚麒 2026-07-09 17:30:42 黄燚麒(#huang) 2026-07-09 17:27:00 黄燚麒 2026-07-09 17:30:42
18 46 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 资产信息详情字段新增 资产信息页面卡信息/设备信息板块,将当前生效套餐的过期时间作为一个字段显示且套餐还剩15天到期时该字段高亮显示。 1 0 2(#2) 1.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:52:36 黄燚麒 2026-07-09 17:25:53 黄燚麒(#huang) 2026-07-09 17:25:00 黄燚麒 2026-07-09 17:25:53
19 45 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 换货管理 | 编号 | 需求 | | ------- | ---------------------------------------------------- | | EXC-001 | 修正换货列表旧资产标识和新资产标识显示混乱问题。 | | EXC-002 | 换货列表中旧资产标识符和新资产标识符均显示为 ICCID。 | | EXC-003 | 旧资产查询支持 ICCID、接入号、虚拟号。 | | EXC-004 | 新资产查询支持 ICCID、接入号、虚拟号。 | 1 0 1(#1) 3.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:51:13 黄燚麒 2026-07-09 16:44:46 黄燚麒(#huang) 2026-07-09 16:44:00 黄燚麒 2026-07-09 16:44:46
20 44 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 列表字段新增 | 编号 | 模块 | 新增字段 | | ------- | ------------ | -------------- | | COL-001 | 退款管理列表 | 提交人、审批人 | | COL-002 | 代理充值列表 | 提交人、审批人 | | COL-003 | 换号管理列表 | 提交人 | 1 0 1(#1) 1.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:51:13 黄燚麒 2026-07-09 16:44:20 黄燚麒(#huang) 2026-07-09 16:44:00 黄燚麒 2026-07-09 16:44:20
21 43 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 代理系列授权 | 编号 | 需求 | | ------- | ------------------------------------------------------------ | | AUT-001 | 代理系列授权-套餐列表中,添加授权套餐支持一次性多选分套餐。 | | AUT-002 | 授权套餐时展示建议售价。 | | AUT-003 | 授权套餐时展示公司成本价。 | | AUT-004 | 已分配套餐和未分配套餐需要明显区分。 | | AUT-005 | 新增代理系列授权时,选择套餐需明确显示当前代理已经被分配过的套餐。 | 1 0 2(#2) 5.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:51:13 黄燚麒 2026-07-09 17:25:42 黄燚麒(#huang) 2026-07-09 17:12:00 黄燚麒 2026-07-09 17:25:42
22 42 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 导出功能 #### 6.8.1 lot 卡导出 ##### 支持套餐临期30天内所有资产的导出。字段按照卡/设备的导出表进行导出。 | 编号 | 需求 | | -------- | ---------------------------- | | EXPD-001 | lot 卡导出字段新增套餐名称。 | | EXPD-002 | lot 卡导出字段新增使用流量。 | | EXPD-003 | lot 卡导出字段新增剩余流量。 | #### 6.8.2 代理资金概况-预充值钱包流水导出 导出字段: | 字段 | 说明 | | ------------------- | ------------------------------------------------------------ | | 店铺名称 | 代理店铺名称 | | 交易类型 | 充值、扣款、退款等 | | 交易金额 | 以元为单位 | | 状态 | 交易状态 | | 资产类型 | 卡/设备等 | | 资产标识 | ICCID/设备号等 | | 交易时间 | 流水生成时间 | | 交易前金额 | 交易前余额 | | 交易后金额 | 交易后余额 | | 购买套餐名称 | 资产此条扣款记录对应的套餐名称 | | 操作人 | 明确交易执行主体:代理账号 / 平台账号(明确账号名称) | | 交易 ID | 每一笔流水的全局唯一主键,彻底避免重复流水、对账串号、精准定位单条交易 | | 关联业务订单号 | 充值、扣款、退款等交易对应的原始业务订单编号 | | 交易渠道 / 支付方式 | 明确交易来源:余额支付等 | #### 6.8.3 套餐列表导出 导出字段: | 字段 | | -------------- | | 套餐编码 | | 套餐名称 | | 套餐系列名称 | | 套餐类型 | | 套餐时长(月) | | 套餐时长说明 | | 套餐周期类型 | | 套餐天数 | | 真流量额度(MB) | | 虚流量额度(MB) | | 是否启用虚流量 | | 虚流量比例 | | 流量重置周期 | | 到期时间基准 | | 成本价(元) | | 建议售价(元) | | 价格配置状态 | | 状态 | | 上架状态 | | 是否赠送套餐 | | 创建人ID | | 更新人ID | | 创建时间 | | 更新时间 | | 删除时间 | #### 6.8.4 退款管理退款列表导出 导出字段: | 字段 | | ---------------- | | 退款单号 | | 代理店铺名称 | | 关联的支付订单号 | | 资产类型 | | 资产标识 | | 套餐名称 | | 原订单金额 | | 实收金额 | | 可退金额 | | 申请退款金额 | | 实际退款金额 | | 退款到账方式 | | 状态 | | 退款原因 | | 备注 | | 审批备注 | | 退款申请时间 | | 退款完成时间 | | 提交人 | | 部门领导审批人 | | 财务审批人 | | 退款凭证 | #### 6.8.5 换货管理导出 ##### C端客户有自己的唯一标识码。换货管理可以针对该C端客户记录该客户换过几次设备或卡。同时资产本身也做换货标识。 导出字段: | 字段 | | ------------ | | 换货单号 | | 换货类型 | | 换货原因 | | 问题描述 | | 旧资产类型 | | 旧资产标识符 | | 新资产标识符 | | 收货人姓名 | | 收货人电话 | | 收货地址 | | 快递公司 | | 快递单号 | | 状态 | | 创建人 | | 创建时间 | #### 6.8.6 代理充值导出 导出字段: | 字段 | | -------------- | | 充值单号 | | 店铺名称 | | 充值类型 | | 充值金额 | | 实付金额 | | 充值前余额 | | 充值后余额 | | 状态 | | 支付方式 | | 支付通道 | | 运营备注 | | 驳回原因 | | 创建时间 | | 支付时间 | | 完成时间 | | 提交人 | | 部门领导审批人 | | 财务审批人 | | 支付凭证 | | 备注 | 1 0 1(#1) 16.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 16:42:07 黄燚麒(#huang) 2026-07-09 16:40:00 黄燚麒 2026-07-09 16:42:07
23 41 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 代理查询限制(类似酷蛙这种客户) 下级只能看到上级给的信息。api对接客户无法通过他的上级代理查到我们公司的信息。 | 编号 | 需求 | | ------- | -------------------------------------------------------- | | API-001 | 支持配置代理 API 对接查询限制。下级客户/代理无法跨级查询 | 1 0 1(#1) 草稿(#draft) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 李昕娉 2026-07-09 16:42:55 2026-07-11 14:24:00 黄燚麒 2026-07-11 14:25:30
24 40 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 套餐设计 | 编号 | 需求 | | ------- | ------------------------------------------ | | PKG-001 | 套餐需标准化管理。 | | PKG-002 | 套餐下架后,正在使用该套餐的客户仍可续费。 | | PKG-003 | 下架套餐续费仅支持客户自己购买。 | | PKG-004 | 下架套餐不可被新购买。 | 1 0 2(#2) 3.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 17:11:34 黄燚麒(#huang) 2026-07-09 17:10:00 黄燚麒 2026-07-09 17:11:34
25 38 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 不同渠道额度处理 | 编号 | 需求 | | ------- | ------------------------------------------------------------ | | BPO-009 | 新建代理时新增“是否可授权额度”开关。平台用户账号默认拥有授权额度。 | | BPO-010 | 不同代理可设置不同额度下限。平台用户可根据不同的角色设置不同的额度下限。 | | BPO-011 | 授权额度用于订购套餐、代理余额充值等需要涉及金额的所有模块。 | | BPO-012 | 授权额度代理可显示负数余额。平台用户也可显示负数余额。 | 1 0 2(#2) 16.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 16:58:16 黄燚麒(#huang) 2026-07-09 16:57:00 黄燚麒 2026-07-09 16:58:16
26 37 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 审核流转 | 编号 | 需求 | | ------- | ------------------------------------------------------------ | | APR-001 | 代理可在系统提交充值申请。 | | APR-002 | 提交充值申请后系统展示收款二维码,代理扫码支付。 | | APR-003 | 充值和退款均支持多级审核。 | | APR-004 | 审核环节包括提交人部门领导审核和财务审核。 | | APR-005 | 待审核订单需有消息提示。 | | APR-006 | 审核提醒按流程环节触发,上一审批人完成审批后才提示下一审批人。 | | APR-007 | 审核通过后通知申请人。 | | APR-008 | 审核驳回后通知申请人,并附带驳回原因。 | | APR-009 | 审核流程需对接企业微信审批流程。 | 1 0 2(#2) 24.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 16:57:35 黄燚麒(#huang) 2026-07-09 16:57:00 黄燚麒 2026-07-09 16:57:35
27 36 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 批量订购套餐 1. 内部员工进入批量订购页面。 2. 选择代理、ICCID号段/设备号、订购套餐。或上传 Excel 文件,资产标识支持 ICCID/设备号 3. Excel表头:跳转至6.3会显示。 4. 系统校验导入文件和资产状态。 5. 校验通过的资产从代理余额扣款并完成订购。 6. 校验失败的明细在页面展示失败原因。 7. 系统记录操作员、导入明细、导入数量和导入时间。 | 编号 | 需求 | | ------- | ---------------------------------------------------- | | BPO-001 | 批量订购由内部员工操作。 | | BPO-002 | 支持代理自行充值钱包后,由员工批量订购并从余额扣款。 | | BPO-003 | 支持员工代充值至代理余额后,再批量订购并从余额扣款。 | | BPO-004 | 批量订购无需审核。 | | BPO-005 | 资产标识支持 ICCID/设备号。 | | BPO-006 | 支持 Excel 模板导入。 | | BPO-007 | 需记录操作员、导入明细、导入数量和导入时间。 | | BPO-008 | 导入失败明细需展示在页面,并显示失败原因。 | excel表导入字段: | 字段 | | ----------------------------- | | 资产类型 | | 资产标识 | | 套餐系列名称 | | 套餐名称 | | 代理名称 | | 支付方式:代理商账户/员工账户 | 1 0 1(#1) 4.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 16:31:53 黄燚麒(#huang) 2026-07-09 16:31:00 黄燚麒 2026-07-09 16:31:53
28 35 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 退款审核 (审批均可通过企微进行提醒和显示并将最新状态同步至卡管) 1. 员工提交退款申请。 2. 退款单进入多级审核流程。 3. 按部门领导、财务顺序审批。 4. 当前环节审批完成后,下一环节审批人收到消息提示。 5. 审批通过或驳回后通知申请人。 1 0 2(#2) 24.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 16:56:31 黄燚麒(#huang) 2026-07-09 16:55:00 黄燚麒 2026-07-09 16:56:31
29 34 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 充值审核流程 代理自己充值: 1. 代理在系统提交充值申请。 2. 系统展示收款二维码。 3. 代理扫码支付。 员工代充值:(审批均可通过企微进行提醒和显示并将最新状态同步至卡管) 1. 充值单进入多级审核流程。 2. 提交人部门领导先审批。 3. 财务在上一审批人通过后收到待办提醒并审批。 4. 审批通过后通知申请人。 5. 审批驳回后通知申请人,并展示驳回原因。 1 0 2(#2) 32.00 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 16:55:42 黄燚麒(#huang) 2026-07-09 16:55:00 黄燚麒 2026-07-09 16:55:42
30 33 物联网卡管系统(#2) /(#0) 7月份迭代计划 [2026-07-10 ~ 2026-07-31](#1) 套餐临期提醒 1. 系统每日计算卡/设备套餐剩余有效期。 2. 命中临期规则后生成临期数据。临期提醒规则:按 15 天、7 天、3 天节点分别进行不同方式的提醒。 3. 企业客户场景:按 15 天、7 天、3 天节点生成临期列表,并通过企业微信推送给对应业务员。 4. 代理端场景(展示):首页展示卡、设备临期数量;资产列表按临期天数高亮。 5. C 端场景(展示):公众号首页在套餐剩余有效期小于或等于 15天时展示续费提醒,并显示立即续费按钮。 6. 当资产续费成功或不再满足临期条件时,临期提醒自动取消。 #### 6.1.1 企业客户临期提醒 | 编号 | 需求 | | ------- | ------------------------------------------------------------ | | EXP-001 | 后台每日生成企业客户临期列表。 | | EXP-002 | 临期节点包括 15 天、7 天、3 天。 | | EXP-003 | 系统需对接企业微信,将临期列表推送给对应业务员。 | | EXP-004 | 同一资产在不同临期节点可重复触发对应节点提醒,但同一节点每日不可重复推送给同一业务员。 | #### 6.1.2 代理端临期提醒 | 编号 | 需求 | | ------- | ------------------------------------------------------------ | | EXP-005 | 代理端首页分别展示临期卡数量和临期设备数量。 | | EXP-006 | 代理端资产列表对临期资产进行颜色标记。 | | EXP-007 | 剩余 15 天标记为粉色,剩余 7 天标记为紫色,剩余 3 天标记为红色。 | | EXP-008 | 剩余 3 天资产在列表中置顶优先展示。 | #### 6.1.3 C 端公众号首页提醒 | 编号 | 需求 | | ------- | ------------------------------------------------------------ | | EXP-009 | 当客户套餐剩余有效期小于或等于 15 天时,公众号首页展示套餐到期提醒。 | | EXP-010 | 提醒展示卡号/设备号、剩余有效期和续费引导文案。 | | EXP-011 | 按钮文案为“立即续费”。 | | EXP-012 | 剩余天数需要按日期每天自动更新。 | | EXP-013 | 当套餐已续费或不再满足临期条件时,提醒不再展示。 | 推荐展示文案: 您的套餐即将到期 卡号/设备号:xxx 剩余有效期:xx 天 为避免到期后影响正常使用,请您提前完成续费。 1 0 1(#1) 激活(#active) 已计划(#planned) 功能(#feature) 李昕娉 2026-07-08 15:49:25 黄燚麒 2026-07-09 16:30:06 黄燚麒(#huang) 2026-07-09 16:29:00 黄燚麒 2026-07-09 16:30:06

View File

@@ -0,0 +1,271 @@
# 7月迭代前端技术方案
> 历史状态:共性前端来源稿;本地审批交互已废弃,最终以前端标准章节和各独立方案为准。
> 当前方案:[标准评审稿](../../7月迭代技术方案-标准评审稿.md)。
> 适用端后台管理端、代理端、C 端 H5/公众号
> 最后更新2026-07-14
> 说明:当前仓库不包含前端源码,本文定义页面、交互和接口契约;实际目录、状态库和组件名称由前端仓库现状映射,禁止据此凭空更换前端技术栈。
---
## 一、目标与边界
本方案解决七月迭代中跨需求的前端共性问题:
- 审批任务、退款和充值使用同一套动态审批展示。
- Excel 导入、批量订购和导出使用统一的异步任务交互。
- 站内消息统一未读数、列表、已读和业务跳转。
- 配置类页面不让用户直接编辑 JSON 或依赖前端自行校验业务规则。
- 金额、状态、时间、权限和错误展示使用统一口径。
本文不指定 Vue、React、Pinia、Redux 或具体 UI 组件库。实现时必须优先复用前端仓库现有的请求封装、权限指令、上传组件、表格和轮询 Hook。
---
## 二、系统上下文
```mermaid
flowchart LR
AdminUser[平台/代理后台用户] --> AdminWeb[后台管理前端]
Customer[C端客户] --> ClientWeb[C端 H5/公众号]
AdminWeb -->|/api/admin/*| API[Junhong API]
ClientWeb -->|/api/c/v1/*| API
API --> DB[(PostgreSQL)]
API --> Redis[(Redis)]
API --> Queue[Asynq]
Queue --> Worker[Worker]
Worker --> APIData[业务数据/对象存储/Gateway]
AdminWeb -->|轮询未读数、任务状态| API
```
前端只根据 API 返回的权限、状态和可操作项渲染,不自行推导“谁能审批”“是否允许扣款”或“当前流程下一步是谁”。
---
## 三、模块边界
建议在现有前端目录结构中映射以下模块,不要求创建新的全局架构:
| 模块 | 负责内容 | 不负责内容 |
|------|----------|------------|
| Approval | 待办列表、流程详情、流程定义配置、审批动作 | 退款或充值的业务表单 |
| Notification | 未读数、通知列表、标记已读、业务跳转 | 审批状态计算 |
| AsyncTask | 导入、批量订购、导出的轮询与结果展示 | 各业务文件解析 |
| Refund | 退款申请、编辑、重新提交、业务处理状态 | 动态审批节点渲染的内部规则 |
| Recharge | 代理在线充值、员工线下代充值、重新提交 | 钱包入账和审批人判断 |
| SystemConfig | 配置表单和版本刷新 | 直接编辑任意 JSON |
全局状态只保留跨页面共享的数据:登录用户、菜单/按钮权限、通知未读数。列表数据、详情数据和表单草稿默认留在页面或模块级状态,避免把服务端状态复制到全局 Store 后长期失真。
---
## 四、接口与类型约定
### 4.1 路径前缀
- 后台管理:`/api/admin/*`
- C 端:`/api/c/v1/*`
- 回调接口不由前端调用。
专项文档出现省略 `/api` 的路径时,以本节和真实路由注册为准,并应在评审前修正。
### 4.2 枚举和显示
- 生命周期状态使用后端返回的 `status` 做逻辑判断,使用 `status_name` 做中文展示。
- 前端不得维护另一份与后端重复的中文状态映射;只有颜色、图标等纯展示映射可以留在前端。
- 金额 API 统一使用“分”,输入组件展示“元”,提交前做整数转换,禁止浮点数直接乘除后提交。
- 时间统一使用后端 ISO 8601 值,展示层按现有项目时区和格式化工具处理。
### 4.3 请求幂等
审批等敏感写操作由前端生成 `request_id`。一次用户操作从首次提交到网络重试必须复用同一个值;用户明确重新发起操作时才生成新值。
按钮提交后进入 loading 并禁止重复点击。前端防重只是体验控制,后端仍必须执行状态条件更新和唯一约束。
---
## 五、统一异步任务交互
适用:设备批量分配、批量订购、导出任务。
不适用于临期状态:临期列表、详情和首页数量由接口实时 SQL 计算;每日任务只生成 15/7/3 天通知,前端不轮询或维护临期快照。
```mermaid
stateDiagram-v2
[*] --> Editing: 填写参数/选择文件
Editing --> Submitting: 提交
Submitting --> Processing: 创建任务成功
Submitting --> Editing: 参数或上传失败
Processing --> Processing: 轮询进度
Processing --> Completed: 全部或部分完成
Processing --> Failed: 任务失败
Processing --> Cancelled: 用户取消且后端确认
Completed --> [*]
Failed --> Editing: 修正后重新提交
Cancelled --> [*]
```
### 5.1 轮询规则
- 创建成功后立即请求一次详情,再按 2 秒、3 秒、5 秒逐步退避,最大间隔 10 秒。
- 页面不可见时暂停轮询,恢复可见时立即刷新。
- 达到终态、离开页面或组件销毁时停止轮询。
- 连续网络失败不把业务任务标记为失败,展示“状态获取失败,点击重试”。
- 服务端返回 `retry_after_seconds` 时优先采用服务端建议。
### 5.2 结果展示
- 必须同时展示总数、成功数、失败数和任务状态。
- 部分成功不能只弹一个成功 Toast失败明细要留在页面并支持下载或复制具体能力按专项方案。
- 导出任务完成后展示下载按钮和链接过期时间;链接过期时重新获取任务详情,不重新创建导出任务。
### 5.3 Excel 模板
- 设备批量分配、批量订购等 Excel 模板由前端作为静态资源维护,后端不提供模板下载 API。
- 模板文件名包含版本号,下载入口与对应上传表单放在同一页面。
- 后端仍必须严格校验表头和内容,不能因为模板由前端提供就信任文件结构。
- 模板字段变更需要前后端同批发布,并保留对用户本地旧模板的可理解错误提示。
---
## 六、统一审批交互
```mermaid
stateDiagram-v2
[*] --> Loading
Loading --> ReadOnly: 当前用户无待处理资格
Loading --> Actionable: 当前用户是待处理审批人
Actionable --> EditingAction: 打开通过/驳回/退回弹框
EditingAction --> Uploading: 上传附件
Uploading --> EditingAction: 上传成功或失败后返回
EditingAction --> Submitting: 意见和附件校验通过
Submitting --> EditingAction: 请求失败且任务仍可操作
Submitting --> ReadOnly: 操作成功或状态已被他人改变
ReadOnly --> [*]
```
### 6.1 页面建议
| 页面 | 建议路由 | 主要能力 |
|------|----------|----------|
| 待我审批 | `/approvals/tasks` | 状态、业务类型、提交人、当前节点、提交时间筛选 |
| 审批详情 | `/approvals/instances/:instance_id` | 业务快照、业务资料、动态节点时间线、审批人和意见、当前可操作按钮 |
| 流程定义 | `/settings/approval-flows` | 草稿、发布版本、停用、业务绑定 |
| 流程定义编辑 | `/settings/approval-flows/:id` | 串行节点配置、角色/账号选择、或签/会签、发布前校验 |
第一版只支持串行节点,前端使用“有序节点列表编辑器”,不建设拖拽 DAG/BPMN 设计器。节点可上移、下移、增加和删除;开始、结束节点由系统生成且不可删除。
### 6.2 动态详情
审批详情按接口返回的 `tasks[]``assignees[]` 渲染,禁止写死“部门领导”和“财务”两个字段。
详情首屏必须先渲染 `business_snapshot`:业务标题、业务单号、提交人、审批关键字段和业务资料。业务资料来自发起时快照,审批附件来自某位审批人的操作记录,页面用两个区域展示;审批人不需要跳转到实时业务页才能知道正在审批什么。
操作按钮显示条件同时满足:
- 流程状态为审批中。
- 当前任务状态为待审批。
- 当前账号对应的审批人状态为待处理。
- 接口返回相应操作权限。
操作成功后重新请求审批实例和关联业务详情,不做乐观状态推进。
审批详情可返回 `action_form.fields`。前端只渲染后端声明的受控字段类型;退款金额和操作密码均由流程节点配置决定,且只有当前动作会完成该节点时才出现。金额展示元、提交分;操作密码不写入全局 Store、本地存储或重试缓存请求结束立即清空。节点没有动作字段时不显示额外表单禁止根据“财务审核”等节点名称自行添加输入项。
每个通过、驳回、退回弹框统一包含“审批意见”和“附件”:
- 通过意见可选;驳回、退回意见必填,最多 1000 字。
- 意见使用普通多行文本框,不提供富文本编辑器。
- 附件最多 5 个、单个默认不超过 20MB允许图片、PDF、Word、Excel打开动作弹框时先生成 `request_id`,申请 `purpose=approval_attachment` 且绑定该 `request_id` 的上传凭证,上传完成后提交 `file_key + file_name`。前端校验只用于体验,最终以后端对象元数据和上传归属校验为准。
- 文件仍在上传时禁止提交审批;删除尚未提交的附件只影响本地表单,不调用删除历史审批附件。
- 网络重试复用原 `request_id`、意见和附件集合;操作成功后清空本地表单。
- 时间线按审批人展示各自意见和附件。审批附件和业务资料分别通过审批实例权限校验接口换取短期 URL不直接拼对象存储地址。
### 6.3 业务处理中的展示
审批通过与退款到账、钱包入账不是同一时刻。退款和充值详情必须同时展示:
- 审批状态。
- 业务处理状态。
- 业务处理失败原因和“系统重试中/联系管理员”的提示;非代理钱包退款审批通过后显示“待人工退款”,仅财务确认权限可见确认完成入口。
不能仅根据业务单原状态显示“待审批”。
### 6.4 存量历史记录
业务详情接口增加 `approval_source`
- `none`:当前业务不需要审批,例如代理在线充值;隐藏审批区域。
- `workflow`:存在通用审批实例,展示动态时间线和 `available_actions`
- `legacy`:发布前已经结束的历史记录,没有完整流程实例;只读展示原业务状态、旧审批摘要和审计信息,不生成虚假节点。
如果一条仍需审批的退款或员工线下充值返回 `approval_instance_id=null`,前端按数据迁移异常展示并禁止任何审批操作,不能退回旧业务单审批接口。
---
## 七、页面与需求映射
| 需求 | 端 | 页面/入口 | 关键交互 |
|------|----|-----------|----------|
| 01 | 后台 | 资产详情复机操作 | 界面不变,展示后端真实结果 |
| 02 | 后台 + C端 | 卡/设备列表实名策略C端流程页 | 单条/批量设置C端按接口策略跳转 |
| 03/07/11/12/13 | 后台 | 原有列表和详情 | 新筛选、新字段、动态审批摘要 |
| 05 | 后台 | 套餐分配弹框、已分配列表 | 生效条件覆盖和修改提示 |
| 08 | 后台 | 设备管理批量操作 | “批量分配代理”和“批量分配套餐系列”两个入口,上传、任务轮询、失败明细 |
| 09 | 后台 + C端 | 系统配置;支付页 | 后台配置支付方式C端隐藏并由后端兜底拦截 |
| 10 | 后台 | 卡/设备资产详情 | 手动设置或取消当前卡限速,单位 `kbps`,不提供套餐限速配置 |
| 14 | 后台 | 各业务列表导出 | 字段选择、权限过滤、统一导出任务 |
| 15 | C端 + 后台 | 当前套餐卡片、后台代购 | 当前套餐旁“续费”复用购买流程;下架套餐仅在合法续费入口展示 |
| 16 | - | 已移出 7 月迭代 | 不建设分销码、代理申请、提现材料和相关审批页面 |
| 17 | 后台/代理端 | 代理信用、钱包详情 | 元/分转换、欠款状态、额度权限 |
| 18/20/21 | 后台 | 待办、退款、充值详情 | 动态审批时间线、处理状态、退回重提 |
| 19 | 后台 | 批量订购 | 参数确认、上传、逐行结果和金额汇总 |
| 22 | 后台 + 代理端 + C端 | 临期列表、首页提醒 | 15/7/3 天分级、续费跳转、到期后自动消失 |
---
## 八、站内消息
- 登录后和进入后台布局时立即获取未读数,之后每 30 秒轮询。
- 页面不可见时暂停,恢复时立即刷新。
- 铃铛数字超过 99 显示 `99+`
- 通知点击只按受控的 `ref_type + ref_id` 路由表跳转,不接受后端返回任意 URL。
- 标记已读失败不阻止查看业务详情,但需要在下次轮询时恢复真实未读状态。
- 通知正文按纯文本展示;若未来支持富文本,必须使用受控模板和统一净化,不直接渲染任意 HTML。
---
## 九、权限与敏感数据
- 菜单和按钮根据登录返回的权限控制可见性,但后端权限校验是最终依据。
- 审批人资格由任务接口返回,前端不根据角色名称推导。
- 导出字段由后端返回允许字段集合;前端只能在允许集合中选择。
- 退款凭证、充值凭证、身份证、营业执照和审批附件使用现有对象存储上传流程,不提交本地路径或长期公开 URL。审批附件额外提交清理后的原文件名作为展示快照。
- 列表和详情对无权限与不存在统一展示,避免通过前端文案泄露资源存在性。
---
## 十、停机发布
1. 发布前进入维护模式,前端统一展示维护页并停止提交写请求。
2. 维护窗口内执行数据库迁移,同时发布 API、Worker/Relay 和前端静态资源。
3. 初始化并启用退款、充值流程定义和业务绑定,执行存量待审批单回填。
4. 前端只调用任务级审批 API不保留退款或线下充值的业务单级通过/驳回按钮,也不保留线下充值“确认入账”按钮。
5. 人工验证登录、菜单权限、流程发起、存量历史展示、待办、审批详情、退回重提和业务处理状态。
6. 验证通过后解除维护模式;失败则在开放访问前回滚整套应用版本。
前端必须容忍新增响应字段且不依赖字段顺序,但本次不要求支持旧后端与新前端或新后端与旧前端交叉运行。
---
## 十一、待前端仓库确认
以下内容不影响当前接口和交互评审,但实施前必须在前端仓库确认:
- 后台、代理端和 C 端分别使用的框架版本与目录结构。
- 现有权限指令、请求封装、上传组件和导出 Hook 的真实名称。
- 是否已有通用任务轮询组件和流程时间线组件。
- 实际菜单路由和按钮权限编码。
- 表格是否支持服务端返回的动态导出字段配置。
确认后只补充实现映射,不改变本文已经评审通过的业务状态和 API 契约。

View File

@@ -0,0 +1,383 @@
# 基础设施站内消息Notification
> 历史状态:初版方案,已被站内通知详细方案替代。
> 替代方案:[站内通知详细方案](../新增需求/03-站内通知详细方案.md) 和 [标准评审稿](../../7月迭代技术方案-标准评审稿.md)。
> 被依赖:需求 18/20/21审批流通知、需求 22临期提醒
> Phase 2 扩展:企业微信推送、短信通知(预留插拔接口)
---
## 一、设计原则
- **业务代码不直接写通知表**:统一通过通知发布器或领域事件异步处理
- **关键领域事件先写 Outbox**:审批、退款、充值等事务内事件由 Outbox Relay 投递 Asynq禁止事务提交后直接入队
- **通知消费必须幂等**:使用稳定的 `event_id` 和接收人唯一约束Asynq 重试不得重复生成站内消息
- **不过度抽象**:不用 interface用具体的 `NotificationPublisher`(后续加渠道 = 在 handler 里加代码)
- **前端轮询**30秒一次 `/api/admin/notifications/unread-count`,不用 WebSocket
```mermaid
sequenceDiagram
participant Biz as 业务事务
participant Outbox as Outbox
participant Relay as Relay
participant Worker as Notification Worker
participant DB as tb_notification
participant Web as 后台前端
Biz->>Outbox: 同事务写领域事件
Relay->>Outbox: 拉取待投递事件
Relay->>Worker: 至少一次投递
Worker->>DB: 按 event_id + recipient 幂等插入
Web->>DB: 经 API 轮询未读数/通知列表
Web->>DB: 经 API 标记已读
```
---
## 二、数据库
```sql
-- 迁移文件YYYYMMDD_create_tb_notification.sql
CREATE TABLE tb_notification (
id BIGSERIAL PRIMARY KEY,
event_id VARCHAR(64) NOT NULL, -- 领域事件ID或调用方请求ID用于幂等
recipient_id BIGINT NOT NULL, -- 用户IDadmin user 或 agent user
recipient_type VARCHAR(20) NOT NULL DEFAULT 'admin', -- admin | agent
type VARCHAR(50) NOT NULL, -- 通知类型(见常量定义)
title VARCHAR(200) NOT NULL, -- 标题
body TEXT NOT NULL DEFAULT '', -- 纯文本正文
ref_type VARCHAR(50), -- 关联业务类型 approval | recharge | refund | iot_card | device
ref_id BIGINT, -- 关联业务ID
is_read BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ -- 过期自动不展示(可选,临期提醒用)
);
CREATE INDEX idx_notification_recipient
ON tb_notification (recipient_id, recipient_type, is_read, created_at DESC);
CREATE INDEX idx_notification_created ON tb_notification (created_at DESC);
CREATE UNIQUE INDEX uq_notification_event_recipient
ON tb_notification (event_id, recipient_id, recipient_type);
COMMENT ON TABLE tb_notification IS '站内消息通知表';
```
---
## 三、常量定义
```go
// pkg/constants/notification.go
// 通知类型
const (
NotifyTypeApprovalPending = "approval.pending" // 待我审批
NotifyTypeApprovalDone = "approval.done" // 审批完成(申请人收)
NotifyTypeApprovalRejected = "approval.rejected" // 审批驳回(申请人收,流程终止)
NotifyTypeApprovalReturned = "approval.returned" // 审批退回(申请人收,可修改后重新提交)
NotifyTypePackageExpiring = "package.expiring" // 套餐临期
NotifyTypeSystemAlert = "system.alert" // 系统通知
)
// 通知接收者类型
const (
NotifyRecipientAdmin = "admin" // 平台用户
NotifyRecipientAgent = "agent" // 代理用户(预留)
)
```
---
## 四、Model
```go
// internal/model/notification.go
// Notification 站内消息模型
type Notification struct {
ID uint `gorm:"column:id;primaryKey" json:"id"`
EventID string `gorm:"column:event_id;type:varchar(64);not null;uniqueIndex:uq_notification_event_recipient,priority:1" json:"event_id"`
RecipientID uint `gorm:"column:recipient_id;not null;index;uniqueIndex:uq_notification_event_recipient,priority:2" json:"recipient_id"`
RecipientType string `gorm:"column:recipient_type;type:varchar(20);not null;default:'admin';uniqueIndex:uq_notification_event_recipient,priority:3" json:"recipient_type"`
Type string `gorm:"column:type;type:varchar(50);not null" json:"type"`
Title string `gorm:"column:title;type:varchar(200);not null" json:"title"`
Body string `gorm:"column:body;type:text;not null;default:''" json:"body"`
RefType *string `gorm:"column:ref_type;type:varchar(50)" json:"ref_type,omitempty"`
RefID *uint `gorm:"column:ref_id" json:"ref_id,omitempty"`
IsRead bool `gorm:"column:is_read;not null;default:false" json:"is_read"`
ReadAt *time.Time `gorm:"column:read_at" json:"read_at,omitempty"`
CreatedAt time.Time `gorm:"column:created_at;not null" json:"created_at"`
ExpiresAt *time.Time `gorm:"column:expires_at" json:"expires_at,omitempty"`
}
func (Notification) TableName() string { return "tb_notification" }
```
---
## 五、发布侧NotificationPublisher
非事务性、允许调用方直接发起的通知使用发布器异步入队。审批等领域事件不直接调用该发布器,而由 `TaskCreated``ProcessApproved` 等事件处理器转换为通知载荷。
领域事件通知使用领域事件自身的 `event_id`;非领域事件调用方使用稳定的业务请求 ID。网络重试或 Asynq 重试时禁止重新生成 ID。
```go
// internal/infrastructure/messaging/notification_publisher.go
// SendPayload 发送通知的参数
type SendPayload struct {
EventID string // 领域事件ID或调用方请求ID同一次重试必须保持不变
RecipientIDs []uint // 接收人ID列表
RecipientType string // admin | agent
Type string // 通知类型常量
Title string // 标题
Body string // 正文
RefType string // 关联业务类型(可选)
RefID uint // 关联业务ID可选
ExpiresAt *time.Time // 过期时间(可选)
}
// NotificationPublisher 通知发布器(非接口,简单具体实现)
type NotificationPublisher struct {
queueClient *queue.Client
logger *zap.Logger
}
// Publish 发布通知(异步)
// 返回错误供调用方或 Asynq Handler 决定重试,禁止吞掉关键通知错误。
func (p *NotificationPublisher) Publish(ctx context.Context, payload SendPayload) error {
if payload.EventID == "" {
return errors.New(errors.CodeInvalidParam, "通知事件ID不能为空")
}
if err := p.queueClient.EnqueueTask(ctx, constants.TaskTypeNotification, payload); err != nil {
p.logger.Error("通知入队失败", zap.Error(err), zap.String("type", payload.Type))
return err
}
return nil
}
```
审批事件处理器调用示例(节点激活后通知候选审批人):
```go
return h.notifyPublisher.Publish(ctx, notification.SendPayload{
EventID: event.EventID,
RecipientIDs: []uint{nextApproverID},
RecipientType: constants.NotifyRecipientAdmin,
Type: constants.NotifyTypeApprovalPending,
Title: "您有一条待审批记录",
Body: fmt.Sprintf("代理「%s」提交了充值申请请及时审批。", shopName),
RefType: "approval",
RefID: processInstanceID,
})
```
审批事务中只写 `TaskCreated` Outbox 事件。即使 Redis/Asynq 暂时不可用,事件仍保留在数据库,由 Relay 重试;通知处理失败则由 Asynq 重试当前任务。
---
## 六、消费侧Asynq Task Handler
```go
// internal/task/notification_handler.go
// HandleNotification 处理通知发送任务
func (h *NotificationHandler) HandleNotification(ctx context.Context, t *asynq.Task) error {
var payload notification.SendPayload
if err := sonic.Unmarshal(t.Payload(), &payload); err != nil {
return fmt.Errorf("反序列化通知载荷失败: %w", err)
}
// 批量写入 tb_notification
records := make([]model.Notification, 0, len(payload.RecipientIDs))
for _, uid := range payload.RecipientIDs {
ref_type := (*string)(nil)
ref_id := (*uint)(nil)
if payload.RefType != "" {
ref_type = &payload.RefType
}
if payload.RefID != 0 {
ref_id = &payload.RefID
}
records = append(records, model.Notification{
EventID: payload.EventID,
RecipientID: uid,
RecipientType: payload.RecipientType,
Type: payload.Type,
Title: payload.Title,
Body: payload.Body,
RefType: ref_type,
RefID: ref_id,
ExpiresAt: payload.ExpiresAt,
})
}
if err := h.db.WithContext(ctx).
Clauses(clause.OnConflict{DoNothing: true}).
CreateInBatches(records, 100).Error; err != nil {
return fmt.Errorf("批量写入通知失败: %w", err)
}
// Phase 2在此处加企微/短信调用
// for _, sender := range h.extraSenders { sender.Send(ctx, payload) }
return nil
}
```
---
## 七、API 设计
### 7.1 未读数量前端轮询用30秒一次
```
GET /api/admin/notifications/unread-count
```
响应:
```json
{ "code": 0, "data": { "count": 5 } }
```
### 7.2 通知列表
```
GET /api/admin/notifications?is_read=false&type=approval.pending&page=1&page_size=20
```
请求参数:
```go
type NotificationListRequest struct {
IsRead *bool `query:"is_read" description:"是否已读(不传=全部)"`
Type string `query:"type" description:"通知类型过滤(可选)"`
Page int `query:"page" description:"页码"`
PageSize int `query:"page_size" description:"每页数量最大50"`
}
```
响应:
```json
{
"code": 0,
"data": {
"list": [
{
"id": 1,
"type": "approval.pending",
"title": "您有一条待审批记录",
"body": "代理「XX店」提交了充值申请请及时审批。",
"ref_type": "approval",
"ref_id": 42,
"is_read": false,
"created_at": "2026-07-11T10:00:00Z"
}
],
"total": 3,
"page": 1,
"page_size": 20
}
}
```
### 7.3 标记已读
```
PUT /api/admin/notifications/{id}/read
```
### 7.4 全部标记已读
```
PUT /api/admin/notifications/read-all
```
可传 `type` 过滤(只把某类型全部已读):
```json
{ "type": "approval.pending" }
```
### DTO
```go
// internal/model/dto/notification_dto.go
type NotificationListRequest struct {
IsRead *bool `query:"is_read"`
Type string `query:"type"`
Page int `query:"page" default:"1"`
PageSize int `query:"page_size" default:"20"`
}
type NotificationItem struct {
ID uint `json:"id"`
Type string `json:"type" description:"通知类型"`
Title string `json:"title"`
Body string `json:"body"`
RefType *string `json:"ref_type,omitempty"`
RefID *uint `json:"ref_id,omitempty"`
IsRead bool `json:"is_read"`
ReadAt *time.Time `json:"read_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
type UnreadCountResponse struct {
Count int64 `json:"count"`
}
type ReadAllRequest struct {
Type string `json:"type" description:"通知类型(为空则全部已读)"`
}
```
---
## 八、前端对接
### 顶部导航栏铃铛
```
组件挂载 → 轮询 GET /api/admin/notifications/unread-count30秒一次
→ count > 0 时铃铛显示红点 + 数字
→ 点击铃铛 → 弹出通知抽屉 or 跳转 /notifications 页面
→ 打开时调 GET /api/admin/notifications?is_read=false
→ 点击某条通知 → PUT /api/admin/notifications/{id}/read → 根据 ref_type+ref_id 跳转对应业务页
```
### 跳转逻辑ref_type
| ref_type | 跳转页面 |
|----------|---------|
| `approval` | `/approvals/instances/{ref_id}` 审批详情 |
| `recharge` | `/agent-recharges/{ref_id}` 充值单详情 |
| `refund` | `/refunds/{ref_id}` 退款单详情 |
| `iot_card` | `/iot-cards/{ref_id}` IoT卡详情临期提醒 |
| `device` | `/devices/{ref_id}` 设备详情(临期提醒) |
### 通知列表页(/notifications
筛选:通知类型(下拉)、已读状态
操作:全部已读按钮
列表字段:类型、标题、时间、已读状态
点击行:跳转关联业务详情
前端页面不可见时暂停未读数轮询,恢复可见时立即刷新。通知正文按纯文本渲染;未来需要富文本时使用受控模板和统一净化,禁止直接渲染业务方提交的 HTML。
---
## 九、Phase 2 扩展预留
当需要接入企微通知时,只需在 `HandleNotification` 里追加:
```go
// 企微通知Phase 2
if h.wecomClient != nil {
for _, uid := range payload.RecipientIDs {
wecomOpenID := h.userStore.GetWecomOpenID(ctx, uid)
h.wecomClient.SendMessage(wecomOpenID, payload.Title, payload.Body)
}
}
```
业务代码零修改Handler 里加一段即可。

View File

@@ -0,0 +1,56 @@
# 需求01行业卡后台手动复机允许未实名
> 状态:原需求来源稿;实名最终规则已由数据同步方案和标准评审稿修正。
---
## 背景
**当前代码**`internal/service/iot_card/stop_resume_service.go:938`
```go
// ManualStartCard - 当前写法(错误)
if card.RealNameStatus != constants.RealNameStatusVerified {
return errors.New(errors.CodeForbidden, "卡未实名,无法操作")
}
```
`isRealnameOK()` 已经正确处理行业卡豁免:
```go
// 第234行行业卡无需实名
func (s *StopResumeService) isRealnameOK(card *model.IotCard) bool {
return card.CardCategory == constants.CardCategoryIndustry ||
card.RealNameStatus == constants.RealNameStatusVerified
}
```
`ManualStartCard` 没有走这个函数,直接判断了 `RealNameStatus`,导致行业卡手动复机也被拦截。
---
## 修改范围
**只改一行**,影响范围极小。
**文件**`internal/service/iot_card/stop_resume_service.go`
```go
// 修改前第938行
if card.RealNameStatus != constants.RealNameStatusVerified {
denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作")
...
}
// 修改后
if !s.isRealnameOK(card) {
denyErr := errors.New(errors.CodeForbidden, "卡未实名,无法操作")
...
}
```
---
## 前端对接
无需前端改动。复机操作界面不变,行业卡原先会报错"卡未实名,无法操作",修复后直接成功。

View File

@@ -0,0 +1,213 @@
# 需求02H5 流程顺序配置化
> 状态:原需求独立稿;最终口径以标准评审稿为准。
---
## 背景
H5 用户进入后:绑定手机号 → 充值 → 实名(当前顺序写死)
需求:允许**按资产个体**配置充值和实名的顺序,支持后台单条或批量改。
---
## 流程图
### 图一H5 登录后资产视角判断与策略读取
```mermaid
flowchart TD
A[用户扫码 / 输入虚拟号] --> B[解析 identifier]
B --> C{资产类型}
C -->|card| D{该卡是否绑定设备?}
D -->|否 独立卡| E[卡视角\n读 IotCard.realname_policy]
D -->|是| F[设备视角\n读 Device.realname_policy\n卡自身策略忽略]
C -->|device| F
E --> G{realname_policy}
F --> G
G -->|none| H[无需实名\n直接进充值页]
G -->|before_order| I[先进实名页\n实名完成后才能充值]
G -->|after_order| J[先进充值页\n充值完成后提示实名]
```
### 图二H5 充值/购买前的策略拦截逻辑
```mermaid
flowchart TD
A[用户发起充值/购买] --> B[读取 resolved.Asset.RealnamePolicy]
B --> C{策略是 before_order?}
C -->|否| D[放行,正常创建订单]
C -->|是| E{当前资产 RealNameStatus == 1?}
E -->|已实名| D
E -->|未实名| F[返回 CodeNeedRealname\nH5 跳转实名页]
```
### 图三GetEffectiveRealnamePolicy 取值逻辑
```mermaid
flowchart TD
A[GetEffectiveRealnamePolicy\ncard, device] --> B{device != nil?}
B -->|是| C[返回 device.RealnamePolicy]
B -->|否| D{card != nil?}
D -->|是| E[返回 card.RealnamePolicy]
D -->|否| F[返回 none]
```
---
## 资产类型与策略归属
系统有两类资产:**独立卡** 和 **设备**
| 资产类型 | realname_policy 归属 | H5 视角 |
|---------|---------------------|---------|
| 独立卡(无设备绑定) | 卡自身的 `realname_policy` | 卡视角 |
| 设备 | 设备自身的 `realname_policy` | 设备视角 |
| 设备下的卡 | **设备**的 `realname_policy`(卡自身策略无效) | 设备视角 |
**关键规则**:设备下的卡无法以卡视角独立登录 H5登录后自动进入设备视角因此实名流程策略由设备决定卡自身的 `realname_policy` 字段对 H5 流程无影响(但字段保留,仅作记录)。
该逻辑已在 `internal/service/asset/service.go:GetEffectiveRealnamePolicy()` 实现:设备不为 nil 时取设备策略,否则取卡策略。
---
## 当前字段状态
两个模型都已有 `realname_policy` 字段,无需迁移:
```go
// internal/model/iot_card.go
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;
comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)"`
// internal/model/device.go
RealnamePolicy string `gorm:"column:realname_policy;type:varchar(20);default:'after_order';not null;
comment:实名认证策略(none=无需实名,before_order=先实名后充值/购买,after_order=先充值/购买后实名)"`
```
值含义:
- `none` — 无需实名
- `before_order` — 先实名后充值
- `after_order` — 先充值后实名(**当前默认**
---
## 后端实现
### 1. 单条修改接口(已有,无需新建)
```
PATCH /api/admin/assets/:identifier/realname-mode
```
该接口已在 `internal/handler/admin/asset.go:UpdateRealnamePolicy()` 实现,通过 identifier 自动解析资产类型,卡和设备都走这里,**不需要再建卡专属或设备专属路由**。
请求体(已有 DTO
```go
type UpdateAssetRealnamePolicyRequest struct {
RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order"
description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"`
}
```
### 2. 新增批量修改接口(需新建)
卡和设备分开批量接口,因为两者在后台是不同的列表页。
#### 2a. 批量修改卡实名策略
```
POST /api/admin/iot-cards/batch-update-realname-policy
```
请求体:
```go
type BatchUpdateIotCardRealnamePolicy struct {
IotCardIDs []uint `json:"iot_card_ids" validate:"required,min=1,max=500,dive,gt=0" description:"卡ID列表最多500条"`
RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order"
description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"`
}
```
Service在一个事务中校验最多 500 条 ID 均存在且均在当前账号数据范围内,再执行 `UPDATE tb_iot_card SET realname_policy = ? WHERE id IN (...)`。任一记录不合法则整批回滚,并写一条包含目标策略和 ID 数量的批量审计日志。
#### 2b. 批量修改设备实名策略
```
POST /api/admin/devices/batch-update-realname-policy
```
请求体:
```go
type BatchUpdateDeviceRealnamePolicy struct {
DeviceIDs []uint `json:"device_ids" validate:"required,min=1,max=500,dive,gt=0" description:"设备ID列表最多500条"`
RealnamePolicy string `json:"realname_policy" validate:"required,oneof=none before_order after_order"
description:"实名策略 (none:无需实名, before_order:先实名后充值, after_order:先充值后实名)"`
}
```
Service与卡批量接口相同单次最多 500 条、事务内全成全败;任一设备不存在或越权则不更新任何记录。
### 3. 全局默认值(兜底)
新建卡/设备时默认 `after_order`,通过 GORM default 标签保证,不需要读 `tb_system_config`
---
## 前端对接
### 卡列表页
"操作"列或批量操作下拉增加"设置实名策略"
- **单条**:弹框选策略 → `PATCH /api/admin/assets/{iccid}/realname-mode`
- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /api/admin/iot-cards/batch-update-realname-policy`
> 注意:设备下的卡即使在卡列表中修改了策略,对 H5 流程也无效H5 取设备策略)。建议在卡列表展示"所属设备"列,提示运营该卡已属于某设备,实名策略需到设备处修改。
### 设备列表页
"操作"列或批量操作下拉增加"设置实名策略"
- **单条**:弹框选策略 → `PATCH /api/admin/assets/{sn}/realname-mode`
- **批量**:勾选多条 → 批量操作 → "设置实名策略" → `POST /api/admin/devices/batch-update-realname-policy`
### 字段展示
卡列表/详情、设备列表/详情均展示"实名策略"字段:
| realname_policy | 展示文案 |
|----------------|---------|
| `none` | 无需实名 |
| `before_order` | 先实名后充值 |
| `after_order` | 先充值后实名 |
### H5 侧C端
H5 读取资产初始化接口返回的 `realname_policy` 字段,决定先跳充值页还是先跳实名页。
- 独立卡登录 → 读卡的 `realname_policy`
- 设备/设备下的卡登录 → 读设备的 `realname_policy`
该逻辑由 `GetEffectiveRealnamePolicy()` 统一处理H5 无需区分资产类型,直接用接口返回值即可。
---
## 实施范围汇总
| 项目 | 状态 | 说明 |
|------|------|------|
| `IotCard.realname_policy` 字段 | ✅ 已有 | 无需迁移 |
| `Device.realname_policy` 字段 | ✅ 已有 | 无需迁移 |
| 单条修改接口 | ✅ 已有 | `PATCH /api/admin/assets/:identifier/realname-mode` |
| `GetEffectiveRealnamePolicy()` | ✅ 已有 | 设备视角取设备策略 |
| H5 充值前校验 | ✅ 已有 | `client_wallet.go` 已正确读取 |
| 批量修改卡接口 | ❌ 待建 | `POST /api/admin/iot-cards/batch-update-realname-policy` |
| 批量修改设备接口 | ❌ 待建 | `POST /api/admin/devices/batch-update-realname-policy` |
| 后台卡列表操作入口 | ❌ 待建(前端) | 单条+批量 |
| 后台设备列表操作入口 | ❌ 待建(前端) | 单条+批量 |

View 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` 参数请求。
---
## 需求07IoT卡/设备管理新增已实名/未实名筛选
### IoT 卡
`IotCard.real_name_status` 已有0=未实名, 1=已实名),`ListStandaloneIotCardRequest` 无该过滤字段,需新增。
**DTO 变更**`internal/model/dto/iot_card_dto.go``ListStandaloneIotCardRequest` 新增):
```go
RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,oneof=0 1" description:"实名状态 (0:未实名, 1:已实名)"`
```
**Store 追加**`internal/store/postgres/iot_card_store.go`
```go
if req.RealNameStatus != nil {
query = query.Where("real_name_status = ?", *req.RealNameStatus)
}
```
### 设备
设备本身目前无 `real_name_status` 字段。语义为:任意一张绑定卡已实名 = 设备已实名。
为避免列表查询时走 EXISTS 子查询,改为**快照方案**:在 `Device` 表落盘,轮询时维护。
#### 迁移
`tb_device` 新增字段:
```sql
ALTER TABLE tb_device
ADD COLUMN real_name_status INT NOT NULL DEFAULT 0;
COMMENT ON COLUMN tb_device.real_name_status
IS '实名状态快照(0=未实名,1=已实名)任意绑定卡已实名则为1由轮询异步维护';
```
**Model**`internal/model/device.go`
```go
RealNameStatus int `gorm:"column:real_name_status;type:int;default:0;not null;comment:实名状态快照(0=未实名,1=已实名)任意绑定卡已实名则为1" json:"real_name_status"`
```
#### 快照更新时机
以下两处卡实名状态变化时,需同步更新所属设备的快照:
**1. 轮询实名处理**`internal/task/polling_realname_handler.go`
卡状态变化后,已有 `triggerDeviceRealnameActivation` 查出 `deviceID`,在此同步更新设备快照:
```go
// statusChanged 时,如果卡属于某设备,重新计算并写入设备快照
if statusChanged {
if binding, err := h.deviceSimBindingStore.GetActiveBindingByCardID(ctx, cardID); err == nil {
h.deviceStore.RefreshRealnameSnapshot(ctx, binding.DeviceID)
}
}
```
**2. 管理员手动修改卡实名状态**`internal/service/iot_card/service.go:ManualUpdateRealnameStatus`
更新卡状态成功后,查所属设备并更新快照(同上逻辑)。
#### 快照计算
`DeviceStore.RefreshRealnameSnapshot`
```go
// RefreshRealnameSnapshot 重新计算并写入设备实名状态快照
func (s *DeviceStore) RefreshRealnameSnapshot(ctx context.Context, deviceID uint) error {
var count int64
s.db.WithContext(ctx).Raw(`
SELECT COUNT(*) FROM tb_device_sim_binding dsb
JOIN tb_iot_card ic ON ic.id = dsb.iot_card_id
WHERE dsb.device_id = ? AND dsb.deleted_at IS NULL
AND ic.real_name_status = 1 AND ic.deleted_at IS NULL
`, deviceID).Scan(&count)
status := 0
if count > 0 {
status = 1
}
return s.db.WithContext(ctx).Model(&model.Device{}).
Where("id = ?", deviceID).
Update("real_name_status", status).Error
}
```
#### DTO 变更
**请求**`internal/model/dto/device_dto.go``ListDeviceRequest` 新增):
```go
RealNameStatus *int `json:"real_name_status" query:"real_name_status" validate:"omitempty,oneof=0 1" description:"实名状态 (0:未实名, 1:已实名)"`
```
**响应**`DeviceResponse` 新增):
```go
RealNameStatus int `json:"real_name_status" description:"实名状态 (0:未实名, 1:已实名)"`
RealNameStatusName string `json:"real_name_status_name" description:"实名状态名称(中文)"`
```
**Store 过滤**(直接 WHERE无需 EXISTS
```go
if req.RealNameStatus != nil {
query = query.Where("real_name_status = ?", *req.RealNameStatus)
}
```
### 前端
IoT卡管理筛选栏新增"实名状态"下拉(全部/已实名/未实名)→ 传 `real_name_status=0|1`
设备管理同上,列表展示 `real_name_status_name` 字段。
---
## 需求11资产详情套餐到期时间与需求06合并
需求11和需求06属于同一业务需求资产层只展示当前及排队主套餐连续使用后的预计最终到期时间。详细计算、DTO 和前端规则统一见[需求04/06独立稿](./需求04-06-退款拦截与最后到期时间.md)。
现有 `current_package_expires_at` 只表示当前套餐结束时间,不能代表资产服务最终结束时间,因此不得继续作为资产详情汇总到期时间或临期判断依据。
统一使用:
```text
estimated_final_expires_at
days_until_final_expiry
expiry_estimate_status
```
前端不自行计算天数高亮颜色和临期状态直接使用需求22统一返回字段。
---
## 需求12换货管理显示修复
### 背景
换货单表 `tb_exchange_order`
- `old_asset_identifier` — 旧资产标识符快照
- `new_asset_identifier` — 新资产标识符快照
- `old_asset_id` / `new_asset_id` — 旧/新资产主键
### EXC-001/EXC-002旧/新资产标识显示不一致
**根本原因**:后端创建换货单时快照逻辑有误(`internal/service/exchange/service.go`)。
- 卡的旧资产:快照了 `card.VirtualNo`(虚拟号),**应为 `card.ICCID`**
- 卡的新资产:快照了操作员输入的 identifier 原值,未规范化,**应统一为 `card.ICCID`**
- 设备:快照 `VirtualNo` 优先,没有则 `IMEI`**逻辑正确,无需改动**
**修复**`internal/service/exchange/service.go`
`resolveAssetByIdentifierWithTx` 及锁定资产路径中,卡的 `Identifier` 改为 `card.ICCID`
```go
// 修复前
return &resolvedExchangeAsset{..., Identifier: card.VirtualNo, ...}
// 修复后
return &resolvedExchangeAsset{..., Identifier: card.ICCID, ...}
```
历史数据不回填,仅修正后续新建换货单的快照行为。
### EXC-003/EXC-004旧/新资产搜索支持 ICCID/接入号/虚拟号
**方案**:拆分为独立的旧资产和新资产搜索,搜索逻辑用**两步查询**,不用 JOIN。
**DTO 变更**`internal/model/dto/exchange_dto.go``ExchangeListRequest`
废弃原有 `Identifier` 字段,改为:
```go
OldAssetKeyword string `json:"old_asset_keyword" query:"old_asset_keyword" validate:"omitempty,max=100" description:"旧资产搜索ICCID/接入号/虚拟号)"`
NewAssetKeyword string `json:"new_asset_keyword" query:"new_asset_keyword" validate:"omitempty,max=100" description:"新资产搜索ICCID/接入号/虚拟号)"`
```
**Store 修改**`internal/store/postgres/exchange_order_store.go`
两步查询——先在资产表搜出 ID再过滤换货表
```go
// 步骤1旧资产关键词搜索
if req.OldAssetKeyword != "" {
kw := "%" + req.OldAssetKeyword + "%"
var cardIDs []uint
s.db.WithContext(ctx).Table("tb_iot_card").
Where("(iccid LIKE ? OR virtual_no LIKE ? OR msisdn LIKE ?) AND deleted_at IS NULL", kw, kw, kw).
Pluck("id", &cardIDs)
var deviceIDs []uint
s.db.WithContext(ctx).Table("tb_device").
Where("(virtual_no LIKE ? OR imei LIKE ?) AND deleted_at IS NULL", kw, kw).
Pluck("id", &deviceIDs)
if len(cardIDs) == 0 && len(deviceIDs) == 0 {
return &ExchangeListResult{}, nil // 无匹配,直接返回空
}
query = query.Where(
"(old_asset_type = 'iot_card' AND old_asset_id IN ?) OR (old_asset_type = 'device' AND old_asset_id IN ?)",
cardIDs, deviceIDs,
)
}
// new_asset_keyword 同理,过滤 new_asset_id
```
### 前端
- EXC-001/002后端修复后`old_asset_identifier``new_asset_identifier` 均为 ICCID或设备号设备展示直接读这两个字段即可
- EXC-003/004搜索栏拆分为"旧资产"和"新资产"两个独立输入框,分别传 `old_asset_keyword``new_asset_keyword`
---
## 需求13列表字段新增
### 核心原则
- 提交人账号名在业务单创建时快照到业务表。
- 审批节点、候选审批人和实际操作人快照统一保存在审批流任务表,不在业务表写死具体节点字段。
- 列表查询审批信息时,根据本页全部 `approval_instance_id` 批量查询并在内存分组,禁止逐条查询造成 N+1。
---
### COL-003换货管理列表新增提交人待建
> 需求文档原写"换号管理",确认为"换货管理"(系统无"换号"概念)。
**迁移**`tb_exchange_order` 新增字段:
```sql
ALTER TABLE tb_exchange_order ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT '';
```
**Model**`internal/model/exchange_order.go`
```go
SubmitterName string `gorm:"column:submitter_name;type:varchar(50);not null;default:'';comment:提交人账号名快照" json:"submitter_name"`
```
**创建换货单时**`internal/service/exchange/service.go`)快照当前操作人 username
```go
SubmitterName: middleware.GetUsername(ctx), // 从 ctx 取当前登录账号的 username
```
**响应 DTO**`internal/model/dto/exchange_dto.go``ExchangeOrderResponse` 新增):
```go
SubmitterName string `json:"submitter_name" description:"提交人账号名"`
```
---
### COL-001退款管理列表新增提交人、审批人依赖审批流
**迁移**`tb_refund_request` 新增提交人快照字段;`approval_instance_id` 由需求18/20统一增加
```sql
ALTER TABLE tb_refund_request
ADD COLUMN submitter_name varchar(50) NOT NULL DEFAULT '';
```
- `submitter_name`:创建退款单时快照操作人 username
- 审批状态、当前节点和审批记录:从审批实例、任务和任务审批人快照批量读取
> **实施依赖**动态审批摘要依赖审批流需求20`submitter_name` 可独立实现。
**响应 DTO**(退款列表响应新增):
```go
SubmitterName string `json:"submitter_name" description:"提交人账号名"`
ApprovalSource string `json:"approval_source" description:"审批来源 (none:无需审批, workflow:通用审批流, legacy:历史业务审批)"`
ApprovalStatus int `json:"approval_status" description:"审批状态 (1:审批中, 2:已通过, 3:已驳回, 4:已退回)"`
ApprovalStatusName string `json:"approval_status_name" description:"审批状态名称(中文)"`
CurrentApprovalNode string `json:"current_approval_node" description:"当前审批节点名称"`
ApprovalRecords []ApprovalRecordSummary `json:"approval_records" description:"审批节点和审批人摘要"`
ProcessingStatus int `json:"processing_status" description:"审批通过后的业务处理状态"`
ProcessingStatusName string `json:"processing_status_name" description:"业务处理状态名称(中文)"`
```
`ApprovalRecordSummary` 动态返回 `node_name``approval_mode``status` 和审批人列表;每位已操作审批人包含动作、审批意见和 `attachment_count`,但列表接口不返回完整附件元数据。不假设固定存在“部门领导”或“财务”节点。
停机发布前已经结束且没有流程实例的退款记录返回 `approval_source=legacy`。这类记录可以使用原 `processor_id``processed_at` 和审计日志组成只读历史摘要,但不得伪造多节点审批时间线;发布时仍待审批的记录必须先回填通用审批实例。
---
### COL-002代理充值列表新增提交人、审批人依赖审批流
与 COL-001 同理,`tb_agent_recharge_record` 仅新增 `submitter_name` 快照字段;`approval_instance_id` 由需求18/21统一增加。审批摘要从审批流批量读取。历史终态充值返回 `approval_source=legacy` 并只读展示原状态和审计信息。
> **实施依赖**`submitter_name` 本迭代可实现动态审批摘要依赖需求21充值审批流
---
### 前端
退款和充值列表增加“审批状态 / 当前节点 / 业务处理状态 / 审批记录”展示。审批记录按节点动态渲染,不能固定绑定两个审批人字段;审批已通过后的代理钱包退款可显示“回退处理中”,其他支付方式显示“待人工退款”,都不能显示成“待审批”。`approval_source=legacy` 时显示“历史审批”标识且不提供操作按钮。

View File

@@ -0,0 +1,206 @@
# 需求04退款中资产禁止换货
# 需求06/11资产预计最终到期时间
> 状态:原需求独立稿;最终口径以标准评审稿为准。
---
## 需求04退款中禁止换货
### 业务规则
资产存在**未结束**的退款申请时,不允许操作换货,提示"该资产存在退款申请,无法操作换货"。
拦截范围:
- `status=1` 待审批。
- `status=4` 已退回,等待提交人修改。
- `status=2` 已通过但 `processing_status!=2`,实际退款仍在处理、等待处理或失败重试。
不拦截:`status=3` 已拒绝,或 `status=2 AND processing_status=2` 已完成实际退款。`processing_status` 由需求20新增。
### 数据模型
退款模型:`RefundRequest`(表 `tb_refund_request`
资产字段为两个独立字段(无 asset_type/asset_id
- `iot_card_id *uint`IoT卡ID卡类资产
- `device_id *uint`设备ID设备类资产
状态常量(`internal/model/refund.go`
```go
RefundStatusPending = 1 // 待审批
RefundStatusApproved = 2 // 已通过
RefundStatusRejected = 3 // 已拒绝
RefundStatusReturned = 4 // 已退回(退回给提交人,仍拦截换货)
```
### 实现位置
换货单创建入口:`internal/service/exchange/service.go` 创建前校验。
### 后端
**Store 新增方法**`internal/store/postgres/refund_store.go`
```go
// HasActiveRefundByCard 检查指定IoT卡是否存在未结束的退款申请
func (s *RefundStore) HasActiveRefundByCard(ctx context.Context, cardID uint) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
Where(`iot_card_id = ?
AND deleted_at IS NULL
AND (
status IN (?, ?)
OR (status = ? AND processing_status <> ?)
)`,
cardID,
model.RefundStatusPending, // 1=待审批
model.RefundStatusReturned, // 4=已退回(拦截)
model.RefundStatusApproved, // 2=审批已通过
constants.ProcessingStatusSucceeded,
).Count(&count).Error
return count > 0, err
}
// HasActiveRefundByDevice 检查指定设备是否存在未结束的退款申请
func (s *RefundStore) HasActiveRefundByDevice(ctx context.Context, deviceID uint) (bool, error) {
var count int64
err := s.db.WithContext(ctx).Model(&model.RefundRequest{}).
Where(`device_id = ?
AND deleted_at IS NULL
AND (
status IN (?, ?)
OR (status = ? AND processing_status <> ?)
)`,
deviceID,
model.RefundStatusPending, // 1=待审批
model.RefundStatusReturned, // 4=已退回(拦截)
model.RefundStatusApproved, // 2=审批已通过
constants.ProcessingStatusSucceeded,
).Count(&count).Error
return count > 0, err
}
```
**Service 校验**`internal/service/exchange/service.go` 创建换货单前调用):
```go
// validateNoActiveRefund 校验资产是否有未结束的退款申请
func (s *ExchangeService) validateNoActiveRefund(ctx context.Context, asset *resolvedExchangeAsset) error {
var hasActive bool
var err error
if asset.CardID != nil {
hasActive, err = s.refundStore.HasActiveRefundByCard(ctx, *asset.CardID)
} else if asset.DeviceID != nil {
hasActive, err = s.refundStore.HasActiveRefundByDevice(ctx, *asset.DeviceID)
}
if err != nil {
return err
}
if hasActive {
return errors.New(errors.CodeForbidden, "该资产存在退款申请,无法操作换货")
}
return nil
}
```
### 前端
无需改动。换货申请时后端返回错误,前端展示错误信息即可。
---
## 需求06/11资产详情-预计最终到期时间
### 业务规则
需求06与需求11是同一个业务需求的两种描述不再拆成“当前套餐到期”和“所有套餐最后到期”两个资产汇总字段。资产详情页只展示**当前生效主套餐 + 所有待生效主套餐按队列接续后的预计最终到期时间**。
不能只取已经写入 `expires_at` 的最大值。排队套餐通常尚未激活,`expires_at` 为空,但其购买时的周期和时长已经确定,正常情况下仍可推算最终到期时间。
加油包不延长主套餐服务周期,不参与本字段计算。已失效、已退款或已过期的使用记录不参与。
### 接口
后台资产详情页实际调用的是:
```
GET /api/admin/assets/resolve/:identifier
```
响应 DTO`AssetResolveResponse``internal/model/dto/asset_dto.go`
该 DTO 目前只有当前套餐到期时间,需新增统一的最终到期响应,并由前端替代原资产汇总展示。
### 后端
**DTO 新增字段**`internal/model/dto/asset_dto.go``AssetResolveResponse`
```go
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at" description:"当前及排队主套餐接续后的预计最终到期时间"`
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余自然日"`
ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态 (exact:可推算, waiting_activation:等待未知激活时间, none:无套餐)"`
```
**Store 新增方法**`internal/store/postgres/package_usage_store.go`
```go
// GetProjectableMainPackagesByCardID 获取可推算的当前/排队主套餐。
func (s *PackageUsageStore) GetProjectableMainPackagesByCardID(ctx context.Context, cardID uint) ([]*model.PackageUsage, error) {
var usages []*model.PackageUsage
err := s.db.WithContext(ctx).
Where("iot_card_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", cardID,
constants.PackageUsageStatusActive, // 1=生效中
constants.PackageUsageStatusPending, // 0=待生效
constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期
).
Order("priority ASC, created_at ASC, id ASC").
Find(&usages).Error
return usages, err
}
// GetProjectableMainPackagesByDeviceID 获取可推算的当前/排队主套餐。
func (s *PackageUsageStore) GetProjectableMainPackagesByDeviceID(ctx context.Context, deviceID uint) ([]*model.PackageUsage, error) {
var usages []*model.PackageUsage
err := s.db.WithContext(ctx).
Where("device_id = ? AND master_usage_id IS NULL AND status IN (?, ?, ?) AND deleted_at IS NULL", deviceID,
constants.PackageUsageStatusActive, // 1=生效中
constants.PackageUsageStatusPending, // 0=待生效
constants.PackageUsageStatusDepleted, // 2=已用完但仍占用当前周期
).
Order("priority ASC, created_at ASC, id ASC").
Find(&usages).Error
return usages, err
}
```
新建 `PackageUsage` 必须快照 `calendar_type_snapshot``duration_months_snapshot``duration_days_snapshot`与需求05的 `expiry_base_snapshot` 一起在购买时写入。旧记录没有时长快照时才回退读取当前套餐,且仅作为历史兼容。
**Service 计算逻辑**(在 `ResolveAsset` 结果组装处添加):
```go
// 1. 找当前主套餐的 expires_at 作为 cursor。
// 2. 按 priority、created_at、id 遍历排队主套餐。
// 3. 每个排队套餐以 cursor 为预计激活点,使用其购买时长快照计算新的 cursor。
// 4. cursor 即预计最后到期时间。
lastExpiry, err := s.packageUsageStore.ProjectLastMainPackageExpiry(ctx, assetType, assetID, now)
if err != nil {
return nil, err
}
resp.EstimatedFinalExpiresAt = lastExpiry
```
`ProjectLastMainPackageExpiry` 是 Query 计算,不写快照表:当前主套餐到期时间变化、排队套餐新增/退款失效后,下一次详情查询立即反映。若资产没有当前套餐且队首套餐仍等待无法预测的外部前置条件(例如尚未实名),返回 `null`,前端显示“—”,不伪造日期。
### 前端
资产详情“套餐信息”板块只保留一个资产汇总展示:
```
预计套餐到期时间2027-01-01
```
读取 `estimated_final_expires_at``exact` 时展示日期;`waiting_activation` 时展示“待激活后起算”;`none` 时展示“—”。当前套餐自身的 `expires_at` 仅在套餐明细列表展示,不再作为第二个资产汇总字段。
高亮和临期提醒统一使用 `days_until_final_expiry`,前端不得再根据 `current_package_expires_at` 自行计算另一套剩余天数。

View File

@@ -0,0 +1,195 @@
# 需求05套餐分配生效条件ExpiryBase 覆盖)
> 状态:原需求独立稿;最终口径以标准评审稿为准。
---
## 背景
`Package.ExpiryBase` 已存在(`from_activation` / `from_purchase`),在套餐创建时设定,控制套餐何时开始计时。
需求:分配套餐给代理时,可以对单条分配记录二次覆盖这个值。
---
## 快照链设计
```mermaid
flowchart TD
Package[套餐默认 ExpiryBase] --> Effective{分配记录是否覆盖?}
Allocation[ShopPackageAllocation.expiry_base_override] --> Effective
Effective -->|有覆盖| Override[使用分配覆盖值]
Effective -->|无覆盖| Default[使用套餐默认值]
Override --> Snapshot[订单创建时写入 PackageUsage.expiry_base_snapshot]
Default --> Snapshot
Snapshot --> Activation[套餐激活只读快照]
Legacy[旧数据快照为空] --> Fallback[兜底读取套餐默认值]
Fallback --> Activation
```
遗留数据兜底:`ExpiryBaseSnapshot` 为空(旧数据)时,回退读 `pkg.ExpiryBase`,行为不变。
---
## 数据库变更
### 1. ShopPackageAllocation 新增覆盖字段
```sql
ALTER TABLE tb_shop_package_allocation
ADD COLUMN expiry_base_override VARCHAR(30);
COMMENT ON COLUMN tb_shop_package_allocation.expiry_base_override
IS '生效条件覆盖NULL=使用套餐默认值, from_activation=实名即生效, from_purchase=购买即生效)';
```
### 2. PackageUsage 新增快照字段
```sql
ALTER TABLE tb_package_usage
ADD COLUMN expiry_base_snapshot VARCHAR(30) NOT NULL DEFAULT '',
ADD COLUMN calendar_type_snapshot VARCHAR(20) NOT NULL DEFAULT '',
ADD COLUMN duration_months_snapshot INT NOT NULL DEFAULT 0,
ADD COLUMN duration_days_snapshot INT NOT NULL DEFAULT 0;
COMMENT ON COLUMN tb_package_usage.expiry_base_snapshot
IS '生效条件快照(创建时从分配记录取有效值写入,空字符串=旧数据兜底读套餐原值)';
COMMENT ON COLUMN tb_package_usage.calendar_type_snapshot
IS '周期类型快照(空字符串=旧数据兜底读套餐原值)';
COMMENT ON COLUMN tb_package_usage.duration_months_snapshot
IS '月数快照0=旧数据兜底读套餐原值)';
COMMENT ON COLUMN tb_package_usage.duration_days_snapshot
IS '天数快照0=旧数据兜底读套餐原值)';
```
旧数据不回填,默认空字符串,激活时自动兜底。
---
## Model 变更
### ShopPackageAllocation`internal/model/shop_package_allocation.go`
```go
// ExpiryBaseOverride 生效条件覆盖
// NULL = 使用宿主套餐的 ExpiryBase有值 = 分配时指定,不受套餐后续修改影响
ExpiryBaseOverride *string `gorm:"column:expiry_base_override;type:varchar(30);comment:生效条件覆盖 NULL=使用套餐默认 from_activation=实名即生效 from_purchase=购买即生效" json:"expiry_base_override"`
```
### PackageUsage`internal/model/package.go`
```go
// ExpiryBaseSnapshot 生效条件快照(创建订单时写入,空字符串=旧数据兜底读套餐原值)
ExpiryBaseSnapshot string `gorm:"column:expiry_base_snapshot;type:varchar(30);not null;default:'';comment:生效条件快照 创建时从分配记录取有效值" json:"expiry_base_snapshot"`
// 以下三个字段和 ExpiryBaseSnapshot 一起固化,供激活和排队最终到期时间计算使用。
CalendarTypeSnapshot string `gorm:"column:calendar_type_snapshot;type:varchar(20);not null;default:'';comment:套餐周期类型快照" json:"calendar_type_snapshot"`
DurationMonthsSnapshot int `gorm:"column:duration_months_snapshot;not null;default:0;comment:套餐月数快照" json:"duration_months_snapshot"`
DurationDaysSnapshot int `gorm:"column:duration_days_snapshot;not null;default:0;comment:套餐天数快照" json:"duration_days_snapshot"`
```
---
## 业务逻辑变更
### 1. 订单创建时快照(`internal/service/order/service.go`
订单创建已通过 `GetByShopAndPackage` 查询分配记录(现有逻辑),在此基础上追加:
```go
// 取生效条件有效值:分配覆盖 > 套餐默认
expiryBase := pkg.ExpiryBase
if allocation.ExpiryBaseOverride != nil && *allocation.ExpiryBaseOverride != "" {
expiryBase = *allocation.ExpiryBaseOverride
}
// 创建 PackageUsage 时一次性写入计时快照
usage.ExpiryBaseSnapshot = expiryBase
usage.CalendarTypeSnapshot = pkg.CalendarType
usage.DurationMonthsSnapshot = pkg.DurationMonths
usage.DurationDaysSnapshot = pkg.DurationDays
```
### 2. 激活时读快照(`internal/service/package/activation_service.go`
```go
// 新订单只读购买快照;旧记录兼容回退套餐当前值。
expiryBase := usage.ExpiryBaseSnapshot
if expiryBase == "" {
expiryBase = pkg.ExpiryBase
}
calendarType := usage.CalendarTypeSnapshot
if calendarType == "" {
calendarType = pkg.CalendarType
}
durationMonths := usage.DurationMonthsSnapshot
if durationMonths == 0 {
durationMonths = pkg.DurationMonths
}
durationDays := usage.DurationDaysSnapshot
if durationDays == 0 {
durationDays = pkg.DurationDays
}
```
同文件所有激活和排队接续位置都使用同一快照解析函数,禁止某一处重新读取可修改的 `Package` 字段。
`internal/service/order/service.go` 中后台囤货路径的 `ExpiryBase` 判断也使用已创建的使用记录快照需求06的“预计最后到期时间”同样只读这组快照保证购买后套餐配置变更不会改写历史预测。
---
## API 变更
### 1. 分配套餐接口(新增参数)
```
POST /api/admin/shop-package-allocations
```
请求 DTO 新增字段:
```go
ExpiryBaseOverride *string `json:"expiry_base_override" validate:"omitempty,oneof=from_activation from_purchase" description:"生效条件覆盖(不传=使用套餐默认, from_activation=实名即生效, from_purchase=购买即生效)"`
```
### 2. 修改已分配套餐的生效条件(新接口)
```
PATCH /api/admin/shop-package-allocations/{id}/expiry-base
```
请求 DTO
```go
type UpdateAllocationExpiryBaseRequest struct {
ExpiryBaseOverride *string `json:"expiry_base_override" validate:"omitempty,oneof=from_activation from_purchase" description:"生效条件null=恢复套餐默认, from_activation=实名即生效, from_purchase=购买即生效)"`
}
```
> 注意:修改已有分配记录的覆盖值,**不影响**已创建的 PackageUsage快照已定只影响后续新建的订单。
---
## 前端对接
### 套餐分配弹框
新增"生效条件"选择项:
```
生效条件:
○ 跟随套餐默认(默认选中,不传 expiry_base_override
○ 购买即生效from_purchase
○ 实名即生效from_activation
```
### 已分配套餐列表
列表新增"生效条件"列:
| 值 | 展示 |
|----|------|
| NULL | 套餐默认 |
| `from_activation` | 实名即生效(已覆盖) |
| `from_purchase` | 购买即生效(已覆盖) |
操作列增加"修改生效条件"按钮,调用 `PATCH /api/admin/shop-package-allocations/{id}/expiry-base`

View File

@@ -0,0 +1,206 @@
# 需求08设备批量分配代理或套餐系列Excel导入
> 状态:原需求独立稿;最终口径以标准评审稿为准。
> 模板:前端静态资源,后端仅负责上传校验和异步处理。
---
## 背景
设备号不连续,无法通过号段批量分配。需要通过上传 Excel 表(表头:设备号)完成两项独立操作:
1. 批量分配设备给代理。
2. 批量分配设备给套餐系列。
两项操作可以复用解析、异步任务和失败明细基础设施,但**一个任务只能执行一个业务命令**,不能在一次提交中同时修改 `shop_id``series_id`
`Device` 表已有 `shop_id *uint``series_id *uint` 字段,分配即更新这两个字段。
```mermaid
sequenceDiagram
actor User as 平台员工
participant Web as 设备管理前端
participant API as BatchAllocation API
participant DB as PostgreSQL
participant Worker as Asynq Worker
User->>Web: 选择“分配代理”或“分配套餐系列”并上传 Excel
Web->>API: POST /api/admin/devices/batch-assign-shop 或 batch-assign-series
API->>DB: 创建任务并保存上传文件引用
API-->>Web: task_id
API->>Worker: 入队处理任务
Worker->>DB: 按设备号批量查询并条件更新
Worker->>DB: 写成功数、失败数和失败明细
Web->>API: 轮询任务详情
API-->>Web: 处理结果
```
---
## 数据库变更
新建批量分配任务表(不复用 `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,
source_file_key VARCHAR(500) NOT NULL,
operation_type VARCHAR(30) NOT NULL,
target_id BIGINT NOT NULL,
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;
CREATE INDEX idx_device_batch_allocation_task_operation ON tb_device_batch_allocation_task(operation_type, status, created_at DESC);
```
状态常量1=处理中2=已完成3=失败。
`operation_type``assign_shop`(分配代理)或 `assign_series`(分配套餐系列)。`target_id` 随操作类型分别指向代理店铺或套餐系列;不建立数据库外键。
失败明细存 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"`
SourceFileKey string `gorm:"column:source_file_key;type:varchar(500);not null;comment:待处理Excel对象存储Key" json:"source_file_key"`
OperationType string `gorm:"column:operation_type;type:varchar(30);not null;comment:操作类型 assign_shop=分配代理 assign_series=分配套餐系列" json:"operation_type"`
TargetID uint `gorm:"column:target_id;not null;comment:操作目标ID代理店铺或套餐系列" json:"target_id"`
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 模板
模板由前端项目作为静态资源随版本发布,后端不提供模板下载接口。
- 文件建议命名:`设备批量分配模板-v1.xlsx`
- 表头:`设备号`(一列)
- 前端“下载模板”按钮直接下载静态文件。
- 后端只校验上传文件的表头、行数和内容,不读取或生成前端模板文件。
模板字段发生变化时,前后端必须在同一发布批次升级;旧模板仍可能被用户保存在本地,因此后端错误需要明确指出缺失或未知表头。
### 2. 上传并提交
```
POST /api/admin/devices/batch-assign-shop
Content-Type: multipart/form-data
字段:
shop_id: 123 (必填,分配给哪个代理)
file: <Excel文件>
```
```text
POST /api/admin/devices/batch-assign-series
Content-Type: multipart/form-data
字段:
series_id: 45 (必填,分配给哪个套餐系列)
file: <Excel文件>
```
两个接口内部创建同一类任务表记录,但分别写入 `operation_type=assign_shop|assign_series`。请求中不接受另一个目标字段,避免前端通过隐藏参数把两个业务动作合并。
**处理逻辑Asynq 异步)**
1. API 将上传文件保存到对象存储,把 `source_file_key` 写入任务Worker 从对象存储下载并解析 Excel
2. 对设备号去重后分批查询 `tb_device`(按 `virtual_no``imei`),禁止逐行查询
3. `assign_shop` 仅按状态/归属条件批量更新 `shop_id``assign_series` 仅更新 `series_id`
4. 未找到的:记录失败原因"设备号不存在"。
5. `assign_shop` 遇到已分配给其他代理的设备:记录"该设备已分配,请先回收"`assign_series` 不改变代理归属。
6. Worker 重试时只处理仍未满足目标结果的设备;已经分配到同一目标的记录按幂等成功处理。
临时本地文件路径不能进入 Asynq 载荷。任务结束后源文件按统一对象存储生命周期清理,清理失败不影响任务结果。
限制:单文件最大 10MB、去重前最多 1000 行、Worker 每批最多 200 条、任务最多保留 1000 条失败明细。超过限制在 API 或解析阶段返回明确错误,不创建无限时长任务。
### 3. 查询任务状态
```
GET /api/admin/devices/batch-allocation/{task_id}
```
响应:
```json
{
"task_no": "DBA20240101001",
"operation_type": "assign_shop",
"target_id": 123,
"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 上传区域;"批量分配套餐系列"弹框只显示套餐系列选择器和 Excel 上传区域。
3. 上传后点击"提交",分别调用 `POST /api/admin/devices/batch-assign-shop``POST /api/admin/devices/batch-assign-series`
4. 提示"任务提交成功,正在处理..."
5. 轮询 `GET /api/admin/devices/batch-allocation/{task_id}` 直到 `status != 1`
6. 展示结果成功X条失败X条失败明细在页面展示
### Excel 规范
- 表头:`设备号`
- 每行一个设备号支持虚拟号或IMEI

View File

@@ -0,0 +1,146 @@
# 需求09C端支付方式限制配置化
> 状态:原需求独立稿;最终口径以标准评审稿为准。
> 依赖:[系统配置](../基础规范/系统配置.md)
---
## 背景
代码已经写好但被注释,注释原因是**微信支付参数未申请下来**,临时注释。
注释位置:
- `internal/handler/app/client_wallet.go`(钱包充值入口)
- `internal/service/client_order/service.go`(订单支付入口)
两处均有注释:`// 第三方支付方式与资产类型必须匹配:单卡只允许支付宝,设备只允许微信(已暂时注释)`
---
## 业务规则
| 资产类型 | 允许的第三方支付 | 禁止的第三方支付 |
|---------|----------------|----------------|
| IoT 卡 | 支付宝、钱包 | 微信 |
| 设备 | 微信、钱包 | 支付宝 |
**钱包支付对所有资产类型均允许。**
```mermaid
flowchart TD
Pay[用户选择支付方式] --> Asset{资产类型}
Asset -->|IoT卡| Card[读取卡允许方式]
Asset -->|设备| Device[读取设备允许方式]
Asset -->|未知| RejectUnknown[拒绝:资产类型无效]
Card --> ConfigOK{配置可用?}
Device --> ConfigOK
ConfigOK -->|是| Match{支付方式在允许集合?}
ConfigOK -->|否| SafeDefault[使用代码内安全默认集合]
SafeDefault --> Match
Match -->|是| Continue[继续支付]
Match -->|否| Reject[拒绝并返回对应中文提示]
```
---
## 实现方案
### 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 errors.New(errors.CodeInvalidParam, "资产类型无效")
}
allowedMethods, err := sysconfig.GetStringSlice(ctx, configKey)
if err != nil || len(allowedMethods) == 0 {
// 配置异常时回退到代码内安全默认值,禁止放开全部支付方式。
allowedMethods = defaultAllowedMethods(assetType)
s.logger.Error("读取支付方式配置失败,已使用安全默认值",
zap.String("config_key", configKey),
zap.Error(err))
}
for _, m := range allowedMethods {
if m == paymentMethod {
return nil
}
}
return errors.New(errors.CodeForbidden, "该资产类型不支持此支付方式")
}
```
`client_wallet.go`(充值)和 `client_order/service.go`(订单支付)的对应位置恢复调用。
系统配置更新时必须保证 `wallet` 始终存在于两个允许集合中;前端将钱包选项显示为勾选且不可取消,后端再次校验,避免配置破坏业务规则。
### 3. 错误信息
用户端错误提示(友好文案):
```go
// 根据资产类型给出具体提示
switch assetType {
case model.AssetTypeIotCard:
return errors.New(errors.CodeForbidden, "卡资产仅支持支付宝或余额支付")
case model.AssetTypeDevice:
return errors.New(errors.CodeForbidden, "设备仅支持微信或余额支付")
}
```
---
## 前端对接
### C端支付页面
前端不维护另一份固定规则。资产初始化/详情接口返回后端已经计算好的:
```go
AllowedPaymentMethods []string `json:"allowed_payment_methods" description:"当前资产允许的支付方式"`
```
支付页只展示该集合中的方式,后端支付接口再次执行相同校验。配置变化后重新进入支付页或刷新资产信息即可获得新集合。
```
allowed_payment_methods = ["alipay", "wallet"] → 展示支付宝、余额
allowed_payment_methods = ["wechat", "wallet"] → 展示微信、余额
```
### 后台配置页面
在系统配置(系统设置 > 系统配置 > `c2b.payment` 模块)中,用 CheckboxGroup 展示:
```
卡资产允许支付方式:☑ 支付宝 ☑ 余额 ☐ 微信
设备允许支付方式: ☐ 支付宝 ☑ 余额 ☑ 微信
```
余额选项固定勾选且禁用,不允许管理员取消。
修改后调用 `PUT /api/admin/system/config/c2b.payment.card_allowed_methods`
> 注意:**微信支付参数申请下来后**,直接在系统配置里把对应资产类型勾上微信即可生效,无需改代码。

View File

@@ -0,0 +1,136 @@
# 需求10Gateway 手动卡限速
> 状态:原需求独立稿;最终口径以标准评审稿为准。
> 已确认边界:本期只提供手动设置/取消限速Gateway 最终对象始终是卡,统一使用 `cardNo`。
---
## 一、范围
本期提供一个统一的后台能力:对一张实际联网卡设置或取消限速。
- 单卡资产:使用 `IotCard.ICCID` 作为 `cardNo`
- 设备资产:查询 `tb_device_sim_binding.is_current=true` 的当前绑定卡,再使用该卡 ICCID。
- `speed_kbps > 0` 表示设置限速;`speed_kbps = 0` 表示取消限速。
- 内部业务单位固定为 `kbps`,前端也按 `kbps` 输入和展示。
本期不做:
- 套餐限速字段或套餐编辑页限速配置。
- 套餐激活、到期、续费、停机、切卡后的自动限速或自动取消。
- 设备 IMEI 限速。
- “Gateway 当前实际限速”查询展示。上游未提供查询接口时,页面只能展示最近一次本系统操作记录。
取消限速仍调用同一个 Gateway 方法。`speed_kbps=0` 是本系统语义Gateway 所需的取消报文仅在 Infrastructure 适配器中转换,不散落在 Handler、Service 或前端。
---
## 二、流程
```mermaid
sequenceDiagram
actor User as 平台员工
participant Web as 资产详情页
participant API as SpeedLimit Application
participant DB as PostgreSQL
participant Resolver as 当前卡解析器
participant Gateway as Gateway Adapter
User->>Web: 输入限速值或点击取消
Web->>API: asset identifier + speed_kbps + request_id
API->>Resolver: 解析实际 cardNo
Resolver-->>API: ICCID 或无当前卡错误
API->>DB: 创建或复用限速操作记录
API->>Gateway: SetSpeedLimit(cardNo, speedKbps)
Gateway-->>API: 成功或失败
API->>DB: 写结果、错误摘要和操作审计
API-->>Web: 返回本次操作结果
```
设备不存在当前绑定卡时,接口返回业务错误并记录失败原因;绝不把设备 IMEI、设备 ID 或空字符串发送给 Gateway。
---
## 三、应用端口与数据
```go
// GatewaySpeedLimitPort 设置或取消卡限速。
// speedKbps=0 表示取消,具体上游参数由适配器转换。
type GatewaySpeedLimitPort interface {
SetSpeedLimit(ctx context.Context, cardNo string, speedKbps int) error
}
// SpeedLimitCardResolver 将卡或设备资产解析为实际联网卡 ICCID。
type SpeedLimitCardResolver interface {
ResolveCardNo(ctx context.Context, assetType string, assetID uint) (string, error)
}
```
新建操作记录表,既用于审计,也用于同一 `request_id` 的幂等重试:
```sql
CREATE TABLE tb_gateway_speed_limit_operation (
id BIGSERIAL PRIMARY KEY,
request_id VARCHAR(64) NOT NULL,
asset_type VARCHAR(20) NOT NULL,
asset_id BIGINT NOT NULL,
card_no VARCHAR(100) NOT NULL,
desired_speed_kbps INT NOT NULL,
status INT NOT NULL DEFAULT 1,
error_summary TEXT NOT NULL DEFAULT '',
operator_id BIGINT NOT NULL,
completed_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_gateway_speed_limit_operation_request
ON tb_gateway_speed_limit_operation(request_id);
CREATE INDEX idx_gateway_speed_limit_operation_asset
ON tb_gateway_speed_limit_operation(asset_type, asset_id, created_at DESC);
```
状态:`1=处理中, 2=成功, 3=失败`。同一 `request_id` 重试时任务、操作人、资产和目标限速一致才返回或继续原记录不一致返回冲突。Gateway 网络超时后允许使用相同目标值重试,因为 `SetSpeedLimit` 是“设为目标状态”的幂等操作。
---
## 四、接口与前端
```text
POST /api/admin/assets/{identifier}/speed-limit
```
```json
{
"request_id": "01JZ...",
"speed_kbps": 1024
}
```
- `speed_kbps` 必须为整数且大于等于 0。
- 取消操作提交 `speed_kbps=0`,不新增第二个取消接口。
- 后端根据 `identifier` 解析资产类型和数据范围,前端不得传 `cardNo`、设备 ID 或资产类型作为权威依据。
资产详情页提供两个独立命令:
- 数字输入框加“保存”用于设置限速,单位固定显示 `kbps`
- 取消按钮用于提交 `speed_kbps=0`,并使用确认弹窗避免误操作。
设备详情页显示“当前使用卡 ICCID”。没有当前卡时禁用两个命令并展示后端返回的原因。页面可展示最近一次操作的目标限速、结果、操作人和时间但不能把该记录描述为 Gateway 的实时状态。
---
## 五、审计与人工验收
每次请求记录:资产类型/ID、最终 `cardNo`、目标 `speed_kbps`、设置或取消、操作人、`request_id`、Gateway 结果和脱敏错误摘要。
人工验收覆盖:
1. 单卡按 ICCID 设置限速。
2. 设备按 `is_current=true` 的绑定卡设置限速,请求中不出现设备 IMEI。
3. 设备无当前卡时拒绝调用 Gateway。
4. `speed_kbps=0` 经同一 Gateway 方法取消限速。
5. 同一 `request_id` 重试不重复生成操作记录;不同请求复用 ID 返回冲突。
6. Gateway 失败记录错误并允许相同目标值重试。
上线前置条件Gateway 提供设置和取消所需的最终报文字段约定。业务层只保持 `cardNo + speedKbps` 契约。

View File

@@ -0,0 +1,412 @@
# 需求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 | 代理充值导出 | **全新**(去掉"支付通道"字段) |
| 需求22 | 临期资产列表导出 | **全新**`scene=expiring_asset` |
---
## 现有导出体系说明
系统已有异步导出框架(`internal/exporter/`
- `tb_export_task` 表记录导出任务
- 导出逻辑通过 `DataSource` 接口实现,每个场景一个文件(如 `iot_card_scene.go`
- `registry.go``NewDefaultRegistry()` 统一注册所有场景
- Asynq Worker 根据任务里的 `scene` 字段,从 Registry 取对应 DataSource 执行
- 前端轮询任务状态后下载
```mermaid
sequenceDiagram
actor User as 后台用户
participant Web as 前端
participant API as ExportTask API
participant DB as PostgreSQL
participant Worker as Asynq Worker
participant Storage as 对象存储
User->>Web: 选择导出字段并确认
Web->>API: POST /api/admin/export-tasks
API->>API: 计算数据范围和字段权限交集
API->>DB: 保存查询、范围和字段快照
API-->>Web: task_id
API->>Worker: 入队导出任务
Worker->>DB: 按 scene 分片查询
Worker->>Storage: 上传导出文件
Worker->>DB: 更新进度和 download file_key
Web->>API: 轮询 GET /api/admin/export-tasks/{id}
API-->>Web: 状态、进度、download_url
```
新增导出模块需要:
1.`pkg/constants/constants.go` 新增 `ExportTaskSceneXxx` 场景常量
2.`internal/exporter/` 新建 `xxx_scene.go`,实现 `DataSource` 接口(`Scene()`/`Count()`/`Headers()`/`Fetch()`
3.`registry.go``NewDefaultRegistry()` 中注册,并更新 `IsSupportedScene()`
4. 扩展统一 `CreateExportTaskRequest.Scene` 的允许值,通过 `POST /api/admin/export-tasks` 创建任务
5. 在字段目录中注册稳定字段 key、中文表头、默认选择和所需导出权限
统一创建请求扩展为:
```go
type CreateExportTaskRequest struct {
Scene string `json:"scene" validate:"required" description:"导出场景"`
Format string `json:"format" validate:"required,oneof=xlsx csv" description:"导出格式"`
Query map[string]any `json:"query,omitempty" description:"与列表一致的筛选条件"`
Fields []string `json:"fields" validate:"required,min=1" description:"申请导出的字段key"`
}
```
新增场景:`agent_wallet_transaction``package``refund``exchange``agent_recharge``expiring_asset`。DTO description 和 `pkg/constants/` 必须同步维护。
---
## EXPD-001~003IoT卡导出字段新增
**修改文件**`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:代理资金概况-预充值钱包流水导出
**新增场景**`scene=agent_wallet_transaction`
支持与现有钱包流水列表相同的筛选条件,异步生成 Excel。
导出字段映射:
| 字段 | 数据来源 |
|------|---------|
| 店铺名称 | JOIN `tb_shop` |
| 交易类型 | `transaction_type`(充值/扣款/退款等,中文化) |
| 交易金额 | `amount / 100` 转元 |
| 状态 | `status` 中文化 |
| 资产类型 | `asset_type` 中文化 |
| 资产标识 | `asset_identifier` |
| 交易时间 | `created_at` |
| 交易前金额 | `balance_before / 100` |
| 交易后金额 | `balance_after / 100` |
| 购买套餐名称 | 新数据读取 `tb_order_item.package_name` 不可变快照;历史缺失时才回退 `metadata.package_name`,禁止关联当前套餐名称 |
| 操作人 | JOIN `tb_account``creator` 字段关联 `tb_account.id`,取 `username` |
| 交易 ID | `id` |
| 关联业务订单号 | `reference_id` 对应的单号JOIN 对应表) |
| 交易渠道/支付方式 | `metadata``payment_method` 字段 |
---
## 6.8.3:套餐列表导出
**新增场景**`scene=package`
支持现有套餐列表筛选条件。
导出字段(按实际列举的 25 个稳定字段 Key
```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:退款管理退款列表导出
**新增场景**`scene=refund`
导出字段(去掉“退款到账方式”,审批信息使用动态摘要,不固定具体节点):
```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:"备注"`
ApprovalSource string `xlsx:"审批来源"`
ApprovalStatus string `xlsx:"审批状态"`
ProcessingStatus string `xlsx:"退款处理状态"`
CurrentNodeName string `xlsx:"当前审批节点"`
ApprovalRecords string `xlsx:"审批记录"`
AppliedAt string `xlsx:"退款申请时间"`
CompletedAt string `xlsx:"退款完成时间"`
SubmitterName string `xlsx:"提交人"`
VoucherURLs string `xlsx:"退款凭证"`
}
```
`ApprovalRecords` 格式示例:`业务审核:张三(通过,同意,附件2个);财务审核:李四(待处理)`。导出只记录附件数量,不导出对象存储 URL 或 file_key。`ProcessingStatus` 区分待人工退款、代理钱包回退处理中、已完成和处理失败。数据源按本批业务单的 `approval_instance_id` 批量查询审批任务、审批人和附件计数,禁止逐行查询。历史终态单据没有流程实例时,`ApprovalSource` 输出“历史审批”,审批记录仅使用原业务审批字段和审计日志,不伪造多节点记录。
---
## 6.8.5:换货管理导出
**新增场景**`scene=exchange`
```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:代理充值导出
**新增场景**`scene=agent_recharge`
去掉"支付通道"字段(需求文档中明确去掉),保留其他字段:
```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:"提交人"`
ApprovalSource string `xlsx:"审批来源"`
ApprovalStatus string `xlsx:"审批状态"`
CurrentNodeName string `xlsx:"当前审批节点"`
ApprovalRecords string `xlsx:"审批记录"`
VoucherURLs string `xlsx:"支付凭证"`
Remark string `xlsx:"备注"`
}
```
充值导出的审批记录格式和批量查询规则与退款导出一致。
---
## 前端对接(通用模式)
各导出入口:对应列表页右上角"导出"按钮(与现有导出按钮样式一致)。
调用流程:
1. 点击“导出”后请求当前账号在该场景可导出的字段目录。
2. 弹框只展示后端允许的字段,默认勾选 `default_selected=true` 的字段。
3. 提交 `scene + format + query + fields``POST /api/admin/export-tasks`
4. 返回 `task_id` 后,前端轮询 `GET /api/admin/export-tasks/{id}` 直到终态。
5. `status=3` 时下载 `download_url`;失败时展示任务错误摘要,禁止自动重复创建任务。
(与现有导出体系完全一致,复用现有前端导出 Hook
---
## 导出字段权限
需求提到"不同权限显示的字段不同,角色管理中新增导出字段配置"。
评审结论:本迭代必须实现角色级导出字段配置,不作为后续预留。
该要求涉及真流量、成本价、身份材料等敏感数据,不能以“本期先全量导出”代替。字段权限必须由后端强制执行,前端复选框只负责展示。
### 1. 字段目录
每个场景在代码中注册稳定字段 key
```go
// ExportFieldDefinition 导出字段定义
type ExportFieldDefinition struct {
Key string
Header string
DefaultSelected bool
Required bool
}
```
示例:
```text
scene=package
package_code 套餐编码 默认选择
package_name 套餐名称 默认选择
real_data_mb 真流量额度 敏感字段
virtual_data_mb 虚流量额度 默认选择
cost_price 成本价 敏感字段
```
表头中文可以调整,但 `scene + field_key` 一经发布不得随意改名,否则历史角色配置会失效。
### 2. 角色字段授权表
```sql
CREATE TABLE tb_role_export_field_permission (
id BIGSERIAL PRIMARY KEY,
role_id BIGINT NOT NULL,
scene VARCHAR(50) NOT NULL,
field_key VARCHAR(100) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
creator BIGINT NOT NULL DEFAULT 0
);
CREATE UNIQUE INDEX idx_role_export_field_permission
ON tb_role_export_field_permission(role_id, scene, field_key);
```
账号拥有多个角色时,字段权限按 RBAC 常规规则取并集;超级管理员拥有全部字段。数据行范围仍使用现有数据权限快照,字段权限不能扩大店铺或企业数据范围。
### 3. 权限 API
```text
GET /api/admin/export-fields?scene=package
```
返回当前账号可选择的字段:
```json
{
"scene": "package",
"fields": [
{
"key": "package_code",
"header": "套餐编码",
"default_selected": true,
"required": true
}
]
}
```
角色管理:
```text
GET /api/admin/roles/{role_id}/export-fields
PUT /api/admin/roles/{role_id}/export-fields
```
更新请求按场景提交字段 key 列表,后端校验字段存在并记录审计日志。
### 4. 创建任务时的权限快照
创建任务时计算:
```text
resolved_fields = requested_fields ∩ role_allowed_fields ∩ scene_supported_fields
```
- 必选字段由后端自动补齐。
- 交集为空时拒绝创建任务。
- `resolved_fields` 和对应 `resolved_headers` 写入任务的 `query_json`Worker 不重新根据后来变化的角色权限扩大字段。
- Worker 只按照快照字段输出,未知字段直接失败,不允许静默回退到全量字段。
### 5. 前端角色配置
- 角色编辑页增加“导出字段权限”页签,按场景分组展示复选框。
- 普通列表的导出弹框只显示当前账号被授权的字段。
- 敏感字段可以增加“敏感”标记,但标记不能替代后端权限。
- 用户取消所有可选字段时禁用提交按钮,并提示至少选择一个字段。
---
## 查询与性能约束
- 退款和充值审批记录必须先按本批 `approval_instance_id` 批量查询,再在内存按实例分组,禁止逐行查审批任务。
- 关联业务单号、套餐名称和操作人必须使用批量 JOIN 或批量查询,禁止 N+1。
- DataSource 的 Count 和 Fetch 必须使用同一份权限过滤和查询条件。
- 动态字段不代表动态拼接不受控 SQL字段 key 必须通过服务端白名单映射到固定查询列。
---
## 发布与回滚
本需求随七月迭代停机发布:
1. 维护窗口内先执行角色字段权限表迁移,并初始化现有角色的最小可用字段集。
2. 同一发布窗口部署场景常量、DataSource、管理 API、Worker 和前端字段选择/角色配置页面。
3. 开放访问前验证普通角色、敏感字段角色和超级管理员的字段集合及实际导出文件。
4. 回滚时可关闭新增场景入口,但保留任务、文件和字段权限历史。
禁止在角色权限尚未初始化时默认放开全部敏感字段;无法解析权限时应拒绝导出并记录错误。

View File

@@ -0,0 +1,587 @@
# 需求15/16/18/19/20/21 技术方案
> 状态:原需求来源稿;其中本地审批流、审批页面和操作密码方案已废弃,最终以企微审批方案和标准评审稿为准。
> 评审建议:需求 15/16、需求 18/20/21、需求 19 分三组评审,不在一次会议中混合确认。
---
## 需求15套餐下架后允许续费
### 业务规则
- 下架套餐(`shelf_status=2`)不可被**新购**
- 下架套餐**可以续费**(已在使用该套餐的客户)
- 续费仅支持**客户自己购买**(不允许代理代购下架套餐给新客户)
```mermaid
flowchart TD
Start[请求购买套餐] --> Enabled{套餐是否启用?}
Enabled -->|否| RejectDisabled[拒绝:套餐已禁用]
Enabled -->|是| Shelf{是否已下架?}
Shelf -->|否| Allow[允许继续下单]
Shelf -->|是| Renewal{当前客户资产是否有该套餐历史使用记录?}
Renewal -->|否| RejectNew[拒绝:下架套餐不可新购]
Renewal -->|是| Actor{是否客户本人续费?}
Actor -->|是| Allow
Actor -->|否| RejectProxy[拒绝:下架套餐不可代购]
```
“客户本人”必须由后端登录主体与资产归属关系判断,不能信任前端传入 `is_renewal=true`
### 后端
**当前逻辑**:下架套餐在购买时被拦截。
修改:在订单创建校验中由后端根据资产、历史使用记录和登录主体判定“新购/续费”,不能接收或信任前端 `is_renewal`
```go
// internal/service/order/service.go 或 client_order/service.go
func (s *Service) validatePackageAvailability(
ctx context.Context,
pkg *model.Package,
asset *ResolvedAsset,
actor *PurchaseActor,
) error {
if pkg.Status == constants.StatusDisabled { // 0=禁用,定义在 pkg/constants/constants.go
return errors.New(errors.CodeForbidden, "套餐已禁用")
}
if pkg.ShelfStatus == constants.ShelfStatusOff { // 2=下架
if !s.hasRenewalEligibility(ctx, asset, actor, pkg.ID) {
return errors.New(errors.CodeForbidden, "套餐已下架,仅支持资产所有人续费")
}
}
return nil
}
```
**续费资格判断**:查当前资产是否有该套餐的有效历史使用记录,并确认当前登录主体就是资产所有人。已失效、已退款或仅创建未生效的记录不能作为续费资格:
```go
func (s *Service) hasRenewalEligibility(ctx context.Context, asset *ResolvedAsset, actor *PurchaseActor, packageID uint) bool {
return actor.OwnsAsset(asset) &&
s.packageUsageStore.HasValidHistory(ctx, asset.Type, asset.ID, packageID)
}
```
### 前端
C端资产详情/当前套餐卡片:在当前套餐旁提供“续费”按钮。点击后复用现有购买套餐流程,并携带资产和当前套餐上下文;后端重新判定资格,前端传入的上下文不构成授权依据。下架套餐不出现在普通“新购套餐”列表,也不额外建设第二个续费套餐列表。
后台代购时:下架套餐的"代购"按钮禁用tooltip 提示"套餐已下架,不可代购"。
---
## 需求16代理分销码与佣金提现已移出7月迭代
> 状态:已移出本期
> 决策日期2026-07-14
> 后续处理:作为独立需求重新评审和排期,不纳入本次开发、迁移和发布
本次范围调整包含整个需求 16
- 代理/员工分销码和推广二维码。
- H5 代理申请、进度查询和退回重提。
- 代理申请接入通用审批及审批后自动开店。
- 店铺发展人关系和代理申请来源字段。
- 佣金提现合同、营业执照、法人身份证、门头照、发票及主体一致性校验。
因此 7 月迭代不创建 `tb_distribution_code``tb_agent_application`,不修改 `tb_shop` 发展人字段和提现材料字段,不注册代理申请相关 API不发布 `agent_application_approval`,也不建设对应前端页面。
通用审批流本期只接入退款和平台员工线下充值。需求 16 后续重新立项时,可以复用本期审批流、站内消息、对象存储和 Outbox 能力但必须重新评审其数据模型、H5 安全、开店幂等、提现材料及工时。
---
## 需求18多人审批APR-001~009
### 依赖
历史设计基于 [已废弃的本地审批流](../历史方案/本地通用审批流-已废弃方案.md) 和 [站内消息初版](../历史方案/站内消息-初版.md)。
APR-009企微审批对接= Phase 2。
```mermaid
sequenceDiagram
actor Applicant as 提交人
participant Biz as 退款/充值业务
participant Approval as 审批流
participant Notice as 站内消息
actor Approver1 as 当前节点审批人
actor Approver2 as 下一节点审批人
Applicant->>Biz: 提交申请
Biz->>Approval: 同事务创建流程实例和首任务
Approval->>Notice: TaskCreated
Notice-->>Approver1: 待审批提醒
Approver1->>Approval: 审批通过
Approval->>Notice: 下一节点 TaskCreated
Notice-->>Approver2: 待审批提醒
Approver2->>Approval: 通过/驳回/退回
Approval->>Notice: 流程结果事件
Notice-->>Applicant: 结果和原因
```
### 实现要点
| 编号 | 需求 | 实现 |
|------|------|------|
| APR-001~003 | 充值/退款多级审核 | 见需求20/21需求16已移出本期 |
| APR-004 | 审核环节:部门领导→财务 | 作为默认流程定义的两个串行节点;系统无部门模型,节点审批人由角色或指定账号配置,禁止按步骤编号或角色名称写死 |
| APR-005 | 待审核有消息提示 | 站内消息 `NotifyTypeApprovalPending` |
| APR-006 | 上一级完成后才提示下一级 | `ProcessInstance` 完成当前任务并激活下一节点,写入 `TaskCreated` Outbox 事件 |
| APR-007 | 通过后通知申请人 | `ProcessApproved` 事件处理器 |
| APR-008 | 驳回/退回后通知申请人含原因 | `ProcessRejected` / `ProcessReturned` 事件处理器 |
| APR-009 | 对接企微 | **Phase 2** |
### 默认流程与可配置边界
- 本迭代可以预置“业务审核 → 财务审核”两个节点,但这只是初始流程定义,不是引擎固定规则。
- 每个节点可配置 `role``user` 审批人来源,以及 `any``all` 完成方式。
- 节点可配置是否要求操作密码;仅触发该节点完成的审批人输入,不能按“财务节点”等名称写死。
- 角色审批在节点激活时解析当前启用账号,并将候选审批人快照到任务审批人表。
- 流程定义发布后不可修改;调整节点或审批人配置时创建新版本。
- 已发起实例始终使用发起时保存的流程定义快照。
### 业务表与审批流的关联
充值单和退款单接入审批流,各自新增 `approval_instance_id` 字段。具体增量 DDL 与业务处理状态字段分别在需求 20、需求 21 中定义,迁移文件只能创建一次。
本段是历史设计。旧接入协议见 [本地通用审批流 - 业务接入协议](../历史方案/本地通用审批流-已废弃方案.md#十一业务接入协议)。
退款和充值详情统一返回 `approval_source=none|workflow|legacy`。代理在线充值等无需审批的记录返回 `none`;发布前已经结束且没有流程实例的历史审批记录返回 `legacy` 并只读展示;发布时仍待审批的退款和员工线下充值必须在维护窗口回填流程实例。
### 前端技术方案
- 历史前端交互见 [前端共性方案历史稿](../历史方案/前端共性方案-历史稿.md#六统一审批交互);当前已改为企微审批只读页面。
- 退款、充值列表只展示审批摘要;审批详情首屏展示发起时固化的业务关键字段和业务资料,随后展示完整审批人、意见和历史实例。
- 每次通过、驳回、退回分别保存审批意见和可选附件;驳回、退回意见必填,附件不与退款/充值业务凭证混用。
- 时间线必须能查看此前审批人的动作、时间、意见和审批附件;业务资料与审批附件分区展示。
- 所有节点名称和审批人来自接口,不保留“部门领导审批人”“财务审批人”固定字段。
- APR-009 不在 Phase 1 前端中展示不可用入口;企业微信接入完成后再增加来源标识和跳转。
---
## 需求19批量订购套餐BPO-001~008
### 支付方式粒度
评审结论:支付方式按**整批统一**设计:
- 页面选择 `offline``agent_wallet`Excel 不再重复填写支付方式。
- 混合支付拆成两个批次,避免一份凭证对应多种支付语义。
- Excel 不包含支付方式列,后端拒绝同一批次混合支付。
### 业务流程
```mermaid
flowchart TD
Start[员工选择代理和支付方式] --> Upload[上传 Excel]
Upload --> Parse[解析并持久化逐行明细]
Parse --> Validate[校验资产、套餐、归属和重复行]
Validate --> Item{处理下一条有效明细}
Item -->|代理钱包| Lock[锁定钱包并校验可用余额]
Item -->|线下支付| Voucher[校验整批支付凭证]
Lock --> Order[单条事务创建订单并扣款]
Voucher --> OrderOffline[单条事务创建已支付订单]
Order --> Result[记录订单 ID 和成功状态]
OrderOffline --> Result
Result --> More{还有待处理明细?}
More -->|是| Item
More -->|否| Summary[汇总任务结果]
Validate -->|校验失败| Failed[记录结构化失败原因]
Failed --> More
```
任务允许部分成功。每一行是独立、可重试、可审计的业务单元,不能只保存一段失败 JSON。
### 数据库变更
```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,
source_file_key VARCHAR(500) NOT NULL,
shop_id BIGINT NOT NULL,
operator_id BIGINT NOT NULL,
payment_method VARCHAR(20) NOT NULL,
voucher_keys JSONB NOT NULL DEFAULT '[]',
total_amount BIGINT NOT NULL DEFAULT 0,
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,
error_message TEXT NOT NULL DEFAULT '',
started_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_bulk_purchase_task_no
ON tb_bulk_purchase_task(task_no)
WHERE deleted_at IS NULL;
CREATE TABLE tb_bulk_purchase_item (
id BIGSERIAL PRIMARY KEY,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
task_id BIGINT NOT NULL,
row_no INT NOT NULL,
asset_type VARCHAR(20) NOT NULL,
asset_identifier VARCHAR(100) NOT NULL,
package_code VARCHAR(50) NOT NULL,
package_name_snapshot VARCHAR(200) NOT NULL DEFAULT '',
package_id BIGINT,
amount BIGINT NOT NULL DEFAULT 0,
status INT NOT NULL DEFAULT 1,
order_id BIGINT,
failure_code VARCHAR(50) NOT NULL DEFAULT '',
failure_reason VARCHAR(500) NOT NULL DEFAULT '',
idempotency_key VARCHAR(100) NOT NULL,
processed_at TIMESTAMPTZ
);
CREATE UNIQUE INDEX idx_bulk_purchase_item_task_row
ON tb_bulk_purchase_item(task_id, row_no);
CREATE UNIQUE INDEX idx_bulk_purchase_item_idempotency
ON tb_bulk_purchase_item(idempotency_key);
CREATE INDEX idx_bulk_purchase_item_task_status
ON tb_bulk_purchase_item(task_id, status);
```
状态建议:
- 任务:`1=待处理, 2=处理中, 3=已完成, 4=部分成功, 5=失败`
- 明细:`1=待处理, 2=处理中, 3=成功, 4=失败`
### 处理与幂等
1. API 校验文件、代理、支付方式和凭证,将 Excel 保存到对象存储并创建任务,返回 `task_id`
2. Worker 根据 `source_file_key` 下载并解析 Excel将每一行先写入明细表再开始业务处理。
3. 同一任务按行顺序处理,避免对同一代理钱包制造不必要的乐观锁冲突。
4. 每行使用独立事务。代理钱包支付时锁定钱包记录,校验 `balance - frozen_balance + credit_limit` 后,在同一事务扣款、创建订单、资金流水并更新明细。
5. `idempotency_key` 使用 `bulk_purchase:{task_id}:{row_no}`。Worker 重试时,已存在成功订单的明细直接跳过。
6. 单行失败不回滚已成功行;失败原因写结构化错误码和用户可见中文原因。
7. 任务汇总从明细表计算,不信任内存计数。
Asynq 载荷只传任务 ID不传文件字节或临时路径。源文件保留周期按对象存储统一策略处理确保 Worker 重试期间仍可读取。
建议单文件上限 1000 行;超过上限在 API 层拒绝,避免长事务和过长处理时间。
### API 设计
**前端静态 Excel 模板**
模板由前端项目随版本发布,后端不提供下载接口。按已确认的“整批统一支付方式”,模板字段为:
```text
资产类型 | 资产标识 | 套餐编码 | 套餐名称
```
`套餐名称`用于人工核对,实际匹配以稳定的 `套餐编码` 为准。后端必须校验表头并对未知列给出明确错误,不能依赖模板一定来自当前前端版本。
**上传并提交**
```text
POST /api/admin/bulk-purchases
Content-Type: multipart/form-data
shop_id: 123
payment_method: offline | agent_wallet
voucher_keys: ["key1","key2"]
file: <Excel文件>
```
`offline``voucher_keys` 必填,`agent_wallet` 时忽略该字段。
**查询任务状态**
```text
GET /api/admin/bulk-purchases/{task_id}
```
**分页查询任务明细**
```text
GET /api/admin/bulk-purchases/{task_id}/items?status=4&page=1&page_size=50
```
### 前端技术方案
- 页面分为“参数确认 → 文件上传 → 处理中 → 结果”四个稳定步骤,刷新页面后可根据任务 ID 恢复进度。
- 提交前展示代理、支付方式、凭证数量和文件名的二次确认;钱包支付额外展示当前可用余额,但最终以 Worker 扣款时校验为准。
- 任务处理中展示总数、已处理数、成功数和失败数,轮询规则复用统一异步任务方案。
- 结果页默认显示失败明细,可切换全部/成功/失败,并可按资产标识搜索。
- 部分成功使用明确状态,不弹“全部成功”提示;再次上传失败行会创建新任务,不修改旧任务历史。
- 操作员、任务号和处理时间在页面固定展示,便于财务和运营追溯。
---
## 需求20退款审批
### 依赖
本段历史设计基于 [已废弃的本地审批流](../历史方案/本地通用审批流-已废弃方案.md)。
### 退款单现有状态(不变)
```
1=待审批 2=已通过 3=已拒绝 4=已退回
```
退款单状态值的原有语义不改;审批进度由 `tb_approval_process_instance` 管理,两者通过 `approval_instance_id` 关联。审批通过后,代理钱包退款自动回退到原扣款代理主钱包;微信、支付宝和线下退款由财务人工完成。业务表增加独立处理状态:
```text
processing_status0=待处理 1=处理中 2=已完成 3=处理失败
```
`status=2` 表示审批结论已通过,`processing_status` 表示实际退款动作是否完成。接口和前端必须同时展示两者。
### 数据库变更
```sql
ALTER TABLE tb_refund_request
ADD COLUMN approval_instance_id BIGINT,
ADD COLUMN processing_status INT NOT NULL DEFAULT 0,
ADD COLUMN processing_error TEXT NOT NULL DEFAULT '',
ADD COLUMN processing_started_at TIMESTAMPTZ,
ADD COLUMN processing_completed_at TIMESTAMPTZ,
ADD COLUMN manual_refund_operator_id BIGINT,
ADD COLUMN manual_refund_completed_at TIMESTAMPTZ,
ADD COLUMN manual_refund_remark TEXT NOT NULL DEFAULT '',
ADD COLUMN manual_refund_voucher_key JSONB NOT NULL DEFAULT '[]';
CREATE INDEX idx_refund_request_approval_instance
ON tb_refund_request(approval_instance_id)
WHERE approval_instance_id IS NOT NULL;
```
`processing_error` 只保存可运维排查的摘要。`manual_refund_*` 只在人工退款确认时写入,退款申请时提交的 `refund_voucher_key` 仍是申请业务资料,不能混用为财务完成凭证。
现有退款审批允许确认 `approved_refund_amount`,切换到通用审批后必须保留:配置为最终决策的节点在完成时返回金额动作字段,审批人可确认实际退款金额;省略时使用申请金额。退款动作适配器在审批事务内校验金额大于 0且不超过申请退款金额和订单实收金额并写入退款单和审批操作日志。会签需要指定金额决策人时流程定义增加其专属最终节点禁止第一位会签人预先锁定金额。
### 流程
```mermaid
sequenceDiagram
actor Applicant as 提交人
actor Approver as 审批人
participant Refund as Refund Application
participant Approval as Approval Application
participant DB as PostgreSQL
participant Worker as AgentWalletRefundHandler
actor Finance as 财务人员
Applicant->>Refund: POST /api/admin/refunds
Refund->>DB: 同事务创建退款单(status=1)
Refund->>Approval: StartProcess(refund, refund_id)
Approval->>DB: 创建实例、首任务、审批人、Outbox
Refund->>DB: 回写 approval_instance_id
Approver->>Approval: 按 task_id 审批,决策节点可提交实际退款金额
Approval->>DB: 提交 ProcessApproved/Rejected/Returned
alt 代理钱包支付且审批通过
DB-->>Worker: Outbox + Asynq 至少一次投递
Worker->>DB: claim processing_status=1status=2
Worker->>DB: 幂等回退原扣款代理主钱包并写资金流水
Worker->>DB: processing_status=2
else 非代理钱包支付且审批通过
Refund->>DB: status=2, processing_status=0待人工退款
Finance->>Refund: POST manual-complete
Refund->>DB: 条件更新处理状态并记录确认信息
else 审批拒绝
Worker->>DB: status 从 1 更新为 3
else 退回修改
Worker->>DB: status 从 1 更新为 4
end
```
`AgentWalletRefundHandler` 仅处理代理钱包订单,使用 `refund:{refund_id}` 作为业务幂等键,并读取审批事务已经持久化的 `approved_refund_amount`。它必须按原扣款资金流水定位原代理主钱包,余额、版本、钱包退款流水和处理状态在同一事务更新。处理失败时单独更新 `processing_status=3` 和错误摘要后返回可重试错误;不得回滚已经完成的审批实例,也不得重复回退。
处理器通过条件更新领取任务:`processing_status IN (0,3)`,或状态为处理中但 `processing_started_at` 已超过约定租约。重复消费者看到未过期的处理中状态时不重复执行;进程在副作用完成后崩溃时,下一次重试依靠业务幂等键恢复并补写成功状态。
本期不调用第三方退款 API也不增加商户退款号、渠道退款号或渠道结果字段。个人/客户资产钱包退款不属于本期自动回退范围,现有对应分支必须在实施时隔离或拒绝进入本流程。
人工退款确认接口:
```text
POST /api/admin/refunds/{id}/manual-complete
```
请求包含 `request_id`、可选 `remark` 和最多 5 个完成凭证。后端仅允许具备财务确认权限的账号对 `status=2 AND processing_status IN (0,3)` 的非代理钱包退款操作;实际金额沿用审批金额,不允许在确认时再次改价。确认记录操作人、时间、备注和凭证后将处理状态置为已完成。
### 退回后重新提交
```
POST /api/admin/refunds/{id}/resubmit
→ 校验 status=4
→ 请求体可修改 actual_received_amount、requested_refund_amount、refund_voucher_key、refund_reason
→ 在同一事务新建 ProcessInstance
→ 更新 approval_instance_idstatus 回到 1待审批
→ processing_status 重置为 0清空本次处理错误和人工确认记录
→ 旧审批实例保留为历史记录
```
复用当前真实路由 `POST /api/admin/refunds/{id}/resubmit`,不新增单独 `PUT`。重提命令沿用现有 `ResubmitRefundRequest` 字段范围;禁止修改订单 ID、资产快照、提交人或已形成的历史审批记录。
### API 响应与前端
退款列表和详情增加:
```json
{
"approval_instance_id": 1001,
"approval_source": "workflow",
"approval_status": 2,
"approval_status_name": "已通过",
"current_node_name": "",
"processing_status": 0,
"processing_status_name": "待人工退款",
"processing_error": ""
}
```
前端展示规则:
| 审批状态 | 处理状态 | 展示 |
|----------|----------|------|
| 审批中 | 待处理 | 待审批 + 当前节点 |
| 已通过 + 代理钱包 | 处理中 | 审批已通过,代理钱包回退处理中 |
| 已通过 + 非代理钱包 | 待处理 | 审批已通过,待人工退款;财务可确认完成 |
| 已通过 | 已完成 | 退款已完成 |
| 已通过 + 代理钱包 | 处理失败 | 系统重试中;管理员可查看错误摘要 |
| 已拒绝 | 待处理 | 已拒绝 + 原因 |
| 已退回 | 待处理 | 已退回,可编辑并重新提交 |
列表页不直接放固定审批按钮。点击进入详情后,根据审批接口返回的 `available_actions` 渲染通过、驳回和退回操作。
决策节点根据 `action_form` 展示“实际退款金额”输入,默认等于申请金额。审批通过后的非代理钱包退款展示“确认人工退款”入口,仅具备财务确认权限时显示;完成凭证与审批附件分区展示。前端只负责元/分转换和基础格式校验,金额上限以后端在审批事务中的校验为准。
停机发布后,现有按退款业务单 ID 直接通过、驳回或退回的路由不再注册;所有退款审批动作统一操作 `task_id`,避免绕过审批人快照、并发控制和操作日志。
维护窗口内需要为 `status=1 AND approval_instance_id IS NULL` 的存量退款单执行幂等回填,从 `refund_approval` 首节点创建流程实例和任务;历史终态退款不伪造流程实例。
---
## 需求21充值审核流程
### 充值单现有状态
```
tb_agent_recharge_record1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款
```
代码中已经存在 `6=已驳回`,不能改写其含义。员工线下充值走审批流时,在现有状态基础上追加 `7=已退回`
```sql
-- 现有1=待支付 2=已支付 3=已完成 4=已关闭 5=已退款 6=已驳回
-- 新增7=已退回
ALTER TABLE tb_agent_recharge_record
ADD COLUMN approval_instance_id BIGINT,
ADD COLUMN processing_status INT NOT NULL DEFAULT 0,
ADD COLUMN processing_error TEXT NOT NULL DEFAULT '',
ADD COLUMN processing_started_at TIMESTAMPTZ,
ADD COLUMN processing_completed_at TIMESTAMPTZ,
ADD COLUMN return_reason VARCHAR(500) NOT NULL DEFAULT '';
CREATE INDEX idx_agent_recharge_approval_instance
ON tb_agent_recharge_record(approval_instance_id)
WHERE approval_instance_id IS NOT NULL;
```
充值业务的状态语义:
- `1=待支付`:创建未支付(线下充值等待审批时也停在这里,由 `approval_instance_id` 查询审批状态)
- `2=已支付`:在线支付已确认,或线下充值审批通过后正在执行钱包入账
- `3=已完成`:充值到账
- `4=已关闭`:取消/超时
- `5=已退款`:退款
- `6=已驳回`:审批流程拒绝
- `7=已退回`:审批人退回给提交人修改
`rejection_reason` 只保存驳回原因,新增 `return_reason` 保存退回修改原因,禁止复用一个字段导致前端无法区分终止和可重提。
充值处理状态保持:`0=未触发, 1=处理中, 2=处理成功, 3=处理失败`。退款的 `0` 已收口为“待处理”,两者不要共用中文状态名称常量。
### 流程
**代理自行充值(不走审批)**
```mermaid
flowchart LR
A[代理提交充值申请] --> B[系统生成收款码]
B --> C[代理扫码支付]
C --> D[支付回调幂等入账]
```
**员工线下代充值(走审批)**
现有 `offline-pay` 的全局操作密码校验必须保留。通用审批详情在最后一个审批节点返回 `operation_password` 动作字段;审批动作适配器调用现有 `OperationPasswordService` 校验通过后才允许流程完成。密码只在内存中参与本次校验,不落库、不写审批日志、不进入 Outbox。
```mermaid
sequenceDiagram
actor Staff as 平台员工
actor Approver as 审批人
participant Recharge as Recharge Application
participant Approval as Approval Application
participant DB as PostgreSQL
participant Worker as RechargeApprovalHandler
Staff->>Recharge: POST /api/admin/agent-recharges(payment_method=offline)
Recharge->>DB: 同事务创建充值单(status=1)
Recharge->>Approval: StartProcess(recharge, recharge_id)
Approval->>DB: 创建实例、首任务、审批人、Outbox
Recharge->>DB: 回写 approval_instance_id
Approver->>Approval: 按 task_id 审批
DB-->>Worker: 投递流程结果事件
alt 审批通过
Worker->>DB: status 从 1 更新为 2processing_status=1
Worker->>DB: 幂等增加钱包余额并写流水
Worker->>DB: status 从 2 更新为 3processing_status=2
else 审批拒绝
Worker->>DB: status 从 1 更新为 6写 rejection_reason
else 退回修改
Worker->>DB: status 从 1 更新为 7写 return_reason
end
```
充值接口独立返回审批状态和业务处理状态。`RechargeApprovalHandler` 使用 `recharge:{recharge_no}` 作为幂等键;钱包余额、版本、充值单和交易流水必须在同一事务更新。
充值处理同样使用 `processing_started_at` 作为可恢复租约。重复事件不能再次增加余额;若钱包流水已经存在而充值单状态未完成,重试只补齐充值单状态。
### 退回后重新提交
```
POST /api/admin/agent-recharges/{id}/resubmit
→ 校验 status=7
→ 请求体可修改 amount、payment_voucher_key、remark
→ 在同一事务新建 ProcessInstance
→ 更新 approval_instance_idstatus 回到 1待支付/待审批)
→ processing_status 重置为 0清空处理错误和 return_reason
→ 旧审批实例保留为历史记录
```
新增 `resubmit` 路由时沿用现有 `/api/admin/agent-recharges` 资源名,不另建 `/agent-recharge-records` 路径。店铺、支付方式和提交人不可修改;编辑与新流程创建必须同事务完成。
### 停机切换
现有 `POST /api/admin/agent-recharges/{id}/offline-pay``POST /api/admin/agent-recharges/{id}/reject` 都会绕过通用审批任务,本次不保留兼容窗口:
1. 发布前进入维护模式,停止创建和处理线下充值。
2. 执行审批关联字段迁移,同时发布新 API、Worker 和前端。
3. 初始化并启用 `recharge → recharge_approval` 绑定。
4. 为存量“平台员工创建 + 线下支付 + 尚未入账”的充值记录幂等创建流程实例,代理在线充值不回填审批。
5. 新前端创建线下充值后直接进入审批详情,不再展示“确认线下充值”按钮。
6. 新版本不注册 `offline-pay` 和业务单级 `reject` 路由;线下充值只能由 `ProcessApproved` 事件触发幂等入账,驳回统一由任务级审批接口产生 `ProcessRejected`
7. 验证审批通过、驳回、退回、处理失败重试和钱包流水后再解除维护模式。
### 前端技术方案
- 代理自行充值保留现有收款码和支付状态页面,不显示审批信息。
- 平台员工选择 `offline` 时,提交成功进入充值详情并展示审批时间线。
- `status=6` 展示“已驳回”,`status=7` 展示“已退回”;两者按钮不同,只有已退回可编辑和重新提交。
- 审批通过但 `processing_status=1` 时显示“充值处理中”;状态为 3 且处理成功后才显示最新钱包余额。
- `processing_status=3` 时不允许前端再次点击入账,只展示系统重试状态和管理员排查入口。

View File

@@ -0,0 +1,349 @@
# 需求17信用额度
> 状态:原需求独立稿;最终口径以标准评审稿为准。
> DDD 范围:仅迁移代理主钱包的复杂写用例,资金列表和统计继续走 Query
> 关联需求BPO-009~012、需求19批量订购
---
## 一、评审结论
代理信用额度在现有 `AgentWallet` 基础上落地,属于典型资金聚合:余额、冻结金额、信用额度、版本和流水必须在同一事务内保持不变量。
平台员工信用额度不实施,原因如下:
- 平台员工是操作主体,不是订单结算主体;实际付款方只能是代理钱包或线下支付主体。
- 客户角色可以保存“新建店铺默认额度”模板,但角色本身不持有余额和债务;实际额度仍写入代理主钱包。
- 系统不存在员工钱包、员工充值、员工还款和离职债务交接链路,负余额无法对账和追责。
- 批量订购已经明确由代理钱包扣款或使用线下支付,不存在必须从员工个人额度扣款的业务场景。
- 引入员工信用会与代理钱包形成两套资金来源,增加订单归属、退款去向和审计解释成本,但不产生实际业务价值。
因此信用额度只属于代理主钱包,平台员工仅通过权限决定是否可以查看或调整代理额度。
---
## 二、已确认范围:代理主钱包授信
### 2.1 业务规则
- 信用额度只作用于代理主钱包,不作用于佣金钱包和资产钱包。
- 可用金额:`balance - frozen_balance + effective_credit_limit`
- `credit_enabled=false` 时,`effective_credit_limit=0`
- 余额可以为负,最低不能小于 `-(credit_limit - frozen_balance)`
- 扣款、冻结、解冻、充值和调额都必须维护同一钱包不变量。
- 存在欠款或冻结金额导致可用金额不足时,禁止降低额度或关闭信用。
- 金额统一使用分,禁止浮点数入库。
```mermaid
flowchart TD
Debit[请求扣款] --> Lock[按钱包ID和version加载]
Lock --> Calc[计算 balance - frozen + effective_credit]
Calc --> Enough{可用金额足够?}
Enough -->|否| Reject[拒绝:可用余额不足]
Enough -->|是| Update[条件更新余额和version]
Update --> Tx[同事务写资金流水]
Tx --> Success[返回扣款后余额]
```
### 2.2 角色默认与店铺实际额度
- 客户角色可以配置 `default_credit_enabled``default_credit_limit`,作为以后新建店铺的默认值。
- 新建店铺时读取请求中的 `default_role_id`,将该角色当时的默认配置复制到新建主钱包。
- 修改角色默认值不更新任何已有店铺,也不批量扫描钱包。
- 已有店铺通过独立资金接口直接修改实际额度;店铺后续角色变化不影响钱包。
- 关闭开关时额度必须为 0。
- 打开开关时额度必须大于 0。
- 修改额度前必须校验修改后的可用金额不为负,而不是只判断 `balance >= 0`
---
## 三、数据库变更
角色字段只是新建店铺模板,不参与运行时扣款。创建店铺后,最终权威数据只读取代理主钱包;修改角色默认值不会产生角色与钱包双写同步。
```sql
ALTER TABLE tb_agent_wallet
ADD COLUMN credit_enabled BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN credit_limit BIGINT NOT NULL DEFAULT 0;
ALTER TABLE tb_role
ADD COLUMN default_credit_enabled BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN default_credit_limit BIGINT NOT NULL DEFAULT 0;
COMMENT ON COLUMN tb_agent_wallet.credit_enabled IS '是否启用信用额度,仅主钱包有效';
COMMENT ON COLUMN tb_agent_wallet.credit_limit IS '信用额度上限(分),仅主钱包有效';
COMMENT ON COLUMN tb_role.default_credit_enabled IS '新建代理店铺是否默认启用信用额度,仅客户角色有效';
COMMENT ON COLUMN tb_role.default_credit_limit IS '新建代理店铺默认信用额度(分),仅客户角色有效';
ALTER TABLE tb_agent_wallet
DROP CONSTRAINT IF EXISTS chk_agent_wallet_frozen_balance;
ALTER TABLE tb_agent_wallet
ADD CONSTRAINT chk_agent_wallet_frozen_nonnegative
CHECK (frozen_balance >= 0),
ADD CONSTRAINT chk_agent_wallet_credit_nonnegative
CHECK (credit_limit >= 0),
ADD CONSTRAINT chk_agent_wallet_available_nonnegative
CHECK (
balance - frozen_balance +
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= 0
);
```
必须先检查生产库真实约束名;`DROP CONSTRAINT IF EXISTS` 不能替代迁移前核对。历史钱包默认关闭信用,行为不变。
---
## 四、领域模型
```go
// AgentWallet 代理主钱包聚合根
type AgentWallet struct {
ID uint
WalletType string
Balance int64
FrozenBalance int64
CreditEnabled bool
CreditLimit int64
Version int64
}
// AvailableBalance 返回当前可用金额。
func (w *AgentWallet) AvailableBalance() int64 {
credit := int64(0)
if w.CreditEnabled {
credit = w.CreditLimit
}
return w.Balance - w.FrozenBalance + credit
}
// Debit 执行钱包扣款并维护信用边界。
func (w *AgentWallet) Debit(amount int64) error {
if amount <= 0 {
return ErrInvalidAmount
}
if w.AvailableBalance() < amount {
return ErrInsufficientAvailableBalance
}
w.Balance -= amount
return nil
}
// ChangeCredit 修改信用配置。
func (w *AgentWallet) ChangeCredit(enabled bool, limit int64) error {
if limit < 0 || (!enabled && limit != 0) || (enabled && limit == 0) {
return ErrInvalidCreditConfig
}
nextCredit := int64(0)
if enabled {
nextCredit = limit
}
if w.Balance-w.FrozenBalance+nextCredit < 0 {
return ErrCreditLimitBelowDebt
}
w.CreditEnabled = enabled
w.CreditLimit = limit
return nil
}
```
领域层只维护资金不变量,不查询角色、店铺名称或页面权限。角色能否授信由 Application 在调用聚合前校验。
---
## 五、应用用例与持久化
### 5.1 修改代理信用额度
```text
Handler
→ ChangeShopCreditUseCase
→ 校验操作人和代理数据权限
→ 加载代理主钱包
→ 调用 wallet.ChangeCredit()
→ 按 version 条件更新钱包
→ 写操作日志和信用变更流水
```
条件更新示例:
```sql
UPDATE tb_agent_wallet
SET credit_enabled = ?,
credit_limit = ?,
version = version + 1,
updated_at = NOW()
WHERE id = ?
AND wallet_type = 'main'
AND version = ?
AND balance - frozen_balance +
CASE WHEN ? THEN ? ELSE 0 END >= 0;
```
受影响行数为 0 时,重新读取钱包以区分并发冲突和额度低于当前欠款。
### 5.2 修改角色默认信用额度
```text
Handler
→ UpdateRoleDefaultCreditUseCase
→ 校验角色为客户角色
→ 校验开关和额度组合
→ 只更新 tb_role 默认模板
→ 写角色配置审计
→ 不查询、不更新已有店铺钱包
```
### 5.3 钱包扣款
所有现有主钱包扣款语句必须从:
```text
balance - frozen_balance >= amount
```
统一改为:
```text
balance - frozen_balance +
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END >= amount
```
扣款、版本递增和钱包流水必须在同一事务。禁止只修改 `GetAvailableBalance()` 而遗漏 Store 中的 SQL 条件,否则页面显示可用但实际仍无法扣款。
### 5.4 受影响用例
- 后台订单和批量订购的代理钱包支付。
- C端/代理端使用代理主钱包的订单支付。
- 钱包冻结与解冻。
- 退款回充和员工线下代充值。
- 资金概况、钱包详情和导出 Query。
实施时只迁移这些被信用额度触碰的完整资金用例,不主动改造佣金钱包和资产钱包。
---
## 六、查询方案
资金概况、钱包详情、列表和导出使用 Query 直接读取:
```sql
balance - frozen_balance +
CASE WHEN credit_enabled THEN credit_limit ELSE 0 END AS available_balance
```
响应统一增加:
```go
CreditEnabled bool `json:"credit_enabled" description:"是否启用信用额度"`
CreditLimit int64 `json:"credit_limit" description:"信用额度(分)"`
AvailableBalance int64 `json:"available_balance" description:"可用金额(分)"`
IsInDebt bool `json:"is_in_debt" description:"余额是否为负"`
DebtAmount int64 `json:"debt_amount" description:"欠款金额(分)"`
```
`debt_amount = max(-balance, 0)`,不包含冻结金额。
---
## 七、API 设计
### 7.1 角色默认额度
```text
PUT /api/admin/roles/{id}/default-credit
```
```go
type UpdateRoleDefaultCreditRequest struct {
EnableCredit bool `json:"enable_credit" description:"新建店铺是否默认开启信用额度"`
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"新建店铺默认信用额度(分)"`
}
```
现有 `POST /api/admin/shops` 继续使用 `default_role_id`。Application 在创建店铺和主钱包的同一事务中读取角色默认值并复制到钱包;角色模板之后变化不影响该钱包。
### 7.2 修改信用额度
```text
PUT /api/admin/shops/{id}/credit-limit
```
```go
type UpdateCreditLimitRequest struct {
EnableCredit bool `json:"enable_credit" description:"是否开启信用额度"`
CreditLimit int64 `json:"credit_limit" validate:"min=0" description:"信用额度(分)"`
Version int64 `json:"version" validate:"min=0" description:"钱包版本,用于并发控制"`
}
```
### 7.3 查询展示
现有 `GET /api/admin/shops/fund-summary` 和代理详情响应增加信用字段;不新增不存在的 `/agent-wallets/{shop_id}` 路由。
---
## 八、前端技术方案
### 8.1 角色管理
```text
新建代理默认信用额度
[开关] 默认允许使用信用额度
默认授信上限 [金额输入,单位元]
提示:修改后只影响以后新建的代理,不影响已有店铺
```
- 开关关闭时清空输入并提交 `credit_limit=0`
- 金额输入使用分/元安全转换,不允许负数和小数精度超过两位。
- 仅客户角色展示该配置;平台角色不展示。
### 8.2 店铺资金概况和详情
```text
账面余额:-¥200.00
冻结金额¥0.00
信用额度¥1,000.00
可用金额¥800.00
```
- `balance < 0` 时账面余额显示欠款样式。
- 可用金额以接口值为准,不在前端自行重复计算。
- 修改额度弹框展示当前余额、冻结金额、额度和修改后可用金额预览;提交后仍以后端校验为准。
- 后端返回并发冲突时刷新钱包版本和最新金额,不保留旧计算结果。
- 修改店铺角色、账号角色或角色默认额度都不能覆盖已有钱包额度。
- 编辑代理基础资料不自动修改信用额度;信用调整使用独立权限和独立接口。
平台员工端不展示个人余额或个人信用额度。拥有信用额度管理权限的员工,只能在代理资金页面查看和调整代理主钱包额度。
---
## 九、审计与可观测性
每次信用配置变更记录:
- 操作人、店铺、钱包 ID。
- 变更前后开关、额度、余额、冻结金额和版本。
- `request_id`、IP、设备信息和时间。
关键日志:
- 扣款因信用额度不足被拒绝。
- 钱包 version 冲突。
- 数据库信用边界约束失败。
- 信用额度调整失败和旧值/新值。
资金流水必须能够通过订单号、批量任务号或充值/退款业务号反查,不以普通操作日志替代钱包流水。
---
## 十、发布与回滚
本需求随七月迭代停机发布:
1. 维护窗口内先核对并调整钱包现有 CHECK 约束,再增加钱包信用字段和角色默认模板字段,历史钱包默认关闭。
2. 同时发布钱包聚合、全部主钱包扣款条件、查询字段和管理端信用配置页面,禁止只改余额展示而遗漏真实扣款 SQL。
3. 开放访问前保持所有代理信用开关关闭,人工验证普通余额、信用扣款、并发冲突和额度调整。
4. 系统开放后配置客户角色的新建默认额度;已有店铺需要授信时仍由管理员在店铺资金页面逐个设置。
尚未开启任何信用额度时可以回滚应用版本和可逆迁移。额度启用并产生负余额后,不能直接关闭信用或删除字段;必须先完成还款或保留当前资金逻辑,数据库字段和资金流水不做破坏性回滚。

View File

@@ -0,0 +1,297 @@
# 需求22套餐临期提醒
> 状态:原需求独立稿;最终口径以标准评审稿为准。
---
## 业务规则
### 临期定义
当资产的当前生效主套餐与全部排队主套餐连续接续后的**预计最终剩余天数**按 `Asia/Shanghai` 自然日计算处于 `015` 天时该资产进入临期状态。预计最终到期时间与需求06/11共用同一个 Query不再维护“当前套餐临期”和“最终到期”两套口径。
没有生效套餐且队首套餐仍等待无法确定时间的实名激活时,返回不可预计状态,不进入临期。已过期资产不属于临期。
### 查询与通知职责
- 后台列表、详情、临期列表、代理首页和 C 端展示均通过 SQL 在查询时实时计算,不建立临期状态快照表,也不由前端轮询生成临期数据。
- 每日任务只负责扫描 15/7/3 天阈值并创建通知记录;它不维护列表数据、不决定前端高亮状态。
- `tb_expiry_push_record` 仅用于防止同一资产、接收人、渠道、阈值重复通知。
### 颜色规则Version 2
| 剩余天数 | 颜色 |
|---------|------|
| ≤ 15天 | 粉红色 |
| ≤ 7天 | 紫色 |
| ≤ 3天 | 红色 |
### 各端提醒规则
| 场景 | 提醒方式 | 触发节点 |
|------|---------|---------|
| **后台管理** | 列表加临期天数列 + 高亮详情加临期字段≤15天高亮 | 实时计算 |
| **企业客户** | 对应店铺业务员接收站内通知;后台每日生成临期列表 | 15天/7天/3天节点 |
| **代理端** | 首页展示临期卡/设备数量;列表高亮 | 实时计算 |
| **C端公众号** | 套餐到期提醒模块≤15天展示 | 实时展示 |
```mermaid
flowchart TD
Schedule[每日定时任务] --> Query[计算预计最终到期时间]
Query --> Predictable{可以推算?}
Predictable -->|否| Skip[不进入临期提醒]
Predictable -->|是| Days[计算最终剩余自然日]
Days --> Node{命中 15/7/3 天节点?}
Node -->|否| End[本次不发送]
Node -->|是| Upsert[按资产+节点+接收人幂等写通知记录]
Upsert --> Notification[站内消息]
```
---
## 数据库变更
临期状态实时计算,不建立临期快照表,避免数据陈旧。为保证通知幂等,单独保存发送记录。
每日临期通知记录需防重,用一个 key 记录已发送或已创建的通知:
```sql
-- 临期推送记录(防重)
CREATE TABLE tb_expiry_push_record (
id BIGSERIAL PRIMARY KEY,
package_usage_id BIGINT NOT NULL,
asset_type VARCHAR(20) NOT NULL, -- iot_card | device
asset_id BIGINT NOT NULL,
recipient_id BIGINT NOT NULL,
channel VARCHAR(20) NOT NULL, -- notification
push_node INT NOT NULL, -- 推送节点3/7/15天
event_id VARCHAR(64) NOT NULL,
pushed_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX idx_expiry_push_idempotency
ON tb_expiry_push_record(
package_usage_id, recipient_id, channel, push_node
);
CREATE UNIQUE INDEX idx_expiry_push_event
ON tb_expiry_push_record(event_id);
```
同一资产可能同时通知代理主账号和店铺业务员,唯一约束必须包含接收人和渠道;否则第一位接收人写入记录后会错误拦截其他接收人。`package_usage_id` 让同一资产续费生成新使用记录后可以再次触发 15/7/3 天提醒。本期只使用站内通知渠道。
---
## 后端实现
### 1. 现有接口新增临期字段
#### 资产列表接口(`GET /api/admin/iot-cards` / `GET /api/admin/devices`
响应新增字段:
```go
type IotCardListItem struct {
// ...原有字段...
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at,omitempty" description:"预计最终到期时间"`
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余天数"`
ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态"`
IsExpiring bool `json:"is_expiring" description:"是否临期"`
}
```
列表 Query 批量加载当前和排队主套餐复用需求06/11的周期、时长快照推算逻辑禁止逐资产查询。`is_expiring``days_until_final_expiry BETWEEN 0 AND 15` 派生;设备和卡不得各写一套日期规则。
#### 资产列表筛选条件新增
```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 追加
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at,omitempty" description:"预计最终到期时间"`
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余天数"`
ExpiryEstimateStatus string `json:"expiry_estimate_status" description:"推算状态"`
IsExpiring bool `json:"is_expiring" description:"是否临期"`
```
### 2. 新增临期列表接口(独立页面)
```
GET /api/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:"到期时间止"`
MaxDaysUntilFinalExpiry *int `query:"max_days_until_final_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"`
DaysUntilFinalExpiry int `json:"days_until_final_expiry"`
ExpiresAt time.Time `json:"expires_at"`
DataUsageMB int64 `json:"data_usage_mb"`
RemainingDataMB int64 `json:"remaining_data_mb"`
}
```
### 3. 每日定时任务(仅通知)
```go
// internal/task/expiry_reminder_handler.go
// HandleExpiryReminder 每日03:00按中国自然日扫描通知阈值。
func (h *ExpiryReminderHandler) HandleExpiryReminder(ctx context.Context, t *asynq.Task) error {
assets, err := h.packageUsageStore.GetExpiringAssetsForNotification(ctx, 15)
if err != nil { return err }
for _, asset := range assets {
node := h.selectNearestUnsentNode(ctx, asset, []int{15, 7, 3})
if node == 0 {
continue
}
today := time.Now().In(shanghaiLocation).Format("2006-01-02") // shanghaiLocation 由 time.LoadLocation("Asia/Shanghai") 初始化
for _, recipientID := range asset.RecipientAccountIDs {
eventID := fmt.Sprintf(
"expiry:%d:%d:%d:%d:%s",
asset.PackageUsageID, node, recipientID, asset.AssetID, today,
)
// 在事务内先写 expiry_push_record再通过 Outbox 发布站内消息。
if err := h.notifyPublisher.Publish(ctx, notification.SendPayload{
EventID: eventID,
RecipientIDs: []uint{recipientID},
RecipientType: constants.NotifyRecipientAdmin,
Type: constants.NotifyTypePackageExpiring,
Title: fmt.Sprintf("套餐临期提醒(%d天节点", node),
Body: fmt.Sprintf("资产 %s 的套餐剩余 %d 天", asset.Identifier, asset.DaysUntilExpiry),
RefType: asset.AssetType,
RefID: asset.AssetID,
}); err != nil {
return err
}
}
}
return nil
}
```
定时任务每日执行一次即可,页面不参与轮询。漏跑恢复后,对每个当前仍在 `015` 天范围内的资产,仅补发一个“当前最近且尚未发送”的阈值:例如第 15 天漏跑、剩余 14 天时补发 15 天通知;剩余 6 天时补发 7 天通知,不补发多条过期阈值。
---
## 导出功能(临期列表)
使用统一导出任务:
```text
POST /api/admin/export-tasks
scene=expiring_asset
```
导出字段:
| 字段 | 说明 |
|------|------|
| 资产标识 | ICCID/设备号 |
| 资产类型 | 卡/设备 |
| 店铺名称 | |
| 套餐名称 | |
| 套餐到期时间 | |
| 剩余天数 | |
| 资产状态 | |
| 已用流量(MB) | |
| 剩余流量(MB) | |
---
## C端接口变更
### 公众号首页
现有接口(`GET /api/c/v1/asset/info`,通过 query param 传资产标识)的响应 DTO `AssetInfoResponse``internal/model/dto/client_asset_dto.go`)新增字段:
```go
EstimatedFinalExpiresAt *time.Time `json:"estimated_final_expires_at,omitempty" description:"预计最终到期时间"`
DaysUntilFinalExpiry *int `json:"days_until_final_expiry" description:"预计最终剩余天数"`
IsExpiring bool `json:"is_expiring" description:"是否临期"`
```
前端逻辑:`is_expiring=true` 时展示续费提醒模块:
```
您的套餐即将到期
卡号/设备号XXXX
剩余有效期XX 天
为避免到期后影响正常使用,请您提前完成续费。
[立即续费]
```
---
## 前端对接(后台管理)
### 资产列表IoT卡管理 / 设备管理)
1. 列表新增"剩余天数"列
2. 根据 `days_until_final_expiry` 高亮行:
- ≤ 15天行背景粉红色
- ≤ 7天行背景紫色
- ≤ 3天行背景红色
3. 筛选条件新增"临期天数"下拉≤15天/≤7天/≤3天
- 选中后传 `expiring_within_days=15`
### 临期资产独立列表页
路由:`/expiring-assets`
```
筛选栏:资产标识 | 资产类型(卡/设备) | 套餐名称 | 到期时间范围 | 剩余天数 | 店铺
列表:资产标识 | 资产类型 | 店铺 | 套餐名称 | 剩余天数 | 到期时间 | 资产状态 | 已用流量 | 剩余流量
操作:导出按钮 → `POST /api/admin/export-tasks``scene=expiring_asset`
```
临期独立列表将 `days_until_final_expiry <= 3` 的资产置顶,再按预计最终到期时间升序。普通卡列表和设备列表只按颜色高亮,不改变原有排序。
### 代理端首页
在首页数据接口新增字段(需要确认代理端首页接口):
```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`

View File

@@ -0,0 +1,456 @@
# 项目 DDD 设计规范
> 状态项目渐进迁移规范7月迭代标准稿已采用。
> 务实型 DDD——不是学院派不强制 Event Sourcing不追求完美追求可维护、可扩展、AI 辅助友好。
> 基准文档:`docs/改造方向.md`(绞杀者模式)
---
## 一、为什么用 DDD / 什么不是 DDD
### 现状问题
- `order/service.go` 3031 行:订单创建、支付、佣金计算、代购、库存扣减全混一起
- Model 只是 GORM 映射,没有业务行为(贫血模型)
- 新增业务联动必须修改原有 Service牵一发动全身
### 目标
不是重写,是**渐进替换**
1. **复杂写功能**:进入 Application + Domain不继续堆入旧 Service
2. **简单写功能**:使用 Application 事务脚本,不强行创建聚合
3. **读取功能**:进入 Query 读取模型,不通过聚合根
4. **旧功能迁移**:只在需求触碰时迁移一个完整用例,不做模块级重写
5. **AI 辅助**结构清晰AI 生成代码时自动落入正确位置
---
## 二、什么时候进入 DDD
DDD 是解决复杂业务边界的手段,不是所有功能的默认目录模板。开始设计前,先判断该需求属于“局部数据操作”还是“独立业务能力”。
### 2.1 优先使用 DDD 的场景
当前需求正在修改的用例满足以下任一条件时,应优先建立或扩展领域模块:
1. 有明确生命周期、状态机、状态转换限制或并发不变量。
2. 一个操作包含多个业务规则、跨模块协作、事务边界或可靠事件。
3. 同一业务能力会被多个业务场景复用,需要稳定的领域接口。
4. 需要策略扩展、规则配置、版本快照、审计追溯或幂等处理。
5. 继续向旧 Service 增加分支,会导致职责继续膨胀或修改相互影响。
审批流、钱包额度、订单支付、佣金结算等属于典型 DDD 场景。
### 2.2 通常不迁移的场景
以下需求通常保持原有 `Handler → Service → Store → Model`
1. 单表 CRUD、字段增删、简单列表筛选和格式转换。
2. 局部缺陷修复,且不改变业务边界或核心规则。
3. 没有独立领域语言、生命周期或跨模块不变量。
4. 引入聚合、仓储接口和领域事件后只有样板代码,没有业务收益。
### 2.3 触碰式迁移
- 默认不迁移当前需求未触碰的旧代码,禁止借功能修改之名扩大为模块级重构。
- 迁移单位是**完整用例**,不是整个模块、文件或数据表。
- 简单字段、筛选、格式转换和局部修复默认沿用旧结构。
- 触碰复杂写逻辑时,只迁移完成当前需求所需的最小完整业务边界。
- 一旦迁移某个用例,其状态规则和业务不变量必须完整收口,禁止一半留在旧 Service、一半进入 Domain。
- 旧 Service 可以暂时作为**内部代码迁移门面**调用新 UseCase调用方迁完后再删除。该规则不代表必须保留旧 HTTP 接口,七月迭代审批相关接口按停机方案直接切换。
- 新旧模块通过 Application 接口、领域事件或防腐层交互,禁止绕过边界直接修改聚合数据。
### 2.4 查询侧规则
复杂查询不通过聚合根,统一采用轻量 CQRS 读取模型:
```text
Handler → Query → GORM/DTO
```
- 列表、详情、联表、统计、报表和导出属于 Query。
- Query 可以直接使用 GORM、CTE、子查询和批量查询并负责读取权限与分页。
- Query 返回专用 DTO/Projection禁止返回后用于业务写入。
- 只有查询结果参与写操作判定时Domain 必须重新校验,不能信任读取快照。
- 当前需求未触碰的旧查询不迁移;复杂度明显增加时,只迁移该查询到 `internal/query/<context>`
- Aggregate Repository 只负责加载和保存聚合,不承载多表列表、报表或导出查询。
---
## 三、目录结构
```
internal/
├── domain/ ← 领域层(不依赖 Fiber/Redis/GORM
│ ├── wallet/
│ │ ├── wallet.go ← 聚合根(含业务方法)
│ │ ├── events.go ← 领域事件定义
│ │ ├── repository.go ← 仓储接口interface
│ │ └── vo.go ← 值对象Money, CreditLimit
│ ├── approval/ ← 新:审批流领域
│ ├── notification/ ← 新:通知领域
│ ├── distribution/ ← 后续预留需求16独立立项后再创建
│ └── 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
├── query/ ← 读取侧(可直接使用 GORM返回 DTO/Projection
│ ├── order/
│ ├── refund/
│ ├── approval/
│ ├── dashboard/
│ └── report/
├── infrastructure/ ← 基础设施层(实现 domain 里的 interface
│ ├── persistence/ ← GORM Repository可委托现有 Store
│ ├── messaging/ ← Outbox Relay、Asynq 发布与消费
│ └── adapter/ ← 外部系统和旧模块防腐层
├── handler/ ← 原有 handler保持不动
├── service/ ← 原有 service保持不动新模块不在这里
└── store/ ← 原有 store保持不动
```
**过渡期规则**
- 旧模块:`internal/service/xxx` + `internal/store/postgres/xxx`
- 复杂写用例:`internal/domain/xxx` + `internal/application/xxx`
- 简单写用例:`internal/application/xxx`
- 读取用例:`internal/query/xxx`
- Handler 按用例调用 Application、Query 或尚未迁移的旧 Service
- 全新领域优先使用纯领域对象;迁移旧模块时可临时复用现有 GORM Model但不得让 Fiber、Redis 或 GORM 查询进入业务方法
---
## 四、领域模块划分
| 领域 | 聚合根 | 核心业务规则 | 状态 |
|------|--------|------------|------|
| **Asset资产** | `IotCard`, `Device` | 停复机条件、实名策略 | 迁移中 |
| **Order订单** | `Order` | 支付、退款、佣金触发 | 迁移中 |
| **Package套餐** | `Package`, `PackageUsage` | 生效条件、临期计算 | 迁移中 |
| **Wallet钱包** | `AgentWallet` | 余额扣减、信用额度、负余额 | **新功能用 DDD** |
| **Shop代理** | `Shop` | 层级关系、信用策略 | 部分迁移 |
| **Approval审批** | `ProcessDefinition`, `ProcessInstance` | 流程版本、审批人解析、状态推进、驳回 | **全新 DDD** |
| **Notification通知** | `Notification` | 分发、已读状态 | **全新 DDD** |
| **Distribution分销** | `DistributionRelation` | 发展人体系、二维码注册 | 后续独立立项,本期不创建 |
---
## 五、聚合根设计原则(富模型)
### 5.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 }
// 完整事务和 Outbox 写入方式见“应用服务(用例)”章节
return uc.repo.Save(ctx, wallet)
}
```
### 5.2 聚合根必须包含
```go
type AggregateRoot struct {
// 业务字段...
// 未发布领域事件,由 Application 在事务内写入 Outbox
domainEvents []DomainEvent
}
// PopEvents 获取并清空领域事件
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)
}
```
### 5.3 与现有 GORM 的共存
迁移旧模块时,聚合根可以临时包装现有 GORM Model减少一次性重构成本
```go
// internal/domain/wallet/wallet.go
// AgentWallet 钱包聚合根
// 直接复用并扩展现有 model.AgentWallet
type AgentWallet struct {
model.AgentWallet // 迁移期复用持久化字段
domainEvents []DomainEvent
}
// 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
}
```
这只是迁移期折中,不是新领域的默认形式。审批流等全新领域应优先使用纯领域对象,由 Infrastructure 负责领域对象与 GORM Model 的转换。
---
## 六、值对象设计
值对象:没有 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。Application 在保存聚合的同一数据库事务中写入 Outbox再由 Relay 异步投递到 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 }
```
**事务边界**
```text
数据库事务
├── 保存聚合
├── 保存操作日志
└── 保存 Outbox 事件
提交事务
Outbox Relay → Asynq → 事件处理器
```
**Application UseCase 保存事件**
```go
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error {
wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID)
if err != nil {
return err
}
if err := wallet.Debit(cmd.Amount); err != nil {
return err
}
if err := uc.walletRepo.Save(txCtx, wallet); err != nil {
return err
}
return uc.outboxRepo.Append(txCtx, wallet.PopEvents())
})
}
```
- Outbox 写入失败:事务回滚,避免“业务成功但关键事件永久丢失”。
- Asynq 暂时不可用不回滚已提交业务Relay 后续重试。
- 消费者必须幂等;涉及余额、退款、充值时使用业务键或状态条件更新。
---
## 八、仓储接口
```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
}
```
实现在 `internal/infrastructure/persistence/wallet_repo.go`,可以复用现有 Store 逻辑。事务由 Application 注入的 `TxManager` 管理Repository 从事务上下文获取 GORM `tx`,领域接口不得暴露 `*gorm.DB`
---
## 九、应用服务(用例)
一个文件 = 一个用例。
- 复杂写用例Application 只做事务和跨聚合编排,业务不变量位于 Domain。
- 简单写用例Application 可以使用事务脚本完成基础校验和单表写入,不要求创建聚合。
- Application 不负责列表、报表和复杂 DTO 拼装,这些职责属于 Query。
```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
outboxRepo OutboxRepository
txManager TxManager
}
// Execute 执行扣款
func (uc *DebitWalletUseCase) Execute(ctx context.Context, cmd DebitCommand) error {
return uc.txManager.RunInTx(ctx, func(txCtx context.Context) error {
wallet, err := uc.walletRepo.GetByID(txCtx, cmd.WalletID)
if err != nil {
return err
}
if err := wallet.Debit(cmd.Amount); err != nil {
return err
}
if err := uc.walletRepo.Save(txCtx, wallet); err != nil {
return err
}
return uc.outboxRepo.Append(txCtx, wallet.PopEvents())
})
}
```
---
## 十、Handler 层调用方式
- 复杂写、简单写Handler → Application UseCase。
- 读取Handler → Query。
- 尚未迁移的旧用例Handler → Service。
- Handler 不直接访问 GORM也不承载业务规则或复杂 DTO 拼装。
```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 保存后直接发布关键事件 | 数据已提交但消息可能丢失,必须事务写 Outbox |
| Outbox 未落库仍提交业务事务 | 会造成业务成功但关键回调永久缺失 |
| 在聚合根里调用 Repository | 聚合根不依赖仓储 |
| Query 执行写操作 | Query 只负责读取模型,写入必须进入 Application |
| 使用 Query 快照替代写侧校验 | 查询结果可能已过期Domain 必须重新验证不变量 |
| 在 Aggregate Repository 中堆叠报表联查 | 聚合仓储负责加载和保存聚合,复杂读取属于 Query |
| 只创建 domain/application 目录但业务规则仍在旧 Service | 这是目录搬迁,不是 DDD 迁移 |
---
## 十二、本次迭代 DDD 落地计划
| 新模块 | 领域路径 | 用例路径 |
|--------|---------|---------|
| 信用额度需求17 | `internal/domain/wallet/` | `internal/application/wallet/` |
| 审批流需求18/20/21 | `internal/domain/approval/` | `internal/application/approval/`,使用版本快照和 Outbox |
| 站内消息需求18/22 | `internal/domain/notification/` | `internal/application/notification/` |
| 批量订购需求19 | 复用 Order 领域 | `internal/application/order/bulk_purchase.go` |
需求 16 已于 2026-07-14 移出 7 月迭代,本期不创建 `distribution` 领域目录、聚合和应用用例;后续独立立项时再按本规范判断领域边界。

View File

@@ -0,0 +1,237 @@
# 基础设施系统配置tb_system_config
> 状态独立方案来源稿最终口径以7月迭代标准评审稿为准。
> 被依赖:需求 09C端支付限制
---
## 一、设计目标
将散落在代码里的"写死配置"提取到数据库,平台管理员可通过后台页面修改,无需重新部署。
```mermaid
sequenceDiagram
actor Admin as 平台超管
participant Web as 后台配置页
participant API as SystemConfig Application
participant DB as PostgreSQL
participant Redis as Redis
participant Biz as 业务读取方
Admin->>Web: 修改受控配置表单
Web->>API: PUT /api/admin/system/config/{config_key}
API->>API: 按配置 key 注册规则校验类型和值域
API->>DB: 更新值并写审计日志
API->>Redis: 删除对应缓存
API-->>Web: 返回最新配置和更新时间
Biz->>Redis: 下次读取缓存未命中
Biz->>DB: 读取最新配置并回填缓存
```
---
## 二、数据库
```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端允许的支付方式');
```
需求02不写入全局默认实名策略。H5 始终读取卡或设备自身的 `realname_policy`;新建资产使用模型默认 `after_order`,避免全局 Key 与资产字段产生两套优先级。
---
## 三、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 /api/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 /api/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:"新配置值"`
}
```
更新接口不能只校验 `value_type`。Application 需要按 `config_key` 注册允许值,例如支付方式只能来自 `alipay/wechat/wallet`,实名策略只能来自 `none/before_order/after_order`。未知 key 默认只读,禁止通过通用页面写入任意系统配置。
---
## 七、前端对接
### 页面:系统设置 > 系统配置
**初期可以做一个通用的 Key-Value 管理页面,按 module 分组展示。**
调用流程:
1. 进入页面 → `GET /api/admin/system/config`(不传 module = 返回全部)
2. 按 module 分组展示,`is_readonly=true` 的配置只读
3. 修改某项 → `PUT /api/admin/system/config/{config_key}`body: `{config_value}`
4. 更新成功后后端立即删除该 key 的缓存,前端提示“配置已更新”并刷新当前值
**C端支付限制配置展示建议**(针对 `c2b.payment` 模块):
不要让后台用户手动填 JSON前端渲染成 CheckboxGroup
```
卡资产允许支付方式:
☑ 支付宝 ☑ 钱包 ☐ 微信
设备允许支付方式:
☐ 支付宝 ☑ 钱包 ☑ 微信
```
前端把选中项序列化成 `["alipay","wallet"]` 提交。
通用 Key-Value 页面只作为管理壳层,已知业务配置必须使用受控组件(单选、复选或开关),不向运营人员暴露 JSON 文本框。

View File

@@ -0,0 +1,625 @@
# 新增需求 01数据同步触发与轮询优化
> 状态:已合并至标准评审稿,本文保留为实施明细。
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
> 范围IoT 卡实名、流量、网络状态和设备实时数据同步。
## 一、已确认决策
1. 轮询、运营商回调、业务事件触发是三条相互隔离的自动同步通道。
2. 轮询始终保留,负责回调失效、事件漏网和上游偶发失败时的最终兜底。
3. 资产是否轮询只保留现有全局开关 `enable_polling`;不再设计“实名资格、流量资格、状态资格”等业务资格开关。
4. 轮询优化只区分活跃卡和不活跃卡,通过不同轮询间隔降低无效请求,不因业务类型直接永久排除某类卡。
5. 行业卡是否需要实名不能由 `card_category=industry` 推断,统一以运营商 `realname_link_type` 判断。
6. 业务事件触发不是新增一个显式同步接口,而是埋点到关键查询、停复机、支付、实名和设备操作用例中。
7. 每次事件默认形成三个独立尝试:立即、事件发生后 3 分钟、事件发生后 5 分钟;三次结束后由常规轮询继续兜底。
8. 不设置统一五分钟冷却。重复请求控制拆成同场景触发合并、单卡请求互斥和运营商最小请求间隔Gateway 超频只记录当前失败,不建立退避状态。
9. 运营商实名回调百分百信任,不做来源真实性校验;但必须经过防腐层完成报文解析、标识转换和状态语义转换。
10. 解除实名第一版不修改业务状态,只保留回调入口、写入统一集成审计并返回运营商要求的成功报文。
11. 三条自动通道和现有手动刷新接口共享同一套“获取上游观测并应用到业务”的 DDD 用例,禁止各入口直接更新卡字段。
12. 同步执行记录属于全局审计的 Integration Log不在本方案新建 `tb_card_sync_execution` 或独立同步监控系统。
13. 本次迁移触碰到的旧实名、流量和状态写逻辑必须删除或改为调用新用例,不保留长期双实现。
## 二、现状问题
当前代码存在以下明确问题:
- `internal/task/polling_realname_handler.go` 直接跳过所有行业卡,与“是否实名由运营商特性决定”冲突。
- `PollingRealnameHandler``PollingCarddataHandler``PollingCardStatusHandler` 同时承担 Gateway 查询、字段更新和业务联动。
- `internal/service/iot_card/service.go:RefreshCardDataFromGateway` 又实现一套实名、流量和网络状态同步。
- `ManualUpdateRealnameStatus` 单独实现实名状态变更联动,无法保证与轮询、回调行为一致。
- `ClientRealnameHandler`、部分设备 Handler 直接调用 Gateway业务事件无法在统一用例边界埋点。
- 现有获取实名链接后的自动检查借用了 `ManualTriggerService`,把系统事件伪装成平台用户手工操作。
- 调度任务载荷只有 `card_id`,缺少触发场景、来源、序列和关联链路。
- `last_sync_time` 被实名、流量和状态共同覆盖,无法说明最后同步了什么。
- 现有显式刷新接口已经存在,再增加 `/card-sync/requests` 只会形成重复入口。
因此,本次不是增加更多同步 Service而是先收口写侧用例再把轮询、回调和事件触发接入同一边界。
## 三、目标架构
```mermaid
flowchart LR
Polling[轮询调度] --> Activity[按活跃度计算下次间隔]
Activity --> Request[RequestCardObservation]
Business[关键业务用例] --> Trigger[CreateSyncTriggerSeries]
Query[关键查询用例] --> Trigger
Trigger --> A1[立即尝试]
Trigger --> A2[3分钟后尝试]
Trigger --> A3[5分钟后尝试]
A1 --> Gate[请求协调器]
A2 --> Gate
A3 --> Gate
Gate --> Request
Manual[现有手动刷新接口] --> Request
Callback[运营商实名回调] --> ACL[运营商防腐层]
ACL --> RealObs[标准实名观测]
Request --> Gateway[Gateway Adapter]
Gateway --> Observation[标准化观测值]
RealObs --> Apply[ApplyCardObservation]
Observation --> Apply
Apply --> Card[保存卡状态]
Apply --> Events[记录领域事件和 Outbox]
Events --> Package[套餐激活与流量扣减]
Events --> StopResume[停复机评估]
Events --> ActivityMark[更新活跃标记]
Request --> Integration[统一 Integration Log]
ACL --> Integration
Events --> Audit[关键业务 Audit Event]
```
各入口边界:
| 入口 | 是否调用 Gateway | 执行方式 | 作用 |
|---|---:|---|---|
| 轮询 | 是 | 异步、持续重排 | 最终一致性兜底 |
| 运营商回调 | 否 | 同步应用状态,联动异步可靠投递 | 实名成功急速通道 |
| 业务事件触发 | 是 | 异步 `0/3/5` 三次 Best Effort | 提高正在操作资产的命中概率 |
| 现有手动刷新 | 是 | 保持现有响应契约 | 用户明确要求立即刷新 |
手动刷新不是第四种自动策略,也不能被业务埋点调用。
## 四、DDD 设计
### 4.1 目录
```text
internal/
├── domain/cardstate/
│ ├── card.go 卡状态聚合及状态转换
│ ├── observation.go 实名、流量、网络状态观测值
│ ├── events.go 状态变化领域事件
│ └── repository.go 聚合仓储接口
├── application/cardsync/
│ ├── request_observation.go 查询 Gateway 并获取标准观测
│ ├── apply_observation.go 在事务内应用观测和领域事件
│ ├── create_trigger_series.go 创建事件触发序列
│ ├── execute_trigger_attempt.go 执行单次阶梯尝试
│ ├── mark_activity.go 更新活跃标记
│ └── refresh_asset.go 承接现有显式刷新接口
├── infrastructure/adapter/gateway/
│ └── card_observation_adapter.go 复用现有 Gateway Client
├── infrastructure/adapter/carrier_callback/
│ ├── translator.go 防腐层统一接口
│ ├── cmcc.go
│ ├── cucc.go
│ └── ctcc.go
├── infrastructure/messaging/cardsync/
│ ├── trigger_publisher.go Outbox/Asynq 任务发布
│ └── trigger_handler.go 阶梯任务处理器
└── query/cardsync/
└── activity_query.go 活跃状态和下次轮询查询
```
### 4.2 实名要求判断
不得继续使用以下判断:
```go
if card.CardCategory == constants.CardCategoryIndustry {
// 跳过实名
}
```
现有 `tb_carrier.realname_link_type` 可以直接决定该运营商接入是否需要实名,不新增重复能力字段:
| `realname_link_type` | 实名要求 | 实名入口 |
|---|---|---|
| `none` | 不需要实名 | 不提供实名链接,不创建实名查询任务 |
| `template` | 需要实名 | 使用模板生成实名链接 |
| `gateway` | 需要实名 | 调用 Gateway 获取实名链接 |
- 资产 `realname_policy` 只决定“先实名后购买”或“先购买后实名”的业务顺序。
- `realname_link_type=none` 时,资产有效实名策略统一视为 `none`
- `realname_link_type!=none` 时,资产 `realname_policy=none` 属于冲突数据,发布前列出并修正,禁止运行时静默选择其中一方。
- `card_category` 只保留业务分类和展示用途,不参与实名轮询、复机或套餐激活判断。
运营商是否配置回调 Adapter 只影响有没有回调急速通道,不改变 `realname_link_type` 对实名要求的判断。
### 4.3 标准化观测值
```go
type SyncSource string
const (
SyncSourcePolling SyncSource = "polling"
SyncSourceBusinessEvent SyncSource = "business_event"
SyncSourceOpenAPI SyncSource = "open_api"
SyncSourceManualRefresh SyncSource = "manual_refresh"
SyncSourceCarrierCallback SyncSource = "carrier_callback"
)
type ObservationMeta struct {
Source SyncSource
TriggerScene string
TriggerSeries string
Attempt int
Provider string
ObservedAt time.Time
RequestID string
CorrelationID string
}
type RealnameObservation struct {
CardID uint
ICCID string
Verified bool
Meta ObservationMeta
}
type TrafficObservation struct {
CardID uint
GatewayReadingMB float64
Meta ObservationMeta
}
type NetworkObservation struct {
CardID uint
CardStatus string
Extend string
GatewayIMEI string
Meta ObservationMeta
}
```
领域层不使用 `map[string]any` 传递核心状态,防止不同入口遗漏字段或混淆单位。
### 4.4 观测应用规则
`ApplyCardObservation` 是唯一允许把上游数据写入卡领域状态的入口:
1. 加载并锁定卡聚合。
2. 根据观测类型执行实名、流量或网络状态规则。
3. 状态未变化时只更新时间和观测来源,不重复产生业务事件。
4. 状态变化时保存聚合,并在同一事务写 Outbox 和关键 Audit Event。
5. 实名 `0 -> 1` 产生 `CardRealnamed`,触发卡级/设备级套餐激活和停复机评估。
6. 流量正增量产生 `CardTrafficIncreased`,由套餐应用服务扣减套餐流量。
7. 网络状态变化产生 `CardNetworkStatusChanged`,由停复机策略重新评估。
8. 状态变化或流量增加时更新卡活跃标记。
解除实名回调不构造 `Verified=false` 观测,不修改卡状态。
## 五、轮询优化:只做活跃度调频
### 5.1 全局开关
`enable_polling` 是资产是否参与自动轮询的唯一业务开关:
- `false`:从所有轮询队列移除,事件触发也不因此失效;用户主动操作仍可触发 Best Effort 同步。
- `true`:按照活跃度和运营商能力进入对应 Gateway 查询队列。
- 设备关闭轮询时,继续沿用现有设备与绑定卡级联规则。
不再提供“实名轮询开关、流量轮询开关、状态轮询开关”给业务人员配置。
### 5.2 活跃标记
卡增加或维护以下轮询调度字段:
```text
polling_activity_level active / inactive
last_polling_activity_at 最后活跃时间
last_polling_activity_scene 最后活跃场景
last_upstream_change_at 上游观测最后发生变化时间
```
以下情况标记为活跃:
| 场景 | 说明 |
|---|---|
| C 端、后台或开放接口查询资产实时信息 | 表明当前有人关心该资产 |
| 获取实名链接 | 表明即将发生实名操作 |
| 停机、复机、切卡、重启、恢复出厂设置 | 表明上游状态正在变化 |
| 套餐支付、激活、失效或流量重置 | 可能影响流量和停复机 |
| 运营商回调 | 上游已发生变化 |
| 轮询发现实名、流量或网络状态变化 | 卡当前确实活跃 |
卡在配置时间内没有业务活动,且连续轮询未发现上游变化时转为 `inactive`。该判断在每次重排队时完成,不新增全表扫描任务。
第一版建议配置而非写死:
```text
inactive_after = 30m
active_realname_interval = 现有实名默认间隔
active_traffic_interval = 现有流量默认间隔
active_network_interval = 现有状态默认间隔
inactive_realname_interval = 15m
inactive_traffic_interval = 15m
inactive_network_interval = 15m
```
现有 `tb_polling_config` 的有效间隔迁移为活跃卡默认值,但不再按 `card_condition/card_category` 匹配多套业务资格。第一版调整为一套全局活跃/不活跃间隔;运营商配置只描述接口能力和上游限频,不再决定某张卡是否“有资格”轮询。上线前使用生产数据做只读测算,确认 Gateway QPS 和最长兜底延迟后再调整数值。
### 5.3 调度规则
```text
enable_polling=false
-> 不入自动轮询队列
enable_polling=true + active
-> 使用全局活跃间隔
enable_polling=true + inactive
-> 使用全局不活跃间隔
realname_link_type=none
-> 不创建实名查询任务
```
已实名卡仍可低频查询实名状态,以发现上游实名逆转;未实名行业卡也不能因卡类别被排除。
## 六、业务事件触发
### 6.1 不是新增接口
本需求不新增 `POST /api/admin/card-sync/requests`,也不新增独立“事件同步”按钮。
现有接口保持:
```http
POST /api/admin/assets/:identifier/refresh
POST /api/c/v1/asset/refresh
```
现有批量轮询运维接口可以保留,但必须改为调用新的 Application 用例,不再自行维护另一套同步逻辑。
业务事件触发由 Application UseCase 或 Query 完成后调用内部端口:
```go
type SyncTriggerCommand struct {
Scene string
ResourceType string
ResourceID uint
CardIDs []uint
SyncTypes []string
ExpectedState map[string]any
Source SyncSource
RequestID string
CorrelationID string
}
```
写操作在业务成功后触发;有数据库事务的关键写操作通过 Outbox 发布触发事件,避免提交成功后进程退出造成埋点丢失。读操作只查询本地快照,不等待 Gateway在返回前 Best Effort 写入 Asynq入队失败不得改变原接口响应。
### 6.2 阶梯式尝试
每个触发序列默认创建三个 Asynq 任务:
| 尝试 | 计划时间 | Asynq 自动重试 |
|---:|---:|---:|
| 1 | 立即 | 0 |
| 2 | 事件发生后 3 分钟 | 0 |
| 3 | 事件发生后 5 分钟 | 0 |
每次任务都有确定性的 `series_id + attempt` 幂等键。单次失败不会额外重试,也不会取消后续两次;三次结束后由常规轮询兜底。
存在明确预期状态时允许提前完成序列:
- 复机后已观测为开机。
- 停机后已观测为停机。
- 获取实名链接后已通过回调或查询确认实名。
- 切卡后设备当前卡已经是目标 ICCID。
序列提前完成后,剩余任务启动时检查状态并直接记为 `completed`,不再请求 Gateway。
### 6.3 关键埋点
埋点放在应用用例成功边界,不散落在 Handler 的 `response.Success` 前后。现有 Handler 直接调用 Gateway 的路径在迁移时收口到 Application。
| 现有入口/用例 | 触发内容 | 预期状态 |
|---|---|---|
| `GET /api/c/v1/asset/info` | 资产绑定卡的实名、流量、网络状态 | 无 |
| `GET /api/admin/assets/:identifier/realtime-status` | 对应卡或设备绑定卡的实时数据 | 无 |
| `GET /api/open/v1/cards/traffic` | 流量 | 无 |
| `GET /api/open/v1/cards/status` | 网络状态 | 无 |
| `GET /api/open/v1/cards/realname-status` | 实名 | 无 |
| `GET /api/open/v1/devices/traffic` | 设备信息、绑定卡流量 | 无 |
| C 端和后台获取实名链接 | 实名 | 已实名 |
| 后台、C 端、开放接口停机/复机成功 | 网络状态 | 停机或开机 |
| 自动停复机 Gateway 调用成功 | 网络状态 | 停机或开机 |
| 订单支付、钱包购买套餐、套餐激活成功 | 实名、流量、网络状态 | 无 |
| 设备切卡或切换模式成功 | 设备信息、源卡和目标卡网络状态、目标卡流量 | 目标 ICCID |
| 设备重启、恢复出厂设置、WiFi 设置成功 | 设备信息、绑定卡网络状态 | 无 |
| 卡绑定/解绑、设备或卡分配/回收 | 只标记活跃;确有上游操作时再创建同步序列 | 无 |
补充规则:
- `POST /api/admin/assets/:identifier/refresh``POST /api/c/v1/asset/refresh` 已经直接同步,不再额外创建 `0/3/5` 序列。
- 运营商实名回调直接应用观测,不再反查 Gateway回调成功后终止相同卡的待执行实名序列。
- 轮询发现状态变化只更新活跃标记,不反向创建新的事件序列。
- 同一次设备操作涉及多张绑定卡时使用同一 `correlation_id`,但每张卡独立限流和记录结果。
### 6.4 冷却、合并与限流
不使用一个固定五分钟 `SET NX` 键拦截所有事件。请求协调器只处理同场景合并、单卡互斥和最小请求间隔Gateway 返回超频后不建立额外退避状态。
#### 同场景触发合并
```text
cardsync:series:{scene}:{resource_type}:{resource_id}:{sync_type}
```
- 同一场景、同一资源、同一同步类型已有未结束序列时,新触发合并到原序列。
- 合并只防止页面轮询、开放接口重试等重复创建大量 `0/3/5` 任务。
- 停复机、支付、切卡等不同业务场景不会被一个查询场景长期压制。
- 序列键只覆盖最后一次计划任务和短暂缓冲,不作为全局五分钟冷却。
#### 单卡请求互斥
```text
cardsync:inflight:{provider}:{sync_type}:{card_id}
```
- 只覆盖一次 Gateway 请求的执行时间TTL 为请求超时加安全余量。
- 防止轮询、手动刷新和事件任务同时请求并重复应用同一观测。
- 轮询命中互斥时延迟短时间重排;事件尝试命中互斥时只跳过当前尝试,后续阶梯任务仍存在。
#### 运营商最小请求间隔
最小间隔按运营商接入和接口类型配置,例如实名、流量、状态可以不同,不能统一写死为五分钟。
- 默认兜底值建议为 10 秒,仅用于阻止近乎同时的重复调用。
- 如果上游明确给出更严格限制,以运营商配置为准。
- 高价值状态变更事件命中最小间隔时,延迟到最近允许时间,不直接丢弃整个序列。
#### 超频结果处理
- Gateway 返回超频时,当前尝试记录为 `rate_limited` 后直接结束。
- 不记录 `blocked_until`,不读取 `Retry-After`,也不执行 `30s/60s/120s` 退避。
- 不为当前尝试额外补发任务,原定 3 分钟、5 分钟尝试保持不变。
- 后续轮询仍按正常调度时间继续,是否再次超频由当时上游实际情况决定。
- 轮询、事件和手动刷新仍共享本系统的并发控制,避免本系统自身在同一时刻并发打满 Gateway。
因此“5 分钟”是第三次尝试的计划时间,不是禁止新业务事件同步的冷却时间。
### 6.5 开放接口行为
开放接口继续返回本地快照,不等待 Gateway
```mermaid
sequenceDiagram
participant Agent as 代理系统
participant API as Open API
participant DB as PostgreSQL
participant Trigger as CreateSyncTriggerSeries
participant Worker as Sync Attempt Worker
Agent->>API: 查询实名/状态/流量
API->>DB: 查询当前本地快照
DB-->>API: 当前数据
API-->>Agent: 按现有结构立即返回
API->>Trigger: Best Effort 创建 0/3/5 序列
Trigger-->>Worker: 同场景重复请求自动合并
```
## 七、运营商实名回调防腐层
### 7.1 防腐层职责
“百分百信任回调”表示信任其业务结论,不表示让运营商报文直接进入领域模型。
```go
type CarrierRealnameCallbackTranslator interface {
TranslateSuccess(body []byte) (CarrierRealnameNotice, error)
TranslateRemoval(body []byte) (CarrierRealnameRemovalNotice, error)
SuccessResponse() CallbackResponse
FailureResponse(err error) CallbackResponse
}
```
各运营商 Adapter 负责:
- 解析 JSON、XML 或表单字段。
- 提取并校验 19/20 位 ICCID、MSISDN 等标识。
- 把运营商状态码翻译为“实名成功”或“解除实名通知”。
- 屏蔽运营商字段名、状态码和成功响应格式。
- 生成脱敏 Integration Log 摘要。
领域层只接收标准 `RealnameObservation`,不依赖移动、联通、电信的报文结构。
#### ICCID 19/20 位解析
回调必须复用现有双列存储和按长度路由规则:
```text
收到 19 位 ICCID
-> 原值查询 tb_iot_card.iccid_19
收到 20 位 ICCID
-> 原值查询 tb_iot_card.iccid_20
收到其他长度
-> invalid_payload不进入实名用例
```
- 先去除首尾空白,再调用现有 ICCID Validator只接受 19 位或 20 位字母数字值。
- 禁止把 20 位回调值截成 19 位后降级查询。
- 禁止为 19 位值自行补第 20 位校验位。
- 查询 Miss 时不切换另一列重试,记录原始长度、运营商和脱敏 ICCID 摘要。
- 复用 `IotCardStore.GetByICCID` 的长度路由语义;防腐层只负责提取和校验,不自己拼接模糊 SQL。
- `iccid_19` 必须在未删除卡中保持唯一。发布前检查重复前缀并建立 Partial Unique Index避免 19 位回调错误命中任意一张卡。
- Repository 查询若发现多条匹配必须返回冲突,不允许使用 `First` 静默选择。
- Integration Log 的 `resource_key` 保存回调原始 19/20 位值,展示时按审计权限脱敏。
### 7.2 路由
```http
POST /api/callback/carriers/cmcc/realname
POST /api/callback/carriers/cucc/realname
POST /api/callback/carriers/ctcc/realname
POST /api/callback/carriers/cmcc/realname/remove
POST /api/callback/carriers/cucc/realname/remove
POST /api/callback/carriers/ctcc/realname/remove
```
- 成功回调:防腐层转换后直接调用 `ApplyCardObservation(Verified=true)`
- 解除回调:只写统一 Integration Log状态记为 `ignored`,不修改卡状态。
- 广电或其他没有回调能力的接入继续依赖事件和轮询。
- 不验证回调是否真的来自运营商,不执行 Gateway 二次查询。
- 不调用旧平台 `inner_callback`、第三方推送或删除实名接口。
### 7.3 响应原则
- 找到卡并应用成功:返回运营商要求的成功报文。
- 重复成功回调:幂等返回成功,不重复激活套餐。
- 找不到卡Integration Log 记 `not_found`,仍返回成功,避免无意义高频重推。
- 报文无法解析:记录 `invalid_payload`;若运营商有固定成功应答要求,仍按接入约定返回,避免不可控重试风暴。
- 状态已落库但异步联动失败:返回成功,联动通过 Outbox 重试。
## 八、统一审计契约
同步运行数据不在本方案定义独立表和独立页面,统一由 `04-全局多视角审计方案.md` 管理。
本模块需要向审计系统提供:
| 记录 | 进入位置 |
|---|---|
| 每次 Gateway 实际请求或被限流、合并的尝试 | Integration Log |
| 每次运营商实名/解除实名回调 | Integration Log |
| 实名、流量、网络状态发生业务变化 | Audit Event + Integration Log |
| 手动刷新、手动实名修改 | Audit Event + Integration Log |
| 连续失败达到阈值、超频持续异常 | 风险 Audit Event |
| 普通高频轮询成功且数据未变化 | 仅 Integration Log |
必须携带:
```text
provider / operation / resource / trigger_scene / trigger_series
attempt / result / duration / request_id / correlation_id
state_changed / provider_code / error_summary
```
资产详情的“查看同步轨迹”跳转审计中心外部集成视角,不再建设 `/operations/card-sync` 独立监控页。
## 九、接口和前端调整
### 9.1 后端接口
本需求不新增显式同步接口。
现有手动刷新接口内部改为调用 `RefreshAssetUseCase`,响应结构和权限保持不变。现有轮询监控接口增加:
- 活跃卡数量、不活跃卡数量。
- 各活跃级别的实际轮询 QPS。
- 因互斥或运营商最小间隔而延迟的任务数,以及 Gateway 超频失败次数。
资产实时状态或详情 Query 增加可选调度信息:
```json
{
"polling": {
"enabled": true,
"activity_level": "active",
"last_activity_at": "2026-07-15T10:00:00+08:00",
"last_activity_scene": "client_asset_info",
"next_poll_at": "2026-07-15T10:01:00+08:00"
}
}
```
同步明细查询复用全局审计接口:
```http
GET /api/admin/audit/integrations
?provider=gateway
&resource_type=iot_card
&resource_key=89860...
&trigger_scene=
&trigger_series=
```
### 9.2 前端
保留现有资产刷新按钮,不新增第二个同步按钮。
资产详情增加紧凑的轮询状态展示:
```text
自动轮询:已开启
活跃状态:活跃
最后活跃2分钟前C端资产详情
下次兜底约1分钟后
同步轨迹:查看
```
“查看”跳转:
```text
/operations/audit?tab=integrations&resource_type=iot_card&resource_key={identifier}
```
轮询监控页只增加活跃/不活跃分布和限流状态,不重复实现 Integration Log 表格、详情抽屉和导出。
## 十、代码迁移范围
### 10.1 必须删除或收口
- 删除 `PollingRealnameHandler``CardCategoryIndustry` 跳过实名的判断。
- 修正 `pkg/constants/iot.go` 中“普通卡必需实名、行业卡无需实名”的绝对化注释和依赖逻辑。
- `PollingRealnameHandler``PollingCarddataHandler``PollingCardStatusHandler` 只调用 Application 用例,不再直接更新卡或执行业务联动。
- 删除重复流量增量算法,只保留领域实现。
- `RefreshCardDataFromGateway` 改为调用 `RequestCardObservation + ApplyCardObservation`
- `ManualUpdateRealnameStatus` 改为调用领域用例。
- 获取实名链接后的 `ManualTriggerService.TriggerSingle` 改为 `CreateSyncTriggerSeries`
- C 端和后台设备操作中直接调用 Gateway 的路径迁入 Application再在用例成功边界创建触发序列。
### 10.2 保留并复用
- Gateway Client 的加密、签名和 HTTP 封装。
- Redis 分片 Sorted Set 调度基础设施。
- Asynq Worker。
- 卡流量同步锁,迁移为通用请求互斥实现。
- 现有资产手动刷新接口和权限契约。
- 现有批量轮询运维能力;其执行逻辑和记录迁入新用例与统一审计后,再下线旧专用日志表。
### 10.3 数据变更
- 不新增实名要求字段,继续以 `tb_carrier.realname_link_type` 判断是否需要实名及链接生成方式。
- 发布前检查 `iccid_19` 重复数据并建立未删除数据范围内的唯一索引,保证 19 位回调精确命中。
- 卡增加轮询活跃标记字段,或由独立调度状态表保存;实现阶段根据写入频率决定,不能把高频调度心跳写入核心卡表。
- 现有 `tb_polling_config` 条件匹配迁移为单一全局活跃/不活跃策略,不再使用 `card_condition/card_category` 形成隐式轮询资格。
- 事件任务载荷增加 `scene/series_id/attempt/source/request_id/correlation_id/expected_state`
- 不创建 `tb_card_sync_execution`
## 十一、发布与人工验证
停机发布API 与 Worker 同时切换:
1. 验证 `realname_link_type=none/template/gateway` 分别对应无需实名、模板实名和 Gateway 实名,不再按行业卡统一跳过。
2. 验证 `enable_polling=false` 会移除自动轮询,但业务事件和手动刷新仍能按各自规则工作。
3. 验证活跃卡使用活跃间隔,不活跃卡使用低频间隔,重新活跃后立即恢复。
4. 验证一次业务事件形成立即、3 分钟、5 分钟三个任务。
5. 验证停复机、实名和切卡达到预期状态后,剩余任务不再请求 Gateway。
6. 验证同场景高频查询只合并重复序列,不压制新的停复机或支付事件。
7. 验证单卡互斥只覆盖请求执行时间,不形成五分钟全局冷却。
8. 验证 Gateway 超频时当前尝试直接记为 `rate_limited`,不创建退避状态,后续阶梯任务仍保留。
9. 验证现有后台和 C 端刷新接口契约不变,且不额外创建阶梯序列。
10. 验证移动、联通、电信回调均先经过防腐层19 位和 20 位 ICCID 分别按双列精确路由,重复成功回调不重复激活套餐。
11. 验证解除实名回调只写 Integration Log不修改卡实名状态。
12. 验证开放接口仍立即返回本地数据,事件触发失败不改变响应。
13. 验证普通同步、状态变化、手动刷新和连续失败能够在审计中心按资源和触发序列查询。

View File

@@ -0,0 +1,878 @@
# 新增需求 02企业微信审批接入
> 状态:已合并至标准评审稿,本文保留为实施明细。
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
> 范围:退款审批、平台员工线下充值审批,以及企业微信模板配置、回调和轮询补偿。
## 一、已确认决策
1. 不建设通用审批流引擎,审批节点、审批人、会签/或签和流程配置全部由企业微信审批模板负责。
2. 本系统只建设企业微信审批接入、状态镜像和审批终态后的业务处理。
3. 退款金额在申请时固定,企微只决定通过或驳回;不允许审批人在终态修改退款金额。
4. 线下充值不再要求操作密码,企微审批通过后自动入账。
5. 企微审批通过后又撤销,不自动冲正已经完成的退款或充值,只记录高等级异常、发送通知并人工处理。
6. 回调是主通道,每 2 分钟查询审批详情作为待审批单兜底。
7. 回调与轮询必须进入同一个状态同步用例,业务终态处理只能执行一次。
8. 企微模板 ID 和控件 ID 都可能因管理员编辑模板而变化,必须使用稳定业务场景码、不可变模板版本和控件映射快照。
9. 本次触碰的退款、线下充值审批逻辑迁移到 Domain/Application旧业务单级通过、驳回、退回和确认入账接口下线不保留两套审批入口。
10. 系统无法从旧模板 ID 自动发现编辑后生成的新模板 ID模板变更必须先暂停业务场景再发布新映射并原子切换避免继续向旧模板提交。
11. 退款仍为财务人工退款,本系统不调用微信、支付宝等支付渠道退款接口;只有代理钱包支付订单自动回溯原扣款代理主钱包,不向个人客户或资产钱包自动回款。
12. 系统账号与企微成员使用 Web 登录二维码自助绑定,普通运营不手工查找或录入 `userid`;管理端只查看状态和强制解绑。
## 二、系统边界
企业微信负责:
- 审批模板编辑。
- 审批节点和审批人配置。
- 审批中的通过、驳回、撤销和删除。
- 审批意见和审批附件展示。
- 企业微信端待办提醒。
本系统负责:
- 退款和线下充值业务单创建。
- 把本地业务快照和附件提交到企微。
- 保存企微审批单号和状态镜像。
- 接收、解密企微回调并查询审批详情。
- 审批通过后记录人工退款终态、按规则回溯代理主钱包,或执行线下充值入账。
- 审批驳回、撤销、删除后的本地业务状态更新。
- 站内结果通知、异常告警和审计。
本系统不再保存审批节点、审批任务、审批人候选集合或本地审批动作。
## 三、总体流程
```mermaid
sequenceDiagram
actor User as 平台员工
participant API as Refund/Recharge Application
participant DB as PostgreSQL
participant Outbox as Outbox
participant Worker as WeCom Submit Worker
participant WeCom as 企业微信审批
participant Callback as 企微回调
participant Sync as SyncApprovalStatus
participant Biz as 业务终态用例
User->>API: 创建退款/线下充值申请
API->>DB: 保存业务单和企微审批实例(submitting)
API->>Outbox: 同事务写 SubmissionRequested
API-->>User: 返回业务单和提交中状态
Outbox->>Worker: 投递提交任务
Worker->>WeCom: 上传附件、applyevent
WeCom-->>Worker: sp_no
Worker->>DB: 保存 sp_no状态改为 pending
WeCom->>Callback: 加密审批事件
Callback->>Callback: 验签、解密、保存事件
Callback->>Sync: 提交状态同步
Sync->>WeCom: getapprovaldetail
WeCom-->>Sync: 审批详情
Sync->>DB: 幂等更新审批状态和详情快照
Sync->>Outbox: 首次进入终态时写业务处理事件
Outbox->>Biz: 可靠执行退款/充值终态用例
```
## 四、基础配置
企微基础连接配置使用现有 Viper 统一配置体系,不通过通用 Key-Value 页面展示明文密钥:
```yaml
wecom:
corp_id: ""
agent_id: 0
agent_secret: ""
callback_token: ""
callback_encoding_aes_key: ""
callback_path: "/api/callback/wecom/approval"
account_binding_redirect_url: "https://后台域名/api/callback/wecom/account-binding"
approval_poll_interval: 2m
template_verify_interval: 10m
request_timeout: 10s
```
环境变量覆盖敏感项:
```text
JUNHONG_WECOM_CORP_ID
JUNHONG_WECOM_AGENT_ID
JUNHONG_WECOM_AGENT_SECRET
JUNHONG_WECOM_CALLBACK_TOKEN
JUNHONG_WECOM_CALLBACK_ENCODING_AES_KEY
JUNHONG_WECOM_ACCOUNT_BINDING_REDIRECT_URL
```
后台只能查询“是否已配置、最近连通时间、最近错误”,不能返回 Secret、Token 或 EncodingAESKey。
企微自建应用还必须配置:
- Web 登录回调可信域名与 `account_binding_redirect_url` 域名一致。
- 应用可见范围覆盖需要发起退款或线下充值审批的平台员工,否则扫码时企微会提示无权限。
- `CorpID``AgentID`、用于换取身份的 Access Token 必须属于同一个自建应用配置。
用户提供的 demo 中已经出现完整密钥正式接入前必须在企业微信后台轮换并禁止将新值写入代码、Markdown、日志或审计快照。
## 五、模板映射与版本
### 5.1 为什么不能只配置 template_id
企业微信模板编辑后可能生成新的模板 ID删除并重新增加控件时控件 ID 也会变化。业务代码如果写死:
```go
templateID = "..."
amountControlID = "Text-..."
```
模板一旦编辑,新审批立即提交失败,历史审批也无法说明当时使用了哪套字段。
因此分为三层:
```text
稳定业务场景 scene_code
↓ 当前启用
不可变模板版本 template_version
↓ 包含
template_id + control_mapping + template_snapshot
```
### 5.2 稳定业务场景
第一版固定两个场景:
| scene_code | 中文名称 | 业务类型 |
|---|---|---|
| `refund_approval` | 退款审批 | refund |
| `offline_recharge_approval` | 线下充值审批 | agent_recharge |
业务代码只引用 `scene_code`,不直接引用企微模板 ID。
### 5.3 场景运行状态
```sql
CREATE TABLE tb_wecom_approval_scene (
scene_code VARCHAR(64) PRIMARY KEY,
scene_name VARCHAR(255) NOT NULL,
status INT NOT NULL DEFAULT 0,
current_template_version_id BIGINT,
paused_reason VARCHAR(255) NOT NULL DEFAULT '',
version BIGINT NOT NULL DEFAULT 0,
updated_by BIGINT,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
`status``0=已暂停, 1=启用, 2=暂停中`。不建立数据库外键,`current_template_version_id` 由领域规则保证引用有效版本。
企微不会根据旧模板 ID 告诉本系统“这个模板已经产生了一个新 ID”。如果旧 ID 仍可访问,仅轮询 `gettemplatedetail` 也无法发现新模板。因此模板编辑采用明确的维护流程:
```text
将 scene_code 改为暂停中,立即阻止新申请和新提交租约
→ 等待已经取得提交租约的 Worker 完成或释放
→ 场景进入已暂停
→ 在企微编辑模板并取得新 template_id
→ 本系统读取新模板并完成控件映射
→ 发布不可变模板版本
→ 同一事务切换 current_template_version_id 并恢复场景
```
场景暂停后,退款或线下充值创建接口在写业务单前直接拒绝新申请,并返回“审批模板维护中”;已取得 `sp_no` 的历史审批继续接收回调和轮询,不受影响。
提交 Worker 调用企微前通过条件更新取得短期 `submit_lease_until`。暂停操作先把场景改为 `暂停中`,此后不再发放新租约;租约全部释放或到期后才显示“已暂停”。这样运营人员看到“已暂停”时,可以确认没有旧模板提交正在进行。
租约覆盖附件上传和 `applyevent` 调用并由 Worker 定时续期;处理结束时使用 `defer` 释放,提交结果未知也必须先持久化状态再释放。暂停流程只认可未过期租约已经全部消失,不能仅按任务进程是否存活判断。
### 5.4 模板版本表
```sql
CREATE TABLE tb_wecom_approval_template_version (
id BIGSERIAL PRIMARY KEY,
scene_code VARCHAR(64) NOT NULL,
version_no INT NOT NULL,
template_id VARCHAR(128) NOT NULL,
template_name VARCHAR(255) NOT NULL DEFAULT '',
status INT NOT NULL DEFAULT 1,
control_mapping JSONB NOT NULL,
template_snapshot JSONB NOT NULL,
template_fingerprint VARCHAR(64) NOT NULL,
last_verified_at TIMESTAMPTZ,
last_verify_error TEXT,
published_by BIGINT NOT NULL,
published_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
disabled_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (scene_code, version_no)
);
CREATE UNIQUE INDEX uq_wecom_template_active_scene
ON tb_wecom_approval_template_version (scene_code)
WHERE status = 1;
```
`status``0=已停用, 1=启用, 2=失效`
发布新版本时,在一个事务内停用旧版本并创建新版本。旧审批实例继续引用旧版本记录,不更新历史快照。
### 5.5 控件映射
稳定业务字段映射到当前模板控件:
```json
{
"shop_name": {
"control": "Text",
"control_id": "Text-xxx",
"required": true
},
"business_no": {
"control": "Text",
"control_id": "Text-yyy",
"required": true
},
"amount": {
"control": "Money",
"control_id": "Money-zzz",
"required": true
},
"attachment": {
"control": "File",
"control_id": "File-aaa",
"required": true
}
}
```
退款场景必填业务字段:
```text
shop_name, refund_no, order_no, asset_identifier,
requested_refund_amount, refund_reason, attachment, submitter
```
线下充值场景必填业务字段:
```text
shop_name, recharge_no, amount, remark, attachment, submitter
```
模板发布时必须调用 `gettemplatedetail` 校验:
- `template_id` 可访问。
- 映射的每个控件 ID 确实存在。
- 控件类型与业务字段要求一致。
- 必填业务字段全部完成映射。
- 同一个控件不能绑定两个业务字段。
### 5.6 模板失效检测
后台任务每 10 分钟验证所有启用模板:
1. 调用 `gettemplatedetail`
2. 使用控件 ID、类型和名称生成 SHA-256 fingerprint。
3. 模板不可访问或 fingerprint 变化时,将版本标记为 `status=2`
4. 模板失效时同时暂停对应场景,禁止创建和提交新审批,但不影响已提交审批的回调和状态查询。
5. 发送站内系统告警,提示管理员发布新的模板映射版本。
该检测只能发现“当前模板 ID 已失效或内容发生变化”,不能自动发现企微生成的新模板 ID所以不能替代上述暂停和发布流程。
提交审批前,如果 `last_verified_at` 超过验证间隔Worker 先同步验证一次。验证失败不调用 `applyevent`
## 六、内部账号与企微成员绑定
创建人必须有明确的企微 `userid`,不使用固定手机号冒充所有申请人,也不要求运营人员进入企微后台查找成员 ID。
### 6.1 绑定流程
```mermaid
sequenceDiagram
actor User as 已登录平台员工
participant FE as 管理后台
participant API as WeComIdentity Application
participant Redis as Redis绑定会话
participant Login as 企业微信Web登录
participant WeCom as 企业微信API
participant DB as PostgreSQL
User->>FE: 点击绑定企业微信
FE->>API: 创建绑定会话
API->>Redis: 保存一次性stateTTL 5分钟
API-->>FE: 返回企微登录URL和session_id
FE->>Login: 新窗口打开企微二维码
User->>Login: 使用企业微信扫码确认
Login->>API: 回调code + state
API->>Redis: 原子消费state
API->>WeCom: auth/getuserinfo(code)
WeCom-->>API: userid
API->>DB: 校验唯一性并保存绑定
API-->>FE: postMessage通知绑定结果
```
后端构造官方 Web 登录地址:
```text
https://login.work.weixin.qq.com/wwlogin/sso/login
?login_type=CorpApp
&appid={CorpID}
&agentid={AgentID}
&redirect_uri={URLEncode后的回调地址}
&state={一次性随机值}
```
回调取得的 `code` 只能使用一次且 5 分钟过期。后端使用同一自建应用的 Access Token 调用:
```http
GET https://qyapi.weixin.qq.com/cgi-bin/auth/getuserinfo
?access_token={access_token}
&code={code}
```
返回 `userid` 才允许绑定;返回 `openid` 表示不是本企业成员,直接拒绝。企微身份接口不返回 `corp_id`,企业归属由登录 URL 中的 `CorpID` 和用于换取身份的自建应用 Access Token 共同限定。
第一版使用官方登录页面新窗口,不引入前端 SDK。回调成功页使用固定后台 Origin 的 `window.opener.postMessage` 通知原页面并关闭窗口;窗口通信失败时,原页面通过 `session_id` 轮询会话状态兜底。
### 6.2 绑定会话
绑定会话只存 Redis不建长期数据库表。`state` 与前端查询会话分开保存,避免回调消费 `state` 后无法查询结果:
```text
key: RedisWecomAccountBindingStateKey(state)
ttl: 5分钟
value:
session_id
key: RedisWecomAccountBindingSessionKey(session_id)
ttl: 10分钟
value:
target_account_id
initiator_account_id
allowed_origin
status
error_code
created_at
```
- 只有已登录且启用的超级管理员、平台用户可以为自己创建绑定会话。
- `state` 使用密码学安全随机数,回调时通过 Lua 原子读取并删除;后续成功或失败结果写入独立的 `session_id` 会话。
- 回调不接受前端传入的 `account_id`,绑定目标只能来自服务端会话。
- 同一账号再次创建会话时,旧会话立即失效。
- `allowed_origin` 从服务端后台域名配置生成,不能接受请求参数覆盖。
- `session_id` 仅用于当前登录用户查询结果,不能作为绑定凭证。
### 6.3 绑定数据
```sql
CREATE TABLE tb_account_wecom_mapping (
id BIGSERIAL PRIMARY KEY,
account_id BIGINT NOT NULL UNIQUE,
wecom_userid VARCHAR(128) NOT NULL UNIQUE,
display_name VARCHAR(255) NOT NULL DEFAULT '',
corp_id VARCHAR(64) NOT NULL,
agent_id BIGINT NOT NULL,
bind_source VARCHAR(32) NOT NULL DEFAULT 'self_scan',
bound_by BIGINT NOT NULL,
status INT NOT NULL DEFAULT 1,
verified_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
```
- 平台员工创建退款或线下充值时必须存在启用绑定,否则拒绝创建并返回可直接拉起绑定窗口的错误状态。
- `account_id``wecom_userid` 都是一对一唯一;企微成员已绑定其他账号时拒绝覆盖,必须先由超级管理员解除旧绑定。
- 重新绑定同一账号必须再次扫码,成功后在事务内替换旧身份并记录前后值审计。
- 自助解绑不影响历史审批;历史实例继续使用提交时保存的 `creator_wecom_userid` 快照,新审批在重新绑定前禁止创建。
- `display_name` 通过读取成员接口获取;权限不足时允许为空,但不影响以 `userid` 发起审批。
- 企微审批详情中的审批人 `userid` 原样保存为快照;能匹配本地账号时额外返回本地账号 ID。
- 用户离职或映射失效不修改历史审批人快照。
普通运营界面不提供手工录入 `userid`。超级管理员仅能查看、强制解绑并要求员工重新扫码,不提供直接改写成员 ID 的入口。
## 七、审批实例
```sql
CREATE TABLE tb_wecom_approval_instance (
id BIGSERIAL PRIMARY KEY,
biz_type VARCHAR(64) NOT NULL,
biz_id BIGINT NOT NULL,
biz_no VARCHAR(64) NOT NULL DEFAULT '',
round_no INT NOT NULL DEFAULT 1,
scene_code VARCHAR(64) NOT NULL,
template_version_id BIGINT NOT NULL,
template_id_snapshot VARCHAR(128) NOT NULL,
control_mapping_snapshot JSONB NOT NULL,
creator_account_id BIGINT NOT NULL,
creator_wecom_userid VARCHAR(128) NOT NULL,
sp_no VARCHAR(128),
status INT NOT NULL DEFAULT 0,
submitted_snapshot JSONB NOT NULL,
approval_detail_snapshot JSONB,
approver_snapshot JSONB,
apply_error TEXT,
submit_lease_until TIMESTAMPTZ,
callback_at TIMESTAMPTZ,
last_polled_at TIMESTAMPTZ,
status_changed_at TIMESTAMPTZ,
business_processed_at TIMESTAMPTZ,
business_process_result VARCHAR(20),
business_process_error TEXT,
version BIGINT NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (biz_type, biz_id, round_no)
);
CREATE UNIQUE INDEX uq_wecom_approval_sp_no
ON tb_wecom_approval_instance (sp_no)
WHERE sp_no IS NOT NULL;
```
内部状态:
| status | 含义 | 对应企微状态 |
|---:|---|---|
| 0 | 提交中 | 尚未取得 sp_no |
| 1 | 审批中 | `sp_status=1` |
| 2 | 已通过 | `sp_status=2` |
| 3 | 已驳回 | `sp_status=3` |
| 4 | 已撤销 | `sp_status=4` |
| 5 | 通过后撤销 | `sp_status=6` |
| 6 | 已删除 | `sp_status=7` |
| 7 | 提交失败 | 明确未创建审批 |
| 8 | 提交结果未知 | 请求超时,无法确认是否创建 |
`submitted_snapshot` 保存提交时的业务展示数据和本地附件 Key不保存临时 `media_id`、签名 URL 或敏感密钥。
## 八、DDD 设计
```text
internal/
├── domain/wecomapproval/
│ ├── approval.go 审批状态机和终态幂等规则
│ ├── scene.go 场景暂停、启用和版本切换不变量
│ ├── template.go 不可变模板版本和控件映射
│ ├── events.go 审批通过/驳回/异常事件
│ └── repository.go
├── application/wecomapproval/
│ ├── change_scene_status.go
│ ├── publish_template_version.go
│ ├── submit_approval.go
│ ├── sync_approval_status.go
│ ├── handle_callback.go
│ ├── poll_pending.go
│ └── resubmit_approval.go
├── domain/wecomidentity/
│ ├── binding.go 账号与企微成员一对一绑定规则
│ └── repository.go
├── application/wecomidentity/
│ ├── create_binding_session.go
│ ├── complete_binding.go
│ ├── get_my_binding.go
│ └── unbind_account.go
├── domain/refund/
│ ├── refund.go 退款金额和状态不变量
│ └── events.go
├── application/refund/
│ ├── create_refund.go
│ ├── complete_wecom_approved.go
│ └── reject_from_wecom.go
├── application/recharge/
│ ├── create_offline_recharge.go
│ ├── complete_wecom_approved.go
│ └── reject_from_wecom.go
└── infrastructure/adapter/wecom/
├── client.go
├── token_provider.go
├── web_login.go
├── identity.go
├── approval.go
├── media.go
└── callback_crypto.go
```
退款和充值的旧 Service 不再作为审批业务入口。列表查询进入 `internal/query/refund``internal/query/recharge``internal/query/wecomapproval`
## 九、审批提交
### 9.1 为什么异步提交
本地业务单与企微远程审批无法放在同一个数据库事务中,因此采用本地事务 + Outbox
```text
业务单创建成功
审批实例 status=0
Outbox: WeComApprovalSubmissionRequested
```
Worker 处理:
1. 仅在场景启用且租约为空或已过期时,通过条件更新取得提交租约。
2. 加载不可变模板版本和提交快照。
3. 确认实例引用的模板版本有效且最近验证成功。
4. 从对象存储下载本地附件到临时文件。
5. 逐个调用 `media/upload` 获取临时 `media_id`
6. 按控件映射构建 `apply_data`
7. 使用模板审批人配置,固定 `use_template_approver=1`
8. 调用 `applyevent`
9. 保存 `sp_no`,状态变为审批中并释放租约。
附件的本地对象存储 Key 是权威记录;企微 `media_id` 只是提交期间使用的临时值。
### 9.2 提交结果未知
`applyevent` 网络超时后可能已经在企微创建审批,因此不能无脑重试,否则可能产生重复审批:
- 收到明确 `errcode != 0`:状态改为提交失败,可修正配置后重新提交。
- 建连失败且确认请求未发送:允许自动重试。
- 请求已发送但响应超时/连接中断:状态改为提交结果未知,不自动再次调用 `applyevent`
- 提交结果未知进入异常页面,由管理员在企微核对后选择“确认未创建并重新提交”或“绑定已有 sp_no”。
## 十、回调和轮询
### 10.1 回调路由
```http
GET /api/callback/wecom/approval
POST /api/callback/wecom/approval
```
- GET 按企微协议完成 URL 校验。
- POST 使用 SHA1 签名校验和 AES-CBC 解密。
- 解密后的 `receiveID` 必须等于配置的 CorpID。
- 回调原始密文、解密结果摘要和处理状态写 Integration Log不写文件系统。
- 回调快速保存事件并返回 `success`,实际状态以 `getapprovaldetail` 为准。
### 10.2 轮询补偿
每 2 分钟查询:
```sql
status = 1
AND (last_polled_at IS NULL OR last_polled_at <= NOW() - INTERVAL '2 minutes')
```
单批限制 100 条,按 `last_polled_at` 排序。回调和轮询都调用 `SyncApprovalStatus`
### 10.3 状态同步幂等
```text
1. 根据 sp_no 加载审批实例
2. 调 getapprovaldetail
3. 保存审批详情和审批人快照
4. 使用 version 乐观锁更新状态
5. 状态没有变化时结束
6. 首次进入终态时在同一事务写对应业务终态 Outbox 事件
7. 业务 Worker 调用对应终态用例,失败按错误类型重试
8. business_processed_at 非空时不得重复执行资金动作
```
## 十一、业务状态处理
### 11.1 退款
创建退款时:
- `requested_refund_amount` 已完成合法性校验并固定。
- `approved_refund_amount` 不再由审批接口输入;企微通过时写为申请金额。
- 退款凭证必须先保存本地对象存储,再上传企微副本。
- 财务在系统外完成人工退款并通过企微审批确认结果;本系统不请求任何支付渠道退款 API。
退款状态在现有枚举基础上追加:
| status | 含义 |
|---:|---|
| 1 | 待审批 |
| 2 | 已通过/人工退款已确认 |
| 3 | 已拒绝 |
| 4 | 已退回,仅保留历史兼容,新企微审批不再产生 |
| 5 | 已撤销/审批已删除 |
不得将历史 `4=已退回` 改写为撤销,避免旧数据语义变化。
企微状态处理:
| 企微状态 | 本地退款动作 |
|---|---|
| 通过 | 记录人工退款完成;代理钱包支付订单回溯原扣款代理主钱包;更新订单状态,随后异步佣金回扣和资产处理 |
| 驳回 | 退款状态改为已拒绝,保存审批详情摘要 |
| 撤销/删除 | 退款状态改为已撤销;允许申请人修改后创建下一轮审批 |
| 通过后撤销 | 已退款则不冲正,记录严重异常并通知财务;尚未执行则阻止退款 |
`POST /api/admin/refunds/{id}/approve``reject``return` 下线。
重新申请使用:
```http
POST /api/admin/refunds/{id}/resubmit
```
它只允许已驳回、已撤销或已删除且业务尚未退款的申请,修改业务资料后创建 `round_no + 1` 的企微审批。
审批通过后的业务 Worker 处理失败时,审批实例保持“已通过”,`business_process_result=failed`,由任务重试;前端明确区分“审批已通过”和“退款终态处理失败”。只有退款终态事务成功后才写 `business_processed_at`
现有 `internal/service/refund/service.go``BuyerTypePersonal -> refundAssetWalletPayment` 与本次确认规则冲突,迁移退款用例时删除该分支及其专用退款回款逻辑。个人客户或资产钱包相关订单只记录人工退款结果,不自动增加资产钱包余额;代理钱包支付订单继续按原扣款流水定位并回溯原代理主钱包。
### 11.2 平台员工线下充值
创建线下充值后立即创建企微审批实例,不再暴露本地“确认入账”和“驳回”按钮。
| 企微状态 | 本地充值动作 |
|---|---|
| 通过 | 无操作密码,自动增加代理主钱包余额并写钱包流水 |
| 驳回 | 状态改为已驳回,保存企微审批摘要 |
| 撤销/删除 | 状态改为已关闭,可重新创建充值申请 |
| 通过后撤销 | 已入账不自动扣回,记录严重异常并通知财务 |
`POST /api/admin/agent-recharges/{id}/offline-pay``reject` 下线。
线上微信/支付宝充值不进入企微审批,保持支付回调流程。
审批通过后的入账任务失败时按相同规则重试;只有钱包余额和流水事务成功后才写 `business_processed_at`。若审批先变为通过后撤销且入账任务尚未成功,后续入账任务检测当前状态后终止,不再加钱。
## 十二、API 设计
### 12.1 基础配置状态
```http
GET /api/admin/wecom/config/status
```
返回是否配置、Token 最近获取时间、回调最近成功时间、最近错误,不返回任何密钥。
### 12.2 审批场景状态
```http
GET /api/admin/wecom/approval-scenes
PUT /api/admin/wecom/approval-scenes/{scene_code}/status
```
```json
{
"status": 0,
"reason": "企微模板编辑中"
}
```
暂停和恢复必须写高等级审计。请求暂停后可能先返回 `status=2`,前端轮询场景列表,只有进入 `status=0` 才允许提示运营人员开始编辑企微模板。已有启用版本的场景只允许在暂停状态发布新版本;首次初始化没有当前版本时可直接发布。
### 12.3 读取企微模板
```http
POST /api/admin/wecom/approval-templates/inspect
```
```json
{
"scene_code": "refund_approval",
"template_id": "企微模板ID"
}
```
返回模板名称和可映射控件列表。
### 12.4 发布模板映射
```http
POST /api/admin/wecom/approval-templates/publish
```
```json
{
"scene_code": "refund_approval",
"template_id": "企微模板ID",
"control_mapping": {
"shop_name": "Text-xxx",
"refund_no": "Text-yyy",
"requested_refund_amount": "Money-zzz",
"attachment": "File-aaa"
}
}
```
后端重新读取模板并校验,不能信任前端提交的控件类型和名称。发布成功后在同一事务内停用旧版本、写入新版本、把尚未取得 `sp_no` 且仍为 `status=0` 的实例改绑到新版本、更新场景当前版本并恢复场景;任一步失败都保持暂停,不允许部分切换。
### 12.5 模板版本列表
```http
GET /api/admin/wecom/approval-templates?scene_code=refund_approval
```
### 12.6 账号绑定
```http
GET /api/admin/wecom/account-binding/me
POST /api/admin/wecom/account-binding/sessions
GET /api/admin/wecom/account-binding/sessions/{session_id}
DELETE /api/admin/wecom/account-binding/me
GET /api/admin/wecom/account-bindings?page=1&page_size=20&binding_status=
DELETE /api/admin/wecom/account-bindings/{account_id}
GET /api/callback/wecom/account-binding?code={code}&state={state}
```
创建会话返回:
```json
{
"session_id": "01J...",
"login_url": "https://login.work.weixin.qq.com/wwlogin/sso/login?...",
"expires_at": "2026-07-15T15:05:00+08:00"
}
```
列表接口不返回 Secret 或完整企微配置。普通平台用户只能查询和解绑自己的绑定;绑定列表和强制解绑仅超级管理员可用,强制解绑必须记录高等级审计。
### 12.7 审批记录
```http
GET /api/admin/wecom/approvals
?biz_type=refund
&status=1
&sp_no=
&biz_no=
&page=1&page_size=20
GET /api/admin/wecom/approvals/{id}
POST /api/admin/wecom/approvals/{id}/sync
POST /api/admin/wecom/approvals/{id}/bind-sp-no
```
`bind-sp-no` 仅用于提交结果未知的人工恢复,必须写高等级审计日志。
### 12.8 业务详情响应
退款和充值详情统一增加:
```json
{
"approval": {
"source": "wecom",
"instance_id": 123,
"sp_no": "202607150001",
"status": 2,
"status_name": "已通过",
"template_name": "退款审批",
"round_no": 1,
"creator_name": "张三",
"approvers": [],
"submitted_at": "2026-07-15T10:00:00+08:00",
"status_changed_at": "2026-07-15T10:05:00+08:00",
"business_process_result": "success"
}
}
```
## 十三、前端方案
### 13.1 审批入口变化
- 删除本系统待审批任务、审批节点配置、审批人配置和审批动作页面。
- 退款和线下充值列表不再显示“通过、驳回、退回、确认入账”按钮。
- 创建成功后展示“正在提交企业微信审批”;取得 `sp_no` 后展示“企业微信审批中”。
- 审批操作全部在企业微信完成。
### 13.2 业务详情
退款和充值详情增加只读“企业微信审批”区块:
- 审批单号。
- 当前状态和状态更新时间。
- 模板名称和模板版本。
- 申请人。
- 审批人、审批结果、意见和时间线。
- 业务处理结果。
- “立即同步”图标按钮,仅触发 `getapprovaldetail`,不提供审批按钮。
通过后撤销且业务已执行时使用红色异常状态条,明确显示“资金动作未自动冲正,需人工处理”。
### 13.3 企微配置页
路由:`/system/wecom`
Tab
1. **连接状态**:只显示配置状态和最近连通结果。
2. **审批模板**:按业务场景展示当前版本、模板 ID、验证状态和历史版本。
3. **账号绑定**:查看平台员工绑定状态、企微名称和最近验证时间;不允许编辑 userid。
4. **异常审批**:提交未知、模板失效、终态业务处理失败、通过后撤销。
模板发布交互:
```text
暂停业务场景并等待状态变为“已暂停”
→ 在企微完成模板编辑并取得新 template_id
→ 选择业务场景
→ 输入新 template_id
→ 点击“读取模板”
→ 左侧显示业务字段,右侧下拉选择企微控件
→ 后端校验
→ 发布新版本并自动恢复场景
```
不允许运营人员直接编辑 `control_mapping` JSON。
场景处于暂停状态时必须在退款和线下充值创建页明确显示维护原因,不能等异步提交后才暴露错误。
账号绑定交互:
- 当前用户个人中心显示“未绑定/已绑定/已失效”、企微成员名称和最近验证时间。
- 点击“绑定企业微信”后打开官方企微二维码窗口,原页面显示 5 分钟倒计时。
- 绑定成功后窗口自动关闭,原页面刷新绑定状态;失败时显示企微返回的可操作原因。
- 创建退款或线下充值时发现未绑定,页面原地展示“绑定企业微信”按钮,绑定成功后继续填写,不要求用户先去配置页。
- 管理员的账号绑定列表只提供筛选、查看和强制解绑;不提供 userid 输入框。
### 13.4 审批运行页
列表路由:`/operations/wecom-approvals`
详情路由:`/operations/wecom-approvals/{id}`,复用同一页面并打开详情抽屉,供业务详情和站内通知稳定跳转。
页面用于运行监控,不提供审批动作:
- 按业务类型、企微状态、提交状态和业务处理结果筛选。
- 查询 `sp_no`、业务单号、申请人和模板版本。
- 查看企微审批详情快照和本地业务处理结果。
- 对审批中记录执行“立即同步”。
- 对提交结果未知记录执行“绑定已有 sp_no”或“确认未创建并重新提交”。
- 对业务处理失败记录查看错误和任务重试状态。
## 十四、Token、日志和限流
- Access Token 缓存在 RedisTTL 使用 `expires_in - 300秒`
- 使用 Redis 锁避免多个进程同时刷新 Token。
- `auth/getuserinfo`、读取成员、`gettemplatedetail``applyevent``getapprovaldetail``media/upload` 分别记录接口名称、耗时、errcode、errmsg 和 request_id。
- 日志禁止记录 access_token、Secret、EncodingAESKey、解密密钥和完整附件内容。
- 绑定、换绑、自助解绑、管理员强制解绑写统一审计日志Access Token 获取和企微身份查询写 Integration Log。
- `applyevent` 不做网络层盲目重试;详情查询允许指数退避重试。
- 审批轮询与回调同步共享企微 API 并发限制。
## 十五、存量数据和发布
停机发布:
1. 在企微自建应用配置 Web 登录可信域名和平台员工可见范围。
2. 创建企微模板版本、账号绑定和审批实例表。
3. 发布企微身份 Adapter、账号绑定回调、审批回调、轮询 Worker 和业务终态用例。
4. 要求会发起审批的平台员工完成扫码绑定。
5. 发布并验证退款、线下充值两个模板映射版本。
6. 下线本地审批动作路由和前端按钮。
7. 存量已通过/已拒绝记录保留原业务审批快照,展示 `approval.source=legacy`
8. 存量待审批退款和线下充值仅在创建人已绑定企微时提交;未绑定记录进入迁移待处理列表。
9. 提交失败的存量记录进入异常审批页面,不允许继续走旧接口处理。
## 十六、人工验证
1. 场景进入暂停中后,新退款或充值申请和新提交租约被阻止;进入已暂停时没有旧模板提交仍在执行,历史审批仍可正常同步。
2. 编辑企微模板导致 template_id 变化后,旧模板版本仍可展示,发布新映射时原子切换并恢复场景。
3. 删除再新增控件后,旧 control ID 不会被继续使用。
4. 退款审批通过后按申请金额确认人工退款结果,重复回调和轮询不会重复处理退款终态。
5. 线下充值审批通过后无需操作密码自动入账,重复终态不会重复加钱。
6. 驳回、撤销、删除状态正确同步。
7. 通过后撤销不自动冲正,异常页面、站内消息和审计记录完整。
8. 回调失效时2 分钟轮询可以推进终态。
9. 回调和轮询同时到达时,只有一个处理器执行业务动作。
10. 附件始终保留本地对象存储 Key企微 media_id 失效不影响历史资料下载。
11. Secret、Token、EncodingAESKey 和附件内容不出现在 API、日志和审计详情中。
12. 已登录平台员工扫码后自动获得企微 userid不需要人工查询或录入成员 ID。
13. 绑定 `state` 过期、重复回调、非企业成员扫码时均不会产生绑定。
14. 同一企微 userid 尝试绑定第二个系统账号时被拒绝,原绑定不被覆盖。
15. 自助解绑和管理员强制解绑不影响历史审批快照,但会阻止该账号发起新审批。

View File

@@ -0,0 +1,457 @@
# 新增需求 03站内通知详细方案
> 状态:已合并至标准评审稿,本文保留为实施明细。
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
> 范围:后台账号、代理账号、企业账号和个人客户的站内通知。
## 一、定位
站内通知只负责“用户登录本系统后可以看到的消息”,不承担企业微信审批流程,也不直接发送企业微信消息。
第一版消息来源:
| 来源 | 接收人 | 示例 |
|---|---|---|
| 企微审批结果 | 业务申请人、相关平台角色 | 退款审批通过、线下充值审批驳回 |
| 套餐临期 | 代理账号、企业业务员、个人客户 | 套餐剩余 15/7/3 天 |
| 数据同步异常 | 平台运维角色 | 模板失效、回调找不到资产、连续同步失败 |
| 系统配置异常 | 平台超管 | 企微配置不可用、支付配置失效 |
企微审批中的“待我审批”由企业微信自身提醒,不在本系统重复创建审批待办。
## 二、设计原则
1. 业务事务不直接写 `tb_notification`,关键事件通过 Outbox 可靠投递。
2. 一条事件向多个接收人发送时,每个接收人保存一条独立通知。
3. 使用 `event_id + recipient_kind + recipient_id` 保证重复消费不生成重复消息。
4. 通知正文是发送时快照,使用受控纯文本模板,不保存任意 HTML。
5. 通知只能跳转到受控业务类型,前端不接受后端返回任意 URL。
6. 通知已读状态只属于当前接收人,任何查询和更新都必须带接收人条件。
7. 过期通知不进入列表和未读数,但历史数据按保留策略清理,不由用户删除。
8. 站内通知是简单写模型,使用 Application + Query + Infrastructure不为形式强行创建领域聚合。
9. 后续新增短信、企微消息等外部渠道时使用独立 Delivery不在站内通知 Handler 中追加外部调用。
## 三、流程
```mermaid
sequenceDiagram
participant Biz as 业务 Application
participant DB as 业务数据库
participant Outbox as Outbox
participant Relay as Outbox Relay
participant Worker as Notification Worker
participant Notice as tb_notification
participant Web as 前端
Biz->>DB: 提交业务状态
Biz->>Outbox: 同事务写通知事件
Relay->>Worker: 至少一次投递
Worker->>Worker: 解析接收人和受控模板
Worker->>Notice: 幂等批量插入
Web->>Notice: 查询未读数/列表
Web->>Notice: 当前接收人标记已读
```
非关键系统告警允许直接入 Asynq但必须携带稳定 `event_id`;不能在重试时重新生成。
## 四、数据模型
```sql
CREATE TABLE tb_notification (
id BIGSERIAL PRIMARY KEY,
event_id VARCHAR(64) NOT NULL,
recipient_kind VARCHAR(32) NOT NULL,
recipient_id BIGINT NOT NULL,
category VARCHAR(32) NOT NULL,
type VARCHAR(64) NOT NULL,
severity VARCHAR(16) NOT NULL DEFAULT 'info',
title VARCHAR(200) NOT NULL,
body TEXT NOT NULL DEFAULT '',
ref_type VARCHAR(64),
ref_id BIGINT,
ref_key VARCHAR(128),
is_read BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMPTZ,
expires_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (event_id, recipient_kind, recipient_id)
);
CREATE INDEX idx_notification_recipient_unread
ON tb_notification (recipient_kind, recipient_id, is_read, created_at DESC);
CREATE INDEX idx_notification_recipient_category
ON tb_notification (recipient_kind, recipient_id, category, created_at DESC);
CREATE INDEX idx_notification_expired
ON tb_notification (expires_at)
WHERE expires_at IS NOT NULL;
```
字段说明:
| 字段 | 说明 |
|---|---|
| `recipient_kind` | `account``personal_customer` |
| `category` | `approval / expiry / sync / system` |
| `type` | 具体通知类型常量 |
| `severity` | `info / warning / error / critical` |
| `ref_type` | 受控业务引用类型 |
| `ref_id` | 业务主键,可为空 |
| `ref_key` | 业务编号或资产标识快照,用于列表展示和资源删除后的追溯 |
不在通知表保存接收人名称、店铺名称等实时关联字段;正文已经是发送时快照,查询时也不需要联表才能展示。
## 五、通知类型
```go
const (
NotifyTypeWeComApprovalApproved = "wecom.approval.approved"
NotifyTypeWeComApprovalRejected = "wecom.approval.rejected"
NotifyTypeWeComApprovalCancelled = "wecom.approval.cancelled"
NotifyTypeWeComApprovalRevoked = "wecom.approval.revoked_after_approved"
NotifyTypeWeComTemplateInvalid = "wecom.template.invalid"
NotifyTypePackageExpiring = "package.expiring"
NotifyTypeCardSyncFailed = "card_sync.failed"
NotifyTypeCarrierCallbackCardNotFound = "carrier_callback.card_not_found"
NotifyTypeSystemAlert = "system.alert"
)
```
类别映射由后端常量维护,前端只按返回值展示,不自行猜测:
```go
func NotificationCategory(notifyType string) string
```
## 六、发布命令
```go
type Recipient struct {
Kind string
ID uint
}
type PublishNotificationCommand struct {
EventID string
Recipients []Recipient
Type string
Severity string
TemplateData map[string]any
RefType string
RefID uint
RefKey string
ExpiresAt *time.Time
}
```
Application 只提交通知类型和受控模板数据,不直接提交最终 HTML。Notification Worker 通过代码内模板注册表渲染:
```go
type TemplateRenderer func(data map[string]any) (title string, body string, err error)
var notificationTemplates = map[string]TemplateRenderer{
constants.NotifyTypeWeComApprovalApproved: renderWeComApprovalApproved,
constants.NotifyTypePackageExpiring: renderPackageExpiring,
}
```
模板字段不足时任务失败并重试,禁止生成标题为空或只有业务编号的残缺通知。
## 七、接收人解析
业务 Application 在发布事件前确定稳定业务接收人:
| 场景 | 接收人来源 |
|---|---|
| 退款/充值审批结果 | 申请人账号 ID |
| 通过后撤销 | 申请人 + 财务角色当前启用账号 |
| 企微模板失效 | 平台超管角色账号 |
| 数据同步连续失败 | 平台运维角色账号 |
| 代理套餐临期 | 资产所属代理及配置的业务员账号 |
| 企业套餐临期 | 企业关联业务员账号 |
| 个人客户套餐临期 | 资产当前绑定的个人客户 ID |
角色接收人应在事件消费时批量解析当前启用账号,具体用户接收人直接使用事件快照中的 ID。
不存在接收人时记录消费结果 `no_recipient`,不能无限重试。
## 八、API
后台账号、代理账号和企业账号统一使用 `/api/admin`,后端从登录上下文取得当前账号 ID。
### 8.1 未读总数
```http
GET /api/admin/notifications/unread-count
GET /api/c/v1/notifications/unread-count
```
响应:
```json
{
"code": 0,
"data": {
"count": 12,
"display_count": "12"
}
}
```
超过 99 时 `display_count` 返回 `99+`,前端也必须兼容直接按 count 计算。
查询条件:
```sql
recipient_kind = ?
AND recipient_id = ?
AND is_read = FALSE
AND (expires_at IS NULL OR expires_at > NOW())
```
第一版直接查询 PostgreSQL不额外维护 Redis 未读计数,避免数据库与缓存双写不一致。
### 8.2 分类未读数
```http
GET /api/admin/notifications/unread-summary
```
```json
{
"code": 0,
"data": {
"total": 12,
"categories": {
"approval": 3,
"expiry": 5,
"sync": 2,
"system": 2
}
}
}
```
个人客户第一版只返回总数,不开放运维类分类。
### 8.3 通知列表
```http
GET /api/admin/notifications
?category=approval
&type=
&severity=
&is_read=false
&page=1&page_size=20
GET /api/c/v1/notifications?page=1&page_size=20
```
响应项:
```json
{
"id": 1001,
"category": "approval",
"type": "wecom.approval.approved",
"severity": "info",
"title": "退款审批已通过",
"body": "退款单 RF202607150001 已通过企业微信审批。",
"ref_type": "refund",
"ref_id": 42,
"ref_key": "RF202607150001",
"is_read": false,
"read_at": null,
"created_at": "2026-07-15T10:00:00+08:00"
}
```
列表固定按 `created_at DESC, id DESC` 排序并服务端分页,最大 `page_size=50`
### 8.4 单条标记已读
```http
PUT /api/admin/notifications/{id}/read
PUT /api/c/v1/notifications/{id}/read
```
条件更新:
```sql
UPDATE tb_notification
SET is_read = TRUE, read_at = NOW()
WHERE id = ?
AND recipient_kind = ?
AND recipient_id = ?
AND is_read = FALSE;
```
记录不存在、无权访问或已经已读时统一返回成功,保持幂等且不泄露其他用户通知是否存在。
### 8.5 批量标记已读
```http
PUT /api/admin/notifications/read-all
PUT /api/c/v1/notifications/read-all
```
```json
{
"category": "approval"
}
```
`category` 为空表示当前接收人的全部未过期通知。接口返回本次实际更新数量。
### 8.6 点击并读取跳转信息
前端通常可直接使用列表中的 `ref_type/ref_id`。为了处理业务被删除、权限变化等情况,允许:
```http
GET /api/admin/notifications/{id}/target
```
后端先校验通知属于当前用户,再返回受控目标:
```json
{
"target_type": "refund_detail",
"target_id": 42,
"available": true
}
```
该接口不返回任意 URL。
## 九、受控跳转
| ref_type | target_type | 后台页面 |
|---|---|---|
| `refund` | `refund_detail` | `/refunds/{id}` |
| `agent_recharge` | `agent_recharge_detail` | `/agent-recharges/{id}` |
| `wecom_approval` | `wecom_approval_detail` | `/operations/wecom-approvals/{id}` |
| `iot_card` | `iot_card_detail` | `/iot-cards/{id}` |
| `device` | `device_detail` | `/devices/{id}` |
| `card_sync` | `card_sync_execution` | `/operations/card-sync?execution_id={id}` |
| `system_config` | `system_config` | `/system/config` |
前端维护 `target_type -> route` 映射。未知类型只展示通知,不执行跳转。
跳转目标页面仍按原业务权限重新校验,拥有通知不等于永久拥有业务数据访问权。
## 十、前端方案
### 10.1 顶部铃铛
```text
登录成功/布局挂载
→ 立即请求 unread-count
→ 每 30 秒轮询
→ 页面不可见时暂停
→ 恢复可见时立即刷新
```
- 未读为 0 时不显示数字。
- 199 显示实际数字,超过 99 显示 `99+`
- 请求失败保留上一次数字,不闪回 0。
- 铃铛区域固定宽度,数字变化不得造成导航布局位移。
### 10.2 通知抽屉
点击铃铛打开右侧抽屉:
- 顶部显示“全部、审批、临期、系统”分段控件。
- 默认加载最近 10 条,提供“查看全部消息”。
- 未读消息使用左侧状态点,不用整行高饱和背景。
- 点击消息先本地进入已读视觉状态,再并行调用标记已读和目标解析。
- 标记已读失败时,下次未读轮询恢复服务端真实状态。
### 10.3 通知中心
后台路由:`/notifications`
页面结构:
```text
分类 Tab + 已读状态筛选 + 严重级别筛选
通知时间线列表
全部已读按钮
服务端分页
```
错误和严重通知显示级别图标;普通通知不使用警告色。
个人客户使用简化通知列表,只显示套餐临期、订单和资产相关消息,不显示平台运维消息。
## 十一、权限与数据安全
- Notification Query 从登录 Context 固定注入接收人,不接收前端传入 `recipient_id`
- 后台账号只能查看自己的消息,平台超管也不能通过通知接口查看其他人的消息。
- 运维需要排查通知投递时,使用全局审计/运维查询接口,不复用用户通知列表接口。
- 正文禁止包含操作密码、支付密钥、身份证完整号码、长期对象存储 URL 和完整企微回调正文。
- 附件只提示“包含附件”,用户进入有权限的业务详情后获取短期下载 URL。
- 所有已读接口不区分“通知不存在”和“通知不属于当前用户”。
## 十二、保留与清理
| 通知类型 | 展示期限 | 数据保留期限 |
|---|---:|---:|
| 套餐临期 | 到期后自动不展示 | 180 天 |
| 审批结果 | 不自动过期 | 365 天 |
| 同步异常 | 30 天 | 180 天 |
| 系统告警 | 由事件指定 | 365 天 |
每日低峰期使用现有数据清理任务按批次删除超过保留期限的数据。清理不修改业务审计和领域流水。
## 十三、外部渠道扩展
未来如果需要企微消息或短信,新增独立投递表:
```text
tb_notification_delivery
notification_event_id
recipient
channel
status
attempts
provider_message_id
last_error
```
站内通知写入和外部渠道发送分别消费同一业务事件,互不影响。不能在 `NotificationHandler` 写入站内消息后直接循环调用企微或短信接口,否则某个外部渠道故障会拖慢所有站内消息。
## 十四、代码结构
```text
internal/
├── application/notification/
│ ├── publish_notification.go
│ ├── mark_read.go
│ └── mark_all_read.go
├── query/notification/
│ ├── list_notifications.go
│ ├── unread_count.go
│ └── resolve_target.go
├── infrastructure/messaging/
│ └── notification_event_handler.go
├── infrastructure/persistence/
│ └── notification_repository.go
├── handler/admin/notification.go
└── handler/app/client_notification.go
```
通知没有复杂状态机,不创建空洞的 `domain/notification` 聚合。Application 负责写操作和幂等Query 直接使用 GORM 查询读取模型。
## 十五、人工验证
1. 同一事件重复消费不会为同一接收人生成重复通知。
2. 同一事件的两个接收人各自生成一条通知,已读状态互不影响。
3. 用户无法读取或标记其他用户通知。
4. 过期通知不进入未读数和列表。
5. 全部已读只影响当前用户和指定分类。
6. 业务权限变化后,通知仍可展示,但目标接口重新校验权限。
7. 前端隐藏页签时暂停轮询,恢复后立即刷新。
8. 企微审批待办不重复生成站内待办,审批结果可以正常通知申请人。
9. 外部消息渠道失败不影响站内通知生成。
10. 通知正文、日志和 API 不包含密钥、操作密码或长期附件 URL。

View File

@@ -0,0 +1,809 @@
# 新增需求 04全局多视角审计方案
> 状态:已合并至标准评审稿,本文保留为实施明细。
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
> 范围:全系统业务写操作、安全操作、外部集成和审计查询前端。
## 一、目标
审计系统必须能够从不同视角回答:
```text
谁做了什么?
某个资源经历了什么?
一次请求引发了哪些跨模块变化?
哪些操作失败、被拒绝或存在风险?
外部 Gateway、运营商和企业微信交互发生了什么
资金变化对应哪个业务动作和审批结果?
```
不是把所有日志塞进一张表,而是建立统一审计事件,并通过 Query 组合访问日志、领域流水和外部集成记录。
## 二、当前代码问题
现有实现已经有账号审计和资产审计,但没有形成全系统能力:
- `tb_account_operation_log``tb_asset_operation_log` 字段、操作类型和查询入口不一致。
- 账号、资产审计通过 goroutine Best Effort 写入,进程退出或数据库短暂失败时会丢失。
- 部分调用方又在审计服务外增加一层 goroutine形成双重异步。
- 退款审批等资金操作没有统一业务审计。
- 线下充值借用账号操作日志,资源类型和关联关系不准确。
- 访问日志只脱敏请求体,响应体仍按原文记录,可能泄露 Token、个人信息和敏感配置。
- 现有审计表只能从单个账号或单个资产查看,无法按请求、关联业务、异常等级和外部交互串联。
## 三、四类记录边界
| 类型 | 权威性 | 存储 | 用途 |
|---|---|---|---|
| Access Log | 非业务权威 | 文件/日志平台 | HTTP 调试、性能和请求追踪 |
| Audit Event | 业务审计权威 | PostgreSQL | 谁对什么做了什么、结果如何 |
| Domain Ledger | 领域事实权威 | 现有业务表 | 钱包流水、退款单、审批实例、订单状态 |
| Integration Log | 外部交互权威 | PostgreSQL | Gateway、运营商回调、企微 API、回调和同步尝试 |
规则:
- Audit Event 不替代钱包流水、退款记录和企微审批详情。
- Access Log 不作为业务审计依据。
- 数据同步的轮询、事件阶梯尝试、手动刷新和运营商回调统一进入 `tb_integration_log`,不再新建 `tb_card_sync_execution`
- 高频轮询只有在状态变化、人工强制触发、连续失败或高风险异常时生成 Audit Event。
- Audit Query 可以把四类记录组合成时间线,但必须标明数据来源。
## 四、审计事件模型
### 4.1 主表
```sql
CREATE TABLE tb_audit_event (
id BIGSERIAL PRIMARY KEY,
event_id VARCHAR(64) NOT NULL UNIQUE,
occurred_at TIMESTAMPTZ NOT NULL,
category VARCHAR(32) NOT NULL,
action VARCHAR(128) NOT NULL,
action_name VARCHAR(255) NOT NULL,
actor_kind VARCHAR(32) NOT NULL,
actor_id BIGINT,
actor_name VARCHAR(255) NOT NULL DEFAULT '',
actor_user_type INT,
source VARCHAR(64) NOT NULL,
tenant_type VARCHAR(32),
tenant_id BIGINT,
shop_id BIGINT,
enterprise_id BIGINT,
result VARCHAR(16) NOT NULL,
risk_level VARCHAR(16) NOT NULL DEFAULT 'normal',
summary TEXT NOT NULL,
error_code VARCHAR(64),
error_message TEXT,
before_data JSONB,
after_data JSONB,
metadata JSONB,
request_id VARCHAR(64),
correlation_id VARCHAR(64),
parent_event_id VARCHAR(64),
ip_address VARCHAR(64),
user_agent TEXT,
request_path VARCHAR(255),
request_method VARCHAR(16),
content_hash VARCHAR(64) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_audit_event_time
ON tb_audit_event (occurred_at DESC, id DESC);
CREATE INDEX idx_audit_event_actor
ON tb_audit_event (actor_kind, actor_id, occurred_at DESC);
CREATE INDEX idx_audit_event_action
ON tb_audit_event (category, action, occurred_at DESC);
CREATE INDEX idx_audit_event_result
ON tb_audit_event (risk_level, result, occurred_at DESC);
CREATE INDEX idx_audit_event_request
ON tb_audit_event (request_id, occurred_at ASC);
CREATE INDEX idx_audit_event_correlation
ON tb_audit_event (correlation_id, occurred_at ASC);
```
### 4.2 资源关系表
一次退款审批通过会同时影响退款单、订单、钱包、钱包流水、套餐和资产,只在主表保存一个 `resource_id` 无法完整查询。
```sql
CREATE TABLE tb_audit_event_resource (
id BIGSERIAL PRIMARY KEY,
audit_event_id BIGINT NOT NULL,
resource_type VARCHAR(64) NOT NULL,
resource_id BIGINT,
resource_key VARCHAR(128) NOT NULL DEFAULT '',
relation VARCHAR(20) NOT NULL DEFAULT 'affected',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX uq_audit_event_resource
ON tb_audit_event_resource (
audit_event_id,
resource_type,
COALESCE(resource_id, 0),
resource_key,
relation
);
CREATE INDEX idx_audit_resource_timeline
ON tb_audit_event_resource (resource_type, resource_id, audit_event_id DESC);
CREATE INDEX idx_audit_resource_key
ON tb_audit_event_resource (resource_type, resource_key, audit_event_id DESC);
```
不建立数据库外键。`relation`
| relation | 含义 |
|---|---|
| `primary` | 本次操作的主要对象 |
| `affected` | 被本次操作修改的对象 |
| `reference` | 只用于解释上下文的关联对象 |
### 4.3 操作者
`actor_kind`
```text
admin_user
agent_user
enterprise_user
personal_customer
open_api_account
system_task
carrier_callback
wecom_callback
```
系统和外部回调允许 `actor_id=NULL`,但必须保存清晰的 `actor_name`,例如“移动实名回调”“企微审批状态同步”“套餐到期任务”。
### 4.4 来源
`source` 表示入口,不表示操作者:
```text
admin_api
personal_api
open_api
asynq
scheduled_job
gateway
carrier_callback.cmcc
carrier_callback.cucc
carrier_callback.ctcc
wecom_callback
wecom_polling
data_migration
```
### 4.5 结果与风险
`result``success / failed / denied / partial`
`risk_level``normal / warning / high / critical`
默认风险示例:
| 操作 | 风险等级 |
|---|---|
| 普通字段修改成功 | normal |
| 批量部分失败 | warning |
| 权限拒绝、重复回调冲突 | warning |
| 钱包余额、退款、角色权限、支付配置修改 | high |
| 企微通过后撤销但资金已执行、审计写入失败 | critical |
## 五、领域模型和代码结构
```text
internal/
├── domain/audit/
│ ├── event.go 不可变 AuditEvent 实体及不变量
│ ├── actor.go 操作者值对象
│ ├── resource.go 多资源关联
│ ├── sanitizer.go 审计数据脱敏规则
│ ├── action_registry.go 操作编码、名称、分类和默认风险
│ └── repository.go
├── application/audit/
│ ├── append_event.go 成功/失败审计写入
│ ├── append_with_tx.go 关键业务事务内写入
│ └── append_system_event.go
├── query/audit/
│ ├── event_list.go
│ ├── actor_view.go
│ ├── resource_view.go
│ ├── request_view.go
│ ├── correlation_view.go
│ ├── risk_view.go
│ ├── integration_view.go
│ └── finance_view.go
└── infrastructure/persistence/
└── audit_repository.go
```
Audit Event 具有独立 `event_id`因此是不可变领域实体不是值对象Actor、Resource 和字段差异是值对象。事件创建后不提供 Update/Delete Repository 方法。
## 六、操作注册表
操作编码禁止在各 Service 中自由拼字符串:
```go
type ActionDefinition struct {
Code string
Name string
Category string
DefaultRiskLevel string
SensitiveFields []string
}
var ActionRegistry = map[string]ActionDefinition{
"account.role.assign": {
Name: "分配账号角色", Category: "security", DefaultRiskLevel: "high",
},
"refund.wecom.approved": {
Name: "企微审批通过并确认人工退款", Category: "finance", DefaultRiskLevel: "high",
},
"recharge.wecom.approved": {
Name: "企微审批通过并完成线下充值", Category: "finance", DefaultRiskLevel: "high",
},
"iot_card.realname.callback": {
Name: "运营商回调更新实名状态", Category: "asset", DefaultRiskLevel: "normal",
},
}
```
DTO `description`、前端中文名称和筛选项都从同一注册表生成,避免操作编码与展示名称不一致。
## 七、写入可靠性
### 7.1 关键成功事件
以下事件必须与业务变更同事务写入:
- 钱包余额变化。
- 人工退款结果确认和代理钱包回溯。
- 线下充值入账。
- 账号角色和权限变化。
- 支付、企微和系统关键配置变化。
- 卡实名、网络状态被人工修改。
- 手工绑定企微 `sp_no`
Application 在事务内调用:
```go
auditWriter.AppendWithTx(ctx, tx, event)
```
审计写入失败时事务回滚,禁止业务成功但关键审计缺失。
### 7.2 异步系统事件
轮询、事件阶梯同步、运营商回调和企微轮询等外部交互统一进入 Integration Log。只有以下情况同时写 Audit Event
- 状态发生业务变化。
- 连续失败达到阈值。
- 人工强制触发。
- 出现高风险异常。
系统事件通过同一业务事务或 Outbox 可靠写入,不启动裸 goroutine。
### 7.3 失败和拒绝事件
业务事务已经回滚时,使用独立短事务写 `failed/denied` 审计。审计写入失败不能把原业务错误改成成功,但必须记录 `critical` 应用日志和监控指标。
## 八、关联链路
### 8.1 request_id
表示一次 HTTP 请求,由现有 Request ID 中间件产生。一次请求触发的所有审计事件使用同一个 `request_id`
### 8.2 correlation_id
表示跨请求、跨任务的完整业务链路:
```text
退款申请创建
→ 企微审批提交
→ 企微回调
→ 人工退款结果入账
→ 代理钱包退款流水(仅代理钱包支付订单)
→ 佣金回扣
→ 套餐失效和停机
```
以上事件共享退款申请创建时生成的 `correlation_id`。Asynq、Outbox、企微实例和同步任务载荷都必须传递该值。
### 8.3 parent_event_id
表示直接因果关系。例如“套餐失效”事件的父事件是“退款终态处理成功”,用于前端画出树状链路。
## 九、数据脱敏
### 9.1 永不进入审计的数据
```text
password
operation_password
access_token / refresh_token
agent_secret / app_secret
callback_token / encoding_aes_key
支付私钥、公钥原文
短信验证码
完整身份证号
对象存储签名 URL
企微 media_id
```
这些字段直接删除,不保存为 `******`,避免字段存在本身造成误用。
### 9.2 按权限展示的数据
手机、IP、ICCID、钱包余额和第三方交易号可以存储受控快照但 Query 根据权限返回:
- 普通审计权限:脱敏展示。
- 敏感审计权限:展示完整值。
- 导出权限单独控制,不能因为可查看页面就默认可导出完整值。
### 9.3 大字段
`before_data``after_data``metadata` 分别限制 16KB。超过后保存
```json
{
"_truncated": true,
"_original_bytes": 38210,
"_summary": "批量修改500条资产",
"_artifact_ref": "batch_task:123"
}
```
批量明细保存在对应任务结果表或对象存储,不塞入审计 JSON。
## 十、外部集成日志
企业微信、运营商回调、Gateway 请求和事件同步尝试使用通用 Integration Log。同步尝试即使在真正发送 HTTP 前因合并、限流或预期状态已满足而结束,也写一条结果记录,保证能够解释“为什么没有请求上游”。
```sql
CREATE TABLE tb_integration_log (
id BIGSERIAL PRIMARY KEY,
integration_id VARCHAR(64) NOT NULL UNIQUE,
provider VARCHAR(32) NOT NULL,
direction VARCHAR(16) NOT NULL,
operation VARCHAR(64) NOT NULL,
external_id VARCHAR(128),
resource_type VARCHAR(64),
resource_id BIGINT,
resource_key VARCHAR(128),
trigger_source VARCHAR(32),
trigger_scene VARCHAR(128),
trigger_series VARCHAR(64),
scheduled_at TIMESTAMPTZ,
started_at TIMESTAMPTZ,
attempt INT NOT NULL DEFAULT 1,
result VARCHAR(20) NOT NULL,
http_status INT,
provider_code VARCHAR(64),
provider_message TEXT,
request_summary JSONB,
response_summary JSONB,
duration_ms BIGINT NOT NULL DEFAULT 0,
state_changed BOOLEAN NOT NULL DEFAULT FALSE,
metadata JSONB,
request_id VARCHAR(64),
correlation_id VARCHAR(64),
audit_event_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_integration_log_time
ON tb_integration_log (created_at DESC, id DESC);
CREATE INDEX idx_integration_log_provider
ON tb_integration_log (provider, operation, result, created_at DESC);
CREATE INDEX idx_integration_log_resource
ON tb_integration_log (resource_type, resource_id, created_at DESC);
CREATE INDEX idx_integration_log_resource_key
ON tb_integration_log (resource_type, resource_key, created_at DESC);
CREATE INDEX idx_integration_log_trigger
ON tb_integration_log (trigger_series, attempt);
CREATE INDEX idx_integration_log_correlation
ON tb_integration_log (correlation_id, created_at ASC);
```
`direction``inbound / outbound`
`result` 至少支持:
```text
success
failed
not_found
invalid_payload
ignored
merged
rate_limited
completed
cancelled
```
数据同步字段约定:
- `provider=gateway`:实名、流量、卡状态和设备信息查询。
- `provider=cmcc/cucc/ctcc``direction=inbound`:运营商实名或解除实名回调。
- `trigger_source``polling/business_event/open_api/manual_refresh/carrier_callback`
- `trigger_scene`:触发埋点场景,例如 `client_asset_info``card_resume``device_switch_card`
- `trigger_series + attempt`:关联一次 `0/3/5` 阶梯同步。
- `result=merged/rate_limited/completed` 时允许没有 HTTP 状态和响应摘要,因为没有真正请求上游。
- `state_changed=true` 时通过 `audit_event_id` 关联对应业务状态变化事件。
普通高频轮询成功也写 Integration Log但不生成 Audit Event。查询和清理必须使用时间索引及分批删除不能在业务请求中同步聚合全量历史。
外部交互正文只保存脱敏摘要。企微加密报文、运营商原始回调和 Gateway 加密请求不进入普通审计详情。
## 十一、审计范围
### 11.1 必须审计的写操作
| 模块 | 操作 |
|---|---|
| 账号权限 | 创建、禁用、删除、角色分配、密码重置 |
| 资产 | 分配、回收、绑定、解绑、停复机、实名策略、手工实名、限速 |
| 套餐 | 创建、修改、上下架、分配、价格、已用量和到期时间调整 |
| 钱包资金 | 充值、扣款、退款、信用变化、人工调整 |
| 订单退款 | 创建退款、企微终态、人工退款确认、代理钱包回溯、资产后处理 |
| 线下充值 | 创建、企微终态、入账、通过后撤销异常 |
| 系统配置 | 支付配置、企微配置状态、模板版本发布、导出字段权限 |
| 数据同步 | 人工强制同步、回调更新业务状态、连续失败告警 |
| 导入导出 | 创建任务、下载敏感导出、批量任务结果 |
| 登录安全 | 登录成功/失败、Token 撤销、开放接口签名失败和重放拒绝 |
### 11.2 不进入业务审计的普通读操作
- 普通列表、详情和未读数查询只进入 Access Log。
- 下载敏感附件、导出、查看完整密钥状态、查看完整个人信息属于敏感读取,需要 Audit Event。
## 十二、多视角 API
### 12.1 全局事件视角
```http
GET /api/admin/audit/events
?category=finance
&action=
&result=success
&risk_level=high
&source=wecom_callback
&start_at=
&end_at=
&keyword=
&page=1&page_size=50
```
`keyword` 只匹配事件摘要、资源编号、操作者名称和 request_id不对 JSONB 做无索引模糊扫描。
### 12.2 事件详情
```http
GET /api/admin/audit/events/{event_id}
```
返回:
- 操作者快照。
- 入口、租户和请求信息。
- 主要资源、影响资源和引用资源。
- 前后数据差异。
- 错误信息和风险等级。
- request/correlation/parent 关联。
- 可访问的领域流水和 Integration Log 链接。
### 12.3 操作者视角
```http
GET /api/admin/audit/actors
?actor_kind=admin_user
&keyword=
&range=30d
&page=1&page_size=20
GET /api/admin/audit/actors/{actor_kind}/{actor_id}/summary?range=30d
GET /api/admin/audit/actors/{actor_kind}/{actor_id}/events?page=1&page_size=50
```
人员列表返回操作者 ID、名称、类型、最近操作时间、操作量、失败量和最高风险等级`keyword` 只做 ID、账号和名称的前缀匹配不扫描审计 JSON。Summary 返回操作量、失败量、拒绝量、高风险量、常用操作、常操作资源和最近登录 IP。
### 12.4 资源视角
```http
GET /api/admin/audit/resources/search
?resource_type=
&keyword=RF202607150001
&page=1&page_size=20
GET /api/admin/audit/resources/{resource_type}/{resource_id}/timeline
?include_related=true
&page=1&page_size=50
```
示例资源:`iot_card / device / refund / order / agent_wallet / wecom_approval / account / system_config`
资源搜索属于 Query 模块,允许直接查询退款、订单、充值、账号和资产等读模型,不要求加载领域聚合。返回候选项的 `resource_type/resource_id/resource_key/display_name`,解决同一关键词命中多个资源的问题。静态 `resources/search` 路由必须先于动态资源路由注册。
`include_related=true` 时通过资源关系表返回影响该资源的跨模块事件,但不无限递归加载关联资源。
### 12.5 请求视角
```http
GET /api/admin/audit/requests/{request_id}/timeline
```
按时间展示一次 HTTP 请求产生的 Access Log 摘要、Audit Event、Outbox 和 Integration Log。
### 12.6 业务链路视角
```http
GET /api/admin/audit/correlations/{correlation_id}/timeline
```
用于查看退款、企微审批、钱包和资产处理等跨请求链路。返回节点包含 `parent_event_id`,前端可展示因果树。
### 12.7 风险视角
```http
GET /api/admin/audit/risks/overview?range=24h
GET /api/admin/audit/risks/events?risk_level=critical&page=1&page_size=50
```
Overview
- 高风险和严重事件数量。
- 权限拒绝、开放接口重放、资金冲突和外部回调异常趋势。
- 按操作人、来源和资源类型聚合。
### 12.8 外部集成视角
```http
GET /api/admin/audit/integrations
?provider=wecom
&direction=inbound
&operation=getapprovaldetail
&result=failed
&external_id=
&resource_type=
&resource_key=
&trigger_source=
&trigger_scene=
&trigger_series=
&page=1&page_size=50
GET /api/admin/audit/integrations/{integration_log_id}
```
列表查询 `tb_integration_log`,并返回关联审计事件和业务链路 ID详情返回脱敏后的请求摘要、响应摘要、耗时、错误、尝试次数、计划时间、触发场景和关联资源不返回密钥、完整回调正文或附件内容。
`trigger_series` 有值时详情同时返回该序列的全部尝试前端按“立即、3 分钟、5 分钟”展示,不要求用户逐条搜索。同步记录不再通过独立 `/card-sync/executions` 接口查询。
### 12.9 资金视角
```http
GET /api/admin/audit/finance/timeline
?business_no=
&wallet_id=
&transaction_no=
&page=1&page_size=50
```
Finance Query 组合:
- Audit Event。
- 代理钱包和资产钱包流水。
- 退款单、充值单和订单。
- 企微审批实例。
每条记录明确 `record_source`,钱包流水仍是金额变化的权威数据。
### 12.10 导出
```http
POST /api/admin/audit/exports
```
复用现有导出任务系统。导出字段按角色配置,默认不允许导出完整 IP、手机号、ICCID、前后 JSON 和外部响应摘要。
## 十三、权限
建议权限码:
```text
audit:global:view
audit:actor:view
audit:resource:view
audit:request:view
audit:risk:view
audit:integration:view
audit:finance:view
audit:sensitive:view
audit:export
```
第一版审计中心只开放给超级管理员和具有对应权限的平台角色。
代理、企业账号不进入全局审计中心;它们在资产、订单、充值等业务详情页只能查看自己有权限资源的资源时间线,且返回字段经过脱敏。
Service/Query 必须重新应用资源权限,前端隐藏 Tab 不能替代后端授权。
## 十四、前端多视角界面
### 14.1 审计中心
路由:`/operations/audit`
使用工作台式紧凑布局,不做营销式卡片页面。顶部为时间范围、关键词和常用过滤器,下方使用 Tab
1. **全局事件**:按时间倒序的审计事件表。
2. **人员行为**:操作者列表、风险统计和行为时间线。
3. **资源轨迹**:输入资源类型和标识,展示资源生命周期。
4. **请求链路**:按 request_id/correlation_id 展示跨模块时间线。
5. **资金审计**:订单、审批、钱包流水和退款/充值组合视图。
6. **风险事件**:失败、拒绝、高风险和严重事件。
7. **外部集成**Gateway、企微、运营商回调和事件同步阶梯尝试。
Tab 根据权限返回,前端不展示无权限入口。
外部集成 Tab 增加紧凑的来源切换:`全部 / Gateway 同步 / 运营商回调 / 企业微信`。选择 Gateway 同步后展示触发来源、触发场景、资源、序列、尝试、结果、耗时和状态是否变化;点击序列打开三次尝试时间线。
### 14.2 全局事件表
字段:
```text
时间 | 风险 | 操作者 | 操作 | 主要资源 | 来源 | 结果 | request_id
```
- 支持服务端分页和列筛选。
- 点击行打开右侧详情抽屉。
- 风险仅用图标和有限颜色表达,普通成功事件保持中性色。
- `request_id`、资源编号可点击进入对应视角。
### 14.3 事件详情抽屉
分区:
```text
事件摘要
操作者与入口
关联资源
字段变更对比
请求与业务链路
错误和外部交互
```
字段变更使用结构化键值对比,不直接把整段 JSON 原样堆在页面。未知字段可以折叠显示原始 JSON。
敏感字段默认脱敏;有 `audit:sensitive:view` 权限时提供“显示敏感数据”按钮,并对该次查看再写一条敏感读取审计。
### 14.4 人员行为视角
左侧为操作者搜索和筛选,右侧显示:
- 30 天操作量、失败率、高风险操作数。
- 常用来源 IP。
- 操作类型分布。
- 最近行为时间线。
- 涉及资源列表。
不做基于行为的自动封禁,只提供审计判断依据。
左侧人员列表调用 `/api/admin/audit/actors`,选择人员后再并行加载 Summary 和事件列表,避免前端从全局事件自行聚合。
### 14.5 资源轨迹视角
支持输入 ICCID、设备虚拟号、退款单号、订单号、充值单号、账号名称等先调用 `/api/admin/audit/resources/search` 返回候选资源;只有一个候选时直接打开时间线,多个候选时由用户选择,前端不自行猜测资源类型。
时间线同时展示:
- 业务状态变化。
- 人工操作。
- 企微审批结果。
- 资金流水链接。
- 重要 Gateway/回调事件。
普通高频轮询成功不进入资源审计时间线,避免有效信息被淹没。
### 14.6 请求和业务链路视角
使用纵向时间线展示节点,节点固定包含时间、来源、动作、结果和耗时。父子事件使用缩进和连接线表达,不使用复杂自由拖拽图编辑器。
### 14.7 风险视角
顶部显示 24 小时趋势和风险分类,下方是风险事件表。支持一键跳转操作者、资源和 correlation 链路,但第一版不建设风险处置工单。
## 十五、Access Log 修正
当前访问日志的响应体需要使用与请求体相同的递归脱敏逻辑:
```go
responseBody := sanitizeBody(c.Response().Body())
```
并增加路由级策略:
| 路由 | Body 记录策略 |
|---|---|
| 登录、Token、支付配置 | 只记录字段名和长度,不记录值 |
| 企微/支付/运营商回调 | 记录摘要和哈希,不记录完整正文 |
| 文件上传下载 | 不记录文件内容和签名 URL |
| 普通 JSON API | 脱敏后最多 50KB |
Access Log 建议保留 30 天;不得因为 Access Log 已存在而省略关键业务 Audit Event。
## 十六、保留策略
| 数据 | 保留期限 |
|---|---:|
| 资金、权限、审批和关键配置 Audit Event | 5 年 |
| 普通资产和业务操作 Audit Event | 2 年 |
| Integration Log | 180 天 |
| Gateway 高频同步 Integration Log | 正常 30 天,异常或状态变化 180 天 |
| Access Log | 30 天 |
Audit Event 默认不在线删除。数据量达到单表维护阈值后按月分区,超期分区归档到低成本存储,再由专用维护流程删除;应用账号不具备 Update/Delete 审计表权限。
## 十七、一次性审计切换
本需求与普通 DDD 触碰式迁移不同审计基础设施、写入入口、Query API 和前端多视角界面在同一次停机发布中全局切换,不能让新旧审计长期并行。
### 17.1 新写入
- 数据同步、企微审批、站内通知和后续新功能只写统一 `tb_audit_event``tb_integration_log`
- 账号、角色、权限、资产、套餐、订单、退款、充值、钱包、系统配置、导入导出和登录安全等现有敏感操作同时切换到统一 Audit Writer。
- 卡同步不创建 `tb_card_sync_execution`;轮询、手动刷新、事件阶梯尝试和运营商回调统一写 Integration Log。
- 发布后旧 `tb_account_operation_log``tb_asset_operation_log`、手动轮询触发日志等表不再产生新记录。
- 禁止双写新旧审计表,避免同一操作产生两条不一致记录。
一次性切换审计不等于一次性把所有旧业务模块迁移为 DDD。未迁移的旧 Service 通过统一审计 Adapter 写入新模型;退款、资金、卡状态等复杂且本次触碰的用例按 DDD 规范迁入 Application/Domain。
### 17.2 历史数据
- 旧审计表保留只读,不在线回填到新表。
- 审计 Query 使用 `UNION ALL` 把旧账号、资产和手动触发日志标准化为历史投影,返回 `record_source=legacy_account/legacy_asset/legacy_polling`
- 历史记录只用于查询,不允许更新或补写。
- 后续达到归档条件后离线迁移或归档旧表,不影响本次新写入一次性切换。
## 十八、代码迁移重点
- 删除 `account_audit.Service.LogOperation``asset_audit.Service.LogOperation` 内部裸 goroutine。
- 删除调用方外围 `go auditService.LogOperation(...)`
- 为尚未迁入 DDD 的旧 Service 提供统一 Audit Writer Adapter并在本次发布中替换全部敏感操作旧写入口。
- 退款、线下充值资金事务使用 `AppendWithTx`
- 卡实名领域用例在状态变化事务内写审计。
- 轮询、事件同步、手动刷新和运营商回调统一写 Integration Log只有状态变化、连续失败、手动操作和高风险异常追加 Audit Event。
- 企微模板发布、手工绑定 sp_no、通过后撤销写高风险事件。
- `pkg/logger/middleware.go` 对响应体和特殊路由实施脱敏策略。
- 现有账号、资产和业务日志详情接口统一改为调用 `query/audit` 的资源时间线或历史投影。
## 十九、人工验证
1. 关键资金事务在审计写入失败时整体回滚。
2. 同一事件不会因 Outbox/Asynq 重试生成重复审计。
3. 一次退款能够从退款单、订单、钱包和资产四个资源视角查到同一事件。
4. request_id 能串联一次 API 请求产生的多条审计记录。
5. correlation_id 能串联退款申请、企微审批、退款、佣金和资产处理。
6. 普通用户不能访问全局、人员、风险和集成视角。
7. 敏感字段默认脱敏,查看完整敏感字段的动作本身再次被审计。
8. Access Log 请求和响应均不泄露 Token、Secret、操作密码和签名 URL。
9. 高频轮询成功只进入外部集成视角,不会淹没业务资源时间线,状态变化和连续失败仍可见。
10. 企微通过后撤销且资金已执行时生成 critical 事件,不自动冲正。
11. 历史账号/资产日志可以通过统一审计页面查询,发布后的新写入不再双写旧表。
12. 审计导出字段受角色权限控制,页面可见不等于可导出完整敏感值。
13. 人员、资源和集成视角都有独立查询接口,前端不通过下载全局事件后自行聚合。
14. 同一次事件同步的立即、3 分钟、5 分钟尝试能通过 `trigger_series` 连续展示,合并、限流和提前完成都有可解释结果。
15. 发布后旧账号、资产和轮询日志表不再新增记录,新旧历史仍能在同一审计界面查询。

View File

@@ -0,0 +1,446 @@
# 新增需求 05代理钱包扫码充值
> 状态:已合并至标准评审稿,本文保留为实施明细。
> 评审主文档:`../../7月迭代技术方案-标准评审稿.md`
> 范围:代理在后台使用微信或支付宝扫码充值代理主钱包。
## 一、已确认决策
1. 代理在后台为自己的店铺主钱包充值。
2. 支付方式支持微信扫码和支付宝扫码。
3. 单笔最低充值金额为 100 元,即 `10000` 分。
4. 代理在线充值不进入企业微信审批,支付成功后直接幂等增加代理主钱包余额。
5. 平台员工线下代充值仍按 `02-企业微信审批接入.md` 走企微审批,与本方案隔离。
6. 后端返回支付二维码内容,前端使用现有二维码组件渲染,不由后端生成或保存二维码图片文件。
7. 微信使用 Native 支付,支付宝使用 `alipay.trade.precreate` 当面付预创建。
8. 支付回调、钱包入账、钱包流水和审计必须幂等,重复回调不能重复加钱。
9. 当前代理充值复杂写逻辑迁移到 Application/Domain旧 Service 不再保留另一套在线入账逻辑。
## 二、现状与缺口
现有代码已经具备代理充值记录、微信/富友回调和钱包入账骨架,但还不能满足本需求:
- `CreateAgentRechargeRequest` 只允许 `wechat/offline`,没有支付宝。
- `AgentRechargeMinAmount=1`,当前最低金额是 1 分。
- 创建接口只生成充值记录,没有创建统一 `tb_payment` 支付单,也没有真正向支付渠道预下单获取二维码。
- 微信支付仅保存当前生效支付配置,响应中没有 `code_url`
- 支付宝回调只分发套餐订单和客户资产钱包充值,没有代理充值订单类型。
- `HandlePaymentCallback` 在旧 Service 中直接更新充值单、钱包和流水,业务状态和幂等边界没有收口为独立用例。
- 前端技术方案只写了“保留收款码和支付状态页面”,没有支付方式选择、最低金额、二维码过期和支付成功状态细节。
现有评审中“代理在线充值不审批”的结论保持不变,本次补齐的是扫码支付和最低金额的完整技术方案。
## 三、业务流程
```mermaid
sequenceDiagram
actor Agent as 代理用户
participant Web as 代理后台
participant API as Recharge Application
participant DB as PostgreSQL
participant Pay as 微信/支付宝
participant Callback as 支付回调
participant Wallet as AgentWallet Domain
Agent->>Web: 输入金额并选择支付方式
Web->>API: 创建扫码充值单
API->>DB: 创建充值单和支付单
API->>Pay: Native/PreCreate 预下单
Pay-->>API: 二维码内容
API-->>Web: 返回二维码和过期时间
Web-->>Agent: 展示二维码并轮询支付状态
Agent->>Pay: 扫码完成支付
Pay->>Callback: 异步支付通知
Callback->>API: ConfirmAgentRechargePayment
API->>DB: 校验支付单、金额和状态
API->>Wallet: CreditRecharge
Wallet->>DB: 同事务增加余额、写流水、完成充值单
API-->>Pay: 返回成功
Web->>API: 查询到已完成
Web-->>Agent: 展示最新钱包余额
```
支付成功不创建 `wecom_approval_instance`,充值详情固定返回:
```json
{
"approval_source": "none",
"approval": null
}
```
## 四、DDD 设计
### 4.1 目录
```text
internal/
├── domain/agentwallet/
│ ├── wallet.go 代理主钱包聚合及余额不变量
│ ├── recharge.go 充值单状态转换
│ ├── events.go 充值到账领域事件
│ └── repository.go 钱包与充值聚合仓储接口
├── application/agentrecharge/
│ ├── create_qr_recharge.go 创建充值单和扫码支付
│ ├── confirm_payment.go 支付回调确认并入账
│ ├── close_expired.go 关闭过期未支付充值单
│ └── get_payment_status.go 轻量支付状态查询
├── infrastructure/adapter/payment/
│ ├── wechat_native.go 微信 Native 预下单
│ └── alipay_precreate.go 支付宝当面付预创建
├── infrastructure/persistence/
│ └── agent_recharge_repository.go
└── query/agentrecharge/
├── list.go
└── detail.go
```
### 4.2 领域状态
继续使用现有充值状态,不为在线充值增加审批状态:
| 状态 | 含义 | 在线充值使用方式 |
|---:|---|---|
| 1 | 待支付 | 已创建二维码,等待扫码 |
| 2 | 已支付 | 支付已确认,钱包入账事务处理中 |
| 3 | 已完成 | 钱包余额和流水已完成 |
| 4 | 已关闭 | 超时未支付或主动取消 |
| 5 | 已退款 | 历史或后续人工退款结果 |
| 6 | 已驳回 | 仅旧数据或线下审批兼容,在线充值不产生 |
`status=2` 是短暂业务处理状态。支付回调事务正常完成时直接推进到 `3`;若钱包入账发生可恢复错误,则保持 `2` 并由可靠任务继续处理。
### 4.3 钱包入账不变量
`AgentWallet.CreditRecharge` 必须保证:
- 只允许向目标店铺的 `wallet_type=main` 钱包入账。
- 入账金额必须等于充值单金额和支付单金额。
- 同一充值单只能生成一条成功钱包流水。
- 钱包余额、`version`、充值单状态和钱包流水在同一数据库事务更新。
- 钱包乐观锁冲突时由 Application 重新加载后有限重试,不能重复创建流水。
- 支付渠道成功不等于业务已经完成;只有钱包事务成功后充值单才变为已完成。
## 五、支付预下单
### 5.1 统一支付单
代理在线充值必须创建 `tb_payment` 记录,不能只依赖 `ARCH` 单号前缀判断支付渠道。
新增支付业务类型:
```go
const PaymentOrderTypeAgentRecharge = "agent_recharge"
```
充值单和支付单在同一事务创建:
```text
tb_agent_recharge_record.status = 1
tb_payment.status = 0
tb_payment.order_type = agent_recharge
tb_payment.order_id = recharge_id
tb_payment.payment_method = wechat / alipay
tb_payment.amount = recharge_amount
tb_payment.payment_config_id = 创建时使用的配置ID
```
先完成本地事务,再调用第三方预下单。预下单失败时把支付单标记为失败并关闭本次充值单,代理重新创建,不复用来源不明确的旧二维码。
### 5.2 微信扫码
微信使用 Native 下单:
```text
微信支付 v3 TransactionNative
-> 返回 code_url
```
现有微信 SDK 已包含 `TransactionNative`,需要在项目支付 Adapter 中封装,不在 Handler 直接调用 SDK。
若当前生效支付配置为:
- `wechat`:使用微信 v3 Native。
- `wechat_v2`:补充 v2 Native 统一下单实现。
- `fuiou`:只有现有富友配置明确支持后台扫码产品时才返回微信可用;不支持时前端隐藏微信扫码入口,不擅自用 JSAPI 代替。
### 5.3 支付宝扫码
支付宝使用当前 SDK 已提供的:
```text
alipay.trade.precreate
-> 返回 qr_code
```
不复用现有 WAP 支付 URL。创建时校验当前支付配置中的
```text
ali_app_id
ali_private_key
ali_public_key
ali_notify_url
```
配置不完整时支付宝方式显示为不可用,不能创建只有本地记录而没有有效二维码的充值单。
### 5.4 二维码响应
后端统一返回二维码内容,不返回二维码图片:
```json
{
"recharge_id": 88,
"recharge_no": "ARCH20260715143000000001",
"payment_no": "ARCH20260715143000000001",
"payment_method": "wechat",
"amount": 10000,
"qr_content": "weixin://wxpay/bizpayurl?...",
"expires_at": "2026-07-15T15:00:00+08:00",
"status": 1,
"status_name": "待支付"
}
```
微信返回 `code_url`、支付宝返回 `qr_code`Application 统一映射为 `qr_content`
## 六、支付回调与直接入账
### 6.1 回调校验
微信和支付宝回调继续执行现有签名、安全和金额校验,并增加代理充值分发:
```text
payment.order_type = agent_recharge
-> ConfirmAgentRechargePayment
```
必须校验:
- 支付单存在且支付方式与回调渠道一致。
- `payment_config_id` 与创建支付单时配置一致。
- 回调金额等于支付单和充值单金额。
- 第三方交易号没有被其他支付单占用。
- 充值单属于支付单中的 `order_id`,不能只依赖订单号前缀。
### 6.2 回调事务
```text
1. 按 payment_no 加载支付单
2. 支付单 pending -> paid 条件更新
3. 充值单 1 -> 2 条件更新
4. 加载代理主钱包并执行 CreditRecharge
5. 钱包余额和 version 更新
6. 创建唯一钱包流水
7. 充值单 2 -> 3写 paid_at/completed_at
8. 写 Audit Event
```
幂等键:
```text
agent_recharge:{recharge_id}:credit
```
数据库还需要保证钱包流水 `reference_type=agent_recharge + reference_id=recharge_id` 唯一。Redis 只用于削减重复并发,不能替代数据库幂等。
### 6.3 回调失败恢复
- 第三方校验失败:拒绝回调,不改变业务状态。
- 支付状态已成功、钱包事务失败:充值单保持已支付,写 Outbox/恢复任务继续入账。
- 钱包流水已存在但充值单未完成:恢复任务只补齐充值单状态。
- 重复回调:查询到支付单或充值单已完成后直接返回渠道成功报文。
- 本功能不引入审批,也不会因为支付金额较大转入审批。
## 七、金额与权限
### 7.1 金额
```go
const AgentRechargeMinAmount int64 = 10000
```
- 单位固定为分。
- DTO 使用 `min=10000`Service/Application 必须再次校验。
- 前端输入单位为元,提交前转换为分。
- 最大金额继续沿用现有系统上限,后续调整单独配置。
- 禁止前端通过浮点数直接计算金额,元转分使用字符串或十进制定点处理。
### 7.2 权限
- 代理只能为当前登录账号所属店铺的主钱包充值。
- 后端从登录上下文校验 `shop_id`,不能只相信请求参数。
- 平台和超级管理员可以查看全部充值记录;是否允许代代理发起在线扫码充值保持现有权限。
- 企业账号无权访问代理充值接口。
- 代理不能查看其他店铺的充值单、支付状态或二维码。
## 八、API 设计
### 8.1 可用支付方式
```http
GET /api/admin/agent-recharges/payment-methods
```
响应:
```json
{
"items": [
{"method": "wechat", "name": "微信支付", "enabled": true},
{"method": "alipay", "name": "支付宝", "enabled": true}
],
"min_amount": 10000,
"max_amount": 100000000
}
```
该路由必须先于 `/:id` 动态路由注册。
### 8.2 创建扫码充值
沿用现有接口:
```http
POST /api/admin/agent-recharges
```
```json
{
"shop_id": 101,
"amount": 10000,
"payment_method": "wechat",
"request_id": "01J2RECHARGE..."
}
```
`payment_method` 调整为:
```text
wechat / alipay / offline
```
其中 `offline` 仅平台员工线下代充值使用,并继续走企微审批;代理用户只能选择 `wechat/alipay`
`request_id` 用于防止前端重复点击创建多个二维码订单,同一店铺下建立业务唯一约束或 Redis 防重键。
### 8.3 查询支付状态
```http
GET /api/admin/agent-recharges/{id}/payment-status
```
```json
{
"status": 3,
"status_name": "已完成",
"payment_status": 1,
"payment_status_name": "已支付",
"paid_at": "2026-07-15T14:35:00+08:00",
"completed_at": "2026-07-15T14:35:01+08:00",
"wallet_balance": 510000
}
```
该接口只返回当前登录代理有权访问的充值单,不返回支付密钥、签名参数或其他店铺余额。
## 九、前端方案
### 9.1 入口
代理后台钱包页面保留“充值”按钮,点击后打开充值弹窗或抽屉,不新增营销页面。
控件:
- 金额使用数字输入框,单位为元,明确最低 100 元。
- 支付方式使用微信/支付宝分段控件,带对应图标。
- 不可用渠道禁用并显示简短原因。
- 主按钮为“生成支付二维码”。
### 9.2 二维码状态
创建成功后展示:
```text
充值金额
支付方式
二维码
二维码剩余有效时间
支付状态
取消/重新生成
```
- 前端使用 `qr_content` 生成二维码,不请求后端图片文件。
- 页面可见时每 3 秒查询一次轻量支付状态。
- 页面隐藏时暂停轮询,恢复可见时立即查询。
- 状态变为已完成、已关闭或离开页面时停止轮询。
- 二维码过期后禁用原二维码,提供“重新生成”命令,重新创建充值单和支付单。
- 支付完成后关闭二维码区域,刷新钱包余额并展示充值成功结果。
- 全流程不展示审批状态或企微审批区块。
### 9.3 列表和详情
代理充值列表增加支付方式和支付状态:
```text
充值单号 | 金额 | 支付方式 | 支付状态 | 充值状态 | 创建时间 | 完成时间
```
在线充值详情返回 `approval_source=none`。平台员工线下代充值详情继续展示企微审批信息,两类记录按 `payment_method` 区分。
## 十、审计与通知
统一审计至少记录:
```text
代理创建充值单
支付预下单成功/失败
微信/支付宝支付回调成功/失败
钱包入账成功/失败
重复回调被幂等忽略
充值单超时关闭
```
支付渠道交互写 `tb_integration_log`,钱包余额变化写关键 `Audit Event`,并关联充值单、支付单、代理钱包和钱包流水。
在线充值不产生审批通知。充值成功后可以生成普通资金结果站内通知,但不能显示“审批通过”。
## 十一、代码迁移范围
### 11.1 必须修改
- `AgentRechargeMinAmount``1` 调整为 `10000`
- `CreateAgentRechargeRequest.payment_method` 增加 `alipay`,金额校验改为 `min=10000`
- 创建代理在线充值时同时创建 `tb_payment` 记录。
- 新增 `PaymentOrderTypeAgentRecharge`
- 微信支付 Adapter 增加 Native 预下单。
- 支付宝 Adapter 增加 `TradePreCreate`
- 支付宝回调增加代理充值分发。
- 微信/富友代理充值回调统一改为按支付单分发,不只依赖 `ARCH` 前缀。
-`agent_recharge.Service.HandlePaymentCallback` 迁入 `ConfirmAgentRechargePayment` 用例。
- 前端增加支付方式查询、二维码展示和支付状态轮询。
### 11.2 保持不变
- 代理在线充值不进入企微审批。
- 平台员工线下代充值继续走企微审批。
- 代理只能充值自己的店铺主钱包。
- 钱包流水仍是资金变化权威记录。
- 现有支付回调验签和金额校验原则保持不变。
## 十二、发布与人工验证
停机发布API 与回调服务同时切换:
1. 验证 99.99 元被后端拒绝100 元可以创建充值单。
2. 验证代理只能为自己的店铺创建微信或支付宝充值。
3. 验证微信 Native 返回有效 `code_url`,前端能够扫码支付。
4. 验证支付宝 PreCreate 返回有效 `qr_code`,前端能够扫码支付。
5. 验证支付回调通过支付单类型分发到代理充值用例。
6. 验证微信、支付宝回调金额不一致时不会增加钱包余额。
7. 验证支付成功后不创建企微审批实例,直接完成钱包入账。
8. 验证重复回调只产生一条钱包流水,余额只增加一次。
9. 验证支付成功但钱包事务暂时失败时能够恢复完成,不需要代理重复支付。
10. 验证二维码过期后充值单关闭,旧二维码不能继续显示为有效。
11. 验证代理充值详情固定返回 `approval_source=none`,不展示审批区域。
12. 验证平台员工线下代充值仍按原企微审批方案执行,不受在线充值改造影响。

View File

@@ -0,0 +1,72 @@
# 禅道补充需求:换货归属与资产换货标识
> 来源:禅道 #98、#86。最终口径以标准评审稿为准。
## 一、现状
当前换货要求新旧资产已经属于同一店铺;归属不一致时直接拒绝。换货完成只更新资产状态,没有把旧资产店铺归属赋给平台库存中的新资产。
资产已有 `asset_status``generation`,换货单也保存新旧资产 ID但资产详情没有统一返回前代和后代资产关系。
## 二、换货归属规则
- 新资产处于平台库存或已经属于旧资产店铺时可以换入。
- 新资产已经属于其他店铺时拒绝换货,不能覆盖其他代理资产。
- 换货完成事务内,新资产继承旧资产 `shop_id` 和租户标签,并写资产分配记录。
- 旧资产保留原 `shop_id`,状态改为已换货,继续用于历史订单、退款和换货查询。
- 前端不提供目标店铺选择;选择新资产后展示将继承的店铺名称。
处理顺序:
```text
锁定换货单和新旧资产
-> 校验新资产库存及归属
-> 新资产继承旧资产店铺
-> 迁移客户绑定、钱包和套餐资料
-> 更新新旧资产状态
-> 完成换货单
-> 写资产分配记录和审计事件
```
任一步失败整笔事务回滚。
## 三、资产详情换货链
卡和设备详情统一返回:
```json
{
"exchange_trace": {
"previous_asset": {
"asset_type": "iot_card",
"asset_id": 1001,
"identifier": "89860...001",
"exchange_no": "EXC202607170001"
},
"next_asset": {
"asset_type": "iot_card",
"asset_id": 1003,
"identifier": "89860...003",
"exchange_no": "EXC202607180001"
}
}
}
```
- 有前代时显示“换货新资产”。
- 有后代时显示“已换出旧资产”。
- A→B→C 场景中B 可以同时展示前代 A 和后代 C。
- 关联资产必须再次经过后端数据权限校验;无权限时只返回标识,不返回可跳转 ID。
复用 `tb_exchange_order`,补充按旧资产查询已完成换货的方法,以及 `new_asset_type + new_asset_id + status` 查询索引,不新增关系表。
## 四、接口与前端
现有卡、设备详情接口增加 `exchange_trace`,不新增独立换货链接口。
前端在资产基础信息附近展示状态标签和关联资产链接。链接进入对应卡或设备详情,不直接跳换货单;换货单号作为辅助信息展示。
## 五、审计
记录旧资产、新资产、换货单、原店铺、新店铺、状态变更和操作人。新资产归属继承属于关键资产变更,必须与换货事务同时写 Audit Event。

View File

@@ -0,0 +1,81 @@
# 禅道补充需求:店铺业务员与钱包余额预警
> 来源:禅道 #96、#97。最终口径以标准评审稿为准。
## 一、边界
店铺业务员只表示平台内部的业务归属,用于列表筛选和通知接收。它不建立代理分销关系,不参与店铺上下级、佣金、提现或数据权限计算。
钱包预警阈值固定为 100 元,不做系统配置、角色配置或店铺配置。
## 二、店铺业务员
`tb_shop` 增加:
```text
business_owner_account_id BIGINT NULL
```
- 只允许绑定启用的平台账号。
- 创建、编辑店铺时可不选择。
- 店铺列表支持按业务员筛选,列表和详情返回账号名称。
- 业务员停用或删除后保留关联和历史审计,但不再作为通知接收人;运营可以重新绑定。
- 不建立数据库外键Application 显式校验账号类型和状态。
API 调整:
```text
POST /api/admin/shops
PUT /api/admin/shops/{id}
GET /api/admin/shops?business_owner_account_id={id}
```
## 三、固定 100 元余额预警
固定常量:
```go
const AgentWalletLowBalanceThreshold int64 = 10000 // 代理主钱包现金余额预警阈值100元
```
只计算代理主钱包现金可用余额:
```text
cash_available = balance - frozen_balance
```
信用额度不参与预警。触发条件:
```text
before_cash_available > 10000
after_cash_available <= 10000
```
接收人:
- 店铺启用的主账号。
- 店铺绑定且仍启用的平台业务员。
本期只发送站内通知,不发送企业微信消息。
## 四、防重复
- 首次跌至 100 元及以下时发送一次。
- 持续低于阈值时,后续扣款不重复通知。
- 余额充值到 100 元以上后重新布防,再次跌破可以重新发送。
- 通知使用钱包 ID、预警轮次和接收人组成稳定业务键。
可以在主钱包保存 `low_balance_alert_active`,或者使用通知状态投影记录当前预警轮次;无论实现方式如何,资金事务不能因为通知失败而回滚,事件先写 Outbox。
## 五、前端
- 店铺创建、编辑增加可搜索的平台业务员下拉框。
- 店铺列表和详情展示业务员,并支持筛选。
- 资金概况在现金可用余额小于等于 100 元时显示红色“现金余额不足 100 元”。
- 不展示阈值编辑入口。
- 通知点击后受控跳转到对应店铺资金概况。
## 六、审计
记录业务员绑定变更、预警触发时的余额/冻结金额、接收人、通知事件 ID 和失败原因。信用额度不得写入余额预警判断结果。

View File

@@ -0,0 +1,81 @@
# 禅道补充需求:代理系列套餐批量授权
> 来源:禅道 #43。最终口径以标准评审稿为准。
## 一、现状与范围
系统已经存在代理系列授权和套餐授权数据,后端 `PUT /api/admin/shop-series-grants/{id}/packages` 也已经接受套餐数组。本需求不是重建授权模型,而是补齐套餐候选 Query 和前端批量交互。
当前主要问题:
- 前端只能逐个授权套餐。
- 首次授权系列和后续管理套餐时,看不出哪些套餐已经授权给当前代理。
- 公司成本价、代理授权成本价和建议售价缺少明确区分。
## 二、套餐候选 Query
新增:
```text
GET /api/admin/shop-series-grants/{id}/package-options
```
查询参数支持套餐名称、编码和授权状态。响应:
```json
{
"package_id": 1001,
"package_name": "移动月包",
"package_code": "CMCC-M01",
"company_cost_price": 5000,
"authorized_cost_price": 6500,
"suggested_retail_price": 8800,
"is_authorized": true,
"shelf_status": 1,
"status": 1
}
```
金额单位统一为分。`authorized_cost_price` 在未授权时返回 `null`
## 三、批量授权
继续使用现有批量写接口:
```text
PUT /api/admin/shop-series-grants/{id}/packages
```
请求一次提交多个套餐:
```json
{
"packages": [
{"package_id": 1001, "cost_price": 6500},
{"package_id": 1002, "cost_price": 7000}
]
}
```
- 同一请求内套餐 ID 必须去重。
- 已经授权且价格相同的套餐按幂等成功处理。
- 已经授权但价格不同的套餐不能被“新增授权”静默改价,应走明确的价格更新操作。
- 套餐必须属于当前授权系列,后端不能只相信前端候选列表。
- 批量写入在同一事务完成,任一套餐不合法则整批失败。
## 四、前端
首次创建系列授权和后续“管理套餐”共用同一个套餐选择组件:
- 表格使用复选框多选。
- 展示套餐名称、编码、公司成本价、当前授权成本价和建议售价。
- 已授权套餐显示“已授权”标签并置灰,不可选择。
- 支持只看未授权套餐和关键词搜索。
- 提交前显示本次新增套餐数量和价格摘要。
已授权置灰只是交互反馈,后端仍负责幂等和归属校验。
## 五、权限与审计
公司成本价属于敏感字段,只有具备系列授权和成本价查看权限的角色可以读取。审计记录代理、系列、套餐列表、授权价格、操作人和变更前后数据。

View File

@@ -0,0 +1,33 @@
# 批量购买套餐脚本功能总结
## 功能说明
新增 Python 运维脚本 `scripts/batch_package_purchase/batch_purchase.py`,用于读取单列 CSV并逐资产调用后台 `POST /api/admin/orders` 接口购买同一个套餐。
## 输入与配置
- CSV 每行一个 ICCID 或虚拟号,首行表头可选。
- `--base-url` 配置接口地址。
- `--package-id` 指定本批次统一购买的套餐 ID。
- 认证支持已有 Access Token或使用后台账号自动登录获取 Token。
- 支付方式固定为 `wallet`
## 安全控制
- 默认仅预演,显式增加 `--execute` 后才会真实下单。
- 下单前统一校验 CSV重复资产会阻断整批执行。
- 每个资产独立调用一次接口,结果逐条刷新到 CSV。
- POST 请求不自动重试,避免响应丢失造成重复购买。
- Token 和密码支持环境变量传入,不写入结果文件。
## 输出
结果 CSV 包含:
- CSV 原始行号和资产标识。
- 成功或失败状态。
- HTTP 状态码和业务错误码。
- 接口消息。
- 成功订单的订单 ID、订单号和订单金额。
脚本执行结束时,成功数量与输入总数一致则退出码为 `0`;存在失败或中途因认证失效停止时退出码为 `2`参数、CSV 或登录错误时退出码为 `1`

View File

@@ -0,0 +1,182 @@
# 技术方案评审规范
> 适用范围:新功能、复杂业务改造、跨模块变更、基础设施建设和需要多人评审的技术方案。
> 核心目标:让评审人能够判断“为什么这样做、是否能落地、失败后怎么办”,而不是仅看到表结构和代码清单。
---
## 一、方案分级
技术方案不要求统一篇幅,也不要求每个需求都强行画图。
| 等级 | 典型场景 | 最低要求 |
|------|----------|----------|
| 简化方案 | 单字段、单表 CRUD、局部筛选、纯展示修复 | 背景、改动范围、接口/字段、前端影响、兼容性、人工验收点 |
| 标准方案 | 新接口、异步任务、跨表写入、配置化、第三方调用 | 简化方案全部内容 + 关键流程图 + 数据与异常处理 + 发布回滚 |
| 完整方案 | 金额、审批、状态机、并发、跨模块事务、可靠事件、公共基础设施 | 标准方案全部内容 + 系统上下文图 + 状态图/时序图 + 前后端方案 + 风险与待决策项 |
以下场景即使代码量不大,也必须使用完整方案:
- 涉及资金、余额、信用额度、退款或充值。
- 涉及审批、权限、审计或敏感数据导出。
- 涉及异步任务、消息投递、最终一致性或第三方回调。
- 修改公共基础设施或多个业务模块共同依赖的契约。
---
## 二、文档状态
需要进入评审的文档,开头必须标明状态:
```text
状态:草稿 | 待评审 | 评审通过 | 已废弃
负责人:姓名或角色
评审人:后端、前端、产品、验收负责人、运维(按实际参与方)
最后更新YYYY-MM-DD
关联需求:需求编号或 OpenSpec change
```
状态含义:
- `草稿`:允许存在未收敛内容,不可直接进入开发。
- `待评审`:方案作者认为信息已经完整,待评审人确认。
- `评审通过`:关键决策和待办已有结论,可以进入实施。
- `已废弃`:保留历史原因,并链接替代方案。
存在评审阻塞项时,禁止标记为“评审通过”。
---
## 三、必备内容
### 3.1 背景、目标与非目标
必须回答:
- 当前问题是什么,谁受到影响。
- 本次要达成的业务结果和可观察结果是什么。
- 本次明确不做什么,为什么不做。
- 当前系统有哪些不能忽略的约束。
### 3.2 现状与改造范围
- 标出当前入口、主要调用链、现有表和已有接口。
- 列出新增、修改、废弃的模块和接口。
- 说明是否触发 DDD 迁移,以及选择复杂写、简单写或 Query 通道的依据。
- 禁止只写理想结构而不说明与旧代码如何共存。
### 3.3 架构与关键流程
按问题选择图,不为凑数量画图:
| 图 | 适用问题 |
|----|----------|
| 系统上下文图 | 谁调用谁、系统边界和外部依赖是什么 |
| 组件图 | Handler、Application、Domain、Repository、队列等如何协作 |
| 流程图 | 条件分支、人工操作和业务步骤如何推进 |
| 状态图 | 哪些状态允许转换,哪些是终态 |
| 时序图 | 事务边界、异步事件、回调和失败重试的先后顺序 |
| 数据关系图 | 新增多张表且关系仅靠文字难以理解 |
图使用 Mermaid图下必须补充关键约束。图不能替代状态枚举、接口字段和异常规则。
### 3.4 后端技术方案
至少包含:
- 领域或模块边界,以及核心不变量归属。
- 数据模型、索引、唯一约束、迁移和历史数据处理。
- API 请求、响应、错误码、权限、分页和兼容策略。
- 事务边界、并发控制、幂等策略和失败重试。
- 异步任务的载荷、状态、重试、死信或人工补偿入口。
- 查询性能、N+1 风险和必要的批量查询方案。
- 审批类方案必须明确审批意见、附件、必填规则、不可修改审计和下载权限,不能只设计状态字段。
### 3.5 前端技术方案
不能只写“前端新增按钮”或“前端展示字段”。至少说明:
- 涉及哪些端后台管理、代理端、企业端、C 端 H5/公众号。
- 页面入口、建议路由、页面间跳转和权限控制。
- API 模块、请求参数、响应状态和刷新策略。
- 表单、列表、详情、弹框、上传和异步任务的交互状态。
- 加载、空数据、重复提交、部分成功、失败、重试和过期状态。
- 金额单位、时间格式、枚举显示和后端字段的唯一口径。
- 滚动发布时说明新旧前后端兼容方式;停机发布时说明维护页、版本一致性和开放访问前检查。
当前仓库不包含前端源码时,方案可以保持框架无关,但必须标明需要前端仓库确认的实现映射,不能凭空指定状态库或组件库。
### 3.6 非功能设计
按实际风险覆盖:
- 权限和数据范围。
- 敏感字段与导出控制。
- 审计日志。
- 性能目标和容量假设。
- 日志、指标、告警和人工排查入口。
- 外部依赖不可用时的降级策略。
### 3.7 发布、回滚与兼容
至少回答:
- 数据库、后端、Worker、前端和配置按什么顺序发布。
- 是否需要先加字段、双写、回填、迁移存量进行中业务或兼容旧接口。
- 如何关闭新入口或停用新流程。
- 回滚时哪些数据不能删除,哪些状态需要人工处理。
- 滚动发布时说明新旧版本并存风险;停机发布时说明如何阻止新写入、迁移存量任务并确认整套版本一致。
### 3.8 风险、待决策与验收
- 风险必须写触发条件、影响和应对方式。
- 待决策项必须给出推荐方案、备选方案和最晚确认时间。
- 验收使用本项目允许的人工方式接口调用、PostgreSQL 数据核对、日志检查和页面操作。
- 不得用“后续再看”掩盖会影响数据模型、接口契约或安全边界的问题。
---
## 四、评审检查清单
### 业务与范围
- [ ] 目标、非目标和验收结果明确。
- [ ] 原始需求中的歧义已经决策或明确列为阻塞项。
- [ ] Phase 1 与后续阶段边界清晰。
### 架构与数据
- [ ] 现状调用链和目标调用链都能解释。
- [ ] DDD 使用有业务理由,没有为简单 CRUD 强行建模。
- [ ] 状态、事务、并发、幂等和最终一致性闭环。
- [ ] 审批意见、附件和业务凭证边界明确,历史操作不可被覆盖。
- [ ] PostgreSQL DDL、索引和历史数据策略可执行。
- [ ] 存量进行中业务有明确的回填、结束或人工处理方案,不会因旧入口下线而悬空。
### 前端与接口
- [ ] 前端页面、状态和异常分支完整。
- [ ] API 路径使用项目真实前缀,字段和枚举一致。
- [ ] 权限不依赖前端隐藏按钮。
- [ ] 异步任务和审批完成后有明确刷新或轮询策略。
### 上线与运维
- [ ] 发布顺序、功能开关或停用方式明确。
- [ ] 回滚不会删除历史审批、资金流水或审计记录。
- [ ] 关键失败能够通过日志、任务状态或管理页面定位。
---
## 五、合格标准
“业内合格”不等于文档很长,而是评审人能基于文档回答以下问题:
1. 方案是否解决了正确的问题。
2. 前后端和外部系统的边界是否一致。
3. 正常路径、异常路径和并发路径是否闭环。
4. 数据是否可追溯,资金和权限是否安全。
5. 能否分阶段发布,失败后能否止损和恢复。
6. 实施人员是否还需要自行猜测关键业务规则。
任一关键问题仍需实施人员自行猜测时,方案只能保持“待评审”,不能视为评审通过。

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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 资产标识符路径参数请求

View File

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

View File

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

View 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
}

View 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)
}
}

View File

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

View File

@@ -67,6 +67,16 @@ func (s *DeviceImportTaskStore) UpdateStatus(ctx context.Context, id uint, statu
return s.db.WithContext(ctx).Model(&model.DeviceImportTask{}).Where("id = ?", id).Updates(updates).Error
}
// UpdateProgress 实时更新导入进度计数(不更新详情列表,用于批量处理中间状态)
func (s *DeviceImportTaskStore) UpdateProgress(ctx context.Context, id uint, successCount, skipCount, failCount int) error {
return s.db.WithContext(ctx).Model(&model.DeviceImportTask{}).Where("id = ?", id).Updates(map[string]any{
"success_count": successCount,
"skip_count": skipCount,
"fail_count": failCount,
"updated_at": time.Now(),
}).Error
}
func (s *DeviceImportTaskStore) UpdateResult(ctx context.Context, id uint, totalCount, successCount, skipCount, failCount, warningCount int, skippedItems, failedItems, warningItems model.ImportResultItems) error {
updates := map[string]any{
"total_count": totalCount,

View File

@@ -72,6 +72,16 @@ func (s *IotCardImportTaskStore) UpdateStatus(ctx context.Context, id uint, stat
return s.db.WithContext(ctx).Model(&model.IotCardImportTask{}).Where("id = ?", id).Updates(updates).Error
}
// UpdateProgress 实时更新导入进度计数(不更新详情列表,用于批量处理中间状态)
func (s *IotCardImportTaskStore) UpdateProgress(ctx context.Context, id uint, successCount, skipCount, failCount int) error {
return s.db.WithContext(ctx).Model(&model.IotCardImportTask{}).Where("id = ?", id).Updates(map[string]interface{}{
"success_count": successCount,
"skip_count": skipCount,
"fail_count": failCount,
"updated_at": time.Now(),
}).Error
}
func (s *IotCardImportTaskStore) UpdateResult(ctx context.Context, id uint, successCount, skipCount, failCount int, skippedItems, failedItems model.ImportResultItems) error {
updates := map[string]interface{}{
"success_count": successCount,

View File

@@ -301,6 +301,40 @@ func (s *PackageUsageStore) ListByCarrier(ctx context.Context, carrierType strin
return usages, nil
}
// GetCurrentGenerationUsagesForSummary 查询当前世代内所有已激活过的套餐记录(含加油包),用于流量汇总。
// 当前世代定义status∈{1,2,3,4} 中 generation 最大值所在的那一批记录。
// 返回计算所需字段DataUsageMB、DataLimitMB、VirtualTotalMBSnapshot、DisplayGainRatioSnapshot、EnableVirtualDataSnapshot。
func (s *PackageUsageStore) GetCurrentGenerationUsagesForSummary(ctx context.Context, carrierType string, carrierID uint) ([]*model.PackageUsage, error) {
var usages []*model.PackageUsage
// 子查询取当前世代编号
subQuery := s.db.WithContext(ctx).Model(&model.PackageUsage{}).
Select("MAX(generation)").
Where("status IN ?", []int{
constants.PackageUsageStatusActive,
constants.PackageUsageStatusDepleted,
constants.PackageUsageStatusExpired,
constants.PackageUsageStatusInvalidated,
})
subQuery = applyPackageUsageCarrierFilter(subQuery, carrierType, carrierID)
query := s.db.WithContext(ctx).Model(&model.PackageUsage{}).
Select("data_usage_mb, data_limit_mb, virtual_total_mb_snapshot, display_gain_ratio_snapshot, enable_virtual_data_snapshot").
Where("status IN ?", []int{
constants.PackageUsageStatusActive,
constants.PackageUsageStatusDepleted,
constants.PackageUsageStatusExpired,
constants.PackageUsageStatusInvalidated,
}).
Where("generation = (?)", subQuery)
query = applyPackageUsageCarrierFilter(query, carrierType, carrierID)
if err := query.Find(&usages).Error; err != nil {
return nil, err
}
return usages, nil
}
func applyPackageUsageCarrierFilter(query *gorm.DB, carrierType string, carrierID uint) *gorm.DB {
if carrierType == "iot_card" {
return query.Where("iot_card_id = ?", carrierID)

View File

@@ -85,7 +85,18 @@ func (h *DeviceImportHandler) HandleDeviceImport(ctx context.Context, task *asyn
return asynq.SkipRetry
}
if importTask.Status != model.ImportTaskStatusPending {
switch importTask.Status {
case model.ImportTaskStatusPending:
// 正常首次处理
case model.ImportTaskStatusProcessing:
// 上次 worker 中途中断(重启/崩溃),重置计数重新处理
// 已入库的设备会被 ExistsByDeviceNoBatch 识别为 skip不会重复写入
h.logger.Warn("检测到导入任务上次处理中途中断,重置进度重新处理",
zap.Uint("task_id", payload.TaskID),
)
h.importTaskStore.UpdateProgress(ctx, importTask.ID, 0, 0, 0)
default:
// 已完成或失败,不重复处理
h.logger.Info("导入任务已处理,跳过",
zap.Uint("task_id", payload.TaskID),
zap.Int("status", importTask.Status),
@@ -185,6 +196,8 @@ func (h *DeviceImportHandler) processImport(ctx context.Context, task *model.Dev
end := min(i+deviceBatchSize, len(rows))
batch := rows[i:end]
h.processBatch(ctx, task, batch, result)
// 每批完成后实时更新进度计数,让前端可以看到处理进度
h.importTaskStore.UpdateProgress(ctx, task.ID, result.successCount, result.skipCount, result.failCount)
}
return result

View File

@@ -41,15 +41,14 @@ type PollingCallback interface {
}
type IotCardImportHandler struct {
db *gorm.DB
redis *redis.Client
importTaskStore *postgres.IotCardImportTaskStore
iotCardStore *postgres.IotCardStore
assetWalletStore *postgres.AssetWalletStore
assetIdentifierStore *postgres.AssetIdentifierStore
storageService *storage.Service
pollingCallback PollingCallback
logger *zap.Logger
db *gorm.DB
redis *redis.Client
importTaskStore *postgres.IotCardImportTaskStore
iotCardStore *postgres.IotCardStore
assetWalletStore *postgres.AssetWalletStore
storageService *storage.Service
pollingCallback PollingCallback
logger *zap.Logger
}
func NewIotCardImportHandler(
@@ -58,21 +57,19 @@ func NewIotCardImportHandler(
importTaskStore *postgres.IotCardImportTaskStore,
iotCardStore *postgres.IotCardStore,
assetWalletStore *postgres.AssetWalletStore,
assetIdentifierStore *postgres.AssetIdentifierStore,
storageSvc *storage.Service,
pollingCallback PollingCallback,
logger *zap.Logger,
) *IotCardImportHandler {
return &IotCardImportHandler{
db: db,
redis: redis,
importTaskStore: importTaskStore,
iotCardStore: iotCardStore,
assetWalletStore: assetWalletStore,
assetIdentifierStore: assetIdentifierStore,
storageService: storageSvc,
pollingCallback: pollingCallback,
logger: logger,
db: db,
redis: redis,
importTaskStore: importTaskStore,
iotCardStore: iotCardStore,
assetWalletStore: assetWalletStore,
storageService: storageSvc,
pollingCallback: pollingCallback,
logger: logger,
}
}
@@ -95,7 +92,18 @@ func (h *IotCardImportHandler) HandleIotCardImport(ctx context.Context, task *as
return asynq.SkipRetry
}
if importTask.Status != model.ImportTaskStatusPending {
switch importTask.Status {
case model.ImportTaskStatusPending:
// 正常首次处理
case model.ImportTaskStatusProcessing:
// 上次 worker 中途中断(重启/崩溃),重置计数重新处理
// 已入库的卡会被 ExistsByICCIDBatch 识别为 skip不会重复写入
h.logger.Warn("检测到导入任务上次处理中途中断,重置进度重新处理",
zap.Uint("task_id", payload.TaskID),
)
h.importTaskStore.UpdateProgress(ctx, importTask.ID, 0, 0, 0)
default:
// 已完成或失败,不重复处理
h.logger.Info("导入任务已处理,跳过",
zap.Uint("task_id", payload.TaskID),
zap.Int("status", importTask.Status),
@@ -218,6 +226,8 @@ func (h *IotCardImportHandler) processImport(ctx context.Context, task *model.Io
end := min(i+batchSize, len(cards))
batch := cards[i:end]
h.processBatch(ctx, task, batch, i+1, result)
// 每批完成后实时更新进度计数,让前端可以看到处理进度
h.importTaskStore.UpdateProgress(ctx, task.ID, result.successCount, result.skipCount, result.failCount)
}
return result
@@ -341,7 +351,14 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
}
now := time.Now()
createdCards := make([]*model.IotCard, 0, len(finalCards))
// 构建待批量写入的卡列表,同时记录原始信息用于失败回溯
type pendingEntry struct {
item model.CardItem
meta cardMeta
}
pending := make([]pendingEntry, 0, len(finalCards))
iotCards := make([]*model.IotCard, 0, len(finalCards))
for _, card := range finalCards {
meta := cardMetaMap[card.ICCID]
@@ -386,43 +403,63 @@ func (h *IotCardImportHandler) processBatch(ctx context.Context, task *model.Iot
iotCard.CreatedAt = now
iotCard.UpdatedAt = now
txErr := h.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Create(iotCard).Error; err != nil {
return err
}
txIdentifierStore := postgres.NewAssetIdentifierStore(tx)
if err := txIdentifierStore.Register(ctx, card.ICCID, model.AssetTypeIotCard, iotCard.ID); err != nil {
return fmt.Errorf("ICCID 已被占用: %s", card.ICCID)
}
if card.VirtualNo != "" {
if err := txIdentifierStore.Register(ctx, card.VirtualNo, model.AssetTypeIotCard, iotCard.ID); err != nil {
return fmt.Errorf("虚拟号已被占用: %s", card.VirtualNo)
}
}
return nil
})
if txErr != nil {
h.logger.Error("创建 IoT 卡失败",
zap.String("iccid", card.ICCID),
zap.Error(txErr),
)
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: meta.line,
ICCID: card.ICCID,
MSISDN: meta.msisdn,
Reason: txErr.Error(),
})
result.failCount++
continue
}
createdCards = append(createdCards, iotCard)
result.successCount++
iotCards = append(iotCards, iotCard)
pending = append(pending, pendingEntry{item: card, meta: meta})
}
h.batchCreateWallets(ctx, createdCards)
if len(iotCards) == 0 {
return
}
// 整批一个事务:批量写入 iot_card + asset_identifier
// 相比逐卡事务,将 N 个事务压缩为 1 个,大幅减少数据库往返
txErr := h.db.Transaction(func(tx *gorm.DB) error {
// 批量插入 iot_cardGORM 会通过 RETURNING 回填所有 ID
if err := tx.CreateInBatches(&iotCards, 500).Error; err != nil {
return err
}
// 用回填的 ID 构建 asset_identifier 列表
identifiers := make([]*model.AssetIdentifier, 0, len(iotCards)*2)
for _, c := range iotCards {
identifiers = append(identifiers, &model.AssetIdentifier{
Identifier: c.ICCID,
AssetType: model.AssetTypeIotCard,
AssetID: c.ID,
})
if c.VirtualNo != "" {
identifiers = append(identifiers, &model.AssetIdentifier{
Identifier: c.VirtualNo,
AssetType: model.AssetTypeIotCard,
AssetID: c.ID,
})
}
}
return tx.CreateInBatches(&identifiers, 500).Error
})
if txErr != nil {
h.logger.Error("批量创建 IoT 卡失败",
zap.Error(txErr),
zap.Int("batch_size", len(iotCards)),
)
for _, p := range pending {
result.failedItems = append(result.failedItems, model.ImportResultItem{
Line: p.meta.line,
ICCID: p.item.ICCID,
MSISDN: p.meta.msisdn,
Reason: "批量写入失败: " + txErr.Error(),
})
result.failCount++
}
return
}
result.successCount += len(iotCards)
h.batchCreateWallets(ctx, iotCards)
if h.pollingCallback != nil {
h.pollingCallback.OnBatchCardsCreated(ctx, createdCards)
h.pollingCallback.OnBatchCardsCreated(ctx, iotCards)
}
}

View File

@@ -88,7 +88,6 @@ func (h *Handler) registerIotCardImportHandler() {
h.workerResult.Stores.IotCardImportTask,
h.workerResult.Stores.IotCard,
h.workerResult.Stores.AssetWallet,
h.workerResult.Stores.AssetIdentifier,
h.storage,
h.pollingCallback,
h.logger,

View File

@@ -0,0 +1,88 @@
# 批量购买套餐脚本
该脚本读取只有一列的 CSV逐条调用 `POST /api/admin/orders`,为一批资产购买同一个套餐。
脚本仅使用 Python 标准库,不需要安装依赖。默认只预演,必须增加 `--execute` 才会真实下单。
## CSV 格式
首行表头可选,每行填写一个 ICCID 或虚拟号:
```csv
identifier
89860000000000000001
VIRTUAL000001
```
脚本会在请求前检查空文件、多列和重复资产。发现重复资产时整批终止,避免同一资产被重复购买。
## 预演
```bash
python3 scripts/batch_package_purchase/batch_purchase.py \
--base-url https://cmp-api.example.com \
--csv scripts/batch_package_purchase/assets.example.csv \
--package-id 123
```
预演只校验 CSV 并展示请求示例,不需要 Token也不会调用接口。
## 使用已有 Token 执行
推荐通过环境变量传递 Token避免进入命令历史
```bash
JUNHONG_ADMIN_TOKEN='<后台Access Token>' \
python3 scripts/batch_package_purchase/batch_purchase.py \
--base-url https://cmp-api.example.com \
--csv /path/to/assets.csv \
--package-id 123 \
--execute
```
## 使用账号自动登录后执行
未提供 Token 时,可以使用后台账号调用 `/api/admin/login` 自动获取 Token
```bash
JUNHONG_ADMIN_USERNAME='<后台账号>' \
JUNHONG_ADMIN_PASSWORD='<后台密码>' \
python3 scripts/batch_package_purchase/batch_purchase.py \
--base-url https://cmp-api.example.com \
--csv /path/to/assets.csv \
--package-id 123 \
--execute
```
也可以使用 `--token``--username``--password` 参数。密码优先通过环境变量或交互输入,避免保存在 Shell 历史中。
## 结果文件
默认在输入 CSV 同目录生成:
```text
原文件名_购买结果_YYYYMMDD_HHMMSS.csv
```
结果包含资产标识、成功或失败状态、HTTP 状态码、业务错误码、错误消息、订单 ID、订单号和订单金额。每处理一条都会立即刷新文件中途中断时已完成的结果不会丢失。
为避免覆盖历史结果,`--output` 指定的文件已经存在时脚本会直接报错。
可通过 `--output` 指定结果路径:
```bash
python3 scripts/batch_package_purchase/batch_purchase.py \
--base-url https://cmp-api.example.com \
--csv /path/to/assets.csv \
--package-id 123 \
--output /path/to/result.csv \
--execute
```
## 其他参数
- `--timeout`:单次请求超时秒数,默认 `30`
- `--interval`:每次下单后的等待秒数,默认 `0.2`
- `--execute`:显式开启真实下单。
脚本固定使用 `wallet` 钱包支付,每个资产单独创建一个订单。脚本不会自动重试 POST 请求,避免服务端已成功但客户端未收到响应时重复下单。失败项可根据结果 CSV 人工确认后单独重跑。

View File

@@ -0,0 +1,23 @@
identifier
8986032445201075412,
8986032445201075413,
8986032445201075414,
8986032445201075416,
8986032445201075417,
8986032445201075418,
8986032445201075419,
8986032445201075420,
8986032445201075421,
8986032445201075422,
8986032445201075423,
8986032445201075425,
8986032445201075426,
8986032445201075428,
8986032445201075430,
8986032445201075433,
8986032445201075434,
8986032445201075441,
8986032445201075444,
8986032445201075447,
8986032445201075448,
8986032445201075451
1 identifier
2 8986032445201075412,
3 8986032445201075413,
4 8986032445201075414,
5 8986032445201075416,
6 8986032445201075417,
7 8986032445201075418,
8 8986032445201075419,
9 8986032445201075420,
10 8986032445201075421,
11 8986032445201075422,
12 8986032445201075423,
13 8986032445201075425,
14 8986032445201075426,
15 8986032445201075428,
16 8986032445201075430,
17 8986032445201075433,
18 8986032445201075434,
19 8986032445201075441,
20 8986032445201075444,
21 8986032445201075447,
22 8986032445201075448,
23 8986032445201075451

View File

@@ -0,0 +1,492 @@
#!/usr/bin/env python3
"""批量购买套餐脚本:读取单列 CSV逐资产调用后台订单接口购买同一套餐。
默认只执行预演。只有显式传入 --execute 时,才会调用 POST /api/admin/orders。
脚本仅使用 Python 标准库,不需要安装第三方依赖。
"""
from __future__ import annotations
import argparse
import csv
import json
import os
import sys
import time
from dataclasses import dataclass
from datetime import datetime
from getpass import getpass
from pathlib import Path
from typing import Any
from urllib.error import HTTPError, URLError
from urllib.request import Request, urlopen
ORDER_PATH = "/api/admin/orders"
LOGIN_PATH = "/api/admin/login"
AUTH_ERROR_CODES = {1002, 1003, 1004}
HEADER_NAMES = {
"iccid",
"virtual_no",
"identifier",
"资产标识",
"虚拟号",
"iccid/虚拟号",
}
@dataclass(frozen=True)
class AssetInput:
"""保存 CSV 中的资产标识及原始行号。"""
line_no: int
identifier: str
@dataclass(frozen=True)
class HTTPResult:
"""保存一次 HTTP 请求的响应信息。"""
status: int
body: dict[str, Any] | None
raw_body: str
@dataclass(frozen=True)
class PurchaseOutcome:
"""保存单个资产的下单结果及结果文件行。"""
row: dict[str, object]
success: bool
http_status: int | None
code: int | str | None
message: str
order_no: str
class RequestFailedError(Exception):
"""表示请求尚未获得可解析的 HTTP 响应。"""
class AdminAPIClient:
"""调用后台认证和订单接口的轻量客户端。"""
def __init__(self, base_url: str, timeout: float) -> None:
self.base_url = base_url.rstrip("/")
self.timeout = timeout
def login(self, username: str, password: str) -> str:
"""使用后台账号登录并返回 Access Token。"""
result = self._post_json(
LOGIN_PATH,
{"username": username, "password": password, "device": "web"},
token=None,
)
code = response_code(result.body)
if not is_success(result.status, code):
raise RequestFailedError(
f"登录失败HTTP {result.status}code={display_value(code)}"
f"msg={response_message(result.body, result.raw_body)}"
)
data = result.body.get("data") if result.body else None
token = data.get("access_token") if isinstance(data, dict) else None
if not isinstance(token, str) or not token.strip():
raise RequestFailedError("登录响应中缺少 data.access_token")
return token.strip()
def create_order(self, token: str, identifier: str, package_id: int) -> HTTPResult:
"""为单个资产创建钱包套餐订单。"""
return self._post_json(
ORDER_PATH,
{
"identifier": identifier,
"package_ids": [package_id],
"payment_method": "wallet",
},
token=token,
)
def _post_json(self, path: str, payload: dict[str, Any], token: str | None) -> HTTPResult:
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":")).encode("utf-8")
headers = {
"Accept": "application/json",
"Content-Type": "application/json",
"User-Agent": "junhong-batch-package-purchase/1.0",
}
if token:
headers["Authorization"] = f"Bearer {token}"
request = Request(
url=self.base_url + path,
data=body,
headers=headers,
method="POST",
)
try:
with urlopen(request, timeout=self.timeout) as response:
raw_body = response.read().decode("utf-8", errors="replace")
return HTTPResult(
status=response.status,
body=parse_json_object(raw_body),
raw_body=raw_body,
)
except HTTPError as exc:
raw_body = exc.read().decode("utf-8", errors="replace")
return HTTPResult(
status=exc.code,
body=parse_json_object(raw_body),
raw_body=raw_body,
)
except (URLError, TimeoutError, OSError) as exc:
raise RequestFailedError(f"请求失败:{exc}") from exc
def parse_args() -> argparse.Namespace:
"""解析命令行参数。"""
parser = argparse.ArgumentParser(
description="读取单列 CSV逐资产调用 /api/admin/orders 购买同一套餐",
)
parser.add_argument(
"--base-url",
default=os.getenv("JUNHONG_ADMIN_BASE_URL", ""),
help="接口 Base URL例如 https://cmp-api.example.com也可使用 JUNHONG_ADMIN_BASE_URL",
)
parser.add_argument("--csv", required=True, help="单列资产 CSV 文件路径,首行可有表头")
parser.add_argument("--package-id", required=True, type=int, help="本批资产统一购买的套餐 ID")
parser.add_argument(
"--token",
default=os.getenv("JUNHONG_ADMIN_TOKEN", ""),
help="后台 Access Token也可使用 JUNHONG_ADMIN_TOKEN",
)
parser.add_argument(
"--username",
default=os.getenv("JUNHONG_ADMIN_USERNAME", ""),
help="未提供 Token 时用于自动登录;也可使用 JUNHONG_ADMIN_USERNAME",
)
parser.add_argument(
"--password",
default=os.getenv("JUNHONG_ADMIN_PASSWORD", ""),
help="后台登录密码;建议使用 JUNHONG_ADMIN_PASSWORD避免进入命令历史",
)
parser.add_argument("--output", default="", help="结果 CSV 路径;默认输出到输入文件同目录")
parser.add_argument("--timeout", type=float, default=30.0, help="单次请求超时秒数(默认 30")
parser.add_argument("--interval", type=float, default=0.2, help="每次下单后的间隔秒数(默认 0.2")
parser.add_argument(
"--execute",
action="store_true",
help="真实调用接口下单;不传时只校验 CSV 并预览请求",
)
return parser.parse_args()
def load_assets(csv_path: Path) -> list[AssetInput]:
"""读取单列 CSV识别可选表头并在下单前拦截重复资产。"""
if not csv_path.exists():
raise ValueError(f"找不到 CSV 文件:{csv_path}")
if not csv_path.is_file():
raise ValueError(f"CSV 路径不是文件:{csv_path}")
assets: list[AssetInput] = []
first_line_by_identifier: dict[str, int] = {}
errors: list[str] = []
first_nonempty_seen = False
with csv_path.open("r", encoding="utf-8-sig", newline="") as file:
reader = csv.reader(file)
for line_no, row in enumerate(reader, start=1):
values = [value.strip() for value in row if value.strip()]
if not values:
continue
if len(values) != 1:
errors.append(f"{line_no} 行必须只有一列,实际读取到 {len(values)} 个非空值")
continue
identifier = values[0]
if not first_nonempty_seen:
first_nonempty_seen = True
if identifier.lower() in HEADER_NAMES:
continue
if identifier in first_line_by_identifier:
errors.append(
f"{line_no} 行与第 {first_line_by_identifier[identifier]} 行重复:{identifier}"
)
continue
first_line_by_identifier[identifier] = line_no
assets.append(AssetInput(line_no=line_no, identifier=identifier))
if errors:
preview = "\n".join(f" - {error}" for error in errors[:20])
if len(errors) > 20:
preview += f"\n - 其余 {len(errors) - 20} 个错误已省略"
raise ValueError(f"CSV 校验失败,请修正后重试:\n{preview}")
if not assets:
raise ValueError("CSV 中没有有效的 ICCID 或虚拟号")
return assets
def parse_json_object(raw_body: str) -> dict[str, Any] | None:
"""尝试把响应正文解析为 JSON 对象。"""
if not raw_body.strip():
return None
try:
value = json.loads(raw_body)
except json.JSONDecodeError:
return None
return value if isinstance(value, dict) else None
def response_code(body: dict[str, Any] | None) -> int | str | None:
"""读取统一响应中的业务错误码。"""
if not body:
return None
code = body.get("code")
if isinstance(code, bool):
return int(code)
if isinstance(code, int):
return code
if isinstance(code, str):
stripped = code.strip()
return int(stripped) if stripped.isdigit() else stripped
return None
def response_message(body: dict[str, Any] | None, raw_body: str) -> str:
"""读取统一响应消息,非 JSON 响应则保留截断后的正文。"""
if body:
message = body.get("msg", body.get("message", ""))
if message is not None and str(message).strip():
return str(message).strip()
text = raw_body.strip().replace("\r", " ").replace("\n", " ")
return text[:500] if text else "接口未返回错误信息"
def is_success(http_status: int, code: int | str | None) -> bool:
"""同时校验 HTTP 状态码和业务响应码。"""
return 200 <= http_status < 300 and str(code) == "0"
def display_value(value: object) -> str:
"""把可能为空的字段转为适合日志和 CSV 的文本。"""
return "" if value is None else str(value)
def resolve_output_path(input_path: Path, output_arg: str) -> Path:
"""生成本批次的结果文件路径。"""
if output_arg.strip():
return Path(output_arg).expanduser().resolve()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
return input_path.with_name(f"{input_path.stem}_购买结果_{timestamp}.csv")
def resolve_token(args: argparse.Namespace, client: AdminAPIClient) -> str:
"""优先使用现有 Token否则使用后台账号自动登录。"""
token = args.token.strip()
if token:
return token
username = args.username.strip()
if not username:
raise ValueError(
"真实执行需要 --token或同时提供 --username/--password"
"也可通过 JUNHONG_ADMIN_TOKEN 等环境变量配置"
)
password = args.password
if not password and sys.stdin.isatty():
password = getpass("请输入后台登录密码:")
if not password:
raise ValueError("使用账号登录时必须提供密码")
print(f"正在使用后台账号 {username!r} 获取 Access Token...")
return client.login(username, password)
def preview(assets: list[AssetInput], base_url: str, package_id: int) -> int:
"""输出预演信息,不发送任何 HTTP 请求。"""
print("预演完成:未发送任何 HTTP 请求。")
print(f"接口地址:{base_url.rstrip('/')}{ORDER_PATH}")
print(f"资产数量:{len(assets)}")
print(f"套餐 ID{package_id}")
print("支付方式wallet")
print("请求示例:")
for asset in assets[:5]:
payload = {
"identifier": asset.identifier,
"package_ids": [package_id],
"payment_method": "wallet",
}
print(f"{asset.line_no} 行:{json.dumps(payload, ensure_ascii=False)}")
if len(assets) > 5:
print(f" 其余 {len(assets) - 5} 条已省略")
print("确认参数无误后,增加 --execute 才会真实购买。")
return 0
def purchase_asset(
client: AdminAPIClient,
token: str,
asset: AssetInput,
package_id: int,
) -> PurchaseOutcome:
"""调用一次订单接口,并转换为统一的结果记录。"""
try:
result = client.create_order(token, asset.identifier, package_id)
code = response_code(result.body)
message = response_message(result.body, result.raw_body)
data = result.body.get("data") if result.body else None
order_data = data if isinstance(data, dict) else {}
success = is_success(result.status, code)
order_no = display_value(order_data.get("order_no"))
return PurchaseOutcome(
row={
"line_no": asset.line_no,
"identifier": asset.identifier,
"status": "成功" if success else "失败",
"http_status": result.status,
"code": display_value(code),
"msg": message,
"order_id": display_value(order_data.get("id")),
"order_no": order_no,
"total_amount": display_value(order_data.get("total_amount")),
},
success=success,
http_status=result.status,
code=code,
message=message,
order_no=order_no,
)
except RequestFailedError as exc:
message = str(exc)
return PurchaseOutcome(
row={
"line_no": asset.line_no,
"identifier": asset.identifier,
"status": "失败",
"http_status": "",
"code": "",
"msg": message,
"order_id": "",
"order_no": "",
"total_amount": "",
},
success=False,
http_status=None,
code=None,
message=message,
order_no="",
)
def execute(
assets: list[AssetInput],
client: AdminAPIClient,
token: str,
package_id: int,
output_path: Path,
interval: float,
) -> int:
"""顺序执行下单,并把每条结果立即写入 CSV。"""
output_path.parent.mkdir(parents=True, exist_ok=True)
success_count = 0
failed_count = 0
total = len(assets)
with output_path.open("w", encoding="utf-8-sig", newline="") as file:
writer = csv.DictWriter(
file,
fieldnames=[
"line_no",
"identifier",
"status",
"http_status",
"code",
"msg",
"order_id",
"order_no",
"total_amount",
],
)
writer.writeheader()
file.flush()
for index, asset in enumerate(assets, start=1):
outcome = purchase_asset(client, token, asset, package_id)
writer.writerow(outcome.row)
if outcome.success:
success_count += 1
print(f"[{index}/{total}] 成功:{asset.identifier},订单号={outcome.order_no}")
else:
failed_count += 1
print(
f"[{index}/{total}] 失败:{asset.identifier}"
f"HTTP={display_value(outcome.http_status)}"
f"code={display_value(outcome.code)}msg={outcome.message}",
file=sys.stderr,
)
file.flush()
if outcome.http_status == 401 or outcome.code in AUTH_ERROR_CODES:
print("认证已失效,停止后续下单;已处理结果已保存。", file=sys.stderr)
break
if interval > 0 and index < total:
time.sleep(interval)
print()
print(f"执行结束:成功 {success_count} 条,失败 {failed_count} 条。")
print(f"结果文件:{output_path}")
return 0 if failed_count == 0 and success_count == total else 2
def main() -> int:
"""校验参数,执行预演或真实批量下单。"""
args = parse_args()
try:
base_url = args.base_url.strip()
if not base_url:
raise ValueError("必须通过 --base-url 或 JUNHONG_ADMIN_BASE_URL 配置接口地址")
if not base_url.startswith(("http://", "https://")):
raise ValueError("base-url 必须以 http:// 或 https:// 开头")
if args.package_id <= 0:
raise ValueError("package-id 必须大于 0")
if args.timeout <= 0:
raise ValueError("timeout 必须大于 0")
if args.interval < 0:
raise ValueError("interval 不能小于 0")
input_path = Path(args.csv).expanduser().resolve()
assets = load_assets(input_path)
if not args.execute:
return preview(assets, base_url, args.package_id)
output_path = resolve_output_path(input_path, args.output)
if output_path == input_path:
raise ValueError("结果文件不能与输入 CSV 使用同一路径")
if output_path.exists():
raise ValueError(f"结果文件已存在,请更换 --output 路径:{output_path}")
client = AdminAPIClient(base_url, args.timeout)
token = resolve_token(args, client)
print(f"即将真实下单:资产 {len(assets)} 个,套餐 ID={args.package_id},支付方式=wallet")
print(f"接口地址:{base_url.rstrip('/')}{ORDER_PATH}")
print(f"结果文件:{output_path}")
return execute(
assets=assets,
client=client,
token=token,
package_id=args.package_id,
output_path=output_path,
interval=args.interval,
)
except (ValueError, RequestFailedError) as exc:
print(f"错误:{exc}", file=sys.stderr)
return 1
except KeyboardInterrupt:
print("\n用户中断执行;已写入的结果会保留。", file=sys.stderr)
return 130
if __name__ == "__main__":
sys.exit(main())

View File

@@ -6,12 +6,29 @@
from __future__ import annotations
import contextlib
import time
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Iterable, Iterator, Optional
BATCH_SIZE = 200
# 重构后分阶段索引查询已无大 JOIN,1000 为安全默认值
BATCH_SIZE = 1000
class _StageTimer:
"""阶段耗时计时器,记录迁移各阶段用时。"""
def __init__(self, func_name: str) -> None:
self._func = func_name
self._t = time.perf_counter()
def log(self, stage: str, **kw: Any) -> None:
elapsed = time.perf_counter() - self._t
parts = " ".join(f"{k}={v}" for k, v in kw.items())
suffix = f" {parts}" if parts else ""
print(f"[legacy] {self._func}.{stage}:{suffix} elapsed={elapsed:.1f}s")
self._t = time.perf_counter()
def _normalize_iccid_pairs(iccid_pairs: Iterable[tuple]) -> list[tuple[str, str, bool]]:
@@ -236,14 +253,35 @@ def _to_mb_decimal(value) -> Decimal:
return Decimal("0")
def _build_card_lookup(
pairs: list[tuple[str, str, bool]],
) -> dict[str, tuple[str, int]]:
"""构建 tbl_card 查询键 → (完整 ICCID, rank) 映射。
rank=0 表示完整精确命中,rank=1 表示 19 位兜底命中。
"""
by_full: dict[str, tuple[str, int]] = {}
for i19, i20, allow_i19_lookup in pairs:
card_key = i20 or i19
by_full[card_key] = (card_key, 0)
if i20 and allow_i19_lookup:
by_full[i19] = (card_key, 1)
return by_full
# ---------------- 查询函数 ----------------
def fetch_current_packages(conn, iccids: Iterable[str]) -> dict[str, LegacyPackage]:
def fetch_current_packages(
conn,
iccids: Iterable[str],
batch_size: int = BATCH_SIZE,
) -> dict[str, LegacyPackage]:
"""查每张卡的当前生效主套餐:status=1、type!=1 且 expire_date 最大。
返回: iccid → LegacyPackage(找不到的 iccid 不在结果中)
"""
timer = _StageTimer("fetch_current_packages")
iccid_list = sorted({i for i in iccids if i})
out: dict[str, LegacyPackage] = {}
if not iccid_list:
@@ -266,11 +304,15 @@ def fetch_current_packages(conn, iccids: Iterable[str]) -> dict[str, LegacyPacka
ORDER BY iccid_mark ASC, expire_date DESC
"""
seen: set[str] = set()
rows = 0
batches = 0
with conn.cursor() as cur:
for batch in _chunks(iccid_list):
for batch in _chunks(iccid_list, batch_size):
batches += 1
cur.execute(sql, (tuple(batch),))
for row in cur.fetchall():
iccid = row["iccid_mark"]
rows += 1
if not iccid or iccid in seen:
continue
seen.add(iccid)
@@ -284,15 +326,21 @@ def fetch_current_packages(conn, iccids: Iterable[str]) -> dict[str, LegacyPacka
flow_size_mb=int(row.get("flow_size") or 0),
status=int(row.get("status") or 0),
)
timer.log("tbl_card_life", keys=len(iccid_list), batches=batches, rows=rows, found=len(out))
return out
def fetch_package_lifecycles(conn, iccid_pairs: Iterable[tuple]) -> dict[str, list[LegacyPackageLifecycle]]:
def fetch_package_lifecycles(
conn,
iccid_pairs: Iterable[tuple],
batch_size: int = BATCH_SIZE,
) -> dict[str, list[LegacyPackageLifecycle]]:
"""查当前生效和未生效待生效正式套餐生命周期。
只返回可迁移范围(active/pending)和需要在审核文件中说明的跳过记录。
加油包(type=1)和已过期记录不生成套餐 SQL,但保留 skip_reason 供审核产物输出。
"""
timer = _StageTimer("fetch_package_lifecycles")
pairs = _normalize_iccid_pairs(iccid_pairs)
out: dict[str, list[LegacyPackageLifecycle]] = {}
if not pairs:
@@ -328,8 +376,11 @@ def fetch_package_lifecycles(conn, iccid_pairs: Iterable[tuple]) -> dict[str, li
COALESCE(expire_date, '9999-12-31') ASC,
id ASC
"""
rows = 0
batches = 0
with conn.cursor() as cur:
for batch in _chunks(iccid_list):
for batch in _chunks(iccid_list, batch_size):
batches += 1
cur.execute(sql, (tuple(batch),))
for row in cur.fetchall():
legacy_iccid = row.get("iccid_mark") or ""
@@ -338,6 +389,7 @@ def fetch_package_lifecycles(conn, iccid_pairs: Iterable[tuple]) -> dict[str, li
iccid = query_to_full.get(legacy_iccid)
if not iccid:
continue
rows += 1
start = row.get("start_date")
expire = row.get("expire_date")
start_str = start.strftime("%Y-%m-%d %H:%M:%S") if start else None
@@ -363,6 +415,7 @@ def fetch_package_lifecycles(conn, iccid_pairs: Iterable[tuple]) -> dict[str, li
skip_reason=skip_reason,
)
out.setdefault(iccid, []).append(item)
timer.log("tbl_card_life", keys=len(iccid_list), batches=batches, rows=rows)
next_sql = """
SELECT
@@ -384,8 +437,11 @@ def fetch_package_lifecycles(conn, iccid_pairs: Iterable[tuple]) -> dict[str, li
COALESCE(effect_complete_time, '9999-12-31') ASC,
id ASC
"""
next_rows = 0
next_batches = 0
with conn.cursor() as cur:
for batch in _chunks(iccid_list):
for batch in _chunks(iccid_list, batch_size):
next_batches += 1
cur.execute(next_sql, (tuple(batch),))
for row in cur.fetchall():
legacy_iccid = row.get("iccid_mark") or ""
@@ -394,6 +450,7 @@ def fetch_package_lifecycles(conn, iccid_pairs: Iterable[tuple]) -> dict[str, li
iccid = query_to_full.get(legacy_iccid)
if not iccid:
continue
next_rows += 1
start = row.get("start_date")
expire = row.get("expire_date")
start_str = start.strftime("%Y-%m-%d %H:%M:%S") if start else None
@@ -417,6 +474,7 @@ def fetch_package_lifecycles(conn, iccid_pairs: Iterable[tuple]) -> dict[str, li
migration_status=migration_status,
skip_reason=skip_reason,
))
timer.log("tbl_next_month_card_life", keys=len(iccid_list), batches=next_batches, rows=next_rows)
return out
@@ -448,13 +506,18 @@ def _classify_next_month_lifecycle(status: int) -> tuple[str, str]:
return "skipped", f"奇成次月待生效状态 {status} 不在迁移范围"
def fetch_card_usage(conn, iccid_pairs: Iterable[tuple]) -> dict[str, LegacyCardUsage]:
"""查每张卡的累计已用流量,并同步取当前生效正式套餐的 flow_add_discount。
def fetch_card_usage(
conn,
iccid_pairs: Iterable[tuple],
batch_size: int = BATCH_SIZE,
) -> dict[str, LegacyCardUsage]:
"""查每张卡的累计已用流量,分阶段索引查询替代大 JOIN。
奇成 ``tbl_card.total_bytes_cnt`` 是虚用量(已被套餐虚比加成)。
这里 JOIN 当前生效正式套餐(``tbl_card_life`` status=1,type!=1 且 ``expire_date`` 最大)
+ ``tbl_set_meal`` 取 ``flow_add_discount``,放进 ``LegacyCardUsage``;
上层通过 ``real_used_mb_floor`` 反算真用量写入新系统。
阶段:
1. tbl_card — iccid_mark + total_bytes_cnt
2. tbl_card_life — 当前生效正式套餐 meal_id(Python 选 expire_date 最大)
3. tbl_set_meal — flow_add_discount(按 meal_id 精确查)
4. Python 组装 LegacyCardUsage
Args:
iccid_pairs: (iccid_19, iccid_20, allow_iccid_19_lookup) 列表。
@@ -462,88 +525,119 @@ def fetch_card_usage(conn, iccid_pairs: Iterable[tuple]) -> dict[str, LegacyCard
Returns:
{完整 ICCID: LegacyCardUsage}。
"""
timer = _StageTimer("fetch_card_usage")
pairs = _normalize_iccid_pairs(iccid_pairs)
out: dict[str, LegacyCardUsage] = {}
if not pairs:
return out
query_to_full: dict[str, tuple[str, int]] = {}
related_keys_by_full: dict[str, set[str]] = {}
for i19, i20, allow_i19_lookup in pairs:
card_key = i20 or i19
# tbl_card 主表同时查优先键和兜底键,结果选择时按 rank:
# 0=业务方完整 ICCID 精确命中,1=20 位卡在奇成主表只存 19 位时兜底。
query_to_full[card_key] = (card_key, 0)
if i20 and allow_i19_lookup:
query_to_full[i19] = (card_key, 1)
if not i20 or allow_i19_lookup:
related_keys_by_full.setdefault(card_key, set()).add(i19)
else:
related_keys_by_full.setdefault(card_key, set())
if i20:
related_keys_by_full[card_key].add(i20)
full_set = sorted(query_to_full.keys())
by_full = _build_card_lookup(pairs)
full_set = sorted(by_full.keys())
# tbl_card 主表用 iccid_mark IN 全长精确匹配;从表只在主表本身是 19 位时
# 按前缀兜底,避免同前缀 20 位卡互相串套餐。
sql = """
# ── 阶段1: tbl_card ───────────────────────────────────────────────────────
card_sql = """
SELECT
c.iccid_mark,
c.total_bytes_cnt,
COALESCE(sm.flow_add_discount, 0) AS flow_add_discount,
CASE WHEN cl.iccid_mark IS NOT NULL THEN 1 ELSE 0 END AS has_active_package
FROM tbl_card c
LEFT JOIN (
SELECT cl1.iccid_mark, cl1.meal_id
FROM tbl_card_life cl1
INNER JOIN (
SELECT iccid_mark, MAX(expire_date) AS max_expire
FROM tbl_card_life
WHERE status = 1
AND COALESCE(CAST(type AS CHAR), '') <> '1'
AND iccid_mark IN %s
GROUP BY iccid_mark
) latest
ON latest.iccid_mark = cl1.iccid_mark
AND latest.max_expire = cl1.expire_date
WHERE cl1.status = 1
AND COALESCE(CAST(cl1.type AS CHAR), '') <> '1'
) cl ON (
cl.iccid_mark = c.iccid_mark
OR (CHAR_LENGTH(c.iccid_mark) = 19 AND LEFT(cl.iccid_mark, 19) = c.iccid_mark)
)
LEFT JOIN tbl_set_meal sm ON sm.id = cl.meal_id
WHERE c.iccid_mark IN %s
iccid_mark,
COALESCE(total_bytes_cnt, 0) AS total_bytes_cnt
FROM tbl_card
WHERE iccid_mark IN %s
"""
hits: dict[str, list[tuple[int, dict]]] = {}
card_rows = 0
card_batches = 0
with conn.cursor() as cur:
for batch in _chunks(full_set):
batch_keys = {query_to_full[x][0] for x in batch}
related_batch = sorted({x for key in batch_keys for x in related_keys_by_full[key]})
cur.execute(sql, (tuple(related_batch), tuple(batch)))
for batch in _chunks(full_set, batch_size):
card_batches += 1
cur.execute(card_sql, (tuple(batch),))
for row in cur.fetchall():
legacy_iccid = row["iccid_mark"]
if not legacy_iccid:
continue
matched = query_to_full.get(legacy_iccid)
matched = by_full.get(legacy_iccid)
if matched is None:
continue
card_key, rank = matched
hits.setdefault(card_key, []).append((rank, row))
card_rows += 1
timer.log("tbl_card", keys=len(full_set), batches=card_batches, rows=card_rows)
# 取最优命中行,构建 legacy_iccid 列表
card_data: dict[str, dict] = {} # card_key → best tbl_card row
for card_key, rows in hits.items():
best_rank = min(rank for rank, _row in rows)
row = next(row for rank, row in rows if rank == best_rank)
best_rank = min(r for r, _ in rows)
card_data[card_key] = next(row for r, row in rows if r == best_rank)
if not card_data:
return out
legacy_iccids = sorted({row["iccid_mark"] for row in card_data.values()})
# ── 阶段2: tbl_card_life 当前生效正式套餐(精确匹配,Python 选最新) ─────────
life_sql = """
SELECT
iccid_mark,
COALESCE(meal_id, '') AS meal_id
FROM tbl_card_life
WHERE iccid_mark IN %s
AND status = 1
AND COALESCE(CAST(type AS CHAR), '') <> '1'
ORDER BY iccid_mark ASC, expire_date DESC, id DESC
"""
current_meal_by_iccid: dict[str, str] = {} # legacy_iccid → meal_id
life_rows = 0
life_batches = 0
with conn.cursor() as cur:
for batch in _chunks(legacy_iccids, batch_size):
life_batches += 1
cur.execute(life_sql, (tuple(batch),))
for row in cur.fetchall():
iccid = row["iccid_mark"]
life_rows += 1
if iccid not in current_meal_by_iccid:
current_meal_by_iccid[iccid] = row.get("meal_id") or ""
timer.log("tbl_card_life", keys=len(legacy_iccids), batches=life_batches, rows=life_rows)
# ── 阶段3: tbl_set_meal flow_add_discount(按 distinct meal_id 查) ─────────
meal_ids = sorted({mid for mid in current_meal_by_iccid.values() if mid})
discount_by_meal: dict[str, Decimal] = {}
if meal_ids:
meal_sql = "SELECT id, COALESCE(flow_add_discount, 0) AS flow_add_discount FROM tbl_set_meal WHERE id IN %s"
meal_batches = 0
with conn.cursor() as cur:
for batch in _chunks(meal_ids, batch_size):
meal_batches += 1
cur.execute(meal_sql, (tuple(batch),))
for row in cur.fetchall():
mid = str(row["id"] or "")
if mid:
discount_by_meal[mid] = _to_mb_decimal(row.get("flow_add_discount"))
timer.log("tbl_set_meal", meal_ids=len(meal_ids), batches=meal_batches)
else:
timer.log("tbl_set_meal", meal_ids=0, skipped=True)
# ── 阶段4: Python 组装 ────────────────────────────────────────────────────
for card_key, base_row in card_data.items():
legacy_iccid = base_row["iccid_mark"]
meal_id = current_meal_by_iccid.get(legacy_iccid, "")
has_active = bool(meal_id or legacy_iccid in current_meal_by_iccid)
discount = discount_by_meal.get(str(meal_id), Decimal("0")) if meal_id else Decimal("0")
out[card_key] = LegacyCardUsage(
iccid=card_key,
virtual_used_mb=_to_mb_decimal(row.get("total_bytes_cnt")),
flow_add_discount_pct=_to_mb_decimal(row.get("flow_add_discount")),
has_active_package=bool(row.get("has_active_package")),
virtual_used_mb=_to_mb_decimal(base_row.get("total_bytes_cnt")),
flow_add_discount_pct=discount,
has_active_package=has_active,
)
timer.log("assemble", cards=len(out))
return out
def fetch_commission_accounts(conn, agent_ids: Iterable[str]) -> dict[str, LegacyCommissionAccount]:
def fetch_commission_accounts(
conn,
agent_ids: Iterable[str],
batch_size: int = BATCH_SIZE,
) -> dict[str, LegacyCommissionAccount]:
"""查代理佣金账户。"""
timer = _StageTimer("fetch_commission_accounts")
agent_list = sorted({a for a in agent_ids if a})
out: dict[str, LegacyCommissionAccount] = {}
if not agent_list:
@@ -557,11 +651,15 @@ def fetch_commission_accounts(conn, agent_ids: Iterable[str]) -> dict[str, Legac
FROM tbl_agent_commission_account
WHERE agent_id IN %s
"""
rows = 0
batches = 0
with conn.cursor() as cur:
for batch in _chunks(agent_list):
for batch in _chunks(agent_list, batch_size):
batches += 1
cur.execute(sql, (tuple(batch),))
for row in cur.fetchall():
aid = row["agent_id"]
rows += 1
if not aid:
continue
out[aid] = LegacyCommissionAccount(
@@ -572,107 +670,69 @@ def fetch_commission_accounts(conn, agent_ids: Iterable[str]) -> dict[str, Legac
total_commission_amount_fen=_to_fen(row.get("total_commision_amount")),
total_draw_amount_fen=_to_fen(row.get("total_draw_amount")),
)
timer.log("tbl_agent_commission_account", agents=len(agent_list), batches=batches, rows=rows)
return out
def fetch_card_meta(
conn,
iccid_pairs: Iterable[tuple],
batch_size: int = BATCH_SIZE,
) -> tuple[dict[str, LegacyCardMeta], set[str]]:
"""查每张卡的元数据(卡本体 + 当前生效套餐 + 虚拟号)
"""查每张卡的元数据,分阶段索引查询替代大 JOIN
阶段:
1. tbl_card — 卡基础字段 + 去重检测
2. tbl_vendor_category — 运营商分类名称(按 distinct category 查)
3. tbl_card_life — 当前生效正式套餐(Python 选 expire_date 最大,精确匹配)
4. tbl_set_meal + tbl_set_meal_series — 套餐系列(按 distinct meal_id 查)
5. tbl_virtual_number — 虚拟号(精确匹配,不做 LEFT() 前缀扫描)
6. Python 组装 LegacyCardMeta
匹配策略:
- tbl_card 主表同时查优先键和显式允许的兜底键:有 iccid_20 时优先
20 位精确命中;只有业务方也显式填了 iccid_19 时,才允许退回 19 位。
只填 iccid_20 的固定 20 位卡绝不拿 19 位前缀查主表。
- tbl_card_life / tbl_virtual_number 从表在主表本身是 19 位时才用
LEFT(iccid_mark, 19) 兜底,避免同前缀 20 位卡互相串数据
- tbl_card 主表同时查优先键和显式允许的兜底键;20 位精确命中优先
- 从表(card_life / virtual_number)用 tbl_card 实际返回的 iccid_mark 精确查询
- 不再使用 LEFT(vn.iccid_mark, 19) 前缀匹配,避免全表扫描
Args:
iccid_pairs: (iccid_19, iccid_20, allow_iccid_19_lookup) 列表。
Returns:
(metas, duplicate_iccids):
- metas: {完整 ICCID: LegacyCardMeta} 单匹结果
- duplicate_iccids: 一份业务方完整 ICCID 命中奇成 tbl_card 多条记录的 set,
这些卡不进 metas,sql_builder 会写 errors.csv 阻断
- metas: {完整 ICCID: LegacyCardMeta}
- duplicate_iccids: 一份业务方 ICCID 命中多条 tbl_card 记录的 set
"""
timer = _StageTimer("fetch_card_meta")
pairs = _normalize_iccid_pairs(iccid_pairs)
if not pairs:
return {}, set()
by_full: dict[str, tuple[str, int]] = {} # tbl_card 查询键 → (完整 ICCID, rank)
related_keys_by_full: dict[str, set[str]] = {}
for i19, i20, allow_i19_lookup in pairs:
card_key = i20 or i19
by_full[card_key] = (card_key, 0)
if i20 and allow_i19_lookup:
by_full[i19] = (card_key, 1)
if not i20 or allow_i19_lookup:
related_keys_by_full.setdefault(card_key, set()).add(i19)
else:
related_keys_by_full.setdefault(card_key, set())
if i20:
related_keys_by_full[card_key].add(i20)
by_full = _build_card_lookup(pairs)
full_set = sorted(by_full.keys())
sql = """
# ── 阶段1: tbl_card ───────────────────────────────────────────────────────
card_sql = """
SELECT
c.id AS card_id,
c.iccid_mark AS iccid,
COALESCE(c.account_id, '') AS account_id,
COALESCE(c.account_name, '') AS account_name,
COALESCE(c.category, 0) AS category,
COALESCE(vc.category_name, '') AS category_name,
COALESCE(c.agent_id, '') AS agent_id,
COALESCE(c.agent_name, '') AS agent_name,
COALESCE(c.phone, '') AS phone,
COALESCE(vn.vcode, '') AS vcode,
COALESCE(cl.meal_id, '') AS meal_id,
COALESCE(cl.meal_name, '') AS meal_name,
COALESCE(CAST(cl.meal_type AS CHAR), '') AS meal_type,
cl.expire_date AS expire_date,
COALESCE(sm.meal_series_id, '') AS series_id,
COALESCE(sms.name, '') AS series_name
FROM tbl_card c
LEFT JOIN tbl_vendor_category vc ON vc.category = c.category
LEFT JOIN tbl_virtual_number vn ON (
vn.iccid_mark = c.iccid_mark
OR (CHAR_LENGTH(c.iccid_mark) = 19 AND LEFT(vn.iccid_mark, 19) = c.iccid_mark)
)
LEFT JOIN (
SELECT cl1.iccid_mark, cl1.meal_id, cl1.meal_name, cl1.type AS meal_type, cl1.expire_date
FROM tbl_card_life cl1
INNER JOIN (
SELECT iccid_mark, MAX(expire_date) AS max_expire
FROM tbl_card_life
WHERE status = 1
AND COALESCE(CAST(type AS CHAR), '') <> '1'
AND iccid_mark IN %s
GROUP BY iccid_mark
) latest
ON latest.iccid_mark = cl1.iccid_mark
AND latest.max_expire = cl1.expire_date
WHERE cl1.status = 1
AND COALESCE(CAST(cl1.type AS CHAR), '') <> '1'
) cl ON (
cl.iccid_mark = c.iccid_mark
OR (CHAR_LENGTH(c.iccid_mark) = 19 AND LEFT(cl.iccid_mark, 19) = c.iccid_mark)
)
LEFT JOIN tbl_set_meal sm ON sm.id = cl.meal_id
LEFT JOIN tbl_set_meal_series sms ON sms.id = sm.meal_series_id
WHERE c.iccid_mark IN %s
id AS card_id,
iccid_mark,
COALESCE(account_id, '') AS account_id,
COALESCE(account_name, '') AS account_name,
COALESCE(category, 0) AS category,
COALESCE(agent_id, '') AS agent_id,
COALESCE(agent_name, '') AS agent_name,
COALESCE(phone, '') AS phone
FROM tbl_card
WHERE iccid_mark IN %s
"""
# 收集每个完整 ICCID 命中的奇成行(可能多条 = 脏数据)
hits: dict[str, list[tuple[int, dict]]] = {}
card_rows = 0
card_batches = 0
with conn.cursor() as cur:
for batch in _chunks(full_set):
batch_keys = {by_full[x][0] for x in batch}
related_batch = sorted({x for key in batch_keys for x in related_keys_by_full[key]})
cur.execute(sql, (tuple(related_batch), tuple(batch)))
for batch in _chunks(full_set, batch_size):
card_batches += 1
cur.execute(card_sql, (tuple(batch),))
for row in cur.fetchall():
legacy_iccid = row["iccid"]
legacy_iccid = row["iccid_mark"]
if not legacy_iccid:
continue
matched = by_full.get(legacy_iccid)
@@ -680,45 +740,162 @@ def fetch_card_meta(
continue
card_key, rank = matched
hits.setdefault(card_key, []).append((rank, row))
card_rows += 1
timer.log("tbl_card", keys=len(full_set), batches=card_batches, rows=card_rows)
metas: dict[str, LegacyCardMeta] = {}
# 去重:同一完整 ICCID 命中多条不同 card_id → duplicates
metas_base: dict[str, dict] = {} # card_key → best tbl_card row
duplicates: set[str] = set()
categories: set[int] = set()
for card_key, rows in hits.items():
best_rank = min(rank for rank, _row in rows)
best_rows = [row for rank, row in rows if rank == best_rank]
# 同一张 tbl_card 可能因 card_life / virtual_number 前缀兜底 JOIN 出多行,
# 这不是主表重复;只有不同 card_id 才算同一业务 ICCID 命中多张卡。
best_rank = min(r for r, _ in rows)
best_rows = [row for r, row in rows if r == best_rank]
rows_by_card_id: dict[str, dict] = {}
for row in best_rows:
rows_by_card_id[str(row.get("card_id") or row.get("iccid") or "")] = row
rows_by_card_id[str(row.get("card_id") or row.get("iccid_mark") or "")] = row
if len(rows_by_card_id) > 1:
duplicates.add(card_key)
continue
row = next(iter(rows_by_card_id.values()))
expire = row.get("expire_date")
metas_base[card_key] = row
categories.add(int(row.get("category") or 0))
if not metas_base:
return {}, duplicates
# legacy_iccids: tbl_card 实际存储的 iccid_mark 值(用于从表精确查询)
legacy_iccids = sorted({row["iccid_mark"] for row in metas_base.values()})
# ── 阶段2: tbl_vendor_category(distinct category,通常极少几十个) ─────────
category_names: dict[int, str] = {}
cat_batches = 0
if categories:
cat_sql = """
SELECT category, COALESCE(category_name, '') AS category_name
FROM tbl_vendor_category
WHERE category IN %s
"""
cat_list = sorted(categories)
with conn.cursor() as cur:
for batch in _chunks(cat_list, batch_size):
cat_batches += 1
cur.execute(cat_sql, (tuple(batch),))
for row in cur.fetchall():
category_names[int(row["category"])] = row.get("category_name") or ""
timer.log("tbl_vendor_category", categories=len(categories), batches=cat_batches)
# ── 阶段3: tbl_card_life 当前生效正式套餐(精确匹配,Python 选最新) ─────────
# 精确用 legacy_iccid 查询;不再做 LEFT(iccid_mark, 19) 前缀扫描
life_sql = """
SELECT
iccid_mark,
COALESCE(meal_id, '') AS meal_id,
COALESCE(meal_name, '') AS meal_name,
COALESCE(CAST(type AS CHAR), '') AS meal_type,
expire_date
FROM tbl_card_life
WHERE iccid_mark IN %s
AND status = 1
AND COALESCE(CAST(type AS CHAR), '') <> '1'
ORDER BY iccid_mark ASC, expire_date DESC, id DESC
"""
current_meals: dict[str, dict] = {} # legacy_iccid → 当前生效套餐行
meal_ids: set[str] = set()
life_rows = 0
life_batches = 0
with conn.cursor() as cur:
for batch in _chunks(legacy_iccids, batch_size):
life_batches += 1
cur.execute(life_sql, (tuple(batch),))
for row in cur.fetchall():
iccid = row["iccid_mark"]
life_rows += 1
if iccid not in current_meals:
current_meals[iccid] = row
if row.get("meal_id"):
meal_ids.add(row["meal_id"])
timer.log("tbl_card_life", keys=len(legacy_iccids), batches=life_batches, rows=life_rows, found=len(current_meals))
# ── 阶段4: tbl_set_meal + tbl_set_meal_series(按 distinct meal_id) ───────
meal_series: dict[str, tuple[str, str]] = {} # meal_id → (series_id, series_name)
meal_batches = 0
if meal_ids:
meal_sql = """
SELECT
sm.id AS meal_id,
COALESCE(sm.meal_series_id, '') AS series_id,
COALESCE(sms.name, '') AS series_name
FROM tbl_set_meal sm
LEFT JOIN tbl_set_meal_series sms ON sms.id = sm.meal_series_id
WHERE sm.id IN %s
"""
meal_id_list = sorted(meal_ids)
with conn.cursor() as cur:
for batch in _chunks(meal_id_list, batch_size):
meal_batches += 1
cur.execute(meal_sql, (tuple(batch),))
for row in cur.fetchall():
mid = str(row["meal_id"] or "")
if mid:
meal_series[mid] = (row.get("series_id") or "", row.get("series_name") or "")
timer.log("tbl_set_meal", meal_ids=len(meal_ids), batches=meal_batches)
# ── 阶段5: tbl_virtual_number(精确匹配,不做前缀扫描) ─────────────────────
virtual_nos: dict[str, str] = {} # legacy_iccid → vcode
vn_rows = 0
vn_batches = 0
with conn.cursor() as cur:
for batch in _chunks(legacy_iccids, batch_size):
vn_batches += 1
cur.execute(
"SELECT iccid_mark, COALESCE(vcode, '') AS vcode FROM tbl_virtual_number WHERE iccid_mark IN %s",
(tuple(batch),),
)
for row in cur.fetchall():
iccid = row["iccid_mark"]
vn_rows += 1
if iccid:
virtual_nos[iccid] = row.get("vcode") or ""
timer.log("tbl_virtual_number", keys=len(legacy_iccids), batches=vn_batches, rows=vn_rows)
# ── 阶段6: Python 组装 ────────────────────────────────────────────────────
metas: dict[str, LegacyCardMeta] = {}
for card_key, base_row in metas_base.items():
legacy_iccid = base_row["iccid_mark"]
meal_row = current_meals.get(legacy_iccid, {})
expire = meal_row.get("expire_date")
expire_str = expire.strftime("%Y-%m-%d %H:%M:%S") if expire else None
meal_id = meal_row.get("meal_id") or ""
series_id, series_name = meal_series.get(str(meal_id), ("", ""))
metas[card_key] = LegacyCardMeta(
legacy_raw_iccid=row["iccid"],
account_id=row.get("account_id") or "",
account_name=row.get("account_name") or "",
category=int(row.get("category") or 0),
category_name=row.get("category_name") or "",
agent_id=row.get("agent_id") or "",
agent_name=row.get("agent_name") or "",
msisdn=row.get("phone") or "",
virtual_no=row.get("vcode") or "",
current_meal_id=row.get("meal_id") or "",
current_meal_name=row.get("meal_name") or "",
current_meal_type=str(row.get("meal_type") or ""),
legacy_raw_iccid=legacy_iccid,
account_id=base_row.get("account_id") or "",
account_name=base_row.get("account_name") or "",
category=int(base_row.get("category") or 0),
category_name=category_names.get(int(base_row.get("category") or 0), ""),
agent_id=base_row.get("agent_id") or "",
agent_name=base_row.get("agent_name") or "",
msisdn=base_row.get("phone") or "",
virtual_no=virtual_nos.get(legacy_iccid, ""),
current_meal_id=meal_id,
current_meal_name=meal_row.get("meal_name") or "",
current_meal_type=str(meal_row.get("meal_type") or ""),
current_expire_date=expire_str,
current_series_id=row.get("series_id") or "",
current_series_name=row.get("series_name") or "",
current_series_id=series_id,
current_series_name=series_name,
)
timer.log("assemble", cards=len(metas), duplicates=len(duplicates))
return metas, duplicates
def fetch_meal_meta(conn, meal_ids: Iterable[str]) -> dict[str, LegacyMealMeta]:
def fetch_meal_meta(
conn,
meal_ids: Iterable[str],
batch_size: int = BATCH_SIZE,
) -> dict[str, LegacyMealMeta]:
"""查套餐 + 系列元数据(供 scan_legacy 收集映射 legacy_* 字段)。"""
timer = _StageTimer("fetch_meal_meta")
id_list = sorted({m for m in meal_ids if m})
out: dict[str, LegacyMealMeta] = {}
if not id_list:
@@ -733,11 +910,15 @@ def fetch_meal_meta(conn, meal_ids: Iterable[str]) -> dict[str, LegacyMealMeta]:
LEFT JOIN tbl_set_meal_series sms ON sms.id = sm.meal_series_id
WHERE sm.id IN %s
"""
rows = 0
batches = 0
with conn.cursor() as cur:
for batch in _chunks(id_list):
for batch in _chunks(id_list, batch_size):
batches += 1
cur.execute(sql, (tuple(batch),))
for row in cur.fetchall():
mid = row["meal_id"]
rows += 1
if not mid:
continue
out[mid] = LegacyMealMeta(
@@ -746,11 +927,17 @@ def fetch_meal_meta(conn, meal_ids: Iterable[str]) -> dict[str, LegacyMealMeta]:
series_id=row.get("series_id") or "",
series_name=row.get("series_name") or "",
)
timer.log("tbl_set_meal", meal_ids=len(id_list), batches=batches, rows=rows)
return out
def fetch_agent_balances(conn, agent_ids: Iterable[str]) -> dict[str, LegacyAgentBalance]:
def fetch_agent_balances(
conn,
agent_ids: Iterable[str],
batch_size: int = BATCH_SIZE,
) -> dict[str, LegacyAgentBalance]:
"""查代理主钱包余额(tbl_agent.balance_money,元单位)。"""
timer = _StageTimer("fetch_agent_balances")
agent_list = sorted({a for a in agent_ids if a})
out: dict[str, LegacyAgentBalance] = {}
if not agent_list:
@@ -761,12 +948,17 @@ def fetch_agent_balances(conn, agent_ids: Iterable[str]) -> dict[str, LegacyAgen
FROM tbl_agent
WHERE id IN %s
"""
rows = 0
batches = 0
with conn.cursor() as cur:
for batch in _chunks(agent_list):
for batch in _chunks(agent_list, batch_size):
batches += 1
cur.execute(sql, (tuple(batch),))
for row in cur.fetchall():
aid = row["id"]
rows += 1
if not aid:
continue
out[aid] = LegacyAgentBalance(agent_id=aid, balance_fen=_to_fen(row.get("balance_money")))
timer.log("tbl_agent", agents=len(agent_list), batches=batches, rows=rows)
return out

View File

@@ -139,6 +139,7 @@ class AgentMapping:
legacy_agent_id: str
legacy_agent_name: str
target_shop_code: Optional[str] # legacy_agent 模式下允许 null,表示该代理资产进平台库存
no_downtime: bool = False # 迁移时免停机:有生效套餐的卡直接以 network_status=1 导入
@dataclass
@@ -166,6 +167,11 @@ class Mapping:
return None
return self.series.get(str(legacy_series_id).strip())
def is_no_downtime_agent(self, legacy_agent_id: str) -> bool:
"""判断代理是否配置了免停机迁移。"""
agent = self.agents.get(str(legacy_agent_id or "").strip())
return agent.no_downtime if agent else False
def lookup_agent_shop_code(self, legacy_agent_id: str) -> Optional[str]:
"""按 agent_id 查 target_shop_code,空字符串视为 None(进平台库存)。"""
if not legacy_agent_id:
@@ -260,6 +266,7 @@ def load_mapping(config_dir: Path) -> Mapping:
legacy_agent_id=str(item["legacy_agent_id"]).strip(),
legacy_agent_name=str(item.get("legacy_agent_name") or ""),
target_shop_code=_opt_shop_code(item.get("target_shop_code"), "agents[].target_shop_code"),
no_downtime=bool(item.get("no_downtime", False)),
)
m.agents[a.legacy_agent_id] = a
@@ -536,6 +543,7 @@ def _append_agents(lines: list[str], mapping: Mapping) -> None:
f" - legacy_agent_id: {_yaml_scalar(item.legacy_agent_id)}",
f" legacy_agent_name: {_yaml_scalar(item.legacy_agent_name)}",
f" target_shop_code: {_yaml_scalar(item.target_shop_code)}",
f" no_downtime: {_yaml_scalar(item.no_downtime)}",
])

View File

@@ -621,6 +621,20 @@ def _write_iot_cards(
card_status = 2 if shop_code else 1
shop_id_expr = _shop_id_sub(shop_code)
# 免停机迁移:代理配置 no_downtime=true 且卡在奇成有当前生效正式套餐
# finalize 保证最后执行,此时 step2 套餐已落库,轮询系统不会在此之前触碰这张卡
is_no_downtime = bool(meta.current_meal_id) and mapping.is_no_downtime_agent(meta.agent_id)
if is_no_downtime:
activation_status = 1
real_name_status = 1 # 卡在线说明已实名,写 1 防止 EvaluateAndAct 因 not_realname 停机
network_status = 1
stop_reason_expr = "NULL"
else:
activation_status = 0
real_name_status = 0
network_status = 0
stop_reason_expr = _sql_str(_initial_polling_stop_reason(c))
f.write(
"INSERT INTO tb_iot_card (\n"
" iccid, iccid_19, iccid_20,\n"
@@ -636,9 +650,9 @@ def _write_iot_cards(
f" {_sql_str(c.iccid_full)}, {_sql_str(c.iccid_19)}, {iccid_20_expr},\n"
f" {carrier.target_carrier_id}, {_sql_str(carrier.target_carrier_type)}, {_sql_str(carrier.target_carrier_name)},\n"
f" {_sql_str(card_category)}, {_sql_str(mapping.migration_batch_no)},\n"
f" {card_status}, 0, 0, 0,\n"
f" {card_status}, {activation_status}, {real_name_status}, {network_status},\n"
f" 1, 1, {is_standalone},\n"
f" 'after_order', {_sql_str(_initial_polling_stop_reason(c))},\n"
f" 'after_order', {stop_reason_expr},\n"
f" {_sql_str(meta.msisdn) if meta.msisdn else 'NULL'}, "
f"{_sql_str(card_virtual_no) if card_virtual_no else 'NULL'}, "
f"{shop_id_expr}, {series_id_expr},\n"
@@ -1077,7 +1091,6 @@ def write_step2(
cards: list[CardRow],
devices: list[DeviceRow],
mapping: Mapping,
card_metas: dict[str, LegacyCardMeta],
usage_snapshots: dict[str, LegacyCardUsage],
package_lifecycles: dict[str, list[LegacyPackageLifecycle]],
commission_snapshots: dict[str, LegacyCommissionAccount],

View File

@@ -4,7 +4,7 @@
工作流:
1. 读 mapping.yaml(必须先跑过 scan_legacy.py)
2. 读 cards.csv / devices.csv
3. 连奇成查每张卡的元数据(category/agent_id/msisdn/virtual_no)
3. 连奇成查每张卡的元数据(category/agent_id/msisdn/virtual_no/当前生效套餐)
4. 应用 mapping 把 legacy_* 翻译成 target_*,自动决定归属
5. 生成 output/step1_*.sql + errors.csv + summary.txt
@@ -14,11 +14,13 @@
用法:
python3 migrate_assets.py [--config-dir config] [--resources-dir resources] [--output-dir output]
[--batch-size 1000]
"""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -31,6 +33,7 @@ def main() -> int:
parser.add_argument("--config-dir", default="config", help="yaml 配置目录")
parser.add_argument("--resources-dir", default="resources", help="CSV 资源目录")
parser.add_argument("--output-dir", default="output", help="SQL/CSV 输出目录")
parser.add_argument("--batch-size", type=int, default=legacy_query.BATCH_SIZE, help="老库批量查询大小")
args = parser.parse_args()
base = Path(__file__).resolve().parent
@@ -38,16 +41,26 @@ def main() -> int:
resources_dir = (base / args.resources_dir).resolve()
output_dir = (base / args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
batch_size = args.batch_size
t0 = time.perf_counter()
mapping = mapping_loader.load_mapping(config_dir)
cards, devices, errors = csv_loader.load_assets(resources_dir, mapping)
print(f"[migrate] 加载 CSV: cards={len(cards)} devices={len(devices)} elapsed={time.perf_counter()-t0:.1f}s")
# 连奇成查卡元数据(运营商分类、agent_id、msisdn、virtual_no、当前生效套餐)
iccid_pairs = [(c.iccid_19, c.iccid_20, c.allow_iccid_19_lookup) for c in cards]
t1 = time.perf_counter()
dsn = mapping_loader.load_legacy_dsn(config_dir)
with legacy_query.connect_readonly(dsn) as conn:
card_metas, dup_iccids = legacy_query.fetch_card_meta(conn, iccid_pairs)
print(f"[migrate] 连接老库: elapsed={time.perf_counter()-t1:.1f}s")
t2 = time.perf_counter()
card_metas, dup_iccids = legacy_query.fetch_card_meta(conn, iccid_pairs, batch_size=batch_size)
print(f"[migrate] fetch_card_meta: cards={len(card_metas)} duplicates={len(dup_iccids)} elapsed={time.perf_counter()-t2:.1f}s")
t3 = time.perf_counter()
sql_builder.write_step1(
output_dir,
cards=cards,
@@ -57,8 +70,10 @@ def main() -> int:
duplicate_iccids=dup_iccids,
errors=errors,
)
print(f"[migrate] SQL 生成: elapsed={time.perf_counter()-t3:.1f}s")
print(f"完成:卡 {len(cards)} 张,设备 {len(devices)} 台,异常 {len(errors)}")
total = time.perf_counter() - t0
print(f"完成:卡 {len(cards)} 张,设备 {len(devices)} 台,异常 {len(errors)} 行,总耗时 {total:.1f}s")
print(f"SQL 输出: {output_dir}")
return 0

View File

@@ -5,17 +5,19 @@
工作流:
1. 读 mapping.yaml + cards.csv
2. 连奇成查:卡元数据(card_meta) + 累计流量(card_usage) + 代理佣金/主钱包余额
2. 连奇成查:累计流量(card_usage) + 套餐生命周期 + 代理佣金/主钱包余额
3. 卡 → 当前生效套餐 → mapping.packages → target_package_id
4. 生成 step2_*.sql
用法:
python3 migrate_runtime.py [--config-dir config] [--resources-dir resources] [--output-dir output]
[--batch-size 1000]
"""
from __future__ import annotations
import argparse
import sys
import time
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
@@ -28,6 +30,7 @@ def main() -> int:
parser.add_argument("--config-dir", default="config", help="yaml 配置目录")
parser.add_argument("--resources-dir", default="resources", help="CSV 资源目录")
parser.add_argument("--output-dir", default="output", help="SQL/CSV 输出目录")
parser.add_argument("--batch-size", type=int, default=legacy_query.BATCH_SIZE, help="老库批量查询大小")
args = parser.parse_args()
base = Path(__file__).resolve().parent
@@ -35,9 +38,13 @@ def main() -> int:
resources_dir = (base / args.resources_dir).resolve()
output_dir = (base / args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
batch_size = args.batch_size
t0 = time.perf_counter()
mapping = mapping_loader.load_mapping(config_dir)
cards, devices, errors = csv_loader.load_assets(resources_dir, mapping)
print(f"[migrate] 加载 CSV: cards={len(cards)} devices={len(devices)} elapsed={time.perf_counter()-t0:.1f}s")
if errors:
sql_builder.write_runtime_input_errors(output_dir, errors)
print(f"cards.csv/devices.csv 存在 {len(errors)} 个基础错误,已写入 {output_dir / 'errors.csv'}", file=sys.stderr)
@@ -46,27 +53,39 @@ def main() -> int:
iccid_pairs = [(c.iccid_19, c.iccid_20, c.allow_iccid_19_lookup) for c in cards]
agent_ids = [a.legacy_agent_id for a in mapping.agents.values() if (a.target_shop_code or "").strip()]
t1 = time.perf_counter()
dsn = mapping_loader.load_legacy_dsn(config_dir)
with legacy_query.connect_readonly(dsn) as conn:
card_metas, _dup_iccids = legacy_query.fetch_card_meta(conn, iccid_pairs)
usage_snapshots = legacy_query.fetch_card_usage(conn, iccid_pairs)
package_lifecycles = legacy_query.fetch_package_lifecycles(conn, iccid_pairs)
commission_snapshots = legacy_query.fetch_commission_accounts(conn, agent_ids) if agent_ids else {}
agent_balances = legacy_query.fetch_agent_balances(conn, agent_ids) if agent_ids else {}
print(f"[migrate] 连接老库: elapsed={time.perf_counter()-t1:.1f}s")
t2 = time.perf_counter()
usage_snapshots = legacy_query.fetch_card_usage(conn, iccid_pairs, batch_size=batch_size)
print(f"[migrate] fetch_card_usage: cards={len(usage_snapshots)} elapsed={time.perf_counter()-t2:.1f}s")
t3 = time.perf_counter()
package_lifecycles = legacy_query.fetch_package_lifecycles(conn, iccid_pairs, batch_size=batch_size)
print(f"[migrate] fetch_package_lifecycles: cards={len(package_lifecycles)} elapsed={time.perf_counter()-t3:.1f}s")
t4 = time.perf_counter()
commission_snapshots = legacy_query.fetch_commission_accounts(conn, agent_ids, batch_size=batch_size) if agent_ids else {}
agent_balances = legacy_query.fetch_agent_balances(conn, agent_ids, batch_size=batch_size) if agent_ids else {}
print(f"[migrate] fetch_agent_data: agents={len(agent_ids)} elapsed={time.perf_counter()-t4:.1f}s")
t5 = time.perf_counter()
warnings = sql_builder.write_step2(
output_dir,
cards=cards,
devices=devices,
mapping=mapping,
card_metas=card_metas,
usage_snapshots=usage_snapshots,
package_lifecycles=package_lifecycles,
commission_snapshots=commission_snapshots,
agent_balances=agent_balances,
)
print(f"[migrate] SQL 生成: elapsed={time.perf_counter()-t5:.1f}s")
print(f"完成:卡 {len(cards)} 张,代理 {len(agent_ids)} 个,警告 {len(warnings)}")
total = time.perf_counter() - t0
print(f"完成:卡 {len(cards)} 张,代理 {len(agent_ids)} 个,警告 {len(warnings)} 条,总耗时 {total:.1f}s")
print(f"SQL 输出: {output_dir}")
return 0

View File

@@ -0,0 +1,186 @@
#!/usr/bin/env python3
"""卡删除脚本:读取 ICCID 列表,生成数据库删除 SQL 和 Redis 轮询清理命令。
用法:
python3 remove_cards.py --input <csv文件> [--output-dir output]
CSV 格式: 纯文本,每行一个 ICCID首行可有 "iccid" 表头(自动跳过)
输出:
output/remove_cards_db.sql -- 数据库软删 + 标识符硬删 SQL事务封装
output/remove_cards_redis_gen.sql -- 在数据库执行后把输出通过 redis-cli 清理轮询队列
"""
from __future__ import annotations
import argparse
import sys
from datetime import datetime
from pathlib import Path
def load_iccids(csv_path: Path) -> list[str]:
"""读取 CSV 文件中的 ICCID 列表,自动跳过空行和表头。"""
iccids: list[str] = []
with csv_path.open(encoding="utf-8") as f:
for lineno, raw in enumerate(f, 1):
line = raw.strip()
if not line:
continue
# 跳过纯字母表头(如 "iccid"
if lineno == 1 and not line.isdigit() and not line.startswith("8"):
print(f"[remove] 跳过表头: {line!r}", file=sys.stderr)
continue
iccids.append(line)
return iccids
def sql_in_list(iccids: list[str]) -> str:
"""生成 SQL IN 列表字符串,如 '898...', '898...'"""
return ", ".join(f"'{i}'" for i in iccids)
def generate_db_sql(iccids: list[str], generated_at: str) -> str:
in_list = sql_in_list(iccids)
count = len(iccids)
return f"""\
-- ============================================================
-- 迁移卡删除脚本 (数据库部分)
-- 生成时间: {generated_at}
-- 卡数量 : {count}
-- 执行前请先用 remove_cards_redis_gen.sql 生成 Redis 清理命令并执行
-- 建议执行: psql ... -1 -f <此文件>
-- ============================================================
BEGIN;
-- 临时表:本次要删除的卡
CREATE TEMP TABLE tmp_remove_cards AS
SELECT id, iccid
FROM tb_iot_card
WHERE iccid IN ({in_list});
DO $$ BEGIN
RAISE NOTICE '本次将删除 % 张卡(共输入 {count} 个 ICCID', (SELECT COUNT(*) FROM tmp_remove_cards);
END $$;
-- 1. 删除套餐使用记录
DELETE FROM tb_package_usage
WHERE iot_card_id IN (SELECT id FROM tmp_remove_cards);
-- 2. 删除订单
DELETE FROM tb_order
WHERE iot_card_id IN (SELECT id FROM tmp_remove_cards);
-- 3. 删除分配记录
DELETE FROM tb_asset_allocation_record
WHERE asset_type = 'iot_card'
AND asset_id IN (SELECT id FROM tmp_remove_cards);
-- 4. 删除资产钱包
DELETE FROM tb_asset_wallet
WHERE resource_type = 'iot_card'
AND resource_id IN (SELECT id FROM tmp_remove_cards);
-- 5. 删除设备绑卡关系
DELETE FROM tb_device_sim_binding
WHERE iot_card_id IN (SELECT id FROM tmp_remove_cards);
-- 6. 删除资产标识符
DELETE FROM tb_asset_identifier
WHERE asset_type = 'iot_card'
AND asset_id IN (SELECT id FROM tmp_remove_cards);
-- 7. 删除卡本体(最后执行)
DELETE FROM tb_iot_card
WHERE id IN (SELECT id FROM tmp_remove_cards);
COMMIT;
"""
def generate_redis_gen_sql(iccids: list[str], generated_at: str) -> str:
in_list = sql_in_list(iccids)
count = len(iccids)
return f"""\
-- ============================================================
-- 轮询队列清理命令生成 SQL
-- 生成时间: {generated_at}
-- 卡数量 : {count}
-- 执行方式:
-- psql ... -t -A -c "$(cat remove_cards_redis_gen.sql)" | redis-cli --pipe
-- 或:
-- psql ... -t -A -f remove_cards_redis_gen.sql | redis-cli
-- ============================================================
WITH cards AS (
SELECT id FROM tb_iot_card
WHERE iccid IN ({in_list})
),
task_types(task_type) AS (
VALUES
('polling:realname'),
('polling:carddata'),
('polling:package'),
('polling:protect'),
('polling:card_status')
)
SELECT format('ZREM polling:shard:%s:queue:%s %s', id % 16, task_type, id)
FROM cards CROSS JOIN task_types
UNION ALL
SELECT format('DEL polling:card:%s', id)
FROM cards
UNION ALL
SELECT format('LREM polling:manual:%s 0 %s', task_type, id)
FROM cards CROSS JOIN task_types
UNION ALL
SELECT format('SREM polling:manual:dedupe:%s %s', task_type, id)
FROM cards CROSS JOIN task_types;
"""
def main() -> int:
parser = argparse.ArgumentParser(description="迁移卡删除脚本:生成删除 SQL 和 Redis 清理命令")
parser.add_argument("--input", required=True, help="ICCID 列表 CSV 文件路径")
parser.add_argument("--output-dir", default="output", help="输出目录(默认 output")
args = parser.parse_args()
base = Path(__file__).resolve().parent
input_path = Path(args.input)
if not input_path.is_absolute():
input_path = base / input_path
output_dir = (base / args.output_dir).resolve()
output_dir.mkdir(parents=True, exist_ok=True)
if not input_path.exists():
print(f"[remove] 错误: 找不到输入文件 {input_path}", file=sys.stderr)
return 1
iccids = load_iccids(input_path)
if not iccids:
print("[remove] 错误: CSV 中没有找到有效的 ICCID", file=sys.stderr)
return 1
print(f"[remove] 读取到 {len(iccids)} 个 ICCID开始生成 SQL...")
generated_at = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
db_sql_path = output_dir / "remove_cards_db.sql"
redis_sql_path = output_dir / "remove_cards_redis_gen.sql"
db_sql_path.write_text(generate_db_sql(iccids, generated_at), encoding="utf-8")
redis_sql_path.write_text(generate_redis_gen_sql(iccids, generated_at), encoding="utf-8")
print(f"[remove] 生成完成:")
print(f" 数据库删除 SQL : {db_sql_path}")
print(f" Redis 清理 SQL : {redis_sql_path}")
print()
print("执行步骤:")
print(" 1. 先执行 Redis 清理(在删除卡数据之前,以便 SQL 还能查到卡 ID:")
print(f" psql <连接参数> -t -A -f {redis_sql_path} | redis-cli")
print(" 2. 再执行数据库删除:")
print(f" psql <连接参数> -1 -f {db_sql_path}")
return 0
if __name__ == "__main__":
sys.exit(main())