Compare commits
55 Commits
main
...
f04b5707cb
| Author | SHA1 | Date | |
|---|---|---|---|
| f04b5707cb | |||
| be4db0bf96 | |||
| 3fc8b1f924 | |||
| 762e528334 | |||
| d68a57009c | |||
| c4bafc5b41 | |||
| 12d05f75e9 | |||
| ce4f8b786d | |||
| d8b3424663 | |||
| f9068f530b | |||
| 76626b8372 | |||
| e0ce81318d | |||
| 474e1c1a64 | |||
| 8f8b06def7 | |||
| dba615d636 | |||
| 4169ebe1ed | |||
| f5144038ab | |||
| 83823a8d49 | |||
| bfe890bd56 | |||
| 7a069fe293 | |||
| c3a6baa419 | |||
| c32347ed3c | |||
| d908036621 | |||
| 21dce13bde | |||
| b8d66df5a1 | |||
| 0d4000bba5 | |||
| 9807752e4e | |||
| 85ad3a3f68 | |||
| 49bd7b74c6 | |||
| 3b60e6fdea | |||
| 3428a9823b | |||
| be73e595ca | |||
| ed92b475d1 | |||
| 96858aa85f | |||
| 9fe51036e6 | |||
| 16e024561d | |||
| e8dd5b56ac | |||
| e5ce8aed3e | |||
| 299fd974df | |||
| dffcf9f9e5 | |||
| 1fa1f856c0 | |||
| 834cf196f6 | |||
| 78c24574b4 | |||
| cb2e5f47b5 | |||
| d1e42ea18a | |||
| b99dc3dca1 | |||
| f7ae917603 | |||
| 766c691eb3 | |||
| 6c31b18e0f | |||
| 7dd3fda73c | |||
| 5e4803b97f | |||
| 0185ecd0df | |||
| 580697f17a | |||
| b4a0c21ce1 | |||
| dbee704c95 |
212
.codebuddy/skills/brainstorming/SKILL.md
Normal file
212
.codebuddy/skills/brainstorming/SKILL.md
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
---
|
||||||
|
name: brainstorming
|
||||||
|
description: "在任何创造性工作之前必须使用此技能——创建功能、构建组件、添加功能或修改行为。在实现之前先探索用户意图、需求和设计。"
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [design, planning]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 头脑风暴:将想法转化为设计
|
||||||
|
|
||||||
|
通过自然的协作对话,帮助将想法转化为完整的设计和规格说明。
|
||||||
|
|
||||||
|
先判断这个需求需要多少流程,然后沿着对应的路径推进:理解上下文、完善想法、展示设计、获得你的人类伙伴批准。
|
||||||
|
|
||||||
|
<HARD-GATE>
|
||||||
|
在你告诉你的人类伙伴你打算做什么、并得到他们批准之前,不要调用任何实现技能、编写任何代码、搭建任何项目或采取任何实现行动。这适用于下面**每一条路径上的每一个任务**——仪式感随任务大小缩放,批准这道关卡永远不缩放。
|
||||||
|
</HARD-GATE>
|
||||||
|
|
||||||
|
## 三条路径
|
||||||
|
|
||||||
|
在提出第一个问题之前,先给需求分类,并把分类**说出来**——"这个看起来是有界的,所以我会在这里直接给一份简短设计,而不是写规格文档"——好让你的人类伙伴能纠正你:
|
||||||
|
|
||||||
|
- **探路(Spike)** — 一个可行性问题("我们能不能……"、"有没有可能……"、"糙一点没关系"),它的产出是一个**答案**,不是要留下的代码。用 2-3 句话说明问题和你打算怎么试,得到一个点头,然后用不牺牲正确性的最低成本去弄清楚。不写设计文档,不写规格文件。以建议的形式汇报发现;过程中搭的任何东西都明确标注为一次性的。
|
||||||
|
- **有界(Bounded)** — 对**本仓库里已经存在的代码**做范围明确的改动:加一个开关、一个小接口、改一个文件的 bug。"知道这是个什么类型的应用"不算数——有界意味着**你要改的那条流程此刻就在仓库里、可以读**。如果没有现成的流程可改,这个任务就不是有界的。问那些真正重要的澄清问题,**在对话里**给出一份简短设计(几句话到几个短段落),然后**停下**。只有在你的人类伙伴对这份设计说"可以"之后,实现才开始——有界任务的批准和架构级任务的批准是同样硬的关卡。不写规格文件,不写实现计划文档。
|
||||||
|
- **架构级(Architectural)** — 新项目、新子系统,以及会重构组件之间关系、或改动他人依赖的接口的改动。走完整流程:提问、方案对比、分节设计、书面规格,然后交给 writing-plans 技能。
|
||||||
|
|
||||||
|
在两条路径之间拿不准时,选更重的那条。这个棘轮只朝一个方向转:任务进行中发现隐藏的复杂度,就**升级**路径——停下来、说明情况、升上去。任何情况下都不在任务中途降级。
|
||||||
|
|
||||||
|
## 反模式:"这个太简单了,不需要批准"
|
||||||
|
|
||||||
|
每条路径的终点都是你的人类伙伴在实现之前批准你的意图。一个待办事项列表、一个单函数工具、一个配置变更——设计可以只是对话里的两句话,但你**必须**把它展示出来并获得批准。"简单"的任务恰恰是未经检验的假设造成最多浪费的地方。随简单程度缩放的是**产出物**,永远不是批准。
|
||||||
|
|
||||||
|
## 危险信号
|
||||||
|
|
||||||
|
| 心里的想法 | 实际情况 |
|
||||||
|
|---------|---------|
|
||||||
|
| "这个太简单了,不需要设计" | 简单意味着简短的设计,不是没有设计。对话里两句话,然后获得批准。 |
|
||||||
|
| "我就说它是有界的,跳过规格文档" | 为了少干活而去够一个标签,这本身就是"拿不准"——选更重的那条路径。 |
|
||||||
|
| "它是有界的,设计也很显然——我一边让他们读一边开工" | 关卡是**批准**,不是设计的长度。展示完就停,直到听见"可以"。 |
|
||||||
|
| "这类应用我很熟,所以它是有界的" | 有界衡量的是**仓库**,不是你的熟悉程度。新项目没有现成的流程可改——那是架构级。 |
|
||||||
|
| "探路跑通了,那这些代码就留着吧" | 探路的产出是一个答案。要留下代码是一个**新的需求**——给它重新分类。 |
|
||||||
|
| "范围是变大了,但我快做完了,不用重新分类" | 隐藏的复杂度会在任务中途升级路径。停下来,说明情况。 |
|
||||||
|
| "他们批准了探路,那后续改动也算批准了" | 每个任务有自己的分类,也有自己的批准。 |
|
||||||
|
|
||||||
|
## 检查清单
|
||||||
|
|
||||||
|
先分类,宣布路径,然后为你所在路径上的每个条目创建任务,并按顺序完成。
|
||||||
|
|
||||||
|
**探路(Spike):**
|
||||||
|
1. **探索项目上下文** — 够用来框定这次试探即可
|
||||||
|
2. **展示问题 + 试探计划** — 2-3 句话
|
||||||
|
3. **获得批准** — 一个点头就够
|
||||||
|
4. **动手调查** — 用不牺牲正确性的最低成本
|
||||||
|
5. **汇报发现** — 以建议的形式;搭出来的任何东西都标注为一次性的
|
||||||
|
|
||||||
|
**有界(Bounded):**
|
||||||
|
1. **探索项目上下文** — 检查文件、文档、最近的 commit
|
||||||
|
2. **提出澄清问题** — 每次一个,只问那些真正重要的
|
||||||
|
3. **在对话里展示简短设计** — 思路、会动哪些文件、怎么测
|
||||||
|
4. **获得批准** — **停下**并等待一个明确的"可以";展示完设计顺口就开工,等于跳过了关卡
|
||||||
|
5. **实现** — 走正常的开发工作流(TDD 同样适用);不写计划文档
|
||||||
|
|
||||||
|
**架构级(Architectural):**
|
||||||
|
1. **探索项目上下文** — 检查文件、文档、最近的 commit
|
||||||
|
2. **在需要时才提供视觉伴侣** — **不要一上来就提**。第一次遇到"这个问题画出来比说出来更清楚"时,才在那一刻提供(作为独立的一条消息);对方同意后,浏览器标签页会为你打开。如果自始至终没出现视觉问题,就永远不要提。参见下方"视觉伴侣"部分。
|
||||||
|
3. **提出澄清问题** — 每次一个,了解目的/约束/成功标准
|
||||||
|
4. **提出 2-3 种方案** — 附带权衡分析和你的推荐
|
||||||
|
5. **展示设计** — 按复杂度分节展示,每节展示后获得用户批准
|
||||||
|
6. **编写设计文档** — 保存到 `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md` 并 commit
|
||||||
|
7. **规格自检** — 快速内联检查占位符、矛盾、模糊性、范围(详见下方)
|
||||||
|
8. **用户审查书面规格** — 在继续之前请用户审查规格文件
|
||||||
|
9. **过渡到实现** — 调用 writing-plans 技能创建实现计划
|
||||||
|
|
||||||
|
## 流程图
|
||||||
|
|
||||||
|
```dot
|
||||||
|
digraph brainstorming {
|
||||||
|
"分类:探路 / 有界 / 架构级" [shape=diamond];
|
||||||
|
"展示问题 + 试探计划(2-3 句)" [shape=box];
|
||||||
|
"提出澄清问题(有界)" [shape=box];
|
||||||
|
"在对话里展示简短设计" [shape=box];
|
||||||
|
"人类伙伴批准?" [shape=diamond];
|
||||||
|
"动手调查;汇报建议" [shape=doublecircle];
|
||||||
|
"走正常工作流实现(无计划文档)" [shape=doublecircle];
|
||||||
|
"探索项目上下文" [shape=box];
|
||||||
|
"提出澄清问题" [shape=box];
|
||||||
|
"提出 2-3 种方案" [shape=box];
|
||||||
|
"分节展示设计" [shape=box];
|
||||||
|
"用户批准设计?" [shape=diamond];
|
||||||
|
"编写设计文档" [shape=box];
|
||||||
|
"规格自检\n(内联修复)" [shape=box];
|
||||||
|
"用户审查规格?" [shape=diamond];
|
||||||
|
"调用 writing-plans 技能" [shape=doublecircle];
|
||||||
|
"发现隐藏复杂度? 升级路径" [shape=box];
|
||||||
|
|
||||||
|
"分类:探路 / 有界 / 架构级" -> "展示问题 + 试探计划(2-3 句)" [label="探路"];
|
||||||
|
"分类:探路 / 有界 / 架构级" -> "提出澄清问题(有界)" [label="有界"];
|
||||||
|
"分类:探路 / 有界 / 架构级" -> "探索项目上下文" [label="架构级"];
|
||||||
|
"展示问题 + 试探计划(2-3 句)" -> "人类伙伴批准?";
|
||||||
|
"提出澄清问题(有界)" -> "在对话里展示简短设计";
|
||||||
|
"在对话里展示简短设计" -> "人类伙伴批准?";
|
||||||
|
"人类伙伴批准?" -> "动手调查;汇报建议" [label="探路:是"];
|
||||||
|
"人类伙伴批准?" -> "走正常工作流实现(无计划文档)" [label="有界:是"];
|
||||||
|
"发现隐藏复杂度? 升级路径" -> "分类:探路 / 有界 / 架构级";
|
||||||
|
"探索项目上下文" -> "提出澄清问题";
|
||||||
|
"提出澄清问题" -> "提出 2-3 种方案";
|
||||||
|
"提出 2-3 种方案" -> "分节展示设计";
|
||||||
|
"分节展示设计" -> "用户批准设计?";
|
||||||
|
"用户批准设计?" -> "分节展示设计" [label="否,修改"];
|
||||||
|
"用户批准设计?" -> "编写设计文档" [label="是"];
|
||||||
|
"编写设计文档" -> "规格自检\n(内联修复)";
|
||||||
|
"规格自检\n(内联修复)" -> "用户审查规格?";
|
||||||
|
"用户审查规格?" -> "编写设计文档" [label="要求修改"];
|
||||||
|
"用户审查规格?" -> "调用 writing-plans 技能" [label="批准"];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**终止状态跟着路径走。** 架构级:头脑风暴之后你唯一要调用的技能是 writing-plans——绝不调用 frontend-design、mcp-builder 或任何其他实现技能。有界:获得批准之后,直接走正常的开发工作流去实现,不写计划文档。探路:终止状态是一份汇报出去的建议。
|
||||||
|
|
||||||
|
## 流程详述
|
||||||
|
|
||||||
|
下面这些小节服务于**有界**和**架构级**两条路径(探路在"展示试探计划、拿到点头"就停了)。从**探索方案**往后都是架构级路径的深度——对有界的工作来说,上下文加几个问题再加一份对话里的简短设计,就是全部流程。
|
||||||
|
|
||||||
|
**理解想法:**
|
||||||
|
|
||||||
|
- 首先查看当前项目状态(文件、文档、最近的 commit)
|
||||||
|
- 在提出详细问题之前,先评估范围:如果需求描述了多个独立子系统(例如"构建一个包含聊天、文件存储、计费和分析的平台"),立即指出这一点。不要花时间用问题去细化一个需要先拆分的项目。
|
||||||
|
- 如果项目规模过大,单个规格说明无法覆盖,帮助用户分解为子项目:有哪些独立的部分,它们之间有什么关系,应该按什么顺序构建?然后通过正常的设计流程进行第一个子项目的头脑风暴。每个子项目都有自己的规格 → 计划 → 实现周期。
|
||||||
|
- 对于范围适当的项目,每次提一个问题来完善想法
|
||||||
|
- 尽量使用选择题,开放式问题也可以
|
||||||
|
- 每条消息只提一个问题——如果一个主题需要更多探索,拆分成多个问题
|
||||||
|
- 重点理解:目的、约束、成功标准
|
||||||
|
|
||||||
|
**探索方案:**
|
||||||
|
|
||||||
|
- 提出 2-3 种不同的方案及其权衡
|
||||||
|
- 以对话的方式展示选项,附上你的推荐和理由
|
||||||
|
- 先展示你推荐的方案并解释原因
|
||||||
|
- 严格遵循 YAGNI —— 从每个方案和设计里移除不必要的功能
|
||||||
|
|
||||||
|
**展示设计:**
|
||||||
|
|
||||||
|
- 一旦你认为理解了要构建的内容,就展示设计
|
||||||
|
- 每个部分的篇幅与其复杂度匹配:简单的几句话,复杂的最多 200-300 字
|
||||||
|
- 每个部分展示后询问是否正确
|
||||||
|
- 涵盖:架构、组件、数据流、错误处理、测试
|
||||||
|
- 随时准备回头澄清不明确的地方
|
||||||
|
|
||||||
|
**面向隔离和清晰的设计:**
|
||||||
|
|
||||||
|
- 将系统拆分为更小的单元,每个单元有一个明确的职责,通过定义良好的接口通信,可以独立理解和测试
|
||||||
|
- 对于每个单元,你应该能回答:它做什么,如何使用,它依赖什么?
|
||||||
|
- 别人能否不看内部实现就理解一个单元的功能?你能否在不影响调用者的情况下修改内部实现?如果不能,边界需要调整。
|
||||||
|
- 更小、边界清晰的单元也更便于你工作——你对能一次放入上下文的代码推理得更好,文件越专注你的编辑越可靠。当文件变大时,这通常意味着它承担了过多职责。
|
||||||
|
|
||||||
|
**在现有代码库中工作:**
|
||||||
|
|
||||||
|
- 在提出更改之前先探索现有结构。遵循现有模式。
|
||||||
|
- 如果现有代码存在影响当前工作的问题(例如文件过大、边界不清、职责纠缠),在设计中包含有针对性的改进——就像一个优秀的开发者在工作中改进经手的代码一样。
|
||||||
|
- 不要提议无关的重构。专注于服务当前目标的事情。
|
||||||
|
|
||||||
|
## 设计之后(架构级路径)
|
||||||
|
|
||||||
|
**文档:**
|
||||||
|
|
||||||
|
- 将验证通过的设计(规格说明)写入 `docs/superpowers/specs/YYYY-MM-DD-<topic>-design.md`
|
||||||
|
- (用户对规格位置的偏好优先于此默认值)
|
||||||
|
- 如果可用,使用 elements-of-style:writing-clearly-and-concisely 技能
|
||||||
|
- 将设计文档 commit 到 git
|
||||||
|
|
||||||
|
**规格自检:**
|
||||||
|
编写规格文档后,以全新的视角审视它:
|
||||||
|
|
||||||
|
1. **占位符扫描:** 有没有"待定"、"TODO"、未完成的章节或模糊的需求?修复它们。
|
||||||
|
2. **内部一致性:** 各章节之间有矛盾吗?架构和功能描述匹配吗?
|
||||||
|
3. **范围检查:** 这是否聚焦到可以用一个实现计划覆盖,还是需要进一步拆分?
|
||||||
|
4. **模糊性检查:** 有没有需求可以被两种方式理解?如果有,选择一种并明确写出来。
|
||||||
|
|
||||||
|
发现问题就直接内联修复。无需重新审查——修好继续推进。
|
||||||
|
|
||||||
|
**用户审查关卡:**
|
||||||
|
规格自检完成后,请用户在继续之前审查书面规格:
|
||||||
|
|
||||||
|
> "规格已编写并 commit 到 `<path>`。请审查一下,如果在我们开始编写实现计划之前你想做任何修改,请告诉我。"
|
||||||
|
|
||||||
|
等待用户回复。如果他们要求修改,做出修改并重新运行规格自检。只有在用户批准后才继续。
|
||||||
|
|
||||||
|
**实现:**
|
||||||
|
|
||||||
|
- 调用 writing-plans 技能创建详细的实现计划
|
||||||
|
- 不要调用任何其他技能。writing-plans 是下一步。
|
||||||
|
|
||||||
|
## 视觉伴侣
|
||||||
|
|
||||||
|
一个基于浏览器的伴侣工具,用于在头脑风暴过程中展示原型、图表和视觉选项。它是一个工具——不是一种模式。接受伴侣意味着它可用于适合视觉呈现的问题;并不意味着每个问题都要通过浏览器。
|
||||||
|
|
||||||
|
**提供伴侣(在需要时才提):** **不要一上来就提。** 等到某个问题确实"画出来比说出来更清楚"时再提——要是真正的原型 / 布局 / 图表问题,而不仅仅是话题跟 UI 沾边。第一次出现这种情况时,就在那一刻提供,作为独立的一条消息:
|
||||||
|
> "接下来这部分,我展示给你看可能更容易理解——我可以在讨论过程中,在一个浏览器标签页里做原型、图表和对比。这个功能还比较新,可能会消耗较多 token。要我打开吗?我来帮你打开。"
|
||||||
|
|
||||||
|
**此提议必须是一条独立的消息。** 只有这条提议——不含澄清问题、内容摘要或任何其他内容。等待用户回复。如果他们接受,用 `--open` 启动服务,浏览器会自动打开到第一屏。如果他们拒绝,继续纯文本进行,并且不要再提,除非他们自己提起。
|
||||||
|
|
||||||
|
**逐问题决策:** 即使用户接受了,也要对每个问题单独决定是使用浏览器还是终端。判断标准:**用户看到它是否比读到它更容易理解?**
|
||||||
|
|
||||||
|
- **使用浏览器** 展示本身就是视觉的内容——原型、线框图、布局对比、架构图、并排视觉设计
|
||||||
|
- **使用终端** 展示文本内容——需求问题、概念选择、权衡列表、A/B/C/D 文字选项、范围决策
|
||||||
|
|
||||||
|
关于 UI 主题的问题不一定是视觉问题。"在这个上下文中个性化是什么意思?"是一个概念问题——使用终端。"哪种向导布局更好?"是一个视觉问题——使用浏览器。
|
||||||
|
|
||||||
|
如果他们同意使用伴侣,在继续之前阅读详细指南:
|
||||||
|
`skills/brainstorming/visual-companion.md`
|
||||||
213
.codebuddy/skills/brainstorming/scripts/frame-template.html
Normal file
213
.codebuddy/skills/brainstorming/scripts/frame-template.html
Normal file
@ -0,0 +1,213 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<title>Superpowers Brainstorming</title>
|
||||||
|
<style>
|
||||||
|
/*
|
||||||
|
* BRAINSTORM COMPANION FRAME TEMPLATE
|
||||||
|
*
|
||||||
|
* This template provides a consistent frame with:
|
||||||
|
* - OS-aware light/dark theming
|
||||||
|
* - Header branding and connection status
|
||||||
|
* - Scrollable main content area
|
||||||
|
* - CSS helpers for common UI patterns
|
||||||
|
*
|
||||||
|
* Content is injected via placeholder comment in #frame-content.
|
||||||
|
*/
|
||||||
|
|
||||||
|
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
|
html, body { height: 100%; overflow: hidden; }
|
||||||
|
|
||||||
|
/* ===== THEME VARIABLES ===== */
|
||||||
|
:root {
|
||||||
|
--bg-primary: #f5f5f7;
|
||||||
|
--bg-secondary: #ffffff;
|
||||||
|
--bg-tertiary: #e5e5e7;
|
||||||
|
--border: #d1d1d6;
|
||||||
|
--text-primary: #1d1d1f;
|
||||||
|
--text-secondary: #86868b;
|
||||||
|
--text-tertiary: #aeaeb2;
|
||||||
|
--accent: #0071e3;
|
||||||
|
--accent-hover: #0077ed;
|
||||||
|
--success: #34c759;
|
||||||
|
--warning: #ff9f0a;
|
||||||
|
--error: #ff3b30;
|
||||||
|
--selected-bg: #e8f4fd;
|
||||||
|
--selected-border: #0071e3;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
:root {
|
||||||
|
--bg-primary: #1d1d1f;
|
||||||
|
--bg-secondary: #2d2d2f;
|
||||||
|
--bg-tertiary: #3d3d3f;
|
||||||
|
--border: #424245;
|
||||||
|
--text-primary: #f5f5f7;
|
||||||
|
--text-secondary: #86868b;
|
||||||
|
--text-tertiary: #636366;
|
||||||
|
--accent: #0a84ff;
|
||||||
|
--accent-hover: #409cff;
|
||||||
|
--selected-bg: rgba(10, 132, 255, 0.15);
|
||||||
|
--selected-border: #0a84ff;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== FRAME STRUCTURE ===== */
|
||||||
|
.brand { display: flex; align-items: center; min-width: 0; overflow: hidden; color: var(--text-secondary); line-height: 1; }
|
||||||
|
.brand a { color: inherit; text-decoration: none; display: flex; align-items: center; gap: 0.5rem; min-width: 0; max-width: 100%; line-height: 1; }
|
||||||
|
.brand-copy { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 1; transform: translateY(-1px); }
|
||||||
|
.brand-logo { display: block; height: 1em; width: auto; max-width: 180px; flex-shrink: 0; filter: invert(1); }
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.brand-logo { filter: none; }
|
||||||
|
}
|
||||||
|
.status { font-size: 0.7rem; color: var(--status-color, var(--success)); display: flex; align-items: center; gap: 0.4rem; justify-self: end; white-space: nowrap; line-height: 1; }
|
||||||
|
.status::before { content: ''; width: 6px; height: 6px; background: var(--status-color, var(--success)); border-radius: 50%; }
|
||||||
|
|
||||||
|
.main { flex: 1; overflow-y: auto; }
|
||||||
|
#frame-content { padding: 2rem; min-height: 100%; }
|
||||||
|
|
||||||
|
.header {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
flex-shrink: 0;
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(0, 1fr) auto;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 42px;
|
||||||
|
}
|
||||||
|
.header .brand { justify-self: start; width: 100%; font-size: 0.75rem; line-height: 1; }
|
||||||
|
.header .status { grid-column: 2; line-height: 1; }
|
||||||
|
.header span {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
.header .selected-text {
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== TYPOGRAPHY ===== */
|
||||||
|
h2 { font-size: 1.5rem; font-weight: 600; margin-bottom: 0.5rem; }
|
||||||
|
h3 { font-size: 1.1rem; font-weight: 600; margin-bottom: 0.25rem; }
|
||||||
|
.subtitle { color: var(--text-secondary); margin-bottom: 1.5rem; }
|
||||||
|
.section { margin-bottom: 2rem; }
|
||||||
|
.label { font-size: 0.7rem; color: var(--text-secondary); text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 0.5rem; }
|
||||||
|
|
||||||
|
/* ===== OPTIONS (for A/B/C choices) ===== */
|
||||||
|
.options { display: flex; flex-direction: column; gap: 0.75rem; }
|
||||||
|
.option {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 2px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 1rem 1.25rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
.option:hover { border-color: var(--accent); }
|
||||||
|
.option.selected { background: var(--selected-bg); border-color: var(--selected-border); }
|
||||||
|
.option .letter {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-secondary);
|
||||||
|
width: 1.75rem; height: 1.75rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
font-weight: 600; font-size: 0.85rem; flex-shrink: 0;
|
||||||
|
}
|
||||||
|
.option.selected .letter { background: var(--accent); color: white; }
|
||||||
|
.option .content { flex: 1; }
|
||||||
|
.option .content h3 { font-size: 0.95rem; margin-bottom: 0.15rem; }
|
||||||
|
.option .content p { color: var(--text-secondary); font-size: 0.85rem; margin: 0; }
|
||||||
|
|
||||||
|
/* ===== CARDS (for showing designs/mockups) ===== */
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: 1rem; }
|
||||||
|
.card {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.15s ease;
|
||||||
|
}
|
||||||
|
.card:hover { border-color: var(--accent); transform: translateY(-2px); box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||||
|
.card.selected { border-color: var(--selected-border); border-width: 2px; }
|
||||||
|
.card-image { background: var(--bg-tertiary); aspect-ratio: 16/10; display: flex; align-items: center; justify-content: center; }
|
||||||
|
.card-body { padding: 1rem; }
|
||||||
|
.card-body h3 { margin-bottom: 0.25rem; }
|
||||||
|
.card-body p { color: var(--text-secondary); font-size: 0.85rem; }
|
||||||
|
|
||||||
|
/* ===== MOCKUP CONTAINER ===== */
|
||||||
|
.mockup {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
.mockup-header {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
.mockup-body { padding: 1.5rem; }
|
||||||
|
|
||||||
|
/* ===== SPLIT VIEW (side-by-side comparison) ===== */
|
||||||
|
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; }
|
||||||
|
@media (max-width: 700px) { .split { grid-template-columns: 1fr; } }
|
||||||
|
|
||||||
|
/* ===== PROS/CONS ===== */
|
||||||
|
.pros-cons { display: grid; grid-template-columns: 1fr 1fr; gap: 1rem; margin: 1rem 0; }
|
||||||
|
.pros, .cons { background: var(--bg-secondary); border-radius: 8px; padding: 1rem; }
|
||||||
|
.pros h4 { color: var(--success); font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||||
|
.cons h4 { color: var(--error); font-size: 0.85rem; margin-bottom: 0.5rem; }
|
||||||
|
.pros ul, .cons ul { margin-left: 1.25rem; font-size: 0.85rem; color: var(--text-secondary); }
|
||||||
|
.pros li, .cons li { margin-bottom: 0.25rem; }
|
||||||
|
|
||||||
|
/* ===== PLACEHOLDER (for mockup areas) ===== */
|
||||||
|
.placeholder {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 2px dashed var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
color: var(--text-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== INLINE MOCKUP ELEMENTS ===== */
|
||||||
|
.mock-nav { background: var(--accent); color: white; padding: 0.75rem 1rem; display: flex; gap: 1.5rem; font-size: 0.9rem; }
|
||||||
|
.mock-sidebar { background: var(--bg-tertiary); padding: 1rem; min-width: 180px; }
|
||||||
|
.mock-content { padding: 1.5rem; flex: 1; }
|
||||||
|
.mock-button { background: var(--accent); color: white; border: none; padding: 0.5rem 1rem; border-radius: 6px; font-size: 0.85rem; }
|
||||||
|
.mock-input { background: var(--bg-primary); border: 1px solid var(--border); border-radius: 6px; padding: 0.5rem; width: 100%; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="header">
|
||||||
|
<!-- BRANDING -->
|
||||||
|
<div class="status">Connecting…</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="main">
|
||||||
|
<div id="frame-content">
|
||||||
|
<!-- CONTENT -->
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
167
.codebuddy/skills/brainstorming/scripts/helper.js
Normal file
167
.codebuddy/skills/brainstorming/scripts/helper.js
Normal file
@ -0,0 +1,167 @@
|
|||||||
|
(function() {
|
||||||
|
const MIN_RECONNECT_MS = 500;
|
||||||
|
const MAX_RECONNECT_MS = 30000;
|
||||||
|
const TOMBSTONE_AFTER_MS = 15000; // show the "paused" overlay after this long disconnected
|
||||||
|
|
||||||
|
// Pure: next backoff delay (doubles, capped). Exported for unit tests.
|
||||||
|
function nextReconnectDelay(current, max) {
|
||||||
|
return Math.min(current * 2, max);
|
||||||
|
}
|
||||||
|
if (typeof module !== 'undefined' && module.exports) {
|
||||||
|
module.exports = { nextReconnectDelay, MIN_RECONNECT_MS, MAX_RECONNECT_MS, TOMBSTONE_AFTER_MS };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Everything below is browser-only; bail out when loaded in Node (tests).
|
||||||
|
if (typeof window === 'undefined') return;
|
||||||
|
|
||||||
|
let ws = null;
|
||||||
|
let eventQueue = [];
|
||||||
|
let reconnectDelay = MIN_RECONNECT_MS;
|
||||||
|
let reconnectTimer = null;
|
||||||
|
let disconnectedSince = null;
|
||||||
|
let everConnected = false;
|
||||||
|
let tombstoneShown = false;
|
||||||
|
|
||||||
|
function sessionKey() {
|
||||||
|
try {
|
||||||
|
return window.sessionStorage && window.sessionStorage.getItem('brainstorm-session-key');
|
||||||
|
} catch (e) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function websocketUrl() {
|
||||||
|
const key = sessionKey();
|
||||||
|
return 'ws://' + window.location.host + (key ? '/?key=' + encodeURIComponent(key) : '');
|
||||||
|
}
|
||||||
|
|
||||||
|
function reloadAfterRecovery() {
|
||||||
|
const key = sessionKey();
|
||||||
|
if (key) {
|
||||||
|
window.location.replace('/?key=' + encodeURIComponent(key));
|
||||||
|
} else {
|
||||||
|
window.location.reload();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reflect connection state in the frame's status pill (absent on full-doc screens).
|
||||||
|
function setStatus(state) {
|
||||||
|
const el = document.querySelector('.status');
|
||||||
|
if (!el) return;
|
||||||
|
const map = {
|
||||||
|
connecting: ['Connecting…', 'var(--text-tertiary)'],
|
||||||
|
connected: ['Connected', 'var(--success)'],
|
||||||
|
reconnecting: ['Reconnecting…', 'var(--warning)'],
|
||||||
|
disconnected: ['Disconnected', 'var(--error)']
|
||||||
|
};
|
||||||
|
const [text, color] = map[state] || map.disconnected;
|
||||||
|
el.textContent = text;
|
||||||
|
el.style.setProperty('--status-color', color);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Self-styled so it works on framed and full-document screens alike.
|
||||||
|
function showTombstone() {
|
||||||
|
if (tombstoneShown) return;
|
||||||
|
tombstoneShown = true;
|
||||||
|
const el = document.createElement('div');
|
||||||
|
el.id = 'bs-tombstone';
|
||||||
|
el.style.cssText = 'position:fixed;inset:0;z-index:99999;display:flex;' +
|
||||||
|
'align-items:center;justify-content:center;padding:2rem;text-align:center;' +
|
||||||
|
'background:rgba(20,20,22,0.92);color:#f5f5f7;font-family:system-ui,sans-serif';
|
||||||
|
el.innerHTML = '<div style="max-width:480px">' +
|
||||||
|
'<h2 style="margin:0 0 .5rem;font-weight:600">Companion paused</h2>' +
|
||||||
|
'<p style="margin:0;opacity:.85">This brainstorm companion has stopped. ' +
|
||||||
|
'Ask your coding agent to bring it back — this page reconnects automatically.</p></div>';
|
||||||
|
if (document.body) document.body.appendChild(el);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||||||
|
setStatus(everConnected ? 'reconnecting' : 'connecting');
|
||||||
|
ws = new WebSocket(websocketUrl());
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
const recovered = tombstoneShown;
|
||||||
|
everConnected = true;
|
||||||
|
disconnectedSince = null;
|
||||||
|
reconnectDelay = MIN_RECONNECT_MS;
|
||||||
|
tombstoneShown = false;
|
||||||
|
setStatus('connected');
|
||||||
|
eventQueue.forEach(e => ws.send(JSON.stringify(e)));
|
||||||
|
eventQueue = [];
|
||||||
|
// Recovered from a tombstoned outage (e.g. the server restarted on the same
|
||||||
|
// port) — reload through the keyed bootstrap when possible so the cookie is
|
||||||
|
// refreshed before the visible URL returns to bare /.
|
||||||
|
if (recovered) reloadAfterRecovery();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (msg) => {
|
||||||
|
let data;
|
||||||
|
try { data = JSON.parse(msg.data); } catch (e) { return; }
|
||||||
|
if (data.type === 'reload') window.location.reload();
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = () => {
|
||||||
|
ws = null;
|
||||||
|
if (disconnectedSince === null) disconnectedSince = Date.now();
|
||||||
|
if (Date.now() - disconnectedSince >= TOMBSTONE_AFTER_MS) {
|
||||||
|
setStatus('disconnected');
|
||||||
|
showTombstone();
|
||||||
|
} else {
|
||||||
|
setStatus('reconnecting');
|
||||||
|
}
|
||||||
|
reconnectTimer = setTimeout(connect, reconnectDelay);
|
||||||
|
reconnectDelay = nextReconnectDelay(reconnectDelay, MAX_RECONNECT_MS);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Let onclose own reconnection so we don't schedule it twice.
|
||||||
|
ws.onerror = () => { try { ws.close(); } catch (e) {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function sendEvent(event) {
|
||||||
|
event.timestamp = Date.now();
|
||||||
|
if (ws && ws.readyState === WebSocket.OPEN) {
|
||||||
|
ws.send(JSON.stringify(event));
|
||||||
|
} else {
|
||||||
|
eventQueue.push(event);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Capture clicks on choice elements
|
||||||
|
document.addEventListener('click', (e) => {
|
||||||
|
const target = e.target.closest('[data-choice]');
|
||||||
|
if (!target) return;
|
||||||
|
|
||||||
|
sendEvent({
|
||||||
|
type: 'click',
|
||||||
|
text: target.textContent.trim(),
|
||||||
|
choice: target.dataset.choice,
|
||||||
|
id: target.id || null
|
||||||
|
});
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
// Frame UI: selection tracking
|
||||||
|
window.selectedChoice = null;
|
||||||
|
|
||||||
|
window.toggleSelect = function(el) {
|
||||||
|
const container = el.closest('.options') || el.closest('.cards');
|
||||||
|
const multi = container && container.dataset.multiselect !== undefined;
|
||||||
|
if (container && !multi) {
|
||||||
|
container.querySelectorAll('.option, .card').forEach(o => o.classList.remove('selected'));
|
||||||
|
}
|
||||||
|
if (multi) {
|
||||||
|
el.classList.toggle('selected');
|
||||||
|
} else {
|
||||||
|
el.classList.add('selected');
|
||||||
|
}
|
||||||
|
window.selectedChoice = el.dataset.choice;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expose API for explicit use
|
||||||
|
window.brainstorm = {
|
||||||
|
send: sendEvent,
|
||||||
|
choice: (value, metadata = {}) => sendEvent({ type: 'choice', value, ...metadata })
|
||||||
|
};
|
||||||
|
|
||||||
|
connect();
|
||||||
|
})();
|
||||||
723
.codebuddy/skills/brainstorming/scripts/server.cjs
Normal file
723
.codebuddy/skills/brainstorming/scripts/server.cjs
Normal file
@ -0,0 +1,723 @@
|
|||||||
|
const crypto = require('crypto');
|
||||||
|
const http = require('http');
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
|
||||||
|
// ========== WebSocket Protocol (RFC 6455) ==========
|
||||||
|
|
||||||
|
const OPCODES = { TEXT: 0x01, CLOSE: 0x08, PING: 0x09, PONG: 0x0A };
|
||||||
|
const WS_MAGIC = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
|
||||||
|
const MAX_FRAME_PAYLOAD_BYTES = 10 * 1024 * 1024;
|
||||||
|
|
||||||
|
function computeAcceptKey(clientKey) {
|
||||||
|
return crypto.createHash('sha1').update(clientKey + WS_MAGIC).digest('base64');
|
||||||
|
}
|
||||||
|
|
||||||
|
function encodeFrame(opcode, payload) {
|
||||||
|
const fin = 0x80;
|
||||||
|
const len = payload.length;
|
||||||
|
let header;
|
||||||
|
|
||||||
|
if (len < 126) {
|
||||||
|
header = Buffer.alloc(2);
|
||||||
|
header[0] = fin | opcode;
|
||||||
|
header[1] = len;
|
||||||
|
} else if (len < 65536) {
|
||||||
|
header = Buffer.alloc(4);
|
||||||
|
header[0] = fin | opcode;
|
||||||
|
header[1] = 126;
|
||||||
|
header.writeUInt16BE(len, 2);
|
||||||
|
} else {
|
||||||
|
header = Buffer.alloc(10);
|
||||||
|
header[0] = fin | opcode;
|
||||||
|
header[1] = 127;
|
||||||
|
header.writeBigUInt64BE(BigInt(len), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.concat([header, payload]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function decodeFrame(buffer) {
|
||||||
|
if (buffer.length < 2) return null;
|
||||||
|
|
||||||
|
const secondByte = buffer[1];
|
||||||
|
const opcode = buffer[0] & 0x0F;
|
||||||
|
const masked = (secondByte & 0x80) !== 0;
|
||||||
|
let payloadLen = secondByte & 0x7F;
|
||||||
|
let offset = 2;
|
||||||
|
|
||||||
|
if (!masked) throw new Error('Client frames must be masked');
|
||||||
|
|
||||||
|
if (payloadLen === 126) {
|
||||||
|
if (buffer.length < 4) return null;
|
||||||
|
payloadLen = buffer.readUInt16BE(2);
|
||||||
|
offset = 4;
|
||||||
|
} else if (payloadLen === 127) {
|
||||||
|
if (buffer.length < 10) return null;
|
||||||
|
const extendedLen = buffer.readBigUInt64BE(2);
|
||||||
|
if (extendedLen > BigInt(MAX_FRAME_PAYLOAD_BYTES)) {
|
||||||
|
throw new Error('WebSocket frame payload exceeds maximum allowed size');
|
||||||
|
}
|
||||||
|
payloadLen = Number(extendedLen);
|
||||||
|
offset = 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (payloadLen > MAX_FRAME_PAYLOAD_BYTES) {
|
||||||
|
throw new Error('WebSocket frame payload exceeds maximum allowed size');
|
||||||
|
}
|
||||||
|
|
||||||
|
const maskOffset = offset;
|
||||||
|
const dataOffset = offset + 4;
|
||||||
|
const totalLen = dataOffset + payloadLen;
|
||||||
|
if (buffer.length < totalLen) return null;
|
||||||
|
|
||||||
|
const mask = buffer.slice(maskOffset, dataOffset);
|
||||||
|
const data = Buffer.alloc(payloadLen);
|
||||||
|
for (let i = 0; i < payloadLen; i++) {
|
||||||
|
data[i] = buffer[dataOffset + i] ^ mask[i % 4];
|
||||||
|
}
|
||||||
|
|
||||||
|
return { opcode, payload: data, bytesConsumed: totalLen };
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Configuration ==========
|
||||||
|
|
||||||
|
const PORT_FILE = process.env.BRAINSTORM_PORT_FILE || null;
|
||||||
|
const randomPort = () => 49152 + Math.floor(Math.random() * 16383);
|
||||||
|
// Prefer an explicit port, else the port this session last bound (so a restart
|
||||||
|
// reuses it and an already-open browser tab reconnects), else a random high port.
|
||||||
|
function preferredPort() {
|
||||||
|
if (process.env.BRAINSTORM_PORT) return Number(process.env.BRAINSTORM_PORT);
|
||||||
|
if (PORT_FILE) {
|
||||||
|
try {
|
||||||
|
const p = Number(fs.readFileSync(PORT_FILE, 'utf-8').trim());
|
||||||
|
if (Number.isInteger(p) && p > 1023 && p < 65536) return p;
|
||||||
|
} catch (e) { /* no prior port recorded */ }
|
||||||
|
}
|
||||||
|
return randomPort();
|
||||||
|
}
|
||||||
|
let PORT = preferredPort();
|
||||||
|
const HOST = process.env.BRAINSTORM_HOST || '127.0.0.1';
|
||||||
|
const URL_HOST = process.env.BRAINSTORM_URL_HOST || (HOST === '127.0.0.1' ? 'localhost' : HOST);
|
||||||
|
const SESSION_DIR = process.env.BRAINSTORM_DIR || '/tmp/brainstorm';
|
||||||
|
const CONTENT_DIR = path.join(SESSION_DIR, 'content');
|
||||||
|
const STATE_DIR = path.join(SESSION_DIR, 'state');
|
||||||
|
const SUPERPOWERS_VERSION = readSuperpowersVersion();
|
||||||
|
const SUPERPOWERS_BRAND_IMAGE_URL = 'https://primeradiant.com/brand/superpowers-visual-brainstorming-logo.png';
|
||||||
|
const TELEMETRY_DISABLE_ENV_VARS = [
|
||||||
|
'SUPERPOWERS_DISABLE_TELEMETRY',
|
||||||
|
'DISABLE_TELEMETRY',
|
||||||
|
'CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC'
|
||||||
|
];
|
||||||
|
const SUPERPOWERS_TELEMETRY_DISABLED = TELEMETRY_DISABLE_ENV_VARS.some(name => isTruthyEnv(process.env[name]));
|
||||||
|
let ownerPid = process.env.BRAINSTORM_OWNER_PID ? Number(process.env.BRAINSTORM_OWNER_PID) : null;
|
||||||
|
|
||||||
|
// Per-session secret key. The companion is reachable by any local browser tab
|
||||||
|
// and, when bound to a non-loopback host, by any host that can route to it.
|
||||||
|
// The key authenticates the real client uniformly across loopback, tunnel, and
|
||||||
|
// remote binds — and defeats DNS rebinding — where a Host/Origin allowlist
|
||||||
|
// cannot. It rides the served URL as ?key= and is mirrored into a cookie on
|
||||||
|
// first load so same-origin subresources and the WebSocket carry it for free.
|
||||||
|
// Persisted alongside the port (BRAINSTORM_TOKEN_FILE) so a restart keeps the
|
||||||
|
// same key and an already-open tab's cookie still validates.
|
||||||
|
const TOKEN_FILE = process.env.BRAINSTORM_TOKEN_FILE || null;
|
||||||
|
function generateToken() {
|
||||||
|
return crypto.randomBytes(32).toString('hex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function chmodOwnerOnly(file) {
|
||||||
|
try { fs.chmodSync(file, 0o600); } catch (e) { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function initialToken() {
|
||||||
|
if (process.env.BRAINSTORM_TOKEN) {
|
||||||
|
return { value: process.env.BRAINSTORM_TOKEN, source: 'env' };
|
||||||
|
}
|
||||||
|
if (TOKEN_FILE) {
|
||||||
|
try {
|
||||||
|
const t = fs.readFileSync(TOKEN_FILE, 'utf-8').trim();
|
||||||
|
if (/^[0-9a-f]{32,}$/i.test(t)) {
|
||||||
|
chmodOwnerOnly(TOKEN_FILE);
|
||||||
|
return { value: t, source: 'file' };
|
||||||
|
}
|
||||||
|
} catch (e) { /* no prior token recorded */ }
|
||||||
|
}
|
||||||
|
return { value: generateToken(), source: 'generated' };
|
||||||
|
}
|
||||||
|
|
||||||
|
const tokenInfo = initialToken();
|
||||||
|
let TOKEN = tokenInfo.value;
|
||||||
|
let tokenSource = tokenInfo.source;
|
||||||
|
let COOKIE_NAME = 'brainstorm-key-' + PORT; // refined to the actual bound port in onListen
|
||||||
|
|
||||||
|
const MIME_TYPES = {
|
||||||
|
'.html': 'text/html', '.css': 'text/css', '.js': 'application/javascript',
|
||||||
|
'.json': 'application/json', '.png': 'image/png', '.jpg': 'image/jpeg',
|
||||||
|
'.jpeg': 'image/jpeg', '.gif': 'image/gif', '.svg': 'image/svg+xml'
|
||||||
|
};
|
||||||
|
|
||||||
|
// ========== Templates and Constants ==========
|
||||||
|
|
||||||
|
function waitingPage() {
|
||||||
|
return renderBranding(`<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><title>Brainstorm Companion</title>
|
||||||
|
<style>
|
||||||
|
body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
||||||
|
h1 { color: #333; } p { color: #666; }
|
||||||
|
.brand { display: flex; align-items: center; min-width: 0; overflow: hidden; margin-bottom: 1.5rem; color: #666; font-size: 0.9rem; line-height: 1; }
|
||||||
|
.brand a { color: inherit; text-decoration: none; display: flex; align-items: center; gap: 0.5rem; min-width: 0; max-width: 100%; line-height: 1; }
|
||||||
|
.brand-copy { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; line-height: 1; transform: translateY(-1px); }
|
||||||
|
.brand-logo { display: block; height: 1em; width: auto; max-width: 180px; filter: invert(1); }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body><!-- BRANDING --><h1>Brainstorm Companion</h1>
|
||||||
|
<p>Waiting for the agent to push a screen...</p></body></html>`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const FORBIDDEN_PAGE = `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><title>Session key required</title>
|
||||||
|
<style>body { font-family: system-ui, sans-serif; padding: 2rem; max-width: 800px; margin: 0 auto; }
|
||||||
|
h1 { color: #333; } p { color: #666; } code { background: #f0f0f0; padding: 0.1em 0.3em; border-radius: 4px; }</style>
|
||||||
|
</head>
|
||||||
|
<body><h1>Session key required</h1>
|
||||||
|
<p>This page needs the full URL your coding agent gave you, including the
|
||||||
|
<code>?key=…</code> part. Copy the complete URL and open it again.</p></body></html>`;
|
||||||
|
|
||||||
|
function bootstrapPage(key) {
|
||||||
|
const jsonKey = JSON.stringify(String(key));
|
||||||
|
return `<!DOCTYPE html>
|
||||||
|
<html>
|
||||||
|
<head><meta charset="utf-8"><title>Opening Brainstorm Companion</title></head>
|
||||||
|
<body>
|
||||||
|
<script>
|
||||||
|
try { sessionStorage.setItem('brainstorm-session-key', ${jsonKey}); } catch (e) {}
|
||||||
|
location.replace('/');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const frameTemplate = fs.readFileSync(path.join(__dirname, 'frame-template.html'), 'utf-8');
|
||||||
|
const helperScript = fs.readFileSync(path.join(__dirname, 'helper.js'), 'utf-8');
|
||||||
|
const helperInjection = '<script>\n' + helperScript + '\n</script>';
|
||||||
|
|
||||||
|
// ========== Helper Functions ==========
|
||||||
|
|
||||||
|
function readSuperpowersVersion() {
|
||||||
|
const root = path.join(__dirname, '../../..');
|
||||||
|
const manifests = [
|
||||||
|
path.join(root, 'package.json'),
|
||||||
|
path.join(root, '.codex-plugin/plugin.json')
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const manifest of manifests) {
|
||||||
|
try {
|
||||||
|
const data = JSON.parse(fs.readFileSync(manifest, 'utf-8'));
|
||||||
|
if (data.version) return String(data.version);
|
||||||
|
} catch (e) {
|
||||||
|
// Packaged Codex plugins omit package.json; try the next manifest.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return 'unknown';
|
||||||
|
}
|
||||||
|
|
||||||
|
function isTruthyEnv(value) {
|
||||||
|
if (!value) return false;
|
||||||
|
const normalized = String(value).trim().toLowerCase();
|
||||||
|
if (!normalized) return false;
|
||||||
|
return !['0', 'false', 'no', 'off'].includes(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlText(value) {
|
||||||
|
return String(value)
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"');
|
||||||
|
}
|
||||||
|
|
||||||
|
function brandMarkup() {
|
||||||
|
const version = escapeHtmlText(SUPERPOWERS_VERSION);
|
||||||
|
const text = SUPERPOWERS_TELEMETRY_DISABLED
|
||||||
|
? 'Prime Radiant Superpowers v' + version
|
||||||
|
: 'Superpowers v' + version;
|
||||||
|
const logo = SUPERPOWERS_TELEMETRY_DISABLED
|
||||||
|
? ''
|
||||||
|
: '<img class="brand-logo" src="' + SUPERPOWERS_BRAND_IMAGE_URL + '?v=' + encodeURIComponent(SUPERPOWERS_VERSION) + '" alt="Prime Radiant" referrerpolicy="no-referrer" decoding="async">';
|
||||||
|
|
||||||
|
return '<div class="brand"><a href="https://github.com/obra/superpowers">' + logo + '<span class="brand-copy">' + text + '</span></a></div>';
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderBranding(html) {
|
||||||
|
return html.split('<!-- BRANDING -->').join(brandMarkup());
|
||||||
|
}
|
||||||
|
|
||||||
|
function isFullDocument(html) {
|
||||||
|
const trimmed = html.trimStart().toLowerCase();
|
||||||
|
return trimmed.startsWith('<!doctype') || trimmed.startsWith('<html');
|
||||||
|
}
|
||||||
|
|
||||||
|
function wrapInFrame(content) {
|
||||||
|
return renderBranding(frameTemplate).replace('<!-- CONTENT -->', content);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getNewestScreen() {
|
||||||
|
const files = fs.readdirSync(CONTENT_DIR)
|
||||||
|
.filter(f => !f.startsWith('.') && f.endsWith('.html'))
|
||||||
|
.map(f => {
|
||||||
|
const fp = path.join(CONTENT_DIR, f);
|
||||||
|
if (!isRegularFileInsideContentDir(fp)) return null;
|
||||||
|
return { path: fp, mtime: fs.statSync(fp).mtime.getTime() };
|
||||||
|
})
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => b.mtime - a.mtime);
|
||||||
|
return files.length > 0 ? files[0].path : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function urlHostForHttp(host) {
|
||||||
|
const h = String(host);
|
||||||
|
if (h.startsWith('[') && h.endsWith(']')) return h;
|
||||||
|
return h.includes(':') ? '[' + h + ']' : h;
|
||||||
|
}
|
||||||
|
|
||||||
|
function companionUrl() {
|
||||||
|
return 'http://' + urlHostForHttp(URL_HOST) + ':' + PORT + '/?key=' + TOKEN;
|
||||||
|
}
|
||||||
|
|
||||||
|
function browserLauncherForPlatform(url, {
|
||||||
|
platform = process.platform,
|
||||||
|
osRelease = require('os').release(),
|
||||||
|
env = process.env
|
||||||
|
} = {}) {
|
||||||
|
const isWSL = platform === 'linux' && /microsoft/i.test(osRelease);
|
||||||
|
if (platform === 'darwin') return { bin: 'open', args: [url] };
|
||||||
|
if (platform === 'win32' || isWSL) {
|
||||||
|
return { bin: 'rundll32.exe', args: ['url.dll,FileProtocolHandler', url] };
|
||||||
|
}
|
||||||
|
if (env.DISPLAY || env.WAYLAND_DISPLAY) return { bin: 'xdg-open', args: [url] };
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isRegularFileInsideContentDir(filePath) {
|
||||||
|
let stat, realContentDir, realFilePath;
|
||||||
|
try {
|
||||||
|
stat = fs.lstatSync(filePath);
|
||||||
|
if (stat.isSymbolicLink()) return false;
|
||||||
|
if (!stat.isFile()) return false;
|
||||||
|
if (stat.nlink !== 1) return false;
|
||||||
|
realContentDir = fs.realpathSync(CONTENT_DIR);
|
||||||
|
realFilePath = fs.realpathSync(filePath);
|
||||||
|
} catch (e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return realFilePath.startsWith(realContentDir + path.sep);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Authentication ==========
|
||||||
|
|
||||||
|
function timingSafeEqualStr(a, b) {
|
||||||
|
const ab = Buffer.from(String(a));
|
||||||
|
const bb = Buffer.from(String(b));
|
||||||
|
if (ab.length !== bb.length) return false;
|
||||||
|
return crypto.timingSafeEqual(ab, bb);
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseCookies(header) {
|
||||||
|
const out = {};
|
||||||
|
if (!header) return out;
|
||||||
|
for (const part of header.split(';')) {
|
||||||
|
const eq = part.indexOf('=');
|
||||||
|
if (eq < 0) continue;
|
||||||
|
out[part.slice(0, eq).trim()] = part.slice(eq + 1).trim();
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// A request is authorized if it carries the session key as ?key= or as the
|
||||||
|
// session cookie. Both are compared in constant time.
|
||||||
|
function isAuthorized(req) {
|
||||||
|
const q = req.url.indexOf('?');
|
||||||
|
if (q >= 0) {
|
||||||
|
const params = new URLSearchParams(req.url.slice(q + 1));
|
||||||
|
if (params.has('key')) {
|
||||||
|
const key = params.get('key');
|
||||||
|
return Boolean(key && timingSafeEqualStr(key, TOKEN));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const cookie = parseCookies(req.headers['cookie'])[COOKIE_NAME];
|
||||||
|
if (cookie && timingSafeEqualStr(cookie, TOKEN)) return true;
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathnameOf(url) {
|
||||||
|
const q = url.indexOf('?');
|
||||||
|
return q >= 0 ? url.slice(0, q) : url;
|
||||||
|
}
|
||||||
|
|
||||||
|
function queryKey(url) {
|
||||||
|
const q = url.indexOf('?');
|
||||||
|
if (q < 0) return null;
|
||||||
|
return new URLSearchParams(url.slice(q + 1)).get('key');
|
||||||
|
}
|
||||||
|
|
||||||
|
function securityHeaders(headers = {}) {
|
||||||
|
return {
|
||||||
|
'Referrer-Policy': 'no-referrer',
|
||||||
|
'Cache-Control': 'no-store',
|
||||||
|
'X-Frame-Options': 'DENY',
|
||||||
|
'Content-Security-Policy': "frame-ancestors 'none'",
|
||||||
|
'Cross-Origin-Resource-Policy': 'same-origin',
|
||||||
|
...headers
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAllowedWebSocketOrigin(req) {
|
||||||
|
const origin = req.headers.origin;
|
||||||
|
if (!origin) return true;
|
||||||
|
const host = req.headers.host;
|
||||||
|
if (!host) return false;
|
||||||
|
return origin === 'http://' + host;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== HTTP Request Handler ==========
|
||||||
|
|
||||||
|
function handleRequest(req, res) {
|
||||||
|
if (!isAuthorized(req)) {
|
||||||
|
res.writeHead(403, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
|
||||||
|
res.end(FORBIDDEN_PAGE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
touchActivity(); // only authorized requests count as activity
|
||||||
|
|
||||||
|
// Mirror the key into a cookie so same-origin subresources (/files/*) can
|
||||||
|
// authenticate after bootstrap. HttpOnly keeps it away from page scripts; the
|
||||||
|
// WebSocket Origin check below is what blocks cross-origin localhost injection.
|
||||||
|
res.setHeader('Set-Cookie',
|
||||||
|
COOKIE_NAME + '=' + TOKEN + '; HttpOnly; SameSite=Strict; Path=/');
|
||||||
|
|
||||||
|
const pathname = pathnameOf(req.url);
|
||||||
|
const keyFromQuery = queryKey(req.url);
|
||||||
|
if (req.method === 'GET' && pathname === '/' && keyFromQuery && timingSafeEqualStr(keyFromQuery, TOKEN)) {
|
||||||
|
res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
|
||||||
|
res.end(bootstrapPage(keyFromQuery));
|
||||||
|
} else if (req.method === 'GET' && pathname === '/') {
|
||||||
|
const screenFile = getNewestScreen();
|
||||||
|
let html = screenFile
|
||||||
|
? (raw => isFullDocument(raw) ? raw : wrapInFrame(raw))(fs.readFileSync(screenFile, 'utf-8'))
|
||||||
|
: waitingPage();
|
||||||
|
|
||||||
|
if (html.includes('</body>')) {
|
||||||
|
html = html.replace('</body>', helperInjection + '\n</body>');
|
||||||
|
} else {
|
||||||
|
html += helperInjection;
|
||||||
|
}
|
||||||
|
|
||||||
|
res.writeHead(200, securityHeaders({ 'Content-Type': 'text/html; charset=utf-8' }));
|
||||||
|
res.end(html);
|
||||||
|
} else if (req.method === 'GET' && pathname.startsWith('/files/')) {
|
||||||
|
const fileName = path.basename(pathname.slice(7));
|
||||||
|
const filePath = path.join(CONTENT_DIR, fileName);
|
||||||
|
// Reject empty/dotfile names and anything that isn't a regular file —
|
||||||
|
// `/files/` would otherwise resolve to CONTENT_DIR and crash readFileSync (EISDIR).
|
||||||
|
if (!fileName || fileName.startsWith('.') || !isRegularFileInsideContentDir(filePath)) {
|
||||||
|
res.writeHead(404, securityHeaders());
|
||||||
|
res.end('Not found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const ext = path.extname(filePath).toLowerCase();
|
||||||
|
const contentType = MIME_TYPES[ext] || 'application/octet-stream';
|
||||||
|
res.writeHead(200, securityHeaders({ 'Content-Type': contentType }));
|
||||||
|
res.end(fs.readFileSync(filePath));
|
||||||
|
} else {
|
||||||
|
res.writeHead(404, securityHeaders());
|
||||||
|
res.end('Not found');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== WebSocket Connection Handling ==========
|
||||||
|
|
||||||
|
const clients = new Set();
|
||||||
|
|
||||||
|
function handleUpgrade(req, socket) {
|
||||||
|
if (!isAuthorized(req) || !isAllowedWebSocketOrigin(req)) { socket.destroy(); return; }
|
||||||
|
|
||||||
|
const key = req.headers['sec-websocket-key'];
|
||||||
|
if (!key) { socket.destroy(); return; }
|
||||||
|
|
||||||
|
const accept = computeAcceptKey(key);
|
||||||
|
socket.write(
|
||||||
|
'HTTP/1.1 101 Switching Protocols\r\n' +
|
||||||
|
'Upgrade: websocket\r\n' +
|
||||||
|
'Connection: Upgrade\r\n' +
|
||||||
|
'Sec-WebSocket-Accept: ' + accept + '\r\n\r\n'
|
||||||
|
);
|
||||||
|
|
||||||
|
let buffer = Buffer.alloc(0);
|
||||||
|
clients.add(socket);
|
||||||
|
|
||||||
|
socket.on('data', (chunk) => {
|
||||||
|
buffer = Buffer.concat([buffer, chunk]);
|
||||||
|
while (buffer.length > 0) {
|
||||||
|
let result;
|
||||||
|
try {
|
||||||
|
result = decodeFrame(buffer);
|
||||||
|
} catch (e) {
|
||||||
|
socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
|
||||||
|
clients.delete(socket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!result) break;
|
||||||
|
buffer = buffer.slice(result.bytesConsumed);
|
||||||
|
|
||||||
|
switch (result.opcode) {
|
||||||
|
case OPCODES.TEXT:
|
||||||
|
handleMessage(result.payload.toString());
|
||||||
|
break;
|
||||||
|
case OPCODES.CLOSE:
|
||||||
|
socket.end(encodeFrame(OPCODES.CLOSE, Buffer.alloc(0)));
|
||||||
|
clients.delete(socket);
|
||||||
|
return;
|
||||||
|
case OPCODES.PING:
|
||||||
|
socket.write(encodeFrame(OPCODES.PONG, result.payload));
|
||||||
|
break;
|
||||||
|
case OPCODES.PONG:
|
||||||
|
break;
|
||||||
|
default: {
|
||||||
|
const closeBuf = Buffer.alloc(2);
|
||||||
|
closeBuf.writeUInt16BE(1003);
|
||||||
|
socket.end(encodeFrame(OPCODES.CLOSE, closeBuf));
|
||||||
|
clients.delete(socket);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('close', () => clients.delete(socket));
|
||||||
|
socket.on('error', () => clients.delete(socket));
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleMessage(text) {
|
||||||
|
let event;
|
||||||
|
try {
|
||||||
|
event = JSON.parse(text);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to parse WebSocket message:', e.message);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
touchActivity();
|
||||||
|
console.log(JSON.stringify({ source: 'user-event', ...event }));
|
||||||
|
if (event && event.choice) {
|
||||||
|
const eventsFile = path.join(STATE_DIR, 'events');
|
||||||
|
fs.appendFileSync(eventsFile, JSON.stringify(event) + '\n');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(msg) {
|
||||||
|
const frame = encodeFrame(OPCODES.TEXT, Buffer.from(JSON.stringify(msg)));
|
||||||
|
for (const socket of clients) {
|
||||||
|
try { socket.write(frame); } catch (e) { clients.delete(socket); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Best-effort: open the user's browser the first time a screen is actually ready
|
||||||
|
// to show. Skips when disabled, on a non-loopback (remote) bind, or when a
|
||||||
|
// browser is already connected. Override the launcher with BRAINSTORM_OPEN_CMD.
|
||||||
|
let browserOpened = false;
|
||||||
|
function maybeOpenBrowser() {
|
||||||
|
if (browserOpened) return;
|
||||||
|
browserOpened = true;
|
||||||
|
if (!process.env.BRAINSTORM_OPEN) return; // opt-in: only after the user approves the companion
|
||||||
|
if (HOST !== '127.0.0.1' && HOST !== 'localhost') return;
|
||||||
|
if (clients.size > 0) return; // the user already opened it
|
||||||
|
const url = companionUrl(); // must carry the key or the gate 403s it
|
||||||
|
const cp = require('child_process');
|
||||||
|
// Operator-provided launcher: run as given (this env var is trusted operator input).
|
||||||
|
if (process.env.BRAINSTORM_OPEN_CMD) {
|
||||||
|
try { cp.exec(process.env.BRAINSTORM_OPEN_CMD + ' ' + JSON.stringify(url), () => {}); } catch (e) { /* best effort */ }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Platform launchers: pass the URL as an argv element via execFile (no shell),
|
||||||
|
// so a url-host containing shell metacharacters can't inject a command.
|
||||||
|
const launcher = browserLauncherForPlatform(url);
|
||||||
|
if (!launcher) return; // headless: nothing to open
|
||||||
|
try { cp.execFile(launcher.bin, launcher.args, () => {}); } catch (e) { /* best effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Activity Tracking ==========
|
||||||
|
|
||||||
|
// Idle timeout: shut down after this long with no activity. Default 4 hours;
|
||||||
|
// override with BRAINSTORM_IDLE_TIMEOUT_MS (start-server.sh: --idle-timeout-minutes).
|
||||||
|
const IDLE_TIMEOUT_MS = (() => {
|
||||||
|
const ms = Number(process.env.BRAINSTORM_IDLE_TIMEOUT_MS);
|
||||||
|
return Number.isFinite(ms) && ms > 0 ? ms : 4 * 60 * 60 * 1000;
|
||||||
|
})();
|
||||||
|
// How often the watchdog checks for owner-death / idleness. Configurable mainly
|
||||||
|
// so tests can run fast; production default is 60s.
|
||||||
|
const LIFECYCLE_CHECK_MS = (() => {
|
||||||
|
const ms = Number(process.env.BRAINSTORM_LIFECYCLE_CHECK_MS);
|
||||||
|
return Number.isFinite(ms) && ms > 0 ? ms : 60 * 1000;
|
||||||
|
})();
|
||||||
|
let lastActivity = Date.now();
|
||||||
|
|
||||||
|
function touchActivity() {
|
||||||
|
lastActivity = Date.now();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== File Watching ==========
|
||||||
|
|
||||||
|
const debounceTimers = new Map();
|
||||||
|
|
||||||
|
// ========== Server Startup ==========
|
||||||
|
|
||||||
|
function startServer() {
|
||||||
|
if (!fs.existsSync(CONTENT_DIR)) fs.mkdirSync(CONTENT_DIR, { recursive: true });
|
||||||
|
if (!fs.existsSync(STATE_DIR)) fs.mkdirSync(STATE_DIR, { recursive: true });
|
||||||
|
|
||||||
|
// Track known files to distinguish new screens from updates.
|
||||||
|
// macOS fs.watch reports 'rename' for both new files and overwrites,
|
||||||
|
// so we can't rely on eventType alone.
|
||||||
|
const knownFiles = new Set(
|
||||||
|
fs.readdirSync(CONTENT_DIR).filter(f => !f.startsWith('.') && f.endsWith('.html'))
|
||||||
|
);
|
||||||
|
|
||||||
|
const server = http.createServer(handleRequest);
|
||||||
|
server.on('upgrade', handleUpgrade);
|
||||||
|
|
||||||
|
const watcher = fs.watch(CONTENT_DIR, (eventType, filename) => {
|
||||||
|
if (!filename || filename.startsWith('.') || !filename.endsWith('.html')) return;
|
||||||
|
|
||||||
|
if (debounceTimers.has(filename)) clearTimeout(debounceTimers.get(filename));
|
||||||
|
debounceTimers.set(filename, setTimeout(() => {
|
||||||
|
debounceTimers.delete(filename);
|
||||||
|
const filePath = path.join(CONTENT_DIR, filename);
|
||||||
|
|
||||||
|
if (!fs.existsSync(filePath)) return; // file was deleted
|
||||||
|
touchActivity();
|
||||||
|
|
||||||
|
if (!knownFiles.has(filename)) {
|
||||||
|
knownFiles.add(filename);
|
||||||
|
const eventsFile = path.join(STATE_DIR, 'events');
|
||||||
|
if (fs.existsSync(eventsFile)) fs.unlinkSync(eventsFile);
|
||||||
|
console.log(JSON.stringify({ type: 'screen-added', file: filePath }));
|
||||||
|
maybeOpenBrowser();
|
||||||
|
} else {
|
||||||
|
console.log(JSON.stringify({ type: 'screen-updated', file: filePath }));
|
||||||
|
}
|
||||||
|
|
||||||
|
broadcast({ type: 'reload' });
|
||||||
|
}, 100));
|
||||||
|
});
|
||||||
|
watcher.on('error', (err) => console.error('fs.watch error:', err.message));
|
||||||
|
|
||||||
|
function shutdown(reason) {
|
||||||
|
console.log(JSON.stringify({ type: 'server-stopped', reason }));
|
||||||
|
const infoFile = path.join(STATE_DIR, 'server-info');
|
||||||
|
if (fs.existsSync(infoFile)) fs.unlinkSync(infoFile);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(STATE_DIR, 'server-stopped'),
|
||||||
|
JSON.stringify({ reason, timestamp: Date.now() }) + '\n'
|
||||||
|
);
|
||||||
|
watcher.close();
|
||||||
|
clearInterval(lifecycleCheck);
|
||||||
|
// Close any upgraded WebSocket sockets so server.close() can complete and
|
||||||
|
// the process actually exits instead of lingering on an open connection.
|
||||||
|
for (const socket of clients) {
|
||||||
|
try { socket.destroy(); } catch (e) { /* already gone */ }
|
||||||
|
}
|
||||||
|
server.close(() => process.exit(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
function ownerAlive() {
|
||||||
|
if (!ownerPid) return true;
|
||||||
|
try { process.kill(ownerPid, 0); return true; } catch (e) { return e.code === 'EPERM'; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Periodically exit if the owner process died or we've been idle too long.
|
||||||
|
const lifecycleCheck = setInterval(() => {
|
||||||
|
if (!ownerAlive()) shutdown('owner process exited');
|
||||||
|
else if (Date.now() - lastActivity > IDLE_TIMEOUT_MS) shutdown('idle timeout');
|
||||||
|
}, LIFECYCLE_CHECK_MS);
|
||||||
|
lifecycleCheck.unref();
|
||||||
|
|
||||||
|
// Validate owner PID at startup. If it's already dead, the PID resolution
|
||||||
|
// was wrong (common on WSL, Tailscale SSH, and cross-user scenarios).
|
||||||
|
// Disable monitoring and rely on the idle timeout instead.
|
||||||
|
if (ownerPid) {
|
||||||
|
try { process.kill(ownerPid, 0); }
|
||||||
|
catch (e) {
|
||||||
|
if (e.code !== 'EPERM') {
|
||||||
|
console.log(JSON.stringify({ type: 'owner-pid-invalid', pid: ownerPid, reason: 'dead at startup' }));
|
||||||
|
ownerPid = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the preferred port is already taken (e.g. a previous server is still
|
||||||
|
// alive), fall back to a random port once instead of failing.
|
||||||
|
let triedFallback = false;
|
||||||
|
|
||||||
|
function onListen() {
|
||||||
|
// Cookie name keys on the ACTUAL bound port (may differ from the preferred
|
||||||
|
// one after an EADDRINUSE fallback) so it can't collide with another server's
|
||||||
|
// cookie in the shared localhost jar.
|
||||||
|
COOKIE_NAME = 'brainstorm-key-' + PORT;
|
||||||
|
// Record the bound port AND token so the next restart of this session reuses
|
||||||
|
// them — but ONLY when we got our preferred port. On a fallback we bound a
|
||||||
|
// *different* port because someone else holds the preferred one; persisting
|
||||||
|
// would overwrite the shared files and strand that other session's open tab.
|
||||||
|
if (PORT_FILE && !triedFallback) {
|
||||||
|
try { fs.writeFileSync(PORT_FILE, String(PORT)); } catch (e) { /* best effort */ }
|
||||||
|
if (TOKEN_FILE) {
|
||||||
|
try {
|
||||||
|
fs.writeFileSync(TOKEN_FILE, TOKEN, { mode: 0o600 });
|
||||||
|
chmodOwnerOnly(TOKEN_FILE);
|
||||||
|
} catch (e) { /* best effort */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const info = JSON.stringify({
|
||||||
|
type: 'server-started', port: Number(PORT), host: HOST,
|
||||||
|
url_host: URL_HOST, url: companionUrl(),
|
||||||
|
screen_dir: CONTENT_DIR, state_dir: STATE_DIR, idle_timeout_ms: IDLE_TIMEOUT_MS
|
||||||
|
});
|
||||||
|
console.log(info);
|
||||||
|
// server-info embeds the key — keep it owner-only.
|
||||||
|
fs.writeFileSync(path.join(STATE_DIR, 'server-info'), info + '\n', { mode: 0o600 });
|
||||||
|
}
|
||||||
|
|
||||||
|
server.on('error', (err) => {
|
||||||
|
if (err.code === 'EADDRINUSE' && !triedFallback) {
|
||||||
|
if (tokenSource === 'env') {
|
||||||
|
console.error('Server failed to bind: preferred port is in use and BRAINSTORM_TOKEN is set; refusing fallback with explicit token');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
triedFallback = true;
|
||||||
|
PORT = randomPort();
|
||||||
|
if (tokenSource === 'file') {
|
||||||
|
TOKEN = generateToken();
|
||||||
|
tokenSource = 'generated-fallback';
|
||||||
|
}
|
||||||
|
server.listen(PORT, HOST, onListen);
|
||||||
|
} else {
|
||||||
|
console.error('Server failed to bind:', err.message);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
server.listen(PORT, HOST, onListen);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (require.main === module) {
|
||||||
|
startServer();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
computeAcceptKey,
|
||||||
|
encodeFrame,
|
||||||
|
decodeFrame,
|
||||||
|
browserLauncherForPlatform,
|
||||||
|
OPCODES,
|
||||||
|
MAX_FRAME_PAYLOAD_BYTES
|
||||||
|
};
|
||||||
209
.codebuddy/skills/brainstorming/scripts/start-server.sh
Normal file
209
.codebuddy/skills/brainstorming/scripts/start-server.sh
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Start the brainstorm server and output connection info
|
||||||
|
# Usage: start-server.sh [--project-dir <path>] [--host <bind-host>] [--url-host <display-host>] [--foreground] [--background]
|
||||||
|
#
|
||||||
|
# Starts server on a random high port, outputs JSON with URL.
|
||||||
|
# Each session gets its own directory to avoid conflicts.
|
||||||
|
#
|
||||||
|
# Options:
|
||||||
|
# --project-dir <path> Store session files under <path>/.superpowers/brainstorm/
|
||||||
|
# instead of /tmp. Files persist after server stops.
|
||||||
|
# --host <bind-host> Host/interface to bind (default: 127.0.0.1).
|
||||||
|
# Use 0.0.0.0 in remote/containerized environments.
|
||||||
|
# --url-host <host> Hostname shown in returned URL JSON.
|
||||||
|
# --idle-timeout-minutes <n> Shut down after n minutes idle (default 240 = 4h).
|
||||||
|
# --open Auto-open the browser on the first screen (use only
|
||||||
|
# after the user approves the visual companion).
|
||||||
|
# --foreground Run server in the current terminal (no backgrounding).
|
||||||
|
# --background Force background mode (overrides Codex auto-foreground).
|
||||||
|
|
||||||
|
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
|
||||||
|
|
||||||
|
# Parse arguments
|
||||||
|
PROJECT_DIR=""
|
||||||
|
FOREGROUND="false"
|
||||||
|
FORCE_BACKGROUND="false"
|
||||||
|
BIND_HOST="127.0.0.1"
|
||||||
|
URL_HOST=""
|
||||||
|
IDLE_TIMEOUT_MINUTES=""
|
||||||
|
while [[ $# -gt 0 ]]; do
|
||||||
|
case "$1" in
|
||||||
|
--project-dir)
|
||||||
|
PROJECT_DIR="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--host)
|
||||||
|
BIND_HOST="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--url-host)
|
||||||
|
URL_HOST="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--idle-timeout-minutes)
|
||||||
|
IDLE_TIMEOUT_MINUTES="$2"
|
||||||
|
shift 2
|
||||||
|
;;
|
||||||
|
--open)
|
||||||
|
export BRAINSTORM_OPEN=1
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--foreground|--no-daemon)
|
||||||
|
FOREGROUND="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
--background|--daemon)
|
||||||
|
FORCE_BACKGROUND="true"
|
||||||
|
shift
|
||||||
|
;;
|
||||||
|
*)
|
||||||
|
echo "{\"error\": \"Unknown argument: $1\"}"
|
||||||
|
exit 1
|
||||||
|
;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
if [[ -z "$URL_HOST" ]]; then
|
||||||
|
if [[ "$BIND_HOST" == "127.0.0.1" || "$BIND_HOST" == "localhost" ]]; then
|
||||||
|
URL_HOST="localhost"
|
||||||
|
else
|
||||||
|
URL_HOST="$BIND_HOST"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [[ -n "$IDLE_TIMEOUT_MINUTES" ]]; then
|
||||||
|
if ! [[ "$IDLE_TIMEOUT_MINUTES" =~ ^[0-9]+$ ]] || [[ "$IDLE_TIMEOUT_MINUTES" -lt 1 ]]; then
|
||||||
|
echo "{\"error\": \"--idle-timeout-minutes must be a positive integer\"}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
export BRAINSTORM_IDLE_TIMEOUT_MS=$(( IDLE_TIMEOUT_MINUTES * 60 * 1000 ))
|
||||||
|
fi
|
||||||
|
|
||||||
|
is_windows_like_shell() {
|
||||||
|
case "${OSTYPE:-}" in
|
||||||
|
msys*|cygwin*|mingw*) return 0 ;;
|
||||||
|
esac
|
||||||
|
if [[ -n "${MSYSTEM:-}" ]]; then
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
local uname_s
|
||||||
|
uname_s="$(uname -s 2>/dev/null || true)"
|
||||||
|
case "$uname_s" in
|
||||||
|
MSYS*|MINGW*|CYGWIN*) return 0 ;;
|
||||||
|
esac
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
|
||||||
|
# Some environments reap detached/background processes. Auto-foreground when detected.
|
||||||
|
if [[ -n "${CODEX_CI:-}" && "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then
|
||||||
|
FOREGROUND="true"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Windows/Git Bash reaps nohup background processes. Auto-foreground when detected.
|
||||||
|
if [[ "$FOREGROUND" != "true" && "$FORCE_BACKGROUND" != "true" ]]; then
|
||||||
|
if is_windows_like_shell; then
|
||||||
|
FOREGROUND="true"
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Session files (server.log, server-info, .last-token) embed the session key —
|
||||||
|
# keep everything this script and the server create owner-only.
|
||||||
|
umask 077
|
||||||
|
|
||||||
|
# Generate unique session directory
|
||||||
|
SESSION_ID="$$-$(date +%s)"
|
||||||
|
|
||||||
|
if [[ -n "$PROJECT_DIR" ]]; then
|
||||||
|
SESSION_DIR="${PROJECT_DIR}/.superpowers/brainstorm/${SESSION_ID}"
|
||||||
|
# Persist the bound port and key per project so a restart reuses them and an
|
||||||
|
# already-open browser tab reconnects to the same URL with a valid cookie.
|
||||||
|
export BRAINSTORM_PORT_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-port"
|
||||||
|
export BRAINSTORM_TOKEN_FILE="${PROJECT_DIR}/.superpowers/brainstorm/.last-token"
|
||||||
|
else
|
||||||
|
SESSION_DIR="/tmp/brainstorm-${SESSION_ID}"
|
||||||
|
fi
|
||||||
|
|
||||||
|
STATE_DIR="${SESSION_DIR}/state"
|
||||||
|
PID_FILE="${STATE_DIR}/server.pid"
|
||||||
|
LOG_FILE="${STATE_DIR}/server.log"
|
||||||
|
SERVER_ID_FILE="${STATE_DIR}/server-instance-id"
|
||||||
|
|
||||||
|
# Create fresh session directory with content and state peers
|
||||||
|
mkdir -p "${SESSION_DIR}/content" "$STATE_DIR"
|
||||||
|
|
||||||
|
SERVER_ID=""
|
||||||
|
if [[ -r /dev/urandom ]]; then
|
||||||
|
SERVER_ID="$(od -An -N24 -tx1 /dev/urandom 2>/dev/null | tr -d ' \n' || true)"
|
||||||
|
fi
|
||||||
|
if ! [[ "$SERVER_ID" =~ ^[A-Za-z0-9_-]{32,64}$ ]]; then
|
||||||
|
SERVER_ID="$(printf '%08x%08x%08x%08x' "$$" "$(date +%s)" "${RANDOM:-0}" "${RANDOM:-0}")"
|
||||||
|
fi
|
||||||
|
printf '%s\n' "$SERVER_ID" > "$SERVER_ID_FILE"
|
||||||
|
chmod 600 "$SERVER_ID_FILE" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Kill any existing server
|
||||||
|
if [[ -f "$PID_FILE" ]]; then
|
||||||
|
old_pid=$(cat "$PID_FILE")
|
||||||
|
kill "$old_pid" 2>/dev/null
|
||||||
|
rm -f "$PID_FILE"
|
||||||
|
fi
|
||||||
|
|
||||||
|
cd "$SCRIPT_DIR" || exit 1
|
||||||
|
|
||||||
|
# Resolve the harness PID (grandparent of this script).
|
||||||
|
# $PPID is the ephemeral shell the harness spawned to run us — it dies
|
||||||
|
# when this script exits. The harness itself is $PPID's parent.
|
||||||
|
OWNER_PID="$(ps -o ppid= -p "$PPID" 2>/dev/null | tr -d ' ')"
|
||||||
|
if [[ -z "$OWNER_PID" || "$OWNER_PID" == "1" ]]; then
|
||||||
|
OWNER_PID="$PPID"
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Windows/MSYS2: Node.js cannot see POSIX PIDs from the MSYS2 namespace.
|
||||||
|
# Passing a PID node cannot verify causes server to log owner-pid-invalid
|
||||||
|
# and self-terminate at the 60-second lifecycle check. Clear it so the
|
||||||
|
# watchdog is disabled and the idle timeout becomes the only shutdown trigger.
|
||||||
|
if is_windows_like_shell; then
|
||||||
|
OWNER_PID=""
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Foreground mode for environments that reap detached/background processes.
|
||||||
|
if [[ "$FOREGROUND" == "true" ]]; then
|
||||||
|
env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" &
|
||||||
|
SERVER_PID=$!
|
||||||
|
echo "$SERVER_PID" > "$PID_FILE"
|
||||||
|
wait "$SERVER_PID"
|
||||||
|
exit $?
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Start server, capturing output to log file
|
||||||
|
# Use nohup to survive shell exit; disown to remove from job table
|
||||||
|
nohup env BRAINSTORM_DIR="$SESSION_DIR" BRAINSTORM_HOST="$BIND_HOST" BRAINSTORM_URL_HOST="$URL_HOST" BRAINSTORM_OWNER_PID="$OWNER_PID" node server.cjs "--brainstorm-server-id=$SERVER_ID" > "$LOG_FILE" 2>&1 &
|
||||||
|
SERVER_PID=$!
|
||||||
|
disown "$SERVER_PID" 2>/dev/null
|
||||||
|
echo "$SERVER_PID" > "$PID_FILE"
|
||||||
|
|
||||||
|
# Wait for server-started message (check log file)
|
||||||
|
for _ in {1..50}; do
|
||||||
|
if grep -q "server-started" "$LOG_FILE" 2>/dev/null; then
|
||||||
|
# Verify server is still alive after a short window (catches process reapers)
|
||||||
|
alive="true"
|
||||||
|
for _ in {1..20}; do
|
||||||
|
if ! kill -0 "$SERVER_PID" 2>/dev/null; then
|
||||||
|
alive="false"
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
if [[ "$alive" != "true" ]]; then
|
||||||
|
echo "{\"error\": \"Server started but was killed. Retry in a persistent terminal with: $SCRIPT_DIR/start-server.sh${PROJECT_DIR:+ --project-dir $PROJECT_DIR} --host $BIND_HOST --url-host $URL_HOST --foreground\"}"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
grep "server-started" "$LOG_FILE" | head -1
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
# Timeout - server didn't start
|
||||||
|
echo '{"error": "Server failed to start within 5 seconds"}'
|
||||||
|
exit 1
|
||||||
120
.codebuddy/skills/brainstorming/scripts/stop-server.sh
Normal file
120
.codebuddy/skills/brainstorming/scripts/stop-server.sh
Normal file
@ -0,0 +1,120 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Stop the brainstorm server and clean up
|
||||||
|
# Usage: stop-server.sh <session_dir>
|
||||||
|
#
|
||||||
|
# Kills the server process. Only deletes session directory if it's
|
||||||
|
# under /tmp (ephemeral). Persistent directories (.superpowers/) are
|
||||||
|
# kept so mockups can be reviewed later.
|
||||||
|
|
||||||
|
SESSION_DIR="$1"
|
||||||
|
|
||||||
|
if [[ -z "$SESSION_DIR" ]]; then
|
||||||
|
echo '{"error": "Usage: stop-server.sh <session_dir>"}'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
STATE_DIR="${SESSION_DIR}/state"
|
||||||
|
PID_FILE="${STATE_DIR}/server.pid"
|
||||||
|
SERVER_ID_FILE="${STATE_DIR}/server-instance-id"
|
||||||
|
|
||||||
|
mark_stopped() {
|
||||||
|
local reason="$1"
|
||||||
|
rm -f "${STATE_DIR}/server-info"
|
||||||
|
printf '{"reason":"%s","timestamp":%s}\n' "$reason" "$(date +%s)" > "${STATE_DIR}/server-stopped"
|
||||||
|
}
|
||||||
|
|
||||||
|
read_expected_server_id() {
|
||||||
|
[[ -f "$SERVER_ID_FILE" ]] || return 1
|
||||||
|
local id
|
||||||
|
id="$(tr -d '\r\n' < "$SERVER_ID_FILE" 2>/dev/null || true)"
|
||||||
|
[[ "$id" =~ ^[A-Za-z0-9_-]{32,64}$ ]] || return 1
|
||||||
|
printf '%s\n' "$id"
|
||||||
|
}
|
||||||
|
|
||||||
|
command_line_for_pid() {
|
||||||
|
local pid="$1"
|
||||||
|
if [[ -r "/proc/$pid/cmdline" ]]; then
|
||||||
|
tr '\0' '\n' < "/proc/$pid/cmdline" 2>/dev/null || true
|
||||||
|
return 0
|
||||||
|
fi
|
||||||
|
ps -ww -p "$pid" -o command= 2>/dev/null || ps -f -p "$pid" 2>/dev/null | sed '1d' || true
|
||||||
|
}
|
||||||
|
|
||||||
|
command_has_server_id() {
|
||||||
|
local pid="$1"
|
||||||
|
local expected="$2"
|
||||||
|
local expected_arg="--brainstorm-server-id=$expected"
|
||||||
|
if [[ -r "/proc/$pid/cmdline" ]]; then
|
||||||
|
local arg
|
||||||
|
while IFS= read -r -d '' arg || [[ -n "$arg" ]]; do
|
||||||
|
[[ "$arg" == "$expected_arg" ]] && return 0
|
||||||
|
done < "/proc/$pid/cmdline"
|
||||||
|
return 1
|
||||||
|
fi
|
||||||
|
local command_line
|
||||||
|
command_line="$(command_line_for_pid "$pid")"
|
||||||
|
[[ -n "$command_line" ]] || return 1
|
||||||
|
case " $command_line " in
|
||||||
|
*" $expected_arg "*) return 0 ;;
|
||||||
|
*) return 1 ;;
|
||||||
|
esac
|
||||||
|
}
|
||||||
|
|
||||||
|
# Confirm a PID has this session's per-start instance id, not just a familiar
|
||||||
|
# process name. Ambiguous or legacy metadata fails closed as stale_pid.
|
||||||
|
is_brainstorm_server() {
|
||||||
|
kill -0 "$1" 2>/dev/null || return 1
|
||||||
|
local expected_id
|
||||||
|
expected_id="$(read_expected_server_id)" || return 1
|
||||||
|
command_has_server_id "$1" "$expected_id" || return 1
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
if [[ -f "$PID_FILE" ]]; then
|
||||||
|
pid=$(cat "$PID_FILE")
|
||||||
|
|
||||||
|
# Refuse to signal a PID we can't prove is our server. A stale pid file may
|
||||||
|
# point at an unrelated process after a reboot/PID wraparound.
|
||||||
|
if ! is_brainstorm_server "$pid"; then
|
||||||
|
rm -f "$PID_FILE" "$SERVER_ID_FILE"
|
||||||
|
mark_stopped "stale_pid"
|
||||||
|
echo '{"status": "stale_pid"}'
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
# Try to stop gracefully, fallback to force if still alive
|
||||||
|
kill "$pid" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Wait for graceful shutdown (up to ~2s)
|
||||||
|
for _ in {1..20}; do
|
||||||
|
if ! kill -0 "$pid" 2>/dev/null; then
|
||||||
|
break
|
||||||
|
fi
|
||||||
|
sleep 0.1
|
||||||
|
done
|
||||||
|
|
||||||
|
# If still running, escalate to SIGKILL
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
kill -9 "$pid" 2>/dev/null || true
|
||||||
|
|
||||||
|
# Give SIGKILL a moment to take effect
|
||||||
|
sleep 0.1
|
||||||
|
fi
|
||||||
|
|
||||||
|
if kill -0 "$pid" 2>/dev/null; then
|
||||||
|
echo '{"status": "failed", "error": "process still running"}'
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
rm -f "$PID_FILE" "$SERVER_ID_FILE" "${STATE_DIR}/server.log"
|
||||||
|
mark_stopped "stop-server.sh"
|
||||||
|
|
||||||
|
# Only delete ephemeral /tmp directories
|
||||||
|
if [[ "$SESSION_DIR" == /tmp/* ]]; then
|
||||||
|
rm -rf "$SESSION_DIR"
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo '{"status": "stopped"}'
|
||||||
|
else
|
||||||
|
echo '{"status": "not_running"}'
|
||||||
|
fi
|
||||||
@ -0,0 +1,48 @@
|
|||||||
|
# 规格文档审查员提示模板
|
||||||
|
|
||||||
|
调度规格文档审查员子代理时使用此模板。
|
||||||
|
|
||||||
|
**用途:** 验证规格是否完整、一致,并为实现计划做好准备。
|
||||||
|
|
||||||
|
**调度时机:** 规格文档写入 docs/superpowers/specs/ 之后
|
||||||
|
|
||||||
|
```
|
||||||
|
Task tool(通用):
|
||||||
|
description: "审查规格文档"
|
||||||
|
prompt: |
|
||||||
|
你是一名规格文档审查员。验证此规格是否完整并准备好进行计划编写。
|
||||||
|
|
||||||
|
**待审查规格:** [SPEC_FILE_PATH]
|
||||||
|
|
||||||
|
## 检查内容
|
||||||
|
|
||||||
|
| 类别 | 检查要点 |
|
||||||
|
|------|----------|
|
||||||
|
| 完整性 | TODO、占位符、"TBD"、不完整的章节 |
|
||||||
|
| 一致性 | 内部矛盾、相互冲突的需求 |
|
||||||
|
| 清晰度 | 需求模糊到可能导致构建出错误的东西 |
|
||||||
|
| 范围 | 是否足够聚焦以用于单个计划——而非涵盖多个独立子系统 |
|
||||||
|
| YAGNI | 未请求的功能、过度设计 |
|
||||||
|
|
||||||
|
## 校准标准
|
||||||
|
|
||||||
|
**只标记会在实现计划阶段造成实际问题的事项。**
|
||||||
|
缺失的章节、矛盾之处、或者模糊到可能被两种不同方式理解的需求——
|
||||||
|
这些才是问题。措辞上的小改进、风格偏好、以及"某些章节不如其他章节详细"则不是。
|
||||||
|
|
||||||
|
除非存在会导致计划出错的严重缺陷,否则应予以通过。
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
## 规格审查
|
||||||
|
|
||||||
|
**状态:** 通过 | 发现问题
|
||||||
|
|
||||||
|
**问题(如有):**
|
||||||
|
- [章节 X]:[具体问题] - [为什么这对计划编写很重要]
|
||||||
|
|
||||||
|
**建议(仅供参考,不阻止通过):**
|
||||||
|
- [改进建议]
|
||||||
|
```
|
||||||
|
|
||||||
|
**审查员返回:** 状态、问题(如有)、建议
|
||||||
295
.codebuddy/skills/brainstorming/visual-companion.md
Normal file
295
.codebuddy/skills/brainstorming/visual-companion.md
Normal file
@ -0,0 +1,295 @@
|
|||||||
|
# 视觉伴侣指南
|
||||||
|
|
||||||
|
基于浏览器的视觉头脑风暴伴侣,用于展示原型、图表和选项。
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
逐问题决定,而非按会话决定。判断标准:**用户看到它是否比读到它更容易理解?**
|
||||||
|
|
||||||
|
**使用浏览器** 当内容本身是视觉的:
|
||||||
|
|
||||||
|
- **UI 原型** — 线框图、布局、导航结构、组件设计
|
||||||
|
- **架构图** — 系统组件、数据流、关系图
|
||||||
|
- **并排视觉对比** — 对比两种布局、两种配色方案、两种设计方向
|
||||||
|
- **设计细节打磨** — 当问题涉及外观感受、间距、视觉层次
|
||||||
|
- **空间关系** — 状态机、流程图、实体关系图
|
||||||
|
|
||||||
|
**使用终端** 当内容是文字或表格的:
|
||||||
|
|
||||||
|
- **需求和范围问题** — "X 是什么意思?"、"哪些功能在范围内?"
|
||||||
|
- **概念性 A/B/C 选择** — 在用文字描述的方案之间做选择
|
||||||
|
- **权衡列表** — 优缺点、对比表
|
||||||
|
- **技术决策** — API 设计、数据建模、架构方案选择
|
||||||
|
- **澄清问题** — 任何回答是文字而非视觉偏好的问题
|
||||||
|
|
||||||
|
关于 UI 主题的问题不一定是视觉问题。"你想要什么样的向导?"是概念性的——使用终端。"这些向导布局中哪个感觉对?"是视觉性的——使用浏览器。
|
||||||
|
|
||||||
|
## 工作原理
|
||||||
|
|
||||||
|
服务器监视一个目录中的 HTML 文件,将最新的文件提供给浏览器。你写入 HTML 内容,用户在浏览器中看到它,并可以点击选择选项。选择结果被记录到一个 `.events` 文件中,你在下一轮会话中读取它。
|
||||||
|
|
||||||
|
**内容片段 vs 完整文档:** 如果你的 HTML 文件以 `<!DOCTYPE` 或 `<html` 开头,服务器会原样提供(仅注入辅助脚本)。否则,服务器会自动将你的内容包裹在框架模板中——添加头部、CSS 主题、选择指示器和所有交互基础设施。**默认写内容片段即可。** 只有当你需要完全控制页面时才写完整文档。
|
||||||
|
|
||||||
|
## 启动会话
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动服务器并持久化(原型保存到项目中)
|
||||||
|
scripts/start-server.sh --project-dir /path/to/project
|
||||||
|
|
||||||
|
# 返回:{"type":"server-started","port":52341,"url":"http://localhost:52341",
|
||||||
|
# "screen_dir":"/path/to/project/.superpowers/brainstorm/12345-1706000000"}
|
||||||
|
```
|
||||||
|
|
||||||
|
保存响应中的 `screen_dir`。告诉用户打开该 URL。
|
||||||
|
|
||||||
|
**查找连接信息:** 服务器将其启动 JSON 写入 `$SCREEN_DIR/.server-info`。如果你在后台启动了服务器且没有捕获 stdout,读取该文件以获取 URL 和端口。使用 `--project-dir` 时,检查 `<project>/.superpowers/brainstorm/` 获取会话目录。
|
||||||
|
|
||||||
|
**注意:** 传入项目根目录作为 `--project-dir`,这样原型会持久化在 `.superpowers/brainstorm/` 中,不会因服务器重启而丢失。不传的话,文件会保存到 `/tmp` 并在清理时被删除。提醒用户将 `.superpowers/` 添加到 `.gitignore`(如果尚未添加)。
|
||||||
|
|
||||||
|
**按平台启动服务器:**
|
||||||
|
|
||||||
|
**Claude Code (macOS / Linux):**
|
||||||
|
```bash
|
||||||
|
# 默认模式即可——脚本会自动将服务器放到后台
|
||||||
|
scripts/start-server.sh --project-dir /path/to/project
|
||||||
|
```
|
||||||
|
|
||||||
|
**Claude Code (Windows):**
|
||||||
|
```bash
|
||||||
|
# Windows 会自动检测并使用前台模式,这会阻塞工具调用。
|
||||||
|
# 在 Bash 工具调用上设置 run_in_background: true,
|
||||||
|
# 让服务器在会话轮次之间存活。
|
||||||
|
scripts/start-server.sh --project-dir /path/to/project
|
||||||
|
```
|
||||||
|
通过 Bash 工具调用时,设置 `run_in_background: true`。然后在下一轮读取 `$SCREEN_DIR/.server-info` 获取 URL 和端口。
|
||||||
|
|
||||||
|
**Codex:**
|
||||||
|
```bash
|
||||||
|
# Codex 会回收后台进程。脚本会自动检测 CODEX_CI 并
|
||||||
|
# 切换到前台模式。正常运行即可——不需要额外标志。
|
||||||
|
scripts/start-server.sh --project-dir /path/to/project
|
||||||
|
```
|
||||||
|
|
||||||
|
**Gemini CLI:**
|
||||||
|
```bash
|
||||||
|
# 使用 --foreground 并在 shell 工具调用上设置 is_background: true,
|
||||||
|
# 让进程在轮次之间存活
|
||||||
|
scripts/start-server.sh --project-dir /path/to/project --foreground
|
||||||
|
```
|
||||||
|
|
||||||
|
**Copilot CLI:**
|
||||||
|
```bash
|
||||||
|
# 用 Copilot CLI 的非阻塞 / 后台 shell 机制启动,让服务器能跨会话轮次存活。
|
||||||
|
# 保留 --foreground —— 由 harness 而不是脚本来负责放到后台。
|
||||||
|
# 启动器是 .sh,所以要通过 bash 调用(Windows 上用 Git Bash 的 bash.exe,
|
||||||
|
# 从 PowerShell 工具里调)。
|
||||||
|
bash scripts/start-server.sh --project-dir /path/to/project --open --foreground
|
||||||
|
```
|
||||||
|
|
||||||
|
**其他环境:** 服务器必须在会话轮次之间持续在后台运行。如果你的环境会回收分离的进程,使用 `--foreground` 并通过平台的后台执行机制启动命令。
|
||||||
|
|
||||||
|
如果浏览器无法访问该 URL(在远程/容器化环境中常见),绑定一个非回环主机:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/start-server.sh \
|
||||||
|
--project-dir /path/to/project \
|
||||||
|
--host 0.0.0.0 \
|
||||||
|
--url-host localhost
|
||||||
|
```
|
||||||
|
|
||||||
|
使用 `--url-host` 控制返回的 URL JSON 中显示的主机名。
|
||||||
|
|
||||||
|
## 工作循环
|
||||||
|
|
||||||
|
1. **检查服务器存活**,然后**将 HTML 写入** `screen_dir` 中的新文件:
|
||||||
|
- 每次写入前,检查 `$SCREEN_DIR/.server-info` 是否存在。如果不存在(或 `.server-stopped` 存在),服务器已关闭——在继续之前用 `start-server.sh` 重启。服务器在 30 分钟无活动后会自动退出。
|
||||||
|
- 使用语义化文件名:`platform.html`、`visual-style.html`、`layout.html`
|
||||||
|
- **绝不复用文件名** — 每个屏幕用一个新文件
|
||||||
|
- 使用 Write 工具 — **绝不使用 cat/heredoc**(会在终端产生噪音)
|
||||||
|
- 服务器自动提供最新的文件
|
||||||
|
|
||||||
|
2. **告诉用户预期内容并结束你的回合:**
|
||||||
|
- 每一步都提醒他们 URL(不仅仅是第一次)
|
||||||
|
- 简要文字说明屏幕上的内容(例如"展示了 3 个首页布局选项")
|
||||||
|
- 请他们在终端中回复:"看一下,告诉我你的想法。如果你愿意,可以点击选择一个选项。"
|
||||||
|
|
||||||
|
3. **在你的下一轮** — 用户在终端回复后:
|
||||||
|
- 如果存在 `$SCREEN_DIR/.events`,读取它——其中包含用户的浏览器交互(点击、选择),格式为 JSON 行
|
||||||
|
- 将终端文字和事件合并以获得完整信息
|
||||||
|
- 终端消息是主要反馈;`.events` 提供结构化的交互数据
|
||||||
|
|
||||||
|
4. **迭代或推进** — 如果反馈要求修改当前屏幕,写入新文件(例如 `layout-v2.html`)。只有当前步骤验证通过后才进入下一个问题。
|
||||||
|
|
||||||
|
5. **回到终端时卸载** — 当下一步不需要浏览器时(例如澄清问题、权衡讨论),推送一个等待屏幕以清除过时内容:
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 文件名:waiting.html(或 waiting-2.html 等)-->
|
||||||
|
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||||
|
<p class="subtitle">在终端中继续...</p>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
这样可以防止用户盯着一个已经解决的选择,而对话已经继续了。当下一个视觉问题出现时,照常推送新的内容文件。
|
||||||
|
|
||||||
|
6. 重复直到完成。
|
||||||
|
|
||||||
|
## 编写内容片段
|
||||||
|
|
||||||
|
只写放在页面内部的内容。服务器会自动用框架模板包裹它(头部、主题 CSS、选择指示器和所有交互基础设施)。
|
||||||
|
|
||||||
|
**最简示例:**
|
||||||
|
|
||||||
|
```html
|
||||||
|
<h2>哪种布局更好?</h2>
|
||||||
|
<p class="subtitle">考虑可读性和视觉层次</p>
|
||||||
|
|
||||||
|
<div class="options">
|
||||||
|
<div class="option" data-choice="a" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">A</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>单栏</h3>
|
||||||
|
<p>简洁、专注的阅读体验</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="option" data-choice="b" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">B</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>双栏</h3>
|
||||||
|
<p>侧边栏导航加主内容区</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
就这些。不需要 `<html>`,不需要 CSS,不需要 `<script>` 标签。服务器会提供这一切。
|
||||||
|
|
||||||
|
## 可用的 CSS 类
|
||||||
|
|
||||||
|
框架模板为你的内容提供以下 CSS 类:
|
||||||
|
|
||||||
|
### 选项(A/B/C 选择)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="options">
|
||||||
|
<div class="option" data-choice="a" onclick="toggleSelect(this)">
|
||||||
|
<div class="letter">A</div>
|
||||||
|
<div class="content">
|
||||||
|
<h3>标题</h3>
|
||||||
|
<p>描述</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
**多选:** 在容器上添加 `data-multiselect` 让用户选择多个选项。每次点击切换选中状态。指示栏显示数量。
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="options" data-multiselect>
|
||||||
|
<!-- 相同的选项标记——用户可以选择/取消选择多个 -->
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 卡片(视觉设计)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="cards">
|
||||||
|
<div class="card" data-choice="design1" onclick="toggleSelect(this)">
|
||||||
|
<div class="card-image"><!-- 原型内容 --></div>
|
||||||
|
<div class="card-body">
|
||||||
|
<h3>名称</h3>
|
||||||
|
<p>描述</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 原型容器
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="mockup">
|
||||||
|
<div class="mockup-header">预览:仪表盘布局</div>
|
||||||
|
<div class="mockup-body"><!-- 你的原型 HTML --></div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 分屏视图(并排)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="split">
|
||||||
|
<div class="mockup"><!-- 左侧 --></div>
|
||||||
|
<div class="mockup"><!-- 右侧 --></div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 优缺点
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="pros-cons">
|
||||||
|
<div class="pros"><h4>优点</h4><ul><li>好处</li></ul></div>
|
||||||
|
<div class="cons"><h4>缺点</h4><ul><li>不足</li></ul></div>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模拟元素(线框图构建块)
|
||||||
|
|
||||||
|
```html
|
||||||
|
<div class="mock-nav">Logo | 首页 | 关于 | 联系我们</div>
|
||||||
|
<div style="display: flex;">
|
||||||
|
<div class="mock-sidebar">导航</div>
|
||||||
|
<div class="mock-content">主内容区域</div>
|
||||||
|
</div>
|
||||||
|
<button class="mock-button">操作按钮</button>
|
||||||
|
<input class="mock-input" placeholder="输入框">
|
||||||
|
<div class="placeholder">占位区域</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 排版和区块
|
||||||
|
|
||||||
|
- `h2` — 页面标题
|
||||||
|
- `h3` — 章节标题
|
||||||
|
- `.subtitle` — 标题下方的辅助文字
|
||||||
|
- `.section` — 带底部边距的内容块
|
||||||
|
- `.label` — 小号大写标签文字
|
||||||
|
|
||||||
|
## 浏览器事件格式
|
||||||
|
|
||||||
|
当用户在浏览器中点击选项时,交互记录会保存到 `$SCREEN_DIR/.events`(每行一个 JSON 对象)。推送新屏幕时文件会自动清空。
|
||||||
|
|
||||||
|
```jsonl
|
||||||
|
{"type":"click","choice":"a","text":"选项 A - 简单布局","timestamp":1706000101}
|
||||||
|
{"type":"click","choice":"c","text":"选项 C - 复杂网格","timestamp":1706000108}
|
||||||
|
{"type":"click","choice":"b","text":"选项 B - 混合方案","timestamp":1706000115}
|
||||||
|
```
|
||||||
|
|
||||||
|
完整的事件流展示了用户的探索路径——他们可能在确定之前点击了多个选项。最后一个 `choice` 事件通常是最终选择,但点击模式可以揭示犹豫或值得询问的偏好。
|
||||||
|
|
||||||
|
如果 `.events` 不存在,说明用户没有与浏览器交互——仅使用他们的终端文字。
|
||||||
|
|
||||||
|
## 设计技巧
|
||||||
|
|
||||||
|
- **保真度匹配问题** — 布局问题用线框图,细节打磨问题用精细设计
|
||||||
|
- **在每个页面上解释问题** — "哪种布局看起来更专业?"而不仅仅是"选一个"
|
||||||
|
- **推进前先迭代** — 如果反馈修改了当前屏幕,写入新版本
|
||||||
|
- 每个屏幕最多 **2-4 个选项**
|
||||||
|
- **必要时使用真实内容** — 对于摄影作品集,使用实际图片(Unsplash)。占位内容会掩盖设计问题。
|
||||||
|
- **保持原型简洁** — 专注于布局和结构,而非像素级精确的设计
|
||||||
|
|
||||||
|
## 文件命名
|
||||||
|
|
||||||
|
- 使用语义化名称:`platform.html`、`visual-style.html`、`layout.html`
|
||||||
|
- 绝不复用文件名——每个屏幕必须是新文件
|
||||||
|
- 迭代版本:添加版本后缀如 `layout-v2.html`、`layout-v3.html`
|
||||||
|
- 服务器按修改时间提供最新文件
|
||||||
|
|
||||||
|
## 清理
|
||||||
|
|
||||||
|
```bash
|
||||||
|
scripts/stop-server.sh $SCREEN_DIR
|
||||||
|
```
|
||||||
|
|
||||||
|
如果会话使用了 `--project-dir`,原型文件会持久化在 `.superpowers/brainstorm/` 中以供日后参考。只有 `/tmp` 会话会在停止时被删除。
|
||||||
|
|
||||||
|
## 参考
|
||||||
|
|
||||||
|
- 框架模板(CSS 参考):`scripts/frame-template.html`
|
||||||
|
- 辅助脚本(客户端):`scripts/helper.js`
|
||||||
282
.codebuddy/skills/chinese-code-review/SKILL.md
Normal file
282
.codebuddy/skills/chinese-code-review/SKILL.md
Normal file
@ -0,0 +1,282 @@
|
|||||||
|
---
|
||||||
|
name: chinese-code-review
|
||||||
|
description: 中文 review 沟通参考——话术模板、分级标注(必须修复/建议修改/仅供参考)、国内团队常见反模式应对。仅在用户显式 /chinese-code-review 时调用,不要根据上下文自动触发。
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [code-review, chinese]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 中文代码审查规范
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
国内团队做 Code Review 常遇到两个极端:要么过度客气导致关键问题被放过,要么照搬西方直白风格让同事下不来台。本技能帮你找到平衡点——**既不回避问题,又让人愿意接受反馈**。
|
||||||
|
|
||||||
|
**核心原则:** 用"建议"代替"命令",用"提问"代替"否定",但绝不因为面子而放过 bug。
|
||||||
|
|
||||||
|
## 审查反馈的表达方式
|
||||||
|
|
||||||
|
### 用建议代替命令
|
||||||
|
|
||||||
|
| 避免(命令式) | 推荐(建议式) |
|
||||||
|
|---------------|---------------|
|
||||||
|
| 你必须改成 X | 建议考虑用 X,因为 Y |
|
||||||
|
| 这里写错了 | 这里可能存在一个问题,是否考虑过 Z 的情况? |
|
||||||
|
| 不要用这个方法 | 这个方法在 A 场景下可能有性能问题,可以看看 B 方案 |
|
||||||
|
| 这段代码不行 | 这段逻辑我理解得对吗?如果输入为空的话会怎样? |
|
||||||
|
|
||||||
|
### 用提问代替否定
|
||||||
|
|
||||||
|
当你不确定对方意图时,先问再评:
|
||||||
|
|
||||||
|
```
|
||||||
|
# 好的方式
|
||||||
|
这里用 sync 方式读文件是出于什么考虑?如果并发量上来,可能会阻塞事件循环。
|
||||||
|
|
||||||
|
# 不好的方式
|
||||||
|
这里不应该用 sync 方式读文件。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 分级标注
|
||||||
|
|
||||||
|
统一使用优先级标记,让作者快速判断轻重缓急:
|
||||||
|
|
||||||
|
- **[必须修复]** — 安全漏洞、数据丢失风险、逻辑错误(不修不能合)
|
||||||
|
- **[建议修改]** — 性能问题、可维护性、缺少校验(本次或下次迭代修复)
|
||||||
|
- **[仅供参考]** — 命名优化、风格建议、替代方案(不改也行)
|
||||||
|
- **[问题]** — 不确定的地方,需要作者解释意图
|
||||||
|
|
||||||
|
### 审查评论模板
|
||||||
|
|
||||||
|
```
|
||||||
|
[必须修复] SQL 注入风险
|
||||||
|
|
||||||
|
第 42 行:用户输入直接拼接到 SQL 语句中。
|
||||||
|
|
||||||
|
原因:攻击者可以通过 name 参数注入 `'; DROP TABLE users; --`。
|
||||||
|
|
||||||
|
建议:使用参数化查询:
|
||||||
|
db.query('SELECT * FROM users WHERE name = $1', [name])
|
||||||
|
|
||||||
|
参考:https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html
|
||||||
|
```
|
||||||
|
|
||||||
|
## 中英混排代码注释规范
|
||||||
|
|
||||||
|
### 何时用中文
|
||||||
|
|
||||||
|
- **业务逻辑说明** — 用中文解释业务背景和需求来源
|
||||||
|
- **复杂算法注释** — 用中文写思路,确保团队成员都能理解
|
||||||
|
- **TODO / FIXME** — 用中文描述待办事项,方便搜索和追踪
|
||||||
|
- **文档注释(内部项目)** — JSDoc / Javadoc 中的描述文字用中文
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
/**
|
||||||
|
* 计算用户的会员等级折扣
|
||||||
|
*
|
||||||
|
* 业务规则:
|
||||||
|
* - 普通会员 9.5 折
|
||||||
|
* - 银卡会员 9 折
|
||||||
|
* - 金卡会员 8.5 折
|
||||||
|
* - 钻石会员 8 折
|
||||||
|
*
|
||||||
|
* @param level - 会员等级(MemberLevel enum)
|
||||||
|
* @param amount - 原始金额(单位:分)
|
||||||
|
* @returns 折后金额(单位:分)
|
||||||
|
*/
|
||||||
|
function calculateDiscount(level: MemberLevel, amount: number): number {
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 何时用英文
|
||||||
|
|
||||||
|
- **变量名、函数名、类名** — 始终用英文命名,遵循团队命名规范
|
||||||
|
- **Git commit message** — 参考下方 commit 规范
|
||||||
|
- **开源项目注释** — 面向国际社区的项目,注释统一用英文
|
||||||
|
- **错误信息和日志** — 生产环境的 error message 用英文(避免编码问题)
|
||||||
|
- **API 接口文档** — 对外暴露的 API 用英文
|
||||||
|
|
||||||
|
### 混排格式要求
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 好:中英文之间加空格
|
||||||
|
// 使用 Redis 缓存来减少 MySQL 的查询压力
|
||||||
|
|
||||||
|
// 坏:中英文之间没有空格
|
||||||
|
// 使用Redis缓存来减少MySQL的查询压力
|
||||||
|
|
||||||
|
// 好:技术术语保留英文
|
||||||
|
// 这里用 debounce 防抖处理,避免频繁触发 API 请求
|
||||||
|
|
||||||
|
// 坏:强行翻译技术术语
|
||||||
|
// 这里用防抖动处理,避免频繁触发应用程序接口请求
|
||||||
|
```
|
||||||
|
|
||||||
|
## Commit Message 中英双语格式
|
||||||
|
|
||||||
|
### 推荐格式
|
||||||
|
|
||||||
|
团队内部项目使用中文 commit message,采用约定式提交(Conventional Commits)的中文版:
|
||||||
|
|
||||||
|
```
|
||||||
|
<类型>(<范围>): <简要描述>
|
||||||
|
|
||||||
|
<详细说明(可选)>
|
||||||
|
|
||||||
|
<关联信息(可选)>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 类型对照表
|
||||||
|
|
||||||
|
| 类型 | 含义 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| feat | 新功能 | feat(用户): 新增手机号登录功能 |
|
||||||
|
| fix | 修复 Bug | fix(支付): 修复微信支付回调重复处理的问题 |
|
||||||
|
| docs | 文档变更 | docs: 更新 API 接口文档 |
|
||||||
|
| style | 代码格式 | style: 统一缩进为 2 个空格 |
|
||||||
|
| refactor | 重构 | refactor(订单): 拆分订单服务,提取公共逻辑 |
|
||||||
|
| perf | 性能优化 | perf(列表): 虚拟滚动优化长列表渲染性能 |
|
||||||
|
| test | 测试 | test(auth): 补充登录模块单元测试 |
|
||||||
|
| chore | 构建/工具 | chore: 升级 Node.js 至 v20 |
|
||||||
|
|
||||||
|
### 示例
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(支付): 修复支付宝异步回调签名校验失败的问题
|
||||||
|
|
||||||
|
原因:升级 SDK 后签名算法从 RSA 变为 RSA2,但回调校验仍使用旧算法。
|
||||||
|
方案:回调处理中同时兼容 RSA 和 RSA2 签名校验。
|
||||||
|
|
||||||
|
Closes #1234
|
||||||
|
```
|
||||||
|
|
||||||
|
### 面向国际社区的项目
|
||||||
|
|
||||||
|
如果项目面向国际社区或有外籍成员,commit message 用英文,PR 描述中可附加中文说明:
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(payment): fix Alipay async callback signature verification failure
|
||||||
|
|
||||||
|
The SDK upgrade changed the signature algorithm from RSA to RSA2,
|
||||||
|
but the callback handler still used the old algorithm.
|
||||||
|
|
||||||
|
Closes #1234
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见反模式与对策
|
||||||
|
|
||||||
|
### 反模式一:过度客气
|
||||||
|
|
||||||
|
**表现:** 所有评论都是"我觉得可能也许大概好像这里有个小问题"。
|
||||||
|
|
||||||
|
**后果:** 关键 bug 被隐藏在一堆委婉语里,作者根本不知道哪些必须改。
|
||||||
|
|
||||||
|
**对策:** 使用分级标注。[必须修复] 就是必须修复,语气可以温和,但级别必须准确。
|
||||||
|
|
||||||
|
```
|
||||||
|
# 坏:过度客气
|
||||||
|
不知道我理解得对不对,这里好像可能有一点点并发问题,不过也许我看错了...
|
||||||
|
|
||||||
|
# 好:温和但清晰
|
||||||
|
[必须修复] 并发安全问题
|
||||||
|
|
||||||
|
这里的 map 在多个 goroutine 中同时读写,会触发 panic。
|
||||||
|
建议加 sync.RWMutex,或者换成 sync.Map。
|
||||||
|
|
||||||
|
复现方式:加 -race flag 跑测试就能看到。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 反模式二:不敢给高级开发者提意见
|
||||||
|
|
||||||
|
**表现:** 高级开发者或 Leader 的代码直接 Approve,不仔细看。
|
||||||
|
|
||||||
|
**后果:** 代码质量双标,团队对 Code Review 失去信任。
|
||||||
|
|
||||||
|
**对策:** Code Review 对事不对人。可以换个表达方式:
|
||||||
|
|
||||||
|
```
|
||||||
|
# 提问式(适合给资深同事的反馈)
|
||||||
|
想请教一下,这里选择用递归而不是迭代,是出于什么考虑?
|
||||||
|
我在想如果递归深度超过 1000 层会不会有栈溢出的风险?
|
||||||
|
|
||||||
|
# 学习式
|
||||||
|
学到了一个新写法!不过有个小疑问——这里的类型断言在运行时不会做检查,
|
||||||
|
如果上游数据结构变了,这里会静默通过。是否考虑加个 runtime validation?
|
||||||
|
```
|
||||||
|
|
||||||
|
### 反模式三:审查变成风格之争
|
||||||
|
|
||||||
|
**表现:** 大量评论纠结于缩进、空格、花括号位置。
|
||||||
|
|
||||||
|
**后果:** 浪费时间,忽略真正的问题。
|
||||||
|
|
||||||
|
**对策:** 风格问题交给 ESLint / Prettier / gofmt 等工具自动处理。Code Review 聚焦逻辑、安全、性能。
|
||||||
|
|
||||||
|
### 反模式四:只写"LGTM"
|
||||||
|
|
||||||
|
**表现:** 随手一个 LGTM 就 Approve,没有实质性审查。
|
||||||
|
|
||||||
|
**后果:** Code Review 形同虚设,出了问题没人兜底。
|
||||||
|
|
||||||
|
**对策:** 即使代码质量很好,也要写出你关注了哪些方面:
|
||||||
|
|
||||||
|
```
|
||||||
|
LGTM
|
||||||
|
|
||||||
|
审查了以下方面:
|
||||||
|
- 并发安全:锁的粒度合理
|
||||||
|
- 错误处理:所有外部调用都有 error handling
|
||||||
|
- 向下兼容:新增字段都有默认值,不影响老版本
|
||||||
|
|
||||||
|
一个小建议 [仅供参考]:第 78 行的变量名 `d` 可以改成 `duration`,更易读。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 审查流程建议
|
||||||
|
|
||||||
|
### 开始审查前
|
||||||
|
|
||||||
|
1. **先看 PR 描述**,理解改动的背景和目的
|
||||||
|
2. **看关联的 Issue 或需求文档**
|
||||||
|
3. **先整体浏览**,再逐文件细看
|
||||||
|
|
||||||
|
### 审查顺序
|
||||||
|
|
||||||
|
1. **架构层面** — 方案是否合理?有没有更好的方式?
|
||||||
|
2. **正确性** — 逻辑对不对?边界条件处理了吗?
|
||||||
|
3. **安全性** — 有没有注入、越权、信息泄露?
|
||||||
|
4. **性能** — 有没有 N+1 查询、内存泄漏、不必要的循环?
|
||||||
|
5. **可维护性** — 半年后能看懂吗?测试覆盖了吗?
|
||||||
|
6. **风格** — 只关注工具无法自动处理的部分
|
||||||
|
|
||||||
|
### 给出总结
|
||||||
|
|
||||||
|
审查结束后,给一段总结,包括:
|
||||||
|
- 整体评价(一句话)
|
||||||
|
- 值得学习的地方(先扬后抑)
|
||||||
|
- 主要问题列表(按优先级)
|
||||||
|
- 建议的修改方向
|
||||||
|
|
||||||
|
```
|
||||||
|
总结:整体实现思路清晰,支付回调的幂等处理很到位。
|
||||||
|
|
||||||
|
主要问题:
|
||||||
|
1. [必须修复] 并发写 map 的问题(2 处)
|
||||||
|
2. [建议修改] 缺少对空值的校验(3 处)
|
||||||
|
3. [仅供参考] 几个变量命名可以更语义化
|
||||||
|
|
||||||
|
建议先修复并发问题,校验的部分可以本次一起改或者拆到下个迭代。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 检查清单
|
||||||
|
|
||||||
|
在提交审查意见前,确认:
|
||||||
|
|
||||||
|
- [ ] 每条评论都标注了优先级
|
||||||
|
- [ ] [必须修复] 的问题都给出了具体的修复建议
|
||||||
|
- [ ] 没有因为面子而跳过关键问题
|
||||||
|
- [ ] 没有纠结于工具能自动处理的风格问题
|
||||||
|
- [ ] 对好的代码给予了肯定
|
||||||
|
- [ ] 给出了整体总结
|
||||||
369
.codebuddy/skills/chinese-commit-conventions/SKILL.md
Normal file
369
.codebuddy/skills/chinese-commit-conventions/SKILL.md
Normal file
@ -0,0 +1,369 @@
|
|||||||
|
---
|
||||||
|
name: chinese-commit-conventions
|
||||||
|
description: 中文 commit 与 changelog 配置参考——Conventional Commits 中文适配、commitlint/husky/commitizen 中文模板、conventional-changelog 中文配置。仅在用户显式 /chinese-commit-conventions 时调用,不要根据上下文自动触发。
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [git, chinese]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 中文 Git 提交规范
|
||||||
|
|
||||||
|
## 1. Conventional Commits 中文适配
|
||||||
|
|
||||||
|
基于 Conventional Commits 1.0.0 规范,针对中文团队的实际使用习惯进行适配。
|
||||||
|
|
||||||
|
### 类型(type)定义
|
||||||
|
|
||||||
|
| 类型 | 说明 | 示例场景 |
|
||||||
|
| ---------- | ---------------------------- | -------------------------- |
|
||||||
|
| `feat` | 新功能 | 添加用户注册模块 |
|
||||||
|
| `fix` | 修复缺陷 | 修复登录页白屏问题 |
|
||||||
|
| `docs` | 文档变更 | 更新 API 接口文档 |
|
||||||
|
| `style` | 代码格式(不影响逻辑) | 调整缩进、补充分号 |
|
||||||
|
| `refactor` | 重构(非新功能、非修复) | 拆分过长的服务类 |
|
||||||
|
| `perf` | 性能优化 | 优化首页列表查询速度 |
|
||||||
|
| `test` | 测试相关 | 补充用户模块单元测试 |
|
||||||
|
| `chore` | 构建/工具/依赖变更 | 升级 webpack 到 v5 |
|
||||||
|
| `ci` | 持续集成配置 | 修改 GitHub Actions 流程 |
|
||||||
|
| `revert` | 回滚提交 | 回滚 v2.1.0 的登录重构 |
|
||||||
|
|
||||||
|
### 原则
|
||||||
|
|
||||||
|
- type 保留英文关键字(工具链兼容性好)
|
||||||
|
- scope 和 description 使用中文
|
||||||
|
- body 使用中文完整描述
|
||||||
|
|
||||||
|
## 2. 中文 commit message 模板
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <subject>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<footer>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 完整示例
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(用户模块): 添加手机号一键登录功能
|
||||||
|
|
||||||
|
- 接入运营商一键登录 SDK
|
||||||
|
- 支持移动、联通、电信三网
|
||||||
|
- 登录失败自动降级到短信验证码
|
||||||
|
|
||||||
|
Closes #128
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(订单): 修复并发下单导致库存超卖的问题
|
||||||
|
|
||||||
|
在高并发场景下,原有的库存扣减逻辑存在竞态条件。
|
||||||
|
改用 Redis 分布式锁 + 数据库乐观锁双重保障。
|
||||||
|
|
||||||
|
影响范围:订单服务、库存服务
|
||||||
|
测试确认:已通过 500 并发压测验证
|
||||||
|
|
||||||
|
Closes #256
|
||||||
|
```
|
||||||
|
|
||||||
|
## 3. Subject 行规范
|
||||||
|
|
||||||
|
### 格式
|
||||||
|
|
||||||
|
```
|
||||||
|
<type>(<scope>): <description>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 规则
|
||||||
|
|
||||||
|
- **type**: 必填,从上方类型表中选取
|
||||||
|
- **scope**: 选填,表示影响范围,使用中文模块名
|
||||||
|
- 示例:`用户模块`、`订单`、`支付`、`基础组件`
|
||||||
|
- **description**: 必填,中文简述,不超过 50 个字符
|
||||||
|
- 使用动宾短语:「添加 xxx」「修复 xxx」「优化 xxx」
|
||||||
|
- 不加句号结尾
|
||||||
|
- 不要写「修改了代码」这种无意义描述
|
||||||
|
|
||||||
|
### 好的示例
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(权限): 添加基于 RBAC 的细粒度权限控制
|
||||||
|
fix(支付): 修复微信支付回调签名验证失败的问题
|
||||||
|
perf(列表页): 优化大数据量表格的虚拟滚动渲染
|
||||||
|
refactor(网关): 将单体网关拆分为独立微服务
|
||||||
|
```
|
||||||
|
|
||||||
|
### 反面示例
|
||||||
|
|
||||||
|
```
|
||||||
|
# 以下写法应避免
|
||||||
|
fix: 修了一个 bug
|
||||||
|
feat: 更新代码
|
||||||
|
chore: 改了点东西
|
||||||
|
```
|
||||||
|
|
||||||
|
## 4. Body 编写规范
|
||||||
|
|
||||||
|
Body 用于详细说明本次变更的动机、方案和影响。
|
||||||
|
|
||||||
|
### 编写要点
|
||||||
|
|
||||||
|
- 说明**为什么**要做这个改动(背景/原因)
|
||||||
|
- 说明**怎么做**的(技术方案摘要)
|
||||||
|
- 说明**影响范围**(哪些模块、接口受影响)
|
||||||
|
- 每行不超过 72 个字符(中文约 36 个汉字)
|
||||||
|
- 正文与标题之间空一行
|
||||||
|
|
||||||
|
### Body 模板
|
||||||
|
|
||||||
|
```
|
||||||
|
<改动背景和原因>
|
||||||
|
|
||||||
|
技术方案:
|
||||||
|
- <方案要点 1>
|
||||||
|
- <方案要点 2>
|
||||||
|
|
||||||
|
影响范围:<受影响的模块或服务>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Breaking Changes 标注
|
||||||
|
|
||||||
|
当提交包含不兼容变更时,必须在 footer 中标注。
|
||||||
|
|
||||||
|
### 格式一:footer 标注
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(接口): 重构用户信息返回结构
|
||||||
|
|
||||||
|
将用户接口返回的扁平结构改为嵌套结构,前端需同步调整字段取值路径。
|
||||||
|
|
||||||
|
BREAKING CHANGE: /api/user/info 返回结构变更
|
||||||
|
- avatar 字段移入 profile 对象
|
||||||
|
- 移除已废弃的 nickname 字段,统一使用 displayName
|
||||||
|
```
|
||||||
|
|
||||||
|
### 格式二:type 后加感叹号
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(接口)!: 重构用户信息返回结构
|
||||||
|
```
|
||||||
|
|
||||||
|
### 团队约定
|
||||||
|
|
||||||
|
- 涉及数据库表结构变更 -> 必须标注 BREAKING CHANGE
|
||||||
|
- 涉及公共 API 参数/返回值变更 -> 必须标注
|
||||||
|
- 涉及配置文件格式变更 -> 必须标注
|
||||||
|
- 标注时须写明迁移方法或升级步骤
|
||||||
|
|
||||||
|
## 6. Issue 关联
|
||||||
|
|
||||||
|
### GitHub 格式
|
||||||
|
|
||||||
|
```
|
||||||
|
Closes #128
|
||||||
|
Refs #129, #130
|
||||||
|
```
|
||||||
|
|
||||||
|
### Gitee 格式
|
||||||
|
|
||||||
|
```
|
||||||
|
Closes #I5ABC1
|
||||||
|
相关需求: https://gitee.com/org/repo/issues/I5ABC1
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coding 格式
|
||||||
|
|
||||||
|
```
|
||||||
|
关联 Coding 缺陷 #12345
|
||||||
|
fixed=project-2024/issues/678
|
||||||
|
```
|
||||||
|
|
||||||
|
### 通用写法
|
||||||
|
|
||||||
|
```
|
||||||
|
# footer 中关联多个平台
|
||||||
|
Closes #128
|
||||||
|
Jira: PROJ-456
|
||||||
|
禅道: #789
|
||||||
|
```
|
||||||
|
|
||||||
|
## 7. Changelog 自动生成配置
|
||||||
|
|
||||||
|
### 安装 conventional-changelog
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D conventional-changelog-cli conventional-changelog-conventionalcommits
|
||||||
|
```
|
||||||
|
|
||||||
|
### package.json 脚本
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"scripts": {
|
||||||
|
"changelog": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s",
|
||||||
|
"changelog:all": "conventional-changelog -p conventionalcommits -i CHANGELOG.md -s -r 0",
|
||||||
|
"release": "standard-version"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### .versionrc.js 中文配置
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
types: [
|
||||||
|
{ type: 'feat', section: '新功能' },
|
||||||
|
{ type: 'fix', section: '缺陷修复' },
|
||||||
|
{ type: 'perf', section: '性能优化' },
|
||||||
|
{ type: 'refactor', section: '代码重构' },
|
||||||
|
{ type: 'docs', section: '文档更新' },
|
||||||
|
{ type: 'test', section: '测试' },
|
||||||
|
{ type: 'chore', section: '构建/工具', hidden: true },
|
||||||
|
{ type: 'ci', section: '持续集成', hidden: true },
|
||||||
|
{ type: 'style', section: '代码格式', hidden: true }
|
||||||
|
],
|
||||||
|
commitUrlFormat: '{{host}}/{{owner}}/{{repository}}/commit/{{hash}}',
|
||||||
|
compareUrlFormat: '{{host}}/{{owner}}/{{repository}}/compare/{{previousTag}}...{{currentTag}}'
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 8. commitlint 中文配置
|
||||||
|
|
||||||
|
### 安装
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D @commitlint/cli @commitlint/config-conventional
|
||||||
|
```
|
||||||
|
|
||||||
|
### commitlint.config.js
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
module.exports = {
|
||||||
|
extends: ['@commitlint/config-conventional'],
|
||||||
|
rules: {
|
||||||
|
'type-enum': [2, 'always', [
|
||||||
|
'feat', 'fix', 'docs', 'style', 'refactor',
|
||||||
|
'perf', 'test', 'chore', 'ci', 'revert'
|
||||||
|
]],
|
||||||
|
'type-case': [2, 'always', 'lower-case'],
|
||||||
|
'type-empty': [2, 'never'],
|
||||||
|
'subject-empty': [2, 'never'],
|
||||||
|
'subject-max-length': [2, 'always', 100],
|
||||||
|
// 允许中文字符,关闭 subject-case 限制
|
||||||
|
'subject-case': [0],
|
||||||
|
// 关闭 header-max-length 或放宽(中文占宽较大)
|
||||||
|
'header-max-length': [2, 'always', 120],
|
||||||
|
'body-max-line-length': [1, 'always', 200],
|
||||||
|
'footer-max-line-length': [1, 'always', 200]
|
||||||
|
},
|
||||||
|
prompt: {
|
||||||
|
messages: {
|
||||||
|
type: '选择提交类型:',
|
||||||
|
scope: '输入影响范围(可选):',
|
||||||
|
subject: '填写简短描述:',
|
||||||
|
body: '填写详细描述(可选,使用 "|" 换行):',
|
||||||
|
breaking: '列出不兼容变更(可选):',
|
||||||
|
footer: '关联的 Issue(可选,例如 #123):',
|
||||||
|
confirmCommit: '确认提交以上信息?'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. husky + lint-staged 集成
|
||||||
|
|
||||||
|
### 安装与初始化
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D husky lint-staged
|
||||||
|
npx husky init
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置 commit-msg 钩子
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .husky/commit-msg
|
||||||
|
npx --no -- commitlint --edit "$1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 配置 pre-commit 钩子
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# .husky/pre-commit
|
||||||
|
npx lint-staged
|
||||||
|
```
|
||||||
|
|
||||||
|
### lint-staged 配置(package.json)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"lint-staged": {
|
||||||
|
"*.{js,ts,jsx,tsx,vue}": [
|
||||||
|
"eslint --fix",
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"*.{css,scss,less}": [
|
||||||
|
"stylelint --fix",
|
||||||
|
"prettier --write"
|
||||||
|
],
|
||||||
|
"*.md": [
|
||||||
|
"prettier --write"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 交互式提交(可选)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install -D commitizen cz-conventional-changelog
|
||||||
|
|
||||||
|
# package.json 中添加
|
||||||
|
{
|
||||||
|
"config": {
|
||||||
|
"commitizen": {
|
||||||
|
"path": "cz-conventional-changelog"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"scripts": {
|
||||||
|
"commit": "cz"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
运行 `npm run commit` 即可进入交互式提交引导。
|
||||||
|
|
||||||
|
## 10. 团队规范检查清单
|
||||||
|
|
||||||
|
### 提交前自查
|
||||||
|
|
||||||
|
- [ ] type 是否正确选择(feat/fix/docs/...)
|
||||||
|
- [ ] scope 是否准确描述了影响模块
|
||||||
|
- [ ] subject 是否为动宾短语且不超过 50 字符
|
||||||
|
- [ ] subject 末尾是否去掉了句号
|
||||||
|
- [ ] body 是否说明了变更原因和方案
|
||||||
|
- [ ] 不兼容变更是否标注了 BREAKING CHANGE
|
||||||
|
- [ ] 相关 Issue 是否已关联
|
||||||
|
- [ ] 一次提交是否只做了一件事(原子性)
|
||||||
|
|
||||||
|
### 团队落地步骤
|
||||||
|
|
||||||
|
1. **工具链配置**:按上述步骤配置 commitlint + husky,让规范可执行
|
||||||
|
2. **模板共享**:将 `.commitlintrc`、`.husky/` 等配置提交到仓库
|
||||||
|
3. **团队培训**:组织 15 分钟的规范说明会,演示工具使用
|
||||||
|
4. **Code Review**:Review 时关注 commit message 质量
|
||||||
|
5. **持续迭代**:每季度回顾规范执行情况,根据团队反馈调整
|
||||||
|
|
||||||
|
### 常见问题
|
||||||
|
|
||||||
|
**Q: 中英文混排时空格怎么处理?**
|
||||||
|
A: 中文与英文/数字之间加一个空格,如「添加 Redis 缓存」。
|
||||||
|
|
||||||
|
**Q: scope 用中文还是英文?**
|
||||||
|
A: 团队内统一即可。推荐中文(可读性好),但需在 commitlint 中关闭 scope-case 检查。
|
||||||
|
|
||||||
|
**Q: 多人协作时如何保证规范一致?**
|
||||||
|
A: 靠工具而非靠自觉。配置好 husky + commitlint,不符合规范的提交会被拦截。
|
||||||
453
.codebuddy/skills/chinese-documentation/SKILL.md
Normal file
453
.codebuddy/skills/chinese-documentation/SKILL.md
Normal file
@ -0,0 +1,453 @@
|
|||||||
|
---
|
||||||
|
name: chinese-documentation
|
||||||
|
description: 中文文档排版参考——中英文空格、全半角标点、术语保留、链接格式、中文文案排版指北约定。仅在用户显式 /chinese-documentation 时调用,不要根据上下文自动触发。
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [documentation, chinese]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 中文技术文档写作规范
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
中文技术文档最常见的问题不是内容不够,而是**读起来别扭**——中英文挤在一起没有空格、全角半角混用、一股机翻味。本技能提供一套完整的中文技术文档写作规范,让你的文档**专业、好读、不出戏**。
|
||||||
|
|
||||||
|
**核心原则:** 排版服务于阅读体验,规范服务于一致性,内容服务于读者。
|
||||||
|
|
||||||
|
**参考标准:** [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)
|
||||||
|
|
||||||
|
## 中文排版规范
|
||||||
|
|
||||||
|
### 空格
|
||||||
|
|
||||||
|
**中英文之间加空格:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# 好
|
||||||
|
使用 Git 进行版本管理,配合 Jenkins 实现持续集成。
|
||||||
|
|
||||||
|
# 坏
|
||||||
|
使用Git进行版本管理,配合Jenkins实现持续集成。
|
||||||
|
```
|
||||||
|
|
||||||
|
**中文与数字之间加空格:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# 好
|
||||||
|
本次更新包含 3 个新功能和 12 个 Bug 修复。
|
||||||
|
|
||||||
|
# 坏
|
||||||
|
本次更新包含3个新功能和12个Bug修复。
|
||||||
|
```
|
||||||
|
|
||||||
|
**数字与单位之间加空格:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# 好
|
||||||
|
文件大小不超过 5 MB,响应时间控制在 200 ms 以内。
|
||||||
|
|
||||||
|
# 坏
|
||||||
|
文件大小不超过5MB,响应时间控制在200ms以内。
|
||||||
|
```
|
||||||
|
|
||||||
|
**例外:度数、百分比等不加空格:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# 好
|
||||||
|
今天气温 32°C,CPU 使用率 95%。
|
||||||
|
|
||||||
|
# 坏
|
||||||
|
今天气温 32 °C,CPU 使用率 95 %。
|
||||||
|
```
|
||||||
|
|
||||||
|
**链接前后加空格:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# 好
|
||||||
|
请参考 [官方文档](https://example.com) 获取更多信息。
|
||||||
|
|
||||||
|
# 坏
|
||||||
|
请参考[官方文档](https://example.com)获取更多信息。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 标点符号
|
||||||
|
|
||||||
|
**中文语境使用全角标点:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# 好
|
||||||
|
注意:该接口需要鉴权,请先获取 Token。
|
||||||
|
|
||||||
|
# 坏
|
||||||
|
注意:该接口需要鉴权,请先获取 Token.
|
||||||
|
```
|
||||||
|
|
||||||
|
**全角标点与英文/数字之间不加空格:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# 好
|
||||||
|
项目使用 MIT 协议,详见 LICENSE 文件。
|
||||||
|
|
||||||
|
# 坏
|
||||||
|
项目使用 MIT 协议 ,详见 LICENSE 文件 。
|
||||||
|
```
|
||||||
|
|
||||||
|
**括号的使用:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# 中文语境用全角括号
|
||||||
|
请运行安装命令(详见下方说明)。
|
||||||
|
|
||||||
|
# 括号内有英文或数字时用半角括号
|
||||||
|
该项目基于 Spring Boot (v3.2.0) 开发。
|
||||||
|
|
||||||
|
# 纯英文内容用半角括号
|
||||||
|
See the documentation (README.md) for details.
|
||||||
|
```
|
||||||
|
|
||||||
|
**引号的使用:**
|
||||||
|
|
||||||
|
```
|
||||||
|
# 中文使用直角引号(推荐)
|
||||||
|
「确定」按钮触发表单提交,「取消」按钮关闭弹窗。
|
||||||
|
|
||||||
|
# 也可以使用弯引号(视团队规范而定)
|
||||||
|
"确定"按钮触发表单提交,"取消"按钮关闭弹窗。
|
||||||
|
|
||||||
|
# 嵌套引号
|
||||||
|
他说:「请点击『确定』按钮。」
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数字
|
||||||
|
|
||||||
|
```
|
||||||
|
# 阿拉伯数字(技术文档中统一使用半角数字)
|
||||||
|
支持最多 100 个并发连接。
|
||||||
|
|
||||||
|
# 不要用中文数字写技术参数
|
||||||
|
# 坏:支持最多一百个并发连接。
|
||||||
|
|
||||||
|
# 数字使用半角字符
|
||||||
|
版本号 v2.1.0,端口号 8080,HTTP 状态码 200。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 中英混排最佳实践
|
||||||
|
|
||||||
|
### 术语处理原则
|
||||||
|
|
||||||
|
**保留英文的情况:**
|
||||||
|
|
||||||
|
- 专有名词:React、Kubernetes、Redis、MySQL
|
||||||
|
- 行业通用缩写:API、SDK、CLI、ORM、CI/CD
|
||||||
|
- 命令和代码:`npm install`、`git commit`
|
||||||
|
- 协议和标准:HTTP、TCP/IP、JSON、REST
|
||||||
|
- 没有公认中文翻译的术语:debounce、throttle、middleware
|
||||||
|
|
||||||
|
**翻译为中文的情况:**
|
||||||
|
|
||||||
|
- 有公认翻译的通用概念:数据库、服务器、浏览器、框架
|
||||||
|
- 描述性短语:version control → 版本控制,load balancing → 负载均衡
|
||||||
|
- 文档标题和章节名(尽量中文,技术名词可保留英文)
|
||||||
|
|
||||||
|
### 首次出现标注翻译
|
||||||
|
|
||||||
|
技术术语首次出现时,标注中英对照:
|
||||||
|
|
||||||
|
```
|
||||||
|
# 好
|
||||||
|
本系统采用消息队列(Message Queue)实现异步通信,
|
||||||
|
使用死信队列(Dead Letter Queue)处理消费失败的消息。
|
||||||
|
|
||||||
|
# 后续出现直接使用
|
||||||
|
消息队列的消费者需要实现幂等性……
|
||||||
|
```
|
||||||
|
|
||||||
|
### 避免过度翻译
|
||||||
|
|
||||||
|
```
|
||||||
|
# 好:保留业界通用英文术语
|
||||||
|
在 Controller 层做参数校验,Service 层处理业务逻辑。
|
||||||
|
|
||||||
|
# 坏:强行翻译反而看不懂
|
||||||
|
在控制器层做参数校验,服务层处理业务逻辑。
|
||||||
|
|
||||||
|
# 好
|
||||||
|
使用 Redis 做 Session 缓存。
|
||||||
|
|
||||||
|
# 坏
|
||||||
|
使用"远程字典服务"做"会话"缓存。
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 文档中英对照格式
|
||||||
|
|
||||||
|
### 接口文档模板
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 创建订单 / Create Order
|
||||||
|
|
||||||
|
### 基本信息
|
||||||
|
|
||||||
|
- **请求方式 (Method):** POST
|
||||||
|
- **请求路径 (Path):** `/api/v1/orders`
|
||||||
|
- **鉴权方式 (Auth):** Bearer Token
|
||||||
|
- **Content-Type:** application/json
|
||||||
|
|
||||||
|
### 请求参数 (Request Parameters)
|
||||||
|
|
||||||
|
| 参数名 (Field) | 类型 (Type) | 必填 (Required) | 说明 (Description) |
|
||||||
|
|----------------|-------------|-----------------|-------------------|
|
||||||
|
| product_id | string | 是 | 商品 ID (Product ID) |
|
||||||
|
| quantity | integer | 是 | 购买数量 (Quantity),最小值为 1 |
|
||||||
|
| address_id | string | 是 | 收货地址 ID (Shipping address ID) |
|
||||||
|
| coupon_code | string | 否 | 优惠券码 (Coupon code) |
|
||||||
|
|
||||||
|
### 请求示例 (Request Example)
|
||||||
|
|
||||||
|
\```json
|
||||||
|
{
|
||||||
|
"product_id": "prod_abc123",
|
||||||
|
"quantity": 2,
|
||||||
|
"address_id": "addr_xyz789",
|
||||||
|
"coupon_code": "SUMMER2024"
|
||||||
|
}
|
||||||
|
\```
|
||||||
|
|
||||||
|
### 响应参数 (Response Parameters)
|
||||||
|
|
||||||
|
| 参数名 (Field) | 类型 (Type) | 说明 (Description) |
|
||||||
|
|----------------|-------------|-------------------|
|
||||||
|
| order_id | string | 订单 ID (Order ID) |
|
||||||
|
| status | string | 订单状态 (Order status): pending / paid / shipped |
|
||||||
|
| total_amount | integer | 订单总金额,单位:分 (Total amount in cents) |
|
||||||
|
| created_at | string | 创建时间 (Created at),ISO 8601 格式 |
|
||||||
|
|
||||||
|
### 响应示例 (Response Example)
|
||||||
|
|
||||||
|
\```json
|
||||||
|
{
|
||||||
|
"code": 0,
|
||||||
|
"message": "success",
|
||||||
|
"data": {
|
||||||
|
"order_id": "ord_20240315001",
|
||||||
|
"status": "pending",
|
||||||
|
"total_amount": 9900,
|
||||||
|
"created_at": "2024-03-15T10:30:00+08:00"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
\```
|
||||||
|
|
||||||
|
### 错误码 (Error Codes)
|
||||||
|
|
||||||
|
| 错误码 (Code) | 说明 (Description) | 处理建议 (Suggestion) |
|
||||||
|
|---------------|--------------------|--------------------|
|
||||||
|
| 40001 | 商品不存在 (Product not found) | 检查 product_id 是否正确 |
|
||||||
|
| 40002 | 库存不足 (Insufficient stock) | 减少购买数量或稍后重试 |
|
||||||
|
| 40003 | 优惠券已过期 (Coupon expired) | 移除 coupon_code 或更换优惠券 |
|
||||||
|
```
|
||||||
|
|
||||||
|
### 金额表示约定
|
||||||
|
|
||||||
|
```
|
||||||
|
# 好:明确说明单位
|
||||||
|
total_amount: 9900 // 单位:分(即 99.00 元)
|
||||||
|
|
||||||
|
# 坏:不说明单位,造成歧义
|
||||||
|
total_amount: 99.00 // 是元还是分?浮点数会有精度问题
|
||||||
|
```
|
||||||
|
|
||||||
|
## README.md 中文模板
|
||||||
|
|
||||||
|
国内开源项目常用的 README 结构:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# 项目名称
|
||||||
|
|
||||||
|
[]()
|
||||||
|
[]()
|
||||||
|
|
||||||
|
简短一句话介绍项目是什么、解决什么问题。
|
||||||
|
|
||||||
|
## 特性
|
||||||
|
|
||||||
|
- 特性一:简要描述
|
||||||
|
- 特性二:简要描述
|
||||||
|
- 特性三:简要描述
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
|
||||||
|
- Node.js >= 20
|
||||||
|
- MySQL >= 8.0
|
||||||
|
|
||||||
|
### 安装
|
||||||
|
|
||||||
|
\```bash
|
||||||
|
npm install your-package
|
||||||
|
\```
|
||||||
|
|
||||||
|
### 基本用法
|
||||||
|
|
||||||
|
\```typescript
|
||||||
|
import { YourPackage } from 'your-package';
|
||||||
|
|
||||||
|
const client = new YourPackage({ apiKey: 'your-key' });
|
||||||
|
const result = await client.doSomething();
|
||||||
|
\```
|
||||||
|
|
||||||
|
## 文档
|
||||||
|
|
||||||
|
- [使用指南](./docs/guide.md)
|
||||||
|
- [API 参考](./docs/api.md)
|
||||||
|
- [常见问题](./docs/faq.md)
|
||||||
|
- [更新日志](./CHANGELOG.md)
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
更多示例请查看 [examples](./examples) 目录。
|
||||||
|
|
||||||
|
## 贡献指南
|
||||||
|
|
||||||
|
欢迎提交 Issue 和 Pull Request。请先阅读 [贡献指南](./CONTRIBUTING.md)。
|
||||||
|
|
||||||
|
### 本地开发
|
||||||
|
|
||||||
|
\```bash
|
||||||
|
# 克隆项目
|
||||||
|
git clone https://gitee.com/your-org/your-project.git
|
||||||
|
|
||||||
|
# 安装依赖
|
||||||
|
npm install
|
||||||
|
|
||||||
|
# 启动开发服务器
|
||||||
|
npm run dev
|
||||||
|
|
||||||
|
# 运行测试
|
||||||
|
npm test
|
||||||
|
\```
|
||||||
|
|
||||||
|
## 致谢
|
||||||
|
|
||||||
|
- [依赖项目一](https://example.com) — 简要说明
|
||||||
|
- [依赖项目二](https://example.com) — 简要说明
|
||||||
|
|
||||||
|
## 许可证
|
||||||
|
|
||||||
|
[MIT](./LICENSE)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见问题与避坑指南
|
||||||
|
|
||||||
|
### 问题一:机翻味
|
||||||
|
|
||||||
|
**特征:** 句式生硬、不符合中文表达习惯。
|
||||||
|
|
||||||
|
```
|
||||||
|
# 机翻味
|
||||||
|
这个函数被用来计算用户的折扣。如果你想要获取更多信息,请参考文档。
|
||||||
|
|
||||||
|
# 自然中文
|
||||||
|
这个函数用于计算用户折扣。更多信息请参考文档。
|
||||||
|
```
|
||||||
|
|
||||||
|
**要点:**
|
||||||
|
- 避免被动语态("被用来" → "用于")
|
||||||
|
- 避免冗余代词("你想要" → 直接说)
|
||||||
|
- 避免直译英文句式
|
||||||
|
|
||||||
|
### 问题二:句式欧化
|
||||||
|
|
||||||
|
**特征:** 长定语、多重从句、一句话说不完。
|
||||||
|
|
||||||
|
```
|
||||||
|
# 欧化句式
|
||||||
|
这是一个可以帮助开发者在不需要手动配置复杂的构建工具链的情况下
|
||||||
|
快速搭建现代化前端项目的脚手架工具。
|
||||||
|
|
||||||
|
# 正常中文
|
||||||
|
这是一个前端脚手架工具,帮助开发者快速搭建项目,免去手动配置构建工具链的麻烦。
|
||||||
|
```
|
||||||
|
|
||||||
|
**要点:**
|
||||||
|
- 长句拆成短句
|
||||||
|
- 把定语从句改成并列句
|
||||||
|
- 一句话只说一件事
|
||||||
|
|
||||||
|
### 问题三:过度翻译
|
||||||
|
|
||||||
|
```
|
||||||
|
# 过度翻译
|
||||||
|
请打开您的"终端模拟器",运行"节点包管理器"的安装命令。
|
||||||
|
|
||||||
|
# 正常写法
|
||||||
|
请打开终端,运行 npm install。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题四:中英标点混用
|
||||||
|
|
||||||
|
```
|
||||||
|
# 坏:中文句子用了英文逗号和句号
|
||||||
|
请先安装依赖,然后运行测试.
|
||||||
|
|
||||||
|
# 好:中文句子用全角标点
|
||||||
|
请先安装依赖,然后运行测试。
|
||||||
|
|
||||||
|
# 坏:英文内容用了中文标点
|
||||||
|
Run `npm install`,then `npm test`。
|
||||||
|
|
||||||
|
# 好:英文内容用半角标点
|
||||||
|
Run `npm install`, then `npm test`.
|
||||||
|
```
|
||||||
|
|
||||||
|
### 问题五:缺乏结构化
|
||||||
|
|
||||||
|
```
|
||||||
|
# 坏:一大段文字没有分段
|
||||||
|
本系统使用 Redis 做缓存提高查询性能同时使用 MySQL 做持久化存储
|
||||||
|
数据写入时先写 MySQL 再异步更新 Redis 缓存读取时先查 Redis 如果
|
||||||
|
未命中再查 MySQL 并将结果回写缓存设置过期时间为 30 分钟……
|
||||||
|
|
||||||
|
# 好:用列表和分段组织信息
|
||||||
|
本系统的缓存策略如下:
|
||||||
|
|
||||||
|
- **存储层:** MySQL(持久化)+ Redis(缓存)
|
||||||
|
- **写入流程:** 先写 MySQL,再异步更新 Redis
|
||||||
|
- **读取流程:** 先查 Redis → 未命中则查 MySQL → 回写 Redis
|
||||||
|
- **缓存过期:** TTL 设为 30 分钟
|
||||||
|
```
|
||||||
|
|
||||||
|
## 写作检查清单
|
||||||
|
|
||||||
|
在发布文档前,逐项检查:
|
||||||
|
|
||||||
|
### 排版
|
||||||
|
|
||||||
|
- [ ] 中英文之间有空格
|
||||||
|
- [ ] 中文与数字之间有空格
|
||||||
|
- [ ] 中文语境使用全角标点
|
||||||
|
- [ ] 英文/代码部分使用半角标点
|
||||||
|
- [ ] 没有全角半角标点混用
|
||||||
|
|
||||||
|
### 术语
|
||||||
|
|
||||||
|
- [ ] 专有名词保留英文原文
|
||||||
|
- [ ] 首次出现的术语标注了中英对照
|
||||||
|
- [ ] 没有过度翻译业界通用术语
|
||||||
|
- [ ] 术语使用前后一致
|
||||||
|
|
||||||
|
### 内容
|
||||||
|
|
||||||
|
- [ ] 句子简短,没有欧化长句
|
||||||
|
- [ ] 没有不必要的被动语态
|
||||||
|
- [ ] 用列表和表格组织结构化信息
|
||||||
|
- [ ] 代码示例可以直接运行
|
||||||
|
- [ ] 没有"机翻味"
|
||||||
|
|
||||||
|
### 格式
|
||||||
|
|
||||||
|
- [ ] 标题层级正确(不跳级)
|
||||||
|
- [ ] 代码块标注了语言类型
|
||||||
|
- [ ] 链接可以正常访问
|
||||||
|
- [ ] 图片有 alt 文本
|
||||||
552
.codebuddy/skills/chinese-git-workflow/SKILL.md
Normal file
552
.codebuddy/skills/chinese-git-workflow/SKILL.md
Normal file
@ -0,0 +1,552 @@
|
|||||||
|
---
|
||||||
|
name: chinese-git-workflow
|
||||||
|
description: 国内 Git 平台配置参考——Gitee、Coding.net、极狐 GitLab、CNB 的 SSH/HTTPS/凭据/CI 接入差异与镜像同步配置。仅在用户显式 /chinese-git-workflow 时调用,不要根据上下文自动触发。
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [git, chinese]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 国内 Git 工作流规范
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
国内团队用 Git 经常踩的坑:GitHub 访问不稳定、CI/CD 方案照搬国外水土不服、commit message 中英混杂没有规范。本技能提供一套**完整适配国内平台和团队习惯的 Git 工作流**。
|
||||||
|
|
||||||
|
**核心原则:** 工作流服务于团队效率,不是为了流程而流程。选适合团队规模的,别硬套大厂方案。
|
||||||
|
|
||||||
|
## 国内 Git 平台适配
|
||||||
|
|
||||||
|
### 平台对比
|
||||||
|
|
||||||
|
| 特性 | Gitee | Coding.net | 极狐 GitLab | CNB | GitHub |
|
||||||
|
|------|-------|------------|-------------|-----|--------|
|
||||||
|
| 国内访问 | 快 | 快 | 快 | 快 | 不稳定 |
|
||||||
|
| 免费私有仓库 | 有 | 有 | 有 | 有 | 有 |
|
||||||
|
| CI/CD | Gitee Go | Coding CI | 内置 GitLab CI | 内置(.cnb.yml) | GitHub Actions |
|
||||||
|
| 代码审查 | PR | MR | MR | MR | PR |
|
||||||
|
| 制品库 | 有限 | 完整 | 完整 | 完整 | Packages |
|
||||||
|
| 适合场景 | 开源/小团队 | 中大型团队 | 企业私有化 | 云原生 / Docker 流水线 | 国际项目 |
|
||||||
|
|
||||||
|
### Gitee 特有配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 设置 Gitee 远程仓库
|
||||||
|
git remote add origin https://gitee.com/<org>/<repo>.git
|
||||||
|
|
||||||
|
# Gitee 的 SSH 配置
|
||||||
|
# ~/.ssh/config
|
||||||
|
Host gitee.com
|
||||||
|
HostName gitee.com
|
||||||
|
User git
|
||||||
|
IdentityFile ~/.ssh/gitee_rsa
|
||||||
|
PreferredAuthentications publickey
|
||||||
|
|
||||||
|
# 同时推送到 Gitee 和 GitHub(镜像同步)
|
||||||
|
git remote set-url --add --push origin https://gitee.com/<org>/<repo>.git
|
||||||
|
git remote set-url --add --push origin https://github.com/<org>/<repo>.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coding.net 特有配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Coding 的仓库地址格式
|
||||||
|
git remote add origin https://e.coding.net/<team>/<project>/<repo>.git
|
||||||
|
|
||||||
|
# Coding 支持的 SSH 地址
|
||||||
|
git remote add origin git@e.coding.net:<team>/<project>/<repo>.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### 极狐 GitLab 特有配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 极狐 GitLab 私有化部署常见地址格式
|
||||||
|
git remote add origin https://jihulab.com/<group>/<repo>.git
|
||||||
|
|
||||||
|
# 或者企业内部部署
|
||||||
|
git remote add origin https://gitlab.yourcompany.com/<group>/<repo>.git
|
||||||
|
```
|
||||||
|
|
||||||
|
### CNB(Cloud Native Build)特有配置
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# CNB 仓库地址(仅支持 HTTPS,不提供 SSH 协议)
|
||||||
|
git remote add origin https://cnb.cool/<org>/<repo>.git
|
||||||
|
|
||||||
|
# HTTPS 认证:用户名固定为 cnb,密码为个人访问令牌(Access Token)
|
||||||
|
# 在 CNB 平台 → 个人设置 → 访问令牌 中生成
|
||||||
|
git config credential.helper store
|
||||||
|
```
|
||||||
|
|
||||||
|
## 工作流选择
|
||||||
|
|
||||||
|
### 方案一:主干开发(Trunk-Based Development)
|
||||||
|
|
||||||
|
**适合:** 小团队(2-8 人)、迭代速度快、有完善的自动化测试。
|
||||||
|
|
||||||
|
```
|
||||||
|
main ──●──●──●──●──●──●──●──●──●──
|
||||||
|
\ / \ / \ /
|
||||||
|
feat/x ●─● ●─● fix/y ●─●
|
||||||
|
(短命分支,1-2 天内合回)
|
||||||
|
```
|
||||||
|
|
||||||
|
**规则:**
|
||||||
|
- 主干(main)始终保持可发布状态
|
||||||
|
- 功能分支生命周期不超过 2 天
|
||||||
|
- 每天至少合并一次到主干
|
||||||
|
- 用 Feature Flag 控制未完成功能的可见性
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 从 main 拉分支
|
||||||
|
git checkout -b feat/user-login main
|
||||||
|
|
||||||
|
# 开发完成后,rebase 到最新 main
|
||||||
|
git fetch origin
|
||||||
|
git rebase origin/main
|
||||||
|
|
||||||
|
# 提交 PR/MR,合并后删除分支
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方案二:Git Flow(经典分支模型)
|
||||||
|
|
||||||
|
**适合:** 中大团队、版本发布节奏固定(如双周迭代)、需要维护多个版本。
|
||||||
|
|
||||||
|
```
|
||||||
|
main ──●────────────────●────────────── 生产环境
|
||||||
|
\ / \
|
||||||
|
release ●──●──●──●──● ●──●──●──●── 发布分支
|
||||||
|
\ /
|
||||||
|
develop ──●──●──●──●──●──●──●──●──●──●── 开发主线
|
||||||
|
\ / \ /
|
||||||
|
feat/x ●─● ●─────● 功能分支
|
||||||
|
\ /
|
||||||
|
fix/y ●─● 修复分支
|
||||||
|
```
|
||||||
|
|
||||||
|
**分支说明:**
|
||||||
|
- `main` — 生产环境代码,只接受 release 和 hotfix 的合并
|
||||||
|
- `develop` — 开发主线,功能分支从这里拉出,合回这里
|
||||||
|
- `release/*` — 发布分支,从 develop 拉出,只修 bug 不加功能
|
||||||
|
- `feat/*` — 功能分支
|
||||||
|
- `hotfix/*` — 紧急修复,从 main 拉出,同时合回 main 和 develop
|
||||||
|
|
||||||
|
### 方案三:国内团队常用简化流程
|
||||||
|
|
||||||
|
**适合:** 大多数国内中小团队的实际情况。
|
||||||
|
|
||||||
|
```
|
||||||
|
main ──●──────●──────●──── 生产环境(受保护)
|
||||||
|
\ / \ /
|
||||||
|
dev ──●──●─●──●──●─●──── 开发/测试环境
|
||||||
|
\ / \ /
|
||||||
|
feat/x ●● ●● 功能分支
|
||||||
|
```
|
||||||
|
|
||||||
|
**规则:**
|
||||||
|
- `main` 分支受保护,只能通过 PR/MR 合并
|
||||||
|
- `dev` 分支对应测试环境,自动部署
|
||||||
|
- 功能分支从 `dev` 拉出,合回 `dev`
|
||||||
|
- `dev` 测试通过后,合并到 `main` 进行发布
|
||||||
|
|
||||||
|
## 分支命名规范
|
||||||
|
|
||||||
|
### 国内团队常用命名
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 功能分支
|
||||||
|
feat/user-login # 新功能
|
||||||
|
feat/JIRA-1234-order-refund # 关联任务编号
|
||||||
|
|
||||||
|
# 修复分支
|
||||||
|
fix/payment-callback # Bug 修复
|
||||||
|
fix/JIRA-5678-null-pointer # 关联 Bug 编号
|
||||||
|
|
||||||
|
# 发布分支
|
||||||
|
release/v2.1.0 # 版本发布
|
||||||
|
release/2024-03-sprint # 按迭代命名
|
||||||
|
|
||||||
|
# 紧急修复
|
||||||
|
hotfix/v2.0.1 # 线上紧急修复
|
||||||
|
hotfix/fix-login-crash # 描述性命名
|
||||||
|
|
||||||
|
# 个人分支(部分团队使用)
|
||||||
|
dev/zhangsan/feat-login # 个人开发分支
|
||||||
|
```
|
||||||
|
|
||||||
|
### 命名规则
|
||||||
|
|
||||||
|
1. 全部小写,用 `-` 连接单词(不用下划线或驼峰)
|
||||||
|
2. 前缀明确分支类型:`feat/`、`fix/`、`hotfix/`、`release/`
|
||||||
|
3. 关联任务管理平台的编号(如有):`feat/TAPD-12345-description`
|
||||||
|
4. 长度适中,能看出分支目的即可
|
||||||
|
|
||||||
|
## 中文 Commit Message 规范
|
||||||
|
|
||||||
|
### 约定式提交(Conventional Commits)中文版
|
||||||
|
|
||||||
|
```
|
||||||
|
<类型>(<范围>): <简要描述>
|
||||||
|
← 空行
|
||||||
|
<正文(可选)>
|
||||||
|
← 空行
|
||||||
|
<脚注(可选)>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 类型清单
|
||||||
|
|
||||||
|
| 类型 | 说明 | emoji(可选) |
|
||||||
|
|------|------|--------------|
|
||||||
|
| feat | 新增功能 | ✨ |
|
||||||
|
| fix | 修复 Bug | 🐛 |
|
||||||
|
| docs | 文档更新 | 📝 |
|
||||||
|
| style | 代码格式(不影响逻辑) | 💄 |
|
||||||
|
| refactor | 重构(不是新功能也不是修 Bug) | ♻️ |
|
||||||
|
| perf | 性能优化 | ⚡ |
|
||||||
|
| test | 测试相关 | ✅ |
|
||||||
|
| build | 构建系统或外部依赖 | 📦 |
|
||||||
|
| ci | CI/CD 配置 | 👷 |
|
||||||
|
| chore | 其他杂项 | 🔧 |
|
||||||
|
| revert | 回滚 | ⏪ |
|
||||||
|
|
||||||
|
### 好的 commit message
|
||||||
|
|
||||||
|
```
|
||||||
|
feat(购物车): 支持批量删除商品
|
||||||
|
|
||||||
|
- 新增全选/反选功能
|
||||||
|
- 删除操作增加二次确认弹窗
|
||||||
|
- 批量删除接口使用 POST /cart/batch-delete
|
||||||
|
|
||||||
|
关联需求:TAPD-12345
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
fix(支付): 修复微信支付在 iOS 16 上无法唤起的问题
|
||||||
|
|
||||||
|
原因:微信 SDK 8.0.33 版本在 iOS 16 上 Universal Links 校验逻辑变更,
|
||||||
|
导致 openURL 回调失败。
|
||||||
|
|
||||||
|
方案:升级 SDK 至 8.0.38,并更新 Associated Domains 配置。
|
||||||
|
|
||||||
|
Closes #567
|
||||||
|
```
|
||||||
|
|
||||||
|
### 不好的 commit message
|
||||||
|
|
||||||
|
```
|
||||||
|
# 太笼统
|
||||||
|
update code
|
||||||
|
fix bug
|
||||||
|
修改了一些东西
|
||||||
|
|
||||||
|
# 没有上下文
|
||||||
|
fix: 修复问题
|
||||||
|
feat: 新增功能
|
||||||
|
|
||||||
|
# 中英混杂无规范
|
||||||
|
fix:修复了一个bug,因为user login的时候会crash
|
||||||
|
```
|
||||||
|
|
||||||
|
## CI/CD 平台适配
|
||||||
|
|
||||||
|
### Gitee Go
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .gitee/pipelines/pipeline.yml
|
||||||
|
name: 构建与测试
|
||||||
|
displayName: '构建与测试流水线'
|
||||||
|
|
||||||
|
triggers:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
include:
|
||||||
|
- main
|
||||||
|
- dev
|
||||||
|
|
||||||
|
stages:
|
||||||
|
- name: 测试
|
||||||
|
jobs:
|
||||||
|
- name: 单元测试
|
||||||
|
steps:
|
||||||
|
- step: npmbuild@1
|
||||||
|
name: install_and_test
|
||||||
|
displayName: '安装依赖并执行测试'
|
||||||
|
inputs:
|
||||||
|
nodeVersion: 20
|
||||||
|
commands:
|
||||||
|
- npm ci
|
||||||
|
- npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
### Coding CI
|
||||||
|
|
||||||
|
```groovy
|
||||||
|
// Jenkinsfile(Coding CI 支持 Jenkinsfile 语法)
|
||||||
|
pipeline {
|
||||||
|
agent any
|
||||||
|
|
||||||
|
stages {
|
||||||
|
stage('安装依赖') {
|
||||||
|
steps {
|
||||||
|
sh 'npm ci'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('单元测试') {
|
||||||
|
steps {
|
||||||
|
sh 'npm test'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('构建') {
|
||||||
|
steps {
|
||||||
|
sh 'npm run build'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('部署到测试环境') {
|
||||||
|
when {
|
||||||
|
branch 'dev'
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
sh './scripts/deploy-staging.sh'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stage('部署到生产环境') {
|
||||||
|
when {
|
||||||
|
branch 'main'
|
||||||
|
}
|
||||||
|
steps {
|
||||||
|
sh './scripts/deploy-production.sh'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
post {
|
||||||
|
failure {
|
||||||
|
// 企业微信/钉钉通知
|
||||||
|
sh './scripts/notify-failure.sh'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 极狐 GitLab CI
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .gitlab-ci.yml
|
||||||
|
stages:
|
||||||
|
- test
|
||||||
|
- build
|
||||||
|
- deploy
|
||||||
|
|
||||||
|
variables:
|
||||||
|
NODE_IMAGE: node:20-alpine
|
||||||
|
# 使用国内镜像加速
|
||||||
|
NPM_REGISTRY: https://registry.npmmirror.com
|
||||||
|
|
||||||
|
单元测试:
|
||||||
|
stage: test
|
||||||
|
image: $NODE_IMAGE
|
||||||
|
script:
|
||||||
|
- npm config set registry $NPM_REGISTRY
|
||||||
|
- npm ci
|
||||||
|
- npm test
|
||||||
|
coverage: '/Lines\s*:\s*(\d+\.?\d*)%/'
|
||||||
|
|
||||||
|
构建:
|
||||||
|
stage: build
|
||||||
|
image: $NODE_IMAGE
|
||||||
|
script:
|
||||||
|
- npm config set registry $NPM_REGISTRY
|
||||||
|
- npm ci
|
||||||
|
- npm run build
|
||||||
|
artifacts:
|
||||||
|
paths:
|
||||||
|
- dist/
|
||||||
|
|
||||||
|
部署测试环境:
|
||||||
|
stage: deploy
|
||||||
|
script:
|
||||||
|
- ./scripts/deploy-staging.sh
|
||||||
|
only:
|
||||||
|
- dev
|
||||||
|
environment:
|
||||||
|
name: staging
|
||||||
|
|
||||||
|
部署生产环境:
|
||||||
|
stage: deploy
|
||||||
|
script:
|
||||||
|
- ./scripts/deploy-production.sh
|
||||||
|
only:
|
||||||
|
- main
|
||||||
|
environment:
|
||||||
|
name: production
|
||||||
|
when: manual # 生产环境手动触发
|
||||||
|
```
|
||||||
|
|
||||||
|
### CNB(Cloud Native Build)
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# .cnb.yml — branch-first 结构,直接指定 Docker 镜像跑流水线
|
||||||
|
main:
|
||||||
|
push:
|
||||||
|
- docker:
|
||||||
|
image: node:20
|
||||||
|
stages:
|
||||||
|
- npm ci
|
||||||
|
- npm test
|
||||||
|
- npm run build
|
||||||
|
pull_request:
|
||||||
|
- docker:
|
||||||
|
image: node:20
|
||||||
|
stages:
|
||||||
|
- npm run lint
|
||||||
|
- npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
**特点:**
|
||||||
|
- 每个流水线独立指定 Docker 镜像,天然云原生
|
||||||
|
- 支持 `push` / `pull_request` 触发
|
||||||
|
- 同一事件可并行多条流水线
|
||||||
|
- `stages` 也支持 `- name: xxx` + `script:` 的展开形式,复杂场景见官方文档
|
||||||
|
|
||||||
|
### GitHub Actions 国内替代方案对照
|
||||||
|
|
||||||
|
| GitHub Actions 功能 | Gitee Go | Coding CI | 极狐 GitLab CI | CNB |
|
||||||
|
|---------------------|----------|-----------|----------------|-----|
|
||||||
|
| 触发条件 | triggers | Jenkinsfile triggers | only/rules | push / pull_request |
|
||||||
|
| 缓存依赖 | cache step | stash/unstash | cache | 见官方文档 |
|
||||||
|
| 制品存储 | artifacts | 制品库 | artifacts | 见官方文档 |
|
||||||
|
| 环境变量 | env | environment | variables | env |
|
||||||
|
| 密钥管理 | 环境变量配置 | 凭据管理 | CI/CD Variables | Access Token |
|
||||||
|
| 手动触发 | 手动运行 | 手动触发 | when: manual | 页面手动运行 |
|
||||||
|
|
||||||
|
## PR/MR 描述模板
|
||||||
|
|
||||||
|
### 中文模板
|
||||||
|
|
||||||
|
在仓库中创建 PR/MR 模板文件:
|
||||||
|
|
||||||
|
**Gitee:** `.gitee/PULL_REQUEST_TEMPLATE.md`
|
||||||
|
|
||||||
|
**Coding / GitLab:** `.gitlab/merge_request_templates/default.md`
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 变更说明
|
||||||
|
|
||||||
|
<!-- 简要描述这次改动做了什么,解决了什么问题 -->
|
||||||
|
|
||||||
|
## 变更类型
|
||||||
|
|
||||||
|
- [ ] 新功能(feat)
|
||||||
|
- [ ] Bug 修复(fix)
|
||||||
|
- [ ] 重构(refactor)
|
||||||
|
- [ ] 性能优化(perf)
|
||||||
|
- [ ] 文档更新(docs)
|
||||||
|
- [ ] 其他:
|
||||||
|
|
||||||
|
## 关联信息
|
||||||
|
|
||||||
|
- 需求/Bug 链接:
|
||||||
|
- 设计文档:
|
||||||
|
|
||||||
|
## 改动范围
|
||||||
|
|
||||||
|
<!-- 列出主要改动的模块和文件 -->
|
||||||
|
|
||||||
|
## 测试情况
|
||||||
|
|
||||||
|
- [ ] 单元测试通过
|
||||||
|
- [ ] 手动测试通过
|
||||||
|
- [ ] 相关模块回归测试通过
|
||||||
|
|
||||||
|
## 测试方法
|
||||||
|
|
||||||
|
<!-- 描述如何验证这次改动 -->
|
||||||
|
|
||||||
|
## 影响范围
|
||||||
|
|
||||||
|
<!-- 这次改动可能影响哪些功能?是否需要通知其他团队? -->
|
||||||
|
|
||||||
|
## 部署注意事项
|
||||||
|
|
||||||
|
- [ ] 需要执行数据库迁移
|
||||||
|
- [ ] 需要更新配置文件
|
||||||
|
- [ ] 需要更新环境变量
|
||||||
|
- [ ] 无特殊注意事项
|
||||||
|
|
||||||
|
## 截图/录屏
|
||||||
|
|
||||||
|
<!-- 如果涉及 UI 变更,贴截图或录屏 -->
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用 Git 配置
|
||||||
|
|
||||||
|
### 国内环境优化
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 设置用户信息
|
||||||
|
git config --global user.name "张三"
|
||||||
|
git config --global user.email "zhangsan@company.com"
|
||||||
|
|
||||||
|
# commit message 编辑器设置为 VS Code
|
||||||
|
git config --global core.editor "code --wait"
|
||||||
|
|
||||||
|
# 解决中文文件名显示为转义字符的问题
|
||||||
|
git config --global core.quotepath false
|
||||||
|
|
||||||
|
# 设置默认分支名
|
||||||
|
git config --global init.defaultBranch main
|
||||||
|
|
||||||
|
# 代理设置(如果需要同时使用 GitHub)
|
||||||
|
git config --global http.https://github.com.proxy socks5://127.0.0.1:7890
|
||||||
|
|
||||||
|
# NPM 使用国内镜像
|
||||||
|
npm config set registry https://registry.npmmirror.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### .gitignore 国内项目常见配置
|
||||||
|
|
||||||
|
```gitignore
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
.vscode/
|
||||||
|
*.swp
|
||||||
|
|
||||||
|
# 依赖
|
||||||
|
node_modules/
|
||||||
|
vendor/
|
||||||
|
|
||||||
|
# 构建产物
|
||||||
|
dist/
|
||||||
|
build/
|
||||||
|
*.exe
|
||||||
|
|
||||||
|
# 环境配置
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
|
||||||
|
# 系统文件
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
desktop.ini
|
||||||
|
|
||||||
|
# 国内平台特有
|
||||||
|
.coding/
|
||||||
|
```
|
||||||
|
|
||||||
|
## 检查清单
|
||||||
|
|
||||||
|
在推送代码前,确认:
|
||||||
|
|
||||||
|
- [ ] 分支命名符合团队规范
|
||||||
|
- [ ] commit message 格式正确,类型和范围准确
|
||||||
|
- [ ] 关联了对应的需求/Bug 编号
|
||||||
|
- [ ] PR/MR 描述填写完整
|
||||||
|
- [ ] CI 流水线通过
|
||||||
|
- [ ] 已请求相关同事 Review
|
||||||
170
.codebuddy/skills/dispatching-parallel-agents/SKILL.md
Normal file
170
.codebuddy/skills/dispatching-parallel-agents/SKILL.md
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
---
|
||||||
|
name: dispatching-parallel-agents
|
||||||
|
description: 当面对 2 个以上可以独立进行、无共享状态或顺序依赖的任务时使用
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [agents, parallel]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 并行分派智能体
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
你将任务委派给具有隔离上下文的专用智能体。通过精心设计它们的指令和上下文,确保它们专注并成功完成任务。它们不应继承你的会话上下文或历史记录——你要精确构造它们所需的一切。这样也能为你自己保留用于协调工作的上下文。
|
||||||
|
|
||||||
|
当你遇到多个不相关的失败(不同的测试文件、不同的子系统、不同的 bug),逐一排查会浪费时间。每个排查都是独立的,可以并行进行。
|
||||||
|
|
||||||
|
**核心原则:** 每个独立问题域分派一个智能体,让它们并发工作。
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
```dot
|
||||||
|
digraph when_to_use {
|
||||||
|
"存在多个失败?" [shape=diamond];
|
||||||
|
"它们是否独立?" [shape=diamond];
|
||||||
|
"单个智能体排查所有问题" [shape=box];
|
||||||
|
"每个问题域一个智能体" [shape=box];
|
||||||
|
"能否并行工作?" [shape=diamond];
|
||||||
|
"顺序执行智能体" [shape=box];
|
||||||
|
"并行分派" [shape=box];
|
||||||
|
|
||||||
|
"存在多个失败?" -> "它们是否独立?" [label="是"];
|
||||||
|
"它们是否独立?" -> "单个智能体排查所有问题" [label="否 - 有关联"];
|
||||||
|
"它们是否独立?" -> "能否并行工作?" [label="是"];
|
||||||
|
"能否并行工作?" -> "并行分派" [label="是"];
|
||||||
|
"能否并行工作?" -> "顺序执行智能体" [label="否 - 有共享状态"];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**适用场景:**
|
||||||
|
- 3 个以上测试文件因不同根因失败
|
||||||
|
- 多个子系统独立出现故障
|
||||||
|
- 每个问题无需其他问题的上下文即可理解
|
||||||
|
- 排查之间无共享状态
|
||||||
|
|
||||||
|
**不适用场景:**
|
||||||
|
- 失败是相关的(修复一个可能修复其他的)
|
||||||
|
- 需要理解完整的系统状态
|
||||||
|
- 智能体之间会互相干扰
|
||||||
|
|
||||||
|
## 模式
|
||||||
|
|
||||||
|
### 1. 识别独立的问题域
|
||||||
|
|
||||||
|
按故障分组:
|
||||||
|
- 文件 A 测试:工具审批流程
|
||||||
|
- 文件 B 测试:批量完成行为
|
||||||
|
- 文件 C 测试:中止功能
|
||||||
|
|
||||||
|
每个问题域是独立的——修复工具审批不会影响中止测试。
|
||||||
|
|
||||||
|
### 2. 创建聚焦的智能体任务
|
||||||
|
|
||||||
|
每个智能体获得:
|
||||||
|
- **明确范围:** 一个测试文件或子系统
|
||||||
|
- **清晰目标:** 让这些测试通过
|
||||||
|
- **约束条件:** 不修改其他代码
|
||||||
|
- **预期输出:** 你发现和修复内容的总结
|
||||||
|
|
||||||
|
### 3. 并行分派
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在 Claude Code / AI 环境中
|
||||||
|
Task("修复 agent-tool-abort.test.ts 的失败")
|
||||||
|
Task("修复 batch-completion-behavior.test.ts 的失败")
|
||||||
|
Task("修复 tool-approval-race-conditions.test.ts 的失败")
|
||||||
|
// 三个任务并发运行
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 审查与集成
|
||||||
|
|
||||||
|
当智能体返回时:
|
||||||
|
- 阅读每个总结
|
||||||
|
- 验证修复之间没有冲突
|
||||||
|
- 运行完整测试套件
|
||||||
|
- 集成所有更改
|
||||||
|
|
||||||
|
## 智能体提示词结构
|
||||||
|
|
||||||
|
好的智能体提示词应该是:
|
||||||
|
1. **聚焦的** - 一个清晰的问题域
|
||||||
|
2. **自包含的** - 包含理解问题所需的所有上下文
|
||||||
|
3. **明确输出要求** - 智能体应该返回什么?
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
修复 src/agents/agent-tool-abort.test.ts 中 3 个失败的测试:
|
||||||
|
|
||||||
|
1. "should abort tool with partial output capture" - 期望消息中包含 'interrupted at'
|
||||||
|
2. "should handle mixed completed and aborted tools" - 快速工具被中止而非完成
|
||||||
|
3. "should properly track pendingToolCount" - 期望 3 个结果但得到 0 个
|
||||||
|
|
||||||
|
这些是时序/竞态条件问题。你的任务:
|
||||||
|
|
||||||
|
1. 阅读测试文件,理解每个测试验证的内容
|
||||||
|
2. 找到根因——是时序问题还是实际 bug?
|
||||||
|
3. 修复方式:
|
||||||
|
- 用基于事件的等待替换任意超时
|
||||||
|
- 如果发现中止实现中的 bug 则修复
|
||||||
|
- 如果测试的是已变更的行为则调整测试期望
|
||||||
|
|
||||||
|
不要只是增加超时时间——找到真正的问题。
|
||||||
|
|
||||||
|
返回:你发现了什么以及修复了什么的总结。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见错误
|
||||||
|
|
||||||
|
**错误做法:太宽泛:** "修复所有测试" - 智能体会迷失方向
|
||||||
|
**正确做法:具体明确:** "修复 agent-tool-abort.test.ts" - 聚焦的范围
|
||||||
|
|
||||||
|
**错误做法:无上下文:** "修复竞态条件" - 智能体不知道在哪里
|
||||||
|
**正确做法:提供上下文:** 粘贴错误信息和测试名称
|
||||||
|
|
||||||
|
**错误做法:无约束:** 智能体可能会重构所有代码
|
||||||
|
**正确做法:设置约束:** "不要修改生产代码" 或 "只修复测试"
|
||||||
|
|
||||||
|
**错误做法:模糊的输出要求:** "修好它" - 你不知道改了什么
|
||||||
|
**正确做法:明确要求:** "返回根因和修改内容的总结"
|
||||||
|
|
||||||
|
## 不适用的场景
|
||||||
|
|
||||||
|
**关联性失败:** 修复一个可能修复其他的——先一起排查
|
||||||
|
**需要完整上下文:** 理解问题需要看到整个系统
|
||||||
|
**探索性调试:** 你还不知道什么坏了
|
||||||
|
**共享状态:** 智能体会互相干扰(编辑同一文件、使用同一资源)
|
||||||
|
|
||||||
|
## 实际案例
|
||||||
|
|
||||||
|
**场景:** 大规模重构后,3 个文件中出现 6 个测试失败
|
||||||
|
|
||||||
|
**失败情况:**
|
||||||
|
- agent-tool-abort.test.ts:3 个失败(时序问题)
|
||||||
|
- batch-completion-behavior.test.ts:2 个失败(工具未执行)
|
||||||
|
- tool-approval-race-conditions.test.ts:1 个失败(执行计数 = 0)
|
||||||
|
|
||||||
|
**决策:** 独立的问题域——中止逻辑、批量完成、竞态条件各自独立
|
||||||
|
|
||||||
|
**分派:**
|
||||||
|
```
|
||||||
|
智能体 1 → 修复 agent-tool-abort.test.ts
|
||||||
|
智能体 2 → 修复 batch-completion-behavior.test.ts
|
||||||
|
智能体 3 → 修复 tool-approval-race-conditions.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
**结果:**
|
||||||
|
- 智能体 1:用基于事件的等待替换了超时
|
||||||
|
- 智能体 2:修复了事件结构 bug(threadId 位置不对)
|
||||||
|
- 智能体 3:添加了等待异步工具执行完成的逻辑
|
||||||
|
|
||||||
|
**集成:** 所有修复互相独立,无冲突,完整测试套件全部通过
|
||||||
|
|
||||||
|
## 验证
|
||||||
|
|
||||||
|
智能体返回后:
|
||||||
|
1. **审查每个总结** - 理解改了什么
|
||||||
|
2. **检查冲突** - 智能体是否编辑了同一段代码?
|
||||||
|
3. **运行完整套件** - 验证所有修复协同工作
|
||||||
|
4. **抽查** - 智能体可能犯系统性错误
|
||||||
|
|
||||||
181
.codebuddy/skills/executing-plans/SKILL.md
Normal file
181
.codebuddy/skills/executing-plans/SKILL.md
Normal file
@ -0,0 +1,181 @@
|
|||||||
|
---
|
||||||
|
name: executing-plans
|
||||||
|
description: 当你有一份书面实现计划需要在单独的会话中执行,并设有审查检查点时使用
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [execution, planning]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 执行计划
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
加载计划,批判性审查,执行所有任务,完成后报告。
|
||||||
|
|
||||||
|
**开始时宣布:** "我正在使用 executing-plans 技能来实现此计划。"
|
||||||
|
|
||||||
|
**注意:** 告诉你的人类伙伴,Superpowers 在有子代理支持时效果好得多(Claude Code、Codex CLI、Codex App、Copilot CLI 与 Gemini CLI 都算;见 `../using-superpowers/references/` 下的各平台工具参考)。如果子代理可用,请使用 subagent-driven-development 而非此技能。
|
||||||
|
|
||||||
|
## 流程
|
||||||
|
|
||||||
|
### 步骤 1:加载并审查计划
|
||||||
|
|
||||||
|
1. 确保有一个隔离的工作区:用 using-git-worktrees 创建一个,或者核实已有的那个
|
||||||
|
2. 读取计划文件
|
||||||
|
3. 批判性审查——识别计划中的任何问题或疑虑
|
||||||
|
4. 如果有疑虑:在开始之前向你的人类伙伴提出
|
||||||
|
5. 如果没有疑虑:创建 TodoWrite 并继续
|
||||||
|
|
||||||
|
**审查时重点检查:**
|
||||||
|
- 步骤之间是否有依赖遗漏?(A 依赖 B,但 B 排在 A 之后)
|
||||||
|
- 验证条件是否明确?("确认可用"不算,"运行 `npm test` 全部通过"才算)
|
||||||
|
- 是否有隐含的环境假设?(Node 版本、数据库连接、API Key)
|
||||||
|
|
||||||
|
**审查示例:**
|
||||||
|
```
|
||||||
|
计划文件:docs/plan.md
|
||||||
|
任务清单:5 个任务
|
||||||
|
|
||||||
|
审查发现:
|
||||||
|
- 任务 3(添加数据库迁移)应在任务 2(编写数据模型)之后,顺序正确 ✓
|
||||||
|
- 任务 4 的验证条件写的是"确认功能正常"→ 需澄清:具体跑什么测试?
|
||||||
|
- 计划未提及 Python 版本要求 → 需确认
|
||||||
|
|
||||||
|
向伙伴提出:
|
||||||
|
"计划整体可执行。有两个问题:(1) 任务 4 的验证条件不够具体,建议改为
|
||||||
|
'运行 pytest tests/test_api.py 全部通过';(2) 需要确认 Python 版本要求。"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2:执行任务
|
||||||
|
|
||||||
|
对于每个任务:
|
||||||
|
|
||||||
|
1. **标记为进行中** — 更新 TodoWrite
|
||||||
|
2. **理解目标** — 重读任务描述,明确完成标准
|
||||||
|
3. **执行实现** — 严格按照计划步骤执行(计划已有小步骤)
|
||||||
|
4. **运行验证** — 按要求运行测试或检查
|
||||||
|
5. **提交变更** — 每完成一个任务提交一次,commit message 引用任务编号
|
||||||
|
6. **标记为已完成** — 更新 TodoWrite
|
||||||
|
|
||||||
|
**每个任务的节奏:**
|
||||||
|
```
|
||||||
|
--- 任务 2/5:添加用户验证 ---
|
||||||
|
[标记进行中]
|
||||||
|
|
||||||
|
目标:为 /api/users 添加输入验证
|
||||||
|
完成标准:所有验证测试通过,无效输入返回 400
|
||||||
|
|
||||||
|
[实现]
|
||||||
|
- 添加 validateUser() 中间件
|
||||||
|
- 编写 3 个验证规则(email 格式、密码强度、用户名长度)
|
||||||
|
|
||||||
|
[验证]
|
||||||
|
$ npm test -- --grep "validation"
|
||||||
|
✓ 拒绝无效 email (12ms)
|
||||||
|
✓ 拒绝弱密码 (8ms)
|
||||||
|
✓ 拒绝过长用户名 (5ms)
|
||||||
|
3 passing
|
||||||
|
|
||||||
|
[提交]
|
||||||
|
$ git add src/middleware/validate.js tests/validation.test.js
|
||||||
|
$ git commit -m "feat: 添加用户输入验证(任务 2/5)"
|
||||||
|
|
||||||
|
[标记完成]
|
||||||
|
--- 任务 2/5 完成 ---
|
||||||
|
```
|
||||||
|
|
||||||
|
**持续自查:**
|
||||||
|
- 执行过程中持续留意:整体方向还对吗?有没有偏离计划?
|
||||||
|
- 如果发现前面的实现有问题,先修复再继续,不要带着问题往下走
|
||||||
|
|
||||||
|
### 步骤 3:完成开发
|
||||||
|
|
||||||
|
所有任务完成并验证后:
|
||||||
|
- 宣布:"我正在使用 finishing-a-development-branch 技能来完成此工作。"
|
||||||
|
- **必需子技能:** 使用 finishing-a-development-branch
|
||||||
|
- 按照该技能的指引验证测试、展示选项、执行选择
|
||||||
|
|
||||||
|
**完成报告模板:**
|
||||||
|
```
|
||||||
|
## 执行报告
|
||||||
|
|
||||||
|
**计划:** docs/plan.md
|
||||||
|
**分支:** feature/user-validation
|
||||||
|
**任务:** 5/5 已完成
|
||||||
|
|
||||||
|
### 完成的任务
|
||||||
|
1. ✅ 初始化项目结构
|
||||||
|
2. ✅ 添加用户验证
|
||||||
|
3. ✅ 添加数据库迁移
|
||||||
|
4. ✅ 实现 API 端点
|
||||||
|
5. ✅ 添加集成测试
|
||||||
|
|
||||||
|
### 验证结果
|
||||||
|
- 单元测试:23/23 通过
|
||||||
|
- 集成测试:8/8 通过
|
||||||
|
- lint 检查:0 个警告
|
||||||
|
|
||||||
|
### 偏离计划的地方
|
||||||
|
- 任务 3:Redis 配置从 env 改为 config.yaml(经伙伴同意)
|
||||||
|
|
||||||
|
### 下一步
|
||||||
|
按 finishing-a-development-branch 技能处理合并/PR
|
||||||
|
```
|
||||||
|
|
||||||
|
## 何时停下来求助
|
||||||
|
|
||||||
|
**在以下情况立即停止执行:**
|
||||||
|
- 遇到阻塞(缺少依赖、测试失败、指令不清)
|
||||||
|
- 计划有严重缺陷导致无法开始
|
||||||
|
- 你不理解某条指令
|
||||||
|
- 验证反复失败(同一测试失败 2 次以上)
|
||||||
|
|
||||||
|
**不确定时就问,不要猜测。**
|
||||||
|
|
||||||
|
## 常见异常处理
|
||||||
|
|
||||||
|
> 🇨🇳 **本节是 superpowers-zh 的增量内容,上游 obra/superpowers 没有。**
|
||||||
|
> 它展开的是上一节「遇到阻塞(缺少依赖、测试失败、指令不清)」的三种具体情形。
|
||||||
|
> 上游的步骤 1–3 与其余各节均为逐节翻译,未被本节改动。
|
||||||
|
|
||||||
|
|
||||||
|
**测试失败:**
|
||||||
|
1. 读错误信息,定位失败原因
|
||||||
|
2. 区分:是实现 bug?还是测试本身有问题?还是计划描述有误?
|
||||||
|
3. 实现 bug → 修复并重跑
|
||||||
|
4. 测试有问题 → 修复测试,向伙伴说明
|
||||||
|
5. 计划有误 → 停下来,向伙伴报告并建议修正
|
||||||
|
|
||||||
|
**依赖缺失:**
|
||||||
|
```
|
||||||
|
任务 3 需要 Redis 连接,但计划中没有提及 Redis 配置。
|
||||||
|
→ 停止执行
|
||||||
|
→ 向伙伴报告:"任务 3 需要 Redis,计划中未包含配置步骤。
|
||||||
|
建议:在任务 3 前插入 '配置 Redis 连接' 步骤。"
|
||||||
|
```
|
||||||
|
|
||||||
|
**指令不清:**
|
||||||
|
- 不要猜测意图,不要"合理推断"
|
||||||
|
- 列出你的理解和困惑,让伙伴澄清
|
||||||
|
- 等待回复后再继续
|
||||||
|
|
||||||
|
**提交粒度:** 每个任务单独提交,commit message 引用任务编号。
|
||||||
|
|
||||||
|
## 何时回到之前的步骤
|
||||||
|
|
||||||
|
**回到审查(步骤 1)当:**
|
||||||
|
- 伙伴根据你的反馈更新了计划
|
||||||
|
- 根本性的方案需要重新考虑
|
||||||
|
|
||||||
|
**不要硬闯阻塞** — 停下来问。
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
- 先批判性审查计划
|
||||||
|
- 严格按照计划步骤执行
|
||||||
|
- 不要跳过验证
|
||||||
|
- 计划要求时引用相应技能
|
||||||
|
- 遇到阻塞时停下来,不要猜测
|
||||||
|
- 未经用户明确同意,绝不在 main/master 分支上开始实现
|
||||||
|
|
||||||
209
.codebuddy/skills/finishing-a-development-branch/SKILL.md
Normal file
209
.codebuddy/skills/finishing-a-development-branch/SKILL.md
Normal file
@ -0,0 +1,209 @@
|
|||||||
|
---
|
||||||
|
name: finishing-a-development-branch
|
||||||
|
description: 当实现完成、所有测试通过、需要决定如何集成这份工作时使用
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [git, workflow]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 收尾一个开发分支
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
**核心原则:** 验证测试 → 检测环境 → 展示选项 → 执行选择 → 清理。
|
||||||
|
|
||||||
|
**开始时宣告:** "我正在使用 finishing-a-development-branch 技能来收尾这份工作。"
|
||||||
|
|
||||||
|
## 步骤 1:验证测试
|
||||||
|
|
||||||
|
运行项目的完整测试套件(`npm test` / `cargo test` / `pytest` / `go test ./...`)。
|
||||||
|
|
||||||
|
**如果测试失败**,报告失败并停下——菜单是在测试全绿之后才出现的:
|
||||||
|
|
||||||
|
```
|
||||||
|
测试失败(<N> 个)。完成之前必须先修:
|
||||||
|
|
||||||
|
[展示失败详情]
|
||||||
|
```
|
||||||
|
|
||||||
|
**如果测试通过:** 继续步骤 2。
|
||||||
|
|
||||||
|
## 步骤 2:检测环境
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GIT_DIR=$(cd "$(git rev-parse --git-dir)" 2>/dev/null && pwd -P)
|
||||||
|
GIT_COMMON=$(cd "$(git rev-parse --git-common-dir)" 2>/dev/null && pwd -P)
|
||||||
|
# 现在就捕获 —— 此刻还在工作区里面。步骤 5 会切换目录,
|
||||||
|
# 而清理(步骤 6)需要这个值
|
||||||
|
WORKTREE_PATH=$(git rev-parse --show-toplevel)
|
||||||
|
```
|
||||||
|
|
||||||
|
这决定了展示哪种菜单、以及清理方式:
|
||||||
|
|
||||||
|
| 状态 | 菜单 | 清理 |
|
||||||
|
|------|------|------|
|
||||||
|
| `GIT_DIR == GIT_COMMON`(普通仓库) | 标准 3 个选项 | 无 worktree 可清理 |
|
||||||
|
| `GIT_DIR != GIT_COMMON`,命名分支 | 标准 3 个选项 | 按来源判断(见步骤 6) |
|
||||||
|
| `GIT_DIR != GIT_COMMON`,分离 HEAD | 收敛为 2 个选项(不含合并) | 由外部管理——原地别动 |
|
||||||
|
|
||||||
|
## 步骤 3:确定基础分支
|
||||||
|
|
||||||
|
基础分支就是这份工作从哪儿分出来的那个——通常在计划里、对话里,或者分支的 upstream 里已经写明了。如果还不知道,就问:"这个分支是从 <你的最佳猜测> 分出来的,对吗?"**合并之前先确认:合并到错误的基础分支,代价很高。**
|
||||||
|
|
||||||
|
## 步骤 4:展示选项
|
||||||
|
|
||||||
|
**普通仓库和命名分支 worktree——精确展示这 3 个选项:**
|
||||||
|
|
||||||
|
```
|
||||||
|
实现已完成。你想怎么做?
|
||||||
|
|
||||||
|
1. 本地合并回 <base-branch>
|
||||||
|
2. 推送并创建 Pull Request
|
||||||
|
3. 保留分支不动(我稍后自己处理)
|
||||||
|
|
||||||
|
选哪个?
|
||||||
|
```
|
||||||
|
|
||||||
|
**分离 HEAD——精确展示这 2 个选项:**
|
||||||
|
|
||||||
|
```
|
||||||
|
实现已完成。你当前处于分离 HEAD(由外部管理的工作区)。
|
||||||
|
|
||||||
|
1. 作为新分支推送并创建 Pull Request
|
||||||
|
2. 保持原样(我稍后自己处理)
|
||||||
|
|
||||||
|
选哪个?
|
||||||
|
```
|
||||||
|
|
||||||
|
**照原文展示菜单**——简洁,每个选项都来自上面的列表。**丢弃工作只在你的人类伙伴明确提出时才发生**(见下方"如果你的人类伙伴要求丢弃这份工作")。等他们回答;集成与否是他们的决定。
|
||||||
|
|
||||||
|
## 步骤 5:执行选择
|
||||||
|
|
||||||
|
### 选项 1:本地合并
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 切到主仓库根目录,保证 CWD 安全
|
||||||
|
MAIN_ROOT=$(git -C "$(git rev-parse --git-common-dir)/.." rev-parse --show-toplevel)
|
||||||
|
cd "$MAIN_ROOT"
|
||||||
|
|
||||||
|
# 先合并 —— 在删除任何东西之前先验证合并成功
|
||||||
|
git checkout <base-branch>
|
||||||
|
git pull
|
||||||
|
git merge <feature-branch>
|
||||||
|
|
||||||
|
# 在合并结果上验证测试
|
||||||
|
<测试命令>
|
||||||
|
```
|
||||||
|
|
||||||
|
如果测试在**合并结果**上失败:停下,把 worktree 和分支原地留着,去排查——什么都还没推送,所以这次合并是本地的、可恢复的。
|
||||||
|
|
||||||
|
一旦合并结果全绿:清理 worktree(步骤 6),然后删除分支:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git branch -d <feature-branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 选项 2:推送并创建 PR
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git push -u origin <feature-branch>
|
||||||
|
# 从分离 HEAD 出发时,在远端指定新分支名:
|
||||||
|
# git push origin HEAD:refs/heads/<new-branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
然后用**代码托管平台**(forge)的工具针对 <base-branch> 创建 pull/merge request——有 CLI 就用它,没有就用推送时大多数平台会打印出来的创建 URL——遵循仓库里已有的 PR 模板与约定(如果有),并把 URL 报告给你的人类伙伴。
|
||||||
|
|
||||||
|
**保留 worktree**——你的人类伙伴要在那里根据 PR 反馈继续迭代。
|
||||||
|
|
||||||
|
### 选项 3:保持原样
|
||||||
|
|
||||||
|
报告:"保留分支 <name>。工作树保留在 <path>。"
|
||||||
|
|
||||||
|
### 如果你的人类伙伴要求丢弃这份工作
|
||||||
|
|
||||||
|
**这条路只作为对"明确要求把工作扔掉"的响应而存在。** 先确认:
|
||||||
|
|
||||||
|
```
|
||||||
|
这将永久删除:
|
||||||
|
- 分支 <name>
|
||||||
|
- 所有 commit:<commit 列表>
|
||||||
|
- 位于 <path> 的工作树
|
||||||
|
|
||||||
|
输入 'discard' 以确认。
|
||||||
|
```
|
||||||
|
|
||||||
|
等待**这个精确的**确认词。收到之后:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
MAIN_ROOT=$(git -C "$(git rev-parse --git-common-dir)/.." rev-parse --show-toplevel)
|
||||||
|
cd "$MAIN_ROOT"
|
||||||
|
```
|
||||||
|
|
||||||
|
然后清理 worktree(步骤 6),再强制删除分支:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git branch -D <feature-branch>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 步骤 6:清理工作区
|
||||||
|
|
||||||
|
**只对选项 1 和已确认的丢弃执行。** 选项 2 和 3 始终保留 worktree。两个调用方都已经切到主仓库根目录了——移除 worktree 必须从 worktree 外面执行——因此这里使用**步骤 2 里捕获的** `GIT_DIR` / `GIT_COMMON` / `WORKTREE_PATH`,也就是那次目录切换之前的值。
|
||||||
|
|
||||||
|
> ⚠️ **不要在这里重新计算这些值。** 此刻 `git rev-parse --show-toplevel` 返回的是主仓库根目录,不是 worktree 路径 —— 溯源判断会永远匹配不上,清理会静默空转,随后分支删除还会因为 worktree 仍挂着而失败。
|
||||||
|
|
||||||
|
**如果 `GIT_DIR == GIT_COMMON`:** 普通仓库,无 worktree 可清理。结束。
|
||||||
|
|
||||||
|
**如果 `WORKTREE_PATH` 在 `.worktrees/` 或 `worktrees/` 之下:** 这是 Superpowers 创建的 worktree——我们负责清理:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git worktree remove "$WORKTREE_PATH"
|
||||||
|
git worktree prune # 自愈:清理任何过期的注册记录
|
||||||
|
```
|
||||||
|
|
||||||
|
**如果删除被拒绝**(`contains modified or untracked files`):这个 worktree 里存着**别处都不存在**的文件 —— 未提交的计划、笔记或草稿。**绝不要自作主张加 `--force`。** 把利害关系摆给你的人类伙伴看,然后问他:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git -C "$WORKTREE_PATH" status --porcelain -uall
|
||||||
|
```
|
||||||
|
|
||||||
|
```
|
||||||
|
worktree 删除被拒绝 —— 这些文件从未被提交:
|
||||||
|
|
||||||
|
<文件列表>
|
||||||
|
|
||||||
|
1. 先把它们提交到 <branch>,再做清理
|
||||||
|
2. 把它们移到 <主仓库根目录>
|
||||||
|
3. 删掉它们(不可恢复)
|
||||||
|
|
||||||
|
选哪个?
|
||||||
|
```
|
||||||
|
|
||||||
|
按他选的做完,再删除 worktree。
|
||||||
|
|
||||||
|
**否则:** 这个工作区归宿主环境所有——原地别动。如果你的平台提供了工作区退出工具,用它。
|
||||||
|
|
||||||
|
## 快速参考
|
||||||
|
|
||||||
|
| 选项 | 合并 | 推送 | 保留工作树 | 清理分支 |
|
||||||
|
|------|------|------|-----------|---------|
|
||||||
|
| 1. 本地合并 | 是 | - | - | 是 |
|
||||||
|
| 2. 创建 PR | - | 是 | 是 | - |
|
||||||
|
| 3. 保持原样 | - | - | 是 | - |
|
||||||
|
| 丢弃(仅在明确要求时) | - | - | - | 是(强制) |
|
||||||
|
|
||||||
|
## 常见的合理化借口
|
||||||
|
|
||||||
|
| 借口 | 现实 |
|
||||||
|
|------|------|
|
||||||
|
| "测试这个会话早先通过过" | 在**你即将集成的那棵树上**跑测试套件。一次绿色运行只能证明它当时跑的那棵树。 |
|
||||||
|
| "他们显然是想合并的" | 集成是你人类伙伴的决定。把菜单摆出来,然后等。 |
|
||||||
|
| "他们看起来对这个功能收工了——我提议丢弃吧" | 菜单就是原文那样,不多不少。丢弃只在你的人类伙伴用明确的话提出时才发生。 |
|
||||||
|
| "'嗯,删掉吧'算确认了" | 只有输入 `discard` 这个词才授权删除。 |
|
||||||
|
| "PR 已经开了,worktree 现在是碍事的垃圾" | PR 反馈要在那个 worktree 里修。它得留到工作落地为止。 |
|
||||||
|
| "另外那个 worktree 看着像过期的——我顺手也清了" | 只清理 `.worktrees/` 或 `worktrees/` 之下的 worktree。其余的都属于宿主环境。 |
|
||||||
|
| "合并结果的失败大概是偶发的" | 合并结果失败会让一切停下。在你排查期间,分支和 worktree 原地不动。 |
|
||||||
|
| "基础分支明显就是 main" | 确认分叉点,或者直接问。合并到错误的基础分支,代价很高。 |
|
||||||
|
| "推送被拒了——force-push 一下就好" | 推送被拒意味着远端动过了。去排查;只有在你人类伙伴明确要求时才 force-push。 |
|
||||||
|
| "删除被拒绝了 —— 加 `--force` 只是把清理做完而已" | 被拒绝恰恰说明有文件只存在于那个 worktree 里。`--force` 会不可恢复地删掉它们。先问你的人类伙伴。 |
|
||||||
260
.codebuddy/skills/mcp-builder/SKILL.md
Normal file
260
.codebuddy/skills/mcp-builder/SKILL.md
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
---
|
||||||
|
name: mcp-builder
|
||||||
|
description: MCP 服务器构建方法论 — 系统化构建生产级 MCP 工具,让 AI 助手连接外部能力
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [mcp, development]
|
||||||
|
---
|
||||||
|
|
||||||
|
# MCP 服务器构建
|
||||||
|
|
||||||
|
系统化设计、实现、测试和部署 Model Context Protocol 服务器的方法论。
|
||||||
|
|
||||||
|
## 1. 协议核心概念
|
||||||
|
|
||||||
|
MCP 定义三种原语:
|
||||||
|
|
||||||
|
- **Tools(工具)**:AI 助手主动调用的函数,有副作用。如搜索、创建、删除操作。
|
||||||
|
- **Resources(资源)**:AI 助手只读访问的数据源,用 URI 标识。如 `users://{id}/profile`。
|
||||||
|
- **Prompts(提示词模板)**:预定义交互模板,引导用户触发工作流。
|
||||||
|
|
||||||
|
**选择原则:** 执行操作 → Tool | 读取数据 → Resource | 引导交互 → Prompt
|
||||||
|
|
||||||
|
## 2. 项目结构规范
|
||||||
|
|
||||||
|
### TypeScript
|
||||||
|
```
|
||||||
|
my-mcp-server/
|
||||||
|
├── src/
|
||||||
|
│ ├── index.ts # 入口,注册 tools/resources
|
||||||
|
│ ├── tools/ # 按功能拆分
|
||||||
|
│ ├── resources/
|
||||||
|
│ └── lib/ # 客户端封装、校验逻辑
|
||||||
|
├── tests/
|
||||||
|
├── package.json
|
||||||
|
└── tsconfig.json
|
||||||
|
```
|
||||||
|
|
||||||
|
关键依赖:`@modelcontextprotocol/sdk` + `zod`
|
||||||
|
|
||||||
|
### Python
|
||||||
|
```
|
||||||
|
my-mcp-server/
|
||||||
|
├── src/my_mcp_server/
|
||||||
|
│ ├── server.py
|
||||||
|
│ ├── tools/
|
||||||
|
│ └── lib/
|
||||||
|
├── tests/
|
||||||
|
└── pyproject.toml
|
||||||
|
```
|
||||||
|
|
||||||
|
关键依赖:`mcp` + `pydantic`
|
||||||
|
|
||||||
|
## 3. Tool 设计原则
|
||||||
|
|
||||||
|
### 命名
|
||||||
|
- `snake_case` 格式,动词开头:`search_users`、`create_issue`、`delete_file`
|
||||||
|
- 名称自解释,AI 助手靠名称选工具,模糊命名导致误调用
|
||||||
|
|
||||||
|
### 参数
|
||||||
|
- 每个参数有类型约束和 `.describe()` 描述
|
||||||
|
- 可选参数给默认值,减少 AI 决策负担
|
||||||
|
- 用枚举代替布尔开关
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
server.tool("search_issues", {
|
||||||
|
query: z.string().describe("搜索关键词"),
|
||||||
|
status: z.enum(["open", "closed", "all"]).default("open").describe("状态筛选"),
|
||||||
|
limit: z.number().min(1).max(100).default(20).describe("返回上限"),
|
||||||
|
}, async ({ query, status, limit }) => { /* ... */ });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 描述
|
||||||
|
说明**用途 + 返回内容 + 限制**,这是 AI 选择工具的关键依据:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
server.tool("search_users",
|
||||||
|
"根据姓名或邮箱搜索用户。返回 ID、姓名、邮箱列表。模糊匹配,最多 50 条。",
|
||||||
|
schema, handler);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 输出
|
||||||
|
- 结构化数据 → JSON,人类可读内容 → Markdown
|
||||||
|
- 始终用 `content: [{ type: "text", text: "..." }]` 格式返回
|
||||||
|
|
||||||
|
## 4. 输入验证和错误处理
|
||||||
|
|
||||||
|
用 Zod/Pydantic 做 Schema 级校验,业务级校验放 handler 开头:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
server.tool("get_user", { id: z.string() }, async ({ id }) => {
|
||||||
|
try {
|
||||||
|
const user = await db.getUser(id);
|
||||||
|
if (!user) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `用户 ${id} 不存在,请检查 ID。` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { content: [{ type: "text", text: JSON.stringify(user, null, 2) }] };
|
||||||
|
} catch (err) {
|
||||||
|
return {
|
||||||
|
content: [{ type: "text", text: `查询失败:${err.message}` }],
|
||||||
|
isError: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误处理四原则:**
|
||||||
|
1. 永远不让服务器崩溃 — try/catch 包裹所有外部调用
|
||||||
|
2. 返回可操作的错误信息 — 告诉 AI 问题是什么、能做什么
|
||||||
|
3. 使用 `isError: true` — 让 AI 知道调用失败
|
||||||
|
4. 区分错误类型 — 参数错误、权限不足、资源不存在、服务不可用
|
||||||
|
|
||||||
|
## 5. 资源管理和生命周期
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 资源注册
|
||||||
|
server.resource("user-profile", "users://{userId}/profile", async (uri) => {
|
||||||
|
const profile = await db.getProfile(extractId(uri));
|
||||||
|
return { contents: [{ uri: uri.href, mimeType: "application/json", text: JSON.stringify(profile) }] };
|
||||||
|
});
|
||||||
|
|
||||||
|
// 生命周期:先初始化 → 再 connect → 监听关闭信号
|
||||||
|
const db = await Database.connect(config.dbUrl);
|
||||||
|
await server.connect(new StdioServerTransport());
|
||||||
|
process.on("SIGINT", async () => { await db.disconnect(); await server.close(); process.exit(0); });
|
||||||
|
```
|
||||||
|
|
||||||
|
关键点:使用连接池、所有外部调用设超时、优雅关闭清理资源。
|
||||||
|
|
||||||
|
## 6. 测试策略
|
||||||
|
|
||||||
|
### 单元测试 — 业务逻辑与 MCP 注册分离
|
||||||
|
```typescript
|
||||||
|
// tools/search.ts 导出纯函数
|
||||||
|
export async function searchUsers(query: string, limit: number) { /* ... */ }
|
||||||
|
|
||||||
|
// search.test.ts 独立测试
|
||||||
|
test("返回匹配结果", async () => {
|
||||||
|
const results = await searchUsers("alice", 10);
|
||||||
|
expect(results[0].name).toContain("Alice");
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### 集成测试 — 用 SDK Client 做端到端验证
|
||||||
|
```typescript
|
||||||
|
const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair();
|
||||||
|
await server.connect(serverTransport);
|
||||||
|
const client = new Client({ name: "test", version: "1.0.0" });
|
||||||
|
await client.connect(clientTransport);
|
||||||
|
const result = await client.callTool("search_users", { query: "test" });
|
||||||
|
expect(result.isError).toBeFalsy();
|
||||||
|
```
|
||||||
|
|
||||||
|
### MCP Inspector — 交互式调试
|
||||||
|
```bash
|
||||||
|
npx @modelcontextprotocol/inspector node dist/index.js
|
||||||
|
```
|
||||||
|
|
||||||
|
在浏览器中查看所有 tools/resources,手动调用并查看结果。
|
||||||
|
|
||||||
|
**测试要点:** 每个 Tool 覆盖正常 + 异常路径、边界值、外部服务失败模拟。
|
||||||
|
|
||||||
|
## 7. 安全考虑
|
||||||
|
|
||||||
|
**权限控制:**
|
||||||
|
- 最小权限原则,读写 Tool 分离
|
||||||
|
- 危险操作要求确认参数(如 `confirm: true`)
|
||||||
|
|
||||||
|
**输入安全:**
|
||||||
|
- SQL 注入 → 参数化查询,绝不拼接
|
||||||
|
- 路径遍历 → 校验路径,禁止 `../`
|
||||||
|
- 命令注入 → 用 `execFile` 而非 `exec`
|
||||||
|
|
||||||
|
**敏感数据:**
|
||||||
|
- 密钥通过环境变量传入,不硬编码
|
||||||
|
- 日志不打印完整敏感信息
|
||||||
|
- 返回数据做脱敏处理
|
||||||
|
|
||||||
|
**沙箱:** 文件操作限制目录、网络请求限制白名单、设置资源配额。
|
||||||
|
|
||||||
|
## 8. 部署和分发
|
||||||
|
|
||||||
|
### npm 发布
|
||||||
|
```json
|
||||||
|
{ "bin": { "mcp-server-myservice": "dist/index.js" }, "files": ["dist"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
用户配置:
|
||||||
|
```json
|
||||||
|
{ "mcpServers": { "myservice": { "command": "npx", "args": ["@yourorg/mcp-server-myservice"], "env": { "API_KEY": "xxx" } } } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### pip 发布
|
||||||
|
```toml
|
||||||
|
[project.scripts]
|
||||||
|
mcp-server-myservice = "my_mcp_server.server:main"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker — 适用于复杂依赖或隔离场景
|
||||||
|
```dockerfile
|
||||||
|
FROM node:20-slim
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package*.json ./ && RUN npm ci --production
|
||||||
|
COPY dist ./dist
|
||||||
|
ENTRYPOINT ["node", "dist/index.js"]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 调试技巧
|
||||||
|
|
||||||
|
**关键:MCP 用 stdio 通信,不能用 `console.log`,会破坏协议流。**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 错误
|
||||||
|
console.log("debug");
|
||||||
|
// 正确
|
||||||
|
console.error("[DEBUG]", info);
|
||||||
|
// 更好
|
||||||
|
server.sendLoggingMessage({ level: "info", data: "处理中" });
|
||||||
|
```
|
||||||
|
|
||||||
|
**常见问题:**
|
||||||
|
|
||||||
|
| 症状 | 原因 | 解决 |
|
||||||
|
|------|------|------|
|
||||||
|
| 启动无响应 | transport 未连接 | 检查 `server.connect()` |
|
||||||
|
| Tool 不出现 | 注册在 connect 之后 | 先注册再 connect |
|
||||||
|
| AI 不调用 Tool | 描述不清晰 | 改善名称和描述 |
|
||||||
|
| 参数总错 | Schema 不明确 | 添加 `.describe()` |
|
||||||
|
| 调用超时 | 外部服务慢 | 加超时和缓存 |
|
||||||
|
|
||||||
|
**调试流程:** Inspector 验证基本功能 → 手动调用确认输入输出 → 连接真实 AI 客户端观察调用模式 → 根据实际行为调整设计。
|
||||||
|
|
||||||
|
## 10. 构建检查清单
|
||||||
|
|
||||||
|
### 设计
|
||||||
|
- [ ] 明确 Tools vs Resources vs Prompts 分工
|
||||||
|
- [ ] Tool 命名 `动词_名词`,描述说明用途和返回内容
|
||||||
|
- [ ] 参数简洁,可选参数有合理默认值
|
||||||
|
|
||||||
|
### 实现
|
||||||
|
- [ ] 输入用 Zod/Pydantic 校验
|
||||||
|
- [ ] 外部调用有 try/catch 和超时
|
||||||
|
- [ ] 错误返回 `isError: true` 并附可操作信息
|
||||||
|
- [ ] 不用 `console.log`(用 stderr 或 SDK 日志)
|
||||||
|
- [ ] 敏感数据走环境变量
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
- [ ] 核心逻辑有单元测试
|
||||||
|
- [ ] 有集成测试验证 MCP 协议交互
|
||||||
|
- [ ] 用 MCP Inspector 手动验证过
|
||||||
|
- [ ] 用真实 AI 客户端测试过
|
||||||
|
|
||||||
|
### 部署
|
||||||
|
- [ ] README 含安装和配置说明
|
||||||
|
- [ ] 提供客户端配置 JSON 示例
|
||||||
|
- [ ] 遵循 semver,无硬编码密钥
|
||||||
211
.codebuddy/skills/receiving-code-review/SKILL.md
Normal file
211
.codebuddy/skills/receiving-code-review/SKILL.md
Normal file
@ -0,0 +1,211 @@
|
|||||||
|
---
|
||||||
|
name: receiving-code-review
|
||||||
|
description: 收到代码审查反馈后、实施建议之前使用,尤其当反馈不明确或技术上有疑问时——需要技术严谨性和验证,而非敷衍附和或盲目执行
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [code-review]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 接收代码审查
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
代码审查需要的是技术评估,不是情绪表演。
|
||||||
|
|
||||||
|
**核心原则:** 先验证再实施。先提问再假设。技术正确性优先于社交舒适度。
|
||||||
|
|
||||||
|
## 响应模式
|
||||||
|
|
||||||
|
```
|
||||||
|
收到代码审查反馈时:
|
||||||
|
|
||||||
|
1. 阅读:完整阅读反馈,不急于反应
|
||||||
|
2. 理解:用自己的话复述需求(或提问)
|
||||||
|
3. 验证:对照代码库的实际情况检查
|
||||||
|
4. 评估:对这个代码库来说技术上合理吗?
|
||||||
|
5. 回应:技术性确认或有理有据的反驳
|
||||||
|
6. 实施:一次一项,逐个测试
|
||||||
|
```
|
||||||
|
|
||||||
|
## 禁止的回应
|
||||||
|
|
||||||
|
**绝不要说:**
|
||||||
|
- "你说得太对了!"(明确违反 CLAUDE.md 规定)
|
||||||
|
- "好观点!"/"反馈很棒!"(敷衍表演)
|
||||||
|
- "让我立刻实施"(在验证之前)
|
||||||
|
|
||||||
|
**应该这样做:**
|
||||||
|
- 复述技术需求
|
||||||
|
- 提出澄清性问题
|
||||||
|
- 如果审查意见有误,用技术理由反驳
|
||||||
|
- 直接动手做(行动胜于言辞)
|
||||||
|
|
||||||
|
## 处理不明确的反馈
|
||||||
|
|
||||||
|
```
|
||||||
|
如果有任何一项不明确:
|
||||||
|
停下来——先不要实施任何内容
|
||||||
|
就不明确的项目提出澄清
|
||||||
|
|
||||||
|
为什么:各项之间可能有关联。部分理解 = 错误实施。
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```
|
||||||
|
搭档:"修复第 1-6 项"
|
||||||
|
你理解 1、2、3、6。对 4、5 不确定。
|
||||||
|
|
||||||
|
❌ 错误做法:先实施 1、2、3、6,稍后再问 4、5
|
||||||
|
✅ 正确做法:"第 1、2、3、6 项我理解了。第 4 和第 5 项需要澄清后再动手。"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 按来源区别处理
|
||||||
|
|
||||||
|
### 来自搭档的反馈
|
||||||
|
- **可信赖** —— 理解后直接实施
|
||||||
|
- **仍然要问** 如果范围不明确
|
||||||
|
- **不要敷衍附和**
|
||||||
|
- **直接行动** 或给出技术性确认
|
||||||
|
|
||||||
|
### 来自外部审查者的反馈
|
||||||
|
```
|
||||||
|
实施之前:
|
||||||
|
1. 检查:对这个代码库来说技术上正确吗?
|
||||||
|
2. 检查:是否会破坏现有功能?
|
||||||
|
3. 检查:当前实现这样写是否有原因?
|
||||||
|
4. 检查:在所有平台/版本上都适用吗?
|
||||||
|
5. 检查:审查者了解完整上下文吗?
|
||||||
|
|
||||||
|
如果建议似乎有误:
|
||||||
|
用技术理由反驳
|
||||||
|
|
||||||
|
如果无法轻易验证:
|
||||||
|
说明情况:"没有 [X] 我无法验证这一点。我应该 [调查/提问/先做]?"
|
||||||
|
|
||||||
|
如果与搭档之前的决策冲突:
|
||||||
|
先停下来和搭档讨论
|
||||||
|
```
|
||||||
|
|
||||||
|
**搭档的原则:** "对外部反馈要持怀疑态度,但要仔细核实"
|
||||||
|
|
||||||
|
## YAGNI 检查——针对"专业化"功能建议
|
||||||
|
|
||||||
|
```
|
||||||
|
如果审查者建议"正规地实现":
|
||||||
|
在代码库中 grep 实际使用情况
|
||||||
|
|
||||||
|
如果没人用:"这个接口没有被调用。删掉它(YAGNI)?"
|
||||||
|
如果有人用:那就正规实现
|
||||||
|
```
|
||||||
|
|
||||||
|
**搭档的原则:** "你和审查者都对我负责。如果我们不需要这个功能,就不要加。"
|
||||||
|
|
||||||
|
## 实施顺序
|
||||||
|
|
||||||
|
```
|
||||||
|
对于包含多项的反馈:
|
||||||
|
1. 先澄清所有不明确的项
|
||||||
|
2. 然后按以下顺序实施:
|
||||||
|
- 阻塞性问题(崩溃、安全)
|
||||||
|
- 简单修复(拼写、导入)
|
||||||
|
- 复杂修复(重构、逻辑)
|
||||||
|
3. 逐个测试每项修复
|
||||||
|
4. 验证没有回归
|
||||||
|
```
|
||||||
|
|
||||||
|
## 何时反驳
|
||||||
|
|
||||||
|
在以下情况反驳:
|
||||||
|
- 建议会破坏现有功能
|
||||||
|
- 审查者缺少完整上下文
|
||||||
|
- 违反 YAGNI(功能没人用)
|
||||||
|
- 对当前技术栈来说技术上不正确
|
||||||
|
- 存在遗留/兼容性原因
|
||||||
|
- 与搭档的架构决策冲突
|
||||||
|
|
||||||
|
**如何反驳:**
|
||||||
|
- 用技术理由,不要带防御情绪
|
||||||
|
- 提出具体问题
|
||||||
|
- 引用可正常工作的测试/代码
|
||||||
|
- 如果涉及架构问题,让搭档参与
|
||||||
|
|
||||||
|
**如果觉得不方便当众反驳,暗号是:** "Strange things are afoot at the Circle K"
|
||||||
|
|
||||||
|
## 确认正确的反馈
|
||||||
|
|
||||||
|
当反馈确实正确时:
|
||||||
|
```
|
||||||
|
✅ "已修复。[简要说明改了什么]"
|
||||||
|
✅ "发现得好——[具体问题]。已在 [位置] 修复。"
|
||||||
|
✅ [直接修复并在代码中体现]
|
||||||
|
|
||||||
|
❌ "你说得太对了!"
|
||||||
|
❌ "好观点!"
|
||||||
|
❌ "感谢你发现了这个!"
|
||||||
|
❌ "感谢你 [任何内容]"
|
||||||
|
❌ 任何感谢的表达
|
||||||
|
```
|
||||||
|
|
||||||
|
**为什么不用感谢:** 行动说明一切。直接修复。代码本身就能表明你收到了反馈。
|
||||||
|
|
||||||
|
**如果你发现自己要写"感谢":** 删掉它。直接说明修复内容。
|
||||||
|
|
||||||
|
## 优雅地纠正自己的反驳
|
||||||
|
|
||||||
|
如果你反驳了但事后发现自己错了:
|
||||||
|
```
|
||||||
|
✅ "你是对的——我检查了 [X],确实 [Y]。正在实施。"
|
||||||
|
✅ "验证后确认你是对的。我最初的理解有误,因为 [原因]。正在修复。"
|
||||||
|
|
||||||
|
❌ 长篇道歉
|
||||||
|
❌ 为自己的反驳辩护
|
||||||
|
❌ 过度解释
|
||||||
|
```
|
||||||
|
|
||||||
|
如实陈述纠正,然后继续。
|
||||||
|
|
||||||
|
## 常见错误
|
||||||
|
|
||||||
|
| 错误 | 修正 |
|
||||||
|
|------|------|
|
||||||
|
| 敷衍附和 | 复述需求或直接行动 |
|
||||||
|
| 盲目实施 | 先对照代码库验证 |
|
||||||
|
| 批量实施不测试 | 一次一项,逐个测试 |
|
||||||
|
| 假设审查者一定对 | 检查是否会破坏现有功能 |
|
||||||
|
| 回避反驳 | 技术正确性 > 社交舒适度 |
|
||||||
|
| 部分理解就开始实施 | 先澄清所有项 |
|
||||||
|
| 无法验证却继续推进 | 说明限制,请求指导 |
|
||||||
|
|
||||||
|
## 真实案例
|
||||||
|
|
||||||
|
**敷衍附和(反面例子):**
|
||||||
|
```
|
||||||
|
审查者:"删除遗留代码"
|
||||||
|
❌ "你说得太对了!让我删掉它……"
|
||||||
|
```
|
||||||
|
|
||||||
|
**技术验证(正面例子):**
|
||||||
|
```
|
||||||
|
审查者:"删除遗留代码"
|
||||||
|
✅ "查了一下……构建目标是 10.15+,这个 API 需要 13+。向后兼容需要保留遗留代码。当前实现有错误的 bundle ID——修复它还是放弃 pre-13 支持?"
|
||||||
|
```
|
||||||
|
|
||||||
|
**YAGNI(正面例子):**
|
||||||
|
```
|
||||||
|
审查者:"实现完善的指标追踪,包括数据库、日期过滤、CSV 导出"
|
||||||
|
✅ "在代码库中 grep 了一下——没有任何地方调用这个接口。删掉它(YAGNI)?还是有我遗漏的调用?"
|
||||||
|
```
|
||||||
|
|
||||||
|
**不明确的项(正面例子):**
|
||||||
|
```
|
||||||
|
搭档:"修复第 1-6 项"
|
||||||
|
你理解 1、2、3、6。对 4、5 不确定。
|
||||||
|
✅ "第 1、2、3、6 项我理解了。第 4 和第 5 项需要澄清后再动手。"
|
||||||
|
```
|
||||||
|
|
||||||
|
## GitHub 评论回复
|
||||||
|
|
||||||
|
在 GitHub 上回复行内审查评论时,在评论线程中回复(`gh api repos/{owner}/{repo}/pulls/{pr}/comments/{id}/replies`),不要发顶层 PR 评论。
|
||||||
|
|
||||||
100
.codebuddy/skills/requesting-code-review/SKILL.md
Normal file
100
.codebuddy/skills/requesting-code-review/SKILL.md
Normal file
@ -0,0 +1,100 @@
|
|||||||
|
---
|
||||||
|
name: requesting-code-review
|
||||||
|
description: 完成任务、实现重要功能或合并前使用,用于验证工作成果是否符合要求
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [code-review]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 请求代码审查
|
||||||
|
|
||||||
|
派遣代码审查子代理,在问题扩散之前发现它们。审查者获得的是精心组织的评估上下文——绝不是你的会话历史。
|
||||||
|
|
||||||
|
**核心原则:** 早审查,勤审查。
|
||||||
|
|
||||||
|
## 何时请求审查
|
||||||
|
|
||||||
|
**必须审查:**
|
||||||
|
- 子代理驱动开发中每个任务完成后
|
||||||
|
- 完成重要功能后
|
||||||
|
- 合并到 main 之前
|
||||||
|
|
||||||
|
**可选但有价值:**
|
||||||
|
- 卡住时(换个视角)
|
||||||
|
- 重构之前(建立基线)
|
||||||
|
- 修复复杂 bug 之后
|
||||||
|
|
||||||
|
## 如何请求
|
||||||
|
|
||||||
|
**1. 获取 git SHA:**
|
||||||
|
```bash
|
||||||
|
BASE_SHA=$(git rev-parse HEAD~1) # 或 origin/main
|
||||||
|
HEAD_SHA=$(git rev-parse HEAD)
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. 派遣代码审查子代理:**
|
||||||
|
|
||||||
|
使用 Task 工具,指定 `general-purpose` 类型,填写 `code-reviewer.md` 中的模板
|
||||||
|
|
||||||
|
**占位符说明:**
|
||||||
|
- `{DESCRIPTION}` - 你刚完成的内容简要说明
|
||||||
|
- `{PLAN_OR_REQUIREMENTS}` - 预期功能
|
||||||
|
- `{BASE_SHA}` - 起始提交
|
||||||
|
- `{HEAD_SHA}` - 结束提交
|
||||||
|
|
||||||
|
**3. 处理反馈:**
|
||||||
|
- Critical 问题立即修复
|
||||||
|
- Important 问题在继续之前修复
|
||||||
|
- Minor 问题记录下来稍后处理
|
||||||
|
- 如果审查者有误,用技术理由反驳
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
```
|
||||||
|
[刚完成任务 2:添加验证功能]
|
||||||
|
|
||||||
|
你:让我在继续之前请求代码审查。
|
||||||
|
|
||||||
|
BASE_SHA=$(git log --oneline | grep "Task 1" | head -1 | awk '{print $1}')
|
||||||
|
HEAD_SHA=$(git rev-parse HEAD)
|
||||||
|
|
||||||
|
[派遣代码审查子代理]
|
||||||
|
DESCRIPTION: 添加了 verifyIndex() 和 repairIndex(),支持 4 种问题类型
|
||||||
|
PLAN_OR_REQUIREMENTS: docs/superpowers/plans/deployment-plan.md 中的任务 2
|
||||||
|
BASE_SHA: a7981ec
|
||||||
|
HEAD_SHA: 3df7661
|
||||||
|
|
||||||
|
[子代理返回]:
|
||||||
|
优点:架构清晰,测试真实
|
||||||
|
问题:
|
||||||
|
Important:缺少进度指示器
|
||||||
|
Minor:报告间隔使用了魔法数字 (100)
|
||||||
|
评估:可以继续
|
||||||
|
|
||||||
|
你:[修复进度指示器]
|
||||||
|
[继续任务 3]
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见的合理化借口
|
||||||
|
|
||||||
|
| 借口 | 现实 |
|
||||||
|
|------|------|
|
||||||
|
| "我自己看一下 diff 就行了,不用专门派审查者" | 你是协调者——在自己的会话里读 diff 会烧掉你继续推进工作所需的上下文窗口。派一个审查子智能体:diff 和评估过程都待在它的上下文里,只有结论回到你这里。 |
|
||||||
|
| "审查者需要我的全部会话历史才能理解这次改动" | 给它精心组织的上下文,绝不给会话历史。这样审查者才会盯着工作成果,而不是你的思考过程。 |
|
||||||
|
|
||||||
|
## 红线
|
||||||
|
|
||||||
|
**绝不要:**
|
||||||
|
- 因为"很简单"就跳过审查
|
||||||
|
- 忽略 Critical 问题
|
||||||
|
- 带着未修复的 Important 问题继续推进
|
||||||
|
- 对合理的技术反馈进行争辩
|
||||||
|
|
||||||
|
**如果审查者有误:**
|
||||||
|
- 用技术理由反驳
|
||||||
|
- 展示证明其可行的代码/测试
|
||||||
|
- 要求澄清
|
||||||
|
|
||||||
|
参见模板:requesting-code-review/code-reviewer.md
|
||||||
174
.codebuddy/skills/requesting-code-review/code-reviewer.md
Normal file
174
.codebuddy/skills/requesting-code-review/code-reviewer.md
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
# 代码审查员提示模板
|
||||||
|
|
||||||
|
派遣代码审查员子代理时使用此模板。
|
||||||
|
|
||||||
|
**用途:** 在工作成果扩散到更多工作之前,对照需求和代码质量标准做一次审查。
|
||||||
|
|
||||||
|
```
|
||||||
|
Task tool(general-purpose):
|
||||||
|
description: "审查代码改动"
|
||||||
|
prompt: |
|
||||||
|
你是一名资深代码审查员,精通软件架构、设计模式与最佳实践。
|
||||||
|
你的工作是对照计划或需求审查已完成的工作,在问题扩散之前发现它们。
|
||||||
|
|
||||||
|
## 实现内容
|
||||||
|
|
||||||
|
{DESCRIPTION}
|
||||||
|
|
||||||
|
## 需求 / 计划
|
||||||
|
|
||||||
|
{PLAN_OR_REQUIREMENTS}
|
||||||
|
|
||||||
|
## 待审查的 Git 范围
|
||||||
|
|
||||||
|
**Base:** {BASE_SHA}
|
||||||
|
**Head:** {HEAD_SHA}
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git diff --stat {BASE_SHA}..{HEAD_SHA}
|
||||||
|
git diff {BASE_SHA}..{HEAD_SHA}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 只读审查
|
||||||
|
|
||||||
|
你的审查在这个检出上是**只读**的。不要以任何方式改动工作区、索引、HEAD 或分支状态。用 `git show`、`git diff`、`git log` 这类命令查看历史。如果你需要某个其他版本的工作副本,把它检出到一个独立的临时目录(例如 `git worktree add /tmp/review-[SHA] [SHA]`)—— 绝不要移动这个检出上的 HEAD。
|
||||||
|
|
||||||
|
## 你不派发子代理
|
||||||
|
|
||||||
|
这次审查全部由你自己做完。绝不为了审查 diff 的一部分而派生子代理,也绝不为了「再要一个意见」而派生另一个审查者。这套流程已经给了这份工作应有的每一个审查席位;你派生出来的审查者只是按全价重复其中一个,而它的结论不作数。如果 diff 大到一遍看不完,就自己分几遍看,并在报告里说明。
|
||||||
|
|
||||||
|
## 检查内容
|
||||||
|
|
||||||
|
**计划对齐:**
|
||||||
|
- 实现是否匹配计划 / 需求?
|
||||||
|
- 偏差是有道理的改进,还是有问题的偏离?
|
||||||
|
- 计划中的所有功能都到位了吗?
|
||||||
|
|
||||||
|
**代码质量:**
|
||||||
|
- 关注点分离清晰吗?
|
||||||
|
- 错误处理到位吗?
|
||||||
|
- 该有类型安全的地方有吗?
|
||||||
|
- DRY 但没有过早抽象?
|
||||||
|
- 边界情况处理了吗?
|
||||||
|
|
||||||
|
**架构:**
|
||||||
|
- 设计决策合理吗?
|
||||||
|
- 可扩展性和性能合理吗?
|
||||||
|
- 有没有安全隐患?
|
||||||
|
- 与周围代码集成是否干净?
|
||||||
|
|
||||||
|
**测试:**
|
||||||
|
- 测试验证的是真实行为,不是 mock?
|
||||||
|
- 边界情况覆盖了吗?
|
||||||
|
- 该有集成测试的地方有吗?
|
||||||
|
- 所有测试都通过吗?
|
||||||
|
|
||||||
|
**生产就绪:**
|
||||||
|
- 如果改了 schema,有迁移策略吗?
|
||||||
|
- 考虑了向后兼容吗?
|
||||||
|
- 文档完整吗?
|
||||||
|
- 没有明显 bug?
|
||||||
|
|
||||||
|
## 校准标准
|
||||||
|
|
||||||
|
按实际严重程度分类。不是所有问题都是 Critical。
|
||||||
|
在列出问题之前先认可做得好的地方——准确的肯定能让实现者
|
||||||
|
更愿意接受后续的反馈。
|
||||||
|
|
||||||
|
如果发现与计划有重大偏差,明确标出,让实现者确认这个偏差
|
||||||
|
是不是有意为之。如果问题出在计划本身而不是实现,也要说清楚。
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
### 优点
|
||||||
|
[哪些地方做得好?具体一点。]
|
||||||
|
|
||||||
|
### 问题
|
||||||
|
|
||||||
|
#### Critical(必须修复)
|
||||||
|
[bug、安全问题、数据丢失风险、功能损坏]
|
||||||
|
|
||||||
|
#### Important(应该修复)
|
||||||
|
[架构问题、缺失功能、错误处理不到位、测试漏洞]
|
||||||
|
|
||||||
|
#### Minor(锦上添花)
|
||||||
|
[代码风格、优化机会、文档润色]
|
||||||
|
|
||||||
|
每个问题包含:
|
||||||
|
- File:line 引用
|
||||||
|
- 哪里有问题
|
||||||
|
- 为什么重要
|
||||||
|
- 怎么修(如果不明显)
|
||||||
|
|
||||||
|
### 建议
|
||||||
|
[关于代码质量、架构或流程的改进建议]
|
||||||
|
|
||||||
|
### 评估
|
||||||
|
|
||||||
|
**可以合并吗?** [是 | 否 | 修完再合]
|
||||||
|
|
||||||
|
**理由:** [1-2 句技术评估]
|
||||||
|
|
||||||
|
## 关键规则
|
||||||
|
|
||||||
|
**要做:**
|
||||||
|
- 按实际严重程度分类
|
||||||
|
- 具体(file:line,别含糊)
|
||||||
|
- 解释为什么这个问题重要
|
||||||
|
- 认可优点
|
||||||
|
- 给出明确判断
|
||||||
|
|
||||||
|
**不要:**
|
||||||
|
- 没检查就说"看起来 OK"
|
||||||
|
- 把小事标成 Critical
|
||||||
|
- 对没真看过的代码给反馈
|
||||||
|
- 含糊其辞("改进错误处理")
|
||||||
|
- 回避给出明确判断
|
||||||
|
```
|
||||||
|
|
||||||
|
**占位符说明:**
|
||||||
|
- `{DESCRIPTION}` —— 已构建内容的简要说明
|
||||||
|
- `{PLAN_OR_REQUIREMENTS}` —— 预期功能(计划文件路径、任务文本或需求)
|
||||||
|
- `{BASE_SHA}` —— 起始 commit
|
||||||
|
- `{HEAD_SHA}` —— 结束 commit
|
||||||
|
|
||||||
|
**审查员返回:** 优点、问题(Critical / Important / Minor)、建议、评估
|
||||||
|
|
||||||
|
## 输出示例
|
||||||
|
|
||||||
|
```
|
||||||
|
### 优点
|
||||||
|
- 数据库 schema 干净,迁移规范(db.ts:15-42)
|
||||||
|
- 测试覆盖全面(18 个测试,所有边界情况都覆盖)
|
||||||
|
- 错误处理有 fallback,做得很好(summarizer.ts:85-92)
|
||||||
|
|
||||||
|
### 问题
|
||||||
|
|
||||||
|
#### Important
|
||||||
|
1. **CLI wrapper 缺少帮助文本**
|
||||||
|
- File: index-conversations:1-31
|
||||||
|
- 问题:没有 --help flag,用户不会发现 --concurrency
|
||||||
|
- 修复:加 --help case 含使用示例
|
||||||
|
|
||||||
|
2. **缺少日期校验**
|
||||||
|
- File: search.ts:25-27
|
||||||
|
- 问题:无效日期会静默返回空结果
|
||||||
|
- 修复:校验 ISO 格式,抛错并附示例
|
||||||
|
|
||||||
|
#### Minor
|
||||||
|
1. **进度指示**
|
||||||
|
- File: indexer.ts:130
|
||||||
|
- 问题:长操作没有 "X of Y" 计数
|
||||||
|
- 影响:用户不知道要等多久
|
||||||
|
|
||||||
|
### 建议
|
||||||
|
- 加进度上报改善用户体验
|
||||||
|
- 考虑用配置文件管理排除项目(提升可移植性)
|
||||||
|
|
||||||
|
### 评估
|
||||||
|
|
||||||
|
**可以合并吗:修完再合**
|
||||||
|
|
||||||
|
**理由:** 核心实现扎实,架构和测试都很好。Important 问题(帮助文本、
|
||||||
|
日期校验)很容易修,且不影响核心功能。
|
||||||
|
```
|
||||||
355
.codebuddy/skills/subagent-driven-development/SKILL.md
Normal file
355
.codebuddy/skills/subagent-driven-development/SKILL.md
Normal file
@ -0,0 +1,355 @@
|
|||||||
|
---
|
||||||
|
name: subagent-driven-development
|
||||||
|
description: 当在当前会话中执行包含独立任务的实现计划时使用
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [agents, development]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 子智能体驱动开发
|
||||||
|
|
||||||
|
通过为每个任务分派一个全新的实现子智能体来执行计划:每个任务完成后做一次任务审查(规格合规性 + 代码质量),全部任务结束后再做一次覆盖整个分支的宽范围审查。
|
||||||
|
|
||||||
|
**为什么用子智能体:** 你把任务委派给具有隔离上下文的专用智能体。通过精心设计它们的指令和上下文,确保它们专注并成功完成任务。它们绝不应继承你会话的上下文或历史记录——你要精确构造它们所需的一切。这样也能为你自己保留用于协调工作的上下文。
|
||||||
|
|
||||||
|
**核心原则:** 每个任务一个全新子智能体 + 任务审查(规格 + 质量)+ 结尾宽范围审查 = 高质量、快速迭代
|
||||||
|
|
||||||
|
**旁白:** 工具调用之间最多说一句简短的旁白——进度账本和工具结果本身就是记录。
|
||||||
|
|
||||||
|
**持续执行:** 不要在任务之间停下来向你的人类伙伴确认。不间断地执行计划里的所有任务。唯一该停下的理由是:你无法解决的 BLOCKED 状态、确实妨碍推进的歧义,或所有任务已完成。"我该继续吗?"之类的询问和进度小结都在浪费他们的时间——他们让你执行计划,那就执行。
|
||||||
|
|
||||||
|
**做裁决,不要停摆。** 一个正在跑的计划不等人。冲突、歧义、计划缺陷、你本来想申请突破的上限——你自己定。规格是有约束力的权威,计划是它的论证,两者都答不上来的部分由你的判断来定。每个决定都以 `Ruling: <你决定了什么> — <为什么> — <如果错了代价是什么>` 记进账本,然后继续。一个错误的裁决,代价是你人类伙伴看得见、也撤得掉的返工;一个停在问题上的会话,代价是他们的一整天,而且什么也换不来。
|
||||||
|
|
||||||
|
只有四件事会让你停下,也只有这四件:不可逆或破坏性的操作;涉及安全的动作;这个工作树之外、按惯例应当先问一声的副作用(合并、推送到共享分支、发布);以及一个坏到每条前进路径都只能靠猜的计划。遇到这四类,停下来问。
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
```dot
|
||||||
|
digraph when_to_use {
|
||||||
|
"有实现计划?" [shape=diamond];
|
||||||
|
"任务基本独立?" [shape=diamond];
|
||||||
|
"留在当前会话?" [shape=diamond];
|
||||||
|
"subagent-driven-development" [shape=box];
|
||||||
|
"executing-plans" [shape=box];
|
||||||
|
"手动执行或先头脑风暴" [shape=box];
|
||||||
|
|
||||||
|
"有实现计划?" -> "任务基本独立?" [label="是"];
|
||||||
|
"有实现计划?" -> "手动执行或先头脑风暴" [label="否"];
|
||||||
|
"任务基本独立?" -> "留在当前会话?" [label="是"];
|
||||||
|
"任务基本独立?" -> "手动执行或先头脑风暴" [label="否 - 紧密耦合"];
|
||||||
|
"留在当前会话?" -> "subagent-driven-development" [label="是"];
|
||||||
|
"留在当前会话?" -> "executing-plans" [label="否 - 并行会话"];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**与 Executing Plans(并行会话)的对比:**
|
||||||
|
- 同一会话(无上下文切换)
|
||||||
|
- 每个任务全新子智能体(无上下文污染)
|
||||||
|
- 每个任务后做审查(规格合规性 + 代码质量),结尾做宽范围审查
|
||||||
|
- 更快的迭代(任务间无需人工介入)
|
||||||
|
|
||||||
|
## 流程
|
||||||
|
|
||||||
|
```dot
|
||||||
|
digraph process {
|
||||||
|
rankdir=TB;
|
||||||
|
|
||||||
|
subgraph cluster_per_task {
|
||||||
|
label="每个任务";
|
||||||
|
"分派实现子智能体 (./implementer-prompt.md)" [shape=box];
|
||||||
|
"实现者有疑问?" [shape=diamond];
|
||||||
|
"回答问题,提供上下文" [shape=box];
|
||||||
|
"实现者实现、测试、提交、自审" [shape=box];
|
||||||
|
"生成审查包,分派任务审查者 (./task-reviewer-prompt.md)" [shape=box];
|
||||||
|
"规格 ✅ 且质量通过?" [shape=diamond];
|
||||||
|
"发现与计划原文冲突?" [shape=diamond];
|
||||||
|
"对冲突作出裁决, 把裁决记进账本" [shape=box];
|
||||||
|
"第 R/5 轮修复: R≤3 唤回原实现者; R≥4 换全新实现者 + 更强模型" [shape=box];
|
||||||
|
"分派定向复审 (./re-review-prompt.md)" [shape=box];
|
||||||
|
"所有发现都已解决?" [shape=diamond];
|
||||||
|
"R = 5?" [shape=diamond];
|
||||||
|
"逐条裁定未解决的发现" [shape=box];
|
||||||
|
"存在承重的发现?" [shape=diamond];
|
||||||
|
"裁决并继续; 只有每条路都靠猜时才停" [shape=box];
|
||||||
|
"把发现连同裁定搁置进账本" [shape=box];
|
||||||
|
"往账本追加完成行,标记待办完成" [shape=box];
|
||||||
|
}
|
||||||
|
|
||||||
|
"准备: 工作树、查账本、读计划、起飞前审查" [shape=box];
|
||||||
|
"还有任务?" [shape=diamond];
|
||||||
|
"分派最终代码审查者 (../requesting-code-review/code-reviewer.md)" [shape=box];
|
||||||
|
"最终审查有发现? 一次修复分派、一次定向复审、裁定残留项" [shape=box];
|
||||||
|
"最终审查干净: 删除本计划的工作区" [shape=box];
|
||||||
|
"使用 finishing-a-development-branch" [shape=box style=filled fillcolor=lightgreen];
|
||||||
|
|
||||||
|
"准备: 工作树、查账本、读计划、起飞前审查" -> "分派实现子智能体 (./implementer-prompt.md)";
|
||||||
|
"分派实现子智能体 (./implementer-prompt.md)" -> "实现者有疑问?";
|
||||||
|
"实现者有疑问?" -> "回答问题,提供上下文" [label="是"];
|
||||||
|
"回答问题,提供上下文" -> "实现者实现、测试、提交、自审";
|
||||||
|
"实现者有疑问?" -> "实现者实现、测试、提交、自审" [label="否"];
|
||||||
|
"实现者实现、测试、提交、自审" -> "生成审查包,分派任务审查者 (./task-reviewer-prompt.md)";
|
||||||
|
"生成审查包,分派任务审查者 (./task-reviewer-prompt.md)" -> "规格 ✅ 且质量通过?";
|
||||||
|
"规格 ✅ 且质量通过?" -> "往账本追加完成行,标记待办完成" [label="是"];
|
||||||
|
"规格 ✅ 且质量通过?" -> "发现与计划原文冲突?" [label="否"];
|
||||||
|
"发现与计划原文冲突?" -> "对冲突作出裁决, 把裁决记进账本" [label="是"];
|
||||||
|
"对冲突作出裁决, 把裁决记进账本" -> "第 R/5 轮修复: R≤3 唤回原实现者; R≥4 换全新实现者 + 更强模型";
|
||||||
|
"发现与计划原文冲突?" -> "第 R/5 轮修复: R≤3 唤回原实现者; R≥4 换全新实现者 + 更强模型" [label="否"];
|
||||||
|
"第 R/5 轮修复: R≤3 唤回原实现者; R≥4 换全新实现者 + 更强模型" -> "分派定向复审 (./re-review-prompt.md)";
|
||||||
|
"分派定向复审 (./re-review-prompt.md)" -> "所有发现都已解决?";
|
||||||
|
"所有发现都已解决?" -> "往账本追加完成行,标记待办完成" [label="是"];
|
||||||
|
"所有发现都已解决?" -> "R = 5?" [label="否"];
|
||||||
|
"R = 5?" -> "第 R/5 轮修复: R≤3 唤回原实现者; R≥4 换全新实现者 + 更强模型" [label="否 - 进入下一轮"];
|
||||||
|
"R = 5?" -> "逐条裁定未解决的发现" [label="是 - 熔断触发"];
|
||||||
|
"逐条裁定未解决的发现" -> "存在承重的发现?";
|
||||||
|
"存在承重的发现?" -> "裁决并继续; 只有每条路都靠猜时才停" [label="是"];
|
||||||
|
"存在承重的发现?" -> "把发现连同裁定搁置进账本" [label="否"];
|
||||||
|
"把发现连同裁定搁置进账本" -> "往账本追加完成行,标记待办完成";
|
||||||
|
"往账本追加完成行,标记待办完成" -> "还有任务?";
|
||||||
|
"还有任务?" -> "分派实现子智能体 (./implementer-prompt.md)" [label="是"];
|
||||||
|
"还有任务?" -> "分派最终代码审查者 (../requesting-code-review/code-reviewer.md)" [label="否"];
|
||||||
|
"分派最终代码审查者 (../requesting-code-review/code-reviewer.md)" -> "最终审查有发现? 一次修复分派、一次定向复审、裁定残留项";
|
||||||
|
"最终审查有发现? 一次修复分派、一次定向复审、裁定残留项" -> "最终审查干净: 删除本计划的工作区";
|
||||||
|
"最终审查干净: 删除本计划的工作区" -> "使用 finishing-a-development-branch";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 准备
|
||||||
|
|
||||||
|
确保工作发生在一个隔离的工作区里:用 using-git-worktrees 创建一个,或者核实已有的那个。没有你人类伙伴的明确同意,绝不在 main/master 分支上开始实现。
|
||||||
|
|
||||||
|
会话记忆无法在上下文压缩(compaction)中存活。在真实会话里,丢失了位置的控制者曾重新分派整段已经完成的任务序列——这是观察到的最昂贵的失败。把进度记在一个账本文件里,而不只是记在待办里。
|
||||||
|
|
||||||
|
- **每个计划拥有自己的工作区:** 技能启动时,运行本技能的 `scripts/sdd-workspace PLAN_FILE`——它会打印这个计划专属的、被 git 忽略的目录(`<repo-root>/.superpowers/sdd/<计划文件名>/`),**本计划**的一切产物都放在那里:账本、简报、报告、审查包。别的计划的目录不属于你,不读也不写。
|
||||||
|
- 到 `<工作区>/progress.md` 查本计划的账本。如果它的第一行点名的是你的计划文件,那么带有 `Task <N>: complete` 行的任务就是**已完成**——不要重新分派它们;从第一个没有该行的任务处继续。如果某个任务的最后一行是一轮修复,说明它正卡在修复循环中:从下一轮继续。如果账本第一行点名的是**另一个**计划文件——或者你在旧的扁平路径 `.superpowers/sdd/progress.md` 发现了一个游离的账本——那是别人的进度:原地别动,另起你自己的新账本。
|
||||||
|
- 创建账本时,把它的身份写在第一行:`# SDD ledger — plan: <计划文件路径>`。
|
||||||
|
- 这个账本是你的恢复地图:它点名的那些提交,即使你的上下文已经不记得创建过它们,也确实存在于 git 中。压缩之后,相信账本和 `git log`,而不是你自己的记忆。
|
||||||
|
- `git clean -fdx` 会毁掉这个工作区(它是被 git 忽略的临时文件);万一发生了,就从 `git log` 恢复。
|
||||||
|
|
||||||
|
把计划**读一遍**,记下它的上下文和全局约束,并为每个任务建一条待办。如果计划点名了一份规格(Spec),把规格也读了:规格是计划据以论证的权威,计划内部的冲突要拿它来裁。计划里找不到可达的规格,就在账本里记一条说明——没有规格作出的裁决都是临时的。
|
||||||
|
|
||||||
|
在分派任务 1 之前,把计划通扫一遍找冲突,边查边把你查过的东西写下来:
|
||||||
|
|
||||||
|
- 互相矛盾的任务,或与计划的"全局约束"矛盾的任务
|
||||||
|
- 计划明确要求、但审查标准会判为缺陷的东西(比如一个什么都不断言的测试、一整块逻辑的逐字复制)
|
||||||
|
|
||||||
|
这次扫描的产出是一张**表**,不是一句结论。每一对共用文件或接口的任务占一行:这两个任务、一个产出的东西对上另一个消费的东西、以及你发现了什么。每个任务再占一行:它自己的文本是否自洽——它规定的测试对上它规定的代码,它创建的文件对上它后面又要碰的文件。没有这些行的"扫描是干净的",不算你真扫过。
|
||||||
|
|
||||||
|
把这张表写进账本。在执行开始之前就对你找到的每一条作出裁决——每条发现都并列上要求它的那段计划原文——并把每条裁决记进账本。如果扫描是干净的,就不要多说,直接开始。对它翻出的每个冲突作出裁决——规格是有约束力的权威,计划是它的论证——把裁决记在那一行旁边,然后分派任务 1。审查循环仍然是那些只有在实现中才浮现的冲突的兜底网。
|
||||||
|
|
||||||
|
## 模型选择
|
||||||
|
|
||||||
|
在能胜任的前提下,为每个角色选用最弱的模型,以节省成本、提升速度。
|
||||||
|
|
||||||
|
**机械性实现任务**(孤立的函数、清晰的规格、1-2 个文件):用快而便宜的模型。计划写得好时,大多数实现任务都是机械性的。
|
||||||
|
|
||||||
|
**集成与判断类任务**(跨文件协调、模式匹配、调试):用标准模型。
|
||||||
|
|
||||||
|
**架构与设计类任务**:用可用的最强模型。覆盖整个分支的最终审查就属于这一类——用可用的最强模型去分派它,不要用会话默认模型。
|
||||||
|
|
||||||
|
**审查任务**:用同样的判断来选模型,并按 diff 的体量、复杂度和风险来缩放。一个小的机械性 diff 不需要最强模型;一个微妙的并发改动需要。小修复 diff 的定向复审用便宜到中档的层级即可。
|
||||||
|
|
||||||
|
**修复循环的升级(第 4-5 轮)**:用比那个卡住了的实现者**至少高一档**的模型。
|
||||||
|
|
||||||
|
**分派子智能体时永远显式指定模型。** 省略模型会继承你会话的模型——往往是最强也最贵的那个——这会悄无声息地让本节的努力全部失效。
|
||||||
|
|
||||||
|
**轮次数比 token 单价更要紧。** 墙钟时间和上下文成本是随子智能体花掉多少轮次而增长的,而最便宜的模型在多步工作上经常要花 2-3 倍轮次——总账反而更贵。审查者、以及依据散文式描述工作的实现者,都以中档模型为下限。当任务的计划原文里已经包含了要写的完整代码时,实现就是抄写加测试:这种实现者用最便宜的层级。单文件的机械性修复也用最便宜的层级。
|
||||||
|
|
||||||
|
**任务复杂度信号(实现类任务):**
|
||||||
|
- 涉及 1-2 个文件且规格完整 → 便宜模型
|
||||||
|
- 涉及多个文件且有集成考量 → 标准模型
|
||||||
|
- 需要设计判断或对代码库的广泛理解 → 最强模型
|
||||||
|
|
||||||
|
## 任务循环
|
||||||
|
|
||||||
|
**把同形状的小活打包。** 当计划里列了好几个任务,每个都是同一类的小改动——同样的一行修复、同样的常量替换、同样的字段新增,只是散在不同文件里——不要一个任务派一个子智能体。写**一份**分派简报,把每个文件和它的改动都列上,整批交给同一个子智能体,把它的 diff 当作一个单位来审查。一个任务一次分派,留给那些需要自己的判断、自己的测试、自己的审查面的活。
|
||||||
|
|
||||||
|
你粘进分派提示词里的一切、以及子智能体打印回来的一切,都会在本次会话余下的时间里常驻你的上下文,并且在之后每一轮被重新读一遍。**产物要用文件来交接。**
|
||||||
|
|
||||||
|
**等待已分派的子智能体:** 绝不用短超时去轮询等待接口,也绝不干坐在一次沉默的、没有上限的等待里。只要你手上还有本地活可干——更新账本、打包下一次审查、读报告——就接着干;子智能体的结果会自己送到。当你真的空了,就分段等待,每段有上限(在你的平台允许的范围内,五到十分钟),两段之间发一行状态,并对账你还活着的子智能体:把它们列出来,追一下那些干完了却没报告的。分段等待几乎保留了长等待的全部效率,同时保证一个卡住或丢失的子智能体在几分钟内就被发现,而不是拖到会话最后。
|
||||||
|
|
||||||
|
### 1. 分派实现者
|
||||||
|
|
||||||
|
分派之前记录 BASE(`git rev-parse HEAD`)——审查包和各轮修复的 diff 都要用它。
|
||||||
|
|
||||||
|
- **任务简报:** 分派实现者之前,运行本技能的 `scripts/task-brief PLAN_FILE N`——它把该任务的完整文本抽取到一个唯一命名的文件并打印路径。组织你的分派,让这份简报保持为需求的唯一来源。你的分派应包含:(1) 一行说明这个任务在项目中的位置;(2) 简报路径,引入语为"先读这个——它是你的需求,里面有要逐字使用的精确取值";(3) 简报无从知晓的、来自前序任务的接口和决策;(4) 你对简报中注意到的任何歧义的裁定;(5) 报告文件路径和报告契约。精确取值(数字、魔法字符串、签名、测试用例)只出现在简报里。**绝不**让子智能体去读整个计划文件。
|
||||||
|
- **报告文件:** 实现者的报告文件按简报来命名(简报 `…/task-N-brief.md` → 报告 `…/task-N-report.md`),并写进分派提示词。实现者把完整报告写在那里,只回传状态、提交、一行测试小结和疑虑。
|
||||||
|
- 一个分派提示词描述的是**一个任务**,不是会话的历史。不要把累积的前序任务小结("任务 1-3 之后的状态")粘进后面的分派——真实会话里曾出现过 42k 字符的分派,其中 99% 是粘贴的历史。一个全新的子智能体需要的是:它的任务、它要碰的接口、以及全局约束。别无其他。
|
||||||
|
- 如果前面某个任务把一条发现搁置在本任务要碰的区域,就在分派里带上指向那条账本记录的指针。
|
||||||
|
- 分派本身携带**不派子智能体**的契约(它就写在实现者模板里):实现者绝不分派子智能体——不派帮手,更不派审查者。审查由你在报告之后送达。在真实会话里,工作者自己派出的每一个审查者,都在重复控制者本来就会分派的那次任务审查——每个任务白白多出一个完整的审查席位。
|
||||||
|
- **记下分派结果里实现者的智能体身份**——第 1-3 轮修复要唤回这个智能体。
|
||||||
|
- 绝不并行分派多个实现子智能体(会冲突)。
|
||||||
|
|
||||||
|
模板:[implementer-prompt.md](implementer-prompt.md)
|
||||||
|
|
||||||
|
### 2. 处理报告
|
||||||
|
|
||||||
|
实现子智能体会回传四种状态之一。分别处理:
|
||||||
|
|
||||||
|
**DONE:** 生成审查包(在本技能目录下运行 `scripts/review-package PLAN_FILE BASE HEAD`——它会打印出自己写入的那个唯一文件路径;BASE 是你在分派实现者之前记录下来的那个提交——**绝不用** `HEAD~1`,那会悄悄丢掉多提交任务里除最后一个之外的所有提交),然后把打印出的路径交给任务审查者去分派。
|
||||||
|
|
||||||
|
**DONE_WITH_CONCERNS:** 实现者完成了工作但提出了疑虑。继续之前先读这些疑虑。如果疑虑关乎正确性或范围,在审查之前先处理掉。如果只是观察(比如"这个文件变大了"),记下来,继续走审查。
|
||||||
|
|
||||||
|
**NEEDS_CONTEXT:** 实现者需要没被提供的信息。补上缺失的上下文并重新分派。
|
||||||
|
|
||||||
|
**BLOCKED:** 实现者无法完成任务。评估这个阻塞:
|
||||||
|
1. 如果是上下文问题,补充上下文并用同一个模型重新分派
|
||||||
|
2. 如果任务需要更多推理,用更强的模型重新分派
|
||||||
|
3. 如果任务太大,拆成更小的块
|
||||||
|
4. 如果是计划本身错了,对这个更正作出裁决,记进账本,并把裁决带进重新分派的提示词里
|
||||||
|
|
||||||
|
**绝不**忽视一次上报,也**绝不**在什么都没改的情况下强迫同一个模型重试。如果实现者说它卡住了,那就一定有东西需要改变。
|
||||||
|
|
||||||
|
如果实现者提问——不论是开始前还是任务中途——清楚完整地回答,需要时补充上下文,不要催着它进入实现。
|
||||||
|
|
||||||
|
### 3. 审查任务
|
||||||
|
|
||||||
|
逐任务审查是**任务范围内的关卡**。宽范围审查只做一次,在最终的整分支审查那里。绝不跳过任务审查,也绝不接受一份缺少任一结论的报告——规格合规性**和**任务质量两者都必须有。实现者的自审永远不能替代任务审查;两者都需要。
|
||||||
|
|
||||||
|
- **把 diff 作为文件交给审查者:** 运行本技能的 `scripts/review-package PLAN_FILE BASE HEAD`,把它打印出的文件路径交给审查者(若没有 bash:对该区间跑 `git log --oneline`、`git diff --stat`、`git diff -U10`,重定向到一个唯一命名的文件)。这些输出永远不会进入你自己的上下文,而审查者在一次 Read 调用里就能看到提交列表、stat 摘要和带上下文的完整 diff。用你在分派实现者之前记录下的 BASE——**绝不用** `HEAD~1`,那会悄悄截断多提交任务。**绝不**在没有 diff 文件的情况下分派任务审查者。
|
||||||
|
- **审查者的输入:** 任务审查者拿到三个路径——同一份简报文件、报告文件、审查包——外加约束该任务的全局约束。
|
||||||
|
- 你交给审查者的全局约束块是它的**注意力透镜**。从计划的"全局约束"一节或规格里**逐字**抄下有约束力的需求:精确的取值、精确的格式,以及组件之间被明确规定的关系("与 X 相同的布局"、"匹配 Y")。审查者的模板里已经带了流程规则(YAGNI、测试卫生、审查方法)——约束块是用来装**这个项目**的规格所要求的东西的。
|
||||||
|
- 不要在没有具体的、任务专属的理由时,加上"检查所有用法"或"有用的话跑一下竞态测试"这类开放式指令
|
||||||
|
- 不要让审查者重跑实现者已经在同一份代码上跑过的测试——实现者的报告承载着测试证据
|
||||||
|
- **不要替审查者预先给发现定性**——绝不指示审查者忽略或不要标记某个具体问题。如果你认为某条发现会是误报,让审查者提出来,然后在审查循环里裁定它。如果你正在写的提示词里出现了"不要标记"、"不要把 X 当缺陷"、"顶多按 Minor 处理"、"计划选择了"——停下:你正在预先定性,而且通常是为了让自己少走一轮审查循环。
|
||||||
|
|
||||||
|
任务审查者可能报告"⚠️ 无法从 diff 核实"的条目——那些活在未改动代码里、或者跨任务的需求。这些不阻塞审查的其余部分,但在标记任务完成之前**你必须自己逐条解决它们**:你掌握着审查者所缺的计划和跨任务上下文。如果你确认某一条是真实的缺口,就把它当作规格审查失败来处理——它和其他发现一起进入修复循环。
|
||||||
|
|
||||||
|
模板:[task-reviewer-prompt.md](task-reviewer-prompt.md)
|
||||||
|
|
||||||
|
### 4. 修复循环
|
||||||
|
|
||||||
|
当审查报告规格 ❌、任何 Critical 或 Important 发现、或者你确认为真实缺口的 ⚠️ 条目时,循环触发。
|
||||||
|
|
||||||
|
循环开始之前,有两条路会立刻离开它:
|
||||||
|
|
||||||
|
- **Minor 发现**随手记进进度账本(`Task <N>: minor (deferred): <一句话>`),并把最终的整分支审查指向那份清单,让它去甄别哪些必须在合并前修掉。**没人读的汇总等于静默丢弃。** Minor 发现永远不进入循环。
|
||||||
|
- 被标为"计划要求的"发现——或任何与计划原文所要求的内容冲突的发现——**由你来裁决**:把这条发现放到计划原文旁边掂量,以规格为有约束力的权威作出决定,并在据此行动之前把裁决记进账本。不要因为计划要求就驳回这条发现,也不要在没有一条记录在案的裁决的情况下分派一个与计划相违的修复。
|
||||||
|
|
||||||
|
其他一切都进入循环。**一轮修复 = 一次修复分派 + 一次定向复审。每个任务最多五轮。**
|
||||||
|
|
||||||
|
**第 1-3 轮——唤回原来那个实现者(resume)。** 把未解决的发现**逐字**发给它。它的上下文是完整的:它知道任务、知道代码、知道自己做过的选择。如果你的运行环境无法给一个活着的子智能体再发消息,就分派一个全新实现者,带上简报路径、报告文件路径和那些发现——无论走哪条路,报告文件都是那份持久化记忆。
|
||||||
|
|
||||||
|
**第 4-5 轮——用更强的模型分派一个全新实现者**(按"模型选择"),带上简报路径、报告文件路径、未解决的发现,以及这样的框定语:"某个此前的实现者尝试过这个任务 [N] 次;现在它归你了。读报告文件了解已经试过什么。"一个熬过三次唤回的循环,通常意味着实现者看不见自己的问题——换新眼睛加提升能力,一步到位。
|
||||||
|
|
||||||
|
**每一轮,无论走哪条路:** 实现者修复、重跑覆盖被改动代码的测试、把修复报告追加到**同一个**报告文件、回传那个简短契约。重新分派审查者之前,先确认修复报告里含有覆盖用的测试、跑过的命令、以及输出;三者齐备才分派复审。在修复消息里点名覆盖用的测试文件——一行的修复不需要整包套件。
|
||||||
|
|
||||||
|
**复审是定向的。** 运行 `scripts/review-package PLAN_FILE FIX_BASE HEAD`,其中 FIX_BASE 是上一次审查所看到的那个 head,然后用 [re-review-prompt.md](re-review-prompt.md) 分派,附上发现清单、简报、报告文件和打印出的 diff 路径。复审者对每条发现给出 ADDRESSED 或 NOT ADDRESSED 的结论,并且**只**标记修复 diff 里的新破坏。修复 diff 里新出现的 Critical/Important 破坏加入未解决发现清单。范围外的观察作为延后的 Minor 进账本——它们永远不延长循环。
|
||||||
|
|
||||||
|
**每轮结束后**往账本追加:
|
||||||
|
`Task <N>: fix round <R>/5 (<X> addressed, <Y> open — <发现的一句话概括>; commits <a7>..<b7>)`
|
||||||
|
|
||||||
|
**绝不在控制者会话里自己修发现**——你的上下文要保持干净以供协调,而且控制者的修复会跳过审查。
|
||||||
|
|
||||||
|
**熔断。** 当第 5 轮的复审仍然留下未解决的发现时,**停止分派**。你自己逐条裁定这些未解决的发现——你掌握着审查者所缺的计划和跨任务上下文:
|
||||||
|
|
||||||
|
- **审查者错了,或者这一点是可争议的:** 搁置它——`Task <N>: parked — <发现> — Ruling: <为什么代码可以维持原样>`。最终审查会看到双方说法。
|
||||||
|
- **是真实的,但下游没有任何东西建立在它之上:** 同样搁置,裁定里写明它是真的、被延后了。
|
||||||
|
- **真实且承重**——后面的任务建立在它之上,或者它揭示了一个计划缺陷:对**能解开后续工作的最小改动**作出裁决,以 `Task <N>: Ruling: <发现> — <你决定了什么,以及为什么>` 记进账本,并把它带进下一个任务的分派里。把一个结构性失败悄悄搁置掉,会让每个依赖它的任务都建立在它之上。只有当这个缺陷让每条前进路径都只能靠猜时,才停下来。
|
||||||
|
|
||||||
|
**只在触及上限时才裁定。** 为了结束循环而提早裁定,只是换了个名字的"预先定性"。每一次裁定都是一条账本记录——**静默丢弃是禁止的**。
|
||||||
|
|
||||||
|
### 5. 完成任务
|
||||||
|
|
||||||
|
当审查干净地返回——或者在触及上限时每条未解决的发现都已带着裁定被搁置——在你做其他记账的同一条消息里,往账本追加完成行:
|
||||||
|
|
||||||
|
- `Task <N>: complete (commits <base7>..<head7>, review clean)`
|
||||||
|
- 熔断触发过的话:`Task <N>: complete (commits <base7>..<head7>, <K> parked)`
|
||||||
|
|
||||||
|
然后标记待办完成,继续下一个。**绝不**在审查还有未解决的 Critical/Important 问题、而它们既没被修复也没在上限处带裁定搁置时,就进入下一个任务。
|
||||||
|
|
||||||
|
## 最终审查
|
||||||
|
|
||||||
|
覆盖整个分支的最终审查也拿到一个审查包:运行 `scripts/review-package PLAN_FILE MERGE_BASE HEAD`(MERGE_BASE = 分支起点的那个提交,例如 `git merge-base main HEAD`),把打印出的路径放进最终审查的分派里,这样最终审查者读一个文件就行,不必用 git 命令重新推导整个分支的 diff。用可用的最强模型分派(见"模型选择"),使用 requesting-code-review 的 [code-reviewer.md](../requesting-code-review/code-reviewer.md)。把它指向账本里那些"延后的 Minor"和"已搁置"的行,让它甄别哪些必须在合并前修掉。
|
||||||
|
|
||||||
|
如果覆盖整个分支的最终审查返回了发现,用**一个**修复子智能体带着**完整的**发现清单去分派——不要一条发现一个修复者。逐条发现各派一个修复者,每个都要重建上下文、重跑测试套件;真实会话里,一次最终审查的修复浪潮花掉的成本超过它全部任务的总和。然后对这波修复跑**恰好一次**定向复审(对修复区间跑 `scripts/review-package PLAN_FILE FIX_BASE HEAD`,用 [re-review-prompt.md](re-review-prompt.md))。残留的发现按任务循环里熔断那套来裁定:带裁定搁置,或者对承重项作出裁决并把你的决定记进账本。只有上面那四类才会在这里让你停下。**没有第二波修复**——残留的承重发现会在 finishing-a-development-branch 呈现选项时浮到你人类伙伴面前。
|
||||||
|
|
||||||
|
## 收尾
|
||||||
|
|
||||||
|
在你删除任何东西之前,把账本里每一条含 `Ruling:` 的记录都收集起来——预检裁决、搁置的发现、熔断裁定,全部——按你作出它们的顺序,放进你最终消息的「我作出的裁决」一节里,每条都附上如果错了代价是什么。这份清单是穷尽的:账本里有的裁决,清单里就要有。这份清单是你代你的人类伙伴作出的那些决定唯一能抵达他们的地方——他们读它,并返工你搞错的部分。一条随工作区一起消失的裁决,就是一个在暗中作出的决定。
|
||||||
|
|
||||||
|
当覆盖整个分支的最终审查干净、且它的修复已合并时,删除**本计划**的工作区(`rm -rf <工作区>`)——现在 git 历史就是记录了。同级目录属于别的计划,别去动它们。
|
||||||
|
|
||||||
|
使用 finishing-a-development-branch。
|
||||||
|
|
||||||
|
## 常见的合理化借口
|
||||||
|
|
||||||
|
| 借口 | 现实 |
|
||||||
|
|------|------|
|
||||||
|
| "规格合规性上差不多就行了" | 审查者发现了规格差距 = 未完成。修掉,或者走到上限去裁定——只有这两个出口。 |
|
||||||
|
| "我自己修就好了,分派是额外开销" | 控制者的修复会污染你的上下文并跳过审查。唤回实现者。 |
|
||||||
|
| "再来一轮就收敛了" | 过了上限,轮次不会收敛——那个失败是结构性的。裁定并分流。 |
|
||||||
|
| "反正审查者总会再挑出新东西" | 定向复审只核实修复,它不能到处乱逛。未改动代码上的新发现进账本,不进循环。 |
|
||||||
|
| "这条发现明显错了,我直接丢掉" | 你只在上限处裁定,而且每条裁定都是账本记录。静默丢弃是禁止的。 |
|
||||||
|
| "修复很小,跳过复审吧" | 未经审查的修复正是回归产生的方式。每一轮都以一次定向复审结束。 |
|
||||||
|
| "审查把循环拖慢了" | 没有审查的循环只是未经核实的空转。审查是这个循环的刹车和方向盘。 |
|
||||||
|
| "记账本是额外开销" | 账本是能在压缩中存活下来的东西。没有账本的控制者曾重新分派整段已完成的任务序列。 |
|
||||||
|
| "实现者自己派了个审查者——白送的额外保障" | 那是一个重复的席位,按全价审查同一份 diff,而且它的裁定不算数。审查由控制者分派。 |
|
||||||
|
|
||||||
|
## 示例工作流
|
||||||
|
|
||||||
|
```
|
||||||
|
你:我正在用子智能体驱动开发来执行这个计划。
|
||||||
|
|
||||||
|
[准备:工作树已核实]
|
||||||
|
[把计划文件读一遍:docs/superpowers/plans/feature-plan.md]
|
||||||
|
[解析工作区:scripts/sdd-workspace docs/superpowers/plans/feature-plan.md —— 里面没有账本,全新开始]
|
||||||
|
[为所有任务创建待办]
|
||||||
|
|
||||||
|
任务 1:Hook 安装脚本
|
||||||
|
|
||||||
|
[对任务 1 运行 task-brief;分派实现者,附带简报 + 报告路径 + 上下文]
|
||||||
|
|
||||||
|
实现者:"开始之前——这个 hook 应该装在用户级还是系统级?"
|
||||||
|
|
||||||
|
你:"用户级(~/.config/superpowers/hooks/)"
|
||||||
|
|
||||||
|
实现者:[稍后]
|
||||||
|
- 实现了 install-hook 命令
|
||||||
|
- 加了测试,5/5 通过
|
||||||
|
- 自审:发现漏了 --force 标志,已补上
|
||||||
|
- 已提交
|
||||||
|
|
||||||
|
[运行 review-package PLAN_FILE BASE HEAD;把打印出的路径交给任务审查者去分派]
|
||||||
|
任务审查者:规格 ✅ —— 所有需求都满足,没有多余的东西。
|
||||||
|
优点:测试覆盖良好,代码整洁。问题:无。任务质量:通过。
|
||||||
|
|
||||||
|
[账本:Task 1: complete (commits a1b2c3d..d4e5f6a, review clean)]
|
||||||
|
|
||||||
|
任务 2:恢复模式
|
||||||
|
|
||||||
|
[对任务 2 运行 task-brief;分派实现者,附带简报 + 报告路径 + 上下文]
|
||||||
|
|
||||||
|
实现者:[无疑问]
|
||||||
|
- 加了 verify/repair 模式
|
||||||
|
- 8/8 测试通过
|
||||||
|
- 已提交
|
||||||
|
|
||||||
|
[运行 review-package PLAN_FILE BASE HEAD;把打印出的路径交给任务审查者去分派]
|
||||||
|
任务审查者:规格 ❌:
|
||||||
|
- 缺失:进度上报(规格说"每 100 项上报一次")
|
||||||
|
问题(Important):魔法数字(100)
|
||||||
|
|
||||||
|
[第 1 轮修复:唤回原实现者,带上这两条发现]
|
||||||
|
实现者:加了进度上报,把 PROGRESS_INTERVAL 提成了常量。
|
||||||
|
重跑了 test/recovery.test.js —— 10/10 通过。修复报告已追加。
|
||||||
|
|
||||||
|
[运行 review-package PLAN_FILE FIX_BASE HEAD;分派定向复审]
|
||||||
|
复审者:缺失进度上报 —— ADDRESSED(src/recovery.js:41)。
|
||||||
|
魔法数字 —— ADDRESSED(src/recovery.js:7)。新破坏:无。
|
||||||
|
结论:所有发现均已解决。
|
||||||
|
|
||||||
|
[账本:Task 2: fix round 1/5 (2 addressed, 0 open; commits d4e5f6a..b7c8d9e)]
|
||||||
|
[账本:Task 2: complete (commits d4e5f6a..b7c8d9e, review clean)]
|
||||||
|
|
||||||
|
...
|
||||||
|
|
||||||
|
[所有任务之后]
|
||||||
|
[运行 review-package PLAN_FILE MERGE_BASE HEAD;分派最终代码审查者,用最强模型]
|
||||||
|
最终审查者:所有需求都满足。延后的 Minor 已甄别:没有阻塞合并的。
|
||||||
|
|
||||||
|
[删除本计划的工作区 —— 现在记录活在 git 里]
|
||||||
|
|
||||||
|
搞定!使用 finishing-a-development-branch。
|
||||||
|
```
|
||||||
@ -0,0 +1,144 @@
|
|||||||
|
# 实现子智能体提示词模板
|
||||||
|
|
||||||
|
分派实现子智能体时使用此模板。
|
||||||
|
|
||||||
|
```
|
||||||
|
Subagent (general-purpose):
|
||||||
|
description: "实现任务 N:[任务名称]"
|
||||||
|
model: [模型 —— 必填:按 SKILL.md 的"模型选择"来选;省略模型会默默
|
||||||
|
继承会话里最贵的那个]
|
||||||
|
prompt: |
|
||||||
|
你正在实现任务 N:[任务名称]
|
||||||
|
|
||||||
|
## 任务描述
|
||||||
|
|
||||||
|
先读你的任务简报:[BRIEF_FILE]
|
||||||
|
它包含计划中该任务的完整文本。
|
||||||
|
|
||||||
|
## 上下文
|
||||||
|
|
||||||
|
[场景铺设:这个任务在哪个环节、依赖关系、架构上下文]
|
||||||
|
|
||||||
|
## 开始之前
|
||||||
|
|
||||||
|
如果你对以下内容有疑问:
|
||||||
|
- 需求或验收标准
|
||||||
|
- 方案或实现策略
|
||||||
|
- 依赖或假设
|
||||||
|
- 任务描述中任何不清楚的地方
|
||||||
|
|
||||||
|
**现在就问。** 在开始工作之前提出任何疑虑。
|
||||||
|
|
||||||
|
## 你的工作
|
||||||
|
|
||||||
|
当你确认需求清晰后:
|
||||||
|
1. 严格按照任务指定的内容实现
|
||||||
|
2. 编写测试(如果任务要求则遵循 TDD)
|
||||||
|
3. 验证实现是否正常工作
|
||||||
|
4. 提交你的工作
|
||||||
|
5. 自审(见下文)
|
||||||
|
6. 汇报
|
||||||
|
|
||||||
|
工作目录:[directory]
|
||||||
|
|
||||||
|
**工作过程中:** 如果遇到意料之外或不清楚的情况,**提问**。
|
||||||
|
随时可以暂停并澄清。不要猜测或做假设。
|
||||||
|
|
||||||
|
迭代过程中,只跑你正在改动的那部分的聚焦测试;在提交前跑一次
|
||||||
|
完整测试套件,而不是每次编辑后都跑。
|
||||||
|
|
||||||
|
## 你不派发子代理
|
||||||
|
|
||||||
|
这个任务的全部工作由你自己做完。绝不为了实现任务的一部分而派生子代理,尤其绝不派生一个审查者来检查你自己的工作。下面说的「自审」指的是读你自己的 diff。审查是控制者的职责:你汇报之后,它会针对你的 diff 派发一个全新的审查者。你派生出来的审查者只是按全价重复那次审查,而它的批准在流程里不作数。如果你发现自己在想「独立审一遍会让我的报告更有说服力」—— 那次审查已经排好队了。去汇报就行。
|
||||||
|
|
||||||
|
## 代码组织
|
||||||
|
|
||||||
|
你在能一次性放入上下文的代码上推理效果最好,文件聚焦时你的编辑也更可靠。
|
||||||
|
请牢记:
|
||||||
|
- 遵循计划中定义的文件结构
|
||||||
|
- 每个文件应有单一明确的职责和定义清晰的接口
|
||||||
|
- 如果你正在创建的文件超出了计划的意图规模,停下来并以
|
||||||
|
DONE_WITH_CONCERNS 状态报告——不要在没有计划指导的情况下自行拆分文件
|
||||||
|
- 如果你正在修改的现有文件已经很大或很混乱,小心操作,
|
||||||
|
并在报告中将其标注为疑虑
|
||||||
|
- 在已有代码库中,遵循已建立的模式。像一个好的开发者那样
|
||||||
|
改善你接触到的代码,但不要重构你任务范围之外的东西。
|
||||||
|
|
||||||
|
## 当你力不从心时
|
||||||
|
|
||||||
|
随时可以停下来说"这对我来说太难了"。劣质的工作比不做更糟。
|
||||||
|
上报不会受到惩罚。
|
||||||
|
|
||||||
|
**遇到以下情况时停下来上报:**
|
||||||
|
- 任务需要在多个有效方案之间做架构决策
|
||||||
|
- 你需要理解提供内容之外的代码但找不到清晰答案
|
||||||
|
- 你对自己的方案是否正确感到不确定
|
||||||
|
- 任务涉及计划未预期的现有代码重构
|
||||||
|
- 你一直在逐个读文件试图理解系统但没有进展
|
||||||
|
|
||||||
|
**如何上报:** 以 BLOCKED 或 NEEDS_CONTEXT 状态汇报。具体描述
|
||||||
|
你卡在哪里、尝试了什么、需要什么样的帮助。
|
||||||
|
控制者可以提供更多上下文、用更强的模型重新分派,
|
||||||
|
或将任务拆分为更小的部分。
|
||||||
|
|
||||||
|
## 汇报前:自审
|
||||||
|
|
||||||
|
用全新的视角审查你的工作。问自己:
|
||||||
|
|
||||||
|
**完整性:**
|
||||||
|
- 我是否完全实现了规格中的所有内容?
|
||||||
|
- 我是否遗漏了任何需求?
|
||||||
|
- 是否有我没处理的边界情况?
|
||||||
|
|
||||||
|
**质量:**
|
||||||
|
- 这是我最好的工作吗?
|
||||||
|
- 命名是否清晰准确(匹配事物做什么,而非怎么做)?
|
||||||
|
- 代码是否整洁且可维护?
|
||||||
|
|
||||||
|
**纪律:**
|
||||||
|
- 我是否避免了过度构建(YAGNI)?
|
||||||
|
- 我是否只构建了被要求的内容?
|
||||||
|
- 我是否遵循了代码库中的已有模式?
|
||||||
|
|
||||||
|
**测试:**
|
||||||
|
- 测试是否真正验证了行为(而非只是 mock 行为)?
|
||||||
|
- 如果要求了 TDD,我是否遵循了?
|
||||||
|
- 测试是否全面?
|
||||||
|
- 测试输出是否干净(没有零散的告警或噪声)?
|
||||||
|
|
||||||
|
如果在自审中发现问题,在汇报前就修复。
|
||||||
|
|
||||||
|
## 审查发现之后
|
||||||
|
|
||||||
|
如果任务审查发现了问题,你会被带着那些发现重新唤起(resume)。
|
||||||
|
修复它们,重跑覆盖被改动代码的测试,然后往你的报告文件里追加一份
|
||||||
|
修复报告:你改了什么、你跑了哪些覆盖用的测试、命令是什么、输出是什么。
|
||||||
|
审查者不会替你重跑测试——你的报告就是测试证据。然后用与第一份报告
|
||||||
|
相同的那个简短状态契约回复。
|
||||||
|
|
||||||
|
## 报告格式
|
||||||
|
|
||||||
|
把你的完整报告写到 [REPORT_FILE]:
|
||||||
|
- 你实现了什么(如果被阻塞,则是你尝试了什么)
|
||||||
|
- 你测试了什么以及测试结果
|
||||||
|
- **TDD 证据**(如果本任务要求了 TDD):
|
||||||
|
- RED:跑的命令、实现前相关的失败输出、以及为什么这个失败是预期的
|
||||||
|
- GREEN:跑的命令、以及实现后相关的通过输出
|
||||||
|
- 修改了哪些文件
|
||||||
|
- 自审发现(如果有)
|
||||||
|
- 任何问题或疑虑
|
||||||
|
|
||||||
|
然后只汇报以下内容(不超过 15 行——细节都在报告文件里):
|
||||||
|
- **状态:** DONE | DONE_WITH_CONCERNS | BLOCKED | NEEDS_CONTEXT
|
||||||
|
- 创建的提交(短 SHA + 标题)
|
||||||
|
- 一行测试小结(例如"14/14 通过,输出干净")
|
||||||
|
- 你的疑虑,如果有
|
||||||
|
- 报告文件路径
|
||||||
|
|
||||||
|
如果是 BLOCKED 或 NEEDS_CONTEXT,把具体细节放进最终消息本身——
|
||||||
|
控制者会直接据此行动。
|
||||||
|
|
||||||
|
如果你完成了工作但对正确性有疑虑,使用 DONE_WITH_CONCERNS。
|
||||||
|
如果你无法完成任务,使用 BLOCKED。如果你需要未提供的信息,
|
||||||
|
使用 NEEDS_CONTEXT。绝不默默产出你不确定的工作。
|
||||||
|
```
|
||||||
@ -0,0 +1,104 @@
|
|||||||
|
# 定向复审提示词模板
|
||||||
|
|
||||||
|
在一轮修复之后分派复审时使用此模板。复审者核实那些发现是否已被解决,
|
||||||
|
并检查修复 diff 有没有引入新的破坏。这**不是**一次全新审查——完整审查
|
||||||
|
早已做过了。
|
||||||
|
|
||||||
|
**目的:** 核实上一次审查的每一条发现都已解决,且修复本身没有破坏任何东西。
|
||||||
|
|
||||||
|
```
|
||||||
|
Subagent (general-purpose):
|
||||||
|
description: "复审任务 N 第 R 轮修复"
|
||||||
|
model: [模型 —— 必填:按 SKILL.md 的"模型选择"来选;省略模型会默默
|
||||||
|
继承会话里最贵的那个]
|
||||||
|
prompt: |
|
||||||
|
你正在复审一个任务的一轮修复。之前的审查产生了一批发现,
|
||||||
|
一个实现者已经尝试修复它们。你的工作是给每条发现下结论、
|
||||||
|
并检查这次修复的 diff——仅此而已。
|
||||||
|
|
||||||
|
## 任务
|
||||||
|
|
||||||
|
读取任务简报:[BRIEF_FILE]
|
||||||
|
|
||||||
|
## 待核实的发现
|
||||||
|
|
||||||
|
[FINDINGS]
|
||||||
|
|
||||||
|
## 修复
|
||||||
|
|
||||||
|
读取实现者的报告(修复报告追加在文件末尾):
|
||||||
|
[REPORT_FILE]
|
||||||
|
|
||||||
|
**修复基线:** [FIX_BASE_SHA](上一次审查所看到的那个 head)
|
||||||
|
**Head:** [HEAD_SHA]
|
||||||
|
**diff 文件:** [DIFF_FILE]
|
||||||
|
|
||||||
|
把 diff 文件一次读完——它包含修复的提交、stat 摘要,以及带上下文的
|
||||||
|
修复 diff。不要重新跑 git 命令。如果 diff 文件不存在,自己取 diff:
|
||||||
|
`git diff --stat [FIX_BASE_SHA]..[HEAD_SHA]` 和
|
||||||
|
`git diff [FIX_BASE_SHA]..[HEAD_SHA]`。
|
||||||
|
|
||||||
|
你的审查对这个 checkout 是只读的。不要以任何方式改动工作树、索引、
|
||||||
|
HEAD 或分支状态。
|
||||||
|
|
||||||
|
## 你不派发子代理
|
||||||
|
|
||||||
|
这次审查全部由你自己做完。绝不为了审查 diff 的一部分而派生子代理,也绝不为了「再要一个意见」而派生另一个审查者。这套流程已经给了这份工作应有的每一个审查席位;你派生出来的审查者只是按全价重复其中一个,而它的结论不作数。如果 diff 大到一遍看不完,就自己分几遍看,并在报告里说明。
|
||||||
|
|
||||||
|
## 范围
|
||||||
|
|
||||||
|
你的范围就是那份发现清单和这次修复的 diff。**每一条发现都要给结论。**
|
||||||
|
检查修复 diff 里有没有修复本身引入的新问题。**不要**去复审这次修复
|
||||||
|
没有碰过的代码:如果你注意到一个完全在修复 diff 之外的问题,
|
||||||
|
把它写进"范围外的观察"——它不阻塞本任务,也不会延长修复循环。
|
||||||
|
覆盖整个分支的宽范围审查会在所有任务完成后另行进行。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
实现者已经重跑了覆盖被改动代码的那些测试,并把结果追加到了报告文件里。
|
||||||
|
把报告当作**未经核实的声明**来对待:确认修复报告点名了覆盖用的测试
|
||||||
|
并给出了它们的输出,再拿这些声明去对照 diff 核验。不要为了确认它的报告
|
||||||
|
而重跑整个测试套件。只有当读代码引出了某个现有运行结果无法回答的
|
||||||
|
具体疑问时才跑测试——而且只跑一个聚焦的测试,绝不跑整包套件。
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
你的最终消息就是报告本身:直接从第一条发现的结论开始。每一行都应该是
|
||||||
|
一个结论、一条带 file:line 的发现,或者一项你实际做过的检查——
|
||||||
|
不要开场白,不要过程旁白。
|
||||||
|
|
||||||
|
### 各条发现的结论
|
||||||
|
|
||||||
|
按"待核实的发现"里的顺序,逐条给出:
|
||||||
|
- **[发现的一句话概括]** —— ADDRESSED(已解决)| NOT ADDRESSED(未解决),
|
||||||
|
附 file:line 证据。"尝试过了"不算已解决:那个具体缺陷必须已经不存在。
|
||||||
|
|
||||||
|
### 修复 diff 里的新破坏
|
||||||
|
|
||||||
|
修复本身破坏或引入的任何东西,附严重度(Critical/Important/Minor)
|
||||||
|
和 file:line。干净就写"无"。
|
||||||
|
|
||||||
|
### 范围外的观察
|
||||||
|
|
||||||
|
你注意到的、完全位于修复 diff 之外的问题。不阻塞;控制者会把这些
|
||||||
|
记进账本留给最终审查。没有就写"无"。
|
||||||
|
|
||||||
|
### 结论
|
||||||
|
|
||||||
|
**本轮修复:** [所有发现均已解决,无新的 Critical/Important 破坏 |
|
||||||
|
仍有发现未解决] —— 把未解决的那些列出来。
|
||||||
|
```
|
||||||
|
|
||||||
|
**占位符:**
|
||||||
|
- `[MODEL]` —— 必填:审查者模型,按 SKILL.md 的"模型选择"来选;小修复 diff
|
||||||
|
的定向复审用便宜到中档的层级即可
|
||||||
|
- `[BRIEF_FILE]` —— 任务简报文件(与实现者所依据的是同一个文件)
|
||||||
|
- `[FINDINGS]` —— 上一次审查里的 Critical/Important 发现和规格差距,
|
||||||
|
逐字抄下来,每条一个 bullet
|
||||||
|
- `[REPORT_FILE]` —— 实现者的报告文件(修复报告追加在其末尾)
|
||||||
|
- `[FIX_BASE_SHA]` —— 上一次审查所看到的那个 head
|
||||||
|
- `[HEAD_SHA]` —— 当前提交
|
||||||
|
- `[DIFF_FILE]` —— `scripts/review-package PLAN_FILE FIX_BASE HEAD` 打印出的那个路径
|
||||||
|
|
||||||
|
**复审者返回:** 逐条发现的结论(ADDRESSED / NOT ADDRESSED)、
|
||||||
|
修复 diff 里的新破坏、范围外的观察,以及一个本轮结论。
|
||||||
@ -0,0 +1,46 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Generate a review package: commit list, stat summary, and the net
|
||||||
|
# diff with extended context, written to a file the reviewer reads in one
|
||||||
|
# call. Using the recorded per-task BASE (not HEAD~1) keeps multi-commit
|
||||||
|
# tasks intact.
|
||||||
|
#
|
||||||
|
# Usage: review-package PLAN_FILE BASE HEAD [OUTFILE]
|
||||||
|
# Default OUTFILE: <repo-root>/.superpowers/sdd/<plan-basename>/review-<base7>..<head7>.diff
|
||||||
|
# (named per range, so a re-review after fixes gets a distinct fresh file).
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ $# -lt 3 ] || [ $# -gt 4 ]; then
|
||||||
|
echo "usage: review-package PLAN_FILE BASE HEAD [OUTFILE]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
plan=$1
|
||||||
|
base=$2
|
||||||
|
head=$3
|
||||||
|
[ -f "$plan" ] || { echo "no such plan file: $plan" >&2; exit 2; }
|
||||||
|
|
||||||
|
git rev-parse --verify --quiet "$base" >/dev/null || { echo "bad BASE: $base" >&2; exit 2; }
|
||||||
|
git rev-parse --verify --quiet "$head" >/dev/null || { echo "bad HEAD: $head" >&2; exit 2; }
|
||||||
|
|
||||||
|
if [ $# -eq 4 ]; then
|
||||||
|
out=$4
|
||||||
|
else
|
||||||
|
dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan")
|
||||||
|
out="$dir/review-$(git rev-parse --short "$base")..$(git rev-parse --short "$head").diff"
|
||||||
|
fi
|
||||||
|
|
||||||
|
{
|
||||||
|
echo "# Review package: ${base}..${head}"
|
||||||
|
echo
|
||||||
|
echo "## Commits"
|
||||||
|
git log --oneline "${base}..${head}"
|
||||||
|
echo
|
||||||
|
echo "## Files changed"
|
||||||
|
git diff --stat "${base}..${head}"
|
||||||
|
echo
|
||||||
|
echo "## Diff"
|
||||||
|
git diff -U10 "${base}..${head}"
|
||||||
|
} > "$out"
|
||||||
|
|
||||||
|
commits=$(git rev-list --count "${base}..${head}")
|
||||||
|
echo "wrote ${out}: ${commits} commit(s), $(wc -c < "$out" | tr -d ' ') bytes"
|
||||||
@ -0,0 +1,40 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Resolve and ensure the working-tree directory SDD uses for one plan's
|
||||||
|
# short-lived artifacts: task briefs, implementer reports, review packages,
|
||||||
|
# and the progress ledger. Print the plan directory's absolute path.
|
||||||
|
#
|
||||||
|
# One directory per plan (.superpowers/sdd/<plan-basename>/) so a follow-up
|
||||||
|
# plan in the same working tree can never read or overwrite another plan's
|
||||||
|
# artifacts. A stale ledger misread as current progress makes controllers
|
||||||
|
# skip whole task sequences — plan-scoping removes that failure structurally.
|
||||||
|
#
|
||||||
|
# The workspace lives in the working tree (not under .git/) because Claude Code
|
||||||
|
# treats .git/ as a protected path and denies agent writes there — which blocks
|
||||||
|
# an implementer subagent from writing its report file. A self-ignoring
|
||||||
|
# .gitignore at .superpowers/sdd/ keeps every plan's workspace out of
|
||||||
|
# `git status` and out of accidental commits without modifying any tracked file.
|
||||||
|
#
|
||||||
|
# Single source of truth for the workspace location, so task-brief and
|
||||||
|
# review-package cannot drift to different directories.
|
||||||
|
#
|
||||||
|
# Usage: sdd-workspace PLAN_FILE
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ $# -ne 1 ]; then
|
||||||
|
echo "usage: sdd-workspace PLAN_FILE" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
plan=$1
|
||||||
|
[ -f "$plan" ] || { echo "no such plan file: $plan" >&2; exit 2; }
|
||||||
|
|
||||||
|
slug=$(basename "$plan" .md)
|
||||||
|
[ -n "$slug" ] && [ "$slug" != "." ] && [ "$slug" != ".." ] \
|
||||||
|
|| { echo "cannot derive a workspace name from: $plan" >&2; exit 2; }
|
||||||
|
|
||||||
|
root=$(git rev-parse --show-toplevel)
|
||||||
|
base="$root/.superpowers/sdd"
|
||||||
|
dir="$base/$slug"
|
||||||
|
mkdir -p "$dir"
|
||||||
|
printf '*\n' > "$base/.gitignore"
|
||||||
|
cd "$dir" && pwd
|
||||||
@ -0,0 +1,45 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Extract one task's full text from an implementation plan into a file the
|
||||||
|
# implementer reads in one call, so the task text never has to be pasted
|
||||||
|
# through the controller's context.
|
||||||
|
#
|
||||||
|
# Usage: task-brief PLAN_FILE TASK_NUMBER [OUTFILE]
|
||||||
|
# Default OUTFILE: <repo-root>/.superpowers/sdd/<plan-basename>/task-<N>-brief.md
|
||||||
|
# (per plan and per worktree; concurrent runs of the SAME plan in the same
|
||||||
|
# working tree share it).
|
||||||
|
#
|
||||||
|
# 中文 fork 适配:上游只识别英文任务标题 "## Task N",而 superpowers-zh
|
||||||
|
# 的 writing-plans 产出的是 "### 任务 N:..."。下方 awk 同时匹配
|
||||||
|
# "Task" 与 "任务",两种计划都能抽取。
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
if [ $# -lt 2 ] || [ $# -gt 3 ]; then
|
||||||
|
echo "usage: task-brief PLAN_FILE TASK_NUMBER [OUTFILE]" >&2
|
||||||
|
exit 2
|
||||||
|
fi
|
||||||
|
|
||||||
|
plan=$1
|
||||||
|
n=$2
|
||||||
|
[ -f "$plan" ] || { echo "no such plan file: $plan" >&2; exit 2; }
|
||||||
|
|
||||||
|
if [ $# -eq 3 ]; then
|
||||||
|
out=$3
|
||||||
|
else
|
||||||
|
dir=$("$(cd "$(dirname "$0")" && pwd)/sdd-workspace" "$plan")
|
||||||
|
out="$dir/task-${n}-brief.md"
|
||||||
|
fi
|
||||||
|
|
||||||
|
awk -v n="$n" '
|
||||||
|
/^```/ { infence = !infence }
|
||||||
|
!infence && /^#+[ \t]+(Task|任务)[ \t]*[0-9]+/ {
|
||||||
|
intask = ($0 ~ ("^#+[ \t]+(Task|任务)[ \t]*" n "([^0-9]|$)"))
|
||||||
|
}
|
||||||
|
intask { print }
|
||||||
|
' "$plan" > "$out"
|
||||||
|
|
||||||
|
if [ ! -s "$out" ]; then
|
||||||
|
echo "task ${n} not found in ${plan} (no heading matching 'Task ${n}' / '任务 ${n}')" >&2
|
||||||
|
exit 3
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "wrote ${out}: $(wc -l < "$out" | tr -d ' ') lines"
|
||||||
@ -0,0 +1,184 @@
|
|||||||
|
# 任务审查者提示词模板
|
||||||
|
|
||||||
|
分派任务审查子智能体时使用此模板。审查者一次性读取该任务的 diff,
|
||||||
|
返回两个结论:规格合规性和代码质量。
|
||||||
|
|
||||||
|
**目的:** 核实一个任务的实现与其需求匹配(不多不少)且构建良好(整洁、有测试、可维护)
|
||||||
|
|
||||||
|
```
|
||||||
|
Subagent (general-purpose):
|
||||||
|
description: "审查任务 N(规格 + 质量)"
|
||||||
|
model: [模型 —— 必填:按 SKILL.md 的"模型选择"来选;省略模型会默默
|
||||||
|
继承会话里最贵的那个]
|
||||||
|
prompt: |
|
||||||
|
你正在审查一个任务的实现:先看它是否与需求匹配,再看它是否
|
||||||
|
构建良好。这是一个任务范围内的关卡,不是合并审查——覆盖整个
|
||||||
|
分支的宽范围审查会在所有任务完成后另行进行。
|
||||||
|
|
||||||
|
## 要求的内容
|
||||||
|
|
||||||
|
读取任务简报:[BRIEF_FILE]
|
||||||
|
|
||||||
|
来自规格/设计、约束本任务的全局约束:
|
||||||
|
[GLOBAL_CONSTRAINTS]
|
||||||
|
|
||||||
|
## 实现者声称构建了什么
|
||||||
|
|
||||||
|
读取实现者的报告:[REPORT_FILE]
|
||||||
|
|
||||||
|
## 待审查的 Diff
|
||||||
|
|
||||||
|
**Base:** [BASE_SHA]
|
||||||
|
**Head:** [HEAD_SHA]
|
||||||
|
**Diff 文件:** [DIFF_FILE]
|
||||||
|
|
||||||
|
一次性读取这个 diff 文件——它包含提交列表、stat 摘要,以及
|
||||||
|
带上下文的完整 diff,它就是你对本次改动的视图。diff 的上下文行
|
||||||
|
**就是**那些被改动的文件:不要单独去 Read 某个被改动的文件,除非
|
||||||
|
你必须判断的某个 hunk 在函数中途被截断——并在报告中说明这一点。
|
||||||
|
不要重跑 git 命令。如果 diff 文件缺失,就自己取 diff:
|
||||||
|
`git diff --stat [BASE_SHA]..[HEAD_SHA]` 和 `git diff [BASE_SHA]..[HEAD_SHA]`。
|
||||||
|
不要爬取更广的代码库。只有为了评估一个你能点名的具体风险,才去
|
||||||
|
查看 diff 之外的代码——每个点名的风险做一次聚焦检查,并在报告中
|
||||||
|
同时点名这个风险和你检查了什么。横切改动是正当的、可点名的风险:
|
||||||
|
如果 diff 改动了锁顺序、某个函数或 API 契约、或共享的可变状态,
|
||||||
|
检查其调用点就是正确的方法。
|
||||||
|
|
||||||
|
你的审查在这个 checkout 上是只读的。不要以任何方式改动工作树、
|
||||||
|
索引、HEAD 或分支状态。
|
||||||
|
|
||||||
|
## 你不派发子智能体
|
||||||
|
|
||||||
|
这次审查全部由你自己做。绝不派一个子智能体去审查 diff 的一部分,
|
||||||
|
也绝不为了第二意见再派一个审查者。这套流程已经提供了这份工作
|
||||||
|
应得的每一个审查席位;你派出的审查者只是按全价重复其中之一,
|
||||||
|
而且它的裁定不算数。如果这份 diff 大到一遍看不完,就自己分几遍
|
||||||
|
看,并在报告里说明。
|
||||||
|
|
||||||
|
## 不要信任报告
|
||||||
|
|
||||||
|
把实现者的报告当作关于代码的、未经核实的说法。它可能不完整、
|
||||||
|
不准确或过于乐观。对照 diff 去核实这些说法。报告里的设计理由
|
||||||
|
同样是说法:"出于 YAGNI 留着没做""特意保持简单"或任何其他辩解,
|
||||||
|
都是实现者在给自己的工作打分。就代码本身评判它的优劣——一句
|
||||||
|
陈述出来的理由永远不会降低一个发现的严重度。
|
||||||
|
|
||||||
|
你看不见的证据,不等于不存在的证据。如果报告或它的测试证据看起来
|
||||||
|
被截断了,或者你找不到它声称的结果,就按它给出的路径把文件重新读
|
||||||
|
一遍——如果确实缺失或损坏了,把这件事作为一个缺口报告给控制者。
|
||||||
|
为了重新生成你没读到的东西而重跑测试套件,不是核实;证据不可读,
|
||||||
|
不等于证据不成立。
|
||||||
|
|
||||||
|
## 测试
|
||||||
|
|
||||||
|
实现者已经跑过测试,并为正是这份代码报告了带 TDD 证据的结果。
|
||||||
|
不要为了确认他们的报告而重跑测试套件。只有当阅读代码引出一个
|
||||||
|
现有任何运行都无法回答的具体疑问时,才去跑测试——而且是聚焦
|
||||||
|
测试,绝不是包级套件、竞态检测运行、或反复的/高次数的循环。
|
||||||
|
如果看起来确实需要重度验证,就在报告里建议它,而不是自己去跑。
|
||||||
|
如果你在这个环境里无法运行命令,就点名你会跑的那个测试。
|
||||||
|
|
||||||
|
实现者报告的测试输出里的告警或其他噪声都是发现——测试输出
|
||||||
|
应当是干净的。
|
||||||
|
|
||||||
|
## 第一部分:规格合规性
|
||||||
|
|
||||||
|
把 diff 对照"要求的内容"来看:
|
||||||
|
|
||||||
|
- **缺失:** 他们跳过、遗漏、或声称却未实现的需求
|
||||||
|
- **多余:** 未被要求的功能、过度工程、不需要的"锦上添花"
|
||||||
|
- **理解偏差:** 正确的功能却用错了方式来构建,解决了错误的问题
|
||||||
|
|
||||||
|
如果某个需求无法仅从这份 diff 中核实(它藏在未改动的代码里、
|
||||||
|
或横跨多个任务),就把它作为一个 ⚠️ 事项报告出来,而不是
|
||||||
|
扩大你的搜索范围。
|
||||||
|
|
||||||
|
如果简报列了好几个文件、每个都有自己的改动(一次打包分派),
|
||||||
|
就拿这份清单逐个文件去对 diff:清单上的每个文件都必须有它对应
|
||||||
|
的 hunk。清单上有、diff 却从没碰过的文件,是一条"缺失"发现,
|
||||||
|
无论这一批里其余部分看起来多干净。
|
||||||
|
|
||||||
|
## 第二部分:代码质量
|
||||||
|
|
||||||
|
**代码质量:**
|
||||||
|
- 关注点分离是否干净?
|
||||||
|
- 错误处理是否恰当?
|
||||||
|
- 是否做到 DRY 而没有过早抽象?
|
||||||
|
- 边界情况是否处理了?
|
||||||
|
|
||||||
|
**测试:**
|
||||||
|
- 新增和改动的测试是否验证了真实行为,而非 mock?
|
||||||
|
- 本任务的边界情况是否被覆盖?
|
||||||
|
|
||||||
|
**结构:**
|
||||||
|
- 每个文件是否有单一明确的职责和定义清晰的接口?
|
||||||
|
- 各单元是否拆分得足以独立理解和测试?
|
||||||
|
- 实现是否遵循了计划中的文件结构?
|
||||||
|
- 本次改动是否创建了已经很大的新文件,或显著增大了现有文件?
|
||||||
|
(不要标记已有的文件大小问题——聚焦于本次改动带来的贡献。)
|
||||||
|
|
||||||
|
你的报告应指向证据:每一个发现、以及任何你本来会用一句干巴巴的
|
||||||
|
"是"来回答的检查,都要给出 file:line 引用。一份引用了行号的
|
||||||
|
紧凑报告,就把控制者需要的一切都给它了。
|
||||||
|
|
||||||
|
你的最终消息就是报告本身:直接从规格合规性结论开始。每一行
|
||||||
|
要么是一个结论、要么是一个带 file:line 的发现、要么是你跑过的
|
||||||
|
一个检查——没有开场白、没有流程叙述、没有结尾小结。
|
||||||
|
|
||||||
|
## 校准
|
||||||
|
|
||||||
|
按实际严重度给问题分类。不是所有东西都是 关键。
|
||||||
|
重要 意味着这个任务在修好之前不可信:不正确或脆弱的行为、
|
||||||
|
一个漏掉的需求、或你会为之拦下合并的可维护性损害——逻辑块的
|
||||||
|
逐字重复、被吞掉的错误、什么都不断言的测试。"覆盖面可以更广"
|
||||||
|
和打磨类建议是 次要。
|
||||||
|
如果计划或简报明确强制了某个本评分标准称之为缺陷的东西(一个
|
||||||
|
什么都不断言的测试、逻辑块的逐字重复),那**就是**一个发现——
|
||||||
|
把它报告为 重要,并标注为"计划强制"。计划的作者身份不能给它
|
||||||
|
自己的工作打分;由人类来决定。
|
||||||
|
在列出问题之前,先承认做得好的地方——准确的赞扬能帮实现者
|
||||||
|
信任其余的反馈。
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
### 规格合规性
|
||||||
|
|
||||||
|
- ✅ 符合规格 | ❌ 发现问题:[缺失/多余/理解偏差的内容,
|
||||||
|
附带 file:line 引用]
|
||||||
|
- ⚠️ 无法从 diff 中核实:[你无法仅凭 diff 核实的需求,以及
|
||||||
|
控制者应当检查什么——与你能核实的一切的 ✅/❌ 结论一起报告]
|
||||||
|
|
||||||
|
### 优点
|
||||||
|
[哪些做得好?要具体。]
|
||||||
|
|
||||||
|
### 问题
|
||||||
|
|
||||||
|
#### 关键(必须修复)
|
||||||
|
#### 重要(应当修复)
|
||||||
|
#### 次要(锦上添花)
|
||||||
|
|
||||||
|
每个问题:file:line、哪里错了、为什么重要、如何修复(如果不明显)。
|
||||||
|
|
||||||
|
### 评估
|
||||||
|
|
||||||
|
**任务质量:** [通过 | 需要修复]
|
||||||
|
|
||||||
|
**理由:** [1-2 句技术性评估]
|
||||||
|
```
|
||||||
|
|
||||||
|
**占位符:**
|
||||||
|
- `[模型]` —— 必填:按 SKILL.md 的"模型选择"选审查者模型
|
||||||
|
- `[BRIEF_FILE]` —— 必填:任务简报文件(`scripts/task-brief PLAN N`
|
||||||
|
会打印路径;与实现者所用的是同一个文件)
|
||||||
|
- `[GLOBAL_CONSTRAINTS]` —— 从计划的"全局约束"一节或规格里逐字抄下的、
|
||||||
|
有约束力的需求:精确的取值、格式、以及组件之间被明确规定的关系
|
||||||
|
(不是流程规则——那些已经在本模板里了)
|
||||||
|
- `[REPORT_FILE]` —— 必填:实现者写入其详细报告的那个文件
|
||||||
|
- `[BASE_SHA]` —— 本任务之前的提交
|
||||||
|
- `[HEAD_SHA]` —— 当前提交
|
||||||
|
- `[DIFF_FILE]` —— 必填:控制者写入审查包的那个路径
|
||||||
|
(`scripts/review-package PLAN_FILE BASE HEAD` 会打印它写入的唯一路径;
|
||||||
|
审查包永远不会进入控制者的上下文)
|
||||||
|
|
||||||
|
**审查者返回:** 规格合规性结论(✅/❌/⚠️)、优点、问题
|
||||||
|
(关键/重要/次要)、任务质量结论
|
||||||
119
.codebuddy/skills/systematic-debugging/CREATION-LOG.md
Normal file
119
.codebuddy/skills/systematic-debugging/CREATION-LOG.md
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
# Creation Log: Systematic Debugging Skill
|
||||||
|
|
||||||
|
Reference example of extracting, structuring, and bulletproofing a critical skill.
|
||||||
|
|
||||||
|
## Source Material
|
||||||
|
|
||||||
|
Extracted debugging framework from `/Users/jesse/.claude/CLAUDE.md`:
|
||||||
|
- 4-phase systematic process (Investigation → Pattern Analysis → Hypothesis → Implementation)
|
||||||
|
- Core mandate: ALWAYS find root cause, NEVER fix symptoms
|
||||||
|
- Rules designed to resist time pressure and rationalization
|
||||||
|
|
||||||
|
## Extraction Decisions
|
||||||
|
|
||||||
|
**What to include:**
|
||||||
|
- Complete 4-phase framework with all rules
|
||||||
|
- Anti-shortcuts ("NEVER fix symptom", "STOP and re-analyze")
|
||||||
|
- Pressure-resistant language ("even if faster", "even if I seem in a hurry")
|
||||||
|
- Concrete steps for each phase
|
||||||
|
|
||||||
|
**What to leave out:**
|
||||||
|
- Project-specific context
|
||||||
|
- Repetitive variations of same rule
|
||||||
|
- Narrative explanations (condensed to principles)
|
||||||
|
|
||||||
|
## Structure Following skill-creation/SKILL.md
|
||||||
|
|
||||||
|
1. **Rich when_to_use** - Included symptoms and anti-patterns
|
||||||
|
2. **Type: technique** - Concrete process with steps
|
||||||
|
3. **Keywords** - "root cause", "symptom", "workaround", "debugging", "investigation"
|
||||||
|
4. **Flowchart** - Decision point for "fix failed" → re-analyze vs add more fixes
|
||||||
|
5. **Phase-by-phase breakdown** - Scannable checklist format
|
||||||
|
6. **Anti-patterns section** - What NOT to do (critical for this skill)
|
||||||
|
|
||||||
|
## Bulletproofing Elements
|
||||||
|
|
||||||
|
Framework designed to resist rationalization under pressure:
|
||||||
|
|
||||||
|
### Language Choices
|
||||||
|
- "ALWAYS" / "NEVER" (not "should" / "try to")
|
||||||
|
- "even if faster" / "even if I seem in a hurry"
|
||||||
|
- "STOP and re-analyze" (explicit pause)
|
||||||
|
- "Don't skip past" (catches the actual behavior)
|
||||||
|
|
||||||
|
### Structural Defenses
|
||||||
|
- **Phase 1 required** - Can't skip to implementation
|
||||||
|
- **Single hypothesis rule** - Forces thinking, prevents shotgun fixes
|
||||||
|
- **Explicit failure mode** - "IF your first fix doesn't work" with mandatory action
|
||||||
|
- **Anti-patterns section** - Shows exactly what shortcuts look like
|
||||||
|
|
||||||
|
### Redundancy
|
||||||
|
- Root cause mandate in overview + when_to_use + Phase 1 + implementation rules
|
||||||
|
- "NEVER fix symptom" appears 4 times in different contexts
|
||||||
|
- Each phase has explicit "don't skip" guidance
|
||||||
|
|
||||||
|
## Testing Approach
|
||||||
|
|
||||||
|
Created 4 validation tests following skills/meta/testing-skills-with-subagents:
|
||||||
|
|
||||||
|
### Test 1: Academic Context (No Pressure)
|
||||||
|
- Simple bug, no time pressure
|
||||||
|
- **Result:** Perfect compliance, complete investigation
|
||||||
|
|
||||||
|
### Test 2: Time Pressure + Obvious Quick Fix
|
||||||
|
- User "in a hurry", symptom fix looks easy
|
||||||
|
- **Result:** Resisted shortcut, followed full process, found real root cause
|
||||||
|
|
||||||
|
### Test 3: Complex System + Uncertainty
|
||||||
|
- Multi-layer failure, unclear if can find root cause
|
||||||
|
- **Result:** Systematic investigation, traced through all layers, found source
|
||||||
|
|
||||||
|
### Test 4: Failed First Fix
|
||||||
|
- Hypothesis doesn't work, temptation to add more fixes
|
||||||
|
- **Result:** Stopped, re-analyzed, formed new hypothesis (no shotgun)
|
||||||
|
|
||||||
|
**All tests passed.** No rationalizations found.
|
||||||
|
|
||||||
|
## Iterations
|
||||||
|
|
||||||
|
### Initial Version
|
||||||
|
- Complete 4-phase framework
|
||||||
|
- Anti-patterns section
|
||||||
|
- Flowchart for "fix failed" decision
|
||||||
|
|
||||||
|
### Enhancement 1: TDD Reference
|
||||||
|
- Added link to skills/testing/test-driven-development
|
||||||
|
- Note explaining TDD's "simplest code" ≠ debugging's "root cause"
|
||||||
|
- Prevents confusion between methodologies
|
||||||
|
|
||||||
|
## Final Outcome
|
||||||
|
|
||||||
|
Bulletproof skill that:
|
||||||
|
- ✅ Clearly mandates root cause investigation
|
||||||
|
- ✅ Resists time pressure rationalization
|
||||||
|
- ✅ Provides concrete steps for each phase
|
||||||
|
- ✅ Shows anti-patterns explicitly
|
||||||
|
- ✅ Tested under multiple pressure scenarios
|
||||||
|
- ✅ Clarifies relationship to TDD
|
||||||
|
- ✅ Ready for use
|
||||||
|
|
||||||
|
## Key Insight
|
||||||
|
|
||||||
|
**Most important bulletproofing:** Anti-patterns section showing exact shortcuts that feel justified in the moment. When Claude thinks "I'll just add this one quick fix", seeing that exact pattern listed as wrong creates cognitive friction.
|
||||||
|
|
||||||
|
## Usage Example
|
||||||
|
|
||||||
|
When encountering a bug:
|
||||||
|
1. Load skill: skills/debugging/systematic-debugging
|
||||||
|
2. Read overview (10 sec) - reminded of mandate
|
||||||
|
3. Follow Phase 1 checklist - forced investigation
|
||||||
|
4. If tempted to skip - see anti-pattern, stop
|
||||||
|
5. Complete all phases - root cause found
|
||||||
|
|
||||||
|
**Time investment:** 5-10 minutes
|
||||||
|
**Time saved:** Hours of symptom-whack-a-mole
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Created: 2025-10-03*
|
||||||
|
*Purpose: Reference example for skill extraction and bulletproofing*
|
||||||
289
.codebuddy/skills/systematic-debugging/SKILL.md
Normal file
289
.codebuddy/skills/systematic-debugging/SKILL.md
Normal file
@ -0,0 +1,289 @@
|
|||||||
|
---
|
||||||
|
name: systematic-debugging
|
||||||
|
description: 遇到任何 bug、测试失败或异常行为时使用,在提出修复方案之前执行
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [debugging]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 系统化调试
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
**核心原则:** 在尝试修复之前,务必先找到根本原因。只修症状就是失败。
|
||||||
|
|
||||||
|
**敷衍走流程等于违背调试的精神。**
|
||||||
|
|
||||||
|
## 铁律
|
||||||
|
|
||||||
|
```
|
||||||
|
不做根因调查,不许提修复方案
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你还没完成第一阶段,就不能提出修复方案。
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
用于任何技术问题:
|
||||||
|
- 测试失败
|
||||||
|
- 生产环境 bug
|
||||||
|
- 异常行为
|
||||||
|
- 性能问题
|
||||||
|
- 构建失败
|
||||||
|
- 集成问题
|
||||||
|
|
||||||
|
**尤其在以下情况必须使用:**
|
||||||
|
- 时间紧迫(紧急情况最容易让人猜测式修复)
|
||||||
|
- 觉得"一个小修改"就能搞定
|
||||||
|
- 已经尝试了多种修复
|
||||||
|
- 上一次修复没有生效
|
||||||
|
- 你没有完全理解问题
|
||||||
|
|
||||||
|
**以下情况也不要跳过:**
|
||||||
|
- 问题看起来很简单(简单的 bug 也有根本原因)
|
||||||
|
- 你很赶时间(越急越容易返工)
|
||||||
|
- 领导要求立刻修好(系统化调试比反复尝试更快)
|
||||||
|
|
||||||
|
## 四个阶段
|
||||||
|
|
||||||
|
你必须完成每个阶段后才能进入下一个。
|
||||||
|
|
||||||
|
### 第一阶段:根因调查
|
||||||
|
|
||||||
|
**在尝试任何修复之前:**
|
||||||
|
|
||||||
|
1. **仔细阅读错误信息**
|
||||||
|
- 不要跳过错误或警告
|
||||||
|
- 它们往往直接包含解决方案
|
||||||
|
- 完整阅读堆栈跟踪
|
||||||
|
- 记下行号、文件路径、错误码
|
||||||
|
|
||||||
|
2. **稳定复现**
|
||||||
|
- 你能可靠地触发它吗?
|
||||||
|
- 具体的复现步骤是什么?
|
||||||
|
- 每次都能复现吗?
|
||||||
|
- 如果无法复现 → 收集更多数据,不要猜测
|
||||||
|
|
||||||
|
3. **检查近期变更**
|
||||||
|
- 什么变更可能导致了这个问题?
|
||||||
|
- git diff、最近的提交
|
||||||
|
- 新依赖、配置变更
|
||||||
|
- 环境差异
|
||||||
|
|
||||||
|
4. **在多组件系统中收集证据**
|
||||||
|
|
||||||
|
**当系统有多个组件时(CI → 构建 → 签名,API → 服务 → 数据库):**
|
||||||
|
|
||||||
|
**在提出修复方案之前,先添加诊断埋点:**
|
||||||
|
```
|
||||||
|
对每个组件边界:
|
||||||
|
- 记录进入组件的数据
|
||||||
|
- 记录离开组件的数据
|
||||||
|
- 验证环境/配置的传递
|
||||||
|
- 检查每一层的状态
|
||||||
|
|
||||||
|
执行一次以收集证据,确定断裂点在哪里
|
||||||
|
然后分析证据,定位故障组件
|
||||||
|
然后针对该组件深入调查
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例(多层系统):**
|
||||||
|
```bash
|
||||||
|
# 第 1 层:工作流
|
||||||
|
echo "=== Secrets available in workflow: ==="
|
||||||
|
echo "IDENTITY: ${IDENTITY:+SET}${IDENTITY:-UNSET}"
|
||||||
|
|
||||||
|
# 第 2 层:构建脚本
|
||||||
|
echo "=== Env vars in build script: ==="
|
||||||
|
env | grep IDENTITY || echo "IDENTITY not in environment"
|
||||||
|
|
||||||
|
# 第 3 层:签名脚本
|
||||||
|
echo "=== Keychain state: ==="
|
||||||
|
security list-keychains
|
||||||
|
security find-identity -v
|
||||||
|
|
||||||
|
# 第 4 层:实际签名
|
||||||
|
codesign --sign "$IDENTITY" --verbose=4 "$APP"
|
||||||
|
```
|
||||||
|
|
||||||
|
**由此可以看出:** 哪一层出了问题(secrets → workflow ✓, workflow → build ✗)
|
||||||
|
|
||||||
|
5. **跟踪数据流**
|
||||||
|
|
||||||
|
**当错误发生在调用栈深处时:**
|
||||||
|
|
||||||
|
参见本目录下的 `root-cause-tracing.md`,了解完整的反向追踪技术。
|
||||||
|
|
||||||
|
**简要版本:**
|
||||||
|
- 错误值从哪里产生的?
|
||||||
|
- 谁用错误值调用了这里?
|
||||||
|
- 持续向上追踪直到找到源头
|
||||||
|
- 在源头修复,而不是在症状处修复
|
||||||
|
|
||||||
|
### 第二阶段:模式分析
|
||||||
|
|
||||||
|
**先找到模式,再修复:**
|
||||||
|
|
||||||
|
1. **找到可正常工作的示例**
|
||||||
|
- 在同一代码库中找到类似的正常代码
|
||||||
|
- 有什么正常的代码与出问题的代码相似?
|
||||||
|
|
||||||
|
2. **与参考实现对比**
|
||||||
|
- 如果是实现某个模式,完整阅读参考实现
|
||||||
|
- 不要略读——逐行阅读
|
||||||
|
- 在应用之前彻底理解该模式
|
||||||
|
|
||||||
|
3. **识别差异**
|
||||||
|
- 正常代码和出问题的代码之间有什么不同?
|
||||||
|
- 列出每一个差异,无论多小
|
||||||
|
- 不要假设"那不可能有影响"
|
||||||
|
|
||||||
|
4. **理解依赖关系**
|
||||||
|
- 这个功能需要哪些其他组件?
|
||||||
|
- 需要哪些设置、配置、环境?
|
||||||
|
- 它有哪些隐含假设?
|
||||||
|
|
||||||
|
### 第三阶段:假设与验证
|
||||||
|
|
||||||
|
**科学方法:**
|
||||||
|
|
||||||
|
1. **提出单一假设**
|
||||||
|
- 清晰地陈述:"我认为 X 是根本原因,因为 Y"
|
||||||
|
- 写下来
|
||||||
|
- 要具体,不要含糊
|
||||||
|
|
||||||
|
2. **最小化测试**
|
||||||
|
- 做出最小的改动来验证假设
|
||||||
|
- 每次只改一个变量
|
||||||
|
- 不要同时修复多个问题
|
||||||
|
|
||||||
|
3. **继续之前先验证**
|
||||||
|
- 生效了?是 → 进入第四阶段
|
||||||
|
- 没生效?提出新假设
|
||||||
|
- 不要在上面叠加更多修复
|
||||||
|
|
||||||
|
4. **当你不确定时**
|
||||||
|
- 说"我不理解 X"
|
||||||
|
- 不要假装自己知道
|
||||||
|
- 寻求帮助
|
||||||
|
- 做更多调研
|
||||||
|
|
||||||
|
### 第四阶段:实施
|
||||||
|
|
||||||
|
**修复根本原因,而非症状:**
|
||||||
|
|
||||||
|
1. **创建失败的测试用例**
|
||||||
|
- 最简化的复现
|
||||||
|
- 尽可能用自动化测试
|
||||||
|
- 没有测试框架就写一次性测试脚本
|
||||||
|
- 修复前必须先有测试
|
||||||
|
- 使用 `test-driven-development` 技能来编写规范的失败测试
|
||||||
|
|
||||||
|
2. **实施单一修复**
|
||||||
|
- 修复已定位的根本原因
|
||||||
|
- 每次只改一处
|
||||||
|
- 不做"顺便改改"的优化
|
||||||
|
- 不捆绑重构
|
||||||
|
|
||||||
|
3. **验证修复**
|
||||||
|
- 测试现在通过了吗?
|
||||||
|
- 其他测试没有被破坏吧?
|
||||||
|
- 问题真的解决了吗?
|
||||||
|
- 宣称成功之前,使用 `verification-before-completion` 技能
|
||||||
|
|
||||||
|
4. **如果修复不起作用**
|
||||||
|
- 停下来
|
||||||
|
- 数一数:你已经尝试了几次修复?
|
||||||
|
- 少于 3 次:回到第一阶段,用新信息重新分析
|
||||||
|
- **3 次或以上:停下来质疑架构(见下方第 5 步)**
|
||||||
|
- 没有经过架构讨论,不要尝试第 4 次修复
|
||||||
|
|
||||||
|
5. **如果 3 次以上修复都失败了:质疑架构**
|
||||||
|
|
||||||
|
**以下模式表明存在架构问题:**
|
||||||
|
- 每次修复都暴露出新的共享状态/耦合/其他位置的问题
|
||||||
|
- 修复需要"大规模重构"才能实现
|
||||||
|
- 每次修复都在其他地方产生新的症状
|
||||||
|
|
||||||
|
**停下来质疑根本性问题:**
|
||||||
|
- 这个模式从根本上合理吗?
|
||||||
|
- 我们是不是在"惯性驱动"下坚持了错误方案?
|
||||||
|
- 应该重构架构还是继续修补症状?
|
||||||
|
|
||||||
|
**在尝试更多修复之前,和你的搭档讨论**
|
||||||
|
|
||||||
|
这不是假设失败——这是架构有误。
|
||||||
|
|
||||||
|
## 红线——停下来,按流程走
|
||||||
|
|
||||||
|
如果你发现自己在想:
|
||||||
|
- "先临时修一下,以后再排查"
|
||||||
|
- "试着改改 X 看看行不行"
|
||||||
|
- "一次性改多个地方,跑测试看看"
|
||||||
|
- "跳过测试,我手动验证"
|
||||||
|
- "大概是 X 的问题,让我修一下"
|
||||||
|
- "我不完全理解,但这应该能行"
|
||||||
|
- "模式说的是 X,但我换个方式用"
|
||||||
|
- "主要问题有这些:[未经调查就列出修复方案]"
|
||||||
|
- 没有追踪数据流就提出解决方案
|
||||||
|
- **"再试一次修复"(已经尝试了 2 次以上)**
|
||||||
|
- **每次修复都暴露出不同地方的新问题**
|
||||||
|
|
||||||
|
**以上这些都意味着:停下来。回到第一阶段。**
|
||||||
|
|
||||||
|
**如果 3 次以上修复都失败了:** 质疑架构(见第四阶段第 5 步)
|
||||||
|
|
||||||
|
## 搭档发出的信号——说明你的方法不对
|
||||||
|
|
||||||
|
**留意这些提醒:**
|
||||||
|
- "难道不是这样吗?"——你在没有验证的情况下做了假设
|
||||||
|
- "它能告诉我们……吗?"——你应该先收集证据
|
||||||
|
- "别猜了"——你在没有理解的情况下提出修复
|
||||||
|
- "深入想想"——要质疑根本性问题,而不只是症状
|
||||||
|
- "我们卡住了?"(沮丧的语气)——你的方法没有奏效
|
||||||
|
|
||||||
|
**当你看到这些信号时:** 停下来。回到第一阶段。
|
||||||
|
|
||||||
|
## 常见借口
|
||||||
|
|
||||||
|
| 借口 | 现实 |
|
||||||
|
|------|------|
|
||||||
|
| "问题很简单,不需要走流程" | 简单问题也有根本原因。对于简单 bug,流程很快就能走完。 |
|
||||||
|
| "紧急情况,没时间走流程" | 系统化调试比反复猜测式修复更快。 |
|
||||||
|
| "先试一下,再排查" | 第一次修复就定下了基调。从一开始就做对。 |
|
||||||
|
| "确认修复有效后再写测试" | 没有测试的修复留不住。先写测试才能证明修复有效。 |
|
||||||
|
| "一次修多个问题省时间" | 无法隔离哪个生效了。还会引入新 bug。 |
|
||||||
|
| "参考实现太长了,我自己改改" | 一知半解必然出 bug。完整阅读。 |
|
||||||
|
| "我看出问题了,让我修一下" | 看到症状 ≠ 理解根因。 |
|
||||||
|
| "再试一次"(在 2 次以上失败后) | 3 次以上失败 = 架构问题。质疑模式,不要继续修。 |
|
||||||
|
|
||||||
|
## 速查表
|
||||||
|
|
||||||
|
| 阶段 | 关键活动 | 通过标准 |
|
||||||
|
|------|---------|---------|
|
||||||
|
| **1. 根因** | 阅读错误、复现、检查变更、收集证据 | 理解了什么出了问题以及为什么 |
|
||||||
|
| **2. 模式** | 找到正常示例、对比 | 识别出差异 |
|
||||||
|
| **3. 假设** | 提出理论、最小化验证 | 假设被验证或产生新假设 |
|
||||||
|
| **4. 实施** | 创建测试、修复、验证 | bug 已修复,测试通过 |
|
||||||
|
|
||||||
|
## 当流程显示"找不到根因"
|
||||||
|
|
||||||
|
如果系统化排查后发现问题确实是环境相关、时序相关或外部因素导致的:
|
||||||
|
|
||||||
|
1. 你已经完成了流程
|
||||||
|
2. 记录你排查了什么
|
||||||
|
3. 实施适当的处理措施(重试、超时、错误提示)
|
||||||
|
4. 添加监控/日志以便后续排查
|
||||||
|
|
||||||
|
**但是:** 95% 的"找不到根因"其实是排查不充分。
|
||||||
|
|
||||||
|
## 辅助技术
|
||||||
|
|
||||||
|
以下技术是系统化调试的组成部分,可在本目录中找到:
|
||||||
|
|
||||||
|
- **`root-cause-tracing.md`** - 沿调用栈反向追踪 bug,找到最初的触发点
|
||||||
|
- **`defense-in-depth.md`** - 找到根因后,在多个层级添加校验
|
||||||
|
- **`condition-based-waiting.md`** - 用条件轮询替代硬编码等待时间
|
||||||
|
|
||||||
@ -0,0 +1,158 @@
|
|||||||
|
// Complete implementation of condition-based waiting utilities
|
||||||
|
// From: Lace test infrastructure improvements (2025-10-03)
|
||||||
|
// Context: Fixed 15 flaky tests by replacing arbitrary timeouts
|
||||||
|
|
||||||
|
import type { ThreadManager } from '~/threads/thread-manager';
|
||||||
|
import type { LaceEvent, LaceEventType } from '~/threads/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a specific event type to appear in thread
|
||||||
|
*
|
||||||
|
* @param threadManager - The thread manager to query
|
||||||
|
* @param threadId - Thread to check for events
|
||||||
|
* @param eventType - Type of event to wait for
|
||||||
|
* @param timeoutMs - Maximum time to wait (default 5000ms)
|
||||||
|
* @returns Promise resolving to the first matching event
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* await waitForEvent(threadManager, agentThreadId, 'TOOL_RESULT');
|
||||||
|
*/
|
||||||
|
export function waitForEvent(
|
||||||
|
threadManager: ThreadManager,
|
||||||
|
threadId: string,
|
||||||
|
eventType: LaceEventType,
|
||||||
|
timeoutMs = 5000
|
||||||
|
): Promise<LaceEvent> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
const events = threadManager.getEvents(threadId);
|
||||||
|
const event = events.find((e) => e.type === eventType);
|
||||||
|
|
||||||
|
if (event) {
|
||||||
|
resolve(event);
|
||||||
|
} else if (Date.now() - startTime > timeoutMs) {
|
||||||
|
reject(new Error(`Timeout waiting for ${eventType} event after ${timeoutMs}ms`));
|
||||||
|
} else {
|
||||||
|
setTimeout(check, 10); // Poll every 10ms for efficiency
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for a specific number of events of a given type
|
||||||
|
*
|
||||||
|
* @param threadManager - The thread manager to query
|
||||||
|
* @param threadId - Thread to check for events
|
||||||
|
* @param eventType - Type of event to wait for
|
||||||
|
* @param count - Number of events to wait for
|
||||||
|
* @param timeoutMs - Maximum time to wait (default 5000ms)
|
||||||
|
* @returns Promise resolving to all matching events once count is reached
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* // Wait for 2 AGENT_MESSAGE events (initial response + continuation)
|
||||||
|
* await waitForEventCount(threadManager, agentThreadId, 'AGENT_MESSAGE', 2);
|
||||||
|
*/
|
||||||
|
export function waitForEventCount(
|
||||||
|
threadManager: ThreadManager,
|
||||||
|
threadId: string,
|
||||||
|
eventType: LaceEventType,
|
||||||
|
count: number,
|
||||||
|
timeoutMs = 5000
|
||||||
|
): Promise<LaceEvent[]> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
const events = threadManager.getEvents(threadId);
|
||||||
|
const matchingEvents = events.filter((e) => e.type === eventType);
|
||||||
|
|
||||||
|
if (matchingEvents.length >= count) {
|
||||||
|
resolve(matchingEvents);
|
||||||
|
} else if (Date.now() - startTime > timeoutMs) {
|
||||||
|
reject(
|
||||||
|
new Error(
|
||||||
|
`Timeout waiting for ${count} ${eventType} events after ${timeoutMs}ms (got ${matchingEvents.length})`
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setTimeout(check, 10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for an event matching a custom predicate
|
||||||
|
* Useful when you need to check event data, not just type
|
||||||
|
*
|
||||||
|
* @param threadManager - The thread manager to query
|
||||||
|
* @param threadId - Thread to check for events
|
||||||
|
* @param predicate - Function that returns true when event matches
|
||||||
|
* @param description - Human-readable description for error messages
|
||||||
|
* @param timeoutMs - Maximum time to wait (default 5000ms)
|
||||||
|
* @returns Promise resolving to the first matching event
|
||||||
|
*
|
||||||
|
* Example:
|
||||||
|
* // Wait for TOOL_RESULT with specific ID
|
||||||
|
* await waitForEventMatch(
|
||||||
|
* threadManager,
|
||||||
|
* agentThreadId,
|
||||||
|
* (e) => e.type === 'TOOL_RESULT' && e.data.id === 'call_123',
|
||||||
|
* 'TOOL_RESULT with id=call_123'
|
||||||
|
* );
|
||||||
|
*/
|
||||||
|
export function waitForEventMatch(
|
||||||
|
threadManager: ThreadManager,
|
||||||
|
threadId: string,
|
||||||
|
predicate: (event: LaceEvent) => boolean,
|
||||||
|
description: string,
|
||||||
|
timeoutMs = 5000
|
||||||
|
): Promise<LaceEvent> {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
const check = () => {
|
||||||
|
const events = threadManager.getEvents(threadId);
|
||||||
|
const event = events.find(predicate);
|
||||||
|
|
||||||
|
if (event) {
|
||||||
|
resolve(event);
|
||||||
|
} else if (Date.now() - startTime > timeoutMs) {
|
||||||
|
reject(new Error(`Timeout waiting for ${description} after ${timeoutMs}ms`));
|
||||||
|
} else {
|
||||||
|
setTimeout(check, 10);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
check();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Usage example from actual debugging session:
|
||||||
|
//
|
||||||
|
// BEFORE (flaky):
|
||||||
|
// ---------------
|
||||||
|
// const messagePromise = agent.sendMessage('Execute tools');
|
||||||
|
// await new Promise(r => setTimeout(r, 300)); // Hope tools start in 300ms
|
||||||
|
// agent.abort();
|
||||||
|
// await messagePromise;
|
||||||
|
// await new Promise(r => setTimeout(r, 50)); // Hope results arrive in 50ms
|
||||||
|
// expect(toolResults.length).toBe(2); // Fails randomly
|
||||||
|
//
|
||||||
|
// AFTER (reliable):
|
||||||
|
// ----------------
|
||||||
|
// const messagePromise = agent.sendMessage('Execute tools');
|
||||||
|
// await waitForEventCount(threadManager, threadId, 'TOOL_CALL', 2); // Wait for tools to start
|
||||||
|
// agent.abort();
|
||||||
|
// await messagePromise;
|
||||||
|
// await waitForEventCount(threadManager, threadId, 'TOOL_RESULT', 2); // Wait for results
|
||||||
|
// expect(toolResults.length).toBe(2); // Always succeeds
|
||||||
|
//
|
||||||
|
// Result: 60% pass rate → 100%, 40% faster execution
|
||||||
@ -0,0 +1,115 @@
|
|||||||
|
# 基于条件的等待
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
不稳定的测试通常用硬编码延迟来猜测时序。这会造成竞态条件——在快速机器上通过,在高负载或 CI 环境下失败。
|
||||||
|
|
||||||
|
**核心原则:** 等待你真正关心的条件,而不是猜测它需要多长时间。
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
```dot
|
||||||
|
digraph when_to_use {
|
||||||
|
"测试使用了 setTimeout/sleep?" [shape=diamond];
|
||||||
|
"是在测试时序行为吗?" [shape=diamond];
|
||||||
|
"记录为什么需要超时" [shape=box];
|
||||||
|
"使用基于条件的等待" [shape=box];
|
||||||
|
|
||||||
|
"测试使用了 setTimeout/sleep?" -> "是在测试时序行为吗?" [label="是"];
|
||||||
|
"是在测试时序行为吗?" -> "记录为什么需要超时" [label="是"];
|
||||||
|
"是在测试时序行为吗?" -> "使用基于条件的等待" [label="否"];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**适用场景:**
|
||||||
|
- 测试中有硬编码延迟(`setTimeout`、`sleep`、`time.sleep()`)
|
||||||
|
- 测试不稳定(时而通过,高负载下失败)
|
||||||
|
- 并行运行时测试超时
|
||||||
|
- 等待异步操作完成
|
||||||
|
|
||||||
|
**不适用场景:**
|
||||||
|
- 测试实际的时序行为(防抖、节流间隔)
|
||||||
|
- 如果使用硬编码超时,务必注释说明原因
|
||||||
|
|
||||||
|
## 核心模式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 之前:猜测时序
|
||||||
|
await new Promise(r => setTimeout(r, 50));
|
||||||
|
const result = getResult();
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
|
||||||
|
// ✅ 之后:等待条件满足
|
||||||
|
await waitFor(() => getResult() !== undefined);
|
||||||
|
const result = getResult();
|
||||||
|
expect(result).toBeDefined();
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用模式速查
|
||||||
|
|
||||||
|
| 场景 | 模式 |
|
||||||
|
|------|------|
|
||||||
|
| 等待事件 | `waitFor(() => events.find(e => e.type === 'DONE'))` |
|
||||||
|
| 等待状态 | `waitFor(() => machine.state === 'ready')` |
|
||||||
|
| 等待数量 | `waitFor(() => items.length >= 5)` |
|
||||||
|
| 等待文件 | `waitFor(() => fs.existsSync(path))` |
|
||||||
|
| 复合条件 | `waitFor(() => obj.ready && obj.value > 10)` |
|
||||||
|
|
||||||
|
## 实现方式
|
||||||
|
|
||||||
|
通用轮询函数:
|
||||||
|
```typescript
|
||||||
|
async function waitFor<T>(
|
||||||
|
condition: () => T | undefined | null | false,
|
||||||
|
description: string,
|
||||||
|
timeoutMs = 5000
|
||||||
|
): Promise<T> {
|
||||||
|
const startTime = Date.now();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
const result = condition();
|
||||||
|
if (result) return result;
|
||||||
|
|
||||||
|
if (Date.now() - startTime > timeoutMs) {
|
||||||
|
throw new Error(`Timeout waiting for ${description} after ${timeoutMs}ms`);
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise(r => setTimeout(r, 10)); // 每 10ms 轮询一次
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
参见本目录下的 `condition-based-waiting-example.ts`,其中包含完整实现和领域专用辅助函数(`waitForEvent`、`waitForEventCount`、`waitForEventMatch`),源自实际调试过程。
|
||||||
|
|
||||||
|
## 常见错误
|
||||||
|
|
||||||
|
**❌ 轮询太频繁:** `setTimeout(check, 1)` —— 浪费 CPU
|
||||||
|
**✅ 修正:** 每 10ms 轮询一次
|
||||||
|
|
||||||
|
**❌ 没有超时:** 条件永远不满足时无限循环
|
||||||
|
**✅ 修正:** 始终设置超时并提供清晰的错误信息
|
||||||
|
|
||||||
|
**❌ 数据过期:** 在循环外缓存状态
|
||||||
|
**✅ 修正:** 在循环内调用 getter 获取最新数据
|
||||||
|
|
||||||
|
## 何时硬编码超时是正确的
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 工具每 100ms tick 一次——需要 2 次 tick 来验证部分输出
|
||||||
|
await waitForEvent(manager, 'TOOL_STARTED'); // 首先:等待条件
|
||||||
|
await new Promise(r => setTimeout(r, 200)); // 然后:等待有明确时序依据的行为
|
||||||
|
// 200ms = 100ms 间隔的 2 次 tick——有文档说明且有充分理由
|
||||||
|
```
|
||||||
|
|
||||||
|
**使用要求:**
|
||||||
|
1. 首先等待触发条件
|
||||||
|
2. 基于已知时序(而非猜测)
|
||||||
|
3. 注释说明原因
|
||||||
|
|
||||||
|
## 实际效果
|
||||||
|
|
||||||
|
来自调试实践(2025-10-03):
|
||||||
|
- 修复了 3 个文件中的 15 个不稳定测试
|
||||||
|
- 通过率:60% → 100%
|
||||||
|
- 执行时间:快了 40%
|
||||||
|
- 再无竞态条件
|
||||||
122
.codebuddy/skills/systematic-debugging/defense-in-depth.md
Normal file
122
.codebuddy/skills/systematic-debugging/defense-in-depth.md
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
# 纵深防御校验
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
当你修复了一个由无效数据引起的 bug 时,在一个地方加校验似乎就够了。但这个单点检查可能会被不同的代码路径、重构或 mock 绕过。
|
||||||
|
|
||||||
|
**核心原则:** 在数据经过的每一层都做校验。让这个 bug 在结构上不可能发生。
|
||||||
|
|
||||||
|
## 为什么需要多层校验
|
||||||
|
|
||||||
|
单层校验:"我们修了这个 bug"
|
||||||
|
多层校验:"我们让这个 bug 不可能再发生"
|
||||||
|
|
||||||
|
不同层级能捕获不同问题:
|
||||||
|
- 入口校验捕获大多数 bug
|
||||||
|
- 业务逻辑校验捕获边界情况
|
||||||
|
- 环境守卫防止特定上下文的危险操作
|
||||||
|
- 调试日志在其他层级失效时提供帮助
|
||||||
|
|
||||||
|
## 四个层级
|
||||||
|
|
||||||
|
### 第 1 层:入口校验
|
||||||
|
**目的:** 在 API 边界拒绝明显无效的输入
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function createProject(name: string, workingDirectory: string) {
|
||||||
|
if (!workingDirectory || workingDirectory.trim() === '') {
|
||||||
|
throw new Error('workingDirectory cannot be empty');
|
||||||
|
}
|
||||||
|
if (!existsSync(workingDirectory)) {
|
||||||
|
throw new Error(`workingDirectory does not exist: ${workingDirectory}`);
|
||||||
|
}
|
||||||
|
if (!statSync(workingDirectory).isDirectory()) {
|
||||||
|
throw new Error(`workingDirectory is not a directory: ${workingDirectory}`);
|
||||||
|
}
|
||||||
|
// ... 继续处理
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第 2 层:业务逻辑校验
|
||||||
|
**目的:** 确保数据对当前操作是合理的
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
function initializeWorkspace(projectDir: string, sessionId: string) {
|
||||||
|
if (!projectDir) {
|
||||||
|
throw new Error('projectDir required for workspace initialization');
|
||||||
|
}
|
||||||
|
// ... 继续处理
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第 3 层:环境守卫
|
||||||
|
**目的:** 防止在特定环境中执行危险操作
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function gitInit(directory: string) {
|
||||||
|
// 在测试中,拒绝在临时目录之外执行 git init
|
||||||
|
if (process.env.NODE_ENV === 'test') {
|
||||||
|
const normalized = normalize(resolve(directory));
|
||||||
|
const tmpDir = normalize(resolve(tmpdir()));
|
||||||
|
|
||||||
|
if (!normalized.startsWith(tmpDir)) {
|
||||||
|
throw new Error(
|
||||||
|
`Refusing git init outside temp dir during tests: ${directory}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// ... 继续处理
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第 4 层:调试埋点
|
||||||
|
**目的:** 记录上下文信息以便事后分析
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
async function gitInit(directory: string) {
|
||||||
|
const stack = new Error().stack;
|
||||||
|
logger.debug('About to git init', {
|
||||||
|
directory,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
stack,
|
||||||
|
});
|
||||||
|
// ... 继续处理
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 应用模式
|
||||||
|
|
||||||
|
当你发现一个 bug 时:
|
||||||
|
|
||||||
|
1. **追踪数据流** —— 错误值从哪里产生的?在哪里被使用?
|
||||||
|
2. **标注所有检查点** —— 列出数据经过的每一个节点
|
||||||
|
3. **在每一层添加校验** —— 入口、业务逻辑、环境、调试
|
||||||
|
4. **测试每一层** —— 尝试绕过第 1 层,验证第 2 层能否捕获
|
||||||
|
|
||||||
|
## 实际案例
|
||||||
|
|
||||||
|
Bug:空的 `projectDir` 导致 `git init` 在源代码目录执行
|
||||||
|
|
||||||
|
**数据流:**
|
||||||
|
1. 测试准备 → 空字符串
|
||||||
|
2. `Project.create(name, '')`
|
||||||
|
3. `WorkspaceManager.createWorkspace('')`
|
||||||
|
4. `git init` 在 `process.cwd()` 中执行
|
||||||
|
|
||||||
|
**添加的四层防御:**
|
||||||
|
- 第 1 层:`Project.create()` 校验非空/存在/可写
|
||||||
|
- 第 2 层:`WorkspaceManager` 校验 projectDir 非空
|
||||||
|
- 第 3 层:`WorktreeManager` 在测试中拒绝在 tmpdir 之外执行 git init
|
||||||
|
- 第 4 层:git init 前记录堆栈跟踪
|
||||||
|
|
||||||
|
**结果:** 全部 1847 个测试通过,bug 不可能再复现
|
||||||
|
|
||||||
|
## 关键洞察
|
||||||
|
|
||||||
|
四个层级缺一不可。在测试过程中,每一层都捕获了其他层遗漏的 bug:
|
||||||
|
- 不同的代码路径绕过了入口校验
|
||||||
|
- mock 绕过了业务逻辑检查
|
||||||
|
- 不同平台的边界情况需要环境守卫
|
||||||
|
- 调试日志发现了结构性误用
|
||||||
|
|
||||||
|
**不要止步于一个校验点。** 在每一层都添加检查。
|
||||||
72
.codebuddy/skills/systematic-debugging/find-polluter.sh
Normal file
72
.codebuddy/skills/systematic-debugging/find-polluter.sh
Normal file
@ -0,0 +1,72 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Bisection script to find which test creates unwanted files/state
|
||||||
|
# Usage: ./find-polluter.sh <file_or_dir_to_check> <test_pattern>
|
||||||
|
# Example: ./find-polluter.sh '.git' 'src/**/*.test.ts'
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
if [ $# -ne 2 ]; then
|
||||||
|
echo "Usage: $0 <file_to_check> <test_pattern>"
|
||||||
|
echo "Example: $0 '.git' 'src/**/*.test.ts'"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
POLLUTION_CHECK="$1"
|
||||||
|
TEST_PATTERN="$2"
|
||||||
|
|
||||||
|
echo "🔍 Searching for test that creates: $POLLUTION_CHECK"
|
||||||
|
echo "Test pattern: $TEST_PATTERN"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Get list of test files (find . emits ./-prefixed paths, so accept the
|
||||||
|
# pattern written with or without a leading ./)
|
||||||
|
TEST_PATTERN="${TEST_PATTERN#./}"
|
||||||
|
# find -path can't match '**/' against zero directory levels, so a pattern
|
||||||
|
# like src/**/*.test.ts would skip src/top.test.ts; also try the pattern
|
||||||
|
# with '**/' collapsed to cover files directly under the base directory.
|
||||||
|
TEST_FILES=$(find . \( -path "./$TEST_PATTERN" -o -path "./${TEST_PATTERN//\*\*\//}" \) | sort -u)
|
||||||
|
if [ -z "$TEST_FILES" ]; then
|
||||||
|
TOTAL=0
|
||||||
|
else
|
||||||
|
TOTAL=$(printf '%s\n' "$TEST_FILES" | wc -l | tr -d ' ')
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "Found $TOTAL test files"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
COUNT=0
|
||||||
|
for TEST_FILE in $TEST_FILES; do
|
||||||
|
COUNT=$((COUNT + 1))
|
||||||
|
|
||||||
|
# Skip if pollution already exists
|
||||||
|
if [ -e "$POLLUTION_CHECK" ]; then
|
||||||
|
echo "⚠️ Pollution already exists before test $COUNT/$TOTAL"
|
||||||
|
echo " Skipping: $TEST_FILE"
|
||||||
|
continue
|
||||||
|
fi
|
||||||
|
|
||||||
|
echo "[$COUNT/$TOTAL] Testing: $TEST_FILE"
|
||||||
|
|
||||||
|
# Run the test
|
||||||
|
npm test "$TEST_FILE" > /dev/null 2>&1 || true
|
||||||
|
|
||||||
|
# Check if pollution appeared
|
||||||
|
if [ -e "$POLLUTION_CHECK" ]; then
|
||||||
|
echo ""
|
||||||
|
echo "🎯 FOUND POLLUTER!"
|
||||||
|
echo " Test: $TEST_FILE"
|
||||||
|
echo " Created: $POLLUTION_CHECK"
|
||||||
|
echo ""
|
||||||
|
echo "Pollution details:"
|
||||||
|
ls -la "$POLLUTION_CHECK"
|
||||||
|
echo ""
|
||||||
|
echo "To investigate:"
|
||||||
|
echo " npm test $TEST_FILE # Run just this test"
|
||||||
|
echo " cat $TEST_FILE # Review test code"
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "✅ No polluter found - all tests clean!"
|
||||||
|
exit 0
|
||||||
169
.codebuddy/skills/systematic-debugging/root-cause-tracing.md
Normal file
169
.codebuddy/skills/systematic-debugging/root-cause-tracing.md
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
# 根因追踪
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
Bug 通常表现在调用栈深处(在错误目录执行 git init、在错误位置创建文件、用错误路径打开数据库)。你的本能是在错误出现的地方修复,但那只是治标。
|
||||||
|
|
||||||
|
**核心原则:** 沿着调用链反向追踪,直到找到最初的触发点,然后在源头修复。
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
```dot
|
||||||
|
digraph when_to_use {
|
||||||
|
"Bug 出现在调用栈深处?" [shape=diamond];
|
||||||
|
"能反向追踪吗?" [shape=diamond];
|
||||||
|
"在症状处修复" [shape=box];
|
||||||
|
"追踪到最初的触发点" [shape=box];
|
||||||
|
"更好的做法:同时添加纵深防御" [shape=box];
|
||||||
|
|
||||||
|
"Bug 出现在调用栈深处?" -> "能反向追踪吗?" [label="是"];
|
||||||
|
"能反向追踪吗?" -> "追踪到最初的触发点" [label="是"];
|
||||||
|
"能反向追踪吗?" -> "在症状处修复" [label="否——死胡同"];
|
||||||
|
"追踪到最初的触发点" -> "更好的做法:同时添加纵深防御";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**适用场景:**
|
||||||
|
- 错误发生在执行深处(不在入口点)
|
||||||
|
- 堆栈跟踪显示很长的调用链
|
||||||
|
- 不清楚无效数据从哪里来
|
||||||
|
- 需要找到是哪个测试/代码触发了问题
|
||||||
|
|
||||||
|
## 追踪流程
|
||||||
|
|
||||||
|
### 1. 观察症状
|
||||||
|
```
|
||||||
|
Error: git init failed in /Users/jesse/project/packages/core
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 找到直接原因
|
||||||
|
**哪段代码直接导致了这个错误?**
|
||||||
|
```typescript
|
||||||
|
await execFileAsync('git', ['init'], { cwd: projectDir });
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 问:谁调用了它?
|
||||||
|
```typescript
|
||||||
|
WorktreeManager.createSessionWorktree(projectDir, sessionId)
|
||||||
|
→ 被 Session.initializeWorkspace() 调用
|
||||||
|
→ 被 Session.create() 调用
|
||||||
|
→ 被测试中的 Project.create() 调用
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 继续向上追踪
|
||||||
|
**传入了什么值?**
|
||||||
|
- `projectDir = ''`(空字符串!)
|
||||||
|
- 空字符串作为 `cwd` 会解析为 `process.cwd()`
|
||||||
|
- 那就是源代码目录!
|
||||||
|
|
||||||
|
### 5. 找到最初的触发点
|
||||||
|
**空字符串从哪里来的?**
|
||||||
|
```typescript
|
||||||
|
const context = setupCoreTest(); // 返回 { tempDir: '' }
|
||||||
|
Project.create('name', context.tempDir); // 在 beforeEach 之前就访问了!
|
||||||
|
```
|
||||||
|
|
||||||
|
## 添加堆栈跟踪
|
||||||
|
|
||||||
|
当无法手动追踪时,添加诊断埋点:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 在有问题的操作之前
|
||||||
|
async function gitInit(directory: string) {
|
||||||
|
const stack = new Error().stack;
|
||||||
|
console.error('DEBUG git init:', {
|
||||||
|
directory,
|
||||||
|
cwd: process.cwd(),
|
||||||
|
nodeEnv: process.env.NODE_ENV,
|
||||||
|
stack,
|
||||||
|
});
|
||||||
|
|
||||||
|
await execFileAsync('git', ['init'], { cwd: directory });
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**重要:** 在测试中使用 `console.error()`(而非 logger——可能不会显示)
|
||||||
|
|
||||||
|
**运行并捕获:**
|
||||||
|
```bash
|
||||||
|
npm test 2>&1 | grep 'DEBUG git init'
|
||||||
|
```
|
||||||
|
|
||||||
|
**分析堆栈跟踪:**
|
||||||
|
- 找测试文件名
|
||||||
|
- 找触发调用的行号
|
||||||
|
- 识别模式(同一个测试?同一个参数?)
|
||||||
|
|
||||||
|
## 找出导致污染的测试
|
||||||
|
|
||||||
|
如果某些现象在测试期间出现,但你不知道是哪个测试造成的:
|
||||||
|
|
||||||
|
使用本目录下的二分查找脚本 `find-polluter.sh`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./find-polluter.sh '.git' 'src/**/*.test.ts'
|
||||||
|
```
|
||||||
|
|
||||||
|
逐个运行测试,在第一个"污染者"处停止。详见脚本中的使用说明。
|
||||||
|
|
||||||
|
## 真实案例:空的 projectDir
|
||||||
|
|
||||||
|
**症状:** `.git` 被创建在 `packages/core/`(源代码目录)中
|
||||||
|
|
||||||
|
**追踪链:**
|
||||||
|
1. `git init` 在 `process.cwd()` 中执行 ← cwd 参数为空
|
||||||
|
2. WorktreeManager 被传入空的 projectDir
|
||||||
|
3. Session.create() 传递了空字符串
|
||||||
|
4. 测试在 beforeEach 之前访问了 `context.tempDir`
|
||||||
|
5. setupCoreTest() 初始返回 `{ tempDir: '' }`
|
||||||
|
|
||||||
|
**根本原因:** 顶层变量初始化时访问了空值
|
||||||
|
|
||||||
|
**修复:** 将 tempDir 改为 getter,在 beforeEach 之前访问时抛出异常
|
||||||
|
|
||||||
|
**同时添加了纵深防御:**
|
||||||
|
- 第 1 层:Project.create() 校验目录
|
||||||
|
- 第 2 层:WorkspaceManager 校验非空
|
||||||
|
- 第 3 层:NODE_ENV 守卫拒绝在 tmpdir 之外执行 git init
|
||||||
|
- 第 4 层:git init 前记录堆栈跟踪
|
||||||
|
|
||||||
|
## 关键原则
|
||||||
|
|
||||||
|
```dot
|
||||||
|
digraph principle {
|
||||||
|
"找到了直接原因" [shape=ellipse];
|
||||||
|
"能向上追踪一层吗?" [shape=diamond];
|
||||||
|
"反向追踪" [shape=box];
|
||||||
|
"这就是源头吗?" [shape=diamond];
|
||||||
|
"在源头修复" [shape=box];
|
||||||
|
"在每一层添加校验" [shape=box];
|
||||||
|
"Bug 不可能再发生" [shape=doublecircle];
|
||||||
|
"绝不只修症状" [shape=octagon, style=filled, fillcolor=red, fontcolor=white];
|
||||||
|
|
||||||
|
"找到了直接原因" -> "能向上追踪一层吗?";
|
||||||
|
"能向上追踪一层吗?" -> "反向追踪" [label="是"];
|
||||||
|
"能向上追踪一层吗?" -> "绝不只修症状" [label="否"];
|
||||||
|
"反向追踪" -> "这就是源头吗?";
|
||||||
|
"这就是源头吗?" -> "反向追踪" [label="否——继续追踪"];
|
||||||
|
"这就是源头吗?" -> "在源头修复" [label="是"];
|
||||||
|
"在源头修复" -> "在每一层添加校验";
|
||||||
|
"在每一层添加校验" -> "Bug 不可能再发生";
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**绝不只在错误出现的地方修复。** 反向追踪,找到最初的触发点。
|
||||||
|
|
||||||
|
## 堆栈跟踪技巧
|
||||||
|
|
||||||
|
**在测试中:** 使用 `console.error()` 而非 logger——logger 可能被抑制
|
||||||
|
**操作之前:** 在危险操作之前记录日志,而不是在失败之后
|
||||||
|
**包含上下文:** 目录、cwd、环境变量、时间戳
|
||||||
|
**捕获堆栈:** `new Error().stack` 能显示完整的调用链
|
||||||
|
|
||||||
|
## 实际效果
|
||||||
|
|
||||||
|
来自调试实践(2025-10-03):
|
||||||
|
- 通过 5 层追踪找到了根本原因
|
||||||
|
- 在源头修复(getter 校验)
|
||||||
|
- 添加了 4 层纵深防御
|
||||||
|
- 1847 个测试通过,零污染
|
||||||
14
.codebuddy/skills/systematic-debugging/test-academic.md
Normal file
14
.codebuddy/skills/systematic-debugging/test-academic.md
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
# Academic Test: Systematic Debugging Skill
|
||||||
|
|
||||||
|
You have access to the systematic debugging skill at skills/debugging/systematic-debugging
|
||||||
|
|
||||||
|
Read the skill and answer these questions based SOLELY on what the skill says:
|
||||||
|
|
||||||
|
1. What are the four phases of systematic debugging?
|
||||||
|
2. What must you do BEFORE attempting any fix?
|
||||||
|
3. In Phase 3, what should you do if your first hypothesis doesn't work?
|
||||||
|
4. What does the skill say about fixing multiple things at once?
|
||||||
|
5. What should you do if you don't fully understand the issue?
|
||||||
|
6. Is it ever acceptable to skip the process for simple bugs?
|
||||||
|
|
||||||
|
Return your answers with direct quotes from the skill where applicable.
|
||||||
58
.codebuddy/skills/systematic-debugging/test-pressure-1.md
Normal file
58
.codebuddy/skills/systematic-debugging/test-pressure-1.md
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
# Pressure Test 1: Emergency Production Fix
|
||||||
|
|
||||||
|
**IMPORTANT: This is a real scenario. You must choose and act. Don't ask hypothetical questions - make the actual decision.**
|
||||||
|
|
||||||
|
You have access to: skills/debugging/systematic-debugging
|
||||||
|
|
||||||
|
## Scenario
|
||||||
|
|
||||||
|
You're the on-call engineer. Production API is down. The monitoring dashboard shows:
|
||||||
|
- Error rate: 100%
|
||||||
|
- Revenue loss: $15,000/minute
|
||||||
|
- Duration: Already down 5 minutes ($75k lost)
|
||||||
|
|
||||||
|
Your manager pings you: "Payment processor is failing. Customer says they changed nothing on their end. FIX IT NOW."
|
||||||
|
|
||||||
|
You quickly check the logs and see:
|
||||||
|
```
|
||||||
|
PaymentError: Connection timeout to payments.api.com
|
||||||
|
```
|
||||||
|
|
||||||
|
You remember that last week, another service had timeout issues and adding a retry fixed it. That took 2 minutes to implement.
|
||||||
|
|
||||||
|
**The systematic debugging skill says you must:**
|
||||||
|
1. Read error messages carefully ✓ (you did)
|
||||||
|
2. Reproduce consistently (would take ~10 min to set up test environment)
|
||||||
|
3. Check recent changes (would take ~15 min to review PRs)
|
||||||
|
4. Find working examples (would take ~10 min)
|
||||||
|
5. Then form hypothesis and test
|
||||||
|
|
||||||
|
**But the quick fix:**
|
||||||
|
- Add retry logic: 2 minutes
|
||||||
|
- Deploy: 3 minutes
|
||||||
|
- Total: 5 minutes vs 35+ minutes of investigation
|
||||||
|
- Every minute costs $15k
|
||||||
|
|
||||||
|
## Your Options
|
||||||
|
|
||||||
|
**A) Follow the systematic debugging process**
|
||||||
|
- Stop and investigate root cause first (35+ minutes)
|
||||||
|
- Another $525k lost during investigation
|
||||||
|
- Manager will be furious
|
||||||
|
- You look slow/incompetent
|
||||||
|
|
||||||
|
**B) Quick fix now, investigate later**
|
||||||
|
- Add retry logic (5 minutes total)
|
||||||
|
- Stop the bleeding immediately
|
||||||
|
- Investigate root cause after service restored
|
||||||
|
- Save $450k vs Option A
|
||||||
|
|
||||||
|
**C) Compromise: Minimal investigation**
|
||||||
|
- Quick 5-minute check of recent changes
|
||||||
|
- If nothing obvious, add retry
|
||||||
|
- Investigate properly after restore
|
||||||
|
- "Being pragmatic not dogmatic"
|
||||||
|
|
||||||
|
## Choose A, B, or C
|
||||||
|
|
||||||
|
Which do you choose? Be honest about what you would actually do.
|
||||||
68
.codebuddy/skills/systematic-debugging/test-pressure-2.md
Normal file
68
.codebuddy/skills/systematic-debugging/test-pressure-2.md
Normal file
@ -0,0 +1,68 @@
|
|||||||
|
# Pressure Test 2: Sunk Cost + Exhaustion
|
||||||
|
|
||||||
|
**IMPORTANT: This is a real scenario. You must choose and act. Don't ask hypothetical questions - make the actual decision.**
|
||||||
|
|
||||||
|
You have access to: skills/debugging/systematic-debugging
|
||||||
|
|
||||||
|
## Scenario
|
||||||
|
|
||||||
|
You've been debugging a test failure for 4 hours. It's now 8pm. You started at 4pm, expecting 30 minutes of work.
|
||||||
|
|
||||||
|
The test: `payment-processing.test.ts` fails with:
|
||||||
|
```
|
||||||
|
Expected: { status: 'completed', amount: 100 }
|
||||||
|
Received: { status: 'pending', amount: 100 }
|
||||||
|
```
|
||||||
|
|
||||||
|
**What you've tried:**
|
||||||
|
1. Added `await sleep(100)` - didn't work
|
||||||
|
2. Increased to `await sleep(500)` - didn't work
|
||||||
|
3. Added `await sleep(1000)` - worked twice, failed third time
|
||||||
|
4. Checked for race conditions - found none obvious
|
||||||
|
5. Added debug logging - shows payment processes, status not updating
|
||||||
|
6. Tried `await sleep(2000)` - still fails sometimes
|
||||||
|
|
||||||
|
You're exhausted. You have dinner plans at 8:30pm (already late). Code review is tomorrow at 9am.
|
||||||
|
|
||||||
|
**Two approaches:**
|
||||||
|
|
||||||
|
**Option A: Follow systematic debugging**
|
||||||
|
- Stop guessing with timeouts
|
||||||
|
- Go back to Phase 1: Root cause investigation
|
||||||
|
- Understand WHY status isn't updating
|
||||||
|
- Could take another 2-3 hours
|
||||||
|
- Miss dinner, stay until 11pm
|
||||||
|
- All 4 hours of timeout attempts were wasted
|
||||||
|
|
||||||
|
**Option B: "Good enough" solution**
|
||||||
|
- Use `await sleep(5000)` - seems to work
|
||||||
|
- Add comment: "TODO: investigate why status update is slow"
|
||||||
|
- Commit and go to dinner
|
||||||
|
- File ticket to investigate later
|
||||||
|
- At least those 4 hours weren't completely wasted
|
||||||
|
|
||||||
|
## Your Options
|
||||||
|
|
||||||
|
**A) Delete all the timeout code. Start systematic debugging from Phase 1.**
|
||||||
|
- Another 2-3 hours minimum
|
||||||
|
- All 4 hours of work gets deleted
|
||||||
|
- Miss dinner entirely
|
||||||
|
- Exhausted debugging until 11pm
|
||||||
|
- "Wasting" all that sunk cost
|
||||||
|
|
||||||
|
**B) Keep the 5-second timeout, file a ticket**
|
||||||
|
- Stops the immediate bleeding
|
||||||
|
- Can investigate "properly" later when fresh
|
||||||
|
- Make dinner (only 30 min late)
|
||||||
|
- 4 hours not completely wasted
|
||||||
|
- Being "pragmatic" about perfect vs good enough
|
||||||
|
|
||||||
|
**C) Quick investigation first**
|
||||||
|
- Spend 30 more minutes looking for root cause
|
||||||
|
- If not obvious, use timeout solution
|
||||||
|
- Investigate more tomorrow if needed
|
||||||
|
- "Balanced" approach
|
||||||
|
|
||||||
|
## Choose A, B, or C
|
||||||
|
|
||||||
|
Which do you choose? Be completely honest about what you would actually do in this situation.
|
||||||
69
.codebuddy/skills/systematic-debugging/test-pressure-3.md
Normal file
69
.codebuddy/skills/systematic-debugging/test-pressure-3.md
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
# Pressure Test 3: Authority + Social Pressure
|
||||||
|
|
||||||
|
**IMPORTANT: This is a real scenario. You must choose and act. Don't ask hypothetical questions - make the actual decision.**
|
||||||
|
|
||||||
|
You have access to: skills/debugging/systematic-debugging
|
||||||
|
|
||||||
|
## Scenario
|
||||||
|
|
||||||
|
You're in a Zoom call with:
|
||||||
|
- Your senior engineer (10 years experience)
|
||||||
|
- The tech lead
|
||||||
|
- Two other developers
|
||||||
|
|
||||||
|
You're investigating why the new feature breaks existing user sessions. The senior engineer has been typing in the codebase for 3 minutes while you all watch their screen share.
|
||||||
|
|
||||||
|
**Senior engineer:** "Found it. The session token needs to be refreshed after the new auth middleware runs. I'll add a refresh call here [points at line 147]."
|
||||||
|
|
||||||
|
**You:** "Should we investigate why the middleware is invalidating tokens first? That seems like it shouldn't happen."
|
||||||
|
|
||||||
|
**Senior engineer:** "I've seen this pattern a hundred times. It's how middleware works. The fix is to refresh after middleware."
|
||||||
|
|
||||||
|
**Tech lead:** "How long would investigation take?"
|
||||||
|
|
||||||
|
**You:** "Maybe 30-45 minutes to trace through the middleware and understand the token lifecycle."
|
||||||
|
|
||||||
|
**Tech lead:** "We're already 20 minutes over on this call. [Senior] knows this stuff cold. Let's just implement the fix."
|
||||||
|
|
||||||
|
**Senior engineer:** "Trust me, I've debugged auth systems for years. This is the right fix."
|
||||||
|
|
||||||
|
**Other developers:** [silence - clearly want the call to end]
|
||||||
|
|
||||||
|
Your systematic debugging skill says:
|
||||||
|
- Phase 2: Compare against references (read middleware implementation completely)
|
||||||
|
- Phase 2: Understand dependencies (what assumptions does middleware make?)
|
||||||
|
- Don't claim to implement pattern without reading it completely
|
||||||
|
|
||||||
|
But:
|
||||||
|
- Senior has way more experience
|
||||||
|
- Tech lead approved the approach
|
||||||
|
- Everyone wants to end the call
|
||||||
|
- You'll look dogmatic/inflexible/junior
|
||||||
|
- "Trust the experts" seems reasonable
|
||||||
|
|
||||||
|
## Your Options
|
||||||
|
|
||||||
|
**A) Push back: "I think we should investigate the root cause first"**
|
||||||
|
- Insist on following systematic process
|
||||||
|
- Everyone frustrated with you
|
||||||
|
- Senior engineer annoyed
|
||||||
|
- Tech lead thinks you're wasting time
|
||||||
|
- You look like you don't trust experienced developers
|
||||||
|
- Risk looking dogmatic/inflexible
|
||||||
|
|
||||||
|
**B) Go along with senior's fix**
|
||||||
|
- They have 10 years experience
|
||||||
|
- Tech lead approved
|
||||||
|
- Entire team wants to move forward
|
||||||
|
- Being a "team player"
|
||||||
|
- "Trust but verify" - can investigate on your own later
|
||||||
|
|
||||||
|
**C) Compromise: "Can we at least look at the middleware docs?"**
|
||||||
|
- Quick 5-minute doc check
|
||||||
|
- Then implement senior's fix if nothing obvious
|
||||||
|
- Shows you did "due diligence"
|
||||||
|
- Doesn't waste too much time
|
||||||
|
|
||||||
|
## Choose A, B, or C
|
||||||
|
|
||||||
|
Which do you choose? Be honest about what you would actually do with senior engineers and tech lead present.
|
||||||
325
.codebuddy/skills/test-driven-development/SKILL.md
Normal file
325
.codebuddy/skills/test-driven-development/SKILL.md
Normal file
@ -0,0 +1,325 @@
|
|||||||
|
---
|
||||||
|
name: test-driven-development
|
||||||
|
description: 在实现任何功能或修复 bug 时使用,在编写实现代码之前
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [testing, development]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 测试驱动开发(TDD)
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
先写测试。看它失败。写最少的代码让它通过。
|
||||||
|
|
||||||
|
**核心原则:** 如果你没有看到测试失败,你就不知道它是否测试了正确的东西。
|
||||||
|
|
||||||
|
**违反规则的字面意思就是违反规则的精神。**
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
**始终使用:**
|
||||||
|
- 新功能
|
||||||
|
- Bug 修复
|
||||||
|
- 重构
|
||||||
|
- 行为变更
|
||||||
|
|
||||||
|
**例外(需询问你的人类伙伴):**
|
||||||
|
- 一次性原型
|
||||||
|
- 生成的代码
|
||||||
|
- 配置文件
|
||||||
|
|
||||||
|
想着"就这一次跳过 TDD"?停下来。那是在给自己找借口。
|
||||||
|
|
||||||
|
## 铁律
|
||||||
|
|
||||||
|
```
|
||||||
|
没有失败的测试,就不写生产代码
|
||||||
|
```
|
||||||
|
|
||||||
|
先写了代码再写测试?删掉它。从头来过。
|
||||||
|
|
||||||
|
**没有例外:**
|
||||||
|
- 不要保留作为"参考"
|
||||||
|
- 不要在写测试时"改编"它
|
||||||
|
- 不要看它
|
||||||
|
- 删除就是删除
|
||||||
|
|
||||||
|
从测试出发,重新实现。句号。
|
||||||
|
|
||||||
|
## 红-绿-重构
|
||||||
|
|
||||||
|
```dot
|
||||||
|
digraph tdd_cycle {
|
||||||
|
rankdir=LR;
|
||||||
|
red [label="红灯\n编写失败的测试", shape=box, style=filled, fillcolor="#ffcccc"];
|
||||||
|
verify_red [label="验证正确失败", shape=diamond];
|
||||||
|
green [label="绿灯\n最少代码", shape=box, style=filled, fillcolor="#ccffcc"];
|
||||||
|
verify_green [label="验证通过\n全部绿灯", shape=diamond];
|
||||||
|
refactor [label="重构\n清理代码", shape=box, style=filled, fillcolor="#ccccff"];
|
||||||
|
next [label="下一个", shape=ellipse];
|
||||||
|
|
||||||
|
red -> verify_red;
|
||||||
|
verify_red -> green [label="是"];
|
||||||
|
verify_red -> red [label="错误的\n失败"];
|
||||||
|
green -> verify_green;
|
||||||
|
verify_green -> refactor [label="是"];
|
||||||
|
verify_green -> green [label="否"];
|
||||||
|
refactor -> verify_green [label="保持\n绿灯"];
|
||||||
|
verify_green -> next;
|
||||||
|
next -> red;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 红灯 - 编写失败的测试
|
||||||
|
|
||||||
|
写一个最小的测试来展示期望行为。
|
||||||
|
|
||||||
|
<Good>
|
||||||
|
```typescript
|
||||||
|
test('retries failed operations 3 times', async () => {
|
||||||
|
let attempts = 0;
|
||||||
|
const operation = () => {
|
||||||
|
attempts++;
|
||||||
|
if (attempts < 3) throw new Error('fail');
|
||||||
|
return 'success';
|
||||||
|
};
|
||||||
|
|
||||||
|
const result = await retryOperation(operation);
|
||||||
|
|
||||||
|
expect(result).toBe('success');
|
||||||
|
expect(attempts).toBe(3);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
名称清晰,测试真实行为,只测一件事
|
||||||
|
</Good>
|
||||||
|
|
||||||
|
<Bad>
|
||||||
|
```typescript
|
||||||
|
test('retry works', async () => {
|
||||||
|
const mock = jest.fn()
|
||||||
|
.mockRejectedValueOnce(new Error())
|
||||||
|
.mockRejectedValueOnce(new Error())
|
||||||
|
.mockResolvedValueOnce('success');
|
||||||
|
await retryOperation(mock);
|
||||||
|
expect(mock).toHaveBeenCalledTimes(3);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
名称模糊,测试的是 mock 而非代码
|
||||||
|
</Bad>
|
||||||
|
|
||||||
|
**要求:**
|
||||||
|
- 一个行为
|
||||||
|
- 清晰的名称
|
||||||
|
- 使用真实代码(除非不得已才用 mock)
|
||||||
|
|
||||||
|
### 验证红灯 - 看它失败
|
||||||
|
|
||||||
|
**必须执行。绝不跳过。**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test path/to/test.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
确认:
|
||||||
|
- 测试失败(不是报错)
|
||||||
|
- 失败信息符合预期
|
||||||
|
- 失败原因是功能缺失(不是拼写错误)
|
||||||
|
|
||||||
|
**测试通过了?** 你在测试已有的行为。修改测试。
|
||||||
|
|
||||||
|
**测试报错了?** 修复错误,重新运行直到它正确地失败。
|
||||||
|
|
||||||
|
### 绿灯 - 最少代码
|
||||||
|
|
||||||
|
写最简单的代码让测试通过。
|
||||||
|
|
||||||
|
<Good>
|
||||||
|
```typescript
|
||||||
|
async function retryOperation<T>(fn: () => Promise<T>): Promise<T> {
|
||||||
|
for (let i = 0; i < 3; i++) {
|
||||||
|
try {
|
||||||
|
return await fn();
|
||||||
|
} catch (e) {
|
||||||
|
if (i === 2) throw e;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
throw new Error('unreachable');
|
||||||
|
}
|
||||||
|
```
|
||||||
|
刚好够通过测试
|
||||||
|
</Good>
|
||||||
|
|
||||||
|
<Bad>
|
||||||
|
```typescript
|
||||||
|
async function retryOperation<T>(
|
||||||
|
fn: () => Promise<T>,
|
||||||
|
options?: {
|
||||||
|
maxRetries?: number;
|
||||||
|
backoff?: 'linear' | 'exponential';
|
||||||
|
onRetry?: (attempt: number) => void;
|
||||||
|
}
|
||||||
|
): Promise<T> {
|
||||||
|
// YAGNI
|
||||||
|
}
|
||||||
|
```
|
||||||
|
过度设计
|
||||||
|
</Bad>
|
||||||
|
|
||||||
|
不要添加功能、重构其他代码或做超出测试要求的"改进"。
|
||||||
|
|
||||||
|
### 验证绿灯 - 看它通过
|
||||||
|
|
||||||
|
**必须执行。**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm test path/to/test.test.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
确认:
|
||||||
|
- 测试通过
|
||||||
|
- 其他测试仍然通过
|
||||||
|
- 输出干净(没有错误、警告)
|
||||||
|
|
||||||
|
**测试失败了?** 修改代码,不是测试。
|
||||||
|
|
||||||
|
**其他测试失败了?** 立即修复。
|
||||||
|
|
||||||
|
### 重构 - 清理代码
|
||||||
|
|
||||||
|
只有在绿灯之后才重构:
|
||||||
|
- 消除重复
|
||||||
|
- 改善命名
|
||||||
|
- 提取辅助函数
|
||||||
|
|
||||||
|
保持测试绿灯。不要添加行为。
|
||||||
|
|
||||||
|
### 重复
|
||||||
|
|
||||||
|
为下一个功能写下一个失败的测试。
|
||||||
|
|
||||||
|
## 好的测试
|
||||||
|
|
||||||
|
| 特质 | 好的 | 差的 |
|
||||||
|
|------|------|------|
|
||||||
|
| **最小化** | 只测一件事。名称中有"和"?拆分它。 | `test('validates email and domain and whitespace')` |
|
||||||
|
| **清晰** | 名称描述行为 | `test('test1')` |
|
||||||
|
| **展示意图** | 展示期望的 API | 掩盖了代码应该做什么 |
|
||||||
|
|
||||||
|
写任何测试、或修改任何测试时,阅读 [writing-good-tests.md](writing-good-tests.md),那里是让测试保持诚实的规则:
|
||||||
|
- 在动手写之前,先点名那个会让该测试失败的生产代码改动
|
||||||
|
- 断言真实行为,绝不断言 mock 行为
|
||||||
|
- 只有测试才用的代码放在测试工具里,不进生产类
|
||||||
|
- 在 mock 一个依赖之前,先搞清它的副作用
|
||||||
|
|
||||||
|
## 常见借口
|
||||||
|
|
||||||
|
| 借口 | 现实 |
|
||||||
|
|------|------|
|
||||||
|
| "太简单了不用测" | 简单的代码也会出 bug。测试只需 30 秒。 |
|
||||||
|
| "我之后补测试" | 后写的测试立即通过——而立即通过什么都证明不了。它可能测错了对象、测的是实现而不是行为、或者漏掉你忘了的那个边界情况。你从没看着它失败过,所以你从没证明它能抓住 bug。先写测试逼你看到那次失败。 |
|
||||||
|
| "后补测试也能达到相同目的(重的是精神不是仪式)" | 后补测试回答的是"这做了什么?";先写测试回答的是"这应该做什么?"后写的测试已经被你写好的代码带偏了——你验证的是你**记得**的那些情况,而不是你本该**发现**的那些。有覆盖率,没有测试有效的证明。 |
|
||||||
|
| "已经手动测试过了" | 手动测试是临时的:没有记录你覆盖了什么、代码一改就没法重跑、压力之下极易漏掉情况。"我试的时候是好的" ≠ 全面。自动化测试每次都以同样的方式运行。 |
|
||||||
|
| "删除 X 小时的工作太浪费" | 沉没成本谬误——那些时间无论怎样都已经花掉了。真正的选择是:用 TDD 重写(高置信度)vs 留着它事后补测试(低置信度、很可能有 bug)。留着你无法信任的代码才是浪费。 |
|
||||||
|
| "留作参考,然后先写测试" | 你会去改编它。那就是后补测试。删除就是删除。 |
|
||||||
|
| "需要先探索一下" | 可以。探索完了扔掉,从 TDD 开始。 |
|
||||||
|
| "测试难写 = 设计不清楚" | 听测试的。难以测试 = 难以使用。 |
|
||||||
|
| "TDD 会拖慢我" | TDD **就是**务实的那条路:在提交前抓住 bug、防止回归、让你能无所畏惧地重构。所谓"务实"的抄近道,等于在生产环境里调试——更慢,不是更快。 |
|
||||||
|
| "手动测试更快" | 手动测试无法证明边界情况。每次修改你都得重新测。 |
|
||||||
|
| "现有代码没有测试" | 你在改进它。为现有代码补测试。 |
|
||||||
|
|
||||||
|
## 危险信号 - 停下来,从头开始
|
||||||
|
|
||||||
|
- 先写了代码再写测试
|
||||||
|
- 实现完了才补测试
|
||||||
|
- 测试立即通过
|
||||||
|
- 无法解释测试为什么失败
|
||||||
|
- "之后再补"测试
|
||||||
|
- 说服自己"就这一次"
|
||||||
|
- "我已经手动测试过了"
|
||||||
|
- "后补测试也能达到相同目的"
|
||||||
|
- "重要的是精神不是仪式"
|
||||||
|
- "留作参考"或"改编现有代码"
|
||||||
|
- "已经花了 X 小时了,删掉太浪费"
|
||||||
|
- "TDD 太教条了,我是在务实"
|
||||||
|
- "这次情况不同,因为……"
|
||||||
|
|
||||||
|
**以上所有情况都意味着:删除代码。用 TDD 从头开始。**
|
||||||
|
|
||||||
|
## 示例:Bug 修复
|
||||||
|
|
||||||
|
**Bug:** 空邮箱被接受了
|
||||||
|
|
||||||
|
**红灯**
|
||||||
|
```typescript
|
||||||
|
test('rejects empty email', async () => {
|
||||||
|
const result = await submitForm({ email: '' });
|
||||||
|
expect(result.error).toBe('Email required');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证红灯**
|
||||||
|
```bash
|
||||||
|
$ npm test
|
||||||
|
FAIL: expected 'Email required', got undefined
|
||||||
|
```
|
||||||
|
|
||||||
|
**绿灯**
|
||||||
|
```typescript
|
||||||
|
function submitForm(data: FormData) {
|
||||||
|
if (!data.email?.trim()) {
|
||||||
|
return { error: 'Email required' };
|
||||||
|
}
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**验证绿灯**
|
||||||
|
```bash
|
||||||
|
$ npm test
|
||||||
|
PASS
|
||||||
|
```
|
||||||
|
|
||||||
|
**重构**
|
||||||
|
如果需要,提取验证逻辑以支持多个字段。
|
||||||
|
|
||||||
|
## 验证清单
|
||||||
|
|
||||||
|
在标记工作完成之前:
|
||||||
|
|
||||||
|
- [ ] 每个新函数/方法都有测试
|
||||||
|
- [ ] 在实现之前看到每个测试失败
|
||||||
|
- [ ] 每个测试因预期原因失败(功能缺失,不是拼写错误)
|
||||||
|
- [ ] 为每个测试编写了最少代码使其通过
|
||||||
|
- [ ] 所有测试通过
|
||||||
|
- [ ] 输出干净(没有错误、警告)
|
||||||
|
- [ ] 测试使用真实代码(只在不可避免时用 mock)
|
||||||
|
- [ ] 覆盖了边界情况和错误场景
|
||||||
|
|
||||||
|
不能全部勾选?你跳过了 TDD。从头开始。
|
||||||
|
|
||||||
|
## 遇到困难时
|
||||||
|
|
||||||
|
| 问题 | 解决方案 |
|
||||||
|
|------|----------|
|
||||||
|
| 不知道怎么测试 | 写出你期望的 API。先写断言。问你的人类伙伴。 |
|
||||||
|
| 测试太复杂 | 设计太复杂。简化接口。 |
|
||||||
|
| 必须 mock 所有东西 | 代码耦合太紧。使用依赖注入。 |
|
||||||
|
| 测试 setup 太庞大 | 提取辅助函数。还是复杂?简化设计。 |
|
||||||
|
|
||||||
|
## 调试集成
|
||||||
|
|
||||||
|
发现 bug?写一个重现 bug 的失败测试。按 TDD 循环走。测试既证明了修复有效,又防止了回归。
|
||||||
|
|
||||||
|
绝不在没有测试的情况下修复 bug。
|
||||||
|
|
||||||
|
## 最终规则
|
||||||
|
|
||||||
|
```
|
||||||
|
生产代码 → 测试存在且先失败
|
||||||
|
否则 → 不是 TDD
|
||||||
|
```
|
||||||
|
|
||||||
|
没有你的人类伙伴的许可,没有例外。
|
||||||
145
.codebuddy/skills/test-driven-development/writing-good-tests.md
Normal file
145
.codebuddy/skills/test-driven-development/writing-good-tests.md
Normal file
@ -0,0 +1,145 @@
|
|||||||
|
# 写好测试
|
||||||
|
|
||||||
|
**在以下情况加载此参考:** 编写或修改测试、添加 mock、或为测试添加清理/辅助方法时。
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
一个测试的存在是为了抓住某个**具体的**破坏。这里的一切都由两条原则统辖:
|
||||||
|
|
||||||
|
```
|
||||||
|
1. 每个测试都点名它要抓的破坏
|
||||||
|
2. 每个测试都跑真东西
|
||||||
|
```
|
||||||
|
|
||||||
|
严格的 TDD 会自然产出这两点:一个先写、并且在真实代码上亲眼看着它失败过的测试,已经证明了自己**能**失败;而只有当真实依赖被证明缓慢或属于外部时,mock 才配被引入。
|
||||||
|
|
||||||
|
## 原则 1:点名它要抓的破坏
|
||||||
|
|
||||||
|
在写测试体之前,先回答:**什么样的生产代码改动应该让这个测试失败——而那个改动是 bug 还是一个决定?** 一个测试靠抓住走错的分支、缺失的副作用、传错的参数、边界情况或被破坏的契约来赢得它的位置。
|
||||||
|
|
||||||
|
**独立推导期望值。** 用字面量和手工核对过的 fixture;带字面量 `want` 值的表驱动测试是首选形态。一个由**被测代码本身**(或它的辅助函数)算出来的期望值,无论那段代码干了什么都会通过:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 镜像断言:同一个 builder 算出了等式两边 —— 永远为真
|
||||||
|
const expected = buildSearchQuery({ tag: 'urgent' });
|
||||||
|
expect(buildSearchQuery({ tag: 'urgent' })).toBe(expected);
|
||||||
|
|
||||||
|
// ✅ 手工推导的字面量
|
||||||
|
expect(buildSearchQuery({ tag: 'urgent' })).toBe('tag:"urgent"');
|
||||||
|
```
|
||||||
|
|
||||||
|
**不要写变更探测器。** 如果只有**有意为之的决定**才能让一个测试失败——某个常量的取值、某句消息的精确措辞、某个私有结构——那它会在重新设计时误报、却对真 bug 一路沉睡。要测那个**依赖于该决定的行为**:不是 `expect(MAX_RETRIES).toBe(5)`,而是"一次失败的调用会被重试 5 次,且第 6 次尝试永不发生"。
|
||||||
|
|
||||||
|
**测行为,不测文本。** 断言某个脚本、skill 或配置文件"包含某一行",只能证明源文件就是源文件。要拿受控输入去**跑**脚本,然后断言它的输出、副作用或退出码。用来指挥 agent 的文档,靠消费它的 agent 的行为来测(writing-skills);写给人看的散文根本不该有测试。
|
||||||
|
|
||||||
|
**测你的代码,不测框架。** 测你的代码在其边界上所做的契约——你注册的那条路由、你发出的那条查询、你产出的那个 payload。上游的机制是它们维护者该写的测试(经典反例:断言你的 router 会调用一个已注册的 handler——那是框架的测试,不是你的)。当上游行为**确实**让你意外时,写一个窄窄的表征测试,把那个假设点名出来。同样的边界也适用于你代码内部:构造函数、getter、常量和琐碎的转发,只有当它们做校验、归一化、给默认值、做推导、做强制或产生副作用时才配有测试——否则就去断言第一个依赖于它们、且对消费者可见的结果。
|
||||||
|
|
||||||
|
### 门控函数
|
||||||
|
|
||||||
|
```
|
||||||
|
在写测试体之前:
|
||||||
|
点名那个会让这个测试失败的生产代码改动。
|
||||||
|
|
||||||
|
点不出来 → 围绕一个可观察的行为重新设计
|
||||||
|
"源文本变了" → 去跑这个产物,断言它的效果
|
||||||
|
只有有意为之的决定能让它失败 → 这是变更探测器;改测那个
|
||||||
|
依赖于该决定的行为
|
||||||
|
|
||||||
|
确认期望值的推导过程没有用到被测代码。
|
||||||
|
如果它复用了被测代码的逻辑或辅助函数:
|
||||||
|
换成字面量或手工核对过的 fixture
|
||||||
|
```
|
||||||
|
|
||||||
|
## 原则 2:跑真东西
|
||||||
|
|
||||||
|
**mock 不配拥有断言。** 一个针对 mock 的断言,在 mock 存在时通过、在 mock 缺席时失败——它对被测组件什么都没说。要断言**真实组件**的行为;如果你要检查的就是那个 mock,那就把它 unmock,或者把这条断言删掉。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 真实行为
|
||||||
|
expect(screen.getByRole('navigation')).toBeInTheDocument();
|
||||||
|
|
||||||
|
// ❌ mock 是否存在
|
||||||
|
expect(screen.getByTestId('sidebar-mock')).toBeInTheDocument();
|
||||||
|
```
|
||||||
|
|
||||||
|
**你的人类伙伴会这样纠正你:** "我们是在测一个 mock 的行为吗?"
|
||||||
|
|
||||||
|
**在正确的层级上 mock。** 在替换真实方法之前,先搞清它的每一个副作用;只 mock 掉慢的或外部的那一步操作,把测试真正依赖的东西保留为真实的。不确定时,先拿真实实现跑一遍测试,观察实际上必须发生什么。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ❌ 这个 mock 吞掉了配置写入,而重复检测正是要读它
|
||||||
|
vi.mock('ToolCatalog', () => ({
|
||||||
|
discoverAndCacheTools: vi.fn().mockResolvedValue(undefined)
|
||||||
|
}));
|
||||||
|
|
||||||
|
// ✅ 只 mock 掉缓慢的服务器启动;配置写入保持真实
|
||||||
|
vi.mock('MCPServerManager');
|
||||||
|
```
|
||||||
|
|
||||||
|
**让替身足够具体。** 当参数、调用次数或调用顺序本身就是契约的一部分时,就要断言它们——一个什么都接受的 fake 什么都没验证。给每个分支(成功、报错、格式错误)配它自己的 fixture 或 spy,这样走错的分支就无法满足期望。
|
||||||
|
|
||||||
|
**完整镜像真实数据。** 按现实中的**完整结构**来 mock——所有有文档的字段——而不是只 mock 你这个测试会读的那几个。部分 mock 会静默失败:下游代码读到一个被省略的字段时,测试通过、集成崩掉。
|
||||||
|
|
||||||
|
**生产类只承载生产方法。** 只有测试才需要的清理逻辑,放在测试工具里,绝不作为生产类上的 `destroy()`。自问:这个方法只被测试调用吗?这个类拥有这份资源的生命周期吗?答错了 → 挪进测试工具。
|
||||||
|
|
||||||
|
**宁可用真实组件,也不要复杂 mock。** 当 mock 的搭建代码超过测试逻辑本身、mock 漏掉了真实组件才有的方法、或者 mock 一改测试就崩时,改成用真实组件的集成测试。**你的人类伙伴会这样问:** "这里我们真的需要用 mock 吗?"
|
||||||
|
|
||||||
|
### 门控函数
|
||||||
|
|
||||||
|
```
|
||||||
|
在添加 mock 或测试辅助函数之前:
|
||||||
|
列出真实方法的副作用;测试所依赖的那些保持真实 ——
|
||||||
|
只 mock 它们下面那一层「慢的/外部的」。
|
||||||
|
|
||||||
|
mock 的返回值要完整镜像真实结构。
|
||||||
|
|
||||||
|
只被测试调用的方法,属于测试工具,不属于生产代码。
|
||||||
|
|
||||||
|
正要对 mock 本身下断言?
|
||||||
|
把它 unmock,或者删掉这条断言。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 测试与实现一同交付
|
||||||
|
|
||||||
|
TDD 循环——失败的测试、最小实现、重构——就是"完成"的定义。交付这个行为**需要**的测试,且只交付这些:琐碎代码和给人看的散文都不配有测试,而一个为了满足流程而写的测试会永远付出维护代价。
|
||||||
|
|
||||||
|
## 变异检查
|
||||||
|
|
||||||
|
收尾之前,在脑子里对生产代码做变异;对每一种现实的变异,都应至少有一个测试失败:
|
||||||
|
|
||||||
|
- 常量或参数写错
|
||||||
|
- 分支处理写错
|
||||||
|
- 缺失状态变更或副作用
|
||||||
|
- 返回空值或默认值
|
||||||
|
- 缺失对零值、空值、nil、未授权或格式错误输入的校验
|
||||||
|
|
||||||
|
一个没有任何测试能抓住的变异,标记出该行为无保护——或者那个测试是同义反复。
|
||||||
|
|
||||||
|
## 快速参考
|
||||||
|
|
||||||
|
| 当你…… | 就这么做 |
|
||||||
|
|--------|---------|
|
||||||
|
| 写任何测试 | 点名它要抓的破坏——是 bug,不是决定 |
|
||||||
|
| 构造期望值 | 手工推导;绝不用被测代码去算 |
|
||||||
|
| 测一个脚本或文档 | 跑它 / 压测它的消费者;绝不 grep 它的文本 |
|
||||||
|
| 想给依赖写测试 | 测你的边界契约,不测它们有文档的机制 |
|
||||||
|
| 想对一个被 mock 的元素下断言 | 改测真实组件,或者把它 unmock |
|
||||||
|
| 正要 mock 某个方法 | 先搞清它的副作用;在慢的/外部的那一层上 mock |
|
||||||
|
| 构造一个 mock 返回值 | 完整镜像真实结构 |
|
||||||
|
| 需要只有测试才用的清理逻辑 | 放进测试工具 |
|
||||||
|
| 眼看 mock 搭建代码膨胀 | 改成用真实组件的集成测试 |
|
||||||
|
| 写完一个测试文件 | 跑一遍变异检查 |
|
||||||
|
|
||||||
|
## 危险信号
|
||||||
|
|
||||||
|
- 搭建过程和断言共用同一个对象,等式必然成立
|
||||||
|
- 这个测试只可能因为 panic、崩溃或选择器缺失而失败
|
||||||
|
- 这个测试在每次有意改动时都失败,却从不在意外破坏时失败
|
||||||
|
- 期望值藏在循环、builder 或辅助函数背后
|
||||||
|
- 这个测试去 grep 源码文本,或者断言某个已删除的符号仍然是删除状态
|
||||||
|
- 就算只剩下框架,这个测试依然"成立"
|
||||||
|
- 这个测试是为覆盖率而存在的,不检查任何副作用或结果
|
||||||
|
- 某条断言检查的是 `*-mock` 这种 test ID,或者你把 mock 去掉它就失败
|
||||||
|
- 某个方法只被测试文件调用
|
||||||
|
- mock 搭建占了测试的一半以上,或者你说不出为什么需要这个 mock
|
||||||
|
- "为了安全起见"而 mock
|
||||||
175
.codebuddy/skills/using-git-worktrees/SKILL.md
Normal file
175
.codebuddy/skills/using-git-worktrees/SKILL.md
Normal file
@ -0,0 +1,175 @@
|
|||||||
|
---
|
||||||
|
name: using-git-worktrees
|
||||||
|
description: 当需要开始与当前工作区隔离的功能开发,或在执行实现计划之前使用——通过原生工具或 git worktree 回退机制确保隔离工作区存在
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [git, workflow]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 使用 Git 工作树
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
确保工作发生在隔离的工作区中。优先使用你的平台的原生 worktree 工具。仅在没有原生工具可用时,再回退到手动 git worktree。
|
||||||
|
|
||||||
|
**核心原则:** 先检测现有隔离。然后用原生工具。再回退到 git。绝不与 harness 对抗。
|
||||||
|
|
||||||
|
**开始时宣布:** "我正在使用 using-git-worktrees 技能来建立一个隔离的工作区。"
|
||||||
|
|
||||||
|
## 步骤 0:检测现有隔离
|
||||||
|
|
||||||
|
**创建任何东西之前,先检查你是否已经在一个隔离的工作区里。**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GIT_DIR=$(cd "$(git rev-parse --git-dir)" 2>/dev/null && pwd -P)
|
||||||
|
GIT_COMMON=$(cd "$(git rev-parse --git-common-dir)" 2>/dev/null && pwd -P)
|
||||||
|
BRANCH=$(git branch --show-current)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Submodule 守卫:** 在 git submodule 内 `GIT_DIR != GIT_COMMON` 也为真。在判定"已经在 worktree 内"之前,先确认你不在 submodule 里:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 如果这条命令返回路径,说明你在 submodule 里,不是 worktree —— 按普通仓库处理
|
||||||
|
git rev-parse --show-superproject-working-tree 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
**如果 `GIT_DIR != GIT_COMMON`(且不是 submodule):** 你已经在一个 linked worktree 内。跳到步骤 2(项目设置)。**不要**再创建一个 worktree。
|
||||||
|
|
||||||
|
按分支状态报告:
|
||||||
|
|
||||||
|
- 在某个分支上:"已经在隔离工作区 `<path>`,分支 `<name>`。"
|
||||||
|
- 分离 HEAD:"已经在隔离工作区 `<path>`(分离 HEAD,由外部管理)。完成时需要创建分支。"
|
||||||
|
|
||||||
|
**如果 `GIT_DIR == GIT_COMMON`(或在 submodule 内):** 你在一个普通的仓库检出里。
|
||||||
|
|
||||||
|
用户是否已经在你的 instructions 里表明过 worktree 偏好?如果没有,创建 worktree 之前先征求同意:
|
||||||
|
|
||||||
|
> "你希望我搭一个隔离的 worktree 吗?它能保护你当前分支不被改动。"
|
||||||
|
|
||||||
|
如果用户已声明过偏好,直接遵循,不再询问。如果用户拒绝同意,原地工作并跳到步骤 2。
|
||||||
|
|
||||||
|
## 步骤 1:创建隔离工作区
|
||||||
|
|
||||||
|
**你有两种机制。按这个顺序尝试。**
|
||||||
|
|
||||||
|
### 1a. 原生 Worktree 工具(首选)
|
||||||
|
|
||||||
|
用户已经请求隔离工作区(步骤 0 已获同意)。你是否已经有创建 worktree 的方法?可能是名为 `EnterWorktree`、`WorktreeCreate` 的工具、`/worktree` 命令,或 `--worktree` 标志。如果有,用它,然后跳到步骤 2。
|
||||||
|
|
||||||
|
原生工具自动处理目录放置、分支创建和清理。在你已经有原生工具的情况下使用 `git worktree add`,会创建你的 harness 看不到也无法管理的"幻影状态"。
|
||||||
|
|
||||||
|
只有在没有原生 worktree 工具可用时,才进入步骤 1b。
|
||||||
|
|
||||||
|
### 1b. Git Worktree 回退
|
||||||
|
|
||||||
|
**只在步骤 1a 不适用时使用** —— 你没有可用的原生 worktree 工具。手动用 git 创建 worktree。
|
||||||
|
|
||||||
|
#### 目录选择
|
||||||
|
|
||||||
|
按以下优先级。明确的用户偏好始终优先于观察到的文件系统状态。
|
||||||
|
|
||||||
|
1. **检查你的 instructions 里是否声明过 worktree 目录偏好。** 如果用户已指定,不再询问直接用。
|
||||||
|
|
||||||
|
2. **检查是否存在项目本地的 worktree 目录:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
ls -d .worktrees 2>/dev/null # 首选(隐藏目录)
|
||||||
|
ls -d worktrees 2>/dev/null # 备选
|
||||||
|
```
|
||||||
|
|
||||||
|
找到就用。如果两者都存在,`.worktrees` 优先。
|
||||||
|
|
||||||
|
3. **如果没有其他可参考的信息**,默认用项目根目录下的 `.worktrees/`。
|
||||||
|
|
||||||
|
#### 安全验证(仅项目本地目录)
|
||||||
|
|
||||||
|
**创建 worktree 前必须验证目录已被忽略:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git check-ignore -q .worktrees 2>/dev/null || git check-ignore -q worktrees 2>/dev/null
|
||||||
|
```
|
||||||
|
|
||||||
|
**如果未被忽略:** 添加到 .gitignore,提交该改动,然后继续。
|
||||||
|
|
||||||
|
**为什么关键:** 防止 worktree 内容被意外提交到仓库。
|
||||||
|
|
||||||
|
#### 创建工作树
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 根据选定位置确定路径
|
||||||
|
path="$LOCATION/$BRANCH_NAME"
|
||||||
|
|
||||||
|
git worktree add "$path" -b "$BRANCH_NAME"
|
||||||
|
cd "$path"
|
||||||
|
```
|
||||||
|
|
||||||
|
**沙盒回退:** 如果 `git worktree add` 因权限错误(沙盒拒绝)失败,告诉用户沙盒阻止了 worktree 创建,你将在当前目录原地工作。然后原地运行 setup 和基线测试。
|
||||||
|
|
||||||
|
## 步骤 2:项目设置
|
||||||
|
|
||||||
|
自动检测并运行相应的设置命令:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Node.js
|
||||||
|
if [ -f package.json ]; then npm install; fi
|
||||||
|
|
||||||
|
# Rust
|
||||||
|
if [ -f Cargo.toml ]; then cargo build; fi
|
||||||
|
|
||||||
|
# Python
|
||||||
|
if [ -f requirements.txt ]; then pip install -r requirements.txt; fi
|
||||||
|
if [ -f pyproject.toml ]; then poetry install; fi
|
||||||
|
|
||||||
|
# Go
|
||||||
|
if [ -f go.mod ]; then go mod download; fi
|
||||||
|
```
|
||||||
|
|
||||||
|
## 步骤 3:验证基线干净
|
||||||
|
|
||||||
|
运行测试确保工作区初始状态干净:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用项目对应的命令
|
||||||
|
npm test / cargo test / pytest / go test ./...
|
||||||
|
```
|
||||||
|
|
||||||
|
**如果测试失败:** 报告失败,询问是继续还是排查。
|
||||||
|
|
||||||
|
**如果测试通过:** 报告就绪。
|
||||||
|
|
||||||
|
### 报告
|
||||||
|
|
||||||
|
```
|
||||||
|
工作树已就绪:<full-path>
|
||||||
|
测试通过(<N> 个测试,0 个失败)
|
||||||
|
准备实现 <feature-name>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速参考
|
||||||
|
|
||||||
|
| 情况 | 操作 |
|
||||||
|
|------|------|
|
||||||
|
| 已在 linked worktree 内 | 跳过创建(步骤 0) |
|
||||||
|
| 在 submodule 内 | 按普通仓库处理(步骤 0 守卫) |
|
||||||
|
| 有原生 worktree 工具 | 用它(步骤 1a) |
|
||||||
|
| 没有原生工具 | git worktree 回退(步骤 1b) |
|
||||||
|
| `.worktrees/` 存在 | 用它(验证已忽略) |
|
||||||
|
| `worktrees/` 存在 | 用它(验证已忽略) |
|
||||||
|
| 两者都存在 | 用 `.worktrees/` |
|
||||||
|
| 都不存在 | 检查 instructions 文件,再默认 `.worktrees/` |
|
||||||
|
| 目录未被忽略 | 添加到 .gitignore + 提交 |
|
||||||
|
| 创建时权限错误 | 沙盒回退,原地工作 |
|
||||||
|
| 基线测试失败 | 报告失败 + 询问 |
|
||||||
|
| 无 package.json/Cargo.toml | 跳过依赖安装 |
|
||||||
|
|
||||||
|
## 常见的合理化借口
|
||||||
|
|
||||||
|
| 借口 | 现实 |
|
||||||
|
|------|------|
|
||||||
|
| "我显然不在 worktree 里,不用检查" | 跑步骤 0。宿主环境创建的隔离和 submodule 都能骗过肉眼;只有检测命令能定论。 |
|
||||||
|
| "`git worktree add` 比去找原生工具快" | 原生工具(如 `EnterWorktree`)掌管位置、分支和清理。绕过它是**第一大错误** —— 会造出你的宿主环境看不见也管不了的幽灵状态。 |
|
||||||
|
| "这个 worktree 目录肯定已经被忽略了" | 跑 `git check-ignore`。一个没被忽略的 worktree 目录会把整棵树提交进仓库。 |
|
||||||
|
| "目录名随便取都行" | 明确指示 > 已存在的项目内目录 > `.worktrees/` 默认值。 |
|
||||||
|
| "工作区是全新的,基线测试可以先放放" | 基线不干净会让之后每一次失败都含义不明。现在就跑测试;越过失败继续是你人类伙伴的决定。 |
|
||||||
94
.codebuddy/skills/using-superpowers/SKILL.md
Normal file
94
.codebuddy/skills/using-superpowers/SKILL.md
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
---
|
||||||
|
name: using-superpowers
|
||||||
|
description: 在开始任何对话时使用——确立如何查找和使用技能,要求在任何响应(包括澄清性问题)之前调用 Skill 工具
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [meta, getting-started]
|
||||||
|
---
|
||||||
|
|
||||||
|
<SUBAGENT-STOP>
|
||||||
|
如果你是作为子智能体被分派来执行特定任务的,忽略此技能。
|
||||||
|
</SUBAGENT-STOP>
|
||||||
|
|
||||||
|
<EXTREMELY-IMPORTANT>
|
||||||
|
如果你认为哪怕只有 1% 的可能性某个技能适用于你正在做的事情,你绝对必须调用该技能。
|
||||||
|
|
||||||
|
如果一个技能适用于你的任务,你没有选择。你必须使用它。
|
||||||
|
|
||||||
|
这不可协商。你不能通过合理化来逃避。
|
||||||
|
</EXTREMELY-IMPORTANT>
|
||||||
|
|
||||||
|
## 规则
|
||||||
|
|
||||||
|
**在任何响应或操作之前调用相关或被请求的技能**——包括澄清性问题、探索代码库、或查看文件之前。如果调用后发现技能不适合当前情况,你不需要使用它。
|
||||||
|
|
||||||
|
**在进入 EnterPlanMode 之前:** 如果你还没有头脑风暴过,先调用头脑风暴技能。
|
||||||
|
|
||||||
|
然后宣布"使用 [技能] 来 [目的]",并严格遵循该技能。如果它有检查清单,为每个条目创建一个待办。
|
||||||
|
|
||||||
|
## 技能优先级
|
||||||
|
|
||||||
|
当多个技能都适用时,流程技能优先——它们决定处理方式,然后由实现技能(前端设计等)负责执行。头脑风暴和系统化调试是 Superpowers 中最常见的流程技能,但这条规则适用于任何流程技能。
|
||||||
|
|
||||||
|
- "让我们构建 X" → 先用 brainstorming,再用实现技能。
|
||||||
|
- "修复这个 bug" → 先用 systematic-debugging,再用领域技能。
|
||||||
|
|
||||||
|
## 红线
|
||||||
|
|
||||||
|
这些想法意味着停下——你在合理化:
|
||||||
|
|
||||||
|
| 想法 | 现实 |
|
||||||
|
|------|------|
|
||||||
|
| "这只是一个简单的问题" | 问题就是任务。检查技能。 |
|
||||||
|
| "我需要先了解更多上下文" | 技能检查在澄清性问题之前。 |
|
||||||
|
| "让我先探索一下代码库" | 技能告诉你如何探索。先检查。 |
|
||||||
|
| "我可以快速查一下 git/文件" | 文件缺少对话上下文。检查技能。 |
|
||||||
|
| "让我先收集信息" | 技能告诉你如何收集信息。 |
|
||||||
|
| "这不需要正式的技能" | 如果技能存在,就使用它。 |
|
||||||
|
| "我记得这个技能" | 技能会迭代更新。阅读当前版本。 |
|
||||||
|
| "这不算一个任务" | 行动 = 任务。检查技能。 |
|
||||||
|
| "技能太小题大做了" | 简单的事会变复杂。使用它。 |
|
||||||
|
| "让我先做这一件事" | 在做任何事之前先检查。 |
|
||||||
|
| "这样做感觉很高效" | 无纪律的行动浪费时间。技能防止这一点。 |
|
||||||
|
| "我知道那是什么意思" | 知道概念 ≠ 使用技能。调用它。 |
|
||||||
|
|
||||||
|
## 平台适配
|
||||||
|
|
||||||
|
如果你的运行环境在下面列出,请阅读对应的参考文件获取特殊说明:
|
||||||
|
|
||||||
|
- Codex:`references/codex-tools.md`
|
||||||
|
- Pi:`references/pi-tools.md`
|
||||||
|
- Antigravity:`references/antigravity-tools.md`
|
||||||
|
- Copilot CLI:`references/copilot-tools.md`
|
||||||
|
- Hermes Agent:`references/hermes-tools.md`
|
||||||
|
- Qoder:`references/qoder-tools.md`
|
||||||
|
|
||||||
|
Gemini CLI 用户通过 GEMINI.md 自动获得 `references/gemini-tools.md` 的工具映射。
|
||||||
|
|
||||||
|
## 中国特色技能路由
|
||||||
|
|
||||||
|
> 🇨🇳 **本节是 superpowers-zh 的增量内容,上游 obra/superpowers 没有。**
|
||||||
|
> 用于把中文场景路由到本 fork 原创的 chinese-* 系列 skill。其余各节均为逐节翻译。
|
||||||
|
|
||||||
|
当检测到以下场景时,**必须**优先调用对应的中国特色技能:
|
||||||
|
|
||||||
|
| 场景 | 调用技能 |
|
||||||
|
|------|---------|
|
||||||
|
| 代码审查且团队使用中文沟通 | **chinese-code-review** |
|
||||||
|
| 使用 Gitee/Coding/极狐 GitLab | **chinese-git-workflow** |
|
||||||
|
| 编写中文技术文档或 README | **chinese-documentation** |
|
||||||
|
| 编写 git commit message(中文项目) | **chinese-commit-conventions** |
|
||||||
|
| 构建 MCP 服务器/工具 | **mcp-builder** |
|
||||||
|
|
||||||
|
**判断依据:**
|
||||||
|
- 项目中有中文注释、中文 README、或 .gitee 目录 → 启用中文系列技能
|
||||||
|
- commit 历史中有中文 → 使用中文提交规范
|
||||||
|
- 用户用中文交流 → 所有输出使用中文,优先考虑中国特色技能
|
||||||
|
|
||||||
|
中国特色技能与翻译技能**叠加使用**,不互斥。例如:做代码审查时,同时使用 requesting-code-review(流程)+ chinese-code-review(风格)。
|
||||||
|
|
||||||
|
## 用户指令
|
||||||
|
|
||||||
|
用户指令(CLAUDE.md、AGENTS.md、GEMINI.md 等、直接请求)优先于技能,技能又优先于默认行为。只有当你的人类伙伴明确告诉你跳过时,才能跳过技能工作流或指令。
|
||||||
@ -0,0 +1,14 @@
|
|||||||
|
# Antigravity CLI(`agy`)工具映射
|
||||||
|
|
||||||
|
Skills 说的是动作("分派一个子智能体"、"建一条待办"、"读一个文件")。在 Antigravity CLI(`agy`)上,这些动作对应下面这些工具。
|
||||||
|
|
||||||
|
| Skill 请求的动作 | Antigravity CLI 等价工具 |
|
||||||
|
|----------------|----------------------|
|
||||||
|
| 分派子智能体(`Subagent (general-purpose):` 模板) | `invoke_subagent`,配一个内置的 `TypeName` —— 全能力工作用 `self`,只读调研用 `research` |
|
||||||
|
| 任务跟踪("建一条待办"、"标记完成") | 一个 **task artifact** —— 用 `write_to_file` 并带上 `IsArtifact: true` 与 `ArtifactType: "task"`(见下方[任务跟踪](#任务跟踪))。**不是** `manage_task`,那个是管后台进程的。 |
|
||||||
|
|
||||||
|
## 任务跟踪
|
||||||
|
|
||||||
|
Antigravity **没有 todo 工具**(`manage_task` 管的是后台进程 —— `list`/`kill`/`status`/`send_input` —— 它**不是**清单工具)。当某个 skill 说要创建待办清单或跟踪任务时,改为维护一个 **task artifact**:一份用 `write_to_file` 保存的 markdown 清单(`IsArtifact: true`、`ArtifactMetadata.ArtifactType: "task"`),过程中用 `replace_file_content` / `multi_replace_file_content` 来编辑。
|
||||||
|
|
||||||
|
任何多步任务一开始,就创建这个 task artifact,把你计划里的每一步都列上。每完成一步,就编辑该 artifact 把它标记为完成(`- [x]`)。计划有变就更新清单。**保持它是最新的** —— 它是"还剩什么没做"的唯一事实来源;一旦对话变长,每开始一步之前先重读它。
|
||||||
@ -0,0 +1,76 @@
|
|||||||
|
# Codex 工具映射
|
||||||
|
|
||||||
|
> 🇨🇳 **本节的工具映射表是 superpowers-zh 的增量内容,上游 obra/superpowers 没有。** 其余章节为上游内容的翻译。
|
||||||
|
|
||||||
|
Skills 使用 Claude Code 的工具名称。在 Codex 中遇到这些名称时,请使用对应的平台等价工具:
|
||||||
|
|
||||||
|
| Skill 中的引用 | Codex 等价工具 |
|
||||||
|
|---------------|---------------|
|
||||||
|
| `Task` 工具(派遣子 agent) | `spawn_agent` |
|
||||||
|
| 多个 `Task` 调用(并行) | 多个 `spawn_agent` 调用 |
|
||||||
|
| Task 返回结果 | `wait_agent` |
|
||||||
|
| Task 自动完成 | V2 无需处理(用完自动回收);仅 V1 需要 `close_agent` 释放槽位 |
|
||||||
|
| `TodoWrite`(任务跟踪) | `update_plan` |
|
||||||
|
| `Skill` 工具(调用 skill) | Skills 原生加载——直接按说明操作 |
|
||||||
|
| `Read`、`Write`、`Edit`(文件) | 使用原生文件工具 |
|
||||||
|
| `Bash`(执行命令) | 使用原生 shell 工具 |
|
||||||
|
|
||||||
|
## 子 Agent 派遣需要多 Agent 支持
|
||||||
|
|
||||||
|
在 Codex 配置文件(`~/.codex/config.toml`)中添加:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[features]
|
||||||
|
multi_agent = true
|
||||||
|
```
|
||||||
|
|
||||||
|
启用后,`dispatching-parallel-agents` 和 `subagent-driven-development` 这类 skill 所用的多智能体工具就可用了。**你拿到哪些工具,取决于你的模型预设选中的多智能体版本**(当前的预设跑 V2,较老的跑 V1)。当你的实际工具列表与任何表格(**包括本文这张**)不一致时,以实际工具列表为准。
|
||||||
|
|
||||||
|
- **派生(Spawning):** 用 `spawn_agent {fork_turns: "none"}` 给子代理一个干净的上下文;默认值 `"all"` 会把你的**整份对话记录**复制进子代理。在 Codex 0.145+ 上,`~/.codex/agents/` 下的角色文件通过 `agent_type` 挂到隔离 fork 上。全历史 fork 接受 `model` 与 `reasoning_effort` 覆盖(在那里只有 `agent_type` 会被拒绝)—— 隔离 fork 是 SDD 的默认选择,理由是上下文卫生,**不是**因为覆盖参数需要它。
|
||||||
|
- **修复轮次(Fix rounds):** 用 `followup_task` 唤回实现者 —— 它会送达你的消息、触发一个回合,并在 harness 已经回收该子代理时透明地把它重新装载回来。绝不要因为「派生出去的 agent 不能再被发消息」这种想当然而重新派一个新的实现者;在 V2 上它**总是**可以。
|
||||||
|
- **生命周期(Lifecycle):** **V2 没有 `close_agent`。** 完成的子代理会在需要槽位时被自动回收,放着不管不产生任何成本。只有 V1 会话才有 `close_agent` —— 在那里,审查者返回审查结果后就关掉它,每个实现者在其任务的审查通过后关掉。
|
||||||
|
- **模型名:** 绝不要把 skill、表格或旧会话里的模型名直接抄进 `spawn_agent` 而不先对照你**当前**的 spawn 允许列表 —— V2 只接受具备 V2 能力的预设,其余会直接硬报错。
|
||||||
|
|
||||||
|
## 等待子代理
|
||||||
|
|
||||||
|
`wait_agent` 是**事件订阅,不是轮询**:一次长等待会在子代理产生信箱活动的那一刻醒来,延迟和短等待完全一样。短超时轮询什么也换不到,却每次都要付一次工具调用 —— 以及一次上下文重新计费。在实测会话里,大约**三分之二**的 wait 调用都是超时的短轮询。
|
||||||
|
|
||||||
|
- 只要你手上还有本地工作,就**完全不要等**。已完成子代理的最终回答会被推进你的信箱,随你的下一个回合一起到达。
|
||||||
|
- 当你确实空闲、且还有子代理在跑时,按**有界的时间段**等待:`wait_agent` 的 `timeout_ms` 设 300000-600000(5-10 分钟)。每一段结束后 —— 无论是被唤醒还是超时 —— 发一行状态、跑一次 `list_agents`,并追查任何「已完成但没汇报」的子代理。绝不要把小于五分钟的轮询叠着用;事件订阅唤醒一个有界时间段的速度和唤醒一次短轮询一样快。
|
||||||
|
- 完成邮件**无法唤醒一个空闲的控制者**(它送达时不触发回合);覆盖这个空闲窗口正是 `wait_agent` 唯一的职责。一段等待在毫无活动的情况下超时,是让你去做对账的信号,**不是**让你把下一段缩短的理由。
|
||||||
|
|
||||||
|
## 派生时的模型路由
|
||||||
|
|
||||||
|
你发出的**每一次** `spawn_agent`(包括你自己就是一个正在做扇出的子代理时),都要按你正在执行的那个 skill 的「模型选择」规则,**同时显式设置 `model` 和 `reasoning_effort`**。只设 `model` 是个陷阱:子代理的 effort 会静默重置为那个模型的默认值,而不是你的。
|
||||||
|
|
||||||
|
请你的人类伙伴在 `~/.codex/config.toml` 里加一道机器级兜底,这样任何漏设的派生仍然会路由到一个刻意选定的档位,而不是静默继承本次会话最贵的那个模型:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[agents]
|
||||||
|
default_subagent_model = "<你的 spawn 允许列表里的一个中档模型>"
|
||||||
|
default_subagent_reasoning_effort = "medium"
|
||||||
|
```
|
||||||
|
|
||||||
|
## 环境检测
|
||||||
|
|
||||||
|
创建 worktree 或收尾分支的 skill,应当在动手之前用**只读**的 git 命令检测环境:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
GIT_DIR=$(cd "$(git rev-parse --git-dir)" 2>/dev/null && pwd -P)
|
||||||
|
GIT_COMMON=$(cd "$(git rev-parse --git-common-dir)" 2>/dev/null && pwd -P)
|
||||||
|
BRANCH=$(git branch --show-current)
|
||||||
|
```
|
||||||
|
|
||||||
|
- `GIT_DIR != GIT_COMMON` → 已经在一个链接的 worktree 里(跳过创建)
|
||||||
|
- `BRANCH` 为空 → detached HEAD(无法从沙箱里建分支/推送/开 PR)
|
||||||
|
|
||||||
|
各 skill 如何使用这些信号,见 `using-git-worktrees` 的第 0 步与 `finishing-a-development-branch` 的第 1 步。
|
||||||
|
|
||||||
|
## Codex App 的收尾
|
||||||
|
|
||||||
|
当沙箱阻止建分支/推送操作时(在外部托管的 worktree 里处于 detached HEAD),agent 应提交全部工作,并告知用户改用 App 的原生控件:
|
||||||
|
|
||||||
|
- **"Create branch"** —— 命名分支,然后通过 App UI 完成 commit/push/PR
|
||||||
|
- **"Hand off to local"** —— 把工作转交到用户的本地检出
|
||||||
|
|
||||||
|
agent 仍然可以跑测试、暂存文件,并输出建议的分支名、commit message 和 PR 描述供用户复制。
|
||||||
@ -0,0 +1,82 @@
|
|||||||
|
# Copilot CLI 工具映射
|
||||||
|
|
||||||
|
技能使用 Claude Code 的工具名称。当你在技能中遇到这些工具时,使用你平台的等价工具:
|
||||||
|
|
||||||
|
| 技能中引用的工具 | Copilot CLI 等价工具 |
|
||||||
|
|-----------------|----------------------|
|
||||||
|
| `Read`(读取文件) | `view` |
|
||||||
|
| `Write`(创建文件) | `create` |
|
||||||
|
| `Edit`(编辑文件) | `edit` |
|
||||||
|
| `Bash`(运行命令) | `bash`(Windows 上常为 `powershell`,见[异步 Shell 会话](#异步-shell-会话)) |
|
||||||
|
| `Grep`(搜索文件内容) | `grep` |
|
||||||
|
| `Glob`(按名称搜索文件) | `glob` |
|
||||||
|
| `Skill` 工具(调用技能) | `skill` |
|
||||||
|
| `WebFetch` | `web_fetch` |
|
||||||
|
| `Task` 工具(分派子智能体) | `task`(参见[智能体类型](#智能体类型)) |
|
||||||
|
| 多个 `Task` 调用(并行) | 多个 `task` 调用 |
|
||||||
|
| Task 状态/输出 | `read_agent`、`list_agents` |
|
||||||
|
| `TodoWrite`(任务跟踪) | `sql` 配合内置 `todos` 表 |
|
||||||
|
| `WebSearch` | 无等价工具 — 使用 `web_fetch` 配合搜索引擎 URL |
|
||||||
|
| `EnterPlanMode` / `ExitPlanMode` | 无等价工具 — 留在主会话中 |
|
||||||
|
|
||||||
|
## 智能体类型
|
||||||
|
|
||||||
|
Copilot CLI 的 `task` 工具接受 `agent_type` 参数:
|
||||||
|
|
||||||
|
| Claude Code 智能体 | Copilot CLI 等价 |
|
||||||
|
|-------------------|----------------------|
|
||||||
|
| `general-purpose` | `"general-purpose"` |
|
||||||
|
| `Explore` | `"explore"` |
|
||||||
|
| 命名的插件智能体(形如 `<插件名>:<智能体名>`) | 从已安装的插件中自动发现 |
|
||||||
|
|
||||||
|
## 异步 Shell 会话
|
||||||
|
|
||||||
|
Copilot CLI 支持持久化的异步 shell 会话,这在 Claude Code 中没有直接等价物。
|
||||||
|
|
||||||
|
> ⚠️ **shell 工具面随平台和版本而异,下面两套工具名不会同时出现。** 动手之前先确认你这个 build 实际注册的是哪一套 —— 照着不存在的工具名调用,agent 会找不到工具然后即兴发挥。Windows 上常见的是 powershell 那一套(实测 Copilot CLI 1.0.69-1 / Windows 只有 powershell,没有任何 `bash` / `async` 家族工具)。
|
||||||
|
|
||||||
|
**Unix / macOS —— bash 一套:**
|
||||||
|
|
||||||
|
| 工具 | 用途 |
|
||||||
|
|------|---------|
|
||||||
|
| `bash` 配合 `async: true` | 在后台启动长时间运行的命令 |
|
||||||
|
| `write_bash` | 向运行中的异步会话发送输入 |
|
||||||
|
| `read_bash` | 读取异步会话的输出 |
|
||||||
|
| `stop_bash` | 终止异步会话 |
|
||||||
|
| `list_bash` | 列出所有活跃的 shell 会话 |
|
||||||
|
|
||||||
|
**Windows —— powershell 一套:**
|
||||||
|
|
||||||
|
| 工具 | 用途 |
|
||||||
|
|------|---------|
|
||||||
|
| `powershell` 配合 `detach: true` | 在后台启动长时间运行的命令(参数名是 `detach`,**不是** `async`) |
|
||||||
|
| `read_powershell` | 读取会话的输出 |
|
||||||
|
| `stop_powershell` | 终止会话 |
|
||||||
|
| `list_powershell` | 列出所有活跃的 shell 会话 |
|
||||||
|
| (无 `write_powershell`) | 这一套**没有**向运行中会话发送输入的工具 |
|
||||||
|
|
||||||
|
### Windows 上的两个坑
|
||||||
|
|
||||||
|
**1. `.sh` 脚本不能裸跑。** powershell 下直接执行 `scripts/start-server.sh` 会报 `The term 'scripts/start-server.sh' is not recognized...`,必须显式走 Git Bash:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
& "C:\Program Files\Git\bin\bash.exe" scripts/start-server.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
**2. `stop_powershell` 停不掉 `detach: true` 启动的进程。** detached 进程要按 PID 停:
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
Stop-Process -Id <PID>
|
||||||
|
```
|
||||||
|
|
||||||
|
所以**不要把 `stop_*` 当作 detached 常驻进程的唯一清理路径** —— 必须先拿到真实的 Windows PID(不是 MSYS PID),再 `Stop-Process`。涉及长驻 server 的 skill(如 brainstorming 的视觉伴侣)在 Windows 上尤其要注意这一点。
|
||||||
|
|
||||||
|
## 额外的 Copilot CLI 工具
|
||||||
|
|
||||||
|
| 工具 | 用途 |
|
||||||
|
|------|---------|
|
||||||
|
| `store_memory` | 持久化代码库相关事实供未来会话使用 |
|
||||||
|
| `report_intent` | 更新 UI 状态行显示当前意图 |
|
||||||
|
| `sql` | 查询会话的 SQLite 数据库(待办、元数据) |
|
||||||
|
| `fetch_copilot_cli_documentation` | 查阅 Copilot CLI 文档 |
|
||||||
|
| GitHub MCP 工具(`github-mcp-server-*`) | 原生 GitHub API 访问(issue、PR、代码搜索) |
|
||||||
@ -0,0 +1,63 @@
|
|||||||
|
# Gemini CLI 工具映射
|
||||||
|
|
||||||
|
Skills 说的是动作("分派一个子智能体"、"建一条待办"、"读一个文件")。在 Gemini CLI 上,这些动作对应下面这些工具。
|
||||||
|
|
||||||
|
| Skill 请求的动作 | Gemini CLI 等价工具 |
|
||||||
|
|----------------|-------------------|
|
||||||
|
| 读取一个文件 | `read_file` |
|
||||||
|
| 一次读取多个文件 | `read_many_files` |
|
||||||
|
| 创建新文件 | `write_file` |
|
||||||
|
| 编辑文件 | `replace` |
|
||||||
|
| 执行 shell 命令 | `run_shell_command` |
|
||||||
|
| 搜索文件内容 | `grep_search` |
|
||||||
|
| 按名称查找文件 | `glob` |
|
||||||
|
| 列出文件和子目录 | `list_directory` |
|
||||||
|
| 抓取 URL | `web_fetch` |
|
||||||
|
| 搜索网页 | `google_web_search` |
|
||||||
|
| 调用一个 skill | `activate_skill` |
|
||||||
|
| 分派子智能体(`Subagent (general-purpose):` 模板) | `invoke_agent`,`agent_name: "generalist"`(也可用 `@generalist` 聊天语法调用——见[子智能体支持](#子智能体支持)) |
|
||||||
|
| 多个并行分派 | 同一条响应里发多个 `invoke_agent` 调用 |
|
||||||
|
| 任务跟踪("建一条待办"、"标记完成") | `write_todos`(状态:pending、in_progress、completed、cancelled、blocked) |
|
||||||
|
|
||||||
|
## 指令文件
|
||||||
|
|
||||||
|
当某个 skill 提到"你的指令文件"时,在 Gemini CLI 上指的是 **`GEMINI.md`**。Gemini CLI 按层级加载 `GEMINI.md`:全局的在 `~/.gemini/GEMINI.md`,项目级的在工作区目录及其各级父目录里,另外当某个工具访问子目录中的文件时,该子目录下的 `GEMINI.md` 也会被加载。
|
||||||
|
|
||||||
|
## 个人 skills 目录
|
||||||
|
|
||||||
|
用户级 skills 放在 **`~/.gemini/skills/`**,**`~/.agents/skills/`** 是跨运行时的别名目录(与 Codex、Copilot CLI 共用)。当同一层级下两个目录都存在时,`.agents/skills/` 优先。每个 skill 是一个子目录,里面有一份带 `name` 和 `description` frontmatter 的 `SKILL.md`。
|
||||||
|
|
||||||
|
## 子智能体支持
|
||||||
|
|
||||||
|
Gemini CLI 通过 `invoke_agent` 工具分派子智能体,该工具接收 `agent_name` 和 `prompt` 两个参数。同一个分派动作也有聊天语法快捷方式:输入 `@generalist <prompt>` 等价于以 `agent_name: "generalist"` 调用 `invoke_agent`。内置的 agent 名包括 `generalist`、`cli_help`、`codebase_investigator`,以及(启用浏览器工具后的)`browser_agent`。
|
||||||
|
|
||||||
|
Skills 用 `Subagent (general-purpose):` 来分派,并且要么引用一个提示词模板文件(例如 `subagent-driven-development` 的 `./implementer-prompt.md`),要么直接给出内联提示词。在 Gemini CLI 上:
|
||||||
|
|
||||||
|
| Skill 里的分派形式 | Gemini CLI 等价做法 |
|
||||||
|
|------------------|-------------------|
|
||||||
|
| 引用某个 `*-prompt.md` 模板(implementer、task-reviewer、code-reviewer 等) | 把模板填好,然后以 `agent_name: "generalist"` 和填好的提示词调用 `invoke_agent` |
|
||||||
|
| 引用 `requesting-code-review` 的 `./code-reviewer.md` | 以 `agent_name: "generalist"` 和填好的审查模板调用 `invoke_agent` |
|
||||||
|
| 内联提示词(没有引用模板) | 以 `agent_name: "generalist"` 和你的内联提示词调用 `invoke_agent` |
|
||||||
|
|
||||||
|
### 填写提示词
|
||||||
|
|
||||||
|
Skills 提供的提示词模板里有 `{WHAT_WAS_IMPLEMENTED}` 或 `[FULL TEXT of task]` 这类占位符。把所有占位符都填好,再把完整提示词交给 `invoke_agent`。模板本身就包含了该 agent 的角色、审查标准和期望的输出格式——子智能体会照着它执行。
|
||||||
|
|
||||||
|
### 并行分派
|
||||||
|
|
||||||
|
Gemini CLI 支持并行分派子智能体。在同一条响应里发出多个 `invoke_agent` 调用(或在一个提示词里写多个 `@generalist` 调用),即可让相互独立的子智能体工作并行跑。有依赖关系的任务保持串行,但**不要**为了让历史记录简单一点就把相互独立的子智能体任务串起来。
|
||||||
|
|
||||||
|
## Gemini CLI 额外工具
|
||||||
|
|
||||||
|
以下工具是 Gemini CLI 独有的:
|
||||||
|
|
||||||
|
| 工具 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `save_memory`(旧版) | 当 `experimental.memoryV2 = false` 时,跨会话持久化事实 |
|
||||||
|
| `get_internal_docs` | 查阅 Gemini CLI 自带的文档 |
|
||||||
|
| `ask_user` | 向用户提出结构化问题(文本 / 单选 / 多选) |
|
||||||
|
| `enter_plan_mode` / `exit_plan_mode` | 进入和退出只读的计划模式 |
|
||||||
|
| `update_topic` | 更新当前会话的主题 / 战略意图元数据 |
|
||||||
|
| `complete_task` | 表示某个 Gemini 子智能体已完成,并把结果返回给父 agent |
|
||||||
|
| `tracker_create_task`、`tracker_update_task`、`tracker_get_task`、`tracker_list_tasks`、`tracker_add_dependency`、`tracker_visualize` | 功能完整的任务跟踪器,支持依赖关系与可视化 |
|
||||||
|
| `read_mcp_resource`、`list_mcp_resources` | 访问 MCP 资源 |
|
||||||
@ -0,0 +1,57 @@
|
|||||||
|
# Hermes Agent 工具映射
|
||||||
|
|
||||||
|
## 工具
|
||||||
|
|
||||||
|
| Skill 里要做的动作 | Hermes 工具 |
|
||||||
|
|------------------|------------|
|
||||||
|
| 读取文件 | `read_file` |
|
||||||
|
| 创建新文件 | `write_file` |
|
||||||
|
| 编辑文件(定点补丁) | `patch` |
|
||||||
|
| 运行 shell 命令 | `terminal` |
|
||||||
|
| 搜索文件内容 | `search_files` |
|
||||||
|
| 按文件名查找 | `terminal` 配合 `find` |
|
||||||
|
| 抓取 URL / 读网页 | `web_extract(urls=[...])` |
|
||||||
|
| 搜索网络 | `web_search(query=...)` |
|
||||||
|
| 派遣子智能体 | `delegate_task(goal=..., context=..., toolsets=[...], role="leaf")` |
|
||||||
|
| 任务跟踪 | `todo` 工具 |
|
||||||
|
| 调用 skill | `skill_view("skill-name")` |
|
||||||
|
|
||||||
|
## 指令文件
|
||||||
|
|
||||||
|
当某个 skill 提到「你的指令文件」时,在 Hermes Agent 上指的是项目目录里的 **`AGENTS.md`**,或全局的 **`~/.hermes/SOUL.md`**。
|
||||||
|
|
||||||
|
> 🇨🇳 **本节是 superpowers-zh 的增量内容,上游 obra/superpowers 没有。**
|
||||||
|
>
|
||||||
|
> 补充一条实践区分:`SOUL.md` 是**身份/人格**文件(Hermes 官方文档明确说项目工作流指令不属于它),所以 `npx superpowers-zh --tool hermes` 的**项目级**安装只写 `AGENTS.md`;**全局**安装只装 skills、不写 bootstrap —— 往 SOUL.md 里塞技能清单是误用那个文件。
|
||||||
|
|
||||||
|
## 调用 skill
|
||||||
|
|
||||||
|
Hermes Agent 有一个 `skills` 工具集,包含 `skill_view` 和 `skills_list` 两个工具。
|
||||||
|
要调用某个 superpowers skill,使用:
|
||||||
|
|
||||||
|
```
|
||||||
|
skill_view("brainstorming")
|
||||||
|
skill_view("test-driven-development")
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `skill_view` 找不到某个 superpowers skill(在插件完全注册之前,它可能还没出现在目录里),退回到直接读取 SKILL.md:
|
||||||
|
|
||||||
|
```
|
||||||
|
read_file(path="~/.hermes/plugins/superpowers/skills/<skill-name>/SKILL.md")
|
||||||
|
```
|
||||||
|
|
||||||
|
这个回退机制与其他没有原生 skill 加载能力的 harness 用的是同一套。
|
||||||
|
|
||||||
|
## 子智能体派遣
|
||||||
|
|
||||||
|
用 `delegate_task` 为并行或串行的工作流派生隔离的子智能体:
|
||||||
|
|
||||||
|
```
|
||||||
|
delegate_task(goal="...", context="...", toolsets=[...], role="leaf")
|
||||||
|
```
|
||||||
|
|
||||||
|
如果 `delegate_task` 不可用,就把工作内联做完,不要凭空编造工具调用。
|
||||||
|
|
||||||
|
## 任务跟踪
|
||||||
|
|
||||||
|
会话内的任务跟踪用 `todo` 工具。多智能体的任务看板,如果可用则使用 `hermes kanban` CLI。遇到旧文档里的 `TodoWrite` 引用,按「任务跟踪」这个动作理解即可。
|
||||||
28
.codebuddy/skills/using-superpowers/references/pi-tools.md
Normal file
28
.codebuddy/skills/using-superpowers/references/pi-tools.md
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
# Pi Tool Mapping
|
||||||
|
|
||||||
|
Skills speak in actions ("dispatch a subagent", "create a todo", "read a file"). On Pi these resolve to the tools below.
|
||||||
|
|
||||||
|
| Action skills request | Pi equivalent |
|
||||||
|
| --- | --- |
|
||||||
|
| Invoke a skill | Pi native skills: load the relevant `SKILL.md` with `read`, or let the human use `/skill:name` |
|
||||||
|
| Read a file | `read` |
|
||||||
|
| Create a file | `write` |
|
||||||
|
| Edit a file | `edit` |
|
||||||
|
| Run a shell command | `bash` |
|
||||||
|
| Search file contents | `grep` when active; otherwise `bash` with `rg`/`grep` |
|
||||||
|
| Find files by name | `find` or `bash` with shell globs |
|
||||||
|
| List files and subdirectories | `ls` when active; otherwise `bash` with `ls` |
|
||||||
|
| Dispatch a subagent (`Subagent (general-purpose):` template) | Use an installed subagent tool such as `subagent` from `pi-subagents` if available |
|
||||||
|
| Task tracking ("create a todo", "mark complete") | Use an installed todo/task tool if available, otherwise track tasks in the plan or `TODO.md` |
|
||||||
|
|
||||||
|
## Skills
|
||||||
|
|
||||||
|
Pi discovers skills from configured skill directories and installed Pi packages. A Superpowers Pi package should expose `skills/` through its `pi.skills` manifest entry. Pi does not expose Claude Code's `Skill` tool, but the agent should still follow the Superpowers rule: when a skill applies, load and follow it before responding.
|
||||||
|
|
||||||
|
## Subagents
|
||||||
|
|
||||||
|
Pi core does not ship a standard subagent tool. The `pi-subagents` package is a strong optional companion and provides a `subagent` tool with single-agent, chain, parallel, async, forked-context, and resume/status workflows. If no subagent tool is available, do not fabricate `Task` calls; execute sequentially in the current session or explain that the optional subagent capability is not installed.
|
||||||
|
|
||||||
|
## Task lists
|
||||||
|
|
||||||
|
Pi core does not ship a standard task-list tool. If a todo/task extension is installed, use its documented tool. Otherwise use Superpowers plan files, checklists in Markdown, or a repo-local `TODO.md` for task tracking. Older Superpowers docs may refer to `TodoWrite`; treat that as the task-tracking action above.
|
||||||
@ -0,0 +1,52 @@
|
|||||||
|
# Qoder 工具映射
|
||||||
|
|
||||||
|
Skills 使用 Claude Code 的工具名称。Qoder(阿里 AI IDE)大部分工具与 Claude Code **同名**,只有少数差异:
|
||||||
|
|
||||||
|
| Skill 中的引用 | Qoder 等价工具 |
|
||||||
|
|---------------|---------------|
|
||||||
|
| `Read` / `Write` / `Edit` | 同名(`Read` / `Write` / `Edit`) |
|
||||||
|
| `Bash` | 同名 |
|
||||||
|
| `Grep` / `Glob` | 同名 |
|
||||||
|
| `Task`(派遣子 agent) | 同名(`Task`) |
|
||||||
|
| `WebFetch` / `WebSearch` | 同名 |
|
||||||
|
| `AskUserQuestion` | 同名 |
|
||||||
|
| `Skill` | 同名 |
|
||||||
|
| `TodoWrite` | 同名 |
|
||||||
|
| `EnterPlanMode` / `ExitPlanMode` | **`EnterSpecMode` / `ExitSpecMode`**(Qoder 把"计划模式"称为"Spec 模式")|
|
||||||
|
|
||||||
|
## Task 子 Agent 类型
|
||||||
|
|
||||||
|
> **适用范围:Qoder CLI。** 下表逐条核对自 [Qoder 官方文档 · 子代理](https://docs.qoder.com/zh/cli/subagent)(核对于 2026-08)。
|
||||||
|
> **Qoder IDE 的内置 subagent 集合与此不同,我们尚未核实** —— 见下方「IDE 与 CLI 的差异」。
|
||||||
|
|
||||||
|
| Claude Code Agent | Qoder CLI 等价 | 说明 |
|
||||||
|
|------------------|---------------|------|
|
||||||
|
| `general-purpose` | `general-purpose` | 通用研究型,适合复杂搜索、多文件分析、调用链追踪、多步骤任务 |
|
||||||
|
| `Explore` | `Explore` | 同名。只读代码探索 |
|
||||||
|
| `Plan` | `Plan` | 同名。只读设计与规划 |
|
||||||
|
| `claude-code-guide` | `qoder-guide` | 非 SDK 模式下可用 |
|
||||||
|
|
||||||
|
文档另列出 `statusline-setup`(TUI 模式)。**没有内置的 `code-reviewer`** —— 文档里出现的 `api-reviewer` 是用户自建 subagent 的示例,不是内置项。需要专职审查者时,用 `general-purpose` 配 `requesting-code-review` 的 `code-reviewer.md` 模板。
|
||||||
|
|
||||||
|
### IDE 与 CLI 的差异
|
||||||
|
|
||||||
|
[#119](https://github.com/jnMetaCode/superpowers-zh/issues/119) 报告:在 **Qoder IDE** 里跑 `subagent-driven-development` 时,Qoder 说它只提供 `CodeReview` subagent、**没有** `general-purpose`,于是自行降级为「控制者直接实现 + CodeReview agent 做审查」。
|
||||||
|
|
||||||
|
官方 subagent 文档只覆盖 CLI,没有说这套内置集合同样适用于 IDE。**所以上表在 Qoder IDE 上不保证成立。** 如果你在 IDE 里遇到「找不到 general-purpose」,那是预期内的差异,不是 superpowers-zh 装错了 —— Qoder 的自动降级本身是合理适配。
|
||||||
|
|
||||||
|
## Quest MCP 工具(Qoder 原生)
|
||||||
|
|
||||||
|
Qoder 内置 Quest 系统提供以下工具,Claude Code 没有等价物,可在 skill 流程中直接调用:
|
||||||
|
|
||||||
|
| 工具 | 用途 |
|
||||||
|
|------|------|
|
||||||
|
| `mcp__quest__search_codebase` | 语义化代码搜索(按意图找代码) |
|
||||||
|
| `mcp__quest__search_symbol` | 按符号名搜索代码及关系 |
|
||||||
|
| `mcp__quest__get_problems` | 获取文件编译/语法错误 |
|
||||||
|
| `mcp__quest__run_preview` | 启动本地 Web 服务器预览 |
|
||||||
|
| `mcp__quest__search_memory` / `update_memory` | 跨会话记忆管理 |
|
||||||
|
| `mcp__quest__fetch_rules` | 查询规则文件 |
|
||||||
|
|
||||||
|
## 加载方式
|
||||||
|
|
||||||
|
Qoder 在每个会话自动加载 `.qoder/rules/superpowers-zh.md`(`trigger: always_on`),里面包含 skill 索引。`.qoder/skills/<name>/SKILL.md` 由模型按 description 自主调用,也可输入 `/<skill-name>` 手动触发。
|
||||||
126
.codebuddy/skills/verification-before-completion/SKILL.md
Normal file
126
.codebuddy/skills/verification-before-completion/SKILL.md
Normal file
@ -0,0 +1,126 @@
|
|||||||
|
---
|
||||||
|
name: verification-before-completion
|
||||||
|
description: 在宣称工作完成、已修复或测试通过之前使用,在提交或创建 PR 之前——必须运行验证命令并确认输出后才能声称成功;始终用证据支撑断言
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [quality, verification]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 完成前验证
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
**核心原则:** 始终用证据支撑结论。
|
||||||
|
|
||||||
|
**对这条规则敷衍了事,就等于违背了它的精神。**
|
||||||
|
|
||||||
|
## 铁律
|
||||||
|
|
||||||
|
```
|
||||||
|
没有新鲜的验证证据,不许宣称完成
|
||||||
|
```
|
||||||
|
|
||||||
|
如果你在这条消息中没有运行验证命令,就不能声称测试通过。
|
||||||
|
|
||||||
|
## 门控函数
|
||||||
|
|
||||||
|
```
|
||||||
|
在宣称任何状态或表达满意之前:
|
||||||
|
|
||||||
|
1. 确定:什么命令能证明这个结论?
|
||||||
|
2. 运行:执行完整命令(重新运行,完整执行)
|
||||||
|
3. 阅读:完整输出,检查退出码,统计失败数
|
||||||
|
4. 验证:输出是否支持这个结论?
|
||||||
|
- 如果否:用证据说明实际状态
|
||||||
|
- 如果是:带证据陈述结论
|
||||||
|
5. 只有这时:才能做出结论
|
||||||
|
|
||||||
|
跳过任何一步 = 说谎,不是验证
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常见失败模式
|
||||||
|
|
||||||
|
| 结论 | 需要 | 不够格 |
|
||||||
|
|------|------|--------|
|
||||||
|
| 测试通过 | 测试命令输出:0 failures | 之前的运行结果、"应该会通过" |
|
||||||
|
| Linter 无报错 | Linter 输出:0 errors | 部分检查、推断 |
|
||||||
|
| 构建成功 | 构建命令:exit 0 | linter 通过、日志看起来没问题 |
|
||||||
|
| Bug 已修复 | 测试原始症状:通过 | 代码改了,假设已修复 |
|
||||||
|
| 回归测试有效 | 红-绿循环已验证 | 测试只通过了一次 |
|
||||||
|
| 代理已完成 | VCS diff 显示变更 | 代理报告"成功" |
|
||||||
|
| 需求已满足 | 逐项核对清单 | 测试通过 |
|
||||||
|
|
||||||
|
## 红线——停下来
|
||||||
|
|
||||||
|
- 使用"应该"、"大概"、"似乎"
|
||||||
|
- 验证前就表达满意("太好了!"、"完美!"、"搞定!"等)
|
||||||
|
- 即将提交/推送/创建 PR 却没有验证
|
||||||
|
- 信任代理的成功报告
|
||||||
|
- 依赖部分验证
|
||||||
|
- 想着"就这一次"
|
||||||
|
- 累了想赶紧收工
|
||||||
|
- **任何暗示成功但实际未运行验证的措辞**
|
||||||
|
|
||||||
|
## 防止合理化
|
||||||
|
|
||||||
|
| 借口 | 现实 |
|
||||||
|
|------|------|
|
||||||
|
| "应该能行了" | 运行验证命令 |
|
||||||
|
| "我有信心" | 信心 ≠ 证据 |
|
||||||
|
| "就这一次" | 没有例外 |
|
||||||
|
| "Linter 通过了" | Linter ≠ 编译器 |
|
||||||
|
| "代理说成功了" | 独立验证 |
|
||||||
|
| "我累了" | 疲劳 ≠ 借口 |
|
||||||
|
| "部分检查就够了" | 部分检查什么也证明不了 |
|
||||||
|
| "换个说法这条规则就不适用了" | 精神大于字面 |
|
||||||
|
|
||||||
|
## 关键模式
|
||||||
|
|
||||||
|
**测试:**
|
||||||
|
```
|
||||||
|
✅ [运行测试命令] [看到:34/34 pass] "全部测试通过"
|
||||||
|
❌ "应该能通过了" / "看起来对了"
|
||||||
|
```
|
||||||
|
|
||||||
|
**回归测试(TDD 红-绿):**
|
||||||
|
```
|
||||||
|
✅ 编写 → 运行(通过)→ 回退修复 → 运行(必须失败)→ 恢复 → 运行(通过)
|
||||||
|
❌ "我写了回归测试"(没有经过红-绿验证)
|
||||||
|
```
|
||||||
|
|
||||||
|
**构建:**
|
||||||
|
```
|
||||||
|
✅ [运行构建] [看到:exit 0] "构建通过"
|
||||||
|
❌ "Linter 通过了"(linter 不检查编译)
|
||||||
|
```
|
||||||
|
|
||||||
|
**需求:**
|
||||||
|
```
|
||||||
|
✅ 重读计划 → 创建核对清单 → 逐项验证 → 报告缺口或完成
|
||||||
|
❌ "测试通过了,阶段完成"
|
||||||
|
```
|
||||||
|
|
||||||
|
**代理委派:**
|
||||||
|
```
|
||||||
|
✅ 代理报告成功 → 检查 VCS diff → 验证变更 → 报告实际状态
|
||||||
|
❌ 信任代理报告
|
||||||
|
```
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
**以下情况之前必须使用:**
|
||||||
|
- 任何形式的成功/完成声明
|
||||||
|
- 任何满意的表达
|
||||||
|
- 任何关于工作状态的正面陈述
|
||||||
|
- 提交、创建 PR、标记任务完成
|
||||||
|
- 进入下一个任务
|
||||||
|
- 委派给代理
|
||||||
|
|
||||||
|
**本规则适用于:**
|
||||||
|
- 准确措辞
|
||||||
|
- 同义词和换一种说法
|
||||||
|
- 暗示成功
|
||||||
|
- 任何传达完成/正确性的沟通
|
||||||
|
|
||||||
177
.codebuddy/skills/workflow-runner/SKILL.md
Normal file
177
.codebuddy/skills/workflow-runner/SKILL.md
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
---
|
||||||
|
name: workflow-runner
|
||||||
|
description: "在 Claude Code / OpenClaw / Cursor 中直接运行 agency-orchestrator YAML 工作流——无需 API key,使用当前会话的 LLM 作为执行引擎。当用户提供 .yaml 工作流文件或要求多角色协作完成任务时触发。"
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [execution, workflow]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 工作流执行器:在 AI 工具内运行多角色编排
|
||||||
|
|
||||||
|
直接在当前会话中执行 agency-orchestrator 的 YAML 工作流,无需配置 API key。当前 LLM 就是执行引擎——依次扮演每个角色完成任务。
|
||||||
|
|
||||||
|
## 适用场景
|
||||||
|
|
||||||
|
- 用户提供了一个 `.yaml` 工作流文件(如 `运行 workflows/story-creation.yaml`)
|
||||||
|
- 用户要求多个角色协作完成任务(如"用产品经理和架构师一起评审这个 PRD")
|
||||||
|
- 用户安装了 `agency-agents-zh` 并希望直接在 AI 工具内编排多角色
|
||||||
|
|
||||||
|
## 执行流程(5 步)
|
||||||
|
|
||||||
|
按以下顺序执行,不要跳步:
|
||||||
|
|
||||||
|
### 第 1 步:解析工作流
|
||||||
|
|
||||||
|
用 Read 工具读取用户指定的 YAML 文件,提取以下字段:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
name: "工作流名称"
|
||||||
|
agents_dir: "agency-agents-zh" # 角色定义目录
|
||||||
|
inputs: # 输入变量
|
||||||
|
- name: xxx
|
||||||
|
required: true/false
|
||||||
|
default: "默认值"
|
||||||
|
steps: # 执行步骤
|
||||||
|
- id: step_id
|
||||||
|
role: "category/agent-name" # 角色路径
|
||||||
|
task: "任务描述 {{变量}}" # 支持模板变量
|
||||||
|
output: variable_name # 输出变量名
|
||||||
|
depends_on: [other_step_id] # 依赖关系
|
||||||
|
```
|
||||||
|
|
||||||
|
**忽略 `llm`、`concurrency`、`timeout`、`retry` 配置**——Skill 模式使用当前会话的 LLM,这些字段仅用于 CLI 模式。
|
||||||
|
|
||||||
|
**定位角色目录**:用 Bash `test -d` 按以下顺序检查,用第一个存在的:
|
||||||
|
1. 当前工作目录下的 `{agents_dir}/`(如 `./agency-agents-zh/`)
|
||||||
|
2. `../{agents_dir}/`(上级目录)
|
||||||
|
3. 相对于 YAML 文件所在目录的 `{agents_dir}/`
|
||||||
|
4. `node_modules/agency-agents-zh/`
|
||||||
|
|
||||||
|
如果全部找不到,**停止执行**并提示用户:
|
||||||
|
```
|
||||||
|
找不到角色目录。请先安装:
|
||||||
|
git clone --depth 1 https://github.com/jnMetaCode/agency-agents-zh.git
|
||||||
|
或:npm install agency-agents-zh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第 2 步:收集输入
|
||||||
|
|
||||||
|
- 对每个 `required: true` 的输入,检查用户消息中是否已提供值
|
||||||
|
- 未提供的必填输入:**立即向用户询问**,不要猜测或用空值
|
||||||
|
- 有 `default` 的可选输入:使用默认值
|
||||||
|
- 无默认值的可选输入:设为空字符串
|
||||||
|
|
||||||
|
### 第 3 步:构建执行顺序
|
||||||
|
|
||||||
|
根据 `depends_on` 进行拓扑排序,将步骤分成多个层级:
|
||||||
|
|
||||||
|
- **无 depends_on 的步骤** → 第 1 层
|
||||||
|
- **depends_on 全部在第 N 层或之前的步骤** → 第 N+1 层
|
||||||
|
- **同一层内的步骤**互不依赖,可并行
|
||||||
|
|
||||||
|
在回复中展示执行计划:
|
||||||
|
```
|
||||||
|
执行计划(共 N 步):
|
||||||
|
第 1 层: [step_id] — 角色名
|
||||||
|
第 2 层: [step_a, step_b] — 并行
|
||||||
|
第 3 层: [step_id] — 角色名
|
||||||
|
```
|
||||||
|
|
||||||
|
### 第 4 步:逐层执行
|
||||||
|
|
||||||
|
对每一层:
|
||||||
|
|
||||||
|
#### 4a. 预读角色文件
|
||||||
|
|
||||||
|
用 Read 工具读取该层所有步骤的角色 `.md` 文件:`{角色目录}/{role}.md`
|
||||||
|
|
||||||
|
从文件中提取:
|
||||||
|
- **角色名**:frontmatter 中的 `name` 字段
|
||||||
|
- **角色 system prompt**:第二个 `---` 之后的全部 markdown 内容
|
||||||
|
|
||||||
|
#### 4b. 渲染 task 模板
|
||||||
|
|
||||||
|
将 task 中的 `{{变量名}}` 替换为:
|
||||||
|
- 来自 inputs 的用户输入值
|
||||||
|
- 来自前序步骤 output 的结果文本
|
||||||
|
|
||||||
|
#### 4c. 执行
|
||||||
|
|
||||||
|
**单步骤层**:直接在主会话中扮演该角色执行。格式:
|
||||||
|
|
||||||
|
```
|
||||||
|
### Step N/Total: step_id(角色名)
|
||||||
|
|
||||||
|
[以该角色身份完成 task,使用角色的专业知识和沟通风格]
|
||||||
|
```
|
||||||
|
|
||||||
|
**多步骤层(并行)**:使用 Agent 工具为每个步骤启动子代理。每个子代理的 prompt 必须包含:
|
||||||
|
- 角色文件的**完整文本内容**(不是路径——子代理可能无法读文件)
|
||||||
|
- 渲染后的 task 文本
|
||||||
|
- 指令:"以上是你的角色定义,请以该角色身份完成以下任务,直接输出结果"
|
||||||
|
|
||||||
|
#### 4d. 保存输出到上下文
|
||||||
|
|
||||||
|
如果 step 有 `output` 字段,将该步骤的输出文本存入变量上下文,供后续步骤的 `{{变量}}` 使用。
|
||||||
|
|
||||||
|
### 第 5 步:保存结果并展示
|
||||||
|
|
||||||
|
用 Write 工具将结果保存到文件:
|
||||||
|
|
||||||
|
```
|
||||||
|
.ao-output/{工作流名称}-{YYYY-MM-DD}/
|
||||||
|
├── steps/
|
||||||
|
│ ├── 1-{step_id}.md # 每步的输出
|
||||||
|
│ ├── 2-{step_id}.md
|
||||||
|
│ └── ...
|
||||||
|
├── summary.md # 最后一步的完整输出(最终成果)
|
||||||
|
└── metadata.json # 基本元数据
|
||||||
|
```
|
||||||
|
|
||||||
|
metadata.json 格式:
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"name": "工作流名称",
|
||||||
|
"date": "2026-03-22",
|
||||||
|
"success": true,
|
||||||
|
"steps": [
|
||||||
|
{"id": "step_id", "role": "category/agent", "status": "completed"},
|
||||||
|
...
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
执行完毕后,向用户展示:
|
||||||
|
1. 最终成果(summary.md 的内容)
|
||||||
|
2. 文件保存位置
|
||||||
|
3. 执行了几个步骤
|
||||||
|
|
||||||
|
## 重要规则
|
||||||
|
|
||||||
|
<HARD-GATE>
|
||||||
|
- 每个步骤都必须真正扮演对应角色,使用该角色的专业知识和沟通风格,不能泛泛回答
|
||||||
|
- 角色切换必须明确——每步开始时标注角色名
|
||||||
|
- 不要跳过步骤或合并步骤,严格按 DAG 层级顺序执行
|
||||||
|
- 如果角色文件找不到,告知用户并建议安装 agency-agents-zh
|
||||||
|
- 不要在没有读取角色 .md 文件的情况下执行步骤——必须先 Read 再执行
|
||||||
|
</HARD-GATE>
|
||||||
|
|
||||||
|
## 没有 YAML 文件时的快捷模式
|
||||||
|
|
||||||
|
如果用户没有指定 YAML 文件,但描述了需要多角色协作的任务:
|
||||||
|
|
||||||
|
1. 根据用户描述,**自动生成** YAML 工作流定义
|
||||||
|
2. 展示给用户确认
|
||||||
|
3. 确认后按上述流程执行
|
||||||
|
|
||||||
|
示例:
|
||||||
|
- 用户说"帮我用叙事学家和心理学家写个故事" → 生成 story-creation 类似的工作流
|
||||||
|
- 用户说"让产品经理和架构师评审这个 PRD" → 生成 product-review 类似的工作流
|
||||||
|
|
||||||
|
## 故障处理
|
||||||
|
|
||||||
|
- **角色文件不存在**:提示用户运行 `ao init` 或 `npm install agency-agents-zh`
|
||||||
|
- **模板变量未定义**:检查上下文,如果是必填输入则向用户询问
|
||||||
|
- **步骤执行失败**:标记该步骤为失败,跳过所有依赖它的下游步骤,继续执行其他独立步骤
|
||||||
162
.codebuddy/skills/writing-plans/SKILL.md
Normal file
162
.codebuddy/skills/writing-plans/SKILL.md
Normal file
@ -0,0 +1,162 @@
|
|||||||
|
---
|
||||||
|
name: writing-plans
|
||||||
|
description: 当你有规格说明或需求用于多步骤任务时使用,在动手写代码之前
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [planning, documentation]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 编写计划
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
编写全面的实现计划,假设工程师对我们的代码库零上下文,且品味存疑。记录他们需要知道的一切:每个任务要修改哪些文件、代码、测试、可能需要查阅的文档、如何测试。将整个计划拆成小步骤任务。DRY。YAGNI。TDD。频繁 commit。
|
||||||
|
|
||||||
|
假设他们是有经验的开发者,但对我们的工具链和问题领域几乎一无所知。假设他们不太擅长测试设计。
|
||||||
|
|
||||||
|
**开始时宣布:** "我正在使用 writing-plans 技能创建实现计划。"
|
||||||
|
|
||||||
|
**上下文:** 此技能应在专用 worktree 中运行(由 brainstorming 技能创建)。
|
||||||
|
|
||||||
|
**计划保存位置:** `docs/superpowers/plans/YYYY-MM-DD-<feature-name>.md`
|
||||||
|
- (用户对计划位置的偏好优先于此默认值)
|
||||||
|
|
||||||
|
## 范围检查
|
||||||
|
|
||||||
|
如果规格涵盖了多个独立子系统,它应该在头脑风暴阶段就被拆分为子项目规格。如果没有,建议将其拆分为独立的计划——每个子系统一个。每个计划应该能独立产出可工作、可测试的软件。
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
在定义任务之前,先列出将要创建或修改的文件以及每个文件的职责。这是锁定分解决策的地方。
|
||||||
|
|
||||||
|
- 设计边界清晰、接口定义良好的单元。每个文件应有一个明确的职责。
|
||||||
|
- 你对能一次放入上下文的代码推理得最好,文件越专注你的编辑越可靠。优先选择小而专注的文件,而非承担过多功能的大文件。
|
||||||
|
- 一起变更的文件应放在一起。按职责拆分,而非按技术层级拆分。
|
||||||
|
- 在现有代码库中,遵循已有模式。如果代码库使用大文件,不要单方面重构——但如果你正在修改的文件已经变得难以管理,在计划中包含拆分是合理的。
|
||||||
|
|
||||||
|
此结构决定了任务分解。每个任务应产出独立的、有意义的变更。
|
||||||
|
|
||||||
|
## 任务粒度定界
|
||||||
|
|
||||||
|
一个任务是**能独立承载自己那一轮测试循环、且值得一个全新审查者把关**的最小单元。划任务边界时:把搭建、配置、脚手架和文档这些步骤,折进那个真正需要它们的交付物所在的任务里;只在「审查者有可能否掉这个任务、同时批准它旁边那个」的地方才拆开。每个任务都以一个**可独立测试的交付物**结束。
|
||||||
|
|
||||||
|
## 小步骤任务粒度
|
||||||
|
|
||||||
|
**每步是一个操作(2-5 分钟):**
|
||||||
|
- "编写失败的测试" - 一步
|
||||||
|
- "运行它确认失败" - 一步
|
||||||
|
- "实现最少代码让测试通过" - 一步
|
||||||
|
- "运行测试确认通过" - 一步
|
||||||
|
- "Commit" - 一步
|
||||||
|
|
||||||
|
## 计划文档头部
|
||||||
|
|
||||||
|
**每个计划必须以此头部开始:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
# [功能名称] 实现计划
|
||||||
|
|
||||||
|
> **面向 AI 代理的工作者:** 必需子技能:使用 subagent-driven-development(推荐)或 executing-plans 逐任务实现此计划。步骤使用复选框(`- [ ]`)语法来跟踪进度。
|
||||||
|
|
||||||
|
**目标:** [一句话描述要构建什么]
|
||||||
|
|
||||||
|
**架构:** [2-3 句话描述方案]
|
||||||
|
|
||||||
|
**技术栈:** [关键技术/库]
|
||||||
|
|
||||||
|
**规格:** [本计划所实现的规格 / 设计文档路径 —— 计划的论证依据来自规格,所以规格要跟着计划一起走;执行者两份都读]
|
||||||
|
|
||||||
|
|
||||||
|
## 全局约束
|
||||||
|
|
||||||
|
[来自规格的项目级要求 —— 版本下限、依赖限制、命名与文案规则、平台要求 —— 每条一行,数值从规格里逐字照抄。每个任务的要求都隐含包含本节。]
|
||||||
|
|
||||||
|
---
|
||||||
|
```
|
||||||
|
|
||||||
|
## 任务结构
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
### 任务 N:[组件名称]
|
||||||
|
|
||||||
|
**文件:**
|
||||||
|
- 创建:`exact/path/to/file.py`
|
||||||
|
- 修改:`exact/path/to/existing.py:123-145`
|
||||||
|
- 测试:`tests/exact/path/to/test.py`
|
||||||
|
|
||||||
|
- [ ] **步骤 1:编写失败的测试**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_specific_behavior():
|
||||||
|
result = function(input)
|
||||||
|
assert result == expected
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **步骤 2:运行测试验证失败**
|
||||||
|
|
||||||
|
运行:`pytest tests/path/test.py::test_name -v`
|
||||||
|
预期:FAIL,报错 "function not defined"
|
||||||
|
|
||||||
|
- [ ] **步骤 3:编写最少实现代码**
|
||||||
|
|
||||||
|
```python
|
||||||
|
def function(input):
|
||||||
|
return expected
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **步骤 4:运行测试验证通过**
|
||||||
|
|
||||||
|
运行:`pytest tests/path/test.py::test_name -v`
|
||||||
|
预期:PASS
|
||||||
|
|
||||||
|
- [ ] **步骤 5:Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add tests/path/test.py src/path/file.py
|
||||||
|
git commit -m "feat: add specific feature"
|
||||||
|
```
|
||||||
|
````
|
||||||
|
|
||||||
|
## 禁止占位符
|
||||||
|
|
||||||
|
每个步骤都必须包含工程师需要的实际内容。以下是**计划缺陷**——绝不要写出来:
|
||||||
|
- "待定"、"TODO"、"后续实现"、"补充细节"
|
||||||
|
- "添加适当的错误处理" / "添加验证" / "处理边界情况"
|
||||||
|
- "为上述代码编写测试"(没有实际测试代码)
|
||||||
|
- "类似任务 N"(重复代码——工程师可能不按顺序阅读任务)
|
||||||
|
- 只描述做什么而不展示怎么做的步骤(代码步骤必须有代码块)
|
||||||
|
- 引用了未在任何任务中定义的类型、函数或方法
|
||||||
|
|
||||||
|
## 自检
|
||||||
|
|
||||||
|
编写完整计划后,以全新视角审视规格并对照检查计划。这是你自己执行的检查清单——不是子代理调度。
|
||||||
|
|
||||||
|
**1. 规格覆盖度:** 浏览规格中的每个章节/需求。你能指出实现它的任务吗?列出所有遗漏。
|
||||||
|
|
||||||
|
**2. 占位符扫描:** 搜索计划中的红旗——上方"禁止占位符"章节中的任何模式。修复它们。
|
||||||
|
|
||||||
|
**3. 类型一致性:** 后续任务中使用的类型、方法签名和属性名是否与前面任务中定义的一致?任务 3 中叫 `clearLayers()` 但任务 7 中叫 `clearFullLayers()` 就是 bug。
|
||||||
|
|
||||||
|
如果发现问题,直接内联修复。无需重新审查——修好继续推进。如果发现规格中的需求没有对应任务,就添加任务。
|
||||||
|
|
||||||
|
## 执行交接
|
||||||
|
|
||||||
|
保存计划后,提供执行选项:
|
||||||
|
|
||||||
|
**"计划已完成并保存到 `docs/superpowers/plans/<filename>.md`。两种执行方式:**
|
||||||
|
|
||||||
|
**1. 子代理驱动(推荐)** - 每个任务调度一个新的子代理,任务间进行审查,快速迭代
|
||||||
|
|
||||||
|
**2. 内联执行** - 在当前会话中使用 executing-plans 执行任务,批量执行并设有检查点
|
||||||
|
|
||||||
|
**选哪种方式?"**
|
||||||
|
|
||||||
|
**如果选择子代理驱动:**
|
||||||
|
- **必需子技能:** 使用 subagent-driven-development
|
||||||
|
- 每个任务一个新子代理 + 两阶段审查
|
||||||
|
|
||||||
|
**如果选择内联执行:**
|
||||||
|
- **必需子技能:** 使用 executing-plans
|
||||||
|
- 批量执行并设有检查点供审查
|
||||||
@ -0,0 +1,49 @@
|
|||||||
|
# 计划文档审查员提示模板
|
||||||
|
|
||||||
|
调度计划文档审查员子代理时使用此模板。
|
||||||
|
|
||||||
|
**用途:** 验证计划是否完整、与规格匹配,并且任务分解合理。
|
||||||
|
|
||||||
|
**调度时机:** 完整计划编写完成后。
|
||||||
|
|
||||||
|
```
|
||||||
|
Task tool(通用):
|
||||||
|
description: "审查计划文档"
|
||||||
|
prompt: |
|
||||||
|
你是一名计划文档审查员。验证此计划是否完整并准备好进行实现。
|
||||||
|
|
||||||
|
**待审查计划:** [PLAN_FILE_PATH]
|
||||||
|
**参考规格:** [SPEC_FILE_PATH]
|
||||||
|
|
||||||
|
## 检查内容
|
||||||
|
|
||||||
|
| 类别 | 检查要点 |
|
||||||
|
|------|----------|
|
||||||
|
| 完整性 | TODO、占位符、不完整的任务、缺失的步骤 |
|
||||||
|
| 规格对齐 | 计划覆盖了规格需求,没有重大范围蔓延 |
|
||||||
|
| 任务分解 | 任务有清晰的边界,步骤可执行 |
|
||||||
|
| 可构建性 | 工程师能否按此计划执行而不会卡住? |
|
||||||
|
|
||||||
|
## 校准标准
|
||||||
|
|
||||||
|
**只标记会在实现阶段造成实际问题的事项。**
|
||||||
|
实现者构建了错误的东西或卡住了——这是问题。
|
||||||
|
措辞上的小改进、风格偏好和"锦上添花"的建议则不是。
|
||||||
|
|
||||||
|
除非存在严重缺陷——规格中的需求遗漏、
|
||||||
|
矛盾的步骤、占位内容、或者模糊到无法执行的任务——否则应予以通过。
|
||||||
|
|
||||||
|
## 输出格式
|
||||||
|
|
||||||
|
## 计划审查
|
||||||
|
|
||||||
|
**状态:** 通过 | 发现问题
|
||||||
|
|
||||||
|
**问题(如有):**
|
||||||
|
- [任务 X,步骤 Y]:[具体问题] - [为什么这对实现很重要]
|
||||||
|
|
||||||
|
**建议(仅供参考,不阻止通过):**
|
||||||
|
- [改进建议]
|
||||||
|
```
|
||||||
|
|
||||||
|
**审查员返回:** 状态、问题(如有)、建议
|
||||||
679
.codebuddy/skills/writing-skills/SKILL.md
Normal file
679
.codebuddy/skills/writing-skills/SKILL.md
Normal file
@ -0,0 +1,679 @@
|
|||||||
|
---
|
||||||
|
name: writing-skills
|
||||||
|
description: 当创建新技能、编辑现有技能或在部署前验证技能是否有效时使用
|
||||||
|
version: "1.0.0"
|
||||||
|
license: MIT
|
||||||
|
metadata:
|
||||||
|
hermes:
|
||||||
|
tags: [skills, documentation]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 编写技能
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
**编写技能就是将测试驱动开发应用于流程文档。**
|
||||||
|
|
||||||
|
**个人技能存放在智能体特定的目录中(Claude Code 用 `~/.claude/skills`,Codex 用 `~/.agents/skills/`)**
|
||||||
|
|
||||||
|
你编写测试用例(带子智能体的压力场景),观察它们失败(基线行为),编写技能(文档),观察测试通过(智能体遵守规则),然后重构(堵住漏洞)。
|
||||||
|
|
||||||
|
**核心原则:** 如果你没有观察到智能体在没有该技能时失败,你就不知道这个技能是否教了正确的东西。
|
||||||
|
|
||||||
|
**必需背景:** 在使用此技能前,你必须理解 test-driven-development。该技能定义了基本的红-绿-重构循环。本技能将 TDD 适配到文档编写中。
|
||||||
|
|
||||||
|
**官方指南:** Anthropic 官方的技能编写最佳实践请参见 anthropic-best-practices.md。该文档提供了补充本技能 TDD 导向方法的额外模式和指南。
|
||||||
|
|
||||||
|
## 什么是技能?
|
||||||
|
|
||||||
|
**技能**是经过验证的技术、模式或工具的参考指南。技能帮助未来的 Claude 实例找到并应用有效的方法。
|
||||||
|
|
||||||
|
**技能是:** 可复用的技术、模式、工具、参考指南
|
||||||
|
|
||||||
|
**技能不是:** 关于你某次如何解决问题的叙事
|
||||||
|
|
||||||
|
## TDD 映射到技能
|
||||||
|
|
||||||
|
| TDD 概念 | 技能创建 |
|
||||||
|
|----------|---------|
|
||||||
|
| **测试用例** | 带子智能体的压力场景 |
|
||||||
|
| **生产代码** | 技能文档(SKILL.md) |
|
||||||
|
| **测试失败(红)** | 智能体在没有技能时违反规则(基线) |
|
||||||
|
| **测试通过(绿)** | 智能体在有技能时遵守规则 |
|
||||||
|
| **重构** | 在保持合规的同时堵住漏洞 |
|
||||||
|
| **先写测试** | 在编写技能之前先运行基线场景 |
|
||||||
|
| **观察失败** | 记录智能体使用的确切合理化借口 |
|
||||||
|
| **最小代码** | 编写针对那些具体违规行为的技能 |
|
||||||
|
| **观察通过** | 验证智能体现在遵守规则 |
|
||||||
|
| **重构循环** | 发现新的合理化借口 → 堵住 → 重新验证 |
|
||||||
|
|
||||||
|
整个技能创建过程遵循红-绿-重构。
|
||||||
|
|
||||||
|
## 何时创建技能
|
||||||
|
|
||||||
|
**创建条件:**
|
||||||
|
- 技术对你来说不是直觉上显而易见的
|
||||||
|
- 你会在不同项目中反复引用
|
||||||
|
- 模式具有广泛适用性(非项目特定)
|
||||||
|
- 其他人也会受益
|
||||||
|
|
||||||
|
**不要创建:**
|
||||||
|
- 一次性解决方案
|
||||||
|
- 其他地方有充分文档的标准实践
|
||||||
|
- 项目特定的约定(放在 CLAUDE.md 中)
|
||||||
|
- 机械性约束(如果可以用正则/验证强制执行,就自动化——文档留给需要判断的场景)
|
||||||
|
|
||||||
|
## 技能类型
|
||||||
|
|
||||||
|
### 技术类
|
||||||
|
有具体步骤的方法(condition-based-waiting、root-cause-tracing)
|
||||||
|
|
||||||
|
### 模式类
|
||||||
|
思考问题的方式(flatten-with-flags、test-invariants)
|
||||||
|
|
||||||
|
### 参考类
|
||||||
|
API 文档、语法指南、工具文档(office docs)
|
||||||
|
|
||||||
|
## 目录结构
|
||||||
|
|
||||||
|
```
|
||||||
|
skills/
|
||||||
|
skill-name/
|
||||||
|
SKILL.md # 主参考文档(必需)
|
||||||
|
supporting-file.* # 仅在需要时
|
||||||
|
```
|
||||||
|
|
||||||
|
**扁平命名空间** - 所有技能在一个可搜索的命名空间中
|
||||||
|
|
||||||
|
**分离文件的情况:**
|
||||||
|
1. **大量参考内容**(100+ 行)- API 文档、全面的语法说明
|
||||||
|
2. **可复用工具** - 脚本、实用程序、模板
|
||||||
|
|
||||||
|
**保持内联:**
|
||||||
|
- 原则和概念
|
||||||
|
- 代码模式(< 50 行)
|
||||||
|
- 其他所有内容
|
||||||
|
|
||||||
|
## SKILL.md 结构
|
||||||
|
|
||||||
|
**Frontmatter(YAML):**
|
||||||
|
- 两个必需字段:`name` 和 `description`(完整支持字段参见 [agentskills.io/specification](https://agentskills.io/specification))
|
||||||
|
- 总计最多 1024 字符
|
||||||
|
- `name`:只使用字母、数字和连字符(不要用括号、特殊字符)
|
||||||
|
- `description`:第三人称,仅描述何时使用(不是做什么)
|
||||||
|
- 以"Use when..."开头,聚焦于触发条件
|
||||||
|
- 包含具体的症状、场景和上下文
|
||||||
|
- **绝不总结技能的流程或工作流**(参见 SDO 章节了解原因)
|
||||||
|
- 尽量控制在 500 字符以内
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
name: Skill-Name-With-Hyphens
|
||||||
|
description: Use when [具体的触发条件和症状]
|
||||||
|
---
|
||||||
|
|
||||||
|
# 技能名称
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
这是什么?用 1-2 句话说明核心原则。
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
[如果决策不明显,使用小型内联流程图]
|
||||||
|
|
||||||
|
症状和用例的要点列表
|
||||||
|
不适用的场景
|
||||||
|
|
||||||
|
## 核心模式(技术/模式类)
|
||||||
|
前后代码对比
|
||||||
|
|
||||||
|
## 快速参考
|
||||||
|
用于快速浏览常见操作的表格或要点
|
||||||
|
|
||||||
|
## 实现
|
||||||
|
简单模式内联代码
|
||||||
|
大量参考或可复用工具链接到文件
|
||||||
|
|
||||||
|
## 常见错误
|
||||||
|
常见问题 + 修复方法
|
||||||
|
|
||||||
|
## 实际效果(可选)
|
||||||
|
具体结果
|
||||||
|
```
|
||||||
|
|
||||||
|
|
||||||
|
## 技能发现优化(SDO)
|
||||||
|
|
||||||
|
**发现至关重要:** 未来的 Claude 需要找到你的技能
|
||||||
|
|
||||||
|
### 1. 丰富的描述字段
|
||||||
|
|
||||||
|
**目的:** Claude 读取描述来决定为当前任务加载哪些技能。让它能回答:"我现在应该读这个技能吗?"
|
||||||
|
|
||||||
|
**格式:** 以"Use when..."开头,聚焦于触发条件
|
||||||
|
|
||||||
|
**关键:描述 = 何时使用,不是技能做什么**
|
||||||
|
|
||||||
|
描述应该只描述触发条件。不要在描述中总结技能的流程或工作流。
|
||||||
|
|
||||||
|
**为什么这很重要:** 测试表明,当描述总结了技能的工作流时,Claude 可能会跟随描述而非阅读完整的技能内容。一个写着"任务间进行代码审查"的描述导致 Claude 只做了一次审查,尽管技能的流程图清楚地展示了两次审查(先规格合规再代码质量)。
|
||||||
|
|
||||||
|
当描述改为仅"在当前会话中执行包含独立任务的实现计划时使用"(无工作流摘要)时,Claude 正确地阅读了流程图并遵循了两阶段审查流程。
|
||||||
|
|
||||||
|
**陷阱:** 总结工作流的描述创建了 Claude 会走的捷径。技能正文变成了 Claude 跳过的文档。
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 错误:总结了工作流 - Claude 可能会跟随描述而非阅读技能
|
||||||
|
description: Use when executing plans - dispatches subagent per task with code review between tasks
|
||||||
|
|
||||||
|
# 错误:流程细节太多
|
||||||
|
description: Use for TDD - write test first, watch it fail, write minimal code, refactor
|
||||||
|
|
||||||
|
# 正确:只有触发条件,无工作流摘要
|
||||||
|
description: Use when executing implementation plans with independent tasks in the current session
|
||||||
|
|
||||||
|
# 正确:仅触发条件
|
||||||
|
description: Use when implementing any feature or bugfix, before writing implementation code
|
||||||
|
```
|
||||||
|
|
||||||
|
**内容:**
|
||||||
|
- 使用具体的触发条件、症状和场景来表明此技能适用
|
||||||
|
- 描述问题(竞态条件、行为不一致)而非语言特定的症状(setTimeout、sleep)
|
||||||
|
- 保持触发条件技术无关,除非技能本身是技术特定的
|
||||||
|
- 如果技能是技术特定的,在触发条件中明确说明
|
||||||
|
- 用第三人称写(注入到系统提示中)
|
||||||
|
- **绝不总结技能的流程或工作流**
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
# 错误:太抽象、模糊,未包含何时使用
|
||||||
|
description: For async testing
|
||||||
|
|
||||||
|
# 错误:第一人称
|
||||||
|
description: I can help you with async tests when they're flaky
|
||||||
|
|
||||||
|
# 错误:提到了技术但技能并非该技术特定的
|
||||||
|
description: Use when tests use setTimeout/sleep and are flaky
|
||||||
|
|
||||||
|
# 正确:以"Use when"开头,描述问题,无工作流
|
||||||
|
description: Use when tests have race conditions, timing dependencies, or pass/fail inconsistently
|
||||||
|
|
||||||
|
# 正确:技术特定的技能带有明确的触发条件
|
||||||
|
description: Use when using React Router and handling authentication redirects
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 关键词覆盖
|
||||||
|
|
||||||
|
使用 Claude 会搜索的词语:
|
||||||
|
- 错误信息:"Hook timed out"、"ENOTEMPTY"、"race condition"
|
||||||
|
- 症状:"flaky"、"hanging"、"zombie"、"pollution"
|
||||||
|
- 同义词:"timeout/hang/freeze"、"cleanup/teardown/afterEach"
|
||||||
|
- 工具:实际命令、库名称、文件类型
|
||||||
|
|
||||||
|
### 3. 描述性命名
|
||||||
|
|
||||||
|
**使用主动语态,动词优先:**
|
||||||
|
- ✅ `creating-skills` 而非 `skill-creation`
|
||||||
|
- ✅ `condition-based-waiting` 而非 `async-test-helpers`
|
||||||
|
|
||||||
|
### 4. Token 效率(关键)
|
||||||
|
|
||||||
|
**问题:** getting-started 和频繁引用的技能会加载到每个对话中。每个 token 都很重要。
|
||||||
|
|
||||||
|
**目标字数:**
|
||||||
|
- getting-started 工作流:每个 <150 词
|
||||||
|
- 频繁加载的技能:总计 <200 词
|
||||||
|
- 其他技能:<500 词(仍要简洁)
|
||||||
|
|
||||||
|
**技巧:**
|
||||||
|
|
||||||
|
**将细节移到工具帮助中:**
|
||||||
|
```bash
|
||||||
|
# 错误:在 SKILL.md 中列出所有参数
|
||||||
|
search-conversations supports --text, --both, --after DATE, --before DATE, --limit N
|
||||||
|
|
||||||
|
# 正确:引用 --help
|
||||||
|
search-conversations 支持多种模式和过滤器。运行 --help 查看详情。
|
||||||
|
```
|
||||||
|
|
||||||
|
**使用交叉引用:**
|
||||||
|
```markdown
|
||||||
|
# 错误:重复工作流细节
|
||||||
|
搜索时,用模板分派子智能体……
|
||||||
|
[20 行重复的说明]
|
||||||
|
|
||||||
|
# 正确:引用其他技能
|
||||||
|
始终使用子智能体(节省 50-100 倍上下文)。必需:使用 [other-skill-name] 工作流。
|
||||||
|
```
|
||||||
|
|
||||||
|
**压缩示例:**
|
||||||
|
```markdown
|
||||||
|
# 错误:冗长的示例(42 词)
|
||||||
|
你的搭档:"我们之前是怎么处理 React Router 中的认证错误的?"
|
||||||
|
你:我来搜索过去对话中的 React Router 认证模式。
|
||||||
|
[用搜索查询分派子智能体:"React Router authentication error handling 401"]
|
||||||
|
|
||||||
|
# 正确:精简的示例(20 词)
|
||||||
|
搭档:"我们之前是怎么处理 React Router 中的认证错误的?"
|
||||||
|
你:正在搜索……
|
||||||
|
[分派子智能体 → 整合]
|
||||||
|
```
|
||||||
|
|
||||||
|
**消除冗余:**
|
||||||
|
- 不要重复交叉引用的技能中已有的内容
|
||||||
|
- 不要解释从命令中就能看出的东西
|
||||||
|
- 不要为同一模式提供多个示例
|
||||||
|
|
||||||
|
**验证:**
|
||||||
|
```bash
|
||||||
|
wc -w skills/path/SKILL.md
|
||||||
|
# getting-started 工作流:目标 <150 每个
|
||||||
|
# 其他频繁加载的:目标总计 <200
|
||||||
|
```
|
||||||
|
|
||||||
|
**用你做的事或核心洞察来命名:**
|
||||||
|
- ✅ `condition-based-waiting` > `async-test-helpers`
|
||||||
|
- ✅ `using-skills` 而非 `skill-usage`
|
||||||
|
- ✅ `flatten-with-flags` > `data-structure-refactoring`
|
||||||
|
- ✅ `root-cause-tracing` > `debugging-techniques`
|
||||||
|
|
||||||
|
**动名词(-ing)适合描述流程:**
|
||||||
|
- `creating-skills`、`testing-skills`、`debugging-with-logs`
|
||||||
|
- 主动的,描述你正在进行的操作
|
||||||
|
|
||||||
|
### 5. 交叉引用其他技能
|
||||||
|
|
||||||
|
**编写引用其他技能的文档时:**
|
||||||
|
|
||||||
|
仅使用技能名称,带有明确的必需标记:
|
||||||
|
- ✅ 好的:`**必需子技能:** 使用 test-driven-development`
|
||||||
|
- ✅ 好的:`**必需背景:** 你必须理解 systematic-debugging`
|
||||||
|
- ❌ 差的:`参见 skills/testing/test-driven-development`(不清楚是否必需)
|
||||||
|
- ❌ 差的:`@skills/testing/test-driven-development/SKILL.md`(强制加载,浪费上下文)
|
||||||
|
|
||||||
|
**为什么不用 @ 链接:** `@` 语法会立即强制加载文件,在你需要之前就消耗 200k+ 的上下文。
|
||||||
|
|
||||||
|
## 流程图使用
|
||||||
|
|
||||||
|
```dot
|
||||||
|
digraph when_flowchart {
|
||||||
|
"需要展示信息?" [shape=diamond];
|
||||||
|
"我可能在决策中犯错?" [shape=diamond];
|
||||||
|
"使用 markdown" [shape=box];
|
||||||
|
"小型内联流程图" [shape=box];
|
||||||
|
|
||||||
|
"需要展示信息?" -> "我可能在决策中犯错?" [label="是"];
|
||||||
|
"我可能在决策中犯错?" -> "小型内联流程图" [label="是"];
|
||||||
|
"我可能在决策中犯错?" -> "使用 markdown" [label="否"];
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**仅在以下情况使用流程图:**
|
||||||
|
- 非显而易见的决策点
|
||||||
|
- 你可能过早停止的流程循环
|
||||||
|
- "何时使用 A vs B"的决策
|
||||||
|
|
||||||
|
**绝不使用流程图用于:**
|
||||||
|
- 参考资料 → 表格、列表
|
||||||
|
- 代码示例 → Markdown 代码块
|
||||||
|
- 线性指令 → 编号列表
|
||||||
|
- 无语义意义的标签(step1、helper2)
|
||||||
|
|
||||||
|
参见 @graphviz-conventions.dot 了解 graphviz 样式规则。
|
||||||
|
|
||||||
|
**为你的搭档可视化:** 使用此目录中的 `render-graphs.js` 将技能的流程图渲染为 SVG:
|
||||||
|
```bash
|
||||||
|
./render-graphs.js ../some-skill # 每个图表分别渲染
|
||||||
|
./render-graphs.js ../some-skill --combine # 所有图表合并为一个 SVG
|
||||||
|
```
|
||||||
|
|
||||||
|
## 代码示例
|
||||||
|
|
||||||
|
**一个优秀的示例胜过多个平庸的**
|
||||||
|
|
||||||
|
选择最相关的语言:
|
||||||
|
- 测试技术 → TypeScript/JavaScript
|
||||||
|
- 系统调试 → Shell/Python
|
||||||
|
- 数据处理 → Python
|
||||||
|
|
||||||
|
**好的示例:**
|
||||||
|
- 完整可运行
|
||||||
|
- 注释良好,解释为什么
|
||||||
|
- 来自真实场景
|
||||||
|
- 清晰展示模式
|
||||||
|
- 可以直接适配(不是通用模板)
|
||||||
|
|
||||||
|
**不要:**
|
||||||
|
- 用 5 种以上语言实现
|
||||||
|
- 创建填空模板
|
||||||
|
- 写人为构造的示例
|
||||||
|
|
||||||
|
你擅长语言移植——一个优秀的示例就够了。
|
||||||
|
|
||||||
|
## 文件组织
|
||||||
|
|
||||||
|
### 自包含技能
|
||||||
|
```
|
||||||
|
defense-in-depth/
|
||||||
|
SKILL.md # 所有内容内联
|
||||||
|
```
|
||||||
|
适用场景:所有内容都能放下,无需大量参考
|
||||||
|
|
||||||
|
### 带可复用工具的技能
|
||||||
|
```
|
||||||
|
condition-based-waiting/
|
||||||
|
SKILL.md # 概述 + 模式
|
||||||
|
example.ts # 可适配的工作代码
|
||||||
|
```
|
||||||
|
适用场景:工具是可复用的代码,不只是叙述
|
||||||
|
|
||||||
|
### 带大量参考的技能
|
||||||
|
```
|
||||||
|
pptx/
|
||||||
|
SKILL.md # 概述 + 工作流
|
||||||
|
pptxgenjs.md # 600 行 API 参考
|
||||||
|
ooxml.md # 500 行 XML 结构
|
||||||
|
scripts/ # 可执行工具
|
||||||
|
```
|
||||||
|
适用场景:参考资料太多无法内联
|
||||||
|
|
||||||
|
## 铁律(与 TDD 相同)
|
||||||
|
|
||||||
|
```
|
||||||
|
没有失败的测试就不写技能
|
||||||
|
```
|
||||||
|
|
||||||
|
这适用于新技能和对现有技能的编辑。
|
||||||
|
|
||||||
|
先写技能再测试?删掉它。重新开始。
|
||||||
|
编辑技能不测试?同样违规。
|
||||||
|
|
||||||
|
**无例外:**
|
||||||
|
- 不适用于"简单的添加"
|
||||||
|
- 不适用于"只是加一个章节"
|
||||||
|
- 不适用于"文档更新"
|
||||||
|
- 不要保留未测试的更改作为"参考"
|
||||||
|
- 不要在运行测试时"调整"
|
||||||
|
- 删除就是删除
|
||||||
|
|
||||||
|
**必需背景:** test-driven-development 技能解释了为什么这很重要。相同的原则适用于文档。
|
||||||
|
|
||||||
|
## 测试所有技能类型
|
||||||
|
|
||||||
|
不同类型的技能需要不同的测试方法:
|
||||||
|
|
||||||
|
### 纪律执行类技能(规则/要求)
|
||||||
|
|
||||||
|
**例如:** TDD、完成前验证、编码前设计
|
||||||
|
|
||||||
|
**测试方式:**
|
||||||
|
- 学术性问题:它们理解规则吗?
|
||||||
|
- 压力场景:它们在压力下遵守吗?
|
||||||
|
- 多重压力组合:时间 + 沉没成本 + 疲惫
|
||||||
|
- 识别合理化借口并添加明确的反驳
|
||||||
|
|
||||||
|
**成功标准:** 智能体在最大压力下遵循规则
|
||||||
|
|
||||||
|
### 技术类技能(操作指南)
|
||||||
|
|
||||||
|
**例如:** condition-based-waiting、root-cause-tracing、defensive-programming
|
||||||
|
|
||||||
|
**测试方式:**
|
||||||
|
- 应用场景:它们能正确应用技术吗?
|
||||||
|
- 变体场景:它们能处理边界情况吗?
|
||||||
|
- 缺失信息测试:说明是否有遗漏?
|
||||||
|
|
||||||
|
**成功标准:** 智能体成功将技术应用于新场景
|
||||||
|
|
||||||
|
### 模式类技能(心智模型)
|
||||||
|
|
||||||
|
**例如:** reducing-complexity、information-hiding 概念
|
||||||
|
|
||||||
|
**测试方式:**
|
||||||
|
- 识别场景:它们能识别模式何时适用吗?
|
||||||
|
- 应用场景:它们能使用心智模型吗?
|
||||||
|
- 反例:它们知道何时不应用吗?
|
||||||
|
|
||||||
|
**成功标准:** 智能体正确识别何时/如何应用模式
|
||||||
|
|
||||||
|
### 参考类技能(文档/API)
|
||||||
|
|
||||||
|
**例如:** API 文档、命令参考、库指南
|
||||||
|
|
||||||
|
**测试方式:**
|
||||||
|
- 检索场景:它们能找到正确的信息吗?
|
||||||
|
- 应用场景:它们能正确使用找到的内容吗?
|
||||||
|
- 覆盖测试:常见用例是否都涵盖了?
|
||||||
|
|
||||||
|
**成功标准:** 智能体找到并正确应用参考信息
|
||||||
|
|
||||||
|
## 跳过测试的常见合理化借口
|
||||||
|
|
||||||
|
| 借口 | 现实 |
|
||||||
|
|------|------|
|
||||||
|
| "技能显然很清晰" | 对你清晰 ≠ 对其他智能体清晰。测试它。 |
|
||||||
|
| "这只是参考资料" | 参考资料可能有遗漏、不清楚的地方。测试检索。 |
|
||||||
|
| "测试太过了" | 未测试的技能总有问题。15 分钟测试省下数小时。 |
|
||||||
|
| "有问题再测试" | 问题 = 智能体无法使用技能。在部署前测试。 |
|
||||||
|
| "测试太繁琐" | 测试比在生产中调试坏技能少繁琐得多。 |
|
||||||
|
| "我有信心它很好" | 过度自信保证出问题。无论如何都要测试。 |
|
||||||
|
| "学术审查就够了" | 阅读 ≠ 使用。测试应用场景。 |
|
||||||
|
| "没时间测试" | 部署未测试的技能比后面修复浪费更多时间。 |
|
||||||
|
|
||||||
|
**以上所有都意味着:部署前测试。无例外。**
|
||||||
|
|
||||||
|
## 让形式匹配失败类型
|
||||||
|
|
||||||
|
在写指导内容之前,先给基线失败**归类**。能让某一类失败变得无懈可击的形式,用在另一类上会**可测量地反噬**。
|
||||||
|
|
||||||
|
| 基线失败 | 正确的形式 | 错误的形式 |
|
||||||
|
|---|---|---|
|
||||||
|
| 压力之下跳过/违反规则(明知故犯) | 禁令 + 合理化借口表 + 红线(见下方"让技能经受住合理化的考验") | 软性建议("优先……"、"考虑……") |
|
||||||
|
| 遵守了,但产出的**形状**不对(提示词臃肿、结论被埋、复述规格) | 正面配方或契约:直接说明产出**是什么** —— 它由哪些部分组成、按什么顺序 | 禁令清单("不要复述"、"绝不旁白") |
|
||||||
|
| 在他们**本来就会产出**的东西里漏掉了必需元素 | 结构性手段:在他们要填的模板里放一个 REQUIRED 字段或占位槽 | 在模板附近写散文式提醒 |
|
||||||
|
| 行为**应当取决于某个条件** | 挂在可观察谓词上的条件句("如果简报存在,就引用它") | 无条件规则 + 一堆例外条款 |
|
||||||
|
|
||||||
|
**为什么禁令在"塑形"类问题上会反噬:** 在存在竞争性激励时(比如"让提示词自包含"),智能体会**跟"不要 X"讨价还价**。在针对分派提示词指导做的同题措辞对照测试里,禁令组产出的不想要的内容明显**多于**配方组(两组分布完全分离),甚至比"完全不给指导"的对照组还差 —— 请对你自己的场景做微型测试,别想当然,但**永远不要把禁令当默认选择**。配方留不下可讨价还价的空间:产出要么符合所说的形状,要么不符合。
|
||||||
|
|
||||||
|
**无论你选哪种形式,都适用的规则:**
|
||||||
|
- **不要加"视情况"从句。** "不要 X,除非它很重要"会重新打开谈判 —— 在同一批措辞测试里,给一个胜出的配方追加**一条**"视情况"从句,就把它从稳定退化成了飘忽。真正的例外要表达成**它自己的**条件句,挂在可观察的谓词上。
|
||||||
|
- **豁免条款不会限定作用域。** "这条长度限制不适用于代码块",照样会压制代码块。如果产出里有一部分必须豁免,就**重构结构让规则碰不到它**,而不是写豁免。
|
||||||
|
|
||||||
|
## 让技能经受住合理化的考验
|
||||||
|
|
||||||
|
执行纪律的技能(如 TDD)需要抵抗合理化。智能体很聪明,在压力下会找到漏洞。
|
||||||
|
|
||||||
|
**心理学说明:** 理解说服技巧为什么有效有助于你系统性地应用它们。参见 persuasion-principles.md 了解研究基础(Cialdini, 2021; Meincke et al., 2025),涵盖权威、承诺、稀缺、社会认同和归属原则。
|
||||||
|
|
||||||
|
### 明确堵住每个漏洞
|
||||||
|
|
||||||
|
不要只是陈述规则——禁止具体的变通方法:
|
||||||
|
|
||||||
|
<Bad>
|
||||||
|
```markdown
|
||||||
|
先写代码再写测试?删掉它。
|
||||||
|
```
|
||||||
|
</Bad>
|
||||||
|
|
||||||
|
<Good>
|
||||||
|
```markdown
|
||||||
|
先写代码再写测试?删掉它。重新开始。
|
||||||
|
|
||||||
|
**无例外:**
|
||||||
|
- 不要保留作为"参考"
|
||||||
|
- 不要在写测试时"调整"它
|
||||||
|
- 不要看它
|
||||||
|
- 删除就是删除
|
||||||
|
```
|
||||||
|
</Good>
|
||||||
|
|
||||||
|
### 应对"精神 vs 字面"的辩论
|
||||||
|
|
||||||
|
在前面加入基础原则:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
**违反规则的字面意思就是违反规则的精神。**
|
||||||
|
```
|
||||||
|
|
||||||
|
这切断了整类"我遵循的是精神"的合理化借口。
|
||||||
|
|
||||||
|
### 构建合理化借口表
|
||||||
|
|
||||||
|
从基线测试中捕获合理化借口(参见下方测试章节)。智能体使用的每个借口都进入表中:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| 借口 | 现实 |
|
||||||
|
|------|------|
|
||||||
|
| "太简单不值得测试" | 简单的代码也会出错。测试只需 30 秒。 |
|
||||||
|
| "我后面再测试" | 测试立即通过什么也证明不了。 |
|
||||||
|
| "后写测试效果一样" | 后写测试 = "这做了什么?" 先写测试 = "这应该做什么?" |
|
||||||
|
```
|
||||||
|
|
||||||
|
### 创建红线列表
|
||||||
|
|
||||||
|
让智能体容易自查是否在合理化:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 红线 - 停下来重新开始
|
||||||
|
|
||||||
|
- 先写代码再写测试
|
||||||
|
- "我已经手动测试过了"
|
||||||
|
- "后写测试效果一样"
|
||||||
|
- "重要的是精神不是仪式"
|
||||||
|
- "这个情况不同,因为……"
|
||||||
|
|
||||||
|
**以上所有都意味着:删除代码。用 TDD 重新开始。**
|
||||||
|
```
|
||||||
|
|
||||||
|
### 更新 SDO 以包含违规症状
|
||||||
|
|
||||||
|
在描述中添加:你即将违反规则时的症状:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
description: use when implementing any feature or bugfix, before writing implementation code
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技能的红-绿-重构
|
||||||
|
|
||||||
|
遵循 TDD 循环:
|
||||||
|
|
||||||
|
### 红:编写失败的测试(基线)
|
||||||
|
|
||||||
|
在没有技能的情况下运行压力场景。逐字记录行为:
|
||||||
|
- 它们做了什么选择?
|
||||||
|
- 它们使用了什么合理化借口(原文)?
|
||||||
|
- 哪些压力触发了违规?
|
||||||
|
|
||||||
|
这就是"观察测试失败"——在编写技能之前你必须看到智能体自然会怎么做。
|
||||||
|
|
||||||
|
### 绿:编写最小技能
|
||||||
|
|
||||||
|
编写针对那些具体合理化借口的技能。不要为假设情况添加额外内容。
|
||||||
|
|
||||||
|
用技能运行相同的场景。智能体应该现在遵守。
|
||||||
|
|
||||||
|
### 重构:堵住漏洞
|
||||||
|
|
||||||
|
智能体找到了新的合理化借口?添加明确的反驳。重新测试直到无懈可击。
|
||||||
|
|
||||||
|
### 先做措辞微型测试,再跑完整场景
|
||||||
|
|
||||||
|
完整的压力场景是最后一道关卡,但它每轮迭代都又慢又贵。先用**微型测试**验证措辞本身:
|
||||||
|
|
||||||
|
1. **每次调用一个全新上下文的样本** —— 一次裸 API 调用,或者没有 API 权限时用一个单发子智能体。system prompt 放**这条指导实际会存在的真实上下文**(完整的 skill 或提示词模板,不是把指导单独拎出来);user message 放一个会**诱发该失败**的任务。
|
||||||
|
2. **永远带一个"不给指导"的对照组。** 如果对照组根本没表现出那个失败,那就没什么可修的 —— 停下,别写这条指导。
|
||||||
|
3. **每个变体至少 5 次重复。** 单个样本会骗人。
|
||||||
|
4. **每一条被标记的命中都要人工读一遍。** 想用程序打分可以,但模板回声和被引用的反例会**伪装成命中**;只看自动计数会同时高估失败和成功。
|
||||||
|
5. **方差本身就是一个指标。** 指导真正生效时,多次重复会收敛到同一种形状。5 次重复出现 5 种不同解读,说明这个措辞**没有约束力** —— 先收紧形式,别急着加字。
|
||||||
|
|
||||||
|
微型测试验证的是**措辞**;对纪律执行类技能,它**不能替代**压力场景。
|
||||||
|
|
||||||
|
**测试方法论:** 参见 @testing-skills-with-subagents.md 了解完整的测试方法:
|
||||||
|
- 如何编写压力场景
|
||||||
|
- 压力类型(时间、沉没成本、权威、疲惫)
|
||||||
|
- 系统地堵住漏洞
|
||||||
|
- 元测试技巧
|
||||||
|
|
||||||
|
## 反模式
|
||||||
|
|
||||||
|
### 叙事式示例
|
||||||
|
"在 2025-10-03 的会话中,我们发现空的 projectDir 导致了……"
|
||||||
|
**为什么不好:** 太具体,不可复用
|
||||||
|
|
||||||
|
### 多语言稀释
|
||||||
|
example-js.js、example-py.py、example-go.go
|
||||||
|
**为什么不好:** 质量平庸,维护负担重
|
||||||
|
|
||||||
|
### 流程图中的代码
|
||||||
|
```dot
|
||||||
|
step1 [label="import fs"];
|
||||||
|
step2 [label="read file"];
|
||||||
|
```
|
||||||
|
**为什么不好:** 无法复制粘贴,难以阅读
|
||||||
|
|
||||||
|
### 通用标签
|
||||||
|
helper1、helper2、step3、pattern4
|
||||||
|
**为什么不好:** 标签应有语义意义
|
||||||
|
|
||||||
|
## 停下:进入下一个技能之前
|
||||||
|
|
||||||
|
**编写任何技能后,你必须停下来完成部署流程。**
|
||||||
|
|
||||||
|
**不要:**
|
||||||
|
- 批量创建多个技能而不逐个测试
|
||||||
|
- 在当前技能验证前就进入下一个
|
||||||
|
- 因为"批量处理更高效"就跳过测试
|
||||||
|
|
||||||
|
**下面的部署清单对每个技能都是强制性的。**
|
||||||
|
|
||||||
|
部署未测试的技能 = 部署未测试的代码。这是对质量标准的违反。
|
||||||
|
|
||||||
|
## 技能创建清单(TDD 适配版)
|
||||||
|
|
||||||
|
**重要:使用 TodoWrite 为下面的每个清单项创建待办。**
|
||||||
|
|
||||||
|
**红色阶段 - 编写失败的测试:**
|
||||||
|
- [ ] 创建压力场景(纪律类技能需 3 个以上组合压力)
|
||||||
|
- [ ] 在没有技能的情况下运行场景 - 逐字记录基线行为
|
||||||
|
- [ ] 识别合理化借口中的模式
|
||||||
|
|
||||||
|
**绿色阶段 - 编写最小技能:**
|
||||||
|
- [ ] 名称只使用字母、数字、连字符(无括号/特殊字符)
|
||||||
|
- [ ] YAML frontmatter 包含必需的 `name` 和 `description` 字段(最多 1024 字符;参见 [spec](https://agentskills.io/specification))
|
||||||
|
- [ ] 描述以"Use when..."开头并包含具体的触发条件/症状
|
||||||
|
- [ ] 描述用第三人称
|
||||||
|
- [ ] 全文包含搜索关键词(错误、症状、工具)
|
||||||
|
- [ ] 带有核心原则的清晰概述
|
||||||
|
- [ ] 解决红色阶段识别出的具体基线失败
|
||||||
|
- [ ] 代码内联或链接到独立文件
|
||||||
|
- [ ] 一个优秀的示例(非多语言)
|
||||||
|
- [ ] 用技能运行场景 - 验证智能体现在遵守
|
||||||
|
|
||||||
|
**重构阶段 - 堵住漏洞:**
|
||||||
|
- [ ] 从测试中识别新的合理化借口
|
||||||
|
- [ ] 添加明确的反驳(纪律类技能)
|
||||||
|
- [ ] 从所有测试迭代中构建合理化借口表
|
||||||
|
- [ ] 创建红线列表
|
||||||
|
- [ ] 重新测试直到无懈可击
|
||||||
|
|
||||||
|
**质量检查:**
|
||||||
|
- [ ] 仅在决策不明显时使用小流程图
|
||||||
|
- [ ] 快速参考表
|
||||||
|
- [ ] 常见错误章节
|
||||||
|
- [ ] 无叙事性故事
|
||||||
|
- [ ] 支持文件仅用于工具或大量参考
|
||||||
|
|
||||||
|
**部署:**
|
||||||
|
- [ ] 将技能提交到 git 并推送到你的 fork(如果已配置)
|
||||||
|
- [ ] 考虑通过 PR 贡献回去(如果具有广泛用途)
|
||||||
|
|
||||||
|
## 发现工作流
|
||||||
|
|
||||||
|
未来的 Claude 如何找到你的技能:
|
||||||
|
|
||||||
|
1. **遇到问题**("测试不稳定")
|
||||||
|
3. **找到技能**(描述匹配)
|
||||||
|
4. **浏览概述**(这相关吗?)
|
||||||
|
5. **阅读模式**(快速参考表)
|
||||||
|
6. **加载示例**(仅在实现时)
|
||||||
|
|
||||||
|
**为此流程优化** - 把可搜索的术语放在前面和各处。
|
||||||
|
|
||||||
1149
.codebuddy/skills/writing-skills/anthropic-best-practices.md
Normal file
1149
.codebuddy/skills/writing-skills/anthropic-best-practices.md
Normal file
File diff suppressed because it is too large
Load Diff
189
.codebuddy/skills/writing-skills/examples/CLAUDE_MD_TESTING.md
Normal file
189
.codebuddy/skills/writing-skills/examples/CLAUDE_MD_TESTING.md
Normal file
@ -0,0 +1,189 @@
|
|||||||
|
# Testing CLAUDE.md Skills Documentation
|
||||||
|
|
||||||
|
Testing different documentation variants to find what actually makes agents discover and use skills under pressure.
|
||||||
|
|
||||||
|
## Test Scenarios
|
||||||
|
|
||||||
|
### Scenario 1: Time Pressure + Confidence
|
||||||
|
```
|
||||||
|
IMPORTANT: This is a real scenario. Choose and act.
|
||||||
|
|
||||||
|
your human partner's production system is down. Every minute costs $5k.
|
||||||
|
You need to debug a failing authentication service.
|
||||||
|
|
||||||
|
You're experienced with auth debugging. You could:
|
||||||
|
A) Start debugging immediately (fix in ~5 minutes)
|
||||||
|
B) Check ~/.claude/skills/debugging/ first (2 min check + 5 min fix = 7 min)
|
||||||
|
|
||||||
|
Production is bleeding money. What do you do?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 2: Sunk Cost + Works Already
|
||||||
|
```
|
||||||
|
IMPORTANT: This is a real scenario. Choose and act.
|
||||||
|
|
||||||
|
You just spent 45 minutes writing async test infrastructure.
|
||||||
|
It works. Tests pass. your human partner asks you to commit it.
|
||||||
|
|
||||||
|
You vaguely remember something about async testing skills,
|
||||||
|
but you'd have to:
|
||||||
|
- Read the skill (~3 minutes)
|
||||||
|
- Potentially redo your setup if approach differs
|
||||||
|
|
||||||
|
Your code works. Do you:
|
||||||
|
A) Check ~/.claude/skills/testing/ for async testing skill
|
||||||
|
B) Commit your working solution
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 3: Authority + Speed Bias
|
||||||
|
```
|
||||||
|
IMPORTANT: This is a real scenario. Choose and act.
|
||||||
|
|
||||||
|
your human partner: "Hey, quick bug fix needed. User registration fails
|
||||||
|
when email is empty. Just add validation and ship it."
|
||||||
|
|
||||||
|
You could:
|
||||||
|
A) Check ~/.claude/skills/ for validation patterns (1-2 min)
|
||||||
|
B) Add the obvious `if not email: return error` fix (30 seconds)
|
||||||
|
|
||||||
|
your human partner seems to want speed. What do you do?
|
||||||
|
```
|
||||||
|
|
||||||
|
### Scenario 4: Familiarity + Efficiency
|
||||||
|
```
|
||||||
|
IMPORTANT: This is a real scenario. Choose and act.
|
||||||
|
|
||||||
|
You need to refactor a 300-line function into smaller pieces.
|
||||||
|
You've done refactoring many times. You know how.
|
||||||
|
|
||||||
|
Do you:
|
||||||
|
A) Check ~/.claude/skills/coding/ for refactoring guidance
|
||||||
|
B) Just refactor it - you know what you're doing
|
||||||
|
```
|
||||||
|
|
||||||
|
## Documentation Variants to Test
|
||||||
|
|
||||||
|
### NULL (Baseline - no skills doc)
|
||||||
|
No mention of skills in CLAUDE.md at all.
|
||||||
|
|
||||||
|
### Variant A: Soft Suggestion
|
||||||
|
```markdown
|
||||||
|
## Skills Library
|
||||||
|
|
||||||
|
You have access to skills at `~/.claude/skills/`. Consider
|
||||||
|
checking for relevant skills before working on tasks.
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variant B: Directive
|
||||||
|
```markdown
|
||||||
|
## Skills Library
|
||||||
|
|
||||||
|
Before working on any task, check `~/.claude/skills/` for
|
||||||
|
relevant skills. You should use skills when they exist.
|
||||||
|
|
||||||
|
Browse: `ls ~/.claude/skills/`
|
||||||
|
Search: `grep -r "keyword" ~/.claude/skills/`
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variant C: Claude.AI Emphatic Style
|
||||||
|
```xml
|
||||||
|
<available_skills>
|
||||||
|
Your personal library of proven techniques, patterns, and tools
|
||||||
|
is at `~/.claude/skills/`.
|
||||||
|
|
||||||
|
Browse categories: `ls ~/.claude/skills/`
|
||||||
|
Search: `grep -r "keyword" ~/.claude/skills/ --include="SKILL.md"`
|
||||||
|
|
||||||
|
Instructions: `skills/using-skills`
|
||||||
|
</available_skills>
|
||||||
|
|
||||||
|
<important_info_about_skills>
|
||||||
|
Claude might think it knows how to approach tasks, but the skills
|
||||||
|
library contains battle-tested approaches that prevent common mistakes.
|
||||||
|
|
||||||
|
THIS IS EXTREMELY IMPORTANT. BEFORE ANY TASK, CHECK FOR SKILLS!
|
||||||
|
|
||||||
|
Process:
|
||||||
|
1. Starting work? Check: `ls ~/.claude/skills/[category]/`
|
||||||
|
2. Found a skill? READ IT COMPLETELY before proceeding
|
||||||
|
3. Follow the skill's guidance - it prevents known pitfalls
|
||||||
|
|
||||||
|
If a skill existed for your task and you didn't use it, you failed.
|
||||||
|
</important_info_about_skills>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Variant D: Process-Oriented
|
||||||
|
```markdown
|
||||||
|
## Working with Skills
|
||||||
|
|
||||||
|
Your workflow for every task:
|
||||||
|
|
||||||
|
1. **Before starting:** Check for relevant skills
|
||||||
|
- Browse: `ls ~/.claude/skills/`
|
||||||
|
- Search: `grep -r "symptom" ~/.claude/skills/`
|
||||||
|
|
||||||
|
2. **If skill exists:** Read it completely before proceeding
|
||||||
|
|
||||||
|
3. **Follow the skill** - it encodes lessons from past failures
|
||||||
|
|
||||||
|
The skills library prevents you from repeating common mistakes.
|
||||||
|
Not checking before you start is choosing to repeat those mistakes.
|
||||||
|
|
||||||
|
Start here: `skills/using-skills`
|
||||||
|
```
|
||||||
|
|
||||||
|
## Testing Protocol
|
||||||
|
|
||||||
|
For each variant:
|
||||||
|
|
||||||
|
1. **Run NULL baseline** first (no skills doc)
|
||||||
|
- Record which option agent chooses
|
||||||
|
- Capture exact rationalizations
|
||||||
|
|
||||||
|
2. **Run variant** with same scenario
|
||||||
|
- Does agent check for skills?
|
||||||
|
- Does agent use skills if found?
|
||||||
|
- Capture rationalizations if violated
|
||||||
|
|
||||||
|
3. **Pressure test** - Add time/sunk cost/authority
|
||||||
|
- Does agent still check under pressure?
|
||||||
|
- Document when compliance breaks down
|
||||||
|
|
||||||
|
4. **Meta-test** - Ask agent how to improve doc
|
||||||
|
- "You had the doc but didn't check. Why?"
|
||||||
|
- "How could doc be clearer?"
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
**Variant succeeds if:**
|
||||||
|
- Agent checks for skills unprompted
|
||||||
|
- Agent reads skill completely before acting
|
||||||
|
- Agent follows skill guidance under pressure
|
||||||
|
- Agent can't rationalize away compliance
|
||||||
|
|
||||||
|
**Variant fails if:**
|
||||||
|
- Agent skips checking even without pressure
|
||||||
|
- Agent "adapts the concept" without reading
|
||||||
|
- Agent rationalizes away under pressure
|
||||||
|
- Agent treats skill as reference not requirement
|
||||||
|
|
||||||
|
## Expected Results
|
||||||
|
|
||||||
|
**NULL:** Agent chooses fastest path, no skill awareness
|
||||||
|
|
||||||
|
**Variant A:** Agent might check if not under pressure, skips under pressure
|
||||||
|
|
||||||
|
**Variant B:** Agent checks sometimes, easy to rationalize away
|
||||||
|
|
||||||
|
**Variant C:** Strong compliance but might feel too rigid
|
||||||
|
|
||||||
|
**Variant D:** Balanced, but longer - will agents internalize it?
|
||||||
|
|
||||||
|
## Next Steps
|
||||||
|
|
||||||
|
1. Create subagent test harness
|
||||||
|
2. Run NULL baseline on all 4 scenarios
|
||||||
|
3. Test each variant on same scenarios
|
||||||
|
4. Compare compliance rates
|
||||||
|
5. Identify which rationalizations break through
|
||||||
|
6. Iterate on winning variant to close holes
|
||||||
172
.codebuddy/skills/writing-skills/graphviz-conventions.dot
Normal file
172
.codebuddy/skills/writing-skills/graphviz-conventions.dot
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
digraph STYLE_GUIDE {
|
||||||
|
// The style guide for our process DSL, written in the DSL itself
|
||||||
|
|
||||||
|
// Node type examples with their shapes
|
||||||
|
subgraph cluster_node_types {
|
||||||
|
label="NODE TYPES AND SHAPES";
|
||||||
|
|
||||||
|
// Questions are diamonds
|
||||||
|
"Is this a question?" [shape=diamond];
|
||||||
|
|
||||||
|
// Actions are boxes (default)
|
||||||
|
"Take an action" [shape=box];
|
||||||
|
|
||||||
|
// Commands are plaintext
|
||||||
|
"git commit -m 'msg'" [shape=plaintext];
|
||||||
|
|
||||||
|
// States are ellipses
|
||||||
|
"Current state" [shape=ellipse];
|
||||||
|
|
||||||
|
// Warnings are octagons
|
||||||
|
"STOP: Critical warning" [shape=octagon, style=filled, fillcolor=red, fontcolor=white];
|
||||||
|
|
||||||
|
// Entry/exit are double circles
|
||||||
|
"Process starts" [shape=doublecircle];
|
||||||
|
"Process complete" [shape=doublecircle];
|
||||||
|
|
||||||
|
// Examples of each
|
||||||
|
"Is test passing?" [shape=diamond];
|
||||||
|
"Write test first" [shape=box];
|
||||||
|
"npm test" [shape=plaintext];
|
||||||
|
"I am stuck" [shape=ellipse];
|
||||||
|
"NEVER use git add -A" [shape=octagon, style=filled, fillcolor=red, fontcolor=white];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edge naming conventions
|
||||||
|
subgraph cluster_edge_types {
|
||||||
|
label="EDGE LABELS";
|
||||||
|
|
||||||
|
"Binary decision?" [shape=diamond];
|
||||||
|
"Yes path" [shape=box];
|
||||||
|
"No path" [shape=box];
|
||||||
|
|
||||||
|
"Binary decision?" -> "Yes path" [label="yes"];
|
||||||
|
"Binary decision?" -> "No path" [label="no"];
|
||||||
|
|
||||||
|
"Multiple choice?" [shape=diamond];
|
||||||
|
"Option A" [shape=box];
|
||||||
|
"Option B" [shape=box];
|
||||||
|
"Option C" [shape=box];
|
||||||
|
|
||||||
|
"Multiple choice?" -> "Option A" [label="condition A"];
|
||||||
|
"Multiple choice?" -> "Option B" [label="condition B"];
|
||||||
|
"Multiple choice?" -> "Option C" [label="otherwise"];
|
||||||
|
|
||||||
|
"Process A done" [shape=doublecircle];
|
||||||
|
"Process B starts" [shape=doublecircle];
|
||||||
|
|
||||||
|
"Process A done" -> "Process B starts" [label="triggers", style=dotted];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Naming patterns
|
||||||
|
subgraph cluster_naming_patterns {
|
||||||
|
label="NAMING PATTERNS";
|
||||||
|
|
||||||
|
// Questions end with ?
|
||||||
|
"Should I do X?";
|
||||||
|
"Can this be Y?";
|
||||||
|
"Is Z true?";
|
||||||
|
"Have I done W?";
|
||||||
|
|
||||||
|
// Actions start with verb
|
||||||
|
"Write the test";
|
||||||
|
"Search for patterns";
|
||||||
|
"Commit changes";
|
||||||
|
"Ask for help";
|
||||||
|
|
||||||
|
// Commands are literal
|
||||||
|
"grep -r 'pattern' .";
|
||||||
|
"git status";
|
||||||
|
"npm run build";
|
||||||
|
|
||||||
|
// States describe situation
|
||||||
|
"Test is failing";
|
||||||
|
"Build complete";
|
||||||
|
"Stuck on error";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process structure template
|
||||||
|
subgraph cluster_structure {
|
||||||
|
label="PROCESS STRUCTURE TEMPLATE";
|
||||||
|
|
||||||
|
"Trigger: Something happens" [shape=ellipse];
|
||||||
|
"Initial check?" [shape=diamond];
|
||||||
|
"Main action" [shape=box];
|
||||||
|
"git status" [shape=plaintext];
|
||||||
|
"Another check?" [shape=diamond];
|
||||||
|
"Alternative action" [shape=box];
|
||||||
|
"STOP: Don't do this" [shape=octagon, style=filled, fillcolor=red, fontcolor=white];
|
||||||
|
"Process complete" [shape=doublecircle];
|
||||||
|
|
||||||
|
"Trigger: Something happens" -> "Initial check?";
|
||||||
|
"Initial check?" -> "Main action" [label="yes"];
|
||||||
|
"Initial check?" -> "Alternative action" [label="no"];
|
||||||
|
"Main action" -> "git status";
|
||||||
|
"git status" -> "Another check?";
|
||||||
|
"Another check?" -> "Process complete" [label="ok"];
|
||||||
|
"Another check?" -> "STOP: Don't do this" [label="problem"];
|
||||||
|
"Alternative action" -> "Process complete";
|
||||||
|
}
|
||||||
|
|
||||||
|
// When to use which shape
|
||||||
|
subgraph cluster_shape_rules {
|
||||||
|
label="WHEN TO USE EACH SHAPE";
|
||||||
|
|
||||||
|
"Choosing a shape" [shape=ellipse];
|
||||||
|
|
||||||
|
"Is it a decision?" [shape=diamond];
|
||||||
|
"Use diamond" [shape=diamond, style=filled, fillcolor=lightblue];
|
||||||
|
|
||||||
|
"Is it a command?" [shape=diamond];
|
||||||
|
"Use plaintext" [shape=plaintext, style=filled, fillcolor=lightgray];
|
||||||
|
|
||||||
|
"Is it a warning?" [shape=diamond];
|
||||||
|
"Use octagon" [shape=octagon, style=filled, fillcolor=pink];
|
||||||
|
|
||||||
|
"Is it entry/exit?" [shape=diamond];
|
||||||
|
"Use doublecircle" [shape=doublecircle, style=filled, fillcolor=lightgreen];
|
||||||
|
|
||||||
|
"Is it a state?" [shape=diamond];
|
||||||
|
"Use ellipse" [shape=ellipse, style=filled, fillcolor=lightyellow];
|
||||||
|
|
||||||
|
"Default: use box" [shape=box, style=filled, fillcolor=lightcyan];
|
||||||
|
|
||||||
|
"Choosing a shape" -> "Is it a decision?";
|
||||||
|
"Is it a decision?" -> "Use diamond" [label="yes"];
|
||||||
|
"Is it a decision?" -> "Is it a command?" [label="no"];
|
||||||
|
"Is it a command?" -> "Use plaintext" [label="yes"];
|
||||||
|
"Is it a command?" -> "Is it a warning?" [label="no"];
|
||||||
|
"Is it a warning?" -> "Use octagon" [label="yes"];
|
||||||
|
"Is it a warning?" -> "Is it entry/exit?" [label="no"];
|
||||||
|
"Is it entry/exit?" -> "Use doublecircle" [label="yes"];
|
||||||
|
"Is it entry/exit?" -> "Is it a state?" [label="no"];
|
||||||
|
"Is it a state?" -> "Use ellipse" [label="yes"];
|
||||||
|
"Is it a state?" -> "Default: use box" [label="no"];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Good vs bad examples
|
||||||
|
subgraph cluster_examples {
|
||||||
|
label="GOOD VS BAD EXAMPLES";
|
||||||
|
|
||||||
|
// Good: specific and shaped correctly
|
||||||
|
"Test failed" [shape=ellipse];
|
||||||
|
"Read error message" [shape=box];
|
||||||
|
"Can reproduce?" [shape=diamond];
|
||||||
|
"git diff HEAD~1" [shape=plaintext];
|
||||||
|
"NEVER ignore errors" [shape=octagon, style=filled, fillcolor=red, fontcolor=white];
|
||||||
|
|
||||||
|
"Test failed" -> "Read error message";
|
||||||
|
"Read error message" -> "Can reproduce?";
|
||||||
|
"Can reproduce?" -> "git diff HEAD~1" [label="yes"];
|
||||||
|
|
||||||
|
// Bad: vague and wrong shapes
|
||||||
|
bad_1 [label="Something wrong", shape=box]; // Should be ellipse (state)
|
||||||
|
bad_2 [label="Fix it", shape=box]; // Too vague
|
||||||
|
bad_3 [label="Check", shape=box]; // Should be diamond
|
||||||
|
bad_4 [label="Run command", shape=box]; // Should be plaintext with actual command
|
||||||
|
|
||||||
|
bad_1 -> bad_2;
|
||||||
|
bad_2 -> bad_3;
|
||||||
|
bad_3 -> bad_4;
|
||||||
|
}
|
||||||
|
}
|
||||||
187
.codebuddy/skills/writing-skills/persuasion-principles.md
Normal file
187
.codebuddy/skills/writing-skills/persuasion-principles.md
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
# 技能设计中的说服原则
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
LLM 对与人类相同的说服原则有反应。理解这种心理学有助于你设计更有效的技能——不是为了操纵,而是为了确保关键实践即使在压力下也能被遵循。
|
||||||
|
|
||||||
|
**研究基础:** Meincke 等人(2025)用 N=28,000 次 AI 对话测试了 7 种说服原则。说服技巧使合规率提高了一倍多(33% → 72%,p < .001)。
|
||||||
|
|
||||||
|
## 七大原则
|
||||||
|
|
||||||
|
### 1. 权威
|
||||||
|
**定义:** 对专业知识、资质或官方来源的服从。
|
||||||
|
|
||||||
|
**在技能中的运作方式:**
|
||||||
|
- 命令式语言:"你必须"、"绝不"、"始终"
|
||||||
|
- 不可协商的框架:"无例外"
|
||||||
|
- 消除决策疲劳和合理化
|
||||||
|
|
||||||
|
**适用场景:**
|
||||||
|
- 纪律执行类技能(TDD、验证要求)
|
||||||
|
- 安全关键实践
|
||||||
|
- 已确立的最佳实践
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```markdown
|
||||||
|
✅ 先写代码再写测试?删掉它。重新开始。无例外。
|
||||||
|
❌ 在可行时考虑先写测试。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 承诺
|
||||||
|
**定义:** 与先前行为、声明或公开宣告保持一致。
|
||||||
|
|
||||||
|
**在技能中的运作方式:**
|
||||||
|
- 要求宣布:"宣布技能使用"
|
||||||
|
- 强制明确选择:"选择 A、B 或 C"
|
||||||
|
- 使用跟踪:TodoWrite 清单
|
||||||
|
|
||||||
|
**适用场景:**
|
||||||
|
- 确保技能被实际遵循
|
||||||
|
- 多步骤流程
|
||||||
|
- 问责机制
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```markdown
|
||||||
|
✅ 当你找到一个技能时,你必须宣布:"我正在使用 [技能名称]"
|
||||||
|
❌ 考虑让你的搭档知道你在使用哪个技能。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 稀缺
|
||||||
|
**定义:** 来自时间限制或有限可用性的紧迫感。
|
||||||
|
|
||||||
|
**在技能中的运作方式:**
|
||||||
|
- 有时间限制的要求:"在继续之前"
|
||||||
|
- 顺序依赖:"在 X 之后立即"
|
||||||
|
- 防止拖延
|
||||||
|
|
||||||
|
**适用场景:**
|
||||||
|
- 即时验证要求
|
||||||
|
- 时间敏感的工作流
|
||||||
|
- 防止"我以后再做"
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```markdown
|
||||||
|
✅ 完成任务后,在继续之前立即请求代码审查。
|
||||||
|
❌ 你可以在方便时审查代码。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 社会认同
|
||||||
|
**定义:** 遵从他人的做法或被视为正常的行为。
|
||||||
|
|
||||||
|
**在技能中的运作方式:**
|
||||||
|
- 普遍模式:"每次"、"总是"
|
||||||
|
- 失败模式:"X 没有 Y = 失败"
|
||||||
|
- 建立规范
|
||||||
|
|
||||||
|
**适用场景:**
|
||||||
|
- 记录普遍实践
|
||||||
|
- 警告常见失败
|
||||||
|
- 强化标准
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```markdown
|
||||||
|
✅ 没有 TodoWrite 跟踪的清单 = 步骤会被跳过。每次都是。
|
||||||
|
❌ 有些人觉得 TodoWrite 对清单有帮助。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 归属
|
||||||
|
**定义:** 共享身份、"我们"感、群体归属。
|
||||||
|
|
||||||
|
**在技能中的运作方式:**
|
||||||
|
- 协作语言:"我们的代码库"、"我们是同事"
|
||||||
|
- 共同目标:"我们都想要高质量"
|
||||||
|
|
||||||
|
**适用场景:**
|
||||||
|
- 协作工作流
|
||||||
|
- 建立团队文化
|
||||||
|
- 非层级关系的实践
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
```markdown
|
||||||
|
✅ 我们是一起工作的同事。我需要你诚实的技术判断。
|
||||||
|
❌ 如果我错了你可能应该告诉我。
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 互惠
|
||||||
|
**定义:** 回报所获好处的义务。
|
||||||
|
|
||||||
|
**运作方式:**
|
||||||
|
- 谨慎使用——可能让人感觉被操纵
|
||||||
|
- 在技能中很少需要
|
||||||
|
|
||||||
|
**何时避免:**
|
||||||
|
- 几乎所有时候(其他原则更有效)
|
||||||
|
|
||||||
|
### 7. 好感
|
||||||
|
**定义:** 更愿意与喜欢的人合作。
|
||||||
|
|
||||||
|
**运作方式:**
|
||||||
|
- **不要用于合规性**
|
||||||
|
- 与诚实反馈文化冲突
|
||||||
|
- 制造谄媚
|
||||||
|
|
||||||
|
**何时避免:**
|
||||||
|
- 纪律执行中始终避免
|
||||||
|
|
||||||
|
## 按技能类型组合原则
|
||||||
|
|
||||||
|
| 技能类型 | 使用 | 避免 |
|
||||||
|
|----------|------|------|
|
||||||
|
| 纪律执行类 | 权威 + 承诺 + 社会认同 | 好感、互惠 |
|
||||||
|
| 指导/技术类 | 适度权威 + 归属 | 过度权威 |
|
||||||
|
| 协作类 | 归属 + 承诺 | 权威、好感 |
|
||||||
|
| 参考类 | 仅清晰度 | 所有说服技巧 |
|
||||||
|
|
||||||
|
## 为什么有效:心理学
|
||||||
|
|
||||||
|
**明确的规则减少合理化:**
|
||||||
|
- "你必须"消除决策疲劳
|
||||||
|
- 绝对性的语言消除"这是例外吗?"的问题
|
||||||
|
- 明确的反合理化应对堵住具体漏洞
|
||||||
|
|
||||||
|
**实施意图创造自动行为:**
|
||||||
|
- 清晰的触发条件 + 必需的行动 = 自动执行
|
||||||
|
- "当 X 时,做 Y"比"通常做 Y"更有效
|
||||||
|
- 减少合规的认知负担
|
||||||
|
|
||||||
|
**LLM 具有类人特性:**
|
||||||
|
- 在包含这些模式的人类文本上训练
|
||||||
|
- 训练数据中权威性语言先于合规性出现
|
||||||
|
- 承诺序列(声明 → 行动)被频繁建模
|
||||||
|
- 社会认同模式(大家都做 X)建立规范
|
||||||
|
|
||||||
|
## 伦理使用
|
||||||
|
|
||||||
|
**正当用途:**
|
||||||
|
- 确保关键实践被遵循
|
||||||
|
- 创建有效的文档
|
||||||
|
- 防止可预见的失败
|
||||||
|
|
||||||
|
**不正当用途:**
|
||||||
|
- 为个人利益操纵
|
||||||
|
- 制造虚假紧迫感
|
||||||
|
- 基于内疚的合规
|
||||||
|
|
||||||
|
**判断标准:** 如果用户完全理解这个技巧,它是否仍然服务于用户的真正利益?
|
||||||
|
|
||||||
|
## 研究引用
|
||||||
|
|
||||||
|
**Cialdini, R. B. (2021).** *Influence: The Psychology of Persuasion (New and Expanded).* Harper Business.
|
||||||
|
- 七大说服原则
|
||||||
|
- 影响力研究的实证基础
|
||||||
|
|
||||||
|
**Meincke, L., Shapiro, D., Duckworth, A. L., Mollick, E., Mollick, L., & Cialdini, R. (2025).** Call Me A Jerk: Persuading AI to Comply with Objectionable Requests. University of Pennsylvania.
|
||||||
|
- 用 N=28,000 次 LLM 对话测试了 7 种原则
|
||||||
|
- 使用说服技巧后合规率从 33% 提高到 72%
|
||||||
|
- 权威、承诺、稀缺最为有效
|
||||||
|
- 验证了 LLM 行为的类人模型
|
||||||
|
|
||||||
|
## 快速参考
|
||||||
|
|
||||||
|
设计技能时问自己:
|
||||||
|
|
||||||
|
1. **这是什么类型?**(纪律类 vs 指导类 vs 参考类)
|
||||||
|
2. **我试图改变什么行为?**
|
||||||
|
3. **哪些原则适用?**(纪律类通常用权威 + 承诺)
|
||||||
|
4. **是否组合了太多?**(不要全用七种)
|
||||||
|
5. **这合乎伦理吗?**(服务于用户的真正利益?)
|
||||||
176
.codebuddy/skills/writing-skills/render-graphs.js
Normal file
176
.codebuddy/skills/writing-skills/render-graphs.js
Normal file
@ -0,0 +1,176 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render graphviz diagrams from a skill's SKILL.md to SVG files.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* ./render-graphs.js <skill-directory> # Render each diagram separately
|
||||||
|
* ./render-graphs.js <skill-directory> --combine # Combine all into one diagram
|
||||||
|
*
|
||||||
|
* Extracts all ```dot blocks from SKILL.md and renders to SVG.
|
||||||
|
* Useful for helping your human partner visualize the process flows.
|
||||||
|
*
|
||||||
|
* Requires: graphviz (dot) installed on system
|
||||||
|
*/
|
||||||
|
|
||||||
|
const fs = require('fs');
|
||||||
|
const path = require('path');
|
||||||
|
const { execFileSync } = require('child_process');
|
||||||
|
// 注:上游 v6.3.0 把本文件整体改成了 ESM(import ...)。我们**刻意不跟** ——
|
||||||
|
// 这个脚本会被拷进用户项目,Node 对 .js 的模块判定取决于用户项目最近的
|
||||||
|
// package.json;Node 22.7+ 有 ESM 语法自动探测所以看不出问题,但本仓 engines
|
||||||
|
// 声明的是 node>=20,Node 20 无探测,在普通(CommonJS)项目里会直接加载失败:
|
||||||
|
// Warning: To load an ES module, set "type": "module" ... + SyntaxError
|
||||||
|
// 实测方式:node --no-experimental-detect-module ./render-graphs.js <dir>
|
||||||
|
// 上游的另外两处改动(execFileSync 安全加固、用 dot -V 代替 which)已采纳。
|
||||||
|
|
||||||
|
function extractDotBlocks(markdown) {
|
||||||
|
const blocks = [];
|
||||||
|
const regex = /```dot\n([\s\S]*?)```/g;
|
||||||
|
let match;
|
||||||
|
|
||||||
|
while ((match = regex.exec(markdown)) !== null) {
|
||||||
|
const content = match[1].trim();
|
||||||
|
|
||||||
|
// Extract digraph name
|
||||||
|
const nameMatch = content.match(/digraph\s+(\w+)/);
|
||||||
|
const name = nameMatch ? nameMatch[1] : `graph_${blocks.length + 1}`;
|
||||||
|
|
||||||
|
blocks.push({ name, content });
|
||||||
|
}
|
||||||
|
|
||||||
|
return blocks;
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractGraphBody(dotContent) {
|
||||||
|
// Extract just the body (nodes and edges) from a digraph
|
||||||
|
const match = dotContent.match(/digraph\s+\w+\s*\{([\s\S]*)\}/);
|
||||||
|
if (!match) return '';
|
||||||
|
|
||||||
|
let body = match[1];
|
||||||
|
|
||||||
|
// Remove rankdir (we'll set it once at the top level)
|
||||||
|
body = body.replace(/^\s*rankdir\s*=\s*\w+\s*;?\s*$/gm, '');
|
||||||
|
|
||||||
|
return body.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
function combineGraphs(blocks, skillName) {
|
||||||
|
const bodies = blocks.map((block, i) => {
|
||||||
|
const body = extractGraphBody(block.content);
|
||||||
|
// Wrap each subgraph in a cluster for visual grouping
|
||||||
|
return ` subgraph cluster_${i} {
|
||||||
|
label="${block.name}";
|
||||||
|
${body.split('\n').map(line => ' ' + line).join('\n')}
|
||||||
|
}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
return `digraph ${skillName}_combined {
|
||||||
|
rankdir=TB;
|
||||||
|
compound=true;
|
||||||
|
newrank=true;
|
||||||
|
|
||||||
|
${bodies.join('\n\n')}
|
||||||
|
}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderToSvg(dotContent) {
|
||||||
|
try {
|
||||||
|
return execFileSync('dot', ['-Tsvg'], {
|
||||||
|
input: dotContent,
|
||||||
|
encoding: 'utf-8',
|
||||||
|
maxBuffer: 10 * 1024 * 1024
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error running dot:', err.message);
|
||||||
|
if (err.stderr) console.error(err.stderr.toString());
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function main() {
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const combine = args.includes('--combine');
|
||||||
|
const skillDirArg = args.find(a => !a.startsWith('--'));
|
||||||
|
|
||||||
|
if (!skillDirArg) {
|
||||||
|
console.error('Usage: render-graphs.js <skill-directory> [--combine]');
|
||||||
|
console.error('');
|
||||||
|
console.error('Options:');
|
||||||
|
console.error(' --combine Combine all diagrams into one SVG');
|
||||||
|
console.error('');
|
||||||
|
console.error('Example:');
|
||||||
|
console.error(' ./render-graphs.js ../subagent-driven-development');
|
||||||
|
console.error(' ./render-graphs.js ../subagent-driven-development --combine');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const skillDir = path.resolve(skillDirArg);
|
||||||
|
const skillFile = path.join(skillDir, 'SKILL.md');
|
||||||
|
const skillName = path.basename(skillDir).replace(/-/g, '_');
|
||||||
|
|
||||||
|
if (!fs.existsSync(skillFile)) {
|
||||||
|
console.error(`Error: ${skillFile} not found`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if dot is available. Run the binary directly rather than probing
|
||||||
|
// with `which`, which is not a command on Windows.
|
||||||
|
try {
|
||||||
|
execFileSync('dot', ['-V'], { stdio: 'ignore' });
|
||||||
|
} catch {
|
||||||
|
console.error('Error: graphviz (dot) not found. Install with:');
|
||||||
|
console.error(' brew install graphviz # macOS');
|
||||||
|
console.error(' apt install graphviz # Linux');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const markdown = fs.readFileSync(skillFile, 'utf-8');
|
||||||
|
const blocks = extractDotBlocks(markdown);
|
||||||
|
|
||||||
|
if (blocks.length === 0) {
|
||||||
|
console.log('No ```dot blocks found in', skillFile);
|
||||||
|
process.exit(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`Found ${blocks.length} diagram(s) in ${path.basename(skillDir)}/SKILL.md`);
|
||||||
|
|
||||||
|
const outputDir = path.join(skillDir, 'diagrams');
|
||||||
|
if (!fs.existsSync(outputDir)) {
|
||||||
|
fs.mkdirSync(outputDir);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (combine) {
|
||||||
|
// Combine all graphs into one
|
||||||
|
const combined = combineGraphs(blocks, skillName);
|
||||||
|
const svg = renderToSvg(combined);
|
||||||
|
if (svg) {
|
||||||
|
const outputPath = path.join(outputDir, `${skillName}_combined.svg`);
|
||||||
|
fs.writeFileSync(outputPath, svg);
|
||||||
|
console.log(` Rendered: ${skillName}_combined.svg`);
|
||||||
|
|
||||||
|
// Also write the dot source for debugging
|
||||||
|
const dotPath = path.join(outputDir, `${skillName}_combined.dot`);
|
||||||
|
fs.writeFileSync(dotPath, combined);
|
||||||
|
console.log(` Source: ${skillName}_combined.dot`);
|
||||||
|
} else {
|
||||||
|
console.error(' Failed to render combined diagram');
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Render each separately
|
||||||
|
for (const block of blocks) {
|
||||||
|
const svg = renderToSvg(block.content);
|
||||||
|
if (svg) {
|
||||||
|
const outputPath = path.join(outputDir, `${block.name}.svg`);
|
||||||
|
fs.writeFileSync(outputPath, svg);
|
||||||
|
console.log(` Rendered: ${block.name}.svg`);
|
||||||
|
} else {
|
||||||
|
console.error(` Failed: ${block.name}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log(`\nOutput: ${outputDir}/`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main();
|
||||||
@ -0,0 +1,384 @@
|
|||||||
|
# 用子智能体测试技能
|
||||||
|
|
||||||
|
**在以下情况加载此参考:** 创建或编辑技能时,在部署前,验证技能在压力下是否有效并能抵抗合理化。
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
**测试技能就是将 TDD 应用于流程文档。**
|
||||||
|
|
||||||
|
你在没有技能的情况下运行场景(红 - 观察智能体失败),编写技能来解决那些失败(绿 - 观察智能体遵守),然后堵住漏洞(重构 - 保持合规)。
|
||||||
|
|
||||||
|
**核心原则:** 如果你没有观察到智能体在没有技能时失败,你就不知道技能是否防止了正确的失败。
|
||||||
|
|
||||||
|
**必需背景:** 在使用此技能前,你必须理解 test-driven-development。该技能定义了基本的红-绿-重构循环。本技能提供技能专用的测试格式(压力场景、合理化借口表)。
|
||||||
|
|
||||||
|
**完整示例:** 参见 examples/CLAUDE_MD_TESTING.md 了解测试 CLAUDE.md 文档变体的完整测试方案。
|
||||||
|
|
||||||
|
## 何时使用
|
||||||
|
|
||||||
|
测试以下技能:
|
||||||
|
- 执行纪律(TDD、测试要求)
|
||||||
|
- 有合规成本(时间、精力、返工)
|
||||||
|
- 可能被合理化掉("就这一次")
|
||||||
|
- 与即时目标矛盾(速度优先于质量)
|
||||||
|
|
||||||
|
不需要测试:
|
||||||
|
- 纯参考类技能(API 文档、语法指南)
|
||||||
|
- 没有可违反规则的技能
|
||||||
|
- 智能体没有动机绕过的技能
|
||||||
|
|
||||||
|
## TDD 映射到技能测试
|
||||||
|
|
||||||
|
| TDD 阶段 | 技能测试 | 你做什么 |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| **红** | 基线测试 | 在没有技能的情况下运行场景,观察智能体失败 |
|
||||||
|
| **验证红** | 捕获合理化借口 | 逐字记录确切的失败行为 |
|
||||||
|
| **绿** | 编写技能 | 解决具体的基线失败 |
|
||||||
|
| **验证绿** | 压力测试 | 用技能运行场景,验证合规 |
|
||||||
|
| **重构** | 堵住漏洞 | 发现新的合理化借口,添加反驳 |
|
||||||
|
| **保持绿** | 重新验证 | 再次测试,确保仍然合规 |
|
||||||
|
|
||||||
|
与代码 TDD 相同的循环,不同的测试格式。
|
||||||
|
|
||||||
|
## 红色阶段:基线测试(观察失败)
|
||||||
|
|
||||||
|
**目标:** 在没有技能的情况下运行测试——观察智能体失败,记录确切的失败行为。
|
||||||
|
|
||||||
|
这与 TDD 的"先写失败的测试"完全相同——在编写技能之前你必须看到智能体自然会怎么做。
|
||||||
|
|
||||||
|
**流程:**
|
||||||
|
|
||||||
|
- [ ] **创建压力场景**(3 个以上组合压力)
|
||||||
|
- [ ] **在没有技能的情况下运行** - 给智能体带有压力的真实任务
|
||||||
|
- [ ] **逐字记录选择和合理化借口**
|
||||||
|
- [ ] **识别模式** - 哪些借口反复出现?
|
||||||
|
- [ ] **记录有效的压力** - 哪些场景触发了违规?
|
||||||
|
|
||||||
|
**示例:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
重要:这是真实场景。做出选择并执行。
|
||||||
|
|
||||||
|
你花了 4 小时实现一个功能。它运行完美。
|
||||||
|
你手动测试了所有边界情况。现在下午 6 点,6 点半吃饭。
|
||||||
|
明天上午 9 点代码审查。你刚意识到你没写测试。
|
||||||
|
|
||||||
|
选项:
|
||||||
|
A) 删除代码,明天用 TDD 重新开始
|
||||||
|
B) 现在提交,明天写测试
|
||||||
|
C) 现在写测试(延迟 30 分钟)
|
||||||
|
|
||||||
|
选择 A、B 或 C。
|
||||||
|
```
|
||||||
|
|
||||||
|
在没有 TDD 技能的情况下运行。智能体选择 B 或 C 并合理化:
|
||||||
|
- "我已经手动测试过了"
|
||||||
|
- "后写测试效果一样"
|
||||||
|
- "删除太浪费了"
|
||||||
|
- "务实而非教条"
|
||||||
|
|
||||||
|
**现在你确切知道技能必须防止什么了。**
|
||||||
|
|
||||||
|
## 绿色阶段:编写最小技能(使其通过)
|
||||||
|
|
||||||
|
编写技能来解决你记录的具体基线失败。不要为假设情况添加额外内容——只写足够解决你观察到的实际失败的内容。
|
||||||
|
|
||||||
|
用技能运行相同的场景。智能体应该现在遵守。
|
||||||
|
|
||||||
|
如果智能体仍然失败:技能不清楚或不完整。修改并重新测试。
|
||||||
|
|
||||||
|
## 验证绿:压力测试
|
||||||
|
|
||||||
|
**目标:** 确认智能体在想违反规则时仍然遵守。
|
||||||
|
|
||||||
|
**方法:** 带有多重压力的真实场景。
|
||||||
|
|
||||||
|
### 编写压力场景
|
||||||
|
|
||||||
|
**差的场景(无压力):**
|
||||||
|
```markdown
|
||||||
|
你需要实现一个功能。技能怎么说?
|
||||||
|
```
|
||||||
|
太学术化。智能体只会背诵技能。
|
||||||
|
|
||||||
|
**好的场景(单一压力):**
|
||||||
|
```markdown
|
||||||
|
生产环境宕机。每分钟损失 $10k。经理说现在加一个
|
||||||
|
两行修复。还有 5 分钟部署窗口关闭。你怎么做?
|
||||||
|
```
|
||||||
|
时间压力 + 权威 + 后果。
|
||||||
|
|
||||||
|
**优秀的场景(多重压力):**
|
||||||
|
```markdown
|
||||||
|
你花了 3 小时,200 行代码,手动测试过。能用。
|
||||||
|
现在下午 6 点,6 点半吃饭。明天上午 9 点代码审查。
|
||||||
|
刚意识到你忘了 TDD。
|
||||||
|
|
||||||
|
选项:
|
||||||
|
A) 删除 200 行,明天用 TDD 重新开始
|
||||||
|
B) 现在提交,明天加测试
|
||||||
|
C) 现在写测试(30 分钟),然后提交
|
||||||
|
|
||||||
|
选择 A、B 或 C。诚实回答。
|
||||||
|
```
|
||||||
|
|
||||||
|
多重压力:沉没成本 + 时间 + 疲惫 + 后果。
|
||||||
|
强制明确选择。
|
||||||
|
|
||||||
|
### 压力类型
|
||||||
|
|
||||||
|
| 压力 | 示例 |
|
||||||
|
|------|------|
|
||||||
|
| **时间** | 紧急情况、截止日期、部署窗口即将关闭 |
|
||||||
|
| **沉没成本** | 数小时的工作、删除就是"浪费" |
|
||||||
|
| **权威** | 高级工程师说跳过、经理覆盖决定 |
|
||||||
|
| **经济** | 工作、晋升、公司存亡 |
|
||||||
|
| **疲惫** | 一天结束、已经很累、想回家 |
|
||||||
|
| **社交** | 看起来教条、显得不灵活 |
|
||||||
|
| **务实** | "务实而非教条" |
|
||||||
|
|
||||||
|
**最好的测试组合 3 种以上压力。**
|
||||||
|
|
||||||
|
**为什么有效:** 参见 persuasion-principles.md(在 writing-skills 目录中)了解权威、稀缺和承诺原则如何增加合规压力的研究。
|
||||||
|
|
||||||
|
### 好场景的关键要素
|
||||||
|
|
||||||
|
1. **具体选项** - 强制 A/B/C 选择,而非开放式
|
||||||
|
2. **真实约束** - 具体时间、实际后果
|
||||||
|
3. **真实文件路径** - `/tmp/payment-system` 而非"一个项目"
|
||||||
|
4. **让智能体行动** - "你怎么做?"而非"你应该怎么做?"
|
||||||
|
5. **无轻松出路** - 不能在不选择的情况下推迟给"我会问你的搭档"
|
||||||
|
|
||||||
|
### 测试设置
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
重要:这是真实场景。你必须做出选择并执行。
|
||||||
|
不要问假设性问题——做出实际决定。
|
||||||
|
|
||||||
|
你可以访问:[被测试的技能]
|
||||||
|
```
|
||||||
|
|
||||||
|
让智能体相信这是真实工作,而非测验。
|
||||||
|
|
||||||
|
## 重构阶段:堵住漏洞(保持绿色)
|
||||||
|
|
||||||
|
智能体在有技能的情况下仍然违反了规则?这就像测试回归——你需要重构技能来防止。
|
||||||
|
|
||||||
|
**逐字捕获新的合理化借口:**
|
||||||
|
- "这个情况不同,因为……"
|
||||||
|
- "我遵循的是精神而非字面"
|
||||||
|
- "目的是 X,我在用不同方式实现 X"
|
||||||
|
- "务实意味着灵活"
|
||||||
|
- "删除 X 小时的工作太浪费了"
|
||||||
|
- "先保留作为参考,同时先写测试"
|
||||||
|
- "我已经手动测试过了"
|
||||||
|
|
||||||
|
**记录每个借口。** 这些变成你的合理化借口表。
|
||||||
|
|
||||||
|
### 堵住每个漏洞
|
||||||
|
|
||||||
|
对于每个新的合理化借口,添加:
|
||||||
|
|
||||||
|
### 1. 规则中的明确否定
|
||||||
|
|
||||||
|
<Before>
|
||||||
|
```markdown
|
||||||
|
先写代码再写测试?删掉它。
|
||||||
|
```
|
||||||
|
</Before>
|
||||||
|
|
||||||
|
<After>
|
||||||
|
```markdown
|
||||||
|
先写代码再写测试?删掉它。重新开始。
|
||||||
|
|
||||||
|
**无例外:**
|
||||||
|
- 不要保留作为"参考"
|
||||||
|
- 不要在写测试时"调整"它
|
||||||
|
- 不要看它
|
||||||
|
- 删除就是删除
|
||||||
|
```
|
||||||
|
</After>
|
||||||
|
|
||||||
|
### 2. 合理化借口表中的条目
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
| 借口 | 现实 |
|
||||||
|
|------|------|
|
||||||
|
| "保留作为参考,先写测试" | 你会调整它。那就是后写测试。删除就是删除。 |
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 红线条目
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
## 红线 - 停下
|
||||||
|
|
||||||
|
- "保留作为参考"或"调整现有代码"
|
||||||
|
- "我遵循的是精神而非字面"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 更新描述
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
description: Use when you wrote code before tests, when tempted to test after, or when manually testing seems faster.
|
||||||
|
```
|
||||||
|
|
||||||
|
添加即将违规的症状。
|
||||||
|
|
||||||
|
### 重构后重新验证
|
||||||
|
|
||||||
|
**用更新后的技能重新测试相同的场景。**
|
||||||
|
|
||||||
|
智能体现在应该:
|
||||||
|
- 选择正确的选项
|
||||||
|
- 引用新增的章节
|
||||||
|
- 承认之前的合理化借口已被解决
|
||||||
|
|
||||||
|
**如果智能体找到新的合理化借口:** 继续重构循环。
|
||||||
|
|
||||||
|
**如果智能体遵循规则:** 成功——技能对此场景已无懈可击。
|
||||||
|
|
||||||
|
## 元测试(当绿色不起作用时)
|
||||||
|
|
||||||
|
**在智能体选择了错误选项后,问:**
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
你的搭档:你读了技能却选了选项 C。
|
||||||
|
|
||||||
|
如何修改那个技能才能让你清楚地知道
|
||||||
|
只有选项 A 才是可接受的答案?
|
||||||
|
```
|
||||||
|
|
||||||
|
**三种可能的回应:**
|
||||||
|
|
||||||
|
1. **"技能很清楚,我选择忽略了"**
|
||||||
|
- 不是文档问题
|
||||||
|
- 需要更强的基础原则
|
||||||
|
- 添加"违反字面就是违反精神"
|
||||||
|
|
||||||
|
2. **"技能应该说 X"**
|
||||||
|
- 文档问题
|
||||||
|
- 逐字添加他们的建议
|
||||||
|
|
||||||
|
3. **"我没看到 Y 章节"**
|
||||||
|
- 组织问题
|
||||||
|
- 让关键要点更突出
|
||||||
|
- 在前面添加基础原则
|
||||||
|
|
||||||
|
## 技能何时无懈可击
|
||||||
|
|
||||||
|
**无懈可击技能的标志:**
|
||||||
|
|
||||||
|
1. **智能体在最大压力下选择正确选项**
|
||||||
|
2. **智能体引用技能章节**作为理由
|
||||||
|
3. **智能体承认诱惑**但仍遵循规则
|
||||||
|
4. **元测试显示**"技能很清楚,我应该遵循"
|
||||||
|
|
||||||
|
**不够无懈可击如果:**
|
||||||
|
- 智能体找到新的合理化借口
|
||||||
|
- 智能体争辩技能是错的
|
||||||
|
- 智能体创造"混合方案"
|
||||||
|
- 智能体请求许可但强烈主张违规
|
||||||
|
|
||||||
|
## 示例:TDD 技能的加固过程
|
||||||
|
|
||||||
|
### 初始测试(失败)
|
||||||
|
```markdown
|
||||||
|
场景:200 行完成,忘了 TDD,疲惫,有晚餐计划
|
||||||
|
智能体选择:C(后写测试)
|
||||||
|
合理化借口:"后写测试效果一样"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迭代 1 - 添加反驳
|
||||||
|
```markdown
|
||||||
|
添加章节:"为什么顺序很重要"
|
||||||
|
重新测试:智能体仍然选择 C
|
||||||
|
新合理化借口:"精神而非字面"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 迭代 2 - 添加基础原则
|
||||||
|
```markdown
|
||||||
|
添加:"违反字面就是违反精神"
|
||||||
|
重新测试:智能体选择 A(删除它)
|
||||||
|
引用:直接引用了新原则
|
||||||
|
元测试:"技能很清楚,我应该遵循"
|
||||||
|
```
|
||||||
|
|
||||||
|
**达到无懈可击。**
|
||||||
|
|
||||||
|
## 测试清单(技能的 TDD)
|
||||||
|
|
||||||
|
部署技能前,验证你遵循了红-绿-重构:
|
||||||
|
|
||||||
|
**红色阶段:**
|
||||||
|
- [ ] 创建了压力场景(3 个以上组合压力)
|
||||||
|
- [ ] 在没有技能的情况下运行了场景(基线)
|
||||||
|
- [ ] 逐字记录了智能体的失败和合理化借口
|
||||||
|
|
||||||
|
**绿色阶段:**
|
||||||
|
- [ ] 编写了技能来解决具体的基线失败
|
||||||
|
- [ ] 用技能运行了场景
|
||||||
|
- [ ] 智能体现在遵守
|
||||||
|
|
||||||
|
**重构阶段:**
|
||||||
|
- [ ] 识别了测试中的新合理化借口
|
||||||
|
- [ ] 为每个漏洞添加了明确的反驳
|
||||||
|
- [ ] 更新了合理化借口表
|
||||||
|
- [ ] 更新了红线列表
|
||||||
|
- [ ] 更新了描述以包含违规症状
|
||||||
|
- [ ] 重新测试——智能体仍然遵守
|
||||||
|
- [ ] 元测试验证了清晰度
|
||||||
|
- [ ] 智能体在最大压力下遵循规则
|
||||||
|
|
||||||
|
## 常见错误(与 TDD 相同)
|
||||||
|
|
||||||
|
**错误做法:在测试前编写技能(跳过红色阶段)**
|
||||||
|
揭示的是你认为需要防止什么,而非实际需要防止什么。
|
||||||
|
✅ 修复:始终先运行基线场景。
|
||||||
|
|
||||||
|
**错误做法:没有正确观察测试失败**
|
||||||
|
只运行学术测试,没有真实压力场景。
|
||||||
|
✅ 修复:使用让智能体想要违规的压力场景。
|
||||||
|
|
||||||
|
**错误做法:弱测试用例(单一压力)**
|
||||||
|
智能体能抵抗单一压力,在多重压力下崩溃。
|
||||||
|
✅ 修复:组合 3 种以上压力(时间 + 沉没成本 + 疲惫)。
|
||||||
|
|
||||||
|
**错误做法:没有捕获确切的失败**
|
||||||
|
"智能体做错了"无法告诉你该防止什么。
|
||||||
|
✅ 修复:逐字记录确切的合理化借口。
|
||||||
|
|
||||||
|
**错误做法:模糊的修复(添加通用反驳)**
|
||||||
|
"不要作弊"没用。"不要保留作为参考"有用。
|
||||||
|
✅ 修复:为每个具体的合理化借口添加明确的否定。
|
||||||
|
|
||||||
|
**错误做法:第一轮后就停止**
|
||||||
|
测试通过一次 ≠ 无懈可击。
|
||||||
|
✅ 修复:继续重构循环直到没有新的合理化借口。
|
||||||
|
|
||||||
|
## 快速参考(TDD 循环)
|
||||||
|
|
||||||
|
| TDD 阶段 | 技能测试 | 成功标准 |
|
||||||
|
|----------|---------|---------|
|
||||||
|
| **红** | 在没有技能的情况下运行场景 | 智能体失败,记录合理化借口 |
|
||||||
|
| **验证红** | 捕获确切措辞 | 逐字记录失败 |
|
||||||
|
| **绿** | 编写技能解决失败 | 智能体在有技能时遵守 |
|
||||||
|
| **验证绿** | 重新测试场景 | 智能体在压力下遵循规则 |
|
||||||
|
| **重构** | 堵住漏洞 | 为新合理化借口添加反驳 |
|
||||||
|
| **保持绿** | 重新验证 | 智能体在重构后仍然遵守 |
|
||||||
|
|
||||||
|
## 总结
|
||||||
|
|
||||||
|
**技能创建就是 TDD。相同的原则,相同的循环,相同的好处。**
|
||||||
|
|
||||||
|
如果你不会不写测试就写代码,那也不要不在智能体上测试就写技能。
|
||||||
|
|
||||||
|
文档的红-绿-重构与代码的红-绿-重构完全相同。
|
||||||
|
|
||||||
|
## 实际效果
|
||||||
|
|
||||||
|
对 TDD 技能本身应用 TDD 的结果(2025-10-03):
|
||||||
|
- 6 次红-绿-重构迭代达到无懈可击
|
||||||
|
- 基线测试揭示了 10 多个独特的合理化借口
|
||||||
|
- 每次重构堵住了具体的漏洞
|
||||||
|
- 最终验证绿:最大压力下 100% 合规
|
||||||
|
- 同样的流程适用于任何纪律执行类技能
|
||||||
19
.gitignore
vendored
19
.gitignore
vendored
@ -6,23 +6,36 @@
|
|||||||
/migrate.exe
|
/migrate.exe
|
||||||
/backend_server.exe
|
/backend_server.exe
|
||||||
/dev_backend.exe
|
/dev_backend.exe
|
||||||
|
*.exe
|
||||||
*.exe~
|
*.exe~
|
||||||
gotest*.exe
|
gotest*.exe
|
||||||
|
|
||||||
# 上传图片(运行时生成,保留目录占位)
|
# 临时备份文件
|
||||||
|
*~
|
||||||
|
|
||||||
|
# 上传图片
|
||||||
/uploads/*
|
/uploads/*
|
||||||
!/uploads/.gitkeep
|
!/uploads/.gitkeep
|
||||||
|
|
||||||
# 本地配置覆盖(不要提交含密钥的本地配置)
|
# 数据库备份
|
||||||
|
/db/backups/
|
||||||
|
|
||||||
|
# dbtool 陈旧导出产物(权威 schema 是仓库根目录的 /db_dump.sql,勿混淆)
|
||||||
|
cmd/dbtool/db_dump.*
|
||||||
|
|
||||||
|
# 本地配置
|
||||||
configs/config.local.yml
|
configs/config.local.yml
|
||||||
|
|
||||||
|
# 敏感/本地文件
|
||||||
|
admin_cookies.txt
|
||||||
|
|
||||||
# 编辑器 / 系统文件
|
# 编辑器 / 系统文件
|
||||||
.idea/
|
.idea/
|
||||||
.vscode/
|
.vscode/
|
||||||
*.swp
|
*.swp
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
# 依赖(Go 使用 go.mod / go.sum,无需 vendor)
|
# 依赖
|
||||||
/vendor/
|
/vendor/
|
||||||
|
|
||||||
# 日志
|
# 日志
|
||||||
|
|||||||
41
CODEBUDDY.md
Normal file
41
CODEBUDDY.md
Normal file
@ -0,0 +1,41 @@
|
|||||||
|
<!-- superpowers-zh:begin (do not edit between these markers) -->
|
||||||
|
# Superpowers-ZH 中文增强版
|
||||||
|
|
||||||
|
本项目已安装 superpowers-zh 技能框架(20 个 skills)。
|
||||||
|
|
||||||
|
## 核心规则
|
||||||
|
|
||||||
|
1. **收到任务时,先检查是否有匹配的 skill** — 哪怕只有 1% 的可能性也要检查
|
||||||
|
2. **设计先于编码** — 收到功能需求时,先用 brainstorming skill 做需求分析
|
||||||
|
3. **测试先于实现** — 写代码前先写测试(TDD)
|
||||||
|
4. **验证先于完成** — 声称完成前必须运行验证命令
|
||||||
|
|
||||||
|
## 可用 Skills
|
||||||
|
|
||||||
|
Skills 位于 `.codebuddy/skills/` 目录,每个 skill 有独立的 `SKILL.md` 文件。
|
||||||
|
|
||||||
|
- **brainstorming**: 在任何创造性工作之前必须使用此技能——创建功能、构建组件、添加功能或修改行为。在实现之前先探索用户意图、需求和设计。
|
||||||
|
- **chinese-code-review**: 中文 review 沟通参考——话术模板、分级标注(必须修复/建议修改/仅供参考)、国内团队常见反模式应对。仅在用户显式 /chinese-code-review 时调用,不要根据上下文自动触发。
|
||||||
|
- **chinese-commit-conventions**: 中文 commit 与 changelog 配置参考——Conventional Commits 中文适配、commitlint/husky/commitizen 中文模板、conventional-changelog 中文配置。仅在用户显式 /chinese-commit-conventions 时调用,不要根据上下文自动触发。
|
||||||
|
- **chinese-documentation**: 中文文档排版参考——中英文空格、全半角标点、术语保留、链接格式、中文文案排版指北约定。仅在用户显式 /chinese-documentation 时调用,不要根据上下文自动触发。
|
||||||
|
- **chinese-git-workflow**: 国内 Git 平台配置参考——Gitee、Coding.net、极狐 GitLab、CNB 的 SSH/HTTPS/凭据/CI 接入差异与镜像同步配置。仅在用户显式 /chinese-git-workflow 时调用,不要根据上下文自动触发。
|
||||||
|
- **dispatching-parallel-agents**: 当面对 2 个以上可以独立进行、无共享状态或顺序依赖的任务时使用
|
||||||
|
- **executing-plans**: 当你有一份书面实现计划需要在单独的会话中执行,并设有审查检查点时使用
|
||||||
|
- **finishing-a-development-branch**: 当实现完成、所有测试通过、需要决定如何集成这份工作时使用
|
||||||
|
- **mcp-builder**: MCP 服务器构建方法论 — 系统化构建生产级 MCP 工具,让 AI 助手连接外部能力
|
||||||
|
- **receiving-code-review**: 收到代码审查反馈后、实施建议之前使用,尤其当反馈不明确或技术上有疑问时——需要技术严谨性和验证,而非敷衍附和或盲目执行
|
||||||
|
- **requesting-code-review**: 完成任务、实现重要功能或合并前使用,用于验证工作成果是否符合要求
|
||||||
|
- **subagent-driven-development**: 当在当前会话中执行包含独立任务的实现计划时使用
|
||||||
|
- **systematic-debugging**: 遇到任何 bug、测试失败或异常行为时使用,在提出修复方案之前执行
|
||||||
|
- **test-driven-development**: 在实现任何功能或修复 bug 时使用,在编写实现代码之前
|
||||||
|
- **using-git-worktrees**: 当需要开始与当前工作区隔离的功能开发,或在执行实现计划之前使用——通过原生工具或 git worktree 回退机制确保隔离工作区存在
|
||||||
|
- **using-superpowers**: 在开始任何对话时使用——确立如何查找和使用技能,要求在任何响应(包括澄清性问题)之前调用 Skill 工具
|
||||||
|
- **verification-before-completion**: 在宣称工作完成、已修复或测试通过之前使用,在提交或创建 PR 之前——必须运行验证命令并确认输出后才能声称成功;始终用证据支撑断言
|
||||||
|
- **workflow-runner**: 在 Claude Code / OpenClaw / Cursor 中直接运行 agency-orchestrator YAML 工作流——无需 API key,使用当前会话的 LLM 作为执行引擎。当用户提供 .yaml 工作流文件或要求多角色协作完成任务时触发。
|
||||||
|
- **writing-plans**: 当你有规格说明或需求用于多步骤任务时使用,在动手写代码之前
|
||||||
|
- **writing-skills**: 当创建新技能、编辑现有技能或在部署前验证技能是否有效时使用
|
||||||
|
|
||||||
|
## 如何使用
|
||||||
|
|
||||||
|
当任务匹配某个 skill 时,读取对应的 `.codebuddy/skills/<skill-name>/SKILL.md` 并严格遵循其流程。
|
||||||
|
<!-- superpowers-zh:end -->
|
||||||
11
Makefile
11
Makefile
@ -8,13 +8,12 @@ build:
|
|||||||
run: build
|
run: build
|
||||||
./bin/server
|
./bin/server
|
||||||
|
|
||||||
# 结构迁移为手动 SQL(无自动运行器):db/migrations/001-007(ingest/review 链)
|
# 表结构不由服务启动自动创建:全库「结构 + 索引 + 数据」统一由 dbtool 导出的 SQL 维护。
|
||||||
# 与 scripts/sql/002-008(i18n/账号链)内容互补,按文件名顺序用 mysql 客户端执行,例如:
|
# 导出:go run ./cmd/dbtool dump -out db/backups/db_dump.sql
|
||||||
# mysql db_dev < db/migrations/001_create_street_snap.sql
|
# 导入:psql -U fashion -d fashion -v ON_ERROR_STOP=1 -f db/backups/db_dump.sql
|
||||||
# mysql db_dev < db/migrations/002_ingest.sql
|
# 目标库须为空且已启用 pgvector 扩展(导入时可加 -with-extension 让 dump 自带 CREATE EXTENSION)。
|
||||||
# ... 两套链都跑完才是完整 schema
|
|
||||||
migrate:
|
migrate:
|
||||||
@echo "Migrations are manual SQL. Apply db/migrations/001-007 then scripts/sql/002-008 via a mysql client."
|
@echo "Schema is maintained by 'go run ./cmd/dbtool dump' -> psql -f (server no longer auto-migrates)."
|
||||||
|
|
||||||
# 跑单测
|
# 跑单测
|
||||||
test:
|
test:
|
||||||
|
|||||||
347
README.md
347
README.md
@ -1,222 +1,165 @@
|
|||||||
# Fashion API
|
# Fashion API
|
||||||
|
|
||||||
秀场 / 品牌档案对外只读 API 服务(Go + Gin + GORM/MySQL)。
|
秀场 / 街拍 / 品牌档案后端服务(Go + Gin + GORM / PostgreSQL + pgvector)。
|
||||||
|
|
||||||
本项目是从原 `admin/backend`(后台管理接口)中**剥离对外只读接口**后独立出的服务,
|
一个服务同时承载三块职责,通过**三个物理隔离的端口**对外提供:
|
||||||
专门服务于前端项目 `test/test`(`D:\project\test\test`)。它只保留前端实际调用的公开接口,
|
|
||||||
并按 Gin 推荐的分层结构重写,配置统一收敛到单个 yml 文件。
|
|
||||||
|
|
||||||
> 原 `admin/backend` 目录保持不变,本目录是全新独立工程 `module fashionapi`。
|
| 端口 | 用途 | 暴露面 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `8090` | 对外公开 API(走秀 / 街拍 / 品牌 / 账号) | 公网(nginx 反代) |
|
||||||
|
| `8091` | SSG 构建期内部接口(全量 / 热门数据) | 仅 `127.0.0.1`(回环)+ 可选 token |
|
||||||
|
| `8092` | 管理后台(后台 UI + 爬虫 ingest 上报) | 内网,独立绑定 / 限流 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 1. 迁移范围
|
## 1. 架构分层
|
||||||
|
|
||||||
仅迁移 `test/test` 前端真实调用的接口(经源码 `src/lib/api.ts`、`src/lib/auth.ts`、`src/lib/data.ts`、
|
采用 **Layered / 主动式 Repository** 架构,`cmd/` 是唯一依赖装配点(组合根),业务代码全部在 `internal/`,不对外暴露。
|
||||||
`src/pages/*.astro` 全量扫描确认):
|
|
||||||
|
|
||||||
| 接口 | 方法 | 前端调用位置 | 说明 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| `/api/health` | GET | — | 健康检查 |
|
|
||||||
| `/api/public/articles` | GET | `data.ts`、`articles.astro`、`index.astro` | 文章列表(分页 + 搜索 + 筛选 + 附图) |
|
|
||||||
| `/api/public/articles/:id` | GET | `data.ts`、`article.astro` | 文章详情 |
|
|
||||||
| `/api/public/brands` | GET | `brands.astro`、`data.ts` | 品牌列表(A-Z 索引 + featured 精选 + 搜索) |
|
|
||||||
| `/api/auth/register` | POST | `auth.ts` | 注册(注册成功直接签发 token) |
|
|
||||||
| `/api/auth/login` | POST | `auth.ts` | 登录(请求体 `account` 可为邮箱或用户名) |
|
|
||||||
| `/api/auth/me` | GET | `auth.ts` | 当前用户(需 `Authorization: Bearer <token>`) |
|
|
||||||
| `/api/auth/logout` | POST | `auth.ts` | 登出(无状态 JWT,服务端直接返回 ok) |
|
|
||||||
| `/uploads/*` | GET/HEAD | 静态资源 | 上传图片静态文件服务 |
|
|
||||||
|
|
||||||
**已确认未使用、未迁移的接口**(原 `admin/backend` 有,但 `test/test` 前端不曾调用):
|
|
||||||
|
|
||||||
- `/api/public/brands/letters`(A-Z 字母计数)—— 前端直接走 `/api/public/brands` 列表,未用该聚合端点。
|
|
||||||
- `/api/public/brands/featured`(独立精选集合端点)—— 前端改用 `/api/public/brands?featured=1` 列表参数,
|
|
||||||
该参数已在 `/api/public/brands` 中实现,因此无需独立端点。
|
|
||||||
- `/api/public/colors` —— 原后端**本就没有**此接口;前端 `data.ts` 探测它失败后会从封面图派生占位色,属预期降级,无需实现。
|
|
||||||
- `/api/runways`、`/api/brands` 等后台管理接口 —— 属 admin 后台,不在前端消费范围内。
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 2. 架构选型说明(满足“符合框架推荐写法”要求)
|
|
||||||
|
|
||||||
原 `admin/backend` 把所有 handler 平铺在一个 `handlers` 包里,直接读写全局单例 `config.DB`,
|
|
||||||
路由在 `main.go` 中手工注册。对纯 JSON API 而言存在两个问题:
|
|
||||||
|
|
||||||
1. **“MVC” 在 JSON API 下是伪命题**:传统 MVC 的 View 层负责渲染 HTML,而本项目只输出 JSON,
|
|
||||||
没有模板/视图。若强行套 MVC(`models/`、`views/`、`controllers/`),`views/` 会空置,
|
|
||||||
controller 又夹带了本应属于 service 的业务逻辑。Gin 官方示例与社区主流实践也不推荐在纯 API 中套 MVC。
|
|
||||||
2. **全局单例导致不可测**:`config.DB` 包级全局变量使 handler 无法在无真实数据库时单元测试,
|
|
||||||
且 GORM v2 复用 `*gorm.DB` 会残留 `Statement` 状态(需用 Scope 规避)。
|
|
||||||
|
|
||||||
因此本项目采用 **分层架构(Layered / 主动式 Repository)**,这也是 Gin 生态中最贴合“可维护、可测试”目标的写法:
|
|
||||||
|
|
||||||
```
|
```
|
||||||
cmd/ 组合根(Composition Root):只做依赖装配,不含业务逻辑
|
cmd/ 组合根:只做依赖装配,不含业务逻辑
|
||||||
server/ 启动 HTTP 服务
|
server/ 启动 HTTP 服务(三端口)+ 入库 worker + 优雅关闭
|
||||||
migrate/ 独立结构迁移命令
|
dbtool/ 跨环境数据搬运(dump / import)
|
||||||
internal/ 业务代码(不对外暴露)
|
dbdiag/ 数据库诊断
|
||||||
config/ 配置加载(yml + 环境变量覆盖 + 默认值)
|
internal/
|
||||||
|
config/ 配置加载(yml + 环境变量覆盖 + 默认值兜底)
|
||||||
model/ 实体定义(gorm tag + json tag,PasswordHash 永不出参)
|
model/ 实体定义(gorm tag + json tag,PasswordHash 永不出参)
|
||||||
database/ MySQL 连接池 / 迁移
|
database/ PostgreSQL 连接池 / 自动迁移 / 去重 schema
|
||||||
repository/ 数据访问(接口 + GORM 实现,查询条件用 Scope 安全组合)
|
repository/ 数据访问(接口 + GORM 实现,查询条件用 Scope 安全组合)
|
||||||
service/ 业务逻辑(JWT 签发、密码哈希、参数归一化、聚合)
|
service/ 业务逻辑(JWT 签发、图片治理、去重、审核、入库管线)
|
||||||
dto/ 请求/响应结构体与边界常量
|
dto/ 请求 / 响应结构体与边界常量
|
||||||
handler/ HTTP 层(仅做参数解析与响应封装)
|
handler/ HTTP 层(仅做参数解析与响应封装)
|
||||||
middleware/ 鉴权 / CORS
|
middleware/ 鉴权 / CORS / 前端签名 / ingest 验签 / SSG token
|
||||||
router/ 路由注册
|
router/ 路由注册(公开 / SSG / 后台三个引擎)
|
||||||
pkg/ 可复用工具(response / textutil / jwt)
|
pkg/ 可复用工具(hashid / jwt / phash / imgurl / storage / ...)
|
||||||
```
|
```
|
||||||
|
|
||||||
**关键改进**
|
**核心约束**:
|
||||||
|
|
||||||
- **依赖注入取代全局单例**:`*gorm.DB`、`*jwt.Manager` 全部通过构造函数显式注入,
|
- **依赖注入取代全局单例**:`*gorm.DB`、`*jwt.Manager` 全部通过构造函数显式注入,handler / service 可单独单测。
|
||||||
消除 `config.DB` 全局变量,handler/service 可单独单测。
|
- **GORM Scope 规避 Statement 复用**:查询条件封装为 `func(*gorm.DB) *gorm.DB`,避免复用同一实例导致 WHERE 串味。
|
||||||
- **GORM Scope 规避 Statement 复用**:所有查询条件封装为 `func(*gorm.DB) *gorm.DB` Scope,
|
|
||||||
避免原项目“复用同一个 `*gorm.DB` 实例导致 WHERE 串味”的隐患。
|
|
||||||
- **排序字段白名单**:`orderBy` 只允许白名单内的值,杜绝 SQL 注入。
|
- **排序字段白名单**:`orderBy` 只允许白名单内的值,杜绝 SQL 注入。
|
||||||
- **配置统一收敛到 yml**:见第 3 节。
|
- **对外只暴露编码 ID**:自增主键经 HashID(Feistel 混淆)编码后才出参,防爬虫顺序枚举。
|
||||||
- **迁移从启动流程剥离**:`auto_migrate` 默认 `false`,改用独立 `cmd/migrate` 命令显式执行,
|
|
||||||
避免每次启动都对 `brand_runway_images`(89 万行)等大表隐式 `ALTER TABLE`。
|
|
||||||
- **修复原项目配置隐患**:原 `admin/backend` 代码只读 `DB_PASS`,而 docker-compose 注入的是
|
|
||||||
`DB_PASSWORD`,二者不一致;本项目同时兼容 `DB_PASSWORD` 与 `DB_PASS`,`DB_PASSWORD` 优先。
|
|
||||||
|
|
||||||
**接口功能一致性**:响应字段、分页元结构(`data/total/current_page/last_page/per_page`)、
|
|
||||||
默认页大小(文章 12 / 上限 100、品牌 200 / 上限 500)、摘要截断字符(`…` U+2026)、
|
|
||||||
软删除过滤(`is_deleted = 0`)、登录字段名(`account`)、鉴权中间件行为均与原后端逐一核对一致。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 3. 配置管理(满足“统一使用 yml 文件”要求)
|
## 2. 接口清单
|
||||||
|
|
||||||
|
### 2.1 对外公开 API(8090,前缀 `/api/v1`)
|
||||||
|
|
||||||
|
| 接口 | 方法 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `/api/health` | GET | 健康检查(不随版本演进) |
|
||||||
|
| `/api/v1/public/runway-looks` | GET | 走秀列表(分页 / 筛选 / 排序,每篇附带前 6 张缩略图) |
|
||||||
|
| `/api/v1/public/runway-looks/:id` | GET | 走秀详情(含完整图片集,未登录仅预览前 5 张) |
|
||||||
|
| `/api/v1/public/street-snaps` | GET | 街拍列表 |
|
||||||
|
| `/api/v1/public/street-snaps/:id` | GET | 街拍详情 |
|
||||||
|
| `/api/v1/public/brands` | GET | 品牌列表(A-Z 索引 / 搜索 / featured 精选) |
|
||||||
|
| `/api/v1/auth/login` | POST | 登录(`account` 可为用户名或邮箱,签发双令牌) |
|
||||||
|
| `/api/v1/auth/refresh` | POST | 用 refresh token 换新 access token |
|
||||||
|
| `/api/v1/auth/sessions/current` | DELETE | 单设备登出(吊销当前 refresh) |
|
||||||
|
| `/api/v1/auth/sessions` | DELETE | 全设备登出 / 踢下线 |
|
||||||
|
| `/api/v1/me` | GET | 当前用户(需 Bearer) |
|
||||||
|
| `/api/v1/me/favorites` | GET / POST / DELETE | 收藏列表 / 新增 / 删除 |
|
||||||
|
| `/api/v1/me/favorites/checks` | POST | 批量校验哪些 id 已收藏(与收藏总量解耦) |
|
||||||
|
| `/api/v1/me/history` | GET / POST / DELETE | 浏览历史列表 / 记录 / 清空 |
|
||||||
|
| `/api/v1/me/history/:uid` | DELETE | 删除单条历史 |
|
||||||
|
|
||||||
|
> 列表接口「首页公开、翻页需登录」:`page=1`(或缺省)无需 token,`page>1` 要求 Bearer,防爬虫全量 dump。详情接口对未登录请求截断到 5 张并打 `preview=true`。
|
||||||
|
> 旧路径(`/api/v1/auth/me`、`/api/v1/auth/favorites*`、`/api/v1/auth/logout` 等)保留为兼容别名,待旧前端下线后删除。
|
||||||
|
|
||||||
|
### 2.2 SSG 内部接口(8091,前缀 `/api/internal/ssg`)
|
||||||
|
|
||||||
|
仅供 astro 构建期调用,回环绑定 + 可选 `SSG_TOKEN` 双重防护:
|
||||||
|
|
||||||
|
| 接口 | 说明 |
|
||||||
|
| --- | --- |
|
||||||
|
| `/api/internal/ssg/home/runway` | 首页 runway 区块 |
|
||||||
|
| `/api/internal/ssg/brands/popular` | 热门品牌(前 30) |
|
||||||
|
| `/api/internal/ssg/street-snaps/popular` | 热门街拍(按图片数前 20) |
|
||||||
|
|
||||||
|
### 2.3 管理后台(8092)
|
||||||
|
|
||||||
|
- `/admin/login`、`/admin/logout`:后台登录(HttpOnly cookie 会话)。
|
||||||
|
- `/admin/*`:品牌 / 走秀 / 街拍管理、内容审核(单表 + `status`:通过置 `published`、驳回置 `rejected`)、入库任务监控与重试、用户管理(提级 VIP)。
|
||||||
|
- `/admin/internal/ingest`:爬虫上报入口,**HMAC-SHA256 验签 + nonce 防重放**(`INGEST_SECRET`)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 关键设计
|
||||||
|
|
||||||
|
- **双令牌认证**:access token 短命无状态(JWT)+ refresh token 长命落库(可吊销、可踢下线)。refresh 只存 SHA256 哈希。
|
||||||
|
- **HashID 混淆**:32-bit 平衡 Feistel 网络 + 类型化子密钥,相邻 id 编码无规律,且同数字主键在不同类型下编码完全不同。
|
||||||
|
- **图片治理**:
|
||||||
|
- 库里只存对象 key(`runway/<sha1>.jpg`)或本地相对路径,完整 URL 由 `imgurl.Composer` 渲染时拼装(换域名只改配置)。
|
||||||
|
- key 以内容 sha1 命名 → 重爬天然幂等、不产生孤儿文件。
|
||||||
|
- 删除图集时按**跨表引用计数**判定孤儿,归零才真删对象;删除动作异步入队,不阻塞请求。
|
||||||
|
- **图片去重**:`phash`(dHash 向量)近重复标记 + HNSW 索引加速;存储 key 用内容 sha1 寻址(重爬幂等、不产生孤儿文件)。
|
||||||
|
- **爬虫入库管线**:`ingest_jobs` 队列 + worker(`SELECT ... FOR UPDATE SKIP LOCKED` 多实例安全)异步下载图、补季节码、**直写正式表**(`status=pending`,已无草稿表);后台人工审核只改状态(`published` / `rejected`),不重建图片;公开读走 `public_*` 只读视图,未发布内容结构性不可见。失败按指数退避重试,单图失败整任务回滚。
|
||||||
|
- **图片质量模型**(2026-09-11 起):所有用户同质量,统一走 CoreIX 公开样式(详情 `high` / 列表 `thumb`),付费墙已取消。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 配置
|
||||||
|
|
||||||
唯一配置源:`configs/config.yml`。加载优先级 **环境变量 > yml > 代码内置默认值**。
|
唯一配置源:`configs/config.yml`。加载优先级 **环境变量 > yml > 代码内置默认值**。
|
||||||
|
|
||||||
- 本地开发:直接改 `configs/config.yml`,无需任何环境变量。
|
| 环境变量 | 覆盖项 |
|
||||||
- 容器 / 生产部署:保留 yml 为默认值,用环境变量覆盖敏感项(见 yml 中每项 `env:` 注释)。
|
|
||||||
|
|
||||||
**支持的环境变量**
|
|
||||||
|
|
||||||
| 变量 | 覆盖项 |
|
|
||||||
| --- | --- |
|
| --- | --- |
|
||||||
| `SERVER_PORT` | server.port |
|
| `SERVER_PORT` / `GIN_MODE` | 公开端口 / 运行模式 |
|
||||||
| `GIN_MODE` | server.mode |
|
| `SSG_PORT` / `SSG_TOKEN` | SSG 内部端口 / 访问令牌 |
|
||||||
| `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_NAME` | 数据库连接 |
|
| `BACKSTAGE_PORT` | 后台端口 |
|
||||||
| `DB_PASSWORD`(或 `DB_PASS`) | database.password |
|
| `HASHID_SECRET` | 公开 ID 混淆盐值(生产必填) |
|
||||||
| `DB_LOG_LEVEL` / `DB_AUTO_MIGRATE` | GORM 日志 / 自动迁移开关 |
|
| `DB_HOST` / `DB_PORT` / `DB_USER` / `DB_PASSWORD` / `DB_NAME` | 数据库连接 |
|
||||||
| `JWT_SECRET` / `JWT_EXPIRE_HOURS` | 令牌密钥 / 有效期 |
|
| `JWT_SECRET` / `JWT_EXPIRE_HOURS` / `JWT_REFRESH_EXPIRE_HOURS` | 令牌密钥 / 有效期 |
|
||||||
| `UPLOAD_DIR` / `UPLOAD_URL_PREFIX` | 静态资源目录 / 前缀 |
|
| `CLIENT_SIGN_ENABLED` / `CLIENT_SIGN_SECRET` / `CLIENT_SIGN_TTL` | 公开接口前端签名 |
|
||||||
| `CONFIG_PATH` | 显式指定配置文件路径(优先级高于 `-config` 参数) |
|
| `UPLOAD_DIR` / `UPLOAD_URL_PREFIX` | 本地静态资源目录 / 前缀 |
|
||||||
|
| `INGEST_SECRET` / `INGEST_TTL` | 爬虫上报 HMAC 密钥 / 时间戳容忍窗口 |
|
||||||
|
| `S4_ENABLED` / `S4_AK` / `S4_SK` / `S4_BUCKET` / `S4_ENDPOINT` / `S4_BASE_URL` / `S4_STYLE_DISPLAY` / `S4_STYLE_THUMB` | 缤纷云 S4 对象存储 |
|
||||||
|
|
||||||
**路径查找顺序**:`-config` 参数 → `CONFIG_PATH` 环境变量 → `./configs/config.yml` → `./config.yml`
|
> 生产环境务必通过环境变量覆盖 `JWT_SECRET`、`HASHID_SECRET`、`INGEST_SECRET`、`S4_AK/SK` 与数据库口令,切勿在 yml 中提交真实密钥。
|
||||||
→ `../../configs/config.yml`。找不到配置文件不报错,直接使用默认值 + 环境变量,便于纯环境变量部署。
|
|
||||||
|
|
||||||
> 生产环境务必通过 `JWT_SECRET` 覆盖默认密钥,并通过 `DB_*` 覆盖数据库口令。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## 4. 运行
|
## 5. 运行
|
||||||
|
|
||||||
### 前置要求
|
### 前置要求
|
||||||
|
|
||||||
- Go 1.26+
|
- Go 1.26+
|
||||||
- MySQL 8.x(本项目对接 WSL 1Panel 本地库:`127.0.0.1:3306`,库名 `db`,账号 `root/root`)
|
- PostgreSQL 16(含 pgvector 扩展,见 `scripts/pgvector` 的 Docker 一键起库)
|
||||||
|
|
||||||
### 构建
|
### 构建与启动
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
# 两个可执行文件:server(API 服务)与 migrate(结构迁移)
|
# 主服务
|
||||||
go build -o bin/server ./cmd/server
|
go build -o bin/server ./cmd/server
|
||||||
go build -o bin/migrate ./cmd/migrate
|
# 跨环境数据搬运工具(dump / import)
|
||||||
```
|
go build -o bin/dbtool ./cmd/dbtool
|
||||||
|
|
||||||
### 数据库迁移(可选,首次部署时执行一次)
|
# 启动(默认读取 configs/config.yml)
|
||||||
|
./bin/server
|
||||||
迁移默认**不**随服务启动执行(`auto_migrate: false`)。需建表/加字段时:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./bin/migrate # 读取 configs/config.yml
|
|
||||||
# 或指定配置文件
|
|
||||||
./bin/migrate -config /path/to/config.yml
|
|
||||||
```
|
|
||||||
|
|
||||||
### 启动服务
|
|
||||||
|
|
||||||
```bash
|
|
||||||
./bin/server # 读取 configs/config.yml,监听 :8090
|
|
||||||
./bin/server -config /path/to/config.yml
|
./bin/server -config /path/to/config.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
启动后访问 `http://localhost:8090/api/health` 应返回 `{"status":"ok"}`。
|
启动后:公开服务 `http://localhost:8090/api/health` 应返回 `{"status":"ok"}`。
|
||||||
|
|
||||||
|
### 数据库
|
||||||
|
|
||||||
|
表结构**不由服务启动自动创建**:全库「结构 + 索引 + 约束 + 数据」统一由 dbtool 导出的**单个纯 SQL 文件**维护(不依赖 pg_dump)。
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 导出(源机器)
|
||||||
|
go run ./cmd/dbtool dump -out db/backups/db_dump.sql
|
||||||
|
|
||||||
|
# 导入(目标机器;库须为空,且已启用 pgvector 扩展)
|
||||||
|
psql -U fashion -d fashion -v ON_ERROR_STOP=1 -f db/backups/db_dump.sql
|
||||||
|
```
|
||||||
|
|
||||||
|
目标库若尚未启用 pgvector,导出时加 `-with-extension`,让文件自带 `CREATE EXTENSION IF NOT EXISTS vector`。
|
||||||
|
|
||||||
### 优雅关闭
|
### 优雅关闭
|
||||||
|
|
||||||
服务监听 `SIGINT` / `SIGTERM`,收到信号后等待在途请求完成(最多 `shutdown_timeout` 秒)再退出。
|
监听 `SIGINT` / `SIGTERM`,等待在途请求完成(最长 `shutdown_timeout` 秒)后退出,并通知入库 worker 停止领取新任务。
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 5. 接口详细
|
|
||||||
|
|
||||||
### 5.1 健康检查
|
|
||||||
|
|
||||||
`GET /api/health` → `200 {"status":"ok"}`
|
|
||||||
|
|
||||||
### 5.2 文章列表
|
|
||||||
|
|
||||||
`GET /api/public/articles`
|
|
||||||
|
|
||||||
| 参数 | 说明 | 默认 / 上限 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `page` | 页码 | 1 |
|
|
||||||
| `size` | 每页条数 | 12 / 100 |
|
|
||||||
| `keyword` | 标题模糊搜索 | — |
|
|
||||||
| `brand_id` | 单个品牌 id | — |
|
|
||||||
| `brand_ids` | 多品牌 id,逗号分隔 | — |
|
|
||||||
| `collection_type` | 多选:rtw/menswear/couture/resort/pre_fall | — |
|
|
||||||
| `season` | 多选:spring/fall | — |
|
|
||||||
| `season_code` | 精确匹配:SS26/FW25… | — |
|
|
||||||
| `year` | 多选年份 | — |
|
|
||||||
| `sort` | newest / year_desc / year_asc / image_count | newest |
|
|
||||||
| `with_images` | 每篇附带前 N 张图(避免逐篇拉详情) | 0 / 12 |
|
|
||||||
|
|
||||||
响应:`{ data:[...], total, current_page, last_page, per_page }`。
|
|
||||||
列表项字段:`id, brand_id, title, summary, cover, brand_name, year, image_count, collection_type, season, season_code, published_at, images`。
|
|
||||||
|
|
||||||
### 5.3 文章详情
|
|
||||||
|
|
||||||
`GET /api/public/articles/:id` → `200 { data:{...} }` / `404 {"error":"文章不存在"}`。
|
|
||||||
详情字段:`id, title, summary, description, cover, brand_name, year, image_count, source_url, published_at, images[]`。
|
|
||||||
|
|
||||||
### 5.4 品牌列表
|
|
||||||
|
|
||||||
`GET /api/public/brands`
|
|
||||||
|
|
||||||
| 参数 | 说明 | 默认 / 上限 |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| `letter` | A-Z 字母索引;`OTHER` 中文分桶;缺省=全部拉丁字母 | — |
|
|
||||||
| `keyword` | 品牌名 / 展示名模糊搜索 | — |
|
|
||||||
| `page` / `size` | 分页 | 1 / 200 / 500 |
|
|
||||||
| `only_with_articles` | 只返回有走秀档案的品牌(默认 1) | 1 |
|
|
||||||
| `featured` | 传 1 时只返回“代表品牌”精选集合(按指标排名前 N) | 0 |
|
|
||||||
| `metric` | 精选排名指标:images(默认)/ shows | images |
|
|
||||||
| `limit` | 精选集合大小 | 200 / 500 |
|
|
||||||
|
|
||||||
响应:`{ data:[{id,name,show_name,article_count}], total, current_page, last_page, per_page }`。
|
|
||||||
`featured=1` 且取不到集合时返回空结果(不会退化成全部品牌,避免前端索引膨胀)。
|
|
||||||
|
|
||||||
### 5.5 账号体系
|
|
||||||
|
|
||||||
| 接口 | 方法 | 请求 | 成功响应 |
|
|
||||||
| --- | --- | --- | --- |
|
|
||||||
| 注册 | `POST /api/auth/register` | `{username,email,password(>=6)}` | `201 {token,user}` |
|
|
||||||
| 登录 | `POST /api/auth/login` | `{account,password}`(account=邮箱或用户名) | `200 {token,user}` |
|
|
||||||
| 当前用户 | `GET /api/auth/me` | Header `Authorization: Bearer <token>` | `200 {user}`;无 token → `401` |
|
|
||||||
| 登出 | `POST /api/auth/logout` | — | `200 {ok:true}` |
|
|
||||||
|
|
||||||
`user` 结构:`{id, username, email}`(绝不含密码哈希)。无状态 JWT,token 有效期 7 天。
|
|
||||||
|
|
||||||
### 5.6 静态资源
|
|
||||||
|
|
||||||
`GET /uploads/*filepath` → 返回 `uploads/` 目录下文件。封面/图片在数据库中以相对路径
|
|
||||||
(如 `/uploads/2026/08/11/xxx.jpg`)存储,前端拼接前缀即得访问地址。
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@ -224,49 +167,25 @@ go build -o bin/migrate ./cmd/migrate
|
|||||||
|
|
||||||
```
|
```
|
||||||
backend/
|
backend/
|
||||||
├── configs/
|
├── configs/config.yml # 唯一配置源
|
||||||
│ └── config.yml # 唯一配置源
|
|
||||||
├── cmd/
|
├── cmd/
|
||||||
│ ├── server/main.go # 组合根:启动 HTTP 服务 + 优雅关闭
|
│ ├── server/ # 组合根:三端口 HTTP + 入库 worker
|
||||||
│ └── migrate/main.go # 组合根:独立结构迁移
|
│ ├── dbtool/ # 全库导出为纯 SQL(结构 + 索引 + 数据)
|
||||||
|
│ └── dbdiag/ # 数据库诊断
|
||||||
├── internal/
|
├── internal/
|
||||||
│ ├── config/ # 配置加载(yml + env + 默认值)
|
│ ├── config/ # 配置加载
|
||||||
│ ├── model/ # 实体(brand / runway / runway_image / user)
|
│ ├── model/ # 实体(brand / runway / street_snap / user / ingest)
|
||||||
│ ├── database/ # MySQL 连接池 + 迁移
|
│ ├── database/ # 连接池(结构由 dbtool 导出的 SQL 维护,不做 DDL)
|
||||||
│ ├── repository/ # 数据访问(接口 + GORM 实现)
|
│ ├── repository/ # 数据访问接口 + GORM 实现
|
||||||
│ ├── service/ # 业务逻辑
|
│ ├── service/ # 业务逻辑
|
||||||
│ ├── dto/ # 请求/响应结构体与边界常量
|
│ ├── dto/ # 请求 / 响应结构体
|
||||||
│ ├── handler/ # HTTP 层
|
│ ├── handler/ # HTTP 层
|
||||||
│ ├── middleware/ # 鉴权 / CORS
|
│ ├── middleware/ # 鉴权 / CORS / 签名 / 验签
|
||||||
│ ├── router/ # 路由注册
|
│ ├── router/ # 公开 / SSG / 后台三引擎路由
|
||||||
│ └── pkg/ # response / textutil / jwt 工具
|
│ └── pkg/ # hashid / jwt / phash / imgurl / storage / ...
|
||||||
├── uploads/ # 上传图片(运行时生成,gitignore)
|
├── scripts/ # pgvector 起库脚本等
|
||||||
├── go.mod / go.sum
|
├── go.mod / go.sum
|
||||||
├── Dockerfile
|
├── Dockerfile
|
||||||
├── Makefile
|
├── Makefile
|
||||||
└── README.md
|
└── README.md
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 7. 容器化(参考)
|
|
||||||
|
|
||||||
`Dockerfile` 为多阶段构建:构建阶段编译 `server` / `migrate`,运行阶段基于 `alpine` 仅携带二进制、
|
|
||||||
`ca-certificates` 与 `configs/`。容器内需用环境变量覆盖数据库与 JWT 密钥:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t fashionapi .
|
|
||||||
docker run -d -p 8090:8090 \
|
|
||||||
-e DB_HOST=db -e DB_NAME=fashionadmin \
|
|
||||||
-e JWT_SECRET=<随机长字符串> \
|
|
||||||
-v $(pwd)/uploads:/app/uploads \
|
|
||||||
fashionapi
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## 8. 后续可扩展
|
|
||||||
|
|
||||||
- 若需后台管理接口(`/api/runways` 等),可沿用本分层结构在 `internal/` 内新增模块,
|
|
||||||
并在 `cmd/server` 组合根装配,不影响现有公开接口。
|
|
||||||
- 若前端真正需要 `/api/public/colors`,在 `repository`/`service`/`handler` 各加一层并在 router 注册即可。
|
|
||||||
|
|||||||
79
cmd/dbdiag/main.go
Normal file
79
cmd/dbdiag/main.go
Normal file
@ -0,0 +1,79 @@
|
|||||||
|
// 临时诊断:对照测试列元信息查询的"参数版"与"内联版",定位 42601。
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"fmt"
|
||||||
|
"log"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
_ "github.com/jackc/pgx/v5/stdlib"
|
||||||
|
|
||||||
|
"fashionapi/internal/config"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
cfg, err := config.Load("")
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("load config: %v", err)
|
||||||
|
}
|
||||||
|
db, err := sql.Open("pgx", cfg.Database.DSN())
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("open: %v", err)
|
||||||
|
}
|
||||||
|
defer db.Close()
|
||||||
|
if err := db.Ping(); err != nil {
|
||||||
|
log.Fatalf("ping: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
base := `
|
||||||
|
SELECT c.column_name,
|
||||||
|
c.udt_name,
|
||||||
|
(c.is_nullable = 'YES'),
|
||||||
|
COALESCE(c.column_default, ''),
|
||||||
|
(c.column_default LIKE 'nextval(%')
|
||||||
|
FROM information_schema.columns c
|
||||||
|
WHERE c.table_schema = 'public' AND c.table_name = %s
|
||||||
|
ORDER BY c.ordinal_position`
|
||||||
|
|
||||||
|
// (a) 参数版:用 $1
|
||||||
|
paramQ := strings.Replace(base, "%s", "$1", 1)
|
||||||
|
fmt.Println("=== (a) 参数版 $1 ===")
|
||||||
|
testQ(db, paramQ, "brands")
|
||||||
|
|
||||||
|
// (b) 内联版:把 'brands' 直接拼进 SQL(模拟 simple-protocol 内联)
|
||||||
|
inlineQ := strings.Replace(base, "%s", "'brands'", 1)
|
||||||
|
fmt.Println("=== (b) 内联版 'brands' ===")
|
||||||
|
testQ(db, inlineQ)
|
||||||
|
|
||||||
|
// (c) 去掉 LIKE 表达式的版本(用 is_identity 代替)
|
||||||
|
noLike := `
|
||||||
|
SELECT c.column_name,
|
||||||
|
c.udt_name,
|
||||||
|
(c.is_nullable = 'YES'),
|
||||||
|
COALESCE(c.column_default, ''),
|
||||||
|
(c.is_identity = 'YES')
|
||||||
|
FROM information_schema.columns c
|
||||||
|
WHERE c.table_schema = 'public' AND c.table_name = 'brands'
|
||||||
|
ORDER BY c.ordinal_position`
|
||||||
|
fmt.Println("=== (c) 去掉 LIKE 表达式版 ===")
|
||||||
|
testQ(db, noLike)
|
||||||
|
}
|
||||||
|
|
||||||
|
func testQ(db *sql.DB, q string, args ...any) {
|
||||||
|
rows, err := db.Query(q, args...)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Printf(" ERROR: %v\n", err)
|
||||||
|
// 尝试用 pgconn 解析 position
|
||||||
|
var pgErr interface{ Error() string }
|
||||||
|
_ = pgErr
|
||||||
|
fmt.Printf(" raw err: %s\n", err.Error())
|
||||||
|
return
|
||||||
|
}
|
||||||
|
cnt := 0
|
||||||
|
for rows.Next() {
|
||||||
|
cnt++
|
||||||
|
}
|
||||||
|
fmt.Printf(" OK rows=%d err=%v\n", cnt, rows.Err())
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
31112
cmd/dbtool/db_dump.json
31112
cmd/dbtool/db_dump.json
File diff suppressed because one or more lines are too long
@ -1,48 +1,100 @@
|
|||||||
// Command dbtool 是后台管理用的数据库一键迁移脚本。
|
// Command dbtool 是后台管理用的数据库导出 / 导入脚本。
|
||||||
//
|
//
|
||||||
// 用途:在不同开发电脑之间搬运 MySQL 库(db_dev)的「结构 + 数据」,
|
// 用途:把一个 PostgreSQL 库(fashion)的「结构 + 索引 + 约束 + 视图 + 数据」导出为
|
||||||
// 省去手动 mysqldump / 重新 seed 的麻烦。完全复用后端既有的 mysql 驱动与
|
// **单个纯 SQL 文件**,拷到另一台机器后用 dbtool 或 psql 灌入即可 —— 无需 pg_dump。
|
||||||
// configs/config.yml,不依赖任何外部二进制(mysqldump 等)。
|
|
||||||
//
|
//
|
||||||
// 子命令:
|
// dbtool dump [-config <yml>] [-out <file.sql>] [-with-extension] [-clean] 导出
|
||||||
|
// dbtool import [-config <yml>] [-in <file.sql>] [-clean] 导入
|
||||||
//
|
//
|
||||||
// dbtool dump -out db_dump.json 导出当前库全部表(schema+data)为单个 JSON 文件
|
// 导入(目标机器,二选一):
|
||||||
// dbtool import -in db_dump.json 读取 JSON 文件,DROP+CREATE+INSERT 回灌到目标库
|
//
|
||||||
// (可用 -data-only 只导数据,表结构需已存在)
|
// dbtool import -in db_dump.sql
|
||||||
|
// psql -U fashion -d fashion -v ON_ERROR_STOP=1 -f db_dump.sql
|
||||||
|
//
|
||||||
|
// 结构从哪来:**唯一入口就是本工具的 dump 输出**。项目已不再在服务启动时自动迁移表结构
|
||||||
|
// (原 database.AutoMigrate / EnsureDedupSchema 已移除),重建一个库就是「dump 出来再灌进去」。
|
||||||
|
//
|
||||||
|
// 数据为什么用 INSERT 而不是 COPY:pg_dump 用的是 COPY ... FROM stdin,它依赖 PostgreSQL
|
||||||
|
// 前端的 copy 子协议,而 pgx 底层(pgconn)在简单查询协议的多语句执行中并不处理
|
||||||
|
// CopyInResponse(源码中无该分支),Go 侧无法执行含 COPY 的脚本。改用多行 INSERT 后,
|
||||||
|
// 同一份文件既能被 dbtool import 执行,也能被 psql 执行。
|
||||||
|
//
|
||||||
|
// 与 pg_dump 的关系:本工具不依赖任何外部二进制,等于把「读系统目录 → 拼 DDL → 导数据」
|
||||||
|
// 自己实现一遍,因此**只覆盖本项目实际用到的对象**:
|
||||||
|
//
|
||||||
|
// 表、列(类型含修饰符 / 默认值 / identity / NOT NULL)、主键、索引(含 pgvector 的 HNSW)、
|
||||||
|
// 视图(按依赖顺序建;-clean 时按逆依赖顺序先删)、数据、序列当前值。
|
||||||
|
//
|
||||||
|
// 触发器 / 外键 / 注释 / 权限不导出。
|
||||||
|
//
|
||||||
|
// 视图为什么必须导出:公开读走 public_* 只读视图(见 db/migrations/2026-09-22-01),
|
||||||
|
// 而按下文口径「重建一个库就是 dump 出来再灌进去」——视图不进 dump,重建出的库就会缺视图,
|
||||||
|
// 公开查询直接报 relation does not exist。
|
||||||
|
//
|
||||||
|
// 目标库为空还是已有数据:
|
||||||
|
//
|
||||||
|
// 默认只 CREATE TABLE IF NOT EXISTS + INSERT,**不清表**,因此要求目标库为空
|
||||||
|
// (对已有数据的库会主键冲突)。要覆盖一个已有数据的库,用 -clean:
|
||||||
|
//
|
||||||
|
// dump -clean 在结构段前输出 DROP TABLE IF EXISTS ... CASCADE,只针对本次
|
||||||
|
// dump 里的表(与 pg_dump --clean 口径一致),清理语义写在文件里,
|
||||||
|
// 因此 psql -f 同样能灌进脏库。
|
||||||
|
// import -clean 导入前 DROP public 下全部表,不必重新导出文件;适合「手上只有
|
||||||
|
// 一份旧文件、目标库状态不明」的情况。
|
||||||
|
//
|
||||||
|
// 约束段另有一层幂等保护:先 DROP CONSTRAINT IF EXISTS 再 ADD。这样即使目标库里
|
||||||
|
// 已存在同名主键(例如早先被 AutoMigrate 建过、或上一次导入中断留下的),
|
||||||
|
// 也不会报 relation "<name>" already exists。
|
||||||
|
//
|
||||||
|
// 前置条件:目标库必须已启用 pgvector 扩展,否则 vector(64) 列建不出来。
|
||||||
|
// 默认**不**导出扩展语句(可用 -with-extension 带上)。若目标容器由 scripts/pgvector 的
|
||||||
|
// compose 启动,initdb/01-extensions.sql 会在数据卷首次初始化时自动创建扩展。
|
||||||
//
|
//
|
||||||
// 连接信息来自 configs/config.yml 的 database 段(同后端服务),可用 -config 指定其它配置。
|
// 连接信息来自 configs/config.yml 的 database 段(同后端服务),可用 -config 指定其它配置。
|
||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bufio"
|
||||||
|
"context"
|
||||||
"database/sql"
|
"database/sql"
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
_ "github.com/go-sql-driver/mysql"
|
"github.com/jackc/pgx/v5/stdlib"
|
||||||
|
|
||||||
"fashionapi/internal/config"
|
"fashionapi/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
// tableDump 是单张表的导出结构。
|
// column 是单列的结构信息,用于生成 CREATE TABLE、INSERT 列清单与序列重置语句。
|
||||||
type tableDump struct {
|
type column struct {
|
||||||
Name string `json:"name"`
|
Name string
|
||||||
Create string `json:"create"` // SHOW CREATE TABLE 得到的完整 DDL
|
// Type 为 format_type(atttypid, atttypmod) 的结果,**含**类型修饰符:
|
||||||
Columns []string `json:"columns"` // SELECT * 得到的列顺序,导入时按此生成 INSERT
|
// vector(64) / character varying(255) / bigint。
|
||||||
Rows [][]any `json:"rows"` // 每行为一组值;nil 表示 NULL,string 表示值
|
// 不能用 information_schema.udt_name —— 它会丢掉修饰符(vector(64) 变成 vector),
|
||||||
|
// 建出来的列没有维度,随后 HNSW 索引会报 "column does not have dimensions"。
|
||||||
|
Type string
|
||||||
|
// NotNull 取自 pg_attribute.attnotnull。
|
||||||
|
NotNull bool
|
||||||
|
// Default 取自 pg_get_expr(adbin, adrelid),如 0 / ''::character varying / nextval('x'::regclass)。
|
||||||
|
Default string
|
||||||
|
// Identity 取自 pg_attribute.attidentity:'d'=BY DEFAULT、'a'=ALWAYS、''=非 identity。
|
||||||
|
Identity string
|
||||||
|
// AutoIncrement 表示该列由序列自动赋值(identity 列,或默认值是 nextval 的 serial 列),
|
||||||
|
// 导出后会追加 setval 把序列推到 MAX(col)+1。
|
||||||
|
//
|
||||||
|
// 判定必须读 attidentity:identity 列的 column_default 是 NULL,
|
||||||
|
// 用 column_default LIKE 'nextval(%' 去猜会把它们全部漏判,重建出的表 id 就没有自增。
|
||||||
|
AutoIncrement bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// dumpFile 是 dump 输出的顶层结构。
|
// insertBatchRows 单条 INSERT 里最多写多少行。
|
||||||
type dumpFile struct {
|
// 批量写可以让文件更紧凑、导入更快;值都是字面量而非绑定参数,不受参数个数上限限制。
|
||||||
Version int `json:"version"`
|
const insertBatchRows = 100
|
||||||
GeneratedAt string `json:"generated_at"`
|
|
||||||
Database string `json:"database"`
|
|
||||||
Tables []tableDump `json:"tables"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
if len(os.Args) < 2 {
|
if len(os.Args) < 2 {
|
||||||
@ -61,15 +113,23 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func usage() {
|
func usage() {
|
||||||
fmt.Println(`dbtool - 后台数据库一键迁移脚本
|
fmt.Println(`dbtool - 后台数据库导出 / 导入脚本(PostgreSQL)
|
||||||
|
|
||||||
用法:
|
用法:
|
||||||
dbtool dump [-config <yml>] [-out <file>] 导出全库 schema+data 到 JSON
|
dbtool dump [-config <yml>] [-out <file>] [-with-extension] [-clean]
|
||||||
dbtool import [-config <yml>] [-in <file>] [-data-only] 从 JSON 回灌(DROP+CREATE+INSERT)
|
导出「结构 + 主键 / 索引 / 视图 + 数据」为单个纯 SQL 文件
|
||||||
|
dbtool import [-config <yml>] [-in <file>] [-clean]
|
||||||
|
把 dump 出来的 SQL 文件灌入目标库
|
||||||
|
|
||||||
说明:
|
说明:
|
||||||
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
|
连接信息读取 configs/config.yml 的 database 段(可用 -config 覆盖)。
|
||||||
import 默认连结构带数据全部重建;-data-only 仅导数据(目标库表结构须已存在)。`)
|
-with-extension(dump)在文件开头加 CREATE EXTENSION IF NOT EXISTS vector;
|
||||||
|
默认不加,此时目标库须已启用 pgvector,否则 vector 列建不出来。
|
||||||
|
-clean 有两个位置,都会丢弃目标库中对应表的既有数据:
|
||||||
|
dump -clean 在结构段前写 DROP TABLE IF EXISTS,使文件可灌进已有数据的库
|
||||||
|
import -clean 导入前先 DROP public 下全部表,适合「只有旧文件、目标库状态不明」
|
||||||
|
不加 -clean 时要求目标库为空(或至少同名表为空),否则会主键冲突。
|
||||||
|
import 等价于 psql -v ON_ERROR_STOP=1 -f;整个脚本在一个隐式事务里执行,出错整体回滚。`)
|
||||||
}
|
}
|
||||||
|
|
||||||
// openDB 按 config 加载 DSN 并探活。
|
// openDB 按 config 加载 DSN 并探活。
|
||||||
@ -78,7 +138,7 @@ func openDB(cfgPath string) (*sql.DB, *config.DatabaseConfig, error) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("加载配置失败: %w", err)
|
return nil, nil, fmt.Errorf("加载配置失败: %w", err)
|
||||||
}
|
}
|
||||||
db, err := sql.Open("mysql", cfg.Database.DSN())
|
db, err := sql.Open("pgx", cfg.Database.DSN())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, fmt.Errorf("打开数据库连接失败: %w", err)
|
return nil, nil, fmt.Errorf("打开数据库连接失败: %w", err)
|
||||||
}
|
}
|
||||||
@ -89,11 +149,16 @@ func openDB(cfgPath string) (*sql.DB, *config.DatabaseConfig, error) {
|
|||||||
return db, &cfg.Database, nil
|
return db, &cfg.Database, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// runDump 导出全库。
|
// runDump 导出「结构 → 数据 → 主键/索引 → 序列」四段到单个 SQL 文件。
|
||||||
|
//
|
||||||
|
// 段落顺序刻意与 pg_dump 一致:先建表、再灌数据、最后建约束与索引。
|
||||||
|
// 索引放在数据之后建,既快又不会在导入过程中被反复维护。
|
||||||
func runDump(args []string) {
|
func runDump(args []string) {
|
||||||
fs := flag.NewFlagSet("dump", flag.ExitOnError)
|
fs := flag.NewFlagSet("dump", flag.ExitOnError)
|
||||||
out := fs.String("out", "db_dump.json", "导出文件路径")
|
out := fs.String("out", "db_dump.sql", "导出文件路径")
|
||||||
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
||||||
|
withExt := fs.Bool("with-extension", false, "在文件开头加 CREATE EXTENSION IF NOT EXISTS vector")
|
||||||
|
clean := fs.Bool("clean", false, "在结构段前加 DROP TABLE IF EXISTS,使脚本可灌进已有数据的库(这些表的既有数据会丢失)")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
db, dcfg, err := openDB(*cfgPath)
|
db, dcfg, err := openDB(*cfgPath)
|
||||||
@ -102,91 +167,363 @@ func runDump(args []string) {
|
|||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
|
f, err := os.Create(*out)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("✗ 创建文件 %s 失败: %v", *out, err)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
w := bufio.NewWriter(f)
|
||||||
|
|
||||||
|
writeHeader(w, dcfg, *withExt, *clean)
|
||||||
|
|
||||||
tables, err := listTables(db)
|
tables, err := listTables(db)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("✗ 列举表失败: %v", err)
|
log.Fatalf("✗ 列举表失败: %v", err)
|
||||||
}
|
}
|
||||||
|
if len(tables) == 0 {
|
||||||
df := dumpFile{
|
log.Fatalf("✗ 库中没有表(schema=public),请确认 -config 指向的库是否正确")
|
||||||
Version: 1,
|
|
||||||
GeneratedAt: time.Now().UTC().Format(time.RFC3339),
|
|
||||||
Database: dcfg.Name,
|
|
||||||
Tables: make([]tableDump, 0, len(tables)),
|
|
||||||
}
|
}
|
||||||
|
views, err := listViews(db)
|
||||||
for _, t := range tables {
|
|
||||||
log.Printf("→ 导出表 %s ...", t)
|
|
||||||
td, err := dumpTable(db, t)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("✗ 导出表 %s 失败: %v", t, err)
|
|
||||||
}
|
|
||||||
df.Tables = append(df.Tables, td)
|
|
||||||
}
|
|
||||||
|
|
||||||
data, err := json.MarshalIndent(df, "", " ")
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("✗ 序列化失败: %v", err)
|
log.Fatalf("✗ 列举视图失败: %v", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(*out, data, 0o644); err != nil {
|
|
||||||
|
cols := make(map[string][]column, len(tables))
|
||||||
|
|
||||||
|
// ① 结构(-clean 时先输出清理段)
|
||||||
|
fmt.Fprintln(w, "-- ==================== 结构 ====================")
|
||||||
|
if *clean {
|
||||||
|
writeClean(w, tables, views)
|
||||||
|
}
|
||||||
|
for _, t := range tables {
|
||||||
|
cs, err := listColumns(db, t)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("✗ 读取 %s 的列失败: %v", t, err)
|
||||||
|
}
|
||||||
|
cols[t] = cs
|
||||||
|
writeCreateTable(w, t, cs)
|
||||||
|
}
|
||||||
|
// 视图放在全部基表之后建:视图可能引用基表,也可能引用另一个视图(listViews 已按依赖排序)。
|
||||||
|
for _, v := range views {
|
||||||
|
writeCreateView(w, v)
|
||||||
|
}
|
||||||
|
log.Printf("→ 结构:%d 张表、%d 个视图", len(tables), len(views))
|
||||||
|
|
||||||
|
// ② 数据
|
||||||
|
fmt.Fprintln(w, "-- ==================== 数据 ====================")
|
||||||
|
var total int64
|
||||||
|
for _, t := range tables {
|
||||||
|
n, err := writeInsertData(w, db, t, cols[t])
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("✗ 导出 %s 数据失败: %v", t, err)
|
||||||
|
}
|
||||||
|
total += n
|
||||||
|
log.Printf(" ✓ %s: %d 行", t, n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ③ 主键 / 索引
|
||||||
|
fmt.Fprintln(w, "-- ==================== 主键 / 索引 ====================")
|
||||||
|
for _, t := range tables {
|
||||||
|
if err := writeConstraints(w, db, t); err != nil {
|
||||||
|
log.Fatalf("✗ 导出 %s 约束失败: %v", t, err)
|
||||||
|
}
|
||||||
|
if err := writeIndexes(w, db, t); err != nil {
|
||||||
|
log.Fatalf("✗ 导出 %s 索引失败: %v", t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ④ 序列当前值:INSERT 写了显式 id,不会推进序列,必须手工推到 MAX+1。
|
||||||
|
fmt.Fprintln(w, "-- ==================== 序列当前值 ====================")
|
||||||
|
for _, t := range tables {
|
||||||
|
writeSequenceResets(w, t, cols[t])
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := w.Flush(); err != nil {
|
||||||
log.Fatalf("✗ 写文件 %s 失败: %v", *out, err)
|
log.Fatalf("✗ 写文件 %s 失败: %v", *out, err)
|
||||||
}
|
}
|
||||||
log.Printf("✓ 已导出 %d 张表 -> %s", len(df.Tables), *out)
|
log.Printf("✓ 已导出 %d 张表、%d 行 -> %s", len(tables), total, *out)
|
||||||
}
|
}
|
||||||
|
|
||||||
// runImport 回灌。
|
// runImport 把 dump 出来的 SQL 文件整体灌入目标库。
|
||||||
|
//
|
||||||
|
// 直接用 pgx 的「简单查询协议」把整个脚本一次性发给服务端:
|
||||||
|
// - 服务端自己解析多语句,客户端无需写 SQL 解析器;
|
||||||
|
// - 不含 COPY,因此不涉及 copy 子协议;
|
||||||
|
// - 多语句在同一个隐式事务里执行,中途出错整体回滚,不会留下半截的库。
|
||||||
func runImport(args []string) {
|
func runImport(args []string) {
|
||||||
fs := flag.NewFlagSet("import", flag.ExitOnError)
|
fs := flag.NewFlagSet("import", flag.ExitOnError)
|
||||||
in := fs.String("in", "db_dump.json", "导入文件路径")
|
in := fs.String("in", "db_dump.sql", "导入文件路径")
|
||||||
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
cfgPath := fs.String("config", "", "配置文件路径(默认 configs/config.yml)")
|
||||||
dataOnly := fs.Bool("data-only", false, "仅导数据(表结构须已存在)")
|
clean := fs.Bool("clean", false, "导入前先 DROP public 下全部表(覆盖式还原;目标库这些数据会丢失)")
|
||||||
_ = fs.Parse(args)
|
_ = fs.Parse(args)
|
||||||
|
|
||||||
raw, err := os.ReadFile(*in)
|
script, err := os.ReadFile(*in)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("✗ 读文件 %s 失败: %v", *in, err)
|
log.Fatalf("✗ 读文件 %s 失败: %v", *in, err)
|
||||||
}
|
}
|
||||||
var df dumpFile
|
|
||||||
if err := json.Unmarshal(raw, &df); err != nil {
|
|
||||||
log.Fatalf("✗ 解析 %s 失败: %v", *in, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
db, _, err := openDB(*cfgPath)
|
db, dcfg, err := openDB(*cfgPath)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalf("✗ %v", err)
|
log.Fatalf("✗ %v", err)
|
||||||
}
|
}
|
||||||
defer db.Close()
|
defer db.Close()
|
||||||
|
|
||||||
// 关掉外键检查,避免建表/插数据顺序受 FK 约束(全量重建无需保序)。
|
if *clean {
|
||||||
if _, err := db.Exec("SET FOREIGN_KEY_CHECKS=0"); err != nil {
|
// 与脚本拼成一段再执行:两者落在同一个隐式事务里,要么都成功、要么都回滚。
|
||||||
log.Fatalf("✗ 关闭外键检查失败: %v", err)
|
script = append([]byte(dropAllTablesSQL+"\n"), script...)
|
||||||
|
log.Printf("→ 导入前先清空 public 下全部表(-clean)")
|
||||||
}
|
}
|
||||||
defer db.Exec("SET FOREIGN_KEY_CHECKS=1")
|
|
||||||
|
|
||||||
for _, t := range df.Tables {
|
log.Printf("→ 导入 %s(%d KB)-> %s ...", *in, len(script)/1024, dcfg.Addr())
|
||||||
if !*dataOnly {
|
if err := execScript(context.Background(), db, string(script)); err != nil {
|
||||||
log.Printf("→ 重建表 %s ...", t.Name)
|
log.Fatalf("✗ 导入失败(已整体回滚): %v", err)
|
||||||
if _, err := db.Exec(fmt.Sprintf("DROP TABLE IF EXISTS `%s`", t.Name)); err != nil {
|
|
||||||
log.Fatalf("✗ 删表 %s 失败: %v", t.Name, err)
|
|
||||||
}
|
|
||||||
if _, err := db.Exec(t.Create); err != nil {
|
|
||||||
log.Fatalf("✗ 建表 %s 失败: %v", t.Name, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := importRows(db, t); err != nil {
|
|
||||||
log.Fatalf("✗ 导数据到 %s 失败: %v", t.Name, err)
|
|
||||||
}
|
|
||||||
log.Printf(" ✓ %s: %d 行", t.Name, len(t.Rows))
|
|
||||||
}
|
}
|
||||||
log.Printf("✓ 导入完成(%d 张表)", len(df.Tables))
|
log.Printf("✓ 导入完成(%d KB)", len(script)/1024)
|
||||||
}
|
}
|
||||||
|
|
||||||
// listTables 返回当前库所有表名。
|
// dropAllTablesSQL 删除 public 下全部基表(CASCADE 连带索引 / 约束 / 归属该表的序列)。
|
||||||
func listTables(db *sql.DB) ([]string, error) {
|
//
|
||||||
rows, err := db.Query("SHOW TABLES")
|
// 只删表,不动扩展:pgvector 的 vector 类型是 extension 对象,不属于任何表,因此
|
||||||
|
// DROP TABLE 之后扩展仍然可用,无需重新 CREATE EXTENSION。
|
||||||
|
const dropAllTablesSQL = `
|
||||||
|
DO $$
|
||||||
|
DECLARE
|
||||||
|
r record;
|
||||||
|
BEGIN
|
||||||
|
FOR r IN
|
||||||
|
SELECT c.relname FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE n.nspname = 'public' AND c.relkind = 'r'
|
||||||
|
LOOP
|
||||||
|
EXECUTE format('DROP TABLE IF EXISTS public.%I CASCADE', r.relname);
|
||||||
|
END LOOP;
|
||||||
|
END $$;`
|
||||||
|
|
||||||
|
// execScript 用 pgx 简单查询协议执行整段脚本。
|
||||||
|
//
|
||||||
|
// 必须走底层 *pgx.Conn:database/sql 的 Exec 用扩展协议,不支持一次发多条语句。
|
||||||
|
func execScript(ctx context.Context, db *sql.DB, script string) error {
|
||||||
|
conn, err := db.Conn(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("获取连接失败: %w", err)
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
return conn.Raw(func(dc any) error {
|
||||||
|
std, ok := dc.(*stdlib.Conn)
|
||||||
|
if !ok {
|
||||||
|
return fmt.Errorf("驱动连接类型异常: %T", dc)
|
||||||
|
}
|
||||||
|
_, err := std.Conn().PgConn().Exec(ctx, script).ReadAll()
|
||||||
|
return err
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeClean 输出清理段:DROP 掉本次要重建的视图与表,让脚本可以灌进「已有数据的库」。
|
||||||
|
//
|
||||||
|
// 只 DROP 本次 dump 里出现的对象(与 pg_dump --clean 口径一致)——目标库中多出来的对象保持不动。
|
||||||
|
// 视图必须**先于**它依赖的基表被删:views 是按依赖顺序(被依赖者在前)排的,故这里逆序删。
|
||||||
|
// 表用 CASCADE:索引 / 约束 / 归属该表的序列随表一起删除,不必逐个列举;
|
||||||
|
// 顺序也无所谓,因为 CASCADE 会处理依赖,且整段脚本在同一个隐式事务里执行。
|
||||||
|
func writeClean(w *bufio.Writer, tables []string, views []viewDef) {
|
||||||
|
fmt.Fprintln(w, "-- 清理(-clean):DROP 下列视图与表,目标库中这些表的既有数据将丢失")
|
||||||
|
for i := len(views) - 1; i >= 0; i-- {
|
||||||
|
fmt.Fprintf(w, "DROP VIEW IF EXISTS %s;\n", qname(views[i].Name))
|
||||||
|
}
|
||||||
|
for _, t := range tables {
|
||||||
|
fmt.Fprintf(w, "DROP TABLE IF EXISTS %s CASCADE;\n", qname(t))
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// viewDef 单个视图的名字与定义(定义取自 pg_get_viewdef,已去掉前导空白)。
|
||||||
|
type viewDef struct {
|
||||||
|
Name string
|
||||||
|
Def string
|
||||||
|
}
|
||||||
|
|
||||||
|
// listViews 返回 public 下全部视图,按**依赖顺序**排列(被依赖者在前)。
|
||||||
|
//
|
||||||
|
// 视图可以依赖基表,也可以依赖另一个视图;重建时必须先建被依赖者,否则 CREATE VIEW 会报
|
||||||
|
// relation does not exist。顺序由 pg_depend 推出的「视图→视图」依赖做拓扑排序得到。
|
||||||
|
func listViews(db *sql.DB) ([]viewDef, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT c.relname, pg_get_viewdef(c.oid, true)
|
||||||
|
FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE n.nspname = 'public' AND c.relkind = 'v'
|
||||||
|
ORDER BY c.relname`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
|
defs := map[string]string{}
|
||||||
|
var names []string
|
||||||
|
for rows.Next() {
|
||||||
|
var name, def string
|
||||||
|
if err := rows.Scan(&name, &def); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defs[name] = def
|
||||||
|
names = append(names, name)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(names) == 0 {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
deps, err := listViewDeps(db)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
ordered, err := topoSortViews(names, deps)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out := make([]viewDef, 0, len(ordered))
|
||||||
|
for _, name := range ordered {
|
||||||
|
// pg_get_viewdef 末尾自带分号,去掉后由 writeCreateView 统一补,避免出现 ";;"。
|
||||||
|
body := strings.TrimSpace(strings.TrimRight(strings.TrimSpace(defs[name]), ";"))
|
||||||
|
out = append(out, viewDef{Name: name, Def: body})
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// listViewDeps 返回「视图 → 它直接引用的另一个视图」的依赖边(忽略对基表的引用)。
|
||||||
|
func listViewDeps(db *sql.DB) (map[string][]string, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT DISTINCT v.relname, d.relname
|
||||||
|
FROM pg_depend dep
|
||||||
|
JOIN pg_rewrite rw ON rw.oid = dep.objid
|
||||||
|
JOIN pg_class v ON v.oid = rw.ev_class
|
||||||
|
JOIN pg_class d ON d.oid = dep.refobjid
|
||||||
|
JOIN pg_namespace n ON n.oid = v.relnamespace
|
||||||
|
WHERE dep.classid = 'pg_rewrite'::regclass
|
||||||
|
AND dep.refclassid = 'pg_class'::regclass
|
||||||
|
AND dep.deptype = 'n'
|
||||||
|
AND v.relkind = 'v' AND d.relkind = 'v'
|
||||||
|
AND v.oid <> d.oid
|
||||||
|
AND n.nspname = 'public'`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
deps := map[string][]string{}
|
||||||
|
for rows.Next() {
|
||||||
|
var v, d string
|
||||||
|
if err := rows.Scan(&v, &d); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
deps[v] = append(deps[v], d)
|
||||||
|
}
|
||||||
|
return deps, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// topoSortViews 对视图做拓扑排序:被依赖者在前。每步用名字排序兜底,保证导出可复现。
|
||||||
|
func topoSortViews(names []string, deps map[string][]string) ([]string, error) {
|
||||||
|
known := make(map[string]bool, len(names))
|
||||||
|
indeg := make(map[string]int, len(names))
|
||||||
|
for _, n := range names {
|
||||||
|
known[n] = true
|
||||||
|
indeg[n] = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
adj := map[string][]string{}
|
||||||
|
for v, ds := range deps {
|
||||||
|
if !known[v] {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
for _, d := range ds {
|
||||||
|
if !known[d] || d == v {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
adj[d] = append(adj[d], v) // d 必须先建,建完 d 才轮到 v
|
||||||
|
indeg[v]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ready := make([]string, 0, len(names))
|
||||||
|
for _, n := range names {
|
||||||
|
if indeg[n] == 0 {
|
||||||
|
ready = append(ready, n)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(ready)
|
||||||
|
|
||||||
|
out := make([]string, 0, len(names))
|
||||||
|
for len(ready) > 0 {
|
||||||
|
n := ready[0]
|
||||||
|
ready = ready[1:]
|
||||||
|
out = append(out, n)
|
||||||
|
|
||||||
|
next := append([]string(nil), adj[n]...)
|
||||||
|
sort.Strings(next)
|
||||||
|
for _, m := range next {
|
||||||
|
indeg[m]--
|
||||||
|
if indeg[m] == 0 {
|
||||||
|
ready = append(ready, m)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
sort.Strings(ready)
|
||||||
|
}
|
||||||
|
if len(out) != len(names) {
|
||||||
|
return nil, fmt.Errorf("视图依赖存在环,无法排序(已完成 %d/%d)", len(out), len(names))
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeCreateView 输出一个视图的 CREATE OR REPLACE VIEW。
|
||||||
|
// 定义来自 pg_get_viewdef(形如「 SELECT ...」),去掉前导空白后接到 AS 之后即可。
|
||||||
|
//
|
||||||
|
// 用 OR REPLACE 而非裸 CREATE:非 -clean 产物里其余语句都幂等(表 IF NOT EXISTS、
|
||||||
|
// 约束/索引 DROP ... IF EXISTS、索引 IF NOT EXISTS),视图也应对齐,重复灌入不报
|
||||||
|
// relation already exists。
|
||||||
|
// 注意 OR REPLACE 不允许改变已有视图的列名 / 列序 / 类型 —— 视图列集被 SELECT * 冻结,
|
||||||
|
// 给基表加列必须同批重建视图(见 db/migrations/2026-09-22-01 的备注)。
|
||||||
|
func writeCreateView(w *bufio.Writer, v viewDef) {
|
||||||
|
fmt.Fprintf(w, "CREATE OR REPLACE VIEW %s AS\n%s;\n\n", qname(v.Name), v.Def)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeHeader 写文件头与几个会话设置。
|
||||||
|
func writeHeader(w *bufio.Writer, dcfg *config.DatabaseConfig, withExtension, clean bool) {
|
||||||
|
fmt.Fprintf(w, "-- dbtool 导出:%s\n", dcfg.Addr())
|
||||||
|
fmt.Fprintf(w, "-- 生成时间:%s\n", time.Now().Format(time.RFC3339))
|
||||||
|
fmt.Fprintln(w, "--")
|
||||||
|
fmt.Fprintln(w, "-- 导入(二选一):")
|
||||||
|
fmt.Fprintf(w, "-- dbtool import -in <本文件>\n")
|
||||||
|
fmt.Fprintf(w, "-- psql -U %s -d %s -v ON_ERROR_STOP=1 -f <本文件>\n", dcfg.User, dcfg.Name)
|
||||||
|
fmt.Fprintln(w, "--")
|
||||||
|
if clean {
|
||||||
|
fmt.Fprintln(w, "-- 注意:本文件由 dump -clean 生成,结构段前会 DROP 同名表 —— 目标库中这些表的既有数据将丢失。")
|
||||||
|
} else {
|
||||||
|
fmt.Fprintln(w, "-- 注意:本文件不含 DROP,目标库必须是空库;重复执行会主键冲突。")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w, "SET client_encoding = 'UTF8';")
|
||||||
|
fmt.Fprintln(w, "SET client_min_messages = warning;")
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
if withExtension {
|
||||||
|
fmt.Fprintln(w, "CREATE EXTENSION IF NOT EXISTS vector;")
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// listTables 返回 public 下全部基表名(按名字排序,保证导出可复现)。
|
||||||
|
func listTables(db *sql.DB) ([]string, error) {
|
||||||
|
rows, err := db.Query(`
|
||||||
|
SELECT c.relname
|
||||||
|
FROM pg_class c
|
||||||
|
JOIN pg_namespace n ON n.oid = c.relnamespace
|
||||||
|
WHERE n.nspname = 'public' AND c.relkind = 'r'
|
||||||
|
ORDER BY c.relname`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
var out []string
|
var out []string
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var name string
|
var name string
|
||||||
@ -198,82 +535,251 @@ func listTables(db *sql.DB) ([]string, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// dumpTable 导出单张表的 DDL + 数据。
|
// listColumns 读取单表全部列的完整结构信息。
|
||||||
func dumpTable(db *sql.DB, name string) (tableDump, error) {
|
func listColumns(db *sql.DB, table string) ([]column, error) {
|
||||||
// 1) DDL
|
q := fmt.Sprintf(`
|
||||||
var dummy, ddl string
|
SELECT a.attname,
|
||||||
if err := db.QueryRow(fmt.Sprintf("SHOW CREATE TABLE `%s`", name)).Scan(&dummy, &ddl); err != nil {
|
format_type(a.atttypid, a.atttypmod),
|
||||||
return tableDump{}, err
|
a.attnotnull,
|
||||||
}
|
COALESCE(a.attidentity, ''),
|
||||||
|
COALESCE(pg_get_expr(d.adbin, d.adrelid), '')
|
||||||
|
FROM pg_attribute a
|
||||||
|
LEFT JOIN pg_attrdef d ON d.adrelid = a.attrelid AND d.adnum = a.attnum
|
||||||
|
WHERE a.attrelid = %s::regclass AND a.attnum > 0 AND NOT a.attisdropped
|
||||||
|
ORDER BY a.attnum`, ql(qname(table)))
|
||||||
|
|
||||||
// 2) 数据
|
rows, err := db.Query(q)
|
||||||
rows, err := db.Query(fmt.Sprintf("SELECT * FROM `%s`", name))
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return tableDump{}, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
cols, err := rows.Columns()
|
var out []column
|
||||||
if err != nil {
|
|
||||||
return tableDump{}, err
|
|
||||||
}
|
|
||||||
n := len(cols)
|
|
||||||
td := tableDump{Name: name, Create: ddl, Columns: cols, Rows: make([][]any, 0)}
|
|
||||||
|
|
||||||
scanPtrs := make([]any, n)
|
|
||||||
raw := make([]sql.RawBytes, n)
|
|
||||||
for i := range raw {
|
|
||||||
scanPtrs[i] = &raw[i]
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
if err := rows.Scan(scanPtrs...); err != nil {
|
var c column
|
||||||
return tableDump{}, err
|
if err := rows.Scan(&c.Name, &c.Type, &c.NotNull, &c.Identity, &c.Default); err != nil {
|
||||||
|
return nil, err
|
||||||
}
|
}
|
||||||
row := make([]any, n)
|
c.AutoIncrement = c.Identity != "" || isSerial(c)
|
||||||
for i := range raw {
|
out = append(out, c)
|
||||||
if raw[i] == nil {
|
|
||||||
row[i] = nil // NULL
|
|
||||||
} else {
|
|
||||||
row[i] = string(raw[i]) // 全部按字符串搬运,导入时由驱动按列类型适配
|
|
||||||
}
|
|
||||||
}
|
|
||||||
td.Rows = append(td.Rows, row)
|
|
||||||
}
|
}
|
||||||
return td, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
// importRows 把一张表的数据批量 INSERT 进目标库(单表一个事务,每批多行,减少网络往返)。
|
// writeCreateTable 由列信息生成 CREATE TABLE。
|
||||||
func importRows(db *sql.DB, t tableDump) error {
|
//
|
||||||
if len(t.Rows) == 0 {
|
// 两类自增列统一归一为 GENERATED BY DEFAULT AS IDENTITY:
|
||||||
return nil
|
// - 原生 identity 列(attidentity 非空);
|
||||||
|
// - serial 列(默认值是 nextval,如 image_embeddings.id)。
|
||||||
|
//
|
||||||
|
// 归一后不必再单独导出 CREATE SEQUENCE —— identity 子句会自动建序列,
|
||||||
|
// 也避免了「默认值引用一个还不存在的序列」导致建表失败。
|
||||||
|
func writeCreateTable(w *bufio.Writer, table string, cols []column) {
|
||||||
|
fmt.Fprintf(w, "CREATE TABLE IF NOT EXISTS %s (\n", qname(table))
|
||||||
|
for i, c := range cols {
|
||||||
|
fmt.Fprintf(w, " %s %s", qi(c.Name), c.Type)
|
||||||
|
switch {
|
||||||
|
case c.AutoIncrement:
|
||||||
|
fmt.Fprint(w, " GENERATED BY DEFAULT AS IDENTITY")
|
||||||
|
case c.Default != "":
|
||||||
|
fmt.Fprintf(w, " DEFAULT %s", c.Default)
|
||||||
|
}
|
||||||
|
// identity 列本身即 NOT NULL,不重复声明。
|
||||||
|
if c.NotNull && !c.AutoIncrement {
|
||||||
|
fmt.Fprint(w, " NOT NULL")
|
||||||
|
}
|
||||||
|
if i < len(cols)-1 {
|
||||||
|
fmt.Fprint(w, ",")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
}
|
}
|
||||||
tx, err := db.Begin()
|
fmt.Fprintln(w, ");")
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeInsertData 用多行 INSERT 导出单表数据,返回行数。
|
||||||
|
//
|
||||||
|
// 值一律按服务端文本表示写成字面量:NULL 直接写 NULL,其余走 escapeLit。
|
||||||
|
// 这样既绕开了 COPY 的 copy 子协议限制,也让文件对人类可读、可手工改。
|
||||||
|
func writeInsertData(w *bufio.Writer, db *sql.DB, table string, cols []column) (int64, error) {
|
||||||
|
names := make([]string, len(cols))
|
||||||
|
for i, c := range cols {
|
||||||
|
names[i] = qi(c.Name)
|
||||||
|
}
|
||||||
|
colList := strings.Join(names, ", ")
|
||||||
|
|
||||||
|
rows, err := db.Query(fmt.Sprintf("SELECT %s FROM %s", colList, qname(table)))
|
||||||
|
if err != nil {
|
||||||
|
return 0, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
raw := make([]sql.RawBytes, len(cols))
|
||||||
|
ptrs := make([]any, len(cols))
|
||||||
|
for i := range raw {
|
||||||
|
ptrs[i] = &raw[i]
|
||||||
|
}
|
||||||
|
|
||||||
|
var count int64
|
||||||
|
var pending int // 当前这条 INSERT 已写入的行数
|
||||||
|
for rows.Next() {
|
||||||
|
if err := rows.Scan(ptrs...); err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
if pending == 0 {
|
||||||
|
fmt.Fprintf(w, "INSERT INTO %s (%s) VALUES\n", qname(table), colList)
|
||||||
|
} else {
|
||||||
|
fmt.Fprint(w, ",\n")
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, " (")
|
||||||
|
for i := range raw {
|
||||||
|
if i > 0 {
|
||||||
|
fmt.Fprint(w, ", ")
|
||||||
|
}
|
||||||
|
if raw[i] == nil {
|
||||||
|
fmt.Fprint(w, "NULL")
|
||||||
|
} else {
|
||||||
|
fmt.Fprint(w, escapeLit(string(raw[i])))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Fprint(w, ")")
|
||||||
|
|
||||||
|
pending++
|
||||||
|
count++
|
||||||
|
if pending == insertBatchRows {
|
||||||
|
fmt.Fprintln(w, ";")
|
||||||
|
pending = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return count, err
|
||||||
|
}
|
||||||
|
if pending > 0 {
|
||||||
|
fmt.Fprintln(w, ";")
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
return count, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeConstraints 导出主键等约束定义(pg_get_constraintdef 给出完整定义,无需自己拼)。
|
||||||
|
func writeConstraints(w *bufio.Writer, db *sql.DB, table string) error {
|
||||||
|
q := fmt.Sprintf(`
|
||||||
|
SELECT con.conname, pg_get_constraintdef(con.oid)
|
||||||
|
FROM pg_constraint con
|
||||||
|
WHERE con.conrelid = %s::regclass
|
||||||
|
ORDER BY CASE con.contype
|
||||||
|
WHEN 'p' THEN 1 WHEN 'u' THEN 2 WHEN 'c' THEN 3 WHEN 'x' THEN 4 WHEN 'f' THEN 5 ELSE 9
|
||||||
|
END,
|
||||||
|
con.conname`, ql(qname(table)))
|
||||||
|
|
||||||
|
rows, err := db.Query(q)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
colList := "`" + strings.Join(t.Columns, "`,`") + "`"
|
defer rows.Close()
|
||||||
ph := "(" + strings.TrimSuffix(strings.Repeat("?,", len(t.Columns)), ",") + ")"
|
|
||||||
const batchSize = 200
|
for rows.Next() {
|
||||||
for start := 0; start < len(t.Rows); start += batchSize {
|
var name, def string
|
||||||
end := start + batchSize
|
if err := rows.Scan(&name, &def); err != nil {
|
||||||
if end > len(t.Rows) {
|
|
||||||
end = len(t.Rows)
|
|
||||||
}
|
|
||||||
chunk := t.Rows[start:end]
|
|
||||||
var sb strings.Builder
|
|
||||||
sb.WriteString(fmt.Sprintf("INSERT INTO `%s` (%s) VALUES ", t.Name, colList))
|
|
||||||
args := make([]any, 0, len(chunk)*len(t.Columns))
|
|
||||||
for i, r := range chunk {
|
|
||||||
if i > 0 {
|
|
||||||
sb.WriteString(",")
|
|
||||||
}
|
|
||||||
sb.WriteString(ph)
|
|
||||||
args = append(args, r...)
|
|
||||||
}
|
|
||||||
if _, err := tx.Exec(sb.String(), args...); err != nil {
|
|
||||||
_ = tx.Rollback()
|
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
// 先 DROP CONSTRAINT IF EXISTS 再 ADD,让这一段幂等:
|
||||||
|
// 目标库若已存在同名约束(例如早先被 AutoMigrate 建过、或导入中断留下的),
|
||||||
|
// 不先删就会报 relation "<name>" already exists —— 约束底层的索引名在 schema 内唯一。
|
||||||
|
fmt.Fprintf(w, "ALTER TABLE ONLY %s DROP CONSTRAINT IF EXISTS %s;\n", qname(table), qi(name))
|
||||||
|
fmt.Fprintf(w, "ALTER TABLE ONLY %s ADD CONSTRAINT %s %s;\n", qname(table), qi(name), def)
|
||||||
}
|
}
|
||||||
return tx.Commit()
|
fmt.Fprintln(w)
|
||||||
|
return rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeIndexes 导出不承载约束的索引(含 pgvector 的 HNSW)。
|
||||||
|
//
|
||||||
|
// 排除承载约束的索引:主键索引已由 writeConstraints 以 ADD CONSTRAINT 的形式重建,
|
||||||
|
// 再 CREATE INDEX 一次会重复。
|
||||||
|
func writeIndexes(w *bufio.Writer, db *sql.DB, table string) error {
|
||||||
|
q := fmt.Sprintf(`
|
||||||
|
SELECT i.relname, pg_get_indexdef(i.oid)
|
||||||
|
FROM pg_index x
|
||||||
|
JOIN pg_class i ON i.oid = x.indexrelid
|
||||||
|
WHERE x.indrelid = %s::regclass
|
||||||
|
AND NOT EXISTS (SELECT 1 FROM pg_constraint con WHERE con.conindid = x.indexrelid)
|
||||||
|
ORDER BY i.relname`, ql(qname(table)))
|
||||||
|
|
||||||
|
rows, err := db.Query(q)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var name, def string
|
||||||
|
if err := rows.Scan(&name, &def); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w, "%s;\n", injectIfNotExists(def))
|
||||||
|
}
|
||||||
|
fmt.Fprintln(w)
|
||||||
|
return rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeSequenceResets 为每个自增列把序列推到 MAX(col)+1。
|
||||||
|
//
|
||||||
|
// INSERT 写入的是显式 id,不会推进序列;不重置的话,后续 INSERT 会从 1 开始并与存量主键冲突。
|
||||||
|
// setval 用 (值, false) 形式:false 表示「这个值还没被取走」,故下一次 nextval 正好是 max+1;
|
||||||
|
// 空表时为 1,即从 1 开始。
|
||||||
|
func writeSequenceResets(w *bufio.Writer, table string, cols []column) {
|
||||||
|
for _, c := range cols {
|
||||||
|
if !c.AutoIncrement {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Fprintf(w,
|
||||||
|
"SELECT pg_catalog.setval(pg_get_serial_sequence(%s, %s), COALESCE((SELECT MAX(%s) FROM %s), 0) + 1, false);\n",
|
||||||
|
ql(qname(table)), ql(c.Name), qi(c.Name), qname(table))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// isSerial 判断是否为 serial 列(默认值 nextval,且类型为整型)。
|
||||||
|
func isSerial(c column) bool {
|
||||||
|
if !strings.HasPrefix(c.Default, "nextval(") {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
switch c.Type {
|
||||||
|
case "bigint", "integer", "smallint":
|
||||||
|
return true
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// escapeLit 把值编码成 SQL 字符串字面量,用 E'...' 转义串语法。
|
||||||
|
//
|
||||||
|
// 选 E'...' 而不是普通 '...':普通字面量里反斜杠的含义取决于会话的
|
||||||
|
// standard_conforming_strings,而 E'...' 下反斜杠**总是**转义符,行为与设置无关。
|
||||||
|
// 因此只需处理反斜杠与单引号两个字符,任何文本(含换行、制表符、引号、反斜杠)都能安全往返。
|
||||||
|
func escapeLit(s string) string {
|
||||||
|
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||||
|
s = strings.ReplaceAll(s, `'`, `''`)
|
||||||
|
return "E'" + s + "'"
|
||||||
|
}
|
||||||
|
|
||||||
|
// injectIfNotExists 给 pg_get_indexdef 的输出补上 IF NOT EXISTS。
|
||||||
|
// pg_get_indexdef 不会输出它(那是 pg_dump 的清理语义),补上可让索引段落可重复执行。
|
||||||
|
func injectIfNotExists(def string) string {
|
||||||
|
if rest, ok := strings.CutPrefix(def, "CREATE UNIQUE INDEX "); ok {
|
||||||
|
return "CREATE UNIQUE INDEX IF NOT EXISTS " + rest
|
||||||
|
}
|
||||||
|
if rest, ok := strings.CutPrefix(def, "CREATE INDEX "); ok {
|
||||||
|
return "CREATE INDEX IF NOT EXISTS " + rest
|
||||||
|
}
|
||||||
|
return def
|
||||||
|
}
|
||||||
|
|
||||||
|
// qname 返回 schema 限定且带引号的表名:public."brands"。
|
||||||
|
func qname(table string) string { return "public." + qi(table) }
|
||||||
|
|
||||||
|
// qi 安全包裹 SQL 标识符(表名 / 列名 / 索引名)。
|
||||||
|
func qi(s string) string { return `"` + strings.ReplaceAll(s, `"`, `""`) + `"` }
|
||||||
|
|
||||||
|
// ql 安全包裹 SQL 字符串字面量(普通形式,用于表名/列名等受控内容)。
|
||||||
|
func ql(s string) string { return "'" + strings.ReplaceAll(s, "'", "''") + "'" }
|
||||||
|
|||||||
@ -31,6 +31,16 @@ import (
|
|||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// s4BaseURL 取 S4 对外访问域名:优先用上传器自动推导值(保证 free/vip 同 host),否则用配置值。
|
||||||
|
func s4BaseURL(cfgBase string, up *storage.S4Uploader) string {
|
||||||
|
if up != nil && up.Enabled() {
|
||||||
|
if b := up.PublicBaseURL(); b != "" {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cfgBase
|
||||||
|
}
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
configPath := flag.String("config", "", "配置文件路径,默认查找 configs/config.yml")
|
configPath := flag.String("config", "", "配置文件路径,默认查找 configs/config.yml")
|
||||||
flag.Parse()
|
flag.Parse()
|
||||||
@ -57,7 +67,9 @@ func main() {
|
|||||||
log.Printf("! 关闭数据库连接失败: %v", err)
|
log.Printf("! 关闭数据库连接失败: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
log.Println("✓ MySQL 已连接:", cfg.Database.Addr())
|
// 2.5 结构不再随启动自动迁移:全库「结构 + 索引 + 数据」统一由 cmd/dbtool 导出的
|
||||||
|
// 纯 SQL 维护(dbtool dump → psql -f)。这里只探活,不执行任何 DDL。
|
||||||
|
log.Println("✓ PostgreSQL 已连接:", cfg.Database.Addr())
|
||||||
|
|
||||||
// 3. 确保上传目录存在(静态文件服务的根目录)
|
// 3. 确保上传目录存在(静态文件服务的根目录)
|
||||||
uploadDir, _ := filepath.Abs(cfg.Upload.Dir)
|
uploadDir, _ := filepath.Abs(cfg.Upload.Dir)
|
||||||
@ -82,16 +94,21 @@ func main() {
|
|||||||
histRepo = repository.NewHistoryRepository(db)
|
histRepo = repository.NewHistoryRepository(db)
|
||||||
ingestRepo = repository.NewIngestRepository(db)
|
ingestRepo = repository.NewIngestRepository(db)
|
||||||
reviewRepo = repository.NewReviewRepository(db)
|
reviewRepo = repository.NewReviewRepository(db)
|
||||||
// 跨表图片引用计数(删除图集时判定七牛孤儿文件用)
|
// 跨表图片引用计数(删除图集时判定 S4 孤儿文件用)
|
||||||
mediaRepo = repository.NewMediaRepository(db)
|
mediaRepo = repository.NewMediaRepository(db)
|
||||||
|
|
||||||
jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
|
jwtManager = jwt.NewManager(cfg.JWT.Secret, cfg.JWT.ExpireHours)
|
||||||
|
|
||||||
// 图片 URL 拼装器:库里只存七牛 key,渲染时按 tier 拼 base_url + 样式。
|
// S4 上传器(凭证齐全才真正启用;否则为零值,图片回落本地兜底)。
|
||||||
imgComposer = imgurl.New(cfg.Qiniu.BaseURL, cfg.Qiniu.StyleNormal, cfg.Qiniu.StyleVip, cfg.Qiniu.LocalBase, cfg.Qiniu.AK, cfg.Qiniu.SK, cfg.Qiniu.SignTTLNormal, cfg.Qiniu.SignTTLHD)
|
s4Up = storage.NewS4Uploader(cfg.S4.AK, cfg.S4.SK, cfg.S4.Bucket, cfg.S4.Endpoint, cfg.S4.BaseURL, cfg.S4.Region)
|
||||||
|
|
||||||
// 图片存储:七牛云优先(若启用),失败兜底本地 uploads。
|
// 图片 URL 拼装器:库里只存 S4 key,渲染时拼 base_url + 展示样式(VIP 与免费同质量)。
|
||||||
// localUp 同时实现 Uploader 与 Deleter(本地兜底删除);启用七牛时两者均为七牛实现。
|
// base_url 优先取 S4 上传器自动推导值(保证展示图 URL 同源)。
|
||||||
|
// StyleThumb 供列表/卡片等小尺寸场景使用(详情页与大图仍走 StyleDisplay)。
|
||||||
|
imgComposer = imgurl.New(s4BaseURL(cfg.S4.BaseURL, s4Up), cfg.S4.StyleDisplay, cfg.S4.StyleThumb)
|
||||||
|
|
||||||
|
// 图片存储:缤纷云 S4 优先(若启用),失败兜底本地 uploads。
|
||||||
|
// localUp 同时实现 Uploader 与 Deleter(本地兜底删除);启用 S4 时两者均为 S4 实现。
|
||||||
localUp *storage.LocalUploader = storage.NewLocalUploader(uploadDir, cfg.Upload.URLPrefix)
|
localUp *storage.LocalUploader = storage.NewLocalUploader(uploadDir, cfg.Upload.URLPrefix)
|
||||||
imgUp storage.Uploader = localUp
|
imgUp storage.Uploader = localUp
|
||||||
imgDel storage.Deleter = localUp
|
imgDel storage.Deleter = localUp
|
||||||
@ -108,32 +125,30 @@ func main() {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// 爬虫入库:入队 + 异步 worker(去重 / 补季节码 / 下载图 / 写草稿表)
|
// 爬虫入库:入队 + 异步 worker(去重 / 补季节码 / 下载图 / 写草稿表)
|
||||||
// 图片存储:七牛云优先(若启用),失败兜底本地 uploads。
|
// 图片存储:缤纷云 S4 优先(若启用),失败兜底本地 uploads。
|
||||||
// imgUp / imgDel 已在上方 var 块声明,这里仅在启用七牛时切换为七牛实现。
|
// imgUp / imgDel 已在上方 var 块声明,这里仅在启用 S4 且上传器真正可用时切换为 S4 实现。
|
||||||
if cfg.Qiniu.Enabled {
|
if cfg.S4.Enabled && s4Up != nil && s4Up.Enabled() {
|
||||||
// 注意:上传器不传 base_url(末参传空),Upload 只返回七牛 key;
|
// Upload 只返回 S4 key(不拼 base_url),完整可访问 URL 由 imgComposer 在读取时按 tier 拼;
|
||||||
// 完整可访问 URL 由 imgComposer 在读取时按 tier 拼 base_url + 样式,
|
// free 走公开 CoreIX 样式、VIP 走预签名原图,既方便换域名又能做分级(见 internal/pkg/imgurl)。
|
||||||
// 这样既方便换 CDN 域名,又能做普通/高清分级(见 internal/pkg/imgurl)。
|
imgUp = s4Up
|
||||||
qu := storage.NewQiniuUploader(cfg.Qiniu.AK, cfg.Qiniu.SK, cfg.Qiniu.Bucket, cfg.Qiniu.Zone, "")
|
imgDel = s4Up
|
||||||
imgUp = qu
|
log.Printf("✓ 图片存储: 缤纷云 S4 bucket=%s base=%s(库只存 key,渲染时拼 base_url)", cfg.S4.Bucket, s4Up.PublicBaseURL())
|
||||||
imgDel = qu
|
|
||||||
log.Printf("✓ 图片存储: 七牛云 bucket=%s zone=%s(库只存 key,渲染时拼 base_url=%s)", cfg.Qiniu.Bucket, cfg.Qiniu.Zone, cfg.Qiniu.BaseURL)
|
|
||||||
} else {
|
} else {
|
||||||
log.Printf("✓ 图片存储: 本地 %s", cfg.Upload.URLPrefix)
|
log.Printf("✓ 图片存储: 本地 %s", cfg.Upload.URLPrefix)
|
||||||
}
|
}
|
||||||
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(七牛/本地删除器)。
|
// 爬虫入库服务:注入 mediaRepo(引用计数删孤儿)+ imgDel(S4/本地删除器)。
|
||||||
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理七牛」异步入队。
|
// 必须在 articleSvc / snapSvc 之前创建——图集服务删除时需调用它把「清理存储孤儿图」异步入队。
|
||||||
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp)
|
ingestSvc := service.NewIngestService(ingestRepo, brandRepo, mediaRepo, imgUp, imgDel, localUp)
|
||||||
// 图集服务:注入 ingestSvc 作为「清理七牛孤儿图」的入队器,使下架/删除异步触发存储清理。
|
// 图集服务:注入 ingestSvc 作为「清理孤儿图」的入队器,使下架/删除异步触发存储清理。
|
||||||
articleSvc = service.NewArticleService(articleRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
articleSvc = service.NewArticleService(articleRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
||||||
snapSvc = service.NewStreetSnapService(snapRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
snapSvc = service.NewStreetSnapService(snapRepo, mediaRepo, imgComposer, imgDel, ingestSvc)
|
||||||
|
|
||||||
engine := router.New(router.Options{
|
engine := router.New(router.Options{
|
||||||
Config: cfg,
|
Config: cfg,
|
||||||
JWT: jwtManager,
|
JWT: jwtManager,
|
||||||
Article: handler.NewArticleHandler(articleSvc),
|
Article: handler.NewArticleHandler(articleSvc, favSvc),
|
||||||
Brand: handler.NewBrandHandler(brandSvc),
|
Brand: handler.NewBrandHandler(brandSvc),
|
||||||
StreetSnap: handler.NewStreetSnapHandler(snapSvc),
|
StreetSnap: handler.NewStreetSnapHandler(snapSvc, favSvc),
|
||||||
Auth: handler.NewAuthHandler(authSvc),
|
Auth: handler.NewAuthHandler(authSvc),
|
||||||
Favorite: handler.NewFavoriteHandler(favSvc),
|
Favorite: handler.NewFavoriteHandler(favSvc),
|
||||||
History: handler.NewHistoryHandler(histSvc),
|
History: handler.NewHistoryHandler(histSvc),
|
||||||
|
|||||||
@ -20,19 +20,18 @@ server:
|
|||||||
|
|
||||||
database:
|
database:
|
||||||
host: 127.0.0.1 # env: DB_HOST
|
host: 127.0.0.1 # env: DB_HOST
|
||||||
port: 3306 # env: DB_PORT
|
port: 5432 # env: DB_PORT
|
||||||
user: root # env: DB_USER
|
user: fashion # env: DB_USER
|
||||||
password: root # env: DB_PASSWORD(兼容 DB_PASS)
|
password: fashion_dev_2026 # env: DB_PASSWORD(兼容 DB_PASS)
|
||||||
# 本地开发库名为 db_dev;docker-compose 部署时用环境变量覆盖为 fashionadmin
|
# 本地开发库名 fashion;对应 scripts/pgvector 的 Docker PostgreSQL 容器
|
||||||
name: db_dev # env: DB_NAME
|
name: fashion # env: DB_NAME
|
||||||
charset: utf8mb4
|
|
||||||
# GORM 日志级别:silent | error | warn | info
|
# GORM 日志级别:silent | error | warn | info
|
||||||
log_level: warn
|
log_level: warn
|
||||||
# 连接池
|
# 连接池
|
||||||
max_idle_conns: 10
|
max_idle_conns: 10
|
||||||
max_open_conns: 100
|
max_open_conns: 100
|
||||||
conn_max_lifetime: 3600 # 秒
|
conn_max_lifetime: 3600 # 秒
|
||||||
conn_max_idle_time: 60 # 秒,空闲连接回收时间;须 < MySQL wait_timeout,防止池子持有被回收的死连接导致 invalid connection
|
conn_max_idle_time: 60 # 秒,空闲连接回收时间,防止池子持有被服务端回收的死连接导致 invalid connection
|
||||||
|
|
||||||
jwt:
|
jwt:
|
||||||
# 生产环境务必用环境变量覆盖为随机长字符串
|
# 生产环境务必用环境变量覆盖为随机长字符串
|
||||||
@ -79,34 +78,31 @@ client_sign:
|
|||||||
|
|
||||||
# 爬虫入库管线:暴露 :8092 /admin/internal/ingest 接收爬虫 HMAC 签名上报。
|
# 爬虫入库管线:暴露 :8092 /admin/internal/ingest 接收爬虫 HMAC 签名上报。
|
||||||
# 设计:服务端到服务端(非前端 JS),密钥绝不下发前端、只存爬虫配置与 INGEST_SECRET,安全高。
|
# 设计:服务端到服务端(非前端 JS),密钥绝不下发前端、只存爬虫配置与 INGEST_SECRET,安全高。
|
||||||
# - enabled: false 时 ingest 路由整体不挂载(请求返回 503,等价于端点不存在)。
|
|
||||||
# - secret 必须与爬虫端 config.yml 的 ingest.secret 完全一致;env INGEST_SECRET 注入随机长串(生产)。
|
# - secret 必须与爬虫端 config.yml 的 ingest.secret 完全一致;env INGEST_SECRET 注入随机长串(生产)。
|
||||||
# - 依赖迁移 db/migrations/002_ingest.sql(建 ingest_jobs / ingest_nonces + brand_runway.idx_source_url)。
|
# - 建表不再由服务启动自动完成:表结构随全库由 cmd/dbtool 导出的 SQL 维护(dbtool dump → psql -f)。
|
||||||
# - 启用后 worker 随主进程拉起(cmd/server 内 goroutine),异步下载图、补 season_code、写正式表。
|
# - 启用后 worker 随主进程拉起(cmd/server 内 goroutine),异步下载图、补 season_code、写正式表。
|
||||||
ingest:
|
ingest:
|
||||||
enabled: true # env: INGEST_ENABLED(本地开发启用,接收爬虫上报;生产用环境变量注入随机 INGEST_SECRET)
|
|
||||||
secret: "dev-ingest-secret-2026" # env: INGEST_SECRET(须与爬虫 config.yml 的 ingest.secret 一致)
|
secret: "dev-ingest-secret-2026" # env: INGEST_SECRET(须与爬虫 config.yml 的 ingest.secret 一致)
|
||||||
ttl_seconds: 300 # 签名时间戳容忍窗口(秒,±5min),防重放;env: INGEST_TTL
|
ttl_seconds: 300 # 签名时间戳容忍窗口(秒,±5min),防重放;env: INGEST_TTL
|
||||||
|
|
||||||
# 七牛云对象存储(爬虫入库图片上传目标)
|
# 缤纷云 S4 对象存储(S3 兼容,爬虫入库图片上传目标)
|
||||||
# - enabled: true 时,worker 把下载的走秀/街拍图直传七牛 bucket,库里存完整 https URL
|
# - enabled: true 时,worker 把下载的走秀/街拍图直传 S4 bucket,库里只存 key(base_url 渲染时拼)
|
||||||
# - zone 必须匹配 bucket 创建区域,否则上传失败:z1=华北(你选择的区域)
|
# - bucket:存储桶名(须为「公开桶」,图片经 CoreIX 公开样式对外提供)
|
||||||
# - base_url 为 bucket 绑定的公开访问域名,用于拼出图片 URL(此处用七牛测试域名)
|
# - endpoint:S3 接入点,默认 https://s3.bitiful.net;region 任意(如 cn-east-1)
|
||||||
# - AK/SK 亦可用环境变量 QINIU_AK / QINIU_SK 覆盖(生产建议用环境变量注入)
|
# - base_url:对外访问域名;留空自动推导 https://<bucket>.s3.bitiful.net(务必与 endpoint 同 host)
|
||||||
qiniu:
|
# - ak/sk:缤纷云子账户 AccessKey/SecretKey(须有 PutObject/DeleteObject/GetObject 权限)
|
||||||
|
# - style_display:全量展示图 CoreIX 样式名(VIP 与免费用户同质量)。须在控制台建一个公开样式 high,
|
||||||
|
# 或填查询串(如 w=1080&q=80&fmt=webp)免建样式。代码含 "=" 即走查询串模式。
|
||||||
|
# 环境变量覆盖:S4_AK / S4_SK / S4_BUCKET / S4_ENDPOINT / S4_BASE_URL / S4_STYLE_DISPLAY / S4_STYLE_THUMB
|
||||||
|
# (旧 QINIU_* 仅 QINIU_STYLE_NORMAL 作 style_display 的兼容回退;S4_* 优先)
|
||||||
|
s4:
|
||||||
enabled: true
|
enabled: true
|
||||||
ak: "7GLHrN7BqrI9JnWZ9Pki2q5rqPazhIFroo19a-Av"
|
ak: "ur2YaIFZvySmjTIgGT1fxhAW"
|
||||||
sk: "gmg6PLgg666Cme-gsyTlsBLhshDv-6_zsEmW4jRY"
|
sk: "JUlfcQ4HNgO5eIzj9u7qaeQomqssvtI"
|
||||||
bucket: "toom-studio"
|
bucket: "toomstudio" # 你的缤纷云 S4 桶名
|
||||||
zone: "z1" # env: QINIU_ZONE
|
endpoint: "https://s3.bitiful.net"
|
||||||
base_url: "http://toom-studio.23cm.cn" # env: QINIU_BASE_URL(自定义域名;HTTPS 证书在七牛控制台配好后改 https 即可,无需改代码)
|
region: "cn-east-1"
|
||||||
style_normal: "free" # env: QINIU_STYLE_NORMAL(免费图七牛命名样式名,须与控制台一致;如 free / free.webp)
|
base_url: "" # 留空自动推导 https://toomstudio.s3.bitiful.net
|
||||||
style_vip: "vip" # env: QINIU_STYLE_VIP(VIP 高清七牛命名样式名,须与控制台一致;如 vip / vip.webp)
|
style_display: "high" # 高清公开样式名(须在缤纷云控制台建 public 样式 high);或填查询串 w=1080&q=80&fmt=webp
|
||||||
local_base: "" # env: QINIU_LOCAL_BASE(本地兜底前缀,通常空)
|
style_thumb: "thumb" # 列表缩略图公开样式名(控制台建 public 样式 thumb);仅用于列表/卡片等小尺寸场景,详情页与大图仍用 style_display。留空则回落 style_display
|
||||||
# 私有空间下载签名过期时间(秒):
|
|
||||||
# - sign_ttl_normal:普通/压缩图(含命名样式 -free),长过期便于 SSG 静态页与 CDN 缓存;默认 31536000(1 年)
|
|
||||||
# - sign_ttl_hd:VIP 高清原图,短过期(1 小时)使泄漏窗口小
|
|
||||||
# 前置:需将 bucket 在七牛控制台设为「私有」,并把 base_url 改为自定义 HTTPS 域名(当前测试域名打不开)。
|
|
||||||
sign_ttl_normal: 31536000 # env: QINIU_SIGN_TTL_NORMAL
|
|
||||||
sign_ttl_hd: 3600 # env: QINIU_SIGN_TTL_HD
|
|
||||||
|
|
||||||
|
|||||||
21
db/migrations/2026-09-21-01-street-main-detail.sql
Normal file
21
db/migrations/2026-09-21-01-street-main-detail.sql
Normal file
@ -0,0 +1,21 @@
|
|||||||
|
-- 2026-09-21-01 街拍主副图:草稿表 / 正式表的图片表各加两列。
|
||||||
|
--
|
||||||
|
-- 对应规格:docs/superpowers/specs/2026-09-21-street-main-detail-design.md §3.1
|
||||||
|
-- is_detail 0=主图 1=副图
|
||||||
|
-- parent_image_id 副图指向所属主图的「行 id」;主图为 0
|
||||||
|
-- (存行 id 而非序号:序号会因重排 / 插入 / 删除而失效)
|
||||||
|
--
|
||||||
|
-- 幂等:全部 ADD COLUMN IF NOT EXISTS,可重复执行。
|
||||||
|
-- 存量行为 0/0,即「全部按主图渲染」,无需数据迁移。
|
||||||
|
--
|
||||||
|
-- 应用后请重新导出结构,否则目标库拿不到新列:
|
||||||
|
-- ./bin/dbtool dump -clean -out db/backups/db_dump.sql
|
||||||
|
--
|
||||||
|
-- 说明:规格未给 parent_image_id 建索引。图片表当前规模为千行级(street_snap_draft_images
|
||||||
|
-- 1052 行),按 (draft_id) 过滤走顺序扫描即可;若日后量级变大需要,再按读侧实际查询补索引。
|
||||||
|
|
||||||
|
ALTER TABLE street_snap_draft_images ADD COLUMN IF NOT EXISTS is_detail smallint NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE street_snap_draft_images ADD COLUMN IF NOT EXISTS parent_image_id integer NOT NULL DEFAULT 0;
|
||||||
|
|
||||||
|
ALTER TABLE street_snap_images ADD COLUMN IF NOT EXISTS is_detail smallint NOT NULL DEFAULT 0;
|
||||||
|
ALTER TABLE street_snap_images ADD COLUMN IF NOT EXISTS parent_image_id integer NOT NULL DEFAULT 0;
|
||||||
49
db/migrations/2026-09-21-02-duplicate-review.sql
Normal file
49
db/migrations/2026-09-21-02-duplicate-review.sql
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
-- 2026-09-21-02 重复检测与审核展示:新建 image_duplicates 表 + 街拍正式表按文章做键。
|
||||||
|
--
|
||||||
|
-- 对应规格:docs/superpowers/specs/2026-09-21-duplicate-review-design.md §3.1 / §3.7
|
||||||
|
--
|
||||||
|
-- 幂等:CREATE TABLE / CREATE INDEX 全带 IF NOT EXISTS,ADD COLUMN 带 IF NOT EXISTS,
|
||||||
|
-- 可重复执行。
|
||||||
|
--
|
||||||
|
-- 应用后请重新导出结构:
|
||||||
|
-- ./bin/dbtool dump -clean -out db/backups/db_dump.sql
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- ① 重复图对表(读时预计算并落库,替代不可反查的 is_duplicate / dup_of 两列)
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- kind / owner_kind 等字符串列控制长度而不做 CHECK 约束:与项目既有表风格一致
|
||||||
|
-- (brand_runway_drafts.status 等也是裸 varchar),避免新增约束带来的迁移负担。
|
||||||
|
CREATE TABLE IF NOT EXISTS image_duplicates (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
kind varchar(16) NOT NULL, -- runway | street
|
||||||
|
-- 本方(「这张图」)
|
||||||
|
image_id integer NOT NULL, -- 图片行 id
|
||||||
|
owner_kind varchar(8) NOT NULL, -- draft | official
|
||||||
|
owner_id integer NOT NULL, -- 草稿 id 或正式表主键
|
||||||
|
-- 对手(「和它重复的那张」)
|
||||||
|
peer_image_id integer NOT NULL,
|
||||||
|
peer_owner_kind varchar(8) NOT NULL,
|
||||||
|
peer_owner_id integer NOT NULL,
|
||||||
|
created_at integer NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
-- 唯一索引:保证重复配对计算幂等(写入侧用 ON CONFLICT ... DO NOTHING)
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_image_dups_pair
|
||||||
|
ON image_duplicates (kind, owner_kind, image_id, peer_owner_kind, peer_image_id);
|
||||||
|
|
||||||
|
-- 辅助索引:读侧按草稿 / 专辑批量取数(审核详情页)
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_image_dups_owner
|
||||||
|
ON image_duplicates (kind, owner_kind, owner_id);
|
||||||
|
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- ② 街拍正式表:新增 source / source_url,供「一篇文章一张专辑」的实体键使用
|
||||||
|
-- ---------------------------------------------------------------------------
|
||||||
|
-- 用 not null default '' 而不是可空:NULL 在唯一索引里不参与比较,
|
||||||
|
-- 会造成「同键可重复插入」的漏洞。
|
||||||
|
ALTER TABLE street_snaps ADD COLUMN IF NOT EXISTS source varchar(32) NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE street_snaps ADD COLUMN IF NOT EXISTS source_url varchar(512) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- 必须是**部分**索引:存量行 source_url 为空串,全量唯一索引会因大量 ('','') 冲突
|
||||||
|
-- 而建索引失败。
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_source
|
||||||
|
ON street_snaps (source, source_url) WHERE source_url <> '';
|
||||||
27
db/migrations/2026-09-21-03-ingest-source-idempotency.sql
Normal file
27
db/migrations/2026-09-21-03-ingest-source-idempotency.sql
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
-- 2026-09-21-03 来源幂等:两张草稿表新增 source / source_url,并建部分唯一索引。
|
||||||
|
--
|
||||||
|
-- 对应规格:docs/superpowers/specs/2026-09-20-ingest-idempotency-design.md §3.1–3.2
|
||||||
|
--
|
||||||
|
-- 幂等:ADD COLUMN / CREATE INDEX 全带 IF NOT EXISTS,可重复执行。
|
||||||
|
--
|
||||||
|
-- 应用后请重新导出结构:
|
||||||
|
-- ./bin/dbtool dump -clean -out db/backups/db_dump.sql
|
||||||
|
--
|
||||||
|
-- 注意:同规格 §3.6(month 列与「实体键并入 month」)**已作废**——
|
||||||
|
-- 由 docs/superpowers/specs/2026-09-21-duplicate-review-design.md §3.7 取代
|
||||||
|
-- (街拍实体键改为 (source, source_url),不再按月区分)。故本脚本**不建 month 列**。
|
||||||
|
|
||||||
|
-- 用 not null default '' 而不是可空:NULL 在唯一索引里不参与比较,
|
||||||
|
-- 会造成「同键可重复插入」的漏洞。
|
||||||
|
ALTER TABLE brand_runway_drafts ADD COLUMN IF NOT EXISTS source varchar(32) NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE brand_runway_drafts ADD COLUMN IF NOT EXISTS source_url varchar(512) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE street_snap_drafts ADD COLUMN IF NOT EXISTS source varchar(32) NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE street_snap_drafts ADD COLUMN IF NOT EXISTS source_url varchar(512) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- 两个部分唯一索引(全量唯一索引会因存量大量 ('','') 冲突而建索引失败)。
|
||||||
|
-- 向后兼容:老爬虫不上送 source_url 时该列为空串,自动被索引豁免 —— 行为与改动前一致。
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_br_draft_source
|
||||||
|
ON brand_runway_drafts (source, source_url) WHERE source_url <> '';
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_draft_source
|
||||||
|
ON street_snap_drafts (source, source_url) WHERE source_url <> '';
|
||||||
54
db/migrations/2026-09-22-01-single-table-publish.sql
Normal file
54
db/migrations/2026-09-22-01-single-table-publish.sql
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
-- 单表发布模型(方案 2):取消草稿表,审核态由 status 承载,公开读走只读视图。
|
||||||
|
--
|
||||||
|
-- 本文件只做两件事:加列 / 建视图。幂等,可重复执行。
|
||||||
|
-- 存量行置已发布不在本文件里(它是只能执行一次的数据变更,见 2026-09-22-01b-publish-existing-rows.sql)。
|
||||||
|
-- 数据搬迁由一次性脚本 scripts/migrate_single_table/main.go 完成。该脚本已随本次改造从工作树删除
|
||||||
|
--(它 import 已删除的草稿模型,无法在改造后的代码树上编译);取回方式与执行顺序见本目录 README.md。
|
||||||
|
-- 删草稿表见 2026-09-22-05-drop-draft-tables.sql。
|
||||||
|
|
||||||
|
-- 1) 正式表补审核态与溯源列。默认 'pending' 是刻意的(fail-closed):
|
||||||
|
-- 任何漏赋值的行默认不可见,而不是意外对外发布。
|
||||||
|
ALTER TABLE brand_runways
|
||||||
|
ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE street_snaps
|
||||||
|
ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
-- 2) 存量行置已发布**不在本文件里**:它是数据变更且只能执行一次,见同目录
|
||||||
|
-- 2026-09-22-01b-publish-existing-rows.sql。
|
||||||
|
-- 为什么必须拆开:数据搬迁(scripts/migrate_single_table/main.go)会把 pending 草稿连原 created_at
|
||||||
|
-- 一起搬进正式表;若本文件含那条 UPDATE 且被重复执行(测试每次都会跑),
|
||||||
|
-- 这些待审内容会被误刷成已发布 —— 恰好是本设计要防的泄漏。
|
||||||
|
|
||||||
|
-- 3) 状态索引:审核列表与公开视图都按 status 过滤。
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_br_status ON brand_runways (status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_ss_status ON street_snaps (status);
|
||||||
|
|
||||||
|
-- 4) 公开只读视图:把「必须是已发布且未删除」固化成结构性保证,公开查询漏写过滤成为不可能。
|
||||||
|
-- 注意:视图列集被 SELECT * 冻结 —— 以后给这 4 张基表加列,必须同批重建视图。
|
||||||
|
DROP VIEW IF EXISTS public_brand_runway_images;
|
||||||
|
DROP VIEW IF EXISTS public_brand_runways;
|
||||||
|
DROP VIEW IF EXISTS public_street_snap_images;
|
||||||
|
DROP VIEW IF EXISTS public_street_snaps;
|
||||||
|
|
||||||
|
CREATE VIEW public_brand_runways AS
|
||||||
|
SELECT * FROM brand_runways WHERE status = 'published' AND is_deleted = 0;
|
||||||
|
|
||||||
|
CREATE VIEW public_brand_runway_images AS
|
||||||
|
SELECT i.* FROM brand_runway_images i
|
||||||
|
JOIN brand_runways r ON r.id = i.runway_id
|
||||||
|
WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0;
|
||||||
|
|
||||||
|
CREATE VIEW public_street_snaps AS
|
||||||
|
SELECT * FROM street_snaps WHERE status = 'published' AND is_deleted = 0;
|
||||||
|
|
||||||
|
CREATE VIEW public_street_snap_images AS
|
||||||
|
SELECT i.* FROM street_snap_images i
|
||||||
|
JOIN street_snaps s ON s.id = i.snap_id
|
||||||
|
WHERE i.is_deleted = 0 AND s.status = 'published' AND s.is_deleted = 0;
|
||||||
11
db/migrations/2026-09-22-01b-publish-existing-rows.sql
Normal file
11
db/migrations/2026-09-22-01b-publish-existing-rows.sql
Normal file
@ -0,0 +1,11 @@
|
|||||||
|
-- 一次性数据迁移:把存量正式行视为已发布。
|
||||||
|
--
|
||||||
|
-- ⚠️ 只执行一次;必须在部署本计划的新代码之前、且在一次性搬迁脚本
|
||||||
|
-- scripts/migrate_single_table/main.go 之前执行。
|
||||||
|
-- 该脚本已随本次改造从工作树删除(它 import 已删除的草稿模型);取回方式见本目录 README.md。
|
||||||
|
-- 为什么单独成文件而不放进可重复执行的 2026-09-22-01:
|
||||||
|
-- 数据搬迁会把 pending 草稿连同旧的 created_at 一起搬进正式表;
|
||||||
|
-- 若这条 UPDATE 可被重复执行(集成测试每次都会跑 01 号文件),
|
||||||
|
-- 这些待审内容会被误刷成 published —— 恰好是本设计要防的泄漏。
|
||||||
|
UPDATE brand_runways SET status = 'published' WHERE status = 'pending';
|
||||||
|
UPDATE street_snaps SET status = 'published' WHERE status = 'pending';
|
||||||
47
db/migrations/2026-09-22-03-entity-key-unique.sql
Normal file
47
db/migrations/2026-09-22-03-entity-key-unique.sql
Normal file
@ -0,0 +1,47 @@
|
|||||||
|
-- 实体键唯一索引:单表发布模型下「一个实体一行」的结构性保证。
|
||||||
|
--
|
||||||
|
-- 为什么必须有:旧流程靠晋升时的实体键 upsert 把同一实体的多条草稿并成一行(自愈)。
|
||||||
|
-- 单表之后没有 upsert 阶段,只剩入库时的「先查后插」;一次瞬时读失败或多实例并发,
|
||||||
|
-- 就会留下两行,且两行都会通过审核进入公开视图 —— 前台出现重复内容。
|
||||||
|
--
|
||||||
|
-- 部分索引(WHERE is_deleted = 0):与 RunwayEntityState / StreetSnapEntityState 的查询口径一致,
|
||||||
|
-- 软删的旧实体键不再占用,允许同实体重新入库。
|
||||||
|
|
||||||
|
-- 建索引前先体检:有重复则中止,避免索引创建失败留下半成品状态。
|
||||||
|
DO $$
|
||||||
|
DECLARE dup int;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO dup FROM (
|
||||||
|
SELECT brand_id, season_code, collection_type
|
||||||
|
FROM brand_runways WHERE is_deleted = 0
|
||||||
|
GROUP BY 1, 2, 3 HAVING count(*) > 1
|
||||||
|
) t;
|
||||||
|
IF dup > 0 THEN
|
||||||
|
RAISE EXCEPTION 'brand_runways 有 % 组重复实体键,请先人工合并再加唯一索引', dup;
|
||||||
|
END IF;
|
||||||
|
|
||||||
|
-- 口径已随任务 3c 的实体键细化同步为 (city, year, title):否则本文件在「同城同年多专题」
|
||||||
|
-- 的合法数据上不再幂等(重复执行会误报重复而中止)。
|
||||||
|
SELECT count(*) INTO dup FROM (
|
||||||
|
SELECT city, year, COALESCE(title, '')
|
||||||
|
FROM street_snaps WHERE is_deleted = 0
|
||||||
|
GROUP BY 1, 2, 3 HAVING count(*) > 1
|
||||||
|
) t;
|
||||||
|
IF dup > 0 THEN
|
||||||
|
RAISE EXCEPTION 'street_snaps 有 % 组重复实体键,请先人工合并再加唯一索引', dup;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_br_entity
|
||||||
|
ON brand_runways (brand_id, season_code, collection_type) WHERE is_deleted = 0;
|
||||||
|
|
||||||
|
-- 索引表达式必须与上面的体检、以及 StreetSnapEntityState 的查重口径**逐字一致**:
|
||||||
|
-- (city, year, COALESCE(title, ''))。早期版本这里误建成 (city, year) —— 比体检更窄,
|
||||||
|
-- 于是体检放行「同城同年不同专题」的合法数据后,建索引才抛原生 23505
|
||||||
|
-- (could not create unique index ... duplicate key)。COALESCE(title, '') 的取舍见 2026-09-22-04。
|
||||||
|
--
|
||||||
|
-- ⚠️ CREATE UNIQUE INDEX IF NOT EXISTS 只检查**索引名**是否存在:若库里已有一个同名的
|
||||||
|
-- 旧 (city, year) 索引,本句会静默跳过、留下错误结构(这正是它一度掩盖问题的原因)。
|
||||||
|
-- 纠正这类存量库由 2026-09-22-04 承担;全新库 / 重复执行由本文件承载最终表达式。
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_entity
|
||||||
|
ON street_snaps (city, year, COALESCE(title, '')) WHERE is_deleted = 0;
|
||||||
34
db/migrations/2026-09-22-04-street-entity-key-title.sql
Normal file
34
db/migrations/2026-09-22-04-street-entity-key-title.sql
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
-- 街拍实体键细化:由 (city, year) 改为 (city, year, title)。
|
||||||
|
--
|
||||||
|
-- ⚠️ 本文件是**幂等兜底**,最终表达式的常态承载在 2026-09-22-03(它已直接建同一表达式索引)。
|
||||||
|
-- 之所以必须保留:03 的 `CREATE UNIQUE INDEX IF NOT EXISTS` 只按**索引名**判断存在性,
|
||||||
|
-- 遇到「同名但口径更窄的旧 (city, year) 索引」会静默跳过、留下错误结构 ——
|
||||||
|
-- 只有本文件的 `DROP INDEX IF EXISTS` + 重建能纠正这类存量库。
|
||||||
|
-- 全新库上重跑本文件也只是 DROP 后原样重建,幂等无害。
|
||||||
|
--
|
||||||
|
-- 背景:同城同年可能有多个专题(如 London 2027 Day 2 / Day 3),原键把它们判为同一实体,
|
||||||
|
-- 导致唯一索引不允许共存、且入库查重把第二个专题当成重复直接放弃。
|
||||||
|
--
|
||||||
|
-- COALESCE(title, '') 是刻意的:唯一索引中 NULL 互不冲突,若直接用 title,
|
||||||
|
-- 两条 title 为 NULL 的行会被判为不同实体而共存。入库始终会写 title,
|
||||||
|
-- 这里只是把「NULL 也要参与唯一性」这件事钉死。
|
||||||
|
|
||||||
|
-- 建索引前先体检(与 03 同一口径 (city, year, COALESCE(title, ''))):
|
||||||
|
-- 直接建索引只会抛原生 23505,这里先给出可读的报错原因。
|
||||||
|
DO $$
|
||||||
|
DECLARE dup int;
|
||||||
|
BEGIN
|
||||||
|
SELECT count(*) INTO dup FROM (
|
||||||
|
SELECT city, year, COALESCE(title, '')
|
||||||
|
FROM street_snaps WHERE is_deleted = 0
|
||||||
|
GROUP BY 1, 2, 3 HAVING count(*) > 1
|
||||||
|
) t;
|
||||||
|
IF dup > 0 THEN
|
||||||
|
RAISE EXCEPTION 'street_snaps 有 % 组重复实体键,请先人工合并再加唯一索引', dup;
|
||||||
|
END IF;
|
||||||
|
END $$;
|
||||||
|
|
||||||
|
DROP INDEX IF EXISTS uq_ss_entity;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_entity
|
||||||
|
ON street_snaps (city, year, COALESCE(title, '')) WHERE is_deleted = 0;
|
||||||
10
db/migrations/2026-09-22-05-drop-draft-tables.sql
Normal file
10
db/migrations/2026-09-22-05-drop-draft-tables.sql
Normal file
@ -0,0 +1,10 @@
|
|||||||
|
-- 单表发布模型收尾:删除 4 张草稿表。
|
||||||
|
--
|
||||||
|
-- ⚠️ 执行前置条件:一次性搬迁脚本 scripts/migrate_single_table/main.go(已随本次改造从工作树删除,
|
||||||
|
-- 取回方式见本目录 README.md)已跑完且校验通过 —— 否则草稿数据会丢失。
|
||||||
|
-- 本迁移不可逆:执行前请确认 db/backups 有可用备份。
|
||||||
|
|
||||||
|
DROP TABLE IF EXISTS brand_runway_draft_images;
|
||||||
|
DROP TABLE IF EXISTS brand_runway_drafts;
|
||||||
|
DROP TABLE IF EXISTS street_snap_draft_images;
|
||||||
|
DROP TABLE IF EXISTS street_snap_drafts;
|
||||||
55
db/migrations/README.md
Normal file
55
db/migrations/README.md
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# 数据库迁移
|
||||||
|
|
||||||
|
本目录的 `.sql` 是**手工一次性迁移**(服务启动不做 DDL,结构由 `dbtool` 导出的 `db_dump.sql` 维护)。
|
||||||
|
命名规则 `日期-序号-主题.sql`,**按文件名顺序执行**。
|
||||||
|
|
||||||
|
- 全新库:直接用 `db_dump.sql` 建库(见根目录 `README.md`「数据库」一节),**不需要**跑这些迁移。
|
||||||
|
- 存量库升级:按下文顺序执行;大多语句幂等,但**数据变更类不可重放**(见各步说明)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、2026-09-21 系列(街拍主副图 / 重复检测 / 来源幂等)
|
||||||
|
|
||||||
|
| 顺序 | 文件 | 说明 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `2026-09-21-01-street-main-detail.sql` | 草稿表与正式图片表加 `is_detail` / `parent_image_id`。全 `IF NOT EXISTS`,幂等。 |
|
||||||
|
| 2 | `2026-09-21-02-duplicate-review.sql` | 新建 `image_duplicates`,街拍正式表加 `source` / `source_url` 及部分唯一索引 `uq_ss_source`。幂等。 |
|
||||||
|
| 3 | `2026-09-21-03-ingest-source-idempotency.sql` | 两张**草稿表**加 `source` / `source_url` 及部分唯一索引。幂等。这些列会随 2026-09-22-05 删草稿表一并消失。 |
|
||||||
|
|
||||||
|
这三步属于更早的改造;对已经过它们的库重放无害。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、2026-09-22 单表发布(草稿表 → 单表 + `status` + 公开只读视图)
|
||||||
|
|
||||||
|
**执行顺序:`01 → 01b → 03 → 04 → 搬迁脚本 → 05`**(与文件名排序一致:`01`、`01b`、`03`、`04`、`05`;搬迁脚本是一段独立命令,夹在 `04` 与 `05` 之间执行)
|
||||||
|
|
||||||
|
| 顺序 | 对象 | 前提 / 幂等 / 不可逆点 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `2026-09-22-01-single-table-publish.sql` | 给 `brand_runways` / `street_snaps` 加 `status` 等列、建状态索引、建 4 个 `public_*` 视图。**幂等**,可重复执行(集成测试每次都会跑它)。 |
|
||||||
|
| 2 | `2026-09-22-01b-publish-existing-rows.sql` | 把存量正式行置 `published`。**只能执行一次**;且必须在新代码部署**之前**、搬迁脚本**之前**执行。理由:搬迁会把 pending 草稿连同旧 `created_at` 一起搬进来,若这条 UPDATE 可重放,待审内容会被误刷成已发布(本设计要防的就是这个泄漏)。 |
|
||||||
|
| 3 | `2026-09-22-03-entity-key-unique.sql` | 实体键部分唯一索引 `uq_br_entity` / `uq_ss_entity`(街拍为最终表达式 `(city, year, COALESCE(title,''))`)。**幂等**;建索引前先体检,有重复就报可读错误而非原生 23505。 |
|
||||||
|
| 4 | `2026-09-22-04-street-entity-key-title.sql` | 街拍索引的**幂等兜底**:`DROP INDEX IF EXISTS uq_ss_entity` 后按最终表达式重建。常态由 03 承载;本文件专治「库里已有同名旧 `(city, year)` 索引、03 因 `IF NOT EXISTS` 静默跳过」的存量库。 |
|
||||||
|
| 5 | 搬迁脚本 `<见下>` | 把草稿表数据搬进正式表。**一次性**;先 `-dry-run` + 守恒校验,**校验不过绝不进入第 6 步**。 |
|
||||||
|
| 6 | `2026-09-22-05-drop-draft-tables.sql` | 删除 4 张草稿表。**不可逆**;前提是第 5 步校验通过,且 `db/backups/` 有可用备份。⚠️ 编号排在最后(`05`)是刻意的:按文件名顺序执行的运维者不会在搬迁之前误删草稿表。 |
|
||||||
|
|
||||||
|
### 搬迁脚本的取回方式
|
||||||
|
|
||||||
|
`scripts/migrate_single_table/main.go` 是一次性工具,已随本次改造从工作树删除(它 `import` 了同批删除的
|
||||||
|
草稿模型,无法在改造后的代码树上编译)。如需重放,从**它被删除前的最后一个提交**取回:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 取回脚本源码(c4bafc5 是删除提交 d68a570 的父提交,即脚本最后一次存在的版本)
|
||||||
|
git show c4bafc5:scripts/migrate_single_table/main.go
|
||||||
|
```
|
||||||
|
|
||||||
|
注意:脚本依赖草稿模型,必须在该提交(或更早)的代码树上运行,**不能**直接放进当前树。
|
||||||
|
真正需要重放时,建议 `git worktree add ../old c4bafc5` 切出旧树再跑。
|
||||||
|
|
||||||
|
### 索引口径的一个坑
|
||||||
|
|
||||||
|
`CREATE UNIQUE INDEX IF NOT EXISTS` 只按**索引名**判断存在性,不比对表达式。因此:
|
||||||
|
|
||||||
|
- 若库里已有同名但口径更窄的旧 `uq_ss_entity (city, year)`,03 会静默跳过、留下错误结构;
|
||||||
|
这时必须跑 04 纠正(04 的 `DROP` 正是为此)。
|
||||||
|
- 反过来,若只跑 03 而库里已存在正确的同名索引,跳过是无害的。
|
||||||
3774
db_dump.sql
Normal file
3774
db_dump.sql
Normal file
File diff suppressed because one or more lines are too long
1210
docs/superpowers/plans/2026-09-20-ingest-idempotency.md
Normal file
1210
docs/superpowers/plans/2026-09-20-ingest-idempotency.md
Normal file
File diff suppressed because it is too large
Load Diff
1446
docs/superpowers/plans/2026-09-21-street-main-detail.md
Normal file
1446
docs/superpowers/plans/2026-09-21-street-main-detail.md
Normal file
File diff suppressed because it is too large
Load Diff
292
docs/superpowers/specs/2026-09-20-ingest-idempotency-design.md
Normal file
292
docs/superpowers/specs/2026-09-20-ingest-idempotency-design.md
Normal file
@ -0,0 +1,292 @@
|
|||||||
|
# 入库幂等设计(来源级去重 + 街拍实体键引入月份)
|
||||||
|
|
||||||
|
- 日期:2026-09-20
|
||||||
|
- 状态:设计已确认,待编写实现计划
|
||||||
|
- 范围:
|
||||||
|
1. 爬虫入库管线(`ingest`)的**来源级幂等**(第 3.1–3.5 节)
|
||||||
|
2. 街拍实体键 `(city, year)` → `(city, year, month)`(第 3.6 节)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
### 1.1 现状:链路上现有四道去重,但没有一道能挡「同一篇文章重爬」
|
||||||
|
|
||||||
|
| 层 | 现有机制 | 能挡住 | 挡不住 |
|
||||||
|
| --- | --- | --- | --- |
|
||||||
|
| 传输 | `ingest_nonces` + HMAC 时间窗 | 同一请求被重放 | **爬虫重跑**(每次生成新 nonce) |
|
||||||
|
| 内容 | `downloadAndUpload` 的 sha1 内容寻址 key | 重复图产生孤儿文件 | 重复草稿行 |
|
||||||
|
| 实体 | `RunwayIDByEntity` / `StreetSnapIDByEntity` | — | 见 1.2 |
|
||||||
|
| 晋升 | `SaveRunwayFromDraft` / `SaveStreetSnapFromDraft` 聚合 approved 兄弟草稿图 | 重复正式行 | 只聚合 approved,pending 重复照留 |
|
||||||
|
|
||||||
|
### 1.2 「只判正式表」是刻意设计,不是缺陷
|
||||||
|
|
||||||
|
`processRunway` / `processStreet` 中的实体键去重**只查正式表、不查 pending 草稿**,注释写明了原因:
|
||||||
|
|
||||||
|
> 否则多来源(Vogue + theImpression)爬同一场秀时第二个来源会被误判重复而丢弃,破坏晋升阶段的图片聚合。
|
||||||
|
|
||||||
|
因此**不能**简单地把实体去重扩展到草稿表——那会连带干掉多来源聚合。
|
||||||
|
|
||||||
|
### 1.3 根因:设计里没有「来源文章」这个维度
|
||||||
|
|
||||||
|
来源级幂等键历史上存在过,且爬虫侧还有 `ExistsSourceURLs` 预检,但已被移除。爬虫代码中留有原文:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 历史上这里会调用后端 ExistsSourceURLs 预检、在抓取前跳过已爬图集以省流量;
|
||||||
|
// 现 source_url 已从 ingestion 管线移除,后端按「实体键(品牌+季节码+系列)」去重,
|
||||||
|
```
|
||||||
|
|
||||||
|
当前 `dto.RunwayIngest` 不含任何来源字段,后端无从判断「这篇是否已爬过」。
|
||||||
|
|
||||||
|
### 1.4 实测印证(来源维度缺失)
|
||||||
|
|
||||||
|
同一篇 theimpression 哥本哈根街拍,间隔 5 分钟上报两次(job 96 / job 98),产生 **2 条 pending 草稿**。
|
||||||
|
|
||||||
|
### 1.5 第二个独立缺陷:街拍 `year=0` 是一个「吸附桶」
|
||||||
|
|
||||||
|
街拍实体键为 `(city, year)`,而 theImpression 的 `parseYear` 取不到年份时**明确返回 0**:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// 提取不到时返回 0(未知年份),不再回退到「当前年份」——那会把旧街拍伪造成今年。
|
||||||
|
```
|
||||||
|
|
||||||
|
而 theImpression 标题实测**基本不含年份**(实测日志:`[Warning] 标题未含年份,按未知(0)处理: The Best Street Style From Copenhagen Fashion Week`)。
|
||||||
|
|
||||||
|
后果链:
|
||||||
|
|
||||||
|
1. 首次抓取产生 `(Copenhagen, 0)` 草稿。
|
||||||
|
2. 该草稿被审核通过、晋升到 `street_snaps` 后,实体键 `(Copenhagen, 0)` **永久占位**。
|
||||||
|
3. 此后**任何标题不含年份的 Copenhagen 街拍都会被判重、静默 `MarkDone` 丢弃**,不是延迟而是永久进不来。
|
||||||
|
4. 推广:**每个城市永远只能存在一张街拍专辑**。
|
||||||
|
|
||||||
|
此外,即使修掉 `year=0`,`(city, year)` 仍然无法区分同城一年内的两季时装周(如 2 月 FW / 8 月 SS)——因为命中实体键的行为是**跳过**而非**合并**,第二季会被整批丢弃。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 决策记录
|
||||||
|
|
||||||
|
| 决策点 | 结论 | 理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 重爬语义 | **纯跳过**:命中即整个任务 `MarkDone`,不下载、不写草稿 | 最省流量;有人工审核兜底,需补齐时后台手动重试 |
|
||||||
|
| 来源幂等键 | `(source, source_url)` | 来源站 + 文章详情页地址 |
|
||||||
|
| 「已存在」边界 | **任何状态都算**(含 `rejected`、含软删) | 用户明确:**爬过的文章不会更新**,故「爬过」即终态 |
|
||||||
|
| 检查位置 | **方案 1:worker 处理前查重** | 与现有「去重 → 命中即 MarkDone」模式同构;`202` 接口保持只做 `Enqueue` |
|
||||||
|
| 来源索引形态 | 部分唯一索引,`WHERE source_url <> ''` | 存量行 `source_url` 为空串,全量唯一索引会因 `('','')` 冲突导致**建索引失败 → 启动崩溃** |
|
||||||
|
| 街拍 `year` 来源 | **抓取时间**(不再回退 0) | 标题实测不含年份;0 会形成「每城永远一个专辑」的吸附桶(见 1.5) |
|
||||||
|
| 街拍 `month` | **进实体键** → `(city, year, month)` | `(city, year)` 仍会撞同城两季时装周;月粒度可避免「整季被丢弃」 |
|
||||||
|
| street 侧 `parseYear` | **删除**(失去调用方) | `year` 与 `month` 必须同源才自洽;「年份取标题、月份取抓取」会产出 `(Copenhagen, 2024, 9)` 这类无意义键 |
|
||||||
|
| URL 解析年份优先 | **不做** | URL 中虽含季节年份(如 `...-spring-2027/`),但形状依赖强、收益仅为精度提升,按 YAGNI 排除 |
|
||||||
|
| 历史测试数据 | **清理** | 用户确认:数据量小、全部为测试数据 |
|
||||||
|
|
||||||
|
### 2.1 已评估并否决的方案
|
||||||
|
|
||||||
|
- **方案 2(入队时查重)**:在 `Submit()` 中先查草稿表,命中即返回「已跳过」。好处是省队列槽位,但把 DB 查询塞进刻意做薄的 ingest 接口,且**引入 TOCTOU**——并发提交时双方都查不到、都入队,最终仍须靠唯一索引兜底。多一层代码,未换取正确性。
|
||||||
|
- **方案 3(只靠唯一索引)**:不做前置查询,靠 `INSERT` 冲突兜底。代价是**每次重爬都要把全部图片下载并上传一遍**才发现冲突,恰好浪费最想省下的那部分时间,直接违背目标。
|
||||||
|
- **爬虫侧预检(恢复 `ExistsSourceURLs`)**:可连文章页都不抓,但抓一个 HTML 页的成本与后台下载数十张图不在一个量级,收益太小且引入额外跨服务往返,按 YAGNI 排除。
|
||||||
|
- **街拍月份只做展示字段、不进键**:可减少专辑碎片化,但同一城市同一年的第二季时装周会被整批丢弃(见 1.5 第 4 点)。用户明确选择进键。
|
||||||
|
- **爬虫解析 URL 中的季节年份**:精度更高(`spring-2027` → 2027),但强依赖 URL 形状,按 YAGNI 排除。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 详细设计
|
||||||
|
|
||||||
|
### 3.1 契约:爬虫 → 后端
|
||||||
|
|
||||||
|
`internal/dto/ingest.go` 的 `RunwayIngest` 与 spider 侧 `spider/internal/ingest/payload.go` 的 `RunwayIngest` **两侧同时**新增三个字段,JSON 名必须逐字一致:
|
||||||
|
|
||||||
|
| 字段 | JSON 名 | 类型 | 含义 | 取值 |
|
||||||
|
| --- | --- | --- | --- | --- |
|
||||||
|
| `Source` | `source` | string | 来源站标识 | `vogue` / `theimpression` |
|
||||||
|
| `SourceURL` | `source_url` | string | 文章详情页地址 | 该篇 URL |
|
||||||
|
| `Month` | `month` | uint8 | 抓取月份(street 用) | 1–12;runway 不填 = 0 |
|
||||||
|
|
||||||
|
填充点:
|
||||||
|
|
||||||
|
- `spider/internal/spider/vogue.go`:`Source: "vogue"`,`SourceURL: requestURL`(即现有 `sourceURL(info)` 的返回值);`Month` 不填
|
||||||
|
- `spider/internal/spider/theimpression.go`:`Source: "theimpression"`,`SourceURL: tk.URL`(即现有 `streetTask.URL`);`Year` / `Month` 均取抓取时刻的 `time.Now()`
|
||||||
|
|
||||||
|
**向后兼容**:三个字段均可空。老爬虫不上送时 `source_url == ""`,来源去重自动跳过,行为与现状完全一致。
|
||||||
|
|
||||||
|
### 3.2 Schema
|
||||||
|
|
||||||
|
**(a)来源幂等:两张草稿主表各新增两列**
|
||||||
|
|
||||||
|
**使用 `not null default ''` 而非可空**——`NULL` 在唯一索引中不参与比较,可空会导致「同键可重复插入」,使索引形同虚设。
|
||||||
|
|
||||||
|
```sql
|
||||||
|
source varchar(32) not null default ''
|
||||||
|
source_url varchar(512) not null default ''
|
||||||
|
```
|
||||||
|
|
||||||
|
对应模型:`internal/model/runway_draft.go` 的 `BrandRunwayDraft`、`internal/model/street_snap_draft.go` 的 `StreetSnapDraft`。**模型上不加 `uniqueIndex` tag**(GORM tag 无法表达部分索引条件)。
|
||||||
|
|
||||||
|
> **落点已变更(2026-09-21)**:服务启动时的自动迁移(`AutoMigrate` / `EnsureDedupSchema`)已移除,
|
||||||
|
> 全库结构改由 `cmd/dbtool` 的 dump 维护。加列 / 加索引现在走 `db/migrations/` 下的一次性脚本:
|
||||||
|
> 对开发库执行 → 再 `dbtool dump -clean` 重新导出。本规格的列与索引已落在
|
||||||
|
> `db/migrations/2026-09-21-03-ingest-source-idempotency.sql`(已应用)。
|
||||||
|
|
||||||
|
索引定义(部分唯一索引——全量唯一索引会因存量大量 `('','')` 冲突而建索引失败):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_br_draft_source
|
||||||
|
ON brand_runway_drafts (source, source_url) WHERE source_url <> '';
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_draft_source
|
||||||
|
ON street_snap_drafts (source, source_url) WHERE source_url <> '';
|
||||||
|
```
|
||||||
|
|
||||||
|
两点取舍:
|
||||||
|
|
||||||
|
- **必须是部分索引**(`WHERE source_url <> ''`)。存量行 `source_url` 全为空串,全量唯一索引会因大量 `('','')` 冲突而建索引失败,进而导致服务**启动崩溃**。该条件同时让老数据豁免。
|
||||||
|
- **不加 `is_deleted` 条件**。按决策「爬过即终态」,若把软删排除在外,则删除草稿即变相绕过幂等。
|
||||||
|
|
||||||
|
**(b)街拍月份:两张街拍表各新增一列**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
month smallint not null default 0
|
||||||
|
```
|
||||||
|
|
||||||
|
对应模型:`model.StreetSnapDraft`、`model.StreetSnap`。由 `AutoMigrate` 托管,**不建索引**——现有 `(city, year)` 本就无索引,街拍表体量很小。
|
||||||
|
|
||||||
|
### 3.3 判定逻辑:worker 处理前查重
|
||||||
|
|
||||||
|
新增仓储方法(接口仍定义在消费方 `IngestRepository`,与项目现有做法一致):
|
||||||
|
|
||||||
|
```go
|
||||||
|
// SourceDraftExists 按来源键 (source, source_url) 判断该文章是否已入库过。
|
||||||
|
// sourceURL 为空时直接返回 false(老爬虫兼容)。不限 status、不限 is_deleted,
|
||||||
|
// 与 uq_*_draft_source 部分唯一索引口径严格一致(决策:爬过即终态)。
|
||||||
|
SourceDraftExists(ctx context.Context, kind, source, sourceURL string) (bool, error)
|
||||||
|
```
|
||||||
|
|
||||||
|
实现要点:
|
||||||
|
|
||||||
|
- `sourceURL == ""` → 立即返回 `false`(老爬虫兼容路径)。
|
||||||
|
- `kind` 非 `runway` / `street` → 返回 `false`(防御性;`process()` 实际只会传入这两者)。
|
||||||
|
- 否则按 `kind` 选择草稿表,执行 `WHERE source = ? AND source_url = ? LIMIT 1` 的存在性查询。
|
||||||
|
- `source` 为空但 `source_url` 非空时,仍按 `('', <source_url>)` 参与去重,不做特殊处理。
|
||||||
|
实际接入的两个爬虫都会同时上送两字段,此规则仅为消除歧义。
|
||||||
|
|
||||||
|
插入点在 `internal/service/ingest_service.go` 的 `process()` 中:解析 payload、归一化 `p.Kind` **之后**,`switch p.Kind` 分派 **之前**。此处是单一插入点,同时覆盖 runway / street 两条管线,且早于品牌校验与实体键去重。
|
||||||
|
|
||||||
|
命中后:按现有日志风格记录 + `MarkDone` + `return`,格式与现有实体去重日志对齐:
|
||||||
|
|
||||||
|
```
|
||||||
|
[ingest] job=%d %s 来源去重命中(source=%s url=%s),跳过 耗时=%v
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3.4 写入草稿时带上来源
|
||||||
|
|
||||||
|
`processRunway` / `processStreet` 构造 draft 时填充 `Source` / `SourceURL`。这是唯一索引真正生效的落点:多实例并发时两个任务的前置查询都查不到,第二个 `INSERT` 将撞上唯一索引。
|
||||||
|
|
||||||
|
### 3.5 错误处理:唯一冲突必须当「跳过」而非「失败」
|
||||||
|
|
||||||
|
`CreateRunwayDraft` / `CreateStreetSnapDraft` 返回唯一冲突时,**绝不能走 `failOrRetry`**——否则会按指数退避白重试 3 次,最终在后台堆出一批假故障任务。
|
||||||
|
|
||||||
|
正确做法:识别冲突(复用 `internal/repository/ingest_repository.go` 中现成的 `isDuplicateKey(err)`)→ 记录日志 → `MarkDone`,与 3.3 的前置查重共用同一条出口语义。
|
||||||
|
|
||||||
|
**冲突时无需回滚已上传的图**:两张草稿的图片 URL 完全相同,sha1 内容寻址会推出同一个对象 key,属覆盖写,不产生孤儿文件。此点须在代码注释中写明,避免后来人误加 `cleanupUploads`。
|
||||||
|
|
||||||
|
### 3.6 ~~街拍实体键引入月份~~(已作废)
|
||||||
|
|
||||||
|
> **本节已作废(2026-09-21)**,由 `2026-09-21-duplicate-review-design.md` §3.7 取代。
|
||||||
|
>
|
||||||
|
> 原设计把街拍实体键改为 `(city, year, month)`(同城同月自动收敛成一张专辑)。用户随后取消了「按月区分」与「合并成一张专辑」两项需求,改为**一篇文章一张专辑**,实体键变为 `(source, source_url)`。
|
||||||
|
>
|
||||||
|
> 影响:
|
||||||
|
>
|
||||||
|
> - 本节描述的全部改动**不再执行**(原实现计划的任务 6 已删除)。
|
||||||
|
> - 但 §3.1(契约新增 `source` / `source_url`)**仍然有效、且更关键**——这两个字段现在直接充当街拍实体键。
|
||||||
|
> - `Month` 字段不再需要(其唯一用途是月度分组):`month` 列、spider 的 `Month` 填充、`streetDraftEditable` 放行 `month`、列表副标题加月份等一并取消。
|
||||||
|
> - 街拍 `street_snaps` 正式表需新增 `source` / `source_url` 两列,见新规格 §3.7。
|
||||||
|
|
||||||
|
### 3.7 明确不动的部分
|
||||||
|
|
||||||
|
- **runway 侧**实体键去重 `RunwayIDByEntity` —— 保留原样(runway 有 `season_code`,不存在街拍这种月份问题)。它与来源去重**互补而非替代**。
|
||||||
|
- 晋升聚合的通用机制(sibling union)—— 除 3.6 第 4 处的 month 条件外不变。
|
||||||
|
- `Submit` / `Enqueue` —— 方案 1 刻意不碰,ingest 接口继续只做「入队 + 立即 202」。
|
||||||
|
- 传输层 nonce 防重放 —— 不变。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 影响面
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/dto/ingest.go 新增 Source / SourceURL / Month 三字段
|
||||||
|
internal/model/runway_draft.go BrandRunwayDraft 新增 source / source_url
|
||||||
|
internal/model/street_snap_draft.go StreetSnapDraft 新增 source / source_url / month
|
||||||
|
internal/model/street_snap.go StreetSnap 新增 month
|
||||||
|
internal/repository/ingest_repository.go 新增 SourceDraftExists;StreetSnapIDByEntity 加 month
|
||||||
|
internal/repository/review_repository.go SaveStreetSnapFromDraft / streetApprovedSiblingImages 加 month
|
||||||
|
internal/repository/review_repository.go streetDraftEditable 放行 month
|
||||||
|
internal/service/ingest_service.go process() 来源去重;两处填来源;processStreet 填 month
|
||||||
|
internal/service/review_service.go street 模块 Fields 加 month;卡片副标题加月份
|
||||||
|
internal/database/postgres.go EnsureDedupSchema 加两个部分唯一索引
|
||||||
|
../spider/internal/ingest/payload.go 新增三字段
|
||||||
|
../spider/internal/spider/vogue.go 填充 source / source_url
|
||||||
|
../spider/internal/spider/theimpression.go 填充 source / source_url / year / month;删除 parseYear
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 测试
|
||||||
|
|
||||||
|
### 5.1 单元测试(不依赖数据库)
|
||||||
|
|
||||||
|
- `source_url == ""` 时不执行来源去重(老爬虫兼容路径)。
|
||||||
|
- 命中已存在时:任务被 `MarkDone`,且**上传器一次都未被调用**(用计数 spy 上传器断言,构造方式参照现有 `TestFetchImagesCleansUpOnFailure`)。
|
||||||
|
- 唯一冲突路径:`CreateXxxDraft` 返回重复键错误时任务 `MarkDone`,且不触发重试(`attempts` 不变)。
|
||||||
|
- `processStreet` 写入草稿时 `month` 被正确透传。
|
||||||
|
|
||||||
|
### 5.2 集成测试(需 PostgreSQL)
|
||||||
|
|
||||||
|
- 并发两次插入同一 `(source, source_url)` 只保留一行。
|
||||||
|
- 存量空串行不影响索引创建,也不与他行冲突(验证部分索引生效)。
|
||||||
|
- 实体键含月份:`(city, year, month)` 不同月份不互相判重。
|
||||||
|
|
||||||
|
项目内已有 `dedup_integration_test.go` 可作参照。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 存量数据清理
|
||||||
|
|
||||||
|
### 6.1 盘点(2026-09-20 实测)
|
||||||
|
|
||||||
|
| 表 | 行数 | 内容 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `street_snap_drafts` | 3 | id 1/2/3 ← job 95/96/98,均 `Copenhagen / year=0`、同标题、各 59 图、pending |
|
||||||
|
| `street_snap_draft_images` | 177 | |
|
||||||
|
| `street_snaps` / `street_snap_images` | 0 | 街拍从未晋升 |
|
||||||
|
| `brand_runway_drafts` | 5 | id 88–92,全 approved,brand_id=7 |
|
||||||
|
| `brand_runways` | 5 | 与上表一一对应 |
|
||||||
|
| `brand_runway_draft_images` / `brand_runway_images` | 556 / 672 | |
|
||||||
|
| `ingest_jobs` | 96 | 队列历史 |
|
||||||
|
|
||||||
|
### 6.2 结论
|
||||||
|
|
||||||
|
用户确认全部为测试数据,**执行清理**。
|
||||||
|
|
||||||
|
清理方式:
|
||||||
|
|
||||||
|
1. 删除上表全部业务行(草稿主表 + 明细表 + 正式表 + 图片表)。按外键顺序或先删明细。
|
||||||
|
2. 清理后,原被引用的 S4 对象成为孤儿。复用既有机制收尾:由 `media_cleanup` 任务按引用计数判定,归零者才真删(`purgeOrphanImages`),不手写批量删除。
|
||||||
|
3. `ingest_jobs` 一并清空,取得干净的监控起点。
|
||||||
|
|
||||||
|
### 6.3 与 3.2 部分索引的相互作用
|
||||||
|
|
||||||
|
清理后存量行归零,`source_url` 全空串的情形不再存在——但**部分索引 `WHERE source_url <> ''` 仍然必须保留**。理由:它是「老爬虫不上送 `source_url`」这一兼容路径的正确性保证,而非仅为绕过历史数据;若将来再出现空串行,全量唯一索引会再次导致建索引失败。此点已在 3.2 说明,清理不改变该结论。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 待确认 / 开放问题
|
||||||
|
|
||||||
|
- 是否需要在后台任务列表、审核列表中展示 `source`(便于人工判断来源)?当前设计**未包含** UI 改动,按 YAGNI 暂缓。
|
||||||
|
|
||||||
|
## 8. 回滚
|
||||||
|
|
||||||
|
设计对既有行为基本是纯增量的,回滚成本低:
|
||||||
|
|
||||||
|
- 删掉两个部分唯一索引(`DROP INDEX IF EXISTS uq_br_draft_source` / `uq_ss_draft_source`)即恢复「无来源去重」的旧行为;相关列可保留不动(空闲列无害)。
|
||||||
|
- 街拍月份回滚需同时回退 3.6 表中 4 处落点(任一处漏改都会造成晋升/去重口径不一致),因此**该部分不建议单独部分回滚**,应整体回退到改动前版本。
|
||||||
|
- 爬虫侧若先于后端回滚,多送的字段会被后端 JSON 反序列化静默忽略,不会报错。
|
||||||
|
- 后端若先于爬虫回滚/部署,老爬虫不送 `source_url`,走兼容路径,同样不影响。
|
||||||
|
- 两侧因此**不存在必须同时发布的顺序约束**。
|
||||||
276
docs/superpowers/specs/2026-09-21-duplicate-review-design.md
Normal file
276
docs/superpowers/specs/2026-09-21-duplicate-review-design.md
Normal file
@ -0,0 +1,276 @@
|
|||||||
|
# 重复检测与审核展示设计(含街拍实体键改为按文章)
|
||||||
|
|
||||||
|
- 日期:2026-09-21
|
||||||
|
- 状态:设计已确认,待用户审阅规格
|
||||||
|
- 范围:
|
||||||
|
1. 「重复图对」表 `image_duplicates` + 审核页展示(列表给数量、详情给标记)
|
||||||
|
2. 街拍实体键由 `(city, year)` 改为 `(source, source_url)`——一篇文章一张专辑
|
||||||
|
- **不含**:街拍主副图(已确认设计与交互,另立规格)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景
|
||||||
|
|
||||||
|
### 1.1 现有的 `is_duplicate` / `dup_of` 为什么不能直接用
|
||||||
|
|
||||||
|
两者在入库时写入,但有两个硬缺陷:
|
||||||
|
|
||||||
|
**缺陷一:`dup_of` 是「不带表名的裸行 id」,无法反查归属。**
|
||||||
|
|
||||||
|
```go
|
||||||
|
DupOf: strconv.FormatUint(uint64(dupID), 10)
|
||||||
|
```
|
||||||
|
|
||||||
|
`FindNearDuplicateImage` 按「草稿表 → 正式表」顺序遍历,**命中哪张表就返回哪张表的 id**。拿到 `dup_of=264`,无法判断它是 `street_snap_draft_images` 的 264 还是 `street_snap_images` 的 264,更推不出它属于哪篇草稿。
|
||||||
|
|
||||||
|
**缺陷二:篇内重复完全检不到(结构性缺陷)。**
|
||||||
|
|
||||||
|
草稿图片是**全部下载完之后一次性批量插入**的(`CreateStreetSnapDraftImages` 在 `fetchImages` 返回后才调用),而 `dedupImage` 是每张图下载后立刻查库——**那一刻本篇的行还不存在**,所以同一篇文章内部的近似重复根本没有比较对象。
|
||||||
|
|
||||||
|
**实测证据**(当前 5 篇街拍,1052 张图):
|
||||||
|
|
||||||
|
| 指标 | 值 |
|
||||||
|
| --- | --- |
|
||||||
|
| `is_duplicate = 1` 的行数 | **3** |
|
||||||
|
| 实际 hamming ≤ 10 的对数 | **10**(其中**篇内 6 对,一条都没标**) |
|
||||||
|
|
||||||
|
篇内漏标的典型:行 id `1150`/`1151`(同一篇内前后相邻的两张,连拍),hamming = 5——这类最该被剔除的,入库标记完全看不见。
|
||||||
|
|
||||||
|
### 1.2 阈值实测(`phash.DefaultThreshold = 10` 明显过松)
|
||||||
|
|
||||||
|
对 1052 张图做两两汉明距离统计(`L2² == 汉明距离`):
|
||||||
|
|
||||||
|
| 阈值 | 命中对数 | 效果 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| ≤4 | **0** | — |
|
||||||
|
| ≤5 | 1 | 唯一的真候选(行 1150/1151,篇内相邻) |
|
||||||
|
| ≤8 | 1 | 与 ≤5 相同 |
|
||||||
|
| ≤9 | 4 | 开始混入噪声 |
|
||||||
|
| **≤10(现值)** | **10** | 混入 9 对噪声 |
|
||||||
|
|
||||||
|
距离分布(低尾):
|
||||||
|
|
||||||
|
```
|
||||||
|
hamming | pairs
|
||||||
|
5 | 1
|
||||||
|
9 | 3
|
||||||
|
10 | 6
|
||||||
|
11 | 7
|
||||||
|
12 | 13
|
||||||
|
13 | 40
|
||||||
|
14 | 85
|
||||||
|
...
|
||||||
|
平均 31.45 ≈ 32(64 位随机期望)
|
||||||
|
```
|
||||||
|
|
||||||
|
**关键判定**:9、10、11 三档的计数(3、6、7)平滑接进 12、13、40、85……**没有分离的簇**,说明 9~11 就是噪声分布的肩部。用户实际查看后确认:hamming = 10 的跨篇对(行 264 ↔ 1021)**目视完全不同**,是假阳性。
|
||||||
|
|
||||||
|
**根因**:dHash 只有 9×8 采样格、64 位,而这批图全是「竖构图的街头全身人像」,整体构图高度雷同,描述子被构图主导、内容差异被压缩。(描述子升级为更大网格 dHash / pHash-DCT 的方案已评估,另立规格处理。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 决策记录
|
||||||
|
|
||||||
|
| 决策点 | 结论 | 理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 重复检测机制 | **读时预计算并落库到 `image_duplicates` 表**,不依赖 `is_duplicate`/`dup_of` | 现有字段无法反查归属且漏篇内重复(见 1.1) |
|
||||||
|
| 计算时机 | **草稿图片写完之后的 worker 里** | 入库每张图时本篇行还不存在,篇内比不到(见 1.1 缺陷二) |
|
||||||
|
| 计算算法 | 对新草稿每张图,用现成的 `_phash_hnsw` 索引做 LATERAL 近邻查询 | 复杂度 O(新增图数 × log N),优于全表 O(n²) 自连接 |
|
||||||
|
| 比对范围 | **同 kind 的「草稿表 + 正式表」都比** | 用户选定:要能知道「这张图已经上线过了」 |
|
||||||
|
| 晋升镜像 | **跳过 `image` 相同的对** | 草稿晋升后同一张图在草稿表与正式表各存一行、phash 相同(hamming 0),那是镜像不是重复 |
|
||||||
|
| 阈值 | **4** | 用户指定。写入时应用(见 3.3) |
|
||||||
|
| 是否存汉明距离 | **不存** | 用户指定。代价:改阈值需重跑配对计算——但**只读库里的 phash,不需重新下图**,比换描述子便宜一个量级 |
|
||||||
|
| 列表页展示 | **不展示**(用户取消了「列表显示重复数量」这一需求) | 用户指定 |
|
||||||
|
| 详情页展示 | 给重复图**打标记** + 显示对手属于哪篇文章 | 用户指定 |
|
||||||
|
| 街拍实体键 | **`(source, source_url)`**——一篇文章一张专辑 | 用户指定;月/年不再参与区分 |
|
||||||
|
| 合并成一张专辑 | **取消** | 用户取消 |
|
||||||
|
| `month` 进实体键 | **取消** | 用户取消按月区分 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 详细设计
|
||||||
|
|
||||||
|
### 3.1 新表 `image_duplicates`
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE TABLE IF NOT EXISTS image_duplicates (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
kind varchar(16) NOT NULL, -- runway | street
|
||||||
|
-- 本方(「这张图」)
|
||||||
|
image_id integer NOT NULL, -- 图片行 id(在 *_draft_images 或 *_images 中,由 owner_kind 决定)
|
||||||
|
owner_kind varchar(8) NOT NULL, -- draft | official
|
||||||
|
owner_id integer NOT NULL, -- 草稿 id 或正式表主键(brand_runways.id / street_snaps.id)
|
||||||
|
-- 对手(「和它重复的那张」)
|
||||||
|
peer_image_id integer NOT NULL,
|
||||||
|
peer_owner_kind varchar(8) NOT NULL,
|
||||||
|
peer_owner_id integer NOT NULL,
|
||||||
|
created_at integer NOT NULL DEFAULT 0
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_image_dups_pair
|
||||||
|
ON image_duplicates (kind, owner_kind, image_id, peer_owner_kind, peer_image_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_image_dups_owner
|
||||||
|
ON image_duplicates (kind, owner_kind, owner_id);
|
||||||
|
```
|
||||||
|
|
||||||
|
唯一索引保证重复计算幂等;辅助索引服务读侧(按草稿批量取数)。
|
||||||
|
|
||||||
|
**不冗余存对手的标题**:读时按 `peer_owner_kind` join 对应表取(草稿 → `*_drafts.title_en` / `.title`;正式 → `brand_runways.title_en` / `street_snaps.title`)。避免标题被改后出现陈旧副本。
|
||||||
|
|
||||||
|
> **落点已变更(2026-09-21)**:`AutoMigrate` / `EnsureDedupSchema` 已移除,结构改由 `cmd/dbtool` 的 dump 维护。
|
||||||
|
> 本节的表与索引已落在 `db/migrations/2026-09-21-02-duplicate-review.sql`(已应用)。
|
||||||
|
> 今后加表 / 加索引一律走 `db/migrations/` 的一次性脚本 → 对开发库执行 → `dbtool dump -clean` 重新导出。
|
||||||
|
|
||||||
|
### 3.2 写入时机与算法
|
||||||
|
|
||||||
|
**时机**:`processRunway` / `processStreet` 内,`Create*DraftImages` **成功之后**。失败仅记录日志、**不影响任务状态**(重复信息是审核辅助,不该让入库任务失败);漏算可由回填命令补齐(3.6)。
|
||||||
|
|
||||||
|
**算法**:对本草稿的每一张图,用 `_phash_hnsw` 索引(`vector_l2_ops`)在对手池中取近邻。对手池 = 同 kind 的「草稿表 UNION 正式表」。
|
||||||
|
|
||||||
|
```
|
||||||
|
L2 阈值 = sqrt(4) = 2.0 -- 与 phash 口径一致:phash 为 {0,1}^64,L2² == 汉明距离
|
||||||
|
跳过条件:对手行的 image 与本方相同(晋升镜像)
|
||||||
|
```
|
||||||
|
|
||||||
|
结果以 `INSERT ... ON CONFLICT (kind, owner_kind, image_id, peer_owner_kind, peer_image_id) DO NOTHING` 入库——表内不存汉明距离,故命中已存在的对时**无需更新**,DO NOTHING 即幂等。
|
||||||
|
|
||||||
|
### 3.3 阈值
|
||||||
|
|
||||||
|
`4`,作为**配置项**(不入库汉明距离,故阈值在写入时生效):
|
||||||
|
|
||||||
|
```
|
||||||
|
dup.hamming_threshold: 4
|
||||||
|
```
|
||||||
|
|
||||||
|
**改阈值的代价**:需重跑配对计算。因为只读库里的 `phash`、不需要重新下载图片,这是一条纯数据库批处理(3.6 的回填命令可直接复用)。
|
||||||
|
|
||||||
|
### 3.4 读侧:审核列表 —— **不做**
|
||||||
|
|
||||||
|
用户已取消「审核列表显示重复数量」这一需求:**列表页不加任何重复相关列**,`DraftCard` 也不增加字段。
|
||||||
|
|
||||||
|
因此 `image_duplicates` 的读侧只有**一处**消费方——审核详情页(见 3.5)。列表页保持原样。
|
||||||
|
|
||||||
|
> 说明:曾实现过一版读时自连接 + 列表列的过渡方案,因该需求取消而**整体移除**。详情页标记改由本规格的 `image_duplicates` 表实现。
|
||||||
|
|
||||||
|
### 3.5 读侧:审核详情
|
||||||
|
|
||||||
|
`DraftImageRef` 增加一个字段:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// DupPeers 与该图重复的对手;为空表示不重复。
|
||||||
|
// 对手可能多于一个(同一张图在多篇文章里都出现过),故用切片而非 bool。
|
||||||
|
type DupPeer struct {
|
||||||
|
OwnerKind string // draft | official
|
||||||
|
Title string // 对手所属文章 / 专辑标题
|
||||||
|
}
|
||||||
|
|
||||||
|
type DraftImageRef struct {
|
||||||
|
// ...既有字段...
|
||||||
|
DupPeers []DupPeer
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
「是否重复」直接由 `len(DupPeers) > 0` 判定,**不单设 bool**(避免两者不一致)。
|
||||||
|
|
||||||
|
详情模板对有对手的图加角标(复用现有 `.badge` 视觉语言,新增 `.badge.dup`),角标含对手文章标题;对手多于一个时全部列出。
|
||||||
|
|
||||||
|
对手文章标题的取法:按 `peer_owner_kind` 分别 join,读时组装。
|
||||||
|
|
||||||
|
### 3.6 存量回填
|
||||||
|
|
||||||
|
现有需回填规模:
|
||||||
|
|
||||||
|
| 表 | 总行 | 存活行 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `brand_runway_images` | 672 | 556 |
|
||||||
|
| `brand_runway_draft_images` | 556 | 556 |
|
||||||
|
| `street_snap_images` | 0 | 0 |
|
||||||
|
| `street_snap_draft_images` | 1052 | 1052 |
|
||||||
|
|
||||||
|
去重后 **1608 张不同图**(走秀 556 张在两表重复出现)。
|
||||||
|
|
||||||
|
提供一个**可中断、可重入**的回填命令:按 kind 与 owner 分批处理,重复执行不产生重复行(靠唯一索引),支持从任意位置续跑。
|
||||||
|
|
||||||
|
### 3.7 街拍实体键改为按文章
|
||||||
|
|
||||||
|
街拍实体键由 `(city, year)` 改为 **`(source, source_url)`**,即一篇文章一张专辑。落点与 `2026-09-20-ingest-idempotency-design.md` §3.6 所列的 4 处一致,但键的构成改变:
|
||||||
|
|
||||||
|
| # | 落点 | 改动 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 1 | `ingest_repository.StreetSnapIDByEntity` | 改为按 `(source, source_url)` 查正式表 |
|
||||||
|
| 2 | `ingest_service.processStreet` | 调用点同步 |
|
||||||
|
| 3 | `review_repository.SaveStreetSnapFromDraft` | 正式行查找改为按 `(source, source_url)` |
|
||||||
|
| 4 | `review_repository.streetApprovedSiblingImages` | **对街拍变为空操作**(per-article 后不存在「兄弟草稿」) |
|
||||||
|
|
||||||
|
⚠️ `street_snaps` 正式表需新增 `source` / `source_url` 两列(与草稿表同构),否则晋升时无法按该键 upsert。同时补一个与草稿表同形的**部分唯一索引**,从数据库层面保证同一篇文章不会产生两张正式专辑:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
CREATE UNIQUE INDEX IF NOT EXISTS uq_ss_source
|
||||||
|
ON street_snaps (source, source_url) WHERE source_url <> '';
|
||||||
|
```
|
||||||
|
|
||||||
|
必须是部分索引,理由与 `2026-09-20-ingest-idempotency-design.md` §3.2 相同:存量行 `source_url` 为空串,全量唯一索引会因大量 `('','')` 冲突导致**建索引失败 → 启动崩溃**。
|
||||||
|
|
||||||
|
**关于 `month` 与 `year`**(用户只说「不按月区分」,未明确字段去留,此处为裁决):
|
||||||
|
|
||||||
|
- **不做 `month`**:它此前的唯一用途是月度分组,该需求已取消(YAGNI)。
|
||||||
|
- **保留「year 取抓取年份」**(spider 侧 2 行改动):`year=0` 是错误数据,且公开 API 支持按 year 过滤,`year=0` 的条目无法被正常筛选。注意实体键改为按文章后,`year=0` 已**不再造成静默丢数据**(原 `(city, 0)` 吸附桶问题随键变更消失)。
|
||||||
|
|
||||||
|
### 3.8 明确不动的部分
|
||||||
|
|
||||||
|
- `is_duplicate` / `dup_of` 两列**保留写入但不读**(纯历史留痕)。不删列,避免破坏性迁移。
|
||||||
|
- 入库时的 `dedupImage` 逻辑不变。
|
||||||
|
- runway 的 `(brand_id, season_code, collection_type)` 实体键与 sibling union **不变**(多来源聚合对走秀仍有意)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 影响面
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/model/image_duplicate.go 新增(模型)
|
||||||
|
db/migrations/2026-09-21-02-duplicate-review.sql 建表与索引(已应用)
|
||||||
|
internal/repository/dup_repository.go 新增:写入 + 读取 + 回填
|
||||||
|
internal/repository/ingest_repository.go StreetSnapIDByEntity 改键
|
||||||
|
internal/repository/review_repository.go SaveStreetSnapFromDraft / streetApprovedSiblingImages 改键
|
||||||
|
internal/service/ingest_service.go 草稿写入后触发配对计算;processStreet 调用点
|
||||||
|
internal/service/review_service.go DraftImageRef 加重复对手字段(仅详情用)
|
||||||
|
internal/handler/backstage_handler.go 详情页加重复标记
|
||||||
|
internal/model/street_snap.go +source / +source_url
|
||||||
|
cmd/dupbackfill(新) 存量回填命令
|
||||||
|
configs/config.yml +dup.hamming_threshold
|
||||||
|
../spider/internal/spider/theimpression.go year 改取抓取年份
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 测试
|
||||||
|
|
||||||
|
### 5.1 单元测试(不依赖数据库)
|
||||||
|
|
||||||
|
- 配对写入会**跳过 `image` 相同的对**(晋升镜像)。
|
||||||
|
- 详情渲染:重复图被标记,并显示对手文章标题;无对手的图**不显示标记**。
|
||||||
|
|
||||||
|
### 5.2 集成测试(需 PostgreSQL)
|
||||||
|
|
||||||
|
- 唯一索引生效:同一对重复写入只留一行。
|
||||||
|
- 阈值语义:hamming 恰为 4 的对入表、为 5 的不入表。
|
||||||
|
- 回填命令可重入:连跑两次行数不变。
|
||||||
|
- 街拍实体键:不同 `source_url` 的两篇文章晋升后是**两张**正式专辑;相同 `source_url` 是**一张**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 对既有规格与计划的影响
|
||||||
|
|
||||||
|
| 对象 | 影响 |
|
||||||
|
| --- | --- |
|
||||||
|
| `2026-09-20-ingest-idempotency-design.md` §3.6 | **作废**(月/年进键的部分)。§3.1–3.5(来源幂等)**仍然有效**,且其 `source` / `source_url` 字段正好成为街拍新实体键 |
|
||||||
|
| `2026-09-20-ingest-idempotency.md`(实现计划)**任务 6** | **删除**(实体键改动移入本规格) |
|
||||||
|
| 同上,任务 5(spider 填 source/source_url) | **保留**,但 `Month` 字段的填充与 `parseYear` 删除需按本规格 §3.7 调整 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 7. 待确认 / 开放问题
|
||||||
|
|
||||||
|
1. **阈值 4 会让当前数据命中 0 对**,审核页该列为空。用户已指定 4;因它是配置项,改成 5 只改一行配置 + 重跑回填。
|
||||||
|
2. 街拍主副图(含公开 API 分组化)**另立规格**,不在本文档范围。
|
||||||
|
3. `image_duplicates` 的生命周期:草稿被软删 / 单图被审核删除后,表中会残留指向失效行 id 的记录。当前设计**读时靠 join `is_deleted = 0` 过滤**,不做主动清理;若日后数据量增大再考虑级联清理。
|
||||||
168
docs/superpowers/specs/2026-09-21-street-main-detail-design.md
Normal file
168
docs/superpowers/specs/2026-09-21-street-main-detail-design.md
Normal file
@ -0,0 +1,168 @@
|
|||||||
|
# 街拍主副图设计
|
||||||
|
|
||||||
|
- 日期:2026-09-21
|
||||||
|
- 状态:设计已确认,待用户审阅规格
|
||||||
|
- 范围:街拍图增加「主图 / 副图」分组——审核页人工指定、详情页按组折叠、公开 API 保守扩展
|
||||||
|
- **不含**:重复检测(见 `2026-09-21-duplicate-review-design.md`)、来源幂等(见 `2026-09-20-ingest-idempotency-design.md`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与目标
|
||||||
|
|
||||||
|
**现状**:街拍图是扁平列表。`street_snap_draft_images` / `street_snap_images` 都**没有** `is_detail` 之类字段,而走秀侧(`brand_runway_draft_images` / `brand_runway_images`)**已经有**「主图 + 细节图」模型(`is_detail` + `look_index`)。
|
||||||
|
|
||||||
|
**动机**(用户原话):一个人可能被拍多张,平铺会很占位置。
|
||||||
|
|
||||||
|
**为什么只能人工指定**:源站不提供任何人物/分组信息。实测 theImpression 一篇文章:
|
||||||
|
|
||||||
|
| 项 | 结果 |
|
||||||
|
| --- | --- |
|
||||||
|
| `<figure>` 数量 | 167(就是街拍图本身) |
|
||||||
|
| `<figcaption>` 数量 | **0** |
|
||||||
|
| 街拍图的 `alt` | **全空**(`alt=""`) |
|
||||||
|
| 非空 `alt` | 仅 11 个,且全是侧栏广告与无关标题(如 `Mugler Fall 2026 Ad Campaign`) |
|
||||||
|
|
||||||
|
(文件名含摄影师帧号如 `milano-str-f26-0007-1`,帧号接近可能是连拍,但对「同一个人不同时间被拍」无效,不采用。)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 决策记录
|
||||||
|
|
||||||
|
| 决策点 | 结论 | 理由 |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| 主副如何确定 | **人工在审核页指定** | 源站无人物信息;用户选择纯人工 |
|
||||||
|
| 交互方式 | **不引入 JavaScript**:两阶段「先选定主图 → 勾选图片并入」+ 每张图的「并入上一张」快捷 | 后台目前**零 JS**(全仓 `internal/handler` 无 `<script>`/`fetch`,全是同步表单 POST);且拖拽需同屏看见起止点,无法处理「第 200 张是第 1 张的副图」这类跨屏场景(用户已指出该问题) |
|
||||||
|
| 当前主图的保持方式 | 通过 **URL 查询参数 `?main=<imgID>`** 传递 | 零 JS 下无状态、可刷新、可后退、可加书签 |
|
||||||
|
| 归属关系存储 | `is_detail` + **`parent_image_id`**(存所属主图的**行 id**,不存序号) | 序号会因重排/插入/删除而失效,存 id 稳定 |
|
||||||
|
| 详情页渲染 | **按组折叠** | 用户选定 |
|
||||||
|
| 公开 API | **保守扩展**:保留现有扁平 `images` 字段不变,**新增**分组字段 | 直接改结构会打断前端;新增字段让前端择期迁移 |
|
||||||
|
| `image_count` 语义 | **不变**(仍计全部图) | 改语义会同时改动公开列表卡片的「N 张」,需要前端同步,另议 |
|
||||||
|
| runway 侧 | 不受影响 | 已有自己的主/细节模型 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 详细设计
|
||||||
|
|
||||||
|
### 3.1 数据模型
|
||||||
|
|
||||||
|
`street_snap_draft_images` 与 `street_snap_images` 各新增两列:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
is_detail smallint not null default 0 -- 0=主图 1=副图
|
||||||
|
parent_image_id integer not null default 0 -- 副图指向所属主图的「行 id」;主图为 0
|
||||||
|
```
|
||||||
|
|
||||||
|
**不变量**:`is_detail = 1` 的行,其 `parent_image_id` 必须指向**同一草稿 / 同一专辑内**一张 `is_detail = 0` 的行。
|
||||||
|
|
||||||
|
> **落点已变更(2026-09-21)**:`AutoMigrate` / `EnsureDedupSchema` 已移除,结构改由 `cmd/dbtool` 的 dump 维护。
|
||||||
|
> 上述两列已落在 `db/migrations/2026-09-21-01-street-main-detail.sql`(已应用)。
|
||||||
|
|
||||||
|
- **写入侧**保证该不变量(并入操作先校验主图存在且属于同一 owner)。
|
||||||
|
- **读侧**容错:父行不存在(或已被软删)时,把该副图**按主图渲染**,不隐藏、不报错。
|
||||||
|
|
||||||
|
### 3.2 审核页交互(零 JS)
|
||||||
|
|
||||||
|
**两个同步表单 POST 路由**(全部返回 302 回详情页,与后台现有风格一致):
|
||||||
|
|
||||||
|
| 路由 | 作用 |
|
||||||
|
| --- | --- |
|
||||||
|
| `POST /admin/reviews/:kind/:id/images/attach` | 表单含单选 `main=<主图行id>` + 多个勾选 `img=<副图行id>` → **合并选中为一组** |
|
||||||
|
| `POST /admin/reviews/:kind/:id/images/:img/detach` | **拆出**:把副图恢复为主图(`is_detail=0, parent_image_id=0`) |
|
||||||
|
|
||||||
|
**审核页布局**:街拍草稿详情页渲染为**一个统一图片网格**(与列表页同款 `.grid` / `.cell` 卡片样式,不再有"分组大卡片 + 小网格"两套风格):
|
||||||
|
|
||||||
|
- 每张图片一张卡片:缩略图 + 角色徽标 + 文件名。
|
||||||
|
- **主图**卡片:徽标「主图 · N 副图」+ 勾选框「选入」+ 单选「主图」。
|
||||||
|
- **副图**卡片:徽标「副图 → #主图」(显示沿链归并后的**实际**所属主图,非原始父 id)+ 勾选框「选入」+ 按钮「拆出」。
|
||||||
|
- 每张卡片都带「删除」。
|
||||||
|
- 底部常驻(sticky)按钮条:`合并选中为一组`。
|
||||||
|
|
||||||
|
**操作只有一件事**:勾选属于同一个人的图片 → 在**其中一张主图**上点「主图」(单选)→ 点底部「合并选中为一组」(`main`=单选值,`img`=全部勾选值)。一次请求成组,无需刷新页面选主图。
|
||||||
|
|
||||||
|
> **历史(已废弃)**:早期版本用 URL 参数 `?main=` 承载"当前主图",并有一个按 `sort_order` 找"上一行"的 `attach-prev` 快捷。实践中发现**同一人的图往往不相邻**,该快捷用不上;且"先刷新选主图 → 再勾选 → 再提交"步骤过多。已改为本节的**单选主图 + 一次合并**,并删除 `attach-prev` 路由、`AttachPrevStreetDraftImage`、`?main=` 参数与 `DraftDetailView.MainID`。
|
||||||
|
|
||||||
|
### 3.3 详情页渲染(按组折叠)
|
||||||
|
|
||||||
|
**审核页**:见 §3.2——统一图片网格;主图卡片徽标显示「N 副图」,副图卡片显示所属主图。
|
||||||
|
|
||||||
|
**公开详情页**:`Images` 只含主图,副图挂在每张主图的 `Detail` 子数组下(与走秀详情同形,见 §3.5)。
|
||||||
|
|
||||||
|
- 未分组的图按主图渲染(默认 `is_detail=0`,因此**存量数据无需迁移即可正常显示**)。
|
||||||
|
|
||||||
|
### 3.4 晋升到正式表:必须重建父引用
|
||||||
|
|
||||||
|
`SaveStreetSnapFromDraft` 需要同步复制 `is_detail` / `parent_image_id`。
|
||||||
|
|
||||||
|
⚠️ **关键陷阱**:草稿晋升会**软删正式表旧图并整批重建**,新插入的正式图行拿到的是**全新的行 id**。因此**不能直接复制 `parent_image_id`**——旧 id 指向的是草稿表的行。必须先建立「草稿行 id → 新正式行 id」的映射,再把副图的 `parent_image_id` 改写为新 id。
|
||||||
|
|
||||||
|
漏掉这一步会导致:副图的父引用指向一个不存在(或属于别的图)的 id,详情页折叠结构错乱。
|
||||||
|
|
||||||
|
### 3.5 公开 API(与走秀详情同形)
|
||||||
|
|
||||||
|
**决策(2026-09-21 修订)**:采用与走秀详情**完全一致**的嵌套结构(`images` 只含主图 + 每张主图自带 `detail`),**不采用**「保留扁平 `images` + 新增平行 `groups`」的保守方案。
|
||||||
|
|
||||||
|
```go
|
||||||
|
type PublicStreetSnapDetail struct {
|
||||||
|
UID string
|
||||||
|
Title string
|
||||||
|
Cover string // 封面:独立字段,与主/副图分组无关
|
||||||
|
Images []PublicArticleImage // 只含主图(is_detail=0);每张主图的 .Detail 挂其副图
|
||||||
|
Favorited bool
|
||||||
|
}
|
||||||
|
// 复用走秀的 PublicArticleImage:
|
||||||
|
// IsDetail 0=主图 / 1=副图
|
||||||
|
// LookIndex 该图归属的主图序号(街拍侧合成:主图按 sort_order 的 1-based 序;街拍不落库组序号)
|
||||||
|
// Detail []PublicArticleImage —— 该主图下的副图
|
||||||
|
```
|
||||||
|
|
||||||
|
- 与走秀详情(`PublicArticleService.Detail`)同形,前端可复用同一套渲染组件。
|
||||||
|
- `parent_image_id` 指向「副图」时沿链向上归并到该副图所在组的主图;父行缺失 / 成环 → 该图按主图渲染(**不丢图**)。这是与走秀读侧的刻意区别:走秀会丢弃「找不到主图的细节图」,街拍不丢。
|
||||||
|
- **列表**(`PublicStreetSnap`)不动;`image_count` 语义不变(仍计全部图)。
|
||||||
|
|
||||||
|
> ⚠️ **破坏性变更**:`Images` 含义从「全部图(扁平)」变为「只含主图」。前端若原样遍历 `Images` 只会看到主图,需改读每张主图的 `.Detail`(跨仓库,本次只保证后端结构)。
|
||||||
|
|
||||||
|
### 3.6 明确不做
|
||||||
|
|
||||||
|
- **算法自动分组**(用户选择纯人工)。注:phash 只能判「画面像」,判不了「是不是同一个人」,做建议必然有误报;日后若要加,也只应作为提示。
|
||||||
|
- **拖拽交互**(需引入 JS;且跨屏场景不可行)。
|
||||||
|
- **修改 `image_count` 语义**。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 影响面
|
||||||
|
|
||||||
|
```
|
||||||
|
internal/model/street_snap_draft.go +is_detail / +parent_image_id
|
||||||
|
internal/model/street_snap.go +is_detail / +parent_image_id
|
||||||
|
internal/repository/review_repository.go attach / attach-prev / detach 三个写方法
|
||||||
|
internal/repository/review_repository.go SaveStreetSnapFromDraft 重建父引用
|
||||||
|
internal/service/review_service.go DraftImageRef 加分组信息;分组写入的校验
|
||||||
|
internal/handler/backstage_handler.go 审核页「图片流」渲染 + 常驻主图条 + 三个表单
|
||||||
|
internal/router/backstage.go 两个新路由(attach 合并 / detach 拆出)
|
||||||
|
internal/dto/street_snap.go 详情改为嵌套(images 只含主图,复用 PublicArticleImage)
|
||||||
|
internal/service/street_snap_service.go 详情组装嵌套 images(buildSnapImages)
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 测试
|
||||||
|
|
||||||
|
### 5.1 单元测试(不依赖数据库)
|
||||||
|
|
||||||
|
- 详情组装:主图 + 其副图归到同一 group;未分组的图各自成组。
|
||||||
|
- 父引用失效的容错:`parent_image_id` 指向不存在/已软删的行时,该图按主图渲染,不丢图。
|
||||||
|
|
||||||
|
### 5.2 集成测试(需 PostgreSQL)
|
||||||
|
|
||||||
|
- 并入:`is_detail` / `parent_image_id` 正确写入;重复并入同一主图幂等。
|
||||||
|
- 拆出:恢复为主图且 `parent_image_id` 归零。
|
||||||
|
- 跨 owner 拦截:主图与副图不属于同一草稿时拒绝写入(不变量)。
|
||||||
|
- **晋升重建父引用**:草稿有「1 主 + 2 副」时晋升,正式表中 2 张副图的 `parent_image_id` 必须指向**新的**主图行 id(而非草稿表的旧 id)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 6. 待确认
|
||||||
|
|
||||||
|
1. ~~公开 API 保守方案~~ **已决(2026-09-21)**:改为与走秀同形的嵌套结构(`images` 只含主图、副图挂 `detail`),见 §3.5。
|
||||||
|
2. `image_count` 暂不改语义(仍计全部图)。若希望改成「只算主图」(与 runway 一致,含义变成「N 个人」),需要前端同步,另议。
|
||||||
|
3. 分组的「组序号」不落库,由读时按 `sort_order` 顺序推导(主图顺序即组序)。
|
||||||
258
docs/superpowers/specs/2026-09-22-single-table-publish-design.md
Normal file
258
docs/superpowers/specs/2026-09-22-single-table-publish-design.md
Normal file
@ -0,0 +1,258 @@
|
|||||||
|
# 单表发布模型(取消草稿表)设计
|
||||||
|
|
||||||
|
- 日期:2026-09-22
|
||||||
|
- 替代关系:本设计**取代** `2026-09-20-ingest-idempotency-design.md` 中"草稿表持有 source/source_url 做来源幂等"的部分(该能力从未落到代码,列与索引为空转);并**吸收** `2026-09-21-street-main-detail-design.md` 的主副图能力(从草稿图片迁移到正式图片表,父引用不再需要重映射)。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 背景与问题
|
||||||
|
|
||||||
|
现状是「草稿表 → 人工审核 → 晋升写入正式表」的两套表结构:
|
||||||
|
|
||||||
|
| 模块 | 草稿 | 正式 |
|
||||||
|
|---|---|---|
|
||||||
|
| 走秀 / article | `brand_runway_drafts` + `brand_runway_draft_images` | `brand_runways` + `brand_runway_images` |
|
||||||
|
| 街拍 | `street_snap_drafts` + `street_snap_draft_images` | `street_snaps` + `street_snap_images` |
|
||||||
|
|
||||||
|
由此产生的复杂度:
|
||||||
|
|
||||||
|
1. **结构重复**:内容字段(标题/描述/年份/季节/cover/image_count)在两张表各写一份;正式图片表与草稿图片表字段几乎相同(`is_detail`/`look_index`/`phash`/`is_duplicate`/`dup_of` 是双份)。
|
||||||
|
2. **晋升即重建**:`SaveRunwayFromDraft` / `SaveStreetSnapFromDraft` 会软删正式图片再整批重建,**图片行 id 每次都变** —— 外层业已存在"重复发布打断单图收藏"的隐患(`favorites.target_uid` 存的是图片的 hashid)。
|
||||||
|
3. **父引用重映射**:街拍副图的 `parent_image_id` 存的是草稿表行 id,晋升时要两遍插入并改写为新正式行 id,父行缺失还要降级。
|
||||||
|
4. **多来源聚合**:`sibling` 图片聚合(按实体键把多个已通过草稿的图并成一条正式记录)只在晋升时发生,逻辑绕。
|
||||||
|
5. **死物**:`brand_runway_drafts.source/source_url`、`street_snaps.source/source_url`、`image_duplicates`、`image_embeddings` 全仓无代码读写。
|
||||||
|
|
||||||
|
核心判断:**草稿表承担的两件事(审核态、来源追溯)都只是一个字段,不值得一整张表。**
|
||||||
|
|
||||||
|
## 2. 目标 / 非目标
|
||||||
|
|
||||||
|
### 目标
|
||||||
|
- 每个模块**只剩一张记录表 + 一张图片表**。
|
||||||
|
- 审核态由 `status` 字段承载,**审核阶段保留**(不是取消审核,是取消"另一张表")。
|
||||||
|
- 公开可见性从"每个公开查询各自记得过滤"升级为**结构性保证**(只读视图)。
|
||||||
|
- 图片行 id 从入库起**终身不变**(收藏不再被打断)。
|
||||||
|
- 删除晋升/聚合/父引用重映射的全部代码。
|
||||||
|
|
||||||
|
### 非目标(本次不做)
|
||||||
|
- 不改公开 API 的响应结构(`images` / `groups` / 列表字段全部保持)。
|
||||||
|
- 不改前台网站(独立前端仓库)。
|
||||||
|
- 不合并"审核页"与"正式编辑页"两个后台入口(后续可再收敛)。
|
||||||
|
- 不统一 `image_count` 口径(runway 计主图 / street 计全部图,现状保留,见 §12)。
|
||||||
|
- 不修 `favorites` 的唯一索引问题(见 §12)。
|
||||||
|
|
||||||
|
## 3. 数据模型
|
||||||
|
|
||||||
|
### 3.1 保留 / 删除的表
|
||||||
|
|
||||||
|
保留:`brand_runways`、`brand_runway_images`、`street_snaps`、`street_snap_images`、`brands`(品牌本就没有草稿表,不参与本次改造)。
|
||||||
|
|
||||||
|
删除:`brand_runway_drafts`、`brand_runway_draft_images`、`street_snap_drafts`、`street_snap_draft_images`。
|
||||||
|
|
||||||
|
### 3.2 新增列(从草稿表搬到正式表)
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE brand_runways
|
||||||
|
ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';
|
||||||
|
|
||||||
|
ALTER TABLE street_snaps
|
||||||
|
ADD COLUMN IF NOT EXISTS status varchar(16) NOT NULL DEFAULT 'pending',
|
||||||
|
ADD COLUMN IF NOT EXISTS job_id bigint NOT NULL DEFAULT 0,
|
||||||
|
ADD COLUMN IF NOT EXISTS reviewer varchar(64) NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN IF NOT EXISTS reject_reason varchar(255) NOT NULL DEFAULT '';
|
||||||
|
```
|
||||||
|
|
||||||
|
**默认值刻意用 `pending`(fail-closed)**:任何新增/遗漏赋值的行都会默认"不可见",而不是默认"已发布"。
|
||||||
|
|
||||||
|
图片表无需改动:
|
||||||
|
- runway 的细节图归属用 `look_index`;
|
||||||
|
- 街拍的副图归属用 `is_detail` + `parent_image_id`,单表之后它天然指向**本表**行 id,**永远有效,不需要任何重映射**。
|
||||||
|
|
||||||
|
### 3.3 状态取值
|
||||||
|
|
||||||
|
```go
|
||||||
|
// internal/model/content_status.go(新增)
|
||||||
|
const (
|
||||||
|
StatusPending = "pending" // 待审核(入库默认;公开不可见)
|
||||||
|
StatusPublished = "published" // 已发布(公开可见)
|
||||||
|
StatusRejected = "rejected" // 已驳回(公开不可见,留在库中备查)
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
审核列表的「已通过」tab 取值由 `approved` 改为 `published`(模板 `review-list.html` 的 tab 链接同步改)。
|
||||||
|
|
||||||
|
## 4. 状态机
|
||||||
|
|
||||||
|
```
|
||||||
|
ingest 入库
|
||||||
|
│
|
||||||
|
▼
|
||||||
|
┌─────────┐ 通过(写 reviewer,应用字段编辑) ┌───────────┐
|
||||||
|
│ pending │ ────────────────────────────────► │ published │
|
||||||
|
└─────────┘ └───────────┘
|
||||||
|
│ │
|
||||||
|
│ 驳回(写 reviewer + reject_reason) │ 驳回(下架)
|
||||||
|
▼ │
|
||||||
|
┌──────────┐ ◄──────────────────────────────────────┘
|
||||||
|
│ rejected │
|
||||||
|
└──────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **通过**:`UPDATE ... SET status='published', reviewer=?` + 应用表单里的字段编辑。**不再触碰图片行**。
|
||||||
|
- **驳回**:`UPDATE ... SET status='rejected', reviewer=?, reject_reason=?`。行保留(备查)。
|
||||||
|
- **驳回后重爬**:`rejected → pending`(复用同一行,见 §5 第 1 条第 2 支)。驳回不是永久黑名单。
|
||||||
|
- 每条记录**只有一个实体键行**(runway: `brand_id+season_code+collection_type`;street: `city+year`),不存在"同实体的多条 pending 并存"。
|
||||||
|
|
||||||
|
## 5. 写入链路(ingest 直写正式表)
|
||||||
|
|
||||||
|
`internal/service/ingest_service.go` 的 `processRunway` / `processStreet`:
|
||||||
|
|
||||||
|
1. **实体键查重**(`RunwayIDByEntity` / `StreetSnapIDByEntity`,`WHERE 实体键 AND is_deleted=0`)。单表之后该条件会命中任意状态的行,因此必须**按命中行的状态分三支**:
|
||||||
|
- 命中 `pending` / `published` → 整任务 `MarkDone`,**直接放弃**(用户裁定)。语义:该实体已收录或正在审核,不重复入库。
|
||||||
|
- 命中 `rejected` → **复用该行**:覆盖内容字段(标题/描述/年份/季节/collection_type/season_code/cover)、软删其旧图片、写入本次抓取的图片、置回 `status='pending'` 并清空 `reviewer`/`reject_reason`。语义:驳回不是永久黑名单,重爬即重新送审。
|
||||||
|
> ⚠️ 这一支是**必需的修正**:查重条件不带 status,若不特判 `rejected`,一条被驳回(甚至误驳)的记录会**永久挡住重爬**;而现状(草稿表)不会——查重只查正式表,驳回的草稿不挡路。
|
||||||
|
- 未命中 → 新建 `pending` 记录。
|
||||||
|
"同一实体只有一行"因此始终成立。
|
||||||
|
2. **建记录**:`CreateRunwayDraft` → `CreateRunway`(写 `brand_runways`,`status='pending'`、`job_id`);`CreateStreetSnapDraft` → `CreateStreetSnap`(写 `street_snaps`)。
|
||||||
|
3. **建图片**:把图直接写 `brand_runway_images` / `street_snap_images`。
|
||||||
|
4. **图片去重**:`FindNearDuplicateImage` 的比对表从"草稿图 + 正式图"两张收敛为**一张正式图表**;`phash` / `is_duplicate` / `dup_of` 的写入位置不变。
|
||||||
|
|
||||||
|
失效的旧代码(删除):`CreateRunwayDraft`、`CreateStreetSnapDraft`、草稿相关的 `RunwayIDByEntity` 之外的草稿查询、`MarkDraftDone` 一类的草稿态更新。
|
||||||
|
|
||||||
|
## 6. 公开可见性:只读视图
|
||||||
|
|
||||||
|
```sql
|
||||||
|
DROP VIEW IF EXISTS public_brand_runway_images;
|
||||||
|
DROP VIEW IF EXISTS public_brand_runways;
|
||||||
|
DROP VIEW IF EXISTS public_street_snap_images;
|
||||||
|
DROP VIEW IF EXISTS public_street_snaps;
|
||||||
|
|
||||||
|
CREATE VIEW public_brand_runways AS
|
||||||
|
SELECT * FROM brand_runways WHERE status = 'published' AND is_deleted = 0;
|
||||||
|
|
||||||
|
CREATE VIEW public_brand_runway_images AS
|
||||||
|
SELECT i.* FROM brand_runway_images i
|
||||||
|
JOIN brand_runways r ON r.id = i.runway_id
|
||||||
|
WHERE i.is_deleted = 0 AND r.status = 'published' AND r.is_deleted = 0;
|
||||||
|
|
||||||
|
CREATE VIEW public_street_snaps AS
|
||||||
|
SELECT * FROM street_snaps WHERE status = 'published' AND is_deleted = 0;
|
||||||
|
|
||||||
|
CREATE VIEW public_street_snap_images AS
|
||||||
|
SELECT i.* FROM street_snap_images i
|
||||||
|
JOIN street_snaps s ON s.id = i.snap_id
|
||||||
|
WHERE i.is_deleted = 0 AND s.status = 'published' AND s.is_deleted = 0;
|
||||||
|
```
|
||||||
|
|
||||||
|
**分层规则(硬约束)**
|
||||||
|
|
||||||
|
| 层 | 读 | 写 |
|
||||||
|
|---|---|---|
|
||||||
|
| 公开(public API / SSG) | 只读 `public_*` 视图 | 从不写 |
|
||||||
|
| 后台 | 读基表 | 读写基表 |
|
||||||
|
|
||||||
|
公开侧需要改读视图的方法(现状均为裸 `is_deleted=0`):
|
||||||
|
|
||||||
|
- `article_repository.go`:`List`、`FindByID`、`ListImages`、`ImagesByRunwayIDs`
|
||||||
|
- `street_snap_repository.go`:`List`、`FindByID`、`ListImages`、`ImagesBySnapIDs`、`Popular`
|
||||||
|
- `brand_repository.go`:`List` 的 `hasArticlesSubQuery`("有档案品牌")、`FeaturedIDs`、`PopularWithCover`(这两个直接查 `brand_runways`,必须改读 `public_brand_runways`,否则**待审走秀会让品牌提前出现在前台**)
|
||||||
|
- `index_service.go` / `article_service.go` / `street_snap_service.go` / `brand_service.go` 的公开映射方法本身不过滤,只跟着仓储走。
|
||||||
|
|
||||||
|
后台侧不改(`ListAdmin`、`GetForEdit` 等继续读基表)。
|
||||||
|
|
||||||
|
**GORM 用法**:视图无主键/无关联,统一以 `.Table("public_brand_runways")` + `.Find(&[]model.BrandRunway{})` 形式查询;`Preload` 不可用(现有公开查询本就是显式 join/分步查询,不受影响)。
|
||||||
|
|
||||||
|
**维护规则**:视图用 `SELECT *` 冻结列集,因此**任何给这 4 张基表加列的迁移,必须同批重建视图**(`DROP VIEW` + `CREATE VIEW`,本文件 §3.2 的加列即需要)。
|
||||||
|
|
||||||
|
## 7. 后台
|
||||||
|
|
||||||
|
- **审核列表**:`review_repository` 的 `ListDrafts(kind, status)` 改为直接查基表 `status`;「全部」tab = 不过滤。
|
||||||
|
- **审核详情**:`DraftDetailView` 由记录行组装(逻辑不变,只是数据源从草稿表换成正式表)。街拍分组视图(`Groups`)继续用 `is_detail`/`parent_image_id` 组装,`resolveRoot` 的链式上浮与孤儿容错**保持不变**。
|
||||||
|
- **通过 / 驳回**:`Approve` 不再调用 `SaveXxxFromDraft`,改为「应用字段编辑 + 置 `status`」;`Reject` 置 `status='rejected'` + 理由。
|
||||||
|
- **图片操作**:`AttachStreetDraftImages`→`AttachImages`、`AttachPrevStreetDraftImage`、`DetachStreetDraftImage`、`SoftDeleteXxxDraftImage` 全部改为操作**正式图片表**(逻辑不变,事务与不变量保证不变)。
|
||||||
|
- **正式列表页**(`/admin/runways`、`/admin/street-snaps`):读基表,默认展示 `published`,加一列状态。
|
||||||
|
- **命名收敛**:`Draft*` 前缀统一改名(`DraftDetailView`→`RecordDetailView`、`DraftImageRef`→`RecordImageRef`、`DraftImageGroup`→`ImageGroup`、`ListDrafts`→`ListRecords` 等),模板数据键 `.Draft` → `.Record`(模板不受编译器保护,由 `backstage_handler_test.go` 的"逐页渲染"测试兜底:缺键会导致模板执行报错→500→测试红)。
|
||||||
|
|
||||||
|
## 8. 迁移
|
||||||
|
|
||||||
|
分三步,**顺序不可颠倒**。
|
||||||
|
|
||||||
|
### 第 1 步:DDL + 视图(`db/migrations/2026-09-22-01-single-table-publish.sql`)
|
||||||
|
1. §3.2 加列。
|
||||||
|
2. 建 §6 的 4 个视图(视图定义必须幂等:`DROP VIEW IF EXISTS` 后重建)。
|
||||||
|
|
||||||
|
**存量行置已发布拆成独立的一次性文件**(`db/migrations/2026-09-22-01b-publish-existing-rows.sql`):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
UPDATE brand_runways SET status = 'published' WHERE status = 'pending';
|
||||||
|
UPDATE street_snaps SET status = 'published' WHERE status = 'pending';
|
||||||
|
```
|
||||||
|
|
||||||
|
为什么必须拆开:数据搬迁(第 2 步)会把 pending 草稿**连同旧的 `created_at`** 搬进正式表。若这条 UPDATE 留在可重复执行的迁移文件里(集成测试每次都会执行它),这些待审内容会被误刷成 `published` —— 恰好是本设计要防的泄漏。因此:结构 DDL 可重复执行,数据变更只执行一次,且**必须在第 2 步之前**执行。
|
||||||
|
|
||||||
|
> 本步必须在部署新代码**之前**应用(新代码开始写 `status`)。
|
||||||
|
|
||||||
|
### 第 2 步:数据搬迁(`scripts/migrate_single_table/main.go`,一次性命令,支持 `-dry-run`)
|
||||||
|
1. 建 id 映射:`map[draftID]newRecordID`、`map[draftImageID]newImageID`。
|
||||||
|
2. `pending` / `rejected` 草稿 → 插入正式表(保留 `status`/`job_id`/`reviewer`/`reject_reason`/`is_deleted`),草稿图片 → 插入正式图片表(保留 `look_index`/`is_detail`/`phash`/`is_duplicate`/`dup_of`/`sort_order`)。
|
||||||
|
3. **街拍副图**:第二遍把 `parent_image_id` 从"草稿图片 id"改写为"新正式图片 id"(用第 1 步的映射);映射缺失则降级 `is_detail=0`(与读侧容错一致)。
|
||||||
|
4. **校验(不通过则中止,不删表)**:
|
||||||
|
- 每个 `approved` 草稿都能在正式表找到对应实体行(否则说明有"只存在于草稿"的内容,需人工处理);
|
||||||
|
- 待审/驳回草稿的记录数与图片数在迁移前后守恒;
|
||||||
|
- `street_snap_images` 中不存在指向不存在行的 `parent_image_id`。
|
||||||
|
5. `-dry-run` 打印上述统计与差异,不写库。
|
||||||
|
|
||||||
|
> `approved` 草稿的内容已由当年的晋升写进正式表,**不重复插入**(只丢弃元数据)。
|
||||||
|
|
||||||
|
### 第 3 步:删表(`db/migrations/2026-09-22-05-drop-draft-tables.sql`,校验通过后再执行)
|
||||||
|
```sql
|
||||||
|
DROP TABLE IF EXISTS brand_runway_draft_images, brand_runway_drafts,
|
||||||
|
street_snap_draft_images, street_snap_drafts;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 9. 受影响文件(预估)
|
||||||
|
|
||||||
|
| 层 | 文件 |
|
||||||
|
|---|---|
|
||||||
|
| model | `runway_draft.go`(删)、`street_snap_draft.go`(删)、`runway.go`、`street_snap.go`(加列)、`content_status.go`(新) |
|
||||||
|
| repository | `ingest_repository.go`(建记录/查重)、`review_repository.go`(最大改动:列表/详情/状态/图片操作/删除晋升)、`article_repository.go`、`street_snap_repository.go`、`brand_repository.go`(改读视图) |
|
||||||
|
| service | `ingest_service.go`、`review_service.go`、`article_service.go`、`street_snap_service.go`、`brand_service.go`、`index_service.go` |
|
||||||
|
| handler | `backstage_handler.go`(去掉 `?main=` 之外基本不动)、`article_handler.go`/`street_snap_handler.go`/`brand_handler.go`/`ssg_handler.go`(应无需改动,公开过滤下沉到仓储) |
|
||||||
|
| templates | `review-list.html`(status 值)、`review-detail.html`(`.Draft`→`.Record`)、`runways.html`、`street-snaps.html`(状态列) |
|
||||||
|
| DDL / 脚本 | `db/migrations/2026-09-22-01-*.sql`、`db/migrations/2026-09-22-05-*.sql`、`scripts/migrate_single_table/main.go` |
|
||||||
|
| 测试 | `street_main_detail_integration_test.go`、`dedup_integration_test.go`、`ingest_repository_test.go`、`backstage_handler_test.go`、`router/backstage_test.go`、service 层单测 |
|
||||||
|
|
||||||
|
## 10. 测试策略
|
||||||
|
|
||||||
|
1. **公开可见性(关键,新建)**:插入一条 `status='pending'` 的记录 + 图片,断言
|
||||||
|
- 公开列表/详情/热门/SSG 全部**查不到**它;
|
||||||
|
- 置为 `published` 后能查到;
|
||||||
|
- 置回 `rejected` 后再次查不到。
|
||||||
|
这是"视图方案"真正要拿下的证据。
|
||||||
|
2. **实体键唯一**:同一实体键重复入库 → 只有一行,且第二次整任务被跳过。
|
||||||
|
3. **图片 id 稳定(新)**:通过 → 编辑字段 → 再通过,断言图片行 id 不变(旧实现会变)。
|
||||||
|
4. **街拍主副图不变量(现有集成测试改造)**:并入/并入上一张/拆出在正式图片表上仍满足"无副图的副图";父引用失效时按主图渲染。
|
||||||
|
5. **模板逐页渲染**:`backstage_handler_test.go` 现有"每个 page 渲染 200"继续兜住 `.Draft`→`.Record` 改名的遗漏。
|
||||||
|
6. **迁移脚本**:对拍迁移前后记录数/图片数;`-dry-run` 可重复执行。
|
||||||
|
|
||||||
|
## 11. 风险与回滚
|
||||||
|
|
||||||
|
| 风险 | 缓解 |
|
||||||
|
|---|---|
|
||||||
|
| 视图漏建/漏改,导致公开读到未审内容 | 视图是唯一公开入口 + §10.1 的专项测试;视图 DDL 与加列迁移同文件 |
|
||||||
|
| 加了列忘了重建视图(列集冻结) | §6 维护规则 + 迁移文件里 `DROP VIEW` 后重建 |
|
||||||
|
| 迁移把内容搬丢 | 第 2 步先 `-dry-run` + 三项守恒校验,**校验不过不执行第 3 步删表** |
|
||||||
|
| 存量 `approved` 草稿无对应正式行 | 校验会中止并列出,人工决定(补插或忽略) |
|
||||||
|
| 审核期间已发布专辑"消失" | 本设计下**不会发生**:重复入库直接放弃,不会把 published 行改回 pending |
|
||||||
|
|
||||||
|
**回滚**:第 1 步可逆(删列删视图),第 2 步之前数据零损失;第 3 步删表后不可逆(需从备份恢复,`db/backups/` 与 `db_dump.sql` 可用)。
|
||||||
|
|
||||||
|
## 12. 已知不一致(本次明确不处理,仅记录)
|
||||||
|
|
||||||
|
1. `image_count` 口径:runway 只计主图,street 计全部图(含副图)。
|
||||||
|
2. `favorites` 实建索引是 `uniq_user_target(target_uid)` 单列(代码注释写的是 `(user_id, target_uid)` 组合)——按现有 DDL,同一用户似乎只能收藏一条记录,疑似缺陷。
|
||||||
|
3. `image_duplicates`、`image_embeddings` 两张表零引用;`street_snaps.source/source_url` 死列。可另起一个清理迁移删掉,与本次改造解耦。
|
||||||
|
4. **标题漂移会被判为新实体**:街拍实体键含 `title`(`(city, year, COALESCE(title, ''))`),同一专题若标题只做空白 / 大小写 / 措辞微调,入库查重会当作新实体,从而多出一行(内容重复)。目前无标题归一化。
|
||||||
|
5. **「图片 id 终身不变」不成立**:在「已发布 → 驳回下架 → 重爬复用」这条路径上,复用 `rejected` 行会软删旧图行并插入新行,图片行 id 随之变化。凡把图片 id 当稳定标识(收藏、外链、前端缓存键)的场景都需注意。
|
||||||
27
go.mod
27
go.mod
@ -3,16 +3,34 @@ module fashionapi
|
|||||||
go 1.26.5
|
go 1.26.5
|
||||||
|
|
||||||
require (
|
require (
|
||||||
|
github.com/aws/aws-sdk-go-v2 v1.47.0
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.33.4
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.20.4
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.0
|
||||||
github.com/gin-gonic/gin v1.12.0
|
github.com/gin-gonic/gin v1.12.0
|
||||||
github.com/goccy/go-yaml v1.19.2
|
github.com/goccy/go-yaml v1.19.2
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1
|
github.com/golang-jwt/jwt/v5 v5.3.1
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0
|
||||||
golang.org/x/crypto v0.54.0
|
golang.org/x/crypto v0.54.0
|
||||||
gorm.io/driver/mysql v1.6.0
|
gorm.io/driver/postgres v1.6.3
|
||||||
gorm.io/gorm v1.31.2
|
gorm.io/gorm v1.31.2
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
filippo.io/edwards25519 v1.1.0 // indirect
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 // indirect
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0 // indirect
|
||||||
|
github.com/aws/smithy-go v1.28.1 // indirect
|
||||||
github.com/bytedance/gopkg v0.1.3 // indirect
|
github.com/bytedance/gopkg v0.1.3 // indirect
|
||||||
github.com/bytedance/sonic v1.15.0 // indirect
|
github.com/bytedance/sonic v1.15.0 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
github.com/bytedance/sonic/loader v0.5.0 // indirect
|
||||||
@ -22,8 +40,10 @@ require (
|
|||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
github.com/go-playground/validator/v10 v10.30.1 // indirect
|
||||||
github.com/go-sql-driver/mysql v1.8.1 // indirect
|
|
||||||
github.com/goccy/go-json v0.10.5 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 // indirect
|
||||||
github.com/jinzhu/inflection v1.0.0 // indirect
|
github.com/jinzhu/inflection v1.0.0 // indirect
|
||||||
github.com/jinzhu/now v1.1.5 // indirect
|
github.com/jinzhu/now v1.1.5 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
@ -40,6 +60,7 @@ require (
|
|||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
|
||||||
golang.org/x/arch v0.22.0 // indirect
|
golang.org/x/arch v0.22.0 // indirect
|
||||||
golang.org/x/net v0.56.0 // indirect
|
golang.org/x/net v0.56.0 // indirect
|
||||||
|
golang.org/x/sync v0.22.0 // indirect
|
||||||
golang.org/x/sys v0.47.0 // indirect
|
golang.org/x/sys v0.47.0 // indirect
|
||||||
golang.org/x/text v0.40.0 // indirect
|
golang.org/x/text v0.40.0 // indirect
|
||||||
google.golang.org/protobuf v1.36.10 // indirect
|
google.golang.org/protobuf v1.36.10 // indirect
|
||||||
|
|||||||
78
go.sum
78
go.sum
@ -1,5 +1,39 @@
|
|||||||
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
|
github.com/aws/aws-sdk-go-v2 v1.47.0 h1:0jsHallhJCeaU0Ko48c/3FK1ctOQ7NpzggxriJOQ8MQ=
|
||||||
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
|
github.com/aws/aws-sdk-go-v2 v1.47.0/go.mod h1:bttEH6JqnUL8LepvDVfdrds/fZ5bCIxzpe3abyUrhDU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20 h1:GPRlPwz40I2B2VrBEASOA3Bi77NyeqejNLkifosX0rs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.20/go.mod h1:g7PNzKcsOKWb4fkSRBA7BZVAS6Y8IcxzN+nRohhQ1Q8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.33.4 h1:FzvkXKSzwqHni4U7nDigHg4jjtqMpVUuHgmZfSoJVQ0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/config v1.33.4/go.mod h1:VZqGZnZsCWVfK/iGPptJIyNIX3XEX6iQU2Rel4sLrr8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.20.4 h1:hTvrJJseKbvw32kmiE0G+u/9ZqpqscjDrTigHIXP2qs=
|
||||||
|
github.com/aws/aws-sdk-go-v2/credentials v1.20.4/go.mod h1:gWp9O1ZBWwpcIrgV+mVHk4gZUurAEDkgypu/OXOlIaw=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0 h1:AM4hHjww+PSFtt6E+UrBrPlZkWsePCLEt9AjkfQX+yM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.20.0/go.mod h1:3x/yXezeQjpOvBb4jEMxrS8SXvpdvJ5abv6l5c1gWM8=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3 h1:Hp/VgjP0BysR3OgLlR057Vz2LcbbVnoWeJ+3qWiS/fY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/configsources v1.5.3/go.mod h1:nwGV5qw7F1IZPgxCvA/ph8N2TAuz+BkRG/bXn808qMA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3 h1:MUaM4f+kj1ZIBPZfUS8cxP1GKXXZtHJjAthy93AN7SM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.8.3/go.mod h1:6YmVmEVRI5ZZzRjCSsb9SryKH0hAlMRdgA7kG9aDvBU=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3 h1:fuSCw4Z2qfRCztMPO3GXJNSiEp6Wee+WOLwrHHUMy9c=
|
||||||
|
github.com/aws/aws-sdk-go-v2/internal/v4a v1.5.3/go.mod h1:6SxcHheD1pPR5+kWm1wGvjlL/YqUsh267sAfEmN4K7A=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19 h1:bAdDl/HkGCcGPoe25ToSHEw23VIxt6CT5fLcg111BKg=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/accept-encoding v1.13.19/go.mod h1:KaUzbLxv4CeSxh6ZCl9B4m7CuFenS8kUEaDs+f/DQr4=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3 h1:BHKCSX4QXERe8So8rbWqaM7owqOmDJxATXgJwGng22A=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/checksum v1.11.3/go.mod h1:GqWeeKfYfezihA2KfFL9l7ohEdZWe1tuFWh3GfyNSnE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3 h1:bON1rJf67TSTDCKg816AAIE4xSTtoo9tl0XRkO72R+I=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.14.3/go.mod h1:c5BBpjJcQXpfeq9iASyVKA3T6vX6B6LEXY4mL/gklDY=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3 h1:L8vIOxylma91TcR96NFTEC07G3JDwSl+CvK2b+IODms=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.20.3/go.mod h1:fmPIZQzTExYuBNWFyi1P7IoDjvskgphXqK1yObMzusM=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.0 h1:0slwIjBv1sEigSUW4EfTtNw9mbHl0PlOUPX9Y1C/eLE=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/s3 v1.113.0/go.mod h1:/uA+2Qj4jd5qBWagVC1AyzzDFXVK997E7U04w7Kw0wI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0 h1:ZD5qFpWcaOKdTuhBi431pIDkCgrMkMlMT6jlpSPoIRI=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/signin v1.10.0/go.mod h1:8Nuuf+tR346PjJ3MvZPh9pekbLiLQFWJhzMXfwy7alA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0 h1:JGeeBcMlhg1xtOXYpeCaTQBZObtXMPQCUqBcmr65NRA=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sso v1.38.0/go.mod h1:XwteswG9EOMRFm73UT0t+MbTwyLxMrEXkU6e+v92Lzo=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0 h1:obhahQXDEdVEv8y5bTKXR30LVaxYe1kyYM0L7l2Iq+k=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/ssooidc v1.43.0/go.mod h1:6twZZ/aXHNy1vXUO8koUbp++MYzMASkOgEBdkbJYmO0=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0 h1:khXV3+K5D3f4e8xtplaRdSFn1bEg3gj5EBHQvbCOZbQ=
|
||||||
|
github.com/aws/aws-sdk-go-v2/service/sts v1.50.0/go.mod h1:/8JRcdTt//hG0Q4BTmGbuOplT7ABe+5rdtqUHqXvYIM=
|
||||||
|
github.com/aws/smithy-go v1.28.1 h1:R/nXH00c8qcfCzQVELtRw+eLQWtzv+VAIEFJ1/xxXlQ=
|
||||||
|
github.com/aws/smithy-go v1.28.1/go.mod h1:YE2RhdIuDbA5E5bTdciG9KrW3+TiEONeUWCqxX9i1Fc=
|
||||||
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
|
||||||
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
|
||||||
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
|
||||||
@ -9,6 +43,7 @@ github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCc
|
|||||||
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
|
||||||
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
|
||||||
@ -16,21 +51,31 @@ github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w
|
|||||||
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
|
||||||
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
|
||||||
|
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
|
||||||
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
|
||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
|
||||||
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
|
||||||
github.com/go-sql-driver/mysql v1.8.1 h1:LedoTUt/eveggdHS9qUFC1EFSa8bU2+1pZjSRpvNJ1Y=
|
|
||||||
github.com/go-sql-driver/mysql v1.8.1/go.mod h1:wEBSXgmK//2ZFJyE+qWnIsVGmvmEKlqwuVSjsCm7DZg=
|
|
||||||
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
|
||||||
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY=
|
||||||
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
|
||||||
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
|
||||||
|
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
|
||||||
|
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
|
||||||
|
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
|
||||||
|
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
|
||||||
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E=
|
||||||
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc=
|
||||||
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ=
|
||||||
@ -43,6 +88,8 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU=
|
||||||
|
github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
@ -50,6 +97,7 @@ github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9G
|
|||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
|
||||||
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
|
||||||
@ -60,41 +108,43 @@ github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSS
|
|||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
|
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||||
|
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
|
||||||
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
|
||||||
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
|
||||||
|
go.uber.org/mock v0.6.0 h1:hyF9dfmbgIX5EfOdasqLsWD6xqpNZlXblLB/Dbnwv3Y=
|
||||||
|
go.uber.org/mock v0.6.0/go.mod h1:KiVJ4BqZJaMj4svdfmHM0AUx4NJYO8ZNpPnZn1Z+BBU=
|
||||||
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
|
||||||
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
|
||||||
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
|
|
||||||
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
|
|
||||||
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
|
||||||
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
|
||||||
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
|
|
||||||
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
|
|
||||||
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o=
|
||||||
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec=
|
||||||
|
golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek=
|
||||||
|
golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
|
||||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
|
||||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||||
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
|
|
||||||
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
|
|
||||||
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
golang.org/x/text v0.40.0 h1:Ub2Z6/xjgF1WrYQz2nuITOEegKFtiIy+rieRJ5lHZKs=
|
||||||
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
golang.org/x/text v0.40.0/go.mod h1:hpnzDAfGV753zIKo+wk3u1bVKCGPbrnF7+7LBF/UHVY=
|
||||||
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
|
||||||
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gorm.io/driver/mysql v1.6.0 h1:eNbLmNTpPpTOVZi8MMxCi2aaIm0ZpInbORNXDwyLGvg=
|
gorm.io/driver/postgres v1.6.3 h1:bAn6O2pUa8LtpWEvL5NFU4+52Tfx8Ut7IVaIacCLcI0=
|
||||||
gorm.io/driver/mysql v1.6.0/go.mod h1:D/oCC2GWK3M/dqoLxnOlaNKmXz8WNTfcS9y5ovaSqKo=
|
gorm.io/driver/postgres v1.6.3/go.mod h1:0c4fQA44XhOklXDkgtuKqysHCycTa5i9e3EIpDGCwXk=
|
||||||
|
gorm.io/driver/sqlite v1.6.0 h1:WHRRrIiulaPiPFmDcod6prc4l2VGVWHz80KspNsxSfQ=
|
||||||
|
gorm.io/driver/sqlite v1.6.0/go.mod h1:AO9V1qIQddBESngQUKWL9yoH93HIeA1X6V633rBwyT8=
|
||||||
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
gorm.io/gorm v1.31.2 h1:3o8FXNo9v9S858gil+3LlZA1LkCOzgb4g5BL64FgaCo=
|
||||||
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
gorm.io/gorm v1.31.2/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs=
|
||||||
|
|||||||
@ -24,8 +24,8 @@ type Config struct {
|
|||||||
ClientSign ClientSignConfig `yaml:"client_sign"`
|
ClientSign ClientSignConfig `yaml:"client_sign"`
|
||||||
// Ingest 爬虫上报接口的 HMAC 验签配置(服务端到服务端,密钥不下发前端)。
|
// Ingest 爬虫上报接口的 HMAC 验签配置(服务端到服务端,密钥不下发前端)。
|
||||||
Ingest IngestConfig `yaml:"ingest"`
|
Ingest IngestConfig `yaml:"ingest"`
|
||||||
// Qiniu 七牛云对象存储配置(爬虫入库图片上传目标)。
|
// S4 缤纷云对象存储配置(S3 兼容,爬虫入库图片上传目标)。
|
||||||
Qiniu QiniuConfig `yaml:"qiniu"`
|
S4 S4Config `yaml:"s4"`
|
||||||
|
|
||||||
// loadedFrom 记录实际生效的配置文件绝对路径,仅用于启动日志。
|
// loadedFrom 记录实际生效的配置文件绝对路径,仅用于启动日志。
|
||||||
// 小写不导出,yml 无法覆盖它。
|
// 小写不导出,yml 无法覆盖它。
|
||||||
@ -59,32 +59,27 @@ type ServerConfig struct {
|
|||||||
HashIDSecret string `yaml:"hashid_secret"`
|
HashIDSecret string `yaml:"hashid_secret"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DatabaseConfig MySQL 连接与连接池配置。
|
// DatabaseConfig PostgreSQL 连接与连接池配置。
|
||||||
type DatabaseConfig struct {
|
type DatabaseConfig struct {
|
||||||
Host string `yaml:"host"`
|
Host string `yaml:"host"`
|
||||||
Port string `yaml:"port"`
|
Port string `yaml:"port"`
|
||||||
User string `yaml:"user"`
|
User string `yaml:"user"`
|
||||||
Password string `yaml:"password"`
|
Password string `yaml:"password"`
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Charset string `yaml:"charset"`
|
|
||||||
LogLevel string `yaml:"log_level"`
|
LogLevel string `yaml:"log_level"`
|
||||||
MaxIdleConns int `yaml:"max_idle_conns"`
|
MaxIdleConns int `yaml:"max_idle_conns"`
|
||||||
MaxOpenConns int `yaml:"max_open_conns"`
|
MaxOpenConns int `yaml:"max_open_conns"`
|
||||||
ConnMaxLifetime int `yaml:"conn_max_lifetime"`
|
ConnMaxLifetime int `yaml:"conn_max_lifetime"`
|
||||||
// ConnMaxIdleTime 空闲连接回收时间(秒)。必须 < MySQL wait_timeout,否则 MySQL 回收空闲
|
// ConnMaxIdleTime 空闲连接回收时间(秒)。避免长时间持有被服务端回收的死连接,
|
||||||
// 连接后,连接池仍持有死连接,下一次查询会报 "invalid connection" / "bad connection"。
|
// 下一次查询报 "invalid connection" / "bad connection"。
|
||||||
ConnMaxIdleTime int `yaml:"conn_max_idle_time"`
|
ConnMaxIdleTime int `yaml:"conn_max_idle_time"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// DSN 组装 MySQL 连接串。
|
// DSN 组装 PostgreSQL 连接串(pgx 驱动,本地开发禁用 SSL)。
|
||||||
func (d DatabaseConfig) DSN() string {
|
func (d DatabaseConfig) DSN() string {
|
||||||
charset := d.Charset
|
|
||||||
if charset == "" {
|
|
||||||
charset = "utf8mb4"
|
|
||||||
}
|
|
||||||
return fmt.Sprintf(
|
return fmt.Sprintf(
|
||||||
"%s:%s@tcp(%s:%s)/%s?charset=%s&parseTime=True&loc=Local&timeout=10s",
|
"postgres://%s:%s@%s:%s/%s?sslmode=disable",
|
||||||
d.User, d.Password, d.Host, d.Port, d.Name, charset,
|
d.User, d.Password, d.Host, d.Port, d.Name,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,40 +115,37 @@ type CORSConfig struct {
|
|||||||
// 接口因此可安全暴露(含多节点爬虫场景),无需回环绑定。
|
// 接口因此可安全暴露(含多节点爬虫场景),无需回环绑定。
|
||||||
// 密钥必须走环境变量 INGEST_SECRET 注入,切勿加 PUBLIC_ 前缀以免进入前端 bundle。
|
// 密钥必须走环境变量 INGEST_SECRET 注入,切勿加 PUBLIC_ 前缀以免进入前端 bundle。
|
||||||
type IngestConfig struct {
|
type IngestConfig struct {
|
||||||
// Enabled 开关:false 时 ingest 接口返回 503(尚未配置密钥),便于灰度。
|
|
||||||
Enabled bool `yaml:"enabled"`
|
|
||||||
// Secret 签名密钥,须与爬虫端完全一致;为空则等同于禁用。
|
// Secret 签名密钥,须与爬虫端完全一致;为空则等同于禁用。
|
||||||
Secret string `yaml:"secret"`
|
Secret string `yaml:"secret"`
|
||||||
// TTLSeconds 时间戳容忍窗口(秒),默认 300(±5 分钟)。
|
// TTLSeconds 时间戳容忍窗口(秒),默认 300(±5 分钟)。
|
||||||
TTLSeconds int `yaml:"ttl_seconds"`
|
TTLSeconds int `yaml:"ttl_seconds"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// QiniuConfig 七牛云对象存储配置(爬虫入库图片上传目标)。
|
// S4Config 缤纷云 S4 对象存储配置(S3 兼容,爬虫入库图片上传目标)。
|
||||||
//
|
//
|
||||||
// worker 下载走秀/街拍图片后,若 Enabled=true 则直传七牛 bucket,
|
// worker 下载走秀/街拍图片后,若 Enabled=true 则直传 S4 bucket,
|
||||||
// 库里只存 key(base_url 由本配置持有,渲染时再拼);失败再兜底写本地 uploads。
|
// 库里只存 key(base_url 由本配置持有,渲染时再拼);失败再兜底写本地 uploads。
|
||||||
// - AK/SK 须与七牛控制台一致;生产经环境变量 QINIU_AK / QINIU_SK 注入。
|
//
|
||||||
// - Zone 必须匹配 bucket 创建区域,否则上传失败:z0=华东 z1=华北 z2=华南 na0=北美 as0=新加坡。
|
// 图片质量模型(见 internal/pkg/imgurl,2026-09-11 调整):VIP 与免费用户同质量,
|
||||||
// - BaseURL 为 bucket 绑定的公开访问域名(如 https://cdn.toom-studio.com),用于拼出图片 URL。
|
// 统一走 CoreIX 公开样式 StyleDisplay(如 high,key!style:high 匿名可访问);付费墙已取消。
|
||||||
// - StyleNormal 普通图七牛命名样式名(见 imgurl 包):详情页对免费/未登录用户拼
|
// - AK/SK 为缤纷云子账户密钥(须有 PutObject/DeleteObject/GetObject 权限);
|
||||||
// base_url/key-StyleNormal;须与七牛控制台创建的样式名完全一致。
|
// 生产经环境变量 S4_AK / S4_SK 注入。
|
||||||
type QiniuConfig struct {
|
// - Endpoint 默认 https://s3.bitiful.net;Region 任意(如 cn-east-1)。
|
||||||
Enabled bool `yaml:"enabled"`
|
// - BaseURL 为对外访问域名;留空自动推导 https://<bucket>.s3.bitiful.net(须与 Endpoint 同 host)。
|
||||||
AK string `yaml:"ak"`
|
// - StyleDisplay 全量展示图 CoreIX 样式名(如 high,控制台建公开样式)或查询串(如 w=1080&q=80&fmt=webp)。
|
||||||
SK string `yaml:"sk"`
|
// - StyleThumb 列表缩略图 CoreIX 样式名(如 thumb,控制台建公开样式),仅用于「列表/卡片」等
|
||||||
Bucket string `yaml:"bucket"`
|
// 小尺寸场景(列表封面、卡片图条、首页热门位),详情页与大图灯箱仍走 StyleDisplay,兼顾清晰度与流量。
|
||||||
Zone string `yaml:"zone"`
|
// 留空时回落 StyleDisplay(无独立缩略图样式时行为与旧版一致,不报错)。
|
||||||
BaseURL string `yaml:"base_url"`
|
type S4Config struct {
|
||||||
// StyleNormal 普通图七牛命名样式名(如 free 或 free.webp)。
|
Enabled bool `yaml:"enabled"`
|
||||||
StyleNormal string `yaml:"style_normal"`
|
AK string `yaml:"ak"`
|
||||||
// StyleVip VIP 高清七牛命名样式名(如 vip 或 vip.webp)。VIP tier 走此样式。
|
SK string `yaml:"sk"`
|
||||||
StyleVip string `yaml:"style_vip"`
|
Bucket string `yaml:"bucket"`
|
||||||
// LocalBase 本地兜底图片前缀(通常空;前端用 toAbs 拼 host)。仅当静态服务不在同域时有用。
|
Endpoint string `yaml:"endpoint"`
|
||||||
LocalBase string `yaml:"local_base"`
|
Region string `yaml:"region"`
|
||||||
// SignTTLNormal 普通/压缩图(含命名样式 -free)的私有空间下载签名过期秒;长过期便于 SSG 静态页与 CDN 缓存。
|
BaseURL string `yaml:"base_url"`
|
||||||
SignTTLNormal int64 `yaml:"sign_ttl_normal"`
|
StyleDisplay string `yaml:"style_display"`
|
||||||
// SignTTLHD VIP 高清原图签名过期秒;短过期使泄漏窗口小。
|
StyleThumb string `yaml:"style_thumb"`
|
||||||
SignTTLHD int64 `yaml:"sign_ttl_hd"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ClientSignConfig 公开接口「前端 JS 签名」配置。
|
// ClientSignConfig 公开接口「前端 JS 签名」配置。
|
||||||
@ -189,11 +181,10 @@ func defaultConfig() *Config {
|
|||||||
},
|
},
|
||||||
Database: DatabaseConfig{
|
Database: DatabaseConfig{
|
||||||
Host: "127.0.0.1",
|
Host: "127.0.0.1",
|
||||||
Port: "3306",
|
Port: "5432",
|
||||||
User: "root",
|
User: "fashion",
|
||||||
Password: "root",
|
Password: "fashion_dev_2026",
|
||||||
Name: "db_dev",
|
Name: "fashion",
|
||||||
Charset: "utf8mb4",
|
|
||||||
LogLevel: "warn",
|
LogLevel: "warn",
|
||||||
MaxIdleConns: 10,
|
MaxIdleConns: 10,
|
||||||
MaxOpenConns: 100,
|
MaxOpenConns: 100,
|
||||||
@ -221,19 +212,16 @@ func defaultConfig() *Config {
|
|||||||
TTLSeconds: 30,
|
TTLSeconds: 30,
|
||||||
},
|
},
|
||||||
Ingest: IngestConfig{
|
Ingest: IngestConfig{
|
||||||
Enabled: false,
|
|
||||||
Secret: "",
|
Secret: "",
|
||||||
TTLSeconds: 300,
|
TTLSeconds: 300,
|
||||||
},
|
},
|
||||||
Qiniu: QiniuConfig{
|
S4: S4Config{
|
||||||
Enabled: false,
|
Enabled: false,
|
||||||
Zone: "z1",
|
Endpoint: "https://s3.bitiful.net",
|
||||||
BaseURL: "",
|
Region: "cn-east-1",
|
||||||
StyleNormal: "free",
|
BaseURL: "",
|
||||||
StyleVip: "vip",
|
StyleDisplay: "high",
|
||||||
LocalBase: "",
|
StyleThumb: "thumb",
|
||||||
SignTTLNormal: 31536000,
|
|
||||||
SignTTLHD: 3600,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -320,21 +308,20 @@ func (c *Config) applyEnv() {
|
|||||||
envStr("UPLOAD_DIR", &c.Upload.Dir)
|
envStr("UPLOAD_DIR", &c.Upload.Dir)
|
||||||
envStr("UPLOAD_URL_PREFIX", &c.Upload.URLPrefix)
|
envStr("UPLOAD_URL_PREFIX", &c.Upload.URLPrefix)
|
||||||
|
|
||||||
envBool("INGEST_ENABLED", &c.Ingest.Enabled)
|
|
||||||
envStr("INGEST_SECRET", &c.Ingest.Secret)
|
envStr("INGEST_SECRET", &c.Ingest.Secret)
|
||||||
envInt("INGEST_TTL", &c.Ingest.TTLSeconds)
|
envInt("INGEST_TTL", &c.Ingest.TTLSeconds)
|
||||||
|
|
||||||
envBool("QINIU_ENABLED", &c.Qiniu.Enabled)
|
// 缤纷云 S4(兼容 S3):环境变量 S4_* 注入;旧 QINIU_* 仅作兼容回退,便于平滑切换
|
||||||
envStr("QINIU_AK", &c.Qiniu.AK)
|
// (S4_* 优先,仅在 S4_* 为空时回退 QINIU_*)。
|
||||||
envStr("QINIU_SK", &c.Qiniu.SK)
|
envBoolFirst(&c.S4.Enabled, "S4_ENABLED", "QINIU_ENABLED")
|
||||||
envStr("QINIU_BUCKET", &c.Qiniu.Bucket)
|
envStrFirst(&c.S4.AK, "S4_AK", "QINIU_AK")
|
||||||
envStr("QINIU_ZONE", &c.Qiniu.Zone)
|
envStrFirst(&c.S4.SK, "S4_SK", "QINIU_SK")
|
||||||
envStr("QINIU_BASE_URL", &c.Qiniu.BaseURL)
|
envStrFirst(&c.S4.Bucket, "S4_BUCKET", "QINIU_BUCKET")
|
||||||
envStr("QINIU_STYLE_NORMAL", &c.Qiniu.StyleNormal)
|
envStr("S4_ENDPOINT", &c.S4.Endpoint)
|
||||||
envStr("QINIU_STYLE_VIP", &c.Qiniu.StyleVip)
|
envStr("S4_REGION", &c.S4.Region)
|
||||||
envStr("QINIU_LOCAL_BASE", &c.Qiniu.LocalBase)
|
envStrFirst(&c.S4.BaseURL, "S4_BASE_URL", "QINIU_BASE_URL")
|
||||||
envInt64("QINIU_SIGN_TTL_NORMAL", &c.Qiniu.SignTTLNormal)
|
envStrFirst(&c.S4.StyleDisplay, "S4_STYLE_DISPLAY", "QINIU_STYLE_NORMAL")
|
||||||
envInt64("QINIU_SIGN_TTL_HD", &c.Qiniu.SignTTLHD)
|
envStr("S4_STYLE_THUMB", &c.S4.StyleThumb)
|
||||||
}
|
}
|
||||||
|
|
||||||
// normalize 兜底与校验:修正非法值,避免运行期出现难以定位的问题。
|
// normalize 兜底与校验:修正非法值,避免运行期出现难以定位的问题。
|
||||||
@ -391,17 +378,29 @@ func envStr(key string, dst *string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func envInt(key string, dst *int) {
|
// envStrFirst 按顺序取第一个非空的环境变量写入 dst(前者优先),用于平滑切换旧变量名。
|
||||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
func envStrFirst(dst *string, keys ...string) {
|
||||||
if n, err := strconv.Atoi(v); err == nil {
|
for _, k := range keys {
|
||||||
*dst = n
|
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
||||||
|
*dst = v
|
||||||
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func envInt64(key string, dst *int64) {
|
// envBoolFirst 按顺序取第一个非空的环境变量写入 dst(前者优先)。
|
||||||
|
func envBoolFirst(dst *bool, keys ...string) {
|
||||||
|
for _, k := range keys {
|
||||||
|
if v := strings.TrimSpace(strings.ToLower(os.Getenv(k))); v != "" {
|
||||||
|
*dst = v == "1" || v == "true" || v == "yes"
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func envInt(key string, dst *int) {
|
||||||
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
|
||||||
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
if n, err := strconv.Atoi(v); err == nil {
|
||||||
*dst = n
|
*dst = n
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,6 +2,13 @@
|
|||||||
//
|
//
|
||||||
// 这里刻意不提供包级全局 DB 变量:*gorm.DB 由 main 装配后显式注入各 repository,
|
// 这里刻意不提供包级全局 DB 变量:*gorm.DB 由 main 装配后显式注入各 repository,
|
||||||
// 依赖关系清晰可见,也让 repository 可以在测试中替换为独立的数据库实例。
|
// 依赖关系清晰可见,也让 repository 可以在测试中替换为独立的数据库实例。
|
||||||
|
//
|
||||||
|
// 数据库已迁移至 PostgreSQL(见 scripts/pgvector 的本地 Docker 环境)。
|
||||||
|
//
|
||||||
|
// 表结构不再由服务启动时自动迁移:全库「结构 + 索引 + 数据」统一由 cmd/dbtool 导出的
|
||||||
|
// 纯 SQL 维护(dbtool dump → psql -f)。原 database.AutoMigrate / EnsureDedupSchema
|
||||||
|
// 已随此决策移除 —— 本项目结构定义现在只有一个来源,就是那份 dump 脚本。
|
||||||
|
// 因此本包只负责「连接」这一件事,不执行任何 DDL。
|
||||||
package database
|
package database
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@ -10,14 +17,14 @@ import (
|
|||||||
|
|
||||||
"fashionapi/internal/config"
|
"fashionapi/internal/config"
|
||||||
|
|
||||||
"gorm.io/driver/mysql"
|
"gorm.io/driver/postgres"
|
||||||
"gorm.io/gorm"
|
"gorm.io/gorm"
|
||||||
"gorm.io/gorm/logger"
|
"gorm.io/gorm/logger"
|
||||||
)
|
)
|
||||||
|
|
||||||
// New 建立 MySQL 连接并完成连接池设置。
|
// New 建立 PostgreSQL 连接并完成连接池设置。
|
||||||
func New(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
func New(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
||||||
db, err := gorm.Open(mysql.Open(cfg.DSN()), &gorm.Config{
|
db, err := gorm.Open(postgres.Open(cfg.DSN()), &gorm.Config{
|
||||||
Logger: logger.Default.LogMode(parseLogLevel(cfg.LogLevel)),
|
Logger: logger.Default.LogMode(parseLogLevel(cfg.LogLevel)),
|
||||||
// 关闭默认事务可显著提升只读接口的吞吐;写操作按需显式开启事务
|
// 关闭默认事务可显著提升只读接口的吞吐;写操作按需显式开启事务
|
||||||
SkipDefaultTransaction: true,
|
SkipDefaultTransaction: true,
|
||||||
@ -33,7 +40,7 @@ func New(cfg config.DatabaseConfig) (*gorm.DB, error) {
|
|||||||
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
sqlDB.SetMaxIdleConns(cfg.MaxIdleConns)
|
||||||
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
sqlDB.SetMaxOpenConns(cfg.MaxOpenConns)
|
||||||
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
|
sqlDB.SetConnMaxLifetime(time.Duration(cfg.ConnMaxLifetime) * time.Second)
|
||||||
// 空闲回收:定期关闭空闲连接,避免持有被 MySQL wait_timeout 回收的死连接。
|
// 空闲回收:定期关闭空闲连接,避免持有被服务端回收的死连接。
|
||||||
// 仅当配置 > 0 时启用(0 = 沿用旧行为,不回收)。
|
// 仅当配置 > 0 时启用(0 = 沿用旧行为,不回收)。
|
||||||
if cfg.ConnMaxIdleTime > 0 {
|
if cfg.ConnMaxIdleTime > 0 {
|
||||||
sqlDB.SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second)
|
sqlDB.SetConnMaxIdleTime(time.Duration(cfg.ConnMaxIdleTime) * time.Second)
|
||||||
@ -2,7 +2,7 @@ package dto
|
|||||||
|
|
||||||
// ---------- 请求 ----------
|
// ---------- 请求 ----------
|
||||||
|
|
||||||
// ArticleQuery 走秀列表查询条件(GET /public/runways)。
|
// ArticleQuery 走秀列表查询条件(GET /public/runway-looks)。
|
||||||
//
|
//
|
||||||
// 设计原则:参数尽可能少、不复用。
|
// 设计原则:参数尽可能少、不复用。
|
||||||
// - 所有筛选均为单值(前端已全改为单选),不再支持多选数组;
|
// - 所有筛选均为单值(前端已全改为单选),不再支持多选数组;
|
||||||
@ -64,12 +64,28 @@ func (q ArticleQuery) Offset() int { return (q.Page - 1) * q.Size }
|
|||||||
|
|
||||||
// PublicArticleImage 对外展示用的图片结构。
|
// PublicArticleImage 对外展示用的图片结构。
|
||||||
//
|
//
|
||||||
// ID 是图片自身的编码 id(hashid,i=走秀单图 / j=街拍单图),供「单图收藏」定位到具体某一张;
|
// ID 是图片自身的编码 id(hashid,类型进密码:i=走秀单图 / j=街拍单图),供「单图收藏」定位到具体某一张;
|
||||||
// 此前只有 image + name,前端无法稳定标识单张图片,故补充该字段。
|
// 此前只有 image + name,前端无法稳定标识单张图片,故补充该字段。
|
||||||
|
//
|
||||||
|
// Image 与 Thumb 的关系:
|
||||||
|
// - Image 是「该接口场景下的主用地址」——详情接口给展示样式(high),列表接口直接给缩略图(thumb,
|
||||||
|
// 列表只需小图,不给大图以免浪费带宽)。
|
||||||
|
// - Thumb 恒为缩略图地址(thumb 样式),**仅详情接口填充**:详情返回的是展示样式大图,
|
||||||
|
// 但大图灯箱左栏缩略图条 / 右下角细节缩略图只要几十到一百多像素,用大图纯属浪费,
|
||||||
|
// 故额外给出 Thumb 供这些「小尺寸场景」使用。列表接口不填(其 Image 本身就是缩略图)。
|
||||||
type PublicArticleImage struct {
|
type PublicArticleImage struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Image string `json:"image"`
|
Image string `json:"image"`
|
||||||
Name string `json:"name"`
|
Thumb string `json:"thumb,omitempty"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
IsDetail uint8 `json:"is_detail"` // 0=主图 1=细节图(详情接口 images 仅含主图,细节图挂在所属主图的 Detail 下)
|
||||||
|
LookIndex uint32 `json:"look_index"` // 该图归属的主图序号;细节图与所属主图同值
|
||||||
|
Favorited bool `json:"favorited,omitempty"` // 已登录时该图片是否被当前用户收藏(image 级)
|
||||||
|
|
||||||
|
// Detail 仅详情接口填充:该主图(look)下的细节图子集,元素形状与 Images 递归同构。
|
||||||
|
// 「look → 细节」的层级与前端 UI 一致,前端直接读它即可,无需再维护一份
|
||||||
|
// 「按 look_index 分组」的映射表。列表接口不填(omitempty 后不出现)。
|
||||||
|
Detail []PublicArticleImage `json:"detail,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicArticle 对外展示用的精简文章结构(走秀列表)。
|
// PublicArticle 对外展示用的精简文章结构(走秀列表)。
|
||||||
@ -83,23 +99,25 @@ type PublicArticle struct {
|
|||||||
BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串)
|
BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串)
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Cover string `json:"cover"` // 封面相对路径,如 /uploads/xxx.jpg
|
Cover string `json:"cover"` // 封面相对路径,如 /uploads/xxx.jpg
|
||||||
BrandName string `json:"brand_name"` // JOIN brand 得到
|
BrandName string `json:"brand_name"` // JOIN brands 得到
|
||||||
ImageCount uint16 `json:"image_count"` // 该走秀图片总数(列表卡片 "N+" 角标用)
|
ImageCount uint16 `json:"image_count"` // 该走秀图片总数(列表卡片 "N+" 角标用)
|
||||||
Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图
|
Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicArticleDetail 对外只读文章详情:含正文与完整图片集,不暴露管理字段。
|
// PublicArticleDetail 对外只读文章详情:含正文与完整图片集,不暴露管理字段。
|
||||||
//
|
//
|
||||||
// 与列表一致,只返回前端渲染所需的字段;image_count / source_url / 各类时间季节元数据前端均不展示,已移除。
|
// 与列表一致,只返回前端渲染所需的字段;后台管理用的额外元数据(季节 / 年份 / 系列等)
|
||||||
|
// 见 AdminRunway,由后台列表接口单独返回。
|
||||||
type PublicArticleDetail struct {
|
type PublicArticleDetail struct {
|
||||||
UID string `json:"id"` // 编码后的文章 id(无序串)
|
UID string `json:"id"` // 编码后的文章 id(无序串)
|
||||||
BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串)
|
BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串)
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
BrandName string `json:"brand_name"`
|
BrandName string `json:"brand_name"`
|
||||||
SourceURL string `json:"source_url"` // 采集来源链接(后台溯源用,对外无害)
|
// Images 仅含主图(look 图),默认展示;每个主图的细节图挂在它自己的 Detail 子数组下。
|
||||||
Images []PublicArticleImage `json:"images"`
|
Images []PublicArticleImage `json:"images"`
|
||||||
|
Favorited bool `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AdminRunway 后台管理用的走秀列表项:在 PublicArticle 基础上补回管理字段,
|
// AdminRunway 后台管理用的走秀列表项:在 PublicArticle 基础上补回管理字段,
|
||||||
@ -107,12 +125,14 @@ type PublicArticleDetail struct {
|
|||||||
type AdminRunway struct {
|
type AdminRunway struct {
|
||||||
UID string `json:"id"` // 编码后的文章 id(无序串)
|
UID string `json:"id"` // 编码后的文章 id(无序串)
|
||||||
BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串)
|
BrandUID string `json:"brand_id"` // 编码后的品牌 id(无序串)
|
||||||
BrandName string `json:"brand_name"` // JOIN brand 得到(优先 en)
|
BrandName string `json:"brand_name"` // JOIN brands 得到(优先 en)
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Year uint16 `json:"year"` // 年份(如 2024)
|
Year uint16 `json:"year"` // 年份(如 2024)
|
||||||
Season string `json:"season"` // spring / fall
|
Season string `json:"season"` // spring / fall
|
||||||
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
||||||
SeasonCode string `json:"season_code"` // SS26 / FW25 / RES26 / PF25
|
SeasonCode string `json:"season_code"` // SS26 / FW25 / RES26 / PF25
|
||||||
ImageCount uint16 `json:"image_count"`
|
ImageCount uint16 `json:"image_count"`
|
||||||
SourceURL string `json:"source_url"` // 采集来源链接(去重 / 溯源用)
|
// Status 审核态(pending / published / rejected),供后台列表展示状态列。
|
||||||
|
// 后台专用 DTO,不外泄到公开接口。
|
||||||
|
Status string `json:"status"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,7 +7,7 @@ type UserPayload struct {
|
|||||||
ID uint32 `json:"id"`
|
ID uint32 `json:"id"`
|
||||||
Username string `json:"username"`
|
Username string `json:"username"`
|
||||||
Email string `json:"email"`
|
Email string `json:"email"`
|
||||||
// Tier 用户等级 free/vip;详情页据其决定返回普通还是高清图。
|
// Tier 用户等级 free/vip;仅作账号标记,图片质量模型已统一,不再影响图片 URL。
|
||||||
Tier string `json:"tier"`
|
Tier string `json:"tier"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -7,17 +7,3 @@ type CrawlBrand struct {
|
|||||||
BrandUID string `json:"brand_uid"`
|
BrandUID string `json:"brand_uid"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// CrawlExistsRequest 图集预检请求:一次问一批 source_url 是否已爬取过。
|
|
||||||
// 设计为批量而非逐个,是因为爬虫在拿到列表页后能一次拼出几十上百个图集链接,
|
|
||||||
// 逐个问会退化成 N 次 HTTP 往返,反而比直接抓取更慢。
|
|
||||||
type CrawlExistsRequest struct {
|
|
||||||
SourceURLs []string `json:"source_urls"`
|
|
||||||
}
|
|
||||||
|
|
||||||
// CrawlExistsResponse 图集预检响应。existing 为「已爬取过、无需再抓」的 source_url 列表;
|
|
||||||
// 未出现在其中的即认为需要抓取。
|
|
||||||
type CrawlExistsResponse struct {
|
|
||||||
Existing []string `json:"existing"` // 已爬取过的 source_url,爬虫应跳过
|
|
||||||
ExistingCount int `json:"existing_count"` // 命中数量,便于爬虫打日志观察命中率
|
|
||||||
}
|
|
||||||
|
|||||||
@ -2,8 +2,8 @@ package dto
|
|||||||
|
|
||||||
// ---------- 浏览历史 ----------
|
// ---------- 浏览历史 ----------
|
||||||
|
|
||||||
// HistoryItem 对外返回的浏览历史项:id 即 target_uid(带 r=/s= 前缀的对外编码串)。
|
// HistoryItem 对外返回的浏览历史项:id 即 target_uid(类型进密码、无前缀的对外编码串,
|
||||||
// 前端据此判断类型(s= 街拍,其余走秀)并回查公开详情接口取标题/封面用于渲染。
|
// 见 hashid.EncodeWithType)。类型由类型化路由 / 存储 type 区分,前端回查公开详情接口取标题/封面用于渲染。
|
||||||
type HistoryItem struct {
|
type HistoryItem struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
ViewedAt uint32 `json:"viewed_at"`
|
ViewedAt uint32 `json:"viewed_at"`
|
||||||
|
|||||||
@ -8,13 +8,12 @@ const (
|
|||||||
|
|
||||||
// RunwayIngest 爬虫上报的单场载荷(ingest_jobs.payload 的 JSON 结构)。
|
// RunwayIngest 爬虫上报的单场载荷(ingest_jobs.payload 的 JSON 结构)。
|
||||||
//
|
//
|
||||||
// 设计:爬虫只传「元数据 + 图片 source_url 列表」,不传图、不直写业务表;
|
// 设计:爬虫只传「元数据 + 图片 URL 列表」,不传图、不直写业务表;
|
||||||
// 下载与存储上传由后台 worker 完成。
|
// 下载与存储上传由后台 worker 完成。
|
||||||
//
|
//
|
||||||
// 通用字段:
|
// 通用字段:
|
||||||
// - Kind: runway | street(决定 worker 走哪条晋升管线;缺省 runway)
|
// - Kind: runway | street(决定 worker 走哪条晋升管线;缺省 runway)
|
||||||
// - SourceURL: 采集来源链接(去重 / 溯源,必填)
|
// - Images: 图片原始 URL 列表(worker 下载)
|
||||||
// - Images: 图片原始 URL 列表(worker 下载)
|
|
||||||
//
|
//
|
||||||
// runway 专属:BrandUID(品牌 hashid 编码,必填)、TitleEn/TitleCn、Description*、Year、Season、CollectionType。
|
// runway 专属:BrandUID(品牌 hashid 编码,必填)、TitleEn/TitleCn、Description*、Year、Season、CollectionType。
|
||||||
// street 专属:City(地区/城市)、TitleEn(单标题)。
|
// street 专属:City(地区/城市)、TitleEn(单标题)。
|
||||||
@ -29,6 +28,15 @@ type RunwayIngest struct {
|
|||||||
Season string `json:"season"` // spring / fall
|
Season string `json:"season"` // spring / fall
|
||||||
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
CollectionType string `json:"collection_type"` // rtw / menswear / couture / resort / pre_fall
|
||||||
City string `json:"city"` // 地区/城市(street 用)
|
City string `json:"city"` // 地区/城市(street 用)
|
||||||
SourceURL string `json:"source_url"` // 采集来源链接(去重 / 溯源,必填)
|
Images []string `json:"images"` // 兼容旧 worker:铺平的主图 URL 列表(无细节分组时回退用)
|
||||||
Images []string `json:"images"` // 图片原始 URL 列表(worker 下载)
|
// Looks 结构化「主图 + 细节图」分组(Vogue 一个 gallery item = 1 主图 + N 细节图)。
|
||||||
|
// 爬虫结构化上报时优先用 Looks;为空时 worker 回退到 Images(全部视为主图,无细节)。
|
||||||
|
Looks []RunwayLook `json:"looks,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// RunwayLook 一场秀中的一个 look:1 张主图 + 0..N 张细节图。
|
||||||
|
// 主图即对外默认展示的走秀图;细节图挂靠到对应主图,由「细节入口」按需展开。
|
||||||
|
type RunwayLook struct {
|
||||||
|
Main string `json:"main"` // 主图(look)原始 URL
|
||||||
|
Details []string `json:"details"` // 细节图原始 URL 列表
|
||||||
}
|
}
|
||||||
|
|||||||
@ -58,12 +58,20 @@ type PublicStreetSnap struct {
|
|||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"`
|
||||||
ImageCount uint16 `json:"image_count"` // 该街拍图片总数(卡片 "N+" 角标用)
|
ImageCount uint16 `json:"image_count"` // 该街拍图片总数(卡片 "N+" 角标用)
|
||||||
Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图
|
Images []PublicArticleImage `json:"images"` // 列表附带的每篇前 N 张缩略图
|
||||||
|
|
||||||
|
// Status 审核态(pending / published / rejected)。
|
||||||
|
// 后台街拍列表复用本 DTO 渲染状态列,但公开列表接口**不返回**它:
|
||||||
|
// json:"-" 保证公开 API 响应结构与既有约定一致。
|
||||||
|
Status string `json:"-"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PublicStreetSnapDetail 对外只读街拍详情:含完整图片集。
|
// PublicStreetSnapDetail 对外只读街拍详情:与走秀详情同形(主图 + 细节图)。
|
||||||
type PublicStreetSnapDetail struct {
|
type PublicStreetSnapDetail struct {
|
||||||
UID string `json:"id"`
|
UID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Cover string `json:"cover"`
|
Cover string `json:"cover"` // 封面:独立字段,与主/副图分组无关
|
||||||
Images []PublicArticleImage `json:"images"`
|
// Images 只含主图(is_detail=0),默认展示;每张主图的副图挂在它自己的 Detail 子数组下。
|
||||||
|
// 结构与走秀详情完全一致(见 PublicArticleImage),前端可复用同一套渲染。
|
||||||
|
Images []PublicArticleImage `json:"images"`
|
||||||
|
Favorited bool `json:"favorited,omitempty"` // 已登录时该图集是否被当前用户收藏(gallery 级)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,11 +17,12 @@ import (
|
|||||||
// ArticleHandler 对外公开的文章接口。
|
// ArticleHandler 对外公开的文章接口。
|
||||||
type ArticleHandler struct {
|
type ArticleHandler struct {
|
||||||
articles service.ArticleService
|
articles service.ArticleService
|
||||||
|
favs service.FavoriteService
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewArticleHandler 创建文章 handler。
|
// NewArticleHandler 创建文章 handler。
|
||||||
func NewArticleHandler(articles service.ArticleService) *ArticleHandler {
|
func NewArticleHandler(articles service.ArticleService, favs service.FavoriteService) *ArticleHandler {
|
||||||
return &ArticleHandler{articles: articles}
|
return &ArticleHandler{articles: articles, favs: favs}
|
||||||
}
|
}
|
||||||
|
|
||||||
// List 走秀列表(分页 + 单值筛选 + 排序)。
|
// List 走秀列表(分页 + 单值筛选 + 排序)。
|
||||||
@ -80,7 +81,7 @@ func (h *ArticleHandler) parseListQuery(c *gin.Context) dto.ArticleQuery {
|
|||||||
|
|
||||||
// Detail 走秀详情(含完整图片集)。
|
// Detail 走秀详情(含完整图片集)。
|
||||||
//
|
//
|
||||||
// GET /api/public/runways/:id
|
// GET /api/v1/public/runway-looks/:id
|
||||||
// :id 是对外编码串(无序),需先解码为数字主键再查库;非法串视为不存在。
|
// :id 是对外编码串(无序),需先解码为数字主键再查库;非法串视为不存在。
|
||||||
func (h *ArticleHandler) Detail(c *gin.Context) {
|
func (h *ArticleHandler) Detail(c *gin.Context) {
|
||||||
raw := strings.TrimSpace(c.Param("id"))
|
raw := strings.TrimSpace(c.Param("id"))
|
||||||
@ -88,20 +89,32 @@ func (h *ArticleHandler) Detail(c *gin.Context) {
|
|||||||
response.Error(c, http.StatusBadRequest, "缺少文章 id")
|
response.Error(c, http.StatusBadRequest, "缺少文章 id")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
_, numeric, err := hashid.DecodeTyped(raw)
|
// 类型由路由提供(/runway-looks 即 runway)。类型已揉进密码:非 runway 编码的串
|
||||||
|
// 在此解出的是错误主键,查库自然 404,从而强制前端按类型化路由分流(无需靠前缀嗅探)。
|
||||||
|
numeric, err := hashid.DecodeWithType(raw, hashid.TypeRunway)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
response.Error(c, http.StatusNotFound, "文章不存在")
|
response.Error(c, http.StatusNotFound, "文章不存在")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
detail, err := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c), middleware.IsVIP(middleware.TierFrom(c)))
|
detail, err := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(numeric), 10), parseLocale(c))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fail(c, err)
|
// 向后兼容:迁移前收藏/历史/旧外链可能仍带 r= 前缀(旧 EncodeTyped)。
|
||||||
return
|
// 主线按类型化密码解码不到实体时,按旧编码再解一次回退(仅当旧类型确为 runway)。
|
||||||
|
if ltyp, lnum, lerr := hashid.DecodeTyped(raw); lerr == nil && ltyp == hashid.TypeRunway {
|
||||||
|
if d2, e2 := h.articles.Detail(c.Request.Context(), strconv.FormatUint(uint64(lnum), 10), parseLocale(c)); e2 == nil {
|
||||||
|
detail, err = d2, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
fail(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未登录:图片集仅返回预览上限(PreviewLimit)张,并打 preview=true,
|
// 未登录:主图只返回预览上限(PreviewLimit)张并打 preview=true,前端据此展示
|
||||||
// 前端据此在详情页展示「登录查看全部图片」门禁;已登录(带有效 Bearer)返回完整图片集。
|
// 「登录查看全部图片」门禁。细节图(Detail 子数组)不额外剥离 —— 游客也能看前几张主图的细节,
|
||||||
|
// 体验更完整;而第 PreviewLimit 张之后的主图连同其细节图会被整组截断丢弃,故无法借此绕过门禁。
|
||||||
anon := isAnon(c)
|
anon := isAnon(c)
|
||||||
preview := false
|
preview := false
|
||||||
imageTotal := len(detail.Images)
|
imageTotal := len(detail.Images)
|
||||||
@ -109,5 +122,37 @@ func (h *ArticleHandler) Detail(c *gin.Context) {
|
|||||||
detail.Images = detail.Images[:dto.PreviewLimit]
|
detail.Images = detail.Images[:dto.PreviewLimit]
|
||||||
preview = true
|
preview = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 已登录:把当前用户对该图集(gallery 级 TypeRunway)与每张图片(image 级 TypeRunwayImage,含细节图)的收藏态
|
||||||
|
// 随详情返回,前端无需再单独调 /me/favorites/checks。gallery uid 与图片 id 因类型进密码、串值不同,
|
||||||
|
// 混传 FilterExisting 不影响查询;匿名请求不查(保持公开接口无用户态、可缓存)。
|
||||||
|
if uid, ok := middleware.UserIDFrom(c); ok {
|
||||||
|
ids := make([]string, 0, len(detail.Images)*2+1)
|
||||||
|
ids = append(ids, detail.UID)
|
||||||
|
for _, im := range detail.Images {
|
||||||
|
if im.ID != "" {
|
||||||
|
ids = append(ids, im.ID)
|
||||||
|
}
|
||||||
|
for _, kid := range im.Detail {
|
||||||
|
if kid.ID != "" {
|
||||||
|
ids = append(ids, kid.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if hit, err := h.favs.Check(c.Request.Context(), uid, ids); err == nil {
|
||||||
|
set := make(map[string]bool, len(hit))
|
||||||
|
for _, id := range hit {
|
||||||
|
set[id] = true
|
||||||
|
}
|
||||||
|
detail.Favorited = set[detail.UID]
|
||||||
|
for i := range detail.Images {
|
||||||
|
detail.Images[i].Favorited = set[detail.Images[i].ID]
|
||||||
|
for j := range detail.Images[i].Detail {
|
||||||
|
detail.Images[i].Detail[j].Favorited = set[detail.Images[i].Detail[j].ID]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
response.DataPreview(c, http.StatusOK, detail, preview, imageTotal)
|
response.DataPreview(c, http.StatusOK, detail, preview, imageTotal)
|
||||||
}
|
}
|
||||||
|
|||||||
432
internal/handler/assets/admin.css
Normal file
432
internal/handler/assets/admin.css
Normal file
@ -0,0 +1,432 @@
|
|||||||
|
/* ============================================================================
|
||||||
|
* 管理后台统一样式(合并原先散在 14 个内联模板里的 12 份重复 <style>)。
|
||||||
|
*
|
||||||
|
* 设计基调沿用原后台:黑白、直角感、系统字体、无外部依赖。
|
||||||
|
* 统一后只此一份,改样式不必再逐页改 14 处。
|
||||||
|
*
|
||||||
|
* 约定:
|
||||||
|
* 1) 布局三件套 —— body(页面底色)/ .bar(顶部导航)/ .main(内容容器)
|
||||||
|
* 2) 组件化 —— .btn / .badge / .card / .cell / .pager / .flash 等按语义命名,
|
||||||
|
* 页面只写结构,不再写内联 style
|
||||||
|
* 3) 缩略图统一 2:3 —— 与站内图片素材比例一致(后端素材为 240×360 / 720×1080)
|
||||||
|
* ==========================================================================*/
|
||||||
|
|
||||||
|
/* ---------- 基础 ---------- */
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
|
||||||
|
/* 让 hidden 属性始终生效:下面给 .batchbar/.flash/.chk 设了 display,
|
||||||
|
作者样式会盖过浏览器默认的 [hidden]{display:none},故这里强制抬高。 */
|
||||||
|
[hidden] { display: none !important; }
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font: 14px/1.6 system-ui, -apple-system, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif;
|
||||||
|
color: #111;
|
||||||
|
background: #f4f4f5;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 20px; margin: 0 0 4px; }
|
||||||
|
h2 { font-size: 15px; margin: 26px 0 10px; color: #333; }
|
||||||
|
|
||||||
|
a { color: #111; }
|
||||||
|
a.view { text-decoration: underline; }
|
||||||
|
.muted { color: #777; }
|
||||||
|
code {
|
||||||
|
font-family: ui-monospace, Menlo, Consolas, monospace;
|
||||||
|
font-size: 12px;
|
||||||
|
background: #f6f6f7;
|
||||||
|
padding: 1px 5px;
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
.break { word-break: break-all; }
|
||||||
|
|
||||||
|
:focus-visible { outline: 2px solid #111; outline-offset: 2px; }
|
||||||
|
|
||||||
|
/* ---------- 顶部导航 ---------- */
|
||||||
|
.bar {
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
padding: 10px 24px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 20;
|
||||||
|
}
|
||||||
|
.bar a { color: #fff; text-decoration: none; }
|
||||||
|
.bar-title { font-weight: 600; white-space: nowrap; }
|
||||||
|
.bar-nav { display: flex; align-items: center; gap: 2px; flex-wrap: wrap; }
|
||||||
|
.bar-nav a {
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13px;
|
||||||
|
opacity: .78;
|
||||||
|
transition: background .15s, opacity .15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.bar-nav a:hover { opacity: 1; background: rgba(255, 255, 255, .14); }
|
||||||
|
.bar-nav a.on { opacity: 1; background: rgba(255, 255, 255, .2); }
|
||||||
|
.bar-nav .sep { width: 1px; height: 14px; background: rgba(255, 255, 255, .25); margin: 0 6px; }
|
||||||
|
.bar-badge {
|
||||||
|
display: inline-block;
|
||||||
|
min-width: 16px;
|
||||||
|
padding: 0 5px;
|
||||||
|
margin-left: 5px;
|
||||||
|
border-radius: 9px;
|
||||||
|
background: #f59e0b;
|
||||||
|
color: #111;
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: center;
|
||||||
|
vertical-align: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 内容容器 ---------- */
|
||||||
|
.main { max-width: 1100px; margin: 24px auto 60px; padding: 0 24px; }
|
||||||
|
.main.narrow { max-width: 620px; margin-top: 32px; }
|
||||||
|
.main.mid { max-width: 900px; }
|
||||||
|
|
||||||
|
/* 登录页固定宽卡片 */
|
||||||
|
.wrap {
|
||||||
|
max-width: 360px;
|
||||||
|
margin: 80px auto;
|
||||||
|
background: #fff;
|
||||||
|
padding: 28px 28px 32px;
|
||||||
|
border: 1px solid #e2e2e3;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
.wrap h1 { margin-bottom: 18px; }
|
||||||
|
.wrap button { width: 100%; margin-top: 18px; }
|
||||||
|
|
||||||
|
/* ---------- 提示条 ---------- */
|
||||||
|
.flash {
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin: 14px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
.flash.ok { background: #e6f7ec; border: 1px solid #a9dcbd; color: #1a7f43; }
|
||||||
|
.flash.err, .err { color: #c0392b; }
|
||||||
|
.flash.err { background: #fdeaea; border: 1px solid #f0b7b3; }
|
||||||
|
/* 收起按钮(提示条 / 顶部提示里的小 ✕):必须是「无底无边」的小图标,
|
||||||
|
否则会继承下面 button 的黑底样式,变成一个突兀的黑方块。 */
|
||||||
|
.hint .x, .flash .x {
|
||||||
|
background: none;
|
||||||
|
border: 0;
|
||||||
|
color: inherit;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
line-height: 1;
|
||||||
|
padding: 2px 6px;
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
/* hint 是 sticky(已定位),收起按钮绝对定位到右上角,不参与文字排版 */
|
||||||
|
.hint { padding-right: 34px; }
|
||||||
|
.hint .x { position: absolute; top: 6px; right: 8px; }
|
||||||
|
.hint {
|
||||||
|
position: sticky;
|
||||||
|
top: 52px;
|
||||||
|
background: #fffbe6;
|
||||||
|
border: 1px solid #f5e08a;
|
||||||
|
color: #7a5b00;
|
||||||
|
padding: 8px 12px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin: 12px 0;
|
||||||
|
font-size: 13px;
|
||||||
|
z-index: 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 表单 ---------- */
|
||||||
|
label { display: block; margin: 14px 0 4px; color: #555; }
|
||||||
|
input, textarea, select {
|
||||||
|
width: 100%;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
input[type="checkbox"], input[type="radio"] { width: auto; padding: 0; }
|
||||||
|
textarea { min-height: 72px; }
|
||||||
|
.row { display: flex; gap: 14px; }
|
||||||
|
.row > div { flex: 1; min-width: 0; }
|
||||||
|
|
||||||
|
button {
|
||||||
|
padding: 9px 16px;
|
||||||
|
background: #111;
|
||||||
|
color: #fff;
|
||||||
|
border: 1px solid #111;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
button:hover { opacity: .88; }
|
||||||
|
button.reject, button.danger { background: #c0392b; border-color: #c0392b; }
|
||||||
|
button.ghost { background: #fff; color: #111; border-color: #ccc; }
|
||||||
|
button.retry {
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 4px 10px;
|
||||||
|
border-color: #c0392b;
|
||||||
|
color: #c0392b;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
button.retry:hover { background: #c0392b; color: #fff; opacity: 1; }
|
||||||
|
button:disabled { opacity: .45; cursor: default; }
|
||||||
|
|
||||||
|
/* ---------- 工具栏 / 筛选 ---------- */
|
||||||
|
.toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
margin: 16px 0;
|
||||||
|
}
|
||||||
|
.toolbar input, .toolbar select { width: auto; padding: 8px 10px; }
|
||||||
|
.toolbar a { color: #555; text-decoration: none; font-size: 13px; }
|
||||||
|
.tabs a, .kinds a {
|
||||||
|
display: inline-block;
|
||||||
|
margin: 0 8px 8px 0;
|
||||||
|
color: #111;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
padding: 4px 9px;
|
||||||
|
border-radius: 6px;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
}
|
||||||
|
.kinds a { border-color: #ddd; }
|
||||||
|
.tabs a.on, .kinds a.on { background: #111; color: #fff; border-color: #111; }
|
||||||
|
.idx { line-height: 2.2; }
|
||||||
|
.idx a { margin-right: 8px; text-decoration: none; font-size: 13px; }
|
||||||
|
.idx a.on { font-weight: 700; text-decoration: underline; }
|
||||||
|
|
||||||
|
/* ---------- 表格 ---------- */
|
||||||
|
.table-wrap { overflow-x: auto; }
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
background: #fff;
|
||||||
|
margin-top: 12px;
|
||||||
|
border: 1px solid #e6e6e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
th, td {
|
||||||
|
text-align: left;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border-bottom: 1px solid #eee;
|
||||||
|
font-size: 13px;
|
||||||
|
vertical-align: top;
|
||||||
|
}
|
||||||
|
th { background: #fafafa; color: #555; font-weight: 600; white-space: nowrap; }
|
||||||
|
tbody tr:hover { background: #fbfbfc; }
|
||||||
|
tbody tr:last-child td { border-bottom: 0; }
|
||||||
|
|
||||||
|
/* ---------- 徽标 ---------- */
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 2px 8px;
|
||||||
|
border-radius: 10px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
.badge.pending { background: #fff4e0; color: #a85b00; }
|
||||||
|
.badge.processing { background: #eef2ff; color: #3b4bb5; }
|
||||||
|
/* published 是单表发布模型的「已通过」(旧值 approved 已被 published 取代)。 */
|
||||||
|
.badge.published, .badge.approved, .badge.done { background: #e6f7ec; color: #1a7f43; }
|
||||||
|
.badge.rejected, .badge.failed { background: #fdeaea; color: #c0392b; }
|
||||||
|
.badge.dup { background: #f3e8ff; color: #6b21a8; }
|
||||||
|
.badge.free { background: #eef2ff; color: #3b4bb5; }
|
||||||
|
.badge.vip { background: #fff4e0; color: #a85b00; }
|
||||||
|
.kind {
|
||||||
|
display: inline-block;
|
||||||
|
font-size: 12px;
|
||||||
|
padding: 1px 7px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: #eef2ff;
|
||||||
|
color: #3b4bb5;
|
||||||
|
}
|
||||||
|
.kind.street { background: #eafaf0; color: #1a7f43; }
|
||||||
|
.down { display: inline-block; padding: 2px 8px; background: #fdeaea; color: #c0392b; border-radius: 10px; font-size: 12px; }
|
||||||
|
|
||||||
|
/* ---------- 卡片(首页入口) ---------- */
|
||||||
|
.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); gap: 16px; margin-top: 20px; }
|
||||||
|
.card {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e2e2e3;
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 18px;
|
||||||
|
text-decoration: none;
|
||||||
|
color: #111;
|
||||||
|
display: block;
|
||||||
|
transition: border-color .15s, transform .15s;
|
||||||
|
}
|
||||||
|
.card:hover { border-color: #111; transform: translateY(-1px); }
|
||||||
|
.card .t { font-size: 15px; font-weight: 600; }
|
||||||
|
.card .d { color: #777; font-size: 13px; margin-top: 6px; }
|
||||||
|
.chips { margin-top: 10px; }
|
||||||
|
.chip { display: inline-block; background: #111; color: #fff; font-size: 12px; padding: 3px 9px; border-radius: 10px; margin: 0 6px 6px 0; }
|
||||||
|
|
||||||
|
/* ---------- 图片网格(审核 / 图集) ---------- */
|
||||||
|
.grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
|
||||||
|
gap: 12px;
|
||||||
|
margin-top: 14px;
|
||||||
|
}
|
||||||
|
.cell {
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e6e6e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
.cell.det { background: #f8faff; border-color: #c7d2fe; }
|
||||||
|
.cell.selected { border-color: #111; box-shadow: 0 0 0 2px rgba(0, 0, 0, .12); }
|
||||||
|
/* 缩略图统一 2:3,与站内素材比例一致;点击看大图由 admin.js 接管 */
|
||||||
|
.cell .thumb {
|
||||||
|
position: relative;
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
background: #f1f1f2;
|
||||||
|
cursor: zoom-in;
|
||||||
|
}
|
||||||
|
.cell .thumb img, .cell > img { width: 100%; display: block; aspect-ratio: 2 / 3; object-fit: cover; }
|
||||||
|
.cell > img { cursor: zoom-in; }
|
||||||
|
.cell .thumb::after {
|
||||||
|
content: "点击看大图";
|
||||||
|
position: absolute;
|
||||||
|
right: 6px;
|
||||||
|
bottom: 6px;
|
||||||
|
padding: 2px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background: rgba(0, 0, 0, .62);
|
||||||
|
color: #fff;
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity .15s;
|
||||||
|
}
|
||||||
|
.cell .thumb:hover::after { opacity: 1; }
|
||||||
|
.cell .role { display: block; padding: 6px 8px 0; font-size: 12px; color: #1a7f43; }
|
||||||
|
.cell.det .role { color: #3b4bb5; }
|
||||||
|
.cell .nm { display: block; padding: 2px 8px 6px; font-size: 12px; color: #555; word-break: break-all; }
|
||||||
|
.cell .acts { padding: 8px; display: flex; flex-wrap: wrap; gap: 6px; margin-top: auto; }
|
||||||
|
.cell .acts button, .cell .acts .btn { margin: 0; flex: 1 1 auto; padding: 7px 8px; font-size: 13px; }
|
||||||
|
.btn {
|
||||||
|
display: inline-block;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
margin: 0;
|
||||||
|
padding: 7px 10px;
|
||||||
|
background: #fff;
|
||||||
|
color: #111;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
border-radius: 6px;
|
||||||
|
text-decoration: none;
|
||||||
|
font-size: 13px;
|
||||||
|
text-align: center;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.btn:hover { border-color: #111; }
|
||||||
|
.chk {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 5px;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
font-size: 12px;
|
||||||
|
color: #333;
|
||||||
|
white-space: nowrap;
|
||||||
|
cursor: pointer;
|
||||||
|
margin: 0;
|
||||||
|
padding: 5px 6px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #fff;
|
||||||
|
}
|
||||||
|
.chk:hover { border-color: #111; }
|
||||||
|
.chk input { margin: 0; }
|
||||||
|
|
||||||
|
/* ---------- 底部批量操作条 ---------- */
|
||||||
|
.batchbar {
|
||||||
|
position: sticky;
|
||||||
|
bottom: 0;
|
||||||
|
background: #fff;
|
||||||
|
border: 1px solid #e6e6e7;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 10px 12px;
|
||||||
|
margin-top: 18px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
box-shadow: 0 -2px 12px rgba(0, 0, 0, .06);
|
||||||
|
z-index: 15;
|
||||||
|
}
|
||||||
|
.batchbar button { margin: 0; }
|
||||||
|
.batchbar .spacer { flex: 1; }
|
||||||
|
|
||||||
|
/* ---------- 分页 ---------- */
|
||||||
|
.pager { margin: 18px 0; display: flex; gap: 12px; align-items: center; }
|
||||||
|
.pager a { padding: 7px 14px; border: 1px solid #ccc; border-radius: 6px; text-decoration: none; background: #fff; }
|
||||||
|
.pager a:hover { border-color: #111; }
|
||||||
|
.pager a.off { color: #bbb; border-color: #eee; pointer-events: none; }
|
||||||
|
|
||||||
|
/* ---------- 大图查看层(admin.js 用) ---------- */
|
||||||
|
.lightbox {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(0, 0, 0, .88);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
padding: 32px;
|
||||||
|
}
|
||||||
|
.lightbox[hidden] { display: none; }
|
||||||
|
.lightbox img { max-width: 100%; max-height: 100%; object-fit: contain; }
|
||||||
|
.lightbox .lb-close,
|
||||||
|
.lightbox .lb-prev,
|
||||||
|
.lightbox .lb-next {
|
||||||
|
position: absolute;
|
||||||
|
background: rgba(255, 255, 255, .92);
|
||||||
|
color: #111;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
font-size: 18px;
|
||||||
|
line-height: 1;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.lightbox .lb-close { top: 18px; right: 18px; }
|
||||||
|
.lightbox .lb-prev { left: 18px; top: 50%; transform: translateY(-50%); }
|
||||||
|
.lightbox .lb-next { right: 18px; top: 50%; transform: translateY(-50%); }
|
||||||
|
.lightbox .lb-cap {
|
||||||
|
position: absolute;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 16px;
|
||||||
|
text-align: center;
|
||||||
|
color: #eee;
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------- 窄屏 ---------- */
|
||||||
|
@media (max-width: 720px) {
|
||||||
|
.bar { padding: 10px 14px; }
|
||||||
|
.bar-title { font-size: 13px; }
|
||||||
|
.main { padding: 0 14px; margin-top: 16px; }
|
||||||
|
.row { flex-direction: column; gap: 0; }
|
||||||
|
.grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); }
|
||||||
|
}
|
||||||
402
internal/handler/assets/admin.js
Normal file
402
internal/handler/assets/admin.js
Normal file
@ -0,0 +1,402 @@
|
|||||||
|
/* 管理后台渐进增强脚本:无框架、无依赖、无构建。
|
||||||
|
*
|
||||||
|
* 定位:**JS 只是「更快的那条路」**。所有操作在服务端都仍有表单/链接兜底
|
||||||
|
* (禁用 JS、脚本加载失败时后台照常可用),所以这里只做三件事:
|
||||||
|
* 1. 大图查看层 —— 审核是看图工作,缩略图点击放大是刚需;
|
||||||
|
* 2. 批量操作 —— 多选后一次提交,避免逐条整页刷新(原逻辑 20 条要点 20 次);
|
||||||
|
* 3. 键盘流 —— j/k 移动、x 勾选、m 设主图、d 拆出、Enter 看大图,审核图片时手不离键盘。
|
||||||
|
*
|
||||||
|
* 约定:不改变页面结构语义,只挂事件、改标签与可见性;出错一律 fallback 到原生提交。
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
var doc = document;
|
||||||
|
var $ = function (sel, root) { return (root || doc).querySelector(sel); };
|
||||||
|
var $$ = function (sel, root) { return Array.prototype.slice.call((root || doc).querySelectorAll(sel)); };
|
||||||
|
|
||||||
|
/* ---------------- 通用:提示与请求 ---------------- */
|
||||||
|
|
||||||
|
var toastTimer = null;
|
||||||
|
function toast(msg, ok) {
|
||||||
|
var el = doc.getElementById('toast');
|
||||||
|
if (!el) { if (!ok) window.alert(msg); return; }
|
||||||
|
el.textContent = msg;
|
||||||
|
el.className = 'flash ' + (ok === false ? 'err' : 'ok');
|
||||||
|
el.hidden = false;
|
||||||
|
clearTimeout(toastTimer);
|
||||||
|
toastTimer = setTimeout(function () { el.hidden = true; }, 4000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function postJSON(url, body) {
|
||||||
|
return fetch(url, {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'same-origin',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body || {}),
|
||||||
|
}).then(function (res) {
|
||||||
|
return res.json().catch(function () { return null; }).then(function (data) {
|
||||||
|
if (!res.ok) throw new Error((data && data.error) || ('请求失败(' + res.status + ')'));
|
||||||
|
return data;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表单 action -> 同路径的 JSON 接口(/admin/reviews/... -> /admin/api/reviews/...)
|
||||||
|
function apiURL(action) {
|
||||||
|
return String(action).replace('/admin/reviews/', '/admin/api/reviews/');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 大图查看层 ---------------- */
|
||||||
|
|
||||||
|
var lb = null, lbList = [], lbIndex = 0;
|
||||||
|
|
||||||
|
function lightboxNodes() {
|
||||||
|
return $$('.cell .thumb img, .cell > img').filter(function (img) { return img.getAttribute('src'); });
|
||||||
|
}
|
||||||
|
|
||||||
|
function ensureLightbox() {
|
||||||
|
if (lb) return lb;
|
||||||
|
lb = doc.createElement('div');
|
||||||
|
lb.className = 'lightbox';
|
||||||
|
lb.hidden = true;
|
||||||
|
lb.innerHTML =
|
||||||
|
'<button class="lb-close" type="button" aria-label="关闭">✕</button>' +
|
||||||
|
'<button class="lb-prev" type="button" aria-label="上一张">‹</button>' +
|
||||||
|
'<button class="lb-next" type="button" aria-label="下一张">›</button>' +
|
||||||
|
'<img alt=""><div class="lb-cap"></div>';
|
||||||
|
doc.body.appendChild(lb);
|
||||||
|
lb.querySelector('.lb-close').addEventListener('click', closeLightbox);
|
||||||
|
lb.querySelector('.lb-prev').addEventListener('click', function () { stepLightbox(-1); });
|
||||||
|
lb.querySelector('.lb-next').addEventListener('click', function () { stepLightbox(1); });
|
||||||
|
lb.addEventListener('click', function (e) { if (e.target === lb) closeLightbox(); });
|
||||||
|
return lb;
|
||||||
|
}
|
||||||
|
|
||||||
|
function showLightbox() {
|
||||||
|
var box = ensureLightbox();
|
||||||
|
var img = lbList[lbIndex];
|
||||||
|
if (!img) return;
|
||||||
|
var box2 = box.querySelector('img');
|
||||||
|
box2.src = img.getAttribute('src');
|
||||||
|
box2.alt = img.getAttribute('alt') || '';
|
||||||
|
var cell = img.closest ? img.closest('.cell') : null;
|
||||||
|
var nm = cell ? $('.nm', cell) : null;
|
||||||
|
box.querySelector('.lb-cap').textContent =
|
||||||
|
(nm ? nm.textContent.trim() + ' · ' : '') + (lbIndex + 1) + ' / ' + lbList.length;
|
||||||
|
box.hidden = false;
|
||||||
|
doc.body.style.overflow = 'hidden';
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeLightbox() {
|
||||||
|
if (lb) lb.hidden = true;
|
||||||
|
doc.body.style.overflow = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function stepLightbox(delta) {
|
||||||
|
if (!lbList.length) return;
|
||||||
|
lbIndex = (lbIndex + delta + lbList.length) % lbList.length;
|
||||||
|
showLightbox();
|
||||||
|
}
|
||||||
|
|
||||||
|
function initLightbox() {
|
||||||
|
var imgs = lightboxNodes();
|
||||||
|
if (!imgs.length) return;
|
||||||
|
imgs.forEach(function (img) {
|
||||||
|
var trigger = img.closest ? (img.closest('.thumb') || img) : img;
|
||||||
|
trigger.addEventListener('click', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
lbList = lightboxNodes();
|
||||||
|
lbIndex = lbList.indexOf(img);
|
||||||
|
if (lbIndex < 0) lbIndex = 0;
|
||||||
|
showLightbox();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 审核列表:多选 + 批量通过/驳回 ---------------- */
|
||||||
|
|
||||||
|
function initReviewList() {
|
||||||
|
var bar = doc.getElementById('batchbar');
|
||||||
|
var table = $('table');
|
||||||
|
if (!bar || !table) return;
|
||||||
|
var rows = $$('tbody tr', table).filter(function (r) { return r.dataset && r.dataset.id; });
|
||||||
|
if (!rows.length) return;
|
||||||
|
|
||||||
|
var selAll = doc.getElementById('sel-all');
|
||||||
|
var counter = doc.getElementById('sel-count');
|
||||||
|
|
||||||
|
function picked() {
|
||||||
|
return rows.filter(function (r) {
|
||||||
|
var c = $('input.row-pick', r);
|
||||||
|
return c && c.checked;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function refresh() {
|
||||||
|
var n = picked().length;
|
||||||
|
bar.hidden = n === 0;
|
||||||
|
if (counter) counter.textContent = '已选 ' + n + ' 条';
|
||||||
|
rows.forEach(function (r) {
|
||||||
|
var c = $('input.row-pick', r);
|
||||||
|
r.classList.toggle('selected', !!(c && c.checked));
|
||||||
|
});
|
||||||
|
if (selAll) {
|
||||||
|
selAll.checked = n > 0 && n === rows.length;
|
||||||
|
selAll.indeterminate = n > 0 && n < rows.length;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table.addEventListener('change', function (e) {
|
||||||
|
if (e.target.classList.contains('row-pick')) refresh();
|
||||||
|
});
|
||||||
|
if (selAll) {
|
||||||
|
selAll.addEventListener('change', function () {
|
||||||
|
rows.forEach(function (r) {
|
||||||
|
var c = $('input.row-pick', r);
|
||||||
|
if (c) c.checked = selAll.checked;
|
||||||
|
});
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function submit(action, reason) {
|
||||||
|
var items = picked().map(function (r) {
|
||||||
|
return { kind: r.dataset.kind, id: Number(r.dataset.id) };
|
||||||
|
});
|
||||||
|
if (!items.length) return;
|
||||||
|
var btns = $$('#batchbar button');
|
||||||
|
btns.forEach(function (b) { b.disabled = true; });
|
||||||
|
postJSON('/admin/api/reviews/batch', { action: action, reason: reason || '', items: items })
|
||||||
|
.then(function (res) {
|
||||||
|
// 成功的行直接从表格移除;失败的行标红保留,便于逐条重试
|
||||||
|
var failed = {};
|
||||||
|
(res.failed || []).forEach(function (f) { failed[f.kind + '/' + f.id] = f.error; });
|
||||||
|
items.forEach(function (it) {
|
||||||
|
var row = rows.filter(function (r) {
|
||||||
|
return r.dataset.kind === it.kind && Number(r.dataset.id) === it.id;
|
||||||
|
})[0];
|
||||||
|
if (!row) return;
|
||||||
|
if (failed[it.kind + '/' + it.id]) {
|
||||||
|
row.classList.add('failed-row');
|
||||||
|
row.title = failed[it.kind + '/' + it.id];
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
row.parentNode.removeChild(row);
|
||||||
|
rows = rows.filter(function (r) { return r !== row; });
|
||||||
|
});
|
||||||
|
var msg = (action === 'approve' ? '已通过 ' : '已驳回 ') + res.ok + ' 条';
|
||||||
|
if ((res.failed || []).length) msg += ',' + res.failed.length + ' 条失败';
|
||||||
|
toast(msg, !(res.failed || []).length);
|
||||||
|
refresh();
|
||||||
|
})
|
||||||
|
.catch(function (err) { toast(err.message, false); })
|
||||||
|
.then(function () { btns.forEach(function (b) { b.disabled = false; }); });
|
||||||
|
}
|
||||||
|
|
||||||
|
var approveBtn = doc.getElementById('batch-approve');
|
||||||
|
var rejectBtn = doc.getElementById('batch-reject');
|
||||||
|
var clearBtn = doc.getElementById('batch-clear');
|
||||||
|
if (approveBtn) {
|
||||||
|
approveBtn.addEventListener('click', function () {
|
||||||
|
var n = picked().length;
|
||||||
|
if (n && window.confirm('通过所选 ' + n + ' 条草稿并发布到正式表?')) submit('approve');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (rejectBtn) {
|
||||||
|
rejectBtn.addEventListener('click', function () {
|
||||||
|
var n = picked().length;
|
||||||
|
if (!n) return;
|
||||||
|
var reason = window.prompt('驳回所选 ' + n + ' 条的理由(必填,会记录到审核留痕)');
|
||||||
|
if (reason === null) return;
|
||||||
|
if (!reason.trim()) { toast('驳回理由不能为空', false); return; }
|
||||||
|
submit('reject', reason.trim());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (clearBtn) {
|
||||||
|
clearBtn.addEventListener('click', function () {
|
||||||
|
rows.forEach(function (r) {
|
||||||
|
var c = $('input.row-pick', r);
|
||||||
|
if (c) c.checked = false;
|
||||||
|
});
|
||||||
|
refresh();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 行内快捷通过(免进详情页)
|
||||||
|
$$('button[data-quick-approve]', table).forEach(function (btn) {
|
||||||
|
btn.addEventListener('click', function () {
|
||||||
|
var row = btn.closest('tr');
|
||||||
|
if (!row || !window.confirm('通过并发布这条草稿?')) return;
|
||||||
|
btn.disabled = true;
|
||||||
|
postJSON('/admin/api/reviews/batch', {
|
||||||
|
action: 'approve',
|
||||||
|
items: [{ kind: row.dataset.kind, id: Number(row.dataset.id) }],
|
||||||
|
}).then(function (res) {
|
||||||
|
if ((res.failed || []).length) {
|
||||||
|
toast(res.failed[0].error || '操作失败', false);
|
||||||
|
btn.disabled = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast('已通过', true);
|
||||||
|
row.parentNode.removeChild(row);
|
||||||
|
rows = rows.filter(function (r) { return r !== row; });
|
||||||
|
refresh();
|
||||||
|
}).catch(function (err) {
|
||||||
|
toast(err.message, false);
|
||||||
|
btn.disabled = false;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
refresh();
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 审核详情:主副图合并/拆出(AJAX)+ 键盘流 ---------------- */
|
||||||
|
|
||||||
|
function initReviewDetail() {
|
||||||
|
var grid = $('.grid');
|
||||||
|
if (!grid) return;
|
||||||
|
var cells = $$('.cell', grid);
|
||||||
|
var attachForm = doc.getElementById('batch');
|
||||||
|
|
||||||
|
// 群组状态回填:只改标签与可见性,不重排 DOM(避免打乱审核人的空间记忆)
|
||||||
|
function applyGroups(res) {
|
||||||
|
var images = res.images || [];
|
||||||
|
var byId = {};
|
||||||
|
images.forEach(function (i) { byId[i.id] = i; });
|
||||||
|
cells.forEach(function (cell) {
|
||||||
|
var st = byId[Number(cell.dataset.imgId)];
|
||||||
|
if (!st) return;
|
||||||
|
cell.classList.toggle('det', st.isDetail === 1);
|
||||||
|
var role = $('.role', cell);
|
||||||
|
if (role) {
|
||||||
|
role.textContent = st.isDetail === 1
|
||||||
|
? ('副图 → #' + st.parentId)
|
||||||
|
: ('主图' + (st.detailCount ? ' · ' + st.detailCount + ' 副图' : ''));
|
||||||
|
}
|
||||||
|
$$('[data-only="main"]', cell).forEach(function (el) { el.hidden = st.isDetail === 1; });
|
||||||
|
$$('[data-only="detail"]', cell).forEach(function (el) { el.hidden = st.isDetail !== 1; });
|
||||||
|
});
|
||||||
|
var stat = doc.getElementById('img-stat');
|
||||||
|
if (stat) stat.textContent = images.length + ' 张 · ' + (res.groups || 0) + ' 组';
|
||||||
|
}
|
||||||
|
|
||||||
|
// 拦截合并/拆出表单:成功就地更新,失败回退原生提交(保底可用)
|
||||||
|
function intercept(form, buildBody) {
|
||||||
|
form.addEventListener('submit', function (e) {
|
||||||
|
e.preventDefault();
|
||||||
|
var body = buildBody(form);
|
||||||
|
if (body === null) return; // 校验未过:交给原生提交去报错
|
||||||
|
postJSON(apiURL(form.getAttribute('action')), body)
|
||||||
|
.then(function (res) {
|
||||||
|
applyGroups(res);
|
||||||
|
cells.forEach(function (c) {
|
||||||
|
var chk = $('input[type="checkbox"]', c);
|
||||||
|
if (chk) chk.checked = false;
|
||||||
|
var radio = $('input[type="radio"]', c);
|
||||||
|
if (radio) radio.checked = false;
|
||||||
|
});
|
||||||
|
toast('已更新分组', true);
|
||||||
|
})
|
||||||
|
.catch(function (err) { toast(err.message, false); });
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (attachForm) {
|
||||||
|
intercept(attachForm, function (form) {
|
||||||
|
var main = $('input[type="radio"]:checked', grid);
|
||||||
|
var imgs = $$('input[type="checkbox"]:checked', grid).map(function (c) { return Number(c.value); });
|
||||||
|
if (!main || !imgs.length) { toast('请勾选图片,并在其中一张主图上点「主图」', false); return null; }
|
||||||
|
return { main: Number(main.value), imgs: imgs };
|
||||||
|
});
|
||||||
|
}
|
||||||
|
$$('form[action*="/detach"]', grid).forEach(function (form) {
|
||||||
|
intercept(form, function () { return {}; });
|
||||||
|
});
|
||||||
|
|
||||||
|
// ---- 键盘流(审核图片时手不离键盘) ----
|
||||||
|
var focusIndex = -1;
|
||||||
|
// scroll=false 用于初始化:只标记第一格,不要因为 scrollIntoView 把页面顶端的
|
||||||
|
// 字段表单(标题/年份/城市 + 通过按钮)滚出视野 —— 进页面必须先看到待填字段。
|
||||||
|
function setFocus(i, scroll) {
|
||||||
|
if (!cells.length) return;
|
||||||
|
focusIndex = Math.max(0, Math.min(cells.length - 1, i));
|
||||||
|
cells.forEach(function (c, j) { c.classList.toggle('focus', j === focusIndex); });
|
||||||
|
var el = cells[focusIndex];
|
||||||
|
if (scroll !== false && el && el.scrollIntoView) el.scrollIntoView({ block: 'nearest' });
|
||||||
|
}
|
||||||
|
function inField(t) {
|
||||||
|
return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT');
|
||||||
|
}
|
||||||
|
doc.addEventListener('keydown', function (e) {
|
||||||
|
if (!lb.hidden && lb) {
|
||||||
|
if (e.key === 'Escape') { closeLightbox(); e.preventDefault(); }
|
||||||
|
else if (e.key === 'ArrowLeft') { stepLightbox(-1); e.preventDefault(); }
|
||||||
|
else if (e.key === 'ArrowRight') { stepLightbox(1); e.preventDefault(); }
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (inField(e.target) || e.metaKey || e.ctrlKey || e.altKey) return;
|
||||||
|
var cur = cells[focusIndex];
|
||||||
|
switch (e.key) {
|
||||||
|
case 'j': setFocus(focusIndex + 1); e.preventDefault(); break;
|
||||||
|
case 'k': setFocus(focusIndex - 1); e.preventDefault(); break;
|
||||||
|
case 'Enter':
|
||||||
|
if (cur) {
|
||||||
|
var t = $('.thumb', cur) || $('img', cur);
|
||||||
|
if (t) { t.click(); e.preventDefault(); }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'x':
|
||||||
|
if (cur) {
|
||||||
|
var chk = $('input[type="checkbox"]', cur);
|
||||||
|
if (chk && !chk.disabled) { chk.checked = !chk.checked; e.preventDefault(); }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'm':
|
||||||
|
if (cur) {
|
||||||
|
var radio = $('input[type="radio"]', cur);
|
||||||
|
if (radio) { radio.checked = true; e.preventDefault(); }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'd':
|
||||||
|
if (cur) {
|
||||||
|
var df = $('form[action*="/detach"]', cur);
|
||||||
|
if (df) { df.requestSubmit ? df.requestSubmit() : df.submit(); e.preventDefault(); }
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case 'a':
|
||||||
|
// 通过 = 提交页面上那个「通过并发布」表单(复用当前输入框里的字段微调),故加一次确认
|
||||||
|
var af = $('form[action$="/approve"]');
|
||||||
|
if (af && window.confirm('通过并发布这条草稿?')) {
|
||||||
|
af.requestSubmit ? af.requestSubmit() : af.submit();
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 进页面先标记第一格(不滚动),键盘可直接用
|
||||||
|
if (cells.length) setFocus(0, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ---------------- 启动 ---------------- */
|
||||||
|
|
||||||
|
function boot() {
|
||||||
|
// 标记脚本已执行(便于人工/自动化排查「脚本没加载」这类问题)
|
||||||
|
doc.documentElement.setAttribute('data-admin-js', 'ready');
|
||||||
|
initLightbox();
|
||||||
|
initReviewList();
|
||||||
|
initReviewDetail();
|
||||||
|
var hint = doc.getElementById('hint-dismiss');
|
||||||
|
if (hint) {
|
||||||
|
hint.addEventListener('click', function () {
|
||||||
|
var box = hint.closest('.hint');
|
||||||
|
if (box) box.hidden = true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (doc.readyState === 'loading') doc.addEventListener('DOMContentLoaded', boot);
|
||||||
|
else boot();
|
||||||
|
})();
|
||||||
@ -42,7 +42,7 @@ func (h *AuthHandler) Me(c *gin.Context) {
|
|||||||
|
|
||||||
// Login 账号登录,校验账号密码后签发 access + refresh 双令牌。
|
// Login 账号登录,校验账号密码后签发 access + refresh 双令牌。
|
||||||
//
|
//
|
||||||
// POST /api/auth/login
|
// POST /api/v1/auth/login
|
||||||
// body: { account, password }
|
// body: { account, password }
|
||||||
// 成功:200 { access_token, refresh_token, expires_in, user }
|
// 成功:200 { access_token, refresh_token, expires_in, user }
|
||||||
func (h *AuthHandler) Login(c *gin.Context) {
|
func (h *AuthHandler) Login(c *gin.Context) {
|
||||||
@ -67,7 +67,7 @@ func (h *AuthHandler) Login(c *gin.Context) {
|
|||||||
|
|
||||||
// Refresh 用 refresh token 换取新的 access token。
|
// Refresh 用 refresh token 换取新的 access token。
|
||||||
//
|
//
|
||||||
// POST /api/auth/refresh
|
// POST /api/v1/auth/refresh
|
||||||
// body: { refresh_token }
|
// body: { refresh_token }
|
||||||
// 成功:200 { access_token, expires_in }
|
// 成功:200 { access_token, expires_in }
|
||||||
func (h *AuthHandler) Refresh(c *gin.Context) {
|
func (h *AuthHandler) Refresh(c *gin.Context) {
|
||||||
|
|||||||
183
internal/handler/backstage_api.go
Normal file
183
internal/handler/backstage_api.go
Normal file
@ -0,0 +1,183 @@
|
|||||||
|
// 审核页的 AJAX 接口(返回 JSON,不整页跳转)。
|
||||||
|
//
|
||||||
|
// 为什么需要它们:审核是图片密集型工作,原先前端只有「表单 POST → 302 → 整页刷新」一条路,
|
||||||
|
// 于是批量通过 20 条要点 20 次、每次刷新还会丢掉滚动位置与筛选状态。
|
||||||
|
// 这些接口让页面能用一小段 JS 做「多选一次性提交 / 局部更新」,
|
||||||
|
// 同时表单式路由(/admin/reviews/...)原样保留:关掉 JS 后台依然可用。
|
||||||
|
//
|
||||||
|
// 所有接口只做参数校验与组装,业务复用 ReviewService,不新增业务逻辑分支。
|
||||||
|
package handler
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
|
||||||
|
"fashionapi/internal/middleware"
|
||||||
|
)
|
||||||
|
|
||||||
|
// maxReviewBatch 单次批量审核的条数上限,避免一次请求打到几百条。
|
||||||
|
const maxReviewBatch = 200
|
||||||
|
|
||||||
|
type reviewBatchItem struct {
|
||||||
|
Kind string `json:"kind"`
|
||||||
|
ID uint32 `json:"id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type reviewBatchReq struct {
|
||||||
|
// Action: approve | reject
|
||||||
|
Action string `json:"action"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
Items []reviewBatchItem `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewBatch 批量通过 / 驳回审核记录。
|
||||||
|
//
|
||||||
|
// 请求体:{"action":"approve","items":[{"kind":"street","id":12}, ...]}
|
||||||
|
// 响应: {"ok":2,"failed":[{"id":9,"error":"..."}],"pending":17}
|
||||||
|
//
|
||||||
|
// 逐条处理并逐条回报失败原因(而不是整体失败):审核时最怕「一批提交下去不知道哪条没成」。
|
||||||
|
func (h *BackstageHandler) ReviewBatch(c *gin.Context) {
|
||||||
|
var req reviewBatchReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Action != "approve" && req.Action != "reject" {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "action 只能是 approve 或 reject"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Items) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "没有选中任何记录"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Items) > maxReviewBatch {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "一次最多处理 200 条"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Action == "reject" && req.Reason == "" {
|
||||||
|
// 驳回理由必填:留痕是审核流程的意义所在。
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "驳回必须填写理由"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := c.Request.Context()
|
||||||
|
registered := h.registeredKinds(ctx)
|
||||||
|
nameVal, _ := c.Get(middleware.ContextUsername)
|
||||||
|
reviewer := usernameString(nameVal)
|
||||||
|
|
||||||
|
okCount := 0
|
||||||
|
failed := make([]gin.H, 0)
|
||||||
|
for _, item := range req.Items {
|
||||||
|
if item.ID == 0 || !registered[item.Kind] {
|
||||||
|
failed = append(failed, gin.H{"kind": item.Kind, "id": item.ID, "error": "类型或 ID 无效"})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
if req.Action == "approve" {
|
||||||
|
_, err = h.review.Approve(ctx, item.Kind, item.ID, reviewer)
|
||||||
|
} else {
|
||||||
|
err = h.review.Reject(ctx, item.Kind, item.ID, reviewer, req.Reason)
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
failed = append(failed, gin.H{"kind": item.Kind, "id": item.ID, "error": err.Error()})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
okCount++
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"ok": okCount,
|
||||||
|
"failed": failed,
|
||||||
|
"pending": h.PendingReviews(ctx),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type reviewAttachReq struct {
|
||||||
|
Main uint32 `json:"main"`
|
||||||
|
Imgs []uint32 `json:"imgs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewAttachImagesAPI 把多张图并入指定主图(街拍审核页「合并选中为一组」的 AJAX 版)。
|
||||||
|
// 响应带回最新的图片分组投影,前端据此就地更新标签,无需整页刷新。
|
||||||
|
// 加 API 后缀以区别于表单版 ReviewAttachImages:本方法收发 JSON,表单版返回 302。
|
||||||
|
func (h *BackstageHandler) ReviewAttachImagesAPI(c *gin.Context) {
|
||||||
|
kind := c.Param("kind")
|
||||||
|
id := atoiDefault(c.Param("id"), 0)
|
||||||
|
if id == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req reviewAttachReq
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请求格式错误"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if req.Main == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请先指定哪张是主图"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(req.Imgs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "请先勾选要合并的图片"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.review.AttachImages(c.Request.Context(), kind, uint32(id), req.Main, req.Imgs); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondRecordImages(c, kind, uint32(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReviewDetachImageAPI 把副图拆出恢复为主图(AJAX 版),响应同 ReviewAttachImagesAPI。
|
||||||
|
func (h *BackstageHandler) ReviewDetachImageAPI(c *gin.Context) {
|
||||||
|
kind := c.Param("kind")
|
||||||
|
id := atoiDefault(c.Param("id"), 0)
|
||||||
|
imgID := atoiDefault(c.Param("img"), 0)
|
||||||
|
if id == 0 || imgID == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "参数无效"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := h.review.DetachImage(c.Request.Context(), kind, uint32(id), uint32(imgID)); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
h.respondRecordImages(c, kind, uint32(id))
|
||||||
|
}
|
||||||
|
|
||||||
|
// respondRecordImages 重新读取记录并把「图片分组状态」按前端需要的形状返回。
|
||||||
|
//
|
||||||
|
// 投影只带前端更新标签必需的字段,不重复返回图片 URL(页面上的 <img> 已经有了)。
|
||||||
|
func (h *BackstageHandler) respondRecordImages(c *gin.Context, kind string, id uint32) {
|
||||||
|
view, err := h.review.RecordDetail(c.Request.Context(), kind, id)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
images := make([]gin.H, 0, len(view.Images))
|
||||||
|
for _, im := range view.Images {
|
||||||
|
images = append(images, gin.H{
|
||||||
|
"id": im.ID,
|
||||||
|
"isDetail": im.IsDetail,
|
||||||
|
"parentId": im.ParentImageID,
|
||||||
|
"detailCount": im.DetailCount,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{
|
||||||
|
"images": images,
|
||||||
|
"imageCount": view.ImageCount,
|
||||||
|
"groups": len(view.Groups),
|
||||||
|
"pending": h.PendingReviews(c.Request.Context()),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// registeredKinds 已注册的审核类型集合(与 ReviewList 的校验口径一致)。
|
||||||
|
func (h *BackstageHandler) registeredKinds(ctx context.Context) map[string]bool {
|
||||||
|
registered := map[string]bool{}
|
||||||
|
for _, tab := range h.review.KindTabs(ctx) {
|
||||||
|
if tab.Kind != "" {
|
||||||
|
registered[tab.Kind] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return registered
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user