diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..16a8a2e --- /dev/null +++ b/.gitignore @@ -0,0 +1,33 @@ +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.Python +env/ +venv/ +.venv/ +pip-log.txt +pip-delete-this-directory.txt +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.log +.git +.mypy_cache +.pytest_cache +.hypothesize + +# Instance specific +instance/ +.env + +# PyCharm +.idea/ + +# VS Code +.vscode/ diff --git a/__pycache__/app.cpython-312.pyc b/__pycache__/app.cpython-312.pyc deleted file mode 100644 index 08608bc..0000000 Binary files a/__pycache__/app.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/config.cpython-312.pyc b/__pycache__/config.cpython-312.pyc deleted file mode 100644 index 3c13584..0000000 Binary files a/__pycache__/config.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/extensions.cpython-312.pyc b/__pycache__/extensions.cpython-312.pyc deleted file mode 100644 index 143ac3f..0000000 Binary files a/__pycache__/extensions.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/init_rbac.cpython-312.pyc b/__pycache__/init_rbac.cpython-312.pyc deleted file mode 100644 index 25d1313..0000000 Binary files a/__pycache__/init_rbac.cpython-312.pyc and /dev/null differ diff --git a/__pycache__/models.cpython-312.pyc b/__pycache__/models.cpython-312.pyc deleted file mode 100644 index 3fca0f2..0000000 Binary files a/__pycache__/models.cpython-312.pyc and /dev/null differ diff --git a/app.py b/app.py index eb9b284..62cfb99 100644 --- a/app.py +++ b/app.py @@ -1,6 +1,6 @@ -from flask import Flask, render_template +from flask import Flask, render_template, jsonify from config import Config -from extensions import db, redis_client +from extensions import db, redis_client, migrate from blueprints.auth import auth_bp from blueprints.api import api_bp from blueprints.admin import admin_bp @@ -18,6 +18,7 @@ def create_app(): # 初始化扩展 db.init_app(app) redis_client.init_app(app) + migrate.init_app(app, db) # 注册蓝图 app.register_blueprint(auth_bp) @@ -25,6 +26,46 @@ def create_app(): app.register_blueprint(admin_bp) app.register_blueprint(payment_bp) + from flask import g, session + from models import User, SystemLog + + @app.before_request + def load_user(): + """在每个请求前加载用户信息到 g,供日志系统使用""" + user_id = session.get('user_id') + if user_id: + g.user_id = user_id + g.user = db.session.get(User, user_id) + else: + g.user_id = None + g.user = None + + @app.context_processor + def inject_menu(): + """将导航菜单注入所有模板,实现服务端渲染,解决闪烁问题""" + from blueprints.auth import get_user_menu + menu = get_user_menu(g.user) if hasattr(g, 'user') else [] + return dict(nav_menu=menu) + + @app.route('/api/system_logs') + def get_system_logs(): + """获取系统日志数据 (供后台管理界面使用)""" + # 这里可以加入权限检查 + logs = SystemLog.query.order_by(SystemLog.created_at.desc()).limit(100).all() + return jsonify([{ + 'id': log.id, + 'user_id': log.user_id, + 'level': log.level, + 'module': log.module, + 'message': log.message, + 'extra': log.extra, + 'ip': log.ip, + 'path': log.path, + 'method': log.method, + 'user_agent': log.user_agent, + 'created_at': log.created_at.strftime('%Y-%m-%d %H:%M:%S') + } for log in logs]) + @app.route('/') def index(): return render_template('index.html') @@ -37,6 +78,10 @@ def create_app(): def visualizer(): return render_template('kongzhiqi.html') + @app.route('/video') + def video_page(): + return render_template('video.html') + # 自动创建数据库表 with app.app_context(): print("🔧 正在检查并创建数据库表...") diff --git a/blueprints/__pycache__/admin.cpython-312.pyc b/blueprints/__pycache__/admin.cpython-312.pyc deleted file mode 100644 index f8db394..0000000 Binary files a/blueprints/__pycache__/admin.cpython-312.pyc and /dev/null differ diff --git a/blueprints/__pycache__/api.cpython-312.pyc b/blueprints/__pycache__/api.cpython-312.pyc deleted file mode 100644 index d499527..0000000 Binary files a/blueprints/__pycache__/api.cpython-312.pyc and /dev/null differ diff --git a/blueprints/__pycache__/auth.cpython-312.pyc b/blueprints/__pycache__/auth.cpython-312.pyc deleted file mode 100644 index c123396..0000000 Binary files a/blueprints/__pycache__/auth.cpython-312.pyc and /dev/null differ diff --git a/blueprints/__pycache__/payment.cpython-312.pyc b/blueprints/__pycache__/payment.cpython-312.pyc deleted file mode 100644 index ea928e7..0000000 Binary files a/blueprints/__pycache__/payment.cpython-312.pyc and /dev/null differ diff --git a/blueprints/admin.py b/blueprints/admin.py index 03892a2..cfdad57 100644 --- a/blueprints/admin.py +++ b/blueprints/admin.py @@ -2,6 +2,7 @@ from flask import Blueprint, request, jsonify from extensions import db from models import User, Role, Permission, SystemDict, SystemNotification, Order from middlewares.auth import permission_required +from services.logger import system_logger admin_bp = Blueprint('admin', __name__, url_prefix='/api/admin') @@ -30,9 +31,11 @@ def save_role(): if not role: return jsonify({"error": "角色不存在"}), 404 role.name = data['name'] role.description = data.get('description') + system_logger.info(f"管理员修改角色: {role.name}") else: role = Role(name=data['name'], description=data.get('description')) db.session.add(role) + system_logger.info(f"管理员创建角色: {role.name}") if 'permissions' in data: perms = Permission.query.filter(Permission.name.in_(data['permissions'])).all() @@ -49,8 +52,10 @@ def delete_role(): if role: if role.name == '超级管理员': return jsonify({"error": "不能删除超级管理员角色"}), 400 + role_name = role.name db.session.delete(role) db.session.commit() + system_logger.info(f"管理员删除角色: {role_name}") return jsonify({"message": "角色删除成功"}) return jsonify({"error": "角色不存在"}), 404 @@ -80,15 +85,46 @@ def get_users(): @permission_required('manage_users') def assign_role(): data = request.json - user = User.query.get(data['user_id']) - role = Role.query.get(data['role_id']) + user = db.session.get(User, data['user_id']) + role = db.session.get(Role, data['role_id']) if user and role: user.role = role db.session.commit() + system_logger.info(f"管理员分配用户角色", user_phone=user.phone, role_name=role.name) return jsonify({"message": "角色分配成功"}) return jsonify({"error": "用户或角色不存在"}), 404 # --- 字典管理 --- +@admin_bp.route('/dict_types', methods=['GET']) +@permission_required('manage_dicts') +def get_dict_types(): + # 获取唯一的字典类型及其记录数 + counts = dict(db.session.query(SystemDict.dict_type, db.func.count(SystemDict.id))\ + .group_by(SystemDict.dict_type).all()) + + # 定义类型的友好名称 (标准类型) + standard_types = { + 'ai_model': 'AI 生成模型', + 'aspect_ratio': '画面比例配置', + 'ai_image_size': '输出尺寸设定', + 'prompt_tpl': '生图提示词模板', + 'video_model': '视频生成模型', + 'video_prompt': '视频提示词模板', + } + + # 合并数据库中存在的其他类型 + all_types = {**standard_types} + for t in counts.keys(): + if t not in all_types: + all_types[t] = t # 未知类型直接使用 Key 作为名称 + + return jsonify({ + "types": [{ + "type": t, + "name": name, + "count": counts.get(t, 0) + } for t, name in all_types.items()] + }) @admin_bp.route('/dicts', methods=['GET']) @permission_required('manage_dicts') def get_dicts(): @@ -118,9 +154,11 @@ def save_dict(): if dict_id: d = SystemDict.query.get(dict_id) if not d: return jsonify({"error": "记录不存在"}), 404 + action = "修改" else: d = SystemDict() db.session.add(d) + action = "创建" d.dict_type = data['dict_type'] d.label = data['label'] @@ -130,6 +168,7 @@ def save_dict(): d.sort_order = data.get('sort_order', 0) db.session.commit() + system_logger.info(f"管理员{action}系统配置: {d.label}") return jsonify({"message": "保存成功"}) @admin_bp.route('/dicts/delete', methods=['POST']) @@ -138,8 +177,10 @@ def delete_dict(): data = request.json d = SystemDict.query.get(data.get('id')) if d: + label = d.label db.session.delete(d) db.session.commit() + system_logger.info(f"管理员删除系统配置: {label}") return jsonify({"message": "删除成功"}) return jsonify({"error": "记录不存在"}), 404 @@ -167,15 +208,18 @@ def save_notification(): if notif_id: n = SystemNotification.query.get(notif_id) if not n: return jsonify({"error": "通知不存在"}), 404 + action = "修改" else: n = SystemNotification() db.session.add(n) + action = "发布" n.title = data['title'] n.content = data['content'] n.is_active = data.get('is_active', True) db.session.commit() + system_logger.info(f"管理员{action}通知: {n.title}") return jsonify({"message": "通知保存成功"}) @admin_bp.route('/notifications/delete', methods=['POST']) @@ -184,8 +228,10 @@ def delete_notification(): data = request.json n = SystemNotification.query.get(data.get('id')) if n: + title = n.title db.session.delete(n) db.session.commit() + system_logger.info(f"管理员删除通知: {title}") return jsonify({"message": "通知删除成功"}) return jsonify({"error": "通知不存在"}), 404 diff --git a/blueprints/api.py b/blueprints/api.py index d5d5c71..250ec96 100644 --- a/blueprints/api.py +++ b/blueprints/api.py @@ -85,7 +85,7 @@ def sync_images_background(app, record_id, raw_urls): # 更新数据库记录为持久化数据结构 try: - record = GenerationRecord.query.get(record_id) + record = db.session.get(GenerationRecord, record_id) if record: record.image_urls = json.dumps(processed_data) db.session.commit() @@ -102,11 +102,12 @@ def process_image_generation(app, user_id, task_id, payload, api_key, target_api resp = requests.post(target_api, json=payload, headers=headers, timeout=1000) if resp.status_code != 200: - # 错误处理:退还积分 - user = User.query.get(user_id) + user = db.session.get(User, user_id) if user and "sk-" in api_key: user.points += cost db.session.commit() + + system_logger.error(f"生图任务失败: {resp.text}", user_id=user_id, task_id=task_id) redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": resp.text})) return @@ -130,25 +131,176 @@ def process_image_generation(app, user_id, task_id, payload, api_key, target_api ).start() # 存入 Redis 标记完成 + system_logger.info(f"生图任务完成", user_id=user_id, task_id=task_id, model=payload.get('model')) redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "complete", "urls": raw_urls})) except Exception as e: # 异常处理:退还积分 - user = User.query.get(user_id) + user = db.session.get(User, user_id) if user and "sk-" in api_key: user.points += cost db.session.commit() + + system_logger.error(f"生图任务异常: {str(e)}", user_id=user_id, task_id=task_id) redis_client.setex(f"task:{task_id}", 3600, json.dumps({"status": "error", "message": str(e)})) +def sync_video_background(app, record_id, raw_url, internal_task_id=None): + """后台同步视频至 MinIO,带重试机制""" + with app.app_context(): + success = False + final_url = raw_url + for attempt in range(3): + try: + # 增加了流式下载,处理大视频文件 + with requests.get(raw_url, stream=True, timeout=120) as r: + r.raise_for_status() + content_type = r.headers.get('content-type', 'video/mp4') + ext = ".mp4" + if "text/html" in content_type: # 有些 API 返回的是跳转页面 + continue + + base_filename = f"video-{uuid.uuid4().hex}" + full_filename = f"{base_filename}{ext}" + + video_io = io.BytesIO() + for chunk in r.iter_content(chunk_size=8192): + video_io.write(chunk) + video_io.seek(0) + + # 上传至 MinIO + s3_client.upload_fileobj( + video_io, + Config.MINIO["bucket"], + full_filename, + ExtraArgs={"ContentType": content_type} + ) + + final_url = f"{Config.MINIO['public_url']}{quote(full_filename)}" + success = True + break + except Exception as e: + system_logger.error(f"同步视频失败 (第{attempt+1}次): {str(e)}") + time.sleep(5) + + if success: + try: + record = db.session.get(GenerationRecord, record_id) + if record: + # 更新记录为 MinIO 的 URL + record.image_urls = json.dumps([{"url": final_url, "type": "video"}]) + db.session.commit() + + # 关键修复:同步更新 Redis 中的缓存,这样前端轮询也能拿到最新的 MinIO 地址 + if internal_task_id: + cached_data = redis_client.get(f"task:{internal_task_id}") + if cached_data: + if isinstance(cached_data, bytes): + cached_data = cached_data.decode('utf-8') + task_info = json.loads(cached_data) + task_info['video_url'] = final_url + redis_client.setex(f"task:{internal_task_id}", 3600, json.dumps(task_info)) + + system_logger.info(f"视频同步 MinIO 成功", video_url=final_url) + except Exception as dbe: + system_logger.error(f"更新视频记录失败: {str(dbe)}") + +def process_video_generation(app, user_id, internal_task_id, payload, api_key, cost): + """异步提交并查询视频任务状态""" + with app.app_context(): + try: + headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"} + # 1. 提交任务 + submit_resp = requests.post(Config.VIDEO_GEN_API, json=payload, headers=headers, timeout=60) + if submit_resp.status_code != 200: + raise Exception(f"视频任务提交失败: {submit_resp.text}") + + submit_result = submit_resp.json() + remote_task_id = submit_result.get('task_id') + if not remote_task_id: + raise Exception(f"未获取到远程任务 ID: {submit_result}") + + # 2. 轮询状态 + max_retries = 90 # 提升到 15 分钟 + video_url = None + for i in range(max_retries): + time.sleep(10) + poll_url = Config.VIDEO_POLL_API.format(task_id=remote_task_id) + poll_resp = requests.get(poll_url, headers=headers, timeout=30) + if poll_resp.status_code != 200: + continue + + poll_result = poll_resp.json() + status = poll_result.get('status', '').upper() + + if status == 'SUCCESS': + # 提取视频输出地址 + if 'data' in poll_result and isinstance(poll_result['data'], dict): + video_url = poll_result['data'].get('output') + if not video_url: + if 'data' in poll_result and isinstance(poll_result['data'], list) and poll_result['data']: + video_url = poll_result['data'][0].get('url') + elif 'video' in poll_result: + video_url = poll_result['video'].get('url') if isinstance(poll_result['video'], dict) else poll_result['video'] + elif 'url' in poll_result: + video_url = poll_result['url'] + break + elif status in ['FAILED', 'ERROR']: + raise Exception(f"视频生成失败: {poll_result.get('fail_reason') or poll_result.get('message') or '未知错误'}") + + if not video_url: + raise Exception("超时未获取到视频地址") + + # 3. 持久化记录 + new_record = GenerationRecord( + user_id=user_id, + prompt=payload.get('prompt'), + model=payload.get('model'), + image_urls=json.dumps([{"url": video_url, "type": "video"}]) + ) + db.session.add(new_record) + db.session.commit() + + # 后台线程异步同步到 MinIO + threading.Thread( + target=sync_video_background, + args=(app, new_record.id, video_url, internal_task_id) + ).start() + + # 4. 存入 Redis + redis_client.setex(f"task:{internal_task_id}", 3600, json.dumps({"status": "complete", "video_url": video_url, "record_id": new_record.id})) + system_logger.info(f"视频生成任务完成", user_id=user_id, task_id=internal_task_id) + + except Exception as e: + system_logger.error(f"视频生成执行异常: {str(e)}", user_id=user_id, task_id=internal_task_id) + # 尝试退费 + try: + user = db.session.get(User, user_id) + if user: + user.points += cost + db.session.commit() + except Exception as re: + system_logger.error(f"退费失败: {str(re)}") + + # 确保 Redis 状态一定被更新,防止前端死循环 + redis_client.setex(f"task:{internal_task_id}", 3600, json.dumps({"status": "error", "message": str(e)})) + @api_bp.route('/api/task_status/') @login_required def get_task_status(task_id): """查询异步任务状态""" - data = redis_client.get(f"task:{task_id}") - if not data: - # 如果 Redis 里没有,可能是刚提交,也可能是过期了 - return jsonify({"status": "pending"}) - return jsonify(json.loads(data)) + try: + data = redis_client.get(f"task:{task_id}") + if not data: + return jsonify({"status": "pending"}) + + # 兼容处理 bytes 和 str + if isinstance(data, bytes): + data = data.decode('utf-8') + + return jsonify(json.loads(data)) + except Exception as e: + system_logger.error(f"查询任务状态异常: {str(e)}") + return jsonify({"status": "error", "message": "状态查询失败"}) @api_bp.route('/api/config') def get_config(): @@ -160,7 +312,9 @@ def get_config(): "models": [], "ratios": [], "prompts": [], - "sizes": [] + "sizes": [], + "video_models": [], + "video_prompts": [] } for d in dicts: @@ -174,6 +328,11 @@ def get_config(): config["prompts"].append(item) elif d.dict_type == 'ai_image_size': config["sizes"].append(item) + elif d.dict_type == 'video_model': + item["cost"] = d.cost + config["video_models"].append(item) + elif d.dict_type == 'video_prompt': + config["video_prompts"].append(item) return jsonify(config) except Exception as e: @@ -193,6 +352,8 @@ def upload(): ExtraArgs={"ContentType": f.content_type} ) img_urls.append(f"{Config.MINIO['public_url']}{quote(filename)}") + + system_logger.info(f"用户上传文件: {len(files)} 个", user_id=session.get('user_id')) return jsonify({"urls": img_urls}) except Exception as e: return jsonify({"error": str(e)}), 500 @@ -202,7 +363,7 @@ def upload(): def generate(): try: user_id = session.get('user_id') - user = User.query.get(user_id) + user = db.session.get(User, user_id) data = request.json if request.is_json else request.form mode = data.get('mode', 'trial') @@ -242,6 +403,7 @@ def generate(): if user.points < cost: return jsonify({"error": f"可用积分不足"}), 400 user.points -= cost + user.has_used_points = True # 标记已使用过积分 db.session.commit() prompt = data.get('prompt') @@ -298,6 +460,9 @@ def generate(): task_id = str(uuid.uuid4()) app = current_app._get_current_object() + log_msg = "用户发起验光单解读" if prompt == "解读验光单" else "用户发起生图任务" + system_logger.info(log_msg, model=model_value, mode=mode) + threading.Thread( target=process_image_generation, args=(app, user_id, task_id, payload, api_key, target_api, cost) @@ -310,6 +475,61 @@ def generate(): except Exception as e: return jsonify({"error": str(e)}), 500 +@api_bp.route('/api/video/generate', methods=['POST']) +@login_required +def video_generate(): + try: + user_id = session.get('user_id') + user = db.session.get(User, user_id) + + data = request.json + # 视频生成统一使用积分模式,隐藏 Key 模式 + if user.points <= 0: + return jsonify({"error": "可用积分不足,请先充值"}), 400 + + model_value = data.get('model', 'veo3.1') + + # 确定积分消耗 (优先从字典获取) + model_dict = SystemDict.query.filter_by(dict_type='video_model', value=model_value).first() + cost = model_dict.cost if model_dict else (15 if "pro" in model_value.lower() or "3.1" in model_value else 10) + + if user.points < cost: + return jsonify({"error": f"积分不足,生成该视频需要 {cost} 积分"}), 400 + + # 扣除积分 + user.points -= cost + user.has_used_points = True + db.session.commit() + + # 构建符合 API 文档的 Payload + payload = { + "model": model_value, + "prompt": data.get('prompt'), + "enhance_prompt": data.get('enhance_prompt', False), + "images": data.get('images', []), + "aspect_ratio": data.get('aspect_ratio', '9:16') + } + + # 使用系统内置的 Key + api_key = Config.TRIAL_KEY # 默认使用试用/中转 Key + + task_id = str(uuid.uuid4()) + app = current_app._get_current_object() + + system_logger.info("用户发起视频生成任务 (积分模式)", model=model_value, cost=cost) + + threading.Thread( + target=process_video_generation, + args=(app, user_id, task_id, payload, api_key, cost) + ).start() + + return jsonify({ + "task_id": task_id, + "message": "视频生成任务已提交,系统正在导演中..." + }) + except Exception as e: + return jsonify({"error": str(e)}), 500 + @api_bp.route('/api/notifications/latest', methods=['GET']) @login_required def get_latest_notification(): @@ -341,8 +561,8 @@ def mark_notif_read(): if not notif_id: return jsonify({"error": "缺少通知 ID"}), 400 - notif = SystemNotification.query.get(notif_id) - user = User.query.get(user_id) + notif = db.session.get(SystemNotification, notif_id) + user = db.session.get(User, user_id) if notif and user: if user not in notif.read_by_users: @@ -382,14 +602,17 @@ def get_history(): # 旧数据:直接返回原图作为缩略图 formatted_urls.append({"url": u, "thumb": u}) else: - # 新数据:包含 url 和 thumb + # 如果是视频类型,提供默认预览图 (此处使用一个公共视频占位图或空) + if u.get('type') == 'video' and not u.get('thumb'): + u['thumb'] = "https://img.icons8.com/flat-round/64/000000/play--v1.png" formatted_urls.append(u) history_list.append({ "id": r.id, + "prompt": r.prompt, "model": r.model, "urls": formatted_urls, - "time": (r.created_at + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M') + "created_at": (r.created_at + timedelta(hours=8)).strftime('%b %d, %H:%M') }) return jsonify({ @@ -399,3 +622,36 @@ def get_history(): }) except Exception as e: return jsonify({"error": str(e)}), 500 + +@api_bp.route('/api/download_proxy', methods=['GET']) +@login_required +def download_proxy(): + """代理下载远程文件,强制浏览器弹出下载""" + url = request.args.get('url') + filename = request.args.get('filename', f"video-{int(time.time())}.mp4") + + if not url: + return jsonify({"error": "缺少 URL 参数"}), 400 + + try: + # 流式获取远程文件 + req = requests.get(url, stream=True, timeout=60) + req.raise_for_status() + + headers = {} + if req.headers.get('Content-Type'): + headers['Content-Type'] = req.headers['Content-Type'] + else: + headers['Content-Type'] = 'application/octet-stream' + + headers['Content-Disposition'] = f'attachment; filename="{filename}"' + + def generate(): + for chunk in req.iter_content(chunk_size=4096): + yield chunk + + return current_app.response_class(generate(), headers=headers) + + except Exception as e: + system_logger.error(f"代理下载失败: {str(e)}") + return jsonify({"error": "下载失败"}), 500 diff --git a/blueprints/auth.py b/blueprints/auth.py index 99c45cb..74d68c6 100644 --- a/blueprints/auth.py +++ b/blueprints/auth.py @@ -1,7 +1,9 @@ from flask import Blueprint, request, jsonify, session, render_template, redirect, url_for +import json from extensions import db from models import User from services.sms_service import SMSService +from services.captcha_service import CaptchaService from services.logger import system_logger from middlewares.auth import admin_required @@ -52,7 +54,7 @@ def buy_page(): from models import Order, User user_id = session['user_id'] - user = User.query.get(user_id) + user = db.session.get(User, user_id) # 获取用户个人充值记录 personal_orders = Order.query.filter_by(user_id=user_id).order_by(Order.created_at.desc()).limit(10).all() @@ -79,18 +81,75 @@ def buy_page(): success=success, order=order) +@auth_bp.route('/api/auth/captcha') +def get_captcha(): + """获取图形验证码并存入 Redis""" + phone = request.args.get('phone') + if not phone: + return jsonify({"error": "缺少参数"}), 400 + + text, img_bytes = CaptchaService.generate_captcha() + + from extensions import redis_client + # 存入 Redis,有效期 5 分钟 + redis_client.setex(f"captcha:{phone}", 300, text.lower()) + + from flask import Response + return Response(img_bytes, mimetype='image/png') + @auth_bp.route('/api/auth/send_code', methods=['POST']) def send_code(): data = request.json phone = data.get('phone') + captcha = data.get('captcha') + ip = request.remote_addr + if not phone: return jsonify({"error": "请输入手机号"}), 400 + if not captcha: + return jsonify({"error": "请输入图形验证码", "show_captcha": True}), 403 + + from extensions import redis_client - system_logger.info(f"用户请求发送验证码", phone=phone) + # 1. 验证图形验证码 + saved_captcha = redis_client.get(f"captcha:{phone}") + if not saved_captcha or captcha.lower() != saved_captcha.decode('utf-8'): + return jsonify({"error": "图形验证码错误或已过期", "refresh_captcha": True}), 403 + + # 验证后立即删除,防止被脚本重复利用来刷短信 + redis_client.delete(f"captcha:{phone}") + + # 2. 频率限制:单手机号 60秒 一次 (后端兜底) + if redis_client.get(f"sms_lock:{phone}"): + return jsonify({"error": "发送过于频繁,请稍后再试"}), 429 + + # 3. 每日限制:单手机号每天最多 10 条 + day_count_key = f"sms_day_count:{phone}" + day_count = int(redis_client.get(day_count_key) or 0) + if day_count >= 10: + return jsonify({"error": "该手机号今日获取验证码次数已达上限"}), 429 + + # 4. 每日限制:单 IP 每天最多 20 条 (防止换号刷) + ip_count_key = f"sms_ip_count:{ip}" + ip_count = int(redis_client.get(ip_count_key) or 0) + if ip_count >= 20: + return jsonify({"error": "您的设备今日发送请求过多,请明天再试"}), 429 + + system_logger.info(f"用户请求发送验证码", phone=phone, ip=ip) success, msg = SMSService.send_code(phone) if success: + # 设置各种限制标记 + from datetime import datetime + now = datetime.now() + seconds_until_midnight = ((23 - now.hour) * 3600) + ((59 - now.minute) * 60) + (60 - now.second) + + redis_client.setex(f"sms_lock:{phone}", 60, "1") + redis_client.setex(day_count_key, seconds_until_midnight, day_count + 1) + redis_client.setex(ip_count_key, seconds_until_midnight, ip_count + 1) + system_logger.info(f"验证码发送成功", phone=phone) return jsonify({"message": "验证码已发送"}) + system_logger.warning(f"验证码发送失败: {msg}", phone=phone) return jsonify({"error": f"发送失败: {msg}"}), 500 @@ -128,19 +187,78 @@ def login(): data = request.json phone = data.get('phone') password = data.get('password') + code = data.get('code') # 可能是高频报错后强制要求的验证码 + + if not phone or not password: + return jsonify({"error": "请输入手机号和密码"}), 400 + + from extensions import redis_client + fail_key = f"login_fail_count:{phone}" + fail_count = int(redis_client.get(fail_key) or 0) + + # 如果失败次数过多,强制要求图型验证码 + if fail_count >= 3: + if not code: + system_logger.warning(f"触发强制安全验证", phone=phone) + return jsonify({ + "error": "由于密码错误次数过多,请输入图形验证码", + "require_captcha": True + }), 403 + + # 验证图形验证码 + saved_captcha = redis_client.get(f"captcha:{phone}") + if not saved_captcha or code.lower() != saved_captcha.decode('utf-8'): + return jsonify({"error": "验证码错误或已过期"}), 400 + + # 验证成功后删除,防止重复使用 + redis_client.delete(f"captcha:{phone}") system_logger.info(f"用户登录尝试", phone=phone) user = User.query.filter_by(phone=phone).first() if user and user.check_password(password): - session.permanent = True # 开启持久化会话 (受 Config.PERMANENT_SESSION_LIFETIME 控制) + # 登录成功,清除失败计数 + redis_client.delete(fail_key) + + session.permanent = True session['user_id'] = user.id system_logger.info(f"用户登录成功", phone=phone, user_id=user.id) return jsonify({"message": "登录成功", "phone": phone}) - system_logger.warning(f"登录失败: 手机号或密码错误", phone=phone) + # 登录失败,增加计数 (有效期 1 小时) + redis_client.setex(fail_key, 3600, fail_count + 1) + + system_logger.warning(f"登录失败: 手机号或密码错误 [次数: {fail_count+1}]", phone=phone) return jsonify({"error": "手机号或密码错误"}), 401 +@auth_bp.route('/api/auth/reset_password', methods=['POST']) +def reset_password(): + """通过短信重置密码""" + data = request.json + phone = data.get('phone') + code = data.get('code') + new_password = data.get('password') + + if not phone or not code or not new_password: + return jsonify({"error": "请填写完整信息"}), 400 + + if not SMSService.verify_code(phone, code): + return jsonify({"error": "验证码错误或已过期"}), 400 + + user = User.query.filter_by(phone=phone).first() + if not user: + return jsonify({"error": "该手机号尚未注册"}), 404 + + user.set_password(new_password) + db.session.commit() + + # 重置成功后清理失败计数 + from extensions import redis_client + redis_client.delete(f"login_fail_count:{phone}") + + system_logger.info(f"用户通过短信重置密码成功", phone=phone, user_id=user.id) + return jsonify({"message": "密码重置成功,请使用新密码登录"}) + @auth_bp.route('/api/auth/logout', methods=['POST']) def logout(): session.pop('user_id', None) @@ -151,12 +269,22 @@ def me(): user_id = session.get('user_id') if not user_id: return jsonify({"logged_in": False}) - user = User.query.get(user_id) + user = db.session.get(User, user_id) + if not user: + session.pop('user_id', None) + return jsonify({"logged_in": False}) + + # 脱敏手机号: 13812345678 -> 138****5678 + phone = user.phone + masked_phone = phone[:3] + "****" + phone[-4:] if len(phone) >= 11 else phone + return jsonify({ "logged_in": True, - "phone": user.phone, - "api_key": user.api_key, # 返回已保存的 API Key - "points": user.points # 返回剩余试用积分 + "phone": masked_phone, # 默认返回脱敏的供前端显示 + "full_phone": phone, # 某些场景可能需要完整号 + "api_key": user.api_key, + "points": user.points, + "hide_custom_key": (not user.api_key) or user.has_used_points # Key为空或使用过积分,则隐藏自定义Key入口 }) @auth_bp.route('/api/auth/change_password', methods=['POST']) @@ -172,7 +300,7 @@ def change_password(): if not old_password or not new_password: return jsonify({"error": "请填写完整信息"}), 400 - user = User.query.get(user_id) + user = db.session.get(User, user_id) if not user.check_password(old_password): return jsonify({"error": "原密码错误"}), 400 @@ -193,20 +321,14 @@ def add_points(): # return jsonify({"error": "请先登录"}), 401 # ... (原有逻辑) -@auth_bp.route('/api/auth/menu', methods=['GET']) -def get_menu(): - """获取动态导航菜单""" - user_id = session.get('user_id') - if not user_id: - return jsonify({"menu": []}) - - user = User.query.get(user_id) +def get_user_menu(user): + """根据用户权限生成菜单列表""" if not user: - return jsonify({"menu": []}) - - # 菜单定义库:名称, 图标, 链接, 所需权限 + return [] + all_menus = [ {"name": "创作工作台", "icon": "wand-2", "url": "/", "perm": None}, + {"name": "AI 视频创作", "icon": "video", "url": "/video", "perm": None}, {"name": "验光单助手", "icon": "scan-eye", "url": "/ocr", "perm": None}, {"name": "购买积分", "icon": "shopping-cart", "url": "/buy", "perm": None}, {"name": "权限管理中心", "icon": "shield-check", "url": "/rbac", "perm": "manage_rbac"}, @@ -215,46 +337,75 @@ def get_menu(): {"name": "系统日志审计", "icon": "terminal", "url": "/logs", "perm": "view_logs"}, ] - # 根据权限过滤 accessible_menu = [] for item in all_menus: if item["perm"] is None or user.has_permission(item["perm"]): accessible_menu.append(item) - - return jsonify({"menu": accessible_menu}) + return accessible_menu -@auth_bp.route('/api/auth/logs', methods=['GET']) -def get_logs(): - """获取系统日志(支持搜索和筛选)""" +@auth_bp.route('/api/auth/menu', methods=['GET']) +def get_menu(): + """获取动态导航菜单""" user_id = session.get('user_id') if not user_id: - return jsonify({"error": "请先登录"}), 401 - - from extensions import redis_client - import json - - # 筛选参数 - level_filter = request.args.get('level') - search_query = request.args.get('search', '').lower() - - # 从 Redis 获取日志 (从 ZSET 读取,按分数降序排列,即最新在前) - logs = redis_client.zrevrange('system_logs_zset', 0, -1) - log_list = [] - - for log in logs: - item = json.loads(log.decode('utf-8')) + return jsonify({"menu": []}) - # 级别过滤 - if level_filter and item['level'] != level_filter: - continue - - # 关键词搜索 (搜索内容、手机号或其它 Extra 字段) - if search_query: - message_match = search_query in item['message'].lower() - extra_match = any(search_query in str(v).lower() for v in item.get('extra', {}).values()) - if not (message_match or extra_match): - continue - - log_list.append(item) + user = db.session.get(User, user_id) + return jsonify({"menu": get_user_menu(user)}) + +@auth_bp.route('/api/auth/logs', methods=['GET']) +@admin_required +def get_logs(): + """获取系统日志(支持搜索、筛选与分页)""" + from models import SystemLog, User - return jsonify({"logs": log_list}) + # 分页与筛选参数 + page = request.args.get('page', 1, type=int) + per_page = request.args.get('per_page', 20, type=int) + level_filter = request.args.get('level') + search_query = request.args.get('search', '').strip() + + query = db.session.query(SystemLog).outerjoin(User) + + # 级别过滤 + if level_filter: + query = query.filter(SystemLog.level == level_filter) + + # 关键词搜索 (支持消息、手机号、IP) + if search_query: + search_filter = db.or_( + SystemLog.message.ilike(f"%{search_query}%"), + SystemLog.ip.ilike(f"%{search_query}%"), + User.phone.ilike(f"%{search_query}%"), + SystemLog.module.ilike(f"%{search_query}%") + ) + query = query.filter(search_filter) + + # 执行分页查询 + pagination = query.order_by(SystemLog.created_at.desc()).paginate( + page=page, per_page=per_page, error_out=False + ) + + logs_data = [] + for log in pagination.items: + logs_data.append({ + "id": log.id, + "time": log.created_at.strftime('%Y-%m-%d %H:%M:%S'), + "level": log.level, + "message": log.message, + "module": log.module, + "user_id": log.user_id, + "user_phone": log.user.phone if log.user else "系统/游客", + "ip": log.ip, + "path": log.path, + "method": log.method, + "extra": json.loads(log.extra) if log.extra else {} + }) + + return jsonify({ + "logs": logs_data, + "total": pagination.total, + "page": page, + "per_page": per_page, + "total_pages": pagination.pages + }) diff --git a/blueprints/payment.py b/blueprints/payment.py index d7c294d..606aff2 100644 --- a/blueprints/payment.py +++ b/blueprints/payment.py @@ -2,11 +2,9 @@ from flask import Blueprint, request, redirect, url_for, session, jsonify, rende from extensions import db from models import Order, User from services.alipay_service import AlipayService +from services.logger import system_logger import uuid from datetime import datetime -import logging - -logger = logging.getLogger(__name__) payment_bp = Blueprint('payment', __name__, url_prefix='/payment') @@ -44,8 +42,10 @@ def create_payment(): ) db.session.add(order) db.session.commit() + system_logger.info(f"用户创建充值订单", order_id=out_trade_no, amount=package['amount'], points=package['points']) except Exception as e: db.session.rollback() + system_logger.error(f"订单创建失败: {str(e)}") return f"订单创建失败: {str(e)}", 500 # 获取支付链接 @@ -58,38 +58,31 @@ def create_payment(): ) return redirect(pay_url) except Exception as e: + system_logger.error(f"支付链接生成失败: {str(e)}") return f"支付链接生成失败: {str(e)}", 500 @payment_bp.route('/return') def payment_return(): """支付成功后的同步跳转页面""" try: - logger.info(f"收到支付宝同步回调,参数: {dict(request.args)}") - data = request.args.to_dict() signature = data.get("sign") if not signature: - logger.error("同步回调缺少签名参数") return "参数错误:缺少签名", 400 alipay_service = AlipayService() - # 直接传递原始字典,由 verify_notify 处理 success = alipay_service.verify_notify(data, signature) - out_trade_no = data.get('out_trade_no') - order = Order.query.filter_by(out_trade_no=out_trade_no).first() if success: - logger.info(f"同步回调验证成功,订单号: {out_trade_no}") - # 重定向到充值页面,并带上成功参数 return redirect(url_for('auth.buy_page', success='true', out_trade_no=out_trade_no)) else: - logger.error(f"同步回调验证失败,订单号: {out_trade_no}") + system_logger.warning(f"支付同步回调验证失败", order_id=out_trade_no) return "支付验证失败", 400 except Exception as e: - logger.error(f"处理同步回调时发生异常: {str(e)}", exc_info=True) + system_logger.error(f"处理同步回调异常: {str(e)}") return f"处理支付回调失败: {str(e)}", 500 @payment_bp.route('/history', methods=['GET']) @@ -130,13 +123,10 @@ def api_payment_history(): def payment_notify(): """支付宝异步通知""" try: - logger.info(f"收到支付宝异步通知,参数: {request.form.to_dict()}") - data = request.form.to_dict() - signature = data.get("sign") # 不要pop,保留原始数据 + signature = data.get("sign") if not signature: - logger.error("异步通知缺少签名参数") return "fail" alipay_service = AlipayService() @@ -146,34 +136,27 @@ def payment_notify(): out_trade_no = data.get('out_trade_no') trade_no = data.get('trade_no') - logger.info(f"异步通知验证成功,订单号: {out_trade_no}, 支付宝交易号: {trade_no}") - order = Order.query.filter_by(out_trade_no=out_trade_no).first() if order and order.status == 'PENDING': order.status = 'PAID' order.trade_no = trade_no order.paid_at = datetime.utcnow() - # 给用户加积分 - user = User.query.get(order.user_id) + user = db.session.get(User, order.user_id) if user: user.points += order.points - logger.info(f"用户 {user.id} 充值 {order.points} 积分") + system_logger.info(f"订单支付成功", order_id=out_trade_no, points=order.points, user_id=user.id) db.session.commit() - logger.info(f"订单 {out_trade_no} 处理成功") return "success" elif order: - logger.warning(f"订单 {out_trade_no} 状态为 {order.status},跳过处理") - return "success" # 已处理过的订单也返回success + return "success" else: - logger.error(f"未找到订单: {out_trade_no}") return "fail" else: - logger.error(f"异步通知验证失败或交易状态异常: {data.get('trade_status')}") return "fail" except Exception as e: - logger.error(f"处理异步通知时发生异常: {str(e)}", exc_info=True) + system_logger.error(f"处理异步通知异常: {str(e)}") db.session.rollback() return "fail" diff --git a/config.py b/config.py index 2edef04..46f1c42 100644 --- a/config.py +++ b/config.py @@ -28,6 +28,8 @@ class Config: # AI API 配置 AI_API = "https://ai.t8star.cn/v1/images/generations" CHAT_API = "https://ai.comfly.chat/v1/chat/completions" + VIDEO_GEN_API = "https://ai.comfly.chat/v2/videos/generations" + VIDEO_POLL_API = "https://ai.comfly.chat/v2/videos/generations/{task_id}" # 试用模式配置 TRIAL_API = "https://ai.comfly.chat/v1/images/generations" diff --git a/create_database.py b/create_database.py index 471ac54..1b3aab5 100644 --- a/create_database.py +++ b/create_database.py @@ -5,10 +5,10 @@ 用于在 PostgreSQL 服务器上创建 ai_vision 数据库 """ -import psycopg2 -from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT +from sqlalchemy import create_engine, text +from sqlalchemy.engine import url -# 数据库连接信息 +# 数据库连接信息 (从 config 或直接指定) DB_HOST = "331002.xyz" DB_PORT = 2022 DB_USER = "user_xREpkJ" @@ -20,30 +20,23 @@ def create_database(): try: # 连接到默认的 postgres 数据库 print(f"🔗 正在连接到 PostgreSQL 服务器 {DB_HOST}:{DB_PORT}...") - conn = psycopg2.connect( - host=DB_HOST, - port=DB_PORT, - user=DB_USER, - password=DB_PASSWORD, - database="postgres" # 先连接到默认数据库 - ) - conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) - cursor = conn.cursor() - # 检查数据库是否存在 - cursor.execute(f"SELECT 1 FROM pg_database WHERE datname = '{DB_NAME}'") - exists = cursor.fetchone() + # 构造连接 URL (连接到 postgres 数据库以执行 CREATE DATABASE) + postgres_url = f"postgresql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/postgres" + engine = create_engine(postgres_url, isolation_level="AUTOCOMMIT") - if exists: - print(f"✅ 数据库 {DB_NAME} 已经存在") - else: - # 创建数据库 - print(f"🔧 正在创建数据库 {DB_NAME}...") - cursor.execute(f'CREATE DATABASE {DB_NAME}') - print(f"✅ 数据库 {DB_NAME} 创建成功!") - - cursor.close() - conn.close() + with engine.connect() as conn: + # 检查数据库是否存在 + result = conn.execute(text(f"SELECT 1 FROM pg_database WHERE datname = '{DB_NAME}'")) + exists = result.fetchone() + + if exists: + print(f"✅ 数据库 {DB_NAME} 已经存在") + else: + # 创建数据库 + print(f"🔧 正在创建数据库 {DB_NAME}...") + conn.execute(text(f'CREATE DATABASE {DB_NAME}')) + print(f"✅ 数据库 {DB_NAME} 创建成功!") print(f"\n📊 数据库信息:") print(f" 主机: {DB_HOST}:{DB_PORT}") @@ -51,12 +44,6 @@ def create_database(): print(f" 用户: {DB_USER}") print(f"\n💡 下一步:运行 python init_db.py 创建数据表") - except psycopg2.Error as e: - print(f"❌ 数据库操作失败: {e}") - print(f"\n可能的原因:") - print(f" 1. 用户 {DB_USER} 没有创建数据库的权限") - print(f" 2. 网络连接问题") - print(f" 3. 数据库服务器配置限制") except Exception as e: print(f"❌ 发生错误: {e}") diff --git a/extensions.py b/extensions.py index 03197e1..b2bd783 100644 --- a/extensions.py +++ b/extensions.py @@ -1,10 +1,12 @@ from flask_sqlalchemy import SQLAlchemy from flask_redis import FlaskRedis +from flask_migrate import Migrate import boto3 from config import Config db = SQLAlchemy() redis_client = FlaskRedis() +migrate = Migrate() # MinIO Client s3_client = boto3.client( diff --git a/fix_db_manual.py b/fix_db_manual.py index 94ef28a..458ebd8 100644 --- a/fix_db_manual.py +++ b/fix_db_manual.py @@ -1,23 +1,20 @@ -import psycopg2 +from sqlalchemy import create_engine, text from config import Config def migrate(): # 从 URI 解析连接参数 - # postgresql://user:pass@host:port/dbname uri = Config.SQLALCHEMY_DATABASE_URI - print(f"正在手动连接数据库进行迁移...") + print(f"正在手动连接数据库进行迁移 (SQLAlchemy)... ") + + engine = create_engine(uri) try: - conn = psycopg2.connect(uri) - cur = conn.cursor() - - # 添加 api_key 字段 - cur.execute("ALTER TABLE users ADD COLUMN IF NOT EXISTS api_key VARCHAR(255);") - - conn.commit() - cur.close() - conn.close() - print("✅ 数据库字段 users.api_key 手动添加成功") + with engine.connect() as conn: + # 添加 api_key 字段 + print("🔧 正在检查并添加 users.api_key 字段...") + conn.execute(text("ALTER TABLE users ADD COLUMN IF NOT EXISTS api_key VARCHAR(255);")) + conn.commit() + print("✅ 数据库字段 users.api_key 处理成功") except Exception as e: print(f"❌ 迁移失败: {e}") diff --git a/fix_db_manual_points.py b/fix_db_manual_points.py index d034758..34bd0b6 100644 --- a/fix_db_manual_points.py +++ b/fix_db_manual_points.py @@ -1,31 +1,28 @@ -import psycopg2 +from sqlalchemy import create_engine, text from config import Config def fix_db(): # 从 SQLALCHEMY_DATABASE_URI 提取连接信息 - # 格式: postgresql://user:pass@host:port/db uri = Config.SQLALCHEMY_DATABASE_URI - print(f"🔗 正在尝试连接数据库...") + print(f"🔗 正在尝试连接数据库 (SQLAlchemy)... ") + + engine = create_engine(uri) try: - conn = psycopg2.connect(uri) - cur = conn.cursor() - - # 检查并添加 points 字段 - cur.execute(""" - DO $$ - BEGIN - IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='points') THEN - ALTER TABLE users ADD COLUMN points INTEGER DEFAULT 2; - END IF; - END $$; - """) - - conn.commit() - cur.close() - conn.close() - print("✅ 数据库字段 points 处理完成 (默认值 2)") - + with engine.connect() as conn: + # 检查并添加 points 字段 + print("🔧 正在检查并添加 users.points 字段...") + conn.execute(text(""" + DO $$ + BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='users' AND column_name='points') THEN + ALTER TABLE users ADD COLUMN points INTEGER DEFAULT 2; + END IF; + END $$; + """)) + conn.commit() + print("✅ 数据库字段 points 处理完成 (默认值 2)") + except Exception as e: print(f"❌ 数据库修复失败: {e}") diff --git a/logs/system.log b/logs/system.log deleted file mode 100644 index 8de7ffb..0000000 --- a/logs/system.log +++ /dev/null @@ -1,168 +0,0 @@ -[2026-01-11 17:43:34] INFO - 用户请求发送验证码 -[2026-01-11 17:43:36] WARNING - 验证码发送失败: Error: InvalidAccessKeyId.NotFound code: 404, Specified access key is not found. request id: 912B43E3-5393-53A4-92B7-669BA1DF61A3 Response: {'RequestId': '912B43E3-5393-53A4-92B7-669BA1DF61A3', 'Message': 'Specified access key is not found.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=InvalidAccessKeyId.NotFound&product=Dysmsapi&requestId=912B43E3-5393-53A4-92B7-669BA1DF61A3', 'HostId': 'dysmsapi.aliyuncs.com', 'Code': 'InvalidAccessKeyId.NotFound', 'statusCode': 404} -[2026-01-11 17:50:36] INFO - 用户请求发送验证码 -[2026-01-11 17:50:37] WARNING - 验证码发送失败: Error: InvalidAccessKeyId.NotFound code: 404, Specified access key is not found. request id: 762395A5-97D5-569F-9A50-8D2AD36009C1 Response: {'RequestId': '762395A5-97D5-569F-9A50-8D2AD36009C1', 'Message': 'Specified access key is not found.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=InvalidAccessKeyId.NotFound&product=Dypnsapi&requestId=762395A5-97D5-569F-9A50-8D2AD36009C1', 'HostId': 'dypnsapi.aliyuncs.com', 'Code': 'InvalidAccessKeyId.NotFound', 'statusCode': 404} -[2026-01-11 17:52:03] INFO - 用户注册请求 -[2026-01-11 17:52:03] WARNING - 注册失败: 验证码错误 -[2026-01-11 17:58:53] INFO - 用户请求发送验证码 -[2026-01-11 17:58:53] INFO - 验证码发送成功 -[2026-01-11 17:59:51] INFO - 用户请求发送验证码 -[2026-01-11 17:59:51] WARNING - 验证码发送失败: 请2秒后再试 -[2026-01-11 17:59:56] INFO - 用户请求发送验证码 -[2026-01-11 17:59:57] WARNING - 验证码发送失败: Error: MissingTemplateParam code: 400, TemplateParam is mandatory for this action. request id: E6CEAF97-4525-55C1-9C4F-DF7A4D008AF4 Response: {'RequestId': 'E6CEAF97-4525-55C1-9C4F-DF7A4D008AF4', 'Message': 'TemplateParam is mandatory for this action.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=MissingTemplateParam&product=Dypnsapi&requestId=E6CEAF97-4525-55C1-9C4F-DF7A4D008AF4', 'HostId': 'dypnsapi.aliyuncs.com', 'Code': 'MissingTemplateParam', 'statusCode': 400} -[2026-01-11 18:02:00] INFO - 用户请求发送验证码 -[2026-01-11 18:02:01] WARNING - 验证码发送失败: Error: MissingTemplateParam code: 400, TemplateParam is mandatory for this action. request id: BDEBE5C4-D5C5-5FAD-AFFB-AE94F42FF7F8 Response: {'RequestId': 'BDEBE5C4-D5C5-5FAD-AFFB-AE94F42FF7F8', 'Message': 'TemplateParam is mandatory for this action.', 'Recommend': 'https://api.aliyun.com/troubleshoot?q=MissingTemplateParam&product=Dypnsapi&requestId=BDEBE5C4-D5C5-5FAD-AFFB-AE94F42FF7F8', 'HostId': 'dypnsapi.aliyuncs.com', 'Code': 'MissingTemplateParam', 'statusCode': 400} -[2026-01-11 18:04:11] INFO - 用户请求发送验证码 -[2026-01-11 18:04:12] WARNING - 验证码发送失败: 请检查模板内容与模板参数是否匹配 -[2026-01-11 18:05:41] INFO - 用户请求发送验证码 -[2026-01-11 18:05:43] WARNING - 验证码发送失败: 非法参数 -[2026-01-11 18:06:41] INFO - 用户请求发送验证码 -[2026-01-11 18:06:42] WARNING - 验证码发送失败: check frequency failed -[2026-01-11 18:07:53] INFO - 用户请求发送验证码 -[2026-01-11 18:07:53] INFO - 验证码发送成功 -[2026-01-11 18:12:40] INFO - 用户请求发送验证码 -[2026-01-11 18:12:42] WARNING - 验证码发送失败: 非法参数 -[2026-01-11 18:13:42] INFO - 用户请求发送验证码 -[2026-01-11 18:13:44] INFO - 验证码发送成功 -[2026-01-11 18:14:18] INFO - 用户注册请求 -[2026-01-11 18:14:19] WARNING - 注册失败: 验证码错误 -[2026-01-11 18:16:05] INFO - 用户注册请求 -[2026-01-11 18:16:07] WARNING - 注册失败: 验证码错误 -[2026-01-11 18:17:34] INFO - 用户注册请求 -[2026-01-11 18:17:35] WARNING - 注册失败: 验证码错误 -[2026-01-11 18:20:57] INFO - 用户请求发送验证码 -[2026-01-11 18:20:59] INFO - 验证码发送成功 -[2026-01-11 18:21:11] INFO - 用户注册请求 -[2026-01-11 18:21:12] INFO - 用户注册成功 -[2026-01-11 18:21:14] INFO - 用户登录尝试 -[2026-01-11 18:21:14] INFO - 用户登录成功 -[2026-01-11 18:33:40] INFO - 用户登录尝试 -[2026-01-11 18:33:40] WARNING - 登录失败: 手机号或密码错误 -[2026-01-11 18:33:47] INFO - 用户登录尝试 -[2026-01-11 18:33:47] INFO - 用户登录成功 -[2026-01-11 18:34:19] INFO - 用户登录尝试 -[2026-01-11 18:34:19] INFO - 用户登录成功 -[2026-01-11 19:05:37] INFO - 用户登录尝试 -[2026-01-11 19:05:37] INFO - 用户登录成功 -[2026-01-11 19:14:10] INFO - 用户登录尝试 -[2026-01-11 19:14:10] INFO - 用户登录成功 -[2026-01-11 21:51:06] INFO - 用户登录尝试 -[2026-01-11 21:51:06] INFO - 用户登录成功 -[2026-01-11 21:59:14] INFO - 试用模式生成 -[2026-01-11 21:59:14] INFO - 用户发起图片生成 -[2026-01-11 22:09:52] INFO - 积分预扣除 (试用模式) -[2026-01-11 22:10:10] INFO - 用户生成图片成功 -[2026-01-11 23:41:00] INFO - 用户登录尝试 -[2026-01-11 23:41:23] INFO - 用户登录尝试 -[2026-01-11 23:41:23] INFO - 用户登录成功 -[2026-01-11 23:43:21] INFO - 用户修改密码成功 -[2026-01-11 23:43:54] INFO - 用户登录尝试 -[2026-01-11 23:43:54] INFO - 用户登录成功 -[2026-01-11 23:44:01] INFO - 用户登录尝试 -[2026-01-11 23:44:01] WARNING - 登录失败: 手机号或密码错误 -[2026-01-11 23:44:07] INFO - 用户登录尝试 -[2026-01-11 23:44:07] WARNING - 登录失败: 手机号或密码错误 -[2026-01-11 23:44:10] INFO - 用户登录尝试 -[2026-01-11 23:44:10] INFO - 用户登录成功 -[2026-01-12 22:00:04] INFO - 用户登录尝试 -[2026-01-12 22:00:04] INFO - 用户登录成功 -[2026-01-12 22:00:55] INFO - 用户充值积分成功 -[2026-01-12 22:32:04] INFO - 用户登录尝试 -[2026-01-12 22:32:04] INFO - 用户登录成功 -[2026-01-12 22:35:37] INFO - 用户登录尝试 -[2026-01-12 22:35:37] INFO - 用户登录成功 -[2026-01-12 22:38:54] INFO - 用户生成图片成功 -[2026-01-12 22:49:54] INFO - 用户登录尝试 -[2026-01-12 22:49:54] INFO - 用户登录成功 -[2026-01-12 22:49:59] INFO - 用户登录尝试 -[2026-01-12 22:49:59] INFO - 用户登录成功 -[2026-01-12 22:53:16] INFO - 用户生成图片成功 -[2026-01-12 23:05:38] INFO - 用户登录尝试 -[2026-01-12 23:05:38] INFO - 用户登录成功 -[2026-01-12 23:09:28] INFO - 用户登录尝试 -[2026-01-12 23:09:28] INFO - 用户登录成功 -[2026-01-12 23:18:01] INFO - 用户生成图片成功 -[2026-01-13 22:27:07] INFO - 用户生成图片成功 -[2026-01-13 22:44:18] INFO - 积分预扣除 (普通试用) -[2026-01-13 22:44:21] WARNING - API 报错,积分已退还 -[2026-01-13 22:46:45] INFO - 积分预扣除 (普通试用) -[2026-01-13 22:47:04] INFO - 用户生成文本成功 -[2026-01-13 22:50:18] INFO - 积分预扣除 (普通试用) -[2026-01-13 22:50:36] INFO - 用户生成文本成功 -[2026-01-13 22:57:55] INFO - 积分预扣除 (普通试用) -[2026-01-13 22:58:07] INFO - 用户生成文本成功 -[2026-01-13 23:02:42] INFO - 积分预扣除 (普通试用) -[2026-01-13 23:02:55] INFO - 用户生成文本成功 -[2026-01-13 23:08:26] INFO - 积分预扣除 (普通试用) -[2026-01-13 23:08:43] INFO - 用户生成文本成功 -[2026-01-13 23:09:40] INFO - 用户登录尝试 -[2026-01-13 23:09:40] INFO - 用户登录成功 -[2026-01-13 23:11:23] INFO - 积分预扣除 (普通试用) -[2026-01-13 23:11:41] INFO - 用户生成文本成功 -[2026-01-13 23:13:29] INFO - 用户登录尝试 -[2026-01-13 23:13:30] INFO - 用户登录成功 -[2026-01-13 23:14:33] INFO - 用户生成图片成功 -[2026-01-13 23:17:57] INFO - 用户生成图片成功 -[2026-01-13 23:18:03] INFO - 用户生成图片成功 -[2026-01-13 23:18:15] INFO - 用户生成图片成功 -[2026-01-13 23:20:10] INFO - 用户生成图片成功 -[2026-01-13 23:20:11] INFO - 用户生成图片成功 -[2026-01-13 23:20:11] INFO - 用户生成图片成功 -[2026-01-13 23:20:14] INFO - 用户生成图片成功 -[2026-01-13 23:21:19] INFO - 用户生成图片成功 -[2026-01-13 23:21:21] INFO - 用户生成图片成功 -[2026-01-13 23:21:21] INFO - 用户生成图片成功 -[2026-01-13 23:21:22] INFO - 用户生成图片成功 -[2026-01-13 23:24:14] INFO - 用户生成图片成功 -[2026-01-13 23:24:15] INFO - 用户生成图片成功 -[2026-01-13 23:24:16] INFO - 用户生成图片成功 -[2026-01-13 23:24:17] INFO - 用户生成图片成功 -[2026-01-13 23:30:04] INFO - 用户生成图片成功 -[2026-01-13 23:30:04] INFO - 用户生成图片成功 -[2026-01-13 23:30:06] INFO - 用户生成图片成功 -[2026-01-13 23:48:04] INFO - 用户生成图片成功 -[2026-01-13 23:48:05] INFO - 用户生成图片成功 -[2026-01-13 23:48:07] INFO - 用户生成图片成功 -[2026-01-13 23:52:15] INFO - 用户生成图片成功 -[2026-01-13 23:52:15] INFO - 用户生成图片成功 -[2026-01-13 23:52:22] INFO - 用户生成图片成功 -[2026-01-13 23:59:01] INFO - 用户登录尝试 -[2026-01-13 23:59:02] INFO - 用户登录成功 -[2026-01-14 16:45:23] INFO - 用户登录尝试 -[2026-01-14 16:45:23] INFO - 用户登录成功 -[2026-01-14 20:12:31] INFO - 用户登录尝试 -[2026-01-14 20:12:31] INFO - 用户登录成功 -[2026-01-14 20:17:10] INFO - 用户登录尝试 -[2026-01-14 20:17:10] INFO - 用户登录成功 -[2026-01-15 20:17:15] INFO - 用户登录尝试 -[2026-01-15 20:17:15] WARNING - 登录失败: 手机号或密码错误 -[2026-01-15 20:17:17] INFO - 用户登录尝试 -[2026-01-15 20:17:17] WARNING - 登录失败: 手机号或密码错误 -[2026-01-15 20:17:21] INFO - 用户登录尝试 -[2026-01-15 20:17:22] WARNING - 登录失败: 手机号或密码错误 -[2026-01-15 20:17:32] INFO - 用户登录尝试 -[2026-01-15 20:17:32] INFO - 用户登录成功 -[2026-01-15 20:22:42] INFO - 用户生成图片成功 -[2026-01-15 20:31:31] INFO - 用户生成图片成功 -[2026-01-15 20:45:22] INFO - 用户生成图片成功 -[2026-01-15 20:49:12] INFO - 用户生成图片成功 -[2026-01-15 20:50:53] INFO - 用户生成图片成功 -[2026-01-15 20:52:35] INFO - 用户生成图片成功 -[2026-01-15 20:52:35] INFO - 用户生成图片成功 -[2026-01-15 20:52:37] INFO - 用户生成图片成功 -[2026-01-15 20:52:37] INFO - 用户生成图片成功 -[2026-01-15 20:56:42] INFO - 用户生成图片成功 -[2026-01-15 20:57:31] INFO - 用户生成图片成功 -[2026-01-15 20:59:14] INFO - 用户生成图片成功 -[2026-01-15 21:00:00] INFO - 用户生成图片成功 -[2026-01-15 21:01:05] INFO - 用户生成图片成功 -[2026-01-15 21:01:05] INFO - 用户生成图片成功 -[2026-01-15 21:01:05] INFO - 用户生成图片成功 -[2026-01-15 21:05:31] INFO - 用户生成图片成功 -[2026-01-15 21:05:31] INFO - 用户生成图片成功 -[2026-01-15 21:05:31] INFO - 用户生成图片成功 -[2026-01-15 21:05:31] INFO - 用户生成图片成功 -[2026-01-15 21:08:09] INFO - 积分预扣除 (普通试用) -[2026-01-15 21:09:55] INFO - 用户生成图片成功 (Base64模式) -[2026-01-15 21:13:38] INFO - 积分预扣除 (普通试用) -[2026-01-15 21:13:59] INFO - 用户生成图片成功 (上传:Base64 / 接收:URL) diff --git a/middlewares/__pycache__/auth.cpython-312.pyc b/middlewares/__pycache__/auth.cpython-312.pyc deleted file mode 100644 index 60baaeb..0000000 Binary files a/middlewares/__pycache__/auth.cpython-312.pyc and /dev/null differ diff --git a/middlewares/auth.py b/middlewares/auth.py index 79ac7a6..05a9e24 100644 --- a/middlewares/auth.py +++ b/middlewares/auth.py @@ -1,6 +1,7 @@ from functools import wraps from flask import session, jsonify, redirect, url_for, request from models import User +from services.logger import system_logger def login_required(f): """登录验证装饰器""" @@ -8,6 +9,7 @@ def login_required(f): def decorated_function(*args, **kwargs): user_id = session.get('user_id') if not user_id: + system_logger.warning(f"未授权访问尝试 (未登录): {request.path}", ip=request.remote_addr) if request.path.startswith('/api/'): return jsonify({"error": "请先登录", "code": 401}), 401 # 记录当前路径以便登录后跳转 @@ -22,12 +24,14 @@ def permission_required(perm_name): def decorated_function(*args, **kwargs): user_id = session.get('user_id') if not user_id: + system_logger.warning(f"未授权访问尝试 (未登录): {request.path}", ip=request.remote_addr) if request.path.startswith('/api/'): return jsonify({"error": "请先登录", "code": 401}), 401 return redirect(url_for('auth.login_page', next=request.path)) user = User.query.get(user_id) if not user or not user.has_permission(perm_name): + system_logger.warning(f"未授权访问尝试 (权限不足: {perm_name}): {request.path}", user_id=user_id, ip=request.remote_addr) if request.path.startswith('/api/'): return jsonify({"error": f"需要权限: {perm_name}", "code": 403}), 403 # 如果没有权限,重定向到首页并提示 diff --git a/migrations/README b/migrations/README new file mode 100644 index 0000000..0e04844 --- /dev/null +++ b/migrations/README @@ -0,0 +1 @@ +Single-database configuration for Flask. diff --git a/migrations/alembic.ini b/migrations/alembic.ini new file mode 100644 index 0000000..ec9d45c --- /dev/null +++ b/migrations/alembic.ini @@ -0,0 +1,50 @@ +# A generic, single database configuration. + +[alembic] +# template used to generate migration files +# file_template = %%(rev)s_%%(slug)s + +# set to 'true' to run the environment during +# the 'revision' command, regardless of autogenerate +# revision_environment = false + + +# Logging configuration +[loggers] +keys = root,sqlalchemy,alembic,flask_migrate + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[logger_flask_migrate] +level = INFO +handlers = +qualname = flask_migrate + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/migrations/env.py b/migrations/env.py new file mode 100644 index 0000000..4c97092 --- /dev/null +++ b/migrations/env.py @@ -0,0 +1,113 @@ +import logging +from logging.config import fileConfig + +from flask import current_app + +from alembic import context + +# this is the Alembic Config object, which provides +# access to the values within the .ini file in use. +config = context.config + +# Interpret the config file for Python logging. +# This line sets up loggers basically. +fileConfig(config.config_file_name) +logger = logging.getLogger('alembic.env') + + +def get_engine(): + try: + # this works with Flask-SQLAlchemy<3 and Alchemical + return current_app.extensions['migrate'].db.get_engine() + except (TypeError, AttributeError): + # this works with Flask-SQLAlchemy>=3 + return current_app.extensions['migrate'].db.engine + + +def get_engine_url(): + try: + return get_engine().url.render_as_string(hide_password=False).replace( + '%', '%%') + except AttributeError: + return str(get_engine().url).replace('%', '%%') + + +# add your model's MetaData object here +# for 'autogenerate' support +# from myapp import mymodel +# target_metadata = mymodel.Base.metadata +config.set_main_option('sqlalchemy.url', get_engine_url()) +target_db = current_app.extensions['migrate'].db + +# other values from the config, defined by the needs of env.py, +# can be acquired: +# my_important_option = config.get_main_option("my_important_option") +# ... etc. + + +def get_metadata(): + if hasattr(target_db, 'metadatas'): + return target_db.metadatas[None] + return target_db.metadata + + +def run_migrations_offline(): + """Run migrations in 'offline' mode. + + This configures the context with just a URL + and not an Engine, though an Engine is acceptable + here as well. By skipping the Engine creation + we don't even need a DBAPI to be available. + + Calls to context.execute() here emit the given string to the + script output. + + """ + url = config.get_main_option("sqlalchemy.url") + context.configure( + url=url, target_metadata=get_metadata(), literal_binds=True + ) + + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online(): + """Run migrations in 'online' mode. + + In this scenario we need to create an Engine + and associate a connection with the context. + + """ + + # this callback is used to prevent an auto-migration from being generated + # when there are no changes to the schema + # reference: http://alembic.zzzcomputing.com/en/latest/cookbook.html + def process_revision_directives(context, revision, directives): + if getattr(config.cmd_opts, 'autogenerate', False): + script = directives[0] + if script.upgrade_ops.is_empty(): + directives[:] = [] + logger.info('No changes in schema detected.') + + conf_args = current_app.extensions['migrate'].configure_args + if conf_args.get("process_revision_directives") is None: + conf_args["process_revision_directives"] = process_revision_directives + + connectable = get_engine() + + with connectable.connect() as connection: + context.configure( + connection=connection, + target_metadata=get_metadata(), + **conf_args + ) + + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/migrations/script.py.mako b/migrations/script.py.mako new file mode 100644 index 0000000..2c01563 --- /dev/null +++ b/migrations/script.py.mako @@ -0,0 +1,24 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} + +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +# revision identifiers, used by Alembic. +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade(): + ${upgrades if upgrades else "pass"} + + +def downgrade(): + ${downgrades if downgrades else "pass"} diff --git a/migrations/versions/9024b393e1ef_add_some_columns.py b/migrations/versions/9024b393e1ef_add_some_columns.py new file mode 100644 index 0000000..e9ac2d3 --- /dev/null +++ b/migrations/versions/9024b393e1ef_add_some_columns.py @@ -0,0 +1,59 @@ +"""add some columns + +Revision ID: 9024b393e1ef +Revises: +Create Date: 2026-01-16 20:58:52.178001 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '9024b393e1ef' +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('ai_models') + op.drop_table('aspect_ratios') + op.drop_table('prompt_templates') + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('has_used_points', sa.Boolean(), nullable=True)) + batch_op.drop_column('role') + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('users', schema=None) as batch_op: + batch_op.add_column(sa.Column('role', sa.VARCHAR(length=20), server_default=sa.text("'user'::character varying"), autoincrement=False, nullable=True)) + batch_op.drop_column('has_used_points') + + op.create_table('prompt_templates', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('label', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('content', sa.TEXT(), autoincrement=False, nullable=False), + sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('prompt_templates_pkey')) + ) + op.create_table('aspect_ratios', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('label', sa.VARCHAR(length=20), autoincrement=False, nullable=False), + sa.Column('value', sa.VARCHAR(length=20), autoincrement=False, nullable=False), + sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('aspect_ratios_pkey')) + ) + op.create_table('ai_models', + sa.Column('id', sa.INTEGER(), autoincrement=True, nullable=False), + sa.Column('name', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('value', sa.VARCHAR(length=100), autoincrement=False, nullable=False), + sa.Column('is_active', sa.BOOLEAN(), autoincrement=False, nullable=True), + sa.PrimaryKeyConstraint('id', name=op.f('ai_models_pkey')), + sa.UniqueConstraint('value', name=op.f('ai_models_value_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) + ) + # ### end Alembic commands ### diff --git a/models.py b/models.py index 05993fc..c04f1d8 100644 --- a/models.py +++ b/models.py @@ -30,6 +30,7 @@ class User(db.Model): password_hash = db.Column(db.String(255), nullable=False) api_key = db.Column(db.String(255)) # 存储用户的 API Key points = db.Column(db.Integer, default=2) # 账户积分,默认赠送2次试用 + has_used_points = db.Column(db.Boolean, default=False) # 是否使用过积分 # 关联角色 ID role_id = db.Column(db.Integer, db.ForeignKey('roles.id')) created_at = db.Column(db.DateTime, default=datetime.utcnow) @@ -117,3 +118,24 @@ class Order(db.Model): paid_at = db.Column(db.DateTime) user = db.relationship('User', backref=db.backref('orders', lazy='dynamic', order_by='Order.created_at.desc()')) + +class SystemLog(db.Model): + """系统精细化日志记录""" + __tablename__ = 'system_logs' + + id = db.Column(db.Integer, primary_key=True) + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # 可能没有登录用户 + level = db.Column(db.String(20), nullable=False) # INFO, WARNING, ERROR, DEBUG + module = db.Column(db.String(50)) # 模块名 + message = db.Column(db.Text, nullable=False) # 日志内容 + extra = db.Column(db.Text) # 额外信息的 JSON 字符串 + + # 请求上下文信息 + ip = db.Column(db.String(50)) + path = db.Column(db.String(255)) + method = db.Column(db.String(10)) + user_agent = db.Column(db.String(255)) + + created_at = db.Column(db.DateTime, default=datetime.now) + + user = db.relationship('User', backref=db.backref('logs', lazy='dynamic', order_by='SystemLog.created_at.desc()')) diff --git a/requirements.txt b/requirements.txt index c73c4f0..8b2ee9d 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/services/__pycache__/alipay_service.cpython-312.pyc b/services/__pycache__/alipay_service.cpython-312.pyc deleted file mode 100644 index e89946c..0000000 Binary files a/services/__pycache__/alipay_service.cpython-312.pyc and /dev/null differ diff --git a/services/__pycache__/logger.cpython-312.pyc b/services/__pycache__/logger.cpython-312.pyc deleted file mode 100644 index 7268fff..0000000 Binary files a/services/__pycache__/logger.cpython-312.pyc and /dev/null differ diff --git a/services/__pycache__/sms_service.cpython-312.pyc b/services/__pycache__/sms_service.cpython-312.pyc deleted file mode 100644 index 7d5fa63..0000000 Binary files a/services/__pycache__/sms_service.cpython-312.pyc and /dev/null differ diff --git a/services/captcha_service.py b/services/captcha_service.py new file mode 100644 index 0000000..ba2b338 --- /dev/null +++ b/services/captcha_service.py @@ -0,0 +1,51 @@ +import random +import string +import io +from PIL import Image, ImageDraw, ImageFont, ImageFilter + +class CaptchaService: + @staticmethod + def generate_captcha(): + # 生成随机 4 位字符 + chars = string.ascii_uppercase + string.digits + captcha_text = ''.join(random.choices(chars, k=4)) + + # 图像参数 + width, height = 120, 50 + background = (255, 255, 255) + image = Image.new('RGB', (width, height), background) + draw = ImageDraw.Draw(image) + + # 尝试加载字体,如果失败则使用默认 + try: + # 在 Windows 上寻找常用路径 + font_path = "C:\\Windows\\Fonts\\arial.ttf" + font = ImageFont.truetype(font_path, 32) + except: + font = ImageFont.load_default() + + # 绘制文本 + for i, char in enumerate(captcha_text): + # 随机颜色和轻微旋转/偏移 + color = (random.randint(0, 150), random.randint(0, 150), random.randint(0, 150)) + draw.text((15 + i * 25, 5), char, font=font, fill=color) + + # 增加噪点线 + for _ in range(5): + x1 = random.randint(0, width) + y1 = random.randint(0, height) + x2 = random.randint(0, width) + y2 = random.randint(0, height) + draw.line((x1, y1, x2, y2), fill=(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)), width=1) + + # 增加噪点点 + for _ in range(30): + draw.point((random.randint(0, width), random.randint(0, height)), fill=(0, 0, 0)) + + # 轻微模糊 + image = image.filter(ImageFilter.SMOOTH) + + # 保存到内存 + buf = io.BytesIO() + image.save(buf, format='PNG') + return captcha_text, buf.getvalue() diff --git a/services/logger.py b/services/logger.py index 2879302..c4b5d3e 100644 --- a/services/logger.py +++ b/services/logger.py @@ -2,8 +2,9 @@ import logging import os from datetime import datetime from logging.handlers import RotatingFileHandler -from extensions import redis_client +from extensions import redis_client, db import json +from flask import request, has_request_context, g # 创建日志目录 LOG_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'logs') @@ -42,10 +43,15 @@ class SystemLogger: """推送到 Redis 并保留 30 天数据""" try: now = datetime.now() + user_id = None + if has_request_context(): + user_id = g.get('user_id') or (getattr(g, 'user', None).id if hasattr(g, 'user') and g.user else None) + log_entry = { "time": now.strftime('%Y-%m-%d %H:%M:%S'), "level": level, "message": message, + "user_id": user_id, "extra": extra or {} } # 使用有序集合 (ZSET),分数为时间戳,方便按时间清理 @@ -57,22 +63,59 @@ class SystemLogger: redis_client.zremrangebyscore('system_logs_zset', 0, thirty_days_ago) except: pass - - def info(self, message, **kwargs): + + def _write_to_db(self, level, message, module=None, extra=None): + """写入数据库""" + try: + from models import SystemLog + + log_data = { + 'level': level, + 'message': message, + 'module': module, + 'user_id': extra.get('user_id') if extra else None, + 'extra': json.dumps(extra, ensure_ascii=False) if extra else None, + 'created_at': datetime.now() + } + + # 捕获请求上下文信息 + if has_request_context(): + log_data.update({ + 'ip': request.remote_addr, + 'path': request.path, + 'method': request.method, + 'user_agent': request.headers.get('User-Agent') + }) + # 如果 log_data 还没 user_id,尝试从 context 获取 + if not log_data['user_id']: + log_data['user_id'] = getattr(g, 'user_id', None) or (getattr(g.user, 'id', None) if hasattr(g, 'user') and g.user else None) + + new_log = SystemLog(**log_data) + db.session.add(new_log) + db.session.commit() + except Exception as e: + # 避免日志错误导致程序崩溃 + self.logger.error(f"Failed to write log to DB: {str(e)}") + + def info(self, message, module=None, **kwargs): self.logger.info(message) self._push_to_redis('INFO', message, kwargs) + self._write_to_db('INFO', message, module, kwargs) - def warning(self, message, **kwargs): + def warning(self, message, module=None, **kwargs): self.logger.warning(message) self._push_to_redis('WARNING', message, kwargs) + self._write_to_db('WARNING', message, module, kwargs) - def error(self, message, **kwargs): + def error(self, message, module=None, **kwargs): self.logger.error(message) self._push_to_redis('ERROR', message, kwargs) + self._write_to_db('ERROR', message, module, kwargs) - def debug(self, message, **kwargs): + def debug(self, message, module=None, **kwargs): self.logger.debug(message) self._push_to_redis('DEBUG', message, kwargs) + self._write_to_db('DEBUG', message, module, kwargs) # 全局日志实例 system_logger = SystemLogger() diff --git a/static/js/auth.js b/static/js/auth.js index f8cf98b..c6f32da 100644 --- a/static/js/auth.js +++ b/static/js/auth.js @@ -1,74 +1,202 @@ -let isRegisterMode = false; +/** + * Auth Modes: + * 0 - Login + * 1 - Register + * 2 - Reset Password + */ +let authMode = 0; -document.getElementById('authSwitchBtn').onclick = () => { - isRegisterMode = !isRegisterMode; - document.getElementById('authTitle').innerText = isRegisterMode ? "加入视界 AI" : "欢迎回来"; - document.getElementById('authSub').innerText = isRegisterMode ? "注册并开启创作" : "请登录以开启 AI 创作之旅"; - document.getElementById('authSubmitBtn').querySelector('span').innerText = isRegisterMode ? "立即注册" : "立即登录"; - document.getElementById('authSwitchBtn').innerText = isRegisterMode ? "已有账号?返回登录" : "没有账号?立即注册"; - document.getElementById('smsGroup').classList.toggle('hidden', !isRegisterMode); -}; +const getEl = (id) => document.getElementById(id); -document.getElementById('sendSmsBtn').onclick = async () => { - const phone = document.getElementById('authPhone').value; - const btn = document.getElementById('sendSmsBtn'); - - if(!phone) return showToast('请输入手机号', 'warning'); - - btn.disabled = true; - const originalText = btn.innerText; - - const r = await fetch('/api/auth/send_code', { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({ phone }) - }); - const d = await r.json(); - - if(d.error) { - showToast(d.error, 'error'); - btn.disabled = false; - } else { - showToast(d.message, 'success'); - let countdown = 60; - const timer = setInterval(() => { - btn.innerText = `${countdown}秒后重试`; - countdown--; - if(countdown < 0) { - clearInterval(timer); - btn.innerText = originalText; - btn.disabled = false; - } - }, 1000); +const updateUI = () => { + const title = getEl('authTitle'); + const sub = getEl('authSub'); + const submitBtnSpan = getEl('authSubmitBtn')?.querySelector('span'); + const switchBtn = getEl('authSwitchBtn'); + const forgotBtn = getEl('forgotPwdBtn'); + const smsGroup = getEl('smsGroup'); + const captchaGroup = getEl('captchaGroup'); + + if (!title || !sub || !submitBtnSpan || !switchBtn || !forgotBtn || !smsGroup || !captchaGroup) return; + + if (authMode === 1) { // Register + title.innerText = "加入视界 AI"; + sub.innerText = "注册并开启创作"; + submitBtnSpan.innerText = "立即注册"; + switchBtn.innerText = "已有账号?返回登录"; + forgotBtn.classList.add('hidden'); + smsGroup.classList.remove('hidden'); + captchaGroup.classList.remove('hidden'); // 注册模式默认显示图形验证码防止刷短信 + } else if (authMode === 2) { // Reset Password + title.innerText = "重置密码"; + sub.innerText = "验证短信以设置新密码"; + submitBtnSpan.innerText = "确认重置"; + switchBtn.innerText = "我想起来了,返回登录"; + forgotBtn.classList.add('hidden'); + smsGroup.classList.remove('hidden'); + captchaGroup.classList.remove('hidden'); // 重置模式默认显示 + } else { // Login + title.innerText = "欢迎回来"; + sub.innerText = "请登录以开启 AI 创作之旅"; + submitBtnSpan.innerText = "立即登录"; + switchBtn.innerText = "没有账号?立即注册"; + forgotBtn.classList.remove('hidden'); + smsGroup.classList.add('hidden'); + captchaGroup.classList.add('hidden'); // 登录默认隐藏,除非高频失败 } }; -document.getElementById('authSubmitBtn').onclick = async () => { - const phone = document.getElementById('authPhone').value; - const password = document.getElementById('authPass').value; - const code = document.getElementById('authCode').value; +const refreshCaptcha = () => { + const phone = getEl('authPhone')?.value; + const captchaImg = getEl('captchaImg'); + if (!phone || !captchaImg) return; + captchaImg.src = `/api/auth/captcha?phone=${phone}&t=${Date.now()}`; +}; - const url = isRegisterMode ? '/api/auth/register' : '/api/auth/login'; - const body = isRegisterMode ? { phone, password, code } : { phone, password }; +const handleAuth = async () => { + const phone = getEl('authPhone')?.value; + const password = getEl('authPass')?.value; - const r = await fetch(url, { - method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify(body) - }); - const d = await r.json(); - if(d.error) { - showToast(d.error, 'error'); - } else { - showToast(d.message, 'success'); - if(isRegisterMode) { - isRegisterMode = true; - document.getElementById('authSwitchBtn').click(); - } else { - // 获取来源页面路径 - const urlParams = new URLSearchParams(window.location.search); - const nextUrl = urlParams.get('next') || '/'; - window.location.href = nextUrl; + if (!phone || !password) { + return showToast('请输入手机号和密码', 'warning'); + } + + let url = ''; + let body = { phone, password }; + + if (authMode === 1) { + url = '/api/auth/register'; + body.code = getEl('authCode')?.value; + } else if (authMode === 0) { + url = '/api/auth/login'; + if (!getEl('captchaGroup')?.classList.contains('hidden')) { + body.code = getEl('authCaptcha')?.value; } + } else if (authMode === 2) { + url = '/api/auth/reset_password'; + body.code = getEl('authCode')?.value; + if (!body.code) return showToast('请输入短信验证码', 'warning'); } -}; \ No newline at end of file + + try { + const btn = getEl('authSubmitBtn'); + if (btn) btn.disabled = true; + + const r = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + const d = await r.json(); + + if (r.status === 403 && d.require_captcha) { + showToast(d.error, 'warning'); + getEl('captchaGroup')?.classList.remove('hidden'); + refreshCaptcha(); + if (btn) btn.disabled = false; + return; + } + + if (d.error) { + showToast(d.error, 'error'); + if (getEl('captchaGroup') && !getEl('captchaGroup').classList.contains('hidden')) { + refreshCaptcha(); + } + if (btn) btn.disabled = false; + } else { + showToast(d.message, 'success'); + if (authMode === 1 || authMode === 2) { + authMode = 0; + updateUI(); + if (btn) btn.disabled = false; + } else { + const urlParams = new URLSearchParams(window.location.search); + const nextUrl = urlParams.get('next') || '/'; + window.location.href = nextUrl; + } + } + } catch (e) { + showToast('网络连接失败', 'error'); + const btn = getEl('authSubmitBtn'); + if (btn) btn.disabled = false; + } +}; + +// 初始化 +document.addEventListener('DOMContentLoaded', () => { + updateUI(); + + getEl('authSwitchBtn').onclick = () => { + authMode = (authMode === 0) ? 1 : 0; + updateUI(); + if (authMode === 1 || authMode === 2) refreshCaptcha(); + }; + + getEl('forgotPwdBtn').onclick = () => { + authMode = 2; + updateUI(); + refreshCaptcha(); + }; + + getEl('captchaImg').onclick = refreshCaptcha; + + getEl('authSubmitBtn').onclick = handleAuth; + + // 回车登录支持 + ['authPhone', 'authPass', 'authCode', 'authCaptcha'].forEach(id => { + getEl(id)?.addEventListener('keydown', (e) => { + if (e.key === 'Enter') handleAuth(); + }); + }); + + getEl('sendSmsBtn').onclick = async () => { + const phone = getEl('authPhone')?.value; + const captcha = getEl('authCaptcha')?.value; + const btn = getEl('sendSmsBtn'); + + if (!phone) return showToast('请输入手机号', 'warning'); + if (!captcha) { + getEl('captchaGroup')?.classList.remove('hidden'); + refreshCaptcha(); + return showToast('请先输入图形验证码以发送短信', 'warning'); + } + + btn.disabled = true; + const originalText = btn.innerText; + + try { + const r = await fetch('/api/auth/send_code', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ phone, captcha }) + }); + const d = await r.json(); + + if (d.error) { + showToast(d.error, 'error'); + btn.disabled = false; + if (d.show_captcha || d.refresh_captcha) { + getEl('captchaGroup')?.classList.remove('hidden'); + refreshCaptcha(); + } + } else { + showToast(d.message, 'success'); + // 发送成功后也要刷新图形验证码,防止被再次利用 + refreshCaptcha(); + let countdown = 60; + const timer = setInterval(() => { + btn.innerText = `${countdown}秒后重试`; + countdown--; + if (countdown < 0) { + clearInterval(timer); + btn.innerText = originalText; + btn.disabled = false; + } + }, 1000); + } + } catch (e) { + showToast('短信发送失败', 'error'); + btn.disabled = false; + } + }; +}); diff --git a/static/js/main.js b/static/js/main.js index 0a038fd..be58315 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -7,38 +7,59 @@ async function checkAuth() { const entry = document.getElementById('loginEntryBtn'); const loginHint = document.getElementById('loginHint'); const submitBtn = document.getElementById('submitBtn'); - - if(d.logged_in) { - if(profile) profile.classList.remove('hidden'); - if(entry) entry.classList.add('hidden'); - if(loginHint) loginHint.classList.add('hidden'); + + if (d.logged_in) { + if (profile) profile.classList.remove('hidden'); + if (entry) entry.classList.add('hidden'); + if (loginHint) loginHint.classList.add('hidden'); submitBtn.disabled = false; submitBtn.classList.remove('opacity-50', 'cursor-not-allowed'); const phoneDisp = document.getElementById('userPhoneDisplay'); - if(phoneDisp) phoneDisp.innerText = d.phone; + if (phoneDisp) phoneDisp.innerText = d.phone; // 处理积分显示 const pointsBadge = document.getElementById('pointsBadge'); const pointsDisplay = document.getElementById('pointsDisplay'); - if(pointsBadge && pointsDisplay) { + if (pointsBadge && pointsDisplay) { pointsBadge.classList.remove('hidden'); pointsDisplay.innerText = d.points; } const headerPoints = document.getElementById('headerPoints'); - if(headerPoints) headerPoints.innerText = d.points; + if (headerPoints) headerPoints.innerText = d.points; + + // 处理自定义 Key 显示逻辑 + // 处理自定义 Key 显示逻辑 + const keyBtn = document.getElementById('modeKeyBtn'); + const modeButtonsContainer = keyBtn ? keyBtn.parentElement : null; + + if (keyBtn && modeButtonsContainer) { + if (d.hide_custom_key) { + // 如果后端说要隐藏(用过积分生成),则隐藏整个按钮组 + modeButtonsContainer.classList.add('hidden'); + // 修改标题为“账户状态” + const authTitle = document.querySelector('#authSection h3'); + if (authTitle) authTitle.innerText = '账户状态'; + } else { + // 否则显示按钮组,让用户可以选择 + modeButtonsContainer.classList.remove('hidden'); + + // 如果有 Key,默认帮忙切过去,方便老用户 + if (d.api_key) { + switchMode('key'); + const keyInput = document.getElementById('apiKey'); + if (keyInput && !keyInput.value) keyInput.value = d.api_key; + return; + } + } + } // 如果用户已经有绑定的 Key,且当前没手动输入,则默认切到 Key 模式 - if(d.api_key) { - switchMode('key'); - const keyInput = document.getElementById('apiKey'); - if(keyInput && !keyInput.value) keyInput.value = d.api_key; - } else { - switchMode('trial'); - } + // 强制使用积分模式 + switchMode('trial'); } else { - if(profile) profile.classList.add('hidden'); - if(entry) entry.classList.remove('hidden'); - if(loginHint) loginHint.classList.remove('hidden'); + if (profile) profile.classList.add('hidden'); + if (entry) entry.classList.remove('hidden'); + if (loginHint) loginHint.classList.remove('hidden'); submitBtn.disabled = true; submitBtn.classList.add('opacity-50', 'cursor-not-allowed'); } @@ -65,20 +86,20 @@ function switchMode(mode) { const keyInputGroup = document.getElementById('keyInputGroup'); const premiumToggle = document.getElementById('premiumToggle'); - if(mode === 'trial') { + if (mode === 'trial') { trialBtn.classList.add('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2'); trialBtn.classList.remove('border-slate-200', 'text-slate-400'); keyBtn.classList.remove('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2'); keyBtn.classList.add('border-slate-200', 'text-slate-400'); keyInputGroup.classList.add('hidden'); - if(premiumToggle) premiumToggle.classList.remove('hidden'); + if (premiumToggle) premiumToggle.classList.remove('hidden'); } else { keyBtn.classList.add('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2'); keyBtn.classList.remove('border-slate-200', 'text-slate-400'); trialBtn.classList.remove('border-indigo-500', 'bg-indigo-50', 'text-indigo-600', 'border-2'); trialBtn.classList.add('border-slate-200', 'text-slate-400'); keyInputGroup.classList.remove('hidden'); - if(premiumToggle) premiumToggle.classList.add('hidden'); + if (premiumToggle) premiumToggle.classList.add('hidden'); } updateCostPreview(); // 切换模式时同步计费预览 } @@ -105,7 +126,7 @@ async function downloadImage(url) { async function loadHistory(isLoadMore = false) { if (isHistoryLoading || (!hasMoreHistory && isLoadMore)) return; - + isHistoryLoading = true; if (!isLoadMore) { currentHistoryPage = 1; @@ -118,9 +139,9 @@ async function loadHistory(isLoadMore = false) { try { const r = await fetch(`/api/history?page=${currentHistoryPage}&per_page=10`); const d = await r.json(); - + const list = document.getElementById('historyList'); - + if (d.history && d.history.length > 0) { const html = d.history.map(item => `
@@ -143,7 +164,7 @@ async function loadHistory(isLoadMore = false) { } else { list.innerHTML = html; } - + hasMoreHistory = d.has_next; currentHistoryPage++; } else if (!isLoadMore) { @@ -166,14 +187,14 @@ async function loadHistory(isLoadMore = false) { async function init() { checkAuth(); - + // 模式切换监听 const modeTrialBtn = document.getElementById('modeTrialBtn'); const modeKeyBtn = document.getElementById('modeKeyBtn'); const isPremiumCheckbox = document.getElementById('isPremium'); - if(modeTrialBtn) modeTrialBtn.onclick = () => switchMode('trial'); - if(modeKeyBtn) modeKeyBtn.onclick = () => switchMode('key'); - if(isPremiumCheckbox) isPremiumCheckbox.onchange = () => updateCostPreview(); + if (modeTrialBtn) modeTrialBtn.onclick = () => switchMode('trial'); + if (modeKeyBtn) modeKeyBtn.onclick = () => switchMode('key'); + if (isPremiumCheckbox) isPremiumCheckbox.onchange = () => updateCostPreview(); // 历史记录控制 const historyDrawer = document.getElementById('historyDrawer'); @@ -184,16 +205,16 @@ async function init() { // 3D 构图辅助控制 const openVisualizerBtn = document.getElementById('openVisualizerBtn'); const closeVisualizerBtn = document.getElementById('closeVisualizerBtn'); - if(openVisualizerBtn) openVisualizerBtn.onclick = openVisualizerModal; - if(closeVisualizerBtn) closeVisualizerBtn.onclick = closeVisualizerModal; + if (openVisualizerBtn) openVisualizerBtn.onclick = openVisualizerModal; + if (closeVisualizerBtn) closeVisualizerBtn.onclick = closeVisualizerModal; - if(showHistoryBtn) { + if (showHistoryBtn) { showHistoryBtn.onclick = () => { historyDrawer.classList.remove('translate-x-full'); loadHistory(false); }; } - if(closeHistoryBtn) { + if (closeHistoryBtn) { closeHistoryBtn.onclick = () => { historyDrawer.classList.add('translate-x-full'); }; @@ -211,11 +232,11 @@ async function init() { // 全部下载按钮逻辑 const downloadAllBtn = document.getElementById('downloadAllBtn'); - if(downloadAllBtn) { + if (downloadAllBtn) { downloadAllBtn.onclick = async () => { - if(currentGeneratedUrls.length === 0) return; + if (currentGeneratedUrls.length === 0) return; showToast(`正在准备下载 ${currentGeneratedUrls.length} 张作品...`, 'info'); - for(const url of currentGeneratedUrls) { + for (const url of currentGeneratedUrls) { await downloadImage(url); // 稍微延迟一下,防止浏览器拦截 await new Promise(r => setTimeout(r, 300)); @@ -225,16 +246,16 @@ async function init() { // 重新生成按钮逻辑 const regenBtn = document.getElementById('regenBtn'); - if(regenBtn) { + if (regenBtn) { regenBtn.onclick = () => { const submitBtn = document.getElementById('submitBtn'); - if(submitBtn) submitBtn.click(); + if (submitBtn) submitBtn.click(); }; } - + // 检查是否有来自 URL 的错误提示 const urlParams = new URLSearchParams(window.location.search); - if(urlParams.has('error')) { + if (urlParams.has('error')) { showToast(urlParams.get('error'), 'error'); // 清理 URL 参数以防刷新时重复提示 window.history.replaceState({}, document.title, window.location.pathname); @@ -246,13 +267,13 @@ async function init() { fillSelect('modelSelect', d.models); fillSelect('ratioSelect', d.ratios); fillSelect('sizeSelect', d.sizes); - fillSelect('promptTpl', [{label:'✨ 自定义创作', value:'manual'}, ...d.prompts]); + fillSelect('promptTpl', [{ label: '✨ 自定义创作', value: 'manual' }, ...d.prompts]); updateCostPreview(); // 初始化时显示默认模型的积分 - } catch(e) { console.error(e); } + } catch (e) { console.error(e); } // 初始化拖拽排序 const prev = document.getElementById('imagePreview'); - if(prev) { + if (prev) { new Sortable(prev, { animation: 150, ghostClass: 'opacity-50', @@ -271,9 +292,9 @@ async function init() { function fillSelect(id, list) { const el = document.getElementById(id); - if(!el) return; + if (!el) return; // 如果是模型选择,增加积分显示 - if(id === 'modelSelect') { + if (id === 'modelSelect') { el.innerHTML = list.map(i => ``).join(''); } else { el.innerHTML = list.map(i => ``).join(''); @@ -286,10 +307,10 @@ function updateCostPreview() { const costPreview = document.getElementById('costPreview'); const isPremium = document.getElementById('isPremium')?.checked || false; const selectedOption = modelSelect.options[modelSelect.selectedIndex]; - + if (currentMode === 'trial' && selectedOption) { let cost = parseInt(selectedOption.getAttribute('data-cost') || 0); - if(isPremium) cost *= 2; // 优质模式 2 倍积分 + if (isPremium) cost *= 2; // 优质模式 2 倍积分 costPreview.innerText = `本次生成将消耗 ${cost} 积分`; costPreview.classList.remove('hidden'); } else { @@ -300,9 +321,9 @@ function updateCostPreview() { // 渲染参考图预览 function renderImagePreviews() { const prev = document.getElementById('imagePreview'); - if(!prev) return; + if (!prev) return; prev.innerHTML = ''; - + uploadedFiles.forEach((file, index) => { // 同步创建容器,确保编号顺序永远正确 const d = document.createElement('div'); @@ -318,7 +339,7 @@ function renderImagePreviews() { `; prev.appendChild(d); - + // 异步加载图片内容 const reader = new FileReader(); reader.onload = (ev) => { @@ -415,7 +436,7 @@ document.getElementById('modelSelect').onchange = (e) => { document.getElementById('promptTpl').onchange = (e) => { const area = document.getElementById('manualPrompt'); - if(e.target.value !== 'manual') { + if (e.target.value !== 'manual') { area.value = e.target.value; area.classList.add('hidden'); } else { @@ -433,20 +454,20 @@ document.getElementById('submitBtn').onclick = async () => { // 检查登录状态并获取积分 const authCheck = await fetch('/api/auth/me'); const authData = await authCheck.json(); - if(!authData.logged_in) { + if (!authData.logged_in) { showToast('请先登录后再生成作品', 'warning'); return; } // 根据模式验证 - if(currentMode === 'key') { - if(!apiKey) return showToast('请输入您的 API 密钥', 'warning'); + if (currentMode === 'key') { + if (!apiKey) return showToast('请输入您的 API 密钥', 'warning'); } else { - if(authData.points <= 0) return showToast('可用积分已耗尽,请充值或切换至自定义 Key 模式', 'warning'); + if (authData.points <= 0) return showToast('可用积分已耗尽,请充值或切换至自定义 Key 模式', 'warning'); } - + // 允许文生图(不强制要求图片),但至少得有提示词或图片 - if(!prompt && uploadedFiles.length === 0) { + if (!prompt && uploadedFiles.length === 0) { return showToast('请至少输入提示词或上传参考图', 'warning'); } @@ -454,7 +475,7 @@ document.getElementById('submitBtn').onclick = async () => { btn.disabled = true; const btnText = btn.querySelector('span'); btnText.innerText = uploadedFiles.length > 0 ? "正在同步参考图..." : "正在开启 AI 引擎..."; - + document.getElementById('statusInfo').classList.remove('hidden'); document.getElementById('placeholder').classList.add('hidden'); document.getElementById('finalWrapper').classList.remove('hidden'); @@ -464,7 +485,7 @@ document.getElementById('submitBtn').onclick = async () => { try { let image_data = []; - + // 1. 将图片转换为 Base64 if (uploadedFiles.length > 0) { btnText.innerText = "正在准备图片数据..."; @@ -504,7 +525,7 @@ document.getElementById('submitBtn').onclick = async () => { }) }); const res = await r.json(); - if(res.error) throw new Error(res.error); + if (res.error) throw new Error(res.error); // 如果直接返回了 data (比如聊天模型),直接显示 if (res.data) { @@ -530,7 +551,7 @@ document.getElementById('submitBtn').onclick = async () => { displayResult(slot, { url: imgUrl }); finishedCount++; btnText.innerText = `AI 构思中 (${finishedCount}/${num})...`; - if(currentMode === 'trial') checkAuth(); + if (currentMode === 'trial') checkAuth(); return; // 任务正常结束 } else if (statusRes.status === 'error') { throw new Error(statusRes.message || "生成失败"); @@ -543,7 +564,7 @@ document.getElementById('submitBtn').onclick = async () => { } catch (e) { slot.className = 'image-frame relative bg-red-50/50 flex items-center justify-center rounded-[2.5rem] border border-red-100 p-6'; - if(e.message.includes('401') || e.message.includes('请先登录')) { + if (e.message.includes('401') || e.message.includes('请先登录')) { slot.innerHTML = `
登录已过期,请重新登录
`; } else { slot.innerHTML = `
生成异常: ${e.message}
`; @@ -591,7 +612,7 @@ init(); // 修改密码弹窗控制 function openPwdModal() { const modal = document.getElementById('pwdModal'); - if(!modal) return; + if (!modal) return; modal.classList.remove('hidden'); setTimeout(() => { modal.classList.add('opacity-100'); @@ -601,7 +622,7 @@ function openPwdModal() { function closePwdModal() { const modal = document.getElementById('pwdModal'); - if(!modal) return; + if (!modal) return; modal.classList.remove('opacity-100'); modal.querySelector('div').classList.add('scale-95'); setTimeout(() => { @@ -611,7 +632,7 @@ function closePwdModal() { } document.addEventListener('click', (e) => { - if(e.target.closest('#openPwdModalBtn')) { + if (e.target.closest('#openPwdModalBtn')) { openPwdModal(); } }); @@ -620,21 +641,21 @@ document.getElementById('pwdForm')?.addEventListener('submit', async (e) => { e.preventDefault(); const old_password = document.getElementById('oldPwd').value; const new_password = document.getElementById('newPwd').value; - + try { const r = await fetch('/api/auth/change_password', { method: 'POST', - headers: {'Content-Type': 'application/json'}, - body: JSON.stringify({old_password, new_password}) + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ old_password, new_password }) }); const d = await r.json(); - if(r.ok) { + if (r.ok) { showToast('密码修改成功,请记牢新密码', 'success'); closePwdModal(); } else { showToast(d.error || '修改失败', 'error'); } - } catch(err) { + } catch (err) { showToast('网络连接失败', 'error'); } }); @@ -642,18 +663,18 @@ document.getElementById('pwdForm')?.addEventListener('submit', async (e) => { // 拍摄角度设置器弹窗控制 function openVisualizerModal() { const modal = document.getElementById('visualizerModal'); - if(!modal) return; - + if (!modal) return; + // 检查是否已上传参考图 if (uploadedFiles.length === 0) { return showToast('请先上传一张参考图作为基准', 'warning'); } - + modal.classList.remove('hidden'); setTimeout(() => { modal.classList.add('opacity-100'); modal.querySelector('div').classList.remove('scale-95'); - + // 将主页面的图片同步到设置器 iframe const reader = new FileReader(); reader.onload = (e) => { @@ -666,7 +687,7 @@ function openVisualizerModal() { function closeVisualizerModal() { const modal = document.getElementById('visualizerModal'); - if(!modal) return; + if (!modal) return; modal.classList.remove('opacity-100'); modal.querySelector('div').classList.add('scale-95'); setTimeout(() => { @@ -678,12 +699,12 @@ function closeVisualizerModal() { window.addEventListener('message', (e) => { if (e.data.type === 'apply_prompt') { isSetterActive = true; - + const area = document.getElementById('manualPrompt'); const promptTpl = document.getElementById('promptTpl'); const modelSelect = document.getElementById('modelSelect'); const sizeGroup = document.getElementById('sizeGroup'); - + // 1. 强制切换并锁定版本 2.0 (nano-banana-2) if (modelSelect) { modelSelect.value = 'nano-banana-2'; @@ -691,26 +712,26 @@ window.addEventListener('message', (e) => { sizeGroup.classList.remove('hidden'); updateCostPreview(); } - + // 2. 隐藏模板选择器 if (promptTpl) { promptTpl.classList.add('hidden'); promptTpl.value = 'manual'; } - + // 3. 强制图片数量为 1 if (uploadedFiles.length > 1) { uploadedFiles = uploadedFiles.slice(0, 1); renderImagePreviews(); } - + // 4. 替换提示词 if (area) { area.value = e.data.prompt; area.classList.remove('hidden'); showToast('已同步拍摄角度并切换至 2.0 引擎', 'success'); } - + closeVisualizerModal(); } }); diff --git a/static/js/video.js b/static/js/video.js new file mode 100644 index 0000000..6b42487 --- /dev/null +++ b/static/js/video.js @@ -0,0 +1,336 @@ +document.addEventListener('DOMContentLoaded', () => { + lucide.createIcons(); + + const submitBtn = document.getElementById('submitBtn'); + const pointsDisplay = document.getElementById('pointsDisplay'); + const headerPoints = document.getElementById('headerPoints'); + const resultVideo = document.getElementById('resultVideo'); + const finalWrapper = document.getElementById('finalWrapper'); + const placeholder = document.getElementById('placeholder'); + const statusInfo = document.getElementById('statusInfo'); + const promptInput = document.getElementById('promptInput'); + const fileInput = document.getElementById('fileInput'); + const imagePreview = document.getElementById('imagePreview'); + const modelSelect = document.getElementById('modelSelect'); + const ratioSelect = document.getElementById('ratioSelect'); + const promptTemplates = document.getElementById('promptTemplates'); + const enhancePrompt = document.getElementById('enhancePrompt'); + + // 历史记录相关 + const historyList = document.getElementById('historyList'); + const historyCount = document.getElementById('historyCount'); + const historyEmpty = document.getElementById('historyEmpty'); + const loadMoreBtn = document.getElementById('loadMoreBtn'); + + let uploadedImageUrl = null; + let historyPage = 1; + let isLoadingHistory = false; + + // 初始化配置 + async function initConfig() { + try { + const r = await fetch('/api/config'); + if (!r.ok) throw new Error('API 响应失败'); + const d = await r.json(); + + if (d.video_models && d.video_models.length > 0) { + modelSelect.innerHTML = d.video_models.map(m => + `` + ).join(''); + } + + if (d.video_prompts && d.video_prompts.length > 0) { + promptTemplates.innerHTML = d.video_prompts.map(p => + `` + ).join(''); + } + } catch (e) { + console.error('加载系统配置失败:', e); + } + } + + // 载入历史记录 + async function loadHistory(page = 1, append = false) { + if (isLoadingHistory) return; + isLoadingHistory = true; + try { + const r = await fetch(`/api/history?page=${page}&per_page=10`); + const d = await r.json(); + + // 过滤出有视频的记录 + const videoRecords = d.history.filter(item => { + const urls = item.urls || []; + return urls.some(u => u.type === 'video' || (typeof u === 'string' && u.endsWith('.mp4'))); + }); + + if (videoRecords.length > 0) { + const html = videoRecords.map(item => { + const videoObj = item.urls.find(u => u.type === 'video') || { url: item.urls[0] }; + const videoUrl = typeof videoObj === 'string' ? videoObj : videoObj.url; + + return ` +
+
+
+ +
+ +
+
+

${item.prompt || '无描述'}

+
+ ${item.created_at} +
+ +
+
+
+
+ `; + }).join(''); + + if (append) { + historyList.insertAdjacentHTML('beforeend', html); + } else { + historyList.innerHTML = html; + } + + historyEmpty.classList.add('hidden'); + historyCount.innerText = videoRecords.length + (append ? parseInt(historyCount.innerText) : 0); + + if (d.history.length >= 10) { + loadMoreBtn.classList.remove('hidden'); + } else { + loadMoreBtn.classList.add('hidden'); + } + } else if (!append) { + historyEmpty.classList.remove('hidden'); + historyList.innerHTML = ''; + } + + lucide.createIcons(); + } catch (e) { + console.error('加载历史失败:', e); + } finally { + isLoadingHistory = false; + } + } + + window.playHistoryVideo = (url) => { + showVideo(url); + window.scrollTo({ top: 0, behavior: 'smooth' }); + }; + + window.downloadUrl = (url) => { + // 使用后端代理强制下载,绕过跨域限制 + showToast('开始下载...', 'info'); + const filename = `vision-video-${Date.now()}.mp4`; + const proxyUrl = `/api/download_proxy?url=${encodeURIComponent(url)}&filename=${filename}`; + + // 创建隐藏的 iframe 触发下载,相比 a 标签兼容性更好 + const iframe = document.createElement('iframe'); + iframe.style.display = 'none'; + iframe.src = proxyUrl; + document.body.appendChild(iframe); + + // 1分钟后清理 iframe + setTimeout(() => document.body.removeChild(iframe), 60000); + }; + + loadMoreBtn.onclick = () => { + historyPage++; + loadHistory(historyPage, true); + }; + + initConfig(); + loadHistory(); + + window.applyTemplate = (text) => { + promptInput.value = text; + showToast('已应用提示词模板', 'success'); + }; + + // 上传图片逻辑 + fileInput.onchange = async (e) => { + const files = e.target.files; + if (files.length === 0) return; + + const formData = new FormData(); + formData.append('images', files[0]); + + try { + submitBtn.disabled = true; + const r = await fetch('/api/upload', { method: 'POST', body: formData }); + const d = await r.json(); + if (d.urls && d.urls.length > 0) { + uploadedImageUrl = d.urls[0]; + imagePreview.innerHTML = ` +
+ + +
+ `; + lucide.createIcons(); + } + } catch (err) { + showToast('图片上传失败', 'error'); + } finally { + submitBtn.disabled = false; + } + }; + + window.removeImage = () => { + uploadedImageUrl = null; + imagePreview.innerHTML = ''; + fileInput.value = ''; + }; + + // 提交生成任务 + submitBtn.onclick = async () => { + const prompt = promptInput.value.trim(); + if (!prompt) return showToast('请输入视频描述', 'warning'); + + const payload = { + prompt, + model: modelSelect.value, + enhance_prompt: enhancePrompt ? enhancePrompt.checked : false, + aspect_ratio: ratioSelect ? ratioSelect.value : '9:16', + images: uploadedImageUrl ? [uploadedImageUrl] : [] + }; + + try { + setLoading(true); + const r = await fetch('/api/video/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + const d = await r.json(); + + if (d.error) { + showToast(d.error, 'error'); + setLoading(false); + } else if (d.task_id) { + showToast(d.message || '任务已提交', 'info'); + pollTaskStatus(d.task_id); + } else { + showToast('未知异常,请重试', 'error'); + setLoading(false); + } + } catch (err) { + console.error('提交生成异常:', err); + showToast('任务提交失败', 'error'); + setLoading(false); + } + }; + + async function pollTaskStatus(taskId) { + let attempts = 0; + const maxAttempts = 180; // 提升到 15 分钟 + + const check = async () => { + try { + const r = await fetch(`/api/task_status/${taskId}?t=${Date.now()}`); + if (!r.ok) { + setTimeout(check, 5000); + return; + } + const d = await r.json(); + + if (d.status === 'complete') { + setLoading(false); + showVideo(d.video_url); + refreshUserPoints(); + // 刷新历史列表 + loadHistory(1, false); + } else if (d.status === 'error') { + showToast(d.message || '生成失败', 'error'); + setLoading(false); + refreshUserPoints(); + } else { + attempts++; + if (attempts >= maxAttempts) { + showToast('生成超时,请稍后在历史记录中查看', 'warning'); + setLoading(false); + return; + } + setTimeout(check, 5000); + } + } catch (e) { + setTimeout(check, 5000); + } + }; + check(); + } + + function showVideo(url) { + if (!url) return; + try { + placeholder.classList.add('hidden'); + finalWrapper.classList.remove('hidden'); + resultVideo.src = url; + resultVideo.load(); + const playPromise = resultVideo.play(); + if (playPromise !== undefined) { + playPromise.catch(e => console.warn('自动播放被拦截')); + } + } catch (err) { + console.error('展示视频失败:', err); + } + } + + const closePreviewBtn = document.getElementById('closePreviewBtn'); + if (closePreviewBtn) { + closePreviewBtn.onclick = () => { + finalWrapper.classList.add('hidden'); + placeholder.classList.remove('hidden'); + resultVideo.pause(); + resultVideo.src = ""; + }; + } + + function setLoading(isLoading) { + submitBtn.disabled = isLoading; + if (isLoading) { + statusInfo.classList.remove('hidden'); + submitBtn.innerHTML = '导演创作中...'; + // 如果正在生成,确保回到预览背景状态(如果当前正在播放旧视频) + if (!finalWrapper.classList.contains('hidden')) { + finalWrapper.classList.add('hidden'); + placeholder.classList.remove('hidden'); + } + } else { + statusInfo.classList.add('hidden'); + submitBtn.innerHTML = '开始生成视频'; + } + if (window.lucide) lucide.createIcons(); + } + + async function refreshUserPoints() { + try { + const r = await fetch('/api/auth/me'); + const d = await r.json(); + if (d.points !== undefined) { + if (pointsDisplay) pointsDisplay.innerText = d.points; + if (headerPoints) headerPoints.innerText = d.points; + } + } catch (e) { } + } + + document.getElementById('downloadBtn').onclick = () => { + const url = resultVideo.src; + if (!url) return; + downloadUrl(url); + }; + + if (regenBtn) { + regenBtn.onclick = () => { + submitBtn.click(); + }; + } +}); + diff --git a/sync_videos_manual.py b/sync_videos_manual.py new file mode 100644 index 0000000..9fa4d9a --- /dev/null +++ b/sync_videos_manual.py @@ -0,0 +1,83 @@ +import json +import io +import requests +import uuid +import time +from urllib.parse import quote +from app import create_app +from extensions import db, s3_client +from config import Config +from models import GenerationRecord + +app = create_app() + +def sync_old_videos(): + with app.app_context(): + print("🔍 开始扫描未同步的视频记录...") + + # 获取所有包含 'video' 字样的记录 (简单过滤) + records = GenerationRecord.query.filter(GenerationRecord.image_urls.like('%video%')).all() + + count = 0 + success_count = 0 + + for r in records: + try: + data = json.loads(r.image_urls) + updated = False + new_data = [] + + for item in data: + # 检查是否是视频且 URL 不是 MinIO 的地址 + if isinstance(item, dict) and item.get('type') == 'video': + url = item.get('url') + if url and Config.MINIO['public_url'] not in url: + print(f"⏳ 正在同步记录 {r.id}: {url[:50]}...") + + # 尝试下载并转存 + try: + with requests.get(url, stream=True, timeout=60) as req: + if req.status_code == 200: + content_type = req.headers.get('content-type', 'video/mp4') + ext = ".mp4" + + base_filename = f"video-{uuid.uuid4().hex}" + full_filename = f"{base_filename}{ext}" + + video_io = io.BytesIO() + for chunk in req.iter_content(chunk_size=8192): + video_io.write(chunk) + video_io.seek(0) + + # 上传至 MinIO + s3_client.upload_fileobj( + video_io, + Config.MINIO["bucket"], + full_filename, + ExtraArgs={"ContentType": content_type} + ) + + final_url = f"{Config.MINIO['public_url']}{quote(full_filename)}" + item['url'] = final_url + updated = True + print(f"✅ 同步成功: {final_url}") + else: + print(f"❌ 下载失败 (Status {req.status_code}),可能链接已过期") + except Exception as e: + print(f"❌ 同步异常: {e}") + + new_data.append(item) + + if updated: + r.image_urls = json.dumps(new_data) + db.session.commit() + success_count += 1 + count += 1 + + except Exception as e: + print(f"处理记录 {r.id} 出错: {e}") + + print(f"\n🎉 扫描完成! 成功同步了 {success_count} 个视频。") + +if __name__ == "__main__": + sync_old_videos() diff --git a/templates/base.html b/templates/base.html index f03c2cc..28ae2c9 100644 --- a/templates/base.html +++ b/templates/base.html @@ -1,43 +1,89 @@ + {% block title %}AI 视界{% endblock %} - + + {% block head %}{% endblock %} +
- +
- -