Compare commits

...

1 Commits

Author SHA1 Message Date
lik
17014446ba start 2026-05-25 12:46:14 +08:00
24 changed files with 5932 additions and 0 deletions

2
.env Normal file
View File

@@ -0,0 +1,2 @@
TAVILY_API_KEY=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
DEEPSEEK_API_KEY=sk-a58ccd82b7ba4ce3ac176a88c9381095

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
/node_modules/
/logs/

116
agent/agent.js Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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 };

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

42
package.json Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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) {
// 根据规则只要进入controllerhttp状态码都是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
View 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();
}
}