From e79d572e0f94b325c4ffc926284a1954eb707f87 Mon Sep 17 00:00:00 2001 From: binfeng <335311125@qq.com> Date: Wed, 17 Jun 2026 18:28:05 +0800 Subject: [PATCH] =?UTF-8?q?feat:=201=20=E5=A2=9E=E5=8A=A0=E9=A6=96?= =?UTF-8?q?=E9=92=A2=E7=94=A8=E6=88=B7=E5=92=8C=E7=BB=84=E7=BB=87=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E6=8E=A5=E5=8F=A3=202=20=E5=A2=9E=E5=8A=A0cursor?= =?UTF-8?q?=E7=9A=84rules=E5=92=8Cskill?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .cursor/README.md | 63 +++++ .cursor/hooks.json | 13 + .cursor/hooks/arch-guard.sh | 12 + .cursor/hooks/ruff-format.sh | 25 ++ .cursor/rules/backend.mdc | 33 +++ .cursor/rules/bisheng-core.mdc | 67 +++++ .cursor/rules/client-frontend.mdc | 34 +++ .cursor/rules/platform-frontend.mdc | 43 +++ .cursor/skills/code-review/SKILL.md | 142 ++++++++++ .cursor/skills/e2e-test/SKILL.md | 251 +++++++++++++++++ .../e2e-test/references/common-pitfalls.md | 216 +++++++++++++++ .../e2e-test/references/test-template.md | 226 ++++++++++++++++ .cursor/skills/i18n-localizer/SKILL.md | 13 + .../i18n-localizer/resources/CONVENTIONS.md | 133 +++++++++ .../resources/CONVENTIONS_PLATFORM.md | 124 +++++++++ .../i18n-localizer/resources/INSTRUCTIONS.md | 78 ++++++ .../skills/react-component-refactor/SKILL.md | 13 + .../resources/EXAMPLES.md | 185 +++++++++++++ .../resources/GUIDELINES.md | 159 +++++++++++ .cursor/skills/sdd-review/SKILL.md | 89 ++++++ .../sdd-review/references/spec-checklist.md | 48 ++++ .../sdd-review/references/tasks-checklist.md | 55 ++++ .cursor/skills/task-review/SKILL.md | 107 ++++++++ .../task-review/references/task-checklist.md | 41 +++ docs/sg/首钢服务字段映射文档.md | 137 ++++++++++ .../alembic/versions/v2_6_0_f045_user_guid.py | 45 ++++ .../api/endpoints/sg_departments_sync.py | 29 ++ .../api/endpoints/sg_sso_account_sync.py | 29 ++ .../sso_sync/api/endpoints/sg_users_sync.py | 29 ++ src/backend/bisheng/sso_sync/api/router.py | 12 + .../bisheng/sso_sync/domain/constants.py | 3 + .../sso_sync/domain/schemas/sg_payloads.py | 180 +++++++++++++ .../services/sg_departments_sync_service.py | 214 +++++++++++++++ .../domain/services/sg_fixed_header_auth.py | 46 ++++ .../services/sg_sso_account_sync_service.py | 114 ++++++++ .../domain/services/sg_users_sync_service.py | 188 +++++++++++++ .../bisheng/user/domain/models/user.py | 15 ++ src/backend/test/fixtures/sso_sync.py | 23 ++ src/backend/test/sso_sync/__init__.py | 0 .../test/sso_sync/test_sg_api_integration.py | 255 ++++++++++++++++++ .../test_sg_departments_sync_service.py | 164 +++++++++++ .../test_sg_sso_account_sync_service.py | 192 +++++++++++++ .../sso_sync/test_sg_users_sync_service.py | 209 ++++++++++++++ 43 files changed, 4054 insertions(+) create mode 100644 .cursor/README.md create mode 100644 .cursor/hooks.json create mode 100755 .cursor/hooks/arch-guard.sh create mode 100755 .cursor/hooks/ruff-format.sh create mode 100644 .cursor/rules/backend.mdc create mode 100644 .cursor/rules/bisheng-core.mdc create mode 100644 .cursor/rules/client-frontend.mdc create mode 100644 .cursor/rules/platform-frontend.mdc create mode 100644 .cursor/skills/code-review/SKILL.md create mode 100644 .cursor/skills/e2e-test/SKILL.md create mode 100644 .cursor/skills/e2e-test/references/common-pitfalls.md create mode 100644 .cursor/skills/e2e-test/references/test-template.md create mode 100644 .cursor/skills/i18n-localizer/SKILL.md create mode 100644 .cursor/skills/i18n-localizer/resources/CONVENTIONS.md create mode 100644 .cursor/skills/i18n-localizer/resources/CONVENTIONS_PLATFORM.md create mode 100644 .cursor/skills/i18n-localizer/resources/INSTRUCTIONS.md create mode 100644 .cursor/skills/react-component-refactor/SKILL.md create mode 100644 .cursor/skills/react-component-refactor/resources/EXAMPLES.md create mode 100644 .cursor/skills/react-component-refactor/resources/GUIDELINES.md create mode 100644 .cursor/skills/sdd-review/SKILL.md create mode 100644 .cursor/skills/sdd-review/references/spec-checklist.md create mode 100644 .cursor/skills/sdd-review/references/tasks-checklist.md create mode 100644 .cursor/skills/task-review/SKILL.md create mode 100644 .cursor/skills/task-review/references/task-checklist.md create mode 100644 docs/sg/首钢服务字段映射文档.md create mode 100644 src/backend/bisheng/core/database/alembic/versions/v2_6_0_f045_user_guid.py create mode 100644 src/backend/bisheng/sso_sync/api/endpoints/sg_departments_sync.py create mode 100644 src/backend/bisheng/sso_sync/api/endpoints/sg_sso_account_sync.py create mode 100644 src/backend/bisheng/sso_sync/api/endpoints/sg_users_sync.py create mode 100644 src/backend/bisheng/sso_sync/domain/schemas/sg_payloads.py create mode 100644 src/backend/bisheng/sso_sync/domain/services/sg_departments_sync_service.py create mode 100644 src/backend/bisheng/sso_sync/domain/services/sg_fixed_header_auth.py create mode 100644 src/backend/bisheng/sso_sync/domain/services/sg_sso_account_sync_service.py create mode 100644 src/backend/bisheng/sso_sync/domain/services/sg_users_sync_service.py create mode 100644 src/backend/test/sso_sync/__init__.py create mode 100644 src/backend/test/sso_sync/test_sg_api_integration.py create mode 100644 src/backend/test/sso_sync/test_sg_departments_sync_service.py create mode 100644 src/backend/test/sso_sync/test_sg_sso_account_sync_service.py create mode 100644 src/backend/test/sso_sync/test_sg_users_sync_service.py diff --git a/.cursor/README.md b/.cursor/README.md new file mode 100644 index 000000000..0a355b418 --- /dev/null +++ b/.cursor/README.md @@ -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`(项目根目录) diff --git a/.cursor/hooks.json b/.cursor/hooks.json new file mode 100644 index 000000000..5c0ffb8b2 --- /dev/null +++ b/.cursor/hooks.json @@ -0,0 +1,13 @@ +{ + "version": 1, + "hooks": { + "afterFileEdit": [ + { + "command": ".cursor/hooks/ruff-format.sh" + }, + { + "command": ".cursor/hooks/arch-guard.sh" + } + ] + } +} diff --git a/.cursor/hooks/arch-guard.sh b/.cursor/hooks/arch-guard.sh new file mode 100755 index 000000000..a82fd0d7a --- /dev/null +++ b/.cursor/hooks/arch-guard.sh @@ -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 diff --git a/.cursor/hooks/ruff-format.sh b/.cursor/hooks/ruff-format.sh new file mode 100755 index 000000000..496221b33 --- /dev/null +++ b/.cursor/hooks/ruff-format.sh @@ -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 diff --git a/.cursor/rules/backend.mdc b/.cursor/rules/backend.mdc new file mode 100644 index 000000000..68f51f86f --- /dev/null +++ b/.cursor/rules/backend.mdc @@ -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//test_xxx.py -v +uv run ruff format && uv run ruff check --fix +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//`, not `test/` root. `asyncio_mode=auto`. diff --git a/.cursor/rules/bisheng-core.mdc b/.cursor/rules/bisheng-core.mdc new file mode 100644 index 000000000..b649878d7 --- /dev/null +++ b/.cursor/rules/bisheng-core.mdc @@ -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: `/{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 diff --git a/.cursor/rules/client-frontend.mdc b/.cursor/rules/client-frontend.mdc new file mode 100644 index 000000000..f063d2772 --- /dev/null +++ b/.cursor/rules/client-frontend.mdc @@ -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) diff --git a/.cursor/rules/platform-frontend.mdc b/.cursor/rules/platform-frontend.mdc new file mode 100644 index 000000000..15d038401 --- /dev/null +++ b/.cursor/rules/platform-frontend.mdc @@ -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 diff --git a/.cursor/skills/code-review/SKILL.md b/.cursor/skills/code-review/SKILL.md new file mode 100644 index 000000000..e10b33236 --- /dev/null +++ b/.cursor/skills/code-review/SKILL.md @@ -0,0 +1,142 @@ +--- +name: code-review +description: >- + L2 特性级多维度代码审查。Feature 全部任务完成后执行。 + 用法:/code-review --base 。 + 当用户说"代码审查"、"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**: +**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 +``` diff --git a/.cursor/skills/e2e-test/SKILL.md b/.cursor/skills/e2e-test/SKILL.md new file mode 100644 index 000000000..683f45616 --- /dev/null +++ b/.cursor/skills/e2e-test/SKILL.md @@ -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 # 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 模式) + +读取 `/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 覆盖报告: + +## 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。 diff --git a/.cursor/skills/e2e-test/references/common-pitfalls.md b/.cursor/skills/e2e-test/references/common-pitfalls.md new file mode 100644 index 000000000..509f65d0c --- /dev/null +++ b/.cursor/skills/e2e-test/references/common-pitfalls.md @@ -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 服务运行中 | diff --git a/.cursor/skills/e2e-test/references/test-template.md b/.cursor/skills/e2e-test/references/test-template.md new file mode 100644 index 000000000..fd29cdfbe --- /dev/null +++ b/.cursor/skills/e2e-test/references/test-template.md @@ -0,0 +1,226 @@ +# E2E 测试文件骨架模板 + +## 完整 pytest 模板 + +```python +""" +E2E tests for + +Prerequisites: +- Backend running on localhost:7860 +- MySQL/Redis/Milvus/ES/OpenFGA services running + +Covers: +- AC-01: +- AC-02: +""" + +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--" + +# Test tenant for multi-tenant isolation +TEST_TENANT = "e2e--tenant" + + +class TestE2E: + """E2E: """ + + # ──────── 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": "" +}) +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 自动注入,不需手动设置) + +# 验证:不同租户的用户看不到此数据 +``` diff --git a/.cursor/skills/i18n-localizer/SKILL.md b/.cursor/skills/i18n-localizer/SKILL.md new file mode 100644 index 000000000..5b6078844 --- /dev/null +++ b/.cursor/skills/i18n-localizer/SKILL.md @@ -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. diff --git a/.cursor/skills/i18n-localizer/resources/CONVENTIONS.md b/.cursor/skills/i18n-localizer/resources/CONVENTIONS.md new file mode 100644 index 000000000..443cc76f3 --- /dev/null +++ b/.cursor/skills/i18n-localizer/resources/CONVENTIONS.md @@ -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 ( +
+ {/* New nested key — use dot notation */} +

