from datetime import datetime, timedelta from decimal import Decimal, InvalidOperation from sqlalchemy.exc import IntegrityError from .models import RefundRecipient, AlipayRefundOrder import base64 import csv import hashlib import json import logging import random import re import secrets import time import urllib.parse import urllib.request import urllib.error import uuid import io import zipfile import xml.etree.ElementTree as ET from pathlib import Path from fastapi import BackgroundTasks, Body, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile, status from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import func, inspect, literal, or_, select, text, union_all, update from sqlalchemy.orm import Session, defer, noload, object_session, selectinload from .config import settings from .database import Base, SessionLocal, engine, get_db from .llm import extract_questions_from_images, generate_answer_for_question, generate_answers_batch, generate_answers_for_questions, generate_questions, generate_qa from .models import AdminLoginLog, AdminUsageEntry, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QABulkGenerationItem, QABulkGenerationJob, QALibraryEntry, RefundUploadSession from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QABulkAnswerRequest, QAGenerateRequest, RegisterRequest from .security import create_session_token, decode_session_token, hash_password, verify_password from .screenshot_storage import screenshot_bytes, screenshot_data_url, screenshot_fields, screenshot_response app = FastAPI(title=settings.app_name, version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"], allow_credentials=True, allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"], allow_headers=["Content-Type"], ) # A question allocation is temporary until the user submits a completion # batch. The one-minute window covers the selection screen; after the user # confirms a selection, the upload screen has a separate thirty-minute window. ASK_SELECTION_TIMEOUT = timedelta(minutes=1) ASK_UPLOAD_TIMEOUT = timedelta(minutes=30) # Bumping this prefix invalidates every previously issued public-user token. # Only Taobao-ID login can issue a token accepted by the current user portals. PUBLIC_USER_SESSION_PREFIX = "tb2_" QA_BULK_MAX_ATTEMPTS = 3 def _new_public_user_token() -> str: return f"{PUBLIC_USER_SESSION_PREFIX}{uuid.uuid4().hex}" def _release_expired_ask_assignments(db: Session, task: AskTask | None = None) -> int: """Release stale ask-task allocations back to the published task pool. Submitted batches are intentionally preserved (including rejected batches) because they are part of the review/history flow. Only the current, unsubmitted reservation is returned to the task pool. """ now = datetime.utcnow() statement = select(AskAssignment).where( or_( (AskAssignment.status == "待选择") & (func.coalesce(AskAssignment.reservation_started_at, AskAssignment.created_at) < now - ASK_SELECTION_TIMEOUT), (AskAssignment.status == "进行中") & (func.coalesce(AskAssignment.reservation_started_at, AskAssignment.updated_at) < now - ASK_UPLOAD_TIMEOUT), ), ) if task is not None: statement = statement.where(AskAssignment.task_id == task.id) expired = db.scalars(statement).unique().all() for assignment in expired: _release_ask_assignment_reservation(db, assignment) if expired: db.flush() return len(expired) def _ask_assignment_expired(assignment: AskAssignment | None, now: datetime | None = None) -> bool: if not assignment or assignment.status not in {"待选择", "进行中"}: return False current = now or datetime.utcnow() timeout = ASK_SELECTION_TIMEOUT if assignment.status == "待选择" else ASK_UPLOAD_TIMEOUT started_at = assignment.reservation_started_at or ( assignment.created_at if assignment.status == "待选择" else assignment.updated_at ) return started_at < current - timeout def _ask_assignment_expires_at(assignment: AskAssignment) -> datetime | None: started_at = assignment.reservation_started_at or ( assignment.created_at if assignment.status == "待选择" else assignment.updated_at ) if assignment.status == "待选择": return started_at + ASK_SELECTION_TIMEOUT if assignment.status == "进行中": return started_at + ASK_UPLOAD_TIMEOUT return None def _batch_question_ids(batch: AskAssignmentBatch | None) -> set[int]: if not batch: return set() try: return {int(value) for value in (batch.selected_question_ids or "").split(",") if value} except ValueError: return set() def _release_ask_assignment_reservation(db: Session, assignment: AskAssignment) -> None: """Release unfinished questions without deleting submitted batch history.""" if not assignment.batches: db.delete(assignment) return used_ids = {item.question_id for item in assignment.used_questions} latest = max(assignment.batches, key=lambda value: value.created_at) rejected_ids = _batch_question_ids(latest) if latest.status == "rejected" else set() for item in list(assignment.items): if item.question_id not in used_ids: db.delete(item) else: item.selected = item.question_id in rejected_ids assignment.status = { "pending": "待审核", "approved": "已通过", "paid": "已通过", "rejected": "未通过", }.get(latest.status, latest.status) assignment.reservation_started_at = None assignment.updated_at = datetime.utcnow() def _product_intent_error(values: list[object]) -> str | None: """Reject obvious placeholder input before creating an AI task.""" text = " ".join(str(value or "").strip() for value in values if str(value or "").strip()) if not text: return "请至少填写一项商品信息" compact = re.sub(r"[\s\W_]+", "", text, flags=re.UNICODE) if not compact: return "商品信息不能只有符号,请填写真实的商品内容" if compact.isdigit(): return "商品信息看起来像占位内容,请填写真实的商品名称、品类、功效或卖点" if re.fullmatch(r"(.{1,3})\1+", compact, flags=re.UNICODE): return "商品信息看起来是重复的占位内容,请补充真实的商品信息" if not re.search(r"[\u4e00-\u9fffA-Za-z]", compact): return "请填写包含文字的真实商品信息" return None def _product_token_error(value: object, qr_image: object = None) -> str | None: """Require at least one way for the user to open the product. Taobao's command is intentionally treated as opaque text. It may contain characters that are not valid in a URL, so no HTTP/HTTPS parsing should be performed here. """ if not str(value or "").strip() and not qr_image: return "请填写商品淘口令或上传商品二维码" return None def _decode_product_qr_image(value: object, filename: object = "") -> tuple[bytes | None, str | None, str | None]: """Decode the small data URL sent by the create-task form.""" if value is None: return None, None, None raw = str(value).strip() if not raw: return None, None, None match = re.fullmatch(r"data:(image/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=\s]+)", raw, flags=re.IGNORECASE) if not match: raise HTTPException(status_code=400, detail="商品二维码必须是 JPG、PNG 或 WEBP 图片") try: image_data = base64.b64decode(re.sub(r"\s+", "", match.group(2)), validate=True) except (ValueError, base64.binascii.Error): raise HTTPException(status_code=400, detail="商品二维码图片数据无效") if not image_data: raise HTTPException(status_code=400, detail="商品二维码图片不能为空") if len(image_data) > 5 * 1024 * 1024: raise HTTPException(status_code=400, detail="商品二维码图片不能超过 5MB") image_name = str(filename or "商品二维码").strip()[:255] or "商品二维码" return image_data, match.group(1).lower(), image_name def _product_qr_data(task: AskTask) -> dict | None: if not task.product_qr_image_data: return None content_type = task.product_qr_image_content_type or "image/png" return { "name": task.product_qr_image_name or "商品二维码", "content_type": content_type, "data_url": f"data:{content_type};base64,{base64.b64encode(task.product_qr_image_data).decode()}", } def _generation_options_error(payload: dict, include_answers: bool = True) -> str | None: # 语言风格、目标人群、场景等生成偏好均可留空,仅限制生成条数必填。 required = [("question_count", "提问生成条数")] missing = [label for key, label in required if not str(payload.get(key, "")).strip()] if missing: return f"请填写{'、'.join(missing)}" try: question_count = int(payload.get("question_count")) except (TypeError, ValueError): return "提问生成条数必须是大于 0 的整数" if question_count < 1: return "提问生成条数必须是大于 0 的整数" return None # 回答用户链接的 MVP 数据暂存于内存:截图只在当前服务进程中保留,后续接入对象存储/持久化表。 ANSWER_TASKS = { "TASK20260822002": { "task_id": "TASK20260822002", "token": "demo-answer-token", "product_name": "泊泉雅三色修护凝精华", "status": "待上传", "questions": [ {"id": "Q001", "question": "这个精华敏感肌可以使用吗?", "answer": "质地温和,使用前建议先做局部测试。"}, {"id": "Q002", "question": "用了会不会觉得油腻?", "answer": "吸收速度较快,肤感清爽不黏腻。"}, {"id": "Q003", "question": "这款成分里面有致痘成分吗?", "answer": "配方清爽,建议结合自己的肤质进行选择。"}, {"id": "Q004", "question": "吸收速度怎么样?使用感如何?", "answer": "延展性好,吸收后肤感柔润,日常使用很舒服。"}, ], "invited_screenshots": [], "final_screenshots": [], "recognized_questions": [], "selected_question_ids": [], "user_name": "", "updated_at": datetime.utcnow().isoformat(), } } PUBLIC_ANSWER_SUBMISSIONS: list[dict] = [] PHONE_CODES: dict[str, dict] = {} DINGTALK_STATES: dict[str, float] = {} ROLE_LABELS = {"super_admin": "超级管理员", "dept_manager": "部门主管", "operator": "普通运营"} ROLE_PERMISSIONS = { "super_admin": ["assistant", "library", "create", "board", "review", "refunds", "usage", "users", "logs", "settings"], "dept_manager": ["assistant", "library", "create", "board", "review", "refunds"], "operator": ["assistant", "library", "create", "board", "review", "refunds"], } def _dingtalk_department_is_allowed( dept_id: str | None, dept_name: str | None, dept_path: str | None = None, dept_name_path: str | None = None, ) -> bool: configured_ids = settings.dingtalk_allowed_dept_ids configured_names = settings.dingtalk_allowed_dept_names if dept_id and dept_id in configured_ids: return True if dept_path and any(f",{candidate}," in dept_path for candidate in configured_ids): return True department_names = { item.strip() for item in str(dept_name_path or dept_name or "").split("/") if item.strip() } return bool(department_names & configured_names) def _dingtalk_department_is_super_admin( dept_id: str | None, dept_name: str | None, dept_path: str | None = None, dept_name_path: str | None = None, ) -> bool: configured_ids = settings.dingtalk_super_admin_dept_ids configured_names = settings.dingtalk_super_admin_dept_names if dept_id and dept_id in configured_ids: return True if dept_path and any(f",{candidate}," in dept_path for candidate in configured_ids): return True department_names = { item.strip() for item in str(dept_name_path or dept_name or "").split("/") if item.strip() } return bool(department_names & configured_names) def public_user(user: AdminUser) -> dict: return { "id": user.id, "username": user.username, "display_name": user.display_name, "role_code": user.role_code, "role_label": ROLE_LABELS.get(user.role_code, user.role_code), "permissions": ROLE_PERMISSIONS.get(user.role_code, []), "dingtalk_user_id": user.dingtalk_user_id, "dingtalk_dept_id": user.dingtalk_dept_id, "dingtalk_dept_name": user.dingtalk_dept_name, "is_dept_manager": user.is_dept_manager, "answer_public_token": user.answer_public_token, } def seed_users(db: Session): if db.scalar(select(AdminUser).limit(1)): return db.add_all([ AdminUser(username=settings.admin_username, password_hash=hash_password(settings.admin_password), display_name="系统管理员", role_code="super_admin"), AdminUser(username=settings.operator_username, password_hash=hash_password(settings.operator_password), display_name="普通运营", role_code="operator"), ]) db.commit() @app.on_event("startup") def startup(): Base.metadata.create_all(bind=engine) settings.screenshot_storage_dir.mkdir(parents=True, exist_ok=True) for screenshot_table in ("answer_screenshots", "ask_assignment_batch_screenshots", "ask_assignment_screenshots"): screenshot_column_defs = {column["name"]: column for column in inspect(engine).get_columns(screenshot_table)} screenshot_columns = set(screenshot_column_defs) if "file_path" not in screenshot_columns: with engine.begin() as connection: connection.execute(text(f"ALTER TABLE {screenshot_table} ADD COLUMN file_path VARCHAR(1000) NULL")) if engine.dialect.name == "mysql" and not screenshot_column_defs["image_data"].get("nullable", False): with engine.begin() as connection: connection.execute(text(f"ALTER TABLE {screenshot_table} MODIFY COLUMN image_data MEDIUMBLOB NULL")) # Add fields introduced after the initial SQLite database was created. columns = {column["name"] for column in inspect(engine).get_columns("ask_tasks")} if "claim_questions_per_user" not in columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN claim_questions_per_user INTEGER NOT NULL DEFAULT 5")) if "shop_name" not in columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN shop_name VARCHAR(255) NOT NULL DEFAULT ''")) if "owner_id" not in columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN owner_id INTEGER NULL")) if "public_token" not in columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN public_token VARCHAR(64) NULL")) if "product_qr_image_data" not in columns: # SQLite accepts LONGBLOB as a type name and stores it with BLOB # affinity; MySQL needs LONGBLOB so a normal QR image is not capped at # the 64KB BLOB limit. qr_blob_type = "LONGBLOB" if engine.dialect.name == "mysql" else "BLOB" with engine.begin() as connection: connection.execute(text(f"ALTER TABLE ask_tasks ADD COLUMN product_qr_image_data {qr_blob_type} NULL")) task_columns = {column["name"] for column in inspect(engine).get_columns("ask_tasks")} if "product_qr_image_content_type" not in task_columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN product_qr_image_content_type VARCHAR(64) NULL")) if "product_qr_image_name" not in task_columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN product_qr_image_name VARCHAR(255) NULL")) assignment_columns = {column["name"] for column in inspect(engine).get_columns("ask_assignments")} if "reservation_started_at" not in assignment_columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_assignments ADD COLUMN reservation_started_at DATETIME NULL")) with engine.begin() as connection: connection.execute(text("CREATE INDEX ix_ask_assignments_reservation_started_at ON ask_assignments (reservation_started_at)")) question_table_columns = {column["name"] for column in inspect(engine).get_columns("ask_questions")} if "source" not in question_table_columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_questions ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'AI生成'")) admin_user_columns = {column["name"] for column in inspect(engine).get_columns("admin_users")} admin_user_migrations = { "dingtalk_user_id": "VARCHAR(128) NULL", "dingtalk_union_id": "VARCHAR(128) NULL", "dingtalk_corp_id": "VARCHAR(128) NULL", "dingtalk_dept_id": "VARCHAR(64) NULL", "dingtalk_dept_name": "VARCHAR(255) NULL", "dingtalk_dept_path": "VARCHAR(1000) NULL", "dingtalk_title": "VARCHAR(255) NULL", "is_dept_manager": "BOOLEAN NOT NULL DEFAULT 0", "answer_public_token": "VARCHAR(96) NULL", "last_answer_shop_name": "VARCHAR(255) NULL", } for column_name, column_type in admin_user_migrations.items(): if column_name not in admin_user_columns: with engine.begin() as connection: connection.execute(text(f"ALTER TABLE admin_users ADD COLUMN {column_name} {column_type}")) answer_submission_columns = {column["name"] for column in inspect(engine).get_columns("answer_submissions")} if "owner_id" not in answer_submission_columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE answer_submissions ADD COLUMN owner_id INTEGER NULL")) if "shop_name" not in answer_submission_columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE answer_submissions ADD COLUMN shop_name VARCHAR(255) NOT NULL DEFAULT ''")) answer_user_columns = {column["name"] for column in inspect(engine).get_columns("answer_users")} if "taobao_id" not in answer_user_columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE answer_users ADD COLUMN taobao_id VARCHAR(64) NULL")) answer_user_indexes = inspect(engine).get_indexes("answer_users") answer_user_constraints = inspect(engine).get_unique_constraints("answer_users") taobao_id_index_exists = any( index.get("name") == "ix_answer_users_taobao_id" or index.get("column_names") == ["taobao_id"] for index in answer_user_indexes ) or any( constraint.get("column_names") == ["taobao_id"] for constraint in answer_user_constraints ) if not taobao_id_index_exists: with engine.begin() as connection: connection.execute(text("CREATE UNIQUE INDEX ix_answer_users_taobao_id ON answer_users (taobao_id)")) question_columns = {column["name"] for column in inspect(engine).get_columns("answer_recognized_questions")} if "operator_image_screenshot_id" not in question_columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE answer_recognized_questions ADD COLUMN operator_image_screenshot_id INTEGER NULL")) if "operator_image_screenshot_ids" not in question_columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE answer_recognized_questions ADD COLUMN operator_image_screenshot_ids TEXT NULL")) db = SessionLocal() try: legacy_tasks = db.scalars( select(AskTask).where(AskTask.public_token.is_(None), AskTask.status != "草稿") ).all() for task in legacy_tasks: task.public_token = secrets.token_urlsafe(24) if legacy_tasks: db.commit() index_names = {index["name"] for index in inspect(engine).get_indexes("ask_tasks")} if "ix_ask_tasks_public_token" not in index_names: with engine.begin() as connection: connection.execute(text("CREATE UNIQUE INDEX ix_ask_tasks_public_token ON ask_tasks (public_token)")) seed_users(db) # Restore department-wide administrator access for existing rows. ID # paths also cover members stored in child departments. department_admins = db.scalars(select(AdminUser).where(AdminUser.is_active.is_(True))).all() promoted = False for department_admin in department_admins: if _dingtalk_department_is_super_admin( department_admin.dingtalk_dept_id, department_admin.dingtalk_dept_name, department_admin.dingtalk_dept_path, ) and department_admin.role_code != "super_admin": department_admin.role_code = "super_admin" promoted = True if promoted: db.commit() # Every operator keeps one stable public answer-task URL. Generate # tokens after seeding so the default accounts receive one as well. answer_users = db.scalars( select(AdminUser).where( AdminUser.answer_public_token.is_(None), AdminUser.role_code != "super_admin", ) ).all() for operator in answer_users: operator.answer_public_token = _new_answer_public_token(db) if answer_users: db.commit() admin_user_indexes = inspect(engine).get_indexes("admin_users") admin_user_unique_constraints = inspect(engine).get_unique_constraints("admin_users") answer_token_index_exists = any( index.get("name") == "ix_admin_users_answer_public_token" or index.get("column_names") == ["answer_public_token"] for index in admin_user_indexes ) or any( constraint.get("column_names") == ["answer_public_token"] for constraint in admin_user_unique_constraints ) if not answer_token_index_exists: with engine.begin() as connection: connection.execute(text("CREATE UNIQUE INDEX ix_admin_users_answer_public_token ON admin_users (answer_public_token)")) finally: db.close() def current_user(request: Request, db: Session = Depends(get_db)) -> AdminUser: token = request.cookies.get(settings.cookie_name) payload = decode_session_token(token) if token else None if not payload: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效,请重新登录") user = db.get(AdminUser, int(payload["sub"])) if not user or not user.is_active: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不可用") # Authentication only needs the loaded user. End the implicit SELECT # transaction here so a subsequent long-running route operation (LLM, # file I/O, or an external API call) does not keep a pool connection # checked out for the whole request. db.expunge(user) db.rollback() return user def require_super_admin(user: AdminUser = Depends(current_user)) -> AdminUser: if user.role_code != "super_admin": raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可访问此功能") return user @app.get("/api/health") def health(): return {"success": True, "message": "ok"} def _answer_task(task_id: str, token: str) -> dict: task = ANSWER_TASKS.get(task_id) if not task or token != task["token"]: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务链接无效或已失效") return task def _public_answer_task(task: dict) -> dict: selected = set(task.get("selected_question_ids", [])) return { "task_id": task["task_id"], "product_name": task["product_name"], "status": task["status"], "recognized_questions": task.get("recognized_questions", []), "selected_questions": [q for q in task["questions"] if q["id"] in selected], "updated_at": task["updated_at"], } def _answer_user_from_token(token: str, db: Session) -> AnswerUser: if not str(token or "").startswith(PUBLIC_USER_SESSION_PREFIX): raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户登录已失效,请使用真实淘宝ID重新登录") user = db.scalar(select(AnswerUser).where(AnswerUser.auth_token == token)) if not user or not user.taobao_id: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户登录已失效,请使用真实淘宝ID重新登录") db.expunge(user) db.rollback() return user def _phone_number(payload: dict) -> str: phone = str(payload.get("phone", "")).strip() if not phone.isdigit() or len(phone) != 11 or not phone.startswith("1"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请输入正确的11位手机号") return phone def _send_phone_code(payload: dict) -> dict: phone = _phone_number(payload) now = datetime.utcnow() previous = PHONE_CODES.get(phone) if previous and (now - previous["sent_at"]).total_seconds() < 60: wait = 60 - int((now - previous["sent_at"]).total_seconds()) raise HTTPException(status_code=429, detail=f"验证码发送过于频繁,请{max(1, wait)}秒后再试") code = f"{random.randint(0, 999999):06d}" PHONE_CODES[phone] = {"code": code, "sent_at": now, "expires_at": now + timedelta(minutes=5), "attempts": 0} result = {"expires_in": 300, "retry_after": 60} if settings.sms_dev_mode: result["dev_code"] = code return result def _phone_login(payload: dict, db: Session) -> AnswerUser: phone = _phone_number(payload) code = str(payload.get("code", "")).strip() challenge = PHONE_CODES.get(phone) if not challenge: raise HTTPException(status_code=400, detail="请先获取短信验证码") if challenge["attempts"] >= 5: PHONE_CODES.pop(phone, None) raise HTTPException(status_code=400, detail="验证码错误次数过多,请重新获取验证码") if datetime.utcnow() > challenge["expires_at"]: PHONE_CODES.pop(phone, None) raise HTTPException(status_code=400, detail="验证码已过期,请重新获取") if code != challenge["code"]: challenge["attempts"] += 1 raise HTTPException(status_code=400, detail="验证码错误,请重新输入") PHONE_CODES.pop(phone, None) user = db.scalar(select(AnswerUser).where(AnswerUser.phone == phone)) if not user: user = AnswerUser(public_id=f"U{uuid.uuid4().int % 10_000_000:07d}", phone=phone, auth_token=uuid.uuid4().hex) db.add(user) else: user.last_login_at = datetime.utcnow() if not user.auth_token: user.auth_token = uuid.uuid4().hex db.commit(); db.refresh(user) return user def _taobao_id(payload: dict) -> str: value = str(payload.get("taobao_id", "")).strip() if not re.fullmatch(r"[\u3400-\u4dbf\u4e00-\u9fffA-Za-z0-9_.-]{2,64}", value): raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="请输入正确的淘宝ID(2-64位中文、英文字母、数字、下划线、点或短横线)", ) return value def _taobao_login(payload: dict, db: Session) -> AnswerUser: taobao_id = _taobao_id(payload) user = db.scalar(select(AnswerUser).where(AnswerUser.taobao_id == taobao_id)) if not user: # Existing databases require the legacy phone column. This internal # unique value is never returned or displayed for Taobao-ID users. legacy_phone_key = f"tb_{hashlib.sha256(taobao_id.encode('utf-8')).hexdigest()[:16]}" user = AnswerUser( public_id=f"U{uuid.uuid4().int % 10_000_000:07d}", phone=legacy_phone_key, taobao_id=taobao_id, auth_token=_new_public_user_token(), ) db.add(user) else: user.last_login_at = datetime.utcnow() if not user.auth_token.startswith(PUBLIC_USER_SESSION_PREFIX): user.auth_token = _new_public_user_token() db.commit(); db.refresh(user) return user def _public_user_id(user: AnswerUser) -> str: return user.taobao_id or user.public_id def _public_user_account(user: AnswerUser) -> str: return user.taobao_id or user.phone def _public_phone_session(user: AnswerUser) -> dict: return { "user_id": _public_user_id(user), "taobao_id": user.taobao_id, "account": _public_user_account(user), "phone": user.phone if not user.taobao_id else "", "token": user.auth_token, } def _dingtalk_configured() -> bool: return bool(settings.dingtalk_client_id and settings.dingtalk_client_secret) def _dingtalk_workbench_configured() -> bool: return bool(settings.dingtalk_corp_id and settings.dingtalk_agent_id and settings.dingtalk_client_id and settings.dingtalk_client_secret) def _dingtalk_exchange_code(code: str) -> dict: request = urllib.request.Request( "https://api.dingtalk.com/v1.0/oauth2/userAccessToken", data=json.dumps({ "clientId": settings.dingtalk_client_id, "clientSecret": settings.dingtalk_client_secret, "code": code, "grantType": "authorization_code", }).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=10) as response: return json.loads(response.read().decode("utf-8")) except Exception as exc: raise HTTPException(status_code=502, detail="钉钉授权码校验失败,请稍后重试") from exc def _dingtalk_user(access_token: str) -> dict: request = urllib.request.Request( "https://api.dingtalk.com/v1.0/contact/users/me", headers={"x-acs-dingtalk-access-token": access_token}, method="GET", ) try: with urllib.request.urlopen(request, timeout=10) as response: return json.loads(response.read().decode("utf-8")) except Exception as exc: raise HTTPException(status_code=502, detail="无法读取钉钉用户信息,请稍后重试") from exc def _dingtalk_oapi_post(path: str, access_token: str, payload: dict) -> dict: request = urllib.request.Request( "https://oapi.dingtalk.com" + path + "?" + urllib.parse.urlencode({"access_token": access_token}), data=json.dumps(payload).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(request, timeout=10) as response: return json.loads(response.read().decode("utf-8")) except Exception: # Organization data is supplemental to authentication. A temporary # directory API failure must not prevent an otherwise valid SSO login. return {} def _dingtalk_org_profile(profile: dict, access_token: str | None) -> dict: """Best-effort enrichment of a DingTalk profile with department data.""" data = dict(profile or {}) data["_org_data_available"] = False user_id = str(data.get("userid") or data.get("userId") or data.get("userid") or "").strip() if access_token and user_id: detail_response = _dingtalk_oapi_post( "/topapi/v2/user/get", access_token, {"userid": user_id, "language": "zh_CN"}, ) detail = detail_response.get("result") or detail_response.get("data") or {} if isinstance(detail, dict) and detail: data.update(detail) data.setdefault("userid", user_id) data["_org_data_available"] = True raw_dept_ids = data.get("dept_id_list") or data.get("deptIdList") or data.get("dept_ids") or [] if isinstance(raw_dept_ids, (str, int)): raw_dept_ids = [raw_dept_ids] dept_ids = [str(value).strip() for value in raw_dept_ids if str(value).strip()] data["_primary_dept_id"] = dept_ids[0] if dept_ids else "" data["_dept_name"] = "" data["_dept_path"] = "" data["_dept_name_path"] = "" if access_token and dept_ids: department_cache = {} candidates = [] for member_dept_id in dept_ids: current_id = member_dept_id chain = [] visited = set() while current_id and current_id not in visited and len(chain) < 20: visited.add(current_id) if current_id not in department_cache: department_cache[current_id] = _dingtalk_oapi_post( "/topapi/v2/department/get", access_token, {"dept_id": int(current_id) if current_id.isdigit() else current_id, "language": "zh_CN"}, ).get("result") or {} result = department_cache[current_id] if not isinstance(result, dict) or not result: break chain.append((current_id, str(result.get("name") or "").strip())) parent_id = result.get("parent_id") or result.get("parentId") current_id = str(parent_id).strip() if parent_id not in (None, "", 0, "0") else "" if chain: chain.reverse() candidates.append({ "dept_id": member_dept_id, "dept_name": chain[-1][1] or "", "dept_path": "," + ",".join(item[0] for item in chain) + ",", "dept_name_path": "/".join(item[1] for item in chain if item[1]), }) selected = next((item for item in candidates if _dingtalk_department_is_super_admin( item["dept_id"], item["dept_name"], item["dept_path"], item["dept_name_path"] )), None) selected = selected or next((item for item in candidates if _dingtalk_department_is_allowed( item["dept_id"], item["dept_name"], item["dept_path"], item["dept_name_path"] )), None) selected = selected or (candidates[0] if candidates else None) if selected: data["_primary_dept_id"] = selected["dept_id"] data["_dept_name"] = selected["dept_name"] data["_dept_path"] = selected["dept_path"] data["_dept_name_path"] = selected["dept_name_path"] leader_info = data.get("leader_in_dept") or data.get("leaderInDept") or [] is_manager = bool(data.get("is_dept_manager") or data.get("isDeptManager")) if isinstance(leader_info, bool): is_manager = is_manager or leader_info elif isinstance(leader_info, dict): is_manager = is_manager or bool(leader_info.get("is_leader") or leader_info.get("isLeader")) elif isinstance(leader_info, list): selected_dept_id = data.get("_primary_dept_id") matching_leaders = [ item for item in leader_info if isinstance(item, dict) and str(item.get("dept_id") or item.get("deptId") or "") == str(selected_dept_id or "") ] leader_candidates = matching_leaders or [item for item in leader_info if isinstance(item, dict)] is_manager = is_manager or any( bool(item.get("is_leader") or item.get("isLeader") or item.get("leader")) for item in leader_candidates ) data["_is_dept_manager"] = is_manager return data def _dingtalk_workbench_user(code: str) -> dict: """Resolve a workbench JSAPI requestAuthCode code.""" token_request = urllib.request.Request( "https://oapi.dingtalk.com/gettoken?" + urllib.parse.urlencode({ "appkey": settings.dingtalk_client_id, "appsecret": settings.dingtalk_client_secret, }), method="GET", ) try: with urllib.request.urlopen(token_request, timeout=10) as response: token_data = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace")[:500] raise HTTPException(status_code=502, detail=f"钉钉应用凭证校验失败:{detail}") from exc except Exception as exc: raise HTTPException(status_code=502, detail=f"钉钉应用凭证校验失败:{exc}") from exc access_token = token_data.get("access_token") if not access_token: raise HTTPException(status_code=502, detail="钉钉未返回应用访问令牌,请检查应用权限") user_request = urllib.request.Request( "https://oapi.dingtalk.com/topapi/v2/user/getuserinfo?" + urllib.parse.urlencode({"access_token": access_token}), data=json.dumps({"code": code}).encode("utf-8"), headers={"Content-Type": "application/json"}, method="POST", ) try: with urllib.request.urlopen(user_request, timeout=10) as response: user_data = json.loads(response.read().decode("utf-8")) except urllib.error.HTTPError as exc: detail = exc.read().decode("utf-8", errors="replace")[:500] raise HTTPException(status_code=502, detail=f"无法读取钉钉用户信息:{detail}") from exc except Exception as exc: raise HTTPException(status_code=502, detail=f"无法读取钉钉用户信息:{exc}") from exc if user_data.get("errcode", 0) not in (0, None): raise HTTPException(status_code=502, detail=f"钉钉用户信息读取失败:{user_data.get('errmsg', '请检查应用权限')}") result = user_data.get("result") or user_data if isinstance(result, dict): result = dict(result) result["_dingtalk_access_token"] = access_token return result def _dingtalk_create_session(profile: dict, db: Session, response: Response) -> AdminUser: union_id = str(profile.get("unionId") or profile.get("unionid") or profile.get("openId") or profile.get("userid") or profile.get("userId") or "").strip() if not union_id: raise HTTPException(status_code=502, detail="钉钉用户信息缺少唯一标识") username = f"dingtalk_{union_id}"[:64] user = db.scalar(select(AdminUser).where(AdminUser.username == username)) display_name = str(profile.get("nick") or profile.get("name") or "钉钉用户").strip()[:64] dingtalk_user_id = str(profile.get("userid") or profile.get("userId") or "").strip() or None dept_id = str(profile.get("_primary_dept_id") or "").strip() or None dept_name = str(profile.get("_dept_name") or profile.get("dept_name") or "").strip() or None dept_path = str(profile.get("_dept_path") or "").strip() or None dept_name_path = str(profile.get("_dept_name_path") or dept_name or "").strip() or None is_dept_manager = bool(profile.get("_is_dept_manager")) department_super_admin = _dingtalk_department_is_super_admin( dept_id, dept_name, dept_path, dept_name_path ) # Existing active rows form the explicit user-management allowlist. New # rows may be created only for the two authorized divisions or for a # department whose members are explicitly configured as administrators. if not user and not ( _dingtalk_department_is_allowed(dept_id, dept_name, dept_path, dept_name_path) or department_super_admin ): raise HTTPException(status_code=403, detail="当前账号不在系统开放部门,请联系管理员") role_code = ( "super_admin" if union_id in settings.dingtalk_super_admin_union_ids or department_super_admin else "dept_manager" if is_dept_manager else "operator" ) if not user: user = AdminUser(username=username, password_hash=hash_password(secrets.token_urlsafe(32)), display_name=display_name, role_code=role_code, dingtalk_user_id=dingtalk_user_id, dingtalk_union_id=union_id, dingtalk_corp_id=settings.dingtalk_corp_id or None, dingtalk_dept_id=dept_id, dingtalk_dept_name=dept_name, dingtalk_dept_path=dept_path, dingtalk_title=str(profile.get("title") or "").strip()[:255] or None, is_dept_manager=is_dept_manager) db.add(user) elif not user.is_active: raise HTTPException(status_code=403, detail="运营账号已被停用") else: user.display_name = display_name or user.display_name user.dingtalk_user_id = dingtalk_user_id or user.dingtalk_user_id user.dingtalk_union_id = union_id or user.dingtalk_union_id user.dingtalk_corp_id = settings.dingtalk_corp_id or user.dingtalk_corp_id if profile.get("_org_data_available"): user.dingtalk_dept_id = dept_id user.dingtalk_dept_name = dept_name user.dingtalk_dept_path = dept_path user.dingtalk_title = str(profile.get("title") or "").strip()[:255] or user.dingtalk_title user.is_dept_manager = is_dept_manager if union_id in settings.dingtalk_super_admin_union_ids or department_super_admin: user.role_code = "super_admin" elif is_dept_manager and user.role_code != "super_admin": user.role_code = "dept_manager" elif user.role_code == "dept_manager" and profile.get("_org_data_available"): user.role_code = "operator" user.last_login_at = datetime.utcnow() db.flush() db.add(AdminLoginLog(user_id=user.id, username=username, success=True)) db.commit(); db.refresh(user) response.set_cookie(settings.cookie_name, create_session_token(user.id, user.role_code), httponly=True, secure=settings.cookie_secure, samesite="lax", max_age=settings.session_hours * 3600) return user def _dingtalk_login(code: str, db: Session, response: Response) -> AdminUser: if not _dingtalk_configured(): raise HTTPException(status_code=503, detail="钉钉登录尚未配置,请联系管理员") code = code.strip() if not code: raise HTTPException(status_code=400, detail="钉钉授权码不能为空") token_data = _dingtalk_exchange_code(code) access_token = token_data.get("accessToken") or token_data.get("access_token") if not access_token: raise HTTPException(status_code=502, detail="钉钉未返回有效授权令牌") profile = _dingtalk_user(access_token) profile = _dingtalk_org_profile(profile, access_token) return _dingtalk_create_session(profile, db, response) def _can_access_ask_task(task: AskTask, user: AdminUser) -> bool: if user.role_code == "super_admin" or task.owner_id == user.id: return True if user.role_code != "dept_manager" or not user.dingtalk_dept_id or not task.owner: return False owner_path = task.owner.dingtalk_dept_path or "" return ( task.owner.dingtalk_dept_id == user.dingtalk_dept_id or f",{user.dingtalk_dept_id}," in owner_path ) def _require_ask_task_access(task: AskTask | None, user: AdminUser) -> AskTask: if not task or not _can_access_ask_task(task, user): raise HTTPException(status_code=404, detail="提问任务不存在") return task def _dedupe_draft_ask_questions(task: AskTask, db: Session) -> int: """Remove duplicate question text from a draft, keeping the first row. This repairs drafts created before the picker became idempotent. Published tasks are never changed, and a draft has no user assignments to orphan. """ seen: set[str] = set() duplicates: list[AskQuestion] = [] for question in sorted(task.questions, key=lambda value: (value.sequence_no, value.id)): key = re.sub(r"\s+", " ", str(question.question_text or "").strip()) if key and key in seen: duplicates.append(question) elif key: seen.add(key) for question in duplicates: db.delete(question) if duplicates: db.flush() db.expire(task, ["questions"]) return len(duplicates) def _ask_task_owner_clause(user: AdminUser): if user.role_code == "super_admin": return True if user.role_code != "dept_manager" or not user.dingtalk_dept_id: return AskTask.owner_id == user.id descendant_owners = select(AdminUser.id).where( or_( AdminUser.id == user.id, AdminUser.dingtalk_dept_path.like(f"%,{user.dingtalk_dept_id},%"), AdminUser.dingtalk_dept_id == user.dingtalk_dept_id, ) ) return AskTask.owner_id.in_(descendant_owners) def _can_access_answer_submission(record: AnswerSubmission | None, user: AdminUser) -> bool: if not record: return False if user.role_code == "super_admin" or record.owner_id == user.id: return True if record.owner_id is None: # Unassigned legacy records are visible only to the super administrator # so they cannot leak into every operator's queue. return False if user.role_code != "dept_manager" or not user.dingtalk_dept_id or not record.owner: return False return ( record.owner.dingtalk_dept_id == user.dingtalk_dept_id or f",{user.dingtalk_dept_id}," in (record.owner.dingtalk_dept_path or "") ) def _answer_owner_clause(user: AdminUser): if user.role_code == "super_admin": return True if user.role_code != "dept_manager" or not user.dingtalk_dept_id: return AnswerSubmission.owner_id == user.id descendant_owners = select(AdminUser.id).where( or_( AdminUser.id == user.id, AdminUser.dingtalk_dept_path.like(f"%,{user.dingtalk_dept_id},%"), AdminUser.dingtalk_dept_id == user.dingtalk_dept_id, ) ) return AnswerSubmission.owner_id.in_(descendant_owners) REFUND_UPLOAD_SESSION_TTL = timedelta(minutes=10) REFUND_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"} def _refund_session_target(session: RefundUploadSession, db: Session): """Resolve the exact payable record carried by a temporary upload token.""" if session.target_kind == "ask_batch": record = db.get(AskAssignmentBatch, session.target_id) return record, record.status == "approved" if record else False if session.target_kind == "ask_assignment": record = db.get(AskAssignment, session.target_id) return record, record.status == "已通过" if record else False if session.target_kind == "answer_submission": record = db.get(AnswerSubmission, session.target_id) return record, record.status == "已通过" if record else False return None, False def _refund_session_target_for_payload(target_kind: str, target_id: int, db: Session): # Avoid constructing a transient ORM object just to resolve the target; # this also keeps the query helper easy to reuse in tests. if target_kind == "ask_batch": record = db.get(AskAssignmentBatch, target_id) return record, record.status == "approved" if record else False if target_kind == "ask_assignment": record = db.get(AskAssignment, target_id) return record, record.status == "已通过" if record else False if target_kind == "answer_submission": record = db.get(AnswerSubmission, target_id) return record, record.status == "已通过" if record else False return None, False def _refund_session_expired(session: RefundUploadSession) -> bool: return session.expires_at <= datetime.utcnow() def _refund_screenshot_count(record, kind: str) -> int: screenshots = list(record.screenshots) return sum(1 for image in screenshots if image.stage == "refund") async def _read_refund_file(refund_file: UploadFile | None) -> tuple[bytes, str, str]: if refund_file is None: raise HTTPException(status_code=400, detail="请上传返款凭证,或先用手机扫码上传") if refund_file.content_type not in REFUND_IMAGE_TYPES: raise HTTPException(status_code=400, detail="返款凭证仅支持 JPG、PNG、WEBP 图片") data = await refund_file.read() if not data: raise HTTPException(status_code=400, detail="返款凭证不能为空") if len(data) > 10 * 1024 * 1024: raise HTTPException(status_code=400, detail="返款凭证不能超过 10MB") return data, refund_file.content_type, (refund_file.filename or "refund")[:255] def _refund_session_payload(session: RefundUploadSession, record) -> dict: return { "token": session.token, "target_kind": session.target_kind, "target_id": session.target_id, "expires_at": session.expires_at.isoformat(), "uploaded": bool(session.uploaded_at) or _refund_screenshot_count(record, session.target_kind) > 0, "used": bool(session.used_at), } def _new_ask_public_token(db: Session) -> str: while True: token = secrets.token_urlsafe(24) if not db.scalar(select(AskTask.id).where(AskTask.public_token == token)): return token def _new_answer_public_token(db: Session) -> str: while True: token = secrets.token_urlsafe(32) if not db.scalar(select(AdminUser.id).where(AdminUser.answer_public_token == token)): return token def _answer_link_owner(token: str, db: Session) -> AdminUser: normalized = str(token or "").strip() if not normalized: raise HTTPException(status_code=400, detail="回答任务专属链接无效") owner = db.scalar( select(AdminUser).where( AdminUser.answer_public_token == normalized, AdminUser.is_active.is_(True), ) ) if not owner: raise HTTPException(status_code=404, detail="回答任务专属链接无效或已停用") return owner def _published_ask_task(public_token: str, db: Session, *, lock: bool = False) -> AskTask: statement = select(AskTask).where( # New links use a random public token. Keep the task number as a # read-compatible alias for links that were already sent before this # change; all newly generated links use public_token. (AskTask.public_token == public_token) | (AskTask.task_no == public_token), AskTask.status == "已发布", ) # Serialize allocation for the same task. This prevents two users who # open the link at the same moment from receiving the same question. if lock: statement = statement.with_for_update() task = db.scalar(statement) if not task: raise HTTPException(status_code=404, detail="提问任务链接无效、未发布或已结束") return task def _ask_screenshot_url( image: AskAssignmentScreenshot | AskAssignmentBatchScreenshot, image_kind: str, use_endpoint: bool, ) -> str: if use_endpoint: return f"/api/admin/ask-{image_kind}-screenshots/{image.id}" return screenshot_data_url(image) def _refund_recipient_data(record, target_kind: str) -> dict: """Expose the payout identity saved alongside a user submission.""" db = object_session(record) if db is None: return {"alipay_account": "", "payee_name": ""} recipient = db.scalar( select(RefundRecipient).where( RefundRecipient.target_kind == target_kind, RefundRecipient.target_id == record.id, ) ) return { "alipay_account": recipient.account if recipient else "", "payee_name": recipient.payee_name if recipient else "", } def _ask_assignment_data( assignment: AskAssignment, include_images: bool = True, include_refund_images: bool = False, image_endpoints: bool = False, ) -> dict: items = sorted(assignment.items, key=lambda item: item.question.sequence_no) used_ids = {item.question_id for item in assignment.used_questions} # List endpoints must not touch the screenshot relationship. The image # BLOBs are large and loading them for every row was the main cause of the # multi-second task-board/review-center delay. screenshots = list(assignment.screenshots) if include_images or (include_refund_images and assignment.status == "已完成") else [] question_screenshots = [item for item in screenshots if item.stage == "question"] payment_screenshots = [item for item in screenshots if item.stage == "payment"] refund_screenshots = [item for item in screenshots if item.stage == "refund"] latest_review = ( max(assignment.review_decisions, key=lambda item: item.created_at) if assignment.review_decisions else None ) # Backfill the read model for approvals created before the used-question # table was introduced. New approvals are persisted in that table below. if assignment.status in {"待审核", "已通过"}: # Submission itself consumes the selected questions. The fallback # also makes records created before the used-question table behave # correctly without a data migration. used_ids.update(item.question_id for item in items if item.selected) return { "assignment_id": assignment.id, "status": assignment.status, "max_questions": len(items), "used_count": len(used_ids), "remaining_count": max(len(items) - len(used_ids), 0), "task_id": assignment.task.task_no, "shop_name": assignment.task.shop_name, "product_name": assignment.task.product_name, "product_link": assignment.task.product_link, "product_qr": _product_qr_data(assignment.task), "creator_name": ( assignment.task.owner.display_name if assignment.task.owner else "运营后台" ), "user_id": _public_user_id(assignment.user), "taobao_id": assignment.user.taobao_id, "phone": _public_user_account(assignment.user), **(_refund_recipient_data(assignment, "ask_assignment") if include_images else {"alipay_account": "", "payee_name": ""}), "created_at": assignment.created_at.isoformat(), "updated_at": assignment.updated_at.isoformat(), "expires_at": ( _ask_assignment_expires_at(assignment).isoformat() if _ask_assignment_expires_at(assignment) else None ), "question_screenshots": [{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "assignment", image_endpoints)} for image in question_screenshots] if include_images else [], "payment_screenshots": [{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "assignment", image_endpoints)} for image in payment_screenshots] if include_images else [], "refund_screenshots": [{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "assignment", image_endpoints)} for image in refund_screenshots] if include_images or include_refund_images else [], "refund_completed": assignment.status == "已完成" or bool(refund_screenshots), "latest_review": ({ "approved": latest_review.approved, "reason_code": latest_review.reason_code, "reason_text": latest_review.reason_text, "created_at": latest_review.created_at.isoformat(), } if latest_review else None), "questions": [{ "id": item.question.id, "sequence_no": item.question.sequence_no, "question": item.question.question_text, "selected": item.selected, "used": item.question_id in used_ids, } for item in items], } def _ask_batch_data( batch: AskAssignmentBatch, include_images: bool = True, include_refund_images: bool = False, image_endpoints: bool = False, ) -> dict: """Serialize one independent submission batch for review/history views.""" assignment = batch.assignment # Batch screenshots are authoritative for batch history. Avoid touching the # legacy assignment screenshot relationship while serializing a batch. parent = _ask_assignment_data(assignment, include_images=False, include_refund_images=False) try: selected_ids = {int(value) for value in (batch.selected_question_ids or "").split(",") if value} except ValueError: selected_ids = set() used_ids = {item.question_id for item in assignment.used_questions} latest_review = ( max(batch.review_decisions, key=lambda item: item.created_at) if batch.review_decisions else None ) screenshots = list(batch.screenshots) if include_images or (include_refund_images and batch.status == "paid") else [] question_images = [item for item in screenshots if item.stage == "question"] payment_images = [item for item in screenshots if item.stage == "payment"] refund_images = [item for item in screenshots if item.stage == "refund"] questions = [] for item in sorted(assignment.items, key=lambda value: value.question.sequence_no): questions.append({ "id": item.question.id, "sequence_no": item.question.sequence_no, "question": item.question.question_text, "selected": item.question_id in selected_ids, "used": item.question_id in used_ids, }) data = { **parent, "assignment_id": batch.id, "batch_id": batch.id, "parent_assignment_id": assignment.id, # A paid batch remains an approved audit record in the review center; # payout completion is represented separately on the task board. "status": {"pending": "待审核", "approved": "已通过", "paid": "已完成", "rejected": "未通过"}.get(batch.status, batch.status), "created_at": batch.created_at.isoformat(), "updated_at": batch.updated_at.isoformat(), **(_refund_recipient_data(batch, "ask_batch") if include_images else {"alipay_account": "", "payee_name": ""}), "questions": questions, "question_screenshots": [ {"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "batch", image_endpoints)} for image in question_images ] if include_images else [], "payment_screenshots": [ {"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "batch", image_endpoints)} for image in payment_images ] if include_images else [], "refund_screenshots": [ {"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "batch", image_endpoints)} for image in refund_images ] if include_images or include_refund_images else [], "refund_completed": batch.status == "paid" or bool(refund_images), "latest_review": ({ "approved": latest_review.approved, "reason_code": latest_review.reason_code, "reason_text": latest_review.reason_text, "created_at": latest_review.created_at.isoformat(), } if latest_review else None), } return data def _sync_ask_assignment_status(assignment: AskAssignment) -> None: """Keep the aggregate assignment status aligned with its latest batch.""" if not assignment.batches: return # Batch order means submission order; a later review decision must not # make an older batch replace the user's newest pending batch. latest = max(assignment.batches, key=lambda value: value.created_at) assignment.status = { "pending": "待审核", "approved": "已通过", "paid": "已通过", "rejected": "未通过", }.get(latest.status, latest.status) assignment.reservation_started_at = None assignment.updated_at = datetime.utcnow() def _image_url(image: AnswerScreenshot, use_endpoint: bool = False) -> str: if use_endpoint: return f"/api/admin/answer-screenshots/{image.id}" return screenshot_data_url(image) LEGACY_FAKE_OCR_QUESTIONS = { "这款商品使用起来感觉怎么样?", "敏感肌可以使用这款商品吗?", "使用后的肤感和效果怎么样?", } def _has_legacy_fake_ocr_questions(record: AnswerSubmission) -> bool: questions = list(record.questions) return bool(questions) and len(questions) <= 3 and all( question.product_title == "待识别商品" and question.question_text in LEGACY_FAKE_OCR_QUESTIONS for question in questions ) def _submission_data( record: AnswerSubmission, include_images: bool = True, include_refund_images: bool = False, image_endpoints: bool = False, ) -> dict: # List endpoints deliberately omit screenshot BLOBs. Do not even touch # the lazy relationship in that mode, otherwise SQLAlchemy fetches every # image from remote MySQL before returning the lightweight response. screenshots = list(record.screenshots) if include_images or (include_refund_images and record.status == "已完成") else [] invited = [x for x in screenshots if x.stage == "invited"] completion = [x for x in screenshots if x.stage == "completion"] payment = [x for x in screenshots if x.stage == "payment"] refund = [x for x in screenshots if x.stage == "refund"] images = {x.id: x for x in screenshots} latest_review = max(record.review_decisions, key=lambda value: value.created_at) if record.review_decisions else None return { "id": record.id, "submission_no": record.submission_no, "user_id": _public_user_id(record.user), "taobao_id": record.user.taobao_id, "phone": _public_user_account(record.user), **(_refund_recipient_data(record, "answer_submission") if include_images else {"alipay_account": "", "payee_name": ""}), "shop_name": record.shop_name or "", "status": record.status, "recognition_failed": record.status == "识别失败" or _has_legacy_fake_ocr_questions(record), "created_at": record.created_at.isoformat(), "updated_at": record.updated_at.isoformat(), "invited_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in invited] if include_images else [], "completion_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in completion] if include_images else [], "payment_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in payment] if include_images else [], "refund_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in refund] if include_images or include_refund_images else [], "refund_completed": record.status == "已完成" or bool(refund), "latest_review": ({ "approved": latest_review.approved, "reason_code": latest_review.reason_code, "reason_text": latest_review.reason_text, "created_at": latest_review.created_at.isoformat(), } if latest_review else None), "questions": [{ "id": q.id, "sequence_no": q.sequence_no, "product_title": q.product_title, "product_image": _image_url(images[q.product_image_screenshot_id], image_endpoints) if include_images and q.product_image_screenshot_id in images else None, "source_image": _image_url(images[q.source_screenshot_id], image_endpoints) if include_images and q.source_screenshot_id in images else None, "operator_images": [ _image_url(images[int(image_id)], image_endpoints) for image_id in ((q.operator_image_screenshot_ids or str(q.operator_image_screenshot_id or "")).split(",")) if image_id.strip().isdigit() and int(image_id) in images ] if include_images else [], "question": q.question_text, "answer": q.answer_text, "answer_status": q.answer_status, "selected": q.selected, } for q in sorted(record.questions, key=lambda value: value.sequence_no)], } def _task_board_data(record: AnswerSubmission, include_images: bool = True, image_endpoints: bool = False) -> dict: selected = [q for q in sorted(record.questions, key=lambda value: value.sequence_no) if q.selected] product_titles = list(dict.fromkeys(q.product_title for q in selected if q.product_title)) summary = _submission_data(record, include_images=True, image_endpoints=image_endpoints) if include_images else { "id": record.id, "submission_no": record.submission_no, "user_id": _public_user_id(record.user), "taobao_id": record.user.taobao_id, "phone": _public_user_account(record.user), "shop_name": record.shop_name or "", "status": record.status, "created_at": record.created_at.isoformat(), "updated_at": record.updated_at.isoformat(), "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "refund_screenshots": [], "refund_completed": record.status == "已完成", "latest_review": None, "questions": [], } status_labels = { "待用户完成": "进行中", "待审核": "待审核", # Passing the content audit means the submission is ready for the # finance check; it is not fully complete until the refund is handled. "已通过": "待返款", "已完成": "已完成", "未通过": "进行中", "已撤销": "已撤销", } # "待审核" and "待返款" mean the user has submitted evidence, not that # the work is finished. The board's 完成 column must only count records # whose refund workflow has reached 已完成. completed_count = len(selected) if record.status == "已完成" else 0 return { **summary, "board_key": f"answer-{record.id}", "record_kind": "answer_submission", "task_id": record.submission_no, "task_type": "answer", "task_type_label": "回答任务", "status_label": status_labels.get(record.status, record.status), "shop_name": record.shop_name or "", "product_name": " / ".join(product_titles) or "待识别商品", "creator_name": record.owner.display_name if record.owner else "运营后台", "qa_count": len(selected), "claimed_count": 1, "submitted_count": 1 if record.status in {"待审核", "已通过", "已完成"} else 0, "pending_review_count": 1 if record.status == "待审核" else 0, "completed_count": completed_count, "total_count": len(selected), "can_cancel": record.status in {"待用户完成", "待审核", "未通过"}, "can_delete": True, "delete_endpoint": f"/api/admin/task-board/{record.id}", "refund_endpoint": f"/api/admin/task-board/{record.id}/confirm-refund" if record.status == "已通过" else None, "refund_screenshot_count": len(summary.get("refund_screenshots") or []) or (1 if record.status == "已完成" else 0), "assigned_users": [{"user_id": _public_user_id(record.user), "phone": _public_user_account(record.user), "taobao_id": record.user.taobao_id, "status": status_labels.get(record.status, record.status)}], "selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": q.answer_text, "product_title": q.product_title} for q in selected] if include_images else []), } def _ask_assignment_child_data(assignment: AskAssignment) -> dict: """Return a compact user-claim row nested under its system ask task.""" task = assignment.task selected_ids = { item.question_id for item in assignment.items if item.selected } used_ids = {item.question_id for item in assignment.used_questions} latest_batch = max(assignment.batches, key=lambda value: value.updated_at or value.created_at, default=None) # A batch can only enter ``paid`` through the refund endpoint, which now # requires a refund screenshot. Keep the board list lightweight and do # not load image BLOBs just to render this indicator. refund_proof_uploaded = bool(latest_batch and latest_batch.status == "paid") or assignment.status == "已完成" batch_selected_ids = set() if latest_batch and latest_batch.selected_question_ids: batch_selected_ids = { int(value) for value in latest_batch.selected_question_ids.split(",") if value.strip().isdigit() } question_count = len(batch_selected_ids or selected_ids or used_ids) refund_completed = bool(latest_batch and latest_batch.status == "paid") or assignment.status == "已完成" refund_pending = bool(latest_batch and latest_batch.status == "approved") or assignment.status == "已通过" if refund_completed: status_label = "已完成" elif refund_pending: status_label = "待返款" elif assignment.status == "待审核" or (latest_batch and latest_batch.status == "pending"): status_label = "待审核" elif assignment.status == "已撤销": status_label = "已撤销" else: status_label = "进行中" detail_endpoint = None refund_endpoint = None if latest_batch and latest_batch.status in {"approved", "paid"}: detail_endpoint = f"/api/admin/task-board/ask-batches/{latest_batch.id}" if not refund_completed: refund_endpoint = f"/api/admin/task-board/ask-batches/{latest_batch.id}/confirm-refund" elif assignment.status in {"已通过", "已完成"} and not assignment.batches: detail_endpoint = f"/api/admin/task-board/ask-assignments/{assignment.id}" if not refund_completed: refund_endpoint = f"/api/admin/task-board/ask-assignments/{assignment.id}/confirm-refund" return { "id": assignment.id, "board_key": f"ask-assignment-{assignment.id}", "record_kind": "ask_assignment", "is_child": True, "task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务", "product_name": task.product_name, "shop_name": task.shop_name, "product_link": task.product_link, "status": assignment.status, "status_label": status_label, "user_id": _public_user_id(assignment.user), "taobao_id": assignment.user.taobao_id, "phone": _public_user_account(assignment.user), "qa_count": question_count, "claimed_count": question_count, "submitted_count": len(used_ids), "pending_review_count": 1 if status_label == "待审核" else 0, # Used questions have been submitted, but remain incomplete until the # operator confirms the refund (the child status becomes 已完成). "completed_count": question_count if refund_completed else 0, "total_count": question_count, "creator_name": task.owner.display_name if task.owner else "运营后台", "created_at": assignment.created_at.isoformat(), "updated_at": assignment.updated_at.isoformat(), "can_cancel": False, "can_complete": False, "can_delete": False, "detail_endpoint": detail_endpoint, "refund_endpoint": refund_endpoint, "assigned_users": [{"user_id": _public_user_id(assignment.user), "phone": _public_user_account(assignment.user), "taobao_id": assignment.user.taobao_id, "status": status_label}], "selected_questions": [], "question_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "refund_screenshots": [], "refund_proof_uploaded": refund_proof_uploaded, "refund_proof_count": 1 if refund_proof_uploaded else 0, "latest_review": None, } def _ask_batch_child_data(batch: AskAssignmentBatch) -> dict: """Return one compact board row for one independent user submission. A user may submit an assignment in several batches. The parent assignment is still the user's question pool, but each batch must remain visible as its own audit/payout row instead of the latest batch replacing the previous one. """ assignment = batch.assignment task = assignment.task try: selected_ids = { int(value) for value in (batch.selected_question_ids or "").split(",") if value.strip().isdigit() } except (TypeError, ValueError): selected_ids = set() items_by_id = {item.question_id: item for item in assignment.items} selected_items = [ items_by_id[question_id] for question_id in selected_ids if question_id in items_by_id ] question_count = len(selected_items) status_label = { "pending": "待审核", "approved": "待返款", "paid": "已完成", # Rejected batches can be submitted again, so keep them in the # existing board's "进行中" bucket while retaining their history. "rejected": "进行中", }.get(batch.status, "进行中") refund_completed = batch.status == "paid" selected_questions = [ { "id": item.question.id, "sequence_no": item.question.sequence_no, "question": item.question.question_text, "answer": "", "product_title": task.product_name, } for item in sorted(selected_items, key=lambda value: value.question.sequence_no) ] detail_endpoint = ( f"/api/admin/task-board/ask-batches/{batch.id}" if batch.status in {"approved", "paid"} else None ) return { "id": batch.id, "batch_id": batch.id, "parent_assignment_id": assignment.id, "board_key": f"ask-batch-{batch.id}", "record_kind": "ask_batch", "is_child": True, "task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务", "product_name": task.product_name, "shop_name": task.shop_name, "product_link": task.product_link, "status": batch.status, "status_label": status_label, "user_id": _public_user_id(assignment.user), "taobao_id": assignment.user.taobao_id, "phone": _public_user_account(assignment.user), "qa_count": question_count, "claimed_count": question_count, "submitted_count": question_count if batch.status in {"pending", "approved", "paid"} else 0, "pending_review_count": 1 if batch.status == "pending" else 0, "completed_count": question_count if refund_completed else 0, "total_count": question_count, "creator_name": task.owner.display_name if task.owner else "运营后台", "created_at": batch.created_at.isoformat(), "updated_at": batch.updated_at.isoformat(), "can_cancel": False, "can_complete": False, "can_delete": True, "detail_endpoint": detail_endpoint, "delete_endpoint": f"/api/admin/task-board/ask-batches/{batch.id}", "refund_endpoint": ( f"/api/admin/task-board/ask-batches/{batch.id}/confirm-refund" if batch.status == "approved" else None ), "assigned_users": [{ "user_id": _public_user_id(assignment.user), "phone": _public_user_account(assignment.user), "taobao_id": assignment.user.taobao_id, "status": status_label, }], "selected_questions": selected_questions, "question_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "refund_screenshots": [], "refund_proof_uploaded": refund_completed, "refund_proof_count": 1 if refund_completed else 0, "latest_review": None, } def _ask_task_is_fully_completed(task: AskTask) -> bool: selected_ids = {question.id for question in task.questions if question.selected} if not selected_ids: return False completed_ids: set[int] = set() for assignment in task.assignments: for batch in assignment.batches: if batch.status == "paid": completed_ids.update(_batch_question_ids(batch)) if not assignment.batches and assignment.status == "已完成": completed_ids.update(item.question_id for item in assignment.items if item.selected) return selected_ids.issubset(completed_ids) def _sync_ask_task_status(task: AskTask) -> None: if task.status != "已撤销": task.status = "已完成" if _ask_task_is_fully_completed(task) else "已发布" task.updated_at = datetime.utcnow() def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: questions = sorted(task.questions, key=lambda value: value.sequence_no) selected_questions = [q for q in questions if q.selected] question_claimants = { item.question_id: assignment.user for assignment in task.assignments for item in assignment.items } # Keep every submission batch as an independent child row. Falling back # to the assignment row preserves compatibility with legacy assignments # created before batch submissions were introduced. claimed_tasks = [] for assignment in task.assignments: if assignment.batches: ordered_batches = sorted( assignment.batches, key=lambda value: value.created_at or datetime.min, ) for batch_no, batch in enumerate(ordered_batches, start=1): child = _ask_batch_child_data(batch) child["batch_no"] = batch_no child["batch_label"] = f"第{batch_no}批提交" claimed_tasks.append(child) else: claimed_tasks.append(_ask_assignment_child_data(assignment)) claimed_tasks.sort( key=lambda value: value.get("updated_at") or value.get("created_at") or "", reverse=True, ) assigned_count = len(task.assignments) submitted_count = sum(len(assignment.used_questions) for assignment in task.assignments) # Keep the parent status based on whether all questions have been claimed; # the 完成 column is deliberately stricter and only counts refunded rows. completed_count = sum(child["completed_count"] for child in claimed_tasks) pending_count = sum(child["pending_review_count"] for child in claimed_tasks) fully_completed = _ask_task_is_fully_completed(task) if task.status == "已撤销": status_label = "已撤销" elif fully_completed: status_label = "已完成" else: status_label = "进行中" return { "id": task.id, "board_key": f"ask-task-{task.id}", "record_kind": "ask_task", "task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务", "public_token": task.public_token, "shop_name": task.shop_name, "product_name": task.product_name, "product_link": task.product_link, # Keep the persisted task state alongside the display label. The # create-task page uses this value to switch to the publish-success # view immediately after the publish request completes. The label is # intentionally derived for the board (a fully claimed task may show # as completed while its persisted workflow state remains published). "status": task.status, # The board list is intentionally lightweight; the QR is included in # task detail/public assignment responses where it is actually used. "product_qr": _product_qr_data(task) if include_questions else None, "claim_questions_per_user": task.claim_questions_per_user or 5, "status_label": status_label, "qa_count": len(selected_questions), "claimed_count": assigned_count, "submitted_count": submitted_count, "pending_review_count": pending_count, "completed_count": completed_count, "total_count": len(selected_questions), "refund_screenshot_count": sum(child.get("refund_proof_count", 0) for child in claimed_tasks), "creator_name": task.owner.display_name if task.owner else "运营后台", "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(), "can_cancel": task.status == "已发布", "can_complete": False, "can_delete": True, "delete_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}", # A system-dispatched task is created by an operator rather than an # answer user, so show the creator in the user-id column instead of # the ambiguous placeholder "--". Claimed child rows still expose # the actual answer user's public ID. "user_id": task.owner.display_name if task.owner else "运营后台", "phone": "", "questions": [], "selected_questions": ([{ "id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": "", "product_title": task.product_name, "source": q.source or "AI生成", "user_id": ( _public_user_id(question_claimants[q.id]) if q.id in question_claimants else "" ), "phone": ( _public_user_account(question_claimants[q.id]) if q.id in question_claimants else "" ), } for q in selected_questions] if include_questions else []), # One user can contribute several submission batches. Keep the # parent detail summary as a unique-user list; the child rows below # still expose every batch independently. "assigned_users": list({ child["assigned_users"][0]["user_id"]: child["assigned_users"][0] for child in claimed_tasks if child.get("assigned_users") }.values()), "claimed_tasks": claimed_tasks, "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None, "detail_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}" if not include_questions else None, } def _answer_review_data( record: AnswerSubmission, include_images: bool = True, image_endpoints: bool = False, ) -> dict: """Keep completed refunds visible as approved audit history.""" data = _submission_data( record, include_images=include_images, image_endpoints=image_endpoints, ) if record.status == "已完成": data["status"] = "已通过" data["refund_completed"] = True else: data["refund_completed"] = False return data def _ask_review_board_data( record: AskAssignmentBatch | AskAssignment, include_images: bool = False, image_endpoints: bool = False, ) -> dict: """Convert one approved ask submission into an independent payout row.""" is_batch = isinstance(record, AskAssignmentBatch) assignment = record.assignment if is_batch else record if include_images: data = _ask_batch_data(record, include_images=True, include_refund_images=True, image_endpoints=image_endpoints) if is_batch else _ask_assignment_data(record, include_images=True, include_refund_images=True, image_endpoints=image_endpoints) selected = [question for question in data["questions"] if question["selected"]] elif is_batch: selected_ids = {int(value) for value in (record.selected_question_ids or "").split(",") if value} selected = [] data = { "assignment_id": record.id, "status": "已通过", "task_id": assignment.task.task_no, "shop_name": assignment.task.shop_name, "product_name": assignment.task.product_name, "product_link": assignment.task.product_link, "product_qr": _product_qr_data(assignment.task), "user_id": _public_user_id(assignment.user), "taobao_id": assignment.user.taobao_id, "phone": _public_user_account(assignment.user), "created_at": record.created_at.isoformat(), "updated_at": record.updated_at.isoformat(), "questions": [], "question_screenshots": [], "payment_screenshots": [], "latest_review": None, } else: data = _ask_assignment_data(record, include_images=False, include_refund_images=True) selected = [question for question in data["questions"] if question["selected"]] selected_ids = {question["id"] for question in selected} data["questions"] = [] refund_completed = record.status == "paid" if is_batch else record.status == "已完成" selected_count = len(selected) if include_images else len(selected_ids) return { **data, "id": record.id, "board_key": f"ask-{'batch' if is_batch else 'assignment'}-{record.id}", "record_kind": "ask_batch" if is_batch else "ask_assignment", "task_type": "ask", "task_type_label": "提问任务", "status_label": "已完成" if refund_completed else "待返款", "shop_name": assignment.task.shop_name, "product_name": assignment.task.product_name, "qa_count": selected_count, "claimed_count": selected_count, "submitted_count": selected_count, "pending_review_count": 0, "completed_count": selected_count if refund_completed else 0, "total_count": selected_count, "creator_name": "运营后台", "can_cancel": False, "can_delete": True, "delete_endpoint": ( f"/api/admin/task-board/ask-batches/{record.id}" if is_batch else f"/api/admin/task-board/ask-assignments/{record.id}" ), "user_id": _public_user_id(assignment.user), "taobao_id": assignment.user.taobao_id, "phone": _public_user_account(assignment.user), "assigned_users": [{ "user_id": _public_user_id(assignment.user), "phone": _public_user_account(assignment.user), "taobao_id": assignment.user.taobao_id, "status": "已完成" if refund_completed else "待返款", }], "selected_questions": ([{ "id": question["id"], "sequence_no": question["sequence_no"], "question": question["question"], "answer": "", "product_title": assignment.task.product_name, } for question in selected] if include_images else []), "detail_endpoint": ( f"/api/admin/task-board/ask-batches/{record.id}" if is_batch else f"/api/admin/task-board/ask-assignments/{record.id}" ), "refund_endpoint": ( None if refund_completed else ( f"/api/admin/task-board/ask-batches/{record.id}/confirm-refund" if is_batch else f"/api/admin/task-board/ask-assignments/{record.id}/confirm-refund" ) ), } @app.get("/api/admin/auth/answer-link") def admin_answer_link(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): """Return the current operator's stable public answer-task URL token.""" stored_user = db.get(AdminUser, user.id) if not stored_user: raise HTTPException(status_code=401, detail="登录状态已失效,请重新登录") if not stored_user.answer_public_token: stored_user.answer_public_token = _new_answer_public_token(db) db.commit() db.refresh(stored_user) return {"success": True, "data": { "token": stored_user.answer_public_token, "path": f"/answer/{stored_user.answer_public_token}", "last_shop_name": stored_user.last_answer_shop_name or "", }} @app.post("/api/answer/auth/phone-login") def answer_phone_login(payload: dict, db: Session = Depends(get_db)): # Validate the link before issuing a user session. The token is carried # by the client on subsequent requests so a phone number can safely use # more than one operator's link without changing the user account model. owner_token = str(payload.get("owner_token") or payload.get("operator_token") or "").strip() _answer_link_owner(owner_token, db) session = _public_phone_session(_phone_login(payload, db)) session["owner_token"] = owner_token return {"success": True, "data": session} @app.post("/api/answer/auth/taobao-login") def answer_taobao_login(payload: dict, db: Session = Depends(get_db)): owner_token = str(payload.get("owner_token") or payload.get("operator_token") or "").strip() _answer_link_owner(owner_token, db) session = _public_phone_session(_taobao_login(payload, db)) session["owner_token"] = owner_token return {"success": True, "data": session} @app.post("/api/answer/auth/send-code") def answer_send_code(payload: dict, db: Session = Depends(get_db)): owner_token = str(payload.get("owner_token") or payload.get("operator_token") or "").strip() _answer_link_owner(owner_token, db) return {"success": True, "data": _send_phone_code(payload)} @app.post("/api/ask/auth/phone-login") def ask_phone_login(payload: dict, db: Session = Depends(get_db)): return {"success": True, "data": _public_phone_session(_phone_login(payload, db))} @app.post("/api/ask/auth/taobao-login") def ask_taobao_login(payload: dict, db: Session = Depends(get_db)): return {"success": True, "data": _public_phone_session(_taobao_login(payload, db))} @app.post("/api/ask/auth/send-code") def ask_send_code(payload: dict): return {"success": True, "data": _send_phone_code(payload)} def _start_ask_reservation(db: Session, task: AskTask, assignment: AskAssignment) -> bool: """Prepare the user's next selectable batch without touching old batches.""" used_ids = {item.question_id for item in assignment.used_questions} claim_limit = task.claim_questions_per_user or 5 if len(used_ids) >= claim_limit: return False existing_ids = {item.question_id for item in assignment.items} allocated_elsewhere = set(db.scalars( select(AskAssignmentQuestion.question_id) .join(AskAssignment, AskAssignmentQuestion.assignment_id == AskAssignment.id) .where(AskAssignment.task_id == task.id, AskAssignment.id != assignment.id) ).all()) needed = max(claim_limit - len(existing_ids), 0) available = [ question for question in task.questions if question.selected and question.id not in existing_ids and question.id not in allocated_elsewhere ] for question in random.sample(available, min(needed, len(available))): db.add(AskAssignmentQuestion( assignment_id=assignment.id, question_id=question.id, selected=False, )) db.flush() db.expire(assignment, ["items"]) active_items = [item for item in assignment.items if item.question_id not in used_ids] if not active_items: return False for item in active_items: item.selected = False assignment.status = "待选择" assignment.reservation_started_at = datetime.utcnow() assignment.updated_at = assignment.reservation_started_at return True @app.get("/api/ask/tasks/{task_no}") def public_ask_task(task_no: str, token: str, refresh: bool = False, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) completed_task = db.scalar(select(AskTask).where( (AskTask.public_token == task_no) | (AskTask.task_no == task_no), AskTask.status == "已完成", )) if completed_task: completed_assignment = db.scalar(select(AskAssignment).where( AskAssignment.task_id == completed_task.id, AskAssignment.user_id == user.id, )) if completed_assignment: raise HTTPException( status_code=409, detail="本次提问任务已全部完成,可查看历史状态", ) task = _published_ask_task(task_no, db, lock=True) current_assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) current_expired = _ask_assignment_expired(current_assignment) released = _release_expired_ask_assignments(db, task) if released: db.commit() db.expire_all() if current_expired: raise HTTPException(status_code=409, detail="提问任务已失效,请点击刷新重新分配任务") if released: task = _published_ask_task(task_no, db, lock=True) assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) if not assignment: # Keep each question reserved while the user is working. Expired # unfinished reservations have already been removed above, so these # are the currently allocated questions that must stay excluded. allocated_ids = set(db.scalars( select(AskAssignmentQuestion.question_id) .join(AskAssignment, AskAssignmentQuestion.assignment_id == AskAssignment.id) .where(AskAssignment.task_id == task.id) ).all()) available = [ question for question in task.questions if question.selected and question.id not in allocated_ids ] if not available: raise HTTPException( status_code=409, detail="本次任务已被领取完,请关注下一个任务!", ) claim_limit = task.claim_questions_per_user or 5 assigned = random.sample(available, min(claim_limit, len(available))) assignment = AskAssignment( task_id=task.id, user_id=user.id, status="待选择", reservation_started_at=datetime.utcnow(), ) db.add(assignment); db.flush() for question in assigned: db.add(AskAssignmentQuestion(assignment_id=assignment.id, question_id=question.id, selected=False)) db.commit(); db.refresh(assignment) elif refresh and assignment.status in {"已通过", "已完成"}: if not _start_ask_reservation(db, task, assignment): raise HTTPException(status_code=409, detail="本次任务已被领取完,请关注下一个任务!") db.commit(); db.refresh(assignment) latest_batch = db.scalar( select(AskAssignmentBatch) .where(AskAssignmentBatch.assignment_id == assignment.id) .order_by(AskAssignmentBatch.created_at.desc()) ) # While the user is selecting a new batch, the parent assignment contains # the current selection. Do not replace it with the previous submitted # batch or a refresh would appear to undo the user's choices. if latest_batch and assignment.status not in {"待选择", "进行中"}: data = _ask_batch_data(latest_batch, include_images=False, include_refund_images=latest_batch.status == "paid") db.rollback() return {"success": True, "data": data} data = _ask_assignment_data(assignment, include_images=False) db.rollback() return {"success": True, "data": data} @app.post("/api/ask/tasks/{task_no}/release") def release_expired_public_ask_assignment(task_no: str, payload: dict, db: Session = Depends(get_db)): """Release an expired reservation without allocating a replacement yet.""" user = _answer_user_from_token(str(payload.get("token", "")), db) task = _published_ask_task(task_no, db) assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) if assignment and _ask_assignment_expired(assignment): _release_ask_assignment_reservation(db, assignment) db.commit() return {"success": True, "data": {"released": True}} db.rollback() return {"success": True, "data": {"released": False}} @app.get("/api/ask/me/assignments") def public_ask_assignments(token: str, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) if _release_expired_ask_assignments(db): db.commit() records = db.scalars( select(AskAssignment) .where(AskAssignment.user_id == user.id) .order_by(AskAssignment.updated_at.desc()) ).unique().all() data = [] for record in records: batches = sorted(record.batches, key=lambda value: value.created_at, reverse=True) if batches: data.extend(_ask_batch_data(batch, include_images=False, include_refund_images=batch.status == "paid") for batch in batches) else: data.append(_ask_assignment_data(record, include_images=False, include_refund_images=record.status == "已完成")) db.rollback() return {"success": True, "data": data} @app.post("/api/ask/tasks/{task_no}/selection") def select_public_ask_questions(task_no: str, payload: dict, db: Session = Depends(get_db)): user = _answer_user_from_token(str(payload.get("token", "")), db) task = _published_ask_task(task_no, db) current_assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) current_expired = _ask_assignment_expired(current_assignment) if _release_expired_ask_assignments(db, task): db.commit() if current_expired: raise HTTPException(status_code=409, detail="提问任务已失效,请点击刷新重新分配任务") assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) if not assignment: raise HTTPException(status_code=409, detail="请先领取提问任务") try: selected_ids = {int(value) for value in payload.get("question_ids", [])} except (TypeError, ValueError): raise HTTPException(status_code=400, detail="问题选择数据无效") assigned_ids = {item.question_id for item in assignment.items} if not selected_ids: raise HTTPException(status_code=400, detail="请至少选择一个问题") if not selected_ids.issubset(assigned_ids): raise HTTPException(status_code=400, detail="选择中包含未分配给当前用户的问题") used_ids = {item.question_id for item in assignment.used_questions} if selected_ids & used_ids: raise HTTPException(status_code=400, detail="已认领的问题不能重复选择") for item in list(assignment.items): # Keep historical submitted questions for batch details, retain only # the questions confirmed for this batch, and immediately return all # other provisional questions to the public pool. if item.question_id in used_ids: item.selected = False elif item.question_id in selected_ids: item.selected = True else: db.delete(item) assignment.status = "进行中" assignment.reservation_started_at = datetime.utcnow() assignment.updated_at = assignment.reservation_started_at db.commit(); db.refresh(assignment) data = _ask_assignment_data(assignment, include_images=False) db.rollback() return {"success": True, "data": data} @app.post("/api/ask/tasks/{task_no}/submission") async def submit_public_ask_task( task_no: str, token: str = Form(...), files: list[UploadFile] = File(...), payment_file: UploadFile = File(...), payee_name: str = Form(default=""), alipay_account: str = Form(default=""), db: Session = Depends(get_db), ): user = _answer_user_from_token(token, db) task = _published_ask_task(task_no, db) current_assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) current_expired = _ask_assignment_expired(current_assignment) if _release_expired_ask_assignments(db, task): db.commit() if current_expired: raise HTTPException(status_code=409, detail="提问任务已失效,请点击刷新重新分配任务") assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) if not assignment: raise HTTPException(status_code=404, detail="提问任务不存在") if assignment.status not in {"进行中", "未通过"}: raise HTTPException(status_code=409, detail="当前任务不能提交审核") if not any(item.selected for item in assignment.items): raise HTTPException(status_code=400, detail="请至少选择一个问题") if not files: raise HTTPException(status_code=400, detail="请至少上传一张提问截图") allowed = {"image/jpeg", "image/png", "image/webp"} if any(upload.content_type not in allowed for upload in [*files, payment_file]): raise HTTPException(status_code=400, detail="仅支持 JPG、PNG、WEBP 图片") selected_ids = [item.question_id for item in assignment.items if item.selected] assignment_id = assignment.id db.rollback() question_uploads = [(upload.filename or "question", upload.content_type, await upload.read()) for upload in files] payment_upload = (payment_file.filename or "payment", payment_file.content_type, await payment_file.read()) assignment = db.get(AskAssignment, assignment_id) if not assignment or assignment.status not in {"进行中", "未通过"} or _ask_assignment_expired(assignment): raise HTTPException(status_code=409, detail="当前任务不能提交审核") selected_ids = [item.question_id for item in assignment.items if item.selected] if not selected_ids: raise HTTPException(status_code=409, detail="当前没有可提交的问题,请刷新后重试") batch = AskAssignmentBatch( assignment_id=assignment.id, status="pending", selected_question_ids=",".join(str(value) for value in selected_ids), ) db.add(batch) db.flush() for filename, content_type, image_data in question_uploads: db.add(AskAssignmentBatchScreenshot( batch_id=batch.id, stage="question", **screenshot_fields(image_data, filename, content_type, "ask-batches"), )) _save_refund_recipient(db, "ask_batch", batch.id, alipay_account, payee_name) filename, content_type, image_data = payment_upload db.add(AskAssignmentBatchScreenshot( batch_id=batch.id, stage="payment", **screenshot_fields(image_data, filename, content_type, "ask-batches"), )) assignment.status = "待审核" assignment.reservation_started_at = None used_ids = {item.question_id for item in assignment.used_questions} for item in assignment.items: if item.selected and item.question_id not in used_ids: db.add(AskAssignmentUsedQuestion(assignment_id=assignment.id, question_id=item.question_id)) used_ids.add(item.question_id) assignment.updated_at = datetime.utcnow() db.commit(); db.refresh(batch); db.refresh(assignment) data = _ask_batch_data(batch, include_images=False) db.rollback() return {"success": True, "data": data} @app.get("/api/admin/ask-completion-reviews") def ask_completion_reviews(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batches = db.scalars( select(AskAssignmentBatch).join(AskAssignmentBatch.assignment).join(AskAssignment.task) .where(AskAssignmentBatch.status == "pending") .where(_ask_task_owner_clause(user)) .order_by(AskAssignmentBatch.updated_at.desc()) ).unique().all() legacy = db.scalars( select(AskAssignment).join(AskAssignment.task).where( AskAssignment.status == "待审核", ~AskAssignment.batches.any(), AskAssignment.screenshots.any(), _ask_task_owner_clause(user), ).order_by(AskAssignment.updated_at.desc()) ).unique().all() data = [_ask_batch_data(batch, include_images=False) for batch in batches] data.extend(_ask_assignment_data(record, include_images=False) for record in legacy) return {"success": True, "data": data} @app.get("/api/admin/review-pending-count") def review_pending_count(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): """Return lightweight sidebar counts without loading review rows or images.""" answer_count = db.scalar( select(func.count(AnswerSubmission.id)).where( AnswerSubmission.status == "待审核", _answer_owner_clause(user), ) ) or 0 ask_batch_count = db.scalar( select(func.count(AskAssignmentBatch.id)).join(AskAssignmentBatch.assignment).join(AskAssignment.task).where(AskAssignmentBatch.status == "pending", _ask_task_owner_clause(user)) ) or 0 legacy_ask_count = db.scalar( select(func.count(AskAssignment.id)).join(AskAssignment.task).where( AskAssignment.status == "待审核", ~AskAssignment.batches.any(), AskAssignment.screenshots.any(), _ask_task_owner_clause(user), ) ) or 0 ask_count = ask_batch_count + legacy_ask_count return { "success": True, "data": { "ask": ask_count, "answer": answer_count, "total": ask_count + answer_count, }, } @app.get("/api/admin/ask-batch-reviews/{batch_id}") def ask_batch_review_detail(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batch = db.scalar( select(AskAssignmentBatch) .where(AskAssignmentBatch.id == batch_id) .options(selectinload(AskAssignmentBatch.screenshots).load_only( AskAssignmentBatchScreenshot.id, AskAssignmentBatchScreenshot.batch_id, AskAssignmentBatchScreenshot.stage, AskAssignmentBatchScreenshot.filename, AskAssignmentBatchScreenshot.content_type, )) ) if not batch: raise HTTPException(status_code=404, detail="提问批次审核任务不存在") _require_ask_task_access(batch.assignment.task, user) return {"success": True, "data": _ask_batch_data(batch, image_endpoints=True)} @app.get("/api/admin/ask-assignment-reviews/{assignment_id}") def ask_assignment_review_detail(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): assignment = db.scalar( select(AskAssignment) .where(AskAssignment.id == assignment_id) .options(selectinload(AskAssignment.screenshots).load_only( AskAssignmentScreenshot.id, AskAssignmentScreenshot.assignment_id, AskAssignmentScreenshot.stage, AskAssignmentScreenshot.filename, AskAssignmentScreenshot.content_type, )) ) if not assignment or not _can_access_ask_task(assignment.task, user): raise HTTPException(status_code=404, detail="提问审核任务不存在") return {"success": True, "data": _ask_assignment_data(assignment, image_endpoints=True)} @app.get("/api/admin/ask-batch-screenshots/{screenshot_id}") def admin_ask_batch_screenshot(screenshot_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): image = db.get(AskAssignmentBatchScreenshot, screenshot_id) if not image or not _can_access_ask_task(image.batch.assignment.task, user): raise HTTPException(status_code=404, detail="截图不存在") return screenshot_response(image) @app.get("/api/admin/ask-assignment-screenshots/{screenshot_id}") def admin_ask_assignment_screenshot(screenshot_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): image = db.get(AskAssignmentScreenshot, screenshot_id) if not image or not _can_access_ask_task(image.assignment.task, user): raise HTTPException(status_code=404, detail="截图不存在") return screenshot_response(image) @app.post("/api/admin/ask-batch-reviews/{batch_id}/decision") def ask_batch_review_decision(batch_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batch = db.get(AskAssignmentBatch, batch_id) if not batch: raise HTTPException(status_code=404, detail="提问批次审核任务不存在") _require_ask_task_access(batch.assignment.task, user) if batch.status != "pending": raise HTTPException(status_code=409, detail="任务不在待审核状态") approved = bool(payload.get("approved")) reason_code = str(payload.get("reason_code", "")).strip() or None reason_text = str(payload.get("reason_text", "")).strip() or None if not approved and not (reason_code or reason_text): raise HTTPException(status_code=400, detail="请选择或填写审核不通过原因") db.add(AskAssignmentBatchReview(batch_id=batch.id, approved=approved, reason_code=reason_code, reason_text=reason_text)) batch.status = "approved" if approved else "rejected" _sync_ask_assignment_status(batch.assignment) db.commit(); db.refresh(batch) return {"success": True, "data": _ask_batch_data(batch, include_images=False)} @app.post("/api/admin/ask-assignment-reviews/{assignment_id}/decision") def ask_assignment_review_decision(assignment_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): assignment = db.get(AskAssignment, assignment_id) if not assignment or not _can_access_ask_task(assignment.task, user): raise HTTPException(status_code=404, detail="提问审核任务不存在") if assignment.status != "待审核": raise HTTPException(status_code=409, detail="任务不在待审核状态") approved = bool(payload.get("approved")) reason_code = str(payload.get("reason_code", "")).strip() or None reason_text = str(payload.get("reason_text", "")).strip() or None if not approved and not (reason_code or reason_text): raise HTTPException(status_code=400, detail="请选择或填写审核不通过原因") db.add(AskAssignmentReview(assignment_id=assignment.id, approved=approved, reason_code=reason_code, reason_text=reason_text)) if approved: used_ids = {item.question_id for item in assignment.used_questions} for item in assignment.items: if item.selected and item.question_id not in used_ids: db.add(AskAssignmentUsedQuestion(assignment_id=assignment.id, question_id=item.question_id)) used_ids.add(item.question_id) assignment.status = "已通过" if approved else "未通过" assignment.updated_at = datetime.utcnow() db.commit(); db.refresh(assignment) return {"success": True, "data": _ask_assignment_data(assignment, include_images=False)} @app.get("/api/answer/me/submissions") def answer_my_submissions(token: str, owner_token: str, include_images: bool = False, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) owner = _answer_link_owner(owner_token, db) statement = select(AnswerSubmission).where( AnswerSubmission.user_id == user.id, AnswerSubmission.owner_id == owner.id, ) records = db.scalars(statement.order_by(AnswerSubmission.created_at.desc())).unique().all() return {"success": True, "data": [_submission_data(record, include_images=include_images) for record in records]} @app.get("/api/answer/me/submissions/{submission_id}") def answer_submission_detail(submission_id: int, token: str, owner_token: str, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) owner = _answer_link_owner(owner_token, db) record = db.get(AnswerSubmission, submission_id) if not record or record.user_id != user.id or record.owner_id != owner.id: raise HTTPException(status_code=404, detail="任务不存在") return {"success": True, "data": _submission_data(record, include_images=True, include_refund_images=True)} def _process_answer_submission(submission_id: int): """Run OCR and answer drafting after the upload response has been sent.""" db = SessionLocal() try: record = db.get(AnswerSubmission, submission_id) if not record or record.status != "待筛选": return saved = sorted( (shot for shot in record.screenshots if shot.stage == "invited"), key=lambda shot: shot.id, ) # A SQLAlchemy Session keeps its transaction/connection until # commit/rollback. Do not hold that connection while OCR/LLM calls # (which can take up to the configured timeout) are running. saved_payload = [(screenshot_bytes(shot), shot.content_type) for shot in saved] db.rollback() extracted = extract_questions_from_images(saved_payload) if not extracted: record = db.get(AnswerSubmission, submission_id) if record and record.status == "待筛选" and not record.questions: record.status = "识别失败" record.updated_at = datetime.utcnow() db.commit() return drafted = [ { "product_title": item["product_title"], "question": item["question"], "source_index": int(item.get("source_index", 0)), "answer": generate_answer_for_question(item["product_title"], item["question"]), } for item in extracted ] # Re-read after the external calls so the write happens in a short, # fresh transaction and does not overwrite a concurrent state change. record = db.get(AnswerSubmission, submission_id) if not record or record.status != "待筛选": db.rollback() return saved = sorted( (shot for shot in record.screenshots if shot.stage == "invited"), key=lambda shot: shot.id, ) for index, item in enumerate(drafted): product, question = item["product_title"], item["question"] source_index = min(max(item["source_index"], 0), len(saved) - 1) source = saved[source_index] db.add(AnswerRecognizedQuestion( submission_id=record.id, source_screenshot_id=source.id, product_image_screenshot_id=source.id, sequence_no=index + 1, product_title=product, question_text=question, answer_text=item["answer"], answer_status="已生成", )) record.updated_at = datetime.utcnow() db.commit() except Exception: db.rollback() # Keep the upload and expose an explicit retry state. Never invent # questions when OCR or answer drafting fails. try: record = db.get(AnswerSubmission, submission_id) if record and record.status == "待筛选" and not record.questions: record.status = "识别失败" record.updated_at = datetime.utcnow() db.commit() except Exception: db.rollback() finally: db.close() @app.post("/api/answer/me/submissions") async def answer_create_submission( background_tasks: BackgroundTasks, token: str = Form(...), owner_token: str = Form(...), files: list[UploadFile] = File(...), db: Session = Depends(get_db), ): user = _answer_user_from_token(token, db) owner = _answer_link_owner(owner_token, db) user_id = user.id db.rollback() if not files or len(files) > 2: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="一次最多上传2张截图") allowed = {"image/jpeg", "image/png", "image/webp"} uploaded = [] for upload in files: if upload.content_type not in allowed: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片") image_data = await upload.read() if not image_data: raise HTTPException(status_code=400, detail="不能上传空图片") if len(image_data) > 10 * 1024 * 1024: raise HTTPException(status_code=413, detail="单张图片不能超过10MB") uploaded.append((upload.filename or "screenshot", upload.content_type, image_data)) # Persist only after all asynchronous file reads have completed. record = AnswerSubmission(submission_no=f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", user_id=user_id, owner_id=owner.id, status="待筛选") db.add(record); db.flush() for filename, content_type, image_data in uploaded: db.add(AnswerScreenshot( submission_id=record.id, stage="invited", **screenshot_fields(image_data, filename, content_type, "answers"), )) db.commit(); db.refresh(record) background_tasks.add_task(_process_answer_submission, record.id) # 先返回轻量结果,图片由后续状态查询按需加载。 return {"success": True, "data": _submission_data(record, include_images=False)} @app.get("/api/admin/answer-task-drafts") def admin_answer_task_drafts(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.status.in_(["待筛选", "识别失败", "待运营派发", "已派发", "待用户完成"])).where(_answer_owner_clause(user)).order_by(AnswerSubmission.created_at.desc())).unique().all() return {"success": True, "data": [_submission_data(record, include_images=False) for record in records]} @app.get("/api/admin/answer-task-drafts/{submission_id}") def admin_answer_task_draft_detail(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record or record.status not in {"待筛选", "识别失败", "待运营派发", "已派发", "待用户完成"} or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="回答任务不存在") return {"success": True, "data": _submission_data(record, include_images=True)} @app.post("/api/admin/answer-task-drafts/{submission_id}/retry-recognition") def retry_answer_task_recognition( submission_id: int, background_tasks: BackgroundTasks, user: AdminUser = Depends(current_user), db: Session = Depends(get_db), ): record = db.get(AnswerSubmission, submission_id) if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="回答任务不存在") legacy_fake_questions = _has_legacy_fake_ocr_questions(record) if record.status != "识别失败" and not legacy_fake_questions: raise HTTPException(status_code=409, detail="当前任务不需要重新识别") if not any(image.stage == "invited" for image in record.screenshots): raise HTTPException(status_code=400, detail="没有可重新识别的截图") if legacy_fake_questions: for question in list(record.questions): db.delete(question) db.flush() record.status = "待筛选" record.updated_at = datetime.utcnow() db.commit() background_tasks.add_task(_process_answer_submission, record.id) return {"success": True, "data": _submission_data(record, include_images=False)} @app.post("/api/admin/answer-task-drafts/questions/{question_id}/operator-image") async def upload_answer_operator_images( question_id: int, files: list[UploadFile] = File(...), user: AdminUser = Depends(current_user), db: Session = Depends(get_db), ): """Attach up to three optional operation-specified images to one question.""" question = db.get(AnswerRecognizedQuestion, question_id) record = db.get(AnswerSubmission, question.submission_id) if question else None if not question or not record or record.status not in {"待筛选", "待运营派发", "已派发"} or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="回答问题不存在或已经发布") if not files or len(files) > 3: raise HTTPException(status_code=400, detail="每道问题最多上传3张运营指定图片") record_id = record.id question_id_value = question.id db.rollback() uploaded = [] for file in files: if file.content_type not in {"image/jpeg", "image/png", "image/webp"}: raise HTTPException(status_code=400, detail="运营指定图片仅支持 JPG、PNG、WEBP") image_data = await file.read() if not image_data: raise HTTPException(status_code=400, detail="不能上传空图片") uploaded.append((file.filename or "operator-image", file.content_type, image_data)) question = db.get(AnswerRecognizedQuestion, question_id_value) record = db.get(AnswerSubmission, record_id) if not question or not record or record.status not in {"待筛选", "待运营派发", "已派发"} or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="回答问题不存在或已经发布") old_ids = [value for value in (question.operator_image_screenshot_ids or "").split(",") if value.strip().isdigit()] for old_id in old_ids: old = db.get(AnswerScreenshot, int(old_id)) if old: db.delete(old) new_ids = [] for filename, content_type, image_data in uploaded: image = AnswerScreenshot( submission_id=record.id, stage="operator", **screenshot_fields(image_data, filename, content_type, "answers"), ) db.add(image); db.flush(); new_ids.append(str(image.id)) question.operator_image_screenshot_ids = ",".join(new_ids) question.operator_image_screenshot_id = int(new_ids[0]) if new_ids else None db.commit() db.refresh(record) return {"success": True, "data": _submission_data(record, include_images=True)} @app.post("/api/admin/answer-task-drafts/{submission_id}/reject") def reject_answer_task_draft(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="回答任务不存在") if record.status not in {"待筛选", "识别失败", "待运营派发"}: raise HTTPException(status_code=409, detail="当前任务不能打回") db.delete(record) db.commit() return {"success": True, "data": {"id": submission_id}} @app.post("/api/admin/ask-tasks/generate") def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): shop_name = str(payload.get("shop_name", "")).strip() product_link = str(payload.get("product_link", "")).strip() product_qr_data, product_qr_content_type, product_qr_name = _decode_product_qr_image( payload.get("product_qr_image"), payload.get("product_qr_image_name") ) product_name = str(payload.get("product_name", "")).strip() if len(shop_name) > 255: raise HTTPException(status_code=400, detail="店铺名称不能超过255个字符") intent_error = _product_intent_error([product_name, payload.get("category"), payload.get("benefits"), payload.get("selling_points")]) if intent_error: raise HTTPException(status_code=400, detail=intent_error) options_error = _generation_options_error(payload, include_answers=False) if options_error: raise HTTPException(status_code=400, detail=options_error) try: count = int(payload.get("question_count", 5)) except (TypeError, ValueError): raise HTTPException(status_code=400, detail="提问生成条数必须是正整数") if count < 1: raise HTTPException(status_code=400, detail="提问生成条数必须大于 0") # Claim limits belong to the publish workflow and must never prevent AI # generation. Drafts keep the neutral default until the publish dialog is # confirmed. claim_limit = 5 existing_task_id = payload.get("task_id") try: task = db.get(AskTask, int(existing_task_id)) if existing_task_id else None except (TypeError, ValueError): task = None if task: _require_ask_task_access(task, user) if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能重新生成") existing_id = task.id if task else None # Release the authentication/existence-check connection before any LLM # request. The task is reloaded below before writing the result. db.rollback() questions = payload.get("questions") if isinstance(payload.get("questions"), list) else [] reference_text = str(payload.get("reference_qa", ""))[:20000] generation_payload = { key: value for key, value in payload.items() if key not in {"shop_name", "product_qr_image", "product_qr_image_name"} } if not questions: questions = generate_questions(generation_payload, count, reference_text) # 自定义问题优先,不足的数量用候选问题补齐,保证返回数量与运营填写值一致。 generated: list[str] = [] for value in questions: question = str(value).strip() if question and question not in generated: generated.append(question) if len(generated) == count: break if len(generated) < count: generated.extend(generate_questions(generation_payload, count - len(generated), reference_text)) if existing_id: task = db.get(AskTask, existing_id) _require_ask_task_access(task, user) if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能重新生成") _dedupe_draft_ask_questions(task, db) task.shop_name = shop_name task.product_link = product_link task.product_qr_image_data = product_qr_data task.product_qr_image_content_type = product_qr_content_type task.product_qr_image_name = product_qr_name task.product_name = product_name or "待补充商品" task.category = str(payload.get("category", "")) task.benefits = str(payload.get("benefits", "")) task.selling_points = str(payload.get("selling_points", "")) task.language_style = str(payload.get("language_style", "")) task.target_group = str(payload.get("target_group", "")) task.usage_scene = str(payload.get("usage_scene", "")) task.purchase_channel = str(payload.get("purchase_channel", "")) task.claim_questions_per_user = claim_limit else: task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", owner_id=user.id, shop_name=shop_name, product_link=product_link, product_qr_image_data=product_qr_data, product_qr_image_content_type=product_qr_content_type, product_qr_image_name=product_qr_name, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), claim_questions_per_user=claim_limit, status="草稿") db.add(task); db.flush() next_sequence = max((question.sequence_no for question in task.questions), default=0) + 1 for index, question in enumerate(generated): db.add(AskQuestion(task_id=task.id, sequence_no=next_sequence + index, question_text=question, source="AI生成", selected=True)) db.commit(); db.refresh(task) return {"success": True, "data": _ask_task_data(task)} @app.patch("/api/admin/ask-tasks/{task_id}/questions/{question_id}") def update_ask_question(task_id: int, question_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): question = db.scalar(select(AskQuestion).where(AskQuestion.id == question_id, AskQuestion.task_id == task_id)) if not question or not _can_access_ask_task(question.task, user): raise HTTPException(status_code=404, detail="提问不存在") if question.task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能修改") if "question" in payload: value = str(payload.get("question", "")).strip() if not value: raise HTTPException(status_code=400, detail="提问内容不能为空") question.question_text = value if "selected" in payload: question.selected = bool(payload["selected"]) db.commit(); db.refresh(question) return {"success": True, "data": {"id": question.id, "question": question.question_text, "selected": question.selected, "source": question.source or "AI生成"}} @app.post("/api/admin/ask-tasks/{task_id}/questions") def add_ask_question(task_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): # Lock the draft row while checking/inserting. This closes the small # race window where two picker requests arrive at the same time; MySQL # serializes them, while SQLite simply ignores FOR UPDATE in local tests. task = db.scalar(select(AskTask).where(AskTask.id == task_id).with_for_update()) text = str(payload.get("question", "")).strip() _require_ask_task_access(task, user) if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能添加提问") if not text: raise HTTPException(status_code=400, detail="提问内容不能为空") # The library picker may retry the same request (for example after a # mobile navigation/re-render). Treat identical text within a draft as # idempotent instead of creating a second row. duplicate = db.scalar(select(AskQuestion).where(AskQuestion.task_id == task.id, AskQuestion.question_text == text)) if duplicate: return {"success": True, "data": {"id": duplicate.id, "sequence_no": duplicate.sequence_no, "question": duplicate.question_text, "selected": duplicate.selected, "source": duplicate.source or "AI生成"}} next_sequence = max((item.sequence_no for item in task.questions), default=0) + 1 source = str(payload.get("source", "自主添加")).strip() or "自主添加" if source not in {"自主添加", "问答库", "批量导入", "AI生成"}: source = "自主添加" question = AskQuestion(task_id=task.id, sequence_no=next_sequence, question_text=text, source=source, selected=True) db.add(question); db.commit(); db.refresh(question) return {"success": True, "data": {"id": question.id, "sequence_no": question.sequence_no, "question": question.question_text, "selected": question.selected, "source": question.source}} @app.post("/api/admin/ask-tasks/{task_id}/questions/bulk") def add_ask_questions_bulk(task_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): """Append an imported question list to an editable ask-task draft.""" task = db.scalar(select(AskTask).where(AskTask.id == task_id).with_for_update()) _require_ask_task_access(task, user) if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能添加提问") values = payload.get("questions") if not isinstance(values, list) or not values: raise HTTPException(status_code=400, detail="没有可导入的问题") if len(values) > 500: raise HTTPException(status_code=400, detail="单次最多导入 500 条问题") existing = {question_key for question_key in ( re.sub(r"\s+", " ", item.question_text.strip()) for item in task.questions ) if question_key} incoming = [] seen = set() for value in values: question_text = re.sub(r"\s+", " ", str(value or "").strip()) if not question_text or len(question_text) > 500 or question_text in existing or question_text in seen: continue seen.add(question_text) incoming.append(question_text) next_sequence = max((item.sequence_no for item in task.questions), default=0) + 1 added = [ AskQuestion( task_id=task.id, sequence_no=next_sequence + index, question_text=question_text, source="批量导入", selected=True, ) for index, question_text in enumerate(incoming) ] db.add_all(added) db.commit() for question in added: db.refresh(question) return {"success": True, "data": { "added_count": len(added), "skipped_count": len(values) - len(added), "items": [{ "id": question.id, "sequence_no": question.sequence_no, "question": question.question_text, "selected": question.selected, "source": question.source, } for question in added], }} @app.delete("/api/admin/ask-tasks/{task_id}/questions/{question_id}") def delete_ask_question(task_id: int, question_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): question = db.scalar(select(AskQuestion).where(AskQuestion.id == question_id, AskQuestion.task_id == task_id)) if not question or not _can_access_ask_task(question.task, user): raise HTTPException(status_code=404, detail="提问不存在") if question.task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能删除提问") db.delete(question) db.commit() return {"success": True, "data": {"id": question_id}} def _ask_publish_fields(payload: dict) -> tuple[str, str, bytes | None, str | None, str | None, int]: shop_name = str(payload.get("shop_name", "")).strip() if not shop_name: raise HTTPException(status_code=400, detail="请填写店铺名称") if len(shop_name) > 255: raise HTTPException(status_code=400, detail="店铺名称不能超过255个字符") product_link = str(payload.get("product_link", "")).strip() qr_data, qr_content_type, qr_name = _decode_product_qr_image( payload.get("product_qr_image"), payload.get("product_qr_image_name") ) product_token_error = _product_token_error(product_link, qr_data) if product_token_error: raise HTTPException(status_code=400, detail=product_token_error) raw_claim_limit = payload.get("claim_questions_per_user", "") if not str(raw_claim_limit).strip(): raise HTTPException(status_code=400, detail="请填写每个用户可领取的问题数量") try: claim_limit = int(raw_claim_limit) except (TypeError, ValueError): raise HTTPException(status_code=400, detail="每个用户可领取的问题数量必须是 2-20 的整数") if claim_limit < 2 or claim_limit > 20: raise HTTPException(status_code=400, detail="每个用户可领取的问题数量必须在 2-20 条之间") return shop_name, product_link, qr_data, qr_content_type, qr_name, claim_limit def _apply_ask_publish_fields(task: AskTask, payload: dict) -> None: shop_name, product_link, qr_data, qr_content_type, qr_name, claim_limit = _ask_publish_fields(payload) task.shop_name = shop_name task.product_link = product_link task.product_qr_image_data = qr_data task.product_qr_image_content_type = qr_content_type task.product_qr_image_name = qr_name task.claim_questions_per_user = claim_limit @app.post("/api/admin/ask-tasks/publish") def create_and_publish_ask_task(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): """Publish the questions currently visible in the editor, with or without an AI draft.""" raw_questions = payload.get("questions") if not isinstance(raw_questions, list): raise HTTPException(status_code=400, detail="请至少选择一个提问") question_rows = [] seen = set() for raw in raw_questions: item = raw if isinstance(raw, dict) else {"question": raw} question_text = re.sub(r"\s+", " ", str(item.get("question", "")).strip()) if not question_text or len(question_text) > 500 or question_text in seen: continue seen.add(question_text) source = str(item.get("source", "自主添加")).strip() if source not in {"自主添加", "问答库", "批量导入", "AI生成"}: source = "自主添加" question_rows.append((item, question_text, source)) if not question_rows: raise HTTPException(status_code=400, detail="请至少选择一个提问") if len(question_rows) > 500: raise HTTPException(status_code=400, detail="单个任务最多发布 500 条问题") raw_task_id = payload.get("task_id") try: task = db.get(AskTask, int(raw_task_id)) if raw_task_id else None except (TypeError, ValueError): task = None if raw_task_id and not task: raise HTTPException(status_code=404, detail="提问任务草稿不存在") if task: _require_ask_task_access(task, user) if task.status != "草稿": raise HTTPException(status_code=409, detail="任务已经发布") else: task = AskTask( task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", owner_id=user.id, shop_name="", product_link="", product_name=str(payload.get("product_name", "")).strip() or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), claim_questions_per_user=5, status="草稿", ) db.add(task) db.flush() _apply_ask_publish_fields(task, payload) if task.questions: by_id = {question.id: question for question in task.questions} selected_ids = set() for item, question_text, _source in question_rows: try: question_id = int(item.get("id")) except (TypeError, ValueError): continue question = by_id.get(question_id) if question: question.question_text = question_text question.selected = True selected_ids.add(question.id) for question in task.questions: question.selected = question.id in selected_ids existing_by_text = {question.question_text: question for question in task.questions} next_sequence = max((question.sequence_no for question in task.questions), default=0) + 1 for _item, question_text, source in question_rows: existing_question = existing_by_text.get(question_text) if existing_question: existing_question.selected = True continue question = AskQuestion(task_id=task.id, sequence_no=next_sequence, question_text=question_text, source=source, selected=True) db.add(question) existing_by_text[question_text] = question next_sequence += 1 else: db.add_all([ AskQuestion(task_id=task.id, sequence_no=index, question_text=question_text, source=source, selected=True) for index, (_item, question_text, source) in enumerate(question_rows, start=1) ]) if not task.public_token: task.public_token = _new_ask_public_token(db) task.status = "已发布" db.commit() db.refresh(task) return {"success": True, "data": _ask_task_data(task)} @app.post("/api/admin/ask-tasks/{task_id}/publish") def publish_ask_task(task_id: int, payload: dict = Body(default={}), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): task = db.get(AskTask, task_id) _require_ask_task_access(task, user) if task.status != "草稿": raise HTTPException(status_code=409, detail="任务已经发布") _dedupe_draft_ask_questions(task, db) if payload: _apply_ask_publish_fields(task, payload) else: product_token_error = _product_token_error(task.product_link, task.product_qr_image_data) if product_token_error: raise HTTPException(status_code=400, detail=f"无法发布任务:{product_token_error}") if not task.shop_name: raise HTTPException(status_code=400, detail="请填写店铺名称") if not any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问") if not task.public_token: task.public_token = _new_ask_public_token(db) task.status = "已发布"; db.commit(); db.refresh(task) return {"success": True, "data": _ask_task_data(task)} @app.post("/api/admin/ask-tasks/{task_id}/cancel") def cancel_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): task = db.get(AskTask, task_id) _require_ask_task_access(task, user) if task.status != "已发布": raise HTTPException(status_code=409, detail="当前任务不能撤销") task.status = "已撤销"; db.commit(); db.refresh(task) return {"success": True, "data": _ask_task_data(task)} @app.post("/api/admin/ask-tasks/{task_id}/complete") def complete_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): task = db.get(AskTask, task_id) _require_ask_task_access(task, user) if task.status != "已发布": raise HTTPException(status_code=409, detail="当前任务不能完成归档") if not _ask_task_is_fully_completed(task): raise HTTPException(status_code=409, detail="全部问题完成返款后,任务才会自动显示为已完成") task.status = "已完成" task.updated_at = datetime.utcnow() db.commit() db.refresh(task) return {"success": True, "data": _ask_task_data(task)} @app.patch("/api/admin/answer-task-drafts/questions/{question_id}") def update_answer_question(question_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): question = db.get(AnswerRecognizedQuestion, question_id) record = db.get(AnswerSubmission, question.submission_id) if question else None if not question or not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="问题不存在") if payload.get("regenerate"): product_title, question_text = question.product_title, question.question_text selected = bool(payload["selected"]) if "selected" in payload else None # Do not keep the ORM connection checked out during the model request. db.rollback() answer_text = generate_answer_for_question(product_title, question_text) question = db.get(AnswerRecognizedQuestion, question_id) record = db.get(AnswerSubmission, question.submission_id) if question else None if not question or not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="问题不存在") if selected is not None: question.selected = selected question.answer_text = answer_text else: if "selected" in payload: question.selected = bool(payload["selected"]) # An empty answer is a valid edit: the operator may intentionally clear # the generated response. Checking truthiness here used to restore the # previous text whenever a textarea was cleared and blurred. if "answer" in payload: question.answer_text = str(payload["answer"] or "").strip() db.commit(); db.refresh(question) return {"success": True, "data": {"id": question.id, "answer": question.answer_text, "selected": question.selected}} @app.post("/api/admin/answer-task-drafts/{submission_id}/publish") def publish_answer_task(submission_id: int, payload: dict = Body(default={}), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="提交记录不存在") if record.status not in {"待筛选", "待运营派发", "已派发"}: raise HTTPException(status_code=409, detail="当前任务已经发布或不能发布") if not any(q.selected for q in record.questions): raise HTTPException(status_code=400, detail="请至少选择一个问题") shop_name = str(payload.get("shop_name", "")).strip() if not shop_name: raise HTTPException(status_code=400, detail="派发前必须填写店铺名称") if len(shop_name) > 255: raise HTTPException(status_code=400, detail="店铺名称不能超过255个字符") record.shop_name = shop_name if record.owner_id is None: record.owner_id = user.id stored_user = db.get(AdminUser, user.id) if stored_user: stored_user.last_answer_shop_name = shop_name record.status = "待用户完成"; db.commit() return {"success": True, "data": _submission_data(record, include_images=False)} @app.get("/api/admin/task-board") def admin_task_board( page: int = 1, page_size: int = 20, task_type: str = "all", status_filter: str = "all", keyword: str = "", date_from: str = "", date_to: str = "", user: AdminUser = Depends(current_user), db: Session = Depends(get_db), ): if _release_expired_ask_assignments(db): db.commit() page = max(page, 1) page_size = min(max(page_size, 10), 100) task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"] answer_candidate = ( select( literal("answer").label("record_kind"), AnswerSubmission.id.label("record_id"), AnswerSubmission.updated_at.label("sort_at"), ) .join(AnswerSubmission.user) .where(AnswerSubmission.status.in_(task_statuses), _answer_owner_clause(user)) ) ask_candidate = ( select( literal("ask").label("record_kind"), AskTask.id.label("record_id"), AskTask.updated_at.label("sort_at"), ) .where( AskTask.status.in_(["已发布", "已完成", "已撤销"]), _ask_task_owner_clause(user), ) ) normalized_type = task_type if task_type in {"ask", "answer"} else "all" candidates = [] if normalized_type in {"all", "answer"}: if status_filter == "进行中": answer_candidate = answer_candidate.where(AnswerSubmission.status.in_(["待用户完成", "未通过"])) elif status_filter == "待审核": answer_candidate = answer_candidate.where(AnswerSubmission.status == "待审核") elif status_filter == "待返款": answer_candidate = answer_candidate.where(AnswerSubmission.status == "已通过") elif status_filter == "已完成": answer_candidate = answer_candidate.where(AnswerSubmission.status == "已完成") elif status_filter == "已撤销": answer_candidate = answer_candidate.where(AnswerSubmission.status == "已撤销") candidates.append(answer_candidate) if normalized_type in {"all", "ask"}: if status_filter == "进行中": ask_candidate = ask_candidate.where(AskTask.status == "已发布") elif status_filter == "待审核": ask_candidate = ask_candidate.where( or_( AskTask.assignments.any(AskAssignment.batches.any(AskAssignmentBatch.status == "pending")), AskTask.assignments.any( (AskAssignment.status == "待审核") & ~AskAssignment.batches.any() ), ) ) elif status_filter == "待返款": ask_candidate = ask_candidate.where( or_( AskTask.assignments.any(AskAssignment.batches.any(AskAssignmentBatch.status == "approved")), AskTask.assignments.any( (AskAssignment.status == "已通过") & ~AskAssignment.batches.any() ), ) ) elif status_filter == "已完成": ask_candidate = ask_candidate.where(AskTask.status == "已完成") elif status_filter == "已撤销": ask_candidate = ask_candidate.where(AskTask.status == "已撤销") candidates.append(ask_candidate) normalized_keyword = keyword.strip() if normalized_keyword: pattern = f"%{normalized_keyword}%" answer_filter = or_( AnswerSubmission.submission_no.like(pattern), AnswerSubmission.shop_name.like(pattern), AnswerSubmission.questions.any( AnswerRecognizedQuestion.product_title.like(pattern) ), AnswerSubmission.user.has(or_( AnswerUser.taobao_id.like(pattern), AnswerUser.public_id.like(pattern), AnswerUser.phone.like(pattern), )), ) ask_filter = or_( AskTask.task_no.like(pattern), AskTask.shop_name.like(pattern), AskTask.product_name.like(pattern), AskTask.owner.has(AdminUser.display_name.like(pattern)), AskTask.assignments.any(AskAssignment.user.has(or_( AnswerUser.taobao_id.like(pattern), AnswerUser.public_id.like(pattern), AnswerUser.phone.like(pattern), ))), ) candidates = [ statement.where(answer_filter if normalized_type == "answer" or index == 0 and normalized_type == "all" else ask_filter) for index, statement in enumerate(candidates) ] try: start_at = datetime.fromisoformat(date_from) if date_from else None end_at = datetime.fromisoformat(date_to) + timedelta(days=1) if date_to else None except ValueError: raise HTTPException(status_code=400, detail="日期筛选格式无效") dated_candidates = [] for index, statement in enumerate(candidates): is_answer = normalized_type == "answer" or (normalized_type == "all" and index == 0) created_column = AnswerSubmission.created_at if is_answer else AskTask.created_at if start_at: statement = statement.where(created_column >= start_at) if end_at: statement = statement.where(created_column < end_at) dated_candidates.append(statement) candidates = dated_candidates combined = candidates[0].subquery() if len(candidates) == 1 else union_all(*candidates).subquery() total = int(db.scalar(select(func.count()).select_from(combined)) or 0) total_pages = max((total + page_size - 1) // page_size, 1) page = min(page, total_pages) page_rows = db.execute( select(combined.c.record_kind, combined.c.record_id) .order_by(combined.c.sort_at.desc(), combined.c.record_kind, combined.c.record_id.desc()) .offset((page - 1) * page_size) .limit(page_size) ).all() answer_ids = [record_id for kind, record_id in page_rows if kind == "answer"] ask_ids = [record_id for kind, record_id in page_rows if kind == "ask"] records = db.scalars( select(AnswerSubmission).options( selectinload(AnswerSubmission.user), selectinload(AnswerSubmission.questions), noload(AnswerSubmission.screenshots), noload(AnswerSubmission.review_decisions), ) .where(AnswerSubmission.id.in_(answer_ids)) ).unique().all() # The board serializer walks several levels below an ask task. Loading # those relationships lazily used to issue multiple SQL statements per # task (more than 160 queries for only 65 board rows). Batch-load the # complete lightweight graph and leave image BLOB relationships unloaded. ask_assignment_loader = selectinload(AskTask.assignments) ask_statement = ( select(AskTask) .options( defer(AskTask.product_qr_image_data), selectinload(AskTask.owner), selectinload(AskTask.questions), ask_assignment_loader.selectinload(AskAssignment.user), ask_assignment_loader.selectinload(AskAssignment.items).selectinload( AskAssignmentQuestion.question ), ask_assignment_loader.selectinload(AskAssignment.used_questions), ask_assignment_loader.selectinload(AskAssignment.batches).options( noload(AskAssignmentBatch.screenshots), noload(AskAssignmentBatch.review_decisions), ), ask_assignment_loader.options( noload(AskAssignment.screenshots), noload(AskAssignment.review_decisions), ), ) .where(AskTask.id.in_(ask_ids)) ) ask_tasks = db.scalars(ask_statement).unique().all() answer_map = {record.id: _task_board_data(record, include_images=False) for record in records} ask_map = {task.id: _ask_task_data(task, include_questions=False) for task in ask_tasks} data = [ (answer_map if kind == "answer" else ask_map)[record_id] for kind, record_id in page_rows ] return {"success": True, "data": { "items": data, "page": page, "page_size": page_size, "total": total, "total_pages": total_pages, }} @app.get("/api/admin/task-board/ask-tasks/{task_id}") def admin_task_board_ask_task_detail(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): task = db.get(AskTask, task_id) if not task or task.status not in {"已发布", "已完成", "已撤销"} or not _can_access_ask_task(task, user): raise HTTPException(status_code=404, detail="提问任务不存在") return {"success": True, "data": _ask_task_data(task, include_questions=True)} @app.delete("/api/admin/task-board/ask-tasks/{task_id}") def admin_task_board_delete_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): task = db.get(AskTask, task_id) _require_ask_task_access(task, user) # AskTask's assignment relationship is intentionally not orphan-cascaded # because assignments may be created by users. Remove those dependent # records explicitly before deleting the task to keep every status # deletable without leaving invalid foreign keys behind. for assignment in list(task.assignments): db.delete(assignment) db.delete(task) db.commit() return {"success": True, "data": {"id": task_id}} @app.get("/api/admin/task-board/ask-batches/{batch_id}") def admin_task_board_ask_batch_detail(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batch = db.scalar( select(AskAssignmentBatch) .where(AskAssignmentBatch.id == batch_id) .options(selectinload(AskAssignmentBatch.screenshots).load_only( AskAssignmentBatchScreenshot.id, AskAssignmentBatchScreenshot.batch_id, AskAssignmentBatchScreenshot.stage, AskAssignmentBatchScreenshot.filename, AskAssignmentBatchScreenshot.content_type, )) ) if not batch or batch.status not in {"approved", "paid"} or not _can_access_ask_task(batch.assignment.task, user): raise HTTPException(status_code=404, detail="待返款提问记录不存在") return {"success": True, "data": _ask_review_board_data(batch, include_images=True, image_endpoints=True)} @app.delete("/api/admin/task-board/ask-batches/{batch_id}") def admin_task_board_delete_ask_batch(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batch = db.get(AskAssignmentBatch, batch_id) if not batch or not _can_access_ask_task(batch.assignment.task, user): raise HTTPException(status_code=404, detail="提问提交记录不存在") task = batch.assignment.task db.delete(batch) db.flush() db.expire(task, ["assignments"]) _sync_ask_task_status(task) db.commit() return {"success": True, "data": {"id": batch_id}} @app.get("/api/admin/task-board/ask-assignments/{assignment_id}") def admin_task_board_ask_assignment_detail(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): assignment = db.scalar( select(AskAssignment) .where(AskAssignment.id == assignment_id) .options(selectinload(AskAssignment.screenshots).load_only( AskAssignmentScreenshot.id, AskAssignmentScreenshot.assignment_id, AskAssignmentScreenshot.stage, AskAssignmentScreenshot.filename, AskAssignmentScreenshot.content_type, )) ) if not assignment or assignment.status not in {"已通过", "已完成"} or not _can_access_ask_task(assignment.task, user): raise HTTPException(status_code=404, detail="待返款提问记录不存在") return {"success": True, "data": _ask_review_board_data(assignment, include_images=True, image_endpoints=True)} @app.delete("/api/admin/task-board/ask-assignments/{assignment_id}") def admin_task_board_delete_ask_assignment(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): assignment = db.get(AskAssignment, assignment_id) if not assignment or not _can_access_ask_task(assignment.task, user): raise HTTPException(status_code=404, detail="提问分配记录不存在") task = assignment.task db.delete(assignment) db.flush() db.expire(task, ["assignments"]) _sync_ask_task_status(task) db.commit() return {"success": True, "data": {"id": assignment_id}} def _save_refund_recipient(db, kind, target_id, account, name): # Legacy clients can still submit screenshots without bank details. if not account.strip(): return if not account.strip() or not name.strip() or len(account.strip()) > 128 or len(name.strip()) > 64: raise HTTPException(status_code=400, detail="请填写支付宝账号和收款人姓名") recipient = db.scalar(select(RefundRecipient).where(RefundRecipient.target_kind == kind, RefundRecipient.target_id == target_id)) if not recipient: recipient = RefundRecipient(target_kind=kind, target_id=target_id) db.add(recipient) recipient.account = account.strip() recipient.payee_name = name.strip() def _payable_target(db, kind, target_id, user): target, payable = _refund_session_target_for_payload(kind, target_id, db) if not target or not payable: raise HTTPException(status_code=409, detail="任务已不在待返款状态") assignment = target.assignment if kind == "ask_batch" else target allowed = _can_access_ask_task(assignment.task, user) if kind != "answer_submission" else _can_access_answer_submission(target, user) if not allowed: raise HTTPException(status_code=404, detail="无权操作该任务") return target @app.get("/api/admin/alipay-refunds") def list_alipay_refunds(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): result = [] for kind, model, payable_status in [("answer_submission", AnswerSubmission, "已通过"), ("ask_batch", AskAssignmentBatch, "approved"), ("ask_assignment", AskAssignment, "已通过")]: for target in db.scalars(select(model).where(model.status == payable_status)).all(): try: _payable_target(db, kind, target.id, user) except HTTPException: continue if kind == "ask_assignment" and target.batches: continue recipient = db.scalar(select(RefundRecipient).where(RefundRecipient.target_kind == kind, RefundRecipient.target_id == target.id)) order = db.scalar(select(AlipayRefundOrder).where(AlipayRefundOrder.target_kind == kind, AlipayRefundOrder.target_id == target.id)) assignment = target.assignment if kind == "ask_batch" else target detail_path = {"answer_submission": f"/api/admin/task-board/{target.id}", "ask_batch": f"/api/admin/task-board/ask-batches/{target.id}", "ask_assignment": f"/api/admin/task-board/ask-assignments/{target.id}"}[kind] result.append({"target_kind": kind, "target_id": target.id, "task_no": target.submission_no if kind == "answer_submission" else assignment.task.task_no, "account": order.account if order else recipient.account if recipient else "", "payee_name": order.payee_name if order else recipient.payee_name if recipient else "", "amount": str(Decimal(order.amount_fen) / 100) if order else "", "payment_status": "待支付(代付接口未接入)" if order else "待提交", "submitted": bool(order), "taobao_id": assignment.user.taobao_id if kind != "answer_submission" else target.user.taobao_id, "shop_name": assignment.task.shop_name if kind != "answer_submission" else target.shop_name, "product_name": assignment.task.product_name if kind != "answer_submission" else (target.questions[0].product_title if target.questions else "待识别商品"), "detail_endpoint": detail_path}) return {"success": True, "data": result} @app.post("/api/admin/alipay-refunds") def submit_alipay_refunds(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): items = payload.get("items") if not isinstance(items, list) or not 1 <= len(items) <= 100: raise HTTPException(status_code=400, detail="每次请选择1至100笔返款") seen = set() for item in items: try: kind, target_id = item["target_kind"], int(item["target_id"]) amount = Decimal(str(item["amount"])) if not amount.is_finite() or amount <= 0 or amount > Decimal("100000") or amount != amount.quantize(Decimal("0.01")): raise ValueError() except (KeyError, TypeError, ValueError, InvalidOperation): raise HTTPException(status_code=400, detail="金额必须大于0,最多两位小数,单笔不超过100000元") if (kind, target_id) in seen: raise HTTPException(status_code=400, detail="请勿重复选择同一笔任务") seen.add((kind, target_id)) _payable_target(db, kind, target_id, user) if db.scalar(select(AlipayRefundOrder.id).where(AlipayRefundOrder.target_kind == kind, AlipayRefundOrder.target_id == target_id)): raise HTTPException(status_code=409, detail="该任务已提交返款,请勿重复提交") account, name = str(item.get("account") or "").strip(), str(item.get("payee_name") or "").strip() if not account or not name or len(account) > 128 or len(name) > 64: raise HTTPException(status_code=400, detail="请填写支付宝账号和收款人姓名") db.add(AlipayRefundOrder(target_kind=kind, target_id=target_id, amount_fen=int(amount * 100), account=account, payee_name=name, created_by_id=user.id)) try: db.commit() except IntegrityError: db.rollback() raise HTTPException(status_code=409, detail="任务已提交,请刷新后重试") # Provider integration intentionally not invoked; no money is transferred. return {"success": True, "data": {"count": len(items), "status": "pending", "message": "已保存,待支付;代付接口尚未接入"}} @app.post("/api/admin/refund-upload-sessions") def create_refund_upload_session(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): """Create a short-lived, record-bound upload URL for the operator modal.""" target_kind = str(payload.get("target_kind") or "").strip() try: target_id = int(payload.get("target_id")) except (TypeError, ValueError): raise HTTPException(status_code=400, detail="返款记录无效") target, payable = _refund_session_target_for_payload(target_kind, target_id, db) if not target or not payable: raise HTTPException(status_code=409, detail="当前任务不在待返款状态") if target_kind in {"ask_batch", "ask_assignment"}: if not _can_access_ask_task(target.assignment.task, user): raise HTTPException(status_code=404, detail="无权操作该返款记录") elif not _can_access_answer_submission(target, user): raise HTTPException(status_code=404, detail="无权操作该返款记录") # 24 random bytes keep the full LAN upload URL inside a compact QR # version while still providing 192 bits of entropy. token = secrets.token_urlsafe(24) session = RefundUploadSession( token=token, target_kind=target_kind, target_id=target_id, created_by_id=user.id, expires_at=datetime.utcnow() + REFUND_UPLOAD_SESSION_TTL, ) db.add(session) db.commit() return {"success": True, "data": { "token": token, "upload_url": f"/refund-upload/{token}", "upload_url_hint": f"手机浏览器打开:/refund-upload/{token}", "expires_at": session.expires_at.isoformat(), "expires_in": int(REFUND_UPLOAD_SESSION_TTL.total_seconds()), "target_kind": target_kind, "target_id": target_id, }} @app.get("/api/refund-upload-sessions/{token}") def get_refund_upload_session(token: str, db: Session = Depends(get_db)): session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == token)) if not session: raise HTTPException(status_code=404, detail="二维码无效或已失效") target, payable = _refund_session_target(session, db) if _refund_session_expired(session): raise HTTPException(status_code=410, detail="二维码已过期,请让运营重新生成") if not target or not payable: raise HTTPException(status_code=410, detail="该返款任务已完成或不可操作") return {"success": True, "data": _refund_session_payload(session, target)} @app.post("/api/refund-upload-sessions/{token}/upload") async def upload_refund_proof_by_session(token: str, refund_file: UploadFile = File(...), db: Session = Depends(get_db)): session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == token)) if not session: raise HTTPException(status_code=404, detail="二维码无效或已失效") if _refund_session_expired(session): raise HTTPException(status_code=410, detail="二维码已过期,请让运营重新生成") if session.used_at: raise HTTPException(status_code=409, detail="该返款任务已经确认完成") target, payable = _refund_session_target(session, db) if not target or not payable: raise HTTPException(status_code=410, detail="该返款任务已完成或不可操作") data, content_type, filename = await _read_refund_file(refund_file) # A re-upload replaces the pending proof for this target, so the operator # always confirms the image currently visible on the phone. for image in list(target.screenshots): if image.stage == "refund": db.delete(image) if session.target_kind == "ask_batch": db.add(AskAssignmentBatchScreenshot( batch_id=target.id, stage="refund", **screenshot_fields(data, filename, content_type, "ask-batches"), )) elif session.target_kind == "ask_assignment": db.add(AskAssignmentScreenshot( assignment_id=target.id, stage="refund", **screenshot_fields(data, filename, content_type, "ask-assignments"), )) else: db.add(AnswerScreenshot( submission_id=target.id, stage="refund", **screenshot_fields(data, filename, content_type, "answers"), )) session.uploaded_at = datetime.utcnow() db.commit() db.refresh(session) return {"success": True, "data": {"uploaded": True, "filename": filename, "uploaded_at": session.uploaded_at.isoformat()}} @app.post("/api/admin/task-board/ask-batches/{batch_id}/confirm-refund") async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile | None = File(None), upload_token: str | None = Form(None), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batch = db.get(AskAssignmentBatch, batch_id) if not batch or not _can_access_ask_task(batch.assignment.task, user): raise HTTPException(status_code=404, detail="待返款提问记录不存在") if batch.status == "paid": raise HTTPException(status_code=409, detail="该任务已经完成返款") if batch.status != "approved": raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") if refund_file is not None: refund_data, content_type, filename = await _read_refund_file(refund_file) for image in list(batch.screenshots): if image.stage == "refund": db.delete(image) db.add(AskAssignmentBatchScreenshot( batch_id=batch.id, stage="refund", **screenshot_fields(refund_data, filename, content_type, "ask-batches"), )) elif not any(image.stage == "refund" for image in batch.screenshots): raise HTTPException(status_code=400, detail="请先上传返款凭证,或用手机扫描二维码上传") if upload_token: session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == upload_token, RefundUploadSession.target_kind == "ask_batch", RefundUploadSession.target_id == batch.id)) if not session or _refund_session_expired(session) or session.used_at: raise HTTPException(status_code=409, detail="扫码上传会话无效或已过期,请重新生成") session.used_at = datetime.utcnow() batch.status = "paid" batch.updated_at = datetime.utcnow() _sync_ask_task_status(batch.assignment.task) db.commit(); db.refresh(batch) return {"success": True, "data": _ask_review_board_data(batch, include_images=True, image_endpoints=True)} @app.post("/api/admin/task-board/ask-assignments/{assignment_id}/confirm-refund") async def confirm_ask_assignment_refund(assignment_id: int, refund_file: UploadFile | None = File(None), upload_token: str | None = Form(None), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): assignment = db.get(AskAssignment, assignment_id) if not assignment or not _can_access_ask_task(assignment.task, user): raise HTTPException(status_code=404, detail="待返款提问记录不存在") if assignment.status == "已完成": raise HTTPException(status_code=409, detail="该任务已经完成返款") if assignment.status != "已通过": raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") if refund_file is not None: refund_data, content_type, filename = await _read_refund_file(refund_file) for image in list(assignment.screenshots): if image.stage == "refund": db.delete(image) db.add(AskAssignmentScreenshot( assignment_id=assignment.id, stage="refund", **screenshot_fields(refund_data, filename, content_type, "ask-assignments"), )) elif not any(image.stage == "refund" for image in assignment.screenshots): raise HTTPException(status_code=400, detail="请先上传返款凭证,或用手机扫描二维码上传") if upload_token: session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == upload_token, RefundUploadSession.target_kind == "ask_assignment", RefundUploadSession.target_id == assignment.id)) if not session or _refund_session_expired(session) or session.used_at: raise HTTPException(status_code=409, detail="扫码上传会话无效或已过期,请重新生成") session.used_at = datetime.utcnow() assignment.status = "已完成" assignment.updated_at = datetime.utcnow() _sync_ask_task_status(assignment.task) db.commit(); db.refresh(assignment) return {"success": True, "data": _ask_review_board_data(assignment, include_images=True, image_endpoints=True)} @app.get("/api/admin/task-board/{submission_id}") def admin_task_board_detail(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.scalar( select(AnswerSubmission) .where(AnswerSubmission.id == submission_id) .options(selectinload(AnswerSubmission.screenshots).load_only( AnswerScreenshot.id, AnswerScreenshot.submission_id, AnswerScreenshot.stage, AnswerScreenshot.filename, AnswerScreenshot.content_type, )) ) if not record or record.status not in {"待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"} or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="任务不存在") return {"success": True, "data": _task_board_data(record, image_endpoints=True)} @app.delete("/api/admin/task-board/{submission_id}") def admin_task_board_delete_submission(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="任务不存在") db.delete(record) db.commit() return {"success": True, "data": {"id": submission_id}} @app.post("/api/admin/task-board/{submission_id}/confirm-refund") async def confirm_answer_refund(submission_id: int, refund_file: UploadFile | None = File(None), upload_token: str | None = Form(None), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="待返款回答记录不存在") if record.status == "已完成": raise HTTPException(status_code=409, detail="该任务已经完成返款") if record.status != "已通过": raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") if refund_file is not None: refund_data, content_type, filename = await _read_refund_file(refund_file) for image in list(record.screenshots): if image.stage == "refund": db.delete(image) db.add(AnswerScreenshot( submission_id=record.id, stage="refund", **screenshot_fields(refund_data, filename, content_type, "answers"), )) elif not any(image.stage == "refund" for image in record.screenshots): raise HTTPException(status_code=400, detail="请先上传返款凭证,或用手机扫描二维码上传") if upload_token: session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == upload_token, RefundUploadSession.target_kind == "answer_submission", RefundUploadSession.target_id == record.id)) if not session or _refund_session_expired(session) or session.used_at: raise HTTPException(status_code=409, detail="扫码上传会话无效或已过期,请重新生成") session.used_at = datetime.utcnow() record.status = "已完成" db.commit(); db.refresh(record) return {"success": True, "data": _task_board_data(record, include_images=True, image_endpoints=True)} @app.post("/api/admin/task-board/{submission_id}/cancel") def admin_task_board_cancel(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="任务不存在") if record.status == "已撤销": raise HTTPException(status_code=409, detail="任务已经撤销") if record.status == "已通过": raise HTTPException(status_code=409, detail="已完成任务不能撤销") if record.status not in {"待用户完成", "待审核", "未通过"}: raise HTTPException(status_code=400, detail="当前状态不能撤销") record.status = "已撤销" db.commit(); db.refresh(record) return {"success": True, "data": _task_board_data(record)} @app.post("/api/answer/me/submissions/{submission_id}/completion") async def answer_completion(submission_id: int, token: str = Form(...), owner_token: str = Form(...), files: list[UploadFile] = File(...), payment_files: list[UploadFile] = File(default=[]), alipay_account: str = Form(default=""), payee_name: str = Form(default=""), db: Session = Depends(get_db)): user = _answer_user_from_token(token, db); owner = _answer_link_owner(owner_token, db); record = db.get(AnswerSubmission, submission_id) if not record or record.user_id != user.id or record.owner_id != owner.id: raise HTTPException(status_code=404, detail="任务不存在") record_status = record.status if record_status not in ("待用户完成", "未通过"): raise HTTPException(status_code=400, detail="当前任务不能提交完成截图") if not files or len(files) > 10: raise HTTPException(status_code=400, detail="最多上传10张截图") if not payment_files or len(payment_files) > 1: raise HTTPException(status_code=400, detail="请上传收款二维码后再提交") db.rollback() uploaded = [] for upload in files: if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: raise HTTPException(status_code=400, detail="仅支持图片") uploaded.append(("completion", upload.filename or "completion", upload.content_type, await upload.read())) for upload in payment_files[:1]: if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: raise HTTPException(status_code=400, detail="收款码仅支持图片") uploaded.append(("payment", upload.filename or "payment", upload.content_type, await upload.read())) record = db.get(AnswerSubmission, submission_id) if not record or record.user_id != user.id or record.status != record_status: raise HTTPException(status_code=409, detail="任务状态已发生变化,请刷新后重试") if record.owner_id != owner.id: raise HTTPException(status_code=404, detail="任务不存在") if record_status == "未通过": for old_screenshot in list(record.screenshots): if old_screenshot.stage in {"completion", "payment"}: db.delete(old_screenshot) for stage, filename, content_type, image_data in uploaded: db.add(AnswerScreenshot( submission_id=record.id, stage=stage, **screenshot_fields(image_data, filename, content_type, "answers"), )) _save_refund_recipient(db, "answer_submission", record.id, alipay_account, payee_name) record.status = "待审核"; db.commit() # 只返回轻量状态,避免重新编码截图阻塞审核状态切换。 return {"success": True, "data": _submission_data(record, include_images=False)} @app.get("/api/admin/answer-completion-reviews") def answer_completion_reviews(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): records = db.scalars( select(AnswerSubmission) .where(AnswerSubmission.status == "待审核") .where(_answer_owner_clause(user)) .order_by(AnswerSubmission.updated_at.desc()) ).unique().all() return {"success": True, "data": [_answer_review_data(record, include_images=False) for record in records]} @app.get("/api/admin/answer-completion-reviews/{submission_id}") def answer_completion_review_detail(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.scalar( select(AnswerSubmission) .where(AnswerSubmission.id == submission_id) .options( selectinload(AnswerSubmission.screenshots).load_only( AnswerScreenshot.id, AnswerScreenshot.submission_id, AnswerScreenshot.stage, AnswerScreenshot.filename, AnswerScreenshot.content_type, ) ) ) if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="任务不存在") return {"success": True, "data": _answer_review_data(record, image_endpoints=True)} @app.get("/api/admin/answer-screenshots/{screenshot_id}") def admin_answer_screenshot(screenshot_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): image = db.get(AnswerScreenshot, screenshot_id) if not image or not _can_access_answer_submission(image.submission, user): raise HTTPException(status_code=404, detail="截图不存在") return screenshot_response(image) @app.post("/api/admin/answer-completion-reviews/{submission_id}/decision") def answer_completion_decision(submission_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="任务不存在") if record.status != "待审核": raise HTTPException(status_code=409, detail="任务不在待审核状态") approved = bool(payload.get("approved")) reason_code = str(payload.get("reason_code", "")).strip() or None reason_text = str(payload.get("reason_text", "")).strip() or None if not approved and not (reason_code or reason_text): raise HTTPException(status_code=400, detail="请选择或填写审核不通过原因") db.add(AnswerReviewDecision(submission_id=record.id, approved=approved, reason_code=reason_code, reason_text=reason_text)) record.status = "已通过" if approved else "未通过"; db.commit(); db.refresh(record) return {"success": True, "data": _submission_data(record, include_images=False)} @app.post("/api/answer/public/submissions") async def public_answer_submission( client_id: str = Form(default="anonymous-browser"), user_name: str = Form(default=""), files: list[UploadFile] = File(...), ): if not files or len(files) > 10: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最多上传10张截图") screenshots = [] for upload in files: if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片") raw = await upload.read() screenshots.append({"name": upload.filename or "screenshot", "content_type": upload.content_type, "data_url": f"data:{upload.content_type};base64,{base64.b64encode(raw).decode()}"}) submission_id = f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}" recognized = [{"id": f"Q{index + 1:03d}", "question": question["question"], "source_image": screenshots[index % len(screenshots)]["name"]} for index, question in enumerate(ANSWER_TASKS["TASK20260822002"]["questions"])] record = {"task_id": submission_id, "client_id": client_id.strip()[:64], "user_name": user_name.strip()[:64] or "未填写", "product_name": "待识别商品", "status": "待筛选", "recognized_questions": recognized, "invited_screenshots": screenshots, "final_screenshots": [], "selected_question_ids": [], "updated_at": datetime.utcnow().isoformat()} PUBLIC_ANSWER_SUBMISSIONS.append(record) return {"success": True, "data": record} @app.get("/api/answer/public/submissions") def public_answer_submission_history(client_id: str): if not client_id: return {"success": True, "data": []} records = [item for item in PUBLIC_ANSWER_SUBMISSIONS if item.get("client_id") == client_id] return {"success": True, "data": sorted(records, key=lambda item: item["updated_at"], reverse=True)} @app.get("/api/answer/tasks/{task_id}") def answer_task_detail(task_id: str, token: str): return {"success": True, "data": _public_answer_task(_answer_task(task_id, token))} @app.post("/api/answer/tasks/{task_id}/invited-screenshots") async def upload_invited_screenshots( task_id: str, token: str = Form(...), user_name: str = Form(default=""), files: list[UploadFile] = File(...), ): task = _answer_task(task_id, token) if not files or len(files) > 10: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最多上传10张截图") screenshots = [] for upload in files: if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片") raw = await upload.read() # MVP 暂存内存,后续接入对象存储;不写入数据库或磁盘。 screenshots.append({"name": upload.filename or "screenshot", "content_type": upload.content_type, "data_url": f"data:{upload.content_type};base64,{base64.b64encode(raw).decode()}"}) task["invited_screenshots"] = screenshots task["user_name"] = user_name.strip()[:64] task["recognized_questions"] = [ {"id": question["id"], "question": question["question"], "source_image": screenshots[index % len(screenshots)]["name"]} for index, question in enumerate(task["questions"]) ] task["status"] = "待筛选" task["updated_at"] = datetime.utcnow().isoformat() return {"success": True, "data": {"status": "已提交审核", "recognized_count": len(task["recognized_questions"])}} @app.post("/api/answer/tasks/{task_id}/select") def select_answer_questions(task_id: str, payload: dict): task = _answer_task(task_id, str(payload.get("token", ""))) question_ids = payload.get("question_ids") or [] valid_ids = {question["id"] for question in task["questions"]} task["selected_question_ids"] = [str(value) for value in question_ids if str(value) in valid_ids] task["status"] = "待完成" task["updated_at"] = datetime.utcnow().isoformat() return {"success": True, "data": _public_answer_task(task)} @app.post("/api/answer/tasks/{task_id}/final-screenshots") async def upload_final_screenshots( task_id: str, token: str = Form(...), files: list[UploadFile] = File(...), ): task = _answer_task(task_id, token) if not files or len(files) > 10: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最多上传10张截图") screenshots = [] for upload in files: if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片") raw = await upload.read() screenshots.append({"name": upload.filename or "screenshot", "content_type": upload.content_type, "data_url": f"data:{upload.content_type};base64,{base64.b64encode(raw).decode()}"}) task["final_screenshots"] = screenshots task["status"] = "待审核" task["updated_at"] = datetime.utcnow().isoformat() return {"success": True, "data": {"status": "已提交审核"}} @app.get("/api/admin/reviews") def reviews(_: AdminUser = Depends(current_user)): records = list(PUBLIC_ANSWER_SUBMISSIONS) for task in ANSWER_TASKS.values(): if task["status"] in {"待筛选", "待审核", "已通过", "未通过"}: records.append({ "task_id": task["task_id"], "user_name": task.get("user_name") or "未填写", "status": task["status"], "product_name": task["product_name"], "recognized_questions": task.get("recognized_questions", []), "selected_questions": [q for q in task["questions"] if q["id"] in set(task.get("selected_question_ids", []))], "invited_screenshots": [{"name": x["name"], "data_url": x["data_url"]} for x in task.get("invited_screenshots", [])], "final_screenshots": [{"name": x["name"], "data_url": x["data_url"]} for x in task.get("final_screenshots", [])], "updated_at": task["updated_at"], }) return {"success": True, "data": records} @app.post("/api/admin/reviews/{task_id}/decision") def review_decision(task_id: str, payload: dict, _: AdminUser = Depends(current_user)): task = ANSWER_TASKS.get(task_id) or next((item for item in PUBLIC_ANSWER_SUBMISSIONS if item["task_id"] == task_id), None) if not task: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审核记录不存在") task["status"] = "已通过" if bool(payload.get("approved")) else "未通过" task["updated_at"] = datetime.utcnow().isoformat() return {"success": True, "data": {"task_id": task_id, "status": task["status"]}} @app.post("/api/admin/qa/generate") def qa_generate(payload: QAGenerateRequest, _: AdminUser = Depends(current_user)): intent_error = _product_intent_error([payload.product_name, payload.category, payload.benefits, payload.selling_points]) if intent_error: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=intent_error) options_error = _generation_options_error(payload.model_dump(), include_answers=True) if options_error: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=options_error) try: return {"success": True, "data": {"items": generate_qa(payload)}} except HTTPException: raise except Exception as exc: # Do not leak a raw 500 to the UI. Unexpected provider/parser errors # are surfaced as a retryable upstream error with a server traceback. import logging logging.getLogger(__name__).exception("QA generation failed") raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="问答生成服务异常,请检查大模型配置后重试") from exc @app.post("/api/admin/qa/generate-answers") def qa_generate_answers(payload: QABulkAnswerRequest, _: AdminUser = Depends(current_user)): intent_error = _product_intent_error([payload.product_name, payload.category, payload.benefits, payload.selling_points]) if intent_error: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=intent_error) questions = _dedupe_import_questions(payload.questions) if not questions: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请至少导入一个有效问题") try: return {"success": True, "data": {"items": generate_answers_for_questions(payload, questions)}} except HTTPException: raise except Exception as exc: import logging logging.getLogger(__name__).exception("Bulk answer generation failed") raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="批量回答生成服务异常,请稍后重试") from exc def _qa_bulk_job_data(job: QABulkGenerationJob, include_items: bool = False) -> dict: processed = job.completed_count + job.failed_count data = { "id": job.public_id, "status": job.status, "total": job.total_count, "completed": job.completed_count, "failed": job.failed_count, "pending": max(0, job.total_count - processed), "progress_percent": round(processed * 100 / job.total_count) if job.total_count else 100, "answers_per_question": job.answers_per_question, "created_at": job.created_at.isoformat(), "updated_at": job.updated_at.isoformat(), } if include_items: succeeded = [] failed = [] for item in sorted(job.items, key=lambda value: value.sequence_no): if item.status == "succeeded": try: answers = json.loads(item.answers_json or "[]") except (TypeError, ValueError): answers = [] succeeded.append({ "id": f"Q{item.sequence_no:03d}", "question": item.question_text, "answers": answers, "source": "批量导入(AI回答)", "status": "待采纳", "accepted": False, }) elif item.status == "failed": failed.append({ "sequence_no": item.sequence_no, "question": item.question_text, "attempts": item.attempts, "error": item.error_message or "生成失败", }) data["items"] = succeeded data["failed_items"] = failed return data def _qa_bulk_job_for_user(public_id: str, user: AdminUser, db: Session) -> QABulkGenerationJob: job = db.scalar(select(QABulkGenerationJob).where(QABulkGenerationJob.public_id == public_id)) if not job or (user.role_code != "super_admin" and job.owner_id != user.id): raise HTTPException(status_code=404, detail="批量生成任务不存在") return job def _refresh_qa_bulk_job_counts(db: Session, job: QABulkGenerationJob, final: bool = False) -> None: # SessionLocal disables autoflush. Flush item status updates first so the # aggregate query always reports the progress that was just persisted. db.flush() job.completed_count = int(db.scalar( select(func.count(QABulkGenerationItem.id)).where( QABulkGenerationItem.job_id == job.id, QABulkGenerationItem.status == "succeeded", ) ) or 0) job.failed_count = int(db.scalar( select(func.count(QABulkGenerationItem.id)).where( QABulkGenerationItem.job_id == job.id, QABulkGenerationItem.status == "failed", ) ) or 0) if final: if job.failed_count: job.status = "partial_failed" if job.completed_count else "failed" else: job.status = "completed" job.updated_at = datetime.utcnow() def _qa_bulk_error_message(exc: Exception) -> str: if isinstance(exc, HTTPException): return str(exc.detail)[:500] return "大模型生成异常,请重试失败问题" def _process_qa_bulk_job(job_id: int) -> None: logger = logging.getLogger(__name__) with SessionLocal() as db: claimed = db.execute( update(QABulkGenerationJob) .where(QABulkGenerationJob.id == job_id, QABulkGenerationJob.status == "queued") .values(status="processing", updated_at=datetime.utcnow()) ) if claimed.rowcount != 1: db.rollback() return # A process can stop after marking a group as processing. A later # retry should safely resume those records instead of leaving them # permanently stuck. db.execute( update(QABulkGenerationItem) .where( QABulkGenerationItem.job_id == job_id, QABulkGenerationItem.status == "processing", ) .values(status="pending") ) db.commit() try: job = db.get(QABulkGenerationJob, job_id) if not job: return payload = QABulkAnswerRequest(**json.loads(job.payload_json)) pending_ids = list(db.scalars( select(QABulkGenerationItem.id) .where( QABulkGenerationItem.job_id == job_id, QABulkGenerationItem.status == "pending", ) .order_by(QABulkGenerationItem.sequence_no) ).all()) batch_size = max(1, min(10, 30 // payload.answers_per_question)) for offset in range(0, len(pending_ids), batch_size): item_ids = pending_ids[offset:offset + batch_size] batch_items = list(db.scalars( select(QABulkGenerationItem) .where(QABulkGenerationItem.id.in_(item_ids)) .order_by(QABulkGenerationItem.sequence_no) ).all()) questions = [item.question_text for item in batch_items] last_error = "批量回答生成不完整,请重试" for attempt in range(QA_BULK_MAX_ATTEMPTS): db.execute( update(QABulkGenerationItem) .where(QABulkGenerationItem.id.in_(item_ids)) .values( status="processing", attempts=QABulkGenerationItem.attempts + 1, error_message=None, updated_at=datetime.utcnow(), ) ) job.updated_at = datetime.utcnow() db.commit() try: answer_groups = generate_answers_batch(payload, questions) if len(answer_groups) != len(batch_items): raise ValueError("answer group count mismatch") if any( not isinstance(answers, list) or len([answer for answer in answers if str(answer).strip()]) < payload.answers_per_question for answers in answer_groups ): raise ValueError("answer count mismatch") except Exception as exc: last_error = _qa_bulk_error_message(exc) logger.warning( "QA bulk job %s batch %s failed on attempt %s/%s: %s", job.public_id, offset // batch_size + 1, attempt + 1, QA_BULK_MAX_ATTEMPTS, last_error, ) if attempt + 1 < QA_BULK_MAX_ATTEMPTS: time.sleep(2 ** attempt) continue db.execute( update(QABulkGenerationItem) .where(QABulkGenerationItem.id.in_(item_ids)) .values( status="failed", error_message=last_error, updated_at=datetime.utcnow(), ) ) break else: for item, answers in zip(batch_items, answer_groups): stored = db.get(QABulkGenerationItem, item.id) stored.answers_json = json.dumps(answers, ensure_ascii=False) stored.status = "succeeded" stored.error_message = None stored.updated_at = datetime.utcnow() break _refresh_qa_bulk_job_counts(db, job) db.commit() _refresh_qa_bulk_job_counts(db, job, final=True) db.commit() except Exception: db.rollback() logger.exception("QA bulk background job %s crashed", job_id) job = db.get(QABulkGenerationJob, job_id) if not job: return db.execute( update(QABulkGenerationItem) .where( QABulkGenerationItem.job_id == job_id, QABulkGenerationItem.status.in_(["pending", "processing"]), ) .values( status="failed", error_message="后台生成任务异常,请重试失败问题", updated_at=datetime.utcnow(), ) ) _refresh_qa_bulk_job_counts(db, job, final=True) db.commit() @app.post("/api/admin/qa/generate-answer-jobs", status_code=status.HTTP_202_ACCEPTED) def create_qa_bulk_generation_job( payload: QABulkAnswerRequest, background_tasks: BackgroundTasks, user: AdminUser = Depends(current_user), db: Session = Depends(get_db), ): intent_error = _product_intent_error([payload.product_name, payload.category, payload.benefits, payload.selling_points]) if intent_error: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=intent_error) questions = _dedupe_import_questions(payload.questions) if not questions: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请至少导入一个有效问题") normalized_payload = payload.model_dump() normalized_payload["questions"] = questions job = QABulkGenerationJob( public_id=uuid.uuid4().hex, owner_id=user.id, payload_json=json.dumps(normalized_payload, ensure_ascii=False), answers_per_question=payload.answers_per_question, status="queued", total_count=len(questions), ) db.add(job) db.flush() db.add_all([ QABulkGenerationItem(job_id=job.id, sequence_no=index, question_text=question) for index, question in enumerate(questions, start=1) ]) db.commit() db.refresh(job) background_tasks.add_task(_process_qa_bulk_job, job.id) return {"success": True, "data": _qa_bulk_job_data(job)} @app.get("/api/admin/qa/generate-answer-jobs/{job_public_id}") def get_qa_bulk_generation_job( job_public_id: str, include_items: bool = False, user: AdminUser = Depends(current_user), db: Session = Depends(get_db), ): job = _qa_bulk_job_for_user(job_public_id, user, db) return {"success": True, "data": _qa_bulk_job_data(job, include_items=include_items)} @app.post("/api/admin/qa/generate-answer-jobs/{job_public_id}/retry", status_code=status.HTTP_202_ACCEPTED) def retry_qa_bulk_generation_job( job_public_id: str, background_tasks: BackgroundTasks, user: AdminUser = Depends(current_user), db: Session = Depends(get_db), ): job = db.scalar( select(QABulkGenerationJob) .where(QABulkGenerationJob.public_id == job_public_id) .with_for_update() ) if not job or (user.role_code != "super_admin" and job.owner_id != user.id): raise HTTPException(status_code=404, detail="批量生成任务不存在") if job.status in {"queued", "processing"}: raise HTTPException(status_code=409, detail="批量生成任务仍在处理中") if job.failed_count < 1: raise HTTPException(status_code=409, detail="当前没有需要重试的失败问题") db.execute( update(QABulkGenerationItem) .where( QABulkGenerationItem.job_id == job.id, QABulkGenerationItem.status == "failed", ) .values(status="pending", error_message=None, updated_at=datetime.utcnow()) ) job.status = "queued" job.failed_count = 0 job.updated_at = datetime.utcnow() db.commit() db.refresh(job) background_tasks.add_task(_process_qa_bulk_job, job.id) return {"success": True, "data": _qa_bulk_job_data(job)} def _qa_library_data(entry: QALibraryEntry) -> dict: try: answers = json.loads(entry.answers_json) except (TypeError, ValueError): answers = [entry.answers_json] if entry.answers_json else [] return { "id": entry.id, "product_name": entry.product_name, "category": entry.category, "benefits": entry.benefits, "selling_points": entry.selling_points, "question": entry.question_text, "answers": answers, "source": entry.source, "created_by": entry.created_by, "created_at": entry.created_at.isoformat(), } @app.get("/api/admin/qa-library") def qa_library(search: str = "", _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): statement = select(QALibraryEntry) keyword = search.strip() if keyword: pattern = f"%{keyword}%" statement = statement.where( or_( QALibraryEntry.product_name.like(pattern), QALibraryEntry.category.like(pattern), QALibraryEntry.question_text.like(pattern), QALibraryEntry.answers_json.like(pattern), ) ) records = db.scalars(statement.order_by(QALibraryEntry.created_at.desc()).limit(500)).all() return {"success": True, "data": [_qa_library_data(record) for record in records]} @app.post("/api/admin/qa-library") def save_qa_library(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): raw_items = payload.get("items") or [] if not isinstance(raw_items, list) or not raw_items: raise HTTPException(status_code=400, detail="请选择需要采纳的问答") product_name = str(payload.get("product_name", "")).strip() category = str(payload.get("category", "")).strip() benefits = str(payload.get("benefits", "")).strip() selling_points = str(payload.get("selling_points", "")).strip() prepared = [] for item in raw_items: question = str(item.get("question", "")).strip() answers = [str(value).strip() for value in (item.get("answers") or []) if str(value).strip()] if not question or not answers: continue normalized = json.dumps( {"product": product_name, "question": question, "answers": answers}, ensure_ascii=False, sort_keys=True, ) fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest() prepared.append((fingerprint, question, answers, str(item.get("source", "AI生成")).strip() or "AI生成")) if not prepared: raise HTTPException(status_code=400, detail="问答内容不能为空") fingerprints = [item[0] for item in prepared] existing = set( db.scalars(select(QALibraryEntry.fingerprint).where(QALibraryEntry.fingerprint.in_(fingerprints))).all() ) created = [] for fingerprint, question, answers, source in prepared: if fingerprint in existing: continue entry = QALibraryEntry( fingerprint=fingerprint, product_name=product_name, category=category, benefits=benefits, selling_points=selling_points, question_text=question, answers_json=json.dumps(answers, ensure_ascii=False), source=source, created_by=user.display_name, ) db.add(entry) created.append(entry) existing.add(fingerprint) db.commit() for entry in created: db.refresh(entry) return { "success": True, "data": { "added_count": len(created), "skipped_count": len(prepared) - len(created), "items": [_qa_library_data(entry) for entry in created], }, } @app.patch("/api/admin/qa-library/{entry_id}") def update_qa_library(entry_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): entry = db.get(QALibraryEntry, entry_id) if not entry: raise HTTPException(status_code=404, detail="问答记录不存在") question = str(payload.get("question", entry.question_text)).strip() answers = [str(value).strip() for value in (payload.get("answers") or []) if str(value).strip()] if not question or not answers: raise HTTPException(status_code=400, detail="问题和回答不能为空") product_name = str(payload.get("product_name", entry.product_name)).strip() category = str(payload.get("category", entry.category)).strip() benefits = str(payload.get("benefits", entry.benefits)).strip() selling_points = str(payload.get("selling_points", entry.selling_points)).strip() normalized = json.dumps( {"product": product_name, "question": question, "answers": answers}, ensure_ascii=False, sort_keys=True, ) fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest() duplicate = db.scalar( select(QALibraryEntry).where( QALibraryEntry.fingerprint == fingerprint, QALibraryEntry.id != entry.id, ) ) if duplicate: raise HTTPException(status_code=409, detail="修改后的内容已存在于问答库") entry.fingerprint = fingerprint entry.product_name = product_name entry.category = category entry.benefits = benefits entry.selling_points = selling_points entry.question_text = question entry.answers_json = json.dumps(answers, ensure_ascii=False) entry.created_by = user.display_name db.commit() db.refresh(entry) return {"success": True, "data": _qa_library_data(entry)} @app.delete("/api/admin/qa-library/{entry_id}") def delete_qa_library(entry_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): entry = db.get(QALibraryEntry, entry_id) if not entry: raise HTTPException(status_code=404, detail="问答记录不存在") db.delete(entry) db.commit() return {"success": True, "data": {"id": entry_id}} @app.post("/api/admin/auth/login") def login(payload: LoginRequest, request: Request, response: Response, db: Session = Depends(get_db)): user = db.scalar(select(AdminUser).where(AdminUser.username == payload.username)) valid = bool(user and user.is_active and verify_password(payload.password, user.password_hash)) db.add(AdminLoginLog(user_id=user.id if user else None, username=payload.username, success=valid, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent"))) if not valid: db.commit() raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误") user.last_login_at = datetime.utcnow() db.commit() response.set_cookie(settings.cookie_name, create_session_token(user.id, user.role_code), httponly=True, secure=settings.cookie_secure, samesite="lax", max_age=settings.session_hours * 3600) return {"success": True, "data": {"user": public_user(user)}} IMPORT_QUESTION_HEADERS = {"问题", "提问", "问题内容", "题目", "question", "question_text", "q"} def _clean_import_question(value: object) -> str: text_value = re.sub(r"\s+", " ", str(value or "").replace("\ufeff", "").strip()) text_value = re.sub(r"^(?:[-*+]\s+|#{1,6}\s+|\d+[.、)]\s*)", "", text_value).strip() text_value = re.sub(r"^(?:问题|提问|question|q)\s*\d*\s*[::]\s*", "", text_value, flags=re.IGNORECASE).strip() if text_value.lower() in IMPORT_QUESTION_HEADERS: return "" if re.match(r"^(?:回答|答案|answer|a)\s*\d*\s*[::]", text_value, flags=re.IGNORECASE): return "" return text_value if 0 < len(text_value) <= 500 else "" def _dedupe_import_questions(values: list[object]) -> list[str]: result = [] seen = set() for value in values: question = _clean_import_question(value) key = question.casefold() if not question or key in seen: continue seen.add(key) result.append(question) return result def _decode_import_file(raw: bytes) -> str: for encoding in ("utf-8-sig", "gb18030"): try: return raw.decode(encoding) except UnicodeDecodeError: continue raise ValueError("文件编码无法识别") def _questions_from_rows(rows: list[list[object]]) -> list[str]: rows = [list(row) for row in rows if any(str(value or "").strip() for value in row)] if not rows: return [] header = [str(value or "").replace("\ufeff", "").strip().lower() for value in rows[0]] question_index = next((index for index, value in enumerate(header) if value in IMPORT_QUESTION_HEADERS), None) data_rows = rows[1:] if question_index is not None else rows values = [] for row in data_rows: if question_index is not None: values.append(row[question_index] if question_index < len(row) else "") else: values.append(next((value for value in row if str(value or "").strip()), "")) return _dedupe_import_questions(values) def _questions_from_json(value: object) -> list[str]: if isinstance(value, dict): for container_key in ("questions", "items", "data", "list"): if container_key in value: return _questions_from_json(value[container_key]) for question_key in ("question", "question_text", "问题", "提问", "问题内容", "题目"): if question_key in value: return _dedupe_import_questions([value[question_key]]) return [] if isinstance(value, list): questions = [] for item in value: questions.extend(_questions_from_json(item) if isinstance(item, (dict, list)) else _dedupe_import_questions([item])) return _dedupe_import_questions(questions) return _dedupe_import_questions([value]) @app.post("/api/admin/ask-questions/import") async def import_ask_questions(file: UploadFile = File(...), _: AdminUser = Depends(current_user)): """Parse a local question list without changing an existing task.""" filename = (file.filename or "").strip() suffix = Path(filename).suffix.lower() if suffix not in {".txt", ".csv", ".json", ".md", ".xlsx"}: raise HTTPException(status_code=400, detail="仅支持 TXT、CSV、JSON、Markdown、XLSX 文件") raw = await file.read() if not raw: raise HTTPException(status_code=400, detail="导入文件不能为空") if len(raw) > 5 * 1024 * 1024: raise HTTPException(status_code=400, detail="导入文件不能超过 5MB") try: if suffix == ".xlsx": from openpyxl import load_workbook workbook = load_workbook(io.BytesIO(raw), read_only=True, data_only=True) sheet = workbook.active questions = _questions_from_rows([list(row) for row in sheet.iter_rows(values_only=True)]) workbook.close() elif suffix == ".json": questions = _questions_from_json(json.loads(_decode_import_file(raw))) elif suffix == ".csv": questions = _questions_from_rows(list(csv.reader(io.StringIO(_decode_import_file(raw))))) else: lines = _decode_import_file(raw).splitlines() explicit_questions = [ line for line in lines if re.match(r"^\s*(?:[-*+]\s*)?(?:\d+[.、)]\s*)?(?:问题|提问|question|q)\s*\d*\s*[::]", line, flags=re.IGNORECASE) ] questions = _dedupe_import_questions(explicit_questions or lines) except (ValueError, UnicodeError) as exc: raise HTTPException(status_code=400, detail="无法读取文件,请确认格式和编码正确") from exc except Exception as exc: import logging logging.getLogger(__name__).exception("Question import parsing failed") raise HTTPException(status_code=400, detail="文件解析失败,请确认文件未损坏") from exc if not questions: raise HTTPException(status_code=400, detail="文件中没有识别到问题,请按每行一个问题整理后重试") if len(questions) > 500: questions = questions[:500] return {"success": True, "data": { "filename": filename, "questions": questions, "count": len(questions), }} @app.post("/api/admin/reference-qa/parse") async def parse_reference_qa(file: UploadFile = File(...), _: AdminUser = Depends(current_user)): """Extract readable question/answer text from an uploaded XLSX workbook. XLSX is a ZIP/XML format; parsing it on the server avoids treating the binary workbook as UTF-8 text in the browser (which produces mojibake). """ filename = (file.filename or "").lower() if not filename.endswith(".xlsx"): raise HTTPException(status_code=400, detail="仅支持解析 .xlsx 文件,请另存为 XLSX 后上传") raw = await file.read() try: try: from openpyxl import load_workbook workbook = load_workbook(io.BytesIO(raw), read_only=True, data_only=True) sheet = workbook.active rows = [[str(value).strip() if value is not None else "" for value in row] for row in sheet.iter_rows(values_only=True)] rows = [row for row in rows if any(row)] workbook.close() except ImportError: rows = None if rows is not None: if not rows: raise ValueError("工作表中没有可读取内容") header = [str(value).lower() for value in rows[0]] question_index = next((i for i, value in enumerate(header) if any(key in value for key in ("问题", "question", "q"))), None) answer_index = next((i for i, value in enumerate(header) if any(key in value for key in ("回答", "问答", "answer", "a"))), None) data_rows = rows[1:] if (question_index is not None or answer_index is not None) else rows lines = [] for values in data_rows: if question_index is not None and question_index < len(values): question = values[question_index] answer = values[answer_index] if answer_index is not None and answer_index < len(values) else "" if question or answer: lines.append(f"问题:{question}\n回答:{answer}") else: lines.append("\t".join(values)) text = "\n".join(lines).strip()[:20000] return {"success": True, "data": {"text": text, "filename": file.filename}} archive = zipfile.ZipFile(io.BytesIO(raw)) ns = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"} def xml_root(path: str): data = archive.read(path) # Some Windows exports declare UTF-8 but actually store Chinese # XML text as GB18030. Decode the XML bytes before parsing so the # original Chinese strings are not replaced with � characters. try: decoded = data.decode("utf-8") if "�" in decoded: decoded = data.decode("gb18030") except UnicodeDecodeError: decoded = data.decode("gb18030") return ET.fromstring(decoded) shared = [] if "xl/sharedStrings.xml" in archive.namelist(): root = xml_root("xl/sharedStrings.xml") shared = ["".join(node.itertext()) for node in root.findall("x:si", ns)] sheet_name = "xl/worksheets/sheet1.xml" if sheet_name not in archive.namelist(): raise ValueError("工作簿缺少首个工作表") root = xml_root(sheet_name) rows = [] for row in root.findall(".//x:sheetData/x:row", ns): values = [] for cell in row.findall("x:c", ns): cell_type = cell.attrib.get("t", "") value = cell.find("x:v", ns) if cell_type == "s" and value is not None: text = shared[int(value.text or "0")] if shared else "" elif cell_type == "inlineStr": text = "".join(cell.itertext()) else: text = value.text if value is not None and value.text else "" values.append(text.strip()) if any(values): rows.append(values) if not rows: raise ValueError("工作表中没有可读取内容") header = [str(value).lower() for value in rows[0]] question_index = next((i for i, value in enumerate(header) if any(key in value for key in ("问题", "question", "q"))), None) answer_index = next((i for i, value in enumerate(header) if any(key in value for key in ("回答", "问答", "answer", "a"))), None) lines = [] header = [str(value).lower() for value in rows[0]] question_index = next((i for i, value in enumerate(header) if any(key in value for key in ("问题", "question", "q"))), None) answer_index = next((i for i, value in enumerate(header) if any(key in value for key in ("回答", "问答", "answer", "a"))), None) data_rows = rows[1:] if (question_index is not None or answer_index is not None) else rows for values in data_rows: if question_index is not None and question_index < len(values): question = values[question_index] answer = values[answer_index] if answer_index is not None and answer_index < len(values) else "" if question or answer: lines.append(f"问题:{question}\n回答:{answer}") else: lines.append("\t".join(values)) text = "\n".join(lines).strip()[:20000] return {"success": True, "data": {"text": text, "filename": file.filename}} except (KeyError, ValueError, IndexError, ET.ParseError, zipfile.BadZipFile, UnicodeError) as exc: raise HTTPException(status_code=400, detail="无法读取该 XLSX 文件,请确认文件未损坏") from exc except Exception as exc: import logging logging.getLogger(__name__).exception("Reference XLSX parsing failed") raise HTTPException(status_code=400, detail="读取参考文件失败,请确认文件格式正确后重试") from exc @app.get("/api/admin/auth/dingtalk/config") def dingtalk_login_config(): return {"success": True, "data": {"enabled": _dingtalk_workbench_configured(), "corp_id": settings.dingtalk_corp_id, "agent_id": settings.dingtalk_agent_id, "redirect_uri": settings.dingtalk_redirect_uri}} @app.post("/api/admin/auth/dingtalk/workbench") def dingtalk_workbench_login(payload: dict, response: Response, db: Session = Depends(get_db)): if not _dingtalk_workbench_configured(): raise HTTPException(status_code=503, detail="钉钉工作台免登尚未配置,请联系管理员") code = str(payload.get("code") or "").strip() if not code: raise HTTPException(status_code=400, detail="钉钉免登授权码不能为空") profile = _dingtalk_workbench_user(code) access_token = profile.pop("_dingtalk_access_token", None) profile = _dingtalk_org_profile(profile, access_token) user = _dingtalk_create_session(profile, db, response) return {"success": True, "data": {"user": public_user(user)}} @app.get("/api/admin/auth/dingtalk/start") def dingtalk_login_start(): if not _dingtalk_configured(): raise HTTPException(status_code=503, detail="钉钉登录尚未配置,请联系管理员") state = secrets.token_urlsafe(24) DINGTALK_STATES[state] = time.time() + 300 params = urllib.parse.urlencode({"redirect_uri": settings.dingtalk_redirect_uri, "client_id": settings.dingtalk_client_id, "response_type": "code", "scope": "openid", "state": state}) return {"success": True, "data": {"url": f"https://login.dingtalk.com/oauth2/auth?{params}"}} @app.get("/api/admin/auth/dingtalk/callback") def dingtalk_login_callback(response: Response, code: str = "", state: str = "", db: Session = Depends(get_db)): expires_at = DINGTALK_STATES.pop(state, 0) if not expires_at or expires_at < time.time(): raise HTTPException(status_code=400, detail="钉钉登录链接已失效,请重新从工作台打开") user = _dingtalk_login(code, db, response) response.headers["Location"] = f"{settings.frontend_url}/?dingtalk=success" response.status_code = 302 return response @app.post("/api/admin/auth/register", status_code=status.HTTP_201_CREATED) def register(_: RegisterRequest): raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="运营后台已关闭自助注册,请联系管理员创建账号") @app.get("/api/admin/auth/me") def me(user: AdminUser = Depends(current_user)): return {"success": True, "data": {"user": public_user(user)}} @app.post("/api/admin/auth/logout") def logout(response: Response): response.delete_cookie(settings.cookie_name) return {"success": True} @app.post("/api/admin/usage/enter") def record_admin_usage_entry(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): """Record a console entry, deduplicated per user for ten minutes.""" now = datetime.utcnow() recent = db.scalar( select(AdminUsageEntry.id) .where( AdminUsageEntry.user_id == user.id, AdminUsageEntry.entered_at >= now - timedelta(minutes=10), ) .order_by(AdminUsageEntry.entered_at.desc()) .limit(1) ) if recent: return {"success": True, "data": {"recorded": False}} entry = AdminUsageEntry( user_id=user.id, username=user.username, display_name=user.display_name, department_name=user.dingtalk_dept_name or "未分配部门", entered_at=now, ) db.add(entry) db.commit() return {"success": True, "data": {"recorded": True}} @app.get("/api/admin/usage/stats") def admin_usage_stats(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): now = datetime.utcnow() start = (now - timedelta(days=29)).replace(hour=0, minute=0, second=0, microsecond=0) entries = db.scalars( select(AdminUsageEntry) .where(AdminUsageEntry.entered_at >= start) .order_by(AdminUsageEntry.entered_at.desc()) ).all() today = now.date() today_entries = [entry for entry in entries if entry.entered_at.date() == today] user_totals = {} department_totals = {} trend = {} for offset in range(30): day = (start + timedelta(days=offset)).date().isoformat() trend[day] = {"date": day, "count": 0, "users": 0} for entry in entries: day = entry.entered_at.date().isoformat() if day in trend: trend[day]["count"] += 1 key = (entry.user_id, entry.username, entry.display_name, entry.department_name) user_totals[key] = user_totals.get(key, 0) + 1 department = entry.department_name or "未分配部门" department_totals.setdefault(department, {"count": 0, "latest": entry.entered_at, "users": {}}) department_totals[department]["count"] += 1 if entry.entered_at > department_totals[department]["latest"]: department_totals[department]["latest"] = entry.entered_at member_key = (entry.user_id, entry.username, entry.display_name) department_totals[department]["users"][member_key] = department_totals[department]["users"].get(member_key, 0) + 1 for day in trend: trend[day]["users"] = len({entry.user_id for entry in entries if entry.entered_at.date().isoformat() == day}) users = [ { "user_id": user_id, "username": username, "display_name": display_name, "department_name": department_name, "count": count, } for (user_id, username, display_name, department_name), count in sorted( user_totals.items(), key=lambda item: (-item[1], item[0][2]) ) ] departments = [ { "name": department_name, "count": values["count"], "user_count": len(values["users"]), "latest_at": values["latest"].isoformat(), "members": [ { "user_id": user_id, "username": username, "display_name": display_name, "count": count, } for (user_id, username, display_name), count in sorted( values["users"].items(), key=lambda item: (-item[1], item[0][2]) ) ], } for department_name, values in sorted( department_totals.items(), key=lambda item: (-item[1]["count"], item[0]) ) ] return { "success": True, "data": { "period_days": 30, "total_count": len(entries), "today_count": len(today_entries), "today_users": len({entry.user_id for entry in today_entries}), "department_count": len(departments), "trend": list(trend.values()), "users": users, "departments": departments, "records": [ { "display_name": entry.display_name, "department_name": entry.department_name, "entered_at": entry.entered_at.isoformat(), } for entry in entries[:100] ], }, } @app.get("/api/admin/auth/login-logs") def login_logs(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): logs = db.scalars(select(AdminLoginLog).order_by(AdminLoginLog.created_at.desc()).limit(100)).all() return {"success": True, "data": [{"username": x.username, "success": x.success, "ip_address": x.ip_address, "created_at": x.created_at.isoformat()} for x in logs]} @app.get("/api/admin/users") def users(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): records = db.scalars(select(AdminUser).order_by(AdminUser.id.asc())).all() return { "success": True, "data": [ { "id": user.id, "username": user.username, "display_name": user.display_name, "role_code": user.role_code, "role_label": ROLE_LABELS.get(user.role_code, user.role_code), "dingtalk_dept_id": user.dingtalk_dept_id, "dingtalk_dept_name": user.dingtalk_dept_name, "is_dept_manager": user.is_dept_manager, "is_active": user.is_active, "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None, } for user in records ], } @app.get("/api/admin/users/{user_id}") def user_detail(user_id: int, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): user = db.get(AdminUser, user_id) if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") return {"success": True, "data": public_user(user) | {"is_active": user.is_active, "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None}} @app.post("/api/admin/users", status_code=status.HTTP_201_CREATED) def create_user(payload: AdminUserCreate, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): if db.scalar(select(AdminUser).where(AdminUser.username == payload.username)): raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="用户名已存在") user = AdminUser(username=payload.username, password_hash=hash_password(payload.password), display_name=payload.display_name, role_code=payload.role_code) db.add(user) db.commit() db.refresh(user) return {"success": True, "data": {"user": public_user(user)}} @app.patch("/api/admin/users/{user_id}") def update_user(user_id: int, payload: AdminUserUpdate, current: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): user = db.get(AdminUser, user_id) if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") changes = payload.model_dump(exclude_unset=True) if user.id == current.id and (changes.get("is_active") is False or changes.get("role_code") not in (None, "super_admin")): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能停用或降级当前登录账号") if changes.get("role_code") in {"operator", "dept_manager"} and user.role_code == "super_admin": super_count = db.scalar(select(func.count()).select_from(AdminUser).where(AdminUser.role_code == "super_admin", AdminUser.is_active.is_(True))) or 0 if super_count <= 1: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少需要保留一个启用中的超级管理员") if "password" in changes: user.password_hash = hash_password(changes.pop("password")) for key, value in changes.items(): setattr(user, key, value) db.commit() db.refresh(user) return {"success": True, "data": {"user": public_user(user)}} @app.delete("/api/admin/users/{user_id}") def delete_user(user_id: int, current: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): user = db.get(AdminUser, user_id) if not user: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在") if user.id == current.id: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除当前登录账号") if user.role_code == "super_admin": super_count = db.scalar(select(func.count()).select_from(AdminUser).where(AdminUser.role_code == "super_admin", AdminUser.is_active.is_(True))) or 0 if super_count <= 1: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少需要保留一个启用中的超级管理员") db.delete(user) db.commit() return {"success": True} @app.get("/api/admin/settings") def system_settings(_: AdminUser = Depends(require_super_admin)): return { "success": True, "data": { "app_name": settings.app_name, "session_hours": settings.session_hours, "database_driver": settings.database_url.split(":", 1)[0], "cookie_secure": settings.cookie_secure, "available_roles": [{"code": code, "label": label} for code, label in ROLE_LABELS.items()], }, }