From 7cb5771e06712ec33821199965966e0f1bf3f3b7 Mon Sep 17 00:00:00 2001 From: chakcy <947105045@qq.com> Date: Thu, 10 Sep 2026 16:43:17 +0800 Subject: [PATCH] =?UTF-8?q?modify:=20auth=E6=8F=92=E4=BB=B6=E6=8B=86?= =?UTF-8?q?=E5=88=86=EF=BC=8C=E5=B0=86token=E7=AD=BE=E5=8F=91=E9=83=A8?= =?UTF-8?q?=E5=88=86=E4=BA=A4=E7=BB=99=E7=BD=91=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 8 +- pyproject.toml | 1 + src/chongming/app/__init__.py | 13 +- src/chongming/app/core/lifespan_setup.py | 29 ++- src/chongming/app/routes/__init__.py | 3 +- src/chongming/app/routes/auth.py | 53 ++++++ test/test_auth_builtin.py | 113 ++++++++++++ test/test_auth_oauth.py | 215 +++++++++++++++++++++++ test/test_auth_race.py | 36 ++-- uv.lock | 33 +++- 10 files changed, 467 insertions(+), 37 deletions(-) create mode 100644 src/chongming/app/routes/auth.py create mode 100644 test/test_auth_builtin.py create mode 100644 test/test_auth_oauth.py diff --git a/README.md b/README.md index c5c83b4..d5cd2fc 100644 --- a/README.md +++ b/README.md @@ -328,7 +328,7 @@ inline worker 的生命周期交给**基于分布式锁的宿主级领导选举* `Worker 管理`(`/workers`,深色主题): -- **登录**:经 auth 插件 `POST /auth/login` 领取管理 Token(无需手填 JWT),从 JWT 解析当前用户与超管标识。 +- **登录**:内置网关端点 `POST /auth/login`(auth 域拆分后登录/签发不依赖 auth 插件,插件缩容或故障时登录与 admin API 仍可用),领取管理 Token(无需手填 JWT),从 JWT 解析当前用户与超管标识。 - **鉴权**:仅 `is_superuser=true` 可管理;权限不足时前端/后端弹出对应提示(403)。 - **列表分页**:展示副本(插件类型 / 副本 ID / 模式 inline|external / 状态),内置分页。 - **查询与筛选**:列表顶部统一筛选栏——关键字搜索(插件类型 / 副本 ID / 实例 ID)、模式筛选(全部 / inline / external)、副本数 ≥ N 过滤;**同时作用于下方「逐实例精细扩缩」分组表**(副本数过滤仅对分组表生效),筛选条件变化自动回到第一页。 @@ -729,7 +729,8 @@ uv pip install -e utils/ # 安装/更新工具包 | 插件 | 路径 | 功能 | | ------------------------ | ----------------------------------- | ------------------------------------------------------- | -| **auth** | `src/plugins/auth/` | 用户注册/登录/资料管理,JWT 鉴权,管理员初始化 | +| **auth** | `src/plugins/auth/` | 用户管理插件:注册/资料/改密(登录与 token 签发已内置网关,Users 模型/种子/校验/签发共享于 `chongming-auth` 包) | +| **auth_oauth** | `src/plugins/auth_oauth/` | 身份源插件示例:GitHub OAuth + 手机验证码登录,基于 `chongming-auth` 契约签发平台 token | | **doubao** | `src/plugins/doubao/` | 豆包视觉理解,支持 AICC 安全通信通道 | | **aibox_sophon_adapter** | `src/plugins/aibox_sophon_adapter/` | AI Box 设备心跳监控、离线检测、告警上报与查询、视频上传 | | **example** | `src/plugins/example/` | 四则运算 + 心跳演示(含 MQTT 端点示例) | @@ -741,6 +742,7 @@ uv pip install -e utils/ # 安装/更新工具包 | 包名 | 路径 | 语言 | 说明 | | ---------------------------- | ----------------------- | ----------- | ---------------------------------------------- | +| **chongming-auth** | `utils/auth/` | Python | auth 域共享契约:Users 模型、建库/种子、登录校验与 JWT 签发(网关内置登录与 auth 插件共用) | | **chongming-config** | `utils/config/` | Python | TOML 配置加载,TypedDict 类型安全 | | **chongming-database** | `utils/database/` | Python | aiosqlite + SQLModel 异步数据库封装 | | **chongming-jwt** | `utils/jwt/` | Rust + PyO3 | JWT 编解码,支持 HS256/384/512 + RS256/384/512 | @@ -837,7 +839,7 @@ uv pip install -e utils/ # 安装/更新工具包子包 | 插件 | HTTP 前缀 | MQTT 主题前缀 | 说明 | | ------------------------ | --------- | ------------------------ | ------------------------------------ | -| **auth** | `/auth` | `auth.*` | 注册、登录、JWT 鉴权、个人信息、改密 | +| **auth** | `/auth` | `auth.*` | 注册、个人信息、改密(登录 `POST /auth/login` 内置网关) | | **doubao** | `/doubao` | `doubao.*` | 火山引擎豆包视觉理解模型 | | **example** | `/calc` | `example.*` | 四则运算 + 访问计数 + MQTT 心跳示例 | | **aibox_sophon_adapter** | `/aibox` | `aibox_sophon_adapter.*` | AI Box 设备心跳适配 + 视频上传 | diff --git a/pyproject.toml b/pyproject.toml index fee9b30..530d0b2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -27,6 +27,7 @@ dependencies = [ "httptools", "bytedance", "chongming-cache", + "chongming-auth", ] description = "基于 modules-bank 的 fastapi 打包脚手架" name = "chongming-zenoh" diff --git a/src/chongming/app/__init__.py b/src/chongming/app/__init__.py index b8a04f7..8e6e4b1 100644 --- a/src/chongming/app/__init__.py +++ b/src/chongming/app/__init__.py @@ -22,7 +22,7 @@ from chongming_config import get_config from .core.logger import get_logger from .core.static_files import SVFSStaticFiles from .core import lifespan_setup, call_stats -from .routes import storage_router, stats_router, worker_admin_router +from .routes import storage_router, stats_router, worker_admin_router, auth_router config = get_config() @@ -39,6 +39,11 @@ async def lifespan(app: FastAPI): # 1. 本地存储(替代 Nanio S3,省 ~172 MB) lifespan_setup.setup_storage(app) + # 1.2 内置 auth 库(users 表 + admin 种子)—— auth 域的基础设施部分: + # 登录/签发内置在网关(管理平面底线),不依赖 auth 插件是否加载; + # 与 auth 插件(用户管理)共用同一张 users 表 + await lifespan_setup.setup_auth_store(app) + # 2. Zenoh session = lifespan_setup.setup_zenoh(app) @@ -123,6 +128,11 @@ async def lifespan(app: FastAPI): # 2. 清理 inline workers(cleanup_workers 内部按 load_mode 过滤) await lifespan_setup.cleanup_workers(app) + # 2.2 关闭内置 auth 库 + auth_db = getattr(app.state, "auth_db", None) + if auth_db is not None: + await auth_db.dispose() + # 2.1 释放 inline worker 所有权分布式锁(仅持锁进程,避免误删其他进程获得的新锁) if getattr(app.state, "inline_owner_locked", False): await manager.release_inline_owner() @@ -303,3 +313,4 @@ if vfs_db_path := config[env].get("file_system", {}).get("path", None): app.include_router(storage_router) app.include_router(stats_router) app.include_router(worker_admin_router) +app.include_router(auth_router) diff --git a/src/chongming/app/core/lifespan_setup.py b/src/chongming/app/core/lifespan_setup.py index 483e9cc..c7435e5 100644 --- a/src/chongming/app/core/lifespan_setup.py +++ b/src/chongming/app/core/lifespan_setup.py @@ -244,6 +244,33 @@ def setup_storage(app: FastAPI): app.state.upload_root = upload_root logger.info(f"本地存储已就绪: {upload_root}") + +async def setup_auth_store(app: FastAPI) -> None: + """初始化内置 auth 库(users 表 + admin 种子)—— auth 域的基础设施部分。 + + 拆分自 plugins.auth:登录/签发内置在网关(管理平面底线),auth 插件 + 缩容到 0 或全部副本故障时登录与 admin API 仍可用。库位置与 auth 插件 + 读同一份配置(config/workers/auth/config.toml 的 [workers.config]: + data_dir / database),指向同一张 users 表;插件配置缺失(如彻底移除 + auth 插件)时回退默认 ./data/auth.db。 + """ + from chongming_auth import open_database, seed_admin + + workers_conf = {} + auth_conf_path = "config/workers/auth/config.toml" + if os.path.isfile(auth_conf_path): + try: + with open(auth_conf_path, "rb") as f: + workers_conf = tomllib.load(f).get("workers", {}).get("config", {}) + except Exception as e: # noqa: BLE001 - 配置损坏不阻断启动,走默认路径 + logger.warning(f"无法读取 auth 插件配置 {auth_conf_path}: {e}(使用默认 data_dir)") + + db = await open_database(workers_conf) + created = await seed_admin(db) + app.state.auth_db = db + logger.info(f"内置 auth 库已就绪 (admin 种子: {'新建' if created else '已存在'})") + + def setup_zenoh(app: FastAPI) -> zenoh.Session: """建立 Zenoh 会话并存入 app.state。""" zenoh_config = config.get("zenoh", {}) @@ -724,7 +751,7 @@ async def setup_mqtt(app: FastAPI, worker_manager: WorkerManager) -> MQTTBridge # 排他锁:基于共享缓存(redis / 共享卷 diskcache),保证全局只有一个 Bridge 消费。 # 相比原文件锁,可跨多台主机收敛——多实例共享同一个外部 Broker 时, # 避免一条 MQTT 请求被 N 台网关上各自消费、各回一份响应。 - bridge_lock = DistributedLock(worker_manager.cache, "mqtt_bridge") + bridge_lock = DistributedLock(worker_manager.cache, "mqtt_bridge") # pyright: ignore[reportArgumentType] if not bridge_lock.acquire(): logger.info( "MQTT Bridge 已由其他网关实例持有(分布式锁),本网关跳过," diff --git a/src/chongming/app/routes/__init__.py b/src/chongming/app/routes/__init__.py index d9a7391..8599271 100644 --- a/src/chongming/app/routes/__init__.py +++ b/src/chongming/app/routes/__init__.py @@ -1,5 +1,6 @@ from .storage import router as storage_router from .stats import router as stats_router from .worker_admin import router as worker_admin_router +from .auth import router as auth_router -__all__ = ["storage_router", "stats_router", "worker_admin_router"] \ No newline at end of file +__all__ = ["storage_router", "stats_router", "worker_admin_router", "auth_router"] \ No newline at end of file diff --git a/src/chongming/app/routes/auth.py b/src/chongming/app/routes/auth.py new file mode 100644 index 0000000..f6681cb --- /dev/null +++ b/src/chongming/app/routes/auth.py @@ -0,0 +1,53 @@ +"""内置登录端点 —— auth 域的基础设施部分(管理平面可用性底线)。 + +拆分自 plugins.auth:登录 / token 签发不再依赖 auth 插件是否加载, +auth 插件缩容到 0 或全部副本故障时,登录与 admin API 仍可用; +用户管理功能(注册 / 资料 / 改密)仍由 auth 插件提供。 + +共享契约(Users 模型 / claims / 失败文案)来自 chongming_auth 包, +路径与响应 shape 与拆分前完全一致(前端零改动)。 +""" +import time + +from chongming_auth import ( + DEFAULT_TOKEN_EXPIRES_SECONDS, + LoginPostRequest, + LoginPostResponse, + issue_token, + touch_last_login, + verify_login, +) +from chongming_config import get_config +from fastapi import APIRouter, Request + +config = get_config() + +router = APIRouter(prefix=config["default"]["prefix"], tags=["auth"]) + + +@router.post( + "/auth/login", + response_model=LoginPostResponse, + summary="登录", + description="根据用户名和密码进行登录,登录成功返回token,获取请求需带式token进行请求", +) +async def login(body: LoginPostRequest, request: Request) -> LoginPostResponse: + db = request.app.state.auth_db + user, message = await verify_login(db, body.username, body.password) + if user is None: + return LoginPostResponse( + result="fail", token="", message=message, timestamp=time.time() + ) + + await touch_last_login(db, user) + + security = get_config()["security"] + token = issue_token( + user, + security["secret_key"], + security["algorithm"], + expires_in_seconds=DEFAULT_TOKEN_EXPIRES_SECONDS, + ) + return LoginPostResponse( + result="success", token=token, message="登录成功", timestamp=time.time() + ) diff --git a/test/test_auth_builtin.py b/test/test_auth_builtin.py new file mode 100644 index 0000000..be33819 --- /dev/null +++ b/test/test_auth_builtin.py @@ -0,0 +1,113 @@ +""" +内置登录端到端测试(auth 域拆分验证) + +验证拆分目标:POST /api/v1/auth/login 内置在网关,不依赖 auth 插件—— +本测试只挂载内置 auth 路由(不加载任何插件),登录/签发/响应 shape 全部可用。 + +同时校验与拆分前 plugins.auth.login 的兼容性: + - 路径 /api/v1/auth/login、响应字段 {result, token, message, timestamp} + - 失败文案逐字一致(用户名错误 / 密码错误) + - token 可被 chongming_jwt 解密回读,claims 含 is_superuser + (网关 admin API 判超管的契约) +""" +import asyncio +import time +from pathlib import Path + +import orjson +import pytest +from chongming_auth import open_database, seed_admin +from chongming_config import get_config +from chongming_jwt import JWT +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from chongming.app.routes.auth import router as auth_router + +_SECURITY = get_config()["security"] +SECRET, ALGORITHM = _SECURITY["secret_key"], _SECURITY["algorithm"] +PREFIX = get_config()["default"]["prefix"] + + +@pytest.fixture() +def client(tmp_path: Path): + """最小 app:仅内置 auth 路由 + 临时 auth 库(模拟 auth 插件完全缺席)。""" + async def _build(): + db = await open_database({"data_dir": str(tmp_path)}) + await seed_admin(db) + return db + + db = asyncio.run(_build()) + + app = FastAPI() + app.include_router(auth_router) + app.state.auth_db = db + with TestClient(app) as c: + yield c + asyncio.run(db.dispose()) + + +def test_login_success_shape_and_claims(client): + resp = client.post( + f"{PREFIX}/auth/login", json={"username": "admin", "password": "admin"} + ) + assert resp.status_code == 200 + data = resp.json() + # 响应 shape 与拆分前 gen_models 产物一致 + assert set(data.keys()) == {"result", "token", "message", "timestamp"} + assert data["result"] == "success" + assert data["message"] == "登录成功" + assert isinstance(data["timestamp"], float) + assert data["timestamp"] == pytest.approx(time.time(), abs=60) + + # token 可解密且 claims 契约完整(is_superuser 是 admin API 的判超管依据) + payload = orjson.loads(JWT(SECRET, ALGORITHM).decode(data["token"])) + assert payload["sub"] + assert payload["username"] == "admin" + assert payload["is_superuser"] is True + + +def test_login_failure_messages_compat(client): + """失败文案与拆分前 plugins.auth.login 逐字一致。""" + for body, expected_msg in [ + ({"username": "nouser", "password": "x"}, "用户名错误"), + ({"username": "admin", "password": "wrong"}, "密码错误"), + ]: + resp = client.post(f"{PREFIX}/auth/login", json=body) + assert resp.status_code == 200 + data = resp.json() + assert data["result"] == "fail" + assert data["token"] == "" + assert data["message"] == expected_msg + assert isinstance(data["timestamp"], float) + + +def test_login_works_without_auth_plugin(client): + """拆分核心目标:无 auth 插件时登录照常(fixture 未加载任何插件)。""" + resp = client.post( + f"{PREFIX}/auth/login", json={"username": "admin", "password": "admin"} + ) + assert resp.status_code == 200 + assert resp.json()["result"] == "success" + + +def test_login_updates_last_login(client, tmp_path): + """登录成功后 last_login_at 落库(顺带修复拆分前未 commit 的旧问题)。""" + client.post(f"{PREFIX}/auth/login", json={"username": "admin", "password": "admin"}) + + row = asyncio.run(_read_admin_row(tmp_path)) + assert row.last_login_at is not None + + +async def _read_admin_row(data_dir: Path): + from chongming_auth import Users + from sqlalchemy import select + + db = await open_database({"data_dir": str(data_dir)}) + try: + async with db.get_session() as sess: + return ( + await sess.execute(select(Users).filter_by(username="admin")) + ).scalar_one() + finally: + await db.dispose() diff --git a/test/test_auth_oauth.py b/test/test_auth_oauth.py new file mode 100644 index 0000000..70a1397 --- /dev/null +++ b/test/test_auth_oauth.py @@ -0,0 +1,215 @@ +""" +auth_oauth 身份源插件测试(基于 chongming-auth 共享契约的第三方登录示例) + +覆盖: + 1. OTP 手机验证码登录全流程(演示模式 dev_code): + 发码 → 错码拒绝 → 正确码登录签发平台 token → 同号复登复用同一账号(绑定生效) + 2. GitHub OAuth 授权码流程(HTTP 调用打桩): + authorize 生成 URL+state → callback find-or-create 签发 token → state 一次性(重放拒绝) + 3. 绑定表与共享 users 表落库验证(chongming_auth.Users 可查) + 4. 插件导入 + 三方契约校验 0 错误 +""" +import asyncio +import tempfile +from pathlib import Path + +import orjson +import pytest +from chongming_auth import Users, open_database +from chongming_cache import Cache +from chongming_config import get_config +from chongming_jwt import JWT +from chongming_worker.contracts import validate_worker_contracts + +# 与被测插件同源:issue_token 从 get_config()["security"] 读密钥,测试解码必须一致 +_SECURITY = get_config()["security"] +SECRET, ALGORITHM = _SECURITY["secret_key"], _SECURITY["algorithm"] + + +@pytest.fixture() +def env(tmp_path: Path): + """初始化插件运行环境:共享 auth 库 + 临时 sqlite 缓存 + global_var 注入。 + + 注意:插件模块 @rpc_method 注册在进程内只执行一次,fixture 不清空注册表 + (clear_registry 会导致后续测试 import no-op、注册表为空)。 + """ + import plugins.auth_oauth # noqa: F401 触发 @rpc_method 注册(幂等) + import plugins.auth_oauth.global_var as gv + + async def _build(): + db = await open_database({"data_dir": str(tmp_path)}) + cache = Cache(backend="sqlite", path=str(tmp_path / "cache.sqlite")) + return db, cache + + db, cache = asyncio.run(_build()) + gv.set_global_var("db", db) + gv.set_global_var("cache", cache) + gv.set_global_var("plugin_config", { + "return_otp_in_response": True, + "github": { + "client_id": "test-client-id", + "client_secret": "test-client-secret", + "redirect_uri": "http://localhost:5173/auth/oauth/github/callback", + "scope": "read:user user:email", + }, + }) + + yield gv + + async def _teardown(): + await db.dispose() + cache.close() + + asyncio.run(_teardown()) + + +def _decode(token: str) -> dict: + return orjson.loads(JWT(SECRET, ALGORITHM).decode(token)) + + +# ---------- 手机验证码登录 ---------- + +def test_otp_full_flow(env): + from plugins.auth_oauth.router.phone import ( + PhoneLoginPostRequest, + PhoneSendCodePostRequest, + phone_login, + phone_send_code, + ) + + phone = "13800138000" + + # 1. 发码(演示模式返回 dev_code) + sent = asyncio.run(phone_send_code(PhoneSendCodePostRequest(phone=phone))) + assert sent.result == "success" and sent.dev_code + + # 2. 错码拒绝 + wrong = asyncio.run(phone_login( + PhoneLoginPostRequest(phone=phone, code="000000" if sent.dev_code != "000000" else "111111") + )) + assert wrong.result == "fail" and wrong.token == "" + + # 3. 正确码登录 → 平台 token + ok = asyncio.run(phone_login( + PhoneLoginPostRequest(phone=phone, code=sent.dev_code) + )) + assert ok.result == "success" and ok.token + claims = _decode(ok.token) + assert claims["username"] == phone + assert claims["is_superuser"] is False + + # 4. 验证码一次性 + replay = asyncio.run(phone_login( + PhoneLoginPostRequest(phone=phone, code=sent.dev_code) + )) + assert replay.result == "fail" + + # 5. 复登(再次发码登录)→ 复用同一账号(绑定生效,不重复建号) + sent2 = asyncio.run(phone_send_code(PhoneSendCodePostRequest(phone=phone))) + ok2 = asyncio.run(phone_login( + PhoneLoginPostRequest(phone=phone, code=sent2.dev_code) + )) + assert _decode(ok2.token)["sub"] == claims["sub"] + + +def test_otp_invalid_phone(env): + from plugins.auth_oauth.router.phone import ( + PhoneSendCodePostRequest, phone_send_code, + ) + + resp = asyncio.run(phone_send_code(PhoneSendCodePostRequest(phone="abc"))) + assert resp.result == "fail" and "手机号" in resp.message + + +# ---------- GitHub OAuth ---------- + +def test_github_authorize_and_callback(env, monkeypatch): + from plugins.auth_oauth.router.github import ( + GithubCallbackPostRequest, github_authorize, github_callback, + ) + from plugins.auth_oauth.providers import github as github_provider + + # authorize:URL 含 client_id 与 state,state 已入缓存 + authz = asyncio.run(github_authorize()) + assert authz.result == "success" + assert "client_id=test-client-id" in authz.authorize_url + assert f"state={authz.state}" in authz.authorize_url + assert env.cache.get(f"auth_oauth:state:{authz.state}") + + # 打桩:授权码换取与用户拉取(免外网) + fake_profile = { + "uid": "97594220", "username": "chongming-demo", + "email": "demo@example.com", "full_name": "Chongming Demo", + "avatar_url": "https://avatars.githubusercontent.com/u/97594220", + } + exchanged = {} + + async def fake_exchange(client_id, client_secret, code, redirect_uri): + exchanged["code"] = code + return "fake-access-token" + + async def fake_fetch(access_token): + assert access_token == "fake-access-token" + return dict(fake_profile) + + monkeypatch.setattr(github_provider, "exchange_code", fake_exchange) + monkeypatch.setattr(github_provider, "fetch_user", fake_fetch) + + resp = asyncio.run(github_callback( + GithubCallbackPostRequest(code="gh-code", state=authz.state) + )) + assert resp.result == "success" and resp.token + claims = _decode(resp.token) + assert claims["username"] == "chongming-demo" + assert claims["email"] == "demo@example.com" + assert exchanged["code"] == "gh-code" + + # state 一次性:重放拒绝 + replay = asyncio.run(github_callback( + GithubCallbackPostRequest(code="gh-code", state=authz.state) + )) + assert replay.result == "fail" and "state" in replay.message + + # 落库验证:users 表有新账号 + 绑定指向同一账号;再次登录(新 state)复用 + async def _read_users(): + db = env.db + from sqlalchemy import select + + async with db.get_session() as sess: + return (await sess.execute(select(Users))).scalars().all() + + users = asyncio.run(_read_users()) + assert len(users) == 1 + assert users[0].email == "demo@example.com" + + async def _second_login(): + from plugins.auth_oauth.router.github import github_authorize as _a + + a = await _a() + return await github_callback( + GithubCallbackPostRequest(code="gh-code-2", state=a.state) + ) + + second = asyncio.run(_second_login()) + assert _decode(second.token)["sub"] == claims["sub"] + + +def test_github_callback_bad_state(env): + from plugins.auth_oauth.router.github import ( + GithubCallbackPostRequest, github_callback, + ) + + resp = asyncio.run(github_callback( + GithubCallbackPostRequest(code="x", state="not-exist") + )) + assert resp.result == "fail" and "state" in resp.message + + +# ---------- 契约校验 ---------- + +def test_contract_validation(env): + import plugins.auth_oauth # noqa: F401 确保注册 + + issues = validate_worker_contracts("auth_oauth") + errors = [i for i in issues if i.get("level") == "error"] + assert errors == [], f"契约校验存在错误: {errors}" diff --git a/test/test_auth_race.py b/test/test_auth_race.py index e0df485..413e2b5 100644 --- a/test/test_auth_race.py +++ b/test/test_auth_race.py @@ -1,37 +1,25 @@ """ 并发启动竞态测试:模拟 gunicorn 多 worker 同时启动时, -多个进程并发调用 plugins.auth.create_workers 初始化默认管理员。 +多个进程并发执行网关内置的 admin 种子(chongming_auth.seed_admin)。 + +auth 域拆分后,admin 种子从 plugins.auth 的 on_init 迁移到网关 +lifespan(setup_auth_store → chongming_auth.seed_admin),本测试随之改指向。 预期:不抛 IntegrityError,users 表中只有一条 admin 记录。 """ import asyncio -import sys import tempfile -from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent / "src")) - -import plugins.auth as auth_plugin - - -def fake_create_workers(session, config, count=1, worker_manager=None): - """替换真实 worker 启动,避免依赖 zenoh 会话。与原始实现同为同步函数。""" - return [] +from chongming_auth import open_database, seed_admin async def main(): - # 替换 Plugin 基类的 worker 创建实现,避免依赖 zenoh 会话 - auth_plugin.plugin._create_workers_impl = fake_create_workers - with tempfile.TemporaryDirectory() as tmp: - config = {"type": "auth", "data_dir": tmp} + db = await open_database({"data_dir": tmp}) # 模拟 4 个 gunicorn worker 进程同时启动 results = await asyncio.gather( - auth_plugin.create_workers(None, config, 3), - auth_plugin.create_workers(None, config, 3), - auth_plugin.create_workers(None, config, 3), - auth_plugin.create_workers(None, config, 3), + *[seed_admin(db) for _ in range(4)], return_exceptions=True, ) @@ -42,15 +30,11 @@ async def main(): raise RuntimeError("并发启动存在未捕获异常") # 校验 users 表中应只有一条 admin - from chongming_database import Database - from plugins.auth.models.auth import Users + from chongming_auth import Users from sqlalchemy import select - db = Database(Path(tmp) / "auth.db") - await db.initialize() async with db.get_session() as sess: - res = await sess.execute(select(Users)) - rows = res.scalars().all() + rows = (await sess.execute(select(Users))).scalars().all() print("✅ 4 个并发启动全部成功") print(f"✅ users 表记录数: {len(rows)}(应为 1)") @@ -58,6 +42,8 @@ async def main(): print(f" - {u.username} <{u.email}> superuser={u.is_superuser}") assert len(rows) == 1, f"预期 1 条 admin,实际 {len(rows)} 条" + assert sum(1 for r in results if r is True) == 1, "应恰好一个并发方完成新建" + await db.dispose() if __name__ == "__main__": diff --git a/uv.lock b/uv.lock index b4ea614..5888bb1 100644 --- a/uv.lock +++ b/uv.lock @@ -429,22 +429,39 @@ wheels = [ { url = "https://mirrors.tuna.tsinghua.edu.cn/pypi/web/packages/cc/61/d01fc49b8dea277640b55a9e15960dbca9fdc8c9fde18e572d39c59f4019/charset_normalizer-3.5.1-py3-none-any.whl", hash = "sha256:6df0ec430f9a831772c23ca5a224cba36517a58a84bb32c32bb59a9fa67c47f6", size = 68658, upload-time = "2026-08-15T08:20:43.306Z" }, ] +[[package]] +name = "chongming-auth" +version = "0.1.0" +source = { registry = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" } +dependencies = [ + { name = "chongming-database" }, + { name = "chongming-jwt" }, + { name = "chongming-password" }, + { name = "orjson" }, + { name = "pydantic" }, + { name = "sqlmodel" }, +] +sdist = { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-auth/0.1.0/chongming_auth-0.1.0.tar.gz", hash = "sha256:745aa621e9ad4ed3ca583c2d310362869dce63e9731f5581d9b777b55203487d" } +wheels = [ + { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-auth/0.1.0/chongming_auth-0.1.0-py3-none-any.whl", hash = "sha256:a89948aa9a0229a79e0f1e2285f1ecf60cefc0c029a7fbdabf0b244a9b902d9a" }, +] + [[package]] name = "chongming-cache" -version = "0.2.1" +version = "0.2.2" source = { registry = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" } dependencies = [ { name = "orjson" }, { name = "redis" }, ] -sdist = { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cache/0.2.1/chongming_cache-0.2.1.tar.gz", hash = "sha256:4945a21b6a53c38a263955fddae85ca8036e3ba62e69b117fcab5fd7cb38cd22" } +sdist = { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cache/0.2.2/chongming_cache-0.2.2.tar.gz", hash = "sha256:1c224329747d99171956ae8e4606a08847acfcbfc6664662bca2513f0c0ca1d1" } wheels = [ - { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cache/0.2.1/chongming_cache-0.2.1-py3-none-any.whl", hash = "sha256:1ca873e4c069ff24b728bb1b90342d66cb6fa6dd994851a1d0cddf2604f0693d" }, + { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cache/0.2.2/chongming_cache-0.2.2-py3-none-any.whl", hash = "sha256:902877168da16e34204982643f44d1313bad5a43bca65d348522523d307f0c16" }, ] [[package]] name = "chongming-cli" -version = "0.3.0" +version = "0.3.1" source = { registry = "http://124.71.68.6:3000/api/packages/chongming/pypi/simple/" } dependencies = [ { name = "chongming-config" }, @@ -452,9 +469,9 @@ dependencies = [ { name = "sqlite-vfs" }, { name = "tomli" }, ] -sdist = { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cli/0.3.0/chongming_cli-0.3.0.tar.gz", hash = "sha256:bdd61eb28909587c2b0c8f1cb675606512a242533eeab30b111678868a36c7a0" } +sdist = { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cli/0.3.1/chongming_cli-0.3.1.tar.gz", hash = "sha256:b5925b9b22ffe374720a48d2614a3a06ace020cba53a20e2b24d53c0e1d2305e" } wheels = [ - { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cli/0.3.0/chongming_cli-0.3.0-py3-none-any.whl", hash = "sha256:b8422c1e648828be157002ba816b9b41c496eb680b3b9b1f00129c92e96fb7b5" }, + { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-cli/0.3.1/chongming_cli-0.3.1-py3-none-any.whl", hash = "sha256:189c9e43d0c88817844f689e32686c2f4b66f4e25e89ab23c1adb135d7a3f4e2" }, ] [[package]] @@ -491,6 +508,7 @@ source = { registry = "http://124.71.68.6:3000/api/packages/chongming/pypi/simpl wheels = [ { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-jwt/0.1.0/chongming_jwt-0.1.0-cp312-cp312-linux_aarch64.whl", hash = "sha256:0512956acaa7a17aaf9708da8d7b3a7dc366ef0226f03550d7b1b06940ba733b" }, { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-jwt/0.1.0/chongming_jwt-0.1.0-cp312-cp312-linux_x86_64.whl", hash = "sha256:527676e710d038f270093d0964c92fc5e976f1f5db23e846e3819b1c8a566721" }, + { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-jwt/0.1.0/chongming_jwt-0.1.0-cp312-cp312-manylinux_2_31_aarch64.whl", hash = "sha256:ec47b7698e928141c9828200bed4377754c296e688f29d694f9b1cc3d5f00bf0" }, { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-jwt/0.1.0/chongming_jwt-0.1.0-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:d2329de197af17141048da32b52dcda87ff578353b4fae78c3bbd0043244cdfc" }, { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-jwt/0.1.0/chongming_jwt-0.1.0-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:d63cc24550b7084e55d4dd0cb9f99cbd0a9d77da39ad0685698fd484e4376e72" }, ] @@ -514,6 +532,7 @@ source = { registry = "http://124.71.68.6:3000/api/packages/chongming/pypi/simpl wheels = [ { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-password/0.1.0/chongming_password-0.1.0-cp312-cp312-linux_aarch64.whl", hash = "sha256:752651cce38a999466e9b342f18f0ceb78b17907646440d2b1c6306ebc9c7648" }, { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-password/0.1.0/chongming_password-0.1.0-cp312-cp312-linux_x86_64.whl", hash = "sha256:97414ddb764a8efa0183eb6a4519530a8522729ff633a06285a88b7b22fa77b4" }, + { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-password/0.1.0/chongming_password-0.1.0-cp312-cp312-manylinux_2_31_aarch64.whl", hash = "sha256:22109f4d1d1263bf840554601da9eb2206fb56ba7deae6828f4ebf710edb8dd1" }, { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-password/0.1.0/chongming_password-0.1.0-cp312-cp312-manylinux_2_34_aarch64.whl", hash = "sha256:65fbe21f0d9cd152e0142b129e23b1697234f5eeba6a9d0ded1a14949849cca3" }, { url = "http://124.71.68.6:3000/api/packages/chongming/pypi/files/chongming-password/0.1.0/chongming_password-0.1.0-cp312-cp312-manylinux_2_34_x86_64.whl", hash = "sha256:9620458d04e1cc9c93842f7b960b2b7af8b9bc5f88643e785d12686301071b3c" }, ] @@ -565,6 +584,7 @@ dependencies = [ { name = "aiofiles" }, { name = "amqtt" }, { name = "bytedance" }, + { name = "chongming-auth" }, { name = "chongming-cache" }, { name = "chongming-jwt" }, { name = "chongming-mqtt" }, @@ -601,6 +621,7 @@ requires-dist = [ { name = "aiofiles" }, { name = "amqtt" }, { name = "bytedance" }, + { name = "chongming-auth" }, { name = "chongming-cache" }, { name = "chongming-jwt" }, { name = "chongming-mqtt" },