From 3d479c52e775276f04c711103fd3b1e167db799d Mon Sep 17 00:00:00 2001 From: 24024 <240241002@qq.com> Date: Sat, 16 May 2026 23:11:08 +0800 Subject: [PATCH] =?UTF-8?q?```=20feat(api):=20=E6=96=B0=E5=A2=9EAI?= =?UTF-8?q?=E7=94=9F=E6=88=90=E5=86=85=E5=AE=B9=E6=A3=80=E6=B5=8B=E5=8A=9F?= =?UTF-8?q?=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 添加aigc-detect路由接口,用于检测图像是否为AI生成内容 - 集成阿里云AIGC检测服务,配置相关API参数 - 实现积分扣除机制,每次检测消耗1积分 feat(frontend): 前端集成AI生成检测UI组件 - 添加formatAigcDetectionResult函数用于格式化检测结果显示 - 实现detectAigcImage函数处理检测请求和响应 - 在图片展示区域添加AI生成检测按钮和结果展示框 - 更新图片容器布局,优化下载按钮显示效果 refactor(service): 优化积分管理逻辑 - 新增try_deduct_points函数实现积分预检查和扣除 - 改进错误处理机制,确保数据库事务安全 refactor(history): 过滤AI检测记录 - 在历史记录查询中排除AI生成检测类型的数据 - 使用image_urls字段过滤检测相关的记录 ``` --- blueprints/api.py | 17 +++ config.py | 4 + services/aigc_detector_service.py | 228 ++++++++++++++++++++++++++++++ services/generation_service.py | 12 ++ services/history_service.py | 3 +- static/js/main.js | 114 ++++++++++++++- 6 files changed, 373 insertions(+), 5 deletions(-) create mode 100644 services/aigc_detector_service.py diff --git a/blueprints/api.py b/blueprints/api.py index 07e04fa..82ff818 100644 --- a/blueprints/api.py +++ b/blueprints/api.py @@ -22,6 +22,7 @@ from services.generation_service import ( validate_video_request, start_async_video_task, ) +from services.aigc_detector_service import detect_aigc_image api_bp = Blueprint("api", __name__) @@ -136,6 +137,22 @@ def generate(): return jsonify({"error": str(e)}), 500 +@api_bp.route("/api/aigc-detect", methods=["POST"]) +@login_required +def aigc_detect(): + try: + user_id = session.get("user_id") + data = request.json if request.is_json else request.form + result, status_code = detect_aigc_image( + user_id, + (data.get("image_url") or data.get("url") or "").strip(), + request.url_root, + ) + return jsonify(result), status_code + except Exception as e: + return jsonify({"error": str(e)}), 500 + + @api_bp.route("/api/video/generate", methods=["POST"]) @login_required def video_generate(): diff --git a/config.py b/config.py index 4858b9f..4ec0cb4 100644 --- a/config.py +++ b/config.py @@ -61,6 +61,10 @@ class Config: # 阿里云短信配置 ALIBABA_CLOUD_ACCESS_KEY_ID = "LTAI5tAbHKxmPKVPYsABEdyq" ALIBABA_CLOUD_ACCESS_KEY_SECRET = "v6URREddBqvGfwZrWH1DWoxs3w6RxZ" + AIGC_DETECTOR_SERVICE = os.getenv("AIGC_DETECTOR_SERVICE", "aigcDetector_ultra") + AIGC_DETECTOR_ENDPOINT = os.getenv("AIGC_DETECTOR_ENDPOINT", "green-cip.cn-beijing.aliyuncs.com") + AIGC_DETECTOR_REGION_ID = os.getenv("AIGC_DETECTOR_REGION_ID", "cn-beijing") + AIGC_DETECTOR_COST = int(os.getenv("AIGC_DETECTOR_COST", "1")) SMS_SIGN_NAME = "速通互联验证码" SMS_TEMPLATE_CODE = "100001" SMS_NEED_PARAM = False # 该模板需要参数,如使用系统赠送模板请改为 False diff --git a/services/aigc_detector_service.py b/services/aigc_detector_service.py new file mode 100644 index 0000000..05474af --- /dev/null +++ b/services/aigc_detector_service.py @@ -0,0 +1,228 @@ +import json +import io +import uuid +from urllib.parse import unquote, urljoin, urlparse + +import requests +from alibabacloud_tea_openapi.client import Client as OpenApiClient +from alibabacloud_tea_openapi import models as open_api_models +from alibabacloud_tea_openapi import utils_models as open_api_util_models +from alibabacloud_tea_util import models as util_models + +from config import Config +from extensions import db, s3_client +from models import GenerationRecord +from services.generation_service import refund_points, try_deduct_points +from services.logger import system_logger +from utils import get_proxied_url + + +def _create_client(): + config = open_api_models.Config( + access_key_id=Config.ALIBABA_CLOUD_ACCESS_KEY_ID, + access_key_secret=Config.ALIBABA_CLOUD_ACCESS_KEY_SECRET, + region_id=Config.AIGC_DETECTOR_REGION_ID, + endpoint=Config.AIGC_DETECTOR_ENDPOINT, + ) + return OpenApiClient(config) + + +def _normalize_image_url(image_url, request_root): + if not image_url: + return None + if image_url.startswith(("http://", "https://")): + return image_url + return urljoin(request_root, image_url.lstrip("/")) + + +def _is_own_file_url(image_url, request_root): + public_path = Config.MINIO["public_url"].rstrip("/") + "/" + parsed = urlparse(image_url) + if not parsed.scheme: + return image_url.startswith(public_path) + + root = urlparse(request_root) + return parsed.netloc == root.netloc and parsed.path.startswith(public_path) + + +def _extract_minio_object_key(image_url, request_root): + public_path = Config.MINIO["public_url"].rstrip("/") + "/" + parsed = urlparse(_normalize_image_url(image_url, request_root)) + minio_endpoint = urlparse(Config.MINIO["endpoint"]) + bucket_path = f"/{Config.MINIO['bucket']}/" + + if parsed.netloc == urlparse(request_root).netloc and parsed.path.startswith(public_path): + return unquote(parsed.path[len(public_path):]) + + if parsed.netloc == minio_endpoint.netloc and parsed.path.startswith(bucket_path): + return unquote(parsed.path[len(bucket_path):]) + + return None + + +def _get_presigned_minio_url(object_key): + return s3_client.generate_presigned_url( + "get_object", + Params={"Bucket": Config.MINIO["bucket"], "Key": object_key}, + ExpiresIn=1800, + ) + + +def _mirror_image_for_detection(image_url, request_root): + """把外部临时图转存到 MinIO,并返回给阿里云可直接下载的签名 URL。""" + normalized_url = _normalize_image_url(image_url, request_root) + object_key = _extract_minio_object_key(normalized_url, request_root) + if object_key: + return _get_presigned_minio_url(object_key) + + resp = requests.get(get_proxied_url(normalized_url), timeout=Config.PROXY_TIMEOUT_SHORT) + resp.raise_for_status() + + content_type = resp.headers.get("Content-Type") or "image/png" + ext = ".png" + if "jpeg" in content_type or "jpg" in content_type: + ext = ".jpg" + elif "webp" in content_type: + ext = ".webp" + + filename = f"aigc-detect-{uuid.uuid4().hex}{ext}" + s3_client.upload_fileobj( + io.BytesIO(resp.content), + Config.MINIO["bucket"], + filename, + ExtraArgs={"ContentType": content_type}, + ) + return _get_presigned_minio_url(filename) + + +def _extract_result(resp): + body = resp.get("body", resp) + data = body.get("Data") or body.get("data") or {} + if isinstance(data, str): + try: + data = json.loads(data) + except json.JSONDecodeError: + data = {"raw": data} + + result = data.get("Result") or data.get("result") or [] + result_items = result if isinstance(result, list) else [] + first = result_items[0] if result_items else {} + if not isinstance(first, dict): + first = {"raw": first} + + label = first.get("Label") or first.get("label") or data.get("Label") or data.get("label") + confidence = ( + first.get("Confidence") + or first.get("confidence") + or first.get("Rate") + or first.get("rate") + or data.get("Confidence") + or data.get("confidence") + ) + risk_level = ( + first.get("RiskLevel") + or first.get("riskLevel") + or data.get("RiskLevel") + or data.get("riskLevel") + ) + suggestion = ( + first.get("Suggestion") + or first.get("suggestion") + or data.get("Suggestion") + or data.get("suggestion") + ) + description = ( + first.get("Description") + or first.get("description") + or data.get("Description") + or data.get("description") + ) + details = [] + for item in result_items: + if not isinstance(item, dict): + continue + details.append({ + "label": item.get("Label") or item.get("label"), + "description": item.get("Description") or item.get("description"), + "confidence": item.get("Confidence") or item.get("confidence"), + "risk_level": item.get("RiskLevel") or item.get("riskLevel") or risk_level, + }) + + return { + "label": label, + "description": description, + "confidence": confidence, + "risk_level": risk_level, + "suggestion": suggestion, + "details": details, + "raw": body, + } + + +def _ensure_success(resp): + body = resp.get("body", resp) + code = body.get("Code") if isinstance(body, dict) else None + if code is None and isinstance(body, dict): + code = body.get("code") + if code is None or str(code) == "200": + return + + message = body.get("Msg") or body.get("Message") or body.get("msg") or body.get("message") or "检测接口返回失败" + raise RuntimeError(f"{message} (code={code})") + + +def detect_aigc_image(user_id, image_url, request_root): + cost = Config.AIGC_DETECTOR_COST + if not image_url: + return {"error": "缺少图片地址"}, 400 + + if not try_deduct_points(user_id, cost): + return {"error": f"积分不足,本次 AI 生成检测需要 {cost} 积分"}, 400 + + try: + normalized_url = _mirror_image_for_detection(image_url, request_root) + client = _create_client() + params = open_api_util_models.Params( + action="ImageModeration", + version="2022-03-02", + protocol="HTTPS", + pathname="/", + method="POST", + auth_type="AK", + body_type="json", + req_body_type="json", + style="ROA", + ) + request = open_api_util_models.OpenApiRequest( + body={ + "Service": Config.AIGC_DETECTOR_SERVICE, + "ServiceParameters": json.dumps( + {"imageUrl": normalized_url}, + ensure_ascii=False, + ), + } + ) + runtime = util_models.RuntimeOptions( + read_timeout=Config.PROXY_TIMEOUT_DEFAULT * 1000, + connect_timeout=Config.PROXY_TIMEOUT_SHORT * 1000, + ) + resp = client.do_request(params, request, runtime) + _ensure_success(resp) + result = _extract_result(resp) + + record = GenerationRecord( + user_id=user_id, + prompt=f"AI生成检测: {normalized_url}", + model=Config.AIGC_DETECTOR_SERVICE, + cost=cost, + image_urls=json.dumps([{"type": "aigc_detection", "url": normalized_url, "result": result}], ensure_ascii=False), + ) + db.session.add(record) + db.session.commit() + + return {"message": "检测完成", "cost": cost, "result": result}, 200 + except Exception as e: + db.session.rollback() + refund_points(user_id, cost) + system_logger.error("AI生成检测失败", user_id=user_id, image_url=image_url, error=str(e)) + return {"error": f"AI生成检测失败: {str(e)}"}, 500 diff --git a/services/generation_service.py b/services/generation_service.py index 195bbcf..9838bb9 100644 --- a/services/generation_service.py +++ b/services/generation_service.py @@ -112,6 +112,18 @@ def refund_points(user_id, cost): db.session.rollback() +def try_deduct_points(user_id, cost): + """扣减积分,余额不足时返回 False。""" + user = db.session.query(User).filter_by(id=user_id).populate_existing().with_for_update().first() + if not user or user.points < cost: + return False + + user.points -= cost + user.has_used_points = True + db.session.commit() + return True + + def handle_chat_generation_sync(user_id, api_key, model_value, prompt, use_trial, cost): """同步处理聊天类模型。""" chat_payload = { diff --git a/services/history_service.py b/services/history_service.py index 5e9a836..970855a 100644 --- a/services/history_service.py +++ b/services/history_service.py @@ -12,7 +12,8 @@ def get_user_history_data(user_id, page=1, per_page=10, filter_type='all'): query = GenerationRecord.query.filter( GenerationRecord.user_id == user_id, GenerationRecord.created_at >= ninety_days_ago, - GenerationRecord.prompt != "解读验光单" + GenerationRecord.prompt != "解读验光单", + ~GenerationRecord.image_urls.like('%"type": "aigc_detection"%') ) if filter_type == 'video': diff --git a/static/js/main.js b/static/js/main.js index 205edd2..fb6849b 100644 --- a/static/js/main.js +++ b/static/js/main.js @@ -167,6 +167,103 @@ async function downloadImage(url, options = {}) { } } +function formatAigcDetectionResult(result) { + const riskMap = { + high: { text: '高风险', badge: 'bg-rose-50 text-rose-600 border-rose-100', bar: 'bg-rose-500' }, + medium: { text: '中风险', badge: 'bg-amber-50 text-amber-600 border-amber-100', bar: 'bg-amber-500' }, + low: { text: '低风险', badge: 'bg-emerald-50 text-emerald-600 border-emerald-100', bar: 'bg-emerald-500' }, + none: { text: '未检出风险', badge: 'bg-emerald-50 text-emerald-600 border-emerald-100', bar: 'bg-emerald-500' } + }; + const riskKey = String(result?.risk_level || '').toLowerCase(); + const risk = riskMap[riskKey] || { text: result?.risk_level || '未知风险', badge: 'bg-slate-50 text-slate-600 border-slate-100', bar: 'bg-indigo-500' }; + const confidence = Number(result?.confidence || 0); + const confidenceText = Number.isFinite(confidence) && confidence > 0 ? confidence.toFixed(confidence % 1 === 0 ? 0 : 1) : '--'; + const confidenceWidth = Number.isFinite(confidence) ? Math.max(0, Math.min(100, confidence)) : 0; + const label = result?.description || result?.label || 'AI生成检测'; + const details = Array.isArray(result?.details) ? result.details : []; + const detailHtml = details.length ? ` +