Quellcode durchsuchen

1. 实现了统一门户跳转按需登录功能,验签通过后自动落库建档并同步组织信息,首次进入设置独立密码、再次进入凭独立密码登录,对端接口支持纯配置或自定义函数适配;
2. 优化了全局检索过渡视图,检索结果展示时同步隐藏下方配套方案等关系面板,退出检索或定位后自动恢复;
3. 修复了检索定位后关系面板页签不随目标切换的问题,定位配套方案或附件时自动切至对应类型页签。

weisijie vor 1 Monat
Ursprung
Commit
16aed0cdd7

+ 36 - 0
backend/.env.example

@@ -34,3 +34,39 @@ DMS_SEED_USER_PASSWORD=
 DMS_SEED_OVERWRITE_PASSWORDS=false
 
 # 既有AI配置仍独立使用 INFO_EXTRACTOR_* 环境变量
+
+# ─────────────────────────────────────────────────────────────
+# 门户跳转按需登录(对接其他系统门户;留空/不配置=功能整体关闭,
+# 用户名密码登录不受影响)。详见 docs/门户对接指南.md
+# ─────────────────────────────────────────────────────────────
+# provider类型:留空=关闭 / mock=本地联调 / http=标准REST配置驱动 / custom=手写适配
+DMS_PORTAL_PROVIDER=
+# 跳转URL中携带token的参数名(默认token)
+DMS_PORTAL_TOKEN_PARAM=token
+
+# ---- 以下仅 provider=http 时需要 ----
+# 对端验签接口地址(token怎么传由下面三项决定)
+DMS_PORTAL_VERIFY_URL=
+# GET 或 POST
+DMS_PORTAL_VERIFY_METHOD=POST
+# token放置位置:header / body / query
+DMS_PORTAL_VERIFY_TOKEN_IN=header
+# header方式时的头名与值模板({token}为占位符)
+DMS_PORTAL_VERIFY_HEADER=Authorization
+DMS_PORTAL_VERIFY_HEADER_TEMPLATE=Bearer {token}
+# 验签成功判定:响应JSON中该字段的值等于该值才算通过(支持a.b点路径)
+DMS_PORTAL_VERIFY_SUCCESS_FIELD=code
+DMS_PORTAL_VERIFY_SUCCESS_VALUE=0
+# 验签响应中用户信息对象所在字段路径(如 data)
+DMS_PORTAL_VERIFY_USER_FIELD=data
+# 验签响应不含完整用户信息时,额外的用户信息接口(留空=验签已含)
+DMS_PORTAL_USERINFO_URL=
+DMS_PORTAL_USERINFO_UID_PARAM=userId
+# 用户信息字段映射 local:remote(local固定为externalId/username/realName/orgName)
+DMS_PORTAL_FIELD_MAP=externalId:userId,username:loginName,realName:realName,orgName:orgName
+
+# ---- 新用户落库 ----
+# 组织名称匹配不到时的默认组织ID(留空=不挂组织)
+DMS_PORTAL_DEFAULT_ORG_ID=
+# 对端HTTP请求超时秒数
+DMS_PORTAL_HTTP_TIMEOUT_SECONDS=10

+ 1 - 0
backend/dms/api/v1/blueprint.py

@@ -121,6 +121,7 @@ from dms.api.v1 import (  # noqa: E402,F401
     documents,
     ip_entries,
     organizations,
+    portal_auth,
     recycle_bin,
     roles,
     runtime_config,

+ 68 - 0
backend/dms/api/v1/portal_auth.py

@@ -0,0 +1,68 @@
+"""门户跳转登录接口:验签/按需落库 + 首次设密。"""
+
+from __future__ import annotations
+
+from flask import request
+
+from dms.api.v1.blueprint import api_v1
+from dms.common.errors import InvalidArgumentError, LoginIpForbiddenError
+from dms.common.response import success_response
+from dms.services.audit_service import record_login_failure
+from dms.services.portal_auth_service import setup_portal_password, verify_portal_launch
+
+
+def _verify_payload() -> str:
+    if not request.is_json:
+        raise InvalidArgumentError("请求体必须是JSON对象")
+    body = request.get_json(silent=True)
+    if not isinstance(body, dict) or set(body) != {"query"}:
+        raise InvalidArgumentError("请求体必须且只能包含query")
+    query = body["query"]
+    if not isinstance(query, str) or not query.strip():
+        raise InvalidArgumentError("query不能为空")
+    return query.strip()
+
+
+@api_v1.post("/auth/portal/verify")
+def portal_verify():
+    """门户跳转验签:透传浏览器地址栏query串,返回跳转阶段与用户名。"""
+    return success_response(verify_portal_launch(_verify_payload()))
+
+
+def _setup_payload() -> tuple[str, str, bool]:
+    if not request.is_json:
+        raise InvalidArgumentError("请求体必须是JSON对象")
+    body = request.get_json(silent=True)
+    if not isinstance(body, dict):
+        raise InvalidArgumentError("请求体必须是JSON对象")
+    expected = {"portalTicket", "newPassword", "keepSignedIn"}
+    if set(body) != expected:
+        raise InvalidArgumentError(
+            "请求体必须且只能包含portalTicket、newPassword和keepSignedIn"
+        )
+    ticket = body["portalTicket"]
+    password = body["newPassword"]
+    keep_signed_in = body["keepSignedIn"]
+    if not isinstance(ticket, str) or not ticket.strip():
+        raise InvalidArgumentError("portalTicket不能为空")
+    if not isinstance(password, str) or not password:
+        raise InvalidArgumentError("newPassword不能为空")
+    if not isinstance(keep_signed_in, bool):
+        raise InvalidArgumentError("keepSignedIn必须是布尔值")
+    return ticket.strip(), password, keep_signed_in
+
+
+@api_v1.post("/auth/portal/setup-password")
+def portal_setup_password():
+    """门户用户首次设置独立密码并登录(票据一次性,5分钟内有效)。"""
+    ticket, password, keep_signed_in = _setup_payload()
+    try:
+        payload = setup_portal_password(ticket, password, keep_signed_in)
+    except LoginIpForbiddenError:
+        record_login_failure(
+            user=None,
+            attempted_username="(portal-setup)",
+            failure_reason="IP_NOT_ALLOWED",
+        )
+        raise
+    return success_response(payload)

+ 10 - 0
backend/dms/common/errors.py

@@ -55,6 +55,16 @@ class AuthVersionMismatchError(AuthenticationError):
     default_message = "登录状态已失效,请重新登录"
 
 
+class PortalTokenInvalidError(AuthenticationError):
+    code = "PORTAL_TOKEN_INVALID"
+    default_message = "门户跳转凭证无效或已过期"
+
+
+class PortalTicketInvalidError(AuthenticationError):
+    code = "PORTAL_TICKET_INVALID"
+    default_message = "设置密码凭证无效或已过期,请从门户重新跳转"
+
+
 class ForbiddenError(DmsError):
     code = "FORBIDDEN"
     status_code = 403

+ 27 - 1
backend/dms/models/user.py

@@ -4,7 +4,17 @@ from __future__ import annotations
 
 from datetime import datetime
 
-from sqlalchemy import BigInteger, CheckConstraint, ForeignKey, Index, Integer, String, text
+from sqlalchemy import (
+    BigInteger,
+    CheckConstraint,
+    Computed,
+    ForeignKey,
+    Index,
+    Integer,
+    String,
+    UniqueConstraint,
+    text,
+)
 from sqlalchemy.dialects.mysql import DATETIME
 from sqlalchemy.orm import Mapped, mapped_column
 
@@ -15,6 +25,16 @@ from dms.extensions import db
 class User(BusinessTableMixin, db.Model):
     __tablename__ = "sys_user"
     __table_args__ = (
+        UniqueConstraint(
+            "external_id",
+            "active_marker",
+            name="uq_sys_user_external_id_active",
+        ),
+        Index(
+            "ix_sys_user_external_active",
+            "external_id",
+            "is_deleted",
+        ),
         CheckConstraint(
             "role_code IN ('USER', 'ADMIN')",
             name="user_role_code",
@@ -86,3 +106,9 @@ class User(BusinessTableMixin, db.Model):
         nullable=True,
     )
     last_login_ip: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    external_id: Mapped[str | None] = mapped_column(String(128), nullable=True)
+    active_marker: Mapped[int | None] = mapped_column(
+        Integer(),
+        Computed("IF(is_deleted = 0, 1, NULL)", persisted=False),
+        nullable=True,
+    )

+ 261 - 0
backend/dms/services/portal_auth_service.py

@@ -0,0 +1,261 @@
+"""门户跳转登录服务:验签 → 用户匹配/按需落库 → 独立密码登录或首次设密。
+
+流程(见 docs/门户对接指南.md):
+1. verify:提取token → 对端验签 → external_id/username 匹配本地用户;
+   不存在则落库(密码为随机占位,不可登录),存在则回填external_id并同步展示信息;
+   用户从未登录过(last_login_at为空)→ SET_PASSWORD(发一次性票据),否则 LOGIN。
+2. setup-password:凭票据设置独立密码并完成登录(复用IP限制/审计/JWT签发)。
+   设密成功后 auth_version+1,票据随之失效(天然一次性)。
+"""
+
+from __future__ import annotations
+
+import secrets
+from datetime import datetime, timedelta, timezone
+
+import jwt
+from flask import current_app
+from sqlalchemy import select
+
+from dms.common.enums import AuditAction, AuditResult, EnabledStatus
+from dms.common.errors import (
+    InternalError,
+    InvalidArgumentError,
+    PortalTicketInvalidError,
+    PortalTokenInvalidError,
+    UserDisabledError,
+)
+from dms.database.transaction import for_update, transaction
+from dms.models import Organization, User
+from dms.security.jwt_tokens import issue_access_token
+from dms.security.passwords import hash_password
+from dms.services.audit_service import auth_audit, client_ip
+from dms.services.ip_entry_service import verify_login_ip
+from dms.services.portal_config import (
+    PortalConfigError,
+    load_portal_config,
+)
+from dms.services.portal_provider import (
+    PortalIdentityError,
+    PortalTokenError,
+    extract_token,
+    resolve_portal_identity,
+)
+from dms.services.user_service import user_summary
+
+SETUP_TICKET_TTL_SECONDS = 300
+_TICKET_PURPOSE = "PORTAL_SETUP"
+
+
+def _validate_portal_password(value: str) -> None:
+    # 与 user_service 的密码规则保持一致(8~64位)
+    if not isinstance(value, str) or not value:
+        raise InvalidArgumentError("密码不能为空")
+    if len(value) < 8 or len(value) > 64:
+        raise InvalidArgumentError("密码长度必须在8~64个字符之间")
+
+
+def _match_organization(
+    session, org_name: str | None
+) -> tuple[int | None, str | None]:
+    """按组织名称精确匹配(未删);匹配不到回落默认组织,再不行不挂组织。"""
+    if org_name:
+        org = session.scalar(
+            select(Organization).where(
+                Organization.org_name == org_name,
+                Organization.is_deleted.is_(False),
+            )
+        )
+        if org is not None:
+            return org.id, org.org_name
+    default_org_id = load_portal_config().default_org_id
+    if default_org_id:
+        org = session.get(Organization, default_org_id)
+        if org is not None and not org.is_deleted:
+            return org.id, org.org_name
+    return None, None
+
+
+def verify_portal_launch(query_string: str) -> dict[str, object]:
+    """门户跳转验签入口,返回 phase/username/realName(及SET_PASSWORD时的票据)。"""
+    if not load_portal_config().enabled:
+        raise InvalidArgumentError("门户登录未启用")
+    token = extract_token(query_string)
+    if token is None:
+        raise InvalidArgumentError(
+            "跳转链接缺少token参数(参数名以DMS_PORTAL_TOKEN_PARAM配置为准)"
+        )
+
+    try:
+        identity = resolve_portal_identity(token)
+    except PortalConfigError as exc:
+        raise InternalError(f"门户配置错误:{exc}") from exc
+    except PortalTokenError as exc:
+        raise PortalTokenInvalidError(str(exc)) from exc
+    except PortalIdentityError as exc:
+        raise InternalError(f"对端用户信息不完整:{exc}") from exc
+
+    if len(identity.username) > 64 or len(identity.real_name) > 64:
+        raise InternalError("对端用户名或姓名超出本系统长度限制(64字符)")
+
+    with transaction() as session:
+        user = session.scalar(
+            select(User).where(
+                User.external_id == identity.external_id,
+                User.is_deleted.is_(False),
+            )
+        )
+        if user is None:
+            user = session.scalar(
+                select(User).where(
+                    User.username == identity.username,
+                    User.is_deleted.is_(False),
+                )
+            )
+
+        org_id, org_name = _match_organization(session, identity.org_name)
+        detail = {
+            "portal": True,
+            "externalId": identity.external_id,
+        }
+
+        if user is None:
+            user = User(
+                username=identity.username,
+                # 随机占位密码:在设置独立密码前任何密码都无法通过校验
+                password_hash=hash_password(secrets.token_urlsafe(32)),
+                real_name=identity.real_name,
+                external_id=identity.external_id,
+                organization_id=org_id,
+                organization_name=org_name,
+                role_code="USER",
+                security_level="INTERNAL",
+                status="ENABLED",
+            )
+            session.add(user)
+            session.flush()
+            session.add(
+                auth_audit(
+                    action=AuditAction.CREATE_USER,
+                    result=AuditResult.SUCCESS,
+                    user=user,
+                    detail=detail,
+                )
+            )
+        else:
+            changed = False
+            if user.external_id != identity.external_id:
+                user.external_id = identity.external_id
+                changed = True
+            # username 不跟随对端变更(避免与本地其他用户冲突),以本地为准登录
+            if identity.real_name and user.real_name != identity.real_name:
+                user.real_name = identity.real_name
+                changed = True
+            if org_id is not None and user.organization_id != org_id:
+                user.organization_id = org_id
+                user.organization_name = org_name
+                changed = True
+            if changed:
+                user.row_version += 1
+                session.add(
+                    auth_audit(
+                        action=AuditAction.EDIT_USER,
+                        result=AuditResult.SUCCESS,
+                        user=user,
+                        detail=detail,
+                    )
+                )
+
+        if user.status != EnabledStatus.ENABLED.value:
+            raise UserDisabledError()
+
+        if user.last_login_at is None:
+            return {
+                "phase": "SET_PASSWORD",
+                "username": user.username,
+                "realName": user.real_name,
+                "portalTicket": _issue_setup_ticket(user),
+            }
+        return {
+            "phase": "LOGIN",
+            "username": user.username,
+            "realName": user.real_name,
+        }
+
+
+def _ticket_secret() -> str:
+    secret = str(current_app.config.get("DMS_JWT_SECRET", "")).strip()
+    if not secret:
+        raise InternalError("JWT签名密钥未配置")
+    return secret
+
+
+def _issue_setup_ticket(user: User) -> str:
+    now = datetime.now(timezone.utc)
+    claims = {
+        "sub": str(user.id),
+        "purpose": _TICKET_PURPOSE,
+        "authVersion": user.auth_version,
+        "iat": now,
+        "exp": now + timedelta(seconds=SETUP_TICKET_TTL_SECONDS),
+        "iss": current_app.config["DMS_JWT_ISSUER"],
+    }
+    return jwt.encode(claims, _ticket_secret(), algorithm="HS256")
+
+
+def _consume_setup_ticket(session, ticket: str) -> User:
+    """校验并消费票据:过期/复用(auth_version已变)/用户无效均拒绝。"""
+    try:
+        claims = jwt.decode(
+            ticket,
+            _ticket_secret(),
+            algorithms=["HS256"],
+            issuer=current_app.config["DMS_JWT_ISSUER"],
+            options={"require": ["sub", "purpose", "authVersion", "exp"]},
+        )
+    except jwt.PyJWTError as exc:
+        raise PortalTicketInvalidError() from exc
+    if claims.get("purpose") != _TICKET_PURPOSE or not str(claims.get("sub", "")).isdigit():
+        raise PortalTicketInvalidError()
+    user = session.scalar(
+        for_update(select(User).where(User.id == int(claims["sub"])))
+    )
+    if user is None or user.is_deleted:
+        raise PortalTicketInvalidError()
+    if user.auth_version != claims.get("authVersion"):
+        # 票据已使用过(设密后auth_version+1)或用户状态已变更
+        raise PortalTicketInvalidError("设置密码凭证已使用,请从门户重新跳转")
+    return user
+
+
+def setup_portal_password(
+    ticket: str, new_password: str, keep_signed_in: bool
+) -> dict[str, object]:
+    """凭票据设置独立密码并直接完成登录(返回与/auth/login一致的结构)。"""
+    _validate_portal_password(new_password)
+    with transaction() as session:
+        user = _consume_setup_ticket(session, ticket)
+        if user.status != EnabledStatus.ENABLED.value:
+            raise UserDisabledError()
+        # IP登录限制与普通登录一致(ADMIN豁免、无部门/无分配放行)
+        verify_login_ip(user)
+        user.password_hash = hash_password(new_password)
+        user.auth_version += 1  # 使票据一次性失效
+        user.last_login_at = datetime.now(timezone.utc).replace(tzinfo=None)
+        user.last_login_ip = client_ip()
+        user.row_version += 1
+        session.add(
+            auth_audit(
+                action=AuditAction.LOGIN,
+                result=AuditResult.SUCCESS,
+                user=user,
+                detail={"portal": True, "setupPassword": True},
+            )
+        )
+        token, expires_in = issue_access_token(user, keep_signed_in=keep_signed_in)
+    return {
+        "accessToken": token,
+        "tokenType": "Bearer",
+        "expiresIn": expires_in,
+        "user": user_summary(user),
+    }

+ 121 - 0
backend/dms/services/portal_config.py

@@ -0,0 +1,121 @@
+"""门户跳转集成配置:全部从环境变量(.env)读取。
+
+对接对端门户系统时,优先只改 .env(provider=http 的声明式对接);
+形态特殊时改 portal_provider.py 中的一个函数(见该文件内模板)。
+本模块在每次请求时读取环境变量,改 .env 后重启进程生效。
+"""
+
+from __future__ import annotations
+
+import os
+from dataclasses import dataclass
+
+
+ALLOWED_PROVIDERS = {"", "mock", "http", "custom"}
+ALLOWED_LOCAL_FIELDS = {"externalId", "username", "realName", "orgName"}
+
+
+class PortalConfigError(Exception):
+    """门户配置缺失或非法(服务端 .env 问题,非用户输入问题)。"""
+
+
+@dataclass(frozen=True)
+class PortalConfig:
+    provider: str  # ""=关闭 / mock / http / custom
+    token_param: str
+    # ---- http provider:验签 ----
+    verify_url: str
+    verify_method: str  # GET / POST
+    verify_token_in: str  # header / body / query
+    verify_header: str
+    verify_header_template: str  # 内含 {token} 占位
+    verify_success_field: str
+    verify_success_value: str
+    verify_user_field: str  # 验签响应中用户信息对象所在字段路径(如 data)
+    # ---- http provider:独立的用户信息接口(可空=验签已含用户信息)----
+    userinfo_url: str
+    userinfo_uid_param: str
+    # ---- 用户信息字段映射:local -> remote ----
+    field_map: dict[str, str]
+    # ---- 新用户落库默认值 ----
+    default_org_id: int | None
+    # ---- 其他 ----
+    http_timeout_seconds: int
+
+    @property
+    def enabled(self) -> bool:
+        return bool(self.provider)
+
+
+def _env(name: str, default: str = "") -> str:
+    return os.environ.get(name, default).strip()
+
+
+def _parse_field_map(raw: str) -> dict[str, str]:
+    """解析 ``local:remote,local:remote`` 形式的字段映射。"""
+    field_map: dict[str, str] = {}
+    for chunk in raw.split(","):
+        chunk = chunk.strip()
+        if not chunk:
+            continue
+        local, sep, remote = chunk.partition(":")
+        local = local.strip()
+        remote = remote.strip()
+        if not sep or local not in ALLOWED_LOCAL_FIELDS or not remote:
+            raise PortalConfigError(
+                f"DMS_PORTAL_FIELD_MAP格式非法({chunk}),"
+                f"合法local字段:{sorted(ALLOWED_LOCAL_FIELDS)}"
+            )
+        field_map[local] = remote
+    return field_map
+
+
+def load_portal_config() -> PortalConfig:
+    provider = _env("DMS_PORTAL_PROVIDER").lower()
+    if provider not in ALLOWED_PROVIDERS:
+        raise PortalConfigError(
+            "DMS_PORTAL_PROVIDER必须是 mock/http/custom 之一,留空表示关闭门户登录"
+        )
+
+    field_map = _parse_field_map(
+        _env(
+            "DMS_PORTAL_FIELD_MAP",
+            "externalId:userId,username:loginName,realName:realName,orgName:orgName",
+        )
+    )
+    if "externalId" not in field_map:
+        raise PortalConfigError("DMS_PORTAL_FIELD_MAP必须包含externalId映射")
+
+    raw_org_id = _env("DMS_PORTAL_DEFAULT_ORG_ID")
+    try:
+        default_org_id = int(raw_org_id) if raw_org_id else None
+    except ValueError as exc:
+        raise PortalConfigError("DMS_PORTAL_DEFAULT_ORG_ID必须是整数") from exc
+
+    timeout_raw = _env("DMS_PORTAL_HTTP_TIMEOUT_SECONDS", "10")
+    try:
+        timeout_seconds = int(timeout_raw) if timeout_raw else 10
+    except ValueError as exc:
+        raise PortalConfigError("DMS_PORTAL_HTTP_TIMEOUT_SECONDS必须是整数") from exc
+
+    token_in = _env("DMS_PORTAL_VERIFY_TOKEN_IN", "header").lower()
+    if token_in not in {"header", "body", "query"}:
+        raise PortalConfigError("DMS_PORTAL_VERIFY_TOKEN_IN必须是header/body/query")
+
+    return PortalConfig(
+        provider=provider,
+        token_param=_env("DMS_PORTAL_TOKEN_PARAM", "token"),
+        verify_url=_env("DMS_PORTAL_VERIFY_URL"),
+        verify_method=_env("DMS_PORTAL_VERIFY_METHOD", "POST").upper(),
+        verify_token_in=token_in,
+        verify_header=_env("DMS_PORTAL_VERIFY_HEADER", "Authorization"),
+        verify_header_template=_env("DMS_PORTAL_VERIFY_HEADER_TEMPLATE", "Bearer {token}"),
+        verify_success_field=_env("DMS_PORTAL_VERIFY_SUCCESS_FIELD", "code"),
+        verify_success_value=_env("DMS_PORTAL_VERIFY_SUCCESS_VALUE", "0"),
+        verify_user_field=_env("DMS_PORTAL_VERIFY_USER_FIELD", "data"),
+        userinfo_url=_env("DMS_PORTAL_USERINFO_URL"),
+        userinfo_uid_param=_env("DMS_PORTAL_USERINFO_UID_PARAM", "userId"),
+        field_map=field_map,
+        default_org_id=default_org_id,
+        http_timeout_seconds=timeout_seconds,
+    )

+ 218 - 0
backend/dms/services/portal_provider.py

@@ -0,0 +1,218 @@
+"""门户身份适配器:验签 + 拉取用户信息(返回统一中间结构 PortalIdentity)。
+
+════════════════════════════════════════════════════════════════
+对端门户系统确定后,本文件是唯一可能需要修改的后端代码:
+
+1. 标准REST形态 → .env 配 DMS_PORTAL_PROVIDER=http + 相关键,无需改代码;
+2. 特殊形态(本地公钥验JWT / HMAC共享密钥 / 怪异接口)→
+   .env 配 DMS_PORTAL_PROVIDER=custom,并替换本文件底部
+   _custom_provider 函数体(按其内的示例模板手写)。
+════════════════════════════════════════════════════════════════
+"""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from urllib.parse import parse_qs
+
+import requests as http_client
+
+from dms.services.portal_config import PortalConfigError, load_portal_config
+
+
+@dataclass(frozen=True)
+class PortalIdentity:
+    """对端门户用户身份的统一中间结构(与对端具体格式解耦)。"""
+
+    external_id: str
+    username: str
+    real_name: str
+    org_name: str | None = None
+
+
+class PortalTokenError(Exception):
+    """对端验签未通过(token非法/过期),端点层转为401。"""
+
+
+class PortalIdentityError(Exception):
+    """对端返回的用户信息缺少必要字段。"""
+
+
+def extract_token(query_string: str) -> str | None:
+    """从跳转URL的query串中提取token(参数名来自配置)。"""
+    cfg = load_portal_config()
+    values = parse_qs(query_string.lstrip("?")).get(cfg.token_param, [])
+    value = values[0].strip() if values else ""
+    return value or None
+
+
+def resolve_portal_identity(token: str) -> PortalIdentity:
+    """按配置的provider类型分发验签与身份拉取。"""
+    cfg = load_portal_config()
+    if cfg.provider == "mock":
+        return _mock_provider(token)
+    if cfg.provider == "http":
+        return _http_provider(token)
+    if cfg.provider == "custom":
+        return _custom_provider(token)
+    raise PortalConfigError("DMS_PORTAL_PROVIDER未配置")
+
+
+# ---------------------------------------------------------------- mock
+
+
+def _mock_provider(token: str) -> PortalIdentity:
+    """开发/测试用。token自描述格式:``mock:<externalId>:<username>:<realName>[:orgName]``。
+
+    示例(浏览器直接访问验证):
+        http://localhost:9345/?token=mock:1001:portal_user:门户联调用户:机关
+    """
+    parts = token.split(":")
+    if len(parts) != 4 and len(parts) != 5:
+        raise PortalTokenError(
+            "mock token格式应为 mock:<externalId>:<username>:<realName>[:orgName]"
+        )
+    org_name = parts[4] if len(parts) == 5 and parts[4] else None
+    return PortalIdentity(
+        external_id=parts[1],
+        username=parts[2],
+        real_name=parts[3],
+        org_name=org_name,
+    )
+
+
+# ---------------------------------------------------------------- http
+
+
+def _dig(data: object, path: str) -> object:
+    """按 ``a.b`` 点路径从嵌套dict取值(仅支持dict层级,不支持数组)。"""
+    current = data
+    for key in path.split("."):
+        if not isinstance(current, dict):
+            return None
+        current = current.get(key)
+    return current
+
+
+def _mapped_str(user_obj: dict, local_field: str, *, required: bool, fallback: str = "") -> str | None:
+    cfg = load_portal_config()
+    remote = cfg.field_map.get(local_field, "")
+    value = user_obj.get(remote) if remote else None
+    if isinstance(value, str) and value.strip():
+        return value.strip()
+    if isinstance(value, int):
+        return str(value)
+    if required and not fallback:
+        raise PortalIdentityError(f"对端用户信息缺少{local_field}(远端字段名:{remote})")
+    return fallback or None
+
+
+def _http_provider(token: str) -> PortalIdentity:
+    """声明式HTTP对接:按.env配置调用对端验签(及可选的用户信息接口)。"""
+    cfg = load_portal_config()
+    if not cfg.verify_url:
+        raise PortalConfigError("DMS_PORTAL_PROVIDER=http时必须配置DMS_PORTAL_VERIFY_URL")
+
+    headers: dict[str, str] = {}
+    params: dict[str, str] = {}
+    body: dict[str, str] = {}
+    if cfg.verify_token_in == "header":
+        headers[cfg.verify_header] = cfg.verify_header_template.format(token=token)
+    elif cfg.verify_token_in == "query":
+        params[cfg.token_param] = token
+    else:
+        body[cfg.token_param] = token
+
+    try:
+        resp = http_client.request(
+            cfg.verify_method,
+            cfg.verify_url,
+            headers=headers,
+            params=params,
+            json=body if body else None,
+            timeout=cfg.http_timeout_seconds,
+        )
+        data = resp.json()
+    except (http_client.RequestException, ValueError) as exc:
+        raise PortalTokenError(f"无法访问对端验签接口:{exc}") from exc
+
+    if str(_dig(data, cfg.verify_success_field)) != cfg.verify_success_value:
+        raise PortalTokenError("对端验签未通过(token非法或已过期)")
+
+    user_obj = _dig(data, cfg.verify_user_field)
+    if not isinstance(user_obj, dict):
+        raise PortalIdentityError(
+            f"对端验签响应中未找到用户信息(字段路径:{cfg.verify_user_field})"
+        )
+
+    if cfg.userinfo_url:
+        # 验签响应未含完整用户信息时,再调一次用户信息接口(GET + uid参数 + Bearer token)
+        uid = _mapped_str(user_obj, "externalId", required=True) or ""
+        try:
+            info_resp = http_client.get(
+                cfg.userinfo_url,
+                params={cfg.userinfo_uid_param: uid},
+                headers={"Authorization": f"Bearer {token}"},
+                timeout=cfg.http_timeout_seconds,
+            )
+            user_obj = info_resp.json()
+        except (http_client.RequestException, ValueError) as exc:
+            raise PortalTokenError(f"无法访问对端用户信息接口:{exc}") from exc
+
+    external_id = _mapped_str(user_obj, "externalId", required=True)
+    username = _mapped_str(user_obj, "username", required=False, fallback=external_id or "")
+    real_name = _mapped_str(user_obj, "realName", required=False, fallback=username or "")
+    if not external_id or not username:
+        raise PortalIdentityError("对端用户信息缺少必要的身份字段")
+    return PortalIdentity(
+        external_id=external_id,
+        username=username,
+        real_name=real_name or username,
+        org_name=_mapped_str(user_obj, "orgName", required=False),
+    )
+
+
+# ---------------------------------------------------------------- custom
+
+
+def _custom_provider(token: str) -> PortalIdentity:
+    """手写适配模板:DMS_PORTAL_PROVIDER=custom 时本函数被调用。
+
+    ═══════════════ 手写示例(直接替换本函数体) ═══════════════
+
+    场景:对端是「GET http://portal.internal/api/sso/verify?ticket=xxx」,
+    返回 ``{"ok": true, "data": {"uid": "1001", "name": "张三", "dept": "作战部"}}``。
+
+    import requests as http_client
+
+    try:
+        resp = http_client.get(
+            "http://portal.internal/api/sso/verify",
+            params={"ticket": token},
+            timeout=10,
+        )
+        data = resp.json()
+    except Exception:
+        raise PortalTokenError("无法访问对端验签接口")
+
+    if not data.get("ok"):
+        raise PortalTokenError("对端验签未通过")
+
+    info = data.get("data") or {}
+    uid = str(info.get("uid") or "").strip()
+    name = str(info.get("name") or "").strip()
+    dept = str(info.get("dept") or "").strip() or None
+    if not uid or not name:
+        raise PortalIdentityError("对端用户信息缺少uid或name")
+
+    return PortalIdentity(external_id=uid, username=uid, real_name=name, org_name=dept)
+
+    ═══════════════ 说明 ═══════════════
+    - 只需返回 PortalIdentity,其余(落库/匹配/设密码/登录)由本系统自动完成;
+    - username 缺乏明确登录名时可用 external_id 代替(如上例);
+    - 验签不通过抛 PortalTokenError,字段缺失抛 PortalIdentityError。
+    """
+    raise PortalConfigError(
+        "DMS_PORTAL_PROVIDER=custom 需要在 dms/services/portal_provider.py 的 "
+        "_custom_provider 中按模板实现对端验签逻辑"
+    )

+ 68 - 0
backend/migrations/versions/0016_portal_user_provision.py

@@ -0,0 +1,68 @@
+"""门户跳转用户预置:sys_user.external_id + 软删唯一约束。
+
+Revision ID: 0016_portal_user_provision
+Revises: 0015_login_ip_restriction
+Create Date: 2026-08-23
+
+变更要点:
+1. sys_user 新增 external_id(对端门户系统的用户唯一标识,NULL=本地原生用户);
+2. 新增 MySQL 生成列 active_marker(未删=1,已删=NULL)与
+   唯一约束 uq_sys_user_external_id_active,防止同一门户用户重复落库;
+3. 降级:存在门户用户时拒绝有损降级。
+"""
+
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+from alembic import op
+import sqlalchemy as sa
+
+revision: str = "0016_portal_user_provision"
+down_revision: str | None = "0015_login_ip_restriction"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.add_column(
+        "sys_user",
+        sa.Column("external_id", sa.String(length=128), nullable=True),
+    )
+    op.add_column(
+        "sys_user",
+        sa.Column(
+            "active_marker",
+            sa.Integer(),
+            sa.Computed("IF(is_deleted = 0, 1, NULL)", persisted=False),
+            nullable=True,
+        ),
+    )
+    op.create_unique_constraint(
+        "uq_sys_user_external_id_active",
+        "sys_user",
+        ["external_id", "active_marker"],
+    )
+    op.create_index(
+        "ix_sys_user_external_active",
+        "sys_user",
+        ["external_id", "is_deleted"],
+    )
+
+
+def downgrade() -> None:
+    bind = op.get_bind()
+    portal_users = bind.scalar(
+        sa.text("SELECT COUNT(*) FROM sys_user WHERE external_id IS NOT NULL")
+    )
+    if portal_users:
+        raise RuntimeError(
+            "0016已存在门户用户(external_id非空),拒绝有损降级;"
+            "请先备份并按数据治理流程处理"
+        )
+    op.drop_index("ix_sys_user_external_active", table_name="sys_user")
+    op.drop_constraint(
+        "uq_sys_user_external_id_active", "sys_user", type_="unique"
+    )
+    op.drop_column("sys_user", "active_marker")
+    op.drop_column("sys_user", "external_id")

+ 1 - 0
backend/requirements.txt

@@ -16,4 +16,5 @@ python-docx>=1.1.0
 olefile>=0.47,<1.0
 pymupdf>=1.23.0
 pypinyin>=0.50.0
+requests>=2.31,<3.0
 tzdata>=2026.3

+ 20 - 1
backend/tests/dms/test_migration.py

@@ -41,6 +41,9 @@ DOCUMENT_RELATIONS_REVISION_PATH = (
 LOGIN_IP_REVISION_PATH = (
     MIGRATIONS_ROOT / "versions" / "0015_login_ip_restriction.py"
 )
+PORTAL_USER_PROVISION_REVISION_PATH = (
+    MIGRATIONS_ROOT / "versions" / "0016_portal_user_provision.py"
+)
 
 
 def _load_revision_module():
@@ -54,7 +57,7 @@ def _load_revision_module():
 def test_alembic_config_and_head_are_valid():
     config = Config(str(MIGRATIONS_ROOT / "alembic.ini"))
     script = ScriptDirectory.from_config(config)
-    assert script.get_current_head() == "0015_login_ip_restriction"
+    assert script.get_current_head() == "0016_portal_user_provision"
 
 
 def test_initial_migration_exports_upgrade_and_downgrade():
@@ -263,3 +266,19 @@ def test_login_ip_restriction_migration_is_scoped_and_reversible():
         for name in ("sys_org_ip_assignment", "sys_ip_entry")
     ]
     assert positions == sorted(positions)
+
+
+def test_portal_user_provision_migration_is_scoped_and_reversible():
+    source = PORTAL_USER_PROVISION_REVISION_PATH.read_text(encoding="utf-8")
+    assert (
+        'down_revision: str | None = "0015_login_ip_restriction"' in source
+    )
+    assert '"external_id"' in source
+    assert "sa.String(length=128)" in source
+    assert "uq_sys_user_external_id_active" in source
+    assert "ix_sys_user_external_active" in source
+    assert "IF(is_deleted = 0, 1, NULL)" in source
+    # 降级保护:存在门户用户时拒绝有损降级
+    assert "external_id IS NOT NULL" in source
+    assert "RuntimeError" in source
+    assert "create_table" not in source

+ 5 - 0
backend/tests/dms/test_models.py

@@ -85,6 +85,7 @@ def test_active_marker_columns_are_virtual_generated_columns():
         "sys_role_permission",
         "sys_ip_entry",
         "sys_org_ip_assignment",
+        "sys_user",
     ):
         column = db.metadata.tables[table_name].c.active_marker
         assert isinstance(column.computed, Computed)
@@ -150,6 +151,10 @@ def test_ip_tables_unique_constraints():
     ) in _unique_column_sets("sys_org_ip_assignment")
 
 
