diff --git a/AGENTS.md b/AGENTS.md index 13623397d..e7c6f5f46 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -34,6 +34,8 @@ BiSheng (毕昇) v2.5.0-dev — 面向企业的开源 LLM 应用 DevOps 平台 **关键原则**: 本地与 Docker 绝不运行同类服务,避免端口冲突。 +**Docker 仅中间件 + 本机 bisheng / Gateway / 双前端**:一键脚本与端口对齐说明见 [`docker/local-dev/README.md`](docker/local-dev/README.md)。 + ## 开发命令 ### 环境准备 @@ -49,12 +51,15 @@ uv sync --frozen --python /path/to/python ### 启动服务 ```bash -# 1. Docker 存储服务 (远程服务器) -cd docker && docker compose -p bisheng up -d -# 停止与本地冲突的容器 -docker stop bisheng-mysql bisheng-redis bisheng-backend bisheng-backend-worker bisheng-frontend +# 1a. 推荐:仅 Docker 中间件(本机跑 bisheng / 前端 / Gateway 时用) +# Windows: powershell -ExecutionPolicy Bypass -File docker/local-dev/start-middleware.ps1 +# Linux/macOS: bash docker/local-dev/start-middleware.sh -# 2. 后端 API (端口 7860) +# 1b. 或:全量 compose 后再手动停掉与本地冲突的容器 +cd docker && docker compose -p bisheng up -d +docker stop bisheng-backend bisheng-backend-worker bisheng-frontend + +# 2. 后端 API (端口 7860;config 须为相对 bisheng 包目录的文件名,例如 export config=config.yaml) cd src/backend .venv/bin/uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --workers 1 --no-access-log @@ -76,8 +81,11 @@ cd src/backend cd /opt/bisheng-gateway mvn clean package -DskipTests -# 启动 (端口 8180,避免与 OpenFGA 8080 冲突) -java -jar target/gateway-0.0.1-SNAPSHOT.jar --spring.profiles.active=dev --server.port=8180 +# 启动 (端口 8180,避免与 OpenFGA 8080 冲突;本地连 Docker 中间件用 local 配置) +java -jar target/gateway-0.0.1-SNAPSHOT.jar --spring.profiles.active=local --server.port=8180 + +# 组织同步到 bisheng:当前 bisheng 已无 /api/v2/group/sync;Gateway 通过 F014 HMAC 调 bisheng +# POST /api/v1/departments/sync。联调入口见 docker/local-dev/README.md §6(含一键脚本与冒烟命令)。 # 启用商业版功能需设置后端环境变量 export BISHENG_PRO=true # 在启动后端前设置,开启 /api/v1/user/sso 端点 diff --git a/docker/bisheng/config/config.yaml b/docker/bisheng/config/config.yaml index 5dd662e9b..832d6277b 100644 --- a/docker/bisheng/config/config.yaml +++ b/docker/bisheng/config/config.yaml @@ -8,6 +8,16 @@ database_url: # 普通模式: redis_url: "redis://redis:6379/1" +# 与 docker-compose 中 openfga 服务对应(容器内网络) +openfga: + enabled: true + api_url: "http://openfga:8080" + +# 网关 HMAC 推送验签(与 bisheng-gateway 的 bisheng.gateway-hmac-secret 一致) +sso_sync: + gateway_hmac_secret: "bisheng-local-hmac-20260422" + signature_header: "X-Signature" + # 集群模式或者哨兵模式(只能选其一): # redis_url: # mode: "cluster" @@ -20,11 +30,6 @@ redis_url: "redis://redis:6379/1" # sentinel_master: "mymaster" # sentinel_password: encrypt(gAAAAABlp4b4c59FeVGF_OQRVf6NOUIGdxq8246EBD-b0hdK_jVKRs1x4PoAn0A6C5S6IiFKmWn0Nm5eBUWu-7jxcqw6TiVjQA==) # db: 1 -openfga: - # openfga的broken地址 - enabled: true - api_url: "http://openfga:8080" - # celery的broken地址 celery_redis_url: "redis://redis:6379/2" celery_task: diff --git a/docker/bisheng/entrypoint.sh b/docker/bisheng/entrypoint.sh index 82082fcfb..46af12b9c 100644 --- a/docker/bisheng/entrypoint.sh +++ b/docker/bisheng/entrypoint.sh @@ -30,8 +30,10 @@ start_default(){ } if [ "$start_mode" = "api" ]; then + echo "Running database migrations..." + alembic upgrade head || echo "WARNING: alembic migration failed, continuing startup..." echo "Starting API server..." - uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --no-access-log --workers 8 + uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --no-access-log --workers 1 elif [ "$start_mode" = "knowledge" ]; then echo "Starting Knowledge Celery worker..." start_knowledge diff --git a/docker/docker-compose.yml b/docker/docker-compose.yml index 4cd56e71b..765dfac7f 100644 --- a/docker/docker-compose.yml +++ b/docker/docker-compose.yml @@ -71,11 +71,12 @@ services: backend: container_name: bisheng-backend - image: dataelement/bisheng-backend:v2.4.0 + image: dataelement/bisheng-backend:v2.5-local ports: - "7860:7860" environment: TZ: Asia/Shanghai + BS_SSO_SYNC__GATEWAY_HMAC_SECRET: "bisheng-local-hmac-20260422" BS_MILVUS_CONNECTION_ARGS: '{"host":"milvus","port":"19530","user":"","password":"","secure":false}' BS_MILVUS_IS_PARTITION: 'true' BS_MILVUS_PARTITION_SUFFIX: '1' @@ -91,6 +92,11 @@ services: - ${DOCKER_VOLUME_DIRECTORY:-.}/bisheng/config/config.yaml:/app/bisheng/config.yaml - ${DOCKER_VOLUME_DIRECTORY:-.}/bisheng/entrypoint.sh:/app/entrypoint.sh - ${DOCKER_VOLUME_DIRECTORY:-.}/data/bisheng:/app/data + # 开发/热修:挂载本地 endpoints,使 GET /api/v1/all 等兼容路由无需重打镜像 + - ../src/backend/bisheng/api/v1/endpoints.py:/app/bisheng/api/v1/endpoints.py:ro + # Bearer + 禁用账号校验在 http_middleware;镜像默认只读 Cookie,不挂则 Platform 永不被踢下线 + - ../src/backend/bisheng/utils/http_middleware.py:/app/bisheng/utils/http_middleware.py:ro + - ../src/backend/bisheng/common/middleware/admin_scope.py:/app/bisheng/common/middleware/admin_scope.py:ro security_opt: - seccomp:unconfined command: sh entrypoint.sh api # 启动api服务 @@ -106,14 +112,16 @@ services: condition: service_healthy redis: condition: service_healthy + # openfga 官方镜像无 HEALTHCHECK,Compose v5 无法用 service_healthy openfga: - condition: service_healthy + condition: service_started backend_worker: container_name: bisheng-backend-worker - image: dataelement/bisheng-backend:v2.4.0 + image: dataelement/bisheng-backend:v2.5-local environment: TZ: Asia/Shanghai + BS_SSO_SYNC__GATEWAY_HMAC_SECRET: "bisheng-local-hmac-20260422" BS_MILVUS_CONNECTION_ARGS: '{"host":"milvus","port":"19530","user":"","password":"","secure":false}' BS_MILVUS_IS_PARTITION: 'true' BS_MILVUS_PARTITION_SUFFIX: '1' @@ -139,11 +147,12 @@ services: redis: condition: service_healthy openfga: - condition: service_healthy + condition: service_started frontend: container_name: bisheng-frontend - image: dataelement/bisheng-frontend:v2.4.0 + # 注意:此处为 Hub 的 latest,未必与自建镜像 backend:v2.5-local 同版本;界面偏旧请 pull 最新或按源码 build(见 AGENTS / 部署文档) + image: dataelement/bisheng-frontend:latest ports: - "3001:3001" environment: diff --git a/docker/local-dev/init-gateway-db.sql b/docker/local-dev/init-gateway-db.sql new file mode 100644 index 000000000..2f1ca0f08 --- /dev/null +++ b/docker/local-dev/init-gateway-db.sql @@ -0,0 +1,51 @@ +CREATE DATABASE IF NOT EXISTS `bisheng_gateway` DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; + +USE `bisheng_gateway`; + +CREATE TABLE IF NOT EXISTS `gt_group_resource` +( + `id` int auto_increment primary key, + `group_id` int not null, + `resource_id` varchar(256) not null, + `resource_limit` int default 0 not null, + `resource_type` tinyint not null +); + +CREATE TABLE IF NOT EXISTS `gt_sensitive_words` +( + `id` int auto_increment primary key, + `resource_id` varchar(256) not null, + `resource_type` tinyint null, + `auto_words` text null, + `words` text null, + `words_types` varchar(32) null, + `is_check` tinyint default 0 not null, + `create_time` datetime not null, + `update_time` datetime not null, + `logic_delete` tinyint default 0 not null, + `auto_reply` varchar(128) null +); + +CREATE TABLE IF NOT EXISTS `gt_user_group` +( + `id` int auto_increment primary key, + `group_name` varchar(256) not null, + `admin_user` varchar(512) null, + `admin_user_id` varchar(512) null, + `group_limit` int default 0 not null, + `create_time` datetime not null, + `update_time` datetime not null, + `logic_delete` tinyint default 0 not null +); + +CREATE TABLE IF NOT EXISTS `gt_block_record` +( + `id` int auto_increment primary key, + `chat_id` varchar(64) null, + `user_input` text null, + `system_out` text null, + `block_words` text null, + `create_time` datetime not null, + `update_time` datetime not null, + `resource_id` varchar(64) null +); diff --git a/docker/local-dev/reset-org-to-superadmin.ps1 b/docker/local-dev/reset-org-to-superadmin.ps1 new file mode 100644 index 000000000..3c50a3467 --- /dev/null +++ b/docker/local-dev/reset-org-to-superadmin.ps1 @@ -0,0 +1,32 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + 在 Docker 容器 bisheng-mysql 中对库 bisheng 执行 reset-org-to-superadmin.sql。 + +.PARAMETER Container + MySQL 容器名,默认 bisheng-mysql。 + +.PARAMETER Password + root 密码,默认 1234(与本地 docker-compose 一致)。 +#> +param( + [string] $Container = "bisheng-mysql", + [string] $Password = "1234" +) + +$ErrorActionPreference = "Stop" +$root = Split-Path -Parent $PSScriptRoot +$sql = Join-Path $PSScriptRoot "reset-org-to-superadmin.sql" + +if (-not (Test-Path $sql)) { + Write-Error "Missing SQL file: $sql" +} + +Write-Host "Running reset on container '$Container' database bisheng ..." -ForegroundColor Cyan +Get-Content -LiteralPath $sql -Encoding UTF8 | docker exec -i $Container mysql -uroot "-p$Password" bisheng + +if ($LASTEXITCODE -ne 0) { + Write-Error "mysql exited with code $LASTEXITCODE" +} + +Write-Host "Done. Check kept_user_id / remaining_users in mysql output above." -ForegroundColor Green diff --git a/docker/local-dev/reset-org-to-superadmin.sql b/docker/local-dev/reset-org-to-superadmin.sql new file mode 100644 index 000000000..9c04dc7e6 --- /dev/null +++ b/docker/local-dev/reset-org-to-superadmin.sql @@ -0,0 +1,69 @@ +-- ============================================================================= +-- bisheng 库:清空「组织同步 + 部门 + 成员」相关数据,仅保留一名超级管理员 +-- +-- 保留用户规则(与产品约定一致): +-- 1) 优先保留「已绑定 role_id = 1(AdminRole)」中 user_id 最小的一名用户; +-- 2) 若无人拥有 role_id=1,则保留 user 表中 user_id 最小的一行(兜底)。 +-- +-- 会删除 / 清空: +-- org_sync_log / org_sync_config、department*、user_department、 +-- 除保留用户外的 user 及依赖外键的子表(channel、linsight_*、usergroup、userrole)、 +-- user_link(非保留用户)、user_tenant(全表后仅为保留用户重建一条根租户记录)、invitecode 全表。 +-- +-- 不会动:tenant、role、知识库 flow 等业务主数据(仅去掉部门挂载关系)。 +-- +-- 执行前务必备份: +-- docker exec bisheng-mysql mysqldump -uroot -p1234 bisheng > backup-bisheng.sql +-- +-- 执行示例(仓库根目录): +-- docker exec -i bisheng-mysql mysql -uroot -p1234 bisheng < docker/local-dev/reset-org-to-superadmin.sql +-- +-- OpenFGA / Redis 中的权限元组或 org_sync 锁不会由此脚本清理;若你启用了 FGA, +-- 清理后请在文档环境执行一次全量同步或按运维流程重建元组。 +-- ============================================================================= + +SET NAMES utf8mb4; +SET @keep := ( + SELECT COALESCE( + (SELECT MIN(ur.user_id) FROM userrole ur WHERE ur.role_id = 1), + (SELECT MIN(u.user_id) FROM `user` u) + ) +); + +-- 空库保护:没有用户则只清组织表,不删 user +DELETE FROM org_sync_log; +DELETE FROM org_sync_config; + +DELETE FROM department_knowledge_space; +DELETE FROM user_department; +DELETE FROM `department`; + +DELETE FROM invitecode; + +DELETE FROM user_link WHERE @keep IS NOT NULL AND user_id <> @keep; +DELETE FROM user_tenant WHERE @keep IS NOT NULL; + +DELETE FROM channel WHERE @keep IS NOT NULL AND user_id <> @keep; +DELETE FROM linsight_session_version WHERE @keep IS NOT NULL AND user_id <> @keep; +DELETE FROM linsight_sop WHERE @keep IS NOT NULL AND user_id <> @keep; +DELETE FROM linsight_sop_record WHERE @keep IS NOT NULL AND user_id <> @keep; + +DELETE FROM usergroup WHERE @keep IS NOT NULL AND user_id <> @keep; +DELETE FROM userrole WHERE @keep IS NOT NULL AND user_id <> @keep; + +DELETE FROM `user` WHERE @keep IS NOT NULL AND user_id <> @keep; + +INSERT INTO user_tenant (user_id, tenant_id, is_default, status, is_active) +SELECT @keep, 1, 1, 'active', 1 +FROM DUAL +WHERE @keep IS NOT NULL; + +DELETE FROM userrole WHERE @keep IS NOT NULL AND user_id = @keep AND role_id <> 1; + +INSERT INTO userrole (user_id, role_id, tenant_id) +SELECT @keep, 1, 1 +FROM DUAL +WHERE @keep IS NOT NULL + AND NOT EXISTS (SELECT 1 FROM userrole ur WHERE ur.user_id = @keep AND ur.role_id = 1); + +SELECT @keep AS kept_user_id, (SELECT COUNT(*) FROM `user`) AS remaining_users; diff --git a/docker/local-dev/start-backend-sso.ps1 b/docker/local-dev/start-backend-sso.ps1 new file mode 100644 index 000000000..73d1789c8 --- /dev/null +++ b/docker/local-dev/start-backend-sso.ps1 @@ -0,0 +1,8 @@ +# 在 bisheng 仓库根目录执行:本机 bisheng API + 开启 SSO(BISHENG_PRO) +$ErrorActionPreference = "Stop" +$backend = Join-Path (Split-Path (Split-Path $PSScriptRoot -Parent) -Parent) "src\backend" +Set-Location $backend +$env:config = "config.yaml" +$env:BISHENG_PRO = "true" +$env:BS_SSO_SYNC__GATEWAY_HMAC_SECRET = "bisheng-local-hmac-20260422" +& ".\.venv\Scripts\python.exe" -m uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --workers 1 --no-access-log diff --git a/docker/local-dev/start-full-stack.ps1 b/docker/local-dev/start-full-stack.ps1 new file mode 100644 index 000000000..66f068878 --- /dev/null +++ b/docker/local-dev/start-full-stack.ps1 @@ -0,0 +1,65 @@ +# 一键:Docker 中间件 + bisheng API (BISHENG_PRO) + Celery worker + Gateway(各开新窗口) +# 在 bisheng 仓库根目录执行: powershell -ExecutionPolicy Bypass -File docker/local-dev/start-full-stack.ps1 +$ErrorActionPreference = "Stop" +$repoRoot = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$env:Path = [System.Environment]::GetEnvironmentVariable("Path","Machine") + ";" + [System.Environment]::GetEnvironmentVariable("Path","User") +$mvnBin = "$env:USERPROFILE\tools\apache-maven-3.9.9\bin" +if (Test-Path $mvnBin) { $env:Path = "$mvnBin;$env:Path" } + +$backend = Join-Path $repoRoot "src\backend" +$gateway = Join-Path (Split-Path $repoRoot -Parent) "bisheng-gateway" +if (-not (Test-Path (Join-Path $gateway "pom.xml"))) { + $gateway = Join-Path $repoRoot "..\bisheng-gateway" +} + +$javaExe = "java" +try { $javaExe = (Get-Command java -ErrorAction Stop).Source } catch { } + +Write-Host "==> Middleware" +& "$PSScriptRoot\start-middleware.ps1" + +if (Test-Path (Join-Path $gateway "pom.xml")) { + Write-Host "==> Maven package gateway" + Push-Location $gateway + mvn -q -DskipTests package + Pop-Location +} + +$py = Join-Path $backend ".venv\Scripts\python.exe" +if (-not (Test-Path $py)) { + Write-Error "Missing $py — run: cd src/backend ; uv sync" +} + +# 子进程内自行设置 $env:…(外层用反引号避免提前展开) +$apiCmd = "& { Set-Location '$backend'; `$env:config='config.yaml'; `$env:BISHENG_PRO='true'; `$env:BS_SSO_SYNC__GATEWAY_HMAC_SECRET='bisheng-local-hmac-20260422'; & '$py' -m uvicorn bisheng.main:app --host 0.0.0.0 --port 7860 --workers 1 --no-access-log }" +Write-Host "==> Start bisheng API (new window)" +Start-Process powershell -ArgumentList "-NoExit", "-Command", $apiCmd -WindowStyle Normal + +Start-Sleep -Seconds 10 + +$celeryCmd = "& { Set-Location '$backend'; `$env:config='config.yaml'; `$env:BISHENG_PRO='true'; `$env:BS_SSO_SYNC__GATEWAY_HMAC_SECRET='bisheng-local-hmac-20260422'; & '$py' -m celery -A bisheng.worker.main worker -l info -c 4 -P threads -Q knowledge_celery,workflow_celery,celery -n dev@%COMPUTERNAME% }" +Write-Host "==> Start Celery worker (new window)" +Start-Process powershell -ArgumentList "-NoExit", "-Command", $celeryCmd -WindowStyle Normal + +Start-Sleep -Seconds 3 + +$jar = Join-Path $gateway "target\gateway-0.0.1-SNAPSHOT.jar" +if (Test-Path $jar) { + $gwCmd = "& { Set-Location '$gateway'; & '$javaExe' -jar '.\target\gateway-0.0.1-SNAPSHOT.jar' --spring.profiles.active=local --server.port=8180 }" + Write-Host "==> Start Gateway (new window)" + Start-Process powershell -ArgumentList "-NoExit", "-Command", $gwCmd -WindowStyle Normal +} else { + Write-Warning "Gateway jar not found: $jar" +} + +Start-Sleep -Seconds 15 + +Write-Host "==> HMAC smoke (via Gateway :8180)" +& $py (Join-Path $repoRoot "scripts\dev\gateway_hmac_org_sync_smoke.py") --base http://127.0.0.1:8180 + +Write-Host "" +Write-Host "bisheng: http://127.0.0.1:7860/health" +Write-Host "Gateway: http://127.0.0.1:8180/api/oauth2/list" +Write-Host "企业微信部门树 -> bisheng (F014): GET http://127.0.0.1:8180/api/group/test" +Write-Host "自定义 JSON -> bisheng: POST http://127.0.0.1:8180/api/group/sso-departments-raw" +Write-Host "Python 自签: & '$py' '$repoRoot\scripts\dev\gateway_hmac_org_sync_smoke.py' --base http://127.0.0.1:8180" diff --git a/docker/local-dev/start-middleware.ps1 b/docker/local-dev/start-middleware.ps1 new file mode 100644 index 000000000..eb1a12945 --- /dev/null +++ b/docker/local-dev/start-middleware.ps1 @@ -0,0 +1,18 @@ +# 仅启动 Docker 中间件(不启 bisheng-backend / worker / frontend 容器) +# 在仓库根目录执行: powershell -File docker/local-dev/start-middleware.ps1 +$ErrorActionPreference = "Stop" +$root = Split-Path (Split-Path $PSScriptRoot -Parent) -Parent +$dockerDir = Join-Path $root "docker" +Set-Location $dockerDir + +Write-Host "Stopping bisheng app containers if present..." +docker stop bisheng-backend bisheng-backend-worker bisheng-frontend 2>$null + +Write-Host "Starting middleware (mysql -> openfga -> redis -> es -> milvus stack)..." +docker compose -f docker-compose.yml -p bisheng up -d mysql openfga-migrate openfga redis elasticsearch etcd minio milvus + +Write-Host "Initializing bisheng_gateway schema (idempotent)..." +$sqlPath = Join-Path $PSScriptRoot "init-gateway-db.sql" +Get-Content -Raw $sqlPath | docker exec -i bisheng-mysql mysql -uroot -p1234 + +Write-Host "Done. MySQL :3306, Redis :6379, OpenFGA :8080, ES :9200, MinIO :9100, Milvus :19530" diff --git a/docker/local-dev/start-middleware.sh b/docker/local-dev/start-middleware.sh new file mode 100644 index 000000000..4f6e219b2 --- /dev/null +++ b/docker/local-dev/start-middleware.sh @@ -0,0 +1,15 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/../.." && pwd)" +cd "$ROOT/docker" + +echo "Stopping bisheng app containers if present..." +docker stop bisheng-backend bisheng-backend-worker bisheng-frontend 2>/dev/null || true + +echo "Starting middleware..." +docker compose -f docker-compose.yml -p bisheng up -d mysql openfga-migrate openfga redis elasticsearch etcd minio milvus + +echo "Initializing bisheng_gateway schema (idempotent)..." +docker exec -i bisheng-mysql mysql -uroot -p1234 < "$ROOT/docker/local-dev/init-gateway-db.sql" + +echo "Done." diff --git a/scripts/dev/gateway_hmac_org_sync_smoke.py b/scripts/dev/gateway_hmac_org_sync_smoke.py new file mode 100644 index 000000000..6a38f448e --- /dev/null +++ b/scripts/dev/gateway_hmac_org_sync_smoke.py @@ -0,0 +1,79 @@ +#!/usr/bin/env python3 +"""Smoke test: F014 POST /api/v1/departments/sync with HMAC (via Gateway or direct bisheng). + +Examples: + python scripts/dev/gateway_hmac_org_sync_smoke.py --base http://127.0.0.1:8180 + python scripts/dev/gateway_hmac_org_sync_smoke.py --base http://127.0.0.1:7860 +""" +from __future__ import annotations + +import argparse +import hashlib +import hmac +import json +import sys +import urllib.error +import urllib.request + + +def sign(method: str, path: str, raw_body: bytes, secret: str) -> str: + msg = f"{method.upper()}\n{path}\n".encode("utf-8") + (raw_body or b"") + return hmac.new(secret.encode("utf-8"), msg, hashlib.sha256).hexdigest() + + +def main() -> int: + p = argparse.ArgumentParser() + p.add_argument("--base", default="http://127.0.0.1:8180", help="Gateway or bisheng base URL") + p.add_argument( + "--secret", + default="bisheng-local-hmac-20260422", + help="Must match bisheng sso_sync.gateway_hmac_secret", + ) + p.add_argument("--path", default="/api/v1/departments/sync") + args = p.parse_args() + + base = args.base.rstrip("/") + path = args.path + if not path.startswith("/"): + path = "/" + path + + ts = 1_710_000_000 + body_obj = { + "upsert": [ + { + "external_id": "smoke-dept-1", + "name": "HMAC Smoke Dept", + "parent_external_id": None, + "sort": 0, + "ts": ts, + } + ], + "remove": [], + "source_ts": ts, + } + raw = json.dumps(body_obj, separators=(",", ":"), ensure_ascii=False).encode("utf-8") + sig = sign("POST", path, raw, args.secret) + + url = base + path + req = urllib.request.Request( + url, + data=raw, + method="POST", + headers={ + "Content-Type": "application/json; charset=utf-8", + "X-Signature": sig, + }, + ) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + out = resp.read().decode("utf-8", errors="replace") + print(resp.status, out[:2000]) + return 0 if resp.status == 200 else 1 + except urllib.error.HTTPError as e: + err = e.read().decode("utf-8", errors="replace") + print(e.code, err[:2000], file=sys.stderr) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/backend/bisheng/api/services/assistant.py b/src/backend/bisheng/api/services/assistant.py index b597d1ef9..0f76250ae 100644 --- a/src/backend/bisheng/api/services/assistant.py +++ b/src/backend/bisheng/api/services/assistant.py @@ -27,6 +27,7 @@ from bisheng.database.models.role_access import AccessType from bisheng.database.models.session import MessageSessionDao from bisheng.database.models.tag import TagDao from bisheng.knowledge.domain.models.knowledge import KnowledgeDao +from bisheng.permission.domain.workflow_app_permission import user_may_share_app from bisheng.llm.domain.services import LLMService from bisheng.share_link.domain.models.share_link import ShareLink from bisheng.tool.domain.models.gpts_tools import GptsToolsDao, GptsTools @@ -117,10 +118,12 @@ class AssistantService(BaseService, AssistantUtils): tool_list, flow_list, knowledge_list = cls.get_link_info(tool_list, flow_list, knowledge_list) assistant.logo = await cls.get_logo_share_link_async(assistant.logo) + can_share = await user_may_share_app(login_user, 'assistant', assistant_id) return AssistantInfo(**assistant.model_dump(), tool_list=tool_list, flow_list=flow_list, - knowledge_list=knowledge_list) + knowledge_list=knowledge_list, + can_share=can_share) @classmethod async def get_one_assistant(cls, assistant_id: str) -> Optional[Assistant]: @@ -181,10 +184,12 @@ class AssistantService(BaseService, AssistantUtils): if shared_children: assistant.is_shared = True + can_share = await user_may_share_app(login_user, 'assistant', str(assistant.id)) return AssistantInfo(**assistant.model_dump(), tool_list=[], flow_list=[], - knowledge_list=[]) + knowledge_list=[], + can_share=can_share) @classmethod def create_assistant_hook(cls, request: Request, assistant: Assistant, user_payload: UserPayload) -> bool: @@ -315,10 +320,12 @@ class AssistantService(BaseService, AssistantUtils): tool_list, flow_list, knowledge_list = cls.get_link_info(req.tool_list, req.flow_list, req.knowledge_list) cls.update_assistant_hook(request, login_user, assistant) + can_share = await user_may_share_app(login_user, 'assistant', str(assistant.id)) return AssistantInfo(**assistant.model_dump(), tool_list=tool_list, flow_list=flow_list, - knowledge_list=knowledge_list) + knowledge_list=knowledge_list, + can_share=can_share) @classmethod def update_assistant_hook(cls, request: Request, login_user: UserPayload, assistant: Assistant) -> bool: diff --git a/src/backend/bisheng/api/services/flow.py b/src/backend/bisheng/api/services/flow.py index d7da0a8e7..9eb148878 100644 --- a/src/backend/bisheng/api/services/flow.py +++ b/src/backend/bisheng/api/services/flow.py @@ -3,6 +3,7 @@ import copy from typing import List, Dict, AsyncGenerator, Union from fastapi import Request +from fastapi.encoders import jsonable_encoder from loguru import logger from bisheng.api.services.audit_log import AuditLogService @@ -21,6 +22,7 @@ from bisheng.database.models.flow import FlowDao, FlowStatus, Flow, FlowType from bisheng.database.models.flow_version import FlowVersionDao, FlowVersionRead, FlowVersion from bisheng.database.models.group_resource import GroupResourceDao, ResourceTypeEnum, GroupResource from bisheng.database.models.role_access import AccessType +from bisheng.permission.domain.workflow_app_permission import user_may_share_app from bisheng.database.models.session import MessageSessionDao from bisheng.database.models.user_group import UserGroupDao from bisheng.share_link.domain.models.share_link import ShareLink @@ -187,7 +189,9 @@ class FlowService(BaseService): flow_info.logo = await cls.get_logo_share_link_async(flow_info.logo) - return resp_200(data=flow_info) + payload = jsonable_encoder(flow_info) + payload['can_share'] = await user_may_share_app(login_user, 'workflow', flow_id) + return resp_200(data=payload) @classmethod async def get_compare_tasks(cls, user: UserPayload, req: FlowCompareReq) -> List: diff --git a/src/backend/bisheng/api/services/workflow.py b/src/backend/bisheng/api/services/workflow.py index db7dfb8ac..37c2fb3be 100644 --- a/src/backend/bisheng/api/services/workflow.py +++ b/src/backend/bisheng/api/services/workflow.py @@ -15,8 +15,11 @@ from bisheng.common.errcode.http_error import NotFoundError, UnAuthorizedError from bisheng.common.services import telemetry_service from bisheng.common.services.base import BaseService from bisheng.core.logger import trace_id_var -from bisheng.database.models.flow import FlowDao, FlowStatus, FlowType, Flow -from bisheng.database.models.flow import UserLinkType +from bisheng.database.models.flow import Flow, FlowDao, FlowStatus, FlowType, UserLinkType +from bisheng.permission.domain.workflow_app_permission import ( + batch_user_may_share_app, + object_type_for_flow_type, +) from bisheng.database.models.flow_version import FlowVersionDao from bisheng.database.models.group_resource import ResourceTypeEnum from bisheng.database.models.role_access import AccessType @@ -78,6 +81,36 @@ class WorkFlowService(BaseService): one['logo'] = cls.get_logo_share_link(one['logo']) return data + @classmethod + async def aenrich_apps_can_share(cls, user: UserPayload, data: list[dict], managed: bool = False) -> list[dict]: + """Set ``can_share`` from ReBAC relation-model ``share_app`` (fail-closed when unknown type).""" + if not data: + return data + if user.is_admin() or managed: + for one in data: + one['can_share'] = True + return data + entries: list[tuple[dict, Optional[str]]] = [] + pairs: list[tuple[str, str]] = [] + for one in data: + ot = object_type_for_flow_type(int(one.get('flow_type') or 0)) + entries.append((one, ot)) + if ot: + pairs.append((ot, str(one['id']))) + if not pairs: + for one, ot in entries: + one['can_share'] = False + return data + flags = await batch_user_may_share_app(user, pairs) + fi = 0 + for one, ot in entries: + if not ot: + one['can_share'] = False + else: + one['can_share'] = bool(flags[fi]) + fi += 1 + return data + @classmethod async def get_all_flows(cls, user: UserPayload, name: str, status: int, tag_id: Optional[int], flow_type: Optional[int], page: int = 1, page_size: int = 10, @@ -118,6 +151,7 @@ class WorkFlowService(BaseService): end_index = start_index + page_size data = data[start_index:end_index] data = cls.add_extra_field(user, data, managed) + data = await cls.aenrich_apps_can_share(user, data, managed) return data, total @@ -346,9 +380,9 @@ class WorkFlowService(BaseService): return workflow_event @classmethod - def get_frequently_used_flows(cls, user: UserPayload, user_link_type: str, - page: int = 1, - page_size: int = 8) -> (list[dict], int): + async def get_frequently_used_flows(cls, user: UserPayload, user_link_type: str, + page: int = 1, + page_size: int = 8) -> (list[dict], int): """ Get common skills """ @@ -384,6 +418,7 @@ class WorkFlowService(BaseService): data = data[start_index:end_index] data = cls.add_extra_field(user, data) + data = await cls.aenrich_apps_can_share(user, data) return data, total @@ -398,7 +433,7 @@ class WorkFlowService(BaseService): return is_new @classmethod - def get_uncategorized_flows( + async def get_uncategorized_flows( cls, user: UserPayload, page: int = 1, @@ -436,6 +471,8 @@ class WorkFlowService(BaseService): for one in data: one['logo'] = cls.get_logo_share_link(one['logo']) + data = await cls.aenrich_apps_can_share(user, data) + return data, total @classmethod diff --git a/src/backend/bisheng/api/v1/schemas.py b/src/backend/bisheng/api/v1/schemas.py index 2e66d363a..f3b4e7f3e 100644 --- a/src/backend/bisheng/api/v1/schemas.py +++ b/src/backend/bisheng/api/v1/schemas.py @@ -308,6 +308,7 @@ class AssistantInfo(AssistantBase): tool_list: List[GptsToolsRead] = Field(default_factory=list, description='Tools for assistantsIDVertical') flow_list: List[FlowRead] = Field(default_factory=list, description='Skills for assistantsIDVertical') knowledge_list: List[KnowledgeRead] = Field(default_factory=list, description='The knowledge base uponIDVertical') + can_share: bool = Field(default=False, description='Current user may copy app share link (relation-model share_app)') class FlowVersionCreate(BaseModel): diff --git a/src/backend/bisheng/common/middleware/admin_scope.py b/src/backend/bisheng/common/middleware/admin_scope.py index 724b177e6..674deff20 100644 --- a/src/backend/bisheng/common/middleware/admin_scope.py +++ b/src/backend/bisheng/common/middleware/admin_scope.py @@ -42,6 +42,7 @@ from bisheng.core.context.tenant import ( from bisheng.utils.http_middleware import ( _check_is_global_super, _decode_jwt_subject, + _extract_http_access_token, ) @@ -78,7 +79,7 @@ class AdminScopeMiddleware(BaseHTTPMiddleware): # JWT decode, no Redis read, no FGA check. return await call_next(request) - token = request.cookies.get('access_token_cookie') + token = _extract_http_access_token(request) if not token: return await call_next(request) diff --git a/src/backend/bisheng/permission/domain/workflow_app_permission.py b/src/backend/bisheng/permission/domain/workflow_app_permission.py new file mode 100644 index 000000000..d4afdd5e2 --- /dev/null +++ b/src/backend/bisheng/permission/domain/workflow_app_permission.py @@ -0,0 +1,250 @@ +"""Fine-grained app (workflow / assistant) permissions aligned with the platform +relation-model template (e.g. ``share_app`` requires inclusion in the grant model). + +Used by workstation / chat list payloads and flow detail APIs for UI gating. +""" + +from __future__ import annotations + +import asyncio +import logging +from typing import Dict, List, Optional, Set, Tuple + +from bisheng.database.models.department import DepartmentDao, UserDepartmentDao + +logger = logging.getLogger(__name__) + +# Mirrors ``RolesAndPermissions.tsx`` RELATION_LEVEL / MODEL_LEVEL for the 应用/工作流 module. +_RELATION_ORDER = {'can_read': 1, 'can_edit': 2, 'can_manage': 3, 'can_delete': 4} +_MODEL_ORDER = {'viewer': 1, 'editor': 2, 'manager': 3, 'owner': 4} + +# (permission_id, minimum template relation) — same ids as platform TEMPLATE_SECTIONS. +_APP_PERMISSION_DEFINITIONS: List[Tuple[str, str]] = [ + ('view_app', 'can_read'), + ('use_app', 'can_read'), + ('edit_app', 'can_edit'), + ('delete_app', 'can_delete'), + ('publish_app', 'can_manage'), + ('unpublish_app', 'can_manage'), + ('share_app', 'can_manage'), + ('manage_app_owner', 'can_manage'), + ('manage_app_manager', 'can_manage'), + ('manage_app_viewer', 'can_manage'), +] + +# get_permission_level() returns owner | can_manage | can_edit | can_read +_PERMISSION_LEVEL_TO_FG_RELATION = { + 'owner': 'owner', + 'can_manage': 'manager', + 'can_edit': 'editor', + 'can_read': 'viewer', +} + +SHARE_APP_PERMISSION_ID = 'share_app' + + +def default_app_permission_ids_for_relation(relation: str) -> Set[str]: + """Default permission ids for a built-in FGA relation (viewer/editor/manager/owner).""" + ml = _MODEL_ORDER.get(relation or '', 0) + out: Set[str] = set() + for pid, req in _APP_PERMISSION_DEFINITIONS: + if ml >= _RELATION_ORDER.get(req, 99): + out.add(pid) + return out + + +def _permission_ids_for_relation(relation: str, model: Optional[dict]) -> Set[str]: + if model is not None: + perms = model.get('permissions') or [] + if perms: + return set(perms) + if model.get('is_system'): + return default_app_permission_ids_for_relation(str(model.get('relation') or '')) + return set() + return default_app_permission_ids_for_relation(relation or '') + + +def _user_matches_binding(binding: dict, tuple_user: str, user_subject_strings: Set[str]) -> bool: + if tuple_user not in user_subject_strings: + return False + st = binding.get('subject_type') + sid = binding.get('subject_id') + expected = ( + f'user:{sid}' + if st == 'user' + else f'{st}:{sid}#member' + ) + return tuple_user == expected + + +async def _binding_department_paths(bindings: List[dict]) -> Dict[int, str]: + department_ids = { + int(b['subject_id']) + for b in bindings + if b.get('subject_type') == 'department' and b.get('include_children') + } + if not department_ids: + return {} + departments = await DepartmentDao.aget_by_ids(list(department_ids)) + return {d.id: (d.path or '') for d in departments or []} + + +async def _resolve_binding_for_tuple( + resource_type: str, + resource_id: str, + tuple_user: str, + relation: str, + bindings: List[dict], + binding_department_paths: Dict[int, str], + user_subject_strings: Set[str], +) -> Optional[dict]: + exact_subject_type = 'user' + exact_subject_id = None + if tuple_user.startswith('user_group:'): + exact_subject_type = 'user_group' + exact_subject_id = int(tuple_user.split(':', 1)[1].split('#', 1)[0]) + elif tuple_user.startswith('department:'): + exact_subject_type = 'department' + exact_subject_id = int(tuple_user.split(':', 1)[1].split('#', 1)[0]) + elif tuple_user.startswith('user:'): + exact_subject_id = int(tuple_user.split(':', 1)[1]) + + for binding in bindings: + if binding.get('resource_type') != resource_type or str(binding.get('resource_id')) != str(resource_id): + continue + if binding.get('relation') != relation: + continue + if exact_subject_id is not None and not binding.get('include_children'): + if ( + binding.get('subject_type') == exact_subject_type + and int(binding.get('subject_id')) == exact_subject_id + and _user_matches_binding(binding, tuple_user, user_subject_strings) + ): + return binding + + if tuple_user.startswith('department:'): + tuple_department_id = int(tuple_user.split(':', 1)[1].split('#', 1)[0]) + tuple_department_rows = await DepartmentDao.aget_by_ids([tuple_department_id]) + tuple_department_path = tuple_department_rows[0].path if tuple_department_rows else '' + for binding in bindings: + if binding.get('resource_type') != resource_type or str(binding.get('resource_id')) != str(resource_id): + continue + if binding.get('relation') != relation: + continue + if binding.get('subject_type') != 'department' or not binding.get('include_children'): + continue + binding_path = binding_department_paths.get(int(binding.get('subject_id'))) + if binding_path and tuple_department_path and tuple_department_path.startswith(binding_path): + return binding + return None + + +async def _collect_user_subject_strings(login_user) -> Set[str]: + out = {f'user:{login_user.user_id}'} + group_ids = await login_user.get_user_group_ids(login_user.user_id) + out.update(f'user_group:{gid}#member' for gid in (group_ids or [])) + uds = await UserDepartmentDao.aget_user_departments(login_user.user_id) + out.update(f'department:{ud.department_id}#member' for ud in (uds or [])) + return out + + +async def get_effective_app_permission_ids( + login_user, + object_type: str, + object_id: str, +) -> Set[str]: + """Effective fine-grained permission ids for the current user on one app resource.""" + from bisheng.permission.domain.services.permission_service import PermissionService + + if login_user.is_admin(): + return {pid for pid, _ in _APP_PERMISSION_DEFINITIONS} + + user_subject_strings = await _collect_user_subject_strings(login_user) + from bisheng.permission.api.endpoints.resource_permission import ( + _get_bindings, + _get_relation_models, + _normalize_model_dict, + ) + + raw_models = await _get_relation_models() + model_map = {m['id']: _normalize_model_dict(m) for m in raw_models} + bindings = await _get_bindings() + binding_department_paths = await _binding_department_paths(bindings) + effective: Set[str] = set() + + fga = PermissionService._get_fga() + if fga is None: + level = await PermissionService.get_permission_level( + user_id=login_user.user_id, + object_type=object_type, + object_id=str(object_id), + login_user=login_user, + ) + relation = _PERMISSION_LEVEL_TO_FG_RELATION.get(level or '') + return _permission_ids_for_relation(relation or '', None) + + try: + tuples = await fga.read_tuples(object=f'{object_type}:{object_id}') + except Exception as e: # noqa: BLE001 + logger.warning('read_tuples failed for %s:%s: %s', object_type, object_id, e) + tuples = [] + + for tuple_data in tuples or []: + tuple_user = tuple_data.get('user') + relation = tuple_data.get('relation') + if not tuple_user or not relation or tuple_user not in user_subject_strings: + continue + binding = await _resolve_binding_for_tuple( + object_type, + str(object_id), + tuple_user, + relation, + bindings, + binding_department_paths, + user_subject_strings, + ) + model = model_map.get(binding.get('model_id')) if binding and binding.get('model_id') else None + effective.update(_permission_ids_for_relation(relation, model)) + + if effective: + return effective + + level = await PermissionService.get_permission_level( + user_id=login_user.user_id, + object_type=object_type, + object_id=str(object_id), + login_user=login_user, + ) + relation = _PERMISSION_LEVEL_TO_FG_RELATION.get(level or '') + return _permission_ids_for_relation(relation or '', None) + + +async def user_may_share_app(login_user, object_type: str, object_id: str) -> bool: + """True if the user's relation model includes ``share_app`` on this resource.""" + perms = await get_effective_app_permission_ids(login_user, object_type, object_id) + return SHARE_APP_PERMISSION_ID in perms + + +def object_type_for_flow_type(flow_type: int) -> Optional[str]: + from bisheng.database.models.flow import FlowType + + if flow_type == FlowType.WORKFLOW.value: + return 'workflow' + if flow_type == FlowType.ASSISTANT.value: + return 'assistant' + return None + + +async def batch_user_may_share_app( + login_user, + items: List[Tuple[str, str]], +) -> List[bool]: + """Parallel ``user_may_share_app`` for list enrichment (same login_user).""" + if not items: + return [] + if login_user.is_admin(): + return [True] * len(items) + results = await asyncio.gather( + *[user_may_share_app(login_user, ot, oid) for ot, oid in items], + ) + return list(results) diff --git a/src/backend/bisheng/user/api/user.py b/src/backend/bisheng/user/api/user.py index de9bc5196..1133ba4a4 100644 --- a/src/backend/bisheng/user/api/user.py +++ b/src/backend/bisheng/user/api/user.py @@ -238,6 +238,35 @@ async def _department_admin_scoped_user_ids(user_id: int) -> Optional[List[int]] return list({int(uid) for uid in rows}) +def _primary_department_id_map_for_user_ids(user_ids: List[int]) -> Dict[int, Optional[int]]: + """user_id -> ``department.id``(主部门),供前端组织树挂载。 + + ``user.dept_id`` 字段为历史/业务侧字符串,与 ``department.id`` 不一定一致; + 选人组件应按 ``user_department`` 关系挂载到树节点。 + """ + ids = [int(x) for x in user_ids if x is not None] + if not ids: + return {} + with get_sync_db_session() as session: + rows = session.exec( + select(UserDepartment).where(col(UserDepartment.user_id).in_(ids)) + ).all() + by_uid: Dict[int, List[UserDepartment]] = {} + for ud in rows or []: + uid = int(ud.user_id) + by_uid.setdefault(uid, []).append(ud) + out: Dict[int, Optional[int]] = {} + for uid in ids: + lst = by_uid.get(uid) or [] + if not lst: + out[uid] = None + continue + prim = [x for x in lst if int(x.is_primary) == 1] + chosen = prim[0] if prim else lst[0] + out[uid] = int(chosen.department_id) + return out + + @router.get('/user/list', status_code=201) async def list_user(*, name: Optional[str] = None, @@ -301,11 +330,14 @@ async def list_user(*, user_ids = list(set(roles_user_ids)) users, total_count = UserDao.filter_users(user_ids, name, page_num, page_size) + uid_list = [int(one.user_id) for one in users if getattr(one, "user_id", None) is not None] + primary_dept_by_user = _primary_department_id_map_for_user_ids(uid_list) res = [] role_dict = {} group_dict = {} for one in users: one_data = one.model_dump() + one_data["department_id"] = primary_dept_by_user.get(int(one.user_id)) if one.user_id is not None else None one_data["avatar"] = UserService.get_avatar_share_link_sync(one_data.get("avatar")) user_roles = get_user_roles(one, role_dict) user_groups = get_user_groups(one, group_dict) @@ -714,11 +746,15 @@ async def get_captcha(): redis_client = await get_redis_client() await redis_client.aset(key, chr_4, expiration=300) - # Add configuration, whether the verification code must be used + # 与 ``UserService.user_login`` 中 ``if await settings.aget_from_db('use_captcha'):`` 同源同判: + # 曾用同步 ``get_from_db`` 时,在部分环境/事件循环下与异步登录读取不一致,导致前端 ``user_capthca=false`` + # 不渲染验证码,但登录仍走验证码校验并报「验证码错误」。 + use_raw = await settings.aget_from_db('use_captcha') + user_captcha_required = True if use_raw else False return resp_200({ 'captcha_key': key, 'captcha': capthca_b64, - 'user_capthca': settings.get_from_db('use_captcha') or False + 'user_capthca': user_captcha_required, }) diff --git a/src/backend/bisheng/utils/http_middleware.py b/src/backend/bisheng/utils/http_middleware.py index 7ad184cd0..7a485229e 100644 --- a/src/backend/bisheng/utils/http_middleware.py +++ b/src/backend/bisheng/utils/http_middleware.py @@ -19,6 +19,8 @@ TENANT_CHECK_EXEMPT_PATHS = ( '/api/v1/user/sso', '/api/v1/user/ldap', '/api/v1/user/public_key', + # 登录页拉验证码;若仍带失效 Bearer,不应走 token_version 否则永远 19103、前端拿不到 user_capthca + '/api/v1/user/get_captcha', '/api/v1/user/switch-tenant', '/api/v1/user/tenants', '/api/v1/env', @@ -49,6 +51,22 @@ def _decode_jwt_subject(token: str) -> Optional[dict]: return None +def _extract_http_access_token(request: Request) -> Optional[str]: + """Resolve JWT: HttpOnly cookie (e.g. server-rendered) or ``Authorization: Bearer`` (platform SPA). + + Platform axios stores the token in ``localStorage`` and sends Bearer headers; + skipping Bearer caused ``token_version`` invalidation (account disable) to never + run for logged-in SPA sessions. + """ + token = request.cookies.get('access_token_cookie') + if token: + return token + auth = (request.headers.get('Authorization') or '').strip() + if auth.lower().startswith('bearer '): + return (auth[7:].strip() or None) + return None + + def _extract_tenant_id_from_token(token: str) -> int: """Decode JWT token and extract tenant_id. Returns DEFAULT_TENANT_ID on failure.""" return _tenant_id_from_subject(_decode_jwt_subject(token)) @@ -210,6 +228,23 @@ async def _apply_token_version_and_visible( }, ) + if user_id: + try: + from bisheng.user.domain.models.user import UserDao + + row = await UserDao.aget_user(int(user_id)) + if row is not None and int(row.delete or 0) == 1: + return JSONResponse( + status_code=401, + content={ + 'status_code': 19104, + 'status_message': 'account disabled — please contact administrator', + 'data': None, + }, + ) + except Exception as exc: # noqa: BLE001 + logger.debug('account-status check failed user_id=%s: %s', user_id, exc) + from bisheng.core.context.tenant import set_visible_tenant_ids try: tenant_id = int(subject.get('tenant_id', 0) or 0) @@ -237,7 +272,7 @@ class CustomMiddleware(BaseHTTPMiddleware): # Tenant context injection from JWT cookie. Decode the JWT once and # share it with the F012 token_version + visible_tenant_ids step so # the same token isn't decoded twice on the hot path. - token = request.cookies.get('access_token_cookie') + token = _extract_http_access_token(request) decoded_subject = _decode_jwt_subject(token) if token else None tenant_id = _set_tenant_context(token, decoded_subject=decoded_subject) diff --git a/src/backend/bisheng/workstation/api/endpoints/apps.py b/src/backend/bisheng/workstation/api/endpoints/apps.py index 5115ef828..20042d060 100644 --- a/src/backend/bisheng/workstation/api/endpoints/apps.py +++ b/src/backend/bisheng/workstation/api/endpoints/apps.py @@ -3,6 +3,7 @@ from typing import Optional from fastapi import APIRouter, Body from bisheng.api.services.workflow import WorkFlowService +from bisheng.permission.domain.workflow_app_permission import batch_user_may_share_app, object_type_for_flow_type from bisheng.api.v1.schemas import ChatList, FrequentlyUsedChat, UnifiedResponseModel, UsedAppPin, resp_200 from bisheng.common.errcode.http_error import UnAuthorizedError from bisheng.common.errcode.workstation import AgentAlreadyExistsError, UsedAppNotFoundError, UsedAppNotOnlineError @@ -21,7 +22,7 @@ router = APIRouter() @router.get('/app/recommended') -def get_recommended_apps(login_user=LoginUserDep): +async def get_recommended_apps(login_user=LoginUserDep): """Return admin-configured recommended apps. - Admins (config page): return every configured app so the selection can echo @@ -48,17 +49,18 @@ def get_recommended_apps(login_user=LoginUserDep): data.sort(key=lambda x: app_order.get(x['id'], len(app_ids))) data = WorkFlowService.add_extra_field(login_user, data) + data = await WorkFlowService.aenrich_apps_can_share(login_user, data) return resp_200(data=data) @router.get('/app/frequently_used') -def get_frequently_used_chat( +async def get_frequently_used_chat( login_user=LoginUserDep, user_link_type: Optional[str] = 'app', page: Optional[int] = 1, limit: Optional[int] = 8, ): - data, _ = WorkFlowService.get_frequently_used_flows(login_user, user_link_type, page, limit) + data, _ = await WorkFlowService.get_frequently_used_flows(login_user, user_link_type, page, limit) return resp_200(data=data) @@ -81,13 +83,13 @@ def delete_frequently_used_chat( @router.get('/app/uncategorized') -def get_uncategorized_chat( +async def get_uncategorized_chat( login_user=LoginUserDep, page: Optional[int] = 1, limit: Optional[int] = 8, keyword: Optional[str] = None, ): - data, _ = WorkFlowService.get_uncategorized_flows(login_user, page, limit, keyword) + data, _ = await WorkFlowService.get_uncategorized_flows(login_user, page, limit, keyword) return resp_200(data=data) @@ -133,6 +135,23 @@ async def get_used_apps(login_user=LoginUserDep, page: int = 1, limit: int = 20) app['tags'] = resource_tag_dict.get(app_id, []) result.append(app) + for app in result: + app['can_share'] = False + share_pairs = [] + share_idx = [] + for idx, app in enumerate(result): + ot = object_type_for_flow_type(int(app.get('flow_type') or 0)) + if ot: + share_pairs.append((ot, str(app['id']))) + share_idx.append(idx) + if login_user.is_admin(): + for app in result: + app['can_share'] = True + elif share_pairs: + flags = await batch_user_may_share_app(login_user, share_pairs) + for j, app_i in enumerate(share_idx): + result[app_i]['can_share'] = bool(flags[j]) + total = len(result) start_index = (page - 1) * limit end_index = start_index + limit diff --git a/src/frontend/.dockerignore b/src/frontend/.dockerignore new file mode 100644 index 000000000..be1cb91fd --- /dev/null +++ b/src/frontend/.dockerignore @@ -0,0 +1,8 @@ +**/node_modules +**/build +**/dist +**/.git +**/.cache +**/.turbo +**/*.log +.DS_Store diff --git a/src/frontend/client/src/@types/app.ts b/src/frontend/client/src/@types/app.ts index ceba9260e..0a41454ad 100644 --- a/src/frontend/client/src/@types/app.ts +++ b/src/frontend/client/src/@types/app.ts @@ -10,6 +10,8 @@ export interface AppItem { is_pinned?: boolean; // Pinned state from backend last_chat_time?: string; // ISO date of last conversation last_chat_id?: string; // Last conversation ID (for "continue chat") + /** ReBAC relation-model ``share_app`` — hide share UI when false */ + can_share?: boolean; } /** Tag/category */ diff --git a/src/frontend/client/src/@types/chat.ts b/src/frontend/client/src/@types/chat.ts index 2de2f696d..4d258e7c2 100644 --- a/src/frontend/client/src/@types/chat.ts +++ b/src/frontend/client/src/@types/chat.ts @@ -15,6 +15,7 @@ export interface FlowData { status: number; update_time: string; // ISO 8601 format date string user_id: null | string; // Assuming it can be null or string + can_share?: boolean; } // diff --git a/src/frontend/client/src/components/permission/PermissionBadge.tsx b/src/frontend/client/src/components/permission/PermissionBadge.tsx index d73595e28..d9986de7b 100644 --- a/src/frontend/client/src/components/permission/PermissionBadge.tsx +++ b/src/frontend/client/src/components/permission/PermissionBadge.tsx @@ -1,32 +1,11 @@ -import { useLocalize } from "~/hooks"; import type { RelationLevel } from "~/api/permission"; -import { cn } from "~/utils"; - -const LEVEL_STYLES: Record = { - owner: "bg-purple-100 text-purple-700 border-purple-200", - manager: "bg-blue-100 text-blue-700 border-blue-200", - editor: "bg-green-100 text-green-700 border-green-200", - viewer: "bg-gray-100 text-gray-700 border-gray-200", -}; interface PermissionBadgeProps { level: RelationLevel | null | undefined; className?: string; } -export function PermissionBadge({ level, className }: PermissionBadgeProps) { - const localize = useLocalize(); - if (!level) return null; - - return ( - - {localize(`com_permission.level_${level}`)} - - ); +/** 列表等场景不展示权限关系角标;保留组件以兼容调用处。 */ +export function PermissionBadge(_props: PermissionBadgeProps) { + return null; } diff --git a/src/frontend/client/src/components/permission/PermissionListTab.tsx b/src/frontend/client/src/components/permission/PermissionListTab.tsx index 7d2bdb3dd..67910d60c 100644 --- a/src/frontend/client/src/components/permission/PermissionListTab.tsx +++ b/src/frontend/client/src/components/permission/PermissionListTab.tsx @@ -13,7 +13,6 @@ import type { import { Building2, Loader2, RotateCcw, Trash2, User, Users } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { useLocalize } from "~/hooks"; -import { PermissionBadge } from "./PermissionBadge"; import { RelationModelOption, RelationSelect } from "./RelationSelect"; const SUBJECT_ICONS = { @@ -239,7 +238,7 @@ export function PermissionListTab({ {isOwner ? ( - + {localize("com_permission.level_owner")} ) : ( @@ -170,17 +171,19 @@ export function SideNav() {
- + {showShareApp ? ( + + ) : null} diff --git a/src/frontend/client/src/pages/appChat/hooks/useAppSidebar.ts b/src/frontend/client/src/pages/appChat/hooks/useAppSidebar.ts index a815bc0aa..bfc9a67b2 100644 --- a/src/frontend/client/src/pages/appChat/hooks/useAppSidebar.ts +++ b/src/frontend/client/src/pages/appChat/hooks/useAppSidebar.ts @@ -12,6 +12,7 @@ import { currentAppInfoState, sidebarVisibleState, } from '~/pages/appChat/store/appSidebarAtoms'; +import { currentChatState } from '~/pages/appChat/store/atoms'; import { generateUUID } from '~/utils'; import { useLocalize } from '~/hooks'; @@ -32,6 +33,7 @@ export function useAppSidebar() { showToastRef.current = showToast; const currentApp = useRecoilValue(currentAppInfoState); + const chatState = useRecoilValue(currentChatState); const setCurrentApp = useSetRecoilState(currentAppInfoState); const [conversations, setConversations] = useRecoilState(appConversationsState); const [sidebarVisible, setSidebarVisible] = useRecoilState(sidebarVisibleState); @@ -119,6 +121,11 @@ export function useAppSidebar() { /** Share the current app */ const shareApp = useCallback(async () => { if (!flowId) return; + const fromChat = + chatState?.flow && String(chatState.flow.id) === String(flowId) ? chatState.flow.can_share : undefined; + const fromSidebar = + currentApp && String(currentApp.id) === String(flowId) ? currentApp.can_share : undefined; + if (fromChat !== true && fromSidebar !== true) return; const url = getAppShareUrl(flowId, flowType || ''); try { await copyText(url); @@ -126,7 +133,7 @@ export function useAppSidebar() { } catch { showToast?.({ message: '复制失败', severity: NotificationSeverity.ERROR }); } - }, [flowId, flowType, showToast]); + }, [flowId, flowType, showToast, chatState?.flow, currentApp]); // Guard: auto-select runs only once per flowId (on initial mount). // This prevents re-triggering when the user creates a new chat or switches conversations. @@ -149,10 +156,11 @@ export function useAppSidebar() { setCurrentApp({ id: data.id ?? flowId, name: data.name ?? '', - description: data.description ?? '', + description: data.description ?? data.desc ?? '', logo: data.logo ?? '', flow_type: Number(data.flow_type ?? numericType), user_id: data.user_id ?? '', + can_share: data.can_share === true, } as AppItem); } catch { // silent — sidebar falls back to placeholder text diff --git a/src/frontend/client/src/pages/apps/components/AgentCard.tsx b/src/frontend/client/src/pages/apps/components/AgentCard.tsx index 7e3ef284d..cf07c143d 100644 --- a/src/frontend/client/src/pages/apps/components/AgentCard.tsx +++ b/src/frontend/client/src/pages/apps/components/AgentCard.tsx @@ -68,28 +68,30 @@ export function AgentCard({ )} - - - - - - { - e.stopPropagation(); - onShare(agent); - }} - > - {localize('com_app_share_app')} - - - + {agent.can_share === true ? ( + + + + + + { + e.stopPropagation(); + onShare(agent); + }} + > + {localize('com_app_share_app')} + + + + ) : null}
) : ( @@ -163,16 +165,18 @@ export function AgentCard({ ) : (
- + {agent.can_share === true ? ( + + ) : null} + {agent.can_share === true ? ( + + ) : null} + ) : ( + + )} + + {node.name} +
+ + {shouldShowRows && ( + <> + {(loadingDeptIds.has(did) && !keywordTrim) && ( +
+
+ +
+ {t("loading", { ns: "bs" })} +
+ )} + {users.map((u) => { + const selected = selectedMap.has(Number(u.value)) + const locked = lockedSet.has(Number(u.value)) + return ( +
setPicked(u)} + > +
+ +
+ setPicked(u)} /> + + {u.label} +
+ ) + })} + {hasChildren && node.children!.map((c) => renderNode(c, depth + 1))} + + )} + + ) + } + + return ( + +
+ + + +
+ +
+ handleKeywordChange(e.target.value)} + /> +
+ {loadingTree ? ( +
{t("loading", { ns: "bs" })}
+ ) : tree.length === 0 ? ( +
{t("system.treeDepartmentSelectEmpty")}
+ ) : ( + <> + {searchingUsers && keywordTrim ? ( +
{t("loading", { ns: "bs" })}
+ ) : null} + {tree.map((n) => renderNode(n, 0))} + + )} +
+
+
+
+ ) +} + diff --git a/src/frontend/platform/src/components/bs-comp/selectComponent/Users.tsx b/src/frontend/platform/src/components/bs-comp/selectComponent/Users.tsx index 75afc1a0e..0210e6d14 100644 --- a/src/frontend/platform/src/components/bs-comp/selectComponent/Users.tsx +++ b/src/frontend/platform/src/components/bs-comp/selectComponent/Users.tsx @@ -1,48 +1,17 @@ -import MultiSelect from "@/components/bs-ui/select/multi"; -import { getUsersApi } from "@/controllers/API/user"; -import { useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; +import DepartmentUsersSelect, { DepartmentUserOption } from "./DepartmentUsersSelect"; -export default function UsersSelect({ multiple = false, lockedValues = [], value, disabled = false, onChange, children }: - { multiple?: boolean, lockedValues?: any[], value: any, disabled?: boolean, onChange: (a: any) => any, children?: (fun: any) => React.ReactNode }) { +export default function UsersSelect({ multiple = false, lockedValues = [], value, disabled = false, onChange }: + { multiple?: boolean, lockedValues?: any[], value: any, disabled?: boolean, onChange: (a: any) => any }) { + const mappedValue: DepartmentUserOption[] = (value || []).map((v: any) => ({ + label: String(v?.label ?? ''), + value: Number(v?.value), + })).filter((x) => x.label && Number.isFinite(x.value)) - const { t } = useTranslation() - const [options, setOptions] = useState([]); - const originOptionsRef = useRef([]) - - const pageRef = useRef(1) - const reload = (page, name) => { - getUsersApi({ page, pageSize: 40, name }).then(res => { - pageRef.current = page - originOptionsRef.current = res.data - const opts = res.data.map(el => ({ label: el.user_name, value: Number(el.user_id) })) - setOptions(_ops => page > 1 ? [..._ops, ...opts] : opts) - }) - } - - useEffect(() => { - reload(1, '') - }, []) - - // 加载更多 - const loadMore = (name) => { - reload(pageRef.current + 1, name) - } - - return Number(x))} disabled={disabled} - options={options} - placeholder={t('system.selectUser')} - searchPlaceholder={t('system.searchUser')} onChange={onChange} - onLoad={() => reload(1, '')} - onSearch={(val) => reload(1, val)} - onScrollLoad={(val) => loadMore(val)} - > - {children?.(reload)} - + /> }; diff --git a/src/frontend/platform/src/contexts/userContext.tsx b/src/frontend/platform/src/contexts/userContext.tsx index 1078dafc9..56a2ea7da 100644 --- a/src/frontend/platform/src/contexts/userContext.tsx +++ b/src/frontend/platform/src/contexts/userContext.tsx @@ -134,6 +134,8 @@ export function UserProvider({ children }: { children: ReactNode }) { // 是否有访问后台权限 if (/^(\/\w+)?\/chat/.test(location.pathname)) return // 排除免登陆 + const BASE_URL = __APP_ENV__.BASE_URL + // v2.5 角色菜单使用 admin 作为管理端父级;旧数据可能仍为 backend(WebMenuResource 遗留) const adminMenuKeys = new Set([ 'backend', @@ -147,14 +149,20 @@ export function UserProvider({ children }: { children: ReactNode }) { 'system_config', 'mark_task', ]) + // 部门管理员在 v2.5 允许进入管理端(至少可管理其权限范围内数据); + // 仅依赖 web_menu 会把这类账号误判成无权限(error=90001)。 const canAccessPlatform = - res.role === 'admin' || web_menu.some((k: string) => adminMenuKeys.has(k)) + res.role === 'admin' + || Boolean(res.is_department_admin) + || Boolean(res.can_manage_user_groups) + || web_menu.some((k: string) => adminMenuKeys.has(k)) if (!canAccessPlatform) { - location.href = `${location.origin}/workspace/c/new?error=90001`; - return; + // 避免把用户踢回工作台并携带 error=90001(影响登录体验); + // 无后台权限时在管理端内落到 403,由后端接口继续做细粒度鉴权。 + history.pushState(null, '', BASE_URL + '/403') + return } - const BASE_URL = __APP_ENV__.BASE_URL const pathName = location.pathname.replace(BASE_URL, ''); // Jump to the route based on permissions diff --git a/src/frontend/platform/src/controllers/API/flow.ts b/src/frontend/platform/src/controllers/API/flow.ts index 439c3eb9a..5df557884 100644 --- a/src/frontend/platform/src/controllers/API/flow.ts +++ b/src/frontend/platform/src/controllers/API/flow.ts @@ -169,14 +169,15 @@ export async function readFlowsFromDatabase(page: number = 1, pageSize: number = } /* app list */ -export async function getAppsApi({ page = 1, pageSize = 20, keyword, tag_id = -1, type, managed }) { +export async function getAppsApi({ page = 1, pageSize = 20, keyword, tag_id = -1, type, managed, status }) { const tagIdStr = tag_id === -1 ? '' : `&tag_id=${tag_id}` const map = { assistant: 5, skill: 1, flow: 10 } const flowType = map[type] ? `&flow_type=${map[type]}` : '' const managedStr = (managed !== undefined && managed !== null && managed !== '') ? `&managed=${managed}` : ''; - const { data, total }: { data: any[], total: number } = await axios.get(`/api/v1/workflow/list?page_num=${page}&page_size=${pageSize}&name=${keyword}${tagIdStr}${flowType}${managedStr}`); + const statusStr = (status === 1 || status === 2) ? `&status=${status}` : '' + const { data, total }: { data: any[], total: number } = await axios.get(`/api/v1/workflow/list?page_num=${page}&page_size=${pageSize}&name=${keyword ?? ''}${tagIdStr}${flowType}${managedStr}${statusStr}`); const newData = data.map(item => { if (item.flow_type !== 5) return item return { diff --git a/src/frontend/platform/src/controllers/request.ts b/src/frontend/platform/src/controllers/request.ts index f6c6db98e..45b02a26b 100644 --- a/src/frontend/platform/src/controllers/request.ts +++ b/src/frontend/platform/src/controllers/request.ts @@ -102,17 +102,25 @@ customAxios.interceptors.response.use(function (response) { }, function (error) { console.error('application error :>> ', error); if (error.response?.status === 401) { - // cookie expires + // 必须在 remove 之前读取:从未持有 ws_token/UUR_INFO 时 401(如登录页拉 /user/info)不应整页跳转,否则会死循环。 + const hadSession = + !!localStorage.getItem('ws_token') + || !!localStorage.getItem('UUR_INFO'); + // cookie / Bearer 失效(含 token_version 失效、账号禁用) + localStorage.removeItem('ws_token'); console.error('登录过期 :>> '); const thirdPartyLoginUrl = localStorage.getItem('THIRD_PARTY_LOGIN_URL'); if (thirdPartyLoginUrl) { + localStorage.removeItem('UUR_INFO'); window.location.href = thirdPartyLoginUrl; return Promise.reject('登录过期'); } - const UUR_INFO = 'UUR_INFO' - const infoStr = localStorage.getItem(UUR_INFO) - localStorage.removeItem(UUR_INFO) - infoStr && location.reload() + localStorage.removeItem('UUR_INFO'); + // 仅「曾有过登录态」时再回根路径,避免深路径 URL 上叠登录页且状态错乱。 + if (hadSession) { + const base = (__APP_ENV__.BASE_URL || '').replace(/\/$/, ''); + window.location.href = `${base}/`; + } return Promise.reject('登录过期,请重新登录'); } if (error.code === "ERR_CANCELED") return Promise.reject(error); diff --git a/src/frontend/platform/src/pages/BuildPage/apps.tsx b/src/frontend/platform/src/pages/BuildPage/apps.tsx index d635e6447..7e60ad032 100644 --- a/src/frontend/platform/src/pages/BuildPage/apps.tsx +++ b/src/frontend/platform/src/pages/BuildPage/apps.tsx @@ -1,7 +1,6 @@ import CardComponent from "@/components/bs-comp/cardComponent"; import AppAvator from "@/components/bs-comp/cardComponent/avatar"; import LabelShow from "@/components/bs-comp/cardComponent/LabelShow"; -import { PermissionBadge } from "@/components/bs-comp/permission/PermissionBadge"; import { PermissionDialog } from "@/components/bs-comp/permission/PermissionDialog"; import { usePermissionLevels } from "@/components/bs-comp/permission/usePermissionLevels"; import { RelationLevel } from "@/components/bs-comp/permission/types"; @@ -35,6 +34,27 @@ import { useCreateTemp, useErrorPrompt, useQueryLabels } from "./hook"; import CardSelectVersion from "./skills/CardSelectVersion"; import CreateTemp from "./skills/CreateTemp"; +/** 按应用上线(2)/下线(1)状态筛选,与后端 ``/api/v1/workflow/list?status=`` 一致 */ +export const SelectAppStatus = ({ defaultValue = 'all', onChange }: { defaultValue?: string; onChange: (v: string) => void }) => { + const [value, setValue] = useState(defaultValue) + const { t } = useTranslation() + + return ( + + ) +} + export const SelectType = ({ all = false, defaultValue = 'all', onChange }) => { const [value, setValue] = useState(defaultValue) const { t } = useTranslation(); @@ -292,6 +312,11 @@ export default function apps() { tempTypeRef.current = v filterData({ type: v }) }} /> + { + filterData({ status: v === 'all' ? undefined : Number(v) }) + }} + /> handleSetting(item)} onPermission={canManage(item.id) ? handleOpenPermission : undefined} - permissionBadge={} // PRD:「可编辑」含上线/下线;与 ReBAC can_edit 对齐(非仅 owner/manager) showSwitch={canEdit(item.id)} showCopy={canCreateApp && canRead(item.id)} diff --git a/src/frontend/platform/src/pages/BuildPage/tools/index.tsx b/src/frontend/platform/src/pages/BuildPage/tools/index.tsx index ba0053adf..b2b499333 100644 --- a/src/frontend/platform/src/pages/BuildPage/tools/index.tsx +++ b/src/frontend/platform/src/pages/BuildPage/tools/index.tsx @@ -1,6 +1,5 @@ import { LoadIcon } from "@/components/bs-icons"; import { LoadingIcon } from "@/components/bs-icons/loading"; -import { PermissionBadge } from "@/components/bs-comp/permission/PermissionBadge"; import { PermissionDialog } from "@/components/bs-comp/permission/PermissionDialog"; import { canManageResource, usePermissionLevels } from "@/components/bs-comp/permission/usePermissionLevels"; import { Accordion } from "@/components/bs-ui/accordion"; @@ -188,7 +187,6 @@ const TabTools = ({ select = null, onSelect }: TabToolsProps) => { onPermission={canManageResource(permLevels, el.id) ? (tool) => { setPermTarget({ id: String(tool.id), name: tool.name }); setPermDialogOpen(true); } : null} - permissionBadge={} > )) ) : ( diff --git a/src/frontend/platform/src/pages/Dashboard/components/dashboard/DashboardSidebar.tsx b/src/frontend/platform/src/pages/Dashboard/components/dashboard/DashboardSidebar.tsx index 99f8c8822..935623f47 100644 --- a/src/frontend/platform/src/pages/Dashboard/components/dashboard/DashboardSidebar.tsx +++ b/src/frontend/platform/src/pages/Dashboard/components/dashboard/DashboardSidebar.tsx @@ -1,6 +1,5 @@ "use client" -import { PermissionBadge } from "@/components/bs-comp/permission/PermissionBadge" import { PermissionDialog } from "@/components/bs-comp/permission/PermissionDialog" import { canManageResource, usePermissionLevels } from "@/components/bs-comp/permission/usePermissionLevels" import { bsConfirm } from "@/components/bs-ui/alertDialog/useConfirm" @@ -238,7 +237,6 @@ export function DashboardSidebar({ onPermission={canManageResource(permLevels, dashboard.id) ? (d) => { setPermTarget({ id: String(d.id), name: d.title }); setPermDialogOpen(true); } : undefined} - permissionBadge={} /> )) )} diff --git a/src/frontend/platform/src/pages/DepartmentPage/components/CreateDepartmentDialog.tsx b/src/frontend/platform/src/pages/DepartmentPage/components/CreateDepartmentDialog.tsx index 26b4ace1a..509bf0604 100644 --- a/src/frontend/platform/src/pages/DepartmentPage/components/CreateDepartmentDialog.tsx +++ b/src/frontend/platform/src/pages/DepartmentPage/components/CreateDepartmentDialog.tsx @@ -8,17 +8,16 @@ import { } from "@/components/bs-ui/dialog" import { Input } from "@/components/bs-ui/input" import { Label } from "@/components/bs-ui/label" -import MultiSelect from "@/components/bs-ui/select/multi" import { toast } from "@/components/bs-ui/toast/use-toast" +import DepartmentUsersSelect, { + DepartmentUserOption, +} from "@/components/bs-comp/selectComponent/DepartmentUsersSelect" import { createDepartmentApi } from "@/controllers/API/department" -import { getUsersApi } from "@/controllers/API/user" import { captureAndAlertRequestErrorHoc } from "@/controllers/request" import { DepartmentTreeNode } from "@/types/api/department" -import { useCallback, useEffect, useRef, useState } from "react" +import { useCallback, useState } from "react" import { useTranslation } from "react-i18next" -type AdminOption = { value: string; label: string } - interface CreateDepartmentDialogProps { tree: DepartmentTreeNode[] defaultParentId: number | null @@ -35,72 +34,9 @@ export function CreateDepartmentDialog({ const { t } = useTranslation() const [name, setName] = useState("") const [parentId, setParentId] = useState(defaultParentId) - const [adminSelectValue, setAdminSelectValue] = useState([]) - const [userSearchOptions, setUserSearchOptions] = useState([]) + const [adminSelectValue, setAdminSelectValue] = useState([]) const [loading, setLoading] = useState(false) - const adminSelectValueRef = useRef([]) - const searchTimerRef = useRef | null>(null) - const searchAbortRef = useRef(null) - - useEffect(() => { - adminSelectValueRef.current = adminSelectValue - }, [adminSelectValue]) - - useEffect(() => { - return () => { - searchAbortRef.current?.abort() - if (searchTimerRef.current) clearTimeout(searchTimerRef.current) - } - }, []) - - const mergeUserOptions = useCallback( - ( - searchResults: { user_id: number; user_name: string }[], - currentAdmins: AdminOption[] - ): AdminOption[] => { - const byVal = new Map() - for (const a of currentAdmins) byVal.set(a.value, a) - for (const u of searchResults) { - const v = String(u.user_id) - if (!byVal.has(v)) { - byVal.set(v, { value: v, label: u.user_name }) - } - } - return Array.from(byVal.values()) - }, - [] - ) - - const runUserSearch = useCallback( - async (q: string, currentAdmins: AdminOption[]) => { - searchAbortRef.current?.abort() - const ac = new AbortController() - searchAbortRef.current = ac - try { - const res = await getUsersApi( - { name: q, page: 1, pageSize: 120 }, - { signal: ac.signal } - ) - if (ac.signal.aborted) return - setUserSearchOptions(mergeUserOptions(res.data || [], currentAdmins)) - } catch { - /* aborted or network */ - } - }, - [mergeUserOptions] - ) - - const scheduleUserSearch = useCallback( - (q: string) => { - if (searchTimerRef.current) clearTimeout(searchTimerRef.current) - searchTimerRef.current = setTimeout(() => { - void runUserSearch(q, adminSelectValueRef.current) - }, 300) - }, - [runUserSearch] - ) - // Flatten tree for parent selector (exclude archived departments) const flatList: { id: number; name: string; depth: number }[] = [] const flatten = (nodes: DepartmentTreeNode[], depth: number) => { @@ -114,11 +50,19 @@ export function CreateDepartmentDialog({ const handleSubmit = useCallback(() => { if (!name || name.length < 2 || name.length > 50) { - toast({ title: t("bs:department.nameLength"), variant: "error" }) + toast({ + title: t("prompt"), + description: t("bs:department.nameLength"), + variant: "error", + }) return } if (parentId === null) { - toast({ title: t("bs:department.selectParent"), variant: "error" }) + toast({ + title: t("prompt"), + description: t("bs:department.selectParent"), + variant: "error", + }) return } setLoading(true) @@ -127,14 +71,18 @@ export function CreateDepartmentDialog({ name, parent_id: parentId, admin_user_ids: adminSelectValue.length - ? adminSelectValue.map((o) => Number(o.value)) + ? adminSelectValue.map((o) => o.value) : undefined, }) ).then((res) => { setLoading(false) // captureAndAlertRequestErrorHoc 在接口失败时返回 false(不是 null) if (res === false) return - toast({ title: t("bs:department.create"), variant: "success" }) + toast({ + title: t("prompt"), + description: t("bs:department.create"), + variant: "success", + }) onCreated() }) }, [name, parentId, adminSelectValue, onCreated, t]) @@ -180,25 +128,16 @@ export function CreateDepartmentDialog({

{t("bs:department.adminsHint")}

- {}} value={adminSelectValue} - options={userSearchOptions} + onChange={(vals) => { + const v = (vals as DepartmentUserOption[]) || [] + setAdminSelectValue(v) + }} placeholder={t("bs:department.adminSelectPlaceholder")} searchPlaceholder={t("bs:department.searchUsersPlaceholder")} - onSearch={(q) => scheduleUserSearch(q)} - onLoad={() => { - void runUserSearch("", adminSelectValueRef.current) - }} - onChange={(vals) => { - const v = (vals as AdminOption[]) || [] - setAdminSelectValue(v) - adminSelectValueRef.current = v - }} className="max-w-xl w-full" - contentClassName="min-w-[var(--radix-select-trigger-width)]" />
diff --git a/src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettings.tsx b/src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettings.tsx index 3e67c4256..9ad53dfbb 100644 --- a/src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettings.tsx +++ b/src/frontend/platform/src/pages/DepartmentPage/components/DepartmentSettings.tsx @@ -5,32 +5,34 @@ import { Label } from "@/components/bs-ui/label" import MultiSelect from "@/components/bs-ui/select/multi" import { Separator } from "@/components/bs-ui/separator" import { toast } from "@/components/bs-ui/toast/use-toast" +import { TreeDepartmentSelect } from "@/components/bs-comp/department/TreeDepartmentSelect" +import DepartmentUsersSelect, { + DepartmentUserOption, +} from "@/components/bs-comp/selectComponent/DepartmentUsersSelect" import { deleteDepartmentApi, getDepartmentAdminsApi, getDepartmentApi, getDepartmentAssignableRolesApi, + moveDepartmentApi, purgeDepartmentApi, restoreDepartmentApi, updateDepartmentApi, } from "@/controllers/API/department" -import { getUsersApi } from "@/controllers/API/user" import { isSyncedSource } from "@/pages/DepartmentPage/constants/syncReadonly" import { captureAndAlertRequestErrorHoc } from "@/controllers/request" import type { DepartmentAdmin, DepartmentTreeNode } from "@/types/api/department" import { useCallback, useEffect, useRef, useState } from "react" import { useTranslation } from "react-i18next" -type AdminOption = { value: string; label: string } - interface DepartmentSettingsProps { dept: DepartmentTreeNode tree: DepartmentTreeNode[] onChanged: () => void } -function adminsToOptions(admins: DepartmentAdmin[]): AdminOption[] { - return admins.map((a) => ({ value: String(a.user_id), label: a.user_name })) +function adminsToOptions(admins: DepartmentAdmin[]): DepartmentUserOption[] { + return admins.map((a) => ({ value: Number(a.user_id), label: a.user_name })) } /** 企业级表单:统一控件最大宽度,右侧对齐 */ @@ -39,79 +41,82 @@ const FORM_CONTROL_WIDTH = "w-full max-w-md" export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettingsProps) { const { t } = useTranslation() const [name, setName] = useState(dept.name) - const [adminSelectValue, setAdminSelectValue] = useState([]) - const [userSearchOptions, setUserSearchOptions] = useState([]) + const [adminSelectValue, setAdminSelectValue] = useState([]) const [defaultRoleIds, setDefaultRoleIds] = useState([]) const [assignableRoles, setAssignableRoles] = useState<{ value: string; label: string }[]>([]) const [saving, setSaving] = useState(false) const [loading, setLoading] = useState(true) + const [parentIdValue, setParentIdValue] = useState(dept.parent_id ?? null) + const [parentTreeNodes, setParentTreeNodes] = useState([]) - const adminSelectValueRef = useRef([]) - const searchTimerRef = useRef | null>(null) - const searchAbortRef = useRef(null) + const adminSelectValueRef = useRef([]) const isSynced = isSyncedSource(dept.source) const isArchived = dept.status === "archived" + const isAbsoluteRootDept = dept.parent_id === null || Number(dept.parent_id) === 0 + // 对部门管理员场景:当前可见树的顶层节点也视为“根节点”(即便全局树里它还有父节点) + const isVisibleRootDept = tree.some((n) => n.id === dept.id) + const isRootDept = isAbsoluteRootDept || isVisibleRootDept /** 仅部门名称对第三方同步部门只读;管理员与默认角色仍可保存 */ const canEditName = !isArchived && !isSynced const canEditPermissions = !isArchived + const canEditParent = !isArchived && !isSynced && !isRootDept /** 最近一次从服务端加载成功的快照,用于「取消」还原 */ const baselineRef = useRef<{ name: string - admins: AdminOption[] + admins: DepartmentUserOption[] defaultRoleIds: string[] + parentId: number | null } | null>(null) useEffect(() => { adminSelectValueRef.current = adminSelectValue }, [adminSelectValue]) - const mergeUserOptions = useCallback( - ( - searchResults: { user_id: number; user_name: string }[], - currentAdmins: AdminOption[] - ): AdminOption[] => { - const byVal = new Map() - for (const a of currentAdmins) byVal.set(a.value, a) - for (const u of searchResults) { - const v = String(u.user_id) - if (!byVal.has(v)) { - byVal.set(v, { value: v, label: u.user_name }) - } + const gatherSubtreeIds = useCallback((node: DepartmentTreeNode | null): Set => { + const ids = new Set() + if (!node) return ids + const walk = (n: DepartmentTreeNode) => { + ids.add(n.id) + for (const c of n.children || []) walk(c) + } + walk(node) + return ids + }, []) + + const findNodeByDeptId = useCallback( + (nodes: DepartmentTreeNode[], deptId: string): DepartmentTreeNode | null => { + for (const n of nodes) { + if (n.dept_id === deptId) return n + const found = findNodeByDeptId(n.children || [], deptId) + if (found) return found } - return Array.from(byVal.values()) + return null }, [] ) - const runUserSearch = useCallback( - async (q: string, currentAdmins: AdminOption[]) => { - searchAbortRef.current?.abort() - const ac = new AbortController() - searchAbortRef.current = ac - try { - const res = await getUsersApi( - { name: q, page: 1, pageSize: 120 }, - { signal: ac.signal } - ) - if (ac.signal.aborted) return - setUserSearchOptions(mergeUserOptions(res.data || [], currentAdmins)) - } catch { - /* aborted or network */ + const buildParentTreeNodes = useCallback( + (nodes: DepartmentTreeNode[], selectedDeptId: string): DepartmentTreeNode[] => { + const selectedNode = findNodeByDeptId(nodes, selectedDeptId) + const excluded = gatherSubtreeIds(selectedNode) + const walk = (n: DepartmentTreeNode): DepartmentTreeNode | null => { + // 仅可挂到当前可见树(入参 nodes)中的 active 节点;且不能选自身/子树 + if (excluded.has(n.id) || n.status !== "active") return null + const nextChildren = (n.children || []) + .map((c) => walk(c)) + .filter((x): x is DepartmentTreeNode => Boolean(x)) + return { + ...n, + children: nextChildren, + } } + return nodes + .map((root) => walk(root)) + .filter((x): x is DepartmentTreeNode => Boolean(x)) }, - [mergeUserOptions] - ) - - const scheduleUserSearch = useCallback( - (q: string) => { - if (searchTimerRef.current) clearTimeout(searchTimerRef.current) - searchTimerRef.current = setTimeout(() => { - void runUserSearch(q, adminSelectValueRef.current) - }, 300) - }, - [runUserSearch] + [findNodeByDeptId, gatherSubtreeIds] ) useEffect(() => { @@ -131,14 +136,18 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings setName(detailRes?.name ?? dept.name) const dr = (detailRes?.default_role_ids ?? []).map(String) setDefaultRoleIds(dr) + const pTreeNodes = buildParentTreeNodes(tree, dept.dept_id) + setParentTreeNodes(pTreeNodes) + const pid = detailRes?.parent_id ?? dept.parent_id ?? null + setParentIdValue(pid) setAssignableRoles( (rolesRes || []).map((r) => ({ value: String(r.id), label: r.role_name })) ) - setUserSearchOptions(mergeUserOptions([], adminOpts)) baselineRef.current = { name: detailRes?.name ?? dept.name, admins: adminOpts, defaultRoleIds: dr, + parentId: pid, } }) .catch(() => { @@ -155,10 +164,8 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings }) return () => { cancelled = true - searchAbortRef.current?.abort() - if (searchTimerRef.current) clearTimeout(searchTimerRef.current) } - }, [dept.dept_id, dept.name, mergeUserOptions, t]) + }, [buildParentTreeNodes, dept.dept_id, dept.name, dept.parent_id, t, tree]) const handleCancel = useCallback(() => { const b = baselineRef.current @@ -167,13 +174,17 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings setAdminSelectValue(b.admins) adminSelectValueRef.current = b.admins setDefaultRoleIds(b.defaultRoleIds) - setUserSearchOptions(mergeUserOptions([], b.admins)) - }, [mergeUserOptions]) + setParentIdValue(b.parentId) + }, []) const handleGlobalSave = useCallback(async () => { if (!canEditPermissions) return if (canEditName && (!name || name.length < 2 || name.length > 50)) { - toast({ title: t("bs:department.nameLength"), variant: "error" }) + toast({ + title: t("prompt"), + description: t("bs:department.nameLength"), + variant: "error", + }) return } setSaving(true) @@ -184,9 +195,22 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings admin_user_ids: number[] } = { default_role_ids: defaultRoleIds.map(Number), - admin_user_ids: adminSelectValue.map((o) => Number(o.value)), + admin_user_ids: adminSelectValue.map((o) => o.value), } if (canEditName) body.name = name.trim() + const nextParentId = parentIdValue + const parentChanged = + canEditParent && + baselineRef.current && + nextParentId !== null && + nextParentId !== baselineRef.current.parentId + + if (parentChanged) { + const moveRes = await captureAndAlertRequestErrorHoc( + moveDepartmentApi(dept.dept_id, nextParentId) + ) + if (moveRes === null || moveRes === false) return + } const res = await captureAndAlertRequestErrorHoc( updateDepartmentApi(dept.dept_id, body) ) @@ -206,8 +230,8 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings name: name.trim(), admins: adminOpts, defaultRoleIds: [...defaultRoleIds], + parentId: nextParentId ?? baselineRef.current?.parentId ?? dept.parent_id ?? null, } - setUserSearchOptions(mergeUserOptions([], adminOpts)) onChanged() } finally { setSaving(false) @@ -215,12 +239,14 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings }, [ adminSelectValue, canEditName, + canEditParent, canEditPermissions, defaultRoleIds, dept.dept_id, - mergeUserOptions, + dept.parent_id, name, onChanged, + parentIdValue, t, ]) @@ -284,14 +310,14 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings }) }, [dept.dept_id, onChanged, t]) - const findParentName = ( + const findParentDisplay = ( nodes: DepartmentTreeNode[], parentId: number | null ): string => { if (parentId === null) return "-" for (const n of nodes) { if (n.id === parentId) return n.name - const found = findParentName(n.children || [], parentId) + const found = findParentDisplay(n.children || [], parentId) if (found !== "-") return found } return "-" @@ -335,14 +361,28 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings /> )} -
- - -
+ {!isRootDept && ( +
+ + {canEditParent ? ( + setParentIdValue(id)} + className={FORM_CONTROL_WIDTH} + placeholder={t("bs:department.selectDept")} + searchPlaceholder={t("bs:department.parentDept")} + modal={false} + /> + ) : ( + + )} +
+ )} {/* 区块二:权限与角色 */} @@ -356,26 +396,18 @@ export function DepartmentSettings({ dept, tree, onChanged }: DepartmentSettings
- {}} value={adminSelectValue} - options={userSearchOptions} - placeholder={t("bs:department.adminSelectPlaceholder")} - searchPlaceholder={t("bs:department.searchUsersPlaceholder")} - onSearch={(q) => scheduleUserSearch(q)} - onLoad={() => { - void runUserSearch("", adminSelectValueRef.current) - }} onChange={(vals) => { - const v = (vals as AdminOption[]) || [] + const v = (vals as DepartmentUserOption[]) || [] setAdminSelectValue(v) adminSelectValueRef.current = v }} + placeholder={t("bs:department.adminSelectPlaceholder")} + searchPlaceholder={t("bs:department.searchUsersPlaceholder")} className={FORM_CONTROL_WIDTH} - contentClassName="min-w-[var(--radix-select-trigger-width)]" />

{t("bs:department.adminsHint")} diff --git a/src/frontend/platform/src/pages/KnowledgePage/KnowledgeFile.tsx b/src/frontend/platform/src/pages/KnowledgePage/KnowledgeFile.tsx index 8a668df3c..19297c40a 100644 --- a/src/frontend/platform/src/pages/KnowledgePage/KnowledgeFile.tsx +++ b/src/frontend/platform/src/pages/KnowledgePage/KnowledgeFile.tsx @@ -13,7 +13,6 @@ import { import { BookIcon } from "@/components/bs-icons/knowledge"; import { LoadIcon, LoadingIcon } from "@/components/bs-icons/loading"; import { bsConfirm } from "@/components/bs-ui/alertDialog/useConfirm"; -import { PermissionBadge } from "@/components/bs-comp/permission/PermissionBadge"; import { PermissionDialog } from "@/components/bs-comp/permission/PermissionDialog"; import { canManageResource, usePermissionLevels } from "@/components/bs-comp/permission/usePermissionLevels"; import { RelationLevel } from "@/components/bs-comp/permission/types"; @@ -537,9 +536,8 @@ export default function KnowledgeFile() {

-
+
{el.name} -
(null); const [selectOpenId, setSelectOpenId] = useState(null); const [modalKey, setModalKey] = useState(0); + const [permDialogOpen, setPermDialogOpen] = useState(false); + const [permTarget, setPermTarget] = useState<{ id: string; name: string } | null>(null); const { page, pageSize, data: datalist, total, loading, setPage, search, reload } = useTable( { cancelLoadingWhenReload: true }, @@ -459,6 +462,10 @@ export default function KnowledgeQa(params) { onValueChange={(selectedValue) => { setSelectOpenId(null); switch (selectedValue) { + case 'permission': + setPermTarget({ id: String(el.id), name: el.name }); + setPermDialogOpen(true); + break; case 'copy': canUseCopy(el) && el.state === KnowledgeBaseStatus.Published && @@ -494,6 +501,14 @@ export default function KnowledgeQa(params) { onClick={(e) => e.stopPropagation()} className="z-50 overflow-visible" > + {canManageResource(permLevels, el.id) && ( + +
+ + {t('managePermission', { ns: 'permission' })} +
+
+ )} )} + + {permTarget && ( + + )}
); } \ No newline at end of file diff --git a/src/frontend/platform/src/pages/LoginPage/login.tsx b/src/frontend/platform/src/pages/LoginPage/login.tsx index 6cb7f0010..67289a57f 100644 --- a/src/frontend/platform/src/pages/LoginPage/login.tsx +++ b/src/frontend/platform/src/pages/LoginPage/login.tsx @@ -82,14 +82,32 @@ export const LoginPage = () => { }, []); const fetchCaptchaData = () => { - getCaptchaApi().then(setCaptchaData) + getCaptchaApi() + .then((raw: any) => { + const rawFlag = raw?.user_capthca ?? raw?.user_captcha; + const enabled = + rawFlag === true || + rawFlag === 1 || + (typeof rawFlag === 'string' + && ['true', '1', 'yes', 'on'].includes(String(rawFlag).trim().toLowerCase())); + setCaptchaData({ + captcha_key: raw?.captcha_key ?? '', + captcha: raw?.captcha ?? '', + user_capthca: enabled, + }); + }) + .catch(() => { + setCaptchaData({ captcha_key: '', captcha: '', user_capthca: false }); + }); }; const [hasLdap, setHasLdap] = useState(false) const [ldapCheckboxLabel, setLdapCheckboxLabel] = useState('') const [isLdapLogin, setIsLdapLogin] = useState(true) const enableDualLogin = hasLdap && !!ldapCheckboxLabel - const shouldUseLdap = hasLdap && (!enableDualLogin || isLdapLogin) + // 仅当网关下发了「LDAP / 本地」切换文案时,才走 LDAP 口令登录;否则有 LDAP 配置也走 Bisheng + // 本地登录(含验证码),避免「界面不显示验证码却走 LDAP/空 captcha_key」导致后端报验证码错误。 + const shouldUseLdap = hasLdap && !!ldapCheckboxLabel && isLdapLogin const showCaptcha = captchaData.user_capthca && (!showLogin || !shouldUseLdap) const handleLogin = async () => { diff --git a/src/frontend/platform/src/pages/SystemPage/components/Departments.tsx b/src/frontend/platform/src/pages/SystemPage/components/Departments.tsx index e3f93fc35..b72387cdf 100644 --- a/src/frontend/platform/src/pages/SystemPage/components/Departments.tsx +++ b/src/frontend/platform/src/pages/SystemPage/components/Departments.tsx @@ -10,10 +10,15 @@ import { DepartmentSettings } from "@/pages/DepartmentPage/components/Department import { DepartmentTrafficControl } from "@/pages/DepartmentPage/components/DepartmentTrafficControl" import { CreateDepartmentDialog } from "@/pages/DepartmentPage/components/CreateDepartmentDialog" import { locationContext } from "@/contexts/locationContext" +import { userContext } from "@/contexts/userContext" export default function Departments() { const { t } = useTranslation() const { appConfig } = useContext(locationContext) + const { user } = useContext(userContext) + /** 与系统页「组织同步」一致:仅平台超级管理员(role=admin) */ + const isSuperAdmin = user?.role === "admin" + const showTrafficControlTab = isSuperAdmin && appConfig.isPro const [tree, setTree] = useState([]) const [selectedDeptId, setSelectedDeptId] = useState(null) const [selectedDept, setSelectedDept] = useState(null) @@ -153,7 +158,7 @@ export default function Departments() { {t("bs:department.members")} {t("bs:department.settings")} - {appConfig.isPro && ( + {showTrafficControlTab && ( {t("bs:department.trafficControl")} )} @@ -169,7 +174,7 @@ export default function Departments() { - {appConfig.isPro && ( + {showTrafficControlTab && ( diff --git a/src/frontend/platform/src/pages/SystemPage/components/OrgSync/index.tsx b/src/frontend/platform/src/pages/SystemPage/components/OrgSync/index.tsx index eb03ac519..5a8295399 100644 --- a/src/frontend/platform/src/pages/SystemPage/components/OrgSync/index.tsx +++ b/src/frontend/platform/src/pages/SystemPage/components/OrgSync/index.tsx @@ -45,13 +45,12 @@ export default function OrgSync() { const gatewayTotal = useOrgSyncStore((s) => s.gatewayTotal) const loading = useOrgSyncStore((s) => s.loading) const page = useOrgSyncStore((s) => s.page) - const fetchGatewayLogs = useOrgSyncStore((s) => s.fetchGatewayLogs) const [detailLog, setDetailLog] = useState(null) useEffect(() => { - captureAndAlertRequestErrorHoc(fetchGatewayLogs(1)) - }, [fetchGatewayLogs]) + captureAndAlertRequestErrorHoc(useOrgSyncStore.getState().fetchGatewayLogs(1)) + }, []) const maxPage = useMemo( () => Math.max(1, Math.ceil(gatewayTotal / PAGE_SIZE)), @@ -61,13 +60,13 @@ export default function OrgSync() { const goPrev = () => { if (page <= 1) return const next = page - 1 - captureAndAlertRequestErrorHoc(fetchGatewayLogs(next)) + captureAndAlertRequestErrorHoc(useOrgSyncStore.getState().fetchGatewayLogs(next)) } const goNext = () => { if (page >= maxPage) return const next = page + 1 - captureAndAlertRequestErrorHoc(fetchGatewayLogs(next)) + captureAndAlertRequestErrorHoc(useOrgSyncStore.getState().fetchGatewayLogs(next)) } return ( diff --git a/src/frontend/platform/src/types/api/user.ts b/src/frontend/platform/src/types/api/user.ts index f126cb97c..ddefba3e2 100644 --- a/src/frontend/platform/src/types/api/user.ts +++ b/src/frontend/platform/src/types/api/user.ts @@ -3,7 +3,10 @@ export type User = { external_id?: string | null; email: string | null; phone_number: string | null; - dept_id: number | null; + /** 历史/业务侧部门标识,字符串居多;与组织树节点 ``id`` 不一定一致 */ + dept_id?: number | string | null; + /** 主部门在 ``department`` 表中的内部主键,与 ``/departments/tree`` 的 ``id`` 对齐(/user/list 补充) */ + department_id?: number | null; remark: string | null; delete: number; create_time: string;