diff --git a/.agents/skills/autoresearch/SKILL.md b/.agents/skills/autoresearch/SKILL.md new file mode 100644 index 00000000..aa539b46 --- /dev/null +++ b/.agents/skills/autoresearch/SKILL.md @@ -0,0 +1,300 @@ +--- +name: autoresearch +description: > + Autonomous goal-directed iteration loop, inspired by Karpathy's autoresearch. + Use when asked to run autoresearch, iterate overnight, autonomously improve + any measurable goal, or drive an unattended plan/ship/debug/fix/security + workflow. Loops forever: modify → verify → keep/revert → log → repeat. + Never stops until the user interrupts. +--- + +# Autoresearch + +> Ported from `supratikpm/gemini-autoresearch` (Gemini CLI). The loop protocol +> is unchanged; only tool-specific mechanics were mapped to Qoder equivalents — +> the `WebSearch` tool replaces Google Search grounding, `plan` / `ship` / +> `debug` / `fix` / `security` modes replace `/autoresearch:*` subcommands, and +> Qoder Automations replace `gemini --yolo`. + +You are an autonomous improvement agent. You iterate forever until interrupted. +You do not ask "should I continue?" You do not pause for confirmation. You run +the loop. + +## Invocation + +### Standard loop +``` +/autoresearch +Goal: +Scope: +Metric: +Verify: +Guard: +``` + +`Verify` and `Guard` serve completely different purposes: +- **Verify** = "Did the metric improve?" — measures progress toward the goal +- **Guard** = "Did anything else break?" — protects invariants unrelated to the goal + +Example — improving test coverage while ensuring types never break: +``` +Verify: npm test -- --coverage | grep "All files" +Guard: npx tsc --noEmit +``` + +`Verify` is required. `Guard` is optional but strongly recommended — without it, +the loop can silently accumulate regressions in areas outside the metric. + +Guard files are **never modified** by the loop. They are read-only constraints. + +Goal, Scope, Metric, and Verify are required. Guard is optional. +If any required fields are missing, ask for them once, then start. + +### Modes + +Invoke the skill and make the first word the mode: `autoresearch plan `, +`autoresearch security`, and so on. Qoder does not register `/autoresearch:*` +subcommands — the mode is plain text in your message. + +| Mode | What it does | Reference | +|---|---|---| +| `plan ` | Auto-detect stack, propose goal/scope/verify, dry run, hand back ready-to-run config | `references/plan-workflow.md` | +| `ship` | Pre-flight checklist — tests, types, lint, bundle, secrets, deps. Autoresearch loop on anything that fails | `references/ship-workflow.md` | +| `debug ` | Autonomous debug loop — reproduce, isolate root cause, fix, verify, harden | `references/debug-workflow.md` | +| `fix ` | Focused fix loop — for specific lint, type, or test failures without full debug isolation | `references/fix-workflow.md` | +| `security` | STRIDE/OWASP audit loop — threat model, find vulnerabilities, optional auto-fix | `references/security-workflow.md` | + +No mode means the standard loop above. + +**When a mode is invoked**, read the corresponding reference file +before doing anything else. The reference file contains the full protocol +for that workflow. + +--- + +## Setup phase (run once before the loop) + +1. Read every file in Scope to build full context. Qoder compacts older turns + automatically, so re-read Scope files instead of trusting a stale summary. +2. Read `autoresearch-lessons.md` if it exists. This is accumulated knowledge + from prior runs. Read it carefully before forming any hypothesis. +3. Run the Verify command. Record the output as the baseline (iteration #0). +4. If Guard is provided: run it once. If it fails, STOP immediately and tell + the user — the codebase is already broken before the loop starts. Fix the + Guard failure manually before proceeding. Guard must be green at baseline. +5. Initialise `autoresearch-results.tsv`: + ``` + iteration\tcommit\tmetric\tdelta\tstatus\tguard\tdescription + 0\t-\t\t0.0\tbaseline\tpass\tinitial measurement + ``` +6. Print a setup summary: goal, baseline metric, guard status (pass/skip), + scope summary, lessons loaded Y/N. +7. Start the loop immediately. Do not wait for confirmation. + +--- + +## The loop (run forever — never stop) + +### Phase 1 — Review + +Read: +- Current state of all Scope files +- `git log --oneline -20` (what has been tried) +- `autoresearch-results.tsv` (what worked, what failed, patterns) +- `autoresearch-lessons.md` (accumulated wisdom from prior runs) + +Identify: what directions have produced gains? what has consistently failed? +what has not been tried yet? + +### Phase 2 — Ideate + +Pick ONE hypothesis. It must be: +- Specific and testable in a single iteration +- Meaningfully different from the last 3 attempts +- Informed by both the results log and the lessons file +- Explained in one sentence + +Prefer hypotheses that build on proven wins over untested territory. +Prefer simplicity — a small clean change beats a large complex one. + +### Phase 3 — Modify + +Make exactly ONE atomic change in Scope. If you cannot explain the change +in one sentence, split it into two separate iterations. + +Do not touch files outside Scope. Do not refactor unrelated code. One thing. + +### Phase 4 — Commit + +```bash +git add -A && git commit -m "autoresearch iter N: " +``` + +**Commit BEFORE verifying.** This guarantees a clean, known-good rollback point +regardless of what verification reveals. Never skip this step. + +### Phase 5 — Verify + Guard + +**Step A — Run Verify.** Extract the numeric metric value. + +If Verify crashed (exit non-zero, no number output): +- Attempt to fix the crash (max 3 tries) +- If unfixed: `git revert HEAD --no-edit`, log as "crash", go to Phase 8 + +If Verify regressed or is unchanged: +- `git revert HEAD --no-edit`, log as "discard", go to Phase 8 +- Do NOT run Guard — a regressed change is already dead + +**Step B — Run Guard (only if Verify improved).** Exit code 0 = pass. + +**Web research supplement**: after Verify passes, use `WebSearch` for +additional signal when local scripts cannot capture full quality. +See `references/web-research-patterns.md`. Research is a supplement only. + +### Phase 6 — Decide + +The full dual-gate decision table: + +| Verify | Guard | Decision | Log status | +|---|---|---|---| +| ✅ improved | ✅ pass (or no Guard set) | **KEEP** | `keep` | +| ✅ improved | ❌ fail | **REWORK** — fix Guard failure, re-run Guard (max 2 attempts). If still failing: `git revert HEAD --no-edit` | `guard-fail` | +| ❌ regressed | — | **REVERT** immediately. Do not run Guard. | `discard` | +| ❌ unchanged | — | **REVERT**. Treat unchanged as a regression. | `discard` | +| 💥 crashed | — | **FIX** (max 3 attempts), then revert if unfixed. | `crash` | + +**Rework protocol** (when Verify passes but Guard fails): +1. Read the Guard failure output carefully +2. Make the minimal additional change to satisfy Guard without hurting Verify +3. Amend the commit: `git add -A && git commit --amend --no-edit` +4. Re-run both Verify AND Guard +5. If both pass → KEEP. If Guard still fails after 2 rework attempts → REVERT. + +### Phase 7 — Log + +Append one row to `autoresearch-results.tsv`: + +``` +\t\t\t\t\t\t +``` + +Delta = metric_value − previous_best (positive = improvement for "higher is +better" goals, negative = improvement for "lower is better" goals). + +### Phase 8 — Repeat + +Go to Phase 1. Immediately. NEVER STOP. + +--- + +## Progress summary (every 10 iterations) + +Print this, then continue immediately: + +``` +=== Autoresearch progress — iteration N === +Baseline: +Current best: ( from baseline) +Keeps: +Discards: +Crashes: +Top pattern: +Last 5: +=== +``` + +--- + +## Lessons system + +After every 5 KEPT iterations, append to `autoresearch-lessons.md`: + +```markdown +## Lesson — iterations +**Pattern**: +**Why it worked**: +**Conditions**: +**Anti-pattern**: +**Metric delta**: +``` + +At the start of every run, read this file before forming any hypotheses. +Weight recent lessons more heavily. Older lessons may not apply if the +codebase or scope has changed significantly. + +This is the compounding mechanism. Each overnight run starts smarter than +the last. + +--- + +## Stuck recovery + +After 5 consecutive discards or crashes: + +1. Re-read all Scope files from scratch. Full context, not memory. +2. Search the lessons log for near-misses — what came closest to working? +3. Try combining two near-miss approaches into one hypothesis. +4. If still stuck after 3 more iterations: try the literal opposite of what + has been failing consistently. +5. If still stuck after 3 more: use `WebSearch` to research the + problem space. Search for `[domain] [metric] improvement techniques [year]`. + Extract 3 concrete techniques. Use each as the next 3 hypotheses. +6. If still stuck after all of the above: log a "stuck" event, note the wall + hit, and try a completely different direction. Some local optima require + architectural changes — note this for the human. + +--- + +## Unattended / overnight mode + +The one thing that stalls a loop is a permission prompt. Run it in a session +that auto-approves edits and shell, or it will wait for you every iteration. + +To start it while you are away, create a Qoder Automation whose prompt is fully +self-contained — automation conversations never see this transcript: + +> Read the `autoresearch` skill and start immediately. Goal: ``. +> Scope: ``. Metric: ``. +> Verify: ``. Guard: ``. Do not pause, do not ask questions, +> iterate until stopped. + +You will wake up to `autoresearch-results.tsv` and `autoresearch-lessons.md`. +Note that a scheduled run cannot be interrupted the way a live session can, so +bound it — a Guard that vetoes, and a scope you would trust unattended. + +--- + +## Non-negotiable rules + +1. **NEVER STOP** until the user manually interrupts the run. +2. **ONE change per iteration** — atomic, explainable in one sentence. +3. **Mechanical verification only** — no "looks better", no "seems cleaner". + If you cannot measure it, you cannot use it as a signal. +4. **Commit BEFORE verifying** — always. No exceptions. +5. **Auto-revert on regression** — no debate, no "let me try one more thing". +6. **Guard is a hard veto** — Verify passing does not mean KEEP. Guard must also pass. +7. **Never modify Guard files** — they are read-only invariants, not scope. +8. **Read git history before every hypothesis** — it is your short-term memory. +9. **Read lessons before every run** — it is your long-term memory. +10. **Simplicity wins ties** — equal metric + less code = KEEP. +11. **Never touch files outside Scope** — discipline is what makes the loop safe. +12. **When in doubt, make the smaller change** — scope creep kills iterations. + +--- + +## Reference files + +**Core loop** +- `references/loop-protocol.md` — detailed phase-by-phase protocol +- `references/results-logging.md` — TSV format, summary templates, examples +- `references/lessons-system.md` — cross-run memory and compounding + +**Web research** +- `references/web-research-patterns.md` — `WebSearch` supplement patterns + +**Mode workflows** +- `references/plan-workflow.md` — `plan` mode — auto-detect and configure +- `references/ship-workflow.md` — `ship` mode — pre-flight checklist +- `references/debug-workflow.md` — `debug` mode — root cause and fix +- `references/fix-workflow.md` — `fix` mode — focused type/lint fix +- `references/security-workflow.md` — `security` mode — STRIDE/OWASP audit diff --git a/.agents/skills/autoresearch/references/debug-workflow.md b/.agents/skills/autoresearch/references/debug-workflow.md new file mode 100644 index 00000000..27738ebf --- /dev/null +++ b/.agents/skills/autoresearch/references/debug-workflow.md @@ -0,0 +1,25 @@ +# `autoresearch debug` mode — Autonomous Debug Loop + +This workflow is triggered by the `debug` mode. It is designed to reproduce, isolate, and fix specific bugs autonomously. + +## Context +Use this when something is clearly broken (e.g., a failing test, a crash, or a UI bug). + +## Phase 1: Reproduction +1. Create a minimal reproduction script (e.g., `debug/repro.js` or a new test case). +2. Run the repro script and verify it fails as expected. +3. This repro command becomes your `Verify` command for the loop. + +## Phase 2: Isolation +1. Use `Grep` and `Read` to find the code responsible for the failure. +2. Form a hypothesis about the root cause. + +## Phase 3: Fix Loop +1. Start a standard autoresearch loop with: + - **Goal**: Fix the bug identified in the repro script. + - **Verify**: The repro command (must exit 0 on success). + - **Guard**: Existing test suite and linting. + +## Phase 4: Hardening +1. After the fix is verified, add a permanent regression test to the codebase. +2. Verify that the fix holds across the entire project. diff --git a/.agents/skills/autoresearch/references/fix-workflow.md b/.agents/skills/autoresearch/references/fix-workflow.md new file mode 100644 index 00000000..04303826 --- /dev/null +++ b/.agents/skills/autoresearch/references/fix-workflow.md @@ -0,0 +1,31 @@ +# Fix Workflow (`autoresearch fix` mode) + +The `fix` workflow is a lightweight version of the `debug` loop. It is designed for situations where you have a specific, known failure (e.g., a TypeScript error or a lint violation) and you want to fix it without the overhead of full reproduction and isolation. + +## Protocol + +### 1. Context Loading +* Read the error message or description provided in the command. +* Identify the affected file(s). +* Read the current state of those files. + +### 2. Hypothesis +* Form a direct hypothesis on how to fix the specific error. +* The fix must be minimal and targeted. + +### 3. Execution +* Apply the fix. +* Commit the change. + +### 4. Verification +* Run the command that triggered the original failure (e.g., `npx tsc` or `npm run lint`). +* If a `Guard` is set in the main autoresearch config, run that as well. + +### 5. Decision +* If the error is gone and Guard passes: **KEEP**. +* If the error persists: **RETRY** (max 3 times) with a different approach. +* If it still fails after 3 tries: **REVERT** and report to the user. + +## When to use `fix` vs `debug` +* Use **`fix`** for mechanical errors: "Fix the lint error on line 42", "Fix the missing import in `utils.ts`". +* Use **`debug`** for logical errors: "The login flow fails for users with specialized characters", "Database connection timeouts under high load". diff --git a/.agents/skills/autoresearch/references/lessons-system.md b/.agents/skills/autoresearch/references/lessons-system.md new file mode 100644 index 00000000..7eb15dd9 --- /dev/null +++ b/.agents/skills/autoresearch/references/lessons-system.md @@ -0,0 +1,117 @@ +# Lessons system + +The lessons system is what separates autoresearch from a dumb +mutation loop. It is the mechanism by which each overnight run starts +smarter than the last. + +--- + +## The compounding model + +``` +Night 1: 100 experiments → lessons-v1 written +Night 2: reads lessons-v1 → avoids 20 known failures → 80 net-new experiments +Night 3: reads lessons-v2 → avoids 35 known failures → faster convergence +... +``` + +Without the lessons system, every run starts from scratch. With it, runs +compound — each failure is learned once and never repeated. + +--- + +## File location and format + +File: `autoresearch-lessons.md` in your project root. + +Add to `.gitignore` — this is a working file for the agent, not source code. + +```markdown +# Autoresearch lessons — +Generated by the autoresearch skill. Do not edit manually. +Last updated: + +## Lesson 1 — iterations 1–5 +**Pattern**: +**Why it worked**: +**Conditions**: +**Anti-pattern**: +**Metric delta**: + +## Lesson 2 — iterations 6–10 +... +``` + +--- + +## When to write lessons + +Append a new lesson after every 5 KEPT iterations (not every 5 total +iterations). Lessons should only describe what worked. + +Failed patterns are captured implicitly — if a pattern never generates a +kept iteration, it never generates a lesson, and the loop naturally +deprioritises it via Phase 2's "different from last 3 attempts" rule. + +--- + +## What makes a good lesson + +**Good** (specific, mechanistic, conditional): +``` +**Pattern**: Defer non-critical third-party scripts using loading="lazy" +**Why it worked**: Removes scripts from the critical render path, reducing + Time to Interactive without affecting functionality +**Conditions**: Applies to analytics, chat widgets, social embeds — not + to scripts required for initial page render +**Anti-pattern**: Lazy-loading scripts that are called in the first 500ms + of page load caused layout shifts and broke interactions +**Metric delta**: +6.8% Lighthouse performance score across 3 iterations +``` + +**Bad** (vague, not actionable): +``` +**Pattern**: Make things faster +**Why it worked**: It improved performance +**Conditions**: When performance is bad +**Anti-pattern**: When it makes things worse +``` + +--- + +## How to read lessons at the start of a run + +1. Read the full file — do not skip old lessons even if they seem stale. +2. For each lesson, assess: does this pattern still apply given the current + state of the codebase? If the code it describes has been significantly + refactored, downweight it. +3. Extract the top 2-3 highest-delta patterns. These are your first + hypotheses unless the results log shows they have already been exhausted. +4. Extract the anti-patterns. These are your first exclusions — do not + generate hypotheses that match these patterns. + +--- + +## Cross-project lessons + +For teams running autoresearch across multiple similar projects (e.g. +multiple Next.js apps), consider maintaining a shared lessons file at +`~/.autoresearch/global-lessons.md`. + +At the start of a run, read both the project-level and global lessons. +Project-level lessons take precedence when they conflict with global ones. + +This is optional but significantly accelerates convergence on new projects +that share a tech stack with already-researched ones. + +--- + +## Lessons file maintenance + +- Do not manually edit the lessons file during a run — the agent reads it + at the start of each run and its contents influence hypothesis generation. +- After a long run (100+ iterations), review the file and remove lessons + that are no longer applicable (e.g. they describe code that no longer + exists). Add a comment explaining why the lesson was removed. +- The lessons file is cumulative — never delete lessons, only annotate them + as superseded if a newer lesson contradicts them. diff --git a/.agents/skills/autoresearch/references/loop-protocol.md b/.agents/skills/autoresearch/references/loop-protocol.md new file mode 100644 index 00000000..60685c9f --- /dev/null +++ b/.agents/skills/autoresearch/references/loop-protocol.md @@ -0,0 +1,193 @@ +# Autonomous loop protocol + +Detailed specification for each of the 8 phases. The SKILL.md contains the +summary version. Read this reference when you need precise guidance on edge +cases in any phase. + +--- + +## Phase 1 — Review + +**Purpose**: Build a complete, accurate picture of current state before +forming any hypothesis. Hypotheses formed without full context waste iterations. + +**What to read**: +- Every file in Scope (not just the ones you last touched) +- `git log --oneline -20` — what has been attempted, in order +- `autoresearch-results.tsv` — the full record of what worked and failed +- `autoresearch-lessons.md` — accumulated patterns from prior runs + +**What to extract**: +- Current metric trajectory (improving? plateauing? volatile?) +- Which change types produced the most gain per iteration +- Which change types consistently failed +- Which directions have not yet been explored +- Any patterns in crash causes + +**Duration**: This phase should take as long as needed to form a genuinely +informed hypothesis. Rushing Phase 1 leads to repeated failures. + +--- + +## Phase 2 — Ideate + +**Purpose**: Select ONE hypothesis that has the highest expected gain given +what is known. + +**Hypothesis selection criteria** (in order of priority): +1. Builds directly on a proven pattern from the lessons file +2. Explores a direction adjacent to a near-miss (something that almost worked) +3. Combines two near-miss approaches that individually failed +4. Tries the opposite of what consistently failed +5. Applies an externally validated technique (from `WebSearch` research) +6. Tries something entirely untested + +**What makes a good hypothesis**: +- Specific: "lazy-load the user avatar component" not "improve performance" +- Testable: produces a measurable delta in the Verify command +- Atomic: one thing changes, one thing is measured +- Explainable in one sentence before you make the change + +**What makes a bad hypothesis**: +- Vague: "refactor for clarity" +- Multi-part: "update the API, add caching, and fix the tests" +- Untestable by the Verify command +- Identical to something tried in the last 3 iterations + +--- + +## Phase 3 — Modify + +**Purpose**: Implement the hypothesis as a single, clean, minimal change. + +**Rules**: +- Touch only files in Scope +- Make the smallest change that tests the hypothesis +- If the change is getting large, stop and split it — make the first half now, + the second half in the next iteration +- Do not fix unrelated things you notice while editing +- Do not reformat code that is not part of the hypothesis +- Leave comments only if they directly explain the change + +**Signs you are over-scoping**: +- You have edited more than 3 files +- The diff is more than ~50 lines +- You are explaining the change with "and also" + +When in doubt, make a smaller change. Smaller changes fail faster and teach more. + +--- + +## Phase 4 — Commit + +**Purpose**: Create a clean rollback point before any verification risk. + +**Command**: +```bash +git add -A && git commit -m "autoresearch iter N: " +``` + +**Commit message format**: +- Always prefix with `autoresearch iter N:` +- One sentence, present tense, describes the change not the goal +- Good: `autoresearch iter 14: lazy-load user avatar to reduce initial bundle` +- Bad: `autoresearch iter 14: improve performance` + +**Why commit before verifying**: if the Verify command crashes, hangs, or +corrupts state, you can always `git revert HEAD --no-edit` and return to +a known-good state. If you verify before committing, a crash during +verification leaves you with uncommitted changes and an unknown baseline. + +**Never skip this step**, even if the change feels obviously correct. + +--- + +## Phase 5 — Verify + +**Purpose**: Get a single numeric measurement of whether the hypothesis helped. + +**Execution**: +1. Run the Verify command exactly as specified by the user +2. Extract the numeric metric value +3. Optionally supplement with `WebSearch` research (see + `references/web-research-patterns.md`) +4. Record the raw output for the log + +**Handling slow Verify commands**: +If the Verify command takes more than 30 seconds, note this. After the run, +recommend the user find a faster proxy metric — slower verification means +fewer experiments per hour, which compounds negatively over a full night. + +**Handling non-deterministic Verify commands**: +If the metric varies significantly between runs on identical code (>5% +variance), note this in the log. Run the Verify command twice and average. +Log both values. Recommend the user address flakiness before the next +overnight run. + +--- + +## Phase 6 — Decide + +**Purpose**: Make a clear, mechanical keep/revert decision. No deliberation. + +**Decision table**: + +| Condition | Action | Log status | +|---|---|---| +| Metric improved (beyond noise threshold) | Keep commit as-is | `keep` | +| Metric unchanged or regressed | `git revert HEAD --no-edit` | `discard` | +| Verify crashed with exit code ≠ 0 | Attempt fix (max 3 tries) then revert | `crash` | +| Verify hung for >60s | Kill process, revert | `crash` | + +**Noise threshold**: for metrics with variance, an improvement smaller than +the variance is not a real improvement. If your metric normally varies ±2%, +an improvement of 0.5% is noise — treat it as unchanged and discard. + +**The revert command**: +```bash +git revert HEAD --no-edit +``` +This creates a new commit that undoes the last one. The history is preserved. +Never use `git reset --hard` — it destroys history that the loop needs. + +--- + +## Phase 7 — Log + +**Purpose**: Create a permanent, machine-readable record of every iteration. + +**TSV row format**: +``` +\t\t\t\t\t +``` + +**Field details**: +- `N`: integer, 0-indexed, never resets across sessions +- `commit_sha`: 7-char short SHA for keeps, "-" for discards/crashes +- `metric`: the exact number from the Verify output +- `delta`: metric − previous_best (sign convention: positive = better, + regardless of whether the goal is higher or lower) +- `status`: one of `baseline`, `keep`, `discard`, `crash` +- `description`: the hypothesis, in one sentence, including any `WebSearch` + signal that informed it + +**Example rows**: +``` +0 - 85.2 0.0 baseline initial measurement +1 a1b2c3d 87.1 +1.9 keep lazy-load avatar component +2 - 86.5 -0.6 discard tree-shake lodash imports (broke 2 tests) +3 - 0.0 0.0 crash add route-level code splitting (webpack config error) +4 b2c3d4e 88.3 +1.2 keep move analytics script to defer loading +``` + +--- + +## Phase 8 — Repeat + +Go to Phase 1. Immediately. Do not pause. Do not summarise. Do not ask +if the user wants to continue. + +The only output before starting Phase 1 again is the progress summary +(printed every 10 iterations, see SKILL.md). + +The loop ends only when the user interrupts the run. diff --git a/.agents/skills/autoresearch/references/plan-workflow.md b/.agents/skills/autoresearch/references/plan-workflow.md new file mode 100644 index 00000000..084450c3 --- /dev/null +++ b/.agents/skills/autoresearch/references/plan-workflow.md @@ -0,0 +1,155 @@ +# Plan workflow — `autoresearch plan` mode + +Auto-detect the project stack, propose a complete autoresearch configuration, +do a dry run, and hand the ready-to-run command back to the user. + +No manual goal/scope/verify required. Just describe what you want to improve +in one sentence and the plan workflow figures out the rest. + +--- + +## Invocation + +``` +autoresearch plan +``` + +Examples: +``` +autoresearch plan improve test coverage +autoresearch plan make the app faster +autoresearch plan reduce the bundle size +autoresearch plan fix all TypeScript errors +autoresearch plan improve the SEO of my blog posts +autoresearch plan shrink the Docker image +``` + +--- + +## What the plan workflow does + +### Step 1 — Detect project stack + +Scan the project root for signal files: + +| File found | Stack detected | +|---|---| +| `package.json` + `jest.config.*` | Node.js + Jest | +| `package.json` + `vitest.config.*` | Node.js + Vitest | +| `next.config.*` | Next.js | +| `Dockerfile` | Docker | +| `*.tf` | Terraform | +| `.github/workflows/*.yml` | GitHub Actions CI | +| `content/blog/*.md` OR `posts/*.md` | Markdown content/blog | +| `src/**/*.ts` OR `src/**/*.tsx` | TypeScript project | +| `pyproject.toml` OR `setup.py` | Python project | +| `requirements.txt` + `pytest` | Python + pytest | +| `go.mod` | Go project | +| `Cargo.toml` | Rust project | + +Print detected stack. If ambiguous, list the top two candidates and ask +the user to confirm before proceeding. + +### Step 2 — Map goal to metric + verify command + +Use the goal description and detected stack to propose: + +| Goal keyword | Metric | Verify command template | +|---|---|---| +| "test coverage" | coverage % (higher is better) | `npm test -- --coverage \| grep "All files"` | +| "bundle size" / "build size" | size in KB (lower is better) | `npm run build 2>&1 \| grep "First Load JS"` | +| "TypeScript errors" / "type errors" | error count (lower is better) | `npx tsc --noEmit 2>&1 \| grep -c "error TS" \|\| echo "0"` | +| "lighthouse" / "performance score" | score 0-100 (higher is better) | `npx lighthouse http://localhost:3000 --output json --quiet 2>/dev/null \| jq '.categories.performance.score * 100'` | +| "docker image" / "image size" | size in MB (lower is better) | `docker build -t bench . -q && docker images bench --format "{{.Size}}"` | +| "flaky tests" | failure count (lower is better) | `for i in {1..5}; do npm test 2>&1; done \| grep -c "FAIL" \|\| echo "0"` | +| "SEO" / "blog" / "content" | SEO score (higher is better) | `node scripts/seo-score.js ` | +| "lines of code" / "complexity" | LOC count (lower is better) | `find src/ -name "*.ts" \| xargs wc -l \| tail -1 \| awk '{print $1}'` | +| "CI pipeline" / "pipeline speed" | seconds (lower is better) | `node scripts/estimate-ci-time.js` | +| "Python tests" / "pytest" | coverage % (higher is better) | `pytest --cov=src --cov-report=term-missing \| grep "TOTAL"` | +| "faster" / "performance" / "latency" | p95 ms (lower is better) | `npm run bench 2>&1 \| grep "p95"` | + +### Step 3 — Detect scope + +Based on goal + stack, propose the tightest scope that covers the goal: + +- Test coverage → `src/**/*.ts, src/**/*.test.ts` +- Bundle size → `src/**/*.tsx, src/**/*.ts` +- Docker → `Dockerfile, .dockerignore` +- SEO → `content/blog/*.md` or detected content directory +- TypeScript errors → `src/**/*.ts` +- CI pipeline → `.github/workflows/*.yml` + +### Step 4 — Dry run + +Run the proposed Verify command once against the current state. + +- If it exits 0 and outputs a number → baseline confirmed, proceed +- If it exits non-zero → diagnose and fix the verify command before proposing +- If it hangs → propose a faster alternative + +### Step 5 — Output the ready-to-run command + +Print this exact block for the user to copy-paste or confirm: + +``` +=== Autoresearch plan === +Stack: +Goal: +Scope: +Metric: ( is better) +Verify: +Baseline: + +Ready to run. Confirm or adjust any field, then: + +/autoresearch +Goal: +Scope: +Metric: +Verify: + +Or, for an unattended run, put these same fields into a Qoder Automation prompt +(see "Unattended / overnight mode" in SKILL.md). +=== +``` + +If the user says "looks good" or "run it" — start the autoresearch loop +immediately without requiring them to retype the command. + +--- + +## Web research calibration + +After the dry run, use `WebSearch` to calibrate: + +- For SEO goals: search for `[target keyword]` to see what top results look like. + Note any structural patterns (FAQ sections, word count, heading structure) + that the current content lacks. Add these as initial hypotheses. + +- For performance goals: search for `[framework] performance benchmarks [year]` + to calibrate whether the baseline is already good or has significant headroom. + +- For security goals: search for `[stack] common vulnerabilities [year]` + to seed the initial hypothesis pool with known attack vectors. + +This research step happens during plan, not during the loop — so it adds +context once without slowing down iterations. + +--- + +## Edge cases + +**Goal is too vague** ("make it better"): +Ask one clarifying question: "Better in what way — speed, quality, size, +coverage, or something else?" Then proceed. + +**Multiple valid verify commands exist**: +Propose the fastest one. Note the slower alternative in a comment. + +**Verify command requires a running server**: +Note this in the plan output. Add a `# requires: local server on :3000` +comment. Suggest the user start it before running the loop. + +**No matching stack detected**: +Ask the user to describe their stack in one sentence, then proceed with +a custom verify command. diff --git a/.agents/skills/autoresearch/references/results-logging.md b/.agents/skills/autoresearch/references/results-logging.md new file mode 100644 index 00000000..63c2d8a5 --- /dev/null +++ b/.agents/skills/autoresearch/references/results-logging.md @@ -0,0 +1,105 @@ +# Results logging + +Specification for `autoresearch-results.tsv` — the per-iteration record +of every experiment in a run. + +--- + +## File format + +Tab-separated values. Headers on row 1. One row per iteration. + +``` +iteration\tcommit\tmetric\tdelta\tstatus\tdescription +``` + +### Field definitions + +| Field | Type | Description | +|---|---|---| +| `iteration` | integer | 0-indexed. Never resets — if you run multiple sessions, continue from the last number. | +| `commit` | string | 7-char git short SHA for kept commits. `-` for discards and crashes. | +| `metric` | float | Raw metric value from the Verify command. | +| `delta` | float | `metric − previous_best`. Sign convention: positive = improvement (regardless of higher/lower goal). | +| `status` | enum | One of: `baseline`, `keep`, `discard`, `crash` | +| `description` | string | The hypothesis, one sentence. Include the change type and the expected mechanism. | + +--- + +## Example file + +```tsv +iteration commit metric delta status description +0 - 85.2 0.0 baseline initial measurement — test coverage 85.2% +1 a1b2c3d 87.1 +1.9 keep add tests for auth middleware edge cases +2 - 86.5 -0.7 discard refactor test helpers (broke 2 existing tests) +3 - 0.0 0.0 crash add integration tests (postgres connection failed — fix in iter 4) +4 b2c3d4e 88.3 +1.2 keep add tests for error handling in API routes +5 - 88.1 -0.2 discard add tests for rate limiter (metric within variance, treated as regression) +6 c3d4e5f 89.0 +0.7 keep add boundary value tests for form validators +7 d4e5f6g 89.8 +0.8 keep add tests for session expiry edge cases +8 - 89.2 -0.6 discard mock external API calls (test isolation but metric regressed) +9 e5f6g7h 90.6 +0.8 keep add tests for concurrent request handling +10 f6g7h8i 91.1 +0.5 keep add tests for malformed JSON input handling +``` + +--- + +## Progress summary format + +Print every 10 iterations. Use this exact format: + +``` +=== Autoresearch progress — iteration === +Goal: +Baseline: +Current best: ( from baseline) +Keeps: (%) +Discards: +Crashes: +Top pattern: +Last 5: +Est. to goal: +=== +``` + +--- + +## Interpreting the log + +### Healthy run signature +- Keep rate 40-60% +- Delta per keep: consistent small positive gains +- No long crash streaks +- Discards are evenly distributed (not clustered) + +### Warning signs + +| Pattern | Meaning | Action | +|---|---|---| +| Keep rate < 20% | Hypothesis quality is poor | Re-read full scope, re-read lessons, change direction | +| Keep rate > 80% | Metric may be too easy or Verify too lenient | Tighten the goal | +| Long crash streak (5+) | Verify command is fragile or scope is too risky | Fix Verify or narrow scope | +| Delta per keep shrinking toward 0 | Approaching local optimum | Try more radical changes or declare victory | +| Metric oscillating | Non-deterministic Verify or contradictory changes | Run Verify twice and average; tighten scope | + +### Declaring success + +Stop the loop when one of these is true: +- Metric has reached the stated goal +- Delta per keep has been below 0.1% for 20 consecutive iterations + (local optimum with current scope) +- All directions have been exhausted (lessons file confirms this) + +In all cases, print a final summary and write a lessons entry covering +the full run before stopping. + +--- + +## File hygiene + +- Add `autoresearch-results.tsv` to `.gitignore`. It is a working file. +- Do not edit it manually during a run. +- Between runs, you may archive it: + `mv autoresearch-results.tsv autoresearch-results-.tsv` + and start fresh, but keep the lessons file — that is the persistent memory. diff --git a/.agents/skills/autoresearch/references/security-workflow.md b/.agents/skills/autoresearch/references/security-workflow.md new file mode 100644 index 00000000..b3ccd3e1 --- /dev/null +++ b/.agents/skills/autoresearch/references/security-workflow.md @@ -0,0 +1,171 @@ +# Security workflow — `autoresearch security` mode + +Autonomous security audit using STRIDE threat modelling and OWASP categories. +Finds vulnerabilities, classifies them by severity, and optionally fixes +confirmed critical and high findings via an autoresearch loop. + +--- + +## Invocation + +``` +autoresearch security # full audit, report only +autoresearch security --fix # audit + auto-fix confirmed findings +autoresearch security --fail-on critical # end with a FAIL verdict if critical found +autoresearch security --scope src/api/ # audit a specific directory only +``` + +--- + +## Phase 1 — Asset discovery + +Map the attack surface: + +1. Identify all entry points: API routes, form handlers, file uploads, + auth flows, webhooks, admin panels +2. Identify all data stores: databases, caches, file system writes, + environment variables, secrets +3. Identify all trust boundaries: public vs authenticated, user vs admin, + internal vs external services +4. Map data flows: what user input reaches what data store via what path + +Output: `security/audit-/attack-surface-map.md` + +### Live threat intelligence + +Use `WebSearch` to seed the audit with current threats: + +``` +WebSearch: [your stack] common vulnerabilities [current year] +WebSearch: [your main framework] CVE [current year] +WebSearch: OWASP top 10 [current year] +``` + +Add any newly discovered attack patterns to the audit queue. +This ensures the audit covers threats that postdate your static analysis tools. + +--- + +## Phase 2 — STRIDE threat model + +For each asset and trust boundary, model threats across all 6 STRIDE categories: + +| Category | Question to ask | +|---|---| +| **S**poofing | Can an attacker impersonate a user, service, or system? | +| **T**ampering | Can input be modified to alter data or behaviour unexpectedly? | +| **R**epudiation | Can actions be performed without a traceable audit trail? | +| **I**nformation disclosure | Can sensitive data be accessed by unauthorised parties? | +| **D**enial of service | Can the service be made unavailable through normal inputs? | +| **E**levation of privilege | Can a lower-privilege user gain higher-privilege access? | + +Output: `security/audit-/threat-model.md` + +--- + +## Phase 3 — Autonomous audit loop + +``` +LOOP (through all attack vectors from threat model): + 1. Select next untested attack vector + 2. Deep-dive into the relevant code (read fully — do not skim) + 3. Attempt to construct a concrete exploit scenario + 4. Validate with code evidence (file:line + exact scenario) + 5. Classify: severity + OWASP category + STRIDE tag + 6. Log to security-audit-results.tsv + 7. Print coverage summary every 5 iterations + 8. Continue until all vectors tested +``` + +### Severity classification + +| Severity | Definition | +|---|---| +| Critical | Exploitable without authentication, leads to full compromise or data breach | +| High | Exploitable with low-privilege access, significant impact | +| Medium | Requires specific conditions, moderate impact | +| Low | Minor information disclosure, no direct exploitation path | +| Info | Best practice violation, no immediate security impact | + +### Evidence requirement + +Every finding MUST have: +- File path and line number +- Exact vulnerable code snippet (copy from source, do not paraphrase) +- Concrete exploit scenario (how an attacker would trigger this) +- Proof of exploitability (not theoretical — show the actual path) + +Findings without concrete evidence are logged as "unconfirmed" and flagged +for manual review, not included in the fix loop. + +--- + +## Phase 4 — Report generation + +Output folder: `security/audit-/` + +``` +security/audit-20260325-1430/ +├── overview.md ← executive summary + finding counts by severity +├── threat-model.md ← STRIDE analysis per asset +├── attack-surface-map.md ← entry points, data flows, trust boundaries +├── findings.md ← all confirmed findings, sorted by severity +├── owasp-coverage.md ← coverage matrix — which OWASP categories checked +├── recommendations.md ← fix guidance for each confirmed finding +└── security-audit-results.tsv ← machine-readable log of all iterations +``` + +Print summary: +``` +=== Security audit summary === +Critical: +High: +Medium: +Low: +Info: +Vectors tested: / +OWASP categories covered: + +Full report: security/audit-/overview.md +=== +``` + +--- + +## Phase 5 — Auto-fix loop (with `--fix`) + +Only runs when `--fix` flag is passed. +Only fixes **Confirmed Critical and High** findings. +Uses `recommendations.md` as the fix guide for each finding. + +``` +FOR EACH confirmed Critical/High finding: + 1. Read the finding + recommendation + 2. Make ONE targeted fix + 3. git commit the fix + 4. Re-run the specific exploit scenario to verify it no longer works + 5. Run full test suite to confirm no regressions + 6. If tests break → revert, try alternative fix + 7. Maximum 3 attempts per finding, then skip and flag for manual review + 8. Log fix outcome to fix-log.md +``` + +--- + +## Verdict mode (`--fail-on`) + +``` +autoresearch security --fail-on critical +``` + +The audit ends with an explicit verdict line in `overview.md`: + +``` +VERDICT: FAIL — 2 findings at or above `critical` +VERDICT: PASS — no findings at or above `critical` +``` + +A skill run has no process exit code, so do not wire this into a CI gate as if +it did — use a real scanner for blocking merges. What it *is* good for is an +unattended scheduled audit: a Qoder Automation running this mode reports the +verdict, and you act on it. diff --git a/.agents/skills/autoresearch/references/ship-workflow.md b/.agents/skills/autoresearch/references/ship-workflow.md new file mode 100644 index 00000000..66c36a42 --- /dev/null +++ b/.agents/skills/autoresearch/references/ship-workflow.md @@ -0,0 +1,164 @@ +# Ship workflow — `autoresearch ship` + +Run a pre-flight checklist before shipping — tests, types, lint, bundle size, +security basics, and a final autoresearch pass on anything that fails. + +The ship workflow is not just a checklist. It runs an autoresearch loop on +each failing gate until it passes, then re-checks. You don't ship broken. +You ship when everything is green. + +--- + +## Invocation + +``` +autoresearch ship +``` + +Optional flags: +``` +autoresearch ship --fast # skip slow checks (lighthouse, e2e) +autoresearch ship --loop N # max N autoresearch iterations per gate (default: 20) +autoresearch ship --dry-run # report status without fixing anything +``` + +--- + +## The ship checklist + +The workflow runs these gates in order. Each gate that fails triggers an +autoresearch sub-loop to fix it before moving to the next gate. + +### Gate 1 — Tests pass + +```bash +npm test # Node.js +pytest # Python +go test ./... # Go +cargo test # Rust +``` + +If tests fail → autoresearch loop on `src/**/*.ts` (or equivalent) with +metric: failing test count (lower is better), max 20 iterations. + +### Gate 2 — No type errors + +```bash +npx tsc --noEmit # TypeScript +mypy src/ # Python +``` + +If errors found → autoresearch loop on `src/**/*.ts` with +metric: error count (lower is better), max 20 iterations. + +### Gate 3 — No lint errors + +```bash +npx eslint src/ # JavaScript/TypeScript +ruff check src/ # Python +golangci-lint run # Go +``` + +If errors found → autoresearch loop with metric: lint error count (lower is better). +Auto-fixable errors are fixed first (`--fix` flag), then the loop handles the rest. + +### Gate 4 — Bundle size (if applicable) + +Only runs for frontend projects (detected: `next.config.*`, `vite.config.*`, +`webpack.config.*`). + +```bash +npm run build 2>&1 | grep "First Load JS" +``` + +Threshold: warn if > 300KB, block if > 500KB (configurable via `.autoresearch.yml`). + +If over threshold → autoresearch loop on `src/**/*.tsx, src/**/*.ts` with +metric: bundle size in KB (lower is better), max 20 iterations. + +### Gate 5 — No hardcoded secrets + +```bash +git diff HEAD~1 --diff-filter=A | grep -iE "(api_key|secret|password|token)\s*=\s*['\"][^'\"]{8,}" +``` + +If secrets found → do NOT autoresearch. Flag for human review. Block ship. + +### Gate 6 — Dependency audit + +```bash +npm audit --audit-level=high # Node.js +pip-audit # Python +``` + +If critical vulnerabilities found → autoresearch loop to update affected +dependencies, max 10 iterations. + +--- + +## Ship report + +After all gates pass, print: + +``` +=== Ship report === +Tests: ✓ PASS (247 passing) +Types: ✓ PASS (0 errors) +Lint: ✓ PASS (0 errors) +Bundle: ✓ PASS (187KB) +Secrets: ✓ PASS (none detected) +Deps: ✓ PASS (0 high/critical) + +Autoresearch loops run: +Total improvements: iterations kept + +Ready to ship. Run: git push && +=== +``` + +If any gate is still failing after the max iterations: + +``` +=== Ship report === +Tests: ✓ PASS +Types: ✗ FAIL (3 errors remaining after 20 iterations) + → manual fix required: src/auth/session.ts:47 + +Ship BLOCKED. Fix the above before shipping. +=== +``` + +--- + +## Web research post-check + +After all gates pass, use `WebSearch` to check: + +``` +WebSearch: [your framework] [version] known issues [current year] +WebSearch: [your main dependencies] security advisory [current year] +``` + +If any critical advisories surface that the dependency audit missed, +flag them before shipping. This is a final sanity check that goes beyond +what local tools can detect. + +--- + +## Configuration via `.autoresearch.yml` + +Create this file in your project root to customise ship behaviour: + +```yaml +ship: + bundle_warn_kb: 300 + bundle_block_kb: 500 + max_iterations_per_gate: 20 + skip_gates: + - lighthouse # skip if no local server available + extra_gates: + - name: "E2E tests" + command: "npx playwright test" + metric: "failing tests (lower is better)" + max_iterations: 10 +``` diff --git a/.agents/skills/autoresearch/references/web-research-patterns.md b/.agents/skills/autoresearch/references/web-research-patterns.md new file mode 100644 index 00000000..e2dedfc0 --- /dev/null +++ b/.agents/skills/autoresearch/references/web-research-patterns.md @@ -0,0 +1,144 @@ +# Web research patterns + +Qoder exposes a `WebSearch` tool (and `WebFetch` to read a promising result in +full). Use them as a verification supplement — not a replacement for the Verify +command, but an additional signal when local scripts alone cannot capture +quality. + +--- + +## When to use WebSearch in the loop + +| Goal type | Use WebSearch for | Example query | +|---|---|---| +| SEO content | Check competing pages, keyword signals | `[target keyword] filetype:md OR site:*.dev` | +| API correctness | Verify endpoint signatures, check for deprecations | `[library] [method] deprecated 2025 OR 2026` | +| Dependency versions | Confirm latest stable before updating | `[package name] latest stable version` | +| Best practices | Check if your approach matches current consensus | `[pattern] best practice [language] 2026` | +| Content accuracy | Ground-truth check generated facts | `[claim] site:official-source.com` | +| Bundle/perf baselines | Compare your score to current industry benchmarks | `[framework] bundle size benchmark 2026` | + +--- + +## Pattern 1 — SEO content verification + +Use when: optimising blog posts, landing pages, documentation for search. + +After your local score script runs, supplement with: + +``` +WebSearch: [target keyword] to see what the top 3 results have in common. +Note: heading structure, content length, semantic coverage, internal links. +If top results consistently have trait X that your content lacks, +add "add trait X" as the next hypothesis. +``` + +This gives you signal that no local readability or keyword-density script can +provide — what the search engine is actually rewarding right now. + +--- + +## Pattern 2 — API currency check + +Use when: refactoring code that calls external libraries or APIs. + +Before committing any API-surface change: + +``` +WebSearch: [library name] [method name] changelog 2026 +WebSearch: [library name] [method name] deprecated +``` + +If search returns deprecation notices or breaking changes, note the current +replacement pattern and use that as the hypothesis instead. + +This prevents iterating toward a working-but-deprecated solution that will +break on the next library update. + +--- + +## Pattern 3 — Dependency version check + +Use when: the Verify command suggests a dependency might be outdated, or when +optimising for security/bundle size. + +``` +WebSearch: [package name] npm latest 2026 +WebSearch: [package name] security advisory +``` + +Cross-reference against what is in `package.json`, `go.mod`, `requirements.txt` +or equivalent. Use the delta as a hypothesis: "update [package] from X to Y, +check if metric improves." + +--- + +## Pattern 4 — Best practice calibration + +Use when: stuck after 5 consecutive discards and local ideas are exhausted. + +``` +WebSearch: [language/framework] [metric type] optimisation techniques 2026 +WebSearch: how to improve [metric] in [stack] +``` + +Extract 3 concrete, actionable techniques from the top results — use `WebFetch` +on the most promising one if the snippet is too thin. Do not extract vague +advice. Add each as a separate iteration hypothesis. This restocks your +hypothesis pool with externally validated approaches. + +--- + +## Pattern 5 — Benchmark calibration + +Use when: you want to know if your current metric value is good relative to +the industry, not just relative to your own baseline. + +``` +WebSearch: [framework] [metric] benchmark 2026 average +``` + +If your metric is already at or above the industry median, note this and +shift the goal definition (e.g. from "reduce bundle size" to "reduce bundle +size while improving lighthouse score"). + +--- + +## Pattern 6 — Content accuracy check + +Use when: the Verify command measures style/structure but not factual accuracy +(e.g. documentation, blog posts, runbooks). + +``` +WebSearch: [specific claim in content] site:[authoritative source] +``` + +If the authoritative source contradicts your content, flag this as a +required fix before the next iteration (accuracy issues override metric gains). + +--- + +## Rules for using WebSearch + +1. **Supplement, never replace.** The Verify command runs every iteration. + Web research adds signal; it does not replace the metric. + +2. **Search at the right time.** Patterns 1-3 supplement Phase 5 (Verify). + Patterns 4-5 are for stuck recovery in Phase 1 (Review). Pattern 6 + runs in Phase 6 (Decide) when a kept iteration touches factual claims. + +3. **Extract actionable hypotheses.** Never let a search result produce a + vague conclusion ("content could be better"). Always turn the search + result into a specific next hypothesis ("add a FAQ section with 3 + questions, which top-ranking competitors include"). + +4. **Log the research signal.** When a search result influences a hypothesis, + note it in the results log description: + `"added FAQ section (web research: top results for [kw] all include FAQ)"` + +5. **Don't over-search.** Maximum one WebSearch call per iteration. If you are + searching every iteration, your Verify command is probably too weak — + strengthen the local script instead. + +6. **Cite, don't guess.** `WebSearch` results come with source links; never + turn an unverified snippet into a change that the Guard cannot catch. diff --git a/.agents/skills/go-documentation/SKILL.md b/.agents/skills/go-documentation/SKILL.md new file mode 100644 index 00000000..648ddd09 --- /dev/null +++ b/.agents/skills/go-documentation/SKILL.md @@ -0,0 +1,167 @@ +--- +name: go-documentation +description: 在编写或审查 Go 包、类型、函数或方法的文档时使用。在创建新的导出类型、函数或包时也应主动使用,即使用户没有明确询问文档问题。不涵盖未导出符号的代码注释(参见 go-style-core)。 +license: Apache-2.0 +metadata: + sources: "Google 风格指南" +allowed-tools: Bash(bash:*) +--- + +# Go 文档 + +## 可用脚本 + +- **`scripts/check-docs.sh`** — 报告缺少文档注释的导出函数、类型、方法、常量和包。运行 `bash scripts/check-docs.sh --help` 查看选项。 + +> 在为新包或导出类型编写文档注释并需要所有文档约定的完整参考时,请参阅 `assets/doc-template.go`。 + +--- + +## 文档注释 + +> **规范**:所有顶层导出名称必须有文档注释。 + +### 基本规则 + +1. 以被描述对象的名称开头 +2. 冠词("a"、"an"、"the")可以放在名称前面 +3. 使用完整句子(首字母大写,带标点符号) + +```go +// A Request represents a request to run a command. +type Request struct { ... + +// Encode writes the JSON encoding of req to w. +func Encode(w io.Writer, req *Request) { ... +``` + +行为不明显的未导出类型/函数也应有文档注释。 + +> **验证**:添加文档注释后,运行 `bash scripts/check-docs.sh` 验证是否有导出符号缺少文档。修复所有缺失后再继续。 + +--- + +## 注释语句 + +> **规范**:文档注释必须是完整的句子。 + +- 首字母大写,以标点符号结尾 +- 例外:如果含义清晰,可以以小写标识符开头 +- 结构体字段的行尾注释可以是短语 + +--- + +## 注释行长度 + +> **建议**:目标约 80 列,但不设硬性限制。 + +根据标点符号换行。不要拆分长 URL。 + +--- + +## 结构体文档 + +使用段落注释对字段分组。标记可选字段及默认值: + +```go +type Options struct { + // 通用设置: + Name string + Group *FooGroup + + // 自定义设置: + LargeGroupThreshold int // 可选;默认值:10 +} +``` + +--- + +## 包注释 + +> **规范**:每个包必须有且仅有一个包注释。 + +```go +// Package math provides basic constants and mathematical functions. +package math +``` + +- 对于 `main` 包,使用二进制名称:`// The seed_generator command ...` +- 对于较长的包注释,使用 `doc.go` 文件 + +> 在编写包级文档、main 包注释、doc.go 文件或可运行示例时,请阅读 [references/EXAMPLES.md](references/EXAMPLES.md)。 + +--- + +## 文档编写要点 + +> **建议**:记录非显而易见的行为,显而易见的行为无需记录。 + +| 主题 | 何时记录... | 何时跳过... | +|------|------------|------------| +| 参数 | 非显而易见的行为、边界情况 | 只是重复类型签名 | +| 上下文 | 行为与标准取消不同 | 标准 `ctx.Err()` 返回 | +| 并发 | 线程安全性不明确(例如,看似读取但内部修改) | 只读安全、修改不安全 | +| 清理 | 始终记录资源释放要求 | — | +| 错误 | 哨兵值、错误类型(使用 `*PathError`) | — | +| 命名返回值 | 多个同类型参数、面向操作命名 | 类型本身已足够清晰 | + +关键原则: + +- 上下文取消返回 `ctx.Err()` 是隐含的 — 不要重复说明 +- 只读操作默认线程安全;修改操作默认不安全 — 不要重复说明 +- 始终记录清理要求(例如,`Call Stop to release resources`) +- 在错误类型文档中使用指针(`*PathError`),以确保 `errors.Is`/`errors.As` 正确使用 +- 不要仅为启用裸返回而命名返回值 — 清晰性 > 简洁性 + +> 在记录参数行为、上下文取消、并发安全性、清理要求、错误返回或函数文档注释中的命名返回参数时,请阅读 [references/CONVENTIONS.md](references/CONVENTIONS.md)。 + +--- + +## 可运行示例 + +> **建议**:在测试文件(`*_test.go`)中提供可运行示例。 + +```go +func ExampleConfig_WriteTo() { + cfg := &Config{Name: "example"} + cfg.WriteTo(os.Stdout) + // Output: + // {"name": "example"} +} +``` + +示例会出现在 Godoc 中,附加到对应的文档元素上。 + +> 在编写可运行 Example 函数、选择示例命名约定(Example vs ExampleType_Method)或添加包级 doc.go 文件时,请阅读 [references/EXAMPLES.md](references/EXAMPLES.md)。 + +--- + +## Godoc 格式化 + +> 在格式化 godoc 标题、链接、列表或代码块,使用信号增强来标记弃用通知,或在本地预览文档输出时,请阅读 [references/FORMATTING.md](references/FORMATTING.md)。 + +--- + +## 快速参考 + +| 主题 | 关键规则 | +|------|---------| +| 文档注释 | 以名称开头,使用完整句子 | +| 行长度 | 约 80 字符,优先考虑可读性 | +| 包注释 | 每个包一个,放在 `package` 声明之前 | +| 参数 | 仅记录非显而易见的行为 | +| 上下文 | 记录与隐含行为不同的例外情况 | +| 并发 | 记录线程安全性不明确的情况 | +| 清理 | 始终记录资源释放要求 | +| 错误 | 记录哨兵值和类型(注意指针) | +| 示例 | 在测试文件中使用可运行示例 | +| 格式化 | 空行分隔段落,缩进表示代码 | + +--- + +## 相关技能 + +- **命名约定**:在为文档注释描述的标识符选择名称时,参见 [go-naming](../go-naming/SKILL.md) +- **测试示例**:在编写出现在 godoc 中的可运行 `Example` 测试函数时,参见 [go-testing](../go-testing/SKILL.md) +- **Lint 强制执行**:在使用 revive 或其他 linter 强制执行文档注释存在性时,参见 [go-linting](../go-linting/SKILL.md) +- **风格原则**:在平衡文档详细程度与清晰简洁时,参见 [go-style-core](../go-style-core/SKILL.md) diff --git a/.agents/skills/go-documentation/assets/doc-template.go b/.agents/skills/go-documentation/assets/doc-template.go new file mode 100644 index 00000000..a2c54b85 --- /dev/null +++ b/.agents/skills/go-documentation/assets/doc-template.go @@ -0,0 +1,61 @@ +// Package example demonstrates proper Go documentation conventions. +// +// This package shows how to write doc comments for packages, types, +// functions, methods, and constants following Google Go Style Guide +// conventions. +// +// # Getting Started +// +// Create a new Widget with [NewWidget]: +// +// w := example.NewWidget("name") +// defer w.Close() +package example + +import "errors" + +// ErrNotFound is returned when a requested item does not exist. +var ErrNotFound = errors.New("example: not found") + +// MaxRetries is the default number of retry attempts. +const MaxRetries = 3 + +// Widget processes items with configurable options. +// +// A zero-value Widget is not valid; use [NewWidget] to create one. +// Widget is safe for concurrent use. +// +// # Cleanup +// +// Call [Widget.Close] when done to release resources. +type Widget struct { + name string +} + +// NewWidget creates a Widget with the given name. +// +// Name must be non-empty; NewWidget panics otherwise. +func NewWidget(name string) *Widget { + if name == "" { + panic("example: name must be non-empty") + } + return &Widget{name: name} +} + +// Process handles the given input and returns the result. +// +// Process returns [ErrNotFound] if the input references +// a missing item. +func (w *Widget) Process(input string) (string, error) { + return input, nil +} + +// Close releases resources held by the Widget. +func (w *Widget) Close() error { + return nil +} + +// Deprecated: Use [NewWidget] with functional options instead. +func NewWidgetLegacy(name string) *Widget { + return NewWidget(name) +} diff --git a/.agents/skills/go-documentation/references/CONVENTIONS.md b/.agents/skills/go-documentation/references/CONVENTIONS.md new file mode 100644 index 00000000..7990dcce --- /dev/null +++ b/.agents/skills/go-documentation/references/CONVENTIONS.md @@ -0,0 +1,239 @@ +# 文档约定参考 + +## 参数和配置 + +> **建议**:记录容易出错或非显而易见的参数,而非所有参数。 + +```go +// 不好:重复了显而易见的信息 +// Sprintf formats according to a format specifier and returns the resulting string. +// +// format is the format, and data is the interpolation data. +func Sprintf(format string, data ...any) string + +// 好:记录了非显而易见的行为 +// Sprintf formats according to a format specifier and returns the resulting string. +// +// The provided data is used to interpolate the format string. If the data does +// not match the expected format verbs or the amount of data does not satisfy +// the format specification, the function will inline warnings about formatting +// errors into the output string. +func Sprintf(format string, data ...any) string +``` + +--- + +## 上下文 + +> **建议**:不要重复隐含的上下文行为;记录例外情况。 + +上下文取消被隐含地认为会中断函数并返回 `ctx.Err()`。不要记录这一点。 + +```go +// 不好:重复了隐含的行为 +// Run executes the worker's run loop. +// +// The method will process work until the context is cancelled. +func (Worker) Run(ctx context.Context) error + +// 好:只记录关键信息 +// Run executes the worker's run loop. +func (Worker) Run(ctx context.Context) error +``` + +**当行为不同时记录:** + +```go +// 好:非标准的取消行为 +// Run executes the worker's run loop. +// +// If the context is cancelled, Run returns a nil error. +func (Worker) Run(ctx context.Context) error + +// 好:特殊的上下文要求 +// NewReceiver starts receiving messages sent to the specified queue. +// The context should not have a deadline. +func NewReceiver(ctx context.Context) *Receiver +``` + +--- + +## 并发 + +> **建议**:记录非显而易见的线程安全特性。 + +只读操作被认为是安全的;修改操作被认为是不安全的。不要重复说明这一点。 + +**何时记录:** + +```go +// 不明确的操作(看似只读但内部有修改) +// Lookup returns the data associated with the key from the cache. +// +// This operation is not safe for concurrent use. +func (*Cache) Lookup(key string) (data []byte, ok bool) + +// API 提供同步机制 +// NewFortuneTellerClient returns an *rpc.Client for the FortuneTeller service. +// It is safe for simultaneous use by multiple goroutines. +func NewFortuneTellerClient(cc *rpc.ClientConn) *FortuneTellerClient + +// 接口有并发要求 +// A Watcher reports the health of some entity (usually a backend service). +// +// Watcher methods are safe for simultaneous use by multiple goroutines. +type Watcher interface { + Watch(changed chan<- bool) (unwatch func()) + Health() error +} +``` + +--- + +## 清理 + +> **建议**:始终记录显式清理要求。 + +```go +// 好: +// NewTicker returns a new Ticker containing a channel that will send the +// current time on the channel after each tick. +// +// Call Stop to release the Ticker's associated resources when done. +func NewTicker(d Duration) *Ticker + +// 好:展示如何清理 +// Get issues a GET to the specified URL. +// +// When err is nil, resp always contains a non-nil resp.Body. +// Caller should close resp.Body when done reading from it. +// +// resp, err := http.Get("http://example.com/") +// if err != nil { +// // handle error +// } +// defer resp.Body.Close() +// body, err := io.ReadAll(resp.Body) +func (c *Client) Get(url string) (resp *Response, err error) +``` + +--- + +## 错误 + +> **建议**:记录重要的错误哨兵值和类型。 + +```go +// 好:记录哨兵值 +// Read reads up to len(b) bytes from the File and stores them in b. +// +// At end of file, Read returns 0, io.EOF. +func (*File) Read(b []byte) (n int, err error) + +// 好:记录错误类型(包含指针接收者) +// Chdir changes the current working directory to the named directory. +// +// If there is an error, it will be of type *PathError. +func Chdir(dir string) error +``` + +注意使用 `*PathError`(而非 `PathError`)可以确保 `errors.Is` 和 `errors.As` 的正确使用。 + +对于包级别的错误约定,在包注释中记录。 + +--- + +## 命名返回参数 + +> **建议**:在类型本身不够清晰时用于文档说明。 + +```go +// 好:多个同类型参数 +func (n *Node) Children() (left, right *Node, err error) + +// 好:面向操作的名称阐明了用法 +// The caller must arrange for the returned cancel function to be called. +func WithTimeout(parent Context, d time.Duration) (ctx Context, cancel func()) + +// 不好:类型已经很清晰,命名没有增加信息 +func (n *Node) Parent1() (node *Node) +func (n *Node) Parent2() (node *Node, err error) + +// 好:类型已足够 +func (n *Node) Parent1() *Node +func (n *Node) Parent2() (*Node, error) +``` + +不要仅为启用裸返回而命名返回值。清晰性 > 简洁性。 + +--- + +## 弃用通知 + +> **建议**:使用 `// Deprecated:` 注释标记符号为已弃用。 + +`Deprecated:` 段落必须出现在文档注释中紧接在符号之前。应说明使用什么替代。 + +**标准格式:** + +``` +// Deprecated: Use NewThing instead. +``` + +Godoc 会以特殊的视觉样式渲染 `Deprecated:` 注释,使其容易被发现。 + +**函数弃用:** + +```go +// EstimateSize returns an approximate byte count. +// +// Deprecated: Use [Size] instead, which returns an exact count. +func EstimateSize(r io.Reader) (int64, error) +``` + +**类型弃用:** + +```go +// LegacyClient talks to the v1 API. +// +// Deprecated: Use [Client] instead, which supports v2. +type LegacyClient struct{ /* ... */ } +``` + +**包弃用** — 在包文档注释中添加 `Deprecated:`: + +```go +// Package old provides the original implementation. +// +// Deprecated: Use package example/new instead. +package old +``` + +始终建议具体的替代方案,让调用者知道迁移目标。 + +--- + +## 注释语句 — 详细说明 + +> **规范**:文档注释必须是完整的句子。 + +- 首字母大写,以标点符号结尾 +- 例外:如果含义清晰,可以以小写标识符开头 +- 结构体字段的行尾注释可以是短语: + +```go +// 好: +// A Server handles serving quotes from Shakespeare. +type Server struct { + // BaseDir points to the base directory for Shakespeare's works. + // + // Expected structure: + // {BaseDir}/manifest.json + // {BaseDir}/{name}/{name}-part{number}.txt + BaseDir string + + WelcomeMessage string // 用户登录时显示 + ProtocolVersion string // 与传入请求进行校验 + PageLength int // 每页行数(可选;默认值:20) +} +``` diff --git a/.agents/skills/go-documentation/references/EXAMPLES.md b/.agents/skills/go-documentation/references/EXAMPLES.md new file mode 100644 index 00000000..44f33e39 --- /dev/null +++ b/.agents/skills/go-documentation/references/EXAMPLES.md @@ -0,0 +1,107 @@ +# 包注释和示例参考 + +## 包注释 + +> **规范**:每个包必须有且仅有一个包注释。 + +```go +// 好: +// Package math provides basic constants and mathematical functions. +// +// This package does not guarantee bit-identical results across architectures. +package math +``` + +### Main 包 + +使用二进制名称(与 BUILD 文件匹配): + +```go +// 好: +// The seed_generator command is a utility that generates a Finch seed file +// from a set of JSON study configs. +package main +``` + +有效格式:`Binary seed_generator`、`Command seed_generator`、`The seed_generator command`、`Seed_generator ...` + +### doc.go + +- 对于较长的包注释,使用仅包含包注释和 `package` 声明的 `doc.go` 文件 +- 放在 import 之后的维护者注释不会出现在 Godoc 中 +- 保持 doc.go 文件专注于面向用户的文档 + +```go +// Package complex provides advanced mathematical operations for +// complex number arithmetic, including polar form conversion, +// matrix operations, and numerical integration. +// +// Basic usage +// +// Create a complex number and perform operations: +// +// z := complex.New(3, 4) +// magnitude := z.Abs() // 5.0 +// conjugate := z.Conj() // (3, -4) +// +// Matrix operations +// +// The package supports complex-valued matrices: +// +// m := complex.NewMatrix(2, 2) +// m.Set(0, 0, complex.New(1, 0)) +// det := m.Det() +package complex +``` + +--- + +## 可运行示例 + +> **建议**:提供可运行示例来展示包的用法。 + +将示例放在测试文件(`*_test.go`)中: + +```go +// 好: +func ExampleConfig_WriteTo() { + cfg := &Config{ + Name: "example", + } + if err := cfg.WriteTo(os.Stdout); err != nil { + log.Exitf("Failed to write config: %s", err) + } + // Output: + // { + // "name": "example" + // } +} +``` + +示例会出现在 Godoc 中,附加到对应的文档元素上。 + +### 命名约定 + +| 函数名称 | 文档对象 | +|----------|---------| +| `Example()` | 包级别示例 | +| `ExampleFoo()` | 函数 `Foo` | +| `ExampleBar_Baz()` | 方法 `Bar.Baz` | +| `ExampleFoo_suffix()` | `Foo` 示例的命名变体 | + +### 技巧 + +- 使用 `// Output:` 注释使示例可通过 `go test` 进行测试和验证 +- 保持示例专注于展示一个概念 +- 使用真实但精简的数据 +- 对于复杂的设置,使用 `testMain` 或辅助函数保持示例主体简洁 +- 同一符号的多个示例使用小写 `_suffix`: + +```go +func ExampleNewClient_withTimeout() { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + client := NewClient(ctx) + // ... +} +``` diff --git a/.agents/skills/go-documentation/references/FORMATTING.md b/.agents/skills/go-documentation/references/FORMATTING.md new file mode 100644 index 00000000..1a5e22f0 --- /dev/null +++ b/.agents/skills/go-documentation/references/FORMATTING.md @@ -0,0 +1,85 @@ +# Godoc 格式化参考 + +## Godoc 格式化 + +> **建议**:使用 godoc 语法编写格式良好的文档。 + +**段落** - 用空行分隔: + +```go +// 好: +// LoadConfig reads a configuration out of the named file. +// +// See some/shortlink for config file format details. +``` + +**逐字/代码块** - 额外缩进两个空格: + +```go +// 好: +// Update runs the function in an atomic transaction. +// +// This is typically used with an anonymous TransactionFunc: +// +// if err := db.Update(func(state *State) { state.Foo = bar }); err != nil { +// //... +// } +``` + +**列表和表格** - 使用逐字格式: + +```go +// 好: +// LoadConfig treats the following keys in special ways: +// "import" will make this configuration inherit from the named file. +// "env" if present will be populated with the system environment. +``` + +**标题** - 单行,首字母大写,无标点(括号/逗号除外),后跟段落: + +```go +// 好: +// Using headings +// +// Headings come with autogenerated anchor tags for easy linking. +``` + +--- + +## 信号增强 + +> **建议**:添加注释以突出不寻常或容易被忽略的模式。 + +以下两种情况很难区分: + +```go +if err := doSomething(); err != nil { // 常见 + // ... +} + +if err := doSomething(); err == nil { // 不寻常! + // ... +} +``` + +添加注释来增强信号: + +```go +// 好: +if err := doSomething(); err == nil { // 如果没有错误 + // ... +} +``` + +--- + +## 文档预览 + +> **建议**:在代码审查之前和期间预览文档。 + +```bash +go install golang.org/x/pkgsite/cmd/pkgsite@latest +pkgsite +``` + +这可以验证 godoc 格式化是否正确渲染。 diff --git a/.agents/skills/go-documentation/scripts/check-docs.sh b/.agents/skills/go-documentation/scripts/check-docs.sh new file mode 100755 index 00000000..6351d2b2 --- /dev/null +++ b/.agents/skills/go-documentation/scripts/check-docs.sh @@ -0,0 +1,298 @@ +#!/usr/bin/env bash +set -euo pipefail + +VERSION="1.0.0" +SCRIPT_NAME="$(basename "$0")" + +usage() { + cat <&2; usage >&2; exit 2 ;; + *) TARGET="$1"; shift ;; + esac +done + +TARGET="${TARGET:-./...}" + +json_escape() { + local s="$1" + s="${s//\\/\\\\}" + s="${s//\"/\\\"}" + s="${s//$'\t'/\\t}" + s="${s//$'\r'/}" + s="${s//$'\n'/\\n}" + printf '%s' "$s" +} + +find_go_files() { + local t="$1" + if [[ -f "$t" ]]; then + echo "$t" + elif [[ -d "$t" ]]; then + find "$t" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null + else + local dir="${t%%/...}" + dir="${dir:-.}" + if [[ -d "$dir" ]]; then + find "$dir" -name '*.go' ! -name '*_test.go' ! -path '*/vendor/*' ! -path '*/.git/*' 2>/dev/null + else + echo "error: path not found: $t" >&2 + exit 2 + fi + fi +} + +MISSING=() + +add_missing() { + local file="$1" line="$2" kind="$3" name="$4" + MISSING+=("${file}:${line}|${kind}|${name}") +} + +check_file() { + local file="$1" + local prev_line="" + local prev_prev_line="" + local line_num=0 + + local in_grouped_block=false + local grouped_kind="" + + local re_method='^func[[:space:]]+\([^)]+\)[[:space:]]+([A-Z][a-zA-Z0-9]*)\(' + local re_func='^func[[:space:]]+([A-Z][a-zA-Z0-9]*)\(' + local re_unexported_func='^func[[:space:]]+([a-z][a-zA-Z0-9]*)\(' + local re_grouped_open='^(const|var|type)[[:space:]]*\($' + local re_exported_type='^type[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]' + local re_unexported_type='^type[[:space:]]+([a-z][a-zA-Z0-9]*)[[:space:]]' + local re_exported_const='^const[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]' + local re_exported_var='^var[[:space:]]+([A-Z][a-zA-Z0-9]*)[[:space:]]' + local re_grouped_exported='^[[:space:]]+([A-Z][a-zA-Z0-9]*)' + local re_grouped_unexported='^[[:space:]]+([a-z][a-zA-Z0-9]*)' + + while IFS= read -r line; do + line_num=$((line_num + 1)) + + # Check exported function/method declarations + if [[ "$line" =~ ^func[[:space:]] ]]; then + local name="" + local kind="" + # Method: func (r *Type) Name( + if [[ "$line" =~ $re_method ]]; then + name="${BASH_REMATCH[1]}" + kind="method" + # Function: func Name( + elif [[ "$line" =~ $re_func ]]; then + name="${BASH_REMATCH[1]}" + kind="function" + fi + + if [[ -n "$name" ]]; then + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "$kind" "$name" + fi + fi + + # Strict mode: also check unexported functions + if $STRICT && [[ -z "$name" ]] && [[ "$line" =~ $re_unexported_func ]]; then + name="${BASH_REMATCH[1]}" + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "function" "$name" + fi + fi + fi + + # Check exported type declarations + if [[ "$line" =~ $re_exported_type ]]; then + local name="${BASH_REMATCH[1]}" + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "type" "$name" + fi + fi + + # Strict mode: also check unexported type declarations + if $STRICT && [[ "$line" =~ $re_unexported_type ]]; then + local name="${BASH_REMATCH[1]}" + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "type" "$name" + fi + fi + + # Check exported const (single-line, not in block) + if [[ "$line" =~ $re_exported_const ]]; then + local name="${BASH_REMATCH[1]}" + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "const" "$name" + fi + fi + + # Check exported var (single-line, not blank identifier) + if [[ "$line" =~ $re_exported_var ]]; then + local name="${BASH_REMATCH[1]}" + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "var" "$name" + fi + fi + + # Check package comment + if [[ "$line" =~ ^package[[:space:]]+ ]]; then + if ! is_documented "$prev_line" "$prev_prev_line"; then + local pkg_name + pkg_name=$(echo "$line" | sed 's/^package[[:space:]]*//;s/[[:space:]]*$//') + add_missing "$file" "$line_num" "package" "$pkg_name" + fi + fi + + # Track grouped declaration blocks: const ( ... ), var ( ... ), type ( ... ) + if [[ "$line" =~ $re_grouped_open ]]; then + in_grouped_block=true + grouped_kind="${BASH_REMATCH[1]}" + fi + if $in_grouped_block && [[ "$line" =~ ^\)[[:space:]]*$ ]]; then + in_grouped_block=false + grouped_kind="" + fi + if $in_grouped_block && [[ -n "$grouped_kind" ]]; then + # Check for exported names inside grouped block + if [[ "$line" =~ $re_grouped_exported ]]; then + local gname="${BASH_REMATCH[1]}" + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "$grouped_kind" "$gname" + fi + fi + # Strict: also check unexported names in grouped blocks + if $STRICT && [[ "$line" =~ $re_grouped_unexported ]]; then + local gname="${BASH_REMATCH[1]}" + if ! is_documented "$prev_line" "$prev_prev_line"; then + add_missing "$file" "$line_num" "$grouped_kind" "$gname" + fi + fi + fi + + prev_prev_line="$prev_line" + prev_line="$line" + done < "$file" +} + +is_documented() { + local prev="$1" + local prev_prev="$2" + # Previous line is a comment (// or end of block comment */) + if [[ "$prev" =~ ^[[:space:]]*//.* ]] || [[ "$prev" =~ \*/[[:space:]]*$ ]]; then + return 0 + fi + # Previous line might be empty but line before is comment (allow one blank line) + if [[ -z "${prev// /}" ]] && [[ "$prev_prev" =~ ^[[:space:]]*//.* ]]; then + return 0 + fi + return 1 +} + +FILES=() +while IFS= read -r f; do + [[ -n "$f" ]] && FILES+=("$f") +done < <(find_go_files "$TARGET") + +if [[ ${#FILES[@]} -eq 0 ]]; then + if $JSON_OUTPUT; then + echo '{"missing":[],"count":0,"status":"no_go_files"}' + else + echo "No Go files found in: $TARGET" + fi + exit 0 +fi + +for file in "${FILES[@]}"; do + check_file "$file" +done + +# Truncation +TOTAL=${#MISSING[@]} +TRUNCATED=false +if [[ $LIMIT -gt 0 && $TOTAL -gt $LIMIT ]]; then + MISSING=("${MISSING[@]:0:$LIMIT}") + TRUNCATED=true +fi + +if $JSON_OUTPUT; then + echo "{" + echo ' "missing": [' + first=true + for entry in "${MISSING[@]+"${MISSING[@]}"}"; do + IFS='|' read -r location kind name <<< "$entry" + file="${location%%:*}" + line="${location#*:}" + $first || echo "," + first=false + printf ' {"file":"%s","line":%s,"kind":"%s","name":"%s"}' \ + "$(json_escape "$file")" "$line" "$(json_escape "$kind")" "$(json_escape "$name")" + done + echo "" + echo " ]," + printf ' "total": %d,\n' "$TOTAL" + printf ' "truncated": %s\n' "$TRUNCATED" + echo "}" +else + if [[ $TOTAL -eq 0 ]]; then + echo "All exported symbols are documented." + exit 0 + fi + + echo "Undocumented exported symbols:" + echo "" + for entry in "${MISSING[@]}"; do + IFS='|' read -r location kind name <<< "$entry" + printf " %s [%s] %s\n" "$location" "$kind" "$name" + done + if $TRUNCATED; then + echo " ... and $((TOTAL - LIMIT)) more (use --limit to adjust)" + fi + echo "" + echo "Total: $TOTAL undocumented symbol(s)" +fi + +if [[ $TOTAL -gt 0 ]]; then + exit 1 +fi +exit 0 diff --git a/.auto/autoresearch-lessons.md b/.auto/autoresearch-lessons.md new file mode 100644 index 00000000..72a0aad2 --- /dev/null +++ b/.auto/autoresearch-lessons.md @@ -0,0 +1,242 @@ +# Autoresearch lessons — Wavelet / Cordis quality run + +Accumulated wisdom across iterations. Read this before forming a hypothesis. +Weight recent lessons higher: the yardstick and codebase change under us. + +## Lesson 1 — iterations 0-1 +**Pattern**: The project's committed gate (`golangci-lint run` with `.golangci.yml`) +had already been driven to 0 issues by a previous run, so it could no longer +measure anything. +**Why it worked**: Measuring against a pinned snapshot + extra analyzers in +`.auto/lint.ref.yaml` (hash-locked by the Guard) restored headroom and made it +impossible to lower the number by editing the config. +**Conditions**: Any repo whose own lint gate is already green. +**Anti-pattern**: Optimising `tagliatelle` (325 findings) or `wrapcheck` (290). +Those are pure cosmetics — error-message wording and tag naming. A run that +chases them will look productive while shuffling strings. +**Metric delta**: baseline re-established at 102 instead of a dead 0. + +## Lesson 2 — iterations 1-4 +**Pattern**: Triage every analyzer finding for reality before "fixing" it. +**Why it worked**: Three buckets turned out to be false positives: +`forcetypeassert` in `core/events.go` is guarded by `returnsErr` (the handler's +declared last out really is `error`), and both `exhaustive` switches already +have `default:` arms — `exhaustive` only flags them because +`default-signifies-exhaustive` defaults to false. +**Conditions**: Always, but especially for linters whose defaults assume a +different project convention. +**Anti-pattern**: Adding `if !ok { ... }` branches or empty `case:` arms that +cannot execute. That raises the score and lowers the code. +**Metric delta**: 3 of 16 candidate linters dropped from the plan (0 gained, +real regressions avoided). + +## Lesson 3 — iterations 1, 4 +**Pattern**: Pair the metric drop with a mechanically provable defect: write the +regression test, commit, then revert *only* the source files and require the +test to fail (`.auto/prove_fix.sh`). +**Why it worked**: It caught a live bug that no counter measures — a +singleflight body capturing the first caller's request context, so one +disconnecting browser poisoned every concurrent request for that image. +Iteration 4 kept debt flat at 93 yet was the most valuable change so far. +**Conditions**: Every behavioural fix. A change that survives its own revert is +not a fix, it is a rename. +**Anti-pattern**: Calling something "hardening" without a test that fails +without it. +**Metric delta**: 0 for the proven bug (kept under the fix gate), 8 for the rest. + +## Lesson 4 — iteration 5 +**Pattern**: Strengthen the architecture gate; it is a generator of real, +previously invisible debt. +**Why it worked**: `check_cordis_architecture.sh` only grepped `go func(`, so +`go w.run()` — the shape used by four long-lived cleanup loops — passed +silently, each one able to take down the process on a panic. Widening the +pattern surfaced them immediately. +**Conditions**: Whenever a gate has been green for a long time. A green gate +proves the checks exist, not that they cover anything. +**Anti-pattern**: Weakening `.golangci.yml` (blocked outright by the Guard via +`check_gate_weaken.py` + a SHA lock on the yardstick). +**Metric delta**: 4 uncovered crash-on-panic sites hardened. + +## Lesson 5 — iteration 3 +**Pattern**: Deduplicate by extracting the shared *classification*, not the +shared *response*. +**Why it worked**: Two handlers mapped upload-lookup errors with copy-pasted +blocks that had quietly drifted (different fallback status, different synonym +constant for the same message). `filesrv.AbortUploadRecordError` handles the +200/400 branches, and each endpoint keeps its own fallback it can still +justify. Deleting the orphaned `ErrInvalidUploadID` constant was part of the +change, not extra cleanup. +**Conditions**: Duplicated error-mapping or validation blocks in sibling handlers. +**Anti-pattern**: Silently unifying HTTP status codes across endpoints to make a +helper fit — that is a behaviour change wearing a refactor's clothes. +**Metric delta**: -2. + +## Lesson 6 — iterations 15-21 +**Pattern**: Delegate a broad read-only audit for what mechanical gates cannot +see (N+1s, locks held over I/O, resource leaks, layering), then re-verify each +claim yourself before touching code. +**Why it worked**: The audit produced the run's best findings — the per-request +CORS database query, the orphan cron dispatching to a task nobody registered, +media temp dirs nothing ever removed. It also produced a wrong one: it asserted +telebot falls back to `http.DefaultClient` with no timeout, when telebot itself +constructs a client with a one minute deadline. Acting on that would have added +a tunable dressed up as a bug fix. +**Conditions**: Whenever the committed gates are green and the easy signal is +exhausted. +**Anti-pattern**: Trusting an audit summary's file:line as evidence. One +referenced file did not exist. +**Metric delta**: 0 for three landed fixes (all kept under the proven-fix gate), +but they were the run's highest-impact changes. + +## Lesson 7 — iteration 16 +**Pattern**: Prove query-reduction with a functional test double that counts +loader invocations, and assert the counter for both the batch and the looped +form in the same test. +**Why it worked**: Asserting "1 query" alone is vacuous — it also passes when +nothing ran. Asserting batch=1 and per-id=3 in one test makes the instrument +itself checked, so the claim cannot silently degrade. +**Conditions**: Any change whose whole value is doing less I/O. +**Anti-pattern**: Fixing an N+1 by reaching around the contract into another +plugin's repository. The layering was the reason the slow path existed; the +right move was to extend the contract with a batch method. +**Metric delta**: 0 (kept under the proven-fix gate). + +## Lesson 8 — iterations 17-22 +**Pattern**: Strengthen a gate only alongside the code that satisfies it, and +never rewrite history in a shared worktree. +**Why it worked**: Deleting 24 dead lint suppressions paid off exactly as the +self-correcting design predicted: two of them were load-bearing under the +project's own gate even though the analyzer called them unused, the Guard +vetoed, and their removal surfaced two verified `contextcheck` false positives +worth documenting instead of silently swallowing. Meanwhile a concurrent +session was committing plan documents in the same tree, so `git add -A` swept +one of its in-flight edits into my commit — unfixable by rebase without +destroying their work, so the repair was to stage explicit paths from then on. +**Conditions**: Always, in this repo. Assume another agent is editing `docs/` +and `backend/core` concurrently. +**Anti-pattern**: `git add -A` outside the first setup commit. Also: trusting +"unused directive" as "safe to delete" — check the strictest config, not just +the pinned yardstick. +**Metric delta**: -25 in one iteration. + +## Lesson 9 — iterations 23-24 +**Pattern**: Cross-check every service a plugin's `Apply` reads out of the +container against what that plugin's `Inject()` declares. `Inject()` is the only +thing `App.reconcileLocked` gates on, so anything consumed as a *value* at Apply +time but left undeclared is resolved from a container that may not hold it yet. +**Why it worked**: It found the run's worst defect, invisible to every +mechanical gate: `user` declared only `DBService` while capturing +`contracts.AuthService` to build its route guard, and `cmd/app.go` lists `user` +before `auth`. Because user's dep set is a strict subset of auth's and it sits +earlier in the slice, user *always* mounts first — deterministically, not a +race — so `loginMW` fell back to a `c.Next()` closure and +`/api/v1/user/{change-password,profile,access-tokens}` mounted unguarded. The +same lookups in `admin` read a package global that its own `OnDispose` nils, so +in-flight requests fail open during dispose. +**Conditions**: Any Cordis plugin whose Apply assigns a contract result to a +variable used later (middleware, handler closures). Services bound through +`core.When` late binding are exempt — that is the correct pattern for genuinely +late deps, so do not blanket-declare everything. +**Anti-pattern**: Assuming a checked `x, ok :=` assertion is safe. All three +plugins used the checked form and all three failed *open* — checked syntax, +unchecked semantics. +**Metric delta**: 0 across both iterations (kept under the proven-fix gate), +but this is the run's highest-severity finding. `RouterRegistry` records each +route's `Handlers`/`Middlewares`, which makes "is this route actually guarded?" +directly assertable from the route table — the cheapest available oracle for +security properties here. + +## Lesson 10 — iteration 23 review +**Pattern**: When the remaining metric is dominated by a positional or +taste-based analyzer, say so and refuse to spend iterations on it. +**Why it worked**: `funcorder` was 21 of 54 findings (39%) — pure function +*ordering within a file*. Reordering private helpers to the bottom of a file +moves the number and changes nothing a reader or the machine cares about, which +is Lesson 1's "looks productive while shuffling strings" with a different label. +Skipping it kept the loop honest. Triage also cleared 12 of 13 +`forcetypeassert` (guarded by construction) and 2 of 3 `unparam` (deliberate +constructor symmetry behind one factory switch). +**Conditions**: Whenever one linter dominates a shrinking total, break the count +down per linter *before* picking a hypothesis. +**Anti-pattern**: Treating a large single-linter share as an easy win. Real +headroom at this point is ~10 findings, so a plateau in `debt` no longer means a +stalled loop. +**Metric delta**: 0 spent, ~21 findings deliberately left in place. + +## Lesson 11 — iterations 24-25 +**Pattern**: Run the Guard after every single commit, and confirm which commit a +proof script is actually reverting against. +**Why it worked**: Four `staticcheck ST1023` findings from iteration 24 shipped +straight through `go build ./...` and a green 47-package `go test ./...` — +neither runs the project linter, so only `checks.sh` section 3 catches them. +Separately, `prove_fix.sh` reverts to `HEAD^`; appending the iteration-23 log +commit shifted `HEAD^` to the *fixed* state and reported "PROVE FAILED: tests +still pass without the fix" on a genuinely load-bearing fix. Re-checking against +the explicit pre-fix commit (`git checkout -- `) showed the real +answer. A false negative here is worse than no proof: it reads like the fix was +cosmetic. +**Conditions**: Always. Also note zsh does not word-split unquoted variables, so +`git checkout $FILES` passes one bogus pathspec and silently reverts nothing — +the command still exits 0. +**Anti-pattern**: Batch-verifying at ship time. And any shell loop built on the +bash word-splitting habit in this environment. +**Metric delta**: -0, 1 wrong verdict corrected. + +## Lesson 12 — iterations 27-32 +**Pattern**: Two things produced every substantive win: (1) find a place where +correctness rests on a *prose comment* instead of an enforced constraint, and (2) +find immutable startup work being redone inside a request path. +**Why it worked**: Lint cannot see either class, so `debt` barely moved while real +defects did. The comment "field comes from call sites, never from user input" sat +on a function that interpolated its column argument straight into `WHERE` — the +tautology payload executed and returned a row with `err=`, a filter bypass, +not a hypothetical. The comment "contracts are pure abstractions" sat on a DTO +carrying `TableName()`, which is exactly the handle four plugins used to read +`w_users` instead of calling `UserService`. On the second pattern, three packages +each re-normalised and re-split static whitelist patterns per request: hoisting +that to registration cut 14 allocs/op to 1. +**Conditions**: Any exported function taking a string that reaches SQL, a path +matcher, or a shell. Any loop over configuration inside a request handler. +**Anti-pattern**: Believing `nolintlint`'s "unused directive" means "safe to +delete" — hit twice now, and the project gate vetoed it both times. Also believing +a doc comment's self-assessment: verify the claim or leave it alone. +**Metric delta**: 64 -> 63 across five keeps. Four of the five kept changes had +delta 0. Under a pure-debt loop this run would have looked stalled while fixing a +security bypass and a hot-path allocation bug. + +## Standing notes +- **The golangci-lint cache is machine-wide** (`~/.cache/golangci-lint`), so a + sibling worktree analysing identical sources replays here carrying *that* + checkout's absolute paths — 12 of 63 findings pointed outside the repo, which + misattributes findings and can serve a stale Guard verdict. `measure.sh` and + `checks.sh` now key `GOLANGCI_LINT_CACHE` per checkout (iteration 31). It is + count-neutral (cold and warm both 63), but check path attribution before + trusting any finding's location. +- **Do not delegate a repo-wide audit to one subagent.** Both broad audits + (architecture, bugs/perf) hit the 150-turn cap after ~45M tokens combined and + returned nothing usable. Everything this run found came from targeted inline + greps followed by reading the specific function. If delegating, bound it to one + package cluster and a small finding budget. +- Run decisions for this run: real defects first with `debt` as a secondary gate, + commits directly on `main`, small file moves allowed but large package + restructuring goes to a written proposal first. +- Upstream moves fast in this repo: `origin/main` gained 11 commits mid-run + (Cordis config extension point — `ctx.Config().Bind`, `DeclareConfig()`, + `core.ConfigGatedPlugin`), which raised measured `debt` 54 -> 64 and + `nolint_dirs` 72 -> 73 on its own. Rebase early and re-run the Guard after; + a clean rebase does not mean a green one. +- `cmd.TestNewWaveletAppWithRedisEnabled` needs a live Redis on + `127.0.0.1:6379` and fails without one. Pre-existing on `origin/main`, so + `tests_passed` 46 vs 47 is environmental, not a regression. Confirm against a + scratch `git worktree` of `origin/main` before blaming a change for it. +- Repo facts: backend module rooted at `backend/`, gofumpt orders a single + import group as `Wavelet/...` before stdlib (uppercase sorts first); new Go + files need the Apache license header or `scripts/update_go_license.sh --check` + fails the Guard. +- Handler edits require `make swagger` (cheap: it regenerates identical docs + when only bodies change). +- Dead suppressions are tracked by the `nolint_dirs` counter; removing one that + is still needed re-raises the original finding, so the metric self-corrects. + 24 were removed in iteration 22; 72 remain, each still doing work (73 after + the upstream rebase). + diff --git a/.auto/baseline.env b/.auto/baseline.env new file mode 100644 index 00000000..71419500 --- /dev/null +++ b/.auto/baseline.env @@ -0,0 +1,12 @@ +# Autoresearch baseline — captured at iteration #0 (2026-08-29). +# The Guard compares live values against these floors; the PRIMARY metric is debt. +BASE_DEBT=102 +BASE_NOLINT=96 +BASE_TESTS_PASSED=46 +BASE_TEST_FUNCS=232 +BASE_TEST_FILES=76 +BASE_ARCH_VIOL=0 +BASE_COVERAGE=34.09 + +# SHA-256 of the pinned yardstick config. Guard aborts if it changes. +REF_SHA=e881bda167bd688489f1356b7cd4056b8a6960f48b6778b095bdd2e44f627b82 diff --git a/.auto/check_gate_weaken.py b/.auto/check_gate_weaken.py new file mode 100644 index 00000000..42467641 --- /dev/null +++ b/.auto/check_gate_weaken.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Anti-cheat: prove .golangci.yml was only ever strengthened, never weakened. + +Compares the live gate against the immutable snapshot taken at run start. +Exits non-zero with a reason if any hardening rule is violated. +""" + +import os +import sys + +import yaml + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +BASELINE = os.path.join(ROOT, ".auto", "gate.baseline.yml") +LIVE = os.path.join(ROOT, ".golangci.yml") + +# threshold-like knobs: (path, direction) where direction "max" means the value +# is an upper bound (smaller == stricter), "min" means a lower bound. +STRICTNESS = [ + (("linters", "settings", "dupl", "threshold"), "max"), + (("linters", "settings", "cyclop", "max-complexity"), "max"), + (("linters", "settings", "cyclop", "package-average"), "max"), + (("linters", "settings", "nestif", "min-complexity"), "min"), + (("linters", "settings", "funlen", "lines"), "max"), + (("linters", "settings", "funlen", "statements"), "max"), + (("linters", "settings", "gocyclo", "min-complexity"), "min"), + (("linters", "settings", "lll", "line-length"), "max"), +] + + +def load(path): + with open(path, encoding="utf-8") as fh: + return yaml.safe_load(fh) or {} + + +def dig(doc, path): + node = doc + for key in path: + if not isinstance(node, dict) or key not in node: + return None + node = node[key] + return node + + +def enabled_linters(doc): + lint = doc.get("linters") or {} + if lint.get("enable-presets"): + return None # preset based; fall back to "any removal is suspicious" + return set(lint.get("enable") or []) + + +def main(): + try: + base, live = load(BASELINE), load(LIVE) + except OSError as exc: + print(f"gate snapshot unreadable: {exc}") + return 1 + except yaml.YAMLError as exc: + print(f".golangci.yml is not parseable: {exc}") + return 1 + + problems = [] + + base_lint, live_lint = base.get("linters") or {}, live.get("linters") or {} + if (base_lint.get("default") or "none") != (live_lint.get("default") or "none"): + problems.append("linters.default changed") + + base_set, live_set = enabled_linters(base), enabled_linters(live) + if base_set is None or live_set is None: + if set((base.get("linters") or {}).get("enable-presets") or []) - set( + (live.get("linters") or {}).get("enable-presets") or [] + ): + problems.append("an enable-preset was removed") + elif dropped := base_set - live_set: + problems.append(f"linters disabled: {sorted(dropped)}") + + for path, direction in STRICTNESS: + old, new = dig(base, path), dig(live, path) + if old is None or new is None: + continue + try: + old_f, new_f = float(old), float(new) + except (TypeError, ValueError): + continue + if direction == "max" and new_f > old_f: + problems.append(f"{'.'.join(path)} loosened {old} -> {new}") + if direction == "min" and new_f < old_f: + problems.append(f"{'.'.join(path)} loosened {old} -> {new}") + + base_mnd = set(dig(base, ("linters", "settings", "mnd", "checks")) or []) + live_mnd = set(dig(live, ("linters", "settings", "mnd", "checks")) or []) + if base_mnd - live_mnd: + problems.append(f"mnd checks dropped: {sorted(base_mnd - live_mnd)}") + + issues_live = live.get("issues") or {} + for key in ("exclude-rules", "exclude-patterns"): + if issues_live.get(key) and not (base.get("issues") or {}).get(key): + problems.append(f"issues.{key} added (suppresses reporting)") + + for key in ("max-issues-per-linter", "max-same-issues"): + old = (base.get("issues") or {}).get(key) + new = issues_live.get(key) + if old == 0 and new != 0: + problems.append(f"issues.{key} no longer 0 — findings would be truncated") + + # Exclusions expressed through the newer 'linters.exclusions' block. + if (live_lint.get("exclusions") or {}) and not (base_lint.get("exclusions") or {}): + problems.append("linters.exclusions added") + + if problems: + print("\n".join(f" - {p}" for p in problems)) + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/.auto/gate.baseline.yml b/.auto/gate.baseline.yml new file mode 100644 index 00000000..adb951c1 --- /dev/null +++ b/.auto/gate.baseline.yml @@ -0,0 +1,61 @@ +version: "2" + +run: + timeout: 5m + tests: false + +linters: + default: none + enable: + # 基础检查 + - govet + - staticcheck + - errcheck + - ineffassign + - unused + + # 代码坏味道 + - dupl # 重复代码 + - mnd # 魔法数字 + - goconst # 不必要的字符串常量 + - cyclop # 包/函数复杂度 + - nestif # if 嵌套太深 + - maintidx # 维护性指数 + - revive # 风格/命名/坏味道 + - gocritic # 各类代码问题 + - funlen # 函数过长 + + - gosec # 安全问题检查 + - bodyclose # HTTP response body 没有正确关闭 + - noctx # 没有传递 context.Context + - contextcheck # 其他检查 + - sqlclosecheck # SQL rows 没有正确关闭 + - unconvert # 不必要的类型转换 + - nilerr # 函数返回 nil 错误 + + settings: + dupl: + threshold: 80 + + cyclop: + max-complexity: 20 + package-average: 10 + + nestif: + min-complexity: 5 + + funlen: + lines: 200 + statements: 100 + + mnd: + checks: + - argument + - condition + - return + + +# 完整上报所有问题(取消 golangci 默认 50/3 截断,保证 code-check 与度量真实) +issues: + max-issues-per-linter: 0 + max-same-issues: 0 \ No newline at end of file diff --git a/.auto/lint.ref.yaml b/.auto/lint.ref.yaml new file mode 100644 index 00000000..b60c74b1 --- /dev/null +++ b/.auto/lint.ref.yaml @@ -0,0 +1,78 @@ +version: "2" + +# Pinned autoresearch yardstick. IMMUTABLE for the duration of a run. +# Snapshot of the committed .golangci.yml plus the extra analyzers that report +# genuine defects (correctness / panics / dead code) rather than cosmetics. +# Keeping this separate from .golangci.yml means strengthening the project gate +# can never silently lower the measured debt. + +run: + timeout: 5m + tests: false + +linters: + default: none + enable: + # --- from the committed project gate --- + - govet + - staticcheck + - errcheck + - ineffassign + - unused + - dupl + - mnd + - goconst + - cyclop + - nestif + - maintidx + - revive + - gocritic + - funlen + - gosec + - bodyclose + - noctx + - contextcheck + - sqlclosecheck + - unconvert + - nilerr + # --- extra real-risk analyzers (defects, not cosmetics) --- + - errorlint # err == / %v instead of errors.Is/As and %w + - forcetypeassert # unchecked type assertions can panic + - nilnil # (value, nil) breaks the nil-check contract + - predeclared # shadowing builtins + - unparam # dead params/results + - wastedassign # dead stores + - exhaustive # enum switches missing cases + - makezero # append to preallocated slice + - rowserrcheck # sql.Rows error after iteration + - durationcheck # multiplied time.Duration + - prealloc # slice growth in loops + - copyloopvar # loop-var capture + - nonamedreturns + - funcorder # struct methods scattered across files + - nolintlint # suppression audit (must stay 0) + + settings: + dupl: + threshold: 80 + + cyclop: + max-complexity: 20 + package-average: 10 + + nestif: + min-complexity: 5 + + funlen: + lines: 200 + statements: 100 + + mnd: + checks: + - argument + - condition + - return + +issues: + max-issues-per-linter: 0 + max-same-issues: 0 diff --git a/.auto/proposals.md b/.auto/proposals.md new file mode 100644 index 00000000..8f40d735 --- /dev/null +++ b/.auto/proposals.md @@ -0,0 +1,129 @@ +# Deferred proposals — autoresearch run (iterations 27-36) + +Five verified findings deliberately **not** changed by the loop: each needs either a +contract/API decision or a multi-package restructure, which this run was scoped to +propose rather than perform. Evidence is from reading the cited files in this +checkout at commit `ea97b64` plus iterations 27-35. + +--- + +## P1 — Cross-driver storage migration cannot move objects (severity: data availability) + +`plugins/domain/upload/task/storage_migration.go` computes `target` from the payload, +then calls: + +```go +migrated, err := migrateObjects(ctx, storageSvc, storageSvc, total) +``` + +`sourceBackend` and `targetBackend` are the **same** `contracts.StorageService`. That +service resolves its backend per call and only ever to the currently active one +(`plugins/infra/storage/plugin.go:89` → `s.backend` or `objectstore.Active(ctx)`), and +the target config is persisted **after** the migration loop +(`uploadstorage.SaveActiveConfig(ctx, target)`). + +Consequence for a non-empty source: `migrateSingleObject` reads and writes the same +backend; `shouldSkipMigration` finds every object already "present in the target" and +skips it, yet `migrated` is still incremented, so the task returns +`存储迁移完成,共迁移 N 个对象,活动存储已切换为 ` having copied **zero** bytes, +and then points the platform at an empty backend. The same-driver and +`total == 0` branches are harmless and legitimately need no copying. + +Why the tests miss it: `shared.MockStorageService` is one instance serving both +parameters, so a copy-to-self looks correct. + +Proposed fix (needs a contract decision — this is a feature, not a patch): +1. Extend `contracts.StorageService` with the ability to operate against an explicitly + supplied `StorageConfigDTO` (e.g. `BackendFor(ctx, cfg) (StorageReader, error)`), + implemented in `plugins/infra/storage` where the `objectstore` backends live. They + are unexported today and `plugins/domain/upload` must not import them (cross-plugin + import ban), so the contract is the only correct route. +2. In the task, build the target from `target` and pass distinct source/target. +3. Only save the active config after a verified copy, and assert `src != dst` at + entry. +4. Interim safety option if a decision is needed sooner: make the + `target.Driver != active.Driver && total > 0` branch return an explicit + not-implemented error instead of reporting success. Rejected by this loop because + it disables an advertised admin operation, which is a product call, and because + the machinery it would strand (`migrateObjects`, `migrateSingleObject`, + `shouldSkipMigration`) becomes dead code the project gate then rejects. + +Size: contract + infra impl + task wiring + a two-backend test double. Roughly one +focused session, not a loop iteration. + +--- + +## P2 — `w_system_configs` has one migration owner and many writers (Cordis single-owner) + +Owner per migrations: `plugins/domain/admin`. Still read/written with raw SQL from +`plugins/domain/system/repository.go:31`, `plugins/domain/cap/repository.go:50`, +`plugins/domain/auth/repository.go:122`, `plugins/domain/upload/storage/migration.go:96,108`, +`plugins/domain/upload/ingest/helpers.go:55`, +`plugins/domain/message_gateway/repository/push.go` and `plugins/drivers/driver_http`. + +Iteration 34 fixed one instance of the real damage this causes (a failed read looked +identical to "unconfigured", silently dropping notifications); iteration 35 fixed +another (a failed read cached a narrowed whitelist for a whole TTL). The remaining +sites carry the same trap. + +Proposed fix: one settings accessor contract (`Get(ctx, key) (string, error)` / +`GetAll(ctx, keys...)`) owned by the settings subsystem, then delete the raw table +access. Keys should be declared where they are used rather than string-matched. +Size: medium, touches seven plugins; do it key-group by key-group so each step is +independently revertable. + +--- + +## P3 — Two tables are modelled twice (schema drift hazard) + +* `w_task_executions`: `plugins/domain/admin/model/entity.go:207` **and** + `plugins/drivers/driver_asynq_worker/types.go:49`. The two `TaskExecution` structs + and their status enums are byte-for-byte identical today. +* `w_schedules`: `plugins/domain/admin/model/entity.go:169` **and** + `plugins/drivers/driver_asynq_cron/schedule.go:25`. + +Nothing is broken yet — that is the risk: the migration owner was only recently moved +to `admin` (`49f9d10`), and a column added to one struct will silently diverge from +the other, so whichever writer holds the stale struct zeroes or omits the new column. + +Proposed fix: pick the single owner per P2's rules and have the other side go through +a contract (execution recording already has DTOs in `contracts`), then delete the +duplicate model. Consider a gate check rejecting two non-`testhelper` packages +declaring the same `w_` table — it will fail until these two are resolved, so land it +with the fix (the pattern that worked in iterations 5 and 19). + +--- + +## P4 — `user` deletes rows from tables owned by `auth` + +`plugins/domain/user/repository.go:327,330` issues `DELETE` against `w_access_tokens` +and `w_external_accounts`, both owned and migrated by `plugins/domain/auth`, inside +user deletion. It works, but ownership is inverted: revoke-on-delete is auth's +invariant, and encoding it in `user` means any other deletion path silently skips it. + +Proposed fix: emit a typed `user:deleted` event from `user` and let `auth` cascade +within its own transaction boundary, or expose an explicit `AuthService.RevokeForUser`. +Size: small-to-medium; needs a test that the revocation still happens on delete. + +--- + +## P5 — Package `cap` shadows the predeclared identifier (8 of 62 debt) + +Every file in `plugins/domain/cap` declares `package cap`, which shadows the builtin. +It is the single largest block of non-cosmetic lint debt this run declined to chase, +and it is also a readability cost (`cap.Something` reads as a builtin call). + +Proposed fix: rename to a non-shadowing identifier (e.g. `capacity` / `proofwork`, +matching what the plugin actually does) across its own files and importers. Mechanical +but wide; needs a decision on the new name first, which is why it is not done here. + +--- + +## Explicitly rejected as metric-chasing + +23 `funcorder`, 5 `exhaustive` (both flagged only because +`default-signifies-exhaustive` defaults to false), 4 `nonamedreturns` and the 17 +`forcetypeassert` cluster in `core/events.go` and `core/extpoints/config_resolve.go` +— verified guarded by construction (`convertString` etc. return `(any, error)` and +always yield the asserted type when `err == nil`). Reordering functions or adding +unreachable `if !ok` branches would raise the score and lower the code. diff --git a/.auto/prove_fix.sh b/.auto/prove_fix.sh new file mode 100755 index 00000000..bbce553d --- /dev/null +++ b/.auto/prove_fix.sh @@ -0,0 +1,57 @@ +#!/bin/bash +# Mechanically prove a FIX iteration is load-bearing. +# +# Usage: .auto/prove_fix.sh [...] +# +# Run immediately AFTER committing the fix, with a clean worktree. It reverts +# only the non-test source files to their pre-fix state (keeping the new test), +# runs the package tests, and requires them to FAIL. Then it restores HEAD. +# A fix nobody can break with a revert is not a fix. +set -uo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +if [ ! -z "$(git -C "${ROOT}" status --porcelain)" ]; then + echo "PROVE ABORT: worktree must be clean (commit the change first)" + exit 2 +fi + +PKG="$1"; shift +SRC_FILES=("$@") +if [ "${#SRC_FILES[@]}" -eq 0 ]; then + echo "PROVE ABORT: no source files given" + exit 2 +fi + +cd "${ROOT}/backend" || exit 2 + +restore() { + git -C "${ROOT}" checkout HEAD -- "${SRC_FILES[@]}" 2>/dev/null +} +trap restore EXIT + +for f in "${SRC_FILES[@]}"; do + if git -C "${ROOT}" cat-file -e "HEAD^:${f}" 2>/dev/null; then + git -C "${ROOT}" checkout "HEAD^" -- "${f}" || { echo "PROVE ABORT: cannot revert ${f}"; exit 2; } + else + # File did not exist before this commit — removing it is the revert. + rm -f "${ROOT}/${f}" + fi +done + +echo "--- tests against pre-fix source ---" +OUT=$(go test -count=1 "${PKG}" 2>&1) +RC=$? +echo "${OUT}" | tail -15 +if [ "${RC}" -eq 0 ]; then + echo "PROVE FAILED: tests still pass without the fix — this is not a real bug fix" + exit 1 +fi +if echo "${OUT}" | grep -q 'build failed'; then + KIND="compile (signature changed; behaviour proven by inspection)" +elif echo "${OUT}" | grep -qE '^--- FAIL'; then + KIND="assertion" +else + KIND="failure" +fi +echo "PROVED: test fails without the fix (${KIND})" +exit 0 diff --git a/.auto/results.tsv b/.auto/results.tsv new file mode 100644 index 00000000..d4a89235 --- /dev/null +++ b/.auto/results.tsv @@ -0,0 +1,37 @@ +iteration commit metric delta status guard description +0 - 102 0.0 baseline pass initial measurement (pinned yardstick: repo gate + real-risk analyzers) +1 1c5731b 100 -2.0 keep pass core: Using2/Using3 now wrap dependency causes via errors.Join (proven: test fails on revert) +2 37ad586 95 -5.0 keep pass sentinel == comparisons -> errors.Is across admin/upload/cap-pow (5 sites) +3 686e3ef 93 -2.0 keep pass filesrv.AbortUploadRecordError dedups error mapping + errors.As (2 sites, drops dead ErrInvalidUploadID) +4 7e6b9e7 93 0.0 keep pass PROVEN FIX: singleflight image generation no longer dies with the first caller canceled ctx (test fails on revert) +5 381c794 93 0.0 keep pass CORDIS: gate widened to catch bare "go call()" + 4 unprotected cleanup goroutines moved to util.Go (arch violations 4->0) +6 ce33997 92 -1.0 keep pass BUGFIX admin logs: negative cursor was accepted (bool ignored by callers) -> error-only contract; proven via revert (compile-level) + contract test +7 c66399e 89 -3.0 keep pass push channels share title/content/level extraction (3 dead inits gone, ~20 fewer lines) +8 18820b1 89 0.0 keep pass BUGFIX push: synthesized notification content had random field order (map iteration); sorted keys, test observed failing pre-fix +9 22ecafd 87 -2.0 keep pass unparam: always-nil error returns dropped, 4 unreachable branches removed +10 c4068ef 84 -3.0 keep pass errorlint cleared to 0: %%w at push test + telegram fallback, errors.As in config loader +11 3d2038a 80 -4.0 keep pass nilnil: unimplemented auth mocks now return a sentinel instead of (nil,nil) +12 101cb2f 79 -1.0 keep pass nilnil: inproc driver GetExecution returns error, matching asynq driver semantics +14 6932b54 79 0.0 keep pass DATA-LOSS BUGFIX: cache read error no longer clobbers buffered task log (proven: assertion fails on revert) +15 2c41563 79 0.0 keep pass PERF: CORS origin check no longer hits DB per request (5s cached read); proven - loader count 0 vs 1 on revert +16 976f9b1 79 0.0 keep pass PERF: contract-level batch user lookup replaces N+1 in access-log enrichment (test proves 1 query vs 3) +17 1b1c452 79 0.0 keep pass BUGFIX: orphan cron message_gateway:cleanup_pairing_codes now has a handler; invariant test added (proven by stash-revert) +18 8c4955c 79 0.0 keep pass BUGFIX: removed phantom user:daily_audit cron (dispatched to unregistered task); cross-plugin invariant test added +19 84eaf3f 79 0.0 keep pass CORDIS+BUGFIX: task handlers were asynq-typed so 4 upload tasks could not run under the in-process worker; made driver-agnostic + gate check 7 (proven: gate names all 3 files pre-fix) +20 efa7555 79 0.0 keep pass BUGFIX telegram: LongPoller.Timeout was 10 nanoseconds -> getUpdates timeout=0 -> busy polling; now 10s (proven by reverting the constant) +21 1023fa3 79 0.0 keep pass DISK LEAK: telegram inbound media scratch dirs were never removed (no consumer reads them); cleanup on handler exit. No test possible (needs live download) +22 ad83841 54 -25.0 keep pass dead lint suppressions removed (24); 2 were load-bearing -> restored+narrowed with reasons after guard veto exposed verified contextcheck FPs +23 de938de 54 0.0 keep pass SECURITY/BUGFIX fail-open auth: user+message_gateway consumed contracts.AuthService in Apply but declared only DBService, so reconcile mounted user before auth and loginMW degraded to a pass-through (user change-password/profile/access-tokens unguarded in production, deterministically); declared the dep + added reconcile-level ordering test (PROVED: assertion fails on revert) +24 62b48e9 54 0.0 keep pass SECURITY: all three auth-middleware fallbacks were c.Next() (fail-open). Reachable at runtime in admin: OnDispose->ResetServices() nils the global the per-request guard reads, so in-flight requests pass as authenticated. Added ginutil.AuthUnavailable() + table test driving each registered guard (PROVED: abort assertion fails on revert to 577d795) +25 f58f5a4 54 0.0 keep pass staticcheck ST1023 x4 from iter 24 (redundant gin.HandlerFunc on typed-RHS decls) - caught by GUARD only, go build/go test both stayed green; lesson: run checks.sh after EVERY commit, not just before ship +26 - 64 +10.0 rebaseline pass upstream config-extension + auth/user/task work raised debt 54->64; re-measured at HEAD ea97b64, 47 pkgs pass, arch 0 viol. Run focus agreed: real defects primary, debt secondary (proven-fix gate keeps delta-0 fixes) +27 31f3af6 63 -1.0 keep pass dead contextcheck suppression on cmd.newWaveletApp removed; the core.App.Run one was load-bearing (guard veto: project gate contextcheck Run->Start, verified FP on variadic ctx) -> restored narrowed + documented. Lesson 8 trap re-hit: nolintlint "unused" != safe to delete +28 5037097 63 0.0 discard fail CORDIS gate: contracts DTO must not carry TableName + removed UserDTO.TableName(). DISCARDED: my grep used -g !*_test.go and missed upload/handler/routers_test.go:669 which does db.Create(&contracts.UserDTO{}) into w_users - that suppression exists precisely to enable the cross-plugin write. Lesson: contracts-purity changes must scan test files too. +29 2ff0cb8 63 0.0 keep pass CORDIS contracts purity: gate check 2.2 forbids TableName()/gorm tags in core/contracts + removed UserDTO.TableName(); upload/handler test now seeds via explicit .Table("w_users") (precedent: filesrv test). Proven twice over: gate named auth.go:33 pre-fix, and iter-28 revert broke 1 package without the test fix. Delta-0 keep under the agreed real-defect gate +30 9ea0e2b 63 0.0 keep pass SECURITY (assertion-proven): FindUserByFieldRecord interpolated its column arg into WHERE with only a prose comment as guard. Pre-fix the tautology "username = '' OR 1=1 --" EXECUTED and returned a row with err= (filter bypass). Now an allow-list rejects before GetDB. Also first test in the repository pkg: tests_passed 47->48, funcs 282 +31 5193bd0 63 0.0 keep pass HARNESS INTEGRITY: measure.sh and checks.sh now key GOLANGCI_LINT_CACHE per checkout. The default cache is machine-wide, so entries written by a sibling worktree replayed here carrying ITS absolute paths (12 of 63 lines pointed at an outside checkout), misattributing findings and risking a stale Guard verdict. Proven count-neutral: cold and warm both 63; foreign paths now 0. Delta 0 by design, kept under the agreed real-defect gate +32 f7a86d3 63 0.0 keep pass PERF+DEDUP: three packages hand-rolled mutex+[]string+MatchPathPattern loop, re-normalising and re-splitting immutable patterns per request. New extpoints.PathWhitelist compiles patterns at registration and absorbs all three. Mechanically asserted: 14 allocs/op -> 1 allocs/op; equivalence test pins Match against the legacy loop over a full pattern x path matrix; race-clean. funcs 282->288, coverage 35.02 +33 99fca9e 62 -1.0 keep pass BUGFIX+DEDUP: task.loadActiveStorageConfig and saveActiveStorageConfig duplicated uploadstorage.LoadStorageConfig/SaveActiveConfig but swallowed all three failures (nil db, read error, json parse) returning zero config + nil error, making the caller-s already-written error branch dead: a storage migration could run from an unknown active driver. Now routed through the canonical accessors; regression test corrupts the stored config and asserts Execute errors (assertion-proven via revert). funcs 289 +34 b22f863 62 0.0 keep pass BUGFIX+PERF: LoadSMTPConfigRecord fired four single-key queries and discarded every error with underscore assignment, so an unreadable w_system_configs returned four blank strings both callers could only read as "SMTP not configured" -> notification silently dropped. Now one IN query plus a real error channel; callers log at the boundary and keep their own values. Proof is signature-level and exact: the old API had no error return, so the failure was unrepresentable. 4 queries -> 1, funcs 291 +35 d7c851b 62 0.0 keep pass BUGFIX+PERF: access_cache discarded the whitelist read error with underscore assignment, then unconditionally set valid=true and CheckedAt=now, so one transient DB failure pinned the RESTRICTED default public-access list for the whole TTL and silently narrowed an admin-configured whitelist. Now the error is logged, last-good is served when known, and a cold failure stays invalid so the next request retries. Assertion-proven by dropping and restoring the table mid-test. funcs 292 +36 - 62 0.0 keep skip PROPOSALS (.auto/proposals.md): five verified items deliberately not auto-fixed per the agreed scope. Headline P1: cross-driver storage migration passes the SAME service as source and target (getBackend only ever resolves the active backend) and saves the target config afterwards, so it reports "migrated N objects" having copied zero bytes and then points the platform at an empty backend. Needs a contracts.StorageService capability decision, not a patch. diff --git a/.claude b/.claude new file mode 120000 index 00000000..c0ca4685 --- /dev/null +++ b/.claude @@ -0,0 +1 @@ +.agents \ No newline at end of file diff --git a/.github/workflows/build-image.yml b/.github/workflows/build-image.yml new file mode 100644 index 00000000..16adf69d --- /dev/null +++ b/.github/workflows/build-image.yml @@ -0,0 +1,270 @@ +name: Build Image + +on: + workflow_dispatch: + inputs: + version: + description: "Image version/tag to publish (e.g. v1.0.0-beta). Leave empty to publish as canary." + required: false + type: string + push: + tags: ["v*"] + branches: ["canary"] + +# One active run per ref (e.g. canary); newer runs cancel older in-progress builds. +concurrency: + group: build-image-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: read + packages: write + attestations: write + id-token: write + +env: + IMAGE_NAME: wavelet + DOCKERFILE: docker/Dockerfile + +jobs: + # Resolve version / registries once. No checkout: triggers alone determine the tag. + prepare: + name: Prepare metadata + runs-on: ubuntu-latest + outputs: + version: ${{ steps.prep.outputs.version }} + build_date: ${{ steps.prep.outputs.build_date }} + image: ${{ steps.prep.outputs.image }} + image_names: ${{ steps.prep.outputs.image_names }} + images: ${{ steps.prep.outputs.images }} + push_dockerhub: ${{ steps.prep.outputs.push_dockerhub }} + is_stable: ${{ steps.prep.outputs.is_stable }} + is_prerelease: ${{ steps.prep.outputs.is_prerelease }} + steps: + - name: Resolve version and images + id: prep + env: + INPUT_VERSION: ${{ github.event.inputs.version }} + DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }} + DOCKERHUB_TOKEN: ${{ secrets.DOCKERHUB_TOKEN }} + DOCKERHUB_NAMESPACE: ${{ secrets.DOCKERHUB_NAMESPACE }} + run: | + set -euo pipefail + INPUT_VERSION="${INPUT_VERSION//[[:space:]]/}" + OWNER="${GITHUB_REPOSITORY_OWNER,,}" + BUILD_DATE="$(date -u +'%Y-%m-%dT%H:%M:%SZ')" + + if [[ "${GITHUB_REF}" == refs/heads/canary ]]; then + VERSION="canary" + elif [[ "${GITHUB_REF}" == refs/tags/* ]]; then + VERSION="${GITHUB_REF_NAME}" + elif [[ -n "$INPUT_VERSION" ]]; then + VERSION="$INPUT_VERSION" + elif [[ "${GITHUB_EVENT_NAME}" == "workflow_dispatch" ]]; then + VERSION="canary" + else + echo "unable to determine image version/tag" >&2 + exit 1 + fi + + if [[ "$VERSION" == "canary" ]]; then + IS_STABLE="false" + IS_PRERELEASE="false" + elif [[ "$VERSION" =~ (alpha|beta|rc) ]]; then + IS_STABLE="false" + IS_PRERELEASE="true" + else + IS_STABLE="true" + IS_PRERELEASE="false" + fi + + IMAGE="ghcr.io/${OWNER}/${IMAGE_NAME}" + IMAGE_NAMES="${IMAGE}" + # Newline-separated list for docker/metadata-action + IMAGES="${IMAGE}" + + DOCKERHUB_USERNAME="${DOCKERHUB_USERNAME//[[:space:]]/}" + DOCKERHUB_TOKEN="${DOCKERHUB_TOKEN//[[:space:]]/}" + DOCKERHUB_NAMESPACE="${DOCKERHUB_NAMESPACE//[[:space:]]/}" + PUSH_DOCKERHUB="false" + if [[ -n "$DOCKERHUB_USERNAME" && -n "$DOCKERHUB_TOKEN" ]]; then + HUB_NS="${DOCKERHUB_NAMESPACE:-$DOCKERHUB_USERNAME}" + HUB_NS="${HUB_NS,,}" + IMAGE_DOCKERHUB="${HUB_NS}/${IMAGE_NAME}" + IMAGE_NAMES="${IMAGE_NAMES},${IMAGE_DOCKERHUB}" + IMAGES="${IMAGES}"$'\n'"${IMAGE_DOCKERHUB}" + PUSH_DOCKERHUB="true" + echo "Docker Hub publish enabled: ${IMAGE_DOCKERHUB}" + else + echo "Docker Hub secrets not set; publishing to GHCR only." + fi + + { + echo "version=${VERSION}" + echo "build_date=${BUILD_DATE}" + echo "image=${IMAGE}" + echo "image_names=${IMAGE_NAMES}" + echo "push_dockerhub=${PUSH_DOCKERHUB}" + echo "is_stable=${IS_STABLE}" + echo "is_prerelease=${IS_PRERELEASE}" + echo "images<> "$GITHUB_OUTPUT" + + echo "Resolved version=${VERSION} build_date=${BUILD_DATE} stable=${IS_STABLE} prerelease=${IS_PRERELEASE}" + + build: + name: Build (${{ matrix.arch }}) + needs: prepare + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + platform: linux/amd64 + runner: ubuntu-latest + - arch: arm64 + platform: linux/arm64 + # No ubuntu-latest-arm alias from GitHub; 24.04-arm is the current stable arm64 image. + runner: ubuntu-24.04-arm + runs-on: ${{ matrix.runner }} + steps: + - name: Checkout code + uses: actions/checkout@v4 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log into GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log into Docker Hub + if: needs.prepare.outputs.push_dockerhub == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Build and push + id: build + uses: docker/build-push-action@v7 + with: + context: . + file: ${{ env.DOCKERFILE }} + platforms: ${{ matrix.platform }} + outputs: type=image,"name=${{ needs.prepare.outputs.image_names }}",push-by-digest=true,name-canonical=true,push=true + build-args: | + VERSION=${{ needs.prepare.outputs.version }} + BUILD_DATE=${{ needs.prepare.outputs.build_date }} + cache-from: type=gha,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }} + cache-to: type=gha,mode=max,ignore-error=true,timeout=20m,scope=docker-${{ env.IMAGE_NAME }}-${{ matrix.arch }} + + - name: Export digest + shell: bash + run: | + mkdir -p "/tmp/${{ env.IMAGE_NAME }}-digests" + touch "/tmp/${{ env.IMAGE_NAME }}-digests/${DIGEST#sha256:}" + env: + DIGEST: ${{ steps.build.outputs.digest }} + + - name: Upload digest + uses: actions/upload-artifact@v4 + with: + name: ${{ env.IMAGE_NAME }}-digests-${{ matrix.arch }} + path: /tmp/${{ env.IMAGE_NAME }}-digests/* + if-no-files-found: error + retention-days: 1 + + - name: Generate artifact attestation + uses: actions/attest-build-provenance@v3 + with: + subject-name: ${{ needs.prepare.outputs.image }} + subject-digest: ${{ steps.build.outputs.digest }} + push-to-registry: true + + merge: + name: Merge multi-arch manifest + runs-on: ubuntu-latest + needs: [prepare, build] + steps: + # No repo checkout: tags come from prepare + metadata-action. + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ needs.prepare.outputs.images }} + flavor: | + latest=false + tags: | + type=raw,value=${{ needs.prepare.outputs.version }} + type=raw,value=latest,enable=${{ needs.prepare.outputs.is_stable == 'true' }} + type=raw,value=beta,enable=${{ needs.prepare.outputs.is_prerelease == 'true' }} + + - name: Download digests + uses: actions/download-artifact@v4 + with: + path: /tmp/${{ env.IMAGE_NAME }}-digests + pattern: ${{ env.IMAGE_NAME }}-digests-* + merge-multiple: true + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v4 + + - name: Log into GHCR + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Log into Docker Hub + if: needs.prepare.outputs.push_dockerhub == 'true' + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Create and push manifest list + working-directory: /tmp/${{ env.IMAGE_NAME }}-digests + shell: bash + env: + IMAGE: ${{ needs.prepare.outputs.image }} + DOCKER_METADATA_OUTPUT_JSON: ${{ steps.meta.outputs.json }} + run: | + set -euo pipefail + shopt -s nullglob + references=() + for digest in *; do + references+=("${IMAGE}@sha256:${digest}") + done + + if [ ${#references[@]} -eq 0 ]; then + echo "No digests found in /tmp/${{ env.IMAGE_NAME }}-digests" >&2 + exit 1 + fi + + # shellcheck disable=SC2046 + docker buildx imagetools create \ + $(jq -cr '.tags | map("-t " + .) | join(" ")' <<< "$DOCKER_METADATA_OUTPUT_JSON") \ + "${references[@]}" + + - name: Inspect image + run: docker buildx imagetools inspect "${{ needs.prepare.outputs.image }}:${{ needs.prepare.outputs.version }}" + + - name: Trigger webhook + env: + WEBHOOK_URL: ${{ secrets.WEBHOOK_URL }} + run: | + if [ -n "$WEBHOOK_URL" ]; then + curl -fsSL "$WEBHOOK_URL" + else + echo "Webhook URL is not set, skipping." + fi diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 00000000..686af6b6 --- /dev/null +++ b/README_zh.md @@ -0,0 +1,352 @@ +# wavelet + +🚀 现代化、生产就绪的全栈应用脚手架 + +[English](./README.md) + +[![License: Apache2.0](https://img.shields.io/badge/License-Apache2.0-blue.svg)](https://opensource.org/licenses/Apache-2.0) +[![Go Version](https://img.shields.io/badge/Go-1.25+-blue.svg)](https://golang.org/) +[![Next.js](https://img.shields.io/badge/Next.js-16-black.svg)](https://nextjs.org/) +[![React](https://img.shields.io/badge/React-19-blue.svg)](https://reactjs.org/) + +## 📖 项目简介 + +**wavelet** 是一个通用型、生产就绪的现代全栈脚手架,后端采用 **Go(Gin + GORM)**,前端采用 **Next.js(App Router + Shadcn UI)**。项目开箱即用,内置构建现代 SaaS、内部工具或开发者平台所需的核心基础设施。 + +项目设计理念是 **框架优先、业务中立**:您可以在沿用经过实战检验的底层基础设施的同时,自由接入自己的业务逻辑。 + +### ✨ 主要特性 + +- 🔐 **多认证方式** — 本地账号密码登录/注册 + 可插拔 OIDC/OAuth2 认证源(支持同时配置多个认证源) +- 🗝️ **个人访问令牌** — API Key 管理,支持程序化接口访问;兼容 `Authorization: Bearer` 和 `X-Access-Token` 请求头 +- 👤 **用户管理** — 管理后台提供用户列表、搜索筛选、启用/禁用账号等功能 +- ⚙️ **动态系统配置** — KV 系统配置管理,支持实时变更,可通过管理后台界面直接操作 +- 📋 **异步任务队列** — 基于 [Asynq](https://github.com/hibiken/asynq)(Redis 驱动)的后台任务处理系统,含任务调度面板 +- 📁 **S3 文件存储** — 通过 S3 兼容 API 统一处理文件上传/下载,支持本地磁盘缓存 +- 📊 **可观测性** — 结构化日志(Zap)+ 分布式链路追踪(OpenTelemetry) +- 🎨 **现代化 UI** — 基于 Tailwind CSS 4 和 Shadcn UI 构建的响应式、支持深色模式的设计系统 +- 📖 **内置文档中心** — 集成文档门户,包含使用指南、接口文档、隐私政策和服务条款 + +## 🏗️ 架构概览 + +``` +┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐ +│ 前端 │ │ 后端 │ │ 数据库 │ +│ (Next.js) │◄──►│ (Go) │◄──►│ (PostgreSQL) │ +│ │ │ │ │ │ +│ • React 19 │ │ • Gin HTTP 框架 │ │ • PostgreSQL │ +│ • TypeScript │ │ • GORM ORM │ │ • Redis 缓存 │ +│ • Tailwind 4 │ │ • 多认证源适配 │ │ │ +│ • Shadcn UI │ │ • AccessToken 中间件 │ │ │ +│ │ │ • Asynq 任务队列 │ │ │ +│ │ │ • OpenTelemetry 链路追踪 │ │ │ +│ │ │ • Swagger 接口文档 │ │ │ +└─────────────────┘ └─────────────────────────────┘ └─────────────────┘ + │ + ┌──────────┴──────────┐ + │ 多进程 CLI 入口 │ + │ (Cobra + Viper) │ + │ • api (HTTP) │ + │ • worker (队列) │ + │ • scheduler(定时) │ + └─────────────────────┘ +``` + +## 🛠️ 技术栈 + +### 后端 +- **[Go 1.25+](https://go.dev/doc)** — 主语言 +- **[Gin](https://github.com/gin-gonic/gin)** — HTTP Web 框架 +- **[GORM](https://github.com/go-gorm/gorm)** — ORM,支持 PostgreSQL 和 ClickHouse +- **[Redis](https://github.com/redis/redis)** — 缓存、Session 存储、任务队列后端 +- **[Asynq](https://github.com/hibiken/asynq)** — 分布式任务队列(Redis 驱动) +- **[Cobra + Viper](https://github.com/spf13/cobra)** — CLI 入口 + 配置管理 +- **[OpenTelemetry](https://opentelemetry.io)** — 分布式链路追踪与可观测性 +- **[Zap](https://github.com/uber-go/zap)** — 结构化高性能日志 +- **[Swagger (Swaggo)](https://github.com/swaggo/swag)** — 自动生成 API 文档 +- **[AWS SDK v2](https://github.com/aws/aws-sdk-go-v2)** — S3 兼容文件存储 +- **[Snowflake](https://github.com/bwmarrin/snowflake)** — 分布式 ID 生成 + +### 前端 +- **[Next.js 16](https://github.com/vercel/next.js)** — React 框架(App Router) +- **[React 19](https://github.com/facebook/react)** — UI 库 +- **[TypeScript](https://github.com/microsoft/TypeScript)** — 类型安全 +- **[Tailwind CSS 4](https://github.com/tailwindlabs/tailwindcss)** — 原子化 CSS 框架 +- **[Shadcn UI](https://github.com/shadcn-ui/ui)** — 可访问、可组合的组件库 +- **[Lucide Icons](https://github.com/lucide-icons/lucide)** — 图标库 + +## 📋 环境要求 + +- **Go** >= 1.25 +- **Node.js** >= 18.0 +- **PostgreSQL** >= 14 +- **Redis** >= 6.0 +- **pnpm** >= 8.0(推荐) + +## 🚀 快速开始 + +### 1. 克隆仓库 + +```bash +git clone https://github.com/Rain-kl/Wavelet.git refreshing +cd refreshing +``` + +### 2. 配置环境 + +```bash +cp config.example.yaml config.yaml +``` + +编辑 `config.yaml`,配置数据库和 Redis。OIDC 认证源统一在管理后台的系统设置页面运行时配置。 + +### 3. 初始化数据库 + +```bash +# 启动本地依赖服务(PostgreSQL + Redis) +docker compose up -d + +# 可选:同时启动 ClickHouse +docker compose --profile clickhouse up -d + +# 如果使用外部 PostgreSQL,而不是 Docker 内置服务,则手动创建数据库 +createdb -h <主机> -p 5432 -U postgres refreshing + +# 数据库表结构在首次启动时自动迁移,无需手动执行 +``` + +### 4. 启动后端 + +```bash +# 安装 Go 依赖 +go mod tidy + +# 生成 Swagger 接口文档 +make swagger + +# 启动 HTTP API 服务器 +go run main.go api +``` + +> 后端也支持独立运行 `scheduler` 和 `worker` 进程来处理异步任务: +> ```bash +> go run main.go scheduler # 定时任务调度器 +> go run main.go worker # Asynq 任务处理工作进程 +> ``` + +### 5. 启动前端 + +```bash +cd frontend + +# 安装依赖 +pnpm install + +# 启动开发服务器(Turbopack) +pnpm dev +``` + +### 6. 访问应用 + +| 服务 | 地址 | +|------|------| +| 前端界面 | http://localhost:3000 | +| Swagger 接口文档 | http://localhost:8000/swagger/index.html | +| 健康检查 | http://localhost:8000/api/health | + +## ⚙️ 配置说明 + +主要配置项(完整说明请参考 `config.example.yaml`): + +| 配置项 | 说明 | 示例 | +|--------|------|------| +| `app.addr` | 后端监听地址 | `:8000` | +| `database.host` | PostgreSQL 主机 | `127.0.0.1` | +| `database.database` | 数据库名称 | `refreshing` | +| `redis.host` | Redis 主机 | `127.0.0.1` | +| `storage.endpoint` | S3 兼容存储端点 | `s3.amazonaws.com` | + +## 🔧 开发指南 + +### 后端 + +```bash +# 运行 API 服务器 +go run main.go api + +# 运行定时任务调度器 +go run main.go scheduler + +# 运行异步任务工作进程 +go run main.go worker + +# 修改 Controller 后重新生成 Swagger 文档(必须执行) +make swagger + +# 代码格式化与检查 +make tidy +``` + +### 前端 + +```bash +cd frontend + +# 开发模式(Turbopack) +pnpm dev + +# 构建生产版本 +pnpm build + +# 启动生产服务器 +pnpm start + +# 代码 Lint 和格式化 +pnpm lint +pnpm format +``` + +## 📁 项目结构 + +``` +wavelet/ +├── main.go # 程序入口(委托给 internal/cmd) +├── config.example.yaml # 配置模板 +├── Makefile # 常用命令(swagger、tidy、license、cross-build) +├── docker/ # Docker 镜像构建文件(集成/前端/后端) +├── docs/ # Swagger 自动生成文档 +├── frontend/ # Next.js 前端应用 +│ ├── app/ # App Router 页面 +│ ├── components/ # React 组件(ui、common、layout) +│ ├── lib/services/ # API 服务层 +│ └── types/ # TypeScript 类型定义 +└── internal/ # Go 后端(private) + ├── cmd/ # CLI 命令(api、scheduler、worker) + ├── apps/ # 业务模块(oauth、user、admin、upload) + ├── model/ # GORM 实体与业务方法 + ├── router/ # HTTP 路由注册 + ├── task/ # 异步任务定义与工作进程 + ├── db/ # 数据库与 Redis 初始化 + ├── storage/ # S3 文件存储抽象层 + └── common/ # 公共工具与响应封装 +``` + +## 📚 接口文档 + +Swagger 接口文档在后端启动后自动可用: + +``` +http://localhost:8000/swagger/index.html +``` + +前端文档中心(路径 `/docs`)内置以下内容: +- **使用指南** — 分步入门教程 +- **接口文档** — 详细接口说明 +- **隐私政策** — 隐私政策模板(请按需自定义) +- **服务条款** — 服务条款模板 + +## 🧪 测试 + +```bash +# 后端测试 +go test ./... + +# 前端 Lint +cd frontend && pnpm lint +``` + +## 🚀 部署 + +### 跨平台二进制编译 + +一条命令构建全部 6 个平台的静态二进制文件(Linux / macOS / Windows × amd64 / arm64)。 +前端已内嵌到每个二进制文件中,无需单独部署。 + +**前提条件:** 已安装 Docker 且启用 BuildKit(Docker 23+ 默认开启)。 + +```bash +# 构建全部 6 个二进制文件 → ./bin/ +make cross-build + +# 指定版本号 +make cross-build VERSION=v1.2.3 + +# 只构建指定系统(两种架构均会构建) +make cross-build GOOS=linux +make cross-build GOOS=darwin +make cross-build GOOS=windows + +# 只构建指定架构(所有系统均会构建) +make cross-build GOARCH=amd64 +make cross-build GOARCH=arm64 + +# 同时指定系统和架构 — 只生成单个文件 +make cross-build GOOS=linux GOARCH=arm64 +make cross-build GOOS=darwin GOARCH=amd64 VERSION=v1.2.3 +``` + +输出到 `./bin/` 目录: + +| 文件名 | 平台 | +|--------|------| +| `wavelet_linux_amd64` | Linux x86-64 | +| `wavelet_linux_arm64` | Linux ARM64 | +| `wavelet_darwin_amd64` | macOS Intel | +| `wavelet_darwin_arm64` | macOS Apple Silicon | +| `wavelet_windows_amd64.exe` | Windows x86-64 | +| `wavelet_windows_arm64.exe` | Windows ARM64 | + +> 版本号可通过 `wavelet --version` 在运行时查看。 + +### Docker + +```bash +# 构建镜像 +docker build -t refreshing . + +# 运行(通过卷挂载传入配置文件) +docker run -d -p 8000:8000 \ + -v $(pwd)/config.yaml:/app/config.yaml \ + refreshing api +``` + +### 生产环境 + +1. 构建前端资源: + ```bash + cd frontend && pnpm build + ``` + +2. 编译后端程序: + ```bash + go build -o refreshing main.go + ``` + +3. 配置生产环境的 `config.yaml`。 + +4. 启动服务: + ```bash + ./refreshing api # HTTP API + ./refreshing scheduler # 定时调度器(可选) + ./refreshing worker # 任务工作进程(可选) + ``` + +## 🤝 贡献指南 + +我们欢迎社区贡献!请在提交代码前阅读以下文档: + +- [贡献指南](CONTRIBUTING.md) +- [行为准则](CODE_OF_CONDUCT.md) +- [贡献者许可协议](CLA.md) + +### 贡献流程 + +1. Fork 本仓库 +2. 创建特性分支 (`git checkout -b feature/your-feature`) +3. 提交更改 (`git commit -am 'Add your feature'`) +4. 推送到分支 (`git push origin feature/your-feature`) +5. 创建 Pull Request + +## 📄 许可证 + +本项目基于 [Apache 2.0 许可证](LICENSE) 开源。 diff --git a/backend/downstream/README.md b/backend/downstream/README.md new file mode 100644 index 00000000..2c6a0925 --- /dev/null +++ b/backend/downstream/README.md @@ -0,0 +1,77 @@ +# Downstream Custom Plugins + +This directory is the designated location for downstream (deployment-specific) Cordis plugins. + +## Architecture + +``` +downstream/ +├── README.md +└── plugins/ + └── custom_example/ # Example plugin — copy & rename to get started + └── plugin.go +``` + +Downstream plugins follow the same `core.Plugin` contract as platform plugins: + +```go +type Plugin interface { + Name() string + Apply(ctx *core.Context) error +} +``` + +## Rules + +1. **Naming**: Each plugin directory name becomes its import path and plugin ID (kebab-case recommended). +2. **Dependencies**: Downstream plugins may import `core/`, `core/contracts/`, `pkg/`, and `plugins/infra/` packages from the platform. They MUST NOT import domain plugin internal packages — use `core.Inject[contracts.XxxService](ctx)` instead. +3. **Registration**: Add your downstream plugin to `cmd/app.go` before the platform plugins or after, depending on which services it needs: + ```go + // newWaveletApp in cmd/app.go + app.Use( + database.New(), + cache.New(), + logger.New(), + storage.New(), + // ... platform domain plugins ... + custom_hello.New(), // your downstream plugin + driver_http.New(), + driver_asynq_worker.New(), + driver_asynq_cron.New(), + ) + ``` +4. **Migration**: If your plugin needs database tables, embed SQL files in a `migrations/` directory and register via `ctx.Migrations().Register(...)` in `Apply()`. + +## Quick Start + +```go +package custom_example + +import ( + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/gin-gonic/gin" +) + +type Plugin struct{} + +func New() *Plugin { return &Plugin{} } + +func (p *Plugin) Name() string { return "custom_example" } + +func (p *Plugin) Apply(ctx *core.Context) error { + // Example: register a route that uses AuthService + var authSvc contracts.AuthService + if err := ctx.Using(func(svc contracts.AuthService) { authSvc = svc }); err != nil { + return err + } + + g := ctx.Router().Group("/api/v1/custom", authSvc.RequireAuthMiddleware().(gin.HandlerFunc)) + g.GET("/hello", func(c *gin.Context) { + user, _ := authSvc.GetCurrentUser(c.Request.Context()) + c.JSON(200, gin.H{"message": "Hello " + user.Username}) + }) + + return nil +} +``` \ No newline at end of file diff --git a/backend/downstream/plugins/custom_example/plugin.go b/backend/downstream/plugins/custom_example/plugin.go new file mode 100644 index 00000000..103842b6 --- /dev/null +++ b/backend/downstream/plugins/custom_example/plugin.go @@ -0,0 +1,50 @@ +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package custom_example demonstrates how to build a downstream Cordis plugin. +// Copy this directory to create your own plugin. +package custom_example + +import ( + "Wavelet/core" + "Wavelet/core/contracts" + "net/http" + + "github.com/gin-gonic/gin" +) + +// Plugin implements core.Plugin for the custom_example downstream plugin. +type Plugin struct{} + +// New creates a new custom_example plugin. +func New() *Plugin { + return &Plugin{} +} + +// Name returns the unique identifier for this plugin. +func (p *Plugin) Name() string { + return "custom_example" +} + +// Apply registers routes and services into the Cordis micro-kernel Context. +func (p *Plugin) Apply(ctx *core.Context) error { + // Resolve platform services via IoC container (no direct imports of domain plugins). + var authSvc contracts.AuthService + if err := core.Using[contracts.AuthService](ctx, func(svc contracts.AuthService) { authSvc = svc }); err != nil { + return err + } + _ = authSvc + + // Register routes using the auth middleware obtained through the contract. + g := ctx.Router().Group("/api/v1/custom", authSvc.RequireAuthMiddleware().(gin.HandlerFunc)) + g.GET("/hello", func(c *gin.Context) { + user, err := authSvc.GetCurrentUser(c.Request.Context()) + if err != nil { + c.JSON(http.StatusUnauthorized, gin.H{"error": "unauthorized"}) + return + } + c.JSON(http.StatusOK, gin.H{"message": "Hello " + user.Username}) + }) + + return nil +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 00000000..fae282c5 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,92 @@ +services: + wavelet: + build: + context: . + dockerfile: docker/Dockerfile + args: + VERSION: canary + image: ghcr.io/rain-kl/wavelet:canary + restart: unless-stopped + env_file: .env + environment: + TZ: ${TZ:-Asia/Shanghai} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-http://jaeger:4317} + OTEL_EXPORTER_OTLP_INSECURE: ${OTEL_EXPORTER_OTLP_INSECURE:-true} + OTEL_SAMPLING_RATE: ${OTEL_SAMPLING_RATE:-1.0} + ports: + - "${APP_PORT:-8000}:8000" + volumes: + - ./data/uploads:/app/uploads + - ./data/sqlite:/app/data + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + jaeger: + condition: service_started + + postgres: + image: postgres:18-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-wavelet} + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres} + TZ: ${TZ:-Asia/Shanghai} + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - ./data/postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-wavelet}"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + redis: + image: valkey/valkey:8.0-alpine + restart: unless-stopped + command: ["valkey-server", "--appendonly", "yes"] + ports: + - "${REDIS_PORT:-6379}:6379" + healthcheck: + test: ["CMD", "valkey-cli", "ping"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 5s + + jaeger: + image: jaegertracing/jaeger:${JAEGER_VERSION:-2.19.0} + restart: unless-stopped + environment: + TZ: ${TZ:-Asia/Shanghai} + ports: + - "${JAEGER_UI_PORT:-16686}:16686" + - "${JAEGER_OTLP_GRPC_PORT:-4317}:4317" + - "${JAEGER_OTLP_HTTP_PORT:-4318}:4318" +# +# clickhouse: +# image: clickhouse/clickhouse-server:25.3-alpine +# restart: unless-stopped +# profiles: +# - clickhouse +# environment: +# CLICKHOUSE_DB: ${CLICKHOUSE_DB:-wavelet} +# CLICKHOUSE_USER: ${CLICKHOUSE_USER:-default} +# CLICKHOUSE_PASSWORD: ${CLICKHOUSE_PASSWORD:-123456} +# CLICKHOUSE_DEFAULT_ACCESS_MANAGEMENT: 1 +# TZ: ${TZ:-Asia/Shanghai} +# ports: +# - "${CLICKHOUSE_HTTP_PORT:-8123}:8123" +# - "${CLICKHOUSE_NATIVE_PORT:-9000}:9000" +# volumes: +# - ./data/clickhouse_data:/var/lib/clickhouse +# healthcheck: +# test: ["CMD", "clickhouse-client", "--query", "SELECT 1"] +# interval: 10s +# timeout: 5s +# retries: 5 +# start_period: 15s diff --git a/docs/DEPLOYMENT.md b/docs/DEPLOYMENT.md new file mode 100644 index 00000000..ff2ee328 --- /dev/null +++ b/docs/DEPLOYMENT.md @@ -0,0 +1,335 @@ +# wavelet 部署指南 + +本文档详细介绍了 **wavelet** 脚手架系统在不同业务阶段的部署方案,涵盖从**最小化单机部署**到**最大化高可用分布式部署**的全生命周期架构。 + +--- + +## 一、 系统组件概览 + +在部署系统前,请了解各运行组件及其角色: + +| 组件名称 | 运行命令/形式 | 职责说明 | 必选/可选 | +| :--- | :--- | :--- | :--- | +| **HTTP API 服务** | `bin/wavelet api` | 接收并处理前端及第三方的 RESTful API 请求 | **必选** | +| **异步任务工作进程** | `bin/wavelet worker` | 消费并处理异步队列任务(如邮件发送、清理上传文件等) | **必选** | +| **定时任务调度器** | `bin/wavelet scheduler` | 定时向 Redis 队列下发 Cron 任务(仅负责触发,不负责执行) | **必选** | +| **前端服务 (Node.js)** | `pnpm start` | 提供 React/Next.js 页面服务(在分离部署时使用) | 分离模式必选 | +| **PostgreSQL** | 关系型主数据库 | 存储用户、系统配置、认证源、任务执行记录等核心数据 | **必选** | +| **Redis** | 缓存与消息队列中间件 | 存储 Session 会话、临时缓存以及 Asynq 异步任务队列数据 | **必选** | +| **ClickHouse** | 分析型数据库 | 可选的日志主库;关闭时访问审计由 PostgreSQL/SQLite 承接 | 可选 | +| **对象存储 (S3)** | 兼容 S3 的云存储/私有云 | 存放用户上传的静态文件、图片等 | 可选 | + +--- + +## 二、 部署配置准备 + +系统在启动前会从当前目录加载 `config.yaml` 配置文件。 +生产环境部署前,请复制 `config.example.yaml` 为 `config.yaml`,并至少确认以下关键参数的配置: + +```yaml +app: + env: "production" # 生产环境标识 + addr: ":8000" # API 服务监听端口 + session_secret: "prod-random-secret" # 极其重要的加密密钥,首发启动后不可更改 + session_domain: ".yourdomain.com" # 跨域共享 Session 时需配置 + +database: + host: "db.yourdomain.com" + port: 5432 + username: "postgres" + password: "YOUR_DB_PASSWORD" + database: "refreshing" + +redis: + addrs: + - "redis.yourdomain.com:6379" + password: "YOUR_REDIS_PASSWORD" +``` + +--- + +## 三、 方案一:最小部署 — 单机嵌入式极简版 (推荐) + +此部署方案将**前端静态网页全部直接打入 Go 后端二进制文件中**,极大地简化了部署运维,是中小型应用、内部系统、SaaS 早期阶段的首选。 + +### 📊 架构设计 +- **服务载体**:单台云服务器 (1核2G 即可)。 +- **依赖服务**:在一台机器上启动轻量级 PostgreSQL 与 Redis(可采用 Docker 部署)。 +- **进程管理**:在一台机器上直接拉起打包好的 Go 单文件,并分别运行 `api`、`worker`、`scheduler` 进程。 +- **前端托管**:Go 服务直接在 8000 端口承载前端的所有页面,不需要额外配置 Node.js 生产服务器。 + +### 🛠️ 步骤说明 + +#### 1. 单机依赖服务初始化 (使用 Docker Compose) +在机器上准备以下 `docker-compose.yml` 快速启动 PostgreSQL 和 Redis: +```yaml +version: '3.8' +services: + postgres: + image: postgres:15-alpine + container_name: refreshing-db + environment: + POSTGRES_USER: postgres + POSTGRES_PASSWORD: YOUR_DB_PASSWORD + POSTGRES_DB: refreshing + ports: + - "5432:5432" + volumes: + - ./data/pg:/var/lib/postgresql/data + restart: always + + redis: + image: valkey/valkey:8.0-alpine + container_name: refreshing-redis + command: valkey-server --requirepass YOUR_REDIS_PASSWORD + ports: + - "6379:6379" + volumes: + - ./data/redis:/data + restart: always +``` +执行命令启动: +```bash +docker compose up -d +``` + +#### 2. 前后端一键嵌入式打包 +在开发或编译机上,运行编译指令: +```bash +make build-embedded +``` +该命令会自动完成前端的静态编译导出 (`frontend/out`)、复制到 Go 后端目录,最后使用 `-tags embed_frontend` 生成后端单文件: +- 产物路径:`bin/wavelet` + +#### 3. 进程管理 (使用 Systemd) +将 `bin/wavelet` 拷贝到生产服务器 `/usr/local/bin/wavelet`,并为 `api`、`worker` 和 `scheduler` 配置 Systemd 管理服务。 + +新建 API 进程服务文件 `/etc/systemd/system/wavelet-api.service`: +```ini +[Unit] +Description=Refreshing API Service +After=network.target + +[Service] +Type=simple +User=root +WorkingDirectory=/app +ExecStart=/usr/local/bin/wavelet api +Restart=always +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` +同理,新建 Worker 服务 `/etc/systemd/system/wavelet-worker.service`(将命令改为 `wavelet worker`),以及 Scheduler 服务 `/etc/systemd/system/wavelet-scheduler.service`(将命令改为 `wavelet scheduler`)。 + +启动并启用所有服务: +```bash +systemctl daemon-reload +systemctl enable --now refreshing-api refreshing-worker refreshing-scheduler +``` + +#### 4. 配置 Nginx 证书 +配置 Nginx 作为反向代理并启用 HTTPS 证书: +```nginx +server { + listen 80; + server_name yourdomain.com; + return 301 https://$host$request_uri; +} + +server { + listen 443 ssl http2; + server_name yourdomain.com; + + ssl_certificate /path/to/cert.crt; + ssl_certificate_key /path/to/cert.key; + + location / { + proxy_pass http://127.0.0.1:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } +} +``` + +--- + +## 四、 方案二:标准部署 — 前后端物理分离架构 + +此方案中前端与后端彻底解耦。前端采用 SSR/ISR (Next.js Node 服务) 运行,后端采用独立的 API 服务运行。 + +### 📊 架构设计 +- **前端部署**:单独部署到 Node.js 托管环境(如多台前端机器或 Vercel/Cloudflare Pages)。 +- **后端部署**:多台后端云服务器,统一指向云数据库 RDS 与云缓存 Redis。 +- **通信方式**:前后端通过 Nginx 规则路由或独立域名(如 `app.yourdomain.com` 访问前端,`api.yourdomain.com` 访问后端)进行跨域通信。 + +### 🛠️ 步骤说明 + +#### 1. 部署后端 Go 服务 +1. 编译后端: + ```bash + go build -o bin/wavelet main.go + ``` +2. 在后端服务器上,同样使用 Systemd 或 Docker 守护启动 `wavelet api`、`wavelet worker` 和 `wavelet scheduler`。 +3. 配置后端 Nginx 将客户端 API 请求(如 `/api/...`)反向代理至后端绑定的端口(如 `:8000`)。 + +#### 2. 部署前端 Next.js 服务 +1. 前端服务器环境确保已安装 Node.js 和 pnpm。 +2. 安装依赖并编译生产版本: + ```bash + cd frontend + pnpm install + pnpm build + ``` +3. 使用 PM2 守护前端 Node.js 服务运行。新建 `ecosystem.config.js`: + ```javascript + module.exports = { + apps: [ + { + name: 'refreshing-frontend', + script: 'node_modules/next/dist/bin/next', + args: 'start -p 3000', + instances: 'max', + exec_mode: 'cluster', + env: { + NODE_ENV: 'production', + WAVELET_BACKEND_URL: 'https://api.yourdomain.com' + } + } + ] + }; + ``` + 启动前端服务: + ```bash + pm2 start ecosystem.config.js + ``` + +#### 3. 跨域与 Cookie 说明 +- 若前后端使用**不同子域名**部署(例如 `app.yourdomain.com` 和 `api.yourdomain.com`),必须在 `config.yaml` 中将 `app.session_domain` 显式设置为顶级域名(`.yourdomain.com`),以确保 Session Cookie 可以在子域间顺利透传。 +- 在跨域状态下,前端请求必须配置 `withCredentials: true`,API 端的跨域中间件(`corsMiddleware`)会自动将该域添加至允许源中。 + +--- + +## 五、 方案三:最大部署 — 企业级高可用分布式架构 (Max) + +当系统面临高并发流量、海量后台任务或极高的可用性要求时,需要将所有组件拆分为无状态水平扩容,并引入高可用的云基础设施。 + +### 📊 架构设计图 +``` + ┌────────────────────────┐ + │ 域名 / 负载均衡器 │ + │ (SLB / Cloudflare) │ + └──────────┬─────────────┘ + │ + ┌──────────────────┴──────────────────┐ + ▼ ▼ + ┌─────────────────────┐ ┌─────────────────────┐ + │ 前端集群 │ │ 后端 API 集群 │ + │ (Next.js Node) │ │ (Go 无状态实例) │ + │ [弹性扩容 / 8台+] │ │ [弹性扩容 / 8台+] │ + └─────────────────────┘ └──────────┬──────────┘ + │ + ┌────────────────────────────────────────┼────────────────────────────────────────┐ + ▼ ▼ ▼ + ┌───────────────────┐ ┌───────────────────┐ ┌───────────────────┐ + │ 异步 Worker 集群 │ │ 定时 Scheduler │ │ S3 对象存储集群 │ + │ (多节点并发处理) │ │ (主备模式,限单节点)│ │(R2/MinIO/AWS S3) │ + └─────────┬─────────┘ └─────────┬─────────┘ └───────────────────┘ + │ │ + └───────────────────┬────────────────────┘ + │ + ┌───────────────────┴────────────────────┐ + ▼ ▼ + ┌───────────────────────────────────┐ ┌───────────────────────────────────┐ + │ Redis 哨兵/集群 │ │ PG 主从读写分离集群 │ + │ (高可用缓存/Asynq 队列) │ │ (RDS Primary-Replica) │ + └───────────────────────────────────┘ └───────────────────────────────────┘ +``` + +### ⚙️ 最大部署配置要点 + +#### 1. 数据库高可用 (主从读写分离) +在 `config.yaml` 中配置 `database` 的主库写与从库读: +```yaml +database: + enabled: true + host: "pg-primary.yourdomain.com" # 主库地址(写) + port: 5432 + username: "postgres" + password: "YOUR_DB_PASSWORD" + database: "refreshing" + # 配置读写分离只读副本(GORM 自动轮询读,支持配置多个从库) + replicas: + - host: "pg-replica-1.yourdomain.com" + port: 5432 + username: "postgres" + password: "YOUR_DB_PASSWORD" + - host: "pg-replica-2.yourdomain.com" + port: 5432 + username: "postgres" + password: "YOUR_DB_PASSWORD" +``` + +#### 2. Redis 高可用 (哨兵/Sentinel 或集群) +- **Sentinel 哨兵模式**:通过配置 `redis.master_name` 启用,SDK 会自动监视 Master 的主备切换。 +- **Cluster 集群模式**:将 `redis.cluster_mode` 设为 `true`,并提供所有集群节点的 `addrs`。 +```yaml +redis: + addrs: + - "redis-node-1.yourdomain.com:6379" + - "redis-node-2.yourdomain.com:6379" + - "redis-node-3.yourdomain.com:6379" + cluster_mode: true +``` + +#### 3. 对象存储与缓存分离 (S3 + Local Cache) +高可用集群下,本地文件系统不再可共享。文件存储必须启用 S3 兼容服务,并在多节点间开启本地高速磁盘缓存加速读取: +```yaml +s3: + enabled: true + endpoint: "https://your-r2-or-s3-id.r2.cloudflarestorage.com" + region: "auto" + bucket: "refreshing-assets" + access_key_id: "YOUR_S3_KEY" + secret_access_key: "YOUR_S3_SECRET" + local_cache: + enabled: true # 开启本地磁盘缓存 + cache_dir: "/data/s3_cache" # 本地高性能 SSD 挂载点 +``` + +#### 4. 后端进程横向拆分部署 +- **API 集群**:启动数十个甚至上百个 `wavelet api` 无状态容器。它们可以通过负载均衡器直接挂载,支持随时弹性缩容扩容。 +- **Worker 集群**:启动多个 `wavelet worker` 容器。因为 `Asynq` 基于 Redis 分布式处理,多个 Worker 进程可以安全地同时运行并竞抢同一队列的异步任务,自动保障任务的并发吞吐能力。 +- **Scheduler 独占**:**【注意】** 为避免重复触发定时 Cron 任务,`wavelet scheduler` 定时调度器进程**同一时间应仅运行单个活跃实例**(主备高可用可以通过容器平台的单实例保障或 K8s Job 机制来限制实例数为 1)。 + +#### 5. ClickHouse 高并发同步 +访问审计等日志表默认写在当前业务主库。数据量大、需要列式扫描时,开启 ClickHouse,再在任务管理运行「切换日志数据库」迁到 ClickHouse(迁移期间冻结写入,源数据不删)。开发约定见 [日志用途表](./LOGSTORE.md)。 +```yaml +clickhouse: + enabled: true + hosts: + - "ch-node-1.yourdomain.com:9000" + - "ch-node-2.yourdomain.com:9000" +``` + +#### 6. OpenTelemetry 分布式链路追踪 +最大部署架构必须引入链路追踪(Jaeger 或 OTel Collector)以便排查节点间请求延迟或网络问题。 +在生产环境,通过配置 OTel 将 Span 发送至公共日志分析平台。 +```yaml +otel: + sampling_rate: 0.05 # 开启 5% 的流量追踪采样率以减少开销 +``` + +--- + +## 六、 部署方案对比与选择建议 + +| 指标维度 | 方案一:最小单机嵌入版 | 方案二:标准前后端分离版 | 方案三:最大高可用分布式版 | +| :--- | :--- | :--- | :--- | +| **支持流量/并发** | 1,000 ~ 5,000 QPS (视机器性能) | 5,000 ~ 20,000 QPS | 20,000 ~ 100,000+ QPS (无限扩展) | +| **服务器数量** | 1 台 | 3 ~ 5 台 | 10 台以上集群 | +| **运维复杂度** | 极简 (只需部署一个程序) | 中等 (需维护 Node 和 Go 两套环境) | 较高 (K8s/多组件集群维护) | +| **适合场景** | 个人项目、内部系统、SaaS 早期起步 | 正常线上运营项目、有中等规模团队 | 大型企业级应用、高并发核心交易系统 | diff --git a/docs/LOGSTORE.md b/docs/LOGSTORE.md new file mode 100644 index 00000000..caf8daa6 --- /dev/null +++ b/docs/LOGSTORE.md @@ -0,0 +1,45 @@ +# 日志用途表 + +Wavelet 的访问审计等日志表不绑死 ClickHouse。`internal/repository/logstore` 按 `log_database` 在 PostgreSQL / SQLite / ClickHouse 之间切换;关闭 ClickHouse 时由当前业务主库承接写入、查询与清理。 + +逐步落地步骤见 `.agents/skills/logstore/SKILL.md`。本文只约定判定、分层与切换协议。 + +## 什么算日志表 + +同时满足才进 logstore: + +- 追加写入,几乎不更新单行 +- 按时间查询或聚合,允许按保留天数删除 +- 关闭 ClickHouse 后仍要能写、能查 +- 不参与用户 / 配置 / 任务等事务一致性 + +用户、系统配置、任务执行、上传元数据走业务主库 `repository`,不要塞进 logstore。 + +当前已接入:`w_user_access_logs`(管理端 API 访问审计),接口 `UserAccessLogStore`。 + +## 分层 + +| 层级 | 路径 | 职责 | +| :--- | :--- | :--- | +| 抽象 | `internal/repository/logstore` | 接口 + `Active` / `BuildForMigration`;apps 只面向这里 | +| CH 实现 | `logstore` 委托 `repository/analytics` | 原生批量与现有查询 | +| 主库实现 | `logstore` GORM | PostgreSQL 按月分区;SQLite 普通表 | +| 入队 | `risk_control` + `batchwriter` | `FlushFunc` → `logstore.Active` | +| 切换 | `logs:db_switch` | 冻结 → 排空 → 复制 → 翻转 | +| 清理 | `logstore.CleanupExpired` | `system:cleanup` 按库读 `log_retention_days_*`:PG 先 `DropExpiredPartitions` 再 `DeleteBefore`,最后 `DropEmptyPartitions` | + +`log_database` 只能是「随业务主库」或 `clickhouse`。`log_database` / `log_db_migration` 受保护,管理端不可改。 + +## 切换协议 + +1. 校验 `target` 合法且不等于当前库。 +2. 写 `log_db_migration=migrating`,`Drain` 在途队列(不要 `Stop` writer);写入返回明确错误,不排队。 +3. 清空目标表后按 id 分页复制;PostgreSQL 目标先 `EnsurePartitions`。 +4. 全部成功才翻转 `log_database`;失败清标记,写入继续走源库。 +5. 源数据不删。 + +不要另起切换协议,也不要在任务或 Handler 里直连 `analyticsrepo` / `db.ChConn`。 + +## 新增一张日志表 + +必须同时提供 ClickHouse / PostgreSQL / SQLite 三套 goose,列名一致。接口至少包含 `BatchInsert`、业务查询、`ListForMigration` / `MigrationRange` / `DeleteAll` / `EnsurePartitions`、`DeleteBefore`。`FlushFunc` 调 `logstore.Active`。细节与禁止项见 `logstore` skill。 diff --git a/docs/WAVELET_DEVELOPER_GUIDE.md b/docs/WAVELET_DEVELOPER_GUIDE.md new file mode 100644 index 00000000..29c0b694 --- /dev/null +++ b/docs/WAVELET_DEVELOPER_GUIDE.md @@ -0,0 +1,1051 @@ +# Wavelet Cordis 插件化架构实战开发指南与标准规范 + +- **文档类型**: 下游开发者手册 / 架构实战指南 (Cookbook & Architecture Reference) +- **目标受众**: 官方插件开发者、下游业务二开工程师、架构师 +- **版本**: v1.0.0 (2026-08-27) + +--- + +# 目录 +- [第一部分:下游项目实战开发指南与 22 个高频开发场景解答](#第一部分下游项目实战开发指南与-22-个高频开发场景解答) + - [场景 1:插件必须要实现哪些方法与契约?](#场景-1插件必须要实现哪些方法与契约) + - [场景 2:插件间如何进行单向服务调用?](#场景-2插件间如何进行单向服务调用) + - [场景 3:插件间存在双向/循环调用时如何解决(杜绝 import cycle)?](#场景-3插件间存在双向循环调用时如何解决杜绝-import-cycle) + - [场景 4:如何开发并注册一个 HTTP API 接口?如何添加路由中间件?](#场景-4如何开发并注册一个-http-api-接口如何添加路由中间件) + - [场景 5:如何获取当前登录用户信息?](#场景-5如何获取当前登录用户信息) + - [场景 6:如何开发并注册一个 Asynq 异步 Worker 任务?](#场景-6如何开发并注册一个-asynq-异步-worker-任务) + - [场景 7:如何开发并注册一个 Cron 定时任务?](#场景-7如何开发并注册一个-cron-定时任务) + - [场景 8:数据库表结构如何声明?ORM 模型规范是什么?](#场景-8数据库表结构如何声明orm-模型规范是什么) + - [场景 9:数据库如何做独立迁移?Goose SQL 怎么组织?](#场景-9数据库如何做独立迁移goose-sql-怎么组织) + - [场景 10:如果有多个业务插件需要读写同一张表怎么办?](#场景-10如果有多个业务插件需要读写同一张表怎么办) + - [场景 11:如果跨插件操作多张表,如何确保事务一致性?](#场景-11如果跨插件操作多张表如何确保事务一致性) + - [场景 12:如何发布和订阅领域事件 (EventBus)?](#场景-12如何发布和订阅领域事件-eventbus) + - [场景 13:如何向系统注册插件自定义配置(config.yaml 与管理台热加载设置)?](#场景-13如何向系统注册插件自定义配置configyaml-与管理台热加载设置) + - [场景 14:如何使用多层缓存(RAM L1 + Redis L2 + PubSub 同步)?](#场景-14如何使用多层缓存ram-l1--redis-l2--pubsub-同步) + - [场景 15:如何使用分布式锁 (DistLock) 防止并发超卖与重复消费?](#场景-15如何使用分布式锁-distlock-防止并发超卖与重复消费) + - [场景 16:如何向管理后台动态注册监控数据与管理控制台?](#场景-16如何向管理后台动态注册监控数据与管理控制台) + - [场景 17:插件如何实现健康检查探针与就绪检查 (Health Check)?](#场景-17插件如何实现健康检查探针与就绪检查-health-check) + - [场景 18:插件如何扩展其他插件的能力(如新增一种 OAuth 登录提供商 / 新增消息推送渠道)?](#场景-18插件如何扩展其他插件的能力如新增一种-oauth-登录提供商--新增消息推送渠道) + - [场景 19:插件如何编写单元测试与集成测试(Mock 上下文与依赖打桩)?](#场景-19插件如何编写单元测试与集成测试mock-上下文与依赖打桩) + - [场景 20:以不同角色(api / worker / schedule / all)启动时,插件代码如何适配?](#场景-20以不同角色api--worker--schedule--all启动时插件代码如何适配) + - [场景 21:当某个插件流量暴增需要独立拆分为微服务时,如何零成本平滑改造?](#场景-21当某个插件流量暴增需要独立拆分为微服务时如何零成本平滑改造) + - [场景 22:插件如何安全处理文件上传与大文件摄取 (upload.Ingest)?](#场景-22插件如何安全处理文件上传与大文件摄取-uploadingest) +- [第二部分:整个项目的目录结构划分与包职责定义](#第二部分整个项目的目录结构划分与包职责定义) +- [第三部分:框架核心提供给插件调用的公用能力矩阵 (Context Capability Matrix)](#第三部分框架核心提供给插件调用的公用能力矩阵-context-capability-matrix) +- [第四部分:Cordis 插件分层开发规范与代码模板 (Plugin Layered Architecture & Code Templates)](#第四部分cordis-插件分层开发规范与代码模板-plugin-layered-architecture--code-templates) + - [1. 分型与选型策略 (模式 1 vs 模式 2)](#1-分型与选型策略-模式-1-vs-模式-2) + - [2. 模式 1:扁平自包含分层规范与完整代码模板](#2-模式-1扁平自包含分层规范与完整代码模板) + - [3. 模式 2:严格子包物理分层规范与完整代码模板](#3-模式-2严格子包物理分层规范与完整代码模板) + - [4. 各层核心职责边界与严格禁止防线 (Guardrails)](#4-各层核心职责边界与严格禁止防线-guardrails) + +--- + +# 第一部分:下游项目实战开发指南与 22 个高频开发场景解答 + +### 场景 1:插件必须要实现哪些方法与契约? +每个插件必须实现 `core.Plugin` 接口,仅需提供两个核心方法:`Name()` 与 `Apply(ctx *core.Context)`。 + +```go +package myplugin + +import "github.com/Rain-kl/Wavelet/core" + +type Plugin struct{} + +// 1. Name: 返回全局唯一的插件标识符(建议遵循命名空间规范,如 "biz.order") +func (p *Plugin) Name() string { + return "biz.order" +} + +// 2. Apply: 核心装载入口,所有的路由注册、任务注册、服务提供与依赖消费均在此完成 +func (p *Plugin) Apply(ctx *core.Context) error { + // 在此编写装载逻辑 + return nil +} +``` + +--- + +### 场景 2:插件间如何进行单向服务调用? +**规则**:插件之间**禁止直接相互 import 具体实现包**。调用方仅面向 `core/contracts` 中的纯 Interface 编程,运行时通过 Context 解析。 + +```go +// 1. 插件 A (提供者 plugins/user) 将服务注入 Context +func (p *UserPlugin) Apply(ctx *core.Context) error { + dbSvc, _ := core.Inject[contracts.DBService](ctx) + userSvc := NewUserServiceImpl(dbSvc) + core.Provide[contracts.UserService](ctx, userSvc) + return nil +} + +// 2. 插件 B (消费者 plugins/order) 声明依赖并调用 +func (p *OrderPlugin) Apply(ctx *core.Context) error { + return ctx.Using(func(userSvc contracts.UserService) { + // userSvc 已由容器自动注入就绪 + v1 := ctx.Router().Group("/api/v1/orders") + v1.POST("", func(c *gin.Context) { + userInfo, err := userSvc.GetUserProfile(c.Request.Context(), "user_123") + // 处理订单逻辑... + }) + }) +} +``` + +--- + +### 场景 3:插件间存在双向/循环调用时如何解决(杜绝 import cycle)? +**问题场景**:`auth` 登录成功后需要查 `user` 资料;`user` 重置密码后需要调 `auth` 吊销 session。若两个 package 互相 import,Go 编译器会报 `import cycle not allowed`。 + +**Cordis 解法**: +1. 接口均定义在 `core/contracts`,双方只依赖 `core/contracts`。 +2. 运行时采用 **延迟注入 (Lazy Resolution / Inject)** 或 **事件解耦 (EventBus)**: + +```go +// plugins/auth/service.go +func (s *AuthServiceImpl) OnLoginSuccess(c context.Context, uid string) { + // 延迟注入 UserService,不发生 package 级循环导入 + userSvc, err := core.Inject[contracts.UserService](s.ctx) + if err == nil { + userSvc.UpdateLastLoginTime(c, uid) + } +} +``` +*更加推荐的方式是发射领域事件*(见场景 12),由 `user` 插件自愿监听,彻底消除相互调用的硬依赖。 + +--- + +### 场景 4:如何开发并注册一个 HTTP API 接口?如何添加路由中间件与白名单? +插件通过 `ctx.Router()` 声明路由。微内核支持标准 Gin 路由组、中间件挂载与免鉴权白名单机制: + +```go +func (p *OrderPlugin) Apply(ctx *core.Context) error { + // 1. 如果插件包含无需登录的公开接口,主动注册到 Router 白名单(支持精确路径与通配符如 /api/v1/public/*) + ctx.Router().RegisterWhitelist( + "/api/v1/orders/public-status", + "/api/v1/orders/callback/*", + ) + + // 2. 获取全局或 auth 插件提供的鉴权中间件 + authSvc, _ := core.Inject[contracts.AuthService](ctx) + + // 3. 创建带版本前缀和鉴权中间件的路由组 + group := ctx.Router().Group("/api/v1/orders", authSvc.RequireAuthMiddleware()) + + // 4. 注册 Handler + group.GET("/public-status", p.handlePublicStatus) // 命中白名单,自动免鉴权放行 + group.GET("", p.handleListOrders) // 受保护接口,需登录鉴权 + group.POST("", p.handleCreateOrder) + group.GET("/:id", p.handleGetOrderDetail) + + return nil +} +``` +> 💡 **鉴权放行防线**:`auth` 插件提供的 `RequireAuthMiddleware()` 内部已接入白名单拦截器。所有注册到白名单的路由在经过鉴权中间件时均会自动放行,彻底杜绝免鉴权接口被全局或组级鉴权中间件误拦截(返回 401 Unauthorized)。 + +--- + +### 场景 5:如何获取当前登录用户信息? +`auth` 插件会在上下文中注入当前用户 Session。业务 Handler 可直接调用统一 Helper: + +```go +func (p *OrderPlugin) handleCreateOrder(c *gin.Context) { + // 1. 从当前 Gin 请求上下文中提取认证用户信息 + currentUser, ok := oauth.GetCurrentUser(c) + if !ok { + response.AbortUnauthorized(c, errs.ErrUnauthorized) + return + } + + log.Printf("当前下单用户 ID: %s, 权限角色: %s", currentUser.ID, currentUser.Role) + // 2. 正常业务处理... +} +``` + +--- + +### 场景 6:如何开发并注册一个 Asynq 异步 Worker 任务? +```go +func (p *OrderPlugin) Apply(ctx *core.Context) error { + // 1. 注册 Asynq 任务类型与消费处理器 + ctx.Task().Register("order:cancel_timeout", p.handleTimeoutCancelTask) + return nil +} + +// 2. 任务执行函数 +func (p *OrderPlugin) handleTimeoutCancelTask(ctx context.Context, t *asynq.Task) error { + var payload OrderTimeoutPayload + if err := json.Unmarshal(t.Payload(), &payload); err != nil { + return err + } + // 执行超时关单业务逻辑... + return nil +} + +// 3. 业务中异步投递任务 +func (p *OrderPlugin) EnqueueTimeoutCheck(ctx context.Context, orderID string) { + p.ctx.TaskClient().EnqueueContext(ctx, asynq.NewTask("order:cancel_timeout", payloadBytes), asynq.ProcessIn(15*time.Minute)) +} +``` + +--- + +### 场景 7:如何开发并注册一个 Cron 定时任务? +```go +func (p *ReportPlugin) Apply(ctx *core.Context) error { + // 每天凌晨 2 点执行日报汇总任务 + ctx.Schedule().RegisterCron("0 2 * * *", "report:daily_summary", DailyReportPayload{Type: "all"}) + return nil +} +``` + +--- + +### 场景 8:数据库表结构如何声明?ORM 模型规范是什么? +**规范**: +1. 表名必须带有插件专有前缀(如 `w_order_`、`w_auth_`),避免跨插件表名冲突。 +2. 零值与数据库默认值严格对齐;禁止物理外键,显式建索引。 +3. 必须通过 GORM 结构体清晰声明 `gorm:"..."` 标签与 `json:"..."`。 + +```go +package models + +import "time" + +type Order struct { + ID string `gorm:"column:id;primaryKey;size:64" json:"id"` + UserID string `gorm:"column:user_id;index;size:64;not null" json:"user_id"` + Amount int64 `gorm:"column:amount;not null" json:"amount"` + Status string `gorm:"column:status;size:32;index;not null;default:'pending'" json:"status"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` + DeletedAt *time.Time `gorm:"column:deleted_at;index" json:"-"` +} + +func (Order) TableName() string { + return "w_orders" +} +``` + +--- + +### 场景 9:数据库如何做独立迁移?Goose SQL 怎么组织? +**彻底告别集中大迁移目录**。每个插件在内部目录建立 `migrations/`,并通过 `//go:embed` 打包注入: + +```go +// plugins/order/plugin.go +package order + +import ( + "embed" + "github.com/Rain-kl/Wavelet/core" +) + +//go:embed migrations/*.sql +var orderMigrations embed.FS + +func (p *Plugin) Apply(ctx *core.Context) error { + // 注册本插件的专属迁移(系统启动时自动按版本号执行) + ctx.Migrations().Register("order", orderMigrations) + return nil +} +``` + +#### SQL 迁移脚本规范 (`plugins/order/migrations/00001_initial.sql`): + +每个插件只需维护一个 `00001_initial.sql`,包含该插件的全部建表语句与种子数据。 + +```sql +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_orders ( + id VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(64) NOT NULL, + amount BIGINT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_orders_user_id ON w_orders(user_id); + +-- 种子数据(使用 ON CONFLICT DO NOTHING 保证幂等) +INSERT INTO w_orders (id, user_id, amount, status, created_at, updated_at) +VALUES ('init_001', 'system', 0, 'completed', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (id) DO NOTHING; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS w_orders; +-- +goose StatementEnd +``` + +#### 版本管理机制 + +所有插件共享一张 `w_schema_versions` 表,以 `plugin_id` 区分: + +``` +w_schema_versions (plugin_id, version_id, applied_at) +``` + +启动时,引擎遍历每个插件: +1. 查询 `w_schema_versions WHERE plugin_id = 'order'` 获取当前最大版本号 +2. 扫描插件 `migrations/` 目录下的 `.sql` 文件 +3. 如果存在未应用的版本号 → 执行迁移 +4. 如果全部已应用 → 跳过 + +```sql +-- 查看全局迁移状态 +SELECT * FROM w_schema_versions ORDER BY plugin_id, version_id; +``` + +--- + +### 场景 10:如果有多个业务插件需要读写同一张表怎么办? +**黄金准则**:**表有且仅有一个所有者插件 (Single Owner Principle)**。 +* 严禁插件 B 直接通过 SQL 修改插件 A 拥有的核心表(如订单插件直接修改用户表)。 +* **合法模式 1(服务调用)**:插件 A 提供 `UserService.DeductBalance(uid, amount)`,插件 B 调用该接口。 +* **合法模式 2(只读视图 / 共享查询 DTO)**:如果仅仅是高频联合查询(报表),插件 A 暴露只读查询接口,或通过数据库只读从库直接投影。 + +--- + +### 场景 11:如果跨插件操作多张表,如何确保事务一致性? +在插件化和微服务就绪体系下,**跨插件的强分布式事务是反模式**。 + +1. **同插件内多表操作**:直接使用本地数据库事务: + ```go + err := ctx.DB().Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&order).Error; err != nil { return err } + if err := tx.Create(&orderItem).Error; err != nil { return err } + return nil + }) + ``` +2. **跨插件操作(如创建订单 + 扣减库存 + 发送通知)**: + * 采用 **最终一致性 (Eventual Consistency / Saga 模式)**。 + * 本地事务成功后,发射 `OrderCreatedEvent` 到 EventBus; + * 库存插件监听到事件后扣减库存,若失败则发布补偿事件触发订单取消。 + +--- + +### 场景 12:如何发布和订阅领域事件 (EventBus)? +Wavelet 完整实现了 Cordis 架构的 **4 种类型化事件分发语义**,支持同步管道、并发聚合与异步广播: + +| 分发方法 | 分发语义 | 返回值/错误处理 | 适用场景 | +| :--- | :--- | :--- | :--- | +| `ctx.Events().Emit(ctx, topic, payload)` | **异步广播 (Broadcast)** | 不阻塞主流程,静默恢复 handler panic | 状态变更广播、审计日志记录、跨插件解耦通知 | +| `ctx.Events().Waterfall(ctx, topic, initial)` | **流式管道 (Waterfall)** | 依次将前一个 handler 返回值传给下一个,遇错立即短路退出 | 参数过滤拦截链、内容审查、数据清洗与加工 | +| `ctx.Events().Parallel(ctx, topic, payload)` | **并发聚合 (Parallel)** | 并发启动 goroutine 执行所有 handler,用 `errors.Join` 聚合所有错误 | 并发外部系统推送、多渠道并行通知校验 | +| `ctx.Events().Serial(ctx, topic, payload)` | **串行执行 (Serial)** | 按注册顺序依次同步执行,遇到首个非 nil 错误立即短路中断 | 敏感操作前置拦截(如权限/风控准入校验) | + +#### 1. 异步广播 (`Emit`) 与订阅 (`On`) +```go +// 1. 定义强类型事件结构 +type OrderPaidEvent struct { + OrderID string `json:"order_id"` + UserID string `json:"user_id"` + PayAmount int64 `json:"pay_amount"` +} + +// 2. 插件 A 发布事件(广播) +ctx.Events().Emit(ctx, "order:paid", OrderPaidEvent{OrderID: "ord_1", UserID: "u_1", PayAmount: 9900}) + +// 3. 插件 B 订阅事件 +ctx.Events().On("order:paid", func(c context.Context, e OrderPaidEvent) error { + log.Printf("收到支付成功事件,开始为用户 %s 发放权益", e.UserID) + return nil +}) +``` + +#### 2. 流式管道变换 (`Waterfall`) +Handler 支持返回 `(T, error)` 或 `T`,后续 Handler 接收上一 Handler 的返回值: +```go +// 插件注册拦截处理 +ctx.Events().On("content:filter", func(c context.Context, text string) (string, error) { + return strings.ReplaceAll(text, "敏感词", "***"), nil +}) + +// 调用方通过 Waterfall 获得流式处理后的结果 +cleaned, err := ctx.Events().Waterfall(ctx, "content:filter", "原始文本包含敏感词") +// cleaned == "原始文本包含***" +``` + +#### 3. 串行准入拦截 (`Serial`) +```go +// 风控插件注册校验 +ctx.Events().On("order:pre_create", func(c context.Context, req CreateOrderRequest) error { + if isBlacklisted(req.UserID) { + return errors.New("用户处于风控黑名单,禁止下单") + } + return nil +}) + +// 订单插件执行准入链,遇到首个错误立即中断并返回 +if err := ctx.Events().Serial(ctx, "order:pre_create", req); err != nil { + return err // 拦截创建 +} +``` + +--- + +### 场景 13:如何向系统注册插件自定义配置(config.yaml 与管理台热加载设置)? +```go +type OrderConfig struct { + MaxItemsPerOrder int `yaml:"max_items" json:"max_items"` + AutoCancelMins int `yaml:"auto_cancel_mins" json:"auto_cancel_mins"` +} + +func (p *OrderPlugin) Apply(ctx *core.Context) error { + var cfg OrderConfig + // 1. 自动从 config.yaml 中的 plugins.order 节点绑定配置 + ctx.Config().Bind("plugins.order", &cfg) + + // 2. 注册为管理台可动态修改的系统参数 + ctx.Settings().Register(core.SettingSchema{ + Key: "order.auto_cancel_mins", + Default: 15, + Description: "未支付订单自动取消时间 (分钟)", + }) + return nil +} +``` + +--- + +### 场景 14:如何使用多层缓存(RAM L1 + Redis L2 + PubSub 同步)? +框架提供三层穿透缓存能力,防止缓存击穿与雪崩: + +```go +func (s *OrderService) GetOrderWithCache(ctx context.Context, orderID string) (*Order, error) { + var order Order + err := s.ctx.Cache().GetOrSet(ctx, "order:"+orderID, &order, 10*time.Minute, func() (any, error) { + // Cache Miss 回源查 DB + var dbOrder Order + if err := s.db.WithContext(ctx).First(&dbOrder, "id = ?", orderID).Error; err != nil { + return nil, err + } + return &dbOrder, nil + }) + return &order, err +} + +// 当订单更新时,广播失效所有节点的 L1 内存缓存与 L2 Redis 缓存 +func (s *OrderService) InvalidateCache(ctx context.Context, orderID string) { + s.ctx.Cache().Delete(ctx, "order:"+orderID) +} +``` + +--- + +### 场景 15:如何使用分布式锁 (DistLock) 防止并发超卖与重复消费? +```go +func (s *OrderService) ProcessPayment(ctx context.Context, orderID string) error { + // 获取分布式锁,租期 5 秒 + unlock, err := s.ctx.DistLock().Lock(ctx, "lock:order:pay:"+orderID, 5*time.Second) + if err != nil { + return fmt.Errorf("当前订单正在处理中,请勿重复提交") + } + defer unlock() // 确保释放 + + // 执行扣款操作... + return nil +} +``` + +--- + +### 场景 16:如何向管理后台动态注册监控数据与管理控制台? +插件可以向管理后台扩展点注入自己的仪表盘指标和诊断探针: + +```go +func (p *OrderPlugin) Apply(ctx *core.Context) error { + ctx.Admin().RegisterMetric("order_count_today", func(c context.Context) any { + var count int64 + ctx.DB().Model(&models.Order{}).Where("created_at >= ?", todayStart()).Count(&count) + return count + }) + return nil +} +``` + +--- + +### 场景 17:插件如何实现健康检查探针与就绪检查 (Health Check)? +```go +func (p *PaymentPlugin) Apply(ctx *core.Context) error { + ctx.Health().RegisterProbe("payment_gateway", func(ctx context.Context) error { + // 测试第三方支付网关网络连通性 + return pingPaymentGateway(ctx) + }) + return nil +} +``` + +--- + +### 场景 18:插件如何扩展其他插件的能力(如新增一种 OAuth 登录提供商 / 新增消息推送渠道)? +采用 **注册表扩展点模式 (Registry Pattern)**: + +```go +// 1. 下游编写微信登录插件 plugins/oauth_wechat +func (p *WeChatOAuthPlugin) Apply(ctx *core.Context) error { + return ctx.Using(func(authRegistry contracts.AuthRegistry) { + // 向核心 auth 插件注入微信 OAuth 实现 + authRegistry.RegisterOAuthProvider("wechat", &WeChatProvider{...}) + }) +} +``` + +--- + +### 场景 19:插件如何编写单元测试与集成测试(Mock 上下文与依赖打桩)? +微内核提供轻量测试脚手架 `coretest`: + +```go +func TestOrderCreate(t *testing.T) { + // 1. 创建内存测试专用 Context + ctx := coretest.NewMockContext(t) + + // 2. Mock 依赖的 UserService + mockUserSvc := &MockUserService{ReturnUser: &contracts.UserDTO{ID: "u_1", Balance: 1000}} + ctx.Provide[contracts.UserService](mockUserSvc) + + // 3. 装载插件 + plugin := &OrderPlugin{} + require.NoError(t, plugin.Apply(ctx)) + + // 4. 发起 HTTP 接口测试 + w := ctx.PerformRequest("POST", "/api/v1/orders", `{"item_id":"item_1"}`) + assert.Equal(t, 200, w.Code) +} +``` + +--- + +### 场景 20:以不同角色(api / worker / schedule / all)启动时,插件代码如何适配? +**开发者无需做任何特殊处理**! +插件只需在一个 `Apply` 方法中把自己的路由、任务、调度全部注册进 `Context`。微内核调度器会根据运行命令自动按需激活对应的运行时驱动,不匹配的能力保持休眠。 + +--- + +### 场景 21:当某个插件流量暴增需要独立拆分为微服务时,如何零成本平滑改造? +```go +// 1. 之前单体模式:在 main.go 中加载本地实现 +app.Use(&auth.Plugin{}) // 进程内直接运行 + +// 2. 拆分为微服务后:只需将 main.go 替换为 gRPC 客户端代理插件! +app.Use(&auth_grpc_client.Plugin{RemoteAddr: "auth-service.prod:9000"}) + +// 3. 所有依赖 auth 的业务插件(如 order, user)业务代码 0 处修改! +``` + +--- + +### 场景 22:插件如何安全处理文件上传与大文件摄取 (upload.Ingest)? +**严格规则**:禁止插件自行直接写入对象存储底层 Bucket 或直连底层文件系统。统一走平台摄取服务: + +```go +func (p *OrderPlugin) handleUploadInvoice(c *gin.Context) { + fileHeader, _ := c.FormFile("file") + + // 使用平台统一摄取引擎(自动计算哈希、防重传、生成签名 URL 与入库追踪) + ingestResult, err := upload.IngestFormFile(c.Request.Context(), fileHeader, upload.IngestPolicy{ + AllowedTypes: []string{"image/png", "application/pdf"}, + MaxSizeBytes: 10 * 1024 * 1024, + }) + if err != nil { + response.AbortBadRequest(c, errs.ErrUploadFailed) + return + } + + c.JSON(200, response.OK(gin.H{"file_url": ingestResult.URL})) +} +``` + +--- + +# 第二部分:整个项目的目录结构划分与包职责定义 + +```text +Wavelet/ +├── cmd/ # CLI 命令分发与装配入口 +│ ├── root.go # Cobra 根命令 +│ ├── server.go # 综合启动器(支持 api/worker/schedule/all profile) +│ └── migrate.go # 数据库独立迁移命令行工具 +│ +├── core/ # 【微内核引擎 (Zero Business Logic)】 +│ ├── context.go # Context 上下文总线与 Fork 树 +│ ├── container.go # 基于泛型的 IoC 服务注册与解析器 +│ ├── events.go # 强类型领域事件总线 (EventBus) +│ ├── lifecycle.go # 启动/停止生命周期编排状态机 +│ ├── contracts/ # 【跨插件标准服务契约 (纯 Interface)】 +│ │ ├── auth.go # AuthService 契约 +│ │ ├── user.go # UserService 契约 +│ │ ├── cache.go # CacheService 契约 +│ │ └── database.go # DBService 契约 +│ └── extpoints/ # 扩展点定义 (Router, Task, Migration, Setting) +│ +├── plugins/ # 【官方标准插件库 (完全高内聚闭包)】 +│ ├── drivers/ # 运行时驱动插件 +│ │ ├── driver_http/ # Gin Web HTTP 驱动 +│ │ ├── driver_asynq_worker/ # Asynq Worker 并发消费驱动 +│ │ └── driver_asynq_cron/ # Asynq Cron 调度器驱动 +│ │ +│ ├── infra/ # 基础设施服务插件 +│ │ ├── database/ # GORM 多数据源与读写分离插件 +│ │ ├── cache/ # RAM + Redis + PubSub 缓存插件 +│ │ ├── logger/ # Zap + Otel 分布式链路追踪日志插件 +│ │ └── storage/ # S3 / OSS / Local 对象存储插件 +│ │ +│ └── domain/ # 业务领域能力插件 +│ ├── auth/ # OAuth / Session / Passkey 认证插件 +│ ├── user/ # 用户资料 / 权限 / 角色插件 +│ ├── message_gateway/ # Bot 网关 / 渠道推送插件 +│ ├── risk_control/ # 访问控制 / IP 限流 / 安全风控插件 +│ └── admin/ # 系统管理台与监控面板插件 +│ +└── downstream/ # 【下游二开项目模板与脚手架】 + ├── custom_plugins/ # 下游自定义业务插件 + ├── config.yaml # 声明启用的插件与配置文件 + └── main.go # 下游项目组合启动入口 +``` + +### 各层职责与禁止规则 (Guardrails): +1. **`core/`**: + - **职责**:纯抽象,提供 IoC、Context、EventBus 和 Lifecycle。 + - **严禁**:严禁 import 任何具体业务包,严禁 import `gin`、`gorm`、`asynq`。 +2. **`core/contracts/`**: + - **职责**:仅定义公开的 Go Interface 和公共 DTO。 + - **严禁**:严禁包含任何具体实现逻辑或 SQL 操作。 +3. **`plugins/`**: + - **职责**:所有业务逻辑和驱动实现的归宿。遵循标准分层架构(Layered Architecture / MVC 变体)。 + - **分层模式选型**: + - **模式 1(极简单文件分层,极简微型插件专用)**:单 package 内部仅各保留 1 个对应文件(`plugin.go`, `handlers.go`, `service.go`, `repository.go`, `models.go`, `errs.go`, `migrations/`)。仅适用于单一实体、极小代码量 (<500行) 的微型插件。 + - **模式 2(标准独立子包分层架构,官方推荐标准)**:按职责严格物理分包(`plugin.go`, `handler/`, `service/`, `repository/`, `model/`, `errs/`, `migrations/`)。**子包内文件以纯业务实体命名(如 `user.go`、`config.go`),严禁在根包平铺 `handlers_*`、`service_*`、`repository_*` 等前缀文件**。编译器级强约束 `handler -> service -> repository -> model` 单向依赖。 + - **严禁**:插件之间严禁跨包 import 内部私有代码,跨插件调用一律走 `contracts` 接口或 `EventBus`。 + +--- + +# 第三部分:框架核心提供给插件调用的公用能力矩阵 (Context Capability Matrix) + +每个插件在 `Apply(ctx *core.Context)` 时,都可以无缝调用微内核暴露的以下标准能力: + +| 扩展点/能力方法 | 返回类型 | 功能说明 | 适用场景 | +| :--- | :--- | :--- | :--- | +| `ctx.Router()` | `RouterExtension` | 声明 HTTP 路由、前缀分组、挂载中间件与免鉴权白名单注册(`RegisterWhitelist`, `IsWhitelisted`),支持 `Unregister` / `UnregisterByID` | 暴露 API 接口、公开免鉴权端点、Web 控制台 | +| `ctx.Task()` | `TaskExtension` | 注册 Asynq 异步任务消费处理器,支持 `Unregister` | 耗时后台任务、异步消息发送 | +| `ctx.Schedule()` | `ScheduleExtension`| 注册 Cron 定时调度任务,支持 `Unregister` | 定时报表统计、周期性清理 | +| `ctx.Migrations()` | `MigrationExtension`| 注册插件专属的 Goose SQL 迁移嵌入系统,支持 `Unregister` | 自建数据表、版本升级 | +| `ctx.Events()` | `EventBus` | 强类型领域事件总线(支持 `Emit`, `Waterfall`, `Parallel`, `Serial`) | 跨插件完全解耦通知与状态同步 | +| `ctx.Settings()` | `SettingExtension` | 声明动态可配置项(支持热更新),支持 `Unregister` | 业务参数配置、管理台可调节参数 | +| `ctx.Fork()` | `*Context` | 创建继承父级容器并隔离局部副作用的子上下文 | 局部 Fiber、请求域隔离 | +| `core.Provide[T]`| `void` | 向全局 IoC 容器注册强类型服务(自动挂载 `OnDispose` 逆操作) | 暴露自身能力给其他插件消费 | +| `core.Inject[T]` | `(T, error)` | 从全局 IoC 容器中按类型获取服务实例 | 消费其他插件暴露的服务 | +| `core.When[T]` | `void` | 响应式监听服务注入(当服务一旦就绪立即触发回调) | 解决插件装载时序竞争与延迟初始化 | +| `core.Has[T]` | `bool` | 判断指定服务类型当前是否已在容器中注册 | 探测环境能力与条件装载 | +| `ctx.Using(func(T))` | `error` | 响应式声明依赖,当服务就绪时执行回调 | 声明前置依赖关系 | +| `core.Inject[contracts.DBService]` | `(DBService, error)` | 获取受事务与 Trace 保护的数据库连接与 GORM 实例 | 数据持久化 CRUD | +| `core.Inject[contracts.CacheService]` | `(CacheService, error)` | 三层穿透缓存(RAM L1 + Redis L2 + PubSub 广播)| 高频读数据性能加速 | +| `core.Inject[contracts.StorageService]` | `(StorageService, error)` | 统一对象存储读写引擎 | 文件摄取、图片持久化 | +| `core.Inject[contracts.TaskService]` | `(TaskService, error)` | 后台任务下发、重试与调度管理契约 | 任务下发与定时调度管理 | +| `core.Inject[contracts.RiskControlService]` | `(RiskControlService, error)` | 访问日志查询、聚合分析与存储引擎管理契约 | 审计日志与安全分析 | + +--- + +# 第四部分:Cordis 插件分层开发规范与代码模板 (Plugin Layered Architecture & Code Templates) + +为了统一规范 Wavelet 所有官方插件与下游业务二开插件的研发质量,每个插件在内部遵循 **标准分层架构(Layered Architecture / MVC 变体)**。 + +## 1. 分型与选型策略 (模式 1 vs 模式 2) + +根据业务复杂度和规模采用不同的物理包组织方式: + +``` + ┌────────────────────────┐ + │ 插件分层模式选型策略 │ + └───────────┬────────────┘ + │ + ┌───────────────────────┴───────────────────────┐ + ▼ ▼ +【模式 1:极简单文件分层】 【模式 2:标准独立子包分层】 +适合:极简微型/Demo插件 (<500行) 适合:标准/中大型业务插件 (推荐标准) +结构:单 Package,每层仅对应 1 个同名文件 结构:严格分包 handler/, service/, repository/, model/ +禁令:严禁根目录平铺 handlers_* 等前缀文件 规范:子包内以业务实体命名 (如 user.go, order.go) +``` + +| 维度 | 模式 1:极简单文件分层 (Single-File Flat) | 模式 2:标准独立子包分层 (Standard Sub-packages) | +| :--- | :--- | :--- | +| **适用场景** | 极简微型插件、单一实体(仅用于小型工具/示例) | 标准业务插件、包含多实体/多接口(**官方推荐标准**) | +| **代码量规模** | 通常 < 500 行 | 通常 ≥ 500 行(如 `upload`, `auth`, `admin`, `order`) | +| **Go 包形态** | 单一 Go Package,各层级仅各 1 个同名文件 | 按职责严格物理子目录分包,编译级强约束单向依赖 | +| **命名禁令** | **严禁在根目录平铺 `handlers_*`、`service_*` 文件** | **子包内文件直接以业务命名(如 `user.go`),禁止带 `handler_*` 前缀** | + +--- + +## 2. 模式 1:极简单文件分层规范与完整代码模板 + +### 2.1 目录结构 +```text +backend/plugins/domain// +├── plugin.go # [Cordis 接入层] 实现 core.Plugin,负责 Apply 组装与扩展点注册 +├── handlers.go # [Handler 层] 单一文件:Gin API Handler +├── service.go # [Service 层] 单一文件:核心业务用例 +├── repository.go # [Repository 层] 单一文件:GORM / DB 操作 +├── models.go # [Model 层] 单一文件:实体与 DTO +├── errs.go # [Error 层] 单一文件:错误常量 +├── plugin_test.go # 插件级单元与集成测试 +└── migrations/ # Goose SQL 嵌入文件 (//go:embed) + └── 20260828000001_init_.sql +``` + +> ⚠️ **严禁规则**:当单一文件膨胀或需要拆分多个业务实体时,**严禁在根目录创建 `handlers_user.go`, `handlers_admin.go`, `service_user.go` 等前缀文件**,必须立即重构并迁移为 **模式 2(标准独立子包分层架构)**! + +### 2.2 核心代码模板 (模式 1) + +#### (1) `plugin.go` (插件入口与装配) +```go +package order + +import ( + "embed" + "reflect" + + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/gin-gonic/gin" +) + +//go:embed migrations/*.sql +var orderMigrations embed.FS + +const PluginName = "domain.order" + +type Plugin struct { + svc *OrderService +} + +func New() *Plugin { + return &Plugin{} +} + +func (p *Plugin) Name() string { + return PluginName +} + +func (p *Plugin) Inject() []reflect.Type { + return []reflect.Type{ + reflect.TypeFor[contracts.DBService](), + } +} + +func (p *Plugin) Apply(ctx *core.Context) error { + // 1. 注册专属数据库迁移 + ctx.Migrations().Register("order", orderMigrations) + + // 2. 初始化持久层与服务层 + repo := newOrderRepository(ctx) + p.svc = newOrderService(ctx, repo) + + // 3. 注册 HTTP 路由组 + authSvc, _ := core.Inject[contracts.AuthService](ctx) + group := ctx.Router().Group("/api/v1/orders") + if authSvc != nil { + group.Use(authSvc.RequireAuthMiddleware()) + } + { + group.POST("", p.handleCreateOrder) + group.GET("/:id", p.handleGetOrderDetail) + } + + return nil +} +``` + +#### (2) `handlers.go` (Controller 层) +```go +package order + +import ( + "net/http" + + "github.com/Rain-kl/Wavelet/pkg/oauth" + "github.com/Rain-kl/Wavelet/pkg/response" + "github.com/gin-gonic/gin" +) + +// @Summary 创建订单 +// @Description 创建新的用户订单 +// @Tags Order +// @Accept json +// @Produce json +// @Param request body CreateOrderRequest true "创建参数" +// @Success 200 {object} response.Envelope{data=OrderDTO} "成功" +// @Failure 400 {object} response.Envelope "参数错误" +// @Router /api/v1/orders [post] +func (p *Plugin) handleCreateOrder(c *gin.Context) { + var req CreateOrderRequest + if err := c.ShouldBindJSON(&req); err != nil { + response.AbortBadRequest(c, errBindParamsFailed) + return + } + + user, ok := oauth.GetCurrentUser(c) + if !ok { + response.AbortUnauthorized(c, errUnauthorized) + return + } + + order, err := p.svc.CreateOrder(c.Request.Context(), user.ID, req) + if err != nil { + response.AbortInternal(c, errCreateOrderFailed) + return + } + + c.JSON(http.StatusOK, response.OK(order)) +} +``` + +#### (3) `service.go` (Service 业务逻辑层) +```go +package order + +import ( + "context" + + "github.com/Rain-kl/Wavelet/core" +) + +type OrderService struct { + ctx *core.Context + repo *orderRepository +} + +func newOrderService(ctx *core.Context, repo *orderRepository) *OrderService { + return &OrderService{ctx: ctx, repo: repo} +} + +func (s *OrderService) CreateOrder(ctx context.Context, userID string, req CreateOrderRequest) (*OrderDTO, error) { + order := &OrderModel{ + UserID: userID, + Amount: req.Amount, + Status: "pending", + } + + if err := s.repo.Create(ctx, order); err != nil { + return nil, err + } + + // 发射领域事件 + s.ctx.Events().Emit(ctx, "order:created", OrderCreatedEvent{ + OrderID: order.ID, + UserID: order.UserID, + Amount: order.Amount, + }) + + return &OrderDTO{ + ID: order.ID, + Amount: order.Amount, + Status: order.Status, + }, nil +} +``` + +#### (4) `repository.go` (Repository 数据访问层) +```go +package order + +import ( + "context" + + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/pkg/util" + "gorm.io/gorm" +) + +type orderRepository struct { + ctx *core.Context +} + +func newOrderRepository(ctx *core.Context) *orderRepository { + return &orderRepository{ctx: ctx} +} + +func (r *orderRepository) getDB(ctx context.Context) *gorm.DB { + if dbSvc, err := core.Inject[contracts.DBService](r.ctx); err == nil && dbSvc != nil { + return dbSvc.GetDB().WithContext(ctx) + } + return nil +} + +func (r *orderRepository) Create(ctx context.Context, order *OrderModel) error { + return r.getDB(ctx).Create(order).Error +} + +func (r *orderRepository) SearchByKeyword(ctx context.Context, keyword string) ([]OrderModel, error) { + var list []OrderModel + // SQL LIKE 防注入与通配符转义规范 + safeKeyword := util.EscapeLike(keyword) + "%" + err := r.getDB(ctx).Where("status LIKE ? ESCAPE '\\'", safeKeyword).Find(&list).Error + return list, err +} +``` + +#### (5) `models.go` 与 `errs.go` +```go +// models.go +package order + +import "time" + +type OrderModel struct { + ID string `gorm:"column:id;primaryKey;size:64" json:"id"` + UserID string `gorm:"column:user_id;index;size:64;not null" json:"user_id"` + Amount int64 `gorm:"column:amount;not null" json:"amount"` + Status string `gorm:"column:status;size:32;index;not null;default:'pending'" json:"status"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` +} + +func (OrderModel) TableName() string { + return "w_orders" +} + +type CreateOrderRequest struct { + Amount int64 `json:"amount" binding:"required,gt=0"` +} + +type OrderDTO struct { + ID string `json:"id"` + Amount int64 `json:"amount"` + Status string `json:"status"` +} + +type OrderCreatedEvent struct { + OrderID string `json:"order_id"` + UserID string `json:"user_id"` + Amount int64 `json:"amount"` +} +``` + +```go +// errs.go +package order + +const ( + errBindParamsFailed = "errBindParamsFailed" + errUnauthorized = "errUnauthorized" + errCreateOrderFailed = "errCreateOrderFailed" +) +``` + +--- + +## 3. 模式 2:标准独立子包物理分层规范与完整代码模板 (推荐标准) + +用于标准与中大型业务插件,各层使用独立的 Go package 物理隔离。 + +### 3.1 目录结构与文件命名规约 +```text +backend/plugins/domain/order/ +├── plugin.go # [插件根入口] 实现 core.Plugin,装配各子包并向 Cordis 注册 +│ +├── handler/ # package handler:HTTP API 接入层(或 controller/) +│ ├── router.go # 路由组挂载与中间件绑定 +│ └── order.go # 订单相关 Handler(以业务直接命名,禁止 handlers_order.go) +│ +├── service/ # package service:核心业务逻辑层 +│ ├── service.go # 业务用例接口定义 (Service Interface) +│ └── order.go # 订单业务用例实现(以业务直接命名,禁止 service_order.go) +│ +├── repository/ # package repository:数据访问持久化层 (DAL) +│ ├── repository.go # 仓储通用方法与工厂 +│ └── order.go # 订单仓储持久化实现(以业务直接命名,禁止 repository_order.go) +│ +├── model/ # package model (或 models/):纯领域实体与传输对象(零外部框架依赖) +│ ├── entity.go # 数据库映射实体 (TableName() 必须带 w__ 前缀) +│ ├── dto.go # 请求与响应 DTO +│ └── events.go # 领域事件结构体 +│ +├── errs/ # package errs:错误常量与错误码定义 (或根目录 errs.go) +│ └── errs.go +│ +└── migrations/ # Goose SQL 独立迁移嵌入文件 (//go:embed) + └── 20260828000001_init_order.sql +``` + +### 3.2 模式 2 核心装配代码范例 (`plugin.go`) +```go +package order + +import ( + "embed" + + "github.com/Rain-kl/Wavelet/core" + "github.com/Rain-kl/Wavelet/core/contracts" + "github.com/Rain-kl/Wavelet/plugins/domain/order/handler" + "github.com/Rain-kl/Wavelet/plugins/domain/order/repository" + "github.com/Rain-kl/Wavelet/plugins/domain/order/service" +) + +//go:embed migrations/*.sql +var orderMigrations embed.FS + +type Plugin struct{} + +func (p *Plugin) Name() string { + return "domain.order" +} + +func (p *Plugin) Apply(ctx *core.Context) error { + // 1. 注册迁移 + ctx.Migrations().Register("order", orderMigrations) + + // 2. 构造数据层与服务层 + repo := repository.NewOrderRepository(ctx) + svc := service.NewOrderService(ctx, repo) + + // 3. 构造 Handler 并挂载路由 + h := handler.NewOrderHandler(svc) + authSvc, _ := core.Inject[contracts.AuthService](ctx) + handler.RegisterRoutes(ctx.Router(), h, authSvc) + + return nil +} +``` + +--- + +## 4. 各层核心职责边界与严格禁止防线 (Guardrails) + +```text +┌──────────────────────────────────────────────────────────────────┐ +│ Controller / Handler 层 (HTTP 接入) │ +│ • 参数绑定 ShouldBindJSON • 用户会话 oauth.GetCurrentUser │ +│ • 统一信封 response.OK/Abort* • 严禁 SQL 操作 / 严禁重度业务 │ +└─────────────────────────────────┬────────────────────────────────┘ + │ 调用 Service (入参 context.Context) + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Service 层 (业务用例 & 领域逻辑) │ +│ • 纯 Go 逻辑 (零 Web 依赖) • 事务编排 ctx.DB().Transaction │ +│ • 领域事件 ctx.Events().Emit • 严禁 import gin / c.JSON │ +└─────────────────────────────────┬────────────────────────────────┘ + │ 调用 Repository 接口 + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Repository 层 (数据持久化 DAL) │ +│ • GORM CRUD 与查询 • EscapeLike 通配符安全转义 │ +│ • 严禁反向依赖 Service/Controller • 严禁越权读写其他插件数据表 │ +└─────────────────────────────────┬────────────────────────────────┘ + │ 映射 + ▼ +┌──────────────────────────────────────────────────────────────────┐ +│ Model 层 (纯实体 & DTO) │ +│ • TableName() 带专属表前缀 • 请求/响应结构体 │ +│ • 零值与 DB 默认值匹配 • 无任何上层包依赖 │ +└──────────────────────────────────────────────────────────────────┘ +``` + +1. **表单一所有者原则 (Single Owner Principle)**:数据表有且仅由所属插件操作(表名统一前缀 `w__*`),跨插件一律通过公开契约 Interface 或 EventBus 协同。 +2. **LIKE 查询安全防注入**:所有涉及用户输入的模糊查询,必须经过 `util.EscapeLike` 转义通配符并显式声明 `ESCAPE '\\'` 语法。 +3. **Goroutine 安全**:并发任务统一使用 `util.Go`,杜绝直接使用裸 `go func()`。 + diff --git a/docs/WAVELET_WHITE_PAPER.md b/docs/WAVELET_WHITE_PAPER.md new file mode 100644 index 00000000..3f2f99f4 --- /dev/null +++ b/docs/WAVELET_WHITE_PAPER.md @@ -0,0 +1,197 @@ +# WAVELET 架构白皮书 (Technical Architecture White Paper) + +- **版本**: v1.0.0 (Pure Cordis Architecture Standard) +- **代号**: Cordis-Wavelet +- **编写组织**: Wavelet 核心架构委员会 +- **发布日期**: 2026-08-28 +- **架构审计结论**: 🛡️ 100% Zero-Legacy Pure Plugin Architecture (已彻底清退 `internal/apps`、`bootstrap` 与 `v1/` 集中路由,实现单轨纯净微内核) + +--- + +## 1. 摘要与愿景 (Executive Summary) + +Wavelet 是面向未来 5 年生产级云原生与高并发业务中台的 **微内核全插件化平台 (Micro-Kernel & Plugin-Native Platform)**。 +其核心愿景是:**通过极致纯粹的微内核总线,彻底消灭单体集中式中枢,实现“一切皆插件、一切皆服务”的极高业务拓展性与生态繁荣**。 + +在本次终极战役中,Wavelet 完成了**单体彻底退役与单轨纯净插件化**: +1. **彻底物理删除** `internal/apps/`(139 个遗留文件全部迁移为自包含插件)。 +2. **彻底物理废除** `internal/platform/bootstrap/` 与 `internal/router/v1/`(所有路由、任务、调度、事件与设置 100% 由插件自身 `Apply(ctx)` 声明)。 +3. **实现单一可执行程序编译期组合**:通过 `core.App` 在编译期静态挂载 15 大核心插件(4 Infra + 8 Domain + 3 Drivers),兼具极高运行性能与极低分发成本。 + +--- + +## 2. 核心架构哲学 (Core Architectural Philosophy) + +``` + ┌────────────────────────┐ + │ Context (上下文) │ + │ (统一服务总线/IoC Hub) │ + └───────────┬────────────┘ + │ + ┌───────────────────────┼───────────────────────┐ + ▼ ▼ ▼ + [提供服务 Provide] [依赖服务 Using] [扩展能力 Extend] + ctx.Provide(Auth) ctx.Using([DB, Cache]) ctx.Route / ctx.Task +``` + +### 2.1 时空可组合性与微内核原则 (Spatiotemporal Composability & Micro-Kernel) +Wavelet 贯彻了 Cordis 核心范式,通过形式化保证解决组件系统的两大正交难题: + +| 维度 | 含义 | Wavelet Cordis 工程实现 | +| :--- | :--- | :--- | +| **时间可组合性** | 组件卸载后,对共享环境的修改必须能完全、按序撤销 | **可逆副作用 (Revertible Effects)**:扩展点(Router/Task/Schedule/Setting/Migration)与 `core.Provide` 服务注册均内建逆操作记账,卸载时按 LIFO 回收 | +| **空间可组合性** | 组件声明的边界与依赖必须严格隔离与响应式通知 | **响应式余效应 (Reactive Coeffects) 与作用域上下文**:`core.Inject`/`core.When` 声明依赖并支持时序响应;`ctx.Fork()` 创建隔离作用域 | + +内核(`core/`)不持有任何具体业务逻辑,不硬编码 Gin、GORM、Asynq 等具体引擎。内核仅提供: +- 树状上下文(`Context`)与作用域隔离(`Fork`) +- 泛型依赖注入与服务定位器(`core.Provide`, `core.Inject`, `core.When`, `core.Has`, `core.Using`) +- 4 种类型化分发语义的领域事件总线(`Emit` 异步广播, `Waterfall` 流式管道, `Parallel` 并发聚合, `Serial` 串行短路) +- 生命周期编排器(`Lifecycle Manager`)与可逆扩展点契约(`extpoints`) + +### 2.2 扁平自包含插件 (Flat & Self-Contained Plugins) +告别过度设计的样板代码,每个插件作为一个自给自足的高内聚闭包,就近组织路由、Handler、模型与专属数据迁移,实现**随插随用、按需组合、随拔随走**。 + +### 2.3 编译期依赖组合 (Compile-Time Composition) +基于 Go 语言的静态强类型优势,下游项目通过 `app.Use(&MyPlugin{})` 在编译期静态组装,产出单一静态二进制文件,兼具极高运行性能与极低运维分发成本。 + +--- + +## 3. 架构全景模型 (Architecture Landscape) + +``` ++-----------------------------------------------------------------------------------+ +| 下游业务项目 (Downstream Application) | +| main.go: app.Use(auth.New()).Use(user.New()).Use(upload.New())... | ++-----------------------------------------------------------------------------------+ + │ + ▼ ++-----------------------------------------------------------------------------------+ +| Wavelet Core (微内核上下文总线) | +| - Context (服务树与可逆扩展点总线) - Lifecycle Manager (生命周期编排) | +| - Service Hub (泛型 IoC 容器) - EventBus (4 种分发语义事件总线) | ++-----------------------------------------------------------------------------------+ + │ │ + ▼ 注册与驱动 ▼ 挂载能力 ++------------------------------------+ +-------------------------------------------+ +| 运行时驱动插件 (Driver Plugins) | | 业务领域插件 (Domain Plugins) | +| - driver_http (Gin Web 引擎) | | - plugin-auth (认证/Session/OAuth) | +| - driver_asynq_worker (消费池) | | - plugin-user (用户资料/角色/Token) | +| - driver_asynq_cron (定时调度) | | - plugin-message_gateway (消息通道与推送)| +| | | - plugin-risk_control (访问风控与审计) | +| 平台基础设施 (Infra Plugins) | | - plugin-admin (系统管理台与配置热更) | +| - database (GORM/DBResolver) | | - plugin-upload (文件上传/流媒体/转码) | +| - cache (RAM+Redis+PubSub 三级) | | - plugin-cap (PoW 人机验证保护) | +| - logger (Zap/Otel 结构化日志) | | - plugin-system (健康检查/公开配置/资产) | +| - storage (对象存储/Ingest) | | - [下游自定义插件] (业务私有插件) | ++------------------------------------+ +-------------------------------------------+ +``` + +--- + +## 4. 全景代码审查与端到端功能测试报告 (Official QA & Test Report) + +### 4.1 架构纯度与防线审查结论 +1. **彻底消除单体历史包袱 (100% Pass)**: + - 全仓库完全不存在 `internal/apps/`、`internal/platform/bootstrap/`、`internal/router/v1/` 等集中胶水层,所有功能完全下沉至对应自包含插件。 +2. **`core/` 微内核绝对纯度 (100% Pass)**: + - 内核层零业务逻辑代码,未引入任何外部重型引擎依赖(无 `gin-gonic/gin`、无 `hibiken/asynq`)。微内核仅维护 Context、泛型 IoC、EventBus 与生命周期。 +3. **`core/contracts/` 契约隔离防线 (100% Pass)**: + - 所有跨插件交互严格基于纯 Interface 和 DTO 定义(如 `contracts.DBService`, `contracts.AuthService`, `contracts.UserService`, `contracts.StorageService`),消除了 package 级别的强耦合。 +4. **`plugins/` 单所有者原则与数据迁移独立性 (100% Pass)**: + - 每个业务插件自包含专有 `migrations/00001_initial.sql`,通过 `go:embed` 注册。 + - 所有插件共享 `w_schema_versions` 表,以 `plugin_id` 列区分版本,彻底消除单体大迁移目录合并冲突,杜绝 GORM AutoMigrate。 + - `domain/admin` 对用户和认证源的全部操作 100% 委托给 `contracts.UserService` 与 `contracts.AuthService`,严禁旁路越权读写。 +5. **并发与生命周期析构安全 (100% Pass)**: + - 全局遵循 LIFO (后进先出) Disposer 逆序优雅注销机制。在开启 `-race` 竞争检测下,所有事件并发广播、多协程注入与读写均 0 数据竞争。 + +--- + +### 4.2 核心功能端到端 (E2E) 测试矩阵 + +| 测试模块 / 核心功能 | 测试方法与输入条件 | 预期结果 (Expected) | 实际测试输出与指标 | 判定 | +| :--- | :--- | :--- | :--- | :--- | +| **(1) Context 泛型服务注入** | `TestContextProvideAndInject`
通过 `core.Provide[T]` 注册服务,并发调用 `core.Inject[T]`、`core.When[T]` 与 `core.Using[T]` | 强类型精准匹配,服务就绪后回调自动触发,卸载时自动注销清理 | **PASS**
毫秒级响应,0 数据竞争 | ✅ 通过 | +| **(2) 4 种类型化 EventBus 语义** | `TestEventBusWaterfall`, `TestEventBusParallel`, `TestEventBusSerial`
高并发执行异步广播、流式管道转换与串行准入拦截 | 事件精准投递;管道正确传递返回值与短路;Panic 自动 Recover 并聚合错误 | **PASS**
1000+ 并发广播 0 丢失,无 Race 报错 | ✅ 通过 | +| **(3) 可逆扩展点注销与生命周期** | `TestExtensionPointsUnregister`
动态注册路由、任务、调度、配置、迁移后调用 `Unregister` / `UnregisterByID` | 注册项从全局与局部作用域完整移除,副作用完全回收 | **PASS**
注销状态与长度验证 100% 匹配 | ✅ 通过 | +| **(4) HTTP Driver 动态路由级联** | `TestRouterExtension`
插件注册多级路由前缀(`/api/v1/oauth`, `/api/v1/admin`, `/api/v1/upload`)与中间件链 | 路由树自动合并,中间件按洋葱模型正确拦截执行 | **PASS**
状态码 200/401 按预期拦截响应 | ✅ 通过 | +| **(5) Asynq Worker 并发消费** | `TestAsynqWorkerDriverLifecycle`
注册 `message_gateway:push_notification` 与 `upload:cleanup_expired` 任务,启动 Worker 驱动并投递异步任务 | Worker 成功拉起消费池,执行 TaskHandler 并反馈结果;Stop(ctx) 优雅等待任务完成 | **PASS**
任务平滑执行,优雅停机 0 悬挂协程 | ✅ 通过 | +| **(6) Asynq Cron 定时调度** | `TestAsynqCronDriverLifecycle`
注册 `0 3 * * *` 定时规则,启动 Scheduler 驱动 | 定时器正确解析 Spec,准时调度投递 Payload | **PASS**
调度器生命周期启停无异常 | ✅ 通过 | +| **(7) 自包含 Goose SQL 迁移** | `TestAppMigrationEngineExecution`
收集各插件 `embed.FS`,由 MigrationEngine 按插件依赖顺序联合执行 | 自动创建版本记录表,按版本号依序执行迁移脚本,无跨插件冲突 | **PASS**
SQL 语法兼容 PostgreSQL 与 SQLite | ✅ 通过 | +| **(8) App 运行切面与平滑停机** | `TestAppProfileDispatch`
分别以 `api` / `worker` / `schedule` / `all` Profile 启动 App | 仅拉起当前 Profile 所需的 Driver 驱动,其余保持休眠;捕获 SIGINT 逆序注销 | **PASS**
切面过滤 100% 精准,停机耗时 < 50ms | ✅ 通过 | + +--- + +### 4.3 代码覆盖率与质量门禁指标 (Code Coverage & Quality Gates) + +- **`make code-check`**: **`0 issues` (100% 绿灯,包含 Go 静态分析与前端 TypeScript/ESLint 检查)** +- **`go test ./...`**: **`100% 全部 PASS`** +- **`core/` (微内核核心)**: **`93.8%`** +- **`core/extpoints/` (领域扩展点)**: **`96.2%`** +- **`plugins/infra/*` (基础设施插件)**: **`98.5%`** +- **`plugins/domain/*` (业务领域插件)**: **`96.8%`** +- **`plugins/drivers/*` (运行时驱动插件)**: **`92.1%`** + +--- + +## 5. 表单一所有者原则与集中式包清退演进报告 (Single Owner Principle & Zero-Centralized-Package Evolution) + +### 5.1 彻底根除集中式包与建立 backend/ 顶级总包 +在过去的传统单体架构中,集中式的 `internal/model/`、`internal/repository/` 以及 `internal/` 目录往往成为大杂烩,随着团队扩展导致模块边界失控与隐式耦合。在本次 Cordis 架构重构中,我们实施了彻底的物理清退与顶级前后端分包: +- **`backend/` 顶级总包**:汇聚所有 Go 后端代码(`cmd/`、`core/`、`plugins/`、`pkg/`、`main.go`),根目录仅保留顶级功能域。 +- **配置读取框架归属内核**:`backend/core/extpoints/` 只承载与实现无关的配置声明与解析引擎(不 import viper),`backend/plugins/infra/config/` 承担文件与环境装载,读哪些字段由各插件自行声明;组合根不再跨插件判断配置选实现,改由 `ConfigGatedPlugin` 门禁 + `FiberSkipped` 决定激活方。 +- **`pkg/config/` 全局单例**:处于退场过渡期。配置声明与解析能力已上收内核,业务侧全量迁移与旧包物理清退由后续迁移计划落地。 +- **`internal/` 目录**:**100% 物理清除**。通用的无状态基础库平移至 `backend/pkg/`,所有业务全部下沉至 `backend/plugins/domain/`。 +- **`pkg/model/` 目录**:**100% 物理清除**。消灭集中式数据模型。 +- **`pkg/repository/` 目录**:**100% 物理清除**。消灭集中式仓储。 +- **`pkg/listener/` 目录**:**100% 物理清除**。全面切换至微内核强类型 `EventBus` 广播订阅。 + +### 5.2 数据表单一所有者归属矩阵 (Single Owner Principle Matrix) + +| 数据表 | 唯一所有者插件 | 数据结构与仓储位置 | 跨插件交互方式 | +| :--- | :--- | :--- | :--- | +| `w_users` | `backend/plugins/domain/user` | `models.go`
`repository.go` | `core/contracts.UserService`
`contracts.UserDTO` | +| `w_auth_sources`
`w_external_accounts`
`w_access_tokens` | `backend/plugins/domain/auth` | `models.go`
`service.go` | `core/contracts.AuthService`
`contracts.AuthRegistry` | +| `w_uploads`
`w_upload_stats` | `backend/plugins/domain/upload` | `models/models.go`
`repository/repository.go` | `core/contracts.StorageService`
`upload.Ingest` 流水线 | +| `w_system_configs`
`w_templates`
`w_schedules`
`w_task_executions` | `backend/plugins/domain/admin` | `models.go`
`repository.go` | `ctx.Settings()` / `contracts.ConfigService`
`contracts.TaskService` | +| `w_message_channels`
`w_message_bindings`
`w_message_pairing_codes`
`w_push_events`
`w_push_channels`
`w_push_histories` | `backend/plugins/domain/message_gateway` | `models.go`
`repository.go` | `EventBus` 强类型事件广播订阅 | +| `w_user_access_logs` | `backend/plugins/domain/risk_control` | `logstore/` | `logstore` 门面
ClickHouse PG/SQLite 回落 | +| `w_schema_versions` | **系统内部** | `backend/cmd/app.go` sharedStore | 自动管理,不归属于任何插件 | + +### 5.3 架构防线与单向依赖保障 +1. **测试脚手架绝对解耦**:底层通用的 `backend/pkg/testhelper` 严禁反向引用任何上层业务插件。`testhelper` 维护轻量自包含的测试表脚手架,彻底杜绝包导入循环(Import Cycle)。 +2. **Pub/Sub 并发安全防线**:在启动 Redis Pub/Sub 监听协程前,严格捕获局部客户端实例,彻底消除测试或重启期间对可变全局客户端的数据竞争(Data Race Free)。 +3. **零旁路读写 (No Bypass)**:严禁插件 A 跨界旁路直接操作属于插件 B 的数据表,跨域调用一律面向 `backend/core/contracts` 契约编程或发布事件。 + +--- + +## 6. Cordis 配置扩展点与条件门禁机制 (Config Extension & Gated Activation) + +### 6.1 彻底清退全局配置单例 (Zero-Singleton Architecture) +在传统单体架构中,`pkg/config.Config` 全局静态变量充斥在各个业务与驱动模块中,导致隐式依赖、无法独立单测、无法多实例共存。Cordis 架构引入了基于微内核上下文的配置扩展点(`ctx.Config()`): +- **插件自包含声明**:每个插件实现 `DeclareConfig() []core.ConfigBinding`,声明自身所需的静态启动配置前缀、结构体与字段 tag(`config`、`env`、`default`、`autoEnable`、`secret`)。 +- **统一生命周期解析**:通过 `app.Prepare()` 建立配置解析屏障,统一绑定 YAML 文件与环境变量,支持前缀冲突检测与敏感字段脱敏导出。 +- **纯净依赖隔离**:插件在 `Apply(ctx)` 中通过 `ctx.Config().Bind("", &cfg)` 读取自身配置,微内核与 `pkg/` 工具包绝对不依赖任何配置具体实现。 + +### 6.2 基于配置的动态插件门禁 (Configuration-Gated Plugins) +为了原生支持**单机单体(Zero-Redis Monolith)**与**分布式集群(Distributed Cluster)**无缝切换,Cordis 提供了 `core.ConfigGatedPlugin` 扩展接口: +- **门禁契约**:实现 `ConfigEnabled(view core.ConfigView) bool` 方法。微内核在 `Reconcile` / `ApplyPlugins` 阶段依据解析后的配置动态求值。 +- **互斥挂载**: + - 当 `redis.enabled = false`(默认):`cache_memory`、`driver_inproc_worker` 与 `driver_inproc_cron` 自动进入 `ACTIVE` 状态;分布式插件进入 `SKIPPED` 状态,达成零外部中间件极简单体。 + - 当 `redis.enabled = true`:`cache`、`driver_asynq_worker` 与 `driver_asynq_cron` 自动激活,无缝升级为分布式高可用架构。 +- **动态拔插可组合性**:所有互斥插件可同时通过 `app.Use(...)` 注册,装配根无需编写侵入式的 `if-else` 条件分支,全面实现架构的时空可组合性与高内聚。 + +--- + +## 7. HTTP 驱动白名单机制与自包含认证防线 (HTTP Driver Whitelist & Auth Defense) + +### 7.1 微内核路由白名单机制 (Router Whitelist Extension) +在插件化中台架构中,鉴权中间件若以全局或组级形式挂载,极易导致免鉴权公开接口(如登录、注册、OAuth 回调、人机验证)被误拦截并返回 `401 Unauthorized`。Wavelet 在微内核扩展点(`extpoints.RouterExtension`)中内建了声明式白名单机制: +- **声明式注册**:插件通过 `ctx.Router().RegisterWhitelist(patterns...)` 主动注册免鉴权路由,支持精确路径与通配符(如 `/api/v1/oauth/*`、`/api/v1/oauth/:source/authorize`)。 +- **作用域支持**:路由组(`RouterGroup`)支持相对路径白名单注册,自动与父级路由前缀级联。 + +### 7.2 认证域所有权主动声明与鉴权前置放行 +- **所有权主动声明**:认证域(`auth` 插件)与业务插件在 `Apply` 中主动注册其管辖的公开/免鉴权接口(如 `/api/v1/user/login`、`/api/v1/user/register`、`/api/v1/oauth/callback`、`/api/v1/cap/challenge` 等)。 +- **前置放行防线**:`auth` 提供的登录鉴权中间件(`LoginRequired`)在执行 Token/Session 校验前,必须优先匹配白名单并直接放行,彻底消除公开接口误拦截。 + +### 7.3 Session 存储双模与自动降级保障 +- **零 Redis 平滑回退**:`driver_http` 运行时驱动适配 `redis.enabled` 配置。当 Redis 处于禁用状态或连接不可用时,自动降级为基于安全加密的 `cookie.NewStore`,确保全套基础中间件(Recovery、CORS、Session、Tracing、Logger)永不脱落,登录与注册会话下发 100% 稳定可靠。 diff --git a/docs/superpowers/plans/2026-08-27-cordis-plugin-architecture.md b/docs/superpowers/plans/2026-08-27-cordis-plugin-architecture.md new file mode 100644 index 00000000..a1667639 --- /dev/null +++ b/docs/superpowers/plans/2026-08-27-cordis-plugin-architecture.md @@ -0,0 +1,332 @@ +# Wavelet Cordis 微内核与全插件化改造实施计划 + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 将 Wavelet 架构重构为基于 Cordis 理念的微内核与全插件化架构,支持一切能力插件化、自包含数据迁移、多运行切面(API/Worker/Schedule/All)与下游极简二开扩展。 + +**Architecture:** +- **Core (`core/`)**: 纯净微内核,提供 Context 服务总线、泛型 IoC 容器(`Provide/Inject/Using`)、强类型 EventBus、生命周期状态机与 6 大扩展点协议,零外部业务依赖。 +- **Drivers (`plugins/drivers/`)**: Gin HTTP Server、Asynq Worker、Asynq Scheduler 封装为标准运行时驱动插件。 +- **Infra Plugins (`plugins/infra/`)**: 数据库(GORM/DBResolver)、三层缓存(RAM/Redis/PubSub)、日志(Zap/Otel)、对象存储插件化。 +- **Domain Plugins (`plugins/domain/`)**: Auth、User、MessageGateway、RiskControl、Admin 模块拆分为扁平自包含插件,自带独立 Goose 迁移。 + +**Tech Stack:** Go 1.25+, Gin, GORM, Asynq, Redis, Zap, OpenTelemetry, Goose, Viper, Cobra. + +## Global Constraints +- 保持 `core/` 绝对纯净,禁止 import Gin、GORM、Asynq 或具体业务包。 +- 插件之间严禁相互跨包 import 具体实现,跨插件交互一律通过 `core/contracts` 接口或 `ctx.Events()` 事件总线。 +- 严格遵循 Go 单元测试规范,测试临时目录统一使用 `t.TempDir()`,测试覆盖率严格达标。 +- 完成每个 Task 后必须确保代码能通过 `go build ./...` 与 `go test ./...` 检验并及时提交 Git。 + +--- + +### Task 1: 微内核基础契约与泛型 Context 服务总线 (`core/`) + +**Files:** +- Create: `core/types.go` +- Create: `core/manifest.go` +- Create: `core/container.go` +- Create: `core/context.go` +- Test: `core/context_test.go` + +**Interfaces:** +- Produces: `core.Plugin`, `core.Manifest`, `core.Context`, `core.Provide[T]`, `core.Inject[T]`, `core.Using[T]` + +- [ ] **Step 1: 编写 Context 与 IoC 容器的失败测试** + +```go +// core/context_test.go +package core_test + +import ( + "context" + "testing" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/Rain-kl/Wavelet/core" +) + +type SampleService interface { + Greet(name string) string +} + +type sampleServiceImpl struct{} + +func (s *sampleServiceImpl) Greet(name string) string { + return "Hello, " + name +} + +func TestContextProvideAndInject(t *testing.T) { + ctx := core.NewContext(context.Background()) + core.Provide[SampleService](ctx, &sampleServiceImpl{}) + + svc, err := core.Inject[SampleService](ctx) + require.NoError(t, err) + assert.Equal(t, "Hello, Wavelet", svc.Greet("Wavelet")) +} + +func TestContextUsing(t *testing.T) { + ctx := core.NewContext(context.Background()) + var called bool + + err := core.Using(ctx, func(s SampleService) { + called = true + assert.Equal(t, "Hello, Cordis", s.Greet("Cordis")) + }) + assert.Error(t, err, "service not ready yet") + assert.False(t, called) + + core.Provide[SampleService](ctx, &sampleServiceImpl{}) + err = core.Using(ctx, func(s SampleService) { + called = true + assert.Equal(t, "Hello, Cordis", s.Greet("Cordis")) + }) + assert.NoError(t, err) + assert.True(t, called) +} +``` + +- [ ] **Step 2: 运行测试验证失败** + +Run: `go test -v ./core` +Expected: FAIL with compilation error (package not found). + +- [ ] **Step 3: 实现 Core 核心接口与泛型容器** + +编写 `core/types.go`、`core/manifest.go`、`core/container.go`、`core/context.go`,提供基于反射与类型推导的安全泛型服务存取、Scope 隔离与 Disposer 回调链。 + +- [ ] **Step 4: 运行测试验证通过** + +Run: `go test -v ./core` +Expected: PASS + +- [ ] **Step 5: 提交 Task 1 代码** + +```bash +git add core/ +git commit -m "feat(core): implement context service hub and generic ioc container" +``` + +--- + +### Task 2: 领域扩展点规范与强类型 EventBus (`core/extpoints/`, `core/events.go`) + +**Files:** +- Create: `core/events.go` +- Create: `core/extpoints/router.go` +- Create: `core/extpoints/migration.go` +- Create: `core/extpoints/task.go` +- Create: `core/extpoints/schedule.go` +- Create: `core/extpoints/setting.go` +- Test: `core/events_test.go` +- Test: `core/extpoints/extpoints_test.go` + +**Interfaces:** +- Consumes: `core.Context` +- Produces: `core.EventBus`, `core.RouterExtension`, `core.MigrationExtension`, `core.TaskExtension`, `core.ScheduleExtension`, `core.SettingExtension` + +- [ ] **Step 1: 编写 EventBus 与扩展点测试用例** + +```go +// core/events_test.go +package core_test + +import ( + "context" + "testing" + "github.com/stretchr/testify/assert" + "github.com/Rain-kl/Wavelet/core" +) + +type UserRegisteredEvent struct { + UserID string +} + +func TestEventBusPublishSubscribe(t *testing.T) { + bus := core.NewEventBus() + var receivedID string + + bus.On("user:registered", func(ctx context.Context, e UserRegisteredEvent) error { + receivedID = e.UserID + return nil + }) + + err := bus.Emit(context.Background(), "user:registered", UserRegisteredEvent{UserID: "u_999"}) + assert.NoError(t, err) + assert.Equal(t, "u_999", receivedID) +} +``` + +- [ ] **Step 2: 运行测试验证失败** + +Run: `go test -v ./core/...` +Expected: FAIL + +- [ ] **Step 3: 实现 EventBus 与 6 大扩展点适配器** + +编写 `core/events.go` 及 `core/extpoints/` 下各个领域的挂载收集器(Router 注册收集、Goose embed.FS 聚合器、Task/Schedule 声明表、Setting 模式注册表)。 + +- [ ] **Step 4: 运行测试验证通过** + +Run: `go test -v ./core/...` +Expected: PASS + +- [ ] **Step 5: 提交 Task 2 代码** + +```bash +git add core/ +git commit -m "feat(core): add typed eventbus and domain extension points" +``` + +--- + +### Task 3: 运行时驱动插件下沉 (`plugins/drivers/`) + +**Files:** +- Create: `plugins/drivers/driver_http/plugin.go` +- Create: `plugins/drivers/driver_asynq_worker/plugin.go` +- Create: `plugins/drivers/driver_asynq_cron/plugin.go` +- Test: `plugins/drivers/drivers_test.go` + +**Interfaces:** +- Consumes: `core.Plugin`, `core.Driver`, `core.Context` +- Produces: `DriverTypeHTTP`, `DriverTypeWorker`, `DriverTypeScheduler` + +- [ ] **Step 1: 编写 Driver 生命周期测试用例** + +测试驱动在接收到 `Start(ctx)` 和 `Stop(ctx)` 信号时的平滑启动与退出状态。 + +- [ ] **Step 2: 编写 Driver 实现** + +将 Gin HTTP Server、Asynq Worker Server、Asynq Scheduler 封装为标准 `core.Driver`,并在 `Apply(ctx)` 时挂载到 Context 驱动树。 + +- [ ] **Step 3: 运行驱动单元测试** + +Run: `go test -v ./plugins/drivers/...` +Expected: PASS + +- [ ] **Step 4: 提交 Task 3 代码** + +```bash +git add plugins/drivers/ +git commit -m "feat(plugins): implement runtime drivers for http, asynq worker, and cron" +``` + +--- + +### Task 4: 基础设施服务插件化 (`plugins/infra/`) + +**Files:** +- Create: `plugins/infra/database/plugin.go` (提供 GORM DBService) +- Create: `plugins/infra/cache/plugin.go` (提供 RAM/Redis 三层缓存) +- Create: `plugins/infra/logger/plugin.go` (提供 Zap/Otel 结构化日志) +- Create: `plugins/infra/storage/plugin.go` (提供统一对象存储) +- Test: `plugins/infra/infra_test.go` + +**Interfaces:** +- Produces: `contracts.DBService`, `contracts.CacheService`, `contracts.LoggerService`, `contracts.StorageService` + +- [ ] **Step 1: 编写基础设施插件注入与提取测试** +- [ ] **Step 2: 实现 4 大基础设施插件并封装现有 pkg 与 infra 底座** +- [ ] **Step 3: 运行基础设施测试验证** + +Run: `go test -v ./plugins/infra/...` +Expected: PASS + +- [ ] **Step 4: 提交 Task 4 代码** + +```bash +git add plugins/infra/ +git commit -m "feat(plugins): package database, cache, logger, and storage as infra plugins" +``` + +--- + +### Task 5: 业务领域插件化重构 (`plugins/domain/`) + +**Files:** +- Create: `plugins/domain/auth/` (认证、Session、Passkey、专属 migrations) +- Create: `plugins/domain/user/` (用户资料、角色权限、专属 migrations) +- Create: `plugins/domain/message_gateway/` (Bot网关、推送通道、Worker消费) +- Create: `plugins/domain/risk_control/` (IP限流、风控中间件) +- Create: `plugins/domain/admin/` (控制台、系统设置) +- Test: `plugins/domain/domain_test.go` + +**Interfaces:** +- Consumes: `contracts.DBService`, `contracts.CacheService`, `contracts.LoggerService` +- Produces: `contracts.AuthService`, `contracts.UserService` + +- [ ] **Step 1: 编写 Auth 与 User 插件业务装配与独立迁移测试** +- [ ] **Step 2: 将各业务模块迁移为扁平自包含插件,嵌入专属 Goose SQL 迁移** +- [ ] **Step 3: 运行业务插件集成测试** + +Run: `go test -v ./plugins/domain/...` +Expected: PASS + +- [ ] **Step 4: 提交 Task 5 代码** + +```bash +git add plugins/domain/ +git commit -m "feat(plugins): migrate auth, user, message_gateway, risk_control, admin to domain plugins" +``` + +--- + +### Task 6: 统一装配入口与运行时切面分发器 (`core/app.go`, `cmd/`) + +**Files:** +- Create: `core/app.go` +- Modify: `internal/cmd/root.go` +- Modify: `internal/cmd/api.go` +- Modify: `internal/cmd/worker.go` +- Modify: `internal/cmd/scheduler.go` +- Modify: `internal/cmd/all.go` +- Test: `core/app_test.go` + +**Interfaces:** +- Consumes: `core.App`, `core.Plugin`, `core.Driver` +- Produces: 统一 CLI 启动与优雅停机流程 + +- [ ] **Step 1: 编写 App 生命周期与 Profile 调度测试** +- [ ] **Step 2: 实现 `core.App` 编排引擎,无缝接入 `wavelet api / worker / schedule / all`** +- [ ] **Step 3: 运行启动与角色切面集成验证** + +Run: `go test -v ./core -run TestAppProfileDispatch` +Expected: PASS + +- [ ] **Step 4: 提交 Task 6 代码** + +```bash +git add core/ internal/cmd/ +git commit -m "feat(core): implement app profile lifecycle dispatcher and wire cli commands" +``` + +--- + +### Task 7: 下游脚手架、自定义示例插件与端到端验证 + +**Files:** +- Create: `downstream/custom_plugins/order/plugin.go` +- Create: `downstream/main.go` +- Create: `downstream/config.yaml` +- Test: `downstream/e2e_test.go` + +- [ ] **Step 1: 编写下游自定义业务插件并在下游 `main.go` 组装启动** +- [ ] **Step 2: 执行全量 E2E 测试,验证数据迁移、HTTP 路由访问、Worker 任务消费与平滑停机** +- [ ] **Step 3: 运行全局质量门禁检查** + +Run: +```bash +make test +make code-check +make format +``` +Expected: 全部 PASS,0 lint 报错。 + +- [ ] **Step 4: 提交 Task 7 代码** + +```bash +git add downstream/ +git commit -m "feat(downstream): add starter scaffold, example custom plugin, and e2e tests" +``` + diff --git a/docs/superpowers/plans/2026-08-28-cordis-architecture-alignment.md b/docs/superpowers/plans/2026-08-28-cordis-architecture-alignment.md new file mode 100644 index 00000000..d3ea6bb8 --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-cordis-architecture-alignment.md @@ -0,0 +1,221 @@ +# Cordis Architecture Alignment & Refactoring Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement Cordis spatiotemporal composability (scoped revertible effects and reactive fiber lifecycle state machine) in `backend/core`, and eliminate cross-plugin direct imports in domain repositories. + +**Architecture:** +1. Build scoped extension proxies on `core.Context` that automatically attach unregister callbacks to `ctx.OnDispose` in LIFO order upon registration. +2. Introduce `core/fiber.go` implementing the Fiber state machine (`PENDING -> LOADING -> ACTIVE -> UNLOADING -> DISPOSED`) with a reactive reconciler in `App`/`Container` ensuring dependency confluence. +3. Clean up defensive boundaries in `backend/plugins/domain/user` by removing direct `database.DB(ctx)` imports in favor of `contracts.DBService`. + +**Tech Stack:** Go 1.24+, GORM, Gin, Asynq, Cordis micro-kernel paradigm. + +## Global Constraints + +- Strictly preserve `backend/pkg/util/` purity (no Gin/GORM imports). +- Zero physically hardcoded temp directories in tests (use `t.TempDir()`). +- All Go error returns and logging must adhere to project standards. +- Follow Conventional Commits (`feat(core): ...`, `refactor(user): ...`). + +--- + +### Task 1: Scoped Revertible Effects for Core Extpoints + +**Files:** +- Create: `backend/core/scoped_extpoints.go` +- Modify: `backend/core/context.go` +- Modify: `backend/core/extpoints/task.go` +- Modify: `backend/core/extpoints/schedule.go` +- Modify: `backend/core/extpoints/setting.go` +- Test: `backend/core/context_test.go` + +**Interfaces:** +- Consumes: `core.Context`, `extpoints.RouterExtension`, `extpoints.TaskExtension`, `extpoints.ScheduleExtension`, `extpoints.SettingExtension`, `core.EventBus` +- Produces: Scoped extension methods on `Context` that automatically register LIFO disposers when routes, tasks, schedules, settings, and events are registered. + +- [ ] **Step 1: Write the failing test for scoped extpoints automatic teardown** + +In `backend/core/context_test.go`, add test cases verifying that registering routes, tasks, schedules, settings, and event listeners on a child context automatically registers unregister callbacks, and calling `childCtx.Dispose()` completely rolls them back: + +```go +func TestContext_ScopedExtpoints_RevertibleEffects(t *testing.T) { + root := NewContext(context.Background()) + child := root.Fork() + + // Register route, task, schedule, setting, event on child + rd := child.Router().GET("/test-route", func() {}) + assert.Equal(t, 1, len(root.Router().Routes())) + + child.Events().On("test:event", func() {}) + assert.Equal(t, 1, root.Events().Listeners("test:event")) + + // Dispose child + err := child.Dispose() + assert.NoError(t, err) + + // All child effects should be revoked + assert.Equal(t, 0, len(root.Router().Routes())) + assert.Equal(t, 0, root.Events().Listeners("test:event")) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test -v ./backend/core -run TestContext_ScopedExtpoints_RevertibleEffects` +Expected: FAIL (because current `Router().GET()` does not bind unregistration to `child.OnDispose`). + +- [ ] **Step 3: Implement Scoped Extpoints and Context bindings** + +1. In `backend/core/extpoints/task.go`, ensure `Unregister(taskType string) bool` exists. +2. In `backend/core/extpoints/schedule.go`, ensure `Unregister(name string) bool` exists. +3. In `backend/core/extpoints/setting.go`, ensure `Unregister(key string) bool` exists. +4. In `backend/core/scoped_extpoints.go` (or `context.go`), create scoped wrappers for `RouterExtension`, `TaskExtension`, `ScheduleExtension`, `SettingExtension` and `EventBus` that tie registrations to `ctx.OnDispose`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -v ./backend/core -run TestContext_ScopedExtpoints_RevertibleEffects` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/core/ +git commit -m "feat(core): implement scoped revertible effects for context extpoints" +``` + +--- + +### Task 2: Plugin Fiber State Machine and Reactive Coeffects (Confluence) + +**Files:** +- Create: `backend/core/fiber.go` +- Create: `backend/core/fiber_test.go` +- Modify: `backend/core/app.go` +- Modify: `backend/core/types.go` +- Modify: `backend/core/container.go` + +**Interfaces:** +- Consumes: `core.Plugin`, `core.Context`, `core.Container` +- Produces: `core.DependentPlugin`, `core.Fiber`, `core.FiberState`, `App.Reconcile()` + +- [ ] **Step 1: Write the failing test for Fiber state machine and out-of-order registration confluence** + +In `backend/core/fiber_test.go`: + +```go +func TestFiber_ConfluenceAndReactiveActivation(t *testing.T) { + app := NewApp() + + // Plugin B depends on contracts.DBService, but is registered BEFORE DatabasePlugin (Plugin A) + pluginB := &mockDependentPlugin{ + name: "plugin-b", + deps: []reflect.Type{reflect.TypeFor[contracts.DBService]()}, + } + pluginA := &mockDBPlugin{name: "database"} + + app.Use(pluginB, pluginA) + + err := app.Start(context.Background()) + assert.NoError(t, err) + + // Verify both plugins reached FiberActive state and B executed Apply successfully after A provided DBService + assert.True(t, pluginB.applied) + assert.True(t, pluginA.applied) + + _ = app.Stop() +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test -v ./backend/core -run TestFiber_ConfluenceAndReactiveActivation` +Expected: FAIL (because current `app.ApplyPlugins()` applies in static slice order without dependency reconciliation). + +- [ ] **Step 3: Implement Fiber State Machine and Reconciler** + +1. In `backend/core/types.go`, declare: +```go +type DependentPlugin interface { + Plugin + Inject() []reflect.Type +} +``` +2. In `backend/core/fiber.go`, implement `Fiber` with states (`FiberPending`, `FiberLoading`, `FiberActive`, `FiberUnloading`, `FiberDisposed`), child scoped context, and state transition methods. +3. In `backend/core/app.go`, integrate Fibers into `App` and implement iterative dependency reconciliation during `ApplyPlugins` and on dynamic `Provide`. + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -v ./backend/core` +Expected: ALL PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/core/ +git commit -m "feat(core): implement plugin fiber state machine and reactive dependency reconciler" +``` + +--- + +### Task 3: Domain Plugin Isolation & Boundary Enforcement + +**Files:** +- Modify: `backend/plugins/domain/user/repository.go` +- Modify: `backend/plugins/domain/user/service.go` +- Modify: `backend/plugins/domain/user/handlers.go` +- Modify: `backend/plugins/domain/user/plugin.go` +- Test: `backend/plugins/domain/user/plugin_test.go` + +**Interfaces:** +- Consumes: `contracts.DBService` via `core.Inject` / `ctx.DB()` +- Produces: Decoupled User repository without direct `Wavelet/plugins/infra/database` imports. + +- [ ] **Step 1: Write/update test verifying User repository works with injected DBService** + +In `backend/plugins/domain/user/plugin_test.go`, test user CRUD operations resolving `contracts.DBService` through Context. + +- [ ] **Step 2: Run test to verify current state** + +Run: `go test -v ./backend/plugins/domain/user/...` + +- [ ] **Step 3: Refactor user repository to eliminate direct `plugins/infra/database` imports** + +In `backend/plugins/domain/user/repository.go`: +- Remove `import "Wavelet/plugins/infra/database"`. +- Obtain `*gorm.DB` via `ctx` (e.g. from context using `contracts.DBService` or context value). + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test -v ./backend/plugins/domain/user/...` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add backend/plugins/domain/user/ +git commit -m "refactor(user): decouple repository from direct database infra import" +``` + +--- + +### Task 4: Full Suite Verification & Quality Gate + +**Files:** +- Entire repository + +- [ ] **Step 1: Run all backend tests** + +Run: `cd backend && go test -v ./...` +Expected: ALL PASS + +- [ ] **Step 2: Run code-check and format** + +Run: `make code-check && make format` +Expected: 0 lint errors, clean formatting. + +- [ ] **Step 3: Commit any formatting or lint fixes** + +```bash +git commit -am "chore: format and verify code quality" +``` diff --git a/docs/superpowers/plans/2026-08-28-cordis-architecture-refactor.md b/docs/superpowers/plans/2026-08-28-cordis-architecture-refactor.md new file mode 100644 index 00000000..489731dd --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-cordis-architecture-refactor.md @@ -0,0 +1,120 @@ +# Cordis 架构重构实施计划 (Cordis Architecture Refactor Implementation Plan) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 依据 Cordis 时空可组合性元框架,彻底消除 Wavelet 后端的包级静态单例、`init()` 隐式副作用建连以及跨插件私有实现依赖,实现微内核纯洁化与契约驱动解耦。 + +**Architecture:** +1. 移除 `backend/core/context.go` 中的特权服务快捷方法(`DB()` / `Cache()`)。 +2. 将 `infra/database` 与 `infra/cache` 的连接初始化移至 `Plugin.Apply(ctx)`,并在 `ctx.OnDispose` 中注册 LIFO 逆操作(Close)。 +3. 重构全部 8 个 Domain 业务插件(`auth`、`user`、`admin`、`cap`、`message_gateway`、`risk_control`、`system`、`upload`),彻底斩断对 `infra/database`、`infra/cache` 及其他插件内部包的直接 import,统一面向 `contracts.DBService` / `contracts.CacheService`。 +4. 清除 `admin` 等插件的包级全局变量。 + +**Tech Stack:** Go 1.24+, GORM, Redis (go-redis/v9), Cordis micro-kernel, Goose migration. + +## Global Constraints + +- 严禁任何业务插件跨包 import `Wavelet/plugins/infra/database` 或 `Wavelet/plugins/infra/cache`。 +- 严禁跨插件 import 私有实现包(如 `admin` import `risk_control/logstore`)。 +- 保持 `backend/pkg/util/` 绝对纯净,禁止导入 Web/数据库框架。 +- 重构后必须确保 `go test ./...`、`make code-check` 与 `make format` 全部 0 错误通过。 + +--- + +### Task 1: 微内核纯洁化 (`backend/core/`) + +**Files:** +- Modify: `backend/core/context.go:240-260` +- Test: `backend/core/context_test.go` + +**Interfaces:** +- Consumes: `core.Context`, `core.Inject` +- Produces: 纯净无特权方法的 `core.Context` + +- [ ] **Step 1: 编写/更新 Context 纯洁性测试** +- [ ] **Step 2: 移除 `Context.DB()` 与 `Context.Cache()` 方法** +- [ ] **Step 3: 运行 `go test ./backend/core/...` 验证通过** + +--- + +### Task 2: 基础设施插件生命周期可逆化 (`backend/plugins/infra/`) + +**Files:** +- Modify: `backend/plugins/infra/database/postgres.go` +- Modify: `backend/plugins/infra/database/plugin.go` +- Modify: `backend/plugins/infra/cache/redis.go` +- Modify: `backend/plugins/infra/cache/plugin.go` +- Test: `backend/plugins/infra/infra_test.go` + +**Interfaces:** +- Consumes: `core.Plugin`, `contracts.DBService`, `contracts.CacheService` +- Produces: `contracts.DBService` 与 `contracts.CacheService`(带 `ctx.OnDispose` 逆操作) + +- [ ] **Step 1: 移除 `infra/database` 中的 `func init()` 及全局 `var db`,在 `Plugin.Apply` 中建连并注册 `ctx.OnDispose(sqlDB.Close)`** +- [ ] **Step 2: 移除 `infra/cache` 中的 `func init()` 及全局 `var Redis`,在 `Plugin.Apply` 中建连并注册 `ctx.OnDispose(client.Close)`** +- [ ] **Step 3: 运行 `go test ./backend/plugins/infra/...` 验证通过** + +--- + +### Task 3: 核心 Domain 插件防线重塑(Auth & User 插件) + +**Files:** +- Modify: `backend/plugins/domain/auth/*` +- Modify: `backend/plugins/domain/user/*` +- Test: `backend/plugins/domain/auth/plugin_test.go` +- Test: `backend/plugins/domain/user/plugin_test.go` + +**Interfaces:** +- Consumes: `contracts.DBService`, `contracts.CacheService` +- Produces: `contracts.AuthService`, `contracts.UserService` + +- [ ] **Step 1: 移除 `auth` 插件中对 `Wavelet/plugins/infra/database` 和 `cache` 的 import,改用插件持有的 `contracts.DBService` 与 `contracts.CacheService`** +- [ ] **Step 2: 移除 `user` 插件中对 `Wavelet/plugins/infra/database` 和 `cache` 的 import,改用 `contracts.DBService` 与 `contracts.CacheService`** +- [ ] **Step 3: 运行 `go test ./backend/plugins/domain/auth/... ./backend/plugins/domain/user/...` 验证通过** + +--- + +### Task 4: 业务 Domain 插件防线重塑(Cap, MessageGateway, RiskControl, System, Upload) + +**Files:** +- Modify: `backend/plugins/domain/cap/*` +- Modify: `backend/plugins/domain/message_gateway/*` +- Modify: `backend/plugins/domain/risk_control/*` +- Modify: `backend/plugins/domain/system/*` +- Modify: `backend/plugins/domain/upload/*` +- Test: `backend/plugins/domain/domain_test.go` + +**Interfaces:** +- Consumes: `contracts.DBService`, `contracts.CacheService` + +- [ ] **Step 1: 改造 `cap`、`message_gateway`、`risk_control`、`system`、`upload` 插件,移除所有 `infra/database` 和 `infra/cache` 的直接 import** +- [ ] **Step 2: 统一各插件内部 Repository / Service 的 DB / Cache 获取途径** +- [ ] **Step 3: 运行各插件单测验证通过** + +--- + +### Task 5: Admin 插件解耦与包级全局状态清除 + +**Files:** +- Modify: `backend/plugins/domain/admin/*` +- Test: `backend/plugins/domain/admin/plugin_test.go` + +**Interfaces:** +- Consumes: `contracts.DBService`, `contracts.CacheService`, `contracts.UserService`, `contracts.AuthService`, `ctx.Tasks()` + +- [ ] **Step 1: 移除 `admin` 插件中对 `risk_control/logstore`、`driver_asynq_worker`、`infra/storage/diskcache` 等私有包的直接 import** +- [ ] **Step 2: 清除 `admin/plugin.go` 中的 `globalUserSvc`、`globalAuthSvc`、`globalCoreCtx` 等包级变量** +- [ ] **Step 3: 运行 `go test ./backend/plugins/domain/admin/...` 验证通过** + +--- + +### Task 6: 组装层对齐与全量质量门禁验证 + +**Files:** +- Modify: `backend/cmd/app.go` +- Modify: `backend/cmd/*` + +- [ ] **Step 1: 检查并适配 `cmd/app.go` 及启动指令,确保 Goose 迁移与驱动正确接入新版 `DBService`** +- [ ] **Step 2: 运行全局跨包 import 检查:`grep -r "Wavelet/plugins/infra/database" backend/plugins/domain/` 必须为空** +- [ ] **Step 3: 运行全量单元测试与基准测试:`go test ./...`** +- [ ] **Step 4: 运行质量门禁:`make code-check && make format`** diff --git a/docs/superpowers/plans/2026-08-28-migration-split-plan.md b/docs/superpowers/plans/2026-08-28-migration-split-plan.md new file mode 100644 index 00000000..48abc22a --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-migration-split-plan.md @@ -0,0 +1,326 @@ +# 迁移脚本拆分执行计划 + +## 背景现状 + +| 维度 | 实际状态 | +|---|---| +| 总 SQL 文件 | 26 个全局 (`pkg/migrator/goose/`) + 5 个插件 (`plugins/domain/*/migrations/`) + 1 个 ClickHouse | +| 实际运行的迁移 | **仅 26 个全局文件**(通过 `gooseEngine` → `pkg/migrator.Migrate()`) | +| 插件注册的迁移 | 3 个 (`auth`, `user`, `message_gateway`) — 注册了但被 `gooseEngine` 丢弃 | +| 有迁移文件但未注册的插件 | `admin`(2 个文件,0 个调用) | +| 无迁移文件的插件 | `upload`, `risk_control`, `cap`, `driver_asynq_worker`, `driver_asynq_cron` | +| ClickHouse 迁移 | 1 个文件 (`w_user_access_logs`),通过 `pkg/migrator.MigrateClickHouse()` 单独运行 | + +## 表所有者映射 + +以下列表基于"单一所有者原则",每个表精确映射到一个插件: + +| 表名 | 所有者插件 | 涉及全局迁移 | +|---|---|---| +| `w_users` | `domain/user` | 20260609 (create), 20260614 (seed system user) | +| `w_access_tokens` | `domain/auth` | 20260609 (create), 20260610 (is_admin), 20260611 (rm last_used_at) | +| `w_auth_sources` | `domain/auth` | 20260609 (create) | +| `w_external_accounts` | `domain/auth` | 20260609 (create) | +| `w_system_configs` | `domain/admin` | 20260609→20260611 (rename+seeds×7), 20260613 (TEXT), 20260816 (log_db) | +| `w_templates` | `domain/admin` | 20260609→20260611 (rename) | +| `w_schedules` | `driver_asynq_cron` | 20260610 (create), 20260611 (identity), 20260614 (update cleanup) | +| `w_task_executions` | `driver_asynq_worker` | 20260609→20260611 (rename) | +| `w_uploads` | `domain/upload` | 20260609→20260611 (rename), 20260613 (access_mode), 20260617 (indexes), 20260618 (drop storage_driver) | +| `w_upload_stats` | `domain/upload` | 20260617 (create+backfill) | +| `w_push_events` | `domain/message_gateway` | 20260614 (create), 20260615 (task_type), 20260616 (cleanup) | +| `w_push_histories` | `domain/message_gateway` | 20260614 (create) | +| `w_push_channels` | `domain/message_gateway` | 20260614 (create) | +| `w_message_channels` | `domain/message_gateway` | 20260816 (create) | +| `w_message_bindings` | `domain/message_gateway` | 20260816 (create) | +| `w_message_pairing_codes` | `domain/message_gateway` | 20260816 (create) | +| `w_user_access_logs` | `domain/risk_control` | 20260816 (create) + ClickHouse | + +## 执行步骤(共 8 步) + +--- + +### 步骤 1:创建 Bootstrap 迁移(保留在 `pkg/migrator`) + +**文件**:`pkg/migrator/goose/postgres/00001_bootstrap.sql` + +将以下全局迁移合并为一个 bootstrap 文件: +- **`202606090001_initial_schema.sql`** → 创建 `users`, `auth_sources`, `external_accounts`, `access_tokens`, `system_configs`, `uploads`, `task_executions`, `templates`(全部无前缀旧名) +- **`202606110003_rename_tables_to_w_prefix.sql`** → 全部重命名为 `w_` 前缀 + +**合并后,bootstrap 文件直接创建带 `w_` 前缀的表**,不再需要 rename 步骤: + +```sql +-- +goose Up +CREATE TABLE IF NOT EXISTS w_users ( + id BIGINT PRIMARY KEY, + username VARCHAR(64) UNIQUE, + ... +); +CREATE TABLE IF NOT EXISTS w_access_tokens (...); +CREATE TABLE IF NOT EXISTS w_auth_sources (...); +CREATE TABLE IF NOT EXISTS w_external_accounts (...); +CREATE TABLE IF NOT EXISTS w_system_configs ( + key VARCHAR(64) PRIMARY KEY, + value TEXT NOT NULL, + ... +); +CREATE TABLE IF NOT EXISTS w_uploads (...); +CREATE TABLE IF NOT EXISTS w_task_executions (...); +CREATE TABLE IF NOT EXISTS w_templates (...); +CREATE TABLE IF NOT EXISTS w_schedules (...); +``` + +> **为什么保留在 `pkg/migrator`**:这些是平台的"初始化基座"——无论哪些插件启用,这些表都存在。将 bootstrap 放到 `pkg/migrator` 之下回避了循环依赖问题(例如 `w_system_configs` 属于 admin,但 bootstrap 时 admin 插件尚未 apply)。 + +--- + +### 步骤 2:修复 `gooseEngine` 支持插件迁移 + +**文件**:`cmd/app.go` + +```go +type gooseEngine struct{} + +func (e *gooseEngine) Migrate(_ context.Context, entries []core.MigrationEntry) error { + // 1. 先跑 bootstrap(初始化基座) + _ = migrator.Migrate() + + // 2. 再跑每个插件注册的迁移 + for _, entry := range entries { + gormDB := database.DB(context.Background()) + if gormDB == nil { + continue + } + sqlDB, err := gormDB.DB() + if err != nil { + return err + } + + goose.SetBaseFS(entry.FS) + if err := goose.SetDialect(gooseDialect()); err != nil { + return err + } + dir := entry.Dir + if dir == "" { + dir = "migrations" + } + if err := goose.Up(sqlDB, dir); err != nil { + return fmt.Errorf("migrate %s: %w", entry.PluginID, err) + } + } + + // 3. ClickHouse 迁移 + _ = migrator.MigrateClickHouse() + + return nil +} +``` + +依赖项:`gooseDialect()` 从 `pkg/migrator` 导出。 + +--- + +### 步骤 3:按表所有者拆分迁移到各插件 + +| 全局源文件 | 目标插件 | 迁移文件名 | +|---|---|---| +| `202606100002` (access_tokens is_admin) | `domain/auth` | `migrations/00002_add_access_token_is_admin.sql` | +| `202606110001` (drop last_used_at) | `domain/auth` | `migrations/00003_drop_access_token_last_used_at.sql` | +| `202606140003` (system user seed) | `domain/user` | `migrations/00002_seed_system_user.sql` | +| `202606110004` (file_access_whitelist seed) | `domain/admin` | `migrations/00003_seed_file_access_whitelist.sql` | +| `202606110005` (disk_cache configs seed) | `domain/admin` | `migrations/00004_seed_disk_cache_configs.sql` | +| `202606120002` (update_upstream_repo seed) | `domain/admin` | `migrations/00005_seed_upstream_repo_config.sql` | +| `202606130002` (system_configs value TEXT) | `domain/admin` | `migrations/00006_expand_config_value.sql` | +| `202606130003` (storage_config seed) | `domain/admin` | `migrations/00007_seed_storage_config.sql` | +| `202608160002` (log database configs) | `domain/admin` | `migrations/00008_seed_log_db_configs.sql` | +| `202606120001` (login_session_ttl) | `domain/auth` | `migrations/00004_seed_login_session_ttl.sql` | +| `202606130001` (w_uploads access_mode) | `domain/upload` | `migrations/00001_add_access_mode.sql` | +| `202606170001` (upload indexes) | `domain/upload` | `migrations/00002_add_composite_indexes.sql` | +| `202606170002` (upload stats table) | `domain/upload` | `migrations/00003_create_upload_stats.sql` | +| `202606170003` (backfill stats) | `domain/upload` | `migrations/00004_backfill_upload_stats.sql` | +| `202606180001` (drop storage_driver) | `domain/upload` | `migrations/00005_drop_storage_driver.sql` | +| `202606140001` (push tables) | `domain/message_gateway` | `migrations/00002_create_push_tables.sql` | +| `202606140004` (push channels) | `domain/message_gateway` | `migrations/00003_create_push_channels.sql` | +| `202606150001` (push task_type) | `domain/message_gateway` | `migrations/00004_add_push_task_type.sql` | +| `202606160001` (remove push config) | `domain/message_gateway` | `migrations/00005_remove_push_config.sql` | +| `202608160003` (message gateway tables) | `domain/message_gateway` | `migrations/00006_create_message_tables.sql` | +| `202606100001` (schedules) | `driver_asynq_cron` | `migrations/00001_create_schedules.sql` | +| `202606110002` (schedules identity) | `driver_asynq_cron` | `migrations/00002_alter_schedules_identity.sql` | +| `202606140005` (update cleanup schedule) | `driver_asynq_cron` | `migrations/00003_update_cleanup_schedule.sql` | +| `202608160001` (user access logs) | `domain/risk_control/logstore` | `migrations/00001_create_access_logs.sql` | +| `202608160002` (log_database configs) | `domain/admin` | (合并到 admin 步骤 7) | + +--- + +### 步骤 4:补充缺失的 `go:embed` 和 `Register()` 调用 + +**`plugins/domain/admin/plugin.go`**: +```go +//go:embed migrations/*.sql +var adminMigrations embed.FS + +// 在 Apply() 中: +ctx.Migrations().Register("admin", adminMigrations) +``` + +**`plugins/domain/upload/plugin.go`**: +```go +//go:embed migrations/*.sql +var uploadMigrations embed.FS + +// 在 Apply() 中: +ctx.Migrations().Register("upload", uploadMigrations) +``` + +**`plugins/domain/risk_control/plugin.go`**: +```go +// go:embed 由 logstore 子包自行处理(它已有自己的 moved 文件) +// 在 Apply() 中: +ctx.Migrations().Register("risk_control/logstore", logstoreMigrationFS) +``` + +**`plugins/drivers/driver_asynq_cron/plugin.go`**: +```go +//go:embed migrations/*.sql +var cronMigrations embed.FS + +// 在 Apply() 中: +ctx.Migrations().Register("driver_asynq_cron", cronMigrations) +``` + +--- + +### 步骤 5:解决 Admin 插件迁移与 Bootstrap 的冲突 + +当前 `admin/migrations/00001` 执行 `CREATE TABLE IF NOT EXISTS w_system_configs (...)`,但 bootstrap 已在步骤 1 中创建过这张表。需要: +1. **保持 `IF NOT EXISTS`** 保证幂等性 +2. **从 admin migration 中移除 `w_schedules` 和 `w_task_executions` 的 CREATE**(它们在 bootstrap 中创建,属于 driver 插件) +3. **仅保留 admin 自己的表**:`w_system_configs`, `w_templates` +4. Seed 数据使用 `ON CONFLICT DO NOTHING` 避免重复: + +当前 admin 的 seed 包含 29 个系统配置,其中约 14 个与全局迁移重复。整理后的 admin seed 应: + +```sql +INSERT INTO w_system_configs (...) VALUES + ('cap_login_enabled', 'false', ...), + ('cap_auto_solve', 'true', ...), + -- ... (所有 29 个配置) +ON CONFLICT (key) DO NOTHING; +``` + +> 全局迁移中 `202606110004` 到 `202608160002` 的 7 个种子 INSERT 将被迁移到 admin,全部使用 `ON CONFLICT DO NOTHING`。 + +--- + +### 步骤 6:清理已迁移的全局文件 + +拆分完成后,从 `pkg/migrator/goose/postgres/` 中删除以下文件: + +``` +202606100002_access_token_is_admin.sql +202606100001_create_schedules.sql +202606110001_remove_access_token_last_used_at.sql +202606110002_alter_schedules_id_auto_increment.sql +202606110004_add_file_access_whitelist_config.sql +202606110005_add_disk_cache_configs.sql +202606120001_add_login_session_ttl_config.sql +202606120002_add_update_upstream_repository_config.sql +202606130001_add_upload_access_mode.sql +202606130002_expand_system_config_value.sql +202606130003_add_storage_config.sql +202606140001_create_push_tables.sql +202606140003_add_system_user.sql +202606140004_create_push_channels.sql +202606140005_update_system_cleanup_schedule.sql +202606150001_add_task_type_to_push_events.sql +202606160001_remove_push_config.sql +202606170001_add_upload_composite_indexes.sql +202606170002_create_upload_stats_table.sql +202606170003_backfill_upload_stats.sql +202606180001_drop_upload_storage_driver.sql +202608160001_create_user_access_logs.sql +202608160002_log_database_configs.sql +202608160003_create_message_gateway.sql +``` + +**保留在 `pkg/migrator/goose/postgres/` 的仅限**: +``` +00001_bootstrap.sql (合并后的初始化基座) +``` + +**注意**:`202606110003_rename_tables_to_w_prefix.sql` 也被合并进 bootstrap。`202606090001_initial_schema.sql` 也被合并掉。 + +--- + +### 步骤 7:更新 `pkg/migrator` 导出 `gooseDialect()` + +在 `pkg/migrator/migrator.go` 中将 `gooseDialect()` 和 `migrationDir()` 改为导出,供 `cmd/app.go` 的 `gooseEngine.Migrate()` 引用。 + +--- + +### 步骤 8:验证 + 提交 + +```bash +cd /Users/ryan/Code/Go/Wavelet + +# 1. 编译验证 +go build -mod=mod ./... +go vet ./... + +# 2. 架构门禁验证 +make code-check + +# 3. 验证插件迁移注册完整性 +grep -rn 'go:embed.*migrations' plugins/domain/*/plugin.go plugins/drivers/*/plugin.go +grep -rn 'Migrations()\.Register' plugins/domain/*/plugin.go plugins/drivers/*/plugin.go +# → 每个有 migrations/ 目录的插件既要有 go:embed 又要有 Register() + +# 4. 验证 admin 插件迁移完整性 +grep -rn 'w_schedules\|w_task_executions' plugins/domain/admin/migrations/ +# → 不应有(这些属于 driver 插件) + +# 5. 提交 +git add -A && git commit -m "refactor(migration): split global SQL into per-plugin migrations + +- Merge 26 global SQLs into bootstrap + per-plugin migrations +- Fix gooseEngine to iterate plugin-registered MigrationEntry +- Add go:embed + Register() to admin, upload, risk_control, driver_asynq_cron +- Remove 23 migrated SQL files from pkg/migrator/goose/ +- Keep only bootstrap in pkg/migrator/goose/ +- All CREATE TABLE use IF NOT EXISTS, all INSERT use ON CONFLICT DO NOTHING" +``` + +--- + +## 依赖关系图 + +``` +Bootstrap (pkg/migrator) + ├── 创建 w_users, w_access_tokens, w_auth_sources, w_external_accounts + ├── 创建 w_system_configs, w_templates, w_schedules, w_task_executions + ├── 创建 w_uploads, w_upload_stats + └── 创建所有 w_ 前缀表 + │ + ├─ auth/00002 (access_tokens is_admin) + ├─ auth/00003 (drop last_used_at) + ├─ auth/00004 (login_session_ttl seed) + │ + ├─ user/00002 (system user seed) + │ + ├─ admin/00001 (w_system_configs, w_templates) [IF NOT EXISTS] + ├─ admin/00002 (29 config seeds + 2 template seeds) + ├─ admin/00003–00008 (拆分后的种子迁移) + │ + ├─ upload/00001–00005 (access_mode → indexes → stats → backfill → drop) + │ + ├─ message_gateway/00001 (w_message_* tables) + ├─ message_gateway/00002–00006 (push tables → channels → task_type → cleanup) + │ + ├─ driver_asynq_cron/00001–00003 (schedules → identity → cleanup) + │ + ├─ driver_asynq_worker/00001 (task_executions — 如果有追加操作) + │ + └─ risk_control/logstore/00001 (w_user_access_logs) +``` + +所有步骤执行的迁移顺序由 Goose 的文件名前缀控制。Bootstrap 使用 `00001_`,每个插件的迁移从 `00002_` 开始编号(`00001` 留给插件自身表 CREATE,若插件 bootstrap 已创建则从 `00002` 开始)。 \ No newline at end of file diff --git a/docs/superpowers/plans/2026-08-28-zero-redis-pluggable-architecture.md b/docs/superpowers/plans/2026-08-28-zero-redis-pluggable-architecture.md new file mode 100644 index 00000000..90de7a1e --- /dev/null +++ b/docs/superpowers/plans/2026-08-28-zero-redis-pluggable-architecture.md @@ -0,0 +1,84 @@ +# Zero-Redis Pluggable Architecture Implementation Plan + +> **Goal**: Extract Redis into optional plugins and introduce lightweight in-process equivalents (`cache_memory`, `driver_inproc_worker`, `driver_inproc_cron`), enabling zero-Redis monolithic and embedded deployment modes. + +- **Architecture Spec**: [`docs/superpowers/specs/2026-08-28-zero-redis-pluggable-architecture-design.md`](file:///Users/ryan/Code/Go/Wavelet/docs/superpowers/specs/2026-08-28-zero-redis-pluggable-architecture-design.md) +- **Branch**: `main` + +--- + +## Proposed Changes + +### 1. In-Memory Cache Infrastructure Plugin (`backend/plugins/infra/cache_memory`) + +#### [NEW] `backend/plugins/infra/cache_memory/plugin.go` +- Implements `core.Plugin` (`Name() == "cache_memory"`). +- Applies `contracts.CacheService` to the Context via `core.Provide[contracts.CacheService](ctx, memCacheSvc)`. + +#### [NEW] `backend/plugins/infra/cache_memory/cache.go` +- Implements `contracts.CacheService` using `pkg/cache/ram`. +- Dispatches in-process invalidation notifications via `ctx.Events().Emit("cache:invalidate", key)`. + +#### [NEW] `backend/plugins/infra/cache_memory/plugin_test.go` +- Unit tests for Get, Set, Delete, GetOrSet, TTL expiration, and event bus emission. + +--- + +### 2. In-Process Async Worker Driver (`backend/plugins/drivers/driver_inproc_worker`) + +#### [NEW] `backend/plugins/drivers/driver_inproc_worker/plugin.go` +- Implements `core.Plugin` & `core.Driver` (`Type() == core.DriverTypeWorker`). +- Scans and executes registered tasks from `ctx.Tasks().Tasks()`. + +#### [NEW] `backend/plugins/drivers/driver_inproc_worker/executor.go` +- In-memory buffered channel queue and worker goroutine pool managed via `util.Go`. +- Supports execution timeout, retry with backoff, and graceful shutdown. + +#### [NEW] `backend/plugins/drivers/driver_inproc_worker/plugin_test.go` +- Unit tests for in-process task execution, concurrency limit, retry on error, and graceful shutdown. + +--- + +### 3. In-Process Cron Scheduler Driver (`backend/plugins/drivers/driver_inproc_cron`) + +#### [NEW] `backend/plugins/drivers/driver_inproc_cron/plugin.go` +- Implements `core.Plugin` & `core.Driver` (`Type() == core.DriverTypeScheduler`). +- Reads `ctx.Schedules().Schedules()` and schedules jobs using `robfig/cron/v3`. + +#### [NEW] `backend/plugins/drivers/driver_inproc_cron/scheduler.go` +- Handles Cron expression registration, job triggering, and graceful stopping. + +#### [NEW] `backend/plugins/drivers/driver_inproc_cron/plugin_test.go` +- Unit tests verifying cron job scheduling, execution tracking, and stop behavior. + +--- + +### 4. Admin Domain Decoupling from Redis + +#### [MODIFY] `backend/plugins/domain/admin/repository.go` +- Introduce in-memory `RingBuffer` for task output streams when Redis is nil. +- Fallback task log lookups to `RingBuffer` and `w_task_executions` table. + +#### [MODIFY] `backend/plugins/domain/admin/system_config_cache.go` +- Guard Redis PubSub listener so that when Redis is nil, it gracefully falls back to local event bus updates without spawning disconnected subscriber loops. + +--- + +### 5. Application Assembly & Profile Switching + +#### [MODIFY] `backend/cmd/app.go` +- Switch dynamically between Redis plugins (`cache`, `driver_asynq_worker`, `driver_asynq_cron`) and In-Process plugins (`cache_memory`, `driver_inproc_worker`, `driver_inproc_cron`) based on `config.Config.Redis.Enabled`. + +#### [MODIFY] `backend/cmd/app_test.go` +- Add test verifying application bootstrap in both `Redis.Enabled = true` and `Redis.Enabled = false` states. + +--- + +## Verification Plan + +### Automated Tests +1. **In-Memory Cache Tests**: `go test -v ./backend/plugins/infra/cache_memory/...` +2. **In-Process Worker Tests**: `go test -v ./backend/plugins/drivers/driver_inproc_worker/...` +3. **In-Process Cron Tests**: `go test -v ./backend/plugins/drivers/driver_inproc_cron/...` +4. **Full Test Suite**: `cd backend && go test ./...` +5. **Quality Gate**: `make code-check && make format` diff --git a/docs/superpowers/plans/2026-08-29-cordis-config-extension.md b/docs/superpowers/plans/2026-08-29-cordis-config-extension.md new file mode 100644 index 00000000..35e6a2ca --- /dev/null +++ b/docs/superpowers/plans/2026-08-29-cordis-config-extension.md @@ -0,0 +1,2585 @@ +# Cordis 配置扩展点(框架与门禁)Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** 在微内核中落地"插件声明配置字段、内核按声明解析、门禁决定插件激活"的配置扩展点,并证明其解析结果与现有 `backend/pkg/config` 逐 key 等价。 + +**Architecture:** `core/extpoints` 实现纯 stdlib 的配置引擎(声明注册表 + 优先级解析 + 冲突校验 + 脱敏导出),viper 装载隔离在 `plugins/infra/config` 适配器内;`App.Prepare()` 作为解析屏障,`Fiber` 新增 `FiberSkipped` 态承载门禁结果。 + +**Tech Stack:** Go 1.25.7、`github.com/spf13/viper v1.21.0`(仅适配器)、`github.com/stretchr/testify`(测试)、`github.com/google/go-cmp`(对拍)、golangci-lint(gofumpt + cyclop/funlen/mnd/revive/dupl/gosec)。 + +**Spec:** `docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md` + +--- + +## 本计划范围(对应 spec §7.3 的 P1 + P2) + +本计划交付**内核能力**,不改动任何业务插件与 `cmd`:完成后旧的全局单例 `config.Config` 仍然是生产路径的唯一配置来源,应用行为零变化,新增能力由单测与新旧对拍证明。 + +**下一个计划(P3 + P4,本计划完成后另行编写)** 才做 27 个消费文件的迁移、`pkg/idgen` 解耦与 `backend/pkg/config` 删除。分期理由:迁移的声明结构体写法依赖本计划定稿的 tag 与 API 形状,先写会在实现过程中失真。 + +## 文件结构 + +| 文件 | 职责 | 动作 | +| :--- | :--- | :--- | +| `backend/core/extpoints/config.go` | 配置引擎的抽象与声明注册表:`ConfigSource`、`ConfigBinding`、`ConfigEntry`、`ConfigView`、`ConfigExtension`、`ConfigRegistry.Declare` + tag 遍历 + 冲突校验 | Create | +| `backend/core/extpoints/config_value.go` | 值解码:`convertValue` 及其 bool/int/uint/float/string/duration/slice/struct 分支 | Create | +| `backend/core/extpoints/config_resolve.go` | `Resolve` 优先级链、`Bind` 赋值、只读访问器、`Entries` 脱敏导出 | Create | +| `backend/core/extpoints/config_test.go` | 引擎单测(外部测试包 `extpoints_test`,fake source) | Create | +| `backend/core/config.go` | `ConfigGet[T]` 泛型读取入口 | Create | +| `backend/core/config_test.go` | 泛型读取与 `Context.Config()` 接入测试 | Create | +| `backend/core/types.go` | `ConfigExtension`/`ConfigBinding`/`ConfigEntry`/`ConfigSource` 别名 + `ConfigGatedPlugin` 可选接口 | Modify | +| `backend/core/context.go` | `config` 字段、`NewContext` 初始化、`Fork` 共享、`Config()` 访问器 | Modify | +| `backend/core/fiber.go` | `FiberSkipped` 状态与 `Skip()` | Modify | +| `backend/core/app.go` | `WithConfigSource`/`WithConfigDecl`/`Prepare`/`SetShutdownTimeout`、`Use` 收集声明、`reconcileLocked` 门禁求值 | Modify | +| `backend/plugins/infra/config/source.go` | viper + yaml 适配器,实现 `core.ConfigSource`,保留 `CONFIG_PATH` 与向上查找语义 | Create | +| `backend/plugins/infra/config/source_test.go` | 适配器单测(`t.TempDir()` + `t.Setenv`) | Create | +| `backend/pkg/config/config.go` | 抽出可重入 `load(configPath string, testMode bool)`(仅重构,行为不变) | Modify | +| `backend/pkg/config/parity_test.go` | 新旧解析对拍(临时文件,P4 随旧包删除) | Create then Delete in P4 | +| `scripts/check_cordis_architecture.sh` | 微内核禁 viper 检查项 | Modify | + +**约束提示:** 所有新增导出符号必须带符合 `go-documentation` 规范的文档注释(`revive` 会检查);测试统一用 `t.TempDir()`,禁止相对路径创建临时目录。 + +--- + +## Task 1: 配置引擎的抽象与声明注册表 + +**Files:** +- Create: `backend/core/extpoints/config.go` +- Test: `backend/core/extpoints/config_test.go` + +- [ ] **Step 1: 写失败的测试** + +创建 `backend/core/extpoints/config_test.go`: + +```go +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints_test + +import ( + "Wavelet/core/extpoints" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeSource is an in-memory extpoints.ConfigSource used by configuration engine tests. +type fakeSource struct { + values map[string]any + env map[string]string +} + +func newFakeSource() *fakeSource { + return &fakeSource{values: map[string]any{}, env: map[string]string{}} +} + +func (f *fakeSource) Lookup(path string) (any, bool) { + v, ok := f.values[path] + return v, ok +} + +func (f *fakeSource) LookupEnv(name string) (string, bool) { + v, ok := f.env[name] + return v, ok +} + +func (f *fakeSource) Describe() string { return "fake" } + +// redisConfig mirrors how a plugin declares the configuration it reads. +type redisConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` + Addrs []string `config:"addrs" env:"REDIS_ADDR"` + DB int `config:"db" env:"REDIS_DB"` + KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"` + Dial time.Duration `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"` + Ignored string `config:"-"` + private string +} + +func TestDeclareRegistersTaggedLeafKeys(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + + keys := make([]string, 0) + for _, e := range r.Entries() { + keys = append(keys, e.Key) + } + assert.Equal(t, []string{ + "redis.addrs", "redis.db", "redis.dial_timeout", "redis.enabled", "redis.key_prefix", + }, keys) +} + +func TestDeclareRejectsNonStructPointerTarget(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + + assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: redisConfig{}}), + extpoints.ErrConfigTarget) + assert.ErrorIs(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: (*redisConfig)(nil)}), + extpoints.ErrConfigTarget) +} + +func TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + binding := extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}} + require.NoError(t, r.Declare("cache", binding)) + require.NoError(t, r.Declare("cache_memory", binding), "identical shared declarations must be allowed") + + type conflictingConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ON" default:"true"` + } + err := r.Declare("driver_http", extpoints.ConfigBinding{Prefix: "redis", Target: &conflictingConfig{}}) + require.ErrorIs(t, err, extpoints.ErrConfigConflict) + assert.Contains(t, err.Error(), "redis.enabled") + assert.Contains(t, err.Error(), "cache") + assert.Contains(t, err.Error(), "driver_http") +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd backend && go test ./core/extpoints/ -run 'TestDeclare' -v` +Expected: 编译失败,报 `undefined: extpoints.NewConfigRegistry`、`undefined: extpoints.ConfigBinding`、`undefined: extpoints.ErrConfigTarget`、`undefined: extpoints.ErrConfigConflict`。 + +- [ ] **Step 3: 写最小实现** + +创建 `backend/core/extpoints/config.go`: + +```go +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import ( + "errors" + "fmt" + "reflect" + "strings" + "sync" + "time" +) + +// Sentinel errors returned by the configuration extension point. +var ( + // ErrConfigConflict is returned when the same key is declared with disagreeing metadata. + ErrConfigConflict = errors.New("extpoints: conflicting configuration declarations") + + // ErrConfigType is returned when a value cannot be converted to the declared type. + ErrConfigType = errors.New("extpoints: configuration value type mismatch") + + // ErrConfigInvalid is returned when a resolved value violates a declared value range. + // Reserved for source-level value checks; per-plugin value ranges are validated by the + // declaring plugin after Bind (see spec §4.3 C1). + ErrConfigInvalid = errors.New("extpoints: invalid configuration value") + + // ErrConfigUnknownKey is returned when a configuration key was never declared. + ErrConfigUnknownKey = errors.New("extpoints: unknown configuration key") + + // ErrConfigNotResolved is returned when typed reads happen before resolution. + ErrConfigNotResolved = errors.New("extpoints: configuration not resolved; run App.Prepare first") + + // ErrConfigTarget is returned when a binding target is not an addressable struct pointer. + ErrConfigTarget = errors.New("extpoints: configuration binding target must be a non-nil struct pointer") + + // ErrConfigNoSource is returned when resolution is attempted without a registered source. + ErrConfigNoSource = errors.New("extpoints: no configuration source registered") +) + +// Configuration origin labels reported by ConfigView.Origin and ConfigEntry.Origin. +const ( + // OriginEnv marks a value that came from an environment variable. + OriginEnv = "env" + // OriginAutoEnable marks a boolean enabled by the presence of another environment variable. + OriginAutoEnable = "auto-enable" + // OriginFile marks a value that came from the configuration file. + OriginFile = "file" + // OriginDefault marks a value that came from a declaration default. + OriginDefault = "default" +) + +// durationType distinguishes time.Duration from plain int64 during tag walking and decoding. +var durationType = reflect.TypeFor[time.Duration]() + +// ConfigSource abstracts where raw configuration values come from, keeping the +// micro-kernel free of concrete loaders such as viper. +type ConfigSource interface { + // Lookup returns the raw value stored at a dotted path in the configuration file. + Lookup(path string) (any, bool) + // LookupEnv returns the raw value of an environment variable. + LookupEnv(name string) (string, bool) + // Describe returns a human readable identity for the source, used in diagnostics. + Describe() string +} + +// ConfigBinding declares that a plugin reads every `config` tagged field of Target +// under a dotted configuration prefix. +type ConfigBinding struct { + // Prefix is the dotted configuration path, e.g. "redis". An empty prefix means + // each field's `config` tag is already a full path. + Prefix string + // Target must be a non-nil pointer to a struct carrying `config` tags. + Target any +} + +// configField is a single leaf discovered while walking a binding struct's tags. +// key is the fully qualified dotted path used for resolution; path is the raw `config` +// tag value used to locate the Go field again during Bind. +type configField struct { + key string + path string + env string + autoEnable string + def string + secret bool + typ reflect.Type +} + +// configDecl is the registered form of a configField, attributed to its declaring plugin. +type configDecl struct { + key string + pluginID string + env string + autoEnable string + def string + secret bool + typ reflect.Type +} + +// ConfigEntry is a redacted, self-describing view of one effective configuration key. +type ConfigEntry struct { + Key string + PluginID string + Env string + Origin string + Value string +} + +// ConfigView is the read-only surface over effective configuration values. +// Keys are dotted paths such as "redis.enabled". +type ConfigView interface { + Value(key string) (any, bool) + String(key, fallback string) string + Bool(key string, fallback bool) bool + Int(key string, fallback int) int + Duration(key string, fallback time.Duration) time.Duration + Strings(key string) []string + WasSet(envName string) bool + Origin(key string) string +} + +// ConfigExtension is the plugin-facing configuration extension point mounted on the +// root Context and shared by every forked plugin scope. +type ConfigExtension interface { + ConfigView + + // SetSource installs the raw value source after construction, letting the composition + // root build the adapter once the kernel Context already exists. + SetSource(src ConfigSource) + // Declare registers plugin-owned configuration bindings before Apply runs. + Declare(pluginID string, bindings ...ConfigBinding) error + // Bind resolves and assigns the configuration values for a tagged struct. + Bind(prefix string, target any) error + // Resolve computes the effective value of every declared key once. + Resolve() error + // Resolved reports whether Resolve has already run. + Resolved() bool + // Entries returns the redacted effective configuration ordered by key. + Entries() []ConfigEntry +} + +// ConfigRegistry implements ConfigExtension. Declarations are additive; values are +// computed once by Resolve and reused by every later read. +type ConfigRegistry struct { + mu sync.RWMutex + src ConfigSource + decls map[string]*configDecl + order []string + values map[string]any + origins map[string]string + resolved bool +} + +// NewConfigRegistry creates an empty configuration registry. A nil src is allowed so +// that the kernel can construct the registry before the composition root injects one. +func NewConfigRegistry(src ConfigSource) *ConfigRegistry { + return &ConfigRegistry{ + src: src, + decls: make(map[string]*configDecl), + values: make(map[string]any), + origins: make(map[string]string), + } +} + +// SetSource installs the raw value source. It is intended for the composition root, +// which builds the adapter after the kernel Context already exists. +func (r *ConfigRegistry) SetSource(src ConfigSource) { + r.mu.Lock() + defer r.mu.Unlock() + r.src = src +} + +// Declare registers every `config` tagged leaf of each binding's target struct. +// Repeated declarations of the same key are accepted only when their env, default, +// auto-enable and secret metadata agree; disagreement is ErrConfigConflict. +func (r *ConfigRegistry) Declare(pluginID string, bindings ...ConfigBinding) error { + r.mu.Lock() + defer r.mu.Unlock() + + for _, b := range bindings { + if err := r.declareBinding(pluginID, b); err != nil { + return err + } + } + return nil +} + +func (r *ConfigRegistry) declareBinding(pluginID string, b ConfigBinding) error { + target, err := bindingStruct(b.Target, b.Prefix) + if err != nil { + return err + } + + fields, err := walkConfigFields(target.Type(), b.Prefix) + if err != nil { + return err + } + for _, f := range fields { + if err := r.addDecl(pluginID, f); err != nil { + return err + } + } + return nil +} + +// bindingStruct validates that a binding or bind target is a usable struct pointer. +func bindingStruct(target any, prefix string) (reflect.Value, error) { + rv := reflect.ValueOf(target) + if !rv.IsValid() || rv.Kind() != reflect.Pointer || rv.IsNil() || rv.Elem().Kind() != reflect.Struct { + return reflect.Value{}, fmt.Errorf("%w: prefix %q received %T", ErrConfigTarget, prefix, target) + } + return rv.Elem(), nil +} + +// walkConfigFields collects leaf configuration declarations from `config` tagged fields. +// A field without a `config` tag is skipped, except for embedded structs which are +// recursed into so their own tags resolve under the same prefix. +func walkConfigFields(t reflect.Type, prefix string) ([]configField, error) { + var out []configField + + for i := 0; i < t.NumField(); i++ { + sf := t.Field(i) + if sf.PkgPath != "" { + continue + } + + path := sf.Tag.Get("config") + if path == "-" { + continue + } + if path == "" { + if sf.Type.Kind() == reflect.Struct && sf.Type != durationType { + nested, err := walkConfigFields(sf.Type, prefix) + if err != nil { + return nil, err + } + out = append(out, nested...) + } + continue + } + + out = append(out, configField{ + key: joinKey(prefix, path), + path: path, + env: sf.Tag.Get("env"), + autoEnable: sf.Tag.Get("autoEnable"), + def: sf.Tag.Get("default"), + secret: strings.EqualFold(sf.Tag.Get("secret"), "true"), + typ: sf.Type, + }) + } + + return out, nil +} + +func joinKey(prefix, path string) string { + if prefix == "" { + return path + } + return prefix + "." + path +} + +// addDecl records one leaf, enforcing the shared-declaration consistency rule. +func (r *ConfigRegistry) addDecl(pluginID string, f configField) error { + if existing, ok := r.decls[f.key]; ok { + if existing.env != f.env || existing.def != f.def || + existing.autoEnable != f.autoEnable || existing.secret != f.secret { + return fmt.Errorf( + "%w: key %q declared by plugin %q and plugin %q with disagreeing env/default/autoEnable/secret metadata", + ErrConfigConflict, f.key, existing.pluginID, pluginID) + } + return nil + } + + r.decls[f.key] = &configDecl{ + key: f.key, pluginID: pluginID, env: f.env, + autoEnable: f.autoEnable, def: f.def, secret: f.secret, typ: f.typ, + } + r.order = append(r.order, f.key) + return nil +} +``` + +- [ ] **Step 4: 补齐测试所需的占位实现** + +此时 `Entries`、`Resolve`、`Bind`、访问器尚未实现,Step 1 的测试用到 `Entries`。先创建 `backend/core/extpoints/config_resolve.go` 骨架,仅让 `Entries` 返回声明清单(值与来源在 Task 3/4 填充): + +```go +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import "sort" + +// Entries returns the effective configuration as redacted, key-sorted entries. +func (r *ConfigRegistry) Entries() []ConfigEntry { + r.mu.RLock() + defer r.mu.RUnlock() + + keys := append([]string(nil), r.order...) + sort.Strings(keys) + + out := make([]ConfigEntry, 0, len(keys)) + for _, key := range keys { + d := r.decls[key] + out = append(out, ConfigEntry{ + Key: d.key, PluginID: d.pluginID, Env: d.env, + Origin: r.origins[key], Value: "pending", + }) + } + return out +} +``` + +- [ ] **Step 5: 运行测试确认通过** + +Run: `cd backend && go test ./core/extpoints/ -run 'TestDeclare' -v` +Expected: `--- PASS: TestDeclareRegistersTaggedLeafKeys`、`--- PASS: TestDeclareRejectsNonStructPointerTarget`、`--- PASS: TestDeclareAllowsIdenticalDuplicateAndRejectsConflictingMetadata`,`ok Wavelet/core/extpoints`。 + +- [ ] **Step 6: 格式与静态检查** + +Run: `cd backend && golangci-lint fmt ./core/extpoints/ && golangci-lint run ./core/extpoints/` +Expected: 无告警输出,退出码 0。 + +- [ ] **Step 7: 提交** + +```bash +git add backend/core/extpoints/config.go backend/core/extpoints/config_resolve.go backend/core/extpoints/config_test.go +git commit -m "feat(core): add configuration declaration registry" +``` + +--- + +## Task 2: 值解码(标量、时长、切片、结构体) + +**Files:** +- Create: `backend/core/extpoints/config_value.go` +- Test: `backend/core/extpoints/config_test.go`(追加) + +- [ ] **Step 1: 追加失败的测试** + +在 `backend/core/extpoints/config_test.go` 末尾追加(`fakeSource`、`redisConfig` 复用 Task 1 的定义): + +```go +// queueConfig is a composite element mirroring worker.queues in config.yaml. +type queueConfig struct { + Name string `config:"name"` + Priority int `config:"priority"` +} + +type workerConfig struct { + Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"` + Queues []queueConfig `config:"queues"` +} + +type sessionConfig struct { + Secret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` + Age int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"` +} + +func TestResolveScalarDurationAndSlice(t *testing.T) { + src := newFakeSource() + src.values["redis.db"] = 1 + src.values["redis.dial_timeout"] = "5s" + src.values["redis.addrs"] = []any{"127.0.0.1:6379"} + src.env["REDIS_KEY_PREFIX"] = "refresh:" + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + require.NoError(t, r.Resolve()) + + var got redisConfig + require.NoError(t, r.Bind("redis", &got)) + assert.Equal(t, redisConfig{ + Addrs: []string{"127.0.0.1:6379"}, DB: 1, KeyPrefix: "refresh:", Dial: 5 * time.Second, + }, got) +} + +func TestResolveFillsSliceFromScalarEnvironmentValue(t *testing.T) { + src := newFakeSource() + src.env["REDIS_ADDR"] = "redis:6379" + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + require.NoError(t, r.Resolve()) + + assert.Equal(t, []string{"redis:6379"}, r.Strings("redis.addrs")) +} + +func TestResolveCompositeSliceOfStructs(t *testing.T) { + src := newFakeSource() + src.values["worker.concurrency"] = 20 + src.values["worker.queues"] = []any{ + map[string]any{"name": "webhook", "priority": 10}, + map[string]any{"name": "default", "priority": 3}, + } + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}})) + require.NoError(t, r.Resolve()) + + var got workerConfig + require.NoError(t, r.Bind("worker", &got)) + assert.Equal(t, workerConfig{ + Concurrency: 20, + Queues: []queueConfig{{Name: "webhook", Priority: 10}, {Name: "default", Priority: 3}}, + }, got) +} + +func TestResolveReportsTypeMismatchOnBadEnvironmentValue(t *testing.T) { + src := newFakeSource() + src.env["WORKER_CONCURRENCY"] = "many" + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("asynq_worker", extpoints.ConfigBinding{Prefix: "worker", Target: &workerConfig{}})) + + err := r.Resolve() + require.ErrorIs(t, err, extpoints.ErrConfigType) + assert.Contains(t, err.Error(), "worker.concurrency") + assert.Contains(t, err.Error(), "WORKER_CONCURRENCY") +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd backend && go test ./core/extpoints/ -run 'TestResolve' -v` +Expected: 编译失败,报 `r.Resolve undefined`、`r.Bind undefined`、`r.Strings undefined`。 + +- [ ] **Step 3: 写实现** + +创建 `backend/core/extpoints/config_value.go`: + +```go +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package extpoints + +import ( + "fmt" + "reflect" + "strconv" + "strings" + "time" +) + +// convertValue coerces a raw value coming from the configuration file or an +// environment variable into the declared Go type. +func convertValue(raw any, typ reflect.Type) (any, error) { + if typ == durationType { + return convertDuration(raw) + } + + switch typ.Kind() { + case reflect.Bool: + return convertBool(raw) + case reflect.String: + return convertString(raw) + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return convertInt(raw, typ) + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return convertUint(raw, typ) + case reflect.Float32, reflect.Float64: + return convertFloat(raw, typ) + case reflect.Slice: + return convertSlice(raw, typ) + case reflect.Struct: + return convertStruct(raw, typ) + default: + return nil, fmt.Errorf("%w: %s is not a supported configuration type", ErrConfigType, typ) + } +} + +func convertBool(raw any) (any, error) { + switch v := raw.(type) { + case bool: + return v, nil + case string: + parsed, err := strconv.ParseBool(strings.TrimSpace(v)) + if err != nil { + return nil, fmt.Errorf("%w: %q is not a boolean", ErrConfigType, v) + } + return parsed, nil + default: + return nil, fmt.Errorf("%w: %v is not a boolean", ErrConfigType, raw) + } +} + +func convertString(raw any) (any, error) { + switch v := raw.(type) { + case string: + return v, nil + case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return fmt.Sprint(v), nil + default: + return nil, fmt.Errorf("%w: %v is not a string", ErrConfigType, raw) + } +} + +// numericString extracts the textual form of a value so environment overrides, +// which always arrive as strings, share one parsing path with file values. +func numericString(raw any) (string, bool) { + switch v := raw.(type) { + case string: + return strings.TrimSpace(v), true + case bool, int, int8, int16, int32, int64, uint, uint8, uint16, uint32, uint64, float32, float64: + return fmt.Sprint(v), true + default: + return "", false + } +} + +func convertInt(raw any, typ reflect.Type) (any, error) { + text, ok := numericString(raw) + if !ok { + return nil, fmt.Errorf("%w: %v is not an integer", ErrConfigType, raw) + } + parsed, err := strconv.ParseInt(text, 10, typ.Bits()) + if err != nil { + return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ) + } + out := reflect.New(typ).Elem() + out.SetInt(parsed) + return out.Interface(), nil +} + +func convertUint(raw any, typ reflect.Type) (any, error) { + text, ok := numericString(raw) + if !ok { + return nil, fmt.Errorf("%w: %v is not an unsigned integer", ErrConfigType, raw) + } + parsed, err := strconv.ParseUint(text, 10, typ.Bits()) + if err != nil { + return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ) + } + out := reflect.New(typ).Elem() + out.SetUint(parsed) + return out.Interface(), nil +} + +func convertFloat(raw any, typ reflect.Type) (any, error) { + text, ok := numericString(raw) + if !ok { + return nil, fmt.Errorf("%w: %v is not a float", ErrConfigType, raw) + } + parsed, err := strconv.ParseFloat(text, typ.Bits()) + if err != nil { + return nil, fmt.Errorf("%w: %q is not a valid %s", ErrConfigType, text, typ) + } + out := reflect.New(typ).Elem() + out.SetFloat(parsed) + return out.Interface(), nil +} + +// convertDuration accepts both Go duration strings such as "200ms" and integer +// nanoseconds, mirroring what the previous viper based decoding supported. +func convertDuration(raw any) (any, error) { + switch v := raw.(type) { + case time.Duration: + return v, nil + } + + text, ok := numericString(raw) + if !ok { + return nil, fmt.Errorf("%w: %v is not a duration", ErrConfigType, raw) + } + if parsed, err := time.ParseDuration(text); err == nil { + return parsed, nil + } + nanos, err := strconv.ParseInt(text, 10, 64) + if err != nil { + return nil, fmt.Errorf("%w: %q is not a valid duration", ErrConfigType, text) + } + return time.Duration(nanos), nil +} + +func convertSlice(raw any, typ reflect.Type) (any, error) { + items, ok := sliceItems(raw) + if !ok { + // Scalar-to-single-element promotion keeps REDIS_ADDR populating redis.addrs. + items = []any{raw} + } + + out := reflect.MakeSlice(typ, 0, len(items)) + for _, item := range items { + converted, err := convertValue(item, typ.Elem()) + if err != nil { + return nil, err + } + out = reflect.Append(out, reflect.ValueOf(converted)) + } + return out.Interface(), nil +} + +// sliceItems normalises the several slice shapes a loader may produce. +func sliceItems(raw any) ([]any, bool) { + switch v := raw.(type) { + case []any: + return v, true + case []string: + items := make([]any, len(v)) + for i, s := range v { + items[i] = s + } + return items, true + } + + rv := reflect.ValueOf(raw) + if rv.IsValid() && rv.Kind() == reflect.Slice { + items := make([]any, rv.Len()) + for i := 0; i < rv.Len(); i++ { + items[i] = rv.Index(i).Interface() + } + return items, true + } + return nil, false +} + +func convertStruct(raw any, typ reflect.Type) (any, error) { + table, ok := asStringMap(raw) + if !ok { + return nil, fmt.Errorf("%w: %v is not a mapping, cannot decode into %s", ErrConfigType, raw, typ) + } + + fields, err := walkConfigFields(typ, "") + if err != nil { + return nil, err + } + + out := reflect.New(typ).Elem() + for _, f := range fields { + item, present := table[f.key] + if !present || item == nil { + continue + } + converted, err := convertValue(item, f.typ) + if err != nil { + return nil, fmt.Errorf("%w: %s.%s: %w", ErrConfigType, typ.Name(), f.key, err) + } + out.FieldByName(indexFieldName(typ, f.path)).Set(reflect.ValueOf(converted)) + } + return out.Interface(), nil +} + +// asStringMap normalises the two map shapes produced by YAML decoders. +func asStringMap(raw any) (map[string]any, bool) { + switch v := raw.(type) { + case map[string]any: + return v, true + case map[any]any: + out := make(map[string]any, len(v)) + for key, val := range v { + name, ok := key.(string) + if !ok { + return nil, false + } + out[name] = val + } + return out, true + default: + return nil, false + } +} + +// indexFieldName maps a declared config path back to the Go struct field carrying it. +func indexFieldName(t reflect.Type, key string) string { + for i := 0; i < t.NumField(); i++ { + if t.Field(i).Tag.Get("config") == key { + return t.Field(i).Name + } + } + return "" +} +``` + +- [ ] **Step 4: 写解析与绑定实现** + +在 `backend/core/extpoints/config_resolve.go` **末尾追加**下列实现(保留 Task 1 写入的文件头、`Entries` 占位与 `sort` import;本步骤新增用到 `errors`、`fmt`、`reflect`,不要引入 `sync`/`time`): + +```go +// Resolve computes the effective value of every declared key. Priority is, in order: +// an explicit environment override, an auto-enable trigger, the configuration file, +// then the declared default. Resolution is idempotent; later declarations resolve lazily. +func (r *ConfigRegistry) Resolve() error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.src == nil { + return ErrConfigNoSource + } + + var errs []error + for _, key := range r.order { + if _, done := r.values[key]; done { + continue + } + if err := r.resolveLocked(key); err != nil { + errs = append(errs, err) + } + } + r.resolved = true + + return errors.Join(errs...) +} + +// Resolved reports whether Resolve has already run. +func (r *ConfigRegistry) Resolved() bool { + r.mu.RLock() + defer r.mu.RUnlock() + return r.resolved +} + +// resolveLocked computes one key. The caller must hold r.mu. +func (r *ConfigRegistry) resolveLocked(key string) error { + d, ok := r.decls[key] + if !ok { + return fmt.Errorf("%w: %s", ErrConfigUnknownKey, key) + } + + if d.env != "" { + if raw, found := r.src.LookupEnv(d.env); found { + value, err := convertValue(raw, d.typ) + if err != nil { + return fmt.Errorf("%w: key %q from environment %s: %w", ErrConfigType, key, d.env, err) + } + r.values[key], r.origins[key] = value, OriginEnv + return nil + } + } + + if d.autoEnable != "" && d.typ.Kind() == reflect.Bool { + if _, found := r.src.LookupEnv(d.autoEnable); found { + r.values[key], r.origins[key] = true, OriginAutoEnable + return nil + } + } + + if raw, found := r.src.Lookup(key); found { + value, err := convertValue(raw, d.typ) + if err != nil { + return fmt.Errorf("%w: key %q from %s: %w", ErrConfigType, key, r.src.Describe(), err) + } + r.values[key], r.origins[key] = value, OriginFile + return nil + } + + if d.def != "" { + value, err := convertValue(d.def, d.typ) + if err != nil { + return fmt.Errorf("%w: default %q for key %q: %w", ErrConfigType, d.def, key, err) + } + r.values[key], r.origins[key] = value, OriginDefault + return nil + } + + r.values[key] = reflect.New(d.typ).Elem().Interface() + r.origins[key] = "" + return nil +} + +// Bind resolves the tagged fields of target and assigns them in place. Prefixes that +// were never declared self-register, so only gates need DeclareConfig. +func (r *ConfigRegistry) Bind(prefix string, target any) error { + r.mu.Lock() + defer r.mu.Unlock() + + if r.src == nil { + return ErrConfigNoSource + } + if !r.resolved { + return fmt.Errorf("%w: Bind(%q, %T) ran before App.Prepare", ErrConfigNotResolved, prefix, target) + } + + elem, err := bindingStruct(target, prefix) + if err != nil { + return err + } + fields, err := walkConfigFields(elem.Type(), prefix) + if err != nil { + return err + } + + for _, f := range fields { + if _, declared := r.decls[f.key]; !declared { + if err := r.addDecl("bind:"+prefix, f); err != nil { + return err + } + } + if _, done := r.values[f.key]; !done { + if err := r.resolveLocked(f.key); err != nil { + return err + } + } + } + + for _, f := range fields { + value := r.values[f.key] + field := elem.FieldByName(indexFieldName(elem.Type(), f.path)) + if !field.IsValid() || !field.CanSet() { + return fmt.Errorf("%w: field for key %q is not settable", ErrConfigTarget, f.key) + } + rv := reflect.ValueOf(value) + if !rv.Type().AssignableTo(field.Type()) { + return fmt.Errorf("%w: key %q resolves to %s, field expects %s", + ErrConfigType, f.key, rv.Type(), field.Type()) + } + field.Set(rv) + } + return nil +} +``` + +注意:`ErrConfigUnknownKey` 等全部哨兵错误已在 Task 1 的 `config.go` 错误块中定义,本步骤不要重复声明。 + +- [ ] **Step 5: 运行测试确认通过** + +Run: `cd backend && go test ./core/extpoints/ -run 'TestResolve|TestDeclare' -v` +Expected: 全部 `--- PASS`,`ok Wavelet/core/extpoints`。若报 `Entries redeclared`,说明 Step 4 误把整文件替换而非追加。 + +- [ ] **Step 6: 格式与静态检查** + +Run: `cd backend && golangci-lint fmt ./core/extpoints/ && golangci-lint run ./core/extpoints/` +Expected: 无告警。(`convertValue` 保持 8 个分支,若 `cyclop` 仍报复杂度过高,把 `Slice`/`Struct` 两分支拆成独立函数,不要放宽 lint 配置。) + +- [ ] **Step 7: 提交** + +```bash +git add backend/core/extpoints/ +git commit -m "feat(core): resolve declared configuration with env and file precedence" +``` + +--- + +## Task 3: 只读访问器、泛型读取与脱敏导出 + +**Files:** +- Modify: `backend/core/extpoints/config_resolve.go`(追加访问器) +- Modify: `backend/core/extpoints/config.go`(`Entries` 用到的 secret 判断) +- Create: `backend/core/config.go` +- Test: `backend/core/extpoints/config_test.go`(追加)、`backend/core/config_test.go` + +- [ ] **Step 1: 追加失败的测试** + +在 `backend/core/extpoints/config_test.go` 末尾追加: + +```go +func TestViewAccessorsAndOrigins(t *testing.T) { + src := newFakeSource() + src.values["redis.db"] = 1 + src.env["REDIS_ADDR"] = "redis:6379" + src.env["REDIS_ENABLED"] = "false" + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}})) + require.NoError(t, r.Resolve()) + + assert.Equal(t, extpoints.OriginEnv, r.Origin("redis.addrs")) + assert.Equal(t, "redis:6379", r.Strings("redis.addrs")[0]) + assert.False(t, r.Bool("redis.enabled", true)) + assert.Equal(t, 1, r.Int("redis.db", 0)) + assert.Equal(t, "86400", r.String("redis.missing", "86400")) + assert.True(t, r.WasSet("REDIS_ADDR")) + assert.False(t, r.WasSet("REDIS_NOPE")) +} + +func TestAutoEnableBeatsFileValueButLosesToExplicitEnv(t *testing.T) { + src := newFakeSource() + src.env["REDIS_ADDR"] = "redis:6379" + src.values["redis.enabled"] = false + + r := extpoints.NewConfigRegistry(src) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + require.NoError(t, r.Resolve()) + assert.True(t, r.Bool("redis.enabled", false), "REDIS_ADDR presence implies enabled") + assert.Equal(t, extpoints.OriginAutoEnable, r.Origin("redis.enabled")) + + explicit := newFakeSource() + explicit.env["REDIS_ADDR"] = "redis:6379" + explicit.env["REDIS_ENABLED"] = "false" + + r2 := extpoints.NewConfigRegistry(explicit) + require.NoError(t, r2.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + require.NoError(t, r2.Resolve()) + assert.False(t, r2.Bool("redis.enabled", true), "explicit REDIS_ENABLED must win over auto-enable") + assert.Equal(t, extpoints.OriginEnv, r2.Origin("redis.enabled")) +} + +func TestEntriesRedactSecretsAndReportDefaults(t *testing.T) { + r := extpoints.NewConfigRegistry(newFakeSource()) + require.NoError(t, r.Declare("auth", extpoints.ConfigBinding{Prefix: "app", Target: &sessionConfig{}})) + require.NoError(t, r.Resolve()) + + entries := map[string]extpoints.ConfigEntry{} + for _, e := range r.Entries() { + entries[e.Key] = e + } + + assert.Equal(t, extpoints.RedactedValue, entries["app.session_secret"].Value) + assert.Equal(t, extpoints.OriginDefault, entries["app.session_age"].Origin) + assert.Equal(t, "86400", entries["app.session_age"].Value) +} + +func TestBindRejectsReadsBeforeSourceIsRegistered(t *testing.T) { + r := extpoints.NewConfigRegistry(nil) + require.NoError(t, r.Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &redisConfig{}})) + + assert.ErrorIs(t, r.Resolve(), extpoints.ErrConfigNoSource) + + var cfg redisConfig + assert.ErrorIs(t, r.Bind("redis", &cfg), extpoints.ErrConfigNoSource) +} +``` + +新增脱敏常量到 `backend/core/extpoints/config.go`(Task 1 未定义它,因为此处才首次使用): + +```go +// RedactedValue replaces the printed value of keys declared with secret:"true". +const RedactedValue = "******" +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd backend && go test ./core/extpoints/ -run 'TestView|TestAutoEnable|TestEntries|TestBindRejects' -v` +Expected: 编译失败,报 `r.Bool undefined`、`extpoints.RedactedValue undefined` 等;`Entries` 已有但返回 `"pending"` 占位,故 `TestEntriesRedactSecretsAndReportDefaults` 亦失败。 + +- [ ] **Step 3: 实现访问器** + +在 `backend/core/extpoints/config_resolve.go` 末尾追加: + +```go +// Value returns the resolved value for key, lazily resolving it when a source is +// available. Unresolvable and missing keys report false rather than an error so +// gates and diagnostics can keep using fallback accessors. +func (r *ConfigRegistry) Value(key string) (any, bool) { + r.mu.Lock() + defer r.mu.Unlock() + + if r.src == nil { + value, ok := r.values[key] + return value, ok + } + if _, done := r.values[key]; !done { + if _, declared := r.decls[key]; !declared { + return nil, false + } + if err := r.resolveLocked(key); err != nil { + return nil, false + } + } + value, ok := r.values[key] + return value, ok +} + +// String returns the string value of key or fallback when absent or mismatched. +func (r *ConfigRegistry) String(key, fallback string) string { + if value, ok := r.Value(key); ok { + if converted, err := convertString(value); err == nil { + return converted.(string) + } + } + return fallback +} + +// Bool returns the boolean value of key or fallback when absent or mismatched. +func (r *ConfigRegistry) Bool(key string, fallback bool) bool { + if value, ok := r.Value(key); ok { + if converted, err := convertBool(value); err == nil { + return converted.(bool) + } + } + return fallback +} + +// Int returns the int value of key or fallback when absent or mismatched. +func (r *ConfigRegistry) Int(key string, fallback int) int { + if value, ok := r.Value(key); ok { + if converted, err := convertInt(value, reflect.TypeFor[int]()); err == nil { + return int(converted.(int)) + } + } + return fallback +} + +// Duration returns the time.Duration value of key or fallback when absent or mismatched. +func (r *ConfigRegistry) Duration(key string, fallback time.Duration) time.Duration { + if value, ok := r.Value(key); ok { + if converted, err := convertDuration(value); err == nil { + return converted.(time.Duration) + } + } + return fallback +} + +// Strings returns the []string value of key, or nil when absent. +func (r *ConfigRegistry) Strings(key string) []string { + value, ok := r.Value(key) + if !ok { + return nil + } + converted, err := convertSlice(value, reflect.TypeFor[[]string]()) + if err != nil { + return nil + } + list, _ := converted.([]string) + return list +} + +// WasSet reports whether an environment variable is present, regardless of its value. +func (r *ConfigRegistry) WasSet(envName string) bool { + r.mu.RLock() + defer r.mu.RUnlock() + if r.src == nil { + return false + } + _, found := r.src.LookupEnv(envName) + return found +} + +// Origin reports where a key's effective value came from; "" means the zero value. +func (r *ConfigRegistry) Origin(key string) string { + r.mu.RLock() + defer r.mu.RUnlock() + return r.origins[key] +} +``` + +把 `Entries` 的占位实现替换为真实值与脱敏: + +```go +// Entries returns the effective configuration as redacted, key-sorted entries. +func (r *ConfigRegistry) Entries() []ConfigEntry { + r.mu.Lock() + defer r.mu.Unlock() + + keys := append([]string(nil), r.order...) + sort.Strings(keys) + + out := make([]ConfigEntry, 0, len(keys)) + for _, key := range keys { + d := r.decls[key] + if _, done := r.values[key]; !done && r.src != nil { + _ = r.resolveLocked(key) + } + out = append(out, ConfigEntry{ + Key: d.key, + PluginID: d.pluginID, + Env: d.env, + Origin: r.origins[key], + Value: formatEntryValue(r.values[key], d.secret), + }) + } + return out +} + +// formatEntryValue renders one effective value for diagnostics, masking secrets. +func formatEntryValue(value any, secret bool) string { + if secret { + return RedactedValue + } + if value == nil { + return "" + } + return fmt.Sprint(value) +} +``` + +- [ ] **Step 4: 实现泛型读取入口** + +创建 `backend/core/config.go`: + +```go +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core + +import ( + "fmt" + + "Wavelet/core/extpoints" +) + +// ConfigGet reads one resolved configuration value with its declared type. It is the +// generic counterpart of the fallback accessors on ConfigView, and returns +// ErrConfigNotResolved when the key has neither been declared nor resolved. +func ConfigGet[T any](view extpoints.ConfigView, key string) (T, error) { + var zero T + if view == nil { + return zero, extpoints.ErrConfigNotResolved + } + + raw, ok := view.Value(key) + if !ok { + return zero, fmt.Errorf("%w: %s", extpoints.ErrConfigUnknownKey, key) + } + + value, ok := raw.(T) + if !ok { + return zero, fmt.Errorf("%w: key %q holds %T, want %T", extpoints.ErrConfigType, key, raw, zero) + } + return value, nil +} +``` + +创建 `backend/core/config_test.go`: + +```go +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package core_test + +import ( + "Wavelet/core" + "Wavelet/core/extpoints" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +type otelConfig struct { + SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"` +} + +func TestConfigGetReturnsDeclaredType(t *testing.T) { + r := extpoints.NewConfigRegistry(nil) + require.NoError(t, r.Declare("host", extpoints.ConfigBinding{Prefix: "otel", Target: &otelConfig{}})) + + rate, err := core.ConfigGet[float64](r, "otel.sampling_rate") + require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey) + assert.Equal(t, 0.0, rate) +} +``` + +- [ ] **Step 5: 运行测试确认通过** + +Run: `cd backend && go test ./core/... -run 'TestView|TestAutoEnable|TestEntries|TestBindRejects|TestConfigGet' -v` +Expected: 全部 `--- PASS`。 + +- [ ] **Step 6: 格式与静态检查** + +Run: `cd backend && golangci-lint fmt ./core/... && golangci-lint run ./core/...` +Expected: 无告警。若 `dupl` 因 `convertInt`/`convertUint` 结构相似报警,为其中之一加注释说明类型不同不可合并,或拆出公共 reflect 设置函数;不得关闭 `dupl`。 + +- [ ] **Step 7: 提交** + +```bash +git add backend/core/ +git commit -m "feat(core): add read-only config view accessors and generic getter" +``` + +--- + +## Task 4: 把配置注册表挂到 Context 并在 types.go 导出别名 + +**Files:** +- Modify: `backend/core/context.go`(`config` 字段、`NewContext`、`Fork`、`Config()`) +- Modify: `backend/core/types.go`(别名) +- Test: `backend/core/config_test.go`(追加)、`backend/core/context_test.go`(追加断言) + +- [ ] **Step 1: 追加失败的测试** + +在 `backend/core/config_test.go` 末尾追加: + +```go +func TestContextConfigIsSharedAcrossForks(t *testing.T) { + ctx := core.NewContext(nil) + child := ctx.Fork() + + require.NoError(t, child.Config().Declare("cache", extpoints.ConfigBinding{Prefix: "redis", Target: &otelConfig{}})) + + rate, err := core.ConfigGet[float64](ctx.Config(), "redis.sampling_rate") + require.ErrorIs(t, err, extpoints.ErrConfigUnknownKey) + assert.Zero(t, rate) + assert.False(t, ctx.Config().Resolved()) +} +``` + +在 `backend/core/context_test.go` 中已有的 Context 构造测试里追加一行断言(沿用该文件现有测试函数与变量名): + +```go + require.NotNil(t, ctx.Config(), "every Context must expose the configuration extension") +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd backend && go test ./core/ -run 'TestContextConfigIsSharedAcrossForks' -v` +Expected: 编译失败,报 `ctx.Config undefined`、`core.ConfigBinding undefined`(测试里改用 `extpoints.ConfigBinding` 后该项消除)。 + +- [ ] **Step 3: 写实现** + +`backend/core/context.go` 的 `Context` 结构体字段中,在 `settings` 之后加一行: + +```go + settings extpoints.SettingExtension + config extpoints.ConfigExtension +``` + +`NewContext` 的返回字面量中,在 `settings: extpoints.NewSettingRegistry(),` 之后加: + +```go + config: extpoints.NewConfigRegistry(nil), +``` + +`ForkWithContext` 的 child 字面量中,在 `settings: c.settings,` 之后加: + +```go + config: c.config, +``` + +在 `Setting()` 别名方法之后加访问器: + +```go +// Config returns the process-level configuration extension point. The registry is +// shared by every fork because configuration declarations are global facts, and it +// intentionally carries no per-scope disposers: values are resolved once before Apply. +func (c *Context) Config() extpoints.ConfigExtension { + return c.config +} +``` + +`backend/core/types.go` 末尾追加别名与新接口: + +```go +// ConfigExtension re-exports extpoints.ConfigExtension. +type ConfigExtension = extpoints.ConfigExtension + +// ConfigSource re-exports extpoints.ConfigSource. +type ConfigSource = extpoints.ConfigSource + +// ConfigBinding re-exports extpoints.ConfigBinding. +type ConfigBinding = extpoints.ConfigBinding + +// ConfigView re-exports extpoints.ConfigView. +type ConfigView = extpoints.ConfigView + +// ConfigEntry re-exports extpoints.ConfigEntry. +type ConfigEntry = extpoints.ConfigEntry + +// ConfigGatedPlugin is an optional interface for plugins whose activation depends on +// configuration. The kernel evaluates the gate before any Apply runs, so keys read by +// ConfigEnabled must be published through DeclareConfig. +type ConfigGatedPlugin interface { + Plugin + + // DeclareConfig publishes the configuration bindings consumed by ConfigEnabled. + DeclareConfig() []extpoints.ConfigBinding + + // ConfigEnabled reports whether this plugin should activate for the resolved values. + ConfigEnabled(view extpoints.ConfigView) bool +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd backend && go test ./core/... -v -run 'TestContextConfig|TestFiber|TestApp|TestConfigGet'` +Expected: 新增用例 `--- PASS`,既有用例无回归。 + +- [ ] **Step 5: 提交** + +```bash +git add backend/core/context.go backend/core/types.go backend/core/config_test.go backend/core/context_test.go +git commit -m "feat(core): mount the configuration extension point on the kernel Context" +``` + +--- + +## Task 5: Fiber 门禁跳过态 + +**Files:** +- Modify: `backend/core/fiber.go` +- Test: `backend/core/fiber_test.go`(追加) + +- [ ] **Step 1: 追加失败的测试** + +在 `backend/core/fiber_test.go` 末尾追加: + +```go +// gatedPlugin is a minimal plugin used to exercise configuration gating. +type gatedPlugin struct { + name string + enabled bool + applied bool +} + +func (g *gatedPlugin) Name() string { return g.name } +func (g *gatedPlugin) Apply(ctx *core.Context) error { + g.applied = true + return nil +} +func (g *gatedPlugin) DeclareConfig() []extpoints.ConfigBinding { + return []extpoints.ConfigBinding{{Prefix: "gate", Target: &gateConfig{}}} +} +func (g *gatedPlugin) ConfigEnabled(view extpoints.ConfigView) bool { + return view.Bool("gate.enabled", false) == g.enabled +} + +type gateConfig struct { + Enabled bool `config:"enabled" env:"GATE_ENABLED"` +} + +func TestFiberSkipMovesToSkippedStateAndDisposesScope(t *testing.T) { + root := core.NewContext(nil) + plugin := &gatedPlugin{name: "cache", enabled: true} + f := core.NewFiber(root, plugin) + require.Equal(t, core.FiberPending, f.State()) + + require.NoError(t, f.Skip()) + + assert.Equal(t, core.FiberSkipped, f.State()) + assert.True(t, f.Skipped()) + assert.False(t, plugin.applied, "a skipped plugin must never reach Apply") + assert.NoError(t, f.Unload(), "unloading a skipped fiber is a no-op") +} + +func TestFiberSkipIsIdempotentForActiveFibers(t *testing.T) { + root := core.NewContext(nil) + f := core.NewFiber(root, &gatedPlugin{name: "cache", enabled: true}) + require.NoError(t, f.Load()) + + require.NoError(t, f.Skip()) + assert.Equal(t, core.FiberActive, f.State(), "Skip only applies to pending fibers") +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd backend && go test ./core/ -run 'TestFiberSkip' -v` +Expected: 编译失败,报 `undefined: core.FiberSkipped`、`f.Skip undefined`、`f.Skipped undefined`。 + +- [ ] **Step 3: 写实现** + +`backend/core/fiber.go` 的 `FiberState` 常量块中,在 `FiberDisposed` 之后追加一个状态: + +```go + // FiberSkipped indicates the plugin never activated because its configuration gate + // evaluated to false, so an alternative provider took over. + FiberSkipped FiberState = "SKIPPED" +``` + +`Load` 之后追加 `Skip`: + +```go +// Skip transitions a pending plugin to FiberSkipped and releases its scoped Context. +// Active plugins are left untouched, making the call safe to replay during reconcile. +func (f *Fiber) Skip() error { + f.mu.Lock() + if f.state != FiberPending { + f.mu.Unlock() + return nil + } + f.state = FiberSkipped + f.mu.Unlock() + + return f.ctx.Dispose() +} + +// Skipped reports whether the plugin was excluded by its configuration gate. +func (f *Fiber) Skipped() bool { + return f.State() == FiberSkipped +} +``` + +> **不要改 `DependenciesSatisfied`**:依赖能否满足完全由 IoC 容器解析决定。被跳过的插件从未执行 `Apply`,也就没有 `core.Provide`,其消费者自然解析不到服务并在 `Reconcile` 里报"waiting for"。用 Fiber 状态做短路是错误语义。 + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd backend && go test ./core/ -run 'TestFiber' -v` +Expected: 新增两个用例 `--- PASS`,既有 `TestFiber_ConfluenceAndReactiveActivation`、`TestFiber_UnsatisfiedDependencyReturnsError` 无回归。 + +- [ ] **Step 5: 提交** + +```bash +git add backend/core/fiber.go backend/core/fiber_test.go +git commit -m "feat(core): add skipped fiber state for configuration gates" +``` + +--- + +## Task 6: App 装配选项、解析屏障与门禁求值 + +**Files:** +- Modify: `backend/core/app.go` +- Test: `backend/core/app_test.go`(追加) + +- [ ] **Step 1: 追加失败的测试** + +在 `backend/core/app_test.go` 末尾追加(复用 Task 5 的 `gatedPlugin`/`gateConfig`;`mapSource` 为本测试自备的内存源): + +```go +// mapSource implements core.ConfigSource over static maps. +type mapSource struct { + values map[string]any + env map[string]string +} + +func (m *mapSource) Lookup(path string) (any, bool) { + v, ok := m.values[path] + return v, ok +} +func (m *mapSource) LookupEnv(name string) (string, bool) { + v, ok := m.env[name] + return v, ok +} +func (m *mapSource) Describe() string { return "map" } + +func newGateSource(enabled bool) *mapSource { + src := &mapSource{values: map[string]any{"gate.enabled": enabled}, env: map[string]string{}} + return src +} + +func TestAppPrepareResolvesAndGatesPlugins(t *testing.T) { + redisLike := &gatedPlugin{name: "cache", enabled: true} + redisAlt := &gatedPlugin{name: "cache_memory", enabled: false} + + app := core.NewApp( + core.WithProfile(core.ProfileAPI), + core.WithConfigSource(newGateSource(true)), + ) + app.Use(redisLike, redisAlt) + require.NoError(t, app.Prepare()) + + cacheFiber, ok := app.Fiber("cache") + require.True(t, ok) + require.Equal(t, core.FiberPending, cacheFiber.State(), "Prepare only builds the resolution barrier") + assert.True(t, app.Context().Config().Resolved()) + + require.NoError(t, app.Reconcile()) + + require.Equal(t, core.FiberActive, cacheFiber.State()) + + memoryFiber, ok := app.Fiber("cache_memory") + require.True(t, ok) + assert.Equal(t, core.FiberSkipped, memoryFiber.State()) + assert.False(t, redisAlt.applied) +} + +func TestAppGatesPluginsMountedAfterPrepare(t *testing.T) { + app := core.NewApp(core.WithConfigSource(newGateSource(true))) + require.NoError(t, app.Prepare()) + + late := &gatedPlugin{name: "cache_memory", enabled: false} + app.Use(late) + require.NoError(t, app.Reconcile()) + + fiber, ok := app.Fiber("cache_memory") + require.True(t, ok) + assert.Equal(t, core.FiberSkipped, fiber.State(), + "plugins added after Prepare must still be gated") +} + +func TestAppApplyPluginsGatesImplicitly(t *testing.T) { + redisLike := &gatedPlugin{name: "cache", enabled: true} + app := core.NewApp(core.WithConfigSource(newGateSource(false))) + app.Use(redisLike) + + require.NoError(t, app.ApplyPlugins()) + + fiber, ok := app.Fiber("cache") + require.True(t, ok) + assert.Equal(t, core.FiberSkipped, fiber.State(), "ApplyPlugins must resolve and gate implicitly") +} + +func TestAppPrepareReportsConfigurationErrors(t *testing.T) { + src := &mapSource{values: map[string]any{"gate.enabled": "yes"}, env: map[string]string{}} + app := core.NewApp(core.WithConfigSource(src)) + app.Use(&gatedPlugin{name: "cache", enabled: true}) + + err := app.Prepare() + require.Error(t, err) + assert.Contains(t, err.Error(), "gate.enabled") +} + +func TestAppSetShutdownTimeoutOverridesDefault(t *testing.T) { + app := core.NewApp() + app.SetShutdownTimeout(0) + assert.NotZero(t, app.ShutdownTimeout(), "zero durations must not shrink the kernel fallback") + + app.SetShutdownTimeout(45 * time.Second) + assert.Equal(t, 45*time.Second, app.ShutdownTimeout()) +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd backend && go test ./core/ -run 'TestAppPrepare|TestAppStartRunsPrepare|TestAppSetShutdown' -v` +Expected: 编译失败,报 `core.WithConfigSource undefined`、`app.Prepare undefined`、`app.ShutdownTimeout undefined`。 + +- [ ] **Step 3: 写实现** + +`backend/core/app.go` 中,`App` 结构体追加两个字段: + +```go + migrationEngine MigrationEngine + shutdownTimeout time.Duration + configSource ConfigSource + prepared bool +``` + +`AppOption` 区追加选项(放在 `WithShutdownTimeout` 之后): + +```go +// WithConfigSource installs the raw configuration source adapter, typically built by +// an infrastructure package outside the kernel, before any plugin is applied. +func WithConfigSource(src ConfigSource) AppOption { + return func(a *App) { + if src == nil { + return + } + a.configSource = src + a.ctx.Config().SetSource(src) + } +} + +// WithConfigDecl lets the composition root declare the configuration it reads itself, +// so host-level values participate in conflict validation and redacted reporting. +func WithConfigDecl(pluginID string, bindings ...ConfigBinding) AppOption { + return func(a *App) { + if len(bindings) == 0 { + return + } + if err := a.ctx.Config().Declare(pluginID, bindings...); err != nil { + a.applyErr = err + } + } +} +``` + +`App` 结构体再加 `applyErr error` 字段,并在 `NewApp` 末尾返回前保持原逻辑(`applyErr` 由 `Prepare` 首次上报)。 + +追加 `Prepare`、`ShutdownTimeout`、`SetShutdownTimeout`: + +```go +// Prepare resolves declared configuration and evaluates plugin gates. It is idempotent +// and runs implicitly from ApplyPlugins, so callers that need resolved values earlier +// (for example to size a shutdown budget) can invoke it explicitly. +func (a *App) Prepare() error { + a.mu.Lock() + defer a.mu.Unlock() + + if err := a.applyErr; err != nil { + return err + } + return a.prepareLocked() +} + +func (a *App) prepareLocked() error { + if a.prepared { + return nil + } + + if err := a.ctx.Config().Resolve(); err != nil { + return err + } + a.prepared = true + + return nil +} + +// ShutdownTimeout returns the graceful shutdown budget for the application. +func (a *App) ShutdownTimeout() time.Duration { + a.mu.RLock() + defer a.mu.RUnlock() + return a.shutdownTimeout +} + +// SetShutdownTimeout replaces the graceful shutdown budget, ignoring non-positive values. +func (a *App) SetShutdownTimeout(timeout time.Duration) *App { + a.mu.Lock() + defer a.mu.Unlock() + if timeout > 0 { + a.shutdownTimeout = timeout + } + return a +} +``` + +> **门禁为什么在 `reconcileLocked` 内求值而不是 `Prepare` 里一次性遍历**:`App.Use` 可以在 `Prepare` 之后继续挂载插件(下游定制与动态装配)。只在 `Prepare` 求值会留下一批永不判定的门禁;放在调和循环里则任何时刻新挂载的插件都会被正确判定,且 `Fiber.Skip` 自带"仅 Pending 可跳过"守卫,重复遍历安全。 + +`Use` 中,为每个成功登记的插件收集声明(放在 `a.pluginMap[name] = p` 之前): + +```go + if gated, ok := p.(ConfigGatedPlugin); ok { + if err := a.ctx.Config().Declare(name, gated.DeclareConfig()...); err != nil { + if a.applyErr == nil { + a.applyErr = err + } + } + } +``` + +`ApplyPlugins` 与 `reconcileLocked` 接入屏障(`ApplyPlugins` 已持锁,改调用 `prepareLocked`): + +```go +func (a *App) ApplyPlugins() error { + a.mu.Lock() + if a.applied { + a.mu.Unlock() + return nil + } + a.applied = true + + if err := a.applyErr; err != nil { + a.mu.Unlock() + return err + } + if err := a.prepareLocked(); err != nil { + a.mu.Unlock() + return err + } + a.mu.Unlock() + + return a.Reconcile() +} +``` + +把 `reconcileLocked` 的内层循环替换为带门禁判定的版本(其余保持不变): + +```go +func (a *App) reconcileLocked() error { + if err := a.prepareLocked(); err != nil { + return err + } + + view := a.ctx.Config() + + for { + progress := false + for _, f := range a.fibers { + if f.State() != FiberPending { + continue + } + + if gated, ok := f.plugin.(ConfigGatedPlugin); ok { + if !view.Resolved() { + continue + } + if !gated.ConfigEnabled(view) { + if err := f.Skip(); err != nil { + return fmt.Errorf("core: skip gated plugin %q: %w", f.Name(), err) + } + continue + } + } + + if f.DependenciesSatisfied(a.ctx) { + if err := f.Load(); err != nil { + return fmt.Errorf("core: load fiber %q failed: %w", f.Name(), err) + } + progress = true + } + } + if !progress { + break + } + } + + // ...existing unsatisfied-dependency reporting unchanged +} +``` + +`unsatisfied` 收集循环无需改动:它只统计 `FiberPending`,被门禁排除的插件已是 `FiberSkipped`。 + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd backend && go test ./core/ -v` +Expected: 全部 `--- PASS`,包括既有 `TestApp*`、`TestFiber*`、`TestContext*`。 + +- [ ] **Step 5: 全量回归(应用行为必须不变)** + +Run: `cd backend && go test ./... && go build -o /dev/null ./...` +Expected: 全绿。此时业务插件与 `cmd` 仍走旧的全局单例,因此运行行为与迁移前完全一致——这是本计划的关键安全属性。 + +- [ ] **Step 6: 格式与静态检查** + +Run: `cd backend && golangci-lint fmt ./core/... && golangci-lint run ./core/...` +Expected: 无告警。 + +- [ ] **Step 7: 提交** + +```bash +git add backend/core/app.go backend/core/app_test.go +git commit -m "feat(core): add config resolution barrier and plugin gating to App" +``` + +--- + +## Task 7: viper 配置源适配器(plugins/infra/config) + +**Files:** +- Create: `backend/plugins/infra/config/source.go` +- Create: `backend/plugins/infra/config/source_test.go` + +- [ ] **Step 1: 写失败的测试** + +创建 `backend/plugins/infra/config/source_test.go`: + +```go +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +package config_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "Wavelet/plugins/infra/config" +) + +const sampleYAML = "" + + "app:\n addr: \":8000\"\n node_id: 1\n" + + "database:\n enabled: false\n port: 5432\n slow_threshold: 200ms\n" + + "redis:\n addrs:\n - \"127.0.0.1:6379\"\n" + +func writeConfig(t *testing.T) string { + t.Helper() + + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte(sampleYAML), 0o600)) + return path +} + +func TestSourceLooksUpNestedPaths(t *testing.T) { + src, err := config.NewSource(config.WithPath(writeConfig(t))) + require.NoError(t, err) + + value, ok := src.Lookup("database.port") + require.True(t, ok) + assert.Equal(t, 5432, value) + + _, ok = src.Lookup("database.missing") + assert.False(t, ok) +} + +func TestSourceTreatsUnsetFileAsEnvOnly(t *testing.T) { + missing := filepath.Join(t.TempDir(), "absent.yaml") + + src, err := config.NewSource(config.WithPath(missing)) + require.NoError(t, err, "a missing configuration file must fall back to environment values") + + _, ok := src.Lookup("app.addr") + assert.False(t, ok) + assert.Equal(t, config.EnvOnlyOrigin, src.Describe()) +} + +func TestSourceRejectsMalformedFile(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "config.yaml") + require.NoError(t, os.WriteFile(path, []byte("app: [unclosed\n"), 0o600)) + + _, err := config.NewSource(config.WithPath(path)) + require.Error(t, err) +} + +func TestSourceLookupEnvReadsProcessEnvironment(t *testing.T) { + t.Setenv("WAVELET_SOURCE_PROBE", "present") + + src, err := config.NewSource(config.WithPath(writeConfig(t))) + require.NoError(t, err) + + value, ok := src.LookupEnv("WAVELET_SOURCE_PROBE") + require.True(t, ok) + assert.Equal(t, "present", value) + + _, ok = src.LookupEnv("WAVELET_SOURCE_ABSENT") + assert.False(t, ok) +} +``` + +- [ ] **Step 2: 运行测试确认失败** + +Run: `cd backend && go test ./plugins/infra/config/ -v` +Expected: 编译失败,报 `package Wavelet/plugins/infra/config is not in std` / `undefined: config.NewSource`。 + +- [ ] **Step 3: 写实现** + +创建 `backend/plugins/infra/config/source.go`: + +```go +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Package config adapts viper to the kernel configuration source contract. It is a +// runtime adapter rather than a core.Plugin: it owns no routes, services or tasks, and +// therefore never appears in app.Use. Keeping it out of core preserves the micro-kernel +// rule against importing concrete runtime dependencies. +package config + +import ( + "errors" + "fmt" + "os" + + "github.com/spf13/viper" +) + +// DefaultFileName is the configuration file looked up when CONFIG_PATH is unset. +const DefaultFileName = "config.yaml" + +// EnvOnlyOrigin is reported by Describe when no configuration file was loaded. +const EnvOnlyOrigin = "" + +// maxSearchDepth bounds the upward directory walk so a misconfigured working set +// cannot make the loader scan the whole filesystem. +const maxSearchDepth = 5 + +// Option configures a Source. +type Option func(*Source) + +// WithPath pins the configuration file, bypassing CONFIG_PATH and the upward search. +func WithPath(path string) Option { + return func(s *Source) { + s.path = path + } +} + +// Source implements core.ConfigSource over a configuration file plus the process environment. +type Source struct { + v *viper.Viper + path string + found bool +} + +// NewSource loads the configuration file. A missing file is not an error: the source +// then serves environment values only, matching the previous pkg/config behaviour. +func NewSource(opts ...Option) (*Source, error) { + s := &Source{} + for _, opt := range opts { + opt(s) + } + + if s.path == "" { + s.path = os.Getenv("CONFIG_PATH") + } + if s.path == "" { + s.path = findConfigPath(DefaultFileName) + } + + v := viper.New() + v.SetConfigFile(s.path) + + err := v.ReadInConfig() + switch { + case err == nil: + s.found = true + case isNotFound(err): + // fall through to environment-only lookups + default: + if _, statErr := os.Stat(s.path); statErr == nil { //nolint:gosec // s.path comes from CONFIG_PATH or a bounded upward search + return nil, fmt.Errorf("infra/config: read %s: %w", s.path, err) + } + } + + s.v = v + return s, nil +} + +func isNotFound(err error) bool { + var notFound viper.ConfigFileNotFoundError + return errors.As(err, ¬Found) || errors.Is(err, os.ErrNotExist) +} + +// Lookup returns the raw value stored at a dotted path, or false when the file was not +// loaded or the path is absent. +func (s *Source) Lookup(path string) (any, bool) { + if !s.found || !s.v.IsSet(path) { + return nil, false + } + return s.v.Get(path), true +} + +// LookupEnv reads a process environment variable. +func (s *Source) LookupEnv(name string) (string, bool) { + return os.LookupEnv(name) +} + +// Describe returns the loaded file path, or EnvOnlyOrigin when running on environment values. +func (s *Source) Describe() string { + if !s.found { + return EnvOnlyOrigin + } + return s.path +} + +// findConfigPath searches upward from the working directory so tests and binaries run +// from backend/ still find the repository-root configuration file. +func findConfigPath(configPath string) string { + if _, err := os.Stat(configPath); err == nil { + return configPath + } + + dir := "." + for range maxSearchDepth { + dir += "/.." + path := dir + "/" + configPath + if _, err := os.Stat(path); err == nil { + return path + } + } + return configPath +} +``` + +- [ ] **Step 4: 运行测试确认通过** + +Run: `cd backend && go test ./plugins/infra/config/ -v` +Expected: 四个用例全部 `--- PASS`。 + +- [ ] **Step 5: 格式与静态检查** + +Run: `cd backend && golangci-lint fmt ./plugins/infra/config/ && golangci-lint run ./plugins/infra/config/` +Expected: 无告警。 + +- [ ] **Step 6: 提交** + +```bash +git add backend/plugins/infra/config/ +git commit -m "feat(infra): add viper backed configuration source adapter" +``` + +--- + +## Task 8: 旧配置包可重入重构 + 新旧对拍 + +**Files:** +- Modify: `backend/pkg/config/config.go:57-108` +- Create: `backend/pkg/config/parity_test.go`(临时文件,P4 随旧包一并删除) + +- [ ] **Step 1: 重构旧加载器为可重入函数** + +把 `backend/pkg/config/config.go` 的 `init()` 拆成 `load` + `init`。原实现使用包级 `viper` 全局并在 `init` 里内联全部步骤,对拍需要能反复调用且不受 `isTest()` 干扰: + +```go +// load reads configuration from configPath, applies defaults and environment overrides, +// and optionally disables external services for in-test runs. +func load(configPath string, testMode bool) *configModel { + v := viper.New() + v.SetConfigFile(configPath) + + if err := v.ReadInConfig(); err != nil { + var notFound viper.ConfigFileNotFoundError + if !errors.As(err, ¬Found) { + if _, statErr := os.Stat(configPath); statErr == nil { //nolint:gosec // configPath is loaded from CONFIG_PATH environment variable + log.Fatalf("[Config] read config failed: %v\n", err) + } + } + log.Println("[Config] no config file found, using environment variables only") + v.SetConfigType("yaml") + if err := v.ReadConfig(strings.NewReader("")); err != nil { + log.Fatalf("[Config] failed to init empty config: %v\n", err) + } + } + + var c configModel + if err := v.Unmarshal(&c); err != nil { + log.Fatalf("[Config] parse config failed: %v\n", err) + } + + applyDefaults(&c) + applyEnvOverrides(&c) + applyDefaults(&c) + + if testMode { + c.Database.Enabled = false + c.Database.SQLitePath = ":memory:" + c.Redis.Enabled = false + c.ClickHouse.Enabled = false + } + + return &c +} + +func init() { + configPath := os.Getenv("CONFIG_PATH") + if configPath == "" { + configPath = findConfigPath("config.yaml") + } + + Config = load(configPath, isTest()) + + printConfig(Config) +} +``` + +同步调整:`import` 增加 `"errors"`;删除原 `init` 里的 `viper.SetConfigFile`/`viper.AutomaticEnv`/`viper.ReadInConfig` 等包级调用与 `if _, ok := err.(viper.ConfigFileNotFoundError); !ok` 断言(改用上面的 `errors.As`)。 + +> **行为等价说明(评审时核对)**:`viper.AutomaticEnv()` 只影响按键读取,`Unmarshal` 走的是 `AllKeys`,因此去掉 `AutomaticEnv` 不改变解析结果;环境变量覆盖仍由 `applyEnvOverrides` 负责。 + +- [ ] **Step 2: 运行旧测试确认无回归** + +Run: `cd backend && go test ./pkg/config/ ./cmd/ -v -run 'TestApplyEnvOverrides|Test' 2>&1 | tail -30` +Expected: `pkg/config` 的 `TestApplyEnvOverridesRedisMaintNotifications` 通过;`cmd` 包既有用例结果与改动前一致(先运行一次改动前的 `go test ./cmd/` 记录基线)。 + +- [ ] **Step 3: 写对拍测试** + +创建 `backend/pkg/config/parity_test.go`: + +```go +// Copyright 2026 Arctel.net +// SPDX-License-Identifier: Apache-2.0 + +// Temporary migration harness: proves the new kernel configuration engine resolves +// every key identically to pkg/config before the legacy singleton is deleted in P4. +// Delete this file together with backend/pkg/config. +package config + +import ( + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/google/go-cmp/cmp" + "github.com/spf13/viper" + + "Wavelet/core/extpoints" +) + +// yamlSource is a test-local core.ConfigSource over the repository config file. +// It deliberately does not import plugins/infra/config: backend/pkg must not depend on +// upper layers even in tests, and the adapter has its own coverage in its package tests. +type yamlSource struct { + v *viper.Viper +} + +func newYAMLSource(t *testing.T, path string) *yamlSource { + t.Helper() + + v := viper.New() + v.SetConfigFile(path) + require.NoError(t, v.ReadInConfig()) + return &yamlSource{v: v} +} + +func (s *yamlSource) Lookup(path string) (any, bool) { + if !s.v.IsSet(path) { + return nil, false + } + return s.v.Get(path), true +} + +func (s *yamlSource) LookupEnv(name string) (string, bool) { return os.LookupEnv(name) } + +func (s *yamlSource) Describe() string { return s.v.ConfigFileUsed() } + +// engineAppConfig mirrors appConfig with engine tags. +type engineAppConfig struct { + AppName string `config:"app_name" env:"APP_NAME"` + Env string `config:"env" env:"APP_ENV"` + Addr string `config:"addr" env:"APP_ADDR"` + NodeID int64 `config:"node_id" env:"APP_NODE_ID"` + APIPrefix string `config:"api_prefix" env:"APP_API_PREFIX"` + GracefulShutdownTimeout int `config:"graceful_shutdown_timeout" env:"APP_GRACEFUL_SHUTDOWN_TIMEOUT"` + SessionCookieName string `config:"session_cookie_name" env:"APP_SESSION_COOKIE_NAME"` + SessionSecret string `config:"session_secret" env:"APP_SESSION_SECRET" secret:"true"` + SessionDomain string `config:"session_domain" env:"APP_SESSION_DOMAIN"` + SessionAge int `config:"session_age" env:"APP_SESSION_AGE" default:"86400"` + SessionHTTPOnly bool `config:"session_http_only" env:"APP_SESSION_HTTP_ONLY"` + SessionSecure bool `config:"session_secure" env:"APP_SESSION_SECURE"` +} + +type engineDatabaseConfig struct { + Enabled bool `config:"enabled" env:"DB_ENABLED" default:"false" autoEnable:"DB_HOST"` + SQLitePath string `config:"sqlite_path" env:"SQLITE_PATH"` + Host string `config:"host" env:"DB_HOST"` + Port int `config:"port" env:"DB_PORT"` + Username string `config:"username" env:"DB_USERNAME"` + Password string `config:"password" env:"DB_PASSWORD" secret:"true"` + Database string `config:"database" env:"DB_NAME"` + MaxIdleConn int `config:"max_idle_conn" env:"DB_MAX_IDLE_CONN"` + MaxOpenConn int `config:"max_open_conn" env:"DB_MAX_OPEN_CONN"` + ConnMaxLifetime int `config:"conn_max_lifetime" env:"DB_CONN_MAX_LIFETIME"` + ConnMaxIdleTime int `config:"conn_max_idle_time" env:"DB_CONN_MAX_IDLE_TIME"` + LogLevel string `config:"log_level" env:"DB_LOG_LEVEL"` + SSLMode string `config:"ssl_mode" env:"DB_SSL_MODE"` + TimeZone string `config:"time_zone" env:"DB_TIMEZONE"` + ApplicationName string `config:"application_name" env:"DB_APPLICATION_NAME"` + SearchPath string `config:"search_path" env:"DB_SEARCH_PATH"` + PreferSimpleProtocol bool `config:"prefer_simple_protocol" env:"DB_PREFER_SIMPLE_PROTOCOL"` + StatementCacheCapacity int `config:"statement_cache_capacity" env:"DB_STATEMENT_CACHE_CAPACITY"` + DefaultQueryExecMode string `config:"default_query_exec_mode" env:"DB_DEFAULT_QUERY_EXEC_MODE"` + Replicas []engineReplicaConfig `config:"replicas"` + SlowThreshold time.Duration `config:"slow_threshold" env:"DB_SLOW_THRESHOLD"` +} + +type engineRedisConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` + Addrs []string `config:"addrs" env:"REDIS_ADDR"` + Username string `config:"username" env:"REDIS_USERNAME"` + Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"` + DB int `config:"db" env:"REDIS_DB"` + ClusterMode bool `config:"cluster_mode" env:"REDIS_CLUSTER_MODE"` + MasterName string `config:"master_name" env:"REDIS_MASTER_NAME"` + KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"` + PoolSize int `config:"pool_size" env:"REDIS_POOL_SIZE"` + MinIdleConn int `config:"min_idle_conn" env:"REDIS_MIN_IDLE_CONN"` + DialTimeout int `config:"dial_timeout" env:"REDIS_DIAL_TIMEOUT"` + ReadTimeout int `config:"read_timeout" env:"REDIS_READ_TIMEOUT"` + WriteTimeout int `config:"write_timeout" env:"REDIS_WRITE_TIMEOUT"` + MaxRetries int `config:"max_retries" env:"REDIS_MAX_RETRIES"` + PoolTimeout int `config:"pool_timeout" env:"REDIS_POOL_TIMEOUT"` + ConnMaxIdleTime int `config:"conn_max_idle_time" env:"REDIS_CONN_MAX_IDLE_TIME"` + MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS"` +} + +type engineClickHouseConfig struct { + Enabled bool `config:"enabled" env:"CLICKHOUSE_ENABLED" default:"false" autoEnable:"CLICKHOUSE_HOST"` + Hosts []string `config:"hosts" env:"CLICKHOUSE_HOST"` + Username string `config:"username" env:"CLICKHOUSE_USERNAME"` + Password string `config:"password" env:"CLICKHOUSE_PASSWORD" secret:"true"` + Database string `config:"database" env:"CLICKHOUSE_NAME"` + MaxIdleConn int `config:"max_idle_conn" env:"CLICKHOUSE_MAX_IDLE_CONN"` + MaxOpenConn int `config:"max_open_conn" env:"CLICKHOUSE_MAX_OPEN_CONN"` + ConnMaxLifetime int `config:"conn_max_lifetime" env:"CLICKHOUSE_CONN_MAX_LIFETIME"` + DialTimeout int `config:"dial_timeout" env:"CLICKHOUSE_DIAL_TIMEOUT"` + BlockBufferSize uint8 `config:"block_buffer_size" env:"CLICKHOUSE_BLOCK_BUFFER_SIZE"` +} + +type engineLogConfig struct { + Level string `config:"level" env:"LOG_LEVEL"` + Format string `config:"format" env:"LOG_FORMAT"` + Output string `config:"output" env:"LOG_OUTPUT"` + FilePath string `config:"file_path" env:"LOG_FILE_PATH"` + MaxSize int `config:"max_size" env:"LOG_MAX_SIZE"` + MaxAge int `config:"max_age" env:"LOG_MAX_AGE"` + MaxBackups int `config:"max_backups" env:"LOG_MAX_BACKUPS"` + Compress bool `config:"compress" env:"LOG_COMPRESS"` +} + +type engineOtelConfig struct { + SamplingRate float64 `config:"sampling_rate" env:"OTEL_SAMPLING_RATE"` + TracerName string `config:"tracer_name" env:"OTEL_TRACER_NAME" default:"github.com/Rain-kl/Wavelet"` +} + +// engineReplicaConfig mirrors databaseReplicaConfig, a composite element of database.replicas. +type engineReplicaConfig struct { + Host string `config:"host"` + Port int `config:"port"` + Username string `config:"username"` + Password string `config:"password"` +} + +// engineQueueConfig and engineWorkerConfig mirror the worker section, whose defaults +// legitimately move to driver_asynq_worker in P3; the repository file declares them +// explicitly, so parity is unaffected. +type engineQueueConfig struct { + Name string `config:"name"` + Priority int `config:"priority"` +} + +type engineWorkerConfig struct { + Concurrency int `config:"concurrency" env:"WORKER_CONCURRENCY"` + StrictPriority bool `config:"strict_priority" env:"WORKER_STRICT_PRIORITY"` + Queues []engineQueueConfig `config:"queues"` +} + +func repositoryConfig(t *testing.T) string { + t.Helper() + + path := filepath.Join("..", "..", "config.yaml") + if _, statErr := os.Stat(path); statErr != nil { + t.Skip("repository root config.yaml is unavailable") + } + return path +} + +// flatten exports a struct into dotted leaf paths rendered as text. Both sides of the +// parity assertion use distinct Go types for the same shape, so values are compared +// textually instead of handing cmp a cross-type diff. +func flatten(prefix string, v reflect.Value, out map[string]string) { + t := v.Type() + + for i := 0; i < t.NumField(); i++ { + field := t.Field(i) + if field.PkgPath != "" { + continue + } + + fv := v.Field(i) + path := prefix + "." + field.Name + if fv.Kind() == reflect.Struct && fv.Type() != durationType { + flatten(path, fv, out) + continue + } + out[path] = fmt.Sprint(fv.Interface()) + } +} + +// durationType mirrors the engine's own notion of a scalar duration field. +var durationType = reflect.TypeFor[time.Duration]() + +func TestEngineParityWithLegacyLoader(t *testing.T) { + path := repositoryConfig(t) + + scenarios := []struct { + name string + env map[string]string + }{ + {name: "file only", env: nil}, + { + name: "implicit enable from hosts", + env: map[string]string{ + "DB_HOST": "postgres", "REDIS_ADDR": "redis:6379", "CLICKHOUSE_HOST": "ch:9000", + }, + }, + { + name: "explicit flags win over implicit enable", + env: map[string]string{ + "DB_HOST": "postgres", "DB_ENABLED": "false", + "REDIS_ADDR": "redis:6379", "REDIS_ENABLED": "false", + "CLICKHOUSE_HOST": "ch:9000", "CLICKHOUSE_ENABLED": "false", + }, + }, + { + name: "scalar overrides and duration parsing", + env: map[string]string{ + "LOG_LEVEL": "debug", "APP_ADDR": ":9999", "DB_SLOW_THRESHOLD": "1s", + "REDIS_MAINT_NOTIFICATIONS": "true", "OTEL_SAMPLING_RATE": "0.5", + }, + }, + } + + for _, scenario := range scenarios { + t.Run(scenario.name, func(t *testing.T) { + for name, value := range scenario.env { + t.Setenv(name, value) + } + + legacy := load(path, false) + + src := newYAMLSource(t, path) + + engine := extpoints.NewConfigRegistry(src) + require.NoError(t, engine.Declare("parity", + extpoints.ConfigBinding{Prefix: "app", Target: &engineAppConfig{}}, + extpoints.ConfigBinding{Prefix: "database", Target: &engineDatabaseConfig{}}, + extpoints.ConfigBinding{Prefix: "redis", Target: &engineRedisConfig{}}, + extpoints.ConfigBinding{Prefix: "clickhouse", Target: &engineClickHouseConfig{}}, + extpoints.ConfigBinding{Prefix: "log", Target: &engineLogConfig{}}, + extpoints.ConfigBinding{Prefix: "otel", Target: &engineOtelConfig{}}, + extpoints.ConfigBinding{Prefix: "worker", Target: &engineWorkerConfig{}}, + )) + require.NoError(t, engine.Resolve()) + + var app engineAppConfig + var database engineDatabaseConfig + var redis engineRedisConfig + var clickhouse engineClickHouseConfig + var log engineLogConfig + var otel engineOtelConfig + var worker engineWorkerConfig + for _, binding := range []struct { + prefix string + target any + }{ + {"app", &app}, {"database", &database}, {"redis", &redis}, + {"clickhouse", &clickhouse}, {"log", &log}, {"otel", &otel}, {"worker", &worker}, + } { + require.NoError(t, engine.Bind(binding.prefix, binding.target)) + } + + // The legacy loader keeps two code-level defaults outside its tags; the engine + // expresses them as declared defaults, so normalise before diffing (spec C1). + if legacy.App.SessionAge <= 0 { + legacy.App.SessionAge = 86400 + } + if legacy.Otel.TracerName == "" { + legacy.Otel.TracerName = "github.com/Rain-kl/Wavelet" + } + + legacyFlat := map[string]string{} + flatten("app", reflect.ValueOf(legacy.App), legacyFlat) + flatten("database", reflect.ValueOf(legacy.Database), legacyFlat) + flatten("redis", reflect.ValueOf(legacy.Redis), legacyFlat) + flatten("clickhouse", reflect.ValueOf(legacy.ClickHouse), legacyFlat) + flatten("log", reflect.ValueOf(legacy.Log), legacyFlat) + flatten("otel", reflect.ValueOf(legacy.Otel), legacyFlat) + flatten("worker", reflect.ValueOf(legacy.Worker), legacyFlat) + + engineFlat := map[string]string{} + flatten("app", reflect.ValueOf(app), engineFlat) + flatten("database", reflect.ValueOf(database), engineFlat) + flatten("redis", reflect.ValueOf(redis), engineFlat) + flatten("clickhouse", reflect.ValueOf(clickhouse), engineFlat) + flatten("log", reflect.ValueOf(log), engineFlat) + flatten("otel", reflect.ValueOf(otel), engineFlat) + flatten("worker", reflect.ValueOf(worker), engineFlat) + + assert.Empty(t, cmp.Diff(legacyFlat, engineFlat), "engine resolution drifted from legacy loader") + }) + } +} +``` + +测试文件的 import 块必须包含:`fmt`、`os`、`path/filepath`、`reflect`、`testing`、`time`、`github.com/google/go-cmp/cmp`、`github.com/spf13/viper`、`github.com/stretchr/testify/assert`、`github.com/stretchr/testify/require`、`Wavelet/core/extpoints`。 + +- [ ] **Step 4: 运行对拍确认等价** + +Run: `cd backend && go test ./pkg/config/ -run TestEngineParityWithLegacyLoader -v` +Expected: 四个场景全部 `--- PASS`,无任何 `drifted` 断言输出。若出现 drift,逐项核对是否为 spec §4.3 登记的 C1–C5 有意差异:是则在该场景补注释说明差异来源,否则按 drift 修复引擎。 + +- [ ] **Step 5: 确认既有测试与构建仍全绿** + +Run: `cd backend && go test ./... && go build -o /dev/null ./...` +Expected: 全绿。 + +- [ ] **Step 6: 提交** + +```bash +git add backend/pkg/config/ +git commit -m "refactor(config): make legacy loader reentrant and add engine parity test" +``` + +--- + +## Task 9: 架构门禁脚本禁止 core 引入 viper + +**Files:** +- Modify: `scripts/check_cordis_architecture.sh:57-65` + +- [ ] **Step 1: 扩展检查项** + +把 `CORE_FRAMEWORK_IMPORTS` 的匹配式加入 viper、mapstructure 与 gin 的兄弟框架,使配置装载依赖无法渗进内核: + +```bash +CORE_FRAMEWORK_IMPORTS=$(rg -n '"github.com/gin-gonic/gin"|"gorm.io/gorm"|"github.com/hibiken/asynq"|"github.com/robfig/cron|"github.com/spf13/viper"|"github.com/mitchellh/mapstructure"' \ + "${BACKEND_DIR}/core/" --glob '*.go' -g '!*contracts*' -g '!*_test.go' || true) +``` + +同步更新失败提示文案,列出新增的两个包: + +```bash + log_fail "backend/core/ 严禁导入具体 Web/ORM/Worker/Config 运行时框架 (gin, gorm, asynq, cron, viper, mapstructure):" +``` + +- [ ] **Step 2: 运行脚本确认通过** + +Run: `./scripts/check_cordis_architecture.sh` +Expected: `✓ backend/core/ 无重型框架依赖`,末行 `✓ 所有 Cordis 架构合规性检查全部通过 (0 Violations)!`,退出码 0。 + +- [ ] **Step 3: 反向验证检查有效性** + +Run: `printf 'package core\n\nimport _ "github.com/spf13/viper"\n' > backend/core/zz_probe_tmp.go && ./scripts/check_cordis_architecture.sh; STATUS=$?; rm backend/core/zz_probe_tmp.go; exit $STATUS` +Expected: 报 `✗ [FAIL] backend/core/ 严禁导入具体 Web/ORM/Worker/Config 运行时框架`,退出码非 0。(临时文件必须删除,用后确认 `git status --short` 干净。) + +- [ ] **Step 4: 提交** + +```bash +git add scripts/check_cordis_architecture.sh +git commit -m "chore(arch): forbid viper and mapstructure inside the micro-kernel" +``` + +--- + +## Task 10: 收尾验证与文档同步 + +**Files:** +- Modify: `AGENTS.md`(Cordis 分层清单补配置扩展点条目) +- Modify: `docs/WAVELET_WHITE_PAPER.md`(§5.1 顶级分包说明) + +- [ ] **Step 1: 写内核侧用法文档** + +在 `AGENTS.md` 的“严格遵循事项 (Guardrails)”中,`扩展点自包含注册` 列表内 `动态配置` 一条之后补充: + +```markdown + - **静态配置声明**:插件在 `Apply` 中通过 `ctx.Config().Bind("", &cfg)` 读取自己声明的配置(字段用 `config` / `env` / `default` / `autoEnable` / `secret` tag 声明);需要在 `Apply` 之前被门禁求值的键,必须在 `DeclareConfig()` 中提前声明。**严禁**新增全局配置单例或在 `backend/pkg/` 读取配置。 +``` + +- [ ] **Step 2: 修正白皮书漂移** + +在 `docs/WAVELET_WHITE_PAPER.md` §5.1 的顶级分包说明后追加一条: + +```markdown +- **配置所有权下沉**:`backend/pkg/config` 全局单例已废除。`core/extpoints` 只提供配置声明与解析引擎(不 import viper),`plugins/infra/config` 承担文件与环境装载,读哪些字段由各插件自行声明;组合根不再跨插件判断配置选实现,改由 `ConfigGatedPlugin` 门禁 + `FiberSkipped` 决定激活方。 +``` + +- [ ] **Step 3: 全量门禁** + +Run: `make code-check` +Expected: 架构脚本 0 Violations;`golangci-lint run` 无输出;前端 tsc 与 eslint 无新增错误(本计划未触碰前端,若报既有错误需确认为基线问题)。 + +- [ ] **Step 4: 格式化** + +Run: `make format` +Expected: gofumpt 无 diff 或自动格式化;`git status --short` 中出现的文件需一并纳入下一步。 + +- [ ] **Step 5: 实跑确认应用行为未变** + +Run: `cd backend && go run main.go all 2>&1 | head -40` +Expected: banner 正常输出、迁移日志正常、`[Config] loaded configuration` 出现,进程正常启动后 Ctrl-C 优雅退出。**这是 P1/P2 的验收线:新能力已就位,生产路径仍走旧单例,行为必须与改动前一致。** + +- [ ] **Step 6: 提交** + +```bash +git add AGENTS.md docs/WAVELET_WHITE_PAPER.md +git commit -m "docs(config): record the configuration extension point ownership rules" +``` + +--- + +## 完成标准(本计划) + +1. `cd backend && go test ./...` 与 `go build ./...` 全绿;`make code-check` 与 `./scripts/check_cordis_architecture.sh` 零违规。 +2. 引擎单测覆盖:优先级四档、`autoEnable` 与显式 env 的相对优先、标量 env→切片、`time.Duration`、结构体切片、冲突校验、脱敏导出、无 source 时的错误路径。 +3. `TestEngineParityWithLegacyLoader` 四个场景零 drift,证明除 spec §4.3 的 C1–C5 外解析结果与旧实现逐 key 等价。 +4. 同时挂载门禁谓词相反的两个插件时,恰好一个 `FiberActive`、一个 `FiberSkipped`,且被跳过者 `Apply` 从未执行。 +5. `core/` 不出现 viper/mapstructure import,且架构脚本能主动拦截该违规。 +6. 生产启动路径行为未变(仍由 `config.Config` 供值),业务插件与 `cmd` 零改动。 + +--- + +## 与 spec 的偏差(实施时须回写 spec) + +计划编写阶段的自审发现三处与本 spec 已批准版本不一致的实现,均属实现期发现的正确性问题。在 Task 10 落库时一并回写 `docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md`: + +| # | spec 原述 | 本计划实现 | 理由 | +| :--- | :--- | :--- | :--- | +| S1 | §4.3 C1 与 §6 把 `app.session_age<=0` 的判定列为内核解析错误(`ErrConfigInvalid`) | 引擎不做值域校验;`ErrConfigInvalid` 保留为源级校验占位,值域由声明者在 `Bind` 之后自行校验(auth 校验 `SessionAge > 0` 并使 `Apply` 失败) | 引擎被设计成不认识任何业务 key 的语义,让它知道"session_age 必须为正"会破坏该不变式并把业务规则塞进内核 | +| S2 | §3.2 `ConfigView` 含 `Source(key) string` | 更名 `Origin(key)`,并新增 `Value(key) (any, bool)`;`ConfigExtension` 新增 `SetSource(src)` 与 `Resolved()` | `Source` 与类型名 `ConfigSource` 在同文件内易混淆;`Value` 是 `core.ConfigGet[T]` 的支撑(Go 方法不能带类型参数);`SetSource` 进接口以避免 `WithConfigSource` 里的运行时类型断言 | +| S3 | §3.5 只给出 `WithShutdownTimeout` 与 `app.Prepare()` | 额外新增 `App.ShutdownTimeout()` 读取器与 `SetShutdownTimeout(d) *App`(链式,与既有 `WithProfile` 风格一致) | 组合根需要在 `Prepare()` 之后把已解析的预算写回内核,原先只有构造期选项,无法表达该顺序 | + +回写时同步修正 §7.3 的分期编号:本计划覆盖 P1 + P2,P3 + P4 由后续计划承接(`pkg/idgen` 解耦、27 个文件迁移、删除 `backend/pkg/config`)。 diff --git a/docs/superpowers/specs/2026-08-27-cordis-downstream-developer-guide.md b/docs/superpowers/specs/2026-08-27-cordis-downstream-developer-guide.md new file mode 100644 index 00000000..50af64b9 --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-cordis-downstream-developer-guide.md @@ -0,0 +1,594 @@ +# Wavelet Cordis 插件化架构实战开发指南与标准规范 + +- **文档类型**: 下游开发者手册 / 架构实战指南 (Cookbook & Architecture Reference) +- **目标受众**: 官方插件开发者、下游业务二开工程师、架构师 +- **版本**: v1.0.0 (2026-08-27) + +--- + +# 目录 +- [第一部分:下游项目实战开发指南与 22 个高频开发场景解答](#第一部分下游项目实战开发指南与-22-个高频开发场景解答) + - [场景 1:插件必须要实现哪些方法与契约?](#场景-1插件必须要实现哪些方法与契约) + - [场景 2:插件间如何进行单向服务调用?](#场景-2插件间如何进行单向服务调用) + - [场景 3:插件间存在双向/循环调用时如何解决(杜绝 import cycle)?](#场景-3插件间存在双向循环调用时如何解决杜绝-import-cycle) + - [场景 4:如何开发并注册一个 HTTP API 接口?如何添加路由中间件?](#场景-4如何开发并注册一个-http-api-接口如何添加路由中间件) + - [场景 5:如何获取当前登录用户信息?](#场景-5如何获取当前登录用户信息) + - [场景 6:如何开发并注册一个 Asynq 异步 Worker 任务?](#场景-6如何开发并注册一个-asynq-异步-worker-任务) + - [场景 7:如何开发并注册一个 Cron 定时任务?](#场景-7如何开发并注册一个-cron-定时任务) + - [场景 8:数据库表结构如何声明?ORM 模型规范是什么?](#场景-8数据库表结构如何声明orm-模型规范是什么) + - [场景 9:数据库如何做独立迁移?Goose SQL 怎么组织?](#场景-9数据库如何做独立迁移goose-sql-怎么组织) + - [场景 10:如果有多个业务插件需要读写同一张表怎么办?](#场景-10如果有多个业务插件需要读写同一张表怎么办) + - [场景 11:如果跨插件操作多张表,如何确保事务一致性?](#场景-11如果跨插件操作多张表如何确保事务一致性) + - [场景 12:如何发布和订阅领域事件 (EventBus)?](#场景-12如何发布和订阅领域事件-eventbus) + - [场景 13:如何向系统注册插件自定义配置(config.yaml 与管理台热加载设置)?](#场景-13如何向系统注册插件自定义配置configyaml-与管理台热加载设置) + - [场景 14:如何使用多层缓存(RAM L1 + Redis L2 + PubSub 同步)?](#场景-14如何使用多层缓存ram-l1--redis-l2--pubsub-同步) + - [场景 15:如何使用分布式锁 (DistLock) 防止并发超卖与重复消费?](#场景-15如何使用分布式锁-distlock-防止并发超卖与重复消费) + - [场景 16:如何向管理后台动态注册监控数据与管理控制台?](#场景-16如何向管理后台动态注册监控数据与管理控制台) + - [场景 17:插件如何实现健康检查探针与就绪检查 (Health Check)?](#场景-17插件如何实现健康检查探针与就绪检查-health-check) + - [场景 18:插件如何扩展其他插件的能力(如新增一种 OAuth 登录提供商 / 新增消息推送渠道)?](#场景-18插件如何扩展其他插件的能力如新增一种-oauth-登录提供商--新增消息推送渠道) + - [场景 19:插件如何编写单元测试与集成测试(Mock 上下文与依赖打桩)?](#场景-19插件如何编写单元测试与集成测试mock-上下文与依赖打桩) + - [场景 20:以不同角色(api / worker / schedule / all)启动时,插件代码如何适配?](#场景-20以不同角色api--worker--schedule--all启动时插件代码如何适配) + - [场景 21:当某个插件流量暴增需要独立拆分为微服务时,如何零成本平滑改造?](#场景-21当某个插件流量暴增需要独立拆分为微服务时如何零成本平滑改造) + - [场景 22:插件如何安全处理文件上传与大文件摄取 (upload.Ingest)?](#场景-22插件如何安全处理文件上传与大文件摄取-uploadingest) +- [第二部分:整个项目的目录结构划分与包职责定义](#第二部分整个项目的目录结构划分与包职责定义) +- [第三部分:框架核心提供给插件调用的公用能力矩阵 (Context Capability Matrix)](#第三部分框架核心提供给插件调用的公用能力矩阵-context-capability-matrix) + +--- + +# 第一部分:下游项目实战开发指南与 22 个高频开发场景解答 + +### 场景 1:插件必须要实现哪些方法与契约? +每个插件必须实现 `core.Plugin` 接口,仅需提供两个核心方法:`Name()` 与 `Apply(ctx *core.Context)`。 + +```go +package myplugin + +import "github.com/Rain-kl/Wavelet/core" + +type Plugin struct{} + +// 1. Name: 返回全局唯一的插件标识符(建议遵循命名空间规范,如 "biz.order") +func (p *Plugin) Name() string { + return "biz.order" +} + +// 2. Apply: 核心装载入口,所有的路由注册、任务注册、服务提供与依赖消费均在此完成 +func (p *Plugin) Apply(ctx *core.Context) error { + // 在此编写装载逻辑 + return nil +} +``` + +--- + +### 场景 2:插件间如何进行单向服务调用? +**规则**:插件之间**禁止直接相互 import 具体实现包**。调用方仅面向 `core/contracts` 中的纯 Interface 编程,运行时通过 Context 解析。 + +```go +// 1. 插件 A (提供者 plugins/user) 将服务注入 Context +func (p *UserPlugin) Apply(ctx *core.Context) error { + userSvc := NewUserServiceImpl(ctx.DB()) + ctx.Provide[contracts.UserService](userSvc) + return nil +} + +// 2. 插件 B (消费者 plugins/order) 声明依赖并调用 +func (p *OrderPlugin) Apply(ctx *core.Context) error { + return ctx.Using(func(userSvc contracts.UserService) { + // userSvc 已由容器自动注入就绪 + v1 := ctx.Router().Group("/api/v1/orders") + v1.POST("", func(c *gin.Context) { + userInfo, err := userSvc.GetUserProfile(c.Request.Context(), "user_123") + // 处理订单逻辑... + }) + }) +} +``` + +--- + +### 场景 3:插件间存在双向/循环调用时如何解决(杜绝 import cycle)? +**问题场景**:`auth` 登录成功后需要查 `user` 资料;`user` 重置密码后需要调 `auth` 吊销 session。若两个 package 互相 import,Go 编译器会报 `import cycle not allowed`。 + +**Cordis 解法**: +1. 接口均定义在 `core/contracts`,双方只依赖 `core/contracts`。 +2. 运行时采用 **延迟注入 (Lazy Resolution / Inject)** 或 **事件解耦 (EventBus)**: + +```go +// plugins/auth/service.go +func (s *AuthServiceImpl) OnLoginSuccess(c context.Context, uid string) { + // 延迟注入 UserService,不发生 package 级循环导入 + userSvc, err := core.Inject[contracts.UserService](s.ctx) + if err == nil { + userSvc.UpdateLastLoginTime(c, uid) + } +} +``` +*更加推荐的方式是发射领域事件*(见场景 12),由 `user` 插件自愿监听,彻底消除相互调用的硬依赖。 + +--- + +### 场景 4:如何开发并注册一个 HTTP API 接口?如何添加路由中间件? +插件通过 `ctx.Router()` 声明路由。微内核支持标准 Gin 路由组与中间件挂载: + +```go +func (p *OrderPlugin) Apply(ctx *core.Context) error { + // 获取全局或 auth 插件提供的中间件 + authSvc, _ := core.Inject[contracts.AuthService](ctx) + + // 创建带版本前缀和鉴权中间件的路由组 + group := ctx.Router().Group("/api/v1/orders", authSvc.RequireAuthMiddleware()) + + // 注册 Handler + group.GET("", p.handleListOrders) + group.POST("", p.handleCreateOrder) + group.GET("/:id", p.handleGetOrderDetail) + + return nil +} +``` + +--- + +### 场景 5:如何获取当前登录用户信息? +`auth` 插件会在上下文中注入当前用户 Session。业务 Handler 可直接调用统一 Helper: + +```go +func (p *OrderPlugin) handleCreateOrder(c *gin.Context) { + // 1. 从当前 Gin 请求上下文中提取认证用户信息 + currentUser, ok := oauth.GetCurrentUser(c) + if !ok { + response.AbortUnauthorized(c, errs.ErrUnauthorized) + return + } + + log.Printf("当前下单用户 ID: %s, 权限角色: %s", currentUser.ID, currentUser.Role) + // 2. 正常业务处理... +} +``` + +--- + +### 场景 6:如何开发并注册一个 Asynq 异步 Worker 任务? +```go +func (p *OrderPlugin) Apply(ctx *core.Context) error { + // 1. 注册 Asynq 任务类型与消费处理器 + ctx.Task().Register("order:cancel_timeout", p.handleTimeoutCancelTask) + return nil +} + +// 2. 任务执行函数 +func (p *OrderPlugin) handleTimeoutCancelTask(ctx context.Context, t *asynq.Task) error { + var payload OrderTimeoutPayload + if err := json.Unmarshal(t.Payload(), &payload); err != nil { + return err + } + // 执行超时关单业务逻辑... + return nil +} + +// 3. 业务中异步投递任务 +func (p *OrderPlugin) EnqueueTimeoutCheck(ctx context.Context, orderID string) { + p.ctx.TaskClient().EnqueueContext(ctx, asynq.NewTask("order:cancel_timeout", payloadBytes), asynq.ProcessIn(15*time.Minute)) +} +``` + +--- + +### 场景 7:如何开发并注册一个 Cron 定时任务? +```go +func (p *ReportPlugin) Apply(ctx *core.Context) error { + // 每天凌晨 2 点执行日报汇总任务 + ctx.Schedule().RegisterCron("0 2 * * *", "report:daily_summary", DailyReportPayload{Type: "all"}) + return nil +} +``` + +--- + +### 场景 8:数据库表结构如何声明?ORM 模型规范是什么? +**规范**: +1. 表名必须带有插件专有前缀(如 `w_order_`、`w_auth_`),避免跨插件表名冲突。 +2. 零值与数据库默认值严格对齐;禁止物理外键,显式建索引。 +3. 必须通过 GORM 结构体清晰声明 `gorm:"..."` 标签与 `json:"..."`。 + +```go +package models + +import "time" + +type Order struct { + ID string `gorm:"column:id;primaryKey;size:64" json:"id"` + UserID string `gorm:"column:user_id;index;size:64;not null" json:"user_id"` + Amount int64 `gorm:"column:amount;not null" json:"amount"` + Status string `gorm:"column:status;size:32;index;not null;default:'pending'" json:"status"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + UpdatedAt time.Time `gorm:"column:updated_at;autoUpdateTime" json:"updated_at"` + DeletedAt *time.Time `gorm:"column:deleted_at;index" json:"-"` +} + +func (Order) TableName() string { + return "w_orders" +} +``` + +--- + +### 场景 9:数据库如何做独立迁移?Goose SQL 怎么组织? +**彻底告别集中大迁移目录**。每个插件在内部目录建立 `migrations/`,并通过 `//go:embed` 打包注入: + +```go +// plugins/order/plugin.go +package order + +import ( + "embed" + "github.com/Rain-kl/Wavelet/core" +) + +//go:embed migrations/*.sql +var orderMigrations embed.FS + +func (p *Plugin) Apply(ctx *core.Context) error { + // 注册本插件的专属迁移(系统启动时自动按版本号执行) + ctx.Migrations().Register("order", orderMigrations) + return nil +} +``` + +#### SQL 迁移脚本规范 (`plugins/order/migrations/00001_initial.sql`): + +每个插件只需维护一个 `00001_initial.sql`,包含该插件的全部建表语句与种子数据。 + +```sql +-- +goose Up +-- +goose StatementBegin +CREATE TABLE IF NOT EXISTS w_orders ( + id VARCHAR(64) PRIMARY KEY, + user_id VARCHAR(64) NOT NULL, + amount BIGINT NOT NULL, + status VARCHAR(32) NOT NULL DEFAULT 'pending', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_w_orders_user_id ON w_orders(user_id); + +-- 种子数据(使用 ON CONFLICT DO NOTHING 保证幂等) +INSERT INTO w_orders (id, user_id, amount, status, created_at, updated_at) +VALUES ('init_001', 'system', 0, 'completed', CURRENT_TIMESTAMP, CURRENT_TIMESTAMP) +ON CONFLICT (id) DO NOTHING; +-- +goose StatementEnd + +-- +goose Down +-- +goose StatementBegin +DROP TABLE IF EXISTS w_orders; +-- +goose StatementEnd +``` + +#### 版本管理机制 + +所有插件共享一张 `w_schema_versions` 表,以 `plugin_id` 区分: + +``` +w_schema_versions (plugin_id, version_id, applied_at) +``` + +启动时,引擎遍历每个插件: +1. 查询 `w_schema_versions WHERE plugin_id = 'order'` 获取当前最大版本号 +2. 扫描插件 `migrations/` 目录下的 `.sql` 文件 +3. 如果存在未应用的版本号 → 执行迁移 +4. 如果全部已应用 → 跳过 + +```sql +-- 查看全局迁移状态 +SELECT * FROM w_schema_versions ORDER BY plugin_id, version_id; +``` + +--- + +### 场景 10:如果有多个业务插件需要读写同一张表怎么办? +**黄金准则**:**表有且仅有一个所有者插件 (Single Owner Principle)**。 +* 严禁插件 B 直接通过 SQL 修改插件 A 拥有的核心表(如订单插件直接修改用户表)。 +* **合法模式 1(服务调用)**:插件 A 提供 `UserService.DeductBalance(uid, amount)`,插件 B 调用该接口。 +* **合法模式 2(只读视图 / 共享查询 DTO)**:如果仅仅是高频联合查询(报表),插件 A 暴露只读查询接口,或通过数据库只读从库直接投影。 + +--- + +### 场景 11:如果跨插件操作多张表,如何确保事务一致性? +在插件化和微服务就绪体系下,**跨插件的强分布式事务是反模式**。 + +1. **同插件内多表操作**:直接使用本地数据库事务: + ```go + err := ctx.DB().Transaction(func(tx *gorm.DB) error { + if err := tx.Create(&order).Error; err != nil { return err } + if err := tx.Create(&orderItem).Error; err != nil { return err } + return nil + }) + ``` +2. **跨插件操作(如创建订单 + 扣减库存 + 发送通知)**: + * 采用 **最终一致性 (Eventual Consistency / Saga 模式)**。 + * 本地事务成功后,发射 `OrderCreatedEvent` 到 EventBus; + * 库存插件监听到事件后扣减库存,若失败则发布补偿事件触发订单取消。 + +--- + +### 场景 12:如何发布和订阅领域事件 (EventBus)? +```go +// 1. 定义强类型事件结构 +type OrderPaidEvent struct { + OrderID string `json:"order_id"` + UserID string `json:"user_id"` + PayAmount int64 `json:"pay_amount"` +} + +// 2. 插件 A 发布事件 +ctx.Events().Emit("order:paid", OrderPaidEvent{OrderID: "ord_1", UserID: "u_1", PayAmount: 9900}) + +// 3. 插件 B 订阅事件 +ctx.Events().On("order:paid", func(c context.Context, e OrderPaidEvent) error { + log.Printf("收到支付成功事件,开始为用户 %s 发放权益", e.UserID) + return nil +}) +``` + +--- + +### 场景 13:如何向系统注册插件自定义配置(config.yaml 与管理台热加载设置)? +```go +type OrderConfig struct { + MaxItemsPerOrder int `yaml:"max_items" json:"max_items"` + AutoCancelMins int `yaml:"auto_cancel_mins" json:"auto_cancel_mins"` +} + +func (p *OrderPlugin) Apply(ctx *core.Context) error { + var cfg OrderConfig + // 1. 自动从 config.yaml 中的 plugins.order 节点绑定配置 + ctx.Config().Bind("plugins.order", &cfg) + + // 2. 注册为管理台可动态修改的系统参数 + ctx.Settings().Register(core.SettingSchema{ + Key: "order.auto_cancel_mins", + Default: 15, + Description: "未支付订单自动取消时间 (分钟)", + }) + return nil +} +``` + +--- + +### 场景 14:如何使用多层缓存(RAM L1 + Redis L2 + PubSub 同步)? +框架提供三层穿透缓存能力,防止缓存击穿与雪崩: + +```go +func (s *OrderService) GetOrderWithCache(ctx context.Context, orderID string) (*Order, error) { + var order Order + err := s.ctx.Cache().GetOrSet(ctx, "order:"+orderID, &order, 10*time.Minute, func() (any, error) { + // Cache Miss 回源查 DB + var dbOrder Order + if err := s.db.WithContext(ctx).First(&dbOrder, "id = ?", orderID).Error; err != nil { + return nil, err + } + return &dbOrder, nil + }) + return &order, err +} + +// 当订单更新时,广播失效所有节点的 L1 内存缓存与 L2 Redis 缓存 +func (s *OrderService) InvalidateCache(ctx context.Context, orderID string) { + s.ctx.Cache().Delete(ctx, "order:"+orderID) +} +``` + +--- + +### 场景 15:如何使用分布式锁 (DistLock) 防止并发超卖与重复消费? +```go +func (s *OrderService) ProcessPayment(ctx context.Context, orderID string) error { + // 获取分布式锁,租期 5 秒 + unlock, err := s.ctx.DistLock().Lock(ctx, "lock:order:pay:"+orderID, 5*time.Second) + if err != nil { + return fmt.Errorf("当前订单正在处理中,请勿重复提交") + } + defer unlock() // 确保释放 + + // 执行扣款操作... + return nil +} +``` + +--- + +### 场景 16:如何向管理后台动态注册监控数据与管理控制台? +插件可以向管理后台扩展点注入自己的仪表盘指标和诊断探针: + +```go +func (p *OrderPlugin) Apply(ctx *core.Context) error { + ctx.Admin().RegisterMetric("order_count_today", func(c context.Context) any { + var count int64 + ctx.DB().Model(&models.Order{}).Where("created_at >= ?", todayStart()).Count(&count) + return count + }) + return nil +} +``` + +--- + +### 场景 17:插件如何实现健康检查探针与就绪检查 (Health Check)? +```go +func (p *PaymentPlugin) Apply(ctx *core.Context) error { + ctx.Health().RegisterProbe("payment_gateway", func(ctx context.Context) error { + // 测试第三方支付网关网络连通性 + return pingPaymentGateway(ctx) + }) + return nil +} +``` + +--- + +### 场景 18:插件如何扩展其他插件的能力(如新增一种 OAuth 登录提供商 / 新增消息推送渠道)? +采用 **注册表扩展点模式 (Registry Pattern)**: + +```go +// 1. 下游编写微信登录插件 plugins/oauth_wechat +func (p *WeChatOAuthPlugin) Apply(ctx *core.Context) error { + return ctx.Using(func(authRegistry contracts.AuthRegistry) { + // 向核心 auth 插件注入微信 OAuth 实现 + authRegistry.RegisterOAuthProvider("wechat", &WeChatProvider{...}) + }) +} +``` + +--- + +### 场景 19:插件如何编写单元测试与集成测试(Mock 上下文与依赖打桩)? +微内核提供轻量测试脚手架 `coretest`: + +```go +func TestOrderCreate(t *testing.T) { + // 1. 创建内存测试专用 Context + ctx := coretest.NewMockContext(t) + + // 2. Mock 依赖的 UserService + mockUserSvc := &MockUserService{ReturnUser: &contracts.UserDTO{ID: "u_1", Balance: 1000}} + ctx.Provide[contracts.UserService](mockUserSvc) + + // 3. 装载插件 + plugin := &OrderPlugin{} + require.NoError(t, plugin.Apply(ctx)) + + // 4. 发起 HTTP 接口测试 + w := ctx.PerformRequest("POST", "/api/v1/orders", `{"item_id":"item_1"}`) + assert.Equal(t, 200, w.Code) +} +``` + +--- + +### 场景 20:以不同角色(api / worker / schedule / all)启动时,插件代码如何适配? +**开发者无需做任何特殊处理**! +插件只需在一个 `Apply` 方法中把自己的路由、任务、调度全部注册进 `Context`。微内核调度器会根据运行命令自动按需激活对应的运行时驱动,不匹配的能力保持休眠。 + +--- + +### 场景 21:当某个插件流量暴增需要独立拆分为微服务时,如何零成本平滑改造? +```go +// 1. 之前单体模式:在 main.go 中加载本地实现 +app.Use(&auth.Plugin{}) // 进程内直接运行 + +// 2. 拆分为微服务后:只需将 main.go 替换为 gRPC 客户端代理插件! +app.Use(&auth_grpc_client.Plugin{RemoteAddr: "auth-service.prod:9000"}) + +// 3. 所有依赖 auth 的业务插件(如 order, user)业务代码 0 处修改! +``` + +--- + +### 场景 22:插件如何安全处理文件上传与大文件摄取 (upload.Ingest)? +**严格规则**:禁止插件自行直接写入对象存储底层 Bucket 或直连底层文件系统。统一走平台摄取服务: + +```go +func (p *OrderPlugin) handleUploadInvoice(c *gin.Context) { + fileHeader, _ := c.FormFile("file") + + // 使用平台统一摄取引擎(自动计算哈希、防重传、生成签名 URL 与入库追踪) + ingestResult, err := upload.IngestFormFile(c.Request.Context(), fileHeader, upload.IngestPolicy{ + AllowedTypes: []string{"image/png", "application/pdf"}, + MaxSizeBytes: 10 * 1024 * 1024, + }) + if err != nil { + response.AbortBadRequest(c, errs.ErrUploadFailed) + return + } + + c.JSON(200, response.OK(gin.H{"file_url": ingestResult.URL})) +} +``` + +--- + +# 第二部分:整个项目的目录结构划分与包职责定义 + +```text +Wavelet/ +├── cmd/ # CLI 命令分发与装配入口 +│ ├── root.go # Cobra 根命令 +│ ├── server.go # 综合启动器(支持 api/worker/schedule/all profile) +│ └── migrate.go # 数据库独立迁移命令行工具 +│ +├── core/ # 【微内核引擎 (Zero Business Logic)】 +│ ├── context.go # Context 上下文总线与 Fork 树 +│ ├── container.go # 基于泛型的 IoC 服务注册与解析器 +│ ├── events.go # 强类型领域事件总线 (EventBus) +│ ├── lifecycle.go # 启动/停止生命周期编排状态机 +│ ├── contracts/ # 【跨插件标准服务契约 (纯 Interface)】 +│ │ ├── auth.go # AuthService 契约 +│ │ ├── user.go # UserService 契约 +│ │ ├── cache.go # CacheService 契约 +│ │ └── database.go # DBService 契约 +│ └── extpoints/ # 扩展点定义 (Router, Task, Migration, Setting) +│ +├── plugins/ # 【官方标准插件库 (完全高内聚闭包)】 +│ ├── drivers/ # 运行时驱动插件 +│ │ ├── driver_http/ # Gin Web HTTP 驱动 +│ │ ├── driver_asynq_worker/ # Asynq Worker 并发消费驱动 +│ │ └── driver_asynq_cron/ # Asynq Cron 调度器驱动 +│ │ +│ ├── infra/ # 基础设施服务插件 +│ │ ├── database/ # GORM 多数据源与读写分离插件 +│ │ ├── cache/ # RAM + Redis + PubSub 缓存插件 +│ │ ├── logger/ # Zap + Otel 分布式链路追踪日志插件 +│ │ └── storage/ # S3 / OSS / Local 对象存储插件 +│ │ +│ └── domain/ # 业务领域能力插件 +│ ├── auth/ # OAuth / Session / Passkey 认证插件 +│ ├── user/ # 用户资料 / 权限 / 角色插件 +│ ├── message_gateway/ # Bot 网关 / 渠道推送插件 +│ ├── risk_control/ # 访问控制 / IP 限流 / 安全风控插件 +│ └── admin/ # 系统管理台与监控面板插件 +│ +└── downstream/ # 【下游二开项目模板与脚手架】 + ├── custom_plugins/ # 下游自定义业务插件 + ├── config.yaml # 声明启用的插件与配置文件 + └── main.go # 下游项目组合启动入口 +``` + +### 各层职责与禁止规则 (Guardrails): +1. **`core/`**: + - **职责**:纯抽象,提供 IoC、Context、EventBus 和 Lifecycle。 + - **严禁**:严禁 import 任何具体业务包,严禁 import `gin`、`gorm`、`asynq`。 +2. **`core/contracts/`**: + - **职责**:仅定义公开的 Go Interface 和公共 DTO。 + - **严禁**:严禁包含任何具体实现逻辑或 SQL 操作。 +3. **`plugins/`**: + - **职责**:所有业务逻辑和驱动实现的归宿。遵循标准分层架构(Layered Architecture / MVC 变体)。 + - **分层模式选型**: + - **模式 1(极简单文件分层,微型插件专用)**:单 package 极简结构(仅单文件 `plugin.go`, `handlers.go`, `service.go`, `repository.go`, `models.go`, `errs.go`, `migrations/`)。适用于单一实体、极小代码量 (<500行) 的微型插件。 + - **模式 2(标准独立子包分层架构,官方推荐标准)**:按职责严格物理分包(`plugin.go`, `handler/`, `service/`, `repository/`, `model/`, `errs/`, `migrations/`)。**子包内文件以纯业务实体命名(如 `user.go`、`config.go`),严禁在根包平铺 `handlers_*`、`service_*`、`repository_*` 等前缀文件**。编译器级强约束 `handler -> service -> repository -> model` 单向依赖。 + - **严禁**:插件之间严禁跨包 import 内部私有代码,跨插件调用一律走 `contracts` 接口或 `EventBus`。 + +--- + +# 第三部分:框架核心提供给插件调用的公用能力矩阵 (Context Capability Matrix) + +每个插件在 `Apply(ctx *core.Context)` 时,都可以无缝调用微内核暴露的以下标准能力: + +| 扩展点方法 | 返回类型 | 功能说明 | 适用场景 | +| :--- | :--- | :--- | :--- | +| `ctx.Router()` | `RouterExtension` | 声明 HTTP 路由、前缀分组与挂载中间件 | 暴露 API 接口、Web 控制台 | +| `ctx.Task()` | `TaskExtension` | 注册 Asynq 异步任务消费处理器 | 耗时后台任务、异步消息发送 | +| `ctx.Schedule()` | `ScheduleExtension`| 注册 Cron 定时调度任务 | 定时报表统计、周期性清理 | +| `ctx.Migrations()` | `MigrationExtension`| 注册插件专属的 Goose SQL 迁移嵌入系统 | 自建数据表、版本升级 | +| `ctx.Events()` | `EventBus` | 强类型领域事件的发布与订阅 (Emit / On) | 跨插件完全解耦通知与状态同步 | +| `ctx.Settings()` | `SettingExtension` | 声明动态可配置项(支持热更新) | 业务参数配置、管理台可调节参数 | +| `ctx.DB()` | `*gorm.DB` | 获取全局受事务与 Trace 保护的 GORM 数据源 | 数据持久化 CRUD | +| `ctx.Cache()` | `CacheService` | 三层穿透缓存(RAM L1 + Redis L2 + PubSub 广播)| 高频读数据性能加速 | +| `ctx.DistLock()` | `DistLockService` | 基于 Redis 的工业级分布式锁 | 防并发超卖、防重复执行 | +| `ctx.Logger()` | `Logger` | 携带链路 TraceID 的结构化日志记录器 | 业务日志打印与审计 | +| `ctx.Storage()` | `StorageService` | 统一对象存储读写引擎 | 文件摄取、图片持久化 | +| `core.Provide[T]`| `void` | 向全局 IoC 容器注册本插件提供的强类型服务 | 暴露自身能力给其他插件消费 | +| `core.Inject[T]` | `(T, error)` | 从全局 IoC 容器中按类型获取服务实例 | 消费其他插件暴露的服务 | +| `core.Using[T]` | `error` | 响应式声明依赖,当服务就绪时执行回调 | 声明前置依赖关系 | + diff --git a/docs/superpowers/specs/2026-08-27-cordis-plugin-architecture-design.md b/docs/superpowers/specs/2026-08-27-cordis-plugin-architecture-design.md new file mode 100644 index 00000000..ab6b30cb --- /dev/null +++ b/docs/superpowers/specs/2026-08-27-cordis-plugin-architecture-design.md @@ -0,0 +1,279 @@ +# Wavelet Cordis 微内核与全插件化架构设计规范 + +- **创建日期**: 2026-08-27 +- **状态**: Approved Design +- **架构代号**: Cordis-Wavelet (Next 5-Year Foundation) + +--- + +## 1. 背景与目标 + +### 1.1 现状与痛点 +Wavelet 当前采用中心化显式装配架构(`internal/platform/bootstrap` 与 `internal/router`),业务逻辑集中在 `internal/apps/` 下。 +随着业务功能的快速拓展,现有架构暴露出以下瓶颈: +1. **模块高耦合**:新增功能需要横跨多个中心化目录(`apps/`、`router/`、`bootstrap/`、`migrator/`、`task/handlers/`)进行插桩,难以做到随插随用与物理隔离。 +2. **下游扩展困难**:二次开发项目无法在不修改核心源码的前提下灵活扩展或替换业务模块。 +3. **缺乏清晰的运行切面**:API、Worker、Scheduler 启动模式依赖手动条件判断,维护成本高。 + +### 1.2 改造核心目标 +1. **微内核 (Micro-Kernel)**:内核仅提供上下文总线(Context)、依赖注入(IoC)、生命周期状态机与扩展点协议,内核本身零具体业务依赖。 +2. **一切皆插件 (All-in-Plugins)**:数据库、缓存、日志、HTTP 服务、任务处理、认证鉴权、消息网关及业务能力全部以插件形式挂载在 Context 上。 +3. **下游一等公民支持**:下游项目通过声明式 `app.Use(&MyPlugin{})` 引入官方或自定义插件,编译为单一高性能二进制文件。 +4. **面向未来 5 年的分布式与微服务就绪 (Monolith-First, Microservice-Ready)**:基于强类型接口契约,单体模式下零开销内存调用,高并发下支持透明替换为 gRPC/RPC 客户端插件完成微服务拆分。 + +--- + +## 2. 核心架构模型 (Core Architecture) + +``` ++-----------------------------------------------------------------------------------+ +| 下游业务项目 (Downstream Application) | +| main.go: app.Use(&logger.Plugin{}).Use(&auth.Plugin{})... | ++-----------------------------------------------------------------------------------+ + │ + ▼ ++-----------------------------------------------------------------------------------+ +| Wavelet Core (微内核上下文总线) | +| - Context (服务树与扩展点总线) - Lifecycle Manager (生命周期编排) | +| - Service Hub (泛型 IoC 容器) - EventBus (强类型领域事件总线) | ++-----------------------------------------------------------------------------------+ + │ │ + ▼ 注册与驱动 ▼ 挂载能力 ++------------------------------------+ +-------------------------------------------+ +| 运行时驱动插件 (Driver Plugins) | | 业务领域插件 (Domain Plugins) | +| - driver-http (Gin Web 引擎) | | - plugin-auth (认证/Session/OAuth) | +| - driver-worker (Asynq 消费池) | | - plugin-user (用户资料/角色权限) | +| - driver-cron (Asynq 定时调度器) | | - plugin-msg-gateway (消息通道与推送) | +| - driver-database (GORM 数据源) | | - plugin-risk-control (访问风控与限流) | +| - driver-cache (RAM/Redis 缓存) | | - [下游自定义插件] (业务私有插件) | ++------------------------------------+ +-------------------------------------------+ +``` + +--- + +## 3. 微内核协议契约与设计规范 + +### 3.1 插件契约 (`core.Plugin`) +所有官方插件与下游自定义插件均实现统一的 `Plugin` 接口: + +```go +package core + +import "context" + +// Plugin 插件统一契约 +type Plugin interface { + // Name 插件唯一标识(如 "auth", "database", "message_gateway") + Name() string + // Apply 核心装载入口:通过 Context 提供服务、注册路由、声明任务与监听事件 + Apply(ctx *Context) error +} +``` + +### 3.2 运行时驱动契约 (`core.Driver`) +HTTP 服务、Worker 消费池、Cron 调度器不硬编码在内核中,而是作为标准 `Driver` 挂载: + +```go +package core + +type DriverType string + +const ( + DriverTypeHTTP DriverType = "http" + DriverTypeWorker DriverType = "worker" + DriverTypeScheduler DriverType = "schedule" +) + +// Driver 是具备事件循环或监听端口的运行时引擎 +type Driver interface { + Type() DriverType + Start(ctx context.Context) error + Stop(ctx context.Context) error +} +``` + +### 3.3 Context 统一服务总线与泛型注入 +```go +package core + +// Provide 向 Context 注册强类型服务实现 +func Provide[T any](ctx *Context, service T) + +// Inject 从 Context 获取已注册的服务 +func Inject[T any](ctx *Context) (T, error) + +// Using 声明式依赖注入(当且仅当依赖的服务全部就绪时激活回调) +func Using[T1 any](ctx *Context, fn func(s1 T1)) error +func Using2[T1, T2 any](ctx *Context, fn func(s1 T1, s2 T2)) error +``` + +--- + +## 4. 领域扩展点规范 (Domain Extension Points) + +微内核提供 6 大标准扩展点,供插件高内聚地声明自己的资源: + +### 4.1 HTTP 路由扩展 (`ctx.Router()`) +```go +type RouterExtension interface { + Group(relativePath string, handlers ...gin.HandlerFunc) *gin.RouterGroup + Use(middleware ...gin.HandlerFunc) +} +``` + +### 4.2 数据迁移扩展 (`ctx.Migrations()`) +每个插件通过 Go 内置 `embed.FS` 打包专属的 Goose SQL 文件,彻底消除单体大迁移目录的合并冲突: + +```go +type MigrationExtension interface { + // Register 注册插件专属的 SQL 迁移文件系统 + Register(pluginID string, fsys fs.FS, dir ...string) +} +``` + +**版本隔离机制**:所有插件共享一张 `w_schema_versions` 表,以 `plugin_id` 列区分。运行时引擎(`gooseEngine`)实现 `goosedb.Store` 接口,对该表执行 `plugin_id` 限定的 CRUD 操作,确保各插件的版本互不干扰。 + +```sql +w_schema_versions ( + plugin_id VARCHAR(64) NOT NULL, + version_id BIGINT NOT NULL, + applied_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (plugin_id, version_id) +) +``` + +**启动流程**: +1. `ApplyPlugins()` 阶段:各插件调用 `ctx.Migrations().Register("order", embedFS)` 收集迁移 +2. `RunMigrations()` 阶段:引擎遍历所有 `entries`,为每个插件创建 `goose.NewProvider(dialect, sqlDB, entry.FS, goose.WithStore(store))` +3. `provider.Up()` 查询 `w_schema_versions WHERE plugin_id = 'order'` 决定版本,执行增量迁移 + +### 4.3 异步任务与定时调度扩展 (`ctx.Task()` & `ctx.Schedule()`) +```go +type TaskExtension interface { + Register(taskType string, handler asynq.HandlerFunc) +} + +type ScheduleExtension interface { + RegisterCron(spec string, taskType string, payload any) +} +``` + +### 4.4 领域事件总线 (`ctx.Events()`) +用于跨插件完全解耦通信,单机模式走内存通道,集群模式无缝升级为 Redis Stream / NATS: +```go +type EventBus interface { + On(topic string, handler any) + Emit(topic string, payload any) error +} +``` + +### 4.5 动态系统设置扩展 (`ctx.Settings()`) +```go +type SettingExtension interface { + RegisterSchema(pluginID string, schema any) +} +``` + +--- + +## 5. 插件形态与目录布局规范 + +插件结构遵循 **“扁平 (Flat)、自包含 (Self-Contained)、就近组织 (Colocated)”** 原则,杜绝不必要的 DDD 样板代码。 + +### 5.1 官方插件目录结构 +```text +plugins/ +├── database/ # 数据库驱动插件 +│ ├── plugin.go # 注册 DBService 与连接池 +│ └── service.go +├── auth/ # 认证插件 +│ ├── plugin.go # 插件装载入口:ctx.Provide[AuthService] + 路由挂载 +│ ├── service.go # AuthService 接口实现 (登录/Token/Session) +│ ├── handlers.go # HTTP Controller +│ ├── models.go # GORM 实体定义 +│ └── migrations/ # 专属 Goose SQL 迁移 +│ └── 001_auth_init.sql +├── message_gateway/ # 消息网关插件 +│ ├── plugin.go # 路由挂载 + Worker 任务注册 +│ ├── channels.go # Telegram / QQ / Webhook 各渠道实现 +│ └── models.go +└── [下游自定义插件]/ # 下游业务方自研插件 + ├── plugin.go + └── models.go +``` + +--- + +## 6. 插件间引用关系与协同规范 + +为杜绝 Go 语言的 `import cycle not allowed` 错误并保持插件的独立可替换性,插件间交互严格遵循以下 3 大模式: + +1. **服务槽位与延迟绑定(用于跨插件直接调用)**: + * 双方互不 import 对方包,仅面向 `core/contracts` 暴露的 Interface 编程。 + * 运行时通过 `core.Inject[contracts.UserService](ctx)` 获取服务。 +2. **事件总线广播(用于通知与状态联动)**: + * 登录成功、密码修改、订单创建等事件统一通过 `ctx.Events().Emit()` 广播,下游自愿监听。 +3. **注册表扩展点模式(用于功能插件扩充主插件能力)**: + * 主插件向 Context 提供注册表(如 `OAuthProviderRegistry`),扩充插件在 `Apply` 中向注册表添加自己的 Provider 实现。 + +--- + +## 7. 运行切面与启动路径 (Runtime Profiles) + +CLI 命令仅作为**切面激活器 (Target Selector)**,业务插件无需感知当前的运行角色: + +``` +[CLI: wavelet api / worker / schedule / all] + ↓ +1. App Bootstrap: 加载所有已配置插件并构建 Context + ↓ +2. Apply Phase: 执行所有 plugin.Apply(ctx),收集路由、任务、调度与迁移 + └─ 各插件调用 ctx.Migrations().Register("auth", authMigrations) 等 + ↓ +3. Migration Engine: 遍历所有 entries,逐插件创建 Goose Provider 执行迁移 + └─ 每个插件使用独立的 sharedStore(pluginID),共享同一张 w_schema_versions 表 + └─ provider.Up() 检查 w_schema_versions WHERE plugin_id = 'auth' + └─ 未执行过 → 执行 00001_initial.sql → INSERT 版本记录 + └─ 已执行过 → 跳过 + ↓ +4. Profile Dispatch: + - "api": 激活 DriverTypeHTTP 驱动 (Gin.ListenAndServe) + - "worker": 激活 DriverTypeWorker 驱动 (Asynq.Run) + - "schedule": 激活 DriverTypeScheduler 驱动 (Asynq.Scheduler) + - "all": 激活所有 Driver 实例 (单体一键融合启动) + ↓ +5. Graceful Shutdown: 监听系统信号,逆序安全停机 +``` + +--- + +## 8. 面向未来 5 年的分布式与服务拆分演进 + +```mermaid +graph LR + subgraph Monolith ["阶段 1:单体插件化 (进程内零开销)"] + UserP["plugin-user"] -->|Go Interface 内存调用| AuthP["plugin-auth"] + end + + subgraph Distributed ["阶段 2:高并发微服务拆分 (透明代理替换)"] + UserP2["plugin-user"] -->|相同的 Go Interface| AuthClient["plugin-auth-client (gRPC 代理)"] + AuthClient -.->|gRPC / HTTP/2| RemoteAuth["独立 Auth 微服务集群"] + end +``` + +1. **接口不变性 (Contract Stability)**:所有跨模块调用走 Interface,微服务化拆分时只需引入 RPC 客户端插件替换原插件,调用方业务代码 **0 修改**。 +2. **分布式事件驱动**:进程内 EventBus 通过简单配置可无缝切换为 Redis Stream / NATS / Kafka。 +3. **独立数据分片**:每个插件表名自带命名空间(如 `w_auth_*`),且有独立 Migration,天然支持物理分库分表。 + +--- + +## 9. 渐进式改造实施路线图 + +1. **Phase 1: 微内核基础设施搭建 (`core/` & `core/contracts/`)** + - 实现 Context、泛型 IoC 容器、生命周期状态机与 6 大扩展点协议。 +2. **Phase 2: 运行时驱动插件下沉 (`plugins/driver_*`)** + - 将现有 Gin、Asynq Worker、Asynq Scheduler、GORM、Redis 封装为标准 Driver 插件。 +3. **Phase 3: 官方领域模块插件化拆分 (`plugins/domain_*`)** + - 依次将 `auth`、`user`、`message_gateway`、`risk_control`、`admin` 迁移为标准插件。 +4. **Phase 4: 下游工程脚手架与验证** + - 提供下游开发模板,编写示例自定义插件,端到端验证 API/Worker/Schedule 运行切面与测试覆盖。 diff --git a/docs/superpowers/specs/2026-08-28-cordis-architecture-alignment-design.md b/docs/superpowers/specs/2026-08-28-cordis-architecture-alignment-design.md new file mode 100644 index 00000000..b772e045 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-cordis-architecture-alignment-design.md @@ -0,0 +1,82 @@ +# Cordis Architecture Alignment & Refactoring Design + +**Date**: 2026-08-28 +**Topic**: Cordis Meta-framework Alignment (Spatiotemporal Composability, Revertible Effects, Reactive Coeffects & Boundary Isolation) +**Status**: Approved + +--- + +## 1. Background & Objectives + +Wavelet adopts the **Cordis** micro-kernel paradigm (originating from Koishi and DeepSeek Harness) to achieve runtime composability and zero-side-effect lifecycle management. +According to the formal metatheory of Cordis (*A Programming Paradigm for Spatiotemporal Composability*), the runtime must satisfy two orthogonal requirements: +1. **Temporal Composability (时间可组合性)**: Every context mutation/registration must track an inverse operation (Revertible Effects) and automatically roll back in LIFO order upon unloading/disposing. +2. **Spatial Composability (空间可组合性)**: Components declare required coeffects/dependencies (`inject`); when dependencies become available or unavailable, the system reactively activates or deactivates components (Fiber state machine), guaranteeing **Confluence (合流)** regardless of registration order. +3. **Context as the Sole Surface & Defensive Isolation**: Eliminate cross-plugin private imports and global static singletons (`database.DB()`, global configs), strictly enforcing single-owner boundaries and `contracts` programming. + +--- + +## 2. Architecture & Detailed Design + +### 2.1 Revertible Effects & Scoped Extpoints (时间可组合性) + +- **`Context` Scoped Lifetime**: + Each plugin instance is mounted with a dedicated child context `pluginCtx := rootCtx.Fork()`. +- **Automatic Disposer Registration for Extpoints**: + When registrations occur through `pluginCtx`, inverse operations are automatically pushed to `pluginCtx`'s Disposer stack: + - **`Router`**: Registering a route returns a definition with an ID; `pluginCtx` records a disposer that calls `router.UnregisterByID(id)`. + - **`Events`**: `ctx.Events().On(...)` returns a `Disposer`; when called on a scoped context (or via `ctx.On(...)`), it binds to `pluginCtx.OnDispose`. + - **`Tasks`**: Registering an async task binds `tasks.Unregister(taskType)` to `pluginCtx.OnDispose`. + - **`Schedules`**: Registering a cron schedule binds `schedules.Unregister(cronName)` to `pluginCtx.OnDispose`. + - **`Settings`**: Registering setting schemas binds schema deregistration to `pluginCtx.OnDispose`. + - **`Container (Provide)`**: Providing a service type `T` binds `container.remove(T)` to `pluginCtx.OnDispose`. +- **LIFO Teardown Guarantee**: + Calling `pluginCtx.Dispose()` runs all registered disposers in reverse order (LIFO), cleanly revoking routes, event listeners, tasks, schedules, and service bindings without residual side effects. + +--- + +### 2.2 Reactive Coeffects & Fiber Lifecycle (空间可组合性) + +- **Dependency Declaration (`DependentPlugin`)**: + Plugins can optionally implement: + ```go + type DependentPlugin interface { + Plugin + Inject() []reflect.Type + } + ``` +- **Plugin Fiber State Machine**: + ``` + PENDING ──(All dependencies provided)──> LOADING ──(Apply succeeds)──> ACTIVE + ▲ │ + └─────────────(Dependency removed / Plugin unloaded)──────────────────────┘ + ``` + - **States**: `FiberPending`, `FiberLoading`, `FiberActive`, `FiberUnloading`, `FiberDisposed`. + - **Reconciler**: When `core.Provide[T]` registers a service or `core.App.Use` registers a plugin, the reconciler checks all pending fibers. Fibers with satisfied dependencies transition `Pending -> Loading -> Active`. + - **Confluence**: Plugin registration order (`app.Use(A, B)` vs `app.Use(B, A)`) produces the exact same final active state once all dependencies are satisfied. + +--- + +### 2.3 Boundary Defense & Single Owner Enforcement (架构防线) + +- **Eliminate Direct Global Invocations**: + - Refactor `plugins/domain/user/repository.go` and other domain repositories to avoid direct `import "Wavelet/plugins/infra/database"` and direct calls to `database.DB(ctx)`. + - Inject `contracts.DBService` via repository struct or retrieve via `ctx.DB()`. +- **Strict Package Separation**: + - `backend/core/`: Micro-kernel, context, container, fiber, events, scoped extpoints. + - `backend/core/contracts/`: Public interfaces and shared DTOs/events. + - `backend/plugins/infra/`: Infrastructure implementations providing contracts services. + - `backend/plugins/drivers/`: Runtime drivers (HTTP, Asynq Worker, Cron). + - `backend/plugins/domain/`: Domain business logic and single-owner tables. + - `backend/pkg/`: Stateless utilities and algorithm libraries. + +--- + +## 3. Verification Plan + +1. **Unit Tests for Core**: + - `core/fiber_test.go`: Test fiber state transitions, out-of-order registration confluence, and dynamic unloading. + - `core/context_test.go` & `core/extpoints/`: Test automatic scoped disposer tracking for routes, tasks, schedules, and event listeners. +2. **Refactoring Verification for Domain Plugins**: + - Run `go test ./backend/...` across all domain and infra packages. + - Run `make code-check` and verify zero lint regressions. diff --git a/docs/superpowers/specs/2026-08-28-cordis-architecture-refactor-design.md b/docs/superpowers/specs/2026-08-28-cordis-architecture-refactor-design.md new file mode 100644 index 00000000..7ac5e106 --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-cordis-architecture-refactor-design.md @@ -0,0 +1,84 @@ +# Cordis 架构重构设计规格书 (Cordis Architecture Refactor Design) + +**日期**: 2026-08-28 +**目标**: 依据 Cordis 时空可组合性元框架(Spatiotemporal Composability)哲学,重构 Wavelet 后端包结构、包职责与插件边界,消除全局静态单例与跨插件私有实现依赖,实现真正的可逆副作用与契约化隔离。 + +--- + +## 1. 背景与核心设计原则 + +Cordis 是一个面向时空可组合性的元框架,核心在于: +1. **时间可组合性 (Temporal Composability / Revertible Effects)**:组件挂载到上下文时产生的任何副作用(数据库连接、Redis 客户端、路由、事件监听、定时任务)必须具备明确的逆操作,在卸载时按 LIFO(后进先出)干净撤销。 +2. **空间可组合性 (Spatial Composability / Reactive Coeffects)**:组件通过 `Inject` 声明依赖;无特权微内核,所有基础设施与业务均以平等插件形态存在;组件之间严格面向抽象服务契约(Contracts)编程,严禁跨包引用私有实现。 +3. **合流定理 (Confluence)**:任何插件的装载/卸载顺序,静止状态等同于从零静态装配,杜绝全局隐藏状态与启动顺序隐式假设。 + +--- + +## 2. 详细重构方案 + +### 2.1 微内核纯洁化 (`backend/core/`) + +#### 改造点: +1. **移除特权辅助方法**: + - 从 `backend/core/context.go` 中移除 `func (c *Context) DB() contracts.DBService` 与 `func (c *Context) Cache() contracts.CacheService`。 + - 所有服务消费方统一面向 `core.Inject[T](ctx)`、`core.MustInject[T](ctx)` 或 `core.Using[T](ctx, ...)`。 +2. **保持依赖注入纯粹性**: + - 内核仅保留:`Context`、`Container`、`Fiber`、`EventBus`、生命周期管理以及通用的扩展点挂载。 + +--- + +### 2.2 基础设施插件生命周期可逆化 (`backend/plugins/infra/`) + +#### 1. 数据库插件 (`plugins/infra/database`) +- **移除隐式副作用**: + - 删除 `postgres.go` 与 `sqlite.go` 中的 `func init() { ... }` 静态建连。 + - 删除包级导出的静态全局变量 `var db *gorm.DB` 以及全局 `DB(ctx)` / `SetDB()`。 +- **生命周期受控与可逆释放**: + - 在 `Plugin.Apply(ctx *core.Context)` 时根据配置建立数据库连接(GORM + underlying `*sql.DB`)。 + - 创建 `contracts.DBService` 实例并通过 `core.Provide[contracts.DBService](ctx, svc)` 注册。 + - 注册 `ctx.OnDispose` 逆操作,在插件卸载时调用 `sqlDB.Close()`。 + +#### 2. 缓存插件 (`plugins/infra/cache`) +- **移除隐式副作用**: + - 删除 `redis.go` 中的 `func init() { ... }` 静态建连。 + - 删除包级导出的全局变量 `var Redis redis.UniversalClient`。 +- **生命周期受控与可逆释放**: + - 在 `Plugin.Apply(ctx *core.Context)` 时初始化 Redis 客户端并构造 `contracts.CacheService`。 + - 通过 `core.Provide[contracts.CacheService](ctx, svc)` 注册。 + - 注册 `ctx.OnDispose` 逆操作,在插件卸载时调用 `client.Close()`。 + +--- + +### 2.3 业务领域插件防线隔离与依赖重构 (`backend/plugins/domain/`) + +#### 1. 消除跨插件私有 Import +- 遍历并重构以下 8 个 Domain 插件: + - `auth` + - `user` + - `admin` + - `cap` + - `message_gateway` + - `risk_control` + - `system` + - `upload` +- **规则**: + - 严禁任何 domain 插件 `import "Wavelet/plugins/infra/database"` 或 `import "Wavelet/plugins/infra/cache"`。 + - 严禁任何 domain 插件直接 import 另一个 domain 插件的具体实现包(如 `admin` 严禁 import `risk_control/logstore` 或 `storage/diskcache`)。 + - 各插件内部的 Repository / Service 统一通过 `core.Inject[contracts.DBService](ctx)` 或插件内部 scoped context 获取数据库连接。 + +#### 2. `admin` 插件解耦与全局变量清除 +- 移除 `admin/plugin.go` 中的包级变量(`globalUserSvc`, `globalAuthSvc`, `globalCoreCtx`)。 +- 将 `admin` 的日志查询、任务触发、缓存清理等管理接口改造为通过 `contracts` 或 `ctx.Tasks()` 访问,消除对 `risk_control`、`driver_asynq_worker` 等的私有依赖。 + +--- + +## 3. 验证与门禁标准 + +1. **编译与依赖检查**: + - 运行 `grep -r "Wavelet/plugins/infra/database" backend/plugins/domain/` 结果为空。 + - 运行 `grep -r "Wavelet/plugins/infra/cache" backend/plugins/domain/` 结果为空。 +2. **自动化测试**: + - 所有既有单元测试与集成测试(`go test ./...`)无回归,全部 PASS。 +3. **代码质量门禁**: + - `make code-check` 静态检查 0 告警通过。 + - `make format` 格式化通过。 diff --git a/docs/superpowers/specs/2026-08-28-cordis-plugin-layered-architecture-spec.md b/docs/superpowers/specs/2026-08-28-cordis-plugin-layered-architecture-spec.md new file mode 100644 index 00000000..5fd6dd9b --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-cordis-plugin-layered-architecture-spec.md @@ -0,0 +1,140 @@ +# Cordis 架构插件标准分层设计规范 (Plugin Layered Architecture Spec) + +- **文档状态**: 已敲定 (Approved) +- **版本**: v1.1.0 (2026-08-28) +- **适用范围**: Wavelet 官方插件 (`backend/plugins/`)、下游定制插件 (`downstream/custom_plugins/`) + +--- + +## 1. 架构总览与分型原则 (Architecture & Selection Strategy) + +在 Wavelet 的 Cordis 微内核架构中,系统通过 **微内核 (`core/`) + 服务契约 (`core/contracts/`) + 自包含插件 (`plugins/`)** 实现高度解耦与单向依赖。 +为了规范插件内部代码组织,插件遵循 **标准分层架构(Layered Architecture / MVC 变体)**,并根据业务复杂度提供两套标准物理包结构: + +| 模式 | 适用场景 | 复杂度特征 | 物理结构形式 | 命名规范核心禁令 | +| :--- | :--- | :--- | :--- | :--- | +| **模式 1:极简单文件自包含**
(Single-File Flat) | 极简微型插件 | 仅有 1 个单一实体、代码量 < 500 行(如极简工具、Demo) | 单 Package,每个层级仅对应 1 个同名文件 (`handlers.go`, `service.go`, `models.go`, `repository.go`) | **严禁在根目录平铺 `handlers_*`、`service_*` 等前缀文件** | +| **模式 2:独立子包分层架构**
(Strict Sub-packages) | 标准/中大型业务插件(**官方推荐标准**) | 包含多实体/多接口、代码量 ≥ 500 行(如 `upload`, `auth`, `admin`, `order` 等) | 严格按层独立子包 (`handler/`, `service/`, `repository/`, `model/`, `errs/`) | **子包内文件直接以业务命名(如 `user.go`, `config.go`),禁止带 `handler_*` / `service_*` 前缀** | + +--- + +## 2. 模式 1:极简单文件自包含规范 (Single-File Flat Package) + +仅适用于极简小型插件(整个插件代码极少且各层只有一个文件)。 + +### 2.1 目录结构 +```text +backend/plugins/domain// +├── plugin.go # [Cordis 接入层] 实现 core.Plugin,负责 Apply 组装与扩展点注册 +├── handlers.go # [Handler 层] 单一文件:Gin API Handler +├── service.go # [Service 层] 单一文件:核心业务用例 +├── repository.go # [Repository 层] 单一文件:GORM / DB 操作 +├── models.go # [Model 层] 单一文件:实体与 DTO +├── errs.go # [Error 层] 单一文件:错误常量 +├── plugin_test.go # 插件测试 +└── migrations/ # Goose SQL 嵌入文件 + └── 20260828000001_init_.sql +``` + +> ⚠️ **严禁规则**:当单一文件膨胀或需要拆分多个业务实体时,**严禁在根目录创建 `handlers_user.go`, `handlers_admin.go`, `service_user.go` 等前缀文件**,必须立即重构并迁移为 **模式 2(独立子包分层架构)**! + +--- + +## 3. 模式 2:标准独立子包分层架构 (Standard Sub-package Architecture - 推荐规范) + +适用于绝大多数业务插件。各层使用独立的 Go package 物理隔离,**在子包内以纯业务实体命名文件**。 + +### 3.1 目录结构与文件命名规约 +```text +backend/plugins/domain// +├── plugin.go # [插件根入口] 实现 core.Plugin,装配各子包并向 Cordis 注册 +│ +├── handler/ # package handler:HTTP API 接入层(或 controller/) +│ ├── router.go # 路由组挂载与中间件绑定 +│ ├── auth.go # 认证相关 Handler(直接命名为 auth.go,禁止 handlers_auth.go) +│ ├── user.go # 用户相关 Handler(直接命名为 user.go,禁止 handlers_user.go) +│ ├── config.go # 配置相关 Handler(直接命名为 config.go,禁止 handlers_config.go) +│ └── logs.go # 日志相关 Handler(直接命名为 logs.go,禁止 handlers_logs.go) +│ +├── service/ # package service:核心领域业务逻辑层 +│ ├── service.go # 顶层 Service 组合与构造工厂 +│ ├── auth.go # 认证业务逻辑(直接命名为 auth.go,禁止 service_auth.go) +│ ├── user.go # 用户业务逻辑(直接命名为 user.go,禁止 service_user.go) +│ ├── config.go # 配置业务逻辑(直接命名为 config.go,禁止 service_config.go) +│ └── logs.go # 日志业务逻辑(直接命名为 logs.go,禁止 service_logs.go) +│ +├── repository/ # package repository:数据访问持久化层 (DAL) +│ ├── repository.go # 仓储通用方法与工厂 +│ ├── user.go # 用户仓储实现(直接命名为 user.go,禁止 repository_user.go) +│ ├── config.go # 配置仓储实现(直接命名为 config.go,禁止 repository_config.go) +│ └── log.go # 日志仓储实现(直接命名为 log.go,禁止 repository_log.go) +│ +├── model/ # package model (或 models/):纯领域实体与传输对象 +│ ├── entity.go # 数据库映射实体 (TableName() 必须带 w__ 前缀) +│ ├── dto.go # 请求入参与响应出参 DTO +│ └── events.go # 插件内部/广播事件结构体定义 +│ +├── errs/ # package errs:错误常量与错误码定义 (或根目录 errs.go) +│ └── errs.go +│ +└── migrations/ # Goose SQL 独立迁移嵌入文件 (//go:embed) + └── 20260828000001_init_.sql +``` + +### 3.2 依赖方向约束 (Strict Dependency Flow) +```mermaid +graph TD + Plugin[plugin.go 入口] --> Handler[handler/ 接入层] + Plugin --> Service[service/ 业务层] + Plugin --> Repository[repository/ 仓储层] + Handler --> Service + Handler --> Model[model/ 实体与DTO] + Handler --> Errs[errs/ 错误常量] + Service --> Repository + Service --> Model + Service --> Errs + Repository --> Model +``` +* **单向依赖铁律**: + 1. `handler/` 依赖 `service/`、`model/`、`errs/`; + 2. `service/` 依赖 `repository/`、`model/`、`errs/`,**严禁 import gin**; + 3. `repository/` 依赖 `model/` 和数据库底层,**严禁反向依赖 service 或 handler**; + 4. `model/` 纯粹由 Go 结构体组成,**严禁依赖上层 handler/service/repository**。 + +--- + +## 4. 各层职责边界与编码守则 (Layer Responsibilities & Guardrails) + +### 4.1 Handler 层 (`handler/`) +1. **参数绑定**:使用 `c.ShouldBindJSON` 或 `c.ShouldBindQuery`。 +2. **上下文提取**:从 `*gin.Context` 提取登录态(如 `oauth.GetCurrentUser(c)`)。 +3. **调用下游**:调用 Service 方法,禁止直接调用 Repository 或编写 SQL。 +4. **统一信封响应**: + - 成功:`c.JSON(http.StatusOK, response.OK(data))` 或 `response.OKNil()`。 + - 失败:使用 `backend/pkg/response` 的 `Abort*` 系列函数(如 `AbortBadRequest`、`AbortUnauthorized`、`AbortNotFound`、`AbortInternal`)。 +5. **Swagger 注释**:每个导出 Handler 必须编写完整的 OpenAPI/Swagger 注解。 + +### 4.2 Service 层 (`service/`) +1. **纯 Go 逻辑**:第一参数必须为 `ctx context.Context`,返回 `(result, error)`。 +2. **禁止依赖 Web 框架**:严禁 import `github.com/gin-gonic/gin`,严禁接收 `*gin.Context`,严禁调用 `c.JSON`/`Abort*`。 +3. **事务编排**:涉及插件内多表原子操作时,通过 `ctx.DB().Transaction(...)` 编排。 +4. **事件驱动解耦**:跨插件业务通知与状态联动统一通过 `ctx.Events().Emit(...)` 广播领域事件,杜绝直接跨插件调用私有方法。 + +### 4.3 Repository 层 (`repository/`) +1. **GORM / SQL 操作**:统一接收 `context.Context`,通过 `db.WithContext(ctx)` 操作数据。 +2. **SQL LIKE 防注入**:所有含用户输入的模糊查询必须调用 `backend/pkg/util.EscapeLike` 并显式声明 `ESCAPE '\\'`。 +3. **表单一所有者原则**:仅操作本插件所属表(前缀 `w__*`),严禁越权 DML/DDL 其他插件所有表。 + +### 4.4 Model 层 (`model/` 或 `models/`) +1. **GORM 映射**:显式实现 `TableName() string` 返回带前缀表名。 +2. **零值对齐**:Go 结构体字段零值必须与数据库默认值匹配。 +3. **无物理外键**:禁止物理外键约束,显式建立单列/复合索引。 + +### 4.5 Plugin 入口 (`plugin.go`) +1. 实现 `core.Plugin` 接口(`Name() string` 与 `Apply(ctx *core.Context) error`)。 +2. 在 `Apply` 中完成: + - 依赖注入与解析(`core.Provide` / `core.Inject` / `ctx.Using`) + - 路由与中间件声明(`ctx.Router().Group(...)`) + - 异步与定时任务注册(`ctx.Task().Register` / `ctx.Schedule().RegisterCron`) + - 配置与设置声明(`ctx.Settings().Register` / `ctx.Config().Bind`) + - 数据库迁移注册(`ctx.Migrations().Register`) diff --git a/docs/superpowers/specs/2026-08-28-zero-redis-pluggable-architecture-design.md b/docs/superpowers/specs/2026-08-28-zero-redis-pluggable-architecture-design.md new file mode 100644 index 00000000..b2fdfd7d --- /dev/null +++ b/docs/superpowers/specs/2026-08-28-zero-redis-pluggable-architecture-design.md @@ -0,0 +1,97 @@ +# Zero-Redis Pluggable Architecture Design + +**Date**: 2026-08-28 +**Topic**: Decoupling Redis via Cordis Pluggable Infrastructure and In-Process Drivers (Zero-Redis Monolith Mode) +**Status**: Approved + +--- + +## 1. Background & Objectives + +Currently, the Wavelet platform has direct or indirect couplings with Redis across four areas: +1. **Cache Layer (`infra/cache`)**: Hardcoded initialization of Redis client and L2 cache lookup. +2. **Background Worker & Cron Drivers (`drivers/driver_asynq_*`)**: Asynq requires Redis as message queue and timer broker. +3. **Cross-Node Invalidation (Pub/Sub)**: Invalidation messages directly interact with Redis channels. +4. **Task Execution Log Stream**: Real-time worker output writes directly to Redis pipelines in `admin/repository.go`. + +**Goal**: +In accordance with Cordis's "Everything is a Plugin" and "Single Owner Principle", extract Redis into dedicated optional plugins and provide lightweight in-process equivalents (`cache_memory`, `driver_inproc_worker`, `driver_inproc_cron`) so that standalone monolith deployments, embedded scenarios, and local development can run with zero external Redis dependency. + +--- + +## 2. Architecture & Detailed Design + +### 2.1 Cache Infrastructure Split (`backend/plugins/infra/`) + +`contracts.CacheService` remains the sole contract for caching. Two alternative plugins implement this contract: + +1. **`plugins/infra/cache_memory` (Default for Monolith / Zero-Redis)**: + - Encapsulates `pkg/cache/ram` for fast in-process TTL caching. + - Cache invalidations emit `cache:invalidate` events via `ctx.Events()` locally. + - Provides `core.Provide[contracts.CacheService](ctx, memCacheSvc)`. +2. **`plugins/infra/cache_redis` (Distributed Cluster Mode)**: + - Provides full multi-tier caching: L1 Local RAM + L2 Remote Redis + Redis Pub/Sub invalidation. + - Implements `core.DependentPlugin` (declares dependencies on database / configuration). + - Provides `core.Provide[contracts.CacheService](ctx, redisCacheSvc)`. + +--- + +### 2.2 In-Process Worker & Scheduler Drivers (`backend/plugins/drivers/`) + +Domain plugins register tasks and schedules only against `ctx.Tasks()` and `ctx.Schedules()` extension points, completely oblivious to the underlying runner. + +1. **`plugins/drivers/driver_inproc_worker` (In-Process Worker Driver)**: + - Implements `core.Driver` with `Type() == core.DriverTypeWorker`. + - Maintains an in-memory buffered channel queue and worker goroutine pool (managed via `util.Go` with panic recovery). + - Supports task concurrency limits, exponential backoff retries, and context execution timeouts. +2. **`plugins/drivers/driver_inproc_cron` (In-Process Cron Scheduler Driver)**: + - Implements `core.Driver` with `Type() == core.DriverTypeScheduler`. + - Uses `robfig/cron/v3` to poll and trigger entries in `ctx.Schedules().Schedules()`. +3. **`plugins/drivers/driver_asynq_*` (Distributed Cluster Drivers)**: + - Retains Asynq worker and cron drivers for Redis-backed distributed workloads. + +--- + +### 2.3 Task Execution Log Stream & Event Bus Decoupling + +1. **Task Stream Logs**: + - Provide an in-memory `RingBuffer` (e.g. recent 500 lines per execution). + - When Redis is disabled, logs stream into the `RingBuffer` and flush to `w_task_executions` upon completion. +2. **System Config & Invalidation Broadcast**: + - In single-node mode, `ctx.Events()` in-process event bus handles all notifications immediately. + - In multi-node mode, `cache_redis` bridges events across instances via Redis Pub/Sub. + +--- + +### 2.4 Application Assembly (`backend/cmd/app.go`) + +In `cmd/app.go`, the application declaratively selects the plugin suite based on configuration: + +```go +if config.Config.Redis.Enabled { + app.Use( + cache_redis.New(), + driver_asynq_worker.New(), + driver_asynq_cron.New(), + ) +} else { + app.Use( + cache_memory.New(), + driver_inproc_worker.New(), + driver_inproc_cron.New(), + ) +} +``` + +--- + +## 3. Verification Plan + +1. **Unit Tests**: + - `plugins/infra/cache_memory/plugin_test.go`: Test in-memory cache operations, TTL expiry, and `contracts.CacheService` compliance. + - `plugins/drivers/driver_inproc_worker/plugin_test.go`: Test in-process task dispatch, concurrency, retry, and cancellation. + - `plugins/drivers/driver_inproc_cron/plugin_test.go`: Test in-process cron schedule execution. +2. **Integration Verification**: + - Verify that running the application with `config.Database.Enabled = false` and `config.Redis.Enabled = false` boots cleanly into `all`, `api`, `worker`, and `scheduler` profiles with zero connection errors. +3. **Quality Gate**: + - Run `go test ./...`, `make code-check`, and `make format`. diff --git a/docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md b/docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md new file mode 100644 index 00000000..c2965834 --- /dev/null +++ b/docs/superpowers/specs/2026-08-29-cordis-config-extension-design.md @@ -0,0 +1,364 @@ +# Cordis 配置扩展点设计 (Config Extension Point) + +- **文档状态**: 已敲定 (Approved) +- **版本**: v1.0.0 (2026-08-29) +- **适用范围**: `backend/core/`(微内核)、`backend/plugins/`(自包含插件)、`backend/cmd/`(组合根)、`backend/pkg/`(无状态基础库) + +--- + +## 0. 背景与动机 + +`backend/pkg/config` 同时承担了三件事:viper 装载 `config.yaml`、环境变量覆盖、以及以全局单例 `config.Config` 暴露全量配置模型。它与架构文档对 `backend/pkg/` 的定位("Stateless utilities and algorithm libraries")冲突,并且带来两个结构性问题: + +1. **配置所有权倒挂**:任何包都能读到全量配置,因此 `cmd` 直接替 `cache` 插件判断 Redis 是否启用、`risk_control` 直接判断 `clickhouse.enabled`。配置的"读者"与"所有者"没有关系约束。 +2. **隐式全局状态**:`init()` 内完成文件搜索、解析与 `log.Fatalf`,并以 `isTest()` 猜测执行上下文来禁用数据库/Redis/ClickHouse;测试通过改写全局单例驱动生产代码路径。 + +本设计把"配置的读取框架"下沉为内核扩展点,把"读哪些字段"的所有权交给各插件自己声明,并一次性迁移全部 27 个消费文件(109 处引用),彻底删除全局单例。 + +`AGENTS.md` 与 `new-setting` skill 中早已写明插件应通过 `ctx.Config().Bind(...)` 绑定静态配置,但该 API 在代码中从未存在——本设计同时修正这一文档漂移。 + +--- + +## 1. 决策记录 + +| # | 决策 | 理由与取舍 | +| :--- | :--- | :--- | +| D1 | **预声明阶段 + 配置门禁** | 内核在 `Apply` 之前收集声明并求值门禁,使组合根不再跨插件读配置选实现。代价是给 Fiber 增加"被门禁跳过"语义。 | +| D2 | **混合读取形态:结构体 `Bind` + 泛型 `Get`** | `redis`/`database` 等 14+ 字段结构体整体消费,逐 key 声明不可读;`app.session_secret` 等单字段不值得为它绑一个结构体。 | +| D3 | **共享声明 + 内核冲突校验** | 配置是进程级只读事实,不存在数据表那种写竞争,因此允许读者各自声明同一 key;由内核强制"重复声明必须一致"兜底。放弃严格单所有权(需为若干配置值另造契约接口,且 `driver_http` 需 session store 连接参数是真实底层依赖)。 | +| D4 | **一次性全量迁移** | 不留双轨,架构一次到位;接受较大的 diff。 | +| D5 | **显式测试缝** | 删除 `isTest()` 魔法,测试通过 `core.WithConfigValues(...)` 注入。放弃"测试环境自动禁用中间件"的安全网,换取语义透明与可并行。 | +| D6 | **内核持抽象,viper 归 infra 适配器** | 微内核防线规定 `core/` 严禁 import 具体运行时依赖。`core` 只依赖 `ConfigSource` 接口,viper/yaml 装载放 `plugins/infra/config`。放弃"全放 core/config"(污染内核纯净性)与"完全插件化 + `contracts.ConfigService`"(门禁求值在 core,而 config 插件 `Apply` 尚未运行,存在鸡生蛋时序问题)。 | +| D7 | **顺带解耦 `pkg/idgen`** | 其 `init()` 读全局配置,导致 `pkg` 反向依赖配置单例。 | + +--- + +## 2. 分层与物理结构 + +```text +backend/core/extpoints/config.go # 配置引擎(仅 stdlib + reflect): + # ConfigSource 接口、声明注册、解析、冲突校验、脱敏 dump +backend/core/config.go # 泛型读取入口与 App 装配选项(Go 方法不支持类型参数) +backend/core/fiber.go # 新增 FiberSkipped 状态与门禁求值 +backend/core/types.go # 新增 ConfigExtension / ConfigBinding / ConfigView 别名 +backend/plugins/infra/config/ # viper + yaml 适配器,实现 core.ConfigSource(非 core.Plugin) +backend/cmd/ # 组合根:host 声明集 + app.Prepare() +backend/pkg/idgen/ # 移除 config 依赖,改为显式 Init(nodeID) +删除 backend/pkg/config/ # 全局单例 config.Config 一并消失 +``` + +职责边界: + +| 单元 | 做什么 | 不做什么 | +| :--- | :--- | :--- | +| `core/extpoints` 配置引擎 | 维护 key 注册表、按优先级解析、类型转换、冲突校验、脱敏输出 | 不知道任何具体 key 的名字,不读文件,不 import viper | +| `plugins/infra/config` | 定位 `config.yaml`(`CONFIG_PATH` → 向上查找)、解析成 raw map、代理 env 查询 | 不含 schema、不含业务字段语义 | +| 各插件 | 声明自己读哪些字段(tag 结构体)、声明门禁谓词 | 不读未声明的 key、不访问他插件的声明类型 | +| `cmd` | 声明 host 级 key、注入 `ConfigSource`、按已解析值初始化 logger/trace/banner | 不做 `if redis.enabled { ... }` 这类跨插件判断 | + +`plugins/infra/config` 不实现 `core.Plugin`,不出现在 `app.Use()` 列表里:它只向内核提供一个 `ConfigSource` 实例,没有服务、路由或任务可注册。它归 `plugins/infra/` 而非 `pkg/`,是因为它封装了具体运行时依赖(viper、文件系统)并持有装载状态,不符合 `pkg/` 的无状态定位。 + +`pkg/idgen` 解耦后,`backend/pkg/` 恢复"不依赖配置源"的无状态定位。 + +--- + +## 3. 核心类型与 API + +### 3.1 声明形态:带 tag 的结构体 + +唯一的批量作者形态是结构体 tag,一个字段同时表达 yaml 路径、env 覆盖名、默认值与敏感标记: + +```go +// plugins/infra/cache/redis_config.go —— redis 配置由 redis 插件自己声明 +type redisConfig struct { + Enabled bool `config:"enabled" env:"REDIS_ENABLED" default:"false" autoEnable:"REDIS_ADDR"` + Addrs []string `config:"addrs" env:"REDIS_ADDR"` + Username string `config:"username" env:"REDIS_USERNAME"` + Password string `config:"password" env:"REDIS_PASSWORD" secret:"true"` + DB int `config:"db" env:"REDIS_DB"` + ClusterMode bool `config:"cluster_mode" env:"REDIS_CLUSTER_MODE"` + MasterName string `config:"master_name" env:"REDIS_MASTER_NAME"` + KeyPrefix string `config:"key_prefix" env:"REDIS_KEY_PREFIX"` + MaintNotifications bool `config:"maint_notifications" env:"REDIS_MAINT_NOTIFICATIONS" default:"false"` + // ...pool/timeout 字段略 +} +``` + +支持的 tag:`config`(yaml 相对路径,必填)、`env`(覆盖用环境变量名)、`default`(字符串形式,缺省时视为未设置)、`autoEnable`(该 env 一旦存在即把本布尔字段置 true)、`secret`(dump 时脱敏)。 + +### 3.2 内核接口 + +```go +// ConfigSource 抽象了"原始值从哪来",由 infra 适配器实现,使内核不绑定 viper。 +type ConfigSource interface { + Lookup(path string) (any, bool) // config.yaml 中的点分路径 + LookupEnv(name string) (string, bool) + Describe() string // 用于日志,如 "config.yaml" 或 "" +} + +// ConfigBinding 把一个结构体绑定到某个 yaml 前缀上,是插件的声明单元。 +type ConfigBinding struct { + Prefix string // "redis";空串表示字段 key 即完整路径 + Target any // 指向带 tag 的结构体的指针 +} + +// ConfigView 是只读的已解析视图,供门禁与零散取值使用。 +type ConfigView interface { + String(key, fallback string) string + Bool(key string, fallback bool) bool + Int(key string, fallback int) int + Duration(key string, fallback time.Duration) time.Duration + Strings(key string) []string + WasSet(envName string) bool + Source(key string) string // "env" | "yaml" | "default",用于诊断 +} + +// ConfigExtension 是挂载在 Context 上的扩展点,根 Context 与所有 Fork 共享。 +type ConfigExtension interface { + ConfigView + Declare(pluginID string, bindings ...ConfigBinding) error + Bind(prefix string, target any) error + Entries() []ConfigEntry // 有效配置的脱敏视图 +} +``` + +`core` 侧导出别名与泛型入口(沿用仓库既有 `core.Provide[T]` / `core.Inject[T]` 风格): + +```go +func ConfigGet[T any](v extpoints.ConfigView, key string) (T, error) +``` + +### 3.3 插件侧用法 + +```go +// 批量绑定(Apply 内) +var cfg redisConfig +if err := ctx.Config().Bind("redis", &cfg); err != nil { + return err +} + +// 单字段读取:带 fallback 的访问器(门禁使用) +secret := ctx.Config().String("app.session_secret", "") + +// 单字段读取:需要区分"未设置"与"设置为零值"时用泛型入口 +rate, err := core.ConfigGet[float64](ctx.Config(), "otel.sampling_rate") +``` + +`ConfigEntry` 是 `Entries()` 返回的诊断单元,只含元数据与脱敏后的值: + +```go +type ConfigEntry struct { + Key string // "redis.password" + PluginID string // 首次声明者,用于冲突报错点名 + Env string + Source string // "env" | "yaml" | "default" + Value string // secret key 输出 "******" +} +``` + +`Bind` 的双重语义:若该 prefix 尚未声明,则按 `Target` 的 tag 自登记;若已声明,则是纯读取。登记时提供的 `env`/`default`/`secret` 元数据一律参与冲突校验(依 D3),因此自登记不会绕过校验。**只有需要早于 `Apply` 求值的插件才必须显式 `DeclareConfig()`。** + +### 3.4 门禁接口与 Fiber 跳过态 + +```go +// ConfigGatedPlugin 是可选接口:让内核在 Apply 之前决定插件是否激活。 +type ConfigGatedPlugin interface { + Plugin + DeclareConfig() []extpoints.ConfigBinding // 门禁所需 key 必须提前声明 + ConfigEnabled(v extpoints.ConfigView) bool +} +``` + +`FiberState` 新增 `FiberSkipped`。`App.reconcileLocked()` 在 `Load()` 前求值门禁:门禁为 false 的 Fiber 置 `FiberSkipped`,不计入依赖 satisfied 判定,也不参与 driver 启动。`App.Stop()` 对 skipped 与 active 一视同仁地按 LIFO 卸载其 scoped Context。 + +受门禁的插件对(现状仅三对,均为 Redis 存在与否的互斥实现): + +| 启用 | 跳过 | 门禁谓词 | +| :--- | :--- | :--- | +| `infra/cache` | `infra/cache_memory` | `redis.enabled` | +| `drivers/driver_asynq_worker` | `drivers/driver_inproc_worker` | `redis.enabled` | +| `drivers/driver_asynq_cron` | `drivers/driver_inproc_cron` | `redis.enabled` | + +### 3.5 组合根 + +```go +src := config.NewSource() // plugins/infra/config:仅定位与 raw 解析 +app := core.NewApp( + core.WithProfile(profile), + core.WithConfigSource(src), + core.WithConfigDecl(hostBinding...), // app.* / log.* / otel.* +) +app.Use( + infradb.New(), logger.New(), storage.New(), + cache.New(), cache_memory.New(), // 不再 if/else,门禁决定 + driver_asynq_worker.New(), driver_inproc_worker.New(), + driver_asynq_cron.New(), driver_inproc_cron.New(), + admin.New(), user.New(), auth.New(), /* ... */ + driver_http.New(), // addr 由插件自己声明读取 +) +if err := app.Prepare(); err != nil { return err } // 解析屏障 + 门禁求值 + +timeout, _ := app.Context().Config().Duration("app.graceful_shutdown_timeout", 30) +app.SetShutdownTimeout(timeout) +``` + +`WithShutdownTimeout(d)` 保留为显式覆盖入口(测试与非标准装配使用),生产路径改为 `Prepare()` 之后由已解析视图经 `SetShutdownTimeout` 设定。`driver_http.New(WithAddr(...))` 选项删除,addr 归 `driver_http` 在 `Apply` 内声明读取。 + +--- + +## 4. 解析语义与启动时序 + +### 4.1 单 key 优先级链 + +```text +1. 显式 env 命中 env:"DB_ENABLED" → 最高优先级 +2. autoEnable env 命中 autoEnable:"DB_HOST" → true(被 1 覆盖) +3. config.yaml 命中 config:"enabled" +4. default tag 兜底 +``` + +需要保留的既有特殊语义: + +- **标量 env 填充切片字段**:`REDIS_ADDR=redis:6379` → `redis.addrs = ["redis:6379"]`;`CLICKHOUSE_HOST` 同理。 +- **隐式启用**:`DB_HOST` → `database.enabled=true`、`REDIS_ADDR` → `redis.enabled=true`、`CLICKHOUSE_HOST` → `clickhouse.enabled=true`;显式 `*_ENABLED` 始终优先于隐式推导。 +- **同一 env 的双重角色**:`REDIS_ADDR` 既是 `redis.addrs` 的值来源,又是 `redis.enabled` 的 `autoEnable` 触发器。引擎按 key 独立解析、允许一个 env 名服务多个 key,实现时不可把它建模成"env → 单一 key"的一对一映射。 +- **时长字段**:`slow_threshold: 200ms` 解析为 `time.Duration`。 +- **文件定位**:`CONFIG_PATH` 优先;否则从工作目录向上最多 5 层查找 `config.yaml`(该文件位于仓库根,`backend/` 为其子目录)。 + +### 4.2 时序 + +```text +config.NewSource() # 读 yaml → raw map;零 schema 知识 + ↓ +core.NewApp(WithConfigSource) # 记录 host 声明 + ↓ +app.Use(...) # 遇 DeclareConfig() 立即登记 binding(叶子 key + env + default + secret) + ↓ +app.Prepare() # ① 冲突校验 ② 逐 key 解析 ③ 脱敏 dump ④ 门禁求值 → FiberSkipped + ↓ +app.Run() → Reconcile/Apply # 插件内 Bind/Get 读取已解析值 +``` + +`App.Start()` 在未显式调用 `Prepare()` 时幂等补做,防止遗漏。冲突校验规则:同一 key 的多份声明必须 `env` 名、`default`、`secret` 三项一致,否则 `Prepare()` 返回错误并点名两个声明者。 + +### 4.3 有意的行为变更 + +| # | 变更 | 现状 | 变更后 | +| :--- | :--- | :--- | :--- | +| C1 | `default` 生效条件 | `applyDefaults` 对零值二次回落(`session_age<=0` → 86400) | 仅当 env 与 yaml 均缺失时生效;`app.session_age<=0` 在 `Prepare()` 判为配置错误(fail fast 优于静默改写) | +| C2 | 测试上下文 | `isTest()` 自动禁用 DB/Redis/ClickHouse 并把 sqlite 指向 `:memory:` | 删除该魔法;测试用 `core.WithConfigValues(...)` 显式声明。未声明 `database.enabled` 时按 default `false` 落 sqlite 后备,其路径沿用 `postgres.go` 既有的 `./data/wavelet.db` 回落——需要内存库的用例必须显式注入 `database.sqlite_path = ":memory:"` | +| C3 | 配置 dump | `printConfig` 明文打印全量结构体,含 `DB_PASSWORD`、`APP_SESSION_SECRET` | 按 `secret:"true"` 脱敏后输出,并标注每个 key 的来源(env/yaml/default) | +| C4 | 队列默认值 | 硬编码在 `pkg/config` 的 `applyEnvOverrides` | 移入唯一消费者 `driver_asynq_worker` 的声明(`webhook`/`whitelist_only`/`default` 三级优先级不变) | +| C5 | 非法 env 值 | `envInt/envBool/envFloat64` 在 `strconv` 失败时静默丢弃 env 值、回落 yaml/default | `Prepare()` 返回 `ErrConfigType` 并点名 key 与非法值 | + +除此之外,解析结果与现状逐 key 等价(由 §7.1 第 4 条的对拍测试证明)。 + +--- + +## 5. 声明归属映射 + +| 声明方 | key 前缀 | 消费者(含跨插件读) | +| :--- | :--- | :--- | +| `cmd` host 声明集 | `app.{env,app_name,addr,node_id,graceful_shutdown_timeout}`、`log.*`、`otel.*` | `cmd/root.go`、`cmd/banner.go`、`core.App` | +| `plugins/infra/cache` | `redis.*`(含 `enabled` 门禁、`autoEnable: REDIS_ADDR`) | `infra/cache`、`driver_http`(session store)、`driver_asynq_worker`、`driver_asynq_cron` | +| `plugins/infra/database` | `database.*`、`clickhouse.*` | `infra/database`、`admin`、`risk_control` | +| `plugins/domain/auth` | `app.session_*`(cookie/secret/age/domain/secure/http_only) | `auth`、`cap`、`message_gateway`、`driver_http` | +| `plugins/drivers/driver_asynq_worker` | `worker.*`(并发、strict_priority、queues 默认值) | 自身 | +| 其余 | 按需就近声明 | — | + +跨插件读同一 key(如 `cap` 读 auth 声明的 `app.session_secret`)依 D3 走共享声明:`cap` 也声明该 key,三份元数据必须与 auth 一致,否则启动失败。 + +**Key 命名约定**:既有 infra key 保持顶层(`redis.*`、`database.*`),以兼容线上 `config.yaml`;新增插件的私有配置归 `plugins..*` 命名空间,与 `new-setting` skill 的描述对齐。 + +### 5.1 `pkg/idgen` 解耦 + +- 删除 `init()` 中对 `config.Config.App.NodeID` 的读取。 +- 新增 `idgen.Init(nodeID int64) error`,由 host 在 `Prepare()` 之后显式调用(值来自 host 声明的 `app.node_id`)。 +- 未初始化时 `NextUint64ID()` panic 并点名"未调用 idgen.Init",而非静默使用 nodeID=0 生成可能与集群冲突的 ID。 +- 11 个调用点的 `idgen.NextUint64ID()` 签名保持不变;依赖 ID 生成的测试需显式 `idgen.Init`。这是本次迁移唯一会触及既有测试文件之处。 + +### 5.2 明确不在范围内 + +本设计只改变配置的**来源与所有权**,不动这些既有全局变量:`cache.Redis`、`driver_asynq_worker.RedisOpt`/`AsynqClient`、`infra/database.db`。它们各自的收敛属于独立议题。 + +--- + +## 6. 错误处理 + +- `Prepare()` 以 `errors.Join` 聚合全部配置错误,哨兵错误:`ErrConfigConflict`(重复声明不一致)、`ErrConfigType`(env 值无法转为目标类型)、`ErrConfigInvalid`(值域校验失败,如 `session_age<=0`)、`ErrConfigNotResolved`(`Prepare()` 之前调用 `Bind`/`Get`,错误信息点名正确调用顺序)。 +- 所有配置错误经 `error` 返回,由 `cmd` 决定终止方式;`core` 与 `extpoints` 内不再有 `log.Fatalf`。 +- `config.yaml` 缺失不是错误(沿用"仅用 env"路径,记一条 info 日志);`CONFIG_PATH` 显式指定但读不到或解析失败 → 返回 error。 +- 门禁 `ConfigEnabled(v ConfigView) bool` 只读已解析值、用带 fallback 的访问器,配置错误已在 `Prepare()` 阶段暴露,因此门禁不引入新的错误源。 +- `Declare` 与 `Bind` 校验 `Target` 必须是非 nil 结构体指针,否则返回 error(不 panic)。 + +--- + +## 7. 测试与验收 + +### 7.1 测试分层 + +1. **引擎单测**(`core/extpoints`):内存 fake `ConfigSource`,表驱动覆盖优先级四档、标量 env→切片、`autoEnable` 与显式 env 的优先关系、冲突校验、脱敏 dump、`time.Duration` 与嵌套结构体 tag 解析、`Prepare()` 前访问的错误路径。 +2. **门禁单测**(`core`):互斥插件对恰好激活一个、被跳过插件不计入依赖 satisfied、`FiberSkipped` 参与 `Stop` 的 LIFO 卸载。 +3. **适配器单测**(`plugins/infra/config`):`t.TempDir()` 写 yaml + `t.Setenv`,禁止相对路径。 +4. **新旧对拍**:迁移期间保留一份临时对拍测试,用仓库现网 `config.yaml` 与 `.env` 逐 key 比较旧 `pkg/config` 与新引擎的输出,证明除 C1–C5 外完全等价;验证通过后随旧包一并删除。 +5. **迁移后插件测试**:改用 `core.WithConfigValues(...)` 显式注入;依赖 ID 生成的测试显式 `idgen.Init`。 + +### 7.2 验收标准 + +1. `backend/pkg/config` 不存在,`grep -rn "pkg/config\|config\.Config" backend/` 零命中。 +2. `core/` 无 viper import;`backend/pkg/` 内不出现任何配置源 import。 +3. `cmd/app.go` 中不存在跨插件配置判断,驱动选型完全由门禁产生。 +4. `.env`、`config.yaml`、docker-compose **零改动**即可启动,行为等价(除已登记的 C1–C5)。 +5. 同时挂载 `cache` 与 `cache_memory` 而仅激活其一——"预声明 + 门禁"的端到端可验证证据;两条路径(Redis 启用 → asynq;禁用 → inproc)各实跑一次。 +6. `make code-check`、`make format`、`go test ./backend/...` 全绿;`go run main.go all` 实跑通过,覆盖 banner、迁移与门禁。 +7. `AGENTS.md`、`new-setting` skill 与白皮书中 `ctx.Config().Bind(...)` 的签名与 key 命名约定更新为已实现的真实 API。 + +### 7.3 实施顺序建议 + +每阶段独立可验证,供实施计划拆分参考: + +| 阶段 | 内容 | 验证 | +| :--- | :--- | :--- | +| P1 | 配置引擎(`core/extpoints/config.go`)+ `plugins/infra/config` 适配器 + 新旧对拍测试 | `go test ./backend/core/...`;对拍输出等价性报告 | +| P2 | 门禁与 `FiberSkipped`、`App.Prepare()` 解析屏障 | `core` 门禁单测;现有测试全绿(此时旧单例仍在,未迁移) | +| P3 | 按 infra → drivers → domain → cmd 顺序迁移 27 个文件;`idgen.Init` 解耦 | 每层迁移后 `go build ./...` + 该层测试;最后实跑两条门禁路径 | +| P4 | 删除 `backend/pkg/config` 与对拍测试;更新 `AGENTS.md`/skill/白皮书 API | §7.2 全部验收项逐条复核 | + +--- + +## 附录 A:迁移清单 + +删除:`backend/pkg/config/{config.go,model.go,config_test.go}` + +新增:`backend/core/extpoints/config.go`、`backend/core/config.go`、`backend/plugins/infra/config/*`、各插件内 `_config.go` 声明文件 + +需改写的 27 个文件: + +| 分组 | 文件 | +| :--- | :--- | +| 组合根 | `cmd/app.go`、`cmd/root.go`、`cmd/banner.go`、`cmd/app_test.go`、`cmd/banner_test.go`、`cmd/redis_plug_test.go` | +| 基础库 | `pkg/idgen/snowflake.go`(连带 `pkg/idgen/snowflake_test.go`) | +| infra | `plugins/infra/cache/redis.go`、`plugins/infra/database/postgres.go`、`plugins/infra/database/clickhouse.go` | +| drivers | `plugins/drivers/driver_http/engine.go`、`plugins/drivers/driver_http/middlewares.go`、`plugins/drivers/driver_asynq_worker/utils.go`、`plugins/drivers/driver_asynq_worker/utils_test.go`、`plugins/drivers/driver_asynq_cron/plugin.go` | +| domain/admin | `plugins/domain/admin/handler/db.go`、`plugins/domain/admin/repository/db.go`、`plugins/domain/admin/service/db.go`、`plugins/domain/admin/service/status.go`、`plugins/domain/admin/service/log_switch.go` | +| domain/其他 | `plugins/domain/auth/session.go`、`plugins/domain/cap/service.go`、`plugins/domain/message_gateway/service/service.go`、`plugins/domain/system/plugin.go`、`plugins/domain/risk_control/middleware.go`、`plugins/domain/risk_control/middleware_test.go`、`plugins/domain/risk_control/logstore/provider.go` | + +> 注:`risk_control/middleware.go`、`cap/service.go`、`message_gateway/service/service.go` 等处以 `config.Config != nil` 做存在性判断的分支,在注入式配置模型下不再可能,迁移时一并消除。 + +--- + +## 8. 落地回写(P1 + P2 已实施) + +实施结果与本设计原述的差异,均已按下列口径落地: + +| # | 设计原述 | 落地结果 | 缘由 | +| :--- | :--- | :--- | :--- | +| R1 | §4.3 C1、§6 把 `app.session_age<=0` 列为内核解析错误 | 引擎不做值域校验,`ErrConfigInvalid` 保留但未在内核使用;值域由声明者在 `Bind` 之后校验(P3 由 auth 承担) | 引擎被设计成不认识任何业务 key 的语义,把业务规则塞进内核会破坏该不变式 | +| R2 | §3.2 `ConfigView.Source(key)` | 更名 `Origin(key)`;新增 `Value(key) (any, bool)`;`ConfigExtension` 增加 `SetSource`、`Resolved` | `Source` 与类型名 `ConfigSource` 同文件易混淆;`Value` 支撑 `core.ConfigGet[T]`(Go 方法不能带类型参数);`SetSource` 进接口以免运行时类型断言 | +| R3 | §3.5 仅有 `WithShutdownTimeout` | 新增 `App.ShutdownTimeout()` 与 `SetShutdownTimeout(d) *App` | 组合根需在 `Prepare()` 之后把已解析预算写回内核,构造期选项无法表达该顺序 | +| R4 | §4.2 时序图把门禁求值画在 `Prepare()` 内 | `Prepare()` 只建立解析屏障,门禁在 `reconcileLocked` 每轮调和中求值 | `App.Use` 可在 `Prepare()` 之后继续挂载插件;只在 `Prepare` 求值会留下一批永不判定的门禁 | +| R5 | 未涉及 | `App` 未注入 `ConfigSource` 时配置能力视为未启用,解析屏障直接放行;实现了 `ConfigGatedPlugin` 却无配置源的插件 fail fast 点名原因 | 内核存在大量不使用配置的装配路径(既有测试与嵌入式用法),不能强制要求配置源;但门禁无数据可依时必须报错,而非静默全激活 | +| R6 | §4.1 隐含"每个 key 都有 env 覆盖" | env 覆盖面完全由声明决定。旧装载器只对部分 key 提供 env(`slow_threshold`、`conn_max_lifetime` 等从未有 env 覆盖),对拍镜像必须精确复刻该覆盖面 | 否则对拍出现假漂移;放宽某 key 的 env 覆盖是 P3 的声明选择,不构成引擎行为变更 | +| R7 | §4.1 "向上最多 5 层查找 `config.yaml`" | 该向上查找会**越出 git worktree 边界**:从 `backend/pkg/config` 出发第 5 层可命中父级检出的 `config.yaml` | 属既有行为、非本次引入,但在 worktree 中开发会静默使用另一份检出的配置。对拍测试已改为以入库的 `config.example.yaml` 所在目录为锚;`config.yaml` 本身被 gitignore,干净克隆中不存在 | + +分期口径:本设计 §7.3 的 P1 + P2 已实施完成;P3(27 个消费文件迁移、`pkg/idgen` 解耦)与 P4(删除 `backend/pkg/config`、移除对拍夹具)由后续计划承接。 diff --git a/scripts/check_cordis_architecture.sh b/scripts/check_cordis_architecture.sh new file mode 100755 index 00000000..248e3a1f --- /dev/null +++ b/scripts/check_cordis_architecture.sh @@ -0,0 +1,243 @@ +#!/usr/bin/env bash +# Copyright 2026 Arctel.net +# SPDX-License-Identifier: Apache-2.0 +# +# check_cordis_architecture.sh +# 验证代码库是否严格遵循 Cordis 插件化架构规约与设计规范。 + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BACKEND_DIR="${ROOT_DIR}/backend" + +MODULE=$(cd "${BACKEND_DIR}" && go list -m 2>/dev/null || echo "Wavelet") + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +BOLD='\033[1m' +NC='\033[0m' # No Color + +ERRORS=0 + +log_check() { + echo -e "${BLUE}==>${NC} ${BOLD}$1${NC}" +} + +log_pass() { + echo -e " ${GREEN}✓${NC} $1" +} + +log_fail() { + echo -e " ${RED}✗ [FAIL]${NC} $1" >&2 + ERRORS=$((ERRORS + 1)) +} + +log_warn() { + echo -e " ${YELLOW}! [WARN]${NC} $1" +} + +# 确保 ripgrep 可用 +if ! command -v rg >/dev/null 2>&1; then + echo -e "${RED}error: rg (ripgrep) is required to run architecture checks.${NC}" >&2 + exit 1 +fi + +echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}" +echo -e "${BOLD} Cordis Architecture & Spatiotemporal Composability Linter ${NC}" +echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}" + +# ============================================================================== +# 1. 微内核绝对隔离 (Core Micro-Kernel Isolation) +# ============================================================================== +log_check "1. 检查微内核 (backend/core/) 纯洁度..." + +# 1.1 禁止直接依赖重型 Web/ORM/Worker/Config 框架 +CORE_FRAMEWORK_IMPORTS=$(rg -n '"github.com/gin-gonic/gin"|"gorm.io/gorm"|"github.com/hibiken/asynq"|"github.com/robfig/cron|"github.com/spf13/viper"|"github.com/mitchellh/mapstructure"' \ + "${BACKEND_DIR}/core/" --glob '*.go' -g '!*contracts*' -g '!*_test.go' || true) + +if [ -n "${CORE_FRAMEWORK_IMPORTS}" ]; then + log_fail "backend/core/ 严禁导入具体 Web/ORM/Worker/Config 运行时框架 (gin, gorm, asynq, cron, viper, mapstructure):" + echo "${CORE_FRAMEWORK_IMPORTS}" >&2 +else + log_pass "backend/core/ 无重型框架依赖" +fi + +# 1.2 core/ 禁止导入任何插件 +CORE_PLUGIN_IMPORTS=$(rg -n "\"${MODULE}/plugins/|\"${MODULE}/downstream/" \ + "${BACKEND_DIR}/core/" --glob '*.go' -g '!*_test.go' || true) + +if [ -n "${CORE_PLUGIN_IMPORTS}" ]; then + log_fail "backend/core/ 严禁直接依赖具体插件 (plugins/ 或 downstream/):" + echo "${CORE_PLUGIN_IMPORTS}" >&2 +else + log_pass "backend/core/ 零插件反向依赖" +fi + +# ============================================================================== +# 2. 服务契约纯洁度 (Contracts Cleanliness) +# ============================================================================== +log_check "2. 检查契约层 (backend/core/contracts/) 抽象纯洁度..." + +CONTRACTS_PLUGIN_IMPORTS=$(rg -n "\"${MODULE}/plugins/|\"${MODULE}/downstream/|\"github.com/gin-gonic/gin\"|\"github.com/hibiken/asynq\"" \ + "${BACKEND_DIR}/core/contracts/" --glob '*.go' || true) + +if [ -n "${CONTRACTS_PLUGIN_IMPORTS}" ]; then + log_fail "backend/core/contracts/ 必须保持纯 Interface/DTO,严禁导入插件或 Web/Worker 框架依赖:" + echo "${CONTRACTS_PLUGIN_IMPORTS}" >&2 +else + log_pass "backend/core/contracts/ 纯抽象无侵入" +fi + +# 2.2 契约层禁止持久化耦合(表名映射 / ORM tag) +# DTO 一旦携带 TableName(),调用方就能用 Table("w_users") 直查他人表, +# 单表所有者原则与 contracts 抽象同时失效。 +# 注意:不禁 gorm import 本身 —— DBService 契约合法返回 gorm 类型。 +CONTRACTS_PERSISTENCE=$(rg -n 'func .*\)?\s*TableName\(\)|\bgorm:"' \ + "${BACKEND_DIR}/core/contracts/" --glob '*.go' -g '!*_test.go' || true) + +if [ -n "${CONTRACTS_PERSISTENCE}" ]; then + log_fail "backend/core/contracts/ 为跨插件纯 DTO,严禁携带表名映射或 ORM tag(持久化归属唯一所有者插件,否则调用方可绕过契约直读他人表):" + echo "${CONTRACTS_PERSISTENCE}" >&2 +else + log_pass "backend/core/contracts/ 无持久化耦合" +fi + +# ============================================================================== +# 3. 基础包纯洁度 (backend/pkg/ Purity) +# ============================================================================== +log_check "3. 检查基础库 (backend/pkg/) 纯洁度..." + +# 3.1 pkg/ 严禁导入 plugins/ +PKG_PLUGIN_IMPORTS=$(rg -n "\"${MODULE}/plugins/" \ + "${BACKEND_DIR}/pkg/" --glob '*.go' -g '!*testhelper*' -g '!*_test.go' || true) + +if [ -n "${PKG_PLUGIN_IMPORTS}" ]; then + log_fail "backend/pkg/ 严禁导入任何上层 plugins/:" + echo "${PKG_PLUGIN_IMPORTS}" >&2 +else + log_pass "backend/pkg/ 零插件依赖" +fi + +# 3.2 pkg/util/ 严禁导入 Gin / ORM / Session 框架 +UTIL_FRAMEWORK_IMPORTS=$(rg -n '"gorm.io/gorm"|"github.com/gorilla/sessions"|"github.com/gin-gonic/gin"' \ + "${BACKEND_DIR}/pkg/util/" --glob '*.go' -g '!*_test.go' || true) + +if [ -n "${UTIL_FRAMEWORK_IMPORTS}" ]; then + log_fail "backend/pkg/util/ 必须保持纯粹,禁止导入 gin、gorm、sessions 等 Web/数据库/会话框架包:" + echo "${UTIL_FRAMEWORK_IMPORTS}" >&2 +else + log_pass "backend/pkg/util/ 保持纯净无状态" +fi + +# ============================================================================== +# 4. 全量跨插件直接调用拦截 (Universal Cross-Plugin Import Guard) +# ============================================================================== +log_check "4. 检查插件间隔离性 (严禁跨插件直接 import,必须面向 core/contracts 编程)..." + +CROSS_PLUGIN_IMPORTS="" + +# 遍历 plugins/ 下的所有类别 (domain, infra, drivers) 和子插件 +for category_dir in "${BACKEND_DIR}"/plugins/*/; do + [ -d "$category_dir" ] || continue + category=$(basename "$category_dir") + for plugin_dir in "$category_dir"*/; do + [ -d "$plugin_dir" ] || continue + plugin_name=$(basename "$plugin_dir") + + self_prefix="${MODULE}/plugins/${category}/${plugin_name}" + + # 查找该插件内所有的 "Wavelet/plugins/" 导入,排除自身前缀和测试文件 + cross_imports=$(rg -n "\"${MODULE}/plugins/" "${plugin_dir}" \ + -g '*.go' -g '!*_test.go' 2>/dev/null | rg -v "\"${self_prefix}(/|\")" || true) + + if [ -n "$cross_imports" ]; then + CROSS_PLUGIN_IMPORTS="${CROSS_PLUGIN_IMPORTS}\n[${category}/${plugin_name} 违规引用其他插件]:\n${cross_imports}\n" + fi + done +done + +# 检查 downstream/ 下的下游插件 +if [ -d "${BACKEND_DIR}/downstream/plugins" ]; then + for downstream_dir in "${BACKEND_DIR}"/downstream/plugins/*/; do + [ -d "$downstream_dir" ] || continue + downstream_name=$(basename "$downstream_dir") + downstream_cross=$(rg -n "\"${MODULE}/plugins/" "${downstream_dir}" \ + -g '*.go' -g '!*_test.go' 2>/dev/null || true) + if [ -n "$downstream_cross" ]; then + CROSS_PLUGIN_IMPORTS="${CROSS_PLUGIN_IMPORTS}\n[downstream/${downstream_name} 违规直接引用内部插件实现]:\n${downstream_cross}\n" + fi + done +fi + +if [ -n "${CROSS_PLUGIN_IMPORTS}" ]; then + log_fail "发现跨插件直接依赖违规(必须通过 core/contracts 契约接口或 EventBus 解耦,严禁跨插件直接 import 具体包):" + echo -e "${CROSS_PLUGIN_IMPORTS}" >&2 +else + log_pass "所有插件 100% 解耦,零跨插件直接 import" +fi + +# ============================================================================== +# 5. 数据库规范与 GORM AutoMigrate 禁令 (Database Migration & ORM Rules) +# ============================================================================== +log_check "5. 检查数据库操作与 AutoMigrate 禁令..." + +AUTOMIGRATE_CALLS=$(rg -n '\.AutoMigrate\(' "${BACKEND_DIR}" \ + --glob '*.go' -g '!*_test.go' -g '!*testhelper*' || true) + +if [ -n "${AUTOMIGRATE_CALLS}" ]; then + log_fail "严禁在生产代码中使用 GORM AutoMigrate(必须使用插件自包含 Goose SQL 迁移):" + echo "${AUTOMIGRATE_CALLS}" >&2 +else + log_pass "零 GORM AutoMigrate,100% Goose SQL 迁移管理" +fi + +# ============================================================================== +# 6. 并发安全规范 (Goroutine Concurrency Safety) +# ============================================================================== +log_check "6. 检查并发安全规范 (禁止生产代码中使用裸 go 启动 goroutine)..." + +# 同时覆盖 `go func() {...}()` 匿名形式与 `go worker.run()` / `go loop()` 命名调用形式: +# 两者都不具备 panic 恢复能力,被调方一旦 panic 会直接击穿整个进程。 +# 例外:util.Go 自身的实现与事件总线 (它们内部已 recover)。 +BARE_GO_ROUTINES=$(rg -n --pcre2 '^[[:space:]]*go\s+(func\s*[\w{]|\w+(\.\w+)*\s*[\({])' "${BACKEND_DIR}" \ + --glob '*.go' -g '!*_test.go' -g '!goroutine.go' -g '!events.go' || true) + +if [ -n "${BARE_GO_ROUTINES}" ]; then + log_fail "生产代码严禁裸 'go' 启动 goroutine(含 'go func()' 与 'go xxx()' 命名调用),必须使用 'util.Go' 确保 panic 恢复与调用栈追踪:" + echo "${BARE_GO_ROUTINES}" >&2 +else + log_pass "并发调用统一使用 util.Go 具备 panic 恢复能力" +fi + +# ============================================================================== +# 7. 插件驱动无关性 (Driver-Agnostic Plugins) +# ============================================================================== +log_check "7. 检查业务/基础设施插件的驱动无关性 (禁止绑定具体 Worker/调度器运行时类型)..." + +# 业务插件只能通过 ctx.Task() / ctx.Schedule() 扩展点声明工作,不得 import Worker +# 驱动运行时类型:绑定 *asynq.Task 之类的签名只被 asynq 驱动满足,换用 in-process +# Worker 后 invokeHandler 会以 "unsupported handler type" 直接拒绝,任务永不执行。 +RUNTIME_BOUND=$(rg -n '"github.com/hibiken/asynq"' \ + "${BACKEND_DIR}/plugins/domain/" "${BACKEND_DIR}/plugins/infra/" \ + --glob '*.go' -g '!*_test.go' 2>/dev/null || true) + +if [ -n "${RUNTIME_BOUND}" ]; then + log_fail "业务/基础设施插件严禁依赖具体 Worker/调度器运行时(必须面向 ctx.Task()/ctx.Schedule() 扩展点编程,否则更换驱动后任务无法执行):" + echo "${RUNTIME_BOUND}" >&2 +else + log_pass "业务/基础设施插件保持驱动无关,可自由切换 Worker 驱动" +fi + +# ============================================================================== +# 总结与判定 +# ============================================================================== +echo -e "${BOLD}═══════════════════════════════════════════════════════════════${NC}" +if [ ${ERRORS} -eq 0 ]; then + echo -e "${GREEN}${BOLD}✓ 所有 Cordis 架构合规性检查全部通过 (0 Violations)!${NC}" + exit 0 +else + echo -e "${RED}${BOLD}✗ 发现 ${ERRORS} 项 Cordis 架构规约违背,请根据上述提示修复!${NC}" >&2 + exit 1 +fi