ai_v/blueprints/api.py
24024 825f4fb4a9 feat(ocr): 新增验光单助手功能页面
- 在主应用路由中添加 /ocr 页面路由渲染 ocr.html
- 菜单中新增“验光单助手”入口,图标为 scan-eye,便于访问
- 在生成文本接口中支持聊天模型,处理 messages 内图片链接为 Base64
- 兼容 messages 为空场景,重构 payload 结构支持图片 Base64 传输
- 解析验光单请求不保存生成记录,避免污染历史数据
- 获取历史记录时过滤掉“解读验光单”的操作记录
- AI 接口配置新增 CHAT_API 地址,支持聊天模型调用

style(frontend): 优化首页图片展示与交互样式

- 缩小加载动画高度,调整提示文字为“AI 构思中...”
- 图片展示容器增加阴影和悬停放大效果,提升视觉体验
- 结果区域改为flex布局,支持滚动区域和固定底部操作栏
- 按钮圆角加大,阴影色调调整,增强交互反馈
- 引入 Tailwind typography 插件,提升排版一致性
- 静态资源由 CDN 改为本地引用避免外部依赖

docs(ui): 补充首页联系方式提示,优化用户导航

- 在用户个人信息区域新增客服 QQ 联系方式悬浮提示
- 调整首页初始占位状态布局,提升视觉层次感
- 细化按钮标签与图标增强可用性提示
2026-01-14 00:00:23 +08:00

