code4eat пре 2 месеци
комит
4055af0d03
100 измењених фајлова са 2368 додато и 0 уклоњено
  1. 6 0
      .dockerignore
  2. 8 0
      .env
  3. 1 0
      .env.production
  4. 24 0
      .gitignore
  5. 110 0
      README.md
  6. 5 0
      backend/.dockerignore
  7. 11 0
      backend/Dockerfile
  8. 26 0
      backend/README.md
  9. 1 0
      backend/__init__.py
  10. BIN
      backend/__pycache__/__init__.cpython-313.pyc
  11. 1 0
      backend/app/__init__.py
  12. BIN
      backend/app/__pycache__/__init__.cpython-313.pyc
  13. BIN
      backend/app/__pycache__/main.cpython-313.pyc
  14. 1 0
      backend/app/core/__init__.py
  15. BIN
      backend/app/core/__pycache__/__init__.cpython-313.pyc
  16. BIN
      backend/app/core/__pycache__/config.cpython-313.pyc
  17. BIN
      backend/app/core/__pycache__/dependencies.cpython-313.pyc
  18. BIN
      backend/app/core/__pycache__/response.cpython-313.pyc
  19. BIN
      backend/app/core/__pycache__/security.cpython-313.pyc
  20. 24 0
      backend/app/core/config.py
  21. 76 0
      backend/app/core/dependencies.py
  22. 21 0
      backend/app/core/response.py
  23. 27 0
      backend/app/core/security.py
  24. 1 0
      backend/app/db/__init__.py
  25. BIN
      backend/app/db/__pycache__/__init__.cpython-313.pyc
  26. BIN
      backend/app/db/__pycache__/base.cpython-313.pyc
  27. BIN
      backend/app/db/__pycache__/session.cpython-313.pyc
  28. 5 0
      backend/app/db/base.py
  29. 11 0
      backend/app/db/session.py
  30. 180 0
      backend/app/main.py
  31. 25 0
      backend/app/models/__init__.py
  32. BIN
      backend/app/models/__pycache__/__init__.cpython-313.pyc
  33. BIN
      backend/app/models/__pycache__/adjust_log.cpython-313.pyc
  34. BIN
      backend/app/models/__pycache__/audit_log.cpython-313.pyc
  35. BIN
      backend/app/models/__pycache__/campus.cpython-313.pyc
  36. BIN
      backend/app/models/__pycache__/department.cpython-313.pyc
  37. BIN
      backend/app/models/__pycache__/duty.cpython-313.pyc
  38. BIN
      backend/app/models/__pycache__/role.cpython-313.pyc
  39. BIN
      backend/app/models/__pycache__/schedule.cpython-313.pyc
  40. BIN
      backend/app/models/__pycache__/shift.cpython-313.pyc
  41. BIN
      backend/app/models/__pycache__/sync_state.cpython-313.pyc
  42. BIN
      backend/app/models/__pycache__/user.cpython-313.pyc
  43. 16 0
      backend/app/models/adjust_log.py
  44. 18 0
      backend/app/models/audit_log.py
  45. 14 0
      backend/app/models/campus.py
  46. 15 0
      backend/app/models/department.py
  47. 18 0
      backend/app/models/duty.py
  48. 40 0
      backend/app/models/role.py
  49. 26 0
      backend/app/models/schedule.py
  50. 17 0
      backend/app/models/shift.py
  51. 13 0
      backend/app/models/sync_state.py
  52. 33 0
      backend/app/models/user.py
  53. 16 0
      backend/app/routers/__init__.py
  54. BIN
      backend/app/routers/__pycache__/__init__.cpython-313.pyc
  55. BIN
      backend/app/routers/__pycache__/adjust_logs.cpython-313.pyc
  56. BIN
      backend/app/routers/__pycache__/auth.cpython-313.pyc
  57. BIN
      backend/app/routers/__pycache__/campuses.cpython-313.pyc
  58. BIN
      backend/app/routers/__pycache__/departments.cpython-313.pyc
  59. BIN
      backend/app/routers/__pycache__/duty.cpython-313.pyc
  60. BIN
      backend/app/routers/__pycache__/roles.cpython-313.pyc
  61. BIN
      backend/app/routers/__pycache__/schedule.cpython-313.pyc
  62. BIN
      backend/app/routers/__pycache__/shifts.cpython-313.pyc
  63. BIN
      backend/app/routers/__pycache__/statistics.cpython-313.pyc
  64. BIN
      backend/app/routers/__pycache__/sync.cpython-313.pyc
  65. BIN
      backend/app/routers/__pycache__/users.cpython-313.pyc
  66. BIN
      backend/app/routers/__pycache__/ws.cpython-313.pyc
  67. 54 0
      backend/app/routers/adjust_logs.py
  68. 232 0
      backend/app/routers/auth.py
  69. 69 0
      backend/app/routers/campuses.py
  70. 65 0
      backend/app/routers/departments.py
  71. 104 0
      backend/app/routers/duty.py
  72. 48 0
      backend/app/routers/roles.py
  73. 265 0
      backend/app/routers/schedule.py
  74. 122 0
      backend/app/routers/shifts.py
  75. 70 0
      backend/app/routers/statistics.py
  76. 23 0
      backend/app/routers/sync.py
  77. 166 0
      backend/app/routers/users.py
  78. 44 0
      backend/app/routers/ws.py
  79. 40 0
      backend/app/schemas/__init__.py
  80. BIN
      backend/app/schemas/__pycache__/__init__.cpython-313.pyc
  81. BIN
      backend/app/schemas/__pycache__/adjust_log.cpython-313.pyc
  82. BIN
      backend/app/schemas/__pycache__/auth.cpython-313.pyc
  83. BIN
      backend/app/schemas/__pycache__/campus.cpython-313.pyc
  84. BIN
      backend/app/schemas/__pycache__/department.cpython-313.pyc
  85. BIN
      backend/app/schemas/__pycache__/duty.cpython-313.pyc
  86. BIN
      backend/app/schemas/__pycache__/pagination.cpython-313.pyc
  87. BIN
      backend/app/schemas/__pycache__/role.cpython-313.pyc
  88. BIN
      backend/app/schemas/__pycache__/schedule.cpython-313.pyc
  89. BIN
      backend/app/schemas/__pycache__/shift.cpython-313.pyc
  90. BIN
      backend/app/schemas/__pycache__/user.cpython-313.pyc
  91. 17 0
      backend/app/schemas/adjust_log.py
  92. 26 0
      backend/app/schemas/auth.py
  93. 18 0
      backend/app/schemas/campus.py
  94. 21 0
      backend/app/schemas/department.py
  95. 32 0
      backend/app/schemas/duty.py
  96. 12 0
      backend/app/schemas/pagination.py
  97. 17 0
      backend/app/schemas/role.py
  98. 59 0
      backend/app/schemas/schedule.py
  99. 32 0
      backend/app/schemas/shift.py
  100. 42 0
      backend/app/schemas/user.py

+ 6 - 0
.dockerignore

@@ -0,0 +1,6 @@
+.git
+.gitignore
+node_modules
+dist
+backend
+.env

+ 8 - 0
.env

@@ -0,0 +1,8 @@
+DATABASE_URL=postgresql+asyncpg://dema:123456@localhost:5432/schedule_management
+JWT_SECRET=/pPs59gfuKMkzmoXGMUb7OJQ5FhgFTV16QY/beoks3zhyzLx5BZgKJyesVno0fAq
+SSO_CHECK_URL=http://47.97.198.219:8000/gateway/oauth2/checkLogin   
+CENTER_BASE_URL=http://47.97.198.219:8000
+CENTER_SYNC_HOUR=8
+CENTER_SYNC_MINUTE=0
+SEED_ADMIN_ACCOUNT=admin
+SEED_ADMIN_PASSWORD=123456

+ 1 - 0
.env.production

@@ -0,0 +1 @@
+VITE_API_BASE_URL=/api

+ 24 - 0
.gitignore

