{question.question}
{question.answer}
diff --git a/backend/app/llm.py b/backend/app/llm.py
index fe06059..a6d660a 100644
--- a/backend/app/llm.py
+++ b/backend/app/llm.py
@@ -21,10 +21,10 @@ def _prompt(payload: QAGenerateRequest) -> str:
商品品类:{payload.category or '未提供'}
商品功效:{payload.benefits or '未提供'}
商品卖点:{payload.selling_points or '未提供'}
-语言风格:{payload.language_style}
-目标人群:{payload.target_group}
-使用场景:{payload.usage_scene}
-购买渠道:{payload.purchase_channel}
+语言风格:{payload.language_style or '客观自然'}
+目标人群:{payload.target_group or '普通消费者'}
+使用场景:{payload.usage_scene or '日常使用'}
+购买渠道:{payload.purchase_channel or '电商平台'}
回答要求:{payload.answer_requirement or '直接回应问题,避免夸大功效'}
回答语气:{style}
真实问答参考:{payload.reference_qa or '未提供'}
@@ -65,7 +65,7 @@ def _parse_content(content: str, expected_answers: int) -> list[dict]:
return items
-def generate_qa(payload: QAGenerateRequest) -> list[dict]:
+def _generate_qa_batch(payload: QAGenerateRequest) -> list[dict]:
if not settings.llm_api_key:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置大模型 API Key,请先设置 LLM_API_KEY")
url = settings.llm_base_url.rstrip("/") + "/chat/completions"
@@ -88,7 +88,33 @@ def generate_qa(payload: QAGenerateRequest) -> list[dict]:
return _parse_content(content, payload.answers_per_question)
-def generate_questions(payload: dict, count: int, reference_qa: str = "") -> list[str]:
+def generate_qa(payload: QAGenerateRequest) -> list[dict]:
+ """Generate the requested amount in small batches so large requests are not truncated."""
+ target = payload.question_count
+ items: list[dict] = []
+ seen: set[str] = set()
+ attempts = 0
+ max_attempts = max((target + 19) // 20 + 2, 3)
+ while len(items) < target and attempts < max_attempts:
+ batch_size = min(20, target - len(items))
+ batch_payload = payload.model_copy(update={"question_count": batch_size})
+ for item in _generate_qa_batch(batch_payload):
+ key = item["question"].strip()
+ if key in seen:
+ continue
+ seen.add(key)
+ items.append(item)
+ if len(items) >= target:
+ break
+ attempts += 1
+ if len(items) < target:
+ logger.warning("LLM returned %s/%s unique QA items", len(items), target)
+ for index, item in enumerate(items[:target], start=1):
+ item["id"] = f"Q{index:03d}"
+ return items[:target]
+
+
+def _generate_questions_batch(payload: dict, count: int, reference_qa: str = "") -> list[str]:
"""Generate only consumer questions for the ask-task workflow."""
if not settings.llm_api_key:
raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="尚未配置大模型 API Key,无法生成真实提问")
@@ -97,10 +123,10 @@ def generate_questions(payload: dict, count: int, reference_qa: str = "") -> lis
商品品类:{payload.get('category') or '未提供'}
商品功效:{payload.get('benefits') or '未提供'}
商品卖点:{payload.get('selling_points') or '未提供'}
-语言风格:{payload.get('language_style')}
-目标人群:{payload.get('target_group')}
-使用场景:{payload.get('usage_scene')}
-购买渠道:{payload.get('purchase_channel')}
+语言风格:{payload.get('language_style') or '客观自然'}
+目标人群:{payload.get('target_group') or '普通消费者'}
+使用场景:{payload.get('usage_scene') or '日常使用'}
+购买渠道:{payload.get('purchase_channel') or '电商平台'}
真实问答参考:{reference_qa or '未提供'}
只返回严格 JSON,不要 Markdown 或解释,格式:{{"questions":["问题1","问题2"]}}。问题应像真实买家提问,尽量不超过30字,不要虚构具体参数或功效。"""
url = settings.llm_base_url.rstrip("/") + "/chat/completions"
@@ -131,6 +157,27 @@ def generate_questions(payload: dict, count: int, reference_qa: str = "") -> lis
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="大模型提问生成失败,请稍后重试") from exc
+def generate_questions(payload: dict, count: int, reference_qa: str = "") -> list[str]:
+ """Generate questions in batches and merge them until the requested count is reached."""
+ target = int(count)
+ output: list[str] = []
+ seen: set[str] = set()
+ attempts = 0
+ max_attempts = max((target + 19) // 20 + 2, 3)
+ while len(output) < target and attempts < max_attempts:
+ batch = _generate_questions_batch(payload, min(20, target - len(output)), reference_qa)
+ for question in batch:
+ if question not in seen:
+ seen.add(question)
+ output.append(question)
+ if len(output) >= target:
+ break
+ attempts += 1
+ if len(output) < target:
+ logger.warning("LLM returned %s/%s unique questions", len(output), target)
+ return output[:target]
+
+
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."""
if not settings.llm_api_key:
diff --git a/backend/app/main.py b/backend/app/main.py
index a4bd437..ead4cf2 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -16,7 +16,7 @@ import xml.etree.ElementTree as ET
from fastapi import BackgroundTasks, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile, status
from fastapi.middleware.cors import CORSMiddleware
-from sqlalchemy import func, or_, select
+from sqlalchemy import func, inspect, or_, select, text
from sqlalchemy.orm import Session, noload, selectinload
from .config import settings
@@ -85,15 +85,8 @@ def _product_intent_error(values: list[object]) -> str | None:
def _generation_options_error(payload: dict, include_answers: bool = True) -> str | None:
- required = [
- ("language_style", "语言风格"),
- ("target_group", "目标人群"),
- ("question_count", "提问生成条数"),
- ("usage_scene", "使用场景"),
- ("purchase_channel", "购买渠道"),
- ]
- if include_answers:
- required.insert(3, ("answers_per_question", "单题回答条数"))
+ # 语言风格、目标人群、场景等生成偏好均可留空,仅限制生成条数必填。
+ required = [("question_count", "提问生成条数")]
missing = [label for key, label in required if not str(payload.get(key, "")).strip()]
if missing:
return f"请填写{'、'.join(missing)}"
@@ -103,13 +96,6 @@ def _generation_options_error(payload: dict, include_answers: bool = True) -> st
return "提问生成条数必须是大于 0 的整数"
if question_count < 1:
return "提问生成条数必须是大于 0 的整数"
- if include_answers:
- try:
- answers_count = int(payload.get("answers_per_question"))
- except (TypeError, ValueError):
- return "单题回答条数必须是大于 0 的整数"
- if answers_count < 1:
- return "单题回答条数必须是大于 0 的整数"
return None
@@ -171,6 +157,21 @@ def seed_users(db: Session):
@app.on_event("startup")
def startup():
Base.metadata.create_all(bind=engine)
+ # Add fields introduced after the initial SQLite database was created.
+ columns = {column["name"] for column in inspect(engine).get_columns("ask_tasks")}
+ if "claim_questions_per_user" not in columns:
+ with engine.begin() as connection:
+ connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN claim_questions_per_user INTEGER NOT NULL DEFAULT 5"))
+ if "owner_id" not in columns:
+ with engine.begin() as connection:
+ connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN owner_id INTEGER NULL"))
+ question_columns = {column["name"] for column in inspect(engine).get_columns("answer_recognized_questions")}
+ if "operator_image_screenshot_id" not in question_columns:
+ with engine.begin() as connection:
+ connection.execute(text("ALTER TABLE answer_recognized_questions ADD COLUMN operator_image_screenshot_id INTEGER NULL"))
+ if "operator_image_screenshot_ids" not in question_columns:
+ with engine.begin() as connection:
+ connection.execute(text("ALTER TABLE answer_recognized_questions ADD COLUMN operator_image_screenshot_ids TEXT NULL"))
db = next(get_db())
try:
seed_users(db)
@@ -353,6 +354,20 @@ def _dingtalk_login(code: str, db: Session, response: Response) -> AdminUser:
return user
+def _can_access_ask_task(task: AskTask, user: AdminUser) -> bool:
+ return user.role_code == "super_admin" or task.owner_id == user.id
+
+
+def _require_ask_task_access(task: AskTask | None, user: AdminUser) -> AskTask:
+ if not task or not _can_access_ask_task(task, user):
+ raise HTTPException(status_code=404, detail="提问任务不存在")
+ return task
+
+
+def _ask_task_owner_clause(user: AdminUser):
+ return True if user.role_code == "super_admin" else AskTask.owner_id == user.id
+
+
def _published_ask_task(task_no: str, db: Session, *, lock: bool = False) -> AskTask:
if task_no == "latest":
statement = select(AskTask).where(AskTask.status == "已发布").order_by(AskTask.created_at.desc())
@@ -521,6 +536,10 @@ def _submission_data(record: AnswerSubmission, include_images: bool = True) -> d
"id": q.id, "sequence_no": q.sequence_no, "product_title": q.product_title,
"product_image": _image_url(images[q.product_image_screenshot_id]) if include_images and q.product_image_screenshot_id in images else None,
"source_image": _image_url(images[q.source_screenshot_id]) if include_images and q.source_screenshot_id in images else None,
+ "operator_images": [
+ _image_url(images[int(image_id)]) for image_id in ((q.operator_image_screenshot_ids or str(q.operator_image_screenshot_id or "")).split(","))
+ if image_id.strip().isdigit() and int(image_id) in images
+ ] if include_images else [],
"question": q.question_text, "answer": q.answer_text, "answer_status": q.answer_status, "selected": q.selected,
} for q in sorted(record.questions, key=lambda value: value.sequence_no)],
}
@@ -570,25 +589,106 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> d
}
+def _ask_assignment_child_data(assignment: AskAssignment) -> dict:
+ """Return a compact user-claim row nested under its system ask task."""
+ task = assignment.task
+ selected_ids = {
+ item.question_id for item in assignment.items if item.selected
+ }
+ used_ids = {item.question_id for item in assignment.used_questions}
+ latest_batch = max(assignment.batches, key=lambda value: value.updated_at or value.created_at, default=None)
+ batch_selected_ids = set()
+ if latest_batch and latest_batch.selected_question_ids:
+ batch_selected_ids = {
+ int(value) for value in latest_batch.selected_question_ids.split(",") if value.strip().isdigit()
+ }
+ question_count = len(batch_selected_ids or selected_ids or used_ids)
+ refund_completed = bool(latest_batch and latest_batch.status == "paid") or assignment.status == "已完成"
+ refund_pending = bool(latest_batch and latest_batch.status == "approved") or assignment.status == "已通过"
+ if refund_completed:
+ status_label = "已完成"
+ elif refund_pending:
+ status_label = "待返款"
+ elif assignment.status == "待审核" or (latest_batch and latest_batch.status == "pending"):
+ status_label = "待审核"
+ elif assignment.status == "已撤销":
+ status_label = "已撤销"
+ else:
+ status_label = "进行中"
+ detail_endpoint = None
+ refund_endpoint = None
+ if latest_batch and latest_batch.status in {"approved", "paid"}:
+ detail_endpoint = f"/api/admin/task-board/ask-batches/{latest_batch.id}"
+ if not refund_completed:
+ refund_endpoint = f"/api/admin/task-board/ask-batches/{latest_batch.id}/confirm-refund"
+ elif assignment.status in {"已通过", "已完成"} and not assignment.batches:
+ detail_endpoint = f"/api/admin/task-board/ask-assignments/{assignment.id}"
+ if not refund_completed:
+ refund_endpoint = f"/api/admin/task-board/ask-assignments/{assignment.id}/confirm-refund"
+ return {
+ "id": assignment.id,
+ "board_key": f"ask-assignment-{assignment.id}",
+ "record_kind": "ask_assignment",
+ "is_child": True,
+ "task_id": task.task_no,
+ "task_type": "ask",
+ "task_type_label": "提问任务",
+ "product_name": task.product_name,
+ "product_link": task.product_link,
+ "status": assignment.status,
+ "status_label": status_label,
+ "user_id": assignment.user.public_id,
+ "phone": assignment.user.phone,
+ "qa_count": question_count,
+ "claimed_count": question_count,
+ "submitted_count": len(used_ids),
+ "pending_review_count": 1 if status_label == "待审核" else 0,
+ "completed_count": len(used_ids) if used_ids else (question_count if refund_completed else 0),
+ "total_count": question_count,
+ "creator_name": task.owner.display_name if task.owner else "运营后台",
+ "created_at": assignment.created_at.isoformat(),
+ "updated_at": assignment.updated_at.isoformat(),
+ "can_cancel": False,
+ "can_complete": False,
+ "can_delete": False,
+ "detail_endpoint": detail_endpoint,
+ "refund_endpoint": refund_endpoint,
+ "assigned_users": [{"user_id": assignment.user.public_id, "phone": assignment.user.phone, "status": status_label}],
+ "selected_questions": [],
+ "question_screenshots": [],
+ "completion_screenshots": [],
+ "payment_screenshots": [],
+ "latest_review": None,
+ }
+
+
def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict:
questions = sorted(task.questions, key=lambda value: value.sequence_no)
selected_questions = [q for q in questions if q.selected]
+ claimed_tasks = sorted(
+ (_ask_assignment_child_data(assignment) for assignment in task.assignments),
+ key=lambda value: value.get("updated_at") or value.get("created_at") or "",
+ reverse=True,
+ )
assigned_count = len(task.assignments)
completed_count = sum(len(assignment.used_questions) for assignment in task.assignments)
- pending_count = sum(1 for assignment in task.assignments if assignment.status in {"待审核", "未通过"})
+ pending_count = sum(child["pending_review_count"] for child in claimed_tasks)
return {
"id": task.id, "board_key": f"ask-task-{task.id}", "record_kind": "ask_task",
"task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务",
"product_name": task.product_name, "product_link": task.product_link, "status": task.status,
+ "claim_questions_per_user": task.claim_questions_per_user or 5,
"status_label": {"已发布": "进行中", "已撤销": "已撤销", "已完成": "已完成"}.get(task.status, task.status),
"qa_count": len(selected_questions), "claimed_count": assigned_count, "submitted_count": completed_count,
"pending_review_count": pending_count, "completed_count": completed_count, "total_count": len(selected_questions),
- "creator_name": "运营后台", "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(),
+ "creator_name": task.owner.display_name if task.owner else "运营后台", "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(),
"can_cancel": task.status == "已发布", "can_complete": task.status == "已发布", "can_delete": True,
"delete_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}",
"user_id": "--", "phone": "", "questions": [],
"selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": "", "product_title": task.product_name} for q in selected_questions] if include_questions else []),
- "assigned_users": [], "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None,
+ "assigned_users": [child["assigned_users"][0] for child in claimed_tasks if child.get("assigned_users")],
+ "claimed_tasks": claimed_tasks,
+ "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None,
"detail_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}" if not include_questions else None,
}
@@ -739,7 +839,8 @@ def public_ask_task(task_no: str, token: str, db: Session = Depends(get_db)):
status_code=409,
detail="本次任务已被领取完,请关注下一个任务!",
)
- assigned = random.sample(available, min(5, len(available)))
+ claim_limit = task.claim_questions_per_user or 5
+ assigned = random.sample(available, min(claim_limit, len(available)))
assignment = AskAssignment(task_id=task.id, user_id=user.id, status="待选择")
db.add(assignment); db.flush()
for question in assigned:
@@ -856,17 +957,19 @@ async def submit_public_ask_task(
@app.get("/api/admin/ask-completion-reviews")
-def ask_completion_reviews(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def ask_completion_reviews(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
batches = db.scalars(
- select(AskAssignmentBatch)
+ select(AskAssignmentBatch).join(AskAssignmentBatch.assignment).join(AskAssignment.task)
.where(AskAssignmentBatch.status == "pending")
+ .where(_ask_task_owner_clause(user))
.order_by(AskAssignmentBatch.updated_at.desc())
).unique().all()
legacy = db.scalars(
- select(AskAssignment).where(
+ select(AskAssignment).join(AskAssignment.task).where(
AskAssignment.status == "待审核",
~AskAssignment.batches.any(),
AskAssignment.screenshots.any(),
+ _ask_task_owner_clause(user),
).order_by(AskAssignment.updated_at.desc())
).unique().all()
data = [_ask_batch_data(batch, include_images=False) for batch in batches]
@@ -875,19 +978,20 @@ def ask_completion_reviews(_: AdminUser = Depends(current_user), db: Session = D
@app.get("/api/admin/review-pending-count")
-def review_pending_count(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def review_pending_count(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
"""Return lightweight sidebar counts without loading review rows or images."""
answer_count = db.scalar(
select(func.count(AnswerSubmission.id)).where(AnswerSubmission.status == "待审核")
) or 0
ask_batch_count = db.scalar(
- select(func.count(AskAssignmentBatch.id)).where(AskAssignmentBatch.status == "pending")
+ select(func.count(AskAssignmentBatch.id)).join(AskAssignmentBatch.assignment).join(AskAssignment.task).where(AskAssignmentBatch.status == "pending", _ask_task_owner_clause(user))
) or 0
legacy_ask_count = db.scalar(
- select(func.count(AskAssignment.id)).where(
+ select(func.count(AskAssignment.id)).join(AskAssignment.task).where(
AskAssignment.status == "待审核",
~AskAssignment.batches.any(),
AskAssignment.screenshots.any(),
+ _ask_task_owner_clause(user),
)
) or 0
ask_count = ask_batch_count + legacy_ask_count
@@ -902,20 +1006,22 @@ def review_pending_count(_: AdminUser = Depends(current_user), db: Session = Dep
@app.get("/api/admin/ask-completion-reviews/{assignment_id}")
-def ask_completion_review_detail(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def ask_completion_review_detail(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
batch = db.get(AskAssignmentBatch, assignment_id)
if batch:
+ _require_ask_task_access(batch.assignment.task, user)
return {"success": True, "data": _ask_batch_data(batch)}
assignment = db.get(AskAssignment, assignment_id)
- if not assignment:
+ if not assignment or not _can_access_ask_task(assignment.task, user):
raise HTTPException(status_code=404, detail="提问审核任务不存在")
return {"success": True, "data": _ask_assignment_data(assignment)}
@app.post("/api/admin/ask-completion-reviews/{assignment_id}/decision")
-def ask_completion_decision(assignment_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+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"))
@@ -1064,6 +1170,43 @@ def admin_answer_task_draft_detail(submission_id: int, _: AdminUser = Depends(cu
return {"success": True, "data": _submission_data(record, include_images=True)}
+@app.post("/api/admin/answer-task-drafts/questions/{question_id}/operator-image")
+async def upload_answer_operator_images(
+ question_id: int,
+ files: list[UploadFile] = File(...),
+ _: AdminUser = Depends(current_user),
+ db: Session = Depends(get_db),
+):
+ """Attach up to three optional operation-specified images to one question."""
+ question = db.get(AnswerRecognizedQuestion, question_id)
+ record = db.get(AnswerSubmission, question.submission_id) if question else None
+ if not question or not record or record.status not in {"待筛选", "待运营派发", "已派发"}:
+ raise HTTPException(status_code=404, detail="回答问题不存在或已经发布")
+ if not files or len(files) > 3:
+ raise HTTPException(status_code=400, detail="每道问题最多上传3张运营指定图片")
+ old_ids = [value for value in (question.operator_image_screenshot_ids or "").split(",") if value.strip().isdigit()]
+ for old_id in old_ids:
+ old = db.get(AnswerScreenshot, int(old_id))
+ if old:
+ db.delete(old)
+ new_ids = []
+ for file in files:
+ if file.content_type not in {"image/jpeg", "image/png", "image/webp"}:
+ db.rollback()
+ raise HTTPException(status_code=400, detail="运营指定图片仅支持 JPG、PNG、WEBP")
+ image_data = await file.read()
+ if not image_data:
+ db.rollback()
+ raise HTTPException(status_code=400, detail="不能上传空图片")
+ image = AnswerScreenshot(submission_id=record.id, stage="operator", filename=file.filename or "operator-image", content_type=file.content_type, image_data=image_data)
+ db.add(image); db.flush(); new_ids.append(str(image.id))
+ question.operator_image_screenshot_ids = ",".join(new_ids)
+ question.operator_image_screenshot_id = int(new_ids[0]) if new_ids else None
+ db.commit()
+ db.refresh(record)
+ return {"success": True, "data": _submission_data(record, include_images=True)}
+
+
@app.post("/api/admin/answer-task-drafts/{submission_id}/reject")
def reject_answer_task_draft(submission_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
record = db.get(AnswerSubmission, submission_id)
@@ -1077,7 +1220,7 @@ def reject_answer_task_draft(submission_id: int, _: AdminUser = Depends(current_
@app.post("/api/admin/ask-tasks/generate")
-def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
product_link = str(payload.get("product_link", "")).strip()
product_name = str(payload.get("product_name", "")).strip()
if not product_link: raise HTTPException(status_code=400, detail="商品链接为必填项")
@@ -1093,12 +1236,23 @@ def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: S
raise HTTPException(status_code=400, detail="提问生成条数必须是正整数")
if count < 1:
raise HTTPException(status_code=400, detail="提问生成条数必须大于 0")
+ raw_claim_limit = payload.get("claim_questions_per_user", "")
+ if str(raw_claim_limit).strip():
+ try:
+ claim_limit = int(raw_claim_limit)
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail="每个用户可认领的问题数量必须是 2-5 的整数")
+ if claim_limit < 2 or claim_limit > 5:
+ raise HTTPException(status_code=400, detail="每个用户可认领的问题数量必须在 2-5 条之间")
+ else:
+ claim_limit = 5
existing_task_id = payload.get("task_id")
try:
task = db.get(AskTask, int(existing_task_id)) if existing_task_id else None
except (TypeError, ValueError):
task = None
if task:
+ _require_ask_task_access(task, user)
if task.status != "草稿":
raise HTTPException(status_code=409, detail="已发布任务不能重新生成")
task.product_link = product_link
@@ -1110,11 +1264,12 @@ def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: S
task.target_group = str(payload.get("target_group", ""))
task.usage_scene = str(payload.get("usage_scene", ""))
task.purchase_channel = str(payload.get("purchase_channel", ""))
+ task.claim_questions_per_user = claim_limit
for question in list(task.questions):
db.delete(question)
db.flush()
else:
- task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", product_link=product_link, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), status="草稿")
+ task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", owner_id=user.id, product_link=product_link, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), claim_questions_per_user=claim_limit, status="草稿")
db.add(task); db.flush()
questions = payload.get("questions") if isinstance(payload.get("questions"), list) else []
reference_text = str(payload.get("reference_qa", ""))[:20000]
@@ -1136,9 +1291,9 @@ def generate_ask_task(payload: dict, _: AdminUser = Depends(current_user), db: S
@app.patch("/api/admin/ask-tasks/{task_id}/questions/{question_id}")
-def update_ask_question(task_id: int, question_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def update_ask_question(task_id: int, question_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
question = db.scalar(select(AskQuestion).where(AskQuestion.id == question_id, AskQuestion.task_id == task_id))
- if not question: raise HTTPException(status_code=404, detail="提问不存在")
+ if not question or not _can_access_ask_task(question.task, user): raise HTTPException(status_code=404, detail="提问不存在")
if question.task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能修改")
if "question" in payload:
value = str(payload.get("question", "")).strip()
@@ -1150,10 +1305,10 @@ def update_ask_question(task_id: int, question_id: int, payload: dict, _: AdminU
@app.post("/api/admin/ask-tasks/{task_id}/questions")
-def add_ask_question(task_id: int, payload: dict, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def add_ask_question(task_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
task = db.get(AskTask, task_id)
text = str(payload.get("question", "")).strip()
- if not task: raise HTTPException(status_code=404, detail="提问任务不存在")
+ _require_ask_task_access(task, user)
if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能添加提问")
if not text: raise HTTPException(status_code=400, detail="提问内容不能为空")
question = AskQuestion(task_id=task.id, sequence_no=len(task.questions) + 1, question_text=text, selected=True)
@@ -1162,9 +1317,9 @@ def add_ask_question(task_id: int, payload: dict, _: AdminUser = Depends(current
@app.post("/api/admin/ask-tasks/{task_id}/publish")
-def publish_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def publish_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
task = db.get(AskTask, task_id)
- if not task: raise HTTPException(status_code=404, detail="提问任务不存在")
+ _require_ask_task_access(task, user)
if task.status != "草稿": raise HTTPException(status_code=409, detail="任务已经发布")
if not any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问")
task.status = "已发布"; db.commit(); db.refresh(task)
@@ -1172,19 +1327,18 @@ def publish_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Ses
@app.post("/api/admin/ask-tasks/{task_id}/cancel")
-def cancel_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def cancel_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
task = db.get(AskTask, task_id)
- if not task: raise HTTPException(status_code=404, detail="提问任务不存在")
+ _require_ask_task_access(task, user)
if task.status != "已发布": raise HTTPException(status_code=409, detail="当前任务不能撤销")
task.status = "已撤销"; db.commit(); db.refresh(task)
return {"success": True, "data": _ask_task_data(task)}
@app.post("/api/admin/ask-tasks/{task_id}/complete")
-def complete_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def complete_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
task = db.get(AskTask, task_id)
- if not task:
- raise HTTPException(status_code=404, detail="提问任务不存在")
+ _require_ask_task_access(task, user)
if task.status != "已发布":
raise HTTPException(status_code=409, detail="当前任务不能完成归档")
task.status = "已完成"
@@ -1217,7 +1371,7 @@ def publish_answer_task(submission_id: int, _: AdminUser = Depends(current_user)
@app.get("/api/admin/task-board")
-def admin_task_board(_: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def admin_task_board(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
if _release_expired_ask_assignments(db):
db.commit()
task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"]
@@ -1231,58 +1385,28 @@ def admin_task_board(_: AdminUser = Depends(current_user), db: Session = Depends
.where(AnswerSubmission.status.in_(task_statuses))
.order_by(AnswerSubmission.updated_at.desc())
).unique().all()
- ask_tasks = db.scalars(select(AskTask).where(AskTask.status.in_(["已发布", "已完成", "已撤销"])).order_by(AskTask.updated_at.desc())).unique().all()
- approved_ask_batches = db.scalars(
- select(AskAssignmentBatch)
- .options(
- selectinload(AskAssignmentBatch.assignment).selectinload(AskAssignment.task),
- selectinload(AskAssignmentBatch.assignment).selectinload(AskAssignment.user),
- noload(AskAssignmentBatch.screenshots),
- noload(AskAssignmentBatch.review_decisions),
- )
- .where(AskAssignmentBatch.status.in_(["approved", "paid"]))
- .order_by(AskAssignmentBatch.updated_at.desc())
- ).unique().all()
- approved_legacy_ask_assignments = db.scalars(
- select(AskAssignment)
- .options(
- selectinload(AskAssignment.task),
- selectinload(AskAssignment.user),
- selectinload(AskAssignment.items).selectinload(AskAssignmentQuestion.question),
- selectinload(AskAssignment.used_questions),
- noload(AskAssignment.screenshots),
- noload(AskAssignment.review_decisions),
- )
- .where(
- AskAssignment.status.in_(["已通过", "已完成"]),
- ~AskAssignment.batches.any(),
- AskAssignment.screenshots.any(),
- )
- .order_by(AskAssignment.updated_at.desc())
- ).unique().all()
+ ask_statement = select(AskTask).options(selectinload(AskTask.owner)).where(AskTask.status.in_(["已发布", "已完成", "已撤销"]), _ask_task_owner_clause(user)).order_by(AskTask.updated_at.desc())
+ ask_tasks = db.scalars(ask_statement).unique().all()
data = (
[_task_board_data(record, include_images=False) for record in records]
+ [_ask_task_data(task, include_questions=False) for task in ask_tasks]
- + [_ask_review_board_data(batch, include_images=False) for batch in approved_ask_batches]
- + [_ask_review_board_data(assignment, include_images=False) for assignment in approved_legacy_ask_assignments]
)
data.sort(key=lambda item: item.get("updated_at") or item.get("created_at") or "", reverse=True)
return {"success": True, "data": data}
@app.get("/api/admin/task-board/ask-tasks/{task_id}")
-def admin_task_board_ask_task_detail(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def admin_task_board_ask_task_detail(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
task = db.get(AskTask, task_id)
- if not task or task.status not in {"已发布", "已完成", "已撤销"}:
+ if not task or task.status not in {"已发布", "已完成", "已撤销"} or not _can_access_ask_task(task, user):
raise HTTPException(status_code=404, detail="提问任务不存在")
return {"success": True, "data": _ask_task_data(task, include_questions=True)}
@app.delete("/api/admin/task-board/ask-tasks/{task_id}")
-def admin_task_board_delete_ask_task(task_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def admin_task_board_delete_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
task = db.get(AskTask, task_id)
- if not task:
- raise HTTPException(status_code=404, detail="提问任务不存在")
+ _require_ask_task_access(task, user)
# AskTask's assignment relationship is intentionally not orphan-cascaded
# because assignments may be created by users. Remove those dependent
# records explicitly before deleting the task to keep every status
@@ -1295,17 +1419,17 @@ def admin_task_board_delete_ask_task(task_id: int, _: AdminUser = Depends(curren
@app.get("/api/admin/task-board/ask-batches/{batch_id}")
-def admin_task_board_ask_batch_detail(batch_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def admin_task_board_ask_batch_detail(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
batch = db.get(AskAssignmentBatch, batch_id)
- if not batch or batch.status not in {"approved", "paid"}:
+ if not batch or batch.status not in {"approved", "paid"} or not _can_access_ask_task(batch.assignment.task, user):
raise HTTPException(status_code=404, detail="待返款提问记录不存在")
return {"success": True, "data": _ask_review_board_data(batch, include_images=True)}
@app.delete("/api/admin/task-board/ask-batches/{batch_id}")
-def admin_task_board_delete_ask_batch(batch_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def admin_task_board_delete_ask_batch(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
batch = db.get(AskAssignmentBatch, batch_id)
- if not batch:
+ if not batch or not _can_access_ask_task(batch.assignment.task, user):
raise HTTPException(status_code=404, detail="提问提交记录不存在")
db.delete(batch)
db.commit()
@@ -1313,17 +1437,17 @@ def admin_task_board_delete_ask_batch(batch_id: int, _: AdminUser = Depends(curr
@app.get("/api/admin/task-board/ask-assignments/{assignment_id}")
-def admin_task_board_ask_assignment_detail(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def admin_task_board_ask_assignment_detail(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
assignment = db.get(AskAssignment, assignment_id)
- if not assignment or assignment.status not in {"已通过", "已完成"}:
+ if not assignment or assignment.status not in {"已通过", "已完成"} or not _can_access_ask_task(assignment.task, user):
raise HTTPException(status_code=404, detail="待返款提问记录不存在")
return {"success": True, "data": _ask_review_board_data(assignment, include_images=True)}
@app.delete("/api/admin/task-board/ask-assignments/{assignment_id}")
-def admin_task_board_delete_ask_assignment(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def admin_task_board_delete_ask_assignment(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
assignment = db.get(AskAssignment, assignment_id)
- if not assignment:
+ if not assignment or not _can_access_ask_task(assignment.task, user):
raise HTTPException(status_code=404, detail="提问分配记录不存在")
db.delete(assignment)
db.commit()
@@ -1331,9 +1455,9 @@ def admin_task_board_delete_ask_assignment(assignment_id: int, _: AdminUser = De
@app.post("/api/admin/task-board/ask-batches/{batch_id}/confirm-refund")
-def confirm_ask_batch_refund(batch_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def confirm_ask_batch_refund(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
batch = db.get(AskAssignmentBatch, batch_id)
- if not batch:
+ if not batch or not _can_access_ask_task(batch.assignment.task, user):
raise HTTPException(status_code=404, detail="待返款提问记录不存在")
if batch.status == "paid":
raise HTTPException(status_code=409, detail="该任务已经完成返款")
@@ -1346,9 +1470,9 @@ def confirm_ask_batch_refund(batch_id: int, _: AdminUser = Depends(current_user)
@app.post("/api/admin/task-board/ask-assignments/{assignment_id}/confirm-refund")
-def confirm_ask_assignment_refund(assignment_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def confirm_ask_assignment_refund(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
assignment = db.get(AskAssignment, assignment_id)
- if not assignment:
+ 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="该任务已经完成返款")
diff --git a/backend/app/models.py b/backend/app/models.py
index e143a52..24f9d4d 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -20,6 +20,7 @@ class AdminUser(Base):
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
login_logs: Mapped[list["AdminLoginLog"]] = relationship(back_populates="user")
+ ask_tasks: Mapped[list["AskTask"]] = relationship(back_populates="owner")
class AdminLoginLog(Base):
@@ -106,6 +107,8 @@ class AnswerRecognizedQuestion(Base):
sequence_no: Mapped[int] = mapped_column(Integer)
product_title: Mapped[str] = mapped_column(String(255))
product_image_screenshot_id: Mapped[int | None] = mapped_column(ForeignKey("answer_screenshots.id"), nullable=True)
+ operator_image_screenshot_id: Mapped[int | None] = mapped_column(ForeignKey("answer_screenshots.id"), nullable=True)
+ operator_image_screenshot_ids: Mapped[str | None] = mapped_column(Text, nullable=True)
question_text: Mapped[str] = mapped_column(Text)
answer_text: Mapped[str] = mapped_column(Text, default="")
answer_status: Mapped[str] = mapped_column(String(32), default="已生成")
@@ -134,6 +137,7 @@ class AskTask(Base):
id: Mapped[int] = mapped_column(Integer, primary_key=True)
task_no: Mapped[str] = mapped_column(String(40), unique=True, index=True)
+ owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True)
product_link: Mapped[str] = mapped_column(String(1000))
product_name: Mapped[str] = mapped_column(String(255))
category: Mapped[str] = mapped_column(String(100), default="")
@@ -143,12 +147,14 @@ class AskTask(Base):
target_group: Mapped[str] = mapped_column(String(100), default="女性")
usage_scene: Mapped[str] = mapped_column(String(100), default="居家")
purchase_channel: Mapped[str] = mapped_column(String(100), default="电商平台")
+ claim_questions_per_user: Mapped[int] = mapped_column(Integer, default=5, nullable=False)
status: Mapped[str] = mapped_column(String(32), default="草稿", 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)
questions: Mapped[list["AskQuestion"]] = relationship(back_populates="task", cascade="all, delete-orphan", lazy="selectin")
assignments: Mapped[list["AskAssignment"]] = relationship(back_populates="task", cascade="all, delete-orphan")
+ owner: Mapped[AdminUser | None] = relationship(back_populates="ask_tasks")
class AskQuestion(Base):
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index eca56e5..35f85a3 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -35,7 +35,7 @@ class QAGenerateRequest(BaseModel):
target_group: str = Field(default="女性", max_length=100)
usage_scene: str = Field(default="居家", max_length=100)
purchase_channel: str = Field(default="电商平台", max_length=100)
- question_count: int = Field(default=5, ge=1, le=20)
+ question_count: int = Field(default=5, ge=1)
answers_per_question: int = Field(default=2, ge=1, le=50)
answer_requirement: str = Field(default="", max_length=1000)
reference_qa: str = Field(default="", max_length=20000)
diff --git a/backend/schema.sql b/backend/schema.sql
index 954c43a..6785902 100644
--- a/backend/schema.sql
+++ b/backend/schema.sql
@@ -31,3 +31,6 @@ CREATE TABLE IF NOT EXISTS admin_login_logs (
KEY idx_admin_login_logs_created (created_at),
CONSTRAINT fk_admin_login_logs_user FOREIGN KEY (user_id) REFERENCES admin_users(id) ON DELETE SET NULL
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
+
+-- 提问任务归属运营账号;历史数据允许为空,超级管理员仍可查看全部任务。
+-- 新环境建表时请将 owner_id 一并加入 ask_tasks;已有环境由应用启动时自动补列。
diff --git a/frontend/src/AnswerUserPortal.jsx b/frontend/src/AnswerUserPortal.jsx
index 7f926dc..5f912e1 100644
--- a/frontend/src/AnswerUserPortal.jsx
+++ b/frontend/src/AnswerUserPortal.jsx
@@ -80,7 +80,7 @@ function AssignmentStage({ api, session, latest, reload, setError }) {
const body = new FormData(); body.append('token', session.token); files.forEach(file => body.append('files', file)); payFiles.forEach(file => body.append('payment_files', file)); setBusy(true)
try { const result = await api(`/api/answer/me/submissions/${latest.id}/completion`, { method: 'POST', body }); await reload(result.data) } catch (err) { setError(err.message) } finally { setBusy(false) }
}
- return <> 请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。完成问答并上传任务凭证
点击复制参考回答即可前往淘宝粘贴提交
{question.answer}
请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。
点击复制参考回答即可前往淘宝粘贴提交
{question.answer}
通过顶部切换创建「提问任务」或「回答任务」。
{task.user_id}
-
-
- {task.task_id}
-
-
- {task.product_name}
- {maskPhone(task.phone)}
-
-
-
- {task.task_type_label}
-
-
- {task.qa_count}
-
- {task.pending_review_count} / {task.completed_count}
-
-
-
- {task.status_label}
-
-
-
- {task.creator_name}
- {formatTime(task.created_at)}
-
-
-
- {task.can_cancel && (
-
- )}
- {task.can_complete && task.task_type === "ask" && (
-
- )}
-
-
- {task.user_id}
+
+ {task.task_id}
+ {task.product_name}{maskPhone(task.phone)}
+ {task.task_type_label}
+ {task.qa_count}
+ {task.pending_review_count} / {task.completed_count}
+ {task.status_label}
+ {task.creator_name}{formatTime(task.created_at)}
+
+
+ {task.can_cancel && (
+
+ )}
+ {task.can_complete && task.task_type === "ask" && (
+
+ )}
+
+
+ {child.user_id}
+ {child.task_id}
+ {child.product_name}{maskPhone(child.phone)}
+ 提问任务
+ {child.qa_count}
+ {child.pending_review_count} / {child.completed_count}
+ {child.status_label}
+ {child.creator_name}{formatTime(child.created_at)}
+
+