{localize("com_knowledge.title")}

+ + {/* Legacy flat key — unchanged */} + + + {/* With interpolation */} +

{localize("com_knowledge.files_count", { 0: fileCount })}

+
+ ); +} +``` + +### 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 })` | diff --git a/.cursor/skills/i18n-localizer/resources/CONVENTIONS_PLATFORM.md b/.cursor/skills/i18n-localizer/resources/CONVENTIONS_PLATFORM.md new file mode 100644 index 000000000..00444d63c --- /dev/null +++ b/.cursor/skills/i18n-localizer/resources/CONVENTIONS_PLATFORM.md @@ -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 ( +
+ {/* Default namespace (bs) */} + + + {/* Specific namespace */} +

{t('knowledge:spaceCreateSuccess')}

+ + {/* With interpolation */} +

{t('knowledge:filesCount', { 0: fileCount })}

+
+ ) +} +``` + +### 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 })` | diff --git a/.cursor/skills/i18n-localizer/resources/INSTRUCTIONS.md b/.cursor/skills/i18n-localizer/resources/INSTRUCTIONS.md new file mode 100644 index 000000000..a3ee30472 --- /dev/null +++ b/.cursor/skills/i18n-localizer/resources/INSTRUCTIONS.md @@ -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) +

松手即可上传文件至此处

