{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张
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 <>
用户ID:{session.user_id} · {maskPhone(session.phone)}{latest?.status === '待用户完成' && <> 派发任务编号:{latest.submission_no}>} 淘宝ID:{accountName(session)}{latest?.status === '待用户完成' && <> 派发任务编号:{latest.submission_no}>}
提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。
用于确认您的账号可回答指定问题(支持上传多张)。
▧ 请确保凭证清晰、信息完整、无遮挡
提交后会立即进入任务派发审核界面,截图识别和问题整理将在后台继续处理。
一次最多上传2张。为了提高问题识别的准确率,请每张图片少于十个问题。
▧ 请确保凭证清晰、信息完整、无遮挡;单张不超过10MB
{formatTime(record.created_at)} · 识别问题 {record.questions.length} 条 · 截图 {record.invited_screenshots.length} 张
使用短信验证码安全登录
+输入淘宝ID即可领取提问任务
- - {devCode && 开发环境验证码:{devCode}} {error &&