From f3f11461de2297fe2356cbce7edd32abc1b0b85f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Thu, 25 Jun 2026 21:40:57 +0800 Subject: [PATCH 1/2] =?UTF-8?q?fix(open-3qfx):=20=E5=85=8D=E7=99=BB?= =?UTF-8?q?=E5=BD=95=E5=BB=BA=E4=BC=9A=E8=AF=9D=E8=A1=A5=20default=20?= =?UTF-8?q?=E7=94=A8=E6=88=B7=E4=B8=8A=E4=B8=8B=E6=96=87,=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20thread=5Fmeta=20=E4=B8=8D=E8=90=BD=E5=BA=93?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 开放接口 /api/open/3qfx/ask 免登录、无 user context,start_run 写 thread_meta 前先 thread_store.get() 默认 user_id=AUTO,resolve_user_id(AUTO) 在无 context 时 抛 RuntimeError 被 start_run 的 try/except 当「非致命」吞掉,thread_meta 行从未 创建,前端按 metadata.taskId 怎么也搜不到这条会话(run 却已建)。 - 调 start_run 前显式 set_current_user 为 default 用户桶(后台 run_agent task 在 create_task 时快照此 context,故后台运行也归属 default,符合任务深链对话 全局共享、按创建者解析文件的语义),thread_meta 正常落库。 - 把 cop_task_detail_* 配置补到实际加载的 offline-backend-20260512/config.yaml (此前只加在不生效的 backend/config.yaml)。 Co-Authored-By: Claude Opus 4.8 (1M context) --- .../backend/app/gateway/routers/open_3qfx.py | 23 ++++++++++++++++++- offline-backend-20260512/config.yaml | 11 +++++++++ 2 files changed, 33 insertions(+), 1 deletion(-) diff --git a/offline-backend-20260512/backend/app/gateway/routers/open_3qfx.py b/offline-backend-20260512/backend/app/gateway/routers/open_3qfx.py index ede4d92b..b15bb77a 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/open_3qfx.py +++ b/offline-backend-20260512/backend/app/gateway/routers/open_3qfx.py @@ -42,6 +42,7 @@ from pydantic import BaseModel, Field from app.gateway.routers.thread_runs import RunCreateRequest from app.gateway.services import start_run from deerflow.config import get_app_config +from deerflow.runtime.user_context import DEFAULT_USER_ID, reset_current_user, set_current_user logger = logging.getLogger(__name__) @@ -51,6 +52,18 @@ router = APIRouter(prefix="/api/open/3qfx", tags=["open"]) _THREEQ_BUSINESS_CODE = "3Q" +class _OpenDefaultUser: + """开放接口(免登录)以「default」用户桶运行的占位 user。 + + 满足 :class:`deerflow.runtime.user_context.CurrentUser` 协议(仅需 ``.id``)。 + 免登录请求本身没有 user context,而 ``start_run`` 写 thread_meta 时默认 ``user_id=AUTO``, + 无 context 会抛 ``RuntimeError`` → 被 ``start_run`` 的 try/except 当「非致命」吞掉 → + **thread_meta 行从未创建**,于是前端按 ``metadata.taskId`` 怎么也搜不到这条会话。 + """ + + id = DEFAULT_USER_ID + + class ThreeQAskRequest(BaseModel): """3qfx 开放问答请求体。""" @@ -174,8 +187,16 @@ async def ask_3qfx(body: ThreeQAskRequest, request: Request) -> ThreeQAskRespons multitask_strategy="reject", ) + # 免登录 → 无 user context。显式设成「default」用户桶,否则 start_run 写 thread_meta 时 + # resolve_user_id(AUTO) 抛 RuntimeError 被吞掉,thread_meta 建不出来、前端按 taskId 搜不到。 + # run_agent 是后台 task,在 create_task 时快照本 context,故后台运行也归属 default 用户 + # (与 metadata.taskId「任务深链对话全局共享、按创建者解析文件」语义一致)。 # enforce_access=False:开放接口已自行解析 admin 配置的 3Q 智能体,不再按当前(无)登录用户做可见性校验。 - record = await start_run(run_body, thread_id, request, enforce_access=False) + token = set_current_user(_OpenDefaultUser()) + try: + record = await start_run(run_body, thread_id, request, enforce_access=False) + finally: + reset_current_user(token) return ThreeQAskResponse( thread_id=record.thread_id, diff --git a/offline-backend-20260512/config.yaml b/offline-backend-20260512/config.yaml index 34b86761..3360a25a 100644 --- a/offline-backend-20260512/config.yaml +++ b/offline-backend-20260512/config.yaml @@ -354,6 +354,9 @@ task_deeplink: test: true # 目的树查询走 mock.purpose_detail 假数据;同时 rwfx 进入「3q 是否完成」预检走 mock.sq_report 假数据 delete_test: true # 删除接口走 mock.delete 假数据(该接口尚未开发完) action_test: true # 行动→任务解析走 mock.action_detail 假数据(xdfx 行动id→任务id) + # 开放接口 POST /api/open/3qfx/ask 按 taskId 拉取任务详情拼开场白;上游不可达时打开下面开关走 mock.cop_task_detail。 + cop_task_detail_test: true + cop_task_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/cop-task-detail" purpose_detail_url: "https://ch1.b.uat.4.cn/consumer/taskAnalyseSearch/getTaskPurposeDetail" # rwfx 与 xdfx 共用同一删除接口:rwfx 追加 ?taskId=(不传 actionId);xdfx 追加 ?actionId=(不传 taskId,直接不出现)。 delete_url: "https://ch1.b.uat.4.cn/bwjt/dropSixOrSevenData" @@ -417,3 +420,11 @@ task_deeplink: taskId: 3338 categoryType: "敌情关键事件 JSON" contentJson: "[{\"topic\":\"示例研判事件\",\"count\":1}]" + # cop_task_detail_test=true 时原样返回给开放接口 /api/open/3qfx/ask(结构同真实 cop-task-detail 返回)。 + cop_task_detail: + data: + id: 2055 + overview: "暑期文旅消费高峰期间的舆情态势综合研判与引导任务" + taskDirection: "正向引导 + 风险预警" + taskName: "任务01:暑期文旅舆情综合研判" + taskContent: "围绕暑期文旅消费高峰,对重点城市、重点景区的公众情绪与舆情态势做综合研判,识别情绪拐点与潜在风险,提出正向引导与处置建议。" From a417124c215ca94912f1d5310a5cfbc42bb04db8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=90=89=E9=91=AB=E5=8D=87?= Date: Thu, 25 Jun 2026 21:41:15 +0800 Subject: [PATCH 2/2] =?UTF-8?q?feat(auth):=20=E7=99=BB=E5=BD=95=E7=99=BD?= =?UTF-8?q?=E5=90=8D=E5=8D=95=E7=BB=86=E5=8C=96=E4=B8=BA=E9=BB=91=E5=90=8D?= =?UTF-8?q?=E5=8D=95=E6=A8=A1=E5=BC=8F(approved=20=E9=BB=98=E8=AE=A4?= =?UTF-8?q?=E6=94=BE=E8=A1=8C)+=20=E5=90=8C=E6=AD=A5=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E4=BA=A7=E7=89=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - approved 放行标记默认 True:新账号首次登录即放行,登录白名单开关打开时只拦截 被管理员显式「取消放行」(approved=False) 的用户;enforce_user_approved 放在 各登录端点拿到 user 后、签发 JWT 前调用,admin 永远放行。 - 同步调整 LoginWhitelistPage / local_provider / models / system_settings / 迁移 20260625_02 / user model / column_additions_mysql.sql 与 CLAUDE.md。 - 更新 backend/backend.zip 构建产物。 Co-Authored-By: Claude Opus 4.8 (1M context) --- CLAUDE.md | 2 +- frontend-web/src/pages/LoginWhitelistPage.tsx | 18 +++++++++--------- .../app/gateway/auth/local_provider.py | 8 +++++--- .../backend/app/gateway/auth/models.py | 2 +- .../backend/app/gateway/routers/auth.py | 19 +++++++++++-------- .../deerflow/config/system_settings.py | 7 ++++--- .../versions/20260625_02_users_approved.py | 8 +++++--- .../deerflow/persistence/user/model.py | 12 ++++++------ .../scripts/sql/column_additions_mysql.sql | 10 +++++----- 9 files changed, 47 insertions(+), 39 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index d80dabce..ca1abd9e 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -82,7 +82,7 @@ The frontend (`frontend-web/`) is a React 19 + Vite SPA: - **定时任务「JSON → HTML 模板填充」(模板数据转换器)**: a third scheduled-task execution mode. In the create/edit dialog (`ScheduledTasksPage.tsx`), selecting the built-in agent **「模板数据转换器」** (`execution_agent_name === "template-json-builder"`, `TEMPLATE_BUILDER_AGENT_ID`) reveals a 「HTML模板」 select (`useHtmlTemplates` → `GET /api/scheduled-tasks/html-templates`) + a **.json 上传** button (reads the file's text into the prompt box) and **hides** the 必须生成 HTML/Markdown switches (this mode always outputs HTML). The chosen template persists as `execution_context.template_config.template_id` (`TemplateFillConfig` in `core/scheduled-tasks/types.ts`). At run time the backend converts the source JSON (the task prompt) into that template's `DATA` structure, validates (retry-on-fail), fills the template, and stores an HTML page previewable via the existing run-detail HTML preview. The source JSON is **any** shape — the agent's `SOUL.md` maps it to the template's schema skeleton. See the backend CLAUDE.md「Template-fill mode」for the engine (`deerflow/runtime/scheduler/template_fill.py`), agent seed, and dispatch. - **轻应用中心 (Light App Center)**: a sibling of 任务管理 in the sidebar with two pages — 应用管理 (admin registers `window_open` or `iframe` mini-apps with a route-param builder: login params resolved from `localStorage.userInfo`, theme params literal) and 应用列表 (card grid of `window_open` apps, `window.open` launch). `iframe` apps are mounted under a chosen parent menu (XK/PG/TY/综合管理) and **dynamically injected into the sidebar** by `core/page-layout/use-sidebar-menu.ts` (`useSidebarMenuItems`), opening in the `LightAppIframeView` host. Client + React Query hooks in `strategy-components/api/light-apps.ts`; URL builder/constants in `strategy-components/lib/light-app.ts`. Backend: `/api/light-apps` (global/shared, admin-gated writes). - **菜单管理 (Menu Management, admin-only)**: an **overlay** over the static sidebar menu — `MenuManagementPage` (`pages/MenuManagementPage.tsx`, route `/page/strategy/admin/menu-management`, opened from the admin-only 「菜单管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`). Admins rename / 停用·启用 / reorder (↑↓ or @dnd-kit drag) / re-parent (horizontal drag **or** the edit dialog's 「移动到」parent selector) any node across 一级/二级/三级, **including injected iframe light-app nodes** (`light-app-`). The static tree (`core/page-layout/sidebar-menu.ts`) stays the source of icons/routes; only per-node deltas (`label`/`disabled`/`parentId`/`sortOrder`) persist to the backend. Merge logic in `core/page-layout/menu-overrides.ts` (`applyMenuOverrides`, applied in `use-sidebar-menu.ts` after light-app injection, before role filter; disabling a node hides its whole subtree) — strict **3-level cap** (`wouldExceedDepth`). Client `strategy-components/api/menu-overrides.ts`. Backend: `/api/menu-overrides` (global/shared; open read, admin atomic replace-all). -- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users still **self-register** as usual but a regular new account defaults to `approved=False` (待审批); admins are created `approved=True`; existing rows are backfilled `True` so flipping the switch on never locks out existing users. Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. +- **白名单管理 (Login Whitelist, admin-only)**: gates **who may log in**, run as a **blocklist (黑名单模式)**. Two pieces: a global master switch (`system_settings.json → login_whitelist.enabled`, surfaced by **admin-only** `GET|PUT /api/system-settings/login-whitelist`) + a per-user **放行** flag (`users.approved` column, migration `20260625_02`). Users **self-register** as usual and **every new account defaults to `approved=True`** — so a user who has never logged into this system before is allowed straight through; the switch only blocks users an admin has explicitly **取消放行** (`approved=False`). (`create_user`/`User`/`UserRow` all default `approved=True`; admins are always approved; existing rows are backfilled `True`.) Login interception is the single helper `enforce_user_approved(user)` in `app/gateway/routers/auth.py`, called in all three login endpoints (`/login/{local,username,token}`) **after** the account is resolved/created and **before** the JWT is issued: switch off → everyone passes; `system_role=="admin"` → always passes (anti-lockout); else `approved=False` (admin-revoked) → 403 `{code:"PENDING_APPROVAL"}` (the frontend's `extractErrorMessage` surfaces the message and login throws before writing any auth state, so no half-login). Admin approves on `pages/LoginWhitelistPage.tsx` (route `/page/strategy/admin/login-whitelist`, entry「白名单管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both sidebar modes): a top master-switch + a list of all registered users (`GET /api/v1/auth/users`, now carrying `approved`) with a per-row 放行 Switch (`PUT /api/v1/auth/users/{id}/approval`, admin-only; revoking only blocks the **next** login — `token_version` is left untouched, existing sessions are not force-killed). Client/hooks: switch in `strategy-components/api/login-whitelist.ts`, user list + approval in `core/auth/{api,hooks}.ts` (`useSetUserApproval`). Backend tests: `tests/test_login_approval.py`. - **敏感词管理 (Sensitive Word Management)**: configures the **展示层脱敏映射** (原始词 → 替换词). **Gated on the username `lqq`** (not the admin role) — `getAccountDisplayName().toLowerCase() === "lqq"` (username = email `@` prefix, else `user_id`, same as the profile page); the dropdown entry, the route guard, and the page-level guard all use this check. `src/lib/desensitize.ts` ships a frozen seed map `SEED_DESENSITIZE_MAP` (the deployment seed) plus a live mutable `DESENSITIZE_MAP` (initialized from the seed so `desensitize()` works before any fetch). `DesensitizeWordsLoader` (mounted in `App.tsx` `AppProviders`) fetches `/api/sensitive-words` on startup and, if non-empty, calls `replaceDesensitizeMap(...)` to make the backend config the source of truth (empty/failed → keep seed). Page `SensitiveWordsPage.tsx` (route `/page/strategy/admin/sensitive-words`, opened from the 「敏感词管理」 item in the **bottom-left settings dropdown** of `components/page-sidebar.tsx`, present in **both** full + collapsed sidebar modes) lists/adds/edits/启停/deletes words and saves the **whole set atomically** (replace-all); a 「导入种子词」 button merges any missing `SEED_DESENSITIZE_MAP` entries into the editor. Client + hooks in `strategy-components/api/sensitive-words.ts` (`wordsToMap` folds enabled rows into the runtime map). Backend: `/api/sensitive-words` (global/shared; open read, write allowed for admin **or** the `lqq` account), mirroring 菜单管理's store shape. **侧边栏 + 导航栏 menu labels are desensitized** at the data source: `useSidebarMenuItems`/`useActiveThirdLevel` (`core/page-layout/use-sidebar-menu.ts`) map every node's `label` through a desensitizer built **directly from the `/api/sensitive-words` query** (`makeDesensitizer(wordsToMap(words))`, falling back to the global seed `desensitize` while empty) — this sidesteps the `replaceDesensitizeMap` effect-timing race. Only `label` is rewritten; `path`/`id`/`icon` are untouched so routing/highlight are unaffected. All nav surfaces (`page-sidebar.tsx`, `page-sidebar-dark.tsx`, `page-sidebar-v1.tsx`, `strategy-components/Header.tsx`, `PageRoutes.tsx`) consume this hook and are covered; `MenuManagementPage` uses the raw tree so admins still edit real labels. - **技能地址管理 (Skill Address Management, admin-only)**: 扫描运行中的技能(`skills/{public,custom}` 下所有 `.md`/`.py`)里的 **http(s) URL 与 IP/IP:端口**,按唯一地址聚合全部出现位置(跨 .md/.py),支持**批量替换**(如把 UAT 地址一次性改成生产)。主体抽成可复用组件 `SkillAddressesPanel`(`pages/SkillAddressesPage.tsx` 导出,默认导出 `SkillAddressesPage` 仍保留旧路由 `/page/strategy/admin/skill-addresses` 作直链回退),**作为「技能地址管理」标签页嵌入管理员「技能管理」页 `pages/AdminSkillsPage.tsx`**(该页加 Tabs:「技能列表」+「技能地址管理」;入口在「设置和更多」下拉的「技能管理」`workspace-nav-menu.tsx` → `/page/workspace/admin/skills`,不再单列于 `components/page-sidebar.tsx` 左下角设置下拉):打开自动扫描并带**进度条**(扫描逐技能 / 应用逐文件 X/N,应对 150+ 技能),URL/IP 类型过滤 + 搜索 + 「片段批量套用」(如所有 `uat.4.cn` → `prod.4.cn`)、每地址「替换为」输入框(可展开看 `file:line` 片段)、内联**预览**、**应用**、**多关键词检索**(空格分隔全部命中),以及**修改记录**面板——每次应用生成一条记录(显示时间/改了哪些地址 from→to/文件数/处数),可**针对单次记录回退**(二次确认)。client + NDJSON 流式解析在 `strategy-components/api/skill-addresses.ts`。后端 `/api/skill-addresses`(**仅管理员**):所有文件 IO 走 `asyncio.to_thread` 不阻塞事件循环;`GET /scan`(+`/scan/stream`)、`POST /preview`、`POST /apply`(+`/apply/stream`)、`GET /backups`、`POST /rollback/{id}`;**子串安全替换**(基于 token span,不会误伤子串)、`expected_total` 乐观锁(409)、写前自动磁盘备份到 `{skills_root}/.address-edits/`(同时写一份 `{id}.manifest.json` 记录本次替换明细,供修改记录展示)、应用后刷新技能系统提示缓存。纯引擎 `deerflow/skills/address_scan.py`,router `app/gateway/routers/skill_addresses.py`,tests `tests/test_skill_addresses.py`。范围仅运行中技能,不动 `deploy/minimal/skills/`。 - **按钮管理 (Task Button Management, admin-only)**: configures the **可配置跳转按钮** rendered in the left task panel of the task deep-link workspaces (rwfx / 3qfx / xdfx, see `TaskFxWorkspace`). Each button = `{business, label, linkType, target, appendTaskId, idParam, idKind, appendAuth, authTokenParam, authNameParam, enabled, sortOrder}`; `linkType` is `url` (外链 `window.open`, optional append of `?{idParam}={id}` — **`idParam` is the editable query-param name**, e.g. `id`/`task_id`), `business` (内部切换到另一业务, reuses `resolveTaskDeeplinkTarget`, always task id), or `purpose` (弹出当前任务的「任务目的」表格 via the shared `TaskPurposeDialog`, no target — configurable for any page incl. xdfx). **`appendAuth` (追加登录态)**: a `url` button can additionally append `?{authTokenParam}={token1}&{authNameParam}={username}` (default param names `token`/`username`) so the跳转目标外部系统 can identify the current user — e.g. `…/action/sentiment?token=&username=lizhaoxing`. **Gated on token login**: `token1` = the `?authToken=` used by token-exchange login; `username` = the raw login name the backend resolved from `getTokenInfo`. Both are stashed at login by `persistLoginCredentials` into `login.token1`/`login.username` (`core/auth/login-credentials.ts`, scoped via [[scoped-storage]] so嵌入/独立会话隔离 + 随独立会话迁移). Only token login writes them → a non-token-login session has no `token1`, so `runTaskButton` silently skips the auth append (the jump still works, just without those two params). The frontend reads `username` straight from the new `UsernameLoginResponse.username` field (the stored email local-part is lossy — lowercased/normalized — so not used), **not** by decoding the JWT. **`idKind` (`task`/`action`) only matters for xdfx**: its deep-link `?taskId=` is an 行动id and the 任务id is resolved at login (`fetchTaskIdFromAction`) — `TaskContext` now also stashes the raw `actionId`, so a url button can append either (default `task`). Page `TaskButtonsPage.tsx` (route `/page/strategy/admin/task-buttons`, **admin-gated**, entry「按钮管理」in the bottom-left settings dropdown of `components/page-sidebar.tsx`, both full + collapsed modes) has per-business tabs, edits name/type/target/启停/排序/增删, saves the **whole set atomically** (replace-all), plus 「导入默认」 to merge code-seed defaults. **Seed/fallback** (like 敏感词管理): only when the whole table is empty (fresh deploy, never saved) do the workspace + page fall back to `defaultTaskButtons(cfg)` — rwfx's url buttons (编辑/上一步/下一步/3q详情) built from the **live** `/api/public/config/task-deeplink` plus cross-business switch buttons for **all three** businesses (each switches to the other two — rwfx→三情/行动, 3qfx→任务/行动, xdfx→任务/三情). Once saved, the store is the sole source of truth. **Bottom 快捷跳转 bar groups by prev/next**: `TaskJumpBar` renders only「上一步/下一步」-type buttons in the bottom bar — a `prev` group (id 含 `prev` 或文案「上一步」, 描边次要样式 + ←图标) rendered **左侧**, then a `next` group (id 含 `next` 或文案「下一步」, 实心主色) on the **右侧** (container `justify-end`); everything else goes to the right-edge floating menu. The default 上一步 button (`rwfx-prev`, `url`, target = `DASHBOARD_AGENT_ROUTE` `/page/strategy/dashboard-agent`, `appendTaskId` + `idParam=taskId` → `?taskId=任务id`, `openMode=self`) 默认指向**应用内**「大屏绘制」页,在 **both** 单智能体 (`TaskFxWorkspace`) 与 多智能体会商 (`RoundtablePlanningPage` 深链 rwfx-A) 都显示(共用 `TaskJumpBar`)。**`runTaskButton` 的 `url` 类型支持应用内路由**:target 以单个 `/` 开头时按内部路由处理——`self` 走 react-router 同窗导航(不刷新),`blank` 拼成当前源的 hash 路由新标签打开;外链行为不变。 The old hardcoded jump buttons were migrated out: `TaskFxWorkspace` no longer hardcodes 切换到三情分析, `TaskActionsPanel` keeps only the non-jump 清除记录, and `TaskPurposePanel` dropped its 3q详情 button (now configurable) **and defaults the 目的树 table to collapsed** (chevron toggle). Helpers `core/auth/task-buttons.ts` (`TaskButton`, `defaultTaskButtons`, `resolveTaskButtons`, `buttonsForBusiness`, `runTaskButton`), client+hooks `strategy-components/api/task-buttons.ts`. Backend: `/api/task-buttons` (global/shared; open read, admin replace-all), store `deerflow.persistence.task_buttons` (table `task_buttons`, auto-created by `create_all`), wired `app.state.task_button_store`. Tests: `tests/test_task_buttons.py`. diff --git a/frontend-web/src/pages/LoginWhitelistPage.tsx b/frontend-web/src/pages/LoginWhitelistPage.tsx index b5851d2a..bebb066a 100644 --- a/frontend-web/src/pages/LoginWhitelistPage.tsx +++ b/frontend-web/src/pages/LoginWhitelistPage.tsx @@ -14,11 +14,11 @@ import { } from "@/strategy-components/api/login-whitelist"; /** - * 登录白名单管理(管理员)。 + * 登录白名单管理(管理员)—— 黑名单模式。 * - * 顶部一个「总开关」:关 = 所有人可登录;开 = 仅已放行用户(或 admin)可登录。 - * 下方列出全部注册用户,逐个放行 / 取消放行。用户仍可自助注册,注册后默认 - * 「待审批」,开关打开时被拦在登录外,直到管理员在此放行。 + * 顶部一个「总开关」:关 = 所有人可登录;开 = 仅被管理员「取消放行」的用户被拦。 + * 新账号(含从未登录过的用户)默认即已放行,管理员在下方对个别用户「取消放行」 + * 来封禁;admin 始终可登录,不受开关与放行状态影响。 */ export default function LoginWhitelistPage() { const isAdmin = getStoredAuth()?.system_role === "admin"; @@ -89,7 +89,7 @@ export default function LoginWhitelistPage() {
登录白名单
-
{enabled ? "已启用:仅已放行用户可登录" : "已停用:所有人可登录"}
+
{enabled ? "已启用:仅被取消放行的用户被拦" : "已停用:所有人可登录"}
- 用户仍可自助注册,注册后默认 待审批。开关关闭时所有人可正常登录使用; - 开启后仅已放行的用户可登录,其余被拦在登录外,请在下方逐个放行。 + 黑名单模式:新账号(含从未登录过的用户)默认已放行,可直接登录使用。开关关闭时所有人可正常登录; + 开启后仅被管理员取消放行的用户被拦在登录外,请在下方对要封禁的用户取消放行。 管理员(admin)始终可登录,不受开关与放行状态影响。

