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
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
......@@ -38,8 +38,8 @@ EXPOSE 5000
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 \
CMD curl -f http://localhost:5000/ || exit 1
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
CMD curl -f http://localhost:5000/api/health || exit 1
# 启动命令
CMD ["python", "app.py"]
\ No newline at end of file
# 使用entrypoint.sh启动(零配置自动迁移)
ENTRYPOINT ["./database/scripts/entrypoint.sh"]
\ 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
# -*- coding: utf-8 -*-
"""
用户认证系统
提供登录验证、session管理和权限检查功能
患者画像回访话术系统
基于Flask的Web应用,提供患者信息管理和AI回访话术功能
"""
import os
import json
import hashlib
import secrets
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
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
# 尝试导入回访记录相关模块
try:
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
# 导入配置
from config import config
# 尝试导入clinic_config,如果失败则使用默认配置
try:
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("使用默认用户配置...")
# 最小默认用户配置(仅作为备用)
DEFAULT_USERS = [
# 管理员
{
'username': 'admin',
'password': 'admin123',
'role': 'admin',
'clinic_id': 'admin',
'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):
"""获取门诊信息"""
return {'name': '未知门诊'}
def load_clinic_patients(clinic_id):
"""加载指定门诊的患者数据"""
try:
# 获取门诊信息
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:
if item.get('patient_id') == patient_id:
script = item.get('callback_script', '')
print(f"✅ 找到患者 {patient_id} 的回访话术,长度: {len(script)}")
return format_callback_script(script)
print(f"⚠️ 未找到患者 {patient_id} 的回访话术")
break
# 导入数据库模型
from database.models import db
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
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 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_app(config_name=None):
"""应用工厂函数"""
if config_name is None:
config_name = os.environ.get('FLASK_ENV', 'default')
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
app = Flask(__name__)
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
# 加载配置
app.config.from_object(config[config_name])
def logout(self, session_id):
"""用户登出"""
if session_id in self.sessions:
del self.sessions[session_id]
return True
return False
# 初始化扩展
db.init_app(app)
migrate = Migrate(app, db)
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
# 注册蓝图
from routes import register_blueprints
register_blueprints(app)
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__)
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'])
# 初始化认证系统
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:
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'])
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 save_callback_record():
"""保存回访记录API"""
try:
data = request.get_json()
# 验证必需字段
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']}"
elif result == '不成功' and data.get('failureReason'):
callback_record += f", 失败原因: {data['failureReason']}"
elif result == '放弃回访' and data.get('abandonReason'):
callback_record += f", 放弃原因: {data['abandonReason']}"
# 添加AI反馈
if data.get('aiFeedbackType'):
callback_record += f", AI反馈: {data['aiFeedbackType']}"
# 添加备注信息
if result == '不成功' and data.get('failureReasonNote'):
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)
# 更新内存缓存
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
temp_callback_records[case_number].append({
'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:
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"""
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}")
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')
# 创建数据库表(只在主进程中执行)
with app.app_context():
try:
# 检查是否是 reloader 进程
if not os.environ.get('WERKZEUG_RUN_MAIN'):
print("🔄 主进程启动中...")
else:
# 普通用户重定向到患者索引页
return redirect(f'/patient_profiles/{session_data["clinic_id"]}/index.html')
else:
return render_template('login.html', error='用户名或密码错误')
print("🔥 Reloader 进程启动中...")
return render_template('login.html')
# 创建所有表
db.create_all()
@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
# 插入默认数据(避免重复插入)
insert_default_data()
@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': '未登录'})
print("✅ 数据库初始化完成")
except Exception as e:
print(f"⚠️ 数据库初始化失败: {e}")
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']
}
})
return app
@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"""
def insert_default_data():
"""插入默认数据"""
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
from database.models import Clinic
from config import CLINIC_CONFIG
# 检查是否已有数据,避免重复插入
existing_count = Clinic.query.count()
if existing_count > 0:
print(f"📊 数据库已有 {existing_count} 个门诊,跳过默认数据插入")
return
# 插入默认门诊数据
for clinic_id, clinic_data in CLINIC_CONFIG.items():
clinic = Clinic(
clinic_name=clinic_data['clinic_name'],
clinic_code=clinic_id,
is_active=True
)
db.session.add(clinic)
db.session.commit()
print("✅ 默认数据插入完成")
@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
db.session.rollback()
print(f"⚠️ 默认数据插入失败: {e}")
# 注意:原来的 serve_patient_profiles 路由已被移除
# 现在所有患者相关页面都通过专门的动态路由处理:
# - /patient_profiles/<clinic_id>/index.html → patient_list 函数
# - /patient_profiles/<clinic_id>/patients/<patient_id>.html → patient_detail 函数
# 创建应用实例
app = create_app()
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(
host='0.0.0.0',
port=port,
debug=is_development,
use_reloader=is_development
)
except Exception as e:
print(f"❌ 启动服务器失败: {e}")
import traceback
traceback.print_exc()
\ No newline at end of file
app.run(
host='0.0.0.0',
port=5001,
debug=True,
use_reloader=True,
threaded=True
)
#!/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():
# 加载患者数据
try:
# 尝试加载现有的患者数据文件
data_files = ['漏诊客户画像.json', '合并结果.json']
data_files = ['漏诊客户画像.json', 'data/patients/merged/合并结果.json']
patients_data = None
for file_name in data_files:
......
......@@ -30,7 +30,7 @@ class DifyConfig:
self.USER_ID = "callback_system" # 默认用户ID
# 输出配置
self.OUTPUT_DIR = "dify_callback_results" # 结果输出目录
self.OUTPUT_DIR = "data/callbacks" # 结果输出目录
self.LOG_LEVEL = "INFO" # 日志级别
def get_headers(self) -> Dict[str, str]:
......
......@@ -12,7 +12,7 @@ from typing import Dict, Any
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 @@
import os
import json
import io
from datetime import datetime
from typing import Dict, List, Any
import pymysql
......@@ -174,6 +175,38 @@ class DataExporter:
# 保存文件
wb.save(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]):
"""创建总览表"""
......
......@@ -250,15 +250,15 @@ def main():
# 定义门诊和对应的JSON文件
clinics = [
('学前街门诊', '学前街门诊.json'), # 根目录文件
('大丰门诊', '诊所患者json/大丰门诊.json'),
('东亭门诊', '诊所患者json/东亭门诊.json'),
('河埒门诊', '诊所患者json/河埒门诊.json'),
('红豆门诊', '诊所患者json/红豆门诊.json'),
('惠山门诊', '诊所患者json/惠山门诊.json'),
('马山门诊', '诊所患者json/马山门诊.json'),
('通善口腔医院', '诊所患者json/通善口腔医院.json'),
('新吴门诊', '诊所患者json/新吴门诊.json')
('学前街门诊', 'data/patients/clinics/学前街门诊.json'),
('大丰门诊', 'data/patients/clinics/大丰门诊.json'),
('东亭门诊', 'data/patients/clinics/东亭门诊.json'),
('河埒门诊', 'data/patients/clinics/河埒门诊.json'),
('红豆门诊', 'data/patients/clinics/红豆门诊.json'),
('惠山门诊', 'data/patients/clinics/惠山门诊.json'),
('马山门诊', 'data/patients/clinics/马山门诊.json'),
('通善口腔医院', 'data/patients/clinics/通善口腔医院.json'),
('新吴门诊', 'data/patients/clinics/新吴门诊.json')
]
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:
MYSQL_COLLATION_SERVER: utf8mb4_unicode_ci
volumes:
- 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:
- "3306:3306" # Host port 3306 maps to container port 3306
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框架
Flask==3.1.1
Flask-CORS==6.0.1
Flask-SQLAlchemy==3.1.1
Flask-Migrate==4.0.7
# 数据库
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 @@
<h3>学前街门诊</h3>
<p>门诊ID: clinic_xuexian</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 class="clinic-card">
<h3>大丰门诊</h3>
<p>门诊ID: clinic_dafeng</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 class="clinic-card">
<h3>东亭门诊</h3>
<p>门诊ID: clinic_dongting</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 class="clinic-card">
<h3>河埒门诊</h3>
<p>门诊ID: clinic_helai</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 class="clinic-card">
<h3>红豆门诊</h3>
<p>门诊ID: clinic_hongdou</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 class="clinic-card">
<h3>惠山门诊</h3>
<p>门诊ID: clinic_huishan</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 class="clinic-card">
<h3>马山门诊</h3>
<p>门诊ID: clinic_mashan</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 class="clinic-card">
<h3>通善口腔医院</h3>
<p>门诊ID: clinic_hospital</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 class="clinic-card">
<h3>新吴门诊</h3>
<p>门诊ID: clinic_xinwu</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>
......@@ -274,32 +274,50 @@
// 调用导出API
fetch('/api/export-data')
.then(response => response.json())
.then(data => {
if (data.success) {
exportStatus.className = 'export-status success';
exportStatus.textContent = `✅ ${data.message} - 文件名: ${data.filename}`;
// 自动下载文件
const downloadLink = document.createElement('a');
downloadLink.href = data.download_url;
downloadLink.download = data.filename;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
.then(response => {
if (response.ok) {
// 获取文件名(从响应头或生成默认名称)
const contentDisposition = response.headers.get('Content-Disposition');
let filename = '回访记录导出.xlsx';
if (contentDisposition) {
const filenameMatch = contentDisposition.match(/filename="(.+)"/);
if (filenameMatch) {
filename = filenameMatch[1];
}
}
// 恢复按钮状态
exportBtn.disabled = false;
exportBtn.textContent = '📊 导出所有诊所数据到Excel';
// 3秒后隐藏状态信息
setTimeout(() => {
exportStatus.style.display = 'none';
}, 3000);
return response.blob().then(blob => ({ blob, filename }));
} else {
throw new Error(data.message);
return response.json().then(data => {
throw new Error(data.message || '导出失败');
});
}
})
.then(({ blob, filename }) => {
exportStatus.className = 'export-status success';
exportStatus.textContent = `✅ 数据导出成功 - 文件名: ${filename}`;
// 创建下载链接
const downloadUrl = window.URL.createObjectURL(blob);
const downloadLink = document.createElement('a');
downloadLink.href = downloadUrl;
downloadLink.download = filename;
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
// 释放URL对象
window.URL.revokeObjectURL(downloadUrl);
// 恢复按钮状态
exportBtn.disabled = false;
exportBtn.textContent = '📊 导出所有诊所数据到Excel';
// 3秒后隐藏状态信息
setTimeout(() => {
exportStatus.style.display = 'none';
}, 3000);
})
.catch(error => {
exportStatus.className = 'export-status error';
exportStatus.textContent = `❌ 导出失败: ${error.message}`;
......
<!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 @@
// 设置API基础URL - 根据当前页面动态配置,避免跨域问题
const currentHost = window.location.hostname;
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}`;
tailwind.config = {
......@@ -987,7 +988,7 @@
};
const apiBase = window.API_BASE_URL || '';
fetch(`${apiBase}/api/callback-records`, {
fetch(`${apiBase}/api/save-callback`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
......@@ -1218,8 +1219,48 @@
}
// 生成AI回访话术
function generateCallbackScript() {
alert('AI回访话术生成功能开发中...');
async function generateCallbackScript() {
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>
</body>
......
......@@ -126,7 +126,7 @@
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6" id="patientsGrid">
{% 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-id="{{ patient.病历号 }}"
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