chore: snapshot before P0 security phase (backup point)
This commit is contained in:
@@ -67,6 +67,11 @@ jobs:
|
||||
- name: Typecheck
|
||||
run: npm run typecheck
|
||||
|
||||
- name: Architecture scan
|
||||
run: |
|
||||
npm run arch:scan
|
||||
npm run arch:query -- violations || true
|
||||
|
||||
- name: Install Playwright Chromium
|
||||
run: npx playwright install chromium
|
||||
|
||||
|
||||
@@ -4,19 +4,23 @@
|
||||
|
||||
**任何任务开始前,必须先查阅架构影响地图,通过图定位代码和模块。**
|
||||
|
||||
1. **先图后码**:执行任何分析、修改、搜索任务时,首先阅读 `docs/architecture/004_architecture_impact_map.md` 或 `docs/architecture/005_architecture_data.json`,从图中定位目标模块、函数、依赖关系,再按图索骥读取源码
|
||||
2. **图未覆盖则先补图**:如果发现项目中存在架构图未记录的模块、函数、表、路由等,**必须优先完善架构图信息**,然后再继续后续工作
|
||||
3. **改码必同步图**:对源码的任何修改完成后,必须同步更新 004 和 005 两个架构文档
|
||||
1. **先图后码**:执行任何分析、修改、搜索任务时,首先运行 `npm run arch:scan` 更新 arch.db,再通过 `npm run arch:query` 查询目标模块、函数、依赖关系,结合阅读 `docs/architecture/004_architecture_impact_map.md` 定位架构设计意图,最后按图索骥读取源码
|
||||
2. **图未覆盖则先补图**:如果发现项目中存在 arch.db 未记录的模块、函数、表、路由等,**必须先运行 `npm run arch:scan` 重新扫描**,然后检查 004 是否需要补充
|
||||
3. **改码必同步图**:对源码的任何修改完成后,必须运行 `npm run arch:scan` 更新 arch.db;若架构设计意图有变化,同步更新 004
|
||||
|
||||
### 架构文档清单
|
||||
|
||||
| 文档 | 用途 |
|
||||
|------|------|
|
||||
| `docs/architecture/004_architecture_impact_map.md` | 人类可读的架构影响地图 |
|
||||
| `docs/architecture/005_architecture_data.json` | AI 友好格式的结构化数据 |
|
||||
| `docs/architecture/004_architecture_impact_map.md` | 架构设计意图唯一源(人类可读) |
|
||||
| `docs/architecture/006_k12_feature_checklist.md` | 标准功能模块清单 |
|
||||
| `docs/architecture/007_gap_audit_report.md` | 差距审计报告 |
|
||||
| `docs/architecture/audit/01_decoupling_roadmap.md` | 解耦路线图 |
|
||||
| `docs/architecture/008_module_role_mapping.md` | 模块角色映射 |
|
||||
| `docs/architecture/roadmap/` | 长远规划(tech-debt/decoupling/pending-features) |
|
||||
| `docs/architecture/audit/` | 架构审查报告与归档(含已废弃的 005 JSON、004 V1) |
|
||||
| `docs/troubleshooting/known-issues.md` | 已知问题速查(场景→技术映射 + 工作经验日志) |
|
||||
|
||||
> 注:005_architecture_data.json 已废弃归档至 `audit/archive/`,结构化数据查询统一通过 arch.db
|
||||
|
||||
### 需要同步图的场景
|
||||
|
||||
@@ -30,9 +34,23 @@
|
||||
|
||||
### 同步方式
|
||||
|
||||
- 修改 Markdown 文档中对应的模块章节
|
||||
- 修改 JSON 文档中对应的节点(`modules.*.exports`、`permissions`、`dependencyMatrix`、`routes`、`dbTables` 等)
|
||||
- 确保两个文档内容一致
|
||||
- 修改源码后运行 `npm run arch:scan` 更新 arch.db(强制)
|
||||
- 若架构设计意图变化,同步更新 `docs/architecture/004_architecture_impact_map.md`
|
||||
- 若发现新的"场景→技术"映射或工作经验,更新 `docs/troubleshooting/known-issues.md`
|
||||
|
||||
## 架构元数据库规则(arch.db)
|
||||
|
||||
**arch.db 是代码结构唯一源,AI 工作前必须运行 `npm run arch:scan` 更新。**
|
||||
|
||||
1. **arch.db 取代 005 JSON**:模块、函数、调用关系、依赖关系、技术标签查询 arch.db,不手动维护结构化数据文件
|
||||
2. **查询命令**:
|
||||
- `npm run arch:query -- sql "<SQL>"` 自定义 SQL 查询
|
||||
- `npm run arch:query -- module-deps` 查模块依赖
|
||||
- `npm run arch:query -- module-reverse-deps <module>` 查反向依赖
|
||||
- `npm run arch:query -- symbol-refs <symbol>` 查符号引用链
|
||||
- `npm run arch:query -- tech-usage <tag>` 查技术使用
|
||||
- `npm run arch:query -- violations` 查架构违规
|
||||
3. **arch.db 不替代 004**:arch.db 是"代码现状",004 是"设计意图",两者互补
|
||||
|
||||
## 编码规范
|
||||
|
||||
@@ -127,7 +145,7 @@ src/modules/[module]/
|
||||
- Layer 2 Semantic(`semantic-light.css` + `semantic-dark.css`):语义令牌,业务代码唯一引用入口
|
||||
- 模块命名空间(`lesson-preparation.css`):`--lp-*` 令牌,明暗双份
|
||||
- Tailwind 暴露(`tailwind-theme.css`):`@theme inline` 将 Semantic 令牌暴露为 `bg-*`/`text-*`/`font-*` 类
|
||||
- **改令牌必同步图**: 修改令牌定义后,同步更新 `docs/architecture/004` 与 `005`
|
||||
- **改令牌必同步图**: 修改令牌定义后,同步更新 `docs/architecture/004_architecture_impact_map.md` 与 arch.db(`npm run arch:scan`)
|
||||
- **ESLint 强制约束**:
|
||||
- `no-restricted-syntax`: 禁止 `#hex` 字面量
|
||||
- `design-tokens/no-hardcoded-fonts`: 禁止 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量(单词边界匹配,不影响 `Interval`/`Interactive` 等标识符)
|
||||
@@ -148,32 +166,61 @@ src/modules/[module]/
|
||||
|
||||
## 问题记录规则
|
||||
|
||||
**所有工作完成后,必须将遇到的问题记录到 `docs/troubleshooting/known-issues.md`(速查手册格式)。**
|
||||
**所有工作完成后,必须将遇到的问题记录到 `docs/troubleshooting/known-issues.md`(索引式速查手册)。**
|
||||
|
||||
### 必须记录的场景
|
||||
|
||||
| 场景 | 记录要求 |
|
||||
|------|---------|
|
||||
| 构建报错(dev/build/lint/tsc) | 记录错误现象 + 正确写法 |
|
||||
| 运行时异常(白屏/API 报错/数据加载失败) | 记录错误现象 + 正确写法 |
|
||||
| 框架/库版本兼容问题 | 记录错误现象 + 正确写法 |
|
||||
| 依赖配置问题(serverExternalPackages/webpackIgnore 等) | 记录错误现象 + 正确写法 |
|
||||
| 架构约束违规 | 记录错误现象 + 正确写法 |
|
||||
| 构建报错(dev/build/lint/tsc) | 记录到"全局经验"对应主题分区 |
|
||||
| 运行时异常(白屏/API 报错/数据加载失败) | 记录到"模块经验"对应模块分区 |
|
||||
| 框架/库版本兼容问题 | 记录到"全局经验: Next.js 配置与运行时" |
|
||||
| 依赖配置问题(serverExternalPackages/webpackIgnore 等) | 记录到"全局经验: Next.js 配置与运行时" |
|
||||
| 架构约束违规 | 记录到"全局经验"对应主题分区 |
|
||||
|
||||
### 记录格式
|
||||
|
||||
以**规则表**形式记录,指明正确做法,无需详细解释原因:
|
||||
索引式表格,指明"场景→技术/规则"映射,不写多行代码示例:
|
||||
|
||||
```markdown
|
||||
## 问题分类标题
|
||||
### X.X 主题分区
|
||||
|
||||
| 规则 | 正确写法 | 错误写法 |
|
||||
|------|---------|---------|
|
||||
| 简述规则 | 代码示例 | 代码示例 |
|
||||
| 场景 | 技术/规则 |
|
||||
|------|----------|
|
||||
| 简述场景 | 正确做法(一句话) |
|
||||
```
|
||||
|
||||
### 记录要求
|
||||
|
||||
- **速查手册风格**:只指明方向,不做新手指导
|
||||
- **索引式**:场景→技术/规则映射,不写代码示例和错误示范列
|
||||
- **去重**:同类问题在原条目补充,不重复创建
|
||||
- **可操作**:正确写法需具体到代码示例
|
||||
- **引用架构规则**:架构分层、模块结构等规则引用 004 和 project_rules,不重复
|
||||
- **工作经验日志**:在"工作经验日志"区按时间倒序追加(50 条上限),记录"做了什么/学到什么/下次注意"
|
||||
|
||||
## AI 工作强制流程
|
||||
|
||||
**所有 AI 工作必须遵循此流程,违反即违规。**
|
||||
|
||||
### 阶段 1: 上下文加载
|
||||
|
||||
1. `npm run arch:scan` 更新 arch.db
|
||||
2. `npm run arch:query -- module-deps` 查目标模块依赖
|
||||
3. `npm run arch:query -- symbol-refs <目标函数>` 查调用链
|
||||
4. 阅读 `src/modules/[模块]/README.md` 读模块工作流程
|
||||
5. 查 `docs/troubleshooting/known-issues.md` "模块经验" 分区读相关经验
|
||||
|
||||
### 阶段 2: 执行工作
|
||||
|
||||
1. 按规划执行
|
||||
2. 修改代码后立即运行 `npm run arch:scan` 更新 arch.db
|
||||
3. 运行 `npx tsc --noEmit` 和 `npm run lint` 确保零错误
|
||||
|
||||
### 阶段 3: 经验沉淀(强制,不可跳过)
|
||||
|
||||
1. 在 `docs/troubleshooting/known-issues.md` "工作经验日志" 区追加一条记录:
|
||||
- 日期 + 时间
|
||||
- 模块
|
||||
- 做了什么 + 学到什么
|
||||
2. 若发现新的"场景→技术"映射 → 提炼到对应模块分区
|
||||
3. 若发现新的架构决策 → 更新 004
|
||||
4. 若代码结构变化 → `npm run arch:scan` 确认 arch.db 已更新
|
||||
|
||||
@@ -69,7 +69,7 @@
|
||||
| 文档 | 归档原因 |
|
||||
|------|---------|
|
||||
| [002 RBAC 重构方案](architecture/002_rbac_refactoring.md) | 描述修复前的安全隐患,当前所有 Server Action 已接入 `requirePermission()` |
|
||||
| [002 角色路由 RFC](architecture/002_role_based_routing.md) | 2025-12-23 提案,当前角色域路由已全部实现 |
|
||||
| [002b 角色路由 RFC](architecture/002b_role_based_routing.md) | 2025-12-23 提案,当前角色域路由已全部实现 |
|
||||
| [003 UI 重构计划](architecture/003_ui_refactoring_plan.md) | 2026-06-16 重构计划,当前已执行完毕 |
|
||||
|
||||
### 设计历史文档
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
4901
docs/architecture/audit/archive/004_architecture_impact_map_v1.md
Normal file
4901
docs/architecture/audit/archive/004_architecture_impact_map_v1.md
Normal file
File diff suppressed because it is too large
Load Diff
10
docs/architecture/audit/archive/README.md
Normal file
10
docs/architecture/audit/archive/README.md
Normal file
@@ -0,0 +1,10 @@
|
||||
# 历史审查报告归档
|
||||
|
||||
> 本目录为只读归档,不再更新。
|
||||
> 有价值的内容已提取到模块 README 和 known-issues.md。
|
||||
|
||||
## 归档文件
|
||||
|
||||
- 005_architecture_data.json(已废弃,由 arch.db 替代)
|
||||
- 60+ 份模块审查报告(历史参考)
|
||||
- data-access-audit-v1 系列文件(数据访问层审查)
|
||||
1682
docs/architecture/audit/archive/known-issues_v1.md
Normal file
1682
docs/architecture/audit/archive/known-issues_v1.md
Normal file
File diff suppressed because it is too large
Load Diff
16
docs/architecture/roadmap/README.md
Normal file
16
docs/architecture/roadmap/README.md
Normal file
@@ -0,0 +1,16 @@
|
||||
# 路线图索引
|
||||
|
||||
> 本目录存放项目长远规划,与架构事实(004)分离。
|
||||
|
||||
## 文档清单
|
||||
|
||||
| 文档 | 用途 |
|
||||
|------|------|
|
||||
| [tech-debt.md](./tech-debt.md) | 技术债清单 |
|
||||
| [decoupling.md](./decoupling.md) | 解耦路线图 |
|
||||
| [pending-features.md](./pending-features.md) | 待开发功能 |
|
||||
|
||||
## 维护规则
|
||||
|
||||
- 规划实现后从本目录删除,迁入 004 架构事实或 git 历史
|
||||
- 不含架构事实,不含经验(经验查 known-issues.md)
|
||||
7
docs/architecture/roadmap/pending-features.md
Normal file
7
docs/architecture/roadmap/pending-features.md
Normal file
@@ -0,0 +1,7 @@
|
||||
# 待开发功能
|
||||
|
||||
> 从 004 各模块"未完成项"迁入。
|
||||
|
||||
## 待开发功能清单
|
||||
|
||||
(迁入后填充)
|
||||
11
docs/architecture/roadmap/tech-debt.md
Normal file
11
docs/architecture/roadmap/tech-debt.md
Normal file
@@ -0,0 +1,11 @@
|
||||
# 技术债清单
|
||||
|
||||
> 从 004 第三部分"已知架构问题和技术债"迁入。
|
||||
|
||||
## 待解决项
|
||||
|
||||
(从 004 迁入后填充)
|
||||
|
||||
## 已解决项
|
||||
|
||||
(已解决项删除,git 历史已记录)
|
||||
@@ -194,7 +194,11 @@ export function UserCard({ user, onSelect, children }: UserCardProps): JSX.Eleme
|
||||
|
||||
### 4.1 配置(tsconfig.json)
|
||||
|
||||
当前项目配置需升级以符合规范。**目标配置**:
|
||||
当前项目配置已基本符合规范(`target: ES2022`、`noImplicitReturns: true`、`noFallthroughCasesInSwitch: true`)。
|
||||
|
||||
**待办**:`noUncheckedIndexedAccess` 当前为 `false`(数组/对象索引不返回 `T | undefined`),逐步迁移后启用。具体配置见 `tsconfig.json`。
|
||||
|
||||
**目标配置**:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -221,12 +225,6 @@ export function UserCard({ user, onSelect, children }: UserCardProps): JSX.Eleme
|
||||
}
|
||||
```
|
||||
|
||||
**当前差异**(需逐步升级):
|
||||
- `target`: `ES2017` → `ES2022`
|
||||
- 缺失 `noUncheckedIndexedAccess`(数组/对象索引返回 `T | undefined`)
|
||||
- 缺失 `noImplicitReturns`(函数所有分支必须返回)
|
||||
- 缺失 `noFallthroughCasesInSwitch`
|
||||
|
||||
### 4.2 类型规则
|
||||
|
||||
1. **禁止 `any`**:未知类型用 `unknown` 并做类型守卫。若极特殊情况必须使用,需 `// eslint-disable-next-line @typescript-eslint/no-explicit-any` 并注释原因
|
||||
@@ -366,23 +364,22 @@ import { cn } from "@/shared/lib/utils";
|
||||
|
||||
### 6.3 设计令牌配置
|
||||
|
||||
本项目在 `src/app/globals.css` 中使用 CSS 变量定义设计令牌:
|
||||
本项目在 `src/app/styles/tokens/` 中使用 CSS 变量定义设计令牌(分层架构):
|
||||
|
||||
```css
|
||||
:root {
|
||||
--background: 0 0% 100%;
|
||||
--foreground: 240 10% 3.9%;
|
||||
--primary: 240 5.9% 10%;
|
||||
--primary-foreground: 0 0% 98%;
|
||||
--destructive: 0 84.2% 60.2%;
|
||||
--border: 240 5.9% 90%;
|
||||
--radius: 0.5rem;
|
||||
/* ... */
|
||||
}
|
||||
```
|
||||
- `primitive.css`: 原始色板/字号/间距/阴影(Layer 1,业务代码不直接引用)
|
||||
- `semantic-light.css` + `semantic-dark.css`: 语义令牌(Layer 2,业务代码唯一引用入口)
|
||||
- `lesson-preparation.css`: `--lp-*` 令牌(明暗双份,模块命名空间)
|
||||
- `tailwind-theme.css`: `@theme inline` 将 Semantic 令牌暴露为 Tailwind 类
|
||||
- `index.css`: 入口文件(汇总 @import)
|
||||
|
||||
**所有视觉设计决策**(颜色、字号、间距)必须体现在设计令牌中,组件中不使用硬编码值。
|
||||
|
||||
**ESLint 强制约束**:
|
||||
- `no-restricted-syntax`: 禁止 `#hex` 颜色字面量
|
||||
- `design-tokens/no-hardcoded-fonts`: 禁止 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量(单词边界匹配,不影响 `Interval`/`Interactive` 等标识符)
|
||||
- 白名单:`primitive.css`(令牌定义)、`email-channel.ts`(邮件 HTML)、`manifest.ts`(PWA)
|
||||
- 任意值豁免需注释:`// eslint-disable-next-line no-restricted-syntax -- <reason>`
|
||||
|
||||
---
|
||||
|
||||
## 七、数据获取与状态管理
|
||||
@@ -400,7 +397,7 @@ import { cn } from "@/shared/lib/utils";
|
||||
**规则**:
|
||||
- 服务端数据获取通过模块的 `data-access.ts` 函数
|
||||
- 客户端动态数据统一使用 **TanStack Query v5+**,**禁止在 `useEffect` 中手写 fetch**
|
||||
- 缓存策略:服务端请求必须显式设置 `next.revalidate` 或使用 `unstable_cache`,并注释缓存时长理由
|
||||
- 缓存策略:服务端请求使用 `cacheFn`(封装 React `cache()` + 自定义缓存层),详见 `shared/lib/cache/`;不使用 `unstable_cache`(权限数据易变,跨请求缓存风险高于收益)
|
||||
|
||||
### 7.2 Server Actions
|
||||
|
||||
@@ -470,18 +467,23 @@ export async function createExamAction(
|
||||
|
||||
### 7.3 状态管理
|
||||
|
||||
| 场景 | 方案 |
|
||||
|------|------|
|
||||
| 局部 UI 状态 | `useState` / `useReducer` |
|
||||
| 跨组件共享(小范围) | React Context |
|
||||
| 跨组件共享(大范围) | Zustand(轻量、可选择订阅) |
|
||||
| 全局状态 | 仅存放真正全局必要数据(认证信息、主题、通知列表) |
|
||||
| URL 状态 | `nuqs`(已集成) |
|
||||
本项目采用 **5 层状态模型**:
|
||||
|
||||
| 层级 | 场景 | 方案 |
|
||||
|------|------|------|
|
||||
| L1 URL | 可分享、可刷新的状态(分页、筛选、排序) | `nuqs` |
|
||||
| L2 Server | 服务端数据 | TanStack Query |
|
||||
| L3 Client Business | 客户端业务状态 | Zustand slice |
|
||||
| L4 Global UI | 全局 UI 状态(弹窗、主题) | Zustand ui-store + ModalRoot |
|
||||
| L5 Form | 表单状态 | react-hook-form + zodResolver |
|
||||
|
||||
**规则**:
|
||||
- Context 拆分:一个 Context 只负责一类数据,避免无关状态变化引发不必要的渲染
|
||||
- 业务数据一律通过路由参数或 TanStack Query 获取,**不存入全局状态**
|
||||
- Zustand 的 `persist` 中间件必须处理版本迁移和敏感数据加密
|
||||
- 优先使用细粒度 Zustand selectors(单字段 selector)而非 `useShallow` 多字段包装
|
||||
- 乐观更新使用 React 19 `useOptimistic` + `useTransition`,替代手动 `isPending` 状态
|
||||
- `useOptimistic` 的 `addOptimistic` 必须在 transition 或 action 内调用(form action 也算 action)
|
||||
|
||||
---
|
||||
|
||||
@@ -700,52 +702,19 @@ test(checkout): cover discount edge cases
|
||||
|
||||
## 十五、统一工具配置
|
||||
|
||||
### 15.1 ESLint(当前配置 + 建议增强)
|
||||
### 15.1 ESLint
|
||||
|
||||
**当前配置**(`eslint.config.mjs`):
|
||||
**当前配置**(`eslint.config.mjs`)已实现以下规则:
|
||||
|
||||
```javascript
|
||||
import { defineConfig, globalIgnores } from "eslint/config";
|
||||
import nextVitals from "eslint-config-next/core-web-vitals";
|
||||
import nextTs from "eslint-config-next/typescript";
|
||||
- `no-restricted-syntax`: 禁止 `#hex` 颜色字面量
|
||||
- `design-tokens/no-hardcoded-fonts`: 禁止 `'Inter'`/`'Fraunces'`/`'JetBrains Mono'` 字面量(单词边界匹配,不影响 `Interval`/`Interactive` 等标识符)
|
||||
- 白名单:`primitive.css`(令牌定义)、`email-channel.ts`(邮件 HTML)、`manifest.ts`(PWA)
|
||||
- `@typescript-eslint/no-explicit-any`: error
|
||||
- `react/react-in-jsx-scope`: off
|
||||
- `react/function-component-definition`: function-declaration
|
||||
- `import/order`: 强制分组排序
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
{
|
||||
rules: {
|
||||
"react-hooks/incompatible-library": "off",
|
||||
},
|
||||
},
|
||||
// ...
|
||||
]);
|
||||
|
||||
export default eslintConfig;
|
||||
```
|
||||
|
||||
**建议增强**(待逐步集成):
|
||||
|
||||
```javascript
|
||||
{
|
||||
extends: [
|
||||
"next/core-web-vitals",
|
||||
"plugin:@typescript-eslint/recommended",
|
||||
"plugin:react/recommended",
|
||||
"plugin:react-hooks/recommended",
|
||||
"plugin:jsx-a11y/recommended",
|
||||
"prettier"
|
||||
],
|
||||
rules: {
|
||||
"@typescript-eslint/no-explicit-any": "error",
|
||||
"react/react-in-jsx-scope": "off",
|
||||
"react/function-component-definition": [2, { "namedComponents": "function-declaration" }],
|
||||
"import/order": ["error", {
|
||||
"groups": ["builtin", "external", "internal", "parent", "sibling", "index", "type"],
|
||||
"newlines-between": "always"
|
||||
}]
|
||||
}
|
||||
}
|
||||
```
|
||||
(具体配置见 `eslint.config.mjs`)
|
||||
|
||||
### 15.2 Prettier
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
846
package-lock.json
generated
846
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -37,7 +37,10 @@
|
||||
"dr:drill": "bash scripts/dr-drill.sh",
|
||||
"dr:drill:ps1": "powershell -ExecutionPolicy Bypass -File scripts/dr-drill.ps1",
|
||||
"dr:health-check": "bash scripts/health-check.sh",
|
||||
"dr:failover": "bash scripts/failover.sh"
|
||||
"dr:failover": "bash scripts/failover.sh",
|
||||
"arch:scan": "tsx scripts/arch-scan/cli.ts scan",
|
||||
"arch:query": "tsx scripts/arch-scan/cli.ts query",
|
||||
"test:arch-scan": "vitest run --config vitest.arch-scan.config.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@alicloud/credentials": "^2.4.5",
|
||||
@@ -77,6 +80,7 @@
|
||||
"@tiptap/starter-kit": "^3.15.3",
|
||||
"@xyflow/react": "^12.11.0",
|
||||
"bcryptjs": "^2.4.3",
|
||||
"better-sqlite3": "^12.11.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"drizzle-orm": "^0.45.1",
|
||||
@@ -108,6 +112,7 @@
|
||||
"tailwindcss-animate": "^1.0.7",
|
||||
"tencentcloud-sdk-nodejs": "^4.1.254",
|
||||
"tiptap-markdown": "^0.9.0",
|
||||
"ts-morph": "^28.0.0",
|
||||
"zod": "^4.2.1",
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
@@ -121,6 +126,7 @@
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/bcryptjs": "^2.4.6",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
@@ -136,6 +142,7 @@
|
||||
"prettier": "^3.7.4",
|
||||
"prettier-plugin-tailwindcss": "^0.7.2",
|
||||
"tailwindcss": "^4",
|
||||
"tsx": "^4.23.0",
|
||||
"typescript": "^5",
|
||||
"vitest": "^4.1.0"
|
||||
}
|
||||
|
||||
3
scripts/arch-scan/.gitignore
vendored
Normal file
3
scripts/arch-scan/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
*.wal
|
||||
*.shm
|
||||
*.tmp
|
||||
BIN
scripts/arch-scan/arch.db
Normal file
BIN
scripts/arch-scan/arch.db
Normal file
Binary file not shown.
BIN
scripts/arch-scan/arch.db-shm
Normal file
BIN
scripts/arch-scan/arch.db-shm
Normal file
Binary file not shown.
0
scripts/arch-scan/arch.db-wal
Normal file
0
scripts/arch-scan/arch.db-wal
Normal file
24
scripts/arch-scan/cli.ts
Normal file
24
scripts/arch-scan/cli.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
import { runScan, runQuery } from "./index";
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const command = args[0];
|
||||
|
||||
if (command === "scan") {
|
||||
runScan();
|
||||
} else if (command === "query") {
|
||||
runQuery(args.slice(1));
|
||||
} else {
|
||||
console.log("Usage:");
|
||||
console.log(" npm run arch:scan");
|
||||
console.log(" npm run arch:query <command> [args]");
|
||||
console.log("");
|
||||
console.log("Commands:");
|
||||
console.log(" scan Scan codebase and update arch.db");
|
||||
console.log(" query ref <symbol> [--forward] Query symbol references");
|
||||
console.log(" query module <name> [--reverse] Query module dependencies");
|
||||
console.log(" query tech <tag> Query tech tag usage");
|
||||
console.log(" query violations Detect architecture violations");
|
||||
console.log(' query sql "<SQL>" Run free SQL');
|
||||
console.log(" query repl SQLite REPL info");
|
||||
process.exit(1);
|
||||
}
|
||||
187
scripts/arch-scan/index.ts
Normal file
187
scripts/arch-scan/index.ts
Normal file
@@ -0,0 +1,187 @@
|
||||
import Database from "better-sqlite3";
|
||||
import { execSync } from "node:child_process";
|
||||
import { initSchema, clearAllData } from "./schema";
|
||||
import {
|
||||
scanAllModules,
|
||||
scanFiles,
|
||||
scanSymbols,
|
||||
scanCalls,
|
||||
scanTechTags,
|
||||
scanFileImports,
|
||||
scanModuleDeps,
|
||||
} from "./scanner";
|
||||
import {
|
||||
querySymbolRefs,
|
||||
querySymbolForwardCalls,
|
||||
queryModuleDeps,
|
||||
queryModuleReverseDeps,
|
||||
queryTechUsage,
|
||||
queryViolations,
|
||||
} from "./query";
|
||||
|
||||
const DB_PATH = "scripts/arch-scan/arch.db";
|
||||
|
||||
function getCommitHash(): string {
|
||||
try {
|
||||
return execSync("git rev-parse --short HEAD")
|
||||
.toString()
|
||||
.trim();
|
||||
} catch {
|
||||
return "unknown";
|
||||
}
|
||||
}
|
||||
|
||||
export function runScan(): void {
|
||||
console.log("Starting architecture scan...");
|
||||
const db = new Database(DB_PATH);
|
||||
db.pragma("journal_mode = WAL");
|
||||
initSchema(db);
|
||||
clearAllData(db);
|
||||
console.log("Schema initialized.");
|
||||
scanAllModules(db, process.cwd());
|
||||
console.log("Modules scanned.");
|
||||
// scanFiles 扫描 DB 中所有模块,只需调用一次
|
||||
scanFiles(db, "src/modules");
|
||||
console.log("Files scanned.");
|
||||
scanSymbols(db);
|
||||
console.log("Symbols scanned.");
|
||||
scanCalls(db);
|
||||
console.log("Calls scanned.");
|
||||
scanTechTags(db);
|
||||
console.log("Tech tags scanned.");
|
||||
scanFileImports(db);
|
||||
scanModuleDeps(db);
|
||||
console.log("Module dependencies scanned.");
|
||||
const now = new Date().toISOString();
|
||||
const commit = getCommitHash();
|
||||
const meta = db.prepare(
|
||||
"INSERT OR REPLACE INTO scan_meta (key, value) VALUES (?, ?)"
|
||||
);
|
||||
meta.run("scanned_at", now);
|
||||
meta.run("commit_hash", commit);
|
||||
meta.run("scanner_version", "1.0.0");
|
||||
const stats = {
|
||||
files: (db.prepare("SELECT count(*) as c FROM files").get() as { c: number }).c,
|
||||
symbols: (db.prepare("SELECT count(*) as c FROM symbols").get() as { c: number }).c,
|
||||
calls: (db.prepare("SELECT count(*) as c FROM calls").get() as { c: number }).c,
|
||||
};
|
||||
meta.run("total_files", String(stats.files));
|
||||
meta.run("total_symbols", String(stats.symbols));
|
||||
meta.run("total_calls", String(stats.calls));
|
||||
console.log(
|
||||
`Scan complete: ${stats.files} files, ${stats.symbols} symbols, ${stats.calls} calls`
|
||||
);
|
||||
console.log(`Commit: ${commit}, Time: ${now}`);
|
||||
db.close();
|
||||
}
|
||||
|
||||
export function runQuery(args: string[]): void {
|
||||
const db = new Database(DB_PATH, { readonly: true });
|
||||
const command = args[0];
|
||||
switch (command) {
|
||||
case "ref": {
|
||||
const symbol = args[1];
|
||||
if (!symbol) {
|
||||
console.error("Usage: arch:query ref <symbolName> [--forward] [--depth=N]");
|
||||
process.exit(1);
|
||||
}
|
||||
const forward = args.includes("--forward");
|
||||
const depthArg = args.find((a) => a.startsWith("--depth="));
|
||||
const depth = depthArg ? parseInt(depthArg.split("=")[1], 10) : 10;
|
||||
if (forward) {
|
||||
const calls = querySymbolForwardCalls(db, symbol, depth);
|
||||
console.log(`▼ ${symbol}`);
|
||||
for (const call of calls) {
|
||||
const indent = " ".repeat(call.depth + 1);
|
||||
console.log(
|
||||
`${indent}└─▶ ${call.callee_name} (${call.callee_path}#L${call.callee_line})`
|
||||
);
|
||||
}
|
||||
} else {
|
||||
const refs = querySymbolRefs(db, symbol, depth);
|
||||
console.log(`▼ ${symbol}`);
|
||||
for (const ref of refs) {
|
||||
const indent = " ".repeat(ref.depth + 1);
|
||||
console.log(
|
||||
`${indent}├─▼ ${ref.caller_name} (${ref.caller_path}#L${ref.caller_line})${ref.is_server_action ? " [Server Action]" : ""}`
|
||||
);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "module": {
|
||||
const moduleName = args[1];
|
||||
if (!moduleName) {
|
||||
console.error("Usage: arch:query module <moduleName> [--reverse] [--depth=N]");
|
||||
process.exit(1);
|
||||
}
|
||||
const reverse = args.includes("--reverse");
|
||||
if (reverse) {
|
||||
const deps = queryModuleReverseDeps(db, moduleName);
|
||||
console.log(`▼ ${moduleName} (reverse deps)`);
|
||||
for (const dep of deps) {
|
||||
console.log(` └─◀ ${dep.source_module} [${dep.dep_type}]`);
|
||||
}
|
||||
} else {
|
||||
const deps = queryModuleDeps(db, moduleName);
|
||||
console.log(`▼ ${moduleName} (forward deps)`);
|
||||
for (const dep of deps) {
|
||||
console.log(` └─▶ ${dep.target_module} [${dep.dep_type}]`);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "tech": {
|
||||
const tag = args[1];
|
||||
if (!tag) {
|
||||
console.error("Usage: arch:query tech <tagName>");
|
||||
process.exit(1);
|
||||
}
|
||||
const usage = queryTechUsage(db, tag);
|
||||
console.log(`▼ tech: ${tag} (${usage.length} usages)`);
|
||||
for (const u of usage) {
|
||||
console.log(` └─ ${u.symbol_name} (${u.file_path})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "violations": {
|
||||
const v = queryViolations(db);
|
||||
console.log("▼ Architecture Violations");
|
||||
console.log(` Long files (>800 lines): ${v.long_files.length}`);
|
||||
for (const f of v.long_files) {
|
||||
console.log(` └─ ${f.path}: ${f.lines} lines`);
|
||||
}
|
||||
console.log(
|
||||
` Server Actions without requirePermission: ${v.server_actions_without_permission.length}`
|
||||
);
|
||||
for (const sa of v.server_actions_without_permission) {
|
||||
console.log(` └─ ${sa.name} (${sa.path})`);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "sql": {
|
||||
const sql = args.slice(1).join(" ");
|
||||
if (!sql) {
|
||||
console.error('Usage: arch:query sql "<SQL>"');
|
||||
process.exit(1);
|
||||
}
|
||||
const rows = db.prepare(sql).all();
|
||||
console.log(JSON.stringify(rows, null, 2));
|
||||
break;
|
||||
}
|
||||
case "repl": {
|
||||
console.log("Entering SQLite REPL. Type .exit to quit.");
|
||||
console.log(`DB: ${DB_PATH}`);
|
||||
console.log(
|
||||
'Tip: use "npm run arch:query -- sql \\"<SQL>\\"" for quick queries instead.'
|
||||
);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
console.error(
|
||||
"Unknown command. Available: ref, module, tech, violations, sql, repl"
|
||||
);
|
||||
process.exit(1);
|
||||
}
|
||||
db.close();
|
||||
}
|
||||
106
scripts/arch-scan/query.test.ts
Normal file
106
scripts/arch-scan/query.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { initSchema } from "./schema";
|
||||
import {
|
||||
querySymbolRefs,
|
||||
querySymbolForwardCalls,
|
||||
queryModuleDeps,
|
||||
queryModuleReverseDeps,
|
||||
queryViolations,
|
||||
} from "./query";
|
||||
|
||||
function setupTestDb(): Database.Database {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
// 插入测试数据
|
||||
db.prepare(
|
||||
"INSERT INTO modules (name, path, layer, created_at) VALUES (?, ?, ?, ?)"
|
||||
).run("exams", "src/modules/exams", "modules", "2026-07-07");
|
||||
db.prepare(
|
||||
"INSERT INTO modules (name, path, layer, created_at) VALUES (?, ?, ?, ?)"
|
||||
).run("grades", "src/modules/grades", "modules", "2026-07-07");
|
||||
db.prepare(
|
||||
"INSERT INTO files (module_id, path, kind, lines) VALUES (?, ?, ?, ?)"
|
||||
).run(1, "src/modules/exams/actions.ts", "actions", 100);
|
||||
db.prepare(
|
||||
"INSERT INTO files (module_id, path, kind, lines) VALUES (?, ?, ?, ?)"
|
||||
).run(1, "src/modules/exams/data-access.ts", "data-access", 200);
|
||||
db.prepare(
|
||||
"INSERT INTO symbols (file_id, name, kind, is_exported, is_async, is_server_action, start_line, end_line) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(1, "createExamAction", "function", 1, 1, 1, 10, 50);
|
||||
db.prepare(
|
||||
"INSERT INTO symbols (file_id, name, kind, is_exported, is_async, is_server_action, start_line, end_line) VALUES (?, ?, ?, ?, ?, ?, ?, ?)"
|
||||
).run(2, "createExam", "function", 1, 1, 0, 20, 80);
|
||||
db.prepare(
|
||||
"INSERT INTO calls (caller_id, callee_id, call_line, count) VALUES (?, ?, ?, ?)"
|
||||
).run(1, 2, 30, 1); // createExamAction 调用 createExam
|
||||
db.prepare(
|
||||
"INSERT INTO module_deps (source_module_id, target_module_id, dep_type) VALUES (?, ?, ?)"
|
||||
).run(1, 2, "data-access-call"); // exams 依赖 grades
|
||||
return db;
|
||||
}
|
||||
|
||||
describe("querySymbolRefs", () => {
|
||||
it("should find callers of a symbol (reverse)", () => {
|
||||
const db = setupTestDb();
|
||||
const refs = querySymbolRefs(db, "createExam");
|
||||
expect(refs).toHaveLength(1);
|
||||
expect(refs[0].caller_name).toBe("createExamAction");
|
||||
});
|
||||
});
|
||||
|
||||
describe("querySymbolForwardCalls", () => {
|
||||
it("should find callees of a symbol (forward)", () => {
|
||||
const db = setupTestDb();
|
||||
const calls = querySymbolForwardCalls(db, "createExamAction");
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].callee_name).toBe("createExam");
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryModuleDeps", () => {
|
||||
it("should find module dependencies (forward)", () => {
|
||||
const db = setupTestDb();
|
||||
const deps = queryModuleDeps(db, "exams");
|
||||
expect(deps).toContainEqual({
|
||||
target_module: "grades",
|
||||
dep_type: "data-access-call",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryModuleReverseDeps", () => {
|
||||
it("should find module reverse dependencies", () => {
|
||||
const db = setupTestDb();
|
||||
const deps = queryModuleReverseDeps(db, "grades");
|
||||
expect(deps).toContainEqual({
|
||||
source_module: "exams",
|
||||
dep_type: "data-access-call",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("queryViolations", () => {
|
||||
it("should detect files over 800 lines", () => {
|
||||
const db = setupTestDb();
|
||||
// 插入一个超长文件
|
||||
db.prepare(
|
||||
"INSERT INTO files (module_id, path, kind, lines) VALUES (?, ?, ?, ?)"
|
||||
).run(1, "src/modules/exams/huge.ts", "lib", 900);
|
||||
const violations = queryViolations(db);
|
||||
expect(violations.long_files).toContainEqual({
|
||||
path: "src/modules/exams/huge.ts",
|
||||
lines: 900,
|
||||
});
|
||||
});
|
||||
|
||||
it("should detect Server Actions without requirePermission", () => {
|
||||
const db = setupTestDb();
|
||||
const violations = queryViolations(db);
|
||||
// createExamAction 是 Server Action 但没有调用 requirePermission
|
||||
const missing = violations.server_actions_without_permission.find(
|
||||
(v) => v.name === "createExamAction"
|
||||
);
|
||||
expect(missing).toBeDefined();
|
||||
});
|
||||
});
|
||||
155
scripts/arch-scan/query.ts
Normal file
155
scripts/arch-scan/query.ts
Normal file
@@ -0,0 +1,155 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
export interface SymbolRef {
|
||||
caller_name: string;
|
||||
caller_path: string;
|
||||
caller_line: number;
|
||||
is_server_action: boolean;
|
||||
depth: number;
|
||||
path_chain: string;
|
||||
}
|
||||
|
||||
export function querySymbolRefs(
|
||||
db: Database.Database,
|
||||
symbolName: string,
|
||||
maxDepth = 10
|
||||
): SymbolRef[] {
|
||||
const sql = `
|
||||
WITH RECURSIVE upstream(caller_id, caller_name, caller_path, caller_line, is_sa, depth, path_chain) AS (
|
||||
SELECT c.caller_id, s.name, f.path, c.call_line, s.is_server_action, 0, s.name
|
||||
FROM calls c
|
||||
JOIN symbols s ON c.caller_id = s.id
|
||||
JOIN files f ON s.file_id = f.id
|
||||
WHERE c.callee_id = (SELECT id FROM symbols WHERE name = ? LIMIT 1)
|
||||
UNION
|
||||
SELECT c.caller_id, s.name, f.path, c.call_line, s.is_server_action, u.depth + 1,
|
||||
u.path_chain || ' → ' || s.name
|
||||
FROM upstream u
|
||||
JOIN calls c ON c.callee_id = u.caller_id
|
||||
JOIN symbols s ON c.caller_id = s.id
|
||||
JOIN files f ON s.file_id = f.id
|
||||
WHERE u.depth < ?
|
||||
)
|
||||
SELECT caller_name, caller_path, caller_line, is_sa as is_server_action, depth, path_chain
|
||||
FROM upstream ORDER BY depth, caller_path`;
|
||||
return db.prepare(sql).all(symbolName, maxDepth) as SymbolRef[];
|
||||
}
|
||||
|
||||
export interface SymbolCall {
|
||||
callee_name: string;
|
||||
callee_path: string;
|
||||
callee_line: number;
|
||||
depth: number;
|
||||
path_chain: string;
|
||||
}
|
||||
|
||||
export function querySymbolForwardCalls(
|
||||
db: Database.Database,
|
||||
symbolName: string,
|
||||
maxDepth = 10
|
||||
): SymbolCall[] {
|
||||
const sql = `
|
||||
WITH RECURSIVE downstream(callee_id, callee_name, callee_path, callee_line, depth, path_chain) AS (
|
||||
SELECT c.callee_id, s.name, f.path, c.call_line, 0, ?
|
||||
FROM calls c
|
||||
JOIN symbols s ON c.callee_id = s.id
|
||||
JOIN files f ON s.file_id = f.id
|
||||
WHERE c.caller_id = (SELECT id FROM symbols WHERE name = ? LIMIT 1)
|
||||
UNION
|
||||
SELECT c.callee_id, s.name, f.path, c.call_line, d.depth + 1,
|
||||
d.path_chain || ' → ' || s.name
|
||||
FROM downstream d
|
||||
JOIN calls c ON c.caller_id = d.callee_id
|
||||
JOIN symbols s ON c.callee_id = s.id
|
||||
JOIN files f ON s.file_id = f.id
|
||||
WHERE d.depth < ?
|
||||
)
|
||||
SELECT callee_name, callee_path, callee_line, depth, path_chain
|
||||
FROM downstream ORDER BY depth, callee_path`;
|
||||
return db.prepare(sql).all(symbolName, symbolName, maxDepth) as SymbolCall[];
|
||||
}
|
||||
|
||||
export interface ModuleDep {
|
||||
target_module: string;
|
||||
dep_type: string;
|
||||
}
|
||||
|
||||
export function queryModuleDeps(
|
||||
db: Database.Database,
|
||||
moduleName: string,
|
||||
_maxDepth = 10
|
||||
): ModuleDep[] {
|
||||
const sql = `
|
||||
SELECT DISTINCT m2.name AS target_module, md.dep_type
|
||||
FROM module_deps md
|
||||
JOIN modules m ON md.source_module_id = m.id
|
||||
JOIN modules m2 ON md.target_module_id = m2.id
|
||||
WHERE m.name = ?`;
|
||||
return db.prepare(sql).all(moduleName) as ModuleDep[];
|
||||
}
|
||||
|
||||
export interface ModuleReverseDep {
|
||||
source_module: string;
|
||||
dep_type: string;
|
||||
}
|
||||
|
||||
export function queryModuleReverseDeps(
|
||||
db: Database.Database,
|
||||
moduleName: string,
|
||||
_maxDepth = 10
|
||||
): ModuleReverseDep[] {
|
||||
const sql = `
|
||||
SELECT DISTINCT m.name AS source_module, md.dep_type
|
||||
FROM module_deps md
|
||||
JOIN modules m ON md.source_module_id = m.id
|
||||
JOIN modules m2 ON md.target_module_id = m2.id
|
||||
WHERE m2.name = ?`;
|
||||
return db.prepare(sql).all(moduleName) as ModuleReverseDep[];
|
||||
}
|
||||
|
||||
export interface TechUsage {
|
||||
symbol_name: string;
|
||||
file_path: string;
|
||||
tag_name: string;
|
||||
}
|
||||
|
||||
export function queryTechUsage(
|
||||
db: Database.Database,
|
||||
tagName: string
|
||||
): TechUsage[] {
|
||||
const sql = `
|
||||
SELECT s.name AS symbol_name, f.path AS file_path, t.name AS tag_name
|
||||
FROM symbols s
|
||||
JOIN symbol_tech_tags stt ON s.id = stt.symbol_id
|
||||
JOIN tech_tags t ON stt.tag_id = t.id
|
||||
JOIN files f ON s.file_id = f.id
|
||||
WHERE t.name = ?`;
|
||||
return db.prepare(sql).all(tagName) as TechUsage[];
|
||||
}
|
||||
|
||||
export interface Violations {
|
||||
long_files: { path: string; lines: number }[];
|
||||
server_actions_without_permission: { name: string; path: string }[];
|
||||
}
|
||||
|
||||
export function queryViolations(db: Database.Database): Violations {
|
||||
const longFiles = db
|
||||
.prepare("SELECT path, lines FROM files WHERE lines > 800 ORDER BY lines DESC")
|
||||
.all() as { path: string; lines: number }[];
|
||||
const serverActionsWithoutPerm = db
|
||||
.prepare(
|
||||
`SELECT s.name, f.path FROM symbols s
|
||||
JOIN files f ON s.file_id = f.id
|
||||
WHERE s.is_server_action = 1
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM calls c
|
||||
JOIN symbols cs ON c.callee_id = cs.id
|
||||
WHERE c.caller_id = s.id AND cs.name = 'requirePermission'
|
||||
)`
|
||||
)
|
||||
.all() as { name: string; path: string }[];
|
||||
return {
|
||||
long_files: longFiles,
|
||||
server_actions_without_permission: serverActionsWithoutPerm,
|
||||
};
|
||||
}
|
||||
226
scripts/arch-scan/scanner.test.ts
Normal file
226
scripts/arch-scan/scanner.test.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { initSchema } from "./schema";
|
||||
import { scanModules, scanFiles, scanSymbols, scanCalls, scanTechTags, scanModuleDeps, scanFileImports } from "./scanner";
|
||||
|
||||
describe("scanModules", () => {
|
||||
it("should detect src/modules/* as modules", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
const modules = db
|
||||
.prepare("SELECT name FROM modules ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
const names = modules.map((m) => m.name);
|
||||
expect(names).toContain("exams");
|
||||
expect(names).toContain("grades");
|
||||
expect(names).toContain("classes");
|
||||
});
|
||||
|
||||
it("should detect shared/ as a module with layer=shared", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/shared");
|
||||
const shared = db
|
||||
.prepare("SELECT * FROM modules WHERE name = 'shared'")
|
||||
.get() as { layer: string };
|
||||
expect(shared.layer).toBe("shared");
|
||||
});
|
||||
|
||||
it("should set layer=modules for src/modules/*", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
const exams = db
|
||||
.prepare("SELECT * FROM modules WHERE name = 'exams'")
|
||||
.get() as { layer: string };
|
||||
expect(exams.layer).toBe("modules");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanFiles", () => {
|
||||
it("should scan .ts and .tsx files in modules", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
const files = db
|
||||
.prepare("SELECT path, kind FROM files WHERE path LIKE '%exams%' LIMIT 5")
|
||||
.all() as { path: string; kind: string }[];
|
||||
expect(files.length).toBeGreaterThan(0);
|
||||
const actionFile = files.find((f) => f.kind === "actions");
|
||||
expect(actionFile).toBeDefined();
|
||||
});
|
||||
|
||||
it("should detect file kind from filename", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
const actionsFile = db
|
||||
.prepare("SELECT kind FROM files WHERE path LIKE '%exams/actions.ts'")
|
||||
.get() as { kind: string };
|
||||
expect(actionsFile.kind).toBe("actions");
|
||||
const dataAccessFile = db
|
||||
.prepare("SELECT kind FROM files WHERE path LIKE '%exams/data-access.ts'")
|
||||
.get() as { kind: string };
|
||||
expect(dataAccessFile.kind).toBe("data-access");
|
||||
});
|
||||
|
||||
it("should count lines correctly", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
const file = db
|
||||
.prepare("SELECT lines FROM files WHERE path LIKE '%exams/actions.ts'")
|
||||
.get() as { lines: number };
|
||||
expect(file.lines).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should detect use server and use client directives", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
const serverFile = db
|
||||
.prepare("SELECT is_server FROM files WHERE path LIKE '%exams/actions.ts'")
|
||||
.get() as { is_server: number };
|
||||
expect(serverFile.is_server).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanSymbols", () => {
|
||||
it("should extract exported functions from actions.ts", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
const symbols = db
|
||||
.prepare(
|
||||
"SELECT name, is_exported, is_async FROM symbols WHERE file_id IN (SELECT id FROM files WHERE path LIKE '%exams/actions.ts') LIMIT 5"
|
||||
)
|
||||
.all() as { name: string; is_exported: number; is_async: number }[];
|
||||
expect(symbols.length).toBeGreaterThan(0);
|
||||
const exported = symbols.find((s) => s.is_exported === 1);
|
||||
expect(exported).toBeDefined();
|
||||
});
|
||||
|
||||
it("should detect Server Action by 'use server' directive", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
const serverActions = db
|
||||
.prepare("SELECT name FROM symbols WHERE is_server_action = 1 LIMIT 5")
|
||||
.all() as { name: string }[];
|
||||
expect(serverActions.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should extract function signatures", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
const withSignature = db
|
||||
.prepare("SELECT signature FROM symbols WHERE signature IS NOT NULL AND signature != '' LIMIT 1")
|
||||
.get() as { signature: string };
|
||||
expect(withSignature).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanCalls", () => {
|
||||
it("should detect calls between symbols", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
scanCalls(db);
|
||||
const calls = db
|
||||
.prepare("SELECT count(*) as count FROM calls")
|
||||
.get() as { count: number };
|
||||
expect(calls.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should record external calls (callee_external)", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
scanCalls(db);
|
||||
const externalCalls = db
|
||||
.prepare("SELECT callee_external FROM calls WHERE callee_external IS NOT NULL LIMIT 5")
|
||||
.all() as { callee_external: string }[];
|
||||
expect(externalCalls.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanTechTags", () => {
|
||||
it("should detect cacheFn usage", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
scanTechTags(db);
|
||||
const cacheFnFiles = db
|
||||
.prepare(
|
||||
`SELECT s.name FROM symbols s
|
||||
JOIN symbol_tech_tags stt ON s.id = stt.symbol_id
|
||||
JOIN tech_tags t ON stt.tag_id = t.id
|
||||
WHERE t.name = 'cacheFn' LIMIT 5`
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
expect(cacheFnFiles.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should create tech_tags entries", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
scanTechTags(db);
|
||||
const tags = db
|
||||
.prepare("SELECT name FROM tech_tags ORDER BY name")
|
||||
.all() as { name: string }[];
|
||||
const tagNames = tags.map((t) => t.name);
|
||||
expect(tagNames).toContain("cacheFn");
|
||||
expect(tagNames).toContain("zustand");
|
||||
expect(tagNames).toContain("Drizzle");
|
||||
});
|
||||
});
|
||||
|
||||
describe("scanModuleDeps", () => {
|
||||
it("should detect module dependencies from imports", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanFileImports(db);
|
||||
scanModuleDeps(db);
|
||||
const deps = db
|
||||
.prepare("SELECT count(*) as count FROM module_deps")
|
||||
.get() as { count: number };
|
||||
expect(deps.count).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("should detect data-access-call dependency type", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
scanModules(db, "src/modules");
|
||||
scanFiles(db, "src/modules");
|
||||
scanSymbols(db);
|
||||
scanCalls(db);
|
||||
scanModuleDeps(db);
|
||||
const dataAccessDeps = db
|
||||
.prepare("SELECT count(*) as count FROM module_deps WHERE dep_type = 'data-access-call'")
|
||||
.get() as { count: number };
|
||||
expect(dataAccessDeps.count).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
552
scripts/arch-scan/scanner.ts
Normal file
552
scripts/arch-scan/scanner.ts
Normal file
@@ -0,0 +1,552 @@
|
||||
import type Database from "better-sqlite3";
|
||||
import { readdirSync, statSync, existsSync, readFileSync } from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import {
|
||||
Project,
|
||||
SyntaxKind,
|
||||
Node,
|
||||
type ModifierableNode,
|
||||
} from "ts-morph";
|
||||
|
||||
function detectLayer(basePath: string): string {
|
||||
const normalized = basePath.replace(/\\/g, "/");
|
||||
if (normalized.endsWith("src/modules")) return "modules";
|
||||
if (normalized.endsWith("src/shared")) return "shared";
|
||||
if (normalized.endsWith("src/app")) return "app";
|
||||
return "root";
|
||||
}
|
||||
|
||||
function extractDescription(modulePath: string): string | null {
|
||||
const readmePath = join(modulePath, "README.md");
|
||||
if (!existsSync(readmePath)) return null;
|
||||
try {
|
||||
const content = readFileSync(readmePath, "utf-8");
|
||||
const lines = content
|
||||
.split("\n")
|
||||
.filter((l: string) => l.trim() && !l.startsWith("#"));
|
||||
return lines[0]?.slice(0, 200) || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function scanModules(db: Database.Database, basePath: string): void {
|
||||
if (!existsSync(basePath)) return;
|
||||
const layer = detectLayer(basePath);
|
||||
const now = new Date().toISOString();
|
||||
const insert = db.prepare(
|
||||
"INSERT OR IGNORE INTO modules (name, path, description, layer, created_at) VALUES (?, ?, ?, ?, ?)"
|
||||
);
|
||||
|
||||
if (layer === "shared") {
|
||||
// src/shared 是单个模块,其子目录是内部结构
|
||||
const description = extractDescription(basePath);
|
||||
insert.run("shared", basePath, description, layer, now);
|
||||
return;
|
||||
}
|
||||
|
||||
// src/modules 和 src/app:每个子目录是一个模块
|
||||
const entries = readdirSync(basePath);
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(basePath, entry);
|
||||
if (!statSync(fullPath).isDirectory()) continue;
|
||||
if (entry.startsWith(".") || entry.startsWith("_")) continue;
|
||||
const description = extractDescription(fullPath);
|
||||
insert.run(entry, fullPath, description, layer, now);
|
||||
}
|
||||
}
|
||||
|
||||
export function scanAllModules(
|
||||
db: Database.Database,
|
||||
projectRoot: string
|
||||
): void {
|
||||
scanModules(db, join(projectRoot, "src/modules"));
|
||||
scanModules(db, join(projectRoot, "src/shared"));
|
||||
}
|
||||
|
||||
const TS_EXTENSIONS = [".ts", ".tsx"];
|
||||
|
||||
function detectFileKind(filePath: string): string {
|
||||
const normalized = filePath.replace(/\\/g, "/");
|
||||
const filename = normalized.split("/").pop() || "";
|
||||
if (filename === "actions.ts" || filename.match(/^actions-\w+\.ts$/)) return "actions";
|
||||
if (filename === "data-access.ts" || filename.match(/^data-access-\w+\.ts$/)) return "data-access";
|
||||
if (filename === "schema.ts") return "schema";
|
||||
if (filename === "types.ts") return "types";
|
||||
if (filename.endsWith(".tsx")) return "component";
|
||||
if (filename.startsWith("use-") && filename.endsWith(".ts")) return "hook";
|
||||
if (filename === "page.tsx") return "page";
|
||||
if (filename === "layout.tsx") return "layout";
|
||||
if (filename === "route.ts") return "route";
|
||||
if (filename === "error.tsx") return "error";
|
||||
if (filename === "loading.tsx") return "loading";
|
||||
if (filename === "proxy.ts") return "config";
|
||||
if (filename === "next.config.ts") return "config";
|
||||
return "lib";
|
||||
}
|
||||
|
||||
function countLines(content: string): number {
|
||||
return content.split("\n").length;
|
||||
}
|
||||
|
||||
function detectDirectives(content: string): {
|
||||
isServer: boolean;
|
||||
isClient: boolean;
|
||||
hasServerOnly: boolean;
|
||||
} {
|
||||
const firstLine = content.split("\n")[0] || "";
|
||||
return {
|
||||
isServer: firstLine.includes('"use server"') || firstLine.includes("'use server'"),
|
||||
isClient: firstLine.includes('"use client"') || firstLine.includes("'use client'"),
|
||||
hasServerOnly:
|
||||
content.includes('import "server-only"') || content.includes("import 'server-only'"),
|
||||
};
|
||||
}
|
||||
|
||||
function scanFilesRecursive(
|
||||
db: Database.Database,
|
||||
dirPath: string,
|
||||
moduleName: string
|
||||
): void {
|
||||
const entries = readdirSync(dirPath);
|
||||
for (const entry of entries) {
|
||||
const fullPath = join(dirPath, entry);
|
||||
const stat = statSync(fullPath);
|
||||
if (stat.isDirectory()) {
|
||||
if (entry.startsWith(".") || entry === "node_modules") continue;
|
||||
scanFilesRecursive(db, fullPath, moduleName);
|
||||
continue;
|
||||
}
|
||||
if (!TS_EXTENSIONS.some((ext) => entry.endsWith(ext))) continue;
|
||||
const content = readFileSync(fullPath, "utf-8");
|
||||
const directives = detectDirectives(content);
|
||||
const kind = detectFileKind(fullPath);
|
||||
const lines = countLines(content);
|
||||
const moduleId = (
|
||||
db.prepare("SELECT id FROM modules WHERE name = ?").get(moduleName) as {
|
||||
id: number;
|
||||
}
|
||||
).id;
|
||||
db.prepare(
|
||||
`INSERT OR REPLACE INTO files (module_id, path, kind, lines, is_server, is_client, has_server_only)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`
|
||||
).run(
|
||||
moduleId,
|
||||
fullPath.replace(/\\/g, "/"),
|
||||
kind,
|
||||
lines,
|
||||
directives.isServer ? 1 : 0,
|
||||
directives.isClient ? 1 : 0,
|
||||
directives.hasServerOnly ? 1 : 0
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function scanFiles(db: Database.Database, basePath: string): void {
|
||||
if (!existsSync(basePath)) return;
|
||||
// 扫描 DB 中所有模块的文件(scanModules 已先行填充 modules 表)
|
||||
const modules = db
|
||||
.prepare("SELECT name, path FROM modules")
|
||||
.all() as { name: string; path: string }[];
|
||||
for (const mod of modules) {
|
||||
if (!existsSync(mod.path)) continue;
|
||||
scanFilesRecursive(db, mod.path, mod.name);
|
||||
}
|
||||
}
|
||||
|
||||
function hasModifier(
|
||||
node: ModifierableNode | undefined,
|
||||
kind: SyntaxKind
|
||||
): boolean {
|
||||
if (!node) return false;
|
||||
try {
|
||||
return node.getModifiers().some((m) => m.getKind() === kind);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function extractSignature(node: Node): string {
|
||||
try {
|
||||
const text = node.getText();
|
||||
const firstLine = text.split("\n")[0];
|
||||
return firstLine.slice(0, 200);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
export function scanSymbols(db: Database.Database): void {
|
||||
const project = new Project({
|
||||
tsConfigFilePath: "./tsconfig.json",
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
});
|
||||
const files = db
|
||||
.prepare("SELECT id, path FROM files")
|
||||
.all() as { id: number; path: string }[];
|
||||
const insertSymbol = db.prepare(
|
||||
`INSERT OR IGNORE INTO symbols (file_id, name, kind, is_exported, is_async, is_server_action, signature, start_line, end_line)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
);
|
||||
for (const file of files) {
|
||||
let sourceFile;
|
||||
try {
|
||||
sourceFile = project.addSourceFileAtPath(file.path);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const fileContent = sourceFile.getFullText();
|
||||
const isServerFile =
|
||||
fileContent.startsWith('"use server"') ||
|
||||
fileContent.startsWith("'use server'");
|
||||
|
||||
sourceFile.forEachChild((node) => {
|
||||
// 处理函数/类/接口/类型别名声明
|
||||
if (
|
||||
Node.isFunctionDeclaration(node) ||
|
||||
Node.isClassDeclaration(node) ||
|
||||
Node.isInterfaceDeclaration(node) ||
|
||||
Node.isTypeAliasDeclaration(node)
|
||||
) {
|
||||
const name = node.getName();
|
||||
if (!name) return;
|
||||
const kind = Node.isFunctionDeclaration(node)
|
||||
? "function"
|
||||
: Node.isClassDeclaration(node)
|
||||
? "class"
|
||||
: Node.isInterfaceDeclaration(node)
|
||||
? "interface"
|
||||
: "type";
|
||||
const exported = hasModifier(node, SyntaxKind.ExportKeyword);
|
||||
const async = hasModifier(node, SyntaxKind.AsyncKeyword);
|
||||
const signature = extractSignature(node);
|
||||
const startLine = node.getStartLineNumber();
|
||||
const endLine = node.getEndLineNumber();
|
||||
const isServerAction = isServerFile && exported && async;
|
||||
insertSymbol.run(
|
||||
file.id,
|
||||
name,
|
||||
kind,
|
||||
exported ? 1 : 0,
|
||||
async ? 1 : 0,
|
||||
isServerAction ? 1 : 0,
|
||||
signature,
|
||||
startLine,
|
||||
endLine
|
||||
);
|
||||
}
|
||||
// 处理变量声明(export const/let/var)
|
||||
if (Node.isVariableStatement(node)) {
|
||||
const exported = hasModifier(node, SyntaxKind.ExportKeyword);
|
||||
const declarations = node.getDeclarations();
|
||||
for (const decl of declarations) {
|
||||
const name = decl.getName();
|
||||
if (!name) continue;
|
||||
const initializer = decl.getInitializer();
|
||||
const kind =
|
||||
initializer && initializer.getKind() === SyntaxKind.ArrowFunction
|
||||
? "function"
|
||||
: "const";
|
||||
const signature = extractSignature(decl);
|
||||
const startLine = decl.getStartLineNumber();
|
||||
const endLine = decl.getEndLineNumber();
|
||||
insertSymbol.run(
|
||||
file.id,
|
||||
name,
|
||||
kind,
|
||||
exported ? 1 : 0,
|
||||
0,
|
||||
0,
|
||||
signature,
|
||||
startLine,
|
||||
endLine
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function findSymbolIdByName(
|
||||
db: Database.Database,
|
||||
name: string,
|
||||
preferFileId?: number
|
||||
): number | null {
|
||||
if (preferFileId !== undefined) {
|
||||
const sameFile = db
|
||||
.prepare("SELECT id FROM symbols WHERE name = ? AND file_id = ? LIMIT 1")
|
||||
.get(name, preferFileId) as { id: number } | undefined;
|
||||
if (sameFile) return sameFile.id;
|
||||
}
|
||||
const anyFile = db
|
||||
.prepare("SELECT id FROM symbols WHERE name = ? LIMIT 1")
|
||||
.get(name) as { id: number } | undefined;
|
||||
return anyFile?.id || null;
|
||||
}
|
||||
|
||||
export function scanCalls(db: Database.Database): void {
|
||||
const project = new Project({
|
||||
tsConfigFilePath: "./tsconfig.json",
|
||||
skipAddingFilesFromTsConfig: true,
|
||||
});
|
||||
const files = db
|
||||
.prepare("SELECT id, path FROM files")
|
||||
.all() as { id: number; path: string }[];
|
||||
const insertCall = db.prepare(
|
||||
`INSERT INTO calls (caller_id, callee_id, callee_external, call_line, count)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
);
|
||||
for (const file of files) {
|
||||
let sourceFile;
|
||||
try {
|
||||
sourceFile = project.addSourceFileAtPath(file.path);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const fileSymbols = db
|
||||
.prepare("SELECT id, name, start_line, end_line FROM symbols WHERE file_id = ?")
|
||||
.all(file.id) as {
|
||||
id: number;
|
||||
name: string;
|
||||
start_line: number;
|
||||
end_line: number;
|
||||
}[];
|
||||
// 按行号建立符号索引(一个符号覆盖 start_line..end_line)
|
||||
const symbolByLine = new Map<number, (typeof fileSymbols)[0]>();
|
||||
for (const sym of fileSymbols) {
|
||||
for (let line = sym.start_line; line <= sym.end_line; line++) {
|
||||
symbolByLine.set(line, sym);
|
||||
}
|
||||
}
|
||||
const callExpressions = sourceFile.getDescendantsOfKind(
|
||||
SyntaxKind.CallExpression
|
||||
);
|
||||
for (const call of callExpressions) {
|
||||
const callLine = call.getStartLineNumber();
|
||||
const caller = symbolByLine.get(callLine);
|
||||
if (!caller) continue;
|
||||
const expr = call.getExpression();
|
||||
let calleeName = "";
|
||||
if (Node.isPropertyAccessExpression(expr)) {
|
||||
calleeName = expr.getName();
|
||||
} else if (Node.isIdentifier(expr)) {
|
||||
calleeName = expr.getText();
|
||||
}
|
||||
if (!calleeName) continue;
|
||||
const calleeId = findSymbolIdByName(db, calleeName, file.id);
|
||||
const calleeExternal = calleeId ? null : calleeName;
|
||||
insertCall.run(caller.id, calleeId, calleeExternal, callLine, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
interface TechTagRule {
|
||||
name: string;
|
||||
category: string;
|
||||
detect: (content: string, imports: string[]) => boolean;
|
||||
}
|
||||
|
||||
const TECH_TAG_RULES: TechTagRule[] = [
|
||||
{
|
||||
name: "cacheFn",
|
||||
category: "cache",
|
||||
detect: (content) => content.includes("cacheFn("),
|
||||
},
|
||||
{
|
||||
name: "zustand",
|
||||
category: "state",
|
||||
detect: (_content, imports) =>
|
||||
imports.some((i) => i.includes('"zustand"') || i.includes("'zustand'")),
|
||||
},
|
||||
{
|
||||
name: "useOptimistic",
|
||||
category: "state",
|
||||
detect: (content) => content.includes("useOptimistic("),
|
||||
},
|
||||
{
|
||||
name: "react-hook-form",
|
||||
category: "form",
|
||||
detect: (_content, imports) => imports.some((i) => i.includes("react-hook-form")),
|
||||
},
|
||||
{
|
||||
name: "TanStack Query",
|
||||
category: "state",
|
||||
detect: (_content, imports) => imports.some((i) => i.includes("@tanstack/react-query")),
|
||||
},
|
||||
{
|
||||
name: "Server Action",
|
||||
category: "server",
|
||||
detect: (content) =>
|
||||
content.startsWith('"use server"') || content.startsWith("'use server'"),
|
||||
},
|
||||
{
|
||||
name: "Tiptap",
|
||||
category: "ui",
|
||||
detect: (_content, imports) => imports.some((i) => i.includes("@tiptap/")),
|
||||
},
|
||||
{
|
||||
name: "Drizzle",
|
||||
category: "db",
|
||||
detect: (_content, imports) => imports.some((i) => i.includes("drizzle-orm")),
|
||||
},
|
||||
{
|
||||
name: "nuqs",
|
||||
category: "state",
|
||||
detect: (_content, imports) =>
|
||||
imports.some((i) => i.includes('"nuqs"') || i.includes("'nuqs'")),
|
||||
},
|
||||
{
|
||||
name: "recharts",
|
||||
category: "ui",
|
||||
detect: (_content, imports) =>
|
||||
imports.some((i) => i.includes('"recharts"') || i.includes("'recharts'")),
|
||||
},
|
||||
];
|
||||
|
||||
export function scanTechTags(db: Database.Database): void {
|
||||
const insertTag = db.prepare(
|
||||
"INSERT OR IGNORE INTO tech_tags (name, category) VALUES (?, ?)"
|
||||
);
|
||||
for (const rule of TECH_TAG_RULES) {
|
||||
insertTag.run(rule.name, rule.category);
|
||||
}
|
||||
const getTagId = db.prepare("SELECT id FROM tech_tags WHERE name = ?");
|
||||
const insertSymbolTag = db.prepare(
|
||||
"INSERT OR IGNORE INTO symbol_tech_tags (symbol_id, tag_id) VALUES (?, ?)"
|
||||
);
|
||||
const files = db
|
||||
.prepare("SELECT id, path FROM files")
|
||||
.all() as { id: number; path: string }[];
|
||||
for (const file of files) {
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(file.path, "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const importLines = content
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("import "));
|
||||
const detectedTags = TECH_TAG_RULES.filter((r) =>
|
||||
r.detect(content, importLines)
|
||||
);
|
||||
if (detectedTags.length === 0) continue;
|
||||
const fileSymbols = db
|
||||
.prepare("SELECT id FROM symbols WHERE file_id = ?")
|
||||
.all(file.id) as { id: number }[];
|
||||
for (const tag of detectedTags) {
|
||||
const tagRow = getTagId.get(tag.name) as { id: number };
|
||||
for (const sym of fileSymbols) {
|
||||
insertSymbolTag.run(sym.id, tagRow.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function extractModuleFromPath(importPath: string): string | null {
|
||||
const modulesMatch = importPath.match(/@\/modules\/([^/"]+)/);
|
||||
if (modulesMatch) return modulesMatch[1];
|
||||
const sharedMatch = importPath.match(/@\/shared/);
|
||||
if (sharedMatch) return "shared";
|
||||
return null;
|
||||
}
|
||||
|
||||
export function scanFileImports(db: Database.Database): void {
|
||||
const files = db
|
||||
.prepare("SELECT id, path FROM files")
|
||||
.all() as { id: number; path: string }[];
|
||||
const insertImport = db.prepare(
|
||||
`INSERT INTO file_imports (source_file_id, imported_file_id, import_path, is_type_only, imported_names)
|
||||
VALUES (?, ?, ?, ?, ?)`
|
||||
);
|
||||
for (const file of files) {
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(file.path, "utf-8");
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
const importRegex =
|
||||
/^import\s+(?:type\s+)?(.+?)\s+from\s+["']([^"']+)["']/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = importRegex.exec(content)) !== null) {
|
||||
const isTypeOnly = match[0].startsWith("import type");
|
||||
const importedNames = match[1];
|
||||
const importPath = match[2];
|
||||
let importedFileId: number | null = null;
|
||||
if (importPath.startsWith("@/")) {
|
||||
const resolvedPath = importPath
|
||||
.replace("@/", "src/")
|
||||
.replace(/\.(ts|tsx)$/, "");
|
||||
const candidatePaths = [
|
||||
resolvedPath,
|
||||
`${resolvedPath}.ts`,
|
||||
`${resolvedPath}.tsx`,
|
||||
`${resolvedPath}/index.ts`,
|
||||
];
|
||||
for (const candidate of candidatePaths) {
|
||||
const result = db
|
||||
.prepare("SELECT id FROM files WHERE path = ?")
|
||||
.get(candidate) as { id: number } | undefined;
|
||||
if (result) {
|
||||
importedFileId = result.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
insertImport.run(
|
||||
file.id,
|
||||
importedFileId,
|
||||
importPath,
|
||||
isTypeOnly ? 1 : 0,
|
||||
importedNames
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function scanModuleDeps(db: Database.Database): void {
|
||||
const insertDep = db.prepare(
|
||||
`INSERT OR IGNORE INTO module_deps (source_module_id, target_module_id, dep_type)
|
||||
VALUES (?, ?, ?)`
|
||||
);
|
||||
const moduleByPath = db
|
||||
.prepare("SELECT name, id FROM modules")
|
||||
.all() as { name: string; id: number }[];
|
||||
const moduleIdByName = new Map(moduleByPath.map((m) => [m.name, m.id]));
|
||||
|
||||
// 1. 从 file_imports 聚合 import 类型依赖
|
||||
const imports = db
|
||||
.prepare(
|
||||
`SELECT fi.import_path, f.module_id AS source_module_id
|
||||
FROM file_imports fi
|
||||
JOIN files f ON fi.source_file_id = f.id
|
||||
WHERE fi.import_path LIKE '%@/modules/%' OR fi.import_path LIKE '%@/shared%'`
|
||||
)
|
||||
.all() as { import_path: string; source_module_id: number }[];
|
||||
for (const imp of imports) {
|
||||
const targetModuleName = extractModuleFromPath(imp.import_path);
|
||||
if (!targetModuleName) continue;
|
||||
const targetModuleId = moduleIdByName.get(targetModuleName);
|
||||
if (!targetModuleId) continue;
|
||||
if (imp.source_module_id === targetModuleId) continue;
|
||||
insertDep.run(imp.source_module_id, targetModuleId, "import");
|
||||
}
|
||||
|
||||
// 2. 从 calls 聚合 data-access-call 类型依赖
|
||||
const crossModuleCalls = db
|
||||
.prepare(
|
||||
`SELECT DISTINCT f1.module_id AS source_module_id, f2.module_id AS target_module_id
|
||||
FROM calls c
|
||||
JOIN symbols s1 ON c.caller_id = s1.id
|
||||
JOIN files f1 ON s1.file_id = f1.id
|
||||
JOIN symbols s2 ON c.callee_id = s2.id
|
||||
JOIN files f2 ON s2.file_id = f2.id
|
||||
WHERE f1.module_id != f2.module_id
|
||||
AND f2.kind = 'data-access'`
|
||||
)
|
||||
.all() as { source_module_id: number; target_module_id: number }[];
|
||||
for (const call of crossModuleCalls) {
|
||||
insertDep.run(call.source_module_id, call.target_module_id, "data-access-call");
|
||||
}
|
||||
}
|
||||
52
scripts/arch-scan/schema.test.ts
Normal file
52
scripts/arch-scan/schema.test.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import Database from "better-sqlite3";
|
||||
import { initSchema } from "./schema";
|
||||
|
||||
describe("initSchema", () => {
|
||||
it("should create all 12 tables", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
const tables = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' ORDER BY name"
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
const tableNames = tables.map((t) => t.name);
|
||||
expect(tableNames).toContain("modules");
|
||||
expect(tableNames).toContain("files");
|
||||
expect(tableNames).toContain("symbols");
|
||||
expect(tableNames).toContain("calls");
|
||||
expect(tableNames).toContain("file_imports");
|
||||
expect(tableNames).toContain("module_deps");
|
||||
expect(tableNames).toContain("tech_tags");
|
||||
expect(tableNames).toContain("symbol_tech_tags");
|
||||
expect(tableNames).toContain("permissions");
|
||||
expect(tableNames).toContain("routes");
|
||||
expect(tableNames).toContain("db_tables");
|
||||
expect(tableNames).toContain("scan_meta");
|
||||
});
|
||||
|
||||
it("should create all indexes", () => {
|
||||
const db = new Database(":memory:");
|
||||
initSchema(db);
|
||||
const indexes = db
|
||||
.prepare(
|
||||
"SELECT name FROM sqlite_master WHERE type='index' AND name LIKE 'idx_%' ORDER BY name"
|
||||
)
|
||||
.all() as { name: string }[];
|
||||
const indexNames = indexes.map((i) => i.name);
|
||||
expect(indexNames).toContain("idx_symbols_file");
|
||||
expect(indexNames).toContain("idx_symbols_name");
|
||||
expect(indexNames).toContain("idx_calls_caller");
|
||||
expect(indexNames).toContain("idx_calls_callee");
|
||||
expect(indexNames).toContain("idx_file_imports_source");
|
||||
expect(indexNames).toContain("idx_file_imports_target");
|
||||
expect(indexNames).toContain("idx_symbol_tech_tags_tag");
|
||||
});
|
||||
|
||||
it("should be idempotent", () => {
|
||||
const db = new Database(":memory:");
|
||||
expect(() => initSchema(db)).not.toThrow();
|
||||
expect(() => initSchema(db)).not.toThrow();
|
||||
});
|
||||
});
|
||||
125
scripts/arch-scan/schema.ts
Normal file
125
scripts/arch-scan/schema.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import type Database from "better-sqlite3";
|
||||
|
||||
const DDL_STATEMENTS = [
|
||||
`CREATE TABLE IF NOT EXISTS modules (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
path TEXT NOT NULL,
|
||||
description TEXT,
|
||||
layer TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS files (
|
||||
id INTEGER PRIMARY KEY,
|
||||
module_id INTEGER REFERENCES modules(id),
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL,
|
||||
lines INTEGER,
|
||||
is_server INTEGER DEFAULT 0,
|
||||
is_client INTEGER DEFAULT 0,
|
||||
has_server_only INTEGER DEFAULT 0
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS symbols (
|
||||
id INTEGER PRIMARY KEY,
|
||||
file_id INTEGER NOT NULL REFERENCES files(id),
|
||||
name TEXT NOT NULL,
|
||||
kind TEXT NOT NULL,
|
||||
is_exported INTEGER DEFAULT 0,
|
||||
is_async INTEGER DEFAULT 0,
|
||||
is_server_action INTEGER DEFAULT 0,
|
||||
signature TEXT,
|
||||
start_line INTEGER,
|
||||
end_line INTEGER,
|
||||
UNIQUE(file_id, name, start_line)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS calls (
|
||||
id INTEGER PRIMARY KEY,
|
||||
caller_id INTEGER NOT NULL REFERENCES symbols(id),
|
||||
callee_id INTEGER REFERENCES symbols(id),
|
||||
callee_external TEXT,
|
||||
call_line INTEGER,
|
||||
count INTEGER DEFAULT 1
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS file_imports (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_file_id INTEGER NOT NULL REFERENCES files(id),
|
||||
imported_file_id INTEGER REFERENCES files(id),
|
||||
import_path TEXT NOT NULL,
|
||||
is_type_only INTEGER DEFAULT 0,
|
||||
imported_names TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS module_deps (
|
||||
id INTEGER PRIMARY KEY,
|
||||
source_module_id INTEGER NOT NULL REFERENCES modules(id),
|
||||
target_module_id INTEGER NOT NULL REFERENCES modules(id),
|
||||
dep_type TEXT NOT NULL,
|
||||
UNIQUE(source_module_id, target_module_id, dep_type)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS tech_tags (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
category TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS symbol_tech_tags (
|
||||
symbol_id INTEGER NOT NULL REFERENCES symbols(id),
|
||||
tag_id INTEGER NOT NULL REFERENCES tech_tags(id),
|
||||
PRIMARY KEY(symbol_id, tag_id)
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS permissions (
|
||||
id INTEGER PRIMARY KEY,
|
||||
key TEXT NOT NULL UNIQUE,
|
||||
description TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS routes (
|
||||
id INTEGER PRIMARY KEY,
|
||||
path TEXT NOT NULL UNIQUE,
|
||||
kind TEXT NOT NULL,
|
||||
file_id INTEGER REFERENCES files(id),
|
||||
min_permission TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS db_tables (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
module_id INTEGER REFERENCES modules(id),
|
||||
description TEXT
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS scan_meta (
|
||||
key TEXT PRIMARY KEY,
|
||||
value TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_symbols_file ON symbols(file_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_symbols_name ON symbols(name)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_calls_caller ON calls(caller_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_calls_callee ON calls(callee_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_file_imports_source ON file_imports(source_file_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_file_imports_target ON file_imports(imported_file_id)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_symbol_tech_tags_tag ON symbol_tech_tags(tag_id)`,
|
||||
];
|
||||
|
||||
export function initSchema(db: Database.Database): void {
|
||||
for (const stmt of DDL_STATEMENTS) {
|
||||
db.exec(stmt);
|
||||
}
|
||||
}
|
||||
|
||||
export function clearAllData(db: Database.Database): void {
|
||||
const tables = [
|
||||
"symbol_tech_tags",
|
||||
"calls",
|
||||
"file_imports",
|
||||
"module_deps",
|
||||
"symbols",
|
||||
"files",
|
||||
"modules",
|
||||
"tech_tags",
|
||||
"permissions",
|
||||
"routes",
|
||||
"db_tables",
|
||||
"scan_meta",
|
||||
];
|
||||
db.exec("PRAGMA foreign_keys = OFF");
|
||||
for (const table of tables) {
|
||||
db.exec(`DELETE FROM ${table}`);
|
||||
}
|
||||
db.exec("PRAGMA foreign_keys = ON");
|
||||
}
|
||||
255
src/modules/adaptive-practice/README.md
Normal file
255
src/modules/adaptive-practice/README.md
Normal file
@@ -0,0 +1,255 @@
|
||||
# adaptive-practice 自适应练习模块
|
||||
|
||||
> 按学生学情自适应出题(错题重做/知识点专项/薄弱章节/AI 推荐),支持答题记录、自动判分、会话状态机与多维度统计分析
|
||||
> 代码规模: 26 文件 · 178 符号 · 52,290 行(arch.db)
|
||||
> 模块路径: `src/modules/adaptive-practice/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
Starter["practice-starter<br/>(330 行) 选择练习类型"]
|
||||
Session["practice-session-view<br/>(300 行) 答题界面"]
|
||||
QCard["question-card<br/>(150 行)"]
|
||||
AnsInput["answer-input<br/>(146 行)"]
|
||||
AnsResult["answer-result<br/>(77 行)"]
|
||||
Result["practice-result-view<br/>(110 行) 结果页"]
|
||||
History["practice-history<br/>(113 行)"]
|
||||
KpWeak["class-kp-weakness-chart<br/>(165 行)"]
|
||||
TypeBreak["practice-type-breakdown-chart<br/>(138 行)"]
|
||||
Rank["student-practice-ranking-table<br/>(113 行)"]
|
||||
Compare["class-practice-comparison-table<br/>(89 行)"]
|
||||
Inactive["inactive-students-alert<br/>(65 行)"]
|
||||
end
|
||||
|
||||
subgraph Service["服务抽象层"]
|
||||
SvcCtx["practice-service<br/>(161 行) Context + 接口"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(347 行, 7 个 Server Actions)"]
|
||||
DA["data-access.ts<br/>(553 行) 会话/答题 CRUD"]
|
||||
DAStrat["data-access-strategy.ts<br/>(344 行) 4 种出题策略"]
|
||||
DAAna["data-access-analytics.ts<br/>(748 行) 班级/学生统计"]
|
||||
Schema["schema.ts<br/>(130 行, Zod)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
Grading["lib/grading.ts<br/>(143 行) autoGradeAnswer"]
|
||||
SourceMeta["lib/source-meta.ts<br/>(115 行) parseSourceMeta"]
|
||||
TypeGuards["lib/type-guards.ts<br/>(106 行) asPracticeXxx"]
|
||||
Errors["lib/errors.ts<br/>(59 行) practiceErrors"]
|
||||
AnsUtils["lib/answer-utils.ts<br/>(14 行)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Sessions[(practice_sessions)]
|
||||
Answers[(practiceAnswers)]
|
||||
Questions[(questions)]
|
||||
Mastery[(knowledgePointMastery)]
|
||||
end
|
||||
|
||||
Starter --> SvcCtx
|
||||
Session --> SvcCtx
|
||||
Session --> QCard
|
||||
QCard --> AnsInput
|
||||
QCard --> AnsResult
|
||||
Starter --> Result
|
||||
History --> SvcCtx
|
||||
KpWeak --> SvcCtx
|
||||
TypeBreak --> SvcCtx
|
||||
Rank --> SvcCtx
|
||||
Compare --> SvcCtx
|
||||
Inactive --> SvcCtx
|
||||
SvcCtx --> Actions
|
||||
|
||||
Actions --> DA
|
||||
DA --> DAStrat
|
||||
DA --> Grading
|
||||
DA --> TypeGuards
|
||||
DAStrat --> SourceMeta
|
||||
DAStrat --> Mastery
|
||||
DAStrat --> Questions
|
||||
DA --> Sessions
|
||||
DA --> Answers
|
||||
DA --> Questions
|
||||
DAAna --> Sessions
|
||||
DAAna --> Answers
|
||||
Actions --> Schema
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 创建练习会话(4 种类型分流)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as 学生
|
||||
participant Starter as practice-starter
|
||||
participant Svc as practice-service
|
||||
participant Act as actions.ts
|
||||
participant Strat as data-access-strategy
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
S->>Starter: 选择练习类型(4 选 1)
|
||||
Starter->>Svc: createSession(formData)
|
||||
Svc->>Act: createPracticeSessionAction
|
||||
Act->>Act: requirePermission(ADAPTIVE_PRACTICE_WRITE)
|
||||
Act->>Act: resolveTargetStudentId(scope)
|
||||
Act->>Strat: selectQuestionsForPractice(type, sourceMeta)
|
||||
alt error_variant
|
||||
Strat->>DB: 查 sourceQuestionIds 题库存在题
|
||||
Strat->>Strat: 按难度升序(先易后难)
|
||||
else knowledge_point
|
||||
Strat->>DB: 按知识点 + 难度过滤
|
||||
else weak_chapter
|
||||
Strat->>DB: 查 mastery < 60 的薄弱 KP
|
||||
Strat->>DB: 按 KP 筛选题
|
||||
else ai_recommended
|
||||
Strat->>DB: 按 AI 推荐 KP 筛选
|
||||
end
|
||||
Strat-->>Act: {questionIds, variants}
|
||||
Act->>DA: createPracticeSession
|
||||
DA->>DB: INSERT practiceSessions + practiceAnswers
|
||||
DA-->>Act: sessionId
|
||||
Act-->>Svc: ActionState<{sessionId, selectedCount}>
|
||||
```
|
||||
|
||||
### 流程 2: 提交答案(自动判分)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant S as 学生
|
||||
participant View as practice-session-view
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant Grading as lib/grading.ts
|
||||
participant DB as MySQL
|
||||
|
||||
S->>View: 选择答案
|
||||
View->>Act: submitPracticeAnswerAction(sessionId, answer)
|
||||
Act->>Act: requirePermission + scope 校验
|
||||
Act->>DA: submitPracticeAnswer
|
||||
DA->>Grading: autoGradeAnswer(questionType, content, answer)
|
||||
alt single_choice / multiple_choice / judgment
|
||||
Grading-->>DA: true/false
|
||||
else text
|
||||
Grading-->>DA: null(留 AI 批改)
|
||||
end
|
||||
DA->>DB: UPDATE practiceAnswers SET isCorrect/score
|
||||
DA->>DB: UPDATE practiceSessions 累计 correctCount
|
||||
DA-->>Act: {isCorrect, score}
|
||||
Act-->>View: ActionState
|
||||
```
|
||||
|
||||
### 流程 3: 会话状态机
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[created 创建] --> B[in_progress 答题中]
|
||||
B -->|submitAnswer| B
|
||||
B -->|completeSession| C[completed 完成]
|
||||
B -->|abandonSession| D[abandoned 放弃]
|
||||
C --> E[结果页 practice-result-view]
|
||||
D --> F[历史记录 practice-history]
|
||||
Note over A,D: 类型守卫 asPracticeStatus<br/>保证状态合法转换
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | practiceSessions / practiceAnswers / questions / knowledgePointMastery 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 请求级去重(55 处,与 Drizzle 并列最高) |
|
||||
| Server Action | requirePermission | ADAPTIVE_PRACTICE_READ / WRITE 权限校验 |
|
||||
| 行级权限 | resolveTargetStudentId | 6 种 dataScope(all/owned/class_members/children/class_taught/grade_managed)归属校验 |
|
||||
| 图表 | recharts | 班级薄弱知识点/练习类型分布图表(18 处) |
|
||||
| 表单 | react-hook-form + Zod | schema.ts 4 个 input schema |
|
||||
| 服务抽象 | Context + Interface | practice-service 解耦组件与 actions,便于 mock 测试 |
|
||||
| 纯函数 | autoGradeAnswer | 客观题自动判分,便于单测 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/adaptive-practice/
|
||||
├─ actions.ts # Server Actions (347 行): 创建/答题/完成/放弃/查询/统计
|
||||
├─ data-access.ts # 主数据访问 (553 行): 会话 CRUD + 答题 + 状态机
|
||||
├─ data-access-strategy.ts # 出题策略 (344 行): 4 种类型 selectForXxx
|
||||
├─ data-access-analytics.ts # 统计分析 (748 行): 班级/学生/类型分布
|
||||
├─ schema.ts # Zod 校验 (130 行): 4 个 input schema
|
||||
├─ types.ts # 类型定义 (153 行): 4 种 SourceMeta + Session/Answer
|
||||
├─ services/
|
||||
│ └─ practice-service.tsx # 服务抽象 (161 行): Context + 接口 + 默认实现
|
||||
├─ lib/
|
||||
│ ├─ grading.ts # 自动判分 (143 行): single_choice/multiple_choice/judgment/text
|
||||
│ ├─ source-meta.ts # 来源元数据 (115 行): parseSourceMeta + 4 类型守卫
|
||||
│ ├─ type-guards.ts # 状态/类型守卫 (106 行): asPracticeStatus/Type/AnswerStatus
|
||||
│ ├─ errors.ts # 错误码 (59 行): practiceErrors
|
||||
│ └─ answer-utils.ts # 答案工具 (14 行)
|
||||
└─ components/ # 14 个组件
|
||||
├─ practice-starter.tsx # 选择练习类型 (330 行)
|
||||
├─ practice-session-view.tsx # 答题界面 (300 行)
|
||||
├─ class-knowledge-point-weakness-chart.tsx # 班级薄弱知识点 (165 行)
|
||||
├─ question-card.tsx # 题目卡片 (150 行)
|
||||
├─ answer-input.tsx # 答案输入 (146 行)
|
||||
├─ practice-type-breakdown-chart.tsx # 练习类型分布 (138 行)
|
||||
├─ practice-history.tsx # 练习历史 (113 行)
|
||||
├─ student-practice-ranking-table.tsx # 学生排名 (113 行)
|
||||
├─ practice-result-view.tsx # 结果页 (110 行)
|
||||
├─ class-practice-comparison-table.tsx # 班级对比 (89 行)
|
||||
├─ answer-result.tsx # 答案结果 (77 行)
|
||||
├─ question-content.tsx # 题目内容 (70 行)
|
||||
├─ inactive-students-alert.tsx # 不活跃提醒 (65 行)
|
||||
└─ practice-starter-with-nav.tsx # 带导航外壳 (46 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | data-access-call + import | getStudentActiveClassId / verifyTeacherOwnsClass / getClassesByGradeId 班级归属与列表 |
|
||||
| users | data-access-call + import | getUserNamesByIds / getUserIdsByGradeId 学生姓名与年级用户 |
|
||||
| shared | import | DB / cache / auth-guard / type-guards / action-utils |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
> arch.db 暂无记录。模块为终端业务模块,直接由 app/ 路由消费。
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **行级权限 resolveTargetStudentId**: 6 种 dataScope 类型分别校验 studentId 归属,防止越权访问他人练习数据
|
||||
- **教师出题前 verifyTeacherOwnsClass**: 教师创建班级维度练习时必须校验班级归属
|
||||
- **出题策略按类型分流**: 4 种 PracticeType 各有独立 selectForXxx 函数,新增类型仅需新增策略函数
|
||||
- **autoGradeAnswer 仅判客观题**: single_choice/multiple_choice/judgment 自动判分,text 题型返回 null 留 AI 批改接口
|
||||
- **WEAK_MASTERY_THRESHOLD = 60**: 薄弱章节策略以此为阈值识别低掌握度知识点
|
||||
- **data-access 按职责拆分 3 文件**: 主 CRUD(553)+ 策略(344)+ 分析(748),避免单文件超 1000 行硬上限
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| strategy 独立文件 | 出题算法可独立演进与单测,不污染主 data-access |
|
||||
| analytics 独立文件 | 统计查询复杂度高(748 行)且与主 CRUD 职责不同,分离降低单文件行数 |
|
||||
| practice-service Context 抽象 | 组件层通过接口消费,默认实现调真实 actions;测试可注入 mock,不同角色可注入不同实现 |
|
||||
| autoGradeAnswer 抽取为纯函数 | 判分逻辑无副作用,便于单测;主观题返回 null 留扩展接口给 AI 批改 |
|
||||
| 4 种 SourceMeta 用 discriminated union | 类型守卫(isXxxSourceMeta)精确分流,parseSourceMeta 统一入口 |
|
||||
| AI 推荐理由用枚举而非翻译文本 | 业务数据存数据库,UI 层 t(`reasons.${reason}`) 查 i18n,避免多语言脏数据 |
|
||||
273
src/modules/ai/README.md
Normal file
273
src/modules/ai/README.md
Normal file
@@ -0,0 +1,273 @@
|
||||
# ai 人工智能模块
|
||||
|
||||
> AI 能力统一封装:聊天、相似题推荐、批改辅助、教案/题目变体生成、错因分析、学情/学习路径建议,支持 SSE 流式推送与多层内容安全过滤
|
||||
> 代码规模: 31 文件 · 203 符号 · 53,197 行(arch.db)
|
||||
> 模块路径: `src/modules/ai/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
Widget["ai-assistant-widget<br/>(27 行, 懒加载外壳)"]
|
||||
Inner["ai-assistant-widget-inner<br/>(332 行, 浮球 + 面板)"]
|
||||
ChatPanel["ai-chat-panel<br/>(219 行)"]
|
||||
ChatMsg["ai-chat-messages<br/>(200 行)"]
|
||||
ChatInput["ai-chat-input<br/>(129 行)"]
|
||||
Renderer["ai-chart-renderer<br/>(367 行)"]
|
||||
MD["ai-markdown-renderer<br/>(163 行)"]
|
||||
Grading["ai-grading-assist<br/>(174 行)"]
|
||||
Lesson["ai-lesson-content-generator<br/>(181 行)"]
|
||||
Variant["ai-question-variant-generator<br/>(219 行)"]
|
||||
ErrBook["ai-error-book-analysis<br/>(247 行)"]
|
||||
Study["ai-study-path<br/>(201 行)"]
|
||||
Child["ai-child-summary<br/>(187 行)"]
|
||||
Usage["ai-usage-dashboard<br/>(221 行)"]
|
||||
Provider["ai-provider-selector<br/>(90 行)"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks"]
|
||||
UseStream["use-ai-chat-stream<br/>(166 行, SSE 流)"]
|
||||
UseSuggest["use-ai-suggestion<br/>(73 行)"]
|
||||
UseDrag["use-drag-position<br/>(131 行)"]
|
||||
UseFloat["use-floating-ball<br/>(161 行)"]
|
||||
UsePersist["use-position-persistence<br/>(105 行)"]
|
||||
StreamUtils["stream-utils.ts<br/>(136 行)"]
|
||||
end
|
||||
|
||||
subgraph Context["Context"]
|
||||
AICP["ai-client-provider<br/>(63 行)"]
|
||||
CreateSvc["create-ai-client-service<br/>(55 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(416 行, 9 个 Server Actions)"]
|
||||
AISvc["services/ai-service.ts<br/>(483 行, createAiService + safeAiCall)"]
|
||||
Safety["services/content-safety.ts<br/>(292 行, 多层过滤)"]
|
||||
Prompts["services/prompt-templates.ts<br/>(301 行, 8 个系统 prompt)"]
|
||||
Tracker["services/usage-tracker.ts<br/>(101 行, withAiTracking)"]
|
||||
DA["data-access.ts<br/>(146 行, 内存统计)"]
|
||||
Schema["schema.ts<br/>(275 行, Zod)"]
|
||||
end
|
||||
|
||||
subgraph DB["存储"]
|
||||
Memory[(内存 usage 事件)]
|
||||
LocalStorage[(localStorage 历史记录)]
|
||||
end
|
||||
|
||||
Widget --> Inner
|
||||
Inner --> ChatPanel
|
||||
ChatPanel --> ChatMsg
|
||||
ChatPanel --> ChatInput
|
||||
ChatMsg --> MD
|
||||
ChatMsg --> Renderer
|
||||
Inner --> UseStream
|
||||
Inner --> UseFloat
|
||||
Inner --> UseDrag
|
||||
UseDrag --> UsePersist
|
||||
UseStream --> StreamUtils
|
||||
UseStream --> LocalStorage
|
||||
Grading --> Actions
|
||||
Lesson --> Actions
|
||||
Variant --> Actions
|
||||
ErrBook --> Actions
|
||||
Study --> Actions
|
||||
Child --> Actions
|
||||
Usage --> Actions
|
||||
Provider --> AICP
|
||||
AICP --> CreateSvc
|
||||
|
||||
Actions --> AISvc
|
||||
Actions --> Schema
|
||||
AISvc --> Prompts
|
||||
AISvc --> Safety
|
||||
AISvc --> Tracker
|
||||
Tracker --> DA
|
||||
DA --> Memory
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: AI 业务调用(safeAiCall 包装 + 内容安全 + 埋点)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant Comp as 业务组件(grading/lesson/variant...)
|
||||
participant Act as actions.ts
|
||||
participant Svc as ai-service.ts
|
||||
participant Safe as content-safety.ts
|
||||
participant Track as usage-tracker.ts
|
||||
participant LLM as AI Provider
|
||||
|
||||
U->>Comp: 触发 AI 能力
|
||||
Comp->>Act: xxxAction(input)
|
||||
Act->>Act: requirePermission(AI_XXX)
|
||||
Act->>Act: Zod schema 校验
|
||||
Act->>Svc: createAiService(userId)
|
||||
Act->>Svc: safeAiCall(() => service.xxx())
|
||||
Svc->>Safe: 输入过滤(blocked patterns)
|
||||
alt 输入违规
|
||||
Safe-->>Svc: blocked
|
||||
Svc-->>Act: {ok:false}
|
||||
else 通过
|
||||
Svc->>Track: withAiTracking 包装
|
||||
Track->>LLM: 调用 LLM(system prompt + input)
|
||||
LLM-->>Track: 文本响应
|
||||
Track->>Svc: 提取 JSON + Zod 二次校验
|
||||
Svc->>Safe: 输出过滤(含学生侧 patterns)
|
||||
Track->>Track: recordAiEvent + trackEvent
|
||||
Svc-->>Act: {ok:true, data}
|
||||
end
|
||||
Act-->>Comp: ActionState<T>
|
||||
```
|
||||
|
||||
### 流程 2: SSE 流式聊天(use-ai-chat-stream)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant Input as ai-chat-input
|
||||
participant Hook as use-ai-chat-stream
|
||||
participant Utils as stream-utils.ts
|
||||
participant SSE as /api/ai/chat/stream
|
||||
participant LLM as AI Provider
|
||||
|
||||
U->>Input: 输入消息
|
||||
Input->>Hook: send(messages)
|
||||
Hook->>Hook: setStreaming(true)
|
||||
Hook->>SSE: fetch POST (AbortController)
|
||||
SSE->>LLM: 流式调用
|
||||
loop 逐 token
|
||||
LLM-->>SSE: data: {token}
|
||||
SSE-->>Hook: SSE chunk
|
||||
Hook->>Utils: consumeSseStream 解析
|
||||
Utils-->>Hook: token
|
||||
Hook->>Hook: appendTokenToLastAssistant
|
||||
Hook->>Input: 实时渲染
|
||||
end
|
||||
LLM-->>SSE: done
|
||||
Hook->>Hook: persistHistory(localStorage, max 20)
|
||||
Hook->>Input: setStreaming(false)
|
||||
Note over U,LLM: 用户可调用 stop()<br/>AbortController 中止
|
||||
```
|
||||
|
||||
### 流程 3: 浮球组件懒加载与位置持久化
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[页面首屏] --> B[ai-assistant-widget 占位]
|
||||
B --> C{用户交互/视口可见}
|
||||
C -->|是| D["next/dynamic ssr:false<br/>加载 inner"]
|
||||
D --> E[use-floating-ball 控制显隐]
|
||||
E --> F[use-drag-position 处理拖拽]
|
||||
F --> G[use-position-persistence<br/>localStorage 持久化坐标]
|
||||
G --> H[下次加载恢复位置]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| AI 集成 | @/shared/lib/ai | createAiChatCompletion 调用 OpenAI 兼容接口 |
|
||||
| 流式 | SSE (fetch + ReadableStream) | use-ai-chat-stream 逐 token 推送 |
|
||||
| Server Action | requirePermission | AI_CHAT / AI_GRADING / AI_LESSON 等权限校验 |
|
||||
| 表单 | react-hook-form + Zod | 输入校验(schema.ts 9 个 schema) |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重(6 处) |
|
||||
| 国际化 | next-intl/server | getTranslations 渲染错误消息 |
|
||||
| 埋点 | @/shared/lib/track-event | withAiTracking 双写 usage + track-event |
|
||||
| 懒加载 | next/dynamic ssr:false | AiAssistantWidget 减小首屏 bundle |
|
||||
| Markdown | 自定义 ai-markdown-renderer | 渲染 AI 回复,代码高亮 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/ai/
|
||||
├─ actions.ts # Server Actions (416 行): 9 个能力(chat/similar/grading/lesson/variant/weakness/child/study/explain)
|
||||
├─ data-access.ts # 内存数据访问 (146 行): getAiUsageStats / recordAiEvent
|
||||
├─ schema.ts # Zod 校验 (275 行): 9 个 input/output schema
|
||||
├─ types.ts # 类型定义 (331 行): AiChatMessage / GradingInput / LessonContentInput...
|
||||
├─ services/
|
||||
│ ├─ ai-service.ts # AI 服务 (483 行): createAiService 工厂 + safeAiCall + JSON 提取
|
||||
│ ├─ content-safety.ts # 内容安全 (292 行): 输入/输出/学生侧 patterns + 每日限额
|
||||
│ ├─ prompt-templates.ts # 系统提示词 (301 行): 8 个业务场景 system prompt
|
||||
│ └─ usage-tracker.ts # 使用埋点 (101 行): withAiTracking + trackAiUsage
|
||||
├─ context/
|
||||
│ ├─ ai-client-provider.tsx # 客户端 Provider (63 行)
|
||||
│ └─ create-ai-client-service.ts # 客户端服务工厂 (55 行)
|
||||
├─ hooks/
|
||||
│ ├─ use-ai-chat-stream.ts # SSE 流式聊天 (166 行)
|
||||
│ ├─ use-ai-suggestion.ts # 一次性建议 (73 行)
|
||||
│ ├─ use-floating-ball.ts # 浮球显隐 (161 行)
|
||||
│ ├─ use-drag-position.ts # 拖拽位置 (131 行)
|
||||
│ ├─ use-position-persistence.ts # 位置持久化 (105 行)
|
||||
│ └─ stream-utils.ts # SSE 解析工具 (136 行)
|
||||
└─ components/ # 15 个客户端组件
|
||||
├─ ai-assistant-widget.tsx # 懒加载外壳 (27 行)
|
||||
├─ ai-assistant-widget-inner.tsx # 浮球 + 面板 (332 行)
|
||||
├─ ai-chart-renderer.tsx # 图表渲染 (367 行)
|
||||
├─ ai-error-book-analysis.tsx # 错题分析 (247 行)
|
||||
├─ ai-usage-dashboard.tsx # 使用统计仪表盘 (221 行)
|
||||
├─ ai-chat-panel.tsx # 聊天面板 (219 行)
|
||||
├─ ai-question-variant-generator.tsx # 题目变体生成 (219 行)
|
||||
├─ ai-study-path.tsx # 学习路径 (201 行)
|
||||
├─ ai-chat-messages.tsx # 聊天消息 (200 行)
|
||||
├─ ai-child-summary.tsx # 孩子学情总结 (187 行)
|
||||
├─ ai-lesson-content-generator.tsx # 教案内容生成 (181 行)
|
||||
├─ ai-grading-assist.tsx # 批改辅助 (174 行)
|
||||
├─ ai-markdown-renderer.tsx # Markdown 渲染 (163 行)
|
||||
├─ ai-chat-input.tsx # 聊天输入 (129 行)
|
||||
└─ ai-provider-selector.tsx # Provider 选择 (90 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| textbooks | data-access-call + import | getKnowledgePointsWithRelations / getStudentKpMastery 提供知识点上下文 |
|
||||
| shared | import | env / auth-guard / track-event / lib/ai / cache / types |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| exams | import | 考试模块调用 AI 批改/相似题推荐 |
|
||||
| homework | import | 作业模块调用 AI 批改辅助/错因分析 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **createAiService 工厂屏蔽 Provider 差异**: 通过 providerId 选择 OpenAI 兼容/Anthropic 等不同客户端,业务层不感知底层
|
||||
- **safeAiCall 统一异常**: AI 调用失败不抛裸异常,返回 `{ok:false, message}` 结构化错误,Server Action 转 ActionState
|
||||
- **AiAssistantWidget 必须 next/dynamic ssr:false 懒加载**: 浮球组件非首屏必需,避免拖慢首屏 bundle
|
||||
- **content-safety 多层过滤**: 输入 patterns + 输出 patterns + 学生侧 patterns(禁止直接给出答案) + 每日限额(防 TOCTOU 竞态)
|
||||
- **data-access.ts 当前为内存实现**: getAiUsageStats / recordAiEvent 仅内存,多实例部署必须替换为 Redis/DB
|
||||
- **AI 返回 JSON 必须经 Zod 二次校验**: ai-service 中 extractBalancedJsonSegment 提取后用 schema 校验,防止 LLM 返回脏数据
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| createAiService 工厂模式 | 屏蔽 Provider 差异(OpenAI 兼容/Anthropic),业务层统一 AiService 接口,新增 Provider 仅改工厂 |
|
||||
| safeAiCall 包装异常 | AI 调用失败率高(网络/限流/超时),统一返回结构化错误避免 try/catch 散落各 Action |
|
||||
| prompt 集中在 prompt-templates.ts | 8 个业务场景 system prompt 统一管理,便于版本控制与 A/B 测试 |
|
||||
| 内存 usage-tracker + track-event 双写 | 内存聚合供管理员仪表盘实时查询,track-event 落库供长期分析;生产替换 Redis 即可 |
|
||||
| AiAssistantWidget 懒加载 ssr:false | 浮球组件依赖浏览器 API(localStorage/drag),SSR 无意义且增大首屏 bundle |
|
||||
| withAiTracking 高阶函数包装 | 透明记录每次调用的 capability/success/duration,业务函数无需关心埋点 |
|
||||
| content-safety 学生侧额外 patterns | K12 场景学生不应直接得到作业答案,需引导思考而非给答案 |
|
||||
229
src/modules/announcements/README.md
Normal file
229
src/modules/announcements/README.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# announcements 公告模块
|
||||
|
||||
> 管理全校/年级/班级公告的发布、状态流转、受众可见性过滤、已读回执与 fan-out 推送
|
||||
> 代码规模: 15 文件 · 3,056 行(arch.db)
|
||||
> 模块路径: `src/modules/announcements/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
AdminView["admin-announcements-view<br/>(83 行, 管理端)"]
|
||||
Form["announcement-form<br/>(268 行, Zod 校验)"]
|
||||
List["announcement-list<br/>(166 行, 用户端)"]
|
||||
Card["announcement-card<br/>(157 行)"]
|
||||
Detail["announcement-detail<br/>(263 行, 详情)"]
|
||||
Page["announcement-pagination<br/>(131 行)"]
|
||||
Ctx["announcements-service-context<br/>(43 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(421 行, 9 个 Server Actions)"]
|
||||
DA["data-access.ts<br/>(603 行, server-only)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数 / Schema"]
|
||||
Schema["schema.ts<br/>(96 行, refineAudience)"]
|
||||
DefaultSvc["default-announcements-service<br/>(31 行)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Announcements[(announcements)]
|
||||
Reads[(announcement_reads)]
|
||||
end
|
||||
|
||||
AdminView --> Form
|
||||
AdminView --> List
|
||||
List --> Card
|
||||
Card --> Detail
|
||||
List --> Page
|
||||
Form --> Schema
|
||||
Form --> Actions
|
||||
List --> Actions
|
||||
Detail --> Actions
|
||||
Ctx --> DefaultSvc
|
||||
|
||||
Actions --> DA
|
||||
Actions --> Notifications["notifications<br/>sendBatchNotifications"]
|
||||
DA --> Announcements
|
||||
DA --> Reads
|
||||
DA --> Users["users.getAllUserIds"]
|
||||
DA --> School["school"]
|
||||
DA --> Classes["classes"]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 公告创建 + 受众校验 + 状态流转
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 管理员
|
||||
participant Form as announcement-form
|
||||
participant Schema as schema.ts
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Form: 填写标题/内容/类型/受众
|
||||
Form->>Schema: CreateAnnouncementSchema.superRefine
|
||||
Schema->>Schema: refineAudience 校验
|
||||
alt type=school
|
||||
Schema->>Schema: targetGradeId/ClassId 必须空
|
||||
else type=grade
|
||||
Schema->>Schema: targetGradeId 必填
|
||||
else type=class
|
||||
Schema->>Schema: targetClassId 必填
|
||||
end
|
||||
Form->>Act: createAnnouncementAction
|
||||
Act->>Act: requirePermission(ANNOUNCEMENT_MANAGE)
|
||||
Act->>DA: insertAnnouncement
|
||||
DA->>DB: INSERT announcements(status=draft)
|
||||
Act->>Act: invalidateFor
|
||||
Act-->>Form: ActionState<{id}>
|
||||
```
|
||||
|
||||
### 流程 2: 发布 + fan-out 通知(school 维度分页遍历)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 管理员
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant Notify as notifications
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Act: publishAnnouncementAction(id)
|
||||
Act->>Act: requirePermission(ANNOUNCEMENT_MANAGE)
|
||||
Act->>DA: publishAnnouncementById(id, now)
|
||||
DA->>DB: UPDATE announcements SET status=published
|
||||
Act->>DA: resolveAnnouncementTargetUserIds
|
||||
alt type=school
|
||||
DA->>DB: 分页遍历 getAllUserIds
|
||||
else type=grade
|
||||
DA->>DB: 查询年级下所有用户
|
||||
else type=class
|
||||
DA->>DB: 查询班级成员
|
||||
end
|
||||
DB-->>DA: targetUserIds[]
|
||||
DA-->>Act: string[]
|
||||
Act->>Notify: sendBatchNotifications(payloads)
|
||||
Note over Act,Notify: 通知失败不阻塞发布<br/>仅 log.error
|
||||
Act->>Act: invalidateFor
|
||||
Act-->>U: ActionState
|
||||
```
|
||||
|
||||
### 流程 3: 用户端可见性过滤 + 已读回执(P0-2 多受众修复)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[用户进入公告列表] --> B[getAnnouncementsAction<br/>权限: ANNOUNCEMENT_READ]
|
||||
B --> C[resolveUserAudience<br/>解析 gradeIds[] / classIds[]]
|
||||
C --> D{受众数组校验}
|
||||
D --> E[isAnnouncementVisibleToAudience<br/>纯函数过滤]
|
||||
E --> F{公告类型}
|
||||
F -->|school| G[全员可见]
|
||||
F -->|grade| H{targetGradeId ∈ audience.gradeIds}
|
||||
F -->|class| I{targetClassId ∈ audience.classIds}
|
||||
G --> J[返回可见公告列表]
|
||||
H --> J
|
||||
I --> J
|
||||
|
||||
J --> K[用户点击公告]
|
||||
K --> L[markAnnouncementAsReadAction]
|
||||
L --> M[INSERT announcement_reads]
|
||||
M --> N[返回 isReadByCurrentUser]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | announcements / announcement_reads 表 CRUD |
|
||||
| 缓存 | cacheFn | 列表/详情查询请求级去重 |
|
||||
| 缓存失效 | invalidateFor | 写操作后精确刷新列表与详情两条路径 |
|
||||
| 表单校验 | Zod + superRefine | schema.ts 的 refineAudience 联合校验类型与受众 |
|
||||
| Server Action | requirePermission | ANNOUNCEMENT_MANAGE(写) / ANNOUNCEMENT_READ(读) 两权限点 |
|
||||
| 通知推送 | notifications.sendBatchNotifications | 发布后 fan-out 批量推送 |
|
||||
| i18n | next-intl getTranslations | 错误消息与通知文案本地化 |
|
||||
| 事件追踪 | trackEvent | 操作错误上报监控告警 |
|
||||
| 日志 | createModuleLogger | 模块级结构化日志 |
|
||||
| 服务抽象 | service-context | 客户端服务注入,便于 Mock 测试 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/announcements/
|
||||
├─ actions.ts # Server Actions (421 行): 9 个 Action 编排
|
||||
├─ data-access.ts # 数据访问 (603 行, server-only): CRUD + fan-out + 可见性
|
||||
├─ schema.ts # Zod 校验 (96 行): refineAudience 联合校验
|
||||
├─ types.ts # 类型定义 (125 行): Announcement / UserAudience
|
||||
├─ is-announcement-visible.test.ts # 可见性纯函数单测 (154 行)
|
||||
├─ schema.test.ts # Schema 校验单测 (211 行)
|
||||
├─ components/ # 7 个组件
|
||||
│ ├─ admin-announcements-view.tsx # 管理端视图 (83 行)
|
||||
│ ├─ announcement-form.tsx # 公告表单 (268 行)
|
||||
│ ├─ announcement-list.tsx # 用户端列表 (166 行)
|
||||
│ ├─ announcement-card.tsx # 公告卡片 (157 行)
|
||||
│ ├─ announcement-detail.tsx # 公告详情 (263 行)
|
||||
│ ├─ announcement-pagination.tsx # 分页组件 (131 行)
|
||||
│ ├─ announcements-service-context.tsx # 服务上下文 (43 行)
|
||||
│ ├─ announcement-card.test.tsx # 卡片组件单测 (304 行)
|
||||
│ └─ default-announcements-service.ts # 默认服务实现 (31 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| shared | import | DB / cache / auth-guard / logger / trackEvent |
|
||||
| notifications | import | 发布时 sendBatchNotifications 批量推送 |
|
||||
| users | data-access-call | resolveAnnouncementTargetUserIds 调用 getAllUserIds |
|
||||
| school | data-access-call | 年级公告解析目标年级用户 |
|
||||
| classes | data-access-call | 班级公告解析目标班级成员 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| search | data-access-call | 全文检索聚合 announcements 表 |
|
||||
| dashboard | data-access-call | 首页公告统计与最近公告展示 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **schema refineAudience 联合校验**: school 不指定受众、grade 必填 targetGradeId、class 必填 targetClassId,避免创建无受众公告
|
||||
- **状态机严格流转**: draft → published → archived,由独立 Action 处理(publishAnnouncementById / archiveAnnouncementById / toggleAnnouncementPin)
|
||||
- **P0-2 多受众修复**: audience 为 `gradeIds[]` / `classIds[]` 数组,解决家长多孩子/教师多班级/年级主任多年级只看到首个受众的截断 Bug
|
||||
- **通知失败不阻塞发布**: notifyAnnouncementPublished 内部 try/catch,失败仅 log.error,不回滚公告状态
|
||||
- **缓存失效双路径**: 写操作后 invalidateFor 必须覆盖列表与详情两条缓存路径
|
||||
- **data-access.ts 首行 server-only**: 禁止前端组件直接调用
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| school fan-out 分页遍历 getAllUserIds | 避免一次性加载全体用户 ID 导致内存峰值 |
|
||||
| announcementReads 独立表 | 与公告本体解耦,支持按用户聚合已读状态,避免公告表膨胀 |
|
||||
| audience 升级为数组(P0-2) | 单值无法表达家长多孩子/教师多班级场景,导致可见性截断 |
|
||||
| 可见性判断抽纯函数 isAnnouncementVisibleToAudience | 便于单测覆盖三类公告的可见性矩阵 |
|
||||
| 通知发送与发布解耦 | 通知基础设施故障不应阻塞业务公告发布 |
|
||||
| service-context 注入服务 | 客户端组件可注入 Mock 服务,便于组件单测 |
|
||||
| refineAudience 用 superRefine | 跨字段联合校验,Zod 原生支持,错误信息精准定位字段 |
|
||||
248
src/modules/attendance/README.md
Normal file
248
src/modules/attendance/README.md
Normal file
@@ -0,0 +1,248 @@
|
||||
# attendance 考勤模块
|
||||
|
||||
> 学生考勤记录(节次考勤)、统计、趋势分析、缺勤预警、考勤-成绩关联分析与家长批量通知
|
||||
> 代码规模: 27 文件 · 168 符号 · 47,392 行(arch.db)
|
||||
> 模块路径: `src/modules/attendance/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
Sheet["attendance-sheet<br/>(509 行) 点名表 form action"]
|
||||
RulesForm["attendance-rules-form<br/>(191 行) 规则配置"]
|
||||
RecordList["attendance-record-list<br/>(172 行) 记录列表"]
|
||||
CorrCard["attendance-grade-correlation-card<br/>(397 行) 关联分析"]
|
||||
Report["attendance-report-print<br/>(284 行) 打印报表"]
|
||||
Compare["class-comparison-card<br/>(165 行) 班级对比"]
|
||||
StudentView["student-attendance-view<br/>(153 行) 学生视图"]
|
||||
WarnCard["attendance-warnings-card<br/>(120 行) 预警卡片"]
|
||||
TrendChart["attendance-trend-chart<br/>(110 行) 趋势图"]
|
||||
Layout["attendance-page-layout<br/>(39 行)"]
|
||||
end
|
||||
|
||||
subgraph Service["服务抽象层"]
|
||||
Ctx["attendance-context<br/>(46 行) Provider"]
|
||||
DataSvc["attendance-data-service<br/>(161 行) 接口 + 默认实现"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(281 行, 5 个 Server Actions)"]
|
||||
DA["data-access.ts<br/>(525 行) CRUD + scope 过滤"]
|
||||
DAStats["data-access-stats.ts<br/>(498 行) 统计查询"]
|
||||
DACorr["data-access-correlation.ts<br/>(214 行) 关联数据"]
|
||||
Notif["notifications.ts<br/>(155 行) 缺勤通知"]
|
||||
Export["export.ts<br/>(92 行) 报表导出"]
|
||||
Schema["schema.ts<br/>(68 行, Zod)"]
|
||||
end
|
||||
|
||||
subgraph Lib["纯函数库"]
|
||||
CorrCompute["correlation-compute.ts<br/>(211 行) Pearson + 风险等级"]
|
||||
WarnCompute["warning-compute.ts<br/>(153 行) 出勤率/连续缺勤预警"]
|
||||
TrendCompute["trend-compute.ts<br/>(86 行) 趋势计算"]
|
||||
TypeGuards["lib/type-guards.ts<br/>(18 行)"]
|
||||
Constants["constants.ts<br/>(20 行)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Records[(attendanceRecords)]
|
||||
Rules[(attendanceRules)]
|
||||
end
|
||||
|
||||
Sheet --> Actions
|
||||
RulesForm --> Actions
|
||||
RecordList --> DataSvc
|
||||
CorrCard --> DataSvc
|
||||
Report --> Export
|
||||
Compare --> DataSvc
|
||||
StudentView --> DataSvc
|
||||
WarnCard --> DataSvc
|
||||
TrendChart --> DataSvc
|
||||
DataSvc --> Ctx
|
||||
DataSvc --> Actions
|
||||
|
||||
Actions --> DA
|
||||
Actions --> Notif
|
||||
Actions --> Schema
|
||||
DA --> Records
|
||||
DA --> Rules
|
||||
DAStats --> Records
|
||||
DACorr --> Records
|
||||
Notif --> Records
|
||||
DAStats --> WarnCompute
|
||||
DAStats --> TrendCompute
|
||||
DACorr --> CorrCompute
|
||||
Export --> DAStats
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 教师点名(form action + useOptimistic)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant T as 教师
|
||||
participant Sheet as attendance-sheet
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant Notif as notifications.ts
|
||||
participant DB as MySQL
|
||||
|
||||
T->>Sheet: 批量选择学生状态
|
||||
Sheet->>Sheet: useOptimistic 乐观更新 UI
|
||||
Sheet->>Act: batchRecordAttendanceAction(formData)
|
||||
Note over Sheet,Act: 必须用 form action<br/>useTransition 会破坏 useFormStatus
|
||||
Act->>Act: requirePermission(ATTENDANCE_MANAGE)
|
||||
Act->>Act: assertClassOwnership(classId, scope)
|
||||
Act->>DA: batchCreateAttendanceRecords
|
||||
DA->>DB: INSERT attendanceRecords(批量)
|
||||
Act->>Notif: notifyParentsOfAbsence(classId, records)
|
||||
Notif->>Notif: 过滤 NOTIFIABLE_STATUSES
|
||||
Notif->>Notif: 批量查学生→家长映射(防 N+1)
|
||||
Notif->>DB: sendBatchNotifications 多渠道
|
||||
Act-->>Sheet: ActionState
|
||||
Note over T,DB: 通知失败不阻断考勤保存
|
||||
```
|
||||
|
||||
### 流程 2: 缺勤预警计算
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[getAttendanceStats] --> B[data-access-stats 查询]
|
||||
B --> C[warning-compute.ts]
|
||||
C --> D{出勤率 < 阈值?}
|
||||
D -->|是| E[rateSeverity 划分等级<br/>high/medium/low]
|
||||
D -->|否| F[不预警]
|
||||
C --> G{连续缺勤 ≥ 阈值?}
|
||||
G -->|是| H[absenceSeverity 划分等级]
|
||||
G -->|否| F
|
||||
E --> I[AttendanceWarningSummary]
|
||||
H --> I
|
||||
I --> J[attendance-warnings-card 渲染]
|
||||
```
|
||||
|
||||
### 流程 3: 考勤-成绩关联分析
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant Card as correlation-card
|
||||
participant Svc as data-service
|
||||
participant DA as data-access-correlation
|
||||
participant Compute as correlation-compute
|
||||
participant DB as MySQL
|
||||
|
||||
Card->>Svc: 查询关联分析
|
||||
Svc->>DA: getAttendanceGradeCorrelation
|
||||
DA->>DB: 查出勤率 + 成绩
|
||||
DA-->>Compute: 原始数据
|
||||
Compute->>Compute: Pearson 相关系数
|
||||
Compute->>Compute: 风险等级分类<br/>(ATTENDANCE 80/90, SCORE 60/75)
|
||||
Compute->>Compute: 相关系数解释<br/>(strong_negative...strong_positive)
|
||||
Compute-->>Card: 关联摘要 + 学生列表
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | attendanceRecords / attendanceRules 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 函数请求级去重(41 处) |
|
||||
| Server Action | requirePermission | ATTENDANCE_MANAGE / ATTENDANCE_READ 权限校验 |
|
||||
| 状态 | useOptimistic | attendance-sheet 点名乐观更新(8 处使用) |
|
||||
| 表单 | react-hook-form + Zod | attendance-rules-form 规则配置 |
|
||||
| 通知 | notifications/sendBatchNotifications | 缺勤批量推送家长,多渠道分发 |
|
||||
| 统计 | 自定义 compute 纯函数 | Pearson 相关系数 / 预警等级 / 趋势 |
|
||||
| 报表 | export.ts | 考勤报表导出与打印 |
|
||||
| scope 过滤 | buildScopeFilter | 6 种 dataScope 行级权限过滤 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/attendance/
|
||||
├─ actions.ts # Server Actions (281 行): record/batch/update/delete/upsertRules
|
||||
├─ data-access.ts # 主数据访问 (525 行): CRUD + buildScopeFilter + buildPeriodFilter
|
||||
├─ data-access-stats.ts # 统计查询 (498 行): 出勤率/连续缺勤/班级对比
|
||||
├─ data-access-correlation.ts # 关联数据 (214 行): 考勤-成绩关联查询
|
||||
├─ schema.ts # Zod 校验 (68 行): Record/Batch/Update/Rule schema
|
||||
├─ types.ts # 类型定义 (269 行): 6 状态/5 节次/Stats/Warning/Correlation
|
||||
├─ constants.ts # 常量 (20 行)
|
||||
├─ notifications.ts # 缺勤通知 (155 行): notifyParentsOfAbsence
|
||||
├─ export.ts # 报表导出 (92 行)
|
||||
├─ correlation-compute.ts # 关联计算 (211 行): Pearson + 风险等级 + 解释
|
||||
├─ warning-compute.ts # 预警计算 (153 行): 出勤率/连续缺勤 severity
|
||||
├─ trend-compute.ts # 趋势计算 (86 行)
|
||||
├─ lib/type-guards.ts # 类型守卫 (18 行)
|
||||
├─ services/
|
||||
│ ├─ attendance-context.tsx # Provider (46 行)
|
||||
│ ├─ attendance-data-service.ts # 数据服务 (161 行): 接口 + 默认实现
|
||||
│ └─ types.ts # 服务类型 (158 行)
|
||||
└─ components/ # 11 个组件
|
||||
├─ attendance-sheet.tsx # 点名表 (509 行, form action + useOptimistic)
|
||||
├─ attendance-grade-correlation-card.tsx # 关联分析 (397 行)
|
||||
├─ attendance-report-print.tsx # 打印报表 (284 行)
|
||||
├─ attendance-rules-form.tsx # 规则配置 (191 行)
|
||||
├─ attendance-record-list.tsx # 记录列表 (172 行)
|
||||
├─ class-comparison-card.tsx # 班级对比 (165 行)
|
||||
├─ student-attendance-view.tsx # 学生视图 (153 行)
|
||||
├─ attendance-warnings-card.tsx # 预警卡片 (120 行)
|
||||
├─ attendance-trend-chart.tsx # 趋势图 (110 行)
|
||||
├─ attendance-page-layout.tsx # 页面布局 (39 行)
|
||||
└─ attendance-stats-class-selector.tsx # 班级选择 (28 行)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| classes | data-access-call + import | verifyTeacherOwnsClass / getClassActiveStudentsWithInfo / getClassNameById |
|
||||
| users | data-access-call + import | getUserNamesByIds 获取记录人/学生姓名 |
|
||||
| grades | data-access-call + import | 关联分析查询学生成绩数据 |
|
||||
| school | data-access-call + import | 学年/学期维度过滤 |
|
||||
| parent | data-access-call + import | getParentStudentMapByStudentIds 通知映射 |
|
||||
| notifications | import | sendBatchNotifications 缺勤多渠道分发 |
|
||||
| shared | import | DB / cache / auth-guard / action-utils |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| grades | data-access-call | 成绩模块引用考勤数据做综合分析 |
|
||||
| leave-requests | data-access-call + import | 请假模块联动考勤记录(请假自动标记 excused) |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **attendance-sheet 必须保留 form action 模式**: useFormStatus 依赖 form action promise,useTransition 会破坏该契约;useOptimistic 配合 form action 实现乐观更新
|
||||
- **节次考勤 period 向后兼容**: null 视为 full_day,buildPeriodFilter 处理 `period IS NULL OR period = 'full_day'` 兼容旧数据
|
||||
- **scope 行级权限 buildScopeFilter**: 6 种 dataScope(all/class_taught/grade_managed/class_members/children/owned)分别构建 SQL 过滤
|
||||
- **通知失败不阻断考勤保存**: notifyParentsOfAbsence 仅记录日志,不影响主流程事务
|
||||
- **NOTIFIABLE_STATUSES 仅 4 种状态触发通知**: absent/late/early_leave/excused,present/school_activity 不通知
|
||||
- **计算逻辑拆分为独立 compute 文件**: data-access 只做读写,correlation/warning/trend 计算为纯函数便于单测
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| attendance-sheet 保留 form action + useOptimistic | useFormStatus 依赖 form action promise,useTransition 会破坏该契约;乐观更新提升点名体验 |
|
||||
| 计算逻辑拆分为 correlation/warning/trend compute | data-access 保持纯读写,计算无 IO 副作用可独立单测 |
|
||||
| 缺勤通知内建 notifications.ts 但调 notifications 模块 | 考勤事件与通知解耦(同模块维护业务规则,跨模块分发) |
|
||||
| 批量通知 sendBatchNotifications + 一次性查家长映射 | 避免 N+1 查询,一次 SQL 拿全部学生→家长映射 |
|
||||
| assertRecordOwnership / assertClassOwnership 双重校验 | 写操作前校验教师对班级/记录的归属,防止越权修改他人班级考勤 |
|
||||
| 风险等级用阈值常量而非魔法数字 | RISK_ATTENDANCE_HIGH/MEDIUM_THRESHOLD(80/90)、SCORE(60/75)集中定义,便于调优 |
|
||||
| 节次考勤向后兼容 null | L-6 引入 period 字段,旧数据 null 自动视为 full_day,无需数据迁移 |
|
||||
229
src/modules/audit/README.md
Normal file
229
src/modules/audit/README.md
Normal file
@@ -0,0 +1,229 @@
|
||||
# audit 审计模块
|
||||
|
||||
> 提供审计日志、登录日志、数据变更日志的查询、Excel 导出与保留策略管理
|
||||
> 代码规模: 24 文件 · 3,451 行(arch.db)
|
||||
> 模块路径: `src/modules/audit/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
LogView["audit-log-view<br/>(145 行)"]
|
||||
LogTable["audit-log-table<br/>(120 行)"]
|
||||
LoginView["login-log-view<br/>(131 行)"]
|
||||
ChangeView["data-change-log-view<br/>(163 行)"]
|
||||
Retention["audit-retention-settings<br/>(218 行)"]
|
||||
Export["audit-log-export-button<br/>(87 行)"]
|
||||
Overview["audit-overview-view<br/>(114 行)"]
|
||||
Charts["趋势/分布图表"]
|
||||
end
|
||||
|
||||
subgraph Hooks["Hooks"]
|
||||
UsePage["use-log-pagination<br/>(28 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(229 行, Server Actions)"]
|
||||
DA["data-access.ts<br/>(481 行, server-only)"]
|
||||
ExportLib["export.ts<br/>(145 行, Excel 导出)"]
|
||||
RetentionLib["retention.ts<br/>(187 行, 保留策略)"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
AuditLogs[(audit_logs)]
|
||||
LoginLogs[(login_logs)]
|
||||
ChangeLogs[(data_change_logs)]
|
||||
SysSettings[(system_settings)]
|
||||
end
|
||||
|
||||
LogView --> LogTable
|
||||
LogTable --> UsePage
|
||||
LoginView --> UsePage
|
||||
ChangeView --> UsePage
|
||||
Export --> Actions
|
||||
Retention --> Actions
|
||||
Overview --> Charts
|
||||
UsePage --> Actions
|
||||
|
||||
Actions --> DA
|
||||
Actions --> ExportLib
|
||||
Actions --> RetentionLib
|
||||
DA --> AuditLogs
|
||||
DA --> LoginLogs
|
||||
DA --> ChangeLogs
|
||||
RetentionLib --> SysSettings
|
||||
RetentionLib --> AuditLogs
|
||||
RetentionLib --> LoginLogs
|
||||
RetentionLib --> ChangeLogs
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 日志查询 + 分页(nuqs URL 状态同步)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 管理员
|
||||
participant View as audit-log-view
|
||||
participant Hook as use-log-pagination
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>View: 打开审计日志页
|
||||
View->>Hook: 读取 nuqs 分页参数
|
||||
Hook->>Act: getDataChangeLogsAction(params)
|
||||
Act->>Act: requirePermission(AUDIT_LOG_READ)
|
||||
Act->>DA: Promise.all[getLogs, getTableOptions, getStats]
|
||||
DA->>DB: SELECT FROM audit_logs LIMIT/OFFSET
|
||||
DA->>DB: SELECT COUNT(*)
|
||||
DB-->>DA: rows + total
|
||||
DA-->>Act: PaginatedResult
|
||||
Act-->>Hook: ActionState
|
||||
Hook-->>View: 渲染表格
|
||||
View->>U: 翻页(URL 同步)
|
||||
```
|
||||
|
||||
### 流程 2: Excel 导出(含审计追踪)
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 管理员
|
||||
participant Btn as audit-log-export-button
|
||||
participant Act as actions.ts
|
||||
participant DA as data-access.ts
|
||||
participant Export as export.ts
|
||||
participant Track as trackEvent
|
||||
|
||||
U->>Btn: 点击导出
|
||||
Btn->>Act: exportAuditLogsAction(params)
|
||||
Act->>Act: requirePermission(AUDIT_LOG_READ)
|
||||
Act->>DA: getAuditLogsForExport(params)
|
||||
DA-->>Act: AuditLog[]
|
||||
Act->>Export: buildAuditLogExport(items)
|
||||
Export-->>Act: {buffer, filename}
|
||||
Act->>Track: trackEvent(audit.exported)
|
||||
Act-->>Btn: ActionState<{buffer, filename}>
|
||||
Btn->>U: 浏览器下载 Excel
|
||||
```
|
||||
|
||||
### 流程 3: 保留策略 + 清理(双权限点)
|
||||
|
||||
```mermaid
|
||||
flowchart TB
|
||||
A[管理员进入保留设置] --> B{操作类型}
|
||||
B -->|查看| C[getAuditRetentionConfigAction<br/>权限: AUDIT_LOG_READ]
|
||||
B -->|保存配置| D[saveAuditRetentionConfigAction<br/>权限: AUDIT_RETENTION_MANAGE]
|
||||
B -->|手动清理| E[purgeAuditLogsAction<br/>权限: AUDIT_LOG_PURGE]
|
||||
|
||||
D --> D1[retention.ts<br/>upsertSystemSetting]
|
||||
D --> D2[invalidateFor 缓存]
|
||||
|
||||
E --> E1[读取配置或入参]
|
||||
E1 --> E2[calculateRetentionThreshold<br/>计算截止日期]
|
||||
E2 --> E3[DELETE FROM audit_logs<br/>WHERE createdAt < threshold]
|
||||
E3 --> E4[DELETE FROM login_logs]
|
||||
E4 --> E5[trackEvent + invalidateFor]
|
||||
|
||||
C --> C1[getAuditRetentionConfig<br/>从 system_settings 读取]
|
||||
C1 --> C2[未配置返回默认值<br/>审计 180 天/登录 365 天]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 数据库 | Drizzle ORM | audit_logs / login_logs / data_change_logs / system_settings 表 CRUD |
|
||||
| 缓存 | cacheFn | data-access 查询函数请求级去重 |
|
||||
| URL 状态 | nuqs | 分页参数与筛选条件 URL 同步 |
|
||||
| Server Action | requirePermission | AUDIT_LOG_READ / AUDIT_LOG_PURGE / AUDIT_RETENTION_MANAGE 三权限点 |
|
||||
| Excel 导出 | shared/lib/excel | export.ts 列定义 + buffer 生成 |
|
||||
| 日志 | createModuleLogger | 模块级结构化日志 |
|
||||
| 事件追踪 | trackEvent | 导出/保留/清理操作审计追踪 |
|
||||
| 缓存失效 | invalidateFor | 配置保存/清理后精确刷新 |
|
||||
| 纯函数 | calculateRetentionThreshold | 保留阈值日期计算,便于单测 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/audit/
|
||||
├─ actions.ts # Server Actions (229 行): 查询/导出/保留/清理编排
|
||||
├─ data-access.ts # 数据访问 (481 行, server-only): 三类日志分页查询
|
||||
├─ export.ts # Excel 导出 (145 行, server-only): 列定义 + 行映射
|
||||
├─ retention.ts # 保留策略 (187 行, server-only): 配置/阈值/清理
|
||||
├─ types.ts # 类型定义 (186 行): 含保留天数常量
|
||||
├─ export.test.ts # Excel 导出单测 (210 行)
|
||||
├─ retention.test.ts # 保留策略单测 (93 行)
|
||||
├─ components/ # 13 个组件
|
||||
│ ├─ audit-log-view.tsx # 审计日志视图 (145 行)
|
||||
│ ├─ audit-log-table.tsx # 审计日志表格 (120 行)
|
||||
│ ├─ audit-log-detail-dialog.tsx # 详情对话框 (144 行)
|
||||
│ ├─ audit-log-export-button.tsx # 导出按钮 (87 行)
|
||||
│ ├─ login-log-view.tsx # 登录日志视图 (131 行)
|
||||
│ ├─ login-log-table.tsx # 登录日志表格 (114 行)
|
||||
│ ├─ data-change-log-view.tsx # 数据变更视图 (163 行)
|
||||
│ ├─ data-change-log-table.tsx # 数据变更表格 (151 行)
|
||||
│ ├─ audit-overview-view.tsx # 概览视图 (114 行, 服务端组件)
|
||||
│ ├─ audit-overview-stats-bar.tsx # 概览统计条 (59 行)
|
||||
│ ├─ audit-activity-trend-chart.tsx # 活动趋势图 (68 行)
|
||||
│ ├─ data-change-distribution-chart.tsx # 变更分布图 (74 行)
|
||||
│ └─ audit-retention-settings.tsx # 保留设置 (218 行)
|
||||
├─ hooks/
|
||||
│ └─ use-log-pagination.ts # 分页 Hook (28 行)
|
||||
└─ services/ # 服务抽象层
|
||||
├─ audit-service.tsx # 审计服务 (140 行)
|
||||
├─ admin-audit-service.ts # 管理员服务 (88 行)
|
||||
└─ mock-audit-service.ts # Mock 服务 (76 行, 开发用)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| shared | data-access + import | DB / cache / auth-guard / excel / logger / session |
|
||||
| settings | data-access-call | retention.ts 调用 getSystemSetting / upsertSystemSetting 读写配置 |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| 全部业务模块 | 间接(经 shared/lib/audit-logger) | 通过 `logAudit` / `logDataChange` 写入审计日志,避免直接依赖 audit 模块造成循环 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **三个权限点严格区分**: `AUDIT_LOG_READ`(读)、`AUDIT_RETENTION_MANAGE`(写配置)、`AUDIT_LOG_PURGE`(物理删除),读权限不授予任何写能力(G4-003/G4-004 修复)
|
||||
- **保留天数常量定义在 types.ts**: 避免客户端组件经 retention.ts 间接拉入 mysql2 / server-only,MIN=30 / MAX=3650 防误设 0 全量删除
|
||||
- **分页硬上限**: `MAX_PAGE_SIZE = 100` 经 `clampPageSize` 强制限制,防大数据量拖垮 DB
|
||||
- **data-access.ts / retention.ts / export.ts 首行 `import "server-only"`**: 防止客户端组件误导入服务端代码
|
||||
- **审计写入下沉 shared/lib**: 业务模块通过 `logAudit` / `logDataChange` 间接写入,避免直接依赖 audit 模块导致循环依赖
|
||||
- **三类日志保留期不同**: 审计日志默认 180 天,登录日志默认 365 天(支持安全取证)
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| 审计写入下沉到 shared/lib/audit-logger | 业务模块不直接依赖 audit 模块,避免循环依赖 |
|
||||
| retention 独立文件(非 data-access 内) | 保留策略与查询职责分离,便于定时任务调用与单测 |
|
||||
| 保留天数常量放 types.ts 而非 retention.ts | 客户端组件需读取默认值展示,放 retention.ts 会经 server-only 阻断 |
|
||||
| purge 使用独立 AUDIT_LOG_PURGE 权限 | 物理删除是破坏性操作,读权限不应授予删除能力 |
|
||||
| saveRetentionConfig 使用 AUDIT_RETENTION_MANAGE | 写配置属于管理操作,与读权限分离,符合最小权限原则 |
|
||||
| 导出操作记录 trackEvent | 导出敏感审计数据需留痕,形成"审计的审计" |
|
||||
| services/ 保留 service 抽象层 | 兼容 mock/开发环境切换,admin 与普通管理员能力隔离 |
|
||||
224
src/modules/auth/README.md
Normal file
224
src/modules/auth/README.md
Normal file
@@ -0,0 +1,224 @@
|
||||
# auth 认证模块
|
||||
|
||||
> 注册、登录、2FA 预检/校验、密码策略与泄露检测、邀请码消费,基于 NextAuth 但登录前不使用 requirePermission
|
||||
> 代码规模: 9 文件 · 40 符号 · 7,817 行(arch.db)
|
||||
> 模块路径: `src/modules/auth/`
|
||||
|
||||
---
|
||||
|
||||
## 架构图
|
||||
|
||||
```mermaid
|
||||
graph TB
|
||||
subgraph Client["客户端组件"]
|
||||
direction TB
|
||||
Layout["auth-layout.tsx<br/>(51 行)"]
|
||||
Login["login-form.tsx<br/>(263 行)"]
|
||||
Register["register-form.tsx<br/>(285 行)"]
|
||||
end
|
||||
|
||||
subgraph Server["服务端"]
|
||||
Actions["actions.ts<br/>(378 行, Server Actions)"]
|
||||
DA["data-access.ts<br/>(128 行, createUser/isEmailAvailable)"]
|
||||
end
|
||||
|
||||
subgraph Services["服务层(services/)"]
|
||||
LoginSvc["login-service.ts<br/>(214 行, 7 类职责)"]
|
||||
TwoFA["two-factor-service.ts<br/>(121 行, TOTP/备份码)"]
|
||||
end
|
||||
|
||||
subgraph NextAuth["NextAuth 集成"]
|
||||
AuthCfg["auth.ts(authorize 回调)<br/>薄包装调用 authenticateUser"]
|
||||
end
|
||||
|
||||
subgraph Shared["shared/lib"]
|
||||
RateLimit["rate-limit"]
|
||||
PwdPolicy["password-policy"]
|
||||
BreachedPwd["breached-password"]
|
||||
LoginLogger["login-logger / track-event"]
|
||||
end
|
||||
|
||||
subgraph DB["数据库表"]
|
||||
Users[(users)]
|
||||
Roles[(roles / users_to_roles)]
|
||||
PwdSec[(password_security)]
|
||||
end
|
||||
|
||||
Layout --> Login
|
||||
Layout --> Register
|
||||
Login --> Actions
|
||||
Register --> Actions
|
||||
Actions --> DA
|
||||
Actions --> Shared
|
||||
AuthCfg --> LoginSvc
|
||||
LoginSvc --> TwoFA
|
||||
LoginSvc --> Shared
|
||||
LoginSvc --> Users
|
||||
LoginSvc --> Roles
|
||||
LoginSvc --> PwdSec
|
||||
DA --> Users
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 核心流程图
|
||||
|
||||
### 流程 1: 邀请码注册
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 注册用户
|
||||
participant Form as register-form
|
||||
participant Act as actions.ts
|
||||
participant InvDA as invitation-codes DA
|
||||
participant DA as data-access.ts
|
||||
participant DB as MySQL
|
||||
|
||||
U->>Form: 填写注册信息 + 邀请码
|
||||
Form->>Act: registerAction
|
||||
Act->>Act: rateLimit(register:{ip})
|
||||
alt 超限
|
||||
Act-->>Form: RATE_LIMIT_EXCEEDED
|
||||
end
|
||||
Act->>Act: RegisterSchema.safeParse(含未成年人监护人校验)
|
||||
Act->>InvDA: validateInvitationCode(code, email)
|
||||
alt 无效/已用/过期
|
||||
Act-->>Form: INVITATION_CODE_INVALID
|
||||
end
|
||||
Act->>DA: isEmailAvailable(email)
|
||||
alt 邮箱已存在
|
||||
Act-->>Form: EMAIL_TAKEN
|
||||
end
|
||||
Act->>Shared: checkBreachedPassword
|
||||
alt 已泄露
|
||||
Act-->>Form: BREACHED_PASSWORD
|
||||
end
|
||||
Act->>InvDA: consumeInvitationCode(code, userId) 事务
|
||||
Act->>DA: createUser(bcrypt cost 12)
|
||||
DA->>DB: INSERT users + users_to_roles(默认 student)
|
||||
Act->>Shared: trackAuthEvent(signup)
|
||||
Act-->>Form: ActionState<RegisterResult>
|
||||
```
|
||||
|
||||
### 流程 2: 登录 + 2FA 预检
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant U as 用户
|
||||
participant NextAuth as auth.ts
|
||||
participant LS as login-service
|
||||
participant TwoFA as two-factor-service
|
||||
participant DB as MySQL
|
||||
|
||||
U->>NextAuth: authorize(credentials)
|
||||
NextAuth->>LS: authenticateUser(input)
|
||||
LS->>LS: rateLimit(login:{ip} + login:{email})
|
||||
LS->>DB: SELECT users WHERE email
|
||||
LS->>LS: isAccountLocked(password_security)
|
||||
LS->>LS: bcrypt.compare + recordFailedLogin
|
||||
LS->>TwoFA: preflightTwoFactorByEmail(email)
|
||||
alt 启用 2FA
|
||||
TwoFA-->>LS: 需要 totpCode
|
||||
LS-->>NextAuth: 返回 2FA required 信号
|
||||
NextAuth-->>U: 二次输入 TOTP/备份码
|
||||
U->>NextAuth: totpCode
|
||||
NextAuth->>TwoFA: verifyTwoFactorForLogin
|
||||
end
|
||||
LS->>LS: resetFailedLogin + resetRateLimit
|
||||
LS->>Shared: logLoginEvent(success)
|
||||
LS-->>NextAuth: AuthenticatedUser
|
||||
NextAuth-->>U: session 建立
|
||||
```
|
||||
|
||||
### 流程 3: 注册错误归一化
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A[原始错误] --> B{classifyRegisterError}
|
||||
B -->|DEFAULT_ROLE_NOT_FOUND| C[DEFAULT_ROLE_MISSING]
|
||||
B -->|duplicate/unique/er_dup_entry| D[EMAIL_TAKEN]
|
||||
B -->|其他| E[UNKNOWN]
|
||||
C --> F[messageForError i18n]
|
||||
D --> F
|
||||
E --> F
|
||||
F --> G[返回 ActionState]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 类别 | 技术 | 用途 |
|
||||
|------|------|------|
|
||||
| 认证框架 | NextAuth.js | session/JWT 管理,authorize 回调薄包装 |
|
||||
| 密码哈希 | bcryptjs(cost 12) | 注册时哈希,登录时 compare |
|
||||
| 速率限制 | rateLimit + rateLimitKey | IP + email 双维度(register/login 独立规则) |
|
||||
| 密码策略 | password-policy | PASSWORD_RULES + isAccountLocked |
|
||||
| 泄露检测 | breached-password | checkBreachedPassword 防止常用弱密码 |
|
||||
| 2FA | TOTP + 备份码 | two-factor-service 处理二次验证 |
|
||||
| 校验 | Zod | RegisterSchema(含未成年人监护人条件) |
|
||||
| 国际化 | next-intl/server | 错误码 → i18n key 翻译 |
|
||||
| 审计 | trackAuthEvent + logLoginEvent | 8 类事件(signup/signin_failure/rate_limited/...) |
|
||||
| 服务端隔离 | import "server-only" | data-access 与 services 防止客户端引用 |
|
||||
|
||||
---
|
||||
|
||||
## 文件结构
|
||||
|
||||
```
|
||||
src/modules/auth/
|
||||
├─ actions.ts # Server Actions (378 行): 注册/邮箱可用性
|
||||
├─ data-access.ts # 数据访问层 (128 行): createUser/isEmailAvailable
|
||||
├─ schema.ts # Zod 验证 (131 行, 含未成年人校验)
|
||||
├─ types.ts # 类型定义 (65 行): RegisterResult/REGISTER_ERROR_CODES
|
||||
├─ services/
|
||||
│ ├─ login-service.ts # 登录服务 (214 行, 7 类职责拆分)
|
||||
│ └─ two-factor-service.ts # 2FA 服务 (121 行, TOTP/备份码)
|
||||
├─ components/
|
||||
│ ├─ auth-layout.tsx # 认证布局 (51 行)
|
||||
│ ├─ login-form.tsx # 登录表单 (263 行)
|
||||
│ └─ register-form.tsx # 注册表单 (285 行)
|
||||
└─ README.md
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 依赖关系
|
||||
|
||||
### 依赖(本模块 → 他模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| invitation-codes | data-access-call(运行时) | validateInvitationCode + consumeInvitationCode |
|
||||
| classes | data-access-call | 注册时班级加入(预留) |
|
||||
| settings | import | 设置页相关 |
|
||||
| shared | import | DB / rate-limit / password-policy / breached-password / NextAuth |
|
||||
|
||||
### 被依赖(他模块 → 本模块)
|
||||
|
||||
| 模块 | 依赖类型 | 用途 |
|
||||
|------|---------|------|
|
||||
| (无) | — | auth 是认证入口,仅 NextAuth 中间件与页面层调用 |
|
||||
|
||||
---
|
||||
|
||||
## 关键约束
|
||||
|
||||
- **不使用 requirePermission**: 登录前无会话,注册/登录走 rateLimit + Zod 校验,2FA 走服务层逻辑
|
||||
- **bcrypt cost 固定 12**: 禁止降低,密码哈希强度不可妥协
|
||||
- **邮箱可用性只返回布尔**: `isEmailAvailable` 仅返回 `{ available: boolean }`,避免邮箱枚举攻击
|
||||
- **错误归一化 classifyRegisterError**: 不向客户端暴露 SQL 文案/表名/字段名,统一映射为 RegisterErrorCode
|
||||
- **速率限制双维度独立**: register:{ip} 与 login:{ip} + login:{email} 独立规则,避免互相影响
|
||||
- **services 层 import "server-only"**: login-service / two-factor-service 防止客户端引用,且通过动态 import 避免 edge runtime 冲突
|
||||
|
||||
---
|
||||
|
||||
## 架构决策
|
||||
|
||||
| 决策 | 原因 |
|
||||
|------|------|
|
||||
| service 层拆分(login-service/two-factor-service) | authorize 回调原 7 类职责混合,拆分后 actions.ts 仅编排,可独立单测 |
|
||||
| 邀请码消费下沉到 invitation-codes data-access | 跨模块事务消费,避免 auth 直接操作 invitation_codes 表 |
|
||||
| REGISTER_ERROR_CODES 定义在 types.ts | 客户端组件可直接导入,避免 "use server" 文件导出非 async 值(Next.js 16 规则) |
|
||||
| 错误归一化屏蔽 SQL 文案 | 防止 duplicate/unique/er_dup_entry 等原始信息泄露邮箱是否存在 |
|
||||
| 2FA 预检 preflightTwoFactorByEmail | 登录第一步决定是否需要 2FA,避免无谓的密码校验后再跳转 |
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user