@@ -161,8 +161,8 @@ export default function LoginWhitelistPage() { 已放行 ) : ( - - 待审批 + + 已拦截 )}
diff --git a/offline-backend-20260512/backend/app/gateway/auth/local_provider.py b/offline-backend-20260512/backend/app/gateway/auth/local_provider.py index 30cb55d1..bd4fa3ff 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/local_provider.py +++ b/offline-backend-20260512/backend/app/gateway/auth/local_provider.py @@ -79,7 +79,7 @@ class LocalAuthProvider(AuthProvider): """ return await self._repo.find_users_by_email_local_part_like(substring, limit=limit) - async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False, approved: bool = False) -> User: + async def create_user(self, email: str, password: str | None = None, system_role: str = "user", needs_setup: bool = False, approved: bool = True) -> User: """Create a new local user. Args: @@ -87,8 +87,10 @@ class LocalAuthProvider(AuthProvider): password: Plain text password (will be hashed) system_role: Role to assign ("admin" or "user") needs_setup: If True, user must complete setup on first login - approved: Login-whitelist 放行标记. Regular self-registered users - default to ``False`` (待审批); admins are created ``True``. + approved: Login-whitelist 放行标记. **黑名单模式**:新账号默认 ``True`` + (从未登录过的用户首次登录即放行),管理员可在白名单管理页对个别 + 用户「取消放行」(置 ``False``) 来封禁。仅当 ``login_whitelist.enabled`` + 打开时 ``approved=False`` 才会被拦。 Returns: Created User instance diff --git a/offline-backend-20260512/backend/app/gateway/auth/models.py b/offline-backend-20260512/backend/app/gateway/auth/models.py index 8484df23..4ec12d0c 100644 --- a/offline-backend-20260512/backend/app/gateway/auth/models.py +++ b/offline-backend-20260512/backend/app/gateway/auth/models.py @@ -30,7 +30,7 @@ class User(BaseModel): # Auth lifecycle needs_setup: bool = Field(default=False, description="True for auto-created admin until setup completes") token_version: int = Field(default=0, description="Incremented on password change to invalidate old JWTs") - approved: bool = Field(default=False, description="登录白名单放行标记;仅当系统设置 login_whitelist.enabled 打开时拦截未放行用户。新普通用户默认待审批(False)。") + approved: bool = Field(default=True, description="登录白名单放行标记(黑名单模式);仅当系统设置 login_whitelist.enabled 打开时拦截被取消放行的用户。新账号默认放行(True),管理员可「取消放行」(False)封禁个别用户。") class UserResponse(BaseModel): diff --git a/offline-backend-20260512/backend/app/gateway/routers/auth.py b/offline-backend-20260512/backend/app/gateway/routers/auth.py index d0c63079..943dd017 100644 --- a/offline-backend-20260512/backend/app/gateway/routers/auth.py +++ b/offline-backend-20260512/backend/app/gateway/routers/auth.py @@ -256,9 +256,9 @@ async def _get_or_create_user_by_username(username: str): if user is None: try: role = "admin" if username == "admin" else "user" - # admin 创建即放行;普通用户自助注册后默认「待审批」(approved=False), - # 需管理员在白名单管理页放行——但仅当登录白名单开关打开时才会被拦。 - user = await provider.create_user(email=email, password=None, system_role=role, needs_setup=False, approved=(role == "admin")) + # 黑名单模式:从未登录过的新用户(此处即首次自动注册)默认放行(approved=True)。 + # 管理员可在白名单管理页对个别用户「取消放行」来封禁——仅当登录白名单开关打开时生效。 + user = await provider.create_user(email=email, password=None, system_role=role, needs_setup=False, approved=True) created = True logger.info("Auto-registered passwordless user: %s", email) except ValueError: @@ -280,16 +280,19 @@ async def _get_or_create_user_by_username(username: str): def enforce_user_approved(user) -> None: - """登录白名单拦截:开关打开且用户未放行时拒绝登录(注册照常,只是不发 token)。 + """登录白名单拦截(**黑名单模式**):开关打开且用户被「取消放行」时拒绝登录。 + + 新账号首次登录即默认放行(``approved=True``,见 ``_get_or_create_user_by_username`` + 与 ``create_user``),所以从未在本系统登录过的用户可以直接使用;白名单开关只用来 + 封禁管理员显式「取消放行」(``approved=False``) 的个别用户。 规则(三条硬约束防锁死): - 开关关(``login_whitelist.enabled`` 默认 False)→ 直接放行,人人可登录。 - admin → 永远放行(避免管理员把自己关在门外)。 - - 其余:``approved`` 为 False(注册后待审批)→ 403 ``PENDING_APPROVAL``; + - 其余:``approved`` 为 False(被管理员取消放行)→ 403 ``PENDING_APPROVAL``; 前端据此提示「等待管理员开通」,且不写任何登录态。 - 放在各登录端点拿到 user 之后、签发 JWT 之前调用——账号此时已自助注册完成, - 被拦只是拿不到 token,管理员可在白名单管理页看到该待审批账号并放行。 + 放在各登录端点拿到 user 之后、签发 JWT 之前调用。 """ try: enabled = load_system_settings().login_whitelist.enabled @@ -304,7 +307,7 @@ def enforce_user_approved(user) -> None: if not getattr(user, "approved", False): raise HTTPException( status_code=status.HTTP_403_FORBIDDEN, - detail={"code": "PENDING_APPROVAL", "message": "您的账号正在等待管理员开通,请联系管理员"}, + detail={"code": "PENDING_APPROVAL", "message": "您的账号已被限制登录,请联系管理员开通"}, ) diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py index 052c4be4..b92fac9b 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/config/system_settings.py @@ -224,9 +224,10 @@ def set_module_tag_ids( class LoginWhitelistSettings(BaseModel): """登录白名单总开关(管理员可在「白名单管理」页切换,即时生效、免重启)。 - ``enabled`` 关(默认) → 忽略每个用户的 ``approved`` 放行标记,人人可登录; - 开 → 仅 ``approved=True`` 或 admin 可登录,其余用户注册后处于「待审批」, - 被管理员放行前一律 403。空/默认即关,故部署后行为零变化。 + 黑名单模式:``enabled`` 关(默认) → 忽略每个用户的 ``approved`` 放行标记, + 人人可登录;开 → 仅被管理员显式「取消放行」(``approved=False``) 的用户被 403, + 新账号默认 ``approved=True`` 故从未登录过的用户照样放行,admin 永远放行。 + 空/默认即关,故部署后行为零变化。 """ enabled: bool = Field(default=False, description="是否启用登录白名单;关=所有人可登录,开=仅已放行用户(或 admin)可登录") diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_02_users_approved.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_02_users_approved.py index 989fc130..f1046aba 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_02_users_approved.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/migrations/versions/20260625_02_users_approved.py @@ -1,8 +1,10 @@ """Add users.approved (登录白名单放行标记). -Backs the login-whitelist feature: when system setting ``login_whitelist.enabled`` -is on, only ``approved=True`` users (or admins) may log in; self-registered -regular users default to ``approved=False`` (待审批) until an admin approves them. +Backs the login-whitelist feature (黑名单模式): when system setting +``login_whitelist.enabled`` is on, only users an admin has explicitly +「取消放行」 (``approved=False``) are blocked; everyone else — including brand-new +accounts that have never logged in (which default to ``approved=True``) — may log +in. Admins always pass. Existing rows are backfilled to TRUE (``server_default="1"``) so flipping the switch on for the first time never locks out users who already had accounts. diff --git a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py index ff90ee6e..858be711 100644 --- a/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py +++ b/offline-backend-20260512/backend/packages/harness/deerflow/persistence/user/model.py @@ -49,12 +49,12 @@ class UserRow(Base): needs_setup: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) token_version: Mapped[int] = mapped_column(nullable=False, default=0) - # 登录白名单(放行)标记。仅当系统设置 ``login_whitelist.enabled`` 打开时生效: - # 关 → 忽略此列,人人可登录;开 → 仅 approved=True(或 admin)可登录,其余 - # 注册后处于「待审批」直到管理员放行。新注册的普通用户默认 False(待审批), - # admin 创建即 True;存量用户由迁移回填 True(避免首次打开开关时把老用户锁死)。 - # server_default=1 让迁移 ADD COLUMN 时把已存在行置为已放行。 - approved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False, server_default=text("1")) + # 登录白名单(放行)标记 —— **黑名单模式**。仅当系统设置 ``login_whitelist.enabled`` + # 打开时生效:关 → 忽略此列,人人可登录;开 → 仅 approved=False(被管理员显式 + # 「取消放行」)的用户被拦,其余(含从未登录过的新账号)放行。新账号默认 True + # (首次登录即放行);存量用户由迁移回填 True。server_default=1 让迁移 ADD COLUMN + # 时把已存在行置为已放行。 + approved: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True, server_default=text("1")) # 岗位 (position) the user is assigned to (1:1). NULL = no explicit # position → the user inherits the ``is_default`` position's grants. Drives diff --git a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql index 75439e15..0cece631 100644 --- a/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql +++ b/offline-backend-20260512/backend/scripts/sql/column_additions_mysql.sql @@ -117,11 +117,11 @@ CALL _add_column_if_missing('knowledge_notes', 'folder', 'VARCHAR(512) NULL DEFA -- ORM: RoundtableJobRow.task_id = String(128), nullable, indexed。 CALL _add_column_if_missing('roundtable_jobs', 'task_id', 'VARCHAR(128) NULL DEFAULT NULL'); --- ---- users.approved: 登录白名单放行标记,对应 migration 20260625_02 ----------- --- 系统设置 login_whitelist.enabled 打开后,仅 approved=1(或 admin)可登录;普通用户 --- 自助注册后默认 0(待审批),管理员在白名单管理页放行。DEFAULT 1 让存量用户(执行本 --- 脚本补列时)一并置为已放行,避免首次打开开关把老用户锁死。 --- ORM: UserRow.approved = Boolean, NOT NULL, server_default '1'。 +-- ---- users.approved: 登录白名单放行标记(黑名单模式),对应 migration 20260625_02 ---- +-- 系统设置 login_whitelist.enabled 打开后,仅被管理员显式「取消放行」(approved=0) 的 +-- 用户被拦;新账号(含从未登录过的用户)默认 1(已放行),admin 永远放行。DEFAULT 1 让 +-- 存量用户(执行本脚本补列时)一并置为已放行,避免首次打开开关把老用户锁死。 +-- ORM: UserRow.approved = Boolean, NOT NULL, default True, server_default '1'。 CALL _add_column_if_missing('users', 'approved', 'TINYINT(1) NOT NULL DEFAULT 1'); -- ---- 清理临时存储过程 --------------------------------------------------------