Compare commits
9
Commits
cd284e3459
...
168abd3133
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
168abd3133 | ||
|
|
1bff9819ab | ||
|
|
b3b51a36f3 | ||
|
|
139013ae4c | ||
|
|
1813998ac5 | ||
|
|
7fc91b75f7 | ||
|
|
0e81341bff | ||
|
|
4e9c19a18e | ||
|
|
c8a44ee1f9 |
@@ -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
|
|
||||||
@@ -2,3 +2,4 @@
|
|||||||
/logs/
|
/logs/
|
||||||
/data/
|
/data/
|
||||||
.env
|
.env
|
||||||
|
conf.json
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { tool } from 'langchain';
|
import { tool } from 'langchain';
|
||||||
import * as z from 'zod';
|
import * as z from 'zod';
|
||||||
|
import { checkUrlAllowed } from './url_guard.js';
|
||||||
|
|
||||||
export const httpGetTool = tool(
|
export const httpGetTool = tool(
|
||||||
async ({ url, headers = {}, timeout = 30000 }) => {
|
async ({ url, headers = {}, timeout = 30000 }) => {
|
||||||
try {
|
// SSRF 防护:仅允许 http/https 且目标不得为环回/内网地址
|
||||||
new URL(url);
|
const blocked = checkUrlAllowed(url);
|
||||||
} catch (e) {
|
if (blocked) {
|
||||||
return JSON.stringify({ success: false, error: `Invalid URL: ${url}` });
|
return JSON.stringify({ success: false, error: blocked });
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { tool } from "@langchain/core/tools";
|
import { tool } from "@langchain/core/tools";
|
||||||
import z from "zod";
|
import z from "zod";
|
||||||
|
import { checkUrlAllowed } from "./url_guard.js";
|
||||||
|
|
||||||
const httpPostTool = tool(
|
const httpPostTool = tool(
|
||||||
async ({ url, body, headers = {}, contentType = 'json', timeout = 30000 }) => {
|
async ({ url, body, headers = {}, contentType = 'json', timeout = 30000 }) => {
|
||||||
try {
|
// SSRF 防护:仅允许 http/https 且目标不得为环回/内网地址
|
||||||
new URL(url);
|
const blocked = checkUrlAllowed(url);
|
||||||
} catch (e) {
|
if (blocked) {
|
||||||
return { success: false, error: `Invalid URL: ${url}` };
|
return { success: false, error: blocked };
|
||||||
}
|
}
|
||||||
|
|
||||||
let requestBody;
|
let requestBody;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -20,6 +20,11 @@ class APP {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setupMiddleware() {
|
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(cors());
|
||||||
this.app.use(bodyParser());
|
this.app.use(bodyParser());
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -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
@@ -1,4 +1,5 @@
|
|||||||
import { DBModel } from "../models/index.js";
|
import { DBModel } from "../models/index.js";
|
||||||
|
import services from "../resource/services.js";
|
||||||
import ResponseUtil from "../utils/responseUtil.js";
|
import ResponseUtil from "../utils/responseUtil.js";
|
||||||
|
|
||||||
class HandlerEscortRecord {
|
class HandlerEscortRecord {
|
||||||
@@ -11,10 +12,40 @@ class HandlerEscortRecord {
|
|||||||
"attendant", "hospital", "schedule", "payment", "notes", "status",
|
"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 = {};
|
const picked = {};
|
||||||
for (const key of Object.keys(body || {})) {
|
for (const key of Object.keys(body || {})) {
|
||||||
const allowed = HandlerEscortRecord.RECORD_FIELDS.some(
|
const allowed = allowFields.some(
|
||||||
(f) => key === f || key.startsWith(f + ".")
|
(f) => key === f || key.startsWith(f + ".")
|
||||||
);
|
);
|
||||||
if (allowed) {
|
if (allowed) {
|
||||||
@@ -24,6 +55,14 @@ class HandlerEscortRecord {
|
|||||||
return picked;
|
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) {
|
async getRecords(ctx) {
|
||||||
return this._listRecords(ctx, false);
|
return this._listRecords(ctx, false);
|
||||||
@@ -36,7 +75,8 @@ class HandlerEscortRecord {
|
|||||||
|
|
||||||
async _listRecords(ctx, forceSelf) {
|
async _listRecords(ctx, forceSelf) {
|
||||||
try {
|
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;
|
const effectiveUserId = forceSelf ? ctx.state.user?._id : userId;
|
||||||
|
|
||||||
// status解析成数组
|
// status解析成数组
|
||||||
@@ -46,8 +86,8 @@ class HandlerEscortRecord {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const records = await DBModel.EscortRecord.findRecords({
|
const records = await DBModel.EscortRecord.findRecords({
|
||||||
page: parseInt(page),
|
page,
|
||||||
pageSize: parseInt(pageSize),
|
pageSize,
|
||||||
status: statusArray,
|
status: statusArray,
|
||||||
userId: effectiveUserId,
|
userId: effectiveUserId,
|
||||||
appointmentDate,
|
appointmentDate,
|
||||||
@@ -68,10 +108,11 @@ class HandlerEscortRecord {
|
|||||||
return ResponseUtil.badRequest(ctx, "缺少陪诊员ID");
|
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, {
|
const records = await DBModel.EscortRecord.findRecordsByAttendant(attendantId, {
|
||||||
page: parseInt(page),
|
page,
|
||||||
pageSize: parseInt(pageSize),
|
pageSize,
|
||||||
status,
|
status,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -92,6 +133,10 @@ class HandlerEscortRecord {
|
|||||||
if (!record) {
|
if (!record) {
|
||||||
return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404);
|
return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404);
|
||||||
}
|
}
|
||||||
|
// 归属校验:非本人订单按无权限处理,避免凭 id 遍历他人订单
|
||||||
|
if (!HandlerEscortRecord.canAccess(ctx.state.user, record)) {
|
||||||
|
return ResponseUtil.forbidden(ctx, "无权访问该订单");
|
||||||
|
}
|
||||||
|
|
||||||
return ResponseUtil.success(ctx, { record }, "查询成功");
|
return ResponseUtil.success(ctx, { record }, "查询成功");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -101,12 +146,39 @@ class HandlerEscortRecord {
|
|||||||
|
|
||||||
async createRecord(ctx) {
|
async createRecord(ctx) {
|
||||||
try {
|
try {
|
||||||
const record = ctx.request.body;
|
const body = ctx.request.body || {};
|
||||||
if (!record.userId) {
|
const userId = ctx.state.user?._id;
|
||||||
return ResponseUtil.badRequest(ctx, "缺少患者信息");
|
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 }, "创建成功");
|
return ResponseUtil.success(ctx, { record: newRecord }, "创建成功");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
return ResponseUtil.internalError(ctx, err.message);
|
return ResponseUtil.internalError(ctx, err.message);
|
||||||
@@ -122,7 +194,20 @@ class HandlerEscortRecord {
|
|||||||
return ResponseUtil.badRequest(ctx, "缺少记录ID");
|
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) {
|
if (!updatedRecord) {
|
||||||
return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404);
|
return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404);
|
||||||
}
|
}
|
||||||
@@ -145,6 +230,20 @@ class HandlerEscortRecord {
|
|||||||
return ResponseUtil.badRequest(ctx, "缺少状态值");
|
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, {
|
const updatedRecord = await DBModel.EscortRecord.updateRecord(id, {
|
||||||
"status": status,
|
"status": status,
|
||||||
});
|
});
|
||||||
@@ -167,6 +266,23 @@ class HandlerEscortRecord {
|
|||||||
return ResponseUtil.badRequest(ctx, "缺少记录ID");
|
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);
|
const deletedRecord = await DBModel.EscortRecord.deleteRecord(id);
|
||||||
|
|
||||||
if (!deletedRecord) {
|
if (!deletedRecord) {
|
||||||
|
|||||||
+124
-3
@@ -8,6 +8,17 @@ class HandlerHealthProfile {
|
|||||||
// 白名单:只允许写入 schema 定义的顶层字段(兼容 'profile.name' 等点号路径)
|
// 白名单:只允许写入 schema 定义的顶层字段(兼容 'profile.name' 等点号路径)
|
||||||
static PROFILE_FIELDS = ["userId", "profile", "location", "health"];
|
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) {
|
static pickFields(body) {
|
||||||
const picked = {};
|
const picked = {};
|
||||||
for (const key of Object.keys(body || {})) {
|
for (const key of Object.keys(body || {})) {
|
||||||
@@ -23,11 +34,12 @@ class HandlerHealthProfile {
|
|||||||
|
|
||||||
async getProfiles(ctx) {
|
async getProfiles(ctx) {
|
||||||
try {
|
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({
|
const result = await DBModel.HealthProfile.findProfiles({
|
||||||
page: parseInt(page),
|
page,
|
||||||
pageSize: parseInt(pageSize),
|
pageSize,
|
||||||
userId,
|
userId,
|
||||||
name,
|
name,
|
||||||
mobile,
|
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) {
|
async getProfileById(ctx) {
|
||||||
try {
|
try {
|
||||||
const { id } = ctx.params;
|
const { id } = ctx.params;
|
||||||
|
|||||||
+3
-1
@@ -5,6 +5,7 @@ import { EscortRecordSchema } from "./schema/escort_record.js"
|
|||||||
import { HealthProfileSchema } from "./schema/health_profile.js"
|
import { HealthProfileSchema } from "./schema/health_profile.js"
|
||||||
import { OrganizationSchema } from "./schema/organization.js"
|
import { OrganizationSchema } from "./schema/organization.js"
|
||||||
import { EmployeeSchema } from "./schema/employee.js"
|
import { EmployeeSchema } from "./schema/employee.js"
|
||||||
|
import { EmployeeReviewSchema } from "./schema/employee_review.js"
|
||||||
import config from '../config.js';
|
import config from '../config.js';
|
||||||
import logger from '../utils/logger.js';
|
import logger from '../utils/logger.js';
|
||||||
|
|
||||||
@@ -16,6 +17,7 @@ class MongoDBSchema {
|
|||||||
this.Organization = null;
|
this.Organization = null;
|
||||||
this.Employee = null;
|
this.Employee = null;
|
||||||
this.HealthProfile = null;
|
this.HealthProfile = null;
|
||||||
|
this.EmployeeReview = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
@@ -45,9 +47,9 @@ class MongoDBSchema {
|
|||||||
this.HealthProfile = this.dbConnection.model('health_profile', HealthProfileSchema)
|
this.HealthProfile = this.dbConnection.model('health_profile', HealthProfileSchema)
|
||||||
this.Organization = this.dbConnection.model('organization', OrganizationSchema)
|
this.Organization = this.dbConnection.model('organization', OrganizationSchema)
|
||||||
this.Employee = this.dbConnection.model('employee', EmployeeSchema)
|
this.Employee = this.dbConnection.model('employee', EmployeeSchema)
|
||||||
|
this.EmployeeReview = this.dbConnection.model('employee_review', EmployeeReviewSchema)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const DBModel = new MongoDBSchema()
|
const DBModel = new MongoDBSchema()
|
||||||
export { DBModel };
|
export { DBModel };
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,21 @@
|
|||||||
import mongoose from "mongoose";
|
import mongoose from "mongoose";
|
||||||
import { pinyin } from "pinyin-pro";
|
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
|
* Employee Schema
|
||||||
* 员工/人员信息,支持陪诊员、护士、医生、管理员等角色
|
* 员工/人员信息,支持陪诊员、护士、医生、管理员等角色
|
||||||
@@ -56,6 +71,19 @@ const EmployeeSchema = mongoose.Schema(
|
|||||||
entryDate: { type: Date, comment: "入职日期" },
|
entryDate: { type: Date, comment: "入职日期" },
|
||||||
workYears: { type: Number, default: 0, comment: "工作年限" },
|
workYears: { type: Number, default: 0, comment: "工作年限" },
|
||||||
qualification: { type: String, default: "", 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: "服务区域/城市列表" },
|
serviceAreas: { type: [String], default: [], comment: "服务区域/城市列表" },
|
||||||
specialties: { type: [String], default: [], comment: "擅长科室/领域" },
|
specialties: { type: [String], default: [], comment: "擅长科室/领域" },
|
||||||
languages: { 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: "接单数量" },
|
orderCount: { type: Number, default: 0, comment: "接单数量" },
|
||||||
isOnline: { type: Boolean, default: false, 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 = {}) {
|
EmployeeSchema.statics.findOnlineAttendants = async function (options = {}) {
|
||||||
const { page = 1, pageSize = 20 } = options;
|
const { page = 1, pageSize = 20 } = options;
|
||||||
@@ -206,7 +240,7 @@ EmployeeSchema.statics.findOnlineAttendants = async function (options = {}) {
|
|||||||
status: "active",
|
status: "active",
|
||||||
"attendantInfo.isOnline": true,
|
"attendantInfo.isOnline": true,
|
||||||
})
|
})
|
||||||
.sort({ "attendantInfo.rating": -1 })
|
.sort({ "work.workYears": -1, "attendantInfo.orderCount": -1 })
|
||||||
.skip(skip)
|
.skip(skip)
|
||||||
.limit(pageSize)
|
.limit(pageSize)
|
||||||
.exec();
|
.exec();
|
||||||
|
|||||||
@@ -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 };
|
||||||
@@ -7,7 +7,7 @@ import mongoose from "mongoose";
|
|||||||
*
|
*
|
||||||
* 用于记录和管理陪诊服务全流程数据,包含以下主要分类:
|
* 用于记录和管理陪诊服务全流程数据,包含以下主要分类:
|
||||||
* - 基础信息:userId(订单提交用户)、healthProfileId(关联健康档案ID)
|
* - 基础信息:userId(订单提交用户)、healthProfileId(关联健康档案ID)
|
||||||
* - 患者信息:patient(姓名、电话、性别、出生年月、身份证号)
|
* - 患者信息:patient(名单字段快照:姓名、电话、性别、出生年月)
|
||||||
* - 陪诊服务:escort(服务ID、服务名称)
|
* - 陪诊服务:escort(服务ID、服务名称)
|
||||||
* - 就诊信息:hospital(医院省份、名称、地址、科室、医生、病历号)
|
* - 就诊信息:hospital(医院省份、名称、地址、科室、医生、病历号)
|
||||||
* - 时间安排:schedule(预约时间、开始时间、结束时间、时长)
|
* - 时间安排:schedule(预约时间、开始时间、结束时间、时长)
|
||||||
@@ -32,7 +32,7 @@ const EscortRecordSchema = mongoose.Schema(
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 关联健康档案ID(可为空)
|
* 关联健康档案ID(下单必填:订单必须关联就诊人)
|
||||||
* @type {ObjectId}
|
* @type {ObjectId}
|
||||||
* @ref health_profile
|
* @ref health_profile
|
||||||
*/
|
*/
|
||||||
@@ -44,16 +44,16 @@ const EscortRecordSchema = mongoose.Schema(
|
|||||||
},
|
},
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 患者信息 - 就诊患者的基本信息
|
* 患者信息 - 下单当刻从健康档案拷贝的「名单字段」快照(仅姓名/电话/性别/出生年月)。
|
||||||
|
* 档案可随时编辑或删除,历史订单至少需要认出"是谁、怎么联系",故名单快照与 healthProfileId 并存:
|
||||||
|
* 展示时以档案最新数据为准,档案缺失时回落到这份快照。
|
||||||
|
* 体征(身高/体重)与证件号不进快照——它们只有详情页兜底在用,详情页本来就实时拉档案
|
||||||
*/
|
*/
|
||||||
patient: {
|
patient: {
|
||||||
name: { type: String, default: "", comment: "患者姓名" },
|
name: { type: String, default: "", comment: "患者姓名" },
|
||||||
mobile: { type: String, default: "", index: true, comment: "患者联系电话" },
|
mobile: { type: String, default: "", index: true, comment: "患者联系电话" },
|
||||||
sex: { type: String, enum: ["male", "female"], comment: "患者性别" },
|
sex: { type: String, enum: ["male", "female"], comment: "患者性别" },
|
||||||
birth: { type: String, default: "", comment: "患者出生年月(YYYY-MM-DD)" },
|
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
@@ -7,11 +7,15 @@ const agreement = {
|
|||||||
title: "服务注意事项",
|
title: "服务注意事项",
|
||||||
type: "list",
|
type: "list",
|
||||||
items: [
|
items: [
|
||||||
"仅支持平台已开通地区医院的代问诊服务,仅限当次就诊门诊当天使用。",
|
"平台提供全天陪诊、半天陪诊、夜间陪诊、代问诊、检查预约、出入院代办、跑腿服务、取药快递等服务,各项服务的内容与范围以其服务页面公示为准。",
|
||||||
|
// 4.2.4.2 收费标准须通过服务平台公示、实收应符合公示;9.2.3 服务合同应含服务费用及明细
|
||||||
|
"各项服务费用以服务页面公示的收费标准为准,未公示的项目不另行收取。",
|
||||||
"全程陪诊半天不超过4小时,全天不超过8小时。",
|
"全程陪诊半天不超过4小时,全天不超过8小时。",
|
||||||
"预约成功后,平台将提前电话联系确认就诊资料及时间,并以短信或微信通知预约人。",
|
"预约成功后,平台将提前电话联系确认就诊资料及时间,并以短信或微信通知预约人。",
|
||||||
"陪诊人员只代客户问诊并转达医嘱,不负责诊断和治疗。需要时陪诊员会让医生和客户直接通话。最终医疗听从医嘱为准。",
|
"陪诊师只代您向医生陈述病情并转达医嘱,不负责诊断和治疗。需要时陪诊师会安排医生与您直接通话,最终以医嘱为准。",
|
||||||
"先预约,电话确认,微信支付,分配陪诊人员,服务开始。",
|
// 流程与 services.js 各服务的 flow 一致(同 guide.js)。
|
||||||
|
// 原列的「选择陪诊师」不在任何服务的 flow 里,且该选择功能尚未上线,故一并去掉
|
||||||
|
"服务流程:预约、沟通信息、支付费用、准备资料、全程陪诊、服务完成。",
|
||||||
"医生停诊等特殊情况,请及时联系客服处理。"
|
"医生停诊等特殊情况,请及时联系客服处理。"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -20,14 +24,14 @@ const agreement = {
|
|||||||
title: "预约与退订",
|
title: "预约与退订",
|
||||||
type: "mixed",
|
type: "mixed",
|
||||||
items: [
|
items: [
|
||||||
"陪诊日期为周一至周日,须在前一个工作日20:00前预约;",
|
"服务日期为周一至周日,请至少提前1天预约;个别服务对预约时限另有要求,以其服务页面公示为准。",
|
||||||
"如需退订,请在就诊前一个工作日联系客服;逾期退订将扣取违约金:"
|
"如需改期或取消,请至少提前1天联系客服;未在时限内提出的,按下表扣除违约金:"
|
||||||
],
|
],
|
||||||
table: {
|
table: {
|
||||||
headers: ["退订时间", "扣款比例"],
|
headers: ["退订时间", "扣款比例"],
|
||||||
rows: [
|
rows: [
|
||||||
["6小时以内", "20%"],
|
["不足1天", "20%"],
|
||||||
["1小时以内", "50%"],
|
["不足1小时", "50%"],
|
||||||
["服务开始后", "100%"]
|
["服务开始后", "100%"]
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -37,8 +41,12 @@ const agreement = {
|
|||||||
title: "服务范围",
|
title: "服务范围",
|
||||||
type: "list",
|
type: "list",
|
||||||
items: [
|
items: [
|
||||||
"包含:诊前问询、提醒、建档取号、代缴费用、排队候诊、问诊沟通、代取药品、解读医嘱、回寄物品。",
|
// 「回寄物品」过宽:国标 8.1 只允许「领取药物和检查结果,必要时代发快递」,
|
||||||
"不包含:急诊就医、插队加号、医院陪护、急诊留观陪护。"
|
// 故收窄到这两类;「开药」类表述一律不出现,只写「代取药品」
|
||||||
|
"包含:诊前问询、提醒、建档取号、代缴费用、排队候诊、问诊沟通、代取药品、领取检查结果并按需代发快递、记录并转达医嘱。",
|
||||||
|
"不包含:急诊就医、插队加号、医院陪护、急诊留观陪护。",
|
||||||
|
// 原本写在第 1 节首条,会被读成「本协议只覆盖代问诊」;移到服务范围里,只限定代问诊这一项
|
||||||
|
"代问诊服务仅支持平台已开通地区的医院,且只限当次就诊门诊当天使用。"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -47,31 +55,38 @@ const agreement = {
|
|||||||
type: "list",
|
type: "list",
|
||||||
items: [
|
items: [
|
||||||
"陪诊服务仅为就医陪伴,不涉及医疗诊断和治疗,用户自行承担医疗决策责任。",
|
"陪诊服务仅为就医陪伴,不涉及医疗诊断和治疗,用户自行承担医疗决策责任。",
|
||||||
"用户应文明交流,如有恶意中伤、人身攻击等不当行为,陪诊人员有权终止服务且不予退款。",
|
"用户应文明交流,如有恶意中伤、人身攻击等不当行为,陪诊师有权终止服务且不予退款。",
|
||||||
"因用户隐瞒病情或提供虚假信息导致的后果,由用户自行承担。",
|
"因用户隐瞒病情或提供虚假信息导致的后果,由用户自行承担。",
|
||||||
"因不可抗力导致服务无法进行的,双方互不承担责任,平台可协助改期或退款。"
|
"因不可抗力导致服务无法进行的,双方互不承担责任,平台可协助改期或退款。",
|
||||||
|
// 9.2.3 要求服务合同写明服务终止条件
|
||||||
|
"服务完成、用户主动取消,或因上述情形终止服务的,本次服务关系即行终止。"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
number: 5,
|
number: 5,
|
||||||
title: "争议解决",
|
title: "隐私保护",
|
||||||
type: "paragraph",
|
type: "list",
|
||||||
content: "本协议适用《中华人民共和国民法典》。如发生争议,双方协商解决;协商不成的,可向平台所在地人民法院提起诉讼。"
|
items: [
|
||||||
|
// 4.2.2.1 与 9.2.3:个人信息按 GB/T 35273 管理,合同须含隐私保护条款
|
||||||
|
"平台按照《信息安全技术 个人信息安全规范》(GB/T 35273)管理您的个人信息,仅在提供服务所必需的范围内收集和使用就诊人姓名、联系方式、就诊资料等信息。",
|
||||||
|
// 4.2.2.2 授权同意是收集与使用的前提
|
||||||
|
"收集和使用上述信息前,平台会取得您或就诊人法定监护人的授权同意。",
|
||||||
|
"您的个人信息仅用于预约、沟通、办理就医事项与服务回访,不会向无关第三方提供;法律法规另有规定或经您同意的除外。",
|
||||||
|
"陪诊师仅可接触本次服务所必需的信息,并对服务过程中知悉的病情、病历等信息保密。",
|
||||||
|
// 4.2.2.3 防止信息泄露、丢失
|
||||||
|
"服务结束后,平台按法律法规要求留存和处置相关信息,并采取措施防止信息泄露、丢失。"
|
||||||
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
number: 6,
|
number: 6,
|
||||||
title: "联系我们",
|
title: "争议解决",
|
||||||
type: "contact",
|
type: "paragraph",
|
||||||
contacts: [
|
content: "本协议适用《中华人民共和国民法典》。如发生争议,双方协商解决;协商不成的,可向平台所在地人民法院提起诉讼。"
|
||||||
{ icon: "mail", text: "service@huashengtec.com", action: "copyEmail" },
|
|
||||||
{ icon: "call", text: "18618162956", action: "callPhone" },
|
|
||||||
{ icon: "calendar-1", text: "周一至周日 08:00-20:00", action: null }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
footer: [
|
footer: [
|
||||||
"依据《中华人民共和国民法典》制定",
|
"依据《中华人民共和国民法典》制定",
|
||||||
"更新日期:2026年5月28日",
|
"更新日期:2026年10月6日",
|
||||||
"© 2026 北京奕华盛科技"
|
"© 2026 北京奕华盛科技"
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|||||||
+282
-149
@@ -2,76 +2,90 @@ const services = [
|
|||||||
{
|
{
|
||||||
id: 1,
|
id: 1,
|
||||||
title: '全天陪诊',
|
title: '全天陪诊',
|
||||||
subtitle: '八小时服务/次,专业陪诊师全程陪同',
|
// 时段用「—」+「、」:括号内不断句,读起来是一个整体;列表页说明句按原文整段展示
|
||||||
|
subtitle: '八小时服务/次(早8点—中午12点、下午1点—晚上5点),专业陪诊师全程陪同就医。',
|
||||||
price: '500.00',
|
price: '500.00',
|
||||||
image: '/images/pz1.jpg',
|
image: '/images/pz1.jpg',
|
||||||
icon: 'user-business',
|
icon: 'user-business',
|
||||||
tag: '',
|
tag: '',
|
||||||
description: '全天陪诊服务提供8小时的专业陪诊服务,陪诊师将全程陪同您完成就医流程。包括挂号、候诊、就诊、检查、取药等环节的协助与引导。我们的陪诊师均经过专业培训,熟悉各大医院流程,能够有效缩短就医时间,提升就医体验。',
|
// 只讲服务对象,不重复 subtitle 已写的时长与陪同方式(详情页两段紧挨着显示)
|
||||||
|
// 三类人群对应 GB/T 47801—2026 3.3 服务对象的「行动、认知、地域差异」三种成因;
|
||||||
|
// 原「需要专业指导」会被读成医疗指导,触及 8.2「不应提供医疗建议」
|
||||||
|
description: '主要服务行动不便、认知或沟通不便、异地就医不熟悉医院的用户。就诊项目较多、耗时长的情况,建议选择本服务。',
|
||||||
flow: [{
|
flow: [{
|
||||||
title: '在线预约',
|
title: '预约',
|
||||||
desc: '选择服务类型,填写就诊信息,提交订单'
|
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '电话确认',
|
// 9.3.1.1 服务前应确认家属陪同、行动能力与特殊需求、服务人员信息、应急联系人
|
||||||
desc: '客服人员将在30分钟内与您电话确认订单详情'
|
// 科室限定「由您确定」,避免被读成 8.2 第4条「代替服务对象选择治疗方案」
|
||||||
},
|
title: '沟通信息',
|
||||||
{
|
desc: '与陪诊师确认就诊医院、就诊时间、就诊人信息、行动能力与特殊需求'
|
||||||
title: '分配陪诊师',
|
|
||||||
desc: '根据您的就诊医院和时间,匹配专业陪诊师'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '支付费用',
|
title: '支付费用',
|
||||||
desc: '通过微信支付服务费用'
|
desc: '通过微信支付服务费用'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '医院集合',
|
title: '准备资料',
|
||||||
desc: '陪诊师按约定时间在医院指定地点与您会合'
|
desc: '根据您要陪诊的项目,准备必要的资料,如身份证、就诊卡、检查报告、问诊诉求等'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '全程陪诊',
|
title: '全程陪诊',
|
||||||
desc: '陪诊师协助完成挂号、就诊、检查、取药等流程'
|
desc: '陪诊师协助完成挂号、就诊、检查、取药等流程'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 9.3.3 服务后:清点材料并逐项签收(9.3.3.1)、告知后续材料领取方式(9.3.3.2)、
|
||||||
|
// 提醒遵医嘱(9.3.3.3)、对服务过程作记录和总结(9.3.3.4)
|
||||||
title: '服务完成',
|
title: '服务完成',
|
||||||
desc: '确认服务完成,可对陪诊师进行评价'
|
desc: '清点病历、检查报告、药品等材料,确认本次就诊所有项目已完成。提醒后续就诊时间、注意事项与医嘱。'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
notices: [
|
notices: [
|
||||||
'服务时长为8小时,超出时间按每小时50元计费',
|
'服务时长为8小时,超出时间按每小时80元计费',
|
||||||
'请提前1天预约',
|
'请提前1天预约',
|
||||||
'如需取消订单,请提前联系客服',
|
// 9.1.1 应提供服务变更手续,故「改期」与「取消」合并为一条。
|
||||||
'服务不包含挂号费、检查费、药费等医疗费用',
|
// 时限全站统一为「至少提前1天」,与协议违约金的起扣档(不足1天扣20%)对齐;
|
||||||
|
// 原「请提前联系客服」没给时间,9.1.1 要求公示变更申请时限,等于缺公示
|
||||||
|
'如需改期或取消订单,请至少提前1天联系客服',
|
||||||
|
'服务费不包含挂号费、检查费、药费等医疗费用',
|
||||||
|
// 8.2 不应签署具有法律效力的医疗文书:陪同现场最容易被要求「帮忙签个字」,
|
||||||
|
// 尤其患者是老人或家属不在场时,所以把边界写死,不留解释空间
|
||||||
|
'具有法律效力的医疗文书(手术、麻醉、特殊检查知情同意书等)需您本人或家属签署,陪诊师不代签',
|
||||||
|
// 9.1.1 与 9.2.3 要求告知投诉渠道
|
||||||
|
'如对服务有疑问或建议,可在「联系我们」页面反馈'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 2,
|
id: 2,
|
||||||
title: '半天陪诊',
|
title: '半天陪诊',
|
||||||
subtitle: '四小时服务/次,适合简单就诊',
|
// 时段写成「上午8点—中午12点 或 下午1点—晚上5点」:一是与 id 1 全天陪诊的两个半天完全对齐
|
||||||
|
// (全天 8 小时就是这两段之和);二是「不包含夜间」由时段本身说清,不必反向声明。
|
||||||
|
// 列表页说明句直接放这段 subtitle 原文(会折两行,属预期)
|
||||||
|
subtitle: '四小时服务/次(上午8点—中午12点 或 下午1点—晚上5点),专业陪诊师全程陪同就医。',
|
||||||
price: '300.00',
|
price: '300.00',
|
||||||
image: '/images/pz1.jpg',
|
image: '/images/pz1.jpg',
|
||||||
icon: 'usergroup',
|
icon: 'usergroup',
|
||||||
tag: '热门',
|
tag: '热门',
|
||||||
description: '半天陪诊服务提供4小时的专业陪诊服务,适合就诊流程相对简单的客户。陪诊师将协助您完成挂号、就诊、简单检查及取药等环节,让您的就医过程更加顺畅。',
|
// 服务对象句与 id 1 一致(同属陪同就医,对象成因相同);首句用「项目较少、半天内完成」
|
||||||
|
// 与 id 1 的「项目较多、耗时长」形成对照,避免两个详情页读起来没有区分度
|
||||||
|
description: '适合就诊项目较少、预计半天内能完成的情况。主要服务行动不便、认知或沟通不便、异地就医不熟悉医院的用户。',
|
||||||
|
// 流程与 id 1 逐字相同,国标条目注释只在 id 1 保留一处,避免两处维护同一段说明
|
||||||
flow: [{
|
flow: [{
|
||||||
title: '在线预约',
|
title: '预约',
|
||||||
desc: '选择服务类型,填写就诊信息,提交订单'
|
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '电话确认',
|
title: '沟通信息',
|
||||||
desc: '客服人员将在30分钟内与您电话确认订单详情'
|
desc: '与陪诊师确认就诊医院、就诊时间、就诊人信息、行动能力与特殊需求'
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '分配陪诊师',
|
|
||||||
desc: '根据您的就诊医院和时间,匹配专业陪诊师'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '支付费用',
|
title: '支付费用',
|
||||||
desc: '通过微信支付服务费用'
|
desc: '通过微信支付服务费用'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '医院集合',
|
title: '准备资料',
|
||||||
desc: '陪诊师按约定时间在医院指定地点与您会合'
|
desc: '根据您要陪诊的项目,准备必要的资料,如身份证、就诊卡、检查报告、问诊诉求等'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '全程陪诊',
|
title: '全程陪诊',
|
||||||
@@ -79,59 +93,92 @@ const services = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '服务完成',
|
title: '服务完成',
|
||||||
desc: '确认服务完成,可对陪诊师进行评价'
|
desc: '清点病历、检查报告、药品等材料,确认本次就诊所有项目已完成。提醒后续就诊时间、注意事项与医嘱。'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
notices: [
|
notices: [
|
||||||
'服务时长为4小时,超出时间按每小时50元计费',
|
// 全站超时统一 80 元/h(原 50 元/h);本项自身均价 300/4h=75 元/h,超时单价略高于均价
|
||||||
'请提前1天预约,紧急订单需额外支付加急费',
|
'服务时长为4小时,超出时间按每小时80元计费',
|
||||||
'如需取消订单,请提前4小时联系客服',
|
// 原「紧急订单需额外支付加急费」只有收费名目没有金额,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,
|
id: 3,
|
||||||
|
// 患者不到场的场景。国标里没有「代问诊」这个动作,最接近的是 7.1.4「协助医患沟通」
|
||||||
|
// 与 8.1「记录医嘱」,所以动作词一律落在「陈述病情、转达诉求、记录医嘱、转达医嘱」上。
|
||||||
|
// 原「详细记录医生的诊断意见和用药建议」会被读成 8.2「判读检查结果、提供医疗建议」,
|
||||||
|
// 且与本项 notices 里「不提供医疗建议」自相矛盾,故统一为「如实记录医嘱并转达」
|
||||||
title: '代问诊',
|
title: '代问诊',
|
||||||
subtitle: '代您到医院与医生进行问诊',
|
// 补「记录并转达」这一对动作,与 description、flow 同一口径
|
||||||
price: '260.00',
|
subtitle: '陪诊师代您向医生陈述病情、记录并转达医嘱,无需亲自到场。',
|
||||||
|
price: '280.00',
|
||||||
image: '/images/wz1.jpg',
|
image: '/images/wz1.jpg',
|
||||||
icon: 'heart',
|
icon: 'heart',
|
||||||
tag: '',
|
tag: '',
|
||||||
description: '代问诊服务适用于无法亲自到医院的客户。我们的陪诊师将携带您的病历资料,代您与医生进行面诊,详细记录医生的诊断意见和用药建议,并及时反馈给您。',
|
// 4.2.2.2 收集与使用服务对象信息须经授权同意,本项全程由他人代为出行,前提必须写明;
|
||||||
|
// 末句对应 8.2 第 4 条「不应代替服务对象或其家属选择或同意治疗方案」
|
||||||
|
description: '代问诊服务适用于无法亲自到医院的用户。经您同意并授权后,陪诊师携带您的病历资料,代您向医生陈述病情、转达就诊诉求,如实记录医嘱并完整转达给您。涉及检查、用药与治疗方案的选择,由医生直接与您或家人沟通,陪诊师不代为决定。',
|
||||||
flow: [{
|
flow: [{
|
||||||
title: '在线预约',
|
title: '预约',
|
||||||
desc: '选择代问诊服务,填写就诊信息和病史'
|
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '资料准备',
|
title: '沟通信息',
|
||||||
desc: '将病历、检查报告等资料拍照上传或快递给陪诊师'
|
desc: '与陪诊师确认就诊医院、就诊时间、就诊人信息、行动能力与特殊需求'
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '电话确认',
|
|
||||||
desc: '客服确认资料完整性和就诊需求'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '支付费用',
|
title: '支付费用',
|
||||||
desc: '通过微信支付服务费用'
|
desc: '通过微信支付服务费用'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
title: '准备资料',
|
||||||
|
desc: '根据您要陪诊的项目,准备必要的资料,如身份证、就诊卡、检查报告、问诊诉求等'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 8.1「记录医嘱」+ 8.2 不得选择性传递:写明「如实、完整」;
|
||||||
|
// 「需要时」替代原「可能随时」——是否需电话由就诊现场决定,不是随时打扰
|
||||||
title: '代问诊',
|
title: '代问诊',
|
||||||
desc: '陪诊师携带资料到医院代您问诊'
|
desc: '陪诊师携带您的病历资料,代您向医生陈述病情、转达就诊诉求,如实记录医嘱并完整转达给您。需要时,可安排您与医生电话沟通。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '结果反馈',
|
// 不新造「单独的取药费用」这类未公示费项(9.1.1 收费须公示、9.2.5 额外费用须事先说明),
|
||||||
desc: '陪诊师将医生诊断意见和用药建议详细反馈给您'
|
// 改为指向已有服务:检查走「检查预约」(id 4),取药走「取药快递」(id 8),费用以对应服务页为准
|
||||||
|
title: '后续事项',
|
||||||
|
desc: '如医生开具检查,可另行预约「检查预约」服务办理;如需取药,可另行预约「取药快递」服务代取并邮寄。相关费用以对应服务页面为准。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 9.3.3 服务后:清点材料并逐项签收(9.3.3.1)、提醒遵医嘱(9.3.3.3)
|
||||||
title: '服务完成',
|
title: '服务完成',
|
||||||
desc: '确认服务完成,可对陪诊师进行评价'
|
desc: '清点病历、检查报告、药品等材料,确认本次就诊所有项目已完成。提醒后续就诊时间、注意事项与医嘱。'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
notices: [
|
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',
|
image: '/images/qbg1.jpg',
|
||||||
icon: 'chart-bar',
|
icon: 'chart-bar',
|
||||||
tag: '',
|
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: [{
|
flow: [{
|
||||||
title: '在线预约',
|
title: '预约',
|
||||||
desc: '选择检查预约服务,填写检查类型和期望时间'
|
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '资料审核',
|
// 这一步只干一件事:把检查申请单拿到手——已有单就上传,没有就先挂号、再去找医生开单。
|
||||||
desc: '上传医生开具的检查申请单'
|
// 「行动能力与特殊需求」是陪同类服务的项,代办预约不涉及,故删。
|
||||||
|
// 「请先挂号」由您自己完成(挂号含选科选医,属 8.2 第4条红线),陪诊师只做挂号之后的陪同开单;
|
||||||
|
// 检查项目由医生根据病情确定,陪诊师不代为选择
|
||||||
|
title: '沟通信息',
|
||||||
|
desc: '与陪诊师确认就诊医院、就诊人信息、检查项目与期望时间,并上传医生开具的检查申请单。尚未开单的,请您先挂号,由陪诊师协助您找医生开具检查申请单。'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '支付费用',
|
title: '支付费用',
|
||||||
desc: '通过微信支付服务费用'
|
desc: '通过微信支付服务费用'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 原「预约办理」+「确认通知」合为一步
|
||||||
title: '预约办理',
|
title: '预约办理',
|
||||||
desc: '工作人员代为联系医院预约检查时间'
|
desc: '陪诊师代为联系医院预约检查时间,并把预约到的具体时间通知您'
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '确认通知',
|
|
||||||
desc: '将预约成功的具体时间通知您'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '现场协助',
|
|
||||||
desc: '如需,可额外购买陪诊服务协助检查'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 原话「确认服务完成」是复述标题的空话,改成交代检查前准备才有内容
|
||||||
title: '服务完成',
|
title: '服务完成',
|
||||||
desc: '确认服务完成'
|
desc: '确认预约结果已通知到您,并交代该项检查的注意事项与准备要求。'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
notices: [
|
notices: [
|
||||||
'需要提供医生开具的检查申请单',
|
'请提前1天预约',
|
||||||
|
// 与流程第 2 步同口径:不是"必须先有单"——没有现成的单也能办,前提是您先挂号
|
||||||
|
'检查申请单需由医生开具,尚未开单的请先挂号,由陪诊师协助您找医生开单',
|
||||||
'部分检查项目有特殊的身体准备要求,请提前了解',
|
'部分检查项目有特殊的身体准备要求,请提前了解',
|
||||||
'预约成功后如需改期,请提前24小时联系',
|
// 8.2 不应签署具有法律效力的医疗文书:增强检查、胃镜等检查本身要签知情同意书,
|
||||||
'服务费用不包含检查费用'
|
// 本项只代约时间,签字环节仍需本人或家属到场
|
||||||
|
'具有法律效力的医疗文书(检查知情同意书等)需您本人或家属签署,陪诊师不代签',
|
||||||
|
// 原流程里「现场协助:如需,可额外购买陪诊服务协助检查」移到此处:
|
||||||
|
// 它是另一项服务的入口,不是本项流程的一环;写明具体服务名,避免浮现未公示的「陪诊服务」费项
|
||||||
|
'检查当天如需有人陪同,可另行预约「半天陪诊」或「全天陪诊」服务',
|
||||||
|
// 9.1.1 应提供服务变更手续。本项原是全站唯一例外(取消检查预约要同时退掉医院号源,
|
||||||
|
// 提前量长于当时全站统一的 4 小时),全站时限改为 1 天后例外消失,与其余服务同口径
|
||||||
|
'如需改期或取消订单,请至少提前1天联系客服',
|
||||||
|
// 流程里出现「挂号」后,排除项必须带上挂号费,否则会被读成 200 元含挂号
|
||||||
|
'服务费不包含挂号费、检查费用,相关费用请在就诊医院现场缴纳',
|
||||||
|
// 9.1.1 与 9.2.3 要求告知投诉渠道
|
||||||
|
'如对服务有疑问或建议,可在「联系我们」页面反馈'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 5,
|
id: 5,
|
||||||
|
// 代办类:只跑手续,不参与诊疗决策。8.2 禁止「签署具有法律效力的医疗文书」,
|
||||||
|
// 故入院登记、知情同意书等一律写明需本人或家属签署,陪诊师不代签
|
||||||
title: '出入院代办',
|
title: '出入院代办',
|
||||||
subtitle: '出入院手续代办',
|
// 原「出入院手续代办」比标题只多两个字,等于把标题复读一遍;按 id 1/2/4 的做法,
|
||||||
price: '280.00',
|
// 副标题要给出标题没有的信息,这里给的是「代办事项清单」
|
||||||
|
subtitle: '入院登记、医保手续、出院结算,均可代为办理',
|
||||||
|
price: '300.00',
|
||||||
image: '/images/yy.jpg',
|
image: '/images/yy.jpg',
|
||||||
icon: 'user-checked',
|
icon: 'user-checked',
|
||||||
tag: '',
|
tag: '',
|
||||||
description: '出入院代办服务帮助您办理医院的入院和出院手续。包括床位预约、入院登记、医保手续办理、出院结算等,让您或家人能够更专注于治疗本身。',
|
// 4.2.2.2 办理出入院要收集身份证、医保卡信息,须经授权同意;首句补服务对象,
|
||||||
|
// 与 id 1/2/3/4 一致;原末句「让您或家人能够更专注于治疗本身」是宣传腔,删
|
||||||
|
description: '经您授权同意后,陪诊师代您办理入院登记、医保手续、出院结算等出入院手续。',
|
||||||
|
// 步骤名与顺序对齐 id 1/2/3(预约 → 沟通信息 → 支付费用 → 准备资料 → 服务动作 → 服务完成);
|
||||||
|
// 原 7 步里的「结果反馈」与「服务完成:确认服务完成」是同一件事的两头,合为一步
|
||||||
flow: [{
|
flow: [{
|
||||||
title: '在线预约',
|
title: '预约',
|
||||||
desc: '选择出入院代办服务,填写相关信息'
|
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '资料准备',
|
// 原名「电话确认」,与 id 1-4 的「沟通信息」是同一件事,统一命名;
|
||||||
desc: '准备身份证、医保卡、押金等相关资料'
|
// 执行人由「客服」改为「陪诊师」——服务执行人一律用陪诊师,客服只管订单变更与投诉
|
||||||
},
|
title: '沟通信息',
|
||||||
{
|
desc: '与陪诊师确认入院或出院、就诊医院与科室、代办事项和所需资料'
|
||||||
title: '电话确认',
|
|
||||||
desc: '客服确认代办事项和所需资料'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '支付费用',
|
title: '支付费用',
|
||||||
desc: '通过微信支付服务费用'
|
desc: '通过微信支付服务费用'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 原「准备身份证、医保卡、押金等相关资料」把押金当成资料;押金是钱,不是材料
|
||||||
|
title: '准备资料',
|
||||||
|
desc: '准备身份证、医保卡等证件,并按医院要求备好住院押金'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
// 与 notices 的「不代签法律效力文书」同口径,在流程里再说一次,避免只看流程的用户漏掉
|
||||||
title: '代办服务',
|
title: '代办服务',
|
||||||
desc: '工作人员代为办理出入院手续'
|
desc: '陪诊师代为办理入院登记、医保手续、出院结算等手续;需您本人或家属签署的文书不代签'
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '结果反馈',
|
|
||||||
desc: '将办理结果和相关单据反馈给您'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 原「结果反馈」+「服务完成(空话)」合为一步,并交代交付物,才对得起一个收尾步骤
|
||||||
title: '服务完成',
|
title: '服务完成',
|
||||||
desc: '确认服务完成'
|
desc: '将办理结果、出院小结及相关单据交付给您,确认本次代办完成。'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
notices: [
|
notices: [
|
||||||
'需要提供患者身份证、医保卡等证件',
|
'请提前1天预约',
|
||||||
'入院代办需提前了解医院床位情况',
|
// 4.2.2.2 授权同意
|
||||||
'服务费用不包含住院押金和医疗费用',
|
'需经您授权同意后,陪诊师方可代您办理出入院手续',
|
||||||
'部分手续可能需要患者本人签字'
|
'请提供患者身份证、医保卡等证件',
|
||||||
|
'入院代办需提前确认医院床位情况',
|
||||||
|
// 句式与 id 1/2/3 统一,并把钱交给谁写清
|
||||||
|
'服务费不包含住院押金和医疗费用,押金与住院费请在医院缴纳',
|
||||||
|
// 8.2:具有法律效力的医疗文书不代签;比原「部分手续可能需要患者本人签字」说死了边界
|
||||||
|
'具有法律效力的医疗文书(如入院登记、知情同意书等)需您本人或家属签署,陪诊师不代签',
|
||||||
|
// 9.1.1 应提供服务变更手续
|
||||||
|
'如需改期或取消订单,请至少提前1天联系客服',
|
||||||
|
// 9.1.1 与 9.2.3 要求告知投诉渠道
|
||||||
|
'如对服务有疑问或建议,可在「联系我们」页面反馈'
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 6,
|
id: 6,
|
||||||
title: '跑腿服务',
|
title: '跑腿服务',
|
||||||
subtitle: '送资料/代排队/买药/取报告等一小时陪诊服务',
|
// 原「…等一小时陪诊服务」把跑腿说成陪诊,与列表页把它归入「就医代办」组自相矛盾;
|
||||||
|
// 本项是按次计时的代办,故直接写出时长「2小时/次」(列表页规格签同步改为 2小时/次)。
|
||||||
|
// 「买药」改为「取药」:国标 8.1 允许的是「代买或代取基础医疗用品」(示例为轮椅、陪护床、口罩)
|
||||||
|
// 与「领取药物」,药品只有"凭处方代领"这一条路;写成「买药」会被读成代为选购药品,
|
||||||
|
// 擦 8.2「提供医疗建议」「代替服务对象选择用药方案」,也与 id 8「代取药品」的口径打架
|
||||||
|
subtitle: '送资料/代排队/取药/取报告等,2小时/次',
|
||||||
price: '160.00',
|
price: '160.00',
|
||||||
image: '/images/pt.jpg',
|
image: '/images/pt.jpg',
|
||||||
icon: 'usergroup',
|
icon: 'usergroup',
|
||||||
tag: '',
|
tag: '',
|
||||||
description: '跑腿服务提供各类医院相关代办事项,包括代排队、代买药、代取报告、代送标本等。适合工作繁忙或行动不便的客户,让您足不出户即可完成医院相关事务。',
|
// 称谓由「客户」改为「用户」,与 id 1-5/7/8 统一;补服务对象句,结构对齐 id 1/2/7;
|
||||||
|
// 原末句「让您足不出户即可完成医院相关事务」是宣传腔,且与首句同义,删;
|
||||||
|
// 「代买药」同步改为「代取药品」——与 id 8 同一动作只留一个说法;
|
||||||
|
// 「代送标本」改为 8.1 原词「样本送检」:标准术语是"样本"+"送检",
|
||||||
|
// 用原词才能在出纠纷时主张落在 8.1 列举范围内
|
||||||
|
description: '主要服务工作繁忙不便请假、或行动不便的用户。可代办代排队、代取药品、代取报告、样本送检等医院相关事项,本人不必到场。',
|
||||||
|
// 步骤名与 id 1-8 对齐:「在线预约」→「预约」、「电话确认」→「沟通信息」、
|
||||||
|
// 「材料交接」→「准备资料」(原顺序本身正确,只改名);
|
||||||
|
// 执行人由「客服」改为「陪诊师」——服务执行人一律用陪诊师,客服只管订单变更与投诉
|
||||||
flow: [{
|
flow: [{
|
||||||
title: '在线预约',
|
title: '预约',
|
||||||
desc: '选择跑腿服务,填写具体代办事项'
|
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '电话确认',
|
title: '沟通信息',
|
||||||
desc: '客服确认代办细节和所需材料'
|
desc: '与陪诊师确认代办事项、办理地点与所需材料'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '支付费用',
|
title: '支付费用',
|
||||||
desc: '通过微信支付服务费用'
|
desc: '通过微信支付服务费用'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '材料交接',
|
title: '准备资料',
|
||||||
desc: '通过快递或约定地点交接所需材料'
|
desc: '通过快递或约定地点交接所需材料'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '代办服务',
|
title: '代办服务',
|
||||||
desc: '工作人员按约定完成代办事项'
|
desc: '陪诊师按约定完成代办事项'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '结果交付',
|
title: '结果交付',
|
||||||
desc: '将办理结果通过快递或约定方式交付'
|
desc: '将办理结果或代取物品通过快递或约定方式交付给您'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 原话「确认服务完成」是复述标题的空话,按 9.3.3 交代签收与后续
|
||||||
title: '服务完成',
|
title: '服务完成',
|
||||||
desc: '确认服务完成'
|
desc: '您收到材料或结果后请及时核对,确认无误即服务完成;如有疑问可联系陪诊师处理。'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
notices: [
|
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,
|
id: 7,
|
||||||
title: 'VIP 5次 半天陪诊',
|
title: '夜间陪诊',
|
||||||
subtitle: '五次半天陪诊服务,每次四小时,更优惠',
|
subtitle: '四小时服务/次,夜间时段专业陪同',
|
||||||
price: '1350.00',
|
price: '500.00',
|
||||||
image: '/images/pz1.jpg',
|
image: '/images/pz1.jpg',
|
||||||
icon: 'usergroup',
|
icon: 'user-business',
|
||||||
tag: '优惠套餐',
|
tag: '',
|
||||||
description: '5次半天陪诊套餐为您提供5次、每次4小时的专业陪诊服务,适合需要长期或多次就诊的客户。相比单次购买更加优惠,让您在需要陪诊时随时可用,无需重复下单。',
|
// 称谓由「客户」改为「用户」,与 id 1-5 统一;结构对齐 id 1(服务对象句 + 适用情形句),
|
||||||
|
// 原句把 flow 里的环节又叙述一遍,且「让夜间就医也能更省心」是宣传腔,一并删
|
||||||
|
description: '主要服务行动不便、认知或沟通不便、异地就医不熟悉医院的用户。夜间就诊、不便独自前往或家属不便陪同时,建议选择本服务。',
|
||||||
|
// 步骤名与 id 1-5 对齐:「在线预约」→「预约」、「电话确认」→「沟通信息」;
|
||||||
|
// 执行人由「客服人员」改为「陪诊师」——服务执行人一律用陪诊师,客服只管订单变更与投诉。
|
||||||
|
// 原「分配陪诊师」一步已删:陪诊师由平台按医院与时间匹配,不是用户可感知的必要环节,
|
||||||
|
// 删后步骤数与 id 1/2 一致
|
||||||
flow: [{
|
flow: [{
|
||||||
title: '在线预约',
|
title: '预约',
|
||||||
desc: '选择5次半天陪诊套餐,填写就诊信息,提交订单'
|
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '电话确认',
|
title: '沟通信息',
|
||||||
desc: '客服人员将在30分钟内与您电话确认订单详情'
|
desc: '与陪诊师确认就诊医院、就诊时间、就诊人信息、行动能力与特殊需求'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '支付费用',
|
title: '支付费用',
|
||||||
desc: '通过微信支付服务费用'
|
desc: '通过微信支付服务费用'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '套餐激活',
|
title: '医院集合',
|
||||||
desc: '确认订单后,5次陪诊服务正式激活'
|
desc: '陪诊师按约定时间在医院指定地点与您会合'
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '预约单次服务',
|
|
||||||
desc: '每次就诊前,提前联系客服预约当次陪诊服务'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '全程陪诊',
|
title: '全程陪诊',
|
||||||
@@ -304,66 +419,84 @@ const services = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '服务完成',
|
title: '服务完成',
|
||||||
desc: '确认服务完成,可对陪诊师进行评价'
|
desc: '清点病历、检查报告、药品等材料,确认本次就诊所有项目已完成。提醒后续就诊时间、注意事项与医嘱。'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
notices: [
|
notices: [
|
||||||
'套餐包含5次半天陪诊服务,每次4小时',
|
// 「全站超时统一 80 元/h」的单列例外:本项自身均价 500/4h=125 元/h,
|
||||||
'每次就诊前请提前1天联系客服预约',
|
// 若也按 80 元/h 收超时,多待 1 小时会比提前买 1 小时便宜 45 元,逻辑倒挂且像鼓励超时。
|
||||||
'套餐有效期为购买之日起12个月',
|
// 原 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,
|
id: 8,
|
||||||
title: 'VIP 5次代买药',
|
// 国标 8.1 明确列有「代取药品」,属允许开展的动作,不涉及诊疗决策
|
||||||
subtitle: '五次代买药服务,更优惠',
|
title: '取药快递',
|
||||||
price: '680.00',
|
subtitle: '代取药品,快递寄送到家',
|
||||||
|
price: '160.00',
|
||||||
image: '/images/pt.jpg',
|
image: '/images/pt.jpg',
|
||||||
icon: 'usergroup',
|
icon: 'usergroup',
|
||||||
tag: '优惠套餐',
|
tag: '',
|
||||||
description: '5次代买药套餐为您提供5次专业代买药服务,适合需要长期服药或多次购药的客户。相比单次购买更加优惠,让您在需要购药时随时可用,无需重复下单。',
|
// 称谓由「客户」改为「用户」、去「我们」,与 id 1-5/7 统一;
|
||||||
|
// 结构对齐 id 1/2/7(服务对象句 + 适用情形句),原句又把 flow 叙述了一遍
|
||||||
|
description: '主要服务不便出门或异地取药的用户。经您同意并授权后,陪诊师携带您的取药凭证代取药品,妥善包装后通过快递寄送到家。',
|
||||||
|
// 「在线预约」→「预约」、「电话确认」→「沟通信息」,与 id 1-7 对齐;
|
||||||
|
// 原「材料交接」排在「支付费用」之前且与 id 6 位置相反,改为全站统一的「准备资料」并回到支付之后;
|
||||||
|
// 执行人由「客服」改为「陪诊师」——服务执行人一律用陪诊师,客服只管订单变更与投诉
|
||||||
flow: [{
|
flow: [{
|
||||||
title: '在线预约',
|
title: '预约',
|
||||||
desc: '选择5次代买药套餐,填写购药信息,提交订单'
|
desc: '在线预约、电话预约、微信预约。电话或微信见联系我们页面'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '电话确认',
|
title: '沟通信息',
|
||||||
desc: '客服人员将在30分钟内与您电话确认订单详情'
|
desc: '与陪诊师确认取药医院或药店、就诊人信息、药品信息与收货地址'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '支付费用',
|
title: '支付费用',
|
||||||
desc: '通过微信支付服务费用'
|
desc: '通过微信支付服务费用'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '套餐激活',
|
title: '准备资料',
|
||||||
desc: '确认订单后,5次代买药服务正式激活'
|
desc: '提供就诊卡、处方或取药单等取药凭证'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '预约单次服务',
|
title: '代取药品',
|
||||||
desc: '每次购药前,提前联系客服预约当次代买药服务'
|
desc: '陪诊师按约定到医院或药店代为取药,并核对药品名称与数量'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '代买药',
|
title: '快递寄出',
|
||||||
desc: '工作人员按约定到医院或药店代为购买药品'
|
desc: '将药品妥善包装后通过快递寄出,并把快递单号告知您'
|
||||||
},
|
|
||||||
{
|
|
||||||
title: '药品交付',
|
|
||||||
desc: '将购买的药品通过快递或约定方式交付给您'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
// 原话「确认服务完成」是复述标题的空话,按 9.3.3 交代签收与后续
|
||||||
title: '服务完成',
|
title: '服务完成',
|
||||||
desc: '确认服务完成'
|
desc: '您收到药品后请及时核对,确认无误即服务完成;如有疑问可联系陪诊师处理。'
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
notices: [
|
notices: [
|
||||||
'套餐包含5次代买药服务',
|
'请提前1天预约',
|
||||||
'每次购药前请提前1天联系客服预约',
|
// 4.2.2.2:取药要用到您的就诊卡与处方,属代办个人信息,前提是授权同意
|
||||||
'套餐有效期为购买之日起12个月',
|
'需经您同意并授权后,陪诊师方可持您的取药凭证代为取药',
|
||||||
'药品费用需另行支付,不包含在套餐价格内',
|
'请提供就诊卡、处方或取药单等取药凭证',
|
||||||
'套餐不可退款,但可转让给直系亲属使用',
|
'请提供准确的收货地址,并保持电话畅通以便签收',
|
||||||
'需提供医生处方或既往购药记录'
|
// 句式与 id 1-7 统一;快递费因距离而异,4.2.4.2 要求收费须公示,
|
||||||
|
// 故不写死金额,改为寄出前先与您确认
|
||||||
|
'服务费不包含药品费用,药品费请按医院或药店实际收费缴纳',
|
||||||
|
'快递费按实际收取,具体金额会在寄出前与您确认',
|
||||||
|
// 9.1.1 应提供服务变更手续
|
||||||
|
'如需改期或取消订单,请至少提前1天联系客服',
|
||||||
|
// 9.1.1 与 9.2.3 要求告知投诉渠道
|
||||||
|
'如对服务有疑问或建议,可在「联系我们」页面反馈'
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
+36
-6
@@ -2,6 +2,7 @@ import Router from "koa-router";
|
|||||||
import { HandlerEscortRecord } from "../handler/escort_record.js";
|
import { HandlerEscortRecord } from "../handler/escort_record.js";
|
||||||
import { HandlerHealthProfile } from "../handler/health_profile.js";
|
import { HandlerHealthProfile } from "../handler/health_profile.js";
|
||||||
import { HandlerResource } from "../handler/resource.js";
|
import { HandlerResource } from "../handler/resource.js";
|
||||||
|
import { HandlerEmployee } from "../handler/employee.js";
|
||||||
import ResponseUtil from "../utils/responseUtil.js";
|
import ResponseUtil from "../utils/responseUtil.js";
|
||||||
import { getUserByToken } from "../utils/user_service.js";
|
import { getUserByToken } from "../utils/user_service.js";
|
||||||
|
|
||||||
@@ -25,6 +26,19 @@ async function requireAuth(ctx, next) {
|
|||||||
await 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) {
|
function printRoutes(stack) {
|
||||||
for (const layer of stack) {
|
for (const layer of stack) {
|
||||||
if (layer.path) {
|
if (layer.path) {
|
||||||
@@ -40,22 +54,32 @@ function registerRoutes(app) {
|
|||||||
const handlerEscortRecord = new HandlerEscortRecord();
|
const handlerEscortRecord = new HandlerEscortRecord();
|
||||||
const handlerHealthProfile = new HandlerHealthProfile();
|
const handlerHealthProfile = new HandlerHealthProfile();
|
||||||
const handlerResource = new HandlerResource();
|
const handlerResource = new HandlerResource();
|
||||||
|
const handlerEmployee = new HandlerEmployee();
|
||||||
|
|
||||||
// 敏感数据路由需要登录后访问
|
// 敏感数据路由需要登录后访问
|
||||||
router.get("/escort-record/my", requireAuth, handlerEscortRecord.getMyRecords.bind(handlerEscortRecord));
|
router.get("/escort-record/my", requireAuth, handlerEscortRecord.getMyRecords.bind(handlerEscortRecord));
|
||||||
router.get("/escort-record/attendant", requireAuth, handlerEscortRecord.getAttendantRecords.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.get("/escort-record/:id", requireAuth, handlerEscortRecord.getRecordById.bind(handlerEscortRecord));
|
||||||
router.post("/escort-record", requireAuth, handlerEscortRecord.createRecord.bind(handlerEscortRecord));
|
router.post("/escort-record", requireAuth, handlerEscortRecord.createRecord.bind(handlerEscortRecord));
|
||||||
router.put("/escort-record/:id", requireAuth, handlerEscortRecord.updateRecord.bind(handlerEscortRecord));
|
router.put("/escort-record/:id", requireAuth, handlerEscortRecord.updateRecord.bind(handlerEscortRecord));
|
||||||
router.patch("/escort-record/:id/status", requireAuth, handlerEscortRecord.updateStatus.bind(handlerEscortRecord));
|
router.patch("/escort-record/:id/status", requireAuth, handlerEscortRecord.updateStatus.bind(handlerEscortRecord));
|
||||||
router.delete("/escort-record/:id", requireAuth, handlerEscortRecord.deleteRecord.bind(handlerEscortRecord));
|
router.delete("/escort-record/:id", requireAuth, handlerEscortRecord.deleteRecord.bind(handlerEscortRecord));
|
||||||
|
|
||||||
router.get("/health-profile", requireAuth, handlerHealthProfile.getProfiles.bind(handlerHealthProfile));
|
// C 端"我的健康档案":userId 一律取自 token,必须注册在 /health-profile/:id 之前
|
||||||
router.get("/health-profile/:id", requireAuth, handlerHealthProfile.getProfileById.bind(handlerHealthProfile));
|
router.get("/health-profile/mine", requireAuth, handlerHealthProfile.getMyProfiles.bind(handlerHealthProfile));
|
||||||
router.post("/health-profile", requireAuth, handlerHealthProfile.createProfile.bind(handlerHealthProfile));
|
router.post("/health-profile/mine", requireAuth, handlerHealthProfile.createMyProfile.bind(handlerHealthProfile));
|
||||||
router.put("/health-profile/:id", requireAuth, handlerHealthProfile.updateProfile.bind(handlerHealthProfile));
|
router.get("/health-profile/mine/:id", requireAuth, handlerHealthProfile.getMyProfileById.bind(handlerHealthProfile));
|
||||||
router.delete("/health-profile/:id", requireAuth, handlerHealthProfile.deleteProfile.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("/service", handlerResource.getServices.bind(handlerResource));
|
||||||
router.get("/agreement", handlerResource.getAgreement.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("/department-rankings", handlerResource.getDepartmentRankings.bind(handlerResource));
|
||||||
router.get("/ai-quick-questions", handlerResource.getAiQuickQuestions.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.routes());
|
||||||
app.use(router.allowedMethods());
|
app.use(router.allowedMethods());
|
||||||
|
|
||||||
|
|||||||
+7
-32
@@ -39,20 +39,20 @@ async function lookup(body) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取用户信息(带 5 分钟进程内缓存,token→user)
|
* 仅按 token 获取用户信息(带 5 分钟进程内缓存,token→user)
|
||||||
*
|
*
|
||||||
* 注意:缓存期内登出/账户锁定/用户信息变更最多延迟 5 分钟生效
|
* 注意:缓存期内登出/账户锁定/用户信息变更最多延迟 5 分钟生效
|
||||||
* (user 服务自身接口即时生效)
|
* (user 服务自身接口即时生效)
|
||||||
*
|
*
|
||||||
|
* 只保留 token 路径:user 服务已不再接受按 userId 明文查询,
|
||||||
|
* 故不存在 userId 兜底,避免凭 userId 冒充他人身份。
|
||||||
|
*
|
||||||
* @param {string} token 登录 token
|
* @param {string} token 登录 token
|
||||||
* @param {string} [userId] 备用用户ID(token 缺失或失效时的兜底路径,保持原 WS 行为)
|
|
||||||
* @returns {Promise<Object|null>} 用户信息或 null
|
* @returns {Promise<Object|null>} 用户信息或 null
|
||||||
*/
|
*/
|
||||||
export async function getUserInfo(token, userId) {
|
export async function getUserByToken(token) {
|
||||||
if (!token && !userId) return null;
|
if (!token) return null;
|
||||||
|
|
||||||
// 1. token 路径(带缓存)
|
|
||||||
if (token) {
|
|
||||||
const key = `tk:${token}`;
|
const key = `tk:${token}`;
|
||||||
const cached = cacheGet(key);
|
const cached = cacheGet(key);
|
||||||
if (cached !== undefined) return cached;
|
if (cached !== undefined) return cached;
|
||||||
@@ -66,34 +66,9 @@ export async function getUserInfo(token, userId) {
|
|||||||
// 无效 token 短缓存,保护 user 服务
|
// 无效 token 短缓存,保护 user 服务
|
||||||
cacheSet(key, null, NEGATIVE_TTL);
|
cacheSet(key, null, NEGATIVE_TTL);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// 网络异常不缓存,走 userId 兜底
|
// 网络异常不缓存,避免把故障期内的问题固化 5 分钟
|
||||||
console.error("getUserByToken error:", err.message);
|
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;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* 仅按 token 获取用户信息(REST 鉴权用,无 userId 兜底)
|
|
||||||
*/
|
|
||||||
export async function getUserByToken(token) {
|
|
||||||
return getUserInfo(token, null);
|
|
||||||
}
|
|
||||||
|
|||||||
+10
-4
@@ -3,7 +3,7 @@ import http from 'http';
|
|||||||
import { DBModel } from "./models/index.js";
|
import { DBModel } from "./models/index.js";
|
||||||
import { chatTask } from "./agent/bots/bot-escort/task.js";
|
import { chatTask } from "./agent/bots/bot-escort/task.js";
|
||||||
import { adminAgent } from "./agent/bots/bot-escort-admin/agent.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 {
|
export default class WebSocketServerManager {
|
||||||
constructor(port = 8080) {
|
constructor(port = 8080) {
|
||||||
@@ -71,9 +71,10 @@ export default class WebSocketServerManager {
|
|||||||
async _handleMessage(ws, data) {
|
async _handleMessage(ws, data) {
|
||||||
try {
|
try {
|
||||||
const raw = data.toString();
|
const raw = data.toString();
|
||||||
console.log('Received:', raw);
|
|
||||||
|
|
||||||
const msg = JSON.parse(raw);
|
const msg = JSON.parse(raw);
|
||||||
|
// 只记录消息类型:正文含健康描述、token/userId 属敏感信息,不进日志
|
||||||
|
console.log('Received message type:', msg.type);
|
||||||
|
|
||||||
if (msg.type === 'ping') {
|
if (msg.type === 'ping') {
|
||||||
ws.send(JSON.stringify({ type: 'pong' }));
|
ws.send(JSON.stringify({ type: 'pong' }));
|
||||||
@@ -91,7 +92,12 @@ export default class WebSocketServerManager {
|
|||||||
};
|
};
|
||||||
|
|
||||||
try {
|
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) {
|
if (!userInfo) {
|
||||||
ws.send(JSON.stringify({ type: 'error', content: '登录已失效' }));
|
ws.send(JSON.stringify({ type: 'error', content: '登录已失效' }));
|
||||||
return;
|
return;
|
||||||
@@ -122,7 +128,7 @@ export default class WebSocketServerManager {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
console.log('[Unknown type]', msg);
|
console.log('[Unknown message type]', msg.type);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Message parse error:', e);
|
console.error('Message parse error:', e);
|
||||||
ws.send(JSON.stringify({ type: 'error', content: 'Invalid JSON' }));
|
ws.send(JSON.stringify({ type: 'error', content: 'Invalid JSON' }));
|
||||||
|
|||||||
Reference in New Issue
Block a user