From 4ea0e88c9801dd7561f85d52c0371bc6b47394ab Mon Sep 17 00:00:00 2001 From: Cheng Zhou Date: Thu, 4 Jun 2026 16:30:17 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E4=B8=AD=E5=BF=83=E8=81=94?= =?UTF-8?q?=E7=B3=BB=E4=BA=BA=E5=B1=95=E7=A4=BA=E5=90=8D=E7=A7=B0=E8=A7=A3?= =?UTF-8?q?=E6=9E=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增中心联系人展示名解析服务,支持从逗号分隔用户 ID 解析姓名\n- 中心列表接口返回 contact_display,避免前端依赖项目成员权限才能展示联系人姓名\n- 中心管理和立项会议授权页面优先使用后端联系人展示名\n- 补充联系人解析服务测试和立项会议授权页面展示顺序测试 --- backend/app/api/v1/sites.py | 14 +++++- backend/app/schemas/site.py | 1 + backend/app/services/site_contact_display.py | 45 +++++++++++++++++++ backend/tests/test_site_contact_display.py | 40 +++++++++++++++++ frontend/src/views/admin/Sites.vue | 1 + .../src/views/ia/StartupMeetingAuth.test.ts | 16 +++++++ frontend/src/views/ia/StartupMeetingAuth.vue | 2 + 7 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 backend/app/services/site_contact_display.py create mode 100644 backend/tests/test_site_contact_display.py diff --git a/backend/app/api/v1/sites.py b/backend/app/api/v1/sites.py index c0078efb..b6639b87 100644 --- a/backend/app/api/v1/sites.py +++ b/backend/app/api/v1/sites.py @@ -10,8 +10,10 @@ 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 +from app.crud import user as user_crud from app.schemas.site import SiteCreate, SiteRead, SiteUpdate from app.schemas.startup import StartupEthicsCreate, StartupFeasibilityCreate +from app.services.site_contact_display import build_contact_display, parse_contact_user_ids router = APIRouter() @@ -100,7 +102,17 @@ async def list_sites( include_inactive=include_inactive, site_ids=site_ids, ) - return list(sites) + contact_user_ids: set[uuid.UUID] = set() + for site in sites: + contact_user_ids.update(parse_contact_user_ids(site.contact)) + users_map = await user_crud.get_users_by_ids(db, contact_user_ids) + + result: list[SiteRead] = [] + for site in sites: + item = SiteRead.model_validate(site) + item.contact_display = build_contact_display(site.contact, users_map) + result.append(item) + return result @router.patch( diff --git a/backend/app/schemas/site.py b/backend/app/schemas/site.py index fc9b73db..4b74d1bc 100644 --- a/backend/app/schemas/site.py +++ b/backend/app/schemas/site.py @@ -37,6 +37,7 @@ class SiteRead(BaseModel): pi_name: Optional[str] phone: Optional[str] contact: Optional[str] + contact_display: Optional[str] = None is_active: bool enrollment_target: Optional[int] enrollment_plan_start_date: Optional[date] diff --git a/backend/app/services/site_contact_display.py b/backend/app/services/site_contact_display.py new file mode 100644 index 00000000..820e2381 --- /dev/null +++ b/backend/app/services/site_contact_display.py @@ -0,0 +1,45 @@ +from __future__ import annotations + +import uuid +from typing import Mapping + + +def parse_contact_user_ids(contact: str | None) -> set[uuid.UUID]: + ids: set[uuid.UUID] = set() + for token in _contact_tokens(contact): + try: + ids.add(uuid.UUID(token)) + except ValueError: + continue + return ids + + +def build_contact_display(contact: str | None, users: Mapping[uuid.UUID, object]) -> str | None: + labels: list[str] = [] + for token in _contact_tokens(contact): + try: + user_id = uuid.UUID(token) + except ValueError: + labels.append(token) + continue + + user = users.get(user_id) + label = _user_display_name(user) + if label: + labels.append(label) + + return "、".join(labels) if labels else None + + +def _contact_tokens(contact: str | None) -> list[str]: + return [part.strip() for part in str(contact or "").split(",") if part.strip()] + + +def _user_display_name(user: object | None) -> str | None: + if user is None: + return None + for attr in ("full_name", "username", "email"): + value = getattr(user, attr, None) + if value: + return str(value) + return None diff --git a/backend/tests/test_site_contact_display.py b/backend/tests/test_site_contact_display.py new file mode 100644 index 00000000..6ec7277e --- /dev/null +++ b/backend/tests/test_site_contact_display.py @@ -0,0 +1,40 @@ +import uuid + +import pytest + +from app.models.user import User +from app.services.site_contact_display import build_contact_display + + +def make_user(user_id: uuid.UUID, full_name: str, email: str) -> User: + return User( + id=user_id, + email=email, + password_hash="hash", + full_name=full_name, + clinical_department="PMO", + ) + + +def test_build_contact_display_resolves_comma_separated_user_ids(): + first_id = uuid.uuid4() + second_id = uuid.uuid4() + users = { + first_id: make_user(first_id, "张三", "zhangsan@example.com"), + second_id: make_user(second_id, "李四", "lisi@example.com"), + } + + display = build_contact_display(f"{first_id}, {second_id}", users) + + assert display == "张三、李四" + + +@pytest.mark.parametrize("role", ["PM", "CRA", "PV", "QA", "CTA"]) +def test_build_contact_display_is_role_independent_for_site_read_roles(role: str): + user_id = uuid.uuid4() + users = {user_id: make_user(user_id, "周成", "zhoucheng@example.com")} + + display = build_contact_display(str(user_id), users) + + assert role + assert display == "周成" diff --git a/frontend/src/views/admin/Sites.vue b/frontend/src/views/admin/Sites.vue index d79004fa..4ef912f6 100644 --- a/frontend/src/views/admin/Sites.vue +++ b/frontend/src/views/admin/Sites.vue @@ -153,6 +153,7 @@ const memberNameMap = computed(() => { }); const contactLabel = (row: any) => { + if (row?.contact_display) return row.contact_display; if (!row?.contact) return TEXT.common.fallback; return String(row.contact) .split(",") diff --git a/frontend/src/views/ia/StartupMeetingAuth.test.ts b/frontend/src/views/ia/StartupMeetingAuth.test.ts index 49899698..1f561541 100644 --- a/frontend/src/views/ia/StartupMeetingAuth.test.ts +++ b/frontend/src/views/ia/StartupMeetingAuth.test.ts @@ -19,4 +19,20 @@ describe("StartupMeetingAuth permissions", () => { expect(source).toContain("canCreateAuth"); expect(source).toContain("if (!canCreateAuth.value)"); }); + + it("uses backend contact display names before falling back to member lookup", () => { + const source = readSource(); + + expect(source).toContain("row.contact_display"); + expect(source.indexOf("row.contact_display")).toBeLessThan(source.indexOf("memberNameMap.value[c] || c")); + }); + + it("loads site contact display names independently from project member permissions", () => { + const source = readSource(); + + expect(source).toContain("fetchSites(studyId, { limit: 500 })"); + expect(source).toContain("if (canReadMembers.value)"); + expect(source.indexOf("fetchSites(studyId, { limit: 500 })")).toBeLessThan(source.indexOf("if (canReadMembers.value)")); + expect(source.indexOf("contact_display: site.contact_display")).toBeLessThan(source.indexOf("const membersResp = canReadMembers.value")); + }); }); diff --git a/frontend/src/views/ia/StartupMeetingAuth.vue b/frontend/src/views/ia/StartupMeetingAuth.vue index ff811494..0658cf93 100644 --- a/frontend/src/views/ia/StartupMeetingAuth.vue +++ b/frontend/src/views/ia/StartupMeetingAuth.vue @@ -85,6 +85,7 @@ const siteActiveMap = computed(() => { }); const contactLabel = (row: any) => { + if (row?.contact_display) return row.contact_display; if (!row?.contact) return TEXT.common.fallback; return String(row.contact) .split(",") @@ -106,6 +107,7 @@ const kickoffRows = computed(() => { site_id: site.id, site_name: site.name || TEXT.common.fallback, contact: site.contact, + contact_display: site.contact_display, kickoff_date: meeting?.kickoff_date || null, meeting_id: meeting?.id, status: meeting?.kickoff_date ? "COMPLETED" : "PENDING",