commit 4e82a5615e4360fe3d29bf2d8801bcdd59126c79 Author: ember <1279347317@qq.com> Date: Wed Jul 9 16:05:50 2025 +0800 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2d2ea85 --- /dev/null +++ b/.gitignore @@ -0,0 +1,14 @@ +node_modules +.env +.env.local +.env.development +.env.production +.env.test +.env.test.local +.env.test.development +.env.test.production +.cursor +.vscode +.git +__pycache__ +front/前端aichat组件prompt.md \ No newline at end of file diff --git a/front b/front new file mode 160000 index 0000000..e3cb2c2 --- /dev/null +++ b/front @@ -0,0 +1 @@ +Subproject commit e3cb2c2ab8d1ea429c7f5b645eda872e3d1d8f14 diff --git a/server/app.py b/server/app.py new file mode 100644 index 0000000..2c42e65 --- /dev/null +++ b/server/app.py @@ -0,0 +1,83 @@ +import flask +from flask import request, jsonify +from models import db, Doctor +from login import auth_bp +from utils.jwtauth import jwt_required +import bcrypt +from user import user_bp +from hospital import hospital_bp +from utils.oss import upload_to_oss +from werkzeug.utils import secure_filename +from image import image_bp +import os + +app = flask.Flask(__name__) +app.config['SQLALCHEMY_DATABASE_URI'] = 'mysql+pymysql://hidoc2:n3kbYPY3feKrJfBd@49.232.202.164:3306/hidoc2' +app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False +app.config['SECRET_KEY'] = 'hidoc_secret_key' # 添加JWT密钥 + +db.init_app(app) + +# 注册认证蓝图 +app.register_blueprint(auth_bp) +app.register_blueprint(user_bp) +app.register_blueprint(hospital_bp) +app.register_blueprint(image_bp) + +# 创建数据库表 +def create_tables(): + with app.app_context(): + db.create_all() + +# 添加一个受保护的API接口示例 +@app.route('/api/protected', methods=['GET']) +@jwt_required +def protected(): + """需要JWT认证的受保护接口示例""" + return jsonify({ + 'code': 200, + 'message': '认证成功', + 'data': { + 'user_id': request.user_id, + 'phone': request.user_phone + } + }) + +@app.route('/api/upload', methods=['POST']) +@jwt_required +def upload_file(): + """接收文件并上传到阿里云OSS的接口""" + if 'file' not in request.files: + return jsonify({'code': 400, 'message': '请求中不包含文件'}), 400 + + file = request.files['file'] + + if file.filename == '': + return jsonify({'code': 400, 'message': '未选择文件'}), 400 + + if file: + # 使用 secure_filename 确保文件名安全 + filename = secure_filename(file.filename) + # 为了防止文件名冲突,可以加上用户ID或时间戳等作为前缀 + # 这里我们简单地存放在 'uploads/' 目录下 + object_name = f"hidoc2/uploads/{request.user_id}/{filename}" + + # 直接上传文件流到OSS + file_url = upload_to_oss(file.stream, object_name) + + if file_url: + return jsonify({ + 'code': 200, + 'message': '文件上传成功', + 'data': { + 'url': file_url + } + }) + else: + return jsonify({'code': 500, 'message': '文件上传失败'}), 500 + + return jsonify({'code': 500, 'message': '发生未知错误'}), 500 + +if __name__ == '__main__': + create_tables() # 在启动应用前创建数据库表 + app.run(debug=True) \ No newline at end of file diff --git a/server/hospital.py b/server/hospital.py new file mode 100644 index 0000000..1961631 --- /dev/null +++ b/server/hospital.py @@ -0,0 +1,417 @@ +import utils.jwtauth +from flask import request, jsonify, Blueprint, make_response +from sqlalchemy import distinct +from models import db, Doctor, Office, DoctorOffice, DoctorHospital, Patient, Case, Image +from datetime import datetime + +hospital_bp = Blueprint('hospital', __name__) + +@hospital_bp.route('/api/hospital/office', methods=['GET']) +@utils.jwtauth.jwt_required +def get_doctor_offices(): + """ + 获取当前登录医生在指定医院负责的所有科室列表。 + 如果医生负责的是一个父科室,则返回该科室及其所有子科室。 + 结果会自动去重。 + + 请求参数: + - hospital_id: 医院ID + + 返回: + { + "code": 200, + "message": "获取科室列表成功", + "data": [ + { + "id": 科室ID, + "name": "科室名称", + "parent_id": 父科室ID, + "hospital_id": 医院ID, + "created_at": "创建时间", + "parent_path": [ + { + "id": 父科室ID, + "name": "父科室名称", + "parent_id": 上级父科室ID, + "hospital_id": 医院ID, + "created_at": "创建时间" + }, + ... + ] + }, + ... + ] + } + """ + user_id = request.user_id + hospital_id = request.args.get('hospital_id') + + if not hospital_id: + return jsonify({ + 'code': 400, + 'message': '缺少必要参数: hospital_id' + }), 400 + + try: + hospital_id = int(hospital_id) + except ValueError: + return jsonify({ + 'code': 400, + 'message': 'hospital_id必须是整数' + }), 400 + + # 验证医生是否属于该医院 + doctor_hospital = DoctorHospital.query.filter_by( + doc_id=user_id, + hosp_id=hospital_id + ).first() + + if not doctor_hospital: + return jsonify({ + 'code': 403, + 'message': '您不属于该医院或该医院不存在' + }), 403 + + # 1. 获取医生在该医院直接负责的所有科室 + doctor_offices = db.session.query(Office).join( + DoctorOffice, Office.id == DoctorOffice.off_id + ).filter( + DoctorOffice.doc_id == user_id, + Office.hospital_id == hospital_id + ).all() + + # 2. 收集所有相关科室(直接负责的 + 子科室),并去重 + final_offices_map = {} # 使用字典去重, key: office.id, value: office object + + def get_all_descendants(office): + # 递归函数,获取一个科室及其所有子孙科室 + if office.id not in final_offices_map: + final_offices_map[office.id] = office + # office.children 来自于 models.py 中的 backref + for child in office.children: + get_all_descendants(child) + + for office in doctor_offices: + get_all_descendants(office) + + # 3. 为每个科室构建完整的父科室路径 + result = [] + # 预先获取医院所有科室,以高效构建父路径,避免循环查库 + all_hospital_offices = {o.id: o for o in Office.query.filter_by(hospital_id=hospital_id).all()} + + for office in final_offices_map.values(): + office_data = office.to_dict() + parent_path = [] + + current_parent_id = office.parent_id + while current_parent_id: + # 使用预先获取的字典来查找父科室 + parent_office = all_hospital_offices.get(current_parent_id) + if parent_office: + parent_path.append(parent_office.to_dict()) + current_parent_id = parent_office.parent_id + else: + break + + # 反转路径,使其从顶层父科室开始 + parent_path.reverse() + office_data['parent_path'] = parent_path + result.append(office_data) + + return make_response(jsonify({ + 'code': 200, + 'message': '获取科室列表成功', + 'data': result + })) + +def get_all_child_offices(parent_office_id): + """ + 递归获取一个科室下的所有子科室ID + """ + # 使用集合以避免重复 + all_office_ids = {int(parent_office_id)} + # 待检查的科室ID队列 + offices_to_check = [int(parent_office_id)] + + while offices_to_check: + current_id = offices_to_check.pop(0) + # 查询以当前科室为父科室的所有子科室 + children = Office.query.filter_by(parent_id=current_id).all() + for child in children: + if child.id not in all_office_ids: + all_office_ids.add(child.id) + offices_to_check.append(child.id) + + return list(all_office_ids) + +@hospital_bp.route('/api/hospital/case', methods=['GET']) +@utils.jwtauth.jwt_required +def get_cases_by_office(): + """ + 根据科室ID获取所有病历(包括子科室),支持分页、病人姓名搜索和只看我的病历 + """ + user_id = request.user_id + office_id = request.args.get('office_id') + patient_name = request.args.get('patient_name') + my_case = request.args.get('my_case') # 'true' or 'false' + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 10, type=int) + + if not office_id: + return jsonify({'code': 400, 'message': '缺少office_id参数'}), 400 + + try: + office_id = int(office_id) + except ValueError: + return jsonify({'code': 400, 'message': 'office_id必须是整数'}), 400 + + # 检查科室是否存在 + office = Office.query.get(office_id) + if not office: + return jsonify({'code': 404, 'message': '科室不存在'}), 404 + + # 获取该科室及其所有子科室的ID + all_office_ids = get_all_child_offices(office_id) + + # 在这些科室中查询病历 + cases_query = Case.query.filter(Case.office_id.in_(all_office_ids)) + + # 如果提供了病人姓名,则加入查询条件 + if patient_name: + cases_query = cases_query.join(Patient).filter(Patient.name.contains(patient_name)) + + # 如果勾选了"只显示我创建的" + if my_case == 'true': + cases_query = cases_query.filter(Case.doctor_id == user_id) + + # 按病历日期降序排序 + cases_pagination = cases_query.order_by(Case.case_date.desc(), Case.created_at.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + cases = cases_pagination.items + + # 优化:一次性获取所有相关影像及其预览图 + images_by_case_id = {} + case_ids = [c.id for c in cases] + + if case_ids: + # 1. 获取所有病例关联的主影像 + main_images = db.session.query(Image).filter( + Image.case_id.in_(case_ids), + Image.parent_image_id.is_(None) + ).all() + + main_image_ids = [img.id for img in main_images] + previews_by_parent_id = {} + + if main_image_ids: + # 2. 为兼容旧版MySQL,采用两步查询法获取预览图 + # 首先,获取所有主影像关联的子影像 + all_child_images = db.session.query(Image).filter( + Image.parent_image_id.in_(main_image_ids) + ).order_by(Image.parent_image_id, Image.id.asc()).all() + + # 然后,在Python中处理,为每个父ID找到第一个子影像 + for child in all_child_images: + if child.parent_image_id not in previews_by_parent_id: + previews_by_parent_id[child.parent_image_id] = child + + # 3. 构建影像信息并按case_id分组 + base_url = "https://cdn.ember.ac.cn" + for img in main_images: + preview_url = f"{base_url}/{img.oss_key}" # 默认是源文件 + + if img.format in ['dicom', 'nii']: + preview_image = previews_by_parent_id.get(img.id) + if preview_image: + preview_url = f"{base_url}/{preview_image.oss_key}" + + image_info = { + 'image_id': img.id, + 'name': img.name, + 'preview_url': preview_url, + 'dim': img.dim + } + if img.case_id not in images_by_case_id: + images_by_case_id[img.case_id] = [] + images_by_case_id[img.case_id].append(image_info) + + # 格式化返回数据 + data = [] + for case in cases: + case_data = case.to_dict() + case_data['patient_name'] = case.patient.name + case_data['doctor_name'] = case.doctor.name + case_data['office_name'] = case.office.name + case_data['images'] = images_by_case_id.get(case.id, []) # 添加影像列表 + data.append(case_data) + + return jsonify({ + 'code': 200, + 'message': '查询成功', + 'data': data, + 'pagination': { + 'page': cases_pagination.page, + 'per_page': cases_pagination.per_page, + 'total_pages': cases_pagination.pages, + 'total_items': cases_pagination.total + } + }) + +@hospital_bp.route('/api/hospital/add_patient', methods=['POST']) +@utils.jwtauth.jwt_required +def add_patient(): + """ + 添加新病人 + """ + data = request.get_json() + if not data: + return jsonify({'code': 400, 'message': '请求体不能为空'}), 400 + + name = data.get('name') + gender = data.get('gender') + birthday = data.get('birthday') # birthday是可选的 + + if not name or not gender: + return jsonify({'code': 400, 'message': '缺少必要参数: name, gender'}), 400 + + if gender not in ['男', '女', '未知']: + return jsonify({'code': 400, 'message': 'gender参数无效,应为 "男"、"女" 或 "未知"'}), 400 + + try: + new_patient = Patient(name=name, gender=gender, birthday=birthday) + db.session.add(new_patient) + db.session.commit() + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'message': f'数据库错误: {str(e)}'}), 500 + + return jsonify({ + 'code': 201, + 'message': '添加病人成功', + 'data': new_patient.to_dict() + }), 201 + +@hospital_bp.route('/api/hospital/case', methods=['POST']) +@utils.jwtauth.jwt_required +def add_case(): + """ + 创建新病历 + """ + doctor_id = request.user_id + data = request.get_json() + if not data: + return jsonify({'code': 400, 'message': '请求体不能为空'}), 400 + + patient_id = data.get('patient_id') + office_id = data.get('office_id') + case_date_str = data.get('case_date') + + if not all([patient_id, office_id, case_date_str]): + return jsonify({'code': 400, 'message': '缺少必要参数: patient_id, office_id, case_date'}), 400 + + # 验证数据 + if not Patient.query.get(patient_id): + return jsonify({'code': 404, 'message': '病人不存在'}), 404 + + office = Office.query.get(office_id) + if not office: + return jsonify({'code': 404, 'message': '科室不存在'}), 404 + + # 验证医生是否属于该科室所在的医院 + if not DoctorHospital.query.filter_by(doc_id=doctor_id, hosp_id=office.hospital_id).first(): + return jsonify({'code': 403, 'message': '您不属于该科室所在的医院,无法创建病历'}), 403 + + try: + case_date = datetime.strptime(case_date_str, '%Y-%m-%d').date() + except ValueError: + return jsonify({'code': 400, 'message': 'case_date格式不正确,应为YYYY-MM-DD'}), 400 + + # 创建新病历 + new_case = Case( + doctor_id=doctor_id, + patient_id=patient_id, + office_id=office_id, + case_date=case_date, + chief_complaint=data.get('chief_complaint'), + present_illness_history=data.get('present_illness_history'), + past_medical_history=data.get('past_medical_history'), + personal_history=data.get('personal_history'), + family_history=data.get('family_history'), + physical_examination=data.get('physical_examination'), + diagnosis=data.get('diagnosis'), + treatment_plan=data.get('treatment_plan'), + medication_details=data.get('medication_details'), + notes=data.get('notes') + ) + + try: + db.session.add(new_case) + db.session.commit() + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'message': f'数据库错误: {str(e)}'}), 500 + + return jsonify({'code': 201, 'message': '病历创建成功', 'data': new_case.to_dict()}), 201 + +@hospital_bp.route('/api/hospital/case', methods=['PUT']) +@utils.jwtauth.jwt_required +def update_case(): + """ + 更新病历信息 + """ + data = request.get_json() + if not data: + return jsonify({'code': 400, 'message': '请求体不能为空'}), 400 + + case_id = data.get('id') + if not case_id: + return jsonify({'code': 400, 'message': '缺少病历ID (id)'}), 400 + + case = Case.query.get(case_id) + if not case: + return jsonify({'code': 404, 'message': '病历不存在'}), 404 + + # 任何医生都可以编辑病历,也可以添加权限控制 + # if case.doctor_id != request.user_id: + # return jsonify({'code': 403, 'message': '您无权编辑此病历'}), 403 + + # 更新字段 + for key, value in data.items(): + # id和创建时间不能被修改 + if key not in ['id', 'created_at', 'doctor_id', 'patient_id'] and hasattr(case, key): + if key == 'case_date': + try: + setattr(case, key, datetime.strptime(value, '%Y-%m-%d').date()) + except (ValueError, TypeError): + # 如果日期格式错误,可以忽略或返回错误 + pass + else: + setattr(case, key, value) + + try: + db.session.commit() + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'message': f'数据库错误: {str(e)}'}), 500 + + return jsonify({'code': 200, 'message': '病历更新成功', 'data': case.to_dict()}) + +# 根据姓名搜索病人 +@hospital_bp.route('/api/hospital/patient', methods=['GET']) +@utils.jwtauth.jwt_required +def search_patient(): + """ + 根据姓名搜索病人 + """ + name = request.args.get('name') + if not name: + return jsonify({'code': 400, 'message': '缺少必要参数: name'}), 400 + + patients = Patient.query.filter(Patient.name.contains(name)).all() + return jsonify({ + 'code': 200, + 'message': '搜索成功', + 'data': [patient.to_dict() for patient in patients] + }) diff --git a/server/image.py b/server/image.py new file mode 100644 index 0000000..bc15b47 --- /dev/null +++ b/server/image.py @@ -0,0 +1,562 @@ +import utils.jwtauth +from flask import request, jsonify, Blueprint +from models import db, Image, Patient, Office, Case, ImageBbox +from utils.oss import upload_to_oss, custom_endpoint +import uuid +import io +import os +import pydicom +from pydicom.pixel_data_handlers.util import apply_voi_lut +import nibabel as nib +import numpy as np +from PIL import Image as PilImage +import tempfile + +image_bp = Blueprint('image', __name__) + +def convert_to_png(pixel_array, is_dicom, ds=None): + """ + 将DICOM或NII文件的像素数组转换为PNG图像字节流。 + 如果可用,会为DICOM应用VOI LUT。 + """ + # 归一化或应用LUT + if is_dicom and ds and 'VOILUTSequence' in ds: + # 应用VOI LUT + arr = apply_voi_lut(pixel_array, ds) + else: + # 其他情况进行简单的归一化 + arr = pixel_array.astype(np.float32) + if np.max(arr) != np.min(arr): + arr = (arr - np.min(arr)) / (np.max(arr) - np.min(arr)) * 255.0 + else: + arr = np.zeros_like(arr) + + arr = arr.astype(np.uint8) + + # 转换为灰度PIL图像 + if arr.ndim == 2: + pil_img = PilImage.fromarray(arr, 'L') + else: # 对于可能存在的多通道数据,只取第一通道 + pil_img = PilImage.fromarray(arr[:,:,0], 'L') + + # 保存到内存缓冲区 + img_buffer = io.BytesIO() + pil_img.save(img_buffer, format='PNG') + img_buffer.seek(0) + + return img_buffer + +@image_bp.route('/api/image/add', methods=['POST']) +@utils.jwtauth.jwt_required +def add_image(): + """ + 处理医学影像上传、处理和数据库记录创建。 + """ + creator_id = request.user_id + + # 1. --- 验证输入 --- + if 'file' not in request.files: + return jsonify({'code': 400, 'message': '缺少必要参数: file'}), 400 + + name = request.form.get('name') + img_type = request.form.get('type') + + if not all([name, img_type]): + return jsonify({'code': 400, 'message': '缺少必要参数: name, type'}), 400 + + file = request.files['file'] + if file.filename == '': + return jsonify({'code': 400, 'message': '未选择文件'}), 400 + + patient_id = request.form.get('patient_id') if request.form.get('patient_id') else None + office_id = request.form.get('office_id') if request.form.get('office_id') else None + case_id = request.form.get('case_id') if request.form.get('case_id') else None + note = request.form.get('note') + + # 验证可选的外键是否存在 + if patient_id and not Patient.query.get(patient_id): + return jsonify({'code': 404, 'message': '病人不存在'}), 404 + if office_id and not Office.query.get(office_id): + return jsonify({'code': 404, 'message': '科室不存在'}), 404 + if case_id and not Case.query.get(case_id): + return jsonify({'code': 404, 'message': '病历不存在'}), 404 + + # 2. --- 文件分析 --- + file_bytes = file.read() + file_size_kb = len(file_bytes) / 1024.0 + file.close() + + file_stream = io.BytesIO(file_bytes) + + ds = None + nib_img = None + img_format = 'picture' + tmp_path = None + + try: + file_stream.seek(0) + ds = pydicom.dcmread(file_stream, stop_before_pixels=False) + img_format = 'dicom' + except pydicom.errors.InvalidDicomError: + pass + + if not ds: + try: + # 使用临时文件来稳健地处理NII加载 + # 后缀有助于nibabel猜测文件类型 + suffix = os.path.splitext(file.filename)[1] if file.filename and file.filename.endswith(('.nii', '.nii.gz')) else '.tmp' + with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp: + tmp.write(file_bytes) + tmp_path = tmp.name + + nib_img = nib.load(tmp_path) + img_format = 'nii' + except (nib.filebasedimages.ImageFileError, ValueError): + # 不是一个有效的NII文件。它将被作为 'picture' 处理。 + pass + + # 3. --- 处理并保存 --- + try: + # 情况 A: 普通图片 + if img_format == 'picture': + oss_key = f"hidoc2/images/{uuid.uuid4()}{os.path.splitext(file.filename)[1]}" + file_stream.seek(0) + if not upload_to_oss(file_stream, oss_key): + raise Exception("OSS upload failed") + + new_image = Image( + name=name, format='picture', type=img_type, dim='2D', + patient_id=patient_id, creator_id=creator_id, + office_id=office_id, case_id=case_id, note=note, + oss_key=oss_key, size=file_size_kb + ) + db.session.add(new_image) + db.session.commit() + return jsonify({'code': 201, 'message': '影像上传成功', 'data': new_image.to_dict()}), 201 + + # 情况 B: DICOM 或 NII + is_dicom = (img_format == 'dicom') + + original_ext = ".dcm" if is_dicom else os.path.splitext(file.filename)[1] + original_oss_key = f"hidoc2/images/{uuid.uuid4()}{original_ext}" + file_stream.seek(0) + if not upload_to_oss(file_stream, original_oss_key): + raise Exception("Original file OSS upload failed") + + pixel_array = ds.pixel_array if is_dicom else nib_img.get_fdata() + shape = pixel_array.shape + is_3d = (hasattr(ds, 'NumberOfFrames') and ds.NumberOfFrames > 1) if is_dicom else (pixel_array.ndim >= 3) + + # B.1 --- 处理3D影像 --- + if is_3d: + slice_x, slice_y, slice_z = (shape[2], shape[1], shape[0]) if is_dicom else (shape[0], shape[1], shape[2]) + + volume_image = Image( + name=name, format=img_format, type=img_type, dim='3D', + patient_id=patient_id, creator_id=creator_id, + office_id=office_id, case_id=case_id, note=note, + oss_key=original_oss_key, size=file_size_kb, + slice_x=slice_x, slice_y=slice_y, slice_z=slice_z + ) + db.session.add(volume_image) + db.session.flush() + parent_id = volume_image.id + + axes = {'x': (slice_x, ' sagittal'), 'y': (slice_y, ' coronal'), 'z': (slice_z, ' axial')} + for axis_name, (slice_count, axis_label) in axes.items(): + for i in range(slice_count): + if axis_name == 'z': + slice_arr = pixel_array[i, :, :] if is_dicom else pixel_array[:, :, i].T + elif axis_name == 'y': + slice_arr = pixel_array[:, i, :] if is_dicom else pixel_array[:, i, :].T + else: # x + slice_arr = pixel_array[:, :, i] if is_dicom else pixel_array[i, :, :].T + + png_buffer = convert_to_png(slice_arr, is_dicom, ds) + png_oss_key = f"hidoc2/images/{uuid.uuid4()}.png" + png_size_kb = len(png_buffer.getvalue()) / 1024.0 + upload_to_oss(png_buffer, png_oss_key) + slice_img = Image( + name=f"{name}_{axis_label.strip()}_{i}", format='picture', type=img_type, dim='2D', + creator_id=creator_id, oss_key=png_oss_key, size=png_size_kb, + parent_image_id=parent_id, slice_direction=axis_name, slice=i + ) + db.session.add(slice_img) + # B.2 --- 处理2D影像 --- + else: + slice_y, slice_x = shape[:2] + + main_image = Image( + name=name, format=img_format, type=img_type, dim='2D', + patient_id=patient_id, creator_id=creator_id, + office_id=office_id, case_id=case_id, note=note, + oss_key=original_oss_key, size=file_size_kb, + slice_x=slice_x, slice_y=slice_y + ) + db.session.add(main_image) + db.session.flush() + parent_id = main_image.id + + png_buffer = convert_to_png(pixel_array, is_dicom, ds) + png_oss_key = f"hidoc2/images/{uuid.uuid4()}.png" + png_size_kb = len(png_buffer.getvalue()) / 1024.0 + upload_to_oss(png_buffer, png_oss_key) + + vis_image = Image( + name=f"{name}_preview", format='picture', type=img_type, dim='2D', + creator_id=creator_id, oss_key=png_oss_key, size=png_size_kb, + parent_image_id=parent_id + ) + db.session.add(vis_image) + + db.session.commit() + return jsonify({'code': 201, 'message': '影像处理成功'}), 201 + + except Exception as e: + db.session.rollback() + import traceback + traceback.print_exc() + return jsonify({'code': 500, 'message': f'处理影像时发生内部错误: {str(e)}'}), 500 + finally: + file_stream.close() + # 确保在使用完毕后删除临时文件 + if tmp_path and os.path.exists(tmp_path): + os.remove(tmp_path) + + +@image_bp.route('/api/image/', methods=['GET']) +@utils.jwtauth.jwt_required +def get_image_preview(image_id): + """ + 获取单个影像的预览信息。 + - 对于2D影像,返回源文件URL和单个预览图URL。 + - 对于3D影像,根据direction参数返回对应方向的所有切片URL。 + """ + image = Image.query.get(image_id) + if not image: + return jsonify({'code': 404, 'message': '影像不存在'}), 404 + + base_url = custom_endpoint + response_data = {'image_info': image.to_dict()} + + # --- 3D影像处理 --- + if image.dim == '3D': + direction = request.args.get('direction', 'z') + if direction not in ['x', 'y', 'z']: + return jsonify({'code': 400, 'message': '无效的direction参数,应为 "x"、"y" 或 "z"'}), 400 + + source_url = f"{base_url}/{image.oss_key}" + + slices = Image.query.filter_by( + parent_image_id=image.id, + slice_direction=direction + ).order_by(Image.slice.asc()).all() + + slice_previews = [ + {'slice': s.slice, 'url': f"{base_url}/{s.oss_key}", 'id': s.id} for s in slices + ] + + # 查询哪些切片包含标注信息 (Bbox) + annotated_slices = [] + if slices: + slice_ids = [s.id for s in slices] + # 查询所有相关的bbox记录,然后获取有bbox的image_id + annotated_slice_ids_query = db.session.query(ImageBbox.image_id).filter( + ImageBbox.image_id.in_(slice_ids) + ).distinct() + + annotated_slice_ids = {row.image_id for row in annotated_slice_ids_query} + + # 过滤出包含标注的切片索引 + annotated_slices = [s.slice for s in slices if s.id in annotated_slice_ids] + + response_data.update({ + 'dim': '3D', + 'source_url': source_url, + 'direction': direction, + 'slice_previews': slice_previews, + 'annotated_slices': annotated_slices + }) + + # --- 2D影像处理 --- + elif image.dim == '2D': + source_url = f"{base_url}/{image.oss_key}" + preview_url = source_url # 默认为自身 + + # 如果是专业的2D影像格式,则查找其单独生成的预览图 + if image.format in ['dicom', 'nii']: + preview_image = Image.query.filter_by(parent_image_id=image.id).first() + if preview_image: + preview_url = f"{base_url}/{preview_image.oss_key}" + + response_data.update({ + 'dim': '2D', + 'source_url': source_url, + 'preview_url': preview_url, + }) + + else: + return jsonify({'code': 500, 'message': '未知的影像维度'}), 500 + + return jsonify({'code': 200, 'message': '获取成功', 'data': response_data}) + + +@image_bp.route('/api/image/', methods=['PUT']) +@utils.jwtauth.jwt_required +def update_image(image_id): + """ + 编辑影像备注 (只允许操作主影像) + """ + user_id = request.user_id + data = request.get_json() + if not data or 'note' not in data: + return jsonify({'code': 400, 'message': '请求体中缺少必要参数: note'}), 400 + + image = Image.query.get(image_id) + if not image: + return jsonify({'code': 404, 'message': '影像不存在'}), 404 + + # 校验是否为主影像 + if image.parent_image_id is not None: + return jsonify({'code': 403, 'message': '只允许编辑主影像的备注'}), 403 + + # 校验创建者权限 + if image.creator_id != user_id: + return jsonify({'code': 403, 'message': '您无权编辑此影像'}), 403 + + try: + image.note = data['note'] + db.session.commit() + return jsonify({'code': 200, 'message': '影像备注更新成功', 'data': image.to_dict()}) + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'message': f'数据库错误: {str(e)}'}), 500 + + +@image_bp.route('/api/image/', methods=['DELETE']) +@utils.jwtauth.jwt_required +def delete_image(image_id): + """ + 删除影像 (只允许操作主影像) + 删除主影像会一并删除其所有子切片和相关标注 + """ + user_id = request.user_id + + image = Image.query.get(image_id) + if not image: + return jsonify({'code': 404, 'message': '影像不存在'}), 404 + + # 校验是否为主影像 + if image.parent_image_id is not None: + return jsonify({'code': 403, 'message': '只允许删除主影像'}), 403 + + # 校验创建者权限 + if image.creator_id != user_id: + return jsonify({'code': 403, 'message': '您无权删除此影像'}), 403 + + try: + # OSS上的文件不会被删除,只删除数据库记录 + db.session.delete(image) + db.session.commit() + return jsonify({'code': 200, 'message': '影像删除成功'}) + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'message': f'数据库错误: {str(e)}'}), 500 + + +@image_bp.route('/api/image/list', methods=['GET']) +@utils.jwtauth.jwt_required +def list_images(): + """ + 获取当前医生创建的所有影像列表 (仅父影像) + """ + creator_id = request.user_id + + # 只查询 parent_image_id 为 NULL 的顶层影像 + images = Image.query.filter_by(creator_id=creator_id, parent_image_id=None).order_by(Image.created_at.desc()).all() + + results = [] + base_url = "https://cdn.ember.ac.cn" + + for image in images: + image_data = image.to_dict() + source_url = f"{base_url}/{image.oss_key}" + preview_url = source_url # 默认预览URL为源URL + + # 1. 对于2D picture类型,使用源URL作为预览 (默认行为) + + # 2. 对于2D dicom/nii, 查找其子预览影像 + if image.dim == '2D' and image.format in ['dicom', 'nii']: + preview_image = Image.query.filter_by(parent_image_id=image.id).first() + if preview_image: + preview_url = f"{base_url}/{preview_image.oss_key}" + + # 3. 对于3D dicom/nii, 查找第一个切片作为预览 + elif image.dim == '3D': + first_slice = Image.query.filter_by(parent_image_id=image.id).order_by(Image.id.asc()).first() + if first_slice: + preview_url = f"{base_url}/{first_slice.oss_key}" + + image_data['source_url'] = source_url + image_data['preview_url'] = preview_url + results.append(image_data) + + return jsonify({'code': 200, 'message': '查询成功', 'data': results}) + + +@image_bp.route('/api/image/annotate', methods=['POST']) +@utils.jwtauth.jwt_required +def add_annotation(): + """ + 为影像添加标注 (目前仅支持bbox) + """ + creator_id = request.user_id + data = request.get_json() + if not data: + return jsonify({'code': 400, 'message': '请求体不能为空'}), 400 + + image_id = data.get('image_id') + anno_type = data.get('anno_type') + + if not all([image_id, anno_type]): + return jsonify({'code': 400, 'message': '缺少必要参数: image_id, anno_type'}), 400 + + image = Image.query.get(image_id) + if not image: + return jsonify({'code': 404, 'message': '影像不存在'}), 404 + + # 检查影像是否为可标注的2D图片 + if image.format != 'picture': + return jsonify({'code': 400, 'message': '标注功能仅支持 picture 格式的影像或切片'}), 400 + + if anno_type == 'bbox': + required_fields = ['up_left_x', 'up_left_y', 'bottom_right_x', 'bottom_right_y'] + if not all(field in data for field in required_fields): + return jsonify({'code': 400, 'message': f'缺少bbox必要参数: {required_fields}'}), 400 + + try: + new_bbox = ImageBbox( + image_id=image_id, + creator_id=creator_id, + up_left_x=int(data['up_left_x']), + up_left_y=int(data['up_left_y']), + bottom_right_x=int(data['bottom_right_x']), + bottom_right_y=int(data['bottom_right_y']), + note=data.get('note') + ) + db.session.add(new_bbox) + db.session.commit() + return jsonify({'code': 201, 'message': '标注创建成功', 'data': new_bbox.to_dict()}), 201 + except (ValueError, TypeError): + return jsonify({'code': 400, 'message': '坐标参数必须是有效的整数'}), 400 + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'message': f'数据库错误: {str(e)}'}), 500 + + elif anno_type == 'mask': + return jsonify({'code': 501, 'message': 'Mask标注功能暂未实现'}), 501 + else: + return jsonify({'code': 400, 'message': '无效的anno_type,应为 "bbox" 或 "mask"'}), 400 + +@image_bp.route('/api/image/annotate', methods=['GET']) +@utils.jwtauth.jwt_required +def get_annotations(): + """ + 获取影像的标注信息 + """ + image_id = request.args.get('image_id') + anno_type = request.args.get('anno_type') + + if not all([image_id, anno_type]): + return jsonify({'code': 400, 'message': '缺少必要参数: image_id, anno_type'}), 400 + + if not Image.query.get(image_id): + return jsonify({'code': 404, 'message': '影像不存在'}), 404 + + if anno_type == 'bbox': + bboxes = ImageBbox.query.filter_by(image_id=image_id).all() + data = [] + for bbox in bboxes: + bbox_data = bbox.to_dict() + bbox_data['creator_name'] = bbox.creator.name if bbox.creator else '未知' + data.append(bbox_data) + return jsonify({'code': 200, 'message': '查询成功', 'data': data}) + + else: + return jsonify({'code': 400, 'message': '无效的anno_type'}), 400 + +@image_bp.route('/api/image/annotate', methods=['DELETE']) +@utils.jwtauth.jwt_required +def delete_annotation(): + """ + 删除指定的标注 + """ + user_id = request.user_id + data = request.get_json() + anno_id = data.get('anno_id') + anno_type = data.get('anno_type') + + if not all([anno_id, anno_type]): + return jsonify({'code': 400, 'message': '请求体中缺少必要参数: anno_id, anno_type'}), 400 + + if anno_type == 'bbox': + bbox = ImageBbox.query.get(anno_id) + if not bbox: + return jsonify({'code': 404, 'message': '标注不存在'}), 404 + + if bbox.creator_id != user_id: + print(bbox.creator_id, user_id) + return jsonify({'code': 403, 'message': '您无权删除此标注'}), 403 + + try: + db.session.delete(bbox) + db.session.commit() + return jsonify({'code': 200, 'message': '标注删除成功'}) + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'message': f'数据库错误: {str(e)}'}), 500 + + else: + return jsonify({'code': 400, 'message': '无效的anno_type'}), 400 + +@image_bp.route('/api/image/annotate', methods=['PUT']) +@utils.jwtauth.jwt_required +def update_annotation(): + """ + 更新指定的标注信息 + """ + user_id = request.user_id + data = request.get_json() + anno_id = data.get('anno_id') + anno_type = data.get('anno_type') + + if not all([anno_id, anno_type]): + return jsonify({'code': 400, 'message': '请求体中缺少必要参数: anno_id, anno_type'}), 400 + + if anno_type == 'bbox': + bbox = ImageBbox.query.get(anno_id) + if not bbox: + return jsonify({'code': 404, 'message': '标注不存在'}), 404 + + if bbox.creator_id != user_id: + return jsonify({'code': 403, 'message': '您无权编辑此标注'}), 403 + + # 更新字段 + try: + for key, value in data.items(): + if key in ['up_left_x', 'up_left_y', 'bottom_right_x', 'bottom_right_y']: + setattr(bbox, key, int(value)) + elif key == 'note': + setattr(bbox, key, value) + + db.session.commit() + return jsonify({'code': 200, 'message': '标注更新成功', 'data': bbox.to_dict()}) + except (ValueError, TypeError): + return jsonify({'code': 400, 'message': '坐标参数必须是有效的整数'}), 400 + except Exception as e: + db.session.rollback() + return jsonify({'code': 500, 'message': f'数据库错误: {str(e)}'}), 500 + + else: + return jsonify({'code': 400, 'message': '无效的anno_type'}), 400 + diff --git a/server/login.py b/server/login.py new file mode 100644 index 0000000..24b07f8 --- /dev/null +++ b/server/login.py @@ -0,0 +1,180 @@ +from flask import Blueprint, request, jsonify, make_response +from models import db, Doctor +from utils.jwtauth import JWTAuth +import bcrypt + +auth_bp = Blueprint('auth', __name__) + +# 注册接口,暂时不用 +# @auth_bp.route('/api/register', methods=['POST']) +# def register(): +# """ +# 医生注册接口 + +# 请求体: +# { +# "phone": "手机号", +# "name": "姓名", +# "password": "密码", +# "gender": "性别", +# } + +# 返回: +# { +# "code": 201, +# "message": "注册成功", +# "data": { +# "id": 医生ID, +# "phone": "手机号", +# "name": "姓名", +# "gender": "性别", +# } +# } +# """ +# data = request.json + +# # 验证请求数据 +# if not data: +# return jsonify({ +# 'code': 400, +# 'message': '请求体不能为空' +# }), 400 + +# # 验证必填字段 +# required_fields = ['phone', 'name', 'password', 'gender'] +# for field in required_fields: +# if field not in data: +# return jsonify({ +# 'code': 400, +# 'message': f'缺少必填字段: {field}' +# }), 400 + +# # 验证性别枚举值 +# if data['gender'] not in ['男', '女', '未知']: +# return jsonify({ +# 'code': 400, +# 'message': '性别必须是: 男, 女, 未知' +# }), 400 + +# # 检查手机号是否已存在 +# if Doctor.query.filter_by(phone=data['phone']).first(): +# return jsonify({ +# 'code': 400, +# 'message': f'手机号 {data["phone"]} 已被注册' +# }), 400 + +# # 对密码进行加密 +# hashed_password = bcrypt.hashpw(data['password'].encode('utf-8'), bcrypt.gensalt()) + +# # 创建新医生 +# new_doctor = Doctor( +# phone=data['phone'], +# name=data['name'], +# gender=data['gender'], +# password=hashed_password.decode('utf-8') # 存储为字符串 +# ) + +# db.session.add(new_doctor) +# db.session.commit() + +# # 准备返回数据,移除敏感信息 +# doctor_data = new_doctor.to_dict() +# doctor_data.pop('password', None) + +# return jsonify({ +# 'code': 201, +# 'message': '注册成功', +# 'data': doctor_data +# }), 201 + +@auth_bp.route('/api/login', methods=['POST']) +def login(): + """ + 医生登录接口 + + 请求体: + { + "phone": "手机号", + "password": "密码" + } + + 返回: + { + "code": 200, + "message": "登录成功", + "data": { + "id": 医生ID, + "phone": "手机号", + "name": "姓名", + "gender": "性别", + } + } + """ + data = request.json + + # 验证请求数据 + if not data: + return jsonify({ + 'code': 400, + 'message': '请求体不能为空' + }), 400 + + phone = data.get('phone') + password = data.get('password') + + if not phone or not password: + return jsonify({ + 'code': 400, + 'message': '手机号和密码不能为空' + }), 400 + + # 查询医生 + doctor = Doctor.query.filter_by(phone=phone).first() + + # 验证医生是否存在及密码是否正确 + if not doctor or not bcrypt.checkpw(password.encode('utf-8'), doctor.password.encode('utf-8')): + return jsonify({ + 'code': 401, + 'message': '手机号或密码错误' + }), 401 + + # 生成JWT令牌 + token = JWTAuth.generate_token(doctor.id, doctor.phone) + + # 准备返回数据,移除敏感信息 + doctor_data = doctor.to_dict() + doctor_data.pop('password', None) + + # 创建响应对象 + response = make_response(jsonify({ + 'code': 200, + 'message': '登录成功', + 'data': doctor_data + })) + + # 设置Cookie + response.set_cookie( + 'token', + token, + httponly=True, + max_age=24 * 60 * 60, # 24小时 + secure=False, # 生产环境应设为True,要求HTTPS + samesite='Lax' + ) + + return response + +@auth_bp.route('/api/logout', methods=['POST']) +def logout(): + """ + 登出接口 + """ + response = make_response(jsonify({ + 'code': 200, + 'message': '登出成功' + })) + + # 清除Cookie中的令牌 + response.set_cookie('token', '', expires=0) + + return response diff --git a/server/models.py b/server/models.py new file mode 100644 index 0000000..83c97f2 --- /dev/null +++ b/server/models.py @@ -0,0 +1,279 @@ +from flask_sqlalchemy import SQLAlchemy +from datetime import datetime + +db = SQLAlchemy() + +class Hospital(db.Model): + __tablename__ = 'hospital' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='医院ID') + name = db.Column(db.String(100), nullable=False, comment='医院名称') + address = db.Column(db.String(255), nullable=True, comment='医院地址') + deleted = db.Column(db.Boolean, default=False, comment='逻辑删除标志,True表示已删除') + created_at = db.Column(db.TIMESTAMP, default=datetime.now, comment='创建时间') + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'address': self.address, + 'deleted': self.deleted, + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None + } + +class Doctor(db.Model): + __tablename__ = 'doctor' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='医生ID') + phone = db.Column(db.String(20), unique=True, nullable=False, comment='医生电话,唯一且非空') + name = db.Column(db.String(100), nullable=False, comment='医生姓名') + password = db.Column(db.String(100), nullable=False, comment='医生密码') + gender = db.Column(db.Enum('男', '女', '未知'), nullable=False, comment='医生性别') + created_at = db.Column(db.TIMESTAMP, default=datetime.now, comment='创建时间') + + def to_dict(self): + return { + 'id': self.id, + 'phone': self.phone, + 'name': self.name, + 'password': self.password, + 'gender': self.gender, + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None + } + +class Office(db.Model): + __tablename__ = 'office' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='科室ID') + name = db.Column(db.String(100), nullable=False, comment='科室名称') + parent_id = db.Column(db.Integer, db.ForeignKey('office.id', ondelete='CASCADE'), nullable=True, comment='上级科室ID') + hospital_id = db.Column(db.Integer, db.ForeignKey('hospital.id'), nullable=False, comment='所属医院ID') + created_at = db.Column(db.TIMESTAMP, default=datetime.now, comment='创建时间') + + # 自引用关系,添加级联删除 + parent = db.relationship('Office', remote_side=[id], + backref=db.backref('children', lazy='dynamic', cascade='all, delete-orphan'), + passive_deletes=True) + # 与医院的关系 + hospital = db.relationship('Hospital', backref=db.backref('offices', lazy='dynamic')) + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'parent_id': self.parent_id, + 'hospital_id': self.hospital_id, + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None + } + +class DoctorOffice(db.Model): + __tablename__ = 'doctor_office' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='关联ID') + doc_id = db.Column(db.Integer, db.ForeignKey('doctor.id', ondelete='CASCADE'), nullable=False, comment='医生ID') + off_id = db.Column(db.Integer, db.ForeignKey('office.id', ondelete='CASCADE'), nullable=False, comment='科室ID') + created_at = db.Column(db.TIMESTAMP, default=datetime.now, comment='创建时间') + + # 关系 + doctor = db.relationship('Doctor', backref=db.backref('offices', lazy='dynamic', cascade='all, delete-orphan')) + office = db.relationship('Office', backref=db.backref('doctors', lazy='dynamic', cascade='all, delete-orphan')) + + def to_dict(self): + return { + 'id': self.id, + 'doc_id': self.doc_id, + 'off_id': self.off_id, + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None + } + +class DoctorHospital(db.Model): + __tablename__ = 'doctor_hospital' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='关联ID') + doc_id = db.Column(db.Integer, db.ForeignKey('doctor.id', ondelete='CASCADE'), nullable=False, comment='医生ID') + hosp_id = db.Column(db.Integer, db.ForeignKey('hospital.id'), nullable=False, comment='医院ID') + created_at = db.Column(db.TIMESTAMP, default=datetime.now, comment='创建时间') + + # 关系 + doctor = db.relationship('Doctor', backref=db.backref('hospitals', lazy='dynamic', cascade='all, delete-orphan')) + hospital = db.relationship('Hospital', backref=db.backref('doctors', lazy='dynamic')) + + def to_dict(self): + return { + 'id': self.id, + 'doc_id': self.doc_id, + 'hosp_id': self.hosp_id, + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None + } + +class Patient(db.Model): + __tablename__ = 'patient' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='病人ID') + name = db.Column(db.String(100), nullable=False, comment='病人姓名') + gender = db.Column(db.Enum('男', '女', '未知'), nullable=False, comment='病人性别') + birthday = db.Column(db.String(10), nullable=True, comment='出生日期,格式YYYY-MM-DD') + created_at = db.Column(db.TIMESTAMP, default=datetime.now, comment='创建时间') + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'gender': self.gender, + 'birthday': self.birthday, + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None + } + +class Case(db.Model): + __tablename__ = 'case' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='病历ID') + patient_id = db.Column(db.Integer, db.ForeignKey('patient.id', ondelete='CASCADE'), nullable=False, comment='病人ID') + office_id = db.Column(db.Integer, db.ForeignKey('office.id', ondelete='CASCADE'), nullable=False, comment='科室ID') + doctor_id = db.Column(db.Integer, db.ForeignKey('doctor.id', ondelete='CASCADE'), nullable=False, comment='医生ID') + case_date = db.Column(db.Date, nullable=False, comment='就诊日期') + chief_complaint = db.Column(db.Text, nullable=True, comment='主诉') + present_illness_history = db.Column(db.Text, nullable=True, comment='现病史') + past_medical_history = db.Column(db.Text, nullable=True, comment='既往史') + personal_history = db.Column(db.Text, nullable=True, comment='个人史') + family_history = db.Column(db.Text, nullable=True, comment='家族史') + physical_examination = db.Column(db.Text, nullable=True, comment='体格检查') + diagnosis = db.Column(db.Text, nullable=True, comment='诊断结果') + treatment_plan = db.Column(db.Text, nullable=True, comment='治疗方案') + medication_details = db.Column(db.Text, nullable=True, comment='用药详情') + notes = db.Column(db.Text, nullable=True, comment='备注') + created_at = db.Column(db.TIMESTAMP, default=datetime.now, comment='创建时间') + updated_at = db.Column(db.TIMESTAMP, default=datetime.now, onupdate=datetime.now, comment='更新时间') + + # 关系 + patient = db.relationship('Patient', backref=db.backref('cases', lazy='dynamic', cascade='all, delete-orphan')) + office = db.relationship('Office', backref=db.backref('cases', lazy='dynamic')) + doctor = db.relationship('Doctor', backref=db.backref('cases', lazy='dynamic')) + + def to_dict(self): + return { + 'id': self.id, + 'patient_id': self.patient_id, + 'office_id': self.office_id, + 'doctor_id': self.doctor_id, + 'case_date': self.case_date.strftime('%Y-%m-%d') if self.case_date else None, + 'chief_complaint': self.chief_complaint, + 'present_illness_history': self.present_illness_history, + 'past_medical_history': self.past_medical_history, + 'personal_history': self.personal_history, + 'family_history': self.family_history, + 'physical_examination': self.physical_examination, + 'diagnosis': self.diagnosis, + 'treatment_plan': self.treatment_plan, + 'medication_details': self.medication_details, + 'notes': self.notes, + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None, + 'updated_at': self.updated_at.strftime('%Y-%m-%d %H:%M:%S') if self.updated_at else None + } + +class Image(db.Model): + __tablename__ = 'image' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='影像ID') + name = db.Column(db.String(255), nullable=True, comment='影像名称') + format = db.Column(db.Enum('dicom', 'nii', 'picture'), nullable=False, comment='影像格式') + type = db.Column(db.Enum('X-ray', 'CT', 'MRI', 'PET', 'US', '其他'), nullable=False, comment='影像类型') + dim = db.Column(db.Enum('2D', '3D'), nullable=False, default='2D', comment='影像维度') + patient_id = db.Column(db.Integer, db.ForeignKey('patient.id', ondelete='SET NULL'), nullable=True, comment='病人ID') + creator_id = db.Column(db.Integer, db.ForeignKey('doctor.id', ondelete='CASCADE'), nullable=False, comment='创建者ID (医生)') + office_id = db.Column(db.Integer, db.ForeignKey('office.id', ondelete='SET NULL'), nullable=True, comment='科室ID') + case_id = db.Column(db.Integer, db.ForeignKey('case.id', ondelete='SET NULL'), nullable=True, comment='病历ID') + note = db.Column(db.Text, nullable=True, comment='备注') + oss_key = db.Column(db.String(255), nullable=False, unique=True, comment='OSS对象存储键') + size = db.Column(db.Numeric(10, 2), nullable=False, comment='文件大小(KB)') + parent_image_id = db.Column(db.Integer, db.ForeignKey('image.id', ondelete='CASCADE'), nullable=True, comment='父影像ID (用于3D影像切片)') + slice_direction = db.Column(db.Enum('x', 'y', 'z'), nullable=True, comment='3D影像切片方向') + slice = db.Column(db.Integer, nullable=True, comment='3D影像切片索引') + slice_x = db.Column(db.Integer, nullable=True, comment='3D影像x方向切片数') + slice_y = db.Column(db.Integer, nullable=True, comment='3D影像y方向切片数') + slice_z = db.Column(db.Integer, nullable=True, comment='3D影像z方向切片数') + created_at = db.Column(db.TIMESTAMP, default=datetime.now, comment='创建时间') + + # 关系 + patient = db.relationship('Patient', backref=db.backref('images', lazy='dynamic')) + creator = db.relationship('Doctor', backref=db.backref('created_images', lazy='dynamic')) + office = db.relationship('Office', backref=db.backref('images', lazy='dynamic')) + case = db.relationship('Case', backref=db.backref('images', lazy='dynamic')) + parent_image = db.relationship('Image', remote_side=[id], backref=db.backref('child_images', lazy='dynamic', cascade='all, delete-orphan')) + + def to_dict(self): + return { + 'id': self.id, + 'name': self.name, + 'format': self.format, + 'type': self.type, + 'dim': self.dim, + 'patient_id': self.patient_id, + 'creator_id': self.creator_id, + 'office_id': self.office_id, + 'case_id': self.case_id, + 'note': self.note, + 'oss_key': self.oss_key, + 'size': float(self.size) if self.size is not None else None, + 'parent_image_id': self.parent_image_id, + 'slice_direction': self.slice_direction, + 'slice': self.slice, + 'slice_x': self.slice_x, + 'slice_y': self.slice_y, + 'slice_z': self.slice_z, + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None + } + +class ImageBbox(db.Model): + __tablename__ = 'image_bbox' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='边界框标注ID') + image_id = db.Column(db.Integer, db.ForeignKey('image.id', ondelete='CASCADE'), nullable=False, comment='影像ID') + creator_id = db.Column(db.Integer, db.ForeignKey('doctor.id', ondelete='CASCADE'), nullable=False, comment='创建者ID (医生)') + up_left_x = db.Column(db.Integer, nullable=False, comment='左上角X坐标') + up_left_y = db.Column(db.Integer, nullable=False, comment='左上角Y坐标') + bottom_right_x = db.Column(db.Integer, nullable=False, comment='右下角X坐标') + bottom_right_y = db.Column(db.Integer, nullable=False, comment='右下角Y坐标') + note = db.Column(db.Text, nullable=True, comment='备注') + created_at = db.Column(db.TIMESTAMP, default=datetime.now, comment='创建时间') + + # 关系 + image = db.relationship('Image', backref=db.backref('bboxes', lazy='dynamic', cascade='all, delete-orphan')) + creator = db.relationship('Doctor', backref=db.backref('created_bboxes', lazy='dynamic')) + + def to_dict(self): + return { + 'id': self.id, + 'image_id': self.image_id, + 'creator_id': self.creator_id, + 'up_left_x': self.up_left_x, + 'up_left_y': self.up_left_y, + 'bottom_right_x': self.bottom_right_x, + 'bottom_right_y': self.bottom_right_y, + 'note': self.note, + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None + } + +class ImageMask(db.Model): + __tablename__ = 'image_mask' + + id = db.Column(db.Integer, primary_key=True, autoincrement=True, comment='掩码标注ID') + image_id = db.Column(db.Integer, db.ForeignKey('image.id', ondelete='CASCADE'), nullable=False, comment='影像ID') + creator_id = db.Column(db.Integer, db.ForeignKey('doctor.id', ondelete='CASCADE'), nullable=False, comment='创建者ID (医生)') + mask_oss_key = db.Column(db.String(255), nullable=False, unique=True, comment='掩码文件OSS键') + note = db.Column(db.Text, nullable=True, comment='备注') + created_at = db.Column(db.TIMESTAMP, default=datetime.now, comment='创建时间') + + # 关系 + image = db.relationship('Image', backref=db.backref('masks', lazy='dynamic', cascade='all, delete-orphan')) + creator = db.relationship('Doctor', backref=db.backref('created_masks', lazy='dynamic')) + + def to_dict(self): + return { + 'id': self.id, + 'image_id': self.image_id, + 'creator_id': self.creator_id, + 'mask_oss_key': self.mask_oss_key, + 'note': self.note, + 'created_at': self.created_at.strftime('%Y-%m-%d %H:%M:%S') if self.created_at else None + } diff --git a/server/user.py b/server/user.py new file mode 100644 index 0000000..e919fbb --- /dev/null +++ b/server/user.py @@ -0,0 +1,95 @@ +import utils.jwtauth +from flask import request, jsonify, Blueprint, make_response +from models import db, Doctor, DoctorHospital, Hospital + +user_bp = Blueprint('user', __name__) + +@user_bp.route('/api/user/info', methods=['GET']) +@utils.jwtauth.jwt_required +def get_user_info(): + user_id = request.user_id + doctor = Doctor.query.filter_by(id=user_id).first() + return make_response(jsonify({ + 'code': 200, + 'message': '获取用户信息成功', + 'data': doctor.to_dict() + })) + +# 获取医生的医院列表。用于医生登录后选择自己的一个医院 +@user_bp.route('/api/user/hospitals', methods=['GET']) +@utils.jwtauth.jwt_required +def get_user_hospitals(): + user_id = request.user_id + + # 查询医生关联的所有未删除的医院 + hospitals = Hospital.query.join( + DoctorHospital, Hospital.id == DoctorHospital.hosp_id + ).filter( + DoctorHospital.doc_id == user_id, + Hospital.deleted == False + ).all() + + # 转换为字典列表 + hospital_list = [hospital.to_dict() for hospital in hospitals] + + return make_response(jsonify({ + 'code': 200, + 'message': '获取医生医院列表成功', + 'data': hospital_list + })) + +# 修改医生个人信息 +@user_bp.route('/api/user/update', methods=['PUT']) +@utils.jwtauth.jwt_required +def update_user_info(): + user_id = request.user_id + data = request.json + + if not data: + return jsonify({ + 'code': 400, + 'message': '请求体不能为空' + }), 400 + + doctor = Doctor.query.filter_by(id=user_id).first() + if not doctor: + return jsonify({ + 'code': 404, + 'message': '用户不存在' + }), 404 + + # 只允许修改姓名和性别 + if 'name' in data: + doctor.name = data['name'] + + if 'gender' in data: + if data['gender'] not in ['男', '女', '未知']: + return jsonify({ + 'code': 400, + 'message': '性别必须是: 男, 女, 未知' + }), 400 + doctor.gender = data['gender'] + + try: + db.session.commit() + return make_response(jsonify({ + 'code': 200, + 'message': '更新用户信息成功', + 'data': doctor.to_dict() + })) + except Exception as e: + db.session.rollback() + return jsonify({ + 'code': 500, + 'message': f'更新用户信息失败: {str(e)}' + }), 500 + + + + + + + + + + diff --git a/server/utils/jwtauth.py b/server/utils/jwtauth.py new file mode 100644 index 0000000..85a0697 --- /dev/null +++ b/server/utils/jwtauth.py @@ -0,0 +1,126 @@ +import jwt +import datetime +from functools import wraps +from flask import request, jsonify, current_app + +class JWTAuth: + @staticmethod + def generate_token(user_id, phone, expire_hours=24): + """ + 生成JWT令牌 + + Args: + user_id: 用户ID + phone: 用户手机号 + expire_hours: 过期时间(小时),默认24小时 + + Returns: + 生成的JWT令牌 + """ + payload = { + 'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=expire_hours), + 'iat': datetime.datetime.utcnow(), + 'sub': str(user_id), + 'phone': phone + } + return jwt.encode( + payload, + current_app.config.get('SECRET_KEY', 'hidoc_secret_key'), + algorithm='HS256' + ) + + @staticmethod + def verify_token(token): + """ + 验证JWT令牌 + + Args: + token: JWT令牌 + + Returns: + 解码后的payload或None(如果验证失败) + """ + try: + payload = jwt.decode( + token, + current_app.config.get('SECRET_KEY', 'hidoc_secret_key'), + algorithms=['HS256'] + ) + return payload + except jwt.ExpiredSignatureError: + return None + except jwt.InvalidTokenError as e: + return None + + @staticmethod + def refresh_token(token, expire_hours=24): + """ + 刷新JWT令牌 + + Args: + token: 原JWT令牌 + expire_hours: 新令牌过期时间(小时) + + Returns: + 新的JWT令牌或None(如果原令牌无效) + """ + try: + payload = jwt.decode( + token, + current_app.config.get('SECRET_KEY', 'hidoc_secret_key'), + algorithms=['HS256'] + ) + + # 创建新的payload,保留原有信息但更新过期时间 + new_payload = { + 'exp': datetime.datetime.utcnow() + datetime.timedelta(hours=expire_hours), + 'iat': datetime.datetime.utcnow(), + 'sub': payload['sub'], + 'phone': payload['phone'] + } + + return jwt.encode( + new_payload, + current_app.config.get('SECRET_KEY', 'hidoc_secret_key'), + algorithm='HS256' + ) + except (jwt.ExpiredSignatureError, jwt.InvalidTokenError): + return None + +def jwt_required(f): + """ + JWT认证装饰器,用于保护需要认证的API接口 + """ + @wraps(f) + def decorated(*args, **kwargs): + token = None + + # 从请求头或Cookie中获取token + auth_header = request.headers.get('Authorization') + if auth_header and auth_header.startswith('Bearer '): + token = auth_header.split(' ')[1] + + if not token: + token = request.cookies.get('token') + + if not token: + return jsonify({ + 'code': 401, + 'message': '缺少认证令牌' + }), 401 + + # 验证token + payload = JWTAuth.verify_token(token) + if not payload: + return jsonify({ + 'code': 401, + 'message': '认证令牌无效或已过期' + }), 401 + + # 将用户信息添加到request对象中,以便在视图函数中使用 + request.user_id = int(payload['sub']) + request.user_phone = payload['phone'] + + return f(*args, **kwargs) + + return decorated diff --git a/server/utils/oss.py b/server/utils/oss.py new file mode 100644 index 0000000..d7b4cff --- /dev/null +++ b/server/utils/oss.py @@ -0,0 +1,37 @@ +import oss2 +import os + +access_key_id = 'LTAI5tHP6zqRhFLCkbCcZ2t1' +access_key_secret = 'VdZs0WNoUvi1zrIpvCnQ4Sk2kJMVa7' +bucket_name = 'emberauthor' +custom_endpoint = 'https://cdn.ember.ac.cn' + +# 创建认证对象 +auth = oss2.Auth(access_key_id, access_key_secret) + +# 创建 Bucket 对象 +bucket = oss2.Bucket(auth, custom_endpoint, bucket_name, is_cname=True) + +def upload_to_oss(file_storage, object_name): + """ + 将文件上传到阿里云OSS。 + + :param file_storage: Flask 请求中的 FileStorage 对象或文件流 + :param object_name: 在 OSS 上存储的对象名称 (e.g., 'images/my-photo.jpg') + :return: 上传成功则返回文件URL,否则返回None + """ + try: + # 使用 put_object 方法上传文件流 + result = bucket.put_object(object_name, file_storage) + + # 如果HTTP状态码是200,说明上传成功 + if result.status == 200: + # 构建文件的公开访问URL + file_url = f"{custom_endpoint}/{object_name}" + return file_url + else: + print(f"OSS upload failed with status: {result.status}") + return None + except oss2.exceptions.OssError as e: + print(f"Error uploading to OSS: {e}") + return None