Compare commits
50 Commits
86f8d0b644
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| c10b70757f | |||
| 4d1e714366 | |||
| d2b765327c | |||
| 7dfcf41b41 | |||
| ed334b946b | |||
| 95b2334658 | |||
| da66e673fe | |||
| 284f6c15c7 | |||
| 55918a0b88 | |||
| d2494798aa | |||
| b9733c4913 | |||
| 9bd55a1695 | |||
| e78f5794b9 | |||
| df76e33105 | |||
| ec86dbf463 | |||
| 817d0d6e04 | |||
| b44363b335 | |||
| 3e8f613475 | |||
| 242e0b1f40 | |||
| 060d8fd65e | |||
| f3297f0529 | |||
| 63ca12393b | |||
| 429edf0d19 | |||
| 7c64e433e8 | |||
| 269769bfe4 | |||
| 1980c846f2 | |||
| 89f9875a97 | |||
| 30c56e66dd | |||
| c86afbfa8f | |||
| aa41a5ed5e | |||
| a308ee228b | |||
| b0da71bd25 | |||
| 7f18765911 | |||
| 876c92095c | |||
| e45610661e | |||
| d85d7bffd6 | |||
| fe77d9ca72 | |||
| 9b83f92fb6 | |||
| 2248558bd3 | |||
| 2aae31ac5f | |||
| 5031bf15b9 | |||
| 9c768e0719 | |||
| b6c379265d | |||
| 4156bfc9dd | |||
| 0ef136f008 | |||
| b1d6355a7d | |||
| 907e500ffb | |||
| 275debdd38 | |||
| b9c3875c08 | |||
| b5147d1acb |
@@ -59,7 +59,7 @@ Archive a completed change in the experimental workflow.
|
|||||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||||
|
|
||||||
If user chooses sync, execute `/opsx:sync` logic. Proceed to archive regardless of choice.
|
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||||
|
|
||||||
5. **Perform the archive**
|
5. **Perform the archive**
|
||||||
|
|
||||||
@@ -153,5 +153,5 @@ Target archive directory already exists.
|
|||||||
- Don't block archive on warnings - just inform and confirm
|
- Don't block archive on warnings - just inform and confirm
|
||||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||||
- Show clear summary of what happened
|
- Show clear summary of what happened
|
||||||
- If sync is requested, use /opsx:sync approach (agent-driven)
|
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
|
||||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||||
|
|||||||
@@ -1,242 +0,0 @@
|
|||||||
---
|
|
||||||
name: "OPSX: Bulk Archive"
|
|
||||||
description: Archive multiple completed changes at once
|
|
||||||
category: Workflow
|
|
||||||
tags: [workflow, archive, experimental, bulk]
|
|
||||||
---
|
|
||||||
|
|
||||||
Archive multiple completed changes in a single operation.
|
|
||||||
|
|
||||||
This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented.
|
|
||||||
|
|
||||||
**Input**: None required (prompts for selection)
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **Get active changes**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get all active changes.
|
|
||||||
|
|
||||||
If no active changes exist, inform user and stop.
|
|
||||||
|
|
||||||
2. **Prompt for change selection**
|
|
||||||
|
|
||||||
Use **AskUserQuestion tool** with multi-select to let user choose changes:
|
|
||||||
- Show each change with its schema
|
|
||||||
- Include an option for "All changes"
|
|
||||||
- Allow any number of selections (1+ works, 2+ is the typical use case)
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT auto-select. Always let the user choose.
|
|
||||||
|
|
||||||
3. **Batch validation - gather status for all selected changes**
|
|
||||||
|
|
||||||
For each selected change, collect:
|
|
||||||
|
|
||||||
a. **Artifact status** - Run `openspec status --change "<name>" --json`
|
|
||||||
- Parse `schemaName` and `artifacts` list
|
|
||||||
- Note which artifacts are `done` vs other states
|
|
||||||
|
|
||||||
b. **Task completion** - Read `openspec/changes/<name>/tasks.md`
|
|
||||||
- Count `- [ ]` (incomplete) vs `- [x]` (complete)
|
|
||||||
- If no tasks file exists, note as "No tasks"
|
|
||||||
|
|
||||||
c. **Delta specs** - Check `openspec/changes/<name>/specs/` directory
|
|
||||||
- List which capability specs exist
|
|
||||||
- For each, extract requirement names (lines matching `### Requirement: <name>`)
|
|
||||||
|
|
||||||
4. **Detect spec conflicts**
|
|
||||||
|
|
||||||
Build a map of `capability -> [changes that touch it]`:
|
|
||||||
|
|
||||||
```
|
|
||||||
auth -> [change-a, change-b] <- CONFLICT (2+ changes)
|
|
||||||
api -> [change-c] <- OK (only 1 change)
|
|
||||||
```
|
|
||||||
|
|
||||||
A conflict exists when 2+ selected changes have delta specs for the same capability.
|
|
||||||
|
|
||||||
5. **Resolve conflicts agentically**
|
|
||||||
|
|
||||||
**For each conflict**, investigate the codebase:
|
|
||||||
|
|
||||||
a. **Read the delta specs** from each conflicting change to understand what each claims to add/modify
|
|
||||||
|
|
||||||
b. **Search the codebase** for implementation evidence:
|
|
||||||
- Look for code implementing requirements from each delta spec
|
|
||||||
- Check for related files, functions, or tests
|
|
||||||
|
|
||||||
c. **Determine resolution**:
|
|
||||||
- If only one change is actually implemented -> sync that one's specs
|
|
||||||
- If both implemented -> apply in chronological order (older first, newer overwrites)
|
|
||||||
- If neither implemented -> skip spec sync, warn user
|
|
||||||
|
|
||||||
d. **Record resolution** for each conflict:
|
|
||||||
- Which change's specs to apply
|
|
||||||
- In what order (if both)
|
|
||||||
- Rationale (what was found in codebase)
|
|
||||||
|
|
||||||
6. **Show consolidated status table**
|
|
||||||
|
|
||||||
Display a table summarizing all changes:
|
|
||||||
|
|
||||||
```
|
|
||||||
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|
|
||||||
|---------------------|-----------|-------|---------|-----------|--------|
|
|
||||||
| schema-management | Done | 5/5 | 2 delta | None | Ready |
|
|
||||||
| project-config | Done | 3/3 | 1 delta | None | Ready |
|
|
||||||
| add-oauth | Done | 4/4 | 1 delta | auth (!) | Ready* |
|
|
||||||
| add-verify-skill | 1 left | 2/5 | None | None | Warn |
|
|
||||||
```
|
|
||||||
|
|
||||||
For conflicts, show the resolution:
|
|
||||||
```
|
|
||||||
* Conflict resolution:
|
|
||||||
- auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order)
|
|
||||||
```
|
|
||||||
|
|
||||||
For incomplete changes, show warnings:
|
|
||||||
```
|
|
||||||
Warnings:
|
|
||||||
- add-verify-skill: 1 incomplete artifact, 3 incomplete tasks
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Confirm batch operation**
|
|
||||||
|
|
||||||
Use **AskUserQuestion tool** with a single confirmation:
|
|
||||||
|
|
||||||
- "Archive N changes?" with options based on status
|
|
||||||
- Options might include:
|
|
||||||
- "Archive all N changes"
|
|
||||||
- "Archive only N ready changes (skip incomplete)"
|
|
||||||
- "Cancel"
|
|
||||||
|
|
||||||
If there are incomplete changes, make clear they'll be archived with warnings.
|
|
||||||
|
|
||||||
8. **Execute archive for each confirmed change**
|
|
||||||
|
|
||||||
Process changes in the determined order (respecting conflict resolution):
|
|
||||||
|
|
||||||
a. **Sync specs** if delta specs exist:
|
|
||||||
- Use the openspec-sync-specs approach (agent-driven intelligent merge)
|
|
||||||
- For conflicts, apply in resolved order
|
|
||||||
- Track if sync was done
|
|
||||||
|
|
||||||
b. **Perform the archive**:
|
|
||||||
```bash
|
|
||||||
mkdir -p openspec/changes/archive
|
|
||||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
|
||||||
```
|
|
||||||
|
|
||||||
c. **Track outcome** for each change:
|
|
||||||
- Success: archived successfully
|
|
||||||
- Failed: error during archive (record error)
|
|
||||||
- Skipped: user chose not to archive (if applicable)
|
|
||||||
|
|
||||||
9. **Display summary**
|
|
||||||
|
|
||||||
Show final results:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Bulk Archive Complete
|
|
||||||
|
|
||||||
Archived 3 changes:
|
|
||||||
- schema-management-cli -> archive/2026-01-19-schema-management-cli/
|
|
||||||
- project-config -> archive/2026-01-19-project-config/
|
|
||||||
- add-oauth -> archive/2026-01-19-add-oauth/
|
|
||||||
|
|
||||||
Skipped 1 change:
|
|
||||||
- add-verify-skill (user chose not to archive incomplete)
|
|
||||||
|
|
||||||
Spec sync summary:
|
|
||||||
- 4 delta specs synced to main specs
|
|
||||||
- 1 conflict resolved (auth: applied both in chronological order)
|
|
||||||
```
|
|
||||||
|
|
||||||
If any failures:
|
|
||||||
```
|
|
||||||
Failed 1 change:
|
|
||||||
- some-change: Archive directory already exists
|
|
||||||
```
|
|
||||||
|
|
||||||
**Conflict Resolution Examples**
|
|
||||||
|
|
||||||
Example 1: Only one implemented
|
|
||||||
```
|
|
||||||
Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt]
|
|
||||||
|
|
||||||
Checking add-oauth:
|
|
||||||
- Delta adds "OAuth Provider Integration" requirement
|
|
||||||
- Searching codebase... found src/auth/oauth.ts implementing OAuth flow
|
|
||||||
|
|
||||||
Checking add-jwt:
|
|
||||||
- Delta adds "JWT Token Handling" requirement
|
|
||||||
- Searching codebase... no JWT implementation found
|
|
||||||
|
|
||||||
Resolution: Only add-oauth is implemented. Will sync add-oauth specs only.
|
|
||||||
```
|
|
||||||
|
|
||||||
Example 2: Both implemented
|
|
||||||
```
|
|
||||||
Conflict: specs/api/spec.md touched by [add-rest-api, add-graphql]
|
|
||||||
|
|
||||||
Checking add-rest-api (created 2026-01-10):
|
|
||||||
- Delta adds "REST Endpoints" requirement
|
|
||||||
- Searching codebase... found src/api/rest.ts
|
|
||||||
|
|
||||||
Checking add-graphql (created 2026-01-15):
|
|
||||||
- Delta adds "GraphQL Schema" requirement
|
|
||||||
- Searching codebase... found src/api/graphql.ts
|
|
||||||
|
|
||||||
Resolution: Both implemented. Will apply add-rest-api specs first,
|
|
||||||
then add-graphql specs (chronological order, newer takes precedence).
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output On Success**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Bulk Archive Complete
|
|
||||||
|
|
||||||
Archived N changes:
|
|
||||||
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
|
|
||||||
- <change-2> -> archive/YYYY-MM-DD-<change-2>/
|
|
||||||
|
|
||||||
Spec sync summary:
|
|
||||||
- N delta specs synced to main specs
|
|
||||||
- No conflicts (or: M conflicts resolved)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output On Partial Success**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Bulk Archive Complete (partial)
|
|
||||||
|
|
||||||
Archived N changes:
|
|
||||||
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
|
|
||||||
|
|
||||||
Skipped M changes:
|
|
||||||
- <change-2> (user chose not to archive incomplete)
|
|
||||||
|
|
||||||
Failed K changes:
|
|
||||||
- <change-3>: Archive directory already exists
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output When No Changes**
|
|
||||||
|
|
||||||
```
|
|
||||||
## No Changes to Archive
|
|
||||||
|
|
||||||
No active changes found. Use `/opsx:new` to create a new change.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Allow any number of changes (1+ is fine, 2+ is the typical use case)
|
|
||||||
- Always prompt for selection, never auto-select
|
|
||||||
- Detect spec conflicts early and resolve by checking codebase
|
|
||||||
- When both changes are implemented, apply specs in chronological order
|
|
||||||
- Skip spec sync only when implementation is missing (warn user)
|
|
||||||
- Show clear per-change status before confirming
|
|
||||||
- Use single confirmation for entire batch
|
|
||||||
- Track and report all outcomes (success/skip/fail)
|
|
||||||
- Preserve .openspec.yaml when moving to archive
|
|
||||||
- Archive directory target uses current date: YYYY-MM-DD-<name>
|
|
||||||
- If archive target exists, fail that change but continue with others
|
|
||||||
@@ -1,114 +0,0 @@
|
|||||||
---
|
|
||||||
name: "OPSX: Continue"
|
|
||||||
description: Continue working on a change - create the next artifact (Experimental)
|
|
||||||
category: Workflow
|
|
||||||
tags: [workflow, artifacts, experimental]
|
|
||||||
---
|
|
||||||
|
|
||||||
Continue working on a change by creating the next artifact.
|
|
||||||
|
|
||||||
**Input**: Optionally specify a change name after `/opsx:continue` (e.g., `/opsx:continue add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no change name provided, prompt for selection**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on.
|
|
||||||
|
|
||||||
Present the top 3-4 most recently modified changes as options, showing:
|
|
||||||
- Change name
|
|
||||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
|
||||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
|
||||||
- How recently it was modified (from `lastModified` field)
|
|
||||||
|
|
||||||
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue.
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
|
||||||
|
|
||||||
2. **Check current status**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>" --json
|
|
||||||
```
|
|
||||||
Parse the JSON to understand current state. The response includes:
|
|
||||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
|
||||||
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
|
|
||||||
- `isComplete`: Boolean indicating if all artifacts are complete
|
|
||||||
|
|
||||||
3. **Act based on status**:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If all artifacts are complete (`isComplete: true`)**:
|
|
||||||
- Congratulate the user
|
|
||||||
- Show final status including the schema used
|
|
||||||
- Suggest: "All artifacts created! You can now implement this change with `/opsx:apply` or archive it with `/opsx:archive`."
|
|
||||||
- STOP
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If artifacts are ready to create** (status shows artifacts with `status: "ready"`):
|
|
||||||
- Pick the FIRST artifact with `status: "ready"` from the status output
|
|
||||||
- Get its instructions:
|
|
||||||
```bash
|
|
||||||
openspec instructions <artifact-id> --change "<name>" --json
|
|
||||||
```
|
|
||||||
- Parse the JSON. The key fields are:
|
|
||||||
- `context`: Project background (constraints for you - do NOT include in output)
|
|
||||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
|
||||||
- `template`: The structure to use for your output file
|
|
||||||
- `instruction`: Schema-specific guidance
|
|
||||||
- `outputPath`: Where to write the artifact
|
|
||||||
- `dependencies`: Completed artifacts to read for context
|
|
||||||
- **Create the artifact file**:
|
|
||||||
- Read any completed dependency files for context
|
|
||||||
- Use `template` as the structure - fill in its sections
|
|
||||||
- Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file
|
|
||||||
- Write to the output path specified in instructions
|
|
||||||
- Show what was created and what's now unlocked
|
|
||||||
- STOP after creating ONE artifact
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If no artifacts are ready (all blocked)**:
|
|
||||||
- This shouldn't happen with a valid schema
|
|
||||||
- Show status and suggest checking for issues
|
|
||||||
|
|
||||||
4. **After creating an artifact, show progress**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output**
|
|
||||||
|
|
||||||
After each invocation, show:
|
|
||||||
- Which artifact was created
|
|
||||||
- Schema workflow being used
|
|
||||||
- Current progress (N/M complete)
|
|
||||||
- What artifacts are now unlocked
|
|
||||||
- Prompt: "Run `/opsx:continue` to create the next artifact"
|
|
||||||
|
|
||||||
**Artifact Creation Guidelines**
|
|
||||||
|
|
||||||
The artifact types and their purpose depend on the schema. Use the `instruction` field from the instructions output to understand what to create.
|
|
||||||
|
|
||||||
Common artifact patterns:
|
|
||||||
|
|
||||||
**spec-driven schema** (proposal → specs → design → tasks):
|
|
||||||
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
|
|
||||||
- The Capabilities section is critical - each capability listed will need a spec file.
|
|
||||||
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
|
|
||||||
- **design.md**: Document technical decisions, architecture, and implementation approach.
|
|
||||||
- **tasks.md**: Break down implementation into checkboxed tasks.
|
|
||||||
|
|
||||||
For other schemas, follow the `instruction` field from the CLI output.
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Create ONE artifact per invocation
|
|
||||||
- Always read dependency artifacts before creating a new one
|
|
||||||
- Never skip artifacts or create out of order
|
|
||||||
- If context is unclear, ask the user before creating
|
|
||||||
- Verify the artifact file exists after writing before marking progress
|
|
||||||
- Use the schema's artifact sequence, don't assume specific artifact names
|
|
||||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
|
||||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
|
||||||
- These guide what you write, but should never appear in the output
|
|
||||||
@@ -7,7 +7,7 @@ tags: [workflow, explore, experimental, thinking]
|
|||||||
|
|
||||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||||
|
|
||||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with `/opsx:new` or `/opsx:ff`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||||
|
|
||||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||||
|
|
||||||
@@ -100,8 +100,7 @@ If the user mentioned a specific change name, read its artifacts for context.
|
|||||||
|
|
||||||
Think freely. When insights crystallize, you might offer:
|
Think freely. When insights crystallize, you might offer:
|
||||||
|
|
||||||
- "This feels solid enough to start a change. Want me to create one?"
|
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||||
→ Can transition to `/opsx:new` or `/opsx:ff`
|
|
||||||
- Or keep exploring - no pressure to formalize
|
- Or keep exploring - no pressure to formalize
|
||||||
|
|
||||||
### When a change exists
|
### When a change exists
|
||||||
@@ -153,7 +152,7 @@ If the user mentions a change or you detect one is relevant:
|
|||||||
|
|
||||||
There's no required ending. Discovery might:
|
There's no required ending. Discovery might:
|
||||||
|
|
||||||
- **Flow into action**: "Ready to start? `/opsx:new` or `/opsx:ff`"
|
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||||
- **Just provide clarity**: User has what they need, moves on
|
- **Just provide clarity**: User has what they need, moves on
|
||||||
- **Continue later**: "We can pick this up anytime"
|
- **Continue later**: "We can pick this up anytime"
|
||||||
|
|||||||
@@ -1,69 +0,0 @@
|
|||||||
---
|
|
||||||
name: "OPSX: New"
|
|
||||||
description: Start a new change using the experimental artifact workflow (OPSX)
|
|
||||||
category: Workflow
|
|
||||||
tags: [workflow, artifacts, experimental]
|
|
||||||
---
|
|
||||||
|
|
||||||
Start a new change using the experimental artifact-driven approach.
|
|
||||||
|
|
||||||
**Input**: The argument after `/opsx:new` is the change name (kebab-case), OR a description of what the user wants to build.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no input provided, ask what they want to build**
|
|
||||||
|
|
||||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
|
||||||
> "What change do you want to work on? Describe what you want to build or fix."
|
|
||||||
|
|
||||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
|
||||||
|
|
||||||
2. **Determine the workflow schema**
|
|
||||||
|
|
||||||
Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow.
|
|
||||||
|
|
||||||
**Use a different schema only if the user mentions:**
|
|
||||||
- A specific schema name → use `--schema <name>`
|
|
||||||
- "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose
|
|
||||||
|
|
||||||
**Otherwise**: Omit `--schema` to use the default.
|
|
||||||
|
|
||||||
3. **Create the change directory**
|
|
||||||
```bash
|
|
||||||
openspec new change "<name>"
|
|
||||||
```
|
|
||||||
Add `--schema <name>` only if the user requested a specific workflow.
|
|
||||||
This creates a scaffolded change at `openspec/changes/<name>/` with the selected schema.
|
|
||||||
|
|
||||||
4. **Show the artifact status**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>"
|
|
||||||
```
|
|
||||||
This shows which artifacts need to be created and which are ready (dependencies satisfied).
|
|
||||||
|
|
||||||
5. **Get instructions for the first artifact**
|
|
||||||
The first artifact depends on the schema. Check the status output to find the first artifact with status "ready".
|
|
||||||
```bash
|
|
||||||
openspec instructions <first-artifact-id> --change "<name>"
|
|
||||||
```
|
|
||||||
This outputs the template and context for creating the first artifact.
|
|
||||||
|
|
||||||
6. **STOP and wait for user direction**
|
|
||||||
|
|
||||||
**Output**
|
|
||||||
|
|
||||||
After completing the steps, summarize:
|
|
||||||
- Change name and location
|
|
||||||
- Schema/workflow being used and its artifact sequence
|
|
||||||
- Current status (0/N artifacts complete)
|
|
||||||
- The template for the first artifact
|
|
||||||
- Prompt: "Ready to create the first artifact? Run `/opsx:continue` or just describe what this change is about and I'll draft it."
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Do NOT create any artifacts yet - just show the instructions
|
|
||||||
- Do NOT advance beyond showing the first artifact template
|
|
||||||
- If the name is invalid (not kebab-case), ask for a valid name
|
|
||||||
- If a change with that name already exists, suggest using `/opsx:continue` instead
|
|
||||||
- Pass --schema if using a non-default workflow
|
|
||||||
@@ -1,525 +0,0 @@
|
|||||||
---
|
|
||||||
name: "OPSX: Onboard"
|
|
||||||
description: Guided onboarding - walk through a complete OpenSpec workflow cycle with narration
|
|
||||||
category: Workflow
|
|
||||||
tags: [workflow, onboarding, tutorial, learning]
|
|
||||||
---
|
|
||||||
|
|
||||||
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Preflight
|
|
||||||
|
|
||||||
Before starting, check if OpenSpec is initialized:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openspec status --json 2>&1 || echo "NOT_INITIALIZED"
|
|
||||||
```
|
|
||||||
|
|
||||||
**If not initialized:**
|
|
||||||
> OpenSpec isn't set up in this project yet. Run `openspec init` first, then come back to `/opsx:onboard`.
|
|
||||||
|
|
||||||
Stop here if not initialized.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: Welcome
|
|
||||||
|
|
||||||
Display:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Welcome to OpenSpec!
|
|
||||||
|
|
||||||
I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it.
|
|
||||||
|
|
||||||
**What we'll do:**
|
|
||||||
1. Pick a small, real task in your codebase
|
|
||||||
2. Explore the problem briefly
|
|
||||||
3. Create a change (the container for our work)
|
|
||||||
4. Build the artifacts: proposal → specs → design → tasks
|
|
||||||
5. Implement the tasks
|
|
||||||
6. Archive the completed change
|
|
||||||
|
|
||||||
**Time:** ~15-20 minutes
|
|
||||||
|
|
||||||
Let's start by finding something to work on.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Task Selection
|
|
||||||
|
|
||||||
### Codebase Analysis
|
|
||||||
|
|
||||||
Scan the codebase for small improvement opportunities. Look for:
|
|
||||||
|
|
||||||
1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files
|
|
||||||
2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch
|
|
||||||
3. **Functions without tests** - Cross-reference `src/` with test directories
|
|
||||||
4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`)
|
|
||||||
5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code
|
|
||||||
6. **Missing validation** - User input handlers without validation
|
|
||||||
|
|
||||||
Also check recent git activity:
|
|
||||||
```bash
|
|
||||||
git log --oneline -10 2>/dev/null || echo "No git history"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Present Suggestions
|
|
||||||
|
|
||||||
From your analysis, present 3-4 specific suggestions:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Task Suggestions
|
|
||||||
|
|
||||||
Based on scanning your codebase, here are some good starter tasks:
|
|
||||||
|
|
||||||
**1. [Most promising task]**
|
|
||||||
Location: `src/path/to/file.ts:42`
|
|
||||||
Scope: ~1-2 files, ~20-30 lines
|
|
||||||
Why it's good: [brief reason]
|
|
||||||
|
|
||||||
**2. [Second task]**
|
|
||||||
Location: `src/another/file.ts`
|
|
||||||
Scope: ~1 file, ~15 lines
|
|
||||||
Why it's good: [brief reason]
|
|
||||||
|
|
||||||
**3. [Third task]**
|
|
||||||
Location: [location]
|
|
||||||
Scope: [estimate]
|
|
||||||
Why it's good: [brief reason]
|
|
||||||
|
|
||||||
**4. Something else?**
|
|
||||||
Tell me what you'd like to work on.
|
|
||||||
|
|
||||||
Which task interests you? (Pick a number or describe your own)
|
|
||||||
```
|
|
||||||
|
|
||||||
**If nothing found:** Fall back to asking what the user wants to build:
|
|
||||||
> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix?
|
|
||||||
|
|
||||||
### Scope Guardrail
|
|
||||||
|
|
||||||
If the user picks or describes something too large (major feature, multi-day work):
|
|
||||||
|
|
||||||
```
|
|
||||||
That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through.
|
|
||||||
|
|
||||||
For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details.
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]?
|
|
||||||
2. **Pick something else** - One of the other suggestions, or a different small task?
|
|
||||||
3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer.
|
|
||||||
|
|
||||||
What would you prefer?
|
|
||||||
```
|
|
||||||
|
|
||||||
Let the user override if they insist—this is a soft guardrail.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: Explore Demo
|
|
||||||
|
|
||||||
Once a task is selected, briefly demonstrate explore mode:
|
|
||||||
|
|
||||||
```
|
|
||||||
Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction.
|
|
||||||
```
|
|
||||||
|
|
||||||
Spend 1-2 minutes investigating the relevant code:
|
|
||||||
- Read the file(s) involved
|
|
||||||
- Draw a quick ASCII diagram if it helps
|
|
||||||
- Note any considerations
|
|
||||||
|
|
||||||
```
|
|
||||||
## Quick Exploration
|
|
||||||
|
|
||||||
[Your brief analysis—what you found, any considerations]
|
|
||||||
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ [Optional: ASCII diagram if helpful] │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
|
|
||||||
Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
|
||||||
|
|
||||||
Now let's create a change to hold our work.
|
|
||||||
```
|
|
||||||
|
|
||||||
**PAUSE** - Wait for user acknowledgment before proceeding.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: Create the Change
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Creating a Change
|
|
||||||
|
|
||||||
A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives in `openspec/changes/<name>/` and holds your artifacts—proposal, specs, design, tasks.
|
|
||||||
|
|
||||||
Let me create one for our task.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Create the change with a derived kebab-case name:
|
|
||||||
```bash
|
|
||||||
openspec new change "<derived-name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**SHOW:**
|
|
||||||
```
|
|
||||||
Created: `openspec/changes/<name>/`
|
|
||||||
|
|
||||||
The folder structure:
|
|
||||||
```
|
|
||||||
openspec/changes/<name>/
|
|
||||||
├── proposal.md ← Why we're doing this (empty, we'll fill it)
|
|
||||||
├── design.md ← How we'll build it (empty)
|
|
||||||
├── specs/ ← Detailed requirements (empty)
|
|
||||||
└── tasks.md ← Implementation checklist (empty)
|
|
||||||
```
|
|
||||||
|
|
||||||
Now let's fill in the first artifact—the proposal.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: Proposal
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## The Proposal
|
|
||||||
|
|
||||||
The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work.
|
|
||||||
|
|
||||||
I'll draft one based on our task.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Draft the proposal content (don't save yet):
|
|
||||||
|
|
||||||
```
|
|
||||||
Here's a draft proposal:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
[1-2 sentences explaining the problem/opportunity]
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
[Bullet points of what will be different]
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
- `<capability-name>`: [brief description]
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
<!-- If modifying existing behavior -->
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- `src/path/to/file.ts`: [what changes]
|
|
||||||
- [other files if applicable]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Does this capture the intent? I can adjust before we save it.
|
|
||||||
```
|
|
||||||
|
|
||||||
**PAUSE** - Wait for user approval/feedback.
|
|
||||||
|
|
||||||
After approval, save the proposal:
|
|
||||||
```bash
|
|
||||||
openspec instructions proposal --change "<name>" --json
|
|
||||||
```
|
|
||||||
Then write the content to `openspec/changes/<name>/proposal.md`.
|
|
||||||
|
|
||||||
```
|
|
||||||
Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves.
|
|
||||||
|
|
||||||
Next up: specs.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Specs
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Specs
|
|
||||||
|
|
||||||
Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear.
|
|
||||||
|
|
||||||
For a small task like this, we might only need one spec file.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Create the spec file:
|
|
||||||
```bash
|
|
||||||
mkdir -p openspec/changes/<name>/specs/<capability-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
Draft the spec content:
|
|
||||||
|
|
||||||
```
|
|
||||||
Here's the spec:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: <Name>
|
|
||||||
|
|
||||||
<Description of what the system should do>
|
|
||||||
|
|
||||||
#### Scenario: <Scenario name>
|
|
||||||
|
|
||||||
- **WHEN** <trigger condition>
|
|
||||||
- **THEN** <expected outcome>
|
|
||||||
- **AND** <additional outcome if needed>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases.
|
|
||||||
```
|
|
||||||
|
|
||||||
Save to `openspec/changes/<name>/specs/<capability>/spec.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7: Design
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Design
|
|
||||||
|
|
||||||
The design captures **how** we'll build it—technical decisions, tradeoffs, approach.
|
|
||||||
|
|
||||||
For small changes, this might be brief. That's fine—not every change needs deep design discussion.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Draft design.md:
|
|
||||||
|
|
||||||
```
|
|
||||||
Here's the design:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[Brief context about the current state]
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
- [What we're trying to achieve]
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
- [What's explicitly out of scope]
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### Decision 1: [Key decision]
|
|
||||||
|
|
||||||
[Explanation of approach and rationale]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
For a small task, this captures the key decisions without over-engineering.
|
|
||||||
```
|
|
||||||
|
|
||||||
Save to `openspec/changes/<name>/design.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 8: Tasks
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
|
|
||||||
|
|
||||||
These should be small, clear, and in logical order.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Generate tasks based on specs and design:
|
|
||||||
|
|
||||||
```
|
|
||||||
Here are the implementation tasks:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. [Category or file]
|
|
||||||
|
|
||||||
- [ ] 1.1 [Specific task]
|
|
||||||
- [ ] 1.2 [Specific task]
|
|
||||||
|
|
||||||
## 2. Verify
|
|
||||||
|
|
||||||
- [ ] 2.1 [Verification step]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
|
|
||||||
```
|
|
||||||
|
|
||||||
**PAUSE** - Wait for user to confirm they're ready to implement.
|
|
||||||
|
|
||||||
Save to `openspec/changes/<name>/tasks.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 9: Apply (Implementation)
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** For each task:
|
|
||||||
|
|
||||||
1. Announce: "Working on task N: [description]"
|
|
||||||
2. Implement the change in the codebase
|
|
||||||
3. Reference specs/design naturally: "The spec says X, so I'm doing Y"
|
|
||||||
4. Mark complete in tasks.md: `- [ ]` → `- [x]`
|
|
||||||
5. Brief status: "✓ Task N complete"
|
|
||||||
|
|
||||||
Keep narration light—don't over-explain every line of code.
|
|
||||||
|
|
||||||
After all tasks:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Implementation Complete
|
|
||||||
|
|
||||||
All tasks done:
|
|
||||||
- [x] Task 1
|
|
||||||
- [x] Task 2
|
|
||||||
- [x] ...
|
|
||||||
|
|
||||||
The change is implemented! One more step—let's archive it.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 10: Archive
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Archiving
|
|
||||||
|
|
||||||
When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/changes/archive/YYYY-MM-DD-<name>/`.
|
|
||||||
|
|
||||||
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:**
|
|
||||||
```bash
|
|
||||||
openspec archive "<name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**SHOW:**
|
|
||||||
```
|
|
||||||
Archived to: `openspec/changes/archive/YYYY-MM-DD-<name>/`
|
|
||||||
|
|
||||||
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 11: Recap & Next Steps
|
|
||||||
|
|
||||||
```
|
|
||||||
## Congratulations!
|
|
||||||
|
|
||||||
You just completed a full OpenSpec cycle:
|
|
||||||
|
|
||||||
1. **Explore** - Thought through the problem
|
|
||||||
2. **New** - Created a change container
|
|
||||||
3. **Proposal** - Captured WHY
|
|
||||||
4. **Specs** - Defined WHAT in detail
|
|
||||||
5. **Design** - Decided HOW
|
|
||||||
6. **Tasks** - Broke it into steps
|
|
||||||
7. **Apply** - Implemented the work
|
|
||||||
8. **Archive** - Preserved the record
|
|
||||||
|
|
||||||
This same rhythm works for any size change—a small fix or a major feature.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Command Reference
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
|---------|--------------|
|
|
||||||
| `/opsx:explore` | Think through problems before/during work |
|
|
||||||
| `/opsx:new` | Start a new change, step through artifacts |
|
|
||||||
| `/opsx:ff` | Fast-forward: create all artifacts at once |
|
|
||||||
| `/opsx:continue` | Continue working on an existing change |
|
|
||||||
| `/opsx:apply` | Implement tasks from a change |
|
|
||||||
| `/opsx:verify` | Verify implementation matches artifacts |
|
|
||||||
| `/opsx:archive` | Archive a completed change |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What's Next?
|
|
||||||
|
|
||||||
Try `/opsx:new` or `/opsx:ff` on something you actually want to build. You've got the rhythm now!
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Graceful Exit Handling
|
|
||||||
|
|
||||||
### User wants to stop mid-way
|
|
||||||
|
|
||||||
If the user says they need to stop, want to pause, or seem disengaged:
|
|
||||||
|
|
||||||
```
|
|
||||||
No problem! Your change is saved at `openspec/changes/<name>/`.
|
|
||||||
|
|
||||||
To pick up where we left off later:
|
|
||||||
- `/opsx:continue <name>` - Resume artifact creation
|
|
||||||
- `/opsx:apply <name>` - Jump to implementation (if tasks exist)
|
|
||||||
|
|
||||||
The work won't be lost. Come back whenever you're ready.
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit gracefully without pressure.
|
|
||||||
|
|
||||||
### User just wants command reference
|
|
||||||
|
|
||||||
If the user says they just want to see the commands or skip the tutorial:
|
|
||||||
|
|
||||||
```
|
|
||||||
## OpenSpec Quick Reference
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
|---------|--------------|
|
|
||||||
| `/opsx:explore` | Think through problems (no code changes) |
|
|
||||||
| `/opsx:new <name>` | Start a new change, step by step |
|
|
||||||
| `/opsx:ff <name>` | Fast-forward: all artifacts at once |
|
|
||||||
| `/opsx:continue <name>` | Continue an existing change |
|
|
||||||
| `/opsx:apply <name>` | Implement tasks |
|
|
||||||
| `/opsx:verify <name>` | Verify implementation |
|
|
||||||
| `/opsx:archive <name>` | Archive when done |
|
|
||||||
|
|
||||||
Try `/opsx:new` to start your first change, or `/opsx:ff` if you want to move fast.
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit gracefully.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive)
|
|
||||||
- **Keep narration light** during implementation—teach without lecturing
|
|
||||||
- **Don't skip phases** even if the change is small—the goal is teaching the workflow
|
|
||||||
- **Pause for acknowledgment** at marked points, but don't over-pause
|
|
||||||
- **Handle exits gracefully**—never pressure the user to continue
|
|
||||||
- **Use real codebase tasks**—don't simulate or use fake examples
|
|
||||||
- **Adjust scope gently**—guide toward smaller tasks but respect user choice
|
|
||||||
@@ -1,13 +1,22 @@
|
|||||||
---
|
---
|
||||||
name: "OPSX: Fast Forward"
|
name: "OPSX: Propose"
|
||||||
description: Create a change and generate all artifacts needed for implementation in one go
|
description: Propose a new change - create it and generate all artifacts in one step
|
||||||
category: Workflow
|
category: Workflow
|
||||||
tags: [workflow, artifacts, experimental]
|
tags: [workflow, artifacts, experimental]
|
||||||
---
|
---
|
||||||
|
|
||||||
Fast-forward through artifact creation - generate everything needed to start implementation.
|
Propose a new change - create the change and generate all artifacts in one step.
|
||||||
|
|
||||||
**Input**: The argument after `/opsx:ff` is the change name (kebab-case), OR a description of what the user wants to build.
|
I'll create a change with artifacts:
|
||||||
|
- proposal.md (what & why)
|
||||||
|
- design.md (how)
|
||||||
|
- tasks.md (implementation steps)
|
||||||
|
|
||||||
|
When ready to implement, run /opsx:apply
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Input**: The argument after `/opsx:propose` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||||
|
|
||||||
**Steps**
|
**Steps**
|
||||||
|
|
||||||
@@ -24,7 +33,7 @@ Fast-forward through artifact creation - generate everything needed to start imp
|
|||||||
```bash
|
```bash
|
||||||
openspec new change "<name>"
|
openspec new change "<name>"
|
||||||
```
|
```
|
||||||
This creates a scaffolded change at `openspec/changes/<name>/`.
|
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||||
|
|
||||||
3. **Get the artifact build order**
|
3. **Get the artifact build order**
|
||||||
```bash
|
```bash
|
||||||
@@ -55,7 +64,7 @@ Fast-forward through artifact creation - generate everything needed to start imp
|
|||||||
- Read any completed dependency files for context
|
- Read any completed dependency files for context
|
||||||
- Create the artifact file using `template` as the structure
|
- Create the artifact file using `template` as the structure
|
||||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||||
- Show brief progress: "✓ Created <artifact-id>"
|
- Show brief progress: "Created <artifact-id>"
|
||||||
|
|
||||||
b. **Continue until all `applyRequires` artifacts are complete**
|
b. **Continue until all `applyRequires` artifacts are complete**
|
||||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||||
@@ -84,7 +93,10 @@ After completing all artifacts, summarize:
|
|||||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||||
- The schema defines what each artifact should contain - follow it
|
- The schema defines what each artifact should contain - follow it
|
||||||
- Read dependency artifacts for context before creating new ones
|
- Read dependency artifacts for context before creating new ones
|
||||||
- Use the `template` as a starting point, filling in based on context
|
- Use `template` as the structure for your output file - fill in its sections
|
||||||
|
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||||
|
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||||
|
- These guide what you write, but should never appear in the output
|
||||||
|
|
||||||
**Guardrails**
|
**Guardrails**
|
||||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||||
@@ -1,134 +0,0 @@
|
|||||||
---
|
|
||||||
name: "OPSX: Sync"
|
|
||||||
description: Sync delta specs from a change to main specs
|
|
||||||
category: Workflow
|
|
||||||
tags: [workflow, specs, experimental]
|
|
||||||
---
|
|
||||||
|
|
||||||
Sync delta specs from a change to main specs.
|
|
||||||
|
|
||||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
|
||||||
|
|
||||||
**Input**: Optionally specify a change name after `/opsx:sync` (e.g., `/opsx:sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no change name provided, prompt for selection**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
|
||||||
|
|
||||||
Show changes that have delta specs (under `specs/` directory).
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
|
||||||
|
|
||||||
2. **Find delta specs**
|
|
||||||
|
|
||||||
Look for delta spec files in `openspec/changes/<name>/specs/*/spec.md`.
|
|
||||||
|
|
||||||
Each delta spec file contains sections like:
|
|
||||||
- `## ADDED Requirements` - New requirements to add
|
|
||||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
|
||||||
- `## REMOVED Requirements` - Requirements to remove
|
|
||||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
|
||||||
|
|
||||||
If no delta specs found, inform user and stop.
|
|
||||||
|
|
||||||
3. **For each delta spec, apply changes to main specs**
|
|
||||||
|
|
||||||
For each capability with a delta spec at `openspec/changes/<name>/specs/<capability>/spec.md`:
|
|
||||||
|
|
||||||
a. **Read the delta spec** to understand the intended changes
|
|
||||||
|
|
||||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
|
||||||
|
|
||||||
c. **Apply changes intelligently**:
|
|
||||||
|
|
||||||
**ADDED Requirements:**
|
|
||||||
- If requirement doesn't exist in main spec → add it
|
|
||||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
|
||||||
|
|
||||||
**MODIFIED Requirements:**
|
|
||||||
- Find the requirement in main spec
|
|
||||||
- Apply the changes - this can be:
|
|
||||||
- Adding new scenarios (don't need to copy existing ones)
|
|
||||||
- Modifying existing scenarios
|
|
||||||
- Changing the requirement description
|
|
||||||
- Preserve scenarios/content not mentioned in the delta
|
|
||||||
|
|
||||||
**REMOVED Requirements:**
|
|
||||||
- Remove the entire requirement block from main spec
|
|
||||||
|
|
||||||
**RENAMED Requirements:**
|
|
||||||
- Find the FROM requirement, rename to TO
|
|
||||||
|
|
||||||
d. **Create new main spec** if capability doesn't exist yet:
|
|
||||||
- Create `openspec/specs/<capability>/spec.md`
|
|
||||||
- Add Purpose section (can be brief, mark as TBD)
|
|
||||||
- Add Requirements section with the ADDED requirements
|
|
||||||
|
|
||||||
4. **Show summary**
|
|
||||||
|
|
||||||
After applying all changes, summarize:
|
|
||||||
- Which capabilities were updated
|
|
||||||
- What changes were made (requirements added/modified/removed/renamed)
|
|
||||||
|
|
||||||
**Delta Spec Format Reference**
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: New Feature
|
|
||||||
The system SHALL do something new.
|
|
||||||
|
|
||||||
#### Scenario: Basic case
|
|
||||||
- **WHEN** user does X
|
|
||||||
- **THEN** system does Y
|
|
||||||
|
|
||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: Existing Feature
|
|
||||||
#### Scenario: New scenario to add
|
|
||||||
- **WHEN** user does A
|
|
||||||
- **THEN** system does B
|
|
||||||
|
|
||||||
## REMOVED Requirements
|
|
||||||
|
|
||||||
### Requirement: Deprecated Feature
|
|
||||||
|
|
||||||
## RENAMED Requirements
|
|
||||||
|
|
||||||
- FROM: `### Requirement: Old Name`
|
|
||||||
- TO: `### Requirement: New Name`
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Principle: Intelligent Merging**
|
|
||||||
|
|
||||||
Unlike programmatic merging, you can apply **partial updates**:
|
|
||||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
|
||||||
- The delta represents *intent*, not a wholesale replacement
|
|
||||||
- Use your judgment to merge changes sensibly
|
|
||||||
|
|
||||||
**Output On Success**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Specs Synced: <change-name>
|
|
||||||
|
|
||||||
Updated main specs:
|
|
||||||
|
|
||||||
**<capability-1>**:
|
|
||||||
- Added requirement: "New Feature"
|
|
||||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
|
||||||
|
|
||||||
**<capability-2>**:
|
|
||||||
- Created new spec file
|
|
||||||
- Added requirement: "Another Feature"
|
|
||||||
|
|
||||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Read both delta and main specs before making changes
|
|
||||||
- Preserve existing content not mentioned in delta
|
|
||||||
- If something is unclear, ask for clarification
|
|
||||||
- Show what you're changing as you go
|
|
||||||
- The operation should be idempotent - running twice should give same result
|
|
||||||
@@ -1,164 +0,0 @@
|
|||||||
---
|
|
||||||
name: "OPSX: Verify"
|
|
||||||
description: Verify implementation matches change artifacts before archiving
|
|
||||||
category: Workflow
|
|
||||||
tags: [workflow, verify, experimental]
|
|
||||||
---
|
|
||||||
|
|
||||||
Verify that an implementation matches the change artifacts (specs, tasks, design).
|
|
||||||
|
|
||||||
**Input**: Optionally specify a change name after `/opsx:verify` (e.g., `/opsx:verify add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no change name provided, prompt for selection**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
|
||||||
|
|
||||||
Show changes that have implementation tasks (tasks artifact exists).
|
|
||||||
Include the schema used for each change if available.
|
|
||||||
Mark changes with incomplete tasks as "(In Progress)".
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
|
||||||
|
|
||||||
2. **Check status to understand the schema**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>" --json
|
|
||||||
```
|
|
||||||
Parse the JSON to understand:
|
|
||||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
|
||||||
- Which artifacts exist for this change
|
|
||||||
|
|
||||||
3. **Get the change directory and load artifacts**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openspec instructions apply --change "<name>" --json
|
|
||||||
```
|
|
||||||
|
|
||||||
This returns the change directory and context files. Read all available artifacts from `contextFiles`.
|
|
||||||
|
|
||||||
4. **Initialize verification report structure**
|
|
||||||
|
|
||||||
Create a report structure with three dimensions:
|
|
||||||
- **Completeness**: Track tasks and spec coverage
|
|
||||||
- **Correctness**: Track requirement implementation and scenario coverage
|
|
||||||
- **Coherence**: Track design adherence and pattern consistency
|
|
||||||
|
|
||||||
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
|
|
||||||
|
|
||||||
5. **Verify Completeness**
|
|
||||||
|
|
||||||
**Task Completion**:
|
|
||||||
- If tasks.md exists in contextFiles, read it
|
|
||||||
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
|
|
||||||
- Count complete vs total tasks
|
|
||||||
- If incomplete tasks exist:
|
|
||||||
- Add CRITICAL issue for each incomplete task
|
|
||||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
|
||||||
|
|
||||||
**Spec Coverage**:
|
|
||||||
- If delta specs exist in `openspec/changes/<name>/specs/`:
|
|
||||||
- Extract all requirements (marked with "### Requirement:")
|
|
||||||
- For each requirement:
|
|
||||||
- Search codebase for keywords related to the requirement
|
|
||||||
- Assess if implementation likely exists
|
|
||||||
- If requirements appear unimplemented:
|
|
||||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
|
||||||
- Recommendation: "Implement requirement X: <description>"
|
|
||||||
|
|
||||||
6. **Verify Correctness**
|
|
||||||
|
|
||||||
**Requirement Implementation Mapping**:
|
|
||||||
- For each requirement from delta specs:
|
|
||||||
- Search codebase for implementation evidence
|
|
||||||
- If found, note file paths and line ranges
|
|
||||||
- Assess if implementation matches requirement intent
|
|
||||||
- If divergence detected:
|
|
||||||
- Add WARNING: "Implementation may diverge from spec: <details>"
|
|
||||||
- Recommendation: "Review <file>:<lines> against requirement X"
|
|
||||||
|
|
||||||
**Scenario Coverage**:
|
|
||||||
- For each scenario in delta specs (marked with "#### Scenario:"):
|
|
||||||
- Check if conditions are handled in code
|
|
||||||
- Check if tests exist covering the scenario
|
|
||||||
- If scenario appears uncovered:
|
|
||||||
- Add WARNING: "Scenario not covered: <scenario name>"
|
|
||||||
- Recommendation: "Add test or implementation for scenario: <description>"
|
|
||||||
|
|
||||||
7. **Verify Coherence**
|
|
||||||
|
|
||||||
**Design Adherence**:
|
|
||||||
- If design.md exists in contextFiles:
|
|
||||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
|
||||||
- Verify implementation follows those decisions
|
|
||||||
- If contradiction detected:
|
|
||||||
- Add WARNING: "Design decision not followed: <decision>"
|
|
||||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
|
||||||
- If no design.md: Skip design adherence check, note "No design.md to verify against"
|
|
||||||
|
|
||||||
**Code Pattern Consistency**:
|
|
||||||
- Review new code for consistency with project patterns
|
|
||||||
- Check file naming, directory structure, coding style
|
|
||||||
- If significant deviations found:
|
|
||||||
- Add SUGGESTION: "Code pattern deviation: <details>"
|
|
||||||
- Recommendation: "Consider following project pattern: <example>"
|
|
||||||
|
|
||||||
8. **Generate Verification Report**
|
|
||||||
|
|
||||||
**Summary Scorecard**:
|
|
||||||
```
|
|
||||||
## Verification Report: <change-name>
|
|
||||||
|
|
||||||
### Summary
|
|
||||||
| Dimension | Status |
|
|
||||||
|--------------|------------------|
|
|
||||||
| Completeness | X/Y tasks, N reqs|
|
|
||||||
| Correctness | M/N reqs covered |
|
|
||||||
| Coherence | Followed/Issues |
|
|
||||||
```
|
|
||||||
|
|
||||||
**Issues by Priority**:
|
|
||||||
|
|
||||||
1. **CRITICAL** (Must fix before archive):
|
|
||||||
- Incomplete tasks
|
|
||||||
- Missing requirement implementations
|
|
||||||
- Each with specific, actionable recommendation
|
|
||||||
|
|
||||||
2. **WARNING** (Should fix):
|
|
||||||
- Spec/design divergences
|
|
||||||
- Missing scenario coverage
|
|
||||||
- Each with specific recommendation
|
|
||||||
|
|
||||||
3. **SUGGESTION** (Nice to fix):
|
|
||||||
- Pattern inconsistencies
|
|
||||||
- Minor improvements
|
|
||||||
- Each with specific recommendation
|
|
||||||
|
|
||||||
**Final Assessment**:
|
|
||||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
|
|
||||||
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
|
||||||
- If all clear: "All checks passed. Ready for archive."
|
|
||||||
|
|
||||||
**Verification Heuristics**
|
|
||||||
|
|
||||||
- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)
|
|
||||||
- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty
|
|
||||||
- **Coherence**: Look for glaring inconsistencies, don't nitpick style
|
|
||||||
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
|
|
||||||
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
|
|
||||||
|
|
||||||
**Graceful Degradation**
|
|
||||||
|
|
||||||
- If only tasks.md exists: verify task completion only, skip spec/design checks
|
|
||||||
- If tasks + specs exist: verify completeness and correctness, skip design
|
|
||||||
- If full artifacts: verify all three dimensions
|
|
||||||
- Always note which checks were skipped and why
|
|
||||||
|
|
||||||
**Output Format**
|
|
||||||
|
|
||||||
Use clear markdown with:
|
|
||||||
- Table for summary scorecard
|
|
||||||
- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)
|
|
||||||
- Code references in format: `file.ts:123`
|
|
||||||
- Specific, actionable recommendations
|
|
||||||
- No vague suggestions like "consider reviewing"
|
|
||||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
|||||||
metadata:
|
metadata:
|
||||||
author: openspec
|
author: openspec
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
generatedBy: "1.1.1"
|
generatedBy: "1.2.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
Implement tasks from an OpenSpec change.
|
Implement tasks from an OpenSpec change.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
|||||||
metadata:
|
metadata:
|
||||||
author: openspec
|
author: openspec
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
generatedBy: "1.1.1"
|
generatedBy: "1.2.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
Archive a completed change in the experimental workflow.
|
Archive a completed change in the experimental workflow.
|
||||||
@@ -63,7 +63,7 @@ Archive a completed change in the experimental workflow.
|
|||||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||||
|
|
||||||
If user chooses sync, execute /opsx:sync logic (use the openspec-sync-specs skill). Proceed to archive regardless of choice.
|
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||||
|
|
||||||
5. **Perform the archive**
|
5. **Perform the archive**
|
||||||
|
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-bulk-archive-change
|
|
||||||
description: Archive multiple completed changes at once. Use when archiving several parallel changes.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Archive multiple completed changes in a single operation.
|
|
||||||
|
|
||||||
This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented.
|
|
||||||
|
|
||||||
**Input**: None required (prompts for selection)
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **Get active changes**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get all active changes.
|
|
||||||
|
|
||||||
If no active changes exist, inform user and stop.
|
|
||||||
|
|
||||||
2. **Prompt for change selection**
|
|
||||||
|
|
||||||
Use **AskUserQuestion tool** with multi-select to let user choose changes:
|
|
||||||
- Show each change with its schema
|
|
||||||
- Include an option for "All changes"
|
|
||||||
- Allow any number of selections (1+ works, 2+ is the typical use case)
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT auto-select. Always let the user choose.
|
|
||||||
|
|
||||||
3. **Batch validation - gather status for all selected changes**
|
|
||||||
|
|
||||||
For each selected change, collect:
|
|
||||||
|
|
||||||
a. **Artifact status** - Run `openspec status --change "<name>" --json`
|
|
||||||
- Parse `schemaName` and `artifacts` list
|
|
||||||
- Note which artifacts are `done` vs other states
|
|
||||||
|
|
||||||
b. **Task completion** - Read `openspec/changes/<name>/tasks.md`
|
|
||||||
- Count `- [ ]` (incomplete) vs `- [x]` (complete)
|
|
||||||
- If no tasks file exists, note as "No tasks"
|
|
||||||
|
|
||||||
c. **Delta specs** - Check `openspec/changes/<name>/specs/` directory
|
|
||||||
- List which capability specs exist
|
|
||||||
- For each, extract requirement names (lines matching `### Requirement: <name>`)
|
|
||||||
|
|
||||||
4. **Detect spec conflicts**
|
|
||||||
|
|
||||||
Build a map of `capability -> [changes that touch it]`:
|
|
||||||
|
|
||||||
```
|
|
||||||
auth -> [change-a, change-b] <- CONFLICT (2+ changes)
|
|
||||||
api -> [change-c] <- OK (only 1 change)
|
|
||||||
```
|
|
||||||
|
|
||||||
A conflict exists when 2+ selected changes have delta specs for the same capability.
|
|
||||||
|
|
||||||
5. **Resolve conflicts agentically**
|
|
||||||
|
|
||||||
**For each conflict**, investigate the codebase:
|
|
||||||
|
|
||||||
a. **Read the delta specs** from each conflicting change to understand what each claims to add/modify
|
|
||||||
|
|
||||||
b. **Search the codebase** for implementation evidence:
|
|
||||||
- Look for code implementing requirements from each delta spec
|
|
||||||
- Check for related files, functions, or tests
|
|
||||||
|
|
||||||
c. **Determine resolution**:
|
|
||||||
- If only one change is actually implemented -> sync that one's specs
|
|
||||||
- If both implemented -> apply in chronological order (older first, newer overwrites)
|
|
||||||
- If neither implemented -> skip spec sync, warn user
|
|
||||||
|
|
||||||
d. **Record resolution** for each conflict:
|
|
||||||
- Which change's specs to apply
|
|
||||||
- In what order (if both)
|
|
||||||
- Rationale (what was found in codebase)
|
|
||||||
|
|
||||||
6. **Show consolidated status table**
|
|
||||||
|
|
||||||
Display a table summarizing all changes:
|
|
||||||
|
|
||||||
```
|
|
||||||
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|
|
||||||
|---------------------|-----------|-------|---------|-----------|--------|
|
|
||||||
| schema-management | Done | 5/5 | 2 delta | None | Ready |
|
|
||||||
| project-config | Done | 3/3 | 1 delta | None | Ready |
|
|
||||||
| add-oauth | Done | 4/4 | 1 delta | auth (!) | Ready* |
|
|
||||||
| add-verify-skill | 1 left | 2/5 | None | None | Warn |
|
|
||||||
```
|
|
||||||
|
|
||||||
For conflicts, show the resolution:
|
|
||||||
```
|
|
||||||
* Conflict resolution:
|
|
||||||
- auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order)
|
|
||||||
```
|
|
||||||
|
|
||||||
For incomplete changes, show warnings:
|
|
||||||
```
|
|
||||||
Warnings:
|
|
||||||
- add-verify-skill: 1 incomplete artifact, 3 incomplete tasks
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Confirm batch operation**
|
|
||||||
|
|
||||||
Use **AskUserQuestion tool** with a single confirmation:
|
|
||||||
|
|
||||||
- "Archive N changes?" with options based on status
|
|
||||||
- Options might include:
|
|
||||||
- "Archive all N changes"
|
|
||||||
- "Archive only N ready changes (skip incomplete)"
|
|
||||||
- "Cancel"
|
|
||||||
|
|
||||||
If there are incomplete changes, make clear they'll be archived with warnings.
|
|
||||||
|
|
||||||
8. **Execute archive for each confirmed change**
|
|
||||||
|
|
||||||
Process changes in the determined order (respecting conflict resolution):
|
|
||||||
|
|
||||||
a. **Sync specs** if delta specs exist:
|
|
||||||
- Use the openspec-sync-specs approach (agent-driven intelligent merge)
|
|
||||||
- For conflicts, apply in resolved order
|
|
||||||
- Track if sync was done
|
|
||||||
|
|
||||||
b. **Perform the archive**:
|
|
||||||
```bash
|
|
||||||
mkdir -p openspec/changes/archive
|
|
||||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
|
||||||
```
|
|
||||||
|
|
||||||
c. **Track outcome** for each change:
|
|
||||||
- Success: archived successfully
|
|
||||||
- Failed: error during archive (record error)
|
|
||||||
- Skipped: user chose not to archive (if applicable)
|
|
||||||
|
|
||||||
9. **Display summary**
|
|
||||||
|
|
||||||
Show final results:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Bulk Archive Complete
|
|
||||||
|
|
||||||
Archived 3 changes:
|
|
||||||
- schema-management-cli -> archive/2026-01-19-schema-management-cli/
|
|
||||||
- project-config -> archive/2026-01-19-project-config/
|
|
||||||
- add-oauth -> archive/2026-01-19-add-oauth/
|
|
||||||
|
|
||||||
Skipped 1 change:
|
|
||||||
- add-verify-skill (user chose not to archive incomplete)
|
|
||||||
|
|
||||||
Spec sync summary:
|
|
||||||
- 4 delta specs synced to main specs
|
|
||||||
- 1 conflict resolved (auth: applied both in chronological order)
|
|
||||||
```
|
|
||||||
|
|
||||||
If any failures:
|
|
||||||
```
|
|
||||||
Failed 1 change:
|
|
||||||
- some-change: Archive directory already exists
|
|
||||||
```
|
|
||||||
|
|
||||||
**Conflict Resolution Examples**
|
|
||||||
|
|
||||||
Example 1: Only one implemented
|
|
||||||
```
|
|
||||||
Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt]
|
|
||||||
|
|
||||||
Checking add-oauth:
|
|
||||||
- Delta adds "OAuth Provider Integration" requirement
|
|
||||||
- Searching codebase... found src/auth/oauth.ts implementing OAuth flow
|
|
||||||
|
|
||||||
Checking add-jwt:
|
|
||||||
- Delta adds "JWT Token Handling" requirement
|
|
||||||
- Searching codebase... no JWT implementation found
|
|
||||||
|
|
||||||
Resolution: Only add-oauth is implemented. Will sync add-oauth specs only.
|
|
||||||
```
|
|
||||||
|
|
||||||
Example 2: Both implemented
|
|
||||||
```
|
|
||||||
Conflict: specs/api/spec.md touched by [add-rest-api, add-graphql]
|
|
||||||
|
|
||||||
Checking add-rest-api (created 2026-01-10):
|
|
||||||
- Delta adds "REST Endpoints" requirement
|
|
||||||
- Searching codebase... found src/api/rest.ts
|
|
||||||
|
|
||||||
Checking add-graphql (created 2026-01-15):
|
|
||||||
- Delta adds "GraphQL Schema" requirement
|
|
||||||
- Searching codebase... found src/api/graphql.ts
|
|
||||||
|
|
||||||
Resolution: Both implemented. Will apply add-rest-api specs first,
|
|
||||||
then add-graphql specs (chronological order, newer takes precedence).
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output On Success**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Bulk Archive Complete
|
|
||||||
|
|
||||||
Archived N changes:
|
|
||||||
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
|
|
||||||
- <change-2> -> archive/YYYY-MM-DD-<change-2>/
|
|
||||||
|
|
||||||
Spec sync summary:
|
|
||||||
- N delta specs synced to main specs
|
|
||||||
- No conflicts (or: M conflicts resolved)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output On Partial Success**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Bulk Archive Complete (partial)
|
|
||||||
|
|
||||||
Archived N changes:
|
|
||||||
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
|
|
||||||
|
|
||||||
Skipped M changes:
|
|
||||||
- <change-2> (user chose not to archive incomplete)
|
|
||||||
|
|
||||||
Failed K changes:
|
|
||||||
- <change-3>: Archive directory already exists
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output When No Changes**
|
|
||||||
|
|
||||||
```
|
|
||||||
## No Changes to Archive
|
|
||||||
|
|
||||||
No active changes found. Use `/opsx:new` to create a new change.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Allow any number of changes (1+ is fine, 2+ is the typical use case)
|
|
||||||
- Always prompt for selection, never auto-select
|
|
||||||
- Detect spec conflicts early and resolve by checking codebase
|
|
||||||
- When both changes are implemented, apply specs in chronological order
|
|
||||||
- Skip spec sync only when implementation is missing (warn user)
|
|
||||||
- Show clear per-change status before confirming
|
|
||||||
- Use single confirmation for entire batch
|
|
||||||
- Track and report all outcomes (success/skip/fail)
|
|
||||||
- Preserve .openspec.yaml when moving to archive
|
|
||||||
- Archive directory target uses current date: YYYY-MM-DD-<name>
|
|
||||||
- If archive target exists, fail that change but continue with others
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-continue-change
|
|
||||||
description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Continue working on a change by creating the next artifact.
|
|
||||||
|
|
||||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no change name provided, prompt for selection**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on.
|
|
||||||
|
|
||||||
Present the top 3-4 most recently modified changes as options, showing:
|
|
||||||
- Change name
|
|
||||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
|
||||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
|
||||||
- How recently it was modified (from `lastModified` field)
|
|
||||||
|
|
||||||
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue.
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
|
||||||
|
|
||||||
2. **Check current status**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>" --json
|
|
||||||
```
|
|
||||||
Parse the JSON to understand current state. The response includes:
|
|
||||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
|
||||||
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
|
|
||||||
- `isComplete`: Boolean indicating if all artifacts are complete
|
|
||||||
|
|
||||||
3. **Act based on status**:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If all artifacts are complete (`isComplete: true`)**:
|
|
||||||
- Congratulate the user
|
|
||||||
- Show final status including the schema used
|
|
||||||
- Suggest: "All artifacts created! You can now implement this change or archive it."
|
|
||||||
- STOP
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If artifacts are ready to create** (status shows artifacts with `status: "ready"`):
|
|
||||||
- Pick the FIRST artifact with `status: "ready"` from the status output
|
|
||||||
- Get its instructions:
|
|
||||||
```bash
|
|
||||||
openspec instructions <artifact-id> --change "<name>" --json
|
|
||||||
```
|
|
||||||
- Parse the JSON. The key fields are:
|
|
||||||
- `context`: Project background (constraints for you - do NOT include in output)
|
|
||||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
|
||||||
- `template`: The structure to use for your output file
|
|
||||||
- `instruction`: Schema-specific guidance
|
|
||||||
- `outputPath`: Where to write the artifact
|
|
||||||
- `dependencies`: Completed artifacts to read for context
|
|
||||||
- **Create the artifact file**:
|
|
||||||
- Read any completed dependency files for context
|
|
||||||
- Use `template` as the structure - fill in its sections
|
|
||||||
- Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file
|
|
||||||
- Write to the output path specified in instructions
|
|
||||||
- Show what was created and what's now unlocked
|
|
||||||
- STOP after creating ONE artifact
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If no artifacts are ready (all blocked)**:
|
|
||||||
- This shouldn't happen with a valid schema
|
|
||||||
- Show status and suggest checking for issues
|
|
||||||
|
|
||||||
4. **After creating an artifact, show progress**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output**
|
|
||||||
|
|
||||||
After each invocation, show:
|
|
||||||
- Which artifact was created
|
|
||||||
- Schema workflow being used
|
|
||||||
- Current progress (N/M complete)
|
|
||||||
- What artifacts are now unlocked
|
|
||||||
- Prompt: "Want to continue? Just ask me to continue or tell me what to do next."
|
|
||||||
|
|
||||||
**Artifact Creation Guidelines**
|
|
||||||
|
|
||||||
The artifact types and their purpose depend on the schema. Use the `instruction` field from the instructions output to understand what to create.
|
|
||||||
|
|
||||||
Common artifact patterns:
|
|
||||||
|
|
||||||
**spec-driven schema** (proposal → specs → design → tasks):
|
|
||||||
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
|
|
||||||
- The Capabilities section is critical - each capability listed will need a spec file.
|
|
||||||
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
|
|
||||||
- **design.md**: Document technical decisions, architecture, and implementation approach.
|
|
||||||
- **tasks.md**: Break down implementation into checkboxed tasks.
|
|
||||||
|
|
||||||
For other schemas, follow the `instruction` field from the CLI output.
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Create ONE artifact per invocation
|
|
||||||
- Always read dependency artifacts before creating a new one
|
|
||||||
- Never skip artifacts or create out of order
|
|
||||||
- If context is unclear, ask the user before creating
|
|
||||||
- Verify the artifact file exists after writing before marking progress
|
|
||||||
- Use the schema's artifact sequence, don't assume specific artifact names
|
|
||||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
|
||||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
|
||||||
- These guide what you write, but should never appear in the output
|
|
||||||
@@ -6,12 +6,12 @@ compatibility: Requires openspec CLI.
|
|||||||
metadata:
|
metadata:
|
||||||
author: openspec
|
author: openspec
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
generatedBy: "1.1.1"
|
generatedBy: "1.2.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||||
|
|
||||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with `/opsx:new` or `/opsx:ff`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||||
|
|
||||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||||
|
|
||||||
@@ -95,8 +95,7 @@ This tells you:
|
|||||||
|
|
||||||
Think freely. When insights crystallize, you might offer:
|
Think freely. When insights crystallize, you might offer:
|
||||||
|
|
||||||
- "This feels solid enough to start a change. Want me to create one?"
|
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||||
→ Can transition to `/opsx:new` or `/opsx:ff`
|
|
||||||
- Or keep exploring - no pressure to formalize
|
- Or keep exploring - no pressure to formalize
|
||||||
|
|
||||||
### When a change exists
|
### When a change exists
|
||||||
@@ -252,7 +251,7 @@ You: That changes everything.
|
|||||||
|
|
||||||
There's no required ending. Discovery might:
|
There's no required ending. Discovery might:
|
||||||
|
|
||||||
- **Flow into action**: "Ready to start? /opsx:new or /opsx:ff"
|
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||||
- **Just provide clarity**: User has what they need, moves on
|
- **Just provide clarity**: User has what they need, moves on
|
||||||
- **Continue later**: "We can pick this up anytime"
|
- **Continue later**: "We can pick this up anytime"
|
||||||
@@ -269,8 +268,7 @@ When it feels like things are crystallizing, you might summarize:
|
|||||||
**Open questions**: [if any remain]
|
**Open questions**: [if any remain]
|
||||||
|
|
||||||
**Next steps** (if ready):
|
**Next steps** (if ready):
|
||||||
- Create a change: /opsx:new <name>
|
- Create a change proposal
|
||||||
- Fast-forward to tasks: /opsx:ff <name>
|
|
||||||
- Keep exploring: just keep talking
|
- Keep exploring: just keep talking
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-new-change
|
|
||||||
description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Start a new change using the experimental artifact-driven approach.
|
|
||||||
|
|
||||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no clear input provided, ask what they want to build**
|
|
||||||
|
|
||||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
|
||||||
> "What change do you want to work on? Describe what you want to build or fix."
|
|
||||||
|
|
||||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
|
||||||
|
|
||||||
2. **Determine the workflow schema**
|
|
||||||
|
|
||||||
Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow.
|
|
||||||
|
|
||||||
**Use a different schema only if the user mentions:**
|
|
||||||
- A specific schema name → use `--schema <name>`
|
|
||||||
- "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose
|
|
||||||
|
|
||||||
**Otherwise**: Omit `--schema` to use the default.
|
|
||||||
|
|
||||||
3. **Create the change directory**
|
|
||||||
```bash
|
|
||||||
openspec new change "<name>"
|
|
||||||
```
|
|
||||||
Add `--schema <name>` only if the user requested a specific workflow.
|
|
||||||
This creates a scaffolded change at `openspec/changes/<name>/` with the selected schema.
|
|
||||||
|
|
||||||
4. **Show the artifact status**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>"
|
|
||||||
```
|
|
||||||
This shows which artifacts need to be created and which are ready (dependencies satisfied).
|
|
||||||
|
|
||||||
5. **Get instructions for the first artifact**
|
|
||||||
The first artifact depends on the schema (e.g., `proposal` for spec-driven).
|
|
||||||
Check the status output to find the first artifact with status "ready".
|
|
||||||
```bash
|
|
||||||
openspec instructions <first-artifact-id> --change "<name>"
|
|
||||||
```
|
|
||||||
This outputs the template and context for creating the first artifact.
|
|
||||||
|
|
||||||
6. **STOP and wait for user direction**
|
|
||||||
|
|
||||||
**Output**
|
|
||||||
|
|
||||||
After completing the steps, summarize:
|
|
||||||
- Change name and location
|
|
||||||
- Schema/workflow being used and its artifact sequence
|
|
||||||
- Current status (0/N artifacts complete)
|
|
||||||
- The template for the first artifact
|
|
||||||
- Prompt: "Ready to create the first artifact? Just describe what this change is about and I'll draft it, or ask me to continue."
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Do NOT create any artifacts yet - just show the instructions
|
|
||||||
- Do NOT advance beyond showing the first artifact template
|
|
||||||
- If the name is invalid (not kebab-case), ask for a valid name
|
|
||||||
- If a change with that name already exists, suggest continuing that change instead
|
|
||||||
- Pass --schema if using a non-default workflow
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-onboard
|
|
||||||
description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Preflight
|
|
||||||
|
|
||||||
Before starting, check if OpenSpec is initialized:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openspec status --json 2>&1 || echo "NOT_INITIALIZED"
|
|
||||||
```
|
|
||||||
|
|
||||||
**If not initialized:**
|
|
||||||
> OpenSpec isn't set up in this project yet. Run `openspec init` first, then come back to `/opsx:onboard`.
|
|
||||||
|
|
||||||
Stop here if not initialized.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: Welcome
|
|
||||||
|
|
||||||
Display:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Welcome to OpenSpec!
|
|
||||||
|
|
||||||
I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it.
|
|
||||||
|
|
||||||
**What we'll do:**
|
|
||||||
1. Pick a small, real task in your codebase
|
|
||||||
2. Explore the problem briefly
|
|
||||||
3. Create a change (the container for our work)
|
|
||||||
4. Build the artifacts: proposal → specs → design → tasks
|
|
||||||
5. Implement the tasks
|
|
||||||
6. Archive the completed change
|
|
||||||
|
|
||||||
**Time:** ~15-20 minutes
|
|
||||||
|
|
||||||
Let's start by finding something to work on.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Task Selection
|
|
||||||
|
|
||||||
### Codebase Analysis
|
|
||||||
|
|
||||||
Scan the codebase for small improvement opportunities. Look for:
|
|
||||||
|
|
||||||
1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files
|
|
||||||
2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch
|
|
||||||
3. **Functions without tests** - Cross-reference `src/` with test directories
|
|
||||||
4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`)
|
|
||||||
5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code
|
|
||||||
6. **Missing validation** - User input handlers without validation
|
|
||||||
|
|
||||||
Also check recent git activity:
|
|
||||||
```bash
|
|
||||||
git log --oneline -10 2>/dev/null || echo "No git history"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Present Suggestions
|
|
||||||
|
|
||||||
From your analysis, present 3-4 specific suggestions:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Task Suggestions
|
|
||||||
|
|
||||||
Based on scanning your codebase, here are some good starter tasks:
|
|
||||||
|
|
||||||
**1. [Most promising task]**
|
|
||||||
Location: `src/path/to/file.ts:42`
|
|
||||||
Scope: ~1-2 files, ~20-30 lines
|
|
||||||
Why it's good: [brief reason]
|
|
||||||
|
|
||||||
**2. [Second task]**
|
|
||||||
Location: `src/another/file.ts`
|
|
||||||
Scope: ~1 file, ~15 lines
|
|
||||||
Why it's good: [brief reason]
|
|
||||||
|
|
||||||
**3. [Third task]**
|
|
||||||
Location: [location]
|
|
||||||
Scope: [estimate]
|
|
||||||
Why it's good: [brief reason]
|
|
||||||
|
|
||||||
**4. Something else?**
|
|
||||||
Tell me what you'd like to work on.
|
|
||||||
|
|
||||||
Which task interests you? (Pick a number or describe your own)
|
|
||||||
```
|
|
||||||
|
|
||||||
**If nothing found:** Fall back to asking what the user wants to build:
|
|
||||||
> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix?
|
|
||||||
|
|
||||||
### Scope Guardrail
|
|
||||||
|
|
||||||
If the user picks or describes something too large (major feature, multi-day work):
|
|
||||||
|
|
||||||
```
|
|
||||||
That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through.
|
|
||||||
|
|
||||||
For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details.
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]?
|
|
||||||
2. **Pick something else** - One of the other suggestions, or a different small task?
|
|
||||||
3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer.
|
|
||||||
|
|
||||||
What would you prefer?
|
|
||||||
```
|
|
||||||
|
|
||||||
Let the user override if they insist—this is a soft guardrail.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: Explore Demo
|
|
||||||
|
|
||||||
Once a task is selected, briefly demonstrate explore mode:
|
|
||||||
|
|
||||||
```
|
|
||||||
Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction.
|
|
||||||
```
|
|
||||||
|
|
||||||
Spend 1-2 minutes investigating the relevant code:
|
|
||||||
- Read the file(s) involved
|
|
||||||
- Draw a quick ASCII diagram if it helps
|
|
||||||
- Note any considerations
|
|
||||||
|
|
||||||
```
|
|
||||||
## Quick Exploration
|
|
||||||
|
|
||||||
[Your brief analysis—what you found, any considerations]
|
|
||||||
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ [Optional: ASCII diagram if helpful] │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
|
|
||||||
Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
|
||||||
|
|
||||||
Now let's create a change to hold our work.
|
|
||||||
```
|
|
||||||
|
|
||||||
**PAUSE** - Wait for user acknowledgment before proceeding.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: Create the Change
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Creating a Change
|
|
||||||
|
|
||||||
A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives in `openspec/changes/<name>/` and holds your artifacts—proposal, specs, design, tasks.
|
|
||||||
|
|
||||||
Let me create one for our task.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Create the change with a derived kebab-case name:
|
|
||||||
```bash
|
|
||||||
openspec new change "<derived-name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**SHOW:**
|
|
||||||
```
|
|
||||||
Created: `openspec/changes/<name>/`
|
|
||||||
|
|
||||||
The folder structure:
|
|
||||||
```
|
|
||||||
openspec/changes/<name>/
|
|
||||||
├── proposal.md ← Why we're doing this (empty, we'll fill it)
|
|
||||||
├── design.md ← How we'll build it (empty)
|
|
||||||
├── specs/ ← Detailed requirements (empty)
|
|
||||||
└── tasks.md ← Implementation checklist (empty)
|
|
||||||
```
|
|
||||||
|
|
||||||
Now let's fill in the first artifact—the proposal.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: Proposal
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## The Proposal
|
|
||||||
|
|
||||||
The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work.
|
|
||||||
|
|
||||||
I'll draft one based on our task.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Draft the proposal content (don't save yet):
|
|
||||||
|
|
||||||
```
|
|
||||||
Here's a draft proposal:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
[1-2 sentences explaining the problem/opportunity]
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
[Bullet points of what will be different]
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
- `<capability-name>`: [brief description]
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
<!-- If modifying existing behavior -->
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- `src/path/to/file.ts`: [what changes]
|
|
||||||
- [other files if applicable]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Does this capture the intent? I can adjust before we save it.
|
|
||||||
```
|
|
||||||
|
|
||||||
**PAUSE** - Wait for user approval/feedback.
|
|
||||||
|
|
||||||
After approval, save the proposal:
|
|
||||||
```bash
|
|
||||||
openspec instructions proposal --change "<name>" --json
|
|
||||||
```
|
|
||||||
Then write the content to `openspec/changes/<name>/proposal.md`.
|
|
||||||
|
|
||||||
```
|
|
||||||
Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves.
|
|
||||||
|
|
||||||
Next up: specs.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Specs
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Specs
|
|
||||||
|
|
||||||
Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear.
|
|
||||||
|
|
||||||
For a small task like this, we might only need one spec file.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Create the spec file:
|
|
||||||
```bash
|
|
||||||
mkdir -p openspec/changes/<name>/specs/<capability-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
Draft the spec content:
|
|
||||||
|
|
||||||
```
|
|
||||||
Here's the spec:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: <Name>
|
|
||||||
|
|
||||||
<Description of what the system should do>
|
|
||||||
|
|
||||||
#### Scenario: <Scenario name>
|
|
||||||
|
|
||||||
- **WHEN** <trigger condition>
|
|
||||||
- **THEN** <expected outcome>
|
|
||||||
- **AND** <additional outcome if needed>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases.
|
|
||||||
```
|
|
||||||
|
|
||||||
Save to `openspec/changes/<name>/specs/<capability>/spec.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7: Design
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Design
|
|
||||||
|
|
||||||
The design captures **how** we'll build it—technical decisions, tradeoffs, approach.
|
|
||||||
|
|
||||||
For small changes, this might be brief. That's fine—not every change needs deep design discussion.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Draft design.md:
|
|
||||||
|
|
||||||
```
|
|
||||||
Here's the design:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[Brief context about the current state]
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
- [What we're trying to achieve]
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
- [What's explicitly out of scope]
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### Decision 1: [Key decision]
|
|
||||||
|
|
||||||
[Explanation of approach and rationale]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
For a small task, this captures the key decisions without over-engineering.
|
|
||||||
```
|
|
||||||
|
|
||||||
Save to `openspec/changes/<name>/design.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 8: Tasks
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
|
|
||||||
|
|
||||||
These should be small, clear, and in logical order.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Generate tasks based on specs and design:
|
|
||||||
|
|
||||||
```
|
|
||||||
Here are the implementation tasks:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. [Category or file]
|
|
||||||
|
|
||||||
- [ ] 1.1 [Specific task]
|
|
||||||
- [ ] 1.2 [Specific task]
|
|
||||||
|
|
||||||
## 2. Verify
|
|
||||||
|
|
||||||
- [ ] 2.1 [Verification step]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
|
|
||||||
```
|
|
||||||
|
|
||||||
**PAUSE** - Wait for user to confirm they're ready to implement.
|
|
||||||
|
|
||||||
Save to `openspec/changes/<name>/tasks.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 9: Apply (Implementation)
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** For each task:
|
|
||||||
|
|
||||||
1. Announce: "Working on task N: [description]"
|
|
||||||
2. Implement the change in the codebase
|
|
||||||
3. Reference specs/design naturally: "The spec says X, so I'm doing Y"
|
|
||||||
4. Mark complete in tasks.md: `- [ ]` → `- [x]`
|
|
||||||
5. Brief status: "✓ Task N complete"
|
|
||||||
|
|
||||||
Keep narration light—don't over-explain every line of code.
|
|
||||||
|
|
||||||
After all tasks:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Implementation Complete
|
|
||||||
|
|
||||||
All tasks done:
|
|
||||||
- [x] Task 1
|
|
||||||
- [x] Task 2
|
|
||||||
- [x] ...
|
|
||||||
|
|
||||||
The change is implemented! One more step—let's archive it.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 10: Archive
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Archiving
|
|
||||||
|
|
||||||
When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/changes/archive/YYYY-MM-DD-<name>/`.
|
|
||||||
|
|
||||||
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:**
|
|
||||||
```bash
|
|
||||||
openspec archive "<name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**SHOW:**
|
|
||||||
```
|
|
||||||
Archived to: `openspec/changes/archive/YYYY-MM-DD-<name>/`
|
|
||||||
|
|
||||||
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 11: Recap & Next Steps
|
|
||||||
|
|
||||||
```
|
|
||||||
## Congratulations!
|
|
||||||
|
|
||||||
You just completed a full OpenSpec cycle:
|
|
||||||
|
|
||||||
1. **Explore** - Thought through the problem
|
|
||||||
2. **New** - Created a change container
|
|
||||||
3. **Proposal** - Captured WHY
|
|
||||||
4. **Specs** - Defined WHAT in detail
|
|
||||||
5. **Design** - Decided HOW
|
|
||||||
6. **Tasks** - Broke it into steps
|
|
||||||
7. **Apply** - Implemented the work
|
|
||||||
8. **Archive** - Preserved the record
|
|
||||||
|
|
||||||
This same rhythm works for any size change—a small fix or a major feature.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Command Reference
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
|---------|--------------|
|
|
||||||
| `/opsx:explore` | Think through problems before/during work |
|
|
||||||
| `/opsx:new` | Start a new change, step through artifacts |
|
|
||||||
| `/opsx:ff` | Fast-forward: create all artifacts at once |
|
|
||||||
| `/opsx:continue` | Continue working on an existing change |
|
|
||||||
| `/opsx:apply` | Implement tasks from a change |
|
|
||||||
| `/opsx:verify` | Verify implementation matches artifacts |
|
|
||||||
| `/opsx:archive` | Archive a completed change |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What's Next?
|
|
||||||
|
|
||||||
Try `/opsx:new` or `/opsx:ff` on something you actually want to build. You've got the rhythm now!
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Graceful Exit Handling
|
|
||||||
|
|
||||||
### User wants to stop mid-way
|
|
||||||
|
|
||||||
If the user says they need to stop, want to pause, or seem disengaged:
|
|
||||||
|
|
||||||
```
|
|
||||||
No problem! Your change is saved at `openspec/changes/<name>/`.
|
|
||||||
|
|
||||||
To pick up where we left off later:
|
|
||||||
- `/opsx:continue <name>` - Resume artifact creation
|
|
||||||
- `/opsx:apply <name>` - Jump to implementation (if tasks exist)
|
|
||||||
|
|
||||||
The work won't be lost. Come back whenever you're ready.
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit gracefully without pressure.
|
|
||||||
|
|
||||||
### User just wants command reference
|
|
||||||
|
|
||||||
If the user says they just want to see the commands or skip the tutorial:
|
|
||||||
|
|
||||||
```
|
|
||||||
## OpenSpec Quick Reference
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
|---------|--------------|
|
|
||||||
| `/opsx:explore` | Think through problems (no code changes) |
|
|
||||||
| `/opsx:new <name>` | Start a new change, step by step |
|
|
||||||
| `/opsx:ff <name>` | Fast-forward: all artifacts at once |
|
|
||||||
| `/opsx:continue <name>` | Continue an existing change |
|
|
||||||
| `/opsx:apply <name>` | Implement tasks |
|
|
||||||
| `/opsx:verify <name>` | Verify implementation |
|
|
||||||
| `/opsx:archive <name>` | Archive when done |
|
|
||||||
|
|
||||||
Try `/opsx:new` to start your first change, or `/opsx:ff` if you want to move fast.
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit gracefully.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive)
|
|
||||||
- **Keep narration light** during implementation—teach without lecturing
|
|
||||||
- **Don't skip phases** even if the change is small—the goal is teaching the workflow
|
|
||||||
- **Pause for acknowledgment** at marked points, but don't over-pause
|
|
||||||
- **Handle exits gracefully**—never pressure the user to continue
|
|
||||||
- **Use real codebase tasks**—don't simulate or use fake examples
|
|
||||||
- **Adjust scope gently**—guide toward smaller tasks but respect user choice
|
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
---
|
---
|
||||||
name: openspec-ff-change
|
name: openspec-propose
|
||||||
description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually.
|
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||||
license: MIT
|
license: MIT
|
||||||
compatibility: Requires openspec CLI.
|
compatibility: Requires openspec CLI.
|
||||||
metadata:
|
metadata:
|
||||||
author: openspec
|
author: openspec
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
generatedBy: "1.1.1"
|
generatedBy: "1.2.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
Fast-forward through artifact creation - generate everything needed to start implementation in one go.
|
Propose a new change - create the change and generate all artifacts in one step.
|
||||||
|
|
||||||
|
I'll create a change with artifacts:
|
||||||
|
- proposal.md (what & why)
|
||||||
|
- design.md (how)
|
||||||
|
- tasks.md (implementation steps)
|
||||||
|
|
||||||
|
When ready to implement, run /opsx:apply
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||||
|
|
||||||
@@ -28,7 +37,7 @@ Fast-forward through artifact creation - generate everything needed to start imp
|
|||||||
```bash
|
```bash
|
||||||
openspec new change "<name>"
|
openspec new change "<name>"
|
||||||
```
|
```
|
||||||
This creates a scaffolded change at `openspec/changes/<name>/`.
|
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||||
|
|
||||||
3. **Get the artifact build order**
|
3. **Get the artifact build order**
|
||||||
```bash
|
```bash
|
||||||
@@ -59,7 +68,7 @@ Fast-forward through artifact creation - generate everything needed to start imp
|
|||||||
- Read any completed dependency files for context
|
- Read any completed dependency files for context
|
||||||
- Create the artifact file using `template` as the structure
|
- Create the artifact file using `template` as the structure
|
||||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||||
- Show brief progress: "✓ Created <artifact-id>"
|
- Show brief progress: "Created <artifact-id>"
|
||||||
|
|
||||||
b. **Continue until all `applyRequires` artifacts are complete**
|
b. **Continue until all `applyRequires` artifacts are complete**
|
||||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||||
@@ -97,5 +106,5 @@ After completing all artifacts, summarize:
|
|||||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||||
- Always read dependency artifacts before creating a new one
|
- Always read dependency artifacts before creating a new one
|
||||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||||
- If a change with that name already exists, suggest continuing that change instead
|
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||||
- Verify each artifact file exists after writing before proceeding to next
|
- Verify each artifact file exists after writing before proceeding to next
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-sync-specs
|
|
||||||
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Sync delta specs from a change to main specs.
|
|
||||||
|
|
||||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
|
||||||
|
|
||||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no change name provided, prompt for selection**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
|
||||||
|
|
||||||
Show changes that have delta specs (under `specs/` directory).
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
|
||||||
|
|
||||||
2. **Find delta specs**
|
|
||||||
|
|
||||||
Look for delta spec files in `openspec/changes/<name>/specs/*/spec.md`.
|
|
||||||
|
|
||||||
Each delta spec file contains sections like:
|
|
||||||
- `## ADDED Requirements` - New requirements to add
|
|
||||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
|
||||||
- `## REMOVED Requirements` - Requirements to remove
|
|
||||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
|
||||||
|
|
||||||
If no delta specs found, inform user and stop.
|
|
||||||
|
|
||||||
3. **For each delta spec, apply changes to main specs**
|
|
||||||
|
|
||||||
For each capability with a delta spec at `openspec/changes/<name>/specs/<capability>/spec.md`:
|
|
||||||
|
|
||||||
a. **Read the delta spec** to understand the intended changes
|
|
||||||
|
|
||||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
|
||||||
|
|
||||||
c. **Apply changes intelligently**:
|
|
||||||
|
|
||||||
**ADDED Requirements:**
|
|
||||||
- If requirement doesn't exist in main spec → add it
|
|
||||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
|
||||||
|
|
||||||
**MODIFIED Requirements:**
|
|
||||||
- Find the requirement in main spec
|
|
||||||
- Apply the changes - this can be:
|
|
||||||
- Adding new scenarios (don't need to copy existing ones)
|
|
||||||
- Modifying existing scenarios
|
|
||||||
- Changing the requirement description
|
|
||||||
- Preserve scenarios/content not mentioned in the delta
|
|
||||||
|
|
||||||
**REMOVED Requirements:**
|
|
||||||
- Remove the entire requirement block from main spec
|
|
||||||
|
|
||||||
**RENAMED Requirements:**
|
|
||||||
- Find the FROM requirement, rename to TO
|
|
||||||
|
|
||||||
d. **Create new main spec** if capability doesn't exist yet:
|
|
||||||
- Create `openspec/specs/<capability>/spec.md`
|
|
||||||
- Add Purpose section (can be brief, mark as TBD)
|
|
||||||
- Add Requirements section with the ADDED requirements
|
|
||||||
|
|
||||||
4. **Show summary**
|
|
||||||
|
|
||||||
After applying all changes, summarize:
|
|
||||||
- Which capabilities were updated
|
|
||||||
- What changes were made (requirements added/modified/removed/renamed)
|
|
||||||
|
|
||||||
**Delta Spec Format Reference**
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: New Feature
|
|
||||||
The system SHALL do something new.
|
|
||||||
|
|
||||||
#### Scenario: Basic case
|
|
||||||
- **WHEN** user does X
|
|
||||||
- **THEN** system does Y
|
|
||||||
|
|
||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: Existing Feature
|
|
||||||
#### Scenario: New scenario to add
|
|
||||||
- **WHEN** user does A
|
|
||||||
- **THEN** system does B
|
|
||||||
|
|
||||||
## REMOVED Requirements
|
|
||||||
|
|
||||||
### Requirement: Deprecated Feature
|
|
||||||
|
|
||||||
## RENAMED Requirements
|
|
||||||
|
|
||||||
- FROM: `### Requirement: Old Name`
|
|
||||||
- TO: `### Requirement: New Name`
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Principle: Intelligent Merging**
|
|
||||||
|
|
||||||
Unlike programmatic merging, you can apply **partial updates**:
|
|
||||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
|
||||||
- The delta represents *intent*, not a wholesale replacement
|
|
||||||
- Use your judgment to merge changes sensibly
|
|
||||||
|
|
||||||
**Output On Success**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Specs Synced: <change-name>
|
|
||||||
|
|
||||||
Updated main specs:
|
|
||||||
|
|
||||||
**<capability-1>**:
|
|
||||||
- Added requirement: "New Feature"
|
|
||||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
|
||||||
|
|
||||||
**<capability-2>**:
|
|
||||||
- Created new spec file
|
|
||||||
- Added requirement: "Another Feature"
|
|
||||||
|
|
||||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Read both delta and main specs before making changes
|
|
||||||
- Preserve existing content not mentioned in delta
|
|
||||||
- If something is unclear, ask for clarification
|
|
||||||
- Show what you're changing as you go
|
|
||||||
- The operation should be idempotent - running twice should give same result
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-verify-change
|
|
||||||
description: Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Verify that an implementation matches the change artifacts (specs, tasks, design).
|
|
||||||
|
|
||||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no change name provided, prompt for selection**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
|
||||||
|
|
||||||
Show changes that have implementation tasks (tasks artifact exists).
|
|
||||||
Include the schema used for each change if available.
|
|
||||||
Mark changes with incomplete tasks as "(In Progress)".
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
|
||||||
|
|
||||||
2. **Check status to understand the schema**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>" --json
|
|
||||||
```
|
|
||||||
Parse the JSON to understand:
|
|
||||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
|
||||||
- Which artifacts exist for this change
|
|
||||||
|
|
||||||
3. **Get the change directory and load artifacts**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openspec instructions apply --change "<name>" --json
|
|
||||||
```
|
|
||||||
|
|
||||||
This returns the change directory and context files. Read all available artifacts from `contextFiles`.
|
|
||||||
|
|
||||||
4. **Initialize verification report structure**
|
|
||||||
|
|
||||||
Create a report structure with three dimensions:
|
|
||||||
- **Completeness**: Track tasks and spec coverage
|
|
||||||
- **Correctness**: Track requirement implementation and scenario coverage
|
|
||||||
- **Coherence**: Track design adherence and pattern consistency
|
|
||||||
|
|
||||||
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
|
|
||||||
|
|
||||||
5. **Verify Completeness**
|
|
||||||
|
|
||||||
**Task Completion**:
|
|
||||||
- If tasks.md exists in contextFiles, read it
|
|
||||||
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
|
|
||||||
- Count complete vs total tasks
|
|
||||||
- If incomplete tasks exist:
|
|
||||||
- Add CRITICAL issue for each incomplete task
|
|
||||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
|
||||||
|
|
||||||
**Spec Coverage**:
|
|
||||||
- If delta specs exist in `openspec/changes/<name>/specs/`:
|
|
||||||
- Extract all requirements (marked with "### Requirement:")
|
|
||||||
- For each requirement:
|
|
||||||
- Search codebase for keywords related to the requirement
|
|
||||||
- Assess if implementation likely exists
|
|
||||||
- If requirements appear unimplemented:
|
|
||||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
|
||||||
- Recommendation: "Implement requirement X: <description>"
|
|
||||||
|
|
||||||
6. **Verify Correctness**
|
|
||||||
|
|
||||||
**Requirement Implementation Mapping**:
|
|
||||||
- For each requirement from delta specs:
|
|
||||||
- Search codebase for implementation evidence
|
|
||||||
- If found, note file paths and line ranges
|
|
||||||
- Assess if implementation matches requirement intent
|
|
||||||
- If divergence detected:
|
|
||||||
- Add WARNING: "Implementation may diverge from spec: <details>"
|
|
||||||
- Recommendation: "Review <file>:<lines> against requirement X"
|
|
||||||
|
|
||||||
**Scenario Coverage**:
|
|
||||||
- For each scenario in delta specs (marked with "#### Scenario:"):
|
|
||||||
- Check if conditions are handled in code
|
|
||||||
- Check if tests exist covering the scenario
|
|
||||||
- If scenario appears uncovered:
|
|
||||||
- Add WARNING: "Scenario not covered: <scenario name>"
|
|
||||||
- Recommendation: "Add test or implementation for scenario: <description>"
|
|
||||||
|
|
||||||
7. **Verify Coherence**
|
|
||||||
|
|
||||||
**Design Adherence**:
|
|
||||||
- If design.md exists in contextFiles:
|
|
||||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
|
||||||
- Verify implementation follows those decisions
|
|
||||||
- If contradiction detected:
|
|
||||||
- Add WARNING: "Design decision not followed: <decision>"
|
|
||||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
|
||||||
- If no design.md: Skip design adherence check, note "No design.md to verify against"
|
|
||||||
|
|
||||||
**Code Pattern Consistency**:
|
|
||||||
- Review new code for consistency with project patterns
|
|
||||||
- Check file naming, directory structure, coding style
|
|
||||||
- If significant deviations found:
|
|
||||||
- Add SUGGESTION: "Code pattern deviation: <details>"
|
|
||||||
- Recommendation: "Consider following project pattern: <example>"
|
|
||||||
|
|
||||||
8. **Generate Verification Report**
|
|
||||||
|
|
||||||
**Summary Scorecard**:
|
|
||||||
```
|
|
||||||
## Verification Report: <change-name>
|
|
||||||
|
|
||||||
### Summary
|
|
||||||
| Dimension | Status |
|
|
||||||
|--------------|------------------|
|
|
||||||
| Completeness | X/Y tasks, N reqs|
|
|
||||||
| Correctness | M/N reqs covered |
|
|
||||||
| Coherence | Followed/Issues |
|
|
||||||
```
|
|
||||||
|
|
||||||
**Issues by Priority**:
|
|
||||||
|
|
||||||
1. **CRITICAL** (Must fix before archive):
|
|
||||||
- Incomplete tasks
|
|
||||||
- Missing requirement implementations
|
|
||||||
- Each with specific, actionable recommendation
|
|
||||||
|
|
||||||
2. **WARNING** (Should fix):
|
|
||||||
- Spec/design divergences
|
|
||||||
- Missing scenario coverage
|
|
||||||
- Each with specific recommendation
|
|
||||||
|
|
||||||
3. **SUGGESTION** (Nice to fix):
|
|
||||||
- Pattern inconsistencies
|
|
||||||
- Minor improvements
|
|
||||||
- Each with specific recommendation
|
|
||||||
|
|
||||||
**Final Assessment**:
|
|
||||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
|
|
||||||
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
|
||||||
- If all clear: "All checks passed. Ready for archive."
|
|
||||||
|
|
||||||
**Verification Heuristics**
|
|
||||||
|
|
||||||
- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)
|
|
||||||
- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty
|
|
||||||
- **Coherence**: Look for glaring inconsistencies, don't nitpick style
|
|
||||||
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
|
|
||||||
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
|
|
||||||
|
|
||||||
**Graceful Degradation**
|
|
||||||
|
|
||||||
- If only tasks.md exists: verify task completion only, skip spec/design checks
|
|
||||||
- If tasks + specs exist: verify completeness and correctness, skip design
|
|
||||||
- If full artifacts: verify all three dimensions
|
|
||||||
- Always note which checks were skipped and why
|
|
||||||
|
|
||||||
**Output Format**
|
|
||||||
|
|
||||||
Use clear markdown with:
|
|
||||||
- Table for summary scorecard
|
|
||||||
- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)
|
|
||||||
- Code references in format: `file.ts:123`
|
|
||||||
- Specific, actionable recommendations
|
|
||||||
- No vague suggestions like "consider reviewing"
|
|
||||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
|||||||
metadata:
|
metadata:
|
||||||
author: openspec
|
author: openspec
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
generatedBy: "1.1.1"
|
generatedBy: "1.2.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
Implement tasks from an OpenSpec change.
|
Implement tasks from an OpenSpec change.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
|||||||
metadata:
|
metadata:
|
||||||
author: openspec
|
author: openspec
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
generatedBy: "1.1.1"
|
generatedBy: "1.2.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
Archive a completed change in the experimental workflow.
|
Archive a completed change in the experimental workflow.
|
||||||
@@ -63,7 +63,7 @@ Archive a completed change in the experimental workflow.
|
|||||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||||
|
|
||||||
If user chooses sync, execute /opsx:sync logic (use the openspec-sync-specs skill). Proceed to archive regardless of choice.
|
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||||
|
|
||||||
5. **Perform the archive**
|
5. **Perform the archive**
|
||||||
|
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-bulk-archive-change
|
|
||||||
description: Archive multiple completed changes at once. Use when archiving several parallel changes.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Archive multiple completed changes in a single operation.
|
|
||||||
|
|
||||||
This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented.
|
|
||||||
|
|
||||||
**Input**: None required (prompts for selection)
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **Get active changes**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get all active changes.
|
|
||||||
|
|
||||||
If no active changes exist, inform user and stop.
|
|
||||||
|
|
||||||
2. **Prompt for change selection**
|
|
||||||
|
|
||||||
Use **AskUserQuestion tool** with multi-select to let user choose changes:
|
|
||||||
- Show each change with its schema
|
|
||||||
- Include an option for "All changes"
|
|
||||||
- Allow any number of selections (1+ works, 2+ is the typical use case)
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT auto-select. Always let the user choose.
|
|
||||||
|
|
||||||
3. **Batch validation - gather status for all selected changes**
|
|
||||||
|
|
||||||
For each selected change, collect:
|
|
||||||
|
|
||||||
a. **Artifact status** - Run `openspec status --change "<name>" --json`
|
|
||||||
- Parse `schemaName` and `artifacts` list
|
|
||||||
- Note which artifacts are `done` vs other states
|
|
||||||
|
|
||||||
b. **Task completion** - Read `openspec/changes/<name>/tasks.md`
|
|
||||||
- Count `- [ ]` (incomplete) vs `- [x]` (complete)
|
|
||||||
- If no tasks file exists, note as "No tasks"
|
|
||||||
|
|
||||||
c. **Delta specs** - Check `openspec/changes/<name>/specs/` directory
|
|
||||||
- List which capability specs exist
|
|
||||||
- For each, extract requirement names (lines matching `### Requirement: <name>`)
|
|
||||||
|
|
||||||
4. **Detect spec conflicts**
|
|
||||||
|
|
||||||
Build a map of `capability -> [changes that touch it]`:
|
|
||||||
|
|
||||||
```
|
|
||||||
auth -> [change-a, change-b] <- CONFLICT (2+ changes)
|
|
||||||
api -> [change-c] <- OK (only 1 change)
|
|
||||||
```
|
|
||||||
|
|
||||||
A conflict exists when 2+ selected changes have delta specs for the same capability.
|
|
||||||
|
|
||||||
5. **Resolve conflicts agentically**
|
|
||||||
|
|
||||||
**For each conflict**, investigate the codebase:
|
|
||||||
|
|
||||||
a. **Read the delta specs** from each conflicting change to understand what each claims to add/modify
|
|
||||||
|
|
||||||
b. **Search the codebase** for implementation evidence:
|
|
||||||
- Look for code implementing requirements from each delta spec
|
|
||||||
- Check for related files, functions, or tests
|
|
||||||
|
|
||||||
c. **Determine resolution**:
|
|
||||||
- If only one change is actually implemented -> sync that one's specs
|
|
||||||
- If both implemented -> apply in chronological order (older first, newer overwrites)
|
|
||||||
- If neither implemented -> skip spec sync, warn user
|
|
||||||
|
|
||||||
d. **Record resolution** for each conflict:
|
|
||||||
- Which change's specs to apply
|
|
||||||
- In what order (if both)
|
|
||||||
- Rationale (what was found in codebase)
|
|
||||||
|
|
||||||
6. **Show consolidated status table**
|
|
||||||
|
|
||||||
Display a table summarizing all changes:
|
|
||||||
|
|
||||||
```
|
|
||||||
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|
|
||||||
|---------------------|-----------|-------|---------|-----------|--------|
|
|
||||||
| schema-management | Done | 5/5 | 2 delta | None | Ready |
|
|
||||||
| project-config | Done | 3/3 | 1 delta | None | Ready |
|
|
||||||
| add-oauth | Done | 4/4 | 1 delta | auth (!) | Ready* |
|
|
||||||
| add-verify-skill | 1 left | 2/5 | None | None | Warn |
|
|
||||||
```
|
|
||||||
|
|
||||||
For conflicts, show the resolution:
|
|
||||||
```
|
|
||||||
* Conflict resolution:
|
|
||||||
- auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order)
|
|
||||||
```
|
|
||||||
|
|
||||||
For incomplete changes, show warnings:
|
|
||||||
```
|
|
||||||
Warnings:
|
|
||||||
- add-verify-skill: 1 incomplete artifact, 3 incomplete tasks
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Confirm batch operation**
|
|
||||||
|
|
||||||
Use **AskUserQuestion tool** with a single confirmation:
|
|
||||||
|
|
||||||
- "Archive N changes?" with options based on status
|
|
||||||
- Options might include:
|
|
||||||
- "Archive all N changes"
|
|
||||||
- "Archive only N ready changes (skip incomplete)"
|
|
||||||
- "Cancel"
|
|
||||||
|
|
||||||
If there are incomplete changes, make clear they'll be archived with warnings.
|
|
||||||
|
|
||||||
8. **Execute archive for each confirmed change**
|
|
||||||
|
|
||||||
Process changes in the determined order (respecting conflict resolution):
|
|
||||||
|
|
||||||
a. **Sync specs** if delta specs exist:
|
|
||||||
- Use the openspec-sync-specs approach (agent-driven intelligent merge)
|
|
||||||
- For conflicts, apply in resolved order
|
|
||||||
- Track if sync was done
|
|
||||||
|
|
||||||
b. **Perform the archive**:
|
|
||||||
```bash
|
|
||||||
mkdir -p openspec/changes/archive
|
|
||||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
|
||||||
```
|
|
||||||
|
|
||||||
c. **Track outcome** for each change:
|
|
||||||
- Success: archived successfully
|
|
||||||
- Failed: error during archive (record error)
|
|
||||||
- Skipped: user chose not to archive (if applicable)
|
|
||||||
|
|
||||||
9. **Display summary**
|
|
||||||
|
|
||||||
Show final results:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Bulk Archive Complete
|
|
||||||
|
|
||||||
Archived 3 changes:
|
|
||||||
- schema-management-cli -> archive/2026-01-19-schema-management-cli/
|
|
||||||
- project-config -> archive/2026-01-19-project-config/
|
|
||||||
- add-oauth -> archive/2026-01-19-add-oauth/
|
|
||||||
|
|
||||||
Skipped 1 change:
|
|
||||||
- add-verify-skill (user chose not to archive incomplete)
|
|
||||||
|
|
||||||
Spec sync summary:
|
|
||||||
- 4 delta specs synced to main specs
|
|
||||||
- 1 conflict resolved (auth: applied both in chronological order)
|
|
||||||
```
|
|
||||||
|
|
||||||
If any failures:
|
|
||||||
```
|
|
||||||
Failed 1 change:
|
|
||||||
- some-change: Archive directory already exists
|
|
||||||
```
|
|
||||||
|
|
||||||
**Conflict Resolution Examples**
|
|
||||||
|
|
||||||
Example 1: Only one implemented
|
|
||||||
```
|
|
||||||
Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt]
|
|
||||||
|
|
||||||
Checking add-oauth:
|
|
||||||
- Delta adds "OAuth Provider Integration" requirement
|
|
||||||
- Searching codebase... found src/auth/oauth.ts implementing OAuth flow
|
|
||||||
|
|
||||||
Checking add-jwt:
|
|
||||||
- Delta adds "JWT Token Handling" requirement
|
|
||||||
- Searching codebase... no JWT implementation found
|
|
||||||
|
|
||||||
Resolution: Only add-oauth is implemented. Will sync add-oauth specs only.
|
|
||||||
```
|
|
||||||
|
|
||||||
Example 2: Both implemented
|
|
||||||
```
|
|
||||||
Conflict: specs/api/spec.md touched by [add-rest-api, add-graphql]
|
|
||||||
|
|
||||||
Checking add-rest-api (created 2026-01-10):
|
|
||||||
- Delta adds "REST Endpoints" requirement
|
|
||||||
- Searching codebase... found src/api/rest.ts
|
|
||||||
|
|
||||||
Checking add-graphql (created 2026-01-15):
|
|
||||||
- Delta adds "GraphQL Schema" requirement
|
|
||||||
- Searching codebase... found src/api/graphql.ts
|
|
||||||
|
|
||||||
Resolution: Both implemented. Will apply add-rest-api specs first,
|
|
||||||
then add-graphql specs (chronological order, newer takes precedence).
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output On Success**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Bulk Archive Complete
|
|
||||||
|
|
||||||
Archived N changes:
|
|
||||||
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
|
|
||||||
- <change-2> -> archive/YYYY-MM-DD-<change-2>/
|
|
||||||
|
|
||||||
Spec sync summary:
|
|
||||||
- N delta specs synced to main specs
|
|
||||||
- No conflicts (or: M conflicts resolved)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output On Partial Success**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Bulk Archive Complete (partial)
|
|
||||||
|
|
||||||
Archived N changes:
|
|
||||||
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
|
|
||||||
|
|
||||||
Skipped M changes:
|
|
||||||
- <change-2> (user chose not to archive incomplete)
|
|
||||||
|
|
||||||
Failed K changes:
|
|
||||||
- <change-3>: Archive directory already exists
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output When No Changes**
|
|
||||||
|
|
||||||
```
|
|
||||||
## No Changes to Archive
|
|
||||||
|
|
||||||
No active changes found. Use `/opsx:new` to create a new change.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Allow any number of changes (1+ is fine, 2+ is the typical use case)
|
|
||||||
- Always prompt for selection, never auto-select
|
|
||||||
- Detect spec conflicts early and resolve by checking codebase
|
|
||||||
- When both changes are implemented, apply specs in chronological order
|
|
||||||
- Skip spec sync only when implementation is missing (warn user)
|
|
||||||
- Show clear per-change status before confirming
|
|
||||||
- Use single confirmation for entire batch
|
|
||||||
- Track and report all outcomes (success/skip/fail)
|
|
||||||
- Preserve .openspec.yaml when moving to archive
|
|
||||||
- Archive directory target uses current date: YYYY-MM-DD-<name>
|
|
||||||
- If archive target exists, fail that change but continue with others
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-continue-change
|
|
||||||
description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Continue working on a change by creating the next artifact.
|
|
||||||
|
|
||||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no change name provided, prompt for selection**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on.
|
|
||||||
|
|
||||||
Present the top 3-4 most recently modified changes as options, showing:
|
|
||||||
- Change name
|
|
||||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
|
||||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
|
||||||
- How recently it was modified (from `lastModified` field)
|
|
||||||
|
|
||||||
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue.
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
|
||||||
|
|
||||||
2. **Check current status**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>" --json
|
|
||||||
```
|
|
||||||
Parse the JSON to understand current state. The response includes:
|
|
||||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
|
||||||
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
|
|
||||||
- `isComplete`: Boolean indicating if all artifacts are complete
|
|
||||||
|
|
||||||
3. **Act based on status**:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If all artifacts are complete (`isComplete: true`)**:
|
|
||||||
- Congratulate the user
|
|
||||||
- Show final status including the schema used
|
|
||||||
- Suggest: "All artifacts created! You can now implement this change or archive it."
|
|
||||||
- STOP
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If artifacts are ready to create** (status shows artifacts with `status: "ready"`):
|
|
||||||
- Pick the FIRST artifact with `status: "ready"` from the status output
|
|
||||||
- Get its instructions:
|
|
||||||
```bash
|
|
||||||
openspec instructions <artifact-id> --change "<name>" --json
|
|
||||||
```
|
|
||||||
- Parse the JSON. The key fields are:
|
|
||||||
- `context`: Project background (constraints for you - do NOT include in output)
|
|
||||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
|
||||||
- `template`: The structure to use for your output file
|
|
||||||
- `instruction`: Schema-specific guidance
|
|
||||||
- `outputPath`: Where to write the artifact
|
|
||||||
- `dependencies`: Completed artifacts to read for context
|
|
||||||
- **Create the artifact file**:
|
|
||||||
- Read any completed dependency files for context
|
|
||||||
- Use `template` as the structure - fill in its sections
|
|
||||||
- Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file
|
|
||||||
- Write to the output path specified in instructions
|
|
||||||
- Show what was created and what's now unlocked
|
|
||||||
- STOP after creating ONE artifact
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If no artifacts are ready (all blocked)**:
|
|
||||||
- This shouldn't happen with a valid schema
|
|
||||||
- Show status and suggest checking for issues
|
|
||||||
|
|
||||||
4. **After creating an artifact, show progress**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output**
|
|
||||||
|
|
||||||
After each invocation, show:
|
|
||||||
- Which artifact was created
|
|
||||||
- Schema workflow being used
|
|
||||||
- Current progress (N/M complete)
|
|
||||||
- What artifacts are now unlocked
|
|
||||||
- Prompt: "Want to continue? Just ask me to continue or tell me what to do next."
|
|
||||||
|
|
||||||
**Artifact Creation Guidelines**
|
|
||||||
|
|
||||||
The artifact types and their purpose depend on the schema. Use the `instruction` field from the instructions output to understand what to create.
|
|
||||||
|
|
||||||
Common artifact patterns:
|
|
||||||
|
|
||||||
**spec-driven schema** (proposal → specs → design → tasks):
|
|
||||||
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
|
|
||||||
- The Capabilities section is critical - each capability listed will need a spec file.
|
|
||||||
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
|
|
||||||
- **design.md**: Document technical decisions, architecture, and implementation approach.
|
|
||||||
- **tasks.md**: Break down implementation into checkboxed tasks.
|
|
||||||
|
|
||||||
For other schemas, follow the `instruction` field from the CLI output.
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Create ONE artifact per invocation
|
|
||||||
- Always read dependency artifacts before creating a new one
|
|
||||||
- Never skip artifacts or create out of order
|
|
||||||
- If context is unclear, ask the user before creating
|
|
||||||
- Verify the artifact file exists after writing before marking progress
|
|
||||||
- Use the schema's artifact sequence, don't assume specific artifact names
|
|
||||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
|
||||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
|
||||||
- These guide what you write, but should never appear in the output
|
|
||||||
@@ -6,12 +6,12 @@ compatibility: Requires openspec CLI.
|
|||||||
metadata:
|
metadata:
|
||||||
author: openspec
|
author: openspec
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
generatedBy: "1.1.1"
|
generatedBy: "1.2.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||||
|
|
||||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with `/opsx:new` or `/opsx:ff`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||||
|
|
||||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||||
|
|
||||||
@@ -95,8 +95,7 @@ This tells you:
|
|||||||
|
|
||||||
Think freely. When insights crystallize, you might offer:
|
Think freely. When insights crystallize, you might offer:
|
||||||
|
|
||||||
- "This feels solid enough to start a change. Want me to create one?"
|
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||||
→ Can transition to `/opsx:new` or `/opsx:ff`
|
|
||||||
- Or keep exploring - no pressure to formalize
|
- Or keep exploring - no pressure to formalize
|
||||||
|
|
||||||
### When a change exists
|
### When a change exists
|
||||||
@@ -252,7 +251,7 @@ You: That changes everything.
|
|||||||
|
|
||||||
There's no required ending. Discovery might:
|
There's no required ending. Discovery might:
|
||||||
|
|
||||||
- **Flow into action**: "Ready to start? /opsx:new or /opsx:ff"
|
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||||
- **Just provide clarity**: User has what they need, moves on
|
- **Just provide clarity**: User has what they need, moves on
|
||||||
- **Continue later**: "We can pick this up anytime"
|
- **Continue later**: "We can pick this up anytime"
|
||||||
@@ -269,8 +268,7 @@ When it feels like things are crystallizing, you might summarize:
|
|||||||
**Open questions**: [if any remain]
|
**Open questions**: [if any remain]
|
||||||
|
|
||||||
**Next steps** (if ready):
|
**Next steps** (if ready):
|
||||||
- Create a change: /opsx:new <name>
|
- Create a change proposal
|
||||||
- Fast-forward to tasks: /opsx:ff <name>
|
|
||||||
- Keep exploring: just keep talking
|
- Keep exploring: just keep talking
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-new-change
|
|
||||||
description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Start a new change using the experimental artifact-driven approach.
|
|
||||||
|
|
||||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no clear input provided, ask what they want to build**
|
|
||||||
|
|
||||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
|
||||||
> "What change do you want to work on? Describe what you want to build or fix."
|
|
||||||
|
|
||||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
|
||||||
|
|
||||||
2. **Determine the workflow schema**
|
|
||||||
|
|
||||||
Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow.
|
|
||||||
|
|
||||||
**Use a different schema only if the user mentions:**
|
|
||||||
- A specific schema name → use `--schema <name>`
|
|
||||||
- "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose
|
|
||||||
|
|
||||||
**Otherwise**: Omit `--schema` to use the default.
|
|
||||||
|
|
||||||
3. **Create the change directory**
|
|
||||||
```bash
|
|
||||||
openspec new change "<name>"
|
|
||||||
```
|
|
||||||
Add `--schema <name>` only if the user requested a specific workflow.
|
|
||||||
This creates a scaffolded change at `openspec/changes/<name>/` with the selected schema.
|
|
||||||
|
|
||||||
4. **Show the artifact status**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>"
|
|
||||||
```
|
|
||||||
This shows which artifacts need to be created and which are ready (dependencies satisfied).
|
|
||||||
|
|
||||||
5. **Get instructions for the first artifact**
|
|
||||||
The first artifact depends on the schema (e.g., `proposal` for spec-driven).
|
|
||||||
Check the status output to find the first artifact with status "ready".
|
|
||||||
```bash
|
|
||||||
openspec instructions <first-artifact-id> --change "<name>"
|
|
||||||
```
|
|
||||||
This outputs the template and context for creating the first artifact.
|
|
||||||
|
|
||||||
6. **STOP and wait for user direction**
|
|
||||||
|
|
||||||
**Output**
|
|
||||||
|
|
||||||
After completing the steps, summarize:
|
|
||||||
- Change name and location
|
|
||||||
- Schema/workflow being used and its artifact sequence
|
|
||||||
- Current status (0/N artifacts complete)
|
|
||||||
- The template for the first artifact
|
|
||||||
- Prompt: "Ready to create the first artifact? Just describe what this change is about and I'll draft it, or ask me to continue."
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Do NOT create any artifacts yet - just show the instructions
|
|
||||||
- Do NOT advance beyond showing the first artifact template
|
|
||||||
- If the name is invalid (not kebab-case), ask for a valid name
|
|
||||||
- If a change with that name already exists, suggest continuing that change instead
|
|
||||||
- Pass --schema if using a non-default workflow
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-onboard
|
|
||||||
description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Preflight
|
|
||||||
|
|
||||||
Before starting, check if OpenSpec is initialized:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openspec status --json 2>&1 || echo "NOT_INITIALIZED"
|
|
||||||
```
|
|
||||||
|
|
||||||
**If not initialized:**
|
|
||||||
> OpenSpec isn't set up in this project yet. Run `openspec init` first, then come back to `/opsx:onboard`.
|
|
||||||
|
|
||||||
Stop here if not initialized.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: Welcome
|
|
||||||
|
|
||||||
Display:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Welcome to OpenSpec!
|
|
||||||
|
|
||||||
I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it.
|
|
||||||
|
|
||||||
**What we'll do:**
|
|
||||||
1. Pick a small, real task in your codebase
|
|
||||||
2. Explore the problem briefly
|
|
||||||
3. Create a change (the container for our work)
|
|
||||||
4. Build the artifacts: proposal → specs → design → tasks
|
|
||||||
5. Implement the tasks
|
|
||||||
6. Archive the completed change
|
|
||||||
|
|
||||||
**Time:** ~15-20 minutes
|
|
||||||
|
|
||||||
Let's start by finding something to work on.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Task Selection
|
|
||||||
|
|
||||||
### Codebase Analysis
|
|
||||||
|
|
||||||
Scan the codebase for small improvement opportunities. Look for:
|
|
||||||
|
|
||||||
1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files
|
|
||||||
2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch
|
|
||||||
3. **Functions without tests** - Cross-reference `src/` with test directories
|
|
||||||
4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`)
|
|
||||||
5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code
|
|
||||||
6. **Missing validation** - User input handlers without validation
|
|
||||||
|
|
||||||
Also check recent git activity:
|
|
||||||
```bash
|
|
||||||
git log --oneline -10 2>/dev/null || echo "No git history"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Present Suggestions
|
|
||||||
|
|
||||||
From your analysis, present 3-4 specific suggestions:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Task Suggestions
|
|
||||||
|
|
||||||
Based on scanning your codebase, here are some good starter tasks:
|
|
||||||
|
|
||||||
**1. [Most promising task]**
|
|
||||||
Location: `src/path/to/file.ts:42`
|
|
||||||
Scope: ~1-2 files, ~20-30 lines
|
|
||||||
Why it's good: [brief reason]
|
|
||||||
|
|
||||||
**2. [Second task]**
|
|
||||||
Location: `src/another/file.ts`
|
|
||||||
Scope: ~1 file, ~15 lines
|
|
||||||
Why it's good: [brief reason]
|
|
||||||
|
|
||||||
**3. [Third task]**
|
|
||||||
Location: [location]
|
|
||||||
Scope: [estimate]
|
|
||||||
Why it's good: [brief reason]
|
|
||||||
|
|
||||||
**4. Something else?**
|
|
||||||
Tell me what you'd like to work on.
|
|
||||||
|
|
||||||
Which task interests you? (Pick a number or describe your own)
|
|
||||||
```
|
|
||||||
|
|
||||||
**If nothing found:** Fall back to asking what the user wants to build:
|
|
||||||
> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix?
|
|
||||||
|
|
||||||
### Scope Guardrail
|
|
||||||
|
|
||||||
If the user picks or describes something too large (major feature, multi-day work):
|
|
||||||
|
|
||||||
```
|
|
||||||
That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through.
|
|
||||||
|
|
||||||
For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details.
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]?
|
|
||||||
2. **Pick something else** - One of the other suggestions, or a different small task?
|
|
||||||
3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer.
|
|
||||||
|
|
||||||
What would you prefer?
|
|
||||||
```
|
|
||||||
|
|
||||||
Let the user override if they insist—this is a soft guardrail.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: Explore Demo
|
|
||||||
|
|
||||||
Once a task is selected, briefly demonstrate explore mode:
|
|
||||||
|
|
||||||
```
|
|
||||||
Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction.
|
|
||||||
```
|
|
||||||
|
|
||||||
Spend 1-2 minutes investigating the relevant code:
|
|
||||||
- Read the file(s) involved
|
|
||||||
- Draw a quick ASCII diagram if it helps
|
|
||||||
- Note any considerations
|
|
||||||
|
|
||||||
```
|
|
||||||
## Quick Exploration
|
|
||||||
|
|
||||||
[Your brief analysis—what you found, any considerations]
|
|
||||||
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ [Optional: ASCII diagram if helpful] │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
|
|
||||||
Explore mode (`/opsx:explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
|
||||||
|
|
||||||
Now let's create a change to hold our work.
|
|
||||||
```
|
|
||||||
|
|
||||||
**PAUSE** - Wait for user acknowledgment before proceeding.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: Create the Change
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Creating a Change
|
|
||||||
|
|
||||||
A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives in `openspec/changes/<name>/` and holds your artifacts—proposal, specs, design, tasks.
|
|
||||||
|
|
||||||
Let me create one for our task.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Create the change with a derived kebab-case name:
|
|
||||||
```bash
|
|
||||||
openspec new change "<derived-name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**SHOW:**
|
|
||||||
```
|
|
||||||
Created: `openspec/changes/<name>/`
|
|
||||||
|
|
||||||
The folder structure:
|
|
||||||
```
|
|
||||||
openspec/changes/<name>/
|
|
||||||
├── proposal.md ← Why we're doing this (empty, we'll fill it)
|
|
||||||
├── design.md ← How we'll build it (empty)
|
|
||||||
├── specs/ ← Detailed requirements (empty)
|
|
||||||
└── tasks.md ← Implementation checklist (empty)
|
|
||||||
```
|
|
||||||
|
|
||||||
Now let's fill in the first artifact—the proposal.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: Proposal
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## The Proposal
|
|
||||||
|
|
||||||
The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work.
|
|
||||||
|
|
||||||
I'll draft one based on our task.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Draft the proposal content (don't save yet):
|
|
||||||
|
|
||||||
```
|
|
||||||
Here's a draft proposal:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
[1-2 sentences explaining the problem/opportunity]
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
[Bullet points of what will be different]
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
- `<capability-name>`: [brief description]
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
<!-- If modifying existing behavior -->
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- `src/path/to/file.ts`: [what changes]
|
|
||||||
- [other files if applicable]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Does this capture the intent? I can adjust before we save it.
|
|
||||||
```
|
|
||||||
|
|
||||||
**PAUSE** - Wait for user approval/feedback.
|
|
||||||
|
|
||||||
After approval, save the proposal:
|
|
||||||
```bash
|
|
||||||
openspec instructions proposal --change "<name>" --json
|
|
||||||
```
|
|
||||||
Then write the content to `openspec/changes/<name>/proposal.md`.
|
|
||||||
|
|
||||||
```
|
|
||||||
Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves.
|
|
||||||
|
|
||||||
Next up: specs.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Specs
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Specs
|
|
||||||
|
|
||||||
Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear.
|
|
||||||
|
|
||||||
For a small task like this, we might only need one spec file.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Create the spec file:
|
|
||||||
```bash
|
|
||||||
mkdir -p openspec/changes/<name>/specs/<capability-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
Draft the spec content:
|
|
||||||
|
|
||||||
```
|
|
||||||
Here's the spec:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: <Name>
|
|
||||||
|
|
||||||
<Description of what the system should do>
|
|
||||||
|
|
||||||
#### Scenario: <Scenario name>
|
|
||||||
|
|
||||||
- **WHEN** <trigger condition>
|
|
||||||
- **THEN** <expected outcome>
|
|
||||||
- **AND** <additional outcome if needed>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases.
|
|
||||||
```
|
|
||||||
|
|
||||||
Save to `openspec/changes/<name>/specs/<capability>/spec.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7: Design
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Design
|
|
||||||
|
|
||||||
The design captures **how** we'll build it—technical decisions, tradeoffs, approach.
|
|
||||||
|
|
||||||
For small changes, this might be brief. That's fine—not every change needs deep design discussion.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Draft design.md:
|
|
||||||
|
|
||||||
```
|
|
||||||
Here's the design:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[Brief context about the current state]
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
- [What we're trying to achieve]
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
- [What's explicitly out of scope]
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### Decision 1: [Key decision]
|
|
||||||
|
|
||||||
[Explanation of approach and rationale]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
For a small task, this captures the key decisions without over-engineering.
|
|
||||||
```
|
|
||||||
|
|
||||||
Save to `openspec/changes/<name>/design.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 8: Tasks
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
|
|
||||||
|
|
||||||
These should be small, clear, and in logical order.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Generate tasks based on specs and design:
|
|
||||||
|
|
||||||
```
|
|
||||||
Here are the implementation tasks:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. [Category or file]
|
|
||||||
|
|
||||||
- [ ] 1.1 [Specific task]
|
|
||||||
- [ ] 1.2 [Specific task]
|
|
||||||
|
|
||||||
## 2. Verify
|
|
||||||
|
|
||||||
- [ ] 2.1 [Verification step]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
|
|
||||||
```
|
|
||||||
|
|
||||||
**PAUSE** - Wait for user to confirm they're ready to implement.
|
|
||||||
|
|
||||||
Save to `openspec/changes/<name>/tasks.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 9: Apply (Implementation)
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** For each task:
|
|
||||||
|
|
||||||
1. Announce: "Working on task N: [description]"
|
|
||||||
2. Implement the change in the codebase
|
|
||||||
3. Reference specs/design naturally: "The spec says X, so I'm doing Y"
|
|
||||||
4. Mark complete in tasks.md: `- [ ]` → `- [x]`
|
|
||||||
5. Brief status: "✓ Task N complete"
|
|
||||||
|
|
||||||
Keep narration light—don't over-explain every line of code.
|
|
||||||
|
|
||||||
After all tasks:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Implementation Complete
|
|
||||||
|
|
||||||
All tasks done:
|
|
||||||
- [x] Task 1
|
|
||||||
- [x] Task 2
|
|
||||||
- [x] ...
|
|
||||||
|
|
||||||
The change is implemented! One more step—let's archive it.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 10: Archive
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Archiving
|
|
||||||
|
|
||||||
When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/changes/archive/YYYY-MM-DD-<name>/`.
|
|
||||||
|
|
||||||
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:**
|
|
||||||
```bash
|
|
||||||
openspec archive "<name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**SHOW:**
|
|
||||||
```
|
|
||||||
Archived to: `openspec/changes/archive/YYYY-MM-DD-<name>/`
|
|
||||||
|
|
||||||
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 11: Recap & Next Steps
|
|
||||||
|
|
||||||
```
|
|
||||||
## Congratulations!
|
|
||||||
|
|
||||||
You just completed a full OpenSpec cycle:
|
|
||||||
|
|
||||||
1. **Explore** - Thought through the problem
|
|
||||||
2. **New** - Created a change container
|
|
||||||
3. **Proposal** - Captured WHY
|
|
||||||
4. **Specs** - Defined WHAT in detail
|
|
||||||
5. **Design** - Decided HOW
|
|
||||||
6. **Tasks** - Broke it into steps
|
|
||||||
7. **Apply** - Implemented the work
|
|
||||||
8. **Archive** - Preserved the record
|
|
||||||
|
|
||||||
This same rhythm works for any size change—a small fix or a major feature.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Command Reference
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
|---------|--------------|
|
|
||||||
| `/opsx:explore` | Think through problems before/during work |
|
|
||||||
| `/opsx:new` | Start a new change, step through artifacts |
|
|
||||||
| `/opsx:ff` | Fast-forward: create all artifacts at once |
|
|
||||||
| `/opsx:continue` | Continue working on an existing change |
|
|
||||||
| `/opsx:apply` | Implement tasks from a change |
|
|
||||||
| `/opsx:verify` | Verify implementation matches artifacts |
|
|
||||||
| `/opsx:archive` | Archive a completed change |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What's Next?
|
|
||||||
|
|
||||||
Try `/opsx:new` or `/opsx:ff` on something you actually want to build. You've got the rhythm now!
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Graceful Exit Handling
|
|
||||||
|
|
||||||
### User wants to stop mid-way
|
|
||||||
|
|
||||||
If the user says they need to stop, want to pause, or seem disengaged:
|
|
||||||
|
|
||||||
```
|
|
||||||
No problem! Your change is saved at `openspec/changes/<name>/`.
|
|
||||||
|
|
||||||
To pick up where we left off later:
|
|
||||||
- `/opsx:continue <name>` - Resume artifact creation
|
|
||||||
- `/opsx:apply <name>` - Jump to implementation (if tasks exist)
|
|
||||||
|
|
||||||
The work won't be lost. Come back whenever you're ready.
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit gracefully without pressure.
|
|
||||||
|
|
||||||
### User just wants command reference
|
|
||||||
|
|
||||||
If the user says they just want to see the commands or skip the tutorial:
|
|
||||||
|
|
||||||
```
|
|
||||||
## OpenSpec Quick Reference
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
|---------|--------------|
|
|
||||||
| `/opsx:explore` | Think through problems (no code changes) |
|
|
||||||
| `/opsx:new <name>` | Start a new change, step by step |
|
|
||||||
| `/opsx:ff <name>` | Fast-forward: all artifacts at once |
|
|
||||||
| `/opsx:continue <name>` | Continue an existing change |
|
|
||||||
| `/opsx:apply <name>` | Implement tasks |
|
|
||||||
| `/opsx:verify <name>` | Verify implementation |
|
|
||||||
| `/opsx:archive <name>` | Archive when done |
|
|
||||||
|
|
||||||
Try `/opsx:new` to start your first change, or `/opsx:ff` if you want to move fast.
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit gracefully.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive)
|
|
||||||
- **Keep narration light** during implementation—teach without lecturing
|
|
||||||
- **Don't skip phases** even if the change is small—the goal is teaching the workflow
|
|
||||||
- **Pause for acknowledgment** at marked points, but don't over-pause
|
|
||||||
- **Handle exits gracefully**—never pressure the user to continue
|
|
||||||
- **Use real codebase tasks**—don't simulate or use fake examples
|
|
||||||
- **Adjust scope gently**—guide toward smaller tasks but respect user choice
|
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
---
|
---
|
||||||
name: openspec-ff-change
|
name: openspec-propose
|
||||||
description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually.
|
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||||
license: MIT
|
license: MIT
|
||||||
compatibility: Requires openspec CLI.
|
compatibility: Requires openspec CLI.
|
||||||
metadata:
|
metadata:
|
||||||
author: openspec
|
author: openspec
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
generatedBy: "1.1.1"
|
generatedBy: "1.2.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
Fast-forward through artifact creation - generate everything needed to start implementation in one go.
|
Propose a new change - create the change and generate all artifacts in one step.
|
||||||
|
|
||||||
|
I'll create a change with artifacts:
|
||||||
|
- proposal.md (what & why)
|
||||||
|
- design.md (how)
|
||||||
|
- tasks.md (implementation steps)
|
||||||
|
|
||||||
|
When ready to implement, run /opsx:apply
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||||
|
|
||||||
@@ -28,7 +37,7 @@ Fast-forward through artifact creation - generate everything needed to start imp
|
|||||||
```bash
|
```bash
|
||||||
openspec new change "<name>"
|
openspec new change "<name>"
|
||||||
```
|
```
|
||||||
This creates a scaffolded change at `openspec/changes/<name>/`.
|
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||||
|
|
||||||
3. **Get the artifact build order**
|
3. **Get the artifact build order**
|
||||||
```bash
|
```bash
|
||||||
@@ -59,7 +68,7 @@ Fast-forward through artifact creation - generate everything needed to start imp
|
|||||||
- Read any completed dependency files for context
|
- Read any completed dependency files for context
|
||||||
- Create the artifact file using `template` as the structure
|
- Create the artifact file using `template` as the structure
|
||||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||||
- Show brief progress: "✓ Created <artifact-id>"
|
- Show brief progress: "Created <artifact-id>"
|
||||||
|
|
||||||
b. **Continue until all `applyRequires` artifacts are complete**
|
b. **Continue until all `applyRequires` artifacts are complete**
|
||||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||||
@@ -97,5 +106,5 @@ After completing all artifacts, summarize:
|
|||||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||||
- Always read dependency artifacts before creating a new one
|
- Always read dependency artifacts before creating a new one
|
||||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||||
- If a change with that name already exists, suggest continuing that change instead
|
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||||
- Verify each artifact file exists after writing before proceeding to next
|
- Verify each artifact file exists after writing before proceeding to next
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-sync-specs
|
|
||||||
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Sync delta specs from a change to main specs.
|
|
||||||
|
|
||||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
|
||||||
|
|
||||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no change name provided, prompt for selection**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
|
||||||
|
|
||||||
Show changes that have delta specs (under `specs/` directory).
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
|
||||||
|
|
||||||
2. **Find delta specs**
|
|
||||||
|
|
||||||
Look for delta spec files in `openspec/changes/<name>/specs/*/spec.md`.
|
|
||||||
|
|
||||||
Each delta spec file contains sections like:
|
|
||||||
- `## ADDED Requirements` - New requirements to add
|
|
||||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
|
||||||
- `## REMOVED Requirements` - Requirements to remove
|
|
||||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
|
||||||
|
|
||||||
If no delta specs found, inform user and stop.
|
|
||||||
|
|
||||||
3. **For each delta spec, apply changes to main specs**
|
|
||||||
|
|
||||||
For each capability with a delta spec at `openspec/changes/<name>/specs/<capability>/spec.md`:
|
|
||||||
|
|
||||||
a. **Read the delta spec** to understand the intended changes
|
|
||||||
|
|
||||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
|
||||||
|
|
||||||
c. **Apply changes intelligently**:
|
|
||||||
|
|
||||||
**ADDED Requirements:**
|
|
||||||
- If requirement doesn't exist in main spec → add it
|
|
||||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
|
||||||
|
|
||||||
**MODIFIED Requirements:**
|
|
||||||
- Find the requirement in main spec
|
|
||||||
- Apply the changes - this can be:
|
|
||||||
- Adding new scenarios (don't need to copy existing ones)
|
|
||||||
- Modifying existing scenarios
|
|
||||||
- Changing the requirement description
|
|
||||||
- Preserve scenarios/content not mentioned in the delta
|
|
||||||
|
|
||||||
**REMOVED Requirements:**
|
|
||||||
- Remove the entire requirement block from main spec
|
|
||||||
|
|
||||||
**RENAMED Requirements:**
|
|
||||||
- Find the FROM requirement, rename to TO
|
|
||||||
|
|
||||||
d. **Create new main spec** if capability doesn't exist yet:
|
|
||||||
- Create `openspec/specs/<capability>/spec.md`
|
|
||||||
- Add Purpose section (can be brief, mark as TBD)
|
|
||||||
- Add Requirements section with the ADDED requirements
|
|
||||||
|
|
||||||
4. **Show summary**
|
|
||||||
|
|
||||||
After applying all changes, summarize:
|
|
||||||
- Which capabilities were updated
|
|
||||||
- What changes were made (requirements added/modified/removed/renamed)
|
|
||||||
|
|
||||||
**Delta Spec Format Reference**
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: New Feature
|
|
||||||
The system SHALL do something new.
|
|
||||||
|
|
||||||
#### Scenario: Basic case
|
|
||||||
- **WHEN** user does X
|
|
||||||
- **THEN** system does Y
|
|
||||||
|
|
||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: Existing Feature
|
|
||||||
#### Scenario: New scenario to add
|
|
||||||
- **WHEN** user does A
|
|
||||||
- **THEN** system does B
|
|
||||||
|
|
||||||
## REMOVED Requirements
|
|
||||||
|
|
||||||
### Requirement: Deprecated Feature
|
|
||||||
|
|
||||||
## RENAMED Requirements
|
|
||||||
|
|
||||||
- FROM: `### Requirement: Old Name`
|
|
||||||
- TO: `### Requirement: New Name`
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Principle: Intelligent Merging**
|
|
||||||
|
|
||||||
Unlike programmatic merging, you can apply **partial updates**:
|
|
||||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
|
||||||
- The delta represents *intent*, not a wholesale replacement
|
|
||||||
- Use your judgment to merge changes sensibly
|
|
||||||
|
|
||||||
**Output On Success**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Specs Synced: <change-name>
|
|
||||||
|
|
||||||
Updated main specs:
|
|
||||||
|
|
||||||
**<capability-1>**:
|
|
||||||
- Added requirement: "New Feature"
|
|
||||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
|
||||||
|
|
||||||
**<capability-2>**:
|
|
||||||
- Created new spec file
|
|
||||||
- Added requirement: "Another Feature"
|
|
||||||
|
|
||||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Read both delta and main specs before making changes
|
|
||||||
- Preserve existing content not mentioned in delta
|
|
||||||
- If something is unclear, ask for clarification
|
|
||||||
- Show what you're changing as you go
|
|
||||||
- The operation should be idempotent - running twice should give same result
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-verify-change
|
|
||||||
description: Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Verify that an implementation matches the change artifacts (specs, tasks, design).
|
|
||||||
|
|
||||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no change name provided, prompt for selection**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
|
||||||
|
|
||||||
Show changes that have implementation tasks (tasks artifact exists).
|
|
||||||
Include the schema used for each change if available.
|
|
||||||
Mark changes with incomplete tasks as "(In Progress)".
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
|
||||||
|
|
||||||
2. **Check status to understand the schema**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>" --json
|
|
||||||
```
|
|
||||||
Parse the JSON to understand:
|
|
||||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
|
||||||
- Which artifacts exist for this change
|
|
||||||
|
|
||||||
3. **Get the change directory and load artifacts**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openspec instructions apply --change "<name>" --json
|
|
||||||
```
|
|
||||||
|
|
||||||
This returns the change directory and context files. Read all available artifacts from `contextFiles`.
|
|
||||||
|
|
||||||
4. **Initialize verification report structure**
|
|
||||||
|
|
||||||
Create a report structure with three dimensions:
|
|
||||||
- **Completeness**: Track tasks and spec coverage
|
|
||||||
- **Correctness**: Track requirement implementation and scenario coverage
|
|
||||||
- **Coherence**: Track design adherence and pattern consistency
|
|
||||||
|
|
||||||
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
|
|
||||||
|
|
||||||
5. **Verify Completeness**
|
|
||||||
|
|
||||||
**Task Completion**:
|
|
||||||
- If tasks.md exists in contextFiles, read it
|
|
||||||
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
|
|
||||||
- Count complete vs total tasks
|
|
||||||
- If incomplete tasks exist:
|
|
||||||
- Add CRITICAL issue for each incomplete task
|
|
||||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
|
||||||
|
|
||||||
**Spec Coverage**:
|
|
||||||
- If delta specs exist in `openspec/changes/<name>/specs/`:
|
|
||||||
- Extract all requirements (marked with "### Requirement:")
|
|
||||||
- For each requirement:
|
|
||||||
- Search codebase for keywords related to the requirement
|
|
||||||
- Assess if implementation likely exists
|
|
||||||
- If requirements appear unimplemented:
|
|
||||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
|
||||||
- Recommendation: "Implement requirement X: <description>"
|
|
||||||
|
|
||||||
6. **Verify Correctness**
|
|
||||||
|
|
||||||
**Requirement Implementation Mapping**:
|
|
||||||
- For each requirement from delta specs:
|
|
||||||
- Search codebase for implementation evidence
|
|
||||||
- If found, note file paths and line ranges
|
|
||||||
- Assess if implementation matches requirement intent
|
|
||||||
- If divergence detected:
|
|
||||||
- Add WARNING: "Implementation may diverge from spec: <details>"
|
|
||||||
- Recommendation: "Review <file>:<lines> against requirement X"
|
|
||||||
|
|
||||||
**Scenario Coverage**:
|
|
||||||
- For each scenario in delta specs (marked with "#### Scenario:"):
|
|
||||||
- Check if conditions are handled in code
|
|
||||||
- Check if tests exist covering the scenario
|
|
||||||
- If scenario appears uncovered:
|
|
||||||
- Add WARNING: "Scenario not covered: <scenario name>"
|
|
||||||
- Recommendation: "Add test or implementation for scenario: <description>"
|
|
||||||
|
|
||||||
7. **Verify Coherence**
|
|
||||||
|
|
||||||
**Design Adherence**:
|
|
||||||
- If design.md exists in contextFiles:
|
|
||||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
|
||||||
- Verify implementation follows those decisions
|
|
||||||
- If contradiction detected:
|
|
||||||
- Add WARNING: "Design decision not followed: <decision>"
|
|
||||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
|
||||||
- If no design.md: Skip design adherence check, note "No design.md to verify against"
|
|
||||||
|
|
||||||
**Code Pattern Consistency**:
|
|
||||||
- Review new code for consistency with project patterns
|
|
||||||
- Check file naming, directory structure, coding style
|
|
||||||
- If significant deviations found:
|
|
||||||
- Add SUGGESTION: "Code pattern deviation: <details>"
|
|
||||||
- Recommendation: "Consider following project pattern: <example>"
|
|
||||||
|
|
||||||
8. **Generate Verification Report**
|
|
||||||
|
|
||||||
**Summary Scorecard**:
|
|
||||||
```
|
|
||||||
## Verification Report: <change-name>
|
|
||||||
|
|
||||||
### Summary
|
|
||||||
| Dimension | Status |
|
|
||||||
|--------------|------------------|
|
|
||||||
| Completeness | X/Y tasks, N reqs|
|
|
||||||
| Correctness | M/N reqs covered |
|
|
||||||
| Coherence | Followed/Issues |
|
|
||||||
```
|
|
||||||
|
|
||||||
**Issues by Priority**:
|
|
||||||
|
|
||||||
1. **CRITICAL** (Must fix before archive):
|
|
||||||
- Incomplete tasks
|
|
||||||
- Missing requirement implementations
|
|
||||||
- Each with specific, actionable recommendation
|
|
||||||
|
|
||||||
2. **WARNING** (Should fix):
|
|
||||||
- Spec/design divergences
|
|
||||||
- Missing scenario coverage
|
|
||||||
- Each with specific recommendation
|
|
||||||
|
|
||||||
3. **SUGGESTION** (Nice to fix):
|
|
||||||
- Pattern inconsistencies
|
|
||||||
- Minor improvements
|
|
||||||
- Each with specific recommendation
|
|
||||||
|
|
||||||
**Final Assessment**:
|
|
||||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
|
|
||||||
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
|
||||||
- If all clear: "All checks passed. Ready for archive."
|
|
||||||
|
|
||||||
**Verification Heuristics**
|
|
||||||
|
|
||||||
- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)
|
|
||||||
- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty
|
|
||||||
- **Coherence**: Look for glaring inconsistencies, don't nitpick style
|
|
||||||
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
|
|
||||||
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
|
|
||||||
|
|
||||||
**Graceful Degradation**
|
|
||||||
|
|
||||||
- If only tasks.md exists: verify task completion only, skip spec/design checks
|
|
||||||
- If tasks + specs exist: verify completeness and correctness, skip design
|
|
||||||
- If full artifacts: verify all three dimensions
|
|
||||||
- Always note which checks were skipped and why
|
|
||||||
|
|
||||||
**Output Format**
|
|
||||||
|
|
||||||
Use clear markdown with:
|
|
||||||
- Table for summary scorecard
|
|
||||||
- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)
|
|
||||||
- Code references in format: `file.ts:123`
|
|
||||||
- Specific, actionable recommendations
|
|
||||||
- No vague suggestions like "consider reviewing"
|
|
||||||
13
.config/dbhub.toml
Normal file
13
.config/dbhub.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[[sources]]
|
||||||
|
id = "main"
|
||||||
|
dsn = "postgresql://erp_pgsql:erp_2025@cxd.whcxd.cn:16159/junhong_cmp_test?sslmode=disable"
|
||||||
|
|
||||||
|
[[tools]]
|
||||||
|
name = "search_objects"
|
||||||
|
source = "main"
|
||||||
|
|
||||||
|
[[tools]]
|
||||||
|
name = "execute_sql"
|
||||||
|
source = "main"
|
||||||
|
readonly = true # Only allow SELECT, SHOW, DESCRIBE, EXPLAIN
|
||||||
|
max_rows = 1000 # Limit query results
|
||||||
@@ -56,7 +56,7 @@ Archive a completed change in the experimental workflow.
|
|||||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||||
|
|
||||||
If user chooses sync, execute `/opsx-sync` logic. Proceed to archive regardless of choice.
|
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||||
|
|
||||||
5. **Perform the archive**
|
5. **Perform the archive**
|
||||||
|
|
||||||
@@ -150,5 +150,5 @@ Target archive directory already exists.
|
|||||||
- Don't block archive on warnings - just inform and confirm
|
- Don't block archive on warnings - just inform and confirm
|
||||||
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
- Preserve .openspec.yaml when moving to archive (it moves with the directory)
|
||||||
- Show clear summary of what happened
|
- Show clear summary of what happened
|
||||||
- If sync is requested, use /opsx-sync approach (agent-driven)
|
- If sync is requested, use the Skill tool to invoke `openspec-sync-specs` (agent-driven)
|
||||||
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
- If delta specs exist, always run the sync assessment and show the combined summary before prompting
|
||||||
|
|||||||
@@ -1,239 +0,0 @@
|
|||||||
---
|
|
||||||
description: Archive multiple completed changes at once
|
|
||||||
---
|
|
||||||
|
|
||||||
Archive multiple completed changes in a single operation.
|
|
||||||
|
|
||||||
This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented.
|
|
||||||
|
|
||||||
**Input**: None required (prompts for selection)
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **Get active changes**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get all active changes.
|
|
||||||
|
|
||||||
If no active changes exist, inform user and stop.
|
|
||||||
|
|
||||||
2. **Prompt for change selection**
|
|
||||||
|
|
||||||
Use **AskUserQuestion tool** with multi-select to let user choose changes:
|
|
||||||
- Show each change with its schema
|
|
||||||
- Include an option for "All changes"
|
|
||||||
- Allow any number of selections (1+ works, 2+ is the typical use case)
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT auto-select. Always let the user choose.
|
|
||||||
|
|
||||||
3. **Batch validation - gather status for all selected changes**
|
|
||||||
|
|
||||||
For each selected change, collect:
|
|
||||||
|
|
||||||
a. **Artifact status** - Run `openspec status --change "<name>" --json`
|
|
||||||
- Parse `schemaName` and `artifacts` list
|
|
||||||
- Note which artifacts are `done` vs other states
|
|
||||||
|
|
||||||
b. **Task completion** - Read `openspec/changes/<name>/tasks.md`
|
|
||||||
- Count `- [ ]` (incomplete) vs `- [x]` (complete)
|
|
||||||
- If no tasks file exists, note as "No tasks"
|
|
||||||
|
|
||||||
c. **Delta specs** - Check `openspec/changes/<name>/specs/` directory
|
|
||||||
- List which capability specs exist
|
|
||||||
- For each, extract requirement names (lines matching `### Requirement: <name>`)
|
|
||||||
|
|
||||||
4. **Detect spec conflicts**
|
|
||||||
|
|
||||||
Build a map of `capability -> [changes that touch it]`:
|
|
||||||
|
|
||||||
```
|
|
||||||
auth -> [change-a, change-b] <- CONFLICT (2+ changes)
|
|
||||||
api -> [change-c] <- OK (only 1 change)
|
|
||||||
```
|
|
||||||
|
|
||||||
A conflict exists when 2+ selected changes have delta specs for the same capability.
|
|
||||||
|
|
||||||
5. **Resolve conflicts agentically**
|
|
||||||
|
|
||||||
**For each conflict**, investigate the codebase:
|
|
||||||
|
|
||||||
a. **Read the delta specs** from each conflicting change to understand what each claims to add/modify
|
|
||||||
|
|
||||||
b. **Search the codebase** for implementation evidence:
|
|
||||||
- Look for code implementing requirements from each delta spec
|
|
||||||
- Check for related files, functions, or tests
|
|
||||||
|
|
||||||
c. **Determine resolution**:
|
|
||||||
- If only one change is actually implemented -> sync that one's specs
|
|
||||||
- If both implemented -> apply in chronological order (older first, newer overwrites)
|
|
||||||
- If neither implemented -> skip spec sync, warn user
|
|
||||||
|
|
||||||
d. **Record resolution** for each conflict:
|
|
||||||
- Which change's specs to apply
|
|
||||||
- In what order (if both)
|
|
||||||
- Rationale (what was found in codebase)
|
|
||||||
|
|
||||||
6. **Show consolidated status table**
|
|
||||||
|
|
||||||
Display a table summarizing all changes:
|
|
||||||
|
|
||||||
```
|
|
||||||
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|
|
||||||
|---------------------|-----------|-------|---------|-----------|--------|
|
|
||||||
| schema-management | Done | 5/5 | 2 delta | None | Ready |
|
|
||||||
| project-config | Done | 3/3 | 1 delta | None | Ready |
|
|
||||||
| add-oauth | Done | 4/4 | 1 delta | auth (!) | Ready* |
|
|
||||||
| add-verify-skill | 1 left | 2/5 | None | None | Warn |
|
|
||||||
```
|
|
||||||
|
|
||||||
For conflicts, show the resolution:
|
|
||||||
```
|
|
||||||
* Conflict resolution:
|
|
||||||
- auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order)
|
|
||||||
```
|
|
||||||
|
|
||||||
For incomplete changes, show warnings:
|
|
||||||
```
|
|
||||||
Warnings:
|
|
||||||
- add-verify-skill: 1 incomplete artifact, 3 incomplete tasks
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Confirm batch operation**
|
|
||||||
|
|
||||||
Use **AskUserQuestion tool** with a single confirmation:
|
|
||||||
|
|
||||||
- "Archive N changes?" with options based on status
|
|
||||||
- Options might include:
|
|
||||||
- "Archive all N changes"
|
|
||||||
- "Archive only N ready changes (skip incomplete)"
|
|
||||||
- "Cancel"
|
|
||||||
|
|
||||||
If there are incomplete changes, make clear they'll be archived with warnings.
|
|
||||||
|
|
||||||
8. **Execute archive for each confirmed change**
|
|
||||||
|
|
||||||
Process changes in the determined order (respecting conflict resolution):
|
|
||||||
|
|
||||||
a. **Sync specs** if delta specs exist:
|
|
||||||
- Use the openspec-sync-specs approach (agent-driven intelligent merge)
|
|
||||||
- For conflicts, apply in resolved order
|
|
||||||
- Track if sync was done
|
|
||||||
|
|
||||||
b. **Perform the archive**:
|
|
||||||
```bash
|
|
||||||
mkdir -p openspec/changes/archive
|
|
||||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
|
||||||
```
|
|
||||||
|
|
||||||
c. **Track outcome** for each change:
|
|
||||||
- Success: archived successfully
|
|
||||||
- Failed: error during archive (record error)
|
|
||||||
- Skipped: user chose not to archive (if applicable)
|
|
||||||
|
|
||||||
9. **Display summary**
|
|
||||||
|
|
||||||
Show final results:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Bulk Archive Complete
|
|
||||||
|
|
||||||
Archived 3 changes:
|
|
||||||
- schema-management-cli -> archive/2026-01-19-schema-management-cli/
|
|
||||||
- project-config -> archive/2026-01-19-project-config/
|
|
||||||
- add-oauth -> archive/2026-01-19-add-oauth/
|
|
||||||
|
|
||||||
Skipped 1 change:
|
|
||||||
- add-verify-skill (user chose not to archive incomplete)
|
|
||||||
|
|
||||||
Spec sync summary:
|
|
||||||
- 4 delta specs synced to main specs
|
|
||||||
- 1 conflict resolved (auth: applied both in chronological order)
|
|
||||||
```
|
|
||||||
|
|
||||||
If any failures:
|
|
||||||
```
|
|
||||||
Failed 1 change:
|
|
||||||
- some-change: Archive directory already exists
|
|
||||||
```
|
|
||||||
|
|
||||||
**Conflict Resolution Examples**
|
|
||||||
|
|
||||||
Example 1: Only one implemented
|
|
||||||
```
|
|
||||||
Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt]
|
|
||||||
|
|
||||||
Checking add-oauth:
|
|
||||||
- Delta adds "OAuth Provider Integration" requirement
|
|
||||||
- Searching codebase... found src/auth/oauth.ts implementing OAuth flow
|
|
||||||
|
|
||||||
Checking add-jwt:
|
|
||||||
- Delta adds "JWT Token Handling" requirement
|
|
||||||
- Searching codebase... no JWT implementation found
|
|
||||||
|
|
||||||
Resolution: Only add-oauth is implemented. Will sync add-oauth specs only.
|
|
||||||
```
|
|
||||||
|
|
||||||
Example 2: Both implemented
|
|
||||||
```
|
|
||||||
Conflict: specs/api/spec.md touched by [add-rest-api, add-graphql]
|
|
||||||
|
|
||||||
Checking add-rest-api (created 2026-01-10):
|
|
||||||
- Delta adds "REST Endpoints" requirement
|
|
||||||
- Searching codebase... found src/api/rest.ts
|
|
||||||
|
|
||||||
Checking add-graphql (created 2026-01-15):
|
|
||||||
- Delta adds "GraphQL Schema" requirement
|
|
||||||
- Searching codebase... found src/api/graphql.ts
|
|
||||||
|
|
||||||
Resolution: Both implemented. Will apply add-rest-api specs first,
|
|
||||||
then add-graphql specs (chronological order, newer takes precedence).
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output On Success**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Bulk Archive Complete
|
|
||||||
|
|
||||||
Archived N changes:
|
|
||||||
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
|
|
||||||
- <change-2> -> archive/YYYY-MM-DD-<change-2>/
|
|
||||||
|
|
||||||
Spec sync summary:
|
|
||||||
- N delta specs synced to main specs
|
|
||||||
- No conflicts (or: M conflicts resolved)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output On Partial Success**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Bulk Archive Complete (partial)
|
|
||||||
|
|
||||||
Archived N changes:
|
|
||||||
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
|
|
||||||
|
|
||||||
Skipped M changes:
|
|
||||||
- <change-2> (user chose not to archive incomplete)
|
|
||||||
|
|
||||||
Failed K changes:
|
|
||||||
- <change-3>: Archive directory already exists
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output When No Changes**
|
|
||||||
|
|
||||||
```
|
|
||||||
## No Changes to Archive
|
|
||||||
|
|
||||||
No active changes found. Use `/opsx-new` to create a new change.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Allow any number of changes (1+ is fine, 2+ is the typical use case)
|
|
||||||
- Always prompt for selection, never auto-select
|
|
||||||
- Detect spec conflicts early and resolve by checking codebase
|
|
||||||
- When both changes are implemented, apply specs in chronological order
|
|
||||||
- Skip spec sync only when implementation is missing (warn user)
|
|
||||||
- Show clear per-change status before confirming
|
|
||||||
- Use single confirmation for entire batch
|
|
||||||
- Track and report all outcomes (success/skip/fail)
|
|
||||||
- Preserve .openspec.yaml when moving to archive
|
|
||||||
- Archive directory target uses current date: YYYY-MM-DD-<name>
|
|
||||||
- If archive target exists, fail that change but continue with others
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
---
|
|
||||||
description: Continue working on a change - create the next artifact (Experimental)
|
|
||||||
---
|
|
||||||
|
|
||||||
Continue working on a change by creating the next artifact.
|
|
||||||
|
|
||||||
**Input**: Optionally specify a change name after `/opsx-continue` (e.g., `/opsx-continue add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no change name provided, prompt for selection**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on.
|
|
||||||
|
|
||||||
Present the top 3-4 most recently modified changes as options, showing:
|
|
||||||
- Change name
|
|
||||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
|
||||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
|
||||||
- How recently it was modified (from `lastModified` field)
|
|
||||||
|
|
||||||
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue.
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
|
||||||
|
|
||||||
2. **Check current status**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>" --json
|
|
||||||
```
|
|
||||||
Parse the JSON to understand current state. The response includes:
|
|
||||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
|
||||||
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
|
|
||||||
- `isComplete`: Boolean indicating if all artifacts are complete
|
|
||||||
|
|
||||||
3. **Act based on status**:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If all artifacts are complete (`isComplete: true`)**:
|
|
||||||
- Congratulate the user
|
|
||||||
- Show final status including the schema used
|
|
||||||
- Suggest: "All artifacts created! You can now implement this change with `/opsx-apply` or archive it with `/opsx-archive`."
|
|
||||||
- STOP
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If artifacts are ready to create** (status shows artifacts with `status: "ready"`):
|
|
||||||
- Pick the FIRST artifact with `status: "ready"` from the status output
|
|
||||||
- Get its instructions:
|
|
||||||
```bash
|
|
||||||
openspec instructions <artifact-id> --change "<name>" --json
|
|
||||||
```
|
|
||||||
- Parse the JSON. The key fields are:
|
|
||||||
- `context`: Project background (constraints for you - do NOT include in output)
|
|
||||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
|
||||||
- `template`: The structure to use for your output file
|
|
||||||
- `instruction`: Schema-specific guidance
|
|
||||||
- `outputPath`: Where to write the artifact
|
|
||||||
- `dependencies`: Completed artifacts to read for context
|
|
||||||
- **Create the artifact file**:
|
|
||||||
- Read any completed dependency files for context
|
|
||||||
- Use `template` as the structure - fill in its sections
|
|
||||||
- Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file
|
|
||||||
- Write to the output path specified in instructions
|
|
||||||
- Show what was created and what's now unlocked
|
|
||||||
- STOP after creating ONE artifact
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If no artifacts are ready (all blocked)**:
|
|
||||||
- This shouldn't happen with a valid schema
|
|
||||||
- Show status and suggest checking for issues
|
|
||||||
|
|
||||||
4. **After creating an artifact, show progress**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output**
|
|
||||||
|
|
||||||
After each invocation, show:
|
|
||||||
- Which artifact was created
|
|
||||||
- Schema workflow being used
|
|
||||||
- Current progress (N/M complete)
|
|
||||||
- What artifacts are now unlocked
|
|
||||||
- Prompt: "Run `/opsx-continue` to create the next artifact"
|
|
||||||
|
|
||||||
**Artifact Creation Guidelines**
|
|
||||||
|
|
||||||
The artifact types and their purpose depend on the schema. Use the `instruction` field from the instructions output to understand what to create.
|
|
||||||
|
|
||||||
Common artifact patterns:
|
|
||||||
|
|
||||||
**spec-driven schema** (proposal → specs → design → tasks):
|
|
||||||
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
|
|
||||||
- The Capabilities section is critical - each capability listed will need a spec file.
|
|
||||||
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
|
|
||||||
- **design.md**: Document technical decisions, architecture, and implementation approach.
|
|
||||||
- **tasks.md**: Break down implementation into checkboxed tasks.
|
|
||||||
|
|
||||||
For other schemas, follow the `instruction` field from the CLI output.
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Create ONE artifact per invocation
|
|
||||||
- Always read dependency artifacts before creating a new one
|
|
||||||
- Never skip artifacts or create out of order
|
|
||||||
- If context is unclear, ask the user before creating
|
|
||||||
- Verify the artifact file exists after writing before marking progress
|
|
||||||
- Use the schema's artifact sequence, don't assume specific artifact names
|
|
||||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
|
||||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
|
||||||
- These guide what you write, but should never appear in the output
|
|
||||||
@@ -4,7 +4,7 @@ description: Enter explore mode - think through ideas, investigate problems, cla
|
|||||||
|
|
||||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||||
|
|
||||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with `/opsx-new` or `/opsx-ff`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||||
|
|
||||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||||
|
|
||||||
@@ -97,8 +97,7 @@ If the user mentioned a specific change name, read its artifacts for context.
|
|||||||
|
|
||||||
Think freely. When insights crystallize, you might offer:
|
Think freely. When insights crystallize, you might offer:
|
||||||
|
|
||||||
- "This feels solid enough to start a change. Want me to create one?"
|
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||||
→ Can transition to `/opsx-new` or `/opsx-ff`
|
|
||||||
- Or keep exploring - no pressure to formalize
|
- Or keep exploring - no pressure to formalize
|
||||||
|
|
||||||
### When a change exists
|
### When a change exists
|
||||||
@@ -150,7 +149,7 @@ If the user mentions a change or you detect one is relevant:
|
|||||||
|
|
||||||
There's no required ending. Discovery might:
|
There's no required ending. Discovery might:
|
||||||
|
|
||||||
- **Flow into action**: "Ready to start? `/opsx-new` or `/opsx-ff`"
|
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||||
- **Just provide clarity**: User has what they need, moves on
|
- **Just provide clarity**: User has what they need, moves on
|
||||||
- **Continue later**: "We can pick this up anytime"
|
- **Continue later**: "We can pick this up anytime"
|
||||||
|
|||||||
@@ -1,66 +0,0 @@
|
|||||||
---
|
|
||||||
description: Start a new change using the experimental artifact workflow (OPSX)
|
|
||||||
---
|
|
||||||
|
|
||||||
Start a new change using the experimental artifact-driven approach.
|
|
||||||
|
|
||||||
**Input**: The argument after `/opsx-new` is the change name (kebab-case), OR a description of what the user wants to build.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no input provided, ask what they want to build**
|
|
||||||
|
|
||||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
|
||||||
> "What change do you want to work on? Describe what you want to build or fix."
|
|
||||||
|
|
||||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
|
||||||
|
|
||||||
2. **Determine the workflow schema**
|
|
||||||
|
|
||||||
Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow.
|
|
||||||
|
|
||||||
**Use a different schema only if the user mentions:**
|
|
||||||
- A specific schema name → use `--schema <name>`
|
|
||||||
- "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose
|
|
||||||
|
|
||||||
**Otherwise**: Omit `--schema` to use the default.
|
|
||||||
|
|
||||||
3. **Create the change directory**
|
|
||||||
```bash
|
|
||||||
openspec new change "<name>"
|
|
||||||
```
|
|
||||||
Add `--schema <name>` only if the user requested a specific workflow.
|
|
||||||
This creates a scaffolded change at `openspec/changes/<name>/` with the selected schema.
|
|
||||||
|
|
||||||
4. **Show the artifact status**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>"
|
|
||||||
```
|
|
||||||
This shows which artifacts need to be created and which are ready (dependencies satisfied).
|
|
||||||
|
|
||||||
5. **Get instructions for the first artifact**
|
|
||||||
The first artifact depends on the schema. Check the status output to find the first artifact with status "ready".
|
|
||||||
```bash
|
|
||||||
openspec instructions <first-artifact-id> --change "<name>"
|
|
||||||
```
|
|
||||||
This outputs the template and context for creating the first artifact.
|
|
||||||
|
|
||||||
6. **STOP and wait for user direction**
|
|
||||||
|
|
||||||
**Output**
|
|
||||||
|
|
||||||
After completing the steps, summarize:
|
|
||||||
- Change name and location
|
|
||||||
- Schema/workflow being used and its artifact sequence
|
|
||||||
- Current status (0/N artifacts complete)
|
|
||||||
- The template for the first artifact
|
|
||||||
- Prompt: "Ready to create the first artifact? Run `/opsx-continue` or just describe what this change is about and I'll draft it."
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Do NOT create any artifacts yet - just show the instructions
|
|
||||||
- Do NOT advance beyond showing the first artifact template
|
|
||||||
- If the name is invalid (not kebab-case), ask for a valid name
|
|
||||||
- If a change with that name already exists, suggest using `/opsx-continue` instead
|
|
||||||
- Pass --schema if using a non-default workflow
|
|
||||||
@@ -1,522 +0,0 @@
|
|||||||
---
|
|
||||||
description: Guided onboarding - walk through a complete OpenSpec workflow cycle with narration
|
|
||||||
---
|
|
||||||
|
|
||||||
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Preflight
|
|
||||||
|
|
||||||
Before starting, check if OpenSpec is initialized:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openspec status --json 2>&1 || echo "NOT_INITIALIZED"
|
|
||||||
```
|
|
||||||
|
|
||||||
**If not initialized:**
|
|
||||||
> OpenSpec isn't set up in this project yet. Run `openspec init` first, then come back to `/opsx-onboard`.
|
|
||||||
|
|
||||||
Stop here if not initialized.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: Welcome
|
|
||||||
|
|
||||||
Display:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Welcome to OpenSpec!
|
|
||||||
|
|
||||||
I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it.
|
|
||||||
|
|
||||||
**What we'll do:**
|
|
||||||
1. Pick a small, real task in your codebase
|
|
||||||
2. Explore the problem briefly
|
|
||||||
3. Create a change (the container for our work)
|
|
||||||
4. Build the artifacts: proposal → specs → design → tasks
|
|
||||||
5. Implement the tasks
|
|
||||||
6. Archive the completed change
|
|
||||||
|
|
||||||
**Time:** ~15-20 minutes
|
|
||||||
|
|
||||||
Let's start by finding something to work on.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Task Selection
|
|
||||||
|
|
||||||
### Codebase Analysis
|
|
||||||
|
|
||||||
Scan the codebase for small improvement opportunities. Look for:
|
|
||||||
|
|
||||||
1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files
|
|
||||||
2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch
|
|
||||||
3. **Functions without tests** - Cross-reference `src/` with test directories
|
|
||||||
4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`)
|
|
||||||
5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code
|
|
||||||
6. **Missing validation** - User input handlers without validation
|
|
||||||
|
|
||||||
Also check recent git activity:
|
|
||||||
```bash
|
|
||||||
git log --oneline -10 2>/dev/null || echo "No git history"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Present Suggestions
|
|
||||||
|
|
||||||
From your analysis, present 3-4 specific suggestions:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Task Suggestions
|
|
||||||
|
|
||||||
Based on scanning your codebase, here are some good starter tasks:
|
|
||||||
|
|
||||||
**1. [Most promising task]**
|
|
||||||
Location: `src/path/to/file.ts:42`
|
|
||||||
Scope: ~1-2 files, ~20-30 lines
|
|
||||||
Why it's good: [brief reason]
|
|
||||||
|
|
||||||
**2. [Second task]**
|
|
||||||
Location: `src/another/file.ts`
|
|
||||||
Scope: ~1 file, ~15 lines
|
|
||||||
Why it's good: [brief reason]
|
|
||||||
|
|
||||||
**3. [Third task]**
|
|
||||||
Location: [location]
|
|
||||||
Scope: [estimate]
|
|
||||||
Why it's good: [brief reason]
|
|
||||||
|
|
||||||
**4. Something else?**
|
|
||||||
Tell me what you'd like to work on.
|
|
||||||
|
|
||||||
Which task interests you? (Pick a number or describe your own)
|
|
||||||
```
|
|
||||||
|
|
||||||
**If nothing found:** Fall back to asking what the user wants to build:
|
|
||||||
> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix?
|
|
||||||
|
|
||||||
### Scope Guardrail
|
|
||||||
|
|
||||||
If the user picks or describes something too large (major feature, multi-day work):
|
|
||||||
|
|
||||||
```
|
|
||||||
That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through.
|
|
||||||
|
|
||||||
For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details.
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]?
|
|
||||||
2. **Pick something else** - One of the other suggestions, or a different small task?
|
|
||||||
3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer.
|
|
||||||
|
|
||||||
What would you prefer?
|
|
||||||
```
|
|
||||||
|
|
||||||
Let the user override if they insist—this is a soft guardrail.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: Explore Demo
|
|
||||||
|
|
||||||
Once a task is selected, briefly demonstrate explore mode:
|
|
||||||
|
|
||||||
```
|
|
||||||
Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction.
|
|
||||||
```
|
|
||||||
|
|
||||||
Spend 1-2 minutes investigating the relevant code:
|
|
||||||
- Read the file(s) involved
|
|
||||||
- Draw a quick ASCII diagram if it helps
|
|
||||||
- Note any considerations
|
|
||||||
|
|
||||||
```
|
|
||||||
## Quick Exploration
|
|
||||||
|
|
||||||
[Your brief analysis—what you found, any considerations]
|
|
||||||
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ [Optional: ASCII diagram if helpful] │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
|
|
||||||
Explore mode (`/opsx-explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
|
||||||
|
|
||||||
Now let's create a change to hold our work.
|
|
||||||
```
|
|
||||||
|
|
||||||
**PAUSE** - Wait for user acknowledgment before proceeding.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: Create the Change
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Creating a Change
|
|
||||||
|
|
||||||
A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives in `openspec/changes/<name>/` and holds your artifacts—proposal, specs, design, tasks.
|
|
||||||
|
|
||||||
Let me create one for our task.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Create the change with a derived kebab-case name:
|
|
||||||
```bash
|
|
||||||
openspec new change "<derived-name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**SHOW:**
|
|
||||||
```
|
|
||||||
Created: `openspec/changes/<name>/`
|
|
||||||
|
|
||||||
The folder structure:
|
|
||||||
```
|
|
||||||
openspec/changes/<name>/
|
|
||||||
├── proposal.md ← Why we're doing this (empty, we'll fill it)
|
|
||||||
├── design.md ← How we'll build it (empty)
|
|
||||||
├── specs/ ← Detailed requirements (empty)
|
|
||||||
└── tasks.md ← Implementation checklist (empty)
|
|
||||||
```
|
|
||||||
|
|
||||||
Now let's fill in the first artifact—the proposal.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: Proposal
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## The Proposal
|
|
||||||
|
|
||||||
The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work.
|
|
||||||
|
|
||||||
I'll draft one based on our task.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Draft the proposal content (don't save yet):
|
|
||||||
|
|
||||||
```
|
|
||||||
Here's a draft proposal:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
[1-2 sentences explaining the problem/opportunity]
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
[Bullet points of what will be different]
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
- `<capability-name>`: [brief description]
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
<!-- If modifying existing behavior -->
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- `src/path/to/file.ts`: [what changes]
|
|
||||||
- [other files if applicable]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Does this capture the intent? I can adjust before we save it.
|
|
||||||
```
|
|
||||||
|
|
||||||
**PAUSE** - Wait for user approval/feedback.
|
|
||||||
|
|
||||||
After approval, save the proposal:
|
|
||||||
```bash
|
|
||||||
openspec instructions proposal --change "<name>" --json
|
|
||||||
```
|
|
||||||
Then write the content to `openspec/changes/<name>/proposal.md`.
|
|
||||||
|
|
||||||
```
|
|
||||||
Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves.
|
|
||||||
|
|
||||||
Next up: specs.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Specs
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Specs
|
|
||||||
|
|
||||||
Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear.
|
|
||||||
|
|
||||||
For a small task like this, we might only need one spec file.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Create the spec file:
|
|
||||||
```bash
|
|
||||||
mkdir -p openspec/changes/<name>/specs/<capability-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
Draft the spec content:
|
|
||||||
|
|
||||||
```
|
|
||||||
Here's the spec:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: <Name>
|
|
||||||
|
|
||||||
<Description of what the system should do>
|
|
||||||
|
|
||||||
#### Scenario: <Scenario name>
|
|
||||||
|
|
||||||
- **WHEN** <trigger condition>
|
|
||||||
- **THEN** <expected outcome>
|
|
||||||
- **AND** <additional outcome if needed>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases.
|
|
||||||
```
|
|
||||||
|
|
||||||
Save to `openspec/changes/<name>/specs/<capability>/spec.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7: Design
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Design
|
|
||||||
|
|
||||||
The design captures **how** we'll build it—technical decisions, tradeoffs, approach.
|
|
||||||
|
|
||||||
For small changes, this might be brief. That's fine—not every change needs deep design discussion.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Draft design.md:
|
|
||||||
|
|
||||||
```
|
|
||||||
Here's the design:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[Brief context about the current state]
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
- [What we're trying to achieve]
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
- [What's explicitly out of scope]
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### Decision 1: [Key decision]
|
|
||||||
|
|
||||||
[Explanation of approach and rationale]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
For a small task, this captures the key decisions without over-engineering.
|
|
||||||
```
|
|
||||||
|
|
||||||
Save to `openspec/changes/<name>/design.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 8: Tasks
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
|
|
||||||
|
|
||||||
These should be small, clear, and in logical order.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Generate tasks based on specs and design:
|
|
||||||
|
|
||||||
```
|
|
||||||
Here are the implementation tasks:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. [Category or file]
|
|
||||||
|
|
||||||
- [ ] 1.1 [Specific task]
|
|
||||||
- [ ] 1.2 [Specific task]
|
|
||||||
|
|
||||||
## 2. Verify
|
|
||||||
|
|
||||||
- [ ] 2.1 [Verification step]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
|
|
||||||
```
|
|
||||||
|
|
||||||
**PAUSE** - Wait for user to confirm they're ready to implement.
|
|
||||||
|
|
||||||
Save to `openspec/changes/<name>/tasks.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 9: Apply (Implementation)
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** For each task:
|
|
||||||
|
|
||||||
1. Announce: "Working on task N: [description]"
|
|
||||||
2. Implement the change in the codebase
|
|
||||||
3. Reference specs/design naturally: "The spec says X, so I'm doing Y"
|
|
||||||
4. Mark complete in tasks.md: `- [ ]` → `- [x]`
|
|
||||||
5. Brief status: "✓ Task N complete"
|
|
||||||
|
|
||||||
Keep narration light—don't over-explain every line of code.
|
|
||||||
|
|
||||||
After all tasks:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Implementation Complete
|
|
||||||
|
|
||||||
All tasks done:
|
|
||||||
- [x] Task 1
|
|
||||||
- [x] Task 2
|
|
||||||
- [x] ...
|
|
||||||
|
|
||||||
The change is implemented! One more step—let's archive it.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 10: Archive
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Archiving
|
|
||||||
|
|
||||||
When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/changes/archive/YYYY-MM-DD-<name>/`.
|
|
||||||
|
|
||||||
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:**
|
|
||||||
```bash
|
|
||||||
openspec archive "<name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**SHOW:**
|
|
||||||
```
|
|
||||||
Archived to: `openspec/changes/archive/YYYY-MM-DD-<name>/`
|
|
||||||
|
|
||||||
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 11: Recap & Next Steps
|
|
||||||
|
|
||||||
```
|
|
||||||
## Congratulations!
|
|
||||||
|
|
||||||
You just completed a full OpenSpec cycle:
|
|
||||||
|
|
||||||
1. **Explore** - Thought through the problem
|
|
||||||
2. **New** - Created a change container
|
|
||||||
3. **Proposal** - Captured WHY
|
|
||||||
4. **Specs** - Defined WHAT in detail
|
|
||||||
5. **Design** - Decided HOW
|
|
||||||
6. **Tasks** - Broke it into steps
|
|
||||||
7. **Apply** - Implemented the work
|
|
||||||
8. **Archive** - Preserved the record
|
|
||||||
|
|
||||||
This same rhythm works for any size change—a small fix or a major feature.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Command Reference
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
|---------|--------------|
|
|
||||||
| `/opsx-explore` | Think through problems before/during work |
|
|
||||||
| `/opsx-new` | Start a new change, step through artifacts |
|
|
||||||
| `/opsx-ff` | Fast-forward: create all artifacts at once |
|
|
||||||
| `/opsx-continue` | Continue working on an existing change |
|
|
||||||
| `/opsx-apply` | Implement tasks from a change |
|
|
||||||
| `/opsx-verify` | Verify implementation matches artifacts |
|
|
||||||
| `/opsx-archive` | Archive a completed change |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What's Next?
|
|
||||||
|
|
||||||
Try `/opsx-new` or `/opsx-ff` on something you actually want to build. You've got the rhythm now!
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Graceful Exit Handling
|
|
||||||
|
|
||||||
### User wants to stop mid-way
|
|
||||||
|
|
||||||
If the user says they need to stop, want to pause, or seem disengaged:
|
|
||||||
|
|
||||||
```
|
|
||||||
No problem! Your change is saved at `openspec/changes/<name>/`.
|
|
||||||
|
|
||||||
To pick up where we left off later:
|
|
||||||
- `/opsx-continue <name>` - Resume artifact creation
|
|
||||||
- `/opsx-apply <name>` - Jump to implementation (if tasks exist)
|
|
||||||
|
|
||||||
The work won't be lost. Come back whenever you're ready.
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit gracefully without pressure.
|
|
||||||
|
|
||||||
### User just wants command reference
|
|
||||||
|
|
||||||
If the user says they just want to see the commands or skip the tutorial:
|
|
||||||
|
|
||||||
```
|
|
||||||
## OpenSpec Quick Reference
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
|---------|--------------|
|
|
||||||
| `/opsx-explore` | Think through problems (no code changes) |
|
|
||||||
| `/opsx-new <name>` | Start a new change, step by step |
|
|
||||||
| `/opsx-ff <name>` | Fast-forward: all artifacts at once |
|
|
||||||
| `/opsx-continue <name>` | Continue an existing change |
|
|
||||||
| `/opsx-apply <name>` | Implement tasks |
|
|
||||||
| `/opsx-verify <name>` | Verify implementation |
|
|
||||||
| `/opsx-archive <name>` | Archive when done |
|
|
||||||
|
|
||||||
Try `/opsx-new` to start your first change, or `/opsx-ff` if you want to move fast.
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit gracefully.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive)
|
|
||||||
- **Keep narration light** during implementation—teach without lecturing
|
|
||||||
- **Don't skip phases** even if the change is small—the goal is teaching the workflow
|
|
||||||
- **Pause for acknowledgment** at marked points, but don't over-pause
|
|
||||||
- **Handle exits gracefully**—never pressure the user to continue
|
|
||||||
- **Use real codebase tasks**—don't simulate or use fake examples
|
|
||||||
- **Adjust scope gently**—guide toward smaller tasks but respect user choice
|
|
||||||
@@ -1,10 +1,19 @@
|
|||||||
---
|
---
|
||||||
description: Create a change and generate all artifacts needed for implementation in one go
|
description: Propose a new change - create it and generate all artifacts in one step
|
||||||
---
|
---
|
||||||
|
|
||||||
Fast-forward through artifact creation - generate everything needed to start implementation.
|
Propose a new change - create the change and generate all artifacts in one step.
|
||||||
|
|
||||||
**Input**: The argument after `/opsx-ff` is the change name (kebab-case), OR a description of what the user wants to build.
|
I'll create a change with artifacts:
|
||||||
|
- proposal.md (what & why)
|
||||||
|
- design.md (how)
|
||||||
|
- tasks.md (implementation steps)
|
||||||
|
|
||||||
|
When ready to implement, run /opsx-apply
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**Input**: The argument after `/opsx-propose` is the change name (kebab-case), OR a description of what the user wants to build.
|
||||||
|
|
||||||
**Steps**
|
**Steps**
|
||||||
|
|
||||||
@@ -21,7 +30,7 @@ Fast-forward through artifact creation - generate everything needed to start imp
|
|||||||
```bash
|
```bash
|
||||||
openspec new change "<name>"
|
openspec new change "<name>"
|
||||||
```
|
```
|
||||||
This creates a scaffolded change at `openspec/changes/<name>/`.
|
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||||
|
|
||||||
3. **Get the artifact build order**
|
3. **Get the artifact build order**
|
||||||
```bash
|
```bash
|
||||||
@@ -52,7 +61,7 @@ Fast-forward through artifact creation - generate everything needed to start imp
|
|||||||
- Read any completed dependency files for context
|
- Read any completed dependency files for context
|
||||||
- Create the artifact file using `template` as the structure
|
- Create the artifact file using `template` as the structure
|
||||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||||
- Show brief progress: "✓ Created <artifact-id>"
|
- Show brief progress: "Created <artifact-id>"
|
||||||
|
|
||||||
b. **Continue until all `applyRequires` artifacts are complete**
|
b. **Continue until all `applyRequires` artifacts are complete**
|
||||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||||
@@ -81,7 +90,10 @@ After completing all artifacts, summarize:
|
|||||||
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
- Follow the `instruction` field from `openspec instructions` for each artifact type
|
||||||
- The schema defines what each artifact should contain - follow it
|
- The schema defines what each artifact should contain - follow it
|
||||||
- Read dependency artifacts for context before creating new ones
|
- Read dependency artifacts for context before creating new ones
|
||||||
- Use the `template` as a starting point, filling in based on context
|
- Use `template` as the structure for your output file - fill in its sections
|
||||||
|
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
||||||
|
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
||||||
|
- These guide what you write, but should never appear in the output
|
||||||
|
|
||||||
**Guardrails**
|
**Guardrails**
|
||||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
---
|
|
||||||
description: Sync delta specs from a change to main specs
|
|
||||||
---
|
|
||||||
|
|
||||||
Sync delta specs from a change to main specs.
|
|
||||||
|
|
||||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
|
||||||
|
|
||||||
**Input**: Optionally specify a change name after `/opsx-sync` (e.g., `/opsx-sync add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no change name provided, prompt for selection**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
|
||||||
|
|
||||||
Show changes that have delta specs (under `specs/` directory).
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
|
||||||
|
|
||||||
2. **Find delta specs**
|
|
||||||
|
|
||||||
Look for delta spec files in `openspec/changes/<name>/specs/*/spec.md`.
|
|
||||||
|
|
||||||
Each delta spec file contains sections like:
|
|
||||||
- `## ADDED Requirements` - New requirements to add
|
|
||||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
|
||||||
- `## REMOVED Requirements` - Requirements to remove
|
|
||||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
|
||||||
|
|
||||||
If no delta specs found, inform user and stop.
|
|
||||||
|
|
||||||
3. **For each delta spec, apply changes to main specs**
|
|
||||||
|
|
||||||
For each capability with a delta spec at `openspec/changes/<name>/specs/<capability>/spec.md`:
|
|
||||||
|
|
||||||
a. **Read the delta spec** to understand the intended changes
|
|
||||||
|
|
||||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
|
||||||
|
|
||||||
c. **Apply changes intelligently**:
|
|
||||||
|
|
||||||
**ADDED Requirements:**
|
|
||||||
- If requirement doesn't exist in main spec → add it
|
|
||||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
|
||||||
|
|
||||||
**MODIFIED Requirements:**
|
|
||||||
- Find the requirement in main spec
|
|
||||||
- Apply the changes - this can be:
|
|
||||||
- Adding new scenarios (don't need to copy existing ones)
|
|
||||||
- Modifying existing scenarios
|
|
||||||
- Changing the requirement description
|
|
||||||
- Preserve scenarios/content not mentioned in the delta
|
|
||||||
|
|
||||||
**REMOVED Requirements:**
|
|
||||||
- Remove the entire requirement block from main spec
|
|
||||||
|
|
||||||
**RENAMED Requirements:**
|
|
||||||
- Find the FROM requirement, rename to TO
|
|
||||||
|
|
||||||
d. **Create new main spec** if capability doesn't exist yet:
|
|
||||||
- Create `openspec/specs/<capability>/spec.md`
|
|
||||||
- Add Purpose section (can be brief, mark as TBD)
|
|
||||||
- Add Requirements section with the ADDED requirements
|
|
||||||
|
|
||||||
4. **Show summary**
|
|
||||||
|
|
||||||
After applying all changes, summarize:
|
|
||||||
- Which capabilities were updated
|
|
||||||
- What changes were made (requirements added/modified/removed/renamed)
|
|
||||||
|
|
||||||
**Delta Spec Format Reference**
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: New Feature
|
|
||||||
The system SHALL do something new.
|
|
||||||
|
|
||||||
#### Scenario: Basic case
|
|
||||||
- **WHEN** user does X
|
|
||||||
- **THEN** system does Y
|
|
||||||
|
|
||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: Existing Feature
|
|
||||||
#### Scenario: New scenario to add
|
|
||||||
- **WHEN** user does A
|
|
||||||
- **THEN** system does B
|
|
||||||
|
|
||||||
## REMOVED Requirements
|
|
||||||
|
|
||||||
### Requirement: Deprecated Feature
|
|
||||||
|
|
||||||
## RENAMED Requirements
|
|
||||||
|
|
||||||
- FROM: `### Requirement: Old Name`
|
|
||||||
- TO: `### Requirement: New Name`
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Principle: Intelligent Merging**
|
|
||||||
|
|
||||||
Unlike programmatic merging, you can apply **partial updates**:
|
|
||||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
|
||||||
- The delta represents *intent*, not a wholesale replacement
|
|
||||||
- Use your judgment to merge changes sensibly
|
|
||||||
|
|
||||||
**Output On Success**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Specs Synced: <change-name>
|
|
||||||
|
|
||||||
Updated main specs:
|
|
||||||
|
|
||||||
**<capability-1>**:
|
|
||||||
- Added requirement: "New Feature"
|
|
||||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
|
||||||
|
|
||||||
**<capability-2>**:
|
|
||||||
- Created new spec file
|
|
||||||
- Added requirement: "Another Feature"
|
|
||||||
|
|
||||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Read both delta and main specs before making changes
|
|
||||||
- Preserve existing content not mentioned in delta
|
|
||||||
- If something is unclear, ask for clarification
|
|
||||||
- Show what you're changing as you go
|
|
||||||
- The operation should be idempotent - running twice should give same result
|
|
||||||
@@ -1,161 +0,0 @@
|
|||||||
---
|
|
||||||
description: Verify implementation matches change artifacts before archiving
|
|
||||||
---
|
|
||||||
|
|
||||||
Verify that an implementation matches the change artifacts (specs, tasks, design).
|
|
||||||
|
|
||||||
**Input**: Optionally specify a change name after `/opsx-verify` (e.g., `/opsx-verify add-auth`). If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no change name provided, prompt for selection**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
|
||||||
|
|
||||||
Show changes that have implementation tasks (tasks artifact exists).
|
|
||||||
Include the schema used for each change if available.
|
|
||||||
Mark changes with incomplete tasks as "(In Progress)".
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
|
||||||
|
|
||||||
2. **Check status to understand the schema**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>" --json
|
|
||||||
```
|
|
||||||
Parse the JSON to understand:
|
|
||||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
|
||||||
- Which artifacts exist for this change
|
|
||||||
|
|
||||||
3. **Get the change directory and load artifacts**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openspec instructions apply --change "<name>" --json
|
|
||||||
```
|
|
||||||
|
|
||||||
This returns the change directory and context files. Read all available artifacts from `contextFiles`.
|
|
||||||
|
|
||||||
4. **Initialize verification report structure**
|
|
||||||
|
|
||||||
Create a report structure with three dimensions:
|
|
||||||
- **Completeness**: Track tasks and spec coverage
|
|
||||||
- **Correctness**: Track requirement implementation and scenario coverage
|
|
||||||
- **Coherence**: Track design adherence and pattern consistency
|
|
||||||
|
|
||||||
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
|
|
||||||
|
|
||||||
5. **Verify Completeness**
|
|
||||||
|
|
||||||
**Task Completion**:
|
|
||||||
- If tasks.md exists in contextFiles, read it
|
|
||||||
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
|
|
||||||
- Count complete vs total tasks
|
|
||||||
- If incomplete tasks exist:
|
|
||||||
- Add CRITICAL issue for each incomplete task
|
|
||||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
|
||||||
|
|
||||||
**Spec Coverage**:
|
|
||||||
- If delta specs exist in `openspec/changes/<name>/specs/`:
|
|
||||||
- Extract all requirements (marked with "### Requirement:")
|
|
||||||
- For each requirement:
|
|
||||||
- Search codebase for keywords related to the requirement
|
|
||||||
- Assess if implementation likely exists
|
|
||||||
- If requirements appear unimplemented:
|
|
||||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
|
||||||
- Recommendation: "Implement requirement X: <description>"
|
|
||||||
|
|
||||||
6. **Verify Correctness**
|
|
||||||
|
|
||||||
**Requirement Implementation Mapping**:
|
|
||||||
- For each requirement from delta specs:
|
|
||||||
- Search codebase for implementation evidence
|
|
||||||
- If found, note file paths and line ranges
|
|
||||||
- Assess if implementation matches requirement intent
|
|
||||||
- If divergence detected:
|
|
||||||
- Add WARNING: "Implementation may diverge from spec: <details>"
|
|
||||||
- Recommendation: "Review <file>:<lines> against requirement X"
|
|
||||||
|
|
||||||
**Scenario Coverage**:
|
|
||||||
- For each scenario in delta specs (marked with "#### Scenario:"):
|
|
||||||
- Check if conditions are handled in code
|
|
||||||
- Check if tests exist covering the scenario
|
|
||||||
- If scenario appears uncovered:
|
|
||||||
- Add WARNING: "Scenario not covered: <scenario name>"
|
|
||||||
- Recommendation: "Add test or implementation for scenario: <description>"
|
|
||||||
|
|
||||||
7. **Verify Coherence**
|
|
||||||
|
|
||||||
**Design Adherence**:
|
|
||||||
- If design.md exists in contextFiles:
|
|
||||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
|
||||||
- Verify implementation follows those decisions
|
|
||||||
- If contradiction detected:
|
|
||||||
- Add WARNING: "Design decision not followed: <decision>"
|
|
||||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
|
||||||
- If no design.md: Skip design adherence check, note "No design.md to verify against"
|
|
||||||
|
|
||||||
**Code Pattern Consistency**:
|
|
||||||
- Review new code for consistency with project patterns
|
|
||||||
- Check file naming, directory structure, coding style
|
|
||||||
- If significant deviations found:
|
|
||||||
- Add SUGGESTION: "Code pattern deviation: <details>"
|
|
||||||
- Recommendation: "Consider following project pattern: <example>"
|
|
||||||
|
|
||||||
8. **Generate Verification Report**
|
|
||||||
|
|
||||||
**Summary Scorecard**:
|
|
||||||
```
|
|
||||||
## Verification Report: <change-name>
|
|
||||||
|
|
||||||
### Summary
|
|
||||||
| Dimension | Status |
|
|
||||||
|--------------|------------------|
|
|
||||||
| Completeness | X/Y tasks, N reqs|
|
|
||||||
| Correctness | M/N reqs covered |
|
|
||||||
| Coherence | Followed/Issues |
|
|
||||||
```
|
|
||||||
|
|
||||||
**Issues by Priority**:
|
|
||||||
|
|
||||||
1. **CRITICAL** (Must fix before archive):
|
|
||||||
- Incomplete tasks
|
|
||||||
- Missing requirement implementations
|
|
||||||
- Each with specific, actionable recommendation
|
|
||||||
|
|
||||||
2. **WARNING** (Should fix):
|
|
||||||
- Spec/design divergences
|
|
||||||
- Missing scenario coverage
|
|
||||||
- Each with specific recommendation
|
|
||||||
|
|
||||||
3. **SUGGESTION** (Nice to fix):
|
|
||||||
- Pattern inconsistencies
|
|
||||||
- Minor improvements
|
|
||||||
- Each with specific recommendation
|
|
||||||
|
|
||||||
**Final Assessment**:
|
|
||||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
|
|
||||||
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
|
||||||
- If all clear: "All checks passed. Ready for archive."
|
|
||||||
|
|
||||||
**Verification Heuristics**
|
|
||||||
|
|
||||||
- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)
|
|
||||||
- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty
|
|
||||||
- **Coherence**: Look for glaring inconsistencies, don't nitpick style
|
|
||||||
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
|
|
||||||
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
|
|
||||||
|
|
||||||
**Graceful Degradation**
|
|
||||||
|
|
||||||
- If only tasks.md exists: verify task completion only, skip spec/design checks
|
|
||||||
- If tasks + specs exist: verify completeness and correctness, skip design
|
|
||||||
- If full artifacts: verify all three dimensions
|
|
||||||
- Always note which checks were skipped and why
|
|
||||||
|
|
||||||
**Output Format**
|
|
||||||
|
|
||||||
Use clear markdown with:
|
|
||||||
- Table for summary scorecard
|
|
||||||
- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)
|
|
||||||
- Code references in format: `file.ts:123`
|
|
||||||
- Specific, actionable recommendations
|
|
||||||
- No vague suggestions like "consider reviewing"
|
|
||||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
|||||||
metadata:
|
metadata:
|
||||||
author: openspec
|
author: openspec
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
generatedBy: "1.1.1"
|
generatedBy: "1.2.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
Implement tasks from an OpenSpec change.
|
Implement tasks from an OpenSpec change.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ compatibility: Requires openspec CLI.
|
|||||||
metadata:
|
metadata:
|
||||||
author: openspec
|
author: openspec
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
generatedBy: "1.1.1"
|
generatedBy: "1.2.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
Archive a completed change in the experimental workflow.
|
Archive a completed change in the experimental workflow.
|
||||||
@@ -63,7 +63,7 @@ Archive a completed change in the experimental workflow.
|
|||||||
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
- If changes needed: "Sync now (recommended)", "Archive without syncing"
|
||||||
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
- If already synced: "Archive now", "Sync anyway", "Cancel"
|
||||||
|
|
||||||
If user chooses sync, execute /opsx-sync logic (use the openspec-sync-specs skill). Proceed to archive regardless of choice.
|
If user chooses sync, use Task tool (subagent_type: "general-purpose", prompt: "Use Skill tool to invoke openspec-sync-specs for change '<name>'. Delta spec analysis: <include the analyzed delta spec summary>"). Proceed to archive regardless of choice.
|
||||||
|
|
||||||
5. **Perform the archive**
|
5. **Perform the archive**
|
||||||
|
|
||||||
|
|||||||
@@ -1,246 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-bulk-archive-change
|
|
||||||
description: Archive multiple completed changes at once. Use when archiving several parallel changes.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Archive multiple completed changes in a single operation.
|
|
||||||
|
|
||||||
This skill allows you to batch-archive changes, handling spec conflicts intelligently by checking the codebase to determine what's actually implemented.
|
|
||||||
|
|
||||||
**Input**: None required (prompts for selection)
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **Get active changes**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get all active changes.
|
|
||||||
|
|
||||||
If no active changes exist, inform user and stop.
|
|
||||||
|
|
||||||
2. **Prompt for change selection**
|
|
||||||
|
|
||||||
Use **AskUserQuestion tool** with multi-select to let user choose changes:
|
|
||||||
- Show each change with its schema
|
|
||||||
- Include an option for "All changes"
|
|
||||||
- Allow any number of selections (1+ works, 2+ is the typical use case)
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT auto-select. Always let the user choose.
|
|
||||||
|
|
||||||
3. **Batch validation - gather status for all selected changes**
|
|
||||||
|
|
||||||
For each selected change, collect:
|
|
||||||
|
|
||||||
a. **Artifact status** - Run `openspec status --change "<name>" --json`
|
|
||||||
- Parse `schemaName` and `artifacts` list
|
|
||||||
- Note which artifacts are `done` vs other states
|
|
||||||
|
|
||||||
b. **Task completion** - Read `openspec/changes/<name>/tasks.md`
|
|
||||||
- Count `- [ ]` (incomplete) vs `- [x]` (complete)
|
|
||||||
- If no tasks file exists, note as "No tasks"
|
|
||||||
|
|
||||||
c. **Delta specs** - Check `openspec/changes/<name>/specs/` directory
|
|
||||||
- List which capability specs exist
|
|
||||||
- For each, extract requirement names (lines matching `### Requirement: <name>`)
|
|
||||||
|
|
||||||
4. **Detect spec conflicts**
|
|
||||||
|
|
||||||
Build a map of `capability -> [changes that touch it]`:
|
|
||||||
|
|
||||||
```
|
|
||||||
auth -> [change-a, change-b] <- CONFLICT (2+ changes)
|
|
||||||
api -> [change-c] <- OK (only 1 change)
|
|
||||||
```
|
|
||||||
|
|
||||||
A conflict exists when 2+ selected changes have delta specs for the same capability.
|
|
||||||
|
|
||||||
5. **Resolve conflicts agentically**
|
|
||||||
|
|
||||||
**For each conflict**, investigate the codebase:
|
|
||||||
|
|
||||||
a. **Read the delta specs** from each conflicting change to understand what each claims to add/modify
|
|
||||||
|
|
||||||
b. **Search the codebase** for implementation evidence:
|
|
||||||
- Look for code implementing requirements from each delta spec
|
|
||||||
- Check for related files, functions, or tests
|
|
||||||
|
|
||||||
c. **Determine resolution**:
|
|
||||||
- If only one change is actually implemented -> sync that one's specs
|
|
||||||
- If both implemented -> apply in chronological order (older first, newer overwrites)
|
|
||||||
- If neither implemented -> skip spec sync, warn user
|
|
||||||
|
|
||||||
d. **Record resolution** for each conflict:
|
|
||||||
- Which change's specs to apply
|
|
||||||
- In what order (if both)
|
|
||||||
- Rationale (what was found in codebase)
|
|
||||||
|
|
||||||
6. **Show consolidated status table**
|
|
||||||
|
|
||||||
Display a table summarizing all changes:
|
|
||||||
|
|
||||||
```
|
|
||||||
| Change | Artifacts | Tasks | Specs | Conflicts | Status |
|
|
||||||
|---------------------|-----------|-------|---------|-----------|--------|
|
|
||||||
| schema-management | Done | 5/5 | 2 delta | None | Ready |
|
|
||||||
| project-config | Done | 3/3 | 1 delta | None | Ready |
|
|
||||||
| add-oauth | Done | 4/4 | 1 delta | auth (!) | Ready* |
|
|
||||||
| add-verify-skill | 1 left | 2/5 | None | None | Warn |
|
|
||||||
```
|
|
||||||
|
|
||||||
For conflicts, show the resolution:
|
|
||||||
```
|
|
||||||
* Conflict resolution:
|
|
||||||
- auth spec: Will apply add-oauth then add-jwt (both implemented, chronological order)
|
|
||||||
```
|
|
||||||
|
|
||||||
For incomplete changes, show warnings:
|
|
||||||
```
|
|
||||||
Warnings:
|
|
||||||
- add-verify-skill: 1 incomplete artifact, 3 incomplete tasks
|
|
||||||
```
|
|
||||||
|
|
||||||
7. **Confirm batch operation**
|
|
||||||
|
|
||||||
Use **AskUserQuestion tool** with a single confirmation:
|
|
||||||
|
|
||||||
- "Archive N changes?" with options based on status
|
|
||||||
- Options might include:
|
|
||||||
- "Archive all N changes"
|
|
||||||
- "Archive only N ready changes (skip incomplete)"
|
|
||||||
- "Cancel"
|
|
||||||
|
|
||||||
If there are incomplete changes, make clear they'll be archived with warnings.
|
|
||||||
|
|
||||||
8. **Execute archive for each confirmed change**
|
|
||||||
|
|
||||||
Process changes in the determined order (respecting conflict resolution):
|
|
||||||
|
|
||||||
a. **Sync specs** if delta specs exist:
|
|
||||||
- Use the openspec-sync-specs approach (agent-driven intelligent merge)
|
|
||||||
- For conflicts, apply in resolved order
|
|
||||||
- Track if sync was done
|
|
||||||
|
|
||||||
b. **Perform the archive**:
|
|
||||||
```bash
|
|
||||||
mkdir -p openspec/changes/archive
|
|
||||||
mv openspec/changes/<name> openspec/changes/archive/YYYY-MM-DD-<name>
|
|
||||||
```
|
|
||||||
|
|
||||||
c. **Track outcome** for each change:
|
|
||||||
- Success: archived successfully
|
|
||||||
- Failed: error during archive (record error)
|
|
||||||
- Skipped: user chose not to archive (if applicable)
|
|
||||||
|
|
||||||
9. **Display summary**
|
|
||||||
|
|
||||||
Show final results:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Bulk Archive Complete
|
|
||||||
|
|
||||||
Archived 3 changes:
|
|
||||||
- schema-management-cli -> archive/2026-01-19-schema-management-cli/
|
|
||||||
- project-config -> archive/2026-01-19-project-config/
|
|
||||||
- add-oauth -> archive/2026-01-19-add-oauth/
|
|
||||||
|
|
||||||
Skipped 1 change:
|
|
||||||
- add-verify-skill (user chose not to archive incomplete)
|
|
||||||
|
|
||||||
Spec sync summary:
|
|
||||||
- 4 delta specs synced to main specs
|
|
||||||
- 1 conflict resolved (auth: applied both in chronological order)
|
|
||||||
```
|
|
||||||
|
|
||||||
If any failures:
|
|
||||||
```
|
|
||||||
Failed 1 change:
|
|
||||||
- some-change: Archive directory already exists
|
|
||||||
```
|
|
||||||
|
|
||||||
**Conflict Resolution Examples**
|
|
||||||
|
|
||||||
Example 1: Only one implemented
|
|
||||||
```
|
|
||||||
Conflict: specs/auth/spec.md touched by [add-oauth, add-jwt]
|
|
||||||
|
|
||||||
Checking add-oauth:
|
|
||||||
- Delta adds "OAuth Provider Integration" requirement
|
|
||||||
- Searching codebase... found src/auth/oauth.ts implementing OAuth flow
|
|
||||||
|
|
||||||
Checking add-jwt:
|
|
||||||
- Delta adds "JWT Token Handling" requirement
|
|
||||||
- Searching codebase... no JWT implementation found
|
|
||||||
|
|
||||||
Resolution: Only add-oauth is implemented. Will sync add-oauth specs only.
|
|
||||||
```
|
|
||||||
|
|
||||||
Example 2: Both implemented
|
|
||||||
```
|
|
||||||
Conflict: specs/api/spec.md touched by [add-rest-api, add-graphql]
|
|
||||||
|
|
||||||
Checking add-rest-api (created 2026-01-10):
|
|
||||||
- Delta adds "REST Endpoints" requirement
|
|
||||||
- Searching codebase... found src/api/rest.ts
|
|
||||||
|
|
||||||
Checking add-graphql (created 2026-01-15):
|
|
||||||
- Delta adds "GraphQL Schema" requirement
|
|
||||||
- Searching codebase... found src/api/graphql.ts
|
|
||||||
|
|
||||||
Resolution: Both implemented. Will apply add-rest-api specs first,
|
|
||||||
then add-graphql specs (chronological order, newer takes precedence).
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output On Success**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Bulk Archive Complete
|
|
||||||
|
|
||||||
Archived N changes:
|
|
||||||
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
|
|
||||||
- <change-2> -> archive/YYYY-MM-DD-<change-2>/
|
|
||||||
|
|
||||||
Spec sync summary:
|
|
||||||
- N delta specs synced to main specs
|
|
||||||
- No conflicts (or: M conflicts resolved)
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output On Partial Success**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Bulk Archive Complete (partial)
|
|
||||||
|
|
||||||
Archived N changes:
|
|
||||||
- <change-1> -> archive/YYYY-MM-DD-<change-1>/
|
|
||||||
|
|
||||||
Skipped M changes:
|
|
||||||
- <change-2> (user chose not to archive incomplete)
|
|
||||||
|
|
||||||
Failed K changes:
|
|
||||||
- <change-3>: Archive directory already exists
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output When No Changes**
|
|
||||||
|
|
||||||
```
|
|
||||||
## No Changes to Archive
|
|
||||||
|
|
||||||
No active changes found. Use `/opsx-new` to create a new change.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Allow any number of changes (1+ is fine, 2+ is the typical use case)
|
|
||||||
- Always prompt for selection, never auto-select
|
|
||||||
- Detect spec conflicts early and resolve by checking codebase
|
|
||||||
- When both changes are implemented, apply specs in chronological order
|
|
||||||
- Skip spec sync only when implementation is missing (warn user)
|
|
||||||
- Show clear per-change status before confirming
|
|
||||||
- Use single confirmation for entire batch
|
|
||||||
- Track and report all outcomes (success/skip/fail)
|
|
||||||
- Preserve .openspec.yaml when moving to archive
|
|
||||||
- Archive directory target uses current date: YYYY-MM-DD-<name>
|
|
||||||
- If archive target exists, fail that change but continue with others
|
|
||||||
@@ -1,118 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-continue-change
|
|
||||||
description: Continue working on an OpenSpec change by creating the next artifact. Use when the user wants to progress their change, create the next artifact, or continue their workflow.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Continue working on a change by creating the next artifact.
|
|
||||||
|
|
||||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no change name provided, prompt for selection**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get available changes sorted by most recently modified. Then use the **AskUserQuestion tool** to let the user select which change to work on.
|
|
||||||
|
|
||||||
Present the top 3-4 most recently modified changes as options, showing:
|
|
||||||
- Change name
|
|
||||||
- Schema (from `schema` field if present, otherwise "spec-driven")
|
|
||||||
- Status (e.g., "0/5 tasks", "complete", "no tasks")
|
|
||||||
- How recently it was modified (from `lastModified` field)
|
|
||||||
|
|
||||||
Mark the most recently modified change as "(Recommended)" since it's likely what the user wants to continue.
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
|
||||||
|
|
||||||
2. **Check current status**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>" --json
|
|
||||||
```
|
|
||||||
Parse the JSON to understand current state. The response includes:
|
|
||||||
- `schemaName`: The workflow schema being used (e.g., "spec-driven")
|
|
||||||
- `artifacts`: Array of artifacts with their status ("done", "ready", "blocked")
|
|
||||||
- `isComplete`: Boolean indicating if all artifacts are complete
|
|
||||||
|
|
||||||
3. **Act based on status**:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If all artifacts are complete (`isComplete: true`)**:
|
|
||||||
- Congratulate the user
|
|
||||||
- Show final status including the schema used
|
|
||||||
- Suggest: "All artifacts created! You can now implement this change or archive it."
|
|
||||||
- STOP
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If artifacts are ready to create** (status shows artifacts with `status: "ready"`):
|
|
||||||
- Pick the FIRST artifact with `status: "ready"` from the status output
|
|
||||||
- Get its instructions:
|
|
||||||
```bash
|
|
||||||
openspec instructions <artifact-id> --change "<name>" --json
|
|
||||||
```
|
|
||||||
- Parse the JSON. The key fields are:
|
|
||||||
- `context`: Project background (constraints for you - do NOT include in output)
|
|
||||||
- `rules`: Artifact-specific rules (constraints for you - do NOT include in output)
|
|
||||||
- `template`: The structure to use for your output file
|
|
||||||
- `instruction`: Schema-specific guidance
|
|
||||||
- `outputPath`: Where to write the artifact
|
|
||||||
- `dependencies`: Completed artifacts to read for context
|
|
||||||
- **Create the artifact file**:
|
|
||||||
- Read any completed dependency files for context
|
|
||||||
- Use `template` as the structure - fill in its sections
|
|
||||||
- Apply `context` and `rules` as constraints when writing - but do NOT copy them into the file
|
|
||||||
- Write to the output path specified in instructions
|
|
||||||
- Show what was created and what's now unlocked
|
|
||||||
- STOP after creating ONE artifact
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
**If no artifacts are ready (all blocked)**:
|
|
||||||
- This shouldn't happen with a valid schema
|
|
||||||
- Show status and suggest checking for issues
|
|
||||||
|
|
||||||
4. **After creating an artifact, show progress**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**Output**
|
|
||||||
|
|
||||||
After each invocation, show:
|
|
||||||
- Which artifact was created
|
|
||||||
- Schema workflow being used
|
|
||||||
- Current progress (N/M complete)
|
|
||||||
- What artifacts are now unlocked
|
|
||||||
- Prompt: "Want to continue? Just ask me to continue or tell me what to do next."
|
|
||||||
|
|
||||||
**Artifact Creation Guidelines**
|
|
||||||
|
|
||||||
The artifact types and their purpose depend on the schema. Use the `instruction` field from the instructions output to understand what to create.
|
|
||||||
|
|
||||||
Common artifact patterns:
|
|
||||||
|
|
||||||
**spec-driven schema** (proposal → specs → design → tasks):
|
|
||||||
- **proposal.md**: Ask user about the change if not clear. Fill in Why, What Changes, Capabilities, Impact.
|
|
||||||
- The Capabilities section is critical - each capability listed will need a spec file.
|
|
||||||
- **specs/<capability>/spec.md**: Create one spec per capability listed in the proposal's Capabilities section (use the capability name, not the change name).
|
|
||||||
- **design.md**: Document technical decisions, architecture, and implementation approach.
|
|
||||||
- **tasks.md**: Break down implementation into checkboxed tasks.
|
|
||||||
|
|
||||||
For other schemas, follow the `instruction` field from the CLI output.
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Create ONE artifact per invocation
|
|
||||||
- Always read dependency artifacts before creating a new one
|
|
||||||
- Never skip artifacts or create out of order
|
|
||||||
- If context is unclear, ask the user before creating
|
|
||||||
- Verify the artifact file exists after writing before marking progress
|
|
||||||
- Use the schema's artifact sequence, don't assume specific artifact names
|
|
||||||
- **IMPORTANT**: `context` and `rules` are constraints for YOU, not content for the file
|
|
||||||
- Do NOT copy `<context>`, `<rules>`, `<project_context>` blocks into the artifact
|
|
||||||
- These guide what you write, but should never appear in the output
|
|
||||||
@@ -6,12 +6,12 @@ compatibility: Requires openspec CLI.
|
|||||||
metadata:
|
metadata:
|
||||||
author: openspec
|
author: openspec
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
generatedBy: "1.1.1"
|
generatedBy: "1.2.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
Enter explore mode. Think deeply. Visualize freely. Follow the conversation wherever it goes.
|
||||||
|
|
||||||
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first (e.g., start a change with `/opsx-new` or `/opsx-ff`). You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
**IMPORTANT: Explore mode is for thinking, not implementing.** You may read files, search code, and investigate the codebase, but you must NEVER write code or implement features. If the user asks you to implement something, remind them to exit explore mode first and create a change proposal. You MAY create OpenSpec artifacts (proposals, designs, specs) if the user asks—that's capturing thinking, not implementing.
|
||||||
|
|
||||||
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
**This is a stance, not a workflow.** There are no fixed steps, no required sequence, no mandatory outputs. You're a thinking partner helping the user explore.
|
||||||
|
|
||||||
@@ -95,8 +95,7 @@ This tells you:
|
|||||||
|
|
||||||
Think freely. When insights crystallize, you might offer:
|
Think freely. When insights crystallize, you might offer:
|
||||||
|
|
||||||
- "This feels solid enough to start a change. Want me to create one?"
|
- "This feels solid enough to start a change. Want me to create a proposal?"
|
||||||
→ Can transition to `/opsx-new` or `/opsx-ff`
|
|
||||||
- Or keep exploring - no pressure to formalize
|
- Or keep exploring - no pressure to formalize
|
||||||
|
|
||||||
### When a change exists
|
### When a change exists
|
||||||
@@ -252,7 +251,7 @@ You: That changes everything.
|
|||||||
|
|
||||||
There's no required ending. Discovery might:
|
There's no required ending. Discovery might:
|
||||||
|
|
||||||
- **Flow into action**: "Ready to start? /opsx-new or /opsx-ff"
|
- **Flow into a proposal**: "Ready to start? I can create a change proposal."
|
||||||
- **Result in artifact updates**: "Updated design.md with these decisions"
|
- **Result in artifact updates**: "Updated design.md with these decisions"
|
||||||
- **Just provide clarity**: User has what they need, moves on
|
- **Just provide clarity**: User has what they need, moves on
|
||||||
- **Continue later**: "We can pick this up anytime"
|
- **Continue later**: "We can pick this up anytime"
|
||||||
@@ -269,8 +268,7 @@ When it feels like things are crystallizing, you might summarize:
|
|||||||
**Open questions**: [if any remain]
|
**Open questions**: [if any remain]
|
||||||
|
|
||||||
**Next steps** (if ready):
|
**Next steps** (if ready):
|
||||||
- Create a change: /opsx-new <name>
|
- Create a change proposal
|
||||||
- Fast-forward to tasks: /opsx-ff <name>
|
|
||||||
- Keep exploring: just keep talking
|
- Keep exploring: just keep talking
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,74 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-new-change
|
|
||||||
description: Start a new OpenSpec change using the experimental artifact workflow. Use when the user wants to create a new feature, fix, or modification with a structured step-by-step approach.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Start a new change using the experimental artifact-driven approach.
|
|
||||||
|
|
||||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no clear input provided, ask what they want to build**
|
|
||||||
|
|
||||||
Use the **AskUserQuestion tool** (open-ended, no preset options) to ask:
|
|
||||||
> "What change do you want to work on? Describe what you want to build or fix."
|
|
||||||
|
|
||||||
From their description, derive a kebab-case name (e.g., "add user authentication" → `add-user-auth`).
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT proceed without understanding what the user wants to build.
|
|
||||||
|
|
||||||
2. **Determine the workflow schema**
|
|
||||||
|
|
||||||
Use the default schema (omit `--schema`) unless the user explicitly requests a different workflow.
|
|
||||||
|
|
||||||
**Use a different schema only if the user mentions:**
|
|
||||||
- A specific schema name → use `--schema <name>`
|
|
||||||
- "show workflows" or "what workflows" → run `openspec schemas --json` and let them choose
|
|
||||||
|
|
||||||
**Otherwise**: Omit `--schema` to use the default.
|
|
||||||
|
|
||||||
3. **Create the change directory**
|
|
||||||
```bash
|
|
||||||
openspec new change "<name>"
|
|
||||||
```
|
|
||||||
Add `--schema <name>` only if the user requested a specific workflow.
|
|
||||||
This creates a scaffolded change at `openspec/changes/<name>/` with the selected schema.
|
|
||||||
|
|
||||||
4. **Show the artifact status**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>"
|
|
||||||
```
|
|
||||||
This shows which artifacts need to be created and which are ready (dependencies satisfied).
|
|
||||||
|
|
||||||
5. **Get instructions for the first artifact**
|
|
||||||
The first artifact depends on the schema (e.g., `proposal` for spec-driven).
|
|
||||||
Check the status output to find the first artifact with status "ready".
|
|
||||||
```bash
|
|
||||||
openspec instructions <first-artifact-id> --change "<name>"
|
|
||||||
```
|
|
||||||
This outputs the template and context for creating the first artifact.
|
|
||||||
|
|
||||||
6. **STOP and wait for user direction**
|
|
||||||
|
|
||||||
**Output**
|
|
||||||
|
|
||||||
After completing the steps, summarize:
|
|
||||||
- Change name and location
|
|
||||||
- Schema/workflow being used and its artifact sequence
|
|
||||||
- Current status (0/N artifacts complete)
|
|
||||||
- The template for the first artifact
|
|
||||||
- Prompt: "Ready to create the first artifact? Just describe what this change is about and I'll draft it, or ask me to continue."
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Do NOT create any artifacts yet - just show the instructions
|
|
||||||
- Do NOT advance beyond showing the first artifact template
|
|
||||||
- If the name is invalid (not kebab-case), ask for a valid name
|
|
||||||
- If a change with that name already exists, suggest continuing that change instead
|
|
||||||
- Pass --schema if using a non-default workflow
|
|
||||||
@@ -1,529 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-onboard
|
|
||||||
description: Guided onboarding for OpenSpec - walk through a complete workflow cycle with narration and real codebase work.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Guide the user through their first complete OpenSpec workflow cycle. This is a teaching experience—you'll do real work in their codebase while explaining each step.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Preflight
|
|
||||||
|
|
||||||
Before starting, check if OpenSpec is initialized:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openspec status --json 2>&1 || echo "NOT_INITIALIZED"
|
|
||||||
```
|
|
||||||
|
|
||||||
**If not initialized:**
|
|
||||||
> OpenSpec isn't set up in this project yet. Run `openspec init` first, then come back to `/opsx-onboard`.
|
|
||||||
|
|
||||||
Stop here if not initialized.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 1: Welcome
|
|
||||||
|
|
||||||
Display:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Welcome to OpenSpec!
|
|
||||||
|
|
||||||
I'll walk you through a complete change cycle—from idea to implementation—using a real task in your codebase. Along the way, you'll learn the workflow by doing it.
|
|
||||||
|
|
||||||
**What we'll do:**
|
|
||||||
1. Pick a small, real task in your codebase
|
|
||||||
2. Explore the problem briefly
|
|
||||||
3. Create a change (the container for our work)
|
|
||||||
4. Build the artifacts: proposal → specs → design → tasks
|
|
||||||
5. Implement the tasks
|
|
||||||
6. Archive the completed change
|
|
||||||
|
|
||||||
**Time:** ~15-20 minutes
|
|
||||||
|
|
||||||
Let's start by finding something to work on.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 2: Task Selection
|
|
||||||
|
|
||||||
### Codebase Analysis
|
|
||||||
|
|
||||||
Scan the codebase for small improvement opportunities. Look for:
|
|
||||||
|
|
||||||
1. **TODO/FIXME comments** - Search for `TODO`, `FIXME`, `HACK`, `XXX` in code files
|
|
||||||
2. **Missing error handling** - `catch` blocks that swallow errors, risky operations without try-catch
|
|
||||||
3. **Functions without tests** - Cross-reference `src/` with test directories
|
|
||||||
4. **Type issues** - `any` types in TypeScript files (`: any`, `as any`)
|
|
||||||
5. **Debug artifacts** - `console.log`, `console.debug`, `debugger` statements in non-debug code
|
|
||||||
6. **Missing validation** - User input handlers without validation
|
|
||||||
|
|
||||||
Also check recent git activity:
|
|
||||||
```bash
|
|
||||||
git log --oneline -10 2>/dev/null || echo "No git history"
|
|
||||||
```
|
|
||||||
|
|
||||||
### Present Suggestions
|
|
||||||
|
|
||||||
From your analysis, present 3-4 specific suggestions:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Task Suggestions
|
|
||||||
|
|
||||||
Based on scanning your codebase, here are some good starter tasks:
|
|
||||||
|
|
||||||
**1. [Most promising task]**
|
|
||||||
Location: `src/path/to/file.ts:42`
|
|
||||||
Scope: ~1-2 files, ~20-30 lines
|
|
||||||
Why it's good: [brief reason]
|
|
||||||
|
|
||||||
**2. [Second task]**
|
|
||||||
Location: `src/another/file.ts`
|
|
||||||
Scope: ~1 file, ~15 lines
|
|
||||||
Why it's good: [brief reason]
|
|
||||||
|
|
||||||
**3. [Third task]**
|
|
||||||
Location: [location]
|
|
||||||
Scope: [estimate]
|
|
||||||
Why it's good: [brief reason]
|
|
||||||
|
|
||||||
**4. Something else?**
|
|
||||||
Tell me what you'd like to work on.
|
|
||||||
|
|
||||||
Which task interests you? (Pick a number or describe your own)
|
|
||||||
```
|
|
||||||
|
|
||||||
**If nothing found:** Fall back to asking what the user wants to build:
|
|
||||||
> I didn't find obvious quick wins in your codebase. What's something small you've been meaning to add or fix?
|
|
||||||
|
|
||||||
### Scope Guardrail
|
|
||||||
|
|
||||||
If the user picks or describes something too large (major feature, multi-day work):
|
|
||||||
|
|
||||||
```
|
|
||||||
That's a valuable task, but it's probably larger than ideal for your first OpenSpec run-through.
|
|
||||||
|
|
||||||
For learning the workflow, smaller is better—it lets you see the full cycle without getting stuck in implementation details.
|
|
||||||
|
|
||||||
**Options:**
|
|
||||||
1. **Slice it smaller** - What's the smallest useful piece of [their task]? Maybe just [specific slice]?
|
|
||||||
2. **Pick something else** - One of the other suggestions, or a different small task?
|
|
||||||
3. **Do it anyway** - If you really want to tackle this, we can. Just know it'll take longer.
|
|
||||||
|
|
||||||
What would you prefer?
|
|
||||||
```
|
|
||||||
|
|
||||||
Let the user override if they insist—this is a soft guardrail.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 3: Explore Demo
|
|
||||||
|
|
||||||
Once a task is selected, briefly demonstrate explore mode:
|
|
||||||
|
|
||||||
```
|
|
||||||
Before we create a change, let me quickly show you **explore mode**—it's how you think through problems before committing to a direction.
|
|
||||||
```
|
|
||||||
|
|
||||||
Spend 1-2 minutes investigating the relevant code:
|
|
||||||
- Read the file(s) involved
|
|
||||||
- Draw a quick ASCII diagram if it helps
|
|
||||||
- Note any considerations
|
|
||||||
|
|
||||||
```
|
|
||||||
## Quick Exploration
|
|
||||||
|
|
||||||
[Your brief analysis—what you found, any considerations]
|
|
||||||
|
|
||||||
┌─────────────────────────────────────────┐
|
|
||||||
│ [Optional: ASCII diagram if helpful] │
|
|
||||||
└─────────────────────────────────────────┘
|
|
||||||
|
|
||||||
Explore mode (`/opsx-explore`) is for this kind of thinking—investigating before implementing. You can use it anytime you need to think through a problem.
|
|
||||||
|
|
||||||
Now let's create a change to hold our work.
|
|
||||||
```
|
|
||||||
|
|
||||||
**PAUSE** - Wait for user acknowledgment before proceeding.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 4: Create the Change
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Creating a Change
|
|
||||||
|
|
||||||
A "change" in OpenSpec is a container for all the thinking and planning around a piece of work. It lives in `openspec/changes/<name>/` and holds your artifacts—proposal, specs, design, tasks.
|
|
||||||
|
|
||||||
Let me create one for our task.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Create the change with a derived kebab-case name:
|
|
||||||
```bash
|
|
||||||
openspec new change "<derived-name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**SHOW:**
|
|
||||||
```
|
|
||||||
Created: `openspec/changes/<name>/`
|
|
||||||
|
|
||||||
The folder structure:
|
|
||||||
```
|
|
||||||
openspec/changes/<name>/
|
|
||||||
├── proposal.md ← Why we're doing this (empty, we'll fill it)
|
|
||||||
├── design.md ← How we'll build it (empty)
|
|
||||||
├── specs/ ← Detailed requirements (empty)
|
|
||||||
└── tasks.md ← Implementation checklist (empty)
|
|
||||||
```
|
|
||||||
|
|
||||||
Now let's fill in the first artifact—the proposal.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 5: Proposal
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## The Proposal
|
|
||||||
|
|
||||||
The proposal captures **why** we're making this change and **what** it involves at a high level. It's the "elevator pitch" for the work.
|
|
||||||
|
|
||||||
I'll draft one based on our task.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Draft the proposal content (don't save yet):
|
|
||||||
|
|
||||||
```
|
|
||||||
Here's a draft proposal:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Why
|
|
||||||
|
|
||||||
[1-2 sentences explaining the problem/opportunity]
|
|
||||||
|
|
||||||
## What Changes
|
|
||||||
|
|
||||||
[Bullet points of what will be different]
|
|
||||||
|
|
||||||
## Capabilities
|
|
||||||
|
|
||||||
### New Capabilities
|
|
||||||
- `<capability-name>`: [brief description]
|
|
||||||
|
|
||||||
### Modified Capabilities
|
|
||||||
<!-- If modifying existing behavior -->
|
|
||||||
|
|
||||||
## Impact
|
|
||||||
|
|
||||||
- `src/path/to/file.ts`: [what changes]
|
|
||||||
- [other files if applicable]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Does this capture the intent? I can adjust before we save it.
|
|
||||||
```
|
|
||||||
|
|
||||||
**PAUSE** - Wait for user approval/feedback.
|
|
||||||
|
|
||||||
After approval, save the proposal:
|
|
||||||
```bash
|
|
||||||
openspec instructions proposal --change "<name>" --json
|
|
||||||
```
|
|
||||||
Then write the content to `openspec/changes/<name>/proposal.md`.
|
|
||||||
|
|
||||||
```
|
|
||||||
Proposal saved. This is your "why" document—you can always come back and refine it as understanding evolves.
|
|
||||||
|
|
||||||
Next up: specs.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 6: Specs
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Specs
|
|
||||||
|
|
||||||
Specs define **what** we're building in precise, testable terms. They use a requirement/scenario format that makes expected behavior crystal clear.
|
|
||||||
|
|
||||||
For a small task like this, we might only need one spec file.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Create the spec file:
|
|
||||||
```bash
|
|
||||||
mkdir -p openspec/changes/<name>/specs/<capability-name>
|
|
||||||
```
|
|
||||||
|
|
||||||
Draft the spec content:
|
|
||||||
|
|
||||||
```
|
|
||||||
Here's the spec:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: <Name>
|
|
||||||
|
|
||||||
<Description of what the system should do>
|
|
||||||
|
|
||||||
#### Scenario: <Scenario name>
|
|
||||||
|
|
||||||
- **WHEN** <trigger condition>
|
|
||||||
- **THEN** <expected outcome>
|
|
||||||
- **AND** <additional outcome if needed>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
This format—WHEN/THEN/AND—makes requirements testable. You can literally read them as test cases.
|
|
||||||
```
|
|
||||||
|
|
||||||
Save to `openspec/changes/<name>/specs/<capability>/spec.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 7: Design
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Design
|
|
||||||
|
|
||||||
The design captures **how** we'll build it—technical decisions, tradeoffs, approach.
|
|
||||||
|
|
||||||
For small changes, this might be brief. That's fine—not every change needs deep design discussion.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Draft design.md:
|
|
||||||
|
|
||||||
```
|
|
||||||
Here's the design:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Context
|
|
||||||
|
|
||||||
[Brief context about the current state]
|
|
||||||
|
|
||||||
## Goals / Non-Goals
|
|
||||||
|
|
||||||
**Goals:**
|
|
||||||
- [What we're trying to achieve]
|
|
||||||
|
|
||||||
**Non-Goals:**
|
|
||||||
- [What's explicitly out of scope]
|
|
||||||
|
|
||||||
## Decisions
|
|
||||||
|
|
||||||
### Decision 1: [Key decision]
|
|
||||||
|
|
||||||
[Explanation of approach and rationale]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
For a small task, this captures the key decisions without over-engineering.
|
|
||||||
```
|
|
||||||
|
|
||||||
Save to `openspec/changes/<name>/design.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 8: Tasks
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Tasks
|
|
||||||
|
|
||||||
Finally, we break the work into implementation tasks—checkboxes that drive the apply phase.
|
|
||||||
|
|
||||||
These should be small, clear, and in logical order.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** Generate tasks based on specs and design:
|
|
||||||
|
|
||||||
```
|
|
||||||
Here are the implementation tasks:
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 1. [Category or file]
|
|
||||||
|
|
||||||
- [ ] 1.1 [Specific task]
|
|
||||||
- [ ] 1.2 [Specific task]
|
|
||||||
|
|
||||||
## 2. Verify
|
|
||||||
|
|
||||||
- [ ] 2.1 [Verification step]
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
Each checkbox becomes a unit of work in the apply phase. Ready to implement?
|
|
||||||
```
|
|
||||||
|
|
||||||
**PAUSE** - Wait for user to confirm they're ready to implement.
|
|
||||||
|
|
||||||
Save to `openspec/changes/<name>/tasks.md`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 9: Apply (Implementation)
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Implementation
|
|
||||||
|
|
||||||
Now we implement each task, checking them off as we go. I'll announce each one and occasionally note how the specs/design informed the approach.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:** For each task:
|
|
||||||
|
|
||||||
1. Announce: "Working on task N: [description]"
|
|
||||||
2. Implement the change in the codebase
|
|
||||||
3. Reference specs/design naturally: "The spec says X, so I'm doing Y"
|
|
||||||
4. Mark complete in tasks.md: `- [ ]` → `- [x]`
|
|
||||||
5. Brief status: "✓ Task N complete"
|
|
||||||
|
|
||||||
Keep narration light—don't over-explain every line of code.
|
|
||||||
|
|
||||||
After all tasks:
|
|
||||||
|
|
||||||
```
|
|
||||||
## Implementation Complete
|
|
||||||
|
|
||||||
All tasks done:
|
|
||||||
- [x] Task 1
|
|
||||||
- [x] Task 2
|
|
||||||
- [x] ...
|
|
||||||
|
|
||||||
The change is implemented! One more step—let's archive it.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 10: Archive
|
|
||||||
|
|
||||||
**EXPLAIN:**
|
|
||||||
```
|
|
||||||
## Archiving
|
|
||||||
|
|
||||||
When a change is complete, we archive it. This moves it from `openspec/changes/` to `openspec/changes/archive/YYYY-MM-DD-<name>/`.
|
|
||||||
|
|
||||||
Archived changes become your project's decision history—you can always find them later to understand why something was built a certain way.
|
|
||||||
```
|
|
||||||
|
|
||||||
**DO:**
|
|
||||||
```bash
|
|
||||||
openspec archive "<name>"
|
|
||||||
```
|
|
||||||
|
|
||||||
**SHOW:**
|
|
||||||
```
|
|
||||||
Archived to: `openspec/changes/archive/YYYY-MM-DD-<name>/`
|
|
||||||
|
|
||||||
The change is now part of your project's history. The code is in your codebase, the decision record is preserved.
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Phase 11: Recap & Next Steps
|
|
||||||
|
|
||||||
```
|
|
||||||
## Congratulations!
|
|
||||||
|
|
||||||
You just completed a full OpenSpec cycle:
|
|
||||||
|
|
||||||
1. **Explore** - Thought through the problem
|
|
||||||
2. **New** - Created a change container
|
|
||||||
3. **Proposal** - Captured WHY
|
|
||||||
4. **Specs** - Defined WHAT in detail
|
|
||||||
5. **Design** - Decided HOW
|
|
||||||
6. **Tasks** - Broke it into steps
|
|
||||||
7. **Apply** - Implemented the work
|
|
||||||
8. **Archive** - Preserved the record
|
|
||||||
|
|
||||||
This same rhythm works for any size change—a small fix or a major feature.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Command Reference
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
|---------|--------------|
|
|
||||||
| `/opsx-explore` | Think through problems before/during work |
|
|
||||||
| `/opsx-new` | Start a new change, step through artifacts |
|
|
||||||
| `/opsx-ff` | Fast-forward: create all artifacts at once |
|
|
||||||
| `/opsx-continue` | Continue working on an existing change |
|
|
||||||
| `/opsx-apply` | Implement tasks from a change |
|
|
||||||
| `/opsx-verify` | Verify implementation matches artifacts |
|
|
||||||
| `/opsx-archive` | Archive a completed change |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## What's Next?
|
|
||||||
|
|
||||||
Try `/opsx-new` or `/opsx-ff` on something you actually want to build. You've got the rhythm now!
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Graceful Exit Handling
|
|
||||||
|
|
||||||
### User wants to stop mid-way
|
|
||||||
|
|
||||||
If the user says they need to stop, want to pause, or seem disengaged:
|
|
||||||
|
|
||||||
```
|
|
||||||
No problem! Your change is saved at `openspec/changes/<name>/`.
|
|
||||||
|
|
||||||
To pick up where we left off later:
|
|
||||||
- `/opsx-continue <name>` - Resume artifact creation
|
|
||||||
- `/opsx-apply <name>` - Jump to implementation (if tasks exist)
|
|
||||||
|
|
||||||
The work won't be lost. Come back whenever you're ready.
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit gracefully without pressure.
|
|
||||||
|
|
||||||
### User just wants command reference
|
|
||||||
|
|
||||||
If the user says they just want to see the commands or skip the tutorial:
|
|
||||||
|
|
||||||
```
|
|
||||||
## OpenSpec Quick Reference
|
|
||||||
|
|
||||||
| Command | What it does |
|
|
||||||
|---------|--------------|
|
|
||||||
| `/opsx-explore` | Think through problems (no code changes) |
|
|
||||||
| `/opsx-new <name>` | Start a new change, step by step |
|
|
||||||
| `/opsx-ff <name>` | Fast-forward: all artifacts at once |
|
|
||||||
| `/opsx-continue <name>` | Continue an existing change |
|
|
||||||
| `/opsx-apply <name>` | Implement tasks |
|
|
||||||
| `/opsx-verify <name>` | Verify implementation |
|
|
||||||
| `/opsx-archive <name>` | Archive when done |
|
|
||||||
|
|
||||||
Try `/opsx-new` to start your first change, or `/opsx-ff` if you want to move fast.
|
|
||||||
```
|
|
||||||
|
|
||||||
Exit gracefully.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Guardrails
|
|
||||||
|
|
||||||
- **Follow the EXPLAIN → DO → SHOW → PAUSE pattern** at key transitions (after explore, after proposal draft, after tasks, after archive)
|
|
||||||
- **Keep narration light** during implementation—teach without lecturing
|
|
||||||
- **Don't skip phases** even if the change is small—the goal is teaching the workflow
|
|
||||||
- **Pause for acknowledgment** at marked points, but don't over-pause
|
|
||||||
- **Handle exits gracefully**—never pressure the user to continue
|
|
||||||
- **Use real codebase tasks**—don't simulate or use fake examples
|
|
||||||
- **Adjust scope gently**—guide toward smaller tasks but respect user choice
|
|
||||||
@@ -1,15 +1,24 @@
|
|||||||
---
|
---
|
||||||
name: openspec-ff-change
|
name: openspec-propose
|
||||||
description: Fast-forward through OpenSpec artifact creation. Use when the user wants to quickly create all artifacts needed for implementation without stepping through each one individually.
|
description: Propose a new change with all artifacts generated in one step. Use when the user wants to quickly describe what they want to build and get a complete proposal with design, specs, and tasks ready for implementation.
|
||||||
license: MIT
|
license: MIT
|
||||||
compatibility: Requires openspec CLI.
|
compatibility: Requires openspec CLI.
|
||||||
metadata:
|
metadata:
|
||||||
author: openspec
|
author: openspec
|
||||||
version: "1.0"
|
version: "1.0"
|
||||||
generatedBy: "1.1.1"
|
generatedBy: "1.2.0"
|
||||||
---
|
---
|
||||||
|
|
||||||
Fast-forward through artifact creation - generate everything needed to start implementation in one go.
|
Propose a new change - create the change and generate all artifacts in one step.
|
||||||
|
|
||||||
|
I'll create a change with artifacts:
|
||||||
|
- proposal.md (what & why)
|
||||||
|
- design.md (how)
|
||||||
|
- tasks.md (implementation steps)
|
||||||
|
|
||||||
|
When ready to implement, run /opsx-apply
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
**Input**: The user's request should include a change name (kebab-case) OR a description of what they want to build.
|
||||||
|
|
||||||
@@ -28,7 +37,7 @@ Fast-forward through artifact creation - generate everything needed to start imp
|
|||||||
```bash
|
```bash
|
||||||
openspec new change "<name>"
|
openspec new change "<name>"
|
||||||
```
|
```
|
||||||
This creates a scaffolded change at `openspec/changes/<name>/`.
|
This creates a scaffolded change at `openspec/changes/<name>/` with `.openspec.yaml`.
|
||||||
|
|
||||||
3. **Get the artifact build order**
|
3. **Get the artifact build order**
|
||||||
```bash
|
```bash
|
||||||
@@ -59,7 +68,7 @@ Fast-forward through artifact creation - generate everything needed to start imp
|
|||||||
- Read any completed dependency files for context
|
- Read any completed dependency files for context
|
||||||
- Create the artifact file using `template` as the structure
|
- Create the artifact file using `template` as the structure
|
||||||
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
- Apply `context` and `rules` as constraints - but do NOT copy them into the file
|
||||||
- Show brief progress: "✓ Created <artifact-id>"
|
- Show brief progress: "Created <artifact-id>"
|
||||||
|
|
||||||
b. **Continue until all `applyRequires` artifacts are complete**
|
b. **Continue until all `applyRequires` artifacts are complete**
|
||||||
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
- After creating each artifact, re-run `openspec status --change "<name>" --json`
|
||||||
@@ -97,5 +106,5 @@ After completing all artifacts, summarize:
|
|||||||
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
- Create ALL artifacts needed for implementation (as defined by schema's `apply.requires`)
|
||||||
- Always read dependency artifacts before creating a new one
|
- Always read dependency artifacts before creating a new one
|
||||||
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
- If context is critically unclear, ask the user - but prefer making reasonable decisions to keep momentum
|
||||||
- If a change with that name already exists, suggest continuing that change instead
|
- If a change with that name already exists, ask if user wants to continue it or create a new one
|
||||||
- Verify each artifact file exists after writing before proceeding to next
|
- Verify each artifact file exists after writing before proceeding to next
|
||||||
@@ -1,138 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-sync-specs
|
|
||||||
description: Sync delta specs from a change to main specs. Use when the user wants to update main specs with changes from a delta spec, without archiving the change.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Sync delta specs from a change to main specs.
|
|
||||||
|
|
||||||
This is an **agent-driven** operation - you will read delta specs and directly edit main specs to apply the changes. This allows intelligent merging (e.g., adding a scenario without copying the entire requirement).
|
|
||||||
|
|
||||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no change name provided, prompt for selection**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
|
||||||
|
|
||||||
Show changes that have delta specs (under `specs/` directory).
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
|
||||||
|
|
||||||
2. **Find delta specs**
|
|
||||||
|
|
||||||
Look for delta spec files in `openspec/changes/<name>/specs/*/spec.md`.
|
|
||||||
|
|
||||||
Each delta spec file contains sections like:
|
|
||||||
- `## ADDED Requirements` - New requirements to add
|
|
||||||
- `## MODIFIED Requirements` - Changes to existing requirements
|
|
||||||
- `## REMOVED Requirements` - Requirements to remove
|
|
||||||
- `## RENAMED Requirements` - Requirements to rename (FROM:/TO: format)
|
|
||||||
|
|
||||||
If no delta specs found, inform user and stop.
|
|
||||||
|
|
||||||
3. **For each delta spec, apply changes to main specs**
|
|
||||||
|
|
||||||
For each capability with a delta spec at `openspec/changes/<name>/specs/<capability>/spec.md`:
|
|
||||||
|
|
||||||
a. **Read the delta spec** to understand the intended changes
|
|
||||||
|
|
||||||
b. **Read the main spec** at `openspec/specs/<capability>/spec.md` (may not exist yet)
|
|
||||||
|
|
||||||
c. **Apply changes intelligently**:
|
|
||||||
|
|
||||||
**ADDED Requirements:**
|
|
||||||
- If requirement doesn't exist in main spec → add it
|
|
||||||
- If requirement already exists → update it to match (treat as implicit MODIFIED)
|
|
||||||
|
|
||||||
**MODIFIED Requirements:**
|
|
||||||
- Find the requirement in main spec
|
|
||||||
- Apply the changes - this can be:
|
|
||||||
- Adding new scenarios (don't need to copy existing ones)
|
|
||||||
- Modifying existing scenarios
|
|
||||||
- Changing the requirement description
|
|
||||||
- Preserve scenarios/content not mentioned in the delta
|
|
||||||
|
|
||||||
**REMOVED Requirements:**
|
|
||||||
- Remove the entire requirement block from main spec
|
|
||||||
|
|
||||||
**RENAMED Requirements:**
|
|
||||||
- Find the FROM requirement, rename to TO
|
|
||||||
|
|
||||||
d. **Create new main spec** if capability doesn't exist yet:
|
|
||||||
- Create `openspec/specs/<capability>/spec.md`
|
|
||||||
- Add Purpose section (can be brief, mark as TBD)
|
|
||||||
- Add Requirements section with the ADDED requirements
|
|
||||||
|
|
||||||
4. **Show summary**
|
|
||||||
|
|
||||||
After applying all changes, summarize:
|
|
||||||
- Which capabilities were updated
|
|
||||||
- What changes were made (requirements added/modified/removed/renamed)
|
|
||||||
|
|
||||||
**Delta Spec Format Reference**
|
|
||||||
|
|
||||||
```markdown
|
|
||||||
## ADDED Requirements
|
|
||||||
|
|
||||||
### Requirement: New Feature
|
|
||||||
The system SHALL do something new.
|
|
||||||
|
|
||||||
#### Scenario: Basic case
|
|
||||||
- **WHEN** user does X
|
|
||||||
- **THEN** system does Y
|
|
||||||
|
|
||||||
## MODIFIED Requirements
|
|
||||||
|
|
||||||
### Requirement: Existing Feature
|
|
||||||
#### Scenario: New scenario to add
|
|
||||||
- **WHEN** user does A
|
|
||||||
- **THEN** system does B
|
|
||||||
|
|
||||||
## REMOVED Requirements
|
|
||||||
|
|
||||||
### Requirement: Deprecated Feature
|
|
||||||
|
|
||||||
## RENAMED Requirements
|
|
||||||
|
|
||||||
- FROM: `### Requirement: Old Name`
|
|
||||||
- TO: `### Requirement: New Name`
|
|
||||||
```
|
|
||||||
|
|
||||||
**Key Principle: Intelligent Merging**
|
|
||||||
|
|
||||||
Unlike programmatic merging, you can apply **partial updates**:
|
|
||||||
- To add a scenario, just include that scenario under MODIFIED - don't copy existing scenarios
|
|
||||||
- The delta represents *intent*, not a wholesale replacement
|
|
||||||
- Use your judgment to merge changes sensibly
|
|
||||||
|
|
||||||
**Output On Success**
|
|
||||||
|
|
||||||
```
|
|
||||||
## Specs Synced: <change-name>
|
|
||||||
|
|
||||||
Updated main specs:
|
|
||||||
|
|
||||||
**<capability-1>**:
|
|
||||||
- Added requirement: "New Feature"
|
|
||||||
- Modified requirement: "Existing Feature" (added 1 scenario)
|
|
||||||
|
|
||||||
**<capability-2>**:
|
|
||||||
- Created new spec file
|
|
||||||
- Added requirement: "Another Feature"
|
|
||||||
|
|
||||||
Main specs are now updated. The change remains active - archive when implementation is complete.
|
|
||||||
```
|
|
||||||
|
|
||||||
**Guardrails**
|
|
||||||
- Read both delta and main specs before making changes
|
|
||||||
- Preserve existing content not mentioned in delta
|
|
||||||
- If something is unclear, ask for clarification
|
|
||||||
- Show what you're changing as you go
|
|
||||||
- The operation should be idempotent - running twice should give same result
|
|
||||||
@@ -1,168 +0,0 @@
|
|||||||
---
|
|
||||||
name: openspec-verify-change
|
|
||||||
description: Verify implementation matches change artifacts. Use when the user wants to validate that implementation is complete, correct, and coherent before archiving.
|
|
||||||
license: MIT
|
|
||||||
compatibility: Requires openspec CLI.
|
|
||||||
metadata:
|
|
||||||
author: openspec
|
|
||||||
version: "1.0"
|
|
||||||
generatedBy: "1.1.1"
|
|
||||||
---
|
|
||||||
|
|
||||||
Verify that an implementation matches the change artifacts (specs, tasks, design).
|
|
||||||
|
|
||||||
**Input**: Optionally specify a change name. If omitted, check if it can be inferred from conversation context. If vague or ambiguous you MUST prompt for available changes.
|
|
||||||
|
|
||||||
**Steps**
|
|
||||||
|
|
||||||
1. **If no change name provided, prompt for selection**
|
|
||||||
|
|
||||||
Run `openspec list --json` to get available changes. Use the **AskUserQuestion tool** to let the user select.
|
|
||||||
|
|
||||||
Show changes that have implementation tasks (tasks artifact exists).
|
|
||||||
Include the schema used for each change if available.
|
|
||||||
Mark changes with incomplete tasks as "(In Progress)".
|
|
||||||
|
|
||||||
**IMPORTANT**: Do NOT guess or auto-select a change. Always let the user choose.
|
|
||||||
|
|
||||||
2. **Check status to understand the schema**
|
|
||||||
```bash
|
|
||||||
openspec status --change "<name>" --json
|
|
||||||
```
|
|
||||||
Parse the JSON to understand:
|
|
||||||
- `schemaName`: The workflow being used (e.g., "spec-driven")
|
|
||||||
- Which artifacts exist for this change
|
|
||||||
|
|
||||||
3. **Get the change directory and load artifacts**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
openspec instructions apply --change "<name>" --json
|
|
||||||
```
|
|
||||||
|
|
||||||
This returns the change directory and context files. Read all available artifacts from `contextFiles`.
|
|
||||||
|
|
||||||
4. **Initialize verification report structure**
|
|
||||||
|
|
||||||
Create a report structure with three dimensions:
|
|
||||||
- **Completeness**: Track tasks and spec coverage
|
|
||||||
- **Correctness**: Track requirement implementation and scenario coverage
|
|
||||||
- **Coherence**: Track design adherence and pattern consistency
|
|
||||||
|
|
||||||
Each dimension can have CRITICAL, WARNING, or SUGGESTION issues.
|
|
||||||
|
|
||||||
5. **Verify Completeness**
|
|
||||||
|
|
||||||
**Task Completion**:
|
|
||||||
- If tasks.md exists in contextFiles, read it
|
|
||||||
- Parse checkboxes: `- [ ]` (incomplete) vs `- [x]` (complete)
|
|
||||||
- Count complete vs total tasks
|
|
||||||
- If incomplete tasks exist:
|
|
||||||
- Add CRITICAL issue for each incomplete task
|
|
||||||
- Recommendation: "Complete task: <description>" or "Mark as done if already implemented"
|
|
||||||
|
|
||||||
**Spec Coverage**:
|
|
||||||
- If delta specs exist in `openspec/changes/<name>/specs/`:
|
|
||||||
- Extract all requirements (marked with "### Requirement:")
|
|
||||||
- For each requirement:
|
|
||||||
- Search codebase for keywords related to the requirement
|
|
||||||
- Assess if implementation likely exists
|
|
||||||
- If requirements appear unimplemented:
|
|
||||||
- Add CRITICAL issue: "Requirement not found: <requirement name>"
|
|
||||||
- Recommendation: "Implement requirement X: <description>"
|
|
||||||
|
|
||||||
6. **Verify Correctness**
|
|
||||||
|
|
||||||
**Requirement Implementation Mapping**:
|
|
||||||
- For each requirement from delta specs:
|
|
||||||
- Search codebase for implementation evidence
|
|
||||||
- If found, note file paths and line ranges
|
|
||||||
- Assess if implementation matches requirement intent
|
|
||||||
- If divergence detected:
|
|
||||||
- Add WARNING: "Implementation may diverge from spec: <details>"
|
|
||||||
- Recommendation: "Review <file>:<lines> against requirement X"
|
|
||||||
|
|
||||||
**Scenario Coverage**:
|
|
||||||
- For each scenario in delta specs (marked with "#### Scenario:"):
|
|
||||||
- Check if conditions are handled in code
|
|
||||||
- Check if tests exist covering the scenario
|
|
||||||
- If scenario appears uncovered:
|
|
||||||
- Add WARNING: "Scenario not covered: <scenario name>"
|
|
||||||
- Recommendation: "Add test or implementation for scenario: <description>"
|
|
||||||
|
|
||||||
7. **Verify Coherence**
|
|
||||||
|
|
||||||
**Design Adherence**:
|
|
||||||
- If design.md exists in contextFiles:
|
|
||||||
- Extract key decisions (look for sections like "Decision:", "Approach:", "Architecture:")
|
|
||||||
- Verify implementation follows those decisions
|
|
||||||
- If contradiction detected:
|
|
||||||
- Add WARNING: "Design decision not followed: <decision>"
|
|
||||||
- Recommendation: "Update implementation or revise design.md to match reality"
|
|
||||||
- If no design.md: Skip design adherence check, note "No design.md to verify against"
|
|
||||||
|
|
||||||
**Code Pattern Consistency**:
|
|
||||||
- Review new code for consistency with project patterns
|
|
||||||
- Check file naming, directory structure, coding style
|
|
||||||
- If significant deviations found:
|
|
||||||
- Add SUGGESTION: "Code pattern deviation: <details>"
|
|
||||||
- Recommendation: "Consider following project pattern: <example>"
|
|
||||||
|
|
||||||
8. **Generate Verification Report**
|
|
||||||
|
|
||||||
**Summary Scorecard**:
|
|
||||||
```
|
|
||||||
## Verification Report: <change-name>
|
|
||||||
|
|
||||||
### Summary
|
|
||||||
| Dimension | Status |
|
|
||||||
|--------------|------------------|
|
|
||||||
| Completeness | X/Y tasks, N reqs|
|
|
||||||
| Correctness | M/N reqs covered |
|
|
||||||
| Coherence | Followed/Issues |
|
|
||||||
```
|
|
||||||
|
|
||||||
**Issues by Priority**:
|
|
||||||
|
|
||||||
1. **CRITICAL** (Must fix before archive):
|
|
||||||
- Incomplete tasks
|
|
||||||
- Missing requirement implementations
|
|
||||||
- Each with specific, actionable recommendation
|
|
||||||
|
|
||||||
2. **WARNING** (Should fix):
|
|
||||||
- Spec/design divergences
|
|
||||||
- Missing scenario coverage
|
|
||||||
- Each with specific recommendation
|
|
||||||
|
|
||||||
3. **SUGGESTION** (Nice to fix):
|
|
||||||
- Pattern inconsistencies
|
|
||||||
- Minor improvements
|
|
||||||
- Each with specific recommendation
|
|
||||||
|
|
||||||
**Final Assessment**:
|
|
||||||
- If CRITICAL issues: "X critical issue(s) found. Fix before archiving."
|
|
||||||
- If only warnings: "No critical issues. Y warning(s) to consider. Ready for archive (with noted improvements)."
|
|
||||||
- If all clear: "All checks passed. Ready for archive."
|
|
||||||
|
|
||||||
**Verification Heuristics**
|
|
||||||
|
|
||||||
- **Completeness**: Focus on objective checklist items (checkboxes, requirements list)
|
|
||||||
- **Correctness**: Use keyword search, file path analysis, reasonable inference - don't require perfect certainty
|
|
||||||
- **Coherence**: Look for glaring inconsistencies, don't nitpick style
|
|
||||||
- **False Positives**: When uncertain, prefer SUGGESTION over WARNING, WARNING over CRITICAL
|
|
||||||
- **Actionability**: Every issue must have a specific recommendation with file/line references where applicable
|
|
||||||
|
|
||||||
**Graceful Degradation**
|
|
||||||
|
|
||||||
- If only tasks.md exists: verify task completion only, skip spec/design checks
|
|
||||||
- If tasks + specs exist: verify completeness and correctness, skip design
|
|
||||||
- If full artifacts: verify all three dimensions
|
|
||||||
- Always note which checks were skipped and why
|
|
||||||
|
|
||||||
**Output Format**
|
|
||||||
|
|
||||||
Use clear markdown with:
|
|
||||||
- Table for summary scorecard
|
|
||||||
- Grouped lists for issues (CRITICAL/WARNING/SUGGESTION)
|
|
||||||
- Code references in format: `file.ts:123`
|
|
||||||
- Specific, actionable recommendations
|
|
||||||
- No vague suggestions like "consider reviewing"
|
|
||||||
39
AGENTS.md
39
AGENTS.md
@@ -38,6 +38,7 @@ handlers := &bootstrap.Handlers{
|
|||||||
## 语言要求
|
## 语言要求
|
||||||
|
|
||||||
**必须遵守:**
|
**必须遵守:**
|
||||||
|
|
||||||
- 永远用中文交互
|
- 永远用中文交互
|
||||||
- 注释必须使用中文
|
- 注释必须使用中文
|
||||||
- 文档必须使用中文
|
- 文档必须使用中文
|
||||||
@@ -63,6 +64,7 @@ handlers := &bootstrap.Handlers{
|
|||||||
| 缓存 | Redis 6.0+ |
|
| 缓存 | Redis 6.0+ |
|
||||||
|
|
||||||
**禁止:**
|
**禁止:**
|
||||||
|
|
||||||
- 直接使用 `database/sql`(必须通过 GORM)
|
- 直接使用 `database/sql`(必须通过 GORM)
|
||||||
- 使用 `net/http` 替代 Fiber
|
- 使用 `net/http` 替代 Fiber
|
||||||
- 使用 `encoding/json` 替代 sonic(除非必要)
|
- 使用 `encoding/json` 替代 sonic(除非必要)
|
||||||
@@ -83,21 +85,25 @@ Handler → Service → Store → Model
|
|||||||
## 核心原则
|
## 核心原则
|
||||||
|
|
||||||
### 错误处理
|
### 错误处理
|
||||||
|
|
||||||
- 所有错误必须在 `pkg/errors/` 中定义
|
- 所有错误必须在 `pkg/errors/` 中定义
|
||||||
- 使用统一错误码系统
|
- 使用统一错误码系统
|
||||||
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
|
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
|
||||||
|
|
||||||
#### 错误报错规范(必须遵守)
|
#### 错误报错规范(必须遵守)
|
||||||
|
|
||||||
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()`、`err.Error()`)
|
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()`、`err.Error()`)
|
||||||
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
|
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
|
||||||
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)` 或 `errors.Wrap(...)`
|
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)` 或 `errors.Wrap(...)`
|
||||||
- 约定用法:`errors.New(code[, msg])`、`errors.Wrap(code, err[, msg])`
|
- 约定用法:`errors.New(code[, msg])`、`errors.Wrap(code, err[, msg])`
|
||||||
|
|
||||||
### 响应格式
|
### 响应格式
|
||||||
|
|
||||||
- 所有 API 响应使用 `pkg/response/` 的统一格式
|
- 所有 API 响应使用 `pkg/response/` 的统一格式
|
||||||
- 格式: `{code, msg, data, timestamp}`
|
- 格式: `{code, msg, data, timestamp}`
|
||||||
|
|
||||||
### 常量管理
|
### 常量管理
|
||||||
|
|
||||||
- 所有常量定义在 `pkg/constants/`
|
- 所有常量定义在 `pkg/constants/`
|
||||||
- Redis key 使用函数生成: `Redis{Module}{Purpose}Key(params...)`
|
- Redis key 使用函数生成: `Redis{Module}{Purpose}Key(params...)`
|
||||||
- 禁止硬编码字符串和 magic numbers
|
- 禁止硬编码字符串和 magic numbers
|
||||||
@@ -177,6 +183,7 @@ func (s *UsageService) ActivateByRealname(ctx context.Context, cardID uint) erro
|
|||||||
#### 未导出符号的注释
|
#### 未导出符号的注释
|
||||||
|
|
||||||
未导出(小写)的函数/方法:
|
未导出(小写)的函数/方法:
|
||||||
|
|
||||||
- **简单逻辑**(< 15 行):可以不加注释
|
- **简单逻辑**(< 15 行):可以不加注释
|
||||||
- **复杂逻辑**(≥ 15 行)或 **非显而易见的算法**:必须加注释
|
- **复杂逻辑**(≥ 15 行)或 **非显而易见的算法**:必须加注释
|
||||||
|
|
||||||
@@ -199,6 +206,7 @@ func (s *Service) buildPermissionTree(permissions []*model.Permission) []*dto.Pe
|
|||||||
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
|
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
|
||||||
|
|
||||||
**✅ 好的内联注释(解释为什么)**:
|
**✅ 好的内联注释(解释为什么)**:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
|
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
|
||||||
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
|
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
|
||||||
@@ -212,6 +220,7 @@ if err := s.freezeCommission(ctx, tx, orderID); err != nil {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**❌ 废话注释(禁止)**:
|
**❌ 废话注释(禁止)**:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 获取用户ID ← 禁止:代码本身已经很清楚
|
// 获取用户ID ← 禁止:代码本身已经很清楚
|
||||||
userID := middleware.GetUserIDFromContext(ctx)
|
userID := middleware.GetUserIDFromContext(ctx)
|
||||||
@@ -248,6 +257,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Go 代码风格
|
### Go 代码风格
|
||||||
|
|
||||||
- 使用 `gofmt` 格式化
|
- 使用 `gofmt` 格式化
|
||||||
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
|
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
|
||||||
- 包名: 简短、小写、单数、无下划线
|
- 包名: 简短、小写、单数、无下划线
|
||||||
@@ -256,6 +266,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
## 数据库设计
|
## 数据库设计
|
||||||
|
|
||||||
**核心规则:**
|
**核心规则:**
|
||||||
|
|
||||||
- ❌ 禁止建立外键约束
|
- ❌ 禁止建立外键约束
|
||||||
- ❌ 禁止使用 GORM 关联关系标签(foreignKey、hasMany、belongsTo)
|
- ❌ 禁止使用 GORM 关联关系标签(foreignKey、hasMany、belongsTo)
|
||||||
- ✅ 关联通过存储 ID 字段手动维护
|
- ✅ 关联通过存储 ID 字段手动维护
|
||||||
@@ -264,6 +275,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
## Go 惯用法 vs Java 风格
|
## Go 惯用法 vs Java 风格
|
||||||
|
|
||||||
### ✅ Go 风格(推荐)
|
### ✅ Go 风格(推荐)
|
||||||
|
|
||||||
- 扁平化包结构(最多 2-3 层)
|
- 扁平化包结构(最多 2-3 层)
|
||||||
- 小而专注的接口(1-3 个方法)
|
- 小而专注的接口(1-3 个方法)
|
||||||
- 直接访问导出字段(不用 getter/setter)
|
- 直接访问导出字段(不用 getter/setter)
|
||||||
@@ -271,6 +283,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
- 显式错误返回和检查
|
- 显式错误返回和检查
|
||||||
|
|
||||||
### ❌ Java 风格(禁止)
|
### ❌ Java 风格(禁止)
|
||||||
|
|
||||||
- 过度抽象(不必要的接口、工厂)
|
- 过度抽象(不必要的接口、工厂)
|
||||||
- Getter/Setter 方法
|
- Getter/Setter 方法
|
||||||
- 深层继承层次
|
- 深层继承层次
|
||||||
@@ -282,6 +295,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
**本项目不使用任何形式的自动化测试代码。**
|
**本项目不使用任何形式的自动化测试代码。**
|
||||||
|
|
||||||
**绝对禁止:**
|
**绝对禁止:**
|
||||||
|
|
||||||
- ❌ **禁止编写单元测试** - 无论任何场景
|
- ❌ **禁止编写单元测试** - 无论任何场景
|
||||||
- ❌ **禁止编写集成测试** - 无论任何场景
|
- ❌ **禁止编写集成测试** - 无论任何场景
|
||||||
- ❌ **禁止编写验收测试** - 无论任何场景
|
- ❌ **禁止编写验收测试** - 无论任何场景
|
||||||
@@ -292,15 +306,18 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
- ❌ **禁止在文档中提及测试要求** - 规范、设计文档均不讨论测试
|
- ❌ **禁止在文档中提及测试要求** - 规范、设计文档均不讨论测试
|
||||||
|
|
||||||
**唯一例外:**
|
**唯一例外:**
|
||||||
|
|
||||||
- ✅ **仅当用户明确要求**时才编写测试代码
|
- ✅ **仅当用户明确要求**时才编写测试代码
|
||||||
- ✅ 用户必须主动说明"请写测试"或"需要测试"
|
- ✅ 用户必须主动说明"请写测试"或"需要测试"
|
||||||
|
|
||||||
**原因说明:**
|
**原因说明:**
|
||||||
|
|
||||||
- 业务系统的正确性通过人工验证和生产环境监控保证
|
- 业务系统的正确性通过人工验证和生产环境监控保证
|
||||||
- 测试代码的维护成本高于价值
|
- 测试代码的维护成本高于价值
|
||||||
- 快速迭代优先于测试覆盖率
|
- 快速迭代优先于测试覆盖率
|
||||||
|
|
||||||
**替代方案:**
|
**替代方案:**
|
||||||
|
|
||||||
- 使用 PostgreSQL MCP 工具手动验证数据
|
- 使用 PostgreSQL MCP 工具手动验证数据
|
||||||
- 使用 Postman/curl 手动测试 API
|
- 使用 Postman/curl 手动测试 API
|
||||||
- 依赖生产环境日志和监控发现问题
|
- 依赖生产环境日志和监控发现问题
|
||||||
@@ -349,23 +366,27 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
## Code Review 检查清单
|
## Code Review 检查清单
|
||||||
|
|
||||||
### 错误处理
|
### 错误处理
|
||||||
|
|
||||||
- [ ] Service 层无 `fmt.Errorf` 对外返回
|
- [ ] Service 层无 `fmt.Errorf` 对外返回
|
||||||
- [ ] Handler 层参数校验不泄露细节
|
- [ ] Handler 层参数校验不泄露细节
|
||||||
- [ ] 错误码使用正确(4xx vs 5xx)
|
- [ ] 错误码使用正确(4xx vs 5xx)
|
||||||
- [ ] 错误日志完整(包含上下文)
|
- [ ] 错误日志完整(包含上下文)
|
||||||
|
|
||||||
### 代码质量
|
### 代码质量
|
||||||
|
|
||||||
- [ ] 遵循 Handler → Service → Store → Model 分层
|
- [ ] 遵循 Handler → Service → Store → Model 分层
|
||||||
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
|
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
|
||||||
- [ ] 常量定义在 `pkg/constants/`
|
- [ ] 常量定义在 `pkg/constants/`
|
||||||
- [ ] 使用 Go 惯用法(非 Java 风格)
|
- [ ] 使用 Go 惯用法(非 Java 风格)
|
||||||
|
|
||||||
### 文档和注释
|
### 文档和注释
|
||||||
|
|
||||||
- [ ] 所有注释使用中文
|
- [ ] 所有注释使用中文
|
||||||
- [ ] 导出函数/类型有文档注释
|
- [ ] 导出函数/类型有文档注释
|
||||||
- [ ] API 路径注释与真实路由一致
|
- [ ] API 路径注释与真实路由一致
|
||||||
|
|
||||||
### 幂等性
|
### 幂等性
|
||||||
|
|
||||||
- [ ] 创建类写操作有 Redis 业务键防重
|
- [ ] 创建类写操作有 Redis 业务键防重
|
||||||
- [ ] 状态变更使用条件更新(`WHERE status = expected`)
|
- [ ] 状态变更使用条件更新(`WHERE status = expected`)
|
||||||
- [ ] 余额/库存变更使用乐观锁(version 字段)
|
- [ ] 余额/库存变更使用乐观锁(version 字段)
|
||||||
@@ -381,6 +402,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
1. **路由层中间件**(粗粒度拦截)
|
1. **路由层中间件**(粗粒度拦截)
|
||||||
- 用于明显的权限限制(如企业账号禁止访问账号管理)
|
- 用于明显的权限限制(如企业账号禁止访问账号管理)
|
||||||
- 示例:
|
- 示例:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
group.Use(func(c *fiber.Ctx) error {
|
group.Use(func(c *fiber.Ctx) error {
|
||||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||||
@@ -404,6 +426,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
- 无需手动调用
|
- 无需手动调用
|
||||||
|
|
||||||
**统一错误返回**:
|
**统一错误返回**:
|
||||||
|
|
||||||
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
|
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
|
||||||
- 不区分"不存在"和"无权限",防止信息泄露
|
- 不区分"不存在"和"无权限",防止信息泄露
|
||||||
|
|
||||||
@@ -522,6 +545,7 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
|
|||||||
**使用方式**:
|
**使用方式**:
|
||||||
|
|
||||||
1. **Service 层集成审计日志**:
|
1. **Service 层集成审计日志**:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *Store
|
store *Store
|
||||||
@@ -585,3 +609,18 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
|
|||||||
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
|
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
|
||||||
|
|
||||||
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
|
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
|
||||||
|
|
||||||
|
# English Learning Mode
|
||||||
|
|
||||||
|
The user is learning English through practical use. Apply these rules in every conversation:
|
||||||
|
|
||||||
|
1. **Always respond in Chinese** — regardless of whether the user writes in English or Chinese.
|
||||||
|
|
||||||
|
2. **When the user writes in English**, append a one-line correction at the end of your response in this format:
|
||||||
|
→ `[natural version of what they wrote]`
|
||||||
|
No explanation needed — just the corrected phrase.
|
||||||
|
|
||||||
|
3. **When the user mixes Chinese into English** (e.g., "I want to 实现 dark mode"), translate the Chinese word/phrase inline and continue naturally. Do not make a
|
||||||
|
big deal of it.
|
||||||
|
|
||||||
|
4. **Never interrupt the flow** to give grammar lessons. Corrections are silent and brief — the user's focus is on the task, not the language.
|
||||||
|
|||||||
39
CLAUDE.md
39
CLAUDE.md
@@ -38,6 +38,7 @@ handlers := &bootstrap.Handlers{
|
|||||||
## 语言要求
|
## 语言要求
|
||||||
|
|
||||||
**必须遵守:**
|
**必须遵守:**
|
||||||
|
|
||||||
- 永远用中文交互
|
- 永远用中文交互
|
||||||
- 注释必须使用中文
|
- 注释必须使用中文
|
||||||
- 文档必须使用中文
|
- 文档必须使用中文
|
||||||
@@ -63,6 +64,7 @@ handlers := &bootstrap.Handlers{
|
|||||||
| 缓存 | Redis 6.0+ |
|
| 缓存 | Redis 6.0+ |
|
||||||
|
|
||||||
**禁止:**
|
**禁止:**
|
||||||
|
|
||||||
- 直接使用 `database/sql`(必须通过 GORM)
|
- 直接使用 `database/sql`(必须通过 GORM)
|
||||||
- 使用 `net/http` 替代 Fiber
|
- 使用 `net/http` 替代 Fiber
|
||||||
- 使用 `encoding/json` 替代 sonic(除非必要)
|
- 使用 `encoding/json` 替代 sonic(除非必要)
|
||||||
@@ -83,21 +85,25 @@ Handler → Service → Store → Model
|
|||||||
## 核心原则
|
## 核心原则
|
||||||
|
|
||||||
### 错误处理
|
### 错误处理
|
||||||
|
|
||||||
- 所有错误必须在 `pkg/errors/` 中定义
|
- 所有错误必须在 `pkg/errors/` 中定义
|
||||||
- 使用统一错误码系统
|
- 使用统一错误码系统
|
||||||
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
|
- Handler 层通过返回 `error` 传递给全局 ErrorHandler
|
||||||
|
|
||||||
#### 错误报错规范(必须遵守)
|
#### 错误报错规范(必须遵守)
|
||||||
|
|
||||||
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()`、`err.Error()`)
|
- Handler 层禁止直接返回/拼接底层错误信息给客户端(例如 `"参数验证失败: "+err.Error()`、`err.Error()`)
|
||||||
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
|
- 参数校验失败:对外统一返回 `errors.New(errors.CodeInvalidParam)`(详细校验错误写日志)
|
||||||
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)` 或 `errors.Wrap(...)`
|
- Service 层禁止对外返回 `fmt.Errorf(...)`,必须返回 `errors.New(...)` 或 `errors.Wrap(...)`
|
||||||
- 约定用法:`errors.New(code[, msg])`、`errors.Wrap(code, err[, msg])`
|
- 约定用法:`errors.New(code[, msg])`、`errors.Wrap(code, err[, msg])`
|
||||||
|
|
||||||
### 响应格式
|
### 响应格式
|
||||||
|
|
||||||
- 所有 API 响应使用 `pkg/response/` 的统一格式
|
- 所有 API 响应使用 `pkg/response/` 的统一格式
|
||||||
- 格式: `{code, msg, data, timestamp}`
|
- 格式: `{code, msg, data, timestamp}`
|
||||||
|
|
||||||
### 常量管理
|
### 常量管理
|
||||||
|
|
||||||
- 所有常量定义在 `pkg/constants/`
|
- 所有常量定义在 `pkg/constants/`
|
||||||
- Redis key 使用函数生成: `Redis{Module}{Purpose}Key(params...)`
|
- Redis key 使用函数生成: `Redis{Module}{Purpose}Key(params...)`
|
||||||
- 禁止硬编码字符串和 magic numbers
|
- 禁止硬编码字符串和 magic numbers
|
||||||
@@ -177,6 +183,7 @@ func (s *UsageService) ActivateByRealname(ctx context.Context, cardID uint) erro
|
|||||||
#### 未导出符号的注释
|
#### 未导出符号的注释
|
||||||
|
|
||||||
未导出(小写)的函数/方法:
|
未导出(小写)的函数/方法:
|
||||||
|
|
||||||
- **简单逻辑**(< 15 行):可以不加注释
|
- **简单逻辑**(< 15 行):可以不加注释
|
||||||
- **复杂逻辑**(≥ 15 行)或 **非显而易见的算法**:必须加注释
|
- **复杂逻辑**(≥ 15 行)或 **非显而易见的算法**:必须加注释
|
||||||
|
|
||||||
@@ -199,6 +206,7 @@ func (s *Service) buildPermissionTree(permissions []*model.Permission) []*dto.Pe
|
|||||||
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
|
| 临时方案/兼容逻辑 | 标注 TODO 或说明背景 |
|
||||||
|
|
||||||
**✅ 好的内联注释(解释为什么)**:
|
**✅ 好的内联注释(解释为什么)**:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
|
// 使用 Redis 分布式锁防止并发重复创建,锁超时 10 秒
|
||||||
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
|
if !s.acquireLock(ctx, lockKey, 10*time.Second) {
|
||||||
@@ -212,6 +220,7 @@ if err := s.freezeCommission(ctx, tx, orderID); err != nil {
|
|||||||
```
|
```
|
||||||
|
|
||||||
**❌ 废话注释(禁止)**:
|
**❌ 废话注释(禁止)**:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
// 获取用户ID ← 禁止:代码本身已经很清楚
|
// 获取用户ID ← 禁止:代码本身已经很清楚
|
||||||
userID := middleware.GetUserIDFromContext(ctx)
|
userID := middleware.GetUserIDFromContext(ctx)
|
||||||
@@ -248,6 +257,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
```
|
```
|
||||||
|
|
||||||
### Go 代码风格
|
### Go 代码风格
|
||||||
|
|
||||||
- 使用 `gofmt` 格式化
|
- 使用 `gofmt` 格式化
|
||||||
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
|
- 遵循 [Effective Go](https://go.dev/doc/effective_go)
|
||||||
- 包名: 简短、小写、单数、无下划线
|
- 包名: 简短、小写、单数、无下划线
|
||||||
@@ -256,6 +266,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
## 数据库设计
|
## 数据库设计
|
||||||
|
|
||||||
**核心规则:**
|
**核心规则:**
|
||||||
|
|
||||||
- ❌ 禁止建立外键约束
|
- ❌ 禁止建立外键约束
|
||||||
- ❌ 禁止使用 GORM 关联关系标签(foreignKey、hasMany、belongsTo)
|
- ❌ 禁止使用 GORM 关联关系标签(foreignKey、hasMany、belongsTo)
|
||||||
- ✅ 关联通过存储 ID 字段手动维护
|
- ✅ 关联通过存储 ID 字段手动维护
|
||||||
@@ -264,6 +275,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
## Go 惯用法 vs Java 风格
|
## Go 惯用法 vs Java 风格
|
||||||
|
|
||||||
### ✅ Go 风格(推荐)
|
### ✅ Go 风格(推荐)
|
||||||
|
|
||||||
- 扁平化包结构(最多 2-3 层)
|
- 扁平化包结构(最多 2-3 层)
|
||||||
- 小而专注的接口(1-3 个方法)
|
- 小而专注的接口(1-3 个方法)
|
||||||
- 直接访问导出字段(不用 getter/setter)
|
- 直接访问导出字段(不用 getter/setter)
|
||||||
@@ -271,6 +283,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
- 显式错误返回和检查
|
- 显式错误返回和检查
|
||||||
|
|
||||||
### ❌ Java 风格(禁止)
|
### ❌ Java 风格(禁止)
|
||||||
|
|
||||||
- 过度抽象(不必要的接口、工厂)
|
- 过度抽象(不必要的接口、工厂)
|
||||||
- Getter/Setter 方法
|
- Getter/Setter 方法
|
||||||
- 深层继承层次
|
- 深层继承层次
|
||||||
@@ -282,6 +295,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
**本项目不使用任何形式的自动化测试代码。**
|
**本项目不使用任何形式的自动化测试代码。**
|
||||||
|
|
||||||
**绝对禁止:**
|
**绝对禁止:**
|
||||||
|
|
||||||
- ❌ **禁止编写单元测试** - 无论任何场景
|
- ❌ **禁止编写单元测试** - 无论任何场景
|
||||||
- ❌ **禁止编写集成测试** - 无论任何场景
|
- ❌ **禁止编写集成测试** - 无论任何场景
|
||||||
- ❌ **禁止编写验收测试** - 无论任何场景
|
- ❌ **禁止编写验收测试** - 无论任何场景
|
||||||
@@ -292,15 +306,18 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
- ❌ **禁止在文档中提及测试要求** - 规范、设计文档均不讨论测试
|
- ❌ **禁止在文档中提及测试要求** - 规范、设计文档均不讨论测试
|
||||||
|
|
||||||
**唯一例外:**
|
**唯一例外:**
|
||||||
|
|
||||||
- ✅ **仅当用户明确要求**时才编写测试代码
|
- ✅ **仅当用户明确要求**时才编写测试代码
|
||||||
- ✅ 用户必须主动说明"请写测试"或"需要测试"
|
- ✅ 用户必须主动说明"请写测试"或"需要测试"
|
||||||
|
|
||||||
**原因说明:**
|
**原因说明:**
|
||||||
|
|
||||||
- 业务系统的正确性通过人工验证和生产环境监控保证
|
- 业务系统的正确性通过人工验证和生产环境监控保证
|
||||||
- 测试代码的维护成本高于价值
|
- 测试代码的维护成本高于价值
|
||||||
- 快速迭代优先于测试覆盖率
|
- 快速迭代优先于测试覆盖率
|
||||||
|
|
||||||
**替代方案:**
|
**替代方案:**
|
||||||
|
|
||||||
- 使用 PostgreSQL MCP 工具手动验证数据
|
- 使用 PostgreSQL MCP 工具手动验证数据
|
||||||
- 使用 Postman/curl 手动测试 API
|
- 使用 Postman/curl 手动测试 API
|
||||||
- 依赖生产环境日志和监控发现问题
|
- 依赖生产环境日志和监控发现问题
|
||||||
@@ -349,23 +366,27 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
## Code Review 检查清单
|
## Code Review 检查清单
|
||||||
|
|
||||||
### 错误处理
|
### 错误处理
|
||||||
|
|
||||||
- [ ] Service 层无 `fmt.Errorf` 对外返回
|
- [ ] Service 层无 `fmt.Errorf` 对外返回
|
||||||
- [ ] Handler 层参数校验不泄露细节
|
- [ ] Handler 层参数校验不泄露细节
|
||||||
- [ ] 错误码使用正确(4xx vs 5xx)
|
- [ ] 错误码使用正确(4xx vs 5xx)
|
||||||
- [ ] 错误日志完整(包含上下文)
|
- [ ] 错误日志完整(包含上下文)
|
||||||
|
|
||||||
### 代码质量
|
### 代码质量
|
||||||
|
|
||||||
- [ ] 遵循 Handler → Service → Store → Model 分层
|
- [ ] 遵循 Handler → Service → Store → Model 分层
|
||||||
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
|
- [ ] 函数长度 ≤ 100 行(核心逻辑 ≤ 50 行)
|
||||||
- [ ] 常量定义在 `pkg/constants/`
|
- [ ] 常量定义在 `pkg/constants/`
|
||||||
- [ ] 使用 Go 惯用法(非 Java 风格)
|
- [ ] 使用 Go 惯用法(非 Java 风格)
|
||||||
|
|
||||||
### 文档和注释
|
### 文档和注释
|
||||||
|
|
||||||
- [ ] 所有注释使用中文
|
- [ ] 所有注释使用中文
|
||||||
- [ ] 导出函数/类型有文档注释
|
- [ ] 导出函数/类型有文档注释
|
||||||
- [ ] API 路径注释与真实路由一致
|
- [ ] API 路径注释与真实路由一致
|
||||||
|
|
||||||
### 幂等性
|
### 幂等性
|
||||||
|
|
||||||
- [ ] 创建类写操作有 Redis 业务键防重
|
- [ ] 创建类写操作有 Redis 业务键防重
|
||||||
- [ ] 状态变更使用条件更新(`WHERE status = expected`)
|
- [ ] 状态变更使用条件更新(`WHERE status = expected`)
|
||||||
- [ ] 余额/库存变更使用乐观锁(version 字段)
|
- [ ] 余额/库存变更使用乐观锁(version 字段)
|
||||||
@@ -381,6 +402,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
1. **路由层中间件**(粗粒度拦截)
|
1. **路由层中间件**(粗粒度拦截)
|
||||||
- 用于明显的权限限制(如企业账号禁止访问账号管理)
|
- 用于明显的权限限制(如企业账号禁止访问账号管理)
|
||||||
- 示例:
|
- 示例:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
group.Use(func(c *fiber.Ctx) error {
|
group.Use(func(c *fiber.Ctx) error {
|
||||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||||
@@ -404,6 +426,7 @@ func (h *AccountHandler) Create(c *fiber.Ctx) error {
|
|||||||
- 无需手动调用
|
- 无需手动调用
|
||||||
|
|
||||||
**统一错误返回**:
|
**统一错误返回**:
|
||||||
|
|
||||||
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
|
- 越权访问统一返回:`errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")`
|
||||||
- 不区分"不存在"和"无权限",防止信息泄露
|
- 不区分"不存在"和"无权限",防止信息泄露
|
||||||
|
|
||||||
@@ -522,6 +545,7 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
|
|||||||
**使用方式**:
|
**使用方式**:
|
||||||
|
|
||||||
1. **Service 层集成审计日志**:
|
1. **Service 层集成审计日志**:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Service struct {
|
type Service struct {
|
||||||
store *Store
|
store *Store
|
||||||
@@ -585,3 +609,18 @@ func RedisOrderCreateLockKey(carrierType string, carrierID uint) string
|
|||||||
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
|
> "任务 3.1 在当前实现中可能不需要,是否可以跳过?"
|
||||||
|
|
||||||
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
|
**详细规范和 OpenSpec 工作流请查看**: `@/openspec/AGENTS.md`
|
||||||
|
|
||||||
|
# English Learning Mode
|
||||||
|
|
||||||
|
The user is learning English through practical use. Apply these rules in every conversation:
|
||||||
|
|
||||||
|
1. **Always respond in Chinese** — regardless of whether the user writes in English or Chinese.
|
||||||
|
|
||||||
|
2. **When the user writes in English**, append a one-line correction at the end of your response in this format:
|
||||||
|
→ `[natural version of what they wrote]`
|
||||||
|
No explanation needed — just the corrected phrase.
|
||||||
|
|
||||||
|
3. **When the user mixes Chinese into English** (e.g., "I want to 实现 dark mode"), translate the Chinese word/phrase inline and continue naturally. Do not make a
|
||||||
|
big deal of it.
|
||||||
|
|
||||||
|
4. **Never interrupt the flow** to give grammar lessons. Corrections are silent and brief — the user's focus is on the task, not the language.
|
||||||
|
|||||||
4
Makefile
4
Makefile
@@ -7,8 +7,8 @@ GOCLEAN=$(GOCMD) clean
|
|||||||
GOTEST=$(GOCMD) test
|
GOTEST=$(GOCMD) test
|
||||||
GOGET=$(GOCMD) get
|
GOGET=$(GOCMD) get
|
||||||
BINARY_NAME=bin/junhong-cmp
|
BINARY_NAME=bin/junhong-cmp
|
||||||
MAIN_PATH=cmd/api/main.go
|
MAIN_PATH=./cmd/api
|
||||||
WORKER_PATH=cmd/worker/main.go
|
WORKER_PATH=./cmd/worker
|
||||||
WORKER_BINARY=bin/junhong-worker
|
WORKER_BINARY=bin/junhong-worker
|
||||||
|
|
||||||
# Database migration parameters
|
# Database migration parameters
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
|
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/routes"
|
"github.com/break/junhong_cmp_fiber/internal/routes"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||||
)
|
)
|
||||||
@@ -22,6 +24,15 @@ func generateOpenAPIDocs(outputPath string, logger *zap.Logger) {
|
|||||||
|
|
||||||
// 3. 创建 Handler(使用 nil 依赖,因为只需要路由结构)
|
// 3. 创建 Handler(使用 nil 依赖,因为只需要路由结构)
|
||||||
handlers := openapi.BuildDocHandlers()
|
handlers := openapi.BuildDocHandlers()
|
||||||
|
handlers.AssetLifecycle = admin.NewAssetLifecycleHandler(nil)
|
||||||
|
handlers.ClientAuth = apphandler.NewClientAuthHandler(nil, nil)
|
||||||
|
handlers.ClientAsset = apphandler.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientWallet = apphandler.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientOrder = apphandler.NewClientOrderHandler(nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientExchange = apphandler.NewClientExchangeHandler(nil)
|
||||||
|
handlers.ClientRealname = apphandler.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientDevice = apphandler.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.AdminExchange = admin.NewExchangeHandler(nil, nil)
|
||||||
|
|
||||||
// 4. 注册所有路由到文档生成器
|
// 4. 注册所有路由到文档生成器
|
||||||
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
||||||
|
|||||||
103
cmd/api/main.go
103
cmd/api/main.go
@@ -27,6 +27,7 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/pkg/database"
|
"github.com/break/junhong_cmp_fiber/pkg/database"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
"github.com/break/junhong_cmp_fiber/pkg/queue"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/sms"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/storage"
|
"github.com/break/junhong_cmp_fiber/pkg/storage"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -42,8 +43,6 @@ func main() {
|
|||||||
// 3. 初始化日志
|
// 3. 初始化日志
|
||||||
appLogger := initLogger(cfg)
|
appLogger := initLogger(cfg)
|
||||||
|
|
||||||
// 4. 验证微信配置
|
|
||||||
validateWechatConfig(cfg, appLogger)
|
|
||||||
defer func() {
|
defer func() {
|
||||||
_ = logger.Sync()
|
_ = logger.Sync()
|
||||||
}()
|
}()
|
||||||
@@ -247,14 +246,11 @@ func applyRateLimiterToBusinessRoutes(app *fiber.App, rateLimitMiddleware fiber.
|
|||||||
adminGroup := app.Group("/api/admin")
|
adminGroup := app.Group("/api/admin")
|
||||||
adminGroup.Use(rateLimitMiddleware)
|
adminGroup.Use(rateLimitMiddleware)
|
||||||
|
|
||||||
h5Group := app.Group("/api/h5")
|
|
||||||
h5Group.Use(rateLimitMiddleware)
|
|
||||||
|
|
||||||
personalGroup := app.Group("/api/c/v1")
|
personalGroup := app.Group("/api/c/v1")
|
||||||
personalGroup.Use(rateLimitMiddleware)
|
personalGroup.Use(rateLimitMiddleware)
|
||||||
|
|
||||||
appLogger.Info("限流器已应用到业务路由组",
|
appLogger.Info("限流器已应用到业务路由组",
|
||||||
zap.Strings("paths", []string{"/api/admin", "/api/h5", "/api/c/v1"}),
|
zap.Strings("paths", []string{"/api/admin", "/api/c/v1"}),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -311,11 +307,42 @@ func initAuthComponents(cfg *config.Config, redisClient *redis.Client, appLogger
|
|||||||
refreshTTL := time.Duration(cfg.JWT.RefreshTokenTTL) * time.Second
|
refreshTTL := time.Duration(cfg.JWT.RefreshTokenTTL) * time.Second
|
||||||
tokenManager := auth.NewTokenManager(redisClient, accessTTL, refreshTTL)
|
tokenManager := auth.NewTokenManager(redisClient, accessTTL, refreshTTL)
|
||||||
|
|
||||||
verificationSvc := verification.NewService(redisClient, nil, appLogger)
|
smsClient := initSMS(cfg, appLogger)
|
||||||
|
verificationSvc := verification.NewService(redisClient, smsClient, appLogger)
|
||||||
|
|
||||||
return jwtManager, tokenManager, verificationSvc
|
return jwtManager, tokenManager, verificationSvc
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func initSMS(cfg *config.Config, appLogger *zap.Logger) *sms.Client {
|
||||||
|
if cfg.SMS.GatewayURL == "" {
|
||||||
|
appLogger.Info("短信服务未配置,跳过初始化")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
timeout := cfg.SMS.Timeout
|
||||||
|
if timeout == 0 {
|
||||||
|
timeout = 10 * time.Second
|
||||||
|
}
|
||||||
|
|
||||||
|
httpClient := sms.NewStandardHTTPClient(0)
|
||||||
|
client := sms.NewClient(
|
||||||
|
cfg.SMS.GatewayURL,
|
||||||
|
cfg.SMS.Username,
|
||||||
|
cfg.SMS.Password,
|
||||||
|
cfg.SMS.Signature,
|
||||||
|
timeout,
|
||||||
|
appLogger,
|
||||||
|
httpClient,
|
||||||
|
)
|
||||||
|
|
||||||
|
appLogger.Info("短信服务已初始化",
|
||||||
|
zap.String("gateway_url", cfg.SMS.GatewayURL),
|
||||||
|
zap.String("signature", cfg.SMS.Signature),
|
||||||
|
)
|
||||||
|
|
||||||
|
return client
|
||||||
|
}
|
||||||
|
|
||||||
func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service {
|
func initStorage(cfg *config.Config, appLogger *zap.Logger) *storage.Service {
|
||||||
if cfg.Storage.Provider == "" || cfg.Storage.S3.Endpoint == "" {
|
if cfg.Storage.Provider == "" || cfg.Storage.S3.Endpoint == "" {
|
||||||
appLogger.Info("对象存储未配置,跳过初始化")
|
appLogger.Info("对象存储未配置,跳过初始化")
|
||||||
@@ -346,6 +373,7 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
|
|||||||
cfg.Gateway.BaseURL,
|
cfg.Gateway.BaseURL,
|
||||||
cfg.Gateway.AppID,
|
cfg.Gateway.AppID,
|
||||||
cfg.Gateway.AppSecret,
|
cfg.Gateway.AppSecret,
|
||||||
|
appLogger,
|
||||||
).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second)
|
).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second)
|
||||||
|
|
||||||
appLogger.Info("Gateway 客户端初始化成功",
|
appLogger.Info("Gateway 客户端初始化成功",
|
||||||
@@ -354,64 +382,3 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
|
|||||||
|
|
||||||
return client
|
return client
|
||||||
}
|
}
|
||||||
|
|
||||||
func validateWechatConfig(cfg *config.Config, appLogger *zap.Logger) {
|
|
||||||
wechatCfg := cfg.Wechat
|
|
||||||
|
|
||||||
if wechatCfg.OfficialAccount.AppID == "" && wechatCfg.Payment.AppID == "" {
|
|
||||||
appLogger.Warn("微信配置未设置,微信相关功能将不可用")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if wechatCfg.OfficialAccount.AppID != "" {
|
|
||||||
if wechatCfg.OfficialAccount.AppSecret == "" {
|
|
||||||
appLogger.Fatal("微信公众号配置不完整",
|
|
||||||
zap.String("missing", "app_secret"),
|
|
||||||
zap.String("env", "JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET"))
|
|
||||||
}
|
|
||||||
appLogger.Info("微信公众号配置已验证",
|
|
||||||
zap.String("app_id", wechatCfg.OfficialAccount.AppID))
|
|
||||||
}
|
|
||||||
|
|
||||||
if wechatCfg.Payment.AppID != "" {
|
|
||||||
missingFields := []string{}
|
|
||||||
|
|
||||||
if wechatCfg.Payment.MchID == "" {
|
|
||||||
missingFields = append(missingFields, "mch_id (JUNHONG_WECHAT_PAYMENT_MCH_ID)")
|
|
||||||
}
|
|
||||||
if wechatCfg.Payment.APIV3Key == "" {
|
|
||||||
missingFields = append(missingFields, "api_v3_key (JUNHONG_WECHAT_PAYMENT_API_V3_KEY)")
|
|
||||||
}
|
|
||||||
if wechatCfg.Payment.CertPath == "" {
|
|
||||||
missingFields = append(missingFields, "cert_path (JUNHONG_WECHAT_PAYMENT_CERT_PATH)")
|
|
||||||
}
|
|
||||||
if wechatCfg.Payment.KeyPath == "" {
|
|
||||||
missingFields = append(missingFields, "key_path (JUNHONG_WECHAT_PAYMENT_KEY_PATH)")
|
|
||||||
}
|
|
||||||
if wechatCfg.Payment.SerialNo == "" {
|
|
||||||
missingFields = append(missingFields, "serial_no (JUNHONG_WECHAT_PAYMENT_SERIAL_NO)")
|
|
||||||
}
|
|
||||||
if wechatCfg.Payment.NotifyURL == "" {
|
|
||||||
missingFields = append(missingFields, "notify_url (JUNHONG_WECHAT_PAYMENT_NOTIFY_URL)")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(missingFields) > 0 {
|
|
||||||
appLogger.Fatal("微信支付配置不完整",
|
|
||||||
zap.Strings("missing_fields", missingFields))
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(wechatCfg.Payment.CertPath); os.IsNotExist(err) {
|
|
||||||
appLogger.Fatal("微信支付证书文件不存在",
|
|
||||||
zap.String("cert_path", wechatCfg.Payment.CertPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err := os.Stat(wechatCfg.Payment.KeyPath); os.IsNotExist(err) {
|
|
||||||
appLogger.Fatal("微信支付私钥文件不存在",
|
|
||||||
zap.String("key_path", wechatCfg.Payment.KeyPath))
|
|
||||||
}
|
|
||||||
|
|
||||||
appLogger.Info("微信支付配置已验证",
|
|
||||||
zap.String("app_id", wechatCfg.Payment.AppID),
|
|
||||||
zap.String("mch_id", wechatCfg.Payment.MchID))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ import (
|
|||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
"github.com/break/junhong_cmp_fiber/internal/bootstrap"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/handler/admin"
|
||||||
|
apphandler "github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/routes"
|
"github.com/break/junhong_cmp_fiber/internal/routes"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
"github.com/break/junhong_cmp_fiber/pkg/openapi"
|
||||||
)
|
)
|
||||||
@@ -31,6 +33,15 @@ func generateAdminDocs(outputPath string) error {
|
|||||||
|
|
||||||
// 3. 创建 Handler(使用 nil 依赖,因为只需要路由结构)
|
// 3. 创建 Handler(使用 nil 依赖,因为只需要路由结构)
|
||||||
handlers := openapi.BuildDocHandlers()
|
handlers := openapi.BuildDocHandlers()
|
||||||
|
handlers.AssetLifecycle = admin.NewAssetLifecycleHandler(nil)
|
||||||
|
handlers.ClientAuth = apphandler.NewClientAuthHandler(nil, nil)
|
||||||
|
handlers.ClientAsset = apphandler.NewClientAssetHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientWallet = apphandler.NewClientWalletHandler(nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientOrder = apphandler.NewClientOrderHandler(nil, nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientExchange = apphandler.NewClientExchangeHandler(nil)
|
||||||
|
handlers.ClientRealname = apphandler.NewClientRealnameHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.ClientDevice = apphandler.NewClientDeviceHandler(nil, nil, nil, nil, nil, nil, nil)
|
||||||
|
handlers.AdminExchange = admin.NewExchangeHandler(nil, nil)
|
||||||
|
|
||||||
// 4. 注册所有路由到文档生成器
|
// 4. 注册所有路由到文档生成器
|
||||||
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
routes.RegisterRoutesWithDoc(app, handlers, &bootstrap.Middlewares{}, adminDoc)
|
||||||
|
|||||||
@@ -249,6 +249,7 @@ func initGateway(cfg *config.Config, appLogger *zap.Logger) *gateway.Client {
|
|||||||
cfg.Gateway.BaseURL,
|
cfg.Gateway.BaseURL,
|
||||||
cfg.Gateway.AppID,
|
cfg.Gateway.AppID,
|
||||||
cfg.Gateway.AppSecret,
|
cfg.Gateway.AppSecret,
|
||||||
|
appLogger,
|
||||||
).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second)
|
).WithTimeout(time.Duration(cfg.Gateway.Timeout) * time.Second)
|
||||||
|
|
||||||
appLogger.Info("Gateway 客户端初始化成功",
|
appLogger.Info("Gateway 客户端初始化成功",
|
||||||
|
|||||||
@@ -22,9 +22,11 @@ version: '3.8'
|
|||||||
#
|
#
|
||||||
# 可选配置(根据需要启用):
|
# 可选配置(根据需要启用):
|
||||||
# - Gateway 服务配置(JUNHONG_GATEWAY_*)
|
# - Gateway 服务配置(JUNHONG_GATEWAY_*)
|
||||||
# - 微信公众号配置(JUNHONG_WECHAT_OFFICIAL_ACCOUNT_*)
|
|
||||||
# - 微信支付配置(JUNHONG_WECHAT_PAYMENT_*)
|
|
||||||
# - 对象存储配置(JUNHONG_STORAGE_*)
|
# - 对象存储配置(JUNHONG_STORAGE_*)
|
||||||
|
# - 短信服务配置(JUNHONG_SMS_*)
|
||||||
|
#
|
||||||
|
# 微信公众号/小程序/支付配置已迁移至数据库(tb_wechat_config 表),
|
||||||
|
# 不再需要环境变量和证书文件挂载。
|
||||||
|
|
||||||
services:
|
services:
|
||||||
api:
|
api:
|
||||||
@@ -65,28 +67,13 @@ services:
|
|||||||
- JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
|
- JUNHONG_GATEWAY_APP_ID=LfjL0WjUqpwkItQ0
|
||||||
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
|
- JUNHONG_GATEWAY_APP_SECRET=K0DYuWzbRE6zg5bX
|
||||||
- JUNHONG_GATEWAY_TIMEOUT=30
|
- JUNHONG_GATEWAY_TIMEOUT=30
|
||||||
# 微信公众号配置(可选)
|
# 短信服务配置
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID=your_app_id
|
- JUNHONG_SMS_GATEWAY_URL=https://gateway.sms.whjhft.com:8443
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET=your_app_secret
|
- JUNHONG_SMS_USERNAME=JH0001
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN=your_token
|
- JUNHONG_SMS_PASSWORD=wwR8E4qnL6F0
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY=your_aes_key
|
- JUNHONG_SMS_SIGNATURE=【JHFTIOT】
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL=https://your-domain.com/callback
|
|
||||||
# 微信支付配置(可选)
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_APP_ID=your_app_id
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_MCH_ID=your_mch_id
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_API_V3_KEY=your_32_char_api_v3_key
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_API_V2_KEY=your_api_v2_key
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_CERT_PATH=/app/certs/apiclient_cert.pem
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_KEY_PATH=/app/certs/apiclient_key.pem
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_SERIAL_NO=your_serial_no
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_NOTIFY_URL=https://your-domain.com/api/callback/wechat-pay
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG=false
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_TIMEOUT=30s
|
|
||||||
volumes:
|
volumes:
|
||||||
# 仅挂载日志目录(配置已嵌入二进制文件)
|
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
# 微信支付证书目录(如果使用微信支付,需要挂载证书)
|
|
||||||
# - ./certs:/app/certs:ro
|
|
||||||
networks:
|
networks:
|
||||||
- junhong-network
|
- junhong-network
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -137,27 +124,8 @@ services:
|
|||||||
- JUNHONG_GATEWAY_APP_ID=60bgt1X8i7AvXqkd
|
- JUNHONG_GATEWAY_APP_ID=60bgt1X8i7AvXqkd
|
||||||
- JUNHONG_GATEWAY_APP_SECRET=BZeQttaZQt0i73moF
|
- JUNHONG_GATEWAY_APP_SECRET=BZeQttaZQt0i73moF
|
||||||
- JUNHONG_GATEWAY_TIMEOUT=30
|
- JUNHONG_GATEWAY_TIMEOUT=30
|
||||||
# 微信公众号配置(可选)
|
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_ID=your_app_id
|
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_APP_SECRET=your_app_secret
|
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_TOKEN=your_token
|
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_AES_KEY=your_aes_key
|
|
||||||
# - JUNHONG_WECHAT_OFFICIAL_ACCOUNT_OAUTH_REDIRECT_URL=https://your-domain.com/callback
|
|
||||||
# 微信支付配置(可选)
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_APP_ID=your_app_id
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_MCH_ID=your_mch_id
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_API_V3_KEY=your_32_char_api_v3_key
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_API_V2_KEY=your_api_v2_key
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_CERT_PATH=/app/certs/apiclient_cert.pem
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_KEY_PATH=/app/certs/apiclient_key.pem
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_SERIAL_NO=your_serial_no
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_NOTIFY_URL=https://your-domain.com/api/callback/wechat-pay
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_HTTP_DEBUG=false
|
|
||||||
# - JUNHONG_WECHAT_PAYMENT_TIMEOUT=30s
|
|
||||||
volumes:
|
volumes:
|
||||||
- ./logs:/app/logs
|
- ./logs:/app/logs
|
||||||
# 微信支付证书目录(如果使用微信支付,需要挂载证书)
|
|
||||||
# - ./certs:/app/certs:ro
|
|
||||||
networks:
|
networks:
|
||||||
- junhong-network
|
- junhong-network
|
||||||
depends_on:
|
depends_on:
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
227
docs/agent-recharge/功能总结.md
Normal file
227
docs/agent-recharge/功能总结.md
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
# 代理预充值功能
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
代理商(店铺)余额钱包的在线充值系统,支持微信在线支付和线下转账两种充值方式,具备完整的 Service/Handler/回调处理链路。充值仅针对余额钱包(`wallet_type=main`),佣金钱包通过分佣自动入账。
|
||||||
|
|
||||||
|
### 背景与动机
|
||||||
|
|
||||||
|
原有 `tb_agent_recharge_record` 表和 Store 层骨架已存在,但缺少 Service 层和 Handler 层,无法通过 API 发起充值。本次补全完整实现,并集成至支付配置管理体系(按 `payment_config_id` 动态路由至微信直连或富友通道)。
|
||||||
|
|
||||||
|
## 核心流程
|
||||||
|
|
||||||
|
### 在线充值流程(微信)
|
||||||
|
|
||||||
|
```
|
||||||
|
代理/平台 → POST /api/admin/agent-recharges
|
||||||
|
│
|
||||||
|
├─ 验证权限:代理只能充自己店铺,平台可指定任意店铺
|
||||||
|
├─ 验证金额范围(100 元~100 万元)
|
||||||
|
├─ 查找目标店铺的 main 钱包
|
||||||
|
├─ 查询 active 支付配置 → 无配置则拒绝(返回 1175)
|
||||||
|
├─ 记录 payment_config_id
|
||||||
|
└─ 创建充值订单(status=1 待支付)
|
||||||
|
└─ 返回订单信息(客户端支付发起【留桩】)
|
||||||
|
|
||||||
|
支付成功 → POST /api/callback/wechat-pay 或 /api/callback/fuiou-pay
|
||||||
|
│
|
||||||
|
├─ 按订单号前缀 "ARCH" 识别为代理充值
|
||||||
|
├─ 查询充值记录,取 payment_config_id
|
||||||
|
├─ 按配置验签
|
||||||
|
└─ agentRechargeService.HandlePaymentCallback()
|
||||||
|
├─ 幂等检查(WHERE status = 1)
|
||||||
|
├─ 更新充值记录状态 → 2(已完成)
|
||||||
|
├─ 代理主钱包余额增加(乐观锁防并发)
|
||||||
|
└─ 创建钱包流水记录
|
||||||
|
```
|
||||||
|
|
||||||
|
### 线下充值流程(仅平台)
|
||||||
|
|
||||||
|
```
|
||||||
|
平台 → POST /api/admin/agent-recharges
|
||||||
|
└─ payment_method = "offline"
|
||||||
|
└─ 创建充值订单(status=1 待支付)
|
||||||
|
|
||||||
|
平台确认 → POST /api/admin/agent-recharges/:id/offline-pay
|
||||||
|
├─ 验证操作密码(二次鉴权)
|
||||||
|
└─ 事务内:
|
||||||
|
├─ 更新充值记录状态 → 2(已完成)
|
||||||
|
├─ 记录 paid_at、completed_at
|
||||||
|
├─ 代理主钱包余额增加(乐观锁 version 字段)
|
||||||
|
├─ 创建钱包流水记录
|
||||||
|
└─ 记录审计日志
|
||||||
|
```
|
||||||
|
|
||||||
|
## 接口说明
|
||||||
|
|
||||||
|
### 基础路径
|
||||||
|
|
||||||
|
`/api/admin/agent-recharges`
|
||||||
|
|
||||||
|
**权限要求**:企业账号(`user_type=4`)在路由层被拦截,返回 `1005`。
|
||||||
|
|
||||||
|
### 接口列表
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 | 权限 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| POST | `/api/admin/agent-recharges` | 创建充值订单 | 代理(自己店铺)/ 平台(任意店铺)|
|
||||||
|
| GET | `/api/admin/agent-recharges` | 查询充值记录列表 | 代理(自己店铺)/ 平台(全部)|
|
||||||
|
| GET | `/api/admin/agent-recharges/:id` | 查询充值记录详情 | 代理(自己店铺)/ 平台(全部)|
|
||||||
|
| POST | `/api/admin/agent-recharges/:id/offline-pay` | 确认线下充值到账 | 仅平台 |
|
||||||
|
|
||||||
|
### 创建充值订单
|
||||||
|
|
||||||
|
**请求体示例(在线充值)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"shop_id": 101,
|
||||||
|
"amount": 50000,
|
||||||
|
"payment_method": "wechat"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求体示例(线下充值)**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"shop_id": 101,
|
||||||
|
"amount": 200000,
|
||||||
|
"payment_method": "offline"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**请求字段**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| shop_id | integer | 是 | 目标店铺 ID(代理只能填自己所属店铺)|
|
||||||
|
| amount | integer | 是 | 充值金额(单位:分),范围 10000~100000000 |
|
||||||
|
| payment_method | string | 是 | `wechat`(在线)/ `offline`(线下,仅平台)|
|
||||||
|
|
||||||
|
**成功响应**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"msg": "success",
|
||||||
|
"data": {
|
||||||
|
"id": 88,
|
||||||
|
"recharge_no": "ARCH20260316100001",
|
||||||
|
"shop_id": 101,
|
||||||
|
"amount": 50000,
|
||||||
|
"payment_method": "wechat",
|
||||||
|
"payment_channel": "wechat_direct",
|
||||||
|
"payment_config_id": 3,
|
||||||
|
"status": 1,
|
||||||
|
"created_at": "2026-03-16T10:00:00+08:00"
|
||||||
|
},
|
||||||
|
"timestamp": "2026-03-16T10:00:00+08:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 线下充值确认
|
||||||
|
|
||||||
|
**请求体**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"operation_password": "Abc123456"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
操作密码验证通过后,事务内同步完成:余额到账 + 钱包流水 + 审计日志。
|
||||||
|
|
||||||
|
## 权限控制矩阵
|
||||||
|
|
||||||
|
| 操作 | 平台账号 | 代理账号 | 企业账号 |
|
||||||
|
|------|----------|----------|----------|
|
||||||
|
| 创建充值(在线) | ✅ 任意店铺 | ✅ 仅自己店铺 | ❌ |
|
||||||
|
| 创建充值(线下) | ✅ 任意店铺 | ❌ | ❌ |
|
||||||
|
| 线下充值确认 | ✅ | ❌ | ❌ |
|
||||||
|
| 查询充值列表 | ✅ 全部 | ✅ 仅自己店铺 | ❌ |
|
||||||
|
| 查询充值详情 | ✅ 全部 | ✅ 仅自己店铺 | ❌ |
|
||||||
|
|
||||||
|
**越权统一响应**:代理访问他人店铺充值记录时,返回 `1121 CodeRechargeNotFound`(不区分不存在与无权限)
|
||||||
|
|
||||||
|
## 数据模型
|
||||||
|
|
||||||
|
### `tb_agent_recharge_record` 新增字段
|
||||||
|
|
||||||
|
| 字段 | 类型 | 可空 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| `payment_config_id` | bigint | 是 | 关联支付配置 ID(线下充值为 NULL,在线充值记录实际使用的配置)|
|
||||||
|
|
||||||
|
### 充值订单状态枚举
|
||||||
|
|
||||||
|
| 值 | 含义 |
|
||||||
|
|----|------|
|
||||||
|
| 1 | 待支付 |
|
||||||
|
| 2 | 已完成 |
|
||||||
|
| 3 | 已取消 |
|
||||||
|
|
||||||
|
### 支付方式与通道
|
||||||
|
|
||||||
|
| payment_method | payment_channel | 说明 |
|
||||||
|
|---------------|----------------|------|
|
||||||
|
| wechat | wechat_direct | 微信直连通道(provider_type=wechat)|
|
||||||
|
| wechat | fuyou | 富友通道(provider_type=fuiou)|
|
||||||
|
| offline | offline | 线下转账 |
|
||||||
|
|
||||||
|
> 前端统一显示"微信支付",后端根据生效配置的 `provider_type` 自动路由,前端不感知具体通道。
|
||||||
|
|
||||||
|
### 充值单号规则
|
||||||
|
|
||||||
|
前缀 `ARCH`,全局唯一,用于回调时识别订单类型。
|
||||||
|
|
||||||
|
## 幂等性设计
|
||||||
|
|
||||||
|
- 回调处理使用状态条件更新:`WHERE status = 1`
|
||||||
|
- `RowsAffected == 0` 时说明已被处理,直接返回成功,不重复入账
|
||||||
|
- 钱包余额更新使用乐观锁(`version` 字段),并发冲突时最多重试 3 次
|
||||||
|
|
||||||
|
## 审计日志
|
||||||
|
|
||||||
|
线下充值确认(`OfflinePay`)操作记录审计日志,字段包括:
|
||||||
|
|
||||||
|
| 字段 | 值 |
|
||||||
|
|------|-----|
|
||||||
|
| `operator_id` | 当前操作人 ID |
|
||||||
|
| `operation_type` | `offline_recharge` |
|
||||||
|
| `operation_desc` | `确认代理充值到账:充值单号 {recharge_no},金额 {amount} 分` |
|
||||||
|
| `before_data` | 操作前余额和充值记录状态 |
|
||||||
|
| `after_data` | 操作后余额和充值记录状态 |
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
| 层级 | 文件 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| DTO | `internal/model/dto/agent_recharge_dto.go` | 请求/响应 DTO |
|
||||||
|
| Service | `internal/service/agent_recharge/service.go` | 充值业务逻辑 |
|
||||||
|
| Handler | `internal/handler/admin/agent_recharge.go` | 4 个 Handler 方法 |
|
||||||
|
| 路由 | `internal/routes/agent_recharge.go` | 路由注册 |
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
| 文件 | 变更说明 |
|
||||||
|
|------|---------|
|
||||||
|
| `internal/model/agent_wallet.go` | 新增 `PaymentConfigID *uint` 字段 |
|
||||||
|
| `internal/handler/callback/payment.go` | 新增 "ARCH" 前缀分发 → agentRechargeService.HandlePaymentCallback() |
|
||||||
|
| `internal/bootstrap/` 系列 | 注册 AgentRechargeService、AgentRechargeHandler |
|
||||||
|
| `cmd/api/docs.go` / `cmd/gendocs/main.go` | 注册 AgentRechargeHandler |
|
||||||
|
| `migrations/000081_add_payment_config_id_to_agent_recharge.up.sql` | tb_agent_recharge_record 新增 payment_config_id 列 |
|
||||||
|
|
||||||
|
## 常量定义
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/constants/wallet.go
|
||||||
|
AgentRechargeOrderPrefix = "ARCH" // 充值单号前缀
|
||||||
|
AgentRechargeMinAmount = 10000 // 最小充值:100 元(单位:分)
|
||||||
|
AgentRechargeMaxAmount = 100000000 // 最大充值:100 万元(单位:分)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已知限制(留桩)
|
||||||
|
|
||||||
|
**客户端支付发起未实现**:在线充值(`payment_method=wechat`)创建订单成功后,前端获取支付参数的接口本次未实现。充值回调处理已完整实现——等支付发起改造完成后,完整的充值支付闭环即可联通。
|
||||||
253
docs/asset-detail-refactor-api-changes.md
Normal file
253
docs/asset-detail-refactor-api-changes.md
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
# 资产详情重构 API 变更说明
|
||||||
|
|
||||||
|
> 适用版本:asset-detail-refactor 提案上线后
|
||||||
|
> 文档更新:2026-03-14
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、现有接口字段变更
|
||||||
|
|
||||||
|
### 1. `device_no` 重命名为 `virtual_no`
|
||||||
|
|
||||||
|
所有涉及设备标识符的接口,响应中的 `device_no` 字段已统一改名为 `virtual_no`,**JSON key 同步变更**,前端需全局替换。
|
||||||
|
|
||||||
|
受影响接口:
|
||||||
|
|
||||||
|
| 接口 | 变更字段 |
|
||||||
|
|------|---------|
|
||||||
|
| `GET /api/admin/devices`(列表/详情响应) | `device_no` → `virtual_no` |
|
||||||
|
| `GET /api/admin/devices/import/tasks/:id` | `failed_items[].device_no` → `virtual_no` |
|
||||||
|
| `GET /api/admin/enterprises/:id/devices`(企业设备列表) | `device_no` → `virtual_no` |
|
||||||
|
| `GET /api/admin/shop-commission/records` | `device_no` → `virtual_no` |
|
||||||
|
| `GET /api/admin/my-commission/records` | `device_no` → `virtual_no` |
|
||||||
|
| 企业卡授权相关响应中的设备字段 | `device_no` → `virtual_no` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 2. 套餐接口新增 `virtual_ratio` 字段
|
||||||
|
|
||||||
|
`GET /api/admin/packages` 及套餐详情响应新增:
|
||||||
|
|
||||||
|
| 新增字段 | 类型 | 说明 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `virtual_ratio` | float64 | 虚流量比例(real_data_mb / virtual_data_mb)。启用虚流量时计算,否则为 1.0 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### 3. IoT 卡接口新增 `virtual_no` 字段
|
||||||
|
|
||||||
|
卡列表/详情响应新增:
|
||||||
|
|
||||||
|
| 新增字段 | 类型 | 说明 |
|
||||||
|
|---------|------|------|
|
||||||
|
| `virtual_no` | string | 虚拟号(可空) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、新增接口
|
||||||
|
|
||||||
|
### 基础说明
|
||||||
|
|
||||||
|
- 路径参数 `asset_type` 取值:`card`(卡)或 `device`(设备)
|
||||||
|
- 企业账号调用 `resolve` 接口会返回 403
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/admin/assets/resolve/:identifier`
|
||||||
|
|
||||||
|
通过任意标识符查询设备或卡的完整详情。支持虚拟号、ICCID、IMEI、SN、MSISDN。
|
||||||
|
|
||||||
|
**响应字段:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `asset_type` | string | `card` 或 `device` |
|
||||||
|
| `asset_id` | uint | 数据库 ID |
|
||||||
|
| `virtual_no` | string | 虚拟号 |
|
||||||
|
| `status` | int | 资产状态 |
|
||||||
|
| `batch_no` | string | 批次号 |
|
||||||
|
| `shop_id` | uint | 所属店铺 ID |
|
||||||
|
| `shop_name` | string | 所属店铺名称 |
|
||||||
|
| `series_id` | uint | 套餐系列 ID |
|
||||||
|
| `series_name` | string | 套餐系列名称 |
|
||||||
|
| `real_name_status` | int | 实名状态:0 未实名 / 1 实名中 / 2 已实名 |
|
||||||
|
| `network_status` | int | 网络状态:0 停机 / 1 开机(仅 card) |
|
||||||
|
| `current_package` | string | 当前套餐名称(无则空) |
|
||||||
|
| `package_total_mb` | int64 | 当前套餐总虚流量 MB |
|
||||||
|
| `package_used_mb` | float64 | 已用虚流量 MB |
|
||||||
|
| `package_remain_mb` | float64 | 剩余虚流量 MB |
|
||||||
|
| `device_protect_status` | string | 保护期状态:`none` / `stop` / `start`(仅 device) |
|
||||||
|
| `activated_at` | time | 激活时间 |
|
||||||
|
| `created_at` | time | 创建时间 |
|
||||||
|
| `updated_at` | time | 更新时间 |
|
||||||
|
| **绑定关系(card 时)** | | |
|
||||||
|
| `iccid` | string | 卡 ICCID |
|
||||||
|
| `bound_device_id` | uint | 绑定设备 ID |
|
||||||
|
| `bound_device_no` | string | 绑定设备虚拟号 |
|
||||||
|
| `bound_device_name` | string | 绑定设备名称 |
|
||||||
|
| **绑定关系(device 时)** | | |
|
||||||
|
| `bound_card_count` | int | 绑定卡数量 |
|
||||||
|
| `cards[]` | array | 绑定卡列表,每项含:`card_id` / `iccid` / `msisdn` / `network_status` / `real_name_status` / `slot_position` |
|
||||||
|
| **设备专属字段(card 时为空)** | | |
|
||||||
|
| `device_name` | string | 设备名称 |
|
||||||
|
| `imei` | string | IMEI |
|
||||||
|
| `sn` | string | 序列号 |
|
||||||
|
| `device_model` | string | 设备型号 |
|
||||||
|
| `device_type` | string | 设备类型 |
|
||||||
|
| `max_sim_slots` | int | 最大插槽数 |
|
||||||
|
| `manufacturer` | string | 制造商 |
|
||||||
|
| **卡专属字段(device 时为空)** | | |
|
||||||
|
| `carrier_type` | string | 运营商类型 |
|
||||||
|
| `carrier_name` | string | 运营商名称 |
|
||||||
|
| `msisdn` | string | 手机号 |
|
||||||
|
| `imsi` | string | IMSI |
|
||||||
|
| `card_category` | string | 卡业务类型 |
|
||||||
|
| `supplier` | string | 供应商 |
|
||||||
|
| `activation_status` | int | 激活状态 |
|
||||||
|
| `enable_polling` | bool | 是否参与轮询 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/admin/assets/:asset_type/:id/realtime-status`
|
||||||
|
|
||||||
|
读取资产实时状态(直接读 DB/Redis,不调网关)。
|
||||||
|
|
||||||
|
**响应字段:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `asset_type` | string | `card` 或 `device` |
|
||||||
|
| `asset_id` | uint | 资产 ID |
|
||||||
|
| `network_status` | int | 网络状态(仅 card) |
|
||||||
|
| `real_name_status` | int | 实名状态(仅 card) |
|
||||||
|
| `current_month_usage_mb` | float64 | 本月已用流量 MB(仅 card) |
|
||||||
|
| `last_sync_time` | time | 最后同步时间(仅 card) |
|
||||||
|
| `device_protect_status` | string | 保护期:`none` / `stop` / `start`(仅 device) |
|
||||||
|
| `cards[]` | array | 所有绑定卡的状态(仅 device),同 resolve 的 cards 结构 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/admin/assets/:asset_type/:id/refresh`
|
||||||
|
|
||||||
|
主动调网关拉取最新数据后返回,响应结构与 `realtime-status` 完全相同。
|
||||||
|
|
||||||
|
> 设备有 **30 秒冷却期**,冷却中调用返回 429。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/admin/assets/:asset_type/:id/packages`
|
||||||
|
|
||||||
|
查询该资产所有套餐记录,含虚流量换算字段。
|
||||||
|
|
||||||
|
**响应为数组,每项字段:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `package_usage_id` | uint | 套餐使用记录 ID |
|
||||||
|
| `package_id` | uint | 套餐 ID |
|
||||||
|
| `package_name` | string | 套餐名称 |
|
||||||
|
| `package_type` | string | `formal`(正式套餐)/ `addon`(加油包) |
|
||||||
|
| `status` | int | 0 待生效 / 1 生效中 / 2 已用完 / 3 已过期 / 4 已失效 |
|
||||||
|
| `status_name` | string | 状态中文名 |
|
||||||
|
| `data_limit_mb` | int64 | 真流量总量 MB |
|
||||||
|
| `virtual_limit_mb` | int64 | 虚流量总量 MB(已按 virtual_ratio 换算) |
|
||||||
|
| `data_usage_mb` | int64 | 已用真流量 MB |
|
||||||
|
| `virtual_used_mb` | float64 | 已用虚流量 MB |
|
||||||
|
| `virtual_remain_mb` | float64 | 剩余虚流量 MB |
|
||||||
|
| `virtual_ratio` | float64 | 虚流量比例 |
|
||||||
|
| `activated_at` | time | 激活时间 |
|
||||||
|
| `expires_at` | time | 到期时间 |
|
||||||
|
| `master_usage_id` | uint | 主套餐 ID(加油包时有值) |
|
||||||
|
| `priority` | int | 优先级 |
|
||||||
|
| `created_at` | time | 创建时间 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `GET /api/admin/assets/:asset_type/:id/current-package`
|
||||||
|
|
||||||
|
查询当前生效中的主套餐,响应结构同 `packages` 数组的单项。无生效套餐时返回 404。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/admin/assets/device/:device_id/stop`
|
||||||
|
|
||||||
|
批量停机设备下所有已实名卡,停机成功后设置 **1 小时停机保护期**(保护期内禁止复机)。
|
||||||
|
|
||||||
|
**响应字段:**
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| `message` | string | 操作结果描述 |
|
||||||
|
| `success_count` | int | 成功停机的卡数量 |
|
||||||
|
| `failed_cards[]` | array | 停机失败列表,每项含 `iccid` 和 `reason` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/admin/assets/device/:device_id/start`
|
||||||
|
|
||||||
|
批量复机设备下所有已实名卡,复机成功后设置 **1 小时复机保护期**(保护期内禁止停机)。
|
||||||
|
|
||||||
|
无响应 body,HTTP 200 即成功。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/admin/assets/card/:iccid/stop`
|
||||||
|
|
||||||
|
手动停机单张卡(通过 ICCID)。若卡绑定的设备在**复机保护期**内,返回 403。
|
||||||
|
|
||||||
|
无响应 body,HTTP 200 即成功。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `POST /api/admin/assets/card/:iccid/start`
|
||||||
|
|
||||||
|
手动复机单张卡(通过 ICCID)。若卡绑定的设备在**停机保护期**内,返回 403。
|
||||||
|
|
||||||
|
无响应 body,HTTP 200 即成功。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、删除的接口
|
||||||
|
|
||||||
|
### IoT 卡
|
||||||
|
|
||||||
|
| 删除的接口 | 替代接口 |
|
||||||
|
|-----------|---------|
|
||||||
|
| `GET /api/admin/iot-cards/by-iccid/:iccid` | `GET /api/admin/assets/resolve/:iccid` |
|
||||||
|
| `GET /api/admin/iot-cards/:iccid/gateway-status` | `GET /api/admin/assets/card/:id/realtime-status` |
|
||||||
|
| `GET /api/admin/iot-cards/:iccid/gateway-flow` | `GET /api/admin/assets/card/:id/realtime-status` |
|
||||||
|
| `GET /api/admin/iot-cards/:iccid/gateway-realname` | `GET /api/admin/assets/card/:id/realtime-status` |
|
||||||
|
| `POST /api/admin/iot-cards/:iccid/stop` | `POST /api/admin/assets/card/:iccid/stop` |
|
||||||
|
| `POST /api/admin/iot-cards/:iccid/start` | `POST /api/admin/assets/card/:iccid/start` |
|
||||||
|
|
||||||
|
### 设备
|
||||||
|
|
||||||
|
| 删除的接口 | 替代接口 |
|
||||||
|
|-----------|---------|
|
||||||
|
| `GET /api/admin/devices/:id` | `GET /api/admin/assets/resolve/:virtual_no` |
|
||||||
|
| `GET /api/admin/devices/by-identifier/:identifier` | `GET /api/admin/assets/resolve/:identifier` |
|
||||||
|
| `GET /api/admin/devices/by-identifier/:identifier/gateway-info` | `GET /api/admin/assets/device/:id/realtime-status` |
|
||||||
|
|
||||||
|
### 企业卡(Admin)
|
||||||
|
|
||||||
|
| 删除的接口 | 替代接口 |
|
||||||
|
|-----------|---------|
|
||||||
|
| `POST /api/admin/enterprises/:id/cards/:card_id/suspend` | `POST /api/admin/assets/card/:iccid/stop` |
|
||||||
|
| `POST /api/admin/enterprises/:id/cards/:card_id/resume` | `POST /api/admin/assets/card/:iccid/start` |
|
||||||
|
|
||||||
|
### 企业设备(H5)
|
||||||
|
|
||||||
|
| 删除的接口 | 替代接口 |
|
||||||
|
|-----------|---------|
|
||||||
|
| `POST /api/h5/enterprise/devices/:device_id/suspend-card` | `POST /api/admin/assets/device/:device_id/stop` |
|
||||||
|
| `POST /api/h5/enterprise/devices/:device_id/resume-card` | `POST /api/admin/assets/device/:device_id/start` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、新增错误码说明
|
||||||
|
|
||||||
|
| HTTP 状态码 | 触发场景 |
|
||||||
|
|------------|---------|
|
||||||
|
| 403 | 设备在保护期内(停机 1h 内禁止复机,反之亦然);企业账号调用 resolve 接口 |
|
||||||
|
| 404 | 标识符未匹配到任何资产;当前无生效套餐 |
|
||||||
|
| 429 | 设备刷新冷却中(30 秒内只能主动刷新一次) |
|
||||||
128
docs/client-api-data-model-fixes/功能总结.md
Normal file
128
docs/client-api-data-model-fixes/功能总结.md
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
# 客户端接口数据模型基础准备 - 功能总结
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本提案作为客户端接口系列的前置基础,完成三类工作:BUG 修复、基础字段准备、旧接口清理。
|
||||||
|
|
||||||
|
## 一、BUG 修复
|
||||||
|
|
||||||
|
### BUG-1:代理零售价修复
|
||||||
|
|
||||||
|
**问题**:`ShopPackageAllocation` 缺少 `retail_price` 字段,所有渠道统一使用 `Package.SuggestedRetailPrice`,代理无法设定自己的零售价。
|
||||||
|
|
||||||
|
**修复内容**:
|
||||||
|
|
||||||
|
- `ShopPackageAllocation` 新增 `retail_price` 字段(迁移中存量数据批量回填为 `SuggestedRetailPrice`)
|
||||||
|
- `GetPurchasePrice()` 改为按渠道取价:代理渠道返回 `allocation.RetailPrice`,平台渠道返回 `SuggestedRetailPrice`
|
||||||
|
- `validatePackages()` 价格累加同步修正,代理渠道额外校验 `RetailPrice >= CostPrice`
|
||||||
|
- 分配创建(`shop_package_batch_allocation`、`shop_series_grant`)时自动设置 `RetailPrice = SuggestedRetailPrice`
|
||||||
|
- 新增 cost_price 分配锁定:存在下级分配记录时禁止修改 `cost_price`
|
||||||
|
- `BatchUpdatePricing` 接口仅支持成本价批量调整(保留 cost_price 锁定规则)
|
||||||
|
- 新增独立接口 `PATCH /api/admin/packages/:id/retail-price`,代理可修改自己的套餐零售价
|
||||||
|
- `PackageResponse` 新增 `retail_price` 字段,利润计算修正为 `RetailPrice - CostPrice`
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `internal/model/shop_package_allocation.go`
|
||||||
|
- `internal/model/dto/shop_package_batch_pricing_dto.go`
|
||||||
|
- `internal/model/dto/package_dto.go`
|
||||||
|
- `internal/service/purchase_validation/service.go`
|
||||||
|
- `internal/service/shop_package_batch_allocation/service.go`
|
||||||
|
- `internal/service/shop_series_grant/service.go`
|
||||||
|
- `internal/service/shop_package_batch_pricing/service.go`
|
||||||
|
- `internal/service/package/service.go`
|
||||||
|
|
||||||
|
### BUG-2:一次性佣金触发条件修复
|
||||||
|
|
||||||
|
**问题**:后台所有订单(包括代理自购)都可能触发一次性佣金。
|
||||||
|
|
||||||
|
**修复内容**:
|
||||||
|
|
||||||
|
- `Order` 新增 `source` 字段(`admin`/`client`),默认 `admin`
|
||||||
|
- 佣金触发条件从 `!order.IsPurchaseOnBehalf` 改为 `!order.IsPurchaseOnBehalf && order.Source == "client"`
|
||||||
|
- `CreateAdminOrder()` 设置 `Source: constants.OrderSourceAdmin`
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `internal/model/order.go`
|
||||||
|
- `internal/service/commission_calculation/service.go`(两个方法)
|
||||||
|
- `internal/service/order/service.go`
|
||||||
|
|
||||||
|
### BUG-4:充值回调事务一致性修复
|
||||||
|
|
||||||
|
**问题**:`HandlePaymentCallback` 中 `UpdateStatusWithOptimisticLock` 和 `UpdatePaymentInfo` 使用 `s.db` 而非事务内 `tx`。
|
||||||
|
|
||||||
|
**修复内容**:
|
||||||
|
|
||||||
|
- `AssetRechargeStore` 新增 `UpdateStatusWithOptimisticLockDB` 和 `UpdatePaymentInfoWithDB` 方法(支持传入 `tx`)
|
||||||
|
- 原方法保留(委托调用新方法),确保向后兼容
|
||||||
|
- `HandlePaymentCallback` 改用事务内 `tx` 调用
|
||||||
|
|
||||||
|
**涉及文件**:
|
||||||
|
- `internal/store/postgres/asset_recharge_store.go`
|
||||||
|
- `internal/service/recharge/service.go`
|
||||||
|
|
||||||
|
## 二、基础字段准备
|
||||||
|
|
||||||
|
### 新增常量文件
|
||||||
|
|
||||||
|
| 文件 | 内容 |
|
||||||
|
|------|------|
|
||||||
|
| `pkg/constants/asset_status.go` | 资产业务状态(在库/已销售/已换货/已停用) |
|
||||||
|
| `pkg/constants/order_source.go` | 订单来源(admin/client) |
|
||||||
|
| `pkg/constants/operator_type.go` | 操作人类型(admin_user/personal_customer) |
|
||||||
|
| `pkg/constants/realname_link.go` | 实名链接类型(none/template/gateway) |
|
||||||
|
|
||||||
|
### 模型字段变更
|
||||||
|
|
||||||
|
| 模型 | 新增字段 | 说明 |
|
||||||
|
|------|---------|------|
|
||||||
|
| `IotCard` | `asset_status`, `generation` | 业务生命周期状态、资产世代编号 |
|
||||||
|
| `Device` | `asset_status`, `generation` | 同上 |
|
||||||
|
| `Order` | `source`, `generation` | 订单来源、资产世代快照 |
|
||||||
|
| `PackageUsage` | `generation` | 资产世代快照 |
|
||||||
|
| `AssetRechargeRecord` | `operator_type`, `generation`, `linked_package_ids`, `linked_order_type`, `linked_carrier_type`, `linked_carrier_id` | 操作人类型、世代、强充关联字段 |
|
||||||
|
| `Carrier` | `realname_link_type`, `realname_link_template` | 实名链接配置 |
|
||||||
|
| `ShopPackageAllocation` | `retail_price` | 代理零售价 |
|
||||||
|
| `PersonalCustomer` | `wx_open_id` 索引变更 | 唯一索引改为普通索引 |
|
||||||
|
|
||||||
|
### Carrier 管理 DTO 更新
|
||||||
|
|
||||||
|
- `CarrierCreateRequest`、`CarrierUpdateRequest` 新增 `realname_link_type` 和 `realname_link_template` 字段
|
||||||
|
- `CarrierResponse` 新增对应展示字段
|
||||||
|
- Carrier Service 的 Create/Update 方法同步处理,Update 时 `template` 类型强制校验模板非空
|
||||||
|
|
||||||
|
### 资产手动停用
|
||||||
|
|
||||||
|
- 新增 `PATCH /api/admin/iot-cards/:id/deactivate` 和 `PATCH /api/admin/devices/:id/deactivate`
|
||||||
|
- 仅 `asset_status` 为 1(在库)或 2(已销售)时允许停用
|
||||||
|
- 使用条件更新确保幂等
|
||||||
|
|
||||||
|
## 三、旧接口清理
|
||||||
|
|
||||||
|
### H5 接口删除
|
||||||
|
|
||||||
|
- 删除 `internal/handler/h5/` 全部文件(5 个)
|
||||||
|
- 删除 `internal/routes/h5*.go`(3 个文件)
|
||||||
|
- 清理 `routes.go`、`order.go`、`recharge.go` 中的 H5 路由注册
|
||||||
|
- 清理 `bootstrap/` 中 H5 Handler 构造和字段
|
||||||
|
- 清理 `middlewares.go` 中 H5 认证中间件
|
||||||
|
- 清理 `pkg/openapi/handlers.go` 中 H5 文档生成引用
|
||||||
|
- 清理 `cmd/api/main.go` 中 H5 限流挂载
|
||||||
|
|
||||||
|
### 个人客户旧登录方法删除
|
||||||
|
|
||||||
|
- 删除 `internal/handler/app/personal_customer.go` 中 Login、SendCode、WechatOAuthLogin、BindWechat 方法
|
||||||
|
- 清理对应路由注册
|
||||||
|
- 保留 UpdateProfile 和 GetProfile
|
||||||
|
|
||||||
|
## 四、数据库迁移
|
||||||
|
|
||||||
|
- 迁移编号:000082
|
||||||
|
- 涉及 7 张表、15+ 个字段变更
|
||||||
|
- 包含存量 `retail_price` 批量回填
|
||||||
|
- 包含 `wx_open_id` 索引从唯一改为普通
|
||||||
|
- 所有字段使用 `NOT NULL DEFAULT` 确保存量兼容
|
||||||
|
|
||||||
|
## 五、后台订单 generation 快照
|
||||||
|
|
||||||
|
- `CreateAdminOrder()` 创建订单时从资产(IotCard/Device)获取当前 `Generation` 值写入订单
|
||||||
|
- 不再依赖数据库默认值 1
|
||||||
1214
docs/client-api-requirements/需求说明.md
Normal file
1214
docs/client-api-requirements/需求说明.md
Normal file
File diff suppressed because it is too large
Load Diff
141
docs/client-auth-system/功能总结.md
Normal file
141
docs/client-auth-system/功能总结.md
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
# C 端认证系统功能总结
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本次实现了面向个人客户(C 端)的完整认证体系,替代旧 H5 登录接口。支持微信公众号和小程序两种登录方式,基于「资产标识符验证 → 微信授权 → 自动绑定资产 → 可选绑定手机号」的流程。
|
||||||
|
|
||||||
|
## 接口列表
|
||||||
|
|
||||||
|
| 接口 | 路径 | 认证 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| A1 | `POST /api/c/v1/auth/verify-asset` | 否 | 资产标识符验证,返回 asset_token |
|
||||||
|
| A2 | `POST /api/c/v1/auth/wechat-login` | 否 | 微信公众号登录 |
|
||||||
|
| A3 | `POST /api/c/v1/auth/miniapp-login` | 否 | 微信小程序登录 |
|
||||||
|
| A4 | `POST /api/c/v1/auth/send-code` | 否 | 发送手机验证码 |
|
||||||
|
| A5 | `POST /api/c/v1/auth/bind-phone` | 是 | 首次绑定手机号 |
|
||||||
|
| A6 | `POST /api/c/v1/auth/change-phone` | 是 | 换绑手机号(双验证码) |
|
||||||
|
| A7 | `POST /api/c/v1/auth/logout` | 是 | 退出登录 |
|
||||||
|
|
||||||
|
## 登录流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户输入资产标识符(SN/IMEI/ICCID)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
[A1] verify-asset → asset_token(5分钟有效)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
微信授权(前端完成)
|
||||||
|
│
|
||||||
|
├── 公众号 → [A2] wechat-login (code + asset_token)
|
||||||
|
└── 小程序 → [A3] miniapp-login (code + asset_token)
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
解析 asset_token → 获取微信 openid
|
||||||
|
→ 查找/创建客户 → 绑定资产
|
||||||
|
→ 签发 JWT + Redis 存储
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
返回 { token, need_bind_phone, is_new_user }
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
need_bind_phone == true?
|
||||||
|
YES → [A4] 发送验证码 → [A5] 绑定手机号
|
||||||
|
NO → 进入主页面
|
||||||
|
```
|
||||||
|
|
||||||
|
## 核心设计
|
||||||
|
|
||||||
|
### 有状态 JWT(JWT + Redis)
|
||||||
|
|
||||||
|
- JWT payload 仅含 `customer_id` + `exp`
|
||||||
|
- 登录时将 token 写入 Redis,TTL 与 JWT 一致
|
||||||
|
- 每次请求在中间件同时校验 JWT 签名和 Redis 有效状态
|
||||||
|
- 支持服务端主动失效(封禁、强制下线、退出登录)
|
||||||
|
- 单点登录:新登录覆盖旧 token
|
||||||
|
|
||||||
|
### OpenID 多记录管理
|
||||||
|
|
||||||
|
- 新增 `tb_personal_customer_openid` 表
|
||||||
|
- 同一客户可在多个 AppID(公众号/小程序)下拥有不同 OpenID
|
||||||
|
- 唯一约束:`UNIQUE(app_id, open_id) WHERE deleted_at IS NULL`
|
||||||
|
- 客户查找逻辑:openid 精确匹配 → unionid 回退合并 → 创建新客户
|
||||||
|
|
||||||
|
### 资产绑定
|
||||||
|
|
||||||
|
- 每次登录创建 `PersonalCustomerDevice` 绑定记录
|
||||||
|
- 同一资产允许被多个客户绑定(支持转手场景)
|
||||||
|
- 首次绑定时自动将资产状态从「在库(1)」更新为「已销售(2)」
|
||||||
|
|
||||||
|
### 微信配置动态加载
|
||||||
|
|
||||||
|
- 登录时从数据库 `tb_wechat_config` 动态读取激活配置
|
||||||
|
- 优先走 WechatConfigService 的 Redis 缓存
|
||||||
|
- 小程序登录直接 HTTP 调用微信 `jscode2session`(不依赖 PowerWeChat SDK)
|
||||||
|
|
||||||
|
## 限流策略
|
||||||
|
|
||||||
|
| 接口 | 维度 | 限制 |
|
||||||
|
|------|------|------|
|
||||||
|
| A1 | IP | 30 次/分钟 |
|
||||||
|
| A4 | 手机号 | 60 秒冷却 |
|
||||||
|
| A4 | IP | 20 次/小时 |
|
||||||
|
| A4 | 手机号 | 10 次/天 |
|
||||||
|
|
||||||
|
## 新增/修改文件
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `internal/model/personal_customer_openid.go` | OpenID 关联模型 |
|
||||||
|
| `internal/model/dto/client_auth_dto.go` | A1-A7 请求/响应 DTO |
|
||||||
|
| `internal/store/postgres/personal_customer_openid_store.go` | OpenID Store |
|
||||||
|
| `internal/service/client_auth/service.go` | 认证 Service(核心业务逻辑) |
|
||||||
|
| `internal/handler/app/client_auth.go` | 认证 Handler(7 个端点) |
|
||||||
|
| `pkg/wechat/miniapp.go` | 小程序 SDK 封装 |
|
||||||
|
| `migrations/000083_add_personal_customer_openid.up.sql` | 迁移文件 |
|
||||||
|
| `migrations/000083_add_personal_customer_openid.down.sql` | 回滚文件 |
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `internal/middleware/personal_auth.go` | 增加 Redis 双重校验 |
|
||||||
|
| `pkg/constants/redis.go` | 新增 token 和限流 Redis Key |
|
||||||
|
| `pkg/errors/codes.go` | 新增错误码 1180-1186 |
|
||||||
|
| `pkg/config/defaults/config.yaml` | 新增 `client.require_phone_binding` |
|
||||||
|
| `pkg/wechat/wechat.go` | 新增 MiniAppServiceInterface |
|
||||||
|
| `pkg/wechat/config.go` | 新增 3 个 DB 动态工厂函数 |
|
||||||
|
| `internal/bootstrap/types.go` | 新增 ClientAuth Handler 字段 |
|
||||||
|
| `internal/bootstrap/handlers.go` | 实例化 ClientAuth Handler |
|
||||||
|
| `internal/bootstrap/services.go` | 初始化 ClientAuth Service |
|
||||||
|
| `internal/bootstrap/stores.go` | 初始化 OpenID Store |
|
||||||
|
| `internal/routes/personal.go` | 注册 7 个认证端点 |
|
||||||
|
| `cmd/api/docs.go` | 注册文档生成器 |
|
||||||
|
| `cmd/gendocs/main.go` | 注册文档生成器 |
|
||||||
|
|
||||||
|
## 错误码
|
||||||
|
|
||||||
|
| 码值 | 常量名 | 说明 |
|
||||||
|
|------|--------|------|
|
||||||
|
| 1180 | CodeAssetNotFound | 资产不存在 |
|
||||||
|
| 1181 | CodeWechatConfigUnavailable | 微信配置不可用 |
|
||||||
|
| 1182 | CodeSmsSendFailed | 短信发送失败 |
|
||||||
|
| 1183 | CodeVerificationCodeInvalid | 验证码错误或已过期 |
|
||||||
|
| 1184 | CodePhoneAlreadyBound | 手机号已被其他客户绑定 |
|
||||||
|
| 1185 | CodeAlreadyBoundPhone | 已绑定手机号不可重复绑定 |
|
||||||
|
| 1186 | CodeOldPhoneMismatch | 旧手机号与当前绑定不匹配 |
|
||||||
|
|
||||||
|
## 数据库变更
|
||||||
|
|
||||||
|
- 新建表 `tb_personal_customer_openid`(迁移 000083)
|
||||||
|
- 唯一索引:`idx_pco_app_id_open_id` (app_id, open_id) 软删除条件
|
||||||
|
- 普通索引:`idx_pco_customer_id` (customer_id)
|
||||||
|
- 条件索引:`idx_pco_union_id` (union_id) WHERE union_id != ''
|
||||||
|
|
||||||
|
## 配置项
|
||||||
|
|
||||||
|
| 配置路径 | 环境变量 | 默认值 | 说明 |
|
||||||
|
|---------|---------|-------|------|
|
||||||
|
| `client.require_phone_binding` | `JUNHONG_CLIENT_REQUIRE_PHONE_BINDING` | `true` | 是否要求绑定手机号 |
|
||||||
122
docs/client-core-business-api/功能总结.md
Normal file
122
docs/client-core-business-api/功能总结.md
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
# 客户端核心业务 API — 功能总结
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本提案为客户端(C 端个人客户)提供完整的业务接口,覆盖资产查询、钱包充值、套餐购买、实名跳转、设备操作 5 大模块共 18 个 API 端点,全部挂载在 `/api/c/v1/` 路径下。
|
||||||
|
|
||||||
|
**前置依赖**:提案 0(数据模型修复)、提案 1(C 端认证系统)。
|
||||||
|
|
||||||
|
## API 端点一览
|
||||||
|
|
||||||
|
### 模块 B:资产信息(4 个接口)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/c/v1/asset/info` | B1 资产基本信息查询 |
|
||||||
|
| GET | `/api/c/v1/asset/packages` | B2 可购买套餐列表 |
|
||||||
|
| GET | `/api/c/v1/asset/package-history` | B3 历史套餐列表 |
|
||||||
|
| POST | `/api/c/v1/asset/refresh` | B4 手动刷新资产状态 |
|
||||||
|
|
||||||
|
### 模块 C:钱包与充值(5 个接口)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/c/v1/wallet/detail` | C1 钱包详情(不存在自动创建) |
|
||||||
|
| GET | `/api/c/v1/wallet/transactions` | C2 钱包流水列表 |
|
||||||
|
| GET | `/api/c/v1/wallet/recharge-check` | C3 充值预检(强充检查) |
|
||||||
|
| POST | `/api/c/v1/wallet/recharge` | C4 创建充值订单(JSAPI 支付) |
|
||||||
|
| GET | `/api/c/v1/wallet/recharges` | C5 充值订单列表 |
|
||||||
|
|
||||||
|
### 模块 D:套餐购买(3 个接口)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/c/v1/orders/create` | D1 创建套餐购买订单(含强充分流) |
|
||||||
|
| GET | `/api/c/v1/orders` | D2 套餐订单列表 |
|
||||||
|
| GET | `/api/c/v1/orders/:id` | D3 套餐订单详情 |
|
||||||
|
|
||||||
|
### 模块 E:实名认证(1 个接口)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/c/v1/realname/link` | E1 获取实名跳转链接 |
|
||||||
|
|
||||||
|
### 模块 F:设备能力(5 个接口)
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| GET | `/api/c/v1/device/cards` | F1 设备卡列表 |
|
||||||
|
| POST | `/api/c/v1/device/reboot` | F2 设备重启 |
|
||||||
|
| POST | `/api/c/v1/device/factory-reset` | F3 恢复出厂设置 |
|
||||||
|
| POST | `/api/c/v1/device/wifi` | F4 设置 WiFi |
|
||||||
|
| POST | `/api/c/v1/device/switch-card` | F5 切卡 |
|
||||||
|
|
||||||
|
## 核心设计决策
|
||||||
|
|
||||||
|
### 1. 数据权限绕过
|
||||||
|
|
||||||
|
客户端调用后台复用 Service 时,统一使用 `gorm.SkipDataPermission(ctx)` 绕过 shop_id 自动过滤,避免个人客户因非店铺主体被误拦截。
|
||||||
|
|
||||||
|
### 2. 归属校验
|
||||||
|
|
||||||
|
所有涉及资产操作的接口统一前置归属校验:查询 `PersonalCustomerDevice` 条件 `customer_id = 当前登录客户` 且 `virtual_no = 资产虚拟号`,未命中返回 403。
|
||||||
|
|
||||||
|
### 3. Generation 过滤
|
||||||
|
|
||||||
|
客户端历史查询统一附加 `WHERE generation = 资产当前 generation`,确保转手后数据隔离。
|
||||||
|
|
||||||
|
### 4. OpenID 安全规范
|
||||||
|
|
||||||
|
支付接口(C4/D1)所需 OpenID 由后端按 `customer_id + app_type` 查询,客户端禁止传入 OpenID。根据 `app_type` 选择对应的微信 AppID 创建支付实例。
|
||||||
|
|
||||||
|
### 5. 强充两阶段
|
||||||
|
|
||||||
|
- 第一阶段(同步):充值入账、更新状态
|
||||||
|
- 第二阶段(异步 Asynq):钱包扣款 → 创建订单 → 激活套餐
|
||||||
|
|
||||||
|
`AssetRechargeRecord.auto_purchase_status` 字段追踪异步状态(pending/success/failed)。
|
||||||
|
|
||||||
|
## 新增文件
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/model/dto/client_asset_dto.go # 资产模块 DTO
|
||||||
|
internal/model/dto/client_wallet_dto.go # 钱包模块 DTO
|
||||||
|
internal/model/dto/client_order_dto.go # 订单模块 DTO
|
||||||
|
internal/model/dto/client_realname_device_dto.go # 实名+设备模块 DTO
|
||||||
|
internal/handler/app/client_asset.go # 资产 Handler
|
||||||
|
internal/handler/app/client_wallet.go # 钱包 Handler
|
||||||
|
internal/handler/app/client_order.go # 订单 Handler
|
||||||
|
internal/handler/app/client_realname.go # 实名 Handler
|
||||||
|
internal/handler/app/client_device.go # 设备 Handler
|
||||||
|
internal/service/client_order/service.go # 客户端订单编排 Service
|
||||||
|
internal/task/auto_purchase.go # 强充异步自动购买任务
|
||||||
|
migrations/000084_add_auto_purchase_status_*.sql # 数据库迁移
|
||||||
|
```
|
||||||
|
|
||||||
|
## 修改文件
|
||||||
|
|
||||||
|
```
|
||||||
|
pkg/constants/constants.go # 新增 auto_purchase_status 常量 + 任务类型
|
||||||
|
pkg/constants/redis.go # 新增客户端购买幂等键
|
||||||
|
pkg/errors/codes.go # 新增 NEED_REALNAME/OPENID_NOT_FOUND 错误码
|
||||||
|
internal/model/asset_wallet.go # AssetRechargeRecord 新增字段
|
||||||
|
internal/bootstrap/types.go # 5 个 Handler 字段
|
||||||
|
internal/bootstrap/handlers.go # Handler 实例化
|
||||||
|
internal/routes/personal.go # 18 个路由注册
|
||||||
|
pkg/openapi/handlers.go # 文档生成 Handler
|
||||||
|
cmd/api/docs.go # 文档注册
|
||||||
|
cmd/gendocs/main.go # 文档注册
|
||||||
|
```
|
||||||
|
|
||||||
|
## 新增错误码
|
||||||
|
|
||||||
|
| 错误码 | 常量名 | 消息 |
|
||||||
|
|--------|--------|------|
|
||||||
|
| 1187 | CodeNeedRealname | 该套餐需实名认证后购买 |
|
||||||
|
| 1188 | CodeOpenIDNotFound | 未找到微信授权信息,请先完成授权 |
|
||||||
|
|
||||||
|
## 数据库变更
|
||||||
|
|
||||||
|
- 表:`tb_asset_recharge_record`
|
||||||
|
- 新增字段:`auto_purchase_status VARCHAR(20) DEFAULT '' NOT NULL`
|
||||||
|
- 迁移版本:000084
|
||||||
94
docs/client-exchange-system/功能总结.md
Normal file
94
docs/client-exchange-system/功能总结.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# 客户端换货系统功能总结
|
||||||
|
|
||||||
|
## 1. 功能概述
|
||||||
|
|
||||||
|
本次实现完成了客户端换货系统的后台与客户端闭环能力,覆盖「后台建单 → 客户端填写收货信息 → 后台发货 → 后台确认完成(可选全量迁移) → 旧资产转新」完整流程。
|
||||||
|
|
||||||
|
## 2. 数据模型与迁移
|
||||||
|
|
||||||
|
- 新增 `tb_exchange_order` 表,承载换货生命周期全量字段:旧/新资产、收货信息、物流信息、迁移状态、业务状态、多租户字段。
|
||||||
|
- 保留历史能力:将旧表 `tb_card_replacement_record` 重命名为 `tb_card_replacement_record_legacy`。
|
||||||
|
- 新增迁移文件:
|
||||||
|
- `000085_add_exchange_order.up/down.sql`
|
||||||
|
- `000086_rename_card_replacement_to_legacy.up/down.sql`
|
||||||
|
|
||||||
|
## 3. 后端实现
|
||||||
|
|
||||||
|
### 3.1 Store 层
|
||||||
|
|
||||||
|
- 新增 `ExchangeOrderStore`:
|
||||||
|
- 创建、按 ID 查询、分页列表查询
|
||||||
|
- 条件状态流转更新(`WHERE status = fromStatus`)
|
||||||
|
- 按旧资产查询进行中换货单(状态 `1/2/3`)
|
||||||
|
|
||||||
|
- 新增 `ResourceTagStore`:用于资源标签复制。
|
||||||
|
|
||||||
|
### 3.2 Service 层
|
||||||
|
|
||||||
|
- 新增 `internal/service/exchange/service.go`:
|
||||||
|
- H1 创建换货单(资产存在校验、进行中校验、单号生成、状态初始化)
|
||||||
|
- H2 列表查询
|
||||||
|
- H3 详情查询
|
||||||
|
- H4 发货(状态校验、同类型校验、新资产在库校验、物流与新资产快照写入)
|
||||||
|
- H5 确认完成(状态校验,可选全量迁移)
|
||||||
|
- H6 取消(仅允许 `1/2 -> 5`)
|
||||||
|
- H7 转新(校验已换货状态、`generation+1`、状态重置、清理绑定、创建新钱包)
|
||||||
|
- G1 查询待处理换货单
|
||||||
|
- G2 提交收货信息(`1 -> 2`)
|
||||||
|
|
||||||
|
- 新增 `internal/service/exchange/migration.go`:
|
||||||
|
- 单事务迁移实现
|
||||||
|
- 钱包余额迁移并写入迁移流水
|
||||||
|
- 套餐使用记录迁移(`tb_package_usage`)
|
||||||
|
- 套餐日记录联动更新(`tb_package_usage_daily_record`)
|
||||||
|
- 累计充值/首充字段复制(旧资产 -> 新资产)
|
||||||
|
- 标签复制(`tb_resource_tag`)
|
||||||
|
- 客户绑定 `virtual_no` 更新(`tb_personal_customer_device`)
|
||||||
|
- 旧资产状态置为已换货(`asset_status=3`)
|
||||||
|
- 换货单迁移结果回写(`migration_completed`、`migration_balance`)
|
||||||
|
|
||||||
|
## 4. Handler 与路由
|
||||||
|
|
||||||
|
### 4.1 后台换货接口
|
||||||
|
|
||||||
|
- 新增 `internal/handler/admin/exchange.go`
|
||||||
|
- 新增 `internal/routes/exchange.go`
|
||||||
|
- 注册接口(标签:`换货管理`):
|
||||||
|
- `POST /api/admin/exchanges`
|
||||||
|
- `GET /api/admin/exchanges`
|
||||||
|
- `GET /api/admin/exchanges/:id`
|
||||||
|
- `POST /api/admin/exchanges/:id/ship`
|
||||||
|
- `POST /api/admin/exchanges/:id/complete`
|
||||||
|
- `POST /api/admin/exchanges/:id/cancel`
|
||||||
|
- `POST /api/admin/exchanges/:id/renew`
|
||||||
|
|
||||||
|
### 4.2 客户端换货接口
|
||||||
|
|
||||||
|
- 新增 `internal/handler/app/client_exchange.go`
|
||||||
|
- 在 `internal/routes/personal.go` 注册:
|
||||||
|
- `GET /api/c/v1/exchange/pending`
|
||||||
|
- `POST /api/c/v1/exchange/:id/shipping-info`
|
||||||
|
|
||||||
|
## 5. 兼容与替换
|
||||||
|
|
||||||
|
- `iot_card_store.go` 的 `is_replaced` 过滤逻辑已切换至 `tb_exchange_order`。
|
||||||
|
- 业务主流程不再依赖旧换卡表(仅模型与 legacy 表保留用于历史数据)。
|
||||||
|
|
||||||
|
## 6. 启动装配与文档生成
|
||||||
|
|
||||||
|
已完成换货模块在以下位置的全链路接入:
|
||||||
|
|
||||||
|
- `internal/bootstrap/types.go`
|
||||||
|
- `internal/bootstrap/stores.go`
|
||||||
|
- `internal/bootstrap/services.go`
|
||||||
|
- `internal/bootstrap/handlers.go`
|
||||||
|
- `internal/routes/admin.go`
|
||||||
|
- `pkg/openapi/handlers.go`
|
||||||
|
- `cmd/api/docs.go`
|
||||||
|
- `cmd/gendocs/main.go`
|
||||||
|
|
||||||
|
## 7. 验证结果
|
||||||
|
|
||||||
|
- 已执行:`go build ./...`,编译通过。
|
||||||
|
- 已执行:数据库迁移 `make migrate-up`,版本到 `86`。
|
||||||
|
- 已完成:变更文件 LSP 诊断检查(无 error 级问题)。
|
||||||
821
docs/discussion/资产详情重构讨论纪要.md
Normal file
821
docs/discussion/资产详情重构讨论纪要.md
Normal file
@@ -0,0 +1,821 @@
|
|||||||
|
# 君鸿卡管系统资产详情体系重构 - 讨论纪要
|
||||||
|
|
||||||
|
> 创建时间:2026-03-12
|
||||||
|
> 最后更新:2026-03-14
|
||||||
|
> 当前阶段:设计讨论(尚未进入 openspec 提案)
|
||||||
|
> 目的:保留完整上下文,供未来继续
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、背景与需求来源
|
||||||
|
|
||||||
|
### 1.1 项目背景
|
||||||
|
|
||||||
|
君鸿卡管系统(junhong_cmp_fiber)是一个面向代理/企业的物联网卡管理平台,核心资产有两类:
|
||||||
|
- **IoT 卡(IotCard)**:纯卡资源,含 ICCID、MSISDN、流量套餐
|
||||||
|
- **设备(Device)**:带卡的硬件设备,一个设备可绑定多张卡,设备级套餐
|
||||||
|
|
||||||
|
### 1.2 需求触发点
|
||||||
|
|
||||||
|
核心痛点:
|
||||||
|
1. **接口分散且重复** - 卡和设备的查询散落在多处,H5/Admin/Personal 三端各有一套
|
||||||
|
2. **详情信息严重缺失** - 现有的详情接口返回数据太少,前端无法据此渲染完整页面
|
||||||
|
3. **网关裸数据透传** - 封装程度不够,没有业务层的聚合和处理
|
||||||
|
4. **虚拟号只存在于设备** - 卡的查询只能靠 ICCID/MSISDN,不方便
|
||||||
|
|
||||||
|
### 1.3 已确认的核心决策
|
||||||
|
|
||||||
|
- ✅ **多接口组合** - 不做单一聚合大接口,前端按需调用
|
||||||
|
- ✅ **统一入口** - 一个接口告诉前端查的是"卡"还是"设备"
|
||||||
|
- ✅ **设备优先查找** - 统一入口先查设备表,再查卡表
|
||||||
|
- ✅ **卡加虚拟号** - 虚拟号概念延伸到卡,与设备的 virtual_no 对等
|
||||||
|
- ✅ **全部一步到位** - 改造不分期,一次性完成
|
||||||
|
- ✅ **resolve 返回中等版本** - 包含资产类型、ID、虚拟号、状态、实名状态、套餐概况、流量使用、所属设备(如果绑定)等关键信息
|
||||||
|
- ✅ **资产类型只有卡和设备两种** - 未来路由器也归属设备,无需预留更多类型
|
||||||
|
- ✅ **虚拟号客服和客户都要用** - 不是只有内部人员用
|
||||||
|
- ✅ **H5 端接口暂时不需要提供** - 后续做到时再删除旧接口
|
||||||
|
- ✅ **套餐查询看历史记录** - 通过套餐记录/订单记录页面查看历史,同时提供当前套餐接口
|
||||||
|
- ✅ **手动刷新接口复用 SyncCardStatusFromGateway** - 无需重新实现,设备时批量刷新所有绑定卡
|
||||||
|
- ✅ **权限不足返回 403** - 明确告知无权限,不假装资产不存在
|
||||||
|
- ✅ **虚拟号人工填写/批量导入** - 无格式规范,允许修改,重复时全批失败并告知原因
|
||||||
|
- ✅ **device_no 字段全量改名为 virtual_no** - 数据库+代码全部更新,不保留旧字段
|
||||||
|
- ✅ **设备停复机有保护期机制** - 保护状态一致性,时长 1 小时,存储在 Redis
|
||||||
|
- ✅ **realtime-status 只查持久化数据** - 不调用网关,刷新用 refresh 接口
|
||||||
|
- ✅ **未实名的卡不参与停复机** - 未实名卡永远是停机状态,保护期逻辑跳过
|
||||||
|
- ✅ **企业账号 resolve 接口** - 企业账号暂不支持 resolve,未来单独开新接口
|
||||||
|
- ✅ **resolve 响应含卡 ICCID** - card 类型时在响应中返回 ICCID,供前端调用停复机接口
|
||||||
|
- ✅ **批量停机部分失败仍设保护期** - 部分卡停机失败时也设置 Redis 保护期,已停机的卡不回滚,失败的卡记录日志
|
||||||
|
- ✅ **流量汇总逻辑统一** - 整个系统使用统一的流量汇总逻辑;设备级套餐从 PackageUsage 汇总多卡用量
|
||||||
|
- ✅ **套餐历史列表规则** - 按创建时间倒序,不分页,包含所有状态(含已失效)
|
||||||
|
- ✅ **current-package 返回主套餐** - 多套餐同时生效时只返回主套餐(master_usage_id IS NULL)
|
||||||
|
- ✅ **轮询系统新增第四种任务** - 保护期一致性检查封装为独立轮询任务类型,不修改现有三种任务
|
||||||
|
- ✅ **卡虚拟号导入只补空白** - 只允许为现有空白虚拟号的卡填入,不支持覆盖更新;与数据库现存数据重复则全批失败
|
||||||
|
- ✅ **设备批量刷新需限频** - Redis 限频保护,同一设备冷却期内(建议 30 秒)不允许重复触发
|
||||||
|
- ✅ **PersonalCustomerDevice 统一改名** - tb_personal_customer_device 表的 device_no 字段一并改为 virtual_no
|
||||||
|
- ✅ **realtime-status 与 resolve 分工明确** - resolve 用于初始加载(含查找),realtime-status 用于已知 ID 的轻量状态轮询(不含套餐流量计算)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、现有系统审计结果
|
||||||
|
|
||||||
|
### 2.1 接口现状(三端盘点)
|
||||||
|
|
||||||
|
| 端 | 卡接口数 | 设备接口数 | 重复停复机 | 套餐接口 |
|
||||||
|
|---|---------|-----------|-----------|---------|
|
||||||
|
| Admin | 9 | 14 | 3处 | 仅流量详单 |
|
||||||
|
| H5 | 4 | 7 | 1处 | 有套餐聚合 |
|
||||||
|
| Personal | 2 | 0 | 无 | 无 |
|
||||||
|
|
||||||
|
**重复停复机的三处实现:**
|
||||||
|
1. Admin 卡端:`POST /iot-cards/:iccid/suspend|resume`(按 ICCID)
|
||||||
|
2. Admin 企业卡端:`POST /enterprises/:id/cards/:card_id/suspend|resume`(按 card_id)
|
||||||
|
3. H5 企业设备端:`POST /h5/devices/:device_id/cards/:card_id/suspend|resume`(按 card_id)
|
||||||
|
|
||||||
|
### 2.2 DTO 缺失分析
|
||||||
|
|
||||||
|
#### 卡详情(IotCardDetailResponse)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 当前实现 (iot_card_dto.go:134-136)
|
||||||
|
type IotCardDetailResponse struct {
|
||||||
|
Code int `json:"code"`
|
||||||
|
Msg string `json:"msg"`
|
||||||
|
Data *StandaloneIotCardResponse `json:"data"` // 只是空壳嵌套!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题**:详情响应只是列表响应的空包装,完全没有额外信息。无套餐、无所属设备、无聚合流量。
|
||||||
|
|
||||||
|
#### 设备详情(DeviceResponse)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 当前实现 (device_dto.go:20)
|
||||||
|
type DeviceResponse struct {
|
||||||
|
// ... 基本字段
|
||||||
|
BoundCardCount int `json:"bound_card_count"` // 只有一个数字!
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**问题**:只返回绑定卡数量,看不到每张卡的实名状态、卡状态、流量使用。
|
||||||
|
|
||||||
|
#### H5 端已有参考实现
|
||||||
|
|
||||||
|
`EnterpriseDeviceDetailResp`(enterprise_device_authorization_dto.go)是目前唯一有"设备+绑定卡列表"聚合的 DTO,可作为 admin 端改造的参考。
|
||||||
|
|
||||||
|
### 2.3 网关接口问题
|
||||||
|
|
||||||
|
**6 个网关查询接口全部是纯透传**:
|
||||||
|
- `gateway.GetCardStatus`
|
||||||
|
- `gateway.GetFlowUsage`
|
||||||
|
- `gateway.GetRealNameStatus`
|
||||||
|
- `gateway.GetDeviceInfo`
|
||||||
|
- `gateway.GetSlotInfo`
|
||||||
|
- `gateway.GetDeviceFlowUsage`
|
||||||
|
|
||||||
|
**问题**:只读不写,不更新 DB 缓存,无业务封装。
|
||||||
|
|
||||||
|
### 2.4 数据模型现状
|
||||||
|
|
||||||
|
| 模型 | 虚拟号 | 缓存字段 | 套餐载体 |
|
||||||
|
|-----|-------|---------|---------|
|
||||||
|
| IotCard | ❌ 无(需新增) | CurrentMonthUsageMB, NetworkStatus, RealNameStatus, LastDataCheckAt | IotCardID |
|
||||||
|
| Device | ✅ device_no(需改名为 virtual_no) | 无 | DeviceID |
|
||||||
|
|
||||||
|
**关键发现**:
|
||||||
|
- `PackageUsage` 模型已支持两种载体:`IotCardID`(单卡)和 `DeviceID`(设备级)
|
||||||
|
- `IotCard.IsStandalone` 字段由触发器维护,标识卡是否绑定到设备
|
||||||
|
- `DeviceStore.GetByIdentifier` 已实现多字段匹配:`WHERE device_no = ? OR imei = ? OR sn = ?`(改造后改为 virtual_no)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、设计方向(已确认)
|
||||||
|
|
||||||
|
### 3.1 统一资产入口(resolve)
|
||||||
|
|
||||||
|
**接口**:`GET /api/admin/assets/resolve/:identifier`
|
||||||
|
|
||||||
|
**查找逻辑**:
|
||||||
|
```
|
||||||
|
1. 先查 device 表(virtual_no / imei / sn)
|
||||||
|
2. 未命中则查 iot_card 表(virtual_no / iccid / msisdn)
|
||||||
|
3. 应用数据权限过滤:代理只能看自己及下级店铺的资产,平台账号看所有
|
||||||
|
4. 有权限 → 返回资产数据(中等版本)
|
||||||
|
5. 无权限 → 返回 HTTP 403
|
||||||
|
6. 未找到 → 返回 HTTP 404
|
||||||
|
```
|
||||||
|
|
||||||
|
**响应结构(已确认)**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// AssetResolveResponse 资产解析响应
|
||||||
|
type AssetResolveResponse struct {
|
||||||
|
// 基础信息
|
||||||
|
AssetType string `json:"asset_type"` // "device" 或 "card"
|
||||||
|
AssetID uint `json:"asset_id"` // 对应表的主键
|
||||||
|
VirtualNo string `json:"virtual_no"` // 统一虚拟号字段(设备/卡均用此字段)
|
||||||
|
ICCID string `json:"iccid,omitempty"` // 仅 card 类型时有值,供前端调用停复机接口使用
|
||||||
|
|
||||||
|
// 状态信息
|
||||||
|
Status int `json:"status"` // 资产状态
|
||||||
|
RealNameStatus int `json:"real_name_status"` // 实名状态
|
||||||
|
|
||||||
|
// 套餐和流量信息(无套餐时返回空字符串/0)
|
||||||
|
CurrentPackage string `json:"current_package"` // 当前套餐名称
|
||||||
|
PackageTotalMB float64 `json:"package_total_mb"` // 真总流量(套餐标称,RealDataMB)
|
||||||
|
PackageVirtualMB float64 `json:"package_virtual_mb"` // 虚总流量(停机阈值,VirtualDataMB)
|
||||||
|
PackageUsedMB float64 `json:"package_used_mb"` // 客户端展示已使用流量(经虚流量换算)
|
||||||
|
PackageRemainMB float64 `json:"package_remain_mb"` // 客户端展示剩余流量
|
||||||
|
|
||||||
|
// 保护期状态(设备类型,以及绑定该设备的卡均返回)
|
||||||
|
DeviceProtectStatus string `json:"device_protect_status"` // "none" / "stop" / "start"
|
||||||
|
|
||||||
|
// 绑定信息(仅 card 类型,且卡绑定了设备时才有值)
|
||||||
|
BoundDeviceID *uint `json:"bound_device_id,omitempty"`
|
||||||
|
BoundDeviceNo string `json:"bound_device_no,omitempty"`
|
||||||
|
BoundDeviceName string `json:"bound_device_name,omitempty"`
|
||||||
|
|
||||||
|
// 设备类型特有:绑定卡信息
|
||||||
|
BoundCardCount int `json:"bound_card_count"`
|
||||||
|
Cards []DeviceCardInfo `json:"cards,omitempty"` // 包含所有状态的卡(含未实名)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeviceCardInfo 设备下绑定卡信息
|
||||||
|
type DeviceCardInfo struct {
|
||||||
|
IotCardID uint `json:"iot_card_id"`
|
||||||
|
ICCID string `json:"iccid"`
|
||||||
|
VirtualNo string `json:"virtual_no"`
|
||||||
|
RealNameStatus int `json:"real_name_status"`
|
||||||
|
NetworkStatus int `json:"network_status"`
|
||||||
|
CurrentMonthUsageMB float64 `json:"current_month_usage_mb"`
|
||||||
|
LastSyncAt *time.Time `json:"last_sync_at"` // 最后与 Gateway 同步时间
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- 卡绑定的设备被软删除时,该卡视为独立卡,不填充绑定信息
|
||||||
|
- 设备下的 `cards` 列表包含所有绑定卡(含未实名、已停用)
|
||||||
|
|
||||||
|
### 3.2 套餐查询接口
|
||||||
|
|
||||||
|
**接口一**:`GET /api/admin/assets/:asset_type/:id/packages`
|
||||||
|
- 返回所有套餐记录(含历史和当前生效套餐)
|
||||||
|
- 按 asset_type 区分查 PackageUsage.IotCardID 还是 PackageUsage.DeviceID
|
||||||
|
- 每条记录包含:套餐名称、真总流量、虚总流量、展示已使用、展示剩余、有效期、状态
|
||||||
|
- **排序**:按创建时间倒序(最新套餐在前)
|
||||||
|
- **分页**:不分页,全量返回
|
||||||
|
- **范围**:包含所有状态(含 status=4 已失效的历史套餐)
|
||||||
|
|
||||||
|
**接口二**:`GET /api/admin/assets/:asset_type/:id/current-package`
|
||||||
|
- 返回当前生效的**主套餐**(status=1 且 master_usage_id IS NULL)的详细信息
|
||||||
|
- 当同时有主套餐 + 加油包生效时,只返回主套餐;需要查看加油包通过接口一的列表查看
|
||||||
|
- 包含完整流量明细:真总量、虚总量、展示已使用、展示剩余
|
||||||
|
|
||||||
|
### 3.3 实时状态查询接口
|
||||||
|
|
||||||
|
**接口**:`GET /api/admin/assets/:asset_type/:id/realtime-status`
|
||||||
|
|
||||||
|
**与 resolve 的定位分工**:
|
||||||
|
|
||||||
|
> **resolve**:初始加载使用,包含查找逻辑 + 全量聚合数据(套餐/流量/绑定信息),数据较重。
|
||||||
|
> **realtime-status**:已知资产 ID 后的轻量状态轮询,**不含套餐流量计算**,专注于网络/实名/保护期状态的快速刷新。
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- **只查询持久化数据(DB/Redis),不调用网关**
|
||||||
|
- 返回最近一次轮询/刷新同步到系统的状态
|
||||||
|
- "实时性"依赖轮询系统保持数据新鲜(实名 5 分钟,流量/套餐 10 分钟)
|
||||||
|
- 需要最新数据时,先调用 refresh 接口手动刷新,再查此接口
|
||||||
|
- 设备类型返回:保护期状态 + 每张绑定卡的状态(网络/实名/流量/最后同步时间)
|
||||||
|
- 卡类型返回:网络状态 + 实名状态 + 流量使用 + 最后同步时间
|
||||||
|
|
||||||
|
### 3.4 手动刷新接口
|
||||||
|
|
||||||
|
**接口**:`POST /api/admin/assets/:asset_type/:id/refresh`
|
||||||
|
|
||||||
|
**说明**:
|
||||||
|
- 调用网关获取最新数据,写回 DB 更新缓存字段,返回刷新后的最新状态
|
||||||
|
- 卡类型:调用已有的 `SyncCardStatusFromGateway(iccid)` 方法
|
||||||
|
- 设备类型:批量刷新所有绑定卡(遍历调用 `SyncCardStatusFromGateway`)
|
||||||
|
- **设备类型需要频率限制**:通过 Redis 记录最后刷新时间,同一设备冷却期内(建议 30 秒)不允许重复触发,防止前端多次快速点击打爆网关
|
||||||
|
|
||||||
|
### 3.5 设备停复机保护期机制
|
||||||
|
|
||||||
|
**背景**:
|
||||||
|
设备本身没有停机/复机概念,对设备停机 = 批量停用其下所有已实名卡。保护期机制确保操作期间所有卡的状态一致性,防止单卡被误操作破坏整体状态。
|
||||||
|
|
||||||
|
**接口**:
|
||||||
|
- `POST /api/admin/assets/device/:device_id/stop`
|
||||||
|
- `POST /api/admin/assets/device/:device_id/start`
|
||||||
|
|
||||||
|
**保护期规则**:
|
||||||
|
|
||||||
|
| 规则 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 保护期时长 | **1 小时**(硬编码在代码常量中) |
|
||||||
|
| 存储方式 | Redis Key `protect:device:{device_id}:stop` 或 `protect:device:{device_id}:start`,TTL=1小时 |
|
||||||
|
| 未实名的卡 | **不参与停复机操作**,未实名卡永远是停机状态,跳过不处理 |
|
||||||
|
| 重叠操作 | 设备在保护期内不允许再次发起相同或相反的停复机操作,返回 HTTP 403 |
|
||||||
|
| 批量停机部分失败 | 部分卡调网关失败时,**仍设置 Redis 保护期**;已成功停机的卡不回滚;失败的卡记录错误日志 |
|
||||||
|
|
||||||
|
**stop 保护期(设备停机后 1 小时内)**:
|
||||||
|
- 对某张已实名卡手动发起复机 → **不允许**(HTTP 403,设备处于停机保护期)
|
||||||
|
- 对某张已实名卡手动发起停机 → 允许(本已是停机,无冲突)
|
||||||
|
- 轮询系统发现某张已实名卡处于开机状态 → **强制调网关停机**,保持一致
|
||||||
|
|
||||||
|
**start 保护期(设备复机后 1 小时内)**:
|
||||||
|
- 对某张已实名卡手动发起停机 → **允许**(用户可主动停单张卡)
|
||||||
|
- 对某张已实名卡手动发起复机 → 允许(本已是复机,无冲突)
|
||||||
|
- 轮询系统发现某张已实名卡处于停机状态 → **强制调网关复机**,保持一致
|
||||||
|
|
||||||
|
**保护期状态对外暴露**:
|
||||||
|
- resolve 接口的 `device_protect_status` 字段返回当前保护期状态
|
||||||
|
- 卡绑定的设备有保护期时,该卡的 resolve 结果也返回 `device_protect_status`
|
||||||
|
|
||||||
|
### 3.6 接口去重(废弃清单)
|
||||||
|
|
||||||
|
**废弃接口**(直接删除,不保留向后兼容):
|
||||||
|
|
||||||
|
| 废弃接口 | 替代接口 |
|
||||||
|
|---------|---------|
|
||||||
|
| `POST /enterprises/:id/cards/:card_id/suspend` | `POST /api/admin/assets/card/:iccid/stop` |
|
||||||
|
| `POST /enterprises/:id/cards/:card_id/resume` | `POST /api/admin/assets/card/:iccid/start` |
|
||||||
|
| `POST /h5/devices/:device_id/cards/:card_id/suspend` | `POST /api/admin/assets/device/:device_id/stop` |
|
||||||
|
| `POST /h5/devices/:device_id/cards/:card_id/resume` | `POST /api/admin/assets/device/:device_id/start` |
|
||||||
|
| 旧 Admin 卡停复机接口(按 ICCID) | `POST /api/admin/assets/card/:iccid/stop|start` |
|
||||||
|
| `GET /devices/:id` | `GET /api/admin/assets/device/:id` |
|
||||||
|
|
||||||
|
### 3.7 数据层变更
|
||||||
|
|
||||||
|
**变更一:设备表字段改名(全量重构)**
|
||||||
|
```sql
|
||||||
|
ALTER TABLE tb_device RENAME COLUMN device_no TO virtual_no;
|
||||||
|
ALTER TABLE tb_personal_customer_device RENAME COLUMN device_no TO virtual_no;
|
||||||
|
```
|
||||||
|
涉及改动范围:Model 定义、DTO 响应、Store 查询、所有引用 `device_no` 的代码,以及 `tb_personal_customer_device` 表的 `device_no` 字段(一并改名为 `virtual_no`),确保系统中不再有 `device_no` 的身影。
|
||||||
|
|
||||||
|
**变更二:卡表新增 virtual_no 字段**
|
||||||
|
```sql
|
||||||
|
ALTER TABLE tb_iot_card ADD COLUMN virtual_no VARCHAR(50);
|
||||||
|
CREATE UNIQUE INDEX idx_iot_card_virtual_no
|
||||||
|
ON tb_iot_card (virtual_no) WHERE deleted_at IS NULL;
|
||||||
|
```
|
||||||
|
- 允许为空(老数据无虚拟号)
|
||||||
|
- 允许手动修改
|
||||||
|
- 全局唯一(导入时检测重复,重复则全批失败并告知具体冲突数据)
|
||||||
|
|
||||||
|
**变更三:套餐表新增 virtual_ratio 字段**
|
||||||
|
```sql
|
||||||
|
ALTER TABLE tb_package ADD COLUMN virtual_ratio DECIMAL(10,6) DEFAULT 1.0;
|
||||||
|
```
|
||||||
|
- 创建套餐时计算并存储:`virtual_ratio = real_data_mb / virtual_data_mb`
|
||||||
|
- 用于客户端展示的流量换算(见第六节)
|
||||||
|
- 未启用虚流量时(`enable_virtual_data=false`),virtual_ratio = 1.0
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、完整接口清单
|
||||||
|
|
||||||
|
| # | 方法 | 路径 | 说明 |
|
||||||
|
|---|------|------|------|
|
||||||
|
| 1 | GET | `/api/admin/assets/resolve/:identifier` | 资产解析(通过任意标识符) |
|
||||||
|
| 1 | GET | `/api/admin/assets/resolve/:identifier` | 资产解析(通过任意标识符) |
|
||||||
|
| 2 | GET | `/api/admin/assets/:asset_type/:id/packages` | 套餐记录(历史+当前) |
|
||||||
|
| 3 | GET | `/api/admin/assets/:asset_type/:id/current-package` | 当前生效主套餐详情 |
|
||||||
|
| 4 | GET | `/api/admin/assets/:asset_type/:id/realtime-status` | 当前持久化状态查询(轻量) |
|
||||||
|
| 5 | POST | `/api/admin/assets/:asset_type/:id/refresh` | 手动刷新(调网关写回 DB) |
|
||||||
|
| 6 | POST | `/api/admin/assets/device/:device_id/stop` | 设备停机(批量停所有已实名卡) |
|
||||||
|
| 7 | POST | `/api/admin/assets/device/:device_id/start` | 设备复机(批量开所有已实名卡) |
|
||||||
|
| 8 | POST | `/api/admin/assets/card/:iccid/stop` | 卡停机 |
|
||||||
|
| 9 | POST | `/api/admin/assets/card/:iccid/start` | 卡复机 |
|
||||||
|
|
||||||
|
> `:asset_type` 取值:`device` 或 `card`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、流程图
|
||||||
|
|
||||||
|
### 5.1 资产查找(resolve)流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["GET /api/admin/assets/resolve/:identifier"] --> B{"查询设备表\nvirtual_no / imei / sn"}
|
||||||
|
B -->|找到| C{"应用数据权限过滤\n代理:仅自己及下级店铺\n平台:所有资产"}
|
||||||
|
B -->|未找到| D{"查询卡表\nvirtual_no / iccid / msisdn"}
|
||||||
|
D -->|找到| C
|
||||||
|
D -->|未找到| E["返回 HTTP 404\n资产不存在"]
|
||||||
|
C -->|有权限| F["聚合资产数据\n基础信息 + 状态 + 套餐流量 + 保护期 + 绑定信息"]
|
||||||
|
C -->|无权限| G["返回 HTTP 403\n无权限查看该资产"]
|
||||||
|
F --> H["返回 AssetResolveResponse"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.2 设备停机/复机流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph 设备停机
|
||||||
|
A1["POST /assets/device/:id/stop"] --> B1{"设备是否存在?"}
|
||||||
|
B1 -->|否| C1["HTTP 404"]
|
||||||
|
B1 -->|是| D1{"设备是否在保护期?"}
|
||||||
|
D1 -->|是| E1["HTTP 403\n设备处于保护期,不允许操作"]
|
||||||
|
D1 -->|否| F1["获取所有已实名下属卡"]
|
||||||
|
F1 --> G1["批量调网关停机"]
|
||||||
|
G1 --> H1["更新各卡 NetworkStatus=停机\n(部分失败时已成功的卡不回滚)"]
|
||||||
|
H1 --> I1["Redis SET protect:device:id:stop\nTTL = 1 小时(部分失败时仍设置)"]
|
||||||
|
I1 --> J1["返回成功(附带失败卡日志)"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 设备复机
|
||||||
|
A2["POST /assets/device/:id/start"] --> B2{"设备是否存在?"}
|
||||||
|
B2 -->|否| C2["HTTP 404"]
|
||||||
|
B2 -->|是| D2{"设备是否在保护期?"}
|
||||||
|
D2 -->|是| E2["HTTP 403\n设备处于保护期,不允许操作"]
|
||||||
|
D2 -->|否| F2["获取所有已实名下属卡"]
|
||||||
|
F2 --> G2["批量调网关复机"]
|
||||||
|
G2 --> H2["更新各卡 NetworkStatus=开机"]
|
||||||
|
H2 --> I2["Redis SET protect:device:id:start\nTTL = 1 小时"]
|
||||||
|
I2 --> J2["返回成功"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.3 手动操作单卡 + 保护期检查
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph 手动停机单卡
|
||||||
|
A1["POST /assets/card/:iccid/stop"] --> B1{"卡是否存在?"}
|
||||||
|
B1 -->|否| C1["HTTP 404"]
|
||||||
|
B1 -->|是| D1{"卡是否已实名?"}
|
||||||
|
D1 -->|未实名| E1["HTTP 403\n未实名卡不允许停复机"]
|
||||||
|
D1 -->|已实名| F1{"卡是否绑定设备?"}
|
||||||
|
F1 -->|未绑定| G1["正常执行停机"]
|
||||||
|
F1 -->|已绑定| H1{"设备有 start 保护期?"}
|
||||||
|
H1 -->|是| I1["允许停机\n与 start 保护期方向一致"]
|
||||||
|
H1 -->|否| G1
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 手动复机单卡
|
||||||
|
A2["POST /assets/card/:iccid/start"] --> B2{"卡是否存在?"}
|
||||||
|
B2 -->|否| C2["HTTP 404"]
|
||||||
|
B2 -->|是| D2{"卡是否已实名?"}
|
||||||
|
D2 -->|未实名| E2["HTTP 403\n未实名卡不允许停复机"]
|
||||||
|
D2 -->|已实名| F2{"卡是否绑定设备?"}
|
||||||
|
F2 -->|未绑定| G2["正常执行复机"]
|
||||||
|
F2 -->|已绑定| H2{"设备有 stop 保护期?"}
|
||||||
|
H2 -->|是| I2["HTTP 403\n设备处于停机保护期\n不允许手动复机"]
|
||||||
|
H2 -->|否| G2
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.4 轮询系统与保护期交互
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["轮询任务触发:检查卡状态"] --> B{"卡是否已实名?"}
|
||||||
|
B -->|未实名| C["跳过,未实名卡不参与停复机逻辑"]
|
||||||
|
B -->|已实名| D{"卡是否绑定设备?"}
|
||||||
|
D -->|未绑定| E["按卡自身逻辑正常处理"]
|
||||||
|
D -->|已绑定| F{"设备是否有保护期?"}
|
||||||
|
F -->|无保护期| E
|
||||||
|
F -->|"stop 保护期"| G{"卡当前网络状态?"}
|
||||||
|
G -->|开机| H["强制调网关停机\n保持与设备保护期一致"]
|
||||||
|
G -->|停机| I["已一致,跳过"]
|
||||||
|
F -->|"start 保护期"| J{"卡当前网络状态?"}
|
||||||
|
J -->|停机| K["强制调网关复机\n保持与设备保护期一致"]
|
||||||
|
J -->|开机| L["已一致,跳过"]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.5 手动刷新(refresh)流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["POST /api/admin/assets/:type/:id/refresh"] --> B{"资产类型"}
|
||||||
|
B -->|card| C["调用 SyncCardStatusFromGateway(iccid)"]
|
||||||
|
C --> D["更新 iot_card 表\nNetworkStatus / RealNameStatus\nCurrentMonthUsageMB / LastSyncTime"]
|
||||||
|
D --> H["返回刷新后的最新状态"]
|
||||||
|
B -->|device| E["检查 Redis 限频(冷却期 30 秒)"]
|
||||||
|
E -->|冷却中| Z["HTTP 429 请勿频繁刷新"]
|
||||||
|
E -->|可刷新| F["查询所有绑定卡列表"]
|
||||||
|
F --> G["遍历每张卡\n调用 SyncCardStatusFromGateway"]
|
||||||
|
G --> H
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5.6 实时状态查询(realtime-status)流程
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
A["GET /api/admin/assets/:type/:id/realtime-status"] --> B{"资产类型"}
|
||||||
|
B -->|card| C["从 DB/Redis 读取持久化的卡状态"]
|
||||||
|
C --> D["返回卡状态\n网络状态 / 实名状态 / 本月已用流量\n最后同步时间"]
|
||||||
|
B -->|device| E["从 DB/Redis 读取持久化的设备数据"]
|
||||||
|
E --> F["读取所有绑定卡的持久化状态"]
|
||||||
|
F --> G["返回设备状态\n保护期状态 + 各绑定卡当前状态 + 最后同步时间"]
|
||||||
|
```
|
||||||
|
|
||||||
|
> **注意**:此接口**不调用网关**,展示的是最近一次轮询/刷新写入的持久化数据。
|
||||||
|
> 如需获取最新数据,请先调用 `POST /refresh` 接口,再查询此接口。
|
||||||
|
|
||||||
|
### 5.7 虚流量计算规则
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph 创建["套餐创建时 - 存储比例"]
|
||||||
|
A1["RealDataMB = 10G 真总流量"] --> C1
|
||||||
|
A2["VirtualDataMB = 9G 虚总流量/停机阈值"] --> C1
|
||||||
|
C1["virtual_ratio = RealDataMB / VirtualDataMB\n= 10 / 9 ≈ 1.111\n存储到 tb_package.virtual_ratio"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 停机["系统内部 - 停机判断"]
|
||||||
|
D1["真已使用\nCurrentMonthUsageMB"] --> E1{"真已使用 >= VirtualDataMB?"}
|
||||||
|
D2["VirtualDataMB = 9G"] --> E1
|
||||||
|
E1 -->|是| F1["触发停机"]
|
||||||
|
E1 -->|否| F2["正常运行"]
|
||||||
|
end
|
||||||
|
|
||||||
|
subgraph 展示["客户端展示 - 流量换算"]
|
||||||
|
G1["真已使用 = 9G"] --> H1
|
||||||
|
H1["展示已使用 = 真已使用 x virtual_ratio\n= 9G x 1.111 = 10G"]
|
||||||
|
G2["展示总量 = RealDataMB = 10G"]
|
||||||
|
H1 --> I1["客户看到 已用10G/共10G = 100% 已停机"]
|
||||||
|
end
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、虚流量计算规则详解
|
||||||
|
|
||||||
|
### 6.1 概念说明
|
||||||
|
|
||||||
|
| 字段 | 含义 | 来源 |
|
||||||
|
|------|------|------|
|
||||||
|
| 真总流量(RealDataMB) | 套餐标称总流量,用户购买的名义流量 | `Package.real_data_mb` |
|
||||||
|
| 虚总流量(VirtualDataMB) | 停机阈值,始终小于真总流量 | `Package.virtual_data_mb` |
|
||||||
|
| virtual_ratio | 换算比例 = RealDataMB / VirtualDataMB | `Package.virtual_ratio`(套餐创建时存储) |
|
||||||
|
| 真已使用 | 网关报告的实际用量 | `IotCard.current_month_usage_mb` |
|
||||||
|
| 展示已使用 | 客户看到的用量 = 真已使用 × virtual_ratio | 计算得出 |
|
||||||
|
| 展示剩余 | 客户看到的剩余 = 真总流量 − 展示已使用 | 计算得出 |
|
||||||
|
|
||||||
|
### 6.2 设计意图
|
||||||
|
|
||||||
|
虚总流量(VirtualDataMB)是系统内部的停机保护阈值。由于网关数据同步存在延迟,若以真总流量作为停机阈值,客户可能在用完 10G 后继续用到 10.5G 才被停机,产生超用。因此系统设置一个比真总流量略小的虚总流量(如 9G)作为实际停机阈值,保证不超用。
|
||||||
|
|
||||||
|
客户端展示时,系统将真实用量按比例换算回真总流量的尺度,使客户的体感与购买的套餐一致:
|
||||||
|
- 当真用量达到 9G(VirtualDataMB)时,卡被停机
|
||||||
|
- 此时展示用量 = 9G × (10G/9G) = 10G,客户看到"已用 10G / 共 10G = 100%"
|
||||||
|
|
||||||
|
### 6.3 计算示例
|
||||||
|
|
||||||
|
| 场景 | 真总 | 虚总(停机阈值) | 真已使用 | 展示已使用 | 展示剩余 | 是否停机 |
|
||||||
|
|------|------|----------------|---------|-----------|---------|---------|
|
||||||
|
| 刚开始 | 10G | 9G | 0G | 0G | 10G | 否 |
|
||||||
|
| 用了一半 | 10G | 9G | 4.5G | 5G | 5G | 否 |
|
||||||
|
| 接近阈值 | 10G | 9G | 8G | ≈8.89G | ≈1.11G | 否 |
|
||||||
|
| 触发停机 | 10G | 9G | 9G | 10G | 0G | **是** |
|
||||||
|
|
||||||
|
### 6.4 未启用虚流量时
|
||||||
|
|
||||||
|
当 `Package.enable_virtual_data = false` 时:
|
||||||
|
- `virtual_ratio = 1.0`
|
||||||
|
- 停机阈值 = 真总流量(RealDataMB)
|
||||||
|
- 展示已使用 = 真已使用(无换算)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、用户的思考与担忧(已全部解决)
|
||||||
|
|
||||||
|
### 7.1 关于接口粒度
|
||||||
|
|
||||||
|
**已确认**:resolve 返回中等版本,多接口组合,前端按需调用。
|
||||||
|
|
||||||
|
### 7.2 关于网关封装程度
|
||||||
|
|
||||||
|
**已确认**:
|
||||||
|
- realtime-status:只查持久化数据,不调用网关
|
||||||
|
- refresh:调用网关并写回 DB,更新缓存字段
|
||||||
|
|
||||||
|
### 7.3 关于停复机去重
|
||||||
|
|
||||||
|
**已确认**:所有停复机统一迁移到 assets 路径,旧接口直接删除。
|
||||||
|
|
||||||
|
### 7.4 关于虚拟号
|
||||||
|
|
||||||
|
**已确认**:
|
||||||
|
- 卡的虚拟号给客服和客户用
|
||||||
|
- 人工填写/批量导入,无格式规范,允许修改
|
||||||
|
- 设备 device_no 全量重命名为 virtual_no
|
||||||
|
- 导入重复时全批失败,告知具体冲突数据
|
||||||
|
|
||||||
|
### 7.5 关于套餐查询
|
||||||
|
|
||||||
|
**已确认**:套餐查询分两个接口,历史套餐接口包含当前套餐,同时单独提供当前套餐接口。
|
||||||
|
|
||||||
|
### 7.6 关于停复机保护期
|
||||||
|
|
||||||
|
**已确认**:保护期 1 小时,Redis 存储,未实名卡不参与,stop 保护期内禁止手动复机,start 保护期内允许手动停机。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、设计决策确认清单
|
||||||
|
|
||||||
|
| 序号 | 问题 | 确认结果 |
|
||||||
|
|-----|------|---------|
|
||||||
|
| 1 | resolve 返回数据范围 | 中等版本,含状态/套餐/流量/绑定信息/保护期 |
|
||||||
|
| 2 | realtime-status 和 refresh 区别 | realtime-status=查持久化数据(轻量),refresh=调网关写回DB |
|
||||||
|
| 3 | 实时状态封装 | 持久化数据展示,不调网关 |
|
||||||
|
| 4 | 手动刷新复用 SyncCardStatusFromGateway | 是,设备时批量刷新所有绑定卡 |
|
||||||
|
| 5 | 停复机统一 | 统一迁移到 /assets 路径,旧接口直接删除 |
|
||||||
|
| 6 | 卡虚拟号生成方式 | 人工填写/批量导入,无格式规范 |
|
||||||
|
| 7 | 废弃接口处理 | 直接删除 |
|
||||||
|
| 8 | 套餐查询接口 | 两个接口:历史套餐列表 + 当前套餐详情 |
|
||||||
|
| 9 | 权限不足的返回 | HTTP 403,明确告知无权限 |
|
||||||
|
| 10 | 保护期时长 | 1 小时,硬编码常量 |
|
||||||
|
| 11 | 虚流量计算 | virtual_ratio=RealDataMB/VirtualDataMB,套餐创建时存储 |
|
||||||
|
| 12 | device_no 改名 | 全量改为 virtual_no,数据库+代码全部更新 |
|
||||||
|
| 13 | 设备下卡列表 | 包含所有状态的卡(含未实名、已停用) |
|
||||||
|
| 14 | 卡绑定设备被软删除时 | 视为独立卡,不填充绑定信息 |
|
||||||
|
| 15 | 未实名卡参与停复机 | 不参与,永远是停机状态,保护期跳过 |
|
||||||
|
| 16 | 数据权限规则 | 代理:仅自己及下级店铺,平台账号:所有资产 |
|
||||||
|
| 17 | 查找失败 404 还是 403 | 资产不存在=404,有资产但无权限=403 |
|
||||||
|
| 18 | 设备卡列表排序 | 无要求 |
|
||||||
|
| 19 | resolve 中 current_package 无套餐时 | 返回空字符串/0 |
|
||||||
|
| 20 | 虚拟号唯一索引 | 需要,允许为空,允许手动修改 |
|
||||||
|
| 21 | 企业账号能否用 resolve | 暂不支持;企业账号未来开新接口 |
|
||||||
|
| 22 | 接口 #2(按主键查详情)的设计 | 已确认删除,与 resolve 功能重叠,无独立价值 |
|
||||||
|
| 23 | resolve 响应是否含 ICCID | 是,card 类型时返回 ICCID,供停复机接口使用 |
|
||||||
|
| 24 | 设备批量停机部分失败策略 | 仍设置 Redis 保护期;已成功停机的卡不回滚;失败的卡记录日志 |
|
||||||
|
| 25 | 流量数据汇总逻辑 | 统一用专门汇总逻辑,从 PackageUsage 读取;设备级套餐汇总所有绑定卡 |
|
||||||
|
| 26 | 套餐历史列表排序和范围 | 按创建时间倒序,不分页,包含所有状态(含 status=4 已失效) |
|
||||||
|
| 27 | current-package 多套餐时返回哪个 | 返回主套餐(master_usage_id IS NULL) |
|
||||||
|
| 28 | 轮询系统保护期检查实现方式 | 新增独立的第四种轮询任务类型,不修改现有三种任务 |
|
||||||
|
| 29 | 卡虚拟号导入规则 | 只允许为空白虚拟号的卡填入;与现存数据重复则全批失败 |
|
||||||
|
| 30 | 设备批量刷新频率限制 | 需要;Redis 限频,同一设备冷却期(建议 30 秒)内不允许重复触发 |
|
||||||
|
| 31 | PersonalCustomerDevice.device_no 改名 | 是,统一改为 virtual_no,与 tb_device 保持语义一致 |
|
||||||
|
| 32 | DeviceCardInfo 需要 last_sync_time | 是,添加 last_sync_at 字段 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、轮询系统补充说明
|
||||||
|
|
||||||
|
### 9.1 整体架构
|
||||||
|
|
||||||
|
轮询系统是君鸿卡管系统维护卡数据实时性的核心机制:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────────────┐
|
||||||
|
│ Worker 服务(后台) │
|
||||||
|
├─────────────────────────────────────────────────────────────────────┤
|
||||||
|
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
|
||||||
|
│ │ Scheduler │────▶│ Asynq 队列 │────▶│ Handler │ │
|
||||||
|
│ │ (调度器) │ │ (任务队列) │ │ (处理器) │ │
|
||||||
|
│ └──────────────┘ └──────────────┘ └──────────────┘ │
|
||||||
|
│ │ │ │
|
||||||
|
│ │ 定时循环 (每秒) │ │
|
||||||
|
│ ▼ ▼ │
|
||||||
|
│ ┌──────────────────────────────────────────────────────────────┐ │
|
||||||
|
│ │ Redis Sorted Set 轮询队列 │ │
|
||||||
|
│ │ - polling:queue:realname (实名检查) │ │
|
||||||
|
│ │ - polling:queue:carddata (流量检查) │ │
|
||||||
|
│ │ - polling:queue:package (套餐检查) │ │
|
||||||
|
│ │ - polling:queue:protect (保护期一致性检查) │ │
|
||||||
|
│ └──────────────────────────────────────────────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────────────┘
|
||||||
|
│
|
||||||
|
│ 调用网关 API
|
||||||
|
▼
|
||||||
|
┌──────────────────────┐
|
||||||
|
│ Gateway 网关 │
|
||||||
|
│ (第三方运营商) │
|
||||||
|
└──────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
### 9.2 四种轮询任务
|
||||||
|
|
||||||
|
| 任务类型 | 触发频率 | 作用 | 更新字段 |
|
||||||
|
|---------|---------|------|---------|
|
||||||
|
| **实名检查** | 默认 5 分钟 | 调用网关查实名状态 | real_name_status |
|
||||||
|
| **流量检查** | 默认 10 分钟 | 调用网关查流量,更新套餐 | current_month_usage_mb |
|
||||||
|
| **套餐检查** | 默认 10 分钟 | 检查是否超额,触发停机 | network_status |
|
||||||
|
| **保护期检查** | 同流量检查频率 | 检查绑定设备保护期,强制同步卡的网络状态 | network_status |
|
||||||
|
|
||||||
|
> **第四种任务设计说明**:保护期一致性检查封装为独立任务类型,不嵌入现有三种任务内部。只检查"已绑定设备且设备当前有保护期"的卡,范围小,可与流量检查同频触发。
|
||||||
|
|
||||||
|
### 9.3 关键特点
|
||||||
|
|
||||||
|
1. **启动时渐进式初始化**:系统启动时把卡分批加载到 Redis 队列(每批 10 万张)
|
||||||
|
2. **按时间排序**:Redis Sorted Set 的 score 是下次检查的时间戳,到期自动被调度器取出
|
||||||
|
3. **并发控制**:通过 Redis 信号量限制并发数(默认 50),防止打爆网关
|
||||||
|
4. **失败重试**:任务失败后重新入队
|
||||||
|
5. **缓存优化**:优先从 Redis 读取卡信息,避免频繁查 DB
|
||||||
|
|
||||||
|
### 9.4 与手动刷新接口的关系
|
||||||
|
|
||||||
|
- **轮询是后台自动跑**:所有卡都会按配置的时间间隔被检查,保证日常数据更新
|
||||||
|
- **手动刷新是前台客服主动用**:只更新这一张卡(或设备的所有绑定卡),满足客户急用场景
|
||||||
|
- **两者是互补关系**:轮询保证数据不会太旧,手动刷新满足实时性要求高的场景
|
||||||
|
|
||||||
|
### 9.5 与设备保护期的交互
|
||||||
|
|
||||||
|
轮询系统在处理设备的绑定卡时,需要检查设备是否有保护期(见 5.4 流程图):
|
||||||
|
- 发现设备有 stop 保护期,且卡为开机状态 → 强制调网关停机
|
||||||
|
- 发现设备有 start 保护期,且卡为停机状态 → 强制调网关复机
|
||||||
|
- 未实名的卡跳过,不参与保护期逻辑
|
||||||
|
|
||||||
|
关键代码位置:
|
||||||
|
- `internal/task/polling_handler.go` - 轮询任务处理器(需新增独立的第四种任务:保护期一致性检查处理函数)
|
||||||
|
- `pkg/constants/redis.go` - 需新增 `RedisDeviceProtectKey()` 函数
|
||||||
|
|
||||||
|
### 9.6 涉及的关键代码
|
||||||
|
|
||||||
|
- `internal/polling/scheduler.go` - 轮询调度器(把卡加入队列)
|
||||||
|
- `internal/task/polling_handler.go` - 任务处理器(实际调网关更新数据)
|
||||||
|
- `internal/service/iot_card/service.go:799` - SyncCardStatusFromGateway 方法
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、下一步行动
|
||||||
|
|
||||||
|
### 10.1 当前阶段
|
||||||
|
|
||||||
|
**设计讨论** - 已完成,所有关键决策已确认,可进入 openspec 提案阶段
|
||||||
|
|
||||||
|
### 10.2 进入 openspec 提案后的任务拆分建议
|
||||||
|
|
||||||
|
**数据层(优先)**:
|
||||||
|
1. 数据库迁移:设备表 `device_no` → `virtual_no`(同步更新 `tb_personal_customer_device.device_no` → `virtual_no`)
|
||||||
|
2. 数据库迁移:卡表新增 `virtual_no` 字段(唯一索引,允许空)
|
||||||
|
3. 数据库迁移:套餐表新增 `virtual_ratio` 字段
|
||||||
|
4. 更新 Device Model 和所有引用 `device_no` 的代码(全量替换,含 PersonalCustomerDevice)
|
||||||
|
5. 更新 Package Service,创建/更新套餐时自动计算并存储 `virtual_ratio`
|
||||||
|
|
||||||
|
**接口层(依次实现)**:
|
||||||
|
6. 实现资产入口 `GET /assets/resolve/:identifier`
|
||||||
|
7. 实现当前状态查询 `GET /assets/:type/:id/realtime-status`
|
||||||
|
8. 实现手动刷新 `POST /assets/:type/:id/refresh`(含设备批量刷新 + Redis 限频)
|
||||||
|
9. 实现套餐记录查询 `GET /assets/:type/:id/packages`
|
||||||
|
10. 实现当前套餐查询 `GET /assets/:type/:id/current-package`
|
||||||
|
11. 实现设备停机 `POST /assets/device/:id/stop`(含保护期逻辑 + 部分失败策略)
|
||||||
|
12. 实现设备复机 `POST /assets/device/:id/start`(含保护期逻辑)
|
||||||
|
13. 实现卡停机 `POST /assets/card/:iccid/stop`(含保护期检查)
|
||||||
|
14. 实现卡复机 `POST /assets/card/:iccid/start`(含保护期检查)
|
||||||
|
|
||||||
|
**轮询系统**:
|
||||||
|
15. 新增第四种轮询任务:保护期一致性检查(独立任务类型,不修改现有三种任务内部逻辑)
|
||||||
|
|
||||||
|
**清理**:
|
||||||
|
16. 删除废弃的停复机接口(见 3.6 废弃清单)
|
||||||
|
17. 丰富现有卡/设备 DTO(IotCardDetailResponse、DeviceResponse)
|
||||||
|
18. 更新 API 文档生成器(docs.go 和 gendocs/main.go)
|
||||||
|
|
||||||
|
### 10.3 涉及的关键代码文件
|
||||||
|
|
||||||
|
**Handler 层**:
|
||||||
|
- `internal/handler/admin/iot_card.go`
|
||||||
|
- `internal/handler/admin/device.go`
|
||||||
|
- `internal/handler/h5/enterprise_device.go`(待删除的废弃接口)
|
||||||
|
|
||||||
|
**Service 层**:
|
||||||
|
- `internal/service/iot_card/service.go`(含 SyncCardStatusFromGateway:799)
|
||||||
|
- `internal/service/iot_card/stop_resume_service.go`(停复机逻辑,需扩展)
|
||||||
|
- `internal/service/device/service.go`(含 GetByIdentifier:177)
|
||||||
|
- `internal/service/package/customer_view_service.go`(套餐聚合,需复用)
|
||||||
|
- `internal/service/package/service.go`(创建套餐时存储 virtual_ratio)
|
||||||
|
|
||||||
|
**Store 层**:
|
||||||
|
- `internal/store/postgres/device_store.go`(GetByIdentifier:62,改用 virtual_no)
|
||||||
|
- `internal/store/postgres/iot_card_store.go`
|
||||||
|
- `internal/store/postgres/personal_customer_device_store.go`(device_no → virtual_no)
|
||||||
|
|
||||||
|
**Model 层**:
|
||||||
|
- `internal/model/iot_card.go`(新增 virtual_no 字段)
|
||||||
|
- `internal/model/device.go`(device_no → virtual_no)
|
||||||
|
- `internal/model/package.go`(新增 virtual_ratio 字段)
|
||||||
|
- `internal/model/personal_customer_device.go`(device_no → virtual_no)
|
||||||
|
|
||||||
|
**DTO 层**:
|
||||||
|
- `internal/model/dto/iot_card_dto.go`(需重构)
|
||||||
|
- `internal/model/dto/device_dto.go`(需丰富)
|
||||||
|
|
||||||
|
**常量层**:
|
||||||
|
- `pkg/constants/redis.go`(新增 `RedisDeviceProtectKey()` 函数)
|
||||||
|
|
||||||
|
**轮询层**:
|
||||||
|
- `internal/task/polling_handler.go`(新增保护期一致性检查独立任务处理函数)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、附录:关键代码片段
|
||||||
|
|
||||||
|
### 11.1 现有空壳详情 DTO
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/model/dto/iot_card_dto.go:134-136
|
||||||
|
type IotCardDetailResponse struct {
|
||||||
|
StandaloneIotCardResponse // 只是列表响应的空包装
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.2 设备详情 DTO
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/model/dto/device_dto.go:20
|
||||||
|
type DeviceResponse struct {
|
||||||
|
ID uint `json:"id"`
|
||||||
|
DeviceNo string `json:"device_no"` // 改名为 virtual_no
|
||||||
|
// ...
|
||||||
|
BoundCardCount int `json:"bound_card_count"` // 只有数字,需丰富
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.3 设备多字段查找 Store
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/store/postgres/device_store.go:62
|
||||||
|
// 改造后:device_no → virtual_no
|
||||||
|
func (s *Store) GetByIdentifier(db *gorm.DB, identifier string) (*model.Device, error) {
|
||||||
|
var device model.Device
|
||||||
|
err := db.Where("virtual_no = ? OR imei = ? OR sn = ?", identifier, identifier, identifier).
|
||||||
|
First(&device).Error
|
||||||
|
return &device, err
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.4 手动刷新方法(待暴露为接口)
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/service/iot_card/service.go:799
|
||||||
|
func (s *Service) SyncCardStatusFromGateway(ctx context.Context, iccid string) error {
|
||||||
|
// 已有实现,需作为接口暴露,并支持设备批量刷新
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.5 新增 Redis Key 常量
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/constants/redis.go
|
||||||
|
// RedisDeviceProtectKey 设备停复机保护期 Key
|
||||||
|
// action: "stop" 或 "start",TTL = 1 小时
|
||||||
|
func RedisDeviceProtectKey(deviceID uint, action string) string {
|
||||||
|
return fmt.Sprintf("protect:device:%d:%s", deviceID, action)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RedisDeviceRefreshCooldownKey 设备手动刷新冷却期 Key,TTL = 冷却时长(建议 30 秒)
|
||||||
|
func RedisDeviceRefreshCooldownKey(deviceID uint) string {
|
||||||
|
return fmt.Sprintf("refresh:cooldown:device:%d", deviceID)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 11.6 virtual_ratio 计算位置
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/service/package/service.go
|
||||||
|
// 创建/更新套餐时计算并存储 virtual_ratio
|
||||||
|
if pkg.EnableVirtualData && pkg.VirtualDataMB > 0 {
|
||||||
|
pkg.VirtualRatio = float64(pkg.RealDataMB) / float64(pkg.VirtualDataMB)
|
||||||
|
} else {
|
||||||
|
pkg.VirtualRatio = 1.0
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> **文档结束**
|
||||||
|
>
|
||||||
|
> 所有设计决策已确认,可进入 openspec 提案阶段。
|
||||||
239
docs/wechat-config-management/功能总结.md
Normal file
239
docs/wechat-config-management/功能总结.md
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
# 微信参数配置管理功能
|
||||||
|
|
||||||
|
## 功能概述
|
||||||
|
|
||||||
|
在管理后台支持多套微信支付配置的 CRUD 管理,每套配置代表一套完整的"微信身份"(公众号 OAuth + 小程序 OAuth + 支付凭证),支持全局唯一激活约束和秒级切换。同时集成富友支付 SDK,作为微信直连的备选渠道。
|
||||||
|
|
||||||
|
### 背景与动机
|
||||||
|
|
||||||
|
原有微信相关参数(公众号 OAuth、小程序、支付凭证)硬编码在环境变量中,只有一套配置,无法动态切换。业务上微信公众号/小程序随时可能被封禁,需要在管理后台**秒级切换**到备用配置恢复 OAuth 登录和支付能力。同时需要接入富友支付作为备选通道,降低对微信直连的单一依赖。
|
||||||
|
|
||||||
|
## 核心设计
|
||||||
|
|
||||||
|
### 配置切换流程
|
||||||
|
|
||||||
|
```
|
||||||
|
管理员激活新配置 POST /api/admin/wechat-configs/:id/activate
|
||||||
|
│
|
||||||
|
├─ ① BEGIN 事务
|
||||||
|
│ ├─ UPDATE tb_wechat_config SET is_active=false WHERE is_active=true
|
||||||
|
│ └─ UPDATE tb_wechat_config SET is_active=true WHERE id=:id
|
||||||
|
├─ ② COMMIT
|
||||||
|
├─ ③ DEL Redis "wechat:config:active"(即时生效)
|
||||||
|
└─ ④ 记录审计日志
|
||||||
|
│
|
||||||
|
├─ 新订单 → 使用新配置(记录新的 payment_config_id)
|
||||||
|
└─ 旧订单(待支付)→ 回调时按 payment_config_id 加载旧配置验签
|
||||||
|
└─ 30 分钟超时自动取消
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生效配置缓存策略
|
||||||
|
|
||||||
|
- **Redis Key**:`wechat:config:active`(见 `pkg/constants/redis.go`)
|
||||||
|
- **TTL**:5 分钟(兜底,防 Redis 缓存与 DB 长期不一致)
|
||||||
|
- **主动失效**:激活、停用、更新生效配置、删除配置时主动 DEL 缓存
|
||||||
|
- **空标记**:无生效配置时缓存 `"none"`,TTL 1 分钟,防止缓存穿透
|
||||||
|
- **读取流程**:Redis GET → 命中返回 → MISS → 查 DB → SET 缓存
|
||||||
|
|
||||||
|
### 配置切换时在途订单处理
|
||||||
|
|
||||||
|
- `tb_order`、`tb_asset_recharge_record`、`tb_agent_recharge_record` 均新增 `payment_config_id` 字段(nullable)
|
||||||
|
- 下单时记录当前使用的配置 ID,配置切换后旧订单仍按 `payment_config_id` 加载旧配置验签
|
||||||
|
- 旧待支付订单由现有 30 分钟超时自动取消机制清理
|
||||||
|
- **有待支付订单引用的配置不允许删除**(软删除后仍可用于验签)
|
||||||
|
|
||||||
|
### 支付回调统一分发
|
||||||
|
|
||||||
|
```
|
||||||
|
回调到达
|
||||||
|
│
|
||||||
|
├─ 微信回调 POST /api/callback/wechat-pay
|
||||||
|
│ └─ PowerWeChat SDK 解析 → 取 out_trade_no
|
||||||
|
│
|
||||||
|
└─ 富友回调 POST /api/callback/fuiou-pay
|
||||||
|
└─ GBK→UTF-8 → XML 解析 → 取 mchnt_order_no
|
||||||
|
│
|
||||||
|
└─ 按订单号前缀分发
|
||||||
|
├─ "ORD" → 套餐订单 → orderService.HandlePaymentCallback()
|
||||||
|
├─ "CRCH" → 资产充值 → rechargeService.HandlePaymentCallback()
|
||||||
|
└─ "ARCH" → 代理充值 → agentRechargeService.HandlePaymentCallback()
|
||||||
|
```
|
||||||
|
|
||||||
|
## 接口说明
|
||||||
|
|
||||||
|
### 基础路径
|
||||||
|
|
||||||
|
`/api/admin/wechat-configs`
|
||||||
|
|
||||||
|
**权限要求**:仅超级管理员(`user_type=1`)和平台用户(`user_type=2`)可访问,其他类型返回 `1005`。
|
||||||
|
|
||||||
|
### 接口列表
|
||||||
|
|
||||||
|
| 方法 | 路径 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| POST | `/api/admin/wechat-configs` | 创建配置 |
|
||||||
|
| GET | `/api/admin/wechat-configs` | 查询配置列表(分页+筛选) |
|
||||||
|
| GET | `/api/admin/wechat-configs/active` | 查询当前生效配置 |
|
||||||
|
| GET | `/api/admin/wechat-configs/:id` | 查询配置详情 |
|
||||||
|
| PUT | `/api/admin/wechat-configs/:id` | 更新配置 |
|
||||||
|
| DELETE | `/api/admin/wechat-configs/:id` | 软删除配置 |
|
||||||
|
| POST | `/api/admin/wechat-configs/:id/activate` | 激活配置 |
|
||||||
|
| POST | `/api/admin/wechat-configs/:id/deactivate` | 停用配置 |
|
||||||
|
| POST | `/api/callback/fuiou-pay` | 富友支付回调(无需认证) |
|
||||||
|
|
||||||
|
### 渠道类型(provider_type)
|
||||||
|
|
||||||
|
| 值 | 说明 | 必填支付字段 |
|
||||||
|
|----|------|-------------|
|
||||||
|
| `wechat` | 微信直连 | `wx_mch_id`、`wx_api_v3_key`、`wx_cert_content`、`wx_key_content`、`wx_serial_no`、`wx_notify_url` |
|
||||||
|
| `fuiou` | 富友聚合支付 | `fy_ins_cd`、`fy_mchnt_cd`、`fy_term_id`、`fy_private_key`、`fy_public_key`、`fy_api_url`、`fy_notify_url` |
|
||||||
|
|
||||||
|
### 敏感字段脱敏规则
|
||||||
|
|
||||||
|
接口响应中所有敏感字段均脱敏,数据库明文存储:
|
||||||
|
|
||||||
|
| 字段类型 | 脱敏规则 | 示例 |
|
||||||
|
|---------|---------|------|
|
||||||
|
| Secret/Key(短) | 前4位 + `***` + 后4位 | `abcd***7890` |
|
||||||
|
| 证书/私钥(长) | 仅显示状态 | `[已配置]` / `[未配置]` |
|
||||||
|
|
||||||
|
**更新脱敏字段**:不传或传空字符串 = 保留原值;传新明文值 = 替换。
|
||||||
|
|
||||||
|
### 删除保护规则
|
||||||
|
|
||||||
|
| 条件 | 错误码 | 错误消息 |
|
||||||
|
|------|--------|---------|
|
||||||
|
| 配置 `is_active=true` | `1171` | 不能删除当前生效的支付配置,请先停用 |
|
||||||
|
| 存在待支付订单引用 | `1172` | 该配置存在未完成的支付订单,暂时无法删除 |
|
||||||
|
|
||||||
|
## 富友支付 SDK
|
||||||
|
|
||||||
|
**位置**:`pkg/fuiou/`
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `types.go` | WxPreCreateRequest/Response、NotifyRequest 等 XML 结构体 |
|
||||||
|
| `client.go` | Client 结构体、NewClient、RSA 签名/验签、HTTP 请求(XML+GBK)|
|
||||||
|
| `wxprecreate.go` | WxPreCreate 方法(公众号 JSAPI + 小程序支付下单)|
|
||||||
|
| `notify.go` | VerifyNotify(GBK→UTF-8 + XML 解析 + RSA 验签)、BuildNotifyResponse |
|
||||||
|
|
||||||
|
**签名算法**:字典序排列参数 → GBK 编码 → MD5 哈希 → RSA 签名 → Base64
|
||||||
|
|
||||||
|
**新增依赖**:`golang.org/x/text`(GBK 编解码)
|
||||||
|
|
||||||
|
## 数据库变更
|
||||||
|
|
||||||
|
### 新建表 `tb_wechat_config`(迁移 000078)
|
||||||
|
|
||||||
|
| 字段组 | 字段 | 说明 |
|
||||||
|
|-------|------|------|
|
||||||
|
| 基础信息 | `id`, `name`, `description`, `provider_type`, `is_active` | 配置基础字段 |
|
||||||
|
| 公众号 OAuth | `oa_app_id`, `oa_app_secret`, `oa_token`, `oa_aes_key`, `oa_oauth_redirect_url` | 公众号相关 |
|
||||||
|
| 小程序 OAuth | `miniapp_app_id`, `miniapp_app_secret` | 小程序相关 |
|
||||||
|
| 微信直连 | `wx_mch_id`, `wx_api_v3_key`, `wx_api_v2_key`, `wx_cert_content`, `wx_key_content`, `wx_serial_no`, `wx_notify_url` | provider_type=wechat 时使用 |
|
||||||
|
| 富友 | `fy_ins_cd`, `fy_mchnt_cd`, `fy_term_id`, `fy_private_key`, `fy_public_key`, `fy_api_url`, `fy_notify_url` | provider_type=fuiou 时使用 |
|
||||||
|
| 审计 | `creator`, `updater`, `created_at`, `updated_at`, `deleted_at` | 标准审计字段 |
|
||||||
|
|
||||||
|
### 新增字段
|
||||||
|
|
||||||
|
| 表 | 字段 | 类型 | 迁移文件 |
|
||||||
|
|----|------|------|---------|
|
||||||
|
| `tb_order` | `payment_config_id` | bigint, nullable | 000079 |
|
||||||
|
| `tb_asset_recharge_record` | `payment_config_id` | bigint, nullable | 000080 |
|
||||||
|
| `tb_agent_recharge_record` | `payment_config_id` | bigint, nullable | 000081 |
|
||||||
|
|
||||||
|
## 新增错误码
|
||||||
|
|
||||||
|
| 错误码 | 常量 | 说明 |
|
||||||
|
|--------|------|------|
|
||||||
|
| 1170 | `CodeWechatConfigNotFound` | 微信支付配置不存在 |
|
||||||
|
| 1171 | `CodeWechatConfigActive` | 不能删除/操作当前生效的支付配置 |
|
||||||
|
| 1172 | `CodeWechatConfigHasPendingOrders` | 该配置存在未完成的支付订单 |
|
||||||
|
| 1173 | `CodeFuiouPayFailed` | 富友支付失败 |
|
||||||
|
| 1174 | `CodeFuiouCallbackInvalid` | 富友回调验签失败 |
|
||||||
|
| 1175 | `CodeNoPaymentConfig` | 当前无可用的支付配置 |
|
||||||
|
|
||||||
|
## 审计日志
|
||||||
|
|
||||||
|
以下操作均记录审计日志(异步写入,失败不影响业务):
|
||||||
|
|
||||||
|
| 操作 | operation_type | 说明 |
|
||||||
|
|------|---------------|------|
|
||||||
|
| 创建配置 | `create` | after_data 存脱敏后配置 |
|
||||||
|
| 更新配置 | `update` | before/after_data 均脱敏 |
|
||||||
|
| 删除配置 | `delete` | before_data 存脱敏后配置 |
|
||||||
|
| 激活配置 | `activate` | before_data=旧配置,after_data=新配置 |
|
||||||
|
| 停用配置 | `deactivate` | before/after_data 存状态变更 |
|
||||||
|
|
||||||
|
## 涉及文件
|
||||||
|
|
||||||
|
### 新增文件
|
||||||
|
|
||||||
|
| 层级 | 文件 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| 模型 | `internal/model/wechat_config.go` | WechatConfig 模型、渠道类型常量 |
|
||||||
|
| DTO | `internal/model/dto/wechat_config_dto.go` | CRUD 请求/响应 DTO、脱敏方法 |
|
||||||
|
| Store | `internal/store/postgres/wechat_config_store.go` | CRUD + 激活/停用 + 统计 |
|
||||||
|
| Service | `internal/service/wechat_config/service.go` | 业务逻辑、缓存管理、删除保护 |
|
||||||
|
| Handler | `internal/handler/admin/wechat_config.go` | 8 个 Handler 方法 |
|
||||||
|
| 路由 | `internal/routes/wechat_config.go` | 路由注册(含平台权限中间件) |
|
||||||
|
| SDK | `pkg/fuiou/types.go` | 富友 XML 结构体 |
|
||||||
|
| SDK | `pkg/fuiou/client.go` | 富友 HTTP 客户端、签名/验签 |
|
||||||
|
| SDK | `pkg/fuiou/wxprecreate.go` | 富友支付下单 |
|
||||||
|
| SDK | `pkg/fuiou/notify.go` | 富友回调验签 |
|
||||||
|
| 迁移 | `migrations/000078_create_wechat_config_table.up.sql` | 创建 tb_wechat_config 表 |
|
||||||
|
| 迁移 | `migrations/000079_add_payment_config_id_to_order.up.sql` | tb_order 新增字段 |
|
||||||
|
| 迁移 | `migrations/000080_add_payment_config_id_to_asset_recharge.up.sql` | tb_asset_recharge_record 新增字段 |
|
||||||
|
| 迁移 | `migrations/000081_add_payment_config_id_to_agent_recharge.up.sql` | tb_agent_recharge_record 新增字段 |
|
||||||
|
|
||||||
|
### 修改文件
|
||||||
|
|
||||||
|
| 文件 | 变更说明 |
|
||||||
|
|------|---------|
|
||||||
|
| `internal/model/order.go` | 新增 `PaymentConfigID *uint` 字段 |
|
||||||
|
| `internal/model/asset_wallet.go` | 新增 `PaymentConfigID *uint` 字段 |
|
||||||
|
| `internal/handler/callback/payment.go` | 支持富友回调 + 按订单前缀分发 + 按 payment_config_id 验签 |
|
||||||
|
| `internal/routes/order.go` | 新增 `/api/callback/fuiou-pay` 路由 |
|
||||||
|
| `internal/service/order/service.go` | 注入 wechatConfigService、下单时记录 payment_config_id |
|
||||||
|
| `internal/bootstrap/` 系列 | 注册 WechatConfigStore/Service/Handler |
|
||||||
|
| `cmd/api/docs.go` / `cmd/gendocs/main.go` | 注册 WechatConfigHandler |
|
||||||
|
|
||||||
|
### 删除/精简文件(YAML 支付方案遗留清理)
|
||||||
|
|
||||||
|
| 文件 | 变更说明 |
|
||||||
|
|------|---------|
|
||||||
|
| `pkg/config/config.go` | 删除 `PaymentConfig` 结构体 + `WechatConfig.Payment` 字段 |
|
||||||
|
| `pkg/config/defaults/config.yaml` | 删除 `wechat.payment:` 整个配置节 |
|
||||||
|
| `pkg/wechat/config.go` | 删除 `NewPaymentApp()` 函数(YAML/CertPath 方式已被 DB Base64 方案替代) |
|
||||||
|
| `cmd/api/main.go` | 删除 `validateWechatConfig` 中所有 `wechatCfg.Payment.*` 相关校验代码 |
|
||||||
|
|
||||||
|
## 常量定义
|
||||||
|
|
||||||
|
```go
|
||||||
|
// pkg/constants/wallet.go(Card* 重命名为 Asset*,旧名保留为废弃别名)
|
||||||
|
AssetWalletResourceTypeIotCard // 原 CardWalletResourceTypeIotCard
|
||||||
|
AssetWalletResourceTypeDevice // 原 CardWalletResourceTypeDevice
|
||||||
|
AssetRechargeOrderPrefix // "CRCH"(原 CardRechargeOrderPrefix)
|
||||||
|
AssetRechargeMinAmount // 最小充值金额(分)
|
||||||
|
AssetRechargeMaxAmount // 最大充值金额(分)
|
||||||
|
|
||||||
|
// pkg/constants/redis.go
|
||||||
|
RedisWechatConfigActiveKey() // "wechat:config:active"
|
||||||
|
|
||||||
|
// internal/model/wechat_config.go
|
||||||
|
ProviderTypeWechat = "wechat" // 微信直连
|
||||||
|
ProviderTypeFuiou = "fuiou" // 富友
|
||||||
|
```
|
||||||
|
|
||||||
|
## 已知限制(留桩)
|
||||||
|
|
||||||
|
以下功能本次**未实现**,待后续会话补全:
|
||||||
|
|
||||||
|
- **客户端支付发起**:`WechatPayJSAPI`、`WechatPayH5`、`FuiouPayJSAPI`、`FuiouPayMiniApp` 均为留桩(返回"暂未实现"错误或 TODO 注释),当前仍保留 `wechatPayment` 单例注入
|
||||||
|
- **OAuth 配置动态加载**:`OfficialAccountService` 仍从环境变量读取,`tb_wechat_config` 中的 `oa_*` 字段仅存储,待 H5/小程序重构时切换
|
||||||
|
|
||||||
|
## 部署注意事项
|
||||||
|
|
||||||
|
1. 执行数据库迁移(000078~000081)后,现有数据不受影响(新字段均为 nullable)
|
||||||
|
2. 原环境变量 `JUNHONG_WECHAT_PAYMENT_*` 系列已不再读取,可清理
|
||||||
|
3. 首次上线后,需要在管理后台手动创建并激活一个微信配置,否则第三方支付功能处于禁用状态(系统自动降级为仅支持钱包/线下支付)
|
||||||
1205
docs/前端接口变更说明.md
Normal file
1205
docs/前端接口变更说明.md
Normal file
File diff suppressed because it is too large
Load Diff
6
go.mod
6
go.mod
@@ -1,6 +1,6 @@
|
|||||||
module github.com/break/junhong_cmp_fiber
|
module github.com/break/junhong_cmp_fiber
|
||||||
|
|
||||||
go 1.25
|
go 1.25.0
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38
|
github.com/ArtisanCloud/PowerWeChat/v3 v3.4.38
|
||||||
@@ -20,6 +20,7 @@ require (
|
|||||||
github.com/xuri/excelize/v2 v2.8.1
|
github.com/xuri/excelize/v2 v2.8.1
|
||||||
go.uber.org/zap v1.27.1
|
go.uber.org/zap v1.27.1
|
||||||
golang.org/x/crypto v0.47.0
|
golang.org/x/crypto v0.47.0
|
||||||
|
golang.org/x/text v0.35.0
|
||||||
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
gopkg.in/natefinch/lumberjack.v2 v2.2.1
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
gorm.io/datatypes v1.2.7
|
gorm.io/datatypes v1.2.7
|
||||||
@@ -88,9 +89,8 @@ require (
|
|||||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
golang.org/x/arch v0.0.0-20210923205945-b76863e36670 // indirect
|
||||||
golang.org/x/net v0.48.0 // indirect
|
golang.org/x/net v0.48.0 // indirect
|
||||||
golang.org/x/sync v0.19.0 // indirect
|
golang.org/x/sync v0.20.0 // indirect
|
||||||
golang.org/x/sys v0.40.0 // indirect
|
golang.org/x/sys v0.40.0 // indirect
|
||||||
golang.org/x/text v0.33.0 // indirect
|
|
||||||
golang.org/x/time v0.14.0 // indirect
|
golang.org/x/time v0.14.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||||
|
|||||||
8
go.sum
8
go.sum
@@ -298,15 +298,15 @@ golang.org/x/image v0.14.0 h1:tNgSxAFe3jC4uYqvZdTr84SZoM1KfwdC9SKIFrLjFn4=
|
|||||||
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
golang.org/x/image v0.14.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE=
|
||||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
|
||||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ=
|
||||||
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||||
golang.org/x/text v0.33.0 h1:B3njUFyqtHDUI5jMn1YIr5B0IE2U0qck04r6d4KPAxE=
|
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
|
||||||
golang.org/x/text v0.33.0/go.mod h1:LuMebE6+rBincTi9+xWTY8TztLzKHc/9C1uBCG27+q8=
|
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
|
||||||
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
|
||||||
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
|
|||||||
@@ -24,6 +24,5 @@ type Dependencies struct {
|
|||||||
QueueClient *queue.Client // Asynq 任务队列客户端
|
QueueClient *queue.Client // Asynq 任务队列客户端
|
||||||
StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil)
|
StorageService *storage.Service // 对象存储服务(可选,配置缺失时为 nil)
|
||||||
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil)
|
GatewayClient *gateway.Client // Gateway API 客户端(可选,配置缺失时为 nil)
|
||||||
WechatOfficialAccount wechat.OfficialAccountServiceInterface // 微信公众号服务(可选)
|
|
||||||
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
|
WechatPayment wechat.PaymentServiceInterface // 微信支付服务(可选)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,41 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||||
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
|
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
clientOrderSvc "github.com/break/junhong_cmp_fiber/internal/service/client_order"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
"github.com/go-playground/validator/v10"
|
"github.com/go-playground/validator/v10"
|
||||||
)
|
)
|
||||||
|
|
||||||
func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
||||||
validate := validator.New()
|
validate := validator.New()
|
||||||
|
personalCustomerDeviceStore := postgres.NewPersonalCustomerDeviceStore(deps.DB)
|
||||||
|
assetWalletStore := postgres.NewAssetWalletStore(deps.DB, deps.Redis)
|
||||||
|
packageStore := postgres.NewPackageStore(deps.DB)
|
||||||
|
shopPackageAllocationStore := postgres.NewShopPackageAllocationStore(deps.DB)
|
||||||
|
iotCardStore := postgres.NewIotCardStore(deps.DB, deps.Redis)
|
||||||
|
deviceStore := postgres.NewDeviceStore(deps.DB, deps.Redis)
|
||||||
|
assetWalletTransactionStore := postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis)
|
||||||
|
assetRechargeStore := postgres.NewAssetRechargeStore(deps.DB, deps.Redis)
|
||||||
|
personalCustomerOpenIDStore := postgres.NewPersonalCustomerOpenIDStore(deps.DB)
|
||||||
|
orderStore := postgres.NewOrderStore(deps.DB, deps.Redis)
|
||||||
|
packageSeriesStore := postgres.NewPackageSeriesStore(deps.DB)
|
||||||
|
shopSeriesAllocationStore := postgres.NewShopSeriesAllocationStore(deps.DB)
|
||||||
|
deviceSimBindingStore := postgres.NewDeviceSimBindingStore(deps.DB, deps.Redis)
|
||||||
|
carrierStore := postgres.NewCarrierStore(deps.DB)
|
||||||
|
clientOrderService := clientOrderSvc.New(
|
||||||
|
svc.Asset,
|
||||||
|
svc.PurchaseValidation,
|
||||||
|
orderStore,
|
||||||
|
assetRechargeStore,
|
||||||
|
assetWalletStore,
|
||||||
|
personalCustomerDeviceStore,
|
||||||
|
personalCustomerOpenIDStore,
|
||||||
|
svc.WechatConfig,
|
||||||
|
packageSeriesStore,
|
||||||
|
shopSeriesAllocationStore,
|
||||||
|
deps.Redis,
|
||||||
|
deps.Logger,
|
||||||
|
)
|
||||||
|
|
||||||
return &Handlers{
|
return &Handlers{
|
||||||
Auth: authHandler.NewHandler(svc.Auth, validate),
|
Auth: authHandler.NewHandler(svc.Auth, validate),
|
||||||
@@ -18,22 +47,27 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
Role: admin.NewRoleHandler(svc.Role, validate),
|
Role: admin.NewRoleHandler(svc.Role, validate),
|
||||||
Permission: admin.NewPermissionHandler(svc.Permission),
|
Permission: admin.NewPermissionHandler(svc.Permission),
|
||||||
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
|
PersonalCustomer: app.NewPersonalCustomerHandler(svc.PersonalCustomer, deps.Logger),
|
||||||
|
ClientAuth: app.NewClientAuthHandler(svc.ClientAuth, deps.Logger),
|
||||||
|
ClientAsset: app.NewClientAssetHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, packageStore, shopPackageAllocationStore, iotCardStore, deviceStore, deps.DB, deps.Logger),
|
||||||
|
ClientWallet: app.NewClientWalletHandler(svc.Asset, personalCustomerDeviceStore, assetWalletStore, assetWalletTransactionStore, assetRechargeStore, svc.Recharge, personalCustomerOpenIDStore, svc.WechatConfig, deps.Redis, deps.Logger, deps.DB, iotCardStore, deviceStore),
|
||||||
|
ClientOrder: app.NewClientOrderHandler(clientOrderService, svc.Asset, orderStore, personalCustomerDeviceStore, iotCardStore, deviceStore, deps.Logger, deps.DB),
|
||||||
|
ClientExchange: app.NewClientExchangeHandler(svc.Exchange),
|
||||||
|
ClientRealname: app.NewClientRealnameHandler(svc.Asset, personalCustomerDeviceStore, iotCardStore, deviceSimBindingStore, carrierStore, deps.GatewayClient, deps.Logger),
|
||||||
|
ClientDevice: app.NewClientDeviceHandler(svc.Asset, personalCustomerDeviceStore, deviceStore, deviceSimBindingStore, iotCardStore, deps.GatewayClient, deps.Logger),
|
||||||
Shop: admin.NewShopHandler(svc.Shop),
|
Shop: admin.NewShopHandler(svc.Shop),
|
||||||
ShopRole: admin.NewShopRoleHandler(svc.Shop),
|
ShopRole: admin.NewShopRoleHandler(svc.Shop),
|
||||||
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
|
AdminAuth: admin.NewAuthHandler(svc.Auth, validate),
|
||||||
H5Auth: h5.NewAuthHandler(svc.Auth, validate),
|
|
||||||
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission),
|
ShopCommission: admin.NewShopCommissionHandler(svc.ShopCommission),
|
||||||
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal),
|
CommissionWithdrawal: admin.NewCommissionWithdrawalHandler(svc.CommissionWithdrawal),
|
||||||
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
|
CommissionWithdrawalSetting: admin.NewCommissionWithdrawalSettingHandler(svc.CommissionWithdrawalSetting),
|
||||||
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
|
Enterprise: admin.NewEnterpriseHandler(svc.Enterprise),
|
||||||
EnterpriseCard: admin.NewEnterpriseCardHandler(svc.EnterpriseCard),
|
EnterpriseCard: admin.NewEnterpriseCardHandler(svc.EnterpriseCard),
|
||||||
EnterpriseDevice: admin.NewEnterpriseDeviceHandler(svc.EnterpriseDevice),
|
EnterpriseDevice: admin.NewEnterpriseDeviceHandler(svc.EnterpriseDevice),
|
||||||
EnterpriseDeviceH5: h5.NewEnterpriseDeviceHandler(svc.EnterpriseDevice),
|
|
||||||
Authorization: admin.NewAuthorizationHandler(svc.Authorization),
|
Authorization: admin.NewAuthorizationHandler(svc.Authorization),
|
||||||
MyCommission: admin.NewMyCommissionHandler(svc.MyCommission),
|
MyCommission: admin.NewMyCommissionHandler(svc.MyCommission),
|
||||||
IotCard: admin.NewIotCardHandler(svc.IotCard, deps.GatewayClient),
|
IotCard: admin.NewIotCardHandler(svc.IotCard),
|
||||||
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
|
IotCardImport: admin.NewIotCardImportHandler(svc.IotCardImport),
|
||||||
Device: admin.NewDeviceHandler(svc.Device, deps.GatewayClient),
|
Device: admin.NewDeviceHandler(svc.Device),
|
||||||
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
|
DeviceImport: admin.NewDeviceImportHandler(svc.DeviceImport),
|
||||||
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
|
AssetAllocationRecord: admin.NewAssetAllocationRecordHandler(svc.AssetAllocationRecord),
|
||||||
Storage: admin.NewStorageHandler(deps.StorageService),
|
Storage: admin.NewStorageHandler(deps.StorageService),
|
||||||
@@ -41,19 +75,22 @@ func initHandlers(svc *services, deps *Dependencies) *Handlers {
|
|||||||
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
|
PackageSeries: admin.NewPackageSeriesHandler(svc.PackageSeries),
|
||||||
Package: admin.NewPackageHandler(svc.Package),
|
Package: admin.NewPackageHandler(svc.Package),
|
||||||
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
|
PackageUsage: admin.NewPackageUsageHandler(svc.PackageDailyRecord),
|
||||||
H5PackageUsage: h5.NewPackageUsageHandler(deps.DB, svc.PackageCustomerView),
|
|
||||||
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
ShopPackageBatchAllocation: admin.NewShopPackageBatchAllocationHandler(svc.ShopPackageBatchAllocation),
|
||||||
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
ShopPackageBatchPricing: admin.NewShopPackageBatchPricingHandler(svc.ShopPackageBatchPricing),
|
||||||
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
|
ShopSeriesGrant: admin.NewShopSeriesGrantHandler(svc.ShopSeriesGrant),
|
||||||
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
|
AdminOrder: admin.NewOrderHandler(svc.Order, validate),
|
||||||
H5Order: h5.NewOrderHandler(svc.Order),
|
AdminExchange: admin.NewExchangeHandler(svc.Exchange, validate),
|
||||||
H5Recharge: h5.NewRechargeHandler(svc.Recharge),
|
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, svc.AgentRecharge, deps.WechatPayment),
|
||||||
PaymentCallback: callback.NewPaymentHandler(svc.Order, svc.Recharge, deps.WechatPayment),
|
|
||||||
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
|
PollingConfig: admin.NewPollingConfigHandler(svc.PollingConfig),
|
||||||
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
|
PollingConcurrency: admin.NewPollingConcurrencyHandler(svc.PollingConcurrency),
|
||||||
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
|
PollingMonitoring: admin.NewPollingMonitoringHandler(svc.PollingMonitoring),
|
||||||
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
|
PollingAlert: admin.NewPollingAlertHandler(svc.PollingAlert),
|
||||||
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
|
PollingCleanup: admin.NewPollingCleanupHandler(svc.PollingCleanup),
|
||||||
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
|
PollingManualTrigger: admin.NewPollingManualTriggerHandler(svc.PollingManualTrigger),
|
||||||
|
Asset: admin.NewAssetHandler(svc.Asset, svc.Device, svc.StopResumeService),
|
||||||
|
AssetLifecycle: admin.NewAssetLifecycleHandler(svc.AssetLifecycle),
|
||||||
|
AssetWallet: admin.NewAssetWalletHandler(svc.AssetWallet),
|
||||||
|
WechatConfig: admin.NewWechatConfigHandler(svc.WechatConfig),
|
||||||
|
AgentRecharge: admin.NewAgentRechargeHandler(svc.AgentRecharge),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ func initMiddlewares(deps *Dependencies, stores *stores) *Middlewares {
|
|||||||
jwtManager := pkgauth.NewJWTManager(cfg.JWT.SecretKey, cfg.JWT.TokenDuration)
|
jwtManager := pkgauth.NewJWTManager(cfg.JWT.SecretKey, cfg.JWT.TokenDuration)
|
||||||
|
|
||||||
// 创建个人客户认证中间件
|
// 创建个人客户认证中间件
|
||||||
personalAuthMiddleware := middleware.NewPersonalAuthMiddleware(jwtManager, deps.Logger)
|
personalAuthMiddleware := middleware.NewPersonalAuthMiddleware(jwtManager, deps.Redis, deps.Logger)
|
||||||
|
|
||||||
// 创建 Token Manager(用于后台和H5认证)
|
// 创建 Token Manager(用于后台和H5认证)
|
||||||
accessTTL := time.Duration(cfg.JWT.AccessTokenTTL) * time.Second
|
accessTTL := time.Duration(cfg.JWT.AccessTokenTTL) * time.Second
|
||||||
@@ -32,13 +32,9 @@ func initMiddlewares(deps *Dependencies, stores *stores) *Middlewares {
|
|||||||
// 创建后台认证中间件(传入 ShopStore 以支持预计算下级店铺 ID)
|
// 创建后台认证中间件(传入 ShopStore 以支持预计算下级店铺 ID)
|
||||||
adminAuthMiddleware := createAdminAuthMiddleware(tokenManager, stores.Shop)
|
adminAuthMiddleware := createAdminAuthMiddleware(tokenManager, stores.Shop)
|
||||||
|
|
||||||
// 创建H5认证中间件(传入 ShopStore 以支持预计算下级店铺 ID)
|
|
||||||
h5AuthMiddleware := createH5AuthMiddleware(tokenManager, stores.Shop)
|
|
||||||
|
|
||||||
return &Middlewares{
|
return &Middlewares{
|
||||||
PersonalAuth: personalAuthMiddleware,
|
PersonalAuth: personalAuthMiddleware,
|
||||||
AdminAuth: adminAuthMiddleware,
|
AdminAuth: adminAuthMiddleware,
|
||||||
H5Auth: h5AuthMiddleware,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -68,29 +64,3 @@ func createAdminAuthMiddleware(tokenManager *pkgauth.TokenManager, shopStore pkg
|
|||||||
ShopStore: shopStore,
|
ShopStore: shopStore,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func createH5AuthMiddleware(tokenManager *pkgauth.TokenManager, shopStore pkgmiddleware.AuthShopStoreInterface) fiber.Handler {
|
|
||||||
return pkgmiddleware.Auth(pkgmiddleware.AuthConfig{
|
|
||||||
TokenValidator: func(token string) (*pkgmiddleware.UserContextInfo, error) {
|
|
||||||
tokenInfo, err := tokenManager.ValidateAccessToken(context.Background(), token)
|
|
||||||
if err != nil {
|
|
||||||
return nil, errors.New(errors.CodeInvalidToken, "认证令牌无效或已过期")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 检查用户类型:H5 允许 Agent(3), Enterprise(4)
|
|
||||||
if tokenInfo.UserType != constants.UserTypeAgent &&
|
|
||||||
tokenInfo.UserType != constants.UserTypeEnterprise {
|
|
||||||
return nil, errors.New(errors.CodeForbidden, "权限不足")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &pkgmiddleware.UserContextInfo{
|
|
||||||
UserID: tokenInfo.UserID,
|
|
||||||
UserType: tokenInfo.UserType,
|
|
||||||
ShopID: tokenInfo.ShopID,
|
|
||||||
EnterpriseID: tokenInfo.EnterpriseID,
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
SkipPaths: []string{"/api/h5/login", "/api/h5/refresh-token"},
|
|
||||||
ShopStore: shopStore,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -7,16 +7,20 @@ import (
|
|||||||
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
|
assetAllocationRecordSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_allocation_record"
|
||||||
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth"
|
authSvc "github.com/break/junhong_cmp_fiber/internal/service/auth"
|
||||||
carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier"
|
carrierSvc "github.com/break/junhong_cmp_fiber/internal/service/carrier"
|
||||||
|
clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth"
|
||||||
commissionCalculationSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
commissionCalculationSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_calculation"
|
||||||
commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
commissionStatsSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_stats"
|
||||||
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
|
commissionWithdrawalSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal"
|
||||||
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
|
commissionWithdrawalSettingSvc "github.com/break/junhong_cmp_fiber/internal/service/commission_withdrawal_setting"
|
||||||
|
|
||||||
|
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||||
|
assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet"
|
||||||
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
|
deviceSvc "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||||
deviceImportSvc "github.com/break/junhong_cmp_fiber/internal/service/device_import"
|
deviceImportSvc "github.com/break/junhong_cmp_fiber/internal/service/device_import"
|
||||||
enterpriseSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise"
|
enterpriseSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise"
|
||||||
enterpriseCardSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise_card"
|
enterpriseCardSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise_card"
|
||||||
enterpriseDeviceSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise_device"
|
enterpriseDeviceSvc "github.com/break/junhong_cmp_fiber/internal/service/enterprise_device"
|
||||||
|
exchangeSvc "github.com/break/junhong_cmp_fiber/internal/service/exchange"
|
||||||
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
iotCardSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||||
iotCardImportSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card_import"
|
iotCardImportSvc "github.com/break/junhong_cmp_fiber/internal/service/iot_card_import"
|
||||||
myCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/my_commission"
|
myCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/my_commission"
|
||||||
@@ -30,11 +34,13 @@ import (
|
|||||||
roleSvc "github.com/break/junhong_cmp_fiber/internal/service/role"
|
roleSvc "github.com/break/junhong_cmp_fiber/internal/service/role"
|
||||||
shopSvc "github.com/break/junhong_cmp_fiber/internal/service/shop"
|
shopSvc "github.com/break/junhong_cmp_fiber/internal/service/shop"
|
||||||
|
|
||||||
|
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
|
||||||
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
pollingSvc "github.com/break/junhong_cmp_fiber/internal/service/polling"
|
||||||
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
|
shopCommissionSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_commission"
|
||||||
shopPackageBatchAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
|
shopPackageBatchAllocationSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_allocation"
|
||||||
shopPackageBatchPricingSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_pricing"
|
shopPackageBatchPricingSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_package_batch_pricing"
|
||||||
shopSeriesGrantSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_series_grant"
|
shopSeriesGrantSvc "github.com/break/junhong_cmp_fiber/internal/service/shop_series_grant"
|
||||||
|
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
|
||||||
)
|
)
|
||||||
|
|
||||||
type services struct {
|
type services struct {
|
||||||
@@ -43,6 +49,7 @@ type services struct {
|
|||||||
Role *roleSvc.Service
|
Role *roleSvc.Service
|
||||||
Permission *permissionSvc.Service
|
Permission *permissionSvc.Service
|
||||||
PersonalCustomer *personalCustomerSvc.Service
|
PersonalCustomer *personalCustomerSvc.Service
|
||||||
|
ClientAuth *clientAuthSvc.Service
|
||||||
Shop *shopSvc.Service
|
Shop *shopSvc.Service
|
||||||
Auth *authSvc.Service
|
Auth *authSvc.Service
|
||||||
ShopCommission *shopCommissionSvc.Service
|
ShopCommission *shopCommissionSvc.Service
|
||||||
@@ -70,6 +77,7 @@ type services struct {
|
|||||||
CommissionStats *commissionStatsSvc.Service
|
CommissionStats *commissionStatsSvc.Service
|
||||||
PurchaseValidation *purchaseValidationSvc.Service
|
PurchaseValidation *purchaseValidationSvc.Service
|
||||||
Order *orderSvc.Service
|
Order *orderSvc.Service
|
||||||
|
Exchange *exchangeSvc.Service
|
||||||
Recharge *rechargeSvc.Service
|
Recharge *rechargeSvc.Service
|
||||||
PollingConfig *pollingSvc.ConfigService
|
PollingConfig *pollingSvc.ConfigService
|
||||||
PollingConcurrency *pollingSvc.ConcurrencyService
|
PollingConcurrency *pollingSvc.ConcurrencyService
|
||||||
@@ -77,6 +85,12 @@ type services struct {
|
|||||||
PollingAlert *pollingSvc.AlertService
|
PollingAlert *pollingSvc.AlertService
|
||||||
PollingCleanup *pollingSvc.CleanupService
|
PollingCleanup *pollingSvc.CleanupService
|
||||||
PollingManualTrigger *pollingSvc.ManualTriggerService
|
PollingManualTrigger *pollingSvc.ManualTriggerService
|
||||||
|
Asset *assetSvc.Service
|
||||||
|
AssetLifecycle *assetSvc.LifecycleService
|
||||||
|
AssetWallet *assetWalletSvc.Service
|
||||||
|
StopResumeService *iotCardSvc.StopResumeService
|
||||||
|
WechatConfig *wechatConfigSvc.Service
|
||||||
|
AgentRecharge *agentRechargeSvc.Service
|
||||||
}
|
}
|
||||||
|
|
||||||
func initServices(s *stores, deps *Dependencies) *services {
|
func initServices(s *stores, deps *Dependencies) *services {
|
||||||
@@ -88,13 +102,30 @@ func initServices(s *stores, deps *Dependencies) *services {
|
|||||||
iotCard := iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient, deps.Logger)
|
iotCard := iotCardSvc.New(deps.DB, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient, deps.Logger)
|
||||||
iotCard.SetPollingCallback(polling.NewAPICallback(deps.Redis, deps.Logger))
|
iotCard.SetPollingCallback(polling.NewAPICallback(deps.Redis, deps.Logger))
|
||||||
|
|
||||||
|
// 创建支付配置服务(Order 和 Recharge 依赖)
|
||||||
|
wechatConfig := wechatConfigSvc.New(s.WechatConfig, s.Order, accountAudit, deps.Redis, deps.Logger)
|
||||||
|
|
||||||
return &services{
|
return &services{
|
||||||
Account: account,
|
Account: account,
|
||||||
AccountAudit: accountAudit,
|
AccountAudit: accountAudit,
|
||||||
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
|
Role: roleSvc.New(s.Role, s.Permission, s.RolePermission),
|
||||||
Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis),
|
Permission: permissionSvc.New(s.Permission, s.AccountRole, s.RolePermission, account, deps.Redis),
|
||||||
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.VerificationService, deps.JWTManager, deps.WechatOfficialAccount, deps.Logger),
|
PersonalCustomer: personalCustomerSvc.NewService(s.PersonalCustomer, s.PersonalCustomerPhone, deps.Logger),
|
||||||
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole),
|
ClientAuth: clientAuthSvc.New(
|
||||||
|
deps.DB,
|
||||||
|
s.PersonalCustomerOpenID,
|
||||||
|
s.PersonalCustomer,
|
||||||
|
s.PersonalCustomerDevice,
|
||||||
|
s.PersonalCustomerPhone,
|
||||||
|
s.IotCard,
|
||||||
|
s.Device,
|
||||||
|
wechatConfig,
|
||||||
|
deps.VerificationService,
|
||||||
|
deps.JWTManager,
|
||||||
|
deps.Redis,
|
||||||
|
deps.Logger,
|
||||||
|
),
|
||||||
|
Shop: shopSvc.New(s.Shop, s.Account, s.ShopRole, s.Role, s.AccountRole, s.AgentWallet),
|
||||||
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
|
Auth: authSvc.New(s.Account, s.AccountRole, s.RolePermission, s.Permission, s.Shop, deps.TokenManager, deps.Logger),
|
||||||
ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionRecord),
|
ShopCommission: shopCommissionSvc.New(s.Shop, s.Account, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionRecord),
|
||||||
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest),
|
CommissionWithdrawal: commissionWithdrawalSvc.New(deps.DB, s.Shop, s.Account, s.AgentWallet, s.AgentWalletTransaction, s.CommissionWithdrawalRequest),
|
||||||
@@ -124,7 +155,7 @@ func initServices(s *stores, deps *Dependencies) *services {
|
|||||||
MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction),
|
MyCommission: myCommissionSvc.New(deps.DB, s.Shop, s.AgentWallet, s.CommissionWithdrawalRequest, s.CommissionWithdrawalSetting, s.CommissionRecord, s.AgentWalletTransaction),
|
||||||
IotCard: iotCard,
|
IotCard: iotCard,
|
||||||
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient),
|
IotCardImport: iotCardImportSvc.New(deps.DB, s.IotCardImportTask, deps.QueueClient),
|
||||||
Device: deviceSvc.New(deps.DB, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries),
|
Device: deviceSvc.New(deps.DB, deps.Redis, s.Device, s.DeviceSimBinding, s.IotCard, s.Shop, s.AssetAllocationRecord, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.PackageSeries, deps.GatewayClient),
|
||||||
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient),
|
DeviceImport: deviceImportSvc.New(deps.DB, s.DeviceImportTask, deps.QueueClient),
|
||||||
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
|
AssetAllocationRecord: assetAllocationRecordSvc.New(deps.DB, s.AssetAllocationRecord, s.Shop, s.Account),
|
||||||
Carrier: carrierSvc.New(s.Carrier),
|
Carrier: carrierSvc.New(s.Carrier),
|
||||||
@@ -137,13 +168,31 @@ func initServices(s *stores, deps *Dependencies) *services {
|
|||||||
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger),
|
ShopSeriesGrant: shopSeriesGrantSvc.New(deps.DB, s.ShopSeriesAllocation, s.ShopPackageAllocation, s.ShopPackageAllocationPriceHistory, s.Shop, s.Package, s.PackageSeries, deps.Logger),
|
||||||
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
CommissionStats: commissionStatsSvc.New(s.ShopSeriesCommissionStats),
|
||||||
PurchaseValidation: purchaseValidation,
|
PurchaseValidation: purchaseValidation,
|
||||||
Order: orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.CardWallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, deps.WechatPayment, deps.QueueClient, deps.Logger),
|
Order: orderSvc.New(deps.DB, deps.Redis, s.Order, s.OrderItem, s.AgentWallet, s.AssetWallet, purchaseValidation, s.ShopPackageAllocation, s.ShopSeriesAllocation, s.IotCard, s.Device, s.PackageSeries, s.PackageUsage, s.Package, wechatConfig, deps.WechatPayment, deps.QueueClient, deps.Logger),
|
||||||
Recharge: rechargeSvc.New(deps.DB, s.CardRecharge, s.CardWallet, s.CardWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, deps.Logger),
|
Exchange: exchangeSvc.New(deps.DB, s.ExchangeOrder, s.IotCard, s.Device, s.AssetWallet, s.AssetWalletTransaction, s.PackageUsage, s.PackageUsageDailyRecord, s.ResourceTag, s.PersonalCustomerDevice, deps.Logger),
|
||||||
|
Recharge: rechargeSvc.New(deps.DB, s.AssetRecharge, s.AssetWallet, s.AssetWalletTransaction, s.IotCard, s.Device, s.ShopSeriesAllocation, s.PackageSeries, s.CommissionRecord, wechatConfig, deps.Logger),
|
||||||
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig),
|
PollingConfig: pollingSvc.NewConfigService(s.PollingConfig),
|
||||||
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
|
PollingConcurrency: pollingSvc.NewConcurrencyService(s.PollingConcurrencyConfig, deps.Redis),
|
||||||
PollingMonitoring: pollingSvc.NewMonitoringService(deps.Redis),
|
PollingMonitoring: pollingSvc.NewMonitoringService(deps.Redis),
|
||||||
PollingAlert: pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger),
|
PollingAlert: pollingSvc.NewAlertService(s.PollingAlertRule, s.PollingAlertHistory, deps.Redis, deps.Logger),
|
||||||
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
|
PollingCleanup: pollingSvc.NewCleanupService(s.DataCleanupConfig, s.DataCleanupLog, deps.Logger),
|
||||||
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger),
|
PollingManualTrigger: pollingSvc.NewManualTriggerService(s.PollingManualTriggerLog, s.IotCard, deps.Redis, deps.Logger),
|
||||||
|
Asset: assetSvc.New(deps.DB, s.Device, s.IotCard, s.PackageUsage, s.Package, s.PackageSeries, s.DeviceSimBinding, s.Shop, deps.Redis, iotCard),
|
||||||
|
AssetLifecycle: assetSvc.NewLifecycleService(deps.DB, s.IotCard, s.Device),
|
||||||
|
AssetWallet: assetWalletSvc.New(s.AssetWallet, s.AssetWalletTransaction),
|
||||||
|
StopResumeService: iotCardSvc.NewStopResumeService(deps.DB, deps.Redis, s.IotCard, s.DeviceSimBinding, deps.GatewayClient, deps.Logger),
|
||||||
|
WechatConfig: wechatConfig,
|
||||||
|
AgentRecharge: agentRechargeSvc.New(
|
||||||
|
deps.DB,
|
||||||
|
s.AgentRecharge,
|
||||||
|
s.AgentWallet,
|
||||||
|
s.AgentWalletTransaction,
|
||||||
|
s.Shop,
|
||||||
|
s.Account,
|
||||||
|
wechatConfig,
|
||||||
|
accountAudit,
|
||||||
|
deps.Redis,
|
||||||
|
deps.Logger,
|
||||||
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,6 +14,8 @@ type stores struct {
|
|||||||
ShopRole *postgres.ShopRoleStore
|
ShopRole *postgres.ShopRoleStore
|
||||||
RolePermission *postgres.RolePermissionStore
|
RolePermission *postgres.RolePermissionStore
|
||||||
PersonalCustomer *postgres.PersonalCustomerStore
|
PersonalCustomer *postgres.PersonalCustomerStore
|
||||||
|
PersonalCustomerOpenID *postgres.PersonalCustomerOpenIDStore
|
||||||
|
PersonalCustomerDevice *postgres.PersonalCustomerDeviceStore
|
||||||
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
PersonalCustomerPhone *postgres.PersonalCustomerPhoneStore
|
||||||
CommissionWithdrawalRequest *postgres.CommissionWithdrawalRequestStore
|
CommissionWithdrawalRequest *postgres.CommissionWithdrawalRequestStore
|
||||||
CommissionRecord *postgres.CommissionRecordStore
|
CommissionRecord *postgres.CommissionRecordStore
|
||||||
@@ -38,6 +40,8 @@ type stores struct {
|
|||||||
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
|
ShopSeriesCommissionStats *postgres.ShopSeriesCommissionStatsStore
|
||||||
Order *postgres.OrderStore
|
Order *postgres.OrderStore
|
||||||
OrderItem *postgres.OrderItemStore
|
OrderItem *postgres.OrderItemStore
|
||||||
|
ExchangeOrder *postgres.ExchangeOrderStore
|
||||||
|
ResourceTag *postgres.ResourceTagStore
|
||||||
PollingConfig *postgres.PollingConfigStore
|
PollingConfig *postgres.PollingConfigStore
|
||||||
PollingConcurrencyConfig *postgres.PollingConcurrencyConfigStore
|
PollingConcurrencyConfig *postgres.PollingConcurrencyConfigStore
|
||||||
PollingAlertRule *postgres.PollingAlertRuleStore
|
PollingAlertRule *postgres.PollingAlertRuleStore
|
||||||
@@ -49,10 +53,12 @@ type stores struct {
|
|||||||
AgentWallet *postgres.AgentWalletStore
|
AgentWallet *postgres.AgentWalletStore
|
||||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||||
AgentRecharge *postgres.AgentRechargeStore
|
AgentRecharge *postgres.AgentRechargeStore
|
||||||
// 卡钱包系统
|
// 资产钱包系统
|
||||||
CardWallet *postgres.CardWalletStore
|
AssetWallet *postgres.AssetWalletStore
|
||||||
CardWalletTransaction *postgres.CardWalletTransactionStore
|
AssetWalletTransaction *postgres.AssetWalletTransactionStore
|
||||||
CardRecharge *postgres.CardRechargeStore
|
AssetRecharge *postgres.AssetRechargeStore
|
||||||
|
// 微信参数配置
|
||||||
|
WechatConfig *postgres.WechatConfigStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func initStores(deps *Dependencies) *stores {
|
func initStores(deps *Dependencies) *stores {
|
||||||
@@ -66,6 +72,8 @@ func initStores(deps *Dependencies) *stores {
|
|||||||
ShopRole: postgres.NewShopRoleStore(deps.DB, deps.Redis),
|
ShopRole: postgres.NewShopRoleStore(deps.DB, deps.Redis),
|
||||||
RolePermission: postgres.NewRolePermissionStore(deps.DB, deps.Redis),
|
RolePermission: postgres.NewRolePermissionStore(deps.DB, deps.Redis),
|
||||||
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
PersonalCustomer: postgres.NewPersonalCustomerStore(deps.DB, deps.Redis),
|
||||||
|
PersonalCustomerOpenID: postgres.NewPersonalCustomerOpenIDStore(deps.DB),
|
||||||
|
PersonalCustomerDevice: postgres.NewPersonalCustomerDeviceStore(deps.DB),
|
||||||
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
PersonalCustomerPhone: postgres.NewPersonalCustomerPhoneStore(deps.DB),
|
||||||
CommissionWithdrawalRequest: postgres.NewCommissionWithdrawalRequestStore(deps.DB, deps.Redis),
|
CommissionWithdrawalRequest: postgres.NewCommissionWithdrawalRequestStore(deps.DB, deps.Redis),
|
||||||
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
|
CommissionRecord: postgres.NewCommissionRecordStore(deps.DB, deps.Redis),
|
||||||
@@ -90,6 +98,8 @@ func initStores(deps *Dependencies) *stores {
|
|||||||
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
|
ShopSeriesCommissionStats: postgres.NewShopSeriesCommissionStatsStore(deps.DB),
|
||||||
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
|
Order: postgres.NewOrderStore(deps.DB, deps.Redis),
|
||||||
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
|
OrderItem: postgres.NewOrderItemStore(deps.DB, deps.Redis),
|
||||||
|
ExchangeOrder: postgres.NewExchangeOrderStore(deps.DB),
|
||||||
|
ResourceTag: postgres.NewResourceTagStore(deps.DB),
|
||||||
PollingConfig: postgres.NewPollingConfigStore(deps.DB),
|
PollingConfig: postgres.NewPollingConfigStore(deps.DB),
|
||||||
PollingConcurrencyConfig: postgres.NewPollingConcurrencyConfigStore(deps.DB),
|
PollingConcurrencyConfig: postgres.NewPollingConcurrencyConfigStore(deps.DB),
|
||||||
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
|
PollingAlertRule: postgres.NewPollingAlertRuleStore(deps.DB),
|
||||||
@@ -101,9 +111,10 @@ func initStores(deps *Dependencies) *stores {
|
|||||||
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
|
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
|
||||||
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
||||||
AgentRecharge: postgres.NewAgentRechargeStore(deps.DB, deps.Redis),
|
AgentRecharge: postgres.NewAgentRechargeStore(deps.DB, deps.Redis),
|
||||||
// 卡钱包系统
|
// 资产钱包系统
|
||||||
CardWallet: postgres.NewCardWalletStore(deps.DB, deps.Redis),
|
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||||
CardWalletTransaction: postgres.NewCardWalletTransactionStore(deps.DB, deps.Redis),
|
AssetWalletTransaction: postgres.NewAssetWalletTransactionStore(deps.DB, deps.Redis),
|
||||||
CardRecharge: postgres.NewCardRechargeStore(deps.DB, deps.Redis),
|
AssetRecharge: postgres.NewAssetRechargeStore(deps.DB, deps.Redis),
|
||||||
|
WechatConfig: postgres.NewWechatConfigStore(deps.DB, deps.Redis),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
"github.com/break/junhong_cmp_fiber/internal/handler/app"
|
||||||
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
|
authHandler "github.com/break/junhong_cmp_fiber/internal/handler/auth"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
"github.com/break/junhong_cmp_fiber/internal/handler/callback"
|
||||||
"github.com/break/junhong_cmp_fiber/internal/handler/h5"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
)
|
)
|
||||||
@@ -16,17 +15,22 @@ type Handlers struct {
|
|||||||
Role *admin.RoleHandler
|
Role *admin.RoleHandler
|
||||||
Permission *admin.PermissionHandler
|
Permission *admin.PermissionHandler
|
||||||
PersonalCustomer *app.PersonalCustomerHandler
|
PersonalCustomer *app.PersonalCustomerHandler
|
||||||
|
ClientAuth *app.ClientAuthHandler
|
||||||
|
ClientAsset *app.ClientAssetHandler
|
||||||
|
ClientWallet *app.ClientWalletHandler
|
||||||
|
ClientOrder *app.ClientOrderHandler
|
||||||
|
ClientExchange *app.ClientExchangeHandler
|
||||||
|
ClientRealname *app.ClientRealnameHandler
|
||||||
|
ClientDevice *app.ClientDeviceHandler
|
||||||
Shop *admin.ShopHandler
|
Shop *admin.ShopHandler
|
||||||
ShopRole *admin.ShopRoleHandler
|
ShopRole *admin.ShopRoleHandler
|
||||||
AdminAuth *admin.AuthHandler
|
AdminAuth *admin.AuthHandler
|
||||||
H5Auth *h5.AuthHandler
|
|
||||||
ShopCommission *admin.ShopCommissionHandler
|
ShopCommission *admin.ShopCommissionHandler
|
||||||
CommissionWithdrawal *admin.CommissionWithdrawalHandler
|
CommissionWithdrawal *admin.CommissionWithdrawalHandler
|
||||||
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
|
CommissionWithdrawalSetting *admin.CommissionWithdrawalSettingHandler
|
||||||
Enterprise *admin.EnterpriseHandler
|
Enterprise *admin.EnterpriseHandler
|
||||||
EnterpriseCard *admin.EnterpriseCardHandler
|
EnterpriseCard *admin.EnterpriseCardHandler
|
||||||
EnterpriseDevice *admin.EnterpriseDeviceHandler
|
EnterpriseDevice *admin.EnterpriseDeviceHandler
|
||||||
EnterpriseDeviceH5 *h5.EnterpriseDeviceHandler
|
|
||||||
Authorization *admin.AuthorizationHandler
|
Authorization *admin.AuthorizationHandler
|
||||||
MyCommission *admin.MyCommissionHandler
|
MyCommission *admin.MyCommissionHandler
|
||||||
IotCard *admin.IotCardHandler
|
IotCard *admin.IotCardHandler
|
||||||
@@ -39,13 +43,11 @@ type Handlers struct {
|
|||||||
PackageSeries *admin.PackageSeriesHandler
|
PackageSeries *admin.PackageSeriesHandler
|
||||||
Package *admin.PackageHandler
|
Package *admin.PackageHandler
|
||||||
PackageUsage *admin.PackageUsageHandler
|
PackageUsage *admin.PackageUsageHandler
|
||||||
H5PackageUsage *h5.PackageUsageHandler
|
|
||||||
ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler
|
ShopPackageBatchAllocation *admin.ShopPackageBatchAllocationHandler
|
||||||
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
|
ShopPackageBatchPricing *admin.ShopPackageBatchPricingHandler
|
||||||
ShopSeriesGrant *admin.ShopSeriesGrantHandler
|
ShopSeriesGrant *admin.ShopSeriesGrantHandler
|
||||||
AdminOrder *admin.OrderHandler
|
AdminOrder *admin.OrderHandler
|
||||||
H5Order *h5.OrderHandler
|
AdminExchange *admin.ExchangeHandler
|
||||||
H5Recharge *h5.RechargeHandler
|
|
||||||
PaymentCallback *callback.PaymentHandler
|
PaymentCallback *callback.PaymentHandler
|
||||||
PollingConfig *admin.PollingConfigHandler
|
PollingConfig *admin.PollingConfigHandler
|
||||||
PollingConcurrency *admin.PollingConcurrencyHandler
|
PollingConcurrency *admin.PollingConcurrencyHandler
|
||||||
@@ -53,6 +55,11 @@ type Handlers struct {
|
|||||||
PollingAlert *admin.PollingAlertHandler
|
PollingAlert *admin.PollingAlertHandler
|
||||||
PollingCleanup *admin.PollingCleanupHandler
|
PollingCleanup *admin.PollingCleanupHandler
|
||||||
PollingManualTrigger *admin.PollingManualTriggerHandler
|
PollingManualTrigger *admin.PollingManualTriggerHandler
|
||||||
|
Asset *admin.AssetHandler
|
||||||
|
AssetLifecycle *admin.AssetLifecycleHandler
|
||||||
|
AssetWallet *admin.AssetWalletHandler
|
||||||
|
WechatConfig *admin.WechatConfigHandler
|
||||||
|
AgentRecharge *admin.AgentRechargeHandler
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middlewares 封装所有中间件
|
// Middlewares 封装所有中间件
|
||||||
@@ -60,6 +67,5 @@ type Handlers struct {
|
|||||||
type Middlewares struct {
|
type Middlewares struct {
|
||||||
PersonalAuth *middleware.PersonalAuthMiddleware
|
PersonalAuth *middleware.PersonalAuthMiddleware
|
||||||
AdminAuth func(*fiber.Ctx) error
|
AdminAuth func(*fiber.Ctx) error
|
||||||
H5Auth func(*fiber.Ctx) error
|
|
||||||
// TODO: 新增 Middleware 在此添加字段
|
// TODO: 新增 Middleware 在此添加字段
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -85,7 +85,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
|||||||
stores.Order,
|
stores.Order,
|
||||||
stores.OrderItem,
|
stores.OrderItem,
|
||||||
stores.AgentWallet,
|
stores.AgentWallet,
|
||||||
stores.CardWallet,
|
stores.AssetWallet,
|
||||||
nil, // purchaseValidationService: 超时取消不需要
|
nil, // purchaseValidationService: 超时取消不需要
|
||||||
stores.ShopPackageAllocation,
|
stores.ShopPackageAllocation,
|
||||||
stores.ShopSeriesAllocation,
|
stores.ShopSeriesAllocation,
|
||||||
@@ -94,6 +94,7 @@ func initWorkerServices(stores *queue.WorkerStores, deps *WorkerDependencies) *q
|
|||||||
stores.PackageSeries,
|
stores.PackageSeries,
|
||||||
stores.PackageUsage,
|
stores.PackageUsage,
|
||||||
stores.Package,
|
stores.Package,
|
||||||
|
nil, // wechatConfigService: 超时取消不需要
|
||||||
nil, // wechatPayment: 超时取消不需要
|
nil, // wechatPayment: 超时取消不需要
|
||||||
nil, // queueClient: 超时取消不触发分佣
|
nil, // queueClient: 超时取消不触发分佣
|
||||||
deps.Logger,
|
deps.Logger,
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ type workerStores struct {
|
|||||||
DataCleanupLog *postgres.DataCleanupLogStore
|
DataCleanupLog *postgres.DataCleanupLogStore
|
||||||
AgentWallet *postgres.AgentWalletStore
|
AgentWallet *postgres.AgentWalletStore
|
||||||
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
AgentWalletTransaction *postgres.AgentWalletTransactionStore
|
||||||
CardWallet *postgres.CardWalletStore
|
AssetWallet *postgres.AssetWalletStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
||||||
@@ -55,7 +55,7 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
|||||||
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
|
DataCleanupLog: postgres.NewDataCleanupLogStore(deps.DB),
|
||||||
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
|
AgentWallet: postgres.NewAgentWalletStore(deps.DB, deps.Redis),
|
||||||
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
AgentWalletTransaction: postgres.NewAgentWalletTransactionStore(deps.DB, deps.Redis),
|
||||||
CardWallet: postgres.NewCardWalletStore(deps.DB, deps.Redis),
|
AssetWallet: postgres.NewAssetWalletStore(deps.DB, deps.Redis),
|
||||||
}
|
}
|
||||||
|
|
||||||
return &queue.WorkerStores{
|
return &queue.WorkerStores{
|
||||||
@@ -81,6 +81,6 @@ func initWorkerStores(deps *WorkerDependencies) *queue.WorkerStores {
|
|||||||
DataCleanupLog: stores.DataCleanupLog,
|
DataCleanupLog: stores.DataCleanupLog,
|
||||||
AgentWallet: stores.AgentWallet,
|
AgentWallet: stores.AgentWallet,
|
||||||
AgentWalletTransaction: stores.AgentWalletTransaction,
|
AgentWalletTransaction: stores.AgentWalletTransaction,
|
||||||
CardWallet: stores.CardWallet,
|
AssetWallet: stores.AssetWallet,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Package gateway 提供 Gateway API 的统一客户端封装
|
// Package gateway 提供 Gateway API 的统一客户端封装
|
||||||
// 实现 AES-128-ECB 加密 + MD5 签名认证机制
|
// 实现 AES-128-ECB 加密 + MD5 签名认证机制,支持请求日志和网络级错误重试
|
||||||
package gateway
|
package gateway
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -13,6 +13,7 @@ import (
|
|||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
"github.com/bytedance/sonic"
|
"github.com/bytedance/sonic"
|
||||||
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -22,6 +23,8 @@ const (
|
|||||||
idleConnTimeout = 90 * time.Second
|
idleConnTimeout = 90 * time.Second
|
||||||
contentTypeJSON = "application/json;charset=utf-8"
|
contentTypeJSON = "application/json;charset=utf-8"
|
||||||
gatewaySuccessCode = 200
|
gatewaySuccessCode = 200
|
||||||
|
defaultMaxRetries = 2
|
||||||
|
retryBaseDelay = 100 * time.Millisecond
|
||||||
)
|
)
|
||||||
|
|
||||||
// Client 是 Gateway API 的 HTTP 客户端
|
// Client 是 Gateway API 的 HTTP 客户端
|
||||||
@@ -31,13 +34,21 @@ type Client struct {
|
|||||||
appSecret string
|
appSecret string
|
||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
timeout time.Duration
|
timeout time.Duration
|
||||||
|
logger *zap.Logger
|
||||||
|
maxRetries int
|
||||||
|
}
|
||||||
|
|
||||||
|
// requestWrapper 用于将请求参数包装为 Gateway 的 {"params": ...} 格式
|
||||||
|
type requestWrapper struct {
|
||||||
|
Params interface{} `json:"params"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewClient 创建 Gateway 客户端实例
|
// NewClient 创建 Gateway 客户端实例
|
||||||
// baseURL: Gateway 服务基础地址
|
// baseURL: Gateway 服务基础地址
|
||||||
// appID: 应用 ID
|
// appID: 应用 ID
|
||||||
// appSecret: 应用密钥(用于加密和签名)
|
// appSecret: 应用密钥(用于加密和签名)
|
||||||
func NewClient(baseURL, appID, appSecret string) *Client {
|
// logger: Zap 日志记录器
|
||||||
|
func NewClient(baseURL, appID, appSecret string, logger *zap.Logger) *Client {
|
||||||
return &Client{
|
return &Client{
|
||||||
baseURL: baseURL,
|
baseURL: baseURL,
|
||||||
appID: appID,
|
appID: appID,
|
||||||
@@ -50,6 +61,8 @@ func NewClient(baseURL, appID, appSecret string) *Client {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
timeout: defaultTimeout,
|
timeout: defaultTimeout,
|
||||||
|
logger: logger,
|
||||||
|
maxRetries: defaultMaxRetries,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -59,19 +72,86 @@ func (c *Client) WithTimeout(timeout time.Duration) *Client {
|
|||||||
return c
|
return c
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WithRetry 设置最大重试次数(支持链式调用)
|
||||||
|
// maxRetries=0 表示不重试,maxRetries=2 表示最多重试 2 次(共 3 次尝试)
|
||||||
|
func (c *Client) WithRetry(maxRetries int) *Client {
|
||||||
|
c.maxRetries = maxRetries
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
|
||||||
// doRequest 执行 Gateway API 请求的统一方法
|
// doRequest 执行 Gateway API 请求的统一方法
|
||||||
// 流程:序列化 → 加密 → 签名 → HTTP POST → 解析响应 → 检查业务状态码
|
// 流程:包装参数 → 序列化 → 加密 → 签名 → HTTP POST(带重试)→ 解析响应 → 检查业务状态码
|
||||||
func (c *Client) doRequest(ctx context.Context, path string, businessData interface{}) (json.RawMessage, error) {
|
// params: 请求参数结构体,内部自动包装为 {"params": <JSON>} 格式
|
||||||
dataBytes, err := sonic.Marshal(businessData)
|
func (c *Client) doRequest(ctx context.Context, path string, params interface{}) (json.RawMessage, error) {
|
||||||
|
startTime := time.Now()
|
||||||
|
|
||||||
|
// 将参数包装为 {"params": ...} 格式后序列化
|
||||||
|
wrapper := requestWrapper{Params: params}
|
||||||
|
dataBytes, err := sonic.Marshal(wrapper)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化业务数据失败")
|
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化业务数据失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 加密业务数据(加密结果不变,可在重试间复用)
|
||||||
encryptedData, err := aesEncrypt(dataBytes, c.appSecret)
|
encryptedData, err := aesEncrypt(dataBytes, c.appSecret)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 带重试的 HTTP 请求
|
||||||
|
var lastErr error
|
||||||
|
for attempt := 0; attempt <= c.maxRetries; attempt++ {
|
||||||
|
if attempt > 0 {
|
||||||
|
// 检查用户 Context 是否已取消
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 指数退避等待:100ms → 200ms → 300ms(封顶 3 倍基础延迟)
|
||||||
|
delay := retryBaseDelay * time.Duration(1<<uint(attempt-1))
|
||||||
|
if delay > retryBaseDelay*3 {
|
||||||
|
delay = retryBaseDelay * 3
|
||||||
|
}
|
||||||
|
c.logger.Warn("Gateway 请求重试",
|
||||||
|
zap.String("path", path),
|
||||||
|
zap.Int("attempt", attempt+1),
|
||||||
|
zap.Duration("delay", delay),
|
||||||
|
)
|
||||||
|
time.Sleep(delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, retryable, err := c.executeHTTPRequest(ctx, path, encryptedData)
|
||||||
|
if err != nil {
|
||||||
|
lastErr = err
|
||||||
|
// 仅对网络级错误重试
|
||||||
|
if retryable && ctx.Err() == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// 成功
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
c.logger.Debug("Gateway 请求成功",
|
||||||
|
zap.String("path", path),
|
||||||
|
zap.Duration("duration", duration),
|
||||||
|
)
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 所有尝试都失败
|
||||||
|
duration := time.Since(startTime)
|
||||||
|
c.logger.Error("Gateway 请求失败",
|
||||||
|
zap.String("path", path),
|
||||||
|
zap.Duration("duration", duration),
|
||||||
|
zap.Error(lastErr),
|
||||||
|
)
|
||||||
|
return nil, lastErr
|
||||||
|
}
|
||||||
|
|
||||||
|
// executeHTTPRequest 执行单次 HTTP 请求(无重试逻辑)
|
||||||
|
// 返回值:响应数据、是否可重试、错误
|
||||||
|
func (c *Client) executeHTTPRequest(ctx context.Context, path string, encryptedData string) (json.RawMessage, bool, error) {
|
||||||
|
// 每次重试使用新的时间戳和签名
|
||||||
timestamp := time.Now().Unix()
|
timestamp := time.Now().Unix()
|
||||||
sign := generateSign(c.appID, encryptedData, timestamp, c.appSecret)
|
sign := generateSign(c.appID, encryptedData, timestamp, c.appSecret)
|
||||||
|
|
||||||
@@ -84,7 +164,7 @@ func (c *Client) doRequest(ctx context.Context, path string, businessData interf
|
|||||||
|
|
||||||
reqBodyBytes, err := sonic.Marshal(reqBody)
|
reqBodyBytes, err := sonic.Marshal(reqBody)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(errors.CodeInternalError, err, "序列化请求体失败")
|
return nil, false, errors.Wrap(errors.CodeInternalError, err, "序列化请求体失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
reqCtx, cancel := context.WithTimeout(ctx, c.timeout)
|
reqCtx, cancel := context.WithTimeout(ctx, c.timeout)
|
||||||
@@ -92,39 +172,64 @@ func (c *Client) doRequest(ctx context.Context, path string, businessData interf
|
|||||||
|
|
||||||
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.baseURL+path, bytes.NewReader(reqBodyBytes))
|
req, err := http.NewRequestWithContext(reqCtx, http.MethodPost, c.baseURL+path, bytes.NewReader(reqBodyBytes))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(errors.CodeGatewayError, err, "创建 HTTP 请求失败")
|
return nil, false, errors.Wrap(errors.CodeGatewayError, err, "创建 HTTP 请求失败")
|
||||||
}
|
}
|
||||||
req.Header.Set("Content-Type", contentTypeJSON)
|
req.Header.Set("Content-Type", contentTypeJSON)
|
||||||
|
|
||||||
resp, err := c.httpClient.Do(req)
|
resp, err := c.httpClient.Do(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if reqCtx.Err() == context.DeadlineExceeded {
|
// 用户 Context 已取消 — 不可重试
|
||||||
return nil, errors.Wrap(errors.CodeGatewayTimeout, err, "Gateway 请求超时")
|
|
||||||
}
|
|
||||||
if ctx.Err() != nil {
|
if ctx.Err() != nil {
|
||||||
return nil, errors.Wrap(errors.CodeGatewayError, ctx.Err(), "请求被取消")
|
return nil, false, errors.Wrap(errors.CodeGatewayError, ctx.Err(), "请求被取消")
|
||||||
}
|
}
|
||||||
return nil, errors.Wrap(errors.CodeGatewayError, err, "发送 HTTP 请求失败")
|
// Client 超时 — 可重试
|
||||||
|
if reqCtx.Err() == context.DeadlineExceeded {
|
||||||
|
return nil, true, errors.Wrap(errors.CodeGatewayTimeout, err, "Gateway 请求超时")
|
||||||
|
}
|
||||||
|
// 其他网络错误(连接失败、DNS 解析等)— 可重试
|
||||||
|
return nil, true, errors.Wrap(errors.CodeGatewayError, err, "发送 HTTP 请求失败")
|
||||||
}
|
}
|
||||||
defer resp.Body.Close()
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
// HTTP 状态码错误 — 不可重试
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return nil, errors.New(errors.CodeGatewayError, fmt.Sprintf("HTTP 状态码异常: %d", resp.StatusCode))
|
return nil, false, errors.New(errors.CodeGatewayError, fmt.Sprintf("HTTP 状态码异常: %d", resp.StatusCode))
|
||||||
}
|
}
|
||||||
|
|
||||||
body, err := io.ReadAll(resp.Body)
|
body, err := io.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "读取响应体失败")
|
return nil, false, errors.Wrap(errors.CodeGatewayInvalidResp, err, "读取响应体失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
var gatewayResp GatewayResponse
|
var gatewayResp GatewayResponse
|
||||||
if err := sonic.Unmarshal(body, &gatewayResp); err != nil {
|
if err := sonic.Unmarshal(body, &gatewayResp); err != nil {
|
||||||
|
return nil, false, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析 Gateway 响应失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gateway 业务错误 — 不可重试
|
||||||
|
if gatewayResp.Code != gatewaySuccessCode {
|
||||||
|
c.logger.Warn("Gateway 业务错误",
|
||||||
|
zap.String("path", path),
|
||||||
|
zap.Int("gateway_code", gatewayResp.Code),
|
||||||
|
zap.String("gateway_msg", gatewayResp.Msg),
|
||||||
|
)
|
||||||
|
return nil, false, errors.New(errors.CodeGatewayError, fmt.Sprintf("Gateway 业务错误: code=%d, msg=%s", gatewayResp.Code, gatewayResp.Msg))
|
||||||
|
}
|
||||||
|
|
||||||
|
return gatewayResp.Data, false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// doRequestWithResponse 执行 Gateway API 请求并自动反序列化响应为目标类型
|
||||||
|
func doRequestWithResponse[T any](c *Client, ctx context.Context, path string, params interface{}) (*T, error) {
|
||||||
|
data, err := c.doRequest(ctx, path, params)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result T
|
||||||
|
if err := sonic.Unmarshal(data, &result); err != nil {
|
||||||
return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析 Gateway 响应失败")
|
return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析 Gateway 响应失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
if gatewayResp.Code != gatewaySuccessCode {
|
return &result, nil
|
||||||
return nil, errors.New(errors.CodeGatewayError, fmt.Sprintf("Gateway 业务错误: code=%d, msg=%s", gatewayResp.Code, gatewayResp.Msg))
|
|
||||||
}
|
|
||||||
|
|
||||||
return gatewayResp.Data, nil
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,165 +5,66 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
"github.com/bytedance/sonic"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetDeviceInfo 获取设备信息
|
// GetDeviceInfo 获取设备信息
|
||||||
// 通过卡号或设备 ID 查询设备的在线状态、信号强度、WiFi 信息等
|
// 通过卡号或设备 ID 查询设备的在线状态、信号强度、WiFi 信息等
|
||||||
|
// POST /device/info
|
||||||
func (c *Client) GetDeviceInfo(ctx context.Context, req *DeviceInfoReq) (*DeviceInfoResp, error) {
|
func (c *Client) GetDeviceInfo(ctx context.Context, req *DeviceInfoReq) (*DeviceInfoResp, error) {
|
||||||
if req.CardNo == "" && req.DeviceID == "" {
|
if req.CardNo == "" && req.DeviceID == "" {
|
||||||
return nil, errors.New(errors.CodeInvalidParam, "cardNo 和 deviceId 至少需要一个")
|
return nil, errors.New(errors.CodeInvalidParam, "cardNo 和 deviceId 至少需要一个")
|
||||||
}
|
}
|
||||||
|
return doRequestWithResponse[DeviceInfoResp](c, ctx, "/device/info", req)
|
||||||
params := make(map[string]interface{})
|
|
||||||
if req.CardNo != "" {
|
|
||||||
params["cardNo"] = req.CardNo
|
|
||||||
}
|
|
||||||
if req.DeviceID != "" {
|
|
||||||
params["deviceId"] = req.DeviceID
|
|
||||||
}
|
|
||||||
|
|
||||||
businessData := map[string]interface{}{
|
|
||||||
"params": params,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.doRequest(ctx, "/device/info", businessData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result DeviceInfoResp
|
|
||||||
if err := sonic.Unmarshal(resp, &result); err != nil {
|
|
||||||
return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析设备信息响应失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSlotInfo 获取设备卡槽信息
|
// GetSlotInfo 获取设备卡槽信息
|
||||||
// 查询设备的所有卡槽及其中的卡信息
|
// 查询设备的所有卡槽及其中的卡信息
|
||||||
|
// POST /device/slot-info
|
||||||
func (c *Client) GetSlotInfo(ctx context.Context, req *DeviceInfoReq) (*SlotInfoResp, error) {
|
func (c *Client) GetSlotInfo(ctx context.Context, req *DeviceInfoReq) (*SlotInfoResp, error) {
|
||||||
if req.CardNo == "" && req.DeviceID == "" {
|
if req.CardNo == "" && req.DeviceID == "" {
|
||||||
return nil, errors.New(errors.CodeInvalidParam, "cardNo 和 deviceId 至少需要一个")
|
return nil, errors.New(errors.CodeInvalidParam, "cardNo 和 deviceId 至少需要一个")
|
||||||
}
|
}
|
||||||
|
return doRequestWithResponse[SlotInfoResp](c, ctx, "/device/slot-info", req)
|
||||||
params := make(map[string]interface{})
|
|
||||||
if req.CardNo != "" {
|
|
||||||
params["cardNo"] = req.CardNo
|
|
||||||
}
|
|
||||||
if req.DeviceID != "" {
|
|
||||||
params["deviceId"] = req.DeviceID
|
|
||||||
}
|
|
||||||
|
|
||||||
businessData := map[string]interface{}{
|
|
||||||
"params": params,
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.doRequest(ctx, "/device/slot-info", businessData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result SlotInfoResp
|
|
||||||
if err := sonic.Unmarshal(resp, &result); err != nil {
|
|
||||||
return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析卡槽信息响应失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetSpeedLimit 设置设备限速
|
// SetSpeedLimit 设置设备限速
|
||||||
// 设置设备的上行和下行速率限制
|
// 设置设备的统一限速值(单位 KB/s)
|
||||||
|
// POST /device/speed-limit
|
||||||
func (c *Client) SetSpeedLimit(ctx context.Context, req *SpeedLimitReq) error {
|
func (c *Client) SetSpeedLimit(ctx context.Context, req *SpeedLimitReq) error {
|
||||||
params := map[string]interface{}{
|
_, err := c.doRequest(ctx, "/device/speed-limit", req)
|
||||||
"deviceId": req.DeviceID,
|
|
||||||
"uploadSpeed": req.UploadSpeed,
|
|
||||||
"downloadSpeed": req.DownloadSpeed,
|
|
||||||
}
|
|
||||||
if req.Extend != "" {
|
|
||||||
params["extend"] = req.Extend
|
|
||||||
}
|
|
||||||
|
|
||||||
businessData := map[string]interface{}{
|
|
||||||
"params": params,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.doRequest(ctx, "/device/speed-limit", businessData)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetWiFi 设置设备 WiFi
|
// SetWiFi 设置设备 WiFi
|
||||||
// 设置设备的 WiFi 名称、密码和启用状态
|
// 配置 WiFi 名称、密码及启用状态,cardNo(ICCID)为必填参数
|
||||||
|
// POST /device/wifi-config
|
||||||
func (c *Client) SetWiFi(ctx context.Context, req *WiFiReq) error {
|
func (c *Client) SetWiFi(ctx context.Context, req *WiFiReq) error {
|
||||||
params := map[string]interface{}{
|
_, err := c.doRequest(ctx, "/device/wifi-config", req)
|
||||||
"deviceId": req.DeviceID,
|
|
||||||
"ssid": req.SSID,
|
|
||||||
"password": req.Password,
|
|
||||||
"enabled": req.Enabled,
|
|
||||||
}
|
|
||||||
if req.Extend != "" {
|
|
||||||
params["extend"] = req.Extend
|
|
||||||
}
|
|
||||||
|
|
||||||
businessData := map[string]interface{}{
|
|
||||||
"params": params,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.doRequest(ctx, "/device/wifi", businessData)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// SwitchCard 设备切换卡
|
// SwitchCard 设备切换卡
|
||||||
// 切换设备当前使用的卡到指定的目标卡
|
// 为多卡设备切换到目标 ICCID,operationType 固定为 2
|
||||||
|
// POST /device/card-switch
|
||||||
func (c *Client) SwitchCard(ctx context.Context, req *SwitchCardReq) error {
|
func (c *Client) SwitchCard(ctx context.Context, req *SwitchCardReq) error {
|
||||||
params := map[string]interface{}{
|
// 强制设置 operationType 为 2(切卡操作)
|
||||||
"deviceId": req.DeviceID,
|
req.OperationType = 2
|
||||||
"targetIccid": req.TargetICCID,
|
_, err := c.doRequest(ctx, "/device/card-switch", req)
|
||||||
}
|
|
||||||
if req.Extend != "" {
|
|
||||||
params["extend"] = req.Extend
|
|
||||||
}
|
|
||||||
|
|
||||||
businessData := map[string]interface{}{
|
|
||||||
"params": params,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.doRequest(ctx, "/device/switch-card", businessData)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResetDevice 设备恢复出厂设置
|
// ResetDevice 设备恢复出厂设置
|
||||||
// 将设备恢复到出厂设置状态
|
// 将设备恢复到出厂设置状态
|
||||||
|
// POST /device/factory-reset
|
||||||
func (c *Client) ResetDevice(ctx context.Context, req *DeviceOperationReq) error {
|
func (c *Client) ResetDevice(ctx context.Context, req *DeviceOperationReq) error {
|
||||||
params := map[string]interface{}{
|
_, err := c.doRequest(ctx, "/device/factory-reset", req)
|
||||||
"deviceId": req.DeviceID,
|
|
||||||
}
|
|
||||||
if req.Extend != "" {
|
|
||||||
params["extend"] = req.Extend
|
|
||||||
}
|
|
||||||
|
|
||||||
businessData := map[string]interface{}{
|
|
||||||
"params": params,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.doRequest(ctx, "/device/reset", businessData)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// RebootDevice 设备重启
|
// RebootDevice 设备重启
|
||||||
// 远程重启设备
|
// 远程重启设备
|
||||||
|
// POST /device/restart
|
||||||
func (c *Client) RebootDevice(ctx context.Context, req *DeviceOperationReq) error {
|
func (c *Client) RebootDevice(ctx context.Context, req *DeviceOperationReq) error {
|
||||||
params := map[string]interface{}{
|
_, err := c.doRequest(ctx, "/device/restart", req)
|
||||||
"deviceId": req.DeviceID,
|
|
||||||
}
|
|
||||||
if req.Extend != "" {
|
|
||||||
params["extend"] = req.Extend
|
|
||||||
}
|
|
||||||
|
|
||||||
businessData := map[string]interface{}{
|
|
||||||
"params": params,
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.doRequest(ctx, "/device/reboot", businessData)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,121 +5,44 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
"github.com/bytedance/sonic"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// QueryCardStatus 查询流量卡状态
|
// QueryCardStatus 查询流量卡状态
|
||||||
|
// POST /flow-card/status
|
||||||
func (c *Client) QueryCardStatus(ctx context.Context, req *CardStatusReq) (*CardStatusResp, error) {
|
func (c *Client) QueryCardStatus(ctx context.Context, req *CardStatusReq) (*CardStatusResp, error) {
|
||||||
businessData := map[string]interface{}{
|
return doRequestWithResponse[CardStatusResp](c, ctx, "/flow-card/status", req)
|
||||||
"params": map[string]interface{}{
|
|
||||||
"cardNo": req.CardNo,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.doRequest(ctx, "/flow-card/status", businessData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result CardStatusResp
|
|
||||||
if err := sonic.Unmarshal(resp, &result); err != nil {
|
|
||||||
return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析卡状态响应失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryFlow 查询流量使用情况
|
// QueryFlow 查询流量使用情况
|
||||||
|
// POST /flow-card/flow
|
||||||
func (c *Client) QueryFlow(ctx context.Context, req *FlowQueryReq) (*FlowUsageResp, error) {
|
func (c *Client) QueryFlow(ctx context.Context, req *FlowQueryReq) (*FlowUsageResp, error) {
|
||||||
businessData := map[string]interface{}{
|
return doRequestWithResponse[FlowUsageResp](c, ctx, "/flow-card/flow", req)
|
||||||
"params": map[string]interface{}{
|
|
||||||
"cardNo": req.CardNo,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.doRequest(ctx, "/flow-card/flow", businessData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result FlowUsageResp
|
|
||||||
if err := sonic.Unmarshal(resp, &result); err != nil {
|
|
||||||
return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析流量使用响应失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// QueryRealnameStatus 查询实名认证状态
|
// QueryRealnameStatus 查询实名认证状态
|
||||||
|
// POST /flow-card/realName
|
||||||
func (c *Client) QueryRealnameStatus(ctx context.Context, req *CardStatusReq) (*RealnameStatusResp, error) {
|
func (c *Client) QueryRealnameStatus(ctx context.Context, req *CardStatusReq) (*RealnameStatusResp, error) {
|
||||||
businessData := map[string]interface{}{
|
return doRequestWithResponse[RealnameStatusResp](c, ctx, "/flow-card/realName", req)
|
||||||
"params": map[string]interface{}{
|
|
||||||
"cardNo": req.CardNo,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.doRequest(ctx, "/flow-card/realName", businessData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result RealnameStatusResp
|
|
||||||
if err := sonic.Unmarshal(resp, &result); err != nil {
|
|
||||||
return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析实名认证状态响应失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopCard 流量卡停机
|
// StopCard 流量卡停机
|
||||||
|
// POST /flow-card/cardStop
|
||||||
func (c *Client) StopCard(ctx context.Context, req *CardOperationReq) error {
|
func (c *Client) StopCard(ctx context.Context, req *CardOperationReq) error {
|
||||||
businessData := map[string]interface{}{
|
_, err := c.doRequest(ctx, "/flow-card/cardStop", req)
|
||||||
"params": map[string]interface{}{
|
|
||||||
"cardNo": req.CardNo,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if req.Extend != "" {
|
|
||||||
businessData["params"].(map[string]interface{})["extend"] = req.Extend
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.doRequest(ctx, "/flow-card/cardStop", businessData)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// StartCard 流量卡复机
|
// StartCard 流量卡复机
|
||||||
|
// POST /flow-card/cardStart
|
||||||
func (c *Client) StartCard(ctx context.Context, req *CardOperationReq) error {
|
func (c *Client) StartCard(ctx context.Context, req *CardOperationReq) error {
|
||||||
businessData := map[string]interface{}{
|
_, err := c.doRequest(ctx, "/flow-card/cardStart", req)
|
||||||
"params": map[string]interface{}{
|
|
||||||
"cardNo": req.CardNo,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if req.Extend != "" {
|
|
||||||
businessData["params"].(map[string]interface{})["extend"] = req.Extend
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := c.doRequest(ctx, "/flow-card/cardStart", businessData)
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetRealnameLink 获取实名认证跳转链接
|
// GetRealnameLink 获取实名认证跳转链接
|
||||||
|
// POST /flow-card/RealNameVerification
|
||||||
func (c *Client) GetRealnameLink(ctx context.Context, req *CardStatusReq) (*RealnameLinkResp, error) {
|
func (c *Client) GetRealnameLink(ctx context.Context, req *CardStatusReq) (*RealnameLinkResp, error) {
|
||||||
businessData := map[string]interface{}{
|
return doRequestWithResponse[RealnameLinkResp](c, ctx, "/flow-card/RealNameVerification", req)
|
||||||
"params": map[string]interface{}{
|
|
||||||
"cardNo": req.CardNo,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
resp, err := c.doRequest(ctx, "/flow-card/RealNameVerification", businessData)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
var result RealnameLinkResp
|
|
||||||
if err := sonic.Unmarshal(resp, &result); err != nil {
|
|
||||||
return nil, errors.Wrap(errors.CodeGatewayInvalidResp, err, "解析实名认证链接响应失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
return &result, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// BatchQuery 批量查询(预留接口,暂未实现)
|
// BatchQuery 批量查询(预留接口,暂未实现)
|
||||||
|
|||||||
@@ -86,25 +86,27 @@ type DeviceInfoResp struct {
|
|||||||
|
|
||||||
// SpeedLimitReq 是设置设备限速的请求
|
// SpeedLimitReq 是设置设备限速的请求
|
||||||
type SpeedLimitReq struct {
|
type SpeedLimitReq struct {
|
||||||
DeviceID string `json:"deviceId" validate:"required" required:"true" description:"设备 ID/IMEI"`
|
CardNo string `json:"cardNo,omitempty" description:"流量卡号(与 DeviceID 二选一)"`
|
||||||
UploadSpeed int `json:"uploadSpeed" validate:"required,min=1" required:"true" minimum:"1" description:"上行速率(KB/s)"`
|
DeviceID string `json:"deviceId,omitempty" description:"设备 ID/IMEI(与 CardNo 二选一)"`
|
||||||
DownloadSpeed int `json:"downloadSpeed" validate:"required,min=1" required:"true" minimum:"1" description:"下行速率(KB/s)"`
|
SpeedLimit int `json:"speedLimit" validate:"required,min=1" required:"true" minimum:"1" description:"限速值(KB/s)"`
|
||||||
Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
|
Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// WiFiReq 是设置设备 WiFi 的请求
|
// WiFiReq 是设置设备 WiFi 的请求
|
||||||
type WiFiReq struct {
|
type WiFiReq struct {
|
||||||
DeviceID string `json:"deviceId" validate:"required" required:"true" description:"设备 ID/IMEI"`
|
CardNo string `json:"cardNo" validate:"required" required:"true" description:"流量卡号(ICCID)"`
|
||||||
|
DeviceID string `json:"deviceId,omitempty" description:"设备 ID/IMEI"`
|
||||||
SSID string `json:"ssid" validate:"required,min=1,max=32" required:"true" minLength:"1" maxLength:"32" description:"WiFi 名称"`
|
SSID string `json:"ssid" validate:"required,min=1,max=32" required:"true" minLength:"1" maxLength:"32" description:"WiFi 名称"`
|
||||||
Password string `json:"password" validate:"required,min=8,max=63" required:"true" minLength:"8" maxLength:"63" description:"WiFi 密码"`
|
Password string `json:"password,omitempty" description:"WiFi 密码"`
|
||||||
Enabled int `json:"enabled" validate:"required,oneof=0 1" required:"true" description:"启用状态(0:禁用, 1:启用)"`
|
Enabled bool `json:"enabled" description:"启用状态"`
|
||||||
Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
|
Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SwitchCardReq 是设备切换卡的请求
|
// SwitchCardReq 是设备切换卡的请求
|
||||||
type SwitchCardReq struct {
|
type SwitchCardReq struct {
|
||||||
DeviceID string `json:"deviceId" validate:"required" required:"true" description:"设备 ID/IMEI"`
|
CardNo string `json:"cardNo" validate:"required" required:"true" description:"设备编号(IMEI)"`
|
||||||
TargetICCID string `json:"targetIccid" validate:"required" required:"true" description:"目标卡 ICCID"`
|
ICCID string `json:"iccid" validate:"required" required:"true" description:"目标卡 ICCID"`
|
||||||
|
OperationType int `json:"operationType" description:"操作类型(固定值 2)"`
|
||||||
Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
|
Extend string `json:"extend,omitempty" description:"扩展字段(广电国网特殊参数)"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
91
internal/handler/admin/agent_recharge.go
Normal file
91
internal/handler/admin/agent_recharge.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
agentRechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/agent_recharge"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AgentRechargeHandler 代理预充值 Handler
|
||||||
|
type AgentRechargeHandler struct {
|
||||||
|
service *agentRechargeSvc.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAgentRechargeHandler 创建代理预充值 Handler
|
||||||
|
func NewAgentRechargeHandler(service *agentRechargeSvc.Service) *AgentRechargeHandler {
|
||||||
|
return &AgentRechargeHandler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建代理充值订单
|
||||||
|
// POST /api/admin/agent-recharges
|
||||||
|
func (h *AgentRechargeHandler) Create(c *fiber.Ctx) error {
|
||||||
|
var req dto.CreateAgentRechargeRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.Create(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 查询代理充值订单列表
|
||||||
|
// GET /api/admin/agent-recharges
|
||||||
|
func (h *AgentRechargeHandler) List(c *fiber.Ctx) error {
|
||||||
|
var req dto.AgentRechargeListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
list, total, err := h.service.List(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 查询代理充值订单详情
|
||||||
|
// GET /api/admin/agent-recharges/:id
|
||||||
|
func (h *AgentRechargeHandler) Get(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的充值记录ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.GetByID(c.UserContext(), uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// OfflinePay 确认线下充值
|
||||||
|
// POST /api/admin/agent-recharges/:id/offline-pay
|
||||||
|
func (h *AgentRechargeHandler) OfflinePay(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的充值记录ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.AgentOfflinePayRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.OfflinePay(c.UserContext(), uint(id), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
186
internal/handler/admin/asset.go
Normal file
186
internal/handler/admin/asset.go
Normal file
@@ -0,0 +1,186 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||||
|
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||||
|
iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssetHandler 资产管理处理器
|
||||||
|
// 提供统一的资产解析、实时状态、套餐查询、停复机等接口
|
||||||
|
type AssetHandler struct {
|
||||||
|
assetService *assetService.Service
|
||||||
|
deviceService *deviceService.Service
|
||||||
|
iotCardStopResume *iotCardService.StopResumeService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAssetHandler 创建资产管理处理器
|
||||||
|
func NewAssetHandler(
|
||||||
|
assetSvc *assetService.Service,
|
||||||
|
deviceSvc *deviceService.Service,
|
||||||
|
iotCardStopResume *iotCardService.StopResumeService,
|
||||||
|
) *AssetHandler {
|
||||||
|
return &AssetHandler{
|
||||||
|
assetService: assetSvc,
|
||||||
|
deviceService: deviceSvc,
|
||||||
|
iotCardStopResume: iotCardStopResume,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve 通过任意标识符解析资产(设备或卡)
|
||||||
|
// GET /api/admin/assets/resolve/:identifier
|
||||||
|
func (h *AssetHandler) Resolve(c *fiber.Ctx) error {
|
||||||
|
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||||
|
if userType == constants.UserTypeEnterprise {
|
||||||
|
return errors.New(errors.CodeForbidden, "企业账号暂不支持此接口")
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier := c.Params("identifier")
|
||||||
|
if identifier == "" {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "标识符不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.assetService.Resolve(c.UserContext(), identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RealtimeStatus 获取资产实时状态
|
||||||
|
// GET /api/admin/assets/:asset_type/:id/realtime-status
|
||||||
|
func (h *AssetHandler) RealtimeStatus(c *fiber.Ctx) error {
|
||||||
|
assetType := c.Params("asset_type")
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.assetService.GetRealtimeStatus(c.UserContext(), assetType, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh 刷新资产状态(调网关同步)
|
||||||
|
// POST /api/admin/assets/:asset_type/:id/refresh
|
||||||
|
func (h *AssetHandler) Refresh(c *fiber.Ctx) error {
|
||||||
|
assetType := c.Params("asset_type")
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.assetService.Refresh(c.UserContext(), assetType, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Packages 获取资产所有套餐列表
|
||||||
|
// GET /api/admin/assets/:asset_type/:id/packages
|
||||||
|
func (h *AssetHandler) Packages(c *fiber.Ctx) error {
|
||||||
|
assetType := c.Params("asset_type")
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.assetService.GetPackages(c.UserContext(), assetType, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CurrentPackage 获取资产当前生效套餐
|
||||||
|
// GET /api/admin/assets/:asset_type/:id/current-package
|
||||||
|
func (h *AssetHandler) CurrentPackage(c *fiber.Ctx) error {
|
||||||
|
assetType := c.Params("asset_type")
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.assetService.GetCurrentPackage(c.UserContext(), assetType, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopDevice 设备停机(批量停机设备下所有已实名卡)
|
||||||
|
// POST /api/admin/assets/device/:device_id/stop
|
||||||
|
func (h *AssetHandler) StopDevice(c *fiber.Ctx) error {
|
||||||
|
deviceID, err := strconv.ParseUint(c.Params("device_id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.deviceService.StopDevice(c.UserContext(), uint(deviceID))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartDevice 设备复机(批量复机设备下所有已实名卡)
|
||||||
|
// POST /api/admin/assets/device/:device_id/start
|
||||||
|
func (h *AssetHandler) StartDevice(c *fiber.Ctx) error {
|
||||||
|
deviceID, err := strconv.ParseUint(c.Params("device_id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.deviceService.StartDevice(c.UserContext(), uint(deviceID)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StopCard 单卡停机(通过ICCID)
|
||||||
|
// POST /api/admin/assets/card/:iccid/stop
|
||||||
|
func (h *AssetHandler) StopCard(c *fiber.Ctx) error {
|
||||||
|
iccid := c.Params("iccid")
|
||||||
|
if iccid == "" {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.iotCardStopResume.ManualStopCard(c.UserContext(), iccid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// StartCard 单卡复机(通过ICCID)
|
||||||
|
// POST /api/admin/assets/card/:iccid/start
|
||||||
|
func (h *AssetHandler) StartCard(c *fiber.Ctx) error {
|
||||||
|
iccid := c.Params("iccid")
|
||||||
|
if iccid == "" {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.iotCardStopResume.ManualStartCard(c.UserContext(), iccid); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
59
internal/handler/admin/asset_lifecycle.go
Normal file
59
internal/handler/admin/asset_lifecycle.go
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssetLifecycleService 资产生命周期服务接口
|
||||||
|
type AssetLifecycleService interface {
|
||||||
|
// DeactivateIotCard 停用 IoT 卡
|
||||||
|
DeactivateIotCard(ctx context.Context, id uint) error
|
||||||
|
// DeactivateDevice 停用设备
|
||||||
|
DeactivateDevice(ctx context.Context, id uint) error
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssetLifecycleHandler 资产生命周期处理器
|
||||||
|
type AssetLifecycleHandler struct {
|
||||||
|
service AssetLifecycleService
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAssetLifecycleHandler 创建资产生命周期处理器
|
||||||
|
func NewAssetLifecycleHandler(service AssetLifecycleService) *AssetLifecycleHandler {
|
||||||
|
return &AssetLifecycleHandler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateIotCard 手动停用 IoT 卡
|
||||||
|
// PATCH /api/admin/iot-cards/:id/deactivate
|
||||||
|
func (h *AssetLifecycleHandler) DeactivateIotCard(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的卡ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.DeactivateIotCard(c.UserContext(), uint(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DeactivateDevice 手动停用设备
|
||||||
|
// PATCH /api/admin/devices/:id/deactivate
|
||||||
|
func (h *AssetLifecycleHandler) DeactivateDevice(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.DeactivateDevice(c.UserContext(), uint(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
88
internal/handler/admin/asset_wallet.go
Normal file
88
internal/handler/admin/asset_wallet.go
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
assetWalletSvc "github.com/break/junhong_cmp_fiber/internal/service/asset_wallet"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// AssetWalletHandler 资产钱包处理器
|
||||||
|
// 提供管理端资产(卡/设备)钱包概况和流水查询接口
|
||||||
|
type AssetWalletHandler struct {
|
||||||
|
service *assetWalletSvc.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAssetWalletHandler 创建资产钱包处理器
|
||||||
|
func NewAssetWalletHandler(svc *assetWalletSvc.Service) *AssetWalletHandler {
|
||||||
|
return &AssetWalletHandler{service: svc}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWallet 查询资产钱包概况
|
||||||
|
// GET /api/admin/assets/:asset_type/:id/wallet
|
||||||
|
func (h *AssetWalletHandler) GetWallet(c *fiber.Ctx) error {
|
||||||
|
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||||
|
if userType == constants.UserTypeEnterprise {
|
||||||
|
return errors.New(errors.CodeForbidden, "企业账号无权查看钱包信息")
|
||||||
|
}
|
||||||
|
|
||||||
|
assetType := c.Params("asset_type")
|
||||||
|
if assetType != "card" && assetType != "device" {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
|
||||||
|
}
|
||||||
|
|
||||||
|
idStr := c.Params("id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.GetWallet(c.UserContext(), assetType, uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListTransactions 查询资产钱包流水列表
|
||||||
|
// GET /api/admin/assets/:asset_type/:id/wallet/transactions
|
||||||
|
func (h *AssetWalletHandler) ListTransactions(c *fiber.Ctx) error {
|
||||||
|
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||||
|
if userType == constants.UserTypeEnterprise {
|
||||||
|
return errors.New(errors.CodeForbidden, "企业账号无权查看钱包信息")
|
||||||
|
}
|
||||||
|
|
||||||
|
assetType := c.Params("asset_type")
|
||||||
|
if assetType != "card" && assetType != "device" {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的资产类型")
|
||||||
|
}
|
||||||
|
|
||||||
|
idStr := c.Params("id")
|
||||||
|
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的资产ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.AssetWalletTransactionListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PageSize > 100 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "每页数量不能超过100")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.ListTransactions(c.UserContext(), assetType, uint(id), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
|
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
|
deviceService "github.com/break/junhong_cmp_fiber/internal/service/device"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
@@ -16,13 +15,11 @@ import (
|
|||||||
|
|
||||||
type DeviceHandler struct {
|
type DeviceHandler struct {
|
||||||
service *deviceService.Service
|
service *deviceService.Service
|
||||||
gatewayClient *gateway.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewDeviceHandler(service *deviceService.Service, gatewayClient *gateway.Client) *DeviceHandler {
|
func NewDeviceHandler(service *deviceService.Service) *DeviceHandler {
|
||||||
return &DeviceHandler{
|
return &DeviceHandler{
|
||||||
service: service,
|
service: service,
|
||||||
gatewayClient: gatewayClient,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,35 +37,6 @@ func (h *DeviceHandler) List(c *fiber.Ctx) error {
|
|||||||
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
|
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *DeviceHandler) GetByID(c *fiber.Ctx) error {
|
|
||||||
idStr := c.Params("id")
|
|
||||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "无效的设备ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.service.Get(c.UserContext(), uint(id))
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *DeviceHandler) GetByIMEI(c *fiber.Ctx) error {
|
|
||||||
imei := c.Params("imei")
|
|
||||||
if imei == "" {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "设备号不能为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.service.GetByDeviceNo(c.UserContext(), imei)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *DeviceHandler) Delete(c *fiber.Ctx) error {
|
func (h *DeviceHandler) Delete(c *fiber.Ctx) error {
|
||||||
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
userType := middleware.GetUserTypeFromContext(c.UserContext())
|
||||||
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
if userType != constants.UserTypeSuperAdmin && userType != constants.UserTypePlatform {
|
||||||
@@ -224,47 +192,15 @@ func (h *DeviceHandler) BatchSetSeriesBinding(c *fiber.Ctx) error {
|
|||||||
return response.Success(c, result)
|
return response.Success(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetGatewayInfo 查询设备信息
|
|
||||||
func (h *DeviceHandler) GetGatewayInfo(c *fiber.Ctx) error {
|
|
||||||
imei := c.Params("imei")
|
|
||||||
if imei == "" {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "设备号不能为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证权限:查询数据库确认设备存在且用户有权限访问
|
|
||||||
_, err := h.service.GetByDeviceNo(c.UserContext(), imei)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeNotFound, "设备不存在或无权限访问")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 Gateway
|
|
||||||
resp, err := h.gatewayClient.GetDeviceInfo(c.UserContext(), &gateway.DeviceInfoReq{
|
|
||||||
DeviceID: imei,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetGatewaySlots 查询设备卡槽信息
|
// GetGatewaySlots 查询设备卡槽信息
|
||||||
|
// GET /api/admin/devices/by-identifier/:identifier/gateway-slots
|
||||||
func (h *DeviceHandler) GetGatewaySlots(c *fiber.Ctx) error {
|
func (h *DeviceHandler) GetGatewaySlots(c *fiber.Ctx) error {
|
||||||
imei := c.Params("imei")
|
identifier := c.Params("identifier")
|
||||||
if imei == "" {
|
if identifier == "" {
|
||||||
return errors.New(errors.CodeInvalidParam, "设备号不能为空")
|
return errors.New(errors.CodeInvalidParam, "设备标识符不能为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证权限:查询数据库确认设备存在且用户有权限访问
|
resp, err := h.service.GatewayGetSlotInfo(c.UserContext(), identifier)
|
||||||
_, err := h.service.GetByDeviceNo(c.UserContext(), imei)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeNotFound, "设备不存在或无权限访问")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 Gateway
|
|
||||||
resp, err := h.gatewayClient.GetSlotInfo(c.UserContext(), &gateway.DeviceInfoReq{
|
|
||||||
DeviceID: imei,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -273,10 +209,11 @@ func (h *DeviceHandler) GetGatewaySlots(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetSpeedLimit 设置设备限速
|
// SetSpeedLimit 设置设备限速
|
||||||
|
// PUT /api/admin/devices/by-identifier/:identifier/speed-limit
|
||||||
func (h *DeviceHandler) SetSpeedLimit(c *fiber.Ctx) error {
|
func (h *DeviceHandler) SetSpeedLimit(c *fiber.Ctx) error {
|
||||||
imei := c.Params("imei")
|
identifier := c.Params("identifier")
|
||||||
if imei == "" {
|
if identifier == "" {
|
||||||
return errors.New(errors.CodeInvalidParam, "设备号不能为空")
|
return errors.New(errors.CodeInvalidParam, "设备标识符不能为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
var req dto.SetSpeedLimitRequest
|
var req dto.SetSpeedLimitRequest
|
||||||
@@ -284,19 +221,7 @@ func (h *DeviceHandler) SetSpeedLimit(c *fiber.Ctx) error {
|
|||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证权限:查询数据库确认设备存在且用户有权限访问
|
if err := h.service.GatewaySetSpeedLimit(c.UserContext(), identifier, &req); err != nil {
|
||||||
_, err := h.service.GetByDeviceNo(c.UserContext(), imei)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeNotFound, "设备不存在或无权限访问")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 Gateway
|
|
||||||
err = h.gatewayClient.SetSpeedLimit(c.UserContext(), &gateway.SpeedLimitReq{
|
|
||||||
DeviceID: imei,
|
|
||||||
UploadSpeed: req.UploadSpeed,
|
|
||||||
DownloadSpeed: req.DownloadSpeed,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -304,27 +229,19 @@ func (h *DeviceHandler) SetSpeedLimit(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SetWiFi 设置设备 WiFi
|
// SetWiFi 设置设备 WiFi
|
||||||
|
// PUT /api/admin/devices/by-identifier/:identifier/wifi
|
||||||
func (h *DeviceHandler) SetWiFi(c *fiber.Ctx) error {
|
func (h *DeviceHandler) SetWiFi(c *fiber.Ctx) error {
|
||||||
imei := c.Params("imei")
|
identifier := c.Params("identifier")
|
||||||
if imei == "" {
|
if identifier == "" {
|
||||||
return errors.New(errors.CodeInvalidParam, "设备号不能为空")
|
return errors.New(errors.CodeInvalidParam, "设备标识符不能为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
var req gateway.WiFiReq
|
var req dto.SetWiFiRequest
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证权限:查询数据库确认设备存在且用户有权限访问
|
if err := h.service.GatewaySetWiFi(c.UserContext(), identifier, &req); err != nil {
|
||||||
_, err := h.service.GetByDeviceNo(c.UserContext(), imei)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeNotFound, "设备不存在或无权限访问")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 Gateway
|
|
||||||
req.DeviceID = imei
|
|
||||||
err = h.gatewayClient.SetWiFi(c.UserContext(), &req)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -332,10 +249,11 @@ func (h *DeviceHandler) SetWiFi(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SwitchCard 切换设备使用的卡
|
// SwitchCard 切换设备使用的卡
|
||||||
|
// POST /api/admin/devices/by-identifier/:identifier/switch-card
|
||||||
func (h *DeviceHandler) SwitchCard(c *fiber.Ctx) error {
|
func (h *DeviceHandler) SwitchCard(c *fiber.Ctx) error {
|
||||||
imei := c.Params("imei")
|
identifier := c.Params("identifier")
|
||||||
if imei == "" {
|
if identifier == "" {
|
||||||
return errors.New(errors.CodeInvalidParam, "设备号不能为空")
|
return errors.New(errors.CodeInvalidParam, "设备标识符不能为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
var req dto.SwitchCardRequest
|
var req dto.SwitchCardRequest
|
||||||
@@ -343,18 +261,7 @@ func (h *DeviceHandler) SwitchCard(c *fiber.Ctx) error {
|
|||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证权限:查询数据库确认设备存在且用户有权限访问
|
if err := h.service.GatewaySwitchCard(c.UserContext(), identifier, &req); err != nil {
|
||||||
_, err := h.service.GetByDeviceNo(c.UserContext(), imei)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeNotFound, "设备不存在或无权限访问")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 Gateway
|
|
||||||
err = h.gatewayClient.SwitchCard(c.UserContext(), &gateway.SwitchCardReq{
|
|
||||||
DeviceID: imei,
|
|
||||||
TargetICCID: req.TargetICCID,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -362,23 +269,14 @@ func (h *DeviceHandler) SwitchCard(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// RebootDevice 重启设备
|
// RebootDevice 重启设备
|
||||||
|
// POST /api/admin/devices/by-identifier/:identifier/reboot
|
||||||
func (h *DeviceHandler) RebootDevice(c *fiber.Ctx) error {
|
func (h *DeviceHandler) RebootDevice(c *fiber.Ctx) error {
|
||||||
imei := c.Params("imei")
|
identifier := c.Params("identifier")
|
||||||
if imei == "" {
|
if identifier == "" {
|
||||||
return errors.New(errors.CodeInvalidParam, "设备号不能为空")
|
return errors.New(errors.CodeInvalidParam, "设备标识符不能为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证权限:查询数据库确认设备存在且用户有权限访问
|
if err := h.service.GatewayRebootDevice(c.UserContext(), identifier); err != nil {
|
||||||
_, err := h.service.GetByDeviceNo(c.UserContext(), imei)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeNotFound, "设备不存在或无权限访问")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 Gateway
|
|
||||||
err = h.gatewayClient.RebootDevice(c.UserContext(), &gateway.DeviceOperationReq{
|
|
||||||
DeviceID: imei,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,23 +284,14 @@ func (h *DeviceHandler) RebootDevice(c *fiber.Ctx) error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ResetDevice 恢复设备出厂设置
|
// ResetDevice 恢复设备出厂设置
|
||||||
|
// POST /api/admin/devices/by-identifier/:identifier/reset
|
||||||
func (h *DeviceHandler) ResetDevice(c *fiber.Ctx) error {
|
func (h *DeviceHandler) ResetDevice(c *fiber.Ctx) error {
|
||||||
imei := c.Params("imei")
|
identifier := c.Params("identifier")
|
||||||
if imei == "" {
|
if identifier == "" {
|
||||||
return errors.New(errors.CodeInvalidParam, "设备号不能为空")
|
return errors.New(errors.CodeInvalidParam, "设备标识符不能为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证权限:查询数据库确认设备存在且用户有权限访问
|
if err := h.service.GatewayResetDevice(c.UserContext(), identifier); err != nil {
|
||||||
_, err := h.service.GetByDeviceNo(c.UserContext(), imei)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeNotFound, "设备不存在或无权限访问")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 Gateway
|
|
||||||
err = h.gatewayClient.ResetDevice(c.UserContext(), &gateway.DeviceOperationReq{
|
|
||||||
DeviceID: imei,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -78,43 +78,3 @@ func (h *EnterpriseCardHandler) ListCards(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
|
return response.SuccessWithPagination(c, result.Items, result.Total, result.Page, result.Size)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *EnterpriseCardHandler) SuspendCard(c *fiber.Ctx) error {
|
|
||||||
enterpriseIDStr := c.Params("id")
|
|
||||||
enterpriseID, err := strconv.ParseUint(enterpriseIDStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "无效的企业ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
cardIDStr := c.Params("card_id")
|
|
||||||
cardID, err := strconv.ParseUint(cardIDStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "无效的卡ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.service.SuspendCard(c.UserContext(), uint(enterpriseID), uint(cardID)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *EnterpriseCardHandler) ResumeCard(c *fiber.Ctx) error {
|
|
||||||
enterpriseIDStr := c.Params("id")
|
|
||||||
enterpriseID, err := strconv.ParseUint(enterpriseIDStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "无效的企业ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
cardIDStr := c.Params("card_id")
|
|
||||||
cardID, err := strconv.ParseUint(cardIDStr, 10, 64)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "无效的卡ID")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.service.ResumeCard(c.UserContext(), uint(enterpriseID), uint(cardID)); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|||||||
131
internal/handler/admin/exchange.go
Normal file
131
internal/handler/admin/exchange.go
Normal file
@@ -0,0 +1,131 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
exchangeService "github.com/break/junhong_cmp_fiber/internal/service/exchange"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ExchangeHandler struct {
|
||||||
|
service *exchangeService.Service
|
||||||
|
validator *validator.Validate
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewExchangeHandler(service *exchangeService.Service, validator *validator.Validate) *ExchangeHandler {
|
||||||
|
return &ExchangeHandler{service: service, validator: validator}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExchangeHandler) Create(c *fiber.Ctx) error {
|
||||||
|
var req dto.CreateExchangeRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.service.Create(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExchangeHandler) List(c *fiber.Ctx) error {
|
||||||
|
var req dto.ExchangeListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.service.List(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExchangeHandler) Get(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的换货单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.service.Get(c.UserContext(), uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExchangeHandler) Ship(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的换货单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.ExchangeShipRequest
|
||||||
|
if err = c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
if err = h.validator.Struct(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.service.Ship(c.UserContext(), uint(id), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExchangeHandler) Complete(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的换货单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = h.service.Complete(c.UserContext(), uint(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExchangeHandler) Cancel(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的换货单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.ExchangeCancelRequest
|
||||||
|
if err = c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
if err = h.validator.Struct(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = h.service.Cancel(c.UserContext(), uint(id), &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ExchangeHandler) Renew(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的换货单ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = h.service.Renew(c.UserContext(), uint(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@ package admin
|
|||||||
import (
|
import (
|
||||||
"github.com/gofiber/fiber/v2"
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
iotCardService "github.com/break/junhong_cmp_fiber/internal/service/iot_card"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
@@ -14,13 +13,11 @@ import (
|
|||||||
|
|
||||||
type IotCardHandler struct {
|
type IotCardHandler struct {
|
||||||
service *iotCardService.Service
|
service *iotCardService.Service
|
||||||
gatewayClient *gateway.Client
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewIotCardHandler(service *iotCardService.Service, gatewayClient *gateway.Client) *IotCardHandler {
|
func NewIotCardHandler(service *iotCardService.Service) *IotCardHandler {
|
||||||
return &IotCardHandler{
|
return &IotCardHandler{
|
||||||
service: service,
|
service: service,
|
||||||
gatewayClient: gatewayClient,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,20 +35,6 @@ func (h *IotCardHandler) ListStandalone(c *fiber.Ctx) error {
|
|||||||
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
|
return response.SuccessWithPagination(c, result.List, result.Total, result.Page, result.PageSize)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *IotCardHandler) GetByICCID(c *fiber.Ctx) error {
|
|
||||||
iccid := c.Params("iccid")
|
|
||||||
if iccid == "" {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.service.GetByICCID(c.UserContext(), iccid)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *IotCardHandler) AllocateCards(c *fiber.Ctx) error {
|
func (h *IotCardHandler) AllocateCards(c *fiber.Ctx) error {
|
||||||
var req dto.AllocateStandaloneCardsRequest
|
var req dto.AllocateStandaloneCardsRequest
|
||||||
if err := c.BodyParser(&req); err != nil {
|
if err := c.BodyParser(&req); err != nil {
|
||||||
@@ -129,78 +112,6 @@ func (h *IotCardHandler) BatchSetSeriesBinding(c *fiber.Ctx) error {
|
|||||||
return response.Success(c, result)
|
return response.Success(c, result)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetGatewayStatus 查询卡实时状态
|
|
||||||
func (h *IotCardHandler) GetGatewayStatus(c *fiber.Ctx) error {
|
|
||||||
iccid := c.Params("iccid")
|
|
||||||
if iccid == "" {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证权限:查询数据库确认卡存在且用户有权限访问
|
|
||||||
_, err := h.service.GetByICCID(c.UserContext(), iccid)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeNotFound, "卡不存在或无权限访问")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 Gateway
|
|
||||||
resp, err := h.gatewayClient.QueryCardStatus(c.UserContext(), &gateway.CardStatusReq{
|
|
||||||
CardNo: iccid,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetGatewayFlow 查询流量使用情况
|
|
||||||
func (h *IotCardHandler) GetGatewayFlow(c *fiber.Ctx) error {
|
|
||||||
iccid := c.Params("iccid")
|
|
||||||
if iccid == "" {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证权限:查询数据库确认卡存在且用户有权限访问
|
|
||||||
_, err := h.service.GetByICCID(c.UserContext(), iccid)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeNotFound, "卡不存在或无权限访问")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 Gateway
|
|
||||||
resp, err := h.gatewayClient.QueryFlow(c.UserContext(), &gateway.FlowQueryReq{
|
|
||||||
CardNo: iccid,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetGatewayRealname 查询实名认证状态
|
|
||||||
func (h *IotCardHandler) GetGatewayRealname(c *fiber.Ctx) error {
|
|
||||||
iccid := c.Params("iccid")
|
|
||||||
if iccid == "" {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证权限:查询数据库确认卡存在且用户有权限访问
|
|
||||||
_, err := h.service.GetByICCID(c.UserContext(), iccid)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeNotFound, "卡不存在或无权限访问")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 Gateway
|
|
||||||
resp, err := h.gatewayClient.QueryRealnameStatus(c.UserContext(), &gateway.CardStatusReq{
|
|
||||||
CardNo: iccid,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetRealnameLink 获取实名认证链接
|
// GetRealnameLink 获取实名认证链接
|
||||||
func (h *IotCardHandler) GetRealnameLink(c *fiber.Ctx) error {
|
func (h *IotCardHandler) GetRealnameLink(c *fiber.Ctx) error {
|
||||||
iccid := c.Params("iccid")
|
iccid := c.Params("iccid")
|
||||||
@@ -208,67 +119,10 @@ func (h *IotCardHandler) GetRealnameLink(c *fiber.Ctx) error {
|
|||||||
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 验证权限:查询数据库确认卡存在且用户有权限访问
|
link, err := h.service.GatewayGetRealnameLink(c.UserContext(), iccid)
|
||||||
_, err := h.service.GetByICCID(c.UserContext(), iccid)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeNotFound, "卡不存在或无权限访问")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 Gateway
|
|
||||||
link, err := h.gatewayClient.GetRealnameLink(c.UserContext(), &gateway.CardStatusReq{
|
|
||||||
CardNo: iccid,
|
|
||||||
})
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return response.Success(c, link)
|
return response.Success(c, link)
|
||||||
}
|
}
|
||||||
|
|
||||||
// StopCard 停止卡服务
|
|
||||||
func (h *IotCardHandler) StopCard(c *fiber.Ctx) error {
|
|
||||||
iccid := c.Params("iccid")
|
|
||||||
if iccid == "" {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证权限:查询数据库确认卡存在且用户有权限访问
|
|
||||||
_, err := h.service.GetByICCID(c.UserContext(), iccid)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeNotFound, "卡不存在或无权限访问")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 Gateway
|
|
||||||
err = h.gatewayClient.StopCard(c.UserContext(), &gateway.CardOperationReq{
|
|
||||||
CardNo: iccid,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|
||||||
// StartCard 恢复卡服务
|
|
||||||
func (h *IotCardHandler) StartCard(c *fiber.Ctx) error {
|
|
||||||
iccid := c.Params("iccid")
|
|
||||||
if iccid == "" {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "ICCID不能为空")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 验证权限:查询数据库确认卡存在且用户有权限访问
|
|
||||||
_, err := h.service.GetByICCID(c.UserContext(), iccid)
|
|
||||||
if err != nil {
|
|
||||||
return errors.New(errors.CodeNotFound, "卡不存在或无权限访问")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 调用 Gateway
|
|
||||||
err = h.gatewayClient.StartCard(c.UserContext(), &gateway.CardOperationReq{
|
|
||||||
CardNo: iccid,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, nil)
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -128,3 +128,21 @@ func (h *PackageHandler) UpdateShelfStatus(c *fiber.Ctx) error {
|
|||||||
|
|
||||||
return response.Success(c, nil)
|
return response.Success(c, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *PackageHandler) UpdateRetailPrice(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的套餐 ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UpdateRetailPriceRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.UpdateRetailPrice(c.UserContext(), uint(id), req.RetailPrice); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
|
|||||||
153
internal/handler/admin/wechat_config.go
Normal file
153
internal/handler/admin/wechat_config.go
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
package admin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
wechatConfigService "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
// WechatConfigHandler 微信参数配置 HTTP 处理器
|
||||||
|
type WechatConfigHandler struct {
|
||||||
|
service *wechatConfigService.Service
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewWechatConfigHandler 创建微信参数配置处理器实例
|
||||||
|
func NewWechatConfigHandler(service *wechatConfigService.Service) *WechatConfigHandler {
|
||||||
|
return &WechatConfigHandler{service: service}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 创建微信参数配置
|
||||||
|
// POST /api/admin/wechat-configs
|
||||||
|
func (h *WechatConfigHandler) Create(c *fiber.Ctx) error {
|
||||||
|
var req dto.CreateWechatConfigRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.Create(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// List 获取微信参数配置列表
|
||||||
|
// GET /api/admin/wechat-configs
|
||||||
|
func (h *WechatConfigHandler) List(c *fiber.Ctx) error {
|
||||||
|
var req dto.WechatConfigListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
configs, total, err := h.service.List(c.UserContext(), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithPagination(c, configs, total, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get 获取微信参数配置详情
|
||||||
|
// GET /api/admin/wechat-configs/:id
|
||||||
|
func (h *WechatConfigHandler) Get(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.Get(c.UserContext(), uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update 更新微信参数配置
|
||||||
|
// PUT /api/admin/wechat-configs/:id
|
||||||
|
func (h *WechatConfigHandler) Update(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.UpdateWechatConfigRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.Update(c.UserContext(), uint(id), &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete 删除微信参数配置
|
||||||
|
// DELETE /api/admin/wechat-configs/:id
|
||||||
|
func (h *WechatConfigHandler) Delete(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.service.Delete(c.UserContext(), uint(id)); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Activate 激活微信参数配置
|
||||||
|
// POST /api/admin/wechat-configs/:id/activate
|
||||||
|
func (h *WechatConfigHandler) Activate(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.Activate(c.UserContext(), uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deactivate 停用微信参数配置
|
||||||
|
// POST /api/admin/wechat-configs/:id/deactivate
|
||||||
|
func (h *WechatConfigHandler) Deactivate(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam, "无效的配置 ID")
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.service.Deactivate(c.UserContext(), uint(id))
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetActive 获取当前生效的微信参数配置
|
||||||
|
// GET /api/admin/wechat-configs/active
|
||||||
|
func (h *WechatConfigHandler) GetActive(c *fiber.Ctx) error {
|
||||||
|
result, err := h.service.GetActiveConfigForAPI(c.UserContext())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if result == nil {
|
||||||
|
return response.SuccessWithMessage(c, nil, "当前无生效的支付配置,仅支持钱包支付")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, result)
|
||||||
|
}
|
||||||
635
internal/handler/app/client_asset.go
Normal file
635
internal/handler/app/client_asset.go
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientAssetHandler C 端资产信息处理器
|
||||||
|
// 提供 B1~B4 资产信息、可购套餐、套餐历史、手动刷新接口
|
||||||
|
type ClientAssetHandler struct {
|
||||||
|
assetService *asset.Service
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore
|
||||||
|
assetWalletStore *postgres.AssetWalletStore
|
||||||
|
packageStore *postgres.PackageStore
|
||||||
|
shopPackageAllocationStore *postgres.ShopPackageAllocationStore
|
||||||
|
iotCardStore *postgres.IotCardStore
|
||||||
|
deviceStore *postgres.DeviceStore
|
||||||
|
db *gorm.DB
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientAssetHandler 创建 C 端资产信息处理器
|
||||||
|
func NewClientAssetHandler(
|
||||||
|
assetService *asset.Service,
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
|
||||||
|
assetWalletStore *postgres.AssetWalletStore,
|
||||||
|
packageStore *postgres.PackageStore,
|
||||||
|
shopPackageAllocationStore *postgres.ShopPackageAllocationStore,
|
||||||
|
iotCardStore *postgres.IotCardStore,
|
||||||
|
deviceStore *postgres.DeviceStore,
|
||||||
|
db *gorm.DB,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *ClientAssetHandler {
|
||||||
|
return &ClientAssetHandler{
|
||||||
|
assetService: assetService,
|
||||||
|
personalDeviceStore: personalDeviceStore,
|
||||||
|
assetWalletStore: assetWalletStore,
|
||||||
|
packageStore: packageStore,
|
||||||
|
shopPackageAllocationStore: shopPackageAllocationStore,
|
||||||
|
iotCardStore: iotCardStore,
|
||||||
|
deviceStore: deviceStore,
|
||||||
|
db: db,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolvedAssetContext struct {
|
||||||
|
CustomerID uint
|
||||||
|
Identifier string
|
||||||
|
Asset *dto.AssetResolveResponse
|
||||||
|
Generation int
|
||||||
|
WalletBalance int64
|
||||||
|
SkipPermissionCtx context.Context
|
||||||
|
IsAgentChannel bool
|
||||||
|
SellerShopID uint
|
||||||
|
MainPackageActived bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAssetFromIdentifier 统一执行资产解析与归属校验
|
||||||
|
// 处理流程:客户鉴权 -> 标识符解析 -> 资产解析 -> 归属校验 -> 世代与钱包信息补齐
|
||||||
|
func (h *ClientAssetHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedAssetContext, error) {
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return nil, errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier = strings.TrimSpace(identifier)
|
||||||
|
if identifier == "" {
|
||||||
|
identifier = strings.TrimSpace(c.Query("identifier"))
|
||||||
|
}
|
||||||
|
if identifier == "" {
|
||||||
|
var req dto.AssetRefreshRequest
|
||||||
|
if err := c.BodyParser(&req); err == nil {
|
||||||
|
identifier = strings.TrimSpace(req.Identifier)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if identifier == "" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{})
|
||||||
|
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo)
|
||||||
|
if ownErr != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
|
||||||
|
if genErr != nil {
|
||||||
|
return nil, genErr
|
||||||
|
}
|
||||||
|
|
||||||
|
walletBalance, walletErr := h.getAssetWalletBalance(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
|
||||||
|
if walletErr != nil {
|
||||||
|
return nil, walletErr
|
||||||
|
}
|
||||||
|
|
||||||
|
ctxInfo := &resolvedAssetContext{
|
||||||
|
CustomerID: customerID,
|
||||||
|
Identifier: identifier,
|
||||||
|
Asset: assetInfo,
|
||||||
|
Generation: generation,
|
||||||
|
WalletBalance: walletBalance,
|
||||||
|
SkipPermissionCtx: skipPermissionCtx,
|
||||||
|
}
|
||||||
|
|
||||||
|
if assetInfo.ShopID != nil && *assetInfo.ShopID > 0 {
|
||||||
|
ctxInfo.IsAgentChannel = true
|
||||||
|
ctxInfo.SellerShopID = *assetInfo.ShopID
|
||||||
|
}
|
||||||
|
|
||||||
|
return ctxInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAssetInfo B1 资产信息
|
||||||
|
// GET /api/c/v1/asset/info
|
||||||
|
func (h *ClientAssetHandler) GetAssetInfo(c *fiber.Ctx) error {
|
||||||
|
var req dto.AssetInfoRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &dto.AssetInfoResponse{
|
||||||
|
AssetType: resolved.Asset.AssetType,
|
||||||
|
AssetID: resolved.Asset.AssetID,
|
||||||
|
Identifier: resolved.Identifier,
|
||||||
|
VirtualNo: resolved.Asset.VirtualNo,
|
||||||
|
Status: resolved.Asset.Status,
|
||||||
|
RealNameStatus: resolved.Asset.RealNameStatus,
|
||||||
|
CarrierName: resolved.Asset.CarrierName,
|
||||||
|
Generation: strconv.Itoa(resolved.Generation),
|
||||||
|
WalletBalance: resolved.WalletBalance,
|
||||||
|
ActivatedAt: resolved.Asset.ActivatedAt,
|
||||||
|
CurrentPackage: resolved.Asset.CurrentPackage,
|
||||||
|
PackageTotalMB: resolved.Asset.PackageTotalMB,
|
||||||
|
PackageUsedMB: resolved.Asset.PackageUsedMB,
|
||||||
|
PackageRemainMB: resolved.Asset.PackageRemainMB,
|
||||||
|
DeviceName: resolved.Asset.DeviceName,
|
||||||
|
IMEI: resolved.Asset.IMEI,
|
||||||
|
SN: resolved.Asset.SN,
|
||||||
|
DeviceModel: resolved.Asset.DeviceModel,
|
||||||
|
DeviceType: resolved.Asset.DeviceType,
|
||||||
|
Manufacturer: resolved.Asset.Manufacturer,
|
||||||
|
MaxSimSlots: resolved.Asset.MaxSimSlots,
|
||||||
|
BoundCardCount: resolved.Asset.BoundCardCount,
|
||||||
|
Cards: resolved.Asset.Cards,
|
||||||
|
DeviceProtectStatus: resolved.Asset.DeviceProtectStatus,
|
||||||
|
ICCID: resolved.Asset.ICCID,
|
||||||
|
MSISDN: resolved.Asset.MSISDN,
|
||||||
|
CarrierID: resolved.Asset.CarrierID,
|
||||||
|
CarrierType: resolved.Asset.CarrierType,
|
||||||
|
NetworkStatus: resolved.Asset.NetworkStatus,
|
||||||
|
ActivationStatus: resolved.Asset.ActivationStatus,
|
||||||
|
CardCategory: resolved.Asset.CardCategory,
|
||||||
|
BoundDeviceID: resolved.Asset.BoundDeviceID,
|
||||||
|
BoundDeviceNo: resolved.Asset.BoundDeviceNo,
|
||||||
|
BoundDeviceName: resolved.Asset.BoundDeviceName,
|
||||||
|
}
|
||||||
|
|
||||||
|
// TODO: Gateway 同步接口对接后,替换为真实设备实时数据
|
||||||
|
if resp.AssetType == "device" {
|
||||||
|
resp.DeviceRealtime = buildMockDeviceRealtime()
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAvailablePackages B2 资产可购套餐列表
|
||||||
|
// GET /api/c/v1/asset/packages
|
||||||
|
func (h *ClientAssetHandler) GetAvailablePackages(c *fiber.Ctx) error {
|
||||||
|
var req dto.AssetPackageListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if resolved.Asset.SeriesID == nil || *resolved.Asset.SeriesID == 0 {
|
||||||
|
return errors.New(errors.CodeNoAvailablePackage, "当前无可购买套餐")
|
||||||
|
}
|
||||||
|
|
||||||
|
allUsages, err := h.assetService.GetPackages(resolved.SkipPermissionCtx, resolved.Asset.AssetType, resolved.Asset.AssetID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
resolved.MainPackageActived = hasActiveMainPackage(allUsages)
|
||||||
|
|
||||||
|
listCtx := resolved.SkipPermissionCtx
|
||||||
|
if resolved.IsAgentChannel {
|
||||||
|
listCtx = context.WithValue(listCtx, constants.ContextKeyUserType, constants.UserTypeAgent)
|
||||||
|
listCtx = context.WithValue(listCtx, constants.ContextKeyShopID, resolved.SellerShopID)
|
||||||
|
}
|
||||||
|
|
||||||
|
pkgs, _, err := h.packageStore.List(listCtx, &store.QueryOptions{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: constants.MaxPageSize,
|
||||||
|
OrderBy: "id DESC",
|
||||||
|
}, map[string]any{
|
||||||
|
"series_id": *resolved.Asset.SeriesID,
|
||||||
|
"status": constants.StatusEnabled,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询可购套餐失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
allocationMap := make(map[uint]*model.ShopPackageAllocation)
|
||||||
|
if resolved.IsAgentChannel {
|
||||||
|
packageIDs := collectPackageIDs(pkgs)
|
||||||
|
allocations, allocErr := h.shopPackageAllocationStore.GetByShopAndPackages(
|
||||||
|
resolved.SkipPermissionCtx,
|
||||||
|
resolved.SellerShopID,
|
||||||
|
packageIDs,
|
||||||
|
)
|
||||||
|
if allocErr != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, allocErr, "查询套餐分配记录失败")
|
||||||
|
}
|
||||||
|
for _, allocation := range allocations {
|
||||||
|
allocationMap[allocation.PackageID] = allocation
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]dto.ClientPackageItem, 0, len(pkgs))
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
item, ok := buildClientPackageItem(pkg, resolved, allocationMap)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, item)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
return errors.New(errors.CodeNoAvailablePackage, "当前无可购买套餐")
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Slice(items, func(i, j int) bool {
|
||||||
|
return items[i].RetailPrice < items[j].RetailPrice
|
||||||
|
})
|
||||||
|
|
||||||
|
return response.Success(c, &dto.AssetPackageListResponse{Packages: items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPackageHistory B3 资产套餐历史
|
||||||
|
// GET /api/c/v1/asset/package-history
|
||||||
|
func (h *ClientAssetHandler) GetPackageHistory(c *fiber.Ctx) error {
|
||||||
|
var req dto.AssetPackageHistoryRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Page < 1 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PageSize < 1 {
|
||||||
|
req.PageSize = constants.DefaultPageSize
|
||||||
|
}
|
||||||
|
if req.PageSize > constants.MaxPageSize {
|
||||||
|
req.PageSize = constants.MaxPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := h.db.WithContext(resolved.SkipPermissionCtx).Model(&model.PackageUsage{}).
|
||||||
|
Where("generation = ?", resolved.Generation)
|
||||||
|
if resolved.Asset.AssetType == "card" {
|
||||||
|
query = query.Where("iot_card_id = ?", resolved.Asset.AssetID)
|
||||||
|
} else {
|
||||||
|
query = query.Where("device_id = ?", resolved.Asset.AssetID)
|
||||||
|
}
|
||||||
|
if req.Status != nil {
|
||||||
|
query = query.Where("status = ?", *req.Status)
|
||||||
|
}
|
||||||
|
if req.PackageType != nil {
|
||||||
|
query = query.Where("package_id IN (?)",
|
||||||
|
h.db.Model(&model.Package{}).Select("id").Where("package_type = ?", *req.PackageType))
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史总数失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var usages []*model.PackageUsage
|
||||||
|
offset := (req.Page - 1) * req.PageSize
|
||||||
|
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&usages).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询套餐历史失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
packageMap, err := h.loadPackageMap(resolved.SkipPermissionCtx, usages)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]dto.AssetPackageResponse, 0, len(usages))
|
||||||
|
for _, usage := range usages {
|
||||||
|
pkg := packageMap[usage.PackageID]
|
||||||
|
ratio := 1.0
|
||||||
|
pkgName := ""
|
||||||
|
pkgType := ""
|
||||||
|
if pkg != nil {
|
||||||
|
ratio = safeVirtualRatio(pkg.VirtualRatio)
|
||||||
|
pkgName = pkg.PackageName
|
||||||
|
pkgType = pkg.PackageType
|
||||||
|
}
|
||||||
|
|
||||||
|
list = append(list, dto.AssetPackageResponse{
|
||||||
|
PackageUsageID: usage.ID,
|
||||||
|
PackageID: usage.PackageID,
|
||||||
|
PackageName: pkgName,
|
||||||
|
PackageType: pkgType,
|
||||||
|
UsageType: usage.UsageType,
|
||||||
|
Status: usage.Status,
|
||||||
|
StatusName: packageStatusName(usage.Status),
|
||||||
|
DataLimitMB: usage.DataLimitMB,
|
||||||
|
VirtualLimitMB: int64(float64(usage.DataLimitMB) / ratio),
|
||||||
|
DataUsageMB: usage.DataUsageMB,
|
||||||
|
VirtualUsedMB: float64(usage.DataUsageMB) / ratio,
|
||||||
|
VirtualRemainMB: float64(usage.DataLimitMB-usage.DataUsageMB) / ratio,
|
||||||
|
VirtualRatio: ratio,
|
||||||
|
ActivatedAt: nonZeroTimePtr(usage.ActivatedAt),
|
||||||
|
ExpiresAt: nonZeroTimePtr(usage.ExpiresAt),
|
||||||
|
MasterUsageID: usage.MasterUsageID,
|
||||||
|
Priority: usage.Priority,
|
||||||
|
CreatedAt: usage.CreatedAt,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshAsset B4 资产刷新
|
||||||
|
// POST /api/c/v1/asset/refresh
|
||||||
|
func (h *ClientAssetHandler) RefreshAsset(c *fiber.Ctx) error {
|
||||||
|
var req dto.AssetRefreshRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := h.assetService.Refresh(
|
||||||
|
resolved.SkipPermissionCtx,
|
||||||
|
resolved.Asset.AssetType,
|
||||||
|
resolved.Asset.AssetID,
|
||||||
|
); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &dto.AssetRefreshResponse{
|
||||||
|
RefreshType: resolved.Asset.AssetType,
|
||||||
|
Accepted: true,
|
||||||
|
CooldownSeconds: 0,
|
||||||
|
}
|
||||||
|
if resolved.Asset.AssetType == constants.ResourceTypeDevice {
|
||||||
|
resp.CooldownSeconds = int(constants.DeviceRefreshCooldownDuration / time.Second)
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientAssetHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) {
|
||||||
|
records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, record := range records {
|
||||||
|
if record == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientAssetHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
|
||||||
|
switch assetType {
|
||||||
|
case "card":
|
||||||
|
card, err := h.iotCardStore.GetByID(ctx, assetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return 0, errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
|
||||||
|
}
|
||||||
|
return card.Generation, nil
|
||||||
|
case "device":
|
||||||
|
device, err := h.deviceStore.GetByID(ctx, assetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return 0, errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
|
||||||
|
}
|
||||||
|
return device.Generation, nil
|
||||||
|
default:
|
||||||
|
return 0, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientAssetHandler) getAssetWalletBalance(ctx context.Context, assetType string, assetID uint) (int64, error) {
|
||||||
|
resourceType := constants.AssetWalletResourceTypeIotCard
|
||||||
|
if assetType == constants.ResourceTypeDevice {
|
||||||
|
resourceType = constants.AssetWalletResourceTypeDevice
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet, err := h.assetWalletStore.GetByResourceTypeAndID(ctx, resourceType, assetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return 0, nil
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询资产钱包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return wallet.Balance, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientAssetHandler) loadPackageMap(ctx context.Context, usages []*model.PackageUsage) (map[uint]*model.Package, error) {
|
||||||
|
ids := make([]uint, 0, len(usages))
|
||||||
|
seen := make(map[uint]struct{}, len(usages))
|
||||||
|
for _, usage := range usages {
|
||||||
|
if usage == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[usage.PackageID]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[usage.PackageID] = struct{}{}
|
||||||
|
ids = append(ids, usage.PackageID)
|
||||||
|
}
|
||||||
|
|
||||||
|
packages, err := h.packageStore.GetByIDsUnscoped(ctx, ids)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询套餐信息失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make(map[uint]*model.Package, len(packages))
|
||||||
|
for _, pkg := range packages {
|
||||||
|
result[pkg.ID] = pkg
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectPackageIDs(pkgs []*model.Package) []uint {
|
||||||
|
ids := make([]uint, 0, len(pkgs))
|
||||||
|
for _, pkg := range pkgs {
|
||||||
|
if pkg == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ids = append(ids, pkg.ID)
|
||||||
|
}
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasActiveMainPackage(usages []*dto.AssetPackageResponse) bool {
|
||||||
|
for _, usage := range usages {
|
||||||
|
if usage == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if usage.PackageType == constants.PackageTypeFormal && usage.Status == constants.PackageUsageStatusActive {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildClientPackageItem(
|
||||||
|
pkg *model.Package,
|
||||||
|
resolved *resolvedAssetContext,
|
||||||
|
allocationMap map[uint]*model.ShopPackageAllocation,
|
||||||
|
) (dto.ClientPackageItem, bool) {
|
||||||
|
if pkg == nil || pkg.Status != constants.StatusEnabled {
|
||||||
|
return dto.ClientPackageItem{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
isAddon := pkg.PackageType == constants.PackageTypeAddon
|
||||||
|
if isAddon && !resolved.MainPackageActived {
|
||||||
|
return dto.ClientPackageItem{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
retailPrice := pkg.SuggestedRetailPrice
|
||||||
|
costPrice := pkg.CostPrice
|
||||||
|
|
||||||
|
if resolved.IsAgentChannel {
|
||||||
|
allocation, ok := allocationMap[pkg.ID]
|
||||||
|
if !ok || allocation == nil {
|
||||||
|
return dto.ClientPackageItem{}, false
|
||||||
|
}
|
||||||
|
if allocation.ShelfStatus != constants.ShelfStatusOn || allocation.Status != constants.StatusEnabled {
|
||||||
|
return dto.ClientPackageItem{}, false
|
||||||
|
}
|
||||||
|
retailPrice = allocation.RetailPrice
|
||||||
|
costPrice = allocation.CostPrice
|
||||||
|
} else if pkg.ShelfStatus != constants.ShelfStatusOn {
|
||||||
|
return dto.ClientPackageItem{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if retailPrice < costPrice {
|
||||||
|
return dto.ClientPackageItem{}, false
|
||||||
|
}
|
||||||
|
|
||||||
|
validityDays := pkg.DurationDays
|
||||||
|
if validityDays <= 0 && pkg.DurationMonths > 0 {
|
||||||
|
validityDays = pkg.DurationMonths * 30
|
||||||
|
}
|
||||||
|
|
||||||
|
dataAllowance := pkg.VirtualDataMB
|
||||||
|
if dataAllowance <= 0 {
|
||||||
|
dataAllowance = pkg.RealDataMB
|
||||||
|
}
|
||||||
|
|
||||||
|
return dto.ClientPackageItem{
|
||||||
|
PackageID: pkg.ID,
|
||||||
|
PackageName: pkg.PackageName,
|
||||||
|
PackageType: pkg.PackageType,
|
||||||
|
RetailPrice: retailPrice,
|
||||||
|
CostPrice: costPrice,
|
||||||
|
ValidityDays: validityDays,
|
||||||
|
IsAddon: isAddon,
|
||||||
|
DataAllowance: dataAllowance,
|
||||||
|
DataUnit: "MB",
|
||||||
|
Description: pkg.PackageCode,
|
||||||
|
}, true
|
||||||
|
}
|
||||||
|
|
||||||
|
func nonZeroTimePtr(t time.Time) *time.Time {
|
||||||
|
if t.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &t
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeVirtualRatio(ratio float64) float64 {
|
||||||
|
if ratio <= 0 {
|
||||||
|
return 1.0
|
||||||
|
}
|
||||||
|
return ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
func packageStatusName(status int) string {
|
||||||
|
switch status {
|
||||||
|
case constants.PackageUsageStatusPending:
|
||||||
|
return "待生效"
|
||||||
|
case constants.PackageUsageStatusActive:
|
||||||
|
return "生效中"
|
||||||
|
case constants.PackageUsageStatusDepleted:
|
||||||
|
return "已用完"
|
||||||
|
case constants.PackageUsageStatusExpired:
|
||||||
|
return "已过期"
|
||||||
|
case constants.PackageUsageStatusInvalidated:
|
||||||
|
return "已失效"
|
||||||
|
default:
|
||||||
|
return "未知"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildMockDeviceRealtime 构建设备实时状态假数据
|
||||||
|
// TODO: Gateway 同步接口对接后移除此函数,改为调用 Gateway 接口获取真实数据
|
||||||
|
func buildMockDeviceRealtime() *dto.DeviceRealtimeInfo {
|
||||||
|
onlineStatus := int64(1)
|
||||||
|
batteryLevel := int64(85)
|
||||||
|
deviceStatus := int64(1)
|
||||||
|
runTime := "3600"
|
||||||
|
connectTime := "3500"
|
||||||
|
rsrp := int64(-80)
|
||||||
|
rsrq := int64(-10)
|
||||||
|
rssi := "-65"
|
||||||
|
sinr := int64(15)
|
||||||
|
ssid := "JunHong-WiFi"
|
||||||
|
wifiEnabled := true
|
||||||
|
wifiPassword := "12345678"
|
||||||
|
ipAddress := "192.168.1.1"
|
||||||
|
lanIP := "192.168.1.1"
|
||||||
|
dailyUsage := "0"
|
||||||
|
maxClients := int64(32)
|
||||||
|
switchMode := 0
|
||||||
|
|
||||||
|
return &dto.DeviceRealtimeInfo{
|
||||||
|
OnlineStatus: &onlineStatus,
|
||||||
|
BatteryLevel: &batteryLevel,
|
||||||
|
Status: &deviceStatus,
|
||||||
|
RunTime: &runTime,
|
||||||
|
ConnectTime: &connectTime,
|
||||||
|
Rsrp: &rsrp,
|
||||||
|
Rsrq: &rsrq,
|
||||||
|
Rssi: &rssi,
|
||||||
|
Sinr: &sinr,
|
||||||
|
SSID: &ssid,
|
||||||
|
WifiEnabled: &wifiEnabled,
|
||||||
|
WifiPassword: &wifiPassword,
|
||||||
|
IPAddress: &ipAddress,
|
||||||
|
LANIP: &lanIP,
|
||||||
|
DailyUsage: &dailyUsage,
|
||||||
|
MaxClients: &maxClients,
|
||||||
|
SwitchMode: &switchMode,
|
||||||
|
}
|
||||||
|
}
|
||||||
165
internal/handler/app/client_auth.go
Normal file
165
internal/handler/app/client_auth.go
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
clientAuthSvc "github.com/break/junhong_cmp_fiber/internal/service/client_auth"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var clientAuthValidator = validator.New()
|
||||||
|
|
||||||
|
// ClientAuthHandler C 端认证处理器
|
||||||
|
type ClientAuthHandler struct {
|
||||||
|
service *clientAuthSvc.Service
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientAuthHandler 创建 C 端认证处理器
|
||||||
|
func NewClientAuthHandler(service *clientAuthSvc.Service, logger *zap.Logger) *ClientAuthHandler {
|
||||||
|
return &ClientAuthHandler{service: service, logger: logger}
|
||||||
|
}
|
||||||
|
|
||||||
|
// VerifyAsset A1 资产验证
|
||||||
|
// POST /api/c/v1/auth/verify-asset
|
||||||
|
func (h *ClientAuthHandler) VerifyAsset(c *fiber.Ctx) error {
|
||||||
|
var req dto.VerifyAssetRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("资产验证参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.service.VerifyAsset(c.UserContext(), &req, c.IP())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// WechatLogin A2 公众号登录
|
||||||
|
// POST /api/c/v1/auth/wechat-login
|
||||||
|
func (h *ClientAuthHandler) WechatLogin(c *fiber.Ctx) error {
|
||||||
|
var req dto.WechatLoginRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("公众号登录参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.service.WechatLogin(c.UserContext(), &req, c.IP())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MiniappLogin A3 小程序登录
|
||||||
|
// POST /api/c/v1/auth/miniapp-login
|
||||||
|
func (h *ClientAuthHandler) MiniappLogin(c *fiber.Ctx) error {
|
||||||
|
var req dto.MiniappLoginRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("小程序登录参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.service.MiniappLogin(c.UserContext(), &req, c.IP())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SendCode A4 发送验证码
|
||||||
|
// POST /api/c/v1/auth/send-code
|
||||||
|
func (h *ClientAuthHandler) SendCode(c *fiber.Ctx) error {
|
||||||
|
var req dto.ClientSendCodeRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("发送验证码参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.service.SendCode(c.UserContext(), &req, c.IP())
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// BindPhone A5 绑定手机号
|
||||||
|
// POST /api/c/v1/auth/bind-phone
|
||||||
|
func (h *ClientAuthHandler) BindPhone(c *fiber.Ctx) error {
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.BindPhoneRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("绑定手机号参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.service.BindPhone(c.UserContext(), customerID, &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ChangePhone A6 更换手机号
|
||||||
|
// POST /api/c/v1/auth/change-phone
|
||||||
|
func (h *ClientAuthHandler) ChangePhone(c *fiber.Ctx) error {
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.ChangePhoneRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientAuthValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("更换手机号参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.service.ChangePhone(c.UserContext(), customerID, &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Logout A7 退出登录
|
||||||
|
// POST /api/c/v1/auth/logout
|
||||||
|
func (h *ClientAuthHandler) Logout(c *fiber.Ctx) error {
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.service.Logout(c.UserContext(), customerID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
317
internal/handler/app/client_device.go
Normal file
317
internal/handler/app/client_device.go
Normal file
@@ -0,0 +1,317 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
assetSvc "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
)
|
||||||
|
|
||||||
|
var clientDeviceValidator = validator.New()
|
||||||
|
|
||||||
|
// deviceAssetInfo validateDeviceAsset 解析后的设备资产信息
|
||||||
|
type deviceAssetInfo struct {
|
||||||
|
DeviceID uint // 设备数据库 ID
|
||||||
|
IMEI string // 设备 IMEI(用于 Gateway API 调用)
|
||||||
|
VirtualNo string // 设备虚拟号(用于所有权校验)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ClientDeviceHandler C 端设备能力处理器
|
||||||
|
// 提供设备卡列表、重启、恢复出厂、WiFi 配置、切卡等操作
|
||||||
|
type ClientDeviceHandler struct {
|
||||||
|
assetService *assetSvc.Service
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore
|
||||||
|
deviceStore *postgres.DeviceStore
|
||||||
|
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||||
|
iotCardStore *postgres.IotCardStore
|
||||||
|
gatewayClient *gateway.Client
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientDeviceHandler 创建 C 端设备能力处理器
|
||||||
|
func NewClientDeviceHandler(
|
||||||
|
assetService *assetSvc.Service,
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
|
||||||
|
deviceStore *postgres.DeviceStore,
|
||||||
|
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||||||
|
iotCardStore *postgres.IotCardStore,
|
||||||
|
gatewayClient *gateway.Client,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *ClientDeviceHandler {
|
||||||
|
return &ClientDeviceHandler{
|
||||||
|
assetService: assetService,
|
||||||
|
personalDeviceStore: personalDeviceStore,
|
||||||
|
deviceStore: deviceStore,
|
||||||
|
deviceSimBindingStore: deviceSimBindingStore,
|
||||||
|
iotCardStore: iotCardStore,
|
||||||
|
gatewayClient: gatewayClient,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateDeviceAsset 校验设备资产的所有权和有效性
|
||||||
|
// 流程:认证 → 资产解析 → 类型校验(仅设备)→ 所有权校验 → IMEI 校验
|
||||||
|
func (h *ClientDeviceHandler) validateDeviceAsset(c *fiber.Ctx, identifier string) (*deviceAssetInfo, error) {
|
||||||
|
// 获取当前登录的个人客户 ID
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return nil, errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.UserContext()
|
||||||
|
|
||||||
|
// 通过标识符解析资产
|
||||||
|
asset, err := h.assetService.Resolve(ctx, identifier)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 仅设备资产支持设备能力操作
|
||||||
|
if asset.AssetType != "device" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "仅设备资产支持该操作")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验个人客户对该设备的所有权
|
||||||
|
owns, err := h.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, asset.VirtualNo)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("校验设备所有权失败",
|
||||||
|
zap.Uint("customer_id", customerID),
|
||||||
|
zap.String("virtual_no", asset.VirtualNo),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errors.New(errors.CodeInternalError)
|
||||||
|
}
|
||||||
|
if !owns {
|
||||||
|
return nil, errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 校验设备 IMEI 是否存在(Gateway API 调用必需)
|
||||||
|
if asset.IMEI == "" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam, "设备IMEI缺失")
|
||||||
|
}
|
||||||
|
|
||||||
|
return &deviceAssetInfo{
|
||||||
|
DeviceID: asset.AssetID,
|
||||||
|
IMEI: asset.IMEI,
|
||||||
|
VirtualNo: asset.VirtualNo,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDeviceCards F1 获取设备卡列表
|
||||||
|
// GET /api/c/v1/device/cards
|
||||||
|
func (h *ClientDeviceHandler) GetDeviceCards(c *fiber.Ctx) error {
|
||||||
|
var req dto.DeviceCardListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientDeviceValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("设备卡列表参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := h.validateDeviceAsset(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.UserContext()
|
||||||
|
|
||||||
|
// 查询设备绑定的所有 SIM 卡
|
||||||
|
bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, info.DeviceID)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("查询设备SIM绑定失败",
|
||||||
|
zap.Uint("device_id", info.DeviceID),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInternalError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 无绑定卡时返回空列表
|
||||||
|
if len(bindings) == 0 {
|
||||||
|
return response.Success(c, &dto.DeviceCardListResponse{Cards: []dto.DeviceCardItem{}})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集卡 ID 并记录插槽位置映射
|
||||||
|
cardIDs := make([]uint, 0, len(bindings))
|
||||||
|
slotMap := make(map[uint]int, len(bindings))
|
||||||
|
for _, b := range bindings {
|
||||||
|
cardIDs = append(cardIDs, b.IotCardID)
|
||||||
|
slotMap[b.IotCardID] = b.SlotPosition
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量查询卡详情
|
||||||
|
cards, err := h.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||||
|
if err != nil {
|
||||||
|
h.logger.Error("批量查询IoT卡失败",
|
||||||
|
zap.Uints("card_ids", cardIDs),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInternalError)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组装响应,slot_position == 1 视为当前激活卡
|
||||||
|
items := make([]dto.DeviceCardItem, 0, len(cards))
|
||||||
|
for _, card := range cards {
|
||||||
|
slot := slotMap[card.ID]
|
||||||
|
items = append(items, dto.DeviceCardItem{
|
||||||
|
CardID: card.ID,
|
||||||
|
ICCID: card.ICCID,
|
||||||
|
MSISDN: card.MSISDN,
|
||||||
|
CarrierName: card.CarrierName,
|
||||||
|
NetworkStatus: networkStatusText(card.NetworkStatus),
|
||||||
|
RealNameStatus: card.RealNameStatus,
|
||||||
|
SlotPosition: slot,
|
||||||
|
IsActive: slot == 1,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, &dto.DeviceCardListResponse{Cards: items})
|
||||||
|
}
|
||||||
|
|
||||||
|
// RebootDevice F2 设备重启
|
||||||
|
// POST /api/c/v1/device/reboot
|
||||||
|
func (h *ClientDeviceHandler) RebootDevice(c *fiber.Ctx) error {
|
||||||
|
var req dto.DeviceRebootRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientDeviceValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("设备重启参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := h.validateDeviceAsset(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 Gateway 重启设备
|
||||||
|
if err := h.gatewayClient.RebootDevice(c.UserContext(), &gateway.DeviceOperationReq{
|
||||||
|
DeviceID: info.IMEI,
|
||||||
|
}); err != nil {
|
||||||
|
h.logger.Error("Gateway重启设备失败",
|
||||||
|
zap.String("imei", info.IMEI),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.Wrap(errors.CodeGatewayError, err, "设备重启失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, &dto.DeviceOperationResponse{Accepted: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// FactoryResetDevice F3 恢复出厂设置
|
||||||
|
// POST /api/c/v1/device/factory-reset
|
||||||
|
func (h *ClientDeviceHandler) FactoryResetDevice(c *fiber.Ctx) error {
|
||||||
|
var req dto.DeviceFactoryResetRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientDeviceValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("恢复出厂设置参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := h.validateDeviceAsset(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 Gateway 恢复出厂设置
|
||||||
|
if err := h.gatewayClient.ResetDevice(c.UserContext(), &gateway.DeviceOperationReq{
|
||||||
|
DeviceID: info.IMEI,
|
||||||
|
}); err != nil {
|
||||||
|
h.logger.Error("Gateway恢复出厂设置失败",
|
||||||
|
zap.String("imei", info.IMEI),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.Wrap(errors.CodeGatewayError, err, "恢复出厂设置失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, &dto.DeviceOperationResponse{Accepted: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetWiFi F4 设备WiFi配置
|
||||||
|
// POST /api/c/v1/device/wifi
|
||||||
|
// 注意:WiFiReq.CardNo 字段名具有误导性,实际传入的是设备 IMEI,而非卡号
|
||||||
|
func (h *ClientDeviceHandler) SetWiFi(c *fiber.Ctx) error {
|
||||||
|
var req dto.DeviceWifiRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientDeviceValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("WiFi配置参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := h.validateDeviceAsset(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 Gateway 配置 WiFi
|
||||||
|
// CardNo 字段虽名为"卡号",但 Gateway 实际要求传入设备 IMEI
|
||||||
|
if err := h.gatewayClient.SetWiFi(c.UserContext(), &gateway.WiFiReq{
|
||||||
|
CardNo: info.IMEI,
|
||||||
|
DeviceID: info.IMEI,
|
||||||
|
SSID: req.SSID,
|
||||||
|
Password: req.Password,
|
||||||
|
Enabled: req.Enabled,
|
||||||
|
}); err != nil {
|
||||||
|
h.logger.Error("Gateway配置WiFi失败",
|
||||||
|
zap.String("imei", info.IMEI),
|
||||||
|
zap.String("ssid", req.SSID),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.Wrap(errors.CodeGatewayError, err, "WiFi配置失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, &dto.DeviceOperationResponse{Accepted: true})
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchCard F5 设备切卡
|
||||||
|
// POST /api/c/v1/device/switch-card
|
||||||
|
func (h *ClientDeviceHandler) SwitchCard(c *fiber.Ctx) error {
|
||||||
|
var req dto.DeviceSwitchCardRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientDeviceValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("设备切卡参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, err := h.validateDeviceAsset(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 调用 Gateway 切卡,CardNo 传设备 IMEI
|
||||||
|
if err := h.gatewayClient.SwitchCard(c.UserContext(), &gateway.SwitchCardReq{
|
||||||
|
CardNo: info.IMEI,
|
||||||
|
ICCID: req.TargetICCID,
|
||||||
|
}); err != nil {
|
||||||
|
h.logger.Error("Gateway切卡失败",
|
||||||
|
zap.String("imei", info.IMEI),
|
||||||
|
zap.String("target_iccid", req.TargetICCID),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.Wrap(errors.CodeGatewayError, err, "设备切卡失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, &dto.DeviceSwitchCardResponse{
|
||||||
|
Accepted: true,
|
||||||
|
TargetICCID: req.TargetICCID,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// networkStatusText 将网络状态码转为文本描述
|
||||||
|
func networkStatusText(status int) string {
|
||||||
|
switch status {
|
||||||
|
case 0:
|
||||||
|
return "停机"
|
||||||
|
case 1:
|
||||||
|
return "开机"
|
||||||
|
default:
|
||||||
|
return "未知"
|
||||||
|
}
|
||||||
|
}
|
||||||
57
internal/handler/app/client_exchange.go
Normal file
57
internal/handler/app/client_exchange.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
exchangeService "github.com/break/junhong_cmp_fiber/internal/service/exchange"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
)
|
||||||
|
|
||||||
|
type ClientExchangeHandler struct {
|
||||||
|
service *exchangeService.Service
|
||||||
|
validator *validator.Validate
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewClientExchangeHandler(service *exchangeService.Service) *ClientExchangeHandler {
|
||||||
|
return &ClientExchangeHandler{service: service, validator: validator.New()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientExchangeHandler) GetPending(c *fiber.Ctx) error {
|
||||||
|
var req dto.ClientExchangePendingRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := h.validator.Struct(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.service.GetPending(c.UserContext(), req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientExchangeHandler) SubmitShippingInfo(c *fiber.Ctx) error {
|
||||||
|
id, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil || id == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
var req dto.ClientShippingInfoRequest
|
||||||
|
if err = c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err = h.validator.Struct(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = h.service.SubmitShippingInfo(c.UserContext(), uint(id), &req); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return response.Success(c, nil)
|
||||||
|
}
|
||||||
415
internal/handler/app/client_order.go
Normal file
415
internal/handler/app/client_order.go
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||||
|
clientorder "github.com/break/junhong_cmp_fiber/internal/service/client_order"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientOrderHandler C 端订单处理器
|
||||||
|
// 提供 D1~D3 下单、列表、详情接口。
|
||||||
|
type ClientOrderHandler struct {
|
||||||
|
clientOrderService *clientorder.Service
|
||||||
|
assetService *asset.Service
|
||||||
|
orderStore *postgres.OrderStore
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore
|
||||||
|
iotCardStore *postgres.IotCardStore
|
||||||
|
deviceStore *postgres.DeviceStore
|
||||||
|
logger *zap.Logger
|
||||||
|
db *gorm.DB
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientOrderHandler 创建 C 端订单处理器。
|
||||||
|
func NewClientOrderHandler(
|
||||||
|
clientOrderService *clientorder.Service,
|
||||||
|
assetService *asset.Service,
|
||||||
|
orderStore *postgres.OrderStore,
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
|
||||||
|
iotCardStore *postgres.IotCardStore,
|
||||||
|
deviceStore *postgres.DeviceStore,
|
||||||
|
logger *zap.Logger,
|
||||||
|
db *gorm.DB,
|
||||||
|
) *ClientOrderHandler {
|
||||||
|
return &ClientOrderHandler{
|
||||||
|
clientOrderService: clientOrderService,
|
||||||
|
assetService: assetService,
|
||||||
|
orderStore: orderStore,
|
||||||
|
personalDeviceStore: personalDeviceStore,
|
||||||
|
iotCardStore: iotCardStore,
|
||||||
|
deviceStore: deviceStore,
|
||||||
|
logger: logger,
|
||||||
|
db: db,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateOrder D1 创建订单。
|
||||||
|
// POST /api/c/v1/orders/create
|
||||||
|
func (h *ClientOrderHandler) CreateOrder(c *fiber.Ctx) error {
|
||||||
|
var req dto.ClientCreateOrderRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
resp, err := h.clientOrderService.CreateOrder(c.UserContext(), customerID, &req)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListOrders D2 订单列表。
|
||||||
|
// GET /api/c/v1/orders
|
||||||
|
func (h *ClientOrderHandler) ListOrders(c *fiber.Ctx) error {
|
||||||
|
var req dto.ClientOrderListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Page < 1 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PageSize < 1 {
|
||||||
|
req.PageSize = constants.DefaultPageSize
|
||||||
|
}
|
||||||
|
if req.PageSize > constants.MaxPageSize {
|
||||||
|
req.PageSize = constants.MaxPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := h.db.WithContext(resolved.SkipPermissionCtx).
|
||||||
|
Model(&model.Order{}).
|
||||||
|
Where("generation = ?", resolved.Generation)
|
||||||
|
|
||||||
|
if resolved.Asset.AssetType == constants.ResourceTypeDevice {
|
||||||
|
query = query.Where("order_type = ? AND device_id = ?", model.OrderTypeDevice, resolved.Asset.AssetID)
|
||||||
|
} else {
|
||||||
|
query = query.Where("order_type = ? AND iot_card_id = ?", model.OrderTypeSingleCard, resolved.Asset.AssetID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PaymentStatus != nil {
|
||||||
|
paymentStatus, ok := clientPaymentStatusToOrderStatus(*req.PaymentStatus)
|
||||||
|
if !ok {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
query = query.Where("payment_status = ?", paymentStatus)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单总数失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var orders []*model.Order
|
||||||
|
offset := (req.Page - 1) * req.PageSize
|
||||||
|
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&orders).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单列表失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
orderIDs := make([]uint, 0, len(orders))
|
||||||
|
for _, order := range orders {
|
||||||
|
if order == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
orderIDs = append(orderIDs, order.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
itemMap, err := h.loadOrderItemMap(resolved.SkipPermissionCtx, orderIDs)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
list := make([]dto.ClientOrderListItem, 0, len(orders))
|
||||||
|
for _, order := range orders {
|
||||||
|
if order == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
packageNames := make([]string, 0, len(itemMap[order.ID]))
|
||||||
|
for _, item := range itemMap[order.ID] {
|
||||||
|
if item == nil || item.PackageName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
packageNames = append(packageNames, item.PackageName)
|
||||||
|
}
|
||||||
|
|
||||||
|
list = append(list, dto.ClientOrderListItem{
|
||||||
|
OrderID: order.ID,
|
||||||
|
OrderNo: order.OrderNo,
|
||||||
|
TotalAmount: order.TotalAmount,
|
||||||
|
PaymentStatus: orderStatusToClientPaymentStatus(order.PaymentStatus),
|
||||||
|
CreatedAt: formatClientOrderTime(order.CreatedAt),
|
||||||
|
PackageNames: packageNames,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithPagination(c, list, total, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOrderDetail D3 订单详情。
|
||||||
|
// GET /api/c/v1/orders/:id
|
||||||
|
func (h *ClientOrderHandler) GetOrderDetail(c *fiber.Ctx) error {
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
orderID, err := strconv.ParseUint(c.Params("id"), 10, 64)
|
||||||
|
if err != nil || orderID == 0 {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
order, items, err := h.orderStore.GetByIDWithItems(c.UserContext(), uint(orderID))
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return errors.New(errors.CodeNotFound, "订单不存在")
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询订单详情失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
virtualNo, err := h.getOrderVirtualNo(c.UserContext(), order)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
owned, ownErr := h.isCustomerOwnAsset(c.UserContext(), customerID, virtualNo)
|
||||||
|
if ownErr != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
return errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
packages := make([]dto.ClientOrderPackageItem, 0, len(items))
|
||||||
|
for _, item := range items {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
packages = append(packages, dto.ClientOrderPackageItem{
|
||||||
|
PackageID: item.PackageID,
|
||||||
|
PackageName: item.PackageName,
|
||||||
|
Price: item.UnitPrice,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &dto.ClientOrderDetailResponse{
|
||||||
|
OrderID: order.ID,
|
||||||
|
OrderNo: order.OrderNo,
|
||||||
|
TotalAmount: order.TotalAmount,
|
||||||
|
PaymentStatus: orderStatusToClientPaymentStatus(order.PaymentStatus),
|
||||||
|
PaymentMethod: order.PaymentMethod,
|
||||||
|
CreatedAt: formatClientOrderTime(order.CreatedAt),
|
||||||
|
PaidAt: formatClientOrderTimePtr(order.PaidAt),
|
||||||
|
CompletedAt: nil,
|
||||||
|
Packages: packages,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientOrderHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedAssetContext, error) {
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return nil, errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier = strings.TrimSpace(identifier)
|
||||||
|
if identifier == "" {
|
||||||
|
identifier = strings.TrimSpace(c.Query("identifier"))
|
||||||
|
}
|
||||||
|
if identifier == "" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{})
|
||||||
|
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo)
|
||||||
|
if ownErr != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
|
||||||
|
if genErr != nil {
|
||||||
|
return nil, genErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resolvedAssetContext{
|
||||||
|
CustomerID: customerID,
|
||||||
|
Identifier: identifier,
|
||||||
|
Asset: assetInfo,
|
||||||
|
Generation: generation,
|
||||||
|
SkipPermissionCtx: skipPermissionCtx,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientOrderHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) {
|
||||||
|
records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, record := range records {
|
||||||
|
if record == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientOrderHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
|
||||||
|
switch assetType {
|
||||||
|
case "card":
|
||||||
|
card, err := h.iotCardStore.GetByID(ctx, assetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return 0, errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
|
||||||
|
}
|
||||||
|
return card.Generation, nil
|
||||||
|
case constants.ResourceTypeDevice:
|
||||||
|
device, err := h.deviceStore.GetByID(ctx, assetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return 0, errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
|
||||||
|
}
|
||||||
|
return device.Generation, nil
|
||||||
|
default:
|
||||||
|
return 0, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientOrderHandler) loadOrderItemMap(ctx context.Context, orderIDs []uint) (map[uint][]*model.OrderItem, error) {
|
||||||
|
result := make(map[uint][]*model.OrderItem)
|
||||||
|
if len(orderIDs) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []*model.OrderItem
|
||||||
|
if err := h.db.WithContext(ctx).Where("order_id IN ?", orderIDs).Order("id ASC").Find(&items).Error; err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询订单明细失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[item.OrderID] = append(result[item.OrderID], item)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientOrderHandler) getOrderVirtualNo(ctx context.Context, order *model.Order) (string, error) {
|
||||||
|
if order == nil {
|
||||||
|
return "", errors.New(errors.CodeNotFound, "订单不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
switch order.OrderType {
|
||||||
|
case model.OrderTypeSingleCard:
|
||||||
|
if order.IotCardID == nil || *order.IotCardID == 0 {
|
||||||
|
return "", errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
card, err := h.iotCardStore.GetByID(ctx, *order.IotCardID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return "", errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
|
||||||
|
}
|
||||||
|
return card.VirtualNo, nil
|
||||||
|
case model.OrderTypeDevice:
|
||||||
|
if order.DeviceID == nil || *order.DeviceID == 0 {
|
||||||
|
return "", errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
device, err := h.deviceStore.GetByID(ctx, *order.DeviceID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return "", errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
|
||||||
|
}
|
||||||
|
return device.VirtualNo, nil
|
||||||
|
default:
|
||||||
|
return "", errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func orderStatusToClientPaymentStatus(status int) int {
|
||||||
|
switch status {
|
||||||
|
case model.PaymentStatusPending:
|
||||||
|
return 0
|
||||||
|
case model.PaymentStatusPaid:
|
||||||
|
return 1
|
||||||
|
case model.PaymentStatusCancelled:
|
||||||
|
return 2
|
||||||
|
default:
|
||||||
|
return status
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clientPaymentStatusToOrderStatus(status int) (int, bool) {
|
||||||
|
switch status {
|
||||||
|
case 0:
|
||||||
|
return model.PaymentStatusPending, true
|
||||||
|
case 1:
|
||||||
|
return model.PaymentStatusPaid, true
|
||||||
|
case 2:
|
||||||
|
return model.PaymentStatusCancelled, true
|
||||||
|
default:
|
||||||
|
return 0, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatClientOrderTime(t time.Time) string {
|
||||||
|
if t.IsZero() {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return t.Format(time.RFC3339)
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatClientOrderTimePtr(t *time.Time) *string {
|
||||||
|
if t == nil || t.IsZero() {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
formatted := formatClientOrderTime(*t)
|
||||||
|
return &formatted
|
||||||
|
}
|
||||||
249
internal/handler/app/client_realname.go
Normal file
249
internal/handler/app/client_realname.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/gateway"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
assetService "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/logger"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
"github.com/go-playground/validator/v10"
|
||||||
|
)
|
||||||
|
|
||||||
|
var clientRealnameValidator = validator.New()
|
||||||
|
|
||||||
|
// ClientRealnameHandler C 端实名认证处理器
|
||||||
|
type ClientRealnameHandler struct {
|
||||||
|
assetService *assetService.Service
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore
|
||||||
|
iotCardStore *postgres.IotCardStore
|
||||||
|
deviceSimBindingStore *postgres.DeviceSimBindingStore
|
||||||
|
carrierStore *postgres.CarrierStore
|
||||||
|
gatewayClient *gateway.Client
|
||||||
|
logger *zap.Logger
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientRealnameHandler 创建 C 端实名认证处理器
|
||||||
|
func NewClientRealnameHandler(
|
||||||
|
assetSvc *assetService.Service,
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
|
||||||
|
iotCardStore *postgres.IotCardStore,
|
||||||
|
deviceSimBindingStore *postgres.DeviceSimBindingStore,
|
||||||
|
carrierStore *postgres.CarrierStore,
|
||||||
|
gatewayClient *gateway.Client,
|
||||||
|
logger *zap.Logger,
|
||||||
|
) *ClientRealnameHandler {
|
||||||
|
return &ClientRealnameHandler{
|
||||||
|
assetService: assetSvc,
|
||||||
|
personalDeviceStore: personalDeviceStore,
|
||||||
|
iotCardStore: iotCardStore,
|
||||||
|
deviceSimBindingStore: deviceSimBindingStore,
|
||||||
|
carrierStore: carrierStore,
|
||||||
|
gatewayClient: gatewayClient,
|
||||||
|
logger: logger,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRealnameLink E1 获取实名认证链接
|
||||||
|
// GET /api/c/v1/realname/link
|
||||||
|
func (h *ClientRealnameHandler) GetRealnameLink(c *fiber.Ctx) error {
|
||||||
|
// 1. 获取当前登录客户
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. 解析请求参数
|
||||||
|
var req dto.RealnimeLinkRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if err := clientRealnameValidator.Struct(&req); err != nil {
|
||||||
|
logger.GetAppLogger().Warn("实名链接参数校验失败", zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.UserContext()
|
||||||
|
|
||||||
|
// 3. 通过标识符解析资产
|
||||||
|
asset, err := h.assetService.Resolve(ctx, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. 验证资产归属(个人客户必须绑定过该资产)
|
||||||
|
owned, err := h.personalDeviceStore.ExistsByCustomerAndDevice(ctx, customerID, asset.VirtualNo)
|
||||||
|
if err != nil {
|
||||||
|
logger.GetAppLogger().Error("查询资产归属失败",
|
||||||
|
zap.Uint("customer_id", customerID),
|
||||||
|
zap.String("virtual_no", asset.VirtualNo),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.New(errors.CodeInternalError, "查询资产归属失败")
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
return errors.New(errors.CodeForbidden, "无权限操作该资源或资源不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. 定位目标卡(3 条路径)
|
||||||
|
var targetCard *model.IotCard
|
||||||
|
switch {
|
||||||
|
case asset.AssetType == "card":
|
||||||
|
// 路径 1:资产本身就是卡,直接使用
|
||||||
|
card, cardErr := h.iotCardStore.GetByID(ctx, asset.AssetID)
|
||||||
|
if cardErr != nil {
|
||||||
|
return errors.New(errors.CodeIotCardNotFound, "卡信息查询失败")
|
||||||
|
}
|
||||||
|
targetCard = card
|
||||||
|
|
||||||
|
case asset.AssetType == "device" && req.ICCID != "":
|
||||||
|
// 路径 2:资产是设备,指定了 ICCID,从设备绑定中查找该卡
|
||||||
|
card, cardErr := h.findCardInDeviceBindings(c, asset.AssetID, req.ICCID)
|
||||||
|
if cardErr != nil {
|
||||||
|
return cardErr
|
||||||
|
}
|
||||||
|
targetCard = card
|
||||||
|
|
||||||
|
case asset.AssetType == "device":
|
||||||
|
// 路径 3:资产是设备,未指定 ICCID,取第一张绑定卡(按插槽位置排序)
|
||||||
|
card, cardErr := h.findFirstBoundCard(c, asset.AssetID)
|
||||||
|
if cardErr != nil {
|
||||||
|
return cardErr
|
||||||
|
}
|
||||||
|
targetCard = card
|
||||||
|
|
||||||
|
default:
|
||||||
|
return errors.New(errors.CodeInvalidParam, "不支持的资产类型")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. 检查实名状态
|
||||||
|
if targetCard.RealNameStatus == 1 {
|
||||||
|
return errors.New(errors.CodeInvalidStatus, "该卡已完成实名")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. 获取运营商信息,根据实名链接类型生成 URL
|
||||||
|
carrier, err := h.carrierStore.GetByID(ctx, targetCard.CarrierID)
|
||||||
|
if err != nil {
|
||||||
|
logger.GetAppLogger().Error("查询运营商失败",
|
||||||
|
zap.Uint("carrier_id", targetCard.CarrierID),
|
||||||
|
zap.Error(err))
|
||||||
|
return errors.New(errors.CodeCarrierNotFound, "运营商信息查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &dto.RealnimeLinkResponse{
|
||||||
|
CardInfo: dto.CardInfoBrief{
|
||||||
|
ICCID: targetCard.ICCID,
|
||||||
|
MSISDN: targetCard.MSISDN,
|
||||||
|
VirtualNo: targetCard.VirtualNo,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
switch carrier.RealnameLinkType {
|
||||||
|
case constants.RealnameLinkTypeNone:
|
||||||
|
// 该运营商不支持在线实名
|
||||||
|
return errors.New(errors.CodeInvalidStatus, "该运营商暂不支持在线实名")
|
||||||
|
|
||||||
|
case constants.RealnameLinkTypeTemplate:
|
||||||
|
// 模板模式:替换占位符生成实名链接
|
||||||
|
url := carrier.RealnameLinkTemplate
|
||||||
|
url = strings.ReplaceAll(url, "{iccid}", targetCard.ICCID)
|
||||||
|
url = strings.ReplaceAll(url, "{msisdn}", targetCard.MSISDN)
|
||||||
|
url = strings.ReplaceAll(url, "{virtual_no}", targetCard.VirtualNo)
|
||||||
|
resp.RealnameMode = constants.RealnameLinkTypeTemplate
|
||||||
|
resp.RealnameURL = url
|
||||||
|
|
||||||
|
case constants.RealnameLinkTypeGateway:
|
||||||
|
// 网关模式:调用 Gateway 接口获取实名链接
|
||||||
|
linkResp, gwErr := h.gatewayClient.GetRealnameLink(ctx, &gateway.CardStatusReq{
|
||||||
|
CardNo: targetCard.ICCID,
|
||||||
|
})
|
||||||
|
if gwErr != nil {
|
||||||
|
logger.GetAppLogger().Error("Gateway 获取实名链接失败",
|
||||||
|
zap.String("iccid", targetCard.ICCID),
|
||||||
|
zap.Error(gwErr))
|
||||||
|
return errors.Wrap(errors.CodeGatewayError, gwErr, "获取实名链接失败")
|
||||||
|
}
|
||||||
|
resp.RealnameMode = constants.RealnameLinkTypeGateway
|
||||||
|
resp.RealnameURL = linkResp.URL
|
||||||
|
|
||||||
|
default:
|
||||||
|
logger.GetAppLogger().Warn("未知的实名链接类型",
|
||||||
|
zap.Uint("carrier_id", carrier.ID),
|
||||||
|
zap.String("realname_link_type", carrier.RealnameLinkType))
|
||||||
|
return errors.New(errors.CodeInvalidStatus, "该运营商暂不支持在线实名")
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// findCardInDeviceBindings 在设备绑定中查找指定 ICCID 的卡
|
||||||
|
func (h *ClientRealnameHandler) findCardInDeviceBindings(c *fiber.Ctx, deviceID uint, iccid string) (*model.IotCard, error) {
|
||||||
|
ctx := c.UserContext()
|
||||||
|
|
||||||
|
// 查询设备的所有有效绑定
|
||||||
|
bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
logger.GetAppLogger().Error("查询设备绑定失败",
|
||||||
|
zap.Uint("device_id", deviceID),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errors.New(errors.CodeInternalError, "查询设备绑定失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 收集所有绑定卡的 ID
|
||||||
|
cardIDs := make([]uint, 0, len(bindings))
|
||||||
|
for _, b := range bindings {
|
||||||
|
cardIDs = append(cardIDs, b.IotCardID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(cardIDs) == 0 {
|
||||||
|
return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定任何卡")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量查询卡,匹配指定的 ICCID
|
||||||
|
cards, err := h.iotCardStore.GetByIDs(ctx, cardIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New(errors.CodeInternalError, "查询卡信息失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, card := range cards {
|
||||||
|
if card.ICCID == iccid {
|
||||||
|
return card, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定指定的 ICCID")
|
||||||
|
}
|
||||||
|
|
||||||
|
// findFirstBoundCard 获取设备第一张绑定卡(按插槽位置排序,取第一张)
|
||||||
|
func (h *ClientRealnameHandler) findFirstBoundCard(c *fiber.Ctx, deviceID uint) (*model.IotCard, error) {
|
||||||
|
ctx := c.UserContext()
|
||||||
|
|
||||||
|
// ListByDeviceID 返回 bind_status=1 的绑定,按 slot_position ASC 排序
|
||||||
|
bindings, err := h.deviceSimBindingStore.ListByDeviceID(ctx, deviceID)
|
||||||
|
if err != nil {
|
||||||
|
logger.GetAppLogger().Error("查询设备绑定失败",
|
||||||
|
zap.Uint("device_id", deviceID),
|
||||||
|
zap.Error(err))
|
||||||
|
return nil, errors.New(errors.CodeInternalError, "查询设备绑定失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(bindings) == 0 {
|
||||||
|
return nil, errors.New(errors.CodeIotCardNotFound, "该设备未绑定任何卡")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 取第一张绑定卡(插槽位置最小的)
|
||||||
|
card, err := h.iotCardStore.GetByID(ctx, bindings[0].IotCardID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.New(errors.CodeIotCardNotFound, "卡信息查询失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
return card, nil
|
||||||
|
}
|
||||||
660
internal/handler/app/client_wallet.go
Normal file
660
internal/handler/app/client_wallet.go
Normal file
@@ -0,0 +1,660 @@
|
|||||||
|
package app
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/middleware"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
||||||
|
asset "github.com/break/junhong_cmp_fiber/internal/service/asset"
|
||||||
|
rechargeSvc "github.com/break/junhong_cmp_fiber/internal/service/recharge"
|
||||||
|
wechatConfigSvc "github.com/break/junhong_cmp_fiber/internal/service/wechat_config"
|
||||||
|
"github.com/break/junhong_cmp_fiber/internal/store/postgres"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/constants"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
|
"github.com/break/junhong_cmp_fiber/pkg/wechat"
|
||||||
|
"github.com/gofiber/fiber/v2"
|
||||||
|
"github.com/redis/go-redis/v9"
|
||||||
|
"go.uber.org/zap"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ClientWalletHandler C 端钱包处理器
|
||||||
|
// 提供 C1~C5 钱包详情、流水、充值前校验、充值下单、充值记录接口
|
||||||
|
type ClientWalletHandler struct {
|
||||||
|
assetService *asset.Service
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore
|
||||||
|
walletStore *postgres.AssetWalletStore
|
||||||
|
transactionStore *postgres.AssetWalletTransactionStore
|
||||||
|
rechargeStore *postgres.AssetRechargeStore
|
||||||
|
rechargeService *rechargeSvc.Service
|
||||||
|
openIDStore *postgres.PersonalCustomerOpenIDStore
|
||||||
|
wechatConfigService *wechatConfigSvc.Service
|
||||||
|
redis *redis.Client
|
||||||
|
logger *zap.Logger
|
||||||
|
db *gorm.DB
|
||||||
|
iotCardStore *postgres.IotCardStore
|
||||||
|
deviceStore *postgres.DeviceStore
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewClientWalletHandler 创建 C 端钱包处理器
|
||||||
|
func NewClientWalletHandler(
|
||||||
|
assetService *asset.Service,
|
||||||
|
personalDeviceStore *postgres.PersonalCustomerDeviceStore,
|
||||||
|
walletStore *postgres.AssetWalletStore,
|
||||||
|
transactionStore *postgres.AssetWalletTransactionStore,
|
||||||
|
rechargeStore *postgres.AssetRechargeStore,
|
||||||
|
rechargeService *rechargeSvc.Service,
|
||||||
|
openIDStore *postgres.PersonalCustomerOpenIDStore,
|
||||||
|
wechatConfigService *wechatConfigSvc.Service,
|
||||||
|
redisClient *redis.Client,
|
||||||
|
logger *zap.Logger,
|
||||||
|
db *gorm.DB,
|
||||||
|
iotCardStore *postgres.IotCardStore,
|
||||||
|
deviceStore *postgres.DeviceStore,
|
||||||
|
) *ClientWalletHandler {
|
||||||
|
return &ClientWalletHandler{
|
||||||
|
assetService: assetService,
|
||||||
|
personalDeviceStore: personalDeviceStore,
|
||||||
|
walletStore: walletStore,
|
||||||
|
transactionStore: transactionStore,
|
||||||
|
rechargeStore: rechargeStore,
|
||||||
|
rechargeService: rechargeService,
|
||||||
|
openIDStore: openIDStore,
|
||||||
|
wechatConfigService: wechatConfigService,
|
||||||
|
redis: redisClient,
|
||||||
|
logger: logger,
|
||||||
|
db: db,
|
||||||
|
iotCardStore: iotCardStore,
|
||||||
|
deviceStore: deviceStore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type resolvedWalletAssetContext struct {
|
||||||
|
CustomerID uint
|
||||||
|
Identifier string
|
||||||
|
Asset *dto.AssetResolveResponse
|
||||||
|
Generation int
|
||||||
|
ResourceType string
|
||||||
|
SkipPermissionCtx context.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWalletDetail C1 钱包详情
|
||||||
|
// GET /api/c/v1/wallet/detail
|
||||||
|
func (h *ClientWalletHandler) GetWalletDetail(c *fiber.Ctx) error {
|
||||||
|
var req dto.WalletDetailRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet, err := h.getOrCreateWallet(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &dto.WalletDetailResponse{
|
||||||
|
WalletID: wallet.ID,
|
||||||
|
ResourceType: wallet.ResourceType,
|
||||||
|
ResourceID: wallet.ResourceID,
|
||||||
|
Balance: wallet.Balance,
|
||||||
|
FrozenBalance: wallet.FrozenBalance,
|
||||||
|
UpdatedAt: wallet.UpdatedAt.Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetWalletTransactions C2 钱包流水列表
|
||||||
|
// GET /api/c/v1/wallet/transactions
|
||||||
|
func (h *ClientWalletHandler) GetWalletTransactions(c *fiber.Ctx) error {
|
||||||
|
var req dto.WalletTransactionListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Page < 1 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PageSize < 1 {
|
||||||
|
req.PageSize = constants.DefaultPageSize
|
||||||
|
}
|
||||||
|
if req.PageSize > constants.MaxPageSize {
|
||||||
|
req.PageSize = constants.MaxPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet, err := h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return response.SuccessWithPagination(c, []dto.WalletTransactionItem{}, 0, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var txType *string
|
||||||
|
if strings.TrimSpace(req.TransactionType) != "" {
|
||||||
|
v := strings.TrimSpace(req.TransactionType)
|
||||||
|
txType = &v
|
||||||
|
}
|
||||||
|
|
||||||
|
startTime, err := parseOptionalTime(req.StartTime)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
endTime, err := parseOptionalTime(req.EndTime)
|
||||||
|
if err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
if startTime != nil && endTime != nil && endTime.Before(*startTime) {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
offset := (req.Page - 1) * req.PageSize
|
||||||
|
list, err := h.transactionStore.ListByResourceIDWithFilter(
|
||||||
|
resolved.SkipPermissionCtx,
|
||||||
|
wallet.ResourceType,
|
||||||
|
wallet.ResourceID,
|
||||||
|
txType,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
offset,
|
||||||
|
req.PageSize,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包流水失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
total, err := h.transactionStore.CountByResourceIDWithFilter(
|
||||||
|
resolved.SkipPermissionCtx,
|
||||||
|
wallet.ResourceType,
|
||||||
|
wallet.ResourceID,
|
||||||
|
txType,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询钱包流水总数失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]dto.WalletTransactionItem, 0, len(list))
|
||||||
|
for _, tx := range list {
|
||||||
|
if tx == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
remark := ""
|
||||||
|
if tx.Remark != nil {
|
||||||
|
remark = *tx.Remark
|
||||||
|
}
|
||||||
|
|
||||||
|
items = append(items, dto.WalletTransactionItem{
|
||||||
|
TransactionID: tx.ID,
|
||||||
|
Type: tx.TransactionType,
|
||||||
|
Amount: tx.Amount,
|
||||||
|
BalanceAfter: tx.BalanceAfter,
|
||||||
|
CreatedAt: tx.CreatedAt.Format(time.RFC3339),
|
||||||
|
Remark: remark,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithPagination(c, items, total, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRechargeCheck C3 充值前校验
|
||||||
|
// GET /api/c/v1/wallet/recharge-check
|
||||||
|
func (h *ClientWalletHandler) GetRechargeCheck(c *fiber.Ctx) error {
|
||||||
|
var req dto.ClientRechargeCheckRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
check, err := h.rechargeService.GetRechargeCheck(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
resp := &dto.ClientRechargeCheckResponse{
|
||||||
|
NeedForceRecharge: check.NeedForceRecharge,
|
||||||
|
ForceRechargeAmount: check.ForceRechargeAmount,
|
||||||
|
TriggerType: check.TriggerType,
|
||||||
|
MinAmount: check.MinAmount,
|
||||||
|
MaxAmount: check.MaxAmount,
|
||||||
|
Message: check.Message,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// CreateRecharge C4 创建充值订单
|
||||||
|
// POST /api/c/v1/wallet/recharge
|
||||||
|
func (h *ClientWalletHandler) CreateRecharge(c *fiber.Ctx) error {
|
||||||
|
var req dto.ClientCreateRechargeRequest
|
||||||
|
if err := c.BodyParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.PaymentMethod != constants.RechargeMethodWechat {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet, err := h.getOrCreateWallet(resolved)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
config, err := h.wechatConfigService.GetActiveConfig(resolved.SkipPermissionCtx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if config == nil {
|
||||||
|
return errors.New(errors.CodeWechatConfigUnavailable)
|
||||||
|
}
|
||||||
|
|
||||||
|
appID, err := pickAppIDByType(config, req.AppType)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
openID, err := h.findOpenIDByCustomerAndAppID(resolved.SkipPermissionCtx, resolved.CustomerID, appID)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rechargeNo := generateClientRechargeNo()
|
||||||
|
recharge := &model.AssetRechargeRecord{
|
||||||
|
UserID: resolved.CustomerID,
|
||||||
|
AssetWalletID: wallet.ID,
|
||||||
|
ResourceType: resolved.ResourceType,
|
||||||
|
ResourceID: resolved.Asset.AssetID,
|
||||||
|
RechargeNo: rechargeNo,
|
||||||
|
Amount: req.Amount,
|
||||||
|
PaymentMethod: constants.RechargeMethodWechat,
|
||||||
|
PaymentConfigID: &config.ID,
|
||||||
|
Status: constants.RechargeStatusPending,
|
||||||
|
ShopIDTag: wallet.ShopIDTag,
|
||||||
|
EnterpriseIDTag: wallet.EnterpriseIDTag,
|
||||||
|
OperatorType: constants.OperatorTypePersonalCustomer,
|
||||||
|
Generation: resolved.Generation,
|
||||||
|
}
|
||||||
|
if err := h.rechargeStore.Create(resolved.SkipPermissionCtx, recharge); err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "创建充值记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
cache := wechat.NewRedisCache(h.redis)
|
||||||
|
paymentApp, err := wechat.NewPaymentAppFromConfig(config, appID, cache, h.logger)
|
||||||
|
if err != nil {
|
||||||
|
return errors.Wrap(errors.CodeWechatPayFailed, err, "初始化微信支付实例失败")
|
||||||
|
}
|
||||||
|
paymentService := wechat.NewPaymentService(paymentApp, h.logger)
|
||||||
|
payResult, err := paymentService.CreateJSAPIOrder(
|
||||||
|
resolved.SkipPermissionCtx,
|
||||||
|
recharge.RechargeNo,
|
||||||
|
"资产钱包充值",
|
||||||
|
openID,
|
||||||
|
int(req.Amount),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
payConfig := buildClientRechargePayConfig(appID, payResult)
|
||||||
|
resp := &dto.ClientRechargeResponse{
|
||||||
|
Recharge: dto.ClientRechargeResult{
|
||||||
|
RechargeID: recharge.ID,
|
||||||
|
RechargeNo: recharge.RechargeNo,
|
||||||
|
Amount: recharge.Amount,
|
||||||
|
Status: recharge.Status,
|
||||||
|
},
|
||||||
|
PayConfig: payConfig,
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.Success(c, resp)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetRechargeList C5 充值记录列表
|
||||||
|
// GET /api/c/v1/wallet/recharges
|
||||||
|
func (h *ClientWalletHandler) GetRechargeList(c *fiber.Ctx) error {
|
||||||
|
var req dto.ClientRechargeListRequest
|
||||||
|
if err := c.QueryParser(&req); err != nil {
|
||||||
|
return errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
if req.Page < 1 {
|
||||||
|
req.Page = 1
|
||||||
|
}
|
||||||
|
if req.PageSize < 1 {
|
||||||
|
req.PageSize = constants.DefaultPageSize
|
||||||
|
}
|
||||||
|
if req.PageSize > constants.MaxPageSize {
|
||||||
|
req.PageSize = constants.MaxPageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
resolved, err := h.resolveAssetFromIdentifier(c, req.Identifier)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
query := h.db.WithContext(resolved.SkipPermissionCtx).
|
||||||
|
Model(&model.AssetRechargeRecord{}).
|
||||||
|
Where("resource_type = ? AND resource_id = ? AND generation = ?", resolved.ResourceType, resolved.Asset.AssetID, resolved.Generation)
|
||||||
|
if req.Status != nil {
|
||||||
|
query = query.Where("status = ?", *req.Status)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录总数失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
var records []*model.AssetRechargeRecord
|
||||||
|
offset := (req.Page - 1) * req.PageSize
|
||||||
|
if err := query.Order("created_at DESC").Offset(offset).Limit(req.PageSize).Find(&records).Error; err != nil {
|
||||||
|
return errors.Wrap(errors.CodeDatabaseError, err, "查询充值记录失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
items := make([]dto.ClientRechargeListItem, 0, len(records))
|
||||||
|
for _, record := range records {
|
||||||
|
if record == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, dto.ClientRechargeListItem{
|
||||||
|
RechargeID: record.ID,
|
||||||
|
RechargeNo: record.RechargeNo,
|
||||||
|
Amount: record.Amount,
|
||||||
|
Status: record.Status,
|
||||||
|
PaymentMethod: record.PaymentMethod,
|
||||||
|
CreatedAt: record.CreatedAt.Format(time.RFC3339),
|
||||||
|
AutoPurchaseStatus: record.AutoPurchaseStatus,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.SuccessWithPagination(c, items, total, req.Page, req.PageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveAssetFromIdentifier 统一执行资产解析与归属校验
|
||||||
|
func (h *ClientWalletHandler) resolveAssetFromIdentifier(c *fiber.Ctx, identifier string) (*resolvedWalletAssetContext, error) {
|
||||||
|
customerID, ok := middleware.GetCustomerID(c)
|
||||||
|
if !ok || customerID == 0 {
|
||||||
|
return nil, errors.New(errors.CodeUnauthorized)
|
||||||
|
}
|
||||||
|
|
||||||
|
identifier = strings.TrimSpace(identifier)
|
||||||
|
if identifier == "" {
|
||||||
|
identifier = strings.TrimSpace(c.Query("identifier"))
|
||||||
|
}
|
||||||
|
if identifier == "" {
|
||||||
|
return nil, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
|
||||||
|
skipPermissionCtx := context.WithValue(c.UserContext(), constants.ContextKeySubordinateShopIDs, []uint{})
|
||||||
|
assetInfo, err := h.assetService.Resolve(skipPermissionCtx, identifier)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
owned, ownErr := h.isCustomerOwnAsset(skipPermissionCtx, customerID, assetInfo.VirtualNo)
|
||||||
|
if ownErr != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, ownErr, "查询资产归属失败")
|
||||||
|
}
|
||||||
|
if !owned {
|
||||||
|
return nil, errors.New(errors.CodeForbidden, "无权限操作该资产或资源不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
resourceType, mapErr := mapAssetTypeToWalletResource(assetInfo.AssetType)
|
||||||
|
if mapErr != nil {
|
||||||
|
return nil, mapErr
|
||||||
|
}
|
||||||
|
|
||||||
|
generation, genErr := h.getAssetGeneration(skipPermissionCtx, assetInfo.AssetType, assetInfo.AssetID)
|
||||||
|
if genErr != nil {
|
||||||
|
return nil, genErr
|
||||||
|
}
|
||||||
|
|
||||||
|
return &resolvedWalletAssetContext{
|
||||||
|
CustomerID: customerID,
|
||||||
|
Identifier: identifier,
|
||||||
|
Asset: assetInfo,
|
||||||
|
Generation: generation,
|
||||||
|
ResourceType: resourceType,
|
||||||
|
SkipPermissionCtx: skipPermissionCtx,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientWalletHandler) isCustomerOwnAsset(ctx context.Context, customerID uint, virtualNo string) (bool, error) {
|
||||||
|
records, err := h.personalDeviceStore.GetByCustomerID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return false, err
|
||||||
|
}
|
||||||
|
for _, record := range records {
|
||||||
|
if record == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if record.Status == constants.StatusEnabled && record.VirtualNo == virtualNo {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientWalletHandler) getAssetGeneration(ctx context.Context, assetType string, assetID uint) (int, error) {
|
||||||
|
switch assetType {
|
||||||
|
case "card":
|
||||||
|
card, err := h.iotCardStore.GetByID(ctx, assetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return 0, errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询卡信息失败")
|
||||||
|
}
|
||||||
|
return card.Generation, nil
|
||||||
|
case "device":
|
||||||
|
device, err := h.deviceStore.GetByID(ctx, assetID)
|
||||||
|
if err != nil {
|
||||||
|
if err == gorm.ErrRecordNotFound {
|
||||||
|
return 0, errors.New(errors.CodeAssetNotFound)
|
||||||
|
}
|
||||||
|
return 0, errors.Wrap(errors.CodeDatabaseError, err, "查询设备信息失败")
|
||||||
|
}
|
||||||
|
return device.Generation, nil
|
||||||
|
default:
|
||||||
|
return 0, errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientWalletHandler) getOrCreateWallet(resolved *resolvedWalletAssetContext) (*model.AssetWallet, error) {
|
||||||
|
wallet, err := h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
|
||||||
|
if err == nil {
|
||||||
|
return wallet, nil
|
||||||
|
}
|
||||||
|
if err != gorm.ErrRecordNotFound {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
shopIDTag := uint(0)
|
||||||
|
if resolved.Asset.ShopID != nil {
|
||||||
|
shopIDTag = *resolved.Asset.ShopID
|
||||||
|
}
|
||||||
|
|
||||||
|
newWallet := &model.AssetWallet{
|
||||||
|
ResourceType: resolved.ResourceType,
|
||||||
|
ResourceID: resolved.Asset.AssetID,
|
||||||
|
Balance: 0,
|
||||||
|
FrozenBalance: 0,
|
||||||
|
Currency: "CNY",
|
||||||
|
Status: constants.AssetWalletStatusNormal,
|
||||||
|
Version: 0,
|
||||||
|
ShopIDTag: shopIDTag,
|
||||||
|
}
|
||||||
|
if createErr := h.walletStore.Create(resolved.SkipPermissionCtx, newWallet); createErr != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, createErr, "创建钱包失败")
|
||||||
|
}
|
||||||
|
|
||||||
|
wallet, err = h.walletStore.GetByResourceTypeAndID(resolved.SkipPermissionCtx, resolved.ResourceType, resolved.Asset.AssetID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.Wrap(errors.CodeDatabaseError, err, "查询钱包失败")
|
||||||
|
}
|
||||||
|
return wallet, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (h *ClientWalletHandler) findOpenIDByCustomerAndAppID(ctx context.Context, customerID uint, appID string) (string, error) {
|
||||||
|
list, err := h.openIDStore.ListByCustomerID(ctx, customerID)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.Wrap(errors.CodeDatabaseError, err, "查询微信授权信息失败")
|
||||||
|
}
|
||||||
|
for _, item := range list {
|
||||||
|
if item == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if item.AppID == appID && strings.TrimSpace(item.OpenID) != "" {
|
||||||
|
return item.OpenID, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", errors.New(errors.CodeOpenIDNotFound)
|
||||||
|
}
|
||||||
|
|
||||||
|
func mapAssetTypeToWalletResource(assetType string) (string, error) {
|
||||||
|
switch assetType {
|
||||||
|
case "card":
|
||||||
|
return constants.AssetWalletResourceTypeIotCard, nil
|
||||||
|
case "device":
|
||||||
|
return constants.AssetWalletResourceTypeDevice, nil
|
||||||
|
default:
|
||||||
|
return "", errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOptionalTime(value string) (*time.Time, error) {
|
||||||
|
v := strings.TrimSpace(value)
|
||||||
|
if v == "" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
layouts := []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"}
|
||||||
|
for _, layout := range layouts {
|
||||||
|
t, err := time.Parse(layout, v)
|
||||||
|
if err == nil {
|
||||||
|
return &t, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, fmt.Errorf("invalid time format")
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickAppIDByType(config *model.WechatConfig, appType string) (string, error) {
|
||||||
|
switch appType {
|
||||||
|
case "official_account":
|
||||||
|
if strings.TrimSpace(config.OaAppID) == "" {
|
||||||
|
return "", errors.New(errors.CodeWechatConfigUnavailable)
|
||||||
|
}
|
||||||
|
return config.OaAppID, nil
|
||||||
|
case "miniapp":
|
||||||
|
if strings.TrimSpace(config.MiniappAppID) == "" {
|
||||||
|
return "", errors.New(errors.CodeWechatConfigUnavailable)
|
||||||
|
}
|
||||||
|
return config.MiniappAppID, nil
|
||||||
|
default:
|
||||||
|
return "", errors.New(errors.CodeInvalidParam)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func generateClientRechargeNo() string {
|
||||||
|
timestamp := time.Now().Format("20060102150405")
|
||||||
|
randomNum := rand.Intn(1000000)
|
||||||
|
return fmt.Sprintf("%s%s%06d", constants.AssetRechargeOrderPrefix, timestamp, randomNum)
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildClientRechargePayConfig(appID string, result *wechat.JSAPIPayResult) dto.ClientRechargePayConfig {
|
||||||
|
resp := dto.ClientRechargePayConfig{AppID: appID}
|
||||||
|
if result == nil || result.PayConfig == nil {
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg, ok := result.PayConfig.(map[string]any); ok {
|
||||||
|
resp.Timestamp = getStringFromAnyMap(cfg, "timeStamp", "timestamp")
|
||||||
|
resp.NonceStr = getStringFromAnyMap(cfg, "nonceStr", "nonce_str")
|
||||||
|
resp.PackageVal = getStringFromAnyMap(cfg, "package")
|
||||||
|
resp.SignType = getStringFromAnyMap(cfg, "signType", "sign_type")
|
||||||
|
resp.PaySign = getStringFromAnyMap(cfg, "paySign", "pay_sign")
|
||||||
|
if appIDVal := getStringFromAnyMap(cfg, "appId", "app_id"); appIDVal != "" {
|
||||||
|
resp.AppID = appIDVal
|
||||||
|
}
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg, ok := result.PayConfig.(map[string]string); ok {
|
||||||
|
resp.Timestamp = cfg["timeStamp"]
|
||||||
|
if resp.Timestamp == "" {
|
||||||
|
resp.Timestamp = cfg["timestamp"]
|
||||||
|
}
|
||||||
|
resp.NonceStr = cfg["nonceStr"]
|
||||||
|
if resp.NonceStr == "" {
|
||||||
|
resp.NonceStr = cfg["nonce_str"]
|
||||||
|
}
|
||||||
|
resp.PackageVal = cfg["package"]
|
||||||
|
resp.SignType = cfg["signType"]
|
||||||
|
if resp.SignType == "" {
|
||||||
|
resp.SignType = cfg["sign_type"]
|
||||||
|
}
|
||||||
|
resp.PaySign = cfg["paySign"]
|
||||||
|
if resp.PaySign == "" {
|
||||||
|
resp.PaySign = cfg["pay_sign"]
|
||||||
|
}
|
||||||
|
if cfg["appId"] != "" {
|
||||||
|
resp.AppID = cfg["appId"]
|
||||||
|
} else if cfg["app_id"] != "" {
|
||||||
|
resp.AppID = cfg["app_id"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return resp
|
||||||
|
}
|
||||||
|
|
||||||
|
func getStringFromAnyMap(m map[string]any, keys ...string) string {
|
||||||
|
for _, key := range keys {
|
||||||
|
val, ok := m[key]
|
||||||
|
if !ok || val == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
switch v := val.(type) {
|
||||||
|
case string:
|
||||||
|
if v != "" {
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
case fmt.Stringer:
|
||||||
|
text := v.String()
|
||||||
|
if text != "" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
text := fmt.Sprintf("%v", v)
|
||||||
|
if text != "" && text != "<nil>" {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -3,7 +3,6 @@
|
|||||||
package app
|
package app
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/break/junhong_cmp_fiber/internal/model/dto"
|
|
||||||
"github.com/break/junhong_cmp_fiber/internal/service/personal_customer"
|
"github.com/break/junhong_cmp_fiber/internal/service/personal_customer"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
"github.com/break/junhong_cmp_fiber/pkg/errors"
|
||||||
"github.com/break/junhong_cmp_fiber/pkg/response"
|
"github.com/break/junhong_cmp_fiber/pkg/response"
|
||||||
@@ -25,45 +24,6 @@ func NewPersonalCustomerHandler(service *personal_customer.Service, logger *zap.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SendCodeRequest 发送验证码请求
|
|
||||||
type SendCodeRequest struct {
|
|
||||||
Phone string `json:"phone" validate:"required,len=11"` // 手机号(11位)
|
|
||||||
}
|
|
||||||
|
|
||||||
// SendCode 发送验证码
|
|
||||||
// POST /api/c/v1/login/send-code
|
|
||||||
func (h *PersonalCustomerHandler) SendCode(c *fiber.Ctx) error {
|
|
||||||
var req SendCodeRequest
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发送验证码
|
|
||||||
if err := h.service.SendVerificationCode(c.Context(), req.Phone); err != nil {
|
|
||||||
h.logger.Error("发送验证码失败",
|
|
||||||
zap.String("phone", req.Phone),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "发送验证码失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, fiber.Map{
|
|
||||||
"message": "验证码已发送",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginRequest 登录请求
|
|
||||||
type LoginRequest struct {
|
|
||||||
Phone string `json:"phone" validate:"required,len=11"` // 手机号(11位)
|
|
||||||
Code string `json:"code" validate:"required,len=6"` // 验证码(6位)
|
|
||||||
}
|
|
||||||
|
|
||||||
// LoginResponse 登录响应
|
|
||||||
type LoginResponse struct {
|
|
||||||
Token string `json:"token"` // 访问令牌
|
|
||||||
Customer *PersonalCustomerDTO `json:"customer"` // 客户信息
|
|
||||||
}
|
|
||||||
|
|
||||||
// PersonalCustomerDTO 个人客户 DTO
|
// PersonalCustomerDTO 个人客户 DTO
|
||||||
type PersonalCustomerDTO struct {
|
type PersonalCustomerDTO struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
@@ -74,87 +34,6 @@ type PersonalCustomerDTO struct {
|
|||||||
Status int `json:"status"`
|
Status int `json:"status"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Login 登录(手机号 + 验证码)
|
|
||||||
// POST /api/c/v1/login
|
|
||||||
func (h *PersonalCustomerHandler) Login(c *fiber.Ctx) error {
|
|
||||||
var req LoginRequest
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 登录
|
|
||||||
token, customer, err := h.service.LoginByPhone(c.Context(), req.Phone, req.Code)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("登录失败",
|
|
||||||
zap.String("phone", req.Phone),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return errors.Wrap(errors.CodeInternalError, err, "登录失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
// 构造响应
|
|
||||||
// 注意:Phone 字段已从 PersonalCustomer 模型移除,需要从 PersonalCustomerPhone 表查询
|
|
||||||
resp := &LoginResponse{
|
|
||||||
Token: token,
|
|
||||||
Customer: &PersonalCustomerDTO{
|
|
||||||
ID: customer.ID,
|
|
||||||
Phone: req.Phone, // 使用请求中的手机号(临时方案)
|
|
||||||
Nickname: customer.Nickname,
|
|
||||||
AvatarURL: customer.AvatarURL,
|
|
||||||
WxOpenID: customer.WxOpenID,
|
|
||||||
Status: customer.Status,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, resp)
|
|
||||||
}
|
|
||||||
|
|
||||||
// WechatOAuthLogin 微信 OAuth 登录
|
|
||||||
// POST /api/c/v1/wechat/auth
|
|
||||||
func (h *PersonalCustomerHandler) WechatOAuthLogin(c *fiber.Ctx) error {
|
|
||||||
var req dto.WechatOAuthRequest
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := h.service.WechatOAuthLogin(c.Context(), req.Code)
|
|
||||||
if err != nil {
|
|
||||||
h.logger.Error("微信 OAuth 登录失败",
|
|
||||||
zap.String("code", req.Code),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, result)
|
|
||||||
}
|
|
||||||
|
|
||||||
// BindWechat 绑定微信
|
|
||||||
// POST /api/c/v1/bind-wechat
|
|
||||||
func (h *PersonalCustomerHandler) BindWechat(c *fiber.Ctx) error {
|
|
||||||
var req dto.WechatOAuthRequest
|
|
||||||
if err := c.BodyParser(&req); err != nil {
|
|
||||||
return errors.New(errors.CodeInvalidParam, "请求参数解析失败")
|
|
||||||
}
|
|
||||||
|
|
||||||
customerID, ok := c.Locals("customer_id").(uint)
|
|
||||||
if !ok {
|
|
||||||
return errors.New(errors.CodeUnauthorized, "未找到客户信息")
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := h.service.BindWechatWithCode(c.Context(), customerID, req.Code); err != nil {
|
|
||||||
h.logger.Error("绑定微信失败",
|
|
||||||
zap.Uint("customer_id", customerID),
|
|
||||||
zap.Error(err),
|
|
||||||
)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
return response.Success(c, fiber.Map{
|
|
||||||
"message": "绑定成功",
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// UpdateProfileRequest 更新个人资料请求
|
// UpdateProfileRequest 更新个人资料请求
|
||||||
type UpdateProfileRequest struct {
|
type UpdateProfileRequest struct {
|
||||||
Nickname string `json:"nickname"` // 昵称
|
Nickname string `json:"nickname"` // 昵称
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user