Commit 379a4af8 by luoqi

feat(auth): 模拟登录接入诊所客服名册 + 列表诊所筛选按可见范围

- 数据:data/jvs-dw/users.json — 116 位在岗客服(回访表口径·近12月),
  结构 = 未来 users/user_clinics 两表形状(externalId/name/tenant/roles/clinics)
- 后端:mock-users.ts 加载器(cwd 解析+缓存);mockLogin 支持 userExternalId
  (选具体客服→sub=externalId/真实姓名/该客服诊所);新增 GET /auth/mock-users
- 前端:快速登录改为「角色(权限)+ 选客服」合并式;名册拉不到时降级回通用网格
- 前端:列表页诊所筛选选项改用 user.clinicIds(RBAC 可见范围),名取 dictionary
  (原误用全 tenant 名表,与后端 scope.clinicIds 过滤对齐)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
parent 3725c9ad
[
{
"externalId": "5588",
"name": "陈洁",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-06",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-06-06",
"contactCount": 1279
}
]
},
{
"externalId": "2923",
"name": "淡夏钦",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-06",
"clinics": [
{
"clinicId": "dad2f04a120947e2b82b41cbd108f3f4",
"lastActiveAt": "2026-06-06",
"contactCount": 3170
},
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2025-10-03",
"contactCount": 11
}
]
},
{
"externalId": "5559",
"name": "费苗妙",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-06",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-06-06",
"contactCount": 2325
}
]
},
{
"externalId": "832",
"name": "康慧捧",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-06",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-06-06",
"contactCount": 3382
}
]
},
{
"externalId": "2974",
"name": "刘海鑫",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-06",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-06-06",
"contactCount": 1066
},
{
"clinicId": "dad2f04a120947e2b82b41cbd108f3f4",
"lastActiveAt": "2025-12-26",
"contactCount": 1
}
]
},
{
"externalId": "3005",
"name": "钱俏虹",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-06",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-06-06",
"contactCount": 3094
}
]
},
{
"externalId": "6684",
"name": "饶佳端",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-06",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-06-06",
"contactCount": 862
}
]
},
{
"externalId": "6739",
"name": "任珊珊",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-06",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-06-06",
"contactCount": 3728
}
]
},
{
"externalId": "6319",
"name": "邵君",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-06",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-06-06",
"contactCount": 3992
}
]
},
{
"externalId": "4795",
"name": "石琳",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-06",
"clinics": [
{
"clinicId": "dad2f04a120947e2b82b41cbd108f3f4",
"lastActiveAt": "2026-06-06",
"contactCount": 5084
},
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2025-08-25",
"contactCount": 1
}
]
},
{
"externalId": "4854",
"name": "万静",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-06",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-06-06",
"contactCount": 1069
}
]
},
{
"externalId": "2998",
"name": "杨慧",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-06",
"clinics": [
{
"clinicId": "dad2f04a120947e2b82b41cbd108f3f4",
"lastActiveAt": "2026-06-06",
"contactCount": 2446
},
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-05-25",
"contactCount": 158
}
]
},
{
"externalId": "5886",
"name": "张旗",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-06",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-06-06",
"contactCount": 809
}
]
},
{
"externalId": "1797",
"name": "庄红梅",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-06",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-06-06",
"contactCount": 2388
},
{
"clinicId": "dad2f04a120947e2b82b41cbd108f3f4",
"lastActiveAt": "2025-08-22",
"contactCount": 12
}
]
},
{
"externalId": "7965",
"name": "陈雨",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-06-05",
"contactCount": 608
}
]
},
{
"externalId": "3006",
"name": "冯俐",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-06-05",
"contactCount": 1791
}
]
},
{
"externalId": "4205",
"name": "高瑞珍",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-06-05",
"contactCount": 739
}
]
},
{
"externalId": "8041",
"name": "季书环",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-06-05",
"contactCount": 499
}
]
},
{
"externalId": "5658",
"name": "金沁",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-06-05",
"contactCount": 4102
}
]
},
{
"externalId": "576",
"name": "金芮冰",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-06-05",
"contactCount": 2318
}
]
},
{
"externalId": "856",
"name": "李文霞",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-06-05",
"contactCount": 47
},
{
"clinicId": "dad2f04a120947e2b82b41cbd108f3f4",
"lastActiveAt": "2025-07-24",
"contactCount": 3
}
]
},
{
"externalId": "5679",
"name": "李欣",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-06-05",
"contactCount": 1688
}
]
},
{
"externalId": "6979",
"name": "刘凯莉",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-06-05",
"contactCount": 3267
}
]
},
{
"externalId": "7817",
"name": "刘孝义",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-06-05",
"contactCount": 3563
}
]
},
{
"externalId": "1008",
"name": "刘艳阳",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-06-05",
"contactCount": 879
}
]
},
{
"externalId": "7153",
"name": "刘颖聪",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-06-05",
"contactCount": 2637
}
]
},
{
"externalId": "7355",
"name": "吕佩姗",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-06-05",
"contactCount": 405
}
]
},
{
"externalId": "3534",
"name": "孟云玲",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-06-05",
"contactCount": 163
}
]
},
{
"externalId": "8066",
"name": "许李丽",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-06-05",
"contactCount": 548
}
]
},
{
"externalId": "849",
"name": "薛玫",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-06-05",
"contactCount": 250
}
]
},
{
"externalId": "5725",
"name": "叶谦",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-06-05",
"contactCount": 325
}
]
},
{
"externalId": "8083",
"name": "张雨菡",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-06-05",
"contactCount": 19
}
]
},
{
"externalId": "7827",
"name": "张紫薇",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-05",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-06-05",
"contactCount": 291
}
]
},
{
"externalId": "4809",
"name": "曹宇琴",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-04",
"clinics": [
{
"clinicId": "dad2f04a120947e2b82b41cbd108f3f4",
"lastActiveAt": "2026-06-04",
"contactCount": 266
}
]
},
{
"externalId": "3802",
"name": "李芬",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-04",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-06-04",
"contactCount": 390
}
]
},
{
"externalId": "5862",
"name": "李红艳",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-04",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-06-04",
"contactCount": 223
}
]
},
{
"externalId": "2922",
"name": "彭淑焱",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-04",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-06-04",
"contactCount": 220
}
]
},
{
"externalId": "1075",
"name": "位其蕾",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-04",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-06-04",
"contactCount": 1474
}
]
},
{
"externalId": "5985",
"name": "张勇",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-04",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-06-04",
"contactCount": 321
}
]
},
{
"externalId": "6232",
"name": "罗冬兰",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-03",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-06-03",
"contactCount": 294
}
]
},
{
"externalId": "4172",
"name": "杨颖",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-03",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-06-03",
"contactCount": 1518
}
]
},
{
"externalId": "5811",
"name": "张钧婷",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-03",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-06-03",
"contactCount": 72
}
]
},
{
"externalId": "5890",
"name": "张哲蒙",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-03",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-06-03",
"contactCount": 92
}
]
},
{
"externalId": "2987",
"name": "王杭丽",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-02",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-06-02",
"contactCount": 68
}
]
},
{
"externalId": "681",
"name": "张悦",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-02",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-06-02",
"contactCount": 989
}
]
},
{
"externalId": "5499",
"name": "姜莹",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-01",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-06-01",
"contactCount": 138
}
]
},
{
"externalId": "5748",
"name": "任雨尧",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-01",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-06-01",
"contactCount": 60
}
]
},
{
"externalId": "5707",
"name": "武晓琳",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-06-01",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-06-01",
"contactCount": 58
}
]
},
{
"externalId": "5850",
"name": "李熠",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-31",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-05-31",
"contactCount": 417
}
]
},
{
"externalId": "5901",
"name": "刘轲",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-31",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-05-31",
"contactCount": 40
}
]
},
{
"externalId": "8027",
"name": "宋楠",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-31",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-05-31",
"contactCount": 1804
}
]
},
{
"externalId": "8076",
"name": "姜荣鑫",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-30",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-05-30",
"contactCount": 123
}
]
},
{
"externalId": "5685",
"name": "束乾凤",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-30",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-05-30",
"contactCount": 157
}
]
},
{
"externalId": "7284",
"name": "宋俊娥",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-30",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-05-30",
"contactCount": 265
}
]
},
{
"externalId": "5654",
"name": "殷盼盼",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-30",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-05-30",
"contactCount": 240
}
]
},
{
"externalId": "2985",
"name": "邓甜甜",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-29",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-05-29",
"contactCount": 259
}
]
},
{
"externalId": "4996",
"name": "尚旭辉",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-29",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-05-29",
"contactCount": 821
}
]
},
{
"externalId": "7898",
"name": "王琦",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-29",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-05-29",
"contactCount": 83
}
]
},
{
"externalId": "5660",
"name": "余婷静",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-29",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-05-29",
"contactCount": 133
}
]
},
{
"externalId": "6277",
"name": "逯梓茜",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-28",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-05-28",
"contactCount": 3387
}
]
},
{
"externalId": "529",
"name": "沃然",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-28",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-05-28",
"contactCount": 1743
}
]
},
{
"externalId": "5745",
"name": "张英雨",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-28",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-05-28",
"contactCount": 160
}
]
},
{
"externalId": "5701",
"name": "居晓菁",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-27",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-05-27",
"contactCount": 542
}
]
},
{
"externalId": "5804",
"name": "寿雨薇",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-26",
"clinics": [
{
"clinicId": "dad2f04a120947e2b82b41cbd108f3f4",
"lastActiveAt": "2026-05-26",
"contactCount": 46
}
]
},
{
"externalId": "2989",
"name": "张艳荣",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-26",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-05-26",
"contactCount": 24
}
]
},
{
"externalId": "5703",
"name": "陈烨华",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-25",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-05-25",
"contactCount": 174
}
]
},
{
"externalId": "5622",
"name": "吕晓锋L",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-25",
"clinics": [
{
"clinicId": "dad2f04a120947e2b82b41cbd108f3f4",
"lastActiveAt": "2026-05-25",
"contactCount": 1
}
]
},
{
"externalId": "678",
"name": "王锐",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-21",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-05-21",
"contactCount": 10
}
]
},
{
"externalId": "6513",
"name": "冯梦佳",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-20",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-05-20",
"contactCount": 172
}
]
},
{
"externalId": "7093",
"name": "陆梓慧",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-19",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-05-19",
"contactCount": 121
}
]
},
{
"externalId": "3608",
"name": "刘柳",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-15",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-05-15",
"contactCount": 20
}
]
},
{
"externalId": "4148",
"name": "姜茜",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-10",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-05-10",
"contactCount": 1813
}
]
},
{
"externalId": "317",
"name": "李银兰",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-08",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-05-08",
"contactCount": 2306
}
]
},
{
"externalId": "5704",
"name": "刘丹",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-04",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-05-04",
"contactCount": 11
}
]
},
{
"externalId": "6537",
"name": "刘宁",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-05-01",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-05-01",
"contactCount": 1321
}
]
},
{
"externalId": "8073",
"name": "李谷玲",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-04-30",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-04-30",
"contactCount": 8
}
]
},
{
"externalId": "5675",
"name": "薛冉",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-04-30",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-04-30",
"contactCount": 363
}
]
},
{
"externalId": "7430",
"name": "张睿",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-04-30",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-04-30",
"contactCount": 12
}
]
},
{
"externalId": "239",
"name": "李闻",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-04-22",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-04-22",
"contactCount": 96
}
]
},
{
"externalId": "5499",
"name": "江涛",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-04-21",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-04-21",
"contactCount": 1064
}
]
},
{
"externalId": "7710",
"name": "胡航",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-04-16",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-04-16",
"contactCount": 1
}
]
},
{
"externalId": "7709",
"name": "王程文",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-04-16",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-04-16",
"contactCount": 985
}
]
},
{
"externalId": "7912",
"name": "张怡梦",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-04-08",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-04-08",
"contactCount": 189
}
]
},
{
"externalId": "5637",
"name": "林桑竹",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-04-05",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-04-05",
"contactCount": 852
}
]
},
{
"externalId": "7800",
"name": "张萌",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-03-15",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-03-15",
"contactCount": 382
}
]
},
{
"externalId": "3008",
"name": "吕晓锋",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-03-14",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-03-14",
"contactCount": 2
}
]
},
{
"externalId": "5618",
"name": "张一",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-03-07",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-03-07",
"contactCount": 334
}
]
},
{
"externalId": "385",
"name": "章峥",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-03-07",
"clinics": [
{
"clinicId": "dad2f04a120947e2b82b41cbd108f3f4",
"lastActiveAt": "2026-03-07",
"contactCount": 3
}
]
},
{
"externalId": "3526",
"name": "陆志英",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-03-02",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-03-02",
"contactCount": 11
}
]
},
{
"externalId": "7743",
"name": "彭碧琦",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-03-01",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-03-01",
"contactCount": 863
}
]
},
{
"externalId": "6388",
"name": "贺尚梅",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-01-25",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-01-25",
"contactCount": 2
}
]
},
{
"externalId": "5624",
"name": "赵茜",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-01-22",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2026-01-22",
"contactCount": 6
}
]
},
{
"externalId": "3529",
"name": "吴仲恺",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-01-17",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2026-01-17",
"contactCount": 2
}
]
},
{
"externalId": "7339",
"name": "张思怡",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2026-01-17",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2026-01-17",
"contactCount": 1
}
]
},
{
"externalId": "2949",
"name": "韩维",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-01-13",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2026-01-13",
"contactCount": 4
}
]
},
{
"externalId": "4579",
"name": "陈姿彤",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2026-01-04",
"clinics": [
{
"clinicId": "dad2f04a120947e2b82b41cbd108f3f4",
"lastActiveAt": "2026-01-04",
"contactCount": 9
}
]
},
{
"externalId": "7719",
"name": "郑晓雪",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2025-12-14",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2025-12-14",
"contactCount": 31
}
]
},
{
"externalId": "7799",
"name": "柳宏昊",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2025-12-03",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2025-12-03",
"contactCount": 5
}
]
},
{
"externalId": "783",
"name": "郭明",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2025-11-25",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2025-11-25",
"contactCount": 124
}
]
},
{
"externalId": "2997",
"name": "郭婷婷",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2025-11-11",
"clinics": [
{
"clinicId": "dad2f04a120947e2b82b41cbd108f3f4",
"lastActiveAt": "2025-11-11",
"contactCount": 7
}
]
},
{
"externalId": "5686",
"name": "刘帅",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2025-11-06",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2025-11-06",
"contactCount": 1
}
]
},
{
"externalId": "6425",
"name": "胡婷",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2025-10-29",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2025-10-29",
"contactCount": 1
}
]
},
{
"externalId": "2492",
"name": "闫保玉",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2025-09-23",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2025-09-23",
"contactCount": 1
}
]
},
{
"externalId": "2955",
"name": "陈丽萍",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2025-08-21",
"clinics": [
{
"clinicId": "7d49539c7573490387c03e6496ff1a6c",
"lastActiveAt": "2025-08-21",
"contactCount": 1
}
]
},
{
"externalId": "5573",
"name": "王杏松",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2025-08-15",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2025-08-15",
"contactCount": 2
}
]
},
{
"externalId": "10",
"name": "杨枫",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2025-08-13",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2025-08-13",
"contactCount": 1
}
]
},
{
"externalId": "1453",
"name": "韩旭彤",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2025-08-11",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2025-08-11",
"contactCount": 2
}
]
},
{
"externalId": "6815",
"name": "张夏薇",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2025-08-07",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2025-08-07",
"contactCount": 93
}
]
},
{
"externalId": "4090",
"name": "李宇琦",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2025-08-01",
"clinics": [
{
"clinicId": "66701845dd2342e19f9e9f576c4ffe9c",
"lastActiveAt": "2025-08-01",
"contactCount": 2
}
]
},
{
"externalId": "5742",
"name": "刘红",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2025-07-31",
"clinics": [
{
"clinicId": "dad2f04a120947e2b82b41cbd108f3f4",
"lastActiveAt": "2025-07-31",
"contactCount": 3
}
]
},
{
"externalId": "2510",
"name": "李明喆",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2025-07-19",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2025-07-19",
"contactCount": 9
}
]
},
{
"externalId": "6389",
"name": "戴洁茹",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2025-07-12",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2025-07-12",
"contactCount": 3
}
]
},
{
"externalId": "4621",
"name": "吴梦娟",
"tenant": "ruier",
"roles": [
"cs"
],
"lastActiveAt": "2025-07-07",
"clinics": [
{
"clinicId": "dad2f04a120947e2b82b41cbd108f3f4",
"lastActiveAt": "2025-07-07",
"contactCount": 3
}
]
},
{
"externalId": "7616",
"name": "杨怡",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2025-07-01",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2025-07-01",
"contactCount": 6
}
]
},
{
"externalId": "6465",
"name": "张隽璇",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2025-06-30",
"clinics": [
{
"clinicId": "c18cadf2d3cd4adda5527debd41356eb",
"lastActiveAt": "2025-06-30",
"contactCount": 2
}
]
},
{
"externalId": "2512",
"name": "闫培芳",
"tenant": "ruitai",
"roles": [
"cs"
],
"lastActiveAt": "2025-06-11",
"clinics": [
{
"clinicId": "e83d432a38bb4f6284713b36db4e7497",
"lastActiveAt": "2025-06-11",
"contactCount": 1
}
]
}
]
import { Body, Controller, Post } from '@nestjs/common'; import { Body, Controller, Get, Post } from '@nestjs/common';
import { ApiOperation, ApiTags } from '@nestjs/swagger'; import { ApiOperation, ApiTags } from '@nestjs/swagger';
import { ZodResponse } from 'nestjs-zod'; import { ZodResponse } from 'nestjs-zod';
import { Public } from '../../common/decorators/public.decorator'; import { Public } from '../../common/decorators/public.decorator';
...@@ -8,6 +8,7 @@ import { ...@@ -8,6 +8,7 @@ import {
ExchangeCodeResponseDto, ExchangeCodeResponseDto,
MockLoginRequestDto, MockLoginRequestDto,
MockLoginResponseDto, MockLoginResponseDto,
MockUsersResponseDto,
RefreshTokenRequestDto, RefreshTokenRequestDto,
RefreshTokenResponseDto, RefreshTokenResponseDto,
TokenExchangeRequestDto, TokenExchangeRequestDto,
...@@ -59,4 +60,16 @@ export class AuthController { ...@@ -59,4 +60,16 @@ export class AuthController {
mockLogin(@Body() dto: MockLoginRequestDto) { mockLogin(@Body() dto: MockLoginRequestDto) {
return this.auth.mockLogin(dto); return this.auth.mockLogin(dto);
} }
@Public()
@Get('mock-users')
@ZodResponse({ status: 200, type: MockUsersResponseDto })
@ApiOperation({
summary: '客服花名册(开发 / 试部署用)— 给"快速登录"对话框选人',
description:
'派生自 data/jvs-dw/users.json(回访表 · 近 12 月在岗)。mock 登录禁用时返空数组。',
})
mockUsers() {
return { users: this.auth.listMockUsers() };
}
} }
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
type AccessTokenPayload, type AccessTokenPayload,
type ExchangeCodeResponse, type ExchangeCodeResponse,
type MockLoginRequest, type MockLoginRequest,
type MockUser,
type Permission, type Permission,
type RefreshTokenResponse, type RefreshTokenResponse,
type TokenDictionary, type TokenDictionary,
...@@ -19,6 +20,7 @@ import { randomCode, verifySecret } from '@pac/utils'; ...@@ -19,6 +20,7 @@ import { randomCode, verifySecret } from '@pac/utils';
import { PrismaService } from '../../prisma/prisma.service'; import { PrismaService } from '../../prisma/prisma.service';
import { RedisService } from '../../redis/redis.service'; import { RedisService } from '../../redis/redis.service';
import { parseDurationToSeconds } from './duration'; import { parseDurationToSeconds } from './duration';
import { loadMockUsers } from './mock-users';
const CODE_PREFIX = 'pac:exchange-code:'; const CODE_PREFIX = 'pac:exchange-code:';
const REFRESH_PREFIX = 'pac:refresh:'; const REFRESH_PREFIX = 'pac:refresh:';
...@@ -264,19 +266,38 @@ export class AuthService { ...@@ -264,19 +266,38 @@ export class AuthService {
); );
} }
const clinicIds = Object.keys(preset.clinics); // 默认:通用预制身份(sub=mock-<tenant>-<role>,看该 tenant 全部诊所)
let sub = `mock-${req.tenant}-${req.role}`;
let subName = MOCK_NAMES[req.tenant]?.[req.role] ?? roleNameZh(req.role);
let clinicIds = Object.keys(preset.clinics);
// 选了具体客服 → 用真实客服身份(sub=externalId / 真实姓名 / 该客服所属诊所)
if (req.userExternalId) {
const cs = loadMockUsers().find(
(u) => u.tenant === req.tenant && u.externalId === req.userExternalId,
);
if (!cs) {
throw new BizError(
ApiCode.CLIENT_VALIDATION_FAILED,
`unknown mock user: ${req.tenant}/${req.userExternalId}`,
);
}
sub = cs.externalId;
subName = cs.name;
// 只保留落在该 tenant 预制诊所内的(防脏数据);为空兜底回全 tenant 诊所
const own = cs.clinics.map((c) => c.clinicId).filter((id) => id in preset.clinics);
clinicIds = own.length > 0 ? own : Object.keys(preset.clinics);
}
const dictionary: TokenDictionary = { const dictionary: TokenDictionary = {
clinics: preset.clinics as Record<string, string>, clinics: preset.clinics as Record<string, string>,
users: { // 给 sub 一个可读**人名**(UI 头像显示 + 话术自报家门"我是X诊所的客服{姓名}"用)
// 给 sub 一个可读**人名**(UI 头像显示 + 话术自报家门"我是X诊所的客服{姓名}"用) users: { [sub]: subName },
[`mock-${req.tenant}-${req.role}`]:
MOCK_NAMES[req.tenant]?.[req.role] ?? roleNameZh(req.role),
},
}; };
const permissions = this.resolvePermissions(req.role); const permissions = this.resolvePermissions(req.role);
const accessToken = await this.signAccessToken({ const accessToken = await this.signAccessToken({
sub: `mock-${req.tenant}-${req.role}`, sub,
hostId: host.id, hostId: host.id,
tenantId: preset.tenantId, tenantId: preset.tenantId,
clinicIds, clinicIds,
...@@ -285,7 +306,7 @@ export class AuthService { ...@@ -285,7 +306,7 @@ export class AuthService {
dictionary, dictionary,
}); });
const refreshToken = await this.signRefreshToken({ const refreshToken = await this.signRefreshToken({
sub: `mock-${req.tenant}-${req.role}`, sub,
hostId: host.id, hostId: host.id,
tenantId: preset.tenantId, tenantId: preset.tenantId,
clinicIds, clinicIds,
...@@ -296,10 +317,38 @@ export class AuthService { ...@@ -296,10 +317,38 @@ export class AuthService {
this.logger.log( this.logger.log(
`mock-login: tenant=${req.tenant}(${preset.tenantNameZh}) role=${req.role} ` + `mock-login: tenant=${req.tenant}(${preset.tenantNameZh}) role=${req.role} ` +
`clinics=${clinicIds.length} hostId=${host.id}`, `sub=${sub}(${subName}) clinics=${clinicIds.length} hostId=${host.id}`,
); );
return { accessToken, refreshToken, expiresIn }; return { accessToken, refreshToken, expiresIn };
} }
/**
* 客服花名册(给"快速登录"对话框选人用)。
* 派生自 data/jvs-dw/users.json,补诊所中文名(从 MOCK_PRESETS)。mock 关闭时返空。
*/
listMockUsers(): MockUser[] {
if (!this.isMockLoginEnabled()) return [];
return loadMockUsers()
.map((u) => {
const preset = this.MOCK_PRESETS[u.tenant];
const clinicDict = (preset?.clinics ?? {}) as Record<string, string>;
return {
externalId: u.externalId,
name: u.name,
tenant: u.tenant,
lastActiveAt: u.lastActiveAt,
contactCount: u.clinics.reduce((s, c) => s + (c.contactCount ?? 0), 0),
clinics: u.clinics.map((c) => ({
clinicId: c.clinicId,
name: clinicDict[c.clinicId] ?? c.clinicId,
})),
};
})
.sort(
(a, b) =>
a.tenant.localeCompare(b.tenant) || b.contactCount - a.contactCount,
);
}
} }
function roleNameZh(role: UserRole): string { function roleNameZh(role: UserRole): string {
......
...@@ -4,6 +4,7 @@ import { ...@@ -4,6 +4,7 @@ import {
ExchangeCodeResponseSchema, ExchangeCodeResponseSchema,
MockLoginRequestSchema, MockLoginRequestSchema,
MockLoginResponseSchema, MockLoginResponseSchema,
MockUsersResponseSchema,
RefreshTokenRequestSchema, RefreshTokenRequestSchema,
RefreshTokenResponseSchema, RefreshTokenResponseSchema,
TokenExchangeRequestSchema, TokenExchangeRequestSchema,
...@@ -18,3 +19,4 @@ export class RefreshTokenRequestDto extends createZodDto(RefreshTokenRequestSche ...@@ -18,3 +19,4 @@ export class RefreshTokenRequestDto extends createZodDto(RefreshTokenRequestSche
export class RefreshTokenResponseDto extends createZodDto(RefreshTokenResponseSchema) {} export class RefreshTokenResponseDto extends createZodDto(RefreshTokenResponseSchema) {}
export class MockLoginRequestDto extends createZodDto(MockLoginRequestSchema) {} export class MockLoginRequestDto extends createZodDto(MockLoginRequestSchema) {}
export class MockLoginResponseDto extends createZodDto(MockLoginResponseSchema) {} export class MockLoginResponseDto extends createZodDto(MockLoginResponseSchema) {}
export class MockUsersResponseDto extends createZodDto(MockUsersResponseSchema) {}
import { existsSync, readFileSync } from 'node:fs';
import { join } from 'node:path';
/**
* Mock 客服花名册 loader —— 读 `data/<host>/users.json`(派生自回访表,近 12 月在岗)。
*
* 这是"文件先行"的临时方案(见 docs 讨论):mock-login 选人用,**非 PAC 主数据**。
* 文件结构 = 未来 `users` + `user_clinics` 两表的形状,以后升表直接灌、不返工。
*
* 路径策略(跟 skill-registry 同理由 — 用 cwd 不用 __dirname,dev/prod 都稳):
* - env PAC_MOCK_USERS_FILE 覆盖(测试/换 host)
* - 默认 `<cwd>/data/jvs-dw/users.json`(data/ 不编译,dev/prod 同路径)
* - 文件不存在 → 返回 [](picker 不显示,不报错)
*
* 内存缓存:启动后读一次;文件由离线 CLI 刷新,运行时不变(改了重启 pac-service)。
*/
export interface RawMockUser {
externalId: string;
name: string;
tenant: 'ruier' | 'ruitai';
roles: string[];
lastActiveAt: string;
clinics: Array<{ clinicId: string; lastActiveAt: string; contactCount: number }>;
}
export function resolveMockUsersFile(): string {
return process.env.PAC_MOCK_USERS_FILE || join(process.cwd(), 'data/jvs-dw/users.json');
}
let cache: RawMockUser[] | null = null;
export function loadMockUsers(): RawMockUser[] {
if (cache) return cache;
const path = resolveMockUsersFile();
if (!existsSync(path)) {
cache = [];
return cache;
}
try {
const parsed = JSON.parse(readFileSync(path, 'utf-8')) as RawMockUser[];
cache = Array.isArray(parsed) ? parsed : [];
} catch {
cache = [];
}
return cache;
}
'use client'; 'use client';
import { useState } from 'react'; import { useEffect, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import type { UserRole } from '@pac/types'; import type { MockUser, UserRole } from '@pac/types';
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
...@@ -11,19 +11,19 @@ import { ...@@ -11,19 +11,19 @@ import {
DialogTitle, DialogTitle,
} from '@/components/ui/dialog'; } from '@/components/ui/dialog';
import { useAuthStore } from '@/stores/auth-store'; import { useAuthStore } from '@/stores/auth-store';
import { mockLogin } from '@/lib/auth-api'; import { getMockUsers, mockLogin } from '@/lib/auth-api';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
/** /**
* MockLoginDialog — 试部署 / 演示用快速登录(只在 dev 启用) * MockLoginDialog — 试部署 / 演示用快速登录(只在 dev 启用)
* *
* 用途:还没接 host SSO 时,客服 / 演示者可一键以 瑞尔/瑞泰 × staff/leader/admin * 主路径(有客服名册时):选「角色(权限)」+「具体客服」→ 以该客服真实身份 + 选定 RBAC 权限登录。
* 身份登录工作台。后端 env `PAC_ENABLE_MOCK_LOGIN=false` 一关即生产模式。 * - 客服名册来自 GET /auth/mock-users(派生自回访表 · 近 12 月在岗)。
* - 两种 role 解耦:客服是"人 + 职能"(都是一线 staff);这里的角色选择是 PAC **权限视角**(staff/leader/admin)。
* *
* 不可关闭(open=true 强制保持):未鉴权用户**必须选一个身份**才能进列表页。 * 降级路径(名册拉不到 / mock 关闭):回退到通用网格(2 brand × 3 role 预制身份),保证永远能登录。
* 已鉴权后由 AuthGate 接管,放行 children。
* *
* 设计:6 个按钮,2 brand × 3 role 网格;点击 → mockLogin API → setTokens → reload * 不可关闭(open=true 强制保持):未鉴权用户**必须选一个身份**才能进列表页
*/ */
type TenantSlug = 'ruier' | 'ruitai'; type TenantSlug = 'ruier' | 'ruitai';
...@@ -39,33 +39,64 @@ const ROLES: { key: UserRole; nameZh: string; desc: string }[] = [ ...@@ -39,33 +39,64 @@ const ROLES: { key: UserRole; nameZh: string; desc: string }[] = [
{ key: 'admin', nameZh: '管理员', desc: '全部权限 · 含后台管理' }, { key: 'admin', nameZh: '管理员', desc: '全部权限 · 含后台管理' },
]; ];
// 模拟身份的演示人名(需与后端 auth.service.MOCK_NAMES 保持一致)— 话术自报家门会用到 // 降级网格的演示人名(需与后端 auth.service.MOCK_NAMES 一致)— 话术自报家门会用到
const MOCK_NAMES: Record<TenantSlug, Record<UserRole, string>> = { const MOCK_NAMES: Record<TenantSlug, Record<UserRole, string>> = {
ruier: { staff: '小王', leader: '李莉', admin: '张敏' }, ruier: { staff: '小王', leader: '李莉', admin: '张敏' },
ruitai: { staff: '小陈', leader: '周琳', admin: '刘洋' }, ruitai: { staff: '小陈', leader: '周琳', admin: '刘洋' },
}; };
function roleNameZh(role: UserRole): string {
return ROLES.find((r) => r.key === role)?.nameZh ?? role;
}
export function MockLoginDialog({ open }: { open: boolean }) { export function MockLoginDialog({ open }: { open: boolean }) {
const setTokens = useAuthStore((s) => s.setTokens); const setTokens = useAuthStore((s) => s.setTokens);
const [busy, setBusy] = useState<string | null>(null); // 'ruier:staff' 等 const [busy, setBusy] = useState<string | null>(null);
const [csUsers, setCsUsers] = useState<MockUser[]>([]);
const [role, setRole] = useState<UserRole>('staff'); // 登录用的 RBAC 权限(默认员工)
const [tenantFilter, setTenantFilter] = useState<'all' | TenantSlug>('all');
const [query, setQuery] = useState('');
useEffect(() => {
getMockUsers()
.then((r) => setCsUsers(r.users))
.catch(() => {}); // mock 关闭 / 无文件 → 空 → 走降级网格
}, []);
const applyTokens = (r: { accessToken: string; refreshToken: string; expiresIn: number }) =>
setTokens({ accessToken: r.accessToken, refreshToken: r.refreshToken, expiresIn: r.expiresIn });
const pick = async (tenant: TenantSlug, role: UserRole) => { // 主路径:以具体客服真实身份 + 选定角色登录
const key = `${tenant}:${role}`; const pickCs = async (cs: MockUser) => {
const key = `cs:${cs.tenant}:${cs.externalId}`;
if (busy) return; if (busy) return;
setBusy(key); setBusy(key);
try { try {
const r = await mockLogin({ tenant, role }); const r = await mockLogin({ tenant: cs.tenant, role, userExternalId: cs.externalId });
setTokens({ applyTokens(r);
accessToken: r.accessToken, const tName = cs.tenant === 'ruier' ? '瑞尔' : '瑞泰';
refreshToken: r.refreshToken, toast.success(`已登录:${cs.name} · ${tName} · ${roleNameZh(role)}`, {
expiresIn: r.expiresIn, description: cs.clinics.map((c) => c.name).join(' / ') || '(模拟身份)',
}); });
const tNameZh = TENANTS.find((t) => t.slug === tenant)?.nameZh ?? tenant; } catch (err) {
const rNameZh = ROLES.find((r) => r.key === role)?.nameZh ?? role; const msg = err instanceof Error ? err.message : String(err);
toast.success(`已登录:${tNameZh} · ${rNameZh}`, { toast.error('登录失败', { description: msg.slice(0, 120) });
setBusy(null);
}
};
// 降级路径:通用预制身份(无名册时)
const pickGeneric = async (tenant: TenantSlug, roleKey: UserRole) => {
const key = `${tenant}:${roleKey}`;
if (busy) return;
setBusy(key);
try {
const r = await mockLogin({ tenant, role: roleKey });
applyTokens(r);
const tName = TENANTS.find((t) => t.slug === tenant)?.nameZh ?? tenant;
toast.success(`已登录:${tName} · ${roleNameZh(roleKey)}`, {
description: '(模拟身份,真生产环境会接宿主 SSO)', description: '(模拟身份,真生产环境会接宿主 SSO)',
}); });
// AuthGate 监听 isAuthenticated,setTokens 后自动放行
} catch (err) { } catch (err) {
const msg = err instanceof Error ? err.message : String(err); const msg = err instanceof Error ? err.message : String(err);
toast.error('登录失败', { description: msg.slice(0, 120) }); toast.error('登录失败', { description: msg.slice(0, 120) });
...@@ -73,13 +104,17 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -73,13 +104,17 @@ export function MockLoginDialog({ open }: { open: boolean }) {
} }
}; };
const q = query.trim();
const filteredCs = csUsers
.filter((u) => (tenantFilter === 'all' ? true : u.tenant === tenantFilter))
.filter((u) => (q ? u.name.includes(q) : true))
.slice(0, 80);
return ( return (
<Dialog open={open}> <Dialog open={open}>
<DialogContent <DialogContent
// 禁掉 ESC / 点遮罩关闭 — 未鉴权必须选身份才能进
onEscapeKeyDown={(e) => e.preventDefault()} onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()} onPointerDownOutside={(e) => e.preventDefault()}
// 隐藏右上 X 关闭按钮(子树里的 DialogPrimitive.Close)
className="max-w-2xl [&>button[aria-label='Close']]:hidden" className="max-w-2xl [&>button[aria-label='Close']]:hidden"
> >
<DialogHeader> <DialogHeader>
...@@ -97,47 +132,147 @@ export function MockLoginDialog({ open }: { open: boolean }) { ...@@ -97,47 +132,147 @@ export function MockLoginDialog({ open }: { open: boolean }) {
</DialogDescription> </DialogDescription>
</DialogHeader> </DialogHeader>
<div className="grid gap-3 pt-2"> {csUsers.length > 0 ? (
{TENANTS.map((t) => ( <div className="grid gap-3 pt-1">
<div {/* ① 登录权限(角色)*/}
key={t.slug} <div>
className={cn('rounded-lg border p-3', t.tone)} <div className="mb-1.5 text-[12px] font-medium text-slate-600">① 登录权限(角色)</div>
> <div className="grid grid-cols-3 gap-2">
<div className="mb-2 flex items-center gap-2 text-[13px] font-semibold text-slate-800"> {ROLES.map((r) => (
<span>{t.nameZh}</span> <button
<span className="text-[10.5px] font-normal text-slate-500"> key={r.key}
{t.slug === 'ruier' ? '3 家诊所(杭州大厦/高德 · 北京朝阳)' : '2 家诊所(通善学前街 · 上海世纪公园)'} type="button"
</span> onClick={() => setRole(r.key)}
className={cn(
'flex flex-col items-start gap-0.5 rounded-md border px-2.5 py-1.5 text-left transition-all',
role === r.key
? 'border-teal-400 bg-teal-50/60 ring-1 ring-teal-200'
: 'border-slate-200 hover:border-teal-300',
)}
>
<span className="text-[12.5px] font-semibold text-slate-800">{r.nameZh}</span>
<span className="text-[10px] leading-tight text-slate-500">{r.desc}</span>
</button>
))}
</div>
</div>
{/* ② 选客服 */}
<div className="rounded-lg border border-slate-200 p-3">
<div className="mb-2 flex items-center justify-between">
<div className="text-[12px] font-medium text-slate-600">
② 选客服 — 以其真实身份 + 上面的「{roleNameZh(role)}」权限登录
</div>
<span className="text-[10.5px] text-slate-500">{csUsers.length} 位在岗 · 近 12 月</span>
</div>
<div className="mb-2 flex items-center gap-1.5">
{([
{ k: 'all', n: '全部' },
{ k: 'ruier', n: '瑞尔' },
{ k: 'ruitai', n: '瑞泰' },
] as const).map((t) => (
<button
key={t.k}
type="button"
onClick={() => setTenantFilter(t.k)}
className={cn(
'rounded-full border px-2.5 py-0.5 text-[11px] transition-colors',
tenantFilter === t.k
? 'border-teal-400 bg-teal-50 text-teal-700'
: 'border-slate-200 text-slate-500 hover:border-teal-300',
)}
>
{t.n}
</button>
))}
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="搜客服姓名…"
className="ml-auto w-40 rounded-md border border-slate-200 px-2.5 py-1 text-[12px] outline-none focus:border-teal-400"
/>
</div> </div>
<div className="grid gap-2 sm:grid-cols-3">
{ROLES.map((r) => { <div className="max-h-56 space-y-1 overflow-y-auto pr-0.5">
const key = `${t.slug}:${r.key}`; {filteredCs.map((cs) => {
const key = `cs:${cs.tenant}:${cs.externalId}`;
const loading = busy === key; const loading = busy === key;
const tName = cs.tenant === 'ruier' ? '瑞尔' : '瑞泰';
const clinics = cs.clinics.map((c) => c.name).join(' / ');
return ( return (
<button <button
key={key} key={key}
type="button" type="button"
onClick={() => pick(t.slug, r.key)}
disabled={!!busy} disabled={!!busy}
onClick={() => pickCs(cs)}
className={cn( className={cn(
'group flex flex-col items-start gap-1 rounded-md border bg-white px-3 py-2 text-left transition-all', 'flex w-full items-center justify-between gap-2 rounded-md border px-2.5 py-1.5 text-left transition-all',
loading loading
? 'border-teal-400 ring-2 ring-teal-200' ? 'border-teal-400 ring-2 ring-teal-200'
: 'border-slate-200 hover:border-teal-400 hover:bg-teal-50/40', : 'border-slate-200 hover:border-teal-400 hover:bg-teal-50/40',
busy && !loading && 'opacity-50', busy && !loading && 'opacity-50',
)} )}
> >
<div className="text-[12.5px] font-semibold text-slate-800"> <span className="flex shrink-0 items-center gap-1.5">
{loading ? '登录中…' : `${r.nameZh} · ${MOCK_NAMES[t.slug][r.key]}`} <span className="text-[12.5px] font-semibold text-slate-800">
</div> {loading ? '登录中…' : cs.name}
<div className="text-[10.5px] leading-tight text-slate-500">{r.desc}</div> </span>
<span className="text-[10.5px] text-slate-500">{tName}</span>
</span>
<span className="truncate text-[10.5px] text-slate-400">{clinics}</span>
</button> </button>
); );
})} })}
{filteredCs.length === 0 && (
<div className="px-1 py-2 text-[11px] text-slate-400">无匹配客服</div>
)}
</div> </div>
</div> </div>
))} </div>
</div> ) : (
// 降级:无客服名册 → 通用网格(保证可登录)
<div className="grid gap-3 pt-2">
{TENANTS.map((t) => (
<div key={t.slug} className={cn('rounded-lg border p-3', t.tone)}>
<div className="mb-2 flex items-center gap-2 text-[13px] font-semibold text-slate-800">
<span>{t.nameZh}</span>
<span className="text-[10.5px] font-normal text-slate-500">
{t.slug === 'ruier'
? '3 家诊所(杭州大厦/高德 · 北京朝阳)'
: '2 家诊所(通善学前街 · 上海世纪公园)'}
</span>
</div>
<div className="grid gap-2 sm:grid-cols-3">
{ROLES.map((r) => {
const key = `${t.slug}:${r.key}`;
const loading = busy === key;
return (
<button
key={key}
type="button"
onClick={() => pickGeneric(t.slug, r.key)}
disabled={!!busy}
className={cn(
'group flex flex-col items-start gap-1 rounded-md border bg-white px-3 py-2 text-left transition-all',
loading
? 'border-teal-400 ring-2 ring-teal-200'
: 'border-slate-200 hover:border-teal-400 hover:bg-teal-50/40',
busy && !loading && 'opacity-50',
)}
>
<div className="text-[12.5px] font-semibold text-slate-800">
{loading ? '登录中…' : `${r.nameZh} · ${MOCK_NAMES[t.slug][r.key]}`}
</div>
<div className="text-[10.5px] leading-tight text-slate-500">{r.desc}</div>
</button>
);
})}
</div>
</div>
))}
</div>
)}
<p className="pt-1 text-[10.5px] leading-relaxed text-slate-400"> <p className="pt-1 text-[10.5px] leading-relaxed text-slate-400">
提示:登录后 token 写入浏览器 localStorage(2h 有效 · 自动 refresh), 提示:登录后 token 写入浏览器 localStorage(2h 有效 · 自动 refresh),
......
...@@ -216,12 +216,12 @@ export function PlansListApp() { ...@@ -216,12 +216,12 @@ export function PlansListApp() {
setQuery({ status: s, page: 1 }); setQuery({ status: s, page: 1 });
clearSelected(); clearSelected();
}; };
// 诊所多选筛选 — 选项来自 token dictionary.clinics(用户可见诊所);状态存进 query.targetClinicIds // 诊所多选筛选 — 选项 = 当前用户**可见诊所**(token.clinicIds = RBAC 范围),名取自 dictionary.clinics
const clinicOptions = useMemo( // (dictionary 只是 id→名 查表,可能含范围外诊所;真正的可见范围以 clinicIds 为准)。状态存 query.targetClinicIds
() => const clinicOptions = useMemo(() => {
Object.entries(user?.dictionary?.clinics ?? {}).map(([id, name]) => ({ id, name: String(name) })), const dict = user?.dictionary?.clinics ?? {};
[user?.dictionary?.clinics], return (user?.clinicIds ?? []).map((id) => ({ id, name: String(dict[id] ?? id) }));
); }, [user?.clinicIds, user?.dictionary?.clinics]);
const selectedClinics = query.targetClinicIds ?? []; const selectedClinics = query.targetClinicIds ?? [];
const setClinics = (ids: string[]) => { const setClinics = (ids: string[]) => {
setQuery({ targetClinicIds: ids.length ? ids : undefined, page: 1 }); setQuery({ targetClinicIds: ids.length ? ids : undefined, page: 1 });
......
...@@ -4,6 +4,7 @@ import type { ...@@ -4,6 +4,7 @@ import type {
ExchangeCodeResponse, ExchangeCodeResponse,
MockLoginRequest, MockLoginRequest,
MockLoginResponse, MockLoginResponse,
MockUsersResponse,
RefreshTokenResponse, RefreshTokenResponse,
} from '@pac/types'; } from '@pac/types';
import { api } from './api-client'; import { api } from './api-client';
...@@ -29,3 +30,8 @@ export async function refreshToken(token: string): Promise<RefreshTokenResponse> ...@@ -29,3 +30,8 @@ export async function refreshToken(token: string): Promise<RefreshTokenResponse>
export async function mockLogin(req: MockLoginRequest): Promise<MockLoginResponse> { export async function mockLogin(req: MockLoginRequest): Promise<MockLoginResponse> {
return api.post<MockLoginResponse>('/pac/v1/auth/mock-login', req, { auth: false }); return api.post<MockLoginResponse>('/pac/v1/auth/mock-login', req, { auth: false });
} }
/// 客服花名册(开发 / 试部署用)— "快速登录"对话框选人;mock 关闭时 users 为空
export async function getMockUsers(): Promise<MockUsersResponse> {
return api.get<MockUsersResponse>('/pac/v1/auth/mock-users', { auth: false });
}
...@@ -114,6 +114,9 @@ export const MockLoginRequestSchema = z.strictObject({ ...@@ -114,6 +114,9 @@ export const MockLoginRequestSchema = z.strictObject({
tenant: z.enum(['ruier', 'ruitai']), tenant: z.enum(['ruier', 'ruitai']),
/// PAC 角色 — staff / leader / admin /// PAC 角色 — staff / leader / admin
role: UserRoleSchema, role: UserRoleSchema,
/// 可选:登录为具体客服(externalId,来自 GET /auth/mock-users)。
/// 给了则用真实客服身份(sub=externalId / 真实姓名 / 该客服所属诊所);不给走通用预制身份。
userExternalId: z.string().optional(),
}); });
export type MockLoginRequest = z.infer<typeof MockLoginRequestSchema>; export type MockLoginRequest = z.infer<typeof MockLoginRequestSchema>;
...@@ -122,6 +125,27 @@ export const MockLoginResponseSchema = ExchangeCodeResponseSchema; ...@@ -122,6 +125,27 @@ export const MockLoginResponseSchema = ExchangeCodeResponseSchema;
export type MockLoginResponse = z.infer<typeof MockLoginResponseSchema>; export type MockLoginResponse = z.infer<typeof MockLoginResponseSchema>;
// ============================================================= // =============================================================
// Mock Users (GET /pac/v1/auth/mock-users) ⭐ 开发 / 试部署用
// =============================================================
//
// 给"快速登录"对话框的客服花名册(派生自 data/<host>/users.json,回访表口径 · 近 12 月在岗)。
// 选一个客服 → POST /auth/mock-login { tenant, role, userExternalId } 以其真实身份登录。
export const MockUserSchema = z.object({
externalId: z.string(),
name: z.string(),
tenant: z.enum(['ruier', 'ruitai']),
lastActiveAt: z.string(),
/// 近 12 月回访量(各诊所合计,排序/展示用)
contactCount: z.number().int(),
/// 所属诊所(带中文名,展示用)
clinics: z.array(z.object({ clinicId: z.string(), name: z.string() })),
});
export type MockUser = z.infer<typeof MockUserSchema>;
export const MockUsersResponseSchema = z.object({ users: z.array(MockUserSchema) });
export type MockUsersResponse = z.infer<typeof MockUsersResponseSchema>;
// =============================================================
// JWT payload — server-only; never sent to host // JWT payload — server-only; never sent to host
// ============================================================= // =============================================================
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment