From 73ac3e81630632a4926d7657a7467e70dc6b76db Mon Sep 17 00:00:00 2001 From: db_huang <18006480240@163.com> Date: Wed, 16 Sep 2026 10:15:20 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=8D=A1=E9=A1=BFbug?= =?UTF-8?q?=EF=BC=8C=E6=96=B0=E5=A2=9E=E9=97=AE=E9=A2=98=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E5=AF=BC=E5=85=A5=EF=BC=8C=E9=87=8A=E6=94=BE=E6=9C=BA=E5=88=B6?= =?UTF-8?q?=E8=B0=83=E6=95=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/app/llm.py | 136 ++- backend/app/main.py | 1307 ++++++++++++++++++++---- backend/app/models.py | 6 + backend/requirements.txt | 2 + backend/tests/conftest.py | 14 + backend/tests/test_auth.py | 686 ++++++++++++- backend/tests/test_llm_ocr.py | 53 + frontend/src/AnswerUserPortal.jsx | 43 +- frontend/src/AskUserPortal.jsx | 254 +++-- frontend/src/CreateAnswerTasksPage.jsx | 85 +- frontend/src/CreateAskTasksPage.jsx | 186 +++- frontend/src/ReviewCenterPage.jsx | 219 +++- frontend/src/TaskBoardPage.jsx | 314 ++++-- frontend/src/admin.css | 531 +++++++++- frontend/src/main.jsx | 32 +- 15 files changed, 3373 insertions(+), 495 deletions(-) create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_llm_ocr.py diff --git a/backend/app/llm.py b/backend/app/llm.py index a6d660a..c25b722 100644 --- a/backend/app/llm.py +++ b/backend/app/llm.py @@ -1,9 +1,14 @@ import json import base64 +import io import logging import re +import time import urllib.error import urllib.request +from concurrent.futures import ThreadPoolExecutor, as_completed + +from PIL import Image, ImageOps from fastapi import HTTPException, status @@ -179,7 +184,7 @@ def generate_questions(payload: dict, count: int, reference_qa: str = "") -> lis def generate_answer_for_question(product_title: str, question: str) -> str: - """Generate one concise answer. Falls back to a safe draft during the simulated-OCR MVP.""" + """Generate one concise answer for an OCR-extracted question.""" if not settings.llm_api_key: return f"结合{product_title}的使用体验来看,建议按自身肤质先少量试用,确认合适后再正常使用。" url = settings.llm_base_url.rstrip("/") + "/chat/completions" @@ -192,7 +197,7 @@ def generate_answer_for_question(product_title: str, question: str) -> str: answer = str(result["choices"][0]["message"]["content"]).strip() return answer[:300] except Exception: - logger.exception("Answer generation failed for simulated OCR question") + logger.exception("Answer generation failed for OCR question") return f"结合{product_title}的使用体验来看,建议按自身肤质先少量试用,确认合适后再正常使用。" @@ -233,31 +238,54 @@ def _parse_ocr_content(content: str) -> list[dict]: return items[:100] -def extract_questions_from_images(images: list[tuple[bytes, str]]) -> list[dict]: - """Extract consumer questions from one or more Taobao Q&A screenshots. +def _compress_ocr_image(image_data: bytes, content_type: str) -> tuple[bytes, str]: + """Normalize screenshots and keep each vision request reasonably small.""" + try: + with Image.open(io.BytesIO(image_data)) as source: + image = ImageOps.exif_transpose(source) + image.thumbnail((2200, 2200), Image.Resampling.LANCZOS) + if image.mode in {"RGBA", "LA"} or "transparency" in image.info: + rgba = image.convert("RGBA") + background = Image.new("RGB", rgba.size, "white") + background.paste(rgba, mask=rgba.getchannel("A")) + image = background + else: + image = image.convert("RGB") - The configured OpenAI-compatible model must support image input. The - caller keeps a deterministic fallback when no vision model/API key is - configured, so uploading screenshots never blocks the task workflow. - """ - if not settings.llm_api_key or not images: - return [] - content = [{ - "type": "text", - "text": ( - "请识别这些淘宝‘问答/待回答’页面截图中的消费者提问。只提取真正的问题句子," - "不要提取导航、按钮、商品名、用户昵称、规则文字或页面标题。去除序号并保持原文。" - "多张截图合并去重。只返回严格 JSON:" - '{"items":[{"product_title":"商品名或待识别商品","question":"问题原文"}]}' - ), - }] - for index, (image_data, content_type) in enumerate(images, start=1): - encoded = base64.b64encode(image_data).decode("ascii") - content.append({"type": "text", "text": f"截图 {index}"}) - content.append({ + for quality in (86, 76, 66): + output = io.BytesIO() + image.save(output, format="JPEG", quality=quality, optimize=True) + compressed = output.getvalue() + if len(compressed) <= 1_500_000: + return compressed, "image/jpeg" + + # Very long/high-detail screenshots get one final size reduction. + image.thumbnail((1700, 1700), Image.Resampling.LANCZOS) + output = io.BytesIO() + image.save(output, format="JPEG", quality=72, optimize=True) + return output.getvalue(), "image/jpeg" + except Exception: + logger.warning("Could not compress OCR image; using original payload", exc_info=True) + return image_data, content_type + + +def _extract_questions_from_image(image_data: bytes, content_type: str) -> list[dict]: + encoded = base64.b64encode(image_data).decode("ascii") + content = [ + { + "type": "text", + "text": ( + "请识别这张淘宝‘问答/待回答’页面截图中的所有消费者提问。只提取真正的问题句子," + "不要提取导航、按钮、商品名、用户昵称、规则文字或页面标题。去除序号并保持原文。" + "只返回严格 JSON:" + '{"items":[{"product_title":"商品名或待识别商品","question":"问题原文"}]}' + ), + }, + { "type": "image_url", "image_url": {"url": f"data:{content_type};base64,{encoded}"}, - }) + }, + ] request_body = { "model": settings.llm_vision_model, "temperature": 0, @@ -273,11 +301,57 @@ def extract_questions_from_images(images: list[tuple[bytes, str]]) -> list[dict] headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.llm_api_key}"}, method="POST", ) - try: - with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response: - result = json.loads(response.read().decode("utf-8")) - content = result["choices"][0]["message"]["content"] - return _parse_ocr_content(content) - except Exception: - logger.exception("Question OCR failed") + with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response: + result = json.loads(response.read().decode("utf-8")) + return _parse_ocr_content(result["choices"][0]["message"]["content"]) + + +def _recognize_one_image(index: int, image: tuple[bytes, str]) -> tuple[int, list[dict]]: + image_data, content_type = _compress_ocr_image(*image) + for attempt in range(2): + try: + items = _extract_questions_from_image(image_data, content_type) + if items: + return index, items + raise ValueError("vision model returned no valid questions") + except Exception: + logger.warning( + "Question OCR failed for image %s on attempt %s/2", + index + 1, + attempt + 1, + exc_info=True, + ) + if attempt == 0: + time.sleep(0.5) + return index, [] + + +def extract_questions_from_images(images: list[tuple[bytes, str]]) -> list[dict]: + """Extract consumer questions from one or more Taobao Q&A screenshots. + + The configured OpenAI-compatible model must support image input. Each + screenshot is compressed and recognized independently. Failed image + requests are retried once, then successful results are merged and deduped. + """ + if not settings.llm_api_key or not images: return [] + recognized: dict[int, list[dict]] = {} + with ThreadPoolExecutor(max_workers=min(3, len(images))) as executor: + futures = { + executor.submit(_recognize_one_image, index, image): index + for index, image in enumerate(images) + } + for future in as_completed(futures): + index, items = future.result() + recognized[index] = items + + merged = [] + seen = set() + for index in range(len(images)): + for item in recognized.get(index, []): + key = re.sub(r"[\s,。!?、,.!?]+", "", item["question"]).lower() + if not key or key in seen: + continue + seen.add(key) + merged.append({**item, "source_index": index}) + return merged[:100] diff --git a/backend/app/main.py b/backend/app/main.py index 40845e2..f5e3e08 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,6 +1,7 @@ from datetime import datetime, timedelta import base64 +import csv import hashlib import json import random @@ -14,11 +15,12 @@ import uuid import io import zipfile import xml.etree.ElementTree as ET +from pathlib import Path from fastapi import BackgroundTasks, Body, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile, status from fastapi.middleware.cors import CORSMiddleware -from sqlalchemy import func, inspect, or_, select, text -from sqlalchemy.orm import Session, noload, selectinload +from sqlalchemy import func, inspect, literal, or_, select, text, union_all +from sqlalchemy.orm import Session, defer, noload, selectinload from .config import settings from .database import Base, SessionLocal, engine, get_db @@ -38,36 +40,92 @@ app.add_middleware( ) # 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) +# batch. The one-minute window covers the selection screen; after the user +# confirms a selection, the upload screen has a separate thirty-minute window. +ASK_SELECTION_TIMEOUT = timedelta(minutes=1) +ASK_UPLOAD_TIMEOUT = timedelta(minutes=30) def _release_expired_ask_assignments(db: Session, task: AskTask | None = None) -> int: - """Release unsubmitted ask-task allocations older than five hours. + """Release stale ask-task allocations back to the published task pool. Submitted batches are intentionally preserved (including rejected batches) - because they are part of the review/history flow. Only assignments that - have never submitted a batch can be safely returned to the task pool. + because they are part of the review/history flow. Only the current, + unsubmitted reservation is returned to the task pool. """ - cutoff = datetime.utcnow() - ASK_ASSIGNMENT_TIMEOUT + now = datetime.utcnow() 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(), + or_( + (AskAssignment.status == "待选择") + & (func.coalesce(AskAssignment.reservation_started_at, AskAssignment.created_at) < now - ASK_SELECTION_TIMEOUT), + (AskAssignment.status == "进行中") + & (func.coalesce(AskAssignment.reservation_started_at, AskAssignment.updated_at) < now - ASK_UPLOAD_TIMEOUT), + ), ) if task is not None: statement = statement.where(AskAssignment.task_id == task.id) expired = db.scalars(statement).unique().all() for assignment in expired: - db.delete(assignment) + _release_ask_assignment_reservation(db, assignment) if expired: db.flush() return len(expired) +def _ask_assignment_expired(assignment: AskAssignment | None, now: datetime | None = None) -> bool: + if not assignment or assignment.status not in {"待选择", "进行中"}: + return False + current = now or datetime.utcnow() + timeout = ASK_SELECTION_TIMEOUT if assignment.status == "待选择" else ASK_UPLOAD_TIMEOUT + started_at = assignment.reservation_started_at or ( + assignment.created_at if assignment.status == "待选择" else assignment.updated_at + ) + return started_at < current - timeout + + +def _ask_assignment_expires_at(assignment: AskAssignment) -> datetime | None: + started_at = assignment.reservation_started_at or ( + assignment.created_at if assignment.status == "待选择" else assignment.updated_at + ) + if assignment.status == "待选择": + return started_at + ASK_SELECTION_TIMEOUT + if assignment.status == "进行中": + return started_at + ASK_UPLOAD_TIMEOUT + return None + + +def _batch_question_ids(batch: AskAssignmentBatch | None) -> set[int]: + if not batch: + return set() + try: + return {int(value) for value in (batch.selected_question_ids or "").split(",") if value} + except ValueError: + return set() + + +def _release_ask_assignment_reservation(db: Session, assignment: AskAssignment) -> None: + """Release unfinished questions without deleting submitted batch history.""" + if not assignment.batches: + db.delete(assignment) + return + used_ids = {item.question_id for item in assignment.used_questions} + latest = max(assignment.batches, key=lambda value: value.created_at) + rejected_ids = _batch_question_ids(latest) if latest.status == "rejected" else set() + for item in list(assignment.items): + if item.question_id not in used_ids: + db.delete(item) + else: + item.selected = item.question_id in rejected_ids + assignment.status = { + "pending": "待审核", + "approved": "已通过", + "paid": "已通过", + "rejected": "未通过", + }.get(latest.status, latest.status) + assignment.reservation_started_at = None + assignment.updated_at = datetime.utcnow() + + def _product_intent_error(values: list[object]) -> str | None: """Reject obvious placeholder input before creating an AI task.""" text = " ".join(str(value or "").strip() for value in values if str(value or "").strip()) @@ -237,6 +295,12 @@ def startup(): if "product_qr_image_name" not in task_columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN product_qr_image_name VARCHAR(255) NULL")) + assignment_columns = {column["name"] for column in inspect(engine).get_columns("ask_assignments")} + if "reservation_started_at" not in assignment_columns: + with engine.begin() as connection: + connection.execute(text("ALTER TABLE ask_assignments ADD COLUMN reservation_started_at DATETIME NULL")) + with engine.begin() as connection: + connection.execute(text("CREATE INDEX ix_ask_assignments_reservation_started_at ON ask_assignments (reservation_started_at)")) question_table_columns = {column["name"] for column in inspect(engine).get_columns("ask_questions")} if "source" not in question_table_columns: with engine.begin() as connection: @@ -252,6 +316,7 @@ def startup(): "dingtalk_title": "VARCHAR(255) NULL", "is_dept_manager": "BOOLEAN NOT NULL DEFAULT 0", "answer_public_token": "VARCHAR(96) NULL", + "last_answer_shop_name": "VARCHAR(255) NULL", } for column_name, column_type in admin_user_migrations.items(): if column_name not in admin_user_columns: @@ -264,6 +329,23 @@ def startup(): if "shop_name" not in answer_submission_columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE answer_submissions ADD COLUMN shop_name VARCHAR(255) NOT NULL DEFAULT ''")) + answer_user_columns = {column["name"] for column in inspect(engine).get_columns("answer_users")} + if "taobao_id" not in answer_user_columns: + with engine.begin() as connection: + connection.execute(text("ALTER TABLE answer_users ADD COLUMN taobao_id VARCHAR(64) NULL")) + answer_user_indexes = inspect(engine).get_indexes("answer_users") + answer_user_constraints = inspect(engine).get_unique_constraints("answer_users") + taobao_id_index_exists = any( + index.get("name") == "ix_answer_users_taobao_id" + or index.get("column_names") == ["taobao_id"] + for index in answer_user_indexes + ) or any( + constraint.get("column_names") == ["taobao_id"] + for constraint in answer_user_constraints + ) + if not taobao_id_index_exists: + with engine.begin() as connection: + connection.execute(text("CREATE UNIQUE INDEX ix_answer_users_taobao_id ON answer_users (taobao_id)")) question_columns = {column["name"] for column in inspect(engine).get_columns("answer_recognized_questions")} if "operator_image_screenshot_id" not in question_columns: with engine.begin() as connection: @@ -420,8 +502,54 @@ def _phone_login(payload: dict, db: Session) -> AnswerUser: return user +def _taobao_id(payload: dict) -> str: + value = str(payload.get("taobao_id", "")).strip() + if not re.fullmatch(r"[A-Za-z0-9_.-]{3,64}", value): + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail="请输入正确的淘宝ID(3-64位字母、数字、下划线、点或短横线)", + ) + return value + + +def _taobao_login(payload: dict, db: Session) -> AnswerUser: + taobao_id = _taobao_id(payload) + user = db.scalar(select(AnswerUser).where(AnswerUser.taobao_id == taobao_id)) + if not user: + # Existing databases require the legacy phone column. This internal + # unique value is never returned or displayed for Taobao-ID users. + legacy_phone_key = f"tb_{hashlib.sha256(taobao_id.encode('utf-8')).hexdigest()[:16]}" + user = AnswerUser( + public_id=f"U{uuid.uuid4().int % 10_000_000:07d}", + phone=legacy_phone_key, + taobao_id=taobao_id, + auth_token=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_user_id(user: AnswerUser) -> str: + return user.taobao_id or user.public_id + + +def _public_user_account(user: AnswerUser) -> str: + return user.taobao_id or user.phone + + def _public_phone_session(user: AnswerUser) -> dict: - return {"user_id": user.public_id, "phone": user.phone, "token": user.auth_token} + return { + "user_id": _public_user_id(user), + "taobao_id": user.taobao_id, + "account": _public_user_account(user), + "phone": user.phone if not user.taobao_id else "", + "token": user.auth_token, + } def _dingtalk_configured() -> bool: @@ -658,7 +786,10 @@ def _can_access_ask_task(task: AskTask, user: AdminUser) -> bool: if user.role_code != "dept_manager" or not user.dingtalk_dept_id or not task.owner: return False owner_path = task.owner.dingtalk_dept_path or "" - return f",{user.dingtalk_dept_id}," in owner_path + return ( + task.owner.dingtalk_dept_id == user.dingtalk_dept_id + or f",{user.dingtalk_dept_id}," in owner_path + ) def _require_ask_task_access(task: AskTask | None, user: AdminUser) -> AskTask: @@ -710,22 +841,22 @@ def _can_access_answer_submission(record: AnswerSubmission | None, user: AdminUs if user.role_code == "super_admin" or record.owner_id == user.id: return True if record.owner_id is None: - # Records created before fixed operator links have no owner. Keep - # those legacy rows operable during migration; every new submission - # made through an operator link is assigned an owner before insert. - return True + # Unassigned legacy records are visible only to the super administrator + # so they cannot leak into every operator's queue. + return False if user.role_code != "dept_manager" or not user.dingtalk_dept_id or not record.owner: return False - return f",{user.dingtalk_dept_id}," in (record.owner.dingtalk_dept_path or "") + return ( + record.owner.dingtalk_dept_id == user.dingtalk_dept_id + or f",{user.dingtalk_dept_id}," in (record.owner.dingtalk_dept_path or "") + ) def _answer_owner_clause(user: AdminUser): if user.role_code == "super_admin": return True if user.role_code != "dept_manager" or not user.dingtalk_dept_id: - # Include legacy unassigned submissions while isolating all rows that - # carry a fixed-link owner. - return or_(AnswerSubmission.owner_id == user.id, AnswerSubmission.owner_id.is_(None)) + return AnswerSubmission.owner_id == user.id descendant_owners = select(AdminUser.id).where( or_( AdminUser.id == user.id, @@ -733,7 +864,7 @@ def _answer_owner_clause(user: AdminUser): AdminUser.dingtalk_dept_id == user.dingtalk_dept_id, ) ) - return or_(AnswerSubmission.owner_id.is_(None), AnswerSubmission.owner_id.in_(descendant_owners)) + return AnswerSubmission.owner_id.in_(descendant_owners) REFUND_UPLOAD_SESSION_TTL = timedelta(minutes=10) @@ -851,7 +982,22 @@ def _published_ask_task(public_token: str, db: Session, *, lock: bool = False) - return task -def _ask_assignment_data(assignment: AskAssignment, include_images: bool = True, include_refund_images: bool = False) -> dict: +def _ask_screenshot_url( + image: AskAssignmentScreenshot | AskAssignmentBatchScreenshot, + image_kind: str, + use_endpoint: bool, +) -> str: + if use_endpoint: + return f"/api/admin/ask-{image_kind}-screenshots/{image.id}" + return f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}" + + +def _ask_assignment_data( + assignment: AskAssignment, + include_images: bool = True, + include_refund_images: bool = False, + image_endpoints: bool = False, +) -> dict: items = sorted(assignment.items, key=lambda item: item.question.sequence_no) used_ids = {item.question_id for item in assignment.used_questions} # List endpoints must not touch the screenshot relationship. The image @@ -863,7 +1009,7 @@ def _ask_assignment_data(assignment: AskAssignment, include_images: bool = True, refund_screenshots = [item for item in screenshots if item.stage == "refund"] latest_review = ( max(assignment.review_decisions, key=lambda item: item.created_at) - if include_images and assignment.review_decisions + if assignment.review_decisions else None ) # Backfill the read model for approvals created before the used-question @@ -884,13 +1030,19 @@ def _ask_assignment_data(assignment: AskAssignment, include_images: bool = True, "product_name": assignment.task.product_name, "product_link": assignment.task.product_link, "product_qr": _product_qr_data(assignment.task), - "user_id": assignment.user.public_id, - "phone": assignment.user.phone, + "user_id": _public_user_id(assignment.user), + "taobao_id": assignment.user.taobao_id, + "phone": _public_user_account(assignment.user), "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 [], - "refund_screenshots": [{"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"} for image in refund_screenshots] if include_images or include_refund_images else [], + "expires_at": ( + _ask_assignment_expires_at(assignment).isoformat() + if _ask_assignment_expires_at(assignment) + else None + ), + "question_screenshots": [{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "assignment", image_endpoints)} for image in question_screenshots] if include_images else [], + "payment_screenshots": [{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "assignment", image_endpoints)} for image in payment_screenshots] if include_images else [], + "refund_screenshots": [{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "assignment", image_endpoints)} for image in refund_screenshots] if include_images or include_refund_images else [], "refund_completed": assignment.status == "已完成" or bool(refund_screenshots), "latest_review": ({ "approved": latest_review.approved, @@ -908,10 +1060,17 @@ def _ask_assignment_data(assignment: AskAssignment, include_images: bool = True, } -def _ask_batch_data(batch: AskAssignmentBatch, include_images: bool = True, include_refund_images: bool = False) -> dict: +def _ask_batch_data( + batch: AskAssignmentBatch, + include_images: bool = True, + include_refund_images: bool = False, + image_endpoints: bool = False, +) -> dict: """Serialize one independent submission batch for review/history views.""" assignment = batch.assignment - parent = _ask_assignment_data(assignment, include_images=False, include_refund_images=include_refund_images) + # Batch screenshots are authoritative for batch history. Avoid touching the + # legacy assignment screenshot relationship while serializing a batch. + parent = _ask_assignment_data(assignment, include_images=False, include_refund_images=False) try: selected_ids = {int(value) for value in (batch.selected_question_ids or "").split(",") if value} except ValueError: @@ -919,7 +1078,7 @@ def _ask_batch_data(batch: AskAssignmentBatch, include_images: bool = True, incl 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 + if batch.review_decisions else None ) screenshots = list(batch.screenshots) if include_images or (include_refund_images and batch.status == "paid") else [] @@ -947,15 +1106,15 @@ def _ask_batch_data(batch: AskAssignmentBatch, include_images: bool = True, incl "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()}"} + {"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "batch", image_endpoints)} for image in question_images ] if include_images else [], "payment_screenshots": [ - {"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"} + {"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "batch", image_endpoints)} for image in payment_images ] if include_images else [], "refund_screenshots": [ - {"id": image.id, "name": image.filename, "data_url": f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"} + {"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "batch", image_endpoints)} for image in refund_images ] if include_images or include_refund_images else [], "refund_completed": batch.status == "paid" or bool(refund_images), @@ -976,15 +1135,44 @@ def _sync_ask_assignment_status(assignment: AskAssignment) -> None: # 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.status = { + "pending": "待审核", + "approved": "已通过", + "paid": "已通过", + "rejected": "未通过", + }.get(latest.status, latest.status) + assignment.reservation_started_at = None assignment.updated_at = datetime.utcnow() -def _image_url(image: AnswerScreenshot) -> str: +def _image_url(image: AnswerScreenshot, use_endpoint: bool = False) -> str: + if use_endpoint: + return f"/api/admin/answer-screenshots/{image.id}" return f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}" -def _submission_data(record: AnswerSubmission, include_images: bool = True, include_refund_images: bool = False) -> dict: +LEGACY_FAKE_OCR_QUESTIONS = { + "这款商品使用起来感觉怎么样?", + "敏感肌可以使用这款商品吗?", + "使用后的肤感和效果怎么样?", +} + + +def _has_legacy_fake_ocr_questions(record: AnswerSubmission) -> bool: + questions = list(record.questions) + return bool(questions) and len(questions) <= 3 and all( + question.product_title == "待识别商品" + and question.question_text in LEGACY_FAKE_OCR_QUESTIONS + for question in questions + ) + + +def _submission_data( + record: AnswerSubmission, + include_images: bool = True, + include_refund_images: bool = False, + image_endpoints: bool = False, +) -> dict: # List endpoints deliberately omit screenshot BLOBs. Do not even touch # the lazy relationship in that mode, otherwise SQLAlchemy fetches every # image from remote MySQL before returning the lightweight response. @@ -998,16 +1186,18 @@ def _submission_data(record: AnswerSubmission, include_images: bool = True, incl return { "id": record.id, "submission_no": record.submission_no, - "user_id": record.user.public_id, - "phone": record.user.phone, + "user_id": _public_user_id(record.user), + "taobao_id": record.user.taobao_id, + "phone": _public_user_account(record.user), "shop_name": record.shop_name or "", "status": record.status, + "recognition_failed": record.status == "识别失败" or _has_legacy_fake_ocr_questions(record), "created_at": record.created_at.isoformat(), "updated_at": record.updated_at.isoformat(), - "invited_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x)} 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 [], - "refund_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x)} for x in refund] if include_images or include_refund_images else [], + "invited_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in invited] if include_images else [], + "completion_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in completion] if include_images else [], + "payment_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in payment] if include_images else [], + "refund_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in refund] if include_images or include_refund_images else [], "refund_completed": record.status == "已完成" or bool(refund), "latest_review": ({ "approved": latest_review.approved, @@ -1017,10 +1207,10 @@ def _submission_data(record: AnswerSubmission, include_images: bool = True, incl } 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, + "product_image": _image_url(images[q.product_image_screenshot_id], image_endpoints) if include_images and q.product_image_screenshot_id in images else None, + "source_image": _image_url(images[q.source_screenshot_id], image_endpoints) if include_images and q.source_screenshot_id in images else None, "operator_images": [ - _image_url(images[int(image_id)]) for image_id in ((q.operator_image_screenshot_ids or str(q.operator_image_screenshot_id or "")).split(",")) + _image_url(images[int(image_id)], image_endpoints) for image_id in ((q.operator_image_screenshot_ids or str(q.operator_image_screenshot_id or "")).split(",")) if image_id.strip().isdigit() and int(image_id) in images ] if include_images else [], "question": q.question_text, "answer": q.answer_text, "answer_status": q.answer_status, "selected": q.selected, @@ -1028,14 +1218,15 @@ def _submission_data(record: AnswerSubmission, include_images: bool = True, incl } -def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> dict: +def _task_board_data(record: AnswerSubmission, include_images: bool = True, image_endpoints: bool = False) -> dict: selected = [q for q in sorted(record.questions, key=lambda value: value.sequence_no) if q.selected] product_titles = list(dict.fromkeys(q.product_title for q in selected if q.product_title)) - summary = _submission_data(record, include_images=True) if include_images else { + summary = _submission_data(record, include_images=True, image_endpoints=image_endpoints) if include_images else { "id": record.id, "submission_no": record.submission_no, - "user_id": record.user.public_id, - "phone": record.user.phone, + "user_id": _public_user_id(record.user), + "taobao_id": record.user.taobao_id, + "phone": _public_user_account(record.user), "shop_name": record.shop_name or "", "status": record.status, "created_at": record.created_at.isoformat(), @@ -1075,7 +1266,7 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> d "delete_endpoint": f"/api/admin/task-board/{record.id}", "refund_endpoint": f"/api/admin/task-board/{record.id}/confirm-refund" if record.status == "已通过" else None, "refund_screenshot_count": len(summary.get("refund_screenshots") or []) or (1 if record.status == "已完成" else 0), - "assigned_users": [{"user_id": record.user.public_id, "phone": record.user.phone, "status": status_labels.get(record.status, record.status)}], + "assigned_users": [{"user_id": _public_user_id(record.user), "phone": _public_user_account(record.user), "taobao_id": record.user.taobao_id, "status": status_labels.get(record.status, record.status)}], "selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": q.answer_text, "product_title": q.product_title} for q in selected] if include_images else []), } @@ -1129,11 +1320,13 @@ def _ask_assignment_child_data(assignment: AskAssignment) -> dict: "task_type": "ask", "task_type_label": "提问任务", "product_name": task.product_name, + "shop_name": task.shop_name, "product_link": task.product_link, "status": assignment.status, "status_label": status_label, - "user_id": assignment.user.public_id, - "phone": assignment.user.phone, + "user_id": _public_user_id(assignment.user), + "taobao_id": assignment.user.taobao_id, + "phone": _public_user_account(assignment.user), "qa_count": question_count, "claimed_count": question_count, "submitted_count": len(used_ids), @@ -1150,7 +1343,7 @@ def _ask_assignment_child_data(assignment: AskAssignment) -> dict: "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}], + "assigned_users": [{"user_id": _public_user_id(assignment.user), "phone": _public_user_account(assignment.user), "taobao_id": assignment.user.taobao_id, "status": status_label}], "selected_questions": [], "question_screenshots": [], "completion_screenshots": [], @@ -1162,11 +1355,149 @@ def _ask_assignment_child_data(assignment: AskAssignment) -> dict: } +def _ask_batch_child_data(batch: AskAssignmentBatch) -> dict: + """Return one compact board row for one independent user submission. + + A user may submit an assignment in several batches. The parent + assignment is still the user's question pool, but each batch must remain + visible as its own audit/payout row instead of the latest batch replacing + the previous one. + """ + assignment = batch.assignment + task = assignment.task + try: + selected_ids = { + int(value) for value in (batch.selected_question_ids or "").split(",") + if value.strip().isdigit() + } + except (TypeError, ValueError): + selected_ids = set() + items_by_id = {item.question_id: item for item in assignment.items} + selected_items = [ + items_by_id[question_id] + for question_id in selected_ids + if question_id in items_by_id + ] + question_count = len(selected_items) + status_label = { + "pending": "待审核", + "approved": "待返款", + "paid": "已完成", + # Rejected batches can be submitted again, so keep them in the + # existing board's "进行中" bucket while retaining their history. + "rejected": "进行中", + }.get(batch.status, "进行中") + refund_completed = batch.status == "paid" + selected_questions = [ + { + "id": item.question.id, + "sequence_no": item.question.sequence_no, + "question": item.question.question_text, + "answer": "", + "product_title": task.product_name, + } + for item in sorted(selected_items, key=lambda value: value.question.sequence_no) + ] + detail_endpoint = ( + f"/api/admin/task-board/ask-batches/{batch.id}" + if batch.status in {"approved", "paid"} + else None + ) + return { + "id": batch.id, + "batch_id": batch.id, + "parent_assignment_id": assignment.id, + "board_key": f"ask-batch-{batch.id}", + "record_kind": "ask_batch", + "is_child": True, + "task_id": task.task_no, + "task_type": "ask", + "task_type_label": "提问任务", + "product_name": task.product_name, + "shop_name": task.shop_name, + "product_link": task.product_link, + "status": batch.status, + "status_label": status_label, + "user_id": _public_user_id(assignment.user), + "taobao_id": assignment.user.taobao_id, + "phone": _public_user_account(assignment.user), + "qa_count": question_count, + "claimed_count": question_count, + "submitted_count": question_count if batch.status in {"pending", "approved", "paid"} else 0, + "pending_review_count": 1 if batch.status == "pending" else 0, + "completed_count": question_count if refund_completed else 0, + "total_count": question_count, + "creator_name": task.owner.display_name if task.owner else "运营后台", + "created_at": batch.created_at.isoformat(), + "updated_at": batch.updated_at.isoformat(), + "can_cancel": False, + "can_complete": False, + "can_delete": True, + "detail_endpoint": detail_endpoint, + "delete_endpoint": f"/api/admin/task-board/ask-batches/{batch.id}", + "refund_endpoint": ( + f"/api/admin/task-board/ask-batches/{batch.id}/confirm-refund" + if batch.status == "approved" + else None + ), + "assigned_users": [{ + "user_id": _public_user_id(assignment.user), + "phone": _public_user_account(assignment.user), + "taobao_id": assignment.user.taobao_id, + "status": status_label, + }], + "selected_questions": selected_questions, + "question_screenshots": [], + "completion_screenshots": [], + "payment_screenshots": [], + "refund_screenshots": [], + "refund_proof_uploaded": refund_completed, + "refund_proof_count": 1 if refund_completed else 0, + "latest_review": None, + } + + +def _ask_task_is_fully_completed(task: AskTask) -> bool: + selected_ids = {question.id for question in task.questions if question.selected} + if not selected_ids: + return False + completed_ids: set[int] = set() + for assignment in task.assignments: + for batch in assignment.batches: + if batch.status == "paid": + completed_ids.update(_batch_question_ids(batch)) + if not assignment.batches and assignment.status == "已完成": + completed_ids.update(item.question_id for item in assignment.items if item.selected) + return selected_ids.issubset(completed_ids) + + +def _sync_ask_task_status(task: AskTask) -> None: + if task.status != "已撤销": + task.status = "已完成" if _ask_task_is_fully_completed(task) else "已发布" + task.updated_at = datetime.utcnow() + + def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: questions = sorted(task.questions, key=lambda value: value.sequence_no) selected_questions = [q for q in questions if q.selected] - claimed_tasks = sorted( - (_ask_assignment_child_data(assignment) for assignment in task.assignments), + # Keep every submission batch as an independent child row. Falling back + # to the assignment row preserves compatibility with legacy assignments + # created before batch submissions were introduced. + claimed_tasks = [] + for assignment in task.assignments: + if assignment.batches: + ordered_batches = sorted( + assignment.batches, + key=lambda value: value.created_at or datetime.min, + ) + for batch_no, batch in enumerate(ordered_batches, start=1): + child = _ask_batch_child_data(batch) + child["batch_no"] = batch_no + child["batch_label"] = f"第{batch_no}批提交" + claimed_tasks.append(child) + else: + claimed_tasks.append(_ask_assignment_child_data(assignment)) + claimed_tasks.sort( key=lambda value: value.get("updated_at") or value.get("created_at") or "", reverse=True, ) @@ -1176,10 +1507,10 @@ def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: # the 完成 column is deliberately stricter and only counts refunded rows. completed_count = sum(child["completed_count"] for child in claimed_tasks) pending_count = sum(child["pending_review_count"] for child in claimed_tasks) - fully_claimed = bool(selected_questions) and submitted_count >= len(selected_questions) + fully_completed = _ask_task_is_fully_completed(task) if task.status == "已撤销": status_label = "已撤销" - elif task.status == "已完成" or fully_claimed: + elif fully_completed: status_label = "已完成" else: status_label = "进行中" @@ -1204,7 +1535,7 @@ def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: "pending_review_count": pending_count, "completed_count": completed_count, "total_count": len(selected_questions), "refund_screenshot_count": sum(child.get("refund_proof_count", 0) for child in claimed_tasks), "creator_name": task.owner.display_name if task.owner else "运营后台", "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(), - "can_cancel": task.status == "已发布", "can_complete": task.status == "已发布", "can_delete": True, + "can_cancel": task.status == "已发布", "can_complete": False, "can_delete": True, "delete_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}", # A system-dispatched task is created by an operator rather than an # answer user, so show the creator in the user-id column instead of @@ -1212,16 +1543,31 @@ def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: # the actual answer user's public ID. "user_id": task.owner.display_name if task.owner else "运营后台", "phone": "", "questions": [], "selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": "", "product_title": task.product_name, "source": q.source or "AI生成"} 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")], + # One user can contribute several submission batches. Keep the + # parent detail summary as a unique-user list; the child rows below + # still expose every batch independently. + "assigned_users": list({ + child["assigned_users"][0]["user_id"]: child["assigned_users"][0] + for child in claimed_tasks + if child.get("assigned_users") + }.values()), "claimed_tasks": claimed_tasks, "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None, "detail_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}" if not include_questions else None, } -def _answer_review_data(record: AnswerSubmission, include_images: bool = True) -> dict: +def _answer_review_data( + record: AnswerSubmission, + include_images: bool = True, + image_endpoints: bool = False, +) -> dict: """Keep completed refunds visible as approved audit history.""" - data = _submission_data(record, include_images=include_images) + data = _submission_data( + record, + include_images=include_images, + image_endpoints=image_endpoints, + ) if record.status == "已完成": data["status"] = "已通过" data["refund_completed"] = True @@ -1230,12 +1576,16 @@ def _answer_review_data(record: AnswerSubmission, include_images: bool = True) - return data -def _ask_review_board_data(record: AskAssignmentBatch | AskAssignment, include_images: bool = False) -> dict: +def _ask_review_board_data( + record: AskAssignmentBatch | AskAssignment, + include_images: bool = False, + image_endpoints: bool = False, +) -> dict: """Convert one approved ask submission into an independent payout row.""" is_batch = isinstance(record, AskAssignmentBatch) assignment = record.assignment if is_batch else record if include_images: - data = _ask_batch_data(record, include_images=True, include_refund_images=True) if is_batch else _ask_assignment_data(record, include_images=True, include_refund_images=True) + data = _ask_batch_data(record, include_images=True, include_refund_images=True, image_endpoints=image_endpoints) if is_batch else _ask_assignment_data(record, include_images=True, include_refund_images=True, image_endpoints=image_endpoints) selected = [question for question in data["questions"] if question["selected"]] elif is_batch: selected_ids = {int(value) for value in (record.selected_question_ids or "").split(",") if value} @@ -1248,8 +1598,9 @@ def _ask_review_board_data(record: AskAssignmentBatch | AskAssignment, include_i "product_name": assignment.task.product_name, "product_link": assignment.task.product_link, "product_qr": _product_qr_data(assignment.task), - "user_id": assignment.user.public_id, - "phone": assignment.user.phone, + "user_id": _public_user_id(assignment.user), + "taobao_id": assignment.user.taobao_id, + "phone": _public_user_account(assignment.user), "created_at": record.created_at.isoformat(), "updated_at": record.updated_at.isoformat(), "questions": [], @@ -1288,11 +1639,13 @@ def _ask_review_board_data(record: AskAssignmentBatch | AskAssignment, include_i if is_batch else f"/api/admin/task-board/ask-assignments/{record.id}" ), - "user_id": assignment.user.public_id, - "phone": assignment.user.phone, + "user_id": _public_user_id(assignment.user), + "taobao_id": assignment.user.taobao_id, + "phone": _public_user_account(assignment.user), "assigned_users": [{ - "user_id": assignment.user.public_id, - "phone": assignment.user.phone, + "user_id": _public_user_id(assignment.user), + "phone": _public_user_account(assignment.user), + "taobao_id": assignment.user.taobao_id, "status": "已完成" if refund_completed else "待返款", }], "selected_questions": ([{ @@ -1329,7 +1682,11 @@ def admin_answer_link(user: AdminUser = Depends(current_user), db: Session = Dep stored_user.answer_public_token = _new_answer_public_token(db) db.commit() db.refresh(stored_user) - return {"success": True, "data": {"token": stored_user.answer_public_token, "path": f"/answer/{stored_user.answer_public_token}"}} + return {"success": True, "data": { + "token": stored_user.answer_public_token, + "path": f"/answer/{stored_user.answer_public_token}", + "last_shop_name": stored_user.last_answer_shop_name or "", + }} @app.post("/api/answer/auth/phone-login") @@ -1338,19 +1695,25 @@ def answer_phone_login(payload: dict, db: Session = Depends(get_db)): # by the client on subsequent requests so a phone number can safely use # more than one operator's link without changing the user account model. owner_token = str(payload.get("owner_token") or payload.get("operator_token") or "").strip() - if owner_token: - _answer_link_owner(owner_token, db) + _answer_link_owner(owner_token, db) session = _public_phone_session(_phone_login(payload, db)) - if owner_token: - session["owner_token"] = owner_token + session["owner_token"] = owner_token + return {"success": True, "data": session} + + +@app.post("/api/answer/auth/taobao-login") +def answer_taobao_login(payload: dict, db: Session = Depends(get_db)): + owner_token = str(payload.get("owner_token") or payload.get("operator_token") or "").strip() + _answer_link_owner(owner_token, db) + session = _public_phone_session(_taobao_login(payload, db)) + session["owner_token"] = owner_token return {"success": True, "data": session} @app.post("/api/answer/auth/send-code") def answer_send_code(payload: dict, db: Session = Depends(get_db)): owner_token = str(payload.get("owner_token") or payload.get("operator_token") or "").strip() - if owner_token: - _answer_link_owner(owner_token, db) + _answer_link_owner(owner_token, db) return {"success": True, "data": _send_phone_code(payload)} @@ -1359,19 +1722,68 @@ def ask_phone_login(payload: dict, db: Session = Depends(get_db)): return {"success": True, "data": _public_phone_session(_phone_login(payload, db))} +@app.post("/api/ask/auth/taobao-login") +def ask_taobao_login(payload: dict, db: Session = Depends(get_db)): + return {"success": True, "data": _public_phone_session(_taobao_login(payload, db))} + + @app.post("/api/ask/auth/send-code") def ask_send_code(payload: dict): return {"success": True, "data": _send_phone_code(payload)} +def _start_ask_reservation(db: Session, task: AskTask, assignment: AskAssignment) -> bool: + """Prepare the user's next selectable batch without touching old batches.""" + used_ids = {item.question_id for item in assignment.used_questions} + claim_limit = task.claim_questions_per_user or 5 + if len(used_ids) >= claim_limit: + return False + existing_ids = {item.question_id for item in assignment.items} + allocated_elsewhere = set(db.scalars( + select(AskAssignmentQuestion.question_id) + .join(AskAssignment, AskAssignmentQuestion.assignment_id == AskAssignment.id) + .where(AskAssignment.task_id == task.id, AskAssignment.id != assignment.id) + ).all()) + needed = max(claim_limit - len(existing_ids), 0) + available = [ + question + for question in task.questions + if question.selected + and question.id not in existing_ids + and question.id not in allocated_elsewhere + ] + for question in random.sample(available, min(needed, len(available))): + db.add(AskAssignmentQuestion( + assignment_id=assignment.id, + question_id=question.id, + selected=False, + )) + db.flush() + db.expire(assignment, ["items"]) + active_items = [item for item in assignment.items if item.question_id not in used_ids] + if not active_items: + return False + for item in active_items: + item.selected = False + assignment.status = "待选择" + assignment.reservation_started_at = datetime.utcnow() + assignment.updated_at = assignment.reservation_started_at + return True + + @app.get("/api/ask/tasks/{task_no}") -def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)): +def public_ask_task(task_no: str, token: str, refresh: bool = False, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) task = _published_ask_task(task_no, db, lock=True) + current_assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) + current_expired = _ask_assignment_expired(current_assignment) released = _release_expired_ask_assignments(db, task) if released: db.commit() db.expire_all() + if current_expired: + raise HTTPException(status_code=409, detail="提问任务已失效,请点击刷新重新分配任务") + if released: task = _published_ask_task(task_no, db, lock=True) assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) if not assignment: @@ -1395,11 +1807,20 @@ def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)): ) 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="待选择") + assignment = AskAssignment( + task_id=task.id, + user_id=user.id, + status="待选择", + reservation_started_at=datetime.utcnow(), + ) db.add(assignment); db.flush() for question in assigned: db.add(AskAssignmentQuestion(assignment_id=assignment.id, question_id=question.id, selected=False)) db.commit(); db.refresh(assignment) + elif refresh and assignment.status in {"已通过", "已完成"}: + if not _start_ask_reservation(db, task, assignment): + raise HTTPException(status_code=409, detail="本次任务已被领取完,请关注下一个任务!") + db.commit(); db.refresh(assignment) latest_batch = db.scalar( select(AskAssignmentBatch) .where(AskAssignmentBatch.assignment_id == assignment.id) @@ -1408,7 +1829,7 @@ def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)): # 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 != "进行中": + if latest_batch and assignment.status not in {"待选择", "进行中"}: data = _ask_batch_data(latest_batch, include_images=False, include_refund_images=latest_batch.status == "paid") db.rollback() return {"success": True, "data": data} @@ -1417,6 +1838,20 @@ def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)): return {"success": True, "data": data} +@app.post("/api/ask/tasks/{task_no}/release") +def release_expired_public_ask_assignment(task_no: str, payload: dict, db: Session = Depends(get_db)): + """Release an expired reservation without allocating a replacement yet.""" + user = _answer_user_from_token(str(payload.get("token", "")), db) + task = _published_ask_task(task_no, db) + assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) + if assignment and _ask_assignment_expired(assignment): + _release_ask_assignment_reservation(db, assignment) + db.commit() + return {"success": True, "data": {"released": True}} + db.rollback() + return {"success": True, "data": {"released": False}} + + @app.get("/api/ask/me/assignments") def public_ask_assignments(token: str, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) @@ -1442,8 +1877,12 @@ def public_ask_assignments(token: str, db: Session = Depends(get_db)): def select_public_ask_questions(task_no: str, payload: dict, db: Session = Depends(get_db)): user = _answer_user_from_token(str(payload.get("token", "")), db) task = _published_ask_task(task_no, db) + current_assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) + current_expired = _ask_assignment_expired(current_assignment) if _release_expired_ask_assignments(db, task): db.commit() + if current_expired: + raise HTTPException(status_code=409, detail="提问任务已失效,请点击刷新重新分配任务") assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) if not assignment: raise HTTPException(status_code=409, detail="请先领取提问任务") @@ -1464,6 +1903,8 @@ def select_public_ask_questions(task_no: str, payload: dict, db: Session = Depen # ``used_ids`` remain locked/read-only and are not resubmitted. item.selected = item.question_id in selected_ids assignment.status = "进行中" + assignment.reservation_started_at = datetime.utcnow() + assignment.updated_at = assignment.reservation_started_at db.commit(); db.refresh(assignment) data = _ask_assignment_data(assignment, include_images=False) db.rollback() @@ -1481,8 +1922,12 @@ async def submit_public_ask_task( ): user = _answer_user_from_token(token, db) task = _published_ask_task(task_no, db) + current_assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) + current_expired = _ask_assignment_expired(current_assignment) if _release_expired_ask_assignments(db, task): db.commit() + if current_expired: + raise HTTPException(status_code=409, detail="提问任务已失效,请点击刷新重新分配任务") assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id)) if not assignment: raise HTTPException(status_code=404, detail="提问任务不存在") @@ -1501,7 +1946,7 @@ async def submit_public_ask_task( question_uploads = [(upload.filename or "question", upload.content_type, await upload.read()) for upload in files] payment_upload = (payment_file.filename or "payment", payment_file.content_type, await payment_file.read()) assignment = db.get(AskAssignment, assignment_id) - if not assignment or assignment.status not in {"进行中", "未通过"}: + if not assignment or assignment.status not in {"进行中", "未通过"} or _ask_assignment_expired(assignment): raise HTTPException(status_code=409, detail="当前任务不能提交审核") selected_ids = [item.question_id for item in assignment.items if item.selected] if not selected_ids: @@ -1518,6 +1963,7 @@ async def submit_public_ask_task( filename, content_type, image_data = payment_upload db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="payment", filename=filename, content_type=content_type, image_data=image_data)) assignment.status = "待审核" + assignment.reservation_started_at = None used_ids = {item.question_id for item in assignment.used_questions} for item in assignment.items: if item.selected and item.question_id not in used_ids: @@ -1582,37 +2028,91 @@ def review_pending_count(user: AdminUser = Depends(current_user), db: Session = } -@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) +@app.get("/api/admin/ask-batch-reviews/{batch_id}") +def ask_batch_review_detail(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + batch = db.scalar( + select(AskAssignmentBatch) + .where(AskAssignmentBatch.id == batch_id) + .options(selectinload(AskAssignmentBatch.screenshots).load_only( + AskAssignmentBatchScreenshot.id, + AskAssignmentBatchScreenshot.batch_id, + AskAssignmentBatchScreenshot.stage, + AskAssignmentBatchScreenshot.filename, + AskAssignmentBatchScreenshot.content_type, + )) + ) + if not batch: + raise HTTPException(status_code=404, detail="提问批次审核任务不存在") + _require_ask_task_access(batch.assignment.task, user) + return {"success": True, "data": _ask_batch_data(batch, image_endpoints=True)} + + +@app.get("/api/admin/ask-assignment-reviews/{assignment_id}") +def ask_assignment_review_detail(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + assignment = db.scalar( + select(AskAssignment) + .where(AskAssignment.id == assignment_id) + .options(selectinload(AskAssignment.screenshots).load_only( + AskAssignmentScreenshot.id, + AskAssignmentScreenshot.assignment_id, + AskAssignmentScreenshot.stage, + AskAssignmentScreenshot.filename, + AskAssignmentScreenshot.content_type, + )) + ) if not assignment or not _can_access_ask_task(assignment.task, user): raise HTTPException(status_code=404, detail="提问审核任务不存在") - return {"success": True, "data": _ask_assignment_data(assignment)} + return {"success": True, "data": _ask_assignment_data(assignment, image_endpoints=True)} -@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)} +@app.get("/api/admin/ask-batch-screenshots/{screenshot_id}") +def admin_ask_batch_screenshot(screenshot_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + image = db.get(AskAssignmentBatchScreenshot, screenshot_id) + if not image or not _can_access_ask_task(image.batch.assignment.task, user): + raise HTTPException(status_code=404, detail="截图不存在") + return Response( + content=image.image_data, + media_type=image.content_type, + headers={"Cache-Control": "private, max-age=600"}, + ) + + +@app.get("/api/admin/ask-assignment-screenshots/{screenshot_id}") +def admin_ask_assignment_screenshot(screenshot_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + image = db.get(AskAssignmentScreenshot, screenshot_id) + if not image or not _can_access_ask_task(image.assignment.task, user): + raise HTTPException(status_code=404, detail="截图不存在") + return Response( + content=image.image_data, + media_type=image.content_type, + headers={"Cache-Control": "private, max-age=600"}, + ) + + +@app.post("/api/admin/ask-batch-reviews/{batch_id}/decision") +def ask_batch_review_decision(batch_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + batch = db.get(AskAssignmentBatch, batch_id) + if not batch: + raise HTTPException(status_code=404, detail="提问批次审核任务不存在") + _require_ask_task_access(batch.assignment.task, user) + if batch.status != "pending": + raise HTTPException(status_code=409, detail="任务不在待审核状态") + approved = bool(payload.get("approved")) + reason_code = str(payload.get("reason_code", "")).strip() or None + reason_text = str(payload.get("reason_text", "")).strip() or None + if not approved and not (reason_code or reason_text): + raise HTTPException(status_code=400, detail="请选择或填写审核不通过原因") + db.add(AskAssignmentBatchReview(batch_id=batch.id, approved=approved, reason_code=reason_code, reason_text=reason_text)) + batch.status = "approved" if approved else "rejected" + _sync_ask_assignment_status(batch.assignment) + db.commit(); db.refresh(batch) + return {"success": True, "data": _ask_batch_data(batch, include_images=False)} + + +@app.post("/api/admin/ask-assignment-reviews/{assignment_id}/decision") +def ask_assignment_review_decision(assignment_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): assignment = db.get(AskAssignment, assignment_id) - if not assignment: + 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="任务不在待审核状态") @@ -1631,27 +2131,27 @@ def ask_completion_decision(assignment_id: int, payload: dict, user: AdminUser = assignment.status = "已通过" if approved else "未通过" assignment.updated_at = datetime.utcnow() db.commit(); db.refresh(assignment) - return {"success": True, "data": _ask_assignment_data(assignment)} + return {"success": True, "data": _ask_assignment_data(assignment, include_images=False)} @app.get("/api/answer/me/submissions") -def answer_my_submissions(token: str, owner_token: str = "", include_images: bool = False, db: Session = Depends(get_db)): +def answer_my_submissions(token: str, owner_token: str, include_images: bool = False, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) - statement = select(AnswerSubmission).where(AnswerSubmission.user_id == user.id) - if owner_token.strip(): - owner = _answer_link_owner(owner_token, db) - statement = statement.where(AnswerSubmission.owner_id == owner.id) + owner = _answer_link_owner(owner_token, db) + statement = select(AnswerSubmission).where( + AnswerSubmission.user_id == user.id, + AnswerSubmission.owner_id == owner.id, + ) records = db.scalars(statement.order_by(AnswerSubmission.created_at.desc())).unique().all() return {"success": True, "data": [_submission_data(record, include_images=include_images) for record in records]} @app.get("/api/answer/me/submissions/{submission_id}") -def answer_submission_detail(submission_id: int, token: str, owner_token: str = "", db: Session = Depends(get_db)): +def answer_submission_detail(submission_id: int, token: str, owner_token: str, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) + owner = _answer_link_owner(owner_token, db) record = db.get(AnswerSubmission, submission_id) - if not record or record.user_id != user.id: - raise HTTPException(status_code=404, detail="任务不存在") - if owner_token.strip() and record.owner_id != _answer_link_owner(owner_token, db).id: + if not record or record.user_id != user.id or record.owner_id != owner.id: raise HTTPException(status_code=404, detail="任务不存在") return {"success": True, "data": _submission_data(record, include_images=True, include_refund_images=True)} @@ -1663,7 +2163,10 @@ def _process_answer_submission(submission_id: int): record = db.get(AnswerSubmission, submission_id) if not record or record.status != "待筛选": return - saved = [shot for shot in record.screenshots if shot.stage == "invited"] + saved = sorted( + (shot for shot in record.screenshots if shot.stage == "invited"), + key=lambda shot: shot.id, + ) # A SQLAlchemy Session keeps its transaction/connection until # commit/rollback. Do not hold that connection while OCR/LLM calls # (which can take up to the configured timeout) are running. @@ -1671,15 +2174,17 @@ def _process_answer_submission(submission_id: int): db.rollback() extracted = extract_questions_from_images(saved_payload) if not extracted: - extracted = [ - {"product_title": "待识别商品", "question": "这款商品使用起来感觉怎么样?"}, - {"product_title": "待识别商品", "question": "敏感肌可以使用这款商品吗?"}, - {"product_title": "待识别商品", "question": "使用后的肤感和效果怎么样?"}, - ][:max(1, min(3, len(saved)))] + record = db.get(AnswerSubmission, submission_id) + if record and record.status == "待筛选" and not record.questions: + record.status = "识别失败" + record.updated_at = datetime.utcnow() + db.commit() + return drafted = [ { "product_title": item["product_title"], "question": item["question"], + "source_index": int(item.get("source_index", 0)), "answer": generate_answer_for_question(item["product_title"], item["question"]), } for item in extracted @@ -1690,13 +2195,18 @@ def _process_answer_submission(submission_id: int): if not record or record.status != "待筛选": db.rollback() return - saved = [shot for shot in record.screenshots if shot.stage == "invited"] + saved = sorted( + (shot for shot in record.screenshots if shot.stage == "invited"), + key=lambda shot: shot.id, + ) for index, item in enumerate(drafted): product, question = item["product_title"], item["question"] + source_index = min(max(item["source_index"], 0), len(saved) - 1) + source = saved[source_index] db.add(AnswerRecognizedQuestion( submission_id=record.id, - source_screenshot_id=saved[index % len(saved)].id, - product_image_screenshot_id=saved[index % len(saved)].id, + source_screenshot_id=source.id, + product_image_screenshot_id=source.id, sequence_no=index + 1, product_title=product, question_text=question, @@ -1707,28 +2217,13 @@ def _process_answer_submission(submission_id: int): db.commit() except Exception: db.rollback() - # Keep the submission visible to operators even if OCR/model is unavailable. + # Keep the upload and expose an explicit retry state. Never invent + # questions when OCR or answer drafting fails. try: record = db.get(AnswerSubmission, submission_id) - db.rollback() - 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="待补充", - )) + if record and record.status == "待筛选" and not record.questions: + record.status = "识别失败" + record.updated_at = datetime.utcnow() db.commit() except Exception: db.rollback() @@ -1740,24 +2235,29 @@ def _process_answer_submission(submission_id: int): async def answer_create_submission( background_tasks: BackgroundTasks, token: str = Form(...), - owner_token: str = Form(default=""), + owner_token: str = Form(...), files: list[UploadFile] = File(...), db: Session = Depends(get_db), ): user = _answer_user_from_token(token, db) - owner = _answer_link_owner(owner_token, db) if owner_token.strip() else None + owner = _answer_link_owner(owner_token, db) user_id = user.id db.rollback() - if not files or len(files) > 10: - raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最多上传10张截图") + if not files or len(files) > 2: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="一次最多上传2张截图") allowed = {"image/jpeg", "image/png", "image/webp"} uploaded = [] for upload in files: if upload.content_type not in allowed: raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片") - uploaded.append((upload.filename or "screenshot", upload.content_type, await upload.read())) + image_data = await upload.read() + if not image_data: + raise HTTPException(status_code=400, detail="不能上传空图片") + if len(image_data) > 10 * 1024 * 1024: + raise HTTPException(status_code=413, detail="单张图片不能超过10MB") + uploaded.append((upload.filename or "screenshot", upload.content_type, image_data)) # Persist only after all asynchronous file reads have completed. - record = AnswerSubmission(submission_no=f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", user_id=user_id, owner_id=owner.id if owner else None, status="待筛选") + record = AnswerSubmission(submission_no=f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", user_id=user_id, owner_id=owner.id, status="待筛选") db.add(record); db.flush() for filename, content_type, image_data in uploaded: db.add(AnswerScreenshot(submission_id=record.id, stage="invited", filename=filename, content_type=content_type, image_data=image_data)) @@ -1769,18 +2269,44 @@ async def answer_create_submission( @app.get("/api/admin/answer-task-drafts") def admin_answer_task_drafts(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): - records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.status.in_(["待筛选", "待运营派发", "已派发", "待用户完成"])).where(_answer_owner_clause(user)).order_by(AnswerSubmission.created_at.desc())).unique().all() + records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.status.in_(["待筛选", "识别失败", "待运营派发", "已派发", "待用户完成"])).where(_answer_owner_clause(user)).order_by(AnswerSubmission.created_at.desc())).unique().all() return {"success": True, "data": [_submission_data(record, include_images=False) for record in records]} @app.get("/api/admin/answer-task-drafts/{submission_id}") def admin_answer_task_draft_detail(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) - if not record or record.status not in {"待筛选", "待运营派发", "已派发", "待用户完成"} or not _can_access_answer_submission(record, user): + if not record or record.status not in {"待筛选", "识别失败", "待运营派发", "已派发", "待用户完成"} or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="回答任务不存在") return {"success": True, "data": _submission_data(record, include_images=True)} +@app.post("/api/admin/answer-task-drafts/{submission_id}/retry-recognition") +def retry_answer_task_recognition( + submission_id: int, + background_tasks: BackgroundTasks, + user: AdminUser = Depends(current_user), + db: Session = Depends(get_db), +): + record = db.get(AnswerSubmission, submission_id) + if not record or not _can_access_answer_submission(record, user): + raise HTTPException(status_code=404, detail="回答任务不存在") + legacy_fake_questions = _has_legacy_fake_ocr_questions(record) + if record.status != "识别失败" and not legacy_fake_questions: + raise HTTPException(status_code=409, detail="当前任务不需要重新识别") + if not any(image.stage == "invited" for image in record.screenshots): + raise HTTPException(status_code=400, detail="没有可重新识别的截图") + if legacy_fake_questions: + for question in list(record.questions): + db.delete(question) + db.flush() + record.status = "待筛选" + record.updated_at = datetime.utcnow() + db.commit() + background_tasks.add_task(_process_answer_submission, record.id) + return {"success": True, "data": _submission_data(record, include_images=False)} + + @app.post("/api/admin/answer-task-drafts/questions/{question_id}/operator-image") async def upload_answer_operator_images( question_id: int, @@ -1831,7 +2357,7 @@ def reject_answer_task_draft(submission_id: int, user: AdminUser = Depends(curre record = db.get(AnswerSubmission, submission_id) if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="回答任务不存在") - if record.status not in {"待筛选", "待运营派发"}: + if record.status not in {"待筛选", "识别失败", "待运营派发"}: raise HTTPException(status_code=409, detail="当前任务不能打回") db.delete(record) db.commit() @@ -1969,13 +2495,64 @@ def add_ask_question(task_id: int, payload: dict, user: AdminUser = Depends(curr return {"success": True, "data": {"id": duplicate.id, "sequence_no": duplicate.sequence_no, "question": duplicate.question_text, "selected": duplicate.selected, "source": duplicate.source or "AI生成"}} next_sequence = max((item.sequence_no for item in task.questions), default=0) + 1 source = str(payload.get("source", "自主添加")).strip() or "自主添加" - if source not in {"自主添加", "问答库", "AI生成"}: + if source not in {"自主添加", "问答库", "批量导入", "AI生成"}: source = "自主添加" question = AskQuestion(task_id=task.id, sequence_no=next_sequence, question_text=text, source=source, selected=True) db.add(question); db.commit(); db.refresh(question) return {"success": True, "data": {"id": question.id, "sequence_no": question.sequence_no, "question": question.question_text, "selected": question.selected, "source": question.source}} +@app.post("/api/admin/ask-tasks/{task_id}/questions/bulk") +def add_ask_questions_bulk(task_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + """Append an imported question list to an editable ask-task draft.""" + task = db.scalar(select(AskTask).where(AskTask.id == task_id).with_for_update()) + _require_ask_task_access(task, user) + if task.status != "草稿": + raise HTTPException(status_code=409, detail="已发布任务不能添加提问") + values = payload.get("questions") + if not isinstance(values, list) or not values: + raise HTTPException(status_code=400, detail="没有可导入的问题") + if len(values) > 500: + raise HTTPException(status_code=400, detail="单次最多导入 500 条问题") + existing = {question_key for question_key in ( + re.sub(r"\s+", " ", item.question_text.strip()) for item in task.questions + ) if question_key} + incoming = [] + seen = set() + for value in values: + question_text = re.sub(r"\s+", " ", str(value or "").strip()) + if not question_text or len(question_text) > 500 or question_text in existing or question_text in seen: + continue + seen.add(question_text) + incoming.append(question_text) + next_sequence = max((item.sequence_no for item in task.questions), default=0) + 1 + added = [ + AskQuestion( + task_id=task.id, + sequence_no=next_sequence + index, + question_text=question_text, + source="批量导入", + selected=True, + ) + for index, question_text in enumerate(incoming) + ] + db.add_all(added) + db.commit() + for question in added: + db.refresh(question) + return {"success": True, "data": { + "added_count": len(added), + "skipped_count": len(values) - len(added), + "items": [{ + "id": question.id, + "sequence_no": question.sequence_no, + "question": question.question_text, + "selected": question.selected, + "source": question.source, + } for question in added], + }} + + @app.delete("/api/admin/ask-tasks/{task_id}/questions/{question_id}") def delete_ask_question(task_id: int, question_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): question = db.scalar(select(AskQuestion).where(AskQuestion.id == question_id, AskQuestion.task_id == task_id)) @@ -2019,6 +2596,8 @@ def complete_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: _require_ask_task_access(task, user) if task.status != "已发布": raise HTTPException(status_code=409, detail="当前任务不能完成归档") + if not _ask_task_is_fully_completed(task): + raise HTTPException(status_code=409, detail="全部问题完成返款后,任务才会自动显示为已完成") task.status = "已完成" task.updated_at = datetime.utcnow() db.commit() @@ -2047,7 +2626,11 @@ def update_answer_question(question_id: int, payload: dict, user: AdminUser = De question.answer_text = answer_text else: if "selected" in payload: question.selected = bool(payload["selected"]) - if str(payload.get("answer", "")).strip(): question.answer_text = str(payload["answer"]).strip() + # An empty answer is a valid edit: the operator may intentionally clear + # the generated response. Checking truthiness here used to restore the + # previous text whenever a textarea was cleared and blurred. + if "answer" in payload: + question.answer_text = str(payload["answer"] or "").strip() db.commit(); db.refresh(question) return {"success": True, "data": {"id": question.id, "answer": question.answer_text, "selected": question.selected}} @@ -2066,15 +2649,149 @@ def publish_answer_task(submission_id: int, payload: dict = Body(default={}), us record.shop_name = shop_name if record.owner_id is None: record.owner_id = user.id + stored_user = db.get(AdminUser, user.id) + if stored_user: + stored_user.last_answer_shop_name = shop_name record.status = "待用户完成"; db.commit() return {"success": True, "data": _submission_data(record, include_images=False)} @app.get("/api/admin/task-board") -def admin_task_board(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +def admin_task_board( + page: int = 1, + page_size: int = 20, + task_type: str = "all", + status_filter: str = "all", + keyword: str = "", + date_from: str = "", + date_to: str = "", + user: AdminUser = Depends(current_user), + db: Session = Depends(get_db), +): if _release_expired_ask_assignments(db): db.commit() + page = max(page, 1) + page_size = min(max(page_size, 10), 100) task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"] + answer_candidate = ( + select( + literal("answer").label("record_kind"), + AnswerSubmission.id.label("record_id"), + AnswerSubmission.updated_at.label("sort_at"), + ) + .join(AnswerSubmission.user) + .where(AnswerSubmission.status.in_(task_statuses), _answer_owner_clause(user)) + ) + ask_candidate = ( + select( + literal("ask").label("record_kind"), + AskTask.id.label("record_id"), + AskTask.updated_at.label("sort_at"), + ) + .where( + AskTask.status.in_(["已发布", "已完成", "已撤销"]), + _ask_task_owner_clause(user), + ) + ) + + normalized_type = task_type if task_type in {"ask", "answer"} else "all" + candidates = [] + if normalized_type in {"all", "answer"}: + if status_filter == "进行中": + answer_candidate = answer_candidate.where(AnswerSubmission.status.in_(["待用户完成", "未通过"])) + elif status_filter == "待审核": + answer_candidate = answer_candidate.where(AnswerSubmission.status == "待审核") + elif status_filter == "待返款": + answer_candidate = answer_candidate.where(AnswerSubmission.status == "已通过") + elif status_filter == "已完成": + answer_candidate = answer_candidate.where(AnswerSubmission.status == "已完成") + elif status_filter == "已撤销": + answer_candidate = answer_candidate.where(AnswerSubmission.status == "已撤销") + candidates.append(answer_candidate) + if normalized_type in {"all", "ask"}: + if status_filter == "进行中": + ask_candidate = ask_candidate.where(AskTask.status == "已发布") + elif status_filter == "待审核": + ask_candidate = ask_candidate.where( + or_( + AskTask.assignments.any(AskAssignment.batches.any(AskAssignmentBatch.status == "pending")), + AskTask.assignments.any( + (AskAssignment.status == "待审核") & ~AskAssignment.batches.any() + ), + ) + ) + elif status_filter == "待返款": + ask_candidate = ask_candidate.where( + or_( + AskTask.assignments.any(AskAssignment.batches.any(AskAssignmentBatch.status == "approved")), + AskTask.assignments.any( + (AskAssignment.status == "已通过") & ~AskAssignment.batches.any() + ), + ) + ) + elif status_filter == "已完成": + ask_candidate = ask_candidate.where(AskTask.status == "已完成") + elif status_filter == "已撤销": + ask_candidate = ask_candidate.where(AskTask.status == "已撤销") + candidates.append(ask_candidate) + + normalized_keyword = keyword.strip() + if normalized_keyword: + pattern = f"%{normalized_keyword}%" + answer_filter = or_( + AnswerSubmission.submission_no.like(pattern), + AnswerSubmission.shop_name.like(pattern), + AnswerSubmission.product_name.like(pattern), + AnswerSubmission.user.has(or_( + AnswerUser.taobao_id.like(pattern), + AnswerUser.public_id.like(pattern), + AnswerUser.phone.like(pattern), + )), + ) + ask_filter = or_( + AskTask.task_no.like(pattern), + AskTask.shop_name.like(pattern), + AskTask.product_name.like(pattern), + AskTask.owner.has(AdminUser.display_name.like(pattern)), + AskTask.assignments.any(AskAssignment.user.has(or_( + AnswerUser.taobao_id.like(pattern), + AnswerUser.public_id.like(pattern), + AnswerUser.phone.like(pattern), + ))), + ) + candidates = [ + statement.where(answer_filter if normalized_type == "answer" or index == 0 and normalized_type == "all" else ask_filter) + for index, statement in enumerate(candidates) + ] + + try: + start_at = datetime.fromisoformat(date_from) if date_from else None + end_at = datetime.fromisoformat(date_to) + timedelta(days=1) if date_to else None + except ValueError: + raise HTTPException(status_code=400, detail="日期筛选格式无效") + dated_candidates = [] + for index, statement in enumerate(candidates): + is_answer = normalized_type == "answer" or (normalized_type == "all" and index == 0) + created_column = AnswerSubmission.created_at if is_answer else AskTask.created_at + if start_at: + statement = statement.where(created_column >= start_at) + if end_at: + statement = statement.where(created_column < end_at) + dated_candidates.append(statement) + candidates = dated_candidates + + combined = candidates[0].subquery() if len(candidates) == 1 else union_all(*candidates).subquery() + total = int(db.scalar(select(func.count()).select_from(combined)) or 0) + total_pages = max((total + page_size - 1) // page_size, 1) + page = min(page, total_pages) + page_rows = db.execute( + select(combined.c.record_kind, combined.c.record_id) + .order_by(combined.c.sort_at.desc(), combined.c.record_kind, combined.c.record_id.desc()) + .offset((page - 1) * page_size) + .limit(page_size) + ).all() + answer_ids = [record_id for kind, record_id in page_rows if kind == "answer"] + ask_ids = [record_id for kind, record_id in page_rows if kind == "ask"] records = db.scalars( select(AnswerSubmission).options( selectinload(AnswerSubmission.user), @@ -2082,18 +2799,49 @@ def admin_task_board(user: AdminUser = Depends(current_user), db: Session = Depe noload(AnswerSubmission.screenshots), noload(AnswerSubmission.review_decisions), ) - .where(AnswerSubmission.status.in_(task_statuses)) - .where(_answer_owner_clause(user)) - .order_by(AnswerSubmission.updated_at.desc()) + .where(AnswerSubmission.id.in_(answer_ids)) ).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] + # The board serializer walks several levels below an ask task. Loading + # those relationships lazily used to issue multiple SQL statements per + # task (more than 160 queries for only 65 board rows). Batch-load the + # complete lightweight graph and leave image BLOB relationships unloaded. + ask_assignment_loader = selectinload(AskTask.assignments) + ask_statement = ( + select(AskTask) + .options( + defer(AskTask.product_qr_image_data), + selectinload(AskTask.owner), + selectinload(AskTask.questions), + ask_assignment_loader.selectinload(AskAssignment.user), + ask_assignment_loader.selectinload(AskAssignment.items).selectinload( + AskAssignmentQuestion.question + ), + ask_assignment_loader.selectinload(AskAssignment.used_questions), + ask_assignment_loader.selectinload(AskAssignment.batches).options( + noload(AskAssignmentBatch.screenshots), + noload(AskAssignmentBatch.review_decisions), + ), + ask_assignment_loader.options( + noload(AskAssignment.screenshots), + noload(AskAssignment.review_decisions), + ), + ) + .where(AskTask.id.in_(ask_ids)) ) - data.sort(key=lambda item: item.get("updated_at") or item.get("created_at") or "", reverse=True) - return {"success": True, "data": data} + ask_tasks = db.scalars(ask_statement).unique().all() + answer_map = {record.id: _task_board_data(record, include_images=False) for record in records} + ask_map = {task.id: _ask_task_data(task, include_questions=False) for task in ask_tasks} + data = [ + (answer_map if kind == "answer" else ask_map)[record_id] + for kind, record_id in page_rows + ] + return {"success": True, "data": { + "items": data, + "page": page, + "page_size": page_size, + "total": total, + "total_pages": total_pages, + }} @app.get("/api/admin/task-board/ask-tasks/{task_id}") @@ -2121,10 +2869,20 @@ def admin_task_board_delete_ask_task(task_id: int, user: AdminUser = Depends(cur @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) + batch = db.scalar( + select(AskAssignmentBatch) + .where(AskAssignmentBatch.id == batch_id) + .options(selectinload(AskAssignmentBatch.screenshots).load_only( + AskAssignmentBatchScreenshot.id, + AskAssignmentBatchScreenshot.batch_id, + AskAssignmentBatchScreenshot.stage, + AskAssignmentBatchScreenshot.filename, + AskAssignmentBatchScreenshot.content_type, + )) + ) if not batch or batch.status not in {"approved", "paid"} or not _can_access_ask_task(batch.assignment.task, user): raise HTTPException(status_code=404, detail="待返款提问记录不存在") - return {"success": True, "data": _ask_review_board_data(batch, include_images=True)} + return {"success": True, "data": _ask_review_board_data(batch, include_images=True, image_endpoints=True)} @app.delete("/api/admin/task-board/ask-batches/{batch_id}") @@ -2132,17 +2890,31 @@ def admin_task_board_delete_ask_batch(batch_id: int, user: AdminUser = Depends(c batch = db.get(AskAssignmentBatch, batch_id) if not batch or not _can_access_ask_task(batch.assignment.task, user): raise HTTPException(status_code=404, detail="提问提交记录不存在") + task = batch.assignment.task db.delete(batch) + db.flush() + db.expire(task, ["assignments"]) + _sync_ask_task_status(task) db.commit() return {"success": True, "data": {"id": batch_id}} @app.get("/api/admin/task-board/ask-assignments/{assignment_id}") def admin_task_board_ask_assignment_detail(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): - assignment = db.get(AskAssignment, assignment_id) + assignment = db.scalar( + select(AskAssignment) + .where(AskAssignment.id == assignment_id) + .options(selectinload(AskAssignment.screenshots).load_only( + AskAssignmentScreenshot.id, + AskAssignmentScreenshot.assignment_id, + AskAssignmentScreenshot.stage, + AskAssignmentScreenshot.filename, + AskAssignmentScreenshot.content_type, + )) + ) if not assignment or assignment.status not in {"已通过", "已完成"} or not _can_access_ask_task(assignment.task, user): raise HTTPException(status_code=404, detail="待返款提问记录不存在") - return {"success": True, "data": _ask_review_board_data(assignment, include_images=True)} + return {"success": True, "data": _ask_review_board_data(assignment, include_images=True, image_endpoints=True)} @app.delete("/api/admin/task-board/ask-assignments/{assignment_id}") @@ -2150,7 +2922,11 @@ def admin_task_board_delete_ask_assignment(assignment_id: int, user: AdminUser = assignment = db.get(AskAssignment, assignment_id) if not assignment or not _can_access_ask_task(assignment.task, user): raise HTTPException(status_code=404, detail="提问分配记录不存在") + task = assignment.task db.delete(assignment) + db.flush() + db.expire(task, ["assignments"]) + _sync_ask_task_status(task) db.commit() return {"success": True, "data": {"id": assignment_id}} @@ -2261,8 +3037,9 @@ async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile | None session.used_at = datetime.utcnow() batch.status = "paid" batch.updated_at = datetime.utcnow() + _sync_ask_task_status(batch.assignment.task) db.commit(); db.refresh(batch) - return {"success": True, "data": _ask_review_board_data(batch, include_images=True)} + return {"success": True, "data": _ask_review_board_data(batch, include_images=True, image_endpoints=True)} @app.post("/api/admin/task-board/ask-assignments/{assignment_id}/confirm-refund") @@ -2289,16 +3066,27 @@ async def confirm_ask_assignment_refund(assignment_id: int, refund_file: UploadF session.used_at = datetime.utcnow() assignment.status = "已完成" assignment.updated_at = datetime.utcnow() + _sync_ask_task_status(assignment.task) db.commit(); db.refresh(assignment) - return {"success": True, "data": _ask_review_board_data(assignment, include_images=True)} + return {"success": True, "data": _ask_review_board_data(assignment, include_images=True, image_endpoints=True)} @app.get("/api/admin/task-board/{submission_id}") def admin_task_board_detail(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): - record = db.get(AnswerSubmission, submission_id) + record = db.scalar( + select(AnswerSubmission) + .where(AnswerSubmission.id == submission_id) + .options(selectinload(AnswerSubmission.screenshots).load_only( + AnswerScreenshot.id, + AnswerScreenshot.submission_id, + AnswerScreenshot.stage, + AnswerScreenshot.filename, + AnswerScreenshot.content_type, + )) + ) if not record or record.status not in {"待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"} or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="任务不存在") - return {"success": True, "data": _task_board_data(record)} + return {"success": True, "data": _task_board_data(record, image_endpoints=True)} @app.delete("/api/admin/task-board/{submission_id}") @@ -2335,7 +3123,7 @@ async def confirm_answer_refund(submission_id: int, refund_file: UploadFile | No session.used_at = datetime.utcnow() record.status = "已完成" db.commit(); db.refresh(record) - return {"success": True, "data": _task_board_data(record, include_images=True)} + return {"success": True, "data": _task_board_data(record, include_images=True, image_endpoints=True)} @app.post("/api/admin/task-board/{submission_id}/cancel") @@ -2355,10 +3143,9 @@ def admin_task_board_cancel(submission_id: int, user: AdminUser = Depends(curren @app.post("/api/answer/me/submissions/{submission_id}/completion") -async def answer_completion(submission_id: int, token: str = Form(...), owner_token: str = Form(default=""), 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 owner_token.strip() and record.owner_id != _answer_link_owner(owner_token, db).id: +async def answer_completion(submission_id: int, token: str = Form(...), owner_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); owner = _answer_link_owner(owner_token, db); record = db.get(AnswerSubmission, submission_id) + if not record or record.user_id != user.id or record.owner_id != owner.id: raise HTTPException(status_code=404, detail="任务不存在") record_status = record.status if record_status not in ("待用户完成", "未通过"): raise HTTPException(status_code=400, detail="当前任务不能提交完成截图") @@ -2375,7 +3162,7 @@ async def answer_completion(submission_id: int, token: str = Form(...), owner_to record = db.get(AnswerSubmission, submission_id) if not record or record.user_id != user.id or record.status != record_status: raise HTTPException(status_code=409, detail="任务状态已发生变化,请刷新后重试") - if owner_token.strip() and record.owner_id != _answer_link_owner(owner_token, db).id: + if record.owner_id != owner.id: raise HTTPException(status_code=404, detail="任务不存在") if record_status == "未通过": for old_screenshot in list(record.screenshots): @@ -2401,10 +3188,34 @@ def answer_completion_reviews(user: AdminUser = Depends(current_user), db: Sessi @app.get("/api/admin/answer-completion-reviews/{submission_id}") def answer_completion_review_detail(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): - record = db.get(AnswerSubmission, submission_id) + record = db.scalar( + select(AnswerSubmission) + .where(AnswerSubmission.id == submission_id) + .options( + selectinload(AnswerSubmission.screenshots).load_only( + AnswerScreenshot.id, + AnswerScreenshot.submission_id, + AnswerScreenshot.stage, + AnswerScreenshot.filename, + AnswerScreenshot.content_type, + ) + ) + ) if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="任务不存在") - return {"success": True, "data": _answer_review_data(record)} + return {"success": True, "data": _answer_review_data(record, image_endpoints=True)} + + +@app.get("/api/admin/answer-screenshots/{screenshot_id}") +def admin_answer_screenshot(screenshot_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + image = db.get(AnswerScreenshot, screenshot_id) + if not image or not _can_access_answer_submission(image.submission, user): + raise HTTPException(status_code=404, detail="截图不存在") + return Response( + content=image.image_data, + media_type=image.content_type, + headers={"Cache-Control": "private, max-age=600"}, + ) @app.post("/api/admin/answer-completion-reviews/{submission_id}/decision") @@ -2419,7 +3230,7 @@ def answer_completion_decision(submission_id: int, payload: dict, user: AdminUse 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)} + return {"success": True, "data": _submission_data(record, include_images=False)} @app.post("/api/answer/public/submissions") @@ -2723,6 +3534,122 @@ def login(payload: LoginRequest, request: Request, response: Response, db: Sessi return {"success": True, "data": {"user": public_user(user)}} +IMPORT_QUESTION_HEADERS = {"问题", "提问", "问题内容", "题目", "question", "question_text", "q"} + + +def _clean_import_question(value: object) -> str: + text_value = re.sub(r"\s+", " ", str(value or "").replace("\ufeff", "").strip()) + text_value = re.sub(r"^(?:[-*+]\s+|#{1,6}\s+|\d+[.、)]\s*)", "", text_value).strip() + text_value = re.sub(r"^(?:问题|提问|question|q)\s*\d*\s*[::]\s*", "", text_value, flags=re.IGNORECASE).strip() + if text_value.lower() in IMPORT_QUESTION_HEADERS: + return "" + if re.match(r"^(?:回答|答案|answer|a)\s*\d*\s*[::]", text_value, flags=re.IGNORECASE): + return "" + return text_value if 0 < len(text_value) <= 500 else "" + + +def _dedupe_import_questions(values: list[object]) -> list[str]: + result = [] + seen = set() + for value in values: + question = _clean_import_question(value) + key = question.casefold() + if not question or key in seen: + continue + seen.add(key) + result.append(question) + return result + + +def _decode_import_file(raw: bytes) -> str: + for encoding in ("utf-8-sig", "gb18030"): + try: + return raw.decode(encoding) + except UnicodeDecodeError: + continue + raise ValueError("文件编码无法识别") + + +def _questions_from_rows(rows: list[list[object]]) -> list[str]: + rows = [list(row) for row in rows if any(str(value or "").strip() for value in row)] + if not rows: + return [] + header = [str(value or "").replace("\ufeff", "").strip().lower() for value in rows[0]] + question_index = next((index for index, value in enumerate(header) if value in IMPORT_QUESTION_HEADERS), None) + data_rows = rows[1:] if question_index is not None else rows + values = [] + for row in data_rows: + if question_index is not None: + values.append(row[question_index] if question_index < len(row) else "") + else: + values.append(next((value for value in row if str(value or "").strip()), "")) + return _dedupe_import_questions(values) + + +def _questions_from_json(value: object) -> list[str]: + if isinstance(value, dict): + for container_key in ("questions", "items", "data", "list"): + if container_key in value: + return _questions_from_json(value[container_key]) + for question_key in ("question", "question_text", "问题", "提问", "问题内容", "题目"): + if question_key in value: + return _dedupe_import_questions([value[question_key]]) + return [] + if isinstance(value, list): + questions = [] + for item in value: + questions.extend(_questions_from_json(item) if isinstance(item, (dict, list)) else _dedupe_import_questions([item])) + return _dedupe_import_questions(questions) + return _dedupe_import_questions([value]) + + +@app.post("/api/admin/ask-questions/import") +async def import_ask_questions(file: UploadFile = File(...), _: AdminUser = Depends(current_user)): + """Parse a local question list without changing an existing task.""" + filename = (file.filename or "").strip() + suffix = Path(filename).suffix.lower() + if suffix not in {".txt", ".csv", ".json", ".md", ".xlsx"}: + raise HTTPException(status_code=400, detail="仅支持 TXT、CSV、JSON、Markdown、XLSX 文件") + raw = await file.read() + if not raw: + raise HTTPException(status_code=400, detail="导入文件不能为空") + if len(raw) > 5 * 1024 * 1024: + raise HTTPException(status_code=400, detail="导入文件不能超过 5MB") + try: + if suffix == ".xlsx": + from openpyxl import load_workbook + workbook = load_workbook(io.BytesIO(raw), read_only=True, data_only=True) + sheet = workbook.active + questions = _questions_from_rows([list(row) for row in sheet.iter_rows(values_only=True)]) + workbook.close() + elif suffix == ".json": + questions = _questions_from_json(json.loads(_decode_import_file(raw))) + elif suffix == ".csv": + questions = _questions_from_rows(list(csv.reader(io.StringIO(_decode_import_file(raw))))) + else: + lines = _decode_import_file(raw).splitlines() + explicit_questions = [ + line for line in lines + if re.match(r"^\s*(?:[-*+]\s*)?(?:\d+[.、)]\s*)?(?:问题|提问|question|q)\s*\d*\s*[::]", line, flags=re.IGNORECASE) + ] + questions = _dedupe_import_questions(explicit_questions or lines) + except (ValueError, UnicodeError) as exc: + raise HTTPException(status_code=400, detail="无法读取文件,请确认格式和编码正确") from exc + except Exception as exc: + import logging + logging.getLogger(__name__).exception("Question import parsing failed") + raise HTTPException(status_code=400, detail="文件解析失败,请确认文件未损坏") from exc + if not questions: + raise HTTPException(status_code=400, detail="文件中没有识别到问题,请按每行一个问题整理后重试") + if len(questions) > 500: + questions = questions[:500] + return {"success": True, "data": { + "filename": filename, + "questions": questions, + "count": len(questions), + }} + + @app.post("/api/admin/reference-qa/parse") async def parse_reference_qa(file: UploadFile = File(...), _: AdminUser = Depends(current_user)): """Extract readable question/answer text from an uploaded XLSX workbook. diff --git a/backend/app/models.py b/backend/app/models.py index 94d1a7c..67f6dba 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -27,6 +27,7 @@ class AdminUser(Base): # operator. The token is intentionally separate from the admin session # cookie and is safe to embed in a long-lived URL. answer_public_token: Mapped[str | None] = mapped_column(String(96), unique=True, index=True, nullable=True) + last_answer_shop_name: Mapped[str | None] = mapped_column(String(255), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) @@ -72,6 +73,7 @@ class AnswerUser(Base): id: Mapped[int] = mapped_column(Integer, primary_key=True) public_id: Mapped[str] = mapped_column(String(32), unique=True, index=True) phone: Mapped[str] = mapped_column(String(20), unique=True, index=True) + taobao_id: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True) auth_token: Mapped[str] = mapped_column(String(64), unique=True, index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) last_login_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) @@ -212,6 +214,10 @@ class AskAssignment(Base): task_id: Mapped[int] = mapped_column(ForeignKey("ask_tasks.id"), index=True) user_id: Mapped[int] = mapped_column(ForeignKey("answer_users.id"), index=True) status: Mapped[str] = mapped_column(String(32), default="待选择", index=True) + # The current reservation window is independent from the assignment's + # lifetime. One assignment can retain several submitted batches while a + # later, unsubmitted selection expires and is released safely. + reservation_started_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True, index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/backend/requirements.txt b/backend/requirements.txt index c724680..13f417a 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,5 @@ pymysql>=1.1,<2.0 python-multipart>=0.0.20,<1.0 httpx2>=2.12,<3.0 python-dotenv>=1.0,<2.0 +openpyxl>=3.1,<4.0 +pillow>=11.0,<13.0 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..7c36c93 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,14 @@ +import os +import tempfile +from pathlib import Path + + +# Never let the integration tests inherit a development or production MySQL +# URL. The application is imported during test collection, so the isolated +# database must be selected before any test module imports app.main. +_test_dir = Path(tempfile.mkdtemp(prefix="wendajia-tests-")) +os.environ["DATABASE_URL"] = f"sqlite:///{(_test_dir / 'app.db').as_posix()}" +os.environ["ADMIN_USERNAME"] = "admin" +os.environ["ADMIN_PASSWORD"] = "admin123456" +os.environ["OPERATOR_USERNAME"] = "operator" +os.environ["OPERATOR_PASSWORD"] = "operator123456" diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 2593204..c69170c 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,6 +1,9 @@ import base64 +from datetime import datetime, timedelta +from io import BytesIO from fastapi.testclient import TestClient +from openpyxl import Workbook from uuid import uuid4 import app.main as main_module @@ -13,6 +16,25 @@ def fake_ask_questions(payload, count, reference_text): return [f"本地测试问题{i + 1}?" for i in range(count)] +def answer_owner_token(client: TestClient, username: str = "operator") -> str: + password = "operator123456" if username == "operator" else "admin123456" + assert client.post("/api/admin/auth/login", json={"username": username, "password": password}).status_code == 200 + response = client.get("/api/admin/auth/answer-link") + assert response.status_code == 200 + return response.json()["data"]["token"] + + +def task_board_items(client: TestClient, **params): + response = client.get("/api/admin/task-board", params=params) + assert response.status_code == 200 + parents = response.json()["data"]["items"] + return [ + row + for parent in parents + for row in [parent, *(parent.get("claimed_tasks") or [])] + ] + + def test_admin_login_me_and_logout(): with TestClient(app) as client: login = client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"}) @@ -43,9 +65,13 @@ def test_register_and_super_admin_user_crud(): username = f"tester_{uuid4().hex[:8]}" with TestClient(app) as client: registration = client.post("/api/admin/auth/register", json={"username": username, "password": "password123", "display_name": "测试账号"}) - assert registration.status_code == 201 - assert registration.json()["data"]["user"]["role_code"] == "operator" + assert registration.status_code == 403 assert client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"}).status_code == 200 + created_response = client.post( + "/api/admin/users", + json={"username": username, "password": "password123", "display_name": "测试账号", "role_code": "operator"}, + ) + assert created_response.status_code == 201 records = client.get("/api/admin/users").json()["data"] created = next(item for item in records if item["username"] == username) assert client.get(f"/api/admin/users/{created['id']}").status_code == 200 @@ -116,13 +142,98 @@ def test_task_board_is_available_to_operator(): assert login.status_code == 200 response = client.get("/api/admin/task-board") assert response.status_code == 200 - assert isinstance(response.json()["data"], list) + assert isinstance(response.json()["data"]["items"], list) + assert response.json()["data"]["page"] == 1 counts = client.get("/api/admin/review-pending-count") assert counts.status_code == 200 count_data = counts.json()["data"] assert count_data["total"] == count_data["ask"] + count_data["answer"] +def test_taobao_id_login_is_shared_by_both_user_portals(): + taobao_id = f"tb{uuid4().hex[:14]}" + with TestClient(app) as client: + owner_token = answer_owner_token(client) + answer_login = client.post( + "/api/answer/auth/taobao-login", + json={"taobao_id": taobao_id, "owner_token": owner_token}, + ) + assert answer_login.status_code == 200 + answer_session = answer_login.json()["data"] + assert answer_session["user_id"] == taobao_id + assert answer_session["taobao_id"] == taobao_id + assert answer_session["account"] == taobao_id + assert answer_session["phone"] == "" + + ask_login = client.post( + "/api/ask/auth/taobao-login", + json={"taobao_id": taobao_id}, + ) + assert ask_login.status_code == 200 + assert ask_login.json()["data"]["token"] == answer_session["token"] + + invalid = client.post( + "/api/ask/auth/taobao-login", + json={"taobao_id": "不合法 ID"}, + ) + assert invalid.status_code == 400 + + +def test_answer_task_publish_remembers_operator_shop_name(): + unique = uuid4().hex[:10] + remembered_shop = f"记忆店铺-{unique}" + with TestClient(app) as client: + assert client.post( + "/api/admin/auth/login", + json={"username": "operator", "password": "operator123456"}, + ).status_code == 200 + with main_module.SessionLocal() as db: + owner = db.scalar( + main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator") + ) + previous_shop_name = owner.last_answer_shop_name + public_user = main_module.AnswerUser( + public_id=f"U{unique[:7]}", + phone=f"136{uuid4().int % 100_000_000:08d}", + auth_token=uuid4().hex, + ) + db.add(public_user) + db.flush() + submission = main_module.AnswerSubmission( + submission_no=f"ANS{unique.upper()}", + user_id=public_user.id, + owner_id=owner.id, + status="待筛选", + ) + db.add(submission) + db.flush() + db.add(main_module.AnswerRecognizedQuestion( + submission_id=submission.id, + sequence_no=1, + product_title="记忆测试商品", + question_text="店铺名会被记住吗?", + answer_text="会。", + selected=True, + )) + db.commit() + submission_id = submission.id + + published = client.post( + f"/api/admin/answer-task-drafts/{submission_id}/publish", + json={"shop_name": remembered_shop}, + ) + assert published.status_code == 200 + link_settings = client.get("/api/admin/auth/answer-link") + assert link_settings.status_code == 200 + assert link_settings.json()["data"]["last_shop_name"] == remembered_shop + with main_module.SessionLocal() as db: + owner = db.scalar( + main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator") + ) + owner.last_answer_shop_name = previous_shop_name + db.commit() + + def test_answer_review_rejection_requires_reason(): with TestClient(app) as client: login = client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}) @@ -131,38 +242,261 @@ def test_answer_review_rejection_requires_reason(): assert missing.status_code == 404 -def test_answer_rejection_reason_syncs_to_public_user(): +def test_answer_review_detail_loads_screenshots_from_protected_endpoint(): + unique = uuid4().hex[:10] + with TestClient(app) as client: + assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 + with main_module.SessionLocal() as db: + owner = db.scalar( + main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator") + ) + public_user = main_module.AnswerUser( + public_id=f"U{unique[:7]}", + phone=f"139{uuid4().int % 100_000_000:08d}", + auth_token=uuid4().hex, + ) + db.add(public_user) + db.flush() + submission = main_module.AnswerSubmission( + submission_no=f"ANS{unique.upper()}", + user_id=public_user.id, + owner_id=owner.id, + shop_name="审核性能测试店铺", + status="待审核", + ) + db.add(submission) + db.flush() + screenshot = main_module.AnswerScreenshot( + submission_id=submission.id, + stage="completion", + filename="completion.png", + content_type="image/png", + image_data=b"review-image-content", + ) + db.add_all([ + screenshot, + main_module.AnswerRecognizedQuestion( + submission_id=submission.id, + sequence_no=1, + product_title="审核性能测试商品", + question_text="测试问题?", + answer_text="测试回答。", + selected=True, + ), + ]) + db.commit() + submission_id = submission.id + + detail = client.get(f"/api/admin/answer-completion-reviews/{submission_id}") + assert detail.status_code == 200 + screenshot_url = detail.json()["data"]["completion_screenshots"][0]["data_url"] + assert screenshot_url.startswith("/api/admin/answer-screenshots/") + assert "base64" not in detail.text + image = client.get(screenshot_url) + assert image.status_code == 200 + assert image.headers["content-type"] == "image/png" + assert image.content == b"review-image-content" + + +def test_ask_review_detail_loads_screenshots_from_protected_endpoint(): + unique = uuid4().hex[:10] + with TestClient(app) as client: + assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 + with main_module.SessionLocal() as db: + owner = db.scalar( + main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator") + ) + public_user = main_module.AnswerUser( + public_id=f"U{unique[:7]}", + phone=f"tb_{unique}", + taobao_id=f"tb{unique}", + auth_token=uuid4().hex, + ) + task = main_module.AskTask( + task_no=f"TASK{unique.upper()}", + public_token=uuid4().hex, + owner_id=owner.id, + product_link="TEST-TOKEN", + shop_name="审核性能测试店铺", + product_name="审核性能测试商品", + status="已发布", + ) + db.add_all([public_user, task]) + db.flush() + question = main_module.AskQuestion( + task_id=task.id, + sequence_no=1, + question_text="提问详情是否轻量?", + selected=True, + ) + assignment = main_module.AskAssignment( + task_id=task.id, + user_id=public_user.id, + status="待审核", + ) + db.add_all([question, assignment]) + db.flush() + db.add(main_module.AskAssignmentQuestion( + assignment_id=assignment.id, + question_id=question.id, + selected=True, + )) + batch = main_module.AskAssignmentBatch( + assignment_id=assignment.id, + status="pending", + selected_question_ids=str(question.id), + ) + db.add(batch) + db.flush() + db.add_all([ + main_module.AskAssignmentBatchScreenshot( + batch_id=batch.id, + stage="question", + filename="question.png", + content_type="image/png", + image_data=b"ask-question-image", + ), + main_module.AskAssignmentBatchScreenshot( + batch_id=batch.id, + stage="payment", + filename="payment.png", + content_type="image/png", + image_data=b"ask-payment-image", + ), + ]) + db.commit() + batch_id = batch.id + + detail = client.get(f"/api/admin/ask-batch-reviews/{batch_id}") + assert detail.status_code == 200 + data = detail.json()["data"] + assert "base64" not in detail.text + question_url = data["question_screenshots"][0]["data_url"] + payment_url = data["payment_screenshots"][0]["data_url"] + assert question_url.startswith("/api/admin/ask-batch-screenshots/") + assert client.get(question_url).content == b"ask-question-image" + assert client.get(payment_url).content == b"ask-payment-image" + + decision = client.post( + f"/api/admin/ask-batch-reviews/{batch_id}/decision", + json={"approved": True}, + ) + assert decision.status_code == 200 + assert "base64" not in decision.text + assert decision.json()["data"]["latest_review"]["approved"] is True + + +def test_failed_answer_recognition_has_no_fake_questions_and_can_retry(monkeypatch): + unique = uuid4().hex[:10] + with TestClient(app) as client: + assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 + with main_module.SessionLocal() as db: + owner = db.scalar( + main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator") + ) + public_user = main_module.AnswerUser( + public_id=f"U{unique[:7]}", + phone=f"137{uuid4().int % 100_000_000:08d}", + auth_token=uuid4().hex, + ) + db.add(public_user) + db.flush() + submission = main_module.AnswerSubmission( + submission_no=f"ANS{unique.upper()}", + user_id=public_user.id, + owner_id=owner.id, + status="待筛选", + ) + db.add(submission) + db.flush() + db.add(main_module.AnswerScreenshot( + submission_id=submission.id, + stage="invited", + filename="invited.png", + content_type="image/png", + image_data=b"image-content", + )) + db.commit() + submission_id = submission.id + + monkeypatch.setattr(main_module, "extract_questions_from_images", lambda images: []) + main_module._process_answer_submission(submission_id) + with main_module.SessionLocal() as db: + failed = db.get(main_module.AnswerSubmission, submission_id) + assert failed.status == "识别失败" + assert failed.questions == [] + + monkeypatch.setattr( + main_module, + "extract_questions_from_images", + lambda images: [{"product_title": "识别商品", "question": "重新识别成功了吗?", "source_index": 0}], + ) + monkeypatch.setattr(main_module, "generate_answer_for_question", lambda product, question: "重新识别成功。") + retried = client.post(f"/api/admin/answer-task-drafts/{submission_id}/retry-recognition") + assert retried.status_code == 200 + with main_module.SessionLocal() as db: + completed = db.get(main_module.AnswerSubmission, submission_id) + assert completed.status == "待筛选" + assert [question.question_text for question in completed.questions] == ["重新识别成功了吗?"] + question_id = completed.questions[0].id + + cleared = client.patch( + f"/api/admin/answer-task-drafts/questions/{question_id}", + json={"answer": ""}, + ) + assert cleared.status_code == 200 + assert cleared.json()["data"]["answer"] == "" + with main_module.SessionLocal() as db: + assert db.get(main_module.AnswerRecognizedQuestion, question_id).answer_text == "" + + +def test_answer_rejection_reason_syncs_to_public_user(monkeypatch): previous_key = settings.llm_api_key settings.llm_api_key = "" - phone = f"19{uuid4().int % 1_000_000_000:09d}" + monkeypatch.setattr( + main_module, + "extract_questions_from_images", + lambda images: [{"product_title": "测试商品", "question": "测试问题?", "source_index": 0}], + ) + taobao_id = f"tb{uuid4().hex[:14]}" try: with TestClient(app) as client: - login = client.post("/api/answer/auth/phone-login", json={"phone": phone}) + owner_token = answer_owner_token(client) + login = client.post("/api/answer/auth/taobao-login", json={"taobao_id": taobao_id, "owner_token": owner_token}) assert login.status_code == 200 token = login.json()["data"]["token"] created = client.post( "/api/answer/me/submissions", - data={"token": token}, + data={"token": token, "owner_token": owner_token}, files=[("files", ("invited.png", b"test-image", "image/png"))], ) assert created.status_code == 200 submission = created.json()["data"] + submission = client.get( + f"/api/answer/me/submissions/{submission['id']}?token={token}&owner_token={owner_token}" + ).json()["data"] question_id = submission["questions"][0]["id"] assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 assert client.patch(f"/api/admin/answer-task-drafts/questions/{question_id}", json={"selected": True}).status_code == 200 - assert client.post(f"/api/admin/answer-task-drafts/{submission['id']}/publish").status_code == 200 + assert client.post( + f"/api/admin/answer-task-drafts/{submission['id']}/publish", + json={"shop_name": "测试店铺"}, + ).status_code == 200 completed = client.post( f"/api/answer/me/submissions/{submission['id']}/completion", - data={"token": token}, - files=[("files", ("done.png", b"done-image", "image/png"))], + data={"token": token, "owner_token": owner_token}, + files=[ + ("files", ("done.png", b"done-image", "image/png")), + ("payment_files", ("payment.png", b"payment-image", "image/png")), + ], ) assert completed.status_code == 200 no_reason = client.post(f"/api/admin/answer-completion-reviews/{submission['id']}/decision", json={"approved": False}) assert no_reason.status_code == 400 rejected = client.post(f"/api/admin/answer-completion-reviews/{submission['id']}/decision", json={"approved": False, "reason_code": "数量不足", "reason_text": "应完成1条回答,截图未完整展示"}) assert rejected.status_code == 200 - current = client.get(f"/api/answer/me/submissions?token={token}").json()["data"][0] + current = client.get(f"/api/answer/me/submissions?token={token}&owner_token={owner_token}").json()["data"][0] assert current["status"] == "未通过" assert current["latest_review"]["reason_code"] == "数量不足" assert "截图未完整展示" in current["latest_review"]["reason_text"] @@ -194,7 +528,7 @@ def test_ask_task_publish_appears_on_task_board(monkeypatch): assert public_token assert public_token != task["task_id"] assert len(public_token) >= 24 - board = client.get("/api/admin/task-board").json()["data"] + board = task_board_items(client, page_size=100) created = next(item for item in board if item["task_id"] == task["task_id"]) assert created["task_type"] == "ask" assert created["task_type_label"] == "提问任务" @@ -250,7 +584,7 @@ def test_ask_task_accepts_qr_image_without_token_and_excludes_it_from_ai(monkeyp published = client.post(f"/api/admin/ask-tasks/{task['id']}/publish") assert published.status_code == 200 assert published.json()["data"]["status"] == "已发布" - board = client.get("/api/admin/task-board").json()["data"] + board = task_board_items(client, page_size=100) created = next(item for item in board if item["task_id"] == task["task_id"]) detail = client.get(created["detail_endpoint"]) assert detail.status_code == 200 @@ -274,6 +608,62 @@ def test_ask_task_regeneration_appends_to_existing_questions(monkeypatch): assert [item["sequence_no"] for item in questions] == [1, 2, 3, 4] +def test_public_ask_assignment_expires_after_selection_timeout(monkeypatch): + monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions) + with TestClient(app) as client: + assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 + generated = client.post( + "/api/admin/ask-tasks/generate", + json={"shop_name": "超时测试店铺", "product_link": "TOKEN-TIMEOUT-1", "product_name": "超时测试商品", "question_count": 2}, + ).json()["data"] + assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200 + taobao_id = f"tb{uuid4().hex[:14]}" + token = client.post("/api/ask/auth/taobao-login", json={"taobao_id": taobao_id}).json()["data"]["token"] + task_url = f"/api/ask/tasks/{generated['task_id']}?token={token}" + first = client.get(task_url) + assert first.status_code == 200 + first_data = first.json()["data"] + assignment_id = first_data["assignment_id"] + with main_module.SessionLocal() as db: + assignment = db.get(main_module.AskAssignment, assignment_id) + assignment.reservation_started_at = datetime.utcnow() - timedelta(minutes=2) + db.commit() + expired = client.get(task_url) + assert expired.status_code == 409 + assert expired.json()["detail"] == "提问任务已失效,请点击刷新重新分配任务" + refreshed = client.get(task_url) + assert refreshed.status_code == 200 + assert refreshed.json()["data"]["created_at"] != first_data["created_at"] + + +def test_public_ask_assignment_expires_after_upload_timeout(monkeypatch): + monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions) + with TestClient(app) as client: + assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 + generated = client.post( + "/api/admin/ask-tasks/generate", + json={"shop_name": "超时测试店铺二", "product_link": "TOKEN-TIMEOUT-2", "product_name": "超时测试商品二", "question_count": 2}, + ).json()["data"] + assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200 + taobao_id = f"tb{uuid4().hex[:14]}" + token = client.post("/api/ask/auth/taobao-login", json={"taobao_id": taobao_id}).json()["data"]["token"] + task_url = f"/api/ask/tasks/{generated['task_id']}?token={token}" + assignment = client.get(task_url).json()["data"] + selected = assignment["questions"][0]["id"] + confirmed = client.post(f"/api/ask/tasks/{generated['task_id']}/selection", json={"token": token, "question_ids": [selected]}) + assert confirmed.status_code == 200 + with main_module.SessionLocal() as db: + stored = db.get(main_module.AskAssignment, assignment["assignment_id"]) + stored.reservation_started_at = datetime.utcnow() - timedelta(minutes=31) + db.commit() + expired = client.get(task_url) + assert expired.status_code == 409 + assert expired.json()["detail"] == "提问任务已失效,请点击刷新重新分配任务" + refreshed = client.get(task_url) + assert refreshed.status_code == 200 + assert refreshed.json()["data"]["status"] == "待选择" + + def test_ask_task_added_question_is_idempotent_and_deletable(monkeypatch): monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions) with TestClient(app) as client: @@ -311,6 +701,74 @@ def test_ask_task_added_question_is_idempotent_and_deletable(monkeypatch): assert all(item["question"] != "问答库重复问题?" for item in refreshed.json()["data"]["selected_questions"]) +def test_ask_question_file_import_parses_supported_text_formats(): + with TestClient(app) as client: + assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 + + text_import = client.post( + "/api/admin/ask-questions/import", + files={"file": ("questions.txt", "Q1:第一条问题?\nA1:参考回答\nQ2:第二条问题?\nQ1:第一条问题?".encode("utf-8"), "text/plain")}, + ) + assert text_import.status_code == 200 + assert text_import.json()["data"]["questions"] == ["第一条问题?", "第二条问题?"] + + csv_import = client.post( + "/api/admin/ask-questions/import", + files={"file": ("questions.csv", "问题,答案\nCSV问题一?,参考回答\nCSV问题二?,参考回答".encode("utf-8"), "text/csv")}, + ) + assert csv_import.status_code == 200 + assert csv_import.json()["data"]["questions"] == ["CSV问题一?", "CSV问题二?"] + + workbook = Workbook() + sheet = workbook.active + sheet.append(["问题", "答案"]) + sheet.append(["表格问题一?", "参考回答"]) + sheet.append(["表格问题二?", "参考回答"]) + workbook_file = BytesIO() + workbook.save(workbook_file) + workbook.close() + xlsx_import = client.post( + "/api/admin/ask-questions/import", + files={"file": ("questions.xlsx", workbook_file.getvalue(), "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")}, + ) + assert xlsx_import.status_code == 200 + assert xlsx_import.json()["data"]["questions"] == ["表格问题一?", "表格问题二?"] + + json_import = client.post( + "/api/admin/ask-questions/import", + files={"file": ("questions.json", '[{"question":"JSON问题一?"},{"问题":"JSON问题二?"}]'.encode("utf-8"), "application/json")}, + ) + assert json_import.status_code == 200 + assert json_import.json()["data"]["questions"] == ["JSON问题一?", "JSON问题二?"] + + +def test_ask_question_bulk_import_appends_and_deduplicates(monkeypatch): + monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions) + with TestClient(app) as client: + assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 + created = client.post( + "/api/admin/ask-tasks/generate", + json={ + "shop_name": f"批量导入测试店铺-{uuid4().hex[:8]}", + "product_link": "TOKEN-BULK-IMPORT", + "product_name": "批量导入测试商品", + "question_count": 1, + }, + ) + assert created.status_code == 200 + task_id = created.json()["data"]["id"] + imported = client.post( + f"/api/admin/ask-tasks/{task_id}/questions/bulk", + json={"questions": ["批量问题一?", "批量问题二?", "批量问题一?", "本地测试问题1?"]}, + ) + assert imported.status_code == 200 + result = imported.json()["data"] + assert result["added_count"] == 2 + assert result["skipped_count"] == 2 + assert [item["question"] for item in result["items"]] == ["批量问题一?", "批量问题二?"] + assert all(item["source"] == "批量导入" for item in result["items"]) + + def test_public_ask_user_gets_stable_unique_five_question_assignment(): with TestClient(app) as client: assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 @@ -325,8 +783,8 @@ def test_public_ask_user_gets_stable_unique_five_question_assignment(): ).json()["data"] assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200 - phone = f"139{uuid4().int % 100_000_000:08d}" - session = client.post("/api/ask/auth/phone-login", json={"phone": phone}).json()["data"] + taobao_id = f"tb{uuid4().hex[:14]}" + session = client.post("/api/ask/auth/taobao-login", json={"taobao_id": taobao_id}).json()["data"] url = f"/api/ask/tasks/{generated['task_id']}?token={session['token']}" first = client.get(url) second = client.get(url) @@ -362,25 +820,30 @@ def test_public_ask_user_gets_stable_unique_five_question_assignment(): ) assert submitted.status_code == 200 assert submitted.json()["data"]["status"] == "待审核" - assert len(submitted.json()["data"]["question_screenshots"]) == 1 - assert len(submitted.json()["data"]["payment_screenshots"]) == 1 + batch_detail = client.get( + f"/api/admin/ask-batch-reviews/{submitted.json()['data']['batch_id']}" + ) + assert batch_detail.status_code == 200 + assert len(batch_detail.json()["data"]["question_screenshots"]) == 1 + assert len(batch_detail.json()["data"]["payment_screenshots"]) == 1 reviews = client.get("/api/admin/ask-completion-reviews") assert reviews.status_code == 200 review = next(item for item in reviews.json()["data"] if item["assignment_id"] == submitted.json()["data"]["assignment_id"]) assert review["status"] == "待审核" decision = client.post( - f"/api/admin/ask-completion-reviews/{review['assignment_id']}/decision", + f"/api/admin/ask-batch-reviews/{review['batch_id']}/decision", json={"approved": True}, ) assert decision.status_code == 200 assert decision.json()["data"]["status"] == "已通过" - board = client.get("/api/admin/task-board").json()["data"] + assert "base64" not in decision.text + board = task_board_items(client, page_size=100) payout = next( item for item in board if item.get("record_kind") == "ask_batch" - and item["id"] == review["assignment_id"] + and item["id"] == review["batch_id"] ) assert payout["status_label"] == "待返款" assert payout["task_type"] == "ask" @@ -390,46 +853,68 @@ def test_public_ask_user_gets_stable_unique_five_question_assignment(): assert payout_detail.status_code == 200 assert len(payout_detail.json()["data"]["question_screenshots"]) == 1 assert len(payout_detail.json()["data"]["payment_screenshots"]) == 1 - refunded = client.post(payout["refund_endpoint"]) + question_image_url = payout_detail.json()["data"]["question_screenshots"][0]["data_url"] + payment_image_url = payout_detail.json()["data"]["payment_screenshots"][0]["data_url"] + assert question_image_url.startswith("/api/admin/ask-") + assert payment_image_url.startswith("/api/admin/ask-") + assert "base64" not in payout_detail.text + assert client.get(question_image_url).content == b"question-image" + assert client.get(payment_image_url).content == b"payment-image" + refunded = client.post( + payout["refund_endpoint"], + files={"refund_file": ("refund.png", b"refund-image", "image/png")}, + ) assert refunded.status_code == 200 assert refunded.json()["data"]["status_label"] == "已完成" assert refunded.json()["data"]["refund_endpoint"] is None - board_after_refund = client.get("/api/admin/task-board").json()["data"] + board_after_refund = task_board_items(client, page_size=100) completed_payout = next( item for item in board_after_refund if item.get("record_kind") == "ask_batch" - and item["id"] == review["assignment_id"] + and item["id"] == review["batch_id"] ) assert completed_payout["status_label"] == "已完成" assert completed_payout["completed_count"] == 1 audit_history = client.get("/api/admin/ask-completion-reviews").json()["data"] assert not any(item["assignment_id"] == review["assignment_id"] for item in audit_history) refreshed = client.get(url) - assert refreshed.json()["data"]["status"] == "已通过" + assert refreshed.json()["data"]["status"] == "已完成" -def test_approved_answer_appears_as_pending_refund_with_detail(): +def test_approved_answer_appears_as_pending_refund_with_detail(monkeypatch): previous_key = settings.llm_api_key settings.llm_api_key = "" - phone = f"19{uuid4().int % 1_000_000_000:09d}" + monkeypatch.setattr( + main_module, + "extract_questions_from_images", + lambda images: [{"product_title": "测试商品", "question": "测试问题?", "source_index": 0}], + ) + taobao_id = f"tb{uuid4().hex[:14]}" try: with TestClient(app) as client: - login = client.post("/api/answer/auth/phone-login", json={"phone": phone}) + owner_token = answer_owner_token(client) + login = client.post("/api/answer/auth/taobao-login", json={"taobao_id": taobao_id, "owner_token": owner_token}) token = login.json()["data"]["token"] created = client.post( "/api/answer/me/submissions", - data={"token": token}, + data={"token": token, "owner_token": owner_token}, files=[("files", ("invited.png", b"invited-image", "image/png"))], ).json()["data"] + created = client.get( + f"/api/answer/me/submissions/{created['id']}?token={token}&owner_token={owner_token}" + ).json()["data"] question_id = created["questions"][0]["id"] assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 assert client.patch(f"/api/admin/answer-task-drafts/questions/{question_id}", json={"selected": True}).status_code == 200 - assert client.post(f"/api/admin/answer-task-drafts/{created['id']}/publish").status_code == 200 + assert client.post( + f"/api/admin/answer-task-drafts/{created['id']}/publish", + json={"shop_name": "测试店铺"}, + ).status_code == 200 completed = client.post( f"/api/answer/me/submissions/{created['id']}/completion", - data={"token": token}, + data={"token": token, "owner_token": owner_token}, files=[ ("files", ("done.png", b"done-image", "image/png")), ("payment_files", ("payment.png", b"payment-image", "image/png")), @@ -442,7 +927,7 @@ def test_approved_answer_appears_as_pending_refund_with_detail(): ) assert approved.status_code == 200 - board = client.get("/api/admin/task-board").json()["data"] + board = task_board_items(client, page_size=100) payout = next( item for item in board @@ -454,11 +939,14 @@ def test_approved_answer_appears_as_pending_refund_with_detail(): assert detail.status_code == 200 assert len(detail.json()["data"]["completion_screenshots"]) == 1 assert len(detail.json()["data"]["payment_screenshots"]) == 1 - refunded = client.post(payout["refund_endpoint"]) + refunded = client.post( + payout["refund_endpoint"], + files={"refund_file": ("refund.png", b"refund-image", "image/png")}, + ) assert refunded.status_code == 200 assert refunded.json()["data"]["status_label"] == "已完成" assert refunded.json()["data"]["refund_endpoint"] is None - board_after_refund = client.get("/api/admin/task-board").json()["data"] + board_after_refund = task_board_items(client, page_size=100) completed_payout = next( item for item in board_after_refund @@ -488,8 +976,8 @@ def test_public_ask_questions_are_unique_across_users_and_eventually_exhausted() sessions = [] for _ in range(3): - phone = f"138{uuid4().int % 100_000_000:08d}" - sessions.append(client.post("/api/ask/auth/phone-login", json={"phone": phone}).json()["data"]) + taobao_id = f"tb{uuid4().hex[:14]}" + sessions.append(client.post("/api/ask/auth/taobao-login", json={"taobao_id": taobao_id}).json()["data"]) first_url = f"/api/ask/tasks/{generated['task_id']}?token={sessions[0]['token']}" second_url = f"/api/ask/tasks/{generated['task_id']}?token={sessions[1]['token']}" @@ -512,3 +1000,131 @@ def test_public_ask_questions_are_unique_across_users_and_eventually_exhausted() exhausted = client.get(third_url) assert exhausted.status_code == 409 assert exhausted.json()["detail"] == "本次任务已被领取完,请关注下一个任务!" + + +def test_answer_submission_requires_owner_link_and_allows_at_most_two_images(): + with TestClient(app) as client: + missing_owner = client.post( + "/api/answer/auth/taobao-login", + json={"taobao_id": f"tb{uuid4().hex[:14]}"}, + ) + assert missing_owner.status_code == 400 + + owner_token = answer_owner_token(client) + login = client.post( + "/api/answer/auth/taobao-login", + json={"taobao_id": f"tb{uuid4().hex[:14]}", "owner_token": owner_token}, + ) + assert login.status_code == 200 + token = login.json()["data"]["token"] + too_many = client.post( + "/api/answer/me/submissions", + data={"token": token, "owner_token": owner_token}, + files=[ + ("files", (f"shot-{index}.png", b"image", "image/png")) + for index in range(3) + ], + ) + assert too_many.status_code == 400 + assert too_many.json()["detail"] == "一次最多上传2张截图" + + +def test_ask_batches_survive_reservation_timeout_and_parent_completes_only_after_all_paid(monkeypatch): + monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions) + with TestClient(app) as client: + assert client.post( + "/api/admin/auth/login", + json={"username": "operator", "password": "operator123456"}, + ).status_code == 200 + generated = client.post( + "/api/admin/ask-tasks/generate", + json={ + "shop_name": "分批保留测试店铺", + "product_link": "https://item.example.com/batch-history", + "product_name": "分批保留测试商品", + "question_count": 2, + "claim_questions_per_user": 2, + }, + ).json()["data"] + assert client.post(f"/api/admin/ask-tasks/{generated['id']}/publish").status_code == 200 + + session = client.post( + "/api/ask/auth/taobao-login", + json={"taobao_id": f"tb{uuid4().hex[:14]}"}, + ).json()["data"] + task_url = f"/api/ask/tasks/{generated['task_id']}?token={session['token']}" + assignment = client.get(task_url).json()["data"] + first_question_id = assignment["questions"][0]["id"] + assert client.post( + f"/api/ask/tasks/{generated['task_id']}/selection", + json={"token": session["token"], "question_ids": [first_question_id]}, + ).status_code == 200 + first_batch = client.post( + f"/api/ask/tasks/{generated['task_id']}/submission", + data={"token": session["token"], "payee_name": "测试用户"}, + files=[ + ("files", ("question-1.png", b"question-1", "image/png")), + ("payment_file", ("payment-1.png", b"payment-1", "image/png")), + ], + ).json()["data"] + assert client.post( + f"/api/admin/ask-batch-reviews/{first_batch['batch_id']}/decision", + json={"approved": True}, + ).status_code == 200 + assert client.post( + f"/api/admin/task-board/ask-batches/{first_batch['batch_id']}/confirm-refund", + files={"refund_file": ("refund-1.png", b"refund-1", "image/png")}, + ).status_code == 200 + + board = task_board_items(client, task_type="ask", page_size=100) + parent = next(item for item in board if item.get("record_kind") == "ask_task" and item["id"] == generated["id"]) + assert parent["status_label"] == "进行中" + assert parent["completed_count"] == 1 + + next_assignment = client.get(f"{task_url}&refresh=true").json()["data"] + assert next_assignment["status"] == "待选择" + with main_module.SessionLocal() as db: + stored = db.get(main_module.AskAssignment, next_assignment["assignment_id"]) + stored.reservation_started_at = datetime.utcnow() - timedelta(minutes=2) + db.commit() + expired = client.get(task_url) + assert expired.status_code == 409 + + with main_module.SessionLocal() as db: + stored = db.get(main_module.AskAssignment, next_assignment["assignment_id"]) + assert len(stored.batches) == 1 + assert stored.batches[0].status == "paid" + + final_assignment = client.get(f"{task_url}&refresh=true").json()["data"] + remaining_question_id = next( + item["id"] for item in final_assignment["questions"] if item["id"] != first_question_id + ) + assert client.post( + f"/api/ask/tasks/{generated['task_id']}/selection", + json={"token": session["token"], "question_ids": [remaining_question_id]}, + ).status_code == 200 + second_batch = client.post( + f"/api/ask/tasks/{generated['task_id']}/submission", + data={"token": session["token"], "payee_name": "测试用户"}, + files=[ + ("files", ("question-2.png", b"question-2", "image/png")), + ("payment_file", ("payment-2.png", b"payment-2", "image/png")), + ], + ).json()["data"] + assert client.post( + f"/api/admin/ask-batch-reviews/{second_batch['batch_id']}/decision", + json={"approved": True}, + ).status_code == 200 + assert client.post( + f"/api/admin/task-board/ask-batches/{second_batch['batch_id']}/confirm-refund", + files={"refund_file": ("refund-2.png", b"refund-2", "image/png")}, + ).status_code == 200 + + completed_board = task_board_items(client, task_type="ask", page_size=100) + completed_parent = next( + item for item in completed_board + if item.get("record_kind") == "ask_task" and item["id"] == generated["id"] + ) + assert completed_parent["status_label"] == "已完成" + assert completed_parent["completed_count"] == 2 + assert len(completed_parent["claimed_tasks"]) == 2 diff --git a/backend/tests/test_llm_ocr.py b/backend/tests/test_llm_ocr.py new file mode 100644 index 0000000..ab9fb0c --- /dev/null +++ b/backend/tests/test_llm_ocr.py @@ -0,0 +1,53 @@ +from collections import Counter +from io import BytesIO + +from PIL import Image + +from app import llm + + +def test_multi_image_ocr_retries_per_image_and_deduplicates(monkeypatch): + attempts = Counter() + + def recognize(image_data, content_type): + attempts[image_data] += 1 + if image_data == b"first" and attempts[image_data] == 1: + raise TimeoutError("temporary timeout") + if image_data == b"first": + return [ + {"product_title": "商品A", "question": "共同问题?"}, + {"product_title": "商品A", "question": "第一张的问题?"}, + ] + return [ + {"product_title": "商品A", "question": "共同问题?"}, + {"product_title": "商品A", "question": "第二张的问题?"}, + ] + + monkeypatch.setattr(llm.settings, "llm_api_key", "test-key") + monkeypatch.setattr(llm, "_compress_ocr_image", lambda data, content_type: (data, content_type)) + monkeypatch.setattr(llm, "_extract_questions_from_image", recognize) + monkeypatch.setattr(llm.time, "sleep", lambda seconds: None) + + result = llm.extract_questions_from_images([ + (b"first", "image/png"), + (b"second", "image/png"), + ]) + + assert attempts == Counter({b"first": 2, b"second": 1}) + assert [item["question"] for item in result] == ["共同问题?", "第一张的问题?", "第二张的问题?"] + assert [item["source_index"] for item in result] == [0, 0, 1] + + +def test_ocr_image_compression_limits_dimensions_and_converts_to_jpeg(): + image = Image.new("RGBA", (3200, 2600), (20, 100, 220, 180)) + source = BytesIO() + image.save(source, format="PNG") + + compressed, content_type = llm._compress_ocr_image(source.getvalue(), "image/png") + + with Image.open(BytesIO(compressed)) as result: + assert max(result.size) <= 2200 + assert result.mode == "RGB" + assert result.format == "JPEG" + assert content_type == "image/jpeg" + assert len(compressed) < len(source.getvalue()) diff --git a/frontend/src/AnswerUserPortal.jsx b/frontend/src/AnswerUserPortal.jsx index df49d5e..7e28b51 100644 --- a/frontend/src/AnswerUserPortal.jsx +++ b/frontend/src/AnswerUserPortal.jsx @@ -1,57 +1,58 @@ import React, { useEffect, useMemo, useRef, useState } from 'react' import { Check, ClipboardCheck, Clock3, Copy, FileCheck2, History, ImagePlus, LogOut, MessageCircle, RefreshCw, ShieldCheck } from 'lucide-react' -const SESSION_KEY = 'answer_user_session' -const STATUS_TEXT = { '待筛选': '截图审核中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '已完成': '已返款', '未通过': '审核不通过', '已撤销': '任务已撤销' } -const maskPhone = (phone = '') => phone.replace(/(\d{3})\d{4}(\d{4})/, '$1****$2') +const SESSION_KEY = 'answer_user_taobao_session' +const STATUS_TEXT = { '待筛选': '截图审核中', '识别失败': '运营处理中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '已完成': '已返款', '未通过': '审核不通过', '已撤销': '任务已撤销' } +const accountName = session => session?.taobao_id || session?.account || session?.user_id || '' const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--' const fileKey = file => `${file.name}-${file.size}-${file.lastModified}` function Top({ session, latest, logout }) { return <>
淘宝问答运营官方承接通道官方认证
ⓘ 规则说明
-
淘宝问答承接● {latest ? STATUS_TEXT[latest.status] || latest.status : '待认领'}

用户ID:{session.user_id} · {maskPhone(session.phone)}{latest?.status === '待用户完成' && <> 派发任务编号:{latest.submission_no}}

+
淘宝问答承接● {latest ? STATUS_TEXT[latest.status] || latest.status : '待认领'}

淘宝ID:{accountName(session)}{latest?.status === '待用户完成' && <> 派发任务编号:{latest.submission_no}}

} function Progress({ status }) { - const active = !status ? 1 : ['待筛选', '待运营派发', '已派发'].includes(status) ? 2 : ['待用户完成', '未通过'].includes(status) ? 3 : 4 + const active = !status ? 1 : ['待筛选', '识别失败', '待运营派发', '已派发'].includes(status) ? 2 : ['待用户完成', '未通过'].includes(status) ? 3 : 4 const labels = ['被邀截图', '任务派发', '完成任务', '终审确认'] return
{labels.map((label, index) => { const step = index + 1; const done = step < active || (active === 4 && ['已通过', '已完成'].includes(status)); return
{done ? : step}{label}
})}
} function Login({ api, onLogin, linkToken }) { - const [phone, setPhone] = useState(''); const [code, setCode] = useState(''); const [busy, setBusy] = useState(false); const [sending, setSending] = useState(false); const [cooldown, setCooldown] = useState(0); const [error, setError] = useState(''); const [devCode, setDevCode] = useState('') - useEffect(() => { if (!cooldown) return undefined; const timer = window.setInterval(() => setCooldown(value => Math.max(0, value - 1)), 1000); return () => window.clearInterval(timer) }, [cooldown]) - async function sendCode() { - if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码') - setSending(true); setError('') - try { const result = await api('/api/answer/auth/send-code', { method: 'POST', body: JSON.stringify({ phone, owner_token: linkToken || undefined }) }); setCooldown(result.data.retry_after || 60); setDevCode(result.data.dev_code || '') } - catch (err) { setError(err.message) } finally { setSending(false) } - } + const [taobaoId, setTaobaoId] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState('') async function submit(event) { event.preventDefault(); setError('') - if (!/^1\d{10}$/.test(phone)) return setError('请输入正确的 11 位手机号码') - if (!/^\d{6}$/.test(code)) return setError('请输入6位验证码') + if (!/^[A-Za-z0-9_.-]{3,64}$/.test(taobaoId.trim())) return setError('请输入正确的淘宝ID') setBusy(true) - try { const result = await api('/api/answer/auth/phone-login', { method: 'POST', body: JSON.stringify({ phone, code, owner_token: linkToken || undefined }) }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data) } + try { const result = await api('/api/answer/auth/taobao-login', { method: 'POST', body: JSON.stringify({ taobao_id: taobaoId.trim(), owner_token: linkToken || undefined }) }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data) } catch (err) { setError(err.message) } finally { setBusy(false) } } - return
淘宝问答运营官方承接通道官方认证

手机号登录

使用短信验证码安全登录

{devCode && 开发环境验证码:{devCode}}{error &&
{error}
}
+ return
淘宝问答运营官方承接通道官方认证

淘宝账号登录

输入淘宝ID即可进入任务中心

{error &&
{error}
}
} function UploadStage({ api, session, onUploaded, setError, linkToken }) { const [files, setFiles] = useState([]); const [busy, setBusy] = useState(false); const inputRef = useRef(null) const previews = useMemo(() => files.map(file => ({ file, url: URL.createObjectURL(file) })), [files]) useEffect(() => () => previews.forEach(item => URL.revokeObjectURL(item.url)), [previews]) - function addFiles(list) { const images = Array.from(list).filter(file => /^image\/(jpeg|png|webp)$/.test(file.type)); setFiles(current => [...current, ...images].filter((file, index, all) => all.findIndex(row => fileKey(row) === fileKey(file)) === index).slice(0, 10)) } + function addFiles(list) { + const images = Array.from(list).filter(file => /^image\/(jpeg|png|webp)$/.test(file.type)) + if (images.some(file => file.size > 10 * 1024 * 1024)) setError('单张图片不能超过10MB') + const valid = images.filter(file => file.size <= 10 * 1024 * 1024) + setFiles(current => { + const next = [...current, ...valid].filter((file, index, all) => all.findIndex(row => fileKey(row) === fileKey(file)) === index) + if (next.length > 2) setError('一次最多上传2张截图') + return next.slice(0, 2) + }) + } async function submit() { if (!files.length) return setError('请至少上传一张被邀请回答截图') setBusy(true); setError(''); const body = new FormData(); body.append('token', session.token); files.forEach(file => body.append('files', file)) if (linkToken) body.append('owner_token', linkToken) try { await api('/api/answer/me/submissions', { method: 'POST', body }); await onUploaded() } catch (err) { setError(err.message) } finally { setBusy(false) } } - return <>
✨ 任务认领与核验

上传被邀请回答截图

提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。

上传被邀请回答截图 截图 · 必传

用于确认您的账号可回答指定问题(支持上传多张)。

{files.length}/10
{previews.map(({ file, url }) =>
{file.name}/
{file.name}
)}{files.length < 10 && }
addFiles(event.target.files)}/>

▧ 请确保凭证清晰、信息完整、无遮挡

+ return <>
✨ 任务认领与核验

上传被邀请回答截图

提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。

上传被邀请回答截图 截图 · 必传

一次最多上传2张。为了提高问题识别的准确率,请每张图片少于十个问题。

{files.length}/2
{previews.map(({ file, url }) =>
{file.name}/
{file.name}
)}{files.length < 2 && }
addFiles(event.target.files)}/>

▧ 请确保凭证清晰、信息完整、无遮挡;单张不超过10MB

} function MaterialCard({ latest, completion = false }) { @@ -121,7 +122,7 @@ function FinalStage({ latest, reload, busy, reopen, startNew }) { } function HistoryPage({ records, close, onContinue }) { - const canContinue = status => ['待筛选', '待运营派发', '已派发', '待用户完成', '未通过'].includes(status) + const canContinue = status => ['待筛选', '识别失败', '待运营派发', '已派发', '待用户完成', '未通过'].includes(status) return

任务状态 / 上传历史({records.length})

{records.length === 0 ?
暂无历史记录
: records.map(record =>
{record.submission_no}{STATUS_TEXT[record.status] || record.status}

{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张

{record.invited_screenshots.slice(0, 4).map(image => { const src = image.url || image.data_url; return })}
{canContinue(record.status) && }
)}
} @@ -214,6 +215,6 @@ export default function AnswerUserPortalV2({ api, linkToken = '' }) { records.slice(0, 20).forEach(record => loadSubmissionImages(record)) }, [historyOpen, records.length]) if (!session) return - return
{!historyOpen && }
{error &&
{error}
}{!loaded ?
正在读取任务状态…
: historyOpen ? setHistoryOpen(false)} onContinue={record => { setActiveRecordId(record.id); setRetrying(record.status === '未通过'); setNewTaskMode(false); setHistoryOpen(false) }}/> : !visibleLatest ? { setNewTaskMode(false); setActiveRecordId(null); await load() }} setError={setError}/> : ['待筛选', '待运营派发', '已派发'].includes(visibleLatest.status) ? { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/> : visibleLatest.status === '待用户完成' || (visibleLatest.status === '未通过' && retrying) ? { if (submitted) { applySubmittedRecord(submitted); setRetrying(false); load(true); return } await load(); setRetrying(false) }} setError={setError}/> : setRetrying(true)} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/>}
{visibleLatest && }
+ return
{!historyOpen && }
{error &&
{error}
}{!loaded ?
正在读取任务状态…
: historyOpen ? setHistoryOpen(false)} onContinue={record => { setActiveRecordId(record.id); setRetrying(record.status === '未通过'); setNewTaskMode(false); setHistoryOpen(false) }}/> : !visibleLatest ? { setNewTaskMode(false); setActiveRecordId(null); await load() }} setError={setError}/> : ['待筛选', '识别失败', '待运营派发', '已派发'].includes(visibleLatest.status) ? { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/> : visibleLatest.status === '待用户完成' || (visibleLatest.status === '未通过' && retrying) ? { if (submitted) { applySubmittedRecord(submitted); setRetrying(false); load(true); return } await load(); setRetrying(false) }} setError={setError}/> : setRetrying(true)} startNew={() => { setError(''); setRetrying(false); setActiveRecordId(null); setHistoryOpen(false); setNewTaskMode(true); window.scrollTo({ top: 0, behavior: 'smooth' }) }}/>}
{visibleLatest && }
} diff --git a/frontend/src/AskUserPortal.jsx b/frontend/src/AskUserPortal.jsx index 97298d7..6616ad9 100644 --- a/frontend/src/AskUserPortal.jsx +++ b/frontend/src/AskUserPortal.jsx @@ -12,12 +12,12 @@ import { Trash2, } from "lucide-react"; -const SESSION_KEY = "ask_user_session"; +const SESSION_KEY = "ask_user_taobao_session"; const EXHAUSTED_MESSAGE = "本次任务已被领取完,请关注下一个任务!"; const INVALID_TASK_MESSAGE = "提问任务链接无效、未发布或已结束"; const fileKey = (file) => `${file.name}-${file.size}-${file.lastModified}`; -const maskPhone = (phone = "") => - phone.replace(/(\d{3})\d{4}(\d{4})/, "$1****$2"); +const accountName = (session) => + session?.taobao_id || session?.account || session?.user_id || ""; function getTaskNo() { const segments = window.location.pathname.split("/").filter(Boolean); @@ -28,41 +28,72 @@ function isInvalidTaskError(message = "") { return message.includes("提问任务链接无效") || message.includes("提问任务不存在"); } -function PhoneLogin({ api, onLogin }) { - const [phone, setPhone] = useState(""); - const [code, setCode] = useState(""); - const [busy, setBusy] = useState(false); - const [sending, setSending] = useState(false); - const [cooldown, setCooldown] = useState(0); - const [error, setError] = useState(""); - const [devCode, setDevCode] = useState(""); +function isExpiredAssignmentError(message = "") { + return message.includes("提问任务已失效") || message.includes("任务已失效"); +} + +function formatCountdown(seconds) { + const safe = Math.max(0, Number(seconds) || 0); + const minutes = Math.floor(safe / 60).toString().padStart(2, "0"); + const remainder = Math.floor(safe % 60).toString().padStart(2, "0"); + return `${minutes}:${remainder}`; +} + +function parseServerDate(value) { + if (!value) return null; + const text = String(value); + // API datetime values are stored as UTC but older responses omit the + // timezone suffix. Explicitly mark those values as UTC before parsing in + // the browser, otherwise China Standard Time makes them appear eight hours + // older and the countdown expires immediately. + return new Date(/[zZ]|[+-]\d{2}:?\d{2}$/.test(text) ? text : `${text}Z`); +} + +function AssignmentCountdown({ expiresAt, onExpired }) { + const [remaining, setRemaining] = useState(() => + expiresAt ? Math.max(0, (parseServerDate(expiresAt)?.getTime() - Date.now()) / 1000) : 0, + ); + const expiredRef = useRef(false); + const onExpiredRef = useRef(onExpired); useEffect(() => { - if (!cooldown) return undefined; - const timer = window.setInterval(() => setCooldown((value) => Math.max(0, value - 1)), 1000); + onExpiredRef.current = onExpired; + }, [onExpired]); + useEffect(() => { + if (!expiresAt) return undefined; + expiredRef.current = false; + const update = () => { + const next = Math.max(0, (parseServerDate(expiresAt)?.getTime() - Date.now()) / 1000); + setRemaining(next); + if (next <= 0 && !expiredRef.current) { + expiredRef.current = true; + onExpiredRef.current?.(); + } + }; + update(); + const timer = window.setInterval(update, 1000); return () => window.clearInterval(timer); - }, [cooldown]); - async function sendCode() { - if (!/^1\d{10}$/.test(phone)) return setError("请输入正确的 11 位手机号码"); - setSending(true); setError(""); - try { - const result = await api("/api/ask/auth/send-code", { method: "POST", body: JSON.stringify({ phone }) }); - setCooldown(result.data.retry_after || 60); setDevCode(result.data.dev_code || ""); - } catch (requestError) { setError(requestError.message); } finally { setSending(false); } - } + }, [expiresAt]); + if (!expiresAt) return null; + return 剩余 {formatCountdown(remaining)}; +} + +function TaobaoLogin({ api, onLogin }) { + const [taobaoId, setTaobaoId] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(""); async function submit(event) { event.preventDefault(); - if (!/^1\d{10}$/.test(phone)) { - setError("请输入正确的 11 位手机号码"); + if (!/^[A-Za-z0-9_.-]{3,64}$/.test(taobaoId.trim())) { + setError("请输入正确的淘宝ID"); return; } - if (!/^\d{6}$/.test(code)) { setError("请输入6位验证码"); return; } setBusy(true); setError(""); try { - const result = await api("/api/ask/auth/phone-login", { + const result = await api("/api/ask/auth/taobao-login", { method: "POST", - body: JSON.stringify({ phone, code }), + body: JSON.stringify({ taobao_id: taobaoId.trim() }), }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data); @@ -87,23 +118,20 @@ function PhoneLogin({ api, onLogin }) { -

手机号登录

-

使用短信验证码安全登录

+

淘宝账号登录

+

输入淘宝ID即可领取提问任务

- - {devCode && 开发环境验证码:{devCode}} {error &&
{error}
} + + + + ); +} + function Header({ session, assignment, @@ -186,9 +235,9 @@ function Header({
问
问大家提问用户 - 手机已绑定 + 淘宝账号已登录 - ID:{session.user_id} · {maskPhone(session.phone)} + 淘宝ID:{accountName(session)}
@@ -332,6 +391,7 @@ function ConfirmStage({ taskNo, onSubmitted, setError, + onExpired, }) { const selectedQuestions = assignment.questions.filter( (item) => item.selected, @@ -395,7 +455,8 @@ function ConfirmStage({ onSubmitted(result.data); } catch (requestError) { setReviewOpen(false); - setError(requestError.message); + if (isExpiredAssignmentError(requestError.message)) onExpired(); + else setError(requestError.message); } finally { setBusy(false); } @@ -503,6 +564,10 @@ function ConfirmStage({ onChange={(event) => addFiles(event.target.files)} /> +
+ + 请在有效期内完成上传并提交 +
@@ -798,24 +863,28 @@ export default function AskUserPortal({ api }) { const [claimEdit, setClaimEdit] = useState(false); const [exhausted, setExhausted] = useState(false); const [invalidTask, setInvalidTask] = useState(""); + const [expiredAssignment, setExpiredAssignment] = useState(false); + const [selectionExpired, setSelectionExpired] = useState(false); - async function loadAssignment(silent = false) { + async function loadAssignment(silent = false, refresh = false) { if (!session) return; const previousAssignmentId = assignment?.assignment_id; if (!silent) setBusy(true); try { const result = await api( - `/api/ask/tasks/${encodeURIComponent(taskNo)}?token=${encodeURIComponent(session.token)}`, + `/api/ask/tasks/${encodeURIComponent(taskNo)}?token=${encodeURIComponent(session.token)}${refresh ? "&refresh=true" : ""}`, ); setAssignment(result.data); setExhausted(false); setInvalidTask(""); + setExpiredAssignment(false); + setSelectionExpired(false); // Keep the current view. In particular, polling a pending review must // not pull the user back to the audit screen after they chose to return // to the claim page. // Keep unsaved checkbox choices while a user is on the claim screen. - // If the five-hour reservation expires, the assignment id changes and - // the fresh allocation is selected instead. + // If an expired reservation is refreshed, the fresh allocation is + // selected instead of restoring the stale checkbox choices. if (!silent || result.data.assignment_id !== previousAssignmentId || assignment?.status !== "待选择") { setSelected( result.data.questions @@ -836,6 +905,11 @@ export default function AskUserPortal({ api }) { setAssignment(null); setInvalidTask(requestError.message); setError(""); + } else if (isExpiredAssignmentError(requestError.message)) { + setAssignment(null); + setSelected([]); + setExpiredAssignment(true); + setError(""); } else { setError(requestError.message); } @@ -858,8 +932,8 @@ export default function AskUserPortal({ api }) { loadAssignment(); }, [session?.token, taskNo]); useEffect(() => { - if (!assignment || !["待审核", "待选择", "进行中"].includes(assignment.status)) return undefined; - const interval = assignment.status === "待审核" ? 10000 : 60000; + if (!assignment || assignment.status !== "待审核") return undefined; + const interval = 10000; const timer = window.setInterval(() => loadAssignment(true), interval); return () => window.clearInterval(timer); }, [assignment?.status, session?.token, taskNo]); @@ -870,12 +944,56 @@ export default function AskUserPortal({ api }) { setAssignment(null); setExhausted(false); setInvalidTask(""); + setExpiredAssignment(false); + setSelectionExpired(false); }; - if (!session) return ; + if (!session) return ; if (exhausted) return ; if (invalidTask) return loadAssignment()} />; + if (expiredAssignment) + return ; + async function releaseAssignment() { + try { + await api(`/api/ask/tasks/${encodeURIComponent(taskNo)}/release`, { + method: "POST", + body: JSON.stringify({ token: session.token }), + }); + } catch { + // The server may already have released the reservation during its + // expiry sweep; a subsequent refresh remains safe in that case. + } + } + function expireSelection() { + if (selectionExpired) return; + void releaseAssignment(); + setSelectionExpired(true); + setSelected([]); + setError(""); + } + function expireUpload() { + if (expiredAssignment) return; + void releaseAssignment(); + setExpiredAssignment(true); + setAssignment(null); + setSelected([]); + setError(""); + } + async function refreshAssignment() { + await releaseAssignment(); + setSelectionExpired(false); + setExpiredAssignment(false); + setAssignment(null); + await loadAssignment(false, true); + } + async function startNextSelection() { + setView("claim"); + setSelected([]); + setNotice(""); + await loadAssignment(false, true); + setClaimEdit(true); + } async function confirm() { if (!selected.length) return; setBusy(true); @@ -914,17 +1032,13 @@ export default function AskUserPortal({ api }) { view={view} onOpenHistory={loadHistory} onOpenClaim={() => { - setView("claim"); - // If a previous batch has completed and questions remain, opening - // the claim tab should show the next selectable batch directly. - setClaimEdit( - Boolean( - assignment && - (assignment.remaining_count ?? 0) > 0 && - assignment.status === "已通过", - ), - ); - setNotice(""); + if (assignment && (assignment.remaining_count ?? 0) > 0 && ["已通过", "已完成"].includes(assignment.status)) { + void startNextSelection(); + } else { + setView("claim"); + setClaimEdit(false); + setNotice(""); + } }} /> {error &&
{error}
} @@ -953,6 +1067,9 @@ export default function AskUserPortal({ api }) { setSelected={setSelected} onConfirm={confirm} busy={busy} + onExpired={expireSelection} + expired={selectionExpired} + onRefresh={refreshAssignment} /> ) : ["待审核", "已通过", "已完成"].includes(assignment.status) ? ( @@ -988,6 +1101,7 @@ export default function AskUserPortal({ api }) { taskNo={taskNo} onSubmitted={setAssignment} setError={setError} + onExpired={expireUpload} /> )}
diff --git a/frontend/src/CreateAnswerTasksPage.jsx b/frontend/src/CreateAnswerTasksPage.jsx index 79e6923..356ecf5 100644 --- a/frontend/src/CreateAnswerTasksPage.jsx +++ b/frontend/src/CreateAnswerTasksPage.jsx @@ -1,4 +1,4 @@ -import React, { useEffect, useMemo, useState } from 'react' +import React, { useEffect, useMemo, useRef, useState } from 'react' export default function CreateAnswerTasksPageV2({ api, user }) { const [records, setRecords] = useState([]) @@ -10,11 +10,25 @@ export default function CreateAnswerTasksPageV2({ api, user }) { const [shopNames, setShopNames] = useState({}) const [answerLink, setAnswerLink] = useState('') const [linkCopied, setLinkCopied] = useState(false) + const editingAnswersRef = useRef(new Set()) + const editedShopNamesRef = useRef(new Set()) + const shopMemoryKey = `answer_last_shop_name_${user?.id || 'current'}` + const lastShopNameRef = useRef(window.localStorage.getItem(shopMemoryKey) || '') useEffect(() => { let active = true api('/api/admin/auth/answer-link').then(result => { - if (active && result.data?.path) setAnswerLink(new URL(result.data.path, window.location.origin).toString()) + if (!active) return + if (result.data?.path) setAnswerLink(new URL(result.data.path, window.location.origin).toString()) + const remembered = String(result.data?.last_shop_name || '').trim() + if (remembered) { + lastShopNameRef.current = remembered + window.localStorage.setItem(shopMemoryKey, remembered) + setShopNames(current => Object.fromEntries(Object.entries(current).map(([recordId, value]) => [ + recordId, + !value && !editedShopNamesRef.current.has(Number(recordId)) ? remembered : value, + ]))) + } }).catch(() => {}) return () => { active = false } }, [api, user?.id]) @@ -41,6 +55,11 @@ export default function CreateAnswerTasksPageV2({ api, user }) { ...record, questions: record.questions.map(question => ({ ...question, + // Keep an in-progress textarea edit while the 5-second polling + // request is returning the previous server value. + answer: editingAnswersRef.current.has(question.id) + ? (previousQuestions.get(question.id)?.answer ?? question.answer) + : question.answer, product_image: question.product_image || previousQuestions.get(question.id)?.product_image || null, source_image: question.source_image || previousQuestions.get(question.id)?.source_image || null, operator_images: question.operator_images?.length ? question.operator_images : (previousQuestions.get(question.id)?.operator_images || []), @@ -50,7 +69,7 @@ export default function CreateAnswerTasksPageV2({ api, user }) { setShopNames(current => { const next = { ...current } result.data.forEach(record => { - if (next[record.id] === undefined) next[record.id] = record.shop_name || '' + if (next[record.id] === undefined) next[record.id] = record.shop_name || lastShopNameRef.current || '' }) return next }) @@ -79,7 +98,19 @@ export default function CreateAnswerTasksPageV2({ api, user }) { setDetailBusy(record.id) try { const result = await api(`/api/admin/answer-task-drafts/${record.id}`) - setRecords(current => current.map(item => item.id === record.id ? result.data : item)) + setRecords(current => current.map(item => { + if (item.id !== record.id) return item + const currentQuestions = new Map(item.questions.map(question => [question.id, question])) + return { + ...result.data, + questions: result.data.questions.map(question => ({ + ...question, + answer: editingAnswersRef.current.has(question.id) + ? (currentQuestions.get(question.id)?.answer ?? question.answer) + : question.answer, + })), + } + })) } catch (err) { setError(err.message) } finally { @@ -110,8 +141,10 @@ export default function CreateAnswerTasksPageV2({ api, user }) { method: 'PATCH', body: JSON.stringify(patch), }) setQuestionLocal(question.id, { answer: result.data.answer, selected: result.data.selected }) + if (Object.prototype.hasOwnProperty.call(patch, 'answer')) editingAnswersRef.current.delete(question.id) } catch (err) { setQuestionLocal(question.id, previous) + if (Object.prototype.hasOwnProperty.call(patch, 'answer')) editingAnswersRef.current.delete(question.id) setError(err.message) } finally { setBusy('') @@ -151,6 +184,12 @@ export default function CreateAnswerTasksPageV2({ api, user }) { method: 'POST', body: JSON.stringify({ shop_name: shopName }), }) + lastShopNameRef.current = shopName + window.localStorage.setItem(shopMemoryKey, shopName) + setShopNames(current => Object.fromEntries(Object.entries(current).map(([recordId, value]) => [ + recordId, + !value && !editedShopNamesRef.current.has(Number(recordId)) ? shopName : value, + ]))) await load() } catch (err) { setError(err.message) @@ -159,6 +198,16 @@ export default function CreateAnswerTasksPageV2({ api, user }) { } } + function updateShopName(recordId, value) { + editedShopNamesRef.current.add(recordId) + setShopNames(current => ({ ...current, [recordId]: value })) + const remembered = value.trim() + if (remembered) { + lastShopNameRef.current = remembered + window.localStorage.setItem(shopMemoryKey, remembered) + } + } + async function reject(record) { if (!record || busy) return setBusy(record.id) @@ -174,6 +223,20 @@ export default function CreateAnswerTasksPageV2({ api, user }) { } } + async function retryRecognition(record) { + if (!record || busy) return + setBusy(record.id) + setError('') + try { + const result = await api(`/api/admin/answer-task-drafts/${record.id}/retry-recognition`, { method: 'POST' }) + setRecords(current => current.map(item => item.id === record.id ? result.data : item)) + } catch (err) { + setError(err.message) + } finally { + setBusy('') + } + } + async function uploadOperatorImage(question, event) { const files = Array.from(event.target.files || []) event.target.value = '' @@ -212,16 +275,18 @@ export default function CreateAnswerTasksPageV2({ api, user }) { const selected = record.questions.filter(question => question.selected).length const allSelected = record.questions.length > 0 && selected === record.questions.length const identifying = record.questions.length === 0 && ['待筛选', '待运营派发'].includes(record.status) + const recognitionFailed = record.status === '识别失败' || record.recognition_failed const open = expanded[record.id] return
-
{record.user_id}用户_{record.user_id.slice(1)}{record.submission_no}已选 {selected}/{record.questions.length} 题
- +
淘宝ID:{record.user_id}{record.submission_no}已选 {selected}/{record.questions.length} 题
+ {identifying && 正在识别,请等待…} - - + {recognitionFailed && 识别失败,请重新识别} + +
{open && record.questions.map(question =>
@@ -230,10 +295,10 @@ export default function CreateAnswerTasksPageV2({ api, user }) {

Q{question.sequence_no}:{question.question}

{question.product_image && 商品主图} - {question.product_title}来源:模拟OCR识别 + {question.product_title}来源:图片OCR识别 {question.source_image &&
识图原始截图:识别原始截图
}
-