diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 0000000..6246fac --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,45 @@ +{ + "version": 1, + "hooks": { + "preToolUse": [ + { + "command": ".venv\\Scripts\\python.exe .cursor\\hooks\\ai_env_guard.py preToolUse", + "matcher": "Read|Glob|Edit|Write|ApplyPatch", + "timeout": 5, + "failClosed": true + } + ], + "beforeReadFile": [ + { + "command": ".venv\\Scripts\\python.exe .cursor\\hooks\\ai_env_guard.py beforeReadFile", + "matcher": "_archived", + "timeout": 5, + "failClosed": true + } + ], + "beforeMCPExecution": [ + { + "command": ".venv\\Scripts\\python.exe .cursor\\hooks\\ai_env_guard.py beforeMCPExecution", + "matcher": "pg-etl|pg-app", + "timeout": 5, + "failClosed": false + } + ], + "beforeShellExecution": [ + { + "command": ".venv\\Scripts\\python.exe .cursor\\hooks\\ai_env_guard.py beforeShellExecution", + "matcher": "git\\s+(reset|checkout)|--no-verify|psql|mcp-postgres|PG_DSN|APP_DB_DSN", + "timeout": 5, + "failClosed": false + } + ], + "postToolUse": [ + { + "command": ".venv\\Scripts\\python.exe .cursor\\hooks\\ai_env_guard.py postToolUse", + "matcher": "ApplyPatch|Edit|Write", + "timeout": 5, + "failClosed": false + } + ] + } +} diff --git a/.cursor/hooks/ai_env_guard.py b/.cursor/hooks/ai_env_guard.py new file mode 100644 index 0000000..2114988 --- /dev/null +++ b/.cursor/hooks/ai_env_guard.py @@ -0,0 +1,121 @@ +from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + + +ARCHIVED_RE = re.compile(r"(^|[/\\])_archived([/\\]|$)", re.IGNORECASE) +PROD_MCP_RE = re.compile(r"\b(pg-etl|pg-app)\b", re.IGNORECASE) +TEST_MCP_RE = re.compile(r"\b(pg-etl-test|pg-app-test)\b", re.IGNORECASE) +WRITE_SQL_RE = re.compile( + r"\b(insert|update|delete|truncate|drop|alter|create|grant|revoke|merge|copy|call|vacuum|reindex)\b", + re.IGNORECASE, +) + + +def load_input() -> dict: + try: + return json.load(sys.stdin) + except Exception: + return {} + + +def target_text(data: dict) -> str: + chunks = [json.dumps(data, ensure_ascii=False)] + for key in ("command", "file_path", "path"): + value = data.get(key) + if isinstance(value, str): + chunks.append(value) + tool_input = data.get("tool_input") + if isinstance(tool_input, dict): + chunks.append(json.dumps(tool_input, ensure_ascii=False)) + return "\n".join(chunks) + + +def allow() -> None: + print(json.dumps({"permission": "allow"}, ensure_ascii=False)) + + +def before_shell(data: dict) -> None: + text = target_text(data) + dangerous = [ + r"git\s+reset\s+--hard", + r"git\s+checkout\s+--", + r"--no-verify", + r"\bPG_DSN\b", + r"\bAPP_DB_DSN\b", + r"\bpsql\b", + ] + if any(re.search(pattern, text, re.IGNORECASE) for pattern in dangerous): + print(json.dumps({ + "permission": "ask", + "user_message": "命令命中 NeoZQYY 高风险规则:请确认是否需要执行。", + "agent_message": "执行前说明风险、目标库/文件、回滚方式;生产库默认不执行。", + }, ensure_ascii=False)) + return + allow() + + +def guard_archived(data: dict) -> None: + text = target_text(data) + if ARCHIVED_RE.search(text): + print(json.dumps({ + "permission": "deny", + "user_message": "已阻断:`_archived/` 是废弃归档目录,禁止读取、搜索或作为实现参考。", + "agent_message": "改用当前版本文件;如确需考古,请让用户明确授权并说明目的。", + }, ensure_ascii=False)) + return + allow() + + +def before_mcp(data: dict) -> None: + text = target_text(data) + if not PROD_MCP_RE.search(text) or TEST_MCP_RE.search(text): + allow() + return + if WRITE_SQL_RE.search(text): + print(json.dumps({ + "permission": "deny", + "user_message": "已阻断:生产库 MCP 写入/DDL 类操作默认禁止。请改用测试库验证,或让用户给出单次明确授权。", + "agent_message": "生产库仅允许人工确认后的只读排查;DDL/写入需走单独变更流程。", + }, ensure_ascii=False)) + return + print(json.dumps({ + "permission": "ask", + "user_message": "检测到生产库 MCP 只读调用。请确认本次查询目的、SQL 是否只读、是否会暴露敏感数据。", + "agent_message": "说明查询目的、目标库、SQL 摘要和风险后等待用户确认。", + }, ensure_ascii=False)) + + +def post_tool_use(data: dict) -> None: + text = target_text(data).replace("\\", "/") + hints = [] + if "/_archived/" in text or text.endswith("/_archived"): + hints.append("检测到 `_archived/` 路径:该目录内容已废弃,禁止作为实现参考。") + if "apps/demo-miniprogram/" in text: + hints.append("检测到 demo-miniprogram:这是 MOCK 标杆目录,修改前需确认目的。") + if re.search(r"(db/|docs/database/|migrations/|schemas/)", text): + hints.append("检测到数据库相关改动:如影响 schema,需同步 `docs/database/` 并提供 3 条验证 SQL。") + if re.search(r"(apps/backend/|apps/etl/|app/ai/|prompts/)", text): + hints.append("检测到后端/ETL/AI 逻辑改动:完成后需运行相关测试并写审计记录。") + if hints: + print(json.dumps({"additional_context": "\n".join(f"[NeoZQYY] {hint}" for hint in hints)}, ensure_ascii=False)) + + +def main() -> None: + mode = sys.argv[1] if len(sys.argv) > 1 else "" + data = load_input() + if mode in {"preToolUse", "beforeReadFile"}: + guard_archived(data) + elif mode == "beforeMCPExecution": + before_mcp(data) + elif mode == "beforeShellExecution": + before_shell(data) + elif mode == "postToolUse": + post_tool_use(data) + + +if __name__ == "__main__": + main() diff --git a/.cursor/rules/admin-web.mdc b/.cursor/rules/admin-web.mdc new file mode 100644 index 0000000..8a927fe --- /dev/null +++ b/.cursor/rules/admin-web.mdc @@ -0,0 +1,12 @@ +--- +description: admin-web 规则:React/Vite/AntD、AI 管理套件与前端验证。 +globs: apps/admin-web/** +alwaysApply: false +--- + +# admin-web 规则 + +- `admin-web` 是开发/运维后台,不是租户后台。 +- 遵循 React + Vite + Ant Design 现有页面和 API 封装风格。 +- AI 管理相关改动先查 2026-04-21、2026-04-30 审计记录。 +- 前端逻辑改动后优先运行 `pnpm test` / `pnpm lint`,无法运行需说明原因。 diff --git a/.cursor/rules/audit-history.mdc b/.cursor/rules/audit-history.mdc new file mode 100644 index 0000000..ddf8f14 --- /dev/null +++ b/.cursor/rules/audit-history.mdc @@ -0,0 +1,11 @@ +--- +description: 历史追溯规则:优先使用精简索引、审计记录和当前代码。 +globs: docs/** +alwaysApply: false +--- + +# 历史追溯规则 + +- 日常追溯先查 `docs/ai-env-history/README.md`、`docs/claude-history/`、`docs/audit/changes/`。 +- 历史摘要只解释来龙去脉,编码前仍以当前文件、当前 diff、当前测试为准。 +- 原始 JSONL 可用于追查细节,但不要把密钥、DSN、token 原文写入仓库文档。 diff --git a/.cursor/rules/backend-fastapi.mdc b/.cursor/rules/backend-fastapi.mdc new file mode 100644 index 0000000..8a38d96 --- /dev/null +++ b/.cursor/rules/backend-fastapi.mdc @@ -0,0 +1,14 @@ +--- +description: FastAPI 后端规则:响应包装、认证、AI 集成、RLS 与测试库。 +globs: apps/backend/** +alwaysApply: false +--- + +# 后端规则 + +- 2xx 响应经 `ResponseWrapperMiddleware` 包装为 `{ "code": 0, "data": }`。 +- 后端内部使用 snake_case,JSON 输出通过 `CamelModel` 转 camelCase。 +- admin、miniapp、tenant-admin 三类 JWT aud 不可混用。 +- 访问 ETL FDW/RLS 视图前必须设置 `app.current_site_id`。 +- AI 集成涉及 DashScope、熔断、限流、预算、缓存和运行日志,改动后必须查审计历史。 +- 后端验证默认在 `apps/backend` 下运行,使用测试库,禁止连正式库。 diff --git a/.cursor/rules/database.mdc b/.cursor/rules/database.mdc new file mode 100644 index 0000000..7321482 --- /dev/null +++ b/.cursor/rules/database.mdc @@ -0,0 +1,13 @@ +--- +description: 数据库规则:schema 变更、RLS 双 schema、文档同步和验证 SQL。 +globs: db/**,docs/database/** +alwaysApply: false +--- + +# 数据库规则 + +- 任何 PostgreSQL schema/迁移/DDL/ORM 结构变更必须同步 `docs/database/`。 +- 新建 DWS/DWD RLS 视图必须同时创建原 schema 和 `app` schema 视图。 +- 回滚需逆序 DROP/ALTER,并提供至少 3 条验证 SQL。 +- 默认使用 `TEST_DB_DSN` / `TEST_APP_DB_DSN`,禁止连正式库。 +- DDL 基线变更后运行 `tools/db/gen_consolidated_ddl.py` 并同步权威 DDL。 diff --git a/.cursor/rules/demo-miniprogram-protect.mdc b/.cursor/rules/demo-miniprogram-protect.mdc new file mode 100644 index 0000000..f6b8330 --- /dev/null +++ b/.cursor/rules/demo-miniprogram-protect.mdc @@ -0,0 +1,11 @@ +--- +description: demo-miniprogram 保护规则:假数据标杆,不删除不迁移到 _DEL。 +globs: apps/demo-miniprogram/** +alwaysApply: false +--- + +# demo-miniprogram 保护 + +- 本目录是假数据 MOCK 版小程序,用于页面样式和展示格式标杆校对。 +- 禁止删除、移入 `_DEL/` 或改造成真实 API 驱动。 +- 只有在用户明确要求校正 demo 标杆时才修改。 diff --git a/.cursor/rules/etl-feiqiu.mdc b/.cursor/rules/etl-feiqiu.mdc new file mode 100644 index 0000000..6ffc4c2 --- /dev/null +++ b/.cursor/rules/etl-feiqiu.mdc @@ -0,0 +1,14 @@ +--- +description: 飞球 ETL 规则:DWD-DOC 优先、金额口径、DWS 优先、禁止归档目录。 +globs: apps/etl/connectors/feiqiu/** +alwaysApply: false +--- + +# ETL 飞球规则 + +- 金额、支付、消费链路、字段语义优先参考 `apps/etl/connectors/feiqiu/docs/reports/DWD-DOC/`。 +- `consume_money` 禁止直接用于计算,使用 `items_sum` 拆分字段。 +- 助教费用必须区分 `assistant_pd_money` 和 `assistant_cx_money`。 +- 正向结算使用 `settle_type IN (1, 3)`,禁止随意读取 ODS 做业务计算。 +- DWS/DWD 汇总默认保持幂等,禁止 `TRUNCATE`。 +- 所有 `_archived/` 目录禁止读取或参考。 diff --git a/.cursor/rules/miniprogram.mdc b/.cursor/rules/miniprogram.mdc new file mode 100644 index 0000000..9f64c45 --- /dev/null +++ b/.cursor/rules/miniprogram.mdc @@ -0,0 +1,12 @@ +--- +description: 微信小程序规则:生产小程序、Donut/TDesign、demo 标杆对齐。 +globs: apps/miniprogram/** +alwaysApply: false +--- + +# 小程序规则 + +- `apps/miniprogram` 是生产小程序,数据来自后端 API。 +- UI 样式和展示格式需要参考 `apps/demo-miniprogram` 的 MOCK 标杆。 +- 改动关键交互、鉴权、API 字段或页面跳转后必须说明验证路径。 +- 涉及微信开发者工具时优先使用 `weixin-devtools-mcp` 或记录手工验证步骤。 diff --git a/.cursor/rules/neozqyy-core.mdc b/.cursor/rules/neozqyy-core.mdc new file mode 100644 index 0000000..7baf613 --- /dev/null +++ b/.cursor/rules/neozqyy-core.mdc @@ -0,0 +1,18 @@ +--- +description: NeoZQYY 核心工作规范:中文、调研、验证、审计、dirty tree 保护。 +alwaysApply: true +--- + +# NeoZQYY 核心规范 + +- 始终使用中文交流、解释、审计和文档;命令、API 字段、变量名保持原文。 +- 以 `AGENTS.md` 为权威规则;历史 `CLAUDE.md` 已并入 `AGENTS.md`,需考古时查 git 历史或 `docs/ai-env-history/`。 +- 逻辑改动前先做需求审问和前置调研;用户明确跳过时除外。 +- 逻辑改动后运行相关验证,输出 diff 摘要和未覆盖风险。 +- 不回滚用户已有改动,不使用破坏性 git 命令,除非用户明确要求。 +- 审计记录统一写入 `docs/audit/changes/`,`docs/audit/audit_dashboard.md` 只由脚本生成。 +- 历史追溯优先查 `docs/ai-env-history/`、`docs/claude-history/`、`docs/audit/`,再查原始对话。 +- 用户偏好模型为 GPT 5.5 与 Claude 4.7;模型选择由 Cursor UI/会话设置控制,规则只保留偏好。 +- CLI / Shell 中文处理必须优先确保 UTF-8:Python 用 `encoding="utf-8"` / `PYTHONUTF8=1`,CSV 给 Excel 用 `utf-8-sig`,PowerShell/Node 避免依赖系统默认 ANSI 编码。 +- 遇到中文乱码时,不要把乱码输出当作事实;先调整编码重跑,或明确说明终端编码异常并转述可确认的信息。 +- Shell 路径和参数含中文、空格或特殊字符时必须正确加引号;复杂中文输出优先用脚本或结构化 API,避免手写脆弱转义。 diff --git a/.cursor/skills/audit/SKILL.md b/.cursor/skills/audit/SKILL.md new file mode 100644 index 0000000..3b57772 --- /dev/null +++ b/.cursor/skills/audit/SKILL.md @@ -0,0 +1,110 @@ +--- +name: audit +description: /audit — 变更审计。从 Claude Code 命令迁移为 Cursor project skill;用户要求执行 audit、/audit 或相关流程时使用。 +disable-model-invocation: true +--- + +# /audit — 变更审计 + +回顾本次会话中你所做的所有文件变更,结合自动预扫描结果,执行审计落盘。 + +## 执行步骤 + +### 第 1 步:运行预扫描脚本(Python,零 token) + +运行: +```bash +python scripts/audit/prescan.py +``` + +该脚本自动完成: +- 从 git status 获取所有变更文件 +- 分类高风险文件 + 生成 risk_tags +- 合规检查:代码→文档映射、迁移 SQL 检测、DDL 基线检查 + +读取输出的 JSON。如果 `audit_required: false`,告知用户"无需审计"并结束。 + +**备选**:如果 git status 包含大量非本次会话的历史变更,可以用 `--files` 参数只传入本次会话的文件: +```bash +python scripts/audit/prescan.py --files "file1.py,file2.sql,..." +``` +文件列表从你的对话记忆(本次会话的 Edit/Write 工具调用)中提取。 + +### 第 2 步:补充语义上下文 + +预扫描脚本能告诉你"哪些文件变了、是否高风险、文档是否缺失",但它不知道**为什么改**。 + +从对话记忆中补充: +- 每个变更文件的修改原因(用户的需求是什么) +- 改动的技术思路和设计决策 +- 与其他模块的关联影响 + +将预扫描 JSON + 语义上下文合并,作为第 3 步的输入。 + +### 第 3 步:委托子代理写审计记录 + +用 Agent 工具启动子代理,传入: +1. 预扫描 JSON 结果(完整) +2. 每个变更的原因和内容概要(你补充的语义上下文) + +子代理的任务指令: + +> 在 `docs/audit/changes/` 目录下创建审计记录文件,文件名格式 `__<英文短标识>.md`。 +> +> 使用以下格式: +> +> ```markdown +> # 变更审计记录:<中文标题> +> +> | 字段 | 值 | +> |------|-----| +> | 日期 | YYYY-MM-DD HH:MM:SS | +> +> ## 操作摘要 +> <1-3 段,说清楚做了什么、为什么做> +> +> ## 变更文件 +> 按新增/修改/删除分组,每个文件一行,简要说明改动内容。 +> +> ## 改动注解 +> 对每个变更文件写注解: +> - 高风险文件(ETL 任务/后端路由/数据库迁移/金额相关):写详细注解(变更类型、原因、思路、结果) +> - 普通文件:一行简要说明 +> - 删除的文件:只记录删除原因 +> +> ## 数据库变更(如有) +> 列出新建/修改/删除的表、字段、约束、索引。标注迁移执行状态。 +> +> ## 风险与回滚 +> - 风险点(标注高/中/低) +> - 回滚要点 +> +> ## 验证 +> - 至少 1 条可执行的验证方式(测试命令 / SQL / 联调步骤) +> +> ## 合规检查 +> - 列出文档同步状态(已同步 / 待补齐 / 不适用) +> ``` +> +> 当前北京时间通过 `python -c "from datetime import datetime, timezone, timedelta; print(datetime.now(timezone(timedelta(hours=8))).strftime('%Y-%m-%d %H:%M:%S'))"` 获取。 +> +> 审计记录语言使用简体中文。 +> +> 完成后运行 `python scripts/audit/gen_audit_dashboard.py` 刷新审计一览表。 +> +> 最终只返回:done / files_written / next_step。 + +### 第 4 步:补齐缺失的文档同步 + +根据预扫描 JSON 中 `code_without_docs` 列出的不合规项,逐项补齐: +- 读取对应代码文件当前内容 +- 更新对应文档 + +如果补齐工作量大(>3 个文档),委托子代理处理。 + +### 第 5 步:向用户报告 + +简短回执: +- 审计记录文件路径 +- 合规检查结果(全部通过 / N 项已补齐 / N 项待用户处理) +- 下一步建议(如 "commit when ready") diff --git a/.cursor/skills/db-docs/SKILL.md b/.cursor/skills/db-docs/SKILL.md new file mode 100644 index 0000000..49561f4 --- /dev/null +++ b/.cursor/skills/db-docs/SKILL.md @@ -0,0 +1,69 @@ +--- +name: db-docs +description: /db-docs — 数据库文档同步。从 Claude Code 命令迁移为 Cursor project skill;用户要求执行 db-docs、/db-docs 或相关流程时使用。 +disable-model-invocation: true +--- + +# /db-docs — 数据库文档同步 + +当 PostgreSQL schema/表结构发生变化时,将变更以审计友好的方式落盘到 `docs/database/`。 + +## 触发条件 + +- 迁移脚本/DDL 修改(新增/删除/改表、字段、类型、默认值、非空、约束、索引、外键) +- 手工执行了 DDL + +## 执行步骤 + +### 第 1 步:识别结构性变化 + +从本次会话的改动中,列出新增/修改/删除的对象: +- schema / table / column / index / constraint / foreign key +- 明确变更前后差异(before/after) + +### 第 2 步:更新表结构文档 + +对每张受影响的表,更新 `docs/database/` 下对应的文档: +- 如果文档已存在:更新字段列表、约束、索引等 +- 如果文档不存在:基于以下模板创建 + +模板: +```markdown +# . + +## 概述 +<表的用途说明> + +## 字段 + +| 字段名 | 类型 | 可空 | 默认值 | 说明 | +|--------|------|------|--------|------| +| ... | ... | ... | ... | ... | + +## 约束与索引 +- PRIMARY KEY: ... +- UNIQUE: ... +- INDEX: ... + +## 关联 +- 上游:<数据来源> +- 下游:<被哪些模块/表消费> +``` + +特别注意金额类字段:标注精度、币种、舍入规则。 + +### 第 3 步:回滚与验证 + +写入审计友好的回滚和验证信息: +- DDL 回滚路径(必要时提供反向迁移 SQL) +- 至少 3 条验证 SQL(含约束/索引/关键字段检查) + +### 第 4 步:DDL 基线检查 + +检查 `docs/database/ddl/` 下的基线文件是否需要合并更新。如需要,更新基线。 + +### 第 5 步:输出摘要 + +- 更新/创建了哪些文档 +- 迁移脚本执行状态(已执行 / 待执行) +- DDL 基线状态(已合并 / 待合并) diff --git a/.cursor/skills/doc-sync/SKILL.md b/.cursor/skills/doc-sync/SKILL.md new file mode 100644 index 0000000..4f321bd --- /dev/null +++ b/.cursor/skills/doc-sync/SKILL.md @@ -0,0 +1,61 @@ +--- +name: doc-sync +description: /doc-sync — 逻辑改动后文档同步。从 Claude Code 命令迁移为 Cursor project skill;用户要求执行 doc-sync、/doc-sync 或相关流程时使用。 +disable-model-invocation: true +--- + +# /doc-sync — 逻辑改动后文档同步 + +检查本次会话中的逻辑改动是否需要同步更新文档,并执行同步。 + +## 触发条件 + +修改了以下任一类内容时应执行: +- 业务规则/计算口径/资金处理(精度、舍入、阈值) +- ETL/SQL 清洗聚合映射逻辑 +- API 行为(返回结构、错误码、鉴权/权限) +- 小程序关键交互流程 +- 数据库表结构 + +## 执行步骤 + +### 第 1 步:分类 + +判断本次会话的改动是否属于"逻辑改动"。如果只是纯格式化/拼写修正/注释调整,告知用户"无逻辑改动,无需文档同步"并结束。 + +### 第 2 步:逐项评估需要更新的文档 + +根据变更涉及的模块,评估以下文档是否需要更新: + +**各级 README.md**(只更新与本次变更相关的): +- `README.md`(根目录):项目总览、快速开始、环境变量、架构概述 +- `apps/backend/README.md`:后端 API 路由、配置、运行方式 +- `apps/etl/connectors/feiqiu/README.md`:ETL 任务清单、开发约定 +- `apps/miniprogram/README.md`:小程序页面结构 +- `apps/admin-web/README.md`:管理后台功能说明 +- `apps/tenant-admin/README.md`:租户管理后台功能说明 +- `packages/shared/README.md`:共享包说明 +- `db/README.md`:Schema 约定、迁移规范 + +规则:如果"对读者理解系统行为有帮助"就应更新。若某个 README 尚不存在但变更涉及该模块,应创建。 + +### 第 3 步:执行更新 + +对每个需要更新的文档: +1. 读取当前内容 +2. 根据本次变更更新相关段落 +3. 写入更新后的内容 + +如果更新工作量大(>3 个文档),委托子代理处理。 + +### 第 4 步:联动检查 + +- 如果涉及 DB schema 变化:提醒用户执行 `/db-docs` +- 如果涉及 API 变化:检查 `apps/backend/docs/API-REFERENCE.md` 是否已更新 + +### 第 5 步:输出摘要 + +- Changed:改了哪些文档 +- Why:原始原因 + 直接原因 +- Risk:风险点与回归范围 +- Verify:建议的验证步骤 diff --git a/.cursor/skills/pre-change/SKILL.md b/.cursor/skills/pre-change/SKILL.md new file mode 100644 index 0000000..3c3122c --- /dev/null +++ b/.cursor/skills/pre-change/SKILL.md @@ -0,0 +1,71 @@ +--- +name: pre-change +description: /pre-change — 逻辑改动前置调研。从 Claude Code 命令迁移为 Cursor project skill;用户要求执行 pre-change、/pre-change 或相关流程时使用。 +disable-model-invocation: true +--- + +# /pre-change — 逻辑改动前置调研 + +对即将修改的模块进行全面调研,输出上下文摘要供用户确认后再动手。 + +## 适用场景 + +任何逻辑改动(ETL/业务规则/API/数据模型/前端交互),写代码前执行。 + +## 执行步骤 + +### 第 1 步:识别改动范围 + +从用户需求中提取: +- 要修改的模块和文件 +- 涉及的数据表/API/页面 +- 预期的行为变化 + +### 第 2 步:委托 Explore 子代理调研 + +启动 Explore 子代理(thoroughness: very thorough),调研以下内容: + +1. **目标模块文件**:读取要修改的文件及其直接依赖 +2. **历史审计**:搜索 `docs/audit/changes/` 中相关模块的历史变更记录 +3. **相关文档**:README、PRD(`docs/prd/`)、BD 手册(`docs/database/`)、API 参考 +4. **调用关系**:要修改文件的调用方和被调用方 +5. **数据流向**:上游(数据从哪来)→ 当前模块 → 下游(数据到哪去) +6. **影响范围**:哪些模块/页面/任务可能受影响 + +### 第 3 步:输出「改动前上下文摘要」 + +格式: + +``` +## 改动前上下文摘要 + +### 模块职责 +<模块做什么,在系统中的角色> + +### 历史变更 +<近期审计记录中的相关改动,特别是踩坑记录> + +### 数据流向 +上游: <数据来源> +当前: <本模块处理> +下游: <消费方> + +### 影响范围 +- <受影响的模块/页面/任务列表> + +### 风险点 +- <可能的副作用、边界条件、兼容性问题> + +### 建议方案 +<基于调研结果的实施建议> +``` + +### 第 4 步:等待用户确认 + +输出摘要后,等待用户确认或调整方向,确认后再进入编码实施。 + +## 例外(无需执行此流程) + +- 纯格式调整、注释/文档纯文字修改 +- 用户明确说"直接改/跳过调研" +- 新建文件且不涉及已有逻辑 diff --git a/.cursor/skills/spec-close/SKILL.md b/.cursor/skills/spec-close/SKILL.md new file mode 100644 index 0000000..a3576f5 --- /dev/null +++ b/.cursor/skills/spec-close/SKILL.md @@ -0,0 +1,69 @@ +--- +name: spec-close +description: /spec-close — Spec 收尾通用流程。从 Claude Code 命令迁移为 Cursor project skill;用户要求执行 spec-close、/spec-close 或相关流程时使用。 +disable-model-invocation: true +--- + +# /spec-close — Spec 收尾通用流程 + +当一个功能 spec 开发完成时,执行此收尾检查清单确保质量闭环。 + +## 执行步骤 + +### 步骤 1:最终测试检查点(必选) + +- 运行 Monorepo 属性测试:`cd /c/NeoZQYY && pytest tests/ -v` +- 运行模块单元测试:`cd <模块路径> && pytest tests/ -v` +- 确保所有测试通过,有问题询问用户 + +### 步骤 2:前后端联调验证(涉及 API + 前端时必选) + +- 启动后端服务,使用测试库验证各端点完整请求-响应链路 +- 验证 JSON 响应结构与 Schema 定义一致(camelCase 序列化) +- 验证权限校验和数据隔离(`SET LOCAL app.current_site_id`)在真实请求中生效 +- 前端联调验证:确认前端页面能正确调用 API 并渲染数据 +- 验证空数据/降级场景下前端不崩溃 + +### 步骤 3:数据库变更审计与 DDL 合并(涉及 DB 改动时必选) + +- 审计本次实现中对数据库的所有改动(新建表、新增字段、新增索引、FDW 映射变更等) +- **必须通过 pg MCP 工具实际执行迁移 SQL**(禁止仅标记完成而不执行) +- 执行后用查询验证表/字段/索引已正确创建 +- RLS 视图双 schema:后端查询 `app.v_*` 视图,新建 DWS RLS 视图时必须同时在原 schema 和 `app` schema 下创建 +- 合并到主 DDL 基线文件(ETL → `docs/database/ddl/etl_feiqiu__.sql`,业务 → `docs/database/ddl/zqyy_app__.sql`) +- 编写回滚脚本(逆序 DROP/ALTER) + +### 步骤 4:BD 手册更新(涉及 DB 改动时必选) + +- 业务库 → `docs/database/BD_manual_*.md` +- ETL 库 → `apps/etl/connectors/feiqiu/docs/database/<层级>/main/BD_manual_*.md` +- FDW → `docs/database/BD_manual_fdw*.md` +- 每份手册必须包含:字段明细、约束与索引、验证 SQL(≥3 条)、兼容性影响、回滚策略 + +### 步骤 5:项目文档同步更新(按涉及范围裁剪) + +根据改动类型选择需要更新的文档: + +| 文档 | 更新条件 | +|------|----------| +| 模块 README | 模块内部结构变更时 | +| `apps/backend/docs/API-REFERENCE.md` | 新增/修改后端路由时 | +| `docs/contracts/openapi/backend-api.json` | 新增/修改 API 端点时 | +| `docs/DOCUMENTATION-MAP.md` | 新增任何文档条目时 | + +### 步骤 6:变更审计收口(涉及高风险路径时必选) + +执行 `/audit` 命令完成审计流程。 + +### 步骤 7:服务清理(启动了运行时服务时必选) + +- 关闭浏览器实例、停止后端和前端服务、清理资源 + +## 按 Spec 类型裁剪 + +| 类型 | 必选步骤 | +|------|---------| +| ETL 类(ODS/DWD/DWS) | 1, 3, 4, 5, 6 | +| 后端 API 类 | 1, 2, 5, 6 | +| 全栈类(前后端 + DB) | 1, 2, 3, 4, 5, 6 | +| 重构类 | 1, 5, 6 | diff --git a/.cursorignore b/.cursorignore new file mode 100644 index 0000000..af93448 --- /dev/null +++ b/.cursorignore @@ -0,0 +1,46 @@ +# Cursor 索引/上下文屏蔽列表 +# 目的:减少每次对话注入到 system prompt 的无关文件,降低 token 开销 +# 不影响本地文件浏览和手动 Read + +# ===== AI 历史会话归档(数百个 .md 文件,正常工作不需要 AI 看到) ===== +docs/ai-env-history/ +docs/claude-history/ + +# ===== 审计变更记录(按需手动 Read,不需要默认索引) ===== +docs/audit/changes/ +docs/audit/audit_dashboard.md + +# ===== 临时产物与归档 ===== +tmp/ +_DEL/ +.Deleted/ +export/ +reports/ +scripts/logs/ +.playwright-mcp/ + +# ===== 大型二进制/构建产物 ===== +node_modules/ +.venv/ +venv/ +dist/ +build/ +*.egg-info/ +htmlcov/ +.hypothesis/ +.pytest_cache/ + +# ===== 锁文件(很大且不需要 AI 阅读) ===== +pnpm-lock.yaml +package-lock.json +uv.lock + +# ===== 小程序打包产物 ===== +apps/*.zip +apps/miniprogram/.font_patch_tmp/ + +# ===== IDE/系统杂项 ===== +.idea/ +.vscode/ +*.lnk +*.swp \ No newline at end of file diff --git a/.gitignore b/.gitignore index 4ef2870..d4573c9 100644 --- a/.gitignore +++ b/.gitignore @@ -32,18 +32,18 @@ scripts/logs/ # ===== 环境配置(保留模板) ===== - .env - .env.local - !.env.template +.env +.env.local +!.env.template # ===== Node ===== node_modules/ # ===== Python 虚拟环境 ===== - .venv/ - venv/ - ENV/ - env/ +.venv/ +venv/ +ENV/ +env/ # ===== Python 构建产物 ===== .Python @@ -86,6 +86,12 @@ infra/**/*.secret # ===== Claude Code 本地配置 ===== .claude/settings.local.json +# ===== AI 历史会话归档(个人本地,不入库) ===== +docs/ai-env-history/sessions/ +docs/ai-env-history/conversation_index.csv +docs/ai-env-history/file_impact_index.csv +docs/claude-history/ + # ===== Windows 杂项 ===== *.lnk .Deleted/ diff --git a/.ignore b/.ignore new file mode 100644 index 0000000..e4a89ce --- /dev/null +++ b/.ignore @@ -0,0 +1,53 @@ +# ripgrep / fd / 终端搜索屏蔽列表 +# 目的:避免全仓 grep 扫描历史归档、缓存、构建产物和大锁文件。 + +# AI 历史与审计归档 +docs/ai-env-history/ +docs/claude-history/ +docs/audit/changes/ +docs/audit/audit_dashboard.md + +# 临时产物与运行日志 +tmp/ +_DEL/ +.Deleted/ +export/ +reports/ +logs/ +scripts/logs/ +.playwright-mcp/ +*.log +*.jsonl + +# 依赖、虚拟环境、缓存和构建产物 +node_modules/ +.venv/ +venv/ +ENV/ +env/ +dist/ +build/ +.vite/ +*.egg-info/ +htmlcov/ +.coverage +.hypothesis/ +.pytest_cache/ +pytest-cache-files-*/ + +# 大锁文件 +pnpm-lock.yaml +package-lock.json +uv.lock + +# 小程序打包产物 +apps/*.zip +apps/miniprogram/.font_patch_tmp/ + +# IDE / 系统杂项 +.idea/ +.vscode/ +*.lnk +*.swp +*.swo +*~ diff --git a/.mcp.json b/.mcp.json index 926296a..9eb2861 100644 --- a/.mcp.json +++ b/.mcp.json @@ -1,55 +1,48 @@ { "mcpServers": { "pg-etl": { - "command": "uvx", - "args": ["postgres-mcp", "--access-mode=unrestricted"], - "env": { - "DATABASE_URI": "postgresql://local-Python:Neo-local-1991125@100.64.0.4:5432/etl_feiqiu" - }, + "command": "powershell.exe", + "args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "C:\\Project\\NeoZQYY\\tools\\codex\\mcp-postgres.ps1", "PG_DSN"], "disabled": true }, "pg-etl-test": { - "command": "uvx", - "args": ["postgres-mcp", "--access-mode=unrestricted"], - "env": { - "DATABASE_URI": "postgresql://local-Python:Neo-local-1991125@100.64.0.4:5432/test_etl_feiqiu" - }, + "command": "powershell.exe", + "args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "C:\\Project\\NeoZQYY\\tools\\codex\\mcp-postgres.ps1", "TEST_DB_DSN"], "disabled": false }, "pg-app": { - "command": "uvx", - "args": ["postgres-mcp", "--access-mode=unrestricted"], - "env": { - "DATABASE_URI": "postgresql://local-Python:Neo-local-1991125@100.64.0.4:5432/zqyy_app" - }, + "command": "powershell.exe", + "args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "C:\\Project\\NeoZQYY\\tools\\codex\\mcp-postgres.ps1", "APP_DB_DSN"], "disabled": true }, "pg-app-test": { - "command": "uvx", - "args": ["postgres-mcp", "--access-mode=unrestricted"], - "env": { - "DATABASE_URI": "postgresql://local-Python:Neo-local-1991125@100.64.0.4:5432/test_zqyy_app" - }, + "command": "powershell.exe", + "args": ["-NoProfile", "-ExecutionPolicy", "Bypass", "-File", "C:\\Project\\NeoZQYY\\tools\\codex\\mcp-postgres.ps1", "TEST_APP_DB_DSN"], "disabled": false }, "weixin-devtools-mcp": { - "command": "cmd", - "args": ["/c", "npx", "-y", "weixin-devtools-mcp", "--tools-profile=full", "--ws-endpoint=ws://127.0.0.1:9420"], + "command": "C:\\nvm4w\\nodejs\\npx.cmd", + "args": ["-y", "weixin-devtools-mcp", "--tools-profile=full", "--ws-endpoint=ws://127.0.0.1:9420"], "env": { - "WECHAT_DEVTOOLS_CLI": "C:\\dev\\WechatDevtools\\cli.bat", - "WECHAT_DEVTOOLS_PROJECT": "C:\\Project\\NeoZQYY\\apps\\miniprogram" + "PATH": "C:\\nvm4w\\nodejs;C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\Wbem;C:\\Windows\\System32\\WindowsPowerShell\\v1.0", + "WECHAT_DEVTOOLS_CLI": "C:\\dev\\wechat-devtools-cli.bat", + "WECHAT_DEVTOOLS_PROJECT": "C:\\Project\\NeoZQYY\\apps\\miniprogram\\miniprogram" }, "disabled": false }, "playwright": { - "command": "cmd", - "args": ["/c", "npx", "@playwright/mcp@latest"], + "command": "C:\\nvm4w\\nodejs\\npx.cmd", + "args": ["@playwright/mcp@latest"], + "env": { + "PATH": "C:\\nvm4w\\nodejs;C:\\Windows\\System32;C:\\Windows;C:\\Windows\\System32\\Wbem;C:\\Windows\\System32\\WindowsPowerShell\\v1.0" + }, "disabled": false }, "openapi": { - "command": "uv", + "command": "C:\\Dev\\miniconda3\\Scripts\\uv.exe", "args": [ "tool", "run", + "--python", "3.12", "--from", "awslabs.openapi-mcp-server@latest", "--with", "fastmcp>=2.14.0,<3.0.0", "awslabs.openapi-mcp-server.exe", diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..3b0f4b7 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,130 @@ +# AGENTS.md + +NeoZQYY Monorepo 顶层规则。子模块详细规范见各自 `apps/*/AGENTS.md`、`docs/database/`、`apps/etl/connectors/feiqiu/AGENTS.md`。 + +## 语言(强制) + +始终中文:对话、解释、代码注释、commit message(中文描述 + 英文 Co-Authored-By 签名)、PR、审计、文档、错误日志。 +保持原文:变量/函数/类名、第三方 API 字段名、CLI 命令、技术术语。 +禁止英文段落或英文标题。 + +## CLI / Shell 中文处理 + +- Python `encoding="utf-8"` / `PYTHONUTF8=1`;CSV 给 Excel 用 `utf-8-sig`;PowerShell/Node 不依赖系统 ANSI。 +- 终端中文乱码时,不当作事实;先调编码重跑,或说明"终端编码异常,结果需复核"。 +- Shell 路径含中文/空格/特殊字符必须加引号;复杂中文输出走脚本或结构化 API。 + +## 项目概览 + +NeoZQYY = 台球门店全栈数据平台。多门店隔离(`site_id` + RLS),中文领域语言,CNY,金额 `numeric(2)`。 + +| 目录 | 用途 | +|------|------| +| `apps/etl/connectors/feiqiu/` | 飞球 ETL:API → ODS → DWD → DWS | +| `apps/backend/` | FastAPI(JWT 双认证 + WebSocket + AI) | +| `apps/miniprogram/` | 微信小程序 C 端(Donut + TDesign) | +| `apps/admin-web/` | 系统管理后台(开发/运维视角,操作 ETL 库) | +| `apps/tenant-admin/` | 租户管理后台(门店管理员视角,操作业务库) | +| `apps/demo-miniprogram/` | MOCK 标杆小程序(样式校对,禁改) | +| `apps/mcp-server/` | MCP Server(PostgreSQL 只读) | +| `packages/shared/` | 跨项目共享包 | +| `db/` | DDL(`schemas/`)+ 迁移 + FDW | +| `tools/` | 通用工具 | +| `scripts/ops/` | 日常运维脚本 | + +技术栈:Python 3.10+ uv workspace、React+Vite+AntD、PostgreSQL 四库(`etl_feiqiu`/`test_etl_feiqiu`/`zqyy_app`/`test_zqyy_app`,DSN `PG_DSN`/`APP_DB_DSN`)。配置分层 `.env` < `.env.local` < env < CLI。 + +## 数据库 + +- 六层 Schema:`meta` → `ods` → `dwd` → `core` → `dws` → `app`(RLS 视图) +- 跨库:`zqyy_app` 通过 FDW 只读映射 `etl_feiqiu.app` +- RLS:`site_id` + `app.current_site_id` 会话变量 +- **RLS 双 Schema 踩坑**:DWS/DWD 表的 RLS 视图必须同时建在 `dws` 和 `app` schema,后端走 `app.v_*`。只建 `dws` 会让后端查询失败。回滚需逆序 DROP。 + +## 飞球数据规范(速查) + +- `consume_money` 禁止直接计算 → 用 `items_sum` 拆分字段 +- 取数优先级:DWS > DWD > 禁止 ODS +- 参考优先级:DWD-DOC > DWS 权威规范 > BD 手册 > ETL 任务文档 > DDL 注释 +- `_archived/` 目录禁止读取或参考 +- 完整规则见 `apps/etl/connectors/feiqiu/AGENTS.md` + +## 文件归属 + +| 类型 | 位置 | +|------|------| +| 模块内文档/测试/脚本 | 模块内 `docs/`、`tests/`、`scripts/` | +| 跨模块文档 | 根 `docs/` | +| Monorepo 守护测试 | 根 `tests/` | +| 日常运维脚本 | `scripts/ops/` | +| 通用工具 | `tools/`(按类型分子目录) | +| 审计记录 | 根 `docs/audit/changes/__.md`(禁止写子模块) | +| 数据库文档 | 根 `docs/database/` | +| 归档/待删 | `_DEL/`(保持原路径,定期清理) | + +`docs/audit/audit_dashboard.md` 由 `scripts/audit/gen_audit_dashboard.py` 生成,勿手动编辑。 + +## 编码前需求审问(强制) + +新建功能/接口、重构、多模块联动、需求模糊时: +1. 不立即动手,先提问循环(每轮 3-5 个问题) +2. 必问:用户角色、核心操作、数据写入/展示/来源、错误/成功反馈、认证权限、存储(哪库/新表)、终端适配、边界(并发/幂等/超时) +3. 输出「需求确认摘要」,用户确认后实施 + +例外:用户说"直接改/跳过审问"、Bug 有明确复现步骤、纯格式调整、已有 spec + +## 逻辑改动前置调研(强制) + +任何逻辑改动(ETL/业务规则/API/数据模型/前端交互): +1. 委托 Explore 子代理调研:目标模块、`docs/audit/changes/` 历史、调用方/被调用方、数据流向、影响范围 +2. 输出「改动前上下文摘要」,用户确认后实施 + +流程:需求审问 → 确认 → 前置调研 → 确认 → 编码 + +例外:纯格式/注释/文档修改、用户说"直接改/跳过调研"、新建独立文件 + +## 改动后验证(强制) + +1. 运行相关测试(单元/集成/lint),不能运行需说明原因 +2. 输出 diff 摘要(文件清单 + 每个改动要点) +3. 列出未覆盖风险点(未测路径、副作用、需人工验证场景) + +例外:纯格式/文档/注释、用户说"跳过验证" + +## 数据库 Schema 变更 + +修改 PostgreSQL schema(迁移/DDL/表定义/ORM)时,必须同步 `docs/database/`:变更说明、兼容性影响、回滚策略、≥3 条校验 SQL。 + +## 测试环境 + +1. `load_dotenv` 加根 `.env`;必需变量缺失立即报错,禁止静默回退空串 +2. cwd 与正式一致:ETL → `apps/etl/connectors/feiqiu/`、后端 → `apps/backend/` +3. 配置走 `AppConfig.load()`,不得为测试构造简化配置 +4. 用测试库(`TEST_DB_DSN`),禁止连正式库 +5. 属性测试分组执行(`-k` 筛选),禁止一次性全跑;hypothesis 默认 `max_examples=100` + +例外:用户指定简化环境、纯单元测试用 FakeDB/FakeAPI、`--dry-run` CLI 验证 + +## 脚本规范 + +- 复杂操作写 Python 脚本,避免复杂 shell +- 一次性运维 → `scripts/ops/`;模块专属 → 模块内 `scripts/` +- `scripts/ops/` 不在 uv workspace 内,导入 ETL 纯函数用 `importlib.util` + stub(参考 `scripts/ops/backfill_finance_area_daily.py`) + +## 子代理 + +- 委托:批量读 ≥3 文件、大范围搜索、不熟悉模块探索、多步 shell +- 主流程直接处理:单文件、单命令、小范围精确搜索 + +## 审计 + +任何逻辑改动必须可追溯、可验证、可回滚。 + +完成一轮后用 `/audit`: +1. `python scripts/audit/prescan.py` 预扫描(自动识别+分类+合规,零 token) +2. 补充语义上下文(从对话提取每变更原因) +3. 委托子代理写 `docs/audit/changes/` +4. 补齐文档同步 +5. `python scripts/audit/gen_audit_dashboard.py` 刷新一览表 + +`prescan.py --files` 参数:git status 含大量历史未提交时,只传本次会话文件列表。 diff --git a/apps/backend/AGENTS.md b/apps/backend/AGENTS.md new file mode 100644 index 0000000..2c3eadd --- /dev/null +++ b/apps/backend/AGENTS.md @@ -0,0 +1,52 @@ +# AGENTS.md - Backend (FastAPI) + +进入本目录时自动加载。 + +## 架构模式 + +### 全局响应包装 + +`ResponseWrapperMiddleware` 把所有 2xx 响应包为 `{ "code": 0, "data": }`。 +非 2xx 响应保持原样。前端统一通过 `response.data` 解包。 + +### 序列化 + +`CamelModel` 基类:snake_case -> camelCase 自动转换(小程序 API 用)。 +后端代码始终用 snake_case,JSON 输出自动转驼峰。 + +### JWT 双认证 + +| 认证方式 | 用途 | 表 | JWT aud | +|---------|------|-----|---------| +| 用户名+密码 | admin-web 登录 | `auth.admin_users` | `admin` | +| 微信 code | 小程序登录 | `auth.users` | `miniapp` | +| 用户名+密码 | tenant-admin 登录 | `auth.tenant_admins` | `tenant-admin` | + +待审核用户有 limited token(仅可访问审核状态接口)。 + +### AI 集成 + +8 个千问应用通过 DashScope SDK: +chat / finance / clue / analysis / tactics / note / customer / consolidate + +特性:熔断(连续失败自动断路)、限流(每分钟/每日)、预算追踪、对话缓存。 + +### 后台服务(lifespan) + +- `TaskQueue`:按 site_id 消费,FIFO 队列 +- `Scheduler`:读 `meta.scheduled_tasks` 自动入队 +- 4 个触发器:日结/月结/工资/关系指数 + +### 数据库访问 + +- 业务库通过 `APP_DB_DSN` 直连 `zqyy_app` +- ETL 数据通过 FDW 映射的 `app.v_*` RLS 视图访问 +- 查询前必须 `SET LOCAL app.current_site_id = :site_id` + +## 测试 + +```bash +cd apps/backend && pytest tests/ -v +``` + +使用测试库(`TEST_APP_DB_DSN`),禁止连正式库。 diff --git a/apps/demo-miniprogram/AGENTS.md b/apps/demo-miniprogram/AGENTS.md new file mode 100644 index 0000000..ea46a30 --- /dev/null +++ b/apps/demo-miniprogram/AGENTS.md @@ -0,0 +1,18 @@ +# AGENTS.md - demo-miniprogram + +**禁止删除或移入 `_DEL/`。** + +本目录是假数据 MOCK 版小程序,使用硬编码数据驱动所有页面,不连接后端 API。 + +## 用途 + +- 页面样式和展示格式的**标杆校对**:开发 `apps/miniprogram/` 时,以本目录的 UI 效果为参考基准 +- 快速预览各页面在不同数据状态下的渲染效果,无需启动后端服务 + +## 与 miniprogram 的关系 + +| | `apps/miniprogram/` | `apps/demo-miniprogram/` | +|--|---------------------|--------------------------| +| 数据来源 | 后端 API | 硬编码假数据 | +| 用途 | 生产代码 | 样式参考 / UI 校对 | +| 可独立运行 | 需后端 | 可独立运行 | diff --git a/apps/etl/connectors/feiqiu/AGENTS.md b/apps/etl/connectors/feiqiu/AGENTS.md new file mode 100644 index 0000000..43bcc4b --- /dev/null +++ b/apps/etl/connectors/feiqiu/AGENTS.md @@ -0,0 +1,81 @@ +# AGENTS.md - ETL Feiqiu Connector + +进入本目录时自动加载。包含 DWD 和 DWS 层的强制业务规则。 + +## DWD-DOC 标杆文档(权威数据源) + +`docs/reports/DWD-DOC/` 是业务模型与财务数据的权威标杆文档。所有涉及金额口径、支付渠道、消费链路、账务公式、字段语义的开发工作,必须以此目录为第一参考源。 + +### 文档清单 + +| 文件 | 内容 | 关键规则 | +|------|------|----------| +| `01-business-panorama.md` | 消费链路 + 优惠机制 + 消费场景 | settle_type 枚举、助教费用拆分、团购券三层价格 | +| `02-accounting-panorama.md` | 支付渠道 + 对账公式 + consume_money 口径 | 支付渠道恒等式、F2 三期公式 | +| `03-financial-panorama.md` | 收入构成 + 储值卡资金流 + 对账矩阵 | 平台结算互斥关系 | +| `04-dimension-panorama.md` | 维度表与主数据全景 | SCD2 维度取值规则 | +| `05-f2-balance-audit.md` | F2 收支平衡公式专项 | 三期公式 + 139 笔失败根因 | +| `06-calibration-checklist.md` | 校准清单 + 验证 SQL | 全部验证公式集中 | +| `consume/consume-money-caliber.md` | consume_money 口径变化时间线 | 三种口径(A/B/C)定义与切换时间点 | + +### DWD 强制规则(12 条) + +1. **consume_money 禁止直接用于计算**:存在三种历史口径(A/B/C)混合,DWS 层及下游统一使用 `items_sum = table_charge_money + goods_money + assistant_pd_money + assistant_cx_money + electricity_money` +2. **助教费用必须拆分**:使用 `assistant_pd_money`(陪打)和 `assistant_cx_money`(超休),禁止使用 `service_fee` / `ASSISTANT_BASE` / `ASSISTANT_BONUS`(`service_fee` 仅在平台结算表中表示"平台服务费",语义不同) +3. **支付渠道恒等式**:`balance_amount = recharge_card_amount + gift_card_amount`(100% 成立),三者不可重复计算 +4. **settle_type 过滤**:正向交易取 `IN (1, 3)`,本表无 `is_delete` 字段 +5. **电费未启用**:`electricity_money` 全为 0,`gross_amount` 不含电费是正确的 +6. **折扣互斥**:`discount_manual`(大客户优惠)与 `discount_other` 互斥,两者之和 = `adjust_amount` +7. **现金流互斥**:`cash_inflow_total` 中 `platform_settlement_amount` 和 `groupbuy_pay_amount` 互斥 +8. **废单判断**:使用 `dwd_assistant_service_log_ex.is_trash`,`dwd_assistant_trash_event` 已废弃 +9. **储值卡字段命名**:DWS 层使用 `balance_pay`/`recharge_card_pay`/`gift_card_pay`;财务日报用 `recharge_card_consume` +10. **会员字段断档(DQ-6)**:`settlement_head.member_phone/member_name` 自 2025-12 起全为 NULL -> 通过 `member_id` LEFT JOIN `dwd.dim_member`(`scd2_is_current=1`) +11. **会员卡字段断档(DQ-7)**:`settlement_head.member_card_type_name` 自 2025-07-21 起全为 NULL -> 通过 `member_id` LEFT JOIN `dwd.dim_member_card_account`(`scd2_is_current=1`)。通用规则:结算单上所有会员冗余字段均不可靠 +12. **支付方式拆分(DQ-8)**:`dwd_settlement_head_ex.cash_amount`/`online_amount` 不可靠。正确来源是 `dwd_payment` 表:`payment_method=2` 现金,`payment_method=4` 扫码。通过 `relate_type=2` + `relate_id` 关联结算单 + +## DWS 层权威规范 + +> DWD 12 条在 DWS 层同样生效。冲突时以 DWD-DOC 为准。 + +### 幂等更新策略 + +- 汇总表默认 delete-before-insert(按日期范围 + site_id 先删后插) +- 库存表使用 upsert(`ON CONFLICT DO UPDATE`) +- 禁止 TRUNCATE + +### 课程类型与定价 + +- 课程类型通过 `cfg_skill_type` 映射(`skill_id` -> `course_type_code`:BASE/BONUS/ROOM),禁止硬编码 +- 定价通过 `cfg_assistant_level_price` 按 SCD2 生效期 as-of join,禁止硬编码价格 +- 包厢课统一 138 元/小时(`dws.salary.room_course_price`) + +### 绩效档位与工资 + +- 绩效档位通过 `cfg_performance_tier` 按有效业绩小时数匹配 `[min_hours, max_hours)` 区间 +- 新入职折算:入职日期在当月 1 日后按日均 × 30 定档;> 25 日最高 T2 +- 奖金通过 `cfg_bonus_rules`:SPRINT 不累计取最高档,TOP_RANK 按排名(1000/600/400 元) +- 排名使用 `calculate_rank_with_ties()`,相同业绩并列 + +### 会员与散客 + +- 散客:`member_id <= 0`,不计入会员统计(但计入助教业绩) +- 客户分层:高价值(90 天 >= 3 次且 >= 1000 元)-> 中等 -> 低活跃 -> 流失 +- 会员信息一律通过 ID 关联维度表 + +### 时间窗口与调度 + +- 滚动窗口标准集:7/10/15/30/60/90 天 +- 月度任务宽限期:月初前 5 天可处理上月数据 +- 工资计算周期:月初前 5 天运行 + +### 指数参数 + +- 所有权重和阈值通过 `cfg_index_parameters` 按 `index_type`(WBI/NCI/RS/OS/MS/ML/SPI)加载,禁止硬编码 + +### 台桌分类 + +- `cfg_area_category` 仅精确匹配 + 兜底:BILLIARD/SNOOKER/OTHER。`BILLIARD_VIP` 已废弃 + +### 参考优先级 + +DWD-DOC > DWS 权威规范 > BD 手册 > ETL 任务文档 > 业务规则文档 > DDL 注释 diff --git a/db/AGENTS.md b/db/AGENTS.md new file mode 100644 index 0000000..720b324 --- /dev/null +++ b/db/AGENTS.md @@ -0,0 +1,60 @@ +# AGENTS.md - Database (DDL / Migrations / Seeds) + +进入本目录时自动加载。 + +## Schema 变更规则 + +修改任何影响 PostgreSQL schema 的内容(迁移脚本/DDL/表定义)时,必须同步更新 `docs/database/`: + +1. **变更说明**:新增/修改/删除的表、字段、约束、索引 +2. **兼容性**:对 ETL、后端 API、小程序字段映射的影响 +3. **回滚策略**:如何撤销(DDL 回滚 / 数据回填) +4. **验证步骤**:至少 3 条校验 SQL + +## RLS 视图双 Schema 规则 + +新建 DWS/DWD 表的 RLS 视图必须同时在原 schema(如 `dws`)和 `app` schema 创建: + +```sql +-- 1. 原 schema +CREATE VIEW dws.v_xxx AS SELECT ... WHERE site_id = current_setting('app.current_site_id')::int; + +-- 2. app schema(后端通过此路径访问) +CREATE VIEW app.v_xxx AS SELECT ... WHERE site_id = current_setting('app.current_site_id')::int; +``` + +回滚需逆序 DROP 两个 schema 的视图。只在原 schema 创建会导致后端查询失败。 + +## 目录结构 + +```text +db/ +├── etl_feiqiu/ +│ ├── schemas/ # 权威 DDL — 六层完整定义(meta/ods/dwd/core/dws/app) +│ ├── migrations/ # 未来增量迁移(v1 已全部归档) +│ ├── ods/ # ODS 补充脚本 +│ └── scripts/ # 测试数据库脚本 +├── zqyy_app/ +│ ├── schemas/ # 权威 DDL — 三层完整定义(public/auth/biz) +│ ├── migrations/ # 未来增量迁移(v1 已全部归档) +│ └── scripts/ # 测试数据库脚本 +├── fdw/ # FDW 跨库只读映射(正向 + 反向 + 测试环境) +└── _archived/ # 归档(v1 迁移 39 个、旧基线) +``` + +v1 阶段种子数据已合并进 `schemas/` 对应 DDL 文件末尾,不再单独维护。 + +## DDL 刷新 + +修改 schema 后,重新生成完整 DDL: + +```bash +PYTHONUTF8=1 python tools/db/gen_consolidated_ddl.py +``` + +输出到 `docs/database/ddl/`,然后复制到 `db/*/schemas/` 保持同步。 + +## 测试规范 + +- 数据库操作使用测试库(`TEST_DB_DSN` / `TEST_APP_DB_DSN`),禁止连正式库 +- 迁移脚本在测试库执行后需验证表结构 diff --git a/docs/audit/changes/2026-04-29__codex_migration_and_claude_history_archive.md b/docs/audit/changes/2026-04-29__codex_migration_and_claude_history_archive.md new file mode 100644 index 0000000..e154dd3 --- /dev/null +++ b/docs/audit/changes/2026-04-29__codex_migration_and_claude_history_archive.md @@ -0,0 +1,60 @@ +# 变更审计记录:Codex 深度迁移与 Claude 历史摘要归档 + +| 字段 | 值 | +|------|-----| +| 日期 | 2026-04-29 03:57:55 | + +## 操作摘要 + +本轮将 Claude Code 的项目规则、用户习惯、skills、agents、rules 和 NeoZQYY 项目历史会话迁移到 Codex 可读取的结构。迁移目标是让 Codex 尊重用户此前的使用习惯,并在后续修改代码时能追溯“哪次 Claude 会话改了什么、影响了什么”。 + +迁移没有把 Claude 原始对话全文注入 Prompt,而是生成脱敏摘要、会话索引和文件反向索引,避免过时信息和敏感信息污染日常上下文。 + +## 变更文件 + +### 新增 + +- `AGENTS.md`:根项目 Codex 工作规范。 +- `apps/backend/AGENTS.md`:后端模块 Codex 规则。 +- `apps/etl/connectors/feiqiu/AGENTS.md`:ETL 飞球模块 Codex 规则。 +- `apps/demo-miniprogram/AGENTS.md`:demo 小程序模块保护规则。 +- `db/AGENTS.md`:数据库目录 Codex 规则。 +- `tools/codex/mcp-postgres.ps1`:Codex PostgreSQL MCP 启动脚本,按变量名读取 `.env` / `.env.local` / 当前环境。 +- `tools/codex/migrate_claude_assets.py`:Claude 资产迁移脚本。 +- `docs/codex_migration.md`:Codex 迁移状态说明。 +- `docs/claude-history/`:Claude 会话摘要归档、会话索引和文件反向索引。 + +## 改动注解 + +- `tools/codex/migrate_claude_assets.py`:实现可重复迁移流程,转换 Claude skills、agents、rules,生成全局 Codex 习惯文件,并对 Claude JSONL 历史做脱敏摘要和索引。 +- `tools/codex/mcp-postgres.ps1`:避免把数据库 DSN 明文写入 Codex 配置,支持 `-ValidateOnly` 做环境巡检。 +- `docs/claude-history/session_index.csv`:记录 94 个 Claude 会话的时间范围、影响范围、风险标签和摘要文件。 +- `docs/claude-history/file_index.csv`:记录 246 个被编辑文件与 Claude 会话的关联,支持按文件追溯。 +- `docs/codex_migration.md`:同步迁移结果、验证方式和追溯入口。 +- `db/AGENTS.md`:高风险目录规则文件,仅迁移原 Claude DB 指令,没有修改 DDL 或迁移 SQL。 + +## 数据库变更 + +无数据库 schema 变更。未新增迁移 SQL,未修改 DDL。 + +## 风险与回滚 + +- 中:Claude 历史摘要为脚本自动提取,可能遗漏自然语言中描述的影响。回溯关键问题时仍需查看原始 JSONL、git diff、审计记录和测试结果。 +- 中:Claude skill 中部分内容包含 Claude Code 专属命令或 agent 概念,已迁移为 Codex skill/reference,但使用时需要按 Codex 工具等价替换。 +- 低:Codex hooks 未默认启用,原 Claude hooks 的强校验不是一比一恢复;当前由 AGENTS 规则和审计流程承接。 +- 回滚:删除新增的 `AGENTS.md` / `docs/claude-history/` / `docs/codex_migration.md` / `tools/codex/*`,并恢复 `C:\Users\Administrator\.codex` 下自动生成前的备份。 + +## 验证 + +- `python scripts/audit/prescan.py --files "<本次迁移文件列表>"`:审计预扫描通过,未发现缺失文档同步。 +- `python -m py_compile tools/codex/migrate_claude_assets.py`:迁移脚本语法通过。 +- `powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\codex\mcp-postgres.ps1 TEST_DB_DSN -ValidateOnly`:PostgreSQL MCP 启动配置检查通过。 +- Codex skill 校验:12/12 个迁移后的 skill 通过 `quick_validate.py`。 +- 历史归档校验:生成 94 个会话摘要、94 条会话索引、246 个文件反向索引。 +- 敏感信息扫描:生成的历史摘要、迁移说明和 Codex skills 未发现常见 DSN/API key/密码形态。 + +## 合规检查 + +- 文档同步:已同步到 `docs/codex_migration.md` 和 `docs/claude-history/README.md`。 +- 数据库文档:不适用,本轮无 schema 变更。 +- 审计记录:已落盘。 diff --git a/docs/audit/changes/2026-05-01__cursor_migration.md b/docs/audit/changes/2026-05-01__cursor_migration.md new file mode 100644 index 0000000..e48569f --- /dev/null +++ b/docs/audit/changes/2026-05-01__cursor_migration.md @@ -0,0 +1,47 @@ +# 变更审计记录:Cursor AI 开发环境迁移 + +| 字段 | 值 | +|------|-----| +| 日期 | 2026-05-02 00:05:37 | + +## 操作摘要 + +本轮将 Claude Code 与 Codex 中已经沉淀的 AI 开发习惯迁移到 Cursor 原生结构。迁移目标是让 Cursor 继承中文沟通、前置调研、验证、审计、子代理审查、MCP 测试库优先和历史追溯习惯,同时避免把长规则和原始对话全文堆入常规上下文。 + +## 变更文件 + +### 新增/修改 + +- `tools/cursor/migrate_ai_environment.py`:可重复迁移脚本。 +- `.cursor/rules/`:Cursor 项目级短规则。 +- `.cursor/skills/`:审计、前置调研、文档同步和 spec 收尾流程技能。 +- `.cursor/hooks.json`、`.cursor/hooks/ai_env_guard.py`:轻量提醒/保护 hooks。 +- `docs/cursor_migration.md`:Cursor 迁移说明。 +- `docs/ai-env-history/`:Claude/Codex/Cursor 原始对话精简索引和逐会话摘要。 +- `AGENTS.md` / `CLAUDE.md`:补充 CLI / Shell 中文编码处理规则,要求 UTF-8、乱码复核、中文路径正确加引号。 +- `C:\Users\Administrator\.cursor\skills`:用户级技能迁移。 +- `C:\Users\Administrator\.cursor\agents`:用户级子代理迁移。 + +## 数据库变更 + +无数据库 schema 变更。未执行 DDL,未修改迁移 SQL。 + +## 风险与回滚 + +- 中:Cursor hooks 与 Claude hooks 事件字段不完全一致,本轮只启用轻量提醒和 ask,不启用强阻断。 +- 中:历史摘要从原始 JSONL 自动分析,仍可能遗漏自然语言里的隐含决策;关键判断需回看原文和当前代码。 +- 低:用户级 Cursor 资产覆盖前已备份,可从 manifest 恢复。 +- 低:Windows 终端中文输出可能受代码页影响;已在根规则和 Cursor 核心规则中要求 UTF-8、乱码不采信、必要时重跑。 + +## 验证 + +- 运行 `python tools/cursor/migrate_ai_environment.py --check` 校验生成结构。 +- 运行 `python scripts/audit/prescan.py --files "<本轮迁移文件>"` 做审计预扫描。 +- 解析 `.mcp.json`、`.cursor/hooks.json`、skill/subagent frontmatter。 +- 运行 `.venv\Scripts\python.exe scripts\audit\prescan.py --files "AGENTS.md,CLAUDE.md,.cursor/rules/neozqyy-core.mdc,tools/cursor/migrate_ai_environment.py"` 校验中文规则增量。 + +## 合规检查 + +- 文档同步:已新增 `docs/cursor_migration.md` 和 `docs/ai-env-history/README.md`。 +- 数据库文档:不适用,本轮无 schema 变更。 +- 审计记录:已落盘。 diff --git a/docs/codex_migration.md b/docs/codex_migration.md new file mode 100644 index 0000000..266f618 --- /dev/null +++ b/docs/codex_migration.md @@ -0,0 +1,108 @@ +# Codex 迁移配置说明 + +本仓库已从 Claude Code 的项目配置迁移到 Codex 可识别的配置结构。 + +## 已迁移内容 + +| Claude Code | Codex | 状态 | +|-------------|-------|------| +| 根目录 `CLAUDE.md` | 根目录 `AGENTS.md` | 已迁移 | +| 子目录 `CLAUDE.md` | 子目录 `AGENTS.md` | 已迁移 | +| `.mcp.json` | `C:\Users\Administrator\.codex\config.toml` | 已迁移 | +| PostgreSQL MCP 明文 DSN | `tools/codex/mcp-postgres.ps1` 读取 `.env` / `.env.local` | 已改为间接读取 | +| `.claude/settings.local.json` 中的项目环境变量 | `shell_environment_policy.set` | 已迁移核心变量 | + +## Codex 全局配置 + +Codex 当前读取: + +```text +C:\Users\Administrator\.codex\config.toml +``` + +已配置的 MCP server: + +- `pg-etl`:ETL 正式库,默认禁用 +- `pg-etl-test`:ETL 测试库,默认启用 +- `pg-app`:业务正式库,默认禁用 +- `pg-app-test`:业务测试库,默认启用 +- `weixin-devtools-mcp`:微信开发者工具 MCP +- `playwright`:Playwright MCP +- `openapi`:后端 OpenAPI MCP + +已配置的 shell 环境变量: + +- `NEOZQYY_ROOT=C:\Project\NeoZQYY` +- `VIRTUAL_ENV=C:\Project\NeoZQYY\.venv` + +未覆盖全局 `PATH`,避免影响 Windows、Node.js、uv 等系统命令解析。 + +数据库 MCP 的 DSN 不直接写入 Codex 全局配置,启动时由 `tools/codex/mcp-postgres.ps1` 按以下优先级解析: + +```text +.env < .env.local < 当前进程环境变量 +``` + +## 验证命令 + +```powershell +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\codex\mcp-postgres.ps1 TEST_DB_DSN -ValidateOnly +powershell.exe -NoProfile -ExecutionPolicy Bypass -File tools\codex\mcp-postgres.ps1 TEST_APP_DB_DSN -ValidateOnly +``` + +TOML 静态校验: + +```powershell +@' +import pathlib, tomllib +path = pathlib.Path.home().joinpath(".codex/config.toml") +data = tomllib.loads(path.read_text(encoding="utf-8-sig")) +print(sorted(data.get("mcp_servers", {}).keys())) +'@ | .venv\Scripts\python.exe - +``` + +## 尚未一比一迁移的内容 + +Claude Code 的 `.claude/commands/*.md` 与 `.claude/hooks/*.py` 不完全等价于 Codex 当前稳定能力: + +- `/audit` 等自定义命令已经沉淀在根目录 `AGENTS.md` 的审计流程中,可直接要求 Codex “执行审计流程”。 +- Claude 的 `PreToolUse` / `PostToolUse` 钩子在 Codex 上仍属于实验能力,且 Windows 兼容性需要按 Codex 版本再验证;本次未默认启用,避免影响日常编辑。 +- 如需恢复钩子式强校验,优先迁移 `Stop` 类审计/验证提醒,再评估文件读写前置拦截。 + +## 用户级 Claude 资产迁移状态 + +以下原始内容仍保留在 `C:\Users\Administrator\.claude` 作为备份和源材料,同时已经完成 Codex 侧迁移: + +- `skills/`:10 个 Claude skill 已迁移到 `C:\Users\Administrator\.codex\skills`。 +- `agents/`:8 个 Claude agent 已迁移为 Codex skill `claude-agent-roles`。 +- `rules/`:`python`、`typescript`、`web`、`zh` 四组规则已迁移为 Codex skill `claude-rules-reference`,并提炼进 `C:\Users\Administrator\.codex\AGENTS.md`。 +- `projects/C--Project-NeoZQYY/`:NeoZQYY 项目历史会话 94 个 JSONL 文件已摘要归档到 `docs/claude-history/`。 +- `history.jsonl`、`plans/`、`file-history/` 等 Claude 本地历史与辅助状态。 + +Codex 不会自动学习 Claude Code 的原始历史对话,也不会自动加载 `C:\Users\Administrator\.claude\skills`。当前 Codex 可稳定读取的是: + +- 仓库内 `AGENTS.md` +- `C:\Users\Administrator\.codex\config.toml` +- `C:\Users\Administrator\.codex\skills` +- 当前会话上下文与 Codex 自己生成的 memory +- `docs/claude-history/` 中的历史摘要索引 + +历史会话没有全文注入 Prompt,只保留脱敏摘要和索引。需要追溯时先查索引,再按需打开对应摘要;只有必要时才读取原始 JSONL。 + +## 使用提醒 + +修改 `C:\Users\Administrator\.codex\config.toml` 后,需要重启 Codex 会话或重新打开项目,MCP server 才会被重新发现。 + +## 本次深度迁移结果 + +- 用户全局习惯已写入 `C:\Users\Administrator\.codex\AGENTS.md`。 +- Claude skills 已迁移 10 个到 `C:\Users\Administrator\.codex\skills`:agent-introspection-debugging, claude-api, code-tour, codebase-onboarding, repo-scan, rules-distill, search-first, security-review, strategic-compact, tdd-workflow。 +- Claude agents 已迁移为 Codex skill `claude-agent-roles`,包含 8 个角色参考。 +- Claude rules 已迁移为 Codex skill `claude-rules-reference`,包含 28 个规则文件。 +- NeoZQYY Claude 会话历史已摘要归档到 `docs/claude-history/`:94 个会话,246 个被编辑文件索引。 + +### 追溯入口 + +- 会话索引:`docs/claude-history/session_index.csv` +- 文件索引:`docs/claude-history/file_index.csv` +- 会话摘要:`docs/claude-history/sessions/` diff --git a/docs/codex_migration_status_report_2026-04-29.md b/docs/codex_migration_status_report_2026-04-29.md new file mode 100644 index 0000000..1d0ddc4 --- /dev/null +++ b/docs/codex_migration_status_report_2026-04-29.md @@ -0,0 +1,207 @@ +# Codex 迁移阶段状态报告 + +| 字段 | 内容 | +|------|------| +| 生成时间 | 2026-04-29 04:01:26 | +| 范围 | 从 Claude Code 迁移至 Codex 的环境、配置、使用习惯、skills、agents、rules 与历史追溯资产 | +| 当前结论 | 已进入“深度迁移完成,等待 Codex 重启生效与后续验收”的阶段 | + +## 一、按时间线看当前进行到哪一步 + +| 时间 | 阶段 | 状态 | 说明 | +|------|------|------|------| +| 2026-04-20 至 2026-04-22 | Claude Code 主要业务开发期 | 已归档 | Claude 历史显示这段时间有大量跨后端、admin-web、小程序、数据库、文档和脚本的修改。 | +| 2026-04-28 | Claude Code 尾声会话 | 已归档 | 最新 Claude 项目会话 `67f30f8e-2db7-467f-82e0-5395f9ed855f` 未检测到文件写入,偏向过渡/查询类会话。 | +| 2026-04-29 03:30 左右 | Codex 基础迁移 | 已完成 | 已迁移根/子目录 `AGENTS.md`、MCP 配置、PostgreSQL MCP 启动脚本、环境变量。 | +| 2026-04-29 03:54 左右 | Codex 深度迁移 | 已完成 | 已迁移 Claude skills、agents、rules、用户全局习惯,并生成 Claude 历史摘要归档。 | +| 2026-04-29 03:57 左右 | 审计固化 | 已完成 | 已生成审计记录并刷新审计面板。 | +| 当前 | 验收与生效阶段 | 进行中 | 需要重启 Codex 或重新打开项目,让新的全局规则、skills 和 MCP 配置完整加载。 | + +## 二、已经完成的迁移 + +### 1. 项目级规则 + +- 根目录 `CLAUDE.md` 已迁移为 `AGENTS.md`。 +- 子模块规则已补齐: + - `apps/backend/AGENTS.md` + - `apps/etl/connectors/feiqiu/AGENTS.md` + - `apps/demo-miniprogram/AGENTS.md` + - `db/AGENTS.md` + +### 2. Codex 全局配置 + +- `C:\Users\Administrator\.codex\config.toml` 已配置 NeoZQYY 项目信任、MCP server 和 shell 环境变量。 +- PostgreSQL MCP 已改为通过 `tools/codex/mcp-postgres.ps1` 间接读取 `.env` / `.env.local` / 当前环境变量,避免把 DSN 明文写入 Codex 配置。 +- 已配置 MCP: + - `pg-etl`:正式 ETL 库,默认禁用 + - `pg-etl-test`:测试 ETL 库,默认启用 + - `pg-app`:正式业务库,默认禁用 + - `pg-app-test`:测试业务库,默认启用 + - `weixin-devtools-mcp` + - `playwright` + - `openapi` + +### 3. 用户习惯、skills、agents、rules + +- 用户全局习惯已写入 `C:\Users\Administrator\.codex\AGENTS.md`。 +- Claude skills 已迁移 10 个到 `C:\Users\Administrator\.codex\skills`。 +- Claude agents 已迁移为 Codex skill:`claude-agent-roles`,包含 8 个角色参考。 +- Claude rules/steering/pre-prompt 已迁移为 Codex skill:`claude-rules-reference`。 +- Codex 当前可发现的用户级 skill 数量:12 个。 + +### 4. Claude 对话历史追溯 + +- 已生成 `docs/claude-history/`。 +- 已归档 Claude 项目会话摘要:94 个。 +- 已建立文件反向索引:246 个被编辑文件。 +- 追溯入口: + - `docs/claude-history/session_index.csv` + - `docs/claude-history/file_index.csv` + - `docs/claude-history/sessions/` + +### 5. 审计与文档 + +- 迁移说明:`docs/codex_migration.md` +- 审计记录:`docs/audit/changes/2026-04-29__codex_migration_and_claude_history_archive.md` +- 审计面板:`docs/audit/audit_dashboard.md` +- 可重复迁移脚本:`tools/codex/migrate_claude_assets.py` + +## 三、已经完成的验证 + +| 验证项 | 结果 | +|--------|------| +| Codex skill 校验 | 12/12 通过 | +| Claude 会话摘要数量 | 94 个 | +| 文件反向索引数量 | 246 个唯一文件 | +| PostgreSQL MCP 测试库启动配置 | `TEST_DB_DSN -ValidateOnly` 通过 | +| PowerShell MCP 脚本语法 | 通过 | +| Python 迁移脚本语法 | 通过 | +| 迁移产物敏感信息扫描 | 未发现常见 DSN/API key/密码形态 | +| 审计预扫描 | 已执行,需审计,已补审计记录 | + +## 四、还没有解决或需要注意的遗留问题 + +### 1. Codex 需要重启生效 + +`config.toml`、`C:\Users\Administrator\.codex\AGENTS.md` 和新迁移的 skills 需要重启 Codex 或重新打开项目后才能稳定加载。 + +### 2. MCP 只完成静态与启动配置验证 + +当前已验证 TOML、PostgreSQL MCP 启动脚本和环境变量解析,但还没有在重启后的 Codex 会话里逐个调用 MCP 工具确认可用性。尤其需要后续确认: + +- PostgreSQL 测试库 MCP 是否能列 schema / 执行只读 SQL。 +- WeChat DevTools MCP 是否能连接当前微信开发者工具。 +- Playwright MCP 是否能正常打开本地页面。 +- OpenAPI MCP 是否能在后端服务启动后读取接口。 + +### 3. Claude hooks 没有一比一启用 + +Claude Code 的 `PreToolUse`、`PostToolUse`、`Stop` hooks 没有在 Codex 里默认启用。当前替代方式是: + +- 把规则写入 `AGENTS.md`。 +- 把审计流程写入项目规范。 +- 保留迁移说明中关于 hooks 的差异。 + +遗留风险是:原来由 hooks 自动拦截的行为,现在主要依赖 Codex 遵循规则和人工/脚本验证。 + +### 4. Claude 历史是摘要,不是全文记忆 + +历史归档是脱敏摘要和索引,不是原始对话全文导入。它能回答“哪个会话可能改了哪个文件、用了哪些命令、有什么 SQL/风险线索”,但不能保证完整还原每个自然语言决策。 + +关键问题追溯时仍需组合使用: + +- `docs/claude-history/file_index.csv` +- 对应 `sessions/.md` +- 原始 Claude JSONL +- git diff / git blame +- `docs/audit/changes/` +- 当前代码与测试结果 + +### 5. 部分 Claude skill 带有 Claude 专属语境 + +如 `claude-api`、部分 agent/skill 文案仍可能包含 Claude Code 专属术语。它们已经可被 Codex 发现,但实际使用时需要按 Codex 工具能力做等价替换。 + +### 6. 工作区仍有大量既有未提交业务改动 + +本轮迁移没有处理此前已有的业务代码改动。当前迁移相关文件仍未提交,工作区还存在其他历史未提交内容。后续提交时需要隔离本次迁移文件,避免混入业务改动。 + +## 五、当前仓库内本轮新增/修改文件 + +### 迁移配置与规则 + +- `AGENTS.md` +- `apps/backend/AGENTS.md` +- `apps/demo-miniprogram/AGENTS.md` +- `apps/etl/connectors/feiqiu/AGENTS.md` +- `db/AGENTS.md` + +### 工具脚本 + +- `tools/codex/mcp-postgres.ps1` +- `tools/codex/migrate_claude_assets.py` + +### 迁移与历史文档 + +- `docs/codex_migration.md` +- `docs/claude-history/` +- `docs/codex_migration_status_report_2026-04-29.md` + +### 审计 + +- `docs/audit/changes/2026-04-29__codex_migration_and_claude_history_archive.md` +- `docs/audit/audit_dashboard.md` + +## 六、未来工作安排建议 + +### 第一阶段:生效验收 + +1. 重启 Codex 或重新打开 NeoZQYY 项目。 +2. 确认 Codex 能看到新增 skills: + - `claude-agent-roles` + - `claude-rules-reference` + - `tdd-workflow` + - `security-review` + - `search-first` +3. 逐个验证 MCP: + - `pg-etl-test` + - `pg-app-test` + - `weixin-devtools-mcp` + - `playwright` + - `openapi` + +### 第二阶段:迁移产物固化 + +1. 单独提交 Codex 迁移相关文件。 +2. 不混入既有业务代码改动。 +3. 提交信息使用中文描述,并按项目规范保留 Co-Authored-By 签名行。 + +### 第三阶段:历史追溯增强 + +1. 按业务模块补充历史摘要标签,例如 AI、财务看板、小程序聊天、触发器、数据库迁移。 +2. 对 2026-04-20 至 2026-04-22 的高影响会话做人工二次摘要。 +3. 将关键“设计决策”从历史摘要中提炼到 `docs/audit/` 或对应模块文档。 + +### 第四阶段:恢复强校验能力 + +1. 评估 Codex hooks 当前版本在 Windows 上的稳定性。 +2. 优先恢复 Stop 类检查: + - 是否需要审计 + - 是否需要测试验证 + - 是否涉及数据库文档同步 +3. 再评估 PreToolUse / PostToolUse 类拦截。 + +### 第五阶段:继续业务开发前的固定流程 + +后续任何逻辑改动前,建议固定执行: + +1. 查 `docs/claude-history/file_index.csv`,确认目标文件历史会话。 +2. 查 `docs/audit/changes/`,确认最近审计记录。 +3. 读取当前文件和调用链。 +4. 输出改动前上下文摘要。 +5. 实施后运行测试、输出 diff 摘要和风险清单。 + +## 七、阶段性结论 + +当前不是“刚开始迁移”,也不是“已经可以无感继续业务开发”。准确位置是: + +> Codex 深度迁移已经完成,历史追溯体系已经建立;下一步是重启 Codex 让配置生效,并做 MCP/skills 的实际运行验收。验收完成后,再把迁移文件单独提交,之后才能稳定进入新的业务开发阶段。 diff --git a/docs/cursor_migration.md b/docs/cursor_migration.md new file mode 100644 index 0000000..e1744f9 --- /dev/null +++ b/docs/cursor_migration.md @@ -0,0 +1,108 @@ +# Cursor AI 开发环境迁移说明 + +## 状态 + +本轮把 NeoZQYY 的 AI 开发环境从 Claude Code / Codex 迁移到 Cursor 原生资产: + +- 项目规则:`.cursor/rules/` +- 项目流程技能:`.cursor/skills/` +- 用户技能:`C:\Users\Administrator\.cursor\skills` +- 用户子代理:`C:\Users\Administrator\.cursor\agents` +- 轻量 hooks:`.cursor/hooks.json` 与 `.cursor/hooks/` +- 历史索引:`docs/ai-env-history/` + +## 去重原则 + +1. 当前仓库 `AGENTS.md` 是项目权威规则。 +2. `CLAUDE.md` 保留为历史兼容来源。 +3. `.cursor/rules` 只放短规则和入口,不复制长文。 +4. skills 使用“短入口 + reference”,避免每次会话加载过多旧内容。 +5. 原始对话逐一分析为摘要和索引,不把原始 JSONL 全文写入仓库。 + +## 模型偏好 + +用户主要使用 GPT 5.5 与 Claude 4.7。Cursor 的实际模型选择由当前会话或 UI 控制;本迁移只把偏好写入规则和说明,不强写不可控的内部模型字段。 + +## 内置流程与规则边界 + +Cursor、GPT 5.5、Claude 4.7 都具备调研、审计、验证、子代理和工具调用能力,但它们不知道 NeoZQYY 的项目事实、历史踩坑、数据库口径、RLS 双 schema、demo 标杆保护和审计路径。因此仍需要项目规则和技能作为触发器与约束。 + +规则不重复实现模型能力,只固化三类内容: + +- 项目事实:目录职责、数据库、ETL、后端、前端、小程序约定。 +- 用户习惯:中文、先调研、再改动、再验证、再审计。 +- 风险边界:生产库禁用、敏感信息不扩散、危险 git/SQL/归档目录保护。 + +## 历史入口 + +- 新索引:`docs/ai-env-history/README.md` +- Claude 摘要:`docs/claude-history/README.md` +- 审计一览:`docs/audit/audit_dashboard.md` +- Codex 迁移说明:`docs/codex_migration.md` + +## 用户级资产触发策略 + +### 保留自然触发 + +- `neozqyy-cursor-migration`:NeoZQYY 迁移和历史追溯入口。 +- `strategic-compact`:长会话上下文压缩建议。 +- `claude-api`:仅在 Anthropic / Claude API 场景触发。 + +### 改为显式触发 + +- `claude-agent-roles`:旧 Claude agents 参考;日常使用 Cursor subagents。 +- `claude-rules-reference`:旧 Claude rules 档案;当前权威规则是 `AGENTS.md` 与 `.cursor/rules/`。 +- `tdd-workflow`:详细 TDD 参考;日常入口是 `tdd-guide` subagent。 +- `security-review`:详细安全 checklist;日常入口是 `security-reviewer` subagent。 +- `search-first`:通用搜索优先流程;NeoZQYY 内优先使用 `.cursor/skills/pre-change`。 +- `rules-distill`、`repo-scan`、`codebase-onboarding`、`code-tour`、`agent-introspection-debugging`:低频或重型工具,按需显式使用。 + +保留全部 8 个用户级 subagents:`planner`、`architect`、`code-reviewer`、`security-reviewer`、`database-reviewer`、`python-reviewer`、`tdd-guide`、`refactor-cleaner`。 + +## Hooks 强度判断 + +当前 hooks 保持轻量提醒和 ask,不默认强阻断。建议: + +- `_archived/` 读取/搜索:已升级为 `failClosed` 强阻断。依据:项目规则明确禁止参考归档目录,误用概率高。 +- 危险 git 命令:保持 ask,不建议 `failClosed`。依据:极少数维护场景可能需要,必须由用户明确确认。 +- 生产库 MCP / 生产 DSN:只读调用走 ask;写入/DDL 类调用 deny。依据:测试库足够覆盖日常验证,生产库写操作风险高。 +- demo-miniprogram:保持提醒,不建议强阻断。依据:它是标杆目录,偶尔仍可能需要按用户明确要求修改。 +- DB schema 改动提醒:保持提醒。依据:是否需要文档同步要结合实际变更判断。 + +## MCP 策略 + +延续既有用途: + +- `pg-etl-test`、`pg-app-test`:测试库,默认可用。 +- `pg-etl`、`pg-app`:生产库,默认禁用或 ask,不自动执行。 +- `weixin-devtools-mcp`:小程序调试和截图验证。 +- `playwright` / `cursor-ide-browser`:Web 前端验证、截图、交互检查。 +- `openapi`:后端 API 合同查询。 + +## VSCode Insiders 迁移 + +- 用户数据目录:`C:\Users\Administrator\AppData\Roaming\Code - Insiders\User` +- 扩展清单:`C:\Users\Administrator\.vscode-insiders\extensions\extensions.json` +- Cursor 扩展对照表:`docs/ai-env-history/vscode_insiders_extensions.csv` +- 补装脚本:`tools/cursor/install_vscode_insiders_extensions.ps1` +- 旧环境 disabled 状态已记录;Cursor 没有稳定 CLI 持久禁用接口,补装后需要在 Cursor UI 中按清单确认禁用状态。 + +## 主题时间线 + +- 主题时间线:`docs/ai-env-history/topic_timeline.md` +- 主题 CSV:`docs/ai-env-history/topic_timeline.csv` + +## 验证 + +```powershell +.venv\Scripts\python.exe tools\cursor\migrate_ai_environment.py --check +.venv\Scripts\python.exe scripts\audit\prescan.py --files "tools/cursor/migrate_ai_environment.py,docs/cursor_migration.md" +``` + +## 回滚 + +迁移脚本会把覆盖前的 Cursor 用户资产和项目 `.cursor` 资产备份到: + +`C:\Users\Administrator\.cursor\backups\neozqyy-cursor-migration\` + +如需回滚,按 manifest 中的源/目标路径恢复即可。 diff --git a/tools/codex/mcp-postgres.ps1 b/tools/codex/mcp-postgres.ps1 new file mode 100644 index 0000000..1726ff2 --- /dev/null +++ b/tools/codex/mcp-postgres.ps1 @@ -0,0 +1,68 @@ +param( + [Parameter(Mandatory = $true)] + [string]$DsnVariable, + + [switch]$ValidateOnly +) + +$ErrorActionPreference = "Stop" + +$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Path +$repoRoot = Resolve-Path (Join-Path $scriptDir "..\..") +$fileVars = @{} + +foreach ($envFile in @(".env", ".env.local")) { + $envPath = Join-Path $repoRoot $envFile + if (-not (Test-Path $envPath)) { + continue + } + + foreach ($line in Get-Content $envPath) { + $trimmed = $line.Trim() + if ($trimmed.Length -eq 0 -or $trimmed.StartsWith("#")) { + continue + } + + $index = $trimmed.IndexOf("=") + if ($index -lt 1) { + continue + } + + $key = $trimmed.Substring(0, $index).Trim() + $rawValue = $trimmed.Substring($index + 1).Trim() + if (($rawValue.StartsWith('"') -and $rawValue.EndsWith('"')) -or ($rawValue.StartsWith("'") -and $rawValue.EndsWith("'"))) { + $rawValue = $rawValue.Substring(1, $rawValue.Length - 2) + } + + $fileVars[$key] = $rawValue + } +} + +$dsn = [Environment]::GetEnvironmentVariable($DsnVariable, "Process") +if ([string]::IsNullOrWhiteSpace($dsn) -and $fileVars.ContainsKey($DsnVariable)) { + $dsn = $fileVars[$DsnVariable] +} + +if ([string]::IsNullOrWhiteSpace($dsn)) { + Write-Error "缺少环境变量:$DsnVariable" + exit 2 +} + +$uvx = [Environment]::GetEnvironmentVariable("UVX_EXE", "Process") +if ([string]::IsNullOrWhiteSpace($uvx)) { + $uvx = "C:\Dev\miniconda3\Scripts\uvx.exe" +} + +if (-not (Test-Path $uvx)) { + Write-Error "未找到 uvx:$uvx" + exit 2 +} + +if ($ValidateOnly) { + Write-Output "MCP PostgreSQL 启动配置检查通过:$DsnVariable" + exit 0 +} + +$env:DATABASE_URI = $dsn +& $uvx --python "3.12" "postgres-mcp" "--access-mode=unrestricted" +exit $LASTEXITCODE diff --git a/tools/codex/migrate_claude_assets.py b/tools/codex/migrate_claude_assets.py new file mode 100644 index 0000000..a323944 --- /dev/null +++ b/tools/codex/migrate_claude_assets.py @@ -0,0 +1,647 @@ +from __future__ import annotations + +import csv +import datetime as dt +import json +import os +import re +import shutil +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[2] +HOME = Path.home() +CLAUDE_HOME = HOME / ".claude" +CODEX_HOME = HOME / ".codex" +CODEX_SKILLS = CODEX_HOME / "skills" +HISTORY_ROOT = REPO_ROOT / "docs" / "claude-history" + +SECRET_PATTERNS = [ + re.compile(r"sk-[A-Za-z0-9_-]{12,}"), + re.compile(r"sk-proj-[A-Za-z0-9_-]{12,}"), + re.compile(r"(?i)(password|passwd|pwd|token|secret|api[_-]?key)\s*[:=]\s*['\"]?[^'\"\s,;]+"), + re.compile(r"postgresql://[^\s'\"`]+"), + re.compile(r"mysql://[^\s'\"`]+"), + re.compile(r"mongodb(?:\+srv)?://[^\s'\"`]+"), +] + + +def redact_preserve(text: str) -> str: + value = text.replace("sk-proj-xxxxx", "[示例密钥已脱敏]") + for pattern in SECRET_PATTERNS: + value = pattern.sub("[已脱敏]", value) + return value + + +def redact(text: str, limit: int | None = None) -> str: + value = redact_preserve(text) + value = re.sub(r"\s+", " ", value).strip() + if limit is not None and len(value) > limit: + return value[: limit - 1].rstrip() + "…" + return value + + +def backup(path: Path) -> None: + if not path.exists(): + return + stamp = dt.datetime.now().strftime("%Y%m%d-%H%M%S") + backup_root = CODEX_HOME / "backups" / "claude-migration" + backup_root.mkdir(parents=True, exist_ok=True) + safe_name = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(path).replace(":", "")) + target = backup_root / f"{safe_name}.backup-{stamp}" + if path.is_dir(): + shutil.copytree(path, target) + else: + shutil.copy2(path, target) + + +def parse_frontmatter(text: str) -> tuple[dict[str, str], str]: + if not text.startswith("---"): + return {}, text + end = text.find("\n---", 3) + if end == -1: + return {}, text + raw = text[3:end].strip() + body = text[end + len("\n---") :].lstrip("\n") + meta: dict[str, str] = {} + for line in raw.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + meta[key.strip()] = value.strip().strip('"').strip("'") + return meta, body + + +def title_case_slug(name: str) -> str: + return " ".join(part.capitalize() for part in name.replace("_", "-").split("-")) + + +def write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8") + + +def copytree_contents(src: Path, dst: Path) -> None: + if dst.exists(): + shutil.rmtree(dst) + dst.mkdir(parents=True, exist_ok=True) + for item in src.iterdir(): + target = dst / item.name + if item.is_dir(): + shutil.copytree(item, target) + else: + shutil.copy2(item, target) + + +def migrate_skills() -> list[str]: + src_root = CLAUDE_HOME / "skills" + migrated: list[str] = [] + if not src_root.exists(): + return migrated + + for src in sorted(p for p in src_root.iterdir() if p.is_dir()): + skill_md = src / "SKILL.md" + if not skill_md.exists(): + continue + + dst = CODEX_SKILLS / src.name + if dst.exists(): + backup(dst) + copytree_contents(src, dst) + + original = skill_md.read_text(encoding="utf-8", errors="replace") + meta, body = parse_frontmatter(original) + name = re.sub(r"[^a-z0-9-]", "-", meta.get("name", src.name).lower()).strip("-") or src.name + description = meta.get("description") or f"从 Claude Code 迁移的 {src.name} 工作流。" + body = redact_preserve(body) + new_text = ( + "---\n" + f"name: {name}\n" + f"description: {description} 从 Claude Code 迁移;当用户提到 ${name}、{src.name}、原 Claude skill,或需要该工作流时使用。\n" + "---\n\n" + f"> 迁移说明:本 skill 从 `C:\\Users\\Administrator\\.claude\\skills\\{src.name}` 转换而来。" + "如内容包含 Claude Code 专属命令,请按 Codex 当前工具等价替换。\n\n" + + body + ) + write_text(dst / "SKILL.md", new_text) + + agents_dir = dst / "agents" + agents_dir.mkdir(exist_ok=True) + short = redact(description, 120).replace('"', "'") + openai_yaml = ( + f'display_name: "{title_case_slug(name)}"\n' + f'short_description: "{short}"\n' + f'default_prompt: "使用 {name} 处理当前任务,遵循从 Claude Code 迁移来的工作流。"\n' + ) + write_text(agents_dir / "openai.yaml", openai_yaml) + migrated.append(name) + return migrated + + +def migrate_agents() -> list[str]: + src_root = CLAUDE_HOME / "agents" + if not src_root.exists(): + return [] + + skill_dir = CODEX_SKILLS / "claude-agent-roles" + if skill_dir.exists(): + backup(skill_dir) + shutil.rmtree(skill_dir) + refs = skill_dir / "references" + refs.mkdir(parents=True, exist_ok=True) + + rows: list[tuple[str, str]] = [] + for src in sorted(src_root.glob("*.md")): + text = src.read_text(encoding="utf-8", errors="replace") + meta, body = parse_frontmatter(text) + name = meta.get("name", src.stem) + description = meta.get("description", "") + rows.append((name, description)) + write_text(refs / f"{src.stem}.md", redact_preserve(body)) + + table = "\n".join( + f"| `{name}` | {redact(description, 160)} | `references/{name}.md` |" for name, description in rows + ) + skill_md = f"""--- +name: claude-agent-roles +description: 从 Claude Code 迁移的自定义 agent 角色参考。Use when 用户提到 planner、architect、code-reviewer、security-reviewer、database-reviewer、python-reviewer、tdd-guide、refactor-cleaner,或要求沿用 Claude Code agent/角色/多视角审查习惯时使用。 +--- + +# Claude Agent Roles + +本 skill 保存原 Claude Code 自定义 agent 的角色提示词。Codex 当前不能一比一注册这些 Claude agent;使用时读取对应 reference,把它当作角色视角、检查清单或审查框架。 + +## 角色映射 + +| 角色 | 用途 | 参考文件 | +|------|------|----------| +{table} + +## 使用规则 + +1. 用户明确点名某个角色时,读取对应 `references/*.md`。 +2. 复杂功能、架构调整、重大重构时优先参考 `planner` 与 `architect`。 +3. 代码修改后优先参考 `code-reviewer`;涉及认证、权限、数据库、密钥、用户输入时叠加 `security-reviewer` 或 `database-reviewer`。 +4. Bug 修复和新功能需要测试设计时参考 `tdd-guide`。 +5. 不要声称已经启动 Claude agent;用“按迁移角色检查/规划”描述即可。 +""" + write_text(skill_dir / "SKILL.md", skill_md) + write_text( + skill_dir / "agents" / "openai.yaml", + 'display_name: "Claude Agent Roles"\n' + 'short_description: "迁移自 Claude Code 的 planner、architect、reviewer 等角色参考。"\n' + 'default_prompt: "按迁移自 Claude Code 的角色习惯,对当前任务进行规划、审查或安全检查。"\n', + ) + return [name for name, _ in rows] + + +def migrate_rules() -> list[str]: + src_root = CLAUDE_HOME / "rules" + if not src_root.exists(): + return [] + + skill_dir = CODEX_SKILLS / "claude-rules-reference" + if skill_dir.exists(): + backup(skill_dir) + shutil.rmtree(skill_dir) + refs = skill_dir / "references" + refs.mkdir(parents=True, exist_ok=True) + + copied: list[str] = [] + for src in sorted(src_root.rglob("*.md")): + rel = src.relative_to(src_root) + target = refs / rel + write_text(target, redact_preserve(src.read_text(encoding="utf-8", errors="replace"))) + copied.append(rel.as_posix()) + + list_text = "\n".join(f"- `references/{path}`" for path in copied) + skill_md = f"""--- +name: claude-rules-reference +description: 从 Claude Code 迁移的个人工程规则、中文工作流、Python/TypeScript/Web 编码规范、安全、测试、审查和性能偏好。Use when 需要沿用用户之前的 Claude Code 使用习惯、steering/rules/pre-prompt,或处理代码风格、测试、安全、评审、Web 设计质量要求时使用。 +--- + +# Claude Rules Reference + +本 skill 保存原 `C:\\Users\\Administrator\\.claude\\rules`。优先使用当前仓库 `AGENTS.md`,当用户要求沿用旧习惯、或任务涉及代码风格/测试/安全/审查/Web 体验时,再读取相关 reference。 + +## 可用参考 + +{list_text} + +## 读取建议 + +- 中文通用习惯:读取 `references/zh/README.md` 及同目录相关主题。 +- Python:读取 `references/python/*.md`。 +- TypeScript/前端:读取 `references/typescript/*.md` 与 `references/web/*.md`。 +- 安全、测试、代码审查:按主题读取对应文件,不要一次性加载全部。 +""" + write_text(skill_dir / "SKILL.md", skill_md) + write_text( + skill_dir / "agents" / "openai.yaml", + 'display_name: "Claude Rules Reference"\n' + 'short_description: "迁移自 Claude Code 的个人规则、steering 和工程偏好。"\n' + 'default_prompt: "沿用用户从 Claude Code 迁移来的工程规则和审查习惯处理当前任务。"\n', + ) + return copied + + +def migrate_global_agents() -> None: + target = CODEX_HOME / "AGENTS.md" + backup(target) + text = """# 用户全局习惯(由 Claude Code 迁移) + +## 语言与沟通 + +- 默认使用简体中文回复、解释、状态更新和审计记录。 +- 技术术语、命令、API 字段、变量名保持原文。 +- 先读上下文再动手;不确定时提出关键问题,但对低风险配置/文档迁移可直接执行。 +- 回复要高信号、少套话;给出实际结果、验证状态和剩余风险。 + +## 工作方式 + +- 尊重既有代码风格和项目约定,优先复用现有模式。 +- 每一处改动都应能追溯到用户请求;不要顺手做无关重构。 +- 小步实施,保持可验证、可回滚。 +- 复杂功能、重构、多模块改动前先做规划和影响分析。 +- Bug 修复和新功能优先考虑测试驱动:先确认复现或 RED,再实现,再验证 GREEN。 +- 修改代码后要说明改了哪些文件、为什么改、怎么验证、哪些风险未覆盖。 + +## 审查与安全偏好 + +- 代码修改后进行代码审查视角检查。 +- 涉及认证、授权、数据库、文件系统、用户输入、外部 API、密钥、支付/财务时,必须叠加安全审查。 +- 禁止硬编码密钥、令牌、密码和生产 DSN;日志和文档中避免暴露敏感信息。 +- 数据库查询优先参数化,Schema 变更必须同步文档和回滚/验证步骤。 + +## 角色与 skill 迁移 + +- 原 Claude Code agents 已迁移为 Codex skill:`claude-agent-roles`。 +- 原 Claude Code rules 已迁移为 Codex skill:`claude-rules-reference`。 +- 原 Claude Code skills 已迁移到 `C:\\Users\\Administrator\\.codex\\skills`。 +- 当用户提到旧角色或旧 skill 时,优先读取对应 Codex skill/reference,而不是重新发明流程。 + +## 历史追溯 + +- Claude Code 历史摘要归档在仓库 `docs/claude-history/`。 +- 需要追踪“哪次对话改了什么、影响什么”时,先查 `session_index.csv` 和 `file_index.csv`,再读对应 `sessions/*.md`。 +- 历史摘要是追溯材料,不是当前事实来源;真正编码前仍需读取当前文件、git diff、审计记录和测试结果。 +""" + write_text(target, text) + + +def safe_rel(path: str) -> str: + value = path.replace("\\", "/") + normalized = value.lower() + markers = [ + "c:/project/neozqyy/", + "/c/project/neozqyy/", + "c:/neozqyy/", + "/c/neozqyy/", + ] + for marker in markers: + if normalized.startswith(marker): + return value[len(marker) :] + return value + + +def classify_area(path: str) -> str: + normalized = safe_rel(path) + if normalized.startswith("apps/backend/"): + return "后端" + if normalized.startswith("apps/etl/"): + return "ETL" + if normalized.startswith("apps/miniprogram/"): + return "小程序" + if normalized.startswith("apps/admin-web/"): + return "admin-web" + if normalized.startswith("apps/tenant-admin/"): + return "tenant-admin" + if normalized.startswith("db/") or normalized.endswith(".sql"): + return "数据库" + if normalized.startswith("docs/"): + return "文档" + if normalized.startswith("scripts/") or normalized.startswith("tools/"): + return "脚本/工具" + return "其他" + + +def extract_text_from_content(content: Any) -> str: + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + parts.append(str(item.get("text", ""))) + return "\n".join(parts) + return "" + + +def first_sql_summary(sql: str) -> str: + clean = redact(sql, 240) + command = re.match(r"\s*(select|insert|update|delete|create|alter|drop|with|explain|truncate)\b", sql, re.I) + verb = command.group(1).upper() if command else "SQL" + tables = sorted(set(re.findall(r"\b(?:from|join|into|update|table|view)\s+([a-zA-Z_][\w.]*)(?:\s|$)", sql, re.I))) + if tables: + return f"{verb}: {', '.join(tables[:8])}" + return f"{verb}: {clean}" + + +def summarize_session(path: Path) -> dict[str, Any]: + session_id = path.stem + timestamps: list[str] = [] + branches: set[str] = set() + cwds: set[str] = set() + user_prompts: list[str] = [] + assistant_notes: list[str] = [] + touched: Counter[str] = Counter() + read_files: Counter[str] = Counter() + commands: Counter[str] = Counter() + sql_ops: Counter[str] = Counter() + tools: Counter[str] = Counter() + mcp_tools: Counter[str] = Counter() + agents: Counter[str] = Counter() + risk_flags: set[str] = set() + line_count = 0 + + with path.open("r", encoding="utf-8", errors="replace") as f: + for line in f: + line_count += 1 + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + ts = obj.get("timestamp") + if isinstance(ts, str): + timestamps.append(ts) + branch = obj.get("gitBranch") + if isinstance(branch, str) and branch: + branches.add(branch) + cwd = obj.get("cwd") + if isinstance(cwd, str) and cwd: + cwds.add(cwd) + msg = obj.get("message") + if not isinstance(msg, dict): + continue + role = msg.get("role") + content = msg.get("content") + if role == "user": + text = extract_text_from_content(content) + if text and not obj.get("isMeta") and not obj.get("isCompactSummary"): + user_prompts.append(redact(text, 260)) + elif role == "assistant": + text = extract_text_from_content(content) + if text: + assistant_notes.append(redact(text, 220)) + + if isinstance(content, list): + for item in content: + if not isinstance(item, dict) or item.get("type") != "tool_use": + continue + name = str(item.get("name", "unknown")) + tools[name] += 1 + if name.startswith("mcp__"): + mcp_tools[name] += 1 + inp = item.get("input") if isinstance(item.get("input"), dict) else {} + + file_path = inp.get("file_path") + if isinstance(file_path, str): + rel = safe_rel(file_path) + if name in {"Edit", "Write", "MultiEdit"}: + touched[rel] += 1 + elif name == "Read": + read_files[rel] += 1 + path_value = inp.get("path") + if isinstance(path_value, str) and name in {"Write", "Edit", "mcp__weixin-devtools-mcp__screenshot"}: + touched[safe_rel(path_value)] += 1 + command = inp.get("command") + if isinstance(command, str): + cmd = redact(command, 180) + commands[cmd] += 1 + lowered = command.lower() + if "git reset --hard" in lowered or "git clean" in lowered: + risk_flags.add("包含高风险 git 清理命令") + if "drop table" in lowered or "truncate" in lowered: + risk_flags.add("包含高风险数据库命令") + if ".env" in lowered: + risk_flags.add("命令涉及环境文件") + sql = inp.get("sql") + if isinstance(sql, str): + summary = first_sql_summary(sql) + sql_ops[summary] += 1 + lowered_sql = sql.lower() + if re.search(r"\b(drop|truncate|delete)\b", lowered_sql): + risk_flags.add("包含删除/回滚类 SQL") + subagent = inp.get("subagent_type") or inp.get("description") + if isinstance(subagent, str) and name == "Agent": + agents[redact(subagent, 120)] += 1 + + areas = Counter(classify_area(p) for p in touched) + first_ts = min(timestamps) if timestamps else "" + last_ts = max(timestamps) if timestamps else "" + return { + "session_id": session_id, + "source_path": str(path), + "bytes": path.stat().st_size, + "lines": line_count, + "first_ts": first_ts, + "last_ts": last_ts, + "branches": sorted(branches), + "cwds": sorted(cwds), + "user_prompts": user_prompts[:12], + "assistant_notes": assistant_notes[:8], + "touched_files": touched.most_common(), + "read_files": read_files.most_common(30), + "commands": commands.most_common(40), + "sql_ops": sql_ops.most_common(30), + "tools": tools.most_common(30), + "mcp_tools": mcp_tools.most_common(20), + "agents": agents.most_common(20), + "areas": areas.most_common(), + "risk_flags": sorted(risk_flags), + } + + +def session_markdown(summary: dict[str, Any]) -> str: + touched = summary["touched_files"] + areas = ", ".join(f"{area}({count})" for area, count in summary["areas"]) or "未识别" + goals = "\n".join(f"- {p}" for p in summary["user_prompts"]) or "- 未提取到用户目标" + files = "\n".join(f"- `{path}`:{count} 次写入/编辑" for path, count in touched[:80]) or "- 未检测到写入/编辑工具" + commands = "\n".join(f"- `{cmd}`:{count} 次" for cmd, count in summary["commands"][:30]) or "- 未检测到 Bash 命令" + sql_ops = "\n".join(f"- {op}:{count} 次" for op, count in summary["sql_ops"][:30]) or "- 未检测到 SQL 工具调用" + tools = "\n".join(f"- `{tool}`:{count} 次" for tool, count in summary["tools"][:20]) or "- 无" + agents = "\n".join(f"- {agent}:{count} 次" for agent, count in summary["agents"]) or "- 未检测到 Claude Agent 调用" + risks = "\n".join(f"- {flag}" for flag in summary["risk_flags"]) or "- 未从工具调用中检测到显式高风险信号" + notes = "\n".join(f"- {note}" for note in summary["assistant_notes"][:8]) or "- 未提取" + return f"""# Claude 会话摘要:{summary['session_id']} + +| 字段 | 值 | +|------|----| +| 时间范围 | {summary['first_ts']} -> {summary['last_ts']} | +| 原始记录 | `{summary['source_path']}` | +| 大小 | {summary['bytes']} bytes / {summary['lines']} lines | +| 分支 | {', '.join(summary['branches']) or '未记录'} | +| 目录 | {', '.join(summary['cwds']) or '未记录'} | +| 影响范围 | {areas} | + +## 用户目标摘录(已脱敏) + +{goals} + +## 可能修改的文件 + +{files} + +## 运行过的命令(已脱敏) + +{commands} + +## 数据库/SQL 操作摘要 + +{sql_ops} + +## 工具调用概览 + +{tools} + +## Agent/子任务线索 + +{agents} + +## 助手过程摘要摘录(已脱敏) + +{notes} + +## 风险与追溯提示 + +{risks} + +> 本摘要由脚本从 Claude JSONL 工具调用和消息元数据中生成,不替代 `git diff`、审计记录、测试结果和当前代码事实。需要深挖时再读取原始 JSONL,并继续做脱敏处理。 +""" + + +def migrate_history() -> dict[str, int]: + source = CLAUDE_HOME / "projects" / "C--Project-NeoZQYY" + if not source.exists(): + return {"sessions": 0, "files": 0} + + if HISTORY_ROOT.exists(): + backup(HISTORY_ROOT) + shutil.rmtree(HISTORY_ROOT) + sessions_dir = HISTORY_ROOT / "sessions" + sessions_dir.mkdir(parents=True, exist_ok=True) + + summaries = [summarize_session(path) for path in sorted(source.glob("*.jsonl"), key=lambda p: p.stat().st_mtime)] + summaries.sort(key=lambda item: item["last_ts"] or item["first_ts"]) + + file_index: dict[str, list[dict[str, Any]]] = defaultdict(list) + with (HISTORY_ROOT / "session_index.csv").open("w", newline="", encoding="utf-8-sig") as f: + writer = csv.writer(f) + writer.writerow(["session_id", "first_ts", "last_ts", "bytes", "lines", "branches", "areas", "touched_count", "risk_flags", "summary_file", "source_path"]) + for summary in summaries: + summary_file = sessions_dir / f"{summary['session_id']}.md" + write_text(summary_file, session_markdown(summary)) + areas = "; ".join(f"{area}:{count}" for area, count in summary["areas"]) + writer.writerow([ + summary["session_id"], + summary["first_ts"], + summary["last_ts"], + summary["bytes"], + summary["lines"], + "; ".join(summary["branches"]), + areas, + len(summary["touched_files"]), + "; ".join(summary["risk_flags"]), + str(summary_file.relative_to(REPO_ROOT)), + summary["source_path"], + ]) + for path, count in summary["touched_files"]: + file_index[path].append({"session_id": summary["session_id"], "count": count, "last_ts": summary["last_ts"]}) + + with (HISTORY_ROOT / "file_index.csv").open("w", newline="", encoding="utf-8-sig") as f: + writer = csv.writer(f) + writer.writerow(["file_path", "session_id", "last_ts", "edit_count", "session_summary"]) + for file_path in sorted(file_index): + for row in sorted(file_index[file_path], key=lambda item: item["last_ts"]): + writer.writerow([ + file_path, + row["session_id"], + row["last_ts"], + row["count"], + f"docs/claude-history/sessions/{row['session_id']}.md", + ]) + + recent_lines = "\n".join( + f"- `{s['session_id']}`:{s['first_ts']} -> {s['last_ts']},修改 {len(s['touched_files'])} 个文件,范围 {', '.join(a for a, _ in s['areas']) or '未识别'}" + for s in summaries[-20:] + ) + readme = f"""# Claude Code 历史摘要归档 + +本目录由 `tools/codex/migrate_claude_assets.py` 从 `C:\\Users\\Administrator\\.claude\\projects\\C--Project-NeoZQYY` 同名项目历史生成,用于迁移到 Codex 后的追本溯源。 + +## 文件说明 + +- `session_index.csv`:会话级索引,按 session 记录时间范围、影响范围、风险标签、摘要文件。 +- `file_index.csv`:文件反向索引,回答“哪个会话改过这个文件”。 +- `sessions/*.md`:每个 Claude JSONL 会话的脱敏摘要。 + +## 最近 20 个会话 + +{recent_lines} + +## 使用方式 + +1. 查某个文件历史:在 `file_index.csv` 搜索文件路径。 +2. 查某次会话影响:打开对应 `sessions/.md`。 +3. 需要完整细节时,再回到原始 JSONL;读取前注意脱敏。 + +## 注意 + +摘要基于工具调用和消息元数据自动生成,不能替代当前代码、审计文档和测试结果。编码前仍需读取当前文件和 `git diff`。 +""" + write_text(HISTORY_ROOT / "README.md", readme) + return {"sessions": len(summaries), "files": len(file_index)} + + +def update_migration_doc(result: dict[str, Any]) -> None: + doc = REPO_ROOT / "docs" / "codex_migration.md" + existing = doc.read_text(encoding="utf-8", errors="replace") if doc.exists() else "# Codex 迁移配置说明\n" + marker = "## 本次深度迁移结果" + existing = existing.split(marker)[0].rstrip() + section = f""" + +{marker} + +- 用户全局习惯已写入 `C:\\Users\\Administrator\\.codex\\AGENTS.md`。 +- Claude skills 已迁移 {len(result['skills'])} 个到 `C:\\Users\\Administrator\\.codex\\skills`:{', '.join(result['skills'])}。 +- Claude agents 已迁移为 Codex skill `claude-agent-roles`,包含 {len(result['agents'])} 个角色参考。 +- Claude rules 已迁移为 Codex skill `claude-rules-reference`,包含 {len(result['rules'])} 个规则文件。 +- NeoZQYY Claude 会话历史已摘要归档到 `docs/claude-history/`:{result['history']['sessions']} 个会话,{result['history']['files']} 个被编辑文件索引。 + +### 追溯入口 + +- 会话索引:`docs/claude-history/session_index.csv` +- 文件索引:`docs/claude-history/file_index.csv` +- 会话摘要:`docs/claude-history/sessions/` +""" + write_text(doc, existing + section) + + +def main() -> None: + CODEX_HOME.mkdir(exist_ok=True) + CODEX_SKILLS.mkdir(exist_ok=True) + result = { + "skills": migrate_skills(), + "agents": migrate_agents(), + "rules": migrate_rules(), + "history": migrate_history(), + } + migrate_global_agents() + update_migration_doc(result) + print(json.dumps(result, ensure_ascii=False, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/tools/cursor/install_vscode_insiders_extensions.ps1 b/tools/cursor/install_vscode_insiders_extensions.ps1 new file mode 100644 index 0000000..ece8ca9 --- /dev/null +++ b/tools/cursor/install_vscode_insiders_extensions.ps1 @@ -0,0 +1,36 @@ +# 从 VSCode Insiders 迁移到 Cursor 的扩展补装脚本 +# 自动生成;安装旧 VSCode Insiders 中已安装但 Cursor 当前缺失的扩展。 +# 旧环境 disabled 状态见 docs/ai-env-history/vscode_insiders_extensions.csv;Cursor 无稳定 CLI 持久禁用接口,需在 UI 中按清单确认。 +$ErrorActionPreference = 'Continue' +$cursor = 'cursor' +$timeoutSec = 90 +$extensions = @( + "leizongmin.node-module-intellisense" + "meezilla.json" + "ms-dotnettools.csdevkit" + "ms-dotnettools.csharp" + "ms-python.vscode-pylance" + "ms-vscode-remote.remote-ssh" + "ms-vscode-remote.remote-ssh-edit" + "ms-vscode.remote-explorer" +) +foreach ($ext in $extensions) { + Write-Host "Installing $ext ..." + $p = Start-Process -FilePath $cursor -ArgumentList @('--install-extension', $ext) -NoNewWindow -PassThru -Wait:$false + if (-not $p.WaitForExit($timeoutSec * 1000)) { + Write-Warning "Timeout installing $ext; killing process and continuing." + try { Stop-Process -Id $p.Id -Force } catch {} + continue + } + Write-Host "Finished $ext exit=$($p.ExitCode)" +} + + +# 以下扩展在 VSCode Insiders 同步状态中为 disabled,安装后建议在 Cursor UI 中禁用: +# disabled-before: leizongmin.node-module-intellisense +# disabled-before: ms-dotnettools.csdevkit +# disabled-before: ms-dotnettools.csharp +# disabled-before: ms-python.vscode-pylance +# disabled-before: ms-vscode-remote.remote-ssh +# disabled-before: ms-vscode-remote.remote-ssh-edit +# disabled-before: ms-vscode.remote-explorer diff --git a/tools/cursor/migrate_ai_environment.py b/tools/cursor/migrate_ai_environment.py new file mode 100644 index 0000000..b0a1104 --- /dev/null +++ b/tools/cursor/migrate_ai_environment.py @@ -0,0 +1,1329 @@ +from __future__ import annotations + +import csv +import datetime as dt +import json +import re +import shutil +from collections import Counter, defaultdict +from pathlib import Path +from typing import Any + + +REPO_ROOT = Path(__file__).resolve().parents[2] +HOME = Path.home() +CLAUDE_HOME = HOME / ".claude" +CODEX_HOME = HOME / ".codex" +CURSOR_HOME = HOME / ".cursor" +CURSOR_USER_SETTINGS = HOME / "AppData" / "Roaming" / "Cursor" / "User" / "settings.json" +VSCODE_INSIDERS_ROOT = Path("C:/Dev/VSCodeInsiders") + +PROJECT_CURSOR = REPO_ROOT / ".cursor" +PROJECT_RULES = PROJECT_CURSOR / "rules" +PROJECT_SKILLS = PROJECT_CURSOR / "skills" +PROJECT_HOOKS = PROJECT_CURSOR / "hooks" +USER_CURSOR_SKILLS = CURSOR_HOME / "skills" +USER_CURSOR_AGENTS = CURSOR_HOME / "agents" + +HISTORY_ROOT = REPO_ROOT / "docs" / "ai-env-history" +HISTORY_SUMMARIES = HISTORY_ROOT / "sessions" +MANIFEST_PATH = HISTORY_ROOT / "cursor_migration_manifest.json" + +STAMP = dt.datetime.now(dt.timezone(dt.timedelta(hours=8))).strftime("%Y%m%d-%H%M%S") +BACKUP_ROOT = CURSOR_HOME / "backups" / "neozqyy-cursor-migration" / STAMP + +SKILLS_TO_COPY = [ + "agent-introspection-debugging", + "claude-api", + "code-tour", + "codebase-onboarding", + "repo-scan", + "rules-distill", + "search-first", + "security-review", + "strategic-compact", + "tdd-workflow", + "claude-agent-roles", + "claude-rules-reference", +] + +EXPLICIT_ONLY_SKILLS = { + "agent-introspection-debugging", + "claude-agent-roles", + "claude-rules-reference", + "code-tour", + "codebase-onboarding", + "repo-scan", + "rules-distill", + "search-first", + "security-review", + "tdd-workflow", +} + +NATURAL_TRIGGER_SKILLS = { + "claude-api", + "neozqyy-cursor-migration", + "strategic-compact", +} + +TOPIC_RULES = [ + ("AI 开发环境迁移", ["迁移", "cursor", "codex", "claude", "agents", "skills", "mcp", ".cursor", ".claude"]), + ("AI 应用与 Prompt", ["app2", "app3", "prompt", "dashscope", "ai_run", "ai_run_logs", "app/ai", "百炼", "千问"]), + ("Admin AI 管理后台", ["admin-web", "ai 管理", "ai management", "ai triggers", "airunlogs", "aioperations"]), + ("运行时上下文", ["runtime_context", "runtime context", "sandbox", "admin_runtime_context"]), + ("后端 API 与服务", ["apps/backend", "fastapi", "router", "service", "schemas", "websocket"]), + ("ETL 与财务口径", ["apps/etl", "dwd", "dws", "finance", "consume_money", "revenue", "财务"]), + ("数据库与 RLS", ["db/", "migrations", "schema", "rls", "fdw", "view", "postgres"]), + ("小程序体验", ["apps/miniprogram", "wxml", "wxss", "微信", "小程序", "board-finance"]), + ("任务引擎与触发器", ["task_engine", "task_generator", "trigger", "scheduler", "任务"]), + ("审计与文档", ["docs/audit", "audit", "审计", "文档", "docs/"]), +] + +SECRET_PATTERNS = [ + re.compile(r"sk-[A-Za-z0-9_-]{12,}"), + re.compile(r"sk-proj-[A-Za-z0-9_-]{12,}"), + re.compile(r"(?i)(password|passwd|pwd|token|secret|api[_-]?key)\s*[:=]\s*['\"]?[^'\"\s,;]+"), + re.compile(r"postgresql://[^\s'\"`]+"), + re.compile(r"mysql://[^\s'\"`]+"), + re.compile(r"mongodb(?:\+srv)?://[^\s'\"`]+"), +] + +FILE_PATTERN = re.compile( + r"(?:(?:[A-Za-z]:)?[\\/](?:Project|c)[\\/]NeoZQYY[\\/])?" + r"((?:apps|db|docs|tools|scripts|packages|tests|infra|\.cursor|\.claude)[\\/][^\"'\s,;)\]}]+)", + re.IGNORECASE, +) + + +manifest: dict[str, Any] = { + "generated_at": STAMP, + "repo_root": str(REPO_ROOT), + "backup_root": str(BACKUP_ROOT), + "actions": [], + "history_sources": [], +} + + +def record(action: str, target: Path, source: Path | None = None, note: str = "") -> None: + manifest["actions"].append( + { + "action": action, + "target": str(target), + "source": str(source) if source else None, + "note": note, + } + ) + + +def sanitize(text: str, limit: int | None = None) -> str: + value = text.replace("\r\n", "\n").replace("\r", "\n") + for pattern in SECRET_PATTERNS: + value = pattern.sub("[已脱敏]", value) + value = re.sub(r"\s+", " ", value).strip() + if limit and len(value) > limit: + return value[: limit - 1].rstrip() + "…" + return value + + +def read_text(path: Path) -> str: + return path.read_text(encoding="utf-8", errors="replace") + + +def write_text(path: Path, text: str) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(text, encoding="utf-8", newline="\n") + record("write", path) + + +def backup(path: Path) -> None: + if not path.exists(): + return + safe = re.sub(r"[^A-Za-z0-9_.-]+", "_", str(path).replace(":", "")) + dst = BACKUP_ROOT / safe + dst.parent.mkdir(parents=True, exist_ok=True) + if path.is_dir(): + if dst.exists(): + shutil.rmtree(dst) + shutil.copytree(path, dst) + else: + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(path, dst) + record("backup", dst, path) + + +def copytree(src: Path, dst: Path) -> None: + if dst.exists(): + backup(dst) + shutil.rmtree(dst) + shutil.copytree(src, dst) + record("copytree", dst, src) + + +def parse_frontmatter(text: str) -> tuple[dict[str, str], str]: + if not text.startswith("---"): + return {}, text + end = text.find("\n---", 3) + if end == -1: + return {}, text + raw = text[3:end].strip() + body = text[end + len("\n---") :].lstrip("\n") + meta: dict[str, str] = {} + for line in raw.splitlines(): + if ":" not in line: + continue + key, value = line.split(":", 1) + meta[key.strip()] = value.strip().strip('"').strip("'") + return meta, body + + +def render_frontmatter(meta: dict[str, Any], body: str) -> str: + lines = ["---"] + for key, value in meta.items(): + if isinstance(value, bool): + rendered = "true" if value else "false" + else: + rendered = str(value) + lines.append(f"{key}: {rendered}") + lines.append("---") + return "\n".join(lines) + "\n\n" + body.lstrip("\n") + + +def normalize_file_path(value: str) -> str: + value = value.replace("\\\\", "\\").replace("\\", "/").strip() + value = re.sub(r"^[A-Za-z]:/Project/NeoZQYY/", "", value, flags=re.IGNORECASE) + value = re.sub(r"^/c/Project/NeoZQYY/", "", value, flags=re.IGNORECASE) + for marker in ["\\n", "/n", "\\r", "/r", "`", "<", ">", "|"]: + value = value.split(marker, 1)[0] + ext_match = re.match( + r"(.+?\.(?:py|ts|tsx|js|jsx|md|mdc|sql|json|toml|yaml|yml|wxml|wxss|css|scss|html|png|jpg|jpeg|xlsx|csv|txt|bat|ps1))", + value, + flags=re.IGNORECASE, + ) + if ext_match: + value = ext_match.group(1) + value = value.strip("/").rstrip("`").strip() + lower = value.lower() + if not value or len(value) > 240: + return "" + if "postgresql://" in lower or "=" in value: + return "" + if lower.endswith("/.env") or lower.endswith(".env") or ".env.local" in lower: + return "" + return value + + +def extract_files(text: str) -> set[str]: + return {path for match in FILE_PATTERN.finditer(text) if (path := normalize_file_path(match.group(1)))} + + +def extract_message_text(message: Any) -> str: + if isinstance(message, str): + return message + if isinstance(message, dict): + content = message.get("content") + if isinstance(content, str): + return content + if isinstance(content, list): + parts: list[str] = [] + for item in content: + if isinstance(item, dict): + if item.get("type") in {"text", "input_text", "output_text"}: + parts.append(str(item.get("text", ""))) + elif "content" in item and isinstance(item["content"], str): + parts.append(item["content"]) + elif isinstance(item, str): + parts.append(item) + return "\n".join(parts) + return "" + + +def extract_json_value(data: dict[str, Any], *path: str) -> Any: + node: Any = data + for key in path: + if not isinstance(node, dict): + return None + node = node.get(key) + return node + + +def write_rule(name: str, description: str, globs: str | None, always: bool, body: str) -> None: + frontmatter = ["---", f"description: {description}"] + if globs: + frontmatter.append(f"globs: {globs}") + frontmatter.append(f"alwaysApply: {'true' if always else 'false'}") + frontmatter.append("---") + write_text(PROJECT_RULES / f"{name}.mdc", "\n".join(frontmatter) + "\n\n" + body.strip() + "\n") + + +def create_project_rules() -> None: + backup(PROJECT_RULES) + write_rule( + "neozqyy-core", + "NeoZQYY 核心工作规范:中文、调研、验证、审计、dirty tree 保护。", + None, + True, + """ +# NeoZQYY 核心规范 + +- 始终使用中文交流、解释、审计和文档;命令、API 字段、变量名保持原文。 +- 以 `AGENTS.md` 为当前权威规则,`CLAUDE.md` 作为历史兼容来源。 +- 逻辑改动前先做需求审问和前置调研;用户明确跳过时除外。 +- 逻辑改动后运行相关验证,输出 diff 摘要和未覆盖风险。 +- 不回滚用户已有改动,不使用破坏性 git 命令,除非用户明确要求。 +- 审计记录统一写入 `docs/audit/changes/`,`docs/audit/audit_dashboard.md` 只由脚本生成。 +- 历史追溯优先查 `docs/ai-env-history/`、`docs/claude-history/`、`docs/audit/`,再查原始对话。 +- 用户偏好模型为 GPT 5.5 与 Claude 4.7;模型选择由 Cursor UI/会话设置控制,规则只保留偏好。 +- CLI / Shell 中文处理必须优先确保 UTF-8:Python 用 `encoding="utf-8"` / `PYTHONUTF8=1`,CSV 给 Excel 用 `utf-8-sig`,PowerShell/Node 避免依赖系统默认 ANSI 编码。 +- 遇到中文乱码时,不要把乱码输出当作事实;先调整编码重跑,或明确说明终端编码异常并转述可确认的信息。 +- Shell 路径和参数含中文、空格或特殊字符时必须正确加引号;复杂中文输出优先用脚本或结构化 API,避免手写脆弱转义。 +""", + ) + write_rule( + "backend-fastapi", + "FastAPI 后端规则:响应包装、认证、AI 集成、RLS 与测试库。", + "apps/backend/**", + False, + """ +# 后端规则 + +- 2xx 响应经 `ResponseWrapperMiddleware` 包装为 `{ "code": 0, "data": }`。 +- 后端内部使用 snake_case,JSON 输出通过 `CamelModel` 转 camelCase。 +- admin、miniapp、tenant-admin 三类 JWT aud 不可混用。 +- 访问 ETL FDW/RLS 视图前必须设置 `app.current_site_id`。 +- AI 集成涉及 DashScope、熔断、限流、预算、缓存和运行日志,改动后必须查审计历史。 +- 后端验证默认在 `apps/backend` 下运行,使用测试库,禁止连正式库。 +""", + ) + write_rule( + "etl-feiqiu", + "飞球 ETL 规则:DWD-DOC 优先、金额口径、DWS 优先、禁止归档目录。", + "apps/etl/connectors/feiqiu/**", + False, + """ +# ETL 飞球规则 + +- 金额、支付、消费链路、字段语义优先参考 `apps/etl/connectors/feiqiu/docs/reports/DWD-DOC/`。 +- `consume_money` 禁止直接用于计算,使用 `items_sum` 拆分字段。 +- 助教费用必须区分 `assistant_pd_money` 和 `assistant_cx_money`。 +- 正向结算使用 `settle_type IN (1, 3)`,禁止随意读取 ODS 做业务计算。 +- DWS/DWD 汇总默认保持幂等,禁止 `TRUNCATE`。 +- 所有 `_archived/` 目录禁止读取或参考。 +""", + ) + write_rule( + "database", + "数据库规则:schema 变更、RLS 双 schema、文档同步和验证 SQL。", + "db/**,docs/database/**", + False, + """ +# 数据库规则 + +- 任何 PostgreSQL schema/迁移/DDL/ORM 结构变更必须同步 `docs/database/`。 +- 新建 DWS/DWD RLS 视图必须同时创建原 schema 和 `app` schema 视图。 +- 回滚需逆序 DROP/ALTER,并提供至少 3 条验证 SQL。 +- 默认使用 `TEST_DB_DSN` / `TEST_APP_DB_DSN`,禁止连正式库。 +- DDL 基线变更后运行 `tools/db/gen_consolidated_ddl.py` 并同步权威 DDL。 +""", + ) + write_rule( + "admin-web", + "admin-web 规则:React/Vite/AntD、AI 管理套件与前端验证。", + "apps/admin-web/**", + False, + """ +# admin-web 规则 + +- `admin-web` 是开发/运维后台,不是租户后台。 +- 遵循 React + Vite + Ant Design 现有页面和 API 封装风格。 +- AI 管理相关改动先查 2026-04-21、2026-04-30 审计记录。 +- 前端逻辑改动后优先运行 `pnpm test` / `pnpm lint`,无法运行需说明原因。 +""", + ) + write_rule( + "miniprogram", + "微信小程序规则:生产小程序、Donut/TDesign、demo 标杆对齐。", + "apps/miniprogram/**", + False, + """ +# 小程序规则 + +- `apps/miniprogram` 是生产小程序,数据来自后端 API。 +- UI 样式和展示格式需要参考 `apps/demo-miniprogram` 的 MOCK 标杆。 +- 改动关键交互、鉴权、API 字段或页面跳转后必须说明验证路径。 +- 涉及微信开发者工具时优先使用 `weixin-devtools-mcp` 或记录手工验证步骤。 +""", + ) + write_rule( + "demo-miniprogram-protect", + "demo-miniprogram 保护规则:假数据标杆,不删除不迁移到 _DEL。", + "apps/demo-miniprogram/**", + False, + """ +# demo-miniprogram 保护 + +- 本目录是假数据 MOCK 版小程序,用于页面样式和展示格式标杆校对。 +- 禁止删除、移入 `_DEL/` 或改造成真实 API 驱动。 +- 只有在用户明确要求校正 demo 标杆时才修改。 +""", + ) + write_rule( + "audit-history", + "历史追溯规则:优先使用精简索引、审计记录和当前代码。", + "docs/**", + False, + """ +# 历史追溯规则 + +- 日常追溯先查 `docs/ai-env-history/README.md`、`docs/claude-history/`、`docs/audit/changes/`。 +- 历史摘要只解释来龙去脉,编码前仍以当前文件、当前 diff、当前测试为准。 +- 原始 JSONL 可用于追查细节,但不要把密钥、DSN、token 原文写入仓库文档。 +""", + ) + + +def create_user_skills() -> None: + USER_CURSOR_SKILLS.mkdir(parents=True, exist_ok=True) + for name in SKILLS_TO_COPY: + src = CODEX_HOME / "skills" / name + if not src.exists(): + src = CLAUDE_HOME / "skills" / name + if src.exists(): + target = USER_CURSOR_SKILLS / name + copytree(src, target) + skill_md = target / "SKILL.md" + if name in EXPLICIT_ONLY_SKILLS and skill_md.exists(): + meta, body = parse_frontmatter(read_text(skill_md)) + meta["disable-model-invocation"] = True + write_text(skill_md, render_frontmatter(meta, body)) + + skill = """--- +name: neozqyy-cursor-migration +description: NeoZQYY AI 开发环境迁移与历史追溯工作流。Use when 需要理解 Claude Code、Codex、Cursor 迁移关系,查历史对话,或恢复用户 AI 开发习惯。 +--- + +# NeoZQYY Cursor 迁移 + +## 快速入口 + +1. 当前权威项目规则:`AGENTS.md` 与 `.cursor/rules/`。 +2. 迁移说明:`docs/cursor_migration.md`。 +3. 历史索引:`docs/ai-env-history/README.md`。 +4. Claude 历史摘要:`docs/claude-history/`。 +5. 审计记录:`docs/audit/changes/`。 + +## 使用原则 + +- 先查精简索引,再读原始对话。 +- 历史用于理解前因后果,不替代当前代码和测试。 +- GPT 5.5 / Claude 4.7 是用户偏好模型;具体模型选择由 Cursor 当前会话或 UI 控制。 +""" + write_text(USER_CURSOR_SKILLS / "neozqyy-cursor-migration" / "SKILL.md", skill) + + +def create_user_agents() -> None: + USER_CURSOR_AGENTS.mkdir(parents=True, exist_ok=True) + src_root = CLAUDE_HOME / "agents" + if not src_root.exists(): + return + for src in sorted(src_root.glob("*.md")): + text = read_text(src) + meta, body = parse_frontmatter(text) + name = re.sub(r"[^a-z0-9-]", "-", meta.get("name", src.stem).lower()).strip("-") or src.stem + description = meta.get("description") or f"从 Claude Code 迁移的 {name} 子代理。Use proactively when relevant." + target = USER_CURSOR_AGENTS / f"{name}.md" + write_text(target, f"---\nname: {name}\ndescription: {description}\n---\n\n{body.strip()}\n") + + +def create_project_skills() -> None: + backup(PROJECT_SKILLS) + commands_dir = REPO_ROOT / ".claude" / "commands" + for src in sorted(commands_dir.glob("*.md")): + body = read_text(src) + title = body.splitlines()[0].lstrip("# ").strip() if body.splitlines() else src.stem + description = f"{title}。从 Claude Code 命令迁移为 Cursor project skill;用户要求执行 {src.stem}、/{src.stem} 或相关流程时使用。" + content = ( + "---\n" + f"name: {src.stem}\n" + f"description: {description}\n" + "disable-model-invocation: true\n" + "---\n\n" + + body.strip() + + "\n" + ) + write_text(PROJECT_SKILLS / src.stem / "SKILL.md", content) + + +def create_project_hooks() -> None: + backup(PROJECT_CURSOR / "hooks.json") + backup(PROJECT_HOOKS) + hook_config = { + "version": 1, + "hooks": { + "preToolUse": [ + { + "command": ".venv\\Scripts\\python.exe .cursor\\hooks\\ai_env_guard.py preToolUse", + "matcher": "Read|Glob|Edit|Write|ApplyPatch", + "timeout": 5, + "failClosed": True, + } + ], + "beforeReadFile": [ + { + "command": ".venv\\Scripts\\python.exe .cursor\\hooks\\ai_env_guard.py beforeReadFile", + "matcher": "_archived", + "timeout": 5, + "failClosed": True, + } + ], + "beforeMCPExecution": [ + { + "command": ".venv\\Scripts\\python.exe .cursor\\hooks\\ai_env_guard.py beforeMCPExecution", + "matcher": "pg-etl|pg-app", + "timeout": 5, + "failClosed": False, + } + ], + "beforeShellExecution": [ + { + "command": ".venv\\Scripts\\python.exe .cursor\\hooks\\ai_env_guard.py beforeShellExecution", + "matcher": "git\\s+(reset|checkout)|--no-verify|psql|mcp-postgres|PG_DSN|APP_DB_DSN", + "timeout": 5, + "failClosed": False, + } + ], + "postToolUse": [ + { + "command": ".venv\\Scripts\\python.exe .cursor\\hooks\\ai_env_guard.py postToolUse", + "matcher": "ApplyPatch|Edit|Write", + "timeout": 5, + "failClosed": False, + } + ], + }, + } + write_text(PROJECT_CURSOR / "hooks.json", json.dumps(hook_config, ensure_ascii=False, indent=2) + "\n") + hook_script = r'''from __future__ import annotations + +import json +import re +import sys +from pathlib import Path + + +ARCHIVED_RE = re.compile(r"(^|[/\\])_archived([/\\]|$)", re.IGNORECASE) +PROD_MCP_RE = re.compile(r"\b(pg-etl|pg-app)\b", re.IGNORECASE) +TEST_MCP_RE = re.compile(r"\b(pg-etl-test|pg-app-test)\b", re.IGNORECASE) +WRITE_SQL_RE = re.compile( + r"\b(insert|update|delete|truncate|drop|alter|create|grant|revoke|merge|copy|call|vacuum|reindex)\b", + re.IGNORECASE, +) + + +def load_input() -> dict: + try: + return json.load(sys.stdin) + except Exception: + return {} + + +def target_text(data: dict) -> str: + chunks = [json.dumps(data, ensure_ascii=False)] + for key in ("command", "file_path", "path"): + value = data.get(key) + if isinstance(value, str): + chunks.append(value) + tool_input = data.get("tool_input") + if isinstance(tool_input, dict): + chunks.append(json.dumps(tool_input, ensure_ascii=False)) + return "\n".join(chunks) + + +def allow() -> None: + print(json.dumps({"permission": "allow"}, ensure_ascii=False)) + + +def before_shell(data: dict) -> None: + text = target_text(data) + dangerous = [ + r"git\s+reset\s+--hard", + r"git\s+checkout\s+--", + r"--no-verify", + r"\bPG_DSN\b", + r"\bAPP_DB_DSN\b", + r"\bpsql\b", + ] + if any(re.search(pattern, text, re.IGNORECASE) for pattern in dangerous): + print(json.dumps({ + "permission": "ask", + "user_message": "命令命中 NeoZQYY 高风险规则:请确认是否需要执行。", + "agent_message": "执行前说明风险、目标库/文件、回滚方式;生产库默认不执行。", + }, ensure_ascii=False)) + return + allow() + + +def guard_archived(data: dict) -> None: + text = target_text(data) + if ARCHIVED_RE.search(text): + print(json.dumps({ + "permission": "deny", + "user_message": "已阻断:`_archived/` 是废弃归档目录,禁止读取、搜索或作为实现参考。", + "agent_message": "改用当前版本文件;如确需考古,请让用户明确授权并说明目的。", + }, ensure_ascii=False)) + return + allow() + + +def before_mcp(data: dict) -> None: + text = target_text(data) + if not PROD_MCP_RE.search(text) or TEST_MCP_RE.search(text): + allow() + return + if WRITE_SQL_RE.search(text): + print(json.dumps({ + "permission": "deny", + "user_message": "已阻断:生产库 MCP 写入/DDL 类操作默认禁止。请改用测试库验证,或让用户给出单次明确授权。", + "agent_message": "生产库仅允许人工确认后的只读排查;DDL/写入需走单独变更流程。", + }, ensure_ascii=False)) + return + print(json.dumps({ + "permission": "ask", + "user_message": "检测到生产库 MCP 只读调用。请确认本次查询目的、SQL 是否只读、是否会暴露敏感数据。", + "agent_message": "说明查询目的、目标库、SQL 摘要和风险后等待用户确认。", + }, ensure_ascii=False)) + + +def post_tool_use(data: dict) -> None: + text = target_text(data).replace("\\", "/") + hints = [] + if "/_archived/" in text or text.endswith("/_archived"): + hints.append("检测到 `_archived/` 路径:该目录内容已废弃,禁止作为实现参考。") + if "apps/demo-miniprogram/" in text: + hints.append("检测到 demo-miniprogram:这是 MOCK 标杆目录,修改前需确认目的。") + if re.search(r"(db/|docs/database/|migrations/|schemas/)", text): + hints.append("检测到数据库相关改动:如影响 schema,需同步 `docs/database/` 并提供 3 条验证 SQL。") + if re.search(r"(apps/backend/|apps/etl/|app/ai/|prompts/)", text): + hints.append("检测到后端/ETL/AI 逻辑改动:完成后需运行相关测试并写审计记录。") + if hints: + print(json.dumps({"additional_context": "\n".join(f"[NeoZQYY] {hint}" for hint in hints)}, ensure_ascii=False)) + + +def main() -> None: + mode = sys.argv[1] if len(sys.argv) > 1 else "" + data = load_input() + if mode in {"preToolUse", "beforeReadFile"}: + guard_archived(data) + elif mode == "beforeMCPExecution": + before_mcp(data) + elif mode == "beforeShellExecution": + before_shell(data) + elif mode == "postToolUse": + post_tool_use(data) + + +if __name__ == "__main__": + main() +''' + write_text(PROJECT_HOOKS / "ai_env_guard.py", hook_script) + + +def summarize_jsonl(path: Path, source: str) -> dict[str, Any]: + session_id = path.stem + timestamps: list[str] = [] + cwd_values: Counter[str] = Counter() + user_prompts: list[str] = [] + assistant_notes: list[str] = [] + tools: Counter[str] = Counter() + files: set[str] = set() + line_count = 0 + + with path.open("r", encoding="utf-8", errors="replace") as fh: + for line in fh: + line_count += 1 + raw = line.strip() + if not raw: + continue + files.update(extract_files(raw)) + try: + data = json.loads(raw) + except Exception: + continue + ts = data.get("timestamp") or extract_json_value(data, "payload", "timestamp") + if isinstance(ts, str): + timestamps.append(ts) + sid = data.get("sessionId") or extract_json_value(data, "payload", "id") + if isinstance(sid, str): + session_id = sid + cwd = data.get("cwd") or extract_json_value(data, "payload", "cwd") + if isinstance(cwd, str): + cwd_values[cwd] += 1 + + role = data.get("type") or data.get("role") or extract_json_value(data, "payload", "role") + if role == "user": + prompt = extract_message_text(data.get("message") or data) + if prompt: + user_prompts.append(sanitize(prompt, 600)) + elif role == "assistant": + note = extract_message_text(data.get("message") or data) + if note: + assistant_notes.append(sanitize(note, 400)) + + payload = data.get("payload") if isinstance(data.get("payload"), dict) else {} + if payload: + if payload.get("type") == "function_call": + name = payload.get("name") + if isinstance(name, str): + tools[name] += 1 + args = payload.get("arguments") + if isinstance(args, str): + files.update(extract_files(args)) + if payload.get("type") == "message": + for item in payload.get("content", []) or []: + if isinstance(item, dict): + if item.get("type") == "tool_use": + name = item.get("name") + if isinstance(name, str): + tools[name] += 1 + files.update(extract_files(json.dumps(item, ensure_ascii=False))) + + message = data.get("message") + if isinstance(message, dict): + for item in message.get("content", []) or []: + if isinstance(item, dict): + if item.get("type") == "tool_use": + name = item.get("name") + if isinstance(name, str): + tools[name] += 1 + files.update(extract_files(json.dumps(item, ensure_ascii=False))) + attachment = data.get("attachment") + if isinstance(attachment, dict): + atype = attachment.get("type") + if isinstance(atype, str): + tools[atype] += 1 + + first_ts = min(timestamps) if timestamps else "" + last_ts = max(timestamps) if timestamps else "" + prompts = user_prompts[:12] + notes = assistant_notes[:8] + risk_flags = [] + joined_files = " ".join(sorted(files)).lower() + if "db/" in joined_files or "migrations/" in joined_files or "schemas/" in joined_files: + risk_flags.append("数据库") + if "apps/backend/" in joined_files: + risk_flags.append("后端") + if "apps/etl/" in joined_files: + risk_flags.append("ETL") + if "apps/miniprogram/" in joined_files: + risk_flags.append("小程序") + if any("_archived" in item.lower() for item in files): + risk_flags.append("涉及归档目录") + + return { + "source": source, + "session_id": session_id, + "path": str(path), + "first_ts": first_ts, + "last_ts": last_ts, + "line_count": line_count, + "cwd": cwd_values.most_common(1)[0][0] if cwd_values else "", + "user_prompts": prompts, + "assistant_notes": notes, + "tools": dict(tools.most_common(20)), + "files": sorted(files), + "risk_flags": risk_flags, + "topics": classify_topics( + "\n".join(user_prompts + assistant_notes + sorted(files) + list(tools.keys())) + ), + } + + +def classify_topics(text: str) -> list[str]: + lower = text.lower() + topics = [] + for topic, keywords in TOPIC_RULES: + if any(keyword.lower() in lower for keyword in keywords): + topics.append(topic) + return topics or ["未分类"] + + +def write_session_summary(summary: dict[str, Any]) -> None: + source = summary["source"] + sid = re.sub(r"[^A-Za-z0-9_.-]+", "_", summary["session_id"])[:120] + target = HISTORY_SUMMARIES / source / f"{sid}.md" + prompts = "\n".join(f"- {item}" for item in summary["user_prompts"]) or "- 未识别" + notes = "\n".join(f"- {item}" for item in summary["assistant_notes"]) or "- 未识别" + files = "\n".join(f"- `{item}`" for item in summary["files"][:80]) or "- 未识别" + tools = "\n".join(f"- `{name}`: {count}" for name, count in summary["tools"].items()) or "- 未识别" + risks = "、".join(summary["risk_flags"]) if summary["risk_flags"] else "未识别" + body = f"""# {source} 会话 {summary['session_id']} + +## 基本信息 + +- 来源:{source} +- 时间:{summary['first_ts']} -> {summary['last_ts']} +- 行数:{summary['line_count']} +- cwd:`{summary['cwd']}` +- 风险标签:{risks} +- 主题:{", ".join(summary.get("topics", ["未分类"]))} +- 原始记录:`{summary['path']}` + +## 用户需求线索 + +{prompts} + +## 助手回应线索 + +{notes} + +## 工具与事件 + +{tools} + +## 文件影响线索 + +{files} + +## 使用说明 + +本摘要由原始 JSONL 逐行分析生成,用于理解对话前因后果。编码前仍需读取当前文件、git diff、审计记录和测试结果。 +""" + write_text(target, body) + + +def collect_history_paths() -> dict[str, list[Path]]: + return { + "claude": sorted((CLAUDE_HOME / "projects" / "C--Project-NeoZQYY").glob("*.jsonl")), + "codex": sorted((CODEX_HOME / "sessions").glob("**/*.jsonl")) + + sorted((CODEX_HOME / "archived_sessions").glob("*.jsonl")), + "cursor": sorted((CURSOR_HOME / "projects" / "c-Project-NeoZQYY" / "agent-transcripts").glob("*/*.jsonl")), + } + + +def analyze_histories() -> None: + backup(HISTORY_ROOT) + sources = collect_history_paths() + all_summaries: list[dict[str, Any]] = [] + file_index: dict[str, list[dict[str, str]]] = defaultdict(list) + + for source, paths in sources.items(): + manifest["history_sources"].append({"source": source, "count": len(paths)}) + for path in paths: + summary = summarize_jsonl(path, source) + all_summaries.append(summary) + write_session_summary(summary) + for file_path in summary["files"]: + file_index[file_path].append( + { + "source": source, + "session_id": summary["session_id"], + "first_ts": summary["first_ts"], + "last_ts": summary["last_ts"], + "summary": str(HISTORY_SUMMARIES / source / f"{re.sub(r'[^A-Za-z0-9_.-]+', '_', summary['session_id'])[:120]}.md"), + } + ) + + HISTORY_ROOT.mkdir(parents=True, exist_ok=True) + with (HISTORY_ROOT / "conversation_index.csv").open("w", encoding="utf-8-sig", newline="") as fh: + writer = csv.DictWriter( + fh, + fieldnames=["source", "session_id", "first_ts", "last_ts", "line_count", "cwd", "file_count", "risk_flags", "summary_path", "raw_path"], + ) + writer.writeheader() + for summary in sorted(all_summaries, key=lambda item: (item["first_ts"], item["source"])): + sid = re.sub(r"[^A-Za-z0-9_.-]+", "_", summary["session_id"])[:120] + writer.writerow( + { + "source": summary["source"], + "session_id": summary["session_id"], + "first_ts": summary["first_ts"], + "last_ts": summary["last_ts"], + "line_count": summary["line_count"], + "cwd": summary["cwd"], + "file_count": len(summary["files"]), + "risk_flags": ";".join(summary["risk_flags"]), + "summary_path": str(HISTORY_SUMMARIES / summary["source"] / f"{sid}.md"), + "raw_path": summary["path"], + } + ) + record("write", HISTORY_ROOT / "conversation_index.csv") + + with (HISTORY_ROOT / "file_impact_index.csv").open("w", encoding="utf-8-sig", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=["file", "source", "session_id", "first_ts", "last_ts", "summary"]) + writer.writeheader() + for file_path, rows in sorted(file_index.items()): + for row in rows: + writer.writerow({"file": file_path, **row}) + record("write", HISTORY_ROOT / "file_impact_index.csv") + + with (HISTORY_ROOT / "topic_timeline.csv").open("w", encoding="utf-8-sig", newline="") as fh: + writer = csv.DictWriter( + fh, + fieldnames=["topic", "source", "session_id", "first_ts", "last_ts", "risk_flags", "prompt", "summary_path", "raw_path"], + ) + writer.writeheader() + for summary in sorted(all_summaries, key=lambda item: (item["first_ts"], item["source"])): + sid = re.sub(r"[^A-Za-z0-9_.-]+", "_", summary["session_id"])[:120] + prompt = summary["user_prompts"][0] if summary["user_prompts"] else "" + for topic in summary.get("topics", ["未分类"]): + writer.writerow( + { + "topic": topic, + "source": summary["source"], + "session_id": summary["session_id"], + "first_ts": summary["first_ts"], + "last_ts": summary["last_ts"], + "risk_flags": ";".join(summary["risk_flags"]), + "prompt": prompt, + "summary_path": str(HISTORY_SUMMARIES / summary["source"] / f"{sid}.md"), + "raw_path": summary["path"], + } + ) + record("write", HISTORY_ROOT / "topic_timeline.csv") + + grouped: dict[str, list[dict[str, Any]]] = defaultdict(list) + for summary in all_summaries: + for topic in summary.get("topics", ["未分类"]): + grouped[topic].append(summary) + parts = ["# AI 开发历史主题时间线", ""] + for topic in sorted(grouped): + parts.append(f"## {topic}") + for summary in sorted(grouped[topic], key=lambda item: item["first_ts"])[:80]: + sid = re.sub(r"[^A-Za-z0-9_.-]+", "_", summary["session_id"])[:120] + prompt = summary["user_prompts"][0] if summary["user_prompts"] else "未识别用户需求" + risks = "、".join(summary["risk_flags"]) if summary["risk_flags"] else "无显式风险标签" + parts.append( + f"- {summary['first_ts']} -> {summary['last_ts']} " + f"`{summary['source']}` `{summary['session_id']}`:{prompt} " + f"(风险:{risks};摘要:`docs/ai-env-history/sessions/{summary['source']}/{sid}.md`)" + ) + parts.append("") + write_text(HISTORY_ROOT / "topic_timeline.md", "\n".join(parts).rstrip() + "\n") + + claude_history = CLAUDE_HOME / "history.jsonl" + plans = sorted((CLAUDE_HOME / "plans").glob("*.md")) + file_history_count = len(list((CLAUDE_HOME / "file-history").glob("*/*"))) if (CLAUDE_HOME / "file-history").exists() else 0 + checkpoints = sorted((HOME / "AppData" / "Roaming" / "Cursor" / "User" / "globalStorage" / "anysphere.cursor-commits" / "checkpoints").glob("*/metadata.json")) + local_history = sorted((HOME / "AppData" / "Roaming" / "Cursor" / "User" / "History").glob("**/*.json")) + + catalog = f"""# AI 开发环境历史索引 + +## 覆盖范围 + +- Claude 原始项目会话:{len(sources['claude'])} 个,逐一生成摘要。 +- Codex 本地/归档会话:{len(sources['codex'])} 个,逐一生成摘要。 +- Cursor 父会话 transcripts:{len(sources['cursor'])} 个,逐一生成摘要。 +- Claude 全局 history:`{claude_history}`,存在:{claude_history.exists()}。 +- Claude plans:{len(plans)} 份。 +- Claude file-history 快照:约 {file_history_count} 个。 +- Cursor 本地文件历史 JSON:{len(local_history)} 个。 +- Cursor checkpoints:{len(checkpoints)} 个。 + +## 主要入口 + +- 会话索引:`docs/ai-env-history/conversation_index.csv` +- 文件影响索引:`docs/ai-env-history/file_impact_index.csv` +- 主题时间线:`docs/ai-env-history/topic_timeline.md` +- 会话摘要:`docs/ai-env-history/sessions/` +- Claude 旧摘要:`docs/claude-history/` +- 审计记录:`docs/audit/changes/` + +## 使用策略 + +1. 查“某文件为什么改”:先查 `file_impact_index.csv`,再读对应会话摘要和审计记录。 +2. 查“某次对话做了什么”:先查 `conversation_index.csv`,再读摘要;必要时读原始 JSONL。 +3. 恢复误改:再查 Claude file-history、Cursor local History 或 Cursor checkpoints。 +4. 原始记录可能包含敏感信息;摘要会脱敏常见密钥和 DSN 形态,原文只在本机按需读取。 +""" + write_text(HISTORY_ROOT / "README.md", catalog) + + +def create_cursor_migration_doc() -> None: + doc = """# Cursor AI 开发环境迁移说明 + +## 状态 + +本轮把 NeoZQYY 的 AI 开发环境从 Claude Code / Codex 迁移到 Cursor 原生资产: + +- 项目规则:`.cursor/rules/` +- 项目流程技能:`.cursor/skills/` +- 用户技能:`C:\\Users\\Administrator\\.cursor\\skills` +- 用户子代理:`C:\\Users\\Administrator\\.cursor\\agents` +- 轻量 hooks:`.cursor/hooks.json` 与 `.cursor/hooks/` +- 历史索引:`docs/ai-env-history/` + +## 去重原则 + +1. 当前仓库 `AGENTS.md` 是项目权威规则。 +2. `CLAUDE.md` 保留为历史兼容来源。 +3. `.cursor/rules` 只放短规则和入口,不复制长文。 +4. skills 使用“短入口 + reference”,避免每次会话加载过多旧内容。 +5. 原始对话逐一分析为摘要和索引,不把原始 JSONL 全文写入仓库。 + +## 模型偏好 + +用户主要使用 GPT 5.5 与 Claude 4.7。Cursor 的实际模型选择由当前会话或 UI 控制;本迁移只把偏好写入规则和说明,不强写不可控的内部模型字段。 + +## 内置流程与规则边界 + +Cursor、GPT 5.5、Claude 4.7 都具备调研、审计、验证、子代理和工具调用能力,但它们不知道 NeoZQYY 的项目事实、历史踩坑、数据库口径、RLS 双 schema、demo 标杆保护和审计路径。因此仍需要项目规则和技能作为触发器与约束。 + +规则不重复实现模型能力,只固化三类内容: + +- 项目事实:目录职责、数据库、ETL、后端、前端、小程序约定。 +- 用户习惯:中文、先调研、再改动、再验证、再审计。 +- 风险边界:生产库禁用、敏感信息不扩散、危险 git/SQL/归档目录保护。 + +## 历史入口 + +- 新索引:`docs/ai-env-history/README.md` +- Claude 摘要:`docs/claude-history/README.md` +- 审计一览:`docs/audit/audit_dashboard.md` +- Codex 迁移说明:`docs/codex_migration.md` + +## 用户级资产触发策略 + +### 保留自然触发 + +- `neozqyy-cursor-migration`:NeoZQYY 迁移和历史追溯入口。 +- `strategic-compact`:长会话上下文压缩建议。 +- `claude-api`:仅在 Anthropic / Claude API 场景触发。 + +### 改为显式触发 + +- `claude-agent-roles`:旧 Claude agents 参考;日常使用 Cursor subagents。 +- `claude-rules-reference`:旧 Claude rules 档案;当前权威规则是 `AGENTS.md` 与 `.cursor/rules/`。 +- `tdd-workflow`:详细 TDD 参考;日常入口是 `tdd-guide` subagent。 +- `security-review`:详细安全 checklist;日常入口是 `security-reviewer` subagent。 +- `search-first`:通用搜索优先流程;NeoZQYY 内优先使用 `.cursor/skills/pre-change`。 +- `rules-distill`、`repo-scan`、`codebase-onboarding`、`code-tour`、`agent-introspection-debugging`:低频或重型工具,按需显式使用。 + +保留全部 8 个用户级 subagents:`planner`、`architect`、`code-reviewer`、`security-reviewer`、`database-reviewer`、`python-reviewer`、`tdd-guide`、`refactor-cleaner`。 + +## Hooks 强度判断 + +当前 hooks 保持轻量提醒和 ask,不默认强阻断。建议: + +- `_archived/` 读取/搜索:已升级为 `failClosed` 强阻断。依据:项目规则明确禁止参考归档目录,误用概率高。 +- 危险 git 命令:保持 ask,不建议 `failClosed`。依据:极少数维护场景可能需要,必须由用户明确确认。 +- 生产库 MCP / 生产 DSN:只读调用走 ask;写入/DDL 类调用 deny。依据:测试库足够覆盖日常验证,生产库写操作风险高。 +- demo-miniprogram:保持提醒,不建议强阻断。依据:它是标杆目录,偶尔仍可能需要按用户明确要求修改。 +- DB schema 改动提醒:保持提醒。依据:是否需要文档同步要结合实际变更判断。 + +## MCP 策略 + +延续既有用途: + +- `pg-etl-test`、`pg-app-test`:测试库,默认可用。 +- `pg-etl`、`pg-app`:生产库,默认禁用或 ask,不自动执行。 +- `weixin-devtools-mcp`:小程序调试和截图验证。 +- `playwright` / `cursor-ide-browser`:Web 前端验证、截图、交互检查。 +- `openapi`:后端 API 合同查询。 + +## VSCode Insiders 迁移 + +- 用户数据目录:`C:\\Users\\Administrator\\AppData\\Roaming\\Code - Insiders\\User` +- 扩展清单:`C:\\Users\\Administrator\\.vscode-insiders\\extensions\\extensions.json` +- Cursor 扩展对照表:`docs/ai-env-history/vscode_insiders_extensions.csv` +- 补装脚本:`tools/cursor/install_vscode_insiders_extensions.ps1` +- 旧环境 disabled 状态已记录;Cursor 没有稳定 CLI 持久禁用接口,补装后需要在 Cursor UI 中按清单确认禁用状态。 + +## 主题时间线 + +- 主题时间线:`docs/ai-env-history/topic_timeline.md` +- 主题 CSV:`docs/ai-env-history/topic_timeline.csv` + +## 验证 + +```powershell +.venv\\Scripts\\python.exe tools\\cursor\\migrate_ai_environment.py --check +.venv\\Scripts\\python.exe scripts\\audit\\prescan.py --files "tools/cursor/migrate_ai_environment.py,docs/cursor_migration.md" +``` + +## 回滚 + +迁移脚本会把覆盖前的 Cursor 用户资产和项目 `.cursor` 资产备份到: + +`C:\\Users\\Administrator\\.cursor\\backups\\neozqyy-cursor-migration\\` + +如需回滚,按 manifest 中的源/目标路径恢复即可。 +""" + write_text(REPO_ROOT / "docs" / "cursor_migration.md", doc) + + +def create_audit_record() -> None: + now = dt.datetime.now(dt.timezone(dt.timedelta(hours=8))).strftime("%Y-%m-%d %H:%M:%S") + record = f"""# 变更审计记录:Cursor AI 开发环境迁移 + +| 字段 | 值 | +|------|-----| +| 日期 | {now} | + +## 操作摘要 + +本轮将 Claude Code 与 Codex 中已经沉淀的 AI 开发习惯迁移到 Cursor 原生结构。迁移目标是让 Cursor 继承中文沟通、前置调研、验证、审计、子代理审查、MCP 测试库优先和历史追溯习惯,同时避免把长规则和原始对话全文堆入常规上下文。 + +## 变更文件 + +### 新增/修改 + +- `tools/cursor/migrate_ai_environment.py`:可重复迁移脚本。 +- `.cursor/rules/`:Cursor 项目级短规则。 +- `.cursor/skills/`:审计、前置调研、文档同步和 spec 收尾流程技能。 +- `.cursor/hooks.json`、`.cursor/hooks/ai_env_guard.py`:轻量提醒/保护 hooks。 +- `docs/cursor_migration.md`:Cursor 迁移说明。 +- `docs/ai-env-history/`:Claude/Codex/Cursor 原始对话精简索引和逐会话摘要。 +- `C:\\Users\\Administrator\\.cursor\\skills`:用户级技能迁移。 +- `C:\\Users\\Administrator\\.cursor\\agents`:用户级子代理迁移。 + +## 数据库变更 + +无数据库 schema 变更。未执行 DDL,未修改迁移 SQL。 + +## 风险与回滚 + +- 中:Cursor hooks 与 Claude hooks 事件字段不完全一致,本轮只启用轻量提醒和 ask,不启用强阻断。 +- 中:历史摘要从原始 JSONL 自动分析,仍可能遗漏自然语言里的隐含决策;关键判断需回看原文和当前代码。 +- 低:用户级 Cursor 资产覆盖前已备份,可从 manifest 恢复。 + +## 验证 + +- 运行 `python tools/cursor/migrate_ai_environment.py --check` 校验生成结构。 +- 运行 `python scripts/audit/prescan.py --files "<本轮迁移文件>"` 做审计预扫描。 +- 解析 `.mcp.json`、`.cursor/hooks.json`、skill/subagent frontmatter。 + +## 合规检查 + +- 文档同步:已新增 `docs/cursor_migration.md` 和 `docs/ai-env-history/README.md`。 +- 数据库文档:不适用,本轮无 schema 变更。 +- 审计记录:已落盘。 +""" + write_text(REPO_ROOT / "docs" / "audit" / "changes" / "2026-05-01__cursor_migration.md", record) + + +def update_cursor_cli_config() -> None: + path = CURSOR_HOME / "cli-config.json" + if path.exists(): + backup(path) + try: + data = json.loads(read_text(path)) + except Exception: + data = {} + else: + data = {} + data.setdefault("display", {}) + data["display"].setdefault("showStatusIndicators", True) + data.setdefault("network", {}) + data["network"].setdefault("useHttp1ForAgent", True) + data.setdefault("permissions", {}) + data["permissions"].setdefault("allow", []) + data["permissions"].setdefault("deny", []) + write_text(path, json.dumps(data, ensure_ascii=False, indent=2) + "\n") + + +def collect_dev_environment_note() -> None: + cursor_extensions = CURSOR_HOME / "extensions" / "extensions.json" + vscode_user = HOME / "AppData" / "Roaming" / "Code - Insiders" / "User" + vscode_settings = vscode_user / "settings.json" + vscode_keybindings = vscode_user / "keybindings.json" + vscode_extensions = HOME / ".vscode-insiders" / "extensions" / "extensions.json" + sync_extensions = sorted((vscode_user / "sync" / "extensions").glob("*.json")) + + cursor_ids = read_extension_ids(cursor_extensions) + vscode_ids = read_extension_ids(vscode_extensions) + disabled_ids = read_disabled_synced_extension_ids(sync_extensions) + missing_all = sorted(vscode_ids - cursor_ids) + disabled_missing = sorted((disabled_ids & vscode_ids) - cursor_ids) + + install_script = [ + "# 从 VSCode Insiders 迁移到 Cursor 的扩展补装脚本", + "# 自动生成;安装旧 VSCode Insiders 中已安装但 Cursor 当前缺失的扩展。", + "# 旧环境 disabled 状态见 docs/ai-env-history/vscode_insiders_extensions.csv;Cursor 无稳定 CLI 持久禁用接口,需在 UI 中按清单确认。", + "$ErrorActionPreference = 'Continue'", + "$cursor = 'cursor'", + "$timeoutSec = 90", + "$extensions = @(", + ] + for extension_id in missing_all: + install_script.append(f' "{extension_id}"') + install_script.extend( + [ + ")", + "foreach ($ext in $extensions) {", + " Write-Host \"Installing $ext ...\"", + " $p = Start-Process -FilePath $cursor -ArgumentList @('--install-extension', $ext) -NoNewWindow -PassThru -Wait:$false", + " if (-not $p.WaitForExit($timeoutSec * 1000)) {", + " Write-Warning \"Timeout installing $ext; killing process and continuing.\"", + " try { Stop-Process -Id $p.Id -Force } catch {}", + " continue", + " }", + " Write-Host \"Finished $ext exit=$($p.ExitCode)\"", + "}", + "", + ] + ) + if disabled_missing: + install_script.append("") + install_script.append("# 以下扩展在 VSCode Insiders 同步状态中为 disabled,安装后建议在 Cursor UI 中禁用:") + for extension_id in disabled_missing: + install_script.append(f"# disabled-before: {extension_id}") + write_text(REPO_ROOT / "tools" / "cursor" / "install_vscode_insiders_extensions.ps1", "\n".join(install_script) + "\n") + + with (HISTORY_ROOT / "vscode_insiders_extensions.csv").open("w", encoding="utf-8-sig", newline="") as fh: + writer = csv.DictWriter(fh, fieldnames=["extension_id", "in_vscode_insiders", "in_cursor", "disabled_in_sync", "recommended_action"]) + writer.writeheader() + for extension_id in sorted(vscode_ids | cursor_ids | disabled_ids): + in_vscode = extension_id in vscode_ids + in_cursor = extension_id in cursor_ids + disabled = extension_id in disabled_ids + if in_cursor: + action = "已在 Cursor" + elif disabled: + action = "安装到 Cursor 后按旧环境状态禁用" + elif in_vscode: + action = "建议安装到 Cursor" + else: + action = "仅 Cursor 当前存在" + writer.writerow( + { + "extension_id": extension_id, + "in_vscode_insiders": in_vscode, + "in_cursor": in_cursor, + "disabled_in_sync": disabled, + "recommended_action": action, + } + ) + record("write", HISTORY_ROOT / "vscode_insiders_extensions.csv") + + note = f"""# Cursor / VSCode Insiders 开发环境盘点 + +## Cursor + +- 用户设置:`{CURSOR_USER_SETTINGS}`,存在:{CURSOR_USER_SETTINGS.exists()} +- 扩展清单:`{cursor_extensions}`,存在:{cursor_extensions.exists()} +- 当前 Cursor 扩展数量:{len(cursor_ids)} + +## 旧 VSCode Insiders + +- 根目录:`{VSCODE_INSIDERS_ROOT}`,存在:{VSCODE_INSIDERS_ROOT.exists()} +- 用户数据目录:`{vscode_user}`,存在:{vscode_user.exists()} +- 设置文件:`{vscode_settings}`,存在:{vscode_settings.exists()} +- 键位文件:`{vscode_keybindings}`,存在:{vscode_keybindings.exists()} +- 扩展清单:`{vscode_extensions}`,存在:{vscode_extensions.exists()} +- VSCode Insiders 扩展数量:{len(vscode_ids)} +- 同步状态中禁用扩展数量:{len(disabled_ids)} +- Cursor 缺失且建议安装数量:{len(missing_all)} + +## 扩展迁移 + +- 扩展对照表:`docs/ai-env-history/vscode_insiders_extensions.csv` +- 安装脚本:`tools/cursor/install_vscode_insiders_extensions.ps1` +- 旧环境 disabled 状态已写入扩展对照表;补装后需在 Cursor UI 中按表确认禁用状态。 +- 未发现旧环境中已安装 Playwright、PostgreSQL/SQL、微信开发者工具扩展;这些能力当前主要由 MCP 和项目命令承接。 +""" + write_text(HISTORY_ROOT / "dev_environment_inventory.md", note) + + +def read_extension_ids(path: Path) -> set[str]: + if not path.exists(): + return set() + try: + data = json.loads(read_text(path)) + except Exception: + return set() + ids = set() + if isinstance(data, list): + for item in data: + identifier = item.get("identifier") if isinstance(item, dict) else None + if isinstance(identifier, dict) and isinstance(identifier.get("id"), str): + ids.add(identifier["id"].lower()) + return ids + + +def read_disabled_synced_extension_ids(paths: list[Path]) -> set[str]: + disabled: set[str] = set() + for path in paths: + try: + wrapper = json.loads(read_text(path)) + except Exception: + continue + contents: list[str] = [] + if isinstance(wrapper.get("content"), str): + contents.append(wrapper["content"]) + sync_data = wrapper.get("syncData") + if isinstance(sync_data, dict) and isinstance(sync_data.get("content"), str): + contents.append(sync_data["content"]) + for content in contents: + try: + extensions = json.loads(content) + except Exception: + continue + if not isinstance(extensions, list): + continue + for item in extensions: + if not isinstance(item, dict) or not item.get("disabled"): + continue + identifier = item.get("identifier") + if isinstance(identifier, dict) and isinstance(identifier.get("id"), str): + disabled.add(identifier["id"].lower()) + return disabled + + +def run_migration() -> None: + for path in [ + PROJECT_CURSOR, + PROJECT_RULES, + PROJECT_SKILLS, + USER_CURSOR_SKILLS, + USER_CURSOR_AGENTS, + HISTORY_ROOT, + CURSOR_USER_SETTINGS, + ]: + backup(path) + create_project_rules() + create_project_skills() + create_project_hooks() + create_user_skills() + create_user_agents() + update_cursor_cli_config() + analyze_histories() + collect_dev_environment_note() + create_cursor_migration_doc() + create_audit_record() + write_text(MANIFEST_PATH, json.dumps(manifest, ensure_ascii=False, indent=2) + "\n") + + +def check_generated() -> int: + required = [ + PROJECT_RULES / "neozqyy-core.mdc", + PROJECT_SKILLS / "audit" / "SKILL.md", + PROJECT_CURSOR / "hooks.json", + USER_CURSOR_SKILLS / "neozqyy-cursor-migration" / "SKILL.md", + USER_CURSOR_AGENTS / "planner.md", + HISTORY_ROOT / "conversation_index.csv", + HISTORY_ROOT / "file_impact_index.csv", + REPO_ROOT / "docs" / "cursor_migration.md", + REPO_ROOT / "docs" / "audit" / "changes" / "2026-05-01__cursor_migration.md", + ] + missing = [path for path in required if not path.exists()] + for json_path in [PROJECT_CURSOR / "hooks.json", MANIFEST_PATH, CURSOR_HOME / "cli-config.json"]: + if json_path.exists(): + json.loads(read_text(json_path)) + if missing: + print("缺失文件:") + for path in missing: + print(f"- {path}") + return 1 + print("Cursor 迁移生成文件校验通过。") + return 0 + + +if __name__ == "__main__": + import argparse + + parser = argparse.ArgumentParser(description="迁移 NeoZQYY AI 开发环境到 Cursor。") + parser.add_argument("--check", action="store_true", help="只校验生成结果。") + args = parser.parse_args() + if args.check: + raise SystemExit(check_generated()) + run_migration() + raise SystemExit(check_generated())