From 635025d2702c79f4ebe5bd0f522b0cacde95fb82 Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 2 Jul 2026 10:02:24 +0800 Subject: [PATCH 01/19] =?UTF-8?q?feat(db):=20=E6=96=B0=E5=A2=9E=20users=20?= =?UTF-8?q?=E8=A1=A8=E8=BF=81=E7=A7=BB=20=E2=80=94=E2=80=94=20=E5=A4=9A?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E7=99=BB=E5=BD=95=E8=B4=A6=E5=8F=B7=E8=90=BD?= =?UTF-8?q?=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 0024_users.py:subject(=JWT sub,PK) / email / argon2 password_hash / roles。supersede 0001 里 users 交给 Next.js 管的旧决定,身份改为后端自管;email/username 大小写不敏感唯一。 Co-Authored-By: Claude Opus 4.8 (1M context) --- infra/migrations/versions/0024_users.py | 57 +++++++++++++++++++++++++ 1 file changed, 57 insertions(+) create mode 100644 infra/migrations/versions/0024_users.py diff --git a/infra/migrations/versions/0024_users.py b/infra/migrations/versions/0024_users.py new file mode 100644 index 00000000..d5d28320 --- /dev/null +++ b/infra/migrations/versions/0024_users.py @@ -0,0 +1,57 @@ +"""users —— 多用户登录的账号表(DB 落库,替代单用户 dev token) + +Revision ID: 0024 +Revises: 0023 +Create Date: 2026-07-01 + +背景:线上 dashboard 此前是单用户 dev 模式(固定 sub=console:dev)。本迁移落 +``users`` 表存账号 + argon2 密码哈希,登录端点在 paper 服务(``POST /auth/login``)校验。 + +**supersede** 0001_initial_schema 里"users / sessions 交给 Next.js better-auth 管,本 +migration 不碰"的旧决定——身份改为后端自管。 + +字段: + +- ``subject``:JWT ``sub`` 字面量,也是 paper ``account_id_from_sub`` 的派生源(**主键**)。 + 作者种 ``console:dev`` 以继承现有模拟盘数据;新用户各自派生独立 subject。 +- ``email`` / ``username``:登录标识(email 必填唯一;username 可空唯一,留作备用登录名)。 +- ``password_hash``:argon2 编码串(含算法 / 盐 / 参数,自描述)。 +- ``roles``:预留权限位,v1 不做门,默认空。 +""" +from __future__ import annotations + +from alembic import op + +revision: str = "0024" +down_revision: str | None = "0023" +branch_labels: str | tuple[str, ...] | None = None +depends_on: str | tuple[str, ...] | None = None + + +def upgrade() -> None: + op.execute( + """ + CREATE TABLE IF NOT EXISTS users ( + subject TEXT PRIMARY KEY, + email TEXT NOT NULL, + username TEXT, + password_hash TEXT NOT NULL, + roles TEXT[] NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() + ) + """ + ) + # email / username 大小写不敏感唯一(登录不该因大小写重复开号)。 + op.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_key " + "ON users (lower(email))" + ) + op.execute( + "CREATE UNIQUE INDEX IF NOT EXISTS users_username_lower_key " + "ON users (lower(username)) WHERE username IS NOT NULL" + ) + + +def downgrade() -> None: + op.execute("DROP TABLE IF EXISTS users") From a643600f2539ccac37784c40bd656e8dc50fcd9b Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 2 Jul 2026 10:02:24 +0800 Subject: [PATCH 02/19] =?UTF-8?q?feat(paper):=20/auth/login=20=E7=AB=AF?= =?UTF-8?q?=E7=82=B9=20+=20argon2=20=E5=AF=86=E7=A0=81=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?=20+=20create=5Fuser=20=E8=84=9A=E6=9C=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api/auth.py:POST /auth/login(无鉴权)查 users 表 argon2 verify,校验放 anyio 线程池不阻塞 live runner,抗时序枚举,失败统一 401。scripts/create_user.py:argon2 upsert 建/改密 CLI(无注册 UI)。加 argon2-cffi 依赖;test_auth_login.py 覆盖对/错/未知/大小写。 Co-Authored-By: Claude Opus 4.8 (1M context) --- services/paper/pyproject.toml | 2 + services/paper/scripts/create_user.py | 111 +++++++++++++++++++ services/paper/src/inalpha_paper/api/auth.py | 80 +++++++++++++ services/paper/src/inalpha_paper/main.py | 2 + services/paper/tests/test_auth_login.py | 87 +++++++++++++++ services/paper/uv.lock | 111 +++++++++++++++++++ 6 files changed, 393 insertions(+) create mode 100644 services/paper/scripts/create_user.py create mode 100644 services/paper/src/inalpha_paper/api/auth.py create mode 100644 services/paper/tests/test_auth_login.py diff --git a/services/paper/pyproject.toml b/services/paper/pyproject.toml index 1c9fa94c..7ee8738a 100644 --- a/services/paper/pyproject.toml +++ b/services/paper/pyproject.toml @@ -15,6 +15,8 @@ dependencies = [ # (infra/docker-compose.prod.yml)。此前 alembic 只是 optuna 的传递依赖——上游 # 换后端就会静默消失,生产迁移会无报警地跳过。 "alembic>=1.13", + # 多用户登录:/auth/login 校验 users.password_hash。argon2 无 72 字节截断坑。 + "argon2-cffi>=23", ] [dependency-groups] diff --git a/services/paper/scripts/create_user.py b/services/paper/scripts/create_user.py new file mode 100644 index 00000000..dba80c11 --- /dev/null +++ b/services/paper/scripts/create_user.py @@ -0,0 +1,111 @@ +"""创建 / 改密一个登录用户。 + +多用户登录暂不做注册 UI —— 初始用户和改密都走本 CLI。密码用 argon2 哈希后落 +``users`` 表,``ON CONFLICT (subject) DO UPDATE`` 幂等(重跑 = 改密 / 改邮箱)。 + +用法:: + + # 作者:沿用 console:dev subject → 继承现有模拟盘 / 会话历史 + uv --project services/paper run python services/paper/scripts/create_user.py \ + --email me@example.com --password 's3cr3t' --subject console:dev + + # 新用户:不给 --subject 则自动生成 user: → 独立空账户 + uv --project services/paper run python services/paper/scripts/create_user.py \ + --email bob@example.com --password 'hunter2' + + # 容器内(生产 paper 镜像已含 DB 依赖 + DATABASE_URL 环境): + docker compose -f infra/docker-compose.prod.yml run --rm paper \ + uv --project paper run python scripts/create_user.py --email ... --password ... --subject console:dev + +约束: + +- 密码经 argon2 哈希,明文不落库、不打日志(仅 argparse 短暂持有)。 +- ``--subject`` 是 JWT ``sub``,也是 paper ``account_id_from_sub`` 的派生源;改它 = 换账户。 +""" +from __future__ import annotations + +import argparse +import asyncio +import logging +import os +from uuid import uuid4 + +from argon2 import PasswordHasher +from inalpha_shared.db import close_pool, get_conn, init_pool + +logger = logging.getLogger(__name__) + +_hasher = PasswordHasher() + + +async def _upsert_user( + *, + subject: str, + email: str, + password: str, + username: str | None, + roles: list[str], +) -> None: + password_hash = _hasher.hash(password) + async with get_conn() as conn: + async with conn.cursor() as cur: + await cur.execute( + """ + INSERT INTO users (subject, email, username, password_hash, roles) + VALUES (%s, %s, %s, %s, %s) + ON CONFLICT (subject) DO UPDATE SET + email = EXCLUDED.email, + username = EXCLUDED.username, + password_hash = EXCLUDED.password_hash, + roles = EXCLUDED.roles, + updated_at = now() + """, + (subject, email, username, password_hash, roles), + ) + await conn.commit() + + +async def _amain(args: argparse.Namespace) -> int: + db_url = os.environ.get( + "DATABASE_URL", + "postgresql+psycopg://quant:devpass@localhost:5433/inalpha", + ) + subject = args.subject or f"user:{uuid4()}" + roles = [r.strip() for r in (args.roles or "").split(",") if r.strip()] + + await init_pool(db_url) + try: + await _upsert_user( + subject=subject, + email=args.email, + password=args.password, + username=args.username, + roles=roles, + ) + finally: + await close_pool() + + print(f"✔ 用户已写入:email={args.email!r} subject={subject!r} roles={roles}") + print(" 用该邮箱 + 密码在 dashboard /login 登录即可。") + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description="创建 / 改密一个登录用户(argon2 落库)。") + parser.add_argument("--email", required=True, help="登录邮箱(大小写不敏感唯一)") + parser.add_argument("--password", required=True, help="明文密码(会被 argon2 哈希)") + parser.add_argument( + "--subject", + default=None, + help="JWT sub(= account 派生源)。作者继承现有数据用 'console:dev';省略则生成 user:", + ) + parser.add_argument("--username", default=None, help="可选备用登录名") + parser.add_argument("--roles", default="", help="逗号分隔角色(预留,v1 不做权限门)") + args = parser.parse_args() + + logging.basicConfig(level=logging.INFO) + return asyncio.run(_amain(args)) + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/services/paper/src/inalpha_paper/api/auth.py b/services/paper/src/inalpha_paper/api/auth.py new file mode 100644 index 00000000..4fa2ee9e --- /dev/null +++ b/services/paper/src/inalpha_paper/api/auth.py @@ -0,0 +1,80 @@ +"""登录端点—— 校验 ``users`` 表里的账号密码,无鉴权。 + +链路:dashboard BFF ``POST /api/auth/login`` → (内网) 本端点 → argon2 verify → +返回 ``{subject, email, roles}``。dashboard 据此用 ``JWT_SECRET`` 签 httpOnly session +cookie(见 ``apps/dashboard/src/lib/session.ts``)。本端点**只校验密码,不签发 JWT**。 + +设计要点: + +- **无 ``get_current_user`` 依赖**(登录本身就是拿凭据换身份,仿 ``api/health.py`` 无鉴权范式)。 +- **argon2 verify 放线程池**(``anyio.to_thread.run_sync``):argon2 是 CPU 密集的同步调用, + paper 是单进程且内嵌 live runner 事件循环,直接跑会卡住撮合循环。 +- **抗用户枚举**:用户不存在时也对一个 dummy hash 跑一次 verify,再统一抛 401 + (``UNAUTHORIZED``),不区分"用户不存在 / 密码错",时序不泄露账号是否存在。 +""" +from __future__ import annotations + +from typing import Any, cast + +import anyio +from argon2 import PasswordHasher +from argon2.exceptions import VerifyMismatchError +from fastapi import APIRouter +from inalpha_shared.db import DBConn +from inalpha_shared.errors import UnauthorizedError +from pydantic import BaseModel, Field + +router = APIRouter(tags=["auth"]) + +_hasher = PasswordHasher() + +# 用户不存在时拿来"陪跑"一次 verify 的占位哈希(抗时序型用户枚举)。值本身无意义 +# ——任何真实密码都不会匹配它,只为消耗与真实 verify 相当的 CPU 时间。 +_DUMMY_HASH = _hasher.hash("inalpha-dummy-password-for-timing-safety") + + +class LoginRequest(BaseModel): + """``POST /auth/login`` 请求体。""" + + email: str = Field(description="登录邮箱(大小写不敏感)") + password: str = Field(description="明文密码,仅用于本次校验,不落库不记日志") + + +class LoginResponse(BaseModel): + """登录成功返回的用户身份(不含任何凭据)。""" + + subject: str = Field(description="JWT sub;dashboard 据此签 session、后端据此隔离数据") + email: str + roles: list[str] = Field(default_factory=list) + + +def _verify_password(password_hash: str, password: str) -> bool: + """同步 argon2 verify(在线程池里调)。不匹配返回 False,不抛。""" + try: + return _hasher.verify(password_hash, password) + except VerifyMismatchError: + return False + + +@router.post("/auth/login", response_model=LoginResponse) +async def login(body: LoginRequest, db: DBConn) -> LoginResponse: + """校验邮箱 + 密码,成功返回用户身份;失败统一 401。""" + async with db.cursor() as cur: + await cur.execute( + "SELECT subject, email, password_hash, roles FROM users " + "WHERE lower(email) = lower(%s)", + (body.email,), + ) + # 连接池用 dict_row row_factory,fetchone 返回 dict(psycopg 默认 stub 标 tuple)。 + row = cast("dict[str, Any] | None", await cur.fetchone()) + + password_hash = row["password_hash"] if row else _DUMMY_HASH + ok = await anyio.to_thread.run_sync(_verify_password, password_hash, body.password) + if not row or not ok: + raise UnauthorizedError("邮箱或密码不正确", code="INVALID_CREDENTIALS") + + return LoginResponse( + subject=row["subject"], + email=row["email"], + roles=list(row["roles"] or []), + ) diff --git a/services/paper/src/inalpha_paper/main.py b/services/paper/src/inalpha_paper/main.py index 8b3d879a..027bc942 100644 --- a/services/paper/src/inalpha_paper/main.py +++ b/services/paper/src/inalpha_paper/main.py @@ -22,6 +22,7 @@ from . import __version__ from .api import ( archetypes, + auth, backtest, health, orders, @@ -162,6 +163,7 @@ async def lifespan(app: FastAPI) -> AsyncIterator[None]: install_error_handler(app) app.include_router(health.router) +app.include_router(auth.router) app.include_router(backtest.router) app.include_router(archetypes.router) app.include_router(orders.router) diff --git a/services/paper/tests/test_auth_login.py b/services/paper/tests/test_auth_login.py new file mode 100644 index 00000000..93f26294 --- /dev/null +++ b/services/paper/tests/test_auth_login.py @@ -0,0 +1,87 @@ +"""POST /auth/login 端到端。 + +验证: + +1. 正确邮箱 + 密码 → 200 + {subject, email, roles} +2. 错误密码 → 401 INVALID_CREDENTIALS +3. 不存在的邮箱 → 401 INVALID_CREDENTIALS(与密码错同一 code,不泄露账号是否存在) +4. 邮箱大小写不敏感 +""" +from __future__ import annotations + +from collections.abc import AsyncIterator + +import pytest +import pytest_asyncio +from argon2 import PasswordHasher +from fastapi.testclient import TestClient +from inalpha_shared.db import get_conn + +pytestmark = pytest.mark.integration + +_TEST_EMAIL = "login-test@example.com" +_TEST_PASSWORD = "correct-horse-battery-staple" +_TEST_SUBJECT = "user:login-test-fixture" + + +@pytest_asyncio.fixture +async def seeded_user(app_with_lifespan: object) -> AsyncIterator[None]: + """往 users 表插一个已知 argon2 哈希的测试用户,测试后删。""" + password_hash = PasswordHasher().hash(_TEST_PASSWORD) + async with get_conn() as conn: + async with conn.cursor() as cur: + await cur.execute( + "INSERT INTO users (subject, email, password_hash, roles) " + "VALUES (%s, %s, %s, %s) " + "ON CONFLICT (subject) DO UPDATE SET " + "email = EXCLUDED.email, password_hash = EXCLUDED.password_hash", + (_TEST_SUBJECT, _TEST_EMAIL, password_hash, ["trader"]), + ) + await conn.commit() + try: + yield + finally: + async with get_conn() as conn: + async with conn.cursor() as cur: + await cur.execute("DELETE FROM users WHERE subject = %s", (_TEST_SUBJECT,)) + await conn.commit() + + +def test_login_success(client: TestClient, seeded_user: None) -> None: + resp = client.post( + "/auth/login", + json={"email": _TEST_EMAIL, "password": _TEST_PASSWORD}, + ) + assert resp.status_code == 200, resp.text + body = resp.json() + assert body["subject"] == _TEST_SUBJECT + assert body["email"] == _TEST_EMAIL + assert body["roles"] == ["trader"] + + +def test_login_email_case_insensitive(client: TestClient, seeded_user: None) -> None: + resp = client.post( + "/auth/login", + json={"email": _TEST_EMAIL.upper(), "password": _TEST_PASSWORD}, + ) + assert resp.status_code == 200, resp.text + assert resp.json()["subject"] == _TEST_SUBJECT + + +def test_login_wrong_password(client: TestClient, seeded_user: None) -> None: + resp = client.post( + "/auth/login", + json={"email": _TEST_EMAIL, "password": "wrong-password"}, + ) + assert resp.status_code == 401 + assert resp.json()["code"] == "INVALID_CREDENTIALS" + + +def test_login_unknown_email(client: TestClient) -> None: + resp = client.post( + "/auth/login", + json={"email": "nobody@example.com", "password": "whatever"}, + ) + assert resp.status_code == 401 + # 与密码错同一 code —— 不泄露账号是否存在。 + assert resp.json()["code"] == "INVALID_CREDENTIALS" diff --git a/services/paper/uv.lock b/services/paper/uv.lock index 8dd8c28b..c4013e78 100644 --- a/services/paper/uv.lock +++ b/services/paper/uv.lock @@ -58,6 +58,49 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/da/42/e921fccf5015463e32a3cf6ee7f980a6ed0f395ceeaa45060b61d86486c2/anyio-4.13.0-py3-none-any.whl", hash = "sha256:08b310f9e24a9594186fd75b4f73f4a4152069e3853f1ed8bfbf58369f4ad708", size = 114353 }, ] +[[package]] +name = "argon2-cffi" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "argon2-cffi-bindings" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/0e/89/ce5af8a7d472a67cc819d5d998aa8c82c5d860608c4db9f46f1162d7dab9/argon2_cffi-25.1.0.tar.gz", hash = "sha256:694ae5cc8a42f4c4e2bf2ca0e64e51e23a040c6a517a85074683d3959e1346c1", size = 45706 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/4f/d3/a8b22fa575b297cd6e3e3b0155c7e25db170edf1c74783d6a31a2490b8d9/argon2_cffi-25.1.0-py3-none-any.whl", hash = "sha256:fdc8b074db390fccb6eb4a3604ae7231f219aa669a2652e0f20e16ba513d5741", size = 14657 }, +] + +[[package]] +name = "argon2-cffi-bindings" +version = "25.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5c/2d/db8af0df73c1cf454f71b2bbe5e356b8c1f8041c979f505b3d3186e520a9/argon2_cffi_bindings-25.1.0.tar.gz", hash = "sha256:b957f3e6ea4d55d820e40ff76f450952807013d361a65d7f28acc0acbf29229d", size = 1783441 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/60/97/3c0a35f46e52108d4707c44b95cfe2afcafc50800b5450c197454569b776/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:3d3f05610594151994ca9ccb3c771115bdb4daef161976a266f0dd8aa9996b8f", size = 54393 }, + { url = "https://files.pythonhosted.org/packages/9d/f4/98bbd6ee89febd4f212696f13c03ca302b8552e7dbf9c8efa11ea4a388c3/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8b8efee945193e667a396cbc7b4fb7d357297d6234d30a489905d96caabde56b", size = 29328 }, + { url = "https://files.pythonhosted.org/packages/43/24/90a01c0ef12ac91a6be05969f29944643bc1e5e461155ae6559befa8f00b/argon2_cffi_bindings-25.1.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3c6702abc36bf3ccba3f802b799505def420a1b7039862014a65db3205967f5a", size = 31269 }, + { url = "https://files.pythonhosted.org/packages/d4/d3/942aa10782b2697eee7af5e12eeff5ebb325ccfb86dd8abda54174e377e4/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a1c70058c6ab1e352304ac7e3b52554daadacd8d453c1752e547c76e9c99ac44", size = 86558 }, + { url = "https://files.pythonhosted.org/packages/0d/82/b484f702fec5536e71836fc2dbc8c5267b3f6e78d2d539b4eaa6f0db8bf8/argon2_cffi_bindings-25.1.0-cp314-cp314t-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e2fd3bfbff3c5d74fef31a722f729bf93500910db650c925c2d6ef879a7e51cb", size = 92364 }, + { url = "https://files.pythonhosted.org/packages/c9/c1/a606ff83b3f1735f3759ad0f2cd9e038a0ad11a3de3b6c673aa41c24bb7b/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c4f9665de60b1b0e99bcd6be4f17d90339698ce954cfd8d9cf4f91c995165a92", size = 85637 }, + { url = "https://files.pythonhosted.org/packages/44/b4/678503f12aceb0262f84fa201f6027ed77d71c5019ae03b399b97caa2f19/argon2_cffi_bindings-25.1.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ba92837e4a9aa6a508c8d2d7883ed5a8f6c308c89a4790e1e447a220deb79a85", size = 91934 }, + { url = "https://files.pythonhosted.org/packages/f0/c7/f36bd08ef9bd9f0a9cff9428406651f5937ce27b6c5b07b92d41f91ae541/argon2_cffi_bindings-25.1.0-cp314-cp314t-win32.whl", hash = "sha256:84a461d4d84ae1295871329b346a97f68eade8c53b6ed9a7ca2d7467f3c8ff6f", size = 28158 }, + { url = "https://files.pythonhosted.org/packages/b3/80/0106a7448abb24a2c467bf7d527fe5413b7fdfa4ad6d6a96a43a62ef3988/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b55aec3565b65f56455eebc9b9f34130440404f27fe21c3b375bf1ea4d8fbae6", size = 32597 }, + { url = "https://files.pythonhosted.org/packages/05/b8/d663c9caea07e9180b2cb662772865230715cbd573ba3b5e81793d580316/argon2_cffi_bindings-25.1.0-cp314-cp314t-win_arm64.whl", hash = "sha256:87c33a52407e4c41f3b70a9c2d3f6056d88b10dad7695be708c5021673f55623", size = 28231 }, + { url = "https://files.pythonhosted.org/packages/1d/57/96b8b9f93166147826da5f90376e784a10582dd39a393c99bb62cfcf52f0/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_universal2.whl", hash = "sha256:aecba1723ae35330a008418a91ea6cfcedf6d31e5fbaa056a166462ff066d500", size = 54121 }, + { url = "https://files.pythonhosted.org/packages/0a/08/a9bebdb2e0e602dde230bdde8021b29f71f7841bd54801bcfd514acb5dcf/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_10_9_x86_64.whl", hash = "sha256:2630b6240b495dfab90aebe159ff784d08ea999aa4b0d17efa734055a07d2f44", size = 29177 }, + { url = "https://files.pythonhosted.org/packages/b6/02/d297943bcacf05e4f2a94ab6f462831dc20158614e5d067c35d4e63b9acb/argon2_cffi_bindings-25.1.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:7aef0c91e2c0fbca6fc68e7555aa60ef7008a739cbe045541e438373bc54d2b0", size = 31090 }, + { url = "https://files.pythonhosted.org/packages/c1/93/44365f3d75053e53893ec6d733e4a5e3147502663554b4d864587c7828a7/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1e021e87faa76ae0d413b619fe2b65ab9a037f24c60a1e6cc43457ae20de6dc6", size = 81246 }, + { url = "https://files.pythonhosted.org/packages/09/52/94108adfdd6e2ddf58be64f959a0b9c7d4ef2fa71086c38356d22dc501ea/argon2_cffi_bindings-25.1.0-cp39-abi3-manylinux_2_26_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d3e924cfc503018a714f94a49a149fdc0b644eaead5d1f089330399134fa028a", size = 87126 }, + { url = "https://files.pythonhosted.org/packages/72/70/7a2993a12b0ffa2a9271259b79cc616e2389ed1a4d93842fac5a1f923ffd/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87b72589133f0346a1cb8d5ecca4b933e3c9b64656c9d175270a000e73b288d", size = 80343 }, + { url = "https://files.pythonhosted.org/packages/78/9a/4e5157d893ffc712b74dbd868c7f62365618266982b64accab26bab01edc/argon2_cffi_bindings-25.1.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:1db89609c06afa1a214a69a462ea741cf735b29a57530478c06eb81dd403de99", size = 86777 }, + { url = "https://files.pythonhosted.org/packages/74/cd/15777dfde1c29d96de7f18edf4cc94c385646852e7c7b0320aa91ccca583/argon2_cffi_bindings-25.1.0-cp39-abi3-win32.whl", hash = "sha256:473bcb5f82924b1becbb637b63303ec8d10e84c8d241119419897a26116515d2", size = 27180 }, + { url = "https://files.pythonhosted.org/packages/e2/c6/a759ece8f1829d1f162261226fbfd2c6832b3ff7657384045286d2afa384/argon2_cffi_bindings-25.1.0-cp39-abi3-win_amd64.whl", hash = "sha256:a98cd7d17e9f7ce244c0803cad3c23a7d379c301ba618a5fa76a67d116618b98", size = 31715 }, + { url = "https://files.pythonhosted.org/packages/42/b9/f8d6fa329ab25128b7e98fd83a3cb34d9db5b059a9847eddb840a0af45dd/argon2_cffi_bindings-25.1.0-cp39-abi3-win_arm64.whl", hash = "sha256:b0fdbcf513833809c882823f98dc2f931cf659d9a1429616ac3adebb49f5db94", size = 27149 }, +] + [[package]] name = "ast-serialize" version = "0.5.0" @@ -125,6 +168,63 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134 }, ] +[[package]] +name = "cffi" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271 }, + { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048 }, + { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529 }, + { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097 }, + { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983 }, + { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519 }, + { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572 }, + { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963 }, + { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361 }, + { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932 }, + { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557 }, + { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762 }, + { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230 }, + { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043 }, + { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446 }, + { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101 }, + { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948 }, + { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422 }, + { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499 }, + { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928 }, + { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302 }, + { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909 }, + { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402 }, + { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780 }, + { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320 }, + { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487 }, + { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049 }, + { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793 }, + { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300 }, + { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244 }, + { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828 }, + { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926 }, + { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328 }, + { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650 }, + { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687 }, + { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773 }, + { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013 }, + { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593 }, + { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354 }, + { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480 }, + { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584 }, + { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443 }, + { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437 }, + { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487 }, + { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726 }, + { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195 }, +] + [[package]] name = "click" version = "8.4.0" @@ -452,6 +552,7 @@ version = "0.2.0" source = { editable = "." } dependencies = [ { name = "alembic" }, + { name = "argon2-cffi" }, { name = "backtester-mcp" }, { name = "exchange-calendars" }, { name = "fastapi" }, @@ -473,6 +574,7 @@ dev = [ [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.13" }, + { name = "argon2-cffi", specifier = ">=23" }, { name = "backtester-mcp", specifier = ">=0.1.0" }, { name = "exchange-calendars", specifier = ">=4.5" }, { name = "fastapi", specifier = ">=0.136.0" }, @@ -1058,6 +1160,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/51/be/6f79d55816d5c22557cf27533543d5d70dfe692adfbee4b99f2760674f38/pyarrow-24.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:c91d00057f23b8d353039520dc3a6c09d8608164c692e9f59a175a42b2ae0c19", size = 28131282 }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172 }, +] + [[package]] name = "pydantic" version = "2.13.4" From e953fc747947c3e50ce1bd088c75f7c8be0685af Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 2 Jul 2026 10:02:24 +0800 Subject: [PATCH 03/19] =?UTF-8?q?feat(orchestration):=20tool=20=E6=89=93?= =?UTF-8?q?=E5=90=8E=E7=AB=AF=E6=97=B6=E8=BD=AC=E5=8F=91=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E7=94=A8=E6=88=B7=20sub,agent=20=E5=86=99=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E6=8C=89=E7=94=A8=E6=88=B7=E9=9A=94=E7=A6=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit auth.ts 加 resolveRequestToken:显式 authToken → 中间件注入的已认证 sub(RequestContext[AUTH_SUB_KEY]) → service subject 兜底。9 个 tool 的 getClient 从只读恒空的 ctx.authToken 改走它,ToolRequestContext 加 get 通道。修此前 agent 发起的 start_strategy/execute_plan/下单 恒落 console:dev 的隔离缺陷。 Co-Authored-By: Claude Opus 4.8 (1M context) --- packages/orchestration/src/auth.ts | 29 +++++++++++++++++++ packages/orchestration/src/tools/data.ts | 8 +++-- packages/orchestration/src/tools/factor.ts | 6 ++-- packages/orchestration/src/tools/market.ts | 6 ++-- packages/orchestration/src/tools/paper.ts | 8 ++--- packages/orchestration/src/tools/research.ts | 6 ++-- packages/orchestration/src/tools/risk.ts | 6 ++-- packages/orchestration/src/tools/strategy.ts | 6 ++-- .../orchestration/src/tools/trade-plan.ts | 6 ++-- packages/orchestration/src/tools/web.ts | 6 ++-- 10 files changed, 59 insertions(+), 28 deletions(-) diff --git a/packages/orchestration/src/auth.ts b/packages/orchestration/src/auth.ts index 9218edb1..36bf202c 100644 --- a/packages/orchestration/src/auth.ts +++ b/packages/orchestration/src/auth.ts @@ -11,6 +11,7 @@ import { SignJWT, jwtVerify } from "jose"; import { getSettings } from "./config.js"; +import { AUTH_SUB_KEY } from "./hooks/with-hooks.js"; type Payload = { sub: string; @@ -50,6 +51,34 @@ export function defaultServiceSubject(): string { return getSettings().consoleSubject; } +/** + * 从 tool 的 ``ctx.requestContext`` 解析打给下游 service 的 token(多用户隔离关键)。 + * + * 优先级: + * 1. **显式 ``authToken``**(scheduler tool-mode 塞的 plain object)——直接 forward。 + * 2. **HTTP 中间件注入的已认证 ``sub``**(``RequestContext[AUTH_SUB_KEY]``,由 mastra + * ``identityMiddleware`` 从 Bearer 提取)——按登录用户 ``sub`` 铸 token,使 agent + * 发起的写操作(start_strategy / execute_plan / 下单 …)落到该用户的 ``account_id``。 + * 3. 都没有 → service subject 兜底(后台任务 / dev 未登录)。 + * + * ⚠️ 历史坑:工具过去只读 ``ctx?.authToken``,而 HTTP 路径下 RequestContext 是 Map 实例、 + * sub 存在 ``AUTH_SUB_KEY`` 下(不是 ``.authToken`` 属性),导致恒落兜底 —— 多用户下 agent + * 写操作全落到 ``console:dev``。本函数同时兼容 ``.authToken`` 属性与 ``.get(AUTH_SUB_KEY)``。 + */ +export async function resolveRequestToken(rc?: { + authToken?: string; + get?: (key: string) => unknown; +}): Promise { + if (typeof rc?.authToken === "string" && rc.authToken) { + return rc.authToken; + } + const sub = typeof rc?.get === "function" ? rc.get(AUTH_SUB_KEY) : undefined; + if (typeof sub === "string" && sub) { + return await mintServiceToken({ sub }); + } + return await mintServiceToken({ sub: defaultServiceSubject() }); +} + /** * 验签 + 解码 JWT。失败抛 ``Error``(让 caller 决定怎么响应)。 */ diff --git a/packages/orchestration/src/tools/data.ts b/packages/orchestration/src/tools/data.ts index 2e1d0524..ee9000c0 100644 --- a/packages/orchestration/src/tools/data.ts +++ b/packages/orchestration/src/tools/data.ts @@ -7,7 +7,7 @@ import { createTool } from "@mastra/core/tools"; import { z } from "zod"; -import { defaultServiceSubject, mintServiceToken } from "../auth.js"; +import { resolveRequestToken } from "../auth.js"; import { DataClient } from "../clients/data.js"; import { getSettings } from "../config.js"; @@ -36,18 +36,20 @@ const SymbolSchema = z type ToolRequestContext = { /** forward 用户 JWT 给 data-service;缺省时 fallback 自签 service token(dev 友好)。 */ authToken?: string; + /** HTTP 路径下 RequestContext 是 Map;get(AUTH_SUB_KEY) 拿已认证 sub。 */ + get?: (key: string) => unknown; }; async function getClient(ctx?: ToolRequestContext): Promise { const settings = getSettings(); - const token = ctx?.authToken ?? (await mintServiceToken({ sub: defaultServiceSubject() })); + const token = await resolveRequestToken(ctx); return new DataClient({ baseUrl: settings.dataServiceUrl, token }); } /** backfill 专用长超时 client —— CCXT rate-limited fetch_ohlcv,大跨度可能分钟级 */ async function getBackfillClient(ctx?: ToolRequestContext): Promise { const settings = getSettings(); - const token = ctx?.authToken ?? (await mintServiceToken({ sub: defaultServiceSubject() })); + const token = await resolveRequestToken(ctx); return new DataClient({ baseUrl: settings.dataServiceUrl, token, diff --git a/packages/orchestration/src/tools/factor.ts b/packages/orchestration/src/tools/factor.ts index 74d40252..d48feaa4 100644 --- a/packages/orchestration/src/tools/factor.ts +++ b/packages/orchestration/src/tools/factor.ts @@ -8,7 +8,7 @@ import { createTool } from "@mastra/core/tools"; import { z } from "zod"; -import { defaultServiceSubject, mintServiceToken } from "../auth.js"; +import { resolveRequestToken } from "../auth.js"; import { FactorClient } from "../clients/factor.js"; import { getSettings } from "../config.js"; import { @@ -31,11 +31,11 @@ const SymbolSchema = z "symbol 不能为空 / 含空格;crypto 'BTC/USDT' / 股票 'AAPL' / 指数 '^N225' / akshare 'sh.600519'", ); -type ToolRequestContext = { authToken?: string }; +type ToolRequestContext = { authToken?: string; get?: (key: string) => unknown }; async function getClient(ctx?: ToolRequestContext): Promise { const settings = getSettings(); - const token = ctx?.authToken ?? (await mintServiceToken({ sub: defaultServiceSubject() })); + const token = await resolveRequestToken(ctx); return new FactorClient({ baseUrl: settings.factorServiceUrl, token }); } diff --git a/packages/orchestration/src/tools/market.ts b/packages/orchestration/src/tools/market.ts index a3ebfb25..e5fa9f3c 100644 --- a/packages/orchestration/src/tools/market.ts +++ b/packages/orchestration/src/tools/market.ts @@ -10,15 +10,15 @@ import { createTool } from "@mastra/core/tools"; import { z } from "zod"; -import { defaultServiceSubject, mintServiceToken } from "../auth.js"; +import { resolveRequestToken } from "../auth.js"; import { DataClient } from "../clients/data.js"; import { getSettings } from "../config.js"; -type ToolRequestContext = { authToken?: string }; +type ToolRequestContext = { authToken?: string; get?: (key: string) => unknown }; async function getClient(ctx?: ToolRequestContext): Promise { const settings = getSettings(); - const token = ctx?.authToken ?? (await mintServiceToken({ sub: defaultServiceSubject() })); + const token = await resolveRequestToken(ctx); return new DataClient({ baseUrl: settings.dataServiceUrl, token }); } diff --git a/packages/orchestration/src/tools/paper.ts b/packages/orchestration/src/tools/paper.ts index 1608bac2..920f65ec 100644 --- a/packages/orchestration/src/tools/paper.ts +++ b/packages/orchestration/src/tools/paper.ts @@ -4,7 +4,7 @@ import { createTool } from "@mastra/core/tools"; import { z } from "zod"; -import { defaultServiceSubject, mintServiceToken } from "../auth.js"; +import { resolveRequestToken } from "../auth.js"; import { PaperClient } from "../clients/paper.js"; import { getSettings } from "../config.js"; @@ -23,7 +23,7 @@ const SymbolSchema = z "symbol 不能为空 / 含空格;支持 crypto 'BTC/USDT' / 普通 'AAPL' / 指数 '^N225' / akshare 'sh.600519' / yfinance '005930.KS' / FRED 'DFF'", ); -type ToolRequestContext = { authToken?: string }; +type ToolRequestContext = { authToken?: string; get?: (key: string) => unknown }; /** * perp(USDT-M 永续)回测入参——run_backtest / cv_backtest / check_sensitivity 共用, @@ -53,7 +53,7 @@ const perpInputFields = { async function getClient(ctx?: ToolRequestContext): Promise { const settings = getSettings(); - const token = ctx?.authToken ?? (await mintServiceToken({ sub: defaultServiceSubject() })); + const token = await resolveRequestToken(ctx); return new PaperClient({ baseUrl: settings.paperServiceUrl, token }); } @@ -67,7 +67,7 @@ async function getBacktestClient( timeoutMs = 300_000, ): Promise { const settings = getSettings(); - const token = ctx?.authToken ?? (await mintServiceToken({ sub: defaultServiceSubject() })); + const token = await resolveRequestToken(ctx); return new PaperClient({ baseUrl: settings.paperServiceUrl, token, timeoutMs }); } diff --git a/packages/orchestration/src/tools/research.ts b/packages/orchestration/src/tools/research.ts index c078f2da..7d4495bb 100644 --- a/packages/orchestration/src/tools/research.ts +++ b/packages/orchestration/src/tools/research.ts @@ -4,7 +4,7 @@ import { createTool } from "@mastra/core/tools"; import { z } from "zod"; -import { defaultServiceSubject, mintServiceToken } from "../auth.js"; +import { resolveRequestToken } from "../auth.js"; import { ResearchClient, type PersonaKey } from "../clients/research.js"; import { getSettings } from "../config.js"; @@ -28,11 +28,11 @@ const SymbolSchema = z "symbol 不能为空 / 含空格;支持 crypto 'BTC/USDT' / 普通 'AAPL' / 指数 '^N225' / akshare 'sh.600519' / yfinance '005930.KS' / FRED 'DFF'", ); -type ToolRequestContext = { authToken?: string }; +type ToolRequestContext = { authToken?: string; get?: (key: string) => unknown }; async function getClient(ctx?: ToolRequestContext): Promise { const settings = getSettings(); - const token = ctx?.authToken ?? (await mintServiceToken({ sub: defaultServiceSubject() })); + const token = await resolveRequestToken(ctx); return new ResearchClient({ baseUrl: settings.researchServiceUrl, token }); } diff --git a/packages/orchestration/src/tools/risk.ts b/packages/orchestration/src/tools/risk.ts index ea3ce17c..ed37c50c 100644 --- a/packages/orchestration/src/tools/risk.ts +++ b/packages/orchestration/src/tools/risk.ts @@ -17,15 +17,15 @@ import { createTool } from "@mastra/core/tools"; import { z } from "zod"; -import { defaultServiceSubject, mintServiceToken } from "../auth.js"; +import { resolveRequestToken } from "../auth.js"; import { RiskClient } from "../clients/risk.js"; import { getSettings } from "../config.js"; -type ToolRequestContext = { authToken?: string }; +type ToolRequestContext = { authToken?: string; get?: (key: string) => unknown }; async function getClient(ctx?: ToolRequestContext): Promise { const settings = getSettings(); - const token = ctx?.authToken ?? (await mintServiceToken({ sub: defaultServiceSubject() })); + const token = await resolveRequestToken(ctx); return new RiskClient({ baseUrl: settings.paperServiceUrl, token }); } diff --git a/packages/orchestration/src/tools/strategy.ts b/packages/orchestration/src/tools/strategy.ts index cac63c94..254abd04 100644 --- a/packages/orchestration/src/tools/strategy.ts +++ b/packages/orchestration/src/tools/strategy.ts @@ -22,15 +22,15 @@ import { createTool } from "@mastra/core/tools"; import { z } from "zod"; -import { defaultServiceSubject, mintServiceToken } from "../auth.js"; +import { resolveRequestToken } from "../auth.js"; import { PaperClient } from "../clients/paper.js"; import { getSettings } from "../config.js"; -type ToolRequestContext = { authToken?: string }; +type ToolRequestContext = { authToken?: string; get?: (key: string) => unknown }; async function getClient(ctx?: ToolRequestContext): Promise { const settings = getSettings(); - const token = ctx?.authToken ?? (await mintServiceToken({ sub: defaultServiceSubject() })); + const token = await resolveRequestToken(ctx); return new PaperClient({ baseUrl: settings.paperServiceUrl, token }); } diff --git a/packages/orchestration/src/tools/trade-plan.ts b/packages/orchestration/src/tools/trade-plan.ts index d2e094fd..6d764ee4 100644 --- a/packages/orchestration/src/tools/trade-plan.ts +++ b/packages/orchestration/src/tools/trade-plan.ts @@ -20,7 +20,7 @@ import { createTool } from "@mastra/core/tools"; import { z } from "zod"; -import { defaultServiceSubject, mintServiceToken } from "../auth.js"; +import { resolveRequestToken } from "../auth.js"; import { HttpClientError } from "../clients/http.js"; import { PaperClient } from "../clients/paper.js"; import { getSettings } from "../config.js"; @@ -39,11 +39,11 @@ const SideSchema = z.enum(["BUY", "SELL"]); const TypeSchema = z.enum(["MARKET", "LIMIT"]); const IntentSchema = z.enum(["open_long", "open_short", "close", "rebalance"]); -type ToolRequestContext = { authToken?: string }; +type ToolRequestContext = { authToken?: string; get?: (key: string) => unknown }; async function getClient(ctx?: ToolRequestContext): Promise { const settings = getSettings(); - const token = ctx?.authToken ?? (await mintServiceToken({ sub: defaultServiceSubject() })); + const token = await resolveRequestToken(ctx); return new PaperClient({ baseUrl: settings.paperServiceUrl, token }); } diff --git a/packages/orchestration/src/tools/web.ts b/packages/orchestration/src/tools/web.ts index b0ba37a4..c3ede2a1 100644 --- a/packages/orchestration/src/tools/web.ts +++ b/packages/orchestration/src/tools/web.ts @@ -7,10 +7,10 @@ import { createTool } from "@mastra/core/tools"; import { z } from "zod"; -import { defaultServiceSubject, mintServiceToken } from "../auth.js"; +import { resolveRequestToken } from "../auth.js"; import { getSettings } from "../config.js"; -type ToolRequestContext = { authToken?: string }; +type ToolRequestContext = { authToken?: string; get?: (key: string) => unknown }; /** * 工具层兜底超时(ms)。Node fetch 无默认超时——data 服务异常无响应时会让 @@ -27,7 +27,7 @@ async function getBaseUrl(): Promise { } async function getAuthHeaders(ctx?: ToolRequestContext): Promise> { - const token = ctx?.authToken ?? (await mintServiceToken({ sub: defaultServiceSubject() })); + const token = await resolveRequestToken(ctx); return { Authorization: `Bearer ${token}` }; } From f53d1b39a82eea7bb151e31da62ea94ae32e22cc Mon Sep 17 00:00:00 2001 From: Miro Date: Thu, 2 Jul 2026 10:02:24 +0800 Subject: [PATCH 04/19] =?UTF-8?q?feat(dashboard):=20=E7=99=BB=E5=BD=95?= =?UTF-8?q?=E4=BC=9A=E8=AF=9D=20+=20=E7=99=BB=E5=BD=95=E9=A1=B5=20+=20?= =?UTF-8?q?=E4=B8=AD=E9=97=B4=E4=BB=B6=E9=89=B4=E6=9D=83=20+=20subject=20?= =?UTF-8?q?=E4=BB=8E=20session=20=E6=B4=BE=E7=94=9F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit session.ts httpOnly session cookie(jose,7d);backend.ts 每请求按登录用户 sub 铸后端 token(Map 缓存),未启用登录回落 CONSOLE_SUBJECT。加 /api/auth/{login,logout,session} + /login 页(独立于 locale 外壳)+ 侧栏登出。proxy.ts 中间件:AUTH_ENABLED 时无 session 页面跳登录、/api 返 401。copilotkit resourceId / mastra 会话 / divination / factor 审批的 subject 统一改从 session 派生,实现读路径 per-user 隔离。 Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/dashboard/messages/en.json | 3 +- apps/dashboard/messages/zh.json | 3 +- .../dashboard/src/app/api/auth/login/route.ts | 47 ++++++ .../src/app/api/auth/logout/route.ts | 10 ++ .../src/app/api/auth/session/route.ts | 14 ++ .../dashboard/src/app/api/copilotkit/route.ts | 7 +- .../src/app/api/divination/history/route.ts | 4 +- .../dashboard/src/app/api/divination/route.ts | 4 +- .../app/api/factors/candidates/[id]/route.ts | 8 +- apps/dashboard/src/app/login/page.tsx | 23 +++ .../src/components/auth/LoginForm.tsx | 146 ++++++++++++++++++ .../src/components/shell/AccountControl.tsx | 78 ++++++++++ .../src/components/shell/ConsoleSidebar.tsx | 3 + apps/dashboard/src/lib/backend.ts | 48 ++++-- apps/dashboard/src/lib/mastra.ts | 10 +- apps/dashboard/src/lib/session.ts | 82 ++++++++++ apps/dashboard/src/proxy.ts | 63 +++++++- 17 files changed, 520 insertions(+), 33 deletions(-) create mode 100644 apps/dashboard/src/app/api/auth/login/route.ts create mode 100644 apps/dashboard/src/app/api/auth/logout/route.ts create mode 100644 apps/dashboard/src/app/api/auth/session/route.ts create mode 100644 apps/dashboard/src/app/login/page.tsx create mode 100644 apps/dashboard/src/components/auth/LoginForm.tsx create mode 100644 apps/dashboard/src/components/shell/AccountControl.tsx create mode 100644 apps/dashboard/src/lib/session.ts diff --git a/apps/dashboard/messages/en.json b/apps/dashboard/messages/en.json index af2b9c0c..bcf97d6c 100644 --- a/apps/dashboard/messages/en.json +++ b/apps/dashboard/messages/en.json @@ -16,7 +16,8 @@ "menu": "Menu", "close": "Close", "collapse": "Collapse sidebar", - "expand": "Expand sidebar" + "expand": "Expand sidebar", + "logout": "Sign out" }, "theme": { "label": "Theme", diff --git a/apps/dashboard/messages/zh.json b/apps/dashboard/messages/zh.json index 81c2db03..612ca47b 100644 --- a/apps/dashboard/messages/zh.json +++ b/apps/dashboard/messages/zh.json @@ -16,7 +16,8 @@ "menu": "菜单", "close": "关闭", "collapse": "收起侧边栏", - "expand": "展开侧边栏" + "expand": "展开侧边栏", + "logout": "登出" }, "theme": { "label": "主题", diff --git a/apps/dashboard/src/app/api/auth/login/route.ts b/apps/dashboard/src/app/api/auth/login/route.ts new file mode 100644 index 00000000..f7a5daa8 --- /dev/null +++ b/apps/dashboard/src/app/api/auth/login/route.ts @@ -0,0 +1,47 @@ +import { NextResponse } from "next/server"; + +import { BackendError, backendFetch } from "@/lib/backend"; +import { SESSION_COOKIE, SESSION_COOKIE_OPTS, createSessionToken } from "@/lib/session"; + +/** + * 登录:校验凭据 → 落 session cookie。 + * + * dashboard 无 DB 凭据,把邮箱 / 密码反代到内网 paper `/auth/login` 校验;成功后用 + * `JWT_SECRET` 签 httpOnly session cookie。密码只透传一次,不落任何日志。 + */ +export async function POST(req: Request): Promise { + let email: unknown; + let password: unknown; + try { + ({ email, password } = await req.json()); + } catch { + return NextResponse.json({ error: "请求体格式错误" }, { status: 400 }); + } + if (typeof email !== "string" || typeof password !== "string" || !email || !password) { + return NextResponse.json({ error: "缺少邮箱或密码" }, { status: 400 }); + } + + try { + const user = await backendFetch<{ subject: string; email: string; roles: string[] }>( + "paper", + "/auth/login", + { auth: false, method: "POST", body: { email, password } }, + ); + const token = await createSessionToken({ + subject: user.subject, + email: user.email, + roles: user.roles ?? [], + }); + const res = NextResponse.json({ ok: true }); + res.cookies.set(SESSION_COOKIE, token, { + ...SESSION_COOKIE_OPTS, + maxAge: 7 * 24 * 3600, + }); + return res; + } catch (err) { + if (err instanceof BackendError && err.status === 401) { + return NextResponse.json({ error: "邮箱或密码不正确" }, { status: 401 }); + } + return NextResponse.json({ error: "登录服务暂不可用,请稍后重试" }, { status: 502 }); + } +} diff --git a/apps/dashboard/src/app/api/auth/logout/route.ts b/apps/dashboard/src/app/api/auth/logout/route.ts new file mode 100644 index 00000000..c14cdd8d --- /dev/null +++ b/apps/dashboard/src/app/api/auth/logout/route.ts @@ -0,0 +1,10 @@ +import { NextResponse } from "next/server"; + +import { SESSION_COOKIE, SESSION_COOKIE_OPTS } from "@/lib/session"; + +/** 登出:清 session cookie。前端随后跳 /login。 */ +export async function POST(): Promise { + const res = NextResponse.json({ ok: true }); + res.cookies.set(SESSION_COOKIE, "", { ...SESSION_COOKIE_OPTS, maxAge: 0 }); + return res; +} diff --git a/apps/dashboard/src/app/api/auth/session/route.ts b/apps/dashboard/src/app/api/auth/session/route.ts new file mode 100644 index 00000000..9b9ce461 --- /dev/null +++ b/apps/dashboard/src/app/api/auth/session/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from "next/server"; + +import { readSession } from "@/lib/session"; + +/** + * 当前登录用户(供侧栏显示 email + 登出按钮判存在)。未登录 / 未启用登录 → `{ user: null }`。 + * 不返回任何凭据。 + */ +export async function GET(): Promise { + const session = await readSession(); + return NextResponse.json({ + user: session ? { email: session.email, subject: session.subject } : null, + }); +} diff --git a/apps/dashboard/src/app/api/copilotkit/route.ts b/apps/dashboard/src/app/api/copilotkit/route.ts index aab7c6aa..86eaf33c 100644 --- a/apps/dashboard/src/app/api/copilotkit/route.ts +++ b/apps/dashboard/src/app/api/copilotkit/route.ts @@ -7,7 +7,7 @@ import { import { getRemoteAgents } from "@ag-ui/mastra"; import { MastraClient } from "@mastra/client-js"; -import { BACKENDS, CONSOLE_SUBJECT, getServiceToken } from "@/lib/backend"; +import { BACKENDS, getServiceToken, getSessionSubject } from "@/lib/backend"; /** * 静音 @ag-ui/mastra 1.0.3 的良性日志噪音:它不认 mastra(v5 streamVNext)的 @@ -57,7 +57,10 @@ if (!(console.warn as { __inalphaFiltered?: boolean }).__inalphaFiltered) { * @returns CopilotKit runtime 的 POST handler */ export const POST = async (req: Request): Promise => { + // token 的 sub = 登录用户(或 dev 下 console:dev)。mastra identityMiddleware 据此 + // 注入 authSub,tool 层再据此打给 Python(resolveRequestToken),保证 agent 写操作落登录用户账户。 const token = await getServiceToken(); + const resourceId = await getSessionSubject(); const mastraClient = new MastraClient({ baseUrl: BACKENDS.mastra, @@ -66,7 +69,7 @@ export const POST = async (req: Request): Promise => { const agents = await getRemoteAgents({ mastraClient, - resourceId: CONSOLE_SUBJECT, + resourceId, }); // CopilotKit 1.59.5 起 `agents` 收紧为 NonEmptyRecord | Promise | factory; diff --git a/apps/dashboard/src/app/api/divination/history/route.ts b/apps/dashboard/src/app/api/divination/history/route.ts index 88a61875..76d6eb97 100644 --- a/apps/dashboard/src/app/api/divination/history/route.ts +++ b/apps/dashboard/src/app/api/divination/history/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { BackendError, backendFetch, CONSOLE_SUBJECT } from "@/lib/backend"; +import { BackendError, backendFetch, getSessionSubject } from "@/lib/backend"; export const dynamic = "force-dynamic"; @@ -15,7 +15,7 @@ export async function GET(req: NextRequest) { try { const data = await backendFetch<{ records: unknown[] }>("mastra", "/divination/history", { timeoutMs: 8000, - query: { subject: CONSOLE_SUBJECT, limit }, + query: { subject: await getSessionSubject(), limit }, }); return NextResponse.json(data); } catch (err) { diff --git a/apps/dashboard/src/app/api/divination/route.ts b/apps/dashboard/src/app/api/divination/route.ts index 7b7ac95d..a470fdd1 100644 --- a/apps/dashboard/src/app/api/divination/route.ts +++ b/apps/dashboard/src/app/api/divination/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { BackendError, backendFetch, CONSOLE_SUBJECT } from "@/lib/backend"; +import { BackendError, backendFetch, getSessionSubject } from "@/lib/backend"; export const dynamic = "force-dynamic"; @@ -29,7 +29,7 @@ export async function POST(req: NextRequest) { mode: body["mode"], question: body["question"], symbol: body["symbol"], - subject: CONSOLE_SUBJECT, + subject: await getSessionSubject(), }, }); return NextResponse.json(record); diff --git a/apps/dashboard/src/app/api/factors/candidates/[id]/route.ts b/apps/dashboard/src/app/api/factors/candidates/[id]/route.ts index f3656bc4..7a9fa195 100644 --- a/apps/dashboard/src/app/api/factors/candidates/[id]/route.ts +++ b/apps/dashboard/src/app/api/factors/candidates/[id]/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; -import { CONSOLE_SUBJECT, backendFetch } from "@/lib/backend"; +import { backendFetch, getSessionSubject } from "@/lib/backend"; import type { FactorCandidate } from "@/lib/types"; export const dynamic = "force-dynamic"; @@ -35,11 +35,9 @@ export async function POST( auth: false, method: "POST", body: { - // 审计可追溯:从控制台账户身份派生,别硬编码占位符——否则所有审核记录 - // 都标同一 "console:dev",事后复盘分不清谁批的。单租户 dev 下 = CONSOLE_SUBJECT; - // 接 session 鉴权后改为从 JWT 派生(同 backend.ts CONSOLE_SUBJECT 约定)。 + // 审计可追溯:从登录用户身份派生(dev 未登录回落 console:dev),别硬编码占位符。 action: body.action, - reviewed_by: CONSOLE_SUBJECT, + reviewed_by: await getSessionSubject(), note: body.note ?? null, }, timeoutMs: 8000, diff --git a/apps/dashboard/src/app/login/page.tsx b/apps/dashboard/src/app/login/page.tsx new file mode 100644 index 00000000..6dc68c35 --- /dev/null +++ b/apps/dashboard/src/app/login/page.tsx @@ -0,0 +1,23 @@ +import { Suspense } from "react"; + +import { LoginForm } from "@/components/auth/LoginForm"; + +/** + * 登录页。刻意放在 `[locale]` 外壳之外 —— 不套控制台侧栏 / 对话栏 / 活动日志, + * 避免未登录时这些组件挂载后打 401。middleware 未登录时重定向到这里。 + */ +export const metadata = { + title: "Sign in · Inalpha", + robots: { index: false, follow: false }, +}; + +export default function LoginPage() { + return ( +
+ {/* useSearchParams 需要 Suspense 边界。 */} + + + +
+ ); +} diff --git a/apps/dashboard/src/components/auth/LoginForm.tsx b/apps/dashboard/src/components/auth/LoginForm.tsx new file mode 100644 index 00000000..21fa4c5d --- /dev/null +++ b/apps/dashboard/src/components/auth/LoginForm.tsx @@ -0,0 +1,146 @@ +"use client"; + +import { useState } from "react"; +import { useRouter, useSearchParams } from "next/navigation"; + +/** + * 登录表单。登录页在 `[locale]` 外壳之外(不套控制台侧栏 / 对话栏 / intl provider), + * 故文案在此按 `navigator.language` 做最小中英切换,不依赖 next-intl。 + */ + +const STRINGS = { + en: { + title: "Operator Console", + subtitle: "Sign in to continue", + email: "Email", + password: "Password", + submit: "Sign in", + submitting: "Signing in…", + invalid: "Incorrect email or password", + unavailable: "Login service unavailable, try again later", + }, + zh: { + title: "操作控制台", + subtitle: "登录以继续", + email: "邮箱", + password: "密码", + submit: "登录", + submitting: "登录中…", + invalid: "邮箱或密码不正确", + unavailable: "登录服务暂不可用,请稍后重试", + }, +}; + +function pickLang(): "en" | "zh" { + if (typeof navigator !== "undefined" && navigator.language?.toLowerCase().startsWith("zh")) { + return "zh"; + } + return "en"; +} + +export function LoginForm() { + const router = useRouter(); + const params = useSearchParams(); + const t = STRINGS[pickLang()]; + + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(false); + + async function onSubmit(e: React.FormEvent) { + e.preventDefault(); + setError(null); + setLoading(true); + try { + const res = await fetch("/api/auth/login", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email, password }), + }); + if (res.ok) { + const from = params.get("from"); + // 只接受站内相对路径,防开放重定向。 + const dest = from && from.startsWith("/") && !from.startsWith("//") ? from : "/"; + router.replace(dest); + router.refresh(); + return; + } + setError(res.status === 401 ? t.invalid : t.unavailable); + } catch { + setError(t.unavailable); + } finally { + setLoading(false); + } + } + + return ( +
+
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Inalpha +
+
Inalpha
+
+ {t.title} +
+
+
+ +

{t.subtitle}

+ +
+ + +
+ + {error && ( +

+ {error} +

+ )} + + +
+ ); +} diff --git a/apps/dashboard/src/components/shell/AccountControl.tsx b/apps/dashboard/src/components/shell/AccountControl.tsx new file mode 100644 index 00000000..64c1b8be --- /dev/null +++ b/apps/dashboard/src/components/shell/AccountControl.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { LogOut } from "lucide-react"; +import { useTranslations } from "next-intl"; + +import { cn } from "@/lib/cn"; + +/** + * 侧栏底部账户控件:显示登录用户邮箱 + 登出。 + * + * 未登录 / 未启用登录(`/api/auth/session` 返 `user: null`)时不渲染 —— 本地 dev 无登录态, + * 侧栏保持原样。登出后跳 `/login`(站点根路径,不带 locale 前缀,故用 next/navigation)。 + */ +export function AccountControl({ collapsed }: { collapsed: boolean }) { + const t = useTranslations("nav"); + const router = useRouter(); + const [email, setEmail] = useState(null); + + useEffect(() => { + let alive = true; + fetch("/api/auth/session") + .then((r) => (r.ok ? r.json() : null)) + .then((d) => { + if (alive) setEmail(d?.user?.email ?? null); + }) + .catch(() => {}); + return () => { + alive = false; + }; + }, []); + + if (!email) return null; + + async function logout() { + await fetch("/api/auth/logout", { method: "POST" }).catch(() => {}); + router.replace("/login"); + router.refresh(); + } + + if (collapsed) { + return ( + + ); + } + + return ( +
+ + {email} + + +
+ ); +} diff --git a/apps/dashboard/src/components/shell/ConsoleSidebar.tsx b/apps/dashboard/src/components/shell/ConsoleSidebar.tsx index d423d31a..1d057d5e 100644 --- a/apps/dashboard/src/components/shell/ConsoleSidebar.tsx +++ b/apps/dashboard/src/components/shell/ConsoleSidebar.tsx @@ -20,6 +20,7 @@ import { import { Link, usePathname } from "@/i18n/navigation"; import { cn } from "@/lib/cn"; +import { AccountControl } from "./AccountControl"; import { LocaleSwitcher } from "./LocaleSwitcher"; import { ThemeToggle } from "./ThemeToggle"; @@ -374,6 +375,7 @@ function SidebarBody({ {/* Footer —— 控制区(主题 / 语言)+ 折叠开关 + build 标记。 */} {collapsed ? (
+ {onToggleCollapsed && (