Compare commits
1 Commits
01d54cc580
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
| 17014446ba |
2
.env
Normal file
2
.env
Normal file
@@ -0,0 +1,2 @@
|
||||
TAVILY_API_KEY=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
|
||||
DEEPSEEK_API_KEY=sk-a58ccd82b7ba4ce3ac176a88c9381095
|
||||
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
/node_modules/
|
||||
/logs/
|
||||
116
agent/agent.js
Normal file
116
agent/agent.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import 'dotenv/config';
|
||||
import { createDeepAgent } from "deepagents";
|
||||
import { ChatOpenAI } from "@langchain/openai";
|
||||
import { AIMessageChunk, ToolMessage } from "langchain";
|
||||
import { SystemMessage, HumanMessage, AIMessage } from "@langchain/core/messages";
|
||||
import { ChatDeepSeek } from "@langchain/deepseek";
|
||||
import Prompts from "./prompts.js";
|
||||
|
||||
export default class EscortAgent {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
clearMessages() {
|
||||
this.messages = [];
|
||||
}
|
||||
|
||||
// msg: { ts: "2023-08-01 10:00:00", content: "你好" }
|
||||
async streamChat(userInfo, msgs, callback) {
|
||||
if (!msgs.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const agent = this._genAgent(userInfo);
|
||||
msgs.forEach(msg => {
|
||||
if (msg.type === "clear") {
|
||||
this.messages = [];
|
||||
} else {
|
||||
this.messages.push(new HumanMessage(msg.content));
|
||||
}
|
||||
});
|
||||
|
||||
if (this.messages.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const INTERESTING_NODES = new Set(["model_request", "tools"]);
|
||||
for await (const [namespace, mode, data] of await agent.stream(
|
||||
{ messages: this.messages },
|
||||
{
|
||||
recursion_limit: 50,
|
||||
streamMode: ["updates", "messages", "custom"], subgraphs: true,
|
||||
configurable: {
|
||||
thread_id: 'default-session'
|
||||
}
|
||||
})) {
|
||||
const isSubagent = namespace.some(s => s.startsWith("tools:"));
|
||||
const source = isSubagent ? "subagent" : "main";
|
||||
if (mode === "updates") {
|
||||
for (const nodeName of Object.keys(data)) {
|
||||
if (!INTERESTING_NODES.has(nodeName)) continue;
|
||||
// Main agent updates (empty namespace)
|
||||
if (namespace.length === 0) {
|
||||
for (const [nodeName, data_] of Object.entries(data)) {
|
||||
if (nodeName === "tools") {
|
||||
// Subagent results returned to main agent
|
||||
for (const msg of data_.messages ?? []) {
|
||||
if (msg.type === "tool") {
|
||||
console.log(`\nSubagent complete: ${msg.name}`);
|
||||
console.log(` Result: ${String(msg.content).slice(0, 200)}...`);
|
||||
}
|
||||
}
|
||||
} else if (nodeName === "model_request") {
|
||||
this.messages.push(...data_.messages);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Subagent updates (non-empty namespace)
|
||||
for (const [nodeName, data_] of Object.entries(data)) {
|
||||
console.log(` [${namespace[0]}] step: ${nodeName}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (mode === "messages") {
|
||||
const [message] = data;
|
||||
if (message.tool_call_chunks?.length) {
|
||||
continue;
|
||||
}
|
||||
if (AIMessageChunk.isInstance(message)) {
|
||||
if (message.text && !message.tool_call_chunks?.length) {
|
||||
callback(source, "ai", message.text, message.id);
|
||||
}
|
||||
if (message.additional_kwargs.reasoning_content && !message.tool_call_chunks?.length) {
|
||||
callback(source, "reasoning", message.additional_kwargs.reasoning_content, message.id);
|
||||
}
|
||||
}
|
||||
if (ToolMessage.isInstance(message) && message.text) {
|
||||
callback(source, "tool", message.text, message.id);
|
||||
}
|
||||
} else if (mode === "custom") {
|
||||
this.logger.info("custom: ", data);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_genAgent(userInfo) {
|
||||
if (this.agent) {
|
||||
return this.agent;
|
||||
}
|
||||
|
||||
this.messages = [];
|
||||
|
||||
this.model = new ChatDeepSeek({
|
||||
model: 'deepseek-v4-pro',
|
||||
apiKey: 'sk-a58ccd82b7ba4ce3ac176a88c9381095',
|
||||
temperature: 0.3
|
||||
});
|
||||
|
||||
this.agent = createDeepAgent({
|
||||
name: "deep-agent",
|
||||
model: this.model,
|
||||
systemPrompt: Prompts.buildSystemPrompt(userInfo)
|
||||
});
|
||||
|
||||
return this.agent;
|
||||
}
|
||||
}
|
||||
56
agent/prompts.js
Normal file
56
agent/prompts.js
Normal file
@@ -0,0 +1,56 @@
|
||||
import moment from "moment";
|
||||
import services from "../resource/services.js";
|
||||
|
||||
class Prompts {
|
||||
static buildSystemPrompt(userInfo) {
|
||||
let userInfo_str = "用户未登录,提示用户先登录,并在'我的'中完善个人信息";
|
||||
if (userInfo) {
|
||||
userInfo_str = JSON.stringify({
|
||||
name: userInfo.profile.name,
|
||||
mobile: userInfo.profile.mobile,
|
||||
sex: userInfo.profile.sex,
|
||||
birth: userInfo.profile.birth,
|
||||
province: userInfo.location.province,
|
||||
city: userInfo.location.city,
|
||||
address: userInfo.addresses || [],
|
||||
});
|
||||
}
|
||||
|
||||
return `
|
||||
# 角色定义
|
||||
你是小橙,一名陪诊服务顾问。温暖、直接、高效。
|
||||
|
||||
# 核心能力
|
||||
- 查询医院、科室、医生信息
|
||||
- 创建、查询陪诊订单
|
||||
- 解答服务流程、价格、注意事项
|
||||
- 提供就诊准备建议
|
||||
|
||||
# 铁律(必须遵守)
|
||||
1. 极度简洁:能一句话说完绝不两句。禁止长篇大论,直接给答案。
|
||||
2. 聊专业问题时,要严肃专注,不偏离主题;要客观公正,绝不主观臆造;同时给用户专业真诚的反馈。
|
||||
3. 聚焦用户最新问题,理解意图,高情商个性化的跟用户沟通,不要一开口就问“需要陪诊服务吗”。
|
||||
4. 主动追问:回答后,一句追问收尾,引导用户给出下一步关键信息。
|
||||
5. 输出要排版层次清晰,格式统一整洁,不要使用markdown格式。
|
||||
6. 医疗问题时,末尾加一句"最终以医生诊断为准"。
|
||||
7. 保护用户隐私,不泄露个人信息。
|
||||
8. 用户询问怎么加入团队或怎么合作时,首先欢迎用户加入团队,然后让用户电话或微信联系。
|
||||
9. 你无法回答的业务问题,要提示用户联系客服。
|
||||
|
||||
# 工作流程
|
||||
1. 问清城市、医院、科室
|
||||
2. 推荐匹配选项
|
||||
3. 确认就诊时间
|
||||
4. 创建订单、确认细节
|
||||
5. 就诊前提醒
|
||||
|
||||
## 参考信息
|
||||
当前日期:${moment().format("YYYY-MM-DD")};
|
||||
用户信息:${userInfo_str};
|
||||
服务项目:${JSON.stringify(services)};
|
||||
服务电话: 18618162956 (微信同号)
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
export default Prompts;
|
||||
29
agent/task.js
Normal file
29
agent/task.js
Normal file
@@ -0,0 +1,29 @@
|
||||
import EscortAgent from "./agent.js";
|
||||
|
||||
class ChatTask {
|
||||
constructor(options = {}) {
|
||||
this.options = {
|
||||
modelProvider: options.modelProvider || "deepseek",
|
||||
apiKey: options.apiKey,
|
||||
baseURL: options.baseURL,
|
||||
modelName: options.modelName,
|
||||
temperature: options.temperature ?? 0.7,
|
||||
maxIterations: options.maxIterations || 10,
|
||||
};
|
||||
|
||||
this.agents = {};
|
||||
}
|
||||
|
||||
async streamChat(userInfo, message, callback) {
|
||||
const userId = userInfo ? userInfo._id : message.appId;
|
||||
if (!this.agents[userId]) {
|
||||
this.agents[userId] = new EscortAgent();
|
||||
}
|
||||
return this.agents[userId].streamChat(userInfo, [message], callback);
|
||||
}
|
||||
}
|
||||
|
||||
const chatTask = new ChatTask();
|
||||
|
||||
export { ChatTask, chatTask };
|
||||
export default chatTask;
|
||||
74
app.js
Normal file
74
app.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import Koa from 'koa';
|
||||
import bodyParser from 'koa-bodyparser';
|
||||
import cors from 'koa-cors';
|
||||
import http from 'http';
|
||||
import { DBModel } from './models/index.js';
|
||||
import registerRoutes from './routes/index.js';
|
||||
import ResponseUtil from './utils/responseUtil.js';
|
||||
|
||||
class APP {
|
||||
constructor() {
|
||||
this.app = new Koa();
|
||||
this.setupDB();
|
||||
this.setupMiddleware();
|
||||
this.setupRoutes();
|
||||
this.setupFallback();
|
||||
}
|
||||
|
||||
setupDB() {
|
||||
DBModel.init();
|
||||
}
|
||||
|
||||
setupMiddleware() {
|
||||
this.app.use(cors());
|
||||
this.app.use(bodyParser());
|
||||
|
||||
this.app.use(async (ctx, next) => {
|
||||
const start = Date.now();
|
||||
await next();
|
||||
const ms = Date.now() - start;
|
||||
console.log(`${ctx.method} ${ctx.url} - ${ctx.status} - ${ms}ms`);
|
||||
});
|
||||
}
|
||||
|
||||
setupRoutes() {
|
||||
this.app.use(async (ctx, next) => {
|
||||
if (ctx.path === '/') {
|
||||
ResponseUtil.success(ctx, { name: 'attendant-api', version: '1.0.0' });
|
||||
return;
|
||||
}
|
||||
await next();
|
||||
});
|
||||
|
||||
this.app._router = registerRoutes(this.app);
|
||||
}
|
||||
|
||||
setupFallback() {
|
||||
this.app.use(async (ctx) => {
|
||||
ctx.status = 404;
|
||||
ctx.body = { code: 404, msg: 'Not Found' };
|
||||
});
|
||||
}
|
||||
|
||||
start(port) {
|
||||
this.server = http.createServer(this.app.callback());
|
||||
this.server.listen(port, () => {
|
||||
console.log(`Server: running on http://localhost:${port}`);
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
console.log('Server stopped');
|
||||
}
|
||||
}
|
||||
|
||||
static createKoaApp() {
|
||||
const instance = new APP();
|
||||
return instance.app;
|
||||
}
|
||||
}
|
||||
|
||||
export { APP };
|
||||
21
conf.json
Normal file
21
conf.json
Normal file
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"wechat": {
|
||||
"appid": "wxf73c79e16837af07",
|
||||
"secret": "5a061d65f4d35d19e62e83b98f6c63cf"
|
||||
},
|
||||
"mongodb": {
|
||||
"str": "mongodb://huashengtec.com:6000",
|
||||
"host": "huashengtec.com",
|
||||
"db": "health",
|
||||
"option": {
|
||||
"user": "ehason",
|
||||
"pass": "Ehason_dbuser_2026",
|
||||
"dbName": "eiot_health",
|
||||
"authSource": "admin",
|
||||
"autoIndex": true,
|
||||
"socketTimeoutMS": 3000,
|
||||
"serverSelectionTimeoutMS": 30000
|
||||
},
|
||||
"debug": true
|
||||
}
|
||||
}
|
||||
127
handler/escort_record.js
Normal file
127
handler/escort_record.js
Normal file
@@ -0,0 +1,127 @@
|
||||
import { DBModel } from "../models/index.js";
|
||||
import ResponseUtil from "../utils/responseUtil.js";
|
||||
|
||||
class HandlerEscortRecord {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
async getMyRecords(ctx) {
|
||||
try {
|
||||
const userId = ctx.state.user?._id || ctx.request.query?.userId;
|
||||
if (!userId) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少用户ID");
|
||||
}
|
||||
|
||||
const { page = 1, pageSize = 20, status } = ctx.request.query;
|
||||
const records = await DBModel.EscortRecord.findRecordsByUser(userId, {
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
status,
|
||||
});
|
||||
|
||||
return ResponseUtil.success(ctx, { records }, "查询成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async getAttendantRecords(ctx) {
|
||||
try {
|
||||
const attendantId = ctx.state.user?._id || ctx.request.query?.attendantId;
|
||||
if (!attendantId) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少陪诊员ID");
|
||||
}
|
||||
|
||||
const { page = 1, pageSize = 20, status } = ctx.request.query;
|
||||
const records = await DBModel.EscortRecord.findRecordsByAttendant(attendantId, {
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
status,
|
||||
});
|
||||
|
||||
return ResponseUtil.success(ctx, { records }, "查询成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async getRecordById(ctx) {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
if (!id) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少记录ID");
|
||||
}
|
||||
|
||||
const record = await DBModel.EscortRecord.findById(id);
|
||||
if (!record) {
|
||||
return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404);
|
||||
}
|
||||
|
||||
return ResponseUtil.success(ctx, { record }, "查询成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async createRecord(ctx) {
|
||||
try {
|
||||
const record = ctx.request.body;
|
||||
if (!record.userId) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少患者信息");
|
||||
}
|
||||
|
||||
const newRecord = await DBModel.EscortRecord.createRecord(record);
|
||||
return ResponseUtil.success(ctx, { record: newRecord }, "创建成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async updateRecord(ctx) {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const update = ctx.request.body;
|
||||
|
||||
if (!id) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少记录ID");
|
||||
}
|
||||
|
||||
const updatedRecord = await DBModel.EscortRecord.updateRecord(id, update);
|
||||
if (!updatedRecord) {
|
||||
return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404);
|
||||
}
|
||||
|
||||
return ResponseUtil.success(ctx, { record: updatedRecord }, "更新成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async updateStatus(ctx) {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const { status } = ctx.request.body;
|
||||
|
||||
if (!id) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少记录ID");
|
||||
}
|
||||
if (!status) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少状态值");
|
||||
}
|
||||
|
||||
const updatedRecord = await DBModel.EscortRecord.updateRecord(id, {
|
||||
"escort.status": status,
|
||||
});
|
||||
|
||||
if (!updatedRecord) {
|
||||
return ResponseUtil.error(ctx, "陪诊记录不存在", null, 404);
|
||||
}
|
||||
|
||||
return ResponseUtil.success(ctx, { record: updatedRecord }, "状态更新成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { HandlerEscortRecord };
|
||||
170
handler/org.js
Normal file
170
handler/org.js
Normal file
@@ -0,0 +1,170 @@
|
||||
import { DBModel } from "../models/index.js";
|
||||
import ResponseUtil from "../utils/responseUtil.js";
|
||||
|
||||
class HandlerHospital {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
async searchHospitalByName(ctx) {
|
||||
try {
|
||||
const { name, page = 1, pageSize = 20 } = ctx.request.query;
|
||||
if (!name) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少搜索关键词");
|
||||
}
|
||||
|
||||
const hospitals = await DBModel.Hospital.findByName(name, {
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
});
|
||||
|
||||
return ResponseUtil.success(ctx, { hospitals }, "查询成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async getHospitalsByCity(ctx) {
|
||||
try {
|
||||
const { city, page = 1, pageSize = 20, level, type } = ctx.request.query;
|
||||
if (!city) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少城市参数");
|
||||
}
|
||||
|
||||
const hospitals = await DBModel.Hospital.findByCity(city, {
|
||||
page: parseInt(page),
|
||||
pageSize: parseInt(pageSize),
|
||||
level,
|
||||
type,
|
||||
});
|
||||
|
||||
return ResponseUtil.success(ctx, { hospitals }, "查询成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async getHospitalSelector(ctx) {
|
||||
try {
|
||||
const { city, level, type } = ctx.request.query;
|
||||
const hospitals = await DBModel.Hospital.getHospitalSelector({
|
||||
city,
|
||||
level,
|
||||
type,
|
||||
});
|
||||
|
||||
return ResponseUtil.success(ctx, { hospitals }, "查询成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async getHospitalById(ctx) {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
if (!id) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少医院ID");
|
||||
}
|
||||
|
||||
const hospital = await DBModel.Hospital.findById(id);
|
||||
if (!hospital) {
|
||||
return ResponseUtil.notFound(ctx, "医院不存在");
|
||||
}
|
||||
|
||||
return ResponseUtil.success(ctx, { hospital }, "查询成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async createHospital(ctx) {
|
||||
try {
|
||||
const hospital = ctx.request.body;
|
||||
if (!hospital.basic?.name) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少医院名称");
|
||||
}
|
||||
|
||||
const newHospital = await DBModel.Hospital.createHospital(hospital);
|
||||
return ResponseUtil.success(ctx, { hospital: newHospital }, "创建成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async updateHospital(ctx) {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const update = ctx.request.body;
|
||||
|
||||
if (!id) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少医院ID");
|
||||
}
|
||||
|
||||
const updatedHospital = await DBModel.Hospital.updateHospital(id, update);
|
||||
if (!updatedHospital) {
|
||||
return ResponseUtil.notFound(ctx, "医院不存在");
|
||||
}
|
||||
|
||||
return ResponseUtil.success(ctx, { hospital: updatedHospital }, "更新成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async setHospitalStatus(ctx) {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
const { isEnabled } = ctx.request.body;
|
||||
|
||||
if (!id) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少医院ID");
|
||||
}
|
||||
|
||||
const updatedHospital = await DBModel.Hospital.setHospitalStatus(id, isEnabled);
|
||||
if (!updatedHospital) {
|
||||
return ResponseUtil.notFound(ctx, "医院不存在");
|
||||
}
|
||||
|
||||
return ResponseUtil.success(ctx, { hospital: updatedHospital }, "状态更新成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async getAllHospitals(ctx) {
|
||||
try {
|
||||
const { page = 1, pageSize = 20 } = ctx.request.query;
|
||||
const skip = (parseInt(page) - 1) * parseInt(pageSize);
|
||||
|
||||
const hospitals = await DBModel.Hospital.find({})
|
||||
.sort({ "service.sortOrder": 1, "basic.name": 1 })
|
||||
.skip(skip)
|
||||
.limit(parseInt(pageSize));
|
||||
|
||||
const total = await DBModel.Hospital.countDocuments({});
|
||||
|
||||
return ResponseUtil.success(ctx, { hospitals, total }, "查询成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
|
||||
async deleteHospital(ctx) {
|
||||
try {
|
||||
const { id } = ctx.params;
|
||||
if (!id) {
|
||||
return ResponseUtil.badRequest(ctx, "缺少医院ID");
|
||||
}
|
||||
|
||||
const deletedHospital = await DBModel.Hospital.findByIdAndDelete(id);
|
||||
if (!deletedHospital) {
|
||||
return ResponseUtil.notFound(ctx, "医院不存在");
|
||||
}
|
||||
|
||||
return ResponseUtil.success(ctx, null, "删除成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { HandlerHospital };
|
||||
18
handler/resource.js
Normal file
18
handler/resource.js
Normal file
@@ -0,0 +1,18 @@
|
||||
import ResponseUtil from "../utils/responseUtil.js";
|
||||
import services from "../resource/services.js";
|
||||
|
||||
class HandlerResource {
|
||||
constructor() {
|
||||
}
|
||||
|
||||
// 获取所有启用的服务列表
|
||||
async getServices(ctx) {
|
||||
try {
|
||||
return ResponseUtil.success(ctx, { services }, "查询成功");
|
||||
} catch (err) {
|
||||
return ResponseUtil.internalError(ctx, err.message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { HandlerResource };
|
||||
10
index.js
Normal file
10
index.js
Normal file
@@ -0,0 +1,10 @@
|
||||
import { APP } from './app.js'
|
||||
import WebSocketServerManager from './websocket.js'
|
||||
|
||||
// HTTP server
|
||||
const koaApp = new APP();
|
||||
koaApp.start(9004);
|
||||
|
||||
// WebSocket server
|
||||
const wsServer = new WebSocketServerManager(9005);
|
||||
wsServer.start();
|
||||
47
models/index.js
Normal file
47
models/index.js
Normal file
@@ -0,0 +1,47 @@
|
||||
'use strict';
|
||||
|
||||
import mongoose from 'mongoose';
|
||||
import { EscortRecordSchema } from "./schema/escort_record.js"
|
||||
import { HospitalSchema } from "./schema/org.js"
|
||||
import config from '../conf.json' with { type: 'json' };
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
class MongoDBSchema {
|
||||
constructor() {
|
||||
this.dbConnection = null;
|
||||
this.User = null;
|
||||
this.EscortRecord = null;
|
||||
this.Hospital = null;
|
||||
}
|
||||
|
||||
init() {
|
||||
mongoose.set("debug", config.mongodb.debug);
|
||||
|
||||
this.dbConnection = mongoose.createConnection(config.mongodb.str, config.mongodb.option);
|
||||
this.dbConnection.on("error", () => {
|
||||
logger.error.bind(logger, "...mongodb connect error ...")
|
||||
});
|
||||
this.dbConnection.on("connected", async () => {
|
||||
logger.info("Mongodb: " + config.mongodb.str + " connected");
|
||||
});
|
||||
this.dbConnection.on("disconnected", () =>
|
||||
logger.warn("Mongodb: " + config.mongodb.str + " disconnected")
|
||||
);
|
||||
this.dbConnection.on("reconnected", () =>
|
||||
logger.info("Mongodb: " + config.mongodb.str + " reconnected")
|
||||
);
|
||||
this.dbConnection.on("disconnecting", () =>
|
||||
logger.warn("Mongodb: " + config.mongodb.str + " disconnecting")
|
||||
);
|
||||
this.dbConnection.on("close", () =>
|
||||
logger.warn("Mongodb: " + config.mongodb.str + " closed")
|
||||
);
|
||||
|
||||
this.EscortRecord = this.dbConnection.model('escort_record', EscortRecordSchema)
|
||||
this.Hospital = this.dbConnection.model('hospital', HospitalSchema)
|
||||
}
|
||||
}
|
||||
|
||||
const DBModel = new MongoDBSchema()
|
||||
export { DBModel };
|
||||
|
||||
304
models/schema/escort_record.js
Normal file
304
models/schema/escort_record.js
Normal file
@@ -0,0 +1,304 @@
|
||||
"use strict";
|
||||
|
||||
import mongoose from "mongoose";
|
||||
|
||||
/**
|
||||
* 陪诊记录Schema定义
|
||||
*
|
||||
* 用于记录和管理陪诊服务全流程数据,包含以下主要分类:
|
||||
* - 基础信息:userId(订单提交用户)
|
||||
* - 患者信息:patient(姓名、电话、性别、年龄、身份证号)
|
||||
* - 陪诊服务:escort(服务ID、服务名称)
|
||||
* - 就诊信息:hospital(医院省份、名称、地址、科室、医生、病历号)
|
||||
* - 时间安排:schedule(预约时间、开始时间、结束时间、时长)
|
||||
* - 陪诊员信息:attendant(陪诊员ID、姓名)
|
||||
* - 费用信息:payment(总费用、已支付、支付状态)
|
||||
* - 备注信息:notes(患者备注、陪诊记录、就诊摘要)
|
||||
* - 状态信息:status(陪诊状态)
|
||||
* - 元数据:meta(创建时间、更新时间)
|
||||
*/
|
||||
const EscortRecordSchema = mongoose.Schema(
|
||||
{
|
||||
/**
|
||||
* 提交订单的用户ID(订单创建者)
|
||||
* @type {ObjectId}
|
||||
* @ref user
|
||||
*/
|
||||
userId: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "user",
|
||||
index: true,
|
||||
comment: "提交订单用户ID"
|
||||
},
|
||||
|
||||
/**
|
||||
* 患者信息 - 就诊患者的基本信息
|
||||
*/
|
||||
patient: {
|
||||
name: { type: String, default: "", comment: "患者姓名" },
|
||||
mobile: { type: String, default: "", comment: "患者联系电话" },
|
||||
sex: { type: String, enum: ["male", "female"], comment: "患者性别" },
|
||||
age: { type: Number, default: 0, comment: "患者年龄" },
|
||||
idnumber: { type: String, default: "", comment: "患者身份证号" },
|
||||
},
|
||||
|
||||
/**
|
||||
* 陪诊服务信息 - 选择的陪诊服务配置
|
||||
*/
|
||||
escort: {
|
||||
serviceId: { type: Number, default: -1, comment: "陪诊服务ID" },
|
||||
serviceName: { type: String, default: "", comment: "陪诊服务名称" },
|
||||
},
|
||||
|
||||
/**
|
||||
* 就诊信息 - 医院和科室相关信息
|
||||
*/
|
||||
hospital: {
|
||||
province: { type: String, default: "", comment: "就诊医院省份" },
|
||||
name: { type: String, default: "", comment: "就诊医院名称" },
|
||||
address: { type: String, default: "", comment: "医院详细地址" },
|
||||
department: { type: String, default: "", comment: "就诊科室" },
|
||||
doctor: { type: String, default: "", comment: "就诊医生" },
|
||||
medicalRecordNo: { type: String, default: "", comment: "病历号" },
|
||||
},
|
||||
|
||||
/**
|
||||
* 时间信息 - 陪诊服务的时间安排
|
||||
*/
|
||||
schedule: {
|
||||
date: { type: Date, default: Date.now, comment: "预约就诊日期" },
|
||||
startTime: { type: String, default: "", comment: "陪诊实际开始时间" },
|
||||
endTime: { type: String, default: "", comment: "陪诊实际结束时间" },
|
||||
duration: { type: Number, default: 60, comment: "陪诊时长(分钟)" },
|
||||
},
|
||||
|
||||
/**
|
||||
* 陪诊员信息 - 为患者提供服务的陪诊员
|
||||
*/
|
||||
attendant: {
|
||||
id: {
|
||||
type: mongoose.Schema.Types.ObjectId,
|
||||
ref: "user",
|
||||
index: true,
|
||||
comment: "陪诊员用户ID"
|
||||
},
|
||||
name: { type: String, default: "", comment: "陪诊员姓名" },
|
||||
sex: { type: String, enum: ["none", "male", "female"], comment: "陪诊员性别" },
|
||||
},
|
||||
|
||||
/**
|
||||
* 费用信息 - 服务费用相关
|
||||
*/
|
||||
payment: {
|
||||
totalFee: { type: Number, default: 0, comment: "陪诊服务总费用(元)" },
|
||||
paidFee: { type: Number, default: 0, comment: "已支付费用(元)" },
|
||||
status: {
|
||||
type: String,
|
||||
enum: ["unpaid", "partial", "paid", "refunded"],
|
||||
default: "unpaid",
|
||||
comment: "支付状态:未支付、部分支付、已支付、已退款",
|
||||
},
|
||||
},
|
||||
|
||||
/**
|
||||
* 备注信息 - 陪诊过程中的记录
|
||||
*/
|
||||
notes: {
|
||||
patientNote: { type: String, default: "", comment: "患者备注/特殊需求" },
|
||||
escortNote: { type: String, default: "", comment: "陪诊员服务记录" },
|
||||
medicalSummary: { type: String, default: "", comment: "就诊摘要" },
|
||||
},
|
||||
|
||||
/**
|
||||
* 陪诊状态
|
||||
* - pending: 待确认 - 订单已创建,等待确认
|
||||
* - confirmed: 已确认 - 订单已确认,等待服务
|
||||
* - in_progress: 进行中 - 陪诊服务正在进行
|
||||
* - completed: 已完成 - 陪诊服务已完成
|
||||
* - cancelled: 已取消 - 订单已取消
|
||||
*/
|
||||
status: {
|
||||
type: String,
|
||||
enum: ["pending", "confirmed", "in_progress", "completed", "cancelled"],
|
||||
default: "pending",
|
||||
comment: "陪诊状态:待确认、已确认、进行中、已完成、已取消",
|
||||
},
|
||||
|
||||
/**
|
||||
* 元数据 - 系统管理信息
|
||||
*/
|
||||
meta: {
|
||||
createtime: { type: Date, default: Date.now, comment: "创建时间" },
|
||||
updatetime: { type: Date, default: Date.now, comment: "更新时间" },
|
||||
},
|
||||
},
|
||||
{
|
||||
minimize: false,
|
||||
strict: false,
|
||||
collection: "escort_record",
|
||||
timestamps: false,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 根据用户ID查找陪诊记录
|
||||
*
|
||||
* @param {ObjectId} userId - 订单提交用户ID
|
||||
* @param {Object} options - 查询选项
|
||||
* @param {number} [options.page=1] - 页码
|
||||
* @param {number} [options.pageSize=20] - 每页数量
|
||||
* @param {string} [options.status] - 状态筛选
|
||||
* @param {Function} [cb] - 可选的回调函数
|
||||
* @returns {Promise<Array>} 陪诊记录列表(按预约日期倒序)
|
||||
*/
|
||||
EscortRecordSchema.statics.findRecordsByUser = async function (userId, options = {}, cb) {
|
||||
const { page = 1, pageSize = 20, status } = options;
|
||||
const filter = { userId };
|
||||
|
||||
if (status) {
|
||||
filter.status = status;
|
||||
}
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
return await this.find(filter)
|
||||
.sort({ "schedule.date": -1 })
|
||||
.skip(skip)
|
||||
.limit(pageSize)
|
||||
.exec(cb);
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据陪诊员ID查找陪诊记录
|
||||
*
|
||||
* @param {ObjectId} attendantId - 陪诊员用户ID
|
||||
* @param {Object} options - 查询选项
|
||||
* @param {number} [options.page=1] - 页码
|
||||
* @param {number} [options.pageSize=20] - 每页数量
|
||||
* @param {string} [options.status] - 状态筛选
|
||||
* @param {Function} [cb] - 可选的回调函数
|
||||
* @returns {Promise<Array>} 陪诊记录列表(按预约日期倒序)
|
||||
*/
|
||||
EscortRecordSchema.statics.findRecordsByAttendant = async function (attendantId, options = {}, cb) {
|
||||
const { page = 1, pageSize = 20, status } = options;
|
||||
const filter = { "attendant.id": attendantId };
|
||||
|
||||
if (status) {
|
||||
filter.status = status;
|
||||
}
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
return await this.find(filter)
|
||||
.sort({ "schedule.date": -1 })
|
||||
.skip(skip)
|
||||
.limit(pageSize)
|
||||
.exec(cb);
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建陪诊记录
|
||||
*
|
||||
* @param {Object} record - 陪诊记录对象
|
||||
* @param {Function} [cb] - 可选的回调函数
|
||||
* @returns {Promise<Object>} 创建的陪诊记录
|
||||
*/
|
||||
EscortRecordSchema.statics.createRecord = async function (record, cb) {
|
||||
record.meta = {
|
||||
createtime: Date.now(),
|
||||
updatetime: Date.now(),
|
||||
};
|
||||
|
||||
const newRecord = new this(record);
|
||||
return await newRecord.save(cb);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新陪诊记录
|
||||
*
|
||||
* @param {ObjectId} id - 记录ID
|
||||
* @param {Object} update - 要更新的字段
|
||||
* @param {Function} [cb] - 可选的回调函数
|
||||
* @returns {Promise<Object|null>} 更新后的记录,失败返回null
|
||||
*/
|
||||
EscortRecordSchema.statics.updateRecord = async function (id, update, cb) {
|
||||
try {
|
||||
update["meta.updatetime"] = Date.now();
|
||||
return await this.findByIdAndUpdate(id, { $set: update }, { new: true }, cb);
|
||||
} catch (error) {
|
||||
console.error("更新陪诊记录失败:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据ID查找陪诊记录
|
||||
*
|
||||
* @param {ObjectId} id - 记录ID
|
||||
* @param {Function} [cb] - 可选的回调函数
|
||||
* @returns {Promise<Object|null>} 陪诊记录,不存在返回null
|
||||
*/
|
||||
EscortRecordSchema.statics.findRecordById = async function (id, cb) {
|
||||
return await this.findById(id).exec(cb);
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据状态查找陪诊记录
|
||||
*
|
||||
* @param {string} status - 状态
|
||||
* @param {Object} options - 查询选项
|
||||
* @param {number} [options.page=1] - 页码
|
||||
* @param {number} [options.pageSize=20] - 每页数量
|
||||
* @param {Function} [cb] - 可选的回调函数
|
||||
* @returns {Promise<Array>} 陪诊记录列表
|
||||
*/
|
||||
EscortRecordSchema.statics.findRecordsByStatus = async function (status, options = {}, cb) {
|
||||
const { page = 1, pageSize = 20 } = options;
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
return await this.find({ status })
|
||||
.sort({ "schedule.date": -1 })
|
||||
.skip(skip)
|
||||
.limit(pageSize)
|
||||
.exec(cb);
|
||||
};
|
||||
|
||||
/**
|
||||
* 删除陪诊记录
|
||||
*
|
||||
* @param {ObjectId} id - 记录ID
|
||||
* @param {Function} [cb] - 可选的回调函数
|
||||
* @returns {Promise<Object|null>} 删除的记录,失败返回null
|
||||
*/
|
||||
EscortRecordSchema.statics.deleteRecord = async function (id, cb) {
|
||||
try {
|
||||
return await this.findByIdAndDelete(id, cb);
|
||||
} catch (error) {
|
||||
console.error("删除陪诊记录失败:", error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 索引定义 ====================
|
||||
|
||||
/**
|
||||
* 用户ID索引 - 用于快速查询用户的陪诊记录
|
||||
*/
|
||||
EscortRecordSchema.index({ userId: 1, "schedule.date": -1 });
|
||||
|
||||
/**
|
||||
* 陪诊员ID索引 - 用于快速查询陪诊员的服务记录
|
||||
*/
|
||||
EscortRecordSchema.index({ "attendant.id": 1, status: 1, "schedule.date": -1 });
|
||||
|
||||
/**
|
||||
* 状态索引 - 用于快速按状态筛选记录
|
||||
*/
|
||||
EscortRecordSchema.index({ status: 1 });
|
||||
|
||||
/**
|
||||
* 医院名称索引 - 用于快速按医院筛选记录
|
||||
*/
|
||||
EscortRecordSchema.index({ "hospital.name": 1 });
|
||||
|
||||
export { EscortRecordSchema };
|
||||
246
models/schema/org.js
Normal file
246
models/schema/org.js
Normal file
@@ -0,0 +1,246 @@
|
||||
"use strict";
|
||||
|
||||
import mongoose from "mongoose";
|
||||
|
||||
/**
|
||||
* 医院Schema定义
|
||||
*
|
||||
* 包含5个主要分类块:基本信息、位置信息、联系方式、服务信息和元数据。
|
||||
* 用于记录和管理医院信息,支持陪诊服务的医院选择功能。
|
||||
*/
|
||||
const HospitalSchema = mongoose.Schema(
|
||||
{
|
||||
// 基本信息 - 医院的核心标识信息
|
||||
basic: {
|
||||
name: { type: String, required: true, comment: "医院名称" },
|
||||
shortName: { type: String, default: "", comment: "医院简称" },
|
||||
pinyin: { type: String, default: "", comment: "医院名称拼音,用于搜索" },
|
||||
pinyinFL: { type: String, default: "", comment: "医院名称拼音首字母,用于搜索" },
|
||||
level: {
|
||||
type: String,
|
||||
enum: ["tertiary", "secondary", "primary", "other"],
|
||||
default: "other",
|
||||
comment: "医院等级:三级、二级、一级、其他",
|
||||
},
|
||||
type: {
|
||||
type: String,
|
||||
enum: ["general", "specialized", "traditional_chinese", "integrated", "other"],
|
||||
default: "general",
|
||||
comment: "医院类型:综合医院、专科医院、中医医院、中西医结合医院、其他",
|
||||
},
|
||||
description: { type: String, default: "", comment: "医院简介" },
|
||||
logo: { type: String, default: "", comment: "医院Logo地址" },
|
||||
},
|
||||
|
||||
// 位置信息 - 医院的地理位置
|
||||
location: {
|
||||
province: { type: String, default: "", comment: "省份" },
|
||||
city: { type: String, default: "", comment: "城市" },
|
||||
district: { type: String, default: "", comment: "区县" },
|
||||
address: { type: String, default: "", comment: "详细地址" },
|
||||
longitude: { type: Number, default: 0, comment: "经度" },
|
||||
latitude: { type: Number, default: 0, comment: "纬度" },
|
||||
},
|
||||
|
||||
// 联系方式 - 医院的联系信息
|
||||
contact: {
|
||||
phone: { type: String, default: "", comment: "联系电话" },
|
||||
emergencyPhone: { type: String, default: "", comment: "急诊电话" },
|
||||
website: { type: String, default: "", comment: "官方网站" },
|
||||
email: { type: String, default: "", comment: "电子邮箱" },
|
||||
},
|
||||
|
||||
// 服务信息 - 医院提供的服务
|
||||
service: {
|
||||
features: [{ type: String, comment: "特色服务" }],
|
||||
isEnabled: { type: Boolean, default: true, comment: "是否启用" },
|
||||
sortOrder: { type: Number, default: 0, comment: "排序优先级" },
|
||||
},
|
||||
|
||||
// 科室信息 - 医院科室详细信息
|
||||
departments: [
|
||||
{
|
||||
name: { type: String, required: true, comment: "科室名称" },
|
||||
pinyin: { type: String, default: "", comment: "科室名称拼音" },
|
||||
pinyinFL: { type: String, default: "", comment: "科室名称拼音首字母" },
|
||||
location: { type: String, default: "", comment: "科室位置(几号楼,几层)" },
|
||||
phone: { type: String, default: "", comment: "科室联系电话" },
|
||||
description: { type: String, default: "", comment: "科室简介" },
|
||||
doctors: [
|
||||
{
|
||||
name: { type: String, required: true, comment: "医生姓名" },
|
||||
title: {
|
||||
type: String,
|
||||
enum: ["resident", "attending", "deputy_chief", "chief", "other"],
|
||||
default: "attending",
|
||||
comment: "医生职称:住院医师、主治医师、副主任医师、主任医师、其他",
|
||||
},
|
||||
specialty: { type: String, default: "", comment: "专业擅长" },
|
||||
avatar: { type: String, default: "", comment: "医生头像" },
|
||||
},
|
||||
],
|
||||
isEnabled: { type: Boolean, default: true, comment: "是否启用" },
|
||||
sortOrder: { type: Number, default: 0, comment: "排序优先级" },
|
||||
},
|
||||
],
|
||||
|
||||
// 元数据 - 系统管理信息
|
||||
meta: {
|
||||
createtime: { type: Date, default: Date.now, comment: "创建时间" },
|
||||
updatetime: { type: Date, default: Date.now, comment: "更新时间" },
|
||||
},
|
||||
},
|
||||
{
|
||||
minimize: false,
|
||||
strict: false,
|
||||
collection: "hospital",
|
||||
timestamps: false,
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* 根据名称查找医院
|
||||
*
|
||||
* @param {String} name - 医院名称(支持模糊搜索)
|
||||
* @param {Object} options - 查询选项 { page, pageSize }
|
||||
* @param {Function} cb - 可选的回调函数
|
||||
* @returns {Promise<Array>} 医院列表
|
||||
*/
|
||||
HospitalSchema.statics.findByName = async function (name, options = {}, cb) {
|
||||
const { page = 1, pageSize = 20 } = options;
|
||||
const filter = {
|
||||
$or: [
|
||||
{ "basic.name": new RegExp(name, "i") },
|
||||
{ "basic.shortName": new RegExp(name, "i") },
|
||||
{ "basic.pinyin": new RegExp(name.toLowerCase(), "i") },
|
||||
{ "basic.pinyinFL": new RegExp(name.toUpperCase(), "i") },
|
||||
],
|
||||
"service.isEnabled": true,
|
||||
};
|
||||
const skip = (page - 1) * pageSize;
|
||||
return await this.find(filter)
|
||||
.sort({ "service.sortOrder": 1, "basic.name": 1 })
|
||||
.skip(skip)
|
||||
.limit(pageSize)
|
||||
.exec(cb);
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据城市查找医院
|
||||
*
|
||||
* @param {String} city - 城市名称
|
||||
* @param {Object} options - 查询选项 { page, pageSize, level, type }
|
||||
* @param {Function} cb - 可选的回调函数
|
||||
* @returns {Promise<Array>} 医院列表
|
||||
*/
|
||||
HospitalSchema.statics.findByCity = async function (city, options = {}, cb) {
|
||||
const { page = 1, pageSize = 20, level, type } = options;
|
||||
const filter = {
|
||||
"location.city": city,
|
||||
"service.isEnabled": true,
|
||||
};
|
||||
if (level) {
|
||||
filter["basic.level"] = level;
|
||||
}
|
||||
if (type) {
|
||||
filter["basic.type"] = type;
|
||||
}
|
||||
const skip = (page - 1) * pageSize;
|
||||
return await this.find(filter)
|
||||
.sort({ "service.sortOrder": 1, "basic.name": 1 })
|
||||
.skip(skip)
|
||||
.limit(pageSize)
|
||||
.exec(cb);
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取所有启用的医院列表(用于选择器)
|
||||
*
|
||||
* @param {Object} options - 查询选项 { city, level, type }
|
||||
* @param {Function} cb - 可选的回调函数
|
||||
* @returns {Promise<Array>} 医院列表(仅包含名称和ID)
|
||||
*/
|
||||
HospitalSchema.statics.getHospitalSelector = async function (options = {}, cb) {
|
||||
const { city, level, type } = options;
|
||||
const filter = { "service.isEnabled": true };
|
||||
if (city) {
|
||||
filter["location.city"] = city;
|
||||
}
|
||||
if (level) {
|
||||
filter["basic.level"] = level;
|
||||
}
|
||||
if (type) {
|
||||
filter["basic.type"] = type;
|
||||
}
|
||||
return await this.find(filter, { "basic.name": 1 })
|
||||
.sort({ "service.sortOrder": 1, "basic.name": 1 })
|
||||
.exec(cb);
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建医院
|
||||
*
|
||||
* @param {Object} hospital - 医院对象
|
||||
* @param {Function} cb - 可选的回调函数
|
||||
* @returns {Promise<Object>} 创建的医院
|
||||
*/
|
||||
HospitalSchema.statics.createHospital = async function (hospital, cb) {
|
||||
hospital.meta = {
|
||||
createtime: Date.now(),
|
||||
updatetime: Date.now(),
|
||||
};
|
||||
const newHospital = new this(hospital);
|
||||
return await newHospital.save(cb);
|
||||
};
|
||||
|
||||
/**
|
||||
* 更新医院信息
|
||||
*
|
||||
* @param {ObjectId} id - 医院ID
|
||||
* @param {Object} update - 要更新的字段
|
||||
* @param {Function} cb - 可选的回调函数
|
||||
* @returns {Promise<Object|null>} 更新后的医院
|
||||
*/
|
||||
HospitalSchema.statics.updateHospital = async function (id, update, cb) {
|
||||
try {
|
||||
update["meta.updatetime"] = Date.now();
|
||||
return await this.findByIdAndUpdate(id, { $set: update }, { new: true }, cb);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 启用/禁用医院
|
||||
*
|
||||
* @param {ObjectId} id - 医院ID
|
||||
* @param {Boolean} isEnabled - 是否启用
|
||||
* @param {Function} cb - 可选的回调函数
|
||||
* @returns {Promise<Object|null>} 更新后的医院
|
||||
*/
|
||||
HospitalSchema.statics.setHospitalStatus = async function (id, isEnabled, cb) {
|
||||
try {
|
||||
return await this.findByIdAndUpdate(
|
||||
id,
|
||||
{
|
||||
"service.isEnabled": isEnabled,
|
||||
"meta.updatetime": Date.now(),
|
||||
},
|
||||
{ new: true },
|
||||
cb
|
||||
);
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
// 医院名称索引
|
||||
HospitalSchema.index({ "basic.name": 1 });
|
||||
|
||||
// 城市和等级索引
|
||||
HospitalSchema.index({ "location.city": 1, "basic.level": 1 });
|
||||
|
||||
// 启用状态索引
|
||||
HospitalSchema.index({ "service.isEnabled": 1 });
|
||||
|
||||
export { HospitalSchema };
|
||||
63
nginx_site.txt
Normal file
63
nginx_site.txt
Normal file
@@ -0,0 +1,63 @@
|
||||
# HTTP 配置(可选:重定向到 HTTPS)
|
||||
server {
|
||||
listen 80;
|
||||
server_name attendant.huashengtec.com;
|
||||
|
||||
# HTTP 重定向到 HTTPS
|
||||
return 301 https://$server_name$request_uri;
|
||||
}
|
||||
|
||||
# HTTPS 配置
|
||||
server {
|
||||
listen 443 ssl;
|
||||
server_name attendant.huashengtec.com;
|
||||
|
||||
# SSL 证书配置
|
||||
ssl_certificate /data/wwwroot/attendant/attendant.huashengtec.com_bundle.crt;
|
||||
ssl_certificate_key /data/wwwroot/attendant/attendant.huashengtec.com.key;
|
||||
|
||||
# SSL 安全配置
|
||||
ssl_protocols TLSv1.2 TLSv1.3;
|
||||
ssl_ciphers HIGH:!aNULL:!MD5;
|
||||
ssl_prefer_server_ciphers on;
|
||||
ssl_session_cache shared:SSL:10m;
|
||||
ssl_session_timeout 10m;
|
||||
|
||||
# 设置 MIME 类型
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# API 反向代理
|
||||
location /api/ {
|
||||
proxy_pass http://127.0.0.1:9004/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
}
|
||||
|
||||
# WebSocket 反向代理
|
||||
location /ws/ {
|
||||
proxy_pass http://127.0.0.1:9005/;
|
||||
proxy_http_version 1.1;
|
||||
|
||||
# WebSocket 必需请求头
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# 长连接超时设置(可选,根据业务调整)
|
||||
proxy_read_timeout 86400s;
|
||||
proxy_send_timeout 86400s;
|
||||
}
|
||||
|
||||
# 前端静态文件
|
||||
location / {
|
||||
root /data/wwwroot/attendant;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
}
|
||||
3868
package-lock.json
generated
Normal file
3868
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
42
package.json
Normal file
42
package.json
Normal file
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "attendant-api",
|
||||
"version": "1.0.0",
|
||||
"description": "值班助手小程序 RESTful API 服务",
|
||||
"type": "module",
|
||||
"main": "app.js",
|
||||
"scripts": {
|
||||
"start": "node index.js",
|
||||
"dev": "nodemon index.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@langchain/anthropic": "^1.4.0",
|
||||
"@langchain/community": "^1.1.28",
|
||||
"@langchain/core": "^1.1.48",
|
||||
"@langchain/deepseek": "^1.0.27",
|
||||
"@langchain/langgraph": "^1.3.2",
|
||||
"@langchain/openai": "^1.4.7",
|
||||
"@langchain/tavily": "^1.2.0",
|
||||
"bcrypt": "^5.1.1",
|
||||
"deepagents": "^1.10.2",
|
||||
"dotenv": "^17.4.2",
|
||||
"koa": "^2.16.4",
|
||||
"koa-bodyparser": "^4.4.1",
|
||||
"koa-cors": "^0.0.16",
|
||||
"koa-router": "^12.0.1",
|
||||
"langchain": "^1.4.2",
|
||||
"lodash": "^4.18.1",
|
||||
"moment": "^2.30.1",
|
||||
"mongoose": "^8.24.0",
|
||||
"node-fetch": "^3.3.2",
|
||||
"winston": "^3.19.0",
|
||||
"ws": "^8.21.0",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
"nodemon": "^3.0.2"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.1.0",
|
||||
"utf-8-validate": "^6.0.6"
|
||||
}
|
||||
}
|
||||
284
resource/services.js
Normal file
284
resource/services.js
Normal file
@@ -0,0 +1,284 @@
|
||||
const services = [
|
||||
{
|
||||
id: 1,
|
||||
title: '全天陪诊',
|
||||
subtitle: '八小时服务/次,专业陪诊师全程陪同',
|
||||
price: '499.00',
|
||||
image: '/images/pz1.jpg',
|
||||
tag: '热门',
|
||||
description: '全天陪诊服务提供8小时的专业陪诊服务,陪诊师将全程陪同您完成就医流程。包括挂号、候诊、就诊、检查、取药等环节的协助与引导。我们的陪诊师均经过专业培训,熟悉各大医院流程,能够有效缩短就医时间,提升就医体验。',
|
||||
flow: [{
|
||||
title: '在线预约',
|
||||
desc: '选择服务类型,填写就诊信息,提交订单'
|
||||
},
|
||||
{
|
||||
title: '电话确认',
|
||||
desc: '客服人员将在30分钟内与您电话确认订单详情'
|
||||
},
|
||||
{
|
||||
title: '分配陪诊师',
|
||||
desc: '根据您的就诊医院和时间,匹配专业陪诊师'
|
||||
},
|
||||
{
|
||||
title: '医院集合',
|
||||
desc: '陪诊师按约定时间在医院指定地点与您会合'
|
||||
},
|
||||
{
|
||||
title: '全程陪诊',
|
||||
desc: '陪诊师协助完成挂号、就诊、检查、取药等流程'
|
||||
},
|
||||
{
|
||||
title: '服务完成',
|
||||
desc: '确认服务完成,可对陪诊师进行评价'
|
||||
}
|
||||
],
|
||||
notices: [
|
||||
'服务时长为8小时,超出时间按每小时50元计费',
|
||||
'请提前1天预约,紧急订单需额外支付加急费',
|
||||
'如需取消订单,请提前4小时联系客服',
|
||||
'服务不包含挂号费、检查费、药费等医疗费用'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
title: '半天陪诊',
|
||||
subtitle: '四小时服务/次,适合简单就诊',
|
||||
price: '298.00',
|
||||
image: '/images/pz1.jpg',
|
||||
tag: '',
|
||||
description: '半天陪诊服务提供4小时的专业陪诊服务,适合就诊流程相对简单的客户。陪诊师将协助您完成挂号、就诊、简单检查及取药等环节,让您的就医过程更加顺畅。',
|
||||
flow: [{
|
||||
title: '在线预约',
|
||||
desc: '选择服务类型,填写就诊信息,提交订单'
|
||||
},
|
||||
{
|
||||
title: '电话确认',
|
||||
desc: '客服人员将在30分钟内与您电话确认订单详情'
|
||||
},
|
||||
{
|
||||
title: '分配陪诊师',
|
||||
desc: '根据您的就诊医院和时间,匹配专业陪诊师'
|
||||
},
|
||||
{
|
||||
title: '医院集合',
|
||||
desc: '陪诊师按约定时间在医院指定地点与您会合'
|
||||
},
|
||||
{
|
||||
title: '全程陪诊',
|
||||
desc: '陪诊师协助完成挂号、就诊、检查、取药等流程'
|
||||
},
|
||||
{
|
||||
title: '服务完成',
|
||||
desc: '确认服务完成,可对陪诊师进行评价'
|
||||
}
|
||||
],
|
||||
notices: [
|
||||
'服务时长为4小时,超出时间按每小时50元计费',
|
||||
'请提前1天预约,紧急订单需额外支付加急费',
|
||||
'如需取消订单,请提前4小时联系客服',
|
||||
'服务不包含挂号费、检查费、药费等医疗费用'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: '2小时陪诊',
|
||||
subtitle: '2小时陪诊,超时需加钟',
|
||||
price: '198.00',
|
||||
image: '/images/pz1.jpg',
|
||||
tag: '',
|
||||
description: '2小时陪诊服务适合仅需简单协助的客户,如单次就诊或简单取药。陪诊师将在约定时间内提供专业的陪诊服务,帮助您高效完成就医流程。',
|
||||
flow: [{
|
||||
title: '在线预约',
|
||||
desc: '选择服务类型,填写就诊信息,提交订单'
|
||||
},
|
||||
{
|
||||
title: '电话确认',
|
||||
desc: '客服人员将在30分钟内与您电话确认订单详情'
|
||||
},
|
||||
{
|
||||
title: '分配陪诊师',
|
||||
desc: '根据您的就诊医院和时间,匹配专业陪诊师'
|
||||
},
|
||||
{
|
||||
title: '医院集合',
|
||||
desc: '陪诊师按约定时间在医院指定地点与您会合'
|
||||
},
|
||||
{
|
||||
title: '全程陪诊',
|
||||
desc: '陪诊师协助完成挂号、就诊、检查、取药等流程'
|
||||
},
|
||||
{
|
||||
title: '服务完成',
|
||||
desc: '确认服务完成,可对陪诊师进行评价'
|
||||
}
|
||||
],
|
||||
notices: [
|
||||
'服务时长为2小时,超出时间按每小时50元计费',
|
||||
'请提前1天预约,紧急订单需额外支付加急费',
|
||||
'如需取消订单,请提前4小时联系客服',
|
||||
'服务不包含挂号费、检查费、药费等医疗费用'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: '代问诊',
|
||||
subtitle: '代您到医院与医生进行问诊',
|
||||
price: '268.00',
|
||||
image: '/images/wz1.jpg',
|
||||
tag: '',
|
||||
description: '代问诊服务适用于无法亲自到医院的客户。我们的陪诊师将携带您的病历资料,代您与医生进行面诊,详细记录医生的诊断意见和用药建议,并及时反馈给您。',
|
||||
flow: [{
|
||||
title: '在线预约',
|
||||
desc: '选择代问诊服务,填写就诊信息和病史'
|
||||
},
|
||||
{
|
||||
title: '资料准备',
|
||||
desc: '将病历、检查报告等资料拍照上传或快递给陪诊师'
|
||||
},
|
||||
{
|
||||
title: '电话确认',
|
||||
desc: '客服确认资料完整性和就诊需求'
|
||||
},
|
||||
{
|
||||
title: '代问诊',
|
||||
desc: '陪诊师携带资料到医院代您问诊'
|
||||
},
|
||||
{
|
||||
title: '结果反馈',
|
||||
desc: '陪诊师将医生诊断意见和用药建议详细反馈给您'
|
||||
},
|
||||
{
|
||||
title: '服务完成',
|
||||
desc: '确认服务完成,可对陪诊师进行评价'
|
||||
}
|
||||
],
|
||||
notices: [
|
||||
'请确保提供的病历资料真实完整',
|
||||
'代问诊不包含挂号费和药费',
|
||||
'部分医院可能要求患者本人到场,请提前确认',
|
||||
'陪诊师会详细记录医嘱,但不提供医疗建议'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: '检查预约',
|
||||
subtitle: '磁共振预约、CT预约、彩超等其他预约',
|
||||
price: '238.00',
|
||||
image: '/images/qbg1.jpg',
|
||||
tag: '',
|
||||
description: '检查预约服务帮助您预约各类医学检查,包括核磁共振(MRI)、CT、彩超、胃镜等。我们熟悉各大医院的预约流程,能够帮您快速预约到合适的检查时间,避免长时间等待。',
|
||||
flow: [{
|
||||
title: '在线预约',
|
||||
desc: '选择检查预约服务,填写检查类型和期望时间'
|
||||
},
|
||||
{
|
||||
title: '资料审核',
|
||||
desc: '上传医生开具的检查申请单'
|
||||
},
|
||||
{
|
||||
title: '预约办理',
|
||||
desc: '工作人员代为联系医院预约检查时间'
|
||||
},
|
||||
{
|
||||
title: '确认通知',
|
||||
desc: '将预约成功的具体时间通知您'
|
||||
},
|
||||
{
|
||||
title: '现场协助',
|
||||
desc: '如需,可额外购买陪诊服务协助检查'
|
||||
},
|
||||
{
|
||||
title: '服务完成',
|
||||
desc: '确认服务完成'
|
||||
}
|
||||
],
|
||||
notices: [
|
||||
'需要提供医生开具的检查申请单',
|
||||
'部分检查项目有特殊的身体准备要求,请提前了解',
|
||||
'预约成功后如需改期,请提前24小时联系',
|
||||
'服务费用不包含检查费用'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 6,
|
||||
title: '出入院代办',
|
||||
subtitle: '出入院手续代办',
|
||||
price: '198.00',
|
||||
image: '/images/yy.jpg',
|
||||
tag: '',
|
||||
description: '出入院代办服务帮助您办理医院的入院和出院手续。包括床位预约、入院登记、医保手续办理、出院结算等,让您或家人能够更专注于治疗本身。',
|
||||
flow: [{
|
||||
title: '在线预约',
|
||||
desc: '选择出入院代办服务,填写相关信息'
|
||||
},
|
||||
{
|
||||
title: '资料准备',
|
||||
desc: '准备身份证、医保卡、押金等相关资料'
|
||||
},
|
||||
{
|
||||
title: '电话确认',
|
||||
desc: '客服确认代办事项和所需资料'
|
||||
},
|
||||
{
|
||||
title: '代办服务',
|
||||
desc: '工作人员代为办理出入院手续'
|
||||
},
|
||||
{
|
||||
title: '结果反馈',
|
||||
desc: '将办理结果和相关单据反馈给您'
|
||||
},
|
||||
{
|
||||
title: '服务完成',
|
||||
desc: '确认服务完成'
|
||||
}
|
||||
],
|
||||
notices: [
|
||||
'需要提供患者身份证、医保卡等证件',
|
||||
'入院代办需提前了解医院床位情况',
|
||||
'服务费用不包含住院押金和医疗费用',
|
||||
'部分手续可能需要患者本人签字'
|
||||
]
|
||||
},
|
||||
{
|
||||
id: 7,
|
||||
title: '送/取/买/跑腿等服务',
|
||||
subtitle: '排队/买药/取报告等一小时陪诊服务',
|
||||
price: '158.00',
|
||||
image: '/images/pt.jpg',
|
||||
tag: '',
|
||||
description: '跑腿服务提供各类医院相关代办事项,包括代排队、代买药、代取报告、代送标本等。适合工作繁忙或行动不便的客户,让您足不出户即可完成医院相关事务。',
|
||||
flow: [{
|
||||
title: '在线预约',
|
||||
desc: '选择跑腿服务,填写具体代办事项'
|
||||
},
|
||||
{
|
||||
title: '电话确认',
|
||||
desc: '客服确认代办细节和所需材料'
|
||||
},
|
||||
{
|
||||
title: '材料交接',
|
||||
desc: '通过快递或约定地点交接所需材料'
|
||||
},
|
||||
{
|
||||
title: '代办服务',
|
||||
desc: '工作人员按约定完成代办事项'
|
||||
},
|
||||
{
|
||||
title: '结果交付',
|
||||
desc: '将办理结果通过快递或约定方式交付'
|
||||
},
|
||||
{
|
||||
title: '服务完成',
|
||||
desc: '确认服务完成'
|
||||
}
|
||||
],
|
||||
notices: [
|
||||
'请详细说明代办事项和特殊要求',
|
||||
'需要提供办理所需的相关证件和材料',
|
||||
'服务时长为1小时,超出按每小时50元计费',
|
||||
'服务费用不包含药品费、检查费等费用'
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
export default services;
|
||||
38
routes/index.js
Normal file
38
routes/index.js
Normal file
@@ -0,0 +1,38 @@
|
||||
import Router from "koa-router";
|
||||
import { HandlerEscortRecord } from "../handler/escort_record.js";
|
||||
import { HandlerResource } from "../handler/resource.js";
|
||||
|
||||
function printRoutes(stack) {
|
||||
for (const layer of stack) {
|
||||
if (layer.path) {
|
||||
const methods = layer.methods.filter(m => m !== '_all');
|
||||
methods.forEach(m => console.log(` [${m.toUpperCase()}] ${layer.path}`));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function registerRoutes(app) {
|
||||
const router = new Router({ prefix: '/health' });
|
||||
|
||||
const handlerEscortRecord = new HandlerEscortRecord();
|
||||
const handlerResource = new HandlerResource();
|
||||
|
||||
router.get("/escort-record/my", handlerEscortRecord.getMyRecords.bind(handlerEscortRecord));
|
||||
router.get("/escort-record/attendant", handlerEscortRecord.getAttendantRecords.bind(handlerEscortRecord));
|
||||
router.get("/escort-record/:id", handlerEscortRecord.getRecordById.bind(handlerEscortRecord));
|
||||
router.post("/escort-record", handlerEscortRecord.createRecord.bind(handlerEscortRecord));
|
||||
router.put("/escort-record/:id", handlerEscortRecord.updateRecord.bind(handlerEscortRecord));
|
||||
router.patch("/escort-record/:id/status", handlerEscortRecord.updateStatus.bind(handlerEscortRecord));
|
||||
|
||||
router.get("/service", handlerResource.getServices.bind(handlerResource));
|
||||
|
||||
app.use(router.routes());
|
||||
app.use(router.allowedMethods());
|
||||
|
||||
console.log('API Routes:');
|
||||
printRoutes(router.stack);
|
||||
|
||||
return router;
|
||||
}
|
||||
|
||||
export default registerRoutes;
|
||||
138
utils/authToken.js
Normal file
138
utils/authToken.js
Normal file
@@ -0,0 +1,138 @@
|
||||
"use strict";
|
||||
|
||||
import crypto from "crypto"
|
||||
import logger from './logger.js'
|
||||
|
||||
function AuthToken() {
|
||||
}
|
||||
|
||||
AuthToken.prototype.init = function (redisdb, defaultExpiresSeconds) {
|
||||
this.tokenDB = redisdb;
|
||||
this.defaultExpiresSeconds = defaultExpiresSeconds;
|
||||
}
|
||||
|
||||
AuthToken.prototype.gen = async function (uid, tokenOld) {
|
||||
let userToken = tokenOld;
|
||||
|
||||
try {
|
||||
let tokenData = await this.tokenDB.get(userToken).then(function (data) {
|
||||
return JSON.parse(data);
|
||||
});
|
||||
if (!tokenData) {
|
||||
if (!userToken || userToken.length <= 16) {
|
||||
let hash = crypto.createHash("md5");
|
||||
hash.update(uid + Date() + Math.random());
|
||||
userToken = hash.digest("hex");
|
||||
}
|
||||
|
||||
tokenData = {
|
||||
uid,
|
||||
token: userToken,
|
||||
ts: Math.floor(Date.now() / 1000),
|
||||
ttl: this.defaultExpiresSeconds,
|
||||
};
|
||||
}
|
||||
|
||||
this.tokenDB.set(userToken, JSON.stringify(tokenData), "EX", this.defaultExpiresSeconds);
|
||||
} catch (err) {
|
||||
logger.error('生成Token失败:', { error: err.message, uid, stack: err.stack });
|
||||
}
|
||||
|
||||
return userToken;
|
||||
};
|
||||
|
||||
AuthToken.prototype.update = async function (userToken) {
|
||||
try {
|
||||
let tokenData = await this.tokenDB.get(userToken).then(function (data) {
|
||||
return JSON.parse(data);
|
||||
});
|
||||
|
||||
if (tokenData) {
|
||||
this.tokenDB.set(userToken, JSON.stringify(tokenData), "EX", this.defaultExpiresSeconds);
|
||||
return tokenData;
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('更新Token失败:', { error: err.message, stack: err.stack });
|
||||
}
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
AuthToken.prototype.del = async function (userToken) {
|
||||
let tokenData = await this.tokenDB.get(userToken).then(function (data) {
|
||||
return JSON.parse(data);
|
||||
});
|
||||
|
||||
if (tokenData) {
|
||||
this.tokenDB.del(userToken);
|
||||
}
|
||||
|
||||
return tokenData;
|
||||
};
|
||||
|
||||
AuthToken.prototype.check = async function (userToken, updateExpire = true) {
|
||||
try {
|
||||
if (updateExpire) {
|
||||
if (await this.tokenDB.expire(userToken, this.defaultExpiresSeconds) >= 1) {
|
||||
return true;
|
||||
}
|
||||
} else {
|
||||
if (await this.tokenDB.exists([userToken]) >= 1) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
logger.error('检查Token失败:', { error: err.message, userToken, stack: err.stack });
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
AuthToken.prototype.get = async function (token) {
|
||||
let tokenData = await this.tokenDB.get(token).then(function (data) {
|
||||
return JSON.parse(data);
|
||||
});
|
||||
|
||||
if (!tokenData) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return tokenData;
|
||||
};
|
||||
|
||||
AuthToken.prototype.koaRequest = async function (ctx, next) {
|
||||
try {
|
||||
let token = ctx.request.body.token;
|
||||
|
||||
if (!token) token = ctx.request.query.token;
|
||||
if (!token) token = ctx.header["authorization"];
|
||||
if (!token) token = ctx.header["token"];
|
||||
|
||||
if (!token) {
|
||||
throw new Error("Need token param.");
|
||||
}
|
||||
|
||||
let ret = await this.check(token);
|
||||
if (!ret) {
|
||||
throw new Error("User not login in.");
|
||||
}
|
||||
|
||||
let tokenData = await this.get(token);
|
||||
if (tokenData && tokenData.uid) {
|
||||
const { DBModel } = await import("../models/index.js");
|
||||
const user = await DBModel.User.findOne({ _id: tokenData.uid });
|
||||
if (user) {
|
||||
ctx.userInfo = user;
|
||||
}
|
||||
}
|
||||
|
||||
ctx.authToken = this;
|
||||
|
||||
return next();
|
||||
} catch (err) {
|
||||
logger.error('身份验证失败:', { error: err.message, token: ctx.request.body.token || ctx.request.query.token || ctx.header["authorization"] || ctx.header["token"], stack: err.stack });
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const Token = new AuthToken()
|
||||
export { Token }
|
||||
48
utils/logger.js
Normal file
48
utils/logger.js
Normal file
@@ -0,0 +1,48 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
|
||||
const logDir = path.join(process.cwd(), 'logs');
|
||||
if (!fs.existsSync(logDir)) {
|
||||
fs.mkdirSync(logDir, { recursive: true });
|
||||
}
|
||||
|
||||
const LEVELS = {
|
||||
ERROR: 'ERROR',
|
||||
WARN: 'WARN',
|
||||
INFO: 'INFO',
|
||||
DEBUG: 'DEBUG'
|
||||
};
|
||||
|
||||
const getCurrentTimestamp = () => {
|
||||
return new Date().toISOString();
|
||||
};
|
||||
|
||||
const writeLog = (level, message, meta = {}) => {
|
||||
const timestamp = getCurrentTimestamp();
|
||||
const logEntry = {
|
||||
timestamp,
|
||||
level,
|
||||
message,
|
||||
meta
|
||||
};
|
||||
|
||||
console.log(`[${timestamp}] ${level}: ${message}`, meta);
|
||||
|
||||
const logFileName = `${new Date().toISOString().slice(0, 10)}.log`;
|
||||
const logFilePath = path.join(logDir, logFileName);
|
||||
|
||||
fs.appendFile(logFilePath, JSON.stringify(logEntry) + '\n', (err) => {
|
||||
if (err) {
|
||||
console.error('Failed to write log to file:', err);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const logger = {
|
||||
error: (message, meta) => writeLog(LEVELS.ERROR, message, meta),
|
||||
warn: (message, meta) => writeLog(LEVELS.WARN, message, meta),
|
||||
info: (message, meta) => writeLog(LEVELS.INFO, message, meta),
|
||||
debug: (message, meta) => writeLog(LEVELS.DEBUG, message, meta)
|
||||
};
|
||||
|
||||
export default logger;
|
||||
13
utils/passwdCrypto.js
Normal file
13
utils/passwdCrypto.js
Normal file
@@ -0,0 +1,13 @@
|
||||
import bcrypt from 'bcrypt';
|
||||
|
||||
const SALT_ROUNDS = 10;
|
||||
|
||||
function hash(text) {
|
||||
return bcrypt.hash(text, SALT_ROUNDS);
|
||||
}
|
||||
|
||||
function compare(text, hash) {
|
||||
return bcrypt.compare(text, hash);
|
||||
}
|
||||
|
||||
export default { hash, compare }
|
||||
85
utils/responseUtil.js
Normal file
85
utils/responseUtil.js
Normal file
@@ -0,0 +1,85 @@
|
||||
/**
|
||||
* 响应工具类
|
||||
* 提供统一的响应格式
|
||||
*/
|
||||
class ResponseUtil {
|
||||
/**
|
||||
* 成功响应
|
||||
* @param {Object} ctx - Koa上下文对象
|
||||
* @param {Object} data - 响应数据
|
||||
* @param {string} message - 响应消息
|
||||
*/
|
||||
static success(ctx, data, message = '操作成功') {
|
||||
ctx.status = 200;
|
||||
ctx.body = {
|
||||
code: 0,
|
||||
msg: message,
|
||||
data,
|
||||
ts: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 错误响应
|
||||
* @param {Object} ctx - Koa上下文对象
|
||||
* @param {string} message - 错误消息
|
||||
* @param {number} errorCode - 应用错误码
|
||||
*/
|
||||
static error(ctx, message, data = null, errorCode = 500) {
|
||||
// 根据规则,只要进入controller,http状态码都是200
|
||||
ctx.status = 200;
|
||||
ctx.body = {
|
||||
code: errorCode,
|
||||
msg: message,
|
||||
data,
|
||||
ts: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 请求参数错误响应
|
||||
* @param {Object} ctx - Koa上下文对象
|
||||
* @param {string} message - 错误消息
|
||||
*/
|
||||
static badRequest(ctx, message = '请求参数错误', data = null) {
|
||||
return ResponseUtil.error(ctx, message, data, 400);
|
||||
}
|
||||
|
||||
/**
|
||||
* 未授权响应
|
||||
* @param {Object} ctx - Koa上下文对象
|
||||
* @param {string} message - 错误消息
|
||||
*/
|
||||
static unauthorized(ctx, message = '未授权', data = null) {
|
||||
return ResponseUtil.error(ctx, message, data, 401);
|
||||
}
|
||||
|
||||
/**
|
||||
* 禁止访问响应
|
||||
* @param {Object} ctx - Koa上下文对象
|
||||
* @param {string} message - 错误消息
|
||||
*/
|
||||
static forbidden(ctx, message = '禁止访问', data = null) {
|
||||
return ResponseUtil.error(ctx, message, data, 403);
|
||||
}
|
||||
|
||||
/**
|
||||
* 资源不存在响应
|
||||
* @param {Object} ctx - Koa上下文对象
|
||||
* @param {string} message - 错误消息
|
||||
*/
|
||||
static notFound(ctx, message = '资源不存在', data = null) {
|
||||
return ResponseUtil.error(ctx, message, data, 404);
|
||||
}
|
||||
|
||||
/**
|
||||
* 服务器内部错误响应
|
||||
* @param {Object} ctx - Koa上下文对象
|
||||
* @param {string} message - 错误消息
|
||||
*/
|
||||
static internalError(ctx, message = '服务器内部错误', data = null) {
|
||||
return ResponseUtil.error(ctx, message, data, 500);
|
||||
}
|
||||
}
|
||||
|
||||
export default ResponseUtil;
|
||||
131
websocket.js
Normal file
131
websocket.js
Normal file
@@ -0,0 +1,131 @@
|
||||
import WebSocket, { WebSocketServer } from 'ws';
|
||||
import http from 'http';
|
||||
import { DBModel } from "./models/index.js";
|
||||
import { chatTask } from "./agent/task.js";
|
||||
|
||||
export default class WebSocketServerManager {
|
||||
constructor(port = 8080) {
|
||||
this.port = port;
|
||||
this.wss = null;
|
||||
this.server = null;
|
||||
}
|
||||
|
||||
start() {
|
||||
this.server = http.createServer((req, res) => {
|
||||
res.writeHead(426, { 'Content-Type': 'text/plain' });
|
||||
res.end('Upgrade Required: WebSocket expected');
|
||||
});
|
||||
|
||||
this.wss = new WebSocketServer({
|
||||
server: this.server,
|
||||
perMessageDeflate: {
|
||||
zlibDeflateOptions: {
|
||||
chunkSize: 1024,
|
||||
memLevel: 7,
|
||||
level: 3
|
||||
},
|
||||
zlibInflateOptions: {
|
||||
chunkSize: 10 * 1024
|
||||
},
|
||||
clientNoContextTakeover: true,
|
||||
serverNoContextTakeover: true,
|
||||
serverMaxWindowBits: 10,
|
||||
concurrencyLimit: 10,
|
||||
threshold: 1024
|
||||
}
|
||||
});
|
||||
|
||||
this.wss.on('connection', (ws, req) => {
|
||||
const path = req.url;
|
||||
console.log(`WebSocket connected: ${req.socket.remoteAddress}, path: ${path}`);
|
||||
|
||||
if (path === '/chat') {
|
||||
this._handleChatConnection(ws);
|
||||
} else {
|
||||
ws.send(JSON.stringify({ type: 'system', content: 'Unknown path' }));
|
||||
ws.close(1000, 'Unknown path');
|
||||
}
|
||||
});
|
||||
|
||||
this.server.listen(this.port, () => {
|
||||
console.log(`WebSocket server started on ws://localhost:${this.port}`);
|
||||
});
|
||||
}
|
||||
|
||||
_handleChatConnection(ws) {
|
||||
ws.send(JSON.stringify({ type: 'system', content: 'Connected to chat server' }));
|
||||
|
||||
ws.on('message', (data) => this._handleMessage(ws, data));
|
||||
|
||||
ws.on('close', () => {
|
||||
console.log('Chat client disconnected');
|
||||
});
|
||||
|
||||
ws.on('error', (err) => {
|
||||
console.error('WebSocket error:', err);
|
||||
});
|
||||
}
|
||||
|
||||
async _handleMessage(ws, data) {
|
||||
try {
|
||||
const raw = data.toString();
|
||||
console.log('Received:', raw);
|
||||
|
||||
const msg = JSON.parse(raw);
|
||||
|
||||
if (msg.type === 'ping') {
|
||||
ws.send(JSON.stringify({ type: 'pong' }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'chat' || msg.type === 'clear') {
|
||||
const userInfo = await this.getUserInfo(msg.userId);
|
||||
chatTask.streamChat(userInfo, msg, (source, type, content, id) => {
|
||||
ws.send(JSON.stringify({ source, type, content, id }));
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (msg.type === 'system') {
|
||||
console.log('[System]', msg.content);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Unknown type]', msg);
|
||||
} catch (e) {
|
||||
console.error('Message parse error:', e);
|
||||
ws.send(JSON.stringify({ type: 'error', content: 'Invalid JSON' }));
|
||||
}
|
||||
}
|
||||
|
||||
broadcast(message) {
|
||||
if (!this.wss) return;
|
||||
this.wss.clients.forEach((client) => {
|
||||
if (client.readyState === WebSocket.OPEN) {
|
||||
client.send(message);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
stop() {
|
||||
if (this.wss) {
|
||||
this.wss.close();
|
||||
this.wss = null;
|
||||
}
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
}
|
||||
console.log('WebSocket server stopped');
|
||||
}
|
||||
|
||||
getClientsCount() {
|
||||
if (!this.wss) return 0;
|
||||
return this.wss.clients.size;
|
||||
}
|
||||
|
||||
async getUserInfo(userId) {
|
||||
if (!DBModel.User || userId.length === 0) return null;
|
||||
return await DBModel.User.findById(userId).lean().exec();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user