+ // After +

{localize("com_knowledge.drop_to_upload")}

+ ``` + +## 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. diff --git a/.cursor/skills/react-component-refactor/SKILL.md b/.cursor/skills/react-component-refactor/SKILL.md new file mode 100644 index 000000000..06b10a240 --- /dev/null +++ b/.cursor/skills/react-component-refactor/SKILL.md @@ -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. diff --git a/.cursor/skills/react-component-refactor/resources/EXAMPLES.md b/.cursor/skills/react-component-refactor/resources/EXAMPLES.md new file mode 100644 index 000000000..aa9f2551a --- /dev/null +++ b/.cursor/skills/react-component-refactor/resources/EXAMPLES.md @@ -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 ( + 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 ( + 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 +}} +``` diff --git a/.cursor/skills/react-component-refactor/resources/GUIDELINES.md b/.cursor/skills/react-component-refactor/resources/GUIDELINES.md new file mode 100644 index 000000000..06724df65 --- /dev/null +++ b/.cursor/skills/react-component-refactor/resources/GUIDELINES.md @@ -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. diff --git a/.cursor/skills/sdd-review/SKILL.md b/.cursor/skills/sdd-review/SKILL.md new file mode 100644 index 000000000..c3037ebe8 --- /dev/null +++ b/.cursor/skills/sdd-review/SKILL.md @@ -0,0 +1,89 @@ +--- +name: sdd-review +description: 对 BiSheng 项目的 SDD 文档执行审查。 + - spec:写完 spec.md 后调用,同时检查 PRD gap 和架构合规性,生成报告供用户参考 + - tasks:写完 tasks.md 后自动调用,检查 AC 追溯、任务拆解质量和技术债预防 + 用法:/sdd-review ,doc_type 为 spec / tasks。 + TRIGGER when: 用户完成了 SDD 的 spec.md / tasks.md 编写,或者用户使用 /sdd-review 命令,或者 Claude 完成了这些文件的编写后需要审查。 +--- + +# SDD Review Skill + +## 调用方式 + +``` +/sdd-review +``` + +例: +``` +/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):执行合并审查** + +读取文件: +- `/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),供用户决定是否修改 + +**等待用户确认**(唯一手动暂停点)。用户确认后,将 `/tasks.md` 状态表中 spec.md 行更新为 `✅ 已评审`。 + +--- + +### tasks 模式(自动审查) + +**第二步(tasks):执行审查** + +读取文件: +- `/tasks.md` +- `/spec.md`(验收标准 + 技术方案) +- `features/v2.5.0/release-contract.md`(领域归属 + 不变量) + +按 `references/tasks-checklist.md` 中的检查清单执行 21 项检查。 + +**第三步(tasks):处理审查结果** + +**输出格式**: +- 有问题:`ISSUE: <描述> | SEVERITY: high/medium/low | TASK: ` +- 无问题:`LGTM` + +**处理逻辑**: +- `LGTM` → 更新 `/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",停止 diff --git a/.cursor/skills/sdd-review/references/spec-checklist.md b/.cursor/skills/sdd-review/references/spec-checklist.md new file mode 100644 index 000000000..91de1d4c2 --- /dev/null +++ b/.cursor/skills/sdd-review/references/spec-checklist.md @@ -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: | SEVERITY: high/medium/low | PRD_REF: +- FORMAT: <格式问题描述> | SEVERITY: high/medium/low +- CONFLICT: <与 INV 冲突描述> | SEVERITY: high | INV: +- ISSUE: <架构/设计问题描述> | SEVERITY: high/medium/low | AC: + +无 gap 且无问题时,只返回一行:LGTM + +注意:本报告供参考,是否修改由用户决定。不要建议修改 spec,只列出观察到的 gap 和问题。 diff --git a/.cursor/skills/sdd-review/references/tasks-checklist.md b/.cursor/skills/sdd-review/references/tasks-checklist.md new file mode 100644 index 000000000..9bd631570 --- /dev/null +++ b/.cursor/skills/sdd-review/references/tasks-checklist.md @@ -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: +无问题时,只返回一行:LGTM diff --git a/.cursor/skills/task-review/SKILL.md b/.cursor/skills/task-review/SKILL.md new file mode 100644 index 000000000..913523ff2 --- /dev/null +++ b/.cursor/skills/task-review/SKILL.md @@ -0,0 +1,107 @@ +--- +name: task-review +description: L1 任务级代码审查。在每个任务完成后执行轻量级约定合规检查, + 确保架构红线和编码约定在任务级别被守住,不让违规累积到特性级审查(L2)才发现。 + 用法:/task-review + TRIGGER when: 用户完成了一个 SDD 任务(实现或测试),或者用户使用 /task-review 命令。 +--- + +# Task Review Skill(L1 任务级审查) + +## 调用方式 + +``` +/task-review +``` + +例: +``` +/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. 从 `/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: + +**任务**: <任务标题> +**类型**: 测试 / 实现 / 基础设施 / 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` 不匹配 → 报告"未找到任务 ",停止 +- 目标文件不存在 → 标记为 WARNING(文件可能尚未创建),继续检查其他文件 diff --git a/.cursor/skills/task-review/references/task-checklist.md b/.cursor/skills/task-review/references/task-checklist.md new file mode 100644 index 000000000..d393f96e3 --- /dev/null +++ b/.cursor/skills/task-review/references/task-checklist.md @@ -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 轮) | diff --git a/docs/sg/首钢服务字段映射文档.md b/docs/sg/首钢服务字段映射文档.md new file mode 100644 index 000000000..78f6e5a23 --- /dev/null +++ b/docs/sg/首钢服务字段映射文档.md @@ -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 | 返回状态
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 | 返回状态
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 | | 反馈结果
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,均非必输 \ No newline at end of file diff --git a/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f045_user_guid.py b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f045_user_guid.py new file mode 100644 index 000000000..6b4da6ef8 --- /dev/null +++ b/src/backend/bisheng/core/database/alembic/versions/v2_6_0_f045_user_guid.py @@ -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') + diff --git a/src/backend/bisheng/sso_sync/api/endpoints/sg_departments_sync.py b/src/backend/bisheng/sso_sync/api/endpoints/sg_departments_sync.py new file mode 100644 index 000000000..10e611f91 --- /dev/null +++ b/src/backend/bisheng/sso_sync/api/endpoints/sg_departments_sync.py @@ -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) + diff --git a/src/backend/bisheng/sso_sync/api/endpoints/sg_sso_account_sync.py b/src/backend/bisheng/sso_sync/api/endpoints/sg_sso_account_sync.py new file mode 100644 index 000000000..8a7a57ba9 --- /dev/null +++ b/src/backend/bisheng/sso_sync/api/endpoints/sg_sso_account_sync.py @@ -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) + diff --git a/src/backend/bisheng/sso_sync/api/endpoints/sg_users_sync.py b/src/backend/bisheng/sso_sync/api/endpoints/sg_users_sync.py new file mode 100644 index 000000000..912555171 --- /dev/null +++ b/src/backend/bisheng/sso_sync/api/endpoints/sg_users_sync.py @@ -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) + diff --git a/src/backend/bisheng/sso_sync/api/router.py b/src/backend/bisheng/sso_sync/api/router.py index e1583c862..a6ab1a2b3 100644 --- a/src/backend/bisheng/sso_sync/api/router.py +++ b/src/backend/bisheng/sso_sync/api/router.py @@ -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) diff --git a/src/backend/bisheng/sso_sync/domain/constants.py b/src/backend/bisheng/sso_sync/domain/constants.py index 8c9fc4724..64d7181b1 100644 --- a/src/backend/bisheng/sso_sync/domain/constants.py +++ b/src/backend/bisheng/sso_sync/domain/constants.py @@ -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 diff --git a/src/backend/bisheng/sso_sync/domain/schemas/sg_payloads.py b/src/backend/bisheng/sso_sync/domain/schemas/sg_payloads.py new file mode 100644 index 000000000..92faa681a --- /dev/null +++ b/src/backend/bisheng/sso_sync/domain/schemas/sg_payloads.py @@ -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') + diff --git a/src/backend/bisheng/sso_sync/domain/services/sg_departments_sync_service.py b/src/backend/bisheng/sso_sync/domain/services/sg_departments_sync_service.py new file mode 100644 index 000000000..0a2658616 --- /dev/null +++ b/src/backend/bisheng/sso_sync/domain/services/sg_departments_sync_service.py @@ -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, + ) + diff --git a/src/backend/bisheng/sso_sync/domain/services/sg_fixed_header_auth.py b/src/backend/bisheng/sso_sync/domain/services/sg_fixed_header_auth.py new file mode 100644 index 000000000..6658d14eb --- /dev/null +++ b/src/backend/bisheng/sso_sync/domain/services/sg_fixed_header_auth.py @@ -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') + diff --git a/src/backend/bisheng/sso_sync/domain/services/sg_sso_account_sync_service.py b/src/backend/bisheng/sso_sync/domain/services/sg_sso_account_sync_service.py new file mode 100644 index 000000000..7cca04851 --- /dev/null +++ b/src/backend/bisheng/sso_sync/domain/services/sg_sso_account_sync_service.py @@ -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') + diff --git a/src/backend/bisheng/sso_sync/domain/services/sg_users_sync_service.py b/src/backend/bisheng/sso_sync/domain/services/sg_users_sync_service.py new file mode 100644 index 000000000..5fa7a1049 --- /dev/null +++ b/src/backend/bisheng/sso_sync/domain/services/sg_users_sync_service.py @@ -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, + ) + diff --git a/src/backend/bisheng/user/domain/models/user.py b/src/backend/bisheng/user/domain/models/user.py index e34d00ec5..399930db7 100644 --- a/src/backend/bisheng/user/domain/models/user.py +++ b/src/backend/bisheng/user/domain/models/user.py @@ -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.""" diff --git a/src/backend/test/fixtures/sso_sync.py b/src/backend/test/fixtures/sso_sync.py index 163da9c76..1e8d6d779 100644 --- a/src/backend/test/fixtures/sso_sync.py +++ b/src/backend/test/fixtures/sso_sync.py @@ -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 diff --git a/src/backend/test/sso_sync/__init__.py b/src/backend/test/sso_sync/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/backend/test/sso_sync/test_sg_api_integration.py b/src/backend/test/sso_sync/test_sg_api_integration.py new file mode 100644 index 000000000..82f8cc855 --- /dev/null +++ b/src/backend/test/sso_sync/test_sg_api_integration.py @@ -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() diff --git a/src/backend/test/sso_sync/test_sg_departments_sync_service.py b/src/backend/test/sso_sync/test_sg_departments_sync_service.py new file mode 100644 index 000000000..edc3f6f6e --- /dev/null +++ b/src/backend/test/sso_sync/test_sg_departments_sync_service.py @@ -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 diff --git a/src/backend/test/sso_sync/test_sg_sso_account_sync_service.py b/src/backend/test/sso_sync/test_sg_sso_account_sync_service.py new file mode 100644 index 000000000..32817d694 --- /dev/null +++ b/src/backend/test/sso_sync/test_sg_sso_account_sync_service.py @@ -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 diff --git a/src/backend/test/sso_sync/test_sg_users_sync_service.py b/src/backend/test/sso_sync/test_sg_users_sync_service.py new file mode 100644 index 000000000..985750770 --- /dev/null +++ b/src/backend/test/sso_sync/test_sg_users_sync_service.py @@ -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