1 增加首钢用户和组织同步接口
2 增加cursor的rules和skill
This commit is contained in:
binfeng
2026-06-17 18:28:05 +08:00
parent 7df3d34950
commit e79d572e0f
43 changed files with 4054 additions and 0 deletions
+63
View File
@@ -0,0 +1,63 @@
# Cursor Configuration
BiSheng 项目的 Cursor Agent 配置,从 `.claude/` 迁移而来。
## 目录结构
```
.cursor/
├── hooks.json # 文件编辑后自动触发 ruff + arch-guard
├── hooks/
│ ├── ruff-format.sh # Python 自动格式化(对应 Claude PostToolUse
│ └── arch-guard.sh # 架构守卫包装脚本
├── rules/ # Cursor Rules (.mdc)
│ ├── bisheng-core.mdc
│ ├── backend.mdc
│ ├── platform-frontend.mdc
│ └── client-frontend.mdc
└── skills/ # Agent Skills(从 .claude/skills/ 同步)
├── code-review/
├── sdd-review/
├── task-review/
├── e2e-test/
├── i18n-localizer/
└── react-component-refactor/
```
## 与 Claude Code 的对应关系
| Claude Code | Cursor |
|-------------|--------|
| `AGENTS.md` | `.cursor/rules/bisheng-core.mdc` (alwaysApply) |
| `.claude/rules/platform-frontend.md` | `.cursor/rules/platform-frontend.mdc` |
| `.claude/rules/client-frontend.md` | `.cursor/rules/client-frontend.mdc` |
| `src/backend/AGENTS.md` | `.cursor/rules/backend.mdc` |
| `.claude/settings.json` PostToolUse hooks | `.cursor/hooks.json` afterFileEdit |
| `.claude/skills/` | `.cursor/skills/` |
## Skills 用法
在 Cursor Agent 对话中使用斜杠命令或直接描述任务:
- `/sdd-review features/v2.5.0/004-rebac-core spec`
- `/task-review features/v2.5.0/004-rebac-core T003`
- `/code-review --base 2.5.0-PM`
- `/e2e-test features/v2.5.0/004-rebac-core`
- `/i18n-localizer` — 国际化模块
- `/react-component-refactor` — 重构大型 React 组件
## 同步维护
更新 `.claude/` 后,需手动同步到 `.cursor/`
```bash
# 同步 skills
cp -R .claude/skills/* .cursor/skills/
# rules 需按 .claude/rules/*.md 和 AGENTS.md 手动更新 .cursor/rules/*.mdc
```
## Hooks 前置条件
- Python 文件编辑后自动 ruff:需要 `src/backend/.venv` 或系统安装 `uv`
- arch-guard:依赖 `scripts/arch-guard.sh`(项目根目录)
+13
View File
@@ -0,0 +1,13 @@
{
"version": 1,
"hooks": {
"afterFileEdit": [
{
"command": ".cursor/hooks/ruff-format.sh"
},
{
"command": ".cursor/hooks/arch-guard.sh"
}
]
}
}
+12
View File
@@ -0,0 +1,12 @@
#!/bin/bash
# Architecture guard hook wrapper (calls scripts/arch-guard.sh)
set -euo pipefail
input=$(cat)
FILE=$(echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('file_path','') or d.get('path','') or '')" 2>/dev/null || true)
[ -z "$FILE" ] && exit 0
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
bash "$ROOT/scripts/arch-guard.sh" "$FILE"
exit 0
+25
View File
@@ -0,0 +1,25 @@
#!/bin/bash
# Auto-format Python files after edit (mirrors .claude PostToolUse ruff hook)
set -euo pipefail
input=$(cat)
FILE=$(echo "$input" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('file_path','') or d.get('path','') or '')" 2>/dev/null || true)
[ -z "$FILE" ] && exit 0
[ ! -f "$FILE" ] && exit 0
echo "$FILE" | grep -q '\.py$' || exit 0
ROOT="$(cd "$(dirname "$0")/../.." && pwd)"
BACKEND="$ROOT/src/backend"
cd "$BACKEND"
if [ -f .venv/bin/ruff ]; then
.venv/bin/ruff format "$FILE" 2>/dev/null || true
.venv/bin/ruff check --fix "$FILE" 2>/dev/null || true
elif command -v uv >/dev/null 2>&1; then
uv run ruff format "$FILE" 2>/dev/null || true
uv run ruff check --fix "$FILE" 2>/dev/null || true
fi
exit 0
+33
View File
@@ -0,0 +1,33 @@
---
description: BiSheng 后端开发规则 — DDD、错误处理、命令
globs: src/backend/**
alwaysApply: false
---
# Backend Development Rules
Full reference: `src/backend/AGENTS.md`. P0 rules in root `AGENTS.md`.
## Error Handling
Never silently swallow exceptions. Use `logger.exception(...)` and re-raise or raise `BaseErrorCode` subclass. Don't launder exceptions through `resp_500(message=str(e))`.
## Key Conventions
- Repository interfaces: `domain/repositories/interfaces/` extend `BaseRepository`
- Implementations: `domain/repositories/implementations/` extend `BaseRepositoryImpl`
- Schema changes: Alembic migrations in `bisheng/core/database/alembic/versions`
- Business errors: `bisheng/common/errcode/` as `BaseErrorCode` subclasses
- Sessions: dependency injection or `@db_session` — no ad-hoc sessions
## Commands (cwd: `src/backend/`)
```bash
uv sync --frozen
uv run pytest test/<module>/test_xxx.py -v
uv run ruff format <file> && uv run ruff check --fix <file>
export config=config.yaml && uv run uvicorn bisheng.main:app --host 0.0.0.0 --port 7860
uv run alembic upgrade head
```
New tests under `test/<module>/`, not `test/` root. `asyncio_mode=auto`.
+67
View File
@@ -0,0 +1,67 @@
---
description: BiSheng 项目核心 P0 规则 — 架构、权限、SDD 工作流
alwaysApply: true
---
# BiSheng Core Rules
**BiSheng (毕昇)** — Enterprise LLM application DevOps platform. Monorepo:
| Path | Stack |
|------|-------|
| `src/backend/` | FastAPI + Celery + Linsight Worker, Python 3.10+, uv, SQLModel |
| `src/frontend/platform/` | Vite 5 + Zustand + react-query v3 + bs-ui |
| `src/frontend/client/` | Vite 6 + Recoil + react-query v5 + shadcn/ui |
## Commands
```bash
cd src/frontend/platform && npm install && npm start -- --host 0.0.0.0 # :3001
cd src/frontend/client && npm install && npm run dev # :4001
bash docker/local-dev/start-middleware.sh # MySQL/Redis/Milvus/ES/MinIO/OpenFGA
```
Backend commands → `src/backend/AGENTS.md`.
## Backend P0
**DDD call chain (never skip):** `Router → Endpoint → Service → Repository → DB`
- Never `import bisheng.database.models.*` in endpoints
- Never write ORM queries in Service; no new DAO entry points for new features
- New module: `<module>/{api/router.py, api/endpoints/, domain/services/, domain/models/, domain/schemas/, domain/repositories/}`
- Register router in `bisheng/api/router.py`
**Dual-DB (MySQL + DM8):** Use `dialect_helpers.JsonType`, `LargeText`, `UPDATE_TIME_SERVER_DEFAULT`. Never `sqlalchemy.JSON`, `LONGTEXT`, `information_schema`, `JSON_EXTRACT`.
**Multi-tenancy:** Never write `WHERE tenant_id = X` manually — SQLAlchemy events auto-inject.
**Permissions:** Always use `PermissionService.check()` / `PermissionService.authorize()`. Never query `role_access` for authorization.
**API:** `UserPayload = Depends(UserPayload.get_login_user)`, `resp_200` / `resp_500`. Error codes: 5-digit `MMMEE` in `common/errcode/`.
## Frontend P0
Two React apps **must not be mixed**. Platform uses Zustand + `@/`; Client uses Recoil + `~/`. Never import axios directly. Single file ≤ 600 lines. 403 handled by interceptors — never add 403 branches.
## Architecture Guard
`scripts/arch-guard.sh` runs after file edits (`.cursor/hooks.json`). VIOLATION rules must be fixed immediately.
## SDD Workflow (non-trivial features)
```
spec.md → /sdd-review spec → tasks.md → /sdd-review tasks → implement → /task-review → /e2e-test → /code-review
```
Artifacts: `features/v{X.Y.Z}/{NNN}-{name}/`. Pause points cannot be skipped.
## Skills
`/sdd-review`, `/task-review`, `/code-review`, `/e2e-test`, `/i18n-localizer`, `/react-component-refactor`
## Common Pitfalls
- MinIO 403: Vite `fileServiceTarget` must match `config.yaml` `object_storage.minio.sharepoint`
- `BISHENG_PRO=true` must be set before backend start for SSO endpoint
- DB config changes: 100s Redis TTL — flush Redis after changes
+34
View File
@@ -0,0 +1,34 @@
---
description: Client 前端开发规则 (src/frontend/client)
globs: src/frontend/client/**
alwaysApply: false
---
# Client Frontend (src/frontend/client/)
## Tech Stack
Vite + React 18 + TypeScript + TailwindCSS 3 + Radix UI (shadcn/ui) + Recoil + react-i18next + react-router-dom v6 + lucide-react
## Mandatory Rules
- TypeScript only (`.ts` / `.tsx`); functional components only
- Path alias: `~/` (or `@/`) → `src/`
- HTTP: `~/api/request.ts` only — never import axios directly
- State: Recoil (`~/store/`) only — no new state libraries
- UI: `~/components/ui/` (shadcn) — no new UI libraries
- Single file ≤ 600 lines
- Toast: `showToast?.({ message, severity: 'error'|'success' })`
- i18n: `useLocalize()` → `localize()`. Locales: `src/locales/{en,zh-Hans,ja}/translation.json`
## Coding Style
- `interface` for Props; `type` for internal types
- `handleXxx` internal; `onXxx` for props
- Named exports, no default exports for components
- Comments in English
## Pitfalls
- 403: handled by interceptor with redirection — no manual handling in business code
- i18n keys: nested namespace format (see `/i18n-localizer` skill)
+43
View File
@@ -0,0 +1,43 @@
---
description: Platform 前端开发规则 (src/frontend/platform)
globs: src/frontend/platform/**
alwaysApply: false
---
# Platform Frontend (src/frontend/platform/)
## Tech Stack
Vite + React 18 + TypeScript + TailwindCSS 3 + Radix UI (bs-ui) + Zustand + React Context + react-i18next + react-router-dom v6 + @xyflow/react
## Mandatory Rules
- TypeScript only (`.ts` / `.tsx`); functional components only
- Path alias: `@/` → `src/`
- HTTP: `@/controllers/request.ts` only — never import axios directly. API in `@/controllers/API/`
- State: Zustand (`@/store/`) for cross-page; Context (`@/contexts/`) for UI-scoped
- UI: `@/components/bs-ui/`; icons from `@/components/bs-icons/`
- Single file ≤ 600 lines
- Toast: `toast({ title, variant: 'error'|'success', description })`
- Confirm: `bsConfirm(...)` from bs-ui
- i18n: `useTranslation()` → `t()`. Locales: `public/locales/{en-US,zh-Hans,ja}/{ns}.json`
## Coding Style
- `interface` for Props; `type` for internal types
- `handleXxx` internal; `onXxx` for props
- Named exports, no default exports for components
- Comments in English
## API Pattern
```typescript
import axios from "@/controllers/request"
import { captureAndAlertRequestErrorHoc } from "@/controllers/request"
captureAndAlertRequestErrorHoc(getSomething()).then(res => { ... })
```
## Pitfalls
- 403: handled by interceptor — no manual handling
- MinIO images: Vite `fileServiceTarget` must match backend `config.yaml` sharepoint
+142
View File
@@ -0,0 +1,142 @@
---
name: code-review
description: >-
L2 特性级多维度代码审查。Feature 全部任务完成后执行。
用法:/code-review --base <branch>。
当用户说"代码审查"、"code review",或使用 /code-review 命令时触发。
---
# Code Review (L2)
## 描述
L2 特性级多维度代码审查。Feature 全部任务完成后执行。
## 触发
```
/code-review --base 2.5.0-PM
```
---
## 审查流程
1. 执行 `git diff 2.5.0-PM...HEAD --stat` 获取变更文件列表
2. 执行 `git diff 2.5.0-PM...HEAD` 获取完整 diff
3. 对照 Feature 的 `spec.md``tasks.md`
4. 按 6 维度逐一审查
5. 输出审查报告
---
## 6 维度审查框架
### 维度 1:边界条件
| 检查项 | 说明 |
|--------|------|
| null/None 处理 | 外部输入是否校验 None/空字符串 |
| 空集合 | 列表/字典为空时是否正确处理(不抛异常) |
| 数值边界 | 分页 page/size 合法性、ID 为 0/-1 |
| 字符串长度 | 数据库字段长度限制是否在 API 层校验 |
| 超时处理 | 外部调用(LLM/MCP/HTTP)是否设置超时 |
| 分页溢出 | 请求超出总页数时返回空列表而非错误 |
### 维度 2:权限与认证
| 检查项 | 说明 |
|--------|------|
| 认证注入 | 需要认证的端点是否使用 `UserPayload = Depends(UserPayload.get_login_user)` |
| 五级权限链路 | 是否遵循:super_admin → tenant 归属 → tenant admin → ReBAC → RBAC 菜单 |
| PermissionService | 权限检查是否走 `PermissionService.check()` 而非直接查旧表 |
| 资源授权 | 创建资源时是否调用 `PermissionService.authorize()` 写入 owner 元组 |
| tenant_id 隔离 | 跨租户访问是否被阻止(SQLAlchemy event 自动注入) |
| WebSocket 认证 | WS 端点是否使用 `UserPayload.get_login_user_from_ws` |
### 维度 3:并发安全
| 检查项 | 说明 |
|--------|------|
| OpenFGA 双写 | MySQL + OpenFGA 写入是否有失败补偿(failed_tuples 表) |
| 数据库事务 | 多表写入是否在同一事务内 |
| Celery 幂等 | 异步任务是否支持重试不产生副作用 |
| 竞态条件 | 并发创建同名资源是否有唯一约束或乐观锁 |
| 会话状态 | Redis 缓存读写是否考虑过期和并发更新 |
### 维度 4:信息泄漏
| 检查项 | 说明 |
|--------|------|
| 硬编码敏感信息 | 代码中无明文密码/密钥/token |
| 错误信息 | 异常响应不暴露堆栈/SQL/内部路径 |
| 日志脱敏 | logger 输出中敏感字段已脱敏 |
| 前端暴露 | 前端代码不包含后端 IP/密钥/内部 API 路径 |
| tenant_id 泄漏 | API 响应不向前端返回其他租户的 tenant_id |
### 维度 5:测试覆盖
| 检查项 | 说明 |
|--------|------|
| Service 测试 | 核心 Service 方法有单元测试(mock DAO |
| API 测试 | 新端点有集成测试(happy path + 主要 error path |
| AC 覆盖 | spec 中每条 AC 都有对应测试或手动验证 |
| 错误路径 | 权限拒绝、参数校验失败等错误路径有测试 |
| 测试质量 | mock 合理,不 mock 掉核心逻辑 |
> **务实适配**:当前测试基础薄弱,降低阈值但要求核心 Service 方法必须有测试。
> 前端暂用手动验证替代(tasks.md 中有「手动验证」描述即可)。
### 维度 6:代码风格
| 检查项 | 说明 |
|--------|------|
| DDD 分层 | 新代码在正确的层级(domain/services vs api/endpoints |
| 命名一致 | DAO/Service/错误码命名遵循项目约定 |
| 代码重复 | 无复制粘贴式重复逻辑(应提取到 Service 或工具函数) |
| 未使用代码 | 无 dead code、注释掉的代码块、空函数 |
| 格式化 | Python 代码通过 ruff checkhook 自动处理) |
---
## 判定规则
| 结果 | 条件 | 动作 |
|------|------|------|
| **PASS** | 无 HIGH 或 MEDIUM | 可合并 |
| **PASS_WITH_WARNINGS** | 仅 MEDIUM 级 | 可合并,记录待改进 |
| **NEEDS_FIX** | 有 HIGH 级 | 修复后重审(最多 2 轮) |
---
## 输出格式
```markdown
# Code Review Report
**Feature**: <feature_name>
**Review scope**: <描述>
**Base branch**: 2.5.0-PM
**Changed files**: <数量>
## Summary
| Dimension | High | Medium | Low | Status |
|-----------|------|--------|-----|--------|
| Boundary Conditions | 0 | 0 | 0 | PASS |
| Permission & Auth | 0 | 0 | 0 | PASS |
| Concurrency Safety | 0 | 0 | 0 | PASS |
| Information Leakage | 0 | 0 | 0 | PASS |
| Test Coverage | 0 | 0 | 0 | PASS |
| Code Style | 0 | 0 | 0 | PASS |
## Findings(如有)
### HIGH
- [Permission] `xxx_endpoint.py:42` — 缺少 PermissionService.check() 调用
### MEDIUM
- [Style] `xxx_service.py:18` — DAO 方法未使用 @classmethod
## Overall: PASS / PASS_WITH_WARNINGS / NEEDS_FIX
```
+251
View File
@@ -0,0 +1,251 @@
---
name: e2e-test
description: >-
为 BiSheng 生成和运行 E2E 测试。两种模式:
(1) SDD 模式 — 基于 feature spec.md 的 AC 生成覆盖;
(2) 自由模式 — 对指定页面/功能写测试。
采用双层策略:API 端到端测试(pytest + httpx)+ 页面手动验证清单。
自动处理认证、多租户隔离、权限检查、Radix UI 交互等常见问题。
用法:/e2e-test [feature_dir] 或 /e2e-test <描述>
当用户说"写 E2E 测试"、"端到端测试"、"E2E coverage"
或使用 /e2e-test 命令时触发。
---
# E2E Test Skill
## 概述
生成并运行 BiSheng 的 E2E 测试,覆盖 API 链路和 UI 交互流程。自动处理 JWT 认证、多租户数据隔离、OpenFGA 权限检查验证、UnifiedResponseModel 响应断言等 BiSheng 特有问题。
## 调用方式
```
/e2e-test <feature_dir> # SDD 模式:基于 spec.md AC 生成
/e2e-test <描述> # 自由模式:对指定页面/功能写测试
```
示例:
```
/e2e-test features/v2.5.0/004-rebac-core
/e2e-test 为租户管理页面写创建流程测试
```
---
## 六步流程
### Step 1:模式识别
解析用户参数:
- **SDD 模式**:参数是 `features/` 开头的路径 → 读取该目录下的 `spec.md`
- **自由模式**:参数是自由文本描述 → 直接进入 Step 3
### Step 2AC 分析(仅 SDD 模式)
读取 `<feature_dir>/spec.md`,从 AC 表格中分类:
**API 行为类**(自动化 pytest 测试):
- CRUD 操作及响应格式
- 权限检查(允许/拒绝)
- 分页、过滤、排序
- 错误码返回(MMMEE
- 跨租户访问拒绝
**UI 交互类**(手动验证清单):
- 表单填写、按钮点击
- 列表展示、搜索过滤
- 弹窗/抽屉交互
- 路由跳转
- 权限控制(按钮隐藏/禁用)
**排除**
- 纯样式/布局调整
- 纯内部状态逻辑
输出分类后的 AC 列表,作为测试用例依据。
### Step 3:基础设施检查
检查共享 helpers 是否存在:
```
src/backend/test/e2e/
├── conftest.py # pytest fixtures(认证、client、cleanup
├── helpers/
│ ├── __init__.py
│ ├── auth.py # JWT 认证 + 用户创建
│ ├── api.py # API 常量 + 通用 CRUD helpers
│ └── cleanup.py # 数据隔离 + 安全 cleanup
└── test_e2e_xxx.py # 各 Feature 的测试文件
```
如果不存在,按照 `references/test-template.md` 创建基础设施。
如果需要新增共享函数,先加到对应的 helpers 文件中。
### Step 4:生成测试
基于 `references/test-template.md` 生成测试文件。
**文件命名**`src/backend/test/e2e/test_e2e_{feature_name}.py`
**强制生成规则(12 条)**
1. **数据隔离(红线)**:测试数据统一 `e2e-{feature}-` 前缀(≥5 字符)。**禁止无条件删除所有资源**——cleanup 必须按前缀过滤,只删本套件创建的数据。E2E 运行前后,非测试数据必须保持不变
2. **双重 cleanup**setup fixture 清理上次残留 + teardown 清理本次数据
3. **测试租户隔离**:使用专用 `test_tenant_id`,不影响正式租户数据。创建测试数据前先确保测试租户存在
4. **认证流程**:通过 helpers 获取 JWT token,注入到请求 headers。测试管理员和普通用户两种角色
5. **响应格式断言**:所有 API 响应必须断言 `UnifiedResponseModel` 格式(`status_code`, `status_message`, `data`
6. **权限测试配对**:每个"允许"操作配对一个"拒绝"测试(不同角色/不同租户)
7. **共享 helpers**:导入 `test/e2e/helpers/` 的函数,**禁止在测试文件内重新定义**通用工具函数
8. **AC 追溯**:每个测试方法的 docstring 标注 `AC-NN: <描述>`
9. **API 验证**:数据变更操作后,通过 GET 请求断言最终状态(不仅依赖创建响应)
10. **错误码精确断言**:业务错误断言具体的 MMMEE 错误码,不仅检查非 200
11. **串行执行**:使用 pytest-ordering 或 class 内方法顺序保证 setup → tests → cleanup
12. **幂等性**:测试可重复运行,不依赖特定的数据库状态(除测试自己创建的数据)
### Step 5:运行与修复
运行生成的测试:
```bash
cd src/backend
.venv/bin/pytest test/e2e/test_e2e_{feature_name}.py -v
```
如果失败,按照 `references/common-pitfalls.md` 的诊断表定位问题。
**最多 3 轮修复**。如果 3 轮后仍有失败,输出剩余问题让用户决定。
**调试技巧**
```bash
# 单个测试
.venv/bin/pytest test/e2e/test_e2e_{feature}.py::TestE2E{Feature}::test_ac01 -v -s
# 显示完整请求/响应
.venv/bin/pytest test/e2e/test_e2e_{feature}.py -v -s --log-cli-level=DEBUG
# 只运行失败的
.venv/bin/pytest test/e2e/test_e2e_{feature}.py --lf -v
```
### Step 6:覆盖报告
输出 AC 覆盖表:
```markdown
# E2E 覆盖报告: <feature_name>
## API 测试结果
| AC-ID | 描述 | 状态 | 测试方法 |
|-------|------|------|---------|
| AC-01 | 创建租户成功 | ✅ 通过 | test_ac01_create_tenant |
| AC-02 | 重复租户名拒绝 | ✅ 通过 | test_ac02_duplicate_name |
| AC-05 | 表单提交创建 | ⏭️ 跳过(UI 交互,见手动清单) | — |
通过: N/M | 跳过: KUI 交互类)| 失败: J
## 手动验证清单
生成位置: `features/v2.5.0/{NNN}-{name}/e2e-checklist.md`
覆盖 AC: AC-05, AC-06, ...
## 整体状态: PASS / PARTIAL / FAIL
```
---
## 手动验证清单格式
当 AC 涉及 UI 交互时,生成结构化验证清单。
**文件位置**`features/v2.5.0/{NNN}-{name}/e2e-checklist.md`
```markdown
# E2E 验证清单: {feature_name}
**测试环境**: http://192.168.106.114:4001 (Platform) / :4001/workspace (Client)
**前置条件**: <描述测试前需要的数据/账号>
## Platform 前端
### AC-05: <描述>
- [ ] 步骤 1: 以管理员登录 Platform (admin/admin123)
- [ ] 步骤 2: 导航到 <页面路径>
- [ ] 步骤 3: 点击 <按钮/元素>
- [ ] 步骤 4: 填写表单: <字段=值>
- [ ] 预期: <具体可观察结果,如 toast 提示、列表刷新>
- [ ] 验证: 刷新页面后数据仍存在
### AC-06: <错误场景描述>
- [ ] 步骤: <触发错误的操作>
- [ ] 预期: <错误提示内容>
## Client 前端(如适用)
### AC-07: <描述>
- [ ] ...
## 回归检查
- [ ] 相关页面(<列出>)正常加载,无 console 错误
- [ ] 既有功能(<列出>)不受影响
- [ ] 不同角色(管理员/普通用户)看到的内容符合权限设定
```
---
## 参考文件
生成测试前**必须阅读**以下参考文件:
| 文件 | 用途 | 何时阅读 |
|------|------|---------|
| `references/test-template.md` | pytest E2E 测试骨架模板 | 生成新测试文件时 |
| `references/common-pitfalls.md` | BiSheng E2E 常见陷阱诊断表 | 测试失败时 |
---
## 已有共享 Helpers 清单
> 首次运行时由 Step 3 自动创建。以下是目标结构。
### `test/e2e/helpers/auth.py`
| 函数 | 签名 | 用途 |
|------|------|------|
| `get_admin_token` | `(client) -> str` | 获取管理员 JWT token |
| `get_user_token` | `(client, username, password) -> str` | 获取指定用户 JWT token |
| `create_test_user` | `(client, admin_token, username, role_id) -> dict` | 创建测试用户 |
| `auth_headers` | `(token) -> dict` | 构建认证请求头 |
### `test/e2e/helpers/api.py`
| 导出 | 用途 |
|------|------|
| `API_BASE` | 后端 API 基础 URL 常量 (`http://localhost:7860/api/v1`) |
| `assert_resp_200(resp)` | 断言 UnifiedResponseModel 成功响应 |
| `assert_resp_error(resp, code)` | 断言 UnifiedResponseModel 错误码 |
| `create_resource(client, path, data, token)` | 通用 POST 创建 |
| `list_resources(client, path, token, params)` | 通用 GET 列表 |
| `delete_resource(client, path, resource_id, token)` | 通用 DELETE |
### `test/e2e/helpers/cleanup.py`
| 函数 | 用途 |
|------|------|
| `cleanup_by_prefix(client, path, prefix, token)` | 安全删除指定前缀的资源。**前缀必须 ≥5 字符**,否则抛错防止误删 |
| `ensure_test_tenant(client, admin_token, tenant_code)` | 确保测试租户存在(不存在则创建) |
---
## 新增 Helper 的规则
当测试需要新的共享函数时:
1. **认证相关** → 加到 `helpers/auth.py`
2. **API 请求/断言** → 加到 `helpers/api.py`
3. **数据管理/fixtures** → 加到 `helpers/cleanup.py`
4. **特定 feature 的 helper** → 留在测试文件内,不提取
提取标准:**2 个以上测试文件使用** → 提取到 helpers。
@@ -0,0 +1,216 @@
# BiSheng E2E 测试常见陷阱与诊断修复
## 陷阱 1:业务错误 HTTP 200
**症状**`assert resp.status_code == 400` 失败,实际收到 200。
**原因**BiSheng 的 `UnifiedResponseModel` 将业务错误包装在 HTTP 200 响应体中,通过 `status_code` 字段区分。
**修复**
```python
# ❌ BiSheng 业务错误也返回 HTTP 200
assert resp.status_code == 400
# ✅ 检查响应体中的 status_code
body = resp.json()
assert body["status_code"] == 10901 # 具体 MMMEE 错误码
assert body["status_message"] != "SUCCESS"
```
---
## 陷阱 2:认证 Token 获取失败
**症状**:登录 API 返回错误,或后续请求 401。
**原因**BiSheng 登录密码需要 RSA 加密。前端从 `/api/v1/user/public_key` 获取公钥后加密。
**修复**
```python
# ✅ 先获取公钥,再加密密码
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import padding
resp = await client.get("/user/public_key")
public_key_pem = resp.json()["data"]["public_key"]
# 加密密码
public_key = serialization.load_pem_public_key(public_key_pem.encode())
encrypted = public_key.encrypt(password.encode(), padding.PKCS1v15())
encrypted_password = base64.b64encode(encrypted).decode()
# 登录
resp = await client.post("/user/login", json={
"user_name": username,
"password": encrypted_password,
})
```
**建议**:将此逻辑封装在 `helpers/auth.py` 中,测试文件直接调用 `get_admin_token()`
---
## 陷阱 3:tenant_id 自动注入导致测试数据不可见
**症状**:创建了数据但 GET 列表查不到。
**原因**SQLAlchemy event 自动注入 `tenant_id` 过滤,测试用户的 tenant_id 与数据不匹配。
**修复**
```python
# ✅ 确保测试用户属于正确的租户
# 1. 创建测试租户
# 2. 将测试用户加入该租户
# 3. 用该用户的 token 创建和查询数据
# ❌ 不要试图绕过 tenant_id(那是安全底线)
```
---
## 陷阱 4OpenFGA 权限未同步
**症状**:创建资源后,同一用户立即查询却被权限拒绝。
**原因**:资源创建时应同步写入 OpenFGA owner 元组,如果 `PermissionService.authorize()` 调用失败或遗漏,用户虽然创建了资源但没有 owner 权限。
**修复**
```python
# ✅ 创建后验证权限元组已写入
resp = await client.post("/resource", json={...}, headers=admin_headers)
data = assert_resp_200(resp)
# 紧接着用同一用户查询,应该能看到
get_resp = await client.get(f"/resource/{data['id']}", headers=admin_headers)
assert_resp_200(get_resp) # 如果失败,说明 OpenFGA 元组没写入
```
---
## 陷阱 5cleanup 顺序错误
**症状**`DELETE /resource/{id}` 返回错误,因为有关联数据未先删除。
**原因**:BiSheng 资源间有关联关系(如知识库→文件、助手→工具/技能/知识库),删除有顺序要求。
**修复**
```python
# ✅ 正确的 cleanup 顺序(依赖关系逆序)
async def cleanup_feature_data(client, token, prefix):
headers = auth_headers(token)
# 1. 先删除依赖方(如关联表、子资源)
# 2. 再删除主资源
# 3. 最后清理 OpenFGA 元组(如有直接操作的话)
# 示例:删除知识库
# 先删知识库文件 → 再删知识库空间
# ❌ 不要假设可以直接删除主资源
```
---
## 陷阱 6:Celery 异步任务未完成就断言
**症状**:创建知识库文件后立即查询,状态还是 `WAITING` 而非 `SUCCESS`
**原因**:文件处理通过 Celery `knowledge_celery` 队列异步执行,创建 API 返回后任务可能还在处理。
**修复**
```python
# ✅ 轮询等待异步任务完成
import asyncio
async def wait_for_status(client, path, token, expected_status, timeout=30):
headers = auth_headers(token)
for _ in range(timeout):
resp = await client.get(path, headers=headers)
data = resp.json()["data"]
if data["status"] == expected_status:
return data
await asyncio.sleep(1)
raise TimeoutError(f"Status not reached: {expected_status}")
# 使用
data = await wait_for_status(
client, f"/knowledge_file/{file_id}",
admin_token, expected_status=2 # SUCCESS
)
```
---
## 陷阱 7:分页参数不一致
**症状**:列表查询返回的数据数量不对。
**原因**BiSheng 不同 API 的分页参数名称可能不同(`page`/`page_num``limit`/`page_size`/`size`)。
**修复**
```python
# ✅ 先查看 API 文档确认参数名
# 常见模式:
resp = await client.get("/resource", params={
"page": 1, # 或 page_num
"limit": 10, # 或 page_size 或 size
}, headers=headers)
# ✅ 响应分页格式(PageData)
data = resp.json()["data"]
items = data["data"] # 列表数据
total = data["total"] # 总数
```
---
## 陷阱 8WebSocket 测试
**症状**:WebSocket 连接失败或消息收不到。
**原因**BiSheng 的 WebSocket 使用特殊的认证方式(`UserPayload.get_login_user_from_ws`),token 通过 query 参数传递。
**修复**
```python
# ✅ WebSocket 认证
import websockets
async with websockets.connect(
f"ws://localhost:7860/api/v1/chat/{flow_id}?t={token}"
) as ws:
# 发送消息
await ws.send(json.dumps({"message": "hello"}))
# 接收响应
response = await ws.recv()
```
---
## 陷阱 9RSA 公钥缓存
**症状**:多个测试用不同用户登录,部分登录失败。
**原因**:公钥可能在短时间内变化,或 RSA 加密使用了错误的 padding。
**修复**
```python
# ✅ 每次登录前重新获取公钥(不缓存)
# helpers/auth.py 中的 get_token() 函数应每次都获取新公钥
```
---
## 快速诊断表
| 错误关键词 | 可能原因 | 首先检查 |
|-----------|---------|---------|
| HTTP 200 但 status_code 非 200 | 业务错误 | 检查 MMMEE 错误码含义 |
| 401 Unauthorized | Token 过期或格式错误 | 重新获取 token,检查 Cookie/Header |
| 查不到刚创建的数据 | tenant_id 不匹配 | 确认用户与资源同租户 |
| 权限拒绝(刚创建的资源) | OpenFGA 元组未写入 | 检查 PermissionService.authorize() |
| DELETE 失败 400/409 | 有关联数据 | 按依赖逆序删除 |
| 异步操作状态不对 | Celery 任务未完成 | 轮询等待 + 增加 timeout |
| 分页数据数量不对 | 参数名不一致 | 查 API 文档确认 page/limit 参数名 |
| 登录失败 | RSA 加密问题 | 检查公钥获取和加密 padding |
| `Connection refused` | 后端未启动 | 确认 localhost:7860 可访问 |
| `Redis connection error` | Redis 未启动 | 确认 Redis 服务运行中 |
@@ -0,0 +1,226 @@
# E2E 测试文件骨架模板
## 完整 pytest 模板
```python
"""
E2E tests for <FEATURE_NAME>
Prerequisites:
- Backend running on localhost:7860
- MySQL/Redis/Milvus/ES/OpenFGA services running
Covers:
- AC-01: <description>
- AC-02: <description>
"""
import pytest
import httpx
from test.e2e.helpers.auth import get_admin_token, get_user_token, auth_headers, create_test_user
from test.e2e.helpers.api import API_BASE, assert_resp_200, assert_resp_error
from test.e2e.helpers.cleanup import cleanup_by_prefix, ensure_test_tenant
# Data prefix for test isolation (must be >= 5 chars)
PREFIX = "e2e-<feature>-"
# Test tenant for multi-tenant isolation
TEST_TENANT = "e2e-<feature>-tenant"
class TestE2E<FeatureName>:
"""E2E: <feature_name>"""
# ──────── Fixtures ────────
@pytest.fixture(autouse=True, scope="class")
async def setup_and_teardown(self):
"""双重 cleanup: setup 清理上次残留 + teardown 清理本次"""
async with httpx.AsyncClient(base_url=API_BASE, timeout=30.0) as client:
# Setup: 获取 admin token
admin_token = await get_admin_token(client)
headers = auth_headers(admin_token)
# Setup: 确保测试租户存在
await ensure_test_tenant(client, admin_token, TEST_TENANT)
# Setup: 清理上次残留的测试数据
await cleanup_by_prefix(client, "/resource", PREFIX, admin_token)
yield # 运行测试
# Teardown: 清理本次创建的测试数据
await cleanup_by_prefix(client, "/resource", PREFIX, admin_token)
@pytest.fixture
async def client(self):
"""提供 httpx AsyncClient"""
async with httpx.AsyncClient(base_url=API_BASE, timeout=30.0) as client:
yield client
@pytest.fixture
async def admin_token(self, client):
"""获取管理员 token"""
return await get_admin_token(client)
@pytest.fixture
async def user_token(self, client, admin_token):
"""创建并返回普通用户 token"""
user = await create_test_user(
client, admin_token,
username=f"{PREFIX}user",
role_id=2 # DefaultRole
)
return await get_user_token(client, user["user_name"], "test_password")
# ──────── Happy Path Tests ────────
async def test_ac01_create_success(self, client, admin_token):
"""AC-01: <操作描述> → <预期结果>"""
headers = auth_headers(admin_token)
# 创建资源
resp = await client.post(
"/resource",
json={"name": f"{PREFIX}test-entity"},
headers=headers,
)
# 断言 UnifiedResponseModel 成功格式
data = assert_resp_200(resp)
assert data["name"] == f"{PREFIX}test-entity"
assert "id" in data
# 通过 GET 验证最终状态(不仅依赖创建响应)
get_resp = await client.get(f"/resource/{data['id']}", headers=headers)
get_data = assert_resp_200(get_resp)
assert get_data["name"] == f"{PREFIX}test-entity"
async def test_ac02_list_with_pagination(self, client, admin_token):
"""AC-02: 分页查询资源列表"""
headers = auth_headers(admin_token)
resp = await client.get(
"/resource",
params={"page": 1, "limit": 10},
headers=headers,
)
data = assert_resp_200(resp)
assert "data" in data # PageData format
assert "total" in data
# ──────── Error Path Tests ────────
async def test_ac03_duplicate_name_rejected(self, client, admin_token):
"""AC-03: 重复名称 → 返回 MMMEE 错误码"""
headers = auth_headers(admin_token)
# 创建第一个
await client.post(
"/resource",
json={"name": f"{PREFIX}duplicate"},
headers=headers,
)
# 创建同名第二个
resp = await client.post(
"/resource",
json={"name": f"{PREFIX}duplicate"},
headers=headers,
)
# 断言具体错误码(不仅检查非 200)
assert_resp_error(resp, expected_code=10901) # MMMEE
# ──────── Permission Tests ────────
async def test_ac04_unauthorized_access_denied(self, client, user_token, admin_token):
"""AC-04: 普通用户无权访问管理接口 → 权限拒绝"""
headers = auth_headers(user_token)
resp = await client.get("/admin-only-resource", headers=headers)
assert_resp_error(resp, expected_code=10601) # user permission denied
async def test_ac05_cross_tenant_blocked(self, client, admin_token):
"""AC-05: 跨租户访问 → tenant_id 不匹配拒绝"""
# 创建资源属于 tenant A
headers_a = auth_headers(admin_token) # tenant A
resp = await client.post(
"/resource",
json={"name": f"{PREFIX}tenant-a-only"},
headers=headers_a,
)
resource_id = assert_resp_200(resp)["id"]
# 用 tenant B 的 token 尝试访问
# (需要创建 tenant B 的用户和 token
# headers_b = auth_headers(tenant_b_token)
# resp = await client.get(f"/resource/{resource_id}", headers=headers_b)
# assert resp.status_code == 200
# body = resp.json()
# assert body["status_code"] != 200 # 应该被拒绝
```
## 关键结构规则
1. **class-based 组织** — 每个 Feature 一个 TestClassfixture 管理生命周期
2. **setup_and_teardown 是 class-scoped** — 确保整个类运行前清理 + 运行后清理
3. **每个测试方法 docstring 标注 AC-NN** — 追溯到 spec.md 的 AC 表格
4. **PREFIX 常量** — 所有测试数据以 `e2e-{feature}-` 开头
5. **API 验证** — 数据变更后,通过 GET 断言最终状态
6. **共享 helpers** — 认证/断言/清理使用 `test/e2e/helpers/`,不在文件内重定义
7. **权限配对** — 每个 "允许" 操作配对一个 "拒绝" 测试
## 响应断言模式
```python
# ✅ 正确:断言 UnifiedResponseModel 完整格式
def assert_resp_200(resp):
assert resp.status_code == 200
body = resp.json()
assert body["status_code"] == 200
assert body["status_message"] == "SUCCESS"
return body["data"]
# ✅ 正确:断言具体 MMMEE 错误码
def assert_resp_error(resp, expected_code):
body = resp.json()
assert body["status_code"] == expected_code
# ❌ 错误:只检查 HTTP 状态码
assert resp.status_code == 400 # BiSheng 业务错误也返回 HTTP 200
```
## 认证模式
```python
# ✅ JWT Cookie 认证(BiSheng 主要认证方式)
headers = {"Cookie": f"access_token_cookie={token}"}
# ✅ 或 Header 认证
headers = {"Authorization": f"Bearer {token}"}
# 获取 token
resp = await client.post("/user/login", json={
"user_name": "admin",
"password": "<rsa_encrypted_password>"
})
token = resp.json()["data"]["access_token"]
```
## 多租户测试模式
```python
# ✅ 测试租户隔离
TEST_TENANT_CODE = "e2e-feature-tenant"
# setup: 确保测试租户存在
await ensure_test_tenant(client, admin_token, TEST_TENANT_CODE)
# 创建属于测试租户的数据
# tenant_id 由 SQLAlchemy event 自动注入,不需手动设置)
# 验证:不同租户的用户看不到此数据
```
+13
View File
@@ -0,0 +1,13 @@
---
name: i18n-localizer
description: Internationalize a module by extracting hardcoded Chinese strings, generating translation keys, and updating all three locale files (en, zh-Hans, ja).
---
# i18n Localizer
This skill extracts hardcoded Chinese strings from a React module and replaces them with `useLocalize()` calls, keeping all three locale files in sync.
## Instructions
1. **Read the Workflow**: Read the content of `resources/INSTRUCTIONS.md` for the complete step-by-step process.
2. **Read the Conventions**: Read `resources/CONVENTIONS.md` for key naming rules and usage patterns.
4. **Execute**: Follow the workflow to localize the target module.
@@ -0,0 +1,133 @@
# i18n Conventions for This Project
## Technology Stack
- **Library**: `i18next` (v24+) + `react-i18next` (v15+) + `i18next-browser-languagedetector` (v8+)
- **Supported Languages**: `en` (English), `zh-Hans` (Simplified Chinese), `ja` (Japanese)
## File Locations
| File | Purpose |
|------|---------|
| `src/locales/i18n.ts` | i18next initialization and configuration |
| `src/locales/en/translation.json` | English translations |
| `src/locales/zh-Hans/translation.json` | Simplified Chinese translations |
| `src/locales/ja/translation.json` | Japanese translations |
| `src/hooks/useLocalize.ts` | Custom hook wrapping `useTranslation` with Recoil lang state |
## Key Naming Convention
### Domain Namespaces
Keys are organized by domain namespace. Each domain is a top-level object in the JSON:
| Namespace | Scope |
|-----------|-------|
| `com_ui` | General UI elements (buttons, labels, status text) |
| `com_nav` | Navigation, sidebar, top bar, menus |
| `com_auth` | Authentication (login, register, password) |
| `com_endpoint` | LLM endpoint configuration |
| `com_sop` | SOP / task execution features |
| `com_knowledge` | Knowledge base management |
| `com_tools` | Tool panel and tool-related features |
| `com_agent` | Agent-related features |
| `com_app` | App center / agent marketplace |
| `com_invite` | Invitation features |
| `com_linsight` | Linsight-specific features |
| `com_label` | Label / tagging features |
| `com_search` | Search-related features |
| `com_file` | File management |
| `com_message` | Chat message related |
| `com_segment` | Mode segment features |
### Key Naming Rules
1. Use **snake_case** (all lowercase, underscores between words).
2. Keep keys **descriptive but concise** (2-5 words).
3. For similar operations, use consistent suffixes: `_success`, `_error`, `_failed`, `_confirm`, `_placeholder`, `_title`, `_desc`.
4. Do NOT include the translated text in the key name.
## JSON File Format
> [!IMPORTANT]
> **Legacy keys** (flat format like `"com_ui_cancel": "Cancel"`) MUST be left as-is. Do NOT refactor them.
> **New keys** MUST use the nested namespace format described below.
### New Key Format (Nested)
New keys use nested objects grouped by domain namespace:
```json
{
"com_ui_cancel": "Cancel",
"com_ui_delete": "Delete",
"com_knowledge": {
"space_create_success": "Knowledge space created",
"space_deleted": "Space has been dissolved",
"folder_max_depth": "Folder depth limit reached (10 levels)",
"drop_to_upload": "Drop files here to upload"
}
}
```
- Old flat keys like `"com_ui_cancel"` stay untouched at root level.
- New keys go inside their namespace object (e.g. `com_knowledge.space_create_success`).
- Within each namespace object, keys are sorted alphabetically.
- Namespace objects are placed after all legacy flat keys, also sorted alphabetically.
### Interpolation
- Use `{{0}}`, `{{1}}` for positional args; `{{name}}` for named args.
- Use `$t(keyName)` to reference other keys inline.
## Usage in Components
### Import Pattern
```tsx
// Preferred: via the barrel export
import { useLocalize } from "~/hooks";
// Alternative: direct import
import useLocalize from "~/hooks/useLocalize";
```
### Component Usage
```tsx
function MyComponent() {
const localize = useLocalize();
return (
<div>
{/* New nested key — use dot notation */}
<h1>{localize("com_knowledge.title")}</h1>
{/* Legacy flat key — unchanged */}
<button>{localize("com_ui_cancel")}</button>
{/* With interpolation */}
<p>{localize("com_knowledge.files_count", { 0: fileCount })}</p>
</div>
);
}
```
### Toast Messages
```tsx
showToast({
message: localize("com_knowledge.space_create_success"),
severity: NotificationSeverity.SUCCESS
});
```
## Interpolation Examples
| Pattern | Locale Value | Code |
|---------|-------------|------|
| Positional | `"已选择 {{0}} 个文件(共 {{1}} 个文件)"` | `localize("key", { 0: selected, 1: total })` |
| Named | `"File: {{name}} exceeds {{size}}MB"` | `localize("key", { name, size })` |
| Nested ref | `"$t(linsight)正在规划..."` | Automatically resolved by i18next |
| Plural (count) | `"剩余任务次数: {{count}}次"` | `localize("key", { count: remaining })` |
@@ -0,0 +1,124 @@
# i18n Conventions for Platform Frontend (src/frontend/platform/)
## Technology Stack
- **Library**: `i18next` (v23+) + `react-i18next` (v15+) + `i18next-http-backend` (v2+)
- **Supported Languages**: `en-US` (English), `zh-Hans` (Simplified Chinese), `ja` (Japanese)
## File Locations
| File | Purpose |
|------|---------|
| `src/i18n.js` | i18next initialization (HTTP backend loader) |
| `public/locales/en-US/{ns}.json` | English translations |
| `public/locales/zh-Hans/{ns}.json` | Simplified Chinese translations |
| `public/locales/ja/{ns}.json` | Japanese translations |
## Namespace Files
Platform uses **multiple namespace files** per language (loaded via HTTP backend at runtime):
| Namespace | File | Scope |
|-----------|------|-------|
| `bs` | `bs.json` | General UI, common labels, system messages |
| `flow` | `flow.json` | Flow/workflow builder, nodes, edges |
| `model` | `model.json` | LLM model management, fine-tuning |
| `tool` | `tool.json` | Tool/plugin management |
| `dashboard` | `dashboard.json` | Dashboard, charts, analytics |
| `knowledge` | `knowledge.json` | Knowledge base management |
> When adding keys, choose the namespace that best matches the module the string belongs to. Default to `bs` for cross-cutting or ambiguous strings.
## Key Naming Convention
### Key Naming Rules
1. Use **dot-separated paths** for hierarchy: `knowledge.spaceCreateSuccess`.
2. Use **camelCase** for leaf keys.
3. Keep keys **descriptive but concise** (2-5 words).
4. For similar operations, use consistent suffixes: `Success`, `Error`, `Failed`, `Confirm`, `Placeholder`, `Title`, `Desc`.
### Example Keys
```json
// public/locales/zh-Hans/bs.json
{
"deleteConfirm": "确定要删除吗?",
"saveSuccess": "保存成功",
"cancel": "取消"
}
// public/locales/zh-Hans/knowledge.json
{
"spaceCreateSuccess": "知识空间创建成功",
"dropToUpload": "松手即可上传文件至此处",
"folderMaxDepth": "文件夹层级已达上限(10层)"
}
```
## JSON File Format
- Each namespace is a **flat key-value** JSON object (no nesting).
- Keys are sorted alphabetically.
- Use `{{0}}`, `{{1}}` for positional interpolation, `{{name}}` for named interpolation.
- Do NOT duplicate existing keys — search before adding.
## Usage in Components
### Import Pattern
```tsx
import { useTranslation } from "react-i18next"
```
### Component Usage
```tsx
function MyComponent() {
const { t } = useTranslation()
return (
<div>
{/* Default namespace (bs) */}
<button>{t('cancel')}</button>
{/* Specific namespace */}
<h1>{t('knowledge:spaceCreateSuccess')}</h1>
{/* With interpolation */}
<p>{t('knowledge:filesCount', { 0: fileCount })}</p>
</div>
)
}
```
### Toast Messages
```tsx
import { toast } from "@/components/bs-ui/toast/use-toast"
toast({
title: t('prompt'),
variant: 'success',
description: t('knowledge:spaceCreateSuccess')
})
```
### Specifying Namespace via useTranslation
```tsx
// Load a specific namespace
const { t } = useTranslation('knowledge')
// Now t('spaceCreateSuccess') resolves from knowledge.json
// Load multiple namespaces
const { t } = useTranslation(['bs', 'knowledge'])
```
## Interpolation Examples
| Pattern | Locale Value | Code |
|---------|-------------|------|
| Positional | `"已选择 {{0}} 个文件(共 {{1}} 个文件)"` | `t('key', { 0: selected, 1: total })` |
| Named | `"文件: {{name}} 超过 {{size}}MB"` | `t('key', { name, size })` |
| Count | `"剩余任务次数:{{count}}次"` | `t('key', { count: remaining })` |
@@ -0,0 +1,78 @@
# i18n Localization Workflow
## Step 1 — Scan the Module
1. Read all `.tsx` and `.ts` files in the target module directory.
2. Identify every hardcoded user-facing string (Chinese text, toast messages, placeholders, button labels, titles, tooltips, error messages, etc.).
3. Ignore: code comments, CSS class names, variable names, enum values, strings already wrapped in `t()` / `localize()` / `i18n.t()`, and dev-only content (`console.log`).
## Step 2 — Generate Translation Keys
For each extracted string, determine which domain namespace it belongs to (e.g. `com_knowledge`, `com_ui`, `com_sop`), then generate a concise snake_case key name.
Example: `"知识空间创建成功"` → namespace `com_knowledge`, key `space_create_success` → used as `com_knowledge.space_create_success`
Refer to `CONVENTIONS.md` and `SAMPLE_KEYS.json` for naming details.
## Step 3 — Update Locale Files
> **CRITICAL**: Legacy flat keys (like `"com_ui_cancel"`) MUST be left untouched. Only ADD new keys using the nested namespace format.
Add new keys to **all three** translation files using nested structure:
```json
{
"com_ui_cancel": "Cancel",
"com_knowledge": {
"space_create_success": "Knowledge space created",
"drop_to_upload": "Drop files here to upload"
}
}
```
| File | Value |
|------|-------|
| `src/locales/zh-Hans/translation.json` | Original Chinese string |
| `src/locales/en/translation.json` | Professional English translation |
| `src/locales/ja/translation.json` | Professional Japanese translation |
Rules:
- Do NOT modify or restructure existing flat keys.
- New keys go inside their namespace object, sorted alphabetically.
- If the namespace object already exists, append to it. If not, create it.
- Namespace objects are placed after all legacy flat keys, sorted alphabetically.
- Use `{{0}}` for positional interpolation, `{{name}}` for named interpolation.
- Do NOT duplicate existing keys — search before adding.
## Step 4 — Update Component Code
1. Import (if not present): `import { useLocalize } from "~/hooks";`
2. Initialize (if not present): `const localize = useLocalize();`
3. Replace hardcoded strings using **dot notation** for new nested keys:
```tsx
// Before
showToast({ message: "知识空间创建成功" });
// After
showToast({ message: localize("com_knowledge.space_create_success") });
// Before (with dynamic values)
message: `已开始处理 ${files.length} 个文件`
// After
message: localize("com_knowledge.files_processing_started", { 0: files.length })
// Before (JSX)
<p>松手即可上传文件至此处</p>
// After
<p>{localize("com_knowledge.drop_to_upload")}</p>
```
## Step 5 — Verify
1. No hardcoded Chinese remains in modified files (excluding code comments).
2. Every new key exists in all three locale JSON files.
3. No existing flat keys were modified or restructured.
## Output
After completing, provide a summary: number of strings extracted, list of new keys, and files modified.
@@ -0,0 +1,13 @@
---
name: react-component-refactor
description: Refactor large React components by extracting hooks, splitting sub-components, and organizing directory structure following established patterns.
---
# React Component Refactor
This skill provides a systematic approach for refactoring complex React components. Use it when a module has overgrown files, tangled state, or unclear separation of concerns.
## Instructions
1. **Read the Guidelines**: Read `resources/GUIDELINES.md` for the complete refactoring checklist and rules.
2. **Read the Examples**: Read `resources/EXAMPLES.md` for concrete before/after patterns from real refactoring work.
3. **Execute**: Follow the guidelines to refactor the target module.
@@ -0,0 +1,185 @@
# React Component Refactoring — Real Examples
These examples are drawn from the `Subscription` module refactoring and demonstrate each pattern in context.
---
## Example 1: Extract Sub-Component
### Before (in `CreateChannelDrawer.tsx`, ~120 lines inline)
```tsx
// Inline sub-component buried inside the main component
function CreateChannelDrawer({ open, onOpenChange, ... }) {
// ... 18 useState calls ...
// Inline sub-component — hard to find, test, or reuse
function SubChannelBlock({ data, onNameChange, ... }) {
// 120 lines of JSX + local state
}
return ( /* uses SubChannelBlock inline */ );
}
```
### After
```
CreateChannel/
├── CreateChannelDrawer.tsx # imports SubChannelBlock
└── SubChannelBlock.tsx # standalone, with exported Props interface
```
```tsx
// SubChannelBlock.tsx
export interface SubChannelData { id: string; name: string; ... }
interface SubChannelBlockProps {
data: SubChannelData;
onNameChange: (name: string) => void;
onRemove: () => void;
// ...
}
export function SubChannelBlock({ data, onNameChange, ... }: SubChannelBlockProps) {
// self-contained component
}
```
---
## Example 2: Extract Form State Hook
### Before (`CreateChannelDrawer.tsx` — 18 useState + handlers)
```tsx
function CreateChannelDrawer(...) {
const [channelName, setChannelName] = useState("");
const [channelDesc, setChannelDesc] = useState("");
const [visibility, setVisibility] = useState("private");
const [sources, setSources] = useState([]);
// ... 14 more useState calls ...
const resetForm = () => { /* reset all 18 states */ };
const handleAddSubChannel = () => { /* manipulate subChannels state */ };
// ... more handlers ...
return ( /* 400+ lines of JSX using all these states */ );
}
```
### After
```
hooks/
└── useCreateChannelForm.ts # all 18 states + handlers
CreateChannel/
└── CreateChannelDrawer.tsx # clean UI component
```
```tsx
// hooks/useCreateChannelForm.ts
export function useCreateChannelForm() {
const [channelName, setChannelName] = useState("");
// ... all states ...
const resetForm = () => { /* ... */ };
const handleAddSubChannel = () => { /* ... */ };
return { channelName, setChannelName, ..., resetForm, handleAddSubChannel };
}
// CreateChannelDrawer.tsx — now a presentational component
function CreateChannelDrawer(...) {
const form = useCreateChannelForm();
return (
<Input value={form.channelName} onChange={e => form.setChannelName(e.target.value)} />
// ... form.visibility, form.handleAddSubChannel, etc.
);
}
```
---
## Example 3: Extract Data Manager Hook
### Before (`AddSourceDropdown.tsx` — 497 lines with data loading + UI)
```tsx
function AddSourceDropdown({ sources, onSourcesChange, expanded, ... }) {
const [wechatSources, setWechatSources] = useState([]);
const [websiteSources, setWebsiteSources] = useState([]);
const [searchKeyword, setSearchKeyword] = useState("");
// Data loading effect
useEffect(() => {
if (!expanded) return;
const load = async () => { /* API call + state mapping */ };
load(currentType);
}, [expanded, activeTab]);
// WeChat auto-detection effect
useEffect(() => { /* 50 lines of async logic */ }, [expanded, viewMode]);
// Filtering logic
const filteredSources = useMemo(() => { /* ... */ }, [...]);
return ( /* 200+ lines of UI */ );
}
```
### After
```
hooks/
└── useSourceManager.ts # API calls, filtering, toggle logic
CreateChannel/
└── AddSourceDropdown.tsx # pure UI (328 lines, down from 497)
```
```tsx
// AddSourceDropdown.tsx — clean separation
function AddSourceDropdown({ sources, onSourcesChange, expanded, ... }) {
const mgr = useSourceManager(sources, onSourcesChange, expanded, onExpandChange);
return (
<Input value={mgr.searchKeyword} onChange={e => mgr.setSearchKeyword(e.target.value)} />
// ... mgr.filteredSources, mgr.toggleSource, mgr.handleConfirm, etc.
);
}
```
---
## Example 4: Extract Validation to Utility
### Before (inline in submit handler — 45 lines of validation)
```tsx
onClick={async () => {
if (form.sources.length < 1) { showToast({ message: "..." }); return; }
if (!form.channelName.trim()) { showToast({ message: "..." }); return; }
if (form.contentFilter) {
const err = validateFilterGroups(form.filterGroups);
if (err) { showToast({ message: err }); return; }
}
if (form.createSubChannel) {
for (const sub of form.subChannels) { /* more checks */ }
}
// ... then build data and submit
}}
```
### After
```tsx
// channelUtils.ts — pure validation function
export function validateCreateChannelForm(
data: CreateChannelFormData,
localize: (key: string) => string
): string | null {
if (data.sources.length < 1) return localize("need_one_source") || "至少需添加 1 个信息源";
if (!data.channelName.trim()) return localize("cannot_empty_channel_name");
// ... all checks ...
return null;
}
// CreateChannelDrawer.tsx — clean submit handler
onClick={async () => {
const data = { /* assemble form data */ };
const error = validateCreateChannelForm(data, localize);
if (error) { showToast({ message: error, severity: "warning" }); return; }
// submit
}}
```
@@ -0,0 +1,159 @@
# React Component Refactoring Guidelines
This document defines the standard refactoring methodology for this project. Follow these rules when adding new features or refactoring existing modules to keep code maintainable and consistent.
---
## 1. Directory Structure Rules
### When to create a sub-directory
- When a feature area has **3+ closely related component files**, group them into a named sub-directory.
- The directory name should describe the **feature**, not the component (e.g., `CreateChannel/`, not `CreateChannelDrawerFiles/`).
### Standard layout
```
src/pages/ModuleName/
├── index.tsx # Page entry, layout & routing
├── moduleUtils.ts # Pure utility functions (validation, data transform, payload builders)
├── hooks/ # Custom hooks (one hook per file)
│ ├── useFeatureForm.ts # Form state & handlers
│ └── useDataManager.ts # Data fetching, filtering, CRUD
├── FeatureA/ # Feature sub-directory
│ ├── MainComponent.tsx # Top-level feature component
│ ├── SubComponentA.tsx # Extracted sub-component
│ └── SubComponentB.tsx # Another extracted sub-component
└── FeatureB/
└── ...
```
### Import path conventions
- Components within the same feature directory use relative imports: `./SubComponent`
- Hooks are imported from `../hooks/useXxx`
- Utils are imported from `../moduleUtils`
---
## 2. Component Splitting Rules
### When to extract a sub-component
- An inline function component is **>120 lines**.
- A block of JSX is **self-contained** (has its own props/state concept).
- A component is **reused** or could be tested independently.
### How to extract
1. Create a new file in the same feature directory.
2. Define a clear `Props` interface and export it.
3. Move the component body; keep UI unchanged.
4. Import and use in the parent — the parent JSX should only change the component reference.
### Naming conventions
- Sub-component file name = component name (PascalCase): `SubChannelBlock.tsx`
- Always `export function ComponentName` (named exports, no default).
- Co-export related types/interfaces that are tightly coupled.
---
## 3. Hook Extraction Rules
### When to extract a hook
- A component has **≥8 `useState` calls**.
- There is a block of **`useEffect` + state** that handles data loading or side effects.
- Multiple event handlers share the same state and form a logical unit.
### Naming conventions
- File: `hooks/useFeatureName.ts` (camelCase with `use` prefix)
- Hook function: `useFeatureName`
- Return a flat object: `{ stateA, setStateA, handlerB, ... }`
- The consuming component accesses via `const form = useFeatureName(...)` and references `form.stateA`
### What belongs in a hook
| Belongs in Hook | Stays in Component |
|---|---|
| `useState` declarations | JSX rendering |
| Derived/computed values (`useMemo`) | Layout-specific handlers (e.g., scroll position) |
| Data loading `useEffect`s | Event handlers that only call `showToast` |
| CRUD handlers (add/remove/update) | Direct UI event wiring |
| Form reset logic | |
### What does NOT belong in a hook
- UI library calls (`showToast`, `localize`) — pass as params if needed
- API layer definitions — keep in `~/api/`
- Component-specific render helpers
---
## 4. Utility / Validation Extraction Rules
### When to extract to `moduleUtils.ts`
- **Validation functions** that check form data and return error messages.
- **Payload builders** that transform form data into API payloads.
- **Data transformers** that convert between API types and UI types.
- **Pure functions** that don't depend on React state or hooks.
### Function signature pattern
```typescript
// Validation: returns error message or null
export function validateFormData(
data: FormDataType,
localize: (key: string) => string
): string | null;
// Payload builder: transforms form → API payload
export function buildPayload(data: FormDataType): ApiPayloadType;
```
### Rules
- Keep functions pure — no side effects.
- Accept `localize` as a parameter for i18n error messages.
- The component is responsible for displaying errors (toast/UI).
---
## 5. Refactoring Checklist
When refactoring a module, follow this order:
1. **[ ] Analyze** — Count lines, identify state density, find inline sub-components.
2. **[ ] Restructure directories** — Group files by feature if threshold met.
3. **[ ] Extract sub-components** — Move inline components to separate files.
4. **[ ] Extract hooks** — Pull state management into `hooks/useXxx.ts`.
5. **[ ] Extract utilities** — Move validation and data transforms to `moduleUtils.ts`.
6. **[ ] Clean imports** — Remove unused imports, verify all paths resolve.
7. **[ ] Verify** — Run `yarn start` to ensure compilation succeeds.
### DO NOT change during refactoring
- **UI/JSX structure** — no visual changes.
- **CSS classes** — keep exact same styling.
- **API layer** — do not restructure API files unless explicitly requested.
- **i18n hardcoded strings** — handle separately with the `i18n-localizer` skill.
---
## 6. File Size Guidelines
| File Type | Target Lines | Action if exceeded |
|---|---|---|
| Page component (`index.tsx`) | < 600 | Extract sub-sections |
| Feature component | < 600 | Extract hooks & sub-components |
| Custom hook | < 200 | Split by concern |
| Utility file | < 300 | Split by domain |
| Sub-component | < 150 | Already well-scoped |
---
## 7. Data Flow Conventions
```
API Layer (~/api/)
↕ raw types
Hooks (hooks/useXxx.ts)
↕ processed state + handlers
Component (Feature/Main.tsx)
↕ props
Sub-components (Feature/Sub.tsx)
```
- **Unidirectional**: Parent → Child via props; Child → Parent via callback props.
- **No prop drilling beyond 3 levels** — if deeper, use a hook or context.
- **Hooks own the state**, components own the rendering.
+89
View File
@@ -0,0 +1,89 @@
---
name: sdd-review
description: 对 BiSheng 项目的 SDD 文档执行审查。
- spec:写完 spec.md 后调用,同时检查 PRD gap 和架构合规性,生成报告供用户参考
- tasks:写完 tasks.md 后自动调用,检查 AC 追溯、任务拆解质量和技术债预防
用法:/sdd-review <feature_dir> <doc_type>doc_type 为 spec / tasks。
TRIGGER when: 用户完成了 SDD 的 spec.md / tasks.md 编写,或者用户使用 /sdd-review 命令,或者 Claude 完成了这些文件的编写后需要审查。
---
# SDD Review Skill
## 调用方式
```
/sdd-review <feature_dir> <doc_type>
```
例:
```
/sdd-review features/v2.5.0/004-rebac-core spec
/sdd-review features/v2.5.0/001-multi-tenant tasks
```
## 审查流程
### 第一步:解析参数
从用户输入或调用上下文中提取:
- `feature_dir`:特性目录路径(如 `features/v2.5.0/004-rebac-core`
- `doc_type`:文档类型,必须是 `spec``tasks`
若参数缺失或无效,向用户报告错误后停止。
---
### spec 模式(辅助审查,不自动推进)
spec.md 合并了需求规范和技术设计,因此 spec 审查同时覆盖需求覆盖和架构合规检查。
**第二步(spec):执行合并审查**
读取文件:
- `<feature_dir>/spec.md`(已写的规格文档)
- spec.md 中"关联 PRD"字段指向的文件(若未标注,读取 `docs/PRD/` 下与特性名最相关的文件)
- `features/v2.5.0/release-contract.md`(不变量约束,确认 spec 未越界)
- `docs/architecture/02-backend-modules.md`(后端模块架构)
- `docs/architecture/10-permission-rbac.md`(权限体系)
`references/spec-checklist.md` 中的检查清单执行 14 项检查。
**第三步(spec):展示报告,等待用户确认**
向用户展示分析结果:
- 无 gap / 无问题:告知"审查通过,可继续确认"
- 有 gap / 有问题:展示每个问题(MISSING / FORMAT / CONFLICT / ISSUE),供用户决定是否修改
**等待用户确认**(唯一手动暂停点)。用户确认后,将 `<feature_dir>/tasks.md` 状态表中 spec.md 行更新为 `✅ 已评审`
---
### tasks 模式(自动审查)
**第二步(tasks):执行审查**
读取文件:
- `<feature_dir>/tasks.md`
- `<feature_dir>/spec.md`(验收标准 + 技术方案)
- `features/v2.5.0/release-contract.md`(领域归属 + 不变量)
`references/tasks-checklist.md` 中的检查清单执行 21 项检查。
**第三步(tasks):处理审查结果**
**输出格式**
- 有问题:`ISSUE: <描述> | SEVERITY: high/medium/low | TASK: <T-NN 若适用>`
- 无问题:`LGTM`
**处理逻辑**
- `LGTM` → 更新 `<feature_dir>/tasks.md` 状态表,将 `tasks.md` 行改为 `✅ 已拆解`
-`high`/`medium` ISSUE → 修复后重新审查(最多 2 轮)
- `low` ISSUE → 记录但跳过
- 2 轮后仍有 `high`/`medium` → 停止,向用户报告剩余问题
## 错误处理
- feature_dir 不存在 → 报告路径错误,停止
- doc_type 不是 spec / tasks → 报告参数错误,停止
- spec.md 不存在 → 报告"找不到 spec.md,请先完成 spec",停止
- tasks.md 不存在(tasks 模式)→ 报告"找不到 tasks.md,请先完成 tasks",停止
@@ -0,0 +1,48 @@
你是 BiSheng 项目的需求分析师兼架构评审员。请对比 PRD 和已写的 spec.md,同时检查需求覆盖和架构合规性。
spec.md 合并了需求规范和技术设计(用户故事 + AC + 架构决策 + API 契约 + 数据模型)。
请自行读取以下文件:
- {feature_dir}/spec.md(已写的规格文档)
- {prd_path}(从 spec.md"关联 PRD"字段获取路径,若未标注则读取 docs/PRD/ 下与特性名最相关的文件)
- features/v2.5.0/release-contract.md(不变量约束 + 领域归属,确认 spec 未越界)
- docs/architecture/02-backend-modules.md(后端模块架构)
- docs/architecture/10-permission-rbac.md(权限体系规范)
**需求分析维度**
1. PRD 中描述的功能点 / 用户场景,spec 是否有对应 AC?
2. PRD 中提到的边界条件、错误场景,spec 是否有覆盖?
3. PRD 中提到的 UI 交互细节,spec 是否有对应业务 AC?
4. spec 的 AC 表格格式是否正确:`| ID | 角色 | 操作 | 预期结果 |`ID 格式 AC-NN
5. 是否有 AC 不可测试(过于模糊)?
6. 是否与 release-contract.md 的 INV 不变量冲突?
**架构合规维度**
7. spec.md 中每条 AC 是否都有技术覆盖(API 端点 / 数据库模型 / 前端组件)?
8. 是否越界进入 release-contract.md 表 1 中归属其他 Feature 的领域?
9. API 契约是否完整:端点表、请求/响应示例(UnifiedResponseModel 包装)、错误码表格(含 MMMEE 编码 + 关联 AC)?
10. 架构决策是否符合项目规范:
- 分层严格自顶向下:Endpoint → Service → DAO,禁止反向导入
- API 响应用 `UnifiedResponseModel[T]``resp_200(data)` / `resp_500(code, msg)`
- 分页用 `PageData[T]`(推荐)或 `PageList[T]`(旧接口兼容)
- 错误码 5 位 MMMEE 编码,类名 `{Module}{Error}Error`,继承 `BaseErrorCode`
- 认证注入 `UserPayload = Depends(UserPayload.get_login_user)`
- 权限检查 `PermissionService.check()`,禁止直接查 role_access/group_resource
11. 设计部分是否只写 Why+What(不写 How,不写测试策略)?
**BiSheng 特有检查**
12. 新 ORM 模型是否包含 `tenant_id` 字段?(INV-1 要求所有业务表含 tenant_id
13. 错误码是否遵循 MMMEE 且不与 release-contract「已分配模块编码」冲突?
14. 权限相关 AC 是否使用 PermissionService 而非旧 role_access/group_resource?(INV-3
返回格式(必须严格遵守):
有 gap / 问题时,每个问题单独一行:
- MISSING: <PRD 中的功能/场景,spec 未覆盖> | SEVERITY: high/medium/low | PRD_REF: <PRD 原文片段或章节>
- FORMAT: <格式问题描述> | SEVERITY: high/medium/low
- CONFLICT: <与 INV 冲突描述> | SEVERITY: high | INV: <INV-N>
- ISSUE: <架构/设计问题描述> | SEVERITY: high/medium/low | AC: <AC-NN 若适用>
无 gap 且无问题时,只返回一行:LGTM
注意:本报告供参考,是否修改由用户决定。不要建议修改 spec,只列出观察到的 gap 和问题。
@@ -0,0 +1,55 @@
你是 BiSheng 项目的任务计划评审员。请审查 {feature_dir}/tasks.md。
请自行读取以下文件:
- {feature_dir}/spec.md(验收标准 + 技术方案)
- features/v2.5.0/release-contract.md(领域归属 + 不变量)
任务规范要求:
- Test-First:后端测试任务必须先于其配对的实现任务
- 每个测试任务必须有"覆盖 AC: AC-NN, AC-NN"标注
- 基础设施任务(ORM 模型、错误码、配置)无测试配对,排在最前面
- 每个任务应在一次 AI 会话内可完成(目标约 30 分钟,最多 1-2 个文件)
- 依赖关系:依赖的任务 ID 必须存在且顺序合理
- 每个任务必须自包含:内联文件路径、逻辑、测试上下文(实现阶段不需要回读 spec.md)
- 任务分 6 类:基础设施 / 后端 Domain / 后端 API / 前端 Platform / 前端 Client / Worker
- 前端任务必须区分 Platformsrc/frontend/platform/)和 Clientsrc/frontend/client/
- Worker 任务须说明 tenant_id 传递方式(Celery headers → ContextVar
- 「测试降级」标注仅在测试成本极高时允许,必须说明理由
审查清单(4 组 17 条 + BiSheng 特有 4 条):
**A. 形式合规**
1. **AC 追溯完整性** — spec.md 中每条 AC 是否都有至少一个测试任务覆盖(带"覆盖 AC:"标注)?
2. **AC 标注完整性** — 是否存在缺少"覆盖 AC:"标注的测试任务?
3. **Test-First 顺序** — 后端测试任务是否先于其配对的实现任务?
4. **依赖关系正确性** — 被依赖的任务 ID 是否存在、顺序是否合理?
5. **原子化** — 每个任务范围是否 ≤ 2 个文件,能在一次会话内完成?
6. **自包含** — 每个任务是否内联了文件路径、逻辑描述、测试上下文?
**B. 任务拆解质量**
7. **粒度合理性** — 单个任务不超过 3 个文件、不跨前后端?
8. **顺序高效性** — 不存在任务 A 的输出被后续任务覆盖/重写的返工情况?
9. **无重复工作** — 不存在多个任务对同一文件同一部分做非增量的重复修改?
10. **spec 覆盖完整性** — spec.md 中定义的每个 API 端点、ORM 模型、Service 方法、前端组件都有对应实现任务?
11. **任务间接口清晰** — 任务描述中明确前驱任务的产出(DAO 方法签名、Service 接口、API 端点路径)?
12. **无过度工程** — 不存在 spec.md 中未提及但 tasks.md 中新增的实现内容?
**C. AC 标注规范**
13. **AC 标注格式** — 必须逐条列举 `AC-01, AC-02`,禁止 `AC-01~AC-05` 范围写法?
14. **测试任务纯净性** — 标注了"覆盖 AC"的测试任务不得混入实现逻辑?
**D. 技术债预防**
15. **无延迟 TODO** — 任务描述中不得有 TODO/FIXME/HACK 将本 Feature 范围内问题推迟?
16. **数据库回滚** — 数据库模型变更任务需包含回滚方案或说明不可逆原因?
17. **跨 Feature 副作用** — 修改其他 Feature 领域对象的写入行为需检查 release-contract.md;修改共享文件需说明影响范围?
**E. BiSheng 特有**
18. **前端分区** — 前端任务是否区分 Platform / Client 两个分区,不混在一起?
19. **Worker tenant_id** — Worker/Celery 任务是否说明 tenant_id 传递方式(headers → ContextVar)?
20. **基础设施优先** — 基础设施任务(ORM/错误码/conftest)是否排在所有业务任务之前?
21. **测试降级理由** — 标注「测试降级」的任务是否说明了充分理由(如需要 Milvus/ES mock)?
返回格式(必须严格遵守):
有问题时,每个问题单独一行:
- ISSUE: <描述> | SEVERITY: high/medium/low | TASK: <T-NN 若适用>
无问题时,只返回一行:LGTM
+107
View File
@@ -0,0 +1,107 @@
---
name: task-review
description: L1 任务级代码审查。在每个任务完成后执行轻量级约定合规检查,
确保架构红线和编码约定在任务级别被守住,不让违规累积到特性级审查(L2)才发现。
用法:/task-review <feature_dir> <task_id>
TRIGGER when: 用户完成了一个 SDD 任务(实现或测试),或者用户使用 /task-review 命令。
---
# Task Review SkillL1 任务级审查)
## 调用方式
```
/task-review <feature_dir> <task_id>
```
例:
```
/task-review features/v2.5.0/004-rebac-core T003
/task-review features/v2.5.0/007-resource-permission-ui T007
```
## 审查流程
### Step 1: 解析参数 + 收集变更范围
1. 验证参数:
- `feature_dir` 必须存在且包含 `tasks.md`
- `task_id` 必须匹配 tasks.md 中的某个任务(格式:`T001``T003` 等)
- 若参数缺失或无效,报告错误后停止
2.`<feature_dir>/tasks.md` 中读取指定任务的元数据:
- 任务类型(测试 / 实现 / 基础设施 / Worker
- 目标文件列表
- 前置依赖
- 配对任务(测试↔实现)
- 覆盖 AC 标注(测试任务)
3. 读取任务声明的所有目标文件内容(直接读取文件,不依赖 git diff)
### Step 2: 判断任务类型,选择检查子集
根据任务类型确定适用的检查项(参见 `references/task-checklist.md`):
| 任务类型 | 适用检查项 | 额外检查 |
|---------|-----------|---------|
| **测试任务** | #2 命名 + #5 前端约定 | AC 标注格式(`覆盖 AC: AC-NN` |
| **实现任务** | 完整 #1~#6 | 配对测试任务已完成(tasks.md 中已打勾) |
| **基础设施任务** | #1 架构分层 + #4 数据库约定 + #6 信息泄漏 | 无 |
| **Worker 任务** | #1 架构 + #4 数据库 + #6 信息泄漏 | tenant_id 通过 Celery headers 传递 |
任务类型判断规则:
- 文件路径包含 `test/``__tests__/` → 测试任务
- 文件路径包含 `domain/models/``common/errcode/` 或任务描述含"ORM""迁移""错误码""配置" → 基础设施任务
- 文件路径包含 `worker/` 或任务描述含"Celery""异步任务" → Worker 任务
- 其他 → 实现任务
- 若任务同时包含测试和实现文件,按实现任务处理
### Step 3: 按检查清单执行检查
逐项执行 `references/task-checklist.md` 中适用的检查项。
### Step 4: 元数据交叉验证
- **文件范围**:任务声明的目标文件是否实际存在,是否存在范围蔓延(修改了任务未声明的文件)
- **配对测试**:若为实现任务,检查 tasks.md 中配对的测试任务是否已打勾 ✅
- **前置依赖**:检查任务声明的依赖项是否已完成(tasks.md 中已打勾)
### Step 5: 输出报告
按以下格式输出:
```markdown
## Task Review: <task_id>
**任务**: <任务标题>
**类型**: 测试 / 实现 / 基础设施 / Worker
**文件**: <文件列表>
| # | 检查项 | 结果 | 说明 |
|---|--------|------|------|
| 1 | 架构分层 | PASS / FAIL / N/A | <若 FAIL,具体描述> |
| 2 | 命名规范 | PASS / FAIL / N/A | |
| 3 | 序列化约定 | PASS / FAIL / N/A | |
| 4 | 数据库约定 | PASS / FAIL / N/A | |
| 5 | 前端约定 | PASS / FAIL / N/A | |
| 6 | 信息泄漏 | PASS / FAIL / N/A | |
**元数据验证**: 文件范围 PASS/FAIL | 配对测试 PASS/FAIL/N/A | 依赖 PASS/FAIL
**结果**: PASS / PASS_WITH_NOTES / NEEDS_FIX
```
### Step 6: 处理结果
| 结果 | 条件 | 动作 |
|------|------|------|
| **PASS** | 全部通过 | 告知用户可以打勾 |
| **PASS_WITH_NOTES** | 仅 MEDIUM 级提醒,无 HIGH | 告知用户可以打勾,列出提醒供参考 |
| **NEEDS_FIX** | 任何 HIGH 违规 | 列出需要修复的具体问题,修复后可再次调用 `/task-review` 重审(最多 1 轮重审) |
## 错误处理
- `feature_dir` 不存在 → 报告路径错误,停止
- `tasks.md` 不存在 → 报告"找不到 tasks.md",停止
- `task_id` 不匹配 → 报告"未找到任务 <task_id>",停止
- 目标文件不存在 → 标记为 WARNING(文件可能尚未创建),继续检查其他文件
@@ -0,0 +1,41 @@
# L1 任务审查检查清单
本清单定义了 `/task-review` 在每个任务完成后执行的精简检查项。
L1 聚焦约定合规和架构红线,不检查边界条件、权限、并发、测试覆盖(留给 L2)。
## 检查项
| # | 检查项 | 适用文件 | 严重度 | 检查方法 |
|---|--------|---------|--------|---------|
| 1 | 架构分层 | 后端 `*.py` | HIGH | Endpoint 不直接实例化 DAO 做复杂业务(应通过 Service);Service 不导入 FastAPI 对象(Request/Response/Depends/APIRouter);`domain/models/` 不得 `from bisheng.*.domain.services``common/``core/` 不得导入领域模块;新代码不放 `api/services/`(旧服务层),应放 `{module}/domain/services/`Worker 只导入 Domain Services 不导入 Endpoint |
| 2 | 命名规范 | 全部 | MEDIUM | DAO 方法:同步 `get_xxx`/`create_xxx`/`update_xxx`/`delete_xxx`,异步 `aget_xxx`/`acreate_xxx`/`aupdate_xxx`/`adelete_xxx`DAO 为 `@classmethod`Service 类名 `{Module}{Function}Service`;错误码类名 `{Module}{Error}Error`Code 遵循 MMMEE;前端页面 PascalCasestore 文件 camelCase+StoreAPI 函数 camelCasei18n key 小写+点分隔 |
| 3 | 序列化约定 | 后端 `*.py` | HIGH | ORM 继承 `SQLModelSerializable`API 响应用 `UnifiedResponseModel``resp_200`/`resp_500`/`ErrorClass.return_resp`);分页用 `PageData[T]`(新代码);枚举序列化为 `.value`SSE 用 `to_sse_event()`WS 关闭用 `websocket_close_message()` |
| 4 | 数据库约定 | 后端 models/migration `*.py` | HIGH | 新表必须含 `tenant_id``index=True`);必须含 `create_time`/`update_time`;禁止手动 `WHERE tenant_id=`SQLAlchemy event 自动注入);禁止 Service 层直接写 SQL(用 DAO classmethod);新模块 DAO 放 `{module}/domain/models/` 而非 `database/models/`;使用 `get_sync_db_session()`/`get_async_db_session()` |
| 5 | 前端约定 | `*.tsx`/`*.ts` | MEDIUM | Platform: 全局状态用 Zustand store`src/store/`),API 通过 `controllers/API/` 封装,用户可见文字走 `t('key')` i18n,新路由在 `src/routes/` 注册。Client: API 通过 `src/api/` 封装,store 用 Zustand`src/store/`),路由基础路径 `/workspace` |
| 6 | 信息泄漏 | 全部 | HIGH | 无硬编码密码/密钥/token`password = "xxx"` 等);错误响应不暴露堆栈/SQL(用 BaseErrorCode);日志中敏感字段脱敏;API 不返回 tenant_id 到前端;前端不硬编码后端 IP。排除:测试 fixtures、config.yaml.example |
## 差异化处理规则
### 测试任务
- 仅检查:#2 命名规范 + #5 前端约定中的 i18n + AC 标注格式(`覆盖 AC: AC-NN`
- 跳过:#1 架构分层、#3 序列化、#4 数据库
### 实现任务
- 完整执行 #1~#6
- 额外验证:配对的测试任务是否已完成(tasks.md 中已打勾)
### 基础设施任务(ORM 模型、错误码、配置)
- 检查:#1 架构分层、#4 数据库约定、#6 信息泄漏
- 跳过:#5 前端约定
### Worker 任务
- 检查:#1 架构分层、#4 数据库约定、#6 信息泄漏
- 额外检查:tenant_id 是否通过 Celery headers 传递并在 Worker 侧恢复 ContextVar
## 判定规则
| 结果 | 条件 | 动作 |
|------|------|------|
| **PASS** | 全部通过 | 打勾,继续下一任务 |
| **PASS_WITH_NOTES** | 仅 MEDIUM 级信息性提醒 | 打勾 + 记录偏差,继续 |
| **NEEDS_FIX** | 任何 HIGH 违规 | 修复 → 重审(最多 1 轮) |
+137
View File
@@ -0,0 +1,137 @@
# 服务字段映射文档
## 一、清单汇总
### 1.1 接口清单
| 序号 | 接口提供方 | 领域 | 接口名称 | 负责人 | 状态 | 数据流向 |
| ---- | ---------- | ---- | -------- | ------ | ---- | -------- |
| 0 | B系统 | PO | 示例数据 | 张三 | | A->B |
### 1.2 版本控制记录
| 序号 | 日期 | 作者 | 版本 | 变更描述 |
| ---- | ------- | -------- | ---- | -------- |
| 1 | 6/16/17 | 汉得顾问 | 1.0 | 初始版本 |
| 2 | | | | |
| 3 | | | | |
| 4 | | | | |
> Type说明(Type Description)IntegerNumberStringDateDateTime
---
## 二、示例数据
### 2.1 输入(行信息)
| 接口名称 | 数据块 | A系统字段(ESB Fields) - 属性名称 | A系统字段(ESB Fields) - 数据类型 | A系统字段(ESB Fields) - 描述 | A系统字段(ESB Fields) - 备注 | B系统字段(NewDMS System Fields) - 属性名称 | B系统字段(NewDMS System Fields) - 描述 | B系统字段(NewDMS System Fields) - 数据类型 | B系统字段(NewDMS System Fields) - 长度 | B系统字段(NewDMS System Fields) - 字段必输(Require:Y/N) | B系统字段(NewDMS System Fields) - 备注 | 接口表名/服务地址 |
| -------- | ------ | -------------------------------- | -------------------------------- | ---------------------------- | ---------------------------- | ------------------------------------------ | -------------------------------------- | ---------------------------------------- | ---------------------------------------- | ------------------------------------------------ | -------------------------------------- | ---------------- |
| 示例数据 | 输入 | ifId | string | 主键值 | | IF_ID | 主键值 | string | 36 | Y | | |
| 示例数据 | 输入 | Price | string | 价格 | | Price | 价格 | number | 12,2 | Y | | |
| 示例数据 | 输入 | beginDate | string | 启用日期 | | StartTime | 启用日期 | date | | N | | |
| 示例数据 | 输入 | endDate | string | 截止日期 | | EndTime | 截止日期 | date | | N | | |
| 示例数据 | 输入 | attribute1 | string | 预留字段 | | ATTRIBUTE1 | 预留字段 | string | 240 | N | | |
| 示例数据 | 输入 | attribute2 | string | 预留字段 | | ATTRIBUTE2 | 预留字段 | string | 240 | N | | |
| 示例数据 | 输入 | attribute3 | string | 预留字段 | | ATTRIBUTE3 | 预留字段 | string | 240 | N | | |
| 示例数据 | 输入 | attribute4 | string | 预留字段 | | ATTRIBUTE4 | 预留字段 | string | 240 | N | | |
| 示例数据 | 输入 | attribute5 | string | 预留字段 | | ATTRIBUTE5 | 预留字段 | string | 240 | N | | |
| 示例数据 | 输入 | attribute6 | string | 预留字段 | | ATTRIBUTE6 | 预留字段 | string | 240 | N | | |
| 示例数据 | 输入 | attribute7 | string | 预留字段 | | ATTRIBUTE7 | 预留字段 | string | 240 | N | | |
| 示例数据 | 输入 | attribute8 | string | 预留字段 | | ATTRIBUTE8 | 预留字段 | string | 240 | N | | |
| 示例数据 | 输入 | attribute9 | string | 预留字段 | | ATTRIBUTE9 | 预留字段 | string | 240 | N | | |
| 示例数据 | 输入 | attribute10 | string | 预留字段 | | ATTRIBUTE10 | 预留字段 | string | 240 | N | | |
### 2.2 输出
#### 2.2.1 结果
| 接口名称 | 数据块 | A系统字段(ESB Fields) - 属性名称 | A系统字段(ESB Fields) - 数据类型 | A系统字段(ESB Fields) - 描述 | A系统字段(ESB Fields) - 备注 | B系统字段(NewDMS System Fields) - 属性名称 | B系统字段(NewDMS System Fields) - 描述 | B系统字段(NewDMS System Fields) - 数据类型 | B系统字段(NewDMS System Fields) - 长度 | B系统字段(NewDMS System Fields) - 字段必输(Require:Y/N) | B系统字段(NewDMS System Fields) - 备注 | 接口表名/服务地址 |
| -------- | ------ | -------------------------------- | -------------------------------- | ---------------------------- | ---------------------------- | ------------------------------------------ | -------------------------------------- | ---------------------------------------- | ---------------------------------------- | ------------------------------------------------ | -------------------------------------- | ---------------- |
| 示例数据 | 输出 | returnStatus | string | 返回状态<br/>S:成功 E:失败 | | | | | | | | |
| 示例数据 | 输出 | returnMsg | string | 返回消息 | | ReMsg | | string | | | | |
| 示例数据 | 输出 | errorCode | string | 错误代码 | | ReCode | S为成功,其他为失败 | string | | | | |
#### 2.2.2 详细结果
| 接口名称 | 数据块 | A系统字段(ESB Fields) - 属性名称 | A系统字段(ESB Fields) - 数据类型 | A系统字段(ESB Fields) - 描述 | A系统字段(ESB Fields) - 备注 | B系统字段(NewDMS System Fields) - 属性名称 | B系统字段(NewDMS System Fields) - 描述 | B系统字段(NewDMS System Fields) - 数据类型 | B系统字段(NewDMS System Fields) - 长度 | B系统字段(NewDMS System Fields) - 字段必输(Require:Y/N) | B系统字段(NewDMS System Fields) - 备注 | 接口表名/服务地址 |
| -------- | ------ | -------------------------------- | -------------------------------- | ---------------------------- | ---------------------------- | ------------------------------------------ | -------------------------------------- | ---------------------------------------- | ---------------------------------------- | ------------------------------------------------ | -------------------------------------- | ---------------- |
| 示例数据 | 输出 | returnStatus | string | 返回状态<br/>S:成功 E:失败 | | | | | | | | |
| 示例数据 | 输出 | returnMsg | string | 返回消息 | | msg | | string | | | | |
| 示例数据 | 输出 | errorCode | string | 错误代码 | | | | | | | | |
| 示例数据 | 输出 | pramilyKey1 | string | 主键值 | | key | | string | | | | |
---
## 三、用户字段
> Type说明(Type Description)IntegerNumberStringDateDateTime
### 3.1 Push Account - 输入(行信息)
| 接口名称 | 数据块 | A系统字段(A Fields) - 属性名称 | A系统字段(A Fields) - 数据类型 | A系统字段(A Fields) - 描述 | A系统字段(A Fields) - 备注 | B系统字段(B System Fields) - 属性名称 | B系统字段(B System Fields) - 描述 | B系统字段(B System Fields) - 数据类型 | B系统字段(B System Fields) - 长度 | B系统字段(B System Fields) - 字段必输(Require:Y/N) | B系统字段(B System Fields) - 备注 | 接口表名/服务地址 |
| ----------- | ------ | ------------------------------ | ------------------------------ | -------------------------- | -------------------------- | ------------------------------------- | -------------------------------- | ------------------------------------ | -------------------------------- | ------------------------------------------------ | -------------------------------- | ---------------- |
| Push Account | 输入 | PersonNo | STRING8| 人员编码(必填) | | | | | | | | |
| Push Account | 输入 | UserName | STRING64| 用户名(必填) | | | | | | | | |
| Push Account | 输入 | FullName | STRING64| 姓名 | | | | | | | | |
| Push Account | 输入 | Mobile | STRING11| 手机号 | | | | | | | | |
| Push Account | 输入 | Sex | STRING1| 性别 | | | | | | | | |
| Push Account | 输入 | Password | STRING64)| 密码(必填) | 初始密码,身份证后6位,后续不做更新。 | | | | | | | |
| Push Account | 输入 | Organize_Code | STRING16| 所属组织机构编码 | | | | | | | | |
| Push Account | 输入 | OrgCode | STRING8| 主数据组织编码 | | | | | | | | |
| Push Account | 输入 | OrganizationId | STRING(64)| 所属机构 | 这四个字段值目前不太规范,暂不提供 | | | | | | | |
| Push Account | 输入 | SidelingOrg | STRING64| 兼职组织 | | | | | | | | |
| Push Account | 输入 | Email | STRING64| 邮箱 | | | | | | | | |
| Push Account | 输入 | Duties | STRING64| 职务 | | | | | | | | |
| Push Account | 输入 | IsDisabled | STRING1| 启用/禁用(必填) | 0:启用、1:禁用 | | | | | | | |
| Push Account | 输入 | PostCode | STRING64| 岗位代码 | | | | | | | | |
| Push Account | 输入 | PostDesc | STRING64| 岗位描述 | | | | | | | | |
| Push Account | 输入 | guid | STRING64| GUID | 账号新增不传,更新时传 | | | | | | | |
### 3.2 Push Account - 输出(结果)
| 接口名称 | 数据块 | A系统字段(A Fields) - 属性名称 | A系统字段(A Fields) - 数据类型 | A系统字段(A Fields) - 描述 | A系统字段(A Fields) - 备注 | B系统字段(B System Fields) - 属性名称 | B系统字段(B System Fields) - 描述 | B系统字段(B System Fields) - 数据类型 | B系统字段(B System Fields) - 长度 | B系统字段(B System Fields) - 字段必输(Require:Y/N) | B系统字段(B System Fields) - 备注 | 接口表名/服务地址 |
| ----------- | ------ | ------------------------------ | ------------------------------ | -------------------------- | -------------------------- | ------------------------------------- | -------------------------------- | ------------------------------------ | -------------------------------- | ------------------------------------------------ | -------------------------------- | ---------------- |
| Push Account | 输出 | Result | | 反馈结果<br/>S:成功 E:失败 | | | | | | | | |
| Push Account | 输出 | UserName | | 用户名 | | | | | | | | |
| Push Account | 输出 | Description | | 描述 | | | | | | | | |
| Push Account | 输出 | guid | | GUID(必返回) | guid:是给业务系统推送用户成功之后业务系统返回值(是业务系统唯一主键,账号不能作为唯一主键) | | | | | | | |
### 3.3 账号回收字段
#### 3.3.1 输入(时间段)
| 接口名称 | 数据块 | A系统字段(A Fields) - 属性名称 | A系统字段(A Fields) - 数据类型 | A系统字段(A Fields) - 描述 | A系统字段(A Fields) - 备注 | B系统字段(B System Fields) - 属性名称 | B系统字段(B System Fields) - 描述 | B系统字段(B System Fields) - 数据类型 | B系统字段(B System Fields) - 长度 | B系统字段(B System Fields) - 字段必输(Require:Y/N) | B系统字段(B System Fields) - 备注 | 接口表名/服务地址 |
| -------- | ------ | ------------------------------ | ------------------------------ | -------------------------- | -------------------------- | ------------------------------------- | -------------------------------- | ------------------------------------ | -------------------------------- | ------------------------------------------------ | -------------------------------- | ---------------- |
| 账号回收字段 | 输入 | lastToken | STRING64 | 开始时间 | | | | | | | | |
| 账号回收字段 | 输入 | endToken | STRING64 | 结束时间 | | | | | | | | |
#### 3.3.2 输出(要回收的字段)
| 接口名称 | 数据块 | A系统字段(A Fields) - 属性名称 | A系统字段(A Fields) - 数据类型 | A系统字段(A Fields) - 描述 | A系统字段(A Fields) - 备注 | B系统字段(B System Fields) - 属性名称 | B系统字段(B System Fields) - 描述 | B系统字段(B System Fields) - 数据类型 | B系统字段(B System Fields) - 长度 | B系统字段(B System Fields) - 字段必输(Require:Y/N) | B系统字段(B System Fields) - 备注 | 接口表名/服务地址 |
| -------- | ------ | ------------------------------ | ------------------------------ | -------------------------- | -------------------------- | ------------------------------------- | -------------------------------- | ------------------------------------ | -------------------------------- | ------------------------------------------------ | -------------------------------- | ---------------- |
| 账号回收字段 | 输出 | UserName | STRING64 | 用户名(必返回) | | | | | | | | |
| 账号回收字段 | 输出 | FullName | STRING64 | 姓名(必返回) | | | | | | | | |
| 账号回收字段 | 输出 | guid | STRING64 | GUID(必返回) | guid:业务系统返回值(是业务系统唯一主键,账号不能作为唯一主键) | | | | | | | |
---
## 四、组织字段
> Type说明(Type Description)IntegerNumberStringDateDateTime
### 4.1 Push Account - 输入(行信息)
| 接口名称 | 数据块 | A系统字段(A Fields) - 属性名称 | A系统字段(A Fields) - 数据类型 | A系统字段(A Fields) - 描述 | A系统字段(A Fields) - 备注 | B系统字段(B System Fields) - 属性名称 | B系统字段(B System Fields) - 描述 | B系统字段(B System Fields) - 数据类型 | B系统字段(B System Fields) - 长度 | B系统字段(B System Fields) - 字段必输(Require:Y/N) | B系统字段(B System Fields) - 备注 | 接口表名/服务地址 |
| ----------- | ------ | ------------------------------ | ------------------------------ | -------------------------- | -------------------------- | ------------------------------------- | -------------------------------- | ------------------------------------ | -------------------------------- | ------------------------------------------------ | -------------------------------- | ---------------- |
| Push Account | 输入 | NAME | STRING8| 全称 | | | | | | | | |
| Push Account | 输入 | FullName | STRING64| 简称 | | | | | | | | |
| Push Account | 输入 | OrgId | STRING64| 组织编码 | | | | | | | | |
| Push Account | 输入 | OrgParentId | STRING11| 父组织编码 | | | | | | | | |
| Push Account | 输入 | ParentId | STRING1| 父机构 | | | | | | | | |
| Push Account | 输入 | Organize_Code | STRING64| 主数据组织机构代码 | | | | | | | | |
| Push Account | 输入 | Sup_Organize_Code | STRING64| 主数据父机构代码 | | | | | | | | |
| Push Account | 输入 | isDisabled | STRING64| 启用/禁用(必填) | 0:启用、1:禁用 | | | | | | | |
| Push Account | 输入 | isDeleted | STRING64| 删除 | 0:正常、1:删除 | | | | | | | |
| Push Account | 输入 | guid | STRING64| GUID(必填) | | | | | | | | |
### 4.2 Push Account - 输出
#### 4.2.1 结果
| 接口名称 | 数据块 | A系统字段(A Fields) - 属性名称 | A系统字段(A Fields) - 数据类型 | A系统字段(A Fields) - 描述 | A系统字段(A Fields) - 备注 | B系统字段(B System Fields) - 属性名称 | B系统字段(B System Fields) - 描述 | B系统字段(B System Fields) - 数据类型 | B系统字段(B System Fields) - 长度 | B系统字段(B System Fields) - 字段必输(Require:Y/N) | B系统字段(B System Fields) - 备注 | 接口表名/服务地址 |
| ----------- | ------ | ------------------------------ | ------------------------------ | -------------------------- | -------------------------- | ------------------------------------- | -------------------------------- | ------------------------------------ | -------------------------------- | ------------------------------------------------ | -------------------------------- | ---------------- |
| Push Account | 输出 | Result | | 反馈结果 | | | | | | | | |
| Push Account | 输出 | NAME | | 组织名 | | | | | | | | |
| Push Account | 输出 | Description | | 描述 | | | | | | | | |
#### 4.2.2 详细结果
无对应字段数据
---
### 补充说明
1. 字段数据类型:Integer、Number、String、Date、DateTime
2. 状态标识:S=成功,E=失败;启用/禁用:0=启用,1=禁用;删除状态:0=正常,1=删除
3. attribute1~attribute10 为预留字段,字符长度240,均非必输
@@ -0,0 +1,45 @@
"""F045: add guid field to user table for SG SSO account sync.
Revision ID: f045_user_guid
Revises: f044_developer_token
Create Date: 2026-06-17
"""
from __future__ import annotations
from typing import Sequence, Union
import sqlalchemy as sa
from alembic import op
from bisheng.core.database.dialect_helpers import column_exists, index_exists
revision: str = 'f045_user_guid'
down_revision: Union[str, Sequence[str], None] = 'f044_developer_token'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
conn = op.get_bind()
if not column_exists(conn, 'user', 'guid'):
op.add_column(
'user',
sa.Column(
'guid',
sa.String(length=64),
nullable=True,
comment='SSO account GUID',
),
)
if not index_exists(conn, 'user', 'ix_user_guid'):
op.create_index('ix_user_guid', 'user', ['guid'])
def downgrade() -> None:
conn = op.get_bind()
if index_exists(conn, 'user', 'ix_user_guid'):
op.drop_index('ix_user_guid', table_name='user')
if column_exists(conn, 'user', 'guid'):
op.drop_column('user', 'guid')
@@ -0,0 +1,29 @@
"""SG organization sync endpoint."""
from fastapi import APIRouter, Depends
from bisheng.sso_sync.domain.schemas.sg_payloads import (
SgDepartmentSyncRequest,
)
from bisheng.sso_sync.domain.services.sg_fixed_header_auth import (
verify_sg_fixed_header,
)
from bisheng.sso_sync.domain.services.sg_departments_sync_service import (
SgDepartmentsSyncService,
)
router = APIRouter(tags=['SSO Sync'])
@router.post(
'/departments/sg-sync',
summary='SG bulk department sync (HMAC-signed)',
)
async def sg_departments_sync(
payload: SgDepartmentSyncRequest,
_: None = Depends(verify_sg_fixed_header),
):
"""SG callback with ESB-shaped response body."""
result = await SgDepartmentsSyncService.execute(payload)
return result.model_dump(by_alias=True)
@@ -0,0 +1,29 @@
"""SG SSO account info sync endpoint."""
from fastapi import APIRouter, Depends
from bisheng.sso_sync.domain.schemas.sg_payloads import (
SgSsoAccountSyncRequest,
)
from bisheng.sso_sync.domain.services.sg_fixed_header_auth import (
verify_sg_fixed_header,
)
from bisheng.sso_sync.domain.services.sg_sso_account_sync_service import (
SgSsoAccountSyncService,
)
router = APIRouter(tags=['SSO Sync'])
@router.post(
'/users/sg-sso-sync',
summary='SG SSO account info sync (HMAC-signed)',
)
async def sg_sso_account_sync(
payload: SgSsoAccountSyncRequest,
_: None = Depends(verify_sg_fixed_header),
):
"""Sync SG SSO account fields to user table."""
result = await SgSsoAccountSyncService.execute(payload)
return result.model_dump(by_alias=True)
@@ -0,0 +1,29 @@
"""SG user sync endpoint."""
from fastapi import APIRouter, Depends
from bisheng.sso_sync.domain.schemas.sg_payloads import (
SgUserSyncRequest,
)
from bisheng.sso_sync.domain.services.sg_fixed_header_auth import (
verify_sg_fixed_header,
)
from bisheng.sso_sync.domain.services.sg_users_sync_service import (
SgUsersSyncService,
)
router = APIRouter(tags=['SSO Sync'])
@router.post(
'/users/sg-sync',
summary='SG bulk user sync (HMAC-signed)',
)
async def sg_users_sync(
payload: SgUserSyncRequest,
_: None = Depends(verify_sg_fixed_header),
):
"""SG callback with ESB-shaped response body for users."""
result = await SgUsersSyncService.execute(payload)
return result.model_dump(by_alias=True)
@@ -11,9 +11,21 @@ from bisheng.sso_sync.api.endpoints.gateway_wecom_org_sync import (
from bisheng.sso_sync.api.endpoints.login_sync import (
router as login_sync_router,
)
from bisheng.sso_sync.api.endpoints.sg_departments_sync import (
router as sg_departments_sync_router,
)
from bisheng.sso_sync.api.endpoints.sg_users_sync import (
router as sg_users_sync_router,
)
from bisheng.sso_sync.api.endpoints.sg_sso_account_sync import (
router as sg_sso_account_sync_router,
)
router = APIRouter()
router.include_router(login_sync_router)
router.include_router(departments_sync_router)
router.include_router(gateway_wecom_org_sync_router)
router.include_router(sg_departments_sync_router)
router.include_router(sg_users_sync_router)
router.include_router(sg_sso_account_sync_router)
@@ -8,6 +8,9 @@ SSO_SOURCE = 'sso'
# Must match org_sync config provider enum (``wecom``).
WECOM_SOURCE = 'wecom'
# Shougang (首钢) org sync source for dedicated SG callback payloads.
SG_SOURCE = 'sg'
# Default source used by single-user login-sync and plain departments/sync
# when the caller does not explicitly specify one. Keep this aligned across
# endpoints so department upsert and subsequent login bind against the same
@@ -0,0 +1,180 @@
"""Schemas for SG (首钢) organization sync endpoint."""
from __future__ import annotations
from typing import List
from pydantic import BaseModel, ConfigDict, Field, field_validator
class SgDepartmentFieldItem(BaseModel):
"""One SG organization row in request ``Field``."""
model_config = ConfigDict(populate_by_name=True)
uuid: str = ''
code: str = ''
pid: str = ''
remark: str = ''
state: str = '0'
@field_validator(
'uuid',
'code',
'pid',
'remark',
'state',
mode='before',
)
@classmethod
def _normalize_to_str(cls, value) -> str:
if value is None:
return ''
return str(value).strip()
class SgDepartmentSyncRequest(BaseModel):
"""SG organization sync request payload."""
model_config = ConfigDict(populate_by_name=True)
mdm_id: int = Field(alias='mdmId')
business_system: int = Field(alias='BusinessSystem')
uuid: str = ''
fields: List[SgDepartmentFieldItem] = Field(default_factory=list, alias='Field')
class SgUserFieldItem(BaseModel):
"""One SG user row in request ``Field``."""
model_config = ConfigDict(populate_by_name=True)
uuid: str = ''
code: str = ''
desc34: str = ''
desc1: str = ''
desc93: str = '01'
@field_validator(
'uuid',
'code',
'desc34',
'desc1',
'desc93',
mode='before',
)
@classmethod
def _normalize_to_str(cls, value) -> str:
if value is None:
return ''
return str(value).strip()
class SgUserSyncRequest(BaseModel):
"""SG user sync request payload."""
model_config = ConfigDict(populate_by_name=True)
mdm_id: int = Field(alias='mdmId')
business_system: int = Field(alias='BusinessSystem')
uuid: str = ''
fields: List[SgUserFieldItem] = Field(default_factory=list, alias='Field')
class SgSsoHeader(BaseModel):
"""Header of SG SSO account sync payload."""
model_config = ConfigDict(populate_by_name=True)
int_key: str = Field(default='', alias='INT_KEY')
sed_name: str = Field(default='', alias='SED_NAME')
rec_name: str = Field(default='', alias='REC_NAME')
send_date: str = Field(default='', alias='SENDDATE')
send_time: str = Field(default='', alias='SENDTIME')
class SgSsoRowItem(BaseModel):
"""One row of SG SSO account sync payload."""
model_config = ConfigDict(populate_by_name=True)
person_no: str = Field(default='', alias='PersonNO')
user_name: str = Field(default='', alias='UserName')
guid: str = Field(default='', alias='Guid')
@field_validator('person_no', 'user_name', 'guid', mode='before')
@classmethod
def _normalize_to_str(cls, value) -> str:
if value is None:
return ''
return str(value).strip()
class SgSsoAccountSyncRequest(BaseModel):
"""Request payload for SG SSO account sync."""
model_config = ConfigDict(populate_by_name=True)
header: SgSsoHeader = Field(default_factory=SgSsoHeader, alias='HEADER')
rows: List[SgSsoRowItem] = Field(default_factory=list, alias='ROW')
class SgSsoAccountSyncResultItem(BaseModel):
"""One result row in SG SSO account sync response."""
model_config = ConfigDict(populate_by_name=True)
result: str = Field(default='0', alias='Result')
user_name: str = Field(default='', alias='UserName')
description: str = Field(default='success', alias='Description')
guid: str = Field(default='', alias='Guid')
class SgSsoAccountSyncResponse(BaseModel):
"""Response payload for SG SSO account sync."""
model_config = ConfigDict(populate_by_name=True)
items: List[SgSsoAccountSyncResultItem] = Field(default_factory=list, alias='TIEM')
class SgDataInfoItem(BaseModel):
"""Per-row result object under ``DATAINFO``."""
model_config = ConfigDict(populate_by_name=True)
uuid: str = ''
code: str = ''
status: str = '0'
version: str = ''
error_text: str = Field(default='', alias='errorText')
class SgDataInfos(BaseModel):
model_config = ConfigDict(populate_by_name=True)
data_info: List[SgDataInfoItem] = Field(default_factory=list, alias='DATAINFO')
class SgDataPayload(BaseModel):
model_config = ConfigDict(populate_by_name=True)
uuid: str = Field(default='', alias='UUID')
data_infos: SgDataInfos = Field(default_factory=SgDataInfos, alias='DATAINFOS')
class SgEsbPayload(BaseModel):
model_config = ConfigDict(populate_by_name=True)
code: str = Field(default='0', alias='CODE')
desc: str = Field(default='success', alias='DESC')
data: SgDataPayload = Field(default_factory=SgDataPayload, alias='DATA')
class SgDepartmentSyncResponse(BaseModel):
"""Top-level SG response payload."""
model_config = ConfigDict(populate_by_name=True)
esb: SgEsbPayload = Field(alias='ESB')
@@ -0,0 +1,214 @@
"""Service for SG (首钢) organization sync payloads."""
from __future__ import annotations
import time
from dataclasses import dataclass
import logging
from bisheng.core.context.tenant import (
bypass_tenant_filter,
current_tenant_id,
set_current_tenant_id,
)
from bisheng.database.models.department import Department, DepartmentDao
from bisheng.database.models.tenant import ROOT_TENANT_ID
from bisheng.sso_sync.domain.constants import SG_SOURCE
from bisheng.sso_sync.domain.schemas.sg_payloads import (
SgDataInfoItem,
SgDataInfos,
SgDataPayload,
SgDepartmentFieldItem,
SgDepartmentSyncRequest,
SgDepartmentSyncResponse,
SgEsbPayload,
)
logger = logging.getLogger(__name__)
@dataclass
class _NormalizedItem:
index: int
payload: SgDepartmentFieldItem
code: str
parent_code: str
name: str
status: int
class SgDepartmentsSyncService:
"""Apply SG department sync to ``department`` table by ``external_id``."""
SOURCE = SG_SOURCE
@classmethod
async def execute(
cls, payload: SgDepartmentSyncRequest,
) -> SgDepartmentSyncResponse:
normalized: list[_NormalizedItem] = []
info_map: dict[int, SgDataInfoItem] = {}
unresolved: set[int] = set()
for idx, item in enumerate(payload.fields):
try:
code = (item.code or '').strip()
if not code:
raise ValueError('code is required')
status = cls._parse_status(item.state)
normalized_item = _NormalizedItem(
index=idx,
payload=item,
code=code,
parent_code=(item.pid or '').strip(),
name=(item.remark or '').strip() or code,
status=status,
)
normalized.append(normalized_item)
unresolved.add(idx)
except Exception as exc: # noqa: BLE001
info_map[idx] = cls._failed_info(
item=item,
code=(item.code or '').strip(),
mdm_id=payload.mdm_id,
message=str(exc),
)
max_round = len(normalized) + 1
with bypass_tenant_filter():
token = set_current_tenant_id(ROOT_TENANT_ID)
try:
for _ in range(max_round):
progressed = False
for row in normalized:
if row.index not in unresolved:
continue
if row.parent_code and cls._parent_still_pending(
row.parent_code, normalized, unresolved,
):
continue
info_map[row.index] = await cls._apply_one(row, payload.mdm_id)
unresolved.remove(row.index)
progressed = True
if not unresolved or not progressed:
break
finally:
current_tenant_id.reset(token)
if unresolved:
idx_to_row = {row.index: row for row in normalized}
for idx in sorted(unresolved):
row = idx_to_row[idx]
msg = (
f'parent external_id={row.parent_code} not found or unresolved'
if row.parent_code else 'item unresolved'
)
info_map[idx] = cls._failed_info(
item=row.payload,
code=row.code,
mdm_id=payload.mdm_id,
message=msg,
)
ordered_infos = [info_map[i] for i in sorted(info_map.keys())]
all_success = all(one.status == '0' for one in ordered_infos)
response = SgDepartmentSyncResponse(
ESB=SgEsbPayload(
CODE='0' if all_success else '1',
DESC='success' if all_success else 'partial_failure',
DATA=SgDataPayload(
UUID=payload.uuid,
DATAINFOS=SgDataInfos(DATAINFO=ordered_infos),
),
),
)
return response
@classmethod
async def _apply_one(
cls, row: _NormalizedItem, mdm_id: int,
) -> SgDataInfoItem:
try:
parent: Department | None = None
if row.parent_code:
parent = await DepartmentDao.aget_by_external_id(
row.parent_code, ROOT_TENANT_ID,
)
if parent is None:
raise ValueError(
f'parent external_id={row.parent_code} not found',
)
ts = int(time.time())
parent_id = (
int(parent.id)
if parent is not None and parent.id is not None
else None
)
parent_path = parent.path if parent is not None else ''
await DepartmentDao.aupsert_by_external_id(
source=cls.SOURCE,
external_id=row.code,
name=row.name,
parent_id=parent_id,
path=parent_path,
sort_order=0,
last_sync_ts=ts,
tenant_id=ROOT_TENANT_ID,
)
if row.status == 1:
await DepartmentDao.aarchive_by_external_id(
cls.SOURCE, row.code, ts,
)
return SgDataInfoItem(
uuid=row.payload.uuid,
code=row.code,
status='0',
version=str(mdm_id),
errorText='',
)
except Exception as exc: # noqa: BLE001
logger.warning(
'SG department sync failed for code=%s: %s', row.code, exc,
)
return cls._failed_info(
item=row.payload,
code=row.code,
mdm_id=mdm_id,
message=str(exc),
)
@staticmethod
def _parse_status(value: str) -> int:
raw = (value or '').strip()
if raw not in {'0', '1'}:
raise ValueError('state must be 0(enabled) or 1(disabled)')
return int(raw)
@staticmethod
def _parent_still_pending(
parent_code: str,
normalized: list[_NormalizedItem],
unresolved: set[int],
) -> bool:
for row in normalized:
if row.code == parent_code and row.index in unresolved:
return True
return False
@staticmethod
def _failed_info(
*,
item: SgDepartmentFieldItem,
code: str,
mdm_id: int,
message: str,
) -> SgDataInfoItem:
return SgDataInfoItem(
uuid=item.uuid,
code=code,
status='1',
version=str(mdm_id),
errorText=message,
)
@@ -0,0 +1,46 @@
"""Fixed-header auth for SG sync endpoints."""
import logging
from fastapi import Request
from bisheng.common.errcode.sso_sync import SsoHmacInvalidError
from bisheng.common.services.config_service import settings
logger = logging.getLogger(__name__)
async def verify_sg_fixed_header(request: Request) -> None:
"""Validate SG sync requests by exact header-value matching.
Uses the configured signature header name and shared secret value:
- header name: ``settings.sso_sync.signature_header`` (default: ``X-Signature``)
- fixed value: ``settings.sso_sync.gateway_hmac_secret``
"""
expected = settings.sso_sync.gateway_hmac_secret
if not expected:
logger.error(
'SG fixed-header auth failed: sso_sync.gateway_hmac_secret is '
'not configured; rejecting request.'
)
raise SsoHmacInvalidError.http_exception('sg fixed header secret not configured')
header_name = settings.sso_sync.signature_header or 'X-Signature'
provided = (request.headers.get(header_name, '') or '').strip()
if not provided:
logger.warning(
'SG fixed-header auth failed: missing %s header from %s on %s',
header_name,
getattr(request.client, 'host', '?'),
request.url.path,
)
raise SsoHmacInvalidError.http_exception('missing sg fixed header')
if provided != expected:
logger.warning(
'SG fixed-header auth failed: header mismatch from %s on %s',
getattr(request.client, 'host', '?'),
request.url.path,
)
raise SsoHmacInvalidError.http_exception('invalid sg fixed header')
@@ -0,0 +1,114 @@
"""Service for SG SSO account info sync."""
from __future__ import annotations
from dataclasses import dataclass
import logging
import uuid
from bisheng.sso_sync.domain.schemas.sg_payloads import (
SgSsoAccountSyncRequest,
SgSsoAccountSyncResponse,
SgSsoAccountSyncResultItem,
SgSsoRowItem,
)
from bisheng.user.domain.models.user import User, UserDao
logger = logging.getLogger(__name__)
@dataclass
class _NormalizedRow:
row: SgSsoRowItem
person_no: str
user_name: str
guid: str
class SgSsoAccountSyncService:
"""Sync SG SSO account fields to ``user`` table."""
@classmethod
async def execute(
cls, payload: SgSsoAccountSyncRequest,
) -> SgSsoAccountSyncResponse:
results: list[SgSsoAccountSyncResultItem] = []
for raw in payload.rows:
try:
row = cls._normalize_row(raw)
user = await cls._resolve_target_user(row)
if user is None:
raise ValueError('user not found by PersonNO or Guid')
target_guid = row.guid or await cls._generate_unique_guid()
await cls._assert_guid_bindable(target_guid, user)
user.user_name = row.user_name
user.guid = target_guid
await UserDao.aupdate_user(user)
results.append(
SgSsoAccountSyncResultItem(
Result='0',
UserName=user.user_name,
Description='success',
Guid=user.guid or '',
)
)
except Exception as exc: # noqa: BLE001
logger.warning('SG SSO account sync row failed: %s', exc)
results.append(
SgSsoAccountSyncResultItem(
Result='1',
UserName=(raw.user_name or '').strip(),
Description=str(exc),
Guid=(raw.guid or '').strip(),
)
)
return SgSsoAccountSyncResponse(TIEM=results)
@classmethod
async def _resolve_target_user(cls, row: _NormalizedRow) -> User | None:
user = await UserDao.aget_by_external_id(row.person_no)
if user is not None:
return user
if row.guid:
return await UserDao.aget_by_guid(row.guid)
return None
@staticmethod
def _normalize_row(raw: SgSsoRowItem) -> _NormalizedRow:
person_no = (raw.person_no or '').strip()
if not person_no:
raise ValueError('PersonNO is required')
user_name = (raw.user_name or '').strip()
if not user_name:
raise ValueError('UserName is required')
guid = (raw.guid or '').strip()
return _NormalizedRow(
row=raw,
person_no=person_no,
user_name=user_name,
guid=guid,
)
@classmethod
async def _generate_unique_guid(cls) -> str:
for _ in range(8):
candidate = str(uuid.uuid4())
exists = await UserDao.aget_by_guid(candidate)
if exists is None:
return candidate
raise ValueError('failed to generate unique guid')
@classmethod
async def _assert_guid_bindable(cls, guid: str, target_user: User) -> None:
owner = await UserDao.aget_by_guid(guid)
if owner is None:
return
if int(owner.user_id or 0) == int(target_user.user_id or 0):
return
raise ValueError('Guid already bound to another user')
@@ -0,0 +1,188 @@
"""Service for SG (首钢) user sync payloads."""
from __future__ import annotations
from dataclasses import dataclass
import logging
from bisheng.core.context.tenant import (
bypass_tenant_filter,
current_tenant_id,
set_current_tenant_id,
)
from bisheng.database.models.department import DepartmentDao
from bisheng.database.models.tenant import ROOT_TENANT_ID
from bisheng.sso_sync.domain.constants import SG_SOURCE
from bisheng.sso_sync.domain.schemas.sg_payloads import (
SgDataInfoItem,
SgDataInfos,
SgDataPayload,
SgEsbPayload,
SgUserFieldItem,
SgUserSyncRequest,
SgDepartmentSyncResponse,
)
from bisheng.user.domain.models.user import User, UserDao
logger = logging.getLogger(__name__)
@dataclass
class _NormalizedUserItem:
index: int
payload: SgUserFieldItem
external_id: str
dept_external_id: str
remark: str
delete_flag: int
class SgUsersSyncService:
"""Apply SG user sync to ``user`` table."""
SOURCE = SG_SOURCE
DISABLE_SOURCE = 'sg_sync'
@classmethod
async def execute(
cls, payload: SgUserSyncRequest,
) -> SgDepartmentSyncResponse:
normalized: list[_NormalizedUserItem] = []
info_map: dict[int, SgDataInfoItem] = {}
for idx, item in enumerate(payload.fields):
try:
external_id = (item.code or '').strip()
if not external_id:
raise ValueError('code is required')
dept_external_id = (item.desc34 or '').strip()
if not dept_external_id:
raise ValueError('desc34 is required')
normalized.append(
_NormalizedUserItem(
index=idx,
payload=item,
external_id=external_id,
dept_external_id=dept_external_id,
remark=(item.desc1 or '').strip(),
delete_flag=cls._parse_delete_flag(item.desc93),
)
)
except Exception as exc: # noqa: BLE001
info_map[idx] = cls._failed_info(
item=item,
code=(item.code or '').strip(),
mdm_id=payload.mdm_id,
message=str(exc),
)
with bypass_tenant_filter():
token = set_current_tenant_id(ROOT_TENANT_ID)
try:
for row in normalized:
info_map[row.index] = await cls._apply_one(row, payload.mdm_id)
finally:
current_tenant_id.reset(token)
ordered_infos = [info_map[i] for i in sorted(info_map.keys())]
all_success = all(one.status == '0' for one in ordered_infos)
return SgDepartmentSyncResponse(
ESB=SgEsbPayload(
CODE='0' if all_success else '1',
DESC='success' if all_success else 'partial_failure',
DATA=SgDataPayload(
UUID=payload.uuid,
DATAINFOS=SgDataInfos(DATAINFO=ordered_infos),
),
),
)
@classmethod
async def _apply_one(
cls, row: _NormalizedUserItem, mdm_id: int,
) -> SgDataInfoItem:
try:
dept = await DepartmentDao.aget_by_source_external_id(
cls.SOURCE, row.dept_external_id,
)
if dept is None:
# Fallback for historical rows without source tagging.
dept = await DepartmentDao.aget_by_external_id(
row.dept_external_id, ROOT_TENANT_ID,
)
if dept is None or dept.id is None:
raise ValueError(
f'department external_id={row.dept_external_id} not found',
)
user = await UserDao.aget_by_source_external_id(
cls.SOURCE, row.external_id,
)
if user is None:
user = User(
user_name=row.remark or row.external_id,
email=None,
phone_number=None,
dept_id=str(int(dept.id)),
remark=row.remark or None,
source=cls.SOURCE,
external_id=row.external_id,
password='',
delete=row.delete_flag,
disable_source=(
cls.DISABLE_SOURCE if row.delete_flag == 1 else None
),
)
await UserDao.add_user_and_default_role(user)
else:
user.dept_id = str(int(dept.id))
user.remark = row.remark or None
user.delete = row.delete_flag
user.disable_source = (
cls.DISABLE_SOURCE if row.delete_flag == 1 else None
)
await UserDao.aupdate_user(user)
return SgDataInfoItem(
uuid=row.payload.uuid,
code=row.external_id,
status='0',
version=str(mdm_id),
errorText='',
)
except Exception as exc: # noqa: BLE001
logger.warning(
'SG user sync failed for code=%s: %s', row.external_id, exc,
)
return cls._failed_info(
item=row.payload,
code=row.external_id,
mdm_id=mdm_id,
message=str(exc),
)
@staticmethod
def _parse_delete_flag(status: str) -> int:
raw = (status or '').strip()
if raw == '01':
return 0
if raw == '02':
return 1
raise ValueError('desc93 must be 01(on-job) or 02(off-job)')
@staticmethod
def _failed_info(
*,
item: SgUserFieldItem,
code: str,
mdm_id: int,
message: str,
) -> SgDataInfoItem:
return SgDataInfoItem(
uuid=item.uuid,
code=code,
status='1',
version=str(mdm_id),
errorText=message,
)
@@ -41,6 +41,13 @@ class UserBase(SQLModelSerializable):
comment='External employee ID for sync',
),
)
guid: Optional[str] = Field(
default=None,
sa_column=Column(
String(64), nullable=True, index=True,
comment='SSO account GUID',
),
)
delete: int = Field(default=0, index=False)
disable_source: Optional[str] = Field(
default=None,
@@ -471,6 +478,14 @@ class UserDao(UserBase):
result = await session.exec(statement)
return result.first()
@classmethod
async def aget_by_guid(cls, guid: str) -> Optional['User']:
"""Get user by SSO guid globally."""
async with get_async_db_session() as session:
statement = select(User).where(User.guid == guid)
result = await session.exec(statement)
return result.first()
@classmethod
async def aget_users_by_external_id(cls, external_id: str) -> List['User']:
"""Get all users by external_id globally, including soft-deleted rows."""
+23
View File
@@ -76,3 +76,26 @@ def disable_sso_secret(monkeypatch):
)
monkeypatch.setattr(mod, 'settings', SimpleNamespace(sso_sync=conf))
return conf
@pytest.fixture
def configure_sg_fixed_header(monkeypatch, hmac_secret):
"""Install SSOSyncConf for SG fixed-header auth (``verify_sg_fixed_header``)."""
import bisheng.sso_sync.domain.services.sg_fixed_header_auth as mod
conf = SSOSyncConf(
gateway_hmac_secret=hmac_secret,
signature_header='X-Signature',
)
monkeypatch.setattr(mod, 'settings', SimpleNamespace(sso_sync=conf))
return conf
@pytest.fixture
def disable_sg_fixed_header(monkeypatch):
"""Force empty secret for SG fixed-header fail-closed branch."""
import bisheng.sso_sync.domain.services.sg_fixed_header_auth as mod
conf = SSOSyncConf(gateway_hmac_secret='', signature_header='X-Signature')
monkeypatch.setattr(mod, 'settings', SimpleNamespace(sso_sync=conf))
return conf
@@ -0,0 +1,255 @@
"""Integration tests for SG sync API routes.
Mounts ``sso_sync_router`` on a minimal FastAPI app and verifies:
- Fixed-header auth wiring on three SG endpoints
- Request alias parsing (``mdmId``, ``Field``, ``ROW``, etc.)
- Service call-through with ESB / TIEM response shapes
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock
import pytest
from fastapi import APIRouter, FastAPI
from starlette.testclient import TestClient
from bisheng.sso_sync.domain.schemas.sg_payloads import (
SgDataInfoItem,
SgDataInfos,
SgDataPayload,
SgDepartmentSyncResponse,
SgEsbPayload,
SgSsoAccountSyncResponse,
SgSsoAccountSyncResultItem,
)
pytest_plugins = ['test.fixtures.sso_sync']
DEPTS_PATH = '/api/v1/departments/sg-sync'
USERS_PATH = '/api/v1/users/sg-sync'
SSO_PATH = '/api/v1/users/sg-sso-sync'
SG_HEADER = 'X-Signature'
def _mount_app() -> FastAPI:
from bisheng.sso_sync.api.router import router as sso_sync_router
app = FastAPI()
api = APIRouter(prefix='/api/v1')
api.include_router(sso_sync_router)
app.include_router(api)
return app
def _esb_response(*, code: str = '0') -> SgDepartmentSyncResponse:
return SgDepartmentSyncResponse(
ESB=SgEsbPayload(
CODE=code,
DESC='success' if code == '0' else 'partial_failure',
DATA=SgDataPayload(
UUID='resp-uuid',
DATAINFOS=SgDataInfos(
DATAINFO=[
SgDataInfoItem(
uuid='u1',
code='D1',
status='0',
version='1',
errorText='',
),
],
),
),
),
)
def _sso_response() -> SgSsoAccountSyncResponse:
return SgSsoAccountSyncResponse(
TIEM=[
SgSsoAccountSyncResultItem(
Result='0',
UserName='Alice',
Description='success',
Guid='guid-1',
),
],
)
@pytest.fixture()
def mocked_sg_services(monkeypatch):
import bisheng.sso_sync.api.endpoints.sg_departments_sync as depts_ep
import bisheng.sso_sync.api.endpoints.sg_sso_account_sync as sso_ep
import bisheng.sso_sync.api.endpoints.sg_users_sync as users_ep
depts_service = AsyncMock(return_value=_esb_response())
users_service = AsyncMock(return_value=_esb_response())
sso_service = AsyncMock(return_value=_sso_response())
monkeypatch.setattr(
depts_ep.SgDepartmentsSyncService, 'execute', depts_service,
)
monkeypatch.setattr(
users_ep.SgUsersSyncService, 'execute', users_service,
)
monkeypatch.setattr(
sso_ep.SgSsoAccountSyncService, 'execute', sso_service,
)
return SimpleNamespace(
depts=depts_service,
users=users_service,
sso=sso_service,
)
class TestSgDepartmentsSyncRoute:
def test_valid_header_invokes_service(
self, configure_sg_fixed_header, hmac_secret, mocked_sg_services,
):
app = _mount_app()
body = {
'mdmId': 7,
'BusinessSystem': 1,
'uuid': 'batch-1',
'Field': [
{
'uuid': 'u1',
'code': 'D1',
'pid': '',
'remark': 'Dept',
'state': '0',
},
],
}
with TestClient(app) as client:
resp = client.post(
DEPTS_PATH,
json=body,
headers={SG_HEADER: hmac_secret},
)
assert resp.status_code == 200
data = resp.json()
assert data['ESB']['CODE'] == '0'
assert data['ESB']['DATA']['UUID'] == 'resp-uuid'
mocked_sg_services.depts.assert_awaited_once()
payload = mocked_sg_services.depts.await_args.args[0]
assert payload.mdm_id == 7
assert payload.fields[0].code == 'D1'
def test_invalid_header_rejected(
self, configure_sg_fixed_header, mocked_sg_services,
):
app = _mount_app()
with TestClient(app) as client:
resp = client.post(
DEPTS_PATH,
json={'mdmId': 1, 'BusinessSystem': 1, 'Field': []},
headers={SG_HEADER: 'wrong-secret'},
)
assert resp.status_code == 19301
mocked_sg_services.depts.assert_not_awaited()
class TestSgUsersSyncRoute:
def test_valid_header_invokes_service(
self, configure_sg_fixed_header, hmac_secret, mocked_sg_services,
):
app = _mount_app()
body = {
'mdmId': 8,
'BusinessSystem': 1,
'Field': [
{
'uuid': 'u1',
'code': 'U1',
'desc34': 'DEPT1',
'desc1': 'Alice',
'desc93': '01',
},
],
}
with TestClient(app) as client:
resp = client.post(
USERS_PATH,
json=body,
headers={SG_HEADER: hmac_secret},
)
assert resp.status_code == 200
assert resp.json()['ESB']['CODE'] == '0'
mocked_sg_services.users.assert_awaited_once()
payload = mocked_sg_services.users.await_args.args[0]
assert payload.fields[0].desc34 == 'DEPT1'
class TestSgSsoAccountSyncRoute:
def test_valid_header_invokes_service(
self, configure_sg_fixed_header, hmac_secret, mocked_sg_services,
):
app = _mount_app()
body = {
'HEADER': {
'INT_KEY': 'k',
'SED_NAME': 'sender',
'REC_NAME': 'receiver',
'SENDDATE': '20260422',
'SENDTIME': '120000',
},
'ROW': [
{
'PersonNO': 'P001',
'UserName': 'Alice',
'Guid': 'guid-1',
},
],
}
with TestClient(app) as client:
resp = client.post(
SSO_PATH,
json=body,
headers={SG_HEADER: hmac_secret},
)
assert resp.status_code == 200
data = resp.json()
assert data['TIEM'][0]['Result'] == '0'
assert data['TIEM'][0]['Guid'] == 'guid-1'
mocked_sg_services.sso.assert_awaited_once()
payload = mocked_sg_services.sso.await_args.args[0]
assert payload.rows[0].person_no == 'P001'
def test_missing_header_rejected(
self, configure_sg_fixed_header, mocked_sg_services,
):
app = _mount_app()
with TestClient(app) as client:
resp = client.post(
SSO_PATH,
json={'ROW': []},
)
assert resp.status_code == 19301
mocked_sg_services.sso.assert_not_awaited()
def test_empty_secret_fail_closed(
self, disable_sg_fixed_header, hmac_secret, mocked_sg_services,
):
app = _mount_app()
with TestClient(app) as client:
resp = client.post(
SSO_PATH,
json={'ROW': []},
headers={SG_HEADER: hmac_secret},
)
assert resp.status_code == 19301
mocked_sg_services.sso.assert_not_awaited()
@@ -0,0 +1,164 @@
"""Tests for ``SgDepartmentsSyncService`` — SG organization sync."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from bisheng.sso_sync.domain.schemas.sg_payloads import (
SgDepartmentFieldItem,
SgDepartmentSyncRequest,
)
MODULE = 'bisheng.sso_sync.domain.services.sg_departments_sync_service'
def _dept(*, dept_id: int = 100, path: str = '/1/100/'):
return SimpleNamespace(id=dept_id, path=path)
def _request(*fields: SgDepartmentFieldItem, mdm_id: int = 42, uuid: str = 'batch-uuid'):
return SgDepartmentSyncRequest(
mdmId=mdm_id,
BusinessSystem=1,
uuid=uuid,
Field=list(fields),
)
@pytest.mark.asyncio
class TestSgDepartmentsSyncService:
async def test_root_department_upsert_success(self):
from bisheng.sso_sync.domain.services.sg_departments_sync_service import (
SgDepartmentsSyncService,
)
payload = _request(
SgDepartmentFieldItem(uuid='u1', code='D1', remark='Engineering', state='0'),
)
with patch(
f'{MODULE}.DepartmentDao.aupsert_by_external_id',
new_callable=AsyncMock,
) as upsert, patch(
f'{MODULE}.DepartmentDao.aarchive_by_external_id',
new_callable=AsyncMock,
) as archive:
response = await SgDepartmentsSyncService.execute(payload)
assert response.esb.code == '0'
assert response.esb.desc == 'success'
infos = response.esb.data.data_infos.data_info
assert len(infos) == 1
assert infos[0].status == '0'
assert infos[0].code == 'D1'
assert infos[0].version == '42'
upsert.assert_awaited_once()
archive.assert_not_awaited()
async def test_child_department_resolves_parent_in_same_batch(self):
from bisheng.sso_sync.domain.services.sg_departments_sync_service import (
SgDepartmentsSyncService,
)
payload = _request(
SgDepartmentFieldItem(uuid='u1', code='P1', remark='Parent', state='0'),
SgDepartmentFieldItem(
uuid='u2', code='C1', pid='P1', remark='Child', state='0',
),
)
parent = _dept(dept_id=10, path='/1/10/')
async def _upsert(**kwargs):
if kwargs['external_id'] == 'P1':
return parent
return _dept(dept_id=11, path='/1/10/11/')
with patch(
f'{MODULE}.DepartmentDao.aget_by_external_id',
new_callable=AsyncMock,
side_effect=lambda ext, _tid: parent if ext == 'P1' else None,
), patch(
f'{MODULE}.DepartmentDao.aupsert_by_external_id',
new_callable=AsyncMock,
side_effect=_upsert,
):
response = await SgDepartmentsSyncService.execute(payload)
assert response.esb.code == '0'
infos = response.esb.data.data_infos.data_info
assert [one.code for one in infos] == ['P1', 'C1']
assert all(one.status == '0' for one in infos)
async def test_disabled_department_triggers_archive(self):
from bisheng.sso_sync.domain.services.sg_departments_sync_service import (
SgDepartmentsSyncService,
)
payload = _request(
SgDepartmentFieldItem(uuid='u1', code='D1', remark='Archived', state='1'),
)
with patch(
f'{MODULE}.DepartmentDao.aupsert_by_external_id',
new_callable=AsyncMock,
), patch(
f'{MODULE}.DepartmentDao.aarchive_by_external_id',
new_callable=AsyncMock,
) as archive:
response = await SgDepartmentsSyncService.execute(payload)
assert response.esb.code == '0'
archive.assert_awaited_once()
assert archive.await_args.args[:2] == ('sg', 'D1')
async def test_missing_code_returns_row_failure(self):
from bisheng.sso_sync.domain.services.sg_departments_sync_service import (
SgDepartmentsSyncService,
)
payload = _request(SgDepartmentFieldItem(uuid='u1', code=''))
response = await SgDepartmentsSyncService.execute(payload)
assert response.esb.code == '1'
info = response.esb.data.data_infos.data_info[0]
assert info.status == '1'
assert 'code is required' in info.error_text
async def test_invalid_state_returns_row_failure(self):
from bisheng.sso_sync.domain.services.sg_departments_sync_service import (
SgDepartmentsSyncService,
)
payload = _request(
SgDepartmentFieldItem(uuid='u1', code='D1', state='9'),
)
response = await SgDepartmentsSyncService.execute(payload)
assert response.esb.code == '1'
info = response.esb.data.data_infos.data_info[0]
assert info.status == '1'
assert 'state must be 0(enabled) or 1(disabled)' in info.error_text
async def test_parent_not_found_marks_row_failed(self):
from bisheng.sso_sync.domain.services.sg_departments_sync_service import (
SgDepartmentsSyncService,
)
payload = _request(
SgDepartmentFieldItem(
uuid='u1', code='C1', pid='MISSING', remark='Child', state='0',
),
)
with patch(
f'{MODULE}.DepartmentDao.aget_by_external_id',
new_callable=AsyncMock,
return_value=None,
):
response = await SgDepartmentsSyncService.execute(payload)
assert response.esb.code == '1'
info = response.esb.data.data_infos.data_info[0]
assert info.status == '1'
assert 'parent external_id=MISSING not found' in info.error_text
@@ -0,0 +1,192 @@
"""Tests for ``SgSsoAccountSyncService`` — SG SSO account sync."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from bisheng.sso_sync.domain.schemas.sg_payloads import (
SgSsoAccountSyncRequest,
SgSsoRowItem,
)
MODULE = 'bisheng.sso_sync.domain.services.sg_sso_account_sync_service'
def _user(*, user_id: int = 5, guid: str | None = None, user_name: str = 'Old'):
return SimpleNamespace(
user_id=user_id,
external_id='P001',
user_name=user_name,
guid=guid,
)
def _request(*rows: SgSsoRowItem):
return SgSsoAccountSyncRequest(ROW=list(rows))
@pytest.mark.asyncio
class TestSgSsoAccountSyncService:
async def test_resolve_user_by_person_no_and_update(self):
from bisheng.sso_sync.domain.services.sg_sso_account_sync_service import (
SgSsoAccountSyncService,
)
target = _user(guid='existing-guid')
payload = _request(
SgSsoRowItem(
PersonNO='P001',
UserName='New Name',
Guid='existing-guid',
),
)
with patch(
f'{MODULE}.UserDao.aget_by_external_id',
new_callable=AsyncMock,
return_value=target,
), patch(
f'{MODULE}.UserDao.aget_by_guid',
new_callable=AsyncMock,
return_value=target,
), patch(
f'{MODULE}.UserDao.aupdate_user',
new_callable=AsyncMock,
) as update_user:
response = await SgSsoAccountSyncService.execute(payload)
assert len(response.items) == 1
item = response.items[0]
assert item.result == '0'
assert item.user_name == 'New Name'
assert item.guid == 'existing-guid'
updated = update_user.await_args.args[0]
assert updated.user_name == 'New Name'
assert updated.guid == 'existing-guid'
async def test_fallback_resolve_user_by_guid(self):
from bisheng.sso_sync.domain.services.sg_sso_account_sync_service import (
SgSsoAccountSyncService,
)
target = _user(guid='guid-only')
payload = _request(
SgSsoRowItem(
PersonNO='P001',
UserName='Guid User',
Guid='guid-only',
),
)
with patch(
f'{MODULE}.UserDao.aget_by_external_id',
new_callable=AsyncMock,
return_value=None,
), patch(
f'{MODULE}.UserDao.aget_by_guid',
new_callable=AsyncMock,
return_value=target,
), patch(
f'{MODULE}.UserDao.aupdate_user',
new_callable=AsyncMock,
):
response = await SgSsoAccountSyncService.execute(payload)
assert response.items[0].result == '0'
assert response.items[0].guid == 'guid-only'
async def test_empty_guid_generates_uuid(self):
from bisheng.sso_sync.domain.services.sg_sso_account_sync_service import (
SgSsoAccountSyncService,
)
target = _user(guid=None)
payload = _request(
SgSsoRowItem(PersonNO='P001', UserName='Alice', Guid=''),
)
with patch(
f'{MODULE}.UserDao.aget_by_external_id',
new_callable=AsyncMock,
return_value=target,
), patch(
f'{MODULE}.UserDao.aget_by_guid',
new_callable=AsyncMock,
return_value=None,
), patch(
f'{MODULE}.UserDao.aupdate_user',
new_callable=AsyncMock,
) as update_user:
response = await SgSsoAccountSyncService.execute(payload)
assert response.items[0].result == '0'
generated = update_user.await_args.args[0].guid
assert generated
assert response.items[0].guid == generated
async def test_user_not_found_returns_failure_row(self):
from bisheng.sso_sync.domain.services.sg_sso_account_sync_service import (
SgSsoAccountSyncService,
)
payload = _request(
SgSsoRowItem(PersonNO='MISSING', UserName='Nobody', Guid=''),
)
with patch(
f'{MODULE}.UserDao.aget_by_external_id',
new_callable=AsyncMock,
return_value=None,
), patch(
f'{MODULE}.UserDao.aget_by_guid',
new_callable=AsyncMock,
return_value=None,
):
response = await SgSsoAccountSyncService.execute(payload)
item = response.items[0]
assert item.result == '1'
assert 'user not found by PersonNO or Guid' in item.description
async def test_guid_bound_to_other_user_fails(self):
from bisheng.sso_sync.domain.services.sg_sso_account_sync_service import (
SgSsoAccountSyncService,
)
target = _user(user_id=5)
other = _user(user_id=99, guid='taken-guid')
payload = _request(
SgSsoRowItem(
PersonNO='P001',
UserName='Alice',
Guid='taken-guid',
),
)
with patch(
f'{MODULE}.UserDao.aget_by_external_id',
new_callable=AsyncMock,
return_value=target,
), patch(
f'{MODULE}.UserDao.aget_by_guid',
new_callable=AsyncMock,
return_value=other,
):
response = await SgSsoAccountSyncService.execute(payload)
item = response.items[0]
assert item.result == '1'
assert 'Guid already bound to another user' in item.description
async def test_missing_person_no_returns_validation_failure(self):
from bisheng.sso_sync.domain.services.sg_sso_account_sync_service import (
SgSsoAccountSyncService,
)
payload = _request(
SgSsoRowItem(PersonNO='', UserName='Alice', Guid=''),
)
response = await SgSsoAccountSyncService.execute(payload)
item = response.items[0]
assert item.result == '1'
assert 'PersonNO is required' in item.description
@@ -0,0 +1,209 @@
"""Tests for ``SgUsersSyncService`` — SG user sync."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from bisheng.sso_sync.domain.schemas.sg_payloads import (
SgUserFieldItem,
SgUserSyncRequest,
)
MODULE = 'bisheng.sso_sync.domain.services.sg_users_sync_service'
def _dept(*, dept_id: int = 200):
return SimpleNamespace(id=dept_id, external_id='DEPT1')
def _user(*, user_id: int = 9, external_id: str = 'U1'):
return SimpleNamespace(
user_id=user_id,
external_id=external_id,
user_name='Old Name',
dept_id='1',
remark='Old',
delete=0,
disable_source=None,
)
def _request(*fields: SgUserFieldItem, mdm_id: int = 99):
return SgUserSyncRequest(
mdmId=mdm_id,
BusinessSystem=1,
uuid='user-batch',
Field=list(fields),
)
@pytest.mark.asyncio
class TestSgUsersSyncService:
async def test_create_new_user_success(self):
from bisheng.sso_sync.domain.services.sg_users_sync_service import (
SgUsersSyncService,
)
payload = _request(
SgUserFieldItem(
uuid='u1',
code='U1',
desc34='DEPT1',
desc1='Alice',
desc93='01',
),
)
with patch(
f'{MODULE}.DepartmentDao.aget_by_source_external_id',
new_callable=AsyncMock,
return_value=_dept(),
), patch(
f'{MODULE}.UserDao.aget_by_source_external_id',
new_callable=AsyncMock,
return_value=None,
), patch(
f'{MODULE}.UserDao.add_user_and_default_role',
new_callable=AsyncMock,
) as add_user:
response = await SgUsersSyncService.execute(payload)
assert response.esb.code == '0'
info = response.esb.data.data_infos.data_info[0]
assert info.status == '0'
assert info.code == 'U1'
created = add_user.await_args.args[0]
assert created.external_id == 'U1'
assert created.user_name == 'Alice'
assert created.dept_id == '200'
assert created.delete == 0
async def test_update_existing_user_success(self):
from bisheng.sso_sync.domain.services.sg_users_sync_service import (
SgUsersSyncService,
)
existing = _user()
payload = _request(
SgUserFieldItem(
uuid='u1',
code='U1',
desc34='DEPT1',
desc1='Alice Updated',
desc93='01',
),
)
with patch(
f'{MODULE}.DepartmentDao.aget_by_source_external_id',
new_callable=AsyncMock,
return_value=_dept(),
), patch(
f'{MODULE}.UserDao.aget_by_source_external_id',
new_callable=AsyncMock,
return_value=existing,
), patch(
f'{MODULE}.UserDao.aupdate_user',
new_callable=AsyncMock,
) as update_user:
response = await SgUsersSyncService.execute(payload)
assert response.esb.code == '0'
update_user.assert_awaited_once()
updated = update_user.await_args.args[0]
assert updated.remark == 'Alice Updated'
assert updated.dept_id == '200'
assert updated.delete == 0
assert updated.disable_source is None
async def test_off_job_user_sets_delete_and_disable_source(self):
from bisheng.sso_sync.domain.services.sg_users_sync_service import (
SgUsersSyncService,
)
existing = _user()
payload = _request(
SgUserFieldItem(
uuid='u1',
code='U1',
desc34='DEPT1',
desc1='Alice',
desc93='02',
),
)
with patch(
f'{MODULE}.DepartmentDao.aget_by_source_external_id',
new_callable=AsyncMock,
return_value=_dept(),
), patch(
f'{MODULE}.UserDao.aget_by_source_external_id',
new_callable=AsyncMock,
return_value=existing,
), patch(
f'{MODULE}.UserDao.aupdate_user',
new_callable=AsyncMock,
) as update_user:
response = await SgUsersSyncService.execute(payload)
updated = update_user.await_args.args[0]
assert updated.delete == 1
assert updated.disable_source == 'sg_sync'
async def test_department_not_found_marks_row_failed(self):
from bisheng.sso_sync.domain.services.sg_users_sync_service import (
SgUsersSyncService,
)
payload = _request(
SgUserFieldItem(
uuid='u1', code='U1', desc34='MISSING', desc1='Bob', desc93='01',
),
)
with patch(
f'{MODULE}.DepartmentDao.aget_by_source_external_id',
new_callable=AsyncMock,
return_value=None,
), patch(
f'{MODULE}.DepartmentDao.aget_by_external_id',
new_callable=AsyncMock,
return_value=None,
):
response = await SgUsersSyncService.execute(payload)
assert response.esb.code == '1'
info = response.esb.data.data_infos.data_info[0]
assert info.status == '1'
assert 'department external_id=MISSING not found' in info.error_text
async def test_missing_desc34_returns_validation_failure(self):
from bisheng.sso_sync.domain.services.sg_users_sync_service import (
SgUsersSyncService,
)
payload = _request(
SgUserFieldItem(uuid='u1', code='U1', desc34='', desc93='01'),
)
response = await SgUsersSyncService.execute(payload)
assert response.esb.code == '1'
info = response.esb.data.data_infos.data_info[0]
assert info.status == '1'
assert 'desc34 is required' in info.error_text
async def test_invalid_desc93_returns_validation_failure(self):
from bisheng.sso_sync.domain.services.sg_users_sync_service import (
SgUsersSyncService,
)
payload = _request(
SgUserFieldItem(
uuid='u1', code='U1', desc34='DEPT1', desc93='99',
),
)
response = await SgUsersSyncService.execute(payload)
assert response.esb.code == '1'
info = response.esb.data.data_infos.data_info[0]
assert 'desc93 must be 01(on-job) or 02(off-job)' in info.error_text