from datetime import datetime, timedelta import base64 import hashlib import json import random import re import secrets import time import urllib.parse import urllib.request import uuid import io import zipfile import xml.etree.ElementTree as ET from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile, status from fastapi.middleware.cors import CORSMiddleware from sqlalchemy import func, inspect, or_, select, text from sqlalchemy.orm import Session, noload, 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_questions, generate_qa from .models import AdminLoginLog, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QALibraryEntry from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QAGenerateRequest, RegisterRequest from .security import create_session_token, decode_session_token, hash_password, verify_password 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. Stale, unfinished allocations are released on the next task/status # read so the questions can flow back to the same published task link. ASK_ASSIGNMENT_TIMEOUT = timedelta(hours=5) def _release_expired_ask_assignments(db: Session, task: AskTask | None = None) -> int: """Release unsubmitted ask-task allocations older than five hours. Submitted batches are intentionally preserved (including rejected batches) because they are part of the review/history flow. Only assignments that have never submitted a batch can be safely returned to the task pool. """ cutoff = datetime.utcnow() - ASK_ASSIGNMENT_TIMEOUT statement = select(AskAssignment).where( AskAssignment.status.in_(["待选择", "进行中"]), # The five-hour window starts when the questions are claimed, not # when the user later changes a selection inside that reservation. AskAssignment.created_at < cutoff, ~AskAssignment.batches.any(), ) if task is not None: statement = statement.where(AskAssignment.task_id == task.id) expired = db.scalars(statement).unique().all() for assignment in expired: db.delete(assignment) if expired: db.flush() return len(expired) 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 _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": "超级管理员", "operator": "普通运营"} ROLE_PERMISSIONS = { "super_admin": ["assistant", "library", "create", "board", "review", "users", "logs", "settings"], "operator": ["assistant", "library", "create", "board", "review"], } 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, []), } 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) # 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 "owner_id" not in columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN owner_id INTEGER NULL")) 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 = next(get_db()) try: seed_users(db) 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="账号不可用") 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: user = db.scalar(select(AnswerUser).where(AnswerUser.auth_token == token)) if not user: raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="回答用户登录已失效") 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 _public_phone_session(user: AnswerUser) -> dict: return {"user_id": user.public_id, "phone": user.phone, "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_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 Exception as exc: raise HTTPException(status_code=502, detail="钉钉应用凭证校验失败,请稍后重试") 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 Exception as exc: raise HTTPException(status_code=502, detail="无法读取钉钉用户信息,请检查应用免登权限") from exc if user_data.get("errcode", 0) not in (0, None): raise HTTPException(status_code=502, detail=f"钉钉用户信息读取失败:{user_data.get('errmsg', '请检查应用权限')}") return user_data.get("result") or user_data 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="钉钉用户信息缺少唯一标识") if settings.dingtalk_allowed_union_ids and union_id not in settings.dingtalk_allowed_union_ids: raise HTTPException(status_code=403, 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] role_code = "super_admin" if union_id in settings.dingtalk_super_admin_union_ids 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) db.add(user) elif not user.is_active: raise HTTPException(status_code=403, detail="运营账号已被停用") else: user.display_name = display_name or user.display_name if union_id in settings.dingtalk_super_admin_union_ids: user.role_code = "super_admin" 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) return _dingtalk_create_session(profile, db, response) def _can_access_ask_task(task: AskTask, user: AdminUser) -> bool: return user.role_code == "super_admin" or task.owner_id == user.id 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 _ask_task_owner_clause(user: AdminUser): return True if user.role_code == "super_admin" else AskTask.owner_id == user.id def _published_ask_task(task_no: str, db: Session, *, lock: bool = False) -> AskTask: if task_no == "latest": statement = select(AskTask).where(AskTask.status == "已发布").order_by(AskTask.created_at.desc()) else: statement = select(AskTask).where(AskTask.task_no == task_no, 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_assignment_data(assignment: AskAssignment, include_images: bool = True) -> 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 else [] question_screenshots = [item for item in screenshots if item.stage == "question"] payment_screenshots = [item for item in screenshots if item.stage == "payment"] latest_review = ( max(assignment.review_decisions, key=lambda item: item.created_at) if include_images and 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, "product_name": assignment.task.product_name, "product_link": assignment.task.product_link, "user_id": assignment.user.public_id, "phone": assignment.user.phone, "created_at": assignment.created_at.isoformat(), "updated_at": assignment.updated_at.isoformat(), "question_screenshots": [{"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"} for image in question_screenshots] if include_images else [], "payment_screenshots": [{"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"} for image in payment_screenshots] if include_images else [], "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) -> dict: """Serialize one independent submission batch for review/history views.""" assignment = batch.assignment parent = _ask_assignment_data(assignment, include_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 include_images and batch.review_decisions else None ) screenshots = list(batch.screenshots) if include_images else [] question_images = [item for item in screenshots if item.stage == "question"] payment_images = [item for item in screenshots if item.stage == "payment"] 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(), "questions": questions, "question_screenshots": [ {"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"} for image in question_images ] if include_images else [], "payment_screenshots": [ {"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"} for image in payment_images ] if include_images else [], "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": "已通过", "rejected": "未通过"}.get(latest.status, latest.status) assignment.updated_at = datetime.utcnow() def _image_url(image: AnswerScreenshot) -> str: return f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}" def _submission_data(record: AnswerSubmission, include_images: bool = True) -> 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 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"] 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": record.user.public_id, "phone": record.user.phone, "status": record.status, "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)} for x in invited] if include_images else [], "completion_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x)} for x in completion] if include_images else [], "payment_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x)} for x in payment] if include_images else [], "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]) if include_images and q.product_image_screenshot_id in images else None, "source_image": _image_url(images[q.source_screenshot_id]) if include_images and q.source_screenshot_id in images else None, "operator_images": [ _image_url(images[int(image_id)]) 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) -> 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) if include_images else { "id": record.id, "submission_no": record.submission_no, "user_id": record.user.public_id, "phone": record.user.phone, "status": record.status, "created_at": record.created_at.isoformat(), "updated_at": record.updated_at.isoformat(), "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "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. "已通过": "待返款", "已完成": "已完成", "未通过": "进行中", "已撤销": "已撤销", } completed_count = len(selected) if record.status in {"待审核", "已通过", "已完成"} 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), "product_name": " / ".join(product_titles) or "待识别商品", "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), "creator_name": "运营后台", "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, "assigned_users": [{"user_id": record.user.public_id, "phone": record.user.phone, "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) 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, "product_link": task.product_link, "status": assignment.status, "status_label": status_label, "user_id": assignment.user.public_id, "phone": assignment.user.phone, "qa_count": question_count, "claimed_count": question_count, "submitted_count": len(used_ids), "pending_review_count": 1 if status_label == "待审核" else 0, "completed_count": len(used_ids) if used_ids else (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": assignment.user.public_id, "phone": assignment.user.phone, "status": status_label}], "selected_questions": [], "question_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None, } 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] claimed_tasks = sorted( (_ask_assignment_child_data(assignment) for assignment in task.assignments), key=lambda value: value.get("updated_at") or value.get("created_at") or "", reverse=True, ) assigned_count = len(task.assignments) completed_count = sum(len(assignment.used_questions) for assignment in task.assignments) pending_count = sum(child["pending_review_count"] for child in claimed_tasks) 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": "提问任务", "product_name": task.product_name, "product_link": task.product_link, "status": task.status, "claim_questions_per_user": task.claim_questions_per_user or 5, "status_label": {"已发布": "进行中", "已撤销": "已撤销", "已完成": "已完成"}.get(task.status, task.status), "qa_count": len(selected_questions), "claimed_count": assigned_count, "submitted_count": completed_count, "pending_review_count": pending_count, "completed_count": completed_count, "total_count": len(selected_questions), "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": task.status == "已发布", "can_delete": True, "delete_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}", "user_id": "--", "phone": "", "questions": [], "selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": "", "product_title": task.product_name} for q in selected_questions] if include_questions else []), "assigned_users": [child["assigned_users"][0] for child in claimed_tasks if child.get("assigned_users")], "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) -> dict: """Keep completed refunds visible as approved audit history.""" data = _submission_data(record, include_images=include_images) 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) -> 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) if is_batch else _ask_assignment_data(record, include_images=True) 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, "product_name": assignment.task.product_name, "product_link": assignment.task.product_link, "user_id": assignment.user.public_id, "phone": assignment.user.phone, "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) 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 "待返款", "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, "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": assignment.user.public_id, "phone": assignment.user.phone, "assigned_users": [{ "user_id": assignment.user.public_id, "phone": assignment.user.phone, "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.post("/api/answer/auth/phone-login") def answer_phone_login(payload: dict, db: Session = Depends(get_db)): return {"success": True, "data": _public_phone_session(_phone_login(payload, db))} @app.post("/api/answer/auth/send-code") def answer_send_code(payload: dict): 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/send-code") def ask_send_code(payload: dict): return {"success": True, "data": _send_phone_code(payload)} @app.get("/api/ask/tasks/{task_no}") def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) task = _published_ask_task(task_no, db, lock=True) released = _release_expired_ask_assignments(db, task) if released: db.commit() db.expire_all() 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="待选择") 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) 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 != "进行中": return {"success": True, "data": _ask_batch_data(latest_batch)} return {"success": True, "data": _ask_assignment_data(assignment)} @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) for batch in batches) else: data.append(_ask_assignment_data(record, include_images=False)) 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) if _release_expired_ask_assignments(db, task): db.commit() 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 assignment.items: # The selection represents only the next batch. Questions in # ``used_ids`` remain locked/read-only and are not resubmitted. item.selected = item.question_id in selected_ids assignment.status = "进行中" db.commit(); db.refresh(assignment) return {"success": True, "data": _ask_assignment_data(assignment)} @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=""), db: Session = Depends(get_db), ): user = _answer_user_from_token(token, db) task = _published_ask_task(task_no, db) if _release_expired_ask_assignments(db, task): db.commit() 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] 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 upload in files: db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="question", filename=upload.filename or "question", content_type=upload.content_type, image_data=await upload.read())) db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="payment", filename=payment_file.filename or "payment", content_type=payment_file.content_type, image_data=await payment_file.read())) assignment.status = "待审核" 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) return {"success": True, "data": _ask_batch_data(batch)} @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 == "待审核") ) 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-completion-reviews/{assignment_id}") def ask_completion_review_detail(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batch = db.get(AskAssignmentBatch, assignment_id) if batch: _require_ask_task_access(batch.assignment.task, user) return {"success": True, "data": _ask_batch_data(batch)} assignment = db.get(AskAssignment, assignment_id) 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)} @app.post("/api/admin/ask-completion-reviews/{assignment_id}/decision") def ask_completion_decision(assignment_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batch = db.get(AskAssignmentBatch, assignment_id) if batch: _require_ask_task_access(batch.assignment.task, user) if batch.status != "pending": raise HTTPException(status_code=409, detail="review already completed") 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="review reason is required") 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)} assignment = db.get(AskAssignment, assignment_id) if not assignment: 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)} @app.get("/api/answer/me/submissions") def answer_my_submissions(token: str, include_images: bool = False, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.user_id == user.id).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, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) record = db.get(AnswerSubmission, submission_id) if not record or record.user_id != user.id: raise HTTPException(status_code=404, detail="任务不存在") return {"success": True, "data": _submission_data(record, include_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 = [shot for shot in record.screenshots if shot.stage == "invited"] extracted = extract_questions_from_images([(shot.image_data, shot.content_type) for shot in saved]) if not extracted: extracted = [ {"product_title": "待识别商品", "question": "这款商品使用起来感觉怎么样?"}, {"product_title": "待识别商品", "question": "敏感肌可以使用这款商品吗?"}, {"product_title": "待识别商品", "question": "使用后的肤感和效果怎么样?"}, ][:max(1, min(3, len(saved)))] for index, item in enumerate(extracted): product, question = item["product_title"], item["question"] db.add(AnswerRecognizedQuestion( submission_id=record.id, source_screenshot_id=saved[index % len(saved)].id, product_image_screenshot_id=saved[index % len(saved)].id, sequence_no=index + 1, product_title=product, question_text=question, answer_text=generate_answer_for_question(product, question), answer_status="已生成", )) record.updated_at = datetime.utcnow() db.commit() except Exception: db.rollback() # Keep the submission visible to operators even if OCR/model is unavailable. try: record = db.get(AnswerSubmission, submission_id) saved = [shot for shot in (record.screenshots if record else []) if shot.stage == "invited"] if record and not record.questions and saved: for index, question in enumerate(( "这款商品使用起来感觉怎么样?", "敏感肌可以使用这款商品吗?", "使用后的肤感和效果怎么样?", )[:max(1, min(3, len(saved)))]): db.add(AnswerRecognizedQuestion( submission_id=record.id, source_screenshot_id=saved[index % len(saved)].id, product_image_screenshot_id=saved[index % len(saved)].id, sequence_no=index + 1, product_title="待识别商品", question_text=question, answer_text="建议结合自身需求和肤质先少量试用。", answer_status="待补充", )) 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(...), files: list[UploadFile] = File(...), db: Session = Depends(get_db), ): user = _answer_user_from_token(token, db) if not files or len(files) > 10: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最多上传10张截图") # 先落库并立即返回;OCR/回答生成放到响应后的后台任务执行。 record = AnswerSubmission(submission_no=f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", user_id=user.id, status="待筛选") db.add(record); db.flush() saved = [] for upload in files: if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: db.rollback(); raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片") shot = AnswerScreenshot(submission_id=record.id, stage="invited", filename=upload.filename or "screenshot", content_type=upload.content_type, image_data=await upload.read()) db.add(shot); db.flush(); saved.append(shot) 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(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)): records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.status.in_(["待筛选", "待运营派发", "已派发", "待用户完成"])).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, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record or record.status not in {"待筛选", "待运营派发", "已派发", "待用户完成"}: raise HTTPException(status_code=404, detail="回答任务不存在") return {"success": True, "data": _submission_data(record, include_images=True)} @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(...), _: 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 {"待筛选", "待运营派发", "已派发"}: raise HTTPException(status_code=404, detail="回答问题不存在或已经发布") if not files or len(files) > 3: raise HTTPException(status_code=400, detail="每道问题最多上传3张运营指定图片") 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 file in files: if file.content_type not in {"image/jpeg", "image/png", "image/webp"}: db.rollback() raise HTTPException(status_code=400, detail="运营指定图片仅支持 JPG、PNG、WEBP") image_data = await file.read() if not image_data: db.rollback() raise HTTPException(status_code=400, detail="不能上传空图片") image = AnswerScreenshot(submission_id=record.id, stage="operator", filename=file.filename or "operator-image", content_type=file.content_type, image_data=image_data) 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, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record: 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)): product_link = str(payload.get("product_link", "")).strip() product_name = str(payload.get("product_name", "")).strip() if not product_link: raise HTTPException(status_code=400, detail="商品链接为必填项") 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") raw_claim_limit = payload.get("claim_questions_per_user", "") if str(raw_claim_limit).strip(): try: claim_limit = int(raw_claim_limit) except (TypeError, ValueError): raise HTTPException(status_code=400, detail="每个用户可认领的问题数量必须是 2-5 的整数") if claim_limit < 2 or claim_limit > 5: raise HTTPException(status_code=400, detail="每个用户可认领的问题数量必须在 2-5 条之间") else: 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="已发布任务不能重新生成") task.product_link = product_link 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 for question in list(task.questions): db.delete(question) db.flush() 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, product_link=product_link, 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() questions = payload.get("questions") if isinstance(payload.get("questions"), list) else [] reference_text = str(payload.get("reference_qa", ""))[:20000] if not questions: questions = generate_questions(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(payload, count - len(generated), reference_text)) for index, question in enumerate(generated): db.add(AskQuestion(task_id=task.id, sequence_no=index + 1, question_text=question, 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}} @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)): task = db.get(AskTask, task_id) 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="提问内容不能为空") question = AskQuestion(task_id=task.id, sequence_no=len(task.questions) + 1, question_text=text, 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}} @app.post("/api/admin/ask-tasks/{task_id}/publish") def publish_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 any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问") 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="当前任务不能完成归档") 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, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): question = db.get(AnswerRecognizedQuestion, question_id) if not question: raise HTTPException(status_code=404, detail="问题不存在") if "selected" in payload: question.selected = bool(payload["selected"]) if str(payload.get("answer", "")).strip(): question.answer_text = str(payload["answer"]).strip() if payload.get("regenerate"): question.answer_text = generate_answer_for_question(question.product_title, question.question_text) 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, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record: 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="请至少选择一个问题") record.status = "待用户完成"; db.commit() return {"success": True, "data": _submission_data(record, include_images=False)} @app.get("/api/admin/task-board") def admin_task_board(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): if _release_expired_ask_assignments(db): db.commit() task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"] records = db.scalars( select(AnswerSubmission).options( selectinload(AnswerSubmission.user), selectinload(AnswerSubmission.questions), noload(AnswerSubmission.screenshots), noload(AnswerSubmission.review_decisions), ) .where(AnswerSubmission.status.in_(task_statuses)) .order_by(AnswerSubmission.updated_at.desc()) ).unique().all() ask_statement = select(AskTask).options(selectinload(AskTask.owner)).where(AskTask.status.in_(["已发布", "已完成", "已撤销"]), _ask_task_owner_clause(user)).order_by(AskTask.updated_at.desc()) ask_tasks = db.scalars(ask_statement).unique().all() data = ( [_task_board_data(record, include_images=False) for record in records] + [_ask_task_data(task, include_questions=False) for task in ask_tasks] ) data.sort(key=lambda item: item.get("updated_at") or item.get("created_at") or "", reverse=True) return {"success": True, "data": data} @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.get(AskAssignmentBatch, batch_id) 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)} @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="提问提交记录不存在") db.delete(batch) 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.get(AskAssignment, assignment_id) 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)} @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="提问分配记录不存在") db.delete(assignment) db.commit() return {"success": True, "data": {"id": assignment_id}} @app.post("/api/admin/task-board/ask-batches/{batch_id}/confirm-refund") def confirm_ask_batch_refund(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="待返款提问记录不存在") if batch.status == "paid": raise HTTPException(status_code=409, detail="该任务已经完成返款") if batch.status != "approved": raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") batch.status = "paid" batch.updated_at = datetime.utcnow() db.commit(); db.refresh(batch) return {"success": True, "data": _ask_review_board_data(batch, include_images=False)} @app.post("/api/admin/task-board/ask-assignments/{assignment_id}/confirm-refund") def confirm_ask_assignment_refund(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="待返款提问记录不存在") if assignment.status == "已完成": raise HTTPException(status_code=409, detail="该任务已经完成返款") if assignment.status != "已通过": raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") assignment.status = "已完成" assignment.updated_at = datetime.utcnow() db.commit(); db.refresh(assignment) return {"success": True, "data": _ask_review_board_data(assignment, include_images=False)} @app.get("/api/admin/task-board/{submission_id}") def admin_task_board_detail(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record or record.status not in {"待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"}: raise HTTPException(status_code=404, detail="任务不存在") return {"success": True, "data": _task_board_data(record)} @app.delete("/api/admin/task-board/{submission_id}") def admin_task_board_delete_submission(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record: 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") def confirm_answer_refund(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record: raise HTTPException(status_code=404, detail="待返款回答记录不存在") if record.status == "已完成": raise HTTPException(status_code=409, detail="该任务已经完成返款") if record.status != "已通过": raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") record.status = "已完成" db.commit(); db.refresh(record) return {"success": True, "data": _task_board_data(record, include_images=False)} @app.post("/api/admin/task-board/{submission_id}/cancel") def admin_task_board_cancel(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record: 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(...), files: list[UploadFile] = File(...), payment_files: list[UploadFile] = File(default=[]), db: Session = Depends(get_db)): user = _answer_user_from_token(token, db); record = db.get(AnswerSubmission, submission_id) if not record or record.user_id != user.id: raise HTTPException(status_code=404, detail="任务不存在") 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="请上传收款二维码后再提交") if record.status == "未通过": for old_screenshot in list(record.screenshots): if old_screenshot.stage in {"completion", "payment"}: db.delete(old_screenshot) for upload in files: if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: raise HTTPException(status_code=400, detail="仅支持图片") db.add(AnswerScreenshot(submission_id=record.id, stage="completion", filename=upload.filename or "completion", content_type=upload.content_type, image_data=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="收款码仅支持图片") db.add(AnswerScreenshot(submission_id=record.id, stage="payment", filename=upload.filename or "payment", content_type=upload.content_type, image_data=await upload.read())) 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(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)): records = db.scalars( select(AnswerSubmission) .where(AnswerSubmission.status == "待审核") .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, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record: raise HTTPException(status_code=404, detail="任务不存在") return {"success": True, "data": _answer_review_data(record)} @app.post("/api/admin/answer-completion-reviews/{submission_id}/decision") def answer_completion_decision(submission_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record: 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)} @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 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)}} @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) 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.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), "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") == "operator"): raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能停用或降级当前登录账号") if changes.get("role_code") == "operator" 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()], }, }