From 7fc91b75f73c8a057de21ae6d21a3720638ecde7 Mon Sep 17 00:00:00 2001 From: lik Date: Tue, 6 Oct 2026 21:44:42 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E4=BA=86employee=E7=9B=B8?= =?UTF-8?q?=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- handler/employee.js | 102 ++++++++++++++++++- models/index.js | 4 +- models/schema/employee.js | 38 ++++++- models/schema/employee_review.js | 166 +++++++++++++++++++++++++++++++ resource/agreement.js | 4 +- resource/services.js | 2 +- routes/index.js | 2 + 7 files changed, 309 insertions(+), 9 deletions(-) create mode 100644 models/schema/employee_review.js diff --git a/handler/employee.js b/handler/employee.js index de31ba4..ed40f1d 100644 --- a/handler/employee.js +++ b/handler/employee.js @@ -1,3 +1,4 @@ +import mongoose from "mongoose"; import { DBModel } from "../models/index.js"; import ResponseUtil from "../utils/responseUtil.js"; @@ -5,15 +6,25 @@ import ResponseUtil from "../utils/responseUtil.js"; * 陪诊师公示:面向 C 端患者的公开信息展示 * * 依据 GB/T 47801—2026 4.2.1.5「陪同人员信息公示机制」组织字段: - * 基本信息 —— 培训记录、服务年限、累计服务次数 + * 基本信息 —— 健康证明有效期、培训记录、服务年限、累计服务次数 * 专业技能 —— 擅长服务场景、特殊技能(语言) * * 两条红线: * 1. 白名单式输出,绝不返回手机号 / 身份证号 / 证书编号原文; - * 「资质已核验」只对外给布尔值,不给编号 - * 2. 不输出评分。attendantInfo.rating 是默认 5.0 的裸值, - * 后端尚无评价数据来源,公示它等于无依据背书 + * 「资质已核验」与健康证明都只对外给布尔值 / 有效期,不给编号 + * 2. 评分只来自 employee_review 的真实评价聚合,且条数达 + * MIN_PUBLIC_REVIEW_COUNT 才输出。attendantInfo.rating 恒为默认 5.0, + * 任何分支都不得读它——那是无依据背书 */ + +/** + * 评价对外公示的最低条数 + * + * 少于这个条数时平均分不具备参考性:一条 5 分就等于满分,展示反而误导用户。 + * 故未达阈值时只回条数、分数给 null,由前端显示"暂不显示评分" + */ +const MIN_PUBLIC_REVIEW_COUNT = 5; + class HandlerEmployee { // 姓名脱敏:只留姓 + 「陪诊师」,不给全名,降低私下联系(跳单)的可能 static maskName(name) { @@ -22,6 +33,40 @@ class HandlerEmployee { return trimmed.slice(0, 1) + "陪诊师"; } + // 日期 → YYYY-MM-DD;无值或非法值返回空串,前端按空串隐藏该行 + static formatDate(value) { + if (!value) return ""; + const date = new Date(value); + if (Number.isNaN(date.getTime())) return ""; + const month = String(date.getMonth() + 1).padStart(2, "0"); + const day = String(date.getDate()).padStart(2, "0"); + return `${date.getFullYear()}-${month}-${day}`; + } + + // 健康证明是否在有效期内。 + // 没登到期日一律判为"不有效"——不能默认有效,那等于无依据背书(同 rating 的处理)。 + // 到期当天仍算有效,故与当天 24:00 比较 + static isHealthCertificateValid(expireDate) { + if (!expireDate) return false; + const endOfDay = new Date(expireDate); + if (Number.isNaN(endOfDay.getTime())) return false; + endOfDay.setHours(23, 59, 59, 999); + return endOfDay.getTime() >= Date.now(); + } + + // 培训记录:只公示培训名称 / 机构 / 结业日期,内部备注一类不外泄 + static toPublicTrainings(work) { + const list = Array.isArray(work.trainings) ? work.trainings : []; + return list + .map((item) => ({ + title: item.title || "", + org: item.org || "", + date: HandlerEmployee.formatDate(item.date), + })) + // 没有名称的条目是脏数据,公示出去只是一行空白 + .filter((item) => item.title); + } + // 白名单式脱敏:逐字段挑选,避免 employee 文档整体外泄 static toPublicAttendant(employee) { const profile = employee.profile || {}; @@ -35,6 +80,12 @@ class HandlerEmployee { sex: profile.sex || "", workYears: work.workYears || 0, qualificationVerified: !!work.qualification, + // 健康证明:4.2.1.5 要求公示的是「有效期」, + // 只给是否在有效期内 + 到期日,编号与发证机构不外泄(同 qualification) + healthCertificate: { + valid: HandlerEmployee.isHealthCertificateValid(work.healthCertificate?.expireDate), + expireDate: HandlerEmployee.formatDate(work.healthCertificate?.expireDate), + }, specialties: info.specialties || [], serviceAreas: info.serviceAreas || [], languages: info.languages || [], @@ -71,6 +122,49 @@ class HandlerEmployee { return ResponseUtil.internalError(ctx, err.message); } } + + // 单个陪诊师的公开详情。 + // 与列表共用同一套白名单——详情页不因为"多进一层"就多给字段, + // 手机号 / 身份证号 / 证书编号在这个分支里同样不出现。 + // 筛选条件也照抄列表:只认在职陪诊员,否则列表里看不到的人却能按 ID 查到 + async getAttendantById(ctx) { + try { + const id = ctx.params.id; + + // 非法 ObjectId 会让 mongoose 抛 CastError,先挡掉,也避免把驱动栈信息带出去 + if (!mongoose.Types.ObjectId.isValid(id)) { + return ResponseUtil.badRequest(ctx, "陪诊师ID不合法"); + } + + const employee = await DBModel.Employee.findOne({ + _id: id, + role: "attendant", + status: "active", + }).exec(); + + if (!employee) { + return ResponseUtil.notFound(ctx, "陪诊师不存在或已下架"); + } + + const attendant = HandlerEmployee.toPublicAttendant(employee); + + // 详情比列表多两块: + // 1) 培训记录——它是数组、体积不确定,列表接口不带 + attendant.trainings = HandlerEmployee.toPublicTrainings(employee.work || {}); + + // 2) 评价聚合——多一次查询,只有详情这一个入口需要 + const review = await DBModel.EmployeeReview.statsByEmployee(id); + attendant.review = { + count: review.count, + // 未达阈值不给分数:条数太少时平均分不具备参考性 + score: review.count >= MIN_PUBLIC_REVIEW_COUNT ? review.score : null, + }; + + return ResponseUtil.success(ctx, { attendant }, "查询成功"); + } catch (err) { + return ResponseUtil.internalError(ctx, err.message); + } + } } export { HandlerEmployee }; \ No newline at end of file diff --git a/models/index.js b/models/index.js index adb3c8a..ab41e90 100644 --- a/models/index.js +++ b/models/index.js @@ -5,6 +5,7 @@ import { EscortRecordSchema } from "./schema/escort_record.js" import { HealthProfileSchema } from "./schema/health_profile.js" import { OrganizationSchema } from "./schema/organization.js" import { EmployeeSchema } from "./schema/employee.js" +import { EmployeeReviewSchema } from "./schema/employee_review.js" import config from '../config.js'; import logger from '../utils/logger.js'; @@ -16,6 +17,7 @@ class MongoDBSchema { this.Organization = null; this.Employee = null; this.HealthProfile = null; + this.EmployeeReview = null; } init() { @@ -45,9 +47,9 @@ class MongoDBSchema { this.HealthProfile = this.dbConnection.model('health_profile', HealthProfileSchema) this.Organization = this.dbConnection.model('organization', OrganizationSchema) this.Employee = this.dbConnection.model('employee', EmployeeSchema) + this.EmployeeReview = this.dbConnection.model('employee_review', EmployeeReviewSchema) } } const DBModel = new MongoDBSchema() export { DBModel }; - diff --git a/models/schema/employee.js b/models/schema/employee.js index f4fbc53..ddc3fd2 100644 --- a/models/schema/employee.js +++ b/models/schema/employee.js @@ -3,6 +3,21 @@ import mongoose from "mongoose"; import { pinyin } from "pinyin-pro"; +/** + * 培训记录子文档 + * + * 国标 GB/T 47801—2026 4.2.1.5 把「培训记录」列为陪同人员信息公示项, + * 故按一条一次培训存成数组,便于逐条核对;不用 _id,这些条目只跟着员工走 + */ +const TrainingSchema = mongoose.Schema( + { + title: { type: String, default: "", comment: "培训名称/课程" }, + org: { type: String, default: "", comment: "培训机构" }, + date: { type: Date, comment: "结业日期" }, + }, + { _id: false } +); + /** * Employee Schema * 员工/人员信息,支持陪诊员、护士、医生、管理员等角色 @@ -56,6 +71,19 @@ const EmployeeSchema = mongoose.Schema( entryDate: { type: Date, comment: "入职日期" }, workYears: { type: Number, default: 0, comment: "工作年限" }, qualification: { type: String, default: "", comment: "资质证书编号" }, + + // 健康证明:国标 4.2.1.5 公示的是「健康证明有效期」。 + // 公示接口只输出"是否在有效期内"+ 到期日;number 属资质原文, + // 与 qualification 一样只作内部留存,绝不随公示接口下发 + healthCertificate: { + number: { type: String, default: "", comment: "健康证明编号(不对外公示)" }, + org: { type: String, default: "", comment: "发证机构" }, + issueDate: { type: Date, comment: "签发日期" }, + expireDate: { type: Date, comment: "有效期至" }, + }, + + // 培训记录:同为 4.2.1.5 公示项 + trainings: { type: [TrainingSchema], default: [], comment: "培训记录列表" }, }, // 陪诊员特有信息 @@ -63,7 +91,10 @@ const EmployeeSchema = mongoose.Schema( serviceAreas: { type: [String], default: [], comment: "服务区域/城市列表" }, specialties: { type: [String], default: [], comment: "擅长科室/领域" }, languages: { type: [String], default: ["普通话"], comment: "掌握语言" }, - rating: { type: Number, default: 5.0, min: 1, max: 5, comment: "评分(1-5)" }, + // 注意:本字段全项目没有任何写入点(恒为默认 5.0),不可作为评级来源。 + // 对外评级一律由 employee_review 真实评价聚合得出(employee_review.js), + // 达阈值才展示,详见 .trae/documents/陪诊师公示与指定陪诊师.md + rating: { type: Number, default: 5.0, min: 1, max: 5, comment: "评分(无写入点,勿用于公示)" }, orderCount: { type: Number, default: 0, comment: "接单数量" }, isOnline: { type: Boolean, default: false, comment: "是否在线" }, }, @@ -197,6 +228,9 @@ EmployeeSchema.statics.updateEmployee = async function (id, update) { /** * 查询在线陪诊员列表 + * + * 排序口径与 handler/employee.js 的公示列表保持一致:服务年限优先、累计服务次数次之。 + * 曾按 attendantInfo.rating 排,但该字段没有任何写入点(恒为 5.0),等于没排序 */ EmployeeSchema.statics.findOnlineAttendants = async function (options = {}) { const { page = 1, pageSize = 20 } = options; @@ -206,7 +240,7 @@ EmployeeSchema.statics.findOnlineAttendants = async function (options = {}) { status: "active", "attendantInfo.isOnline": true, }) - .sort({ "attendantInfo.rating": -1 }) + .sort({ "work.workYears": -1, "attendantInfo.orderCount": -1 }) .skip(skip) .limit(pageSize) .exec(); diff --git a/models/schema/employee_review.js b/models/schema/employee_review.js new file mode 100644 index 0000000..86f5f31 --- /dev/null +++ b/models/schema/employee_review.js @@ -0,0 +1,166 @@ +"use strict"; + +import mongoose from "mongoose"; + +/** + * 员工评价 Schema + * + * 用途:为「员工信息公示」提供唯一可信的评级来源。当前只有陪诊师(role: attendant) + * 有对外的评价公示场景,故本表目前只被 handler/employee.js 的陪诊师详情页消费; + * 但表本身按「员工」建,不绑死陪诊师——后期护士 / 医生等角色接评价时直接复用, + * 不必再建一张平行的表(这也是它叫 employee_review 而不是 attendant_review 的原因)。 + * + * 设计前提(见 .trae/documents/陪诊师公示与指定陪诊师.md): + * - 一次服务最多产生一条评价,故 recordId 建唯一索引,从数据层断掉刷分 + * - 评级不由 employee.attendantInfo.rating 提供——那个字段没有任何写入点 + * - 只有 status 为 published 的评价计入评分,运营隐藏掉的不参与 + * - 是否对外展示分数由阈值决定(阈值在 handler/employee.js),本表只负责算平均分 + */ +const EmployeeReviewSchema = mongoose.Schema( + { + /** + * 关联的服务记录ID(一次服务一条评价) + * + * 当前评价只发生在陪诊服务上,故指向 escort_record。 + * 若未来非陪诊类评价(如问诊)接入,此处需补一个来源标识字段来区分记录所属集合—— + * ObjectId 全局唯一,唯一索引可以照旧沿用,但读取时要先知道去哪个集合取原文。 + * 该字段现在不加:不想再造一个没有写入点、等不到用处的空字段。 + * + * @type {ObjectId} + * @ref escort_record + */ + recordId: { + type: mongoose.Schema.Types.ObjectId, + ref: "escort_record", + required: true, + comment: "关联服务记录ID", + }, + + /** + * 被评价的员工ID + * + * 名字用 employeeId 而非 attendantId:面向 employee 集合,不预设角色。 + * 角色本身不冗余在这里,需要时由 employee 文档的 role 提供,避免两处口径打架。 + * + * @type {ObjectId} + * @ref employee + */ + employeeId: { + type: mongoose.Schema.Types.ObjectId, + ref: "employee", + index: true, + required: true, + comment: "被评价员工ID", + }, + + /** + * 评价人用户ID + * @type {ObjectId} + * @ref user + */ + userId: { + type: mongoose.Schema.Types.ObjectId, + ref: "user", + index: true, + comment: "评价人用户ID", + }, + + /** + * 评分(1-5 星) + */ + score: { + type: Number, + min: 1, + max: 5, + required: true, + comment: "评分(1-5)", + }, + + /** + * 评价正文 + */ + content: { type: String, default: "", comment: "评价正文" }, + + /** + * 状态 + * - published: 已发布 - 计入评分,可对外展示 + * - hidden: 已隐藏 - 运营判定为不当内容,不计入评分 + */ + status: { + type: String, + enum: ["published", "hidden"], + default: "published", + comment: "状态:已发布、已隐藏", + }, + + /** + * 元数据 + */ + meta: { + createtime: { type: Date, default: Date.now, comment: "创建时间" }, + updatetime: { type: Date, default: Date.now, comment: "更新时间" }, + }, + }, + { + minimize: false, + strict: false, + collection: "employee_review", + timestamps: false, + } +); + +/** + * 统计某位员工的公开评价数与平均分 + * + * 只聚合 published,隐藏评价不参与;平均分保留一位小数, + * 公示页只展示一位,多出来的小数位没有意义。 + * 是否对外展示由调用方按阈值决定,本方法不做门槛判断 + * + * @param {ObjectId|string} employeeId - 员工ID + * @returns {Promise<{count: number, score: number|null}>} 评价条数与平均分(无评价时 score 为 null) + */ +EmployeeReviewSchema.statics.statsByEmployee = async function (employeeId) { + // 非法 ID 直接返回空统计,不抛 CastError + if (!employeeId || !mongoose.Types.ObjectId.isValid(employeeId)) { + return { count: 0, score: null }; + } + + const rows = await this.aggregate([ + { + $match: { + employeeId: new mongoose.Types.ObjectId(employeeId), + status: "published", + }, + }, + { + $group: { + _id: null, + count: { $sum: 1 }, + score: { $avg: "$score" }, + }, + }, + ]); + + if (!rows.length) { + return { count: 0, score: null }; + } + + return { + count: rows[0].count, + score: Math.round(rows[0].score * 10) / 10, + }; +}; + +// ==================== 索引定义 ==================== + +/** + * 服务记录唯一索引 - 一次服务只允许一条评价,从数据层堵住刷分 + */ +EmployeeReviewSchema.index({ recordId: 1 }, { unique: true }); + +/** + * 员工 + 状态索引 - 用于按员工聚合公开评价 + */ +EmployeeReviewSchema.index({ employeeId: 1, status: 1 }); + +export { EmployeeReviewSchema }; diff --git a/resource/agreement.js b/resource/agreement.js index abe0d0d..e49f738 100644 --- a/resource/agreement.js +++ b/resource/agreement.js @@ -13,7 +13,9 @@ const agreement = { "全程陪诊半天不超过4小时,全天不超过8小时。", "预约成功后,平台将提前电话联系确认就诊资料及时间,并以短信或微信通知预约人。", "陪诊师只代您向医生陈述病情并转达医嘱,不负责诊断和治疗。需要时陪诊师会安排医生与您直接通话,最终以医嘱为准。", - "服务流程:预约、沟通信息、选择陪诊师、支付费用、全程陪诊、服务完成。可在「陪诊师团队」页面选择陪诊师,未指定时由平台按医院与时间匹配。", + // 流程与 services.js 各服务的 flow 一致(同 guide.js)。 + // 原列的「选择陪诊师」不在任何服务的 flow 里,且该选择功能尚未上线,故一并去掉 + "服务流程:预约、沟通信息、支付费用、准备资料、全程陪诊、服务完成。", "医生停诊等特殊情况,请及时联系客服处理。" ] }, diff --git a/resource/services.js b/resource/services.js index 23755bc..44bfa8c 100644 --- a/resource/services.js +++ b/resource/services.js @@ -395,7 +395,7 @@ const services = [ description: '主要服务行动不便、认知或沟通不便、异地就医不熟悉医院的用户。夜间就诊、不便独自前往或家属不便陪同时,建议选择本服务。', // 步骤名与 id 1-5 对齐:「在线预约」→「预约」、「电话确认」→「沟通信息」; // 执行人由「客服人员」改为「陪诊师」——服务执行人一律用陪诊师,客服只管订单变更与投诉。 - // 原「分配陪诊师」一步已删:陪诊师可在「陪诊师团队」页面自选,匹配不是用户可感知的必要环节, + // 原「分配陪诊师」一步已删:陪诊师由平台按医院与时间匹配,不是用户可感知的必要环节, // 删后步骤数与 id 1/2 一致 flow: [{ title: '预约', diff --git a/routes/index.js b/routes/index.js index f5308e7..97d6c8d 100644 --- a/routes/index.js +++ b/routes/index.js @@ -76,6 +76,8 @@ function registerRoutes(app) { // 陪诊师公示(公开):面向 C 端患者展示陪同人员信息。 // 与上面的 /service、/agreement 一样属公示类资源,不加 requireAuth router.get("/attendant", handlerEmployee.getAttendants.bind(handlerEmployee)); + // 单个陪诊师详情。必须注册在 /attendant 之后,避免被它抢匹配 + router.get("/attendant/:id", handlerEmployee.getAttendantById.bind(handlerEmployee)); app.use(router.routes()); app.use(router.allowedMethods());