浏览代码

增加登录系统

xueyiming 1 天之前
父节点
当前提交
2ec5029419

+ 8 - 0
.env.example

@@ -43,6 +43,14 @@ MYSQL_WRITE_TIMEOUT_SECONDS=30
 MYSQL_OPERATIONAL_RESERVE=4
 MYSQL_ECHO=false
 
+# Local web authentication. The bootstrap account is only created when the
+# username does not already exist; changing these values will not reset it.
+AUTH_SESSION_HOURS=12
+AUTH_COOKIE_SECURE=false
+AUTH_BOOTSTRAP_ADMIN_USERNAME=admin
+AUTH_BOOTSTRAP_ADMIN_PASSWORD=
+AUTH_BOOTSTRAP_ADMIN_DISPLAY_NAME=系统管理员
+
 # ODPS (MaxCompute)
 ODPS_ACCESS_ID=
 ODPS_ACCESS_KEY=

+ 23 - 0
README.md

@@ -209,6 +209,29 @@ cd web && npm install && npm run dev
 - 前端: http://127.0.0.1:5173 (开发代理 `/api` → 8080)
 - 默认展开 3 层,可选择展开层数,支持节点手动展开/收起
 
+### 登录与权限
+
+Web 控制台使用本地账号和服务端 Session。系统包含两个固定角色:
+
+- `admin`:全部页面和 API 权限,并可在“用户管理”中创建、禁用和重置账号;
+- `user`:仅可访问“全局需求地图”和“需求汇总”及其只读 API。
+
+每次成功登录都会创建一条独立、不可刷新的会话 Token 记录,同一账号可同时在任意数量
+的浏览器或设备登录。退出当前设备只删除当前 Token;管理员修改账号角色、禁用账号或
+重置密码时,会统一使该账号的旧 Token 失效。
+
+首次部署前先执行数据库迁移,并通过环境变量创建初始管理员:
+
+```bash
+alembic upgrade head
+export AUTH_BOOTSTRAP_ADMIN_USERNAME=admin
+export AUTH_BOOTSTRAP_ADMIN_PASSWORD='replace-with-a-strong-password'
+python -m api
+```
+
+初始管理员只会在该用户名不存在时创建,修改环境变量不会重置已有密码。生产 HTTPS
+环境必须设置 `AUTH_COOKIE_SECURE=true`。
+
 ## 运行测试
 
 ```bash

+ 88 - 0
alembic/versions/20260730_04_add_local_auth.py

@@ -0,0 +1,88 @@
+"""add local users and server-side sessions
+
+Revision ID: 20260730_04
+Revises: 20260729_03
+Create Date: 2026-07-30
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260730_04"
+down_revision: str | None = "20260729_03"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.create_table(
+        "auth_user",
+        sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+        sa.Column("username", sa.String(length=64), nullable=False),
+        sa.Column("password_hash", sa.String(length=255), nullable=False),
+        sa.Column("display_name", sa.String(length=128), nullable=False),
+        sa.Column("role", sa.String(length=16), nullable=False),
+        sa.Column("status", sa.String(length=16), nullable=False),
+        sa.Column("failed_login_count", sa.Integer(), nullable=False, server_default="0"),
+        sa.Column("locked_until", sa.DateTime(), nullable=True),
+        sa.Column("last_login_at", sa.DateTime(), nullable=True),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.Column("updated_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("username", name="uq_auth_user_username"),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index("ix_auth_user_username", "auth_user", ["username"], unique=False)
+    op.create_index(
+        "idx_auth_user_role_status",
+        "auth_user",
+        ["role", "status"],
+        unique=False,
+    )
+
+    op.create_table(
+        "auth_session",
+        sa.Column("id", sa.Integer(), autoincrement=True, nullable=False),
+        sa.Column("token_hash", sa.String(length=64), nullable=False),
+        sa.Column("user_id", sa.Integer(), nullable=False),
+        sa.Column("expires_at", sa.DateTime(), nullable=False),
+        sa.Column("last_active_at", sa.DateTime(), nullable=False),
+        sa.Column("ip_address", sa.String(length=64), nullable=True),
+        sa.Column("user_agent", sa.String(length=512), nullable=True),
+        sa.Column("created_at", sa.DateTime(), server_default=sa.func.now(), nullable=False),
+        sa.ForeignKeyConstraint(["user_id"], ["auth_user.id"], ondelete="CASCADE"),
+        sa.PrimaryKeyConstraint("id"),
+        sa.UniqueConstraint("token_hash", name="uq_auth_session_token_hash"),
+        mysql_charset="utf8mb4",
+    )
+    op.create_index(
+        "ix_auth_session_token_hash",
+        "auth_session",
+        ["token_hash"],
+        unique=False,
+    )
+    op.create_index(
+        "idx_auth_session_user",
+        "auth_session",
+        ["user_id", "expires_at"],
+        unique=False,
+    )
+    op.create_index(
+        "idx_auth_session_expiry",
+        "auth_session",
+        ["expires_at"],
+        unique=False,
+    )
+
+
+def downgrade() -> None:
+    op.drop_index("idx_auth_session_expiry", table_name="auth_session")
+    op.drop_index("idx_auth_session_user", table_name="auth_session")
+    op.drop_index("ix_auth_session_token_hash", table_name="auth_session")
+    op.drop_table("auth_session")
+    op.drop_index("idx_auth_user_role_status", table_name="auth_user")
+    op.drop_index("ix_auth_user_username", table_name="auth_user")
+    op.drop_table("auth_user")

+ 33 - 0
alembic/versions/20260730_05_immutable_auth_session.py

@@ -0,0 +1,33 @@
+"""make persisted authentication sessions immutable
+
+Revision ID: 20260730_05
+Revises: 20260730_04
+Create Date: 2026-07-30
+"""
+from __future__ import annotations
+
+from collections.abc import Sequence
+
+import sqlalchemy as sa
+from alembic import op
+
+revision: str = "20260730_05"
+down_revision: str | None = "20260730_04"
+branch_labels: str | Sequence[str] | None = None
+depends_on: str | Sequence[str] | None = None
+
+
+def upgrade() -> None:
+    op.drop_column("auth_session", "last_active_at")
+
+
+def downgrade() -> None:
+    op.add_column(
+        "auth_session",
+        sa.Column(
+            "last_active_at",
+            sa.DateTime(),
+            nullable=False,
+            server_default=sa.func.now(),
+        ),
+    )

+ 6 - 0
api/app.py

@@ -11,11 +11,14 @@ from pydantic import BaseModel, Field
 from sqlalchemy import text
 from starlette.exceptions import HTTPException as StarletteHTTPException
 
+from api.auth_middleware import AuthenticationMiddleware
+from api.routers.auth import router as auth_router
 from api.routers.pipeline import router as pipeline_router
 from api.services.agent_catalog import (
     get_agent_detail,
     update_agent_document_injection,
 )
+from api.services.auth import ensure_bootstrap_admin
 from api.services.category_tree import build_category_tree
 from api.services.demand_belong_category import list_demand_belong_categories
 from api.services.demand_grade import list_demand_grades
@@ -59,11 +62,13 @@ class SPAStaticFiles(StaticFiles):
 async def lifespan(_app: FastAPI):
     if get_infra_settings().database_auto_create:
         init_db()
+    ensure_bootstrap_admin()
     yield
     dispose_engine()
 
 
 app = FastAPI(title="SupplyAgent API", version="0.1.0", lifespan=lifespan)
+app.include_router(auth_router)
 app.include_router(pipeline_router)
 
 app.add_middleware(
@@ -78,6 +83,7 @@ app.add_middleware(
     allow_methods=["*"],
     allow_headers=["*"],
 )
+app.add_middleware(AuthenticationMiddleware)
 
 
 @app.get("/health")

+ 74 - 0
api/auth_middleware.py

@@ -0,0 +1,74 @@
+from __future__ import annotations
+
+import re
+from collections.abc import Awaitable, Callable
+
+from fastapi import Request
+from starlette.concurrency import run_in_threadpool
+from starlette.middleware.base import BaseHTTPMiddleware
+from starlette.responses import JSONResponse, Response
+
+from api.services.auth import ADMIN_ROLE, SESSION_COOKIE_NAME, resolve_session
+
+_PUBLIC_PATHS = {
+    "/api/auth/login",
+}
+_AUTHENTICATED_USER_PATHS = {
+    ("GET", "/api/auth/me"),
+    ("POST", "/api/auth/logout"),
+}
+_NORMAL_USER_PATHS = {
+    ("GET", "/api/category-tree"),
+    ("GET", "/api/demand-grade"),
+    ("GET", "/api/video-discovery/demands"),
+}
+_NORMAL_USER_PATTERNS = (
+    re.compile(r"^/api/video-discovery/demands/\d+$"),
+    re.compile(r"^/api/demand-grade/\d+/videos$"),
+)
+
+
+def normal_user_can_access(method: str, path: str) -> bool:
+    if (method, path) in _AUTHENTICATED_USER_PATHS:
+        return True
+    if (method, path) in _NORMAL_USER_PATHS:
+        return True
+    return method == "GET" and any(pattern.fullmatch(path) for pattern in _NORMAL_USER_PATTERNS)
+
+
+def _is_protected_path(path: str) -> bool:
+    return (
+        path == "/api"
+        or path.startswith("/api/")
+        or path in {"/docs", "/redoc", "/openapi.json"}
+    )
+
+
+class AuthenticationMiddleware(BaseHTTPMiddleware):
+    """Authenticate API requests and enforce the two fixed application roles."""
+
+    async def dispatch(
+        self,
+        request: Request,
+        call_next: Callable[[Request], Awaitable[Response]],
+    ) -> Response:
+        path = request.url.path
+        method = request.method.upper()
+        if method == "OPTIONS" or not _is_protected_path(path) or path in _PUBLIC_PATHS:
+            return await call_next(request)
+
+        token = request.cookies.get(SESSION_COOKIE_NAME)
+        user = await run_in_threadpool(resolve_session, token) if token else None
+        if user is None:
+            return JSONResponse(
+                status_code=401,
+                content={"detail": "authentication required"},
+            )
+
+        request.state.current_user = user
+        if user["role"] == ADMIN_ROLE or normal_user_can_access(method, path):
+            return await call_next(request)
+        return JSONResponse(
+            status_code=403,
+            content={"detail": "permission denied"},
+        )

+ 120 - 0
api/routers/auth.py

@@ -0,0 +1,120 @@
+from __future__ import annotations
+
+from fastapi import APIRouter, HTTPException, Request, Response, status
+
+from api.schemas.auth import CreateAuthUserBody, LoginBody, UpdateAuthUserBody
+from api.services.auth import (
+    SESSION_COOKIE_NAME,
+    DuplicateUsernameError,
+    InvalidCredentialsError,
+    authenticate,
+    create_user,
+    delete_user,
+    list_users,
+    revoke_session,
+    update_user,
+)
+from supply_infra.config import get_infra_settings
+
+router = APIRouter(prefix="/api", tags=["authentication"])
+
+
+@router.post("/auth/login")
+def login(body: LoginBody, request: Request, response: Response) -> dict:
+    try:
+        user, token = authenticate(
+            username=body.username,
+            password=body.password,
+            ip_address=request.client.host if request.client else None,
+            user_agent=request.headers.get("user-agent"),
+        )
+    except InvalidCredentialsError:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail="用户名或密码错误",
+        ) from None
+
+    settings = get_infra_settings()
+    response.set_cookie(
+        key=SESSION_COOKIE_NAME,
+        value=token,
+        max_age=settings.auth_session_hours * 60 * 60,
+        httponly=True,
+        secure=settings.auth_cookie_secure,
+        samesite="lax",
+        path="/",
+    )
+    return {"user": user}
+
+
+@router.get("/auth/me")
+def me(request: Request) -> dict:
+    return {"user": request.state.current_user}
+
+
+@router.post("/auth/logout")
+def logout(request: Request, response: Response) -> dict[str, bool]:
+    token = request.cookies.get(SESSION_COOKIE_NAME)
+    if token:
+        revoke_session(token)
+    response.delete_cookie(
+        key=SESSION_COOKIE_NAME,
+        path="/",
+        secure=get_infra_settings().auth_cookie_secure,
+        httponly=True,
+        samesite="lax",
+    )
+    return {"ok": True}
+
+
+@router.get("/admin/users")
+def admin_users() -> dict:
+    return {"items": list_users()}
+
+
+@router.post("/admin/users", status_code=201)
+def admin_create_user(body: CreateAuthUserBody) -> dict:
+    try:
+        return create_user(
+            username=body.username,
+            password=body.password,
+            display_name=body.display_name,
+            role=body.role,
+        )
+    except DuplicateUsernameError:
+        raise HTTPException(status_code=409, detail="用户名已存在") from None
+
+
+@router.patch("/admin/users/{user_id}")
+def admin_update_user(
+    user_id: int,
+    body: UpdateAuthUserBody,
+    request: Request,
+) -> dict:
+    current_user = request.state.current_user
+    if current_user["id"] == user_id and (
+        (body.role is not None and body.role != current_user["role"])
+        or body.status == "disabled"
+        or body.password is not None
+    ):
+        raise HTTPException(status_code=409, detail="不能降级、禁用或重置当前登录账号")
+
+    result = update_user(
+        user_id,
+        display_name=body.display_name,
+        role=body.role,
+        status=body.status,
+        password=body.password,
+    )
+    if result is None:
+        raise HTTPException(status_code=404, detail="用户不存在")
+    return result
+
+
+@router.delete("/admin/users/{user_id}", status_code=204)
+def admin_delete_user(user_id: int, request: Request) -> Response:
+    if request.state.current_user["id"] == user_id:
+        raise HTTPException(status_code=409, detail="不能删除当前登录账号")
+    if not delete_user(user_id):
+        raise HTTPException(status_code=404, detail="用户不存在")
+    return Response(status_code=204)

+ 32 - 0
api/schemas/auth.py

@@ -0,0 +1,32 @@
+from __future__ import annotations
+
+from typing import Literal
+
+from pydantic import BaseModel, Field
+
+Username = str
+Role = Literal["admin", "user"]
+UserStatus = Literal["active", "disabled"]
+
+
+class LoginBody(BaseModel):
+    username: Username = Field(min_length=3, max_length=64)
+    password: str = Field(min_length=1, max_length=128)
+
+
+class CreateAuthUserBody(BaseModel):
+    username: Username = Field(
+        min_length=3,
+        max_length=64,
+        pattern=r"^[A-Za-z0-9_.-]+$",
+    )
+    password: str = Field(min_length=8, max_length=128)
+    display_name: str = Field(min_length=1, max_length=128)
+    role: Role = "user"
+
+
+class UpdateAuthUserBody(BaseModel):
+    display_name: str | None = Field(default=None, min_length=1, max_length=128)
+    role: Role | None = None
+    status: UserStatus | None = None
+    password: str | None = Field(default=None, min_length=8, max_length=128)

+ 280 - 0
api/services/auth.py

@@ -0,0 +1,280 @@
+from __future__ import annotations
+
+import hashlib
+import hmac
+import logging
+import secrets
+from datetime import datetime, timedelta
+from typing import Any
+
+from sqlalchemy.exc import IntegrityError
+
+from supply_infra.config import get_infra_settings
+from supply_infra.db import get_session
+from supply_infra.db.models.auth_user import AuthUser
+from supply_infra.db.repositories.auth_repo import AuthRepository
+from supply_infra.pipeline.dates import CHINA_TIMEZONE
+
+logger = logging.getLogger(__name__)
+
+SESSION_COOKIE_NAME = "supply_session"
+ADMIN_ROLE = "admin"
+USER_ROLE = "user"
+ACTIVE_STATUS = "active"
+DISABLED_STATUS = "disabled"
+VALID_ROLES = {ADMIN_ROLE, USER_ROLE}
+VALID_STATUSES = {ACTIVE_STATUS, DISABLED_STATUS}
+
+_SCRYPT_N = 2**14
+_SCRYPT_R = 8
+_SCRYPT_P = 1
+_DUMMY_PASSWORD_HASH: str | None = None
+
+
+class InvalidCredentialsError(Exception):
+    """Raised for every login rejection to avoid exposing account state."""
+
+
+class DuplicateUsernameError(Exception):
+    """Raised when a local username already exists."""
+
+
+def _now() -> datetime:
+    return datetime.now(CHINA_TIMEZONE).replace(tzinfo=None)
+
+
+def normalize_username(username: str) -> str:
+    return username.strip().lower()
+
+
+def hash_password(password: str) -> str:
+    salt = secrets.token_bytes(16)
+    derived = hashlib.scrypt(
+        password.encode("utf-8"),
+        salt=salt,
+        n=_SCRYPT_N,
+        r=_SCRYPT_R,
+        p=_SCRYPT_P,
+        dklen=32,
+    )
+    return (
+        f"scrypt${_SCRYPT_N}${_SCRYPT_R}${_SCRYPT_P}$"
+        f"{salt.hex()}${derived.hex()}"
+    )
+
+
+def verify_password(password: str, encoded: str) -> bool:
+    try:
+        algorithm, n, r, p, salt_hex, expected_hex = encoded.split("$", 5)
+        if algorithm != "scrypt":
+            return False
+        derived = hashlib.scrypt(
+            password.encode("utf-8"),
+            salt=bytes.fromhex(salt_hex),
+            n=int(n),
+            r=int(r),
+            p=int(p),
+            dklen=len(bytes.fromhex(expected_hex)),
+        )
+        return hmac.compare_digest(derived.hex(), expected_hex)
+    except (TypeError, ValueError):
+        return False
+
+
+def _dummy_password_hash() -> str:
+    global _DUMMY_PASSWORD_HASH
+    if _DUMMY_PASSWORD_HASH is None:
+        _DUMMY_PASSWORD_HASH = hash_password("supply-agent-invalid-password")
+    return _DUMMY_PASSWORD_HASH
+
+
+def hash_session_token(token: str) -> str:
+    return hashlib.sha256(token.encode("utf-8")).hexdigest()
+
+
+def serialize_user(user: AuthUser) -> dict[str, Any]:
+    return {
+        "id": user.id,
+        "username": user.username,
+        "display_name": user.display_name,
+        "role": user.role,
+        "status": user.status,
+        "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
+        "created_at": user.created_at.isoformat() if user.created_at else None,
+        "updated_at": user.updated_at.isoformat() if user.updated_at else None,
+    }
+
+
+def authenticate(
+    *,
+    username: str,
+    password: str,
+    ip_address: str | None,
+    user_agent: str | None,
+) -> tuple[dict[str, Any], str]:
+    now = _now()
+    normalized = normalize_username(username)
+    settings = get_infra_settings()
+
+    with get_session() as db:
+        repo = AuthRepository(db)
+        repo.delete_expired_sessions(now)
+        user = repo.get_user_by_username(normalized)
+
+        if user is None:
+            verify_password(password, _dummy_password_hash())
+            raise InvalidCredentialsError
+
+        if user.locked_until and user.locked_until > now:
+            verify_password(password, user.password_hash)
+            raise InvalidCredentialsError
+
+        if user.locked_until and user.locked_until <= now:
+            user.locked_until = None
+            user.failed_login_count = 0
+
+        password_valid = verify_password(password, user.password_hash)
+        if not password_valid or user.status != ACTIVE_STATUS:
+            if password_valid:
+                raise InvalidCredentialsError
+            user.failed_login_count += 1
+            if user.failed_login_count >= 5:
+                user.failed_login_count = 0
+                user.locked_until = now + timedelta(minutes=15)
+            raise InvalidCredentialsError
+
+        user.failed_login_count = 0
+        user.locked_until = None
+        user.last_login_at = now
+        token = secrets.token_urlsafe(32)
+        repo.create_session(
+            token_hash=hash_session_token(token),
+            user_id=user.id,
+            expires_at=now + timedelta(hours=settings.auth_session_hours),
+            ip_address=(ip_address or "")[:64] or None,
+            user_agent=(user_agent or "")[:512] or None,
+        )
+        db.flush()
+        return serialize_user(user), token
+
+
+def resolve_session(token: str) -> dict[str, Any] | None:
+    now = _now()
+    with get_session() as db:
+        repo = AuthRepository(db)
+        user = repo.get_active_session(
+            token_hash=hash_session_token(token),
+            now=now,
+        )
+        if user is None:
+            return None
+        return serialize_user(user)
+
+
+def revoke_session(token: str) -> None:
+    with get_session() as db:
+        AuthRepository(db).delete_session_by_hash(hash_session_token(token))
+
+
+def list_users() -> list[dict[str, Any]]:
+    with get_session() as db:
+        return [serialize_user(user) for user in AuthRepository(db).list_users()]
+
+
+def create_user(
+    *,
+    username: str,
+    password: str,
+    display_name: str,
+    role: str,
+) -> dict[str, Any]:
+    if role not in VALID_ROLES:
+        raise ValueError("invalid role")
+    try:
+        with get_session() as db:
+            user = AuthRepository(db).create_user(
+                username=normalize_username(username),
+                password_hash=hash_password(password),
+                display_name=display_name.strip(),
+                role=role,
+            )
+            db.flush()
+            return serialize_user(user)
+    except IntegrityError as exc:
+        raise DuplicateUsernameError from exc
+
+
+def update_user(
+    user_id: int,
+    *,
+    display_name: str | None = None,
+    role: str | None = None,
+    status: str | None = None,
+    password: str | None = None,
+) -> dict[str, Any] | None:
+    if role is not None and role not in VALID_ROLES:
+        raise ValueError("invalid role")
+    if status is not None and status not in VALID_STATUSES:
+        raise ValueError("invalid status")
+
+    with get_session() as db:
+        repo = AuthRepository(db)
+        user = repo.get_user(user_id)
+        if user is None:
+            return None
+        revoke_existing_sessions = False
+        if display_name is not None:
+            user.display_name = display_name.strip()
+        if role is not None and role != user.role:
+            user.role = role
+            revoke_existing_sessions = True
+        if status is not None and status != user.status:
+            user.status = status
+            revoke_existing_sessions = True
+        if password is not None:
+            user.password_hash = hash_password(password)
+            user.failed_login_count = 0
+            user.locked_until = None
+            revoke_existing_sessions = True
+        if revoke_existing_sessions:
+            repo.delete_user_sessions(user.id)
+        db.flush()
+        return serialize_user(user)
+
+
+def delete_user(user_id: int) -> bool:
+    with get_session() as db:
+        repo = AuthRepository(db)
+        user = repo.get_user(user_id)
+        if user is None:
+            return False
+        repo.delete_user_sessions(user.id)
+        repo.delete_user(user)
+        return True
+
+
+def ensure_bootstrap_admin() -> None:
+    settings = get_infra_settings()
+    username = normalize_username(settings.auth_bootstrap_admin_username)
+    password = settings.auth_bootstrap_admin_password.get_secret_value()
+    if not username and not password:
+        return
+    if not username or not password:
+        raise RuntimeError(
+            "AUTH_BOOTSTRAP_ADMIN_USERNAME and AUTH_BOOTSTRAP_ADMIN_PASSWORD "
+            "must be configured together"
+        )
+    if len(password) < 8:
+        raise RuntimeError("AUTH_BOOTSTRAP_ADMIN_PASSWORD must contain at least 8 characters")
+
+    with get_session() as db:
+        repo = AuthRepository(db)
+        if repo.get_user_by_username(username) is not None:
+            return
+        repo.create_user(
+            username=username,
+            password_hash=hash_password(password),
+            display_name=settings.auth_bootstrap_admin_display_name.strip() or "系统管理员",
+            role=ADMIN_ROLE,
+        )
+        logger.info("Created bootstrap administrator: username=%s", username)

+ 114 - 35
api/services/video_discovery.py

@@ -17,6 +17,75 @@ from supply_infra.db.repositories.multi_demand_video_detail_repo import (
 from supply_infra.db.session import get_session
 
 _POINT_TYPES = {"inspiration", "purpose", "key"}
+_SOURCE_VIDEO_GRADES = frozenset({"B", "C", "D"})
+_VIDEO_DETAIL_URL_TEMPLATE = "https://admin.piaoquantv.com/cms/post-detail/{vid}/detail"
+
+
+def _parse_video_ids(raw: Any) -> list[str]:
+    """Parse JSON array or comma-separated text into ordered unique vid list."""
+    if raw is None:
+        return []
+
+    items: list[Any]
+    if isinstance(raw, str):
+        text = raw.strip()
+        if not text:
+            return []
+        try:
+            parsed = json.loads(text)
+        except (TypeError, ValueError):
+            parsed = None
+        if isinstance(parsed, list):
+            items = parsed
+        elif parsed is not None:
+            items = [parsed]
+        else:
+            items = [part.strip() for part in text.split(",")]
+    elif isinstance(raw, (list, tuple)):
+        items = list(raw)
+    else:
+        return []
+
+    result: list[str] = []
+    seen: set[str] = set()
+    for item in items:
+        vid = str(item).strip() if item is not None else ""
+        if vid and vid not in seen:
+            seen.add(vid)
+            result.append(vid)
+    return result
+
+
+def _normalize_url(value: Any) -> str | None:
+    if value is None:
+        return None
+    text = str(value).strip()
+    return text or None
+
+
+def _serialize_video(
+    video_id: str,
+    detail: Any | None,
+    points: list[dict[str, Any]],
+) -> dict[str, Any]:
+    url1 = _normalize_url(detail.url1) if detail else None
+    url2 = _normalize_url(detail.url2) if detail else None
+    return {
+        "vid": video_id,
+        "title": detail.title if detail else None,
+        "url1": url1,
+        "url2": url2,
+        "video_detail_url": _VIDEO_DETAIL_URL_TEMPLATE.format(vid=video_id),
+        "point_count": len(points),
+        "points": points,
+    }
+
+
+def _source_video_count(row: Any, expansion_counts: dict[int, int]) -> int:
+    grade = str(row.grade or "").upper()
+    if grade in _SOURCE_VIDEO_GRADES:
+        return len(_parse_video_ids(row.video_list))
+    return expansion_counts.get(int(row.id), 0)
 
 
 def _parse_string_list(raw: Any) -> list[str]:
@@ -131,7 +200,7 @@ def list_video_discovery_demands(biz_dt: str | None = None) -> dict[str, Any]:
                 _serialize_demand(
                     row,
                     category_names,
-                    video_counts.get(int(row.id), 0),
+                    _source_video_count(row, video_counts),
                 )
                 for row in rows
             ],
@@ -142,52 +211,62 @@ def get_video_discovery_demand(demand_grade_id: int) -> dict[str, Any] | None:
     """
     Resolve the judged video list and hit evidence from demand_video_expansion.
 
-    multi_demand_video_detail contributes only the title and no other field.
+    S/A 使用拓展命中视频;B/C/D 使用 demand_grade.video_list 中的源视频。
+    multi_demand_video_detail 补充 title 与解构 url1/url2。
     """
     with get_session() as session:
         grade = DemandGradeRepository(session).get_by_id(demand_grade_id)
         if grade is None:
             return None
 
-        expansions = DemandVideoExpansionRepository(session).list_by_demand_grade(
-            str(grade.biz_dt),
-            demand_grade_id,
-        )
         category_names = _category_name_map(session, [grade])
+        grade_code = str(grade.grade or "").upper()
 
-        video_ids: list[str] = []
-        points_by_video: dict[str, list[dict[str, Any]]] = {}
-        for row in expansions:
-            video_id = str(row.video_id).strip()
-            if not video_id or row.point_type not in _POINT_TYPES:
-                continue
-            if video_id not in points_by_video:
-                video_ids.append(video_id)
-                points_by_video[video_id] = []
-            points_by_video[video_id].append(
-                {
-                    "point_type": row.point_type,
-                    "expanded_text": row.expanded_text,
-                    "point_desc": row.point_desc,
-                    "reason": row.reason,
-                }
+        if grade_code in _SOURCE_VIDEO_GRADES:
+            video_ids = _parse_video_ids(grade.video_list)
+            details = MultiDemandVideoDetailRepository(session).list_by_vids(video_ids)
+            videos = [
+                _serialize_video(video_id, details.get(video_id), [])
+                for video_id in video_ids
+            ]
+        else:
+            expansions = DemandVideoExpansionRepository(session).list_by_demand_grade(
+                str(grade.biz_dt),
+                demand_grade_id,
             )
 
-        details = MultiDemandVideoDetailRepository(session).list_by_vids(video_ids)
-        videos: list[dict[str, Any]] = []
-        for video_id in video_ids:
-            points = points_by_video[video_id]
-            detail = details.get(video_id)
-            videos.append(
-                {
-                    "vid": video_id,
-                    "title": detail.title if detail else None,
-                    "point_count": len(points),
-                    "points": points,
-                }
-            )
+            video_ids: list[str] = []
+            points_by_video: dict[str, list[dict[str, Any]]] = {}
+            for row in expansions:
+                video_id = str(row.video_id).strip()
+                if not video_id or row.point_type not in _POINT_TYPES:
+                    continue
+                if video_id not in points_by_video:
+                    video_ids.append(video_id)
+                    points_by_video[video_id] = []
+                points_by_video[video_id].append(
+                    {
+                        "point_type": row.point_type,
+                        "expanded_text": row.expanded_text,
+                        "point_desc": row.point_desc,
+                        "reason": row.reason,
+                    }
+                )
+
+            details = MultiDemandVideoDetailRepository(session).list_by_vids(video_ids)
+            videos = [
+                _serialize_video(
+                    video_id,
+                    details.get(video_id),
+                    points_by_video[video_id],
+                )
+                for video_id in video_ids
+            ]
 
         demand = _serialize_demand(grade, category_names, len(videos))
         demand["point_count"] = sum(video["point_count"] for video in videos)
         demand["videos"] = videos
+        demand["video_source"] = (
+            "pool" if grade_code in _SOURCE_VIDEO_GRADES else "expansion"
+        )
         return demand

+ 3 - 0
deploy/README.md

@@ -15,6 +15,9 @@ Scheduler、多个 Pipeline Worker 和 Reconciler。
    调度 deadline,手工/API/CLI 补数不设置 deadline。
 7. 单个 `find_agent` 默认最多运行 600 秒(10 分钟);运行中的找片批次每 60 秒输出一次
    进度日志,超时记录会标记失败,批次继续处理下一条需求。
+8. 首次部署配置 `AUTH_BOOTSTRAP_ADMIN_USERNAME` 和至少 8 位的
+   `AUTH_BOOTSTRAP_ADMIN_PASSWORD` 以创建初始管理员;已有同名用户不会被覆盖。
+   HTTPS 生产环境设置 `AUTH_COOKIE_SECURE=true`。
 
 示例:
 

+ 76 - 0
scripts/backfill_multi_demand_video_urls.py

@@ -0,0 +1,76 @@
+#!/usr/bin/env python3
+"""回填 multi_demand_video_detail 已有行的 url1/url2(从 ODPS 拉取)。
+
+Usage:
+  python scripts/backfill_multi_demand_video_urls.py
+  python scripts/backfill_multi_demand_video_urls.py --decode-dt 20260729
+  python scripts/backfill_multi_demand_video_urls.py --limit 500 --offset 0
+  python scripts/backfill_multi_demand_video_urls.py --batch-size 100
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import logging
+import sys
+from pathlib import Path
+
+_ROOT = Path(__file__).resolve().parents[1]
+if str(_ROOT) not in sys.path:
+    sys.path.insert(0, str(_ROOT))
+
+from supply_infra.scheduler.jobs.demand_pool.videos import (  # noqa: E402
+    VIDEO_SYNC_BATCH_SIZE,
+    backfill_multi_demand_video_urls,
+)
+
+logging.basicConfig(
+    level=logging.INFO,
+    format="%(asctime)s %(levelname)s %(name)s: %(message)s",
+)
+
+
+def _build_parser() -> argparse.ArgumentParser:
+    parser = argparse.ArgumentParser(
+        description="回填 multi_demand_video_detail.url1/url2"
+    )
+    parser.add_argument(
+        "--decode-dt",
+        help="ODPS dwd_topic_decode_result_di 分区日期 YYYYMMDD,默认昨天",
+    )
+    parser.add_argument(
+        "--limit",
+        type=int,
+        default=None,
+        help="本轮最多处理的 vid 数,默认全部",
+    )
+    parser.add_argument(
+        "--offset",
+        type=int,
+        default=0,
+        help="pending 列表起始偏移,用于分批手工执行",
+    )
+    parser.add_argument(
+        "--batch-size",
+        type=int,
+        default=VIDEO_SYNC_BATCH_SIZE,
+        help=f"每批查询 ODPS 的 vid 数,默认 {VIDEO_SYNC_BATCH_SIZE}",
+    )
+    return parser
+
+
+def main(argv: list[str] | None = None) -> int:
+    args = _build_parser().parse_args(argv)
+    result = backfill_multi_demand_video_urls(
+        limit=args.limit,
+        offset=args.offset,
+        batch_size=args.batch_size,
+        decode_dt=args.decode_dt,
+    )
+    print(json.dumps(result, ensure_ascii=False, indent=2))
+    return 0
+
+
+if __name__ == "__main__":
+    raise SystemExit(main())

+ 10 - 0
sql/multi_demand_video_detail_add_url1_url2.sql

@@ -0,0 +1,10 @@
+-- multi_demand_video_detail 增加 url1、url2(与 vid 同级,来自 ODPS dwd_topic_decode_result_di 表字段)
+-- MySQL 5.7+ / 8.0+
+
+ALTER TABLE `multi_demand_video_detail`
+  ADD COLUMN `url1` VARCHAR(1024) NULL
+    COMMENT '解构URL1(dwd_topic_decode_result_di 表字段,与 vid 同级)'
+    AFTER `vid`,
+  ADD COLUMN `url2` VARCHAR(1024) NULL
+    COMMENT '解构URL2(dwd_topic_decode_result_di 表字段,与 vid 同级)'
+    AFTER `url1`;

+ 17 - 1
supply_infra/config.py

@@ -4,7 +4,7 @@ from functools import lru_cache
 from pathlib import Path
 from urllib.parse import quote_plus
 
-from pydantic import Field, model_validator
+from pydantic import Field, SecretStr, model_validator
 from pydantic_settings import BaseSettings, SettingsConfigDict
 
 # supply_infra/config.py -> project root; avoid depending on process cwd
@@ -73,6 +73,22 @@ class InfraSettings(BaseSettings):
     )
     mysql_echo: bool = Field(default=False, alias="MYSQL_ECHO")
 
+    # Local web authentication
+    auth_session_hours: int = Field(default=12, ge=1, le=168, alias="AUTH_SESSION_HOURS")
+    auth_cookie_secure: bool = Field(default=False, alias="AUTH_COOKIE_SECURE")
+    auth_bootstrap_admin_username: str = Field(
+        default="",
+        alias="AUTH_BOOTSTRAP_ADMIN_USERNAME",
+    )
+    auth_bootstrap_admin_password: SecretStr = Field(
+        default=SecretStr(""),
+        alias="AUTH_BOOTSTRAP_ADMIN_PASSWORD",
+    )
+    auth_bootstrap_admin_display_name: str = Field(
+        default="系统管理员",
+        alias="AUTH_BOOTSTRAP_ADMIN_DISPLAY_NAME",
+    )
+
     # ODPS (MaxCompute)
     odps_access_id: str = Field(default="", alias="ODPS_ACCESS_ID")
     odps_access_key: str = Field(default="", alias="ODPS_ACCESS_KEY")

+ 4 - 0
supply_infra/db/models/__init__.py

@@ -1,6 +1,8 @@
 """ORM entity models — one file per table."""
 
 from supply_infra.db.models.agent_document_injection import AgentDocumentInjection
+from supply_infra.db.models.auth_session import AuthSession
+from supply_infra.db.models.auth_user import AuthUser
 from supply_infra.db.models.category_tree_weight import CategoryTreeWeight
 from supply_infra.db.models.demand_belong_category import DemandBelongCategory
 from supply_infra.db.models.demand_belong_pool_rel import DemandBelongPoolRel
@@ -35,6 +37,8 @@ from supply_infra.db.models.video_discovery import (
 
 __all__ = [
     "AgentDocumentInjection",
+    "AuthSession",
+    "AuthUser",
     "CategoryTreeWeight",
     "DemandBelongCategory",
     "DemandBelongPoolRel",

+ 39 - 0
supply_infra/db/models/auth_session.py

@@ -0,0 +1,39 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, ForeignKey, Index, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class AuthSession(Base):
+    """Server-side browser session. Only a SHA-256 token hash is persisted."""
+
+    __tablename__ = "auth_session"
+    __table_args__ = (
+        Index("idx_auth_session_user", "user_id", "expires_at"),
+        Index("idx_auth_session_expiry", "expires_at"),
+    )
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+    token_hash: Mapped[str] = mapped_column(
+        String(64),
+        nullable=False,
+        unique=True,
+        index=True,
+    )
+    user_id: Mapped[int] = mapped_column(
+        Integer,
+        ForeignKey("auth_user.id", ondelete="CASCADE"),
+        nullable=False,
+    )
+    expires_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
+    ip_address: Mapped[str | None] = mapped_column(String(64), nullable=True)
+    user_agent: Mapped[str | None] = mapped_column(String(512), nullable=True)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+    )

+ 43 - 0
supply_infra/db/models/auth_user.py

@@ -0,0 +1,43 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import DateTime, Index, Integer, String, func
+from sqlalchemy.orm import Mapped, mapped_column
+
+from supply_infra.db.base import Base
+
+
+class AuthUser(Base):
+    """Local SupplyAgent account with one fixed role."""
+
+    __tablename__ = "auth_user"
+    __table_args__ = (
+        Index("idx_auth_user_role_status", "role", "status"),
+    )
+
+    id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
+    username: Mapped[str] = mapped_column(
+        String(64),
+        nullable=False,
+        unique=True,
+        index=True,
+    )
+    password_hash: Mapped[str] = mapped_column(String(255), nullable=False)
+    display_name: Mapped[str] = mapped_column(String(128), nullable=False)
+    role: Mapped[str] = mapped_column(String(16), nullable=False, default="user")
+    status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
+    failed_login_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
+    locked_until: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
+    created_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+    )
+    updated_at: Mapped[datetime] = mapped_column(
+        DateTime,
+        nullable=False,
+        server_default=func.now(),
+        onupdate=func.now(),
+    )

+ 10 - 0
supply_infra/db/models/multi_demand_video_detail.py

@@ -16,6 +16,16 @@ class MultiDemandVideoDetail(Base):
 
     id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True)
     vid: Mapped[str] = mapped_column(String(64), nullable=False, comment="视频id")
+    url1: Mapped[str | None] = mapped_column(
+        String(1024),
+        nullable=True,
+        comment="解构URL1(dwd_topic_decode_result_di 表字段,与 vid 同级)",
+    )
+    url2: Mapped[str | None] = mapped_column(
+        String(1024),
+        nullable=True,
+        comment="解构URL2(dwd_topic_decode_result_di 表字段,与 vid 同级)",
+    )
     title: Mapped[str | None] = mapped_column(
         String(512), nullable=True, comment="视频标题(decode_result.target_post.title)"
     )

+ 2 - 0
supply_infra/db/repositories/__init__.py

@@ -3,6 +3,7 @@
 from supply_infra.db.repositories.agent_document_injection_repo import (
     AgentDocumentInjectionRepository,
 )
+from supply_infra.db.repositories.auth_repo import AuthRepository
 from supply_infra.db.repositories.base import BaseRepository
 from supply_infra.db.repositories.category_tree_weight_repo import (
     CategoryTreeWeightRepository,
@@ -44,6 +45,7 @@ from supply_infra.db.repositories.video_discovery_repo import VideoDiscoveryRepo
 
 __all__ = [
     "AgentDocumentInjectionRepository",
+    "AuthRepository",
     "BaseRepository",
     "CategoryTreeWeightRepository",
     "DemandBelongCategoryRepository",

+ 104 - 0
supply_infra/db/repositories/auth_repo.py

@@ -0,0 +1,104 @@
+from __future__ import annotations
+
+from datetime import datetime
+
+from sqlalchemy import delete, select
+from sqlalchemy.orm import Session
+
+from supply_infra.db.models.auth_session import AuthSession
+from supply_infra.db.models.auth_user import AuthUser
+
+
+class AuthRepository:
+    """Persistence operations for local users and server-side sessions."""
+
+    def __init__(self, session: Session) -> None:
+        self.session = session
+
+    def get_user(self, user_id: int) -> AuthUser | None:
+        return self.session.get(AuthUser, user_id)
+
+    def get_user_by_username(self, username: str) -> AuthUser | None:
+        stmt = select(AuthUser).where(AuthUser.username == username)
+        return self.session.scalar(stmt)
+
+    def list_users(self) -> list[AuthUser]:
+        stmt = select(AuthUser).order_by(AuthUser.created_at.asc(), AuthUser.id.asc())
+        return list(self.session.scalars(stmt).all())
+
+    def create_user(
+        self,
+        *,
+        username: str,
+        password_hash: str,
+        display_name: str,
+        role: str,
+        status: str = "active",
+    ) -> AuthUser:
+        user = AuthUser(
+            username=username,
+            password_hash=password_hash,
+            display_name=display_name,
+            role=role,
+            status=status,
+        )
+        self.session.add(user)
+        self.session.flush()
+        return user
+
+    def delete_user(self, user: AuthUser) -> None:
+        self.session.delete(user)
+        self.session.flush()
+
+    def create_session(
+        self,
+        *,
+        token_hash: str,
+        user_id: int,
+        expires_at: datetime,
+        ip_address: str | None,
+        user_agent: str | None,
+    ) -> AuthSession:
+        auth_session = AuthSession(
+            token_hash=token_hash,
+            user_id=user_id,
+            expires_at=expires_at,
+            ip_address=ip_address,
+            user_agent=user_agent,
+        )
+        self.session.add(auth_session)
+        self.session.flush()
+        return auth_session
+
+    def get_active_session(
+        self,
+        *,
+        token_hash: str,
+        now: datetime,
+    ) -> AuthUser | None:
+        stmt = (
+            select(AuthUser)
+            .select_from(AuthSession)
+            .join(AuthUser, AuthUser.id == AuthSession.user_id)
+            .where(
+                AuthSession.token_hash == token_hash,
+                AuthSession.expires_at > now,
+                AuthUser.status == "active",
+            )
+        )
+        return self.session.scalar(stmt)
+
+    def delete_session_by_hash(self, token_hash: str) -> None:
+        self.session.execute(
+            delete(AuthSession).where(AuthSession.token_hash == token_hash)
+        )
+
+    def delete_user_sessions(self, user_id: int) -> None:
+        self.session.execute(
+            delete(AuthSession).where(AuthSession.user_id == user_id)
+        )
+
+    def delete_expired_sessions(self, now: datetime) -> None:
+        self.session.execute(
+            delete(AuthSession).where(AuthSession.expires_at <= now)
+        )

+ 28 - 0
supply_infra/db/repositories/multi_demand_video_detail_repo.py

@@ -64,6 +64,16 @@ class MultiDemandVideoDetailRepository(BaseRepository[MultiDemandVideoDetail]):
         )
         return [str(v) for v in self.session.scalars(stmt).all() if v]
 
+    def list_vids_missing_urls(self) -> list[str]:
+        """返回 url1 或 url2 为空的全部 vid。"""
+        stmt = select(MultiDemandVideoDetail.vid).where(
+            MultiDemandVideoDetail.url1.is_(None)
+            | (MultiDemandVideoDetail.url1 == "")
+            | MultiDemandVideoDetail.url2.is_(None)
+            | (MultiDemandVideoDetail.url2 == "")
+        )
+        return [str(v) for v in self.session.scalars(stmt).all() if v]
+
     def bulk_insert_ignore(self, rows: list[dict]) -> int:
         """批量插入,MySQL 自动忽略 vid 重复行。"""
         if not rows:
@@ -93,6 +103,24 @@ class MultiDemandVideoDetailRepository(BaseRepository[MultiDemandVideoDetail]):
             updated += result.rowcount or 0
         return updated
 
+    def update_urls(self, urls_by_vid: dict[str, dict[str, str | None]]) -> int:
+        """按 vid 批量更新 url1/url2(values 为落库字段名 → URL 文本)。"""
+        if not urls_by_vid:
+            return 0
+
+        updated = 0
+        for vid, values in urls_by_vid.items():
+            if not values:
+                continue
+            stmt = (
+                update(MultiDemandVideoDetail)
+                .where(MultiDemandVideoDetail.vid == vid)
+                .values(**values)
+            )
+            result = self.session.execute(stmt)
+            updated += result.rowcount or 0
+        return updated
+
     def update_points(self, points_by_vid: dict[str, dict[str, str | None]]) -> int:
         """按 vid 批量更新灵感点/目的点/关键点(values 为落库字段名 → JSON 文本)。"""
         if not points_by_vid:

+ 3 - 1
supply_infra/odps/client.py

@@ -124,7 +124,7 @@ class ODPSClient:
         vids: list[str],
         batch_size: int = 100,
     ) -> list[dict[str, Any]]:
-        """按 vid 批量拉取 dwd_topic_decode_result_di 的 decode_result。"""
+        """按 vid 批量拉取 dwd_topic_decode_result_di 的 vid、url1、url2、decode_result。"""
         # 保序去重
         unique_vids = list(
             dict.fromkeys(
@@ -141,6 +141,8 @@ class ODPSClient:
             sql = f"""
             SELECT  vid
                     ,decode_result
+                    ,url1
+                    ,url2
             FROM    loghubods.dwd_topic_decode_result_di
             WHERE   dt = '{dt}'
             AND     vid IN ({in_list})

+ 163 - 0
supply_infra/scheduler/jobs/demand_pool/videos.py

@@ -99,6 +99,14 @@ def _extract_title(payload: dict[str, Any]) -> str | None:
     return text[:512] if text else None
 
 
+def _extract_url(value: Any, max_len: int = 1024) -> str | None:
+    """从 ODPS 行字段提取 URL 文本(与 vid 同级)。"""
+    if value is None:
+        return None
+    text = str(value).strip()
+    return text[:max_len] if text else None
+
+
 def _sync_one_batch(
     odps: ODPSClient,
     decode_dt: str,
@@ -141,6 +149,8 @@ def _sync_one_batch(
         insert_rows.append(
             {
                 "vid": vid,
+                "url1": _extract_url(row.get("url1")),
+                "url2": _extract_url(row.get("url2")),
                 "title": _extract_title(payload),
                 "final_topic_json": topic_json,
                 **_extract_all_points(payload),
@@ -280,3 +290,156 @@ def sync_multi_demand_videos(
     }
     logger.info("Multi demand video sync completed: %s", result)
     return result
+
+
+def _backfill_urls_one_batch(
+    odps: ODPSClient,
+    decode_dt: str,
+    batch_vids: list[str],
+    batch_idx: int,
+    batch_total: int,
+) -> dict[str, int]:
+    """查询一批 vid 的 url1/url2,立刻更新 MySQL。"""
+    logger.info(
+        "Backfill batch %d/%d: query %d vids, decode_dt=%s",
+        batch_idx,
+        batch_total,
+        len(batch_vids),
+        decode_dt,
+    )
+    odps_rows = odps.fetch_topic_decode_results(
+        decode_dt, batch_vids, batch_size=len(batch_vids)
+    )
+
+    urls_by_vid: dict[str, dict[str, str | None]] = {}
+    seen_vids: set[str] = set()
+    for row in odps_rows:
+        raw_vid = row.get("vid")
+        if raw_vid is None:
+            continue
+        vid = str(raw_vid).strip()
+        if not vid or vid in seen_vids:
+            continue
+        url1 = _extract_url(row.get("url1"))
+        url2 = _extract_url(row.get("url2"))
+        if url1 is None and url2 is None:
+            continue
+        seen_vids.add(vid)
+        urls_by_vid[vid] = {"url1": url1, "url2": url2}
+
+    with get_session() as session:
+        updated = MultiDemandVideoDetailRepository(session).update_urls(urls_by_vid)
+
+    odps_vids = {
+        str(r.get("vid")).strip()
+        for r in odps_rows
+        if r.get("vid") is not None and str(r.get("vid")).strip()
+    }
+    stats = {
+        "odps_rows": len(odps_rows),
+        "matched": len(urls_by_vid),
+        "updated": updated,
+        "missing_in_odps": len(set(batch_vids) - odps_vids),
+        "no_urls_in_odps": len(odps_rows) - len(urls_by_vid),
+    }
+    logger.info("Backfill batch %d/%d done: %s", batch_idx, batch_total, stats)
+    return stats
+
+
+def backfill_multi_demand_video_urls(
+    limit: int | None = None,
+    offset: int = 0,
+    batch_size: int = VIDEO_SYNC_BATCH_SIZE,
+    *,
+    decode_dt: str | None = None,
+) -> dict[str, Any]:
+    """
+    回填已有 multi_demand_video_detail 行的 url1/url2。
+
+    - 目标:url1 或 url2 为空的 vid
+    - 数据来源:ODPS dwd_topic_decode_result_di 表字段(与 vid 同级)
+    - decode_dt 默认「今天的昨天」
+    """
+    resolved_decode_dt = decode_dt or (
+        datetime.now() - timedelta(days=1)
+    ).strftime("%Y%m%d")
+    start = max(0, int(offset))
+    chunk = max(1, int(batch_size))
+    logger.info(
+        "Starting multi demand video url backfill: decode_dt=%s limit=%s offset=%d batch_size=%d",
+        resolved_decode_dt,
+        limit,
+        start,
+        chunk,
+    )
+
+    with get_session() as session:
+        pending_all = sorted(
+            MultiDemandVideoDetailRepository(session).list_vids_missing_urls()
+        )
+
+    pending = pending_all[start:]
+    if limit is not None:
+        pending = pending[: max(0, int(limit))]
+
+    logger.info(
+        "Url backfill vids: pending_total=%d to_process=%d",
+        len(pending_all),
+        len(pending),
+    )
+
+    empty = {
+        "decode_dt": resolved_decode_dt,
+        "pending_total": len(pending_all),
+        "offset": start,
+        "batch_size": chunk,
+        "batches": 0,
+        "processed": 0,
+        "odps_rows": 0,
+        "matched": 0,
+        "updated": 0,
+        "missing_in_odps": 0,
+        "no_urls_in_odps": 0,
+        "next_offset": start,
+        "remaining": max(0, len(pending_all) - start),
+    }
+    if not pending:
+        logger.info("No pending vids for url backfill: %s", empty)
+        return empty
+
+    odps = get_odps_client()
+    batches = [pending[i : i + chunk] for i in range(0, len(pending), chunk)]
+    total_odps_rows = 0
+    total_matched = 0
+    total_updated = 0
+    total_missing = 0
+    total_no_urls = 0
+
+    for idx, batch_vids in enumerate(batches, start=1):
+        stats = _backfill_urls_one_batch(
+            odps, resolved_decode_dt, batch_vids, idx, len(batches)
+        )
+        total_odps_rows += stats["odps_rows"]
+        total_matched += stats["matched"]
+        total_updated += stats["updated"]
+        total_missing += stats["missing_in_odps"]
+        total_no_urls += stats["no_urls_in_odps"]
+
+    next_offset = start + len(pending)
+    result = {
+        "decode_dt": resolved_decode_dt,
+        "pending_total": len(pending_all),
+        "offset": start,
+        "batch_size": chunk,
+        "batches": len(batches),
+        "processed": len(pending),
+        "odps_rows": total_odps_rows,
+        "matched": total_matched,
+        "updated": total_updated,
+        "missing_in_odps": total_missing,
+        "no_urls_in_odps": total_no_urls,
+        "next_offset": next_offset,
+        "remaining": max(0, len(pending_all) - next_offset),
+    }
+    logger.info("Multi demand video url backfill completed: %s", result)
+    return result

+ 92 - 9
web/src/App.vue

@@ -1,20 +1,35 @@
 <script setup lang="ts">
 import { computed, onMounted, ref, watch } from 'vue'
-import { RouterLink, RouterView, useRoute } from 'vue-router'
+import { RouterLink, RouterView, useRoute, useRouter } from 'vue-router'
+import { currentUser, defaultRouteFor, isAdmin, logout } from './auth'
 
 const route = useRoute()
+const router = useRouter()
 const navOpen = ref(false)
 const sidebarCollapsed = ref(false)
 const currentTitle = computed(() => (route.meta.title as string) || 'SupplyAgent')
 const SIDEBAR_STORAGE_KEY = 'supply-agent-sidebar-collapsed'
 
-const navItems = [
-  { to: '/', label: '供给总览', icon: '◫' },
-  { to: '/demand-map', label: '全局需求地图', icon: '⌁' },
-  { to: '/video-discovery', label: '需求汇总', icon: '▷' },
-  { to: '/pipeline-runs', label: '定时任务', icon: '◷' },
-  { to: '/demand-process', label: 'Agent 审计', icon: '◎' },
+const adminNavItems = [
+  { to: '/', label: '供给总览', icon: '◫', admin: true },
+  { to: '/demand-map', label: '全局需求地图', icon: '⌁', admin: false },
+  { to: '/video-discovery', label: '需求汇总', icon: '▷', admin: false },
+  { to: '/pipeline-runs', label: '定时任务', icon: '◷', admin: true },
+  { to: '/demand-process', label: 'Agent 审计', icon: '◎', admin: true },
+  { to: '/admin/users', label: '用户管理', icon: '◇', admin: true },
 ]
+const navItems = computed(() =>
+  adminNavItems.filter((item) => isAdmin.value || !item.admin),
+)
+const appHome = computed(() =>
+  currentUser.value ? defaultRouteFor(currentUser.value) : '/login',
+)
+
+async function signOut() {
+  await logout()
+  navOpen.value = false
+  await router.replace('/login')
+}
 
 onMounted(() => {
   sidebarCollapsed.value = window.localStorage.getItem(SIDEBAR_STORAGE_KEY) === 'true'
@@ -26,9 +41,12 @@ watch(sidebarCollapsed, (collapsed) => {
 </script>
 
 <template>
-  <div class="app-shell">
+  <div v-if="route.meta.layout === 'auth'" class="auth-shell">
+    <RouterView />
+  </div>
+  <div v-else class="app-shell">
     <aside class="sidebar" :class="{ open: navOpen, collapsed: sidebarCollapsed }">
-      <RouterLink class="brand" to="/" @click="navOpen = false">
+      <RouterLink class="brand" :to="appHome" @click="navOpen = false">
         <span class="brand-mark">S</span>
         <span class="brand-copy">
           <strong>SupplyAgent</strong>
@@ -89,6 +107,13 @@ watch(sidebarCollapsed, (collapsed) => {
         <div class="topbar-meta">
           <span class="environment"><i /> PRODUCTION</span>
           <span class="clock">Asia / Shanghai</span>
+          <button class="account-button" type="button" title="退出登录" @click="signOut">
+            <span>{{ currentUser?.display_name?.slice(0, 1) || 'U' }}</span>
+            <span class="account-copy">
+              <strong>{{ currentUser?.display_name || currentUser?.username }}</strong>
+              <small>{{ isAdmin ? '管理员 · 点击退出' : '普通用户 · 点击退出' }}</small>
+            </span>
+          </button>
         </div>
       </header>
 
@@ -107,6 +132,10 @@ watch(sidebarCollapsed, (collapsed) => {
 </template>
 
 <style scoped>
+.auth-shell {
+  min-height: 100vh;
+}
+
 .app-shell {
   display: flex;
   height: 100vh;
@@ -337,6 +366,51 @@ watch(sidebarCollapsed, (collapsed) => {
   letter-spacing: 0.08em;
 }
 
+.account-button {
+  display: flex;
+  height: 38px;
+  align-items: center;
+  gap: 8px;
+  padding: 0 10px 0 6px;
+  border: 1px solid #e1e4ea;
+  border-radius: 10px;
+  background: #fff;
+  color: #545c6b;
+  cursor: pointer;
+}
+
+.account-button > span:first-child {
+  display: grid;
+  width: 26px;
+  height: 26px;
+  place-items: center;
+  border-radius: 8px;
+  background: #ececff;
+  color: #5b5cc9;
+  font-size: 11px;
+  font-weight: 800;
+}
+
+.account-copy {
+  text-align: left;
+}
+
+.account-copy strong,
+.account-copy small {
+  display: block;
+}
+
+.account-copy strong {
+  color: #444c5d;
+  font-size: 10px;
+}
+
+.account-copy small {
+  margin-top: 1px;
+  color: #8c93a0;
+  font-size: 8px;
+}
+
 .environment {
   display: inline-flex;
   align-items: center;
@@ -453,6 +527,15 @@ watch(sidebarCollapsed, (collapsed) => {
     display: none;
   }
 
+  .environment,
+  .account-copy {
+    display: none;
+  }
+
+  .account-button {
+    padding: 0 5px;
+  }
+
   .main {
     padding: 14px;
   }

+ 51 - 0
web/src/api/adminUsers.ts

@@ -0,0 +1,51 @@
+import type { AuthUser, UserRole, UserStatus } from '../auth'
+
+async function readJson<T>(response: Response): Promise<T> {
+  if (!response.ok) {
+    const payload = await response.json().catch(() => null) as { detail?: string } | null
+    throw new Error(payload?.detail || `请求失败:${response.status}`)
+  }
+  return response.json() as Promise<T>
+}
+
+export async function fetchUsers(): Promise<AuthUser[]> {
+  const response = await readJson<{ items: AuthUser[] }>(await fetch('/api/admin/users'))
+  return response.items
+}
+
+export async function createUser(payload: {
+  username: string
+  password: string
+  display_name: string
+  role: UserRole
+}): Promise<AuthUser> {
+  return readJson(await fetch('/api/admin/users', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(payload),
+  }))
+}
+
+export async function updateUser(
+  userId: number,
+  payload: {
+    display_name?: string
+    role?: UserRole
+    status?: UserStatus
+    password?: string
+  },
+): Promise<AuthUser> {
+  return readJson(await fetch(`/api/admin/users/${userId}`, {
+    method: 'PATCH',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify(payload),
+  }))
+}
+
+export async function removeUser(userId: number): Promise<void> {
+  const response = await fetch(`/api/admin/users/${userId}`, { method: 'DELETE' })
+  if (!response.ok) {
+    const payload = await response.json().catch(() => null) as { detail?: string } | null
+    throw new Error(payload?.detail || `删除失败:${response.status}`)
+  }
+}

+ 75 - 0
web/src/auth.ts

@@ -0,0 +1,75 @@
+import { computed, ref } from 'vue'
+
+export type UserRole = 'admin' | 'user'
+export type UserStatus = 'active' | 'disabled'
+
+export interface AuthUser {
+  id: number
+  username: string
+  display_name: string
+  role: UserRole
+  status: UserStatus
+  last_login_at: string | null
+  created_at: string | null
+  updated_at: string | null
+}
+
+export const currentUser = ref<AuthUser | null>(null)
+export const authResolved = ref(false)
+export const isAdmin = computed(() => currentUser.value?.role === 'admin')
+
+let pendingLoad: Promise<AuthUser | null> | null = null
+
+export function defaultRouteFor(user: AuthUser): string {
+  return user.role === 'admin' ? '/' : '/demand-map'
+}
+
+export async function loadCurrentUser(force = false): Promise<AuthUser | null> {
+  if (authResolved.value && !force) return currentUser.value
+  if (pendingLoad && !force) return pendingLoad
+
+  pendingLoad = (async () => {
+    try {
+      const response = await fetch('/api/auth/me')
+      if (!response.ok) {
+        currentUser.value = null
+        return null
+      }
+      const payload = await response.json() as { user: AuthUser }
+      currentUser.value = payload.user
+      return payload.user
+    } catch {
+      currentUser.value = null
+      return null
+    } finally {
+      authResolved.value = true
+      pendingLoad = null
+    }
+  })()
+  return pendingLoad
+}
+
+export async function login(username: string, password: string): Promise<AuthUser> {
+  const response = await fetch('/api/auth/login', {
+    method: 'POST',
+    headers: { 'Content-Type': 'application/json' },
+    body: JSON.stringify({ username, password }),
+  })
+  if (!response.ok) {
+    const payload = await response.json().catch(() => null) as { detail?: string } | null
+    throw new Error(payload?.detail || '登录失败,请稍后重试')
+  }
+  const payload = await response.json() as { user: AuthUser }
+  currentUser.value = payload.user
+  authResolved.value = true
+  return payload.user
+}
+
+export async function logout(): Promise<void> {
+  try {
+    await fetch('/api/auth/logout', { method: 'POST' })
+  } finally {
+    currentUser.value = null
+    authResolved.value = true
+  }
+}

+ 52 - 6
web/src/router.ts

@@ -2,48 +2,94 @@ import { createRouter, createWebHistory } from 'vue-router'
 import CategoryTreeView from './views/CategoryTreeView.vue'
 import DemandProcessView from './views/DemandProcessView.vue'
 import GlobalDemandMapView from './views/GlobalDemandMapView.vue'
+import ForbiddenView from './views/ForbiddenView.vue'
+import LoginView from './views/LoginView.vue'
 import OverviewView from './views/OverviewView.vue'
 import PipelineRunsView from './views/PipelineRunsView.vue'
+import UserManagementView from './views/UserManagementView.vue'
 import VideoDiscoveryView from './views/VideoDiscoveryView.vue'
+import { defaultRouteFor, loadCurrentUser } from './auth'
 
 export const router = createRouter({
   history: createWebHistory(),
   routes: [
+    {
+      path: '/login',
+      name: 'login',
+      component: LoginView,
+      meta: { title: '登录', public: true, layout: 'auth' },
+    },
     {
       path: '/',
       name: 'overview',
       component: OverviewView,
-      meta: { title: '供给智能总览' },
+      meta: { title: '供给智能总览', admin: true },
     },
     {
       path: '/demand-map',
       name: 'demand-map',
       component: GlobalDemandMapView,
-      meta: { title: '平台全局需求地图' },
+      meta: { title: '平台全局需求地图', userAllowed: true },
     },
     {
       path: '/demand-tree',
       name: 'category-tree',
       component: CategoryTreeView,
-      meta: { title: '全局分类树' },
+      meta: { title: '全局分类树', admin: true },
     },
     {
       path: '/demand-process',
       name: 'demand-process',
       component: DemandProcessView,
-      meta: { title: '需求归类过程' },
+      meta: { title: '需求归类过程', admin: true },
     },
     {
       path: '/video-discovery',
       name: 'video-discovery',
       component: VideoDiscoveryView,
-      meta: { title: '需求汇总' },
+      meta: { title: '需求汇总', userAllowed: true },
     },
     {
       path: '/pipeline-runs',
       name: 'pipeline-runs',
       component: PipelineRunsView,
-      meta: { title: '定时任务运行中心' },
+      meta: { title: '定时任务运行中心', admin: true },
+    },
+    {
+      path: '/admin/users',
+      name: 'admin-users',
+      component: UserManagementView,
+      meta: { title: '用户管理', admin: true },
+    },
+    {
+      path: '/forbidden',
+      name: 'forbidden',
+      component: ForbiddenView,
+      meta: { title: '无权访问', userAllowed: true },
+    },
+    {
+      path: '/:pathMatch(.*)*',
+      redirect: '/',
     },
   ],
 })
+
+router.beforeEach(async (to) => {
+  if (to.meta.public) {
+    const user = await loadCurrentUser()
+    if (user && to.name === 'login') return defaultRouteFor(user)
+    return true
+  }
+
+  const user = await loadCurrentUser()
+  if (!user) {
+    return {
+      name: 'login',
+      query: { redirect: to.fullPath },
+    }
+  }
+  if (user.role === 'admin') return true
+  if (to.meta.userAllowed) return true
+  if (to.name === 'forbidden') return true
+  return { name: 'forbidden' }
+})

+ 4 - 0
web/src/types/videoDiscovery.ts

@@ -27,12 +27,16 @@ export interface VideoDiscoveryPoint {
 export interface VideoDiscoveryVideo {
   vid: string
   title: string | null
+  url1: string | null
+  url2: string | null
+  video_detail_url: string | null
   point_count: number
   points: VideoDiscoveryPoint[]
 }
 
 export interface VideoDiscoveryDemandDetail extends VideoDiscoveryDemand {
   point_count: number
+  video_source: 'pool' | 'expansion'
   videos: VideoDiscoveryVideo[]
 }
 

+ 21 - 0
web/src/utils/videoLinks.ts

@@ -0,0 +1,21 @@
+export const VIDEO_DETAIL_BASE_URL = 'https://admin.piaoquantv.com/cms/post-detail'
+
+export interface VideoUrlFields {
+  url1?: string | null
+  url2?: string | null
+  video_detail_url?: string | null
+}
+
+/** 解构结果链接:优先 url2,为空则用 url1。 */
+export function pickDecodeUrl(video: VideoUrlFields): string | null {
+  const url2 = video.url2?.trim()
+  if (url2) return url2
+  const url1 = video.url1?.trim()
+  return url1 || null
+}
+
+export function videoDetailUrl(vid: string, fallbackUrl?: string | null): string {
+  const custom = fallbackUrl?.trim()
+  if (custom) return custom
+  return `${VIDEO_DETAIL_BASE_URL}/${vid}/detail`
+}

+ 24 - 0
web/src/views/ForbiddenView.vue

@@ -0,0 +1,24 @@
+<script setup lang="ts">
+import { computed } from 'vue'
+import { RouterLink } from 'vue-router'
+import { currentUser, defaultRouteFor } from '../auth'
+
+const home = computed(() => currentUser.value ? defaultRouteFor(currentUser.value) : '/login')
+</script>
+
+<template>
+  <section class="forbidden">
+    <span>403</span>
+    <h1>无权访问此页面</h1>
+    <p>当前账号没有对应权限。如需访问,请联系系统管理员。</p>
+    <RouterLink :to="home">返回可访问页面</RouterLink>
+  </section>
+</template>
+
+<style scoped>
+.forbidden { display: grid; min-height: calc(100vh - 130px); place-content: center; text-align: center; }
+.forbidden > span { color: #6869d2; font-size: 13px; font-weight: 800; letter-spacing: .2em; }
+h1 { margin: 12px 0 8px; color: #262d3d; font-size: 30px; }
+p { margin: 0 0 24px; color: #7c8493; font-size: 13px; }
+a { justify-self: center; padding: 11px 18px; border-radius: 9px; background: #5b5cc9; color: #fff; font-size: 12px; font-weight: 700; text-decoration: none; }
+</style>

+ 173 - 0
web/src/views/LoginView.vue

@@ -0,0 +1,173 @@
+<script setup lang="ts">
+import { ref } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
+import { defaultRouteFor, login } from '../auth'
+
+const route = useRoute()
+const router = useRouter()
+const username = ref('')
+const password = ref('')
+const busy = ref(false)
+const error = ref('')
+
+async function submit() {
+  if (busy.value) return
+  busy.value = true
+  error.value = ''
+  try {
+    const user = await login(username.value, password.value)
+    const requested = typeof route.query.redirect === 'string' && route.query.redirect.startsWith('/')
+      ? route.query.redirect
+      : defaultRouteFor(user)
+    const normalUserCanOpen = requested === '/demand-map' || requested === '/video-discovery'
+    await router.replace(user.role === 'admin' || normalUserCanOpen ? requested : defaultRouteFor(user))
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : '登录失败,请稍后重试'
+  } finally {
+    busy.value = false
+  }
+}
+</script>
+
+<template>
+  <main class="login-page">
+    <section class="login-brand">
+      <div class="brand-lockup">
+        <span>S</span>
+        <div>
+          <strong>SupplyAgent</strong>
+          <small>CONTENT INTELLIGENCE</small>
+        </div>
+      </div>
+      <div class="brand-message">
+        <span>AI-NATIVE CONTENT SUPPLY</span>
+        <h1>让内容需求转化为<br />可执行的供给方向</h1>
+        <p>连接需求信号、智能决策与内容生产,形成清晰、可靠、可追溯的供给链路。</p>
+      </div>
+      <div class="brand-grid" aria-hidden="true">
+        <i v-for="index in 24" :key="index" />
+      </div>
+    </section>
+
+    <section class="login-panel">
+      <form class="login-card" @submit.prevent="submit">
+        <header>
+          <span>WELCOME BACK</span>
+          <h2>登录 SupplyAgent</h2>
+          <p>使用管理员分配的系统账号继续。</p>
+        </header>
+
+        <label>
+          <span>用户名</span>
+          <input
+            v-model.trim="username"
+            name="username"
+            autocomplete="username"
+            minlength="3"
+            maxlength="64"
+            placeholder="请输入用户名"
+            required
+            autofocus
+          />
+        </label>
+
+        <label>
+          <span>密码</span>
+          <input
+            v-model="password"
+            type="password"
+            name="password"
+            autocomplete="current-password"
+            maxlength="128"
+            placeholder="请输入密码"
+            required
+          />
+        </label>
+
+        <p v-if="error" class="login-error" role="alert">{{ error }}</p>
+
+        <button type="submit" :disabled="busy">
+          <span v-if="busy" class="spinner" />
+          {{ busy ? '正在登录…' : '登录系统' }}
+        </button>
+
+        <footer>登录遇到问题,请联系系统管理员</footer>
+      </form>
+    </section>
+  </main>
+</template>
+
+<style scoped>
+.login-page {
+  display: grid;
+  min-height: 100vh;
+  grid-template-columns: minmax(420px, 1.08fr) minmax(420px, .92fr);
+  background: #f7f8fb;
+}
+.login-brand {
+  position: relative;
+  display: flex;
+  min-height: 100vh;
+  padding: 46px 58px;
+  flex-direction: column;
+  overflow: hidden;
+  background:
+    radial-gradient(circle at 74% 24%, rgba(117, 117, 224, .34), transparent 27%),
+    linear-gradient(145deg, #22234f 0%, #333476 48%, #595bc2 100%);
+  color: #fff;
+}
+.brand-lockup { display: flex; align-items: center; gap: 13px; position: relative; z-index: 2; }
+.brand-lockup > span {
+  display: grid; width: 42px; height: 42px; place-items: center; border-radius: 13px;
+  background: rgba(255,255,255,.16); border: 1px solid rgba(255,255,255,.25);
+  font-size: 20px; font-weight: 800; box-shadow: 0 16px 36px rgba(14,15,51,.24);
+}
+.brand-lockup strong, .brand-lockup small { display: block; }
+.brand-lockup strong { font-size: 17px; }
+.brand-lockup small { margin-top: 3px; color: #c4c5f0; font-size: 8px; letter-spacing: .18em; }
+.brand-message { position: relative; z-index: 2; margin: auto 0; max-width: 620px; }
+.brand-message > span { color: #bfc0f2; font-size: 11px; font-weight: 800; letter-spacing: .18em; }
+.brand-message h1 { margin: 20px 0; font-size: clamp(38px, 4vw, 62px); line-height: 1.16; letter-spacing: -.045em; }
+.brand-message p { max-width: 520px; margin: 0; color: #d7d8f4; font-size: 15px; line-height: 1.9; }
+.brand-grid {
+  position: absolute; right: -75px; bottom: -80px; display: grid; width: 430px; height: 360px;
+  grid-template-columns: repeat(6, 1fr); gap: 12px; transform: rotate(-8deg); opacity: .34;
+}
+.brand-grid i { border: 1px solid rgba(255,255,255,.28); border-radius: 14px; }
+.login-panel { display: grid; min-height: 100vh; padding: 48px; place-items: center; }
+.login-card { width: min(100%, 400px); }
+.login-card header { margin-bottom: 38px; }
+.login-card header > span { color: #6768ce; font-size: 10px; font-weight: 800; letter-spacing: .18em; }
+.login-card h2 { margin: 10px 0 8px; color: #252b3b; font-size: 30px; letter-spacing: -.035em; }
+.login-card header p { margin: 0; color: #838a98; font-size: 13px; }
+.login-card label { display: grid; gap: 8px; margin-top: 20px; }
+.login-card label span { color: #4e5667; font-size: 12px; font-weight: 700; }
+.login-card input {
+  height: 48px; padding: 0 15px; border: 1px solid #d8dce5; border-radius: 10px;
+  outline: none; background: #fff; color: #252b3b; font: inherit; transition: .16s;
+}
+.login-card input:focus { border-color: #6a6bd5; box-shadow: 0 0 0 3px rgba(106,107,213,.12); }
+.login-card button {
+  display: flex; width: 100%; height: 48px; margin-top: 24px; align-items: center;
+  justify-content: center; gap: 9px; border: 0; border-radius: 10px; background: #5b5cc9;
+  color: #fff; font-size: 13px; font-weight: 700; cursor: pointer;
+  box-shadow: 0 12px 28px rgba(74,75,175,.24);
+}
+.login-card button:hover:not(:disabled) { background: #5051bd; }
+.login-card button:disabled { cursor: wait; opacity: .72; }
+.login-error {
+  margin: 16px 0 -6px; padding: 10px 12px; border: 1px solid #f2c7ce; border-radius: 8px;
+  background: #fff4f5; color: #b4233b; font-size: 12px;
+}
+.login-card footer { margin-top: 22px; color: #9aa0ac; font-size: 11px; text-align: center; }
+.spinner { width: 15px; height: 15px; border: 2px solid rgba(255,255,255,.35); border-top-color: #fff; border-radius: 50%; animation: spin .7s linear infinite; }
+@keyframes spin { to { transform: rotate(360deg); } }
+@media (max-width: 860px) {
+  .login-page { display: block; }
+  .login-brand { min-height: 240px; padding: 28px; }
+  .brand-message { margin: auto 0 10px; }
+  .brand-message > span, .brand-message p { display: none; }
+  .brand-message h1 { margin: 30px 0 0; font-size: 32px; }
+  .login-panel { min-height: calc(100vh - 240px); padding: 36px 24px; }
+}
+</style>

+ 220 - 0
web/src/views/UserManagementView.vue

@@ -0,0 +1,220 @@
+<script setup lang="ts">
+import { onMounted, reactive, ref } from 'vue'
+import type { AuthUser, UserRole, UserStatus } from '../auth'
+import { currentUser } from '../auth'
+import { createUser, fetchUsers, removeUser, updateUser } from '../api/adminUsers'
+
+const users = ref<AuthUser[]>([])
+const loading = ref(true)
+const busyId = ref<number | null>(null)
+const error = ref('')
+const notice = ref('')
+const resetUserId = ref<number | null>(null)
+const resetPassword = ref('')
+const form = reactive({
+  username: '',
+  display_name: '',
+  password: '',
+  role: 'user' as UserRole,
+})
+
+async function load() {
+  loading.value = true
+  error.value = ''
+  try {
+    users.value = await fetchUsers()
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : String(cause)
+  } finally {
+    loading.value = false
+  }
+}
+
+async function submit() {
+  error.value = ''
+  notice.value = ''
+  try {
+    await createUser(form)
+    notice.value = `账号 ${form.username} 已创建`
+    Object.assign(form, { username: '', display_name: '', password: '', role: 'user' })
+    await load()
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : String(cause)
+  }
+}
+
+async function changeUser(user: AuthUser, payload: { role?: UserRole; status?: UserStatus }) {
+  busyId.value = user.id
+  error.value = ''
+  try {
+    await updateUser(user.id, payload)
+    await load()
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : String(cause)
+  } finally {
+    busyId.value = null
+  }
+}
+
+async function savePassword(user: AuthUser) {
+  busyId.value = user.id
+  error.value = ''
+  try {
+    await updateUser(user.id, { password: resetPassword.value })
+    resetUserId.value = null
+    resetPassword.value = ''
+    notice.value = `${user.display_name} 的密码已重置`
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : String(cause)
+  } finally {
+    busyId.value = null
+  }
+}
+
+async function deleteAccount(user: AuthUser) {
+  if (!window.confirm(`确认删除账号“${user.username}”吗?此操作不可恢复。`)) return
+  busyId.value = user.id
+  error.value = ''
+  try {
+    await removeUser(user.id)
+    notice.value = `账号 ${user.username} 已删除`
+    await load()
+  } catch (cause) {
+    error.value = cause instanceof Error ? cause.message : String(cause)
+  } finally {
+    busyId.value = null
+  }
+}
+
+function readableTime(value: string | null): string {
+  if (!value) return '从未登录'
+  return value.replace('T', ' ').slice(0, 16)
+}
+
+onMounted(load)
+</script>
+
+<template>
+  <main class="users-page">
+    <header>
+      <div>
+        <span>ACCESS CONTROL</span>
+        <h1>用户管理</h1>
+        <p>创建本地账号,并在管理员和普通用户两个固定角色间分配权限。</p>
+      </div>
+      <div class="role-note">
+        <strong>{{ users.length }}</strong>
+        <span>系统账号</span>
+      </div>
+    </header>
+
+    <p v-if="error" class="message error" role="alert">{{ error }}</p>
+    <p v-if="notice" class="message success">{{ notice }}</p>
+
+    <section class="create-card">
+      <div>
+        <span>NEW ACCOUNT</span>
+        <h2>创建用户</h2>
+      </div>
+      <form @submit.prevent="submit">
+        <label><span>用户名</span><input v-model.trim="form.username" minlength="3" maxlength="64" pattern="[A-Za-z0-9_.-]+" required /></label>
+        <label><span>显示名称</span><input v-model.trim="form.display_name" maxlength="128" required /></label>
+        <label><span>初始密码</span><input v-model="form.password" type="password" minlength="8" maxlength="128" autocomplete="new-password" required /></label>
+        <label>
+          <span>角色</span>
+          <select v-model="form.role">
+            <option value="user">普通用户</option>
+            <option value="admin">管理员</option>
+          </select>
+        </label>
+        <button>创建账号</button>
+      </form>
+    </section>
+
+    <section class="list-card">
+      <div class="list-head">
+        <div><span>ACCOUNT DIRECTORY</span><h2>全部用户</h2></div>
+        <button type="button" @click="load">刷新</button>
+      </div>
+      <p v-if="loading" class="empty">正在加载用户…</p>
+      <div v-else class="user-list">
+        <article v-for="user in users" :key="user.id" class="user-row">
+          <span class="avatar">{{ user.display_name.slice(0, 1).toUpperCase() }}</span>
+          <div class="identity">
+            <strong>{{ user.display_name }}</strong>
+            <small>@{{ user.username }} · {{ readableTime(user.last_login_at) }}</small>
+          </div>
+          <select
+            :value="user.role"
+            :disabled="busyId === user.id || currentUser?.id === user.id"
+            aria-label="用户角色"
+            @change="changeUser(user, { role: ($event.target as HTMLSelectElement).value as UserRole })"
+          >
+            <option value="user">普通用户</option>
+            <option value="admin">管理员</option>
+          </select>
+          <button
+            class="status"
+            :class="user.status"
+            :disabled="busyId === user.id || currentUser?.id === user.id"
+            @click="changeUser(user, { status: user.status === 'active' ? 'disabled' : 'active' })"
+          >
+            {{ user.status === 'active' ? '已启用' : '已禁用' }}
+          </button>
+          <button
+            class="link"
+            :disabled="currentUser?.id === user.id"
+            @click="resetUserId = resetUserId === user.id ? null : user.id"
+          >
+            重置密码
+          </button>
+          <button class="link danger" :disabled="currentUser?.id === user.id" @click="deleteAccount(user)">删除</button>
+          <form v-if="resetUserId === user.id" class="reset-form" @submit.prevent="savePassword(user)">
+            <input v-model="resetPassword" type="password" minlength="8" maxlength="128" placeholder="输入至少 8 位的新密码" autocomplete="new-password" required />
+            <button :disabled="busyId === user.id">保存新密码</button>
+          </form>
+        </article>
+        <p v-if="!users.length" class="empty">暂无用户</p>
+      </div>
+    </section>
+  </main>
+</template>
+
+<style scoped>
+.users-page { max-width: 1180px; margin: 0 auto; padding: 12px 0 60px; color: #303747; }
+header { display: flex; align-items: flex-start; justify-content: space-between; margin-bottom: 24px; }
+header > div > span, .create-card > div > span, .list-head span { color: #6869cf; font-size: 10px; font-weight: 800; letter-spacing: .14em; }
+h1 { margin: 7px 0; font-size: 30px; letter-spacing: -.035em; }
+header p { margin: 0; color: #7b8392; font-size: 13px; }
+.role-note { display: flex; min-width: 100px; padding: 13px 16px; flex-direction: column; border: 1px solid #e0e3e9; border-radius: 12px; background: #fff; }
+.role-note strong { font-size: 22px; }.role-note span { color: #8a919d; font-size: 10px; }
+.message { padding: 11px 14px; border-radius: 9px; font-size: 12px; }
+.message.error { border: 1px solid #f0c4cb; background: #fff3f4; color: #b4233b; }
+.message.success { border: 1px solid #bce5d3; background: #effbf6; color: #147653; }
+.create-card, .list-card { border: 1px solid #e0e3e9; border-radius: 14px; background: #fff; box-shadow: 0 10px 30px rgba(35,42,62,.04); }
+.create-card { display: grid; grid-template-columns: 150px 1fr; gap: 26px; padding: 20px; }
+h2 { margin: 5px 0 0; font-size: 17px; }
+.create-card form { display: grid; grid-template-columns: 1fr 1fr 1fr 150px auto; gap: 10px; align-items: end; }
+label { display: grid; gap: 6px; } label span { color: #667080; font-size: 10px; font-weight: 700; }
+input, select { height: 39px; padding: 0 11px; border: 1px solid #d8dce5; border-radius: 8px; background: #fff; color: #384050; font: inherit; font-size: 12px; }
+button { height: 39px; padding: 0 15px; border: 0; border-radius: 8px; background: #5d5ec9; color: #fff; font-size: 11px; font-weight: 700; cursor: pointer; }
+button:disabled { cursor: not-allowed; opacity: .45; }
+.list-card { margin-top: 18px; padding: 20px; }
+.list-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 14px; }
+.list-head button { height: 32px; background: #f0f1f6; color: #596171; }
+.user-list { display: grid; gap: 8px; }
+.user-row { display: grid; grid-template-columns: 38px minmax(180px, 1fr) 125px 82px auto auto; gap: 10px; align-items: center; padding: 12px; border: 1px solid #e8eaf0; border-radius: 10px; }
+.avatar { display: grid; width: 36px; height: 36px; place-items: center; border-radius: 10px; background: #ececff; color: #5b5cc7; font-size: 13px; font-weight: 800; }
+.identity strong, .identity small { display: block; }.identity strong { font-size: 12px; }.identity small { margin-top: 4px; color: #8a919d; font-size: 10px; }
+.status { height: 32px; padding: 0 10px; }.status.active { background: #eaf8f2; color: #18815e; }.status.disabled { background: #f3f4f6; color: #737b88; }
+.link { height: 32px; padding: 0 5px; background: transparent; color: #5d5ec9; }.link.danger { color: #bd4051; }
+.reset-form { grid-column: 2 / -1; display: flex; gap: 8px; padding-top: 4px; }.reset-form input { min-width: 260px; }.reset-form button { height: 39px; }
+.empty { padding: 30px; color: #8a919d; text-align: center; font-size: 12px; }
+@media (max-width: 980px) {
+  .create-card { grid-template-columns: 1fr; }
+  .create-card form { grid-template-columns: 1fr 1fr; }
+  .user-row { grid-template-columns: 38px 1fr 120px; }
+  .user-row > button, .user-row > select { grid-column: auto; }
+  .reset-form { grid-column: 1 / -1; }
+}
+</style>

+ 127 - 17
web/src/views/VideoDiscoveryView.vue

@@ -5,6 +5,7 @@ import {
   fetchVideoDiscoveryDemands,
 } from '../api/videoDiscovery'
 import { formatPosteriorDiff } from '../types/category'
+import { pickDecodeUrl, videoDetailUrl } from '../utils/videoLinks'
 import type {
   VideoDiscoveryDemand,
   VideoDiscoveryDemandDetail,
@@ -16,6 +17,7 @@ type SupplyFilter = 'all' | 'with-video' | 'posterior' | 'no-video'
 type PointFilter = 'all' | 'purpose' | 'key' | 'inspiration'
 
 const GRADE_ORDER = ['S', 'A', 'B', 'C', 'D'] as const
+const SOURCE_VIDEO_GRADES = new Set(['B', 'C', 'D'])
 const POINT_META = {
   purpose: { label: '目的点', short: '目的', symbol: '◎' },
   key: { label: '关键点', short: '关键', symbol: '◆' },
@@ -92,6 +94,14 @@ const selectedDemand = computed(
   () => demands.value.find((item) => item.id === selectedDemandId.value) ?? null,
 )
 
+const isSourceOnlyGrade = computed(() =>
+  SOURCE_VIDEO_GRADES.has((selectedDemand.value?.grade ?? '').toUpperCase()),
+)
+
+const showHitContentPanel = computed(
+  () => !isSourceOnlyGrade.value && (selectedVideo.value?.point_count ?? 0) > 0,
+)
+
 const visibleDemands = computed(() =>
   filteredDemands.value.slice(0, visibleDemandLimit.value),
 )
@@ -170,6 +180,19 @@ const progressWidth = computed(() => {
   return `${Math.min(100, count * 10)}%`
 })
 
+const emptyVideoTitle = computed(() =>
+  isSourceOnlyGrade.value ? '这个需求暂无关联源视频' : '这个需求暂无命中视频',
+)
+
+const emptyVideoHint = computed(() => {
+  if ((detail.value?.videos.length ?? 0) > 0) {
+    return '清空搜索或切换点位类型'
+  }
+  return isSourceOnlyGrade.value
+    ? 'demand_grade.video_list 中暂无 vid'
+    : 'demand_video_expansion 中暂无命中记录'
+})
+
 onMounted(async () => {
   try {
     const response = await fetchVideoDiscoveryDemands()
@@ -276,6 +299,14 @@ async function copyText(text: string, key: string) {
     copiedKey.value = null
   }
 }
+
+function decodeUrlFor(video: VideoDiscoveryVideo): string | null {
+  return pickDecodeUrl(video)
+}
+
+function detailUrlFor(video: VideoDiscoveryVideo): string {
+  return videoDetailUrl(video.vid, video.video_detail_url)
+}
 </script>
 
 <template>
@@ -403,7 +434,13 @@ async function copyText(text: string, key: string) {
               <span>需求分 {{ formatScore(item.score) }}</span>
               <span class="dot-sep">·</span>
               <span :class="{ muted: item.video_count === 0 }">
-                {{ item.video_count ? `${item.video_count} 条命中视频` : '暂无命中' }}
+                {{
+                  item.video_count
+                    ? SOURCE_VIDEO_GRADES.has(item.grade)
+                      ? `${item.video_count} 条源视频`
+                      : `${item.video_count} 条命中视频`
+                    : '暂无视频'
+                }}
               </span>
               <span class="card-arrow">›</span>
             </div>
@@ -507,7 +544,7 @@ async function copyText(text: string, key: string) {
                 aria-label="搜索候选视频"
               />
             </label>
-            <div v-if="detail.point_count > 0" class="point-filter">
+            <div v-if="detail.point_count > 0 && !isSourceOnlyGrade" class="point-filter">
               <button
                 v-for="filter in availablePointFilters"
                 :key="filter"
@@ -534,10 +571,31 @@ async function copyText(text: string, key: string) {
               <div class="video-main">
                 <div class="video-title-row">
                   <strong>{{ video.title || `视频 ${video.vid}` }}</strong>
-                  <span>{{ video.point_count }} 个内容点</span>
+                  <span v-if="!isSourceOnlyGrade">{{ video.point_count }} 个内容点</span>
                 </div>
                 <code>{{ video.vid }}</code>
-                <div class="video-point-badges">
+                <div class="video-link-row">
+                  <a
+                    v-if="decodeUrlFor(video)"
+                    :href="decodeUrlFor(video)!"
+                    class="video-link"
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    @click.stop
+                  >
+                    解构结果
+                  </a>
+                  <a
+                    :href="detailUrlFor(video)"
+                    class="video-link"
+                    target="_blank"
+                    rel="noopener noreferrer"
+                    @click.stop
+                  >
+                    视频详情
+                  </a>
+                </div>
+                <div v-if="!isSourceOnlyGrade" class="video-point-badges">
                   <span v-if="videoPointCount(video, 'purpose')" class="point-badge purpose">
                     ◎ 目的 {{ videoPointCount(video, 'purpose') }}
                   </span>
@@ -557,14 +615,8 @@ async function copyText(text: string, key: string) {
           </div>
           <div v-else class="empty-state">
             <span class="empty-mark">○</span>
-            <strong>{{ detail.videos.length ? '没有匹配的视频' : '这个需求暂无命中视频' }}</strong>
-            <span>
-              {{
-                detail.videos.length
-                  ? '清空搜索或切换点位类型'
-                  : 'demand_video_expansion 中暂无命中记录'
-              }}
-            </span>
+            <strong>{{ detail.videos.length ? '没有匹配的视频' : emptyVideoTitle }}</strong>
+            <span>{{ emptyVideoHint }}</span>
           </div>
         </template>
       </section>
@@ -573,9 +625,11 @@ async function copyText(text: string, key: string) {
         <div class="panel-head">
           <div>
             <span class="step-index">03</span>
-            <h2>命中视频内容</h2>
+            <h2>{{ isSourceOnlyGrade ? '视频信息' : '命中视频内容' }}</h2>
           </div>
-          <span v-if="selectedVideo" class="source-label">EXPANSION</span>
+          <span v-if="selectedVideo" class="source-label">
+            {{ isSourceOnlyGrade ? 'POOL' : 'EXPANSION' }}
+          </span>
         </div>
 
         <template v-if="selectedVideo">
@@ -591,9 +645,28 @@ async function copyText(text: string, key: string) {
                 {{ copiedKey === `video-${selectedVideo.vid}` ? '已复制' : '复制 ID' }}
               </button>
             </div>
+            <div class="video-link-row panel-links">
+              <a
+                v-if="decodeUrlFor(selectedVideo)"
+                :href="decodeUrlFor(selectedVideo)!"
+                class="video-link"
+                target="_blank"
+                rel="noopener noreferrer"
+              >
+                解构结果
+              </a>
+              <a
+                :href="detailUrlFor(selectedVideo)"
+                class="video-link"
+                target="_blank"
+                rel="noopener noreferrer"
+              >
+                视频详情
+              </a>
+            </div>
           </div>
 
-          <div class="coverage-card">
+          <div v-if="showHitContentPanel" class="coverage-card">
             <div class="coverage-head">
               <span>点位覆盖</span>
               <strong>{{ selectedVideo.point_count }} 个</strong>
@@ -604,7 +677,7 @@ async function copyText(text: string, key: string) {
             <p>结合目的、关键和灵感点判断该视频能否承接当前需求。</p>
           </div>
 
-          <div v-if="selectedPointGroups.length" class="point-groups">
+          <div v-if="showHitContentPanel && selectedPointGroups.length" class="point-groups">
             <section
               v-for="group in selectedPointGroups"
               :key="group.type"
@@ -653,6 +726,11 @@ async function copyText(text: string, key: string) {
               </article>
             </section>
           </div>
+          <div v-else-if="isSourceOnlyGrade" class="empty-state evidence-empty">
+            <span class="empty-mark">◇</span>
+            <strong>暂无命中视频内容</strong>
+            <span>B/C/D 等级展示需求池关联源视频,可通过上方链接查看解构结果与视频详情</span>
+          </div>
           <div v-else class="empty-state evidence-empty">
             <span class="empty-mark">◇</span>
             <strong>暂无内容点位</strong>
@@ -1364,7 +1442,7 @@ async function copyText(text: string, key: string) {
 .video-main code,
 .video-id-row code {
   display: block;
-  margin: 3px 0 7px;
+  margin: 3px 0 5px;
   overflow: hidden;
   color: #919a93;
   font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
@@ -1373,6 +1451,38 @@ async function copyText(text: string, key: string) {
   white-space: nowrap;
 }
 
+.video-link-row {
+  display: flex;
+  flex-wrap: wrap;
+  gap: 6px;
+  margin-bottom: 6px;
+}
+
+.video-link-row.panel-links {
+  margin-top: 10px;
+  margin-bottom: 0;
+}
+
+.video-link {
+  display: inline-flex;
+  align-items: center;
+  padding: 2px 7px;
+  border: 1px solid #cfd9d2;
+  border-radius: 5px;
+  background: #fff;
+  color: #3a6a50;
+  font-size: 9px;
+  font-weight: 700;
+  text-decoration: none;
+  transition: 0.15s ease;
+}
+
+.video-link:hover {
+  border-color: #8eb19a;
+  background: #f3f8f4;
+  color: #24553a;
+}
+
 .video-point-badges {
   display: flex;
   flex-wrap: wrap;