From 74feca446718f7cf86abe113b40d06d507d730bb Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Fri, 8 May 2026 22:13:12 +0800 Subject: [PATCH] feat: harden auth and study workflows - replace plaintext login and unlock requests with RSA-OAEP/AES-GCM encrypted payloads - add login challenge replay protection, production RSA key validation, and auth tests - wire compose to environment-driven dev/prod settings without committing local secrets - update setup-config smoke scripts and Postman docs for encrypted login - add visit schedule migrations/tests and update study/subject setup workflows --- README.md | 6 +- ...20260508_01_replace_global_visit_window.py | 43 ++ ...60508_02_remove_summary_objective_notes.py | 47 ++ .../20260508_03_add_subject_baseline_date.py | 26 + backend/app/api/v1/auth.py | 94 ++- backend/app/api/v1/studies.py | 29 +- backend/app/api/v1/subjects.py | 6 +- backend/app/api/v1/visits.py | 7 +- backend/app/core/config.py | 7 +- backend/app/core/login_crypto.py | 138 ++++ backend/app/crud/study.py | 7 +- backend/app/crud/subject.py | 70 +- backend/app/crud/visit.py | 123 +++- backend/app/main.py | 2 + backend/app/models/study.py | 9 +- backend/app/models/subject.py | 1 + backend/app/schemas/study.py | 63 +- backend/app/schemas/study_setup_config.py | 29 +- backend/app/schemas/subject.py | 4 + backend/app/schemas/user.py | 6 +- backend/requirements.txt | 1 + backend/scripts/smoke_setup_config.py | 52 +- backend/tests/test_login_crypto.py | 39 ++ backend/tests/test_registration.py | 129 +++- backend/tests/test_visit_schedule.py | 144 ++++ database/init.sql | 32 +- docker-compose.yaml | 8 + docs/README.md | 1 + .../guides/branch-environment-installation.md | 328 +++++++++ docs/guides/setup-config-api.md | 20 +- .../setup-config.postman_collection.json | 32 +- docs/setup-config-curl-smoke.sh | 63 +- frontend/src/api/auth.ts | 5 +- frontend/src/api/authClient.ts | 18 +- frontend/src/locales/zh-CN.ts | 3 +- frontend/src/session/sessionManager.ts | 15 +- frontend/src/store/auth.ts | 15 +- frontend/src/types/api.ts | 26 +- frontend/src/types/setupConfig.ts | 14 +- frontend/src/utils/auth.ts | 28 - frontend/src/utils/loginCrypto.test.ts | 18 + frontend/src/utils/loginCrypto.ts | 56 ++ frontend/src/utils/setupPublishWorkflow.ts | 7 +- frontend/src/views/Login.vue | 36 +- frontend/src/views/admin/ProjectDetail.vue | 658 +++++++++++++++--- frontend/src/views/subjects/SubjectDetail.vue | 138 +++- frontend/src/views/subjects/SubjectForm.vue | 354 +++++++--- 47 files changed, 2423 insertions(+), 534 deletions(-) create mode 100644 backend/alembic/versions/20260508_01_replace_global_visit_window.py create mode 100644 backend/alembic/versions/20260508_02_remove_summary_objective_notes.py create mode 100644 backend/alembic/versions/20260508_03_add_subject_baseline_date.py create mode 100644 backend/app/core/login_crypto.py create mode 100644 backend/tests/test_login_crypto.py create mode 100644 backend/tests/test_visit_schedule.py create mode 100644 docs/guides/branch-environment-installation.md create mode 100644 frontend/src/utils/loginCrypto.test.ts create mode 100644 frontend/src/utils/loginCrypto.ts diff --git a/README.md b/README.md index 62f45e77..ed77cecb 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,9 @@ - 对外入口:`nginx` 提供前端静态资源,并同域反代后端 API - 数据库 schema 来源:Alembic migration,不再依赖 `database/init.sql` - 默认无任何业务预置数据;生产初始化只确保固定管理员 `admin@huapont.cn / admin123` 存在 +- 生产环境必须配置 `LOGIN_RSA_PRIVATE_KEY`;登录密码传输使用 RSA-OAEP 包裹 AES-GCM 密钥,数据库仍只保存 bcrypt 哈希 +- 浏览器端加密依赖 WebCrypto 安全上下文;生产访问必须使用 HTTPS,本地 `localhost` 例外 +- 当前 challenge 缓存在后端进程内,默认最多保留 1000 个;多实例或多 worker 部署需改为共享缓存或启用粘性会话 - 验证方式: - `docker compose config` - `curl -i http://127.0.0.1:8888/` @@ -34,6 +37,7 @@ ## 仓库治理文档 - 分支治理规范:`docs/branch-governance.md` +- 分支环境安装配置:`docs/guides/branch-environment-installation.md` - 发布检查清单:`docs/guides/release-checklist.md` ## 本地配置 @@ -57,6 +61,6 @@ > ADMIN 具备所有 PM 权限,项目内操作与 PM 同步放行。 ## 注意 -- 登录使用邮箱 + 密码,未审核/已拒绝/已停用账号无法登录。 +- 登录使用邮箱 + 密码,前端会先获取登录公钥并加密提交;未审核/已拒绝/已停用账号无法登录。 - 令牌与当前项目上下文保存在浏览器 LocalStorage 中,清除后需重新登录/选择项目。 - 若后端重启,确认数据库容器仍健康,前端会弹出错误提示。 diff --git a/backend/alembic/versions/20260508_01_replace_global_visit_window.py b/backend/alembic/versions/20260508_01_replace_global_visit_window.py new file mode 100644 index 00000000..08f775c4 --- /dev/null +++ b/backend/alembic/versions/20260508_01_replace_global_visit_window.py @@ -0,0 +1,43 @@ +"""replace global visit window with per-visit schedule + +Revision ID: 20260508_01 +Revises: 20260331_01 +Create Date: 2026-05-08 11:30:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +revision: str = "20260508_01" +down_revision: Union[str, None] = "20260331_01" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column( + "studies", + sa.Column( + "visit_schedule", + postgresql.JSONB(astext_type=sa.Text()), + nullable=False, + server_default=sa.text("'[]'::jsonb"), + ), + ) + op.drop_column("studies", "visit_window_end_offset") + op.drop_column("studies", "visit_window_start_offset") + op.drop_column("studies", "visit_total") + op.drop_column("studies", "visit_interval_days") + + +def downgrade() -> None: + op.add_column("studies", sa.Column("visit_interval_days", sa.Integer(), nullable=True)) + op.add_column("studies", sa.Column("visit_total", sa.Integer(), nullable=True)) + op.add_column("studies", sa.Column("visit_window_start_offset", sa.Integer(), nullable=True)) + op.add_column("studies", sa.Column("visit_window_end_offset", sa.Integer(), nullable=True)) + op.drop_column("studies", "visit_schedule") diff --git a/backend/alembic/versions/20260508_02_remove_summary_objective_notes.py b/backend/alembic/versions/20260508_02_remove_summary_objective_notes.py new file mode 100644 index 00000000..8a602aa4 --- /dev/null +++ b/backend/alembic/versions/20260508_02_remove_summary_objective_notes.py @@ -0,0 +1,47 @@ +"""remove summary and objective note fields + +Revision ID: 20260508_02 +Revises: 20260508_01 +Create Date: 2026-05-08 15:55:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260508_02" +down_revision: Union[str, None] = "20260508_01" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + bind.execute( + sa.text( + """ + UPDATE study_setup_configs + SET published_project_snapshot = published_project_snapshot - 'summary_note' - 'objective_note' + WHERE published_project_snapshot IS NOT NULL + """ + ) + ) + bind.execute( + sa.text( + """ + UPDATE study_setup_config_versions + SET published_project_snapshot = published_project_snapshot - 'summary_note' - 'objective_note' + WHERE published_project_snapshot IS NOT NULL + """ + ) + ) + op.drop_column("studies", "objective_note") + op.drop_column("studies", "summary_note") + + +def downgrade() -> None: + op.add_column("studies", sa.Column("summary_note", sa.Text(), nullable=True)) + op.add_column("studies", sa.Column("objective_note", sa.Text(), nullable=True)) diff --git a/backend/alembic/versions/20260508_03_add_subject_baseline_date.py b/backend/alembic/versions/20260508_03_add_subject_baseline_date.py new file mode 100644 index 00000000..d3a6d2a8 --- /dev/null +++ b/backend/alembic/versions/20260508_03_add_subject_baseline_date.py @@ -0,0 +1,26 @@ +"""add subject baseline date + +Revision ID: 20260508_03 +Revises: 20260508_02 +Create Date: 2026-05-08 16:35:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +revision: str = "20260508_03" +down_revision: Union[str, None] = "20260508_02" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + op.add_column("subjects", sa.Column("baseline_date", sa.Date(), nullable=True)) + + +def downgrade() -> None: + op.drop_column("subjects", "baseline_date") diff --git a/backend/app/api/v1/auth.py b/backend/app/api/v1/auth.py index ed3eb31b..14ee5ff5 100644 --- a/backend/app/api/v1/auth.py +++ b/backend/app/api/v1/auth.py @@ -1,12 +1,13 @@ from datetime import datetime, timedelta, timezone from fastapi import APIRouter, Depends, HTTPException, status from fastapi import File, UploadFile -from pydantic import BaseModel, EmailStr, Field +from pydantic import BaseModel, Field from sqlalchemy.ext.asyncio import AsyncSession from pathlib import Path import uuid from app.core.config import settings +from app.core.login_crypto import create_login_challenge, decrypt_login_payload, get_public_key_pem from app.core.security import create_access_token, decode_token_allow_expired, oauth2_scheme, verify_password from app.core.deps import get_current_user, get_db_session from app.crud import user as user_crud @@ -16,8 +17,16 @@ from fastapi.responses import FileResponse class LoginRequest(BaseModel): - email: EmailStr - password: str = Field(min_length=1) + key_id: str = Field(min_length=1) + challenge: str = Field(min_length=16) + ciphertext: str = Field(min_length=1) + + +class LoginKeyResponse(BaseModel): + key_id: str + public_key: str + challenge: str + expires_at: datetime class ExtendResponse(BaseModel): @@ -25,9 +34,8 @@ class ExtendResponse(BaseModel): expiresAt: datetime -class UnlockRequest(BaseModel): - email: EmailStr - password: str = Field(min_length=1) +class UnlockRequest(LoginRequest): + pass class UnlockResponse(BaseModel): @@ -40,6 +48,42 @@ AVATAR_ROOT = Path(__file__).resolve().parent.parent.parent / "uploads" / "avata AVATAR_ROOT.mkdir(parents=True, exist_ok=True) +def issue_user_token(db_user) -> Token: + session_start = datetime.now(timezone.utc) + access_token = create_access_token( + user_id=str(db_user.id), + role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role, + expires_minutes=None, + session_start=session_start, + ) + return Token(access_token=access_token, token_type="bearer") + + +async def authenticate_encrypted_password(payload: LoginRequest, db: AsyncSession): + decrypted = decrypt_login_payload( + key_id=payload.key_id, + challenge=payload.challenge, + ciphertext=payload.ciphertext, + ) + if not decrypted: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="无法验证登录凭据", + ) + db_user = await user_crud.get_by_email(db, decrypted.email) + if not db_user: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="账号不存在", + ) + if not verify_password(decrypted.password, db_user.password_hash): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="密码错误", + ) + return db_user + + @router.post("/register", status_code=status.HTTP_201_CREATED) async def register( payload: UserRegisterRequest, @@ -54,35 +98,29 @@ async def register( return {"message": "注册成功,等待管理员审核"} +@router.get("/login-key", response_model=LoginKeyResponse) +async def get_login_key() -> LoginKeyResponse: + challenge = create_login_challenge() + return LoginKeyResponse( + key_id=settings.LOGIN_RSA_KEY_ID, + public_key=get_public_key_pem(), + challenge=challenge.value, + expires_at=challenge.expires_at, + ) + + @router.post("/login", response_model=Token) async def login_for_access_token( payload: LoginRequest, db: AsyncSession = Depends(get_db_session) ) -> Token: - db_user = await user_crud.get_by_email(db, payload.email) - if not db_user: - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="账号不存在", - ) - if not verify_password(payload.password, db_user.password_hash): - raise HTTPException( - status_code=status.HTTP_401_UNAUTHORIZED, - detail="密码错误", - ) + db_user = await authenticate_encrypted_password(payload, db) if db_user.status != UserStatus.ACTIVE: raise HTTPException( status_code=status.HTTP_401_UNAUTHORIZED, detail="账号未审核或不可用", ) - session_start = datetime.now(timezone.utc) - access_token = create_access_token( - user_id=str(db_user.id), - role=db_user.role.value if hasattr(db_user.role, "value") else db_user.role, - expires_minutes=None, - session_start=session_start, - ) - return Token(access_token=access_token, token_type="bearer") + return issue_user_token(db_user) @router.get("/me", response_model=UserRead) @@ -133,11 +171,7 @@ async def unlock_session( payload: UnlockRequest, db: AsyncSession = Depends(get_db_session), ) -> UnlockResponse: - db_user = await user_crud.get_by_email(db, payload.email) - if not db_user: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不存在") - if not verify_password(payload.password, db_user.password_hash): - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="密码错误") + db_user = await authenticate_encrypted_password(payload, db) if db_user.status != UserStatus.ACTIVE: raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="账号已停用") session_start = datetime.now(timezone.utc) diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py index 2c54569a..0875004f 100644 --- a/backend/app/api/v1/studies.py +++ b/backend/app/api/v1/studies.py @@ -283,13 +283,8 @@ def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot: plan_end_date=_to_date_text(getattr(study, "plan_end_date", None)), planned_site_count=getattr(study, "planned_site_count", None), planned_enrollment_count=getattr(study, "planned_enrollment_count", None), - summary_note=getattr(study, "summary_note", None) or "", - objective_note=getattr(study, "objective_note", None) or "", status=getattr(study, "status", None) or "", - visit_interval_days=getattr(study, "visit_interval_days", None), - visit_total=getattr(study, "visit_total", None), - visit_window_start_offset=getattr(study, "visit_window_start_offset", None), - visit_window_end_offset=getattr(study, "visit_window_end_offset", None), + visit_schedule=getattr(study, "visit_schedule", None) or [], ) @@ -360,17 +355,9 @@ def _ensure_study_timeline_valid( *, plan_start_date: date | None, plan_end_date: date | None, - visit_window_start_offset: int | None, - visit_window_end_offset: int | None, ) -> None: if plan_start_date and plan_end_date and plan_start_date > plan_end_date: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="项目计划结束日期不能早于开始日期") - if ( - visit_window_start_offset is not None - and visit_window_end_offset is not None - and visit_window_start_offset > visit_window_end_offset - ): - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="访视窗口开始偏移不能晚于结束偏移") def _resolve_version_label(record: Any) -> str: @@ -649,8 +636,6 @@ async def create_study( _ensure_study_timeline_valid( plan_start_date=study_in.plan_start_date, plan_end_date=study_in.plan_end_date, - visit_window_start_offset=study_in.visit_window_start_offset, - visit_window_end_offset=study_in.visit_window_end_offset, ) study_in.code = study_in.code.strip() if not study_in.code: @@ -746,21 +731,9 @@ async def update_study( next_plan_start = study_in.plan_start_date if "plan_start_date" in study_in.model_fields_set else study.plan_start_date next_plan_end = study_in.plan_end_date if "plan_end_date" in study_in.model_fields_set else study.plan_end_date - next_window_start = ( - study_in.visit_window_start_offset - if "visit_window_start_offset" in study_in.model_fields_set - else study.visit_window_start_offset - ) - next_window_end = ( - study_in.visit_window_end_offset - if "visit_window_end_offset" in study_in.model_fields_set - else study.visit_window_end_offset - ) _ensure_study_timeline_valid( plan_start_date=next_plan_start, plan_end_date=next_plan_end, - visit_window_start_offset=next_window_start, - visit_window_end_offset=next_window_end, ) updated = await study_crud.update(db, study, study_in) diff --git a/backend/app/api/v1/subjects.py b/backend/app/api/v1/subjects.py index 7370b693..8c33e466 100644 --- a/backend/app/api/v1/subjects.py +++ b/backend/app/api/v1/subjects.py @@ -173,9 +173,9 @@ async def update_subject( await _ensure_subject_active(db, subject) old_status = subject.status updated = await subject_crud.update_subject(db, subject, subject_in) - # auto-generate visits when enrolled - if subject_in.status and subject_in.status == "ENROLLED" and (subject_in.enrollment_date or updated.enrollment_date): - await subject_crud.generate_default_visits(db, updated) + # 基线/治疗日期是访视计划的唯一推算基准,不能用入组日期替代。 + if updated.baseline_date: + await subject_crud.sync_visits_from_baseline(db, updated) detail = None if subject_in.status and subject_in.status != old_status: detail = f"参与者 {updated.subject_no} 状态 {old_status} -> {subject_in.status}" diff --git a/backend/app/api/v1/visits.py b/backend/app/api/v1/visits.py index 29bf8da0..abd695e6 100644 --- a/backend/app/api/v1/visits.py +++ b/backend/app/api/v1/visits.py @@ -90,15 +90,12 @@ async def create_visit( if next_visit_code == "V1" and visit_in.planned_date: study = await study_crud.get(db, study_id) if study: - await visit_crud.create_followup_visits( + await visit_crud.create_scheduled_visits( db, study_id=study_id, subject=subject, base_date=visit_in.planned_date, - visit_total=study.visit_total, - visit_interval_days=study.visit_interval_days, - window_start_offset=study.visit_window_start_offset, - window_end_offset=study.visit_window_end_offset, + visit_schedule=study.visit_schedule, ) await audit_crud.log_action( db, diff --git a/backend/app/core/config.py b/backend/app/core/config.py index e0eaeb53..ad8ee134 100644 --- a/backend/app/core/config.py +++ b/backend/app/core/config.py @@ -1,5 +1,5 @@ from functools import lru_cache -from typing import Literal +from typing import Literal, Optional from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict @@ -19,6 +19,11 @@ class Settings(BaseSettings): JWT_EXPIRE_MINUTES: int = 60 JWT_EXTEND_GRACE_SECONDS: int = 120 ABSOLUTE_SESSION_MAX_HOURS: int = 8 + LOGIN_RSA_PRIVATE_KEY: Optional[str] = None + LOGIN_RSA_PUBLIC_KEY: Optional[str] = None + LOGIN_RSA_KEY_ID: str = "default" + LOGIN_CHALLENGE_TTL_SECONDS: int = 120 + LOGIN_CHALLENGE_MAX_ACTIVE: int = 1000 @lru_cache diff --git a/backend/app/core/login_crypto.py b/backend/app/core/login_crypto.py new file mode 100644 index 00000000..722433d5 --- /dev/null +++ b/backend/app/core/login_crypto.py @@ -0,0 +1,138 @@ +import base64 +import json +import secrets +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from typing import Any + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cryptography.hazmat.primitives.asymmetric.rsa import RSAPrivateKey, RSAPublicKey +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from pydantic import BaseModel, EmailStr, Field, ValidationError + +from app.core.config import settings + + +class DecryptedLoginPayload(BaseModel): + email: EmailStr + password: str = Field(min_length=1, max_length=72) + challenge: str = Field(min_length=16) + + +class EncryptedLoginEnvelope(BaseModel): + encrypted_key: str = Field(min_length=1) + iv: str = Field(min_length=1) + data: str = Field(min_length=1) + + +@dataclass(frozen=True) +class LoginChallenge: + value: str + expires_at: datetime + + +_private_key: RSAPrivateKey | None = None +_public_key_pem: str | None = None +_challenges: dict[str, LoginChallenge] = {} + + +def _normalize_pem(value: str) -> str: + return value.replace("\\n", "\n").strip() + + +def _load_or_create_private_key() -> RSAPrivateKey: + configured_key = settings.LOGIN_RSA_PRIVATE_KEY + if configured_key: + key = serialization.load_pem_private_key(_normalize_pem(configured_key).encode("utf-8"), password=None) + if not isinstance(key, RSAPrivateKey): + raise ValueError("LOGIN_RSA_PRIVATE_KEY 必须是 RSA 私钥") + return key + if settings.ENV == "production": + raise ValueError("生产环境必须配置 LOGIN_RSA_PRIVATE_KEY") + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +def get_private_key() -> RSAPrivateKey: + global _private_key + if _private_key is None: + _private_key = _load_or_create_private_key() + return _private_key + + +def validate_login_crypto_configuration() -> None: + if settings.ENV == "production" and not settings.LOGIN_RSA_PRIVATE_KEY: + raise ValueError("生产环境必须配置 LOGIN_RSA_PRIVATE_KEY") + + +def get_public_key_pem() -> str: + global _public_key_pem + if _public_key_pem is not None: + return _public_key_pem + if settings.LOGIN_RSA_PUBLIC_KEY: + if not settings.LOGIN_RSA_PRIVATE_KEY: + raise ValueError("配置 LOGIN_RSA_PUBLIC_KEY 时必须同时配置 LOGIN_RSA_PRIVATE_KEY") + _public_key_pem = _normalize_pem(settings.LOGIN_RSA_PUBLIC_KEY) + return _public_key_pem + public_key: RSAPublicKey = get_private_key().public_key() + _public_key_pem = public_key.public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ).decode("utf-8") + return _public_key_pem + + +def create_login_challenge() -> LoginChallenge: + prune_expired_challenges() + while len(_challenges) >= settings.LOGIN_CHALLENGE_MAX_ACTIVE: + oldest = next(iter(_challenges)) + _challenges.pop(oldest, None) + value = secrets.token_urlsafe(32) + expires_at = datetime.now(timezone.utc) + timedelta(seconds=settings.LOGIN_CHALLENGE_TTL_SECONDS) + challenge = LoginChallenge(value=value, expires_at=expires_at) + _challenges[value] = challenge + return challenge + + +def prune_expired_challenges() -> None: + now = datetime.now(timezone.utc) + expired = [value for value, challenge in _challenges.items() if challenge.expires_at <= now] + for value in expired: + _challenges.pop(value, None) + + +def consume_login_challenge(value: str) -> bool: + prune_expired_challenges() + challenge = _challenges.pop(value, None) + if not challenge: + return False + return challenge.expires_at > datetime.now(timezone.utc) + + +def decrypt_login_payload(*, key_id: str, challenge: str, ciphertext: str) -> DecryptedLoginPayload | None: + if key_id != settings.LOGIN_RSA_KEY_ID: + return None + if not consume_login_challenge(challenge): + return None + try: + envelope_data = json.loads(base64.b64decode(ciphertext, validate=True).decode("utf-8")) + envelope = EncryptedLoginEnvelope.model_validate(envelope_data) + encrypted_key = base64.b64decode(envelope.encrypted_key, validate=True) + iv = base64.b64decode(envelope.iv, validate=True) + encrypted_payload = base64.b64decode(envelope.data, validate=True) + aes_key = get_private_key().decrypt( + encrypted_key, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + decrypted = AESGCM(aes_key).decrypt(iv, encrypted_payload, None) + payload: Any = json.loads(decrypted.decode("utf-8")) + parsed = DecryptedLoginPayload.model_validate(payload) + except (ValueError, TypeError, json.JSONDecodeError, ValidationError): + return None + if parsed.challenge != challenge: + return None + return parsed diff --git a/backend/app/crud/study.py b/backend/app/crud/study.py index 275add11..7568c9fe 100644 --- a/backend/app/crud/study.py +++ b/backend/app/crud/study.py @@ -30,14 +30,9 @@ async def create(db: AsyncSession, study_in: StudyCreate, *, created_by: uuid.UU planned_enrollment_count=study_in.planned_enrollment_count, enrollment_monthly_goal_note=study_in.enrollment_monthly_goal_note, enrollment_stage_breakdown=study_in.enrollment_stage_breakdown, - summary_note=study_in.summary_note, - objective_note=study_in.objective_note, phase=study_in.phase, status=study_in.status, - visit_interval_days=study_in.visit_interval_days, - visit_total=study_in.visit_total, - visit_window_start_offset=study_in.visit_window_start_offset, - visit_window_end_offset=study_in.visit_window_end_offset, + visit_schedule=[item.model_dump() for item in study_in.visit_schedule], created_by=created_by, ) db.add(study) diff --git a/backend/app/crud/subject.py b/backend/app/crud/subject.py index 1f734832..0e29168b 100644 --- a/backend/app/crud/subject.py +++ b/backend/app/crud/subject.py @@ -1,5 +1,5 @@ import uuid -from datetime import date, timedelta +from datetime import date from typing import Sequence from sqlalchemy import delete as sa_delete, select, update as sa_update @@ -37,6 +37,10 @@ def _validate_subject_date_chain( raise ValueError("完成日期不能早于入组日期") +def _should_sync_visits(previous_baseline_date: date | None, next_baseline_date: date | None) -> bool: + return next_baseline_date is not None and previous_baseline_date != next_baseline_date + + async def _validate_site(db: AsyncSession, study_id: uuid.UUID, site_id: uuid.UUID) -> None: result = await db.execute(select(Site).where(Site.id == site_id)) site = result.scalar_one_or_none() @@ -51,7 +55,7 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj _validate_subject_date_chain( screening_date=subject_in.screening_date, consent_date=subject_in.consent_date, - enrollment_date=None, + enrollment_date=subject_in.enrollment_date, completion_date=None, ) subject = Subject( @@ -61,7 +65,8 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj status="SCREENING", screening_date=subject_in.screening_date, consent_date=subject_in.consent_date, - enrollment_date=None, + enrollment_date=subject_in.enrollment_date, + baseline_date=subject_in.baseline_date, completion_date=None, drop_reason=None, ) @@ -69,15 +74,7 @@ async def create_subject(db: AsyncSession, study_id: uuid.UUID, subject_in: Subj await db.commit() await db.refresh(subject) - # initial visit: Screening (V0) - await visit_crud.create_visit( - db, - study_id=study_id, - visit_in=None, - subject=subject, - visit_code="V0", - planned_date=subject.screening_date, - ) + await sync_visits_from_baseline(db, subject) return subject @@ -103,49 +100,26 @@ async def list_subjects( return result.scalars().all() -async def generate_default_visits(db: AsyncSession, subject: Subject) -> None: - # Baseline + Follow-up visits based on enrollment_date - if not subject.enrollment_date: - return +async def sync_visits_from_baseline(db: AsyncSession, subject: Subject) -> None: + # 基线/治疗日期是访视计划的唯一推算基准,不能用入组日期替代。 result = await db.execute(select(Study).where(Study.id == subject.study_id)) study = result.scalar_one_or_none() if not study: return - visit_total = study.visit_total or 3 - visit_interval_days = study.visit_interval_days or 30 - window_start_offset = study.visit_window_start_offset - window_end_offset = study.visit_window_end_offset - baseline_date = subject.enrollment_date - - result = await db.execute(select(Visit.visit_code).where(Visit.subject_id == subject.id)) - existing_codes = {row[0] for row in result.all()} - if "V1" not in existing_codes: - window_start = baseline_date + timedelta(days=window_start_offset) if window_start_offset is not None else None - window_end = baseline_date + timedelta(days=window_end_offset) if window_end_offset is not None else None - await visit_crud.create_visit( - db, - study_id=subject.study_id, - visit_in=None, - subject=subject, - visit_code="V1", - planned_date=baseline_date, - window_start=window_start, - window_end=window_end, - ) - - await visit_crud.create_followup_visits( + await visit_crud.create_scheduled_visits( db, study_id=subject.study_id, subject=subject, - base_date=baseline_date, - visit_total=visit_total, - visit_interval_days=visit_interval_days, - window_start_offset=window_start_offset, - window_end_offset=window_end_offset, + base_date=subject.baseline_date, + visit_schedule=study.visit_schedule, ) +async def generate_default_visits(db: AsyncSession, subject: Subject) -> None: + await sync_visits_from_baseline(db, subject) + + async def update_subject(db: AsyncSession, subject: Subject, subject_in: SubjectUpdate) -> Subject: update_data = subject_in.model_dump(exclude_unset=True) next_screening_date = subject.screening_date @@ -169,6 +143,14 @@ async def update_subject(db: AsyncSession, subject: Subject, subject_in: Subject return subject +def should_generate_visits_after_subject_update( + *, + previous_baseline_date: date | None, + next_baseline_date: date | None, +) -> bool: + return _should_sync_visits(previous_baseline_date, next_baseline_date) + + async def delete_subject(db: AsyncSession, subject: Subject) -> None: subject_id = subject.id await db.execute(sa_delete(Visit).where(Visit.subject_id == subject_id)) diff --git a/backend/app/crud/visit.py b/backend/app/crud/visit.py index ab73c6db..7d7e418f 100644 --- a/backend/app/crud/visit.py +++ b/backend/app/crud/visit.py @@ -5,11 +5,59 @@ from typing import Sequence from sqlalchemy import and_, func, or_, select, update as sa_update from sqlalchemy.ext.asyncio import AsyncSession +from app.models.study import Study from app.models.subject import Subject from app.models.visit import Visit from app.schemas.visit import VisitCreate, VisitUpdate +def _visit_schedule_order_map(visit_schedule: list[dict] | None) -> dict[str, int]: + order_map: dict[str, int] = {} + for index, item in enumerate(visit_schedule or []): + code = str(item.get("visit_code") or "").strip() + if code and code not in order_map: + order_map[code] = index + return order_map + + +def sort_visits_for_display(visits: Sequence[Visit], visit_schedule: list[dict] | None = None) -> list[Visit]: + order_map = _visit_schedule_order_map(visit_schedule) + fallback_start = len(order_map) + return sorted( + visits, + key=lambda visit: ( + order_map.get((visit.visit_code or "").strip(), fallback_start), + visit.planned_date is None, + visit.planned_date or date.max, + visit.visit_code or "", + ), + ) + + +def build_visit_schedule_dates(visit_schedule: list[dict] | None, base_date: date | None) -> list[dict]: + if not visit_schedule: + return [] + rows: list[dict] = [] + for item in visit_schedule: + code = str(item.get("visit_code") or "").strip() + if not code: + continue + baseline_offset_days = int(item.get("baseline_offset_days", 0)) + window_before_days = int(item.get("window_before_days", 0)) + window_after_days = int(item.get("window_after_days", 0)) + planned_date = base_date + timedelta(days=baseline_offset_days) if base_date else None + rows.append( + { + "visit_code": code, + "baseline_offset_days": baseline_offset_days, + "planned_date": planned_date, + "window_start": planned_date - timedelta(days=window_before_days) if planned_date else None, + "window_end": planned_date + timedelta(days=window_after_days) if planned_date else None, + } + ) + return rows + + async def create_visit( db: AsyncSession, *, @@ -20,14 +68,16 @@ async def create_visit( planned_date, window_start: date | None = None, window_end: date | None = None, + actual_date: date | None = None, + status: str = "PLANNED", ) -> Visit: visit = Visit( study_id=study_id, subject_id=subject.id, visit_code=visit_code, planned_date=planned_date, - actual_date=None, - status="PLANNED", + actual_date=actual_date, + status=status, window_start=window_start if window_start is not None else (visit_in.window_start if visit_in else None), window_end=window_end if window_end is not None else (visit_in.window_end if visit_in else None), notes=None, @@ -39,8 +89,13 @@ async def create_visit( async def list_visits(db: AsyncSession, subject_id: uuid.UUID) -> Sequence[Visit]: - result = await db.execute(select(Visit).where(Visit.subject_id == subject_id).order_by(Visit.planned_date)) - return result.scalars().all() + result = await db.execute(select(Visit).where(Visit.subject_id == subject_id)) + visits = result.scalars().all() + if not visits: + return [] + study_result = await db.execute(select(Study.visit_schedule).where(Study.id == visits[0].study_id)) + visit_schedule = study_result.scalar_one_or_none() or [] + return sort_visits_for_display(visits, visit_schedule) async def mark_overdue_as_lost(db: AsyncSession, subject_id: uuid.UUID) -> None: @@ -150,32 +205,50 @@ async def list_lost_visits( return result.all() -async def create_followup_visits( +async def create_scheduled_visits( db: AsyncSession, *, study_id: uuid.UUID, subject: Subject, - base_date: date, - visit_total: int | None, - visit_interval_days: int | None, - window_start_offset: int | None, - window_end_offset: int | None, + base_date: date | None, + visit_schedule: list[dict] | None, ) -> Sequence[Visit]: - if not visit_total or not visit_interval_days or visit_total < 2: + if not visit_schedule: return [] - result = await db.execute(select(Visit.visit_code).where(Visit.subject_id == subject.id)) - existing_codes = {row[0] for row in result.all()} + result = await db.execute(select(Visit).where(Visit.subject_id == subject.id)) + existing_by_code = {visit.visit_code: visit for visit in result.scalars().all()} created: list[Visit] = [] - for index in range(2, visit_total + 1): - code = f"V{index}" - if code in existing_codes: + for item in build_visit_schedule_dates(visit_schedule, base_date): + code = item["visit_code"] + is_baseline_visit = item["baseline_offset_days"] == 0 and base_date is not None + existing = existing_by_code.get(code) + if existing: + if is_baseline_visit: + await db.execute( + sa_update(Visit) + .where(Visit.id == existing.id) + .values( + planned_date=item["planned_date"], + window_start=item["window_start"], + window_end=item["window_end"], + actual_date=base_date, + status="DONE", + ) + ) + await db.commit() + elif existing.status == "PLANNED" and existing.actual_date is None: + await db.execute( + sa_update(Visit) + .where(Visit.id == existing.id) + .values( + planned_date=item["planned_date"], + window_start=item["window_start"], + window_end=item["window_end"], + ) + ) + await db.commit() continue - planned_date = base_date + timedelta(days=visit_interval_days * (index - 1)) - window_start = ( - planned_date + timedelta(days=window_start_offset) if window_start_offset is not None else None - ) - window_end = planned_date + timedelta(days=window_end_offset) if window_end_offset is not None else None created.append( await create_visit( db, @@ -183,9 +256,11 @@ async def create_followup_visits( visit_in=None, subject=subject, visit_code=code, - planned_date=planned_date, - window_start=window_start, - window_end=window_end, + planned_date=item["planned_date"], + window_start=item["window_start"], + window_end=item["window_end"], + actual_date=base_date if is_baseline_visit else None, + status="DONE" if is_baseline_visit else "PLANNED", ) ) return created diff --git a/backend/app/main.py b/backend/app/main.py index fd415f37..03cfb705 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -12,6 +12,7 @@ from sqlalchemy import text from app.api.v1.router import api_router from app.core.config import settings from app.core.exceptions import register_exception_handlers +from app.core.login_crypto import validate_login_crypto_configuration from app.crud.user import ensure_admin_exists from app.db.base import Base from app.db.session import SessionLocal, engine @@ -76,6 +77,7 @@ async def _ensure_legacy_primary_keys(conn) -> None: def create_app() -> FastAPI: + validate_login_crypto_configuration() app = FastAPI( title="CTMS 后端 API", description="临床试验项目管理系统后端接口文档", diff --git a/backend/app/models/study.py b/backend/app/models/study.py index cafdfd6e..d65fc86b 100644 --- a/backend/app/models/study.py +++ b/backend/app/models/study.py @@ -1,7 +1,7 @@ import uuid from datetime import date, datetime -from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, String, Text, func +from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, JSON, String, Text, func from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.orm import Mapped, mapped_column @@ -32,14 +32,9 @@ class Study(Base): planned_enrollment_count: Mapped[int | None] = mapped_column(Integer, nullable=True) enrollment_monthly_goal_note: Mapped[str | None] = mapped_column(Text, nullable=True) enrollment_stage_breakdown: Mapped[str | None] = mapped_column(Text, nullable=True) - summary_note: Mapped[str | None] = mapped_column(Text, nullable=True) - objective_note: Mapped[str | None] = mapped_column(Text, nullable=True) phase: Mapped[str | None] = mapped_column(String(50), nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="DRAFT") is_locked: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) - visit_interval_days: Mapped[int | None] = mapped_column(Integer, nullable=True) - visit_total: Mapped[int | None] = mapped_column(Integer, nullable=True) - visit_window_start_offset: Mapped[int | None] = mapped_column(Integer, nullable=True) - visit_window_end_offset: Mapped[int | None] = mapped_column(Integer, nullable=True) + visit_schedule: Mapped[list[dict]] = mapped_column(JSON, nullable=False, default=list) created_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/backend/app/models/subject.py b/backend/app/models/subject.py index 2c118b04..c14acc76 100644 --- a/backend/app/models/subject.py +++ b/backend/app/models/subject.py @@ -20,6 +20,7 @@ class Subject(Base): screening_date: Mapped[date | None] = mapped_column(Date, nullable=True) consent_date: Mapped[date | None] = mapped_column(Date, nullable=True) enrollment_date: Mapped[date | None] = mapped_column(Date, nullable=True) + baseline_date: Mapped[date | None] = mapped_column(Date, nullable=True) completion_date: Mapped[date | None] = mapped_column(Date, nullable=True) drop_reason: Mapped[str | None] = mapped_column(Text, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, server_default=func.now()) diff --git a/backend/app/schemas/study.py b/backend/app/schemas/study.py index 87305f9a..59a032d6 100644 --- a/backend/app/schemas/study.py +++ b/backend/app/schemas/study.py @@ -2,12 +2,36 @@ import uuid from datetime import date, datetime from typing import Literal, Optional -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator StudyStatus = Literal["DRAFT", "ACTIVE", "CLOSED"] -class StudyCreate(BaseModel): +class VisitScheduleItem(BaseModel): + visit_code: str = Field(min_length=1, max_length=50) + baseline_offset_days: int = Field(ge=0, le=3650) + window_before_days: int = Field(ge=0, le=365) + window_after_days: int = Field(ge=0, le=365) + + +class VisitScheduleMixin(BaseModel): + visit_schedule: list[VisitScheduleItem] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_visit_schedule(self): + codes: set[str] = set() + for index, item in enumerate(self.visit_schedule): + code = item.visit_code.strip() + if not code: + raise ValueError(f"第 {index + 1} 行访视编号不能为空") + if code in codes: + raise ValueError(f"访视编号重复:{code}") + codes.add(code) + item.visit_code = code + return self + + +class StudyCreate(VisitScheduleMixin): name: str = Field(min_length=1) code: str = Field(min_length=1) project_full_name: Optional[str] = None @@ -28,14 +52,8 @@ class StudyCreate(BaseModel): planned_enrollment_count: Optional[int] = None enrollment_monthly_goal_note: Optional[str] = None enrollment_stage_breakdown: Optional[str] = None - summary_note: Optional[str] = None - objective_note: Optional[str] = None phase: Optional[str] = None status: StudyStatus = "DRAFT" - visit_interval_days: Optional[int] = None - visit_total: Optional[int] = None - visit_window_start_offset: Optional[int] = None - visit_window_end_offset: Optional[int] = None class StudyUpdate(BaseModel): @@ -59,15 +77,25 @@ class StudyUpdate(BaseModel): planned_enrollment_count: Optional[int] = None enrollment_monthly_goal_note: Optional[str] = None enrollment_stage_breakdown: Optional[str] = None - summary_note: Optional[str] = None - objective_note: Optional[str] = None phase: Optional[str] = None status: Optional[StudyStatus] = None is_locked: Optional[bool] = None - visit_interval_days: Optional[int] = None - visit_total: Optional[int] = None - visit_window_start_offset: Optional[int] = None - visit_window_end_offset: Optional[int] = None + visit_schedule: Optional[list[VisitScheduleItem]] = None + + @model_validator(mode="after") + def validate_visit_schedule(self): + if self.visit_schedule is None: + return self + codes: set[str] = set() + for index, item in enumerate(self.visit_schedule): + code = item.visit_code.strip() + if not code: + raise ValueError(f"第 {index + 1} 行访视编号不能为空") + if code in codes: + raise ValueError(f"访视编号重复:{code}") + codes.add(code) + item.visit_code = code + return self class StudyRead(BaseModel): @@ -92,15 +120,10 @@ class StudyRead(BaseModel): planned_enrollment_count: Optional[int] enrollment_monthly_goal_note: Optional[str] enrollment_stage_breakdown: Optional[str] - summary_note: Optional[str] - objective_note: Optional[str] phase: Optional[str] status: StudyStatus is_locked: bool - visit_interval_days: Optional[int] - visit_total: Optional[int] - visit_window_start_offset: Optional[int] - visit_window_end_offset: Optional[int] + visit_schedule: list[VisitScheduleItem] created_by: Optional[uuid.UUID] created_at: datetime diff --git a/backend/app/schemas/study_setup_config.py b/backend/app/schemas/study_setup_config.py index 0183a4ba..5d456d72 100644 --- a/backend/app/schemas/study_setup_config.py +++ b/backend/app/schemas/study_setup_config.py @@ -2,7 +2,7 @@ import uuid from datetime import datetime from typing import Literal -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator ConfirmStatus = Literal["待确认", "已确认", "退回"] @@ -143,6 +143,13 @@ class SetupProjectionSummary(BaseModel): skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list) +class VisitScheduleItem(BaseModel): + visit_code: str = Field(min_length=1, max_length=50) + baseline_offset_days: int = Field(ge=0, le=3650) + window_before_days: int = Field(ge=0, le=365) + window_after_days: int = Field(ge=0, le=365) + + class ProjectPublishSnapshot(BaseModel): code: str = "" name: str = "" @@ -162,13 +169,21 @@ class ProjectPublishSnapshot(BaseModel): plan_end_date: str = "" planned_site_count: int | None = None planned_enrollment_count: int | None = None - summary_note: str = "" - objective_note: str = "" status: str = "" - visit_interval_days: int | None = None - visit_total: int | None = None - visit_window_start_offset: int | None = None - visit_window_end_offset: int | None = None + visit_schedule: list[VisitScheduleItem] = Field(default_factory=list) + + @model_validator(mode="after") + def validate_visit_schedule(self): + codes: set[str] = set() + for index, item in enumerate(self.visit_schedule): + code = item.visit_code.strip() + if not code: + raise ValueError(f"第 {index + 1} 行访视编号不能为空") + if code in codes: + raise ValueError(f"访视编号重复:{code}") + codes.add(code) + item.visit_code = code + return self class StudySetupConfigRead(BaseModel): diff --git a/backend/app/schemas/subject.py b/backend/app/schemas/subject.py index 11449ba4..fcaa32d9 100644 --- a/backend/app/schemas/subject.py +++ b/backend/app/schemas/subject.py @@ -10,12 +10,15 @@ class SubjectCreate(BaseModel): subject_no: str screening_date: Optional[date] = None consent_date: Optional[date] = None + enrollment_date: Optional[date] = None + baseline_date: Optional[date] = None class SubjectUpdate(BaseModel): status: Optional[str] = None consent_date: Optional[date] = None enrollment_date: Optional[date] = None + baseline_date: Optional[date] = None completion_date: Optional[date] = None drop_reason: Optional[str] = None @@ -29,6 +32,7 @@ class SubjectRead(BaseModel): screening_date: Optional[date] consent_date: Optional[date] enrollment_date: Optional[date] + baseline_date: Optional[date] completion_date: Optional[date] drop_reason: Optional[str] created_at: datetime diff --git a/backend/app/schemas/user.py b/backend/app/schemas/user.py index bb9e5b8f..c343e08e 100644 --- a/backend/app/schemas/user.py +++ b/backend/app/schemas/user.py @@ -23,7 +23,7 @@ class UserDisplay(BaseModel): class _PasswordValidator(BaseModel): - password: Optional[str] = Field(default=None, min_length=8) + password: Optional[str] = Field(default=None, min_length=8, max_length=72) @field_validator("password") @classmethod @@ -36,7 +36,7 @@ class _PasswordValidator(BaseModel): class UserRegisterRequest(_PasswordValidator): - password: str = Field(min_length=8) + password: str = Field(min_length=8, max_length=72) email: EmailStr full_name: str = Field(min_length=1) role: RegisterRole @@ -44,7 +44,7 @@ class UserRegisterRequest(_PasswordValidator): class UserCreate(_PasswordValidator): - password: str = Field(min_length=8) + password: str = Field(min_length=8, max_length=72) email: EmailStr full_name: str = Field(min_length=1) role: UserRole diff --git a/backend/requirements.txt b/backend/requirements.txt index 7e3e6a59..e58d9030 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,6 +2,7 @@ fastapi==0.104.1 uvicorn[standard]==0.24.0.post1 sqlalchemy==2.0.23 asyncpg==0.29.0 +aiosqlite==0.20.0 pydantic-settings==2.1.0 python-jose[cryptography]==3.3.0 passlib[bcrypt]==1.7.4 diff --git a/backend/scripts/smoke_setup_config.py b/backend/scripts/smoke_setup_config.py index 857def60..0254d1ec 100644 --- a/backend/scripts/smoke_setup_config.py +++ b/backend/scripts/smoke_setup_config.py @@ -1,4 +1,5 @@ import asyncio +import base64 import json import os import sys @@ -6,6 +7,9 @@ import urllib.error import urllib.request import asyncpg +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.ciphers.aead import AESGCM BASE = os.getenv("BASE_URL", "http://localhost:8000") @@ -66,13 +70,53 @@ def db_fetch(sql: str, *args) -> list[dict]: return asyncio.run(_db_fetch(sql, *args)) -def main() -> int: - print(f"[config] BASE={BASE} EMAIL={ADMIN_EMAIL}") - status, login = request_json( +def encrypted_login(email: str, password: str) -> tuple[int, dict]: + status, login_key = request_json("/api/v1/auth/login-key") + if status != 200: + return status, login_key + public_key = serialization.load_pem_public_key(login_key["public_key"].encode("utf-8")) + plaintext = json.dumps( + { + "email": email, + "password": password, + "challenge": login_key["challenge"], + }, + separators=(",", ":"), + ).encode("utf-8") + aes_key = AESGCM.generate_key(bit_length=256) + iv = os.urandom(12) + encrypted_data = AESGCM(aes_key).encrypt(iv, plaintext, None) + encrypted_key = public_key.encrypt( + aes_key, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + return request_json( "/api/v1/auth/login", method="POST", - payload={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD}, + payload={ + "key_id": login_key["key_id"], + "challenge": login_key["challenge"], + "ciphertext": base64.b64encode( + json.dumps( + { + "encrypted_key": base64.b64encode(encrypted_key).decode("ascii"), + "iv": base64.b64encode(iv).decode("ascii"), + "data": base64.b64encode(encrypted_data).decode("ascii"), + }, + separators=(",", ":"), + ).encode("utf-8") + ).decode("ascii"), + }, ) + + +def main() -> int: + print(f"[config] BASE={BASE} EMAIL={ADMIN_EMAIL}") + status, login = encrypted_login(ADMIN_EMAIL, ADMIN_PASSWORD) assert_or_exit(status == 200, f"登录失败 status={status} body={login}") token = login["access_token"] diff --git a/backend/tests/test_login_crypto.py b/backend/tests/test_login_crypto.py new file mode 100644 index 00000000..eda18df9 --- /dev/null +++ b/backend/tests/test_login_crypto.py @@ -0,0 +1,39 @@ +import pytest + +from app.core import login_crypto +from app.core.config import settings +from app.main import create_app + + +def reset_login_crypto_state(): + login_crypto._private_key = None + login_crypto._public_key_pem = None + login_crypto._challenges.clear() + + +def test_production_requires_configured_login_private_key(monkeypatch): + reset_login_crypto_state() + monkeypatch.setattr(settings, "ENV", "production") + monkeypatch.setattr(settings, "LOGIN_RSA_PRIVATE_KEY", None) + + with pytest.raises(ValueError, match="生产环境必须配置 LOGIN_RSA_PRIVATE_KEY"): + create_app() + + reset_login_crypto_state() + + +def test_login_challenge_cache_has_max_active_limit(monkeypatch): + reset_login_crypto_state() + monkeypatch.setattr(settings, "ENV", "test") + monkeypatch.setattr(settings, "LOGIN_CHALLENGE_MAX_ACTIVE", 2) + + first = login_crypto.create_login_challenge() + second = login_crypto.create_login_challenge() + third = login_crypto.create_login_challenge() + + assert len(login_crypto._challenges) == 2 + assert not login_crypto.consume_login_challenge(first.value) + assert login_crypto.consume_login_challenge(second.value) + assert login_crypto.consume_login_challenge(third.value) + + reset_login_crypto_state() diff --git a/backend/tests/test_registration.py b/backend/tests/test_registration.py index 6603a54e..f8fc2cb1 100644 --- a/backend/tests/test_registration.py +++ b/backend/tests/test_registration.py @@ -1,7 +1,15 @@ import pytest import pytest_asyncio +from cryptography.hazmat.primitives.ciphers.aead import AESGCM +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding from httpx import AsyncClient +from sqlalchemy.dialects.postgresql import UUID from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine +from sqlalchemy.ext.compiler import compiles +import base64 +import json +import os from app.main import create_app from app.core.deps import get_db_session @@ -12,6 +20,55 @@ from app.models.user import User, UserRole, UserStatus TEST_DATABASE_URL = "sqlite+aiosqlite:///:memory:" +@compiles(UUID, "sqlite") +def compile_uuid_for_sqlite(_type, _compiler, **_kw): + return "CHAR(32)" + + +async def encrypted_auth_payload(client: AsyncClient, email: str, password: str) -> dict: + key_resp = await client.get("/api/v1/auth/login-key") + assert key_resp.status_code == 200 + login_key = key_resp.json() + public_key = serialization.load_pem_public_key(login_key["public_key"].encode("utf-8")) + plaintext = json.dumps( + { + "email": email, + "password": password, + "challenge": login_key["challenge"], + }, + separators=(",", ":"), + ).encode("utf-8") + aes_key = AESGCM.generate_key(bit_length=256) + iv = os.urandom(12) + encrypted_data = AESGCM(aes_key).encrypt(iv, plaintext, None) + encrypted_key = public_key.encrypt( + aes_key, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), + ) + return { + "key_id": login_key["key_id"], + "challenge": login_key["challenge"], + "ciphertext": base64.b64encode( + json.dumps( + { + "encrypted_key": base64.b64encode(encrypted_key).decode("ascii"), + "iv": base64.b64encode(iv).decode("ascii"), + "data": base64.b64encode(encrypted_data).decode("ascii"), + }, + separators=(",", ":"), + ).encode("utf-8") + ).decode("ascii"), + } + + +async def encrypted_login(client: AsyncClient, email: str, password: str): + return await client.post("/api/v1/auth/login", json=await encrypted_auth_payload(client, email, password)) + + @pytest_asyncio.fixture async def client_and_db(): engine = create_async_engine(TEST_DATABASE_URL, future=True) @@ -87,7 +144,7 @@ async def test_login_blocked_before_approval(client_and_db): "department": "Safety", } await client.post("/api/v1/auth/register", json=payload) - resp = await client.post("/api/v1/auth/login", json={"email": payload["email"], "password": payload["password"]}) + resp = await encrypted_login(client, payload["email"], payload["password"]) assert resp.status_code == 401 assert "账号未审核" in resp.json().get("detail", "") @@ -107,7 +164,7 @@ async def test_admin_can_approve_user(client_and_db): user = await user_crud.get_by_email(session, payload["email"]) user_id = user.id - admin_login = await client.post("/api/v1/auth/login", json={"email": "admin@test.com", "password": "admin123"}) + admin_login = await encrypted_login(client, "admin@test.com", "admin123") token = admin_login.json()["access_token"] headers = {"Authorization": f"Bearer {token}"} @@ -131,3 +188,71 @@ async def test_admin_role_cannot_register(client_and_db): } resp = await client.post("/api/v1/auth/register", json=payload) assert resp.status_code in (400, 422) + + +@pytest.mark.asyncio +async def test_plaintext_login_is_rejected(client_and_db): + client, _ = client_and_db + resp = await client.post("/api/v1/auth/login", json={"email": "admin@test.com", "password": "admin123"}) + assert resp.status_code == 422 + + +@pytest.mark.asyncio +async def test_login_challenge_cannot_be_reused(client_and_db): + client, _ = client_and_db + key_resp = await client.get("/api/v1/auth/login-key") + login_key = key_resp.json() + public_key = serialization.load_pem_public_key(login_key["public_key"].encode("utf-8")) + plaintext = json.dumps( + { + "email": "admin@test.com", + "password": "admin123", + "challenge": login_key["challenge"], + }, + separators=(",", ":"), + ).encode("utf-8") + aes_key = AESGCM.generate_key(bit_length=256) + iv = os.urandom(12) + encrypted_data = AESGCM(aes_key).encrypt(iv, plaintext, None) + encrypted_key = public_key.encrypt( + aes_key, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ) + ) + ciphertext = base64.b64encode( + json.dumps( + { + "encrypted_key": base64.b64encode(encrypted_key).decode("ascii"), + "iv": base64.b64encode(iv).decode("ascii"), + "data": base64.b64encode(encrypted_data).decode("ascii"), + }, + separators=(",", ":"), + ).encode("utf-8") + ).decode("ascii") + payload = { + "key_id": login_key["key_id"], + "challenge": login_key["challenge"], + "ciphertext": ciphertext, + } + + first = await client.post("/api/v1/auth/login", json=payload) + second = await client.post("/api/v1/auth/login", json=payload) + + assert first.status_code == 200 + assert second.status_code == 401 + + +@pytest.mark.asyncio +async def test_unlock_requires_encrypted_password(client_and_db): + client, _ = client_and_db + plaintext = await client.post("/api/v1/auth/unlock", json={"email": "admin@test.com", "password": "admin123"}) + encrypted = await client.post( + "/api/v1/auth/unlock", + json=await encrypted_auth_payload(client, "admin@test.com", "admin123"), + ) + + assert plaintext.status_code == 422 + assert encrypted.status_code == 200 diff --git a/backend/tests/test_visit_schedule.py b/backend/tests/test_visit_schedule.py new file mode 100644 index 00000000..c16c0f14 --- /dev/null +++ b/backend/tests/test_visit_schedule.py @@ -0,0 +1,144 @@ +from datetime import date +from types import SimpleNamespace + +from app.crud.subject import should_generate_visits_after_subject_update +from app.crud.visit import build_visit_schedule_dates, sort_visits_for_display +from app.schemas.study import StudyUpdate + + +def test_build_visit_schedule_dates_uses_per_visit_windows(): + baseline_date = date(2026, 5, 8) + + visits = build_visit_schedule_dates( + [ + { + "visit_code": "基线访视", + "baseline_offset_days": 0, + "window_before_days": 0, + "window_after_days": 0, + }, + { + "visit_code": "V1", + "baseline_offset_days": 7, + "window_before_days": 2, + "window_after_days": 2, + }, + { + "visit_code": "V2", + "baseline_offset_days": 15, + "window_before_days": 2, + "window_after_days": 2, + }, + { + "visit_code": "V3", + "baseline_offset_days": 23, + "window_before_days": 3, + "window_after_days": 3, + }, + ], + baseline_date, + ) + + assert [ + (visit["visit_code"], visit["baseline_offset_days"], visit["planned_date"], visit["window_start"], visit["window_end"]) + for visit in visits + ] == [ + ("基线访视", 0, date(2026, 5, 8), date(2026, 5, 8), date(2026, 5, 8)), + ("V1", 7, date(2026, 5, 15), date(2026, 5, 13), date(2026, 5, 17)), + ("V2", 15, date(2026, 5, 23), date(2026, 5, 21), date(2026, 5, 25)), + ("V3", 23, date(2026, 5, 31), date(2026, 5, 28), date(2026, 6, 3)), + ] + + +def test_build_visit_schedule_dates_keeps_visit_structure_without_baseline_date(): + visits = build_visit_schedule_dates( + [ + { + "visit_code": "基线访视", + "baseline_offset_days": 0, + "window_before_days": 0, + "window_after_days": 0, + }, + { + "visit_code": "V1", + "baseline_offset_days": 7, + "window_before_days": 2, + "window_after_days": 2, + }, + ], + None, + ) + + assert [ + (visit["visit_code"], visit["planned_date"], visit["window_start"], visit["window_end"]) + for visit in visits + ] == [ + ("基线访视", None, None, None), + ("V1", None, None, None), + ] + + +def test_study_update_allows_multiple_visits_with_same_baseline_offset(): + payload = StudyUpdate( + visit_schedule=[ + { + "visit_code": "筛选访视", + "baseline_offset_days": 0, + "window_before_days": 0, + "window_after_days": 0, + }, + { + "visit_code": "基线访视", + "baseline_offset_days": 0, + "window_before_days": 0, + "window_after_days": 0, + }, + ] + ) + + assert [item.visit_code for item in payload.visit_schedule or []] == ["筛选访视", "基线访视"] + + +def test_sort_visits_for_display_follows_configured_visit_order_without_dates(): + visits = [ + SimpleNamespace(visit_code="V1", planned_date=None), + SimpleNamespace(visit_code="V2", planned_date=None), + SimpleNamespace(visit_code="基线访视", planned_date=None), + ] + visit_schedule = [ + {"visit_code": "基线访视"}, + {"visit_code": "V1"}, + {"visit_code": "V2"}, + ] + + assert [visit.visit_code for visit in sort_visits_for_display(visits, visit_schedule)] == ["基线访视", "V1", "V2"] + + +def test_sort_visits_for_display_does_not_infer_business_order(): + visits = [ + SimpleNamespace(visit_code="基线访视", planned_date=None), + SimpleNamespace(visit_code="V1", planned_date=None), + SimpleNamespace(visit_code="V2", planned_date=None), + ] + visit_schedule = [ + {"visit_code": "V1"}, + {"visit_code": "V2"}, + {"visit_code": "基线访视"}, + ] + + assert [visit.visit_code for visit in sort_visits_for_display(visits, visit_schedule)] == ["V1", "V2", "基线访视"] + + +def test_should_generate_visits_when_baseline_date_is_set_or_changed(): + assert should_generate_visits_after_subject_update( + previous_baseline_date=None, + next_baseline_date=date(2026, 5, 8), + ) + assert should_generate_visits_after_subject_update( + previous_baseline_date=date(2026, 5, 8), + next_baseline_date=date(2026, 5, 9), + ) + assert not should_generate_visits_after_subject_update( + previous_baseline_date=None, + next_baseline_date=None, + ) diff --git a/database/init.sql b/database/init.sql index 80d6ab26..42e8a715 100644 --- a/database/init.sql +++ b/database/init.sql @@ -46,10 +46,7 @@ CREATE TABLE IF NOT EXISTS public.studies ( phase character varying(50), status character varying(20) NOT NULL DEFAULT 'DRAFT', is_locked boolean NOT NULL DEFAULT false, - visit_interval_days integer, - visit_total integer, - visit_window_start_offset integer, - visit_window_end_offset integer, + visit_schedule jsonb NOT NULL DEFAULT '[]'::jsonb, created_by uuid, created_at timestamp with time zone NOT NULL DEFAULT now(), CONSTRAINT uq_studies_code UNIQUE (code), @@ -112,6 +109,7 @@ CREATE TABLE IF NOT EXISTS public.subjects ( screening_date date, consent_date date, enrollment_date date, + baseline_date date, completion_date date, drop_reason text, created_at timestamp with time zone NOT NULL DEFAULT now(), @@ -591,7 +589,7 @@ ON CONFLICT (email) DO UPDATE SET updated_at = EXCLUDED.updated_at; INSERT INTO public.studies ( - id, code, name, sponsor, protocol_no, phase, status, visit_interval_days, visit_total, visit_window_start_offset, visit_window_end_offset, created_by, created_at + id, code, name, sponsor, protocol_no, phase, status, visit_schedule, created_by, created_at ) VALUES ( 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa', 'DEMO-CTMS', @@ -600,10 +598,12 @@ INSERT INTO public.studies ( 'DP-001', 'Phase II', 'ACTIVE', - 7, - 3, - -2, - 2, + '[ + {"visit_code": "基线访视", "baseline_offset_days": 0, "window_before_days": 0, "window_after_days": 0}, + {"visit_code": "V1", "baseline_offset_days": 7, "window_before_days": 2, "window_after_days": 2}, + {"visit_code": "V2", "baseline_offset_days": 15, "window_before_days": 2, "window_after_days": 2}, + {"visit_code": "V3", "baseline_offset_days": 23, "window_before_days": 3, "window_after_days": 3} + ]'::jsonb, (SELECT id FROM public.users WHERE email = 'admin@example.com'), '2025-01-06 08:00:00+00' ) ON CONFLICT (code) DO UPDATE SET @@ -612,10 +612,7 @@ INSERT INTO public.studies ( protocol_no = EXCLUDED.protocol_no, phase = EXCLUDED.phase, status = EXCLUDED.status, - visit_interval_days = EXCLUDED.visit_interval_days, - visit_total = EXCLUDED.visit_total, - visit_window_start_offset = EXCLUDED.visit_window_start_offset, - visit_window_end_offset = EXCLUDED.visit_window_end_offset, + visit_schedule = EXCLUDED.visit_schedule, created_by = EXCLUDED.created_by; INSERT INTO public.sites ( @@ -753,17 +750,18 @@ INSERT INTO public.training_authorizations ( ON CONFLICT (id) DO NOTHING; INSERT INTO public.subjects ( - id, study_id, site_id, subject_no, status, screening_date, consent_date, enrollment_date, completion_date, drop_reason, created_at, updated_at + id, study_id, site_id, subject_no, status, screening_date, consent_date, enrollment_date, baseline_date, completion_date, drop_reason, created_at, updated_at ) VALUES - ('11111111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'SUBJ-001', 'ENROLLED', '2025-01-05', '2025-01-06', '2025-01-10', NULL, NULL, '2025-01-10 10:00:00+00', '2025-01-10 10:00:00+00'), - ('22222222-3333-4444-5555-666666666666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'cccccccc-cccc-cccc-cccc-cccccccccccc', 'SUBJ-002', 'SCREENING', '2025-01-12', '2025-01-13', NULL, NULL, NULL, '2025-01-12 10:00:00+00', '2025-01-12 10:00:00+00'), - ('33333333-4444-5555-6666-777777777777', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'SUBJ-003', 'COMPLETED', '2024-12-20', '2024-12-21', '2024-12-28', '2025-02-05', NULL, '2025-02-05 10:00:00+00', '2025-02-05 10:00:00+00') + ('11111111-2222-3333-4444-555555555555', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'SUBJ-001', 'ENROLLED', '2025-01-05', '2025-01-06', '2025-01-10', '2025-01-10', NULL, NULL, '2025-01-10 10:00:00+00', '2025-01-10 10:00:00+00'), + ('22222222-3333-4444-5555-666666666666', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'cccccccc-cccc-cccc-cccc-cccccccccccc', 'SUBJ-002', 'SCREENING', '2025-01-12', '2025-01-13', NULL, NULL, NULL, NULL, '2025-01-12 10:00:00+00', '2025-01-12 10:00:00+00'), + ('33333333-4444-5555-6666-777777777777', (SELECT id FROM public.studies WHERE code = 'DEMO-CTMS'), 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb', 'SUBJ-003', 'COMPLETED', '2024-12-20', '2024-12-21', '2024-12-28', '2024-12-28', '2025-02-05', NULL, '2025-02-05 10:00:00+00', '2025-02-05 10:00:00+00') ON CONFLICT (study_id, subject_no) DO UPDATE SET site_id = EXCLUDED.site_id, status = EXCLUDED.status, screening_date = EXCLUDED.screening_date, consent_date = EXCLUDED.consent_date, enrollment_date = EXCLUDED.enrollment_date, + baseline_date = EXCLUDED.baseline_date, completion_date = EXCLUDED.completion_date, drop_reason = EXCLUDED.drop_reason, updated_at = EXCLUDED.updated_at; diff --git a/docker-compose.yaml b/docker-compose.yaml index e5d43329..44fb55d6 100755 --- a/docker-compose.yaml +++ b/docker-compose.yaml @@ -30,6 +30,10 @@ services: - "8000:8000" environment: DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db + ENV: ${ENV:-development} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:-dev-secret} + LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-} + LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default} depends_on: db: condition: service_healthy @@ -43,6 +47,10 @@ services: command: python scripts/init_production.py environment: DATABASE_URL: postgresql+asyncpg://ctms_user:secret_password@db/ctms_db + ENV: ${ENV:-development} + JWT_SECRET_KEY: ${JWT_SECRET_KEY:-dev-secret} + LOGIN_RSA_PRIVATE_KEY: ${LOGIN_RSA_PRIVATE_KEY:-} + LOGIN_RSA_KEY_ID: ${LOGIN_RSA_KEY_ID:-default} depends_on: db: condition: service_healthy diff --git a/docs/README.md b/docs/README.md index 9b6e816c..0c7cb5c4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -5,6 +5,7 @@ CTMS 文档按用途分为三类:当前操作手册、审计与治理记录、 ## 当前常用 - [`guides/release-checklist.md`](guides/release-checklist.md): 发布前检查项与回归门禁 +- [`guides/branch-environment-installation.md`](guides/branch-environment-installation.md): `dev`、`main`、`release` 分支环境安装配置 - [`guides/setup-config-api.md`](guides/setup-config-api.md): 立项配置接口、联调与冒烟说明 - [`setup-config-curl-smoke.sh`](setup-config-curl-smoke.sh): 立项配置 curl 冒烟脚本 - [`postman/setup-config.postman_collection.json`](postman/setup-config.postman_collection.json): Postman 联调集合 diff --git a/docs/guides/branch-environment-installation.md b/docs/guides/branch-environment-installation.md new file mode 100644 index 00000000..ef5326d9 --- /dev/null +++ b/docs/guides/branch-environment-installation.md @@ -0,0 +1,328 @@ +# CTMS 分支环境安装配置指南 + +本文定义 `dev`、`main`、`release` 三类分支对应的推荐部署环境、配置文件、初始化步骤与验证命令。分支本身不会自动切换运行模式,实际模式由后端环境变量 `ENV` 决定。 + +## 分支与环境映射 + +| 分支 | 分支定位 | 推荐环境 | 后端 `ENV` | 用途 | +| --- | --- | --- | --- | --- | +| `dev` | 日常开发与集成 | 开发环境 | `development` | 功能开发、联调、自测、内部集成 | +| `main` | 下一版本候选 | 预发布 / 验收环境 | `production` | 回归测试、验收、发布候选验证 | +| `release` | 当前稳定生产线 | 生产环境 | `production` | 正式生产部署、生产 hotfix | + +推广路径保持为: + +```text +feature/* -> dev -> main -> release +``` + +不要把 `release` 当作日常开发分支使用。`main` 应只接收已经准备进入候选版本范围的变更。 + +## 公共前置条件 + +所有环境都需要: + +- Docker 与 Docker Compose +- 可写的 `pg_data/` 数据目录 +- 端口 `8888`、`8000`、`5432` 未被占用 +- 后端镜像可安装 `backend/requirements.txt` 中的依赖 +- 前端镜像可执行 `npm ci` 与 `npm run build` + +当前 compose 服务拓扑: + +```text +nginx -> backend -> db +``` + +对外入口: + +- 前端:`http://localhost:8888` +- 后端 API:同域 `/api/v1/*` +- 健康检查:`http://localhost:8888/health` + +## dev 分支:开发环境 + +`dev` 默认用于本地开发和内部集成,推荐使用 `ENV=development`。 + +### 1. 切换分支 + +```bash +git checkout dev +git pull --rebase origin dev +``` + +### 2. 配置 `.env` + +根目录 `.env` 不提交到仓库。开发环境推荐: + +```env +COMPOSE_PROJECT_NAME=ctms_dev +ENV=development +JWT_SECRET_KEY=dev-secret +LOGIN_RSA_KEY_ID=default +LOGIN_RSA_PRIVATE_KEY= +``` + +说明: + +- `ENV=development` 会启用开发行为。 +- 未配置 `LOGIN_RSA_PRIVATE_KEY` 时,后端启动后会生成临时 RSA 私钥。 +- 后端重启后临时公钥会变化,已有登录页应刷新后重新登录。 +- `JWT_SECRET_KEY=dev-secret` 只允许本地开发使用。 + +### 3. 启动 + +```bash +docker compose up -d --build backend nginx +``` + +如果需要首次启动完整栈: + +```bash +docker compose up -d --build +``` + +### 4. 开发模式行为 + +`development` 模式下: + +- FastAPI `debug=True` +- 应用启动时会执行 `Base.metadata.create_all` +- 应用启动时会确保默认管理员存在 +- 未配置 RSA 私钥时允许临时生成 + +这些行为只适合开发,不适合生产。 + +### 5. 验证 + +```bash +docker compose ps +docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV)" +curl -i http://127.0.0.1:8888/health +curl -i http://127.0.0.1:8888/api/v1/auth/login-key +``` + +预期: + +```text +ENV=development +GET /health -> 200 +GET /api/v1/auth/login-key -> 200, key_id=default +``` + +## main 分支:预发布 / 验收环境 + +`main` 是下一正式版本候选分支,应按生产模式运行,但不直接承载正式生产流量。 + +### 1. 切换分支 + +```bash +git checkout main +git pull --rebase origin main +``` + +### 2. 配置 `.env` + +预发布环境应使用 `ENV=production`,并配置独立于生产的密钥: + +```env +COMPOSE_PROJECT_NAME=ctms_staging +ENV=production +JWT_SECRET_KEY= +LOGIN_RSA_KEY_ID=staging-YYYYMMDD +LOGIN_RSA_PRIVATE_KEY= +``` + +生成密钥示例: + +```bash +openssl rand -hex 32 +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 +``` + +要求: + +- `JWT_SECRET_KEY` 不得使用 `dev-secret`。 +- `LOGIN_RSA_PRIVATE_KEY` 必须固定保存,不能每次部署重新生成。 +- staging 私钥不得复用 production 私钥。 + +### 3. 初始化数据库 + +生产模式不会自动建表或自动补管理员。首次部署或迁移前执行: + +```bash +docker compose run --rm backend-init +``` + +该步骤应运行 Alembic migration,并确保固定管理员账号存在。 + +### 4. 启动 + +```bash +docker compose up -d --build +``` + +### 5. 验证 + +```bash +docker compose config +docker compose ps +docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV); print(settings.JWT_SECRET_KEY == 'dev-secret'); print(bool(settings.LOGIN_RSA_PRIVATE_KEY))" +curl -i http://127.0.0.1:8888/health +curl -i http://127.0.0.1:8888/api/v1/auth/login-key +``` + +预期: + +```text +ENV=production +JWT_SECRET_KEY == dev-secret -> False +LOGIN_RSA_PRIVATE_KEY_SET -> True +GET /health -> 200 +GET /api/v1/auth/login-key -> 200 +``` + +### 6. 验收门禁 + +在把 `main` 推进到 `release` 前,至少执行: + +```bash +docker compose run --rm -v "$PWD/backend/tests:/code/tests:ro" backend python -m pytest +cd frontend && npm run test:unit +cd frontend && npm run type-check +cd frontend && npm run build +``` + +## release 分支:生产环境 + +`release` 是正式生产稳定分支。只有正式发布和生产 hotfix 应进入该分支。 + +### 1. 切换分支 + +```bash +git checkout release +git pull --rebase origin release +``` + +### 2. 配置 `.env` + +生产环境必须使用生产专用配置: + +```env +COMPOSE_PROJECT_NAME=ctms_prod +ENV=production +JWT_SECRET_KEY= +LOGIN_RSA_KEY_ID=prod-YYYYMMDD +LOGIN_RSA_PRIVATE_KEY= +``` + +要求: + +- `.env` 必须只保存在部署机器或密钥管理系统中。 +- 不得提交 `.env`、私钥、JWT 密钥。 +- `JWT_SECRET_KEY` 轮换会使既有 token 失效,应安排维护窗口。 +- `LOGIN_RSA_PRIVATE_KEY` 轮换会影响新登录密钥获取,应同步更新 `LOGIN_RSA_KEY_ID`。 + +### 3. HTTPS 要求 + +登录加密依赖浏览器 WebCrypto。生产访问必须使用 HTTPS: + +- `https://正式域名` +- 本地 `localhost` 是浏览器安全上下文例外,但不能代表生产可用性 + +如果生产仍通过 `http://服务器IP:8888` 访问,前端会拒绝执行登录加密。 + +### 4. 初始化与迁移 + +首次部署或每次包含 migration 的发布: + +```bash +docker compose run --rm backend-init +``` + +确认 migration 成功后再启动或滚动重启服务。 + +### 5. 启动 + +```bash +docker compose up -d --build +``` + +### 6. 生产验证 + +```bash +docker compose ps +docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV); print(settings.JWT_SECRET_KEY == 'dev-secret'); print(bool(settings.LOGIN_RSA_PRIVATE_KEY)); print(settings.LOGIN_RSA_KEY_ID)" +curl -i https:///health +curl -i https:///api/v1/auth/login-key +``` + +预期: + +```text +ENV=production +JWT_SECRET_KEY == dev-secret -> False +LOGIN_RSA_PRIVATE_KEY_SET -> True +GET /health -> 200 +GET /api/v1/auth/login-key -> 200 +``` + +### 7. 多实例约束 + +当前登录 challenge 默认保存在后端进程内: + +- 单实例、单 worker:可直接使用 +- 多实例或多 worker:必须满足以下至少一项 + - 使用共享缓存保存 challenge,例如 Redis + - 启用粘性会话,确保 `/login-key` 与 `/login` 命中同一后端进程 + - 将后端限制为单 worker 单副本 + +如果不满足,上线后可能出现偶发登录失败。 + +## 环境变量说明 + +| 变量 | dev | main/staging | release/production | 说明 | +| --- | --- | --- | --- | --- | +| `COMPOSE_PROJECT_NAME` | `ctms_dev` | `ctms_staging` | `ctms_prod` | 防止不同环境容器名、网络名冲突 | +| `ENV` | `development` | `production` | `production` | 后端运行模式 | +| `JWT_SECRET_KEY` | `dev-secret` | 强随机 | 强随机 | JWT 签名密钥 | +| `LOGIN_RSA_KEY_ID` | `default` | `staging-YYYYMMDD` | `prod-YYYYMMDD` | 登录 RSA 密钥版本 | +| `LOGIN_RSA_PRIVATE_KEY` | 空 | staging 私钥 | production 私钥 | RSA 私钥,生产模式必填 | + +## 常见问题 + +### 登录页提示当前浏览器环境不支持安全登录加密 + +原因:非 HTTPS、非 localhost 的访问环境不满足 WebCrypto 安全上下文要求。 + +处理: + +- 本地使用 `http://localhost:8888` +- staging/production 使用 HTTPS 域名 + +### production 模式启动失败,提示必须配置 LOGIN_RSA_PRIVATE_KEY + +原因:`ENV=production` 时必须提供固定 RSA 私钥。 + +处理: + +```bash +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 +``` + +将输出 PEM 写入 `.env` 的 `LOGIN_RSA_PRIVATE_KEY`,换行使用 `\n` 转义。 + +### 切换分支后环境不符合预期 + +分支不会自动修改 `.env`。切换分支后应重新确认: + +```bash +docker compose config +docker compose exec backend python -c "from app.core.config import settings; print(settings.ENV)" +``` + +必要时修改 `.env` 并重启: + +```bash +docker compose up -d --build backend nginx +``` diff --git a/docs/guides/setup-config-api.md b/docs/guides/setup-config-api.md index 64b1cffe..5ec66047 100644 --- a/docs/guides/setup-config-api.md +++ b/docs/guides/setup-config-api.md @@ -104,13 +104,21 @@ "plan_end_date": "2026-12-31", "planned_site_count": 12, "planned_enrollment_count": 120, - "summary_note": "", - "objective_note": "", "status": "DRAFT", - "visit_interval_days": null, - "visit_total": null, - "visit_window_start_offset": null, - "visit_window_end_offset": null + "visit_schedule": [ + { + "visit_code": "基线访视", + "baseline_offset_days": 0, + "window_before_days": 0, + "window_after_days": 0 + }, + { + "visit_code": "V1", + "baseline_offset_days": 7, + "window_before_days": 2, + "window_after_days": 2 + } + ] }, "saved_by": "11111111-1111-1111-1111-111111111111", "saved_by_name": "System Admin", diff --git a/docs/postman/setup-config.postman_collection.json b/docs/postman/setup-config.postman_collection.json index 5b7e5f97..07f9e509 100644 --- a/docs/postman/setup-config.postman_collection.json +++ b/docs/postman/setup-config.postman_collection.json @@ -6,7 +6,35 @@ }, "item": [ { - "name": "1. 登录(获取 Token)", + "name": "1. 获取登录公钥", + "request": { + "method": "GET", + "header": [], + "url": { + "raw": "{{base_url}}/api/v1/auth/login-key", + "host": ["{{base_url}}"], + "path": ["api", "v1", "auth", "login-key"] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test('status is 200', function () { pm.response.to.have.status(200); });", + "var json = pm.response.json();", + "pm.collectionVariables.set('login_key_id', json.key_id || '');", + "pm.collectionVariables.set('login_challenge', json.challenge || '');", + "pm.collectionVariables.set('login_public_key', json.public_key || '');", + "pm.collectionVariables.set('login_ciphertext', '<请生成 AES-GCM 密文,并用 login_public_key 通过 RSA-OAEP-SHA256 加密 AES key 后填写外层 Base64 envelope>');" + ] + } + } + ] + }, + { + "name": "2. 加密登录(获取 Token)", "request": { "method": "POST", "header": [ @@ -14,7 +42,7 @@ ], "body": { "mode": "raw", - "raw": "{\n \"email\": \"{{email}}\",\n \"password\": \"{{password}}\"\n}" + "raw": "{\n \"key_id\": \"{{login_key_id}}\",\n \"challenge\": \"{{login_challenge}}\",\n \"ciphertext\": \"{{login_ciphertext}}\"\n}" }, "url": { "raw": "{{base_url}}/api/v1/auth/login", diff --git a/docs/setup-config-curl-smoke.sh b/docs/setup-config-curl-smoke.sh index 1184c1e2..bf85e409 100755 --- a/docs/setup-config-curl-smoke.sh +++ b/docs/setup-config-curl-smoke.sh @@ -7,10 +7,65 @@ PASSWORD="${PASSWORD:-admin123}" STUDY_ID="${STUDY_ID:-aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa}" echo "[1/6] login: $EMAIL" -TOKEN=$(curl -sS -X POST "$BASE_URL/api/v1/auth/login" \ - -H 'Content-Type: application/json' \ - -d "{\"email\":\"$EMAIL\",\"password\":\"$PASSWORD\"}" | \ - python3 -c 'import json,sys; print(json.load(sys.stdin).get("access_token",""))') +TOKEN=$(BASE_URL="$BASE_URL" EMAIL="$EMAIL" PASSWORD="$PASSWORD" python3 - <<'PY' +import base64 +import json +import os +import urllib.request + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +base_url = os.environ["BASE_URL"] +email = os.environ["EMAIL"] +password = os.environ["PASSWORD"] + +with urllib.request.urlopen(f"{base_url}/api/v1/auth/login-key", timeout=20) as resp: + login_key = json.loads(resp.read().decode()) + +public_key = serialization.load_pem_public_key(login_key["public_key"].encode("utf-8")) +plaintext = json.dumps( + {"email": email, "password": password, "challenge": login_key["challenge"]}, + separators=(",", ":"), +).encode("utf-8") +aes_key = AESGCM.generate_key(bit_length=256) +iv = os.urandom(12) +encrypted_data = AESGCM(aes_key).encrypt(iv, plaintext, None) +encrypted_key = public_key.encrypt( + aes_key, + padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, + ), +) +payload = json.dumps( + { + "key_id": login_key["key_id"], + "challenge": login_key["challenge"], + "ciphertext": base64.b64encode( + json.dumps( + { + "encrypted_key": base64.b64encode(encrypted_key).decode("ascii"), + "iv": base64.b64encode(iv).decode("ascii"), + "data": base64.b64encode(encrypted_data).decode("ascii"), + }, + separators=(",", ":"), + ).encode("utf-8") + ).decode("ascii"), + } +).encode() +req = urllib.request.Request( + f"{base_url}/api/v1/auth/login", + method="POST", + headers={"Content-Type": "application/json"}, + data=payload, +) +with urllib.request.urlopen(req, timeout=20) as resp: + print(json.loads(resp.read().decode()).get("access_token", "")) +PY +) if [[ -z "$TOKEN" ]]; then echo "login failed: access_token empty" diff --git a/frontend/src/api/auth.ts b/frontend/src/api/auth.ts index 937ff9eb..01970c38 100644 --- a/frontend/src/api/auth.ts +++ b/frontend/src/api/auth.ts @@ -1,10 +1,13 @@ import type { AxiosResponse } from "axios"; import api, { apiGet, apiPatch, apiPost } from "./axios"; -import type { UserMeResponse, LoginRequest, LoginResponse, RegisterRequest } from "../types/api"; +import type { UserMeResponse, LoginRequest, LoginResponse, LoginKeyResponse, RegisterRequest } from "../types/api"; export const login = (payload: LoginRequest): Promise> => apiPost("/api/v1/auth/login", payload); +export const getLoginKey = (): Promise> => + apiGet("/api/v1/auth/login-key"); + export const fetchMe = (): Promise> => apiGet("/api/v1/auth/me"); export const register = (payload: RegisterRequest): Promise> => diff --git a/frontend/src/api/authClient.ts b/frontend/src/api/authClient.ts index 9c10975f..63a8f10f 100644 --- a/frontend/src/api/authClient.ts +++ b/frontend/src/api/authClient.ts @@ -16,6 +16,19 @@ export type UnlockResponse = { expiresAt: string; }; +export type LoginKeyResponse = { + key_id: string; + public_key: string; + challenge: string; + expires_at: string; +}; + +export type EncryptedPasswordRequest = { + key_id: string; + challenge: string; + ciphertext: string; +}; + export const extendToken = (token: string): Promise> => authClient.post( "/api/v1/auth/extend", @@ -27,7 +40,10 @@ export const extendToken = (token: string): Promise> => +export const getLoginKey = (): Promise> => + authClient.get("/api/v1/auth/login-key"); + +export const unlockSession = (payload: EncryptedPasswordRequest): Promise> => authClient.post("/api/v1/auth/unlock", payload); export default authClient; diff --git a/frontend/src/locales/zh-CN.ts b/frontend/src/locales/zh-CN.ts index 10122f57..47329ef2 100644 --- a/frontend/src/locales/zh-CN.ts +++ b/frontend/src/locales/zh-CN.ts @@ -121,11 +121,12 @@ export const TEXT = { screeningDate: "筛选日期", consentDate: "知情日期", enrollmentDate: "入组日期", + baselineDate: "基线/治疗日期", completionDate: "完成日期", dropReason: "退出原因", recordDate: "日期", content: "内容", - visitCode: "访视编号", + visitCode: "访视", plannedDate: "计划访视", actualDate: "实际访视", windowStart: "窗口开始", diff --git a/frontend/src/session/sessionManager.ts b/frontend/src/session/sessionManager.ts index 0157e415..a55c17c5 100644 --- a/frontend/src/session/sessionManager.ts +++ b/frontend/src/session/sessionManager.ts @@ -2,7 +2,8 @@ import router from "../router"; import { useAuthStore } from "../store/auth"; import { useSessionStore } from "../store/session"; import { getToken, setToken, clearToken } from "../utils/auth"; -import { extendToken, unlockSession } from "../api/authClient"; +import { extendToken, getLoginKey, unlockSession } from "../api/authClient"; +import { encryptLoginPayload } from "../utils/loginCrypto"; import { parseJwtExp } from "./jwt"; export const IDLE_TIMEOUT_MINUTES = 30; @@ -292,7 +293,17 @@ export const startTokenKeepAlive = () => { export const unlockWithPassword = async (email: string, password: string) => { const session = useSessionStore(); const auth = useAuthStore(); - const { data } = await unlockSession({ email, password }); + const { data: loginKey } = await getLoginKey(); + const ciphertext = await encryptLoginPayload(loginKey.public_key, { + email, + password, + challenge: loginKey.challenge, + }); + const { data } = await unlockSession({ + key_id: loginKey.key_id, + challenge: loginKey.challenge, + ciphertext, + }); updateToken(data.accessToken); session.unlock(); try { diff --git a/frontend/src/store/auth.ts b/frontend/src/store/auth.ts index 18a5979e..6520edf6 100644 --- a/frontend/src/store/auth.ts +++ b/frontend/src/store/auth.ts @@ -1,7 +1,8 @@ import { defineStore } from "pinia"; import { ref } from "vue"; -import { login as apiLogin, fetchMe } from "../api/auth"; +import { getLoginKey, login as apiLogin, fetchMe } from "../api/auth"; import { setToken, clearToken, getToken } from "../utils/auth"; +import { encryptLoginPayload } from "../utils/loginCrypto"; import type { UserInfo } from "../types/api"; import { useStudyStore } from "./study"; import { useSessionStore } from "./session"; @@ -16,7 +17,17 @@ export const useAuthStore = defineStore("auth", () => { const login = async (email: string, password: string) => { loading.value = true; try { - const { data } = await apiLogin({ email, password }); + const { data: loginKey } = await getLoginKey(); + const ciphertext = await encryptLoginPayload(loginKey.public_key, { + email, + password, + challenge: loginKey.challenge, + }); + const { data } = await apiLogin({ + key_id: loginKey.key_id, + challenge: loginKey.challenge, + ciphertext, + }); token.value = data.access_token; setToken(data.access_token); // 避免锁屏态下 fetchMe 被请求拦截 diff --git a/frontend/src/types/api.ts b/frontend/src/types/api.ts index ae8ca944..0cf85169 100644 --- a/frontend/src/types/api.ts +++ b/frontend/src/types/api.ts @@ -15,8 +15,9 @@ export interface ApiError { } export interface LoginRequest { - email: string; - password: string; + key_id: string; + challenge: string; + ciphertext: string; } export interface LoginResponse { @@ -24,6 +25,13 @@ export interface LoginResponse { token_type: string; } +export interface LoginKeyResponse { + key_id: string; + public_key: string; + challenge: string; + expires_at: string; +} + export type UserRole = "PM" | "CRA" | "PV" | "IMP" | "QA" | "ADMIN"; export type UserStatus = "PENDING" | "ACTIVE" | "REJECTED" | "DISABLED"; @@ -79,20 +87,22 @@ export interface Study { planned_enrollment_count?: number | null; enrollment_monthly_goal_note?: string | null; enrollment_stage_breakdown?: string | null; - summary_note?: string | null; - objective_note?: string | null; phase?: string | null; status: string; is_locked?: boolean; - visit_interval_days?: number | null; - visit_total?: number | null; - visit_window_start_offset?: number | null; - visit_window_end_offset?: number | null; + visit_schedule?: VisitScheduleItem[]; created_by?: string | null; created_at?: string; role_in_study?: string | null; } +export interface VisitScheduleItem { + visit_code: string; + baseline_offset_days: number; + window_before_days: number; + window_after_days: number; +} + export interface StudyMember { id: string; study_id: string; diff --git a/frontend/src/types/setupConfig.ts b/frontend/src/types/setupConfig.ts index 700a3996..c03b2748 100644 --- a/frontend/src/types/setupConfig.ts +++ b/frontend/src/types/setupConfig.ts @@ -66,6 +66,13 @@ export interface SetupConfigDraft { centerConfirm: CenterConfirmDraft[]; } +export interface VisitScheduleItem { + visit_code: string; + baseline_offset_days: number; + window_before_days: number; + window_after_days: number; +} + export interface ProjectPublishSnapshot { code: string; name: string; @@ -85,13 +92,8 @@ export interface ProjectPublishSnapshot { plan_end_date: string; planned_site_count: number | null; planned_enrollment_count: number | null; - summary_note: string; - objective_note: string; status: string; - visit_interval_days: number | null; - visit_total: number | null; - visit_window_start_offset: number | null; - visit_window_end_offset: number | null; + visit_schedule: VisitScheduleItem[]; } export interface StudySetupConfigResponse { diff --git a/frontend/src/utils/auth.ts b/frontend/src/utils/auth.ts index f5e16640..dbdf55c6 100644 --- a/frontend/src/utils/auth.ts +++ b/frontend/src/utils/auth.ts @@ -1,5 +1,4 @@ const TOKEN_KEY = "ctms_token"; -const CREDENTIAL_KEY = "ctms_credential"; export const getToken = (): string | null => localStorage.getItem(TOKEN_KEY); @@ -10,30 +9,3 @@ export const setToken = (token: string): void => { export const clearToken = (): void => { localStorage.removeItem(TOKEN_KEY); }; - -export const setCachedCredential = (email: string, password: string) => { - try { - const payload = btoa(JSON.stringify({ email, password })); - localStorage.setItem(CREDENTIAL_KEY, payload); - } catch { - /* ignore */ - } -}; - -export const getCachedCredential = (): { email: string; password: string } | null => { - const value = localStorage.getItem(CREDENTIAL_KEY); - if (!value) return null; - try { - const parsed = JSON.parse(atob(value)); - if (parsed?.email && parsed?.password) { - return { email: parsed.email, password: parsed.password }; - } - } catch { - /* ignore */ - } - return null; -}; - -export const clearCachedCredential = (): void => { - localStorage.removeItem(CREDENTIAL_KEY); -}; diff --git a/frontend/src/utils/loginCrypto.test.ts b/frontend/src/utils/loginCrypto.test.ts new file mode 100644 index 00000000..860de348 --- /dev/null +++ b/frontend/src/utils/loginCrypto.test.ts @@ -0,0 +1,18 @@ +import { describe, expect, it, vi } from "vitest"; +import { encryptLoginPayload } from "./loginCrypto"; + +describe("encryptLoginPayload", () => { + it("fails with a clear error outside a secure browser context", async () => { + vi.stubGlobal("isSecureContext", false); + + await expect( + encryptLoginPayload("unused", { + email: "admin@test.com", + password: "admin123", + challenge: "challenge-value-with-enough-length", + }) + ).rejects.toThrow("当前浏览器环境不支持安全登录加密"); + + vi.unstubAllGlobals(); + }); +}); diff --git a/frontend/src/utils/loginCrypto.ts b/frontend/src/utils/loginCrypto.ts new file mode 100644 index 00000000..cca17679 --- /dev/null +++ b/frontend/src/utils/loginCrypto.ts @@ -0,0 +1,56 @@ +const pemToArrayBuffer = (pem: string): ArrayBuffer => { + const base64 = pem + .replace(/-----BEGIN PUBLIC KEY-----/g, "") + .replace(/-----END PUBLIC KEY-----/g, "") + .replace(/\s/g, ""); + const binary = atob(base64); + const bytes = new Uint8Array(binary.length); + for (let index = 0; index < binary.length; index += 1) { + bytes[index] = binary.charCodeAt(index); + } + return bytes.buffer; +}; + +const arrayBufferToBase64 = (buffer: ArrayBuffer): string => { + const bytes = new Uint8Array(buffer); + let binary = ""; + bytes.forEach((byte) => { + binary += String.fromCharCode(byte); + }); + return btoa(binary); +}; + +const assertSecureCryptoAvailable = () => { + if (!window.isSecureContext || !crypto?.subtle) { + throw new Error("当前浏览器环境不支持安全登录加密,请使用 HTTPS 或 localhost 访问系统"); + } +}; + +export const encryptLoginPayload = async ( + publicKeyPem: string, + payload: { email: string; password: string; challenge: string } +): Promise => { + assertSecureCryptoAvailable(); + const publicKey = await crypto.subtle.importKey( + "spki", + pemToArrayBuffer(publicKeyPem), + { + name: "RSA-OAEP", + hash: "SHA-256", + }, + false, + ["encrypt"] + ); + const aesKey = await crypto.subtle.generateKey({ name: "AES-GCM", length: 256 }, true, ["encrypt"]); + const iv = crypto.getRandomValues(new Uint8Array(12)); + const encoded = new TextEncoder().encode(JSON.stringify(payload)); + const encryptedData = await crypto.subtle.encrypt({ name: "AES-GCM", iv }, aesKey, encoded); + const rawAesKey = await crypto.subtle.exportKey("raw", aesKey); + const encryptedKey = await crypto.subtle.encrypt({ name: "RSA-OAEP" }, publicKey, rawAesKey); + const envelope = { + encrypted_key: arrayBufferToBase64(encryptedKey), + iv: arrayBufferToBase64(iv.buffer), + data: arrayBufferToBase64(encryptedData), + }; + return btoa(JSON.stringify(envelope)); +}; diff --git a/frontend/src/utils/setupPublishWorkflow.ts b/frontend/src/utils/setupPublishWorkflow.ts index 95175736..7aff9934 100644 --- a/frontend/src/utils/setupPublishWorkflow.ts +++ b/frontend/src/utils/setupPublishWorkflow.ts @@ -118,13 +118,8 @@ const PROJECT_FIELD_LABEL_MAP: Record = { plan_end_date: "计划结束日期", planned_site_count: "计划中心数", planned_enrollment_count: "计划入组数", - summary_note: "方案摘要说明", - objective_note: "研究目标摘要", status: "项目状态", - visit_interval_days: "访视间隔(天)", - visit_total: "访视总数", - visit_window_start_offset: "窗口期起始偏移", - visit_window_end_offset: "窗口期结束偏移", + visit_schedule: "访视计划", }; export const buildProjectDiffRows = ( diff --git a/frontend/src/views/Login.vue b/frontend/src/views/Login.vue index 7191cd5b..9ec09681 100644 --- a/frontend/src/views/Login.vue +++ b/frontend/src/views/Login.vue @@ -74,12 +74,6 @@ -
- - 记住此设备 - -
- @@ -100,7 +94,6 @@ import { useRouter } from "vue-router"; import { ElMessage, type FormInstance, type FormRules } from "element-plus"; import { useAuthStore } from "../store/auth"; import { useStudyStore } from "../store/study"; -import { getCachedCredential, setCachedCredential, clearCachedCredential } from "../utils/auth"; import { TEXT, requiredMessage } from "../locales"; import { consumeLogoutReason, LOGOUT_REASON_TIMEOUT } from "../session/sessionManager"; @@ -111,7 +104,6 @@ const formRef = ref(); const form = reactive({ email: "", password: "", - remember: false, }); const rules: FormRules = { @@ -129,12 +121,7 @@ onMounted(() => { if (logoutReason === LOGOUT_REASON_TIMEOUT) { ElMessage.warning("长时间未操作,已自动退出登录,请重新登录"); } - const cached = getCachedCredential(); - if (cached) { - form.email = cached.email; - form.password = cached.password; - form.remember = true; - } + form.email = localStorage.getItem("ctms_last_login_email") || ""; }); const onSubmit = async () => { @@ -144,11 +131,6 @@ const onSubmit = async () => { loading.value = true; try { await auth.login(form.email, form.password); - if (form.remember) { - setCachedCredential(form.email, form.password); - } else { - clearCachedCredential(); - } const studyStore = useStudyStore(); if (!studyStore.currentStudy) { if (auth.user?.role === "ADMIN") { @@ -327,22 +309,6 @@ const onSubmit = async () => { transition: background-color 5000s ease-in-out 0s; } -.remember-row { - display: flex; - align-items: center; - justify-content: flex-start; - margin: -6px 0 18px; -} - -.remember-checkbox :deep(.el-checkbox__label) { - padding-left: 8px; -} - -.remember-text { - color: #64748b; - font-size: 13px; -} - .login-btn { width: 100%; height: 48px; diff --git a/frontend/src/views/admin/ProjectDetail.vue b/frontend/src/views/admin/ProjectDetail.vue index 85491aba..7671737b 100644 --- a/frontend/src/views/admin/ProjectDetail.vue +++ b/frontend/src/views/admin/ProjectDetail.vue @@ -471,55 +471,12 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- {{ item.label }} - - {{ item.value || "-" }} +
+
+
访视计划
+
已更新 - + + 编辑 + +
+
+
+
+ 访视 + 基线后天数 + 访视窗 +
+
+ {{ row.visit_code || "-" }} + {{ row.baseline_offset_days }} 天 + {{ formatVisitWindow(row) }} +
+
暂无访视计划
@@ -1661,6 +1644,100 @@ + + +
+ 添加访视 +
+
+
+ 访视 + 基线后天数 + 窗口前(天) + 窗口后(天) + +
+
+
+ 访视 + + + + + + + + + +
+
+ 基线后天数 + +
+
+ 窗口前 + +
+
+ 窗口后 + +
+ +
+
+
+ +
+ ("basic"); const step1EditSection = ref("all"); const isEditing = ref(false); const drawerClosing = ref(false); +const visitScheduleDialogVisible = ref(false); const drawerVisible = computed(() => { + if (visitScheduleDialogVisible.value) return false; if (activeStep.value === 2 || activeStep.value === 4) return false; return isEditing.value || drawerClosing.value; }); +const visitScheduleDragIndex = ref(null); +const visitScheduleDragOverIndex = ref(null); +type VisitScheduleInsertPosition = "before" | "after"; +const visitScheduleInsertPosition = ref("before"); const drawerCloseTimer = ref(undefined); const submitting = ref(false); const formRef = ref(); @@ -2311,14 +2395,148 @@ const form = ref({ phase: "", status: "DRAFT", scope: "国内", - summary_note: "", - objective_note: "", - visit_interval_days: null as number | null, - visit_total: null as number | null, - visit_window_start_offset: null as number | null, - visit_window_end_offset: null as number | null, + visit_schedule: [] as VisitScheduleItem[], }); const formBaselineSnapshot = ref(""); +const regularVisitOptionValue = "__REGULAR_VISIT__"; +const safetyFollowUpVisitOptionValue = "__SAFETY_FOLLOW_UP_VISIT__"; +const specialVisitOptions = ["筛选访视", "基线访视", "终止治疗", "提前终止"]; + +const getNextRegularVisitCode = (excludeIndex?: number): string => { + const maxRegularIndex = form.value.visit_schedule.reduce((maxIndex, row, rowIndex) => { + if (rowIndex === excludeIndex) return maxIndex; + const match = /^V(\d+)$/.exec((row.visit_code || "").trim()); + if (!match) return maxIndex; + return Math.max(maxIndex, Number(match[1])); + }, 0); + return `V${maxRegularIndex + 1}`; +}; + +const getNextSafetyFollowUpVisitCode = (excludeIndex?: number): string => { + const maxSafetyFollowUpIndex = form.value.visit_schedule.reduce((maxIndex, row, rowIndex) => { + if (rowIndex === excludeIndex) return maxIndex; + const match = /^安全性随访V(\d+)$/.exec((row.visit_code || "").trim()); + if (!match) return maxIndex; + return Math.max(maxIndex, Number(match[1])); + }, 0); + return `安全性随访V${maxSafetyFollowUpIndex + 1}`; +}; + +const hasDuplicateVisitCode = (visitCode: string, currentIndex: number): boolean => { + const normalizedCode = visitCode.trim(); + if (!normalizedCode) return false; + return form.value.visit_schedule.some( + (row, rowIndex) => rowIndex !== currentIndex && (row.visit_code || "").trim() === normalizedCode + ); +}; + +const normalizeVisitSchedule = (rows: VisitScheduleItem[] | undefined | null): VisitScheduleItem[] => + (rows || []).map((row) => ({ + visit_code: (row.visit_code || "").trim(), + baseline_offset_days: Number(row.baseline_offset_days || 0), + window_before_days: Number(row.window_before_days || 0), + window_after_days: Number(row.window_after_days || 0), + })); + +const addVisitScheduleRow = () => { + form.value.visit_schedule.push({ + visit_code: getNextRegularVisitCode(), + baseline_offset_days: 0, + window_before_days: 0, + window_after_days: 0, + }); +}; + +const removeVisitScheduleRow = (index: number) => { + form.value.visit_schedule.splice(index, 1); +}; + +const handleVisitScheduleCodeChange = (index: number, value: string) => { + const row = form.value.visit_schedule[index]; + if (!row) return; + let nextVisitCode = String(value || "").trim(); + if (nextVisitCode === regularVisitOptionValue) { + nextVisitCode = getNextRegularVisitCode(index); + } else if (nextVisitCode === safetyFollowUpVisitOptionValue) { + nextVisitCode = getNextSafetyFollowUpVisitCode(index); + } + if (hasDuplicateVisitCode(nextVisitCode, index)) { + row.visit_code = ""; + ElMessage.warning(`访视已存在:${nextVisitCode}`); + return; + } + row.visit_code = nextVisitCode; +}; + +const handleVisitScheduleDragStart = (index: number, event: DragEvent) => { + visitScheduleDragIndex.value = index; + visitScheduleDragOverIndex.value = index; + visitScheduleInsertPosition.value = "before"; + event.dataTransfer?.setData("text/plain", String(index)); + if (event.dataTransfer) { + event.dataTransfer.effectAllowed = "move"; + } +}; + +const handleVisitScheduleDragOver = (index: number, event: DragEvent) => { + if (visitScheduleDragIndex.value === null) return; + const target = event.currentTarget as HTMLElement | null; + if (target) { + const rect = target.getBoundingClientRect(); + visitScheduleInsertPosition.value = event.clientY < rect.top + rect.height / 2 ? "before" : "after"; + } + visitScheduleDragOverIndex.value = index; +}; + +const handleVisitScheduleDrop = (targetIndex: number) => { + const sourceIndex = visitScheduleDragIndex.value; + if (sourceIndex === null) { + handleVisitScheduleDragEnd(); + return; + } + let insertIndex = visitScheduleInsertPosition.value === "after" ? targetIndex + 1 : targetIndex; + if (sourceIndex === targetIndex || sourceIndex + 1 === insertIndex) { + handleVisitScheduleDragEnd(); + return; + } + const nextRows = [...form.value.visit_schedule]; + const [movedRow] = nextRows.splice(sourceIndex, 1); + if (!movedRow) { + handleVisitScheduleDragEnd(); + return; + } + if (sourceIndex < insertIndex) { + insertIndex -= 1; + } + nextRows.splice(insertIndex, 0, movedRow); + form.value.visit_schedule = nextRows; + handleVisitScheduleDragEnd(); +}; + +const handleVisitScheduleDragEnd = () => { + visitScheduleDragIndex.value = null; + visitScheduleDragOverIndex.value = null; + visitScheduleInsertPosition.value = "before"; +}; + +const isVisitScheduleInsertTarget = (index: number, position: VisitScheduleInsertPosition) => + visitScheduleDragIndex.value !== null && + visitScheduleDragIndex.value !== index && + visitScheduleDragOverIndex.value === index && + visitScheduleInsertPosition.value === position; + +const formatVisitSchedule = (rows: VisitScheduleItem[] | undefined | null): string => { + const normalized = normalizeVisitSchedule(rows); + if (!normalized.length) return "-"; + return normalized + .map( + (row) => + `${row.visit_code || "-"}:基线+${row.baseline_offset_days}天,窗口-${row.window_before_days}/+${row.window_after_days}天` + ) + .join(";"); +}; + +const formatVisitWindow = (row: VisitScheduleItem): string => `-${row.window_before_days} / +${row.window_after_days} 天`; const setupDraft = reactive({ projectMilestones: [], @@ -2354,9 +2572,6 @@ const canEditSetup = computed( const canMutateDraft = () => canEditSetup.value && !isPublishedView.value; const canEditStep1BasicGroup = (group: Exclude) => activeStep.value === 0 && isEditing.value && (step1EditSection.value === "all" || step1EditSection.value === group); -const canEditStep1Summary = computed( - () => activeStep.value === 0 && isEditing.value && (step1EditSection.value === "all" || step1EditSection.value === "summary") -); const isStep1BasicScopedEdit = computed( () => activeStep.value === 0 && isEditing.value && ["project", "research", "execution"].includes(step1EditSection.value) ); @@ -2370,7 +2585,7 @@ const stepActionMode = computed(() => { case 2: return "edit"; case 0: - return infoTab.value === "summary" ? "edit" : "none"; + return "none"; case 1: return "project-milestone"; case 3: @@ -2483,14 +2698,9 @@ const serializeFormForCompare = (): string => plan_start_date: "", plan_end_date: "", planned_enrollment_count: null, - summary_note: form.value.summary_note || "", - objective_note: form.value.objective_note || "", phase: form.value.phase || "", status: form.value.status || "", - visit_interval_days: form.value.visit_interval_days, - visit_total: form.value.visit_total, - visit_window_start_offset: form.value.visit_window_start_offset, - visit_window_end_offset: form.value.visit_window_end_offset, + visit_schedule: normalizeVisitSchedule(form.value.visit_schedule), }); const serializeSetupForCompare = (): string => JSON.stringify(clone(setupDraft)); const buildProjectPublishSnapshotFromStudy = (study: Partial | null | undefined): ProjectPublishSnapshot => ({ @@ -2512,13 +2722,8 @@ const buildProjectPublishSnapshotFromStudy = (study: Partial | null | und plan_end_date: study?.plan_end_date || "", planned_site_count: study?.planned_site_count ?? null, planned_enrollment_count: study?.planned_enrollment_count ?? null, - summary_note: study?.summary_note || "", - objective_note: study?.objective_note || "", status: study?.status || "", - visit_interval_days: study?.visit_interval_days ?? null, - visit_total: study?.visit_total ?? null, - visit_window_start_offset: study?.visit_window_start_offset ?? null, - visit_window_end_offset: study?.visit_window_end_offset ?? null, + visit_schedule: normalizeVisitSchedule(study?.visit_schedule), }); const buildProjectPublishSnapshot = (): ProjectPublishSnapshot => buildProjectPublishSnapshotFromStudy({ @@ -2540,13 +2745,8 @@ const buildProjectPublishSnapshot = (): ProjectPublishSnapshot => plan_end_date: form.value.plan_end_date, planned_site_count: form.value.planned_site_count, planned_enrollment_count: form.value.planned_enrollment_count, - summary_note: form.value.summary_note, - objective_note: form.value.objective_note, status: form.value.status, - visit_interval_days: form.value.visit_interval_days, - visit_total: form.value.visit_total, - visit_window_start_offset: form.value.visit_window_start_offset, - visit_window_end_offset: form.value.visit_window_end_offset, + visit_schedule: normalizeVisitSchedule(form.value.visit_schedule), }); const serializeProjectForPublishCompare = (): string => JSON.stringify(buildProjectPublishSnapshot()); const hasFormUnsavedChanges = computed(() => Boolean(formBaselineSnapshot.value && serializeFormForCompare() !== formBaselineSnapshot.value)); @@ -2774,14 +2974,7 @@ const executionInfoItems = computed(() => [ }, ]); -const summaryInfoItems = computed(() => [ - { label: "访视总数", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_total), updated: isProjectSnapshotFieldUpdated("visit_total") }, - { label: "访视间隔(天)", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_interval_days), updated: isProjectSnapshotFieldUpdated("visit_interval_days") }, - { label: "窗口期起始偏移", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_window_start_offset), updated: isProjectSnapshotFieldUpdated("visit_window_start_offset") }, - { label: "窗口期结束偏移", value: toDisplayNumber(currentProjectPublishSnapshot.value.visit_window_end_offset), updated: isProjectSnapshotFieldUpdated("visit_window_end_offset") }, - { label: "方案摘要说明", value: currentProjectPublishSnapshot.value.summary_note || "-", updated: isProjectSnapshotFieldUpdated("summary_note") }, - { label: "研究目标摘要", value: currentProjectPublishSnapshot.value.objective_note || "-", updated: isProjectSnapshotFieldUpdated("objective_note") }, -]); +const summaryVisitScheduleRows = computed(() => normalizeVisitSchedule(currentProjectPublishSnapshot.value.visit_schedule)); const rules = ref({ code: [{ required: true, message: requiredMessage(TEXT.common.fields.projectCode), trigger: "blur" }], @@ -4283,12 +4476,7 @@ const syncForm = (data: Study | null) => { form.value.phase = data?.phase || ""; form.value.status = data?.status || "DRAFT"; form.value.scope = "国内"; - form.value.summary_note = data?.summary_note || ""; - form.value.objective_note = data?.objective_note || ""; - form.value.visit_interval_days = data?.visit_interval_days ?? null; - form.value.visit_total = data?.visit_total ?? null; - form.value.visit_window_start_offset = data?.visit_window_start_offset ?? null; - form.value.visit_window_end_offset = data?.visit_window_end_offset ?? null; + form.value.visit_schedule = normalizeVisitSchedule(data?.visit_schedule); formBaselineSnapshot.value = serializeFormForCompare(); }; @@ -4498,6 +4686,9 @@ const startStep1SectionEdit = (section: Exclude) => { startEdit(); if (isEditing.value) { step1EditSection.value = section; + if (section === "summary") { + visitScheduleDialogVisible.value = true; + } } }; @@ -4512,12 +4703,17 @@ const cancelEdit = () => { } isEditing.value = false; step1EditSection.value = "all"; + visitScheduleDialogVisible.value = false; clearSetupValidationErrors(); formBaselineSnapshot.value = serializeFormForCompare(); }; const closeEditDrawer = () => { if (!isEditing.value || drawerClosing.value) return; + if (visitScheduleDialogVisible.value) { + cancelEdit(); + return; + } drawerClosing.value = true; drawerCloseTimer.value = window.setTimeout(() => { cancelEdit(); @@ -4528,6 +4724,15 @@ const closeEditDrawer = () => { const closeEditDrawerAfterSave = (onClosed?: () => void) => { if (!isEditing.value || drawerClosing.value) return; + if (visitScheduleDialogVisible.value) { + visitScheduleDialogVisible.value = false; + isEditing.value = false; + step1EditSection.value = "all"; + clearSetupValidationErrors(); + formBaselineSnapshot.value = serializeFormForCompare(); + onClosed?.(); + return; + } drawerClosing.value = true; drawerCloseTimer.value = window.setTimeout(() => { isEditing.value = false; @@ -4540,6 +4745,32 @@ const closeEditDrawerAfterSave = (onClosed?: () => void) => { }, 220); }; +const cancelVisitScheduleDialogEdit = () => { + cancelEdit(); +}; + +const saveVisitScheduleDialogEdit = async () => { + if (!canEditSetup.value) return; + drawerConfirmLoading.value = true; + try { + const projectChanged = refreshProjectDirtyState(); + if (projectChanged) { + persistProjectDraftToLocal(); + } else { + clearLocalProjectDraft(); + markServerSyncedIfNoLocalChanges(); + } + formBaselineSnapshot.value = serializeFormForCompare(); + visitScheduleDialogVisible.value = false; + isEditing.value = false; + step1EditSection.value = "all"; + clearSetupValidationErrors(); + ElMessage.success("访视计划已保存"); + } finally { + drawerConfirmLoading.value = false; + } +}; + const handleDrawerConfirm = async () => { if (!canEditSetup.value) return; drawerConfirmLoading.value = true; @@ -4603,14 +4834,9 @@ const saveEdit = async (closeAfterSave = true, showSuccessMessage = true): Promi plan_end_date: form.value.plan_end_date || null, planned_site_count: form.value.planned_site_count, planned_enrollment_count: form.value.planned_enrollment_count, - summary_note: form.value.summary_note?.trim() || null, - objective_note: form.value.objective_note?.trim() || null, phase: form.value.phase?.trim() || null, status: form.value.status, - visit_interval_days: form.value.visit_interval_days, - visit_total: form.value.visit_total, - visit_window_start_offset: form.value.visit_window_start_offset, - visit_window_end_offset: form.value.visit_window_end_offset, + visit_schedule: normalizeVisitSchedule(form.value.visit_schedule), }); project.value = data as Study; syncFormFromProjectWithoutSetupLink(project.value); @@ -5225,12 +5451,7 @@ const downloadVersionRaw = (versionItem: StudySetupConfigVersionItem) => { ["项目状态", projectSnapshot?.status ? statusLabel(projectSnapshot.status) : "-"], ["计划中心数", projectSnapshot?.planned_site_count ?? "-"], ["计划入组数", projectSnapshot?.planned_enrollment_count ?? "-"], - ["访视总数", projectSnapshot?.visit_total ?? "-"], - ["访视间隔(天)", projectSnapshot?.visit_interval_days ?? "-"], - ["窗口期起始偏移", projectSnapshot?.visit_window_start_offset ?? "-"], - ["窗口期结束偏移", projectSnapshot?.visit_window_end_offset ?? "-"], - ["方案摘要说明", projectSnapshot?.summary_note || "-"], - ["研究目标摘要", projectSnapshot?.objective_note || "-"], + ["访视计划", formatVisitSchedule(projectSnapshot?.visit_schedule)], ]; const step2Headers = ["里程碑", "计划日期", "开始日期", "结束日期", "耗时(天)", "负责人", "状态", "备注"]; @@ -6962,6 +7183,7 @@ onBeforeUnmount(() => { } .setup-section { + position: relative; padding: 8px 12px 12px; border-bottom: 1px solid #edf2f8; background: #ffffff; @@ -7559,6 +7781,12 @@ onBeforeUnmount(() => { gap: 8px; } +.info-group-title-actions { + display: flex; + align-items: center; + gap: 10px; +} + .info-group-edit-btn { min-width: 64px; height: 28px; @@ -7674,6 +7902,7 @@ onBeforeUnmount(() => { .setup-info-tabs { margin-top: -8px; + position: relative; } .setup-section :deep(.el-tabs__item) { @@ -8017,6 +8246,202 @@ onBeforeUnmount(() => { margin-bottom: 8px; } +.summary-section-bar { + display: flex; + align-items: center; + justify-content: flex-end; + gap: 12px; + margin: 10px 0 8px; +} + +.summary-display-group { + padding-top: 16px; +} + +.visit-schedule-display { + width: 100%; + border: 1px solid #e1e9f3; + border-radius: 8px; + overflow: hidden; + background: #fff; +} + +.visit-schedule-display-head, +.visit-schedule-display-row { + display: grid; + grid-template-columns: minmax(160px, 0.8fr) minmax(160px, 0.8fr) minmax(220px, 1fr); + gap: 16px; + align-items: center; +} + +.visit-schedule-display-head { + min-height: 40px; + padding: 0 14px; + background: #f3f7fc; + border-bottom: 1px solid #dce6f2; + color: #35516f; + font-size: 13px; + font-weight: 800; +} + +.visit-schedule-display-row { + min-height: 42px; + padding: 0 14px; + border-bottom: 1px solid #edf2f7; + color: #334155; + font-size: 13px; +} + +.visit-schedule-display-row:last-of-type { + border-bottom: none; +} + +.visit-schedule-display-code { + color: #0f172a; + font-weight: 700; +} + +.visit-schedule-display-empty { + padding: 18px 14px; + color: #94a3b8; + font-size: 13px; + text-align: center; +} + +.visit-schedule-editor { + width: 100%; + border: 1px solid #e1e9f3; + border-radius: 8px; + overflow: hidden; + background: #fff; +} + +.visit-schedule-head, +.visit-schedule-row { + display: grid; + grid-template-columns: minmax(220px, 0.9fr) minmax(210px, 1fr) minmax(150px, 0.75fr) minmax(150px, 0.75fr) 48px; + gap: 12px; + align-items: center; +} + +.visit-schedule-head { + min-height: 40px; + padding: 0 12px; + background: #f3f7fc; + border-bottom: 1px solid #dce6f2; + color: #35516f; + font-size: 13px; + font-weight: 800; +} + +.visit-schedule-row { + position: relative; + padding: 9px 12px; + border-bottom: 1px solid #edf2f7; + cursor: grab; + transition: background-color 0.16s ease, box-shadow 0.16s ease, opacity 0.16s ease; +} + +.visit-schedule-row:active { + cursor: grabbing; +} + +.visit-schedule-row:last-child { + border-bottom: none; +} + +.visit-schedule-row.is-dragging { + background: #f8fbff; + opacity: 0.62; +} + +.visit-schedule-row.insert-before::before, +.visit-schedule-row.insert-after::after { + position: absolute; + left: 12px; + right: 12px; + z-index: 2; + height: 3px; + border-radius: 999px; + background: #2f80ed; + box-shadow: 0 0 0 3px rgba(47, 128, 237, 0.14); + content: ""; +} + +.visit-schedule-row.insert-before::before { + top: -2px; +} + +.visit-schedule-row.insert-after::after { + bottom: -2px; +} + +.visit-schedule-row.insert-before, +.visit-schedule-row.insert-after { + background: #f8fbff; +} + +.visit-code-field { + min-width: 0; +} + +.visit-field { + min-width: 0; +} + +.visit-field-label { + display: none; + margin-bottom: 5px; + color: #64748b; + font-size: 12px; + font-weight: 700; +} + +.visit-schedule-remove { + justify-self: center; + width: 32px; + height: 32px; +} + +.visit-schedule-add { + margin-top: 0; +} + +.visit-schedule-dialog :deep(.el-dialog) { + border-radius: 10px; +} + +.visit-schedule-dialog :deep(.el-dialog__header) { + padding: 18px 20px 12px; + margin: 0; + border-bottom: 1px solid #e6edf7; +} + +.visit-schedule-dialog :deep(.el-dialog__title) { + color: #0f172a; + font-size: 18px; + font-weight: 700; +} + +.visit-schedule-dialog :deep(.el-dialog__body) { + padding: 14px 20px 18px; +} + +.visit-schedule-dialog :deep(.el-dialog__footer) { + padding: 12px 20px; + border-top: 1px solid #e6edf7; +} + +.visit-schedule-dialog-form .summary-section-bar { + margin-top: 0; +} + +.visit-schedule-dialog-footer { + display: flex; + justify-content: flex-end; + gap: 10px; +} + .conflict-readable-diff :deep(.el-table th.el-table__cell) { background: #f7faff; color: #2f4672; @@ -8040,6 +8465,33 @@ onBeforeUnmount(() => { grid-template-columns: 1fr; } + .visit-schedule-head { + display: none; + } + + .visit-schedule-display-head { + display: none; + } + + .visit-schedule-display-row { + grid-template-columns: 1fr; + gap: 4px; + padding: 10px 14px; + } + + .visit-schedule-row { + grid-template-columns: 1fr; + gap: 10px; + } + + .visit-field-label { + display: block; + } + + .visit-schedule-remove { + justify-self: flex-end; + } + .flow-label { font-size: 14px; } diff --git a/frontend/src/views/subjects/SubjectDetail.vue b/frontend/src/views/subjects/SubjectDetail.vue index 6d04a7fa..b070c0f8 100644 --- a/frontend/src/views/subjects/SubjectDetail.vue +++ b/frontend/src/views/subjects/SubjectDetail.vue @@ -26,7 +26,7 @@
- + {{ detail.subject_no || TEXT.common.fallback }} {{ siteMap[detail.site_id] || TEXT.common.fallback }} @@ -59,6 +59,16 @@ /> {{ displayDate(detail.enrollment_date) }} + + + {{ displayDate(detail.baseline_date) }} + - + +
+ + {{ currentTabAction.label }} + +
-
- - {{ TEXT.common.actions.newHistory }} - -
@@ -118,11 +128,6 @@
-
- - {{ TEXT.common.actions.newVisit }} - -
@@ -201,11 +206,6 @@
-
- - {{ TEXT.common.actions.newAe }} - -
@@ -259,11 +259,6 @@
-
- - {{ TEXT.modules.subjectDetail.newPd }} - -
@@ -455,6 +450,7 @@ const detail = reactive({ screening_date: "", consent_date: "", enrollment_date: "", + baseline_date: "", completion_date: "", drop_reason: "", }); @@ -464,6 +460,7 @@ const subjectSaving = ref(false); const subjectForm = reactive({ status: "SCREENING", enrollment_date: "", + baseline_date: "", consent_date: "", completion_date: "", drop_reason: "", @@ -615,6 +612,7 @@ const startSubjectEdit = () => { subjectForm.status = detail.status || "SCREENING"; subjectForm.consent_date = detail.consent_date || ""; subjectForm.enrollment_date = detail.enrollment_date || ""; + subjectForm.baseline_date = detail.baseline_date || ""; subjectForm.completion_date = detail.completion_date || ""; subjectForm.drop_reason = detail.drop_reason || ""; subjectEditing.value = true; @@ -636,13 +634,15 @@ const saveSubjectEdit = async () => { status: subjectForm.status || null, consent_date: subjectForm.consent_date || null, enrollment_date: subjectForm.enrollment_date || null, + baseline_date: subjectForm.baseline_date || null, completion_date: subjectForm.completion_date || null, drop_reason: subjectForm.drop_reason || null, }; await updateSubject(studyId, subjectId, payload); ElMessage.success(TEXT.common.messages.saveSuccess); subjectEditing.value = false; - loadSubject(); + await loadSubject(); + await loadVisits(); } catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.saveFailed); } finally { @@ -720,15 +720,7 @@ const loadVisits = async () => { try { const { data } = (await fetchVisits(studyId, subjectId)) as any; const items = Array.isArray(data) ? data : data.items || []; - visitItems.value = items.sort((a: any, b: any) => { - const getNum = (code: any) => { - const text = String(code || ""); - if (!text.startsWith("V")) return Number.POSITIVE_INFINITY; - const num = Number(text.slice(1)); - return Number.isFinite(num) ? num : Number.POSITIVE_INFINITY; - }; - return getNum(a?.visit_code) - getNum(b?.visit_code); - }); + visitItems.value = items; } catch (e: any) { ElMessage.error(e?.response?.data?.message || TEXT.common.messages.loadFailed); } finally { @@ -1036,6 +1028,20 @@ const openPdDialog = (row?: any) => { pdDialogVisible.value = true; }; +const currentTabAction = computed(() => { + switch (activeTab.value) { + case "visits": + return { label: TEXT.common.actions.newVisit, onClick: () => openVisitDialog() }; + case "ae": + return { label: TEXT.common.actions.newAe, onClick: () => openAeDialog() }; + case "pd": + return { label: TEXT.modules.subjectDetail.newPd, onClick: () => openPdDialog() }; + case "history": + default: + return { label: TEXT.common.actions.newHistory, onClick: () => openHistoryDialog() }; + } +}); + const savePd = async () => { if (isReadOnly.value) { ElMessage.warning("中心已停用"); @@ -1123,6 +1129,68 @@ onMounted(async () => { padding: 0; } +.subject-tabs-shell { + margin-top: 12px; + border-top: 8px solid #f3f6fb; +} + +.subject-tabs-shell { + position: relative; +} + +.subject-tabs-shell :deep(.el-tabs__header) { + margin: 0; + padding: 0 160px 0 0; + background: #ffffff; + border-top: 1px solid #e5edf6; + border-bottom: 1px solid #e5edf6; +} + +.subject-tabs-shell :deep(.el-tabs__nav-wrap) { + padding-left: 0; +} + +.subject-tabs-shell :deep(.el-tabs__item) { + height: 48px; + line-height: 48px; + font-weight: 700; +} + +.subject-tabs-shell :deep(.el-tabs__content) { + padding-top: 0; + background: #ffffff; +} + +.subject-tabs-action { + position: absolute; + top: 8px; + right: 0; + z-index: 2; + display: flex; + align-items: center; +} + +.subject-overview-descriptions :deep(.el-descriptions__table) { + table-layout: fixed; +} + +.subject-overview-descriptions :deep(.el-descriptions__label) { + width: 140px; + min-width: 140px; + color: #344258; + font-weight: 700; +} + +.subject-overview-descriptions :deep(.el-descriptions__content) { + width: calc(50% - 140px); + min-width: 0; +} + +.subject-overview-descriptions :deep(.el-descriptions__cell) { + height: 42px; + vertical-align: middle; +} + .page-title { margin: 0; font-size: 22px; @@ -1140,12 +1208,6 @@ onMounted(async () => { gap: 8px; } -.tab-actions { - display: flex; - justify-content: flex-end; - margin-bottom: 10px; -} - .subject-detail-table :deep(.el-table__inner-wrapper::before) { display: none; } diff --git a/frontend/src/views/subjects/SubjectForm.vue b/frontend/src/views/subjects/SubjectForm.vue index 8bc026ca..53d2a974 100644 --- a/frontend/src/views/subjects/SubjectForm.vue +++ b/frontend/src/views/subjects/SubjectForm.vue @@ -1,86 +1,120 @@