@@ -0,0 +1,24 @@
+# Logs
+logs
+*.log
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
+pnpm-debug.log*
+lerna-debug.log*
+
+node_modules
+dist
+dist-ssr
+*.local
+
+# Editor directories and files
+.vscode/*
+!.vscode/extensions.json
+.idea
+.DS_Store
+*.suo
+*.ntvs*
+*.njsproj
+*.sln
+*.sw?

+ 110 - 0
README.md

@@ -0,0 +1,110 @@
+# 项目部署(Docker Compose 一键部署)
+
+## 快速启动
+1. 安装 Docker 与 Docker Compose。
+2. 在项目根目录执行:
+   ```bash
+   docker compose up -d --build
+   ```
+3. 访问:`http://localhost:8080`
+
+默认管理员账号会自动初始化,账号/密码在 `deploy.env` 中配置(`SEED_ADMIN_ACCOUNT` / `SEED_ADMIN_PASSWORD`),首次登录后请修改密码。
+
+## 离线交付(内网部署)
+外网机器(生成离线镜像包):
+1. 在项目根目录执行:
+   ```bash
+   ./scripts/package-offline.sh
+   ```
+2. 拷贝生成的 `deploy-offline/` 到内网机器。
+
+内网机器(导入并启动):
+1. 在项目根目录执行:
+   ```bash
+   cd deploy-offline
+   ./scripts/import-images.sh schedule-management-images.tar
+   ```
+2. 访问:`http://localhost:8080`
+
+如需修改数据库或密钥等配置,请编辑 `deploy.env`。
+
+## 端口与 HTTPS
+- 端口:默认对外暴露 `8080`,如需更改请修改 `docker-compose.yml` 中的 `frontend.ports`。
+- 域名/HTTPS:如需域名或 HTTPS,可在 Nginx 配置中添加证书与域名绑定。
+
+## 后端说明
+后端开发与本地运行说明见 `backend/README.md`。
+
+# React + TypeScript + Vite
+
+This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
+
+Currently, two official plugins are available:
+
+- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
+- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
+
+## React Compiler
+
+The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
+
+## Expanding the ESLint configuration
+
+If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
+
+```js
+export default defineConfig([
+  globalIgnores(['dist']),
+  {
+    files: ['**/*.{ts,tsx}'],
+    extends: [
+      // Other configs...
+
+      // Remove tseslint.configs.recommended and replace with this
+      tseslint.configs.recommendedTypeChecked,
+      // Alternatively, use this for stricter rules
+      tseslint.configs.strictTypeChecked,
+      // Optionally, add this for stylistic rules
+      tseslint.configs.stylisticTypeChecked,
+
+      // Other configs...
+    ],
+    languageOptions: {
+      parserOptions: {
+        project: ['./tsconfig.node.json', './tsconfig.app.json'],
+        tsconfigRootDir: import.meta.dirname,
+      },
+      // other options...
+    },
+  },
+])
+```
+
+You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
+
+```js
+// eslint.config.js
+import reactX from 'eslint-plugin-react-x'
+import reactDom from 'eslint-plugin-react-dom'
+
+export default defineConfig([
+  globalIgnores(['dist']),
+  {
+    files: ['**/*.{ts,tsx}'],
+    extends: [
+      // Other configs...
+      // Enable lint rules for React
+      reactX.configs['recommended-typescript'],
+      // Enable lint rules for React DOM
+      reactDom.configs.recommended,
+    ],
+    languageOptions: {
+      parserOptions: {
+        project: ['./tsconfig.node.json', './tsconfig.app.json'],
+        tsconfigRootDir: import.meta.dirname,
+      },
+      // other options...
+    },
+  },
+])
+```

+ 5 - 0
backend/.dockerignore

@@ -0,0 +1,5 @@
+__pycache__
+*.pyc
+*.pyo
+*.pyd
+.env

+ 11 - 0
backend/Dockerfile

@@ -0,0 +1,11 @@
+FROM python:3.11-slim
+WORKDIR /app
+ENV PYTHONDONTWRITEBYTECODE=1
+ENV PYTHONUNBUFFERED=1
+
+COPY requirements.txt /tmp/requirements.txt
+RUN pip install --no-cache-dir -r /tmp/requirements.txt
+
+COPY . /app/backend
+
+CMD ["uvicorn", "backend.app.main:app", "--host", "0.0.0.0", "--port", "8000"]

+ 26 - 0
backend/README.md

@@ -0,0 +1,26 @@
+# Backend (FastAPI + PostgreSQL)
+
+## 运行准备
+- Python 3.11(推荐)
+- PostgreSQL(已启用 `pgcrypto` 扩展)
+
+## 安装依赖
+```bash
+pip install -r backend/requirements.txt
+```
+
+## 环境变量
+在项目根目录或 `backend/` 下创建 `.env`(任选其一),示例:
+```
+DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:5432/schedule_management
+JWT_SECRET=change-me
+```
+
+## 启动
+```bash
+uvicorn backend.app.main:app --reload
+```
+
+## 默认账号
+- 账号:`admin`
+- 密码:`admin123`

+ 1 - 0
backend/__init__.py

@@ -0,0 +1 @@
+__all__ = []

BIN
backend/__pycache__/__init__.cpython-313.pyc


+ 1 - 0
backend/app/__init__.py

@@ -0,0 +1 @@
+__all__ = []

BIN
backend/app/__pycache__/__init__.cpython-313.pyc


BIN
backend/app/__pycache__/main.cpython-313.pyc


+ 1 - 0
backend/app/core/__init__.py

@@ -0,0 +1 @@
+__all__ = []

BIN
backend/app/core/__pycache__/__init__.cpython-313.pyc


BIN
backend/app/core/__pycache__/config.cpython-313.pyc


BIN
backend/app/core/__pycache__/dependencies.cpython-313.pyc


BIN
backend/app/core/__pycache__/response.cpython-313.pyc


BIN
backend/app/core/__pycache__/security.cpython-313.pyc


+ 24 - 0
backend/app/core/config.py

@@ -0,0 +1,24 @@
+from pydantic_settings import BaseSettings
+
+
+class Settings(BaseSettings):
+    app_name: str = "schedule-management-backend"
+    env: str = "local"
+    database_url: str = "postgresql+asyncpg://postgres:postgres@localhost:5432/schedule_management"
+    jwt_secret: str = "change-me"
+    jwt_algorithm: str = "HS256"
+    jwt_access_minutes: int = 60
+    jwt_refresh_days: int = 7
+    sso_check_url: str = ""
+    sso_timeout_seconds: int = 5
+    seed_admin_account: str = "admin"
+    seed_admin_password: str = "admin123"
+    center_base_url: str = ""
+    center_sync_hour: int = 8
+    center_sync_minute: int = 0
+
+    class Config:
+        env_file = ".env"
+
+
+settings = Settings()

+ 76 - 0
backend/app/core/dependencies.py

@@ -0,0 +1,76 @@
+from fastapi import Depends, HTTPException, status
+from fastapi.security import OAuth2PasswordBearer
+from jose import JWTError, jwt
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy import select
+from backend.app.core.config import settings
+from backend.app.db.session import get_db
+from backend.app.models import User, RolePermission
+from backend.app.schemas.auth import TokenPayload
+
+
+oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login")
+
+
+async def get_current_user_by_token(token: str, db: AsyncSession) -> User:
+    try:
+        payload = jwt.decode(token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
+        token_data = TokenPayload(**payload)
+        if token_data.type != "access":
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效凭证")
+        if token_data.ver is None:
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效凭证")
+    except JWTError as exc:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效凭证") from exc
+
+    result = await db.execute(select(User).where(User.id == token_data.sub))
+    user = result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
+    if user.token_version != token_data.ver:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="凭证已失效")
+    return user
+
+
+async def get_current_user(
+    token: str = Depends(oauth2_scheme),
+    db: AsyncSession = Depends(get_db),
+) -> User:
+    return await get_current_user_by_token(token, db)
+
+
+def require_permissions(required: list[str]):
+    async def checker(
+        current_user: User = Depends(get_current_user),
+        db: AsyncSession = Depends(get_db),
+    ) -> User:
+        if not required:
+            return current_user
+        result = await db.execute(
+            select(RolePermission.permission_code).where(RolePermission.role_id == current_user.role_id)
+        )
+        user_permissions = {row[0] for row in result.fetchall()}
+        missing = [code for code in required if code not in user_permissions]
+        if missing:
+            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限操作")
+        return current_user
+
+    return checker
+
+
+def require_any_permissions(required: list[str]):
+    async def checker(
+        current_user: User = Depends(get_current_user),
+        db: AsyncSession = Depends(get_db),
+    ) -> User:
+        if not required:
+            return current_user
+        result = await db.execute(
+            select(RolePermission.permission_code).where(RolePermission.role_id == current_user.role_id)
+        )
+        user_permissions = {row[0] for row in result.fetchall()}
+        if not user_permissions.intersection(required):
+            raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="无权限操作")
+        return current_user
+
+    return checker

+ 21 - 0
backend/app/core/response.py

@@ -0,0 +1,21 @@
+from typing import Any, Optional
+from datetime import datetime
+
+
+def build_envelope(
+    data: Any,
+    trace_id: str,
+    code: str = "0",
+    message: str = "ok",
+    details: Optional[dict] = None,
+) -> dict:
+    payload = {
+        "code": code,
+        "message": message,
+        "data": data,
+        "traceId": trace_id,
+        "ts": int(datetime.utcnow().timestamp() * 1000),
+    }
+    if details is not None:
+        payload["details"] = details
+    return payload

+ 27 - 0
backend/app/core/security.py

@@ -0,0 +1,27 @@
+from datetime import datetime, timedelta
+from jose import jwt
+from passlib.context import CryptContext
+from .config import settings
+
+
+pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
+
+
+def verify_password(plain_password: str, hashed_password: str) -> bool:
+    return pwd_context.verify(plain_password, hashed_password)
+
+
+def hash_password(password: str) -> str:
+    return pwd_context.hash(password)
+
+
+def create_access_token(subject: str, token_version: int, expires_minutes: int | None = None) -> str:
+    expire = datetime.utcnow() + timedelta(minutes=expires_minutes or settings.jwt_access_minutes)
+    to_encode = {"sub": subject, "exp": expire, "type": "access", "ver": token_version}
+    return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)
+
+
+def create_refresh_token(subject: str, token_version: int, expires_days: int | None = None) -> str:
+    expire = datetime.utcnow() + timedelta(days=expires_days or settings.jwt_refresh_days)
+    to_encode = {"sub": subject, "exp": expire, "type": "refresh", "ver": token_version}
+    return jwt.encode(to_encode, settings.jwt_secret, algorithm=settings.jwt_algorithm)

+ 1 - 0
backend/app/db/__init__.py

@@ -0,0 +1 @@
+__all__ = []

BIN
backend/app/db/__pycache__/__init__.cpython-313.pyc


BIN
backend/app/db/__pycache__/base.cpython-313.pyc


BIN
backend/app/db/__pycache__/session.cpython-313.pyc


+ 5 - 0
backend/app/db/base.py

@@ -0,0 +1,5 @@
+from sqlalchemy.orm import DeclarativeBase
+
+
+class Base(DeclarativeBase):
+    pass

+ 11 - 0
backend/app/db/session.py

@@ -0,0 +1,11 @@
+from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine, AsyncSession
+from backend.app.core.config import settings
+
+
+engine = create_async_engine(settings.database_url, pool_pre_ping=True)
+SessionLocal = async_sessionmaker(bind=engine, class_=AsyncSession, expire_on_commit=False)
+
+
+async def get_db() -> AsyncSession:
+    async with SessionLocal() as session:
+        yield session

+ 180 - 0
backend/app/main.py

@@ -0,0 +1,180 @@
+import asyncio
+import json
+import logging
+import time
+from uuid import uuid4
+from contextlib import suppress
+from fastapi import FastAPI, HTTPException, Request
+from fastapi.exceptions import RequestValidationError
+from fastapi.middleware.cors import CORSMiddleware
+from fastapi.responses import JSONResponse
+from starlette.responses import Response
+from sqlalchemy import text
+from sqlalchemy.exc import IntegrityError
+from backend.app.core.config import settings
+from backend.app.core.response import build_envelope
+from backend.app.db.session import engine, SessionLocal
+from backend.app.db.base import Base
+from backend.app import models  # noqa: F401
+from backend.app.services.seed import seed_data
+from backend.app.services.center_sync import run_center_sync_loop
+from backend.app.routers import auth, users, roles, campuses, departments, shifts, schedule, duty, statistics, adjust_logs, sync, ws
+
+
+app = FastAPI(title=settings.app_name)
+app.add_middleware(
+    CORSMiddleware,
+    allow_origins=[
+        "http://localhost:5173",
+        "http://127.0.0.1:5173"
+    ],
+    allow_credentials=True,
+    allow_methods=["*"],
+    allow_headers=["*"],
+)
+
+logger = logging.getLogger("uvicorn.error")
+sync_stop_event = asyncio.Event()
+sync_task: asyncio.Task | None = None
+
+
+def _clone_headers(response, trace_id: str) -> dict:
+    headers = dict(response.headers)
+    headers.pop("content-length", None)
+    headers["X-Trace-Id"] = trace_id
+    return headers
+
+
+@app.middleware("http")
+async def add_trace_and_wrap(request: Request, call_next):
+    trace_id = request.headers.get("x-trace-id") or request.headers.get("traceId") or uuid4().hex
+    request.state.trace_id = trace_id
+    response = await call_next(request)
+
+    content_type = response.headers.get("content-type", "")
+    if response.status_code >= 400 or "application/json" not in content_type:
+        response.headers["X-Trace-Id"] = trace_id
+        return response
+
+    body = b""
+    async for chunk in response.body_iterator:
+        body += chunk
+
+    headers = _clone_headers(response, trace_id)
+
+    if not body:
+        return JSONResponse(build_envelope(None, trace_id), status_code=response.status_code, headers=headers)
+
+    try:
+        data = json.loads(body)
+    except json.JSONDecodeError:
+        return Response(
+            content=body,
+            status_code=response.status_code,
+            headers=headers,
+            media_type=response.media_type,
+        )
+
+    if isinstance(data, dict) and {"code", "message", "data"}.issubset(data.keys()):
+        data.setdefault("traceId", trace_id)
+        data.setdefault("ts", int(time.time() * 1000))
+        return JSONResponse(data, status_code=response.status_code, headers=headers)
+
+    return JSONResponse(build_envelope(data, trace_id), status_code=response.status_code, headers=headers)
+
+
+def get_trace_id(request: Request) -> str:
+    trace_id = getattr(request.state, "trace_id", None)
+    return trace_id or uuid4().hex
+
+
+@app.exception_handler(RequestValidationError)
+async def validation_exception_handler(request: Request, exc: RequestValidationError):
+    field_errors = []
+    for err in exc.errors():
+        loc = [str(item) for item in err.get("loc", []) if item not in ("body", "query", "path")]
+        field = ".".join(loc) if loc else ".".join([str(item) for item in err.get("loc", [])])
+        field_errors.append({"field": field, "reason": err.get("msg")})
+    trace_id = get_trace_id(request)
+    payload = build_envelope(
+        None,
+        trace_id,
+        code="VALIDATION_ERROR",
+        message="参数校验失败",
+        details={"fieldErrors": field_errors},
+    )
+    return JSONResponse(payload, status_code=400)
+
+
+@app.exception_handler(HTTPException)
+async def http_exception_handler(request: Request, exc: HTTPException):
+    code_map = {
+        400: "VALIDATION_ERROR",
+        401: "UNAUTHORIZED",
+        403: "FORBIDDEN",
+        404: "NOT_FOUND",
+        409: "CONFLICT",
+        429: "RATE_LIMITED",
+    }
+    trace_id = get_trace_id(request)
+    code = code_map.get(exc.status_code, "INTERNAL_ERROR")
+    message = exc.detail if isinstance(exc.detail, str) else "请求失败"
+    payload = build_envelope(None, trace_id, code=code, message=message)
+    return JSONResponse(payload, status_code=exc.status_code)
+
+
+@app.exception_handler(IntegrityError)
+async def integrity_exception_handler(request: Request, exc: IntegrityError):
+    trace_id = get_trace_id(request)
+    logger.exception("IntegrityError traceId=%s", trace_id)
+    payload = build_envelope(None, trace_id, code="CONFLICT", message="数据冲突")
+    return JSONResponse(payload, status_code=409)
+
+
+@app.exception_handler(Exception)
+async def general_exception_handler(request: Request, exc: Exception):
+    trace_id = get_trace_id(request)
+    logger.exception("Unhandled error traceId=%s", trace_id)
+    payload = build_envelope(None, trace_id, code="INTERNAL_ERROR", message="系统异常,请稍后再试")
+    return JSONResponse(payload, status_code=500)
+
+
+@app.on_event("startup")
+async def on_startup():
+    global sync_task
+    async with engine.begin() as conn:
+        # 依赖 pgcrypto 扩展提供 gen_random_uuid()
+        # 若数据库账号无权限创建扩展,会在这里直接失败并提示,避免后续插入时报错更隐蔽
+        await conn.execute(text('CREATE EXTENSION IF NOT EXISTS "pgcrypto";'))
+        await conn.run_sync(Base.metadata.create_all)
+    async with SessionLocal() as session:
+        await seed_data(session)
+    sync_task = asyncio.create_task(run_center_sync_loop(sync_stop_event))
+
+
+@app.on_event("shutdown")
+async def on_shutdown():
+    sync_stop_event.set()
+    if sync_task:
+        sync_task.cancel()
+        with suppress(asyncio.CancelledError):
+            await sync_task
+
+
+@app.get("/health")
+async def health():
+    return {"status": "ok"}
+
+
+app.include_router(auth.router)
+app.include_router(users.router)
+app.include_router(roles.router)
+app.include_router(campuses.router)
+app.include_router(departments.router)
+app.include_router(shifts.router)
+app.include_router(schedule.router)
+app.include_router(duty.router)
+app.include_router(statistics.router)
+app.include_router(adjust_logs.router)
+app.include_router(sync.router)
+app.include_router(ws.router)

+ 25 - 0
backend/app/models/__init__.py

@@ -0,0 +1,25 @@
+from .role import Role, Permission, RolePermission
+from .user import User
+from .campus import Campus
+from .department import Department
+from .shift import Shift
+from .schedule import ScheduleItem
+from .duty import DutyItem
+from .adjust_log import AdjustLog
+from .audit_log import AuditLog
+from .sync_state import SyncState
+
+__all__ = [
+    "Role",
+    "Permission",
+    "RolePermission",
+    "User",
+    "Campus",
+    "Department",
+    "Shift",
+    "ScheduleItem",
+    "DutyItem",
+    "AdjustLog",
+    "AuditLog",
+    "SyncState",
+]

BIN
backend/app/models/__pycache__/__init__.cpython-313.pyc


BIN
backend/app/models/__pycache__/adjust_log.cpython-313.pyc


BIN
backend/app/models/__pycache__/audit_log.cpython-313.pyc


BIN
backend/app/models/__pycache__/campus.cpython-313.pyc


BIN
backend/app/models/__pycache__/department.cpython-313.pyc


BIN
backend/app/models/__pycache__/duty.cpython-313.pyc


BIN
backend/app/models/__pycache__/role.cpython-313.pyc


BIN
backend/app/models/__pycache__/schedule.cpython-313.pyc


BIN
backend/app/models/__pycache__/shift.cpython-313.pyc


BIN
backend/app/models/__pycache__/sync_state.cpython-313.pyc


BIN
backend/app/models/__pycache__/user.cpython-313.pyc


+ 16 - 0
backend/app/models/adjust_log.py

@@ -0,0 +1,16 @@
+from sqlalchemy import Column, Date, DateTime, ForeignKey, String, Text, text
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.sql import func
+from backend.app.db.base import Base
+
+
+class AdjustLog(Base):
+    __tablename__ = "adjust_logs"
+
+    id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
+    dept_id = Column(UUID(as_uuid=True), ForeignKey("departments.id", ondelete="RESTRICT"), nullable=False)
+    date = Column(Date, nullable=False)
+    type = Column(String, nullable=False)
+    operator = Column(String, nullable=False)
+    created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+    note = Column(Text)

+ 18 - 0
backend/app/models/audit_log.py

@@ -0,0 +1,18 @@
+from sqlalchemy import Column, DateTime, String, Text, text
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.sql import func
+from sqlalchemy.types import JSON
+from backend.app.db.base import Base
+
+
+class AuditLog(Base):
+    __tablename__ = "audit_logs"
+
+    id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
+    actor_id = Column(UUID(as_uuid=True))
+    action = Column(String, nullable=False)
+    target_type = Column(String, nullable=False)
+    target_id = Column(UUID(as_uuid=True))
+    detail = Column(JSON)
+    ip = Column(Text)
+    created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)

+ 14 - 0
backend/app/models/campus.py

@@ -0,0 +1,14 @@
+from sqlalchemy import Column, DateTime, String, text
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.sql import func
+from backend.app.db.base import Base
+
+
+class Campus(Base):
+    __tablename__ = "campuses"
+
+    id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
+    name = Column(String, unique=True, nullable=False)
+    external_hosp_id = Column(String, unique=True)
+    created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

+ 15 - 0
backend/app/models/department.py

@@ -0,0 +1,15 @@
+from sqlalchemy import Column, DateTime, ForeignKey, String, text
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.sql import func
+from backend.app.db.base import Base
+
+
+class Department(Base):
+    __tablename__ = "departments"
+
+    id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
+    campus_id = Column(UUID(as_uuid=True), ForeignKey("campuses.id", ondelete="RESTRICT"), nullable=False)
+    name = Column(String, nullable=False)
+    external_dept_id = Column(String, unique=True)
+    created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

+ 18 - 0
backend/app/models/duty.py

@@ -0,0 +1,18 @@
+from sqlalchemy import Column, Date, DateTime, ForeignKey, String, Text, Index, text
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.sql import func
+from backend.app.db.base import Base
+
+
+class DutyItem(Base):
+    __tablename__ = "duty_items"
+    __table_args__ = (Index("idx_duty_date", "date"),)
+
+    id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
+    date = Column(Date, nullable=False)
+    staff_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False)
+    duty_type = Column(String, nullable=False)
+    contact = Column(String)
+    note = Column(Text)
+    created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

+ 40 - 0
backend/app/models/role.py

@@ -0,0 +1,40 @@
+from sqlalchemy import Boolean, Column, DateTime, ForeignKey, String, Text, text
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from backend.app.db.base import Base
+
+
+class Role(Base):
+    __tablename__ = "roles"
+
+    id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
+    name = Column(String, unique=True, nullable=False)
+    external_role_id = Column(String, unique=True)
+    description = Column(Text)
+    is_system = Column(Boolean, nullable=False, server_default="true")
+    created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+
+    permissions = relationship("RolePermission", back_populates="role", cascade="all, delete-orphan")
+
+
+class Permission(Base):
+    __tablename__ = "permissions"
+
+    code = Column(String, primary_key=True)
+    name = Column(String, nullable=False)
+    module = Column(String, nullable=False)
+    created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+
+    roles = relationship("RolePermission", back_populates="permission", cascade="all, delete-orphan")
+
+
+class RolePermission(Base):
+    __tablename__ = "role_permissions"
+
+    role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id", ondelete="CASCADE"), primary_key=True)
+    permission_code = Column(String, ForeignKey("permissions.code", ondelete="CASCADE"), primary_key=True)
+
+    role = relationship("Role", back_populates="permissions")
+    permission = relationship("Permission", back_populates="roles")

+ 26 - 0
backend/app/models/schedule.py

@@ -0,0 +1,26 @@
+from sqlalchemy import Column, Date, DateTime, ForeignKey, String, Text, UniqueConstraint, Index, text
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.sql import func
+from backend.app.db.base import Base
+
+
+class ScheduleItem(Base):
+    __tablename__ = "schedule_items"
+    __table_args__ = (
+        UniqueConstraint("dept_id", "date", "shift_id", "staff_id", name="uq_schedule_unique"),
+        Index("idx_schedule_dept_date", "dept_id", "date"),
+        Index("idx_schedule_staff_date", "staff_id", "date"),
+    )
+
+    id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
+    dept_id = Column(UUID(as_uuid=True), ForeignKey("departments.id", ondelete="RESTRICT"), nullable=False)
+    date = Column(Date, nullable=False)
+    shift_id = Column(UUID(as_uuid=True), ForeignKey("shifts.id", ondelete="RESTRICT"), nullable=True)
+    staff_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="RESTRICT"), nullable=False)
+    tag = Column(String, nullable=False, server_default="normal")
+    note = Column(Text)
+    original_staff_id = Column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"))
+    substitute_for_id = Column(UUID(as_uuid=True), ForeignKey("schedule_items.id", ondelete="SET NULL"))
+    reason = Column(Text)
+    created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

+ 17 - 0
backend/app/models/shift.py

@@ -0,0 +1,17 @@
+from sqlalchemy import Boolean, Column, DateTime, String, Text, Time, text
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.sql import func
+from backend.app.db.base import Base
+
+
+class Shift(Base):
+    __tablename__ = "shifts"
+
+    id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
+    name = Column(String, nullable=False)
+    start_time = Column(Time, nullable=False)
+    end_time = Column(Time, nullable=False)
+    enabled = Column(Boolean, nullable=False, server_default="true")
+    remark = Column(Text)
+    created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

+ 13 - 0
backend/app/models/sync_state.py

@@ -0,0 +1,13 @@
+from sqlalchemy import Column, DateTime, String, Text
+from sqlalchemy.sql import func
+from backend.app.db.base import Base
+
+
+class SyncState(Base):
+    __tablename__ = "sync_states"
+
+    key = Column(String, primary_key=True)
+    token = Column(Text, nullable=False)
+    last_sync_at = Column(DateTime(timezone=True))
+    created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)

+ 33 - 0
backend/app/models/user.py

@@ -0,0 +1,33 @@
+from sqlalchemy import Column, DateTime, ForeignKey, Integer, String, Text, text
+from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import relationship
+from sqlalchemy.sql import func
+from backend.app.db.base import Base
+
+
+class User(Base):
+    __tablename__ = "users"
+
+    id = Column(UUID(as_uuid=True), primary_key=True, server_default=text("gen_random_uuid()"))
+    name = Column(String, nullable=False)
+    account = Column(String, unique=True, nullable=False)
+    phone = Column(String)
+    title = Column(String)
+    avatar = Column(Text)
+    external_user_id = Column(String)
+    tenant_id = Column(String)
+    tenant_name = Column(String)
+    hosp_id = Column(String)
+    hosp_name = Column(String)
+    role_id = Column(UUID(as_uuid=True), ForeignKey("roles.id", ondelete="RESTRICT"), nullable=False)
+    campus_id = Column(UUID(as_uuid=True), ForeignKey("campuses.id", ondelete="SET NULL"))
+    dept_id = Column(UUID(as_uuid=True), ForeignKey("departments.id", ondelete="SET NULL"))
+    status = Column(String, nullable=False, server_default="active")
+    password_hash = Column(String, nullable=False)
+    token_version = Column(Integer, nullable=False, server_default="1")
+    created_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
+    updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
+
+    role = relationship("Role", lazy="joined")
+    campus = relationship("Campus", lazy="joined")
+    department = relationship("Department", lazy="joined")

+ 16 - 0
backend/app/routers/__init__.py

@@ -0,0 +1,16 @@
+from . import auth, users, roles, campuses, departments, shifts, schedule, duty, statistics, adjust_logs, sync, ws
+
+__all__ = [
+    "auth",
+    "users",
+    "roles",
+    "campuses",
+    "departments",
+    "shifts",
+    "schedule",
+    "duty",
+    "statistics",
+    "adjust_logs",
+    "sync",
+    "ws",
+]

BIN
backend/app/routers/__pycache__/__init__.cpython-313.pyc


BIN
backend/app/routers/__pycache__/adjust_logs.cpython-313.pyc


BIN
backend/app/routers/__pycache__/auth.cpython-313.pyc


BIN
backend/app/routers/__pycache__/campuses.cpython-313.pyc


BIN
backend/app/routers/__pycache__/departments.cpython-313.pyc


BIN
backend/app/routers/__pycache__/duty.cpython-313.pyc


BIN
backend/app/routers/__pycache__/roles.cpython-313.pyc


BIN
backend/app/routers/__pycache__/schedule.cpython-313.pyc


BIN
backend/app/routers/__pycache__/shifts.cpython-313.pyc


BIN
backend/app/routers/__pycache__/statistics.cpython-313.pyc


BIN
backend/app/routers/__pycache__/sync.cpython-313.pyc


BIN
backend/app/routers/__pycache__/users.cpython-313.pyc


BIN
backend/app/routers/__pycache__/ws.cpython-313.pyc


+ 54 - 0
backend/app/routers/adjust_logs.py

@@ -0,0 +1,54 @@
+from uuid import UUID
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from backend.app.core.dependencies import require_permissions, get_current_user
+from backend.app.db.session import get_db
+from backend.app.models import AdjustLog, User
+from backend.app.schemas.adjust_log import AdjustLogResponse
+from backend.app.schemas.pagination import PaginatedResponse
+
+
+router = APIRouter(prefix="/adjust-logs", tags=["adjust-logs"])
+
+
+def is_limited_scope(user: User) -> bool:
+    return user.role and user.role.name not in {"管理员", "排班员"}
+
+
+@router.get(
+    "",
+    response_model=list[AdjustLogResponse] | PaginatedResponse[AdjustLogResponse],
+    dependencies=[Depends(require_permissions(["schedule.view"]))],
+)
+async def list_logs(
+    db: AsyncSession = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+    dept_id: UUID | None = Query(default=None),
+    page: int | None = Query(default=None, ge=1),
+    page_size: int | None = Query(default=None, ge=1, le=200, alias="pageSize"),
+    size: int | None = Query(default=None, ge=1, le=200),
+):
+    query = select(AdjustLog)
+    if is_limited_scope(current_user) and current_user.dept_id:
+        query = query.where(AdjustLog.dept_id == current_user.dept_id)
+    elif dept_id:
+        query = query.where(AdjustLog.dept_id == dept_id)
+    if page is None and page_size is None and size is None:
+        result = await db.execute(query.order_by(AdjustLog.created_at.desc()))
+        return result.scalars().all()
+
+    page_value = page or 1
+    size_value = page_size or size or 10
+    total = await db.scalar(select(func.count()).select_from(query.subquery()))
+    result = await db.execute(
+        query.order_by(AdjustLog.created_at.desc())
+        .offset((page_value - 1) * size_value)
+        .limit(size_value)
+    )
+    return {
+        "items": result.scalars().all(),
+        "total": total or 0,
+        "page": page_value,
+        "pageSize": size_value,
+    }

+ 232 - 0
backend/app/routers/auth.py

@@ -0,0 +1,232 @@
+import logging
+import secrets
+import httpx
+from fastapi import APIRouter, Depends, HTTPException, status
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from backend.app.core.security import create_access_token, create_refresh_token, hash_password, verify_password
+from backend.app.core.config import settings
+from jose import jwt, JWTError
+from backend.app.db.session import get_db
+from backend.app.models import Role, RolePermission, User
+from backend.app.services.center_sync import (
+    get_or_create_pending_role,
+    resolve_department_by_external_id,
+    resolve_role_by_external_ids,
+    trigger_center_sync,
+    upsert_sync_token,
+)
+from backend.app.schemas.auth import LoginRequest, RefreshRequest, SSOExchangeRequest, Token
+
+
+router = APIRouter(prefix="/auth", tags=["auth"])
+logger = logging.getLogger("uvicorn.error")
+
+
+def _parse_role_ids(claims: dict) -> list[str]:
+    raw = claims.get("role_ids") or claims.get("roleIds") or claims.get("roleId") or claims.get("roles")
+    if raw is None:
+        return []
+    if isinstance(raw, list):
+        return [str(item).strip() for item in raw if item is not None and str(item).strip()]
+    raw_str = str(raw).strip()
+    if not raw_str:
+        return []
+    if "," in raw_str:
+        return [item.strip() for item in raw_str.split(",") if item.strip()]
+    return [raw_str]
+
+
+def _normalize_text(value) -> str | None:
+    if value is None:
+        return None
+    text = str(value).strip()
+    return text or None
+
+
+def _build_center_user_url() -> str:
+    base = settings.center_base_url.rstrip("/")
+    return f"{base}/gateway/centerSys/user/getUserByToken"
+
+
+async def _user_has_permission(db: AsyncSession, role_id, permission_code: str) -> bool:
+    result = await db.execute(
+        select(RolePermission.permission_code)
+        .where(RolePermission.role_id == role_id, RolePermission.permission_code == permission_code)
+        .limit(1)
+    )
+    return result.first() is not None
+
+
+@router.post("/login", response_model=Token)
+async def login(payload: LoginRequest, db: AsyncSession = Depends(get_db)):
+    result = await db.execute(select(User).where(User.account == payload.account))
+    user = result.scalar_one_or_none()
+    if not user or not verify_password(payload.password, user.password_hash):
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号或密码错误")
+    return Token(
+        access_token=create_access_token(str(user.id), user.token_version),
+        refresh_token=create_refresh_token(str(user.id), user.token_version),
+    )
+
+
+@router.post("/sso/exchange", response_model=Token)
+async def exchange_sso_token(payload: SSOExchangeRequest, db: AsyncSession = Depends(get_db)):
+    if not settings.sso_check_url:
+        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="SSO未配置")
+    if not settings.center_base_url:
+        raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="中台基础地址未配置")
+
+    try:
+        async with httpx.AsyncClient(timeout=settings.sso_timeout_seconds) as client:
+            response = await client.post(settings.sso_check_url, headers={"token": payload.token})
+    except httpx.RequestError as exc:
+        raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="SSO服务不可用") from exc
+
+    if response.status_code != status.HTTP_200_OK:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="SSO校验失败")
+
+    try:
+        data = response.json()
+    except ValueError as exc:
+        raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="SSO响应异常") from exc
+    if data.get("status") != 200:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail=data.get("msg") or "SSO校验失败")
+
+    try:
+        async with httpx.AsyncClient(timeout=settings.sso_timeout_seconds) as client:
+            user_url = _build_center_user_url()
+            user_response = await client.get(user_url, headers={"token": payload.token})
+            if user_response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED:
+                user_response = await client.post(user_url, headers={"token": payload.token})
+    except httpx.RequestError as exc:
+        raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="中台用户服务不可用") from exc
+
+    if user_response.status_code != status.HTTP_200_OK:
+        raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="中台用户信息获取失败")
+
+    try:
+        user_payload = user_response.json()
+    except ValueError as exc:
+        raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="中台用户信息返回异常") from exc
+
+    if user_payload.get("status") != 200:
+        raise HTTPException(
+            status_code=status.HTTP_401_UNAUTHORIZED,
+            detail=user_payload.get("msg") or "中台用户信息获取失败",
+        )
+
+    user_info = user_payload.get("data") or {}
+    if not isinstance(user_info, dict):
+        raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="中台用户信息格式异常")
+
+    external_user_id = _normalize_text(user_info.get("userId") or user_info.get("id"))
+    if not external_user_id:
+        raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="用户信息缺少用户标识")
+
+    account = _normalize_text(user_info.get("empNo") or user_info.get("account")) or external_user_id
+    name = (user_info.get("name") or account).strip()
+    phone = _normalize_text(user_info.get("phoneNumber") or user_info.get("phone"))
+    avatar = _normalize_text(user_info.get("avatarUrl"))
+    title = _normalize_text(
+        user_info.get("jobTitleName") or user_info.get("positionName") or user_info.get("professionalTitleName")
+    )
+    external_dept_id = _normalize_text(user_info.get("departmentId") or user_info.get("departmentCode"))
+    role_id = _normalize_text(user_info.get("roleId") or user_info.get("roleCode"))
+    tenant_id = _normalize_text(user_info.get("tenant_id") or user_info.get("tenantId"))
+    tenant_name = _normalize_text(user_info.get("tenant_name") or user_info.get("tenantName"))
+    hosp_id = _normalize_text(user_info.get("hospId") or user_info.get("hosp_id"))
+    hosp_name = _normalize_text(user_info.get("hospName") or user_info.get("hosp_name"))
+
+    resolved_role = None
+    if role_id:
+        resolved_role = await resolve_role_by_external_ids(db, [role_id])
+    if not resolved_role and settings.seed_admin_account and account == settings.seed_admin_account:
+        role_result = await db.execute(select(Role).where(Role.name == "管理员"))
+        resolved_role = role_result.scalar_one_or_none()
+        if not resolved_role:
+            raise HTTPException(status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="管理员角色未配置")
+
+    pending_role = await get_or_create_pending_role(db)
+    dept = await resolve_department_by_external_id(db, str(external_dept_id) if external_dept_id is not None else None)
+
+    result = await db.execute(select(User).where(User.external_user_id == str(external_user_id)))
+    user = result.scalar_one_or_none()
+    if not user and account:
+        result = await db.execute(select(User).where(User.account == account))
+        user = result.scalar_one_or_none()
+
+    if not user:
+        role_id = resolved_role.id if resolved_role else pending_role.id
+        user = User(
+            name=name,
+            account=account,
+            phone=phone,
+            title=title,
+            avatar=avatar,
+            role_id=role_id,
+            status="active",
+            password_hash=hash_password(secrets.token_urlsafe(24)),
+            token_version=1,
+            external_user_id=str(external_user_id),
+            tenant_id=str(tenant_id) if tenant_id is not None else None,
+            tenant_name=tenant_name,
+            hosp_id=str(hosp_id) if hosp_id is not None else None,
+            hosp_name=hosp_name,
+            dept_id=dept.id if dept else None,
+            campus_id=dept.campus_id if dept else None
+        )
+        db.add(user)
+    else:
+        user.name = name
+        user.phone = phone
+        user.title = title
+        user.avatar = avatar
+        user.external_user_id = str(external_user_id)
+        user.tenant_id = str(tenant_id) if tenant_id is not None else user.tenant_id
+        user.tenant_name = tenant_name or user.tenant_name
+        user.hosp_id = str(hosp_id) if hosp_id is not None else user.hosp_id
+        user.hosp_name = hosp_name or user.hosp_name
+        if dept:
+            user.dept_id = dept.id
+            user.campus_id = dept.campus_id
+        if resolved_role and (account == settings.seed_admin_account or user.role_id == pending_role.id):
+            user.role_id = resolved_role.id
+
+    await upsert_sync_token(db, payload.token)
+    await db.commit()
+    await db.refresh(user)
+    try:
+        if await _user_has_permission(db, user.role_id, "users.view"):
+            await trigger_center_sync(payload.token, restart=False, wait=False)
+    except Exception:
+        logger.exception("登录后触发中台同步失败")
+    return Token(
+        access_token=create_access_token(str(user.id), user.token_version),
+        refresh_token=create_refresh_token(str(user.id), user.token_version)
+    )
+
+
+@router.post("/refresh", response_model=Token)
+async def refresh_token(payload: RefreshRequest, db: AsyncSession = Depends(get_db)):
+    try:
+        data = jwt.decode(payload.refresh_token, settings.jwt_secret, algorithms=[settings.jwt_algorithm])
+        if data.get("type") != "refresh":
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效刷新令牌")
+        user_id = data.get("sub")
+        token_version = data.get("ver")
+        if token_version is None:
+            raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效刷新令牌")
+    except JWTError as exc:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="无效刷新令牌") from exc
+
+    result = await db.execute(select(User).where(User.id == user_id))
+    user = result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户不存在")
+    if user.token_version != token_version:
+        raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="刷新令牌已失效")
+    return Token(
+        access_token=create_access_token(str(user.id), user.token_version),
+        refresh_token=create_refresh_token(str(user.id), user.token_version)
+    )

+ 69 - 0
backend/app/routers/campuses.py

@@ -0,0 +1,69 @@
+from uuid import UUID
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from backend.app.core.dependencies import require_any_permissions, require_permissions
+from backend.app.db.session import get_db
+from backend.app.models import Campus, Department, User
+from backend.app.schemas.campus import CampusCreate, CampusResponse, CampusUpdate
+
+
+router = APIRouter(prefix="/campuses", tags=["campuses"])
+
+
+def is_limited_scope(user: User) -> bool:
+    return user.role and user.role.name not in {"管理员", "排班员"}
+
+
+@router.get("", response_model=list[CampusResponse])
+async def list_campuses(
+    db: AsyncSession = Depends(get_db),
+    current_user: User = Depends(require_any_permissions(["users.view", "schedule.view"])),
+):
+    query = select(Campus)
+    if is_limited_scope(current_user):
+        if current_user.campus_id:
+            query = query.where(Campus.id == current_user.campus_id)
+        elif current_user.dept_id:
+            dept_result = await db.execute(select(Department).where(Department.id == current_user.dept_id))
+            dept = dept_result.scalar_one_or_none()
+            if dept:
+                query = query.where(Campus.id == dept.campus_id)
+            else:
+                return []
+        else:
+            return []
+    result = await db.execute(query.order_by(Campus.name))
+    return result.scalars().all()
+
+
+@router.post("", response_model=CampusResponse, dependencies=[Depends(require_permissions(["users.edit"]))])
+async def create_campus(payload: CampusCreate, db: AsyncSession = Depends(get_db)):
+    campus = Campus(name=payload.name)
+    db.add(campus)
+    await db.commit()
+    await db.refresh(campus)
+    return campus
+
+
+@router.put("/{campus_id}", response_model=CampusResponse, dependencies=[Depends(require_permissions(["users.edit"]))])
+async def update_campus(campus_id: UUID, payload: CampusUpdate, db: AsyncSession = Depends(get_db)):
+    result = await db.execute(select(Campus).where(Campus.id == campus_id))
+    campus = result.scalar_one_or_none()
+    if not campus:
+        raise HTTPException(status_code=404, detail="院区不存在")
+    campus.name = payload.name
+    await db.commit()
+    await db.refresh(campus)
+    return campus
+
+
+@router.delete("/{campus_id}", dependencies=[Depends(require_permissions(["users.delete"]))])
+async def delete_campus(campus_id: UUID, db: AsyncSession = Depends(get_db)):
+    result = await db.execute(select(Campus).where(Campus.id == campus_id))
+    campus = result.scalar_one_or_none()
+    if not campus:
+        raise HTTPException(status_code=404, detail="院区不存在")
+    await db.delete(campus)
+    await db.commit()
+    return {"success": True}

+ 65 - 0
backend/app/routers/departments.py

@@ -0,0 +1,65 @@
+from uuid import UUID
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select
+from sqlalchemy.ext.asyncio import AsyncSession
+from backend.app.core.dependencies import require_any_permissions, require_permissions
+from backend.app.db.session import get_db
+from backend.app.models import Department, User
+from backend.app.schemas.department import DepartmentCreate, DepartmentResponse, DepartmentUpdate
+
+
+router = APIRouter(prefix="/departments", tags=["departments"])
+
+
+def is_limited_scope(user: User) -> bool:
+    return user.role and user.role.name not in {"管理员", "排班员"}
+
+
+@router.get("", response_model=list[DepartmentResponse])
+async def list_departments(
+    db: AsyncSession = Depends(get_db),
+    current_user: User = Depends(require_any_permissions(["users.view", "schedule.view"])),
+):
+    query = select(Department)
+    if is_limited_scope(current_user):
+        if current_user.dept_id:
+            query = query.where(Department.id == current_user.dept_id)
+        elif current_user.campus_id:
+            query = query.where(Department.campus_id == current_user.campus_id)
+        else:
+            return []
+    result = await db.execute(query.order_by(Department.name))
+    return result.scalars().all()
+
+
+@router.post("", response_model=DepartmentResponse, dependencies=[Depends(require_permissions(["users.edit"]))])
+async def create_department(payload: DepartmentCreate, db: AsyncSession = Depends(get_db)):
+    dept = Department(campus_id=payload.campus_id, name=payload.name)
+    db.add(dept)
+    await db.commit()
+    await db.refresh(dept)
+    return dept
+
+
+@router.put("/{dept_id}", response_model=DepartmentResponse, dependencies=[Depends(require_permissions(["users.edit"]))])
+async def update_department(dept_id: UUID, payload: DepartmentUpdate, db: AsyncSession = Depends(get_db)):
+    result = await db.execute(select(Department).where(Department.id == dept_id))
+    dept = result.scalar_one_or_none()
+    if not dept:
+        raise HTTPException(status_code=404, detail="科室不存在")
+    dept.campus_id = payload.campus_id
+    dept.name = payload.name
+    await db.commit()
+    await db.refresh(dept)
+    return dept
+
+
+@router.delete("/{dept_id}", dependencies=[Depends(require_permissions(["users.delete"]))])
+async def delete_department(dept_id: UUID, db: AsyncSession = Depends(get_db)):
+    result = await db.execute(select(Department).where(Department.id == dept_id))
+    dept = result.scalar_one_or_none()
+    if not dept:
+        raise HTTPException(status_code=404, detail="科室不存在")
+    await db.delete(dept)
+    await db.commit()
+    return {"success": True}

+ 104 - 0
backend/app/routers/duty.py

@@ -0,0 +1,104 @@
+from typing import Optional
+from datetime import date
+from uuid import UUID
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import func, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from backend.app.core.dependencies import require_permissions, get_current_user
+from backend.app.db.session import get_db
+from backend.app.models import DutyItem, User
+from backend.app.schemas.pagination import PaginatedResponse
+from backend.app.schemas.duty import DutyCreate, DutyResponse, DutyUpdate
+
+
+router = APIRouter(prefix="/duty", tags=["duty"])
+
+
+def is_limited_scope(user: User) -> bool:
+    return user.role and user.role.name not in {"管理员", "排班员"}
+
+
+@router.get(
+    "",
+    response_model=list[DutyResponse] | PaginatedResponse[DutyResponse],
+    dependencies=[Depends(require_permissions(["duty.view"]))],
+)
+async def list_duty(
+    db: AsyncSession = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+    start: Optional[str] = Query(default=None),
+    end: Optional[str] = Query(default=None),
+    page: Optional[int] = Query(default=None, ge=1),
+    page_size: Optional[int] = Query(default=None, ge=1, le=200, alias="pageSize"),
+    size: Optional[int] = Query(default=None, ge=1, le=200),
+):
+    query = select(DutyItem)
+    if is_limited_scope(current_user) and current_user.dept_id:
+        query = query.where(DutyItem.staff_id == current_user.id)
+    if start:
+        query = query.where(DutyItem.date >= date.fromisoformat(start))
+    if end:
+        query = query.where(DutyItem.date <= date.fromisoformat(end))
+    if page is None and page_size is None and size is None:
+        result = await db.execute(query.order_by(DutyItem.date))
+        return result.scalars().all()
+
+    page_value = page or 1
+    size_value = page_size or size or 10
+    total = await db.scalar(select(func.count()).select_from(query.subquery()))
+    result = await db.execute(
+        query.order_by(DutyItem.date)
+        .offset((page_value - 1) * size_value)
+        .limit(size_value)
+    )
+    return {
+        "items": result.scalars().all(),
+        "total": total or 0,
+        "page": page_value,
+        "pageSize": size_value,
+    }
+
+
+@router.post("", response_model=DutyResponse, dependencies=[Depends(require_permissions(["duty.create"]))])
+async def create_duty(
+    payload: DutyCreate,
+    db: AsyncSession = Depends(get_db),
+):
+    item = DutyItem(
+        date=date.fromisoformat(payload.date),
+        staff_id=payload.staff_id,
+        duty_type=payload.duty_type,
+        contact=payload.contact,
+        note=payload.note
+    )
+    db.add(item)
+    await db.commit()
+    await db.refresh(item)
+    return item
+
+
+@router.put("/{item_id}", response_model=DutyResponse, dependencies=[Depends(require_permissions(["duty.edit"]))])
+async def update_duty(item_id: UUID, payload: DutyUpdate, db: AsyncSession = Depends(get_db)):
+    result = await db.execute(select(DutyItem).where(DutyItem.id == item_id))
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(status_code=404, detail="值班不存在")
+    update_data = payload.model_dump(exclude_unset=True)
+    if "date" in update_data and update_data["date"]:
+        update_data["date"] = date.fromisoformat(update_data["date"])
+    for key, value in update_data.items():
+        setattr(item, key, value)
+    await db.commit()
+    await db.refresh(item)
+    return item
+
+
+@router.delete("/{item_id}", dependencies=[Depends(require_permissions(["duty.delete"]))])
+async def delete_duty(item_id: UUID, db: AsyncSession = Depends(get_db)):
+    result = await db.execute(select(DutyItem).where(DutyItem.id == item_id))
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(status_code=404, detail="值班不存在")
+    await db.delete(item)
+    await db.commit()
+    return {"success": True}

+ 48 - 0
backend/app/routers/roles.py

@@ -0,0 +1,48 @@
+from uuid import UUID
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy import select, delete
+from sqlalchemy.ext.asyncio import AsyncSession
+from sqlalchemy.orm import selectinload
+from backend.app.core.dependencies import require_permissions
+from backend.app.db.session import get_db
+from backend.app.models import Role, RolePermission
+from backend.app.schemas.role import RoleResponse, RoleUpdate
+
+
+router = APIRouter(prefix="/roles", tags=["roles"])
+
+
+@router.get("", response_model=list[RoleResponse], dependencies=[Depends(require_permissions(["users.view"]))])
+async def list_roles(db: AsyncSession = Depends(get_db)):
+    result = await db.execute(select(Role).options(selectinload(Role.permissions)))
+    roles = result.scalars().all()
+    response = []
+    for role in roles:
+        permissions = [perm.permission_code for perm in role.permissions]
+        response.append(RoleResponse(
+            id=role.id,
+            name=role.name,
+            description=role.description,
+            permissions=permissions
+        ))
+    return response
+
+
+@router.put("/{role_id}", response_model=RoleResponse, dependencies=[Depends(require_permissions(["users.assignRole"]))])
+async def update_role(role_id: UUID, payload: RoleUpdate, db: AsyncSession = Depends(get_db)):
+    result = await db.execute(select(Role).where(Role.id == role_id))
+    role = result.scalar_one_or_none()
+    if not role:
+        raise HTTPException(status_code=404, detail="角色不存在")
+
+    await db.execute(delete(RolePermission).where(RolePermission.role_id == role_id))
+    for code in payload.permissions:
+        db.add(RolePermission(role_id=role_id, permission_code=code))
+    await db.commit()
+    await db.refresh(role)
+    return RoleResponse(
+        id=role.id,
+        name=role.name,
+        description=role.description,
+        permissions=payload.permissions
+    )

+ 265 - 0
backend/app/routers/schedule.py

@@ -0,0 +1,265 @@
+from datetime import datetime, date
+from typing import Optional
+from uuid import UUID
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import select, delete
+from sqlalchemy.ext.asyncio import AsyncSession
+from backend.app.core.dependencies import require_permissions, get_current_user
+from backend.app.db.session import get_db
+from backend.app.models import ScheduleItem, AdjustLog, User
+from backend.app.schemas.schedule import (
+    ScheduleCreate,
+    ScheduleResponse,
+    ScheduleUpdate,
+    SwapRequest,
+    SubstituteRequest,
+    StopClinicRequest
+)
+
+
+router = APIRouter(prefix="/schedule", tags=["schedule"])
+
+
+def is_limited_scope(user: User) -> bool:
+    return user.role and user.role.name not in {"管理员", "排班员"}
+
+
+async def ensure_unique_schedule(
+    db: AsyncSession,
+    dept_id: UUID,
+    schedule_date: date,
+    shift_id: Optional[UUID],
+    staff_id: Optional[UUID],
+    exclude_id: Optional[UUID] = None,
+) -> None:
+    if not shift_id or not staff_id:
+        return
+    query = select(ScheduleItem.id).where(
+        ScheduleItem.dept_id == dept_id,
+        ScheduleItem.date == schedule_date,
+        ScheduleItem.shift_id == shift_id,
+        ScheduleItem.staff_id == staff_id,
+    )
+    if exclude_id:
+        query = query.where(ScheduleItem.id != exclude_id)
+    result = await db.execute(query)
+    if result.scalar_one_or_none():
+        raise HTTPException(status_code=409, detail="该人员在此班次已有排班")
+
+
+@router.get("", response_model=list[ScheduleResponse], dependencies=[Depends(require_permissions(["schedule.view"]))])
+async def list_schedule(
+    db: AsyncSession = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+    dept_id: Optional[UUID] = Query(default=None),
+    start: Optional[str] = Query(default=None),
+    end: Optional[str] = Query(default=None),
+):
+    query = select(ScheduleItem)
+    if is_limited_scope(current_user):
+        if not current_user.dept_id:
+            return []
+        query = query.where(ScheduleItem.dept_id == current_user.dept_id)
+    elif dept_id:
+        query = query.where(ScheduleItem.dept_id == dept_id)
+    if start:
+        query = query.where(ScheduleItem.date >= date.fromisoformat(start))
+    if end:
+        query = query.where(ScheduleItem.date <= date.fromisoformat(end))
+    result = await db.execute(query.order_by(ScheduleItem.date))
+    return result.scalars().all()
+
+
+@router.post("", response_model=ScheduleResponse, dependencies=[Depends(require_permissions(["schedule.create"]))])
+async def create_schedule(
+    payload: ScheduleCreate,
+    db: AsyncSession = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+):
+    if is_limited_scope(current_user) and payload.dept_id != current_user.dept_id:
+        raise HTTPException(status_code=403, detail="无权操作其他科室")
+    schedule_date = date.fromisoformat(payload.date)
+    await ensure_unique_schedule(db, payload.dept_id, schedule_date, payload.shift_id, payload.staff_id)
+    item = ScheduleItem(
+        dept_id=payload.dept_id,
+        date=schedule_date,
+        shift_id=payload.shift_id,
+        staff_id=payload.staff_id,
+        tag=payload.tag,
+        note=payload.note,
+        original_staff_id=payload.original_staff_id,
+        substitute_for_id=payload.substitute_for_id,
+        reason=payload.reason
+    )
+    db.add(item)
+    await db.commit()
+    await db.refresh(item)
+    return item
+
+
+@router.put("/{item_id}", response_model=ScheduleResponse, dependencies=[Depends(require_permissions(["schedule.edit"]))])
+async def update_schedule(
+    item_id: UUID,
+    payload: ScheduleUpdate,
+    db: AsyncSession = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+):
+    result = await db.execute(select(ScheduleItem).where(ScheduleItem.id == item_id))
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(status_code=404, detail="排班不存在")
+    if is_limited_scope(current_user) and item.dept_id != current_user.dept_id:
+        raise HTTPException(status_code=403, detail="无权操作其他科室")
+    update_data = payload.model_dump(exclude_unset=True)
+    if update_data:
+        next_shift_id = update_data.get("shift_id", item.shift_id)
+        next_staff_id = update_data.get("staff_id", item.staff_id)
+        await ensure_unique_schedule(db, item.dept_id, item.date, next_shift_id, next_staff_id, exclude_id=item.id)
+    for key, value in update_data.items():
+        setattr(item, key, value)
+    await db.commit()
+    await db.refresh(item)
+    return item
+
+
+@router.delete("/{item_id}", dependencies=[Depends(require_permissions(["schedule.delete"]))])
+async def delete_schedule(
+    item_id: UUID,
+    db: AsyncSession = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+):
+    result = await db.execute(select(ScheduleItem).where(ScheduleItem.id == item_id))
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(status_code=404, detail="排班不存在")
+    if is_limited_scope(current_user) and item.dept_id != current_user.dept_id:
+        raise HTTPException(status_code=403, detail="无权操作其他科室")
+    await db.delete(item)
+    await db.commit()
+    return {"success": True}
+
+
+@router.post("/swap", dependencies=[Depends(require_permissions(["schedule.swap"]))])
+async def swap_schedule(
+    payload: SwapRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+):
+    result = await db.execute(select(ScheduleItem).where(ScheduleItem.id.in_([payload.source_id, payload.target_id])))
+    items = result.scalars().all()
+    if len(items) != 2:
+        raise HTTPException(status_code=404, detail="排班记录不存在")
+    item_a, item_b = items
+    if is_limited_scope(current_user) and (item_a.dept_id != current_user.dept_id or item_b.dept_id != current_user.dept_id):
+        raise HTTPException(status_code=403, detail="无权操作其他科室")
+    item_a.staff_id, item_b.staff_id = item_b.staff_id, item_a.staff_id
+    db.add(AdjustLog(
+        dept_id=item_a.dept_id,
+        date=item_a.date,
+        type="swap",
+        operator=current_user.name,
+        created_at=datetime.utcnow(),
+        note="调班"
+    ))
+    await db.commit()
+    return {"success": True}
+
+
+@router.post("/substitute", dependencies=[Depends(require_permissions(["schedule.substitute"]))])
+async def substitute_schedule(
+    payload: SubstituteRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+):
+    result = await db.execute(select(ScheduleItem).where(ScheduleItem.id == payload.item_id))
+    item = result.scalar_one_or_none()
+    if not item:
+        raise HTTPException(status_code=404, detail="排班不存在")
+    if is_limited_scope(current_user) and item.dept_id != current_user.dept_id:
+        raise HTTPException(status_code=403, detail="无权操作其他科室")
+    await ensure_unique_schedule(db, item.dept_id, item.date, item.shift_id, payload.new_staff_id)
+    item.tag = "substituted"
+    item.original_staff_id = item.staff_id
+
+    substitute_item = ScheduleItem(
+        dept_id=item.dept_id,
+        date=item.date,
+        shift_id=item.shift_id,
+        staff_id=payload.new_staff_id,
+        tag="substitute",
+        substitute_for_id=item.id,
+        original_staff_id=item.original_staff_id,
+        reason=payload.reason
+    )
+    db.add(substitute_item)
+    db.add(AdjustLog(
+        dept_id=item.dept_id,
+        date=item.date,
+        type="substitute",
+        operator=current_user.name,
+        created_at=datetime.utcnow(),
+        note=payload.reason
+    ))
+    await db.commit()
+    await db.refresh(substitute_item)
+    return {"success": True, "substitute_id": str(substitute_item.id)}
+
+
+@router.post("/cancel-substitute", dependencies=[Depends(require_permissions(["schedule.substitute"]))])
+async def cancel_substitute(
+    item_id: UUID,
+    db: AsyncSession = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+):
+    result = await db.execute(select(ScheduleItem).where(ScheduleItem.id == item_id))
+    substitute_item = result.scalar_one_or_none()
+    if not substitute_item:
+        raise HTTPException(status_code=404, detail="替班记录不存在")
+    if is_limited_scope(current_user) and substitute_item.dept_id != current_user.dept_id:
+        raise HTTPException(status_code=403, detail="无权操作其他科室")
+
+    if substitute_item.substitute_for_id:
+        original_result = await db.execute(select(ScheduleItem).where(ScheduleItem.id == substitute_item.substitute_for_id))
+        original_item = original_result.scalar_one_or_none()
+        if original_item:
+            original_item.tag = "normal"
+
+    await db.delete(substitute_item)
+    await db.commit()
+    return {"success": True}
+
+
+@router.post("/stop-clinic", dependencies=[Depends(require_permissions(["schedule.stopClinic"]))])
+async def stop_clinic(
+    payload: StopClinicRequest,
+    db: AsyncSession = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+):
+    if is_limited_scope(current_user) and payload.dept_id != current_user.dept_id:
+        raise HTTPException(status_code=403, detail="无权操作其他科室")
+    await db.execute(
+        delete(ScheduleItem)
+        .where(
+            ScheduleItem.dept_id == payload.dept_id,
+            ScheduleItem.date == date.fromisoformat(payload.date)
+        )
+    )
+    stop_item = ScheduleItem(
+        dept_id=payload.dept_id,
+        date=date.fromisoformat(payload.date),
+        shift_id=None,
+        staff_id=current_user.id,
+        tag="stopClinic",
+        note=payload.reason
+    )
+    db.add(stop_item)
+    db.add(AdjustLog(
+        dept_id=payload.dept_id,
+        date=payload.date,
+        type="stopClinic",
+        operator=current_user.name,
+        created_at=datetime.utcnow(),
+        note=payload.reason
+    ))
+    await db.commit()
+    return {"success": True}

+ 122 - 0
backend/app/routers/shifts.py

@@ -0,0 +1,122 @@
+from typing import Optional
+from uuid import UUID
+from datetime import time
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import and_, extract, func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from backend.app.core.dependencies import require_any_permissions, require_permissions
+from backend.app.db.session import get_db
+from backend.app.models import Shift
+from backend.app.schemas.pagination import PaginatedResponse
+from backend.app.schemas.shift import ShiftCreate, ShiftResponse, ShiftUpdate
+
+
+router = APIRouter(prefix="/shifts", tags=["shifts"])
+
+
+@router.get(
+    "",
+    response_model=list[ShiftResponse] | PaginatedResponse[ShiftResponse],
+    dependencies=[Depends(require_any_permissions(["shifts.view", "schedule.view"]))],
+)
+async def list_shifts(
+    db: AsyncSession = Depends(get_db),
+    keyword: Optional[str] = Query(default=None),
+    status: Optional[str] = Query(default=None),
+    shift_type: Optional[str] = Query(default=None, alias="type"),
+    page: Optional[int] = Query(default=None, ge=1),
+    page_size: Optional[int] = Query(default=None, ge=1, le=200, alias="pageSize"),
+    size: Optional[int] = Query(default=None, ge=1, le=200),
+):
+    query = select(Shift)
+    if keyword:
+        like = f"%{keyword.strip()}%"
+        query = query.where(or_(Shift.name.ilike(like), Shift.remark.ilike(like)))
+    if status == "enabled":
+        query = query.where(Shift.enabled.is_(True))
+    elif status == "disabled":
+        query = query.where(Shift.enabled.is_(False))
+    if shift_type == "cross":
+        query = query.where(Shift.end_time < Shift.start_time)
+    elif shift_type == "night":
+        query = query.where(
+            or_(
+                Shift.end_time < Shift.start_time,
+                extract("hour", Shift.start_time) >= 18,
+                extract("hour", Shift.start_time) < 6,
+            )
+        )
+    elif shift_type == "day":
+        query = query.where(
+            and_(
+                Shift.end_time >= Shift.start_time,
+                extract("hour", Shift.start_time) >= 6,
+                extract("hour", Shift.start_time) < 18,
+            )
+        )
+
+    if page is None and page_size is None and size is None:
+        result = await db.execute(query.order_by(Shift.name))
+        return result.scalars().all()
+
+    page_value = page or 1
+    size_value = page_size or size or 10
+    total = await db.scalar(select(func.count()).select_from(query.subquery()))
+    result = await db.execute(
+        query.order_by(Shift.name)
+        .offset((page_value - 1) * size_value)
+        .limit(size_value)
+    )
+    return {
+        "items": result.scalars().all(),
+        "total": total or 0,
+        "page": page_value,
+        "pageSize": size_value,
+    }
+
+
+@router.post("", response_model=ShiftResponse, dependencies=[Depends(require_permissions(["shifts.create"]))])
+async def create_shift(payload: ShiftCreate, db: AsyncSession = Depends(get_db)):
+    start_time = time.fromisoformat(payload.start_time)
+    end_time = time.fromisoformat(payload.end_time)
+    shift = Shift(
+        name=payload.name,
+        start_time=start_time,
+        end_time=end_time,
+        enabled=payload.enabled,
+        remark=payload.remark
+    )
+    db.add(shift)
+    await db.commit()
+    await db.refresh(shift)
+    return shift
+
+
+@router.put("/{shift_id}", response_model=ShiftResponse, dependencies=[Depends(require_permissions(["shifts.edit"]))])
+async def update_shift(shift_id: UUID, payload: ShiftUpdate, db: AsyncSession = Depends(get_db)):
+    result = await db.execute(select(Shift).where(Shift.id == shift_id))
+    shift = result.scalar_one_or_none()
+    if not shift:
+        raise HTTPException(status_code=404, detail="班次不存在")
+    update_data = payload.model_dump(exclude_unset=True)
+    if "start_time" in update_data and update_data["start_time"]:
+        update_data["start_time"] = time.fromisoformat(update_data["start_time"])
+    if "end_time" in update_data and update_data["end_time"]:
+        update_data["end_time"] = time.fromisoformat(update_data["end_time"])
+    for key, value in update_data.items():
+        setattr(shift, key, value)
+    await db.commit()
+    await db.refresh(shift)
+    return shift
+
+
+@router.patch("/{shift_id}/toggle", response_model=ShiftResponse, dependencies=[Depends(require_permissions(["shifts.edit"]))])
+async def toggle_shift(shift_id: UUID, enabled: bool, db: AsyncSession = Depends(get_db)):
+    result = await db.execute(select(Shift).where(Shift.id == shift_id))
+    shift = result.scalar_one_or_none()
+    if not shift:
+        raise HTTPException(status_code=404, detail="班次不存在")
+    shift.enabled = enabled
+    await db.commit()
+    await db.refresh(shift)
+    return shift

+ 70 - 0
backend/app/routers/statistics.py

@@ -0,0 +1,70 @@
+from datetime import date
+from fastapi import APIRouter, Depends, Query
+from sqlalchemy import func, select, case
+from sqlalchemy.ext.asyncio import AsyncSession
+from backend.app.core.dependencies import require_permissions, get_current_user
+from backend.app.db.session import get_db
+from backend.app.models import ScheduleItem, User
+
+
+router = APIRouter(prefix="/statistics", tags=["statistics"])
+
+
+def is_limited_scope(user: User) -> bool:
+    return user.role and user.role.name not in {"管理员", "排班员"}
+
+
+@router.get("/schedule", dependencies=[Depends(require_permissions(["statistics.view"]))])
+async def get_schedule_stats(
+    db: AsyncSession = Depends(get_db),
+    current_user: User = Depends(get_current_user),
+    dept_id: str | None = Query(default=None),
+    start: date | None = Query(default=None),
+    end: date | None = Query(default=None),
+):
+    conditions = []
+    if is_limited_scope(current_user):
+        if not current_user.dept_id:
+            return {"byDept": {}, "byStaff": {}, "byShift": {}, "total": 0}
+        conditions.append(ScheduleItem.dept_id == current_user.dept_id)
+    elif dept_id:
+        conditions.append(ScheduleItem.dept_id == dept_id)
+    conditions.append(ScheduleItem.tag != "stopClinic")
+    if start:
+        conditions.append(ScheduleItem.date >= start)
+    if end:
+        conditions.append(ScheduleItem.date <= end)
+
+    total_result = await db.execute(
+        select(func.count()).select_from(select(ScheduleItem).where(*conditions).subquery())
+    )
+    total = total_result.scalar_one() or 0
+
+    by_dept = await db.execute(
+        select(ScheduleItem.dept_id, func.count())
+        .where(*conditions)
+        .group_by(ScheduleItem.dept_id)
+    )
+
+    by_shift = await db.execute(
+        select(ScheduleItem.shift_id, func.count())
+        .where(*conditions)
+        .group_by(ScheduleItem.shift_id)
+    )
+
+    by_staff = await db.execute(
+        select(
+            ScheduleItem.staff_id,
+            func.count(case((ScheduleItem.tag == "substitute", 1))).label("substitute"),
+            func.count(case((ScheduleItem.tag != "substitute", 1))).label("normal"),
+        )
+        .where(*conditions)
+        .group_by(ScheduleItem.staff_id)
+    )
+
+    return {
+        "byDept": {str(row[0]): row[1] for row in by_dept.fetchall()},
+        "byShift": {str(row[0]): row[1] for row in by_shift.fetchall()},
+        "byStaff": {str(row[0]): {"normal": row[2], "substitute": row[1]} for row in by_staff.fetchall()},
+        "total": total
+    }

+ 23 - 0
backend/app/routers/sync.py

@@ -0,0 +1,23 @@
+from fastapi import APIRouter, Depends, HTTPException
+from sqlalchemy.ext.asyncio import AsyncSession
+
+from backend.app.core.dependencies import require_permissions
+from backend.app.db.session import get_db
+from backend.app.services.center_sync import get_sync_status, get_sync_token, trigger_center_sync
+
+
+router = APIRouter(prefix="/sync", tags=["sync"])
+
+
+@router.post("/center", dependencies=[Depends(require_permissions(["users.view"]))])
+async def sync_center(db: AsyncSession = Depends(get_db)):
+    token = await get_sync_token(db)
+    if not token:
+        raise HTTPException(status_code=400, detail="未找到中台同步 token,请先通过带 token 的地址登录")
+    await trigger_center_sync(token, restart=True, wait=False)
+    return {"success": True}
+
+
+@router.get("/center/status", dependencies=[Depends(require_permissions(["users.view"]))])
+async def sync_center_status(db: AsyncSession = Depends(get_db)):
+    return await get_sync_status(db)

+ 166 - 0
backend/app/routers/users.py

@@ -0,0 +1,166 @@
+from typing import Optional
+from uuid import UUID
+from fastapi import APIRouter, Depends, HTTPException, Query
+from sqlalchemy import func, or_, select
+from sqlalchemy.ext.asyncio import AsyncSession
+from backend.app.core.dependencies import get_current_user, require_any_permissions, require_permissions
+from backend.app.core.security import hash_password
+from backend.app.db.session import get_db
+from backend.app.models import RolePermission, User
+from backend.app.schemas.pagination import PaginatedResponse
+from backend.app.schemas.user import CurrentUserResponse, UserCreate, UserResponse, UserUpdate
+
+
+router = APIRouter(prefix="/users", tags=["users"])
+
+
+def is_limited_scope(user: User) -> bool:
+    return user.role and user.role.name not in {"管理员", "排班员"}
+
+
+@router.get("/me", response_model=CurrentUserResponse)
+async def get_current_user_profile(
+    current_user: User = Depends(get_current_user),
+    db: AsyncSession = Depends(get_db),
+):
+    result = await db.execute(
+        select(RolePermission.permission_code).where(RolePermission.role_id == current_user.role_id)
+    )
+    permissions = [row[0] for row in result.fetchall()]
+    return {
+        "id": current_user.id,
+        "name": current_user.name,
+        "account": current_user.account,
+        "phone": current_user.phone,
+        "title": current_user.title,
+        "avatar": current_user.avatar,
+        "role_id": current_user.role_id,
+        "campus_id": current_user.campus_id,
+        "dept_id": current_user.dept_id,
+        "status": current_user.status,
+        "permissions": permissions,
+    }
+
+
+@router.get(
+    "",
+    response_model=list[UserResponse] | PaginatedResponse[UserResponse],
+)
+async def list_users(
+    db: AsyncSession = Depends(get_db),
+    current_user: User = Depends(require_any_permissions(["users.view", "schedule.view", "duty.view"])),
+    keyword: Optional[str] = Query(default=None),
+    campus_id: Optional[UUID] = Query(default=None),
+    dept_id: Optional[UUID] = Query(default=None),
+    role_id: Optional[UUID] = Query(default=None),
+    status: Optional[str] = Query(default=None),
+    page: Optional[int] = Query(default=None, ge=1),
+    page_size: Optional[int] = Query(default=None, ge=1, le=200, alias="pageSize"),
+    size: Optional[int] = Query(default=None, ge=1, le=200),
+):
+    query = select(User)
+    if is_limited_scope(current_user):
+        if current_user.dept_id:
+            dept_id = current_user.dept_id
+        elif current_user.campus_id:
+            campus_id = current_user.campus_id
+        else:
+            if page is None and page_size is None and size is None:
+                return []
+            return {"items": [], "total": 0, "page": page or 1, "pageSize": page_size or size or 10}
+
+    if keyword:
+        like = f"%{keyword.strip()}%"
+        query = query.where(
+            or_(
+                User.name.ilike(like),
+                User.account.ilike(like),
+                User.phone.ilike(like),
+                User.title.ilike(like),
+            )
+        )
+    if campus_id:
+        query = query.where(User.campus_id == campus_id)
+    if dept_id:
+        query = query.where(User.dept_id == dept_id)
+    if role_id:
+        query = query.where(User.role_id == role_id)
+    if status:
+        query = query.where(User.status == status)
+    if page is None and page_size is None and size is None:
+        result = await db.execute(query.order_by(User.created_at.desc()))
+        return result.scalars().all()
+
+    page_value = page or 1
+    size_value = page_size or size or 10
+    total = await db.scalar(select(func.count()).select_from(query.subquery()))
+    result = await db.execute(
+        query.order_by(User.created_at.desc())
+        .offset((page_value - 1) * size_value)
+        .limit(size_value)
+    )
+    return {
+        "items": result.scalars().all(),
+        "total": total or 0,
+        "page": page_value,
+        "pageSize": size_value,
+    }
+
+
+@router.post("", response_model=UserResponse, dependencies=[Depends(require_permissions(["users.create"]))])
+async def create_user(payload: UserCreate, db: AsyncSession = Depends(get_db)):
+    exists = await db.execute(select(User.id).where(User.account == payload.account))
+    if exists.scalar_one_or_none():
+        raise HTTPException(status_code=409, detail="账号已存在")
+    user = User(
+        name=payload.name,
+        account=payload.account,
+        phone=payload.phone,
+        title=payload.title,
+        avatar=payload.avatar,
+        role_id=payload.role_id,
+        campus_id=payload.campus_id,
+        dept_id=payload.dept_id,
+        status=payload.status,
+        password_hash=hash_password(payload.password),
+        token_version=1
+    )
+    db.add(user)
+    await db.commit()
+    await db.refresh(user)
+    return user
+
+
+@router.put("/{user_id}", response_model=UserResponse, dependencies=[Depends(require_permissions(["users.edit"]))])
+async def update_user(user_id: UUID, payload: UserUpdate, db: AsyncSession = Depends(get_db)):
+    result = await db.execute(select(User).where(User.id == user_id))
+    user = result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(status_code=404, detail="人员不存在")
+    update_data = payload.model_dump(exclude_unset=True)
+    bump_token = False
+    if "password" in update_data:
+        update_data["password_hash"] = hash_password(update_data.pop("password"))
+        bump_token = True
+    if "status" in update_data and update_data["status"] != user.status:
+        bump_token = True
+    if "role_id" in update_data and update_data["role_id"] != user.role_id:
+        bump_token = True
+    for key, value in update_data.items():
+        setattr(user, key, value)
+    if bump_token:
+        user.token_version = (user.token_version or 1) + 1
+    await db.commit()
+    await db.refresh(user)
+    return user
+
+
+@router.delete("/{user_id}", dependencies=[Depends(require_permissions(["users.delete"]))])
+async def delete_user(user_id: UUID, db: AsyncSession = Depends(get_db)):
+    result = await db.execute(select(User).where(User.id == user_id))
+    user = result.scalar_one_or_none()
+    if not user:
+        raise HTTPException(status_code=404, detail="人员不存在")
+    await db.delete(user)
+    await db.commit()
+    return {"success": True}

+ 44 - 0
backend/app/routers/ws.py

@@ -0,0 +1,44 @@
+from fastapi import APIRouter, HTTPException, Query, WebSocket, WebSocketDisconnect, status
+from sqlalchemy import select
+
+from backend.app.core.dependencies import get_current_user_by_token
+from backend.app.db.session import SessionLocal
+from backend.app.models import RolePermission
+from backend.app.services.center_sync import get_sync_status
+from backend.app.services.sync_ws import sync_broadcaster
+
+
+router = APIRouter()
+
+
+@router.websocket("/ws/sync")
+async def sync_status_socket(websocket: WebSocket, token: str | None = Query(default=None)):
+    if not token:
+        await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
+        return
+
+    async with SessionLocal() as db:
+        try:
+            user = await get_current_user_by_token(token, db)
+        except HTTPException:
+            await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
+            return
+
+        result = await db.execute(
+            select(RolePermission.permission_code).where(RolePermission.role_id == user.role_id)
+        )
+        permissions = {row[0] for row in result.fetchall()}
+        if "users.view" not in permissions:
+            await websocket.close(code=status.WS_1008_POLICY_VIOLATION)
+            return
+
+        await sync_broadcaster.connect(websocket)
+        await sync_broadcaster.send_status(websocket, await get_sync_status(db))
+
+        try:
+            while True:
+                await websocket.receive_text()
+        except WebSocketDisconnect:
+            pass
+        finally:
+            await sync_broadcaster.disconnect(websocket)

+ 40 - 0
backend/app/schemas/__init__.py

@@ -0,0 +1,40 @@
+from .auth import Token, TokenPayload, LoginRequest, RefreshRequest, SSOExchangeRequest
+from .user import UserBase, UserCreate, UserUpdate, UserResponse
+from .role import RoleResponse, RoleUpdate
+from .campus import CampusCreate, CampusResponse, CampusUpdate
+from .department import DepartmentCreate, DepartmentResponse, DepartmentUpdate
+from .shift import ShiftCreate, ShiftResponse, ShiftUpdate
+from .schedule import ScheduleCreate, ScheduleResponse, ScheduleUpdate, SwapRequest, SubstituteRequest, StopClinicRequest
+from .duty import DutyCreate, DutyResponse, DutyUpdate
+
+__all__ = [
+    "Token",
+    "TokenPayload",
+    "LoginRequest",
+    "RefreshRequest",
+    "SSOExchangeRequest",
+    "UserBase",
+    "UserCreate",
+    "UserUpdate",
+    "UserResponse",
+    "RoleResponse",
+    "RoleUpdate",
+    "CampusCreate",
+    "CampusResponse",
+    "CampusUpdate",
+    "DepartmentCreate",
+    "DepartmentResponse",
+    "DepartmentUpdate",
+    "ShiftCreate",
+    "ShiftResponse",
+    "ShiftUpdate",
+    "ScheduleCreate",
+    "ScheduleResponse",
+    "ScheduleUpdate",
+    "SwapRequest",
+    "SubstituteRequest",
+    "StopClinicRequest",
+    "DutyCreate",
+    "DutyResponse",
+    "DutyUpdate",
+]

BIN
backend/app/schemas/__pycache__/__init__.cpython-313.pyc


BIN
backend/app/schemas/__pycache__/adjust_log.cpython-313.pyc


BIN
backend/app/schemas/__pycache__/auth.cpython-313.pyc


BIN
backend/app/schemas/__pycache__/campus.cpython-313.pyc


BIN
backend/app/schemas/__pycache__/department.cpython-313.pyc


BIN
backend/app/schemas/__pycache__/duty.cpython-313.pyc


BIN
backend/app/schemas/__pycache__/pagination.cpython-313.pyc


BIN
backend/app/schemas/__pycache__/role.cpython-313.pyc


BIN
backend/app/schemas/__pycache__/schedule.cpython-313.pyc


BIN
backend/app/schemas/__pycache__/shift.cpython-313.pyc


BIN
backend/app/schemas/__pycache__/user.cpython-313.pyc


+ 17 - 0
backend/app/schemas/adjust_log.py

@@ -0,0 +1,17 @@
+from datetime import date, datetime
+from pydantic import BaseModel
+from typing import Optional
+from uuid import UUID
+
+
+class AdjustLogResponse(BaseModel):
+    id: UUID
+    dept_id: UUID
+    date: date
+    type: str
+    operator: str
+    created_at: datetime
+    note: Optional[str] = None
+
+    class Config:
+        from_attributes = True

+ 26 - 0
backend/app/schemas/auth.py

@@ -0,0 +1,26 @@
+from pydantic import BaseModel
+
+
+class LoginRequest(BaseModel):
+    account: str
+    password: str
+
+
+class RefreshRequest(BaseModel):
+    refresh_token: str
+
+
+class SSOExchangeRequest(BaseModel):
+    token: str
+
+
+class Token(BaseModel):
+    access_token: str
+    refresh_token: str
+    token_type: str = "bearer"
+
+
+class TokenPayload(BaseModel):
+    sub: str
+    type: str
+    ver: int | None = None

+ 18 - 0
backend/app/schemas/campus.py

@@ -0,0 +1,18 @@
+from pydantic import BaseModel
+from uuid import UUID
+
+
+class CampusCreate(BaseModel):
+    name: str
+
+
+class CampusUpdate(BaseModel):
+    name: str
+
+
+class CampusResponse(BaseModel):
+    id: UUID
+    name: str
+
+    class Config:
+        from_attributes = True

+ 21 - 0
backend/app/schemas/department.py

@@ -0,0 +1,21 @@
+from pydantic import BaseModel
+from uuid import UUID
+
+
+class DepartmentCreate(BaseModel):
+    campus_id: UUID
+    name: str
+
+
+class DepartmentUpdate(BaseModel):
+    campus_id: UUID
+    name: str
+
+
+class DepartmentResponse(BaseModel):
+    id: UUID
+    campus_id: UUID
+    name: str
+
+    class Config:
+        from_attributes = True

+ 32 - 0
backend/app/schemas/duty.py

@@ -0,0 +1,32 @@
+from datetime import date
+from pydantic import BaseModel
+from typing import Optional
+from uuid import UUID
+
+
+class DutyCreate(BaseModel):
+    date: str
+    staff_id: UUID
+    duty_type: str
+    contact: Optional[str] = None
+    note: Optional[str] = None
+
+
+class DutyUpdate(BaseModel):
+    date: Optional[str] = None
+    staff_id: Optional[UUID] = None
+    duty_type: Optional[str] = None
+    contact: Optional[str] = None
+    note: Optional[str] = None
+
+
+class DutyResponse(BaseModel):
+    id: UUID
+    date: date
+    staff_id: UUID
+    duty_type: str
+    contact: Optional[str] = None
+    note: Optional[str] = None
+
+    class Config:
+        from_attributes = True

+ 12 - 0
backend/app/schemas/pagination.py

@@ -0,0 +1,12 @@
+from typing import Generic, TypeVar
+from pydantic import BaseModel
+
+
+T = TypeVar("T")
+
+
+class PaginatedResponse(BaseModel, Generic[T]):
+    items: list[T]
+    total: int
+    page: int
+    pageSize: int

+ 17 - 0
backend/app/schemas/role.py

@@ -0,0 +1,17 @@
+from pydantic import BaseModel
+from typing import List, Optional
+from uuid import UUID
+
+
+class RoleResponse(BaseModel):
+    id: UUID
+    name: str
+    description: Optional[str] = None
+    permissions: List[str] = []
+
+    class Config:
+        from_attributes = True
+
+
+class RoleUpdate(BaseModel):
+    permissions: List[str]

+ 59 - 0
backend/app/schemas/schedule.py

@@ -0,0 +1,59 @@
+from datetime import date
+from pydantic import BaseModel
+from typing import Optional
+from uuid import UUID
+
+
+class ScheduleCreate(BaseModel):
+    dept_id: UUID
+    date: str
+    shift_id: Optional[UUID] = None
+    staff_id: UUID
+    tag: str = "normal"
+    note: Optional[str] = None
+    original_staff_id: Optional[UUID] = None
+    substitute_for_id: Optional[UUID] = None
+    reason: Optional[str] = None
+
+
+class ScheduleUpdate(BaseModel):
+    shift_id: Optional[UUID] = None
+    staff_id: Optional[UUID] = None
+    tag: Optional[str] = None
+    note: Optional[str] = None
+    original_staff_id: Optional[UUID] = None
+    substitute_for_id: Optional[UUID] = None
+    reason: Optional[str] = None
+
+
+class ScheduleResponse(BaseModel):
+    id: UUID
+    dept_id: UUID
+    date: date
+    shift_id: Optional[UUID] = None
+    staff_id: UUID
+    tag: str
+    note: Optional[str] = None
+    original_staff_id: Optional[UUID] = None
+    substitute_for_id: Optional[UUID] = None
+    reason: Optional[str] = None
+
+    class Config:
+        from_attributes = True
+
+
+class SwapRequest(BaseModel):
+    source_id: UUID
+    target_id: UUID
+
+
+class SubstituteRequest(BaseModel):
+    item_id: UUID
+    new_staff_id: UUID
+    reason: Optional[str] = None
+
+
+class StopClinicRequest(BaseModel):
+    dept_id: UUID
+    date: str
+    reason: Optional[str] = None

+ 32 - 0
backend/app/schemas/shift.py

@@ -0,0 +1,32 @@
+from datetime import time
+from pydantic import BaseModel
+from typing import Optional
+from uuid import UUID
+
+
+class ShiftCreate(BaseModel):
+    name: str
+    start_time: str
+    end_time: str
+    enabled: bool = True
+    remark: Optional[str] = None
+
+
+class ShiftUpdate(BaseModel):
+    name: Optional[str] = None
+    start_time: Optional[str] = None
+    end_time: Optional[str] = None
+    enabled: Optional[bool] = None
+    remark: Optional[str] = None
+
+
+class ShiftResponse(BaseModel):
+    id: UUID
+    name: str
+    start_time: time
+    end_time: time
+    enabled: bool
+    remark: Optional[str] = None
+
+    class Config:
+        from_attributes = True

+ 42 - 0
backend/app/schemas/user.py

@@ -0,0 +1,42 @@
+from pydantic import BaseModel, Field
+from typing import Optional
+from uuid import UUID
+
+
+class UserBase(BaseModel):
+    name: str
+    account: str
+    phone: Optional[str] = None
+    title: Optional[str] = None
+    avatar: Optional[str] = None
+    role_id: UUID
+    campus_id: Optional[UUID] = None
+    dept_id: Optional[UUID] = None
+    status: str = Field(default="active")
+
+
+class UserCreate(UserBase):
+    password: str
+
+
+class UserUpdate(BaseModel):
+    name: Optional[str] = None
+    phone: Optional[str] = None
+    title: Optional[str] = None
+    avatar: Optional[str] = None
+    role_id: Optional[UUID] = None
+    campus_id: Optional[UUID] = None
+    dept_id: Optional[UUID] = None
+    status: Optional[str] = None
+    password: Optional[str] = None
+
+
+class UserResponse(UserBase):
+    id: UUID
+
+    class Config:
+        from_attributes = True
+
+
+class CurrentUserResponse(UserResponse):
+    permissions: list[str] = []

Неке датотеке нису приказане због велике количине промена