+def test_portal_user_external_id_unique_constraint():
+    assert ("external_id", "active_marker") in _unique_column_sets("sys_user")
+
+
 def test_no_foreign_key_uses_cascade_and_audit_has_no_foreign_keys():
     for table in db.metadata.tables.values():
         for foreign_key in table.foreign_keys:

+ 300 - 0
backend/tests/dms/test_portal_auth.py

@@ -0,0 +1,300 @@
+"""门户跳转按需登录(mock provider)全分支测试。"""
+
+from __future__ import annotations
+
+from sqlalchemy import delete
+
+import pytest
+
+from dms.extensions import db
+from dms.models import AuditLog, IpEntry, OrgIpAssignment, Organization, User
+
+
+VERIFY_PATH = "/api/v1/auth/portal/verify"
+SETUP_PATH = "/api/v1/auth/portal/setup-password"
+
+
+@pytest.fixture()
+def portal_mock(monkeypatch):
+    monkeypatch.setenv("DMS_PORTAL_PROVIDER", "mock")
+
+
+@pytest.fixture(autouse=True)
+def _clean_ip_tables(b2_app):
+    """清空IP两表,保证setup-password的verify_login_ip恒走无分配放行分支。"""
+    with b2_app.app_context():
+        db.session.execute(delete(OrgIpAssignment))
+        db.session.execute(delete(IpEntry))
+        db.session.commit()
+
+
+def _verify(client, token: str):
+    return client.post(VERIFY_PATH, json={"query": f"token={token}"})
+
+
+def _find_user(app, username: str) -> User:
+    with app.app_context():
+        user = db.session.scalar(
+            db.select(User).where(User.username == username)
+        )
+        assert user is not None
+        return user
+
+
+# ---------------------------------------------------------------- 开关与入参
+
+
+def test_verify_rejected_when_portal_disabled(b2_client, monkeypatch):
+    monkeypatch.delenv("DMS_PORTAL_PROVIDER", raising=False)
+    response = b2_client.post(
+        VERIFY_PATH, json={"query": "token=mock:1:a:b"}
+    )
+    assert response.status_code == 400
+    assert response.get_json()["code"] == "INVALID_ARGUMENT"
+
+
+def test_verify_rejects_payload_with_extra_fields(b2_client, portal_mock):
+    response = b2_client.post(
+        VERIFY_PATH, json={"query": "token=mock:1:a:b", "extra": 1}
+    )
+    assert response.status_code == 400
+
+
+def test_verify_rejects_missing_token(b2_client, portal_mock):
+    response = b2_client.post(VERIFY_PATH, json={"query": "foo=1&bar=2"})
+    assert response.status_code == 400
+    assert response.get_json()["code"] == "INVALID_ARGUMENT"
+
+
+def test_verify_rejects_invalid_portal_token(b2_client, portal_mock):
+    response = _verify(b2_client, "not-a-mock-token")
+    assert response.status_code == 401
+    assert response.get_json()["code"] == "PORTAL_TOKEN_INVALID"
+
+
+# ---------------------------------------------------------------- 新用户落库
+
+
+def test_verify_provisions_new_user_and_issues_setup_ticket(
+    b2_app, b2_client, portal_mock
+):
+    response = _verify(b2_client, "mock:9001:portal_first:门户新用户:作战部")
+    assert response.status_code == 200
+    data = response.get_json()["data"]
+    assert data["phase"] == "SET_PASSWORD"
+    assert data["username"] == "portal_first"
+    assert data["realName"] == "门户新用户"
+    assert data["portalTicket"]
+
+    user = _find_user(b2_app, "portal_first")
+    assert user.external_id == "9001"
+    assert user.organization_name == "作战部"
+    assert user.last_login_at is None
+    assert user.status == "ENABLED"
+    assert user.role_code == "USER"
+    with b2_app.app_context():
+        audit = db.session.scalar(
+            db.select(AuditLog).where(
+                AuditLog.username == "portal_first",
+                AuditLog.action_type == "CREATE_USER",
+            )
+        )
+        assert audit is not None
+
+
+def test_provisioned_user_cannot_login_before_setup(
+    b2_client, b2_password, portal_mock
+):
+    # 占位密码随机不可知:设密前任何密码都无法登录
+    _verify(b2_client, "mock:9002:portal_locked:未设密用户")
+    response = b2_client.post(
+        "/api/v1/auth/login",
+        json={
+            "username": "portal_locked",
+            "password": b2_password,
+            "keepSignedIn": False,
+        },
+    )
+    assert response.status_code == 401
+    assert response.get_json()["code"] == "INVALID_CREDENTIALS"
+
+
+def test_organization_falls_back_to_none_when_no_match(
+    b2_app, b2_client, portal_mock
+):
+    _verify(b2_client, "mock:9003:portal_noorg:无组织用户:不存在部门")
+    user = _find_user(b2_app, "portal_noorg")
+    assert user.organization_id is None
+    assert user.organization_name is None
+
+
+def test_organization_falls_back_to_configured_default(
+    b2_app, b2_client, portal_mock, monkeypatch
+):
+    with b2_app.app_context():
+        root = db.session.scalar(
+            db.select(Organization).where(Organization.org_code == "ORG_ROOT")
+        )
+        assert root is not None
+        monkeypatch.setenv("DMS_PORTAL_DEFAULT_ORG_ID", str(root.id))
+    _verify(b2_client, "mock:9004:portal_deforg:默认组织用户:不存在部门")
+    user = _find_user(b2_app, "portal_deforg")
+    assert user.organization_name == "机关"
+
+
+# ---------------------------------------------------------------- 既有用户匹配
+
+
+def test_existing_username_matched_and_backfilled(b2_app, b2_client, portal_mock):
+    # b2_app预置username="user"(作战部,从未登录)→ 按用户名匹配并同步展示信息
+    response = _verify(b2_client, "mock:8001:user:门户同步姓名:通信部")
+    data = response.get_json()["data"]
+    assert data["phase"] == "SET_PASSWORD"
+    assert data["username"] == "user"
+
+    user = _find_user(b2_app, "user")
+    assert user.external_id == "8001"
+    assert user.real_name == "门户同步姓名"
+    assert user.organization_name == "通信部"
+    with b2_app.app_context():
+        audit = db.session.scalar(
+            db.select(AuditLog).where(
+                AuditLog.username == "user",
+                AuditLog.action_type == "EDIT_USER",
+            )
+        )
+        assert audit is not None
+
+
+def test_disabled_user_rejected(b2_client, portal_mock):
+    # b2_app预置username="disabled"(DISABLED)
+    response = _verify(b2_client, "mock:8002:disabled:停用用户")
+    assert response.status_code == 401
+    assert response.get_json()["code"] == "USER_DISABLED"
+
+
+# ---------------------------------------------------------------- 设密与登录闭环
+
+
+def test_setup_password_completes_login_and_enables_normal_login(
+    b2_client, portal_mock
+):
+    data = _verify(
+        b2_client, "mock:9005:portal_setup:门户设密用户"
+    ).get_json()["data"]
+    response = b2_client.post(
+        SETUP_PATH,
+        json={
+            "portalTicket": data["portalTicket"],
+            "newPassword": "Portal#9005",
+            "keepSignedIn": False,
+        },
+    )
+    assert response.status_code == 200
+    payload = response.get_json()["data"]
+    assert payload["tokenType"] == "Bearer"
+    assert payload["expiresIn"] == 7200
+    assert payload["user"]["username"] == "portal_setup"
+
+    # 独立密码设好后可走普通用户名密码登录
+    login = b2_client.post(
+        "/api/v1/auth/login",
+        json={
+            "username": "portal_setup",
+            "password": "Portal#9005",
+            "keepSignedIn": False,
+        },
+    )
+    assert login.status_code == 200
+
+    # 票据一次性:设密后auth_version已变更,复用被拒
+    replay = b2_client.post(
+        SETUP_PATH,
+        json={
+            "portalTicket": data["portalTicket"],
+            "newPassword": "Portal#9095",
+            "keepSignedIn": False,
+        },
+    )
+    assert replay.status_code == 401
+    assert replay.get_json()["code"] == "PORTAL_TICKET_INVALID"
+
+
+def test_setup_password_keep_signed_in_extends_expiry(b2_client, portal_mock):
+    data = _verify(
+        b2_client, "mock:9006:portal_keep:保持登录用户"
+    ).get_json()["data"]
+    response = b2_client.post(
+        SETUP_PATH,
+        json={
+            "portalTicket": data["portalTicket"],
+            "newPassword": "Portal#9006",
+            "keepSignedIn": True,
+        },
+    )
+    assert response.status_code == 200
+    assert response.get_json()["data"]["expiresIn"] == 604800
+
+
+def test_setup_password_rejects_weak_password_without_burning_ticket(
+    b2_client, portal_mock
+):
+    data = _verify(
+        b2_client, "mock:9007:portal_weak:弱密码用户"
+    ).get_json()["data"]
+    weak = b2_client.post(
+        SETUP_PATH,
+        json={
+            "portalTicket": data["portalTicket"],
+            "newPassword": "short",
+            "keepSignedIn": False,
+        },
+    )
+    assert weak.status_code == 400
+    assert weak.get_json()["code"] == "INVALID_ARGUMENT"
+
+    # 弱密码校验先于票据消费,票据未烧毁仍可正常设密
+    retry = b2_client.post(
+        SETUP_PATH,
+        json={
+            "portalTicket": data["portalTicket"],
+            "newPassword": "Portal#9007",
+            "keepSignedIn": False,
+        },
+    )
+    assert retry.status_code == 200
+
+
+def test_setup_password_rejects_garbage_ticket(b2_client, portal_mock):
+    response = b2_client.post(
+        SETUP_PATH,
+        json={
+            "portalTicket": "not-a-jwt",
+            "newPassword": "Portal#9000",
+            "keepSignedIn": False,
+        },
+    )
+    assert response.status_code == 401
+    assert response.get_json()["code"] == "PORTAL_TICKET_INVALID"
+
+
+def test_second_launch_returns_login_phase(b2_client, portal_mock):
+    token = "mock:9008:portal_again:二次跳转用户"
+    first = _verify(b2_client, token).get_json()["data"]
+    assert first["phase"] == "SET_PASSWORD"
+
+    setup = b2_client.post(
+        SETUP_PATH,
+        json={
+            "portalTicket": first["portalTicket"],
+            "newPassword": "Portal#9008",
+            "keepSignedIn": False,
+        },
+    )
+    assert setup.status_code == 200
+
+    # 设密登录后(last_login_at非空),再次跳转直接进入独立密码登录态
+    second = _verify(b2_client, token).get_json()["data"]
+    assert second["phase"] == "LOGIN"
+    assert second["username"] == "portal_again"
+    assert "portalTicket" not in second

