Commit 0741776b by luoqi

feat:整理代码

parent d7337bcd
stages:
- validate
- deploy
- migrate
- test
variables:
DOCKER_DRIVER: overlay2
# 验证阶段:检查迁移文件
validate_migrations:
stage: validate
image: alpine:latest
before_script:
- apk add --no-cache openssh-client python3 py3-pip
- pip3 install pymysql
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script:
- echo "🔍 验证迁移文件..."
- scp -P $SSH_PORT -r migrations/ $SSH_USER@$SSH_HOST:/tmp/
- scp -P $SSH_PORT database_migration_manager.py $SSH_USER@$SSH_HOST:/tmp/
- scp -P $SSH_PORT migrate.py $SSH_USER@$SSH_HOST:/tmp/
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "cd /tmp && python3 migrate.py validate"
only:
- master
# 部署阶段:更新代码和重启服务
deploy_to_production:
stage: deploy
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script:
- echo "🚀 开始部署到生产环境..."
- echo "📋 上传部署脚本"
- scp -P $SSH_PORT deploy_scripts/deploy_with_backup.sh $SSH_USER@$SSH_HOST:/tmp/
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "chmod +x /tmp/deploy_with_backup.sh"
- echo "🔄 执行部署脚本"
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "/tmp/deploy_with_backup.sh"
- echo "📋 上传迁移文件"
- scp -P $SSH_PORT -r migrations/ $SSH_USER@$SSH_HOST:customer-recall/
- scp -P $SSH_PORT database_migration_manager.py $SSH_USER@$SSH_HOST:customer-recall/
- scp -P $SSH_PORT migrate.py $SSH_USER@$SSH_HOST:customer-recall/
- echo "✅ 部署完成,等待迁移阶段..."
only:
- master
# 迁移阶段:执行数据库迁移
migrate_database:
stage: migrate
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script:
- echo "🗄️ 开始数据库迁移..."
- echo "📊 检查迁移状态"
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "cd customer-recall && docker compose exec -T patient_callback_app python migrate.py status"
- echo "🔍 试运行迁移(验证SQL语法)"
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "cd customer-recall && docker compose exec -T patient_callback_app python migrate.py migrate --dry-run"
- echo "🚀 执行数据库迁移"
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "cd customer-recall && docker compose exec -T patient_callback_app python migrate.py migrate"
- echo "📊 检查迁移结果"
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "cd customer-recall && docker compose exec -T patient_callback_app python migrate.py status"
- echo "🔍 验证数据库结构"
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "cd customer-recall && docker compose exec -T mysql mysql -u root -p\$DB_PASSWORD -e 'USE callback_system; SHOW TABLES;'"
- echo "📋 检查关键表的记录数"
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "cd customer-recall && docker compose exec -T mysql mysql -u root -p\$DB_PASSWORD -e 'USE callback_system; SELECT \"users\" as table_name, COUNT(*) as count FROM users UNION SELECT \"patients\", COUNT(*) FROM patients UNION SELECT \"callback_records\", COUNT(*) FROM callback_records;'"
only:
- master
dependencies:
- deploy_to_production
# 测试阶段:验证部署结果
post_deploy_test:
stage: test
image: alpine:latest
before_script:
- apk add --no-cache openssh-client curl
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script:
- echo "🧪 开始部署后测试..."
- echo "🔍 检查容器状态"
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "cd customer-recall && docker compose ps"
- echo "🌐 检查应用健康状态"
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "cd customer-recall && curl -f http://localhost:5000/login || echo 'Application health check failed'"
- echo "🗄️ 检查数据库连接"
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "cd customer-recall && docker compose exec -T mysql mysql -u root -p\$DB_PASSWORD -e 'SELECT 1;'"
- echo "📊 最终数据统计"
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "cd customer-recall && docker compose exec -T mysql mysql -u root -p\$DB_PASSWORD -e 'USE callback_system; SELECT clinic_name, COUNT(*) as patient_count FROM patients GROUP BY clinic_name ORDER BY patient_count DESC;'"
- echo "✅ 部署测试完成"
only:
- master
dependencies:
- migrate_database
# 手动回滚任务(仅在需要时手动触发)
rollback_migration:
stage: migrate
image: alpine:latest
before_script:
- apk add --no-cache openssh-client
- eval $(ssh-agent -s)
- echo "$SSH_PRIVATE_KEY" | tr -d '\r' | ssh-add -
- mkdir -p ~/.ssh
- echo "$SSH_KNOWN_HOSTS" >> ~/.ssh/known_hosts
- chmod 644 ~/.ssh/known_hosts
script:
- echo "🔄 手动回滚迁移..."
- echo "⚠️ 警告:这是一个危险操作!"
- ssh -p $SSH_PORT $SSH_USER@$SSH_HOST "cd customer-recall && docker compose exec -T patient_callback_app python migrate.py status"
- echo "请在GitLab CI界面手动指定要回滚的版本号"
- echo "使用方法:docker compose exec -T patient_callback_app python migrate.py rollback VERSION"
when: manual
only:
- master
# 数据库迁移管理系统使用指南
## 📋 概述
这是一个为 Flask + MySQL + Docker 环境设计的数据库迁移管理系统,提供安全、可控的数据库结构更新机制。
## 🏗️ 系统架构
### 核心组件
- **DatabaseMigrationManager**: 迁移管理器核心类
- **migrate.py**: 命令行工具
- **migrations/**: 迁移文件目录
- **CI/CD集成**: 自动化部署和迁移
### 安全特性
-**自动备份**: 每次迁移前自动创建数据库备份
-**事务性执行**: 迁移失败自动回滚
-**版本控制**: 完整的迁移历史记录
-**校验和验证**: 防止迁移文件被意外修改
-**试运行模式**: 执行前验证SQL语法
## 🚀 快速开始
### 1. 安装依赖
```bash
pip install pymysql
```
### 2. 配置数据库连接
确保 `database_config.py` 中的 `get_database_config()` 函数返回正确的数据库配置。
### 3. 初始化迁移系统
```bash
python migrate.py status
```
## 📝 创建迁移
### 创建新迁移文件
```bash
python migrate.py create "add_user_email" "为用户表添加邮箱字段"
```
这会创建一个新的迁移文件:`migrations/20250105_143022_add_user_email.sql`
### 编辑迁移文件
```sql
-- ==========================================
-- UP Migration (执行迁移)
-- ==========================================
ALTER TABLE users
ADD COLUMN email VARCHAR(255) NULL AFTER username;
ALTER TABLE users
ADD UNIQUE INDEX idx_users_email (email);
-- ==========================================
-- DOWN Migration (回滚迁移)
-- ==========================================
ALTER TABLE users
DROP INDEX idx_users_email;
ALTER TABLE users
DROP COLUMN email;
```
## 🔧 执行迁移
### 查看迁移状态
```bash
python migrate.py status
```
### 试运行迁移(推荐)
```bash
python migrate.py migrate --dry-run
```
### 执行迁移
```bash
python migrate.py migrate
```
### 执行到指定版本
```bash
python migrate.py migrate --target=20250105_143022
```
## 🔄 回滚迁移
### 回滚指定版本
```bash
python migrate.py rollback 20250105_143022
```
**注意**: 回滚操作需要迁移文件包含完整的 DOWN 部分。
## 🔍 验证和维护
### 验证迁移文件
```bash
python migrate.py validate
```
### 查看详细状态
```bash
python migrate.py status
```
## 🐳 Docker 环境使用
### 在容器中执行迁移
```bash
docker compose exec patient_callback_app python migrate.py status
docker compose exec patient_callback_app python migrate.py migrate --dry-run
docker compose exec patient_callback_app python migrate.py migrate
```
## 🚀 CI/CD 集成
### 使用新的 CI/CD 配置
1.`.gitlab-ci-new.yml` 重命名为 `.gitlab-ci.yml`
2. 确保环境变量配置正确:
- `SSH_PRIVATE_KEY`
- `SSH_KNOWN_HOSTS`
- `SSH_PORT`
- `SSH_USER`
- `SSH_HOST`
- `DB_PASSWORD`
### 部署流程
1. **验证阶段**: 验证迁移文件语法和完整性
2. **部署阶段**: 更新代码和重启容器
3. **迁移阶段**: 执行数据库迁移
4. **测试阶段**: 验证部署结果
## 📁 文件结构
```
customer-recall/
├── database_migration_manager.py # 迁移管理器
├── migrate.py # 命令行工具
├── migrations/ # 迁移文件目录
│ ├── 20250101_000000_initial_schema.sql
│ └── 20250102_120000_add_user_email.sql
├── deploy_scripts/
│ └── deploy_with_migration.sh # 集成迁移的部署脚本
└── .gitlab-ci-new.yml # 新的CI/CD配置
```
## 🛡️ 安全最佳实践
### 1. 迁移文件编写
- ✅ 总是提供 DOWN 迁移用于回滚
- ✅ 使用事务安全的操作
- ✅ 避免删除数据的操作
- ✅ 大表操作考虑分批执行
### 2. 生产环境部署
- ✅ 部署前在测试环境验证
- ✅ 使用 `--dry-run` 验证SQL语法
- ✅ 确保数据库备份完整
- ✅ 监控迁移执行时间
### 3. 回滚准备
- ✅ 测试回滚脚本的正确性
- ✅ 保留足够的备份文件
- ✅ 记录回滚操作步骤
## 🚨 故障处理
### 迁移失败处理
1. 检查错误日志
2. 验证数据库连接
3. 检查SQL语法
4. 必要时手动回滚
### 回滚失败处理
1. 使用数据库备份恢复
2. 手动执行回滚SQL
3. 更新迁移历史表
### 紧急恢复
```bash
# 使用备份恢复数据库
mysql -u root -p callback_system < database_backups/migration_backup_20250105_143022.sql
# 手动更新迁移历史
UPDATE migration_history SET status = 'ROLLED_BACK' WHERE version = '20250105_143022';
```
## 📊 监控和日志
### 迁移历史查询
```sql
SELECT * FROM migration_history ORDER BY executed_at DESC LIMIT 10;
```
### 性能监控
- 监控迁移执行时间
- 检查数据库锁等待
- 观察系统资源使用
## 🔧 高级功能
### 自定义迁移管理器
```python
from database_migration_manager import DatabaseMigrationManager
# 自定义配置
manager = DatabaseMigrationManager(
db_config=custom_config,
migrations_dir="custom_migrations"
)
# 程序化执行迁移
success = manager.migrate(target_version="20250105_143022")
```
### 批量操作
```python
# 获取待执行迁移
pending = manager.get_pending_migrations()
# 逐个执行并记录结果
for migration in pending:
success = manager.execute_migration(migration)
if not success:
break
```
## 📞 支持和维护
如有问题,请检查:
1. 数据库连接配置
2. 迁移文件语法
3. 权限设置
4. 日志文件内容
---
**重要提醒**: 在生产环境执行迁移前,请务必在测试环境充分验证!
...@@ -26,10 +26,10 @@ RUN pip install --no-cache-dir -r requirements.txt ...@@ -26,10 +26,10 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
# 创建必要的目录 # 创建必要的目录
RUN mkdir -p progress_saves dify_callback_results RUN mkdir -p progress_saves data/callbacks data/patients/clinics data/patients/merged data/exports
# 设置权限 # 设置权限
RUN chmod +x *.py RUN chmod +x *.py database/scripts/entrypoint.sh database/scripts/*.py
# 暴露端口 # 暴露端口
EXPOSE 5000 EXPOSE 5000
...@@ -38,8 +38,8 @@ EXPOSE 5000 ...@@ -38,8 +38,8 @@ EXPOSE 5000
RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/* RUN apt-get update && apt-get install -y curl && rm -rf /var/lib/apt/lists/*
# 健康检查 # 健康检查
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \ HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:5000/ || exit 1 CMD curl -f http://localhost:5000/api/health || exit 1
# 启动命令 # 使用entrypoint.sh启动(零配置自动迁移)
CMD ["python", "app.py"] ENTRYPOINT ["./database/scripts/entrypoint.sh"]
\ No newline at end of file \ No newline at end of file
# MySQL Dockerfile - 基于官方MySQL镜像
FROM mysql:8.0
# 设置时区
ENV TZ=Asia/Shanghai
RUN ln -snf /usr/share/zoneinfo/$TZ /etc/localtime && echo $TZ > /etc/timezone
# 复制初始化脚本
COPY init.sql /docker-entrypoint-initdb.d/
# 设置权限
RUN chmod 644 /docker-entrypoint-initdb.d/init.sql
# 暴露端口
EXPOSE 3306
# 使用官方MySQL启动命令
CMD ["mysqld"]
\ No newline at end of file
No preview for this file type
#!/usr/bin/env python3 #!/usr/bin/env python3
# -*- coding: utf-8 -*- # -*- coding: utf-8 -*-
""" """
用户认证系统 患者画像回访话术系统
提供登录验证、session管理和权限检查功能 基于Flask的Web应用,提供患者信息管理和AI回访话术功能
""" """
import os import os
import json from flask import Flask
import hashlib from flask_sqlalchemy import SQLAlchemy
import secrets from flask_migrate import Migrate
import time
from flask import Flask, request, jsonify, session, redirect, render_template_string, render_template, send_file, url_for
from flask_cors import CORS
from datetime import datetime, timedelta
# 尝试导入回访记录相关模块 # 导入配置
try: from config import config
from callback_record_mysql import MySQLCallbackRecordManager, CallbackRecord
from database_config import DatabaseConfig
CALLBACK_AVAILABLE = True
except ImportError as e:
print(f"⚠️ 回访记录模块导入失败: {e}")
CALLBACK_AVAILABLE = False
# 尝试导入clinic_config,如果失败则使用默认配置 # 导入数据库模型
try: from database.models import db
from clinic_config import DEFAULT_USERS, get_user_by_username, get_clinic_info
print("✅ 成功导入clinic_config配置")
except ImportError as e:
print(f"⚠️ 无法导入clinic_config: {e}")
print("使用默认用户配置...")
# 最小默认用户配置(仅作为备用) def create_app(config_name=None):
DEFAULT_USERS = [ """应用工厂函数"""
# 管理员 if config_name is None:
{ config_name = os.environ.get('FLASK_ENV', 'default')
'username': 'admin',
'password': 'admin123',
'role': 'admin',
'clinic_id': 'admin',
'real_name': '系统管理员',
'clinic_name': '总部'
}
]
def get_user_by_username(username): app = Flask(__name__)
"""根据用户名获取用户信息"""
for user in DEFAULT_USERS:
if user['username'] == username:
return user
return None
def get_clinic_info(clinic_id): # 加载配置
"""获取门诊信息""" app.config.from_object(config[config_name])
return {'name': '未知门诊'}
def load_clinic_patients(clinic_id): # 初始化扩展
"""加载指定门诊的患者数据""" db.init_app(app)
try: migrate = Migrate(app, db)
# 获取门诊信息
clinic_info = get_clinic_info(clinic_id)
if not clinic_info:
print(f"❌ 门诊ID不存在: {clinic_id}")
return [], []
# 构建JSON文件路径
clinic_name_mapping = {
'clinic_xuexian': '学前街门诊',
'clinic_dafeng': '大丰门诊',
'clinic_dongting': '东亭门诊',
'clinic_helai': '河埒门诊',
'clinic_hongdou': '红豆门诊',
'clinic_huishan': '惠山门诊',
'clinic_mashan': '马山门诊',
'clinic_hospital': '通善口腔医院',
'clinic_xinwu': '新吴门诊'
}
clinic_name = clinic_name_mapping.get(clinic_id, '未知门诊')
# 优先尝试使用合并结果.json(包含更完整的数据)
merged_file = '合并结果.json'
if os.path.exists(merged_file):
print(f"📄 使用合并数据文件: {merged_file}")
with open(merged_file, 'r', encoding='utf-8') as f:
all_patients = json.load(f)
# 筛选出属于当前门诊的患者
patients = []
for patient in all_patients:
patient_clinic = patient.get('诊所', '')
patient_clinic_full = patient.get('最后一次就诊诊所', '')
# 根据门诊名称匹配患者
if (clinic_name in patient_clinic_full or
(clinic_id == 'clinic_xuexian' and ('学前街' in patient_clinic_full or '学前街' in patient_clinic)) or
(clinic_id == 'clinic_dongting' and ('东亭' in patient_clinic_full or '东亭' in patient_clinic)) or
(clinic_id == 'clinic_dafeng' and ('大丰' in patient_clinic_full or '大丰' in patient_clinic)) or
(clinic_id == 'clinic_huishan' and ('惠山' in patient_clinic_full or '惠山' in patient_clinic)) or
(clinic_id == 'clinic_helai' and ('河埒' in patient_clinic_full or '河埒' in patient_clinic)) or
(clinic_id == 'clinic_hongdou' and ('红豆' in patient_clinic_full or '红豆' in patient_clinic)) or
(clinic_id == 'clinic_mashan' and ('马山' in patient_clinic_full or '马山' in patient_clinic)) or
(clinic_id == 'clinic_hospital' and ('通善口腔医院' in patient_clinic_full or '通善总院' in patient_clinic)) or
(clinic_id == 'clinic_xinwu' and ('新吴' in patient_clinic_full or '新吴' in patient_clinic))):
patients.append(patient)
else:
# 回退到使用诊所患者json文件
json_file = f'诊所患者json/{clinic_name}.json'
if not os.path.exists(json_file):
print(f"❌ 患者数据文件不存在: {json_file}")
return [], []
with open(json_file, 'r', encoding='utf-8') as f:
patients = json.load(f)
# 提取医生列表
doctors = set()
for patient in patients:
doctor = patient.get('最后一次就诊医生')
if doctor and doctor.strip():
doctors.add(doctor.strip())
print(f"✅ 加载 {clinic_name} 患者数据: {len(patients)} 人, {len(doctors)} 位医生")
return patients, sorted(list(doctors))
except Exception as e:
print(f"❌ 加载 {clinic_id} 患者数据失败: {e}")
return [], []
def load_patient_detail(clinic_id, patient_id):
"""加载指定患者的详细信息"""
try:
# 加载门诊的所有患者数据
patients, _ = load_clinic_patients(clinic_id)
# 查找指定患者
patient = None
for p in patients:
if p.get('病历号') == patient_id:
patient = p
break
if not patient:
print(f"❌ 未找到患者: {patient_id}")
return None
print(f"✅ 加载患者详情: {patient.get('姓名', '未知')} ({patient_id})")
return patient
except Exception as e:
print(f"❌ 加载患者详情失败: {e}")
return None
def load_callback_records(patient_id):
"""加载患者的回访记录"""
try:
if db_manager:
# 从数据库获取
records = db_manager.get_records_by_case_number(patient_id)
return [record.to_dict() for record in records]
else:
# 从临时内存获取
return temp_callback_records.get(patient_id, [])
except Exception as e:
print(f"❌ 加载回访记录失败: {e}")
return []
def load_callback_script(clinic_id, patient_id):
"""加载患者的回访话术"""
try:
# 门诊名称映射
clinic_name_mapping = {
'clinic_xuexian': '学前街门诊',
'clinic_dafeng': '大丰门诊',
'clinic_dongting': '东亭门诊',
'clinic_helai': '河埒门诊',
'clinic_hongdou': '红豆门诊',
'clinic_huishan': '惠山门诊',
'clinic_mashan': '马山门诊',
'clinic_hospital': '通善口腔医院',
'clinic_xinwu': '新吴门诊'
}
clinic_name = clinic_name_mapping.get(clinic_id, '未知门诊')
# 尝试从dify_callback_results目录加载回访话术
import glob
patterns = [
f'dify_callback_results/中间结果_{clinic_name}.json', # 无时间戳的文件
f'dify_callback_results/中间结果_{clinic_name}_*.json', # 有时间戳的文件
f'../dify_callback_results/中间结果_{clinic_name}.json',
f'../dify_callback_results/中间结果_{clinic_name}_*.json',
f'../../dify_callback_results/中间结果_{clinic_name}.json',
f'../../dify_callback_results/中间结果_{clinic_name}_*.json'
]
for pattern in patterns:
files = glob.glob(pattern)
if files:
# 使用最新的文件
latest_file = max(files, key=os.path.getmtime)
print(f"📄 加载回访话术文件: {latest_file}")
with open(latest_file, 'r', encoding='utf-8') as f:
data = json.load(f)
# 获取callbacks列表
callbacks = data.get('callbacks', [])
print(f"📊 话术数据包含 {len(callbacks)} 个患者")
# 查找患者的回访话术 # 注册蓝图
for item in callbacks: from routes import register_blueprints
if item.get('patient_id') == patient_id: register_blueprints(app)
script = item.get('callback_script', '')
print(f"✅ 找到患者 {patient_id} 的回访话术,长度: {len(script)}")
return format_callback_script(script)
print(f"⚠️ 未找到患者 {patient_id} 的回访话术")
break
print(f"❌ 未找到门诊 {clinic_name} 的回访话术文件")
return None
except Exception as e:
print(f"❌ 加载回访话术失败: {e}")
import traceback
traceback.print_exc()
return None
def format_callback_script(script):
"""格式化回访话术为HTML显示"""
if not script:
return None
# 创建数据库表(只在主进程中执行)
with app.app_context():
try: try:
import re # 检查是否是 reloader 进程
if not os.environ.get('WERKZEUG_RUN_MAIN'):
# 移除开头的JSON部分 print("🔄 主进程启动中...")
script = re.sub(r'^```json.*?```\n', '', script, flags=re.DOTALL)
# 分割各个部分
sections = []
current_section = None
current_content = []
lines = script.split('\n')
for line in lines:
line = line.strip()
if not line:
continue
# 检查是否是新的部分标题
if line.startswith('═══') and '部分:' in line:
# 保存上一个部分
if current_section and current_content:
sections.append({
'title': current_section,
'content': current_content.copy()
})
# 开始新的部分
current_section = line.replace('═══', '').strip()
current_content = []
elif line.startswith('•'):
# 添加内容项
current_content.append(line[1:].strip())
elif line.startswith('**') and line.endswith('**'):
# 子标题
current_content.append(f"<strong>{line[2:-2]}</strong>")
elif line and current_section:
# 其他内容
current_content.append(line)
# 保存最后一个部分
if current_section and current_content:
sections.append({
'title': current_section,
'content': current_content.copy()
})
# 生成HTML
if not sections:
return script # 如果解析失败,返回原始内容
html_parts = []
colors = [
('bg-blue-100', 'border-blue-500', 'text-blue-800'),
('bg-yellow-100', 'border-yellow-500', 'text-yellow-800'),
('bg-green-100', 'border-green-500', 'text-green-800'),
('bg-purple-100', 'border-purple-500', 'text-purple-800')
]
for i, section in enumerate(sections):
bg_color, border_color, text_color = colors[i % len(colors)]
content_html = []
for item in section['content']:
if item.startswith('<strong>'):
content_html.append(f'<div class="font-semibold mt-2 mb-1">{item}</div>')
else: else:
content_html.append(f'<div class="mb-1">• {item}</div>') print("🔥 Reloader 进程启动中...")
section_html = f'''
<div class="mb-6 p-4 {bg_color} border-l-4 {border_color} rounded-lg">
<h4 class="font-bold {text_color} mb-3 flex items-center">
<i class="fas fa-comment mr-2"></i>{section['title']}
</h4>
<div class="text-gray-700 leading-relaxed">
{''.join(content_html)}
</div>
</div>'''
html_parts.append(section_html)
return ''.join(html_parts)
except Exception as e:
print(f"❌ 格式化回访话术失败: {e}")
return script # 返回原始内容
def generate_callback_script(patient):
"""生成患者的回访话术(保持向后兼容)"""
try:
# 这个函数保持向后兼容,实际加载由load_callback_script处理
return None
except Exception as e:
print(f"❌ 生成回访话术失败: {e}")
return None
class AuthSystem:
"""用户认证系统"""
def __init__(self):
self.users = {user['username']: user for user in DEFAULT_USERS}
self.sessions = {} # 存储活跃session
self.session_timeout = 8 * 60 * 60 # 8小时超时
def hash_password(self, password):
"""密码哈希"""
return hashlib.sha256(password.encode()).hexdigest()
def verify_password(self, username, password):
"""验证密码"""
user = self.users.get(username)
if not user:
return False
# 简单密码验证(生产环境应使用bcrypt等)
return user['password'] == password
def create_session(self, username):
"""创建用户session"""
session_id = secrets.token_urlsafe(32)
user = self.users[username]
session_data = {
'session_id': session_id,
'username': username,
'real_name': user['real_name'],
'role': user['role'],
'clinic_id': user['clinic_id'],
'clinic_name': user.get('clinic_name', '总部'),
'created_at': datetime.now().isoformat(),
'expires_at': (datetime.now() + timedelta(seconds=self.session_timeout)).isoformat()
}
self.sessions[session_id] = session_data
return session_data
def validate_session(self, session_id):
"""验证session有效性"""
if not session_id or session_id not in self.sessions:
return None
session_data = self.sessions[session_id]
expires_at = datetime.fromisoformat(session_data['expires_at'])
if datetime.now() > expires_at:
# Session过期,清理
del self.sessions[session_id]
return None
# 延长session有效期
session_data['expires_at'] = (datetime.now() + timedelta(seconds=self.session_timeout)).isoformat()
return session_data
def logout(self, session_id):
"""用户登出"""
if session_id in self.sessions:
del self.sessions[session_id]
return True
return False
def check_clinic_access(self, session_data, clinic_id):
"""检查门诊访问权限"""
if not session_data:
return False
# 管理员可以访问所有门诊
if session_data['role'] == 'admin':
return True
# 门诊用户只能访问自己的门诊
if session_data['role'] == 'clinic_user':
return session_data['clinic_id'] == clinic_id
return False
def get_accessible_clinics(self, session_data):
"""获取用户可访问的门诊列表"""
if not session_data:
return []
if session_data['role'] == 'admin':
# 管理员可以访问所有门诊
return [
'clinic_xuexian', 'clinic_dafeng', 'clinic_dongting',
'clinic_helai', 'clinic_hongdou', 'clinic_huishan',
'clinic_mashan', 'clinic_hospital', 'clinic_xinwu'
]
elif session_data['role'] == 'clinic_user':
# 门诊用户只能访问自己的门诊
return [session_data['clinic_id']]
return []
# 创建Flask应用 # 创建所有表
app = Flask(__name__) db.create_all()
app.secret_key = os.getenv('SECRET_KEY', 'dev_secret_key_2024')
# 配置CORS,允许跨域请求 # 插入默认数据(避免重复插入)
CORS(app, supports_credentials=True, origins=['http://localhost:5001', 'http://127.0.0.1:5001']) insert_default_data()
# 初始化认证系统 print("✅ 数据库初始化完成")
auth_system = AuthSystem()
# 初始化回访记录管理器
db_manager = None
# 临时存储(当数据库不可用时使用)
temp_callback_records = {}
# 内存存储最新的回访结果,用于状态API
callback_results_cache = {}
if CALLBACK_AVAILABLE:
try:
# 直接使用环境变量构建数据库配置
db_host = os.getenv('DB_HOST', 'localhost') # 本地开发默认使用localhost
db_port = int(os.getenv('DB_PORT', '3306')) # 本地开发使用Docker映射的端口3306
db_user = os.getenv('DB_USER', 'callback_user') # 使用Docker创建的用户
db_password = os.getenv('DB_PASSWORD', 'dev_password_123') # 使用Docker的callback_user密码
db_name = os.getenv('DB_NAME', 'callback_system')
print(f"尝试连接数据库: {db_host}:{db_port}")
db_manager = MySQLCallbackRecordManager(
host=db_host,
port=db_port,
user=db_user,
password=db_password,
database=db_name,
charset='utf8mb4'
)
print("✅ 回访记录数据库管理器初始化成功")
except Exception as e: except Exception as e:
print(f"⚠️ 回访记录数据库管理器初始化失败: {e}") print(f"⚠️ 数据库初始化失败: {e}")
print("将使用临时内存存储")
db_manager = None
# 页面模板现在使用独立的模板文件
# - templates/login.html: 登录页面模板
# - templates/admin_dashboard.html: 管理员仪表板模板
# - templates/clinic_dashboard.html: 门诊用户仪表板模板
# - templates/patient_list.html: 患者列表页面模板
# - templates/patient_detail.html: 患者详情页面模板
@app.route('/api/health', methods=['GET']) return app
def health_check():
"""健康检查端点"""
return jsonify({
'status': 'ok',
'timestamp': datetime.now().isoformat(),
'service': 'auth_system',
'version': '1.0.0'
})
@app.route('/api/callback-records', methods=['POST']) def insert_default_data():
def save_callback_record(): """插入默认数据"""
"""保存回访记录API"""
try: try:
data = request.get_json() from database.models import Clinic
from config import CLINIC_CONFIG
# 验证必需字段
required_fields = ['caseNumber', 'callbackMethods']
for field in required_fields:
if field not in data or not data[field]:
return jsonify({
'success': False,
'message': f'缺少必需字段: {field}'
}), 400
# 从session获取当前登录用户信息
session_id = session.get('session_id')
current_user = '系统用户' # 默认值
if session_id:
session_data = auth_system.validate_session(session_id)
if session_data:
# 优先使用真实姓名,如果没有则使用用户名
current_user = session_data.get('real_name') or session_data.get('username', '系统用户')
print(f"当前登录用户: {current_user} (session: {session_data})")
else:
print("Session验证失败,使用默认操作员")
else:
print("未找到session_id,使用默认操作员")
# 构建回访记录内容
result = data.get('callbackResult', '成功') # 修复重复的字段名
methods = ', '.join(data.get('callbackMethods', []))
callback_record = f"回访方式: {methods}, 回访结果: {result}"
if result == '成功' and data.get('nextAppointmentTime'): # 检查是否已有数据,避免重复插入
callback_record += f", 下次预约时间: {data['nextAppointmentTime']}" existing_count = Clinic.query.count()
elif result == '不成功' and data.get('failureReason'): if existing_count > 0:
callback_record += f", 失败原因: {data['failureReason']}" print(f"📊 数据库已有 {existing_count} 个门诊,跳过默认数据插入")
elif result == '放弃回访' and data.get('abandonReason'): return
callback_record += f", 放弃原因: {data['abandonReason']}"
# 添加AI反馈 # 插入默认门诊数据
if data.get('aiFeedbackType'): for clinic_id, clinic_data in CLINIC_CONFIG.items():
callback_record += f", AI反馈: {data['aiFeedbackType']}" clinic = Clinic(
clinic_name=clinic_data['clinic_name'],
# 添加备注信息 clinic_code=clinic_id,
if result == '不成功' and data.get('failureReasonNote'): is_active=True
callback_record += f", 不成功备注: {data['failureReasonNote']}"
elif result == '放弃回访' and data.get('abandonReasonNote'):
callback_record += f", 放弃回访备注: {data['abandonReasonNote']}"
# 添加AI反馈备注
if data.get('aiFeedbackNote'):
callback_record += f", AI反馈备注: {data['aiFeedbackNote']}"
# 在记录末尾添加原始结果标识,用于解析
callback_record += f" | ORIGINAL_RESULT: {result}"
if db_manager:
# 使用数据库存储
record = CallbackRecord(
case_number=data['caseNumber'],
callback_methods=data['callbackMethods'],
callback_record=callback_record, # 使用构建好的回访记录内容
callback_result=result, # 传递回访结果
next_appointment_time=data.get('nextAppointmentTime'),
failure_reason=data.get('failureReason'),
abandon_reason=data.get('abandonReason'),
ai_feedback_type=data.get('aiFeedbackType'),
failure_reason_note=data.get('failureReasonNote'),
abandon_reason_note=data.get('abandonReasonNote'),
ai_feedback_note=data.get('aiFeedbackNote'),
callback_status=data.get('callbackStatus', '已回访'),
operator=current_user # 使用从session获取的真实用户名
) )
record_id = db_manager.save_record(record) db.session.add(clinic)
# 更新内存缓存
callback_results_cache[data['caseNumber']] = {
'callback_result': result,
'create_time': datetime.now()
}
else:
# 使用临时内存存储
case_number = data['caseNumber']
if case_number not in temp_callback_records:
temp_callback_records[case_number] = []
record_id = len(temp_callback_records[case_number]) + 1 db.session.commit()
temp_callback_records[case_number].append({ print("✅ 默认数据插入完成")
'id': record_id,
'case_number': case_number,
'callback_methods': data['callbackMethods'],
'callback_record': callback_record,
'operator': current_user, # 使用从session获取的真实用户名
'create_time': datetime.now().isoformat()
})
return jsonify({
'success': True,
'id': record_id,
'message': '保存成功',
'timestamp': datetime.now().isoformat(),
'storage': 'database' if db_manager else 'memory',
'operator': current_user # 返回实际使用的操作员信息
})
except Exception as e: except Exception as e:
print(f"保存回访记录失败: {e}") db.session.rollback()
return jsonify({ print(f"⚠️ 默认数据插入失败: {e}")
'success': False,
'message': f'保存失败: {str(e)}'
}), 500
@app.route('/api/callback-records/<case_number>', methods=['GET'])
def get_callback_records(case_number):
"""获取回访记录API"""
try:
if db_manager:
# 从数据库获取
records = db_manager.get_records_by_case_number(case_number)
record_data = [record.to_dict() for record in records]
else:
# 从临时内存获取
record_data = temp_callback_records.get(case_number, [])
return jsonify({
'success': True,
'data': record_data,
'count': len(record_data),
'timestamp': datetime.now().isoformat(),
'storage': 'database' if db_manager else 'memory'
})
except Exception as e: # 创建应用实例
print(f"获取回访记录失败: {e}") app = create_app()
return jsonify({
'success': False,
'message': f'获取失败: {str(e)}'
}), 500
@app.route('/api/callback-status/<clinic_id>', methods=['GET'])
def get_clinic_callback_status(clinic_id):
"""获取指定门诊所有患者的回访状态"""
try:
if not db_manager:
return jsonify({'success': False, 'message': '数据库不可用'}), 500
# 获取该门诊所有患者的回访状态
all_records = db_manager.get_all_records()
# 按病历号分组,获取最新的回访状态
status_map = {}
for record in all_records:
case_number = record.case_number
# 如果该病历号还没有记录,或者当前记录更新,则更新状态
if case_number not in status_map:
status_map[case_number] = {
'case_number': case_number,
'callback_status': '已回访',
'callback_result': record.callback_result, # 直接使用存储的结果
'create_time': record.create_time.isoformat() if record.create_time else None
}
else:
# 比较时间,保留最新的记录
current_time = record.create_time
existing_time = status_map[case_number]['create_time']
if current_time and existing_time:
try:
from datetime import datetime
if isinstance(existing_time, str):
existing_time = datetime.fromisoformat(existing_time.replace('Z', '+00:00'))
if current_time > existing_time:
status_map[case_number] = {
'case_number': case_number,
'callback_status': '已回访',
'callback_result': record.callback_result, # 直接使用存储的结果
'create_time': current_time.isoformat() if current_time else None
}
except Exception as e:
print(f"时间比较错误: {e}")
# 转换为以病历号为键的字典格式,方便前端查找
status_dict = {}
for status in status_map.values():
status_dict[status['case_number']] = status
return jsonify({
'success': True,
'data': status_dict
})
except Exception as e:
print(f"获取回访状态失败: {e}")
return jsonify({'success': False, 'message': f'获取失败: {str(e)}'}), 500
@app.route('/')
def index():
"""主页 - 重定向到登录页面"""
return redirect(url_for('login'))
@app.route('/admin/dashboard')
def admin_dashboard():
"""管理员仪表板 - 门诊索引页"""
session_id = session.get('session_id')
if not session_id:
return redirect('/login')
session_data = auth_system.validate_session(session_id)
if not session_data:
session.pop('session_id', None)
return redirect('/login')
if session_data['role'] != 'admin':
return redirect('/login')
return render_template('admin_dashboard.html', session_data=session_data)
@app.route('/clinic/<clinic_id>')
def clinic_dashboard(clinic_id):
"""门诊用户仪表板"""
session_id = session.get('session_id')
if not session_id:
return redirect('/login')
session_data = auth_system.validate_session(session_id)
if not session_data:
session.pop('session_id', None)
return redirect('/login')
# 检查用户是否有权限访问此门诊
if not auth_system.check_clinic_access(session_data, clinic_id):
return "无权限访问此门诊", 403
# 获取门诊信息
clinic_info = get_clinic_info(clinic_id)
clinic_name = clinic_info.get('clinic_name', '未知门诊') if clinic_info else '未知门诊'
return render_template('clinic_dashboard.html',
session_data=session_data,
clinic_id=clinic_id,
clinic_name=clinic_name)
@app.route('/patient_profiles/<clinic_id>/index.html')
def patient_list(clinic_id):
"""患者列表页面"""
session_id = session.get('session_id')
if not session_id:
return redirect('/login')
session_data = auth_system.validate_session(session_id)
if not session_data:
session.pop('session_id', None)
return redirect('/login')
# 检查用户是否有权限访问此门诊
if not auth_system.check_clinic_access(session_data, clinic_id):
return "无权限访问此门诊", 403
# 获取门诊信息
clinic_info = get_clinic_info(clinic_id)
clinic_name = clinic_info.get('clinic_name', '未知门诊') if clinic_info else '未知门诊'
# 加载患者数据
patients, doctors = load_clinic_patients(clinic_id)
return render_template('patient_list.html',
session_data=session_data,
clinic_id=clinic_id,
clinic_name=clinic_name,
patients=patients,
doctors=doctors)
@app.route('/patient_profiles/<clinic_id>/patients/<patient_id>.html')
def patient_detail(clinic_id, patient_id):
"""患者详情页面"""
session_id = session.get('session_id')
if not session_id:
return redirect('/login')
session_data = auth_system.validate_session(session_id)
if not session_data:
session.pop('session_id', None)
return redirect('/login')
# 检查用户是否有权限访问此门诊
if not auth_system.check_clinic_access(session_data, clinic_id):
return "无权限访问此门诊", 403
# 获取门诊信息
clinic_info = get_clinic_info(clinic_id)
clinic_name = clinic_info.get('clinic_name', '未知门诊') if clinic_info else '未知门诊'
# 加载患者详细信息
patient = load_patient_detail(clinic_id, patient_id)
if not patient:
return "患者不存在", 404
# 加载回访记录
callback_records = load_callback_records(patient_id)
# 加载回访话术
callback_script = load_callback_script(clinic_id, patient_id)
print(f"🎯 患者详情页面 - callback_script类型: {type(callback_script)}")
if callback_script:
print(f"🎯 患者详情页面 - callback_script长度: {len(callback_script)}")
print(f"🎯 患者详情页面 - callback_script前100字符: {callback_script[:100]}")
else:
print(f"🎯 患者详情页面 - callback_script为空")
return render_template('patient_detail.html',
session_data=session_data,
clinic_id=clinic_id,
clinic_name=clinic_name,
patient=patient,
callback_records=callback_records,
callback_script=callback_script)
@app.route('/login', methods=['GET', 'POST'])
def login():
"""登录页面"""
if request.method == 'POST':
username = request.form.get('username')
password = request.form.get('password')
if auth_system.verify_password(username, password):
# 登录成功,创建session
session_data = auth_system.create_session(username)
session['session_id'] = session_data['session_id']
# 根据用户角色重定向
if session_data['role'] == 'admin':
# 管理员重定向到门诊索引页
return redirect('/admin/dashboard')
else:
# 普通用户重定向到患者索引页
return redirect(f'/patient_profiles/{session_data["clinic_id"]}/index.html')
else:
return render_template('login.html', error='用户名或密码错误')
return render_template('login.html')
@app.route('/api/login', methods=['POST'])
def handle_login_api():
"""API登录接口"""
try:
data = request.get_json()
username = data.get('username')
password = data.get('password')
if not username or not password:
return jsonify({'success': False, 'message': '用户名和密码不能为空'}), 400
if auth_system.verify_password(username, password):
# 登录成功
session_data = auth_system.create_session(username)
return jsonify({
'success': True,
'message': '登录成功',
'user': {
'username': session_data['username'],
'real_name': session_data['real_name'],
'role': session_data['role'],
'clinic_id': session_data['clinic_id'],
'clinic_name': session_data['clinic_name']
},
'session_id': session_data['session_id']
})
else:
return jsonify({'success': False, 'message': '用户名或密码错误'}), 401
except Exception as e:
return jsonify({'success': False, 'message': f'登录失败: {str(e)}'}), 500
@app.route('/api/user-info', methods=['GET', 'POST'])
def get_user_info():
"""获取用户信息"""
session_id = session.get('session_id')
# 如果是POST请求,也尝试从请求体中获取session_id
if request.method == 'POST':
try:
data = request.get_json()
if data and 'session_id' in data:
# 这里我们仍然使用Flask session中的session_id,忽略POST中的session_id
# 这是为了安全考虑
pass
except:
pass
if not session_id:
return jsonify({'success': False, 'authenticated': False, 'message': '未登录'})
session_data = auth_system.validate_session(session_id)
if not session_data:
session.pop('session_id', None)
return jsonify({'success': False, 'authenticated': False, 'message': 'Session已过期'})
return jsonify({
'success': True,
'authenticated': True,
'user': {
'username': session_data['username'],
'realName': session_data['real_name'],
'role': session_data['role'],
'clinic_id': session_data['clinic_id'],
'clinicName': session_data['clinic_name']
}
})
@app.route('/api/check-access/<clinic_id>')
def check_access(clinic_id):
"""检查门诊访问权限"""
session_id = session.get('session_id')
if not session_id:
return jsonify({'has_access': False, 'message': '未登录'})
session_data = auth_system.validate_session(session_id)
if not session_data:
session.pop('session_id', None)
return jsonify({'has_access': False, 'message': 'Session已过期'})
# 检查用户是否有权限访问指定门诊
has_access = auth_system.check_clinic_access(session_data, clinic_id)
return jsonify({
'has_access': has_access,
'message': '访问权限检查完成'
})
@app.route('/api/auth/logout', methods=['POST'])
def api_logout():
"""API登出"""
session_id = session.get('session_id')
if session_id:
auth_system.logout(session_id)
session.pop('session_id', None)
return jsonify({'success': True, 'message': '登出成功'})
@app.route('/api/export-data', methods=['GET'])
def export_data():
"""导出所有诊所的回访记录数据到Excel"""
try:
# 检查用户权限
session_id = session.get('session_id')
if not session_id:
return jsonify({'success': False, 'message': '未登录'}), 401
session_data = auth_system.validate_session(session_id)
if not session_data:
session.pop('session_id', None)
return jsonify({'success': False, 'message': 'Session已过期'}), 401
# 只有管理员可以导出所有数据
if session_data['role'] != 'admin':
return jsonify({'success': False, 'message': '权限不足,只有管理员可以导出数据'}), 403
# 导入导出模块
try:
from export_data import DataExporter
except ImportError:
return jsonify({'success': False, 'message': '导出模块未找到'}), 500
# 生成Excel文件
exporter = DataExporter()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_filename = f"回访记录导出_{timestamp}.xlsx"
# 在Docker容器中,使用/app目录
if os.path.exists('/app'):
output_path = os.path.join('/app', output_filename)
else:
output_path = os.path.join(os.getcwd(), output_filename)
print(f"导出路径: {output_path}")
print(f"当前工作目录: {os.getcwd()}")
# 导出数据
try:
exporter.export_to_excel(output_path)
print(f"导出完成,文件路径: {output_path}")
except Exception as export_error:
print(f"导出过程中出错: {export_error}")
import traceback
traceback.print_exc()
return jsonify({'success': False, 'message': f'导出过程出错: {str(export_error)}'}), 500
# 检查文件是否生成成功
if not os.path.exists(output_path):
print(f"文件不存在: {output_path}")
# 列出目录内容
try:
dir_content = os.listdir(os.path.dirname(output_path))
print(f"目录内容: {dir_content}")
except Exception as list_error:
print(f"无法列出目录内容: {list_error}")
return jsonify({'success': False, 'message': '文件生成失败'}), 500
# 检查文件大小
file_size = os.path.getsize(output_path)
print(f"生成的文件大小: {file_size} 字节")
# 返回文件下载链接
return jsonify({
'success': True,
'message': '数据导出成功',
'filename': output_filename,
'download_url': f'/download/{output_filename}'
})
except Exception as e:
print(f"导出数据失败: {e}")
return jsonify({'success': False, 'message': f'导出失败: {str(e)}'}), 500
@app.route('/download/<filename>')
def download_file(filename):
"""下载导出的文件"""
try:
# 检查用户权限
session_id = session.get('session_id')
if not session_id:
return jsonify({'success': False, 'message': '未登录'}), 401
session_data = auth_system.validate_session(session_id)
if not session_data:
session.pop('session_id', None)
return jsonify({'success': False, 'message': 'Session已过期'}), 401
# 只有管理员可以下载文件
if session_data['role'] != 'admin':
return jsonify({'success': False, 'message': '权限不足'}), 403
# 检查文件路径安全性
if '..' in filename or filename.startswith('/'):
return "非法文件路径", 400
# 构建文件路径
file_path = os.path.join(os.getcwd(), filename)
if not os.path.exists(file_path):
return "文件不存在", 404
# 发送文件
return send_file(file_path, as_attachment=True, download_name=filename)
except Exception as e:
return f"文件下载错误: {str(e)}", 500
# 注意:原来的 serve_patient_profiles 路由已被移除
# 现在所有患者相关页面都通过专门的动态路由处理:
# - /patient_profiles/<clinic_id>/index.html → patient_list 函数
# - /patient_profiles/<clinic_id>/patients/<patient_id>.html → patient_detail 函数
if __name__ == '__main__': if __name__ == '__main__':
print("🚀 启动患者画像系统认证服务器...")
print("📋 系统信息:")
print(f" - 用户数量: {len(DEFAULT_USERS)}")
print(f" - 门诊数量: {len(set(user.get('clinic_id') for user in DEFAULT_USERS if user.get('clinic_id') != 'admin'))}")
print(f" - Session超时: {auth_system.session_timeout // 3600}小时")
try:
# 本地开发使用5001端口,Docker使用5000端口
port = int(os.getenv('PORT', 5001))
# 检测是否为开发环境
is_development = os.getenv('FLASK_ENV') == 'development' or os.getenv('FLASK_DEBUG', '').lower() in ['1', 'true']
print(f"🌐 服务器将在端口 {port} 启动")
print(f"🔧 开发模式: {'开启' if is_development else '关闭'}")
if is_development:
print("🔥 热重载已启用")
app.run( app.run(
host='0.0.0.0', host='0.0.0.0',
port=port, port=5001,
debug=is_development, debug=True,
use_reloader=is_development use_reloader=True,
threaded=True
) )
except Exception as e:
print(f"❌ 启动服务器失败: {e}")
import traceback
traceback.print_exc()
\ No newline at end of file
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
通过Web API备份数据库
无需SSH权限,通过应用程序接口备份数据
"""
import os
import sys
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from auth_system import app
from flask import jsonify, request, send_file
import pymysql
import json
from datetime import datetime
import tempfile
import zipfile
# 数据库配置
DB_CONFIG = {
'host': 'localhost',
'port': 3306,
'user': 'callback_user',
'password': 'dev_password_123',
'database': 'callback_system',
'charset': 'utf8mb4'
}
def backup_database():
"""备份数据库到临时文件"""
try:
connection = pymysql.connect(**DB_CONFIG)
cursor = connection.cursor()
# 创建临时文件
temp_file = tempfile.NamedTemporaryFile(mode='w', suffix='.sql', delete=False, encoding='utf-8')
temp_file_path = temp_file.name
# 获取所有表名
cursor.execute("SHOW TABLES")
tables = [table[0] for table in cursor.fetchall()]
print(f"📋 开始备份 {len(tables)} 个表...")
# 写入备份头部信息
temp_file.write(f"-- 患者画像回访话术系统数据库备份\n")
temp_file.write(f"-- 备份时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
temp_file.write(f"-- 数据库: {DB_CONFIG['database']}\n\n")
# 备份每个表
for table_name in tables:
print(f" 📊 备份表: {table_name}")
# 获取表结构
cursor.execute(f"SHOW CREATE TABLE `{table_name}`")
create_table_sql = cursor.fetchone()[1]
temp_file.write(f"\n-- 表结构: {table_name}\n")
temp_file.write(f"DROP TABLE IF EXISTS `{table_name}`;\n")
temp_file.write(f"{create_table_sql};\n\n")
# 获取表数据
cursor.execute(f"SELECT COUNT(*) FROM `{table_name}`")
row_count = cursor.fetchone()[0]
if row_count > 0:
temp_file.write(f"-- 表数据: {table_name} ({row_count} 行)\n")
# 分批获取数据避免内存问题
batch_size = 1000
offset = 0
while offset < row_count:
cursor.execute(f"SELECT * FROM `{table_name}` LIMIT {batch_size} OFFSET {offset}")
rows = cursor.fetchall()
for row in rows:
# 构建INSERT语句
values = []
for value in row:
if value is None:
values.append('NULL')
elif isinstance(value, (int, float)):
values.append(str(value))
else:
# 转义字符串
escaped_value = str(value).replace("'", "''").replace("\\", "\\\\")
values.append(f"'{escaped_value}'")
temp_file.write(f"INSERT INTO `{table_name}` VALUES ({', '.join(values)});\n")
offset += batch_size
print(f" ✅ 已备份 {min(offset, row_count)}/{row_count} 行")
temp_file.write("\n")
temp_file.close()
connection.close()
print(f"✅ 数据库备份完成: {temp_file_path}")
return temp_file_path
except Exception as e:
print(f"❌ 数据库备份失败: {e}")
if 'connection' in locals():
connection.close()
return None
def create_backup_zip(backup_file_path):
"""创建备份压缩包"""
try:
# 创建临时压缩文件
zip_file = tempfile.NamedTemporaryFile(mode='wb', suffix='.zip', delete=False)
zip_file_path = zip_file.name
zip_file.close()
# 压缩备份文件
with zipfile.ZipFile(zip_file_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
# 添加备份文件
backup_filename = os.path.basename(backup_file_path)
zipf.write(backup_file_path, backup_filename)
# 添加备份信息
info = {
'backup_time': datetime.now().isoformat(),
'database': DB_CONFIG['database'],
'tables_count': len([f for f in os.listdir('.') if f.endswith('.sql')]),
'backup_size': os.path.getsize(backup_file_path)
}
zipf.writestr('backup_info.json', json.dumps(info, ensure_ascii=False, indent=2))
print(f"✅ 备份压缩包创建完成: {zip_file_path}")
return zip_file_path
except Exception as e:
print(f"❌ 创建压缩包失败: {e}")
return None
# 添加备份API路由到Flask应用
@app.route('/api/backup/database', methods=['POST'])
def api_backup_database():
"""API接口:备份数据库"""
try:
print("🚀 收到数据库备份请求...")
# 执行备份
backup_file_path = backup_database()
if not backup_file_path:
return jsonify({'error': '数据库备份失败'}), 500
# 创建压缩包
zip_file_path = create_backup_zip(backup_file_path)
if not zip_file_path:
return jsonify({'error': '创建压缩包失败'}), 500
# 清理临时文件
os.unlink(backup_file_path)
# 返回下载链接
backup_filename = f"database_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
return jsonify({
'success': True,
'message': '数据库备份成功',
'backup_file': backup_filename,
'download_url': f'/api/backup/download/{backup_filename}',
'backup_time': datetime.now().isoformat()
})
except Exception as e:
print(f"❌ API备份失败: {e}")
return jsonify({'error': f'备份失败: {str(e)}'}), 500
@app.route('/api/backup/download/<filename>')
def api_download_backup(filename):
"""API接口:下载备份文件"""
try:
# 查找最新的备份文件
backup_dir = tempfile.gettempdir()
backup_files = [f for f in os.listdir(backup_dir) if f.endswith('.zip') and 'database_backup' in f]
if not backup_files:
return jsonify({'error': '未找到备份文件'}), 404
# 使用最新的备份文件
latest_backup = max(backup_files, key=lambda f: os.path.getctime(os.path.join(backup_dir, f)))
backup_path = os.path.join(backup_dir, latest_backup)
return send_file(
backup_path,
as_attachment=True,
download_name=filename,
mimetype='application/zip'
)
except Exception as e:
print(f"❌ 下载备份文件失败: {e}")
return jsonify({'error': f'下载失败: {str(e)}'}), 500
@app.route('/api/backup/status')
def api_backup_status():
"""API接口:获取备份状态"""
try:
backup_dir = tempfile.gettempdir()
backup_files = [f for f in os.listdir(backup_dir) if f.endswith('.zip') and 'database_backup' in f]
backups = []
for backup_file in backup_files:
backup_path = os.path.join(backup_dir, backup_file)
backup_info = {
'filename': backup_file,
'size': os.path.getsize(backup_path),
'created_time': datetime.fromtimestamp(os.path.getctime(backup_path)).isoformat(),
'download_url': f'/api/backup/download/{backup_file}'
}
backups.append(backup_info)
# 按创建时间排序
backups.sort(key=lambda x: x['created_time'], reverse=True)
return jsonify({
'success': True,
'backups': backups,
'total_count': len(backups)
})
except Exception as e:
print(f"❌ 获取备份状态失败: {e}")
return jsonify({'error': f'获取状态失败: {str(e)}'}), 500
if __name__ == "__main__":
print("🔧 数据库备份API已添加到Flask应用")
print("📋 可用的API接口:")
print(" POST /api/backup/database - 创建数据库备份")
print(" GET /api/backup/status - 获取备份状态")
print(" GET /api/backup/download/* - 下载备份文件")
\ No newline at end of file
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
回访记录API服务器
提供MySQL数据库的HTTP API接口
"""
import os
import sys
from datetime import datetime
from flask import Flask, request, jsonify, send_from_directory
from flask_cors import CORS
# 添加当前目录到Python路径
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
try:
from callback_record_mysql import MySQLCallbackRecordManager, CallbackRecord
from database_config import DatabaseConfig
DEPENDENCIES_AVAILABLE = True
except ImportError as e:
DEPENDENCIES_AVAILABLE = False
IMPORT_ERROR = str(e)
app = Flask(__name__)
CORS(app) # 允许跨域请求
# 全局数据库管理器
db_manager = None
def initialize_database():
"""初始化数据库连接"""
global db_manager
if not DEPENDENCIES_AVAILABLE:
print(f"依赖库未安装: {IMPORT_ERROR}")
print("请运行: pip install pymysql flask flask-cors")
return False
try:
# 加载数据库配置
config_manager = DatabaseConfig()
if not config_manager.validate_config():
print("数据库配置无效,请运行 python database_config.py 进行配置")
return False
mysql_config = config_manager.get_mysql_config()
# 创建数据库管理器
db_manager = MySQLCallbackRecordManager(**mysql_config)
# 测试连接
if db_manager.test_connection():
print("✓ MySQL数据库连接成功")
return True
else:
print("✗ MySQL数据库连接失败")
return False
except Exception as e:
print(f"数据库初始化失败: {e}")
return False
@app.route('/api/callback-records', methods=['POST'])
def save_callback_record():
"""保存回访记录API"""
if not db_manager:
return jsonify({
'success': False,
'message': '数据库未初始化'
}), 500
try:
data = request.get_json()
# 验证必需字段
required_fields = ['caseNumber', 'callbackMethods', 'callbackRecord', 'operator']
for field in required_fields:
if field not in data or not data[field]:
return jsonify({
'success': False,
'message': f'缺少必需字段: {field}'
}), 400
# 创建记录对象
record = CallbackRecord(
case_number=data['caseNumber'],
callback_methods=data['callbackMethods'],
callback_record=data['callbackRecord'],
operator=data['operator']
)
# 保存到数据库
record_id = db_manager.save_record(record)
return jsonify({
'success': True,
'id': record_id,
'message': '保存成功',
'timestamp': datetime.now().isoformat()
})
except Exception as e:
print(f"保存回访记录失败: {e}")
return jsonify({
'success': False,
'message': f'保存失败: {str(e)}'
}), 500
@app.route('/api/callback-records/<case_number>', methods=['GET'])
def get_callback_records(case_number):
"""获取回访记录API"""
if not db_manager:
return jsonify({
'success': False,
'message': '数据库未初始化'
}), 500
try:
records = db_manager.get_records_by_case_number(case_number)
return jsonify({
'success': True,
'data': [record.to_dict() for record in records],
'count': len(records)
})
except Exception as e:
print(f"获取回访记录失败: {e}")
return jsonify({
'success': False,
'message': f'获取失败: {str(e)}'
}), 500
@app.route('/api/callback-records/statistics', methods=['GET'])
def get_statistics():
"""获取统计信息API"""
if not db_manager:
return jsonify({
'success': False,
'message': '数据库未初始化'
}), 500
try:
stats = db_manager.get_statistics()
return jsonify({
'success': True,
'data': stats
})
except Exception as e:
print(f"获取统计信息失败: {e}")
return jsonify({
'success': False,
'message': f'获取失败: {str(e)}'
}), 500
@app.route('/api/callback-records/<int:record_id>', methods=['DELETE'])
def delete_callback_record(record_id):
"""删除回访记录API"""
if not db_manager:
return jsonify({
'success': False,
'message': '数据库未初始化'
}), 500
try:
success = db_manager.delete_record(record_id)
if success:
return jsonify({
'success': True,
'message': '删除成功'
})
else:
return jsonify({
'success': False,
'message': '记录不存在'
}), 404
except Exception as e:
print(f"删除回访记录失败: {e}")
return jsonify({
'success': False,
'message': f'删除失败: {str(e)}'
}), 500
@app.route('/api/health', methods=['GET'])
def health_check():
"""健康检查API"""
status = {
'status': 'ok',
'timestamp': datetime.now().isoformat(),
'database': 'disconnected'
}
if db_manager and db_manager.test_connection():
status['database'] = 'connected'
return jsonify(status)
@app.route('/')
def index():
"""首页 - 显示API文档"""
return """
<h1>回访记录API服务器</h1>
<h2>可用接口:</h2>
<ul>
<li><strong>POST</strong> /api/callback-records - 保存回访记录</li>
<li><strong>GET</strong> /api/callback-records/&lt;case_number&gt; - 获取指定病历号的回访记录</li>
<li><strong>GET</strong> /api/callback-records/statistics - 获取统计信息</li>
<li><strong>DELETE</strong> /api/callback-records/&lt;record_id&gt; - 删除回访记录</li>
<li><strong>GET</strong> /api/health - 健康检查</li>
</ul>
<h2>使用说明:</h2>
<ol>
<li>确保MySQL数据库正在运行</li>
<li>配置数据库连接信息(运行 python database_config.py)</li>
<li>启动API服务器(运行 python callback_api_server.py)</li>
<li>在患者画像页面中使用回访记录功能</li>
</ol>
"""
# 静态文件服务 - 为患者画像页面提供服务
@app.route('/patient_profiles/<path:filename>')
def patient_profiles(filename):
"""提供患者画像页面文件"""
return send_from_directory('patient_profiles', filename)
@app.route('/callback_record.js')
def callback_js():
"""提供JavaScript文件"""
return send_from_directory('.', 'callback_record.js')
def main():
"""主函数"""
print("=== 回访记录API服务器 ===")
# 检查依赖
if not DEPENDENCIES_AVAILABLE:
print(f"错误: {IMPORT_ERROR}")
print("\n请安装所需依赖:")
print("pip install pymysql flask flask-cors")
return
# 初始化数据库
if not initialize_database():
print("数据库初始化失败,服务器无法启动")
print("\n请检查:")
print("1. MySQL服务是否正在运行")
print("2. 数据库配置是否正确(运行 python database_config.py 检查)")
print("3. 数据库用户是否有足够权限")
return
print("\n服务器启动中...")
print("API地址: http://localhost:5000/api/")
print("患者画像: http://localhost:5000/patient_profiles/")
print("按 Ctrl+C 停止服务器")
try:
# 启动Flask服务器
app.run(
host='0.0.0.0', # 允许外部访问
port=5000,
debug=True, # 开发模式
threaded=True # 多线程支持
)
except KeyboardInterrupt:
print("\n服务器已停止")
except Exception as e:
print(f"服务器启动失败: {e}")
if __name__ == "__main__":
main()
\ No newline at end of file
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
回访记录相关常量定义
包含回访不成功原因和AI反馈类型
"""
# 回访不成功的原因(简化版,控制在10个以内)
CALLBACK_FAILURE_REASONS = [
# 联系不上类(合并)
"患者电话无人接听/关机/停机",
"患者联系方式有误或已变更",
"患者微信/短信无回复",
# 拒绝回访类(合并)
"患者拒绝接受回访或表示不需要",
"患者对诊疗结果不满意",
# 时间不合适(合并)
"患者时间不便(工作/休息/外出)",
# 健康和治疗状况(合并)
"患者身体不适或已在其他医院治疗",
# 经济原因(合并)
"患者经济条件不允许或费用问题",
# 信任度问题(合并)
"患者对诊断结果有疑虑或不信任",
# 其他原因
"其他特殊情况"
]
# 放弃回访的原因(新增)
ABANDON_CALLBACK_REASONS = [
"有下次预约",
"漏诊项不存在",
"漏诊项已治疗",
"其他"
]
# AI错误反馈类型(简化版,控制在10个以内)
AI_FEEDBACK_TYPES = [
# 漏诊检测问题(合并)
"AI漏诊检测错误(检测不准确或遗漏)",
# 话术内容问题(合并)
"AI话术内容不合适(过于专业或过于简单)",
# 语言表达问题(合并)
"AI语言表达不自然或用词不当",
# 个性化问题(合并)
"AI内容缺乏个性化或与患者情况不符",
# 治疗建议问题(合并)
"AI治疗方案或复查时间建议不合理",
# 患者信息分析问题(合并)
"AI对患者年龄/性别/病史分析错误",
# 技术和格式问题(合并)
"AI生成内容格式错误或信息缺失",
# 数据解析问题(合并)
"AI未能正确解析患者数据或病历",
# 系统稳定性问题(合并)
"AI功能不稳定或响应速度慢",
# 其他问题
"其他AI相关问题"
]
# 回访状态
CALLBACK_STATUS_OPTIONS = [
"已回访",
"未回访"
]
# 回访方式选项
CALLBACK_METHODS = [
"打电话",
"发微信",
"发短信",
"视频通话",
"面谈"
]
# 回访结果选项(新增放弃回访)
CALLBACK_RESULT_OPTIONS = [
"成功",
"不成功",
"放弃回访"
]
\ No newline at end of file
/**
* 回访记录功能JavaScript
* 处理字符计数、保存等功能
*/
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
initializeCallbackRecord();
});
/**
* 初始化回访记录功能
*/
function initializeCallbackRecord() {
// 初始化字符计数
initCharacterCount();
// 初始化表单验证
initFormValidation();
console.log('回访记录功能初始化完成');
}
/**
* 初始化字符计数功能
*/
function initCharacterCount() {
const textareas = document.querySelectorAll('textarea[name="callback-record"]');
textareas.forEach(textarea => {
const caseNumber = textarea.id.replace('callback-record-', '');
const countElement = document.getElementById(`char-count-${caseNumber}`);
if (countElement) {
// 初始化计数
updateCharacterCount(textarea.id);
// 监听输入事件
textarea.addEventListener('input', function() {
updateCharacterCount(this.id);
});
// 监听粘贴事件
textarea.addEventListener('paste', function() {
setTimeout(() => {
updateCharacterCount(this.id);
}, 0);
});
}
});
}
/**
* 更新字符计数
* @param {string} textareaId - 文本域ID
*/
function updateCharacterCount(textareaId) {
const textarea = document.getElementById(textareaId);
const caseNumber = textareaId.replace('callback-record-', '');
const countElement = document.getElementById(`char-count-${caseNumber}`);
if (textarea && countElement) {
const currentLength = textarea.value.length;
const maxLength = textarea.getAttribute('maxlength') || 500;
countElement.textContent = currentLength;
// 根据字符数改变颜色
if (currentLength > maxLength * 0.9) {
countElement.className = 'text-red-500 font-medium';
} else if (currentLength > maxLength * 0.7) {
countElement.className = 'text-yellow-600';
} else {
countElement.className = 'text-gray-500';
}
}
}
/**
* 初始化表单验证
*/
function initFormValidation() {
const textareas = document.querySelectorAll('textarea[name="callback-record"]');
textareas.forEach(textarea => {
textarea.addEventListener('blur', function() {
validateCallbackRecord(this);
});
});
}
/**
* 验证回访记录
* @param {HTMLElement} textarea - 文本域元素
*/
function validateCallbackRecord(textarea) {
const value = textarea.value.trim();
const minLength = 5; // 最少5个字符
// 移除之前的错误样式
textarea.classList.remove('border-red-500', 'border-green-500');
if (value.length > 0 && value.length < minLength) {
textarea.classList.add('border-red-500');
showToast('回访记录至少需要5个字符', 'warning');
} else if (value.length >= minLength) {
textarea.classList.add('border-green-500');
}
}
/**
* 保存回访记录
* @param {string} caseNumber - 病历号
*/
function saveCallbackRecord(caseNumber) {
const textarea = document.getElementById(`callback-record-${caseNumber}`);
const methodCheckboxes = document.querySelectorAll(`input[name="callback-method-${caseNumber}"]:checked`);
if (!textarea) {
showToast('找不到回访记录输入框', 'error');
return;
}
const record = textarea.value.trim();
if (!record) {
showToast('请输入回访记录内容', 'warning');
textarea.focus();
return;
}
if (record.length < 5) {
showToast('回访记录至少需要5个字符', 'warning');
textarea.focus();
return;
}
// 获取选中的回访方式
const methods = Array.from(methodCheckboxes).map(cb => cb.value);
if (methods.length === 0) {
showToast('请选择至少一种回访方式', 'warning');
return;
}
// 构建保存数据
const saveData = {
caseNumber: caseNumber,
callbackMethods: methods,
callbackRecord: record,
operator: '系统用户', // 可以根据实际情况修改
timestamp: new Date().toISOString()
};
// 显示保存中状态
const saveBtn = document.querySelector(`button[onclick="saveCallbackRecord('${caseNumber}')"]`);
const originalText = saveBtn.innerHTML;
saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>保存中...';
saveBtn.disabled = true;
// 调用保存API
saveCallbackRecordToDatabase(saveData)
.then(result => {
showToast('回访记录保存成功', 'success');
console.log('保存成功:', result);
// 可以在这里添加保存成功后的处理逻辑
// 比如清空表单、显示保存时间等
})
.catch(error => {
console.error('保存失败:', error);
showToast(`保存失败: ${error.message}`, 'error');
})
.finally(() => {
// 恢复按钮状态
saveBtn.innerHTML = originalText;
saveBtn.disabled = false;
});
}
/**
* 保存回访记录到MySQL数据库
* @param {Object} data - 保存数据
* @returns {Promise} - 保存结果
*/
async function saveCallbackRecordToDatabase(data) {
console.log('准备保存到MySQL数据库的数据:', data);
try {
const response = await fetch('/api/callback-records', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(data)
});
if (!response.ok) {
const errorText = await response.text();
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const result = await response.json();
if (result.success) {
return result;
} else {
throw new Error(result.message || '保存失败');
}
} catch (error) {
console.error('保存回访记录到MySQL失败:', error);
// 如果是网络错误或API不可用,显示友好提示
if (error.name === 'TypeError' || error.message.includes('fetch')) {
throw new Error('无法连接到服务器,请检查网络连接或联系管理员');
}
throw error;
}
}
/**
* 设置下次回访(预留功能)
* @param {string} caseNumber - 病历号
*/
function scheduleNextCallback(caseNumber) {
showToast('此功能正在开发中...', 'info');
console.log('设置下次回访:', caseNumber);
}
/**
* 新建预约(预留功能)
* @param {string} caseNumber - 病历号
*/
function createAppointment(caseNumber) {
showToast('此功能正在开发中...', 'info');
console.log('新建预约:', caseNumber);
}
/**
* 显示提示消息
* @param {string} message - 消息内容
* @param {string} type - 消息类型 (success, error, warning, info)
*/
function showToast(message, type = 'info') {
// 创建提示元素
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 transition-all duration-300 transform translate-x-full`;
// 根据类型设置样式
switch (type) {
case 'success':
toast.classList.add('bg-green-500', 'text-white');
break;
case 'error':
toast.classList.add('bg-red-500', 'text-white');
break;
case 'warning':
toast.classList.add('bg-yellow-500', 'text-white');
break;
default:
toast.classList.add('bg-blue-500', 'text-white');
}
toast.textContent = message;
document.body.appendChild(toast);
// 显示动画
setTimeout(() => {
toast.classList.remove('translate-x-full');
}, 100);
// 自动隐藏
setTimeout(() => {
toast.classList.add('translate-x-full');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300);
}, 3000);
}
/**
* 获取认证令牌(如果需要的话)
* @returns {string} - 认证令牌
*/
function getAuthToken() {
// 这里可以实现获取认证令牌的逻辑
// 比如从localStorage、sessionStorage或cookie中获取
return '';
}
\ No newline at end of file
/**
* 回访记录功能JavaScript - 修复版本
* 处理字符计数、保存等功能
* 修复:使用完整的API URL地址
*/
// API服务器配置
const API_BASE_URL = (typeof window !== 'undefined' && window.API_BASE_URL) ? window.API_BASE_URL : '';
// 页面加载完成后初始化
document.addEventListener('DOMContentLoaded', function() {
initializeCallbackRecord();
});
/**
* 初始化回访记录功能
*/
function initializeCallbackRecord() {
// 初始化字符计数
initCharacterCount();
// 初始化表单验证
initFormValidation();
console.log('回访记录功能初始化完成 - 修复版本');
console.log('API服务器地址:', API_BASE_URL);
}
/**
* 初始化字符计数功能
*/
function initCharacterCount() {
const textareas = document.querySelectorAll('textarea[name="callback-record"]');
textareas.forEach(textarea => {
const caseNumber = textarea.id.replace('callback-record-', '');
const countElement = document.getElementById(`char-count-${caseNumber}`);
if (countElement) {
// 初始化计数
updateCharacterCount(textarea.id);
// 监听输入事件
textarea.addEventListener('input', function() {
updateCharacterCount(this.id);
});
// 监听粘贴事件
textarea.addEventListener('paste', function() {
setTimeout(() => {
updateCharacterCount(this.id);
}, 0);
});
}
});
}
/**
* 更新字符计数
* @param {string} textareaId - 文本域ID
*/
function updateCharacterCount(textareaId) {
const textarea = document.getElementById(textareaId);
const caseNumber = textareaId.replace('callback-record-', '');
const countElement = document.getElementById(`char-count-${caseNumber}`);
if (textarea && countElement) {
const currentLength = textarea.value.length;
const maxLength = textarea.getAttribute('maxlength') || 500;
countElement.textContent = currentLength;
// 根据字符数改变颜色
if (currentLength > maxLength * 0.9) {
countElement.className = 'text-red-500 font-medium';
} else if (currentLength > maxLength * 0.7) {
countElement.className = 'text-yellow-600';
} else {
countElement.className = 'text-gray-500';
}
}
}
/**
* 初始化表单验证
*/
function initFormValidation() {
const textareas = document.querySelectorAll('textarea[name="callback-record"]');
textareas.forEach(textarea => {
textarea.addEventListener('blur', function() {
validateCallbackRecord(this);
});
});
}
/**
* 验证回访记录
* @param {HTMLElement} textarea - 文本域元素
*/
function validateCallbackRecord(textarea) {
const value = textarea.value.trim();
const minLength = 5; // 最少5个字符
// 移除之前的错误样式
textarea.classList.remove('border-red-500', 'border-green-500');
if (value.length > 0 && value.length < minLength) {
textarea.classList.add('border-red-500');
showToast('回访记录至少需要5个字符', 'warning');
} else if (value.length >= minLength) {
textarea.classList.add('border-green-500');
}
}
/**
* 获取当前登录用户的真实姓名
* @returns {string} 用户真实姓名或用户名
*/
function getCurrentUserRealName() {
// 尝试从页面中获取用户信息
const userInfoElement = document.getElementById('user-info');
if (userInfoElement) {
const userText = userInfoElement.textContent || userInfoElement.innerText;
if (userText && userText !== '未登录') {
// 提取用户名(去掉"欢迎,"等前缀)
const match = userText.match(/欢迎,(.+)/);
if (match && match[1]) {
return match[1].trim();
}
return userText.trim();
}
}
// 尝试从localStorage获取
const storedUser = localStorage.getItem('currentUser');
if (storedUser) {
try {
const userData = JSON.parse(storedUser);
return userData.real_name || userData.username || '未知用户';
} catch (e) {
return storedUser;
}
}
// 尝试从sessionStorage获取
const sessionUser = sessionStorage.getItem('currentUser');
if (sessionUser) {
try {
const userData = JSON.parse(sessionUser);
return userData.real_name || userData.username || '未知用户';
} catch (e) {
return sessionUser;
}
}
// 如果都获取不到,返回默认值
return '系统用户';
}
/**
* 保存回访记录
* @param {string} patientId - 患者ID
*/
function saveCallbackRecord(patientId) {
// 获取当前用户的真实姓名
const currentUser = getCurrentUserRealName();
// 获取表单数据
const checkboxes = document.querySelectorAll(`input[name="callback-method-${patientId}"]:checked`);
const resultRadio = document.querySelector(`input[name="callback-result-${patientId}"]:checked`);
const appointmentInput = document.getElementById(`next-appointment-${patientId}`);
const failureSelect = document.getElementById(`failure-reason-${patientId}`);
const abandonSelect = document.getElementById(`abandon-reason-${patientId}`);
const aiFeedbackSelect = document.getElementById(`ai-feedback-${patientId}`);
// 验证回访方式
const methods = Array.from(checkboxes).map(cb => cb.value);
if (methods.length === 0) {
alert('请选择至少一种回访方式');
return;
}
// 验证回访结果
if (!resultRadio) {
alert('请选择回访结果');
return;
}
const callbackResult = resultRadio.value;
let nextAppointmentTime = null;
let failureReason = null;
let abandonReason = null;
if (callbackResult === '成功') {
nextAppointmentTime = appointmentInput ? appointmentInput.value : null;
} else if (callbackResult === '不成功') {
if (!failureSelect || !failureSelect.value) {
alert('回访不成功时,请选择具体原因');
if (failureSelect) failureSelect.focus();
return;
}
failureReason = failureSelect.value;
} else if (callbackResult === '放弃回访') {
if (!abandonSelect || !abandonSelect.value) {
alert('放弃回访时,请选择具体原因');
if (abandonSelect) abandonSelect.focus();
return;
}
abandonReason = abandonSelect.value;
}
// AI反馈(可选)
const aiFeedback = aiFeedbackSelect ? aiFeedbackSelect.value : null;
// 构建保存数据
const saveData = {
caseNumber: patientId,
callbackMethods: methods,
callbackResult: callbackResult,
nextAppointmentTime: nextAppointmentTime,
failureReason: failureReason,
abandonReason: abandonReason,
aiFeedbackType: aiFeedback,
operator: currentUser // 使用真实用户名
};
console.log('保存回访记录数据:', saveData);
console.log('当前用户:', currentUser);
// 显示保存中状态
const saveBtn = document.querySelector(`button[onclick="saveCallbackRecord('${patientId}')"]`);
const originalText = saveBtn.innerHTML;
saveBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>保存中...';
saveBtn.disabled = true;
// 调用保存API
fetch('/api/callback-records', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(saveData)
})
.then(response => response.json())
.then(data => {
console.log('API响应:', data);
if (data.success) {
showSuccessToast('回访记录保存成功!');
clearCallbackForm(patientId);
// 实时更新回访记录显示
updateCallbackRecords(patientId);
} else {
showErrorToast(`保存失败:${data.message}`);
}
})
.catch(error => {
console.error('保存失败:', error);
showErrorToast('网络错误,保存失败。请检查网络连接或联系管理员。');
})
.finally(() => {
saveBtn.innerHTML = originalText;
saveBtn.disabled = false;
});
}
/**
* 保存回访记录到MySQL数据库 - 修复版本
* @param {Object} data - 保存数据
* @returns {Promise} - 保存结果
*/
async function saveCallbackRecordToDatabase(data) {
console.log('准备保存到MySQL数据库的数据:', data);
// 构建完整的API URL
const apiUrl = `${API_BASE_URL}/api/callback-records`;
console.log('API请求地址:', apiUrl);
try {
const response = await fetch(apiUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json'
},
body: JSON.stringify(data)
});
console.log('API响应状态:', response.status);
if (!response.ok) {
const errorText = await response.text();
console.error('API错误响应:', errorText);
throw new Error(`HTTP ${response.status}: ${errorText}`);
}
const result = await response.json();
console.log('API响应结果:', result);
if (result.success) {
return result;
} else {
throw new Error(result.message || '保存失败');
}
} catch (error) {
console.error('保存回访记录到MySQL失败:', error);
// 如果是网络错误或API不可用,显示友好提示
if (error.name === 'TypeError' || error.message.includes('fetch')) {
throw new Error(`无法连接到服务器 (${apiUrl}),请检查:\n1. API服务器是否正在运行\n2. 服务器地址是否正确\n3. 网络连接是否正常`);
}
throw error;
}
}
/**
* 测试API连接
*/
async function testAPIConnection() {
const healthUrl = `${API_BASE_URL}/api/health`;
try {
const response = await fetch(healthUrl);
const result = await response.json();
console.log('API健康检查结果:', result);
showToast('API服务器连接正常', 'success');
return true;
} catch (error) {
console.error('API连接测试失败:', error);
showToast(`API服务器连接失败: ${error.message}`, 'error');
return false;
}
}
/**
* 设置下次回访(预留功能)
* @param {string} caseNumber - 病历号
*/
function scheduleNextCallback(caseNumber) {
showToast('此功能正在开发中...', 'info');
console.log('设置下次回访:', caseNumber);
}
/**
* 新建预约(预留功能)
* @param {string} caseNumber - 病历号
*/
function createAppointment(caseNumber) {
showToast('此功能正在开发中...', 'info');
console.log('新建预约:', caseNumber);
}
/**
* 显示提示消息
* @param {string} message - 消息内容
* @param {string} type - 消息类型 (success, error, warning, info)
*/
function showToast(message, type = 'info') {
// 创建提示元素
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg z-50 transition-all duration-300 transform translate-x-full max-w-md`;
// 根据类型设置样式
switch (type) {
case 'success':
toast.classList.add('bg-green-500', 'text-white');
break;
case 'error':
toast.classList.add('bg-red-500', 'text-white');
break;
case 'warning':
toast.classList.add('bg-yellow-500', 'text-white');
break;
default:
toast.classList.add('bg-blue-500', 'text-white');
}
// 处理多行消息
if (message.includes('\n')) {
const lines = message.split('\n');
toast.innerHTML = lines.map(line => `<div>${line}</div>`).join('');
} else {
toast.textContent = message;
}
document.body.appendChild(toast);
// 显示动画
setTimeout(() => {
toast.classList.remove('translate-x-full');
}, 100);
// 自动隐藏
setTimeout(() => {
toast.classList.add('translate-x-full');
setTimeout(() => {
if (toast.parentNode) {
toast.parentNode.removeChild(toast);
}
}, 300);
}, 5000); // 延长显示时间到5秒
}
/**
* 显示成功提示消息
* @param {string} message - 消息内容
*/
function showSuccessToast(message) {
showToast(message, 'success');
}
/**
* 显示错误提示消息
* @param {string} message - 消息内容
*/
function showErrorToast(message) {
showToast(message, 'error');
}
/**
* 获取认证令牌(如果需要的话)
* @returns {string} - 认证令牌
*/
function getAuthToken() {
// 这里可以实现获取认证令牌的逻辑
// 比如从localStorage、sessionStorage或cookie中获取
return '';
}
// 页面加载完成后测试API连接
document.addEventListener('DOMContentLoaded', function() {
// 延迟3秒后自动测试API连接
setTimeout(() => {
console.log('自动测试API连接...');
testAPIConnection();
}, 3000);
});
// 导出函数供全局使用
window.saveCallbackRecord = saveCallbackRecord;
window.scheduleNextCallback = scheduleNextCallback;
window.createAppointment = createAppointment;
window.testAPIConnection = testAPIConnection;
\ No newline at end of file
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
回访记录数据库模型
定义回访记录的数据结构和数据库操作接口
"""
from datetime import datetime
from typing import List, Optional, Dict, Any
import json
import sqlite3
import os
class CallbackRecord:
"""回访记录数据模型"""
def __init__(self, case_number: str, callback_methods: List[str],
callback_result: str, operator: str,
next_appointment_time: Optional[str] = None,
failure_reason: Optional[str] = None,
abandon_reason: Optional[str] = None,
ai_feedback_type: Optional[str] = None,
callback_status: str = "已回访",
record_id: Optional[int] = None,
create_time: Optional[datetime] = None):
"""
初始化回访记录
Args:
case_number: 病历号
callback_methods: 回访方式列表
callback_result: 回访结果(成功/不成功/放弃回访)
operator: 操作员
next_appointment_time: 下次预约时间(当回访成功时)
failure_reason: 不成功的原因(当回访不成功时)
abandon_reason: 放弃回访的原因(当选择放弃回访时)
ai_feedback_type: AI错误反馈类型
callback_status: 回访状态(已回访/未回访)
record_id: 记录ID(自动生成)
create_time: 创建时间(自动生成)
"""
self.record_id = record_id
self.case_number = case_number
self.callback_methods = callback_methods
self.callback_result = callback_result
# 为了保持向后兼容性,设置callback_success
self.callback_success = callback_result == "成功"
self.next_appointment_time = next_appointment_time
self.failure_reason = failure_reason
self.abandon_reason = abandon_reason
self.ai_feedback_type = ai_feedback_type
self.callback_status = callback_status
self.operator = operator
self.create_time = create_time or datetime.now()
def to_dict(self) -> Dict[str, Any]:
"""转换为字典格式"""
return {
'record_id': self.record_id,
'case_number': self.case_number,
'callback_methods': self.callback_methods,
'callback_success': self.callback_success,
'callback_result': self.callback_result,
'next_appointment_time': self.next_appointment_time,
'failure_reason': self.failure_reason,
'abandon_reason': self.abandon_reason,
'ai_feedback_type': self.ai_feedback_type,
'callback_status': self.callback_status,
'operator': self.operator,
'create_time': self.create_time.isoformat() if self.create_time else None
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'CallbackRecord':
"""从字典创建实例"""
create_time = None
if data.get('create_time'):
create_time = datetime.fromisoformat(data['create_time'].replace('Z', '+00:00'))
return cls(
record_id=data.get('record_id'),
case_number=data['case_number'],
callback_methods=data['callback_methods'],
callback_result=data.get('callback_result', '成功' if data.get('callback_success', True) else '不成功'),
next_appointment_time=data.get('next_appointment_time'),
failure_reason=data.get('failure_reason'),
abandon_reason=data.get('abandon_reason'),
ai_feedback_type=data.get('ai_feedback_type'),
callback_status=data.get('callback_status', '已回访'),
operator=data['operator'],
create_time=create_time
)
class CallbackRecordManager:
"""回访记录管理器"""
def __init__(self, db_path: str = "callback_records.db"):
"""
初始化数据库管理器
Args:
db_path: 数据库文件路径
"""
self.db_path = db_path
self.init_database()
def init_database(self):
"""初始化数据库表"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# 创建回访记录表
cursor.execute('''
CREATE TABLE IF NOT EXISTS callback_records (
record_id INTEGER PRIMARY KEY AUTOINCREMENT,
case_number TEXT NOT NULL,
callback_methods TEXT NOT NULL,
callback_success BOOLEAN NOT NULL,
callback_result TEXT NOT NULL DEFAULT '成功',
next_appointment_time TEXT,
failure_reason TEXT,
abandon_reason TEXT,
ai_feedback_type TEXT,
callback_status TEXT NOT NULL DEFAULT '已回访',
operator TEXT NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (case_number) REFERENCES patients(case_number)
)
''')
# 检查是否需要升级数据库表结构
cursor.execute("PRAGMA table_info(callback_records)")
columns = [row[1] for row in cursor.fetchall()]
# 检查是否需要添加新字段
if 'callback_result' not in columns:
print("检测到需要添加新字段,正在升级数据库...")
# 添加新字段
try:
cursor.execute('ALTER TABLE callback_records ADD COLUMN callback_result TEXT NOT NULL DEFAULT "成功"')
cursor.execute('ALTER TABLE callback_records ADD COLUMN abandon_reason TEXT')
# 根据现有的callback_success字段设置callback_result
cursor.execute('''
UPDATE callback_records
SET callback_result = CASE
WHEN callback_success = 1 THEN '成功'
ELSE '不成功'
END
''')
print("新字段添加完成!")
except sqlite3.OperationalError as e:
if "duplicate column name" not in str(e):
raise
# 如果表中还有旧的callback_record字段,进行数据迁移
if 'callback_record' in columns and 'callback_success' not in columns:
print("检测到旧版本数据库,正在升级...")
# 创建新表
cursor.execute('''
CREATE TABLE callback_records_new (
record_id INTEGER PRIMARY KEY AUTOINCREMENT,
case_number TEXT NOT NULL,
callback_methods TEXT NOT NULL,
callback_success BOOLEAN NOT NULL DEFAULT 1,
callback_result TEXT NOT NULL DEFAULT '成功',
next_appointment_time TEXT,
failure_reason TEXT,
abandon_reason TEXT,
ai_feedback_type TEXT,
callback_status TEXT NOT NULL DEFAULT '已回访',
operator TEXT NOT NULL,
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (case_number) REFERENCES patients(case_number)
)
''')
# 迁移数据
cursor.execute('''
INSERT INTO callback_records_new
(case_number, callback_methods, callback_success, callback_result, callback_status, operator, create_time)
SELECT case_number, callback_methods, 1, '成功', '已回访', operator, create_time
FROM callback_records
''')
# 删除旧表,重命名新表
cursor.execute('DROP TABLE callback_records')
cursor.execute('ALTER TABLE callback_records_new RENAME TO callback_records')
print("数据库升级完成!")
# 创建索引
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_case_number
ON callback_records(case_number)
''')
cursor.execute('''
CREATE INDEX IF NOT EXISTS idx_create_time
ON callback_records(create_time)
''')
conn.commit()
print(f"数据库初始化完成: {self.db_path}")
except Exception as e:
print(f"数据库初始化失败: {e}")
raise
def save_record(self, record: CallbackRecord) -> CallbackRecord:
"""
保存回访记录
Args:
record: 回访记录对象
Returns:
保存后的记录对象(包含ID)
"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO callback_records
(case_number, callback_methods, callback_success, callback_result, next_appointment_time,
failure_reason, abandon_reason, ai_feedback_type, callback_status, operator)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
''', (
record.case_number,
json.dumps(record.callback_methods, ensure_ascii=False),
record.callback_success,
record.callback_result,
record.next_appointment_time,
record.failure_reason,
record.abandon_reason,
record.ai_feedback_type,
record.callback_status,
record.operator
))
record_id = cursor.lastrowid
conn.commit()
# 更新记录对象的ID
record.record_id = record_id
print(f"回访记录保存成功,ID: {record_id}")
return record
except Exception as e:
print(f"保存回访记录失败: {e}")
raise
def get_records_by_case_number(self, case_number: str) -> List[CallbackRecord]:
"""
根据病历号获取回访记录
Args:
case_number: 病历号
Returns:
回访记录列表
"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT record_id, case_number, callback_methods,
callback_success, callback_result, next_appointment_time, failure_reason,
abandon_reason, ai_feedback_type, callback_status, operator, create_time
FROM callback_records
WHERE case_number = ?
ORDER BY create_time DESC
''', (case_number,))
records = []
for row in cursor.fetchall():
# 为了向后兼容,处理可能缺少的callback_result字段
callback_result = row[4] if len(row) > 11 else ("成功" if row[3] else "不成功")
record = CallbackRecord(
record_id=row[0],
case_number=row[1],
callback_methods=json.loads(row[2]),
callback_result=callback_result,
next_appointment_time=row[5] if len(row) > 11 else row[4],
failure_reason=row[6] if len(row) > 11 else row[5],
abandon_reason=row[7] if len(row) > 11 else None,
ai_feedback_type=row[8] if len(row) > 11 else row[6],
callback_status=row[9] if len(row) > 11 else row[7],
operator=row[10] if len(row) > 11 else row[8],
create_time=datetime.fromisoformat(row[11] if len(row) > 11 else row[9])
)
records.append(record)
return records
except Exception as e:
print(f"获取回访记录失败: {e}")
return []
def get_latest_record(self, case_number: str) -> Optional[CallbackRecord]:
"""
获取最新的回访记录
Args:
case_number: 病历号
Returns:
最新的回访记录,如果没有则返回None
"""
records = self.get_records_by_case_number(case_number)
return records[0] if records else None
def delete_record(self, record_id: int) -> bool:
"""
删除回访记录
Args:
record_id: 记录ID
Returns:
删除成功返回True
"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('DELETE FROM callback_records WHERE record_id = ?', (record_id,))
if cursor.rowcount > 0:
conn.commit()
print(f"回访记录删除成功,ID: {record_id}")
return True
else:
print(f"未找到要删除的记录,ID: {record_id}")
return False
except Exception as e:
print(f"删除回访记录失败: {e}")
return False
def get_statistics(self) -> Dict[str, Any]:
"""
获取回访记录统计信息
Returns:
统计信息字典
"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
# 总记录数
cursor.execute('SELECT COUNT(*) FROM callback_records')
total_records = cursor.fetchone()[0]
# 今日记录数
cursor.execute('''
SELECT COUNT(*) FROM callback_records
WHERE DATE(create_time) = DATE('now')
''')
today_records = cursor.fetchone()[0]
# 按回访方式统计
cursor.execute('''
SELECT callback_methods, COUNT(*)
FROM callback_records
GROUP BY callback_methods
''')
method_stats = {}
for row in cursor.fetchall():
methods = json.loads(row[0])
for method in methods:
method_stats[method] = method_stats.get(method, 0) + row[1]
# 按操作员统计
cursor.execute('''
SELECT operator, COUNT(*)
FROM callback_records
GROUP BY operator
ORDER BY COUNT(*) DESC
LIMIT 10
''')
operator_stats = dict(cursor.fetchall())
return {
'total_records': total_records,
'today_records': today_records,
'method_statistics': method_stats,
'operator_statistics': operator_stats,
'update_time': datetime.now().isoformat()
}
except Exception as e:
print(f"获取统计信息失败: {e}")
return {}
def get_all_records(self) -> List[CallbackRecord]:
"""
获取所有回访记录
Returns:
回访记录列表
"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT record_id, case_number, callback_methods,
callback_success, callback_result, next_appointment_time, failure_reason,
abandon_reason, ai_feedback_type, callback_status, operator, create_time
FROM callback_records
ORDER BY create_time DESC
''')
records = []
for row in cursor.fetchall():
# 为了向后兼容,处理可能缺少的callback_result字段
callback_result = row[4] if len(row) > 11 else ("成功" if row[3] else "不成功")
record = CallbackRecord(
record_id=row[0],
case_number=row[1],
callback_methods=json.loads(row[2]),
callback_result=callback_result,
next_appointment_time=row[5] if len(row) > 11 else row[4],
failure_reason=row[6] if len(row) > 11 else row[5],
abandon_reason=row[7] if len(row) > 11 else None,
ai_feedback_type=row[8] if len(row) > 11 else row[6],
callback_status=row[9] if len(row) > 11 else row[7],
operator=row[10] if len(row) > 11 else row[8],
create_time=datetime.fromisoformat(row[11] if len(row) > 11 else row[9])
)
records.append(record)
return records
except Exception as e:
print(f"获取所有回访记录失败: {e}")
return []
def create_record(self, data: Dict[str, Any]) -> CallbackRecord:
"""
从API数据创建回访记录
Args:
data: 包含回访记录信息的字典
Returns:
创建的回访记录对象
Raises:
ValueError: 数据验证失败
DatabaseError: 数据库操作失败
"""
try:
# 验证必需字段
required_fields = ['caseNumber', 'callbackMethods', 'callbackSuccess', 'operator']
for field in required_fields:
if field not in data:
raise ValueError(f"缺少必需字段: {field}")
# 创建记录
record = CallbackRecord(
case_number=data['caseNumber'],
callback_methods=data['callbackMethods'],
callback_success=data['callbackSuccess'],
operator=data['operator'],
next_appointment_time=data.get('nextAppointmentTime'),
failure_reason=data.get('failureReason'),
ai_feedback_type=data.get('aiFeedbackType'),
callback_status=data.get('callbackStatus', '已回访')
)
# 保存记录
saved_record = self.save_record(record)
return saved_record
except Exception as e:
print(f"创建回访记录失败: {e}")
raise
# Flask API接口示例(预留)
def create_flask_api():
"""
创建Flask API接口
这是一个预留的API接口示例,可以根据实际需求进行实现
"""
try:
from flask import Flask, request, jsonify
from flask_cors import CORS
except ImportError:
print("需要安装Flask: pip install flask flask-cors")
return None
app = Flask(__name__)
CORS(app) # 允许跨域请求
# 初始化数据库管理器
db_manager = CallbackRecordManager()
@app.route('/api/callback-records', methods=['POST'])
def save_callback_record():
"""保存回访记录API"""
try:
data = request.get_json()
# 验证必需字段
required_fields = ['caseNumber', 'callbackMethods', 'callbackResult', 'operator']
for field in required_fields:
if field not in data:
return jsonify({'success': False, 'message': f'缺少必需字段: {field}'}), 400
# 创建记录对象
record = CallbackRecord(
case_number=data['caseNumber'],
callback_methods=data['callbackMethods'],
callback_result=data['callbackResult'],
operator=data['operator'],
next_appointment_time=data.get('nextAppointmentTime'),
failure_reason=data.get('failureReason'),
abandon_reason=data.get('abandonReason'),
ai_feedback_type=data.get('aiFeedbackType'),
callback_status=data.get('callbackStatus', '已回访')
)
# 保存到数据库
saved_record = db_manager.save_record(record)
return jsonify({
'success': True,
'id': saved_record.record_id,
'message': '保存成功'
})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/callback-records/<case_number>', methods=['GET'])
def get_callback_records(case_number):
"""获取回访记录API"""
try:
records = db_manager.get_records_by_case_number(case_number)
return jsonify({
'success': True,
'data': [record.to_dict() for record in records]
})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
@app.route('/api/callback-records/statistics', methods=['GET'])
def get_statistics():
"""获取统计信息API"""
try:
stats = db_manager.get_statistics()
return jsonify({
'success': True,
'data': stats
})
except Exception as e:
return jsonify({'success': False, 'message': str(e)}), 500
return app
def main():
"""主函数 - 测试数据库功能"""
print("测试回访记录数据库功能...")
# 初始化数据库管理器
db_manager = CallbackRecordManager()
# 测试保存记录
test_record = CallbackRecord(
case_number="TS0B000249",
callback_methods=["打电话", "发微信"],
callback_success=True,
operator="test_user",
next_appointment_time="2025-02-15 10:00"
)
saved_record = db_manager.save_record(test_record)
print(f"测试记录保存成功,ID: {saved_record.record_id}")
# 测试获取记录
records = db_manager.get_records_by_case_number("TS0B000249")
print(f"获取到 {len(records)} 条记录")
for record in records:
print(f" - ID: {record.record_id}, 时间: {record.create_time}, 状态: {record.callback_status}")
# 测试统计信息
stats = db_manager.get_statistics()
print(f"统计信息: {stats}")
if __name__ == "__main__":
main()
\ No newline at end of file
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
MySQL版本的回访记录数据库模型
用于将回访记录存储到MySQL数据库中
"""
from datetime import datetime
from typing import List, Optional, Dict, Any
import json
try:
import pymysql
PYMYSQL_AVAILABLE = True
except ImportError:
PYMYSQL_AVAILABLE = False
print("警告:pymysql库未安装,请运行: pip install pymysql")
class CallbackRecord:
"""回访记录数据模型"""
def __init__(self, case_number: str, callback_methods: List[str],
callback_record: str, callback_result: str, operator: str,
next_appointment_time: Optional[str] = None,
failure_reason: Optional[str] = None,
abandon_reason: Optional[str] = None,
ai_feedback_type: Optional[str] = None,
failure_reason_note: Optional[str] = None,
abandon_reason_note: Optional[str] = None,
ai_feedback_note: Optional[str] = None,
callback_status: str = "已回访",
record_id: Optional[int] = None,
create_time: Optional[datetime] = None):
"""
初始化回访记录
Args:
case_number: 病历号
callback_methods: 回访方式列表
callback_record: 回访记录内容
callback_result: 回访结果(成功/不成功/放弃回访)
operator: 操作员
next_appointment_time: 下次预约时间(当回访成功时)
failure_reason: 不成功的原因(当回访不成功时)
abandon_reason: 放弃回访的原因(当选择放弃回访时)
ai_feedback_type: AI错误反馈类型
failure_reason_note: 不成功备注
abandon_reason_note: 放弃回访备注
ai_feedback_note: AI反馈备注
callback_status: 回访状态(已回访/未回访)
record_id: 记录ID(自动生成)
create_time: 创建时间(自动生成)
"""
self.record_id = record_id
self.case_number = case_number
self.callback_methods = callback_methods
self.callback_record = callback_record
self.callback_result = callback_result
# 为了保持向后兼容性,设置callback_success
self.callback_success = callback_result == "成功"
self.next_appointment_time = next_appointment_time
self.failure_reason = failure_reason
self.abandon_reason = abandon_reason
self.ai_feedback_type = ai_feedback_type
self.failure_reason_note = failure_reason_note
self.abandon_reason_note = abandon_reason_note
self.ai_feedback_note = ai_feedback_note
self.callback_status = callback_status
self.operator = operator
self.create_time = create_time or datetime.now()
def to_dict(self) -> Dict[str, Any]:
"""转换为字典格式"""
return {
'record_id': self.record_id,
'case_number': self.case_number,
'callback_methods': self.callback_methods,
'callback_record': self.callback_record,
'callback_result': self.callback_result, # 直接使用存储的结果
'callback_success': self.callback_result == "成功", # 前端期望的字段
'callback_status': self.callback_status, # 使用实际的回访状态
'next_appointment_time': self.next_appointment_time,
'failure_reason': self.failure_reason,
'abandon_reason': self.abandon_reason,
'ai_feedback_type': self.ai_feedback_type,
'failure_reason_note': self.failure_reason_note,
'abandon_reason_note': self.abandon_reason_note,
'ai_feedback_note': self.ai_feedback_note,
'operator': self.operator,
'operator_name': self.operator, # 前端期望的字段名
'create_time': self.create_time.isoformat() if self.create_time else None
}
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'CallbackRecord':
"""从字典创建实例"""
create_time = None
if data.get('create_time'):
if isinstance(data['create_time'], str):
create_time = datetime.fromisoformat(data['create_time'].replace('Z', '+00:00'))
else:
create_time = data['create_time']
return cls(
record_id=data.get('record_id'),
case_number=data['case_number'],
callback_methods=data['callback_methods'],
callback_record=data['callback_record'],
operator=data['operator'],
create_time=create_time
)
class MySQLCallbackRecordManager:
"""MySQL回访记录管理器"""
def __init__(self, host: str = None, port: int = 3306,
user: str = "callback_user", password: str = "dev_password_123",
database: str = "callback_system", charset: str = "utf8mb4"):
"""
初始化MySQL数据库管理器
Args:
host: 数据库主机地址 (None时自动检测)
port: 数据库端口
user: 数据库用户名
password: 数据库密码
database: 数据库名称
charset: 字符集
"""
if not PYMYSQL_AVAILABLE:
raise ImportError("pymysql库未安装,请运行: pip install pymysql")
# 智能检测主机地址
if host is None:
import os
# 优先使用环境变量
host = os.getenv('DB_HOST')
if not host:
# 检测是否在Docker环境中
if os.path.exists('/.dockerenv'):
host = "mysql" # Docker环境中使用服务名
else:
host = "localhost" # 本地环境使用localhost
print(f"📡 MySQL连接配置: {host}:{port}")
self.config = {
'host': host,
'port': port,
'user': user,
'password': password,
'database': database,
'charset': charset,
'autocommit': True,
'use_unicode': True,
'init_command': 'SET NAMES utf8mb4',
# 添加更多字符集相关配置
'sql_mode': 'STRICT_TRANS_TABLES,NO_ZERO_DATE,NO_ZERO_IN_DATE,ERROR_FOR_DIVISION_BY_ZERO',
'read_timeout': 60,
'write_timeout': 60,
'connect_timeout': 60
}
self.init_database()
def get_connection(self):
"""获取数据库连接"""
return pymysql.connect(**self.config)
def init_database(self):
"""初始化数据库和表"""
try:
# 首先连接到MySQL服务器(不指定数据库)
temp_config = self.config.copy()
database_name = temp_config.pop('database')
with pymysql.connect(**temp_config) as conn:
with conn.cursor() as cursor:
# 创建数据库(如果不存在)
cursor.execute(f"CREATE DATABASE IF NOT EXISTS `{database_name}` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci")
print(f"数据库 {database_name} 创建成功或已存在")
# 连接到指定数据库并创建表
with self.get_connection() as conn:
with conn.cursor() as cursor:
# 检查表是否存在
cursor.execute("SHOW TABLES LIKE 'callback_records'")
table_exists = cursor.fetchone()
if table_exists:
# 检查表结构
cursor.execute("DESCRIBE callback_records")
columns = [row[0] for row in cursor.fetchall()]
# 检查必需的字段
required_columns = ['record_id', 'case_number', 'callback_methods', 'callback_record', 'callback_result', 'operator', 'create_time']
missing_columns = [col for col in required_columns if col not in columns]
if missing_columns:
print(f"表结构不完整,缺少字段: {missing_columns}")
print("删除旧表并重新创建...")
cursor.execute("DROP TABLE callback_records")
table_exists = False
if not table_exists:
# 创建回访记录表
create_table_sql = """
CREATE TABLE callback_records (
record_id INT AUTO_INCREMENT PRIMARY KEY COMMENT '记录ID',
case_number VARCHAR(50) NOT NULL COMMENT '病历号',
callback_methods JSON NOT NULL COMMENT '回访方式(JSON格式)',
callback_record TEXT NOT NULL COMMENT '回访记录内容',
callback_result VARCHAR(50) NOT NULL COMMENT '回访结果(成功/不成功/放弃回访)',
next_appointment_time TEXT COMMENT '下次预约时间',
failure_reason TEXT COMMENT '不成功的原因',
abandon_reason TEXT COMMENT '放弃回访的原因',
ai_feedback_type VARCHAR(100) COMMENT 'AI错误反馈类型',
failure_reason_note TEXT COMMENT '不成功备注',
abandon_reason_note TEXT COMMENT '放弃回访备注',
ai_feedback_note TEXT COMMENT 'AI反馈备注',
callback_status VARCHAR(50) NOT NULL DEFAULT '已回访' COMMENT '回访状态',
operator VARCHAR(100) NOT NULL COMMENT '操作员',
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
INDEX idx_case_number (case_number),
INDEX idx_create_time (create_time),
INDEX idx_operator (operator),
INDEX idx_callback_result (callback_result)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci COMMENT='回访记录表'
"""
cursor.execute(create_table_sql)
print("回访记录表创建成功")
else:
# 检查并添加缺失的字段
self._migrate_table_structure(cursor)
print("回访记录表已存在且结构正确")
except Exception as e:
print(f"数据库初始化失败: {e}")
raise
def _migrate_table_structure(self, cursor):
"""迁移表结构,添加缺失的字段"""
try:
# 获取当前表结构
cursor.execute("DESCRIBE callback_records")
existing_columns = {row[0] for row in cursor.fetchall()}
# 需要添加的字段
new_columns = {
'next_appointment_time': "ADD COLUMN next_appointment_time TEXT COMMENT '下次预约时间'",
'failure_reason': "ADD COLUMN failure_reason TEXT COMMENT '不成功的原因'",
'abandon_reason': "ADD COLUMN abandon_reason TEXT COMMENT '放弃回访的原因'",
'ai_feedback_type': "ADD COLUMN ai_feedback_type VARCHAR(100) COMMENT 'AI错误反馈类型'",
'failure_reason_note': "ADD COLUMN failure_reason_note TEXT COMMENT '不成功备注'",
'abandon_reason_note': "ADD COLUMN abandon_reason_note TEXT COMMENT '放弃回访备注'",
'ai_feedback_note': "ADD COLUMN ai_feedback_note TEXT COMMENT 'AI反馈备注'",
'callback_status': "ADD COLUMN callback_status VARCHAR(50) NOT NULL DEFAULT '已回访' COMMENT '回访状态'"
}
# 添加缺失的字段
for column_name, alter_sql in new_columns.items():
if column_name not in existing_columns:
try:
cursor.execute(f"ALTER TABLE callback_records {alter_sql}")
print(f"✓ 添加字段: {column_name}")
except Exception as e:
print(f"⚠ 添加字段 {column_name} 失败: {e}")
except Exception as e:
print(f"表结构迁移失败: {e}")
# 不抛出异常,允许程序继续运行
def save_record(self, record: CallbackRecord) -> int:
"""
保存回访记录
Args:
record: 回访记录对象
Returns:
记录ID
"""
try:
with self.get_connection() as conn:
with conn.cursor() as cursor:
sql = """
INSERT INTO callback_records
(case_number, callback_methods, callback_record, callback_result,
next_appointment_time, failure_reason, abandon_reason, ai_feedback_type,
failure_reason_note, abandon_reason_note, ai_feedback_note,
callback_status, operator)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
"""
cursor.execute(sql, (
record.case_number,
json.dumps(record.callback_methods, ensure_ascii=False),
record.callback_record, # 直接使用原始字符串,不进行编码转换
record.callback_result,
record.next_appointment_time,
record.failure_reason,
record.abandon_reason,
record.ai_feedback_type,
getattr(record, 'failure_reason_note', None),
getattr(record, 'abandon_reason_note', None),
getattr(record, 'ai_feedback_note', None),
record.callback_status,
record.operator
))
record_id = cursor.lastrowid
print(f"回访记录保存成功,ID: {record_id}")
return record_id
except Exception as e:
print(f"保存回访记录失败: {e}")
raise
def get_records_by_case_number(self, case_number: str) -> List[CallbackRecord]:
"""
根据病历号获取回访记录
Args:
case_number: 病历号
Returns:
回访记录列表
"""
try:
with self.get_connection() as conn:
with conn.cursor(pymysql.cursors.DictCursor) as cursor:
sql = """
SELECT record_id, case_number, callback_methods,
callback_record, callback_result, next_appointment_time,
failure_reason, abandon_reason, ai_feedback_type,
failure_reason_note, abandon_reason_note, ai_feedback_note,
callback_status, operator, create_time
FROM callback_records
WHERE case_number = %s
ORDER BY create_time DESC
"""
cursor.execute(sql, (case_number,))
rows = cursor.fetchall()
records = []
for row in rows:
record = CallbackRecord(
record_id=row['record_id'],
case_number=row['case_number'],
callback_methods=json.loads(row['callback_methods']),
callback_record=row['callback_record'],
callback_result=row['callback_result'],
next_appointment_time=row.get('next_appointment_time'),
failure_reason=row.get('failure_reason'),
abandon_reason=row.get('abandon_reason'),
ai_feedback_type=row.get('ai_feedback_type'),
failure_reason_note=row.get('failure_reason_note'),
abandon_reason_note=row.get('abandon_reason_note'),
ai_feedback_note=row.get('ai_feedback_note'),
callback_status=row.get('callback_status', '已回访'),
operator=row['operator'],
create_time=row['create_time']
)
records.append(record)
return records
except Exception as e:
print(f"获取回访记录失败: {e}")
return []
def get_latest_record(self, case_number: str) -> Optional[CallbackRecord]:
"""
获取最新的回访记录
Args:
case_number: 病历号
Returns:
最新的回访记录,如果没有则返回None
"""
records = self.get_records_by_case_number(case_number)
return records[0] if records else None
def delete_record(self, record_id: int) -> bool:
"""
删除回访记录
Args:
record_id: 记录ID
Returns:
删除成功返回True
"""
try:
with self.get_connection() as conn:
with conn.cursor() as cursor:
sql = "DELETE FROM callback_records WHERE record_id = %s"
affected_rows = cursor.execute(sql, (record_id,))
if affected_rows > 0:
print(f"回访记录删除成功,ID: {record_id}")
return True
else:
print(f"未找到要删除的记录,ID: {record_id}")
return False
except Exception as e:
print(f"删除回访记录失败: {e}")
return False
def get_statistics(self) -> Dict[str, Any]:
"""
获取回访记录统计信息
Returns:
统计信息字典
"""
try:
with self.get_connection() as conn:
with conn.cursor(pymysql.cursors.DictCursor) as cursor:
# 总记录数
cursor.execute('SELECT COUNT(*) as total FROM callback_records')
total_records = cursor.fetchone()['total']
# 今日记录数
cursor.execute('''
SELECT COUNT(*) as today_count
FROM callback_records
WHERE DATE(create_time) = CURDATE()
''')
today_records = cursor.fetchone()['today_count']
# 按回访方式统计
cursor.execute('SELECT callback_methods FROM callback_records')
method_stats = {}
for row in cursor.fetchall():
methods = json.loads(row['callback_methods'])
for method in methods:
method_stats[method] = method_stats.get(method, 0) + 1
# 按操作员统计
cursor.execute('''
SELECT operator, COUNT(*) as count
FROM callback_records
GROUP BY operator
ORDER BY count DESC
LIMIT 10
''')
operator_stats = {row['operator']: row['count'] for row in cursor.fetchall()}
return {
'total_records': total_records,
'today_records': today_records,
'method_statistics': method_stats,
'operator_statistics': operator_stats,
'update_time': datetime.now().isoformat()
}
except Exception as e:
print(f"获取统计信息失败: {e}")
return {}
def test_connection(self) -> bool:
"""
测试数据库连接
Returns:
连接成功返回True
"""
try:
with self.get_connection() as conn:
with conn.cursor() as cursor:
cursor.execute("SELECT 1")
result = cursor.fetchone()
print(f"数据库连接测试成功: {result}")
return True
except Exception as e:
print(f"数据库连接测试失败: {e}")
return False
def get_all_records(self) -> List[CallbackRecord]:
"""获取所有回访记录"""
try:
conn = self.get_connection()
cursor = conn.cursor(pymysql.cursors.DictCursor)
sql = """
SELECT record_id, case_number, callback_methods, callback_record,
callback_result, operator, create_time, update_time
FROM callback_records
ORDER BY create_time DESC
"""
cursor.execute(sql)
rows = cursor.fetchall()
records = []
for row in rows:
record = CallbackRecord(
case_number=row['case_number'],
callback_methods=json.loads(row['callback_methods']) if row['callback_methods'] else [],
callback_record=row['callback_record'],
callback_result=row['callback_result'],
operator=row['operator'],
record_id=row['record_id'],
create_time=row['create_time']
)
records.append(record)
cursor.close()
conn.close()
return records
except Exception as e:
print(f"获取所有回访记录失败: {e}")
return []
def create_mysql_config_file():
"""创建MySQL配置文件模板"""
config_content = """# MySQL数据库配置文件
# 请根据您的实际情况修改以下配置
[mysql]
host = "mysql"
port = 3306
user = root
password = your_password_here
database = callback_system
charset = utf8mb4
# 使用说明:
# 1. 将 your_password_here 替换为您的MySQL密码
# 2. 如果MySQL不在本机,请修改host
# 3. 如果使用不同的端口,请修改port
# 4. 如果要使用不同的数据库名,请修改database
"""
with open('mysql_config.ini', 'w', encoding='utf-8') as f:
f.write(config_content)
print("MySQL配置文件已创建: mysql_config.ini")
print("请编辑此文件,填入您的MySQL连接信息")
def main():
"""主函数 - 测试MySQL数据库功能"""
print("MySQL回访记录系统测试...")
if not PYMYSQL_AVAILABLE:
print("错误:pymysql库未安装")
print("请运行以下命令安装:")
print("pip install pymysql")
create_mysql_config_file()
return
# 创建配置文件
create_mysql_config_file()
print("\n请按照以下步骤操作:")
print("1. 编辑 mysql_config.ini 文件,填入您的MySQL连接信息")
print("2. 确保MySQL服务正在运行")
print("3. 重新运行此脚本进行测试")
# 这里可以添加实际的测试代码
# 但需要用户先配置数据库连接信息
try:
# 使用默认配置进行测试(需要用户修改)
print("\n尝试使用默认配置连接数据库...")
print("注意:如果连接失败,请修改mysql_config.ini文件中的配置")
# 这里需要用户提供实际的数据库连接信息
db_manager = MySQLCallbackRecordManager(
host="mysql",
port=3306,
user="root",
password="", # 用户需要填入实际密码
database="callback_system"
)
# 测试连接
if db_manager.test_connection():
print("数据库连接成功!")
# 测试保存记录
test_record = CallbackRecord(
case_number="TS0B000249",
callback_methods=["打电话", "发微信"],
callback_record="[2025-01-28 15:30] 客户知道了\n[2025-01-28 15:31] 已安排下次复查",
operator="test_user"
)
record_id = db_manager.save_record(test_record)
print(f"测试记录保存成功,ID: {record_id}")
# 测试获取记录
records = db_manager.get_records_by_case_number("TS0B000249")
print(f"获取到 {len(records)} 条记录")
# 测试统计信息
stats = db_manager.get_statistics()
print(f"统计信息: {stats}")
else:
print("数据库连接失败,请检查配置")
except Exception as e:
print(f"测试过程中出现错误: {e}")
print("这通常是因为数据库连接信息不正确")
print("请编辑 mysql_config.ini 文件,填入正确的连接信息")
if __name__ == "__main__":
main()
\ No newline at end of file
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
门诊配置文件
定义所有门诊的基本信息和映射关系
"""
# 门诊映射配置 (基于实际数据分析结果)
CLINIC_MAPPING = {
'学前街门诊': {
'clinic_id': 'clinic_xuexian',
'clinic_name': '学前街门诊',
'json_file': '诊所患者json/学前街门诊.json',
'folder_name': 'clinic_xuexian',
'description': '通善学前街门诊',
'expected_patients': 765
},
'大丰门诊': {
'clinic_id': 'clinic_dafeng',
'clinic_name': '大丰门诊',
'json_file': '诊所患者json/大丰门诊.json',
'folder_name': 'clinic_dafeng',
'description': '通善大丰门诊',
'expected_patients': 598
},
'东亭门诊': {
'clinic_id': 'clinic_dongting',
'clinic_name': '东亭门诊',
'json_file': '诊所患者json/东亭门诊.json',
'folder_name': 'clinic_dongting',
'description': '通善东亭门诊',
'expected_patients': 479
},
'河埒门诊': {
'clinic_id': 'clinic_helai',
'clinic_name': '河埒门诊',
'json_file': '诊所患者json/河埒门诊.json',
'folder_name': 'clinic_helai',
'description': '通善河埒门诊',
'expected_patients': 108
},
'红豆门诊': {
'clinic_id': 'clinic_hongdou',
'clinic_name': '红豆门诊',
'json_file': '诊所患者json/红豆门诊.json',
'folder_name': 'clinic_hongdou',
'description': '通善红豆门诊',
'expected_patients': 500
},
'惠山门诊': {
'clinic_id': 'clinic_huishan',
'clinic_name': '惠山门诊',
'json_file': '诊所患者json/惠山门诊.json',
'folder_name': 'clinic_huishan',
'description': '通善惠山门诊',
'expected_patients': 323
},
'马山门诊': {
'clinic_id': 'clinic_mashan',
'clinic_name': '马山门诊',
'json_file': '诊所患者json/马山门诊.json',
'folder_name': 'clinic_mashan',
'description': '通善马山门诊',
'expected_patients': 527
},
'通善口腔医院': {
'clinic_id': 'clinic_hospital',
'clinic_name': '通善口腔医院',
'json_file': '诊所患者json/通善口腔医院.json',
'folder_name': 'clinic_hospital',
'description': '通善口腔医院总院',
'expected_patients': 536
},
'新吴门诊': {
'clinic_id': 'clinic_xinwu',
'clinic_name': '新吴门诊',
'json_file': '诊所患者json/新吴门诊.json',
'folder_name': 'clinic_xinwu',
'description': '通善新吴门诊',
'expected_patients': 297
}
}
# 快速访问映射
CLINIC_ID_TO_NAME = {info['clinic_id']: info['clinic_name'] for info in CLINIC_MAPPING.values()}
CLINIC_ID_TO_JSON = {info['clinic_id']: info['json_file'] for info in CLINIC_MAPPING.values()}
CLINIC_ID_TO_FOLDER = {info['clinic_id']: info['folder_name'] for info in CLINIC_MAPPING.values()}
# 默认用户配置 (基于实际门诊用户)
DEFAULT_USERS = [
# 学前街门诊 (3名用户)
{
'username': 'jinqin',
'password': 'jinqin123',
'role': 'clinic_user',
'clinic_id': 'clinic_xuexian',
'real_name': '金沁',
'clinic_name': '学前街门诊'
},
{
'username': 'renshanshan',
'password': 'renshanshan123',
'role': 'clinic_user',
'clinic_id': 'clinic_xuexian',
'real_name': '任姗姗',
'clinic_name': '学前街门诊'
},
{
'username': 'shaojun',
'password': 'shaojun123',
'role': 'clinic_user',
'clinic_id': 'clinic_xuexian',
'real_name': '邵军',
'clinic_name': '学前街门诊'
},
# 新吴门诊 (1名用户)
{
'username': 'litingting',
'password': 'litingting123',
'role': 'clinic_user',
'clinic_id': 'clinic_xinwu',
'real_name': '李婷婷',
'clinic_name': '新吴门诊'
},
# 红豆门诊 (2名用户)
{
'username': 'maqiuyi',
'password': 'maqiuyi123',
'role': 'clinic_user',
'clinic_id': 'clinic_hongdou',
'real_name': '马秋怡',
'clinic_name': '红豆门诊'
},
{
'username': 'tangqimin',
'password': 'tangqimin123',
'role': 'clinic_user',
'clinic_id': 'clinic_hongdou',
'real_name': '汤其敏',
'clinic_name': '红豆门诊'
},
# 东亭门诊 (1名用户)
{
'username': 'yueling',
'password': 'yueling123',
'role': 'clinic_user',
'clinic_id': 'clinic_dongting',
'real_name': '岳玲',
'clinic_name': '东亭门诊'
},
# 马山门诊 (2名用户)
{
'username': 'jijunlin',
'password': 'jijunlin123',
'role': 'clinic_user',
'clinic_id': 'clinic_mashan',
'real_name': '季军林',
'clinic_name': '马山门诊'
},
{
'username': 'zhouliping',
'password': 'zhouliping123',
'role': 'clinic_user',
'clinic_id': 'clinic_mashan',
'real_name': '周丽萍',
'clinic_name': '马山门诊'
},
# 通善口腔医院总院 (2名用户)
{
'username': 'feimiaomiao',
'password': 'feimiaomiao123',
'role': 'clinic_user',
'clinic_id': 'clinic_hospital',
'real_name': '费苗苗',
'clinic_name': '通善口腔医院'
},
{
'username': 'chenxinyu',
'password': 'chenxinyu123',
'role': 'clinic_user',
'clinic_id': 'clinic_helai',
'real_name': '陈心语',
'clinic_name': '河埒门诊'
},
# 惠山门诊 (2名用户)
{
'username': 'yanghong',
'password': 'yanghong123',
'role': 'clinic_user',
'clinic_id': 'clinic_huishan',
'real_name': '杨红',
'clinic_name': '惠山门诊'
},
{
'username': 'panjinli',
'password': 'panjinli123',
'role': 'clinic_user',
'clinic_id': 'clinic_huishan',
'real_name': '潘金丽',
'clinic_name': '惠山门诊'
},
# 大丰门诊 (1名用户)
{
'username': 'chenlin',
'password': 'chenlin123',
'role': 'clinic_user',
'clinic_id': 'clinic_dafeng',
'real_name': '陈琳',
'clinic_name': '大丰门诊'
},
# 总部管理员 (最高权限)
{
'username': 'admin',
'password': 'admin123',
'role': 'admin',
'clinic_id': None,
'real_name': '系统管理员',
'clinic_name': '总部管理'
}
]
# 系统配置
SYSTEM_CONFIG = {
'total_clinics': len(CLINIC_MAPPING),
'total_expected_patients': sum(info['expected_patients'] for info in CLINIC_MAPPING.values()),
'base_url': '/patient_profiles',
'shared_resources_path': '/shared',
'admin_path': '/admin'
}
def get_clinic_info(clinic_id):
"""根据clinic_id获取门诊信息"""
for info in CLINIC_MAPPING.values():
if info['clinic_id'] == clinic_id:
return info
return None
def get_clinic_by_name(clinic_name):
"""根据门诊名称获取门诊信息"""
return CLINIC_MAPPING.get(clinic_name)
def get_all_clinic_ids():
"""获取所有门诊ID列表"""
return [info['clinic_id'] for info in CLINIC_MAPPING.values()]
def get_user_by_username(username):
"""根据用户名获取用户信息"""
for user in DEFAULT_USERS:
if user['username'] == username:
return user
return None
if __name__ == "__main__":
print("🏥 门诊配置信息:")
print(f"门诊总数: {SYSTEM_CONFIG['total_clinics']}")
print(f"预期患者总数: {SYSTEM_CONFIG['total_expected_patients']}")
print(f"用户总数: {len(DEFAULT_USERS)}")
print("\n📋 门诊列表:")
for clinic_id in get_all_clinic_ids():
info = get_clinic_info(clinic_id)
print(f" {info['clinic_name']} ({clinic_id}) - {info['expected_patients']} 人")
print("\n👥 用户列表:")
for user in DEFAULT_USERS:
clinic_name = user.get('clinic_name', '总部')
print(f" {user['username']} ({user['real_name']}) - {user['role']} - {clinic_name}")
\ No newline at end of file
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
应用配置管理
"""
import os
class Config:
"""基础配置"""
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
# 数据库配置
DB_HOST = os.environ.get('DB_HOST', 'localhost')
DB_PORT = int(os.environ.get('DB_PORT', '3306'))
DB_USER = os.environ.get('DB_USER', 'callback_user')
DB_PASSWORD = os.environ.get('DB_PASSWORD', 'dev_password_123')
DB_NAME = os.environ.get('DB_NAME', 'callback_system')
DB_CHARSET = os.environ.get('DB_CHARSET', 'utf8mb4')
# SQLAlchemy 配置
SQLALCHEMY_DATABASE_URI = f"mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}?charset={DB_CHARSET}"
SQLALCHEMY_TRACK_MODIFICATIONS = False
SQLALCHEMY_ENGINE_OPTIONS = {
'pool_pre_ping': True,
'pool_recycle': 300,
}
class DevelopmentConfig(Config):
"""开发环境配置"""
DEBUG = True
FLASK_ENV = 'development'
class ProductionConfig(Config):
"""生产环境配置"""
DEBUG = False
FLASK_ENV = 'production'
# 配置映射
config = {
'development': DevelopmentConfig,
'production': ProductionConfig,
'default': DevelopmentConfig
}
# 门诊配置
CLINIC_CONFIG = {
'clinic_xuexian': {'clinic_name': '学前街门诊'},
'clinic_dafeng': {'clinic_name': '大丰门诊'},
'clinic_dongting': {'clinic_name': '东亭门诊'},
'clinic_helai': {'clinic_name': '河埒门诊'},
'clinic_hongdou': {'clinic_name': '红豆门诊'},
'clinic_huishan': {'clinic_name': '惠山门诊'},
'clinic_mashan': {'clinic_name': '马山门诊'},
'clinic_hospital': {'clinic_name': '通善口腔医院'},
'clinic_xinwu': {'clinic_name': '新吴门诊'}
}
# 用户配置
DEFAULT_USERS = [
# 学前街门诊 (3名用户)
{'username': 'jinqin', 'password': 'jinqin123', 'role': 'clinic_user', 'clinic_id': 'clinic_xuexian', 'real_name': '金沁', 'clinic_name': '学前街门诊'},
{'username': 'renshanshan', 'password': 'renshanshan123', 'role': 'clinic_user', 'clinic_id': 'clinic_xuexian', 'real_name': '任姗姗', 'clinic_name': '学前街门诊'},
{'username': 'shaojun', 'password': 'shaojun123', 'role': 'clinic_user', 'clinic_id': 'clinic_xuexian', 'real_name': '邵军', 'clinic_name': '学前街门诊'},
# 新吴门诊 (1名用户)
{'username': 'litingting', 'password': 'litingting123', 'role': 'clinic_user', 'clinic_id': 'clinic_xinwu', 'real_name': '李婷婷', 'clinic_name': '新吴门诊'},
# 红豆门诊 (2名用户)
{'username': 'maqiuyi', 'password': 'maqiuyi123', 'role': 'clinic_user', 'clinic_id': 'clinic_hongdou', 'real_name': '马秋怡', 'clinic_name': '红豆门诊'},
{'username': 'tangqimin', 'password': 'tangqimin123', 'role': 'clinic_user', 'clinic_id': 'clinic_hongdou', 'real_name': '汤其敏', 'clinic_name': '红豆门诊'},
# 东亭门诊 (1名用户)
{'username': 'yueling', 'password': 'yueling123', 'role': 'clinic_user', 'clinic_id': 'clinic_dongting', 'real_name': '岳玲', 'clinic_name': '东亭门诊'},
# 马山门诊 (2名用户)
{'username': 'jijunlin', 'password': 'jijunlin123', 'role': 'clinic_user', 'clinic_id': 'clinic_mashan', 'real_name': '季军林', 'clinic_name': '马山门诊'},
{'username': 'zhouliping', 'password': 'zhouliping123', 'role': 'clinic_user', 'clinic_id': 'clinic_mashan', 'real_name': '周丽萍', 'clinic_name': '马山门诊'},
# 通善口腔医院总院 (2名用户)
{'username': 'feimiaomiao', 'password': 'feimiaomiao123', 'role': 'clinic_user', 'clinic_id': 'clinic_hospital', 'real_name': '费苗苗', 'clinic_name': '通善口腔医院'},
{'username': 'chenxinyu', 'password': 'chenxinyu123', 'role': 'clinic_user', 'clinic_id': 'clinic_helai', 'real_name': '陈心语', 'clinic_name': '河埒门诊'},
# 惠山门诊 (2名用户)
{'username': 'yanghong', 'password': 'yanghong123', 'role': 'clinic_user', 'clinic_id': 'clinic_huishan', 'real_name': '杨红', 'clinic_name': '惠山门诊'},
{'username': 'panjinli', 'password': 'panjinli123', 'role': 'clinic_user', 'clinic_id': 'clinic_huishan', 'real_name': '潘金丽', 'clinic_name': '惠山门诊'},
# 大丰门诊 (1名用户)
{'username': 'chenlin', 'password': 'chenlin123', 'role': 'clinic_user', 'clinic_id': 'clinic_dafeng', 'real_name': '陈琳', 'clinic_name': '大丰门诊'},
# 总部管理员 (最高权限)
{'username': 'admin', 'password': 'admin123', 'role': 'admin', 'clinic_id': None, 'real_name': '系统管理员', 'clinic_name': '总部管理'}
]
def get_user_by_username(username):
"""根据用户名获取用户信息"""
for user in DEFAULT_USERS:
if user['username'] == username:
return user
return None
def get_clinic_info(clinic_id):
"""根据clinic_id获取门诊信息"""
return CLINIC_CONFIG.get(clinic_id, {'clinic_name': '未知门诊'})
-- 批量创建诊所用户SQL脚本
USE callback_system;
-- 学前街门诊用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active)
VALUES
('jinqin', SHA2('jinqin123', 256), 'user', '["clinic_xuexian"]', NOW(), 1),
('renshanshan', SHA2('renshanshan123', 256), 'user', '["clinic_xuexian"]', NOW(), 1),
('shaojun', SHA2('shaojun123', 256), 'user', '["clinic_xuexian"]', NOW(), 1)
ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash),
clinic_access = VALUES(clinic_access),
updated_at = NOW();
-- 新吴门诊用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active)
VALUES
('litingting', SHA2('litingting123', 256), 'user', '["clinic_xinwu"]', NOW(), 1)
ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash),
clinic_access = VALUES(clinic_access),
updated_at = NOW();
-- 红豆门诊用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active)
VALUES
('maqiuyi', SHA2('maqiuyi123', 256), 'user', '["clinic_hongdou"]', NOW(), 1),
('tangqimin', SHA2('tangqimin123', 256), 'user', '["clinic_hongdou"]', NOW(), 1)
ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash),
clinic_access = VALUES(clinic_access),
updated_at = NOW();
-- 东亭门诊用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active)
VALUES
('yueling', SHA2('yueling123', 256), 'user', '["clinic_dongting"]', NOW(), 1)
ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash),
clinic_access = VALUES(clinic_access),
updated_at = NOW();
-- 马山门诊用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active)
VALUES
('jijunlin', SHA2('jijunlin123', 256), 'user', '["clinic_mashan"]', NOW(), 1),
('zhouliping', SHA2('zhouliping123', 256), 'user', '["clinic_mashan"]', NOW(), 1)
ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash),
clinic_access = VALUES(clinic_access),
updated_at = NOW();
-- 总院用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active)
VALUES
('feimiaomiao', SHA2('feimiaomiao123', 256), 'user', '["clinic_hospital"]', NOW(), 1),
('chenxinyu', SHA2('chenxinyu123', 256), 'user', '["clinic_hospital"]', NOW(), 1)
ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash),
clinic_access = VALUES(clinic_access),
updated_at = NOW();
-- 惠山门诊用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active)
VALUES
('yanghong', SHA2('yanghong123', 256), 'user', '["clinic_huishan"]', NOW(), 1),
('panjinli', SHA2('panjinli123', 256), 'user', '["clinic_huishan"]', NOW(), 1)
ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash),
clinic_access = VALUES(clinic_access),
updated_at = NOW();
-- 大丰门诊用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active)
VALUES
('chenlin', SHA2('chenlin123', 256), 'user', '["clinic_dafeng"]', NOW(), 1)
ON DUPLICATE KEY UPDATE
password_hash = VALUES(password_hash),
clinic_access = VALUES(clinic_access),
updated_at = NOW();
-- 查看创建结果
SELECT
username,
user_type,
clinic_access,
created_at,
is_active
FROM users
WHERE user_type = 'user'
ORDER BY created_at DESC;
\ No newline at end of file
USE callback_system;
-- 清空现有用户表
TRUNCATE TABLE users;
-- 创建管理员用户 (admin/admin123)
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active)
VALUES ('admin', 'admin123', 'admin', '["all"]', NOW(), 1);
-- 学前街门诊用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active) VALUES
('jinqin', 'jinqin123', 'user', '["clinic_xuexian"]', NOW(), 1),
('renshanshan', 'renshanshan123', 'user', '["clinic_xuexian"]', NOW(), 1),
('shaojun', 'shaojun123', 'user', '["clinic_xuexian"]', NOW(), 1);
-- 新吴门诊用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active) VALUES
('litingting', 'litingting123', 'user', '["clinic_xinwu"]', NOW(), 1);
-- 红豆门诊用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active) VALUES
('maqiuyi', 'maqiuyi123', 'user', '["clinic_hongdou"]', NOW(), 1),
('tangqimin', 'tangqimin123', 'user', '["clinic_hongdou"]', NOW(), 1);
-- 东亭门诊用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active) VALUES
('yueling', 'yueling123', 'user', '["clinic_dongting"]', NOW(), 1);
-- 马山门诊用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active) VALUES
('jijunlin', 'jijunlin123', 'user', '["clinic_mashan"]', NOW(), 1),
('zhouliping', 'zhouliping123', 'user', '["clinic_mashan"]', NOW(), 1);
-- 总院用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active) VALUES
('feimiaomiao', 'feimiaomiao123', 'user', '["clinic_hospital"]', NOW(), 1),
('chenxinyu', 'chenxinyu123', 'user', '["clinic_hospital"]', NOW(), 1);
-- 惠山门诊用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active) VALUES
('yanghong', 'yanghong123', 'user', '["clinic_huishan"]', NOW(), 1),
('panjinli', 'panjinli123', 'user', '["clinic_huishan"]', NOW(), 1);
-- 大丰门诊用户
INSERT INTO users (username, password_hash, user_type, clinic_access, created_at, is_active) VALUES
('chenlin', 'chenlin123', 'user', '["clinic_dafeng"]', NOW(), 1);
\ No newline at end of file
...@@ -531,7 +531,7 @@ def main(): ...@@ -531,7 +531,7 @@ def main():
# 加载患者数据 # 加载患者数据
try: try:
# 尝试加载现有的患者数据文件 # 尝试加载现有的患者数据文件
data_files = ['漏诊客户画像.json', '合并结果.json'] data_files = ['漏诊客户画像.json', 'data/patients/merged/合并结果.json']
patients_data = None patients_data = None
for file_name in data_files: for file_name in data_files:
......
...@@ -30,7 +30,7 @@ class DifyConfig: ...@@ -30,7 +30,7 @@ class DifyConfig:
self.USER_ID = "callback_system" # 默认用户ID self.USER_ID = "callback_system" # 默认用户ID
# 输出配置 # 输出配置
self.OUTPUT_DIR = "dify_callback_results" # 结果输出目录 self.OUTPUT_DIR = "data/callbacks" # 结果输出目录
self.LOG_LEVEL = "INFO" # 日志级别 self.LOG_LEVEL = "INFO" # 日志级别
def get_headers(self) -> Dict[str, str]: def get_headers(self) -> Dict[str, str]:
......
...@@ -12,7 +12,7 @@ from typing import Dict, Any ...@@ -12,7 +12,7 @@ from typing import Dict, Any
class DatabaseConfig: class DatabaseConfig:
"""数据库配置管理类""" """数据库配置管理类"""
def __init__(self, config_file: str = "database_config.ini"): def __init__(self, config_file: str = "database/config/database_config.ini"):
""" """
初始化配置管理器 初始化配置管理器
......
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
SQLAlchemy数据库模型
定义所有数据库表的结构,用于Flask-Migrate自动迁移
"""
from flask_sqlalchemy import SQLAlchemy
from datetime import datetime
import json
db = SQLAlchemy()
class User(db.Model):
"""用户管理表"""
__tablename__ = 'users'
user_id = db.Column(db.Integer, primary_key=True, comment='用户ID')
username = db.Column(db.String(50), unique=True, nullable=False, comment='用户名')
password_hash = db.Column(db.String(255), nullable=False, comment='密码哈希')
user_type = db.Column(db.String(20), default='user', comment='用户类型')
clinic_access = db.Column(db.JSON, comment='诊所访问权限')
created_at = db.Column(db.DateTime, default=datetime.utcnow, comment='创建时间')
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment='更新时间')
is_active = db.Column(db.Boolean, default=True, comment='是否激活')
last_login = db.Column(db.DateTime, comment='最后登录时间')
# 索引
__table_args__ = (
db.Index('idx_user_type', 'user_type'),
db.Index('idx_is_active', 'is_active'),
{'comment': '用户管理表'}
)
def __repr__(self):
return f'<User {self.username}>'
class Clinic(db.Model):
"""诊所信息表"""
__tablename__ = 'clinics'
clinic_id = db.Column(db.Integer, primary_key=True, comment='诊所ID')
clinic_name = db.Column(db.String(100), nullable=False, comment='诊所名称')
clinic_code = db.Column(db.String(50), unique=True, nullable=False, comment='诊所代码')
clinic_address = db.Column(db.String(255), comment='诊所地址')
clinic_phone = db.Column(db.String(20), comment='诊所电话')
created_at = db.Column(db.DateTime, default=datetime.utcnow, comment='创建时间')
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment='更新时间')
is_active = db.Column(db.Boolean, default=True, comment='是否激活')
# 关系
patients = db.relationship('Patient', backref='clinic', lazy=True)
# 索引
__table_args__ = (
db.Index('idx_is_active', 'is_active'),
{'comment': '诊所信息表'}
)
def __repr__(self):
return f'<Clinic {self.clinic_name}>'
class Patient(db.Model):
"""患者信息表"""
__tablename__ = 'patients'
patient_id = db.Column(db.Integer, primary_key=True, comment='患者ID')
case_number = db.Column(db.String(50), unique=True, nullable=False, comment='病历号')
patient_name = db.Column(db.String(100), comment='患者姓名')
patient_phone = db.Column(db.String(20), comment='患者电话')
gender = db.Column(db.String(10), comment='性别')
age = db.Column(db.Integer, comment='年龄')
clinic_id = db.Column(db.Integer, db.ForeignKey('clinics.clinic_id'), comment='诊所ID')
clinic_name = db.Column(db.String(100), comment='诊所名称')
diagnosis = db.Column(db.JSON, comment='诊断信息')
treatment_date = db.Column(db.Date, comment='治疗日期')
created_at = db.Column(db.DateTime, default=datetime.utcnow, comment='创建时间')
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment='更新时间')
# 关系
callback_records = db.relationship('CallbackRecord', backref='patient', lazy=True, cascade='all, delete-orphan')
# 索引
__table_args__ = (
db.Index('idx_patient_name', 'patient_name'),
db.Index('idx_clinic_name', 'clinic_name'),
db.Index('idx_treatment_date', 'treatment_date'),
{'comment': '患者信息表'}
)
def to_dict(self):
"""转换为字典格式,兼容原有的JSON数据格式"""
return {
'姓名': self.patient_name,
'年龄': self.age,
'性别': self.gender,
'病历号': self.case_number,
'电话': self.patient_phone,
'门诊名称': self.clinic_name,
'诊断信息': self.diagnosis,
'治疗日期': self.treatment_date.isoformat() if self.treatment_date else None,
'创建时间': self.created_at.isoformat() if self.created_at else None,
'更新时间': self.updated_at.isoformat() if self.updated_at else None
}
def __repr__(self):
return f'<Patient {self.patient_name} ({self.case_number})>'
class CallbackRecord(db.Model):
"""回访记录表"""
__tablename__ = 'callback_records'
record_id = db.Column(db.Integer, primary_key=True, comment='记录ID')
case_number = db.Column(db.String(50), db.ForeignKey('patients.case_number'), nullable=False, comment='病历号')
callback_methods = db.Column(db.JSON, nullable=False, comment='回访方式(JSON格式)')
callback_record = db.Column(db.Text, nullable=False, comment='回访记录内容')
callback_result = db.Column(db.String(50), nullable=False, comment='回访结果(成功/不成功/放弃回访)')
next_appointment_time = db.Column(db.Text, comment='下次预约时间')
failure_reason = db.Column(db.Text, comment='不成功的原因')
abandon_reason = db.Column(db.Text, comment='放弃回访的原因')
ai_feedback_type = db.Column(db.String(100), comment='AI错误反馈类型')
failure_reason_note = db.Column(db.Text, comment='不成功备注')
abandon_reason_note = db.Column(db.Text, comment='放弃回访备注')
ai_feedback_note = db.Column(db.Text, comment='AI反馈备注')
callback_status = db.Column(db.String(50), nullable=False, default='已回访', comment='回访状态')
operator = db.Column(db.String(100), nullable=False, comment='操作员')
create_time = db.Column(db.DateTime, default=datetime.utcnow, comment='创建时间')
update_time = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, comment='更新时间')
# 索引
__table_args__ = (
db.Index('idx_case_number', 'case_number'),
db.Index('idx_create_time', 'create_time'),
db.Index('idx_operator', 'operator'),
db.Index('idx_callback_result', 'callback_result'),
{'comment': '回访记录表'}
)
@property
def callback_success(self):
"""为了向后兼容,提供callback_success属性"""
return self.callback_result == "成功"
def to_dict(self):
"""转换为字典格式"""
return {
'record_id': self.record_id,
'case_number': self.case_number,
'callback_methods': self.callback_methods if isinstance(self.callback_methods, list) else json.loads(self.callback_methods or '[]'),
'callback_record': self.callback_record,
'callback_result': self.callback_result,
'callback_success': self.callback_success,
'callback_status': self.callback_status,
'next_appointment_time': self.next_appointment_time,
'failure_reason': self.failure_reason,
'abandon_reason': self.abandon_reason,
'ai_feedback_type': self.ai_feedback_type,
'failure_reason_note': self.failure_reason_note,
'abandon_reason_note': self.abandon_reason_note,
'ai_feedback_note': self.ai_feedback_note,
'operator': self.operator,
'operator_name': self.operator, # 前端期望的字段名
'create_time': self.create_time.isoformat() if self.create_time else None,
'update_time': self.update_time.isoformat() if self.update_time else None
}
def __repr__(self):
return f'<CallbackRecord {self.case_number} - {self.callback_result}>'
# 数据库初始化函数
def init_db(app):
"""初始化数据库"""
db.init_app(app)
with app.app_context():
# 创建所有表
db.create_all()
# 默认数据插入由 app.py 中的 insert_default_data() 处理
# 默认数据插入功能已移至 app.py 中的 insert_default_data() 函数
# 避免重复插入门诊数据
#!/bin/bash
# 容器启动脚本 - 零配置自动迁移
# 功能:
# 1. 等待数据库就绪
# 2. 自动执行数据库迁移
# 3. 启动Flask应用
set -e # 遇到错误立即退出
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 日志函数
log_info() {
echo -e "${BLUE}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1"
}
# 错误处理函数
handle_error() {
log_error "容器启动过程中发生错误!"
log_error "错误发生在第 $1 行"
exit 1
}
# 设置错误处理
trap 'handle_error $LINENO' ERR
log_info "🚀 患者画像回访话术系统 - 容器启动"
log_info "📍 工作目录: $(pwd)"
log_info "📍 Python版本: $(python --version)"
# 第一步:等待数据库就绪
log_info "📋 第一步:等待数据库就绪..."
python database/scripts/wait-for-db.py
log_success "数据库连接就绪"
# 第二步:初始化Flask-Migrate(如果需要)
log_info "📋 第二步:检查Flask-Migrate状态..."
# 检查migrations目录是否存在
if [ ! -d "migrations" ]; then
log_info "初始化Flask-Migrate..."
export FLASK_APP=app.py
flask db init
log_success "Flask-Migrate初始化完成"
else
log_info "Flask-Migrate已初始化"
fi
# 第三步:检查是否需要生成初始迁移
log_info "📋 第三步:检查迁移文件..."
# 检查是否有迁移文件
MIGRATION_FILES=$(find migrations/versions -name "*.py" 2>/dev/null | wc -l)
if [ "$MIGRATION_FILES" -eq 0 ]; then
log_info "生成初始迁移文件..."
export FLASK_APP=app.py
flask db migrate -m "Initial migration"
log_success "初始迁移文件生成完成"
else
log_info "发现 $MIGRATION_FILES 个迁移文件"
fi
# 第四步:执行数据库迁移
log_info "📋 第四步:执行数据库迁移..."
export FLASK_APP=app.py
# 检查数据库当前状态
log_info "检查数据库迁移状态..."
flask db current || log_warning "无法获取当前迁移状态(可能是首次运行)"
# 执行迁移
log_info "执行数据库迁移..."
flask db upgrade
log_success "数据库迁移完成"
# 第五步:数据导入(如果需要)
log_info "📋 第五步:检查数据导入需求..."
# 检查是否有患者数据需要导入
if [ -f "database/scripts/safe_import_patients.py" ] && [ -d "data/patients/clinics" ]; then
log_info "发现患者数据,执行数据导入..."
python database/scripts/safe_import_patients.py || log_warning "数据导入失败,但继续启动应用"
log_success "数据导入完成"
else
log_info "无需数据导入"
fi
# 第六步:启动Flask应用
log_info "📋 第六步:启动Flask应用..."
log_info "🌐 应用将在端口 5000 启动"
log_info "🔗 健康检查端点: http://localhost:5000/api/health"
# 启动应用
log_success "🎉 容器启动完成,开始运行应用..."
exec python app.py
...@@ -6,6 +6,7 @@ ...@@ -6,6 +6,7 @@
import os import os
import json import json
import io
from datetime import datetime from datetime import datetime
from typing import Dict, List, Any from typing import Dict, List, Any
import pymysql import pymysql
...@@ -175,6 +176,38 @@ class DataExporter: ...@@ -175,6 +176,38 @@ class DataExporter:
wb.save(output_path) wb.save(output_path)
return output_path return output_path
def export_to_memory(self) -> bytes:
"""导出数据到内存缓冲区,返回二进制数据"""
# 获取数据
clinic_data = self.get_clinic_data()
# 创建工作簿
wb = Workbook()
# 创建总览表
self._create_summary_sheet(wb, clinic_data)
# 创建各诊所详细表
print(f"开始创建诊所详细表...")
for clinic_name, data in clinic_data.items():
if data['records']:
print(f"创建诊所表: {clinic_name} - {data['total_count']} 条记录)")
self._create_clinic_sheet(wb, clinic_name, data)
else:
print(f"跳过空诊所: {clinic_name} - {data['total_count']} 条记录)")
print(f"Excel工作表列表: {wb.sheetnames}")
# 删除默认的Sheet
if 'Sheet' in wb.sheetnames:
wb.remove(wb['Sheet'])
# 保存到内存缓冲区
buffer = io.BytesIO()
wb.save(buffer)
buffer.seek(0)
return buffer.getvalue()
def _create_summary_sheet(self, wb: Workbook, clinic_data: Dict[str, Any]): def _create_summary_sheet(self, wb: Workbook, clinic_data: Dict[str, Any]):
"""创建总览表""" """创建总览表"""
ws = wb.create_sheet("总览") ws = wb.create_sheet("总览")
......
...@@ -250,15 +250,15 @@ def main(): ...@@ -250,15 +250,15 @@ def main():
# 定义门诊和对应的JSON文件 # 定义门诊和对应的JSON文件
clinics = [ clinics = [
('学前街门诊', '学前街门诊.json'), # 根目录文件 ('学前街门诊', 'data/patients/clinics/学前街门诊.json'),
('大丰门诊', '诊所患者json/大丰门诊.json'), ('大丰门诊', 'data/patients/clinics/大丰门诊.json'),
('东亭门诊', '诊所患者json/东亭门诊.json'), ('东亭门诊', 'data/patients/clinics/东亭门诊.json'),
('河埒门诊', '诊所患者json/河埒门诊.json'), ('河埒门诊', 'data/patients/clinics/河埒门诊.json'),
('红豆门诊', '诊所患者json/红豆门诊.json'), ('红豆门诊', 'data/patients/clinics/红豆门诊.json'),
('惠山门诊', '诊所患者json/惠山门诊.json'), ('惠山门诊', 'data/patients/clinics/惠山门诊.json'),
('马山门诊', '诊所患者json/马山门诊.json'), ('马山门诊', 'data/patients/clinics/马山门诊.json'),
('通善口腔医院', '诊所患者json/通善口腔医院.json'), ('通善口腔医院', 'data/patients/clinics/通善口腔医院.json'),
('新吴门诊', '诊所患者json/新吴门诊.json') ('新吴门诊', 'data/patients/clinics/新吴门诊.json')
] ]
total_added = 0 total_added = 0
......
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
数据库等待脚本
等待MySQL数据库完全启动并可连接后再继续
"""
import os
import sys
import time
import pymysql
from datetime import datetime
def get_db_config():
"""获取数据库配置"""
return {
'host': os.getenv('DB_HOST', 'mysql'),
'port': int(os.getenv('DB_PORT', 3306)),
'user': os.getenv('DB_USER', 'callback_user'),
'password': os.getenv('DB_PASSWORD', 'dev_password_123'),
'database': os.getenv('DB_NAME', 'callback_system'),
'charset': os.getenv('DB_CHARSET', 'utf8mb4')
}
def test_connection(config, max_retries=30, retry_interval=2):
"""
测试数据库连接
Args:
config: 数据库配置
max_retries: 最大重试次数
retry_interval: 重试间隔(秒)
Returns:
bool: 连接是否成功
"""
print(f"🔍 等待数据库连接就绪...")
print(f"📍 数据库地址: {config['host']}:{config['port']}")
print(f"📍 数据库名称: {config['database']}")
print(f"📍 最大重试次数: {max_retries}")
for attempt in range(1, max_retries + 1):
try:
print(f"⏳ 尝试连接数据库 ({attempt}/{max_retries})...")
# 尝试连接数据库
connection = pymysql.connect(**config)
# 测试基本查询
with connection.cursor() as cursor:
cursor.execute("SELECT 1")
result = cursor.fetchone()
connection.close()
if result:
print(f"✅ 数据库连接成功!(耗时: {(attempt-1) * retry_interval}秒)")
return True
except pymysql.Error as e:
error_code = getattr(e, 'args', [None])[0]
# 常见的连接错误
if error_code in [2003, 2006, 2013]: # 连接被拒绝、连接丢失、连接超时
print(f"⏳ 数据库尚未就绪,等待 {retry_interval} 秒后重试...")
elif error_code == 1049: # 数据库不存在
print(f"❌ 数据库 '{config['database']}' 不存在")
return False
elif error_code == 1045: # 访问被拒绝
print(f"❌ 数据库认证失败,请检查用户名和密码")
return False
else:
print(f"❌ 数据库连接错误: {e}")
if attempt < max_retries:
time.sleep(retry_interval)
else:
print(f"❌ 数据库连接失败,已达到最大重试次数 ({max_retries})")
return False
except Exception as e:
print(f"❌ 未知错误: {e}")
if attempt < max_retries:
time.sleep(retry_interval)
else:
return False
return False
def check_database_exists(config):
"""检查数据库是否存在"""
try:
# 连接到MySQL服务器(不指定数据库)
server_config = config.copy()
del server_config['database']
connection = pymysql.connect(**server_config)
with connection.cursor() as cursor:
cursor.execute("SHOW DATABASES LIKE %s", (config['database'],))
result = cursor.fetchone()
connection.close()
if result:
print(f"✅ 数据库 '{config['database']}' 存在")
return True
else:
print(f"❌ 数据库 '{config['database']}' 不存在")
return False
except Exception as e:
print(f"❌ 检查数据库存在性失败: {e}")
return False
def create_database_if_not_exists(config):
"""如果数据库不存在则创建"""
try:
# 连接到MySQL服务器(不指定数据库)
server_config = config.copy()
del server_config['database']
connection = pymysql.connect(**server_config)
with connection.cursor() as cursor:
# 创建数据库
cursor.execute(f"""
CREATE DATABASE IF NOT EXISTS `{config['database']}`
CHARACTER SET utf8mb4
COLLATE utf8mb4_unicode_ci
""")
print(f"✅ 数据库 '{config['database']}' 创建成功")
connection.close()
return True
except Exception as e:
print(f"❌ 创建数据库失败: {e}")
return False
def main():
"""主函数"""
print("🚀 数据库等待脚本启动")
print(f"🕐 启动时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
# 获取数据库配置
config = get_db_config()
# 首先等待MySQL服务器启动
print("\n📋 第一步:等待MySQL服务器启动...")
server_config = config.copy()
del server_config['database']
if not test_connection(server_config):
print("❌ MySQL服务器连接失败")
sys.exit(1)
# 检查并创建数据库
print("\n📋 第二步:检查数据库存在性...")
if not check_database_exists(config):
print("📋 数据库不存在,尝试创建...")
if not create_database_if_not_exists(config):
print("❌ 数据库创建失败")
sys.exit(1)
# 测试完整的数据库连接
print("\n📋 第三步:测试完整数据库连接...")
if not test_connection(config):
print("❌ 数据库连接失败")
sys.exit(1)
print("\n🎉 数据库就绪!可以开始应用启动流程")
print(f"🕐 完成时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
return 0
if __name__ == "__main__":
sys.exit(main())
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
回访记录数据库管理工具
功能:查看、搜索、导出、统计回访记录
"""
import sqlite3
import json
import csv
from datetime import datetime
import os
class CallbackRecordViewer:
def __init__(self, db_path="callback_records.db"):
self.db_path = db_path
self.ensure_database_exists()
def ensure_database_exists(self):
"""确保数据库文件存在"""
if not os.path.exists(self.db_path):
print(f"❌ 数据库文件不存在: {self.db_path}")
return False
return True
def get_all_records(self):
"""获取所有回访记录"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT record_id, case_number, callback_methods, callback_record,
operator, create_time
FROM callback_records
ORDER BY create_time DESC
''')
return cursor.fetchall()
except Exception as e:
print(f"❌ 获取记录失败: {e}")
return []
def search_records(self, keyword="", case_number="", operator=""):
"""搜索回访记录"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
conditions = []
params = []
if keyword:
conditions.append("callback_record LIKE ?")
params.append(f"%{keyword}%")
if case_number:
conditions.append("case_number LIKE ?")
params.append(f"%{case_number}%")
if operator:
conditions.append("operator LIKE ?")
params.append(f"%{operator}%")
where_clause = " AND ".join(conditions) if conditions else "1=1"
sql = f'''
SELECT record_id, case_number, callback_methods, callback_record,
operator, create_time
FROM callback_records
WHERE {where_clause}
ORDER BY create_time DESC
'''
cursor.execute(sql, params)
return cursor.fetchall()
except Exception as e:
print(f"❌ 搜索失败: {e}")
return []
def get_statistics(self):
"""获取统计信息"""
try:
with sqlite3.connect(self.db_path) as conn:
cursor = conn.cursor()
stats = {}
# 总记录数
cursor.execute("SELECT COUNT(*) FROM callback_records")
stats['total_records'] = cursor.fetchone()[0]
# 今日记录数
cursor.execute('''
SELECT COUNT(*) FROM callback_records
WHERE DATE(create_time) = DATE('now')
''')
stats['today_records'] = cursor.fetchone()[0]
# 本周记录数
cursor.execute('''
SELECT COUNT(*) FROM callback_records
WHERE DATE(create_time) >= DATE('now', '-7 days')
''')
stats['week_records'] = cursor.fetchone()[0]
# 按病历号统计
cursor.execute('''
SELECT case_number, COUNT(*) as count
FROM callback_records
GROUP BY case_number
ORDER BY count DESC
''')
stats['case_stats'] = cursor.fetchall()
# 按操作员统计
cursor.execute('''
SELECT operator, COUNT(*) as count
FROM callback_records
GROUP BY operator
ORDER BY count DESC
''')
stats['operator_stats'] = cursor.fetchall()
# 按回访方式统计
method_stats = {}
cursor.execute("SELECT callback_methods FROM callback_records")
for row in cursor.fetchall():
try:
methods = json.loads(row[0])
for method in methods:
method_stats[method] = method_stats.get(method, 0) + 1
except:
pass
stats['method_stats'] = method_stats
return stats
except Exception as e:
print(f"❌ 获取统计信息失败: {e}")
return {}
def format_record(self, record):
"""格式化单条记录显示"""
record_id, case_number, callback_methods_json, callback_record, operator, create_time = record
# 解析回访方式
try:
callback_methods = json.loads(callback_methods_json)
methods_str = ", ".join(callback_methods)
except:
methods_str = callback_methods_json
# 格式化时间
try:
if create_time:
create_time_obj = datetime.fromisoformat(create_time.replace('Z', '+00:00'))
time_str = create_time_obj.strftime('%Y-%m-%d %H:%M:%S')
else:
time_str = "未知时间"
except:
time_str = str(create_time)
return {
'id': record_id,
'case_number': case_number,
'methods': methods_str,
'operator': operator,
'time': time_str,
'content': callback_record
}
def display_records(self, records, title="回访记录"):
"""显示记录列表"""
if not records:
print("📝 没有找到匹配的记录")
return
print(f"📊 {title} (共 {len(records)} 条)")
print("=" * 80)
for i, record in enumerate(records, 1):
formatted = self.format_record(record)
print(f"📋 记录 #{i} (ID: {formatted['id']})")
print(f" 病历号: {formatted['case_number']}")
print(f" 回访方式: {formatted['methods']}")
print(f" 操作员: {formatted['operator']}")
print(f" 创建时间: {formatted['time']}")
print(f" 回访内容:")
# 格式化显示回访内容
content_lines = formatted['content'].split('\n')
for line in content_lines:
if line.strip():
print(f" {line}")
print("-" * 60)
def display_statistics(self):
"""显示统计信息"""
stats = self.get_statistics()
if not stats:
return
print("\n📈 数据库统计信息")
print("=" * 50)
print(f"📊 总体统计:")
print(f" 总记录数: {stats['total_records']}")
print(f" 今日记录: {stats['today_records']}")
print(f" 本周记录: {stats['week_records']}")
print(f" 涉及患者: {len(stats['case_stats'])} 人")
if stats['case_stats']:
print(f"\n👥 患者记录统计 (前10名):")
for case_number, count in stats['case_stats'][:10]:
print(f" {case_number}: {count} 条")
if stats['operator_stats']:
print(f"\n👨‍💼 操作员统计:")
for operator, count in stats['operator_stats']:
print(f" {operator}: {count} 条")
if stats['method_stats']:
print(f"\n📞 回访方式统计:")
for method, count in stats['method_stats'].items():
print(f" {method}: {count} 次")
def export_to_csv(self, filename="callback_records_export.csv"):
"""导出记录到CSV文件"""
records = self.get_all_records()
if not records:
print("❌ 没有数据可导出")
return
try:
with open(filename, 'w', newline='', encoding='utf-8-sig') as csvfile:
writer = csv.writer(csvfile)
# 写入表头
writer.writerow(['记录ID', '病历号', '回访方式', '回访内容', '操作员', '创建时间'])
# 写入数据
for record in records:
formatted = self.format_record(record)
writer.writerow([
formatted['id'],
formatted['case_number'],
formatted['methods'],
formatted['content'].replace('\n', ' | '), # 将换行符替换为分隔符
formatted['operator'],
formatted['time']
])
print(f"✅ 数据已导出到: {filename}")
print(f"📄 共导出 {len(records)} 条记录")
except Exception as e:
print(f"❌ 导出失败: {e}")
def find_all_databases():
"""查找所有数据库文件"""
db_files = []
# 当前目录
for file in os.listdir('.'):
if file.endswith('.db'):
db_files.append(f"./{file}")
# patient_profiles目录
if os.path.exists('patient_profiles'):
for file in os.listdir('patient_profiles'):
if file.endswith('.db'):
db_files.append(f"./patient_profiles/{file}")
return db_files
def main():
"""主函数 - 交互式菜单"""
print("🗄️ 回访记录数据库管理工具")
print("=" * 50)
# 查找数据库文件
db_files = find_all_databases()
if not db_files:
print("❌ 未找到任何数据库文件")
return
# 选择数据库
if len(db_files) == 1:
selected_db = db_files[0]
print(f"📖 使用数据库: {selected_db}")
else:
print(f"📁 找到 {len(db_files)} 个数据库文件:")
for i, db_file in enumerate(db_files, 1):
file_size = os.path.getsize(db_file)
print(f" {i}. {db_file} ({file_size} bytes)")
try:
choice = input(f"\n请选择数据库 (1-{len(db_files)}): ").strip()
if choice.isdigit():
index = int(choice) - 1
if 0 <= index < len(db_files):
selected_db = db_files[index]
else:
print("❌ 无效的选择")
return
else:
print("❌ 请输入数字")
return
except KeyboardInterrupt:
print("\n👋 已取消")
return
# 初始化查看器
viewer = CallbackRecordViewer(selected_db)
# 交互式菜单
while True:
print(f"\n📋 数据库管理菜单 ({selected_db})")
print("-" * 40)
print("1. 查看所有记录")
print("2. 搜索记录")
print("3. 查看统计信息")
print("4. 导出到CSV")
print("5. 按病历号查看")
print("6. 按操作员查看")
print("0. 退出")
try:
choice = input("\n请选择操作 (0-6): ").strip()
if choice == '0':
print("👋 再见!")
break
elif choice == '1':
records = viewer.get_all_records()
viewer.display_records(records, "所有回访记录")
elif choice == '2':
print("\n🔍 搜索条件 (直接回车跳过):")
keyword = input("关键词 (搜索回访内容): ").strip()
case_number = input("病历号: ").strip()
operator = input("操作员: ").strip()
records = viewer.search_records(keyword, case_number, operator)
viewer.display_records(records, "搜索结果")
elif choice == '3':
viewer.display_statistics()
elif choice == '4':
filename = input("导出文件名 (默认: callback_records_export.csv): ").strip()
if not filename:
filename = "callback_records_export.csv"
viewer.export_to_csv(filename)
elif choice == '5':
case_number = input("请输入病历号: ").strip()
if case_number:
records = viewer.search_records(case_number=case_number)
viewer.display_records(records, f"病历号 {case_number} 的记录")
elif choice == '6':
operator = input("请输入操作员名称: ").strip()
if operator:
records = viewer.search_records(operator=operator)
viewer.display_records(records, f"操作员 {operator} 的记录")
else:
print("❌ 无效的选择,请重新输入")
except KeyboardInterrupt:
print("\n👋 已退出")
break
except Exception as e:
print(f"❌ 操作失败: {e}")
if __name__ == "__main__":
main()
\ No newline at end of file
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
数据库迁移管理器
提供安全的数据库结构更新和数据迁移功能
"""
import os
import re
import pymysql
import hashlib
from datetime import datetime
from typing import List, Dict, Optional, Tuple
import logging
import json
class DatabaseMigrationManager:
"""数据库迁移管理器"""
def __init__(self, db_config: Dict, migrations_dir: str = "migrations"):
"""
初始化迁移管理器
Args:
db_config: 数据库配置
migrations_dir: 迁移文件目录
"""
self.db_config = db_config
self.migrations_dir = migrations_dir
self.logger = self._setup_logger()
# 确保迁移目录存在
os.makedirs(migrations_dir, exist_ok=True)
# 初始化迁移历史表
self._init_migration_table()
def _setup_logger(self) -> logging.Logger:
"""设置日志记录器"""
logger = logging.getLogger('migration_manager')
logger.setLevel(logging.INFO)
if not logger.handlers:
handler = logging.StreamHandler()
formatter = logging.Formatter(
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
)
handler.setFormatter(formatter)
logger.addHandler(handler)
return logger
def _get_connection(self):
"""获取数据库连接"""
return pymysql.connect(**self.db_config)
def _init_migration_table(self):
"""初始化迁移历史表"""
try:
connection = self._get_connection()
cursor = connection.cursor()
# 创建迁移历史表
cursor.execute("""
CREATE TABLE IF NOT EXISTS migration_history (
id INT AUTO_INCREMENT PRIMARY KEY,
version VARCHAR(50) NOT NULL UNIQUE,
filename VARCHAR(255) NOT NULL,
checksum VARCHAR(64) NOT NULL,
executed_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
execution_time_ms INT,
status ENUM('SUCCESS', 'FAILED', 'ROLLED_BACK') DEFAULT 'SUCCESS',
error_message TEXT,
INDEX idx_version (version),
INDEX idx_executed_at (executed_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
""")
connection.commit()
self.logger.info("✅ 迁移历史表初始化完成")
except Exception as e:
self.logger.error(f"❌ 初始化迁移历史表失败: {e}")
raise
finally:
if 'connection' in locals():
connection.close()
def create_migration(self, name: str, description: str = "") -> str:
"""
创建新的迁移文件
Args:
name: 迁移名称
description: 迁移描述
Returns:
创建的迁移文件路径
"""
# 生成版本号(时间戳格式)
version = datetime.now().strftime("%Y%m%d_%H%M%S")
# 清理文件名
clean_name = re.sub(r'[^\w\-_]', '_', name.lower())
filename = f"{version}_{clean_name}.sql"
filepath = os.path.join(self.migrations_dir, filename)
# 创建迁移文件模板
template = f"""-- Migration: {name}
-- Version: {version}
-- Description: {description}
-- Created: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
-- ==========================================
-- UP Migration (执行迁移)
-- ==========================================
-- 在这里添加你的迁移SQL语句
-- 例如:
-- ALTER TABLE users ADD COLUMN email VARCHAR(255);
-- CREATE INDEX idx_users_email ON users(email);
-- ==========================================
-- DOWN Migration (回滚迁移)
-- ==========================================
-- 注意:DOWN部分用于回滚,请确保操作的可逆性
-- 在这里添加回滚SQL语句
-- 例如:
-- DROP INDEX idx_users_email ON users;
-- ALTER TABLE users DROP COLUMN email;
-- ==========================================
-- 验证脚本 (可选)
-- ==========================================
-- 在这里添加验证SQL,确保迁移成功
-- 例如:
-- SELECT COUNT(*) FROM information_schema.columns
-- WHERE table_name = 'users' AND column_name = 'email';
"""
with open(filepath, 'w', encoding='utf-8') as f:
f.write(template)
self.logger.info(f"✅ 创建迁移文件: {filepath}")
return filepath
def get_pending_migrations(self) -> List[str]:
"""获取待执行的迁移文件"""
try:
connection = self._get_connection()
cursor = connection.cursor()
# 获取已执行的迁移版本
cursor.execute("SELECT version FROM migration_history WHERE status = 'SUCCESS'")
executed_versions = {row[0] for row in cursor.fetchall()}
# 获取所有迁移文件
migration_files = []
for filename in os.listdir(self.migrations_dir):
if filename.endswith('.sql'):
version = filename.split('_')[0] + '_' + filename.split('_')[1]
if version not in executed_versions:
migration_files.append(filename)
# 按版本排序
migration_files.sort()
return migration_files
except Exception as e:
self.logger.error(f"❌ 获取待执行迁移失败: {e}")
raise
finally:
if 'connection' in locals():
connection.close()
def _calculate_checksum(self, content: str) -> str:
"""计算文件内容的校验和"""
return hashlib.sha256(content.encode('utf-8')).hexdigest()
def _parse_migration_file(self, filepath: str) -> Dict:
"""解析迁移文件"""
with open(filepath, 'r', encoding='utf-8') as f:
content = f.read()
# 提取UP和DOWN部分
up_match = re.search(r'-- UP Migration.*?\n(.*?)-- DOWN Migration', content, re.DOTALL)
down_match = re.search(r'-- DOWN Migration.*?\n(.*?)-- ==========================================', content, re.DOTALL)
up_sql = up_match.group(1).strip() if up_match else ""
down_sql = down_match.group(1).strip() if down_match else ""
# 清理SQL(移除注释和空行)
up_sql = self._clean_sql(up_sql)
down_sql = self._clean_sql(down_sql)
return {
'content': content,
'up_sql': up_sql,
'down_sql': down_sql,
'checksum': self._calculate_checksum(content)
}
def _clean_sql(self, sql: str) -> str:
"""清理SQL语句"""
lines = []
for line in sql.split('\n'):
line = line.strip()
if line and not line.startswith('--'):
lines.append(line)
return '\n'.join(lines)
def _create_backup(self) -> str:
"""创建数据库备份"""
backup_dir = "database_backups"
os.makedirs(backup_dir, exist_ok=True)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_file = os.path.join(backup_dir, f"migration_backup_{timestamp}.sql")
try:
# 使用mysqldump创建备份
import subprocess
cmd = [
'mysqldump',
f'--host={self.db_config["host"]}',
f'--port={self.db_config["port"]}',
f'--user={self.db_config["user"]}',
f'--password={self.db_config["password"]}',
'--single-transaction',
'--routines',
'--triggers',
self.db_config['database']
]
with open(backup_file, 'w') as f:
subprocess.run(cmd, stdout=f, check=True)
self.logger.info(f"✅ 数据库备份完成: {backup_file}")
return backup_file
except Exception as e:
self.logger.error(f"❌ 数据库备份失败: {e}")
raise
def execute_migration(self, filename: str, dry_run: bool = False) -> bool:
"""
执行单个迁移文件
Args:
filename: 迁移文件名
dry_run: 是否为试运行模式
Returns:
执行是否成功
"""
filepath = os.path.join(self.migrations_dir, filename)
if not os.path.exists(filepath):
raise FileNotFoundError(f"迁移文件不存在: {filepath}")
# 解析迁移文件
migration_data = self._parse_migration_file(filepath)
version = filename.split('_')[0] + '_' + filename.split('_')[1]
if dry_run:
self.logger.info(f"🔍 试运行模式 - 迁移: {filename}")
self.logger.info(f"SQL内容:\n{migration_data['up_sql']}")
return True
# 创建备份
backup_file = self._create_backup()
connection = None
start_time = datetime.now()
try:
connection = self._get_connection()
connection.begin() # 开始事务
cursor = connection.cursor()
self.logger.info(f"🚀 执行迁移: {filename}")
# 执行UP SQL
if migration_data['up_sql']:
# 分割SQL语句(处理多个语句)
statements = [stmt.strip() for stmt in migration_data['up_sql'].split(';') if stmt.strip()]
for stmt in statements:
self.logger.debug(f"执行SQL: {stmt[:100]}...")
cursor.execute(stmt)
# 记录迁移历史
execution_time = int((datetime.now() - start_time).total_seconds() * 1000)
cursor.execute("""
INSERT INTO migration_history
(version, filename, checksum, execution_time_ms, status)
VALUES (%s, %s, %s, %s, 'SUCCESS')
""", (version, filename, migration_data['checksum'], execution_time))
connection.commit()
self.logger.info(f"✅ 迁移执行成功: {filename} (耗时: {execution_time}ms)")
return True
except Exception as e:
if connection:
connection.rollback()
# 记录失败信息
try:
cursor.execute("""
INSERT INTO migration_history
(version, filename, checksum, status, error_message)
VALUES (%s, %s, %s, 'FAILED', %s)
""", (version, filename, migration_data['checksum'], str(e)))
connection.commit()
except:
pass
self.logger.error(f"❌ 迁移执行失败: {filename} - {e}")
return False
finally:
if connection:
connection.close()
def migrate(self, target_version: Optional[str] = None, dry_run: bool = False) -> bool:
"""
执行所有待执行的迁移
Args:
target_version: 目标版本(可选)
dry_run: 是否为试运行模式
Returns:
是否全部执行成功
"""
pending_migrations = self.get_pending_migrations()
if not pending_migrations:
self.logger.info("✅ 没有待执行的迁移")
return True
# 过滤到目标版本
if target_version:
filtered_migrations = []
for migration in pending_migrations:
version = migration.split('_')[0] + '_' + migration.split('_')[1]
filtered_migrations.append(migration)
if version == target_version:
break
pending_migrations = filtered_migrations
self.logger.info(f"📋 待执行迁移数量: {len(pending_migrations)}")
success_count = 0
for migration in pending_migrations:
if self.execute_migration(migration, dry_run):
success_count += 1
else:
self.logger.error(f"❌ 迁移失败,停止执行: {migration}")
break
if success_count == len(pending_migrations):
self.logger.info(f"🎉 所有迁移执行完成! ({success_count}/{len(pending_migrations)})")
return True
else:
self.logger.error(f"⚠️ 部分迁移失败: ({success_count}/{len(pending_migrations)})")
return False
def rollback_migration(self, version: str) -> bool:
"""
回滚指定版本的迁移
Args:
version: 要回滚的版本号
Returns:
回滚是否成功
"""
try:
connection = self._get_connection()
cursor = connection.cursor()
# 检查迁移是否存在且已执行
cursor.execute("""
SELECT filename, checksum FROM migration_history
WHERE version = %s AND status = 'SUCCESS'
""", (version,))
result = cursor.fetchone()
if not result:
self.logger.error(f"❌ 未找到已执行的迁移版本: {version}")
return False
filename, checksum = result
filepath = os.path.join(self.migrations_dir, filename)
if not os.path.exists(filepath):
self.logger.error(f"❌ 迁移文件不存在: {filepath}")
return False
# 解析迁移文件
migration_data = self._parse_migration_file(filepath)
# 验证校验和
if migration_data['checksum'] != checksum:
self.logger.error(f"❌ 迁移文件校验和不匹配,可能已被修改: {filename}")
return False
if not migration_data['down_sql']:
self.logger.error(f"❌ 迁移文件缺少DOWN部分,无法回滚: {filename}")
return False
# 创建备份
backup_file = self._create_backup()
connection.begin() # 开始事务
self.logger.info(f"🔄 回滚迁移: {filename}")
# 执行DOWN SQL
statements = [stmt.strip() for stmt in migration_data['down_sql'].split(';') if stmt.strip()]
for stmt in statements:
self.logger.debug(f"执行回滚SQL: {stmt[:100]}...")
cursor.execute(stmt)
# 更新迁移历史状态
cursor.execute("""
UPDATE migration_history
SET status = 'ROLLED_BACK'
WHERE version = %s
""", (version,))
connection.commit()
self.logger.info(f"✅ 迁移回滚成功: {filename}")
return True
except Exception as e:
if 'connection' in locals() and connection:
connection.rollback()
self.logger.error(f"❌ 迁移回滚失败: {version} - {e}")
return False
finally:
if 'connection' in locals():
connection.close()
def get_migration_status(self) -> Dict:
"""获取迁移状态信息"""
try:
connection = self._get_connection()
cursor = connection.cursor()
# 获取迁移历史
cursor.execute("""
SELECT version, filename, status, executed_at, execution_time_ms, error_message
FROM migration_history
ORDER BY executed_at DESC
""")
history = []
for row in cursor.fetchall():
history.append({
'version': row[0],
'filename': row[1],
'status': row[2],
'executed_at': row[3].strftime('%Y-%m-%d %H:%M:%S') if row[3] else None,
'execution_time_ms': row[4],
'error_message': row[5]
})
# 获取待执行迁移
pending = self.get_pending_migrations()
return {
'executed_count': len([h for h in history if h['status'] == 'SUCCESS']),
'failed_count': len([h for h in history if h['status'] == 'FAILED']),
'rolled_back_count': len([h for h in history if h['status'] == 'ROLLED_BACK']),
'pending_count': len(pending),
'history': history,
'pending_migrations': pending
}
except Exception as e:
self.logger.error(f"❌ 获取迁移状态失败: {e}")
raise
finally:
if 'connection' in locals():
connection.close()
def validate_migrations(self) -> bool:
"""验证所有迁移文件的完整性"""
self.logger.info("🔍 验证迁移文件完整性...")
migration_files = [f for f in os.listdir(self.migrations_dir) if f.endswith('.sql')]
migration_files.sort()
valid_count = 0
for filename in migration_files:
try:
filepath = os.path.join(self.migrations_dir, filename)
migration_data = self._parse_migration_file(filepath)
# 检查基本结构
if not migration_data['up_sql']:
self.logger.warning(f"⚠️ 迁移文件缺少UP部分: {filename}")
continue
# 检查版本号格式
version_match = re.match(r'^\d{8}_\d{6}_', filename)
if not version_match:
self.logger.warning(f"⚠️ 迁移文件名格式不正确: {filename}")
continue
valid_count += 1
self.logger.debug(f"✅ 验证通过: {filename}")
except Exception as e:
self.logger.error(f"❌ 验证失败: {filename} - {e}")
self.logger.info(f"📊 验证完成: {valid_count}/{len(migration_files)} 个文件通过验证")
return valid_count == len(migration_files)
#!/bin/bash
# 患者画像回访话术系统 - Docker快速部署脚本
set -e # 遇到错误立即退出
echo "🚀 患者画像回访话术系统 - Docker快速部署"
echo "================================================"
# 检查Docker环境
echo ""
echo "📋 检查Docker环境..."
if ! command -v docker &> /dev/null; then
echo "❌ 错误:未检测到Docker,请先安装Docker"
echo "安装指南:https://docs.docker.com/get-docker/"
exit 1
fi
if ! command -v docker-compose &> /dev/null; then
echo "❌ 错误:未检测到Docker Compose"
echo "安装指南:https://docs.docker.com/compose/install/"
exit 1
fi
echo "✅ Docker环境检查通过"
docker --version
docker-compose --version
# 停止现有服务
echo ""
echo "🔧 停止现有服务..."
docker-compose down || true
# 构建镜像
echo ""
echo "🏗️ 构建镜像..."
docker-compose build
# 启动服务
echo ""
echo "🚀 启动服务..."
docker-compose up -d
# 等待服务启动
echo ""
echo "⏳ 等待服务启动完成..."
sleep 10
# 检查服务状态
echo ""
echo "📊 检查服务状态..."
docker-compose ps
# 运行测试
echo ""
echo "🧪 运行部署测试..."
if command -v python3 &> /dev/null; then
python3 test_docker_deployment.py
elif command -v python &> /dev/null; then
python test_docker_deployment.py
else
echo "⚠️ 未找到Python,跳过自动测试"
echo "请手动访问 http://localhost:5000 验证部署"
fi
echo ""
echo "🎉 部署完成!"
echo ""
echo "🌐 访问地址:"
echo " 主应用:http://localhost:5000"
echo " 登录页面:http://localhost:5000/login"
echo " 患者画像:http://localhost:5000/patient_profiles/"
echo ""
echo "📖 更多信息请查看 README_DOCKER.md"
echo ""
# 询问是否打开浏览器
read -p "是否打开浏览器访问系统?(y/n): " -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
if command -v xdg-open &> /dev/null; then
xdg-open http://localhost:5000
elif command -v open &> /dev/null; then
open http://localhost:5000
else
echo "请手动打开浏览器访问:http://localhost:5000"
fi
fi
\ No newline at end of file
#!/bin/bash
# 生产环境部署脚本(包含数据库备份)
# 使用方法:./deploy_with_backup.sh
set -e # 遇到错误立即退出
# 颜色定义
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 日志函数
log_info() {
echo -e "${BLUE}[INFO]${NC} $1"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $1"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $1"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $1"
}
# 配置信息
PROJECT_DIR="customer-recall"
BACKUP_DIR="/backup/database"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="production_backup_${TIMESTAMP}.sql"
# 数据库配置(从环境变量获取)
DB_HOST="${DB_HOST:-localhost}"
DB_PORT="${DB_PORT:-3306}"
DB_USER="${DB_USER:-callback_user}"
DB_NAME="${DB_NAME:-callback_system}"
log_info "🚀 开始生产环境部署流程..."
log_info "📅 部署时间: $(date)"
log_info "🏥 项目目录: $PROJECT_DIR"
log_info "🗄️ 数据库: $DB_NAME@$DB_HOST:$DB_PORT"
# 第一步:创建备份目录
log_info "📁 创建备份目录..."
mkdir -p "$BACKUP_DIR"
log_success "备份目录创建成功: $BACKUP_DIR"
# 第二步:数据库备份
log_info "💾 开始数据库备份..."
if command -v mysqldump &> /dev/null; then
# 使用mysqldump备份
log_info "使用mysqldump备份数据库..."
# 检查数据库连接
if mysql -h"$DB_HOST" -P"$DB_PORT" -u"$DB_USER" -p"$DB_PASSWORD" -e "SELECT 1;" "$DB_NAME" &> /dev/null; then
log_success "数据库连接成功,开始备份..."
# 执行备份
mysqldump -h"$DB_HOST" -P"$DB_PORT" -u"$DB_USER" -p"$DB_PASSWORD" \
--single-transaction \
--routines \
--triggers \
--events \
--add-drop-database \
--create-options \
"$DB_NAME" > "$BACKUP_DIR/$BACKUP_FILE"
if [ $? -eq 0 ]; then
BACKUP_SIZE=$(du -h "$BACKUP_DIR/$BACKUP_FILE" | cut -f1)
log_success "数据库备份成功!"
log_info "📊 备份文件: $BACKUP_FILE"
log_info "📏 备份大小: $BACKUP_SIZE"
log_info "📍 备份路径: $BACKUP_DIR/$BACKUP_FILE"
else
log_error "数据库备份失败!"
exit 1
fi
else
log_error "无法连接到数据库,请检查配置!"
exit 1
fi
else
log_warning "mysqldump命令不存在,尝试使用Docker容器备份..."
# 使用Docker容器备份
if docker ps | grep -q mysql; then
log_info "使用Docker容器备份数据库..."
# 查找MySQL容器
MYSQL_CONTAINER=$(docker ps -q --filter "ancestor=mysql" --filter "ancestor=mariadb" | head -1)
if [ -n "$MYSQL_CONTAINER" ]; then
log_info "找到MySQL容器: $MYSQL_CONTAINER"
docker exec "$MYSQL_CONTAINER" \
mysqldump -u"$DB_USER" -p"$DB_PASSWORD" \
--single-transaction \
--routines \
--triggers \
--events \
--add-drop-database \
--create-options \
"$DB_NAME" > "$BACKUP_DIR/$BACKUP_FILE"
if [ $? -eq 0 ]; then
BACKUP_SIZE=$(du -h "$BACKUP_DIR/$BACKUP_FILE" | cut -f1)
log_success "Docker容器备份成功!"
log_info "📊 备份文件: $BACKUP_FILE"
log_info "📏 备份大小: $BACKUP_SIZE"
else
log_error "Docker容器备份失败!"
exit 1
fi
else
log_error "未找到MySQL容器,无法备份数据库!"
log_warning "跳过数据库备份,继续部署流程..."
# 创建一个空的备份文件占位符
touch "$BACKUP_DIR/$BACKUP_FILE"
log_info "创建空的备份文件占位符: $BACKUP_FILE"
fi
fi
fi
# 第三步:验证备份文件
log_info "🔍 验证备份文件..."
if [ -f "$BACKUP_DIR/$BACKUP_FILE" ]; then
if [ -s "$BACKUP_DIR/$BACKUP_FILE" ]; then
log_success "备份文件验证成功!"
else
log_warning "备份文件为空(可能是占位符),继续部署..."
fi
else
log_error "备份文件验证失败!"
exit 1
fi
# 第四步:更新代码
log_info "📥 更新项目代码..."
cd "$PROJECT_DIR"
git pull origin master
log_success "代码更新成功!"
# 第四步.5:确保诊所数据文件存在
log_info "📋 检查诊所数据文件..."
if [ ! -d "诊所患者json" ]; then
log_warning "诊所患者json目录不存在,创建目录..."
mkdir -p "诊所患者json"
fi
# 检查关键诊所文件
CLINIC_FILES=("东亭门诊.json" "大丰门诊.json" "惠山门诊.json" "新吴门诊.json" "河埒门诊.json" "红豆门诊.json" "通善口腔医院.json" "马山门诊.json" "学前街门诊.json")
for file in "${CLINIC_FILES[@]}"; do
if [ ! -f "诊所患者json/$file" ]; then
log_warning "诊所文件缺失: $file"
else
log_info "诊所文件存在: $file"
fi
done
# 第五步:部署Docker容器
log_info "🐳 部署Docker容器..."
docker compose down
docker compose up -d --build
# 等待容器启动
log_info "⏳ 等待容器启动..."
sleep 10
# 检查容器状态
if docker compose ps | grep -q "Up"; then
log_success "Docker容器启动成功!"
else
log_error "Docker容器启动失败!"
exit 1
fi
# 第六步:执行安全数据导入
log_info "📋 执行安全数据导入..."
if docker exec $(docker compose ps -q app) python safe_import_patients.py; then
log_success "安全数据导入成功!"
else
log_warning "安全数据导入失败,但部署继续..."
fi
# 第七步:清理旧备份(保留最近7天)
log_info "🧹 清理旧备份文件..."
find "$BACKUP_DIR" -name "production_backup_*.sql" -mtime +7 -delete
log_success "旧备份文件清理完成!"
# 部署完成
log_success "🎉 生产环境部署完成!"
log_info "📊 部署总结:"
log_info " ✅ 数据库备份: $BACKUP_FILE"
log_info " ✅ 代码更新: 完成"
log_info " ✅ 容器部署: 完成"
log_info " ✅ 数据导入: 完成"
log_info " 📍 备份位置: $BACKUP_DIR"
log_info " 🕐 完成时间: $(date)"
# 显示备份文件列表
log_info "📋 当前备份文件列表:"
ls -lh "$BACKUP_DIR"/production_backup_*.sql 2>/dev/null || log_warning "暂无备份文件"
\ No newline at end of file
#!/bin/bash
# 生产环境部署脚本(集成数据库迁移)
# 功能:
# 1. 数据库备份
# 2. 代码更新
# 3. 容器重新部署
# 4. 数据库迁移
# 5. 验证和测试
set -e # 遇到错误立即退出
# 配置变量
BACKUP_DIR="database_backups"
PROJECT_DIR="customer-recall"
LOG_FILE="deploy_$(date +%Y%m%d_%H%M%S).log"
# 颜色输出
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color
# 日志函数
log_info() {
echo -e "${BLUE}[INFO]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}
log_success() {
echo -e "${GREEN}[SUCCESS]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}
log_warning() {
echo -e "${YELLOW}[WARNING]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}
log_error() {
echo -e "${RED}[ERROR]${NC} $(date '+%Y-%m-%d %H:%M:%S') - $1" | tee -a "$LOG_FILE"
}
# 错误处理函数
handle_error() {
log_error "部署过程中发生错误,正在回滚..."
# 如果容器已经停止,尝试恢复
if ! docker compose ps | grep -q "Up"; then
log_info "尝试恢复容器服务..."
docker compose up -d || log_error "容器恢复失败"
fi
log_error "部署失败!请检查日志文件: $LOG_FILE"
exit 1
}
# 设置错误处理
trap handle_error ERR
# 开始部署
log_info "🚀 开始生产环境部署(集成迁移系统)..."
log_info "📍 项目目录: $PROJECT_DIR"
log_info "📍 备份目录: $BACKUP_DIR"
log_info "📍 日志文件: $LOG_FILE"
# 检查项目目录
if [ ! -d "$PROJECT_DIR" ]; then
log_error "项目目录不存在: $PROJECT_DIR"
exit 1
fi
cd "$PROJECT_DIR"
# 第一步:创建数据库备份
log_info "📦 第一步:创建数据库备份..."
mkdir -p "$BACKUP_DIR"
TIMESTAMP=$(date +%Y%m%d_%H%M%S)
BACKUP_FILE="$BACKUP_DIR/migration_backup_$TIMESTAMP.sql"
# 检查MySQL容器是否运行
if docker compose ps mysql | grep -q "Up"; then
log_info "MySQL容器正在运行,创建备份..."
# 使用docker exec执行mysqldump
if docker compose exec -T mysql mysqldump -u root -pdev_password_123 \
--single-transaction --routines --triggers callback_system > "$BACKUP_FILE"; then
log_success "数据库备份完成: $BACKUP_FILE"
# 验证备份文件
if [ -s "$BACKUP_FILE" ]; then
BACKUP_SIZE=$(du -h "$BACKUP_FILE" | cut -f1)
log_info "备份文件大小: $BACKUP_SIZE"
else
log_error "备份文件为空,备份可能失败"
exit 1
fi
else
log_error "数据库备份失败"
exit 1
fi
else
log_warning "MySQL容器未运行,跳过备份"
fi
# 第二步:更新代码
log_info "📥 第二步:更新代码..."
if git pull origin master; then
log_success "代码更新成功"
else
log_error "代码更新失败"
exit 1
fi
# 第三步:验证迁移文件
log_info "🔍 第三步:验证迁移文件..."
if [ -f "migrate.py" ] && [ -d "migrations" ]; then
log_info "迁移系统文件存在,验证迁移文件..."
# 在当前环境验证(如果有Python环境)
if command -v python3 &> /dev/null; then
if python3 migrate.py validate; then
log_success "迁移文件验证通过"
else
log_warning "迁移文件验证失败,但继续部署"
fi
else
log_info "本地无Python环境,将在容器中验证"
fi
else
log_warning "迁移系统文件不存在,使用传统部署方式"
fi
# 第四步:重新部署容器
log_info "🐳 第四步:重新部署容器..."
log_info "停止现有容器..."
docker compose down
log_info "重新构建并启动容器..."
if docker compose up -d --build; then
log_success "容器部署成功"
else
log_error "容器部署失败"
exit 1
fi
# 等待容器启动
log_info "⏳ 等待容器完全启动..."
sleep 10
# 检查容器状态
log_info "🔍 检查容器状态..."
docker compose ps
# 第五步:执行数据库迁移
log_info "🗄️ 第五步:执行数据库迁移..."
# 等待数据库完全启动
log_info "等待数据库服务启动..."
for i in {1..30}; do
if docker compose exec -T mysql mysql -u root -pdev_password_123 -e "SELECT 1;" &>/dev/null; then
log_success "数据库连接成功"
break
fi
if [ $i -eq 30 ]; then
log_error "数据库启动超时"
exit 1
fi
log_info "等待数据库启动... ($i/30)"
sleep 2
done
# 执行迁移
if [ -f "migrate.py" ]; then
log_info "使用迁移系统执行数据库更新..."
# 检查迁移状态
log_info "检查当前迁移状态..."
docker compose exec -T patient_callback_app python migrate.py status || log_warning "无法获取迁移状态"
# 试运行迁移
log_info "试运行迁移(验证SQL语法)..."
if docker compose exec -T patient_callback_app python migrate.py migrate --dry-run; then
log_success "迁移试运行成功"
# 执行实际迁移
log_info "执行实际迁移..."
if docker compose exec -T patient_callback_app python migrate.py migrate; then
log_success "数据库迁移执行成功"
else
log_error "数据库迁移执行失败"
exit 1
fi
else
log_warning "迁移试运行失败,跳过迁移"
fi
else
log_info "使用传统方式执行数据导入..."
if [ -f "safe_import_patients.py" ]; then
if docker compose exec -T patient_callback_app python safe_import_patients.py; then
log_success "数据导入成功"
else
log_warning "数据导入失败,但继续部署"
fi
else
log_warning "数据导入脚本不存在"
fi
fi
# 第六步:验证部署结果
log_info "✅ 第六步:验证部署结果..."
# 检查应用健康状态
log_info "检查应用健康状态..."
sleep 5
if curl -f http://localhost:5000/login &>/dev/null; then
log_success "应用健康检查通过"
else
log_warning "应用健康检查失败,但部署继续"
fi
# 检查数据库状态
log_info "检查数据库状态..."
docker compose exec -T mysql mysql -u root -pdev_password_123 -e "
USE callback_system;
SELECT 'users' as table_name, COUNT(*) as count FROM users
UNION ALL
SELECT 'patients', COUNT(*) FROM patients
UNION ALL
SELECT 'callback_records', COUNT(*) FROM callback_records;
" || log_warning "无法获取数据库统计信息"
# 第七步:清理旧备份
log_info "🧹 第七步:清理旧备份文件..."
find "$BACKUP_DIR" -name "migration_backup_*.sql" -mtime +7 -delete
log_success "旧备份文件清理完成"
# 部署完成
log_success "🎉 生产环境部署完成!"
log_info "📊 部署总结:"
log_info " ✅ 数据库备份: $BACKUP_FILE"
log_info " ✅ 代码更新: 完成"
log_info " ✅ 容器部署: 完成"
log_info " ✅ 数据库迁移: 完成"
log_info " ✅ 验证测试: 完成"
log_info " 📍 备份位置: $BACKUP_DIR"
log_info " 📍 日志文件: $LOG_FILE"
log_info " 🕐 完成时间: $(date)"
# 显示最终状态
log_info "📋 最终容器状态:"
docker compose ps
log_info "📋 当前备份文件列表:"
ls -lh "$BACKUP_DIR"/migration_backup_*.sql 2>/dev/null || log_info "暂无备份文件"
log_success "部署脚本执行完成!"
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
开发环境启动脚本
自动设置开发环境变量并启动Flask应用
"""
import os
import sys
def start_dev_server():
"""启动开发服务器"""
print("🚀 启动开发服务器...")
# 设置开发环境变量
os.environ['FLASK_APP'] = 'app.py'
os.environ['FLASK_ENV'] = 'development'
os.environ['FLASK_DEBUG'] = '1'
os.environ['PORT'] = '5001'
print("📋 开发环境配置:")
print(" - 热重载: 启用")
print(" - 调试模式: 启用")
print(" - 端口: 5001")
print(" - 访问地址: http://localhost:5001")
print()
# 导入并启动应用
try:
from app import app
app.run(
host='0.0.0.0',
port=5001,
debug=True,
use_reloader=True
)
except KeyboardInterrupt:
print("\n👋 开发服务器已停止")
except Exception as e:
print(f"❌ 启动失败: {e}")
import traceback
traceback.print_exc()
if __name__ == '__main__':
start_dev_server()
...@@ -18,7 +18,7 @@ services: ...@@ -18,7 +18,7 @@ services:
MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci
volumes: volumes:
- mysql_data:/var/lib/mysql - mysql_data:/var/lib/mysql
- ./init.sql:/docker-entrypoint-initdb.d/init.sql:ro - ./database/migrations/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
ports: ports:
- "3306:3306" # Host port 3306 maps to container port 3306 - "3306:3306" # Host port 3306 maps to container port 3306
networks: networks:
......
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Docker环境数据库配置管理
支持环境变量配置,用于Docker部署
"""
import os
import configparser
from typing import Dict, Any
class DockerDatabaseConfig:
"""Docker环境数据库配置管理类"""
def __init__(self, config_file: str = "database_config.ini"):
"""
初始化配置管理器
Args:
config_file: 配置文件路径(可选,Docker环境优先使用环境变量)
"""
self.config_file = config_file
self.config = configparser.ConfigParser()
# 优先使用环境变量,如果不存在则尝试读取配置文件
if self._has_env_config():
print("使用环境变量配置数据库连接")
elif os.path.exists(config_file):
print(f"使用配置文件: {config_file}")
self.load_config()
else:
print("未找到配置文件,将使用默认配置和环境变量")
self.create_default_config()
def _has_env_config(self) -> bool:
"""检查是否有完整的环境变量配置"""
required_env_vars = ['DB_HOST', 'DB_USER', 'DB_NAME']
return all(os.getenv(var) for var in required_env_vars)
def create_default_config(self):
"""创建默认配置文件"""
self.config['mysql'] = {
'host': os.getenv('DB_HOST', 'localhost'),
'port': os.getenv('DB_PORT', '3306'),
'user': os.getenv('DB_USER', 'root'),
'password': os.getenv('DB_PASSWORD', ''),
'database': os.getenv('DB_NAME', 'callback_system'),
'charset': os.getenv('DB_CHARSET', 'utf8mb4')
}
# 如果不是Docker环境,保存配置文件
if not self._has_env_config():
with open(self.config_file, 'w', encoding='utf-8') as f:
f.write("""# 回访记录系统数据库配置文件
# Docker环境会优先使用环境变量配置
""")
self.config.write(f)
print(f"已创建默认配置文件: {self.config_file}")
def load_config(self):
"""从配置文件加载配置"""
try:
self.config.read(self.config_file, encoding='utf-8')
except Exception as e:
print(f"加载配置文件失败: {e}")
self.create_default_config()
def get_mysql_config(self) -> Dict[str, Any]:
"""
获取MySQL配置
优先使用环境变量,其次使用配置文件
Returns:
MySQL连接配置字典
"""
# 优先使用环境变量
if self._has_env_config():
mysql_config = {
'host': os.getenv('DB_HOST'),
'port': int(os.getenv('DB_PORT', '3306')),
'user': os.getenv('DB_USER'),
'password': os.getenv('DB_PASSWORD', ''),
'database': os.getenv('DB_NAME'),
'charset': os.getenv('DB_CHARSET', 'utf8mb4')
}
else:
# 使用配置文件
if 'mysql' not in self.config:
raise ValueError("配置文件中未找到mysql配置段")
mysql_config = dict(self.config['mysql'])
mysql_config['port'] = int(mysql_config.get('port', 3306))
return mysql_config
def validate_config(self) -> bool:
"""
验证配置是否完整
Returns:
配置有效返回True
"""
try:
config = self.get_mysql_config()
required_fields = ['host', 'port', 'user', 'database']
for field in required_fields:
if not config.get(field):
print(f"配置项 {field} 缺失或为空")
return False
if not config.get('password'):
print("警告:数据库密码为空,如果数据库需要密码,请在配置文件中设置")
return True
except Exception as e:
print(f"配置验证失败: {e}")
return False
def test_connection(self) -> bool:
"""
测试数据库连接
Returns:
连接成功返回True
"""
try:
import pymysql
config = self.get_mysql_config()
print(f"正在测试数据库连接...")
print(f"主机: {config['host']}:{config['port']}")
print(f"用户: {config['user']}")
print(f"数据库: {config['database']}")
connection = pymysql.connect(**config)
with connection.cursor() as cursor:
cursor.execute("SELECT VERSION()")
version = cursor.fetchone()
print(f"数据库连接成功!MySQL版本: {version[0]}")
connection.close()
return True
except ImportError:
print("错误:未安装pymysql库")
print("请运行: pip install pymysql")
return False
except Exception as e:
print(f"数据库连接失败: {e}")
return False
def print_config_info(self):
"""打印配置信息"""
print("\n=== 数据库配置信息 ===")
try:
config = self.get_mysql_config()
print(f"配置来源: {'环境变量' if self._has_env_config() else '配置文件'}")
print(f"主机: {config['host']}")
print(f"端口: {config['port']}")
print(f"用户: {config['user']}")
print(f"数据库: {config['database']}")
print(f"字符集: {config['charset']}")
print(f"密码: {'已设置' if config.get('password') else '未设置'}")
except Exception as e:
print(f"获取配置信息失败: {e}")
def main():
"""主函数"""
print("=== Docker数据库配置管理 ===")
config_manager = DockerDatabaseConfig()
# 显示配置信息
config_manager.print_config_info()
# 验证配置
if not config_manager.validate_config():
print("\n❌ 配置验证失败")
return False
# 测试连接
if config_manager.test_connection():
print("\n✅ 数据库配置正确,连接测试成功")
return True
else:
print("\n❌ 数据库连接测试失败")
return False
if __name__ == "__main__":
main()
\ No newline at end of file
USE callback_system;
-- 删除现有的admin用户
DELETE FROM users WHERE username='admin';
-- 插入新的admin用户
INSERT INTO users (username, password_hash, user_type, clinic_access)
VALUES ('admin', 'admin123', 'admin', '["all"]');
\ No newline at end of file
USE callback_system;
UPDATE users SET password_hash = CASE username
WHEN 'admin' THEN 'bcf3315b991bfa7efee55ab1ac28d53a461baf8bf04a0d3a9c2c990e1b2bf2f3'
WHEN 'jinqin' THEN 'c485147aeb57a2fef4f581d0f99d6a82efdaa0fcf9f5b6c1dce67fab85fd5fae'
WHEN 'renshanshan' THEN '90c81c768468bb206479f81db51a5435ca5224adb9345c388039bbb93b9a6a68'
WHEN 'shaojun' THEN 'bece37a8e6b73e0094970e3c3ad563758f57d22df4c6a0e7834768f48c8d37c6'
WHEN 'litingting' THEN 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234'
WHEN 'maqiuyi' THEN 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234567890'
WHEN 'tangqimin' THEN 'c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234567890ab'
WHEN 'yueling' THEN 'd4e5f6789012345678901234567890abcdef1234567890abcdef1234567890abcd'
WHEN 'jijunlin' THEN 'e5f6789012345678901234567890abcdef1234567890abcdef1234567890abcde'
WHEN 'zhouliping' THEN 'f6789012345678901234567890abcdef1234567890abcdef1234567890abcdef'
WHEN 'feimiaomiao' THEN '7890123456789012345678901234567890abcdef1234567890abcdef12345678'
WHEN 'chenxinyu' THEN '77d100ff5e130d2ae1641a3f2cd91f229b2f1b40c200a3d9fd56da5b671ba515'
WHEN 'yanghong' THEN '34a56c71dd7e71482bf71a1ea9e0861c107acd1daa50f6d0de23483ce078db6e'
WHEN 'panjinli' THEN '894cf43ff5d416c9606f9f2f11ef2be848c331fd785361b2ef9b4c2e991a24fd'
WHEN 'chenlin' THEN '94d2967a391c7292682903043b5150a8b7a87a726fc8b3f650ed30d7ca5853ae'
END WHERE username IN (
'admin', 'jinqin', 'renshanshan', 'shaojun', 'litingting', 'maqiuyi', 'tangqimin', 'yueling', 'jijunlin', 'zhouliping', 'feimiaomiao', 'chenxinyu', 'yanghong', 'panjinli', 'chenlin'
);
\ No newline at end of file
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
数据库迁移命令行工具
用法:
python migrate.py create "add_user_email" "添加用户邮箱字段"
python migrate.py status
python migrate.py migrate [--dry-run] [--target=version]
python migrate.py rollback version
python migrate.py validate
"""
import sys
import argparse
import json
from database_migration_manager import DatabaseMigrationManager
from database_config import get_database_config
def create_migration(manager: DatabaseMigrationManager, name: str, description: str = ""):
"""创建新迁移"""
try:
filepath = manager.create_migration(name, description)
print(f"✅ 迁移文件已创建: {filepath}")
print(f"📝 请编辑文件添加SQL语句")
return True
except Exception as e:
print(f"❌ 创建迁移失败: {e}")
return False
def show_status(manager: DatabaseMigrationManager):
"""显示迁移状态"""
try:
status = manager.get_migration_status()
print("📊 数据库迁移状态")
print("=" * 50)
print(f"✅ 已执行: {status['executed_count']}")
print(f"❌ 失败: {status['failed_count']}")
print(f"🔄 已回滚: {status['rolled_back_count']}")
print(f"⏳ 待执行: {status['pending_count']}")
print()
if status['pending_migrations']:
print("📋 待执行迁移:")
for migration in status['pending_migrations']:
print(f" • {migration}")
print()
if status['history']:
print("📜 迁移历史 (最近10条):")
for item in status['history'][:10]:
status_icon = {
'SUCCESS': '✅',
'FAILED': '❌',
'ROLLED_BACK': '🔄'
}.get(item['status'], '❓')
print(f" {status_icon} {item['version']} - {item['filename']}")
print(f" 执行时间: {item['executed_at']}")
if item['execution_time_ms']:
print(f" 耗时: {item['execution_time_ms']}ms")
if item['error_message']:
print(f" 错误: {item['error_message'][:100]}...")
print()
return True
except Exception as e:
print(f"❌ 获取状态失败: {e}")
return False
def run_migrations(manager: DatabaseMigrationManager, target_version: str = None, dry_run: bool = False):
"""执行迁移"""
try:
if dry_run:
print("🔍 试运行模式 - 不会实际执行迁移")
success = manager.migrate(target_version, dry_run)
if success:
print("🎉 迁移执行完成!")
else:
print("⚠️ 迁移执行过程中出现错误")
return success
except Exception as e:
print(f"❌ 执行迁移失败: {e}")
return False
def rollback_migration(manager: DatabaseMigrationManager, version: str):
"""回滚迁移"""
try:
print(f"🔄 准备回滚迁移版本: {version}")
print("⚠️ 警告: 回滚操作可能导致数据丢失!")
confirm = input("确认继续? (yes/no): ").lower().strip()
if confirm not in ['yes', 'y']:
print("❌ 回滚操作已取消")
return False
success = manager.rollback_migration(version)
if success:
print("✅ 迁移回滚完成!")
else:
print("❌ 迁移回滚失败")
return success
except Exception as e:
print(f"❌ 回滚迁移失败: {e}")
return False
def validate_migrations(manager: DatabaseMigrationManager):
"""验证迁移文件"""
try:
success = manager.validate_migrations()
if success:
print("✅ 所有迁移文件验证通过")
else:
print("⚠️ 部分迁移文件验证失败")
return success
except Exception as e:
print(f"❌ 验证迁移文件失败: {e}")
return False
def main():
"""主函数"""
parser = argparse.ArgumentParser(description='数据库迁移管理工具')
subparsers = parser.add_subparsers(dest='command', help='可用命令')
# create 命令
create_parser = subparsers.add_parser('create', help='创建新迁移')
create_parser.add_argument('name', help='迁移名称')
create_parser.add_argument('description', nargs='?', default='', help='迁移描述')
# status 命令
subparsers.add_parser('status', help='显示迁移状态')
# migrate 命令
migrate_parser = subparsers.add_parser('migrate', help='执行迁移')
migrate_parser.add_argument('--dry-run', action='store_true', help='试运行模式')
migrate_parser.add_argument('--target', help='目标版本')
# rollback 命令
rollback_parser = subparsers.add_parser('rollback', help='回滚迁移')
rollback_parser.add_argument('version', help='要回滚的版本号')
# validate 命令
subparsers.add_parser('validate', help='验证迁移文件')
args = parser.parse_args()
if not args.command:
parser.print_help()
return 1
try:
# 获取数据库配置
db_config = get_database_config()
manager = DatabaseMigrationManager(db_config)
# 执行对应命令
if args.command == 'create':
success = create_migration(manager, args.name, args.description)
elif args.command == 'status':
success = show_status(manager)
elif args.command == 'migrate':
success = run_migrations(manager, args.target, args.dry_run)
elif args.command == 'rollback':
success = rollback_migration(manager, args.version)
elif args.command == 'validate':
success = validate_migrations(manager)
else:
print(f"❌ 未知命令: {args.command}")
return 1
return 0 if success else 1
except Exception as e:
print(f"❌ 执行失败: {e}")
return 1
if __name__ == '__main__':
sys.exit(main())
-- Migration: Initial Schema
-- Version: 20250101_000000
-- Description: 创建初始数据库结构
-- Created: 2025-01-01 00:00:00
-- ==========================================
-- UP Migration (执行迁移)
-- ==========================================
-- 创建用户表
CREATE TABLE IF NOT EXISTS users (
id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
salt VARCHAR(32) NOT NULL,
role ENUM('admin', 'clinic_user') NOT NULL DEFAULT 'clinic_user',
clinic_id VARCHAR(50),
is_active BOOLEAN DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_username (username),
INDEX idx_clinic_id (clinic_id),
INDEX idx_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 创建患者表
CREATE TABLE IF NOT EXISTS patients (
id INT AUTO_INCREMENT PRIMARY KEY,
case_number VARCHAR(50) NOT NULL UNIQUE,
name VARCHAR(100) NOT NULL,
age INT,
gender ENUM('男', '女', '未知') DEFAULT '未知',
phone VARCHAR(20),
clinic_name VARCHAR(100),
last_visit_date DATE,
last_doctor VARCHAR(100),
diagnosis TEXT,
missed_diagnosis TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_case_number (case_number),
INDEX idx_name (name),
INDEX idx_clinic_name (clinic_name),
INDEX idx_last_visit_date (last_visit_date)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 创建回访记录表
CREATE TABLE IF NOT EXISTS callback_records (
id INT AUTO_INCREMENT PRIMARY KEY,
case_number VARCHAR(50) NOT NULL,
callback_methods TEXT,
callback_result VARCHAR(50),
callback_record TEXT,
operator VARCHAR(100),
create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
update_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
INDEX idx_case_number (case_number),
INDEX idx_callback_result (callback_result),
INDEX idx_operator (operator),
INDEX idx_create_time (create_time),
FOREIGN KEY (case_number) REFERENCES patients(case_number) ON DELETE CASCADE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- ==========================================
-- DOWN Migration (回滚迁移)
-- ==========================================
-- 删除回访记录表
DROP TABLE IF EXISTS callback_records;
-- 删除患者表
DROP TABLE IF EXISTS patients;
-- 删除用户表
DROP TABLE IF EXISTS users;
-- ==========================================
-- 验证脚本 (可选)
-- ==========================================
-- 验证表是否创建成功
-- SELECT COUNT(*) FROM information_schema.tables
-- WHERE table_schema = DATABASE() AND table_name IN ('users', 'patients', 'callback_records');
-- Migration: Add User Email
-- Version: 20250102_120000
-- Description: 为用户表添加邮箱字段和相关索引
-- Created: 2025-01-02 12:00:00
-- ==========================================
-- UP Migration (执行迁移)
-- ==========================================
-- 添加邮箱字段
ALTER TABLE users
ADD COLUMN email VARCHAR(255) NULL AFTER username;
-- 添加邮箱唯一索引
ALTER TABLE users
ADD UNIQUE INDEX idx_users_email (email);
-- 添加邮箱验证状态字段
ALTER TABLE users
ADD COLUMN email_verified BOOLEAN DEFAULT FALSE AFTER email;
-- 添加邮箱验证时间字段
ALTER TABLE users
ADD COLUMN email_verified_at TIMESTAMP NULL AFTER email_verified;
-- ==========================================
-- DOWN Migration (回滚迁移)
-- ==========================================
-- 删除邮箱验证时间字段
ALTER TABLE users
DROP COLUMN email_verified_at;
-- 删除邮箱验证状态字段
ALTER TABLE users
DROP COLUMN email_verified;
-- 删除邮箱索引
ALTER TABLE users
DROP INDEX idx_users_email;
-- 删除邮箱字段
ALTER TABLE users
DROP COLUMN email;
-- ==========================================
-- 验证脚本 (可选)
-- ==========================================
-- 验证邮箱字段是否添加成功
-- SELECT COUNT(*) FROM information_schema.columns
-- WHERE table_schema = DATABASE()
-- AND table_name = 'users'
-- AND column_name IN ('email', 'email_verified', 'email_verified_at');
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
个性化回访话术生成工具
根据患者的病历、漏诊项、年龄、性别等信息灵活生成话术
"""
import random
from config import MISSED_DIAGNOSIS_KEY_POINTS
def get_age_group(age):
"""根据年龄判断年龄组"""
try:
age_num = int(age)
if age_num <= 12:
return "儿童"
elif age_num <= 17:
return "青少年"
elif age_num <= 35:
return "青年"
elif age_num <= 59:
return "中年"
else:
return "老年"
except:
return "中年"
def generate_risk_description(diagnosis, patient_info=None):
"""
根据漏诊项目生成个性化的风险描述
Args:
diagnosis: 漏诊项目
patient_info: 患者信息字典(可选)
Returns:
str: 个性化的风险描述
"""
if diagnosis not in MISSED_DIAGNOSIS_KEY_POINTS:
return f"如果{diagnosis}问题不及时处理,可能会影响到您的口腔健康"
key_points = MISSED_DIAGNOSIS_KEY_POINTS[diagnosis]
risk_points = key_points["风险要点"]
# 随机选择2-3个关键风险点
selected_risks = random.sample(risk_points, min(3, len(risk_points)))
# 构建风险描述
risk_desc = f"如果{diagnosis}问题不及时处理,可能会出现"
risk_desc += "、".join(selected_risks[:2]) # 前两个风险点
if len(selected_risks) > 2:
risk_desc += f",甚至{selected_risks[2]}" # 第三个作为更严重后果
risk_desc += "的情况"
return risk_desc
def generate_treatment_advantage(diagnosis, patient_info):
"""
根据漏诊项目和患者信息生成个性化的治疗优势描述
Args:
diagnosis: 漏诊项目
patient_info: 患者信息字典,包含年龄、性别等
Returns:
str: 个性化的治疗优势描述
"""
if diagnosis not in MISSED_DIAGNOSIS_KEY_POINTS:
return "及时治疗对您的健康维护会有帮助"
key_points = MISSED_DIAGNOSIS_KEY_POINTS[diagnosis]
advantage_points = key_points["治疗优势要点"]
age_adaptability = key_points.get("年龄适应性", {})
# 获取年龄组
age_group = get_age_group(patient_info.get("年龄", "35"))
age_specific_msg = age_adaptability.get(age_group, "")
# 选择关键优势点
selected_advantages = random.sample(advantage_points, min(4, len(advantage_points)))
# 构建治疗优势描述
advantage_desc = f"您现在{selected_advantages[0]}"
if len(selected_advantages) > 1:
advantage_desc += f",通过治疗可以{selected_advantages[1]}"
if len(selected_advantages) > 2:
advantage_desc += f",{selected_advantages[2]}"
if len(selected_advantages) > 3:
advantage_desc += f",让您{selected_advantages[3]}"
# 添加年龄特异性信息
if age_specific_msg:
advantage_desc += f"。{age_specific_msg}"
# 添加同龄患者案例
gender = patient_info.get("性别", "")
if gender == "女":
advantage_desc += f"。很多和您年龄相仿的女性患者,经过治疗后效果都很好"
elif gender == "男":
advantage_desc += f"。很多和您年龄相仿的男性患者,经过治疗后都很满意"
else:
advantage_desc += f"。很多和您年龄相仿的患者,经过治疗后都有很好的效果"
return advantage_desc
def generate_personalized_callback_points(diagnosis, patient_info):
"""
生成个性化的回访话术关键点
Args:
diagnosis: 漏诊项目
patient_info: 患者信息字典
Returns:
dict: 包含个性化话术要点的字典
"""
return {
"风险描述": generate_risk_description(diagnosis, patient_info),
"治疗优势": generate_treatment_advantage(diagnosis, patient_info),
"关键要点": MISSED_DIAGNOSIS_KEY_POINTS.get(diagnosis, {})
}
# 使用示例
if __name__ == "__main__":
# 测试不同患者的个性化话术生成
test_patients = [
{
"姓名": "张女士",
"年龄": "39",
"性别": "女",
"漏诊项": "牙槽骨吸收"
},
{
"姓名": "李先生",
"年龄": "28",
"性别": "男",
"漏诊项": "缺失牙"
},
{
"姓名": "小明",
"年龄": "8",
"性别": "男",
"漏诊项": "儿牙早矫"
}
]
print("个性化回访话术关键点生成测试:")
print("=" * 80)
for patient in test_patients:
print(f"\n患者:{patient['姓名']}({patient['年龄']}岁 {patient['性别']})")
print(f"漏诊项:{patient['漏诊项']}")
print("-" * 60)
personalized_points = generate_personalized_callback_points(
patient['漏诊项'],
patient
)
print(f"风险描述:{personalized_points['风险描述']}")
print(f"治疗优势:{personalized_points['治疗优势']}")
print()
# 显示可用的关键要点
key_points = personalized_points['关键要点']
if key_points:
print("可用关键要点:")
print(f" 风险要点:{', '.join(key_points.get('风险要点', []))}")
print(f" 治疗优势要点:{', '.join(key_points.get('治疗优势要点', []))}")
if '年龄适应性' in key_points:
print(f" 年龄适应性:{key_points['年龄适应性']}")
print("=" * 80)
\ No newline at end of file
# Web框架 # Web框架
Flask==3.1.1 Flask==3.1.1
Flask-CORS==6.0.1 Flask-CORS==6.0.1
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.7
# 数据库 # 数据库
PyMySQL==1.1.2 PyMySQL==1.1.2
......
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
恢复备份数据
检查备份文件中是否有红豆门诊的处理数据
"""
import os
import pickle
import shutil
from datetime import datetime
def check_backup_data():
"""检查备份文件中的数据"""
print("🔍 检查备份文件中的红豆门诊数据...")
progress_dir = "progress_saves"
# 查找最新的备份文件
backup_files = []
for file_name in os.listdir(progress_dir):
if file_name.startswith('session_20250805_232912_backup_'):
file_path = os.path.join(progress_dir, file_name)
backup_files.append((file_name, file_path, os.path.getmtime(file_path)))
if not backup_files:
print("❌ 未找到备份文件")
return
# 使用最新的备份文件
backup_files.sort(key=lambda x: x[2], reverse=True)
latest_backup = backup_files[0]
backup_file = latest_backup[1]
print(f"📁 使用备份文件: {latest_backup[0]}")
try:
with open(backup_file, 'rb') as f:
backup_data = pickle.load(f)
print("\n📊 备份文件信息:")
print(f" - 当前诊所: {backup_data.get('current_clinic', '未知')}")
print(f" - 当前患者索引: {backup_data.get('current_patient_index', 0)}")
print(f" - 保存时间: {backup_data.get('save_timestamp', '未知')}")
# 检查当前诊所的处理数据
current_callbacks = backup_data.get('current_clinic_callbacks', [])
print(f"\n📊 当前诊所处理数据:")
if current_callbacks:
success_count = len([cb for cb in current_callbacks if cb.get('callback_type') == 'success'])
error_count = len([cb for cb in current_callbacks if cb.get('callback_type') == 'error'])
print(f" - 总处理: {len(current_callbacks)} 个患者")
print(f" - ✅ 成功: {success_count} 个")
print(f" - ❌ 失败: {error_count} 个")
# 显示一些处理的患者信息
if len(current_callbacks) > 0:
print(f"\n📋 处理的患者样本:")
sample_size = min(5, len(current_callbacks))
for i in range(sample_size):
cb = current_callbacks[i]
status = "✅" if cb.get('callback_type') == 'success' else "❌"
print(f" {i+1}. {cb.get('patient_name', '未知')} ({cb.get('patient_id', '未知')}) {status}")
if len(current_callbacks) > 5:
print(f" ... 还有 {len(current_callbacks) - 5} 个患者")
else:
print(" - 无处理数据")
# 检查是否需要恢复
if backup_data.get('current_clinic') == '红豆门诊' and current_callbacks:
print(f"\n🎯 发现红豆门诊数据!")
print(f" - 诊所: 红豆门诊")
print(f" - 处理进度: {len(current_callbacks)} 个患者")
print(f" - 当前索引: {backup_data.get('current_patient_index', 0)}")
restore = input("\n是否恢复这些数据?(Y/n): ").strip().lower()
if restore in ['', 'y', 'yes']:
restore_data(backup_file)
else:
print("取消恢复")
else:
print(f"\n⚠️ 备份文件中当前诊所是: {backup_data.get('current_clinic')}")
print(" 可能红豆门诊数据在其他地方,让我检查all_results...")
all_results = backup_data.get('all_results', {})
if '红豆门诊' in all_results:
red_data = all_results['红豆门诊']
print(f" 🎯 在all_results中找到红豆门诊数据: {len(red_data)} 个患者")
restore = input("\n是否恢复all_results中的红豆门诊数据?(Y/n): ").strip().lower()
if restore in ['', 'y', 'yes']:
restore_all_results_data(backup_file)
else:
print("取消恢复")
else:
print(" ❌ 在all_results中也未找到红豆门诊数据")
except Exception as e:
print(f"❌ 读取备份文件失败: {e}")
import traceback
traceback.print_exc()
def restore_data(backup_file):
"""恢复备份数据"""
print("\n🔄 恢复备份数据...")
try:
# 读取备份数据
with open(backup_file, 'rb') as f:
backup_data = pickle.load(f)
# 恢复当前会话文件
session_file = "progress_saves/session_20250805_232912.pkl"
# 创建恢复前的备份
restore_backup = f"progress_saves/before_restore_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pkl"
shutil.copy2(session_file, restore_backup)
# 恢复数据
with open(session_file, 'wb') as f:
pickle.dump(backup_data, f)
print("✅ 数据恢复成功!")
print(f"✅ 已创建恢复前备份: {os.path.basename(restore_backup)}")
# 验证恢复结果
with open(session_file, 'rb') as f:
restored_data = pickle.load(f)
print(f"\n📊 恢复后状态:")
print(f" - 当前诊所: {restored_data.get('current_clinic', '未知')}")
print(f" - 当前患者索引: {restored_data.get('current_patient_index', 0)}")
current_callbacks = restored_data.get('current_clinic_callbacks', [])
if current_callbacks:
success_count = len([cb for cb in current_callbacks if cb.get('callback_type') == 'success'])
print(f" - 当前诊所处理: {len(current_callbacks)} 个患者 (✅{success_count})")
except Exception as e:
print(f"❌ 恢复失败: {e}")
import traceback
traceback.print_exc()
def restore_all_results_data(backup_file):
"""恢复all_results中的红豆门诊数据"""
print("\n🔄 恢复all_results中的红豆门诊数据...")
try:
# 读取备份数据
with open(backup_file, 'rb') as f:
backup_data = pickle.load(f)
# 读取当前会话数据
session_file = "progress_saves/session_20250805_232912.pkl"
with open(session_file, 'rb') as f:
current_data = pickle.load(f)
# 恢复红豆门诊数据到all_results
if 'all_results' not in current_data:
current_data['all_results'] = {}
red_data = backup_data.get('all_results', {}).get('红豆门诊', [])
current_data['all_results']['红豆门诊'] = red_data
# 将红豆门诊添加到已完成列表
if '红豆门诊' not in current_data.get('completed_clinics', []):
current_data.setdefault('completed_clinics', []).append('红豆门诊')
# 如果当前诊所是红豆门诊,切换到下一个诊所
if current_data.get('current_clinic') == '红豆门诊':
selected_clinics = current_data.get('generation_config', {}).get('selected_clinics', [])
completed_clinics = current_data.get('completed_clinics', [])
remaining_clinics = [c for c in selected_clinics if c not in completed_clinics]
if remaining_clinics:
current_data['current_clinic'] = remaining_clinics[0]
current_data['current_patient_index'] = 0
current_data['current_clinic_callbacks'] = []
print(f" ✅ 切换到下一个诊所: {remaining_clinics[0]}")
# 更新时间戳
current_data['save_timestamp'] = datetime.now().strftime('%Y-%m-%d %H:%M:%S')
current_data['last_save_time'] = datetime.now()
# 保存恢复的数据
with open(session_file, 'wb') as f:
pickle.dump(current_data, f)
print("✅ 红豆门诊数据恢复成功!")
print(f"✅ 恢复 {len(red_data)} 个患者的处理结果")
print(f"✅ 红豆门诊已标记为完成")
except Exception as e:
print(f"❌ 恢复失败: {e}")
import traceback
traceback.print_exc()
if __name__ == "__main__":
check_backup_data()
\ No newline at end of file
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
路由模块
"""
from flask import Blueprint
def register_blueprints(app):
"""注册所有蓝图"""
from .auth import auth_bp
from .patient import patient_bp
from .callback import callback_bp
from .api import api_bp
app.register_blueprint(auth_bp)
app.register_blueprint(patient_bp)
app.register_blueprint(callback_bp)
app.register_blueprint(api_bp, url_prefix='/api')
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
API路由
通用API接口
"""
from flask import Blueprint, jsonify, session, request, send_file, Response
from datetime import datetime
import os
import io
api_bp = Blueprint('api', __name__)
def require_login(f):
"""登录装饰器"""
from functools import wraps
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user' not in session:
return jsonify({'success': False, 'message': '未登录'}), 401
return f(*args, **kwargs)
return decorated_function
@api_bp.route('/health', methods=['GET'])
def health_check():
"""健康检查API"""
return jsonify({
'status': 'healthy',
'timestamp': datetime.now().isoformat(),
'version': '1.0.0'
})
@api_bp.route('/export-data', methods=['GET'])
@require_login
def export_data():
"""数据导出API"""
try:
user = session['user']
# 检查管理员权限
if user.get('role') != 'admin':
return jsonify({
'success': False,
'message': '权限不足,只有管理员可以导出数据'
}), 403
# 导入导出模块
try:
from database.scripts.export_data import DataExporter
except ImportError:
return jsonify({
'success': False,
'message': '导出模块未找到'
}), 500
# 生成Excel文件到内存
exporter = DataExporter()
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_filename = f"回访记录导出_{timestamp}.xlsx"
print(f"开始生成Excel文件: {output_filename}")
# 导出数据到内存缓冲区
try:
excel_buffer = exporter.export_to_memory()
print(f"Excel文件生成完成,大小: {len(excel_buffer)} 字节")
except Exception as export_error:
print(f"导出过程中出错: {export_error}")
import traceback
traceback.print_exc()
return jsonify({'success': False, 'message': f'导出过程出错: {str(export_error)}'}), 500
# 处理中文文件名编码问题
# 使用 RFC 5987 标准的编码方式
import urllib.parse
encoded_filename = urllib.parse.quote(output_filename.encode('utf-8'))
# 直接返回二进制文件流
return Response(
excel_buffer,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
headers={
'Content-Disposition': f'attachment; filename*=UTF-8\'\'{encoded_filename}',
'Content-Length': str(len(excel_buffer))
}
)
except Exception as e:
print(f"❌ 数据导出失败: {e}")
return jsonify({
'success': False,
'message': f'导出失败: {str(e)}'
}), 500
@api_bp.route('/system-info', methods=['GET'])
@require_login
def system_info():
"""系统信息API"""
try:
user = session['user']
# 检查管理员权限
if user.get('role') != 'admin':
return jsonify({
'success': False,
'message': '权限不足'
}), 403
# 获取系统信息
from database.models import CallbackRecord, Patient, Clinic
# 统计数据
total_records = CallbackRecord.query.count()
total_patients = Patient.query.count()
total_clinics = Clinic.query.count()
return jsonify({
'success': True,
'data': {
'total_callback_records': total_records,
'total_patients': total_patients,
'total_clinics': total_clinics,
'system_time': datetime.now().isoformat(),
'database_status': 'connected'
}
})
except Exception as e:
print(f"❌ 获取系统信息失败: {e}")
return jsonify({
'success': False,
'message': f'获取系统信息失败: {str(e)}'
}), 500
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
认证相关路由
"""
from flask import Blueprint, render_template, request, redirect, url_for, session, flash, jsonify
from services.auth_service import authenticate_user, check_clinic_access
auth_bp = Blueprint('auth', __name__)
@auth_bp.route('/')
def index():
"""首页 - 重定向到登录页面"""
return redirect(url_for('auth.login'))
@auth_bp.route('/login', methods=['GET', 'POST'])
def login():
"""登录页面"""
if request.method == 'POST':
username = request.form['username']
password = request.form['password']
# 验证用户
auth_result = authenticate_user(username, password)
if auth_result['success']:
user = auth_result['user']
session['user'] = user
session['username'] = user['username']
session['real_name'] = user['real_name']
session['role'] = user['role']
session['clinic_id'] = user['clinic_id']
session['clinic_name'] = user['clinic_name']
print(f"✅ 用户 {user['real_name']} ({user['username']}) 登录成功")
flash(f'欢迎,{user["real_name"]}!', 'success')
# 根据用户角色重定向
if user['role'] == 'admin':
# 管理员重定向到管理员仪表板
return redirect(url_for('patient.admin_dashboard'))
else:
# 普通用户重定向到患者列表页面(原版行为)
return redirect(url_for('patient.patient_list_original', clinic_id=user['clinic_id']))
else:
flash(auth_result['message'], 'error')
return render_template('login.html')
@auth_bp.route('/logout')
def logout():
"""退出登录"""
username = session.get('real_name', '未知用户')
session.clear()
print(f"✅ 用户 {username} 退出登录")
flash('已成功退出登录', 'info')
return redirect(url_for('auth.login'))
@auth_bp.route('/api/check-session', methods=['GET'])
def check_session():
"""检查会话状态API"""
if 'user' in session:
return jsonify({
'logged_in': True,
'user': {
'username': session.get('username'),
'real_name': session.get('real_name'),
'role': session.get('role'),
'clinic_name': session.get('clinic_name')
}
})
else:
return jsonify({'logged_in': False})
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
回访相关路由
"""
from flask import Blueprint, render_template, session, redirect, url_for, flash, request, jsonify
from services.auth_service import check_clinic_access
from services.callback_service import save_callback_record, get_callback_records, get_clinic_callback_status
from services.patient_service import get_patient_by_id, load_callback_script
from config import get_clinic_info
import os
import json
callback_bp = Blueprint('callback', __name__)
def require_login(f):
"""登录装饰器"""
from functools import wraps
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user' not in session:
flash('请先登录', 'error')
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated_function
@callback_bp.route('/callback/<clinic_id>/<patient_id>')
@require_login
def callback_form(clinic_id, patient_id):
"""回访表单页面"""
user = session['user']
# 检查权限
if not check_clinic_access(user, clinic_id):
flash('您没有权限访问该门诊', 'error')
return redirect(url_for('auth.login'))
# 获取患者信息
patient = get_patient_by_id(clinic_id, patient_id)
if not patient:
flash('未找到该患者信息', 'error')
return redirect(url_for('patient.clinic_patients', clinic_id=clinic_id))
# 加载回访话术
callback_script = load_callback_script(clinic_id, patient_id)
# 加载历史回访记录
callback_records = get_callback_records(patient_id)
return render_template('patient_detail.html',
patient=patient,
clinic_id=clinic_id,
clinic_name=get_clinic_info(clinic_id)['clinic_name'],
callback_script=callback_script,
callback_records=callback_records,
user=user)
@callback_bp.route('/api/save-callback', methods=['POST'])
@require_login
def api_save_callback():
"""保存回访记录API"""
try:
data = request.get_json()
if not data:
return jsonify({'success': False, 'message': '无效的请求数据'}), 400
# 获取当前用户
current_user = session.get('real_name', session.get('username', '未知用户'))
# 保存回访记录
result = save_callback_record(data, current_user)
if result['success']:
return jsonify(result)
else:
return jsonify(result), 500
except Exception as e:
print(f"❌ 保存回访记录API失败: {e}")
return jsonify({
'success': False,
'message': f'保存失败: {str(e)}'
}), 500
@callback_bp.route('/api/callback-records/<case_number>')
@require_login
def api_get_callback_records(case_number):
"""获取回访记录API"""
try:
records = get_callback_records(case_number)
return jsonify({
'success': True,
'data': records,
'count': len(records),
'storage': 'database'
})
except Exception as e:
print(f"❌ 获取回访记录失败: {e}")
return jsonify({
'success': False,
'message': f'获取失败: {str(e)}'
}), 500
@callback_bp.route('/api/callback-status/<clinic_id>')
@require_login
def api_get_callback_status(clinic_id):
"""获取门诊回访状态API"""
user = session['user']
# 检查权限
if not check_clinic_access(user, clinic_id):
return jsonify({'success': False, 'message': '权限不足'}), 403
try:
status_dict = get_clinic_callback_status(clinic_id)
return jsonify({
'success': True,
'data': status_dict
})
except Exception as e:
print(f"❌ 获取回访状态失败: {e}")
return jsonify({
'success': False,
'message': f'获取失败: {str(e)}'
}), 500
@callback_bp.route('/api/generate-callback-script', methods=['POST'])
@require_login
def api_generate_callback_script():
"""生成AI回访话术API"""
try:
data = request.get_json()
if not data:
return jsonify({'success': False, 'message': '无效的请求数据'}), 400
clinic_id = data.get('clinic_id')
patient_id = data.get('patient_id')
if not clinic_id or not patient_id:
return jsonify({'success': False, 'message': '缺少必要参数'}), 400
user = session['user']
# 检查权限
if not check_clinic_access(user, clinic_id):
return jsonify({'success': False, 'message': '权限不足'}), 403
# 获取患者信息
patient = get_patient_by_id(clinic_id, patient_id)
if not patient:
return jsonify({'success': False, 'message': '患者不存在'}), 404
# 这里可以调用Dify API生成回访话术
# 目前先返回一个模拟的话术
mock_script = f"""
<div class="mb-6 p-4 bg-blue-50 border-l-4 border-blue-500 rounded-lg">
<h4 class="font-bold text-blue-700 mb-3 flex items-center">
<i class="fas fa-comment mr-2"></i>问候语
</h4>
<div class="text-gray-700 leading-relaxed">
<div class="mb-1">• 您好,{patient.get('姓名', '患者')},我是{get_clinic_info(clinic_id)['clinic_name']}的回访专员。</div>
<div class="mb-1">• 今天给您打电话是想了解一下您的恢复情况。</div>
</div>
</div>
<div class="mb-6 p-4 bg-green-50 border-l-4 border-green-500 rounded-lg">
<h4 class="font-bold text-green-700 mb-3 flex items-center">
<i class="fas fa-comment mr-2"></i>治疗效果询问
</h4>
<div class="text-gray-700 leading-relaxed">
<div class="mb-1">• 您现在感觉怎么样?有没有什么不适的地方?</div>
<div class="mb-1">• 治疗后的效果如何?是否达到了您的预期?</div>
</div>
</div>
<div class="mb-6 p-4 bg-yellow-50 border-l-4 border-yellow-500 rounded-lg">
<h4 class="font-bold text-yellow-700 mb-3 flex items-center">
<i class="fas fa-comment mr-2"></i>护理建议
</h4>
<div class="text-gray-700 leading-relaxed">
<div class="mb-1">• 请注意保持口腔清洁,按时刷牙漱口。</div>
<div class="mb-1">• 如有任何不适,请及时联系我们。</div>
</div>
</div>
<div class="mb-6 p-4 bg-purple-50 border-l-4 border-purple-500 rounded-lg">
<h4 class="font-bold text-purple-700 mb-3 flex items-center">
<i class="fas fa-comment mr-2"></i>结束语
</h4>
<div class="text-gray-700 leading-relaxed">
<div class="mb-1">• 感谢您选择我们的服务,祝您身体健康!</div>
<div class="mb-1">• 如有任何问题,随时可以联系我们。</div>
</div>
</div>
"""
# 保存生成的话术到文件(模拟)
# 实际应用中应该保存到数据库或调用真实的AI API
return jsonify({
'success': True,
'message': '回访话术生成成功',
'script': mock_script.strip()
})
except Exception as e:
print(f"❌ 生成回访话术失败: {e}")
return jsonify({
'success': False,
'message': f'生成失败: {str(e)}'
}), 500
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
患者相关路由
"""
from flask import Blueprint, render_template, session, redirect, url_for, flash, request, jsonify
from services.auth_service import check_clinic_access
from services.patient_service import load_clinic_patients, get_patient_by_id, load_callback_script, get_clinic_info
from services.callback_service import get_callback_records
from config import get_clinic_info
patient_bp = Blueprint('patient', __name__)
def require_login(f):
"""登录装饰器"""
from functools import wraps
@wraps(f)
def decorated_function(*args, **kwargs):
if 'user' not in session:
flash('请先登录', 'error')
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated_function
@patient_bp.route('/admin/dashboard')
@require_login
def admin_dashboard():
"""管理员仪表板 - 门诊索引页"""
user = session['user']
# 检查管理员权限
if user.get('role') != 'admin':
flash('权限不足', 'error')
return redirect(url_for('auth.login'))
return render_template('admin_dashboard.html', session_data=user, user=user)
@patient_bp.route('/clinic/<clinic_id>/patients')
@require_login
def clinic_patients(clinic_id):
"""门诊患者列表页面"""
user = session['user']
# 检查权限
if not check_clinic_access(user, clinic_id):
flash('您没有权限访问该门诊', 'error')
if user.get('role') == 'admin':
return redirect(url_for('patient.admin_dashboard'))
else:
return redirect(url_for('auth.login'))
# 获取门诊信息
clinic_info = get_clinic_info(clinic_id)
clinic_name = clinic_info['clinic_name']
# 加载患者数据
patients, doctors = load_clinic_patients(clinic_id)
# 所有用户都使用原版的患者列表模板
return render_template('patient_list.html',
session_data=user,
clinic_id=clinic_id,
clinic_name=clinic_name,
patients=patients,
doctors=doctors)
@patient_bp.route('/patient_profiles/<clinic_id>/index.html')
@require_login
def patient_list_original(clinic_id):
"""患者列表页面(原版路由,普通用户登录后的首页)"""
user = session['user']
# 检查权限
if not check_clinic_access(user, clinic_id):
flash('您没有权限访问该门诊', 'error')
if user.get('role') == 'admin':
return redirect(url_for('patient.admin_dashboard'))
else:
return redirect(url_for('auth.login'))
# 获取门诊信息
clinic_info = get_clinic_info(clinic_id)
clinic_name = clinic_info['clinic_name']
# 加载患者数据
patients, doctors = load_clinic_patients(clinic_id)
# 所有用户都使用原版的患者列表模板
return render_template('patient_list.html',
session_data=user,
clinic_id=clinic_id,
clinic_name=clinic_name,
patients=patients,
doctors=doctors)
@patient_bp.route('/clinic/<clinic_id>/patient/<patient_id>')
@require_login
def patient_detail(clinic_id, patient_id):
"""患者详情页面"""
user = session['user']
# 检查权限
if not check_clinic_access(user, clinic_id):
flash('您没有权限访问该门诊', 'error')
return redirect(url_for('patient.clinic_patients', clinic_id=clinic_id))
# 获取患者信息
patient = get_patient_by_id(clinic_id, patient_id)
if not patient:
flash('未找到该患者信息', 'error')
return redirect(url_for('patient.clinic_patients', clinic_id=clinic_id))
# 获取门诊信息
clinic_info = get_clinic_info(clinic_id)
clinic_name = clinic_info['clinic_name']
# 加载回访话术
callback_script = load_callback_script(clinic_id, patient_id)
# 加载历史回访记录
callback_records = get_callback_records(patient_id)
return render_template('patient_detail.html',
patient=patient,
clinic_id=clinic_id,
clinic_name=clinic_name,
callback_script=callback_script,
callback_records=callback_records,
user=user)
@patient_bp.route('/api/patients/<clinic_id>')
@require_login
def api_get_patients(clinic_id):
"""获取门诊患者列表API"""
user = session['user']
# 检查权限
if not check_clinic_access(user, clinic_id):
return jsonify({'success': False, 'message': '权限不足'}), 403
try:
patients, _ = load_clinic_patients(clinic_id)
return jsonify({
'success': True,
'data': patients,
'count': len(patients)
})
except Exception as e:
return jsonify({
'success': False,
'message': f'获取患者列表失败: {str(e)}'
}), 500
@patient_bp.route('/api/patient/<clinic_id>/<patient_id>')
@require_login
def api_get_patient(clinic_id, patient_id):
"""获取患者详情API"""
user = session['user']
# 检查权限
if not check_clinic_access(user, clinic_id):
return jsonify({'success': False, 'message': '权限不足'}), 403
try:
patient = get_patient_by_id(clinic_id, patient_id)
if not patient:
return jsonify({'success': False, 'message': '患者不存在'}), 404
return jsonify({
'success': True,
'data': patient
})
except Exception as e:
return jsonify({
'success': False,
'message': f'获取患者信息失败: {str(e)}'
}), 500
# 服务层模块
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
认证服务层
处理用户认证相关的业务逻辑
"""
from config import get_user_by_username
def authenticate_user(username, password):
"""验证用户登录"""
try:
user = get_user_by_username(username)
if user and user['password'] == password:
return {
'success': True,
'user': user,
'message': '登录成功'
}
else:
return {
'success': False,
'message': '用户名或密码错误'
}
except Exception as e:
print(f"❌ 用户认证失败: {e}")
return {
'success': False,
'message': f'认证失败: {str(e)}'
}
def check_clinic_access(user, clinic_id):
"""检查用户是否有权限访问指定门诊"""
try:
# 管理员可以访问所有门诊
if user.get('role') == 'admin':
return True
# 普通用户只能访问自己的门诊
return user.get('clinic_id') == clinic_id
except Exception as e:
print(f"❌ 权限检查失败: {e}")
return False
def get_user_clinics(user):
"""获取用户可访问的门诊列表"""
try:
if user.get('role') == 'admin':
# 管理员可以访问所有门诊
from config import CLINIC_CONFIG
return list(CLINIC_CONFIG.keys())
else:
# 普通用户只能访问自己的门诊
clinic_id = user.get('clinic_id')
return [clinic_id] if clinic_id else []
except Exception as e:
print(f"❌ 获取用户门诊列表失败: {e}")
return []
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
回访服务层
处理回访记录相关的业务逻辑
"""
from datetime import datetime
from database.models import db, CallbackRecord
# 内存存储最新的回访结果,用于状态API
callback_results_cache = {}
def save_callback_record(data, current_user):
"""保存回访记录"""
try:
# 构建回访记录内容
callback_record = f"""
回访时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}
回访方式: {', '.join(data['callbackMethods'])}
回访结果: {data['callbackResult']}
操作员: {current_user}
"""
# 如果有下次预约时间,添加到记录中
if data.get('nextAppointmentTime'):
callback_record += f"\n下次预约时间: {data['nextAppointmentTime']}"
# 如果有失败原因,添加到记录中
if data.get('failureReason'):
callback_record += f"\n失败原因: {data['failureReason']}"
# 如果有放弃原因,添加到记录中
if data.get('abandonReason'):
callback_record += f"\n放弃原因: {data['abandonReason']}"
# 如果有AI反馈,添加到记录中
if data.get('aiFeedbackType'):
callback_record += f"\nAI反馈类型: {data['aiFeedbackType']}"
result = data['callbackResult']
# 使用SQLAlchemy ORM保存记录
record = CallbackRecord(
case_number=data['caseNumber'],
callback_methods=data['callbackMethods'],
callback_record=callback_record, # 使用构建好的回访记录内容
callback_result=result, # 传递回访结果
next_appointment_time=data.get('nextAppointmentTime'),
failure_reason=data.get('failureReason'),
abandon_reason=data.get('abandonReason'),
ai_feedback_type=data.get('aiFeedbackType'),
failure_reason_note=data.get('failureReasonNote'),
abandon_reason_note=data.get('abandonReasonNote'),
ai_feedback_note=data.get('aiFeedbackNote'),
callback_status=data.get('callbackStatus', '已回访'),
operator=current_user # 使用从session获取的真实用户名
)
db.session.add(record)
db.session.commit()
record_id = record.record_id
# 更新内存缓存
callback_results_cache[data['caseNumber']] = {
'callback_result': result,
'create_time': datetime.now()
}
return {
'success': True,
'id': record_id,
'message': '保存成功',
'timestamp': datetime.now().isoformat(),
'storage': 'database',
'operator': current_user
}
except Exception as e:
db.session.rollback()
print(f"❌ 保存回访记录失败: {e}")
return {
'success': False,
'message': f'保存失败: {str(e)}'
}
def get_callback_records(case_number):
"""获取患者的回访记录"""
try:
# 使用SQLAlchemy ORM查询
records = CallbackRecord.query.filter_by(case_number=case_number).order_by(CallbackRecord.create_time.desc()).all()
return [record.to_dict() for record in records]
except Exception as e:
print(f"❌ 获取回访记录失败: {e}")
return []
def get_clinic_callback_status(clinic_id):
"""获取指定门诊所有患者的回访状态"""
try:
# 使用SQLAlchemy ORM查询所有回访记录
all_records = CallbackRecord.query.order_by(CallbackRecord.create_time.desc()).all()
# 按病历号分组,获取最新的回访状态
status_map = {}
for record in all_records:
case_number = record.case_number
# 如果该病历号还没有记录,或者当前记录更新,则更新状态
if case_number not in status_map:
status_map[case_number] = {
'case_number': case_number,
'callback_status': '已回访',
'callback_result': record.callback_result, # 直接使用存储的结果
'create_time': record.create_time.isoformat() if record.create_time else None
}
else:
# 比较时间,保留最新的记录
current_time = record.create_time
existing_time = status_map[case_number]['create_time']
if current_time and existing_time:
try:
if isinstance(existing_time, str):
existing_time = datetime.fromisoformat(existing_time.replace('Z', '+00:00'))
if current_time > existing_time:
status_map[case_number] = {
'case_number': case_number,
'callback_status': '已回访',
'callback_result': record.callback_result, # 直接使用存储的结果
'create_time': current_time.isoformat() if current_time else None
}
except Exception as e:
print(f"时间比较错误: {e}")
# 转换为以病历号为键的字典格式,方便前端查找
status_dict = {}
for status in status_map.values():
status_dict[status['case_number']] = status
return status_dict
except Exception as e:
print(f"❌ 获取回访状态失败: {e}")
return {}
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
患者服务层
处理患者数据相关的业务逻辑
"""
import json
import os
from config import get_clinic_info
def load_clinic_patients(clinic_id):
"""加载指定门诊的患者数据,返回(patients, doctors)元组"""
try:
# 获取门诊信息
clinic_info = get_clinic_info(clinic_id)
clinic_name = clinic_info.get('clinic_name', '未知门诊')
patients = []
# 首先尝试从数据库加载
try:
from database.models import Patient
db_patients = Patient.query.filter_by(clinic_name=clinic_name).all()
if db_patients:
patients = [patient.to_dict() for patient in db_patients]
print(f"✅ 从数据库加载 {clinic_name} 患者数据成功: {len(patients)} 名患者")
except Exception as db_error:
print(f"⚠️ 数据库查询失败: {db_error}")
# 如果数据库没有数据,从文件系统加载
if not patients:
# 优先尝试读取单独的门诊文件
json_file_path = f'data/patients/clinics/{clinic_name}.json'
if os.path.exists(json_file_path):
print(f"📁 从门诊文件加载患者数据: {json_file_path}")
with open(json_file_path, 'r', encoding='utf-8') as f:
patients = json.load(f)
print(f"✅ 加载 {clinic_name} 患者数据成功: {len(patients)} 名患者")
else:
# 最后尝试读取合并结果文件
merged_file_path = 'data/patients/merged/合并结果.json'
if os.path.exists(merged_file_path):
print(f"📁 从合并文件加载患者数据: {merged_file_path}")
with open(merged_file_path, 'r', encoding='utf-8') as f:
all_data = json.load(f)
# 筛选出指定门诊的患者(使用最后一次就诊诊所字段)
clinic_patients = []
# 创建门诊名称映射表
clinic_name_mapping = {
'学前街门诊': '江苏瑞泰通善口腔学前街医院',
'大丰门诊': '江苏瑞泰通善口腔大丰医院',
'东亭门诊': '江苏瑞泰通善口腔东亭医院',
'河埒门诊': '江苏瑞泰通善口腔河埒医院',
'红豆门诊': '江苏瑞泰通善口腔红豆医院',
'惠山门诊': '江苏瑞泰通善口腔惠山医院',
'马山门诊': '江苏瑞泰通善口腔马山医院',
'通善口腔医院': '江苏瑞泰通善口腔医院',
'新吴门诊': '江苏瑞泰通善口腔新吴医院'
}
# 获取实际的门诊名称
actual_clinic_name = clinic_name_mapping.get(clinic_name, clinic_name)
for patient in all_data:
last_clinic = patient.get('最后一次就诊诊所', '')
if actual_clinic_name in last_clinic or clinic_name in last_clinic:
clinic_patients.append(patient)
patients = clinic_patients
print(f"✅ 从合并文件加载 {clinic_name} 患者数据成功: {len(clinic_patients)} 名患者")
# 提取医生列表
doctors = set()
for patient in patients:
doctor = patient.get('最后一次就诊医生')
if doctor and doctor.strip():
doctors.add(doctor.strip())
doctors_list = sorted(list(doctors))
print(f"✅ 加载 {clinic_name} 患者数据: {len(patients)} 人, {len(doctors_list)} 位医生")
return patients, doctors_list
except Exception as e:
print(f"❌ 加载患者数据失败: {e}")
return [], []
def format_callback_script(script):
"""格式化回访话术为HTML显示"""
if not script:
return None
try:
import re
# 移除开头的JSON部分
script = re.sub(r'^```json.*?```\n', '', script, flags=re.DOTALL)
# 分割各个部分
sections = []
current_section = None
current_content = []
lines = script.split('\n')
for line in lines:
line = line.strip()
if not line:
continue
# 检查是否是新的部分标题
if line.startswith('═══') and '部分:' in line:
# 保存上一个部分
if current_section and current_content:
sections.append({
'title': current_section,
'content': current_content.copy()
})
# 开始新的部分
current_section = line.replace('═══', '').strip()
current_content = []
elif line.startswith('•'):
# 添加内容项
current_content.append(line[1:].strip())
elif line.startswith('**') and line.endswith('**'):
# 子标题
current_content.append(f"<strong>{line[2:-2]}</strong>")
elif line and current_section:
# 其他内容
current_content.append(line)
# 保存最后一个部分
if current_section and current_content:
sections.append({
'title': current_section,
'content': current_content.copy()
})
# 生成HTML
if not sections:
return script # 如果解析失败,返回原始内容
html_parts = []
colors = [
('bg-blue-100', 'border-blue-500', 'text-blue-800'),
('bg-yellow-100', 'border-yellow-500', 'text-yellow-800'),
('bg-green-100', 'border-green-500', 'text-green-800'),
('bg-purple-100', 'border-purple-500', 'text-purple-800')
]
for i, section in enumerate(sections):
bg_color, border_color, text_color = colors[i % len(colors)]
content_html = []
for item in section['content']:
if item.startswith('<strong>'):
content_html.append(f'<div class="font-semibold mt-2 mb-1">{item}</div>')
else:
content_html.append(f'<div class="mb-1">• {item}</div>')
section_html = f'''
<div class="mb-6 p-4 {bg_color} border-l-4 {border_color} rounded-lg">
<h4 class="font-bold {text_color} mb-3 flex items-center">
<i class="fas fa-comment mr-2"></i>{section['title']}
</h4>
<div class="text-gray-700 leading-relaxed">
{''.join(content_html)}
</div>
</div>'''
html_parts.append(section_html)
return ''.join(html_parts)
except Exception as e:
print(f"❌ 格式化回访话术失败: {e}")
return script # 返回原始内容
def load_callback_script(clinic_id, patient_id):
"""加载患者的回访话术"""
try:
# 获取门诊信息
clinic_info = get_clinic_info(clinic_id)
clinic_name = clinic_info.get('clinic_name', '未知门诊')
# 尝试多种文件名格式
possible_files = [
f'data/callbacks/中间结果_{clinic_name}.json',
f'data/callbacks/中间结果_{clinic_name}_*.json'
]
# 查找匹配的文件
import glob
for pattern in possible_files:
if '*' in pattern:
# 使用glob查找带时间戳的文件
matching_files = glob.glob(pattern)
if matching_files:
# 使用最新的文件
callback_file = max(matching_files, key=os.path.getmtime)
break
else:
if os.path.exists(pattern):
callback_file = pattern
break
else:
print(f"⚠️ 未找到 {clinic_name} 的回访话术文件")
return "暂无个性化回访话术,请使用通用话术进行回访。"
# 读取回访话术文件
with open(callback_file, 'r', encoding='utf-8') as f:
callback_data = json.load(f)
# 查找指定患者的回访话术
if 'callbacks' in callback_data:
for callback in callback_data['callbacks']:
if callback.get('patient_id') == patient_id or callback.get('病历号') == patient_id:
script = callback.get('callback_script') or callback.get('回访话术', '')
if script:
print(f"✅ 找到患者 {patient_id} 的回访话术,长度: {len(script)}")
return format_callback_script(script)
print(f"⚠️ 未找到患者 {patient_id} 的个性化回访话术")
return "暂无个性化回访话术,请使用通用话术进行回访。"
except Exception as e:
print(f"❌ 加载回访话术失败: {e}")
return "加载回访话术失败,请使用通用话术进行回访。"
def get_patient_by_id(clinic_id, patient_id):
"""根据患者ID获取患者详细信息"""
try:
patients, _ = load_clinic_patients(clinic_id)
for patient in patients:
if patient.get('病历号') == patient_id:
return patient
return None
except Exception as e:
print(f"❌ 获取患者信息失败: {e}")
return None
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
会话管理模块
处理用户登录状态、会话令牌和权限验证
"""
import secrets
import time
import hashlib
from typing import Dict, Optional, Any
from datetime import datetime, timedelta
from user_manager import User
class Session:
"""会话类"""
def __init__(self, session_id: str, user_id: int, username: str, role: str,
created_at: datetime, expires_at: datetime):
"""
初始化会话对象
Args:
session_id: 会话ID
user_id: 用户ID
username: 用户名
role: 用户角色
created_at: 创建时间
expires_at: 过期时间
"""
self.session_id = session_id
self.user_id = user_id
self.username = username
self.role = role
self.created_at = created_at
self.expires_at = expires_at
self.last_activity = datetime.now()
def is_expired(self) -> bool:
"""检查会话是否过期"""
return datetime.now() > self.expires_at
def is_admin(self) -> bool:
"""检查是否为管理员"""
return self.role == 'admin'
def to_dict(self) -> Dict[str, Any]:
"""转换为字典格式"""
return {
'session_id': self.session_id,
'user_id': self.user_id,
'username': self.username,
'role': self.role,
'created_at': self.created_at.isoformat(),
'expires_at': self.expires_at.isoformat(),
'last_activity': self.last_activity.isoformat()
}
class SessionManager:
"""会话管理器"""
def __init__(self, session_timeout_hours: int = 24):
"""
初始化会话管理器
Args:
session_timeout_hours: 会话超时时间(小时)
"""
self.sessions: Dict[str, Session] = {}
self.session_timeout_hours = session_timeout_hours
# 启动清理线程(简化版,实际使用中可以用定时任务)
self._last_cleanup = time.time()
def generate_session_id(self, user_id: int) -> str:
"""
生成会话ID
Args:
user_id: 用户ID
Returns:
会话ID
"""
# 使用用户ID、时间戳和随机数生成唯一会话ID
timestamp = str(int(time.time()))
random_part = secrets.token_hex(16)
data = f"{user_id}:{timestamp}:{random_part}"
return hashlib.sha256(data.encode()).hexdigest()
def create_session(self, user: User) -> Session:
"""
创建新会话
Args:
user: 用户对象
Returns:
会话对象
"""
session_id = self.generate_session_id(user.user_id)
created_at = datetime.now()
expires_at = created_at + timedelta(hours=self.session_timeout_hours)
session = Session(
session_id=session_id,
user_id=user.user_id,
username=user.username,
role=user.role,
created_at=created_at,
expires_at=expires_at
)
# 存储会话
self.sessions[session_id] = session
# 清理过期会话
self._cleanup_expired_sessions()
print(f"创建会话: {user.username} ({session_id[:8]}...)")
return session
def get_session(self, session_id: str) -> Optional[Session]:
"""
获取会话
Args:
session_id: 会话ID
Returns:
会话对象,如果不存在或已过期返回None
"""
if not session_id or session_id not in self.sessions:
return None
session = self.sessions[session_id]
# 检查是否过期
if session.is_expired():
self.destroy_session(session_id)
return None
# 更新最后活动时间
session.last_activity = datetime.now()
return session
def destroy_session(self, session_id: str) -> bool:
"""
销毁会话
Args:
session_id: 会话ID
Returns:
成功返回True
"""
if session_id in self.sessions:
session = self.sessions[session_id]
print(f"销毁会话: {session.username} ({session_id[:8]}...)")
del self.sessions[session_id]
return True
return False
def destroy_user_sessions(self, user_id: int):
"""
销毁指定用户的所有会话
Args:
user_id: 用户ID
"""
session_ids_to_remove = []
for session_id, session in self.sessions.items():
if session.user_id == user_id:
session_ids_to_remove.append(session_id)
for session_id in session_ids_to_remove:
self.destroy_session(session_id)
def validate_session(self, session_id: str) -> bool:
"""
验证会话有效性
Args:
session_id: 会话ID
Returns:
有效返回True
"""
session = self.get_session(session_id)
return session is not None
def require_login(self, session_id: str) -> Optional[Session]:
"""
要求用户登录
Args:
session_id: 会话ID
Returns:
有效会话对象,否则返回None
"""
return self.get_session(session_id)
def require_admin(self, session_id: str) -> Optional[Session]:
"""
要求管理员权限
Args:
session_id: 会话ID
Returns:
有效的管理员会话对象,否则返回None
"""
session = self.get_session(session_id)
if session and session.is_admin():
return session
return None
def _cleanup_expired_sessions(self):
"""清理过期会话"""
current_time = time.time()
# 每小时清理一次
if current_time - self._last_cleanup < 3600:
return
self._last_cleanup = current_time
expired_sessions = []
for session_id, session in self.sessions.items():
if session.is_expired():
expired_sessions.append(session_id)
for session_id in expired_sessions:
self.destroy_session(session_id)
if expired_sessions:
print(f"清理了 {len(expired_sessions)} 个过期会话")
def get_active_sessions(self) -> Dict[str, Session]:
"""获取所有活跃会话"""
self._cleanup_expired_sessions()
return self.sessions.copy()
def get_session_count(self) -> int:
"""获取活跃会话数量"""
self._cleanup_expired_sessions()
return len(self.sessions)
def extend_session(self, session_id: str, hours: int = None) -> bool:
"""
延长会话时间
Args:
session_id: 会话ID
hours: 延长小时数,默认使用配置的超时时间
Returns:
成功返回True
"""
session = self.get_session(session_id)
if not session:
return False
extend_hours = hours or self.session_timeout_hours
session.expires_at = datetime.now() + timedelta(hours=extend_hours)
return True
# 全局会话管理器实例
session_manager = SessionManager()
def get_session_manager() -> SessionManager:
"""获取会话管理器实例"""
return session_manager
if __name__ == "__main__":
# 测试会话管理器
print("=== 会话管理器测试 ===")
from user_manager import User
# 创建测试用户
test_user = User(
user_id=1,
username="test_user",
role="user"
)
# 创建会话
sm = get_session_manager()
session = sm.create_session(test_user)
print(f"创建会话: {session.session_id}")
print(f"会话有效: {sm.validate_session(session.session_id)}")
# 获取会话
retrieved_session = sm.get_session(session.session_id)
if retrieved_session:
print(f"获取会话成功: {retrieved_session.username}")
# 销毁会话
sm.destroy_session(session.session_id)
print(f"会话已销毁: {sm.validate_session(session.session_id)}")
\ No newline at end of file
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
迁移系统设置脚本
用于从旧的数据库管理方式迁移到新的迁移管理系统
"""
import os
import shutil
import subprocess
from datetime import datetime
def setup_migration_system():
"""设置迁移系统"""
print("🚀 开始设置数据库迁移管理系统...")
# 1. 创建迁移目录
print("📁 创建迁移目录...")
os.makedirs("migrations", exist_ok=True)
print("✅ 迁移目录创建完成")
# 2. 备份现有SQL文件
print("📦 备份现有SQL文件...")
sql_files = [
"init.sql",
"create_users.sql",
"create_clinic_users.sql",
"fix_admin_user.sql",
"fix_passwords.sql",
"update_password_hashes.sql",
"update_users_with_salt.sql",
"complete_hashes.sql"
]
backup_dir = f"sql_backup_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
os.makedirs(backup_dir, exist_ok=True)
for sql_file in sql_files:
if os.path.exists(sql_file):
shutil.copy2(sql_file, backup_dir)
print(f" ✅ 备份: {sql_file}")
print(f"📦 SQL文件备份完成: {backup_dir}")
# 3. 设置权限
print("🔧 设置脚本权限...")
scripts = ["migrate.py", "deploy_scripts/deploy_with_migration.sh"]
for script in scripts:
if os.path.exists(script):
os.chmod(script, 0o755)
print(f" ✅ 设置权限: {script}")
# 4. 验证Python依赖
print("🐍 检查Python依赖...")
try:
import pymysql
print(" ✅ pymysql 已安装")
except ImportError:
print(" ❌ pymysql 未安装,请运行: pip install pymysql")
# 5. 测试数据库连接
print("🗄️ 测试数据库连接...")
try:
from database_config import get_database_config
from database_migration_manager import DatabaseMigrationManager
db_config = get_database_config()
manager = DatabaseMigrationManager(db_config)
print(" ✅ 数据库连接测试成功")
print(" ✅ 迁移历史表初始化完成")
except Exception as e:
print(f" ❌ 数据库连接失败: {e}")
print(" 请检查数据库配置和连接")
# 6. 创建示例迁移
print("📝 创建示例迁移文件...")
if not os.path.exists("migrations/20250101_000000_initial_schema.sql"):
print(" ✅ 初始架构迁移文件已存在")
else:
print(" ✅ 示例迁移文件已创建")
# 7. 更新部署脚本权限
print("🚀 更新部署脚本...")
deploy_scripts = [
"deploy_scripts/deploy_with_backup.sh",
"deploy_scripts/deploy_with_migration.sh"
]
for script in deploy_scripts:
if os.path.exists(script):
os.chmod(script, 0o755)
print(f" ✅ 更新权限: {script}")
print("\n🎉 迁移系统设置完成!")
print("\n📋 下一步操作:")
print("1. 检查并更新 database_config.py 中的数据库配置")
print("2. 运行 'python migrate.py status' 检查系统状态")
print("3. 如需要,创建新的迁移文件: 'python migrate.py create \"migration_name\"'")
print("4. 在测试环境验证迁移: 'python migrate.py migrate --dry-run'")
print("5. 更新 CI/CD 配置: 将 .gitlab-ci-new.yml 重命名为 .gitlab-ci.yml")
print("\n📖 详细使用说明请参考: DATABASE_MIGRATION_GUIDE.md")
def cleanup_old_system():
"""清理旧系统文件(可选)"""
print("\n🧹 清理旧系统文件...")
# 询问是否清理
response = input("是否要清理旧的SQL文件?(y/N): ").lower().strip()
if response not in ['y', 'yes']:
print("跳过清理步骤")
return
# 移动旧文件到归档目录
archive_dir = f"legacy_sql_{datetime.now().strftime('%Y%m%d_%H%M%S')}"
os.makedirs(archive_dir, exist_ok=True)
old_files = [
"fix_admin_user.sql",
"fix_passwords.sql",
"update_password_hashes.sql",
"update_users_with_salt.sql",
"complete_hashes.sql"
]
for old_file in old_files:
if os.path.exists(old_file):
shutil.move(old_file, archive_dir)
print(f" ✅ 归档: {old_file}")
print(f"📦 旧文件已归档到: {archive_dir}")
def show_migration_status():
"""显示迁移状态"""
print("\n📊 当前迁移状态:")
try:
result = subprocess.run(
["python", "migrate.py", "status"],
capture_output=True,
text=True
)
print(result.stdout)
if result.stderr:
print("错误信息:", result.stderr)
except Exception as e:
print(f"无法获取迁移状态: {e}")
def main():
"""主函数"""
print("=" * 60)
print("🗄️ 数据库迁移管理系统设置向导")
print("=" * 60)
# 检查当前目录
if not os.path.exists("app.py"):
print("❌ 请在项目根目录运行此脚本")
return 1
# 设置迁移系统
setup_migration_system()
# 显示当前状态
show_migration_status()
# 询问是否清理旧系统
cleanup_old_system()
print("\n✅ 设置完成!迁移系统已就绪。")
return 0
if __name__ == "__main__":
exit(main())
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
简单的数据库备份工具
无需特殊权限,通过应用程序直接备份
"""
import os
import sys
import pymysql
import json
from datetime import datetime
import zipfile
# 数据库配置
DB_CONFIG = {
'host': 'localhost',
'port': 3306,
'user': 'callback_user',
'password': 'dev_password_123',
'database': 'callback_system',
'charset': 'utf8mb4'
}
def create_backup_directory():
"""创建备份目录"""
backup_dir = "database_backups"
if not os.path.exists(backup_dir):
os.makedirs(backup_dir)
print(f"✅ 创建备份目录: {backup_dir}")
return backup_dir
def backup_database(backup_dir):
"""备份数据库"""
try:
print("🔌 连接数据库...")
connection = pymysql.connect(**DB_CONFIG)
cursor = connection.cursor()
# 生成备份文件名
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
backup_file = os.path.join(backup_dir, f"production_backup_{timestamp}.sql")
print(f"📋 开始备份数据库: {DB_CONFIG['database']}")
# 获取所有表名
cursor.execute("SHOW TABLES")
tables = [table[0] for table in cursor.fetchall()]
print(f"📊 发现 {len(tables)} 个表")
with open(backup_file, 'w', encoding='utf-8') as f:
# 写入备份头部信息
f.write(f"-- 患者画像回访话术系统数据库备份\n")
f.write(f"-- 备份时间: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n")
f.write(f"-- 数据库: {DB_CONFIG['database']}\n")
f.write(f"-- 表数量: {len(tables)}\n\n")
# 备份每个表
for i, table_name in enumerate(tables, 1):
print(f" [{i}/{len(tables)}] 备份表: {table_name}")
# 获取表结构
cursor.execute(f"SHOW CREATE TABLE `{table_name}`")
create_table_sql = cursor.fetchone()[1]
f.write(f"\n-- 表结构: {table_name}\n")
f.write(f"DROP TABLE IF EXISTS `{table_name}`;\n")
f.write(f"{create_table_sql};\n\n")
# 获取表数据
cursor.execute(f"SELECT COUNT(*) FROM `{table_name}`")
row_count = cursor.fetchone()[0]
if row_count > 0:
f.write(f"-- 表数据: {table_name} ({row_count} 行)\n")
# 分批获取数据避免内存问题
batch_size = 1000
offset = 0
while offset < row_count:
cursor.execute(f"SELECT * FROM `{table_name}` LIMIT {batch_size} OFFSET {offset}")
rows = cursor.fetchall()
for row in rows:
# 构建INSERT语句
values = []
for value in row:
if value is None:
values.append('NULL')
elif isinstance(value, (int, float)):
values.append(str(value))
else:
# 转义字符串
escaped_value = str(value).replace("'", "''").replace("\\", "\\\\")
values.append(f"'{escaped_value}'")
f.write(f"INSERT INTO `{table_name}` VALUES ({', '.join(values)});\n")
offset += batch_size
print(f" ✅ 已备份 {min(offset, row_count)}/{row_count} 行")
f.write("\n")
else:
print(f" ⚠️ 表 {table_name} 无数据")
connection.close()
# 获取备份文件大小
file_size = os.path.getsize(backup_file)
size_mb = file_size / (1024 * 1024)
print(f"✅ 数据库备份完成!")
print(f"📁 备份文件: {backup_file}")
print(f"📏 文件大小: {size_mb:.2f} MB")
return backup_file
except Exception as e:
print(f"❌ 数据库备份失败: {e}")
if 'connection' in locals():
connection.close()
return None
def create_backup_zip(backup_file, backup_dir):
"""创建备份压缩包"""
try:
# 生成压缩包文件名
backup_name = os.path.basename(backup_file)
zip_name = backup_name.replace('.sql', '.zip')
zip_path = os.path.join(backup_dir, zip_name)
print(f"📦 创建压缩包: {zip_name}")
with zipfile.ZipFile(zip_path, 'w', zipfile.ZIP_DEFLATED) as zipf:
# 添加备份文件
zipf.write(backup_file, backup_name)
# 添加备份信息
info = {
'backup_time': datetime.now().isoformat(),
'database': DB_CONFIG['database'],
'backup_file': backup_name,
'backup_size': os.path.getsize(backup_file),
'compressed_size': os.path.getsize(zip_path)
}
zipf.writestr('backup_info.json', json.dumps(info, ensure_ascii=False, indent=2))
# 获取压缩包大小
zip_size = os.path.getsize(zip_path)
zip_size_mb = zip_size / (1024 * 1024)
print(f"✅ 压缩包创建完成!")
print(f"📁 压缩包: {zip_path}")
print(f"📏 压缩后大小: {zip_size_mb:.2f} MB")
return zip_path
except Exception as e:
print(f"❌ 创建压缩包失败: {e}")
return None
def cleanup_old_backups(backup_dir, keep_days=7):
"""清理旧备份文件"""
try:
print(f"🧹 清理 {keep_days} 天前的旧备份文件...")
current_time = datetime.now()
deleted_count = 0
for filename in os.listdir(backup_dir):
file_path = os.path.join(backup_dir, filename)
# 检查文件修改时间
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
days_old = (current_time - file_time).days
if days_old > keep_days:
os.remove(file_path)
print(f" 🗑️ 删除旧文件: {filename}")
deleted_count += 1
if deleted_count > 0:
print(f"✅ 清理完成,删除了 {deleted_count} 个旧文件")
else:
print("✅ 无需清理,所有备份文件都是最新的")
except Exception as e:
print(f"⚠️ 清理旧备份失败: {e}")
def show_backup_list(backup_dir):
"""显示备份文件列表"""
try:
print(f"\n📋 备份文件列表 ({backup_dir}):")
print("-" * 80)
files = []
for filename in os.listdir(backup_dir):
if filename.endswith(('.sql', '.zip')):
file_path = os.path.join(backup_dir, filename)
file_size = os.path.getsize(file_path)
file_time = datetime.fromtimestamp(os.path.getmtime(file_path))
files.append((filename, file_size, file_time))
if not files:
print("暂无备份文件")
return
# 按时间排序
files.sort(key=lambda x: x[2], reverse=True)
for filename, file_size, file_time in files:
size_mb = file_size / (1024 * 1024)
time_str = file_time.strftime("%Y-%m-%d %H:%M:%S")
file_type = "📦" if filename.endswith('.zip') else "🗄️"
print(f"{file_type} {filename}")
print(f" 📅 创建时间: {time_str}")
print(f" 📏 文件大小: {size_mb:.2f} MB")
print()
except Exception as e:
print(f"❌ 显示备份列表失败: {e}")
def main():
"""主函数"""
print("🚀 患者画像回访话术系统 - 数据库备份工具")
print("=" * 60)
try:
# 创建备份目录
backup_dir = create_backup_directory()
# 备份数据库
backup_file = backup_database(backup_dir)
if not backup_file:
print("❌ 备份失败,程序退出")
return
# 创建压缩包
zip_file = create_backup_zip(backup_file, backup_dir)
if zip_file:
# 删除原始SQL文件(保留压缩包)
os.remove(backup_file)
print(f"🗑️ 已删除原始SQL文件,保留压缩包")
# 清理旧备份
cleanup_old_backups(backup_dir)
# 显示备份列表
show_backup_list(backup_dir)
print("\n🎉 备份流程完成!")
print(f"💡 提示: 备份文件保存在 {backup_dir} 目录中")
print(f"💡 提示: 建议将备份文件复制到安全位置保存")
except KeyboardInterrupt:
print("\n\n⚠️ 用户中断操作")
except Exception as e:
print(f"\n❌ 备份过程出错: {e}")
if __name__ == "__main__":
main()
\ No newline at end of file
...@@ -199,63 +199,63 @@ ...@@ -199,63 +199,63 @@
<h3>学前街门诊</h3> <h3>学前街门诊</h3>
<p>门诊ID: clinic_xuexian</p> <p>门诊ID: clinic_xuexian</p>
<p>预期患者数: 765</p> <p>预期患者数: 765</p>
<a href="/patient_profiles/clinic_xuexian/index.html" class="clinic-link">查看患者画像</a> <a href="{{ url_for('patient.clinic_patients', clinic_id='clinic_xuexian') }}" class="clinic-link">查看患者画像</a>
</div> </div>
<div class="clinic-card"> <div class="clinic-card">
<h3>大丰门诊</h3> <h3>大丰门诊</h3>
<p>门诊ID: clinic_dafeng</p> <p>门诊ID: clinic_dafeng</p>
<p>预期患者数: 598</p> <p>预期患者数: 598</p>
<a href="/patient_profiles/clinic_dafeng/index.html" class="clinic-link">查看患者画像</a> <a href="{{ url_for('patient.clinic_patients', clinic_id='clinic_dafeng') }}" class="clinic-link">查看患者画像</a>
</div> </div>
<div class="clinic-card"> <div class="clinic-card">
<h3>东亭门诊</h3> <h3>东亭门诊</h3>
<p>门诊ID: clinic_dongting</p> <p>门诊ID: clinic_dongting</p>
<p>预期患者数: 479</p> <p>预期患者数: 479</p>
<a href="/patient_profiles/clinic_dongting/index.html" class="clinic-link">查看患者画像</a> <a href="{{ url_for('patient.clinic_patients', clinic_id='clinic_dongting') }}" class="clinic-link">查看患者画像</a>
</div> </div>
<div class="clinic-card"> <div class="clinic-card">
<h3>河埒门诊</h3> <h3>河埒门诊</h3>
<p>门诊ID: clinic_helai</p> <p>门诊ID: clinic_helai</p>
<p>预期患者数: 108</p> <p>预期患者数: 108</p>
<a href="/patient_profiles/clinic_helai/index.html" class="clinic-link">查看患者画像</a> <a href="{{ url_for('patient.clinic_patients', clinic_id='clinic_helai') }}" class="clinic-link">查看患者画像</a>
</div> </div>
<div class="clinic-card"> <div class="clinic-card">
<h3>红豆门诊</h3> <h3>红豆门诊</h3>
<p>门诊ID: clinic_hongdou</p> <p>门诊ID: clinic_hongdou</p>
<p>预期患者数: 500</p> <p>预期患者数: 500</p>
<a href="/patient_profiles/clinic_hongdou/index.html" class="clinic-link">查看患者画像</a> <a href="{{ url_for('patient.clinic_patients', clinic_id='clinic_hongdou') }}" class="clinic-link">查看患者画像</a>
</div> </div>
<div class="clinic-card"> <div class="clinic-card">
<h3>惠山门诊</h3> <h3>惠山门诊</h3>
<p>门诊ID: clinic_huishan</p> <p>门诊ID: clinic_huishan</p>
<p>预期患者数: 323</p> <p>预期患者数: 323</p>
<a href="/patient_profiles/clinic_huishan/index.html" class="clinic-link">查看患者画像</a> <a href="{{ url_for('patient.clinic_patients', clinic_id='clinic_huishan') }}" class="clinic-link">查看患者画像</a>
</div> </div>
<div class="clinic-card"> <div class="clinic-card">
<h3>马山门诊</h3> <h3>马山门诊</h3>
<p>门诊ID: clinic_mashan</p> <p>门诊ID: clinic_mashan</p>
<p>预期患者数: 527</p> <p>预期患者数: 527</p>
<a href="/patient_profiles/clinic_mashan/index.html" class="clinic-link">查看患者画像</a> <a href="{{ url_for('patient.clinic_patients', clinic_id='clinic_mashan') }}" class="clinic-link">查看患者画像</a>
</div> </div>
<div class="clinic-card"> <div class="clinic-card">
<h3>通善口腔医院</h3> <h3>通善口腔医院</h3>
<p>门诊ID: clinic_hospital</p> <p>门诊ID: clinic_hospital</p>
<p>预期患者数: 536</p> <p>预期患者数: 536</p>
<a href="/patient_profiles/clinic_hospital/index.html" class="clinic-link">查看患者画像</a> <a href="{{ url_for('patient.clinic_patients', clinic_id='clinic_hospital') }}" class="clinic-link">查看患者画像</a>
</div> </div>
<div class="clinic-card"> <div class="clinic-card">
<h3>新吴门诊</h3> <h3>新吴门诊</h3>
<p>门诊ID: clinic_xinwu</p> <p>门诊ID: clinic_xinwu</p>
<p>预期患者数: 297</p> <p>预期患者数: 297</p>
<a href="/patient_profiles/clinic_xinwu/index.html" class="clinic-link">查看患者画像</a> <a href="{{ url_for('patient.clinic_patients', clinic_id='clinic_xinwu') }}" class="clinic-link">查看患者画像</a>
</div> </div>
</div> </div>
</div> </div>
...@@ -274,20 +274,41 @@ ...@@ -274,20 +274,41 @@
// 调用导出API // 调用导出API
fetch('/api/export-data') fetch('/api/export-data')
.then(response => response.json()) .then(response => {
.then(data => { if (response.ok) {
if (data.success) { // 获取文件名(从响应头或生成默认名称)
const contentDisposition = response.headers.get('Content-Disposition');
let filename = '回访记录导出.xlsx';
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="(.+)"/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
return response.blob().then(blob => ({ blob, filename }));
} else {
return response.json().then(data => {
throw new Error(data.message || '导出失败');
});
}
})
.then(({ blob, filename }) => {
exportStatus.className = 'export-status success'; exportStatus.className = 'export-status success';
exportStatus.textContent = `✅ ${data.message} - 文件名: ${data.filename}`; exportStatus.textContent = `✅ 数据导出成功 - 文件名: ${filename}`;
// 自动下载文件 // 创建下载链接
const downloadUrl = window.URL.createObjectURL(blob);
const downloadLink = document.createElement('a'); const downloadLink = document.createElement('a');
downloadLink.href = data.download_url; downloadLink.href = downloadUrl;
downloadLink.download = data.filename; downloadLink.download = filename;
document.body.appendChild(downloadLink); document.body.appendChild(downloadLink);
downloadLink.click(); downloadLink.click();
document.body.removeChild(downloadLink); document.body.removeChild(downloadLink);
// 释放URL对象
window.URL.revokeObjectURL(downloadUrl);
// 恢复按钮状态 // 恢复按钮状态
exportBtn.disabled = false; exportBtn.disabled = false;
exportBtn.textContent = '📊 导出所有诊所数据到Excel'; exportBtn.textContent = '📊 导出所有诊所数据到Excel';
...@@ -296,9 +317,6 @@ ...@@ -296,9 +317,6 @@
setTimeout(() => { setTimeout(() => {
exportStatus.style.display = 'none'; exportStatus.style.display = 'none';
}, 3000); }, 3000);
} else {
throw new Error(data.message);
}
}) })
.catch(error => { .catch(error => {
exportStatus.className = 'export-status error'; exportStatus.className = 'export-status error';
......
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{{ clinic_name }} - 患者画像回访话术系统</title>
<style>
body {
font-family: 'Microsoft YaHei', Arial, sans-serif;
margin: 0;
padding: 0;
background: #f5f5f5;
}
.header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
}
.header h1 {
margin: 0;
font-size: 24px;
}
.user-info {
display: flex;
align-items: center;
gap: 20px;
}
.logout-btn {
background: rgba(255,255,255,0.2);
border: 1px solid rgba(255,255,255,0.3);
color: white;
padding: 8px 16px;
border-radius: 5px;
cursor: pointer;
text-decoration: none;
}
.logout-btn:hover {
background: rgba(255,255,255,0.3);
}
.container {
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.clinic-card {
background: white;
border-radius: 10px;
padding: 30px;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
margin-top: 20px;
}
.clinic-card h2 {
margin: 0 0 20px 0;
color: #333;
}
.clinic-card p {
color: #666;
margin: 10px 0;
}
.patient-link {
display: inline-block;
background: #667eea;
color: white;
padding: 12px 24px;
border-radius: 5px;
text-decoration: none;
margin-top: 20px;
font-size: 16px;
}
.patient-link:hover {
background: #5a6fd8;
}
</style>
</head>
<body>
<div class="header">
<h1>患者画像回访话术系统 - {{ clinic_name }}</h1>
<div class="user-info">
<span>欢迎,{{ session_data.real_name }}</span>
<a href="/login" class="logout-btn">登出</a>
</div>
</div>
<div class="container">
<div class="clinic-card">
<h2>{{ clinic_name }}</h2>
<p><strong>门诊ID:</strong> {{ clinic_id }}</p>
<p><strong>用户:</strong> {{ session_data.real_name }} ({{ session_data.username }})</p>
<p><strong>角色:</strong> 门诊用户</p>
<p>您可以查看和管理本门诊的患者画像和回访话术数据。</p>
<a href="/patient_profiles/{{ clinic_id }}/index.html" class="patient-link">查看患者画像</a>
</div>
</div>
</body>
</html>
{% extends "base.html" %}
{% block title %}{{ clinic_info.clinic_name }} - 患者画像系统{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<!-- 头部 -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-gray-900 mb-2">{{ clinic_info.clinic_name }}</h1>
<p class="text-xl text-gray-600">患者画像系统</p>
<div class="mt-4 text-sm text-gray-500">
<i class="fas fa-users mr-2"></i>
共 {{ patients|length }} 个患者
<span class="mx-2">|</span>
<i class="fas fa-sync-alt mr-2"></i>
动态加载
</div>
</div>
<!-- 搜索和筛选 -->
<div class="mb-6">
<div class="flex flex-col md:flex-row gap-4">
<div class="flex-1">
<input type="text"
id="searchInput"
placeholder="搜索患者姓名或病历号..."
class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div class="flex gap-2">
<select id="ageFilter" class="px-4 py-2 border border-gray-300 rounded-lg">
<option value="">所有年龄</option>
<option value="0-18">0-18岁</option>
<option value="19-35">19-35岁</option>
<option value="36-60">36-60岁</option>
<option value="60+">60岁以上</option>
</select>
<select id="genderFilter" class="px-4 py-2 border border-gray-300 rounded-lg">
<option value="">所有性别</option>
<option value="男"></option>
<option value="女"></option>
</select>
</div>
</div>
</div>
<!-- 患者列表 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" id="patientGrid">
{% for patient in patients %}
<div class="patient-card bg-white rounded-lg shadow-md hover:shadow-lg transition-shadow duration-200 p-6"
data-name="{{ patient.姓名 }}"
data-id="{{ patient.病历号 }}"
data-age="{{ patient.年龄 }}"
data-gender="{{ patient.性别 }}">
<!-- 患者基本信息 -->
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-blue-100 rounded-full flex items-center justify-center mr-4">
<i class="fas fa-user text-blue-600"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">{{ patient.姓名 }}</h3>
<p class="text-sm text-gray-600">{{ patient.性别 }} · {{ patient.年龄 }}岁</p>
</div>
</div>
<!-- 病历信息 -->
<div class="mb-4">
<p class="text-sm text-gray-600 mb-1">
<i class="fas fa-id-card mr-2"></i>
病历号:{{ patient.病历号 }}
</p>
{% if patient.最后一次就诊时间 %}
<p class="text-sm text-gray-600 mb-1">
<i class="fas fa-calendar mr-2"></i>
最后就诊:{{ patient.最后一次就诊时间[:10] }}
</p>
{% endif %}
{% if patient.最后一次就诊医生 %}
<p class="text-sm text-gray-600">
<i class="fas fa-user-md mr-2"></i>
医生:{{ patient.最后一次就诊医生 }}
</p>
{% endif %}
</div>
<!-- 诊断信息 -->
{% if patient.上次就诊诊断 %}
<div class="mb-4">
<p class="text-xs text-gray-500 mb-1">最近诊断:</p>
<p class="text-sm text-gray-700 line-clamp-2">{{ patient.上次就诊诊断[:50] }}{% if patient.上次就诊诊断|length > 50 %}...{% endif %}</p>
</div>
{% endif %}
<!-- 操作按钮 -->
<div class="flex gap-2">
<a href="/patient_profiles/{{ clinic_id }}/patients/{{ patient.病历号 }}.html"
class="flex-1 bg-blue-500 hover:bg-blue-600 text-white text-center py-2 px-4 rounded-lg text-sm font-medium transition-colors duration-200">
<i class="fas fa-eye mr-1"></i>
查看详情
</a>
<button onclick="quickCallback('{{ patient.病历号 }}')"
class="bg-green-500 hover:bg-green-600 text-white py-2 px-3 rounded-lg text-sm transition-colors duration-200">
<i class="fas fa-phone"></i>
</button>
</div>
</div>
{% endfor %}
</div>
<!-- 空状态 -->
<div id="emptyState" class="text-center py-12 hidden">
<i class="fas fa-search text-gray-400 text-4xl mb-4"></i>
<p class="text-gray-500">没有找到匹配的患者</p>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// 搜索和筛选功能
document.addEventListener('DOMContentLoaded', function() {
const searchInput = document.getElementById('searchInput');
const ageFilter = document.getElementById('ageFilter');
const genderFilter = document.getElementById('genderFilter');
const patientCards = document.querySelectorAll('.patient-card');
const emptyState = document.getElementById('emptyState');
function filterPatients() {
const searchTerm = searchInput.value.toLowerCase();
const selectedAge = ageFilter.value;
const selectedGender = genderFilter.value;
let visibleCount = 0;
patientCards.forEach(card => {
const name = card.dataset.name.toLowerCase();
const id = card.dataset.id.toLowerCase();
const age = parseInt(card.dataset.age);
const gender = card.dataset.gender;
// 搜索匹配
const matchesSearch = name.includes(searchTerm) || id.includes(searchTerm);
// 年龄筛选
let matchesAge = true;
if (selectedAge) {
if (selectedAge === '0-18') matchesAge = age <= 18;
else if (selectedAge === '19-35') matchesAge = age >= 19 && age <= 35;
else if (selectedAge === '36-60') matchesAge = age >= 36 && age <= 60;
else if (selectedAge === '60+') matchesAge = age > 60;
}
// 性别筛选
const matchesGender = !selectedGender || gender === selectedGender;
if (matchesSearch && matchesAge && matchesGender) {
card.style.display = 'block';
visibleCount++;
} else {
card.style.display = 'none';
}
});
// 显示/隐藏空状态
if (visibleCount === 0) {
emptyState.classList.remove('hidden');
} else {
emptyState.classList.add('hidden');
}
}
// 绑定事件
searchInput.addEventListener('input', filterPatients);
ageFilter.addEventListener('change', filterPatients);
genderFilter.addEventListener('change', filterPatients);
});
// 快速回访功能
function quickCallback(patientId) {
// 这里可以实现快速回访功能
alert(`快速回访功能:${patientId}`);
}
</script>
{% endblock %}
{% extends "base.html" %}
{% block title %}通善口腔 - 患者画像系统{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<!-- 头部 -->
<div class="text-center mb-12">
<h1 class="text-4xl font-bold text-gray-900 mb-4">通善口腔患者画像系统</h1>
<p class="text-xl text-gray-600">请选择要访问的门诊</p>
<div class="mt-4 text-sm text-gray-500">
<i class="fas fa-sync-alt mr-2"></i>
动态加载 · 实时数据
</div>
</div>
<!-- 门诊卡片网格 -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6 mb-12">
{% for clinic_id, clinic_info in clinics.items() %}
<div class="bg-white rounded-xl shadow-lg hover:shadow-xl transition-all duration-300 transform hover:-translate-y-1 border border-gray-100">
<div class="p-6">
<!-- 门诊图标和名称 -->
<div class="flex items-center mb-4">
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-blue-600 rounded-lg flex items-center justify-center mr-4">
<i class="fas fa-hospital text-white text-xl"></i>
</div>
<div>
<h3 class="text-lg font-semibold text-gray-900">{{ clinic_info.clinic_name }}</h3>
<p class="text-sm text-gray-500">{{ clinic_info.description }}</p>
</div>
</div>
<!-- 统计信息 -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="bg-blue-50 p-3 rounded-lg text-center">
<div class="text-2xl font-bold text-blue-600">{{ clinic_info.expected_patients }}</div>
<div class="text-xs text-blue-500">预期患者</div>
</div>
<div class="bg-green-50 p-3 rounded-lg text-center">
<div class="text-2xl font-bold text-green-600">{{ clinic_info.get('actual_patients', '?') }}</div>
<div class="text-xs text-green-500">实际患者</div>
</div>
</div>
<!-- 操作按钮 -->
<div class="space-y-2">
<a href="/patient_profiles/{{ clinic_id }}/index.html"
class="block w-full bg-blue-500 hover:bg-blue-600 text-white text-center py-3 px-4 rounded-lg font-medium transition-colors duration-200">
<i class="fas fa-users mr-2"></i>
查看患者列表
</a>
<div class="grid grid-cols-2 gap-2">
<button onclick="showClinicStats('{{ clinic_id }}')"
class="bg-gray-100 hover:bg-gray-200 text-gray-700 py-2 px-3 rounded-lg text-sm transition-colors duration-200">
<i class="fas fa-chart-bar mr-1"></i>
统计
</button>
<button onclick="exportClinicData('{{ clinic_id }}')"
class="bg-gray-100 hover:bg-gray-200 text-gray-700 py-2 px-3 rounded-lg text-sm transition-colors duration-200">
<i class="fas fa-download mr-1"></i>
导出
</button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- 系统统计 -->
<div class="bg-white rounded-xl shadow-lg p-6 border border-gray-100 mb-8">
<h2 class="text-2xl font-bold text-gray-900 mb-6 flex items-center">
<i class="fas fa-chart-pie text-blue-600 mr-3"></i>
系统概览
</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-6">
<div class="text-center">
<div class="text-3xl font-bold text-blue-600 mb-2">{{ total_clinics }}</div>
<div class="text-sm text-gray-600">门诊数量</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-green-600 mb-2">{{ total_patients }}</div>
<div class="text-sm text-gray-600">总患者数</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-purple-600 mb-2">{{ total_records }}</div>
<div class="text-sm text-gray-600">回访记录</div>
</div>
<div class="text-center">
<div class="text-3xl font-bold text-orange-600 mb-2">{{ active_users }}</div>
<div class="text-sm text-gray-600">活跃用户</div>
</div>
</div>
</div>
<!-- 快速操作 -->
<div class="bg-white rounded-xl shadow-lg p-6 border border-gray-100">
<h2 class="text-2xl font-bold text-gray-900 mb-6 flex items-center">
<i class="fas fa-bolt text-yellow-600 mr-3"></i>
快速操作
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<button onclick="searchPatient()"
class="bg-blue-500 hover:bg-blue-600 text-white p-4 rounded-lg transition-colors duration-200 flex items-center justify-center">
<i class="fas fa-search mr-2"></i>
搜索患者
</button>
<button onclick="generateReport()"
class="bg-green-500 hover:bg-green-600 text-white p-4 rounded-lg transition-colors duration-200 flex items-center justify-center">
<i class="fas fa-file-alt mr-2"></i>
生成报告
</button>
<button onclick="systemSettings()"
class="bg-purple-500 hover:bg-purple-600 text-white p-4 rounded-lg transition-colors duration-200 flex items-center justify-center">
<i class="fas fa-cog mr-2"></i>
系统设置
</button>
</div>
</div>
<!-- 页脚 -->
<div class="text-center mt-12">
<p class="text-gray-500">
<i class="fas fa-hospital mr-2"></i>
共 {{ total_clinics }} 个门诊,{{ total_patients }} 个患者
</p>
<p class="text-xs text-gray-400 mt-2">
© 2025 通善口腔患者画像系统 v3.0 - 动态模板版本
</p>
</div>
</div>
<!-- 搜索模态框 -->
<div id="searchModal" class="fixed inset-0 bg-black bg-opacity-50 hidden z-50">
<div class="flex items-center justify-center min-h-screen p-4">
<div class="bg-white rounded-xl shadow-xl max-w-md w-full p-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold text-gray-900">搜索患者</h3>
<button onclick="closeSearchModal()" class="text-gray-400 hover:text-gray-600">
<i class="fas fa-times"></i>
</button>
</div>
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 mb-2">搜索条件</label>
<input type="text" id="searchInput" placeholder="输入患者姓名或病历号..."
class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent">
</div>
<div class="flex justify-end space-x-3">
<button onclick="closeSearchModal()"
class="px-4 py-2 text-gray-600 hover:text-gray-800 transition-colors">
取消
</button>
<button onclick="performSearch()"
class="px-4 py-2 bg-blue-500 hover:bg-blue-600 text-white rounded-lg transition-colors">
搜索
</button>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_scripts %}
<script>
// 显示门诊统计
function showClinicStats(clinicId) {
alert(`显示 ${clinicId} 的统计信息`);
// 这里可以实现具体的统计功能
}
// 导出门诊数据
function exportClinicData(clinicId) {
alert(`导出 ${clinicId} 的数据`);
// 这里可以实现数据导出功能
}
// 搜索患者
function searchPatient() {
document.getElementById('searchModal').classList.remove('hidden');
document.getElementById('searchInput').focus();
}
// 关闭搜索模态框
function closeSearchModal() {
document.getElementById('searchModal').classList.add('hidden');
document.getElementById('searchInput').value = '';
}
// 执行搜索
function performSearch() {
const searchTerm = document.getElementById('searchInput').value.trim();
if (!searchTerm) {
alert('请输入搜索条件');
return;
}
// 这里可以实现具体的搜索功能
alert(`搜索: ${searchTerm}`);
closeSearchModal();
}
// 生成报告
function generateReport() {
alert('生成系统报告功能');
// 这里可以实现报告生成功能
}
// 系统设置
function systemSettings() {
alert('系统设置功能');
// 这里可以实现系统设置功能
}
// 键盘事件
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeSearchModal();
}
if (e.key === 'Enter' && !document.getElementById('searchModal').classList.contains('hidden')) {
performSearch();
}
});
// 点击模态框外部关闭
document.getElementById('searchModal').addEventListener('click', function(e) {
if (e.target === this) {
closeSearchModal();
}
});
</script>
{% endblock %}
...@@ -18,7 +18,8 @@ ...@@ -18,7 +18,8 @@
// 设置API基础URL - 根据当前页面动态配置,避免跨域问题 // 设置API基础URL - 根据当前页面动态配置,避免跨域问题
const currentHost = window.location.hostname; const currentHost = window.location.hostname;
const currentPort = window.location.port || (window.location.protocol === 'https:' ? '443' : '80'); const currentPort = window.location.port || (window.location.protocol === 'https:' ? '443' : '80');
const apiPort = currentPort === '5001' ? '5001' : '4002'; // 本地开发用5001,Docker用4002 // 使用当前页面的端口,支持5003(当前)、5001(旧版本)、4002(Docker)
const apiPort = currentPort;
window.API_BASE_URL = `${window.location.protocol}//${currentHost}:${apiPort}`; window.API_BASE_URL = `${window.location.protocol}//${currentHost}:${apiPort}`;
tailwind.config = { tailwind.config = {
...@@ -987,7 +988,7 @@ ...@@ -987,7 +988,7 @@
}; };
const apiBase = window.API_BASE_URL || ''; const apiBase = window.API_BASE_URL || '';
fetch(`${apiBase}/api/callback-records`, { fetch(`${apiBase}/api/save-callback`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
...@@ -1218,8 +1219,48 @@ ...@@ -1218,8 +1219,48 @@
} }
// 生成AI回访话术 // 生成AI回访话术
function generateCallbackScript() { async function generateCallbackScript() {
alert('AI回访话术生成功能开发中...'); const button = event.target;
const originalText = button.innerHTML;
try {
// 显示加载状态
button.disabled = true;
button.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>生成中...';
// 获取患者信息
const patientId = '{{ patient.病历号 }}';
const clinicId = '{{ clinic_id }}';
// 调用生成API
const response = await fetch(`${window.API_BASE_URL}/api/generate-callback-script`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
clinic_id: clinicId,
patient_id: patientId
})
});
const data = await response.json();
if (data.success) {
// 刷新页面以显示新生成的话术
window.location.reload();
} else {
alert('生成失败:' + (data.message || '未知错误'));
}
} catch (error) {
console.error('生成回访话术失败:', error);
alert('生成失败:网络错误或服务器错误');
} finally {
// 恢复按钮状态
button.disabled = false;
button.innerHTML = originalText;
}
} }
</script> </script>
</body> </body>
......
...@@ -126,7 +126,7 @@ ...@@ -126,7 +126,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" id="patientsGrid"> <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" id="patientsGrid">
{% for patient in patients %} {% for patient in patients %}
<a href="/patient_profiles/{{ clinic_id }}/patients/{{ patient.病历号 }}.html" class="block patient-card" <a href="{{ url_for('patient.patient_detail', clinic_id=clinic_id, patient_id=patient.病历号) }}" class="block patient-card"
data-name="{{ patient.姓名 }}" data-name="{{ patient.姓名 }}"
data-id="{{ patient.病历号 }}" data-id="{{ patient.病历号 }}"
data-clinic="{{ patient.最后一次就诊诊所 or clinic_name }}" data-clinic="{{ patient.最后一次就诊诊所 or clinic_name }}"
......
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
时间格式化工具函数
用于智能显示就诊时间
"""
import pandas as pd
from datetime import datetime
def format_visit_time_smart(visit_time_str):
"""
智能格式化就诊时间
Args:
visit_time_str: 就诊时间字符串,格式如 "2024-12-17 00:00:00"
Returns:
str: 格式化后的时间字符串
- 今年:显示月份+日期(如:5月12号)
- 去年:显示去年+月份(如:去年12月)
- 更早年份:显示年份+月份(如:2022年5月)
"""
try:
# 处理空值
if pd.isna(visit_time_str):
return "上次"
visit_time_str = str(visit_time_str).strip()
if not visit_time_str:
return "上次"
# 提取日期部分(去掉时间部分)
date_part = visit_time_str.split()[0]
visit_date = datetime.strptime(date_part, '%Y-%m-%d')
current_date = datetime.now()
# 判断年份关系
if visit_date.year == current_date.year:
# 今年:显示月份+日期
return f"{visit_date.month}月{visit_date.day}号"
elif visit_date.year == current_date.year - 1:
# 去年:显示去年+月份
return f"去年{visit_date.month}月"
else:
# 更早的年份:显示具体年月
return f"{visit_date.year}年{visit_date.month}月"
except Exception as e:
# 解析失败时返回默认值
return "上次"
def apply_smart_time_to_template(template_text, visit_time_str):
"""
将智能时间格式应用到模板文本中
Args:
template_text: 包含【智能时间显示】占位符的模板文本
visit_time_str: 就诊时间字符串
Returns:
str: 替换后的模板文本
"""
smart_time = format_visit_time_smart(visit_time_str)
return template_text.replace("【智能时间显示】", smart_time)
# 使用示例
if __name__ == "__main__":
# 测试不同时间格式
test_times = [
"2025-05-12 10:30:00", # 今年
"2024-12-17 14:20:00", # 去年
"2022-03-15 09:15:00", # 更早年份
"", # 空值
None # None值
]
print("智能时间格式化测试:")
for time_str in test_times:
formatted = format_visit_time_smart(time_str)
print(f"输入: {time_str} -> 输出: {formatted}")
\ No newline at end of file
USE callback_system;
UPDATE users SET password_hash = CASE username
WHEN 'admin' THEN 'bcf3315b991bfa7efee55ab1ac28d53a461baf8bf04a0d3a9c2c990e1b2bf2f3'
WHEN 'jinqin' THEN 'c485147aeb57a2fef4f581d0f99d6a82efdaa0fcf9f5b6c1dce67fab85fd5fae'
WHEN 'renshanshan' THEN '90c81c768468bb206479f81db51a5435ca5224adb9345c388039bbb93b9a6a68'
WHEN 'shaojun' THEN 'bece37a8e6b73e0094970e3c3ad563758f57d22df4c6a0e7834768f48c8d37c6'
WHEN 'litingting' THEN 'a1b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234'
WHEN 'maqiuyi' THEN 'b2c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234567890'
WHEN 'tangqimin' THEN 'c3d4e5f6789012345678901234567890abcdef1234567890abcdef1234567890ab'
WHEN 'yueling' THEN 'd4e5f6789012345678901234567890abcdef1234567890abcdef1234567890abcd'
WHEN 'jijunlin' THEN 'e5f6789012345678901234567890abcdef1234567890abcdef1234567890abcde'
WHEN 'zhouliping' THEN 'f6789012345678901234567890abcdef1234567890abcdef1234567890abcdef'
WHEN 'feimiaomiao' THEN '7890123456789012345678901234567890abcdef1234567890abcdef12345678'
WHEN 'chenxinyu' THEN '77d100ff5e130d2ae1641a3f2cd91f229b2f1b40c200a3d9fd56da5b671ba515'
WHEN 'yanghong' THEN '34a56c71dd7e71482bf71a1ea9e0861c107acd1daa50f6d0de23483ce078db6e'
WHEN 'panjinli' THEN '894cf43ff5d416c9606f9f2f11ef2be848c331fd785361b2ef9b4c2e991a24fd'
WHEN 'chenlin' THEN '94d2967a391c7292682903043b5150a8b7a87a726fc8b3f650ed30d7ca5853ae'
END WHERE username IN (
'admin', 'jinqin', 'renshanshan', 'shaojun', 'litingting', 'maqiuyi', 'tangqimin', 'yueling', 'jijunlin', 'zhouliping', 'feimiaomiao', 'chenxinyu', 'yanghong', 'panjinli', 'chenlin'
);
\ No newline at end of file
USE callback_system;
-- 更新所有用户的salt字段
UPDATE users SET salt = 'admin_salt' WHERE username = 'admin';
UPDATE users SET salt = 'jinqin_salt' WHERE username = 'jinqin';
UPDATE users SET salt = 'renshanshan_salt' WHERE username = 'renshanshan';
UPDATE users SET salt = 'shaojun_salt' WHERE username = 'shaojun';
UPDATE users SET salt = 'litingting_salt' WHERE username = 'litingting';
UPDATE users SET salt = 'maqiuyi_salt' WHERE username = 'maqiuyi';
UPDATE users SET salt = 'tangqimin_salt' WHERE username = 'tangqimin';
UPDATE users SET salt = 'yueling_salt' WHERE username = 'yueling';
UPDATE users SET salt = 'jijunlin_salt' WHERE username = 'jijunlin';
UPDATE users SET salt = 'zhouliping_salt' WHERE username = 'zhouliping';
UPDATE users SET salt = 'feimiaomiao_salt' WHERE username = 'feimiaomiao';
UPDATE users SET salt = 'chenxinyu_salt' WHERE username = 'chenxinyu';
UPDATE users SET salt = 'yanghong_salt' WHERE username = 'yanghong';
UPDATE users SET salt = 'panjinli_salt' WHERE username = 'panjinli';
UPDATE users SET salt = 'chenlin_salt' WHERE username = 'chenlin';
\ No newline at end of file
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
用户管理模块
提供用户认证、注册、密码管理等功能
"""
import hashlib
import secrets
import datetime
from typing import Optional, List, Dict, Any
import pymysql
from database_config import DatabaseConfig
class User:
"""用户类"""
def __init__(self, user_id: int = None, username: str = "", password_hash: str = "",
salt: str = "", role: str = "user", created_at: datetime.datetime = None,
last_login: datetime.datetime = None, is_active: bool = True):
"""
初始化用户对象
Args:
user_id: 用户ID
username: 用户名
password_hash: 密码哈希值
salt: 密码盐值
role: 用户角色 (admin, user)
created_at: 创建时间
last_login: 最后登录时间
is_active: 是否激活
"""
self.user_id = user_id
self.username = username
self.password_hash = password_hash
self.salt = salt
self.role = role
self.created_at = created_at or datetime.datetime.now()
self.last_login = last_login
self.is_active = is_active
def to_dict(self) -> Dict[str, Any]:
"""转换为字典格式"""
return {
'user_id': self.user_id,
'username': self.username,
'role': self.role,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_login': self.last_login.isoformat() if self.last_login else None,
'is_active': self.is_active
}
class UserManager:
"""用户管理器"""
def __init__(self, host: str, port: int, user: str, password: str,
database: str, charset: str = 'utf8mb4'):
"""
初始化用户管理器
Args:
host: 数据库主机
port: 数据库端口
user: 数据库用户名
password: 数据库密码
database: 数据库名
charset: 字符集
"""
self.host = host
self.port = port
self.user = user
self.password = password
self.database = database
self.charset = charset
# 初始化数据库表
self._create_tables()
# 创建默认管理员账户
self._create_default_admin()
def _get_connection(self):
"""获取数据库连接"""
return pymysql.connect(
host=self.host,
port=self.port,
user=self.user,
password=self.password,
database=self.database,
charset=self.charset,
autocommit=True
)
def _create_tables(self):
"""创建用户表"""
connection = self._get_connection()
try:
cursor = connection.cursor()
# 创建用户表
create_users_table = """
CREATE TABLE IF NOT EXISTS users (
user_id INT AUTO_INCREMENT PRIMARY KEY,
username VARCHAR(50) UNIQUE NOT NULL,
password_hash VARCHAR(255) NOT NULL,
salt VARCHAR(255) NOT NULL,
role ENUM('admin', 'user') DEFAULT 'user',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
last_login TIMESTAMP NULL,
is_active BOOLEAN DEFAULT TRUE,
INDEX idx_username (username),
INDEX idx_role (role)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci;
"""
cursor.execute(create_users_table)
print("✓ 用户表创建/验证完成")
except Exception as e:
print(f"创建用户表失败: {e}")
raise
finally:
connection.close()
def _create_default_admin(self):
"""创建默认管理员账户"""
try:
# 检查是否已有管理员账户
if self.get_user_by_username("admin"):
return
# 创建默认管理员:admin/admin123
salt = self._generate_salt()
password_hash = self._hash_password("admin123", salt)
admin_user = User(
username="admin",
password_hash=password_hash,
salt=salt,
role="admin"
)
user_id = self._create_user_in_db(admin_user)
if user_id:
print("✓ 默认管理员账户已创建 (用户名: admin, 密码: admin123)")
print(" 请登录后立即修改密码!")
except Exception as e:
print(f"创建默认管理员账户失败: {e}")
def _generate_salt(self) -> str:
"""生成随机盐值"""
return secrets.token_hex(32)
def _hash_password(self, password: str, salt: str) -> str:
"""
对密码进行哈希加密
Args:
password: 原始密码
salt: 盐值
Returns:
加密后的密码哈希值
"""
# 使用SHA-256进行多轮哈希
password_bytes = (password + salt).encode('utf-8')
# 进行10000轮哈希增强安全性
for _ in range(10000):
password_bytes = hashlib.sha256(password_bytes).digest()
return password_bytes.hex()
def verify_password(self, password: str, password_hash: str, salt: str) -> bool:
"""
验证密码
Args:
password: 输入的密码
password_hash: 存储的密码哈希值
salt: 盐值
Returns:
密码正确返回True
"""
computed_hash = self._hash_password(password, salt)
return computed_hash == password_hash
def create_user(self, username: str, password: str, role: str = "user") -> Optional[int]:
"""
创建新用户
Args:
username: 用户名
password: 密码
role: 用户角色
Returns:
成功返回用户ID,失败返回None
"""
try:
# 检查用户名是否已存在
if self.get_user_by_username(username):
raise ValueError(f"用户名 '{username}' 已存在")
# 生成盐值和密码哈希
salt = self._generate_salt()
password_hash = self._hash_password(password, salt)
# 创建用户对象
user = User(
username=username,
password_hash=password_hash,
salt=salt,
role=role
)
# 保存到数据库
return self._create_user_in_db(user)
except Exception as e:
print(f"创建用户失败: {e}")
return None
def _create_user_in_db(self, user: User) -> Optional[int]:
"""将用户保存到数据库"""
connection = self._get_connection()
try:
cursor = connection.cursor()
insert_sql = """
INSERT INTO users (username, password_hash, salt, role, created_at, is_active)
VALUES (%s, %s, %s, %s, %s, %s)
"""
cursor.execute(insert_sql, (
user.username,
user.password_hash,
user.salt,
user.role,
user.created_at,
user.is_active
))
return cursor.lastrowid
except Exception as e:
print(f"保存用户到数据库失败: {e}")
return None
finally:
connection.close()
def authenticate(self, username: str, password: str) -> Optional[User]:
"""
用户认证
Args:
username: 用户名
password: 密码
Returns:
认证成功返回User对象,失败返回None
"""
try:
user = self.get_user_by_username(username)
if not user:
return None
if not user.is_active:
print(f"用户 '{username}' 已被禁用")
return None
if self.verify_password(password, user.password_hash, user.salt):
# 更新最后登录时间
self._update_last_login(user.user_id)
user.last_login = datetime.datetime.now()
return user
return None
except Exception as e:
print(f"用户认证失败: {e}")
return None
def get_user_by_username(self, username: str) -> Optional[User]:
"""根据用户名获取用户"""
connection = self._get_connection()
try:
cursor = connection.cursor()
select_sql = """
SELECT user_id, username, password_hash, salt, user_type,
created_at, last_login, is_active
FROM users WHERE username = %s
"""
cursor.execute(select_sql, (username,))
row = cursor.fetchone()
if row:
return User(
user_id=row[0],
username=row[1],
password_hash=row[2],
salt=row[3],
role=row[4],
created_at=row[5],
last_login=row[6],
is_active=row[7]
)
return None
except Exception as e:
print(f"获取用户失败: {e}")
return None
finally:
connection.close()
def get_user_by_id(self, user_id: int) -> Optional[User]:
"""根据用户ID获取用户"""
connection = self._get_connection()
try:
cursor = connection.cursor()
select_sql = """
SELECT user_id, username, password_hash, salt, user_type,
created_at, last_login, is_active
FROM users WHERE user_id = %s
"""
cursor.execute(select_sql, (user_id,))
row = cursor.fetchone()
if row:
return User(
user_id=row[0],
username=row[1],
password_hash=row[2],
salt=row[3],
role=row[4],
created_at=row[5],
last_login=row[6],
is_active=row[7]
)
return None
except Exception as e:
print(f"获取用户失败: {e}")
return None
finally:
connection.close()
def _update_last_login(self, user_id: int):
"""更新最后登录时间"""
connection = self._get_connection()
try:
cursor = connection.cursor()
update_sql = """
UPDATE users SET last_login = CURRENT_TIMESTAMP
WHERE user_id = %s
"""
cursor.execute(update_sql, (user_id,))
except Exception as e:
print(f"更新登录时间失败: {e}")
finally:
connection.close()
def change_password(self, user_id: int, old_password: str, new_password: str) -> bool:
"""
修改密码
Args:
user_id: 用户ID
old_password: 旧密码
new_password: 新密码
Returns:
修改成功返回True
"""
try:
user = self.get_user_by_id(user_id)
if not user:
return False
# 验证旧密码
if not self.verify_password(old_password, user.password_hash, user.salt):
return False
# 生成新的盐值和密码哈希
new_salt = self._generate_salt()
new_password_hash = self._hash_password(new_password, new_salt)
# 更新数据库
connection = self._get_connection()
try:
cursor = connection.cursor()
update_sql = """
UPDATE users SET password_hash = %s, salt = %s
WHERE user_id = %s
"""
cursor.execute(update_sql, (new_password_hash, new_salt, user_id))
return True
finally:
connection.close()
except Exception as e:
print(f"修改密码失败: {e}")
return False
def get_all_users(self) -> List[User]:
"""获取所有用户列表"""
connection = self._get_connection()
try:
cursor = connection.cursor()
select_sql = """
SELECT user_id, username, password_hash, salt, user_type,
created_at, last_login, is_active
FROM users ORDER BY created_at DESC
"""
cursor.execute(select_sql)
rows = cursor.fetchall()
users = []
for row in rows:
users.append(User(
user_id=row[0],
username=row[1],
password_hash=row[2],
salt=row[3],
role=row[4],
created_at=row[5],
last_login=row[6],
is_active=row[7]
))
return users
except Exception as e:
print(f"获取用户列表失败: {e}")
return []
finally:
connection.close()
def test_connection(self) -> bool:
"""测试数据库连接"""
try:
connection = self._get_connection()
connection.close()
return True
except Exception as e:
print(f"数据库连接测试失败: {e}")
return False
def create_user_manager() -> Optional[UserManager]:
"""创建用户管理器实例"""
try:
# 加载数据库配置
config_manager = DatabaseConfig()
if not config_manager.validate_config():
print("数据库配置无效,请运行 python database_config.py 进行配置")
return None
mysql_config = config_manager.get_mysql_config()
# 创建用户管理器
user_manager = UserManager(**mysql_config)
# 测试连接
if user_manager.test_connection():
print("✓ 用户管理器初始化成功")
return user_manager
else:
print("✗ 用户管理器连接失败")
return None
except Exception as e:
print(f"创建用户管理器失败: {e}")
return None
if __name__ == "__main__":
# 测试用户管理器
print("=== 用户管理器测试 ===")
user_manager = create_user_manager()
if user_manager:
print("用户管理器创建成功!")
# 显示现有用户
users = user_manager.get_all_users()
print(f"\n当前系统中有 {len(users)} 个用户:")
for user in users:
print(f" - {user.username} ({user.role}) - 创建时间: {user.created_at}")
else:
print("用户管理器创建失败!")
\ No newline at end of file
# 工具模块
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
辅助工具函数
"""
from datetime import datetime
import json
import os
def format_datetime(dt):
"""格式化日期时间"""
if isinstance(dt, str):
return dt
if dt:
return dt.strftime('%Y-%m-%d %H:%M:%S')
return None
def safe_json_loads(json_str, default=None):
"""安全的JSON解析"""
try:
if isinstance(json_str, str):
return json.loads(json_str)
return json_str
except (json.JSONDecodeError, TypeError):
return default or {}
def safe_json_dumps(obj, default=None):
"""安全的JSON序列化"""
try:
return json.dumps(obj, ensure_ascii=False, indent=2)
except (TypeError, ValueError):
return default or '{}'
def ensure_dir_exists(dir_path):
"""确保目录存在"""
if not os.path.exists(dir_path):
os.makedirs(dir_path, exist_ok=True)
return dir_path
def get_file_size(file_path):
"""获取文件大小(字节)"""
try:
return os.path.getsize(file_path)
except OSError:
return 0
def format_file_size(size_bytes):
"""格式化文件大小"""
if size_bytes == 0:
return "0B"
size_names = ["B", "KB", "MB", "GB"]
i = 0
while size_bytes >= 1024 and i < len(size_names) - 1:
size_bytes /= 1024.0
i += 1
return f"{size_bytes:.1f}{size_names[i]}"
def truncate_text(text, max_length=100):
"""截断文本"""
if not text:
return ""
if len(text) <= max_length:
return text
return text[:max_length] + "..."
def validate_case_number(case_number):
"""验证病历号格式"""
if not case_number:
return False
# 简单验证:非空且长度合理
return len(case_number.strip()) > 0 and len(case_number.strip()) <= 50
def sanitize_filename(filename):
"""清理文件名,移除不安全字符"""
import re
# 移除或替换不安全字符
filename = re.sub(r'[<>:"/\\|?*]', '_', filename)
# 移除多余的空格和点
filename = re.sub(r'\s+', '_', filename.strip())
filename = filename.strip('.')
return filename or 'unnamed'
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment