This commit is contained in:
2026-07-05 14:43:34 +08:00
commit 17a8027b20
277 changed files with 63794 additions and 0 deletions
+264
View File
@@ -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 到真实后端的切换。
+1602
View File
File diff suppressed because it is too large Load Diff
+21
View File
@@ -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"
}
}
+63
View File
@@ -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 };
+174
View File
@@ -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();
+34
View File
@@ -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)`
+21
View File
@@ -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 服务器内部错误
+53
View File
@@ -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;
+44
View File
@@ -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 };
+28
View File
@@ -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`
+21
View File
@@ -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 };
+200
View File
@@ -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;
+79
View File
@@ -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
};
+15
View File
@@ -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`
+16
View File
@@ -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 };
+68
View File
@@ -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;
+50
View File
@@ -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 };
+28
View File
@@ -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 };
+68
View File
@@ -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;
+52
View File
@@ -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 };
+19
View File
@@ -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` 等键。
+110
View File
@@ -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('服务器内部错误'));
}
});
+90
View File
@@ -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 };
+12
View File
@@ -0,0 +1,12 @@
# Department 模块
## 职责
科室资源查询。
## 接口
| Method | Path | 说明 |
|---|---|---|
| GET | `/api/v1/departments` | 科室列表(?hospitalId&enabled) |
## DTO 字段
`id` / `name` / `icon` / `description` / `doctorCount` / `sort` / `enabled` / `createdAt` / `updatedAt`
+14
View File
@@ -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 };
+37
View File
@@ -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;
+19
View File
@@ -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 };
+18
View File
@@ -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`
+35
View File
@@ -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 };
+111
View File
@@ -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;
+58
View File
@@ -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 };
+17
View File
@@ -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`
+25
View File
@@ -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 };
+100
View File
@@ -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;
+59
View File
@@ -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 };
+11
View File
@@ -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 };
+31
View File
@@ -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;
+30
View File
@@ -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 };
+30
View File
@@ -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`
+27
View File
@@ -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 };
+55
View File
@@ -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;
+24
View File
@@ -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 };
+22
View File
@@ -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 };
+75
View File
@@ -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 };
+46
View File
@@ -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
};
+69
View File
@@ -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`。
+34
View File
@@ -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 };
+120
View File
@@ -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;
+89
View File
@@ -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;
+220
View File
@@ -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
};
+27
View File
@@ -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;
+146
View File
@@ -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 };
+69
View File
@@ -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;
+88
View File
@@ -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;
+154
View File
@@ -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 };
+167
View File
@@ -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;
+30
View File
@@ -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);
});
+26
View File
@@ -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 };
+27
View File
@@ -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 };
+28
View File
@@ -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 };
+36
View File
@@ -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 };
+47
View File
@@ -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 };
+31
View File
@@ -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 };
+54
View File
@@ -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 };
+30
View File
@@ -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 };
+45
View File
@@ -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 };
+348
View File
@@ -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;
+34
View File
@@ -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 };
+14
View File
@@ -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 };
+31
View File
@@ -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 };