Compare commits

..
9 Commits
Author SHA1 Message Date
lik 168abd3133 修改了bug 2026-10-07 15:51:13 +08:00
lik 1bff9819ab 修改了bug 2026-10-07 15:50:18 +08:00
lik b3b51a36f3 tmp 2026-10-07 14:55:25 +08:00
lik 139013ae4c tmp 2026-10-07 11:52:30 +08:00
lik 1813998ac5 tmp 2026-10-06 22:32:47 +08:00
lik 7fc91b75f7 修改了employee相关 2026-10-06 21:44:42 +08:00
lik 0e81341bff tmp 2026-10-06 17:46:45 +08:00
lik 4e9c19a18e tmp 2026-10-06 16:48:04 +08:00
lik c8a44ee1f9 tmp 2026-10-01 15:35:51 +08:00
19 changed files with 1115 additions and 310 deletions
-6
View File
@@ -1,6 +0,0 @@
TAVILY_API_KEY=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
DEEPSEEK_API_KEY=sk-a58ccd82b7ba4ce3ac176a88c9381095
BAIDU_MAP_AUTH_TOKEN=sk-ap-9xcqwNJ3FyJGUoAoQoCgWLqPd5o2tJKCA1xaSMRaZT0zDo0PCFm7rczL4anUuXf5
PORT=9004
WS_PORT=9005
USER_SERVICE_URL=http://127.0.0.1:9010
+1
View File
@@ -2,3 +2,4 @@
/logs/
/data/
.env
conf.json
+5 -4
View File
@@ -1,12 +1,13 @@
import { tool } from 'langchain';
import * as z from 'zod';
import { checkUrlAllowed } from './url_guard.js';
export const httpGetTool = tool(
async ({ url, headers = {}, timeout = 30000 }) => {
try {
new URL(url);
} catch (e) {
return JSON.stringify({ success: false, error: `Invalid URL: ${url}` });
// SSRF 防护:仅允许 http/https 且目标不得为环回/内网地址
const blocked = checkUrlAllowed(url);
if (blocked) {
return JSON.stringify({ success: false, error: blocked });
}
try {
+5 -4
View File
@@ -1,12 +1,13 @@
import { tool } from "@langchain/core/tools";
import z from "zod";
import { checkUrlAllowed } from "./url_guard.js";
const httpPostTool = tool(
async ({ url, body, headers = {}, contentType = 'json', timeout = 30000 }) => {
try {
new URL(url);
} catch (e) {
return { success: false, error: `Invalid URL: ${url}` };
// SSRF 防护:仅允许 http/https 且目标不得为环回/内网地址
const blocked = checkUrlAllowed(url);
if (blocked) {
return { success: false, error: blocked };
}
let requestBody;
+74
View File
@@ -0,0 +1,74 @@
/**
* 出网 URL 安全校验(SSRF 防护)
*
* Agent 的 HTTP 工具处理的 URL 来自模型输出,而模型输入含不可信的用户内容,
* 因此可能被诱导去访问环回/内网地址(如本机 127.0.0.1 上的其他服务、云元数据地址)。
* 这里集中做白名单式校验:仅允许 http/https,且目标不能是环回/内网/链路本地地址。
*/
// 仅允许的协议
const ALLOWED_PROTOCOLS = ["http:", "https:"];
// 本地主机名
const BLOCKED_HOSTNAMES = new Set(["localhost", "localhost.localdomain", "ip6-localhost"]);
/**
* 是否为内网/保留 IPv4 地址
* @param {string} host 形如 10.0.0.1 的地址
*/
function isPrivateIPv4(host) {
const parts = host.split(".");
if (parts.length !== 4) return false;
const nums = parts.map((p) => (/^\d+$/.test(p) ? Number(p) : NaN));
if (nums.some((n) => !Number.isInteger(n) || n < 0 || n > 255)) return false;
const [a, b] = nums;
if (a === 0 || a === 10 || a === 127) return true; // 当前网络/内网/环回
if (a === 100 && b >= 64 && b <= 127) return true; // 100.64.0.0/10 运营商级 NAT
if (a === 169 && b === 254) return true; // 169.254.0.0/16 链路本地(含云 metadata)
if (a === 172 && b >= 16 && b <= 31) return true; // 172.16.0.0/12
if (a === 192 && b === 168) return true; // 192.168.0.0/16
return false;
}
/**
* 校验 URL 是否允许出网访问
* @param {string} url 待校验的 URL
* @returns {string|null} 不允许时返回原因文案,允许时返回 null
*/
export function checkUrlAllowed(url) {
let parsed;
try {
parsed = new URL(url);
} catch (e) {
return `Invalid URL: ${url}`;
}
if (!ALLOWED_PROTOCOLS.includes(parsed.protocol)) {
return `Blocked protocol: ${parsed.protocol}`;
}
// URL.hostname 对 IPv6 会带方括号,统一去掉并转小写
const hostname = parsed.hostname.replace(/^\[|\]$/g, "").toLowerCase();
if (BLOCKED_HOSTNAMES.has(hostname)) {
return `Blocked host: ${hostname}`;
}
if (isPrivateIPv4(hostname)) {
return `Blocked private network address: ${hostname}`;
}
if (hostname.includes(":")) {
// IPv6:环回 ::1 / 未指定 :: / 唯一本地 fc00::、fd00:: / 链路本地 fe80::
if (hostname === "::1" || hostname === "::") {
return `Blocked private network address: ${hostname}`;
}
if (/^f[cd]/.test(hostname)) {
return `Blocked private network address: ${hostname}`;
}
if (/^fe[89ab]/.test(hostname)) {
return `Blocked private network address: ${hostname}`;
}
}
return null;
}
+5
View File
@@ -20,6 +20,11 @@ class APP {
}
setupMiddleware() {
// 服务在 nginx 之后,需信任代理头才能拿到真实客户端 IP
// (否则 ctx.ip 恒为 127.0.0.1,限流会变成全站共享一个桶)
this.app.proxy = true;
this.app.proxyIpHeader = "X-Real-IP";
this.app.use(cors());
this.app.use(bodyParser());
-45
View File
@@ -1,45 +0,0 @@
{
"app": {
"wxapp-escort": {
},
"wxapp-escort-admin": {
}
},
"agent": {
"maxHistoryChars": 40000,
"dataDir": "./data",
"model": "deepseek",
"deepseek": {
"apiKey": "sk-a58ccd82b7ba4ce3ac176a88c9381095",
"flashModel": "deepseek-flash",
"proModel": "deepseek-v4-pro"
},
"glm": {
"apiKey": "ed5f2ec42cb9413f87402d38321553f8.u0FalmfLdXxmst69",
"flashModel": "glm-5.3-flash",
"proModel": "glm-5.3"
},
"tavily": {
"apiKey": "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"
},
"baiduMap": {
"authToken": "sk-ap-9xcqwNJ3FyJGUoAoQoCgWLqPd5o2tJKCA1xaSMRaZT0zDo0PCFm7rczL4anUuXf5"
}
},
"mongodb": {
"str": "mongodb://ehason:Ehason_dbuser_2026@huashengtec.com:6000/?authSource=admin&appName=ws-health&retryWrites=true&w=majority",
"host": "huashengtec.com",
"option": {
"dbName": "eiot_health",
"autoIndex": true,
"maxPoolSize": 50,
"minPoolSize": 5,
"maxIdleTimeMS": 60000,
"connectTimeoutMS": 10000,
"serverSelectionTimeoutMS": 30000,
"socketTimeoutMS": 45000,
"compressors": "zlib"
},
"debug": true
}
}
+176
View File
@@ -0,0 +1,176 @@
import mongoose from "mongoose";
import { DBModel } from "../models/index.js";
import ResponseUtil from "../utils/responseUtil.js";
/**
* 陪诊师公示:面向 C 端患者的公开信息展示
*
* 依据 GB/T 47801—2026 4.2.1.5「陪同人员信息公示机制」组织字段:
* 基本信息 —— 健康证明有效期、培训记录、服务年限、累计服务次数
* 专业技能 —— 擅长服务场景、特殊技能(语言)
*
* 两条红线:
* 1. 白名单式输出,绝不返回手机号 / 身份证号 / 证书编号原文;
* 「资质已核验」与健康证明都只对外给布尔值 / 有效期,不给编号。
* birthday 是这条规则下唯一的例外:年龄改由前端换算,故按 YYYY-MM-DD
* 下发(精确到日、不含时分秒),不随响应带出任何时刻信息
* 2. 评分只来自 employee_review 的真实评价聚合,且条数达
* MIN_PUBLIC_REVIEW_COUNT 才输出。attendantInfo.rating 恒为默认 5.0,
* 任何分支都不得读它——那是无依据背书
*/
/**
* 评价对外公示的最低条数
*
* 少于这个条数时平均分不具备参考性:一条 5 分就等于满分,展示反而误导用户。
* 故未达阈值时只回条数、分数给 null,由前端显示"暂不显示评分"
*/
const MIN_PUBLIC_REVIEW_COUNT = 5;
class HandlerEmployee {
// 姓名脱敏:只留姓 + 「陪诊师」,不给全名,降低私下联系(跳单)的可能
static maskName(name) {
const trimmed = String(name || "").trim();
if (!trimmed) return "陪诊师";
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 || {};
const work = employee.work || {};
const info = employee.attendantInfo || {};
return {
id: employee._id,
name: HandlerEmployee.maskName(profile.name),
avatar: profile.avatar || "",
sex: profile.sex || "",
// 年龄由前端换算,这里只下发原料。刻意走 formatDate 而不是原始 Date:
// 一是含 T/Z 的 ISO 串在小程序 iOS 上会解析成 Invalid Date,
// 二是未登记时给空串,前端有一个明确的哨兵值可判(不是 undefined / null 混用)
birthday: HandlerEmployee.formatDate(profile.birthday),
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 || [],
orderCount: info.orderCount || 0,
introduction: employee.introduction || "",
};
}
// 公开的陪诊师列表。
// 与 /service、/agreement 等公示类资源一致,不加 requireAuth——
// 公示信息本身不敏感,且避免登录态异常导致页面空白
async getAttendants(ctx) {
try {
const page = parseInt(ctx.request.query.page) || 1;
const pageSize = parseInt(ctx.request.query.pageSize) || 50;
// 只取在职陪诊员。
// 排序不按 attendantInfo.rating(无可信来源),改按服务年限 / 累计接单数
const list = await DBModel.Employee.find({
role: "attendant",
status: "active",
})
.sort({ "work.workYears": -1, "attendantInfo.orderCount": -1 })
.skip((page - 1) * pageSize)
.limit(pageSize)
.exec();
return ResponseUtil.success(
ctx,
{ attendants: list.map(HandlerEmployee.toPublicAttendant) },
"查询成功"
);
} catch (err) {
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 };
+129 -13
View File
@@ -1,4 +1,5 @@
import { DBModel } from "../models/index.js";
import services from "../resource/services.js";
import ResponseUtil from "../utils/responseUtil.js";
class HandlerEscortRecord {
@@ -11,10 +12,40 @@ class HandlerEscortRecord {
"attendant", "hospital", "schedule", "payment", "notes", "status",
];
static pickFields(body) {
// 用户端可自助修改的字段:费用、状态、陪诊员分配等一律由平台控制
static OWNER_UPDATE_FIELDS = [
"patient", "healthProfileId", "hospital", "schedule", "notes",
];
// 用户端可自助触发的状态(取消/确认完成),其余状态由平台流转
static OWNER_ALLOWED_STATUS = ["cancelled", "completed"];
// 单页最大条数,避免 pageSize 传超大值一次拖库
static MAX_PAGE_SIZE = 200;
/**
* 是否为管理端账号(与 WS 管理端 agent 的校验口径一致)
* @param {Object} user token 解析出的用户
*/
static isAdmin(user) {
return !!(user && user.app && ("wxapp-escort-admin" in user.app));
}
/**
* 记录访问校验:仅记录所属用户本人或管理员可读写
* @param {Object} user 当前登录用户
* @param {Object} record 目标记录
*/
static canAccess(user, record) {
if (!record) return false;
if (HandlerEscortRecord.isAdmin(user)) return true;
return String(record.userId) === String(user?._id);
}
static pickFields(body, allowFields = HandlerEscortRecord.RECORD_FIELDS) {
const picked = {};
for (const key of Object.keys(body || {})) {
const allowed = HandlerEscortRecord.RECORD_FIELDS.some(
const allowed = allowFields.some(
(f) => key === f || key.startsWith(f + ".")
);
if (allowed) {
@@ -24,6 +55,14 @@ class HandlerEscortRecord {
return picked;
}
// 解析分页参数并夹紧上限,防止超大 pageSize 拖库
static parsePaging(query = {}) {
const page = Math.max(1, parseInt(query.page) || 1);
const rawSize = parseInt(query.pageSize) || 20;
const pageSize = Math.min(Math.max(1, rawSize), HandlerEscortRecord.MAX_PAGE_SIZE);
return { page, pageSize };
}
// 按条件查询记录(管理端,可查全部)
async getRecords(ctx) {
return this._listRecords(ctx, false);
@@ -36,7 +75,8 @@ class HandlerEscortRecord {
async _listRecords(ctx, forceSelf) {
try {
const { page = 1, pageSize = 20, status, userId, appointmentDate, healthProfileId } = ctx.request.query;
const { status, userId, appointmentDate, healthProfileId } = ctx.request.query;
const { page, pageSize } = HandlerEscortRecord.parsePaging(ctx.request.query);
const effectiveUserId = forceSelf ? ctx.state.user?._id : userId;
// status解析成数组
@@ -46,8 +86,8 @@ class HandlerEscortRecord {
}
const records = await DBModel.EscortRecord.findRecords({
page: parseInt(page),
pageSize: parseInt(pageSize),
page,
pageSize,
status: statusArray,
userId: effectiveUserId,
appointmentDate,
@@ -68,10 +108,11 @@ class HandlerEscortRecord {
return ResponseUtil.badRequest(ctx, "缺少陪诊员ID");
}
const { page = 1, pageSize = 20, status } = ctx.request.query;
const { status } = ctx.request.query;
const { page, pageSize } = HandlerEscortRecord.parsePaging(ctx.request.query);
const records = await DBModel.EscortRecord.findRecordsByAttendant(attendantId, {
page: parseInt(page),
pageSize: parseInt(pageSize),
page,
pageSize,
status,
});
@@ -92,6 +133,10 @@ class HandlerEscortRecord {
if (!record) {
return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404);
}
// 归属校验:非本人订单按无权限处理,避免凭 id 遍历他人订单
if (!HandlerEscortRecord.canAccess(ctx.state.user, record)) {
return ResponseUtil.forbidden(ctx, "无权访问该订单");
}
return ResponseUtil.success(ctx, { record }, "查询成功");
} catch (err) {
@@ -101,12 +146,39 @@ class HandlerEscortRecord {
async createRecord(ctx) {
try {
const record = ctx.request.body;
if (!record.userId) {
return ResponseUtil.badRequest(ctx, "缺少患者信息");
const body = ctx.request.body || {};
const userId = ctx.state.user?._id;
if (!userId) {
return ResponseUtil.unauthorized(ctx, "登录已失效");
}
// 订单必须关联就诊人:patient 是下单当刻的快照,healthProfileId 才是关联
if (!body.healthProfileId) {
return ResponseUtil.badRequest(ctx, "缺少就诊人档案ID");
}
const newRecord = await DBModel.EscortRecord.createRecord(HandlerEscortRecord.pickFields(record));
// 就诊人档案必须属于当前登录用户,避免把订单挂到他人档案上
const profile = await DBModel.HealthProfile.findProfileById(body.healthProfileId);
if (!profile || String(profile.userId) !== String(userId)) {
return ResponseUtil.badRequest(ctx, "就诊人档案不存在");
}
// 服务项目与价格以服务端 services.js 为准,客户端提交的价格一律忽略
const service = services.find((s) => String(s.id) === String(body.escort?.serviceId));
if (!service) {
return ResponseUtil.badRequest(ctx, "服务项目不存在");
}
const payload = HandlerEscortRecord.pickFields(body);
// 归属、状态、费用全部由服务端决定
payload.userId = userId;
payload.status = "pending";
payload.payment = {
totalFee: Number(service.price) || 0,
paidFee: 0,
status: "unpaid",
};
const newRecord = await DBModel.EscortRecord.createRecord(payload);
return ResponseUtil.success(ctx, { record: newRecord }, "创建成功");
} catch (err) {
return ResponseUtil.internalError(ctx, err.message);
@@ -122,7 +194,20 @@ class HandlerEscortRecord {
return ResponseUtil.badRequest(ctx, "缺少记录ID");
}
const updatedRecord = await DBModel.EscortRecord.updateRecord(id, HandlerEscortRecord.pickFields(update));
const existing = await DBModel.EscortRecord.findById(id);
if (!existing) {
return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404);
}
if (!HandlerEscortRecord.canAccess(ctx.state.user, existing)) {
return ResponseUtil.forbidden(ctx, "无权修改该订单");
}
// 非管理员只能改就诊信息类字段,费用/状态/归属/陪诊员分配不可自助修改
const allowFields = HandlerEscortRecord.isAdmin(ctx.state.user)
? HandlerEscortRecord.RECORD_FIELDS
: HandlerEscortRecord.OWNER_UPDATE_FIELDS;
const updatedRecord = await DBModel.EscortRecord.updateRecord(id, HandlerEscortRecord.pickFields(update, allowFields));
if (!updatedRecord) {
return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404);
}
@@ -145,6 +230,20 @@ class HandlerEscortRecord {
return ResponseUtil.badRequest(ctx, "缺少状态值");
}
const existing = await DBModel.EscortRecord.findById(id);
if (!existing) {
return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404);
}
if (!HandlerEscortRecord.canAccess(ctx.state.user, existing)) {
return ResponseUtil.forbidden(ctx, "无权修改该订单");
}
// 用户端只允许「取消」与「确认完成」,其余状态由平台流转
if (!HandlerEscortRecord.isAdmin(ctx.state.user)
&& !HandlerEscortRecord.OWNER_ALLOWED_STATUS.includes(status)) {
return ResponseUtil.forbidden(ctx, "不支持的状态变更");
}
const updatedRecord = await DBModel.EscortRecord.updateRecord(id, {
"status": status,
});
@@ -167,6 +266,23 @@ class HandlerEscortRecord {
return ResponseUtil.badRequest(ctx, "缺少记录ID");
}
const existing = await DBModel.EscortRecord.findById(id);
if (!existing) {
return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404);
}
if (!HandlerEscortRecord.canAccess(ctx.state.user, existing)) {
return ResponseUtil.forbidden(ctx, "无权删除该订单");
}
// 与前端口径一致:仅「未支付且未进入服务阶段」的订单可删。
// 已支付/进行中/已完成的订单承载服务与费用记录,不能只靠前端隐藏按钮来保护
const paymentStatus = existing.payment?.status || "unpaid";
const deletable = paymentStatus === "unpaid"
&& ["pending", "confirmed", "cancelled"].includes(existing.status);
if (!HandlerEscortRecord.isAdmin(ctx.state.user) && !deletable) {
return ResponseUtil.forbidden(ctx, "该订单不可删除");
}
const deletedRecord = await DBModel.EscortRecord.deleteRecord(id);
if (!deletedRecord) {
+124 -3
View File
@@ -8,6 +8,17 @@ class HandlerHealthProfile {
// 白名单:只允许写入 schema 定义的顶层字段(兼容 'profile.name' 等点号路径)
static PROFILE_FIELDS = ["userId", "profile", "location", "health"];
// 单页最大条数,避免 pageSize 传超大值一次拖库
static MAX_PAGE_SIZE = 200;
// 解析分页参数并夹紧上限
static parsePaging(query = {}) {
const page = Math.max(1, parseInt(query.page) || 1);
const rawSize = parseInt(query.pageSize) || 20;
const pageSize = Math.min(Math.max(1, rawSize), HandlerHealthProfile.MAX_PAGE_SIZE);
return { page, pageSize };
}
static pickFields(body) {
const picked = {};
for (const key of Object.keys(body || {})) {
@@ -23,11 +34,12 @@ class HandlerHealthProfile {
async getProfiles(ctx) {
try {
const { page = 1, pageSize = 20, userId, name, mobile, sortBy } = ctx.request.query;
const { userId, name, mobile, sortBy } = ctx.request.query;
const { page, pageSize } = HandlerHealthProfile.parsePaging(ctx.request.query);
const result = await DBModel.HealthProfile.findProfiles({
page: parseInt(page),
pageSize: parseInt(pageSize),
page,
pageSize,
userId,
name,
mobile,
@@ -40,6 +52,115 @@ class HandlerHealthProfile {
}
}
// 查询"我的"健康档案列表:强制以登录用户身份查询,忽略请求中的 userId
async getMyProfiles(ctx) {
try {
const { name, mobile, sortBy } = ctx.request.query;
const { page, pageSize } = HandlerHealthProfile.parsePaging(ctx.request.query);
const userId = ctx.state.user?._id;
const result = await DBModel.HealthProfile.findProfiles({
page,
pageSize,
userId,
name,
mobile,
sortBy,
});
return ResponseUtil.success(ctx, result, "查询成功");
} catch (err) {
return ResponseUtil.internalError(ctx, err.message);
}
}
// 查询"我的"单个健康档案:非本人档案一律按不存在处理
async getMyProfileById(ctx) {
try {
const { id } = ctx.params;
if (!id) {
return ResponseUtil.badRequest(ctx, "缺少档案ID");
}
const profile = await HandlerHealthProfile.findOwnProfile(ctx, id);
if (!profile) {
return ResponseUtil.notFound(ctx, "健康档案不存在");
}
return ResponseUtil.success(ctx, { profile }, "查询成功");
} catch (err) {
return ResponseUtil.internalError(ctx, err.message);
}
}
// 新增"我的"健康档案:userId 以 token 为准,避免写到他人名下
async createMyProfile(ctx) {
try {
const body = HandlerHealthProfile.pickFields(ctx.request.body);
body.userId = ctx.state.user?._id;
const newProfile = await DBModel.HealthProfile.createProfile(body);
return ResponseUtil.success(ctx, { profile: newProfile }, "创建成功");
} catch (err) {
return ResponseUtil.internalError(ctx, err.message);
}
}
// 更新"我的"健康档案:先校验归属,再更新
// 注意:入参使用 "profile.name" 这类点号路径,
// 因为 schema 在更新姓名时会额外写入 "profile.pinyin",与整体替换 profile 对象会冲突
async updateMyProfile(ctx) {
try {
const { id } = ctx.params;
if (!id) {
return ResponseUtil.badRequest(ctx, "缺少档案ID");
}
const profile = await HandlerHealthProfile.findOwnProfile(ctx, id);
if (!profile) {
return ResponseUtil.notFound(ctx, "健康档案不存在");
}
const update = HandlerHealthProfile.pickFields(ctx.request.body);
delete update.userId; // 不允许通过请求转移档案归属
const updatedProfile = await DBModel.HealthProfile.updateProfileById(id, update);
return ResponseUtil.success(ctx, { profile: updatedProfile }, "更新成功");
} catch (err) {
return ResponseUtil.internalError(ctx, err.message);
}
}
// 删除"我的"健康档案:先校验归属,再删除
async deleteMyProfile(ctx) {
try {
const { id } = ctx.params;
if (!id) {
return ResponseUtil.badRequest(ctx, "缺少档案ID");
}
const profile = await HandlerHealthProfile.findOwnProfile(ctx, id);
if (!profile) {
return ResponseUtil.notFound(ctx, "健康档案不存在");
}
await DBModel.HealthProfile.deleteProfileById(id);
return ResponseUtil.success(ctx, { profile }, "删除成功");
} catch (err) {
return ResponseUtil.internalError(ctx, err.message);
}
}
// 按 id 查找档案并校验归属,非本人档案返回 null
static async findOwnProfile(ctx, id) {
const profile = await DBModel.HealthProfile.findProfileById(id);
const userId = ctx.state.user?._id;
if (!profile || String(profile.userId) !== String(userId)) {
return null;
}
return profile;
}
async getProfileById(ctx) {
try {
const { id } = ctx.params;
+3 -1
View File
@@ -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 };
+36 -2
View File
@@ -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();
+166
View File
@@ -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 };
+6 -6
View File
@@ -7,7 +7,7 @@ import mongoose from "mongoose";
*
* 用于记录和管理陪诊服务全流程数据,包含以下主要分类:
* - 基础信息:userId(订单提交用户)、healthProfileId(关联健康档案ID)
* - 患者信息:patient(姓名、电话、性别、出生年月、身份证号)
* - 患者信息:patient(名单字段快照:姓名、电话、性别、出生年月)
* - 陪诊服务:escort(服务ID、服务名称)
* - 就诊信息:hospital(医院省份、名称、地址、科室、医生、病历号)
* - 时间安排:schedule(预约时间、开始时间、结束时间、时长)
@@ -32,7 +32,7 @@ const EscortRecordSchema = mongoose.Schema(
},
/**
* 关联健康档案ID(可为空)
* 关联健康档案ID(下单必填:订单必须关联就诊人)
* @type {ObjectId}
* @ref health_profile
*/
@@ -44,16 +44,16 @@ const EscortRecordSchema = mongoose.Schema(
},
/**
* 患者信息 - 就诊患者的基本信息
* 患者信息 - 下单当刻从健康档案拷贝的「名单字段」快照(仅姓名/电话/性别/出生年月)。
* 档案可随时编辑或删除,历史订单至少需要认出"是谁、怎么联系",故名单快照与 healthProfileId 并存:
* 展示时以档案最新数据为准,档案缺失时回落到这份快照。
* 体征(身高/体重)与证件号不进快照——它们只有详情页兜底在用,详情页本来就实时拉档案
*/
patient: {
name: { type: String, default: "", comment: "患者姓名" },
mobile: { type: String, default: "", index: true, comment: "患者联系电话" },
sex: { type: String, enum: ["male", "female"], comment: "患者性别" },
birth: { type: String, default: "", comment: "患者出生年月(YYYY-MM-DD)" },
weight: { type: Number, default: 0, comment: "患者体重(kg)" },
height: { type: Number, default: 0, comment: "患者身高(cm)" },
idnumber: { type: String, default: "", comment: "患者身份证号" },
},
/**
+37 -22
View File
@@ -7,11 +7,15 @@ const agreement = {
title: "服务注意事项",
type: "list",
items: [
"仅支持平台已开通地区医院的代问诊服务,仅限当次就诊门诊当天使用。",
"平台提供全天陪诊、半天陪诊、夜间陪诊、代问诊、检查预约、出入院代办、跑腿服务、取药快递等服务,各项服务的内容与范围以其服务页面公示为准。",
// 4.2.4.2 收费标准须通过服务平台公示、实收应符合公示;9.2.3 服务合同应含服务费用及明细
"各项服务费用以服务页面公示的收费标准为准,未公示的项目不另行收取。",
"全程陪诊半天不超过4小时,全天不超过8小时。",
"预约成功后,平台将提前电话联系确认就诊资料及时间,并以短信或微信通知预约人。",
"陪诊人员只代客户问诊并转达医嘱,不负责诊断和治疗。需要时陪诊员会让医生和客户直接通话。最终医疗听从医嘱为准。",
"先预约,电话确认,微信支付,分配陪诊人员,服务开始。",
"陪诊师只代您向医生陈述病情并转达医嘱,不负责诊断和治疗。需要时陪诊师会安排医生与您直接通话,最终以医嘱为准。",
// 流程与 services.js 各服务的 flow 一致(同 guide.js)。
// 原列的「选择陪诊师」不在任何服务的 flow 里,且该选择功能尚未上线,故一并去掉
"服务流程:预约、沟通信息、支付费用、准备资料、全程陪诊、服务完成。",
"医生停诊等特殊情况,请及时联系客服处理。"
]
},
@@ -20,14 +24,14 @@ const agreement = {
title: "预约与退订",
type: "mixed",
items: [
"陪诊日期为周一至周日,须在前一个工作日20:00前预约;",
"如需退订,请在就诊前一个工作日联系客服;逾期退订将扣取违约金:"
"服务日期为周一至周日,请至少提前1天预约;个别服务对预约时限另有要求,以其服务页面公示为准。",
"如需改期或取消,请至少提前1天联系客服;未在时限内提出的,按下表扣除违约金:"
],
table: {
headers: ["退订时间", "扣款比例"],
rows: [
["6小时以内", "20%"],
["1小时以内", "50%"],
["不足1天", "20%"],
["不足1小时", "50%"],
["服务开始后", "100%"]
]
}
@@ -37,8 +41,12 @@ const agreement = {
title: "服务范围",
type: "list",
items: [
"包含:诊前问询、提醒、建档取号、代缴费用、排队候诊、问诊沟通、代取药品、解读医嘱、回寄物品。",
"不包含:急诊就医、插队加号、医院陪护、急诊留观陪护。"
// 「回寄物品」过宽:国标 8.1 只允许「领取药物和检查结果,必要时代发快递」,
// 故收窄到这两类;「开药」类表述一律不出现,只写「代取药品」
"包含:诊前问询、提醒、建档取号、代缴费用、排队候诊、问诊沟通、代取药品、领取检查结果并按需代发快递、记录并转达医嘱。",
"不包含:急诊就医、插队加号、医院陪护、急诊留观陪护。",
// 原本写在第 1 节首条,会被读成「本协议只覆盖代问诊」;移到服务范围里,只限定代问诊这一项
"代问诊服务仅支持平台已开通地区的医院,且只限当次就诊门诊当天使用。"
]
},
{
@@ -47,31 +55,38 @@ const agreement = {
type: "list",
items: [
"陪诊服务仅为就医陪伴,不涉及医疗诊断和治疗,用户自行承担医疗决策责任。",
"用户应文明交流,如有恶意中伤、人身攻击等不当行为,陪诊人员有权终止服务且不予退款。",
"用户应文明交流,如有恶意中伤、人身攻击等不当行为,陪诊师有权终止服务且不予退款。",
"因用户隐瞒病情或提供虚假信息导致的后果,由用户自行承担。",
"因不可抗力导致服务无法进行的,双方互不承担责任,平台可协助改期或退款。"
"因不可抗力导致服务无法进行的,双方互不承担责任,平台可协助改期或退款。",
// 9.2.3 要求服务合同写明服务终止条件
"服务完成、用户主动取消,或因上述情形终止服务的,本次服务关系即行终止。"
]
},
{
number: 5,
title: "争议解决",
type: "paragraph",
content: "本协议适用《中华人民共和国民法典》。如发生争议,双方协商解决;协商不成的,可向平台所在地人民法院提起诉讼。"
title: "隐私保护",
type: "list",
items: [
// 4.2.2.1 与 9.2.3:个人信息按 GB/T 35273 管理,合同须含隐私保护条款
"平台按照《信息安全技术 个人信息安全规范》(GB/T 35273)管理您的个人信息,仅在提供服务所必需的范围内收集和使用就诊人姓名、联系方式、就诊资料等信息。",
// 4.2.2.2 授权同意是收集与使用的前提
"收集和使用上述信息前,平台会取得您或就诊人法定监护人的授权同意。",
"您的个人信息仅用于预约、沟通、办理就医事项与服务回访,不会向无关第三方提供;法律法规另有规定或经您同意的除外。",
"陪诊师仅可接触本次服务所必需的信息,并对服务过程中知悉的病情、病历等信息保密。",
// 4.2.2.3 防止信息泄露、丢失
"服务结束后,平台按法律法规要求留存和处置相关信息,并采取措施防止信息泄露、丢失。"
]
},
{
number: 6,
title: "联系我们",
type: "contact",
contacts: [
{ icon: "mail", text: "service@huashengtec.com", action: "copyEmail" },
{ icon: "call", text: "18618162956", action: "callPhone" },
{ icon: "calendar-1", text: "周一至周日 08:00-20:00", action: null }
]
title: "争议解决",
type: "paragraph",
content: "本协议适用《中华人民共和国民法典》。如发生争议,双方协商解决;协商不成的,可向平台所在地人民法院提起诉讼。"
}
],
footer: [
"依据《中华人民共和国民法典》制定",
"更新日期:2026年5月28日",
"更新日期:2026年10月6日",
"© 2026 北京奕华盛科技"
]
};
+282 -149
View File
@@ -2,76 +2,90 @@ const services = [
{
id: 1,
title: '全天陪诊',
subtitle: '八小时服务/次,专业陪诊师全程陪同',
// 时段用「—」+「、」:括号内不断句,读起来是一个整体;列表页说明句按原文整段展示
subtitle: '八小时服务/次(早8点—中午12点、下午1点—晚上5点),专业陪诊师全程陪同就医。',
price: '500.00',
image: '/images/pz1.jpg',
icon: 'user-business',
tag: '',
description: '全天陪诊服务提供8小时的专业陪诊服务,陪诊师将全程陪同您完成就医流程。包括挂号、候诊、就诊、检查、取药等环节的协助与引导。我们的陪诊师均经过专业培训,熟悉各大医院流程,能够有效缩短就医时间,提升就医体验。',
// 只讲服务对象,不重复 subtitle 已写的时长与陪同方式(详情页两段紧挨着显示)
// 三类人群对应 GB/T 47801—2026 3.3 服务对象的「行动、认知、地域差异」三种成因;
// 原「需要专业指导」会被读成医疗指导,触及 8.2「不应提供医疗建议」
description: '主要服务行动不便、认知或沟通不便、异地就医不熟悉医院的用户。就诊项目较多、耗时长的情况,建议选择本服务。',
flow: [{
title: '在线预约',
desc: '选择服务类型,填写就诊信息,提交订单'
title: '预约',
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
},
{
title: '电话确认',
desc: '客服人员将在30分钟内与您电话确认订单详情'
},
{
title: '分配陪诊师',
desc: '根据您的就诊医院和时间,匹配专业陪诊师'
// 9.3.1.1 服务前应确认家属陪同、行动能力与特殊需求、服务人员信息、应急联系人
// 科室限定「由您确定」,避免被读成 8.2 第4条「代替服务对象选择治疗方案」
title: '沟通信息',
desc: '与陪诊师确认就诊医院、就诊时间、就诊人信息、行动能力与特殊需求'
},
{
title: '支付费用',
desc: '通过微信支付服务费用'
},
{
title: '医院集合',
desc: '陪诊师按约定时间在医院指定地点与您会合'
title: '准备资料',
desc: '根据您要陪诊的项目,准备必要的资料,如身份证、就诊卡、检查报告、问诊诉求等'
},
{
title: '全程陪诊',
desc: '陪诊师协助完成挂号、就诊、检查、取药等流程'
},
{
// 9.3.3 服务后:清点材料并逐项签收(9.3.3.1)、告知后续材料领取方式(9.3.3.2)、
// 提醒遵医嘱(9.3.3.3)、对服务过程作记录和总结(9.3.3.4)
title: '服务完成',
desc: '确认服务完成,可对陪诊师进行评价'
desc: '清点病历、检查报告、药品等材料,确认本次就诊所有项目已完成。提醒后续就诊时间、注意事项与医嘱。'
}
],
notices: [
'服务时长为8小时,超出时间按每小时50元计费',
'服务时长为8小时,超出时间按每小时80元计费',
'请提前1天预约',
'如需取消订单,请提前联系客服',
'服务不包含挂号费、检查费、药费等医疗费用',
// 9.1.1 应提供服务变更手续,故「改期」与「取消」合并为一条。
// 时限全站统一为「至少提前1天」,与协议违约金的起扣档(不足1天扣20%)对齐;
// 原「请提前联系客服」没给时间,9.1.1 要求公示变更申请时限,等于缺公示
'如需改期或取消订单,请至少提前1天联系客服',
'服务费不包含挂号费、检查费、药费等医疗费用',
// 8.2 不应签署具有法律效力的医疗文书:陪同现场最容易被要求「帮忙签个字」,
// 尤其患者是老人或家属不在场时,所以把边界写死,不留解释空间
'具有法律效力的医疗文书(手术、麻醉、特殊检查知情同意书等)需您本人或家属签署,陪诊师不代签',
// 9.1.1 与 9.2.3 要求告知投诉渠道
'如对服务有疑问或建议,可在「联系我们」页面反馈'
]
},
{
id: 2,
title: '半天陪诊',
subtitle: '四小时服务/次,适合简单就诊',
// 时段写成「上午8点—中午12点 或 下午1点—晚上5点」:一是与 id 1 全天陪诊的两个半天完全对齐
// (全天 8 小时就是这两段之和);二是「不包含夜间」由时段本身说清,不必反向声明。
// 列表页说明句直接放这段 subtitle 原文(会折两行,属预期)
subtitle: '四小时服务/次(上午8点—中午12点 或 下午1点—晚上5点),专业陪诊师全程陪同就医。',
price: '300.00',
image: '/images/pz1.jpg',
icon: 'usergroup',
tag: '热门',
description: '半天陪诊服务提供4小时的专业陪诊服务,适合就诊流程相对简单的客户。陪诊师将协助您完成挂号、就诊、简单检查及取药等环节,让您的就医过程更加顺畅。',
// 服务对象句与 id 1 一致(同属陪同就医,对象成因相同);首句用「项目较少、半天内完成」
// 与 id 1 的「项目较多、耗时长」形成对照,避免两个详情页读起来没有区分度
description: '适合就诊项目较少、预计半天内能完成的情况。主要服务行动不便、认知或沟通不便、异地就医不熟悉医院的用户。',
// 流程与 id 1 逐字相同,国标条目注释只在 id 1 保留一处,避免两处维护同一段说明
flow: [{
title: '在线预约',
desc: '选择服务类型,填写就诊信息,提交订单'
title: '预约',
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
},
{
title: '电话确认',
desc: '客服人员将在30分钟内与您电话确认订单详情'
},
{
title: '分配陪诊师',
desc: '根据您的就诊医院和时间,匹配专业陪诊师'
title: '沟通信息',
desc: '与陪诊师确认就诊医院、就诊时间、就诊人信息、行动能力与特殊需求'
},
{
title: '支付费用',
desc: '通过微信支付服务费用'
},
{
title: '医院集合',
desc: '陪诊师按约定时间在医院指定地点与您会合'
title: '准备资料',
desc: '根据您要陪诊的项目,准备必要的资料,如身份证、就诊卡、检查报告、问诊诉求等'
},
{
title: '全程陪诊',
@@ -79,59 +93,92 @@ const services = [
},
{
title: '服务完成',
desc: '确认服务完成,可对陪诊师进行评价'
desc: '清点病历、检查报告、药品等材料,确认本次就诊所有项目已完成。提醒后续就诊时间、注意事项与医嘱。'
}
],
notices: [
'服务时长为4小时,超出时间按每小时50元计费',
'请提前1天预约,紧急订单需额外支付加急费',
'如需取消订单,请提前4小时联系客服',
'服务不包含挂号费、检查费、药费等医疗费用'
// 全站超时统一 80 元/h(原 50 元/h);本项自身均价 300/4h=75 元/h,超时单价略高于均价
'服务时长为4小时,超出时间按每小时80元计费',
// 原「紧急订单需额外支付加急费」只有收费名目没有金额,4.2.4.2 要求收费标准须公示,
// 故不公示未定金额,改为指向客服确认
'请提前1天预约,紧急订单请联系客服确认',
// 9.1.1 应提供服务变更手续,与 id 1 同样把「改期」并入取消一条
'如需改期或取消订单,请至少提前1天联系客服',
'服务费不包含挂号费、检查费、药费等医疗费用',
// 8.2 同 id 1:法律效力医疗文书不代签,注释只在 id 1 保留一份
'具有法律效力的医疗文书(手术、麻醉、特殊检查知情同意书等)需您本人或家属签署,陪诊师不代签',
// 9.1.1 与 9.2.3 要求告知投诉渠道
'如对服务有疑问或建议,可在「联系我们」页面反馈'
]
},
{
id: 3,
// 患者不到场的场景。国标里没有「代问诊」这个动作,最接近的是 7.1.4「协助医患沟通」
// 与 8.1「记录医嘱」,所以动作词一律落在「陈述病情、转达诉求、记录医嘱、转达医嘱」上。
// 原「详细记录医生的诊断意见和用药建议」会被读成 8.2「判读检查结果、提供医疗建议」,
// 且与本项 notices 里「不提供医疗建议」自相矛盾,故统一为「如实记录医嘱并转达」
title: '代问诊',
subtitle: '代您到医院与医生进行问诊',
price: '260.00',
// 补「记录并转达」这一对动作,与 description、flow 同一口径
subtitle: '陪诊师代您向医生陈述病情、记录并转达医嘱,无需亲自到场。',
price: '280.00',
image: '/images/wz1.jpg',
icon: 'heart',
tag: '',
description: '代问诊服务适用于无法亲自到医院的客户。我们的陪诊师将携带您的病历资料,代您与医生进行面诊,详细记录医生的诊断意见和用药建议,并及时反馈给您。',
// 4.2.2.2 收集与使用服务对象信息须经授权同意,本项全程由他人代为出行,前提必须写明;
// 末句对应 8.2 第 4 条「不应代替服务对象或其家属选择或同意治疗方案」
description: '代问诊服务适用于无法亲自到医院的用户。经您同意并授权后,陪诊师携带您的病历资料,代您向医生陈述病情、转达就诊诉求,如实记录医嘱并完整转达给您。涉及检查、用药与治疗方案的选择,由医生直接与您或家人沟通,陪诊师不代为决定。',
flow: [{
title: '在线预约',
desc: '选择代问诊服务,填写就诊信息和病史'
title: '预约',
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
},
{
title: '资料准备',
desc: '将病历、检查报告等资料拍照上传或快递给陪诊师'
},
{
title: '电话确认',
desc: '客服确认资料完整性和就诊需求'
title: '沟通信息',
desc: '与陪诊师确认就诊医院、就诊时间、就诊人信息、行动能力与特殊需求'
},
{
title: '支付费用',
desc: '通过微信支付服务费用'
},
{
title: '准备资料',
desc: '根据您要陪诊的项目,准备必要的资料,如身份证、就诊卡、检查报告、问诊诉求等'
},
{
// 8.1「记录医嘱」+ 8.2 不得选择性传递:写明「如实、完整」;
// 「需要时」替代原「可能随时」——是否需电话由就诊现场决定,不是随时打扰
title: '代问诊',
desc: '陪诊师携带资料到医院代您问诊'
desc: '陪诊师携带您的病历资料,代您向医生陈述病情、转达就诊诉求,如实记录医嘱并完整转达给您。需要时,可安排您与医生电话沟通。'
},
{
title: '结果反馈',
desc: '陪诊师将医生诊断意见和用药建议详细反馈给您'
// 不新造「单独的取药费用」这类未公示费项(9.1.1 收费须公示、9.2.5 额外费用须事先说明),
// 改为指向已有服务:检查走「检查预约」(id 4),取药走「取药快递」(id 8),费用以对应服务页为准
title: '后续事项',
desc: '如医生开具检查,可另行预约「检查预约」服务办理;如需取药,可另行预约「取药快递」服务代取并邮寄。相关费用以对应服务页面为准。'
},
{
// 9.3.3 服务后:清点材料并逐项签收(9.3.3.1)、提醒遵医嘱(9.3.3.3)
title: '服务完成',
desc: '确认服务完成,可对陪诊师进行评价'
desc: '清点病历、检查报告、药品等材料,确认本次就诊所有项目已完成。提醒后续就诊时间、注意事项与医嘱。'
}
],
notices: [
'请提前1天预约',
// 8.1 开头与 4.2.2.2:代为沟通的前提是本人授权同意
'需经您同意并授权后,陪诊师方可代您与医生沟通',
'请确保提供的病历资料真实完整',
'代问诊不包含挂号费和药费',
// 8.2 第 4 条:不代做治疗决定;与 description、flow 同一口径
'陪诊师仅如实记录并转达医嘱,不提供医疗建议,也不代为选择检查、用药或治疗方案',
// 8.2 不应签署具有法律效力的医疗文书。本项患者不到场,代签风险最大,
// 故除「不代签」外再给出动作:现场要求签字时先联系您本人,由您或家属决定
'具有法律效力的医疗文书(知情同意书等)需您本人或家属签署;现场要求签字时,陪诊师会先联系您,不代为签署',
'部分医院可能要求患者本人到场,请提前确认',
'陪诊师会详细记录医嘱,但不提供医疗建议'
'能否与医生电话沟通以就诊现场安排为准;如无法沟通,陪诊师会完整记录医嘱并转达给您',
// 句式与 id 1/2 统一
'服务费不包含挂号费、检查费、药费等医疗费用',
// 9.1.1 应提供服务变更手续
'如需改期或取消订单,请至少提前1天联系客服',
// 9.1.1 与 9.2.3 要求告知投诉渠道
'如对服务有疑问或建议,可在「联系我们」页面反馈'
]
},
{
@@ -142,161 +189,229 @@ const services = [
image: '/images/qbg1.jpg',
icon: 'chart-bar',
tag: '',
description: '检查预约服务帮助您预约各类医学检查,包括核磁共振(MRI)、CT、彩超、胃镜等。我们熟悉各大医院的预约流程,能够帮您快速预约到合适的检查时间,避免长时间等待。',
// 去「我们」(全站统一用「陪诊师」),并补服务对象句,结构对齐 id 1/2/7;
// 原名「核磁共振(MRI)」与副标题的「磁共振」是同一项检查的两个名字,统一为「磁共振(MRI)」;
// 原文首句「检查预约服务帮助您预约…」是把标题复述一遍,改为直接说可约项目
description: '主要服务不便多次往返医院或异地就医的用户。可预约磁共振(MRI)、CT、彩超、胃镜等各类医学检查,大部分医院的这些检查开单当天排不上号,陪诊师会为您预约到合适的检查时间,避免长时间等待。',
// 这项只做一件事:按医生开的单子代约一个检查时间,所以流程比 id 1 全程陪诊还短。
// 原 7 步(在线预约/资料审核/支付费用/预约办理/确认通知/现场协助/服务完成)里有三处冗余:
// 「资料审核」就是「沟通信息」要交的资料;「确认通知」是「预约办理」的后半截;
// 「现场协助」是转卖别的服务,不属于本项流程,移到 notices
// 全站统一称「陪诊师」:id 1/2/3/7 原本就这么写,id 4/5/6/8 原文写「工作人员」,
// 同一角色两个称呼指同一个人,已全部统一(含 id 8 的 description 与 flow)
flow: [{
title: '在线预约',
desc: '选择检查预约服务,填写检查类型和期望时间'
title: '预约',
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
},
{
title: '资料审核',
desc: '上传医生开具的检查申请单'
// 这一步只干一件事:把检查申请单拿到手——已有单就上传,没有就先挂号、再去找医生开单。
// 「行动能力与特殊需求」是陪同类服务的项,代办预约不涉及,故删。
// 「请先挂号」由您自己完成(挂号含选科选医,属 8.2 第4条红线),陪诊师只做挂号之后的陪同开单;
// 检查项目由医生根据病情确定,陪诊师不代为选择
title: '沟通信息',
desc: '与陪诊师确认就诊医院、就诊人信息、检查项目与期望时间,并上传医生开具的检查申请单。尚未开单的,请您先挂号,由陪诊师协助您找医生开具检查申请单。'
},
{
title: '支付费用',
desc: '通过微信支付服务费用'
},
{
// 原「预约办理」+「确认通知」合为一步
title: '预约办理',
desc: '工作人员代为联系医院预约检查时间'
},
{
title: '确认通知',
desc: '将预约成功的具体时间通知您'
},
{
title: '现场协助',
desc: '如需,可额外购买陪诊服务协助检查'
desc: '陪诊师代为联系医院预约检查时间,并把预约到的具体时间通知您'
},
{
// 原话「确认服务完成」是复述标题的空话,改成交代检查前准备才有内容
title: '服务完成',
desc: '确认服务完成'
desc: '确认预约结果已通知到您,并交代该项检查的注意事项与准备要求。'
}
],
notices: [
'需要提供医生开具的检查申请单',
'请提前1天预约',
// 与流程第 2 步同口径:不是"必须先有单"——没有现成的单也能办,前提是您先挂号
'检查申请单需由医生开具,尚未开单的请先挂号,由陪诊师协助您找医生开单',
'部分检查项目有特殊的身体准备要求,请提前了解',
'预约成功后如需改期,请提前24小时联系',
'服务费用不包含检查费用'
// 8.2 不应签署具有法律效力的医疗文书:增强检查、胃镜等检查本身要签知情同意书,
// 本项只代约时间,签字环节仍需本人或家属到场
'具有法律效力的医疗文书(检查知情同意书等)需您本人或家属签署,陪诊师不代签',
// 原流程里「现场协助:如需,可额外购买陪诊服务协助检查」移到此处:
// 它是另一项服务的入口,不是本项流程的一环;写明具体服务名,避免浮现未公示的「陪诊服务」费项
'检查当天如需有人陪同,可另行预约「半天陪诊」或「全天陪诊」服务',
// 9.1.1 应提供服务变更手续。本项原是全站唯一例外(取消检查预约要同时退掉医院号源,
// 提前量长于当时全站统一的 4 小时),全站时限改为 1 天后例外消失,与其余服务同口径
'如需改期或取消订单,请至少提前1天联系客服',
// 流程里出现「挂号」后,排除项必须带上挂号费,否则会被读成 200 元含挂号
'服务费不包含挂号费、检查费用,相关费用请在就诊医院现场缴纳',
// 9.1.1 与 9.2.3 要求告知投诉渠道
'如对服务有疑问或建议,可在「联系我们」页面反馈'
]
},
{
id: 5,
// 代办类:只跑手续,不参与诊疗决策。8.2 禁止「签署具有法律效力的医疗文书」,
// 故入院登记、知情同意书等一律写明需本人或家属签署,陪诊师不代签
title: '出入院代办',
subtitle: '出入院手续代办',
price: '280.00',
// 原「出入院手续代办」比标题只多两个字,等于把标题复读一遍;按 id 1/2/4 的做法,
// 副标题要给出标题没有的信息,这里给的是「代办事项清单」
subtitle: '入院登记、医保手续、出院结算,均可代为办理',
price: '300.00',
image: '/images/yy.jpg',
icon: 'user-checked',
tag: '',
description: '出入院代办服务帮助您办理医院的入院和出院手续。包括床位预约、入院登记、医保手续办理、出院结算等,让您或家人能够更专注于治疗本身。',
// 4.2.2.2 办理出入院要收集身份证、医保卡信息,须经授权同意;首句补服务对象,
// 与 id 1/2/3/4 一致;原末句「让您或家人能够更专注于治疗本身」是宣传腔,删
description: '经您授权同意后,陪诊师代您办理入院登记、医保手续、出院结算等出入院手续。',
// 步骤名与顺序对齐 id 1/2/3(预约 → 沟通信息 → 支付费用 → 准备资料 → 服务动作 → 服务完成);
// 原 7 步里的「结果反馈」与「服务完成:确认服务完成」是同一件事的两头,合为一步
flow: [{
title: '在线预约',
desc: '选择出入院代办服务,填写相关信息'
title: '预约',
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
},
{
title: '资料准备',
desc: '准备身份证、医保卡、押金等相关资料'
},
{
title: '电话确认',
desc: '客服确认代办事项和所需资料'
// 原名「电话确认」,与 id 1-4 的「沟通信息」是同一件事,统一命名;
// 执行人由「客服」改为「陪诊师」——服务执行人一律用陪诊师,客服只管订单变更与投诉
title: '沟通信息',
desc: '与陪诊师确认入院或出院、就诊医院与科室、代办事项和所需资料'
},
{
title: '支付费用',
desc: '通过微信支付服务费用'
},
{
// 原「准备身份证、医保卡、押金等相关资料」把押金当成资料;押金是钱,不是材料
title: '准备资料',
desc: '准备身份证、医保卡等证件,并按医院要求备好住院押金'
},
{
// 与 notices 的「不代签法律效力文书」同口径,在流程里再说一次,避免只看流程的用户漏掉
title: '代办服务',
desc: '工作人员代为办理出入院手续'
},
{
title: '结果反馈',
desc: '将办理结果和相关单据反馈给您'
desc: '陪诊师代为办理入院登记、医保手续、出院结算等手续;需您本人或家属签署的文书不代签'
},
{
// 原「结果反馈」+「服务完成(空话)」合为一步,并交代交付物,才对得起一个收尾步骤
title: '服务完成',
desc: '确认服务完成'
desc: '将办理结果、出院小结及相关单据交付给您,确认本次代办完成。'
}
],
notices: [
'需要提供患者身份证、医保卡等证件',
'入院代办需提前了解医院床位情况',
'服务费用不包含住院押金和医疗费用',
'部分手续可能需要患者本人签字'
'请提前1天预约',
// 4.2.2.2 授权同意
'需经您授权同意后,陪诊师方可代您办理出入院手续',
'请提供患者身份证、医保卡等证件',
'入院代办需提前确认医院床位情况',
// 句式与 id 1/2/3 统一,并把钱交给谁写清
'服务费不包含住院押金和医疗费用,押金与住院费请在医院缴纳',
// 8.2:具有法律效力的医疗文书不代签;比原「部分手续可能需要患者本人签字」说死了边界
'具有法律效力的医疗文书(如入院登记、知情同意书等)需您本人或家属签署,陪诊师不代签',
// 9.1.1 应提供服务变更手续
'如需改期或取消订单,请至少提前1天联系客服',
// 9.1.1 与 9.2.3 要求告知投诉渠道
'如对服务有疑问或建议,可在「联系我们」页面反馈'
]
},
{
id: 6,
title: '跑腿服务',
subtitle: '送资料/代排队/买药/取报告等一小时陪诊服务',
// 原「…等一小时陪诊服务」把跑腿说成陪诊,与列表页把它归入「就医代办」组自相矛盾;
// 本项是按次计时的代办,故直接写出时长「2小时/次」(列表页规格签同步改为 2小时/次)。
// 「买药」改为「取药」:国标 8.1 允许的是「代买或代取基础医疗用品」(示例为轮椅、陪护床、口罩)
// 与「领取药物」,药品只有"凭处方代领"这一条路;写成「买药」会被读成代为选购药品,
// 擦 8.2「提供医疗建议」「代替服务对象选择用药方案」,也与 id 8「代取药品」的口径打架
subtitle: '送资料/代排队/取药/取报告等,2小时/次',
price: '160.00',
image: '/images/pt.jpg',
icon: 'usergroup',
tag: '',
description: '跑腿服务提供各类医院相关代办事项,包括代排队、代买药、代取报告、代送标本等。适合工作繁忙或行动不便的客户,让您足不出户即可完成医院相关事务。',
// 称谓由「客户」改为「用户」,与 id 1-5/7/8 统一;补服务对象句,结构对齐 id 1/2/7;
// 原末句「让您足不出户即可完成医院相关事务」是宣传腔,且与首句同义,删;
// 「代买药」同步改为「代取药品」——与 id 8 同一动作只留一个说法;
// 「代送标本」改为 8.1 原词「样本送检」:标准术语是"样本"+"送检",
// 用原词才能在出纠纷时主张落在 8.1 列举范围内
description: '主要服务工作繁忙不便请假、或行动不便的用户。可代办代排队、代取药品、代取报告、样本送检等医院相关事项,本人不必到场。',
// 步骤名与 id 1-8 对齐:「在线预约」→「预约」、「电话确认」→「沟通信息」、
// 「材料交接」→「准备资料」(原顺序本身正确,只改名);
// 执行人由「客服」改为「陪诊师」——服务执行人一律用陪诊师,客服只管订单变更与投诉
flow: [{
title: '在线预约',
desc: '选择跑腿服务,填写具体代办事项'
title: '预约',
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
},
{
title: '电话确认',
desc: '客服确认代办细节和所需材料'
title: '沟通信息',
desc: '与陪诊师确认代办事项、办理地点与所需材料'
},
{
title: '支付费用',
desc: '通过微信支付服务费用'
},
{
title: '材料交接',
title: '准备资料',
desc: '通过快递或约定地点交接所需材料'
},
{
title: '代办服务',
desc: '工作人员按约定完成代办事项'
desc: '陪诊师按约定完成代办事项'
},
{
title: '结果交付',
desc: '将办理结果通过快递或约定方式交付'
desc: '将办理结果或代取物品通过快递或约定方式交付给您'
},
{
// 原话「确认服务完成」是复述标题的空话,按 9.3.3 交代签收与后续
title: '服务完成',
desc: '确认服务完成'
desc: '您收到材料或结果后请及时核对,确认无误即服务完成;如有疑问可联系陪诊师处理。'
}
],
notices: [
'请提前1天预约',
// 4.2.2.2:代办要用到您的证件与材料,前提是授权同意,与 id 3/5/8 同口径
'需经您同意并授权后,陪诊师方可持您的证件和材料代您办理',
'请详细说明代办事项和特殊要求',
'需要提供办理所需的相关证件和材料',
'服务时长为1小时,超出按每小时50元计费',
'服务费用不包含药品费、检查费等费用'
// 时长由 1 小时改为 2 小时,与副标题一致;本项自身均价 160/2h=80 元/h,与超时单价齐平
'服务时长为2小时,超出时间按每小时80元计费',
// 句式与 id 1-8 统一(原「服务费用不包含…」)
'服务费不包含药品费、检查费等医疗费用',
// 本项是「本人不到场」的形态——8.1 的动作与 9.3.2.1 的核对、9.3.3.1 的当面签收
// 都以双方在场为前提,本项无从执行,故补一句免责界定把前提说清。
// id 3 已有「不代为选择检查、用药或治疗方案」+「医疗文书需本人或家属签署」,
// id 4 已有「检查知情同意书需本人或家属签署」,两项不重复添加
'本项为辅助代办,不改变由您本人或家属签署医疗文书、确认治疗方案的前提',
// 9.1.1 应提供服务变更手续
'如需改期或取消订单,请至少提前1天联系客服',
// 9.1.1 与 9.2.3 要求告知投诉渠道
'如对服务有疑问或建议,可在「联系我们」页面反馈'
]
},
{
id: 7,
title: 'VIP 5次 半天陪诊',
subtitle: '五次半天陪诊服务,每次四小时,更优惠',
price: '1350.00',
title: '夜间陪诊',
subtitle: '四小时服务/次,夜间时段专业陪同',
price: '500.00',
image: '/images/pz1.jpg',
icon: 'usergroup',
tag: '优惠套餐',
description: '5次半天陪诊套餐为您提供5次、每次4小时的专业陪诊服务,适合需要长期或多次就诊的客户。相比单次购买更加优惠,让您在需要陪诊时随时可用,无需重复下单。',
icon: 'user-business',
tag: '',
// 称谓由「客户」改为「用户」,与 id 1-5 统一;结构对齐 id 1(服务对象句 + 适用情形句),
// 原句把 flow 里的环节又叙述一遍,且「让夜间就医也能更省心」是宣传腔,一并删
description: '主要服务行动不便、认知或沟通不便、异地就医不熟悉医院的用户。夜间就诊、不便独自前往或家属不便陪同时,建议选择本服务。',
// 步骤名与 id 1-5 对齐:「在线预约」→「预约」、「电话确认」→「沟通信息」;
// 执行人由「客服人员」改为「陪诊师」——服务执行人一律用陪诊师,客服只管订单变更与投诉。
// 原「分配陪诊师」一步已删:陪诊师由平台按医院与时间匹配,不是用户可感知的必要环节,
// 删后步骤数与 id 1/2 一致
flow: [{
title: '在线预约',
desc: '选择5次半天陪诊套餐,填写就诊信息,提交订单'
title: '预约',
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
},
{
title: '电话确认',
desc: '客服人员将在30分钟内与您电话确认订单详情'
title: '沟通信息',
desc: '与陪诊师确认就诊医院、就诊时间、就诊人信息、行动能力与特殊需求'
},
{
title: '支付费用',
desc: '通过微信支付服务费用'
},
{
title: '套餐激活',
desc: '确认订单后,5次陪诊服务正式激活'
},
{
title: '预约单次服务',
desc: '每次就诊前,提前联系客服预约当次陪诊服务'
title: '医院集合',
desc: '陪诊师按约定时间在医院指定地点与您会合'
},
{
title: '全程陪诊',
@@ -304,66 +419,84 @@ const services = [
},
{
title: '服务完成',
desc: '确认服务完成,可对陪诊师进行评价'
desc: '清点病历、检查报告、药品等材料,确认本次就诊所有项目已完成。提醒后续就诊时间、注意事项与医嘱。'
}
],
notices: [
'套餐包含5次半天陪诊服务,每次4小时',
'每次就诊前请提前1天联系客服预约',
'套餐有效期为购买之日起12个月',
'服务不包含挂号费、检查费、药费等医疗费用',
'套餐不可退款,但可转让给直系亲属使用'
// 「全站超时统一 80 元/h」的单列例外:本项自身均价 500/4h=125 元/h,
// 若也按 80 元/h 收超时,多待 1 小时会比提前买 1 小时便宜 45 元,逻辑倒挂且像鼓励超时。
// 原 150 元/h 与全天/半天不一致,故降到与自身均价齐平的 125 元/h
'服务时长为4小时,超出时间按每小时125元计费',
'夜间就诊请提前1天预约',
// 9.1.1 应提供服务变更手续,与 id 1/2/3/5 统一为「改期或取消」(原只有取消)
'如需改期或取消订单,请至少提前1天联系客服',
// 8.2 同 id 1:夜间就诊多由家属陪同,法律效力医疗文书仍须本人或家属签署
'具有法律效力的医疗文书(手术、麻醉、特殊检查知情同意书等)需您本人或家属签署,陪诊师不代签',
// 句式与 id 1-5 统一(原「服务不包含」少一个「费」)
'服务费不包含挂号费、检查费、药费等医疗费用',
// 9.1.1 与 9.2.3 要求告知投诉渠道(原缺);页面名与全站一致,用「联系我们」
'如对服务有疑问或建议,可在「联系我们」页面反馈'
]
},
{
id: 8,
title: 'VIP 5次代买药',
subtitle: '五次代买药服务,更优惠',
price: '680.00',
// 国标 8.1 明确列有「代取药品」,属允许开展的动作,不涉及诊疗决策
title: '取药快递',
subtitle: '代取药品,快递寄送到家',
price: '160.00',
image: '/images/pt.jpg',
icon: 'usergroup',
tag: '优惠套餐',
description: '5次代买药套餐为您提供5次专业代买药服务,适合需要长期服药或多次购药的客户。相比单次购买更加优惠,让您在需要购药时随时可用,无需重复下单。',
tag: '',
// 称谓由「客户」改为「用户」、去「我们」,与 id 1-5/7 统一;
// 结构对齐 id 1/2/7(服务对象句 + 适用情形句),原句又把 flow 叙述了一遍
description: '主要服务不便出门或异地取药的用户。经您同意并授权后,陪诊师携带您的取药凭证代取药品,妥善包装后通过快递寄送到家。',
// 「在线预约」→「预约」、「电话确认」→「沟通信息」,与 id 1-7 对齐;
// 原「材料交接」排在「支付费用」之前且与 id 6 位置相反,改为全站统一的「准备资料」并回到支付之后;
// 执行人由「客服」改为「陪诊师」——服务执行人一律用陪诊师,客服只管订单变更与投诉
flow: [{
title: '在线预约',
desc: '选择5次代买药套餐,填写购药信息,提交订单'
title: '预约',
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
},
{
title: '电话确认',
desc: '客服人员将在30分钟内与您电话确认订单详情'
title: '沟通信息',
desc: '与陪诊师确认取药医院或药店、就诊人信息、药品信息与收货地址'
},
{
title: '支付费用',
desc: '通过微信支付服务费用'
},
{
title: '套餐激活',
desc: '确认订单后,5次代买药服务正式激活'
title: '准备资料',
desc: '提供就诊卡、处方或取药单等取药凭证'
},
{
title: '预约单次服务',
desc: '每次购药前,提前联系客服预约当次代买药服务'
title: '代取药品',
desc: '陪诊师按约定到医院或药店代为取药,并核对药品名称与数量'
},
{
title: '代买药',
desc: '工作人员按约定到医院或药店代为购买药品'
},
{
title: '药品交付',
desc: '将购买的药品通过快递或约定方式交付给您'
title: '快递寄出',
desc: '将药品妥善包装后通过快递寄出,并把快递单号告知您'
},
{
// 原话「确认服务完成」是复述标题的空话,按 9.3.3 交代签收与后续
title: '服务完成',
desc: '确认服务完成'
desc: '您收到药品后请及时核对,确认无误即服务完成;如有疑问可联系陪诊师处理。'
}
],
notices: [
'套餐包含5次代买药服务',
'每次购药前请提前1天联系客服预约',
'套餐有效期为购买之日起12个月',
'药品费用需另行支付,不包含在套餐价格内',
'套餐不可退款,但可转让给直系亲属使用',
'需提供医生处方或既往购药记录'
'请提前1天预约',
// 4.2.2.2:取药要用到您的就诊卡与处方,属代办个人信息,前提是授权同意
'需经您同意并授权后,陪诊师方可持您的取药凭证代为取药',
'请提供就诊卡、处方或取药单等取药凭证',
'请提供准确的收货地址,并保持电话畅通以便签收',
// 句式与 id 1-7 统一;快递费因距离而异,4.2.4.2 要求收费须公示,
// 故不写死金额,改为寄出前先与您确认
'服务费不包含药品费用,药品费请按医院或药店实际收费缴纳',
'快递费按实际收取,具体金额会在寄出前与您确认',
// 9.1.1 应提供服务变更手续
'如需改期或取消订单,请至少提前1天联系客服',
// 9.1.1 与 9.2.3 要求告知投诉渠道
'如对服务有疑问或建议,可在「联系我们」页面反馈'
]
}
]
+36 -6
View File
@@ -2,6 +2,7 @@ import Router from "koa-router";
import { HandlerEscortRecord } from "../handler/escort_record.js";
import { HandlerHealthProfile } from "../handler/health_profile.js";
import { HandlerResource } from "../handler/resource.js";
import { HandlerEmployee } from "../handler/employee.js";
import ResponseUtil from "../utils/responseUtil.js";
import { getUserByToken } from "../utils/user_service.js";
@@ -25,6 +26,19 @@ async function requireAuth(ctx, next) {
await next();
}
/**
* 管理员校验中间件:仅管理端小程序绑定的账号可访问管理端接口
* 与 WS 管理端 agent 的校验口径保持一致(app 中是否存在 wxapp-escort-admin)
*/
async function requireAdmin(ctx, next) {
const user = ctx.state.user;
if (!user || !user.app || !("wxapp-escort-admin" in user.app)) {
ResponseUtil.forbidden(ctx, "无管理员权限");
return;
}
await next();
}
function printRoutes(stack) {
for (const layer of stack) {
if (layer.path) {
@@ -40,22 +54,32 @@ function registerRoutes(app) {
const handlerEscortRecord = new HandlerEscortRecord();
const handlerHealthProfile = new HandlerHealthProfile();
const handlerResource = new HandlerResource();
const handlerEmployee = new HandlerEmployee();
// 敏感数据路由需要登录后访问
router.get("/escort-record/my", requireAuth, handlerEscortRecord.getMyRecords.bind(handlerEscortRecord));
router.get("/escort-record/attendant", requireAuth, handlerEscortRecord.getAttendantRecords.bind(handlerEscortRecord));
router.get("/escort-record", requireAuth, handlerEscortRecord.getRecords.bind(handlerEscortRecord));
// 全量查询属管理端能力,必须管理员
router.get("/escort-record", requireAuth, requireAdmin, handlerEscortRecord.getRecords.bind(handlerEscortRecord));
router.get("/escort-record/:id", requireAuth, handlerEscortRecord.getRecordById.bind(handlerEscortRecord));
router.post("/escort-record", requireAuth, handlerEscortRecord.createRecord.bind(handlerEscortRecord));
router.put("/escort-record/:id", requireAuth, handlerEscortRecord.updateRecord.bind(handlerEscortRecord));
router.patch("/escort-record/:id/status", requireAuth, handlerEscortRecord.updateStatus.bind(handlerEscortRecord));
router.delete("/escort-record/:id", requireAuth, handlerEscortRecord.deleteRecord.bind(handlerEscortRecord));
router.get("/health-profile", requireAuth, handlerHealthProfile.getProfiles.bind(handlerHealthProfile));
router.get("/health-profile/:id", requireAuth, handlerHealthProfile.getProfileById.bind(handlerHealthProfile));
router.post("/health-profile", requireAuth, handlerHealthProfile.createProfile.bind(handlerHealthProfile));
router.put("/health-profile/:id", requireAuth, handlerHealthProfile.updateProfile.bind(handlerHealthProfile));
router.delete("/health-profile/:id", requireAuth, handlerHealthProfile.deleteProfile.bind(handlerHealthProfile));
// C 端"我的健康档案":userId 一律取自 token,必须注册在 /health-profile/:id 之前
router.get("/health-profile/mine", requireAuth, handlerHealthProfile.getMyProfiles.bind(handlerHealthProfile));
router.post("/health-profile/mine", requireAuth, handlerHealthProfile.createMyProfile.bind(handlerHealthProfile));
router.get("/health-profile/mine/:id", requireAuth, handlerHealthProfile.getMyProfileById.bind(handlerHealthProfile));
router.put("/health-profile/mine/:id", requireAuth, handlerHealthProfile.updateMyProfile.bind(handlerHealthProfile));
router.delete("/health-profile/mine/:id", requireAuth, handlerHealthProfile.deleteMyProfile.bind(handlerHealthProfile));
// 以下为管理端档案接口(C 端一律走 /mine),必须管理员
router.get("/health-profile", requireAuth, requireAdmin, handlerHealthProfile.getProfiles.bind(handlerHealthProfile));
router.get("/health-profile/:id", requireAuth, requireAdmin, handlerHealthProfile.getProfileById.bind(handlerHealthProfile));
router.post("/health-profile", requireAuth, requireAdmin, handlerHealthProfile.createProfile.bind(handlerHealthProfile));
router.put("/health-profile/:id", requireAuth, requireAdmin, handlerHealthProfile.updateProfile.bind(handlerHealthProfile));
router.delete("/health-profile/:id", requireAuth, requireAdmin, handlerHealthProfile.deleteProfile.bind(handlerHealthProfile));
router.get("/service", handlerResource.getServices.bind(handlerResource));
router.get("/agreement", handlerResource.getAgreement.bind(handlerResource));
@@ -64,6 +88,12 @@ function registerRoutes(app) {
router.get("/department-rankings", handlerResource.getDepartmentRankings.bind(handlerResource));
router.get("/ai-quick-questions", handlerResource.getAiQuickQuestions.bind(handlerResource));
// 陪诊师公示(公开):面向 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());
+7 -32
View File
@@ -39,20 +39,20 @@ async function lookup(body) {
}
/**
* 获取用户信息(带 5 分钟进程内缓存,token→user)
* 仅按 token 获取用户信息(带 5 分钟进程内缓存,token→user)
*
* 注意:缓存期内登出/账户锁定/用户信息变更最多延迟 5 分钟生效
* (user 服务自身接口即时生效)
*
* 只保留 token 路径:user 服务已不再接受按 userId 明文查询,
* 故不存在 userId 兜底,避免凭 userId 冒充他人身份。
*
* @param {string} token 登录 token
* @param {string} [userId] 备用用户ID(token 缺失或失效时的兜底路径,保持原 WS 行为)
* @returns {Promise<Object|null>} 用户信息或 null
*/
export async function getUserInfo(token, userId) {
if (!token && !userId) return null;
export async function getUserByToken(token) {
if (!token) return null;
// 1. token 路径(带缓存)
if (token) {
const key = `tk:${token}`;
const cached = cacheGet(key);
if (cached !== undefined) return cached;
@@ -66,34 +66,9 @@ export async function getUserInfo(token, userId) {
// 无效 token 短缓存,保护 user 服务
cacheSet(key, null, NEGATIVE_TTL);
} catch (err) {
// 网络异常不缓存,走 userId 兜底
// 网络异常不缓存,避免把故障期内的问题固化 5 分钟
console.error("getUserByToken error:", err.message);
}
}
// 2. userId 兜底路径
if (userId) {
const key = `uid:${userId}`;
const cached = cacheGet(key);
if (cached !== undefined) return cached;
try {
const user = await lookup({ userId });
if (user) {
cacheSet(key, user, CACHE_TTL);
return user;
}
} catch (err) {
console.error("getUserById error:", err.message);
}
}
return null;
}
/**
* 仅按 token 获取用户信息(REST 鉴权用,无 userId 兜底)
*/
export async function getUserByToken(token) {
return getUserInfo(token, null);
}
+10 -4
View File
@@ -3,7 +3,7 @@ import http from 'http';
import { DBModel } from "./models/index.js";
import { chatTask } from "./agent/bots/bot-escort/task.js";
import { adminAgent } from "./agent/bots/bot-escort-admin/agent.js"
import { getUserInfo } from "./utils/user_service.js";
import { getUserByToken } from "./utils/user_service.js";
export default class WebSocketServerManager {
constructor(port = 8080) {
@@ -71,9 +71,10 @@ export default class WebSocketServerManager {
async _handleMessage(ws, data) {
try {
const raw = data.toString();
console.log('Received:', raw);
const msg = JSON.parse(raw);
// 只记录消息类型:正文含健康描述、token/userId 属敏感信息,不进日志
console.log('Received message type:', msg.type);
if (msg.type === 'ping') {
ws.send(JSON.stringify({ type: 'pong' }));
@@ -91,7 +92,12 @@ export default class WebSocketServerManager {
};
try {
const userInfo = await getUserInfo(msg.token, msg.userId);
// 身份只认 token:不再接受凭 userId 明文接入,防止冒充他人聊天
if (!msg.token) {
ws.send(JSON.stringify({ type: 'error', content: '缺少登录凭证,请重新登录' }));
return;
}
const userInfo = await getUserByToken(msg.token);
if (!userInfo) {
ws.send(JSON.stringify({ type: 'error', content: '登录已失效' }));
return;
@@ -122,7 +128,7 @@ export default class WebSocketServerManager {
return;
}
console.log('[Unknown type]', msg);
console.log('[Unknown message type]', msg.type);
} catch (e) {
console.error('Message parse error:', e);
ws.send(JSON.stringify({ type: 'error', content: 'Invalid JSON' }));