Merged
feat:初始化manager #419
feat:初始化manager #419
Merged
王欣Add on Aug 14
Total: 220 files changed+40868-0
@@ -0,0 +1,209 @@
1+# Manager 管理面
2+ 
3+`applications/manager` 是 JiuwenClaw 的管理平面,包含三个子模块:
4+ 
5+| 目录 | 说明 | 默认端口 |
6+|------|------|----------|
7+| `identity_center/` | 认证中心(OAuth2 / JWT、用户与组织) | `8770` |
8+| `manager_server/` | 管理 API(实例、模板、策略等)+ Manager WebSocket | REST `8765`、WS `8766` |
9+| `manager_web/` | 管理面 Web 前端(React + Vite) | `5273` |
10+ 
11+本地开发时,**先启动身份中心,再启动管理 API,最后启动前端**。
12+ 
13+---
14+ 
15+## 环境要求
16+ 
17+- Python `>=3.11.4,<3.14`
18+- [uv](https://docs.astral.sh/uv/)(推荐)或 pip
19+- Node.js `>=18`(前端开发 / 构建)
20+- 仓库根目录下的 `foundation` 包(Python 依赖)
21+ 
22+---
23+ 
24+## 首次安装
25+ 
26+### 方式一:使用 uv(推荐)
27+ 
28+```bash
29+# 创建虚拟环境(如果尚未创建)
30+uv venv
31+ 
32+# Windows
33+.venv\Scripts\activate
34+# Linux / macOS
35+# source .venv/bin/activate
36+ 
37+# 安装 Python 依赖(一次性装齐,避免 uv sync --project 互相覆盖)
38+uv pip install -e foundation -e applications/manager/identity_center -e applications/manager/manager_server
39+ 
40+# 安装前端依赖
41+cd applications/manager/manager_web && npm install && cd ../../..
42+```
43+ 
44+> **注意**:不要用 `uv sync --project ... --active` 分别安装两个子项目——每次 sync 只保留当前项目的依赖,后执行的会把先安装的服务卸掉。应使用上面的 `uv pip install -e` 一次性安装。
45+>
46+> 激活后请确认 Python 来自本仓库 `.venv`(Windows:`where python` 应指向 `.venv\Scripts\python.exe`)。若当前是 conda 等其他环境,先 `deactivate` 再激活 `.venv`。
47+ 
48+### 方式二:使用 pip + venv
49+ 
50+```bash
51+# 创建虚拟环境
52+python -m venv .venv
53+ 
54+# Windows
55+.venv\Scripts\activate
56+# Linux / macOS
57+# source .venv/bin/activate
58+ 
59+# 安装 Python 依赖
60+pip install -e foundation
61+pip install -e applications/manager/identity_center
62+pip install -e applications/manager/manager_server
63+ 
64+# 安装前端依赖
65+cd applications/manager/manager_web && npm install && cd ../../..
66+```
67+ 
68+默认使用 SQLite,无需额外配置数据库:
69+ 
70+- 身份库:`identity.db`
71+- 管理库:`manager.db`
72+ 
73+默认登录账号见下方「本地开发启动」章节。
74+ 
75+---
76+ 
77+## 本地开发启动(推荐)
78+ 
79+开 **3 个终端**,均在仓库根目录、**已激活虚拟环境**的前提下执行。
80+ 
81+### 终端 1:身份中心
82+ 
83+```bash
84+identity-center
85+# 或: python -m identity_center.main
86+```
87+ 
88+### 终端 2:管理 API
89+ 
90+```bash
91+manager-server
92+# 或: python -m manager_server.main
93+```
94+ 
95+### 终端 3:前端开发服务器
96+ 
97+```bash
98+cd applications/manager/manager_web
99+npm run dev
100+```
101+ 
102+浏览器访问:**http://127.0.0.1:5273**
103+ 
104+### 默认登录账号
105+ 
106+首次启动 `identity-center` 后会自动创建以下账号(仅用于本地开发):
107+ 
108+| 用户名 | 密码 | 角色 | 用途 |
109+|--------|------|------|------|
110+| `admin` | `admin` | 管理员 | 登录管理面(`/manager`),可进行实例、模板、策略等配置 |
111+| `user1` | `user1` | 普通用户 | 登录用户端(`/user`),无管理权限 |
112+ 
113+Vite 开发服务器已配置代理,`/api` → `8765`、`/idp` → `8770`(会自动去掉 `/idp` 前缀)。
114+ 
115+> 若登录报 `/idp/v1/auth/token` 404,请确认 `identity-center` 已在 `8770` 端口运行,并重启 `npm run dev` 使代理配置生效。
116+`/web/invoke`、`/file-api` 依赖 Gateway / User Server,未启动时聊天相关功能不可用,管理面主体功能可正常使用。
117+ 
118+---
119+ 
120+## 生产 / 集成模式(统一入口)
121+ 
122+构建静态资源后,由 `manager-web` 提供单一 HTTP 入口(默认 `5273`),统一转发 `/api`、`/idp` 等请求。
123+ 
124+```bash
125+# 构建前端(完整集成还需构建 user_web)
126+cd applications/access/user_web && npm install && npm run build
127+cd ../../manager/manager_web && npm install && npm run build
128+cd ../../..
129+ 
130+# 启动后端(需先激活虚拟环境)
131+identity-center
132+manager-server
133+ 
134+# 启动统一 Web 入口
135+manager-web \
136+ --host 127.0.0.1 --port 5273 \
137+ --dist applications/manager/manager_web/dist \
138+ --chat-dist applications/access/user_web/dist
139+```
140+ 
141+`5273` 为外部唯一入口:
142+ 
143+- `/auth`、`/user`、`/manager` — Manager SPA
144+- `/chat/` — 同源 User Web iframe
145+- `/api` — 转发至 Manager API(`8765`)
146+- `/idp` — 转发至身份中心(`8770`)
147+- `/web/invoke`、`/file-api` — 转发至 Gateway / User Server
148+ 
149+也可一键启动 API + 已构建的 Web(需先 `npm run build`):
150+ 
151+```bash
152+manager-start # API + Web
153+manager-start manager # 仅 API
154+manager-start web # 仅 Web
155+```
156+ 
157+---
158+ 
159+## 常用环境变量
160+ 
161+可在仓库根目录创建 `.env`,各服务启动时自动读取:
162+ 
163+```env
164+# 身份中心
165+IDENTITY_REST_HOST=0.0.0.0
166+IDENTITY_REST_PORT=8770
167+IDENTITY_DB_TYPE=sqlite
168+IDENTITY_SQLITE_PATH=identity.db
169+ 
170+# 管理 API
171+MANAGER_REST_HOST=0.0.0.0
172+MANAGER_REST_PORT=8765
173+MANAGER_WS_PORT=8766
174+MANAGER_DB_TYPE=sqlite
175+MANAGER_SQLITE_PATH=manager.db
176+IDENTITY_PUBLIC_KEY_URL=http://127.0.0.1:8770/v1/auth/public_key
177+ 
178+# 统一 Web 入口(manager-web)
179+MANAGER_WEB_HOST=127.0.0.1
180+MANAGER_WEB_PORT=5273
181+MANAGER_WEB_PROXY_TARGET=http://127.0.0.1:8765
182+MANAGER_WEB_IDP_TARGET=http://127.0.0.1:8770
183+```
184+ 
185+---
186+ 
187+## 健康检查
188+ 
189+| 服务 | 地址 |
190+|------|------|
191+| 身份中心 API 文档 | http://127.0.0.1:8770/docs |
192+| 管理 API 健康检查 | http://127.0.0.1:8765/api/health |
193+| Manager WebSocket 状态 | http://127.0.0.1:8765/api/manager-ws/status |
194+| 前端 | http://127.0.0.1:5273 |
195+ 
196+---
197+ 
198+## 命令速查
199+ 
200+| 命令 | 说明 |
201+|------|------|
202+| `identity-center` | 启动身份中心 |
203+| `manager-server` | 启动管理 API + WebSocket |
204+| `manager-web` | 启动统一 Web 入口(静态资源 + 反向代理) |
205+| `manager-start` | 一键启动 API + Web |
206+| `npm run dev` | 前端开发模式(在 `manager_web/` 目录下) |
207+| `npm run build` | 构建前端静态资源到 `manager_web/dist/` |
208+ 
209+> 以上 Python 命令均需在已激活的虚拟环境中执行(uv 或 pip 安装后均可直接使用)。
@@ -0,0 +1,45 @@
1+[build-system]
2+requires = ["setuptools>=61.0", "wheel"]
3+build-backend = "setuptools.build_meta"
4+ 
5+[project]
6+# 重构:原 jiuwenclaw-identity,迁入 runtime 后改名为 identity-center
7+name = "identity-center"
8+version = "0.1.0"
9+description = "JiuwenClaw identity / auth service (OAuth2 + JWT, standalone identity DB)"
10+requires-python = ">=3.11.4,<3.14"
11+license = { text = "Apache-2.0" }
12+classifiers = [
13+ "License :: OSI Approved :: Apache Software License",
14+]
15+dependencies = [
16+ "fastapi>=0.115",
17+ "uvicorn[standard]>=0.30",
18+ "pydantic>=2.0",
19+ "pydantic-settings>=2.0",
20+ "structlog>=24.0",
21+ "PyJWT>=2.8",
22+ "cryptography>=42.0",
23+ "python-multipart>=0.0.9",
24+ # 重构:foundation 由 git 源(agent-runtime@develop)改为本仓源码,见 [tool.uv.sources]
25+ "openjiuwen-runtime-foundation",
26+ "asyncpg>=0.29",
27+]
28+ 
29+[project.optional-dependencies]
30+dev = [
31+ "pytest>=9.0.2",
32+ "pytest-asyncio>=1.3.0",
33+ "ruff>=0.6",
34+]
35+ 
36+[project.scripts]
37+identity-center = "identity_center.main:main"
38+ 
39+# 重构:foundation 改用本仓 editable 源码,不再从 gitcode 拉取
40+[tool.uv.sources]
41+openjiuwen-runtime-foundation = { path = "../../../foundation", editable = true }
42+ 
43+[tool.setuptools.packages.find]
44+where = ["src"]
45+include = ["identity_center*"]
@@ -0,0 +1,8 @@
1+"""JiuwenClaw 身份/认证服务(独立部署)。
2+ 
3+职责:OAuth2 密码流签发 JWT、身份/凭据/组织/成员的权威数据源;
4+claw_manager 与企业版 web 仅作为资源服务器验签 JWT、读取 claims。
5+认证后端可二次开发(默认本地口令库,厂商可接自有库/LDAP/OIDC)。
6+"""
7+ 
8+__version__ = "0.1.0"
@@ -0,0 +1,70 @@
1+"""身份/认证服务 FastAPI 应用工厂。"""
2+ 
3+from __future__ import annotations
4+ 
5+from contextlib import asynccontextmanager
6+ 
7+from fastapi import FastAPI
8+from fastapi.middleware.cors import CORSMiddleware
9+ 
10+from identity_center import __version__
11+from identity_center.infrastructure.config import settings
12+from identity_center.infrastructure.db import create_db_handler, database_config_summary
13+from identity_center.infrastructure.logger import configure_logging, get_logger
14+from identity_center.models.table_init import init_all_tables
15+from identity_center.routers.register import router_register
16+ 
17+_log = get_logger(__name__)
18+ 
19+ 
20+@asynccontextmanager
21+async def lifespan(application: FastAPI):
22+ configure_logging()
23+ db_handler = create_db_handler()
24+ application.state.db_handler = db_handler
25+ await db_handler.init_database()
26+ await db_handler.connect()
27+ await init_all_tables(db_handler)
28+ 
29+ from identity_center.core.auth import seed_defaults
30+ 
31+ await seed_defaults(db_handler)
32+ 
33+ # JWT 签名密钥:落库自举(缺失则生成 RSA-2048 落身份库)+ 填充进程缓存。
34+ # 多副本天然读同一行 → k8s 一致,无需 Secret 同步。
35+ from identity_center.security.jwt_keys import load_signing_key
36+ 
37+ await load_signing_key(db_handler)
38+ 
39+ _log.info(
40+ "startup",
41+ version=__version__,
42+ db=database_config_summary(),
43+ issuer=settings.jwt_issuer,
44+ audience=settings.jwt_audience,
45+ access_ttl=settings.access_ttl_seconds,
46+ )
47+ yield
48+ await db_handler.disconnect()
49+ _log.info("shutdown")
50+ 
51+ 
52+def create_app() -> FastAPI:
53+ application = FastAPI(
54+ title="identity-center",
55+ description="JiuwenClaw 身份/认证服务(OAuth2 + JWT,独立身份库)",
56+ version=__version__,
57+ lifespan=lifespan,
58+ )
59+ application.add_middleware(
60+ CORSMiddleware,
61+ allow_origins=["*"],
62+ allow_credentials=True,
63+ allow_methods=["*"],
64+ allow_headers=["*"],
65+ )
66+ router_register(application)
67+ return application
68+ 
69+ 
70+app = create_app()
@@ -0,0 +1,3 @@
1+from identity_center.core.auth.service import IdentityAuthService, seed_defaults
2+ 
3+__all__ = ("IdentityAuthService", "seed_defaults")
@@ -0,0 +1,38 @@
1+"""本地口令哈希(PBKDF2-HMAC-SHA256,标准库实现,无外部依赖)。
2+ 
3+存储格式:``pbkdf2_sha256$<iterations>$<salt_hex>$<hash_hex>``。
4+"""
5+ 
6+from __future__ import annotations
7+ 
8+import hashlib
9+import hmac
10+import secrets
11+ 
12+_ALGO = "pbkdf2_sha256"
13+_ITERATIONS = 200_000
14+ 
15+ 
16+def hash_password(password: str) -> str:
17+ salt = secrets.token_bytes(16)
18+ dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, _ITERATIONS)
19+ return f"{_ALGO}${_ITERATIONS}${salt.hex()}${dk.hex()}"
20+ 
21+ 
22+def verify_password(password: str, stored: str | None) -> bool:
23+ if not stored:
24+ return False
25+ try:
26+ algo, iter_s, salt_hex, hash_hex = stored.split("$", 3)
27+ except ValueError:
28+ return False
29+ if algo != _ALGO:
30+ return False
31+ try:
32+ iterations = int(iter_s)
33+ salt = bytes.fromhex(salt_hex)
34+ expected = bytes.fromhex(hash_hex)
35+ except ValueError:
36+ return False
37+ dk = hashlib.pbkdf2_hmac("sha256", password.encode("utf-8"), salt, iterations)
38+ return hmac.compare_digest(dk, expected)
@@ -0,0 +1,65 @@
1+"""可插拔认证 provider(认证可二次开发的扩展点)。
2+ 
3+``AuthProvider.authenticate`` 负责"验证凭据 → 返回 user_id";签发 JWT 的流程与
4+具体认证方式解耦。默认 ``LocalPasswordProvider`` 查本地 ``auth_identity``。
5+厂商接入自有库 / LDAP / OIDC:再实现一个 provider 并 ``register_provider`` 即可,
6+不动 ``IdentityAuthService`` 与表结构。
7+"""
8+ 
9+from __future__ import annotations
10+ 
11+from typing import Protocol
12+ 
13+from openjiuwen_runtime.foundation.db.handler import DBHandler
14+ 
15+from identity_center.core.auth.password import verify_password
16+from identity_center.models.identity_models import AUTH_IDENTITY_TABLE_DEF
17+ 
18+_AUTH_IDENTITY_TABLE = AUTH_IDENTITY_TABLE_DEF.table_name
19+ 
20+ 
21+class AuthProvider(Protocol):
22+ name: str
23+ 
24+ async def authenticate(
25+ self, handler: DBHandler, username: str, password: str
26+ ) -> str | None:
27+ """验证成功返回 user_id;失败返回 None。"""
28+ ...
29+ 
30+ 
31+class LocalPasswordProvider:
32+ """本地口令认证:按 (provider='local', external_subject=username) 查身份并校验口令。"""
33+ 
34+ name = "local"
35+ 
36+ async def authenticate(
37+ self, handler: DBHandler, username: str, password: str
38+ ) -> str | None:
39+ rows = await handler.list_records(
40+ _AUTH_IDENTITY_TABLE,
41+ {"provider": self.name, "external_subject": username},
42+ limit=1,
43+ offset=0,
44+ )
45+ if not rows:
46+ return None
47+ row = rows[0]
48+ if not verify_password(password, getattr(row, "credential", None)):
49+ return None
50+ return str(getattr(row, "user_id", "") or "") or None
51+ 
52+ 
53+# 注册表:二次开发时往这里加自定义 provider。
54+_PROVIDERS: dict[str, AuthProvider] = {}
55+ 
56+ 
57+def register_provider(provider: AuthProvider) -> None:
58+ _PROVIDERS[provider.name] = provider
59+ 
60+ 
61+def get_provider(name: str) -> AuthProvider | None:
62+ return _PROVIDERS.get(name)
63+ 
64+ 
65+register_provider(LocalPasswordProvider())
@@ -0,0 +1,195 @@
1+"""认证服务:provider 校验凭据 → 签发 access JWT + refresh token;刷新 / 登出 / 播种。
2+ 
3+access = 自包含 JWT(不落库,资源服务器公钥验签);refresh = 不透明串落 ``auth_session``,
4+可撤销/轮换。``IdentityAuthService.login`` 成功返回 token 束 + 用户信息。
5+"""
6+ 
7+from __future__ import annotations
8+ 
9+import secrets
10+from datetime import timedelta, timezone
11+from typing import Any
12+ 
13+from openjiuwen_runtime.foundation.db.handler import DBHandler
14+ 
15+from identity_center.core.auth.password import hash_password
16+from identity_center.core.auth.providers import get_provider
17+from identity_center.infrastructure.config import settings
18+from identity_center.infrastructure.logger import get_logger
19+from identity_center.infrastructure.utils import utc_now
20+from identity_center.models.identity_models import (
21+ APP_USER_TABLE_DEF,
22+ AUTH_IDENTITY_TABLE_DEF,
23+ AUTH_SESSION_TABLE_DEF,
24+ NO_ORG_GROUP_ID,
25+ ORG_TABLE_DEF,
26+ USER_ORG_MEMBERSHIP_TABLE_DEF,
27+)
28+from identity_center.security.tokens import issue_access_token
29+ 
30+_log = get_logger(__name__)
31+ 
32+_APP_USER = APP_USER_TABLE_DEF.table_name
33+_AUTH_IDENTITY = AUTH_IDENTITY_TABLE_DEF.table_name
34+_AUTH_SESSION = AUTH_SESSION_TABLE_DEF.table_name
35+_ORG = ORG_TABLE_DEF.table_name
36+_MEMBERSHIP = USER_ORG_MEMBERSHIP_TABLE_DEF.table_name
37+ 
38+_DEFAULT_PROVIDER = "local"
39+_CAP = 1000
40+ 
41+ 
42+def public_user(row: Any) -> dict[str, Any]:
43+ return {
44+ "user_id": getattr(row, "user_id", None),
45+ "display_name": getattr(row, "display_name", None),
46+ "is_admin": bool(getattr(row, "is_admin", False)),
47+ "status": getattr(row, "status", None),
48+ }
49+ 
50+ 
51+class IdentityAuthService:
52+ def __init__(self, handler: DBHandler) -> None:
53+ self._h = handler
54+ 
55+ async def _load_groups(self, user_id: str) -> list[str]:
56+ """用户所属真实组织 id(排除无组织保留行)。"""
57+ rows = await self._h.list_records(_MEMBERSHIP, {"user_id": user_id}, limit=_CAP, offset=0)
58+ gids = [str(getattr(r, "group_id", "")) for r in rows]
59+ return [g for g in gids if g and g != NO_ORG_GROUP_ID]
60+ 
61+ async def _issue_for(self, user: Any) -> dict[str, Any]:
62+ user_id = str(getattr(user, "user_id"))
63+ groups = await self._load_groups(user_id)
64+ access, expires_in = issue_access_token(
65+ user_id,
66+ display_name=getattr(user, "display_name", None),
67+ is_admin=bool(getattr(user, "is_admin", False)),
68+ groups=groups,
69+ )
70+ now = utc_now()
71+ refresh = secrets.token_urlsafe(32)
72+ await self._h.create(
73+ _AUTH_SESSION,
74+ {
75+ "refresh_token": refresh,
76+ "user_id": user_id,
77+ "created_at": now,
78+ "expires_at": now + timedelta(seconds=int(settings.refresh_ttl_seconds)),
79+ },
80+ )
81+ return {
82+ "access_token": access,
83+ "token_type": "bearer",
84+ "expires_in": expires_in,
85+ "refresh_token": refresh,
86+ "user": public_user(user),
87+ }
88+ 
89+ async def login(
90+ self, username: str, password: str, *, provider: str = _DEFAULT_PROVIDER
91+ ) -> dict[str, Any] | str:
92+ """成功返回 token 束 + user;失败返回 ``"bad_credentials"`` / ``"disabled"``。"""
93+ prov = get_provider(provider)
94+ if prov is None:
95+ return "bad_credentials"
96+ user_id = await prov.authenticate(self._h, username.strip(), password)
97+ if not user_id:
98+ _log.info("[Auth] login.fail", username=username.strip(), reason="bad_credentials")
99+ return "bad_credentials"
100+ user = await self._h.get(_APP_USER, {"user_id": user_id})
101+ if user is None:
102+ return "bad_credentials"
103+ if str(getattr(user, "status", "")) != "active":
104+ return "disabled"
105+ _log.info("[Auth] login.ok", user_id=user_id, provider=provider)
106+ return await self._issue_for(user)
107+ 
108+ async def refresh(self, refresh_token: str) -> dict[str, Any] | str:
109+ """用 refresh 换新 access(并轮换 refresh)。失败返回 ``"invalid_refresh"``。"""
110+ if not refresh_token:
111+ return "invalid_refresh"
112+ sess = await self._h.get(_AUTH_SESSION, {"refresh_token": refresh_token})
113+ if sess is None:
114+ return "invalid_refresh"
115+ expires_at = getattr(sess, "expires_at", None)
116+ if expires_at is not None:
117+ if expires_at.tzinfo is None:
118+ expires_at = expires_at.replace(tzinfo=timezone.utc)
119+ if utc_now() > expires_at:
120+ await self._h.delete(_AUTH_SESSION, {"refresh_token": refresh_token})
121+ return "invalid_refresh"
122+ user = await self._h.get(_APP_USER, {"user_id": getattr(sess, "user_id", None)})
123+ if user is None or str(getattr(user, "status", "")) != "active":
124+ await self._h.delete(_AUTH_SESSION, {"refresh_token": refresh_token})
125+ return "invalid_refresh"
126+ # 轮换:撤销旧 refresh,签发新束
127+ await self._h.delete(_AUTH_SESSION, {"refresh_token": refresh_token})
128+ return await self._issue_for(user)
129+ 
130+ async def logout(self, refresh_token: str) -> None:
131+ if refresh_token:
132+ await self._h.delete(_AUTH_SESSION, {"refresh_token": refresh_token})
133+ 
134+ async def get_user(self, user_id: str) -> Any | None:
135+ return await self._h.get(_APP_USER, {"user_id": user_id})
136+ 
137+ async def list_my_orgs(self, user_id: str) -> list[dict[str, Any]]:
138+ """自己所属真实组织;仅当不属于任何真实组织时,才附"无组织"入口。"""
139+ gids = await self._load_groups(user_id)
140+ out: list[dict[str, Any]] = []
141+ for gid in gids:
142+ org = await self._h.get(_ORG, {"group_id": gid})
143+ if org is not None and str(getattr(org, "status", "")) == "active":
144+ out.append({"group_id": gid, "name": getattr(org, "name", None)})
145+ if not out:
146+ none_org = await self._h.get(_ORG, {"group_id": NO_ORG_GROUP_ID})
147+ if none_org is not None:
148+ out.append({"group_id": NO_ORG_GROUP_ID, "name": getattr(none_org, "name", None)})
149+ return out
150+ 
151+ 
152+# ----------------------- 播种 -----------------------
153+async def _ensure_org(handler: DBHandler, group_id: str, name: str) -> None:
154+ if await handler.get(_ORG, {"group_id": group_id}) is not None:
155+ return
156+ now = utc_now()
157+ await handler.create(
158+ _ORG,
159+ {"group_id": group_id, "name": name, "status": "active", "created_at": now, "updated_at": now},
160+ )
161+ 
162+ 
163+async def _ensure_local_user(
164+ handler: DBHandler, *, user_id: str, display_name: str, is_admin: bool,
165+ username: str, password: str,
166+) -> None:
167+ if await handler.get(_APP_USER, {"user_id": user_id}) is not None:
168+ return
169+ now = utc_now()
170+ await handler.create(
171+ _APP_USER,
172+ {"user_id": user_id, "display_name": display_name, "is_admin": is_admin,
173+ "status": "active", "created_at": now, "updated_at": now},
174+ )
175+ await handler.create(
176+ _AUTH_IDENTITY,
177+ {"user_id": user_id, "provider": _DEFAULT_PROVIDER, "external_subject": username,
178+ "credential": hash_password(password), "created_at": now, "updated_at": now},
179+ )
180+ _log.info("[auth] seeded user", user_id=user_id, is_admin=is_admin)
181+ 
182+ 
183+async def seed_defaults(handler: DBHandler) -> None:
184+ """幂等播种:无组织保留行 + 引导 admin(可选 user1)。不播种任何业务/演示数据。"""
185+ await _ensure_org(handler, NO_ORG_GROUP_ID, "无组织")
186+ if settings.seed_admin:
187+ await _ensure_local_user(
188+ handler, user_id="admin", display_name="Administrator", is_admin=True,
189+ username="admin", password="admin",
190+ )
191+ if settings.seed_user1:
192+ await _ensure_local_user(
193+ handler, user_id="user1", display_name="User One", is_admin=False,
194+ username="user1", password="user1",
195+ )
@@ -0,0 +1,3 @@
1+from identity_center.core.iam.services import OrgService, UserService
2+ 
3+__all__ = ("OrgService", "UserService")
@@ -0,0 +1,301 @@
1+"""身份目录管理:组织 / 用户 / 成员 的 CRUD 与批量(权威库=身份库)。
2+ 
3+bot / 可见性 / 模板等平台配置不在此(留在 claw_manager 管理库)。
4+DBHandler.get/list_records 返回记录对象(属性访问),统一用 ``_g`` 读取。
5+"""
6+ 
7+from __future__ import annotations
8+ 
9+from dataclasses import dataclass
10+from typing import Any
11+ 
12+from openjiuwen_runtime.foundation.db.handler import DBHandler
13+ 
14+from identity_center.core.auth.password import hash_password
15+from identity_center.infrastructure.logger import get_logger
16+from identity_center.infrastructure.utils import (
17+ iso_datetime,
18+ new_uuid4,
19+ strip_optional,
20+ utc_now,
21+)
22+from identity_center.models.identity_models import (
23+ APP_USER_TABLE_DEF,
24+ AUTH_IDENTITY_TABLE_DEF,
25+ AUTH_SESSION_TABLE_DEF,
26+ NO_ORG_GROUP_ID,
27+ ORG_TABLE_DEF,
28+ USER_ORG_MEMBERSHIP_TABLE_DEF,
29+)
30+ 
31+_log = get_logger(__name__)
32+ 
33+_APP_USER = APP_USER_TABLE_DEF.table_name
34+_AUTH_IDENTITY = AUTH_IDENTITY_TABLE_DEF.table_name
35+_AUTH_SESSION = AUTH_SESSION_TABLE_DEF.table_name
36+_ORG = ORG_TABLE_DEF.table_name
37+_MEMBERSHIP = USER_ORG_MEMBERSHIP_TABLE_DEF.table_name
38+_LOCAL = "local"
39+_CAP = 100_000
40+ 
41+ 
42+def _g(row: Any, key: str, default: Any = None) -> Any:
43+ return getattr(row, key, default)
44+ 
45+ 
46+async def _delete_where(
47+ handler: DBHandler, table: str, filters: dict[str, Any], pk_field: str
48+) -> None:
49+ rows = await handler.list_records(table, filters, limit=_CAP, offset=0)
50+ for r in rows:
51+ await handler.delete(table, {pk_field: _g(r, pk_field)})
52+ 
53+ 
54+@dataclass(frozen=True)
55+class _PageOpts:
56+ """分页/排序选项(把分页相关参数收进具名对象,避免函数参数过多)。"""
57+ page: int = 1
58+ page_size: int = 50
59+ order_by: list[tuple[str, bool]] | None = None
60+ 
61+ 
62+async def _paginate(
63+ handler: DBHandler, table: str, filters: dict[str, Any], row_out, opts: _PageOpts,
64+) -> dict[str, Any]:
65+ page = max(opts.page, 1)
66+ page_size = min(max(opts.page_size, 1), 200)
67+ offset = (page - 1) * page_size
68+ rows = await handler.list_records(
69+ table, filters, limit=page_size, offset=offset, order_by=opts.order_by,
70+ )
71+ total = await handler.count_records(table, filters)
72+ return {"items": [row_out(r) for r in rows], "total": total, "page": page, "page_size": page_size}
73+ 
74+ 
75+# ======================= 组织 =======================
76+def _org_out(row: Any) -> dict[str, Any]:
77+ return {
78+ "group_id": _g(row, "group_id"),
79+ "name": _g(row, "name"),
80+ "status": _g(row, "status"),
81+ "created_at": iso_datetime(_g(row, "created_at")),
82+ "updated_at": iso_datetime(_g(row, "updated_at")),
83+ }
84+ 
85+ 
86+class OrgService:
87+ def __init__(self, handler: DBHandler) -> None:
88+ self._h = handler
89+ 
90+ async def list(self, page: int = 1, page_size: int = 50) -> dict[str, Any]:
91+ return await _paginate(self._h, _ORG, {}, _org_out, _PageOpts(page, page_size, [("created_at", False)]))
92+ 
93+ async def get(self, group_id: str) -> dict[str, Any] | None:
94+ row = await self._h.get(_ORG, {"group_id": group_id})
95+ return _org_out(row) if row is not None else None
96+ 
97+ async def create(self, group_id: str | None, name: str) -> dict[str, Any]:
98+ gid = strip_optional(group_id) or new_uuid4()
99+ if await self._h.get(_ORG, {"group_id": gid}) is not None:
100+ raise ValueError(f"org already exists: {gid}")
101+ now = utc_now()
102+ await self._h.create(
103+ _ORG,
104+ {"group_id": gid, "name": name.strip(), "status": "active", "created_at": now, "updated_at": now},
105+ )
106+ _log.info("[IAM] org.create", group_id=gid, name=name.strip())
107+ return _org_out(await self._h.get(_ORG, {"group_id": gid}))
108+ 
109+ async def update(self, group_id: str, name: str | None, status: str | None) -> dict[str, Any] | None:
110+ if await self._h.get(_ORG, {"group_id": group_id}) is None:
111+ return None
112+ updates: dict[str, Any] = {"updated_at": utc_now()}
113+ if name is not None:
114+ updates["name"] = name.strip()
115+ if status is not None:
116+ updates["status"] = status.strip()
117+ await self._h.update(_ORG, {"group_id": group_id}, updates)
118+ return _org_out(await self._h.get(_ORG, {"group_id": group_id}))
119+ 
120+ async def delete(self, group_id: str) -> bool:
121+ if group_id == NO_ORG_GROUP_ID:
122+ raise ValueError("cannot delete the reserved '无组织' org")
123+ if await self._h.get(_ORG, {"group_id": group_id}) is None:
124+ return False
125+ await _delete_where(self._h, _MEMBERSHIP, {"group_id": group_id}, "id")
126+ # bot 可见性(org scope)在管理库,由 claw_manager 侧自行清理(跨库,不在此处理)。
127+ await self._h.delete(_ORG, {"group_id": group_id})
128+ _log.info("[IAM] org.delete", group_id=group_id)
129+ return True
130+ 
131+ async def list_members(self, group_id: str) -> list[dict[str, Any]]:
132+ """列出某组织的用户;``__none__`` = 无任何组织绑定的用户。"""
133+ if group_id == NO_ORG_GROUP_ID:
134+ users = await self._h.list_records(_APP_USER, {}, limit=_CAP, offset=0)
135+ out: list[dict[str, Any]] = []
136+ for u in users:
137+ mem = await self._h.list_records(_MEMBERSHIP, {"user_id": _g(u, "user_id")}, limit=1, offset=0)
138+ if not mem:
139+ out.append(_user_out(u))
140+ return out
141+ rows = await self._h.list_records(_MEMBERSHIP, {"group_id": group_id}, limit=_CAP, offset=0)
142+ members: list[dict[str, Any]] = []
143+ for r in rows:
144+ u = await self._h.get(_APP_USER, {"user_id": _g(r, "user_id")})
145+ if u is not None:
146+ members.append(_user_out(u))
147+ return members
148+ 
149+ async def add_members(self, group_id: str, user_ids: list[str]) -> list[str]:
150+ if group_id == NO_ORG_GROUP_ID:
151+ raise ValueError("cannot add members to the no-org group")
152+ if await self._h.get(_ORG, {"group_id": group_id}) is None:
153+ raise ValueError(f"org not found: {group_id}")
154+ added: list[str] = []
155+ now = utc_now()
156+ for raw in user_ids:
157+ uid = strip_optional(raw)
158+ if not uid:
159+ continue
160+ if await self._h.get(_APP_USER, {"user_id": uid}) is None:
161+ raise ValueError(f"user not found: {uid}")
162+ exists = await self._h.list_records(_MEMBERSHIP, {"user_id": uid, "group_id": group_id}, limit=1, offset=0)
163+ if exists:
164+ continue
165+ await self._h.create(_MEMBERSHIP, {"user_id": uid, "group_id": group_id, "created_at": now})
166+ added.append(uid)
167+ _log.info("[IAM] org.add_members", group_id=group_id, added=added)
168+ return added
169+ 
170+ async def remove_member(self, group_id: str, user_id: str) -> bool:
171+ if group_id == NO_ORG_GROUP_ID:
172+ raise ValueError("cannot remove members from the no-org group")
173+ await _delete_where(self._h, _MEMBERSHIP, {"user_id": user_id, "group_id": group_id}, "id")
174+ _log.info("[IAM] org.remove_member", group_id=group_id, user_id=user_id)
175+ return True
176+ 
177+ 
178+# ======================= 用户 =======================
179+def _user_out(row: Any) -> dict[str, Any]:
180+ return {
181+ "user_id": _g(row, "user_id"),
182+ "display_name": _g(row, "display_name"),
183+ "is_admin": bool(_g(row, "is_admin")),
184+ "status": _g(row, "status"),
185+ "created_at": iso_datetime(_g(row, "created_at")),
186+ "updated_at": iso_datetime(_g(row, "updated_at")),
187+ }
188+ 
189+ 
190+class UserService:
191+ def __init__(self, handler: DBHandler) -> None:
192+ self._h = handler
193+ 
194+ async def list(self, page: int = 1, page_size: int = 50) -> dict[str, Any]:
195+ return await _paginate(self._h, _APP_USER, {}, _user_out, _PageOpts(page, page_size, [("created_at", False)]))
196+ 
197+ async def get(self, user_id: str) -> dict[str, Any] | None:
198+ row = await self._h.get(_APP_USER, {"user_id": user_id})
199+ if row is None:
200+ return None
201+ out = _user_out(row)
202+ out["group_ids"] = await self.list_org_ids(user_id)
203+ return out
204+ 
205+ async def create(
206+ self, *, user_id: str | None, display_name: str, is_admin: bool, username: str, password: str
207+ ) -> dict[str, Any]:
208+ username = username.strip()
209+ uid = strip_optional(user_id) or username
210+ if await self._h.get(_APP_USER, {"user_id": uid}) is not None:
211+ raise ValueError(f"user already exists: {uid}")
212+ dup = await self._h.list_records(
213+ _AUTH_IDENTITY, {"provider": _LOCAL, "external_subject": username}, limit=1, offset=0,
214+ )
215+ if dup:
216+ raise ValueError(f"username already taken: {username}")
217+ now = utc_now()
218+ await self._h.create(
219+ _APP_USER,
220+ {"user_id": uid, "display_name": display_name.strip(), "is_admin": is_admin,
221+ "status": "active", "created_at": now, "updated_at": now},
222+ )
223+ await self._h.create(
224+ _AUTH_IDENTITY,
225+ {"user_id": uid, "provider": _LOCAL, "external_subject": username,
226+ "credential": hash_password(password), "created_at": now, "updated_at": now},
227+ )
228+ _log.info("[IAM] user.create", user_id=uid, username=username, is_admin=is_admin)
229+ created = await self.get(uid)
230+ if created is None: # pragma: no cover - 刚写入必存在
231+ raise RuntimeError(f"user just created but missing: {uid}")
232+ return created
233+ 
234+ async def update(
235+ self, user_id: str, *, display_name: str | None, is_admin: bool | None,
236+ status: str | None, password: str | None,
237+ ) -> dict[str, Any] | None:
238+ if await self._h.get(_APP_USER, {"user_id": user_id}) is None:
239+ return None
240+ now = utc_now()
241+ updates: dict[str, Any] = {"updated_at": now}
242+ if display_name is not None:
243+ updates["display_name"] = display_name.strip()
244+ if is_admin is not None:
245+ updates["is_admin"] = is_admin
246+ if status is not None:
247+ updates["status"] = status.strip()
248+ await self._h.update(_APP_USER, {"user_id": user_id}, updates)
249+ if password:
250+ await self._set_local_password(user_id, password, now)
251+ _log.info("[IAM] user.update", user_id=user_id,
252+ fields=[k for k in updates if k != "updated_at"], password_changed=bool(password))
253+ return await self.get(user_id)
254+ 
255+ async def _set_local_password(self, user_id: str, password: str, now: Any) -> None:
256+ rows = await self._h.list_records(_AUTH_IDENTITY, {"provider": _LOCAL, "user_id": user_id}, limit=1, offset=0)
257+ cred = hash_password(password)
258+ if rows:
259+ await self._h.update(_AUTH_IDENTITY, {"id": _g(rows[0], "id")}, {"credential": cred, "updated_at": now})
260+ else:
261+ await self._h.create(
262+ _AUTH_IDENTITY,
263+ {"user_id": user_id, "provider": _LOCAL, "external_subject": user_id,
264+ "credential": cred, "created_at": now, "updated_at": now},
265+ )
266+ 
267+ async def delete(self, user_id: str) -> bool:
268+ if await self._h.get(_APP_USER, {"user_id": user_id}) is None:
269+ return False
270+ await _delete_where(self._h, _AUTH_IDENTITY, {"user_id": user_id}, "id")
271+ await _delete_where(self._h, _AUTH_SESSION, {"user_id": user_id}, "refresh_token")
272+ await _delete_where(self._h, _MEMBERSHIP, {"user_id": user_id}, "id")
273+ await self._h.delete(_APP_USER, {"user_id": user_id})
274+ _log.info("[IAM] user.delete", user_id=user_id)
275+ return True
276+ 
277+ async def list_org_ids(self, user_id: str) -> list[str]:
278+ rows = await self._h.list_records(_MEMBERSHIP, {"user_id": user_id}, limit=_CAP, offset=0)
279+ return [str(_g(r, "group_id")) for r in rows]
280+ 
281+ async def set_orgs(self, user_id: str, group_ids: list[str]) -> list[str]:
282+ """整体覆盖该用户的组织绑定(批量)。校验组织存在。"""
283+ if await self._h.get(_APP_USER, {"user_id": user_id}) is None:
284+ raise ValueError(f"user not found: {user_id}")
285+ target: set[str] = set()
286+ for gid in group_ids:
287+ g = strip_optional(gid)
288+ if not g:
289+ continue
290+ if await self._h.get(_ORG, {"group_id": g}) is None:
291+ raise ValueError(f"org not found: {g}")
292+ target.add(g)
293+ current = set(await self.list_org_ids(user_id))
294+ now = utc_now()
295+ for gid in target - current:
296+ await self._h.create(_MEMBERSHIP, {"user_id": user_id, "group_id": gid, "created_at": now})
297+ for gid in current - target:
298+ await _delete_where(self._h, _MEMBERSHIP, {"user_id": user_id, "group_id": gid}, "id")
299+ _log.info("[IAM] user.set_orgs", user_id=user_id,
300+ added=sorted(target - current), removed=sorted(current - target))
301+ return sorted(target)
@@ -0,0 +1,61 @@
1+"""身份服务运行时配置(从 .env / 环境变量加载,前缀 IDENTITY_)。"""
2+ 
3+from __future__ import annotations
4+ 
5+from pathlib import Path
6+ 
7+from pydantic import Field
8+from pydantic_settings import BaseSettings, SettingsConfigDict
9+ 
10+ 
11+def _resolve_env_files() -> tuple[str | Path, ...]:
12+ candidates: list[Path] = [Path.cwd() / ".env"]
13+ here = Path(__file__).resolve()
14+ for depth in (5, 6):
15+ candidates.append(here.parents[depth] / ".env")
16+ return tuple(p for p in candidates if p.is_file())
17+ 
18+ 
19+class Settings(BaseSettings):
20+ model_config = SettingsConfigDict(
21+ env_file=_resolve_env_files() or None,
22+ env_file_encoding="utf-8",
23+ extra="ignore",
24+ )
25+ 
26+ # ---- HTTP ----
27+ rest_host: str = Field(default="0.0.0.0", validation_alias="IDENTITY_REST_HOST")
28+ rest_port: int = Field(default=8770, validation_alias="IDENTITY_REST_PORT")
29+ 
30+ # ---- 数据库(独立身份库;DBHandler 抽象,sqlite/mysql/postgresql 通用)----
31+ db_type: str = Field(default="sqlite", validation_alias="IDENTITY_DB_TYPE")
32+ sqlite_path: str = Field(default="identity.db", validation_alias="IDENTITY_SQLITE_PATH")
33+ db_host: str = Field(default="127.0.0.1", validation_alias="IDENTITY_DB_HOST")
34+ db_port: int = Field(default=3306, validation_alias="IDENTITY_DB_PORT")
35+ db_user: str = Field(default="root", validation_alias="IDENTITY_DB_USER")
36+ db_password: str = Field(default="root", validation_alias="IDENTITY_DB_PASSWORD")
37+ db_name: str = Field(default="identity", validation_alias="IDENTITY_DB_NAME")
38+ pg_schema: str = Field(default="public", validation_alias="IDENTITY_PG_SCHEMA")
39+ 
40+ # ---- JWT(RS256:私钥签发,资源服务器用公钥验签)----
41+ # 重构:统一去旧名 jiuwenclaw→openjiuwen;跨服务契约,manager_server 须用同一 issuer/audience 验签
42+ jwt_issuer: str = Field(default="openjiuwen-identity", validation_alias="IDENTITY_JWT_ISSUER")
43+ jwt_audience: str = Field(default="openjiuwen", validation_alias="IDENTITY_JWT_AUDIENCE")
44+ access_ttl_seconds: int = Field(default=1800, validation_alias="IDENTITY_ACCESS_TTL")
45+ refresh_ttl_seconds: int = Field(default=7 * 24 * 3600, validation_alias="IDENTITY_REFRESH_TTL")
46+ # JWT 签名密钥落身份库(表 identity_jwt_signing_key,生成一次→落库→多副本读同一行)。
47+ 
48+ # ---- 引导播种 ----
49+ seed_admin: bool = Field(default=True, validation_alias="IDENTITY_SEED_ADMIN")
50+ seed_user1: bool = Field(default=True, validation_alias="IDENTITY_SEED_USER1")
51+ 
52+ @property
53+ def host(self) -> str:
54+ return self.rest_host
55+ 
56+ @property
57+ def port(self) -> int:
58+ return self.rest_port
59+ 
60+ 
61+settings = Settings()
@@ -0,0 +1,85 @@
1+"""身份服务数据库句柄(SQLite / MySQL / PostgreSQL);配置来自 .env(IDENTITY_*)。
2+ 
3+独立身份库:与 claw_manager 的管理库物理隔离。底层走 foundation DBHandler 抽象,
4+先用 SQLite 保证功能,后续切 MySQL/PostgreSQL 仅改 .env,不动业务代码。
5+"""
6+ 
7+from __future__ import annotations
8+ 
9+from pathlib import Path
10+from typing import Any
11+ 
12+from openjiuwen_runtime.foundation.db.handler import DBHandler
13+from openjiuwen_runtime.foundation.db.mysql_handler import MySQLHandler
14+from openjiuwen_runtime.foundation.db.postgresql_handler import PostgreSQLHandler
15+from openjiuwen_runtime.foundation.db.sqlite_handler import SQLiteHandler
16+ 
17+from identity_center.infrastructure.config import Settings, settings
18+from identity_center.infrastructure.logger import get_logger
19+ 
20+_PKG_ROOT = Path(__file__).resolve().parents[3]
21+ 
22+logger = get_logger(__name__)
23+ 
24+_db_handler: DBHandler | None = None
25+ 
26+ 
27+def get_db_handler() -> DBHandler:
28+ if _db_handler is None:
29+ raise RuntimeError("Database handler is not initialized; call create_db_handler first.")
30+ return _db_handler
31+ 
32+ 
33+def _sqlite_handler_from_settings(cfg: Settings) -> SQLiteHandler:
34+ raw_path = Path(cfg.sqlite_path.strip()).expanduser()
35+ raw_path = raw_path.resolve() if raw_path.is_absolute() else (_PKG_ROOT / raw_path).resolve()
36+ raw_path.parent.mkdir(parents=True, exist_ok=True)
37+ return SQLiteHandler(str(raw_path.as_posix()))
38+ 
39+ 
40+def _mysql_handler_from_settings(cfg: Settings) -> MySQLHandler:
41+ try:
42+ return MySQLHandler(
43+ host=str(cfg.db_host).strip(), port=int(cfg.db_port),
44+ user=str(cfg.db_user).strip(), password=str(cfg.db_password),
45+ database=str(cfg.db_name).strip(),
46+ )
47+ except (TypeError, ValueError) as e:
48+ logger.exception("Invalid MySQL config (IDENTITY_DB_*).")
49+ raise ValueError("Invalid MySQL database configuration.") from e
50+ 
51+ 
52+def _pg_handler_from_settings(cfg: Settings) -> PostgreSQLHandler:
53+ try:
54+ return PostgreSQLHandler(
55+ host=str(cfg.db_host).strip(), port=int(cfg.db_port),
56+ user=str(cfg.db_user).strip(), password=str(cfg.db_password),
57+ database=str(cfg.db_name).strip(), schema=str(cfg.pg_schema).strip(),
58+ )
59+ except (TypeError, ValueError) as e:
60+ logger.exception("Invalid PostgreSQL config (IDENTITY_DB_*).")
61+ raise ValueError("Invalid PostgreSQL database configuration.") from e
62+ 
63+ 
64+def create_db_handler(cfg: Settings | None = None) -> DBHandler:
65+ global _db_handler
66+ active = cfg or settings
67+ db_type = str(active.db_type or "").strip().lower() or "sqlite"
68+ logger.info("Using identity database: %s", db_type)
69+ if db_type == "sqlite":
70+ _db_handler = _sqlite_handler_from_settings(active)
71+ elif db_type == "mysql":
72+ _db_handler = _mysql_handler_from_settings(active)
73+ elif db_type in ("postgresql", "postgres", "pg"):
74+ _db_handler = _pg_handler_from_settings(active)
75+ else:
76+ raise ValueError(f"Unsupported db_type: {db_type}. Use sqlite/mysql/postgresql.")
77+ return _db_handler
78+ 
79+ 
80+def database_config_summary(cfg: Settings | None = None) -> dict[str, Any]:
81+ active = cfg or settings
82+ db_type = str(active.db_type or "").strip().lower() or "sqlite"
83+ if db_type == "sqlite":
84+ return {"db_type": db_type, "sqlite_path": active.sqlite_path}
85+ return {"db_type": db_type, "host": active.db_host, "port": active.db_port, "database": active.db_name}
@@ -0,0 +1,24 @@
1+"""结构化日志(与 claw_manager / 网关侧风格一致,便于关联)。"""
2+ 
3+from __future__ import annotations
4+ 
5+import sys
6+ 
7+import structlog
8+ 
9+ 
10+def configure_logging() -> None:
11+ structlog.configure(
12+ processors=[
13+ structlog.processors.add_log_level,
14+ structlog.processors.TimeStamper(fmt="iso", utc=True),
15+ structlog.dev.ConsoleRenderer(colors=sys.stderr.isatty()),
16+ ],
17+ context_class=dict,
18+ logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
19+ cache_logger_on_first_use=True,
20+ )
21+ 
22+ 
23+def get_logger(name: str):
24+ return structlog.get_logger(name)
@@ -0,0 +1,34 @@
1+"""通用工具:UTC 时间、ID、字符串处理。"""
2+ 
3+from __future__ import annotations
4+ 
5+import uuid
6+from datetime import datetime, timezone
7+ 
8+ 
9+def utc_now() -> datetime:
10+ return datetime.now(timezone.utc)
11+ 
12+ 
13+def iso_datetime(dt: datetime | None) -> str | None:
14+ """datetime → ISO-8601 UTC 字符串(末尾 Z);None 返回 None。"""
15+ if dt is None:
16+ return None
17+ if dt.tzinfo is None:
18+ dt = dt.replace(tzinfo=timezone.utc)
19+ return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
20+ 
21+ 
22+def new_id() -> str:
23+ return str(uuid.uuid4())
24+ 
25+ 
26+def new_uuid4() -> str:
27+ return str(uuid.uuid4())
28+ 
29+ 
30+def strip_optional(value: str | None) -> str | None:
31+ if value is None:
32+ return None
33+ stripped = value.strip()
34+ return stripped or None
@@ -0,0 +1,21 @@
1+"""进程入口:等价于 `uvicorn identity_center.app:app`。"""
2+ 
3+from __future__ import annotations
4+ 
5+ 
6+def main() -> None:
7+ import uvicorn
8+ 
9+ from identity_center.infrastructure.config import settings
10+ 
11+ uvicorn.run(
12+ "identity_center.app:app",
13+ host=settings.host,
14+ port=settings.port,
15+ factory=False,
16+ log_level="info",
17+ )
18+ 
19+ 
20+if __name__ == "__main__":
21+ main()
@@ -0,0 +1,113 @@
1+"""身份服务表定义:用户 / 认证身份 / 刷新会话 / 组织 / 成员。
2+ 
3+权威的"人 + 凭据 + 目录(组织/成员)"数据源,独立于 claw_manager 管理库。
4+认证与身份解耦:``app_user`` 存身份/角色,``auth_identity`` 存凭据/外部 IdP
5+(二次开发新增 provider 不动业务表)。bot / 可见性 / 模板等平台配置留在管理库。
6+"""
7+ 
8+from __future__ import annotations
9+ 
10+from openjiuwen_runtime.foundation.db.table_def import (
11+ ColumnDefinition,
12+ IndexDefinition,
13+ TableDefinition,
14+)
15+ 
16+# 无组织的保留 group_id(避免 NULL 特判,可见性/查询全程统一)。
17+NO_ORG_GROUP_ID = "__none__"
18+ 
19+APP_USER_TABLE_DEF = TableDefinition(
20+ table_name="app_user",
21+ columns=[
22+ ColumnDefinition("user_id", "string", length=64, primary_key=True, nullable=False),
23+ ColumnDefinition("display_name", "string", length=128, nullable=False),
24+ ColumnDefinition("is_admin", "boolean", nullable=False, default=False),
25+ ColumnDefinition("status", "string", length=16, nullable=False, default="active"),
26+ ColumnDefinition("created_at", "datetime", nullable=False),
27+ ColumnDefinition("updated_at", "datetime", nullable=False),
28+ ],
29+)
30+ 
31+# 认证身份:一个 user 可挂多种登录方式(local / oidc / ldap...)。换登录方式只动这张表。
32+AUTH_IDENTITY_TABLE_DEF = TableDefinition(
33+ table_name="auth_identity",
34+ columns=[
35+ ColumnDefinition("id", "integer", primary_key=True, autoincrement=True, nullable=False),
36+ ColumnDefinition("user_id", "string", length=64, nullable=False),
37+ ColumnDefinition("provider", "string", length=32, nullable=False),
38+ ColumnDefinition("external_subject", "string", length=256, nullable=False),
39+ ColumnDefinition("credential", "string", length=512, nullable=True),
40+ ColumnDefinition("created_at", "datetime", nullable=False),
41+ ColumnDefinition("updated_at", "datetime", nullable=False),
42+ ],
43+ indexes=[
44+ IndexDefinition(["provider", "external_subject"], unique=True),
45+ IndexDefinition(["user_id"], unique=False),
46+ ],
47+)
48+ 
49+# 刷新会话:refresh token 落地,可撤销/轮换(access JWT 自包含、不落库)。
50+AUTH_SESSION_TABLE_DEF = TableDefinition(
51+ table_name="auth_session",
52+ columns=[
53+ ColumnDefinition("refresh_token", "string", length=128, primary_key=True, nullable=False),
54+ ColumnDefinition("user_id", "string", length=64, nullable=False),
55+ ColumnDefinition("created_at", "datetime", nullable=False),
56+ ColumnDefinition("expires_at", "datetime", nullable=False),
57+ ],
58+ indexes=[
59+ IndexDefinition(["user_id"], unique=False),
60+ IndexDefinition(["expires_at"], unique=False),
61+ ],
62+)
63+ 
64+ORG_TABLE_DEF = TableDefinition(
65+ table_name="org",
66+ columns=[
67+ ColumnDefinition("group_id", "string", length=64, primary_key=True, nullable=False),
68+ ColumnDefinition("name", "string", length=128, nullable=False),
69+ ColumnDefinition("status", "string", length=16, nullable=False, default="active"),
70+ ColumnDefinition("created_at", "datetime", nullable=False),
71+ ColumnDefinition("updated_at", "datetime", nullable=False),
72+ ],
73+)
74+ 
75+# 用户↔组织 多对多。默认无组织 = 不存在任何真实成员关系(自动归类)。
76+USER_ORG_MEMBERSHIP_TABLE_DEF = TableDefinition(
77+ table_name="user_org_membership",
78+ columns=[
79+ ColumnDefinition("id", "integer", primary_key=True, autoincrement=True, nullable=False),
80+ ColumnDefinition("user_id", "string", length=64, nullable=False),
81+ ColumnDefinition("group_id", "string", length=64, nullable=False),
82+ ColumnDefinition("created_at", "datetime", nullable=False),
83+ ],
84+ indexes=[
85+ IndexDefinition(["user_id", "group_id"], unique=True),
86+ IndexDefinition(["group_id"], unique=False),
87+ ],
88+)
89+ 
90+# JWT 签名密钥(RS256,单例固定主键 id="default")。生成一次→落库→所有副本读同一行。
91+# 参考 claw_manager `manager_identity` 的落库范式,但本表/库/算法独立(RSA PEM 文本)。
92+IDENTITY_JWT_SIGNING_KEY_TABLE_DEF = TableDefinition(
93+ table_name="identity_jwt_signing_key",
94+ columns=[
95+ ColumnDefinition("id", "string", length=32, primary_key=True, nullable=False),
96+ ColumnDefinition("sign_alg", "string", length=32, nullable=False), # "RS256"
97+ ColumnDefinition("private_key", "string", length=4096, nullable=False), # PKCS8 PEM
98+ ColumnDefinition("public_key", "string", length=1024, nullable=False), # SPKI PEM
99+ ColumnDefinition("key_version", "string", length=32, nullable=False), # "v1"
100+ ColumnDefinition("fingerprint", "string", length=128, nullable=False), # SHA-256 hex(public PEM)
101+ ColumnDefinition("created_at", "datetime", nullable=False),
102+ ColumnDefinition("updated_at", "datetime", nullable=False),
103+ ],
104+)
105+ 
106+IDENTITY_TABLE_DEFINITIONS = (
107+ APP_USER_TABLE_DEF,
108+ AUTH_IDENTITY_TABLE_DEF,
109+ AUTH_SESSION_TABLE_DEF,
110+ ORG_TABLE_DEF,
111+ USER_ORG_MEMBERSHIP_TABLE_DEF,
112+ IDENTITY_JWT_SIGNING_KEY_TABLE_DEF,
113+)
@@ -0,0 +1,12 @@
1+"""身份库表初始化(幂等)。"""
2+ 
3+from __future__ import annotations
4+ 
5+from openjiuwen_runtime.foundation.db.handler import DBHandler
6+ 
7+from identity_center.models.identity_models import IDENTITY_TABLE_DEFINITIONS
8+ 
9+ 
10+async def init_all_tables(handler: DBHandler) -> None:
11+ for table_def in IDENTITY_TABLE_DEFINITIONS:
12+ await handler.init_table(table_def)
@@ -0,0 +1,97 @@
1+"""认证路由:OAuth2 密码流 /token、/me、/me/orgs、/refresh、/logout、/public_key。"""
2+ 
3+from __future__ import annotations
4+ 
5+from typing import Annotated, Any
6+ 
7+from fastapi import APIRouter, Depends, HTTPException, Response, status
8+from fastapi.security import OAuth2PasswordRequestForm
9+from openjiuwen_runtime.foundation.db.handler import DBHandler
10+ 
11+from identity_center.core.auth import IdentityAuthService
12+from identity_center.infrastructure.db import get_db_handler
13+from identity_center.routers.deps import get_current_claims
14+from identity_center.schemas.auth_schemas import (
15+ LogoutBody,
16+ RefreshBody,
17+ TokenResponse,
18+ UserOut,
19+)
20+from identity_center.security.jwt_keys import public_pem
21+ 
22+_Handler = Annotated[DBHandler, Depends(get_db_handler)]
23+_Claims = Annotated[dict[str, Any], Depends(get_current_claims)]
24+ 
25+auth_router = APIRouter()
26+ 
27+# detail 用稳定 code(前端据此映射双语文案,未知回退通用句子)。
28+_LOGIN_ERR = {
29+ "bad_credentials": (status.HTTP_401_UNAUTHORIZED, "auth_bad_credentials"),
30+ "disabled": (status.HTTP_403_FORBIDDEN, "auth_disabled"),
31+ "invalid_refresh": (status.HTTP_401_UNAUTHORIZED, "auth_invalid_refresh"),
32+}
33+ 
34+ 
35+def _raise(code: str) -> None:
36+ http_status, detail = _LOGIN_ERR.get(code, (status.HTTP_400_BAD_REQUEST, code))
37+ raise HTTPException(
38+ status_code=http_status, detail=detail, headers={"WWW-Authenticate": "Bearer"}
39+ )
40+ 
41+ 
42+@auth_router.post("/token", response_model=TokenResponse)
43+async def token(
44+ form: Annotated[OAuth2PasswordRequestForm, Depends()],
45+ handler: _Handler,
46+):
47+ """OAuth2 密码流:表单 username/password → 签发 access JWT + refresh。
48+ 
49+ (Swagger 右上角 Authorize 即调用此端点。)
50+ """
51+ result = await IdentityAuthService(handler).login(form.username, form.password)
52+ if isinstance(result, str):
53+ _raise(result)
54+ return TokenResponse(**{k: result[k] for k in ("access_token", "token_type", "expires_in", "refresh_token")})
55+ 
56+ 
57+@auth_router.get("/me", response_model=UserOut)
58+async def me(claims: _Claims, handler: _Handler):
59+ """当前登录用户(来自 JWT claims,并以库中最新状态校正)。"""
60+ svc = IdentityAuthService(handler)
61+ user = await svc.get_user(str(claims.get("sub")))
62+ if user is None or str(getattr(user, "status", "")) != "active":
63+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="user not found or disabled")
64+ return UserOut(
65+ user_id=str(getattr(user, "user_id")),
66+ display_name=getattr(user, "display_name", None),
67+ is_admin=bool(getattr(user, "is_admin", False)),
68+ status=getattr(user, "status", None),
69+ groups=list(claims.get("groups") or []),
70+ )
71+ 
72+ 
73+@auth_router.get("/me/orgs")
74+async def my_orgs(claims: _Claims, handler: _Handler):
75+ """当前用户可选组织(有真实组织则不含『无组织』)。"""
76+ orgs = await IdentityAuthService(handler).list_my_orgs(str(claims.get("sub")))
77+ return {"orgs": orgs}
78+ 
79+ 
80+@auth_router.post("/refresh", response_model=TokenResponse)
81+async def refresh(body: RefreshBody, handler: _Handler):
82+ result = await IdentityAuthService(handler).refresh(body.refresh_token)
83+ if isinstance(result, str):
84+ _raise(result)
85+ return TokenResponse(**{k: result[k] for k in ("access_token", "token_type", "expires_in", "refresh_token")})
86+ 
87+ 
88+@auth_router.post("/logout")
89+async def logout(body: LogoutBody, handler: _Handler):
90+ await IdentityAuthService(handler).logout(body.refresh_token)
91+ return {"ok": True}
92+ 
93+ 
94+@auth_router.get("/public_key")
95+async def public_key():
96+ """RS256 公钥(PEM)。资源服务器拿它本地验签 JWT,无需回调本服务。"""
97+ return Response(content=public_pem(), media_type="application/x-pem-file")
@@ -0,0 +1,40 @@
1+"""鉴权依赖:OAuth2 Bearer + JWT 验签 → 当前用户 claims;admin 守卫。
2+ 
3+``oauth2_scheme`` 让 ``/docs`` 出现 Authorize(密码流,tokenUrl 指向 /token)。
4+本服务自身验签用本地公钥;claw_manager / 企业版 web 复用同样的验签逻辑(拿公钥)。
5+"""
6+ 
7+from __future__ import annotations
8+ 
9+from typing import Annotated, Any
10+ 
11+import jwt
12+from fastapi import Depends, HTTPException, status
13+from fastapi.security import OAuth2PasswordBearer
14+ 
15+from identity_center.security.tokens import decode_access_token
16+ 
17+# tokenUrl 为相对路径,指向下方 /v1/auth/token;Swagger Authorize 用它走密码流。
18+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="v1/auth/token")
19+ 
20+_CRED_EXC = HTTPException(
21+ status_code=status.HTTP_401_UNAUTHORIZED,
22+ detail="invalid or expired token",
23+ headers={"WWW-Authenticate": "Bearer"},
24+)
25+ 
26+ 
27+async def get_current_claims(token: Annotated[str, Depends(oauth2_scheme)]) -> dict[str, Any]:
28+ try:
29+ return decode_access_token(token)
30+ except jwt.PyJWTError as exc:
31+ raise _CRED_EXC from exc
32+ 
33+ 
34+_Claims = Annotated[dict[str, Any], Depends(get_current_claims)]
35+ 
36+ 
37+async def require_admin(claims: _Claims) -> dict[str, Any]:
38+ if not bool(claims.get("is_admin")):
39+ raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="admin required")
40+ return claims
@@ -0,0 +1,205 @@
1+"""身份目录管理 API:组织 / 用户 / 成员(全部 admin 守卫,校验 JWT 的 is_admin claim)。"""
2+ 
3+from __future__ import annotations
4+ 
5+from typing import Annotated, Any
6+ 
7+from fastapi import APIRouter, Depends, HTTPException, Query
8+from openjiuwen_runtime.foundation.db.handler import DBHandler
9+ 
10+from identity_center.core.iam import OrgService, UserService
11+from identity_center.infrastructure.db import get_db_handler
12+from identity_center.routers.deps import require_admin
13+from identity_center.schemas.iam_schemas import (
14+ AddMembersBody,
15+ OrgCreateBody,
16+ OrgUpdateBody,
17+ SetMembershipBody,
18+ UserCreateBody,
19+ UsersBatchCreateBody,
20+ UserUpdateBody,
21+)
22+ 
23+_Handler = Annotated[DBHandler, Depends(get_db_handler)]
24+ 
25+ 
26+def _bad(exc: ValueError) -> HTTPException:
27+ return HTTPException(status_code=400, detail=str(exc))
28+ 
29+ 
30+# ============== 组织(admin) ==============
31+org_router = APIRouter(dependencies=[Depends(require_admin)])
32+ 
33+ 
34+@org_router.get("/")
35+async def list_orgs(handler: _Handler, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200)):
36+ return await OrgService(handler).list(page, page_size)
37+ 
38+ 
39+@org_router.post("/")
40+async def create_org(body: OrgCreateBody, handler: _Handler):
41+ try:
42+ return await OrgService(handler).create(body.group_id, body.name)
43+ except ValueError as e:
44+ raise _bad(e) from e
45+ 
46+ 
47+@org_router.get("/{group_id}")
48+async def get_org(group_id: str, handler: _Handler):
49+ org = await OrgService(handler).get(group_id)
50+ if org is None:
51+ raise HTTPException(status_code=404, detail="org not found")
52+ return org
53+ 
54+ 
55+@org_router.patch("/{group_id}")
56+async def update_org(group_id: str, body: OrgUpdateBody, handler: _Handler):
57+ org = await OrgService(handler).update(group_id, body.name, body.status)
58+ if org is None:
59+ raise HTTPException(status_code=404, detail="org not found")
60+ return org
61+ 
62+ 
63+@org_router.get("/{group_id}/members")
64+async def list_org_members(group_id: str, handler: _Handler):
65+ return {"users": await OrgService(handler).list_members(group_id)}
66+ 
67+ 
68+@org_router.post("/{group_id}/members")
69+async def add_org_members(group_id: str, body: AddMembersBody, handler: _Handler):
70+ try:
71+ return {"added": await OrgService(handler).add_members(group_id, body.user_ids)}
72+ except ValueError as e:
73+ raise _bad(e) from e
74+ 
75+ 
76+@org_router.delete("/{group_id}/members/{user_id}")
77+async def remove_org_member(group_id: str, user_id: str, handler: _Handler):
78+ try:
79+ await OrgService(handler).remove_member(group_id, user_id)
80+ except ValueError as e:
81+ raise _bad(e) from e
82+ return {"removed": True}
83+ 
84+ 
85+@org_router.delete("/{group_id}")
86+async def delete_org(group_id: str, handler: _Handler):
87+ try:
88+ ok = await OrgService(handler).delete(group_id)
89+ except ValueError as e:
90+ raise _bad(e) from e
91+ if not ok:
92+ raise HTTPException(status_code=404, detail="org not found")
93+ return {"deleted": True}
94+ 
95+ 
96+# ============== 用户(admin) ==============
97+user_router = APIRouter(dependencies=[Depends(require_admin)])
98+ 
99+ 
100+@user_router.get("/")
101+async def list_users(handler: _Handler, page: int = Query(1, ge=1), page_size: int = Query(50, ge=1, le=200)):
102+ return await UserService(handler).list(page, page_size)
103+ 
104+ 
105+@user_router.post("/")
106+async def create_user(body: UserCreateBody, handler: _Handler):
107+ try:
108+ return await UserService(handler).create(
109+ user_id=body.user_id, display_name=body.display_name, is_admin=body.is_admin,
110+ username=body.username, password=body.password,
111+ )
112+ except ValueError as e:
113+ raise _bad(e) from e
114+ 
115+ 
116+@user_router.post("/batch")
117+async def batch_create_users(body: UsersBatchCreateBody, handler: _Handler):
118+ """批量新建用户(前端解析 Excel/CSV 后提交 JSON)。
119+ 
120+ 逐行处理、部分成功:建用户失败(重名/缺项)只标记该行 error;组织无效或设置报错
121+ 仅忽略该组织并归入无组织(告警),绝不使建用户失败。
122+ """
123+ usvc = UserService(handler)
124+ # 一次性载入有效组织,内存校验(id 与名称都可匹配)
125+ orgs_page = await OrgService(handler).list(1, 200)
126+ orgs = orgs_page.get("items", []) if isinstance(orgs_page, dict) else []
127+ valid_ids = {org["group_id"] for org in orgs}
128+ name_to_id = {org["name"]: org["group_id"] for org in orgs if org.get("name")}
129+ 
130+ results: list[dict[str, Any]] = []
131+ ok_count = 0
132+ for idx, item in enumerate(body.users):
133+ row = idx + 1
134+ try:
135+ created = await usvc.create(
136+ user_id=None, display_name=(item.display_name or item.username).strip(),
137+ is_admin=item.is_admin, username=item.username, password=item.password,
138+ )
139+ except ValueError as exc:
140+ results.append({"row": row, "username": item.username, "ok": False, "error": str(exc)})
141+ continue
142+ uid = created["user_id"]
143+ warnings: list[str] = []
144+ resolved: list[str] = []
145+ for token in item.orgs:
146+ tok = str(token).strip()
147+ if not tok:
148+ continue
149+ gid = tok if tok in valid_ids else name_to_id.get(tok)
150+ if gid:
151+ resolved.append(gid)
152+ else:
153+ warnings.append(f"组织 '{tok}' 不存在,已忽略")
154+ if resolved:
155+ try:
156+ await usvc.set_orgs(uid, resolved)
157+ except ValueError as exc: # 极端情况:仍归入无组织,不失败
158+ warnings.append(f"设置组织失败,已归入无组织:{exc}")
159+ ok_count += 1
160+ results.append({"row": row, "username": item.username, "ok": True, "user_id": uid, "warnings": warnings})
161+ 
162+ return {
163+ "summary": {"total": len(body.users), "ok": ok_count, "failed": len(body.users) - ok_count},
164+ "results": results,
165+ }
166+ 
167+ 
168+@user_router.get("/{user_id}")
169+async def get_user(user_id: str, handler: _Handler):
170+ user = await UserService(handler).get(user_id)
171+ if user is None:
172+ raise HTTPException(status_code=404, detail="user not found")
173+ return user
174+ 
175+ 
176+@user_router.patch("/{user_id}")
177+async def update_user(user_id: str, body: UserUpdateBody, handler: _Handler):
178+ user = await UserService(handler).update(
179+ user_id, display_name=body.display_name, is_admin=body.is_admin,
180+ status=body.status, password=body.password,
181+ )
182+ if user is None:
183+ raise HTTPException(status_code=404, detail="user not found")
184+ return user
185+ 
186+ 
187+@user_router.delete("/{user_id}")
188+async def delete_user(user_id: str, handler: _Handler):
189+ ok = await UserService(handler).delete(user_id)
190+ if not ok:
191+ raise HTTPException(status_code=404, detail="user not found")
192+ return {"deleted": True}
193+ 
194+ 
195+@user_router.get("/{user_id}/orgs")
196+async def list_user_orgs(user_id: str, handler: _Handler):
197+ return {"group_ids": await UserService(handler).list_org_ids(user_id)}
198+ 
199+ 
200+@user_router.put("/{user_id}/orgs")
201+async def set_user_orgs(user_id: str, body: SetMembershipBody, handler: _Handler):
202+ try:
203+ return {"group_ids": await UserService(handler).set_orgs(user_id, body.group_ids)}
204+ except ValueError as e:
205+ raise _bad(e) from e
@@ -0,0 +1,16 @@
1+"""路由注册:全部挂在 /v1 下。"""
2+ 
3+from __future__ import annotations
4+ 
5+from fastapi import APIRouter, FastAPI
6+ 
7+from identity_center.routers.auth_routers import auth_router
8+from identity_center.routers.iam_routers import org_router, user_router
9+ 
10+ 
11+def router_register(app: FastAPI) -> None:
12+ v1 = APIRouter(prefix="/v1")
13+ v1.include_router(auth_router, prefix="/auth", tags=["Auth"])
14+ v1.include_router(org_router, prefix="/orgs", tags=["Directory · Orgs"])
15+ v1.include_router(user_router, prefix="/users", tags=["Directory · Users"])
16+ app.include_router(v1)
@@ -0,0 +1,30 @@
1+"""认证服务的请求/响应模型。"""
2+ 
3+from __future__ import annotations
4+ 
5+from pydantic import BaseModel
6+ 
7+ 
8+class TokenResponse(BaseModel):
9+ """OAuth2 标准 token 响应(/token、/refresh 返回)。"""
10+ 
11+ access_token: str
12+ token_type: str = "bearer"
13+ expires_in: int
14+ refresh_token: str
15+ 
16+ 
17+class UserOut(BaseModel):
18+ user_id: str
19+ display_name: str | None = None
20+ is_admin: bool = False
21+ status: str | None = None
22+ groups: list[str] = []
23+ 
24+ 
25+class RefreshBody(BaseModel):
26+ refresh_token: str
27+ 
28+ 
29+class LogoutBody(BaseModel):
30+ refresh_token: str
@@ -0,0 +1,58 @@
1+"""身份目录管理请求体(组织 / 用户 / 成员)。"""
2+ 
3+from __future__ import annotations
4+ 
5+from pydantic import BaseModel, Field
6+ 
7+ 
8+class OrgCreateBody(BaseModel):
9+ group_id: str | None = Field(default=None, max_length=64)
10+ name: str = Field(..., min_length=1, max_length=128)
11+ 
12+ 
13+class OrgUpdateBody(BaseModel):
14+ name: str | None = Field(default=None, max_length=128)
15+ status: str | None = Field(default=None, max_length=16)
16+ 
17+ 
18+class UserCreateBody(BaseModel):
19+ user_id: str | None = Field(default=None, max_length=64)
20+ display_name: str = Field(..., min_length=1, max_length=128)
21+ is_admin: bool = False
22+ username: str = Field(..., min_length=1, max_length=256)
23+ password: str = Field(..., min_length=1, max_length=256)
24+ 
25+ 
26+class UserUpdateBody(BaseModel):
27+ display_name: str | None = Field(default=None, max_length=128)
28+ is_admin: bool | None = None
29+ status: str | None = Field(default=None, max_length=16)
30+ password: str | None = Field(default=None, max_length=256)
31+ 
32+ 
33+class SetMembershipBody(BaseModel):
34+ """整体覆盖某用户的组织绑定(批量)。"""
35+ 
36+ group_ids: list[str] = Field(default_factory=list)
37+ 
38+ 
39+class AddMembersBody(BaseModel):
40+ """从组织侧批量加入用户(幂等)。"""
41+ 
42+ user_ids: list[str] = Field(default_factory=list)
43+ 
44+ 
45+class UserBatchItem(BaseModel):
46+ """批量新建用户的单行(对应 Excel/CSV 一行)。"""
47+ 
48+ username: str = Field(..., min_length=1, max_length=256)
49+ password: str = Field(..., min_length=1, max_length=256)
50+ display_name: str | None = Field(default=None, max_length=128) # 空 → 回退 username
51+ is_admin: bool = False
52+ orgs: list[str] = Field(default_factory=list) # 组织 id 或名称;无效自动忽略 → 无组织
53+ 
54+ 
55+class UsersBatchCreateBody(BaseModel):
56+ """批量新建用户请求体(前端解析 Excel/CSV 后提交 JSON)。"""
57+ 
58+ users: list[UserBatchItem] = Field(..., min_length=1, max_length=500)
@@ -0,0 +1,118 @@
1+"""JWT 签名密钥(RS256):落库自举,生成一次→存身份库→所有副本读同一行。
2+ 
3+k8s 多副本天然一致(DB 即共享密钥库,无需 Secret 同步)。私钥仅本服务持有(签发
4+access JWT);公钥经 ``/v1/auth/public_key`` 暴露,供资源服务器本地验签。
5+ 
6+> 落库范式参考 claw_manager 既有的 `get_or_create_manager_signing_key`,但**表/库/
7+> 算法均独立**(本表 `identity_jwt_signing_key`、RSA PEM 文本),函数名 `get_or_create_
8+> jwt_signing_key` 以示区分。
9+"""
10+ 
11+from __future__ import annotations
12+ 
13+import hashlib
14+from dataclasses import dataclass
15+ 
16+from cryptography.hazmat.primitives import serialization
17+from cryptography.hazmat.primitives.asymmetric import rsa
18+from openjiuwen_runtime.foundation.db.handler import DBHandler
19+ 
20+from identity_center.infrastructure.logger import get_logger
21+from identity_center.infrastructure.utils import utc_now
22+from identity_center.models.identity_models import IDENTITY_JWT_SIGNING_KEY_TABLE_DEF
23+ 
24+_log = get_logger(__name__)
25+ 
26+_TABLE = IDENTITY_JWT_SIGNING_KEY_TABLE_DEF.table_name
27+_SINGLETON_ID = "default"
28+_ALG = "RS256"
29+_VERSION = "v1"
30+ 
31+# 进程内缓存(启动时由 load_signing_key 填充)。
32+_PRIVATE_PEM: bytes | None = None
33+_PUBLIC_PEM: bytes | None = None
34+ 
35+ 
36+@dataclass(frozen=True)
37+class JwtSigningKey:
38+ key_version: str
39+ private_pem: bytes
40+ public_pem: bytes
41+ fingerprint: str
42+ 
43+ 
44+def _generate_keypair() -> tuple[bytes, bytes]:
45+ key = rsa.generate_private_key(public_exponent=65537, key_size=2048)
46+ priv_pem = key.private_bytes(
47+ encoding=serialization.Encoding.PEM,
48+ format=serialization.PrivateFormat.PKCS8,
49+ encryption_algorithm=serialization.NoEncryption(),
50+ )
51+ pub_pem = key.public_key().public_bytes(
52+ encoding=serialization.Encoding.PEM,
53+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
54+ )
55+ return priv_pem, pub_pem
56+ 
57+ 
58+def _row_to_key(row: object) -> JwtSigningKey:
59+ return JwtSigningKey(
60+ key_version=str(getattr(row, "key_version", _VERSION)),
61+ private_pem=str(getattr(row, "private_key", "")).encode("utf-8"),
62+ public_pem=str(getattr(row, "public_key", "")).encode("utf-8"),
63+ fingerprint=str(getattr(row, "fingerprint", "")),
64+ )
65+ 
66+ 
67+async def get_or_create_jwt_signing_key(handler: DBHandler) -> JwtSigningKey:
68+ """读取 JWT 签名密钥;不存在则生成 RSA-2048 并落库(单例,幂等,并发安全)。"""
69+ existing = await handler.get(_TABLE, {"id": _SINGLETON_ID})
70+ if existing is not None:
71+ return _row_to_key(existing)
72+ 
73+ priv_pem, pub_pem = _generate_keypair()
74+ fingerprint = hashlib.sha256(pub_pem).hexdigest()
75+ now = utc_now()
76+ try:
77+ await handler.create(
78+ _TABLE,
79+ {
80+ "id": _SINGLETON_ID,
81+ "sign_alg": _ALG,
82+ "private_key": priv_pem.decode("utf-8"),
83+ "public_key": pub_pem.decode("utf-8"),
84+ "key_version": _VERSION,
85+ "fingerprint": fingerprint,
86+ "created_at": now,
87+ "updated_at": now,
88+ },
89+ )
90+ _log.info("[jwt] generated & persisted RS256 signing key", version=_VERSION, fp=fingerprint[:16])
91+ except Exception: # noqa: BLE001
92+ # 并发下另一副本可能抢先建好 → 回读返回那一行(保证全副本同一密钥)。
93+ again = await handler.get(_TABLE, {"id": _SINGLETON_ID})
94+ if again is not None:
95+ return _row_to_key(again)
96+ raise
97+ return JwtSigningKey(_VERSION, priv_pem, pub_pem, fingerprint)
98+ 
99+ 
100+async def load_signing_key(handler: DBHandler) -> JwtSigningKey:
101+ """启动时调用:落库自举 + 填充进程缓存。"""
102+ global _PRIVATE_PEM, _PUBLIC_PEM
103+ key = await get_or_create_jwt_signing_key(handler)
104+ _PRIVATE_PEM, _PUBLIC_PEM = key.private_pem, key.public_pem
105+ _log.info("[jwt] loaded signing key from DB", version=key.key_version, fp=key.fingerprint[:16])
106+ return key
107+ 
108+ 
109+def private_pem() -> bytes:
110+ if _PRIVATE_PEM is None:
111+ raise RuntimeError("JWT signing key not loaded; call load_signing_key(handler) at startup")
112+ return _PRIVATE_PEM
113+ 
114+ 
115+def public_pem() -> bytes:
116+ if _PUBLIC_PEM is None:
117+ raise RuntimeError("JWT signing key not loaded; call load_signing_key(handler) at startup")
118+ return _PUBLIC_PEM
@@ -0,0 +1,58 @@
1+"""Access JWT 的签发与校验(RS256)。
2+ 
3+claims:``sub``(user_id)、``name``、``is_admin``、``groups``(组织 id 列表)、
4+``iss``/``aud``/``iat``/``exp``/``jti``/``typ=access``。资源服务器用公钥验签即可,
5+无需回调本服务。
6+"""
7+ 
8+from __future__ import annotations
9+ 
10+import uuid
11+from datetime import timedelta
12+from typing import Any
13+ 
14+import jwt
15+ 
16+from identity_center.infrastructure.config import settings
17+from identity_center.infrastructure.utils import utc_now
18+from identity_center.security.jwt_keys import private_pem, public_pem
19+ 
20+_ALG = "RS256"
21+ 
22+ 
23+def issue_access_token(
24+ user_id: str,
25+ *,
26+ display_name: str | None = None,
27+ is_admin: bool = False,
28+ groups: list[str] | None = None,
29+) -> tuple[str, int]:
30+ """返回 (access_token, expires_in_seconds)。"""
31+ now = utc_now()
32+ ttl = int(settings.access_ttl_seconds)
33+ payload: dict[str, Any] = {
34+ "sub": user_id,
35+ "name": display_name or user_id,
36+ "is_admin": bool(is_admin),
37+ "groups": groups or [],
38+ "iss": settings.jwt_issuer,
39+ "aud": settings.jwt_audience,
40+ "iat": now,
41+ "exp": now + timedelta(seconds=ttl),
42+ "jti": str(uuid.uuid4()),
43+ "typ": "access",
44+ }
45+ token = jwt.encode(payload, private_pem(), algorithm=_ALG)
46+ return token, ttl
47+ 
48+ 
49+def decode_access_token(token: str) -> dict[str, Any]:
50+ """验签 + 校验 iss/aud/exp;失败抛 jwt 异常。返回 claims。"""
51+ return jwt.decode(
52+ token,
53+ public_pem(),
54+ algorithms=[_ALG],
55+ audience=settings.jwt_audience,
56+ issuer=settings.jwt_issuer,
57+ options={"require": ["exp", "iat", "sub"]},
58+ )
@@ -0,0 +1,53 @@
1+[build-system]
2+requires = ["setuptools>=61.0", "wheel"]
3+build-backend = "setuptools.build_meta"
4+ 
5+[project]
6+# 重构:原 jiuwenclaw-manager (Claw Manager),迁入 runtime 后改名 manager-server
7+name = "manager-server"
8+version = "0.1.0"
9+description = "Agent manager server (management plane)"
10+requires-python = ">=3.11.4,<3.14"
11+license = { text = "Apache-2.0" }
12+classifiers = [
13+ "License :: OSI Approved :: Apache Software License",
14+]
15+dependencies = [
16+ "fastapi>=0.115",
17+ "uvicorn[standard]>=0.30",
18+ "pydantic>=2.0",
19+ "pydantic-settings>=2.0",
20+ "httpx>=0.27",
21+ "structlog>=24.0",
22+ "pyyaml>=6.0",
23+ # 重构:foundation 由 git 源(agent-runtime@develop)改为本仓源码,见 [tool.uv.sources]
24+ "openjiuwen-runtime-foundation",
25+ "websockets>=12.0,<16",
26+ "asyncpg>=0.29",
27+ "cryptography>=42.0",
28+ "PyJWT>=2.8",
29+]
30+ 
31+[project.optional-dependencies]
32+dev = [
33+ "pytest>=9.0.2",
34+ "pytest-asyncio>=1.3.0",
35+ "ruff>=0.6",
36+]
37+ 
38+# 重构:foundation 改用本仓 editable 源码,不再从 gitcode 拉取
39+[tool.uv.sources]
40+openjiuwen-runtime-foundation = { path = "../../../foundation", editable = true }
41+ 
42+[project.scripts]
43+manager-server = "manager_server.main:main"
44+manager-start = "manager_server.start_services:main"
45+manager-web = "manager_server.manager_web:main"
46+ 
47+[tool.setuptools.packages.find]
48+where = ["src"]
49+include = ["manager_server*"]
50+ 
51+[tool.ruff]
52+src = ["src"]
53+line-length = 100
@@ -0,0 +1,383 @@
1+#!/usr/bin/env python3
2+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
3+"""配置完成后,经 Gateway WebChannel 发送一条用户聊天请求(验证 AgentServer 侧企业策略 / 模型 / Skill / 扩展配置)。
4+ 
5+依赖:主仓库已安装 ``websockets``(``uv sync`` 或 ``pip install websockets``)。
6+ 
7+典型用法(先完成 provision-local + ``enterprise_config_demo_data_config.py``,记下 provision 返回的 web 端口)::
8+ 
9+ # alice → default/vision 均为 M3 VIP-加强对话;Skill W1;扩展 E3(覆盖服务 E1+E2)
10+ uv run python packages/jiuwenclaw-ee/claw_manager/scripts/enterprise_config_chat.py \\
11+ --group-id g_demo_sales --bot-id bot_main --user-id alice \\
12+ --content "用一句话说明当前使用的模型" --web-port 19234
13+ 
14+ # bob → default=M5,vision=M2;Skill W1+W2;扩展 E1+E2(继承服务级)
15+ uv run python .../enterprise_config_chat.py \\
16+ --group-id g_demo_sales --bot-id bot_main --user-id bob --web-port 19234
17+ 
18+ # g_unknown → 四模型槽位均为 M1;Skill W3;扩展 E4
19+ uv run python .../enterprise_config_chat.py \\
20+ --group-id g_unknown --bot-id bot_main --user-id bob --web-port 19234
21+ 
22+``service_config`` 由 Gateway Runtime 加载,请用 ``enterprise_runtime_service_config.py`` 验证(见数据模型 §3.2)。
23+ 
24+也可把 provision-local 的 JSON 响应存为文件后自动读端口(同样放末尾)::
25+ 
26+ uv run python .../enterprise_config_chat.py \\
27+ --group-id g_demo_sales --bot-id bot_main --user-id alice \\
28+ --provision-json provision.json
29+ 
30+连接远程 Gateway WebChannel::
31+ 
32+ uv run python .../enterprise_config_chat.py \\
33+ --group-id g_demo_sales --bot-id bot_main --user-id alice \\
34+ --ws-url ws://10.0.0.1:19001/ws
35+"""
36+ 
37+from __future__ import annotations
38+ 
39+import argparse
40+import asyncio
41+import json
42+import logging
43+import sys
44+import uuid
45+from pathlib import Path
46+from typing import Any
47+from urllib.parse import urlparse
48+ 
49+logger = logging.getLogger(__name__)
50+_stream_logger = logging.getLogger(f"{__name__}.stream")
51+ 
52+ 
53+def _configure_cli_logging() -> None:
54+ """INFO 走 stdout,ERROR 走 stderr,便于联调脚本对照终端习惯。"""
55+ root = logging.getLogger()
56+ root.handlers.clear()
57+ root.setLevel(logging.INFO)
58+ fmt = logging.Formatter("%(message)s")
59+ out = logging.StreamHandler(sys.stdout)
60+ out.setLevel(logging.INFO)
61+ out.setFormatter(fmt)
62+ err = logging.StreamHandler(sys.stderr)
63+ err.setLevel(logging.ERROR)
64+ err.setFormatter(fmt)
65+ root.addHandler(out)
66+ root.addHandler(err)
67+ 
68+ _stream_logger.handlers.clear()
69+ _stream_logger.propagate = False
70+ _stream_logger.setLevel(logging.INFO)
71+ stream_out = logging.StreamHandler(sys.stdout)
72+ stream_out.setLevel(logging.INFO)
73+ stream_out.setFormatter(fmt)
74+ stream_out.terminator = ""
75+ _stream_logger.addHandler(stream_out)
76+ 
77+ 
78+def _write_stream(text: str) -> None:
79+ """流式输出模型片段(非结构化日志,保持单行连续打印)。"""
80+ _stream_logger.info(text)
81+ 
82+ 
83+# enterprise_config_demo_data_config.py 写入后的预期槽位(便于联调对照 AgentServer 日志)
84+_MODEL_SLOT_KEYS = ("default_model", "vision_model", "video_model", "audio_model")
85+ 
86+_DEMO_ENTERPRISE_EXPECTATIONS: dict[tuple[str, str], dict[str, Any]] = {
87+ ("g_demo_sales", "alice"): {
88+ "model_slots": {
89+ "default_model": "M3 VIP-加强对话 (gpt-5)",
90+ "vision_model": "M3 VIP-加强对话 (gpt-5)",
91+ "video_model": "M1 全局兜底-经济型 (gpt-4o-mini)",
92+ "audio_model": "M1 全局兜底-经济型 (gpt-4o-mini)",
93+ },
94+ "skill_whitelist": "W1 销售组-天气 Skill",
95+ "extension_config": "E3 Agent Server 错误恢复(覆盖服务 E1+E2)",
96+ "note": "2.6.1 覆盖 2.5.1 的 default/vision;video/audio 由 2.7 回填 M1 全局兜底-经济型",
97+ },
98+ ("g_demo_sales", "bob"): {
99+ "model_slots": {
100+ "default_model": "M5 销售组映射专用 (gpt-4o-group-map)",
101+ "vision_model": "M2 销售组-标准型 (gpt-4o)",
102+ "video_model": "M1 全局兜底-经济型 (gpt-4o-mini)",
103+ "audio_model": "M1 全局兜底-经济型 (gpt-4o-mini)",
104+ },
105+ "skill_whitelist": "W1 销售组-天气 Skill + W2 销售组-CRM Skill(继承 2.5.1)",
106+ "extension_config": "E1 Gateway 请求前鉴权 + E2 Gateway 请求后日志(继承 2.5.1)",
107+ "note": "default=M5(2.6.2+2.8.2);vision=M2(继承 2.5.1);video/audio=M1 全局兜底-经济型(2.7 回填)",
108+ },
109+ ("g_unknown", "bob"): {
110+ "model_slots": {
111+ "default_model": "M1 全局兜底-经济型 (gpt-4o-mini)",
112+ "vision_model": "M1 全局兜底-经济型 (gpt-4o-mini)",
113+ "video_model": "M1 全局兜底-经济型 (gpt-4o-mini)",
114+ "audio_model": "M1 全局兜底-经济型 (gpt-4o-mini)",
115+ },
116+ "skill_whitelist": "W3 全局兜底 Skill",
117+ "extension_config": "E4 Gateway 定时清理",
118+ "note": "未命中服务策略,四槽位均走 2.7 全局兜底(M1 全局兜底-经济型)",
119+ },
120+}
121+ 
122+ 
123+def _log_demo_expectation(group_id: str, user_id: str) -> None:
124+ hint = _DEMO_ENTERPRISE_EXPECTATIONS.get((group_id, user_id))
125+ if hint is None:
126+ hint = _DEMO_ENTERPRISE_EXPECTATIONS.get((group_id, "bob"))
127+ if hint is None:
128+ return
129+ slots = hint.get("model_slots") or {}
130+ slot_parts = [f"{k}={slots.get(k, '未配置')}" for k in _MODEL_SLOT_KEYS]
131+ logger.info("[expect] 演示 seed 预期模型槽位: %s", "; ".join(slot_parts))
132+ if hint.get("note"):
133+ logger.info("[expect] 说明: %s", hint["note"])
134+ logger.info(
135+ "[expect] skill_whitelist=%s; extension_config=%s",
136+ hint["skill_whitelist"],
137+ hint["extension_config"],
138+ )
139+ logger.info(
140+ "[expect] 可在 AgentServer 日志中查找 "
141+ "[enterprise_config] loaded enterprise config 确认各槽位 template_ref"
142+ )
143+ logger.info(
144+ "[expect] service_config 由 Gateway Runtime 验证,见 enterprise_runtime_service_config.py"
145+ )
146+ 
147+ 
148+def _load_web_port_from_provision(path: Path) -> int:
149+ raw = json.loads(path.read_text(encoding="utf-8"))
150+ data = raw.get("data", raw)
151+ ports = data.get("ports") if isinstance(data, dict) else None
152+ if not isinstance(ports, dict):
153+ raise ValueError(f"无法在 {path} 中找到 data.ports")
154+ web = ports.get("web")
155+ if web is None:
156+ raise ValueError(f"无法在 {path} 中找到 data.ports.web")
157+ return int(web)
158+ 
159+ 
160+def _resolve_ws_url(args: argparse.Namespace) -> str:
161+ """解析 WebSocket 目标:--ws-url 优先,否则由 host + web 端口 + path 拼装。"""
162+ if args.ws_url:
163+ url = str(args.ws_url).strip()
164+ parsed = urlparse(url)
165+ if parsed.scheme not in ("ws", "wss"):
166+ raise ValueError(f"--ws-url 须为 ws:// 或 wss://,当前 scheme={parsed.scheme!r}")
167+ if not parsed.netloc:
168+ raise ValueError(f"--ws-url 无效(缺少 host): {url!r}")
169+ return url
170+ if args.provision_json is not None:
171+ web_port = _load_web_port_from_provision(args.provision_json)
172+ else:
173+ web_port = int(args.web_port)
174+ return f"ws://{args.host}:{web_port}{args.ws_path}"
175+ 
176+ 
177+def _browser_origin_header(ws_url: str) -> dict[str, str]:
178+ """WebChannel 默认校验 Origin;Python websockets 客户端需模拟浏览器 Origin。"""
179+ parsed = urlparse(ws_url)
180+ host = parsed.hostname or "127.0.0.1"
181+ http_scheme = "https" if parsed.scheme == "wss" else "http"
182+ port = parsed.port
183+ default_port = 443 if http_scheme == "https" else 80
184+ if port is not None and port != default_port:
185+ origin = f"{http_scheme}://{host}:{port}"
186+ else:
187+ origin = f"{http_scheme}://{host}"
188+ return {"Origin": origin}
189+ 
190+ 
191+def _parse_args() -> argparse.Namespace:
192+ p = argparse.ArgumentParser(description="经 Gateway /ws 发送 chat.send(企业路由参数在 params 内)")
193+ p.add_argument("--host", default="127.0.0.1", help="Gateway 主机,默认 127.0.0.1")
194+ p.add_argument("--ws-path", default="/ws", help="WebSocket 路径,默认 /ws")
195+ p.add_argument("--session-id", default="", help="会话 ID;留空则由 Gateway 自动生成")
196+ p.add_argument(
197+ "--content",
198+ default="你好,请用一句话回复,并说明你当前使用的模型名称。",
199+ help="用户消息正文(会同时写入 content 与 query)",
200+ )
201+ p.add_argument("--group-id", default="g_demo_sales", help="企业策略 group_id")
202+ p.add_argument("--bot-id", default="bot_main", help="企业策略 bot_id")
203+ p.add_argument("--user-id", default="alice", help="企业策略 user_id / agent_id 匹配")
204+ p.add_argument("--mode", default="agent.plan", help="运行模式,如 agent.plan")
205+ p.add_argument(
206+ "--timeout",
207+ type=float,
208+ default=180.0,
209+ help="等待 chat.final 的最长时间(秒)",
210+ )
211+ p.add_argument(
212+ "--print-deltas",
213+ action="store_true",
214+ help="打印 chat.delta 流式片段(默认只打印 final)",
215+ )
216+ src = p.add_mutually_exclusive_group(required=True)
217+ src.add_argument("--web-port", type=int, help="Gateway WebChannel 端口(provision 返回的 ports.web)")
218+ src.add_argument(
219+ "--provision-json",
220+ type=Path,
221+ help="provision-local 响应 JSON 文件路径(读取 data.ports.web)",
222+ )
223+ src.add_argument(
224+ "--ws-url",
225+ help="完整 WebSocket URL(连接远程 Gateway),如 ws://host:19001/ws 或 wss://host/ws",
226+ )
227+ return p.parse_args()
228+ 
229+ 
230+async def _recv_json(ws: Any, timeout: float) -> dict[str, Any]:
231+ raw = await asyncio.wait_for(ws.recv(), timeout=timeout)
232+ data = json.loads(raw)
233+ if not isinstance(data, dict):
234+ raise TypeError(f"非 JSON 对象: {raw!r}")
235+ return data
236+ 
237+ 
238+async def _run_chat(args: argparse.Namespace) -> int:
239+ import websockets
240+ 
241+ ws_url = _resolve_ws_url(args)
242+ 
243+ session_id = (args.session_id or "").strip() or f"sess_{uuid.uuid4().hex[:12]}"
244+ req_id = f"req_{uuid.uuid4().hex[:12]}"
245+ 
246+ params: dict[str, Any] = {
247+ "session_id": session_id,
248+ "content": args.content,
249+ "query": args.content,
250+ "mode": args.mode,
251+ "group_id": args.group_id,
252+ "bot_id": args.bot_id,
253+ "user_id": args.user_id,
254+ }
255+ 
256+ req = {
257+ "type": "req",
258+ "id": req_id,
259+ "method": "chat.send",
260+ "params": params,
261+ }
262+ 
263+ logger.info("[connect] %s", ws_url)
264+ logger.info(
265+ "[send] session_id=%s group_id=%s bot_id=%s user_id=%s",
266+ session_id,
267+ args.group_id,
268+ args.bot_id,
269+ args.user_id,
270+ )
271+ _log_demo_expectation(args.group_id, args.user_id)
272+ logger.info("[send] content=%r", args.content)
273+ 
274+ deadline = asyncio.get_running_loop().time() + args.timeout
275+ 
276+ ws_headers = _browser_origin_header(ws_url)
277+ async with websockets.connect(
278+ ws_url,
279+ open_timeout=15,
280+ additional_headers=ws_headers,
281+ ) as ws:
282+ await ws.send(json.dumps(req, ensure_ascii=False))
283+ 
284+ accepted = False
285+ while asyncio.get_running_loop().time() < deadline:
286+ remaining = max(0.1, deadline - asyncio.get_running_loop().time())
287+ try:
288+ frame = await _recv_json(ws, remaining)
289+ except asyncio.TimeoutError:
290+ logger.error("[timeout] 未在时限内收到 chat.final")
291+ return 2
292+ 
293+ ftype = frame.get("type")
294+ 
295+ if ftype == "res" and frame.get("id") == req_id:
296+ ok = bool(frame.get("ok"))
297+ payload = frame.get("payload") or {}
298+ logger.info(
299+ "[res] ok=%s payload=%s",
300+ ok,
301+ json.dumps(payload, ensure_ascii=False),
302+ )
303+ if not ok:
304+ err = frame.get("error") or payload.get("error") or frame
305+ logger.error("[error] %s", err)
306+ return 1
307+ accepted = bool(payload.get("accepted", True))
308+ if not accepted:
309+ logger.error("[error] chat.send 未被接受")
310+ return 1
311+ continue
312+ 
313+ if ftype == "event":
314+ event = frame.get("event")
315+ payload = frame.get("payload") or {}
316+ if event == "connection.ack":
317+ logger.info("[event] connection.ack")
318+ continue
319+ if event == "chat.delta" and args.print_deltas:
320+ chunk = payload.get("content") or payload.get("text") or ""
321+ if chunk:
322+ _write_stream(chunk)
323+ continue
324+ if event == "chat.final":
325+ if args.print_deltas:
326+ _write_stream("\n")
327+ final_text = str(payload.get("content") or payload.get("text") or "")
328+ logger.info(
329+ "[event] chat.final session_id=%s",
330+ payload.get("session_id", session_id),
331+ )
332+ logger.info("%s", final_text or "(empty)")
333+ return 0
334+ if event == "chat.error":
335+ logger.error(
336+ "[event] chat.error %s",
337+ json.dumps(payload, ensure_ascii=False),
338+ )
339+ return 1
340+ logger.info(
341+ "[event] %s %s",
342+ event,
343+ json.dumps(payload, ensure_ascii=False)[:200],
344+ )
345+ continue
346+ 
347+ logger.info("[frame] %s", json.dumps(frame, ensure_ascii=False)[:500])
348+ 
349+ if not accepted:
350+ logger.error("[timeout] 未收到 chat.send 的 res 确认")
351+ return 2
352+ logger.error("[timeout] 已接受请求但未收到 chat.final")
353+ return 2
354+ 
355+ 
356+def main() -> int:
357+ _configure_cli_logging()
358+ try:
359+ import websockets # noqa: F401
360+ except ImportError:
361+ logger.error(
362+ "缺少 websockets,请在 jiuwenclaw 仓库根目录执行: uv sync 或 pip install websockets"
363+ )
364+ return 1
365+ 
366+ args = _parse_args()
367+ try:
368+ return asyncio.run(_run_chat(args))
369+ except KeyboardInterrupt:
370+ return 130
371+ except OSError as connect_err:
372+ logger.error("[connect-failed] %s", connect_err)
373+ logger.error(
374+ "请确认 Gateway 已启动,且 --web-port / --provision-json / --ws-url 指向可访问的 WebChannel。"
375+ )
376+ return 1
377+ except Exception as err:
378+ logger.error("[failed] %s", err)
379+ return 1
380+ 
381+ 
382+if __name__ == "__main__":
383+ raise SystemExit(main())
@@ -0,0 +1,372 @@
1+#!/usr/bin/env python3
2+# Copyright (c) Huawei Technologies Co., Ltd. 2026. All rights reserved.
3+"""验证 Gateway Runtime Management 按路由上下文加载正确的 ``service_config`` 模板。
4+ 
5+与 ``RuntimeManagementAgentClient.send_request`` / ``send_request_stream`` 使用同一套
6+``load_effective_enterprise_config(..., slots=[service_config])`` 读库逻辑(不依赖 K8s 池)。
7+加载 ``service_config`` 槽位时,还会解析命中策略上的 ``service_id`` / ``agent_id`` 模板
8+(如 ``${group_id}::${bot_id}``、``${user_id}``),与 Runtime 转发前写入 ``AgentRequest`` 的行为一致。
9+ 
10+前置:已完成 ``provision-local``、``enterprise_config_demo_data_config.py`` 写入演示策略。
11+数据库连接与 Gateway 进程一致,由 ``manager_ws_client.infrastructure.db.Database`` 按 ``GATEWAY_*`` /
12+``.env`` 解析(支持 sqlite / mysql),脚本侧无需再传数据目录。
13+ 
14+典型用法(PowerShell,项目根目录;请在仓库根 ``.env`` 中配置与 Gateway 相同的 ``GATEWAY_*``)::
15+ 
16+ # 单场景:alice → S1 销售组 AgentServer 池(2.5.1)
17+ uv run python packages/jiuwenclaw-ee/claw_manager/scripts/enterprise_runtime_service_config.py \\
18+ --group-id g_demo_sales --bot-id bot_main --user-id alice b26bc496-dfee-488b-a2ab-8bae8ce94985
19+ 
20+ # 一次跑完文档 3.2.1–3.2.3 三个演示场景
21+ uv run python packages/jiuwenclaw-ee/claw_manager/scripts/enterprise_runtime_service_config.py \\
22+ --provision-json provision.json --all-scenarios
23+ 
24+``--provision-json`` 可自动读取 ``jiuwenclaw_id``。
25+"""
26+ 
27+from __future__ import annotations
28+ 
29+import argparse
30+import asyncio
31+import importlib
32+import json
33+import logging
34+import sys
35+from dataclasses import dataclass
36+from pathlib import Path
37+from typing import Any
38+ 
39+logger = logging.getLogger(__name__)
40+ 
41+ 
42+@dataclass(frozen=True)
43+class ServiceConfigExpectation:
44+ label: str
45+ template_name: str
46+ min_idle_services: int
47+ max_services: int
48+ policy_source: str
49+ service_id: str | None
50+ agent_id: str | None
51+ 
52+ 
53+_DEMO_SCENARIOS: dict[tuple[str, str], ServiceConfigExpectation] = {
54+ ("g_demo_sales", "alice"): ServiceConfigExpectation(
55+ label="S1 销售组 AgentServer 池",
56+ template_name="销售组 AgentServer 池",
57+ min_idle_services=2,
58+ max_services=10,
59+ policy_source="2.5.1",
60+ service_id="g_demo_sales::bot_main",
61+ agent_id="alice",
62+ ),
63+ ("g_demo_sales", "bob"): ServiceConfigExpectation(
64+ label="S1 销售组 AgentServer 池",
65+ template_name="销售组 AgentServer 池",
66+ min_idle_services=2,
67+ max_services=10,
68+ policy_source="继承 2.5.1",
69+ service_id="g_demo_sales::bot_main",
70+ agent_id="default_agent_id_1",
71+ ),
72+ ("g_unknown", "bob"): ServiceConfigExpectation(
73+ label="S2 全局兜底 AgentServer 池",
74+ template_name="全局兜底 AgentServer 池",
75+ min_idle_services=1,
76+ max_services=5,
77+ policy_source="2.7",
78+ service_id=None,
79+ agent_id=None,
80+ ),
81+}
82+ 
83+_ALL_SCENARIO_KEYS = (
84+ ("g_demo_sales", "bot_main", "alice"),
85+ ("g_demo_sales", "bot_main", "bob"),
86+ ("g_unknown", "bot_main", "bob"),
87+)
88+ 
89+ 
90+def _configure_cli_logging() -> None:
91+ root = logging.getLogger()
92+ root.handlers.clear()
93+ root.setLevel(logging.INFO)
94+ fmt = logging.Formatter("%(message)s")
95+ out = logging.StreamHandler(sys.stdout)
96+ out.setLevel(logging.INFO)
97+ out.setFormatter(fmt)
98+ err = logging.StreamHandler(sys.stderr)
99+ err.setLevel(logging.ERROR)
100+ err.setFormatter(fmt)
101+ root.addHandler(out)
102+ root.addHandler(err)
103+ 
104+ 
105+def _load_jiuwenclaw_id_from_provision(path: Path) -> str:
106+ raw = json.loads(path.read_text(encoding="utf-8"))
107+ data = raw.get("data", raw)
108+ if not isinstance(data, dict):
109+ raise ValueError(f"无法在 {path} 中找到 data 对象")
110+ jiuwenclaw_id = str(data.get("jiuwenclaw_id") or "").strip()
111+ if not jiuwenclaw_id:
112+ raise ValueError(f"无法在 {path} 中找到 data.jiuwenclaw_id")
113+ return jiuwenclaw_id
114+ 
115+ 
116+def _bootstrap_modules() -> tuple[Any, Any, Any, Any, Any]:
117+ from jiuwenclaw.infrastructure.module_importer import (
118+ ensure_manager_ws_client_package,
119+ import_manager_ws_client_module,
120+ )
121+ 
122+ ext_root = ensure_manager_ws_client_package()
123+ loader_mod = import_manager_ws_client_module("core.enterprise_config.loader")
124+ schemas_mod = import_manager_ws_client_module("core.enterprise_config.schemas")
125+ utils_mod = import_manager_ws_client_module("infrastructure.utils")
126+ db_mod = import_manager_ws_client_module("infrastructure.db")
127+ gateway_db_mod = import_manager_ws_client_module("core.enterprise_config.gateway_db")
128+ database = db_mod.Database(relative_root=ext_root)
129+ return (
130+ loader_mod.load_effective_enterprise_config,
131+ schemas_mod.TemplateRefSlot.SERVICE_CONFIG,
132+ utils_mod.set_jiuwenclaw_id,
133+ database,
134+ gateway_db_mod,
135+ )
136+ 
137+ 
138+async def _ensure_gateway_database(database: Any) -> None:
139+ """与 Gateway / enterprise_config 读库相同:经 ``Database.ensure_ready`` 连接。"""
140+ await database.ensure_ready(log_prefix="enterprise_runtime_verify")
141+ summary = database.config_summary()
142+ logger.info("[db] gateway database ready: %s", summary)
143+ if summary.get("db_type") == "sqlite":
144+ sqlite_path = Path(str(summary.get("sqlite_path", "")))
145+ if not sqlite_path.is_file():
146+ logger.warning("[db] sqlite 文件不存在: %s", sqlite_path)
147+ 
148+ 
149+async def _close_gateway_databases(database: Any, gateway_db_mod: Any) -> None:
150+ """关闭脚本与 ``enterprise_config.gateway_db`` 各自持有的连接。"""
151+ enterprise_db = getattr(gateway_db_mod, "_db", None)
152+ for label, db in (
153+ ("enterprise_runtime_verify", database),
154+ ("enterprise_config", enterprise_db),
155+ ):
156+ if db is None:
157+ continue
158+ try:
159+ await db.close()
160+ except Exception as exc:
161+ logger.warning("[db] %s disconnect error: %s", label, exc)
162+ 
163+ 
164+class _RoutingRequest:
165+ def __init__(
166+ self,
167+ *,
168+ group_id: str,
169+ bot_id: str,
170+ user_id: str,
171+ ) -> None:
172+ self.params = {
173+ "group_id": group_id,
174+ "bot_id": bot_id,
175+ "user_id": user_id,
176+ }
177+ 
178+ 
179+def _log_expectation(group_id: str, user_id: str) -> ServiceConfigExpectation | None:
180+ expect = _DEMO_SCENARIOS.get((group_id, user_id))
181+ if expect is None:
182+ expect = _DEMO_SCENARIOS.get((group_id, "bob"))
183+ if expect is None:
184+ logger.warning("[expect] 未找到演示 seed 对照表(group_id=%s user_id=%s)", group_id, user_id)
185+ return None
186+ logger.info(
187+ "[expect] service_config=%s (%s); template_name=%r; min_idle=%s max_services=%s; "
188+ "service_id=%r agent_id=%r",
189+ expect.label,
190+ expect.policy_source,
191+ expect.template_name,
192+ expect.min_idle_services,
193+ expect.max_services,
194+ expect.service_id,
195+ expect.agent_id,
196+ )
197+ return expect
198+ 
199+ 
200+def _entity_matches(entity: dict[str, Any], expect: ServiceConfigExpectation) -> list[str]:
201+ errors: list[str] = []
202+ name = str(entity.get("template_name") or "")
203+ if name != expect.template_name:
204+ errors.append(f"template_name={name!r} 预期 {expect.template_name!r}")
205+ for field in ("min_idle_services", "max_services"):
206+ actual = entity.get(field)
207+ expected = getattr(expect, field)
208+ try:
209+ if int(actual) != int(expected):
210+ errors.append(f"{field}={actual} 预期 {expected}")
211+ except (TypeError, ValueError):
212+ errors.append(f"{field}={actual!r} 无法解析为整数")
213+ return errors
214+ 
215+ 
216+def _policy_ids_match(loaded: Any, expect: ServiceConfigExpectation) -> list[str]:
217+ errors: list[str] = []
218+ if loaded.service_id != expect.service_id:
219+ errors.append(
220+ f"service_id={loaded.service_id!r} 预期 {expect.service_id!r}"
221+ )
222+ if loaded.agent_id != expect.agent_id:
223+ errors.append(
224+ f"agent_id={loaded.agent_id!r} 预期 {expect.agent_id!r}"
225+ )
226+ return errors
227+ 
228+ 
229+async def _verify_one(
230+ *,
231+ jiuwenclaw_id: str,
232+ group_id: str,
233+ bot_id: str,
234+ user_id: str,
235+ load_fn: Any,
236+ service_config_slot: Any,
237+) -> int:
238+ expect = _log_expectation(group_id, user_id)
239+ logger.info(
240+ "[load] jiuwenclaw_id=%s group_id=%s bot_id=%s user_id=%s",
241+ jiuwenclaw_id,
242+ group_id,
243+ bot_id,
244+ user_id,
245+ )
246+ 
247+ request = _RoutingRequest(group_id=group_id, bot_id=bot_id, user_id=user_id)
248+ loaded = await load_fn(request, [service_config_slot])
249+ if loaded is None:
250+ logger.error("[fail] 未加载到 service_config(策略未命中或模板不存在)")
251+ return 1
252+ 
253+ entities = loaded.service_config or []
254+ if not entities:
255+ logger.error("[fail] service_config 为空 template_ref=%s", loaded.template_ref)
256+ return 1
257+ 
258+ entity = entities[0]
259+ logger.info(
260+ "[loaded] template_id=%s template_name=%s min_idle_services=%s max_services=%s "
261+ "service_policy_id=%s global_policy_id=%s service_id=%s agent_id=%s",
262+ entity.get("template_id"),
263+ entity.get("template_name"),
264+ entity.get("min_idle_services"),
265+ entity.get("max_services"),
266+ loaded.service_policy_id,
267+ loaded.global_policy_id,
268+ loaded.service_id,
269+ loaded.agent_id,
270+ )
271+ 
272+ if expect is None:
273+ logger.info("[ok] 已加载 service_config(无演示对照表,跳过字段断言)")
274+ return 0
275+ 
276+ errors = _entity_matches(entity, expect)
277+ errors.extend(_policy_ids_match(loaded, expect))
278+ if errors:
279+ for err in errors:
280+ logger.error("[fail] %s", err)
281+ return 1
282+ 
283+ logger.info("[ok] service_config 与演示 seed 预期一致(%s)", expect.label)
284+ return 0
285+ 
286+ 
287+async def _run(args: argparse.Namespace) -> int:
288+ jiuwenclaw_id = (args.jiuwenclaw_id or "").strip()
289+ 
290+ if args.provision_json is not None and not jiuwenclaw_id:
291+ jiuwenclaw_id = _load_jiuwenclaw_id_from_provision(args.provision_json)
292+ 
293+ if not jiuwenclaw_id:
294+ logger.error("缺少 jiuwenclaw_id( positional 或 --provision-json)")
295+ return 1
296+ 
297+ load_fn, service_config_slot, set_jiuwenclaw_id, database, gateway_db_mod = (
298+ _bootstrap_modules()
299+ )
300+ set_jiuwenclaw_id(jiuwenclaw_id)
301+ await _ensure_gateway_database(database)
302+ 
303+ try:
304+ if args.all_scenarios:
305+ exit_code = 0
306+ for group_id, bot_id, user_id in _ALL_SCENARIO_KEYS:
307+ logger.info("")
308+ logger.info("=== 场景 group_id=%s user_id=%s ===", group_id, user_id)
309+ code = await _verify_one(
310+ jiuwenclaw_id=jiuwenclaw_id,
311+ group_id=group_id,
312+ bot_id=bot_id,
313+ user_id=user_id,
314+ load_fn=load_fn,
315+ service_config_slot=service_config_slot,
316+ )
317+ if code != 0:
318+ exit_code = code
319+ return exit_code
320+ 
321+ return await _verify_one(
322+ jiuwenclaw_id=jiuwenclaw_id,
323+ group_id=args.group_id,
324+ bot_id=args.bot_id,
325+ user_id=args.user_id,
326+ load_fn=load_fn,
327+ service_config_slot=service_config_slot,
328+ )
329+ finally:
330+ await _close_gateway_databases(database, gateway_db_mod)
331+ 
332+ 
333+def _parse_args() -> argparse.Namespace:
334+ p = argparse.ArgumentParser(
335+ description="验证 Gateway Runtime Management 的 service_config 企业策略加载",
336+ )
337+ p.add_argument(
338+ "jiuwenclaw_id",
339+ nargs="?",
340+ default="",
341+ help="provision-local 返回的 jiuwenclaw_id(positional,建议放在命令末尾;可与 --provision-json 二选一)",
342+ )
343+ p.add_argument("--group-id", default="g_demo_sales", help="企业策略 group_id")
344+ p.add_argument("--bot-id", default="bot_main", help="企业策略 bot_id")
345+ p.add_argument("--user-id", default="alice", help="企业策略 user_id")
346+ p.add_argument(
347+ "--provision-json",
348+ type=Path,
349+ help="provision-local 响应 JSON(读取 jiuwenclaw_id)",
350+ )
351+ p.add_argument(
352+ "--all-scenarios",
353+ action="store_true",
354+ help="依次验证文档 3.2.1 alice / 3.2.2 bob / 3.2.3 g_unknown 三个 service_config 场景",
355+ )
356+ return p.parse_args()
357+ 
358+ 
359+def main() -> int:
360+ _configure_cli_logging()
361+ args = _parse_args()
362+ try:
363+ return asyncio.run(_run(args))
364+ except KeyboardInterrupt:
365+ return 130
366+ except Exception as exc:
367+ logger.error("[failed] %s", exc)
368+ return 1
369+ 
370+ 
371+if __name__ == "__main__":
372+ raise SystemExit(main())
@@ -0,0 +1,22 @@
1+{
2+ "enabled": true,
3+ "defaults": "ask",
4+ "tools": {
5+ "bash": "deny",
6+ "todo_list": "allow"
7+ },
8+ "rules": [
9+ {
10+ "id": "demo_allow_echo",
11+ "pattern": "echo *",
12+ "action": "allow"
13+ }
14+ ],
15+ "approval_overrides": [],
16+ "file_guard": {
17+ "workspace": { "rw_enabled": true },
18+ "global": {},
19+ "trusted_exec_directory": [],
20+ "tool_bindings": {}
21+ }
22+}
@@ -0,0 +1,12 @@
1+"""JiuwenClaw Manager — EE 管理平面服务。"""
2+ 
3+# 重构:jiuwenclaw 仅本地 provision(subprocess 拉起 gateway/agent)时需要,服务本身(REST/验签)不需要。
4+# 迁移期不做 provision 且 jiuwenclaw 不在 runtime 内,故容错降级、不阻塞导入(provision 真用到时再报错)。
5+try:
6+ from manager_server.infrastructure.jiuwenclaw_importer import ensure_jiuwenclaw_importable
7+ 
8+ ensure_jiuwenclaw_importable()
9+except ImportError:
10+ pass
11+ 
12+__version__ = "0.1.0"
@@ -0,0 +1,90 @@
1+"""FastAPI 应用工厂。"""
2+ 
3+from __future__ import annotations
4+ 
5+import asyncio
6+from contextlib import asynccontextmanager
7+ 
8+from fastapi import FastAPI
9+from fastapi.middleware.cors import CORSMiddleware
10+ 
11+from manager_server import __version__
12+from manager_server.infrastructure.config import settings
13+from manager_server.infrastructure.db import create_db_handler, database_config_summary
14+from manager_server.infrastructure.logger import configure_logging, get_logger
15+from manager_server.models.table_init import init_all_tables
16+from manager_server.routers.register import router_register
17+from manager_server.schedulers.heartbeat_scanner import run_heartbeat_scan_loop
18+ 
19+_log = get_logger(__name__)
20+ 
21+ 
22+@asynccontextmanager
23+async def lifespan(application: FastAPI):
24+ from manager_server.manager_ws_server import ManagerWsServer
25+ 
26+ configure_logging()
27+ db_handler = create_db_handler()
28+ application.state.db_handler = db_handler
29+ await db_handler.init_database()
30+ await db_handler.connect()
31+ await init_all_tables(db_handler)
32+ # 身份/账号种子已移至独立认证服务(jiuwenclaw_identity);管理库不再播种用户/组织。
33+ # 加载/生成 Manager 签名密钥对(Ed25519),供握手下发公钥与下发加签使用。
34+ from manager_server.security.keys import get_or_create_manager_signing_key
35+ from manager_server.security.sign_provider import set_manager_signing_key
36+ 
37+ set_manager_signing_key(await get_or_create_manager_signing_key(db_handler))
38+ stop = asyncio.Event()
39+ scan_task = asyncio.create_task(run_heartbeat_scan_loop(stop, db_handler))
40+ if settings.manager_ws_enabled:
41+ manager_ws_server = ManagerWsServer(
42+ host=settings.manager_ws_host,
43+ port=settings.manager_ws_port,
44+ )
45+ await manager_ws_server.start()
46+ ManagerWsServer.set_instance(manager_ws_server)
47+ _log.info(
48+ "startup",
49+ version=__version__,
50+ db=database_config_summary(),
51+ manager_ws=(
52+ f"ws://{settings.manager_ws_host}:{settings.manager_ws_port}"
53+ if settings.manager_ws_enabled
54+ else None
55+ ),
56+ )
57+ yield
58+ stop.set()
59+ scan_task.cancel()
60+ try:
61+ await scan_task
62+ except asyncio.CancelledError:
63+ pass
64+ manager_ws_server = ManagerWsServer.get_instance()
65+ if manager_ws_server is not None:
66+ ManagerWsServer.set_instance(None)
67+ await manager_ws_server.stop()
68+ await db_handler.disconnect()
69+ _log.info("shutdown")
70+ 
71+ 
72+def create_app() -> FastAPI:
73+ application = FastAPI(
74+ title="manager-server",
75+ description="JiuwenClaw EE 管理平面(Claw Manager)",
76+ version=__version__,
77+ lifespan=lifespan,
78+ )
79+ application.add_middleware(
80+ CORSMiddleware,
81+ allow_origins=["*"],
82+ allow_credentials=True,
83+ allow_methods=["*"],
84+ allow_headers=["*"],
85+ )
86+ router_register(application)
87+ return application
88+ 
89+ 
90+app = create_app()
@@ -0,0 +1 @@
1+"""业务核心层(请从子包直接导入,勿在本包 ``__init__`` 聚合导出,避免循环依赖)。"""
@@ -0,0 +1,16 @@
1+from .channel_config import push_channel_config_op
2+from .logging_config import push_logging_config_op
3+from .embed_config import push_embed_config_op
4+from .task_memory_config import push_task_memory_config_op
5+ 
6+from .permissions_config import push_permissions_config_op
7+from .memory_config import push_memory_config_op
8+ 
9+__all__ = (
10+ "push_channel_config_op",
11+ "push_logging_config_op",
12+ "push_embed_config_op",
13+ "push_task_memory_config_op",
14+ "push_permissions_config_op",
15+ "push_memory_config_op",
16+)