+ 242 - 0
docs/门户对接指南.md

@@ -0,0 +1,242 @@
+# 门户跳转按需登录 · 对接指南
+
+> 适用场景:本系统被挂载到其他系统(下称"对端门户")中,用户从对端页面点击链接跳转进入本系统,免二次注册:首次跳转自动建号并设置本系统独立密码,之后跳转直接用独立密码登录。
+>
+> **对接现场只需关注本文档第 2、3 章**:优先纯 `.env` 配置(场景A);形态特殊时手写一个函数(场景B,附完整示例代码)。其余章节为背景与运维参考。
+
+---
+
+## 1. 功能流程(已实现,无需改动)
+
+```
+用户从对端门户点击跳转链接
+        │  http://<本系统>/?token=xxxx
+        ▼
+前端检测到地址栏含 query → 进入门户落地页,query 原样透传后端
+        │  POST /api/v1/auth/portal/verify { "query": "?token=xxxx" }
+        ▼
+后端提取 token → 调对端接口验签 → 拿到用户信息(externalId/username/realName/orgName)
+        │
+        ├─ 本库无此用户 → 自动落库(随机占位密码,任何密码都登录不了)
+        │       └─ 该用户从未登录过 → 返回 SET_PASSWORD + 一次性票据(5分钟)
+        │               └─ 前端让用户设置本系统独立密码 → 直接登录成功
+        │
+        └─ 本库已有此用户(按 externalId 或 username 匹配)→ 同步姓名/组织
+                ├─ 从未登录过 → SET_PASSWORD(同上)
+                └─ 登录过 → 返回 LOGIN → 前端让用户输入独立密码 → 走原有 /auth/login 登录
+```
+
+关键设计(了解即可):
+
+- **未对接前零影响**:`DMS_PORTAL_PROVIDER` 留空时功能整体关闭,原有用户名密码登录完全不变;前端无 query 时也直接显示原登录页。
+- **独立密码**:本系统密码不与对端共享。首次跳转的用户必须先设独立密码才能进入,此后跳转/直连登录都用这套密码。
+- **一次性票据**:设密票据是 5 分钟有效的 JWT,设密成功后立即失效(复用 auth_version 机制),不能重放。
+- **审计**:自动建号记 `CREATE_USER`、信息同步记 `EDIT_USER`、设密登录记 `LOGIN`,detail 中带 `portal: true`。
+- **IP 登录限制、停用用户**:与普通登录同一套校验。
+
+## 2. 配置速查表(backend/.env)
+
+所有配置集中在 `.env`,改后**重启进程生效**。完整键说明见 `backend/.env.example` 门户段。
+
+| 键 | 默认 | 说明 |
+|---|---|---|
+| `DMS_PORTAL_PROVIDER` | 空 | **总开关**。空=关闭 / `mock` / `http` / `custom` |
+| `DMS_PORTAL_TOKEN_PARAM` | `token` | 跳转 URL 中 token 的参数名 |
+| `DMS_PORTAL_VERIFY_URL` | 空 | 对端验签接口地址 |
+| `DMS_PORTAL_VERIFY_METHOD` | `POST` | `GET` 或 `POST` |
+| `DMS_PORTAL_VERIFY_TOKEN_IN` | `header` | token 放哪:`header` / `body` / `query` |
+| `DMS_PORTAL_VERIFY_HEADER` | `Authorization` | header 方式时的头名 |
+| `DMS_PORTAL_VERIFY_HEADER_TEMPLATE` | `Bearer {token}` | header 值模板,`{token}` 占位 |
+| `DMS_PORTAL_VERIFY_SUCCESS_FIELD` | `code` | 验签成功判定字段(支持 `a.b` 路径) |
+| `DMS_PORTAL_VERIFY_SUCCESS_VALUE` | `0` | 该字段等于此值才算通过 |
+| `DMS_PORTAL_VERIFY_USER_FIELD` | `data` | 验签响应中用户信息对象所在路径 |
+| `DMS_PORTAL_USERINFO_URL` | 空 | 验签不含用户信息时,额外调的用户信息接口 |
+| `DMS_PORTAL_USERINFO_UID_PARAM` | `userId` | 用户信息接口的 uid 参数名(GET + Bearer token) |
+| `DMS_PORTAL_FIELD_MAP` | 见 .env.example | 字段映射 `local:remote,...`,local 固定四种 |
+| `DMS_PORTAL_DEFAULT_ORG_ID` | 空 | 组织名匹配不到时挂的默认组织 ID |
+| `DMS_PORTAL_HTTP_TIMEOUT_SECONDS` | `10` | 对端 HTTP 超时 |
+
+## 3. 对接场景
+
+### 场景A:对端是标准 REST(首选,纯配置零代码)
+
+对端提供:验签接口(token 换用户信息)即可。假设对端接口形态为——
+
+```
+POST http://portal.internal/api/sso/verify
+Header: Authorization: Bearer <token>
+响应: { "code": 0, "data": { "userId": "1001", "loginName": "zhangsan",
+                              "realName": "张三", "orgName": "作战部" } }
+```
+
+那么 `.env` 这样配(只列需要改的):
+
+```ini
+DMS_PORTAL_PROVIDER=http
+DMS_PORTAL_TOKEN_PARAM=token
+DMS_PORTAL_VERIFY_URL=http://portal.internal/api/sso/verify
+DMS_PORTAL_VERIFY_METHOD=POST
+DMS_PORTAL_VERIFY_TOKEN_IN=header
+DMS_PORTAL_VERIFY_SUCCESS_FIELD=code
+DMS_PORTAL_VERIFY_SUCCESS_VALUE=0
+DMS_PORTAL_VERIFY_USER_FIELD=data
+DMS_PORTAL_FIELD_MAP=externalId:userId,username:loginName,realName:realName,orgName:orgName
+```
+
+常见变体速查:
+
+| 对端形态 | 关键配置 |
+|---|---|
+| token 放 body:`{"ticket": "xxx"}` | `VERIFY_TOKEN_IN=body`,`TOKEN_PARAM=ticket` |
+| token 放 query:`GET /verify?token=x` | `VERIFY_TOKEN_IN=query`,`VERIFY_METHOD=GET` |
+| 成功判定是 `{"success": true}` | `VERIFY_SUCCESS_FIELD=success`,`VERIFY_SUCCESS_VALUE=true` |
+| 用户信息嵌套两层 `{"data":{"user":{...}}}` | `VERIFY_USER_FIELD=data.user` |
+| 验签只返回 uid,还要再查用户接口 | 配 `USERINFO_URL`(系统自动 GET 该地址,`UID_PARAM=uid值` + `Authorization: Bearer <token>`,响应按 FIELD_MAP 解析) |
+
+### 场景B:形态特殊,手写适配(唯一可能改代码的场景)
+
+适用:本地公钥验 JWT、HMAC 共享密钥、怪异协议等。步骤只有两步:
+
+**第 1 步** `.env` 设:
+
+```ini
+DMS_PORTAL_PROVIDER=custom
+DMS_PORTAL_TOKEN_PARAM=token
+```
+
+**第 2 步** 打开 `backend/dms/services/portal_provider.py`,找到文件末尾的 `_custom_provider` 函数,**用下面的模板替换整个函数体**(按对端实际接口改 URL、参数、字段名):
+
+```python
+def _custom_provider(token: str) -> PortalIdentity:
+    """手写适配:对端是 GET http://portal.internal/api/sso/verify?ticket=xxx,
+    返回 {"ok": true, "data": {"uid": "1001", "name": "张三", "dept": "作战部"}}。
+    """
+    import requests as http_client
+
+    try:
+        resp = http_client.get(
+            "http://portal.internal/api/sso/verify",
+            params={"ticket": token},
+            timeout=10,
+        )
+        data = resp.json()
+    except Exception:
+        raise PortalTokenError("无法访问对端验签接口")
+
+    if not data.get("ok"):
+        raise PortalTokenError("对端验签未通过")
+
+    info = data.get("data") or {}
+    uid = str(info.get("uid") or "").strip()
+    name = str(info.get("name") or "").strip()
+    dept = str(info.get("dept") or "").strip() or None
+    if not uid or not name:
+        raise PortalIdentityError("对端用户信息缺少uid或name")
+
+    return PortalIdentity(external_id=uid, username=uid, real_name=name, org_name=dept)
+```
+
+手写规则(只有三条):
+
+1. **返回** `PortalIdentity(external_id=..., username=..., real_name=..., org_name=...)`,其余(落库、匹配、设密码、登录)系统自动完成。`org_name` 可传 `None`。
+2. **验签不通过**抛 `PortalTokenError("原因")`(前端提示凭证无效);**字段缺失**抛 `PortalIdentityError("原因")`。
+3. `username` 拿不到明确登录名时,直接用 `external_id` 代替(如上例)。
+
+另一个常见变体——**对端给的是 JWT、要求本地用共享密钥验签**(不发起 HTTP 请求):
+
+```python
+def _custom_provider(token: str) -> PortalIdentity:
+    """对端签发的JWT,HMAC-SHA256共享密钥本地验签。"""
+    import jwt
+
+    try:
+        claims = jwt.decode(
+            token,
+            "对端提供的共享密钥",          # ← 改这里
+            algorithms=["HS256"],
+            issuer="portal",               # ← 对端iss,无则删掉此行
+        )
+    except jwt.PyJWTError:
+        raise PortalTokenError("对端跳转凭证无效或已过期")
+
+    uid = str(claims.get("uid") or "").strip()
+    name = str(claims.get("name") or "").strip()
+    if not uid or not name:
+        raise PortalIdentityError("对端跳转凭证缺少uid或name")
+
+    return PortalIdentity(
+        external_id=uid,
+        username=uid,
+        real_name=name,
+        org_name=str(claims.get("dept") or "").strip() or None,
+    )
+```
+
+### 场景C:本地联调 / 演示(mock)
+
+`.env` 设 `DMS_PORTAL_PROVIDER=mock` 后,token 本身就是用户信息,格式:
+
+```
+mock:<externalId>:<username>:<realName>[:<orgName>]
+```
+
+浏览器直接访问即可走完整流程(建号→设密→登录→二次跳转 LOGIN):
+
+```
+http://localhost:9345/?token=mock:1001:portal_user:门户联调用户:作战部
+```
+
+## 4. 用户匹配与落库规则
+
+| 情形 | 行为 |
+|---|---|
+| 本库已有 `external_id` 相同的有效用户 | 直接命中,同步姓名/组织(用户名不变) |
+| 无 external_id 命中,但 `username` 相同 | 视为同一人,回填 external_id 并同步展示信息 |
+| 都没有 | 新建用户:角色 USER、密级 INTERNAL、状态 ENABLED、密码为随机占位(**任何密码都登录不了**) |
+| 停用用户 | 拒绝(401 USER_DISABLED),与普通登录一致 |
+| 组织 | 优先按 `orgName` 与本系统组织名**精确匹配**;匹配不到挂 `DEFAULT_ORG_ID`;再没有就不挂组织 |
+
+`SET_PASSWORD`(要求设独立密码)的判定是**该用户在本系统从未登录过**(`last_login_at` 为空)。因此管理员预先建好的账号首次从门户进入时,也会走自助设密激活,属于预期行为。
+
+## 5. 接口契约(前端已实现,供对端/运维参考)
+
+### POST /api/v1/auth/portal/verify(无需认证)
+
+```json
+请求: { "query": "?token=xxxx" }        // 地址栏query串原样透传
+响应 data:
+  { "phase": "LOGIN", "username": "zhangsan", "realName": "张三" }
+或{ "phase": "SET_PASSWORD", "username": "zhangsan", "realName": "张三", "portalTicket": "<jwt>" }
+```
+
+### POST /api/v1/auth/portal/setup-password(无需认证)
+
+```json
+请求: { "portalTicket": "<jwt>", "newPassword": "8~64位", "keepSignedIn": false }
+响应 data: 与 /auth/login 完全一致(accessToken/expiresIn/user)
+```
+
+错误码:`PORTAL_TOKEN_INVALID`(401 对端验签未过)、`PORTAL_TICKET_INVALID`(401 票据无效/过期/已用)、`USER_DISABLED`(401)、`INVALID_ARGUMENT`(400 参数/配置问题)。
+
+## 6. 部署与运维注意
+
+1. **数据库**:迁移 `0016_portal_user_provision`(sys_user 加 external_id 列与唯一约束)。打包环境用 `dms-server.exe upgrade-db --yes` 升级;源码环境 `python -m alembic -c migrations/alembic.ini upgrade head`。
+2. **前端无需任何配置**:打包产物已含门户落地页,检测到地址栏 query 自动启用;用户也可点"使用账号密码登录"回落原登录页。
+3. **票据一次性**:用户没在 5 分钟内完成设密,或刷新重试,重新从门户跳转即可(会发新票据)。
+4. **密码规则**:8~64 位,与系统内修改密码规则一致。
+5. **对端跳转链接要求**:GET 请求、token 在 query 中(参数名可配)。建议对端 token 有效期 ≥ 5 分钟、一次性。
+
+## 7. 测试
+
+后端(需本地 MySQL 测试库,mock provider 全分支 15 例):
+
+```bash
+cd backend
+export DMS_TEST_DATABASE_URL="mysql+pymysql://<用户>:<密码>@localhost:3306/dms_test?charset=utf8mb4"
+export DMS_TEST_JWT_SECRET="local-test-jwt-secret-not-for-production"
+export DMS_TEST_USER_PASSWORD="TestUser@123456"
+export PYTEST_DISABLE_PLUGIN_AUTOLOAD=1
+python -m pytest tests/dms/test_portal_auth.py -q
+```
+
+前端:`cd frontend && npx vitest run src/features/auth`(portalAuthApi 契约 7 例)。