443 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import os
import uuid
import json
import requests
import io
import threading
import time
import base64
from flask import Blueprint, request, jsonify, session, current_app
from urllib.parse import quote
from config import Config
from extensions import s3_client, redis_client, db
from models import GenerationRecord, User, SystemDict, SystemNotification
from middlewares.auth import login_required
from services.logger import system_logger
api_bp = Blueprint('api', __name__)
def sync_images_background(app, record_id, raw_urls):
"""后台同步图片至 MinIO并生成缩略图带重试机制"""
with app.app_context():
processed_data = []
for raw_url in raw_urls:
success = False
for attempt in range(3): # 3 次重试机制
try:
img_resp = requests.get(raw_url, timeout=30)
if img_resp.status_code == 200:
content = img_resp.content
ext = ".png"
base_filename = f"gen-{uuid.uuid4().hex}"
full_filename = f"{base_filename}{ext}"
thumb_filename = f"{base_filename}-thumb{ext}"
# 1. 上传原图
s3_client.upload_fileobj(
io.BytesIO(content),
Config.MINIO["bucket"],
full_filename,
ExtraArgs={"ContentType": "image/png"}
)
full_url = f"{Config.MINIO['public_url']}{quote(full_filename)}"
thumb_url = full_url # 默认使用原图
# 2. 生成并上传缩略图 (400px 宽度)
try:
from PIL import Image
img = Image.open(io.BytesIO(content))
# 转换为 RGB 如果是 RGBA (避免某些格式保存问题)
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
# 缩放至宽度 400, 高度等比
w, h = img.size
if w > 400:
ratio = 400 / float(w)
img.thumbnail((400, int(h * ratio)), Image.Resampling.LANCZOS)
thumb_io = io.BytesIO()
# 缩略图保存为 JPEG 以获得更小的体积
img.save(thumb_io, format='JPEG', quality=80, optimize=True)
thumb_io.seek(0)
s3_client.upload_fileobj(
thumb_io,
Config.MINIO["bucket"],
thumb_filename.replace('.png', '.jpg'),
ExtraArgs={"ContentType": "image/jpeg"}
)
thumb_url = f"{Config.MINIO['public_url']}{quote(thumb_filename.replace('.png', '.jpg'))}"
except Exception as thumb_e:
print(f"⚠️ 缩略图生成失败: {thumb_e}")
processed_data.append({"url": full_url, "thumb": thumb_url})
success = True
break
except Exception as e:
print(f"⚠️ 第 {attempt+1} 次同步失败: {e}")
time.sleep(2 ** attempt) # 指数退避
if not success:
# 如果最终失败,保留原始 URL
processed_data.append({"url": raw_url, "thumb": raw_url})
# 更新数据库记录为持久化数据结构
try:
record = GenerationRecord.query.get(record_id)
if record:
record.image_urls = json.dumps(processed_data)
db.session.commit()
print(f"✅ 记录 {record_id} 图片及缩略图已完成同步")
except Exception as e:
print(f"❌ 更新记录失败: {e}")
@api_bp.route('/api/config')
def get_config():
"""从本地数据库字典获取配置"""
try:
dicts = SystemDict.query.filter_by(is_active=True).order_by(SystemDict.sort_order.desc()).all()
config = {
"models": [],
"ratios": [],
"prompts": [],
"sizes": []
}
for d in dicts:
item = {"label": d.label, "value": d.value}
if d.dict_type == 'ai_model':
item["cost"] = d.cost
config["models"].append(item)
elif d.dict_type == 'aspect_ratio':
config["ratios"].append(item)
elif d.dict_type == 'prompt_tpl':
config["prompts"].append(item)
elif d.dict_type == 'ai_image_size':
config["sizes"].append(item)
return jsonify(config)
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/upload', methods=['POST'])
@login_required
def upload():
try:
files = request.files.getlist('images')
img_urls = []
for f in files:
ext = os.path.splitext(f.filename)[1]
filename = f"{uuid.uuid4().hex}{ext}"
s3_client.upload_fileobj(
f, Config.MINIO["bucket"], filename,
ExtraArgs={"ContentType": f.content_type}
)
img_urls.append(f"{Config.MINIO['public_url']}{quote(filename)}")
return jsonify({"urls": img_urls})
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/generate', methods=['POST'])
@login_required
def generate():
try:
user_id = session.get('user_id')
user = User.query.get(user_id)
data = request.json if request.is_json else request.form
mode = data.get('mode', 'trial')
is_premium = data.get('is_premium', False)
input_key = data.get('apiKey')
target_api = Config.AI_API
api_key = None
use_trial = False
if mode == 'key':
# 自定义 Key 模式:优先使用本次输入的,否则使用数据库存的
api_key = input_key or user.api_key
if not api_key:
return jsonify({"error": "请先输入您的 API 密钥"}), 400
else:
# 积分/试用模式
if user.points > 0:
# 核心修复:优质模式使用专属 Key
api_key = Config.PREMIUM_KEY if is_premium else Config.TRIAL_KEY
target_api = Config.TRIAL_API
use_trial = True
else:
return jsonify({"error": "可用积分已耗尽,请充值或切换至自定义 Key 模式"}), 400
# 如果是 Key 模式且输入了新 Key则自动更新到数据库保存
if mode == 'key' and input_key and input_key != user.api_key:
user.api_key = input_key
db.session.commit()
# 获取模型及对应的消耗积分
model_value = data.get('model')
is_chat_model = "gemini" in model_value.lower() or "gpt" in model_value.lower()
model_dict = SystemDict.query.filter_by(dict_type='ai_model', value=model_value).first()
cost = model_dict.cost if model_dict else 1
# 核心修复:优质模式积分消耗 X2
if use_trial and is_premium:
cost *= 2
# --- 积分预扣除逻辑 (点击即扣) ---
if use_trial:
if user.points < cost:
return jsonify({"error": f"可用积分不足,优质模式需要 {cost} 积分,您当前剩余 {user.points} 积分"}), 400
user.points -= cost
db.session.commit()
system_logger.info(f"积分预扣除 ({'优质' if is_premium else '普通'}试用)", phone=user.phone, cost=cost, remaining_points=user.points)
try:
prompt = data.get('prompt')
model = model_value
ratio = data.get('ratio')
size = data.get('size')
input_img_urls = data.get('image_urls', [])
payload = {
"prompt": prompt,
"model": model,
"response_format": "url",
"aspect_ratio": ratio
}
if input_img_urls:
payload["image"] = input_img_urls
if model == "nano-banana-2" and size:
payload["image_size"] = size
# 如果是聊天模型,重新构建 payload
if is_chat_model:
messages = data.get('messages')
# 核心修复:将图片 URL 转换为 Base64解决第三方接口禁止 9000 端口访问的问题
def url_to_base64(url):
if not url or not url.startswith('http'):
return url
if ':9000' not in url:
return url
try:
# 尝试通过 S3 客户端直接读取(更安全,避开网络回环问题)
filename = url.split('/')[-1]
from urllib.parse import unquote
filename = unquote(filename)
resp = s3_client.get_object(Bucket=Config.MINIO["bucket"], Key=filename)
content = resp['Body'].read()
mime_type = resp.get('ContentType', 'image/jpeg')
encoded_string = base64.b64encode(content).decode('utf-8')
return f"data:{mime_type};base64,{encoded_string}"
except Exception as e:
print(f"⚠️ Base64 转换失败: {e}")
return url
if not messages:
# 兼容性处理:如果没有 messages构建一个简单的
messages = [
{"role": "system", "content": data.get('system_prompt', "You are a helpful assistant.")},
{"role": "user", "content": [
{"type": "text", "text": prompt}
]}
]
for img_url in input_img_urls:
messages[1]["content"].append({"type": "image_url", "image_url": {"url": url_to_base64(img_url)}})
else:
# 递归处理传入的 messages
for msg in messages:
content = msg.get('content')
if isinstance(content, list):
for item in content:
if isinstance(item, dict) and item.get('type') == 'image_url':
img_info = item.get('image_url')
if img_info and 'url' in img_info:
img_info['url'] = url_to_base64(img_info['url'])
payload = {
"model": model,
"messages": messages,
"stream": False
}
target_api = Config.CHAT_API
if not mode == 'key':
# 使用生图默认的试用/优质 Key
api_key = Config.PREMIUM_KEY if is_premium else Config.TRIAL_KEY
headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
resp = requests.post(target_api, json=payload, headers=headers, timeout=300)
if resp.status_code != 200:
# API 报错,退还积分
if use_trial:
user.points += cost
db.session.commit()
system_logger.warning(f"API 报错,积分已退还", phone=user.phone, status_code=resp.status_code)
return jsonify({"error": resp.text}), resp.status_code
api_result = resp.json()
if is_chat_model:
# 聊天模型返回的是文本
content = api_result['choices'][0]['message']['content']
# 核心修复如果是验光单解读OCR不存入生图历史记录避免污染生图历史
if prompt != "解读验光单":
new_record = GenerationRecord(
user_id=session.get('user_id'),
prompt=prompt,
model=model,
image_urls=json.dumps([{"type": "text", "content": content}])
)
db.session.add(new_record)
db.session.commit()
system_logger.info(f"用户生成文本成功", phone=user.phone, model=model, record_id=new_record.id)
else:
system_logger.info(f"用户解析验光单成功", phone=user.phone, model=model)
return jsonify({
"data": [{"content": content, "type": "text"}],
"message": "解析成功!"
})
raw_urls = [item['url'] for item in api_result.get('data', [])]
# 立即写入数据库(先存原始 URL
new_record = GenerationRecord(
user_id=session.get('user_id'),
prompt=prompt,
model=model,
image_urls=json.dumps(raw_urls)
)
db.session.add(new_record)
db.session.commit()
# 写入系统日志
system_logger.info(
f"用户生成图片成功",
phone=user.phone,
model=model,
record_id=new_record.id
)
# 启动后台线程同步图片,不阻塞前端返回
app = current_app._get_current_object()
threading.Thread(
target=sync_images_background,
args=(app, new_record.id, raw_urls)
).start()
# 立即返回原始 URL 给前端展示
return jsonify({
"data": [{"url": url} for url in raw_urls],
"message": "生成成功!作品正在后台同步至云存储。"
})
except Exception as e:
# 发生系统异常,退还积分
if use_trial:
user.points += cost
db.session.commit()
system_logger.error(f"生成异常,积分已退还", phone=user.phone, error=str(e))
return jsonify({"error": str(e)}), 500
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/notifications/latest', methods=['GET'])
@login_required
def get_latest_notification():
"""获取用户最近一条未读的激活通知"""
try:
user_id = session.get('user_id')
latest = SystemNotification.query.filter_by(is_active=True)\
.filter(~SystemNotification.read_by_users.any(id=user_id))\
.order_by(SystemNotification.created_at.desc()).first()
if latest:
return jsonify({
"id": latest.id,
"title": latest.title,
"content": latest.content
})
return jsonify({"id": None})
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/notifications/read', methods=['POST'])
@login_required
def mark_notif_read():
"""将通知标记为已读"""
try:
user_id = session.get('user_id')
data = request.json
notif_id = data.get('id')
if not notif_id:
return jsonify({"error": "缺少通知 ID"}), 400
notif = SystemNotification.query.get(notif_id)
user = User.query.get(user_id)
if notif and user:
if user not in notif.read_by_users:
notif.read_by_users.append(user)
db.session.commit()
return jsonify({"status": "ok"})
except Exception as e:
return jsonify({"error": str(e)}), 500
@api_bp.route('/api/history', methods=['GET'])
@login_required
def get_history():
"""获取用户的历史生成记录 (支持分页,限 90 天内)"""
try:
from datetime import datetime, timedelta
user_id = session.get('user_id')
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 10, type=int)
# 计算 90 天前的时间
ninety_days_ago = datetime.utcnow() - timedelta(days=90)
pagination = GenerationRecord.query.filter(
GenerationRecord.user_id == user_id,
GenerationRecord.created_at >= ninety_days_ago,
GenerationRecord.prompt != "解读验光单" # 过滤掉验光单助手的操作记录
).order_by(GenerationRecord.created_at.desc())\
.paginate(page=page, per_page=per_page, error_out=False)
# 格式化 URL兼容新旧数据格式
history_list = []
for r in pagination.items:
raw_urls = json.loads(r.image_urls)
formatted_urls = []
for u in raw_urls:
if isinstance(u, str):
# 旧数据:直接返回原图作为缩略图
formatted_urls.append({"url": u, "thumb": u})
else:
# 新数据:包含 url 和 thumb
formatted_urls.append(u)
history_list.append({
"id": r.id,
"model": r.model,
"urls": formatted_urls,
"time": (r.created_at + timedelta(hours=8)).strftime('%Y-%m-%d %H:%M')
})
return jsonify({
"history": history_list,
"has_next": pagination.has_next,
"total": pagination.total
})
except Exception as e:
return jsonify({"error": str(e)}), 500