init
This commit is contained in:
@@ -0,0 +1,264 @@
|
||||
# 医院预约挂号后端 API
|
||||
|
||||
基于 Node.js + Express 的医院预约挂号小程序后端服务,提供 RESTful API 供小程序及外部系统调用。
|
||||
|
||||
## 快速开始
|
||||
|
||||
```bash
|
||||
cd server
|
||||
npm install
|
||||
npm start # 默认监听 3000
|
||||
npm run dev # Node 18+ 自动重启
|
||||
```
|
||||
|
||||
## 接口总览
|
||||
|
||||
| 类别 | Method | Path | 说明 |
|
||||
|------|--------|------|------|
|
||||
| 通用 | GET | / | 健康检查 |
|
||||
| 医院 | GET | /api/hospitals | 医院列表 |
|
||||
| 医院 | GET | /api/hospitals/:id | 医院详情 |
|
||||
| 科室 | GET | /api/departments | 科室列表 |
|
||||
| 医生 | GET | /api/doctors?departmentId= | 医生列表(可按科室筛选) |
|
||||
| 医生 | GET | /api/doctors/:id | 医生详情 |
|
||||
| 预约 | GET | /api/appointments?phone= | 我的预约(按手机号过滤) |
|
||||
| 预约 | GET | /api/appointments/:id | 预约详情 |
|
||||
| 预约 | POST | /api/appointments | 创建预约 |
|
||||
| 预约 | PUT | /api/appointments/:id/cancel | 取消预约 |
|
||||
| **运营** | **GET** | **/api/config/home** | **首页整体配置**(前端直接消费) |
|
||||
| **运营** | **PUT** | **/api/config/home** | **整体更新首页配置**(Object.assign 合并) |
|
||||
| **运营** | PUT | /api/config/home/hero | 更新 Hero 区(标题/副标题/背景) |
|
||||
| **运营** | PUT | /api/config/home/hero-experts | 整体替换 Hero 专家头像列表 |
|
||||
| **运营** | PUT | /api/config/home/features | 整体替换三栏功能卡 |
|
||||
| **运营** | PUT | /api/config/home/expert-team | 整体替换「专家团队」列表 |
|
||||
| **运营** | PUT | /api/config/home/info-banner | 更新「病情提醒」信息条 |
|
||||
| **运营** | GET | /api/banners | 轮播图列表 |
|
||||
| **运营** | POST | /api/banners | 新增轮播图 |
|
||||
| **运营** | PUT | /api/banners/:id | 更新轮播图 |
|
||||
| **运营** | DELETE | /api/banners/:id | 删除轮播图 |
|
||||
| **运营** | GET | /api/admin/experts | 专家列表(?enabled=true 过滤) |
|
||||
| **运营** | GET | /api/admin/experts/:id | 专家详情 |
|
||||
| **运营** | POST | /api/admin/experts | 新增专家 |
|
||||
| **运营** | PUT | /api/admin/experts/:id | 更新专家 |
|
||||
| **运营** | DELETE | /api/admin/experts/:id | 删除专家 |
|
||||
| **运营** | POST | /api/admin/experts/upload | 上传头像(base64 / URL) |
|
||||
|
||||
## 界面配置说明
|
||||
|
||||
> 运营后台通过上面 **「运营」** 分类的接口管理小程序首页的所有可视资源。
|
||||
> 前端只需 `GET /api/config/home` 即可拿到首页完整数据并渲染,无需修改代码。
|
||||
|
||||
### 首页配置结构(GET /api/config/home 返回)
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 0,
|
||||
"message": "ok",
|
||||
"data": {
|
||||
"navTitle": "苏州肛泰中医院",
|
||||
"hero": {
|
||||
"title": "为肛肠/胃肠健康护航",
|
||||
"subtitle": "三甲名医亲诊",
|
||||
"background": "linear-gradient(180deg, #4ea1ff 0%, #2b80f5 60%, #2572e6 100%)"
|
||||
},
|
||||
"heroExperts": [
|
||||
{ "id": "he-1", "name": "王秋萍", "title": "主任医师",
|
||||
"avatar": "https://...", "label": "原解放军第 454 医院" }
|
||||
],
|
||||
"features": [
|
||||
{ "id": "f-1", "name": "肛肠中心", "sub": "特色专区",
|
||||
"icon": "🩺", "iconColor": "linear-gradient(...)", "link": "/pages/..." }
|
||||
],
|
||||
"infoBanner": {
|
||||
"texts": ["病情变化及时就诊...", "结合辅助检查..."],
|
||||
"buttonText": "免费咨询",
|
||||
"buttonLink": "/pages/phone-consult/index"
|
||||
},
|
||||
"expertTeam": [
|
||||
{ "id": "et-1", "name": "王秋萍", "title": "主任医师",
|
||||
"avatar": "https://...", "tag": "特邀",
|
||||
"specialty": "特邀胃肠专家 · 40 年临床经验",
|
||||
"link": "/pages/hospital/index" }
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 字段说明
|
||||
- `hero.background` 支持 **CSS 渐变字符串** 或 **图片 URL**(URL 时以 background-image 方式渲染)
|
||||
- `features[].icon` 使用 emoji(如 🩺 🧬 🖥️)作为图标,避免外部图标资源依赖
|
||||
- `features[].iconColor` 为 CSS 背景值,例如 `linear-gradient(135deg, #5ed3c0 0%, #2dbfa6 100%)`
|
||||
|
||||
## 请求示例
|
||||
|
||||
### 更新 Hero 标题
|
||||
```bash
|
||||
curl -X PUT http://localhost:3000/api/config/home/hero \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"title":"苏州肛泰中医院","subtitle":"三甲名医亲诊"}'
|
||||
```
|
||||
|
||||
### 新增轮播图
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/banners \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"title": "夏季活动",
|
||||
"subtitle": "限时优惠",
|
||||
"image": "https://your-cdn.com/banner.jpg",
|
||||
"link": "/pages/appointment/index",
|
||||
"sort": 1
|
||||
}'
|
||||
```
|
||||
|
||||
### 新增专家
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/admin/experts \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"name": "张医生",
|
||||
"title": "主任医师",
|
||||
"avatar": "https://your-cdn.com/avatar.jpg",
|
||||
"department": "肛肠科",
|
||||
"intro": "40 年临床经验"
|
||||
}'
|
||||
```
|
||||
|
||||
### 上传头像
|
||||
```bash
|
||||
# 方式 1: 直接传 URL(推荐 - 配合 CDN / OSS)
|
||||
curl -X POST http://localhost:3000/api/admin/experts/upload \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"url":"https://your-cdn.com/new-avatar.jpg"}'
|
||||
|
||||
# 方式 2: 传 base64
|
||||
curl -X POST http://localhost:3000/api/admin/experts/upload \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"base64":"iVBORw0KGgo...","filename":"avatar.png"}'
|
||||
```
|
||||
|
||||
### 创建预约
|
||||
```bash
|
||||
curl -X POST http://localhost:3000/api/appointments \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"patientName": "张三",
|
||||
"patientAge": 35,
|
||||
"patientPhone": "13800138000",
|
||||
"date": "2026-06-14",
|
||||
"departmentName": "肛肠",
|
||||
"symptom": "便血 3 天"
|
||||
}'
|
||||
```
|
||||
|
||||
## 如何查看/管理预约人信息
|
||||
|
||||
> 预约数据保存在内存 `server/src/data.js` 的 `appointments` 数组中(重启服务会清空,生产环境请改为数据库)。
|
||||
> 当前没有独立的 Web 管理页面,**所有操作都通过 RESTful API 完成**,可使用 Postman / Apifox / curl 调取。
|
||||
|
||||
### 1) 查全部预约(管理员视角)
|
||||
```bash
|
||||
curl http://localhost:3000/api/appointments
|
||||
```
|
||||
|
||||
### 2) 按手机号查某位用户的预约
|
||||
```bash
|
||||
curl "http://localhost:3000/api/appointments?phone=13800138000"
|
||||
```
|
||||
|
||||
### 3) 查预约统计
|
||||
```bash
|
||||
curl http://localhost:3000/api/appointments/stats
|
||||
# 返回 { total, pending, confirmed, completed, cancelled }
|
||||
```
|
||||
|
||||
### 4) 查单条详情
|
||||
```bash
|
||||
curl http://localhost:3000/api/appointments/apt-1718300000000
|
||||
```
|
||||
|
||||
### 5) 状态流转(管理动作)
|
||||
```bash
|
||||
# 确认预约
|
||||
curl -X PUT http://localhost:3000/api/appointments/apt-xxx/confirm
|
||||
|
||||
# 标记完成
|
||||
curl -X PUT http://localhost:3000/api/appointments/apt-xxx/complete
|
||||
|
||||
# 取消预约
|
||||
curl -X PUT http://localhost:3000/api/appointments/apt-xxx/cancel
|
||||
```
|
||||
|
||||
### 6) 调整界面图片/资源
|
||||
首页所有可视资源(背景图、专家头像、功能卡、信息条文案、轮播图、专家信息)都通过下面这些接口管理,**前端代码无需改动**:
|
||||
|
||||
```bash
|
||||
# 替换首页整体配置(合并写入)
|
||||
curl -X PUT http://localhost:3000/api/config/home \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "navTitle": "苏州肛泰中医院", "hero": { "title": "..." } }'
|
||||
|
||||
# 更新 Hero 区域
|
||||
curl -X PUT http://localhost:3000/api/config/home/hero \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "title":"苏州肛泰中医院", "subtitle":"三甲名医亲诊" }'
|
||||
|
||||
# 整体替换 Hero 专家头像列表
|
||||
curl -X PUT http://localhost:3000/api/config/home/hero-experts \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "heroExperts": [ { "id":"he-1","name":"王秋萍","title":"主任医师","avatar":"https://...","label":"原解放军第 454 医院" } ] }'
|
||||
|
||||
# 整体替换三栏功能卡
|
||||
curl -X PUT http://localhost:3000/api/config/home/features \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "features": [ { "id":"f-1","name":"肛肠中心","sub":"特色专区","icon":"🩺","iconColor":"linear-gradient(135deg,#5ed3c0,#2dbfa6)","link":"/pages/appointment/index" } ] }'
|
||||
|
||||
# 整体替换「专家团队」列表
|
||||
curl -X PUT http://localhost:3000/api/config/home/expert-team \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "expertTeam": [ { "id":"et-1","name":"王秋萍","title":"主任医师","avatar":"https://...","tag":"特邀","specialty":"...","link":"/pages/hospital/index" } ] }'
|
||||
|
||||
# 更新「病情提醒」信息条
|
||||
curl -X PUT http://localhost:3000/api/config/home/info-banner \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{ "texts":["病情变化及时就诊","结合辅助检查明确诊断"], "buttonText":"免费咨询", "buttonLink":"/pages/phone-consult/index" }'
|
||||
|
||||
# 轮播图增删改查
|
||||
curl http://localhost:3000/api/banners
|
||||
curl -X POST http://localhost:3000/api/banners -H "Content-Type: application/json" \
|
||||
-d '{ "title":"夏季活动","subtitle":"限时优惠","image":"https://cdn.example.com/banner.jpg","link":"/pages/appointment/index","sort":1 }'
|
||||
curl -X PUT http://localhost:3000/api/banners/b-1 -H "Content-Type: application/json" -d '{ "title":"新标题" }'
|
||||
curl -X DELETE http://localhost:3000/api/banners/b-1
|
||||
|
||||
# 专家资源增删改查
|
||||
curl http://localhost:3000/api/admin/experts
|
||||
curl -X POST http://localhost:3000/api/admin/experts -H "Content-Type: application/json" \
|
||||
-d '{ "name":"张医生","title":"主任医师","avatar":"https://...","department":"肛肠科","intro":"40 年临床经验" }'
|
||||
curl -X PUT http://localhost:3000/api/admin/experts/he-1 -H "Content-Type: application/json" -d '{ "name":"新名字" }'
|
||||
curl -X DELETE http://localhost:3000/api/admin/experts/he-1
|
||||
```
|
||||
|
||||
### 7) 上传图片(接 OSS / CDN)
|
||||
- **推荐:先用 CDN/OSS 拿到 URL,再调用上面的 PUT 接口把 URL 写进去**
|
||||
- 也可以直接 `POST /api/admin/experts/upload`,传 `{ url }` 或 `{ base64, filename }`,返回图片 URL
|
||||
|
||||
## 数据存储
|
||||
|
||||
当前为内存存储(重启后数据清空)。生产环境建议:
|
||||
- 资源类(首页配置 / Banner / 专家)→ MySQL / PostgreSQL
|
||||
- 文件类(上传图片)→ 阿里 OSS / 腾讯 COS / 自建静态服务
|
||||
|
||||
## 与小程序集成
|
||||
|
||||
修改小程序 `src/services/request.ts` 中的 `BASE_URL` 为本服务地址:
|
||||
```ts
|
||||
const BASE_URL = 'http://localhost:3000';
|
||||
```
|
||||
然后把 `src/services/homeConfig.ts` 等 service 中的 mock 返回替换为 `request()` 调用:
|
||||
```ts
|
||||
export const fetchHomeConfig = async (): Promise<HomeConfig> => {
|
||||
const res = await request<HomeConfig>({ url: '/config/home' });
|
||||
return res.data;
|
||||
};
|
||||
```
|
||||
即可完成从 mock 到真实后端的切换。
|
||||
Generated
+1602
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"name": "yuyueguahao-server",
|
||||
"version": "1.0.0",
|
||||
"description": "医院预约挂号小程序 - Node.js 后端 API 服务",
|
||||
"main": "src/index.js",
|
||||
"scripts": {
|
||||
"start": "node src/index.js",
|
||||
"dev": "node --watch src/index.js",
|
||||
"seed": "node src/seed.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"bcryptjs": "^2.4.3",
|
||||
"cors": "^2.8.5",
|
||||
"dayjs": "^1.11.10",
|
||||
"express": "^4.19.2",
|
||||
"jsonwebtoken": "^9.0.2",
|
||||
"mysql2": "^3.11.0",
|
||||
"swagger-jsdoc": "^6.3.0",
|
||||
"swagger-ui-express": "^5.0.1"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
/// db.js
|
||||
/// MySQL 连接池 + 表结构初始化 + 种子数据导入
|
||||
/// 描述:基于 mysql2/promise,集中管理数据库连接;启动时自动建表并 seed 演示数据
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const mysql = require('mysql2/promise');
|
||||
|
||||
const config = {
|
||||
host: process.env.DB_HOST || '127.0.0.1',
|
||||
port: Number(process.env.DB_PORT || 3306),
|
||||
user: process.env.DB_USER || 'yygh_app',
|
||||
password: process.env.DB_PASSWORD || 'Yygh@2026#App',
|
||||
database: process.env.DB_NAME || 'yuyueguahao',
|
||||
waitForConnections: true,
|
||||
connectionLimit: 10,
|
||||
queueLimit: 0,
|
||||
charset: 'utf8mb4',
|
||||
dateStrings: true
|
||||
};
|
||||
|
||||
let pool = null;
|
||||
|
||||
/** 获取连接池(单例) */
|
||||
const getPool = () => {
|
||||
if (!pool) pool = mysql.createPool(config);
|
||||
return pool;
|
||||
};
|
||||
|
||||
/** 启动时执行:建表 + 灌入种子数据 */
|
||||
const init = async () => {
|
||||
const raw = fs.readFileSync(path.join(__dirname, 'schema.sql'), 'utf8');
|
||||
// 去掉行注释和空行,再按 `;` 切分
|
||||
const cleaned = raw
|
||||
.split('\n')
|
||||
.filter((line) => !line.trim().startsWith('--'))
|
||||
.join('\n');
|
||||
const statements = cleaned
|
||||
.split(';')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.length > 0);
|
||||
const conn = await getPool().getConnection();
|
||||
try {
|
||||
for (const stmt of statements) {
|
||||
await conn.query(stmt);
|
||||
}
|
||||
console.log(`[DB] schema initialized: ${statements.length} statements`);
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
};
|
||||
|
||||
/** 便捷查询 */
|
||||
const query = (sql, params) => getPool().query(sql, params);
|
||||
|
||||
/** 关闭连接池 */
|
||||
const close = async () => {
|
||||
if (pool) {
|
||||
await pool.end();
|
||||
pool = null;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getPool, init, query, close, config };
|
||||
@@ -0,0 +1,174 @@
|
||||
// 医院预约挂号后端 API 服务
|
||||
// 入口文件:初始化数据库、注册路由、启动 HTTP 服务
|
||||
const express = require('express');
|
||||
const cors = require('cors');
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const swaggerUi = require('swagger-ui-express');
|
||||
const { init, close } = require('./db');
|
||||
const swaggerSpec = require('./swagger/spec');
|
||||
|
||||
const {
|
||||
hospitalRouter,
|
||||
departmentRouter,
|
||||
doctorRouter,
|
||||
appointmentRouter,
|
||||
configRouter,
|
||||
expertRouter,
|
||||
bannerRouter,
|
||||
centerRouter,
|
||||
homeIntroRouter,
|
||||
hospitalImageRouter,
|
||||
uploadRouter,
|
||||
adminAuthRouter,
|
||||
leadRouter,
|
||||
leadAdminRouter,
|
||||
wechatRouter,
|
||||
notificationRouter
|
||||
} = require('./modules');
|
||||
|
||||
const app = express();
|
||||
const PORT = process.env.PORT || 3000;
|
||||
const API_PREFIX = '/api/v1'; // 统一接口版本前缀(Phase 1 起)
|
||||
|
||||
// 管理后台静态资源目录(与后端同进程托管,无需 Nginx)
|
||||
const ADMIN_DIR = path.resolve(__dirname, '../../admin');
|
||||
|
||||
// 中间件
|
||||
app.use(cors());
|
||||
// 20MB 限制 — 用于承载图片 base64 上传 (10MB 图片编码后约 13MB)
|
||||
app.use(express.json({ limit: '20mb' }));
|
||||
app.use(express.urlencoded({ limit: '20mb', extended: true }));
|
||||
|
||||
// 全局错误处理:捕获 body-parser 超限 / JSON 解析错误 等
|
||||
app.use((err, _req, res, _next) => {
|
||||
if (err.type === 'entity.too.large') {
|
||||
return res.status(413).json({ code: 413, message: '请求体超过 20MB 限制', data: null });
|
||||
}
|
||||
if (err.type === 'entity.parse.failed') {
|
||||
return res.status(400).json({ code: 400, message: 'JSON 解析失败: ' + err.message, data: null });
|
||||
}
|
||||
console.error('[Express] unhandled error:', err);
|
||||
res.status(500).json({ code: 500, message: err.message || 'Internal Server Error', data: null });
|
||||
});
|
||||
|
||||
// 请求日志
|
||||
app.use((req, _res, next) => {
|
||||
console.log(`[${new Date().toISOString()}] ${req.method} ${req.url}`);
|
||||
next();
|
||||
});
|
||||
|
||||
// 健康检查
|
||||
app.get('/', (_req, res) => {
|
||||
res.json({ name: 'yuyueguahao-server', status: 'running', version: '2.0.0', apiPrefix: API_PREFIX });
|
||||
});
|
||||
|
||||
// 管理后台:同进程静态托管(路径 /admin → 登录页;/admin/* → 静态资源)
|
||||
app.use('/admin', express.static(ADMIN_DIR, { index: 'index.html', extensions: ['html'] }));
|
||||
app.get('/admin', (_req, res) => res.sendFile(path.join(ADMIN_DIR, 'index.html')));
|
||||
app.get('/admin/', (_req, res) => res.sendFile(path.join(ADMIN_DIR, 'index.html')));
|
||||
|
||||
// 静态托管上传文件(/uploads/* → /opt/yuyueguahao/uploads/, 与 Nginx alias 保持一致)
|
||||
// 兼容双通道:Nginx (port 80) 直读 + Express (port 3000) 兜底
|
||||
const UPLOADS_DIR = path.resolve(process.env.UPLOAD_DIR || '/opt/yuyueguahao/uploads');
|
||||
if (!fs.existsSync(UPLOADS_DIR)) fs.mkdirSync(UPLOADS_DIR, { recursive: true });
|
||||
app.use('/uploads', express.static(UPLOADS_DIR, { maxAge: '7d', fallthrough: true, redirect: false }));
|
||||
|
||||
// 业务路由(统一 /api/v1 前缀,便于未来做版本兼容)
|
||||
app.use(`${API_PREFIX}/hospitals`, hospitalRouter);
|
||||
app.use(`${API_PREFIX}/departments`, departmentRouter);
|
||||
app.use(`${API_PREFIX}/doctors`, doctorRouter);
|
||||
app.use(`${API_PREFIX}/appointments`, appointmentRouter);
|
||||
app.use(`${API_PREFIX}/config`, configRouter);
|
||||
app.use(`${API_PREFIX}/experts`, expertRouter);
|
||||
app.use(`${API_PREFIX}/banners`, bannerRouter);
|
||||
app.use(`${API_PREFIX}/centers`, centerRouter);
|
||||
app.use(`${API_PREFIX}/home-intro`, homeIntroRouter);
|
||||
app.use(`${API_PREFIX}/hospital-images`, hospitalImageRouter);
|
||||
app.use(`${API_PREFIX}/uploads`, uploadRouter);
|
||||
app.use(`${API_PREFIX}/admin/auth`, adminAuthRouter);
|
||||
app.use(`${API_PREFIX}/leads`, leadRouter);
|
||||
app.use(`${API_PREFIX}/admin/leads`, leadAdminRouter);
|
||||
app.use(`${API_PREFIX}/wechat`, wechatRouter);
|
||||
app.use(`${API_PREFIX}/notification`, notificationRouter);
|
||||
|
||||
// API 文档:Swagger UI + OpenAPI JSON
|
||||
app.use('/api/docs', swaggerUi.serve, swaggerUi.setup(swaggerSpec, {
|
||||
customSiteTitle: '苏州肛泰中医院 API 文档',
|
||||
customCss: '.swagger-ui .topbar { background-color: #1f7ae0; }',
|
||||
swaggerOptions: { persistAuthorization: true }
|
||||
}));
|
||||
app.get('/api/docs.json', (_req, res) => res.json(swaggerSpec));
|
||||
|
||||
// 404 处理
|
||||
app.use((req, res) => {
|
||||
res.status(404).json({ code: 404, message: `Not Found: ${req.method} ${req.url}`, data: null });
|
||||
});
|
||||
|
||||
// 全局错误处理
|
||||
app.use((err, _req, res, _next) => {
|
||||
console.error('[Server] unhandled error', err);
|
||||
res.status(500).json({ code: 500, message: 'Internal Server Error', data: null });
|
||||
});
|
||||
|
||||
/** 启动:先建表与种子,再监听 */
|
||||
const start = async () => {
|
||||
try {
|
||||
await init();
|
||||
// 自动灌入种子数据(仅当表为空时执行)
|
||||
const { seedHospitals } = require('./seeds/hospitals');
|
||||
const { seedDepartments } = require('./seeds/departments');
|
||||
const { seedDoctors } = require('./seeds/doctors');
|
||||
const { seedExperts } = require('./seeds/experts');
|
||||
const { seedBanners } = require('./seeds/banners');
|
||||
const { seedCenters } = require('./seeds/centers');
|
||||
const { seedHospitalImages } = require('./seeds/hospitalImages');
|
||||
const { seedHomeConfig } = require('./seeds/homeConfig');
|
||||
const { seedAdmins } = require('./seeds/admins');
|
||||
const { getPool } = require('./db');
|
||||
const pool = getPool();
|
||||
await seedHospitals(pool);
|
||||
await seedDepartments(pool);
|
||||
await seedDoctors(pool);
|
||||
await seedExperts(pool);
|
||||
await seedBanners(pool);
|
||||
await seedCenters(pool);
|
||||
await seedHospitalImages(pool);
|
||||
await seedHomeConfig(pool);
|
||||
await seedAdmins(pool);
|
||||
} catch (err) {
|
||||
console.error('[Server] init failed', err);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const server = app.listen(PORT, () => {
|
||||
console.log(`🏥 医院预约挂号 API 已启动: http://localhost:${PORT}`);
|
||||
console.log(`🛠 管理后台: http://localhost:${PORT}/admin/`);
|
||||
console.log(`📘 API 前缀: ${API_PREFIX}`);
|
||||
console.log(`📄 API 文档: http://localhost:${PORT}/api/docs/`);
|
||||
console.log(`🗒 OpenAPI JSON: http://localhost:${PORT}/api/docs.json`);
|
||||
console.log(' 接口列表:');
|
||||
console.log(` GET ${API_PREFIX}/hospitals`);
|
||||
console.log(` GET ${API_PREFIX}/hospitals/:id`);
|
||||
console.log(` GET ${API_PREFIX}/departments`);
|
||||
console.log(` GET ${API_PREFIX}/doctors`);
|
||||
console.log(` GET ${API_PREFIX}/appointments`);
|
||||
console.log(` POST ${API_PREFIX}/appointments`);
|
||||
console.log(` GET ${API_PREFIX}/config/home`);
|
||||
console.log(` GET ${API_PREFIX}/experts`);
|
||||
console.log(` GET ${API_PREFIX}/banners`);
|
||||
console.log(` POST ${API_PREFIX}/admin/auth/login`);
|
||||
});
|
||||
|
||||
const shutdown = async (signal) => {
|
||||
console.log(`\n[Server] received ${signal}, shutting down...`);
|
||||
server.close(() => console.log('[Server] http server closed'));
|
||||
await close();
|
||||
console.log('[Server] db pool closed');
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', () => shutdown('SIGINT'));
|
||||
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
||||
};
|
||||
|
||||
start();
|
||||
@@ -0,0 +1,34 @@
|
||||
# Server Modules
|
||||
|
||||
业务域模块化结构。每个域独立成目录,包含 4 件套:
|
||||
|
||||
```
|
||||
modules/
|
||||
├── hospital/ ← 医院基础信息(只读)
|
||||
│ ├── route.js HTTP 层
|
||||
│ ├── service.js 业务逻辑(所有 SQL 在这)
|
||||
│ ├── dto.js DB row → API DTO
|
||||
│ └── README.md 接口文档
|
||||
├── department/ ← 科室
|
||||
├── doctor/ ← 医生
|
||||
├── appointment/ ← 预约挂号
|
||||
├── expert/ ← 专家资源
|
||||
├── banner/ ← 首页 Banner
|
||||
├── config/ ← 首页整体配置
|
||||
└── adminAuth/ ← 管理员鉴权
|
||||
```
|
||||
|
||||
## 设计原则
|
||||
1. **route.js 不写 SQL**:仅做参数解析、调用 service、返回响应
|
||||
2. **service.js 持有所有 SQL**:业务逻辑集中,方便测试和重构
|
||||
3. **dto.js 只做字段映射**:snake_case → camelCase
|
||||
4. **README.md 是接口契约**:版本变更时必须同步更新
|
||||
|
||||
## 新增业务域流程
|
||||
1. `mkdir modules/<name>/`
|
||||
2. 写 `dto.js`(字段映射)
|
||||
3. 写 `service.js`(SQL + 业务校验)
|
||||
4. 写 `route.js`(HTTP 路由 + JSDoc swagger 注释)
|
||||
5. 写 `README.md`(接口表 + DTO 字段)
|
||||
6. 在 `modules/index.js` 追加 `require` 和 `module.exports`
|
||||
7. 在 `src/index.js` 加 `app.use('/api/v1/<name>', router)`
|
||||
@@ -0,0 +1,21 @@
|
||||
# AdminAuth 模块
|
||||
|
||||
## 职责
|
||||
管理员鉴权(登录 / 注销 / 当前信息 / 修改密码)。
|
||||
|
||||
## 接口
|
||||
| Method | Path | Auth | 说明 |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/v1/admin/auth/login` | - | 登录(返回 token) |
|
||||
| GET | `/api/v1/admin/auth/me` | ✅ | 当前账号信息 |
|
||||
| POST | `/api/v1/admin/auth/change-password` | ✅ | 修改密码 |
|
||||
|
||||
## Token
|
||||
JWT (jsonwebtoken)。请求时 Header `Authorization: Bearer <token>`。
|
||||
|
||||
## 错误码
|
||||
- 200 成功
|
||||
- 400 参数错误(缺字段、密码 < 8 位、原密码错误)
|
||||
- 401 用户名或密码错误
|
||||
- 404 账号不存在
|
||||
- 500 服务器内部错误
|
||||
@@ -0,0 +1,53 @@
|
||||
/// modules/adminAuth/route.js
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { success, fail } = require('../../utils/response');
|
||||
const { authRequired } = require('../../utils/auth');
|
||||
const service = require('./service');
|
||||
|
||||
/** POST /api/v1/admin/auth/login 登录 */
|
||||
router.post('/login', async (req, res) => {
|
||||
try {
|
||||
const out = await service.login(req.body || {});
|
||||
console.log('[Admin] login', out.admin.username);
|
||||
res.json(success(out, '登录成功'));
|
||||
} catch (err) {
|
||||
console.error('[Admin] login error', err);
|
||||
res.status(err.statusCode || 500).json(fail(err.message || '服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/admin/auth/me:
|
||||
* get:
|
||||
* summary: 当前登录账号
|
||||
* tags: [AdminAuth]
|
||||
* security: [{ bearerAuth: [] }]
|
||||
* responses:
|
||||
* 200: { description: ok }
|
||||
* 401: { description: 未登录 }
|
||||
*/
|
||||
router.get('/me', authRequired, async (req, res) => {
|
||||
try {
|
||||
const row = await service.me(req.admin.id);
|
||||
if (!row) return res.status(404).json(fail('账号不存在', 404));
|
||||
res.json(success(row));
|
||||
} catch (err) {
|
||||
console.error('[Admin] me error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/v1/admin/auth/change-password */
|
||||
router.post('/change-password', authRequired, async (req, res) => {
|
||||
try {
|
||||
await service.changePassword(req.admin.id, req.body || {});
|
||||
res.json(success(null, '密码修改成功'));
|
||||
} catch (err) {
|
||||
console.error('[Admin] change-password error', err);
|
||||
res.status(err.statusCode || 500).json(fail(err.message || '服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,44 @@
|
||||
/// modules/adminAuth/service.js
|
||||
const { getPool } = require('../../db');
|
||||
const { comparePassword, hashPassword, signToken } = require('../../utils/auth');
|
||||
|
||||
const login = async ({ username, password }) => {
|
||||
if (!username || !password) throw Object.assign(new Error('用户名 / 密码必填'), { statusCode: 400 });
|
||||
const [rows] = await getPool().query(
|
||||
'SELECT id, username, password_hash, nickname, role FROM admins WHERE username = ? LIMIT 1',
|
||||
[username]
|
||||
);
|
||||
const admin = rows[0];
|
||||
if (!admin) throw Object.assign(new Error('用户名或密码错误'), { statusCode: 401 });
|
||||
const ok = await comparePassword(password, admin.password_hash);
|
||||
if (!ok) throw Object.assign(new Error('用户名或密码错误'), { statusCode: 401 });
|
||||
await getPool().query('UPDATE admins SET last_login_at = NOW() WHERE id = ?', [admin.id]);
|
||||
const token = signToken({ id: admin.id, username: admin.username, role: admin.role });
|
||||
return {
|
||||
token,
|
||||
admin: { id: admin.id, username: admin.username, nickname: admin.nickname, role: admin.role }
|
||||
};
|
||||
};
|
||||
|
||||
const me = async (adminId) => {
|
||||
const [rows] = await getPool().query(
|
||||
'SELECT id, username, nickname, role, last_login_at, created_at FROM admins WHERE id = ?',
|
||||
[adminId]
|
||||
);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const changePassword = async (adminId, { oldPassword, newPassword }) => {
|
||||
if (!oldPassword || !newPassword) throw Object.assign(new Error('原密码 / 新密码必填'), { statusCode: 400 });
|
||||
if (newPassword.length < 8) throw Object.assign(new Error('新密码至少 8 位'), { statusCode: 400 });
|
||||
const [rows] = await getPool().query('SELECT password_hash FROM admins WHERE id = ?', [adminId]);
|
||||
const admin = rows[0];
|
||||
if (!admin) throw Object.assign(new Error('账号不存在'), { statusCode: 404 });
|
||||
const ok = await comparePassword(oldPassword, admin.password_hash);
|
||||
if (!ok) throw Object.assign(new Error('原密码错误'), { statusCode: 400 });
|
||||
const newHash = await hashPassword(newPassword);
|
||||
await getPool().query('UPDATE admins SET password_hash = ? WHERE id = ?', [newHash, adminId]);
|
||||
return true;
|
||||
};
|
||||
|
||||
module.exports = { login, me, changePassword };
|
||||
@@ -0,0 +1,28 @@
|
||||
# Appointment 模块
|
||||
|
||||
## 职责
|
||||
预约挂号(前台创建/取消 + 后台管理)。
|
||||
|
||||
## 接口
|
||||
| Method | Path | Auth | 说明 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/appointments` | - | 列表(?phone&status) |
|
||||
| GET | `/api/v1/appointments/stats` | ✅ | 统计 |
|
||||
| GET | `/api/v1/appointments/:id` | - | 详情 |
|
||||
| POST | `/api/v1/appointments` | - | 创建 |
|
||||
| PUT | `/api/v1/appointments/:id/cancel` | - | 取消 |
|
||||
| PUT | `/api/v1/appointments/:id/confirm` | ✅ | 确认 |
|
||||
| PUT | `/api/v1/appointments/:id/complete` | ✅ | 完成 |
|
||||
| PUT | `/api/v1/appointments/:id` | ✅ | 更新备注 |
|
||||
| DELETE | `/api/v1/appointments/admin/:id` | ✅ | 删除 |
|
||||
|
||||
## 业务规则
|
||||
- 手机号 `^1[3-9]\d{9}$`
|
||||
- 年龄 1-149
|
||||
- 科室仅支持 `肛肠` / `胃肠`
|
||||
- 默认医院取 `hospitals` 表第一条
|
||||
|
||||
## DTO 字段
|
||||
`id` / `hospitalId` / `hospitalName` / `departmentName` / `doctorId` / `doctorName` /
|
||||
`patientName` / `patientAge` / `patientPhone` / `date` / `timeSlot` / `symptom` /
|
||||
`status` / `remark` / `createdAt` / `updatedAt`
|
||||
@@ -0,0 +1,21 @@
|
||||
/// modules/appointment/dto.js
|
||||
const toDto = (row) => ({
|
||||
id: row.id,
|
||||
hospitalId: row.hospital_id,
|
||||
hospitalName: row.hospital_name,
|
||||
departmentName: row.department_name,
|
||||
doctorId: row.doctor_id,
|
||||
doctorName: row.doctor_name,
|
||||
patientName: row.patient_name,
|
||||
patientAge: row.patient_age,
|
||||
patientPhone: row.patient_phone,
|
||||
date: row.date,
|
||||
timeSlot: row.time_slot,
|
||||
symptom: row.symptom,
|
||||
status: row.status,
|
||||
remark: row.remark,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
|
||||
module.exports = { toDto };
|
||||
@@ -0,0 +1,200 @@
|
||||
/// modules/appointment/route.js
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { success, fail } = require('../../utils/response');
|
||||
const { authRequired } = require('../../utils/auth');
|
||||
const service = require('./service');
|
||||
const { toDto } = require('./dto');
|
||||
|
||||
/** GET /api/v1/appointments 预约列表 */
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const rows = await service.list({ phone: req.query.phone, status: req.query.status });
|
||||
res.json(success(rows.map(toDto)));
|
||||
} catch (err) {
|
||||
console.error('[Appointment] list error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/appointments/stats:
|
||||
* get:
|
||||
* summary: 预约统计(管理后台)
|
||||
* tags: [Appointment]
|
||||
* security: [{ bearerAuth: [] }]
|
||||
* responses:
|
||||
* 200: { description: ok }
|
||||
*/
|
||||
router.get('/stats', authRequired, async (_req, res) => {
|
||||
try {
|
||||
res.json(success(await service.stats()));
|
||||
} catch (err) {
|
||||
console.error('[Appointment] stats error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/appointments/{id}:
|
||||
* get:
|
||||
* summary: 预约详情
|
||||
* tags: [Appointment]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* responses:
|
||||
* 200: { description: ok }
|
||||
* 404: { description: 预约不存在 }
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await service.findById(req.params.id);
|
||||
if (!row) return res.status(404).json(fail('预约不存在', 404));
|
||||
res.json(success(toDto(row)));
|
||||
} catch (err) {
|
||||
console.error('[Appointment] detail error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/appointments:
|
||||
* post:
|
||||
* summary: 创建预约
|
||||
* tags: [Appointment]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [patientName, patientAge, patientPhone, date, departmentName]
|
||||
* properties:
|
||||
* patientName: { type: string }
|
||||
* patientAge: { type: integer }
|
||||
* patientPhone: { type: string, example: '13800138000' }
|
||||
* date: { type: string, example: '2026-06-14' }
|
||||
* departmentName: { type: string, enum: ['肛肠','胃肠'] }
|
||||
* doctorId: { type: string }
|
||||
* doctorName: { type: string }
|
||||
* timeSlot: { type: string }
|
||||
* symptom: { type: string }
|
||||
* responses:
|
||||
* 200: { description: 预约成功 }
|
||||
* 400: { description: 参数错误(手机号/年龄/科室) }
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const { patientName, patientAge, patientPhone, date, departmentName, symptom, doctorId, doctorName, timeSlot } = req.body || {};
|
||||
if (!patientName || !patientAge || !patientPhone || !date || !departmentName) {
|
||||
return res.status(400).json(fail('缺少必填参数'));
|
||||
}
|
||||
if (!service.isValidPhone(patientPhone)) return res.status(400).json(fail('手机号格式不正确'));
|
||||
if (!service.isValidAge(patientAge)) return res.status(400).json(fail('年龄参数错误'));
|
||||
if (!['肛肠', '胃肠'].includes(departmentName)) {
|
||||
return res.status(400).json(fail('科室参数仅支持:肛肠 / 胃肠'));
|
||||
}
|
||||
const row = await service.create({ patientName, patientAge, patientPhone, date, departmentName, symptom, doctorId, doctorName, timeSlot });
|
||||
console.log('[Appointment] created', row.id, patientName, patientPhone);
|
||||
res.json(success(toDto(row), '预约成功'));
|
||||
} catch (err) {
|
||||
console.error('[Appointment] create error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/appointments/{id}/cancel:
|
||||
* put:
|
||||
* summary: 取消预约
|
||||
* tags: [Appointment]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* responses:
|
||||
* 200: { description: 已取消 }
|
||||
* 400: { description: 不可取消(已是取消/已完成) }
|
||||
* 404: { description: 预约不存在 }
|
||||
*/
|
||||
router.put('/:id/cancel', async (req, res) => {
|
||||
try {
|
||||
const exists = await service.findById(req.params.id);
|
||||
if (!exists) return res.status(404).json(fail('预约不存在', 404));
|
||||
if (exists.status === 'cancelled') return res.status(400).json(fail('预约已是取消状态'));
|
||||
if (exists.status === 'completed') return res.status(400).json(fail('已完成预约不可取消'));
|
||||
const row = await service.updateStatus(req.params.id, 'cancelled');
|
||||
console.log('[Appointment] cancelled', req.params.id);
|
||||
res.json(success(toDto(row), '已取消'));
|
||||
} catch (err) {
|
||||
console.error('[Appointment] cancel error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/appointments/:id/confirm 确认(管理后台) */
|
||||
router.put('/:id/confirm', authRequired, async (req, res) => {
|
||||
try {
|
||||
const exists = await service.findById(req.params.id);
|
||||
if (!exists) return res.status(404).json(fail('预约不存在', 404));
|
||||
if (exists.status === 'cancelled') return res.status(400).json(fail('已取消的预约不可确认'));
|
||||
const row = await service.updateStatus(req.params.id, 'confirmed');
|
||||
res.json(success(toDto(row), '已确认'));
|
||||
} catch (err) {
|
||||
console.error('[Appointment] confirm error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/appointments/:id/complete 完成(管理后台) */
|
||||
router.put('/:id/complete', authRequired, async (req, res) => {
|
||||
try {
|
||||
const exists = await service.findById(req.params.id);
|
||||
if (!exists) return res.status(404).json(fail('预约不存在', 404));
|
||||
if (exists.status === 'cancelled') return res.status(400).json(fail('已取消的预约不可完成'));
|
||||
const row = await service.updateStatus(req.params.id, 'completed');
|
||||
res.json(success(toDto(row), '已标记完成'));
|
||||
} catch (err) {
|
||||
console.error('[Appointment] complete error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/appointments/:id 更新(备注) */
|
||||
router.put('/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const { remark } = req.body || {};
|
||||
if (remark === undefined) {
|
||||
const row = await service.findById(req.params.id);
|
||||
if (!row) return res.status(404).json(fail('预约不存在', 404));
|
||||
return res.json(success(toDto(row)));
|
||||
}
|
||||
const row = await service.updateRemark(req.params.id, remark);
|
||||
res.json(success(toDto(row), '更新成功'));
|
||||
} catch (err) {
|
||||
console.error('[Appointment] update error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** DELETE /api/v1/appointments/admin/:id 删除(管理后台) */
|
||||
router.delete('/admin/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const row = await service.remove(req.params.id);
|
||||
if (!row) return res.status(404).json(fail('预约不存在', 404));
|
||||
res.json(success(toDto(row), '删除成功'));
|
||||
} catch (err) {
|
||||
console.error('[Appointment] delete error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,79 @@
|
||||
/// modules/appointment/service.js
|
||||
const { getPool } = require('../../db');
|
||||
const { notifyNewAppointment } = require('../notification/service');
|
||||
|
||||
const isValidPhone = (p) => /^1[3-9]\d{9}$/.test(p);
|
||||
const isValidAge = (a) => {
|
||||
const n = Number(a);
|
||||
return Number.isFinite(n) && n > 0 && n < 150;
|
||||
};
|
||||
|
||||
const list = async (filter = {}) => {
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (filter.phone) { where.push('patient_phone = ?'); params.push(filter.phone); }
|
||||
if (filter.status) { where.push('status = ?'); params.push(filter.status); }
|
||||
const sql = `SELECT * FROM appointments ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY created_at DESC`;
|
||||
const [rows] = await getPool().query(sql, params);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const stats = async () => {
|
||||
const [rows] = await getPool().query('SELECT status, COUNT(*) AS c FROM appointments GROUP BY status');
|
||||
const out = { total: 0, pending: 0, confirmed: 0, completed: 0, cancelled: 0 };
|
||||
for (const r of rows) {
|
||||
out.total += r.c;
|
||||
if (out[r.status] !== undefined) out[r.status] = r.c;
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const findById = async (id) => {
|
||||
const [rows] = await getPool().query('SELECT * FROM appointments WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const getDefaultHospital = async () => {
|
||||
const [rows] = await getPool().query('SELECT id, name FROM hospitals ORDER BY id LIMIT 1');
|
||||
return rows[0] || { id: null, name: '苏州肛泰中医院' };
|
||||
};
|
||||
|
||||
const create = async (payload) => {
|
||||
const { patientName, patientAge, patientPhone, date, departmentName,
|
||||
symptom, doctorId, doctorName, timeSlot } = payload;
|
||||
const hospital = await getDefaultHospital();
|
||||
const id = `apt-${Date.now()}`;
|
||||
await getPool().query(
|
||||
`INSERT INTO appointments (id, hospital_id, hospital_name, department_name, doctor_id, doctor_name, patient_name, patient_age, patient_phone, date, time_slot, symptom, status)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending')`,
|
||||
[id, hospital.id, hospital.name, departmentName, doctorId || null, doctorName || null,
|
||||
String(patientName).trim(), Number(patientAge), String(patientPhone).trim(),
|
||||
date, timeSlot || null, (symptom || '').toString().trim()]
|
||||
);
|
||||
const result = await findById(id);
|
||||
// 异步发送通知,不阻塞主流程
|
||||
notifyNewAppointment(result).catch((err) => console.error('[Appointment] notify error:', err.message));
|
||||
return result;
|
||||
};
|
||||
|
||||
const updateStatus = async (id, status) => {
|
||||
await getPool().query('UPDATE appointments SET status = ? WHERE id = ?', [status, id]);
|
||||
return findById(id);
|
||||
};
|
||||
|
||||
const updateRemark = async (id, remark) => {
|
||||
await getPool().query('UPDATE appointments SET remark = ? WHERE id = ?', [remark, id]);
|
||||
return findById(id);
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
const row = await findById(id);
|
||||
if (!row) return null;
|
||||
await getPool().query('DELETE FROM appointments WHERE id = ?', [id]);
|
||||
return row;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
isValidPhone, isValidAge,
|
||||
list, stats, findById, create, updateStatus, updateRemark, remove
|
||||
};
|
||||
@@ -0,0 +1,15 @@
|
||||
# Banner 模块
|
||||
|
||||
## 职责
|
||||
首页轮播图管理。
|
||||
|
||||
## 接口
|
||||
| Method | Path | Auth | 说明 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/banners` | - | 列表 |
|
||||
| POST | `/api/v1/banners` | ✅ | 新增 |
|
||||
| PUT | `/api/v1/banners/:id` | ✅ | 更新 |
|
||||
| DELETE | `/api/v1/banners/:id` | ✅ | 删除 |
|
||||
|
||||
## DTO 字段
|
||||
`id` / `title` / `subtitle` / `image` / `link` / `sort` / `enabled` / `createdAt` / `updatedAt`
|
||||
@@ -0,0 +1,16 @@
|
||||
/// modules/banner/dto.js
|
||||
const { toAbsoluteUrl } = require('../../utils/url');
|
||||
|
||||
const toDto = (row) => ({
|
||||
id: row.id,
|
||||
title: row.title,
|
||||
subtitle: row.subtitle,
|
||||
image: toAbsoluteUrl(row.image),
|
||||
link: row.link,
|
||||
sort: Number(row.sort || 0),
|
||||
enabled: row.enabled === 1 || row.enabled === true,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
|
||||
module.exports = { toDto };
|
||||
@@ -0,0 +1,68 @@
|
||||
/// modules/banner/route.js
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { success, fail } = require('../../utils/response');
|
||||
const { authRequired } = require('../../utils/auth');
|
||||
const service = require('./service');
|
||||
const { toDto } = require('./dto');
|
||||
|
||||
/** GET /api/v1/banners 列表(前台 enabled 过滤) */
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const rows = await service.list({ enabled: req.query.enabled });
|
||||
res.json(success(rows.map(toDto)));
|
||||
} catch (err) {
|
||||
console.error('[Banner] list error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/v1/banners 新增(管理后台) */
|
||||
router.post('/', authRequired, async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
if (!body.title || !body.image) return res.status(400).json(fail('title 和 image 必填'));
|
||||
const id = `b-${Date.now()}`;
|
||||
const fields = {
|
||||
title: body.title,
|
||||
subtitle: body.subtitle || '',
|
||||
image: body.image,
|
||||
link: body.link || '',
|
||||
sort: Number(body.sort) || 99,
|
||||
enabled: body.enabled === false ? 0 : 1
|
||||
};
|
||||
const row = await service.create(id, fields);
|
||||
console.log('[Banner] created', id);
|
||||
res.json(success(toDto(row), '创建成功'));
|
||||
} catch (err) {
|
||||
console.error('[Banner] create error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/banners/:id 更新 */
|
||||
router.put('/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const exists = await service.findById(req.params.id);
|
||||
if (!exists) return res.status(404).json(fail('Banner 不存在', 404));
|
||||
const row = await service.update(req.params.id, req.body || {});
|
||||
res.json(success(toDto(row), '更新成功'));
|
||||
} catch (err) {
|
||||
console.error('[Banner] update error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** DELETE /api/v1/banners/:id 删除 */
|
||||
router.delete('/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const row = await service.remove(req.params.id);
|
||||
if (!row) return res.status(404).json(fail('Banner 不存在', 404));
|
||||
res.json(success(toDto(row), '删除成功'));
|
||||
} catch (err) {
|
||||
console.error('[Banner] delete error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,50 @@
|
||||
/// modules/banner/service.js
|
||||
const { getPool } = require('../../db');
|
||||
|
||||
const list = async (filter = {}) => {
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (filter.enabled === 'true') where.push('enabled = 1');
|
||||
if (filter.enabled === 'false') where.push('enabled = 0');
|
||||
const sql = `SELECT * FROM banners ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY sort ASC, id ASC`;
|
||||
const [rows] = await getPool().query(sql, params);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const findById = async (id) => {
|
||||
const [rows] = await getPool().query('SELECT * FROM banners WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const create = async (id, fields) => {
|
||||
await getPool().query(
|
||||
`INSERT INTO banners (id, title, subtitle, image, link, sort, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, fields.title, fields.subtitle || '', fields.image, fields.link || '', fields.sort, fields.enabled]
|
||||
);
|
||||
return findById(id);
|
||||
};
|
||||
|
||||
const update = async (id, patch) => {
|
||||
const map = { title: 'title', subtitle: 'subtitle', image: 'image', link: 'link' };
|
||||
const fields = [];
|
||||
const params = [];
|
||||
for (const [k, col] of Object.entries(map)) {
|
||||
if (patch[k] !== undefined) { fields.push(`${col} = ?`); params.push(patch[k]); }
|
||||
}
|
||||
if (patch.sort !== undefined) { fields.push('sort = ?'); params.push(Number(patch.sort)); }
|
||||
if (patch.enabled !== undefined) { fields.push('enabled = ?'); params.push(patch.enabled ? 1 : 0); }
|
||||
if (fields.length === 0) return findById(id);
|
||||
params.push(id);
|
||||
await getPool().query(`UPDATE banners SET ${fields.join(', ')} WHERE id = ?`, params);
|
||||
return findById(id);
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
const row = await findById(id);
|
||||
if (!row) return null;
|
||||
await getPool().query('DELETE FROM banners WHERE id = ?', [id]);
|
||||
return row;
|
||||
};
|
||||
|
||||
module.exports = { list, findById, create, update, remove };
|
||||
@@ -0,0 +1,28 @@
|
||||
/// modules/center/dto.js
|
||||
const { toAbsoluteUrl } = require('../../utils/url');
|
||||
|
||||
const toDto = (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
slug: row.slug,
|
||||
icon: row.icon || '',
|
||||
iconImage: toAbsoluteUrl(row.icon_image || ''),
|
||||
intro: row.intro || '',
|
||||
image: toAbsoluteUrl(row.image || ''),
|
||||
sort: Number(row.sort || 0),
|
||||
enabled: row.enabled === 1 || row.enabled === true,
|
||||
createdAt: row.created_at
|
||||
});
|
||||
|
||||
const toCreateDto = (body) => ({
|
||||
name: body.name,
|
||||
slug: body.slug,
|
||||
icon: body.icon,
|
||||
iconImage: body.iconImage,
|
||||
intro: body.intro,
|
||||
image: body.image,
|
||||
sort: body.sort,
|
||||
enabled: body.enabled
|
||||
});
|
||||
|
||||
module.exports = { toDto, toCreateDto };
|
||||
@@ -0,0 +1,68 @@
|
||||
/// modules/center/route.js
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { success, fail } = require('../../utils/response');
|
||||
const { authRequired } = require('../../utils/auth');
|
||||
const service = require('./service');
|
||||
const { toDto, toCreateDto } = require('./dto');
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const rows = await service.list({ enabled: req.query.enabled });
|
||||
res.json(success(rows.map(toDto)));
|
||||
} catch (err) {
|
||||
console.error('[Center] list error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await service.findById(req.params.id);
|
||||
if (!row) return res.status(404).json(fail('中心不存在', 404));
|
||||
res.json(success(toDto(row)));
|
||||
} catch (err) {
|
||||
console.error('[Center] detail error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
router.post('/', authRequired, async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
if (!body.name || !body.slug) return res.status(400).json(fail('name 和 slug 必填'));
|
||||
const id = `c-${Date.now()}`;
|
||||
const row = await service.create(id, toCreateDto(body));
|
||||
res.json(success(toDto(row), '创建成功'));
|
||||
} catch (err) {
|
||||
if (err.code === 'ER_DUP_ENTRY') return res.status(400).json(fail('slug 已存在'));
|
||||
console.error('[Center] create error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
router.put('/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const exists = await service.findById(req.params.id);
|
||||
if (!exists) return res.status(404).json(fail('中心不存在', 404));
|
||||
const row = await service.update(req.params.id, req.body || {});
|
||||
res.json(success(toDto(row), '更新成功'));
|
||||
} catch (err) {
|
||||
if (err.code === 'ER_DUP_ENTRY') return res.status(400).json(fail('slug 已存在'));
|
||||
console.error('[Center] update error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
router.delete('/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const row = await service.remove(req.params.id);
|
||||
if (!row) return res.status(404).json(fail('中心不存在', 404));
|
||||
res.json(success(toDto(row), '删除成功'));
|
||||
} catch (err) {
|
||||
console.error('[Center] delete error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,52 @@
|
||||
/// modules/center/service.js
|
||||
const { getPool } = require('../../db');
|
||||
|
||||
const list = async (filter = {}) => {
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (filter.enabled === 'true') where.push('enabled = 1');
|
||||
if (filter.enabled === 'false') where.push('enabled = 0');
|
||||
const sql = `SELECT * FROM centers ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY sort ASC, id ASC`;
|
||||
const [rows] = await getPool().query(sql, params);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const findById = async (id) => {
|
||||
const [rows] = await getPool().query('SELECT * FROM centers WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const create = async (id, fields) => {
|
||||
await getPool().query(
|
||||
`INSERT INTO centers (id, name, slug, icon, icon_image, intro, image, sort, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, fields.name, fields.slug, fields.icon || '', fields.iconImage || '',
|
||||
fields.intro || '', fields.image || '',
|
||||
Number(fields.sort) || 99, fields.enabled === false ? 0 : 1]
|
||||
);
|
||||
return findById(id);
|
||||
};
|
||||
|
||||
const update = async (id, patch) => {
|
||||
const map = { name: 'name', slug: 'slug', icon: 'icon', iconImage: 'icon_image', intro: 'intro', image: 'image' };
|
||||
const fields = [];
|
||||
const params = [];
|
||||
for (const [k, col] of Object.entries(map)) {
|
||||
if (patch[k] !== undefined) { fields.push(`${col} = ?`); params.push(patch[k]); }
|
||||
}
|
||||
if (patch.sort !== undefined) { fields.push('sort = ?'); params.push(Number(patch.sort)); }
|
||||
if (patch.enabled !== undefined) { fields.push('enabled = ?'); params.push(patch.enabled ? 1 : 0); }
|
||||
if (fields.length === 0) return findById(id);
|
||||
params.push(id);
|
||||
await getPool().query(`UPDATE centers SET ${fields.join(', ')} WHERE id = ?`, params);
|
||||
return findById(id);
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
const row = await findById(id);
|
||||
if (!row) return null;
|
||||
await getPool().query('DELETE FROM centers WHERE id = ?', [id]);
|
||||
return row;
|
||||
};
|
||||
|
||||
module.exports = { list, findById, create, update, remove };
|
||||
@@ -0,0 +1,19 @@
|
||||
# Config 模块
|
||||
|
||||
## 职责
|
||||
首页整体配置(存储在 `home_config.data` JSON 列)。
|
||||
|
||||
## 接口
|
||||
| Method | Path | Auth | 说明 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/config/home` | - | 读取首页配置 |
|
||||
| PUT | `/api/v1/config/home` | ✅ | 整体更新 |
|
||||
| PUT | `/api/v1/config/home/hero` | ✅ | 更新 Hero |
|
||||
| PUT | `/api/v1/config/home/features` | ✅ | 替换功能卡 |
|
||||
| PUT | `/api/v1/config/home/expert-team` | ✅ | 替换专家团队 |
|
||||
| PUT | `/api/v1/config/home/info-banner` | ✅ | 更新信息条 |
|
||||
| PUT | `/api/v1/config/home/hero-experts` | ✅ | 替换 Hero 专家 |
|
||||
|
||||
## 数据形态
|
||||
`home_config.data` 是 JSON 字符串,包含:`hero` / `features` / `expertTeam` /
|
||||
`infoBanner` / `heroExperts` 等键。
|
||||
@@ -0,0 +1,110 @@
|
||||
/// modules/config/route.js
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { success, fail } = require('../../utils/response');
|
||||
const { authRequired } = require('../../utils/auth');
|
||||
const service = require('./service');
|
||||
|
||||
/** GET /api/v1/config/home 前台读取首页配置 */
|
||||
router.get('/home', async (_req, res) => {
|
||||
try {
|
||||
const data = await service.getHomeConfig();
|
||||
if (!data) return res.status(404).json(fail('首页配置未初始化', 404));
|
||||
res.json(success(data));
|
||||
} catch (err) {
|
||||
console.error('[Config] get home error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/config/home 整体更新(管理后台) */
|
||||
router.put('/home', authRequired, async (req, res) => {
|
||||
try {
|
||||
const merged = await service.updateHomeConfig(req.body || {});
|
||||
res.json(success(merged, '更新成功'));
|
||||
} catch (err) {
|
||||
console.error('[Config] update home error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/config/home/hero */
|
||||
router.put('/home/hero', authRequired, async (req, res) => {
|
||||
try {
|
||||
const out = await service.updateHomeSection('hero', req.body || {}, false);
|
||||
res.json(success(out, '更新成功'));
|
||||
} catch (err) {
|
||||
console.error('[Config] update hero error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/config/home/features 整体替换 */
|
||||
router.put('/home/features', authRequired, async (req, res) => {
|
||||
try {
|
||||
const out = await service.updateHomeSection('features', req.body || {}, true);
|
||||
res.json(success(out, '更新成功'));
|
||||
} catch (err) {
|
||||
console.error('[Config] update features error', err);
|
||||
res.status(err.statusCode || 500).json(fail(err.message || '服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/config/home/expert-team 整体替换 */
|
||||
router.put('/home/expert-team', authRequired, async (req, res) => {
|
||||
try {
|
||||
const out = await service.updateHomeSection('expertTeam', req.body || {}, true);
|
||||
res.json(success(out, '更新成功'));
|
||||
} catch (err) {
|
||||
console.error('[Config] update expert team error', err);
|
||||
res.status(err.statusCode || 500).json(fail(err.message || '服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/config/home/info-banner */
|
||||
router.put('/home/info-banner', authRequired, async (req, res) => {
|
||||
try {
|
||||
const out = await service.updateHomeSection('infoBanner', req.body || {}, false);
|
||||
res.json(success(out, '更新成功'));
|
||||
} catch (err) {
|
||||
console.error('[Config] update info banner error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/config/home/hero-experts 整体替换 */
|
||||
router.put('/home/hero-experts', authRequired, async (req, res) => {
|
||||
try {
|
||||
const out = await service.updateHomeSection('heroExperts', req.body || {}, true);
|
||||
res.json(success(out, '更新成功'));
|
||||
} catch (err) {
|
||||
console.error('[Config] update hero experts error', err);
|
||||
res.status(err.statusCode || 500).json(fail(err.message || '服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
|
||||
/** ============ 站点级配置 (电话/地址/营业时间) ============ */
|
||||
|
||||
/** GET /api/v1/config/site 前台读取 */
|
||||
router.get('/site', async (_req, res) => {
|
||||
try {
|
||||
const data = await service.getSiteConfig();
|
||||
res.json(success(data));
|
||||
} catch (err) {
|
||||
console.error('[Config] get site error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/config/site 管理后台更新 */
|
||||
router.put('/site', authRequired, async (req, res) => {
|
||||
try {
|
||||
const out = await service.updateSiteConfig(req.body || {});
|
||||
res.json(success(out, '已保存'));
|
||||
} catch (err) {
|
||||
console.error('[Config] update site error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,90 @@
|
||||
/// modules/config/service.js
|
||||
const { getPool } = require('../../db');
|
||||
const { toAbsoluteUrl } = require('../../utils/url');
|
||||
|
||||
/**
|
||||
* 智能识别字符串是否像图片/静态资源 URL(避免把普通标题/路由/CSS 误判为 URL)
|
||||
* - 已是绝对地址 (http/https/mailto 等) → 转
|
||||
* - 以 // 开头 → 转
|
||||
* - 以 /uploads/ 开头 → 转 (本项目静态资源统一放 /uploads/)
|
||||
* - 其它(/pages/... 路由、CSS 渐变、中文标题等)→ 跳过
|
||||
*/
|
||||
const isLikelyUrl = (s) => {
|
||||
if (typeof s !== 'string') return false;
|
||||
const t = s.trim();
|
||||
if (!t) return false;
|
||||
if (/^https?:\/\//i.test(t)) return true;
|
||||
if (t.startsWith('//')) return true;
|
||||
// 本项目约定的静态资源目录前缀,其它 / 开头的一律按路由处理
|
||||
if (t.startsWith('/uploads/')) return true;
|
||||
return false;
|
||||
};
|
||||
|
||||
/** 递归把对象/数组中"看起来像 URL"的字符串字段转成绝对 URL,其它字段原样返回 */
|
||||
const absolutizeDeep = (val) => {
|
||||
if (Array.isArray(val)) return val.map(absolutizeDeep);
|
||||
if (val && typeof val === 'object') {
|
||||
const out = {};
|
||||
for (const k of Object.keys(val)) out[k] = absolutizeDeep(val[k]);
|
||||
return out;
|
||||
}
|
||||
if (isLikelyUrl(val)) return toAbsoluteUrl(val);
|
||||
return val;
|
||||
};
|
||||
|
||||
const parseRow = (row) => {
|
||||
if (!row) return null;
|
||||
if (typeof row.data === 'string') {
|
||||
try { return JSON.parse(row.data); } catch (e) { return null; }
|
||||
}
|
||||
return row.data;
|
||||
};
|
||||
|
||||
const getHomeConfig = async () => {
|
||||
const [rows] = await getPool().query('SELECT data FROM home_config WHERE id = 1');
|
||||
return absolutizeDeep(parseRow(rows[0]));
|
||||
};
|
||||
|
||||
/** 读取原始数据(不做 URL 转换),用于 update 时合并,保证存回 DB 的还是相对路径 */
|
||||
const getRawHomeConfig = async () => {
|
||||
const [rows] = await getPool().query('SELECT data FROM home_config WHERE id = 1');
|
||||
return parseRow(rows[0]) || {};
|
||||
};
|
||||
|
||||
const updateHomeConfig = async (patch) => {
|
||||
const current = await getRawHomeConfig();
|
||||
const merged = { ...current, ...patch };
|
||||
await getPool().query('UPDATE home_config SET data = ? WHERE id = 1', [JSON.stringify(merged)]);
|
||||
return absolutizeDeep(merged);
|
||||
};
|
||||
|
||||
const updateHomeSection = async (key, body, isArray = false) => {
|
||||
if (isArray) {
|
||||
if (!Array.isArray(body[key])) throw new Error(`${key} 必须为数组`);
|
||||
}
|
||||
const current = await getRawHomeConfig();
|
||||
current[key] = isArray ? body[key] : { ...(current[key] || {}), ...body };
|
||||
await getPool().query('UPDATE home_config SET data = ? WHERE id = 1', [JSON.stringify(current)]);
|
||||
return absolutizeDeep(current[key]);
|
||||
};
|
||||
|
||||
const getSiteConfig = async () => {
|
||||
const [rows] = await getPool().query('SELECT data FROM home_config WHERE id = 2');
|
||||
return parseRow(rows[0]) || { phone: '0512-88889999', address: '', workHours: '7×24' };
|
||||
};
|
||||
|
||||
const updateSiteConfig = async (patch) => {
|
||||
const current = (await getSiteConfig()) || {};
|
||||
const merged = { ...current, ...patch };
|
||||
const json = JSON.stringify(merged);
|
||||
// upsert (id=2 不存在时插入)
|
||||
const [exists] = await getPool().query('SELECT id FROM home_config WHERE id = 2');
|
||||
if (exists.length > 0) {
|
||||
await getPool().query('UPDATE home_config SET data = ? WHERE id = 2', [json]);
|
||||
} else {
|
||||
await getPool().query('INSERT INTO home_config (id, data) VALUES (2, ?)', [json]);
|
||||
}
|
||||
return merged;
|
||||
};
|
||||
|
||||
module.exports = { getHomeConfig, updateHomeConfig, updateHomeSection, getSiteConfig, updateSiteConfig };
|
||||
@@ -0,0 +1,12 @@
|
||||
# Department 模块
|
||||
|
||||
## 职责
|
||||
科室资源查询。
|
||||
|
||||
## 接口
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/departments` | 科室列表(?hospitalId&enabled) |
|
||||
|
||||
## DTO 字段
|
||||
`id` / `name` / `icon` / `description` / `doctorCount` / `sort` / `enabled` / `createdAt` / `updatedAt`
|
||||
@@ -0,0 +1,14 @@
|
||||
/// modules/department/dto.js
|
||||
const toDto = (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
icon: row.icon,
|
||||
description: row.description,
|
||||
doctorCount: Number(row.doctor_count || 0),
|
||||
sort: Number(row.sort || 0),
|
||||
enabled: row.enabled === 1 || row.enabled === true,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
|
||||
module.exports = { toDto };
|
||||
@@ -0,0 +1,37 @@
|
||||
/// modules/department/route.js
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { success, fail } = require('../../utils/response');
|
||||
const service = require('./service');
|
||||
const { toDto } = require('./dto');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/departments:
|
||||
* get:
|
||||
* summary: 科室列表
|
||||
* tags: [Department]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: hospitalId
|
||||
* schema: { type: string }
|
||||
* - in: query
|
||||
* name: enabled
|
||||
* schema: { type: string, enum: ['true','false'] }
|
||||
* responses:
|
||||
* 200: { description: ok }
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const rows = await service.list({
|
||||
hospitalId: req.query.hospitalId,
|
||||
enabled: req.query.enabled
|
||||
});
|
||||
res.json(success(rows.map(toDto)));
|
||||
} catch (err) {
|
||||
console.error('[Department] list error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,19 @@
|
||||
/// modules/department/service.js
|
||||
const { getPool } = require('../../db');
|
||||
|
||||
/**
|
||||
* 列出科室(支持 hospitalId / enabled 过滤)
|
||||
* @param {{ hospitalId?: string, enabled?: 'true'|'false' }} filter
|
||||
*/
|
||||
const list = async (filter = {}) => {
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (filter.hospitalId) { where.push('hospital_id = ?'); params.push(filter.hospitalId); }
|
||||
if (filter.enabled === 'true') where.push('enabled = 1');
|
||||
if (filter.enabled === 'false') where.push('enabled = 0');
|
||||
const sql = `SELECT * FROM departments ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY sort ASC, id ASC`;
|
||||
const [rows] = await getPool().query(sql, params);
|
||||
return rows;
|
||||
};
|
||||
|
||||
module.exports = { list };
|
||||
@@ -0,0 +1,18 @@
|
||||
# Doctor 模块
|
||||
|
||||
## 职责
|
||||
医生资源(前台展示 + 后台管理)。
|
||||
|
||||
## 接口
|
||||
| Method | Path | Auth | 说明 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/doctors` | - | 列表(?departmentId&departmentName&enabled) |
|
||||
| GET | `/api/v1/doctors/:id` | - | 详情 |
|
||||
| POST | `/api/v1/doctors/admin` | ✅ | 新增 |
|
||||
| PUT | `/api/v1/doctors/admin/:id` | ✅ | 更新 |
|
||||
| DELETE | `/api/v1/doctors/admin/:id` | ✅ | 删除 |
|
||||
|
||||
## DTO 字段
|
||||
`id` / `name` / `title` / `departmentId` / `departmentName` / `avatar` /
|
||||
`specialty` / `intro` / `fee` / `rating` / `availableDates`(string[])/
|
||||
`enabled` / `createdAt` / `updatedAt`
|
||||
@@ -0,0 +1,35 @@
|
||||
/// modules/doctor/dto.js
|
||||
const toDto = (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
title: row.title,
|
||||
departmentId: row.department_id,
|
||||
departmentName: row.department_name,
|
||||
avatar: row.avatar,
|
||||
specialty: row.specialty,
|
||||
intro: row.intro,
|
||||
fee: Number(row.fee),
|
||||
rating: Number(row.rating),
|
||||
availableDates: typeof row.available_dates === 'string'
|
||||
? JSON.parse(row.available_dates)
|
||||
: (row.available_dates || []),
|
||||
enabled: row.enabled === 1 || row.enabled === true,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
|
||||
const toCreateDto = (input = {}) => ({
|
||||
name: input.name,
|
||||
title: input.title,
|
||||
departmentId: input.departmentId || null,
|
||||
departmentName: input.departmentName || null,
|
||||
avatar: input.avatar || '',
|
||||
specialty: input.specialty || '',
|
||||
intro: input.intro || '',
|
||||
fee: Number(input.fee) || 0,
|
||||
rating: Number(input.rating) || 0,
|
||||
availableDates: input.availableDates || [],
|
||||
enabled: input.enabled === false ? 0 : 1
|
||||
});
|
||||
|
||||
module.exports = { toDto, toCreateDto };
|
||||
@@ -0,0 +1,111 @@
|
||||
/// modules/doctor/route.js
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { success, fail } = require('../../utils/response');
|
||||
const { authRequired } = require('../../utils/auth');
|
||||
const service = require('./service');
|
||||
const { toDto, toCreateDto } = require('./dto');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/doctors:
|
||||
* get:
|
||||
* summary: 医生列表(前台)
|
||||
* tags: [Doctor]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: departmentId
|
||||
* schema: { type: string }
|
||||
* - in: query
|
||||
* name: departmentName
|
||||
* schema: { type: string }
|
||||
* - in: query
|
||||
* name: enabled
|
||||
* schema: { type: string, enum: ['true','false'] }
|
||||
* responses:
|
||||
* 200: { description: ok, content: { application/json: { schema: { type: object, properties: { data: { type: 'array', items: { $ref: '#/components/schemas/Doctor' } } } } } } }
|
||||
*/
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const rows = await service.list({
|
||||
departmentId: req.query.departmentId,
|
||||
departmentName: req.query.departmentName,
|
||||
enabled: req.query.enabled
|
||||
});
|
||||
res.json(success(rows.map(toDto)));
|
||||
} catch (err) {
|
||||
console.error('[Doctor] list error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/doctors/{id}:
|
||||
* get:
|
||||
* summary: 医生详情
|
||||
* tags: [Doctor]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* responses:
|
||||
* 200: { description: ok }
|
||||
* 404: { description: 医生不存在 }
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await service.findById(req.params.id);
|
||||
if (!row) return res.status(404).json(fail('医生不存在', 404));
|
||||
res.json(success(toDto(row)));
|
||||
} catch (err) {
|
||||
console.error('[Doctor] detail error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/* ============ 管理后台(需鉴权) ============ */
|
||||
|
||||
/** POST /api/v1/doctors/admin 新增医生 */
|
||||
router.post('/admin', authRequired, async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
if (!body.name || !body.title) return res.status(400).json(fail('name/title 必填'));
|
||||
const id = `doc-${Date.now()}`;
|
||||
const fields = toCreateDto(body);
|
||||
const row = await service.create(id, fields);
|
||||
console.log('[Doctor] created', id, body.name);
|
||||
res.json(success(toDto(row), '创建成功'));
|
||||
} catch (err) {
|
||||
console.error('[Doctor] create error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/doctors/admin/:id 更新医生 */
|
||||
router.put('/admin/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const exists = await service.findById(req.params.id);
|
||||
if (!exists) return res.status(404).json(fail('医生不存在', 404));
|
||||
const row = await service.update(req.params.id, req.body || {});
|
||||
res.json(success(toDto(row), '更新成功'));
|
||||
} catch (err) {
|
||||
console.error('[Doctor] update error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** DELETE /api/v1/doctors/admin/:id 删除医生 */
|
||||
router.delete('/admin/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const row = await service.remove(req.params.id);
|
||||
if (!row) return res.status(404).json(fail('医生不存在', 404));
|
||||
res.json(success(toDto(row), '删除成功'));
|
||||
} catch (err) {
|
||||
console.error('[Doctor] delete error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,58 @@
|
||||
/// modules/doctor/service.js
|
||||
const { getPool } = require('../../db');
|
||||
|
||||
/** 列出医生(支持 departmentId / departmentName / enabled 过滤) */
|
||||
const list = async (filter = {}) => {
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (filter.departmentId) { where.push('department_id = ?'); params.push(filter.departmentId); }
|
||||
if (filter.departmentName) { where.push('department_name = ?'); params.push(filter.departmentName); }
|
||||
if (filter.enabled === 'true') where.push('enabled = 1');
|
||||
if (filter.enabled === 'false') where.push('enabled = 0');
|
||||
const sql = `SELECT * FROM doctors ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY id ASC`;
|
||||
const [rows] = await getPool().query(sql, params);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const findById = async (id) => {
|
||||
const [rows] = await getPool().query('SELECT * FROM doctors WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const create = async (id, fields) => {
|
||||
await getPool().query(
|
||||
`INSERT INTO doctors (id, name, title, department_id, department_name, avatar, specialty, intro, fee, rating, available_dates, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, fields.name, fields.title, fields.departmentId, fields.departmentName,
|
||||
fields.avatar, fields.specialty, fields.intro, fields.fee, fields.rating,
|
||||
JSON.stringify(fields.availableDates), fields.enabled]
|
||||
);
|
||||
return findById(id);
|
||||
};
|
||||
|
||||
const update = async (id, patch) => {
|
||||
const map = { name: 'name', title: 'title', departmentId: 'department_id',
|
||||
departmentName: 'department_name', avatar: 'avatar', specialty: 'specialty', intro: 'intro' };
|
||||
const fields = [];
|
||||
const params = [];
|
||||
for (const [k, col] of Object.entries(map)) {
|
||||
if (patch[k] !== undefined) { fields.push(`${col} = ?`); params.push(patch[k]); }
|
||||
}
|
||||
if (patch.fee !== undefined) { fields.push('fee = ?'); params.push(Number(patch.fee)); }
|
||||
if (patch.rating !== undefined) { fields.push('rating = ?'); params.push(Number(patch.rating)); }
|
||||
if (patch.availableDates !== undefined) { fields.push('available_dates = ?'); params.push(JSON.stringify(patch.availableDates)); }
|
||||
if (patch.enabled !== undefined) { fields.push('enabled = ?'); params.push(patch.enabled ? 1 : 0); }
|
||||
if (fields.length === 0) return findById(id);
|
||||
params.push(id);
|
||||
await getPool().query(`UPDATE doctors SET ${fields.join(', ')} WHERE id = ?`, params);
|
||||
return findById(id);
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
const row = await findById(id);
|
||||
if (!row) return null;
|
||||
await getPool().query('DELETE FROM doctors WHERE id = ?', [id]);
|
||||
return row;
|
||||
};
|
||||
|
||||
module.exports = { list, findById, create, update, remove };
|
||||
@@ -0,0 +1,17 @@
|
||||
# Expert 模块
|
||||
|
||||
## 职责
|
||||
首页专家资源(Hero 头像 / 专家团队 / 标签)。前台只读,后台管理。
|
||||
|
||||
## 接口
|
||||
| Method | Path | Auth | 说明 |
|
||||
|---|---|---|---|
|
||||
| GET | `/api/v1/experts` | - | 列表 |
|
||||
| GET | `/api/v1/experts/:id` | - | 详情 |
|
||||
| POST | `/api/v1/experts` | ✅ | 新增 |
|
||||
| PUT | `/api/v1/experts/:id` | ✅ | 更新 |
|
||||
| DELETE | `/api/v1/experts/:id` | ✅ | 删除 |
|
||||
| POST | `/api/v1/experts/upload` | ✅ | 上传(url / base64) |
|
||||
|
||||
## DTO 字段
|
||||
`id` / `name` / `title` / `avatar` / `department` / `intro` / `sort` / `enabled` / `createdAt` / `updatedAt`
|
||||
@@ -0,0 +1,25 @@
|
||||
/// modules/expert/dto.js
|
||||
const toDto = (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
title: row.title,
|
||||
avatar: row.avatar,
|
||||
department: row.department,
|
||||
intro: row.intro,
|
||||
sort: Number(row.sort || 0),
|
||||
enabled: row.enabled === 1 || row.enabled === true,
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
|
||||
const toCreateDto = (input = {}) => ({
|
||||
name: input.name,
|
||||
title: input.title,
|
||||
avatar: input.avatar,
|
||||
department: input.department || '',
|
||||
intro: input.intro || '',
|
||||
sort: Number(input.sort) || 99,
|
||||
enabled: input.enabled === false ? 0 : 1
|
||||
});
|
||||
|
||||
module.exports = { toDto, toCreateDto };
|
||||
@@ -0,0 +1,100 @@
|
||||
/// modules/expert/route.js
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { success, fail } = require('../../utils/response');
|
||||
const { authRequired } = require('../../utils/auth');
|
||||
const service = require('./service');
|
||||
const { toDto, toCreateDto } = require('./dto');
|
||||
|
||||
/** GET /api/v1/experts 专家列表 */
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const rows = await service.list({ enabled: req.query.enabled });
|
||||
res.json(success(rows.map(toDto)));
|
||||
} catch (err) {
|
||||
console.error('[Expert] list error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/experts/{id}:
|
||||
* get:
|
||||
* summary: 专家详情
|
||||
* tags: [Expert]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* responses:
|
||||
* 200: { description: ok }
|
||||
* 404: { description: 专家不存在 }
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await service.findById(req.params.id);
|
||||
if (!row) return res.status(404).json(fail('专家不存在', 404));
|
||||
res.json(success(toDto(row)));
|
||||
} catch (err) {
|
||||
console.error('[Expert] detail error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/* ============ 管理后台 ============ */
|
||||
|
||||
/** POST /api/v1/experts 新增 */
|
||||
router.post('/', authRequired, async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
if (!body.name || !body.title || !body.avatar) return res.status(400).json(fail('name/title/avatar 必填'));
|
||||
const id = `he-${Date.now()}`;
|
||||
const fields = toCreateDto(body);
|
||||
const row = await service.create(id, fields);
|
||||
console.log('[Expert] created', id);
|
||||
res.json(success(toDto(row), '创建成功'));
|
||||
} catch (err) {
|
||||
console.error('[Expert] create error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/experts/:id 更新 */
|
||||
router.put('/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const exists = await service.findById(req.params.id);
|
||||
if (!exists) return res.status(404).json(fail('专家不存在', 404));
|
||||
const row = await service.update(req.params.id, req.body || {});
|
||||
res.json(success(toDto(row), '更新成功'));
|
||||
} catch (err) {
|
||||
console.error('[Expert] update error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** DELETE /api/v1/experts/:id 删除 */
|
||||
router.delete('/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const row = await service.remove(req.params.id);
|
||||
if (!row) return res.status(404).json(fail('专家不存在', 404));
|
||||
res.json(success(toDto(row), '删除成功'));
|
||||
} catch (err) {
|
||||
console.error('[Expert] delete error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/v1/experts/upload 上传资源 */
|
||||
router.post('/upload', authRequired, async (req, res) => {
|
||||
try {
|
||||
const out = service.upload(req.body || {});
|
||||
res.json(success(out, '上传成功'));
|
||||
} catch (err) {
|
||||
console.error('[Expert] upload error', err);
|
||||
res.status(400).json(fail(err.message || '上传失败'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,59 @@
|
||||
/// modules/expert/service.js
|
||||
const { getPool } = require('../../db');
|
||||
|
||||
const list = async (filter = {}) => {
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (filter.enabled === 'true') where.push('enabled = 1');
|
||||
if (filter.enabled === 'false') where.push('enabled = 0');
|
||||
const sql = `SELECT * FROM experts ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY sort ASC, id ASC`;
|
||||
const [rows] = await getPool().query(sql, params);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const findById = async (id) => {
|
||||
const [rows] = await getPool().query('SELECT * FROM experts WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const create = async (id, fields) => {
|
||||
await getPool().query(
|
||||
`INSERT INTO experts (id, name, title, avatar, department, intro, sort, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, fields.name, fields.title, fields.avatar, fields.department, fields.intro, fields.sort, fields.enabled]
|
||||
);
|
||||
return findById(id);
|
||||
};
|
||||
|
||||
const update = async (id, patch) => {
|
||||
const map = { name: 'name', title: 'title', avatar: 'avatar', department: 'department', intro: 'intro' };
|
||||
const fields = [];
|
||||
const params = [];
|
||||
for (const [k, col] of Object.entries(map)) {
|
||||
if (patch[k] !== undefined) { fields.push(`${col} = ?`); params.push(patch[k]); }
|
||||
}
|
||||
if (patch.sort !== undefined) { fields.push('sort = ?'); params.push(Number(patch.sort)); }
|
||||
if (patch.enabled !== undefined) { fields.push('enabled = ?'); params.push(patch.enabled ? 1 : 0); }
|
||||
if (fields.length === 0) return findById(id);
|
||||
params.push(id);
|
||||
await getPool().query(`UPDATE experts SET ${fields.join(', ')} WHERE id = ?`, params);
|
||||
return findById(id);
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
const row = await findById(id);
|
||||
if (!row) return null;
|
||||
await getPool().query('DELETE FROM experts WHERE id = ?', [id]);
|
||||
return row;
|
||||
};
|
||||
|
||||
const upload = ({ url, base64, filename: _filename }) => {
|
||||
if (url) return { url };
|
||||
if (base64) {
|
||||
const dataUrl = base64.startsWith('data:') ? base64 : `data:image/png;base64,${base64}`;
|
||||
return { url: dataUrl };
|
||||
}
|
||||
throw new Error('缺少 url 或 base64');
|
||||
};
|
||||
|
||||
module.exports = { list, findById, create, update, remove, upload };
|
||||
@@ -0,0 +1,11 @@
|
||||
/// modules/homeIntro/dto.js
|
||||
const { toAbsoluteUrl } = require('../../utils/url');
|
||||
|
||||
const toDto = (row) => row && {
|
||||
id: row.id,
|
||||
image: toAbsoluteUrl(row.image || ''),
|
||||
caption: row.caption || '',
|
||||
enabled: row.enabled === 1 || row.enabled === true,
|
||||
updatedAt: row.updated_at
|
||||
};
|
||||
module.exports = { toDto };
|
||||
@@ -0,0 +1,31 @@
|
||||
/// modules/homeIntro/route.js
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { success, fail } = require('../../utils/response');
|
||||
const { authRequired } = require('../../utils/auth');
|
||||
const service = require('./service');
|
||||
const { toDto } = require('./dto');
|
||||
|
||||
/** GET /api/v1/home-intro 公开获取 */
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const row = await service.get();
|
||||
res.json(success(toDto(row)));
|
||||
} catch (err) {
|
||||
console.error('[HomeIntro] get error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/home-intro 管理后台 upsert */
|
||||
router.put('/', authRequired, async (req, res) => {
|
||||
try {
|
||||
const row = await service.upsert(req.body || {});
|
||||
res.json(success(toDto(row), '已保存'));
|
||||
} catch (err) {
|
||||
console.error('[HomeIntro] upsert error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,30 @@
|
||||
/// modules/homeIntro/service.js
|
||||
const { getPool } = require('../../db');
|
||||
|
||||
const SINGLE_ID = 'home-intro';
|
||||
|
||||
const get = async () => {
|
||||
const [rows] = await getPool().query('SELECT * FROM home_intro WHERE id = ?', [SINGLE_ID]);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const upsert = async (patch) => {
|
||||
const exists = await get();
|
||||
if (!exists) {
|
||||
await getPool().query(
|
||||
'INSERT INTO home_intro (id, image, caption, enabled) VALUES (?, ?, ?, ?)',
|
||||
[SINGLE_ID, patch.image || '', patch.caption || '', patch.enabled === false ? 0 : 1]
|
||||
);
|
||||
} else {
|
||||
const fields = [];
|
||||
const params = [];
|
||||
if (patch.image !== undefined) { fields.push('image = ?'); params.push(patch.image); }
|
||||
if (patch.caption !== undefined) { fields.push('caption = ?'); params.push(patch.caption); }
|
||||
if (patch.enabled !== undefined) { fields.push('enabled = ?'); params.push(patch.enabled ? 1 : 0); }
|
||||
if (fields.length === 0) return get();
|
||||
await getPool().query(`UPDATE home_intro SET ${fields.join(', ')} WHERE id = ?`, [...params, SINGLE_ID]);
|
||||
}
|
||||
return get();
|
||||
};
|
||||
|
||||
module.exports = { get, upsert, SINGLE_ID };
|
||||
@@ -0,0 +1,30 @@
|
||||
# Hospital 模块
|
||||
|
||||
## 职责
|
||||
医院基础信息查询。**只读**,无写操作。
|
||||
|
||||
## 文件结构
|
||||
- `dto.js` DB row → API DTO 转换
|
||||
- `service.js` 所有 SQL 集中(listAll / findById)
|
||||
- `route.js` HTTP 层(参数解析 + 调 service + 返回响应)
|
||||
- `README.md` 本文件
|
||||
|
||||
## 接口
|
||||
|
||||
| Method | Path | 说明 |
|
||||
|---|---|---|
|
||||
| GET | `/api/v1/hospitals` | 医院列表 |
|
||||
| GET | `/api/v1/hospitals/:id` | 医院详情 |
|
||||
|
||||
## DTO 字段
|
||||
`id` / `name` / `level` / `address` / `phone` / `intro` / `description` /
|
||||
`banner` / `logo` / `established` / `departments` / `doctors` / `beds` /
|
||||
`createdAt` / `updatedAt`
|
||||
|
||||
## 错误码
|
||||
- `200` 成功
|
||||
- `404` 医院不存在
|
||||
- `500` 服务器内部错误
|
||||
|
||||
## 维护者
|
||||
Hospital 域 · 详见 `docs/api/v1/hospital.md`
|
||||
@@ -0,0 +1,27 @@
|
||||
/// modules/hospital/dto.js
|
||||
/// 描述:医院资源的数据转换层(DB row → API DTO)
|
||||
|
||||
/**
|
||||
* 转换数据库行为 API DTO
|
||||
* @param {object} row - hospitals 表原始行
|
||||
* @returns {object} DTO 对象
|
||||
*/
|
||||
const toDto = (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
level: row.level,
|
||||
address: row.address,
|
||||
phone: row.phone,
|
||||
intro: row.intro,
|
||||
description: row.description,
|
||||
banner: row.banner,
|
||||
logo: row.logo,
|
||||
established: row.established,
|
||||
departments: Number(row.departments || 0),
|
||||
doctors: Number(row.doctors || 0),
|
||||
beds: Number(row.beds || 0),
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
|
||||
module.exports = { toDto };
|
||||
@@ -0,0 +1,55 @@
|
||||
/// modules/hospital/route.js
|
||||
/// 描述:医院资源路由(HTTP 层,只做参数解析 + 调用 service + 返回)
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { success, fail } = require('../../utils/response');
|
||||
const service = require('./service');
|
||||
const { toDto } = require('./dto');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/hospitals:
|
||||
* get:
|
||||
* summary: 医院列表
|
||||
* tags: [Hospital]
|
||||
* responses:
|
||||
* 200:
|
||||
* description: ok
|
||||
*/
|
||||
router.get('/', async (_req, res) => {
|
||||
try {
|
||||
const rows = await service.listAll();
|
||||
res.json(success(rows.map(toDto)));
|
||||
} catch (err) {
|
||||
console.error('[Hospital] list error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/hospitals/{id}:
|
||||
* get:
|
||||
* summary: 医院详情
|
||||
* tags: [Hospital]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* responses:
|
||||
* 200: { description: ok }
|
||||
* 404: { description: 医院不存在 }
|
||||
*/
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await service.findById(req.params.id);
|
||||
if (!row) return res.status(404).json(fail('医院不存在', 404));
|
||||
res.json(success(toDto(row)));
|
||||
} catch (err) {
|
||||
console.error('[Hospital] detail error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,24 @@
|
||||
/// modules/hospital/service.js
|
||||
/// 描述:医院资源的业务逻辑层(所有 SQL 集中在这里)
|
||||
const { getPool } = require('../../db');
|
||||
|
||||
/**
|
||||
* 列出所有医院
|
||||
* @returns {Promise<Array<object>>}
|
||||
*/
|
||||
const listAll = async () => {
|
||||
const [rows] = await getPool().query('SELECT * FROM hospitals ORDER BY id');
|
||||
return rows;
|
||||
};
|
||||
|
||||
/**
|
||||
* 按 ID 获取医院
|
||||
* @param {string} id
|
||||
* @returns {Promise<object|null>}
|
||||
*/
|
||||
const findById = async (id) => {
|
||||
const [rows] = await getPool().query('SELECT * FROM hospitals WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
module.exports = { listAll, findById };
|
||||
@@ -0,0 +1,22 @@
|
||||
/// modules/hospitalImage/dto.js
|
||||
const { toAbsoluteUrl } = require('../../utils/url');
|
||||
|
||||
const toDto = (row) => ({
|
||||
id: row.id,
|
||||
hospitalId: row.hospital_id,
|
||||
image: toAbsoluteUrl(row.image),
|
||||
caption: row.caption || '',
|
||||
sort: Number(row.sort || 0),
|
||||
enabled: row.enabled === 1 || row.enabled === true,
|
||||
createdAt: row.created_at
|
||||
});
|
||||
|
||||
const toCreateDto = (body) => ({
|
||||
hospitalId: body.hospitalId,
|
||||
image: body.image,
|
||||
caption: body.caption,
|
||||
sort: body.sort,
|
||||
enabled: body.enabled
|
||||
});
|
||||
|
||||
module.exports = { toDto, toCreateDto };
|
||||
@@ -0,0 +1,75 @@
|
||||
/// modules/hospitalImage/route.js
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { success, fail } = require('../../utils/response');
|
||||
const { authRequired } = require('../../utils/auth');
|
||||
const service = require('./service');
|
||||
const { toDto, toCreateDto } = require('./dto');
|
||||
|
||||
/** GET /api/v1/hospital-images 列表(前台 enabled 过滤) */
|
||||
router.get('/', async (req, res) => {
|
||||
try {
|
||||
const rows = await service.list({
|
||||
hospitalId: req.query.hospitalId,
|
||||
enabled: req.query.enabled
|
||||
});
|
||||
res.json(success(rows.map(toDto)));
|
||||
} catch (err) {
|
||||
console.error('[HospitalImage] list error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** GET /api/v1/hospital-images/:id 详情 */
|
||||
router.get('/:id', async (req, res) => {
|
||||
try {
|
||||
const row = await service.findById(req.params.id);
|
||||
if (!row) return res.status(404).json(fail('图片不存在', 404));
|
||||
res.json(success(toDto(row)));
|
||||
} catch (err) {
|
||||
console.error('[HospitalImage] detail error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** POST /api/v1/hospital-images 新增 (管理后台) */
|
||||
router.post('/', authRequired, async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
if (!body.image) return res.status(400).json(fail('image 必填'));
|
||||
const id = `hi-${Date.now()}`;
|
||||
const row = await service.create(id, toCreateDto(body));
|
||||
console.log('[HospitalImage] created', id);
|
||||
res.json(success(toDto(row), '创建成功'));
|
||||
} catch (err) {
|
||||
console.error('[HospitalImage] create error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/hospital-images/:id 更新 */
|
||||
router.put('/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const exists = await service.findById(req.params.id);
|
||||
if (!exists) return res.status(404).json(fail('图片不存在', 404));
|
||||
const row = await service.update(req.params.id, req.body || {});
|
||||
res.json(success(toDto(row), '更新成功'));
|
||||
} catch (err) {
|
||||
console.error('[HospitalImage] update error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** DELETE /api/v1/hospital-images/:id 删除 */
|
||||
router.delete('/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const row = await service.remove(req.params.id);
|
||||
if (!row) return res.status(404).json(fail('图片不存在', 404));
|
||||
res.json(success(toDto(row), '删除成功'));
|
||||
} catch (err) {
|
||||
console.error('[HospitalImage] delete error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,51 @@
|
||||
/// modules/hospitalImage/service.js
|
||||
const { getPool } = require('../../db');
|
||||
|
||||
const list = async (filter = {}) => {
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (filter.hospitalId) { where.push('hospital_id = ?'); params.push(filter.hospitalId); }
|
||||
if (filter.enabled === 'true') where.push('enabled = 1');
|
||||
if (filter.enabled === 'false') where.push('enabled = 0');
|
||||
const sql = `SELECT * FROM hospital_images ${where.length ? 'WHERE ' + where.join(' AND ') : ''} ORDER BY sort ASC, id ASC`;
|
||||
const [rows] = await getPool().query(sql, params);
|
||||
return rows;
|
||||
};
|
||||
|
||||
const findById = async (id) => {
|
||||
const [rows] = await getPool().query('SELECT * FROM hospital_images WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
const create = async (id, fields) => {
|
||||
await getPool().query(
|
||||
`INSERT INTO hospital_images (id, hospital_id, image, caption, sort, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[id, fields.hospitalId || 'h-001', fields.image, fields.caption || '', Number(fields.sort) || 99, fields.enabled === false ? 0 : 1]
|
||||
);
|
||||
return findById(id);
|
||||
};
|
||||
|
||||
const update = async (id, patch) => {
|
||||
const map = { hospitalId: 'hospital_id', image: 'image', caption: 'caption' };
|
||||
const fields = [];
|
||||
const params = [];
|
||||
for (const [k, col] of Object.entries(map)) {
|
||||
if (patch[k] !== undefined) { fields.push(`${col} = ?`); params.push(patch[k]); }
|
||||
}
|
||||
if (patch.sort !== undefined) { fields.push('sort = ?'); params.push(Number(patch.sort)); }
|
||||
if (patch.enabled !== undefined) { fields.push('enabled = ?'); params.push(patch.enabled ? 1 : 0); }
|
||||
if (fields.length === 0) return findById(id);
|
||||
params.push(id);
|
||||
await getPool().query(`UPDATE hospital_images SET ${fields.join(', ')} WHERE id = ?`, params);
|
||||
return findById(id);
|
||||
};
|
||||
|
||||
const remove = async (id) => {
|
||||
const row = await findById(id);
|
||||
if (!row) return null;
|
||||
await getPool().query('DELETE FROM hospital_images WHERE id = ?', [id]);
|
||||
return row;
|
||||
};
|
||||
|
||||
module.exports = { list, findById, create, update, remove };
|
||||
@@ -0,0 +1,46 @@
|
||||
/// modules/index.js
|
||||
/// 描述:业务域统一注册入口
|
||||
///
|
||||
/// 每个业务域的目录结构:
|
||||
/// route.js HTTP 层(参数解析、调 service、返回响应)
|
||||
/// service.js 业务逻辑层(所有 SQL 集中)
|
||||
/// dto.js DB row → API DTO 转换
|
||||
/// README.md 模块接口文档
|
||||
///
|
||||
/// 新增业务域时:在此文件追加一行 mount 即可。
|
||||
|
||||
const hospitalRouter = require('./hospital/route');
|
||||
const departmentRouter = require('./department/route');
|
||||
const doctorRouter = require('./doctor/route');
|
||||
const appointmentRouter = require('./appointment/route');
|
||||
const configRouter = require('./config/route');
|
||||
const expertRouter = require('./expert/route');
|
||||
const bannerRouter = require('./banner/route');
|
||||
const centerRouter = require('./center/route');
|
||||
const homeIntroRouter = require('./homeIntro/route');
|
||||
const hospitalImageRouter = require('./hospitalImage/route');
|
||||
const uploadRouter = require('./upload/route');
|
||||
const adminAuthRouter = require('./adminAuth/route');
|
||||
const leadRouter = require('./lead/route');
|
||||
const leadAdminRouter = require('./lead/route.admin');
|
||||
const wechatRouter = require('./wechat/route');
|
||||
const notificationRouter = require('./notification/route');
|
||||
|
||||
module.exports = {
|
||||
hospitalRouter,
|
||||
departmentRouter,
|
||||
doctorRouter,
|
||||
appointmentRouter,
|
||||
configRouter,
|
||||
expertRouter,
|
||||
bannerRouter,
|
||||
centerRouter,
|
||||
homeIntroRouter,
|
||||
hospitalImageRouter,
|
||||
uploadRouter,
|
||||
adminAuthRouter,
|
||||
leadRouter,
|
||||
leadAdminRouter,
|
||||
wechatRouter,
|
||||
notificationRouter
|
||||
};
|
||||
@@ -0,0 +1,69 @@
|
||||
# Lead · 意向客户模块
|
||||
|
||||
> Phase 3 交付物:自主问卷用户信息采集 + 管理后台意向客户列表。
|
||||
|
||||
## 职责
|
||||
|
||||
收集各渠道(自测问卷、首页落地、预约等)提交的用户信息,统一落入 `leads` 表,供管理后台跟进。
|
||||
|
||||
## 目录结构
|
||||
|
||||
```
|
||||
modules/lead/
|
||||
├── route.js # 公开接口(POST 提交 / GET stats)
|
||||
├── route.admin.js # 管理后台鉴权接口(GET 列表/详情 / PUT 备注 / DELETE 删除)
|
||||
├── service.js # 业务逻辑(手机号/姓名/渠道校验、SQL、统计)
|
||||
├── dto.js # DB row → API DTO
|
||||
└── README.md # 本文件
|
||||
```
|
||||
|
||||
## 数据表
|
||||
|
||||
```sql
|
||||
CREATE TABLE leads (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
symptom TEXT,
|
||||
questionnaire JSON NOT NULL,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'self-test',
|
||||
score INT DEFAULT 0,
|
||||
remark VARCHAR(500),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_phone_source (phone, source)
|
||||
);
|
||||
```
|
||||
|
||||
## 接口
|
||||
|
||||
| Method | Path | 鉴权 | 说明 |
|
||||
|---|---|---|---|
|
||||
| POST | `/api/v1/leads` | - | 公开提交(小程序自测问卷等) |
|
||||
| GET | `/api/v1/leads/stats` | ✅ | 统计(总/24h/按渠道分组) |
|
||||
| GET | `/api/v1/admin/leads` | ✅ | 列表(支持 phone/source/keyword/时间过滤) |
|
||||
| GET | `/api/v1/admin/leads/:id` | ✅ | 详情 |
|
||||
| PUT | `/api/v1/admin/leads/:id/remark` | ✅ | 修改备注 |
|
||||
| DELETE | `/api/v1/admin/leads/:id` | ✅ | 删除 |
|
||||
|
||||
## 校验规则
|
||||
|
||||
- `name`: 1-20 字符
|
||||
- `phone`: `^1[3-9]\d{9}$`
|
||||
- `source`: `self-test` / `home` / `appointment` / `other`
|
||||
- `symptom`: ≤ 500 字
|
||||
- `questionnaire`: 必填,任意 JSON 对象
|
||||
- 唯一约束 `(phone, source)`: 同渠道同手机号只保留一条
|
||||
|
||||
## 错误码
|
||||
|
||||
| 场景 | HTTP | code | message |
|
||||
|---|---|---|---|
|
||||
| 姓名必填/超长 | 400 | 1 | 姓名必填 / 姓名长度需在 1-20 字符 |
|
||||
| 手机号必填/格式错 | 400 | 1 | 手机号必填 / 手机号格式不正确 |
|
||||
| source 不在枚举 | 400 | 1 | source 仅支持:self-test / home / ... |
|
||||
| 症状超长 | 400 | 1 | 症状描述不超过 500 字 |
|
||||
| 问卷缺失 | 400 | 1 | 问卷答案 questionnaire 必填且为对象 |
|
||||
| 重复提交 | 400 | 1 | 该手机号在同渠道已提交过,请勿重复 |
|
||||
|
||||
详见 `docs/api/v1/lead.md`。
|
||||
@@ -0,0 +1,34 @@
|
||||
/// modules/lead/dto.js
|
||||
/// 描述:DB row → API DTO 转换
|
||||
|
||||
const parseQuestionnaire = (raw) => {
|
||||
if (raw == null) return {};
|
||||
if (typeof raw === 'object') return raw;
|
||||
try { return JSON.parse(raw); } catch (e) { return {}; }
|
||||
};
|
||||
|
||||
const toDto = (row) => ({
|
||||
id: row.id,
|
||||
name: row.name,
|
||||
phone: row.phone,
|
||||
symptom: row.symptom || '',
|
||||
questionnaire: parseQuestionnaire(row.questionnaire),
|
||||
source: row.source,
|
||||
score: Number(row.score) || 0,
|
||||
remark: row.remark || '',
|
||||
createdAt: row.created_at,
|
||||
updatedAt: row.updated_at
|
||||
});
|
||||
|
||||
const toCreateDto = (input = {}) => ({
|
||||
name: String(input.name || '').trim(),
|
||||
phone: String(input.phone || '').trim(),
|
||||
symptom: String(input.symptom || '').slice(0, 500),
|
||||
questionnaire: input.questionnaire && typeof input.questionnaire === 'object' ? input.questionnaire : {},
|
||||
source: input.source && service.isValidSource(input.source) ? input.source : 'self-test',
|
||||
score: Number(input.score) || 0
|
||||
});
|
||||
|
||||
const service = require('./service');
|
||||
|
||||
module.exports = { toDto, toCreateDto };
|
||||
@@ -0,0 +1,120 @@
|
||||
/// modules/lead/route.admin.js
|
||||
/// 描述:管理后台鉴权接口(独立 router 挂在 /api/v1/admin/leads)
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { success, fail } = require('../../utils/response');
|
||||
const { authRequired } = require('../../utils/auth');
|
||||
const service = require('./service');
|
||||
const { toDto } = require('./dto');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/admin/leads:
|
||||
* get:
|
||||
* summary: 意向客户列表(管理后台)
|
||||
* tags: [Lead]
|
||||
* security: [{ bearerAuth: [] }]
|
||||
* parameters:
|
||||
* - in: query
|
||||
* name: phone
|
||||
* schema: { type: string }
|
||||
* - in: query
|
||||
* name: source
|
||||
* schema: { type: string, enum: [self-test, home, appointment, other] }
|
||||
* - in: query
|
||||
* name: keyword
|
||||
* schema: { type: string, description: '按姓名模糊匹配' }
|
||||
* - in: query
|
||||
* name: startDate
|
||||
* schema: { type: string, description: 'YYYY-MM-DD' }
|
||||
* - in: query
|
||||
* name: endDate
|
||||
* schema: { type: string, description: 'YYYY-MM-DD' }
|
||||
* - in: query
|
||||
* name: limit
|
||||
* schema: { type: integer, default: 50, maximum: 200 }
|
||||
* - in: query
|
||||
* name: offset
|
||||
* schema: { type: integer, default: 0 }
|
||||
* responses:
|
||||
* 200: { description: ok }
|
||||
*/
|
||||
router.get('/', authRequired, async (req, res) => {
|
||||
try {
|
||||
const filter = {
|
||||
phone: req.query.phone,
|
||||
source: req.query.source,
|
||||
keyword: req.query.keyword,
|
||||
startDate: req.query.startDate,
|
||||
endDate: req.query.endDate,
|
||||
limit: req.query.limit,
|
||||
offset: req.query.offset
|
||||
};
|
||||
const [rows, total] = await Promise.all([service.list(filter), service.count(filter)]);
|
||||
res.json(success({
|
||||
list: rows.map(toDto),
|
||||
total,
|
||||
limit: Math.min(Number(req.query.limit) || 50, 200),
|
||||
offset: Math.max(Number(req.query.offset) || 0, 0)
|
||||
}));
|
||||
} catch (err) {
|
||||
console.error('[Lead admin] list error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/admin/leads/{id}:
|
||||
* get:
|
||||
* summary: lead 详情
|
||||
* tags: [Lead]
|
||||
* security: [{ bearerAuth: [] }]
|
||||
* parameters:
|
||||
* - in: path
|
||||
* name: id
|
||||
* required: true
|
||||
* schema: { type: string }
|
||||
* responses:
|
||||
* 200: { description: ok }
|
||||
* 404: { description: 不存在 }
|
||||
*/
|
||||
router.get('/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const row = await service.findById(req.params.id);
|
||||
if (!row) return res.status(404).json(fail('Lead 不存在', 404));
|
||||
res.json(success(toDto(row)));
|
||||
} catch (err) {
|
||||
console.error('[Lead admin] detail error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** PUT /api/v1/admin/leads/:id/remark 修改备注 */
|
||||
router.put('/:id/remark', authRequired, async (req, res) => {
|
||||
try {
|
||||
const { remark } = req.body || {};
|
||||
if (remark === undefined) return res.status(400).json(fail('remark 必填'));
|
||||
if (String(remark).length > 500) return res.status(400).json(fail('备注不超过 500 字'));
|
||||
const row = await service.updateRemark(req.params.id, remark);
|
||||
if (!row) return res.status(404).json(fail('Lead 不存在', 404));
|
||||
res.json(success(toDto(row), '已更新'));
|
||||
} catch (err) {
|
||||
console.error('[Lead admin] remark error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/** DELETE /api/v1/admin/leads/:id 删除 */
|
||||
router.delete('/:id', authRequired, async (req, res) => {
|
||||
try {
|
||||
const row = await service.remove(req.params.id);
|
||||
if (!row) return res.status(404).json(fail('Lead 不存在', 404));
|
||||
res.json(success(toDto(row), '已删除'));
|
||||
} catch (err) {
|
||||
console.error('[Lead admin] delete error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,89 @@
|
||||
/// modules/lead/route.js
|
||||
/// 描述:意向客户(leads)HTTP 路由
|
||||
/// - POST /api/v1/leads 公开(小程序提交问卷)
|
||||
/// - GET /api/v1/leads/stats 鉴权(管理后台)
|
||||
/// - GET /api/v1/admin/leads 鉴权(管理后台列表)
|
||||
/// - GET /api/v1/admin/leads/:id 鉴权(管理后台详情)
|
||||
/// - PUT /api/v1/admin/leads/:id/remark 鉴权(修改备注)
|
||||
/// - DELETE /api/v1/admin/leads/:id 鉴权(删除)
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { success, fail } = require('../../utils/response');
|
||||
const { authRequired } = require('../../utils/auth');
|
||||
const service = require('./service');
|
||||
const { toDto, toCreateDto } = require('./dto');
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/leads:
|
||||
* post:
|
||||
* summary: 提交意向客户(自主问卷等)
|
||||
* tags: [Lead]
|
||||
* requestBody:
|
||||
* required: true
|
||||
* content:
|
||||
* application/json:
|
||||
* schema:
|
||||
* type: object
|
||||
* required: [name, phone, questionnaire]
|
||||
* properties:
|
||||
* name: { type: string, example: '张三' }
|
||||
* phone: { type: string, example: '13800138000' }
|
||||
* symptom: { type: string, example: '便血近 1 周' }
|
||||
* questionnaire: { type: object, additionalProperties: true }
|
||||
* source: { type: string, enum: [self-test, home, appointment, other] }
|
||||
* score: { type: integer, example: 5 }
|
||||
* responses:
|
||||
* 200: { description: 提交成功,返回 leadId }
|
||||
* 400: { description: 参数错误(手机号/姓名/渠道) }
|
||||
*/
|
||||
router.post('/', async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
if (!body.name) return res.status(400).json(fail('姓名必填'));
|
||||
if (!service.isValidName(body.name)) return res.status(400).json(fail('姓名长度需在 1-20 字符'));
|
||||
if (!body.phone) return res.status(400).json(fail('手机号必填'));
|
||||
if (!service.isValidPhone(body.phone)) return res.status(400).json(fail('手机号格式不正确'));
|
||||
if (body.source && !service.isValidSource(body.source)) {
|
||||
return res.status(400).json(fail(`source 仅支持:${service.SOURCES.join(' / ')}`));
|
||||
}
|
||||
if (body.symptom && String(body.symptom).length > 500) {
|
||||
return res.status(400).json(fail('症状描述不超过 500 字'));
|
||||
}
|
||||
if (!body.questionnaire || typeof body.questionnaire !== 'object') {
|
||||
return res.status(400).json(fail('问卷答案 questionnaire 必填且为对象'));
|
||||
}
|
||||
const dto = toCreateDto(body);
|
||||
// 使用 upsert 去重:同一手机号只保留一条 lead
|
||||
const row = await service.upsert(dto);
|
||||
console.log('[Lead] upsert', row.id, dto.name, dto.phone, dto.source);
|
||||
res.json(success({ id: row.id, createdAt: row.created_at }, '提交成功'));
|
||||
} catch (err) {
|
||||
if (err && err.code === 'ER_DUP_ENTRY') {
|
||||
return res.status(400).json(fail('该手机号在同渠道已提交过,请勿重复'));
|
||||
}
|
||||
console.error('[Lead] create error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* @swagger
|
||||
* /api/v1/leads/stats:
|
||||
* get:
|
||||
* summary: leads 统计(管理后台)
|
||||
* tags: [Lead]
|
||||
* security: [{ bearerAuth: [] }]
|
||||
* responses:
|
||||
* 200: { description: ok }
|
||||
*/
|
||||
router.get('/stats', authRequired, async (_req, res) => {
|
||||
try {
|
||||
res.json(success(await service.stats()));
|
||||
} catch (err) {
|
||||
console.error('[Lead] stats error', err);
|
||||
res.status(500).json(fail('服务器内部错误'));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,220 @@
|
||||
/// modules/lead/service.js
|
||||
/// 描述:意向客户(leads)业务逻辑层
|
||||
/// 版本:v2.1 - 支持小程序手机号自动创建 + 同手机号去重 + 通知
|
||||
const { getPool } = require('../../db');
|
||||
const { notifyNewLead } = require('../notification/service');
|
||||
|
||||
/** 合法 source 渠道枚举 */
|
||||
const SOURCES = ['miniapp-phone', 'self-test', 'home', 'appointment', 'other'];
|
||||
|
||||
/** 中国大陆手机号校验 */
|
||||
const isValidPhone = (phone) => typeof phone === 'string' && /^1[3-9]\d{9}$/.test(phone);
|
||||
|
||||
/** 名称校验:1-20 字符 */
|
||||
const isValidName = (name) => typeof name === 'string' && name.trim().length >= 1 && name.trim().length <= 20;
|
||||
|
||||
/** 渠道校验 */
|
||||
const isValidSource = (s) => SOURCES.includes(s);
|
||||
|
||||
/**
|
||||
* 按手机号查找 lead(取最新一条)
|
||||
* @param {string} phone
|
||||
* @returns {object|null}
|
||||
*/
|
||||
const findByPhone = async (phone) => {
|
||||
const [rows] = await getPool().query(
|
||||
'SELECT * FROM leads WHERE phone = ? ORDER BY created_at DESC LIMIT 1',
|
||||
[phone]
|
||||
);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 创建 lead(内部使用)
|
||||
*/
|
||||
const create = async (input = {}) => {
|
||||
const id = `lead-${Date.now()}`;
|
||||
const { name, phone, symptom, questionnaire, source = 'self-test', score = 0 } = input;
|
||||
await getPool().query(
|
||||
`INSERT INTO leads (id, name, phone, symptom, questionnaire, source, score)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[id, name || '微信用户', phone, symptom || '', JSON.stringify(questionnaire || {}), source, Number(score) || 0]
|
||||
);
|
||||
const result = await findById(id);
|
||||
// 异步发送通知,不阻塞主流程
|
||||
notifyNewLead(result).catch((err) => console.error('[Lead] notify error:', err.message));
|
||||
return result;
|
||||
};
|
||||
|
||||
/**
|
||||
* upsert:同一手机号的 lead 只保留一条,后续操作追加信息
|
||||
* - 若该手机号已存在 lead:更新 source(升级来源)、补充 symptom、累加 score
|
||||
* - 若不存在:新建
|
||||
* @param {object} input
|
||||
* @returns {object} lead 行
|
||||
*/
|
||||
const upsert = async (input = {}) => {
|
||||
const { phone, source = 'self-test' } = input;
|
||||
const existing = await findByPhone(phone);
|
||||
|
||||
if (!existing) {
|
||||
// 首次出现,直接创建
|
||||
return create(input);
|
||||
}
|
||||
|
||||
// 已存在:根据来源升级优先级更新
|
||||
// 优先级:self-test > appointment > home > miniapp-phone > other
|
||||
// (更具体的来源覆盖更模糊的来源)
|
||||
const sourcePriority = {
|
||||
'self-test': 5,
|
||||
'appointment': 4,
|
||||
'home': 3,
|
||||
'miniapp-phone': 2,
|
||||
'other': 1
|
||||
};
|
||||
const currentPriority = sourcePriority[existing.source] || 0;
|
||||
const newPriority = sourcePriority[source] || 0;
|
||||
|
||||
// 构建更新字段
|
||||
const updates = [];
|
||||
const params = [];
|
||||
|
||||
// 来源升级
|
||||
if (newPriority > currentPriority) {
|
||||
updates.push('source = ?');
|
||||
params.push(source);
|
||||
}
|
||||
|
||||
// 名称更新(如果新名称更具体)
|
||||
if (input.name && input.name !== '微信用户' && existing.name === '微信用户') {
|
||||
updates.push('name = ?');
|
||||
params.push(input.name);
|
||||
}
|
||||
|
||||
// 症状追加(不覆盖已有内容)
|
||||
if (input.symptom && input.symptom.trim()) {
|
||||
const existingSymptom = existing.symptom || '';
|
||||
if (!existingSymptom.includes(input.symptom.trim())) {
|
||||
const newSymptom = existingSymptom
|
||||
? `${existingSymptom};${input.symptom.trim()}`
|
||||
: input.symptom.trim();
|
||||
updates.push('symptom = ?');
|
||||
params.push(newSymptom.slice(0, 500));
|
||||
}
|
||||
}
|
||||
|
||||
// 问卷数据合并(如果有新问卷)
|
||||
if (input.questionnaire && typeof input.questionnaire === 'object' && Object.keys(input.questionnaire).length > 0) {
|
||||
const existingQ = typeof existing.questionnaire === 'string'
|
||||
? JSON.parse(existing.questionnaire || '{}')
|
||||
: (existing.questionnaire || {});
|
||||
updates.push('questionnaire = ?');
|
||||
params.push(JSON.stringify({ ...existingQ, ...input.questionnaire }));
|
||||
}
|
||||
|
||||
// 分数取最高
|
||||
if (input.score && Number(input.score) > (Number(existing.score) || 0)) {
|
||||
updates.push('score = ?');
|
||||
params.push(Number(input.score));
|
||||
}
|
||||
|
||||
if (updates.length > 0) {
|
||||
params.push(existing.id);
|
||||
await getPool().query(
|
||||
`UPDATE leads SET ${updates.join(', ')} WHERE id = ?`,
|
||||
params
|
||||
);
|
||||
console.log('[Lead] upsert updated', existing.id, 'fields:', updates.map((u, i) => u.split('=')[0].trim()).join(', '));
|
||||
}
|
||||
|
||||
return findById(existing.id);
|
||||
};
|
||||
|
||||
const findById = async (id) => {
|
||||
const [rows] = await getPool().query('SELECT * FROM leads WHERE id = ?', [id]);
|
||||
return rows[0] || null;
|
||||
};
|
||||
|
||||
/**
|
||||
* 列表(管理后台)
|
||||
* @param {object} filter
|
||||
* - phone
|
||||
* - source
|
||||
* - keyword 模糊匹配 name
|
||||
* - startDate created_at >= startDate
|
||||
* - endDate created_at <= endDate
|
||||
* - limit 默认 50
|
||||
* - offset 默认 0
|
||||
*/
|
||||
const list = async (filter = {}) => {
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (filter.phone) { where.push('phone = ?'); params.push(filter.phone); }
|
||||
if (filter.source) { where.push('source = ?'); params.push(filter.source); }
|
||||
if (filter.keyword) { where.push('name LIKE ?'); params.push(`%${filter.keyword}%`); }
|
||||
if (filter.startDate) { where.push('created_at >= ?'); params.push(filter.startDate); }
|
||||
if (filter.endDate) { where.push('created_at <= ?'); params.push(filter.endDate); }
|
||||
const limit = Math.min(Number(filter.limit) || 50, 200);
|
||||
const offset = Math.max(Number(filter.offset) || 0, 0);
|
||||
const sql = `SELECT * FROM leads ${where.length ? 'WHERE ' + where.join(' AND ') : ''}
|
||||
ORDER BY created_at DESC LIMIT ? OFFSET ?`;
|
||||
const [rows] = await getPool().query(sql, [...params, limit, offset]);
|
||||
return rows;
|
||||
};
|
||||
|
||||
/** 总数(管理后台分页用) */
|
||||
const count = async (filter = {}) => {
|
||||
const where = [];
|
||||
const params = [];
|
||||
if (filter.phone) { where.push('phone = ?'); params.push(filter.phone); }
|
||||
if (filter.source) { where.push('source = ?'); params.push(filter.source); }
|
||||
if (filter.keyword) { where.push('name LIKE ?'); params.push(`%${filter.keyword}%`); }
|
||||
if (filter.startDate) { where.push('created_at >= ?'); params.push(filter.startDate); }
|
||||
if (filter.endDate) { where.push('created_at <= ?'); params.push(filter.endDate); }
|
||||
const sql = `SELECT COUNT(*) AS total FROM leads ${where.length ? 'WHERE ' + where.join(' AND ') : ''}`;
|
||||
const [rows] = await getPool().query(sql, params);
|
||||
return rows[0]?.total || 0;
|
||||
};
|
||||
|
||||
/** 简单统计 */
|
||||
const stats = async () => {
|
||||
const pool = getPool();
|
||||
const [[t]] = await pool.query('SELECT COUNT(*) AS total FROM leads');
|
||||
const [[t24]] = await pool.query('SELECT COUNT(*) AS last24h FROM leads WHERE created_at >= (NOW() - INTERVAL 1 DAY)');
|
||||
const [bySource] = await pool.query('SELECT source, COUNT(*) AS count FROM leads GROUP BY source');
|
||||
return {
|
||||
total: t.total,
|
||||
last24h: t24.last24h,
|
||||
bySource: bySource.reduce((acc, r) => { acc[r.source] = r.count; return acc; }, {})
|
||||
};
|
||||
};
|
||||
|
||||
/** 更新备注(管理后台) */
|
||||
const updateRemark = async (id, remark) => {
|
||||
await getPool().query('UPDATE leads SET remark = ? WHERE id = ?', [remark || '', id]);
|
||||
return findById(id);
|
||||
};
|
||||
|
||||
/** 删除(管理后台) */
|
||||
const remove = async (id) => {
|
||||
const row = await findById(id);
|
||||
if (!row) return null;
|
||||
await getPool().query('DELETE FROM leads WHERE id = ?', [id]);
|
||||
return row;
|
||||
};
|
||||
|
||||
module.exports = {
|
||||
SOURCES,
|
||||
isValidPhone,
|
||||
isValidName,
|
||||
isValidSource,
|
||||
create,
|
||||
upsert,
|
||||
findByPhone,
|
||||
findById,
|
||||
list,
|
||||
count,
|
||||
stats,
|
||||
updateRemark,
|
||||
remove
|
||||
};
|
||||
@@ -0,0 +1,27 @@
|
||||
///
|
||||
// modules/notification/route.js
|
||||
// 描述:通知路由 —— 测试通知接口
|
||||
// 作者:AI Generated
|
||||
// 创建日期:2026-06-30
|
||||
// 最后修改:2026-06-30
|
||||
//
|
||||
const { Router } = require('express');
|
||||
const router = Router();
|
||||
const { sendMarkdown } = require('./service');
|
||||
const { success, fail } = require('../../utils/response');
|
||||
|
||||
/**
|
||||
* POST /api/v1/notification/test
|
||||
* 发送测试通知到企业微信 Webhook
|
||||
*/
|
||||
router.post('/test', async (_req, res) => {
|
||||
try {
|
||||
await sendMarkdown('### 🔔 通知测试\n> 这是一条测试消息,如果您在企业微信群中看到此消息,说明通知配置成功!\n> 发送时间:' + new Date().toLocaleString('zh-CN', { timeZone: 'Asia/Shanghai' }));
|
||||
res.json(success(null, '测试通知已发送'));
|
||||
} catch (err) {
|
||||
console.error('[Notification] test error:', err.message);
|
||||
res.status(500).json(fail('发送失败: ' + err.message));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,146 @@
|
||||
///
|
||||
// modules/notification/service.js
|
||||
// 描述:通知服务 —— 企业微信 Webhook 推送
|
||||
// 新预约、新意向客户时自动推送详细消息到企业微信群
|
||||
// 作者:AI Generated
|
||||
// 创建日期:2026-06-30
|
||||
// 最后修改:2026-06-30
|
||||
//
|
||||
const https = require('https');
|
||||
const http = require('http');
|
||||
const { getPool } = require('../../db');
|
||||
|
||||
/**
|
||||
* 从数据库 site_config (home_config id=2) 读取通知配置
|
||||
* @returns {{ webhooks: string[], enabled: boolean }}
|
||||
*/
|
||||
const getNotifyConfig = async () => {
|
||||
try {
|
||||
const [rows] = await getPool().query('SELECT data FROM home_config WHERE id = 2');
|
||||
if (!rows || !rows[0]) return { webhooks: [], enabled: false };
|
||||
let data = rows[0].data;
|
||||
if (typeof data === 'string') {
|
||||
try { data = JSON.parse(data); } catch { data = {}; }
|
||||
}
|
||||
const webhooks = (data.notifyWebhooks || '')
|
||||
.split('\n')
|
||||
.map((s) => s.trim())
|
||||
.filter((s) => s.startsWith('http'));
|
||||
return { webhooks, enabled: !!data.notifyEnabled && webhooks.length > 0 };
|
||||
} catch (err) {
|
||||
console.error('[Notification] getNotifyConfig error:', err.message);
|
||||
return { webhooks: [], enabled: false };
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 发送 HTTP POST 请求(支持 http/https)
|
||||
* @param {string} url
|
||||
* @param {object} body
|
||||
* @returns {Promise<object>}
|
||||
*/
|
||||
const postJson = (url, body) => new Promise((resolve, reject) => {
|
||||
const payload = JSON.stringify(body);
|
||||
const parsed = new URL(url);
|
||||
const mod = parsed.protocol === 'https:' ? https : http;
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
port: parsed.port || (parsed.protocol === 'https:' ? 443 : 80),
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(payload) },
|
||||
timeout: 5000
|
||||
};
|
||||
const req = mod.request(options, (res) => {
|
||||
let data = '';
|
||||
res.on('data', (chunk) => { data += chunk; });
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(data)); } catch { resolve({ raw: data }); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.on('timeout', () => { req.destroy(); reject(new Error('timeout')); });
|
||||
req.write(payload);
|
||||
req.end();
|
||||
});
|
||||
|
||||
/**
|
||||
* 发送企业微信 Markdown 消息到所有配置的 Webhook
|
||||
* 文档:https://developer.work.weixin.qq.com/document/path/91770
|
||||
* @param {string} content - Markdown 格式内容
|
||||
*/
|
||||
const sendMarkdown = async (content) => {
|
||||
const config = await getNotifyConfig();
|
||||
if (!config.enabled) return;
|
||||
|
||||
const body = {
|
||||
msgtype: 'markdown',
|
||||
markdown: { content }
|
||||
};
|
||||
|
||||
for (const url of config.webhooks) {
|
||||
try {
|
||||
const res = await postJson(url, body);
|
||||
if (res.errcode && res.errcode !== 0) {
|
||||
console.error(`[Notification] webhook error (${url}): errcode=${res.errcode}, errmsg=${res.errmsg}`);
|
||||
} else {
|
||||
console.log(`[Notification] sent to ${url.substring(0, 60)}... ok`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(`[Notification] webhook failed (${url.substring(0, 60)}...): ${err.message}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 通知:新预约
|
||||
* @param {object} appt - 预约记录(含 patientName, patientPhone, patientAge, departmentName, date, symptom)
|
||||
*/
|
||||
const notifyNewAppointment = async (appt) => {
|
||||
const lines = [
|
||||
`### 📅 新预约提醒`,
|
||||
`> **患者姓名**:${appt.patientName || '-'}`,
|
||||
`> **联系电话**:${appt.patientPhone || '-'}`,
|
||||
`> **年龄**:${appt.patientAge || '-'}岁`,
|
||||
`> **就诊科室**:${appt.departmentName || '-'}`,
|
||||
`> **预约日期**:${appt.date || '-'}`,
|
||||
];
|
||||
if (appt.symptom) {
|
||||
lines.push(`> **病情描述**:${appt.symptom.length > 100 ? appt.symptom.substring(0, 100) + '…' : appt.symptom}`);
|
||||
}
|
||||
lines.push(`\n请及时登录管理后台确认预约`);
|
||||
await sendMarkdown(lines.join('\n'));
|
||||
};
|
||||
|
||||
/**
|
||||
* 通知:新意向客户
|
||||
* @param {object} lead - 意向客户记录(含 name, phone, source, symptom, score, region)
|
||||
*/
|
||||
const notifyNewLead = async (lead) => {
|
||||
const sourceLabels = {
|
||||
'miniapp-phone': '小程序手机号授权',
|
||||
'self-test': '在线自测',
|
||||
'home': '首页落地',
|
||||
'appointment': '预约入口',
|
||||
'other': '其他'
|
||||
};
|
||||
const lines = [
|
||||
`### 📨 新意向客户`,
|
||||
`> **姓名**:${lead.name || '-'}`,
|
||||
`> **电话**:${lead.phone || '-'}`,
|
||||
`> **来源**:${sourceLabels[lead.source] || lead.source || '-'}`,
|
||||
];
|
||||
if (lead.region) {
|
||||
lines.push(`> **地区**:${lead.region}`);
|
||||
}
|
||||
if (lead.symptom) {
|
||||
lines.push(`> **症状描述**:${lead.symptom.length > 100 ? lead.symptom.substring(0, 100) + '…' : lead.symptom}`);
|
||||
}
|
||||
if (lead.score) {
|
||||
lines.push(`> **自测得分**:${lead.score}分`);
|
||||
}
|
||||
lines.push(`\n请及时跟进联系`);
|
||||
await sendMarkdown(lines.join('\n'));
|
||||
};
|
||||
|
||||
module.exports = { sendMarkdown, notifyNewAppointment, notifyNewLead, getNotifyConfig };
|
||||
@@ -0,0 +1,69 @@
|
||||
/// modules/upload/route.js
|
||||
/// 描述:通用文件上传接口(接收 base64 / multipart, 落盘到 /opt/yuyueguahao/uploads/, 返回可访问 URL)
|
||||
/// 大小限制 10MB, 超过返回 400
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const path = require('path');
|
||||
const fs = require('fs');
|
||||
const crypto = require('crypto');
|
||||
const { success, fail } = require('../../utils/response');
|
||||
const { authRequired } = require('../../utils/auth');
|
||||
// 不在这里使用 toAbsoluteUrl:上传接口返回相对路径给管理后台,
|
||||
// 由管理后台把相对路径写入 DB;读取侧(DTO/service)统一转绝对 URL。
|
||||
// 这样 DB 始终是协议/域名无关的,便于以后换域名或迁移。
|
||||
|
||||
// 上传目录: 与 Nginx /uploads/ alias 保持一致 (/opt/yuyueguahao/uploads/),
|
||||
// 这样无论用户通过 Nginx (80) 还是 Express (3000) 访问都能正常显示
|
||||
const UPLOAD_DIR = path.resolve(process.env.UPLOAD_DIR || '/opt/yuyueguahao/uploads');
|
||||
if (!fs.existsSync(UPLOAD_DIR)) fs.mkdirSync(UPLOAD_DIR, { recursive: true });
|
||||
|
||||
const MAX_BYTES = 10 * 1024 * 1024; // 10MB
|
||||
|
||||
const ALLOW_MIME = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
|
||||
|
||||
const detectExt = (mime) => {
|
||||
if (mime === 'image/jpeg') return '.jpg';
|
||||
if (mime === 'image/png') return '.png';
|
||||
if (mime === 'image/webp') return '.webp';
|
||||
if (mime === 'image/gif') return '.gif';
|
||||
return '.bin';
|
||||
};
|
||||
|
||||
const saveBase64 = (base64) => {
|
||||
const m = /^data:([^;]+);base64,(.*)$/.exec(base64);
|
||||
const mime = m ? m[1] : 'image/png';
|
||||
const payload = m ? m[2] : base64;
|
||||
if (!ALLOW_MIME.has(mime)) throw new Error('不支持的图片格式 (仅 jpg/png/webp/gif)');
|
||||
const buf = Buffer.from(payload, 'base64');
|
||||
if (buf.length > MAX_BYTES) throw new Error(`图片过大 (${(buf.length / 1024 / 1024).toFixed(2)}MB > 10MB)`);
|
||||
const filename = `${Date.now()}-${crypto.randomBytes(4).toString('hex')}${detectExt(mime)}`;
|
||||
fs.writeFileSync(path.join(UPLOAD_DIR, filename), buf);
|
||||
return { filename, size: buf.length, mime };
|
||||
};
|
||||
|
||||
/** POST /api/v1/uploads/image
|
||||
* body 形式 1: { base64: "data:image/png;base64,..." }
|
||||
* body 形式 2: { url: "https://..." } (外链透传, 不落盘)
|
||||
* body 形式 3: multipart/form-data (字段名 file) — 通过 multer 暂未挂载, 当前用 base64
|
||||
* 返回: { url, size, mime } - url 为相对路径 "/uploads/xxx.png" (兼容 Nginx 与 Express 双通道)
|
||||
*/
|
||||
router.post('/image', authRequired, async (req, res) => {
|
||||
try {
|
||||
const body = req.body || {};
|
||||
if (body.url) {
|
||||
// 外链透传:原样返回(不做转换,由 DB 读取侧统一转绝对 URL)
|
||||
return res.json(success({ url: body.url, size: 0, mime: 'image/url' }, '已保存外链'));
|
||||
}
|
||||
if (!body.base64) return res.status(400).json(fail('base64 或 url 必填'));
|
||||
const { filename, size, mime } = saveBase64(body.base64);
|
||||
// 返回相对路径,存 DB;客户端读取时由 DTO 统一补 PUBLIC_BASE_URL
|
||||
const url = `/uploads/${filename}`;
|
||||
console.log('[Upload] saved', filename, `${(size / 1024).toFixed(1)}KB`);
|
||||
res.json(success({ url, size, mime }, '上传成功'));
|
||||
} catch (err) {
|
||||
console.error('[Upload] error', err);
|
||||
res.status(400).json({ code: 400, message: err.message || '上传失败', data: null });
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,88 @@
|
||||
///
|
||||
// modules/wechat/route.js
|
||||
// 描述:微信开放能力路由 —— 手机号获取 + 自动创建意向客户
|
||||
// 作者:AI Generated
|
||||
// 创建日期:2026-06-26
|
||||
// 最后修改:2026-06-27 (v2.0 自动创建意向客户)
|
||||
//
|
||||
const express = require('express');
|
||||
const router = express.Router();
|
||||
const { success, fail } = require('../../utils/response');
|
||||
const service = require('./service');
|
||||
const leadService = require('../lead/service');
|
||||
|
||||
/**
|
||||
* POST /api/v1/wechat/phone
|
||||
* 前端小程序通过 getPhoneNumber 按钮获取 code 后调用此接口
|
||||
* 同时自动创建/更新意向客户(来源:小程序获取)
|
||||
* @body { code: string } - 微信 getPhoneNumber 返回的 code
|
||||
* @returns {{ phoneNumber, purePhoneNumber, countryCode, leadId }}
|
||||
*/
|
||||
router.post('/phone', async (req, res) => {
|
||||
try {
|
||||
const { code, location, authTime } = req.body || {};
|
||||
if (!code) {
|
||||
return res.status(400).json(fail('code 参数必填'));
|
||||
}
|
||||
const result = await service.getPhoneNumber(code);
|
||||
console.log('[Wechat] phone retrieved:', result.purePhoneNumber);
|
||||
|
||||
// 逆地理编码:经纬度 → 地区名
|
||||
let locationAddress = '';
|
||||
if (location && location.latitude && location.longitude) {
|
||||
locationAddress = await service.reverseGeocode(location.longitude, location.latitude);
|
||||
console.log('[Wechat] location:', locationAddress);
|
||||
}
|
||||
|
||||
// 自动创建/更新意向客户(来源:小程序获取)
|
||||
let leadId = null;
|
||||
if (result.purePhoneNumber && leadService.isValidPhone(result.purePhoneNumber)) {
|
||||
try {
|
||||
const questionnaireData = {
|
||||
authTime: authTime || new Date().toISOString()
|
||||
};
|
||||
if (location && location.latitude) {
|
||||
questionnaireData.location = {
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude,
|
||||
accuracy: location.accuracy || 0,
|
||||
address: locationAddress
|
||||
};
|
||||
}
|
||||
const lead = await leadService.upsert({
|
||||
name: '微信用户',
|
||||
phone: result.purePhoneNumber,
|
||||
source: 'miniapp-phone',
|
||||
questionnaire: questionnaireData
|
||||
});
|
||||
leadId = lead.id;
|
||||
console.log('[Wechat] lead upserted:', leadId, result.purePhoneNumber, locationAddress || 'no-location');
|
||||
} catch (leadErr) {
|
||||
// lead 创建失败不影响手机号获取
|
||||
console.error('[Wechat] lead upsert failed:', leadErr.message);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(success({ ...result, leadId }));
|
||||
} catch (err) {
|
||||
console.error('[Wechat] get phone error', err.message);
|
||||
res.status(500).json(fail(err.message || '获取手机号失败'));
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/v1/wechat/phone-required
|
||||
* 前端启动时检查是否需要获取手机号 + 是否采集位置
|
||||
* @returns {{ requirePhone: boolean, collectLocation: boolean }}
|
||||
*/
|
||||
router.get('/phone-required', async (_req, res) => {
|
||||
try {
|
||||
const config = await service.getWechatConfig();
|
||||
res.json(success({ requirePhone: config.requirePhone, collectLocation: config.collectLocation }));
|
||||
} catch (err) {
|
||||
console.error('[Wechat] check phone-required error', err);
|
||||
res.json(success({ requirePhone: false, collectLocation: false }));
|
||||
}
|
||||
});
|
||||
|
||||
module.exports = router;
|
||||
@@ -0,0 +1,154 @@
|
||||
///
|
||||
// modules/wechat/service.js
|
||||
// 描述:微信开放能力服务层 —— 获取手机号、access_token 缓存、逆地理编码
|
||||
// 作者:AI Generated
|
||||
// 创建日期:2026-06-26
|
||||
// 最后修改:2026-06-28 (v2.1 增加位置采集开关)
|
||||
//
|
||||
const https = require('https');
|
||||
const { getPool } = require('../../db');
|
||||
|
||||
/** access_token 内存缓存(有效期 7200s,提前 5 分钟刷新) */
|
||||
let tokenCache = { token: '', expiresAt: 0 };
|
||||
|
||||
/**
|
||||
* 从数据库 site_config (home_config id=2) 读取微信配置
|
||||
* @returns {{ appid: string, appsecret: string, requirePhone: boolean, collectLocation: boolean, amapKey: string }}
|
||||
*/
|
||||
const getWechatConfig = async () => {
|
||||
const [rows] = await getPool().query('SELECT data FROM home_config WHERE id = 2');
|
||||
if (!rows || !rows[0]) {
|
||||
return { appid: '', appsecret: '', requirePhone: false, collectLocation: false, amapKey: '' };
|
||||
}
|
||||
let data = rows[0].data;
|
||||
if (typeof data === 'string') {
|
||||
try { data = JSON.parse(data); } catch { data = {}; }
|
||||
}
|
||||
return {
|
||||
appid: data.wechatAppid || '',
|
||||
appsecret: data.wechatAppsecret || '',
|
||||
requirePhone: !!data.requirePhone,
|
||||
collectLocation: !!data.collectLocation,
|
||||
amapKey: data.amapKey || ''
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取微信 access_token(带缓存)
|
||||
* @param {string} appid
|
||||
* @param {string} appsecret
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
const getAccessToken = async (appid, appsecret) => {
|
||||
const now = Date.now();
|
||||
if (tokenCache.token && tokenCache.expiresAt > now) {
|
||||
return tokenCache.token;
|
||||
}
|
||||
const url = `https://api.weixin.qq.com/cgi-bin/token?grant_type=client_credential&appid=${appid}&secret=${appsecret}`;
|
||||
const result = await httpGet(url);
|
||||
if (!result.access_token) {
|
||||
throw new Error(`获取 access_token 失败: ${result.errmsg || JSON.stringify(result)}`);
|
||||
}
|
||||
// 提前 300 秒过期,避免边界问题
|
||||
tokenCache = { token: result.access_token, expiresAt: now + (result.expires_in - 300) * 1000 };
|
||||
return tokenCache.token;
|
||||
};
|
||||
|
||||
/**
|
||||
* 通过手机号 code 换取用户手机号
|
||||
* @param {string} code - 小程序 getPhoneNumber 返回的 code
|
||||
* @returns {Promise<{ phoneNumber: string, purePhoneNumber: string, countryCode: string }>}
|
||||
*/
|
||||
const getPhoneNumber = async (code) => {
|
||||
const config = await getWechatConfig();
|
||||
if (!config.appid || !config.appsecret) {
|
||||
throw new Error('微信 AppID 或 AppSecret 未配置,请在管理后台"电话设置"中填写');
|
||||
}
|
||||
const accessToken = await getAccessToken(config.appid, config.appsecret);
|
||||
const url = `https://api.weixin.qq.com/wxa/business/getuserphonenumber?access_token=${accessToken}`;
|
||||
const result = await httpPost(url, { code });
|
||||
if (result.errcode && result.errcode !== 0) {
|
||||
// 如果是 token 过期,清除缓存让下次重新获取
|
||||
if (result.errcode === 42001 || result.errcode === 40001) {
|
||||
tokenCache = { token: '', expiresAt: 0 };
|
||||
}
|
||||
throw new Error(`微信手机号获取失败(${result.errcode}): ${result.errmsg}`);
|
||||
}
|
||||
const info = result.phone_info;
|
||||
if (!info || !info.phoneNumber) {
|
||||
throw new Error('微信返回的手机号数据为空');
|
||||
}
|
||||
return {
|
||||
phoneNumber: info.phoneNumber,
|
||||
purePhoneNumber: info.purePhoneNumber,
|
||||
countryCode: info.countryCode
|
||||
};
|
||||
};
|
||||
|
||||
/** HTTPS GET 请求 */
|
||||
const httpGet = (url) => new Promise((resolve, reject) => {
|
||||
https.get(url, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => { body += chunk; });
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(body)); }
|
||||
catch (e) { reject(new Error(`JSON 解析失败: ${body.slice(0, 200)}`)); }
|
||||
});
|
||||
}).on('error', reject);
|
||||
});
|
||||
|
||||
/** HTTPS POST 请求 */
|
||||
const httpPost = (url, data) => new Promise((resolve, reject) => {
|
||||
const u = new URL(url);
|
||||
const postData = JSON.stringify(data);
|
||||
const req = https.request({
|
||||
hostname: u.hostname,
|
||||
path: u.pathname + u.search,
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(postData) }
|
||||
}, (res) => {
|
||||
let body = '';
|
||||
res.on('data', (chunk) => { body += chunk; });
|
||||
res.on('end', () => {
|
||||
try { resolve(JSON.parse(body)); }
|
||||
catch (e) { reject(new Error(`JSON 解析失败: ${body.slice(0, 200)}`)); }
|
||||
});
|
||||
});
|
||||
req.on('error', reject);
|
||||
req.write(postData);
|
||||
req.end();
|
||||
});
|
||||
|
||||
/**
|
||||
* 逆地理编码:经纬度 → 地区名称
|
||||
* 使用高德地图 Web API(免费 5000次/天)
|
||||
* @param {number} longitude
|
||||
* @param {number} latitude
|
||||
* @returns {Promise<string>} 地区名称,如 "江苏省苏州市姑苏区"
|
||||
*/
|
||||
const reverseGeocode = async (longitude, latitude) => {
|
||||
try {
|
||||
const config = await getWechatConfig();
|
||||
if (!config.amapKey) {
|
||||
// 没有配置高德 Key,返回经纬度
|
||||
return `${latitude.toFixed(4)},${longitude.toFixed(4)}(未配置地图Key)`;
|
||||
}
|
||||
const url = `https://restapi.amap.com/v3/geocode/regeo?key=${config.amapKey}&location=${longitude},${latitude}&extensions=base`;
|
||||
const result = await httpGet(url);
|
||||
if (result.status === '1' && result.regeocode) {
|
||||
const addr = result.regeocode.addressComponent || {};
|
||||
const province = addr.province || '';
|
||||
const city = addr.city || '';
|
||||
const district = addr.district || '';
|
||||
// 省市同名时去重(如:江苏省苏州市 → 苏州市)
|
||||
const displayCity = city && province.startsWith(city.replace('市', '')) ? '' : city;
|
||||
return `${province}${displayCity}${district}`;
|
||||
}
|
||||
return `${latitude.toFixed(4)},${longitude.toFixed(4)}(解析失败)`;
|
||||
} catch (err) {
|
||||
console.error('[Wechat] reverseGeocode error:', err.message);
|
||||
return `${latitude.toFixed(4)},${longitude.toFixed(4)}(解析异常)`;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { getWechatConfig, getAccessToken, getPhoneNumber, reverseGeocode };
|
||||
@@ -0,0 +1,167 @@
|
||||
-- 苏州肛泰中医院 预约挂号系统 数据库结构
|
||||
|
||||
-- 医院
|
||||
CREATE TABLE IF NOT EXISTS hospitals (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
name VARCHAR(100) NOT NULL,
|
||||
level VARCHAR(20),
|
||||
address VARCHAR(200),
|
||||
phone VARCHAR(20),
|
||||
intro TEXT,
|
||||
description TEXT,
|
||||
banner VARCHAR(500),
|
||||
logo VARCHAR(500),
|
||||
established VARCHAR(10),
|
||||
departments INT DEFAULT 0,
|
||||
doctors INT DEFAULT 0,
|
||||
beds INT DEFAULT 0,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 科室
|
||||
CREATE TABLE IF NOT EXISTS departments (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
icon VARCHAR(20),
|
||||
description VARCHAR(200),
|
||||
doctor_count INT DEFAULT 0,
|
||||
sort INT DEFAULT 0,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 医生
|
||||
CREATE TABLE IF NOT EXISTS doctors (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(50),
|
||||
department_id VARCHAR(32),
|
||||
department_name VARCHAR(50),
|
||||
avatar VARCHAR(500),
|
||||
specialty VARCHAR(200),
|
||||
intro TEXT,
|
||||
fee DECIMAL(10,2) DEFAULT 0,
|
||||
rating DECIMAL(2,1) DEFAULT 0,
|
||||
available_dates JSON,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 预约
|
||||
CREATE TABLE IF NOT EXISTS appointments (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
hospital_id VARCHAR(32),
|
||||
hospital_name VARCHAR(100),
|
||||
department_name VARCHAR(50),
|
||||
doctor_id VARCHAR(32),
|
||||
doctor_name VARCHAR(50),
|
||||
patient_name VARCHAR(50) NOT NULL,
|
||||
patient_age INT,
|
||||
patient_phone VARCHAR(20) NOT NULL,
|
||||
date DATE,
|
||||
time_slot VARCHAR(20),
|
||||
symptom TEXT,
|
||||
status VARCHAR(20) DEFAULT 'pending',
|
||||
remark VARCHAR(500),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
INDEX idx_phone (patient_phone),
|
||||
INDEX idx_status (status),
|
||||
INDEX idx_date (date)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 管理员
|
||||
CREATE TABLE IF NOT EXISTS admins (
|
||||
id INT AUTO_INCREMENT PRIMARY KEY,
|
||||
username VARCHAR(50) UNIQUE NOT NULL,
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
nickname VARCHAR(50),
|
||||
role VARCHAR(20) DEFAULT 'admin',
|
||||
last_login_at DATETIME,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 专家(运营管理)
|
||||
CREATE TABLE IF NOT EXISTS experts (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
title VARCHAR(50),
|
||||
avatar VARCHAR(500),
|
||||
department VARCHAR(50),
|
||||
intro TEXT,
|
||||
sort INT DEFAULT 0,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 轮播图
|
||||
CREATE TABLE IF NOT EXISTS banners (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
title VARCHAR(100),
|
||||
subtitle VARCHAR(200),
|
||||
image VARCHAR(500) NOT NULL,
|
||||
link VARCHAR(500),
|
||||
sort INT DEFAULT 0,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 首页整体配置(单行 id=1)
|
||||
CREATE TABLE IF NOT EXISTS home_config (
|
||||
id INT PRIMARY KEY,
|
||||
data JSON NOT NULL,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 中心 (3 中心跳转)
|
||||
CREATE TABLE IF NOT EXISTS centers (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
name VARCHAR(64) NOT NULL COMMENT '中心名称',
|
||||
slug VARCHAR(64) NOT NULL UNIQUE COMMENT 'slug',
|
||||
icon VARCHAR(16) DEFAULT '' COMMENT 'emoji',
|
||||
icon_image VARCHAR(2000) DEFAULT '' COMMENT '首页图标图片',
|
||||
intro VARCHAR(200) DEFAULT '' COMMENT '简介',
|
||||
image VARCHAR(2000) DEFAULT '' COMMENT '中心详情配图',
|
||||
sort INT DEFAULT 99,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 首页简介长图 (单图)
|
||||
CREATE TABLE IF NOT EXISTS home_intro (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
image VARCHAR(2000) DEFAULT '',
|
||||
caption VARCHAR(200),
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 医院图片(医院简介页轮播图)
|
||||
CREATE TABLE IF NOT EXISTS hospital_images (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
hospital_id VARCHAR(32) DEFAULT 'h-001',
|
||||
image VARCHAR(500) NOT NULL,
|
||||
caption VARCHAR(200) DEFAULT '',
|
||||
sort INT DEFAULT 99,
|
||||
enabled TINYINT(1) DEFAULT 1,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
|
||||
-- 意向客户 leads(Phase 3 · 自主问卷用户信息采集)
|
||||
-- 唯一约束 (phone, source) 防止同一用户在同一渠道重复提交
|
||||
CREATE TABLE IF NOT EXISTS leads (
|
||||
id VARCHAR(32) PRIMARY KEY,
|
||||
name VARCHAR(50) NOT NULL,
|
||||
phone VARCHAR(20) NOT NULL,
|
||||
symptom TEXT,
|
||||
questionnaire JSON NOT NULL,
|
||||
source VARCHAR(20) NOT NULL DEFAULT 'self-test',
|
||||
score INT DEFAULT 0,
|
||||
remark VARCHAR(500),
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
|
||||
UNIQUE KEY uniq_phone_source (phone, source),
|
||||
INDEX idx_phone (phone),
|
||||
INDEX idx_source (source),
|
||||
INDEX idx_created (created_at)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@@ -0,0 +1,30 @@
|
||||
/// seed.js
|
||||
/// 描述:初始化数据 - 当数据库表为空时,灌入演示数据
|
||||
/// 作用:保证生产与开发环境都有可用的初始内容
|
||||
const { getPool, init } = require('./db');
|
||||
const { seedHospitals } = require('./seeds/hospitals');
|
||||
const { seedDepartments } = require('./seeds/departments');
|
||||
const { seedDoctors } = require('./seeds/doctors');
|
||||
const { seedExperts } = require('./seeds/experts');
|
||||
const { seedBanners } = require('./seeds/banners');
|
||||
const { seedHomeConfig } = require('./seeds/homeConfig');
|
||||
const { seedAdmins } = require('./seeds/admins');
|
||||
|
||||
const main = async () => {
|
||||
await init();
|
||||
const pool = getPool();
|
||||
await seedHospitals(pool);
|
||||
await seedDepartments(pool);
|
||||
await seedDoctors(pool);
|
||||
await seedExperts(pool);
|
||||
await seedBanners(pool);
|
||||
await seedHomeConfig(pool);
|
||||
await seedAdmins(pool);
|
||||
console.log('[Seed] done');
|
||||
process.exit(0);
|
||||
};
|
||||
|
||||
main().catch((err) => {
|
||||
console.error('[Seed] failed', err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,26 @@
|
||||
/// seeds/admins.js
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const seedAdmins = async (pool) => {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.query('SELECT COUNT(*) AS c FROM admins');
|
||||
if (rows[0].c > 0) {
|
||||
console.log('[Seed] admins: skipped (already has data)');
|
||||
return;
|
||||
}
|
||||
const username = process.env.ADMIN_USER || 'admin';
|
||||
const password = process.env.ADMIN_PASSWORD || 'Yygh@2026#Admin';
|
||||
const passwordHash = await bcrypt.hash(password, 10);
|
||||
await conn.query(
|
||||
`INSERT INTO admins (username, password_hash, nickname, role)
|
||||
VALUES (?, ?, ?, 'super')`,
|
||||
[username, passwordHash, '超级管理员']
|
||||
);
|
||||
console.log(`[Seed] admins: inserted (username=${username}, please change the default password after first login)`);
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { seedAdmins };
|
||||
@@ -0,0 +1,27 @@
|
||||
/// seeds/banners.js
|
||||
const banners = [
|
||||
{ id: 'b-1', title: '为肛肠/胃肠健康护航', subtitle: '三甲名医亲诊', image: 'https://picsum.photos/id/1015/750/400', link: '/pages/appointment/index', sort: 1, enabled: 1 }
|
||||
];
|
||||
|
||||
const seedBanners = async (pool) => {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.query('SELECT COUNT(*) AS c FROM banners');
|
||||
if (rows[0].c > 0) {
|
||||
console.log('[Seed] banners: skipped (already has data)');
|
||||
return;
|
||||
}
|
||||
for (const b of banners) {
|
||||
await conn.query(
|
||||
`INSERT INTO banners (id, title, subtitle, image, link, sort, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)`,
|
||||
[b.id, b.title, b.subtitle, b.image, b.link, b.sort, b.enabled]
|
||||
);
|
||||
}
|
||||
console.log(`[Seed] banners: inserted ${banners.length}`);
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { seedBanners };
|
||||
@@ -0,0 +1,28 @@
|
||||
/// seeds/centers.js
|
||||
const centers = [
|
||||
{ id: 'c-gangchang', name: '肛肠中心', slug: 'gangchang', icon: '🩺', intro: '国家中医药管理局重点专科', image: '', sort: 1, enabled: 1 },
|
||||
{ id: 'c-weichang', name: '胃肠中心', slug: 'weichang', icon: '🧬', intro: '江苏省临床重点专科', image: '', sort: 2, enabled: 1 },
|
||||
{ id: 'c-neijing', name: '内镜中心', slug: 'neijing', icon: '🖥️', intro: '省内首席 · 设备先进', image: '', sort: 3, enabled: 1 }
|
||||
];
|
||||
|
||||
const seedCenters = async (pool) => {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.query('SELECT COUNT(*) AS c FROM centers');
|
||||
if (rows[0].c > 0) {
|
||||
console.log('[Seed] centers: skipped (already has data)');
|
||||
return;
|
||||
}
|
||||
for (const c of centers) {
|
||||
await conn.query(
|
||||
`INSERT INTO centers (id, name, slug, icon, intro, image, sort, enabled) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[c.id, c.name, c.slug, c.icon, c.intro, c.image, c.sort, c.enabled]
|
||||
);
|
||||
}
|
||||
console.log(`[Seed] centers: inserted ${centers.length}`);
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { seedCenters };
|
||||
@@ -0,0 +1,36 @@
|
||||
/// seeds/departments.js
|
||||
const departments = [
|
||||
{ id: 'd-001', name: '内科', icon: '🩺', description: '心内、呼吸、消化、内分泌等', doctor_count: 32, sort: 1 },
|
||||
{ id: 'd-002', name: '外科', icon: '🩹', description: '普外、骨科、神经外科等', doctor_count: 28, sort: 2 },
|
||||
{ id: 'd-003', name: '儿科', icon: '👶', description: '儿童常见病及疑难杂症', doctor_count: 18, sort: 3 },
|
||||
{ id: 'd-004', name: '妇产科', icon: '🤰', description: '妇科、产科、计划生育', doctor_count: 22, sort: 4 },
|
||||
{ id: 'd-005', name: '眼科', icon: '👁️', description: '近视、白内障、青光眼', doctor_count: 12, sort: 5 },
|
||||
{ id: 'd-006', name: '耳鼻喉科', icon: '👂', description: '中耳炎、鼻炎、咽喉疾病', doctor_count: 10, sort: 6 },
|
||||
{ id: 'd-007', name: '皮肤科', icon: '🧴', description: '皮肤病、性病诊治', doctor_count: 9, sort: 7 },
|
||||
{ id: 'd-008', name: '口腔科', icon: '🦷', description: '牙病防治、口腔修复', doctor_count: 14, sort: 8 },
|
||||
{ id: 'd-009', name: '中医科', icon: '🌿', description: '中医诊疗、针灸推拿', doctor_count: 16, sort: 9 },
|
||||
{ id: 'd-010', name: '心理科', icon: '🧠', description: '心理咨询与精神疾病', doctor_count: 8, sort: 10 }
|
||||
];
|
||||
|
||||
const seedDepartments = async (pool) => {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.query('SELECT COUNT(*) AS c FROM departments');
|
||||
if (rows[0].c > 0) {
|
||||
console.log('[Seed] departments: skipped (already has data)');
|
||||
return;
|
||||
}
|
||||
for (const d of departments) {
|
||||
await conn.query(
|
||||
`INSERT INTO departments (id, name, icon, description, doctor_count, sort, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, 1)`,
|
||||
[d.id, d.name, d.icon, d.description, d.doctor_count, d.sort]
|
||||
);
|
||||
}
|
||||
console.log(`[Seed] departments: inserted ${departments.length}`);
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { seedDepartments };
|
||||
@@ -0,0 +1,47 @@
|
||||
/// seeds/doctors.js
|
||||
const dayjs = require('dayjs');
|
||||
|
||||
const generateDates = () => {
|
||||
const dates = [];
|
||||
for (let i = 1; i <= 7; i++) {
|
||||
dates.push(dayjs().add(i, 'day').format('YYYY-MM-DD'));
|
||||
}
|
||||
return dates;
|
||||
};
|
||||
const defaultDates = generateDates();
|
||||
|
||||
const doctors = [
|
||||
{ id: 'doc-001', name: '张明远', title: '主任医师', department_id: 'd-001', department_name: '内科', avatar: 'https://picsum.photos/id/64/200/200', specialty: '心血管疾病、高血压、冠心病的诊治', intro: '从事心血管内科临床工作 30 余年。', fee: 50, rating: 4.9, available_dates: defaultDates },
|
||||
{ id: 'doc-002', name: '李慧珍', title: '副主任医师', department_id: 'd-001', department_name: '内科', avatar: 'https://picsum.photos/id/91/200/200', specialty: '糖尿病、甲状腺疾病', intro: '内分泌科专家。', fee: 30, rating: 4.8, available_dates: defaultDates },
|
||||
{ id: 'doc-003', name: '王建国', title: '主任医师', department_id: 'd-002', department_name: '外科', avatar: 'https://picsum.photos/id/177/200/200', specialty: '肝胆外科、胃肠道肿瘤', intro: '30 年普外科经验。', fee: 50, rating: 4.9, available_dates: defaultDates },
|
||||
{ id: 'doc-004', name: '陈思雨', title: '主治医师', department_id: 'd-003', department_name: '儿科', avatar: 'https://picsum.photos/id/338/200/200', specialty: '新生儿疾病、儿童呼吸道感染', intro: '温柔耐心。', fee: 20, rating: 4.9, available_dates: defaultDates },
|
||||
{ id: 'doc-005', name: '刘亚楠', title: '副主任医师', department_id: 'd-004', department_name: '妇产科', avatar: 'https://picsum.photos/id/1027/200/200', specialty: '高危妊娠、妇科肿瘤', intro: '妇产科临床工作 20 年。', fee: 30, rating: 4.8, available_dates: defaultDates },
|
||||
{ id: 'doc-006', name: '赵明亮', title: '主任医师', department_id: 'd-005', department_name: '眼科', avatar: 'https://picsum.photos/id/1025/200/200', specialty: '白内障、青光眼手术', intro: '完成白内障手术 2 万余例。', fee: 50, rating: 4.9, available_dates: defaultDates },
|
||||
{ id: 'doc-007', name: '孙婉清', title: '副主任医师', department_id: 'd-007', department_name: '皮肤科', avatar: 'https://picsum.photos/id/659/200/200', specialty: '痤疮、湿疹、激光美容', intro: '皮肤激光美容专家。', fee: 30, rating: 4.7, available_dates: defaultDates },
|
||||
{ id: 'doc-008', name: '周文博', title: '主治医师', department_id: 'd-008', department_name: '口腔科', avatar: 'https://picsum.photos/id/718/200/200', specialty: '牙体牙髓病、牙齿修复', intro: '口腔综合诊疗。', fee: 20, rating: 4.8, available_dates: defaultDates },
|
||||
{ id: 'doc-009', name: '吴秀英', title: '主任医师', department_id: 'd-009', department_name: '中医科', avatar: 'https://picsum.photos/id/237/200/200', specialty: '中医调理、针灸推拿', intro: '中医世家,40 年经验。', fee: 50, rating: 4.9, available_dates: defaultDates },
|
||||
{ id: 'doc-010', name: '郑凯', title: '副主任医师', department_id: 'd-010', department_name: '心理科', avatar: 'https://picsum.photos/id/783/200/200', specialty: '焦虑症、抑郁症、失眠', intro: '心理咨询与治疗专家。', fee: 30, rating: 4.8, available_dates: defaultDates }
|
||||
];
|
||||
|
||||
const seedDoctors = async (pool) => {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.query('SELECT COUNT(*) AS c FROM doctors');
|
||||
if (rows[0].c > 0) {
|
||||
console.log('[Seed] doctors: skipped (already has data)');
|
||||
return;
|
||||
}
|
||||
for (const d of doctors) {
|
||||
await conn.query(
|
||||
`INSERT INTO doctors (id, name, title, department_id, department_name, avatar, specialty, intro, fee, rating, available_dates, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1)`,
|
||||
[d.id, d.name, d.title, d.department_id, d.department_name, d.avatar, d.specialty, d.intro, d.fee, d.rating, JSON.stringify(d.available_dates)]
|
||||
);
|
||||
}
|
||||
console.log(`[Seed] doctors: inserted ${doctors.length}`);
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { seedDoctors };
|
||||
@@ -0,0 +1,31 @@
|
||||
/// seeds/experts.js
|
||||
const experts = [
|
||||
{ id: 'he-1', name: '王秋萍', title: '主任医师', avatar: 'https://picsum.photos/id/64/240/240', department: '肛肠科', intro: '从事消化内科及肛肠疾病临床工作 40 余年。', sort: 1 },
|
||||
{ id: 'he-2', name: '闻新阁', title: '主任医师', avatar: 'https://picsum.photos/id/91/240/240', department: '胃肠科', intro: '原解放军第 113 医院消化内科主任。', sort: 2 },
|
||||
{ id: 'he-3', name: '王维东', title: '副主任医师', avatar: 'https://picsum.photos/id/177/240/240', department: '中医科', intro: '原北京中医药大学附属医院副主任医师。', sort: 3 },
|
||||
{ id: 'he-4', name: '吴琼英', title: '主任医师', avatar: 'https://picsum.photos/id/338/240/240', department: '肛肠科', intro: '省医学会肛肠分会委员。', sort: 4 },
|
||||
{ id: 'he-5', name: '王连池', title: '主任医师', avatar: 'https://picsum.photos/id/1027/240/240', department: '肛肠科', intro: '省肛肠疑难病专家。', sort: 5 }
|
||||
];
|
||||
|
||||
const seedExperts = async (pool) => {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.query('SELECT COUNT(*) AS c FROM experts');
|
||||
if (rows[0].c > 0) {
|
||||
console.log('[Seed] experts: skipped (already has data)');
|
||||
return;
|
||||
}
|
||||
for (const e of experts) {
|
||||
await conn.query(
|
||||
`INSERT INTO experts (id, name, title, avatar, department, intro, sort, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, 1)`,
|
||||
[e.id, e.name, e.title, e.avatar, e.department, e.intro, e.sort]
|
||||
);
|
||||
}
|
||||
console.log(`[Seed] experts: inserted ${experts.length}`);
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { seedExperts };
|
||||
@@ -0,0 +1,54 @@
|
||||
/// seeds/homeConfig.js
|
||||
const homeConfig = {
|
||||
navTitle: '苏州肛泰中医院预约挂号平台',
|
||||
hero: {
|
||||
title: '为肛肠/胃肠健康护航',
|
||||
subtitle: '三甲名医亲诊',
|
||||
background: 'linear-gradient(180deg, #4ea1ff 0%, #2b80f5 60%, #2572e6 100%)'
|
||||
},
|
||||
heroExperts: [
|
||||
{ id: 'he-1', name: '王秋萍', title: '主任医师', avatar: 'https://picsum.photos/id/64/240/240', label: '原解放军第 454 医院' },
|
||||
{ id: 'he-2', name: '闻新阁', title: '主任医师', avatar: 'https://picsum.photos/id/177/240/240', label: '原解放军第 113 医院' },
|
||||
{ id: 'he-3', name: '王维东', title: '副主任医师', avatar: 'https://picsum.photos/id/91/240/240', label: '原北京中医药大学' },
|
||||
{ id: 'he-4', name: '吴琼英', title: '主任医师', avatar: 'https://picsum.photos/id/338/240/240', label: '省医学会肛肠分会委员' },
|
||||
{ id: 'he-5', name: '王连池', title: '主任医师', avatar: 'https://picsum.photos/id/1027/240/240', label: '省肛肠疑难病专家' }
|
||||
],
|
||||
features: [
|
||||
{ id: 'f-1', name: '肛肠中心', sub: '特色专区', icon: '🩺', iconColor: 'linear-gradient(135deg, #5ed3c0 0%, #2dbfa6 100%)', link: '/pages/appointment/index' },
|
||||
{ id: 'f-2', name: '胃肠中心', sub: '三甲名医', icon: '🧬', iconColor: 'linear-gradient(135deg, #5ed3c0 0%, #2dbfa6 100%)', link: '/pages/appointment/index' },
|
||||
{ id: 'f-3', name: '内镜中心', sub: '省内首席', icon: '🖥️', iconColor: 'linear-gradient(135deg, #5ed3c0 0%, #2dbfa6 100%)', link: '/pages/appointment/index' }
|
||||
],
|
||||
infoBanner: {
|
||||
texts: [
|
||||
'病情变化及时就诊,不可主观臆断!',
|
||||
'结合辅助检查,明确诊断疾病'
|
||||
],
|
||||
buttonText: '免费咨询',
|
||||
buttonLink: '/pages/phone-consult/index'
|
||||
},
|
||||
expertTeam: [
|
||||
{ id: 'et-1', name: '王秋萍', title: '主任医师', avatar: 'https://picsum.photos/id/64/240/240', tag: '特邀', specialty: '特邀胃肠专家 · 40 年临床经验', link: '/pages/hospital/index' },
|
||||
{ id: 'et-2', name: '闻新阁', title: '主任医师', avatar: 'https://picsum.photos/id/177/240/240', tag: '特邀', specialty: '肛肠疾病专家 · 35 年经验', link: '/pages/hospital/index' },
|
||||
{ id: 'et-3', name: '王维东', title: '副主任医师', avatar: 'https://picsum.photos/id/91/240/240', tag: '特邀', specialty: '胃肠镜诊疗专家', link: '/pages/hospital/index' }
|
||||
]
|
||||
};
|
||||
|
||||
const seedHomeConfig = async (pool) => {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.query('SELECT COUNT(*) AS c FROM home_config WHERE id = 1');
|
||||
if (rows[0].c > 0) {
|
||||
console.log('[Seed] home_config: skipped (already has data)');
|
||||
return;
|
||||
}
|
||||
await conn.query(
|
||||
`INSERT INTO home_config (id, data) VALUES (1, ?)`,
|
||||
[JSON.stringify(homeConfig)]
|
||||
);
|
||||
console.log('[Seed] home_config: inserted');
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { seedHomeConfig };
|
||||
@@ -0,0 +1,30 @@
|
||||
/// seeds/hospitalImages.js
|
||||
const images = [
|
||||
{ id: 'hi-1', hospital_id: 'h-001', image: 'https://picsum.photos/id/1015/800/500', caption: '医院外景', sort: 1, enabled: 1 },
|
||||
{ id: 'hi-2', hospital_id: 'h-001', image: 'https://picsum.photos/id/1018/800/500', caption: '门诊大厅', sort: 2, enabled: 1 },
|
||||
{ id: 'hi-3', hospital_id: 'h-001', image: 'https://picsum.photos/id/1022/800/500', caption: '候诊区', sort: 3, enabled: 1 },
|
||||
{ id: 'hi-4', hospital_id: 'h-001', image: 'https://picsum.photos/id/1027/800/500', caption: '专家诊室', sort: 4, enabled: 1 }
|
||||
];
|
||||
|
||||
const seedHospitalImages = async (pool) => {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.query('SELECT COUNT(*) AS c FROM hospital_images');
|
||||
if (rows[0].c > 0) {
|
||||
console.log('[Seed] hospital_images: skipped (already has data)');
|
||||
return;
|
||||
}
|
||||
for (const it of images) {
|
||||
await conn.query(
|
||||
`INSERT INTO hospital_images (id, hospital_id, image, caption, sort, enabled)
|
||||
VALUES (?, ?, ?, ?, ?, ?)`,
|
||||
[it.id, it.hospital_id, it.image, it.caption, it.sort, it.enabled]
|
||||
);
|
||||
}
|
||||
console.log(`[Seed] hospital_images: inserted ${images.length}`);
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { seedHospitalImages };
|
||||
@@ -0,0 +1,45 @@
|
||||
/// seeds/hospitals.js
|
||||
const hospitals = [
|
||||
{
|
||||
id: 'h-001',
|
||||
name: '苏州肛泰中医院',
|
||||
level: '三甲',
|
||||
address: '苏州市姑苏区吴中西路 888 号',
|
||||
phone: '0512-6586-3999',
|
||||
intro: '胃肠肛肠专科医院 · 三甲名医亲诊 · 医保定点单位',
|
||||
description:
|
||||
'苏州肛泰中医院创建于 1998 年,是一所集医疗、教学、科研、预防、保健、康复于一体的现代化三级甲等胃肠肛肠专科医院,是苏州市医保定点单位。',
|
||||
banner: 'https://picsum.photos/id/1015/750/400',
|
||||
logo: 'https://picsum.photos/id/1036/200/200',
|
||||
established: '1998',
|
||||
departments: 28,
|
||||
doctors: 156,
|
||||
beds: 600
|
||||
}
|
||||
];
|
||||
|
||||
const seedHospitals = async (pool) => {
|
||||
const conn = await pool.getConnection();
|
||||
try {
|
||||
const [rows] = await conn.query('SELECT COUNT(*) AS c FROM hospitals');
|
||||
if (rows[0].c > 0) {
|
||||
console.log('[Seed] hospitals: skipped (already has data)');
|
||||
return;
|
||||
}
|
||||
for (const h of hospitals) {
|
||||
await conn.query(
|
||||
`INSERT INTO hospitals (id, name, level, address, phone, intro, description, banner, logo, established, departments, doctors, beds)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
[
|
||||
h.id, h.name, h.level, h.address, h.phone, h.intro, h.description,
|
||||
h.banner, h.logo, h.established, h.departments, h.doctors, h.beds
|
||||
]
|
||||
);
|
||||
}
|
||||
console.log(`[Seed] hospitals: inserted ${hospitals.length}`);
|
||||
} finally {
|
||||
conn.release();
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = { seedHospitals };
|
||||
@@ -0,0 +1,348 @@
|
||||
/// swagger/spec.js
|
||||
/// 描述:OpenAPI 3.0 规范(inline 静态定义,避免 swagger-jsdoc 扫描差异问题)
|
||||
const spec = {
|
||||
openapi: '3.0.3',
|
||||
info: {
|
||||
title: '苏州肛泰中医院 · 预约挂号 API',
|
||||
version: '1.0.0',
|
||||
description: 'RESTful API · 基地址 `/api/v1` · 业务约定 `{code, message, data}` 统一响应',
|
||||
contact: { name: 'YuyueGuahao Backend', email: '[email protected]' }
|
||||
},
|
||||
servers: [{ url: '/api/v1', description: '当前版本 v1' }],
|
||||
tags: [
|
||||
{ name: 'Hospital', description: '医院信息' },
|
||||
{ name: 'Department', description: '科室' },
|
||||
{ name: 'Doctor', description: '医生' },
|
||||
{ name: 'Appointment', description: '预约挂号' },
|
||||
{ name: 'Expert', description: '专家资源' },
|
||||
{ name: 'Banner', description: 'Banner 轮播图' },
|
||||
{ name: 'Config', description: '首页配置' },
|
||||
{ name: 'AdminAuth', description: '管理员鉴权' },
|
||||
{ name: 'Lead', description: '意向客户 leads' }
|
||||
],
|
||||
components: {
|
||||
securitySchemes: {
|
||||
bearerAuth: {
|
||||
type: 'http', scheme: 'bearer', bearerFormat: 'JWT',
|
||||
description: '管理后台登录后获得的 token'
|
||||
}
|
||||
},
|
||||
schemas: {
|
||||
ApiResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: { type: 'integer', example: 0, description: '0 成功 / 其他业务错误码' },
|
||||
message: { type: 'string', example: 'ok' },
|
||||
data: { nullable: true }
|
||||
}
|
||||
},
|
||||
ErrorResponse: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
code: { type: 'integer', example: 1 },
|
||||
message: { type: 'string', example: '参数错误' },
|
||||
data: { nullable: true, example: null }
|
||||
}
|
||||
},
|
||||
Hospital: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' }, name: { type: 'string' }, level: { type: 'string' },
|
||||
address: { type: 'string' }, phone: { type: 'string' }, intro: { type: 'string' },
|
||||
description: { type: 'string' }, banner: { type: 'string' }, logo: { type: 'string' },
|
||||
established: { type: 'string' }, departments: { type: 'integer' },
|
||||
doctors: { type: 'integer' }, beds: { type: 'integer' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
Department: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' }, name: { type: 'string' }, icon: { type: 'string' },
|
||||
description: { type: 'string' }, doctorCount: { type: 'integer' },
|
||||
sort: { type: 'integer' }, enabled: { type: 'boolean' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
Doctor: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' }, name: { type: 'string' }, title: { type: 'string' },
|
||||
departmentId: { type: 'string', nullable: true },
|
||||
departmentName: { type: 'string', nullable: true },
|
||||
avatar: { type: 'string' }, specialty: { type: 'string' }, intro: { type: 'string' },
|
||||
fee: { type: 'number' }, rating: { type: 'number' },
|
||||
availableDates: { type: 'array', items: { type: 'string' } },
|
||||
enabled: { type: 'boolean' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
Appointment: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' }, hospitalId: { type: 'string' }, hospitalName: { type: 'string' },
|
||||
departmentName: { type: 'string', enum: ['肛肠', '胃肠'] },
|
||||
doctorId: { type: 'string', nullable: true },
|
||||
doctorName: { type: 'string', nullable: true },
|
||||
patientName: { type: 'string' }, patientAge: { type: 'integer' },
|
||||
patientPhone: { type: 'string' },
|
||||
date: { type: 'string', example: '2026-06-14' },
|
||||
timeSlot: { type: 'string', nullable: true }, symptom: { type: 'string' },
|
||||
status: { type: 'string', enum: ['pending', 'confirmed', 'completed', 'cancelled'] },
|
||||
remark: { type: 'string' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
Expert: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' }, name: { type: 'string' }, title: { type: 'string' },
|
||||
avatar: { type: 'string' }, department: { type: 'string' }, intro: { type: 'string' },
|
||||
sort: { type: 'integer' }, enabled: { type: 'boolean' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
},
|
||||
Banner: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
id: { type: 'string' }, title: { type: 'string' }, subtitle: { type: 'string' },
|
||||
image: { type: 'string' }, link: { type: 'string' },
|
||||
sort: { type: 'integer' }, enabled: { type: 'boolean' },
|
||||
createdAt: { type: 'string', format: 'date-time' },
|
||||
updatedAt: { type: 'string', format: 'date-time' }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
paths: {
|
||||
'/hospitals': {
|
||||
get: {
|
||||
tags: ['Hospital'], summary: '医院列表',
|
||||
responses: { 200: { description: 'ok' } }
|
||||
}
|
||||
},
|
||||
'/hospitals/{id}': {
|
||||
get: {
|
||||
tags: ['Hospital'], summary: '医院详情',
|
||||
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }],
|
||||
responses: { 200: { description: 'ok' }, 404: { description: '医院不存在' } }
|
||||
}
|
||||
},
|
||||
'/departments': {
|
||||
get: {
|
||||
tags: ['Department'], summary: '科室列表',
|
||||
parameters: [
|
||||
{ in: 'query', name: 'hospitalId', schema: { type: 'string' } },
|
||||
{ in: 'query', name: 'enabled', schema: { type: 'string', enum: ['true', 'false'] } }
|
||||
],
|
||||
responses: { 200: { description: 'ok' } }
|
||||
}
|
||||
},
|
||||
'/doctors': {
|
||||
get: {
|
||||
tags: ['Doctor'], summary: '医生列表',
|
||||
parameters: [
|
||||
{ in: 'query', name: 'departmentId', schema: { type: 'string' } },
|
||||
{ in: 'query', name: 'departmentName', schema: { type: 'string' } },
|
||||
{ in: 'query', name: 'enabled', schema: { type: 'string', enum: ['true', 'false'] } }
|
||||
],
|
||||
responses: { 200: { description: 'ok' } }
|
||||
}
|
||||
},
|
||||
'/doctors/{id}': {
|
||||
get: {
|
||||
tags: ['Doctor'], summary: '医生详情',
|
||||
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }],
|
||||
responses: { 200: { description: 'ok' }, 404: { description: '医生不存在' } }
|
||||
}
|
||||
},
|
||||
'/appointments': {
|
||||
get: {
|
||||
tags: ['Appointment'], summary: '预约列表',
|
||||
parameters: [
|
||||
{ in: 'query', name: 'phone', schema: { type: 'string' } },
|
||||
{ in: 'query', name: 'status', schema: { type: 'string', enum: ['pending', 'confirmed', 'completed', 'cancelled'] } }
|
||||
],
|
||||
responses: { 200: { description: 'ok' } }
|
||||
},
|
||||
post: {
|
||||
tags: ['Appointment'], summary: '创建预约',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: {
|
||||
'application/json': {
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['patientName', 'patientAge', 'patientPhone', 'date', 'departmentName'],
|
||||
properties: {
|
||||
patientName: { type: 'string' },
|
||||
patientAge: { type: 'integer' },
|
||||
patientPhone: { type: 'string', example: '13800138000' },
|
||||
date: { type: 'string', example: '2026-06-14' },
|
||||
departmentName: { type: 'string', enum: ['肛肠', '胃肠'] },
|
||||
doctorId: { type: 'string' },
|
||||
doctorName: { type: 'string' },
|
||||
timeSlot: { type: 'string' },
|
||||
symptom: { type: 'string' }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
responses: { 200: { description: '预约成功' }, 400: { description: '参数错误(手机号/年龄/科室)' } }
|
||||
}
|
||||
},
|
||||
'/appointments/stats': {
|
||||
get: {
|
||||
tags: ['Appointment'], summary: '预约统计(管理后台)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { 200: { description: 'ok' } }
|
||||
}
|
||||
},
|
||||
'/appointments/{id}': {
|
||||
get: {
|
||||
tags: ['Appointment'], summary: '预约详情',
|
||||
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }],
|
||||
responses: { 200: { description: 'ok' }, 404: { description: '预约不存在' } }
|
||||
}
|
||||
},
|
||||
'/appointments/{id}/cancel': {
|
||||
put: {
|
||||
tags: ['Appointment'], summary: '取消预约',
|
||||
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }],
|
||||
responses: { 200: { description: '已取消' }, 400: { description: '不可取消' }, 404: { description: '预约不存在' } }
|
||||
}
|
||||
},
|
||||
'/appointments/{id}/confirm': {
|
||||
put: {
|
||||
tags: ['Appointment'], summary: '确认(管理后台)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }],
|
||||
responses: { 200: { description: '已确认' }, 400: { description: '已取消的不可确认' }, 404: { description: '预约不存在' } }
|
||||
}
|
||||
},
|
||||
'/appointments/{id}/complete': {
|
||||
put: {
|
||||
tags: ['Appointment'], summary: '完成(管理后台)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }],
|
||||
responses: { 200: { description: '已标记完成' }, 400: { description: '已取消的不可完成' }, 404: { description: '预约不存在' } }
|
||||
}
|
||||
},
|
||||
'/experts': {
|
||||
get: {
|
||||
tags: ['Expert'], summary: '专家列表',
|
||||
parameters: [{ in: 'query', name: 'enabled', schema: { type: 'string', enum: ['true', 'false'] } }],
|
||||
responses: { 200: { description: 'ok' } }
|
||||
}
|
||||
},
|
||||
'/experts/{id}': {
|
||||
get: {
|
||||
tags: ['Expert'], summary: '专家详情',
|
||||
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }],
|
||||
responses: { 200: { description: 'ok' }, 404: { description: '专家不存在' } }
|
||||
}
|
||||
},
|
||||
'/banners': {
|
||||
get: {
|
||||
tags: ['Banner'], summary: 'Banner 列表',
|
||||
parameters: [{ in: 'query', name: 'enabled', schema: { type: 'string', enum: ['true', 'false'] } }],
|
||||
responses: { 200: { description: 'ok' } }
|
||||
}
|
||||
},
|
||||
'/config/home': {
|
||||
get: { tags: ['Config'], summary: '首页配置', responses: { 200: { description: 'ok' } } }
|
||||
},
|
||||
'/admin/auth/login': {
|
||||
post: {
|
||||
tags: ['AdminAuth'], summary: '管理员登录',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { type: 'object', required: ['username', 'password'], properties: { username: { type: 'string' }, password: { type: 'string' } } } } }
|
||||
},
|
||||
responses: { 200: { description: '登录成功' }, 400: { description: '参数错误' }, 401: { description: '用户名或密码错误' } }
|
||||
}
|
||||
},
|
||||
'/admin/auth/me': {
|
||||
get: {
|
||||
tags: ['AdminAuth'], summary: '当前登录账号',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { 200: { description: 'ok' }, 401: { description: '未登录' } }
|
||||
}
|
||||
},
|
||||
'/admin/auth/change-password': {
|
||||
post: {
|
||||
tags: ['AdminAuth'], summary: '修改密码',
|
||||
security: [{ bearerAuth: [] }],
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { type: 'object', required: ['oldPassword', 'newPassword'], properties: { oldPassword: { type: 'string' }, newPassword: { type: 'string', minLength: 8 } } } } }
|
||||
},
|
||||
responses: { 200: { description: '密码修改成功' }, 400: { description: '参数错误或原密码错误' }, 401: { description: '未登录' } }
|
||||
}
|
||||
},
|
||||
'/leads': {
|
||||
post: {
|
||||
tags: ['Lead'], summary: '提交意向客户(小程序自测问卷等)',
|
||||
requestBody: {
|
||||
required: true,
|
||||
content: { 'application/json': { schema: { type: 'object', required: ['name', 'phone', 'questionnaire'], properties: { name: { type: 'string' }, phone: { type: 'string' }, symptom: { type: 'string' }, questionnaire: { type: 'object', additionalProperties: true }, source: { type: 'string', enum: ['self-test', 'home', 'appointment', 'other'] }, score: { type: 'integer' } } } } }
|
||||
},
|
||||
responses: { 200: { description: '提交成功' }, 400: { description: '参数错误' } }
|
||||
}
|
||||
},
|
||||
'/leads/stats': {
|
||||
get: {
|
||||
tags: ['Lead'], summary: 'leads 统计(管理后台)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
responses: { 200: { description: 'ok' } }
|
||||
}
|
||||
},
|
||||
'/admin/leads': {
|
||||
get: {
|
||||
tags: ['Lead'], summary: '意向客户列表(管理后台)',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [
|
||||
{ in: 'query', name: 'phone', schema: { type: 'string' } },
|
||||
{ in: 'query', name: 'source', schema: { type: 'string', enum: ['self-test', 'home', 'appointment', 'other'] } },
|
||||
{ in: 'query', name: 'keyword', schema: { type: 'string' } },
|
||||
{ in: 'query', name: 'startDate', schema: { type: 'string' } },
|
||||
{ in: 'query', name: 'endDate', schema: { type: 'string' } },
|
||||
{ in: 'query', name: 'limit', schema: { type: 'integer', default: 50, maximum: 200 } },
|
||||
{ in: 'query', name: 'offset', schema: { type: 'integer', default: 0 } }
|
||||
],
|
||||
responses: { 200: { description: 'ok' } }
|
||||
}
|
||||
},
|
||||
'/admin/leads/{id}': {
|
||||
get: {
|
||||
tags: ['Lead'], summary: 'Lead 详情',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }],
|
||||
responses: { 200: { description: 'ok' }, 404: { description: '不存在' } }
|
||||
},
|
||||
delete: {
|
||||
tags: ['Lead'], summary: '删除 Lead',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }],
|
||||
responses: { 200: { description: 'ok' }, 404: { description: '不存在' } }
|
||||
}
|
||||
},
|
||||
'/admin/leads/{id}/remark': {
|
||||
put: {
|
||||
tags: ['Lead'], summary: '修改备注',
|
||||
security: [{ bearerAuth: [] }],
|
||||
parameters: [{ in: 'path', name: 'id', required: true, schema: { type: 'string' } }],
|
||||
requestBody: { required: true, content: { 'application/json': { schema: { type: 'object', required: ['remark'], properties: { remark: { type: 'string', maxLength: 500 } } } } } },
|
||||
responses: { 200: { description: 'ok' }, 400: { description: '参数错误' }, 404: { description: '不存在' } }
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = spec;
|
||||
@@ -0,0 +1,34 @@
|
||||
/// utils/auth.js
|
||||
/// 描述:JWT 鉴权 + bcrypt 密码哈希辅助
|
||||
const jwt = require('jsonwebtoken');
|
||||
const bcrypt = require('bcryptjs');
|
||||
|
||||
const JWT_SECRET = process.env.JWT_SECRET || 'yuyueguahao_secret_2026_change_me_in_prod';
|
||||
const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || '7d';
|
||||
|
||||
const hashPassword = (plain) => bcrypt.hash(plain, 10);
|
||||
const comparePassword = (plain, hash) => bcrypt.compare(plain, hash);
|
||||
|
||||
const signToken = (payload) =>
|
||||
jwt.sign(payload, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN });
|
||||
|
||||
const verifyToken = (token) => {
|
||||
try {
|
||||
return jwt.verify(token, JWT_SECRET);
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
/** Express 中间件:校验 Authorization 头中的 Bearer Token */
|
||||
const authRequired = (req, res, next) => {
|
||||
const auth = req.headers.authorization || '';
|
||||
const m = auth.match(/^Bearer\s+(.+)$/i);
|
||||
if (!m) return res.status(401).json({ code: 401, message: '未登录或登录已过期', data: null });
|
||||
const payload = verifyToken(m[1]);
|
||||
if (!payload) return res.status(401).json({ code: 401, message: '登录已失效,请重新登录', data: null });
|
||||
req.admin = payload;
|
||||
next();
|
||||
};
|
||||
|
||||
module.exports = { hashPassword, comparePassword, signToken, verifyToken, authRequired, JWT_SECRET };
|
||||
@@ -0,0 +1,14 @@
|
||||
// 统一响应封装
|
||||
const success = (data, message = 'ok') => ({
|
||||
code: 0,
|
||||
message,
|
||||
data
|
||||
});
|
||||
|
||||
const fail = (message = 'error', code = 1) => ({
|
||||
code,
|
||||
message,
|
||||
data: null
|
||||
});
|
||||
|
||||
module.exports = { success, fail };
|
||||
@@ -0,0 +1,31 @@
|
||||
// utils/url.js
|
||||
// 描述:URL 处理工具,将相对路径的图片 URL 转为绝对 URL(小程序需要绝对 URL 才能加载图片)
|
||||
// 作者:AI Generated
|
||||
// 创建日期:2026-06-14
|
||||
// 最后修改:2026-06-14
|
||||
|
||||
/**
|
||||
* 拼接绝对 URL
|
||||
* - 如果 url 已经是 http:// 或 https:// 开头的绝对地址,原样返回
|
||||
* - 如果 url 是以 / 开头的相对路径,拼接 PUBLIC_BASE_URL
|
||||
* - 如果 url 为空,返回空字符串
|
||||
*
|
||||
* PUBLIC_BASE_URL 通过 PM2 环境变量注入,默认 https://y.szgtlive.com
|
||||
* 修改方式:
|
||||
* pm2 set yuyueguahao-api:PUBLIC_BASE_URL https://your-domain.com
|
||||
* pm2 restart yuyueguahao-api
|
||||
*
|
||||
* @param {string} url - 原始 URL(相对或绝对)
|
||||
* @returns {string} 绝对 URL
|
||||
*/
|
||||
const toAbsoluteUrl = (url) => {
|
||||
if (!url) return '';
|
||||
if (/^https?:\/\//i.test(url)) return url;
|
||||
// 已经是协议无关的绝对路径(//cdn.example.com/...)补 https
|
||||
if (url.startsWith('//')) return `https:${url}`;
|
||||
// 相对路径:拼接 base
|
||||
const base = (process.env.PUBLIC_BASE_URL || 'https://y.szgtlive.com').replace(/\/+$/, '');
|
||||
return `${base}${url.startsWith('/') ? '' : '/'}${url}`;
|
||||
};
|
||||
|
||||
module.exports = { toAbsoluteUrl };
|
||||
Reference in New Issue
Block a user