+ 223 - 8
frontend/src/app/App.tsx

@@ -21,9 +21,11 @@ import {
   loginSession,
   logoutSession,
   restoreSession,
+  setupPortalPasswordSession,
   type AuthenticatedPage,
   type NavigationItem,
 } from "../features/auth/authSession";
+import { portalAuthApi } from "../features/auth/portalAuthApi";
 import type { LoginRequest, UserSummary } from "../features/auth/authTypes";
 import { CategoryType, EnabledStatus } from "../shared/api/enums";
 import { categoryApi } from "../features/categories/categoryApi";
@@ -2339,6 +2341,186 @@ function LoginPage({ onLogin, notice }: {
   );
 }
 
+function PortalLaunchScreen({ query, onLogin, onSetupLogin, onFallback }: {
+  query: string;
+  onLogin: (request: LoginRequest) => Promise<void>;
+  onSetupLogin: (ticket: string, newPassword: string, keepSignedIn: boolean) => Promise<void>;
+  onFallback: () => void;
+}) {
+  const [stage, setStage] = useState<"verifying" | "login" | "setup" | "error">("verifying");
+  const [username, setUsername] = useState("");
+  const [realName, setRealName] = useState("");
+  const [ticket, setTicket] = useState("");
+  const [password, setPassword] = useState("");
+  const [confirmPassword, setConfirmPassword] = useState("");
+  const [keepSignedIn, setKeepSignedIn] = useState(false);
+  const [error, setError] = useState("");
+  const [errorRequestId, setErrorRequestId] = useState("");
+  const [submitting, setSubmitting] = useState(false);
+
+  useEffect(() => {
+    let cancelled = false;
+    portalAuthApi.verify(query).then((data) => {
+      if (cancelled) return;
+      setUsername(data.username);
+      setRealName(data.realName);
+      if (data.phase === "SET_PASSWORD") {
+        setTicket(data.portalTicket);
+        setStage("setup");
+      } else {
+        setStage("login");
+      }
+    }).catch((verifyError) => {
+      if (cancelled) return;
+      if (verifyError instanceof HttpError) {
+        setError(verifyError.message);
+        setErrorRequestId(verifyError.requestId);
+      } else {
+        setError("门户验签失败,请稍后重试");
+      }
+      setStage("error");
+    });
+    return () => { cancelled = true; };
+  }, [query]);
+
+  const captureFormError = (formError: unknown, fallback: string) => {
+    if (formError instanceof HttpError) {
+      setError(formError.message);
+      setErrorRequestId(formError.requestId);
+    } else {
+      setError(fallback);
+    }
+  };
+
+  const submitLogin = async () => {
+    if (submitting) return;
+    if (!password.trim()) { setError("请输入密码"); return; }
+    setSubmitting(true); setError(""); setErrorRequestId("");
+    try {
+      await onLogin({ username, password, keepSignedIn });
+    } catch (loginError) {
+      captureFormError(loginError, "登录失败,请稍后重试");
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  const submitSetup = async () => {
+    if (submitting) return;
+    if (password.length < 8 || password.length > 64) { setError("密码长度必须在8~64个字符之间"); return; }
+    if (password !== confirmPassword) { setError("两次输入的密码不一致"); return; }
+    setSubmitting(true); setError(""); setErrorRequestId("");
+    try {
+      await onSetupLogin(ticket, password, keepSignedIn);
+    } catch (setupError) {
+      captureFormError(setupError, "设置密码失败,请稍后重试");
+    } finally {
+      setSubmitting(false);
+    }
+  };
+
+  if (stage === "verifying") {
+    return <AuthStateScreen title="正在验证门户跳转" description="正在向门户验证跳转凭证,请稍候。" primaryLabel="" onPrimary={() => { }} busy />;
+  }
+  if (stage === "error") {
+    return (
+      <AuthStateScreen
+        title="门户跳转失败"
+        description={error}
+        requestId={errorRequestId || undefined}
+        primaryLabel="重试"
+        onPrimary={() => { setError(""); setErrorRequestId(""); setStage("verifying"); }}
+        secondaryLabel="使用账号密码登录"
+        onSecondary={onFallback}
+      />
+    );
+  }
+
+  const isSetup = stage === "setup";
+  const handlePrimary = isSetup ? () => void submitSetup() : () => void submitLogin();
+  const fields = isSetup
+    ? [
+        { key: "password", label: "设置独立密码", type: "password", placeholder: "8~64位", icon: <Key size={14} style={{ color: B }} /> },
+        { key: "confirm", label: "确认密码", type: "password", placeholder: "再次输入新密码", icon: <Key size={14} style={{ color: B }} /> },
+      ]
+    : [
+        { key: "password", label: "本系统独立密码", type: "password", placeholder: "请输入密码", icon: <Lock size={14} style={{ color: B }} /> },
+      ];
+
+  return (
+    <div className="w-full h-screen flex flex-col overflow-hidden" style={{ fontFamily: "'Noto Sans SC', sans-serif", background: PBG }}>
+      <div className="relative z-30 flex-shrink-0 flex items-center justify-center py-3" style={{ background: B }}>
+        <span className="text-xl font-black tracking-[0.22em] whitespace-nowrap" style={{ color: W }}>方案数据管理</span>
+      </div>
+      <div aria-hidden className="relative z-20 flex-shrink-0"
+        style={{ height: 20, background: `linear-gradient(to bottom, ${B} 0%, ${PBG} 100%)` }} />
+      <div className="flex-1 flex items-center justify-center relative z-10">
+        <div style={{ background: W, width: 400, border: `1px solid rgba(40,118,193,0.2)` }} className="shadow-2xl rounded-sm overflow-hidden">
+          <div className="h-1" style={{ background: `linear-gradient(90deg, ${N}, ${B})` }} />
+          <div className="px-10 py-10">
+            <div className="flex flex-col items-center mb-8">
+              <div className="w-14 h-14 rounded-full flex items-center justify-center mb-4" style={{ background: N }}>
+                {isSetup ? <Key size={22} style={{ color: W }} /> : <Lock size={22} style={{ color: W }} />}
+              </div>
+              <h2 className="text-lg font-black tracking-widest" style={{ color: N }}>{isSetup ? "设置独立密码" : "门户登录"}</h2>
+              <p className="text-sm font-bold mt-1 text-center" style={{ color: `rgba(26,82,153,0.4)` }}>
+                {isSetup ? `首次从门户进入,请为 ${realName} 设置本系统独立密码` : `门户身份已确认:${realName},请输入本系统独立密码`}
+              </p>
+            </div>
+            <div className="mb-4 flex items-center gap-2 px-3 py-2 rounded-sm" style={{ background: `rgba(40,118,193,0.04)`, border: `1px solid rgba(26,82,153,0.2)` }}>
+              <User size={14} style={{ color: B }} />
+              <span className="text-sm font-black" style={{ color: N }}>{username}</span>
+            </div>
+            {error && (
+              <div className="mb-4 flex items-center gap-2 px-3 py-2 rounded-sm" style={{ background: `rgba(40,118,193,0.08)`, border: `1px solid rgba(26,82,153,0.2)` }}>
+                <AlertCircle size={14} style={{ color: B }} />
+                <div>
+                  <span className="text-sm font-black" style={{ color: N }}>{error}</span>
+                  {errorRequestId && <div className="mt-1 text-[11px] font-bold" style={{ color: `rgba(26,82,153,0.55)` }}>错误编号:{errorRequestId}</div>}
+                </div>
+              </div>
+            )}
+            <div className="space-y-4">
+              {fields.map(f => (
+                <div key={f.key}>
+                  <label className="text-sm font-black mb-1.5 block" style={{ color: N }}>{f.label}</label>
+                  <div className="flex items-center gap-3 rounded-sm border h-11 px-3 transition-colors" style={{ borderColor: "rgba(26,82,153,0.2)", background: `rgba(40,118,193,0.04)` }}>
+                    {f.icon}
+                    <input type={f.type} disabled={submitting} className="flex-1 text-sm bg-transparent outline-none font-bold disabled:cursor-not-allowed" style={{ color: N }}
+                      placeholder={f.placeholder}
+                      value={f.key === "password" ? password : confirmPassword}
+                      onChange={e => {
+                        f.key === "password" ? setPassword(e.target.value) : setConfirmPassword(e.target.value);
+                        setError(""); setErrorRequestId("");
+                      }}
+                      onKeyDown={e => { if (e.key === "Enter") handlePrimary(); }} />
+                  </div>
+                </div>
+              ))}
+              <div className="flex items-center justify-between">
+                <label className="flex items-center gap-2 cursor-pointer">
+                  <input type="checkbox" disabled={submitting} checked={keepSignedIn} onChange={e => setKeepSignedIn(e.target.checked)} className="accent-[#2876C1]" />
+                  <span className="text-sm font-bold" style={{ color: `rgba(26,82,153,0.55)` }}>保持登录</span>
+                </label>
+                <button onClick={onFallback} className="text-xs font-bold hover:opacity-70" style={{ color: `rgba(26,82,153,0.5)` }}>使用账号密码登录</button>
+              </div>
+              <button onClick={handlePrimary} disabled={submitting} aria-busy={submitting}
+                className="w-full h-11 font-black text-base rounded-sm transition-opacity hover:opacity-80 active:opacity-60 disabled:opacity-60 disabled:cursor-not-allowed tracking-widest mt-2"
+                style={{ background: N, color: W }}>
+                {submitting ? "处理中…" : (isSetup ? "设置密码并登录" : "登 录")}
+              </button>
+            </div>
+            <p className="text-center text-xs font-bold mt-6" style={{ color: `rgba(26,82,153,0.3)` }}>本系统为涉密系统 · 严禁未授权访问</p>
+          </div>
+        </div>
+      </div>
+      <div className="relative z-10 pb-6 text-center text-xs font-bold" style={{ color: `rgba(26,82,153,0.3)` }}>
+        © 方案数据管理 · 版权所有 · 严禁扩散
+      </div>
+    </div>
+  );
+}
+
 function AuthStateScreen({ title, description, requestId, primaryLabel, onPrimary, secondaryLabel, onSecondary, busy }: {
   title: string;
   description: string;
@@ -2770,6 +2952,14 @@ const RELATION_TABS = [
 ] as const;
 type RelationTabId = (typeof RELATION_TABS)[number]["id"];
 
+/** 定位目标对应的关系面板页签:附件→其类型页签,主案/子案→配套方案(默认)。 */
+function locateRelationTab(item: DocumentSummary): RelationTabId {
+  if (item.documentType === DocumentType.ATTACHMENT && item.attachmentType) {
+    return item.attachmentType;
+  }
+  return "sub";
+}
+
 function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, currentUser }: AuthenticatedPageProps) {
   const [categoryId, setCategoryId] = useState<string | null>(null);
   const [allCategoryCount, setAllCategoryCount] = useState<number | null>(null);
@@ -2862,12 +3052,13 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
   }, [lowerTab]);
 
   // 定位跳转:清空检索退出过渡视图,跳到主案分类并选中主案展开关系面板
-  const jumpToMainPlan = (main: DocumentSummary) => {
+  const jumpToMainPlan = (main: DocumentSummary, tab: RelationTabId = "sub") => {
     resetGlobalSearch();
     setCategoryId(main.categoryId);
     setSelected(main);
     setPreview(null);
     setHideRelations(false);
+    setLowerTab(tab);
     setPage(1);
     setSearchPage(1);
     setLocateError(null);
@@ -2876,16 +3067,17 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
     setLocateError(null);
     try {
       const resolution = await resolveLocateTarget(item, searchResults.items);
-      if (resolution.kind === "main") jumpToMainPlan(resolution.main);
+      if (resolution.kind === "main") jumpToMainPlan(resolution.main, locateRelationTab(item));
       else setLocateCandidates(resolution);
     } catch (error) {
       setLocateError(error instanceof HttpError ? error.message : "定位失败,请稍后重试");
     }
   };
   const confirmLocate = async (mainPlanId: string) => {
+    const tab = locateCandidates ? locateRelationTab(locateCandidates.attachment) : "sub";
     setLocateCandidates(null);
     try {
-      jumpToMainPlan(await documentApi.detail(mainPlanId));
+      jumpToMainPlan(await documentApi.detail(mainPlanId), tab);
     } catch (error) {
       setLocateError(error instanceof HttpError ? error.message : "定位失败,请稍后重试");
     }
@@ -2928,7 +3120,7 @@ function RealMainPage({ onNav, navigationItems, onLogout, logoutPending, current
             </div>
           </div>
         )}
-        {!hideRelations && selected?.documentType === DocumentType.MAIN && (
+        {!hideRelations && !searchActive && selected?.documentType === DocumentType.MAIN && (
         <div className="h-[390px] flex-shrink-0 flex flex-col" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
           <div className="flex border-b" style={{ borderColor: "rgba(40,118,193,.14)" }}>
             {RELATION_TABS.map((item) => (
@@ -3049,11 +3241,12 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
 
   // 定位跳转:清空检索退出过渡视图,跳到主案分类并选中主案展开关系面板
   // 后台页 detail 受可见性限制,失败时保留原始错误信息提示
-  const jumpToMainPlan = (main: DocumentSummary) => {
+  const jumpToMainPlan = (main: DocumentSummary, tab: RelationTabId = "sub") => {
     resetGlobalSearch();
     setCategoryId(main.categoryId);
     setSelected(main);
     setPreview(null);
+    setRelationTab(tab);
     setPlanPage(1);
     setSearchPage(1);
     setLocateError(null);
@@ -3062,16 +3255,17 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
     setLocateError(null);
     try {
       const resolution = await resolveLocateTarget(item, searchResults.items);
-      if (resolution.kind === "main") jumpToMainPlan(resolution.main);
+      if (resolution.kind === "main") jumpToMainPlan(resolution.main, locateRelationTab(item));
       else setLocateCandidates(resolution);
     } catch (error) {
       setLocateError(error instanceof HttpError ? error.message : "定位失败,请稍后重试");
     }
   };
   const confirmLocate = async (mainPlanId: string) => {
+    const tab = locateCandidates ? locateRelationTab(locateCandidates.attachment) : "sub";
     setLocateCandidates(null);
     try {
-      jumpToMainPlan(await documentApi.detail(mainPlanId));
+      jumpToMainPlan(await documentApi.detail(mainPlanId), tab);
     } catch (error) {
       setLocateError(error instanceof HttpError ? error.message : "定位失败,请稍后重试");
     }
@@ -3146,7 +3340,7 @@ function RealAdminPage({ onNav, navigationItems, onLogout, logoutPending, curren
                 </div>
               </div>
             )}
-            {selected?.documentType === DocumentType.MAIN && (
+            {!searchActive && selected?.documentType === DocumentType.MAIN && (
             <div className="h-[400px] flex-shrink-0 flex flex-col" style={{ background: W, border: "1px solid rgba(40,118,193,.18)" }}>
               <div className="flex border-b" style={{ borderColor: "rgba(40,118,193,.14)" }}>
                 {RELATION_TABS.map((item) => (
@@ -3206,6 +3400,8 @@ export default function App() {
   const [authNotice, setAuthNotice] = useState("");
   const [recoveryError, setRecoveryError] = useState<{ message: string; requestId: string } | null>(null);
   const [logoutPending, setLogoutPending] = useState(false);
+  // 门户跳转:地址栏带query参数进入时优先走门户验签流程(可回落普通登录)
+  const [portalLaunch, setPortalLaunch] = useState(() => window.location.search.length > 1);
 
   const restoreCurrentSession = useCallback(async () => {
     if (!tokenStore.getAccessToken()) {
@@ -3289,6 +3485,15 @@ export default function App() {
     setAuthStatus("authenticated");
   };
 
+  const handlePortalSetupLogin = async (ticket: string, newPassword: string, keepSignedIn: boolean) => {
+    const user = await setupPortalPasswordSession(ticket, newPassword, keepSignedIn);
+    setCurrentUser(user);
+    setPage(getFirstAllowedPage(user.allowedModules) ?? "login");
+    setAuthNotice("");
+    setRecoveryError(null);
+    setAuthStatus("authenticated");
+  };
+
   const handleLogout = async () => {
     if (logoutPending) return;
     setLogoutPending(true);
@@ -3347,6 +3552,16 @@ export default function App() {
   }
 
   if (authStatus === "unauthenticated" || !currentUser) {
+    if (portalLaunch) {
+      return (
+        <PortalLaunchScreen
+          query={window.location.search}
+          onLogin={handleLogin}
+          onSetupLogin={handlePortalSetupLogin}
+          onFallback={() => setPortalLaunch(false)}
+        />
+      );
+    }
     return <LoginPage onLogin={handleLogin} notice={authNotice} />;
   }
 

+ 159 - 0
frontend/src/features/auth/__tests__/portalAuthApi.test.ts

@@ -0,0 +1,159 @@
+import { describe, expect, it, vi } from "vitest";
+import type { JsonResult } from "../../../shared/api/types";
+import { createPortalAuthApi } from "../portalAuthApi";
+import type {
+  LoginResponseData,
+  PortalVerifyData,
+  UserSummary,
+} from "../authTypes";
+
+const user: UserSummary = {
+  id: "9223372036854775807",
+  username: "portal_user",
+  realName: "门户用户",
+  organizationId: "20",
+  organizationName: "作战部",
+  roleCode: "USER",
+  roleId: null,
+  roleName: null,
+  securityLevel: "INTERNAL",
+  status: "ENABLED",
+  authVersion: 0,
+  rowVersion: 0,
+  lastLoginAt: null,
+  lastLoginIp: null,
+  allowedModules: ["DOCUMENT_BROWSER"],
+};
+
+function result<T>(data: T): JsonResult<T> {
+  return {
+    data,
+    message: "success",
+    requestId: "request-id",
+    httpStatus: 200,
+    headers: new Headers(),
+  };
+}
+
+describe("portalAuthApi", () => {
+  it("verify 透传query串且不携带认证头", async () => {
+    const data: PortalVerifyData = {
+      phase: "LOGIN",
+      username: "portal_user",
+      realName: "门户用户",
+    };
+    const post = vi.fn().mockResolvedValue(result(data));
+    const api = createPortalAuthApi({ post });
+
+    await expect(api.verify("?token=abc")).resolves.toEqual(data);
+    expect(post).toHaveBeenCalledWith(
+      "auth/portal/verify",
+      { query: "?token=abc" },
+      { auth: false, signal: undefined },
+    );
+  });
+
+  it("verify 接受SET_PASSWORD阶段并透传signal", async () => {
+    const data: PortalVerifyData = {
+      phase: "SET_PASSWORD",
+      username: "portal_user",
+      realName: "门户用户",
+      portalTicket: "ticket-jwt",
+    };
+    const post = vi.fn().mockResolvedValue(result(data));
+    const controller = new AbortController();
+    const api = createPortalAuthApi({ post });
+
+    await expect(api.verify("token=abc", controller.signal)).resolves.toEqual(
+      data,
+    );
+    expect(post).toHaveBeenCalledWith(
+      "auth/portal/verify",
+      { query: "token=abc" },
+      { auth: false, signal: controller.signal },
+    );
+  });
+
+  it("verify 拒绝未知phase的契约违规响应", async () => {
+    const post = vi.fn().mockResolvedValue(
+      result({ phase: "UNKNOWN", username: "x", realName: "y" }),
+    );
+    const api = createPortalAuthApi({ post });
+
+    await expect(api.verify("token=abc")).rejects.toMatchObject({
+      code: "INTERNAL_ERROR",
+      message: "门户验签响应不符合DMS接口契约",
+      requestId: "request-id",
+    });
+  });
+
+  it("setupPassword 携带票据与新密码并校验短期有效期", async () => {
+    const response: LoginResponseData = {
+      accessToken: "jwt",
+      tokenType: "Bearer",
+      expiresIn: 7200,
+      user,
+    };
+    const post = vi.fn().mockResolvedValue(result(response));
+    const api = createPortalAuthApi({ post });
+
+    await expect(
+      api.setupPassword("ticket-jwt", "Portal#9001", false),
+    ).resolves.toEqual(response);
+    expect(post).toHaveBeenCalledWith(
+      "auth/portal/setup-password",
+      {
+        portalTicket: "ticket-jwt",
+        newPassword: "Portal#9001",
+        keepSignedIn: false,
+      },
+      { auth: false },
+    );
+  });
+
+  it("setupPassword 校验保持登录时的长效有效期", async () => {
+    const post = vi.fn().mockResolvedValue(
+      result({
+        accessToken: "jwt",
+        tokenType: "Bearer",
+        expiresIn: 604800,
+        user,
+      } satisfies LoginResponseData),
+    );
+    const api = createPortalAuthApi({ post });
+
+    await expect(
+      api.setupPassword("ticket-jwt", "Portal#9001", true),
+    ).resolves.toMatchObject({ expiresIn: 604800 });
+  });
+
+  it("setupPassword 拒绝与keepSignedIn不一致的expiresIn", async () => {
+    const post = vi.fn().mockResolvedValue(
+      result({
+        accessToken: "jwt",
+        tokenType: "Bearer",
+        expiresIn: 604800,
+        user,
+      } satisfies LoginResponseData),
+    );
+    const api = createPortalAuthApi({ post });
+
+    await expect(
+      api.setupPassword("ticket-jwt", "Portal#9001", false),
+    ).rejects.toMatchObject({
+      code: "INTERNAL_ERROR",
+      message: "登录凭证有效期不符合DMS接口契约",
+    });
+  });
+
+  it("setupPassword 拒绝不符契约的登录响应", async () => {
+    const post = vi.fn().mockResolvedValue(result({ accessToken: "jwt" }));
+    const api = createPortalAuthApi({ post });
+
+    await expect(
+      api.setupPassword("ticket-jwt", "Portal#9001", false),
+    ).rejects.toMatchObject({
+      message: "门户设密登录响应不符合DMS接口契约",
+    });
+  });
+});

+ 1 - 1
frontend/src/features/auth/authApi.ts

@@ -21,7 +21,7 @@ interface AuthHttpClient {
   ): Promise<JsonResult<T>>;
 }
 
-function contractError(
+export function contractError(
   result: JsonResult<unknown>,
   message: string,
 ): HttpError {

+ 12 - 0
frontend/src/features/auth/authSession.ts

@@ -4,6 +4,7 @@ import {
 } from "../../shared/api/enums";
 import { tokenStore } from "../../shared/auth/tokenStore";
 import { authApi, type AuthApi } from "./authApi";
+import { portalAuthApi, type PortalAuthApi } from "./portalAuthApi";
 import type { LoginRequest, UserSummary } from "./authTypes";
 
 export type AuthenticatedPage = "main" | "admin" | "audit" | "directory";
@@ -63,6 +64,17 @@ export async function loginSession(
   return response.user;
 }
 
+export async function setupPortalPasswordSession(
+  portalTicket: string,
+  newPassword: string,
+  keepSignedIn: boolean,
+  api: PortalAuthApi = portalAuthApi,
+): Promise<UserSummary> {
+  const response = await api.setupPassword(portalTicket, newPassword, keepSignedIn);
+  tokenStore.saveAccessToken(response.accessToken, keepSignedIn);
+  return response.user;
+}
+
 export async function restoreSession(
   api: AuthApi = authApi,
 ): Promise<UserSummary | null> {

+ 17 - 0
frontend/src/features/auth/authTypes.ts

@@ -43,6 +43,10 @@ export interface LoginResponseData {
   user: UserSummary;
 }
 
+export type PortalVerifyData =
+  | { phase: "LOGIN"; username: string; realName: string }
+  | { phase: "SET_PASSWORD"; username: string; realName: string; portalTicket: string };
+
 function isRecord(value: unknown): value is Record<string, unknown> {
   return !!value && typeof value === "object";
 }
@@ -107,3 +111,16 @@ export function isLoginResponseData(
     isUserSummary(value.user)
   );
 }
+
+export function isPortalVerifyData(
+  value: unknown,
+): value is PortalVerifyData {
+  if (!isRecord(value)) return false;
+  if (typeof value.username !== "string" || !value.username) return false;
+  if (typeof value.realName !== "string" || !value.realName) return false;
+  if (value.phase === "LOGIN") return true;
+  if (value.phase === "SET_PASSWORD") {
+    return typeof value.portalTicket === "string" && !!value.portalTicket;
+  }
+  return false;
+}

+ 61 - 0
frontend/src/features/auth/portalAuthApi.ts

@@ -0,0 +1,61 @@
+import {
+  httpClient,
+  type RequestOptions,
+} from "../../shared/api/httpClient";
+import type { JsonResult } from "../../shared/api/types";
+import { contractError } from "./authApi";
+import {
+  isLoginResponseData,
+  isPortalVerifyData,
+  type LoginResponseData,
+  type PortalVerifyData,
+} from "./authTypes";
+
+interface PortalHttpClient {
+  post<T>(
+    path: string,
+    body?: unknown | FormData,
+    options?: RequestOptions,
+  ): Promise<JsonResult<T>>;
+}
+
+export function createPortalAuthApi(client: PortalHttpClient = httpClient) {
+  return {
+    /** 门户跳转验签:把地址栏query串原样透传给后端(参数名由后端配置解析)。 */
+    async verify(query: string, signal?: AbortSignal): Promise<PortalVerifyData> {
+      const result = await client.post<unknown>(
+        "auth/portal/verify",
+        { query },
+        { auth: false, signal },
+      );
+      if (!isPortalVerifyData(result.data)) {
+        throw contractError(result, "门户验签响应不符合DMS接口契约");
+      }
+      return result.data;
+    },
+
+    /** 门户用户首次设置独立密码并登录(票据一次性,5分钟内有效)。 */
+    async setupPassword(
+      portalTicket: string,
+      newPassword: string,
+      keepSignedIn: boolean,
+    ): Promise<LoginResponseData> {
+      const result = await client.post<unknown>(
+        "auth/portal/setup-password",
+        { portalTicket, newPassword, keepSignedIn },
+        { auth: false },
+      );
+      if (!isLoginResponseData(result.data)) {
+        throw contractError(result, "门户设密登录响应不符合DMS接口契约");
+      }
+      const expectedExpiresIn = keepSignedIn ? 604800 : 7200;
+      if (result.data.expiresIn !== expectedExpiresIn) {
+        throw contractError(result, "登录凭证有效期不符合DMS接口契约");
+      }
+      return result.data;
+    },
+  };
+}
+
+export const portalAuthApi = createPortalAuthApi();
+export type PortalAuthApi = ReturnType<typeof createPortalAuthApi>;

+ 9 - 3
frontend/src/features/documents/__tests__/searchTransitionScope.test.ts

@@ -10,9 +10,9 @@ const hook = readFileSync(new URL("../globalSearchResults.ts", import.meta.url),
  */
 
 describe("关系面板按需显示", () => {
-  it("主页与后台页均仅在选中主案时渲染关系面板,占位文案已删除", () => {
-    expect(app).toContain("!hideRelations && selected?.documentType === DocumentType.MAIN && (");
-    expect(app.match(/selected\?\.documentType === DocumentType\.MAIN && \(/g)?.length).toBe(2);
+  it("主页与后台页均仅在选中主案且未处于检索态时渲染关系面板,占位文案已删除", () => {
+    expect(app).toContain("!hideRelations && !searchActive && selected?.documentType === DocumentType.MAIN && (");
+    expect(app.match(/!searchActive && selected\?\.documentType === DocumentType\.MAIN && \(/g)?.length).toBe(2);
     expect(app).not.toContain("请选择主案");
   });
 });
@@ -57,6 +57,12 @@ describe("定位逻辑", () => {
     expect(app.match(/setCategoryId\(main\.categoryId\);/g)?.length).toBe(2);
     expect(app.match(/setSelected\(main\);/g)?.length).toBe(2);
   });
+
+  it("定位按目标类型切换关系面板页签:附件→类型页签,主案/子案→配套方案", () => {
+    expect(app).toContain("function locateRelationTab");
+    expect(app.match(/setLowerTab\(tab\);/g)?.length).toBe(1);
+    expect(app.match(/setRelationTab\(tab\);/g)?.length).toBe(1);
+  });
 });
 
 describe("聚合数据层拉取策略", () => {

+ 3 - 2
frontend/src/features/files/__tests__/q3fScope.test.ts

@@ -46,8 +46,9 @@ describe("Q3-F 1.4 business alignment", () => {
     // flatMode 仅一级分类有意义:选中节点沿父链上溯到顶级读取配置
     expect(app).toContain("onSelectCategory?.(id, node ? node.flatMode : false)");
     expect(app).toContain("setHideRelations(flatTop)");
-    // 需求更新:面板还需选中主案才渲染(未选中时整体隐藏,不再显示占位)
-    expect(app).toContain("{!hideRelations && selected?.documentType === DocumentType.MAIN && (");
+    // 需求更新:面板还需选中主案才渲染(未选中时整体隐藏,不再显示占位);
+    // 全局搜索激活时同样隐藏配套列表
+    expect(app).toContain("{!hideRelations && !searchActive && selected?.documentType === DocumentType.MAIN && (");
   });
 
   it("对话框点击外部不再自动关闭,分类表单回车提交", () => {

+ 9 - 0
prompts.md

@@ -533,3 +533,12 @@ build.ps1,中文乱码了,解决一下
 目前,我的后端有没有什么必须使用python才能方便实现的功能?(如向量化、智能化之类的)。如果没有,我想尝试使用springboot进行转写,这样跨平台部署更加方便。但是,不要删除当前后端代码,在新目录下进行写入。
 
 ---
+
+检索功能,检索结果出来时,隐藏下方配套方案等面板显示。
+
+---
+
+检索结果定位功能,假如下方面板关闭前点到的是支撑成果,那么即使检索出来的是配套方案,点击定位后,下方面板显示的也还是支撑成果。应该显示配套方案。请修复。
+
+---
+