From db2d38edbc7e73722004baa3928805edf1cc70f4 Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Fri, 27 Feb 2026 16:16:26 +0800 Subject: [PATCH] =?UTF-8?q?=E7=AB=8B=E9=A1=B9=E9=85=8D=E7=BD=AE=E6=95=B0?= =?UTF-8?q?=E6=8D=AE=E5=85=A5=E5=BA=93=E3=80=81=E5=AE=A1=E8=AE=A1=E6=97=A5?= =?UTF-8?q?=E5=BF=97=E6=95=B0=E6=8D=AE=E5=85=A5=E5=BA=93=E3=80=81=E7=BC=96?= =?UTF-8?q?=E8=BE=91=E9=A1=B5UI=E7=95=8C=E9=9D=A2=E7=BE=8E=E5=8C=96?= =?UTF-8?q?=E3=80=81=E8=AE=BE=E5=A4=87=E7=AE=A1=E7=90=86=E5=86=85=E5=AE=B9?= =?UTF-8?q?=E8=A1=A5=E5=85=85=E3=80=81=E5=AE=A1=E8=AE=A1=E6=97=A5=E5=BF=97?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E4=BC=98=E5=8C=96?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../workflows/storage-persistence-guard.yml | 24 + .../20260227_01_add_material_equipments.py | 63 + ...20260227_02_add_milestone_editor_fields.py | 48 + ...03_add_setup_published_project_snapshot.py | 38 + backend/app/api/v1/audit_logs.py | 31 +- backend/app/api/v1/material_equipments.py | 153 ++ backend/app/api/v1/members.py | 54 +- backend/app/api/v1/project_milestones.py | 74 + backend/app/api/v1/router.py | 4 +- backend/app/api/v1/sites.py | 55 + backend/app/api/v1/studies.py | 51 + backend/app/crud/material_equipment.py | 71 + backend/app/crud/project_milestone.py | 54 + backend/app/crud/study.py | 2 + backend/app/crud/study_setup_config.py | 9 +- backend/app/db/base.py | 1 + backend/app/models/material_equipment.py | 29 + backend/app/models/milestone.py | 4 + backend/app/models/study_setup_config.py | 1 + backend/app/schemas/audit.py | 7 + backend/app/schemas/material_equipment.py | 81 ++ backend/app/schemas/project_milestone.py | 29 + backend/app/schemas/study_setup_config.py | 30 + .../app/services/setup_config_projection.py | 5 + backend/scripts/storage_persistence_audit.py | 172 +++ database/init.sql | 24 + docs/setup-config-api.md | 28 + docs/storage-persistence-audit.md | 56 + docs/storage-persistence-governance.md | 52 + frontend/src/api/auditLogs.ts | 5 +- frontend/src/api/materialEquipments.ts | 16 + frontend/src/api/projectMilestones.ts | 10 + frontend/src/audit/index.ts | 46 +- frontend/src/components/Layout.vue | 18 +- frontend/src/locales/zh-CN.ts | 7 - frontend/src/router/index.ts | 7 - frontend/src/styles/main.css | 19 +- frontend/src/types/setupConfig.ts | 30 + frontend/src/utils/setupPublishWorkflow.ts | 148 ++ frontend/src/views/admin/AuditLogs.vue | 67 +- frontend/src/views/admin/ProjectDetail.vue | 1279 ++++++++++------- frontend/src/views/admin/ProjectMembers.vue | 96 -- frontend/src/views/admin/SiteCraBinding.vue | 22 - frontend/src/views/admin/SiteForm.vue | 26 - frontend/src/views/admin/Sites.vue | 33 - frontend/src/views/ia/MaterialEquipment.vue | 602 +++++++- frontend/src/views/ia/MaterialOthers.vue | 13 - frontend/src/views/ia/ProjectMilestones.vue | 151 +- 48 files changed, 2936 insertions(+), 909 deletions(-) create mode 100644 .github/workflows/storage-persistence-guard.yml create mode 100644 backend/alembic/versions/20260227_01_add_material_equipments.py create mode 100644 backend/alembic/versions/20260227_02_add_milestone_editor_fields.py create mode 100644 backend/alembic/versions/20260227_03_add_setup_published_project_snapshot.py create mode 100644 backend/app/api/v1/material_equipments.py create mode 100644 backend/app/api/v1/project_milestones.py create mode 100644 backend/app/crud/material_equipment.py create mode 100644 backend/app/crud/project_milestone.py create mode 100644 backend/app/models/material_equipment.py create mode 100644 backend/app/schemas/material_equipment.py create mode 100644 backend/app/schemas/project_milestone.py create mode 100755 backend/scripts/storage_persistence_audit.py create mode 100644 docs/storage-persistence-audit.md create mode 100644 docs/storage-persistence-governance.md create mode 100644 frontend/src/api/materialEquipments.ts create mode 100644 frontend/src/api/projectMilestones.ts create mode 100644 frontend/src/utils/setupPublishWorkflow.ts delete mode 100644 frontend/src/views/ia/MaterialOthers.vue diff --git a/.github/workflows/storage-persistence-guard.yml b/.github/workflows/storage-persistence-guard.yml new file mode 100644 index 00000000..01caf1f3 --- /dev/null +++ b/.github/workflows/storage-persistence-guard.yml @@ -0,0 +1,24 @@ +name: Storage Persistence Guard + +on: + pull_request: + push: + branches: + - main + - master + +jobs: + storage-persistence-audit: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Run storage persistence guard + run: | + python backend/scripts/storage_persistence_audit.py --format markdown --output docs/storage-persistence-audit.md --fail-on-banned diff --git a/backend/alembic/versions/20260227_01_add_material_equipments.py b/backend/alembic/versions/20260227_01_add_material_equipments.py new file mode 100644 index 00000000..16f33eda --- /dev/null +++ b/backend/alembic/versions/20260227_01_add_material_equipments.py @@ -0,0 +1,63 @@ +"""add material_equipments table + +Revision ID: 20260227_01 +Revises: 20260213_03 +Create Date: 2026-02-27 10:30:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "20260227_01" +down_revision: Union[str, None] = "20260213_03" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if "material_equipments" not in inspector.get_table_names(): + op.create_table( + "material_equipments", + sa.Column("id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("study_id", postgresql.UUID(as_uuid=True), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("spec_model", sa.String(length=255), nullable=False), + sa.Column("unit", sa.String(length=50), nullable=True), + sa.Column("brand", sa.String(length=100), nullable=False), + sa.Column("origin", sa.String(length=255), nullable=True), + sa.Column("production_permit_file_name", sa.String(length=255), nullable=True), + sa.Column("tech_index_file_name", sa.String(length=255), nullable=True), + sa.Column("need_calibration", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("calibration_cycle_days", sa.Integer(), nullable=True), + sa.Column("created_by", postgresql.UUID(as_uuid=True), nullable=True), + sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.text("now()"), nullable=False), + sa.ForeignKeyConstraint(["study_id"], ["studies.id"]), + sa.ForeignKeyConstraint(["created_by"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + + indexes = {idx["name"] for idx in inspector.get_indexes("material_equipments")} + index_name = op.f("ix_material_equipments_study_id") + if index_name not in indexes: + op.create_index(index_name, "material_equipments", ["study_id"], unique=False) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + + if "material_equipments" in inspector.get_table_names(): + indexes = {idx["name"] for idx in inspector.get_indexes("material_equipments")} + index_name = op.f("ix_material_equipments_study_id") + if index_name in indexes: + op.drop_index(index_name, table_name="material_equipments") + op.drop_table("material_equipments") diff --git a/backend/alembic/versions/20260227_02_add_milestone_editor_fields.py b/backend/alembic/versions/20260227_02_add_milestone_editor_fields.py new file mode 100644 index 00000000..e798c423 --- /dev/null +++ b/backend/alembic/versions/20260227_02_add_milestone_editor_fields.py @@ -0,0 +1,48 @@ +"""add milestone editor fields + +Revision ID: 20260227_02 +Revises: 20260227_01 +Create Date: 2026-02-27 11:20:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision: str = "20260227_02" +down_revision: Union[str, None] = "20260227_01" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("milestones")} + + if "adjusted_start_date" not in columns: + op.add_column("milestones", sa.Column("adjusted_start_date", sa.Date(), nullable=True)) + if "adjusted_end_date" not in columns: + op.add_column("milestones", sa.Column("adjusted_end_date", sa.Date(), nullable=True)) + if "actual_start_date" not in columns: + op.add_column("milestones", sa.Column("actual_start_date", sa.Date(), nullable=True)) + if "actual_end_date" not in columns: + op.add_column("milestones", sa.Column("actual_end_date", sa.Date(), nullable=True)) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("milestones")} + + if "actual_end_date" in columns: + op.drop_column("milestones", "actual_end_date") + if "actual_start_date" in columns: + op.drop_column("milestones", "actual_start_date") + if "adjusted_end_date" in columns: + op.drop_column("milestones", "adjusted_end_date") + if "adjusted_start_date" in columns: + op.drop_column("milestones", "adjusted_start_date") diff --git a/backend/alembic/versions/20260227_03_add_setup_published_project_snapshot.py b/backend/alembic/versions/20260227_03_add_setup_published_project_snapshot.py new file mode 100644 index 00000000..d19c8491 --- /dev/null +++ b/backend/alembic/versions/20260227_03_add_setup_published_project_snapshot.py @@ -0,0 +1,38 @@ +"""add published project snapshot to study_setup_configs + +Revision ID: 20260227_03 +Revises: 20260227_02 +Create Date: 2026-02-27 12:20:00.000000 + +""" +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa +from sqlalchemy.dialects import postgresql + + +# revision identifiers, used by Alembic. +revision: str = "20260227_03" +down_revision: Union[str, None] = "20260227_02" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("study_setup_configs")} + if "published_project_snapshot" not in columns: + op.add_column( + "study_setup_configs", + sa.Column("published_project_snapshot", postgresql.JSONB(astext_type=sa.Text()), nullable=True), + ) + + +def downgrade() -> None: + bind = op.get_bind() + inspector = sa.inspect(bind) + columns = {col["name"] for col in inspector.get_columns("study_setup_configs")} + if "published_project_snapshot" in columns: + op.drop_column("study_setup_configs", "published_project_snapshot") diff --git a/backend/app/api/v1/audit_logs.py b/backend/app/api/v1/audit_logs.py index abc19c6f..b5aff1a0 100644 --- a/backend/app/api/v1/audit_logs.py +++ b/backend/app/api/v1/audit_logs.py @@ -3,9 +3,9 @@ import uuid from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_db_session, require_roles, require_study_member +from app.core.deps import get_current_user, get_db_session, require_roles, require_study_member from app.crud import audit as audit_crud -from app.schemas.audit import AuditLogRead +from app.schemas.audit import AuditEventCreate, AuditLogRead router = APIRouter() @@ -52,3 +52,30 @@ async def delete_audit_log( if not log or log.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审计日志不存在") await audit_crud.delete_log(db, log) + + +@router.post( + "/events", + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_study_member())], +) +async def create_audit_event( + study_id: uuid.UUID, + payload: AuditEventCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +): + allowed_actions = {"AUDIT_EXPORT_SYSTEM", "AUDIT_EXPORT_PROJECT"} + if payload.action not in allowed_actions: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不支持的审计事件") + await audit_crud.log_action( + db, + study_id=study_id, + entity_type=payload.entity_type or "audit_log", + entity_id=payload.entity_id, + action=payload.action, + detail=payload.detail, + operator_id=current_user.id, + operator_role=current_user.role, + ) + return {"ok": True} diff --git a/backend/app/api/v1/material_equipments.py b/backend/app/api/v1/material_equipments.py new file mode 100644 index 00000000..469dfff5 --- /dev/null +++ b/backend/app/api/v1/material_equipments.py @@ -0,0 +1,153 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_not_locked, require_study_roles +from app.crud import audit as audit_crud +from app.crud import material_equipment as equipment_crud +from app.crud import study as study_crud +from app.schemas.material_equipment import MaterialEquipmentCreate, MaterialEquipmentRead, MaterialEquipmentUpdate + +router = APIRouter() + + +async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + return study + + +def _validate_calibration(need_calibration: bool, calibration_cycle_days: int | None): + if need_calibration and (calibration_cycle_days is None or calibration_cycle_days < 1): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail="需要校准时校准周期必须大于0") + + +@router.post( + "/equipment", + response_model=MaterialEquipmentRead, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], +) +async def create_equipment( + study_id: uuid.UUID, + equipment_in: MaterialEquipmentCreate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> MaterialEquipmentRead: + await _ensure_study_exists(db, study_id) + _validate_calibration(equipment_in.need_calibration, equipment_in.calibration_cycle_days) + if not equipment_in.need_calibration: + equipment_in = equipment_in.model_copy(update={"calibration_cycle_days": None}) + item = await equipment_crud.create_equipment(db, study_id, equipment_in, created_by=current_user.id) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="material_equipment", + entity_id=item.id, + action="CREATE_MATERIAL_EQUIPMENT", + detail=f"设备 {item.id} 已创建", + operator_id=current_user.id, + operator_role=current_user.role, + ) + return MaterialEquipmentRead.model_validate(item) + + +@router.get( + "/equipment", + response_model=list[MaterialEquipmentRead], + dependencies=[Depends(require_study_member())], +) +async def list_equipments( + study_id: uuid.UUID, + name: str | None = None, + skip: int = 0, + limit: int = 100, + db: AsyncSession = Depends(get_db_session), +) -> list[MaterialEquipmentRead]: + await _ensure_study_exists(db, study_id) + items = await equipment_crud.list_equipments(db, study_id, name=name, skip=skip, limit=limit) + return [MaterialEquipmentRead.model_validate(item) for item in items] + + +@router.get( + "/equipment/{equipment_id}", + response_model=MaterialEquipmentRead, + dependencies=[Depends(require_study_member())], +) +async def get_equipment( + study_id: uuid.UUID, + equipment_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), +) -> MaterialEquipmentRead: + await _ensure_study_exists(db, study_id) + item = await equipment_crud.get_equipment(db, equipment_id) + if not item or item.study_id != study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在") + return MaterialEquipmentRead.model_validate(item) + + +@router.patch( + "/equipment/{equipment_id}", + response_model=MaterialEquipmentRead, + dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], +) +async def update_equipment( + study_id: uuid.UUID, + equipment_id: uuid.UUID, + equipment_in: MaterialEquipmentUpdate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> MaterialEquipmentRead: + await _ensure_study_exists(db, study_id) + item = await equipment_crud.get_equipment(db, equipment_id) + if not item or item.study_id != study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在") + + next_need_calibration = equipment_in.need_calibration if equipment_in.need_calibration is not None else item.need_calibration + next_cycle = equipment_in.calibration_cycle_days if equipment_in.calibration_cycle_days is not None else item.calibration_cycle_days + _validate_calibration(next_need_calibration, next_cycle) + if not next_need_calibration: + equipment_in = equipment_in.model_copy(update={"calibration_cycle_days": None}) + + item = await equipment_crud.update_equipment(db, item, equipment_in) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="material_equipment", + entity_id=equipment_id, + action="UPDATE_MATERIAL_EQUIPMENT", + detail=f"设备 {equipment_id} 已更新", + operator_id=current_user.id, + operator_role=current_user.role, + ) + return MaterialEquipmentRead.model_validate(item) + + +@router.delete( + "/equipment/{equipment_id}", + status_code=status.HTTP_204_NO_CONTENT, + dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], +) +async def delete_equipment( + study_id: uuid.UUID, + equipment_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> None: + await _ensure_study_exists(db, study_id) + item = await equipment_crud.get_equipment(db, equipment_id) + if not item or item.study_id != study_id: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="设备记录不存在") + await equipment_crud.delete_equipment(db, item) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="material_equipment", + entity_id=equipment_id, + action="DELETE_MATERIAL_EQUIPMENT", + detail=f"设备 {equipment_id} 已删除", + operator_id=current_user.id, + operator_role=current_user.role, + ) diff --git a/backend/app/api/v1/members.py b/backend/app/api/v1/members.py index 7e9a4f8a..c2245bc8 100644 --- a/backend/app/api/v1/members.py +++ b/backend/app/api/v1/members.py @@ -1,9 +1,11 @@ import uuid +import json from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession -from app.core.deps import get_db_session, require_study_member, require_study_roles, require_study_not_locked +from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked +from app.crud import audit as audit_crud from app.crud import member as member_crud from app.crud import study as study_crud from app.crud import user as user_crud @@ -30,6 +32,7 @@ async def add_member( study_id: uuid.UUID, member_in: StudyMemberCreate, db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), ) -> StudyMemberRead: await _ensure_study_exists(db, study_id) existing = await member_crud.get_member(db, study_id, member_in.user_id) @@ -40,9 +43,33 @@ async def add_member( existing, StudyMemberUpdate(is_active=True, role_in_study=member_in.role_in_study), ) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="study_member", + entity_id=updated.id, + action="PROJECT_MEMBER_UPDATED", + detail=json.dumps({ + "targetName": str(updated.user_id), + "before": {"is_active": existing.is_active, "role_in_study": existing.role_in_study}, + "after": {"is_active": updated.is_active, "role_in_study": updated.role_in_study}, + }, ensure_ascii=False), + operator_id=current_user.id, + operator_role=current_user.role, + ) return updated raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="成员已存在") member = await member_crud.add_member(db, study_id, member_in) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="study_member", + entity_id=member.id, + action="PROJECT_MEMBER_ADDED", + detail=json.dumps({"targetName": str(member.user_id), "after": {"role_in_study": member.role_in_study, "is_active": member.is_active}}, ensure_ascii=False), + operator_id=current_user.id, + operator_role=current_user.role, + ) return member @@ -95,12 +122,25 @@ async def update_member( member_id: uuid.UUID, member_in: StudyMemberUpdate, db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), ) -> StudyMemberRead: await _ensure_study_exists(db, study_id) member = await member_crud.get_member_by_id(db, member_id) if not member or member.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在") + before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active} updated = await member_crud.update_member(db, member, member_in) + after_data = {"role_in_study": updated.role_in_study, "is_active": updated.is_active} + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="study_member", + entity_id=updated.id, + action="PROJECT_MEMBER_UPDATED", + detail=json.dumps({"targetName": str(updated.user_id), "before": before_data, "after": after_data}, ensure_ascii=False), + operator_id=current_user.id, + operator_role=current_user.role, + ) return updated @@ -113,10 +153,22 @@ async def remove_member( study_id: uuid.UUID, member_id: uuid.UUID, db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), ) -> StudyMemberRead: await _ensure_study_exists(db, study_id) member = await member_crud.get_member_by_id(db, member_id) if not member or member.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="成员不存在") + before_data = {"role_in_study": member.role_in_study, "is_active": member.is_active} removed = await member_crud.remove_member(db, member) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="study_member", + entity_id=removed.id, + action="PROJECT_MEMBER_REMOVED", + detail=json.dumps({"targetName": str(removed.user_id), "before": before_data, "after": {"is_active": removed.is_active}}, ensure_ascii=False), + operator_id=current_user.id, + operator_role=current_user.role, + ) return removed diff --git a/backend/app/api/v1/project_milestones.py b/backend/app/api/v1/project_milestones.py new file mode 100644 index 00000000..fbaab559 --- /dev/null +++ b/backend/app/api/v1/project_milestones.py @@ -0,0 +1,74 @@ +import uuid + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.deps import get_current_user, get_db_session, require_study_member, require_study_not_locked, require_study_roles +from app.crud import audit as audit_crud +from app.crud import project_milestone as milestone_crud +from app.crud import study as study_crud +from app.schemas.project_milestone import ProjectMilestoneRead, ProjectMilestoneUpdate + +router = APIRouter() + + +async def _ensure_study_exists(db: AsyncSession, study_id: uuid.UUID): + study = await study_crud.get(db, study_id) + if not study: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目不存在") + return study + + +def _validate_date_range(start, end, label: str): + if (start and not end) or (not start and end): + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"{label}开始和结束日期需同时填写") + if start and end and end < start: + raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=f"{label}结束日期不能早于开始日期") + + +@router.get( + "/milestones", + response_model=list[ProjectMilestoneRead], + dependencies=[Depends(require_study_member())], +) +async def list_project_milestones( + study_id: uuid.UUID, + db: AsyncSession = Depends(get_db_session), +) -> list[ProjectMilestoneRead]: + await _ensure_study_exists(db, study_id) + rows = await milestone_crud.list_project_milestones(db, study_id) + return [ProjectMilestoneRead.model_validate(item) for item in rows] + + +@router.patch( + "/milestones/{milestone_id}", + response_model=ProjectMilestoneRead, + dependencies=[Depends(require_study_roles(["PM", "CRA"])), Depends(require_study_not_locked())], +) +async def update_project_milestone( + study_id: uuid.UUID, + milestone_id: uuid.UUID, + payload: ProjectMilestoneUpdate, + db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), +) -> ProjectMilestoneRead: + await _ensure_study_exists(db, study_id) + row = await milestone_crud.get_project_milestone(db, study_id, milestone_id) + if not row: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="项目里程碑不存在") + + _validate_date_range(payload.adjusted_start_date, payload.adjusted_end_date, "调整计划时间") + _validate_date_range(payload.actual_start_date, payload.actual_end_date, "实际时间") + + item = await milestone_crud.update_project_milestone(db, row, payload) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="milestone", + entity_id=milestone_id, + action="UPDATE_PROJECT_MILESTONE", + detail=f"项目里程碑 {milestone_id} 已更新", + operator_id=current_user.id, + operator_role=current_user.role, + ) + return ProjectMilestoneRead.model_validate(item) diff --git a/backend/app/api/v1/router.py b/backend/app/api/v1/router.py index 0f6b9f87..533704ed 100644 --- a/backend/app/api/v1/router.py +++ b/backend/app/api/v1/router.py @@ -1,6 +1,6 @@ from fastapi import APIRouter -from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents, overview, notifications +from app.api.v1 import auth, users, admin_users, studies, sites, members, attachments, audit_logs, dashboard, subjects, visits, aes, finance_dashboard, finance_contracts, finance_specials, fees_contracts, fees_specials, fees_attachments, drug_shipments, material_equipments, project_milestones, startup, knowledge_notes, subject_histories, faq_categories, faqs, documents, overview, notifications api_router = APIRouter() @@ -26,6 +26,8 @@ api_router.include_router(fees_contracts.router, prefix="/fees", tags=["fees-con api_router.include_router(fees_specials.router, prefix="/fees", tags=["fees-specials"]) api_router.include_router(fees_attachments.router, prefix="/fees", tags=["fees-attachments"]) api_router.include_router(drug_shipments.router, prefix="/studies/{study_id}/drug", tags=["drug-shipments"]) +api_router.include_router(material_equipments.router, prefix="/studies/{study_id}/materials", tags=["material-equipments"]) +api_router.include_router(project_milestones.router, prefix="/studies/{study_id}/project", tags=["project-milestones"]) api_router.include_router(startup.router, prefix="/studies/{study_id}/startup", tags=["startup"]) api_router.include_router(knowledge_notes.router, prefix="/studies/{study_id}/knowledge", tags=["knowledge"]) api_router.include_router(subject_histories.router, prefix="/studies/{study_id}/subjects/{subject_id}", tags=["subject-histories"]) diff --git a/backend/app/api/v1/sites.py b/backend/app/api/v1/sites.py index 84816540..807b10a6 100644 --- a/backend/app/api/v1/sites.py +++ b/backend/app/api/v1/sites.py @@ -1,9 +1,11 @@ import uuid +import json from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy.ext.asyncio import AsyncSession from app.core.deps import get_cra_site_scope, get_current_user, get_db_session, require_study_member, require_study_roles, require_study_not_locked +from app.crud import audit as audit_crud from app.crud import site as site_crud from app.crud import startup as startup_crud from app.crud import study as study_crud @@ -46,6 +48,16 @@ async def create_site( StartupEthicsCreate(site_id=site.id), created_by=getattr(current_user, "id", None), ) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="site", + entity_id=site.id, + action="SITE_CREATED", + detail=json.dumps({"targetName": site.name, "after": {"name": site.name, "is_active": site.is_active}}, ensure_ascii=False), + operator_id=current_user.id, + operator_role=current_user.role, + ) return site @@ -86,12 +98,38 @@ async def update_site( site_id: uuid.UUID, site_in: SiteUpdate, db: AsyncSession = Depends(get_db_session), + current_user=Depends(get_current_user), ) -> SiteRead: await _ensure_study_exists(db, study_id) site = await site_crud.get_site(db, site_id) if not site or site.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在") + before_data = { + "name": site.name, + "city": site.city, + "pi_name": site.pi_name, + "contact": site.contact, + "is_active": site.is_active, + } updated = await site_crud.update_site(db, site, site_in) + after_data = { + "name": updated.name, + "city": updated.city, + "pi_name": updated.pi_name, + "contact": updated.contact, + "is_active": updated.is_active, + } + action = "SITE_STATUS_CHANGED" if before_data.get("is_active") != after_data.get("is_active") else "SITE_UPDATED" + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="site", + entity_id=updated.id, + action=action, + detail=json.dumps({"targetName": updated.name, "before": before_data, "after": after_data}, ensure_ascii=False), + operator_id=current_user.id, + operator_role=current_user.role, + ) return updated @@ -113,5 +151,22 @@ async def delete_site( site = await site_crud.get_site(db, site_id) if not site or site.study_id != study_id: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="分中心不存在") + before_data = { + "name": site.name, + "city": site.city, + "pi_name": site.pi_name, + "contact": site.contact, + "is_active": site.is_active, + } await site_crud.delete_site_and_related(db, site) + await audit_crud.log_action( + db, + study_id=study_id, + entity_type="site", + entity_id=site_id, + action="SITE_DELETED", + detail=json.dumps({"targetName": site.name, "before": before_data, "after": {"deleted": True}}, ensure_ascii=False), + operator_id=current_user.id, + operator_role=current_user.role, + ) return None diff --git a/backend/app/api/v1/studies.py b/backend/app/api/v1/studies.py index 6dbfac26..cd54c236 100644 --- a/backend/app/api/v1/studies.py +++ b/backend/app/api/v1/studies.py @@ -25,6 +25,7 @@ from app.schemas.common import PaginatedResponse from app.schemas.study import StudyCreate, StudyRead, StudyUpdate from app.schemas.member import StudyMemberCreate from app.schemas.study_setup_config import ( + ProjectPublishSnapshot, SetupProjectionSummary, StudySetupConfigData, StudySetupConfigPublish, @@ -253,6 +254,42 @@ def _build_default_setup_config_from_study(study, sites: list) -> StudySetupConf ) +def _to_date_text(value: date | None) -> str: + if not value: + return "" + return value.isoformat() + + +def _build_project_publish_snapshot(study) -> ProjectPublishSnapshot: + return ProjectPublishSnapshot( + code=getattr(study, "code", None) or "", + name=getattr(study, "name", None) or "", + project_full_name=getattr(study, "project_full_name", None) or "", + sponsor=getattr(study, "sponsor", None) or "", + protocol_no=getattr(study, "protocol_no", None) or "", + lead_unit=getattr(study, "lead_unit", None) or "", + principal_investigator=getattr(study, "principal_investigator", None) or "", + main_pm=getattr(study, "main_pm", None) or "", + research_analysis=getattr(study, "research_analysis", None) or "", + research_product=getattr(study, "research_product", None) or "", + control_product=getattr(study, "control_product", None) or "", + indication=getattr(study, "indication", None) or "", + research_population=getattr(study, "research_population", None) or "", + research_design=getattr(study, "research_design", None) or "", + plan_start_date=_to_date_text(getattr(study, "plan_start_date", None)), + 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), + ) + + def _to_setup_config_read( record, *, @@ -268,6 +305,11 @@ def _to_setup_config_read( data=StudySetupConfigData.model_validate(record.config or {}), publish_status=record.publish_status or "DRAFT", published_data=StudySetupConfigData.model_validate(record.published_config) if record.published_config else None, + published_project_snapshot=( + ProjectPublishSnapshot.model_validate(record.published_project_snapshot) + if record.published_project_snapshot + else None + ), published_by=record.published_by, published_by_name=published_by_name, published_at=record.published_at, @@ -764,12 +806,18 @@ async def upsert_study_setup_config( existing = await study_setup_config_crud.get_by_study(db, study_id) old_config = dict(existing.config or {}) if existing else {} + force_draft = bool(payload.force_draft) + if existing and existing.publish_status == "PUBLISHED" and existing.published_project_snapshot: + current_project_snapshot = _build_project_publish_snapshot(study).model_dump(mode="json") + if current_project_snapshot != dict(existing.published_project_snapshot or {}): + force_draft = True record, conflict = await study_setup_config_crud.upsert( db, study_id, expected_version=payload.expected_version, data=payload.data, saved_by=current_user.id, + force_draft=force_draft, ) if conflict: _raise_conflict_error() @@ -857,6 +905,9 @@ async def publish_study_setup_config( for item in projection.skipped_items ], ) + study_after_publish = await study_crud.get(db, study_id) + if study_after_publish: + published.published_project_snapshot = _build_project_publish_snapshot(study_after_publish).model_dump(mode="json") await audit_crud.log_action( db, study_id=study_id, diff --git a/backend/app/crud/material_equipment.py b/backend/app/crud/material_equipment.py new file mode 100644 index 00000000..f922ce21 --- /dev/null +++ b/backend/app/crud/material_equipment.py @@ -0,0 +1,71 @@ +import uuid +from typing import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.material_equipment import MaterialEquipment +from app.schemas.material_equipment import MaterialEquipmentCreate, MaterialEquipmentUpdate + + +async def create_equipment( + db: AsyncSession, + study_id: uuid.UUID, + equipment_in: MaterialEquipmentCreate, + created_by: uuid.UUID | None, +) -> MaterialEquipment: + item = MaterialEquipment( + study_id=study_id, + name=equipment_in.name, + spec_model=equipment_in.spec_model, + unit=equipment_in.unit, + brand=equipment_in.brand, + origin=equipment_in.origin, + production_permit_file_name=equipment_in.production_permit_file_name, + tech_index_file_name=equipment_in.tech_index_file_name, + need_calibration=equipment_in.need_calibration, + calibration_cycle_days=equipment_in.calibration_cycle_days, + created_by=created_by, + ) + db.add(item) + await db.commit() + await db.refresh(item) + return item + + +async def get_equipment(db: AsyncSession, equipment_id: uuid.UUID) -> MaterialEquipment | None: + result = await db.execute(select(MaterialEquipment).where(MaterialEquipment.id == equipment_id)) + return result.scalar_one_or_none() + + +async def list_equipments( + db: AsyncSession, + study_id: uuid.UUID, + name: str | None = None, + skip: int = 0, + limit: int = 100, +) -> Sequence[MaterialEquipment]: + stmt = select(MaterialEquipment).where(MaterialEquipment.study_id == study_id) + if name: + stmt = stmt.where(MaterialEquipment.name.ilike(f"%{name}%")) + stmt = stmt.order_by(MaterialEquipment.created_at.desc()).offset(skip).limit(limit) + result = await db.execute(stmt) + return result.scalars().all() + + +async def update_equipment( + db: AsyncSession, + equipment: MaterialEquipment, + equipment_in: MaterialEquipmentUpdate, +) -> MaterialEquipment: + update_data = equipment_in.model_dump(exclude_unset=True) + for key, value in update_data.items(): + setattr(equipment, key, value) + await db.commit() + await db.refresh(equipment) + return equipment + + +async def delete_equipment(db: AsyncSession, equipment: MaterialEquipment) -> None: + await db.delete(equipment) + await db.commit() diff --git a/backend/app/crud/project_milestone.py b/backend/app/crud/project_milestone.py new file mode 100644 index 00000000..5a22870f --- /dev/null +++ b/backend/app/crud/project_milestone.py @@ -0,0 +1,54 @@ +import uuid +from typing import Sequence + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.milestone import Milestone +from app.schemas.project_milestone import ProjectMilestoneUpdate + +PROJECT_MILESTONE_TYPE = "SETUP_PROJECT_MILESTONE" + + +async def list_project_milestones(db: AsyncSession, study_id: uuid.UUID) -> Sequence[Milestone]: + stmt = ( + select(Milestone) + .where( + Milestone.study_id == study_id, + Milestone.type == PROJECT_MILESTONE_TYPE, + ) + .order_by(Milestone.planned_date.asc().nulls_last(), Milestone.created_at.asc()) + ) + result = await db.execute(stmt) + return result.scalars().all() + + +async def get_project_milestone(db: AsyncSession, study_id: uuid.UUID, milestone_id: uuid.UUID) -> Milestone | None: + result = await db.execute( + select(Milestone).where( + Milestone.id == milestone_id, + Milestone.study_id == study_id, + Milestone.type == PROJECT_MILESTONE_TYPE, + ) + ) + return result.scalar_one_or_none() + + +async def update_project_milestone(db: AsyncSession, milestone: Milestone, data: ProjectMilestoneUpdate) -> Milestone: + milestone.adjusted_start_date = data.adjusted_start_date + milestone.adjusted_end_date = data.adjusted_end_date + milestone.actual_start_date = data.actual_start_date + milestone.actual_end_date = data.actual_end_date + milestone.actual_date = data.actual_end_date + milestone.notes = data.notes + + if milestone.actual_end_date: + milestone.status = "DONE" + elif milestone.actual_start_date: + milestone.status = "IN_PROGRESS" + else: + milestone.status = "NOT_STARTED" + + await db.commit() + await db.refresh(milestone) + return milestone diff --git a/backend/app/crud/study.py b/backend/app/crud/study.py index 83cfe108..a31ea2df 100644 --- a/backend/app/crud/study.py +++ b/backend/app/crud/study.py @@ -112,6 +112,7 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None: from app.models.document import Document from app.models.attachment import Attachment from app.models.drug_shipment import DrugShipment + from app.models.material_equipment import MaterialEquipment from app.models.training_authorization import TrainingAuthorization from app.models.startup_feasibility import StartupFeasibility from app.models.startup_ethics import StartupEthics @@ -145,6 +146,7 @@ async def delete(db: AsyncSession, study_id: uuid.UUID) -> None: # 6. 删除药物配送 await db.execute(sa_delete(DrugShipment).where(DrugShipment.study_id == study_id)) + await db.execute(sa_delete(MaterialEquipment).where(MaterialEquipment.study_id == study_id)) # 7. 删除附件和文档 await db.execute(sa_delete(Attachment).where(Attachment.study_id == study_id)) diff --git a/backend/app/crud/study_setup_config.py b/backend/app/crud/study_setup_config.py index d2ccc341..4007c7b0 100644 --- a/backend/app/crud/study_setup_config.py +++ b/backend/app/crud/study_setup_config.py @@ -28,6 +28,7 @@ async def upsert( expected_version: int | None, data: StudySetupConfigData | dict, saved_by: uuid.UUID | None, + force_draft: bool = False, ) -> tuple[StudySetupConfig | None, bool]: existing = await get_by_study(db, study_id) payload = _to_storage(data) @@ -37,7 +38,13 @@ async def upsert( existing.version = existing.version + 1 existing.config = payload existing.saved_by = saved_by - existing.publish_status = "PUBLISHED" if existing.published_config == payload else "DRAFT" + if force_draft: + existing.publish_status = "DRAFT" + elif existing.publish_status == "DRAFT": + # Keep draft state sticky until explicit publish API is called. + existing.publish_status = "DRAFT" + else: + existing.publish_status = "PUBLISHED" if existing.published_config == payload else "DRAFT" existing.updated_at = datetime.utcnow() await db.commit() await db.refresh(existing) diff --git a/backend/app/db/base.py b/backend/app/db/base.py index b1e87b21..e12e4949 100644 --- a/backend/app/db/base.py +++ b/backend/app/db/base.py @@ -23,6 +23,7 @@ from app.models.contract_fee_payment import ContractFeePayment # noqa: F401 from app.models.special_expense import SpecialExpense # noqa: F401 from app.models.fee_attachment import FeeAttachment # noqa: F401 from app.models.drug_shipment import DrugShipment # noqa: F401 +from app.models.material_equipment import MaterialEquipment # noqa: F401 from app.models.startup_feasibility import StartupFeasibility # noqa: F401 from app.models.startup_ethics import StartupEthics # noqa: F401 from app.models.kickoff_meeting import KickoffMeeting # noqa: F401 diff --git a/backend/app/models/material_equipment.py b/backend/app/models/material_equipment.py new file mode 100644 index 00000000..21cd1683 --- /dev/null +++ b/backend/app/models/material_equipment.py @@ -0,0 +1,29 @@ +import uuid +from datetime import datetime + +from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, String, func +from sqlalchemy.dialects.postgresql import UUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.db.base_class import Base + + +class MaterialEquipment(Base): + __tablename__ = "material_equipments" + + id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4) + study_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("studies.id"), index=True, nullable=False) + name: Mapped[str] = mapped_column(String(255), nullable=False) + spec_model: Mapped[str] = mapped_column(String(255), nullable=False) + unit: Mapped[str | None] = mapped_column(String(50), nullable=True) + brand: Mapped[str] = mapped_column(String(100), nullable=False) + origin: Mapped[str | None] = mapped_column(String(255), nullable=True) + production_permit_file_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + tech_index_file_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + need_calibration: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default="false") + calibration_cycle_days: Mapped[int | None] = mapped_column(Integer, nullable=True) + 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()) + updated_at: Mapped[datetime] = mapped_column( + DateTime(timezone=True), nullable=False, server_default=func.now(), onupdate=func.now() + ) diff --git a/backend/app/models/milestone.py b/backend/app/models/milestone.py index e26eca4d..3ffd4d25 100644 --- a/backend/app/models/milestone.py +++ b/backend/app/models/milestone.py @@ -16,6 +16,10 @@ class Milestone(Base): type: Mapped[str] = mapped_column(String(50), nullable=False) name: Mapped[str] = mapped_column(String(200), nullable=False) planned_date: Mapped[date | None] = mapped_column(Date, nullable=True) + adjusted_start_date: Mapped[date | None] = mapped_column(Date, nullable=True) + adjusted_end_date: Mapped[date | None] = mapped_column(Date, nullable=True) + actual_start_date: Mapped[date | None] = mapped_column(Date, nullable=True) + actual_end_date: Mapped[date | None] = mapped_column(Date, nullable=True) actual_date: Mapped[date | None] = mapped_column(Date, nullable=True) status: Mapped[str] = mapped_column(String(20), nullable=False, default="NOT_STARTED") site_id: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("sites.id"), nullable=True) diff --git a/backend/app/models/study_setup_config.py b/backend/app/models/study_setup_config.py index bedae373..4dea6071 100644 --- a/backend/app/models/study_setup_config.py +++ b/backend/app/models/study_setup_config.py @@ -18,6 +18,7 @@ class StudySetupConfig(Base): config: Mapped[dict] = mapped_column(JSONB, nullable=False) publish_status: Mapped[str] = mapped_column(default="DRAFT", nullable=False) published_config: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + published_project_snapshot: Mapped[dict | None] = mapped_column(JSONB, nullable=True) published_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) published_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) saved_by: Mapped[uuid.UUID | None] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id"), nullable=True) diff --git a/backend/app/schemas/audit.py b/backend/app/schemas/audit.py index a0e1a817..563317ef 100644 --- a/backend/app/schemas/audit.py +++ b/backend/app/schemas/audit.py @@ -14,3 +14,10 @@ class AuditLogRead(BaseModel): created_at: datetime model_config = ConfigDict(from_attributes=True) + + +class AuditEventCreate(BaseModel): + action: str + detail: Optional[str] = None + entity_type: Optional[str] = None + entity_id: Optional[uuid.UUID] = None diff --git a/backend/app/schemas/material_equipment.py b/backend/app/schemas/material_equipment.py new file mode 100644 index 00000000..ca6a2ff8 --- /dev/null +++ b/backend/app/schemas/material_equipment.py @@ -0,0 +1,81 @@ +import uuid +from datetime import datetime + +from pydantic import BaseModel, ConfigDict, field_validator + + +class MaterialEquipmentCreate(BaseModel): + name: str + spec_model: str + unit: str | None = None + brand: str + origin: str | None = None + production_permit_file_name: str | None = None + tech_index_file_name: str | None = None + need_calibration: bool + calibration_cycle_days: int | None = None + + @field_validator("name", "spec_model", "brand", mode="before") + @classmethod + def _strip_required(cls, value: str) -> str: + text = (value or "").strip() + if not text: + raise ValueError("不能为空") + return text + + @field_validator("unit", "origin", "production_permit_file_name", "tech_index_file_name", mode="before") + @classmethod + def _strip_optional(cls, value: str | None) -> str | None: + if value is None: + return None + text = value.strip() + return text or None + + +class MaterialEquipmentUpdate(BaseModel): + name: str | None = None + spec_model: str | None = None + unit: str | None = None + brand: str | None = None + origin: str | None = None + production_permit_file_name: str | None = None + tech_index_file_name: str | None = None + need_calibration: bool | None = None + calibration_cycle_days: int | None = None + + @field_validator("name", "spec_model", "brand", mode="before") + @classmethod + def _strip_required(cls, value: str | None) -> str | None: + if value is None: + return None + text = value.strip() + if not text: + raise ValueError("不能为空") + return text + + @field_validator("unit", "origin", "production_permit_file_name", "tech_index_file_name", mode="before") + @classmethod + def _strip_optional(cls, value: str | None) -> str | None: + if value is None: + return None + text = value.strip() + return text or None + + +class MaterialEquipmentRead(BaseModel): + id: uuid.UUID + study_id: uuid.UUID + name: str + spec_model: str + unit: str | None + brand: str + origin: str | None + production_permit_file_name: str | None + tech_index_file_name: str | None + need_calibration: bool + calibration_cycle_days: int | None + created_by: uuid.UUID | None + created_at: datetime + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) diff --git a/backend/app/schemas/project_milestone.py b/backend/app/schemas/project_milestone.py new file mode 100644 index 00000000..ae8c6be7 --- /dev/null +++ b/backend/app/schemas/project_milestone.py @@ -0,0 +1,29 @@ +import uuid +from datetime import date, datetime + +from pydantic import BaseModel, ConfigDict + + +class ProjectMilestoneRead(BaseModel): + id: uuid.UUID + study_id: uuid.UUID + name: str + planned_date: date | None + adjusted_start_date: date | None + adjusted_end_date: date | None + actual_start_date: date | None + actual_end_date: date | None + status: str + notes: str | None + owner_name: str | None + updated_at: datetime + + model_config = ConfigDict(from_attributes=True) + + +class ProjectMilestoneUpdate(BaseModel): + adjusted_start_date: date | None = None + adjusted_end_date: date | None = None + actual_start_date: date | None = None + actual_end_date: date | None = None + notes: str | None = None diff --git a/backend/app/schemas/study_setup_config.py b/backend/app/schemas/study_setup_config.py index bf99f023..ce719529 100644 --- a/backend/app/schemas/study_setup_config.py +++ b/backend/app/schemas/study_setup_config.py @@ -80,6 +80,7 @@ class StudySetupConfigData(BaseModel): class StudySetupConfigUpsert(BaseModel): expected_version: int | None = None data: StudySetupConfigData + force_draft: bool = False class StudySetupConfigPublish(BaseModel): @@ -120,6 +121,34 @@ class SetupProjectionSummary(BaseModel): skipped_items: list[SetupProjectionSkippedItem] = Field(default_factory=list) +class ProjectPublishSnapshot(BaseModel): + code: str = "" + name: str = "" + project_full_name: str = "" + sponsor: str = "" + protocol_no: str = "" + lead_unit: str = "" + principal_investigator: str = "" + main_pm: str = "" + research_analysis: str = "" + research_product: str = "" + control_product: str = "" + indication: str = "" + research_population: str = "" + research_design: str = "" + plan_start_date: str = "" + 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 + + class StudySetupConfigRead(BaseModel): id: uuid.UUID study_id: uuid.UUID @@ -127,6 +156,7 @@ class StudySetupConfigRead(BaseModel): data: StudySetupConfigData publish_status: str published_data: StudySetupConfigData | None = None + published_project_snapshot: ProjectPublishSnapshot | None = None published_by: uuid.UUID | None = None published_by_name: str | None = None published_at: datetime | None = None diff --git a/backend/app/services/setup_config_projection.py b/backend/app/services/setup_config_projection.py index 7cdc3565..8e0218cf 100644 --- a/backend/app/services/setup_config_projection.py +++ b/backend/app/services/setup_config_projection.py @@ -143,6 +143,11 @@ async def _replace_project_milestones( type=PROJECT_MILESTONE_TYPE, name=name, planned_date=planned_date, + adjusted_start_date=_parse_date((getattr(row, "adjustedStartDate", "") or "").strip()), + adjusted_end_date=_parse_date((getattr(row, "adjustedEndDate", "") or "").strip()), + actual_start_date=_parse_date((getattr(row, "actualStartDate", "") or "").strip()), + actual_end_date=_parse_date((getattr(row, "actualEndDate", "") or "").strip()), + actual_date=_parse_date((getattr(row, "actualEndDate", "") or "").strip()), status=mapped_status, owner_id=owner_id, owner_name=owner_name, diff --git a/backend/scripts/storage_persistence_audit.py b/backend/scripts/storage_persistence_audit.py new file mode 100755 index 00000000..d5e4a148 --- /dev/null +++ b/backend/scripts/storage_persistence_audit.py @@ -0,0 +1,172 @@ +#!/usr/bin/env python3 +from __future__ import annotations + +import argparse +import json +import re +from dataclasses import dataclass, asdict +from pathlib import Path +from typing import Iterable + + +REPO_ROOT = Path(__file__).resolve().parents[2] +FRONTEND_SRC = REPO_ROOT / "frontend" / "src" + +FILE_GLOBS = ("**/*.ts", "**/*.tsx", "**/*.vue") +CALL_PATTERNS = { + "localStorage.getItem": re.compile(r"localStorage\.getItem\(([^)]+)\)"), + "localStorage.setItem": re.compile(r"localStorage\.setItem\(([^,)]+)"), + "localStorage.removeItem": re.compile(r"localStorage\.removeItem\(([^)]+)\)"), + "sessionStorage.getItem": re.compile(r"sessionStorage\.getItem\(([^)]+)\)"), + "sessionStorage.setItem": re.compile(r"sessionStorage\.setItem\(([^,)]+)"), + "sessionStorage.removeItem": re.compile(r"sessionStorage\.removeItem\(([^)]+)\)"), +} + +BANNED_KEY_MARKERS = ("audit_local_",) + + +@dataclass +class Finding: + file: str + line: int + call: str + key_expr: str + severity: str + category: str + note: str + + +def iter_source_files() -> Iterable[Path]: + for pattern in FILE_GLOBS: + for path in FRONTEND_SRC.glob(pattern): + if path.is_file(): + yield path + + +def classify(file_path: str, key_expr: str) -> tuple[str, str, str]: + normalized = key_expr.replace('"', "").replace("'", "").strip() + lowered = normalized.lower() + file_lower = file_path.lower() + + if "projectdetail.vue" in file_lower: + if "publishedprojectsignaturekey" in lowered: + return ("medium", "publish-helper", "发布比对签名缓存,属于辅助数据") + if "storagekey" in lowered or "projectdraftstoragekey" in lowered: + return ("high", "business-draft", "立项配置草稿本地兜底,需显式提示未落库") + + if "audit_local_" in lowered: + return ("high", "audit-data", "审计日志本地缓存,不能作为正式审计数据源") + if "ctms_setup_config_draft_" in lowered or "ctms_setup_project_draft_" in lowered: + return ("high", "business-draft", "立项配置草稿本地兜底,需显式提示未落库") + if "ctms_setup_published_project_sig_" in lowered: + return ("medium", "publish-helper", "发布比对签名缓存,属于辅助数据") + + if any(k in lowered for k in ("token_key", "last_login_email_key", "logout_reason_storage_key")): + return ("low", "auth-session", "认证/会话数据(非业务主数据)") + if any(k in lowered for k in ("ctms_token", "ctms_last_login_email", "credential", "auth")): + return ("low", "auth-session", "认证/会话数据(非业务主数据)") + if any(k in lowered for k in ("ctms_sidebar_collapsed", "study", "site", "role")): + return ("low", "ui-preference", "UI偏好/上下文缓存") + + if "personaltodo" in file_path.lower(): + return ("low", "feature-cache", "工作台本地待办缓存(非核心主数据)") + + return ("medium", "unknown", "未命中规则,建议人工复核是否为业务关键数据") + + +def scan_findings() -> list[Finding]: + findings: list[Finding] = [] + for path in iter_source_files(): + try: + content = path.read_text(encoding="utf-8") + except UnicodeDecodeError: + content = path.read_text(encoding="utf-8", errors="ignore") + lines = content.splitlines() + rel = str(path.relative_to(REPO_ROOT)) + for idx, line in enumerate(lines, start=1): + for call_name, pattern in CALL_PATTERNS.items(): + for match in pattern.finditer(line): + key_expr = match.group(1).strip() + severity, category, note = classify(rel, key_expr) + findings.append( + Finding( + file=rel, + line=idx, + call=call_name, + key_expr=key_expr, + severity=severity, + category=category, + note=note, + ) + ) + return findings + + +def markdown_report(findings: list[Finding]) -> str: + counts: dict[str, int] = {"high": 0, "medium": 0, "low": 0} + for item in findings: + counts[item.severity] = counts.get(item.severity, 0) + 1 + + lines = [ + "# 存储落库核查报告", + "", + f"- 扫描目录: `{FRONTEND_SRC}`", + f"- 总发现数: `{len(findings)}`", + f"- 高风险: `{counts['high']}` / 中风险: `{counts['medium']}` / 低风险: `{counts['low']}`", + "", + "## 明细", + "", + "| 风险 | 分类 | 文件 | 行号 | 调用 | key表达式 | 说明 |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + for item in sorted(findings, key=lambda x: (x.severity, x.file, x.line)): + lines.append( + f"| {item.severity} | {item.category} | `{item.file}` | {item.line} | `{item.call}` | `{item.key_expr}` | {item.note} |" + ) + return "\n".join(lines) + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description="Scan frontend storage usage and classify persistence risks.") + parser.add_argument("--format", choices=("markdown", "json"), default="markdown") + parser.add_argument("--output", default="", help="Optional output file path.") + parser.add_argument( + "--fail-on-banned", + action="store_true", + help="Exit with code 2 when banned local key markers are detected.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + findings = scan_findings() + + banned_hits = [] + if args.fail_on_banned: + for item in findings: + expr = item.key_expr.lower().replace('"', "").replace("'", "") + if any(marker in expr for marker in BANNED_KEY_MARKERS): + banned_hits.append(item) + + if args.format == "json": + output_text = json.dumps([asdict(item) for item in findings], ensure_ascii=False, indent=2) + else: + output_text = markdown_report(findings) + + if args.output: + out = Path(args.output) + if not out.is_absolute(): + out = REPO_ROOT / out + out.parent.mkdir(parents=True, exist_ok=True) + out.write_text(output_text + "\n", encoding="utf-8") + else: + print(output_text) + + if banned_hits: + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/database/init.sql b/database/init.sql index ac045573..6ef5993f 100644 --- a/database/init.sql +++ b/database/init.sql @@ -87,6 +87,10 @@ CREATE TABLE IF NOT EXISTS public.milestones ( type character varying(50) NOT NULL, name character varying(200) NOT NULL, planned_date date, + adjusted_start_date date, + adjusted_end_date date, + actual_start_date date, + actual_end_date date, actual_date date, status character varying(20) NOT NULL DEFAULT 'NOT_STARTED', site_id uuid, @@ -329,6 +333,25 @@ CREATE TABLE IF NOT EXISTS public.drug_shipments ( CONSTRAINT fk_drug_shipments_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT ); +CREATE TABLE IF NOT EXISTS public.material_equipments ( + id uuid PRIMARY KEY, + study_id uuid NOT NULL, + name character varying(255) NOT NULL, + spec_model character varying(255) NOT NULL, + unit character varying(50), + brand character varying(100) NOT NULL, + origin character varying(255), + production_permit_file_name character varying(255), + tech_index_file_name character varying(255), + need_calibration boolean NOT NULL DEFAULT false, + calibration_cycle_days integer, + created_by uuid, + created_at timestamp with time zone NOT NULL DEFAULT now(), + updated_at timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT fk_material_equipments_study_id FOREIGN KEY (study_id) REFERENCES public.studies(id) ON DELETE RESTRICT, + CONSTRAINT fk_material_equipments_created_by FOREIGN KEY (created_by) REFERENCES public.users(id) ON DELETE RESTRICT +); + CREATE TABLE IF NOT EXISTS public.startup_feasibility ( id uuid PRIMARY KEY, study_id uuid NOT NULL, @@ -515,6 +538,7 @@ CREATE INDEX IF NOT EXISTS ix_finance_items_site_id ON public.finance_items (sit CREATE INDEX IF NOT EXISTS ix_finance_items_subject_id ON public.finance_items (subject_id); CREATE INDEX IF NOT EXISTS ix_drug_shipments_study_id ON public.drug_shipments (study_id); CREATE INDEX IF NOT EXISTS ix_drug_shipments_center_id ON public.drug_shipments (center_id); +CREATE INDEX IF NOT EXISTS ix_material_equipments_study_id ON public.material_equipments (study_id); CREATE INDEX IF NOT EXISTS ix_startup_feasibility_study_id ON public.startup_feasibility (study_id); CREATE INDEX IF NOT EXISTS ix_startup_ethics_study_id ON public.startup_ethics (study_id); CREATE INDEX IF NOT EXISTS ix_kickoff_meetings_study_id ON public.kickoff_meetings (study_id); diff --git a/docs/setup-config-api.md b/docs/setup-config-api.md index 77b2839c..d2cfcdcb 100644 --- a/docs/setup-config-api.md +++ b/docs/setup-config-api.md @@ -10,6 +10,7 @@ - `草稿视图`: 当前草稿可编辑 - `发布预览`: 当前草稿只读预览(不直接读取 `published_data`) - `published_data`: 用于发布快照、差异比较与版本能力 + - `published_project_snapshot`: 发布时的项目基础信息快照(用于“项目信息差异”判定) ## 2. 权限矩阵 - `GET /setup-config` @@ -80,6 +81,33 @@ "centerConfirm": [] }, "published_data": null, + "published_project_snapshot": { + "code": "DEMO-CTMS", + "name": "演示项目", + "project_full_name": "演示项目全称", + "sponsor": "", + "protocol_no": "", + "lead_unit": "", + "principal_investigator": "", + "main_pm": "", + "research_analysis": "", + "research_product": "", + "control_product": "", + "indication": "", + "research_population": "", + "research_design": "", + "plan_start_date": "2026-02-01", + "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 + }, "saved_by": "11111111-1111-1111-1111-111111111111", "saved_by_name": "System Admin", "published_by": null, diff --git a/docs/storage-persistence-audit.md b/docs/storage-persistence-audit.md new file mode 100644 index 00000000..183f1d3f --- /dev/null +++ b/docs/storage-persistence-audit.md @@ -0,0 +1,56 @@ +# 存储落库核查报告 + +- 扫描目录: `/Users/zcc/MyCTMS/ctms-project/frontend/src` +- 总发现数: `46` +- 高风险: `4` / 中风险: `0` / 低风险: `42` + +## 明细 + +| 风险 | 分类 | 文件 | 行号 | 调用 | key表达式 | 说明 | +| --- | --- | --- | --- | --- | --- | --- | +| high | business-draft | `frontend/src/views/admin/ProjectDetail.vue` | 2849 | `localStorage.getItem` | `storageKey.value` | 立项配置草稿本地兜底,需显式提示未落库 | +| high | business-draft | `frontend/src/views/admin/ProjectDetail.vue` | 2908 | `localStorage.getItem` | `projectDraftStorageKey.value` | 立项配置草稿本地兜底,需显式提示未落库 | +| high | business-draft | `frontend/src/views/admin/ProjectDetail.vue` | 2976 | `localStorage.removeItem` | `projectDraftStorageKey.value` | 立项配置草稿本地兜底,需显式提示未落库 | +| high | business-draft | `frontend/src/views/admin/ProjectDetail.vue` | 2984 | `localStorage.removeItem` | `storageKey.value` | 立项配置草稿本地兜底,需显式提示未落库 | +| low | ui-preference | `frontend/src/components/Layout.vue` | 227 | `localStorage.getItem` | `"ctms_sidebar_collapsed"` | UI偏好/上下文缓存 | +| low | ui-preference | `frontend/src/components/Layout.vue` | 390 | `localStorage.setItem` | `"ctms_sidebar_collapsed"` | UI偏好/上下文缓存 | +| low | auth-session | `frontend/src/components/LockScreenModal.vue` | 92 | `localStorage.getItem` | `"ctms_last_login_email"` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/components/ThreadList.vue` | 72 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 132 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 137 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/components/attachments/AttachmentList.vue` | 168 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 169 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 174 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/components/fees/FeeAttachmentPanel.vue` | 223 | `localStorage.getItem` | `"ctms_token"` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/session/sessionManager.ts` | 33 | `localStorage.setItem` | `"ctms_auth_broadcast"` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/session/sessionManager.ts` | 181 | `sessionStorage.removeItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/session/sessionManager.ts` | 184 | `sessionStorage.setItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/session/sessionManager.ts` | 192 | `sessionStorage.getItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/session/sessionManager.ts` | 193 | `sessionStorage.removeItem` | `LOGOUT_REASON_STORAGE_KEY` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/store/auth.ts` | 24 | `localStorage.setItem` | `LAST_LOGIN_EMAIL_KEY` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/store/auth.ts` | 39 | `localStorage.setItem` | `LAST_LOGIN_EMAIL_KEY` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/store/auth.ts` | 46 | `localStorage.getItem` | `LAST_LOGIN_EMAIL_KEY` | 认证/会话数据(非业务主数据) | +| low | ui-preference | `frontend/src/store/study.ts` | 20 | `localStorage.getItem` | `LAST_STUDY_BY_USER_KEY` | UI偏好/上下文缓存 | +| low | ui-preference | `frontend/src/store/study.ts` | 30 | `localStorage.setItem` | `LAST_STUDY_BY_USER_KEY` | UI偏好/上下文缓存 | +| low | ui-preference | `frontend/src/store/study.ts` | 44 | `localStorage.removeItem` | `SITE_KEY` | UI偏好/上下文缓存 | +| low | ui-preference | `frontend/src/store/study.ts` | 48 | `localStorage.setItem` | `STUDY_KEY` | UI偏好/上下文缓存 | +| low | ui-preference | `frontend/src/store/study.ts` | 50 | `localStorage.setItem` | `STUDY_ROLE_KEY` | UI偏好/上下文缓存 | +| low | auth-session | `frontend/src/store/study.ts` | 52 | `localStorage.getItem` | `"ctms_last_login_email"` | 认证/会话数据(非业务主数据) | +| low | ui-preference | `frontend/src/store/study.ts` | 59 | `localStorage.getItem` | `STUDY_KEY` | UI偏好/上下文缓存 | +| low | ui-preference | `frontend/src/store/study.ts` | 67 | `localStorage.getItem` | `STUDY_ROLE_KEY` | UI偏好/上下文缓存 | +| low | ui-preference | `frontend/src/store/study.ts` | 70 | `localStorage.getItem` | `SITE_KEY` | UI偏好/上下文缓存 | +| low | ui-preference | `frontend/src/store/study.ts` | 116 | `localStorage.removeItem` | `STUDY_KEY` | UI偏好/上下文缓存 | +| low | ui-preference | `frontend/src/store/study.ts` | 117 | `localStorage.removeItem` | `STUDY_ROLE_KEY` | UI偏好/上下文缓存 | +| low | ui-preference | `frontend/src/store/study.ts` | 118 | `localStorage.removeItem` | `SITE_KEY` | UI偏好/上下文缓存 | +| low | ui-preference | `frontend/src/store/study.ts` | 165 | `localStorage.setItem` | `STUDY_ROLE_KEY` | UI偏好/上下文缓存 | +| low | ui-preference | `frontend/src/store/study.ts` | 167 | `localStorage.removeItem` | `STUDY_ROLE_KEY` | UI偏好/上下文缓存 | +| low | ui-preference | `frontend/src/store/study.ts` | 174 | `localStorage.setItem` | `SITE_KEY` | UI偏好/上下文缓存 | +| low | ui-preference | `frontend/src/store/study.ts` | 176 | `localStorage.removeItem` | `SITE_KEY` | UI偏好/上下文缓存 | +| low | auth-session | `frontend/src/utils/auth.ts` | 4 | `localStorage.getItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/utils/auth.ts` | 7 | `localStorage.setItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/utils/auth.ts` | 11 | `localStorage.removeItem` | `TOKEN_KEY` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/utils/auth.ts` | 17 | `localStorage.setItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/utils/auth.ts` | 24 | `localStorage.getItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) | +| low | auth-session | `frontend/src/utils/auth.ts` | 38 | `localStorage.removeItem` | `CREDENTIAL_KEY` | 认证/会话数据(非业务主数据) | +| low | feature-cache | `frontend/src/views/workbench/components/PersonalTodo.vue` | 79 | `localStorage.getItem` | `props.storageKey` | 工作台本地待办缓存(非核心主数据) | +| low | feature-cache | `frontend/src/views/workbench/components/PersonalTodo.vue` | 91 | `localStorage.setItem` | `props.storageKey` | 工作台本地待办缓存(非核心主数据) | diff --git a/docs/storage-persistence-governance.md b/docs/storage-persistence-governance.md new file mode 100644 index 00000000..129558d6 --- /dev/null +++ b/docs/storage-persistence-governance.md @@ -0,0 +1,52 @@ +# 全项目重要数据落库治理基线(2026-02-27) + +## 1. 口径 + +- 纳入范围:业务主数据 + 审计数据 +- 排除范围:纯 UI 偏好、登录态会话、跨标签广播等非业务主数据 + +## 2. 自动核查入口 + +```bash +backend/scripts/storage_persistence_audit.py --format markdown --output docs/storage-persistence-audit.md +backend/scripts/storage_persistence_audit.py --fail-on-banned +``` + +- `docs/storage-persistence-audit.md`:当前扫描快照 +- `--fail-on-banned`:用于阻断禁止项(当前默认禁止 `audit_local_*`) + +## 3. 当前结论(基于 2026-02-27 扫描) + +- 高风险:4 条(均为 `ProjectDetail` 本地草稿兜底读写) +- 中风险:0 条 +- 低风险:42 条(会话、偏好、工作台本地待办等) + +## 4. 已完成整改 + +### 4.1 审计链路 + +- 关键管理动作改为后端落库(站点、项目成员) +- 审计页改为服务端唯一数据源 +- 前端本地审计日志通道已下线(不再写 `audit_local_*`) +- 审计导出事件改为后端写入审计表 + +### 4.2 业务链路 + +- 设备管理已接入后端并落库 +- 项目里程碑编辑保存已改为调用后端接口并落库 +- 立项配置草稿保留本地兜底,但已增加: + - `SYNCED / LOCAL_ONLY / DIRTY_UNSAVED` 状态识别 + - 本地草稿恢复确认(含过期/冲突提示) + - 发布前 `LOCAL_ONLY` 阻断 + - 保存失败重试与成功后自动清理本地草稿 +- 发布比对签名已改为后端返回的 `published_project_snapshot`,不再依赖 localStorage + +### 4.3 门禁与流程 + +- 已新增 CI 工作流:`.github/workflows/storage-persistence-guard.yml` +- PR / push 到主干时会执行 `storage_persistence_audit.py --fail-on-banned` + +## 5. 剩余优化项(可选) + +1. 将“高风险清单”阈值从固定 key 规则升级为白名单机制(白名单外一律告警)。 +2. 评估是否把 `ProjectDetail` 本地草稿从 localStorage 迁移为后端草稿(可选能力,非当前口径强制项)。 diff --git a/frontend/src/api/auditLogs.ts b/frontend/src/api/auditLogs.ts index 70181edf..7fc8488d 100644 --- a/frontend/src/api/auditLogs.ts +++ b/frontend/src/api/auditLogs.ts @@ -1,4 +1,4 @@ -import { apiDelete, apiGet } from "./axios"; +import { apiDelete, apiGet, apiPost } from "./axios"; import type { ApiListResponse } from "../types/api"; export const fetchAuditLogs = (studyId: string, params?: Record) => @@ -6,3 +6,6 @@ export const fetchAuditLogs = (studyId: string, params?: Record) => export const deleteAuditLog = (studyId: string, logId: string) => apiDelete(`/api/v1/studies/${studyId}/audit-logs/${logId}`); + +export const createAuditEvent = (studyId: string, payload: Record) => + apiPost(`/api/v1/studies/${studyId}/audit-logs/events`, payload); diff --git a/frontend/src/api/materialEquipments.ts b/frontend/src/api/materialEquipments.ts new file mode 100644 index 00000000..6450bff6 --- /dev/null +++ b/frontend/src/api/materialEquipments.ts @@ -0,0 +1,16 @@ +import { apiDelete, apiGet, apiPatch, apiPost } from "./axios"; + +export const listMaterialEquipments = (studyId: string, params?: Record) => + apiGet(`/api/v1/studies/${studyId}/materials/equipment`, { params }); + +export const getMaterialEquipment = (studyId: string, equipmentId: string) => + apiGet(`/api/v1/studies/${studyId}/materials/equipment/${equipmentId}`); + +export const createMaterialEquipment = (studyId: string, payload: Record) => + apiPost(`/api/v1/studies/${studyId}/materials/equipment`, payload); + +export const updateMaterialEquipment = (studyId: string, equipmentId: string, payload: Record) => + apiPatch(`/api/v1/studies/${studyId}/materials/equipment/${equipmentId}`, payload); + +export const deleteMaterialEquipment = (studyId: string, equipmentId: string) => + apiDelete(`/api/v1/studies/${studyId}/materials/equipment/${equipmentId}`); diff --git a/frontend/src/api/projectMilestones.ts b/frontend/src/api/projectMilestones.ts new file mode 100644 index 00000000..2beef10b --- /dev/null +++ b/frontend/src/api/projectMilestones.ts @@ -0,0 +1,10 @@ +import { apiGet, apiPatch } from "./axios"; + +export const listProjectMilestones = (studyId: string) => + apiGet(`/api/v1/studies/${studyId}/project/milestones`); + +export const updateProjectMilestone = ( + studyId: string, + milestoneId: string, + payload: Record +) => apiPatch(`/api/v1/studies/${studyId}/project/milestones/${milestoneId}`, payload); diff --git a/frontend/src/audit/index.ts b/frontend/src/audit/index.ts index 12adecff..b1f23c5d 100644 --- a/frontend/src/audit/index.ts +++ b/frontend/src/audit/index.ts @@ -1,7 +1,5 @@ import { auditDict } from "./auditDict"; import { roleDict, getDictLabel, statusDict } from "../dictionaries"; -import { useAuthStore } from "../store/auth"; -import { useStudyStore } from "../store/study"; import { TEXT } from "../locales"; export interface AuditEvent { @@ -250,47 +248,9 @@ export const normalizeAuditEvent = (raw: any, userMap: Record): export { auditDict }; -const LOCAL_KEY_PREFIX = "audit_local_"; -const getLocalKey = (studyId?: string | null) => `${LOCAL_KEY_PREFIX}${studyId || "global"}`; - -const appendLocalAudit = (studyId: string | null, log: any) => { - try { - const key = getLocalKey(studyId); - const existingRaw = localStorage.getItem(key); - const existing = existingRaw ? JSON.parse(existingRaw) : []; - existing.unshift(log); - localStorage.setItem(key, JSON.stringify(existing.slice(0, 200))); - } catch { - /* ignore */ - } -}; - -// 前端占位的 logAudit:不修改后端接口,落地到本地存储,失败不影响主流程 +// Deprecated: keep API-compatible no-op to avoid accidental reliance on browser local storage. export const logAudit = async (eventType: string, payload: Record) => { - try { - const auth = useAuthStore(); - const study = useStudyStore(); - const user = auth.user; - const studyId = study.currentStudy?.id || null; - const log = { - action: eventType, - operator_id: user?.id || user?.username || TEXT.audit.currentUser, - operator_role: user?.role || "", - entity_type: payload.targetType || TEXT.audit.localClient, - entity_id: payload.targetId || "", - created_at: new Date().toISOString(), - detail: JSON.stringify({ - targetName: payload.targetName, - before: payload.before, - after: payload.after, - reason: payload.reason, - severity: payload.severity, - result: payload.severity === "normal" ? "SUCCESS" : "FAIL", - }), - }; - appendLocalAudit(studyId, log); - } catch { - /* ignore */ - } + void eventType; + void payload; return Promise.resolve(); }; diff --git a/frontend/src/components/Layout.vue b/frontend/src/components/Layout.vue index 1540465b..7fb743d1 100644 --- a/frontend/src/components/Layout.vue +++ b/frontend/src/components/Layout.vue @@ -58,7 +58,6 @@ {{ TEXT.menu.drugShipments }} {{ TEXT.menu.materialEquipment }} - {{ TEXT.menu.materialOthers }} @@ -206,7 +205,7 @@ + + diff --git a/frontend/src/views/ia/MaterialOthers.vue b/frontend/src/views/ia/MaterialOthers.vue deleted file mode 100644 index 00e56e94..00000000 --- a/frontend/src/views/ia/MaterialOthers.vue +++ /dev/null @@ -1,13 +0,0 @@ - - - diff --git a/frontend/src/views/ia/ProjectMilestones.vue b/frontend/src/views/ia/ProjectMilestones.vue index c5e6b4dc..01472fdd 100644 --- a/frontend/src/views/ia/ProjectMilestones.vue +++ b/frontend/src/views/ia/ProjectMilestones.vue @@ -196,11 +196,10 @@ import { computed, onMounted, reactive, ref, watch } from "vue"; import { ElMessage } from "element-plus"; import { useStudyStore } from "../../store/study"; -import { fetchStudySetupConfig } from "../../api/studies"; +import { listProjectMilestones, updateProjectMilestone } from "../../api/projectMilestones"; import { TEXT } from "../../locales"; import StateEmpty from "../../components/StateEmpty.vue"; import { displayDateTime } from "../../utils/display"; -import type { ProjectMilestoneDraft, StudySetupConfigResponse } from "../../types/setupConfig"; interface MilestoneRow { id: string; @@ -234,11 +233,10 @@ type MilestoneLocalEdit = { const study = useStudyStore(); const rows = ref([]); const loading = ref(false); -const dataSourceLabel = ref(TEXT.modules.projectMilestones.sourceDraft); +const dataSourceLabel = ref(TEXT.modules.projectMilestones.sourcePublished); const updatedAtLabel = ref(TEXT.common.fallback); const editorVisible = ref(false); const editingRowId = ref(""); -const localEdits = ref>({}); const editorForm = reactive({ adjustedStartDate: "", adjustedEndDate: "", @@ -249,8 +247,6 @@ const editorForm = reactive({ const doneCount = computed(() => rows.value.filter((item) => getStatusView(item).completed).length); -const getLocalEditKey = (studyId: string) => `ctms_project_milestones_edits_${studyId}`; - const toDateOnly = (value?: string): Date | null => { if (!value) return null; const d = new Date(`${value}T00:00:00`); @@ -267,22 +263,24 @@ const calcDurationDays = (start?: string, end?: string): number | null => { }; const toDurationText = (days: number | null) => (days && days > 0 ? `${days}天` : TEXT.common.fallback); -const toPositiveInt = (value: unknown): number | null => { - const n = Number(value); - if (!Number.isFinite(n) || n <= 0) return null; - return Math.floor(n); -}; -const applyEditToRow = (row: MilestoneRow): MilestoneRow => { - const patch = localEdits.value[row.id] || {}; - const adjustedStartDate = String(patch.adjustedStartDate ?? row.adjustedStartDate ?? "").trim(); - const adjustedEndDate = String(patch.adjustedEndDate ?? row.adjustedEndDate ?? "").trim(); - const actualStartDate = String(patch.actualStartDate ?? row.actualStartDate ?? "").trim(); - const actualEndDate = String(patch.actualEndDate ?? row.actualEndDate ?? "").trim(); +const normalizeRow = (row: any): MilestoneRow => { + const startDate = String(row?.planned_date || "").trim(); + const endDate = String(row?.planned_date || "").trim(); + const adjustedStartDate = String(row?.adjusted_start_date || "").trim(); + const adjustedEndDate = String(row?.adjusted_end_date || "").trim(); + const actualStartDate = String(row?.actual_start_date || "").trim(); + const actualEndDate = String(row?.actual_end_date || "").trim(); const adjustedDurationDays = calcDurationDays(adjustedStartDate, adjustedEndDate); const actualDurationDays = calcDurationDays(actualStartDate, actualEndDate); return { - ...row, + id: row?.id || "", + name: String(row?.name || "").trim(), + planDate: startDate, + startDate, + endDate, + durationDays: startDate ? 1 : null, + durationText: startDate ? "1天" : TEXT.common.fallback, adjustedStartDate, adjustedEndDate, adjustedDurationDays, @@ -291,85 +289,26 @@ const applyEditToRow = (row: MilestoneRow): MilestoneRow => { actualEndDate, actualDurationDays, actualDurationText: toDurationText(actualDurationDays), - remark: String(patch.remark ?? row.remark ?? "").trim(), + owner: String(row?.owner_name || "").trim(), + status: String(row?.status || "NOT_STARTED").trim(), + remark: String(row?.notes || "").trim(), }; }; -const loadLocalEdits = () => { - const studyId = study.currentStudy?.id; - if (!studyId) return; - try { - const raw = localStorage.getItem(getLocalEditKey(studyId)); - localEdits.value = raw ? (JSON.parse(raw) as Record) : {}; - } catch { - localEdits.value = {}; - } -}; - -const persistLocalEdits = () => { - const studyId = study.currentStudy?.id; - if (!studyId) return; - localStorage.setItem(getLocalEditKey(studyId), JSON.stringify(localEdits.value)); -}; - -const normalizeRows = (items: ProjectMilestoneDraft[]) => - items - .map((item, index) => { - const planDate = String(item.planDate || "").trim(); - const startDate = String(item.startDate || planDate).trim(); - const endDate = String(item.endDate || planDate).trim(); - const adjustedStartDate = String((item as any).adjustedStartDate || "").trim(); - const adjustedEndDate = String((item as any).adjustedEndDate || "").trim(); - const actualStartDate = String((item as any).actualStartDate || "").trim(); - const actualEndDate = String((item as any).actualEndDate || "").trim(); - const rawDays = toPositiveInt(item.durationDays); - const adjustedRawDays = toPositiveInt((item as any).adjustedDurationDays) ?? calcDurationDays(adjustedStartDate, adjustedEndDate); - const actualRawDays = toPositiveInt((item as any).actualDurationDays) ?? calcDurationDays(actualStartDate, actualEndDate); - const durationDays = rawDays ?? (startDate || endDate ? 1 : null); - const adjustedDurationDays = adjustedRawDays ?? (adjustedStartDate || adjustedEndDate ? 1 : null); - const actualDurationDays = actualRawDays ?? (actualStartDate || actualEndDate ? 1 : null); - return { - id: item.id || `setup-project-milestone-${index + 1}`, - name: String(item.name || "").trim(), - planDate, - startDate, - endDate, - durationDays, - durationText: durationDays ? `${durationDays}天` : TEXT.common.fallback, - adjustedStartDate, - adjustedEndDate, - adjustedDurationDays, - adjustedDurationText: adjustedDurationDays ? `${adjustedDurationDays}天` : TEXT.common.fallback, - actualStartDate, - actualEndDate, - actualDurationDays, - actualDurationText: actualDurationDays ? `${actualDurationDays}天` : TEXT.common.fallback, - owner: String(item.owner || "").trim(), - status: String(item.status || "未开始").trim(), - remark: String(item.remark || "").trim(), - }; - }) - .filter((item) => item.name || item.startDate || item.endDate || item.owner || item.remark); - -const pickProjectMilestones = (setup: StudySetupConfigResponse): ProjectMilestoneDraft[] => { - const publishedRows = normalizeRows(setup.published_data?.projectMilestones || []); - if (publishedRows.length > 0) { - dataSourceLabel.value = TEXT.modules.projectMilestones.sourcePublished; - return setup.published_data?.projectMilestones || []; - } - dataSourceLabel.value = setup.published_data ? TEXT.modules.projectMilestones.sourceDraftFallback : TEXT.modules.projectMilestones.sourceDraft; - return setup.data?.projectMilestones || []; -}; - const loadMilestones = async () => { const studyId = study.currentStudy?.id; if (!studyId) return; loading.value = true; try { - const { data } = await fetchStudySetupConfig(studyId); - loadLocalEdits(); - rows.value = normalizeRows(pickProjectMilestones(data)).map(applyEditToRow); - updatedAtLabel.value = displayDateTime(data.published_at || data.updated_at); + const { data } = (await listProjectMilestones(studyId)) as any; + const list = Array.isArray(data) ? data : data?.items || []; + rows.value = list.map(normalizeRow); + const updatedAt = list + .map((item: any) => String(item?.updated_at || "")) + .filter(Boolean) + .sort() + .pop(); + updatedAtLabel.value = updatedAt ? displayDateTime(updatedAt) : TEXT.common.fallback; } catch (error: any) { rows.value = []; updatedAtLabel.value = TEXT.common.fallback; @@ -468,25 +407,26 @@ const openEditor = (row: MilestoneRow) => { editorVisible.value = true; }; -const saveEditor = () => { - if (!editingRowId.value) return; +const saveEditor = async () => { + const studyId = study.currentStudy?.id; + if (!studyId || !editingRowId.value) return; if (!validateDateRange(editorForm.adjustedStartDate, editorForm.adjustedEndDate, "调整计划时间")) return; if (!validateDateRange(editorForm.actualStartDate, editorForm.actualEndDate, "实际时间")) return; - const patch: MilestoneLocalEdit = { - adjustedStartDate: editorForm.adjustedStartDate || "", - adjustedEndDate: editorForm.adjustedEndDate || "", - actualStartDate: editorForm.actualStartDate || "", - actualEndDate: editorForm.actualEndDate || "", - remark: (editorForm.remark || "").trim(), + const patch = { + adjusted_start_date: editorForm.adjustedStartDate || null, + adjusted_end_date: editorForm.adjustedEndDate || null, + actual_start_date: editorForm.actualStartDate || null, + actual_end_date: editorForm.actualEndDate || null, + notes: (editorForm.remark || "").trim() || null, }; - localEdits.value = { - ...localEdits.value, - [editingRowId.value]: patch, - }; - persistLocalEdits(); - rows.value = rows.value.map((item) => (item.id === editingRowId.value ? applyEditToRow(item) : item)); - editorVisible.value = false; - ElMessage.success("里程碑时间已更新"); + try { + await updateProjectMilestone(studyId, editingRowId.value, patch); + await loadMilestones(); + editorVisible.value = false; + ElMessage.success("里程碑时间已更新"); + } catch (error: any) { + ElMessage.error(error?.response?.data?.detail || TEXT.common.messages.saveFailed); + } }; onMounted(() => { @@ -497,7 +437,6 @@ watch( () => study.currentStudy?.id, () => { rows.value = []; - localEdits.value = {}; updatedAtLabel.value = TEXT.common.fallback; loadMilestones(); }