mirror of
https://github.com/dataelement/bisheng.git
synced 2026-09-21 04:26:48 +08:00
feat:
1 增加首钢用户和组织同步接口 2 增加cursor的rules和skill
This commit is contained in:
@@ -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`(项目根目录)
|
||||
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"afterFileEdit": [
|
||||
{
|
||||
"command": ".cursor/hooks/ruff-format.sh"
|
||||
},
|
||||
{
|
||||
"command": ".cursor/hooks/arch-guard.sh"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Executable
+12
@@ -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
|
||||
Executable
+25
@@ -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
|
||||
@@ -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`.
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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 check(hook 自动处理) |
|
||||
|
||||
---
|
||||
|
||||
## 判定规则
|
||||
|
||||
| 结果 | 条件 | 动作 |
|
||||
|------|------|------|
|
||||
| **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
|
||||
```
|
||||
@@ -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 2:AC 分析(仅 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 | 跳过: K(UI 交互类)| 失败: 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(那是安全底线)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 陷阱 4:OpenFGA 权限未同步
|
||||
|
||||
**症状**:创建资源后,同一用户立即查询却被权限拒绝。
|
||||
|
||||
**原因**:资源创建时应同步写入 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 元组没写入
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 陷阱 5:cleanup 顺序错误
|
||||
|
||||
**症状**:`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"] # 总数
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 陷阱 8:WebSocket 测试
|
||||
|
||||
**症状**: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()
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 陷阱 9:RSA 公钥缓存
|
||||
|
||||
**症状**:多个测试用不同用户登录,部分登录失败。
|
||||
|
||||
**原因**:公钥可能在短时间内变化,或 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 一个 TestClass,fixture 管理生命周期
|
||||
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 自动注入,不需手动设置)
|
||||
|
||||
# 验证:不同租户的用户看不到此数据
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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
|
||||
- 前端任务必须区分 Platform(src/frontend/platform/)和 Client(src/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
|
||||
@@ -0,0 +1,107 @@
|
||||
---
|
||||
name: task-review
|
||||
description: L1 任务级代码审查。在每个任务完成后执行轻量级约定合规检查,
|
||||
确保架构红线和编码约定在任务级别被守住,不让违规累积到特性级审查(L2)才发现。
|
||||
用法:/task-review <feature_dir> <task_id>
|
||||
TRIGGER when: 用户完成了一个 SDD 任务(实现或测试),或者用户使用 /task-review 命令。
|
||||
---
|
||||
|
||||
# Task Review Skill(L1 任务级审查)
|
||||
|
||||
## 调用方式
|
||||
|
||||
```
|
||||
/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;前端页面 PascalCase,store 文件 camelCase+Store,API 函数 camelCase;i18n 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 轮) |
|
||||
@@ -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):Integer;Number;String;Date;DateTime
|
||||
|
||||
---
|
||||
|
||||
## 二、示例数据
|
||||
### 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):Integer;Number;String;Date;DateTime
|
||||
|
||||
### 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 | STRING(8)| 人员编码(必填) | | | | | | | | |
|
||||
| Push Account | 输入 | UserName | STRING(64)| 用户名(必填) | | | | | | | | |
|
||||
| Push Account | 输入 | FullName | STRING(64)| 姓名 | | | | | | | | |
|
||||
| Push Account | 输入 | Mobile | STRING(11)| 手机号 | | | | | | | | |
|
||||
| Push Account | 输入 | Sex | STRING(1)| 性别 | | | | | | | | |
|
||||
| Push Account | 输入 | Password | STRING(64)| 密码(必填) | 初始密码,身份证后6位,后续不做更新。 | | | | | | | |
|
||||
| Push Account | 输入 | Organize_Code | STRING(16)| 所属组织机构编码 | | | | | | | | |
|
||||
| Push Account | 输入 | OrgCode | STRING(8)| 主数据组织编码 | | | | | | | | |
|
||||
| Push Account | 输入 | OrganizationId | STRING(64)| 所属机构 | 这四个字段值目前不太规范,暂不提供 | | | | | | | |
|
||||
| Push Account | 输入 | SidelingOrg | STRING(64)| 兼职组织 | | | | | | | | |
|
||||
| Push Account | 输入 | Email | STRING(64)| 邮箱 | | | | | | | | |
|
||||
| Push Account | 输入 | Duties | STRING(64)| 职务 | | | | | | | | |
|
||||
| Push Account | 输入 | IsDisabled | STRING(1)| 启用/禁用(必填) | 0:启用、1:禁用 | | | | | | | |
|
||||
| Push Account | 输入 | PostCode | STRING(64)| 岗位代码 | | | | | | | | |
|
||||
| Push Account | 输入 | PostDesc | STRING(64)| 岗位描述 | | | | | | | | |
|
||||
| Push Account | 输入 | guid | STRING(64)| 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 | STRING(64) | 开始时间 | | | | | | | | |
|
||||
| 账号回收字段 | 输入 | endToken | STRING(64) | 结束时间 | | | | | | | | |
|
||||
|
||||
#### 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 | STRING(64) | 用户名(必返回) | | | | | | | | |
|
||||
| 账号回收字段 | 输出 | FullName | STRING(64) | 姓名(必返回) | | | | | | | | |
|
||||
| 账号回收字段 | 输出 | guid | STRING(64) | GUID(必返回) | guid:业务系统返回值(是业务系统唯一主键,账号不能作为唯一主键) | | | | | | | |
|
||||
|
||||
---
|
||||
|
||||
## 四、组织字段
|
||||
> Type说明(Type Description):Integer;Number;String;Date;DateTime
|
||||
|
||||
### 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 | STRING(8)| 全称 | | | | | | | | |
|
||||
| Push Account | 输入 | FullName | STRING(64)| 简称 | | | | | | | | |
|
||||
| Push Account | 输入 | OrgId | STRING(64)| 组织编码 | | | | | | | | |
|
||||
| Push Account | 输入 | OrgParentId | STRING(11)| 父组织编码 | | | | | | | | |
|
||||
| Push Account | 输入 | ParentId | STRING(1)| 父机构 | | | | | | | | |
|
||||
| Push Account | 输入 | Organize_Code | STRING(64)| 主数据组织机构代码 | | | | | | | | |
|
||||
| Push Account | 输入 | Sup_Organize_Code | STRING(64)| 主数据父机构代码 | | | | | | | | |
|
||||
| Push Account | 输入 | isDisabled | STRING(64)| 启用/禁用(必填) | 0:启用、1:禁用 | | | | | | | |
|
||||
| Push Account | 输入 | isDeleted | STRING(64)| 删除 | 0:正常、1:删除 | | | | | | | |
|
||||
| Push Account | 输入 | guid | STRING(64)| 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
@@ -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
|
||||
Reference in New Issue
Block a user