diff --git a/backend/app/main.py b/backend/app/main.py index 3dcbd13..eedb6a2 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -23,7 +23,7 @@ from sqlalchemy.orm import Session, noload, selectinload from .config import settings from .database import Base, SessionLocal, engine, get_db from .llm import extract_questions_from_images, generate_answer_for_question, generate_questions, generate_qa -from .models import AdminLoginLog, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QALibraryEntry +from .models import AdminLoginLog, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QALibraryEntry, RefundUploadSession from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QAGenerateRequest, RegisterRequest from .security import create_session_token, decode_session_token, hash_password, verify_password @@ -85,18 +85,51 @@ def _product_intent_error(values: list[object]) -> str | None: return None -def _product_token_error(value: object) -> str | None: - """Validate only that a Taobao product token was provided. +def _product_token_error(value: object, qr_image: object = None) -> str | None: + """Require at least one way for the user to open the product. Taobao's command is intentionally treated as opaque text. It may contain characters that are not valid in a URL, so no HTTP/HTTPS parsing should be performed here. """ - if not str(value or "").strip(): - return "商品淘口令为必填项" + if not str(value or "").strip() and not qr_image: + return "请填写商品淘口令或上传商品二维码" return None +def _decode_product_qr_image(value: object, filename: object = "") -> tuple[bytes | None, str | None, str | None]: + """Decode the small data URL sent by the create-task form.""" + if value is None: + return None, None, None + raw = str(value).strip() + if not raw: + return None, None, None + match = re.fullmatch(r"data:(image/(?:jpeg|png|webp));base64,([A-Za-z0-9+/=\s]+)", raw, flags=re.IGNORECASE) + if not match: + raise HTTPException(status_code=400, detail="商品二维码必须是 JPG、PNG 或 WEBP 图片") + try: + image_data = base64.b64decode(re.sub(r"\s+", "", match.group(2)), validate=True) + except (ValueError, base64.binascii.Error): + raise HTTPException(status_code=400, detail="商品二维码图片数据无效") + if not image_data: + raise HTTPException(status_code=400, detail="商品二维码图片不能为空") + if len(image_data) > 5 * 1024 * 1024: + raise HTTPException(status_code=400, detail="商品二维码图片不能超过 5MB") + image_name = str(filename or "商品二维码").strip()[:255] or "商品二维码" + return image_data, match.group(1).lower(), image_name + + +def _product_qr_data(task: AskTask) -> dict | None: + if not task.product_qr_image_data: + return None + content_type = task.product_qr_image_content_type or "image/png" + return { + "name": task.product_qr_image_name or "商品二维码", + "content_type": content_type, + "data_url": f"data:{content_type};base64,{base64.b64encode(task.product_qr_image_data).decode()}", + } + + def _generation_options_error(payload: dict, include_answers: bool = True) -> str | None: # 语言风格、目标人群、场景等生成偏好均可留空,仅限制生成条数必填。 required = [("question_count", "提问生成条数")] @@ -180,12 +213,33 @@ def startup(): if "claim_questions_per_user" not in columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN claim_questions_per_user INTEGER NOT NULL DEFAULT 5")) + if "shop_name" not in columns: + with engine.begin() as connection: + connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN shop_name VARCHAR(255) NOT NULL DEFAULT ''")) if "owner_id" not in columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN owner_id INTEGER NULL")) if "public_token" not in columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN public_token VARCHAR(64) NULL")) + if "product_qr_image_data" not in columns: + # SQLite accepts LONGBLOB as a type name and stores it with BLOB + # affinity; MySQL needs LONGBLOB so a normal QR image is not capped at + # the 64KB BLOB limit. + qr_blob_type = "LONGBLOB" if engine.dialect.name == "mysql" else "BLOB" + with engine.begin() as connection: + connection.execute(text(f"ALTER TABLE ask_tasks ADD COLUMN product_qr_image_data {qr_blob_type} NULL")) + task_columns = {column["name"] for column in inspect(engine).get_columns("ask_tasks")} + if "product_qr_image_content_type" not in task_columns: + with engine.begin() as connection: + connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN product_qr_image_content_type VARCHAR(64) NULL")) + if "product_qr_image_name" not in task_columns: + with engine.begin() as connection: + connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN product_qr_image_name VARCHAR(255) NULL")) + question_table_columns = {column["name"] for column in inspect(engine).get_columns("ask_questions")} + if "source" not in question_table_columns: + with engine.begin() as connection: + connection.execute(text("ALTER TABLE ask_questions ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'AI生成'")) admin_user_columns = {column["name"] for column in inspect(engine).get_columns("admin_users")} admin_user_migrations = { "dingtalk_user_id": "VARCHAR(128) NULL", @@ -583,6 +637,28 @@ def _require_ask_task_access(task: AskTask | None, user: AdminUser) -> AskTask: return task +def _dedupe_draft_ask_questions(task: AskTask, db: Session) -> int: + """Remove duplicate question text from a draft, keeping the first row. + + This repairs drafts created before the picker became idempotent. Published + tasks are never changed, and a draft has no user assignments to orphan. + """ + seen: set[str] = set() + duplicates: list[AskQuestion] = [] + for question in sorted(task.questions, key=lambda value: (value.sequence_no, value.id)): + key = re.sub(r"\s+", " ", str(question.question_text or "").strip()) + if key and key in seen: + duplicates.append(question) + elif key: + seen.add(key) + for question in duplicates: + db.delete(question) + if duplicates: + db.flush() + db.expire(task, ["questions"]) + return len(duplicates) + + def _ask_task_owner_clause(user: AdminUser): if user.role_code == "super_admin": return True @@ -625,6 +701,72 @@ def _answer_owner_clause(user: AdminUser): return or_(AnswerSubmission.owner_id.is_(None), AnswerSubmission.owner_id.in_(descendant_owners)) +REFUND_UPLOAD_SESSION_TTL = timedelta(minutes=10) +REFUND_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"} + + +def _refund_session_target(session: RefundUploadSession, db: Session): + """Resolve the exact payable record carried by a temporary upload token.""" + if session.target_kind == "ask_batch": + record = db.get(AskAssignmentBatch, session.target_id) + return record, record.status == "approved" if record else False + if session.target_kind == "ask_assignment": + record = db.get(AskAssignment, session.target_id) + return record, record.status == "已通过" if record else False + if session.target_kind == "answer_submission": + record = db.get(AnswerSubmission, session.target_id) + return record, record.status == "已通过" if record else False + return None, False + + +def _refund_session_target_for_payload(target_kind: str, target_id: int, db: Session): + # Avoid constructing a transient ORM object just to resolve the target; + # this also keeps the query helper easy to reuse in tests. + if target_kind == "ask_batch": + record = db.get(AskAssignmentBatch, target_id) + return record, record.status == "approved" if record else False + if target_kind == "ask_assignment": + record = db.get(AskAssignment, target_id) + return record, record.status == "已通过" if record else False + if target_kind == "answer_submission": + record = db.get(AnswerSubmission, target_id) + return record, record.status == "已通过" if record else False + return None, False + + +def _refund_session_expired(session: RefundUploadSession) -> bool: + return session.expires_at <= datetime.utcnow() + + +def _refund_screenshot_count(record, kind: str) -> int: + screenshots = list(record.screenshots) + return sum(1 for image in screenshots if image.stage == "refund") + + +async def _read_refund_file(refund_file: UploadFile | None) -> tuple[bytes, str, str]: + if refund_file is None: + raise HTTPException(status_code=400, detail="请上传返款凭证,或先用手机扫码上传") + if refund_file.content_type not in REFUND_IMAGE_TYPES: + raise HTTPException(status_code=400, detail="返款凭证仅支持 JPG、PNG、WEBP 图片") + data = await refund_file.read() + if not data: + raise HTTPException(status_code=400, detail="返款凭证不能为空") + if len(data) > 10 * 1024 * 1024: + raise HTTPException(status_code=400, detail="返款凭证不能超过 10MB") + return data, refund_file.content_type, (refund_file.filename or "refund")[:255] + + +def _refund_session_payload(session: RefundUploadSession, record) -> dict: + return { + "token": session.token, + "target_kind": session.target_kind, + "target_id": session.target_id, + "expires_at": session.expires_at.isoformat(), + "uploaded": bool(session.uploaded_at) or _refund_screenshot_count(record, session.target_kind) > 0, + "used": bool(session.used_at), + } + + def _new_ask_public_token(db: Session) -> str: @@ -681,8 +823,10 @@ def _ask_assignment_data(assignment: AskAssignment, include_images: bool = True, "used_count": len(used_ids), "remaining_count": max(len(items) - len(used_ids), 0), "task_id": assignment.task.task_no, + "shop_name": assignment.task.shop_name, "product_name": assignment.task.product_name, "product_link": assignment.task.product_link, + "product_qr": _product_qr_data(assignment.task), "user_id": assignment.user.public_id, "phone": assignment.user.phone, "created_at": assignment.created_at.isoformat(), @@ -855,7 +999,10 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True) -> d "未通过": "进行中", "已撤销": "已撤销", } - completed_count = len(selected) if record.status in {"待审核", "已通过", "已完成"} else 0 + # "待审核" and "待返款" mean the user has submitted evidence, not that + # the work is finished. The board's 完成 column must only count records + # whose refund workflow has reached 已完成. + completed_count = len(selected) if record.status == "已完成" else 0 return { **summary, "board_key": f"answer-{record.id}", "record_kind": "answer_submission", @@ -931,7 +1078,9 @@ def _ask_assignment_child_data(assignment: AskAssignment) -> dict: "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), + # Used questions have been submitted, but remain incomplete until the + # operator confirms the refund (the child status becomes 已完成). + "completed_count": question_count if refund_completed else 0, "total_count": question_count, "creator_name": task.owner.display_name if task.owner else "运营后台", "created_at": assignment.created_at.isoformat(), @@ -962,9 +1111,12 @@ def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: reverse=True, ) assigned_count = len(task.assignments) - completed_count = sum(len(assignment.used_questions) for assignment in task.assignments) + submitted_count = sum(len(assignment.used_questions) for assignment in task.assignments) + # Keep the parent status based on whether all questions have been claimed; + # the 完成 column is deliberately stricter and only counts refunded rows. + completed_count = sum(child["completed_count"] for child in claimed_tasks) pending_count = sum(child["pending_review_count"] for child in claimed_tasks) - fully_claimed = bool(selected_questions) and completed_count >= len(selected_questions) + fully_claimed = bool(selected_questions) and submitted_count >= len(selected_questions) if task.status == "已撤销": status_label = "已撤销" elif task.status == "已完成" or fully_claimed: @@ -975,10 +1127,20 @@ def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: "id": task.id, "board_key": f"ask-task-{task.id}", "record_kind": "ask_task", "task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务", "public_token": task.public_token, - "product_name": task.product_name, "product_link": task.product_link, "status": task.status, + "shop_name": task.shop_name, + "product_name": task.product_name, "product_link": task.product_link, + # Keep the persisted task state alongside the display label. The + # create-task page uses this value to switch to the publish-success + # view immediately after the publish request completes. The label is + # intentionally derived for the board (a fully claimed task may show + # as completed while its persisted workflow state remains published). + "status": task.status, + # The board list is intentionally lightweight; the QR is included in + # task detail/public assignment responses where it is actually used. + "product_qr": _product_qr_data(task) if include_questions else None, "claim_questions_per_user": task.claim_questions_per_user or 5, "status_label": status_label, - "qa_count": len(selected_questions), "claimed_count": assigned_count, "submitted_count": completed_count, + "qa_count": len(selected_questions), "claimed_count": assigned_count, "submitted_count": submitted_count, "pending_review_count": pending_count, "completed_count": completed_count, "total_count": len(selected_questions), "refund_screenshot_count": sum(child.get("refund_proof_count", 0) for child in claimed_tasks), "creator_name": task.owner.display_name if task.owner else "运营后台", "created_at": task.created_at.isoformat(), "updated_at": task.updated_at.isoformat(), @@ -989,7 +1151,7 @@ def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: # the ambiguous placeholder "--". Claimed child rows still expose # the actual answer user's public ID. "user_id": task.owner.display_name if task.owner else "运营后台", "phone": "", "questions": [], - "selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": "", "product_title": task.product_name} for q in selected_questions] if include_questions else []), + "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")], "claimed_tasks": claimed_tasks, "invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None, @@ -1022,8 +1184,10 @@ def _ask_review_board_data(record: AskAssignmentBatch | AskAssignment, include_i "assignment_id": record.id, "status": "已通过", "task_id": assignment.task.task_no, + "shop_name": assignment.task.shop_name, "product_name": assignment.task.product_name, "product_link": assignment.task.product_link, + "product_qr": _product_qr_data(assignment.task), "user_id": assignment.user.public_id, "phone": assignment.user.phone, "created_at": record.created_at.isoformat(), @@ -1048,12 +1212,13 @@ def _ask_review_board_data(record: AskAssignmentBatch | AskAssignment, include_i "task_type": "ask", "task_type_label": "提问任务", "status_label": "已完成" if refund_completed else "待返款", + "shop_name": assignment.task.shop_name, "product_name": assignment.task.product_name, "qa_count": selected_count, "claimed_count": selected_count, "submitted_count": selected_count, "pending_review_count": 0, - "completed_count": selected_count, + "completed_count": selected_count if refund_completed else 0, "total_count": selected_count, "creator_name": "运营后台", "can_cancel": False, @@ -1579,9 +1744,17 @@ def reject_answer_task_draft(submission_id: int, _: AdminUser = Depends(current_ @app.post("/api/admin/ask-tasks/generate") def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + shop_name = str(payload.get("shop_name", "")).strip() product_link = str(payload.get("product_link", "")).strip() + product_qr_data, product_qr_content_type, product_qr_name = _decode_product_qr_image( + payload.get("product_qr_image"), payload.get("product_qr_image_name") + ) product_name = str(payload.get("product_name", "")).strip() - product_token_error = _product_token_error(product_link) + if not shop_name: + raise HTTPException(status_code=400, detail="店铺名称为必填项") + if len(shop_name) > 255: + raise HTTPException(status_code=400, detail="店铺名称不能超过255个字符") + product_token_error = _product_token_error(product_link, product_qr_data) if product_token_error: raise HTTPException(status_code=400, detail=product_token_error) intent_error = _product_intent_error([product_name, payload.get("category"), payload.get("benefits"), payload.get("selling_points")]) @@ -1621,8 +1794,13 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db db.rollback() questions = payload.get("questions") if isinstance(payload.get("questions"), list) else [] reference_text = str(payload.get("reference_qa", ""))[:20000] + generation_payload = { + key: value + for key, value in payload.items() + if key not in {"shop_name", "product_qr_image", "product_qr_image_name"} + } if not questions: - questions = generate_questions(payload, count, reference_text) + questions = generate_questions(generation_payload, count, reference_text) # 自定义问题优先,不足的数量用候选问题补齐,保证返回数量与运营填写值一致。 generated: list[str] = [] for value in questions: @@ -1632,13 +1810,18 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db if len(generated) == count: break if len(generated) < count: - generated.extend(generate_questions(payload, count - len(generated), reference_text)) + generated.extend(generate_questions(generation_payload, count - len(generated), reference_text)) if existing_id: task = db.get(AskTask, existing_id) _require_ask_task_access(task, user) if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能重新生成") + _dedupe_draft_ask_questions(task, db) + task.shop_name = shop_name task.product_link = product_link + task.product_qr_image_data = product_qr_data + task.product_qr_image_content_type = product_qr_content_type + task.product_qr_image_name = product_qr_name task.product_name = product_name or "待补充商品" task.category = str(payload.get("category", "")) task.benefits = str(payload.get("benefits", "")) @@ -1648,13 +1831,12 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db task.usage_scene = str(payload.get("usage_scene", "")) task.purchase_channel = str(payload.get("purchase_channel", "")) task.claim_questions_per_user = claim_limit - for question in list(task.questions): - db.delete(question) - db.flush() else: - task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", owner_id=user.id, product_link=product_link, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), claim_questions_per_user=claim_limit, status="草稿") + task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", owner_id=user.id, shop_name=shop_name, product_link=product_link, product_qr_image_data=product_qr_data, product_qr_image_content_type=product_qr_content_type, product_qr_image_name=product_qr_name, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), claim_questions_per_user=claim_limit, status="草稿") db.add(task); db.flush() - for index, question in enumerate(generated): db.add(AskQuestion(task_id=task.id, sequence_no=index + 1, question_text=question, selected=True)) + next_sequence = max((question.sequence_no for question in task.questions), default=0) + 1 + for index, question in enumerate(generated): + db.add(AskQuestion(task_id=task.id, sequence_no=next_sequence + index, question_text=question, source="AI生成", selected=True)) db.commit(); db.refresh(task) return {"success": True, "data": _ask_task_data(task)} @@ -1670,19 +1852,44 @@ def update_ask_question(task_id: int, question_id: int, payload: dict, user: Adm question.question_text = value if "selected" in payload: question.selected = bool(payload["selected"]) db.commit(); db.refresh(question) - return {"success": True, "data": {"id": question.id, "question": question.question_text, "selected": question.selected}} + return {"success": True, "data": {"id": question.id, "question": question.question_text, "selected": question.selected, "source": question.source or "AI生成"}} @app.post("/api/admin/ask-tasks/{task_id}/questions") def add_ask_question(task_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): - task = db.get(AskTask, task_id) + # Lock the draft row while checking/inserting. This closes the small + # race window where two picker requests arrive at the same time; MySQL + # serializes them, while SQLite simply ignores FOR UPDATE in local tests. + task = db.scalar(select(AskTask).where(AskTask.id == task_id).with_for_update()) text = str(payload.get("question", "")).strip() _require_ask_task_access(task, user) if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能添加提问") if not text: raise HTTPException(status_code=400, detail="提问内容不能为空") - question = AskQuestion(task_id=task.id, sequence_no=len(task.questions) + 1, question_text=text, selected=True) + # The library picker may retry the same request (for example after a + # mobile navigation/re-render). Treat identical text within a draft as + # idempotent instead of creating a second row. + duplicate = db.scalar(select(AskQuestion).where(AskQuestion.task_id == task.id, AskQuestion.question_text == text)) + if duplicate: + return {"success": True, "data": {"id": duplicate.id, "sequence_no": duplicate.sequence_no, "question": duplicate.question_text, "selected": duplicate.selected, "source": duplicate.source or "AI生成"}} + next_sequence = max((item.sequence_no for item in task.questions), default=0) + 1 + source = str(payload.get("source", "自主添加")).strip() or "自主添加" + if source not in {"自主添加", "问答库", "AI生成"}: + source = "自主添加" + question = AskQuestion(task_id=task.id, sequence_no=next_sequence, question_text=text, source=source, selected=True) db.add(question); db.commit(); db.refresh(question) - return {"success": True, "data": {"id": question.id, "sequence_no": question.sequence_no, "question": question.question_text, "selected": question.selected}} + return {"success": True, "data": {"id": question.id, "sequence_no": question.sequence_no, "question": question.question_text, "selected": question.selected, "source": question.source}} + + +@app.delete("/api/admin/ask-tasks/{task_id}/questions/{question_id}") +def delete_ask_question(task_id: int, question_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + question = db.scalar(select(AskQuestion).where(AskQuestion.id == question_id, AskQuestion.task_id == task_id)) + if not question or not _can_access_ask_task(question.task, user): + raise HTTPException(status_code=404, detail="提问不存在") + if question.task.status != "草稿": + raise HTTPException(status_code=409, detail="已发布任务不能删除提问") + db.delete(question) + db.commit() + return {"success": True, "data": {"id": question_id}} @app.post("/api/admin/ask-tasks/{task_id}/publish") @@ -1690,7 +1897,8 @@ def publish_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: task = db.get(AskTask, task_id) _require_ask_task_access(task, user) if task.status != "草稿": raise HTTPException(status_code=409, detail="任务已经发布") - product_token_error = _product_token_error(task.product_link) + _dedupe_draft_ask_questions(task, db) + product_token_error = _product_token_error(task.product_link, task.product_qr_image_data) if product_token_error: raise HTTPException(status_code=400, detail=f"无法发布任务:{product_token_error}") if not any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问") @@ -1842,8 +2050,90 @@ def admin_task_board_delete_ask_assignment(assignment_id: int, user: AdminUser = return {"success": True, "data": {"id": assignment_id}} +@app.post("/api/admin/refund-upload-sessions") +def create_refund_upload_session(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + """Create a short-lived, record-bound upload URL for the operator modal.""" + target_kind = str(payload.get("target_kind") or "").strip() + try: + target_id = int(payload.get("target_id")) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="返款记录无效") + target, payable = _refund_session_target_for_payload(target_kind, target_id, db) + if not target or not payable: + raise HTTPException(status_code=409, detail="当前任务不在待返款状态") + if target_kind in {"ask_batch", "ask_assignment"}: + if not _can_access_ask_task(target.assignment.task, user): + raise HTTPException(status_code=404, detail="无权操作该返款记录") + elif not _can_access_answer_submission(target, user): + raise HTTPException(status_code=404, detail="无权操作该返款记录") + # 24 random bytes keep the full LAN upload URL inside a compact QR + # version while still providing 192 bits of entropy. + token = secrets.token_urlsafe(24) + session = RefundUploadSession( + token=token, + target_kind=target_kind, + target_id=target_id, + created_by_id=user.id, + expires_at=datetime.utcnow() + REFUND_UPLOAD_SESSION_TTL, + ) + db.add(session) + db.commit() + return {"success": True, "data": { + "token": token, + "upload_url": f"/refund-upload/{token}", + "upload_url_hint": f"手机浏览器打开:/refund-upload/{token}", + "expires_at": session.expires_at.isoformat(), + "expires_in": int(REFUND_UPLOAD_SESSION_TTL.total_seconds()), + "target_kind": target_kind, + "target_id": target_id, + }} + + +@app.get("/api/refund-upload-sessions/{token}") +def get_refund_upload_session(token: str, db: Session = Depends(get_db)): + session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == token)) + if not session: + raise HTTPException(status_code=404, detail="二维码无效或已失效") + target, payable = _refund_session_target(session, db) + if _refund_session_expired(session): + raise HTTPException(status_code=410, detail="二维码已过期,请让运营重新生成") + if not target or not payable: + raise HTTPException(status_code=410, detail="该返款任务已完成或不可操作") + return {"success": True, "data": _refund_session_payload(session, target)} + + +@app.post("/api/refund-upload-sessions/{token}/upload") +async def upload_refund_proof_by_session(token: str, refund_file: UploadFile = File(...), db: Session = Depends(get_db)): + session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == token)) + if not session: + raise HTTPException(status_code=404, detail="二维码无效或已失效") + if _refund_session_expired(session): + raise HTTPException(status_code=410, detail="二维码已过期,请让运营重新生成") + if session.used_at: + raise HTTPException(status_code=409, detail="该返款任务已经确认完成") + target, payable = _refund_session_target(session, db) + if not target or not payable: + raise HTTPException(status_code=410, detail="该返款任务已完成或不可操作") + data, content_type, filename = await _read_refund_file(refund_file) + # A re-upload replaces the pending proof for this target, so the operator + # always confirms the image currently visible on the phone. + for image in list(target.screenshots): + if image.stage == "refund": + db.delete(image) + if session.target_kind == "ask_batch": + db.add(AskAssignmentBatchScreenshot(batch_id=target.id, stage="refund", filename=filename, content_type=content_type, image_data=data)) + elif session.target_kind == "ask_assignment": + db.add(AskAssignmentScreenshot(assignment_id=target.id, stage="refund", filename=filename, content_type=content_type, image_data=data)) + else: + db.add(AnswerScreenshot(submission_id=target.id, stage="refund", filename=filename, content_type=content_type, image_data=data)) + session.uploaded_at = datetime.utcnow() + db.commit() + db.refresh(session) + return {"success": True, "data": {"uploaded": True, "filename": filename, "uploaded_at": session.uploaded_at.isoformat()}} + + @app.post("/api/admin/task-board/ask-batches/{batch_id}/confirm-refund") -async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile = File(...), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile | None = File(None), upload_token: str | None = Form(None), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): batch = db.get(AskAssignmentBatch, batch_id) if not batch or not _can_access_ask_task(batch.assignment.task, user): raise HTTPException(status_code=404, detail="待返款提问记录不存在") @@ -1851,12 +2141,19 @@ async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile = File raise HTTPException(status_code=409, detail="该任务已经完成返款") if batch.status != "approved": raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") - if refund_file.content_type not in {"image/jpeg", "image/png", "image/webp"}: - raise HTTPException(status_code=400, detail="返款凭证仅支持 JPG、PNG、WEBP 图片") - refund_data = await refund_file.read() - if not refund_data: - raise HTTPException(status_code=400, detail="请上传返款凭证") - db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="refund", filename=refund_file.filename or "refund", content_type=refund_file.content_type, image_data=refund_data)) + if refund_file is not None: + refund_data, content_type, filename = await _read_refund_file(refund_file) + for image in list(batch.screenshots): + if image.stage == "refund": + db.delete(image) + db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="refund", filename=filename, content_type=content_type, image_data=refund_data)) + elif not any(image.stage == "refund" for image in batch.screenshots): + raise HTTPException(status_code=400, detail="请先上传返款凭证,或用手机扫描二维码上传") + if upload_token: + session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == upload_token, RefundUploadSession.target_kind == "ask_batch", RefundUploadSession.target_id == batch.id)) + if not session or _refund_session_expired(session) or session.used_at: + raise HTTPException(status_code=409, detail="扫码上传会话无效或已过期,请重新生成") + session.used_at = datetime.utcnow() batch.status = "paid" batch.updated_at = datetime.utcnow() db.commit(); db.refresh(batch) @@ -1864,7 +2161,7 @@ async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile = File @app.post("/api/admin/task-board/ask-assignments/{assignment_id}/confirm-refund") -async def confirm_ask_assignment_refund(assignment_id: int, refund_file: UploadFile = File(...), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +async def confirm_ask_assignment_refund(assignment_id: int, refund_file: UploadFile | None = File(None), upload_token: str | None = Form(None), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): assignment = db.get(AskAssignment, assignment_id) if not assignment or not _can_access_ask_task(assignment.task, user): raise HTTPException(status_code=404, detail="待返款提问记录不存在") @@ -1872,12 +2169,19 @@ async def confirm_ask_assignment_refund(assignment_id: int, refund_file: UploadF raise HTTPException(status_code=409, detail="该任务已经完成返款") if assignment.status != "已通过": raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") - if refund_file.content_type not in {"image/jpeg", "image/png", "image/webp"}: - raise HTTPException(status_code=400, detail="返款凭证仅支持 JPG、PNG、WEBP 图片") - refund_data = await refund_file.read() - if not refund_data: - raise HTTPException(status_code=400, detail="请上传返款凭证") - db.add(AskAssignmentScreenshot(assignment_id=assignment.id, stage="refund", filename=refund_file.filename or "refund", content_type=refund_file.content_type, image_data=refund_data)) + if refund_file is not None: + refund_data, content_type, filename = await _read_refund_file(refund_file) + for image in list(assignment.screenshots): + if image.stage == "refund": + db.delete(image) + db.add(AskAssignmentScreenshot(assignment_id=assignment.id, stage="refund", filename=filename, content_type=content_type, image_data=refund_data)) + elif not any(image.stage == "refund" for image in assignment.screenshots): + raise HTTPException(status_code=400, detail="请先上传返款凭证,或用手机扫描二维码上传") + if upload_token: + session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == upload_token, RefundUploadSession.target_kind == "ask_assignment", RefundUploadSession.target_id == assignment.id)) + if not session or _refund_session_expired(session) or session.used_at: + raise HTTPException(status_code=409, detail="扫码上传会话无效或已过期,请重新生成") + session.used_at = datetime.utcnow() assignment.status = "已完成" assignment.updated_at = datetime.utcnow() db.commit(); db.refresh(assignment) @@ -1903,7 +2207,7 @@ def admin_task_board_delete_submission(submission_id: int, user: AdminUser = Dep @app.post("/api/admin/task-board/{submission_id}/confirm-refund") -async def confirm_answer_refund(submission_id: int, refund_file: UploadFile = File(...), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): +async def confirm_answer_refund(submission_id: int, refund_file: UploadFile | None = File(None), upload_token: str | None = Form(None), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): record = db.get(AnswerSubmission, submission_id) if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="待返款回答记录不存在") @@ -1911,12 +2215,19 @@ async def confirm_answer_refund(submission_id: int, refund_file: UploadFile = Fi raise HTTPException(status_code=409, detail="该任务已经完成返款") if record.status != "已通过": raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") - if refund_file.content_type not in {"image/jpeg", "image/png", "image/webp"}: - raise HTTPException(status_code=400, detail="返款凭证仅支持 JPG、PNG、WEBP 图片") - refund_data = await refund_file.read() - if not refund_data: - raise HTTPException(status_code=400, detail="请上传返款凭证") - db.add(AnswerScreenshot(submission_id=record.id, stage="refund", filename=refund_file.filename or "refund", content_type=refund_file.content_type, image_data=refund_data)) + if refund_file is not None: + refund_data, content_type, filename = await _read_refund_file(refund_file) + for image in list(record.screenshots): + if image.stage == "refund": + db.delete(image) + db.add(AnswerScreenshot(submission_id=record.id, stage="refund", filename=filename, content_type=content_type, image_data=refund_data)) + elif not any(image.stage == "refund" for image in record.screenshots): + raise HTTPException(status_code=400, detail="请先上传返款凭证,或用手机扫描二维码上传") + if upload_token: + session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == upload_token, RefundUploadSession.target_kind == "answer_submission", RefundUploadSession.target_id == record.id)) + if not session or _refund_session_expired(session) or session.used_at: + raise HTTPException(status_code=409, detail="扫码上传会话无效或已过期,请重新生成") + session.used_at = datetime.utcnow() record.status = "已完成" db.commit(); db.refresh(record) return {"success": True, "data": _task_board_data(record, include_images=True)} diff --git a/backend/app/models.py b/backend/app/models.py index 9165e7b..4e074c1 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -1,6 +1,7 @@ from datetime import datetime from sqlalchemy import Boolean, DateTime, ForeignKey, Integer, LargeBinary, String, Text, UniqueConstraint +from sqlalchemy.dialects.mysql import MEDIUMBLOB from sqlalchemy.orm import Mapped, mapped_column, relationship from .database import Base @@ -150,6 +151,15 @@ class AskTask(Base): public_token: Mapped[str | None] = mapped_column(String(64), unique=True, index=True, nullable=True) owner_id: Mapped[int | None] = mapped_column(ForeignKey("admin_users.id"), nullable=True, index=True) product_link: Mapped[str] = mapped_column(String(1000)) + # QR images are capped at 5 MB by the API. MEDIUMBLOB keeps fresh MySQL + # installs above the 64 KB BLOB limit while the generic type remains + # compatible with SQLite development databases. + product_qr_image_data: Mapped[bytes | None] = mapped_column( + LargeBinary().with_variant(MEDIUMBLOB, "mysql"), nullable=True + ) + product_qr_image_content_type: Mapped[str | None] = mapped_column(String(64), nullable=True) + product_qr_image_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + shop_name: Mapped[str] = mapped_column(String(255), default="") product_name: Mapped[str] = mapped_column(String(255)) category: Mapped[str] = mapped_column(String(100), default="") benefits: Mapped[str] = mapped_column(Text, default="") @@ -175,6 +185,9 @@ class AskQuestion(Base): task_id: Mapped[int] = mapped_column(ForeignKey("ask_tasks.id"), index=True) sequence_no: Mapped[int] = mapped_column(Integer) question_text: Mapped[str] = mapped_column(Text) + # Keep the origin so the draft UI can distinguish AI-generated questions + # from questions added manually or imported from the Q&A library. + source: Mapped[str] = mapped_column(String(32), default="AI生成", nullable=False) selected: Mapped[bool] = mapped_column(Boolean, default=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) @@ -315,3 +328,27 @@ class AskAssignmentReview(Base): created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) assignment: Mapped[AskAssignment] = relationship(back_populates="review_decisions") + + +class RefundUploadSession(Base): + """Short-lived, login-free upload ticket for an operator refund proof. + + The token is deliberately bound to one concrete board record rather than + accepting a task id from the phone. This keeps a scanned code from being + reused to attach a screenshot to a different task. + """ + + __tablename__ = "refund_upload_sessions" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + token: Mapped[str] = mapped_column(String(96), unique=True, index=True) + target_kind: Mapped[str] = mapped_column(String(32), index=True) + target_id: Mapped[int] = mapped_column(Integer, index=True) + # Keep this as an indexed audit value without a database FK. Existing + # deployments may use a different integer width for admin_users.id, and + # MySQL rejects creating the new table when FK column types differ. + created_by_id: Mapped[int] = mapped_column(Integer, index=True) + expires_at: Mapped[datetime] = mapped_column(DateTime, index=True) + uploaded_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) diff --git a/backend/data/prepush_check.db b/backend/data/prepush_check.db new file mode 100644 index 0000000..eba2606 Binary files /dev/null and b/backend/data/prepush_check.db differ diff --git a/backend/data/prepush_check3.db b/backend/data/prepush_check3.db new file mode 100644 index 0000000..18b3266 Binary files /dev/null and b/backend/data/prepush_check3.db differ diff --git a/backend/data/prepush_check4.db b/backend/data/prepush_check4.db new file mode 100644 index 0000000..b7b13b3 Binary files /dev/null and b/backend/data/prepush_check4.db differ diff --git a/backend/data/prepush_check5.db b/backend/data/prepush_check5.db new file mode 100644 index 0000000..d3bba66 Binary files /dev/null and b/backend/data/prepush_check5.db differ diff --git a/backend/data/prepush_targeted2.db b/backend/data/prepush_targeted2.db new file mode 100644 index 0000000..fdc5b22 Binary files /dev/null and b/backend/data/prepush_targeted2.db differ diff --git a/backend/data/test-completed-count-focused.db b/backend/data/test-completed-count-focused.db new file mode 100644 index 0000000..b392c47 Binary files /dev/null and b/backend/data/test-completed-count-focused.db differ diff --git a/backend/data/test-completed-count.db b/backend/data/test-completed-count.db new file mode 100644 index 0000000..332ce0d Binary files /dev/null and b/backend/data/test-completed-count.db differ diff --git a/backend/data/test-local-qr.db b/backend/data/test-local-qr.db new file mode 100644 index 0000000..287d2cc Binary files /dev/null and b/backend/data/test-local-qr.db differ diff --git a/backend/data/test-publish-status-2.db b/backend/data/test-publish-status-2.db new file mode 100644 index 0000000..09aecdf Binary files /dev/null and b/backend/data/test-publish-status-2.db differ diff --git a/backend/data/test-publish-status.db b/backend/data/test-publish-status.db new file mode 100644 index 0000000..1660d01 Binary files /dev/null and b/backend/data/test-publish-status.db differ diff --git a/backend/data/test-qr-final.db b/backend/data/test-qr-final.db new file mode 100644 index 0000000..387bb26 Binary files /dev/null and b/backend/data/test-qr-final.db differ diff --git a/backend/data/test-qr-fresh-2.db b/backend/data/test-qr-fresh-2.db new file mode 100644 index 0000000..a51f26c Binary files /dev/null and b/backend/data/test-qr-fresh-2.db differ diff --git a/backend/data/test-qr-fresh-3.db b/backend/data/test-qr-fresh-3.db new file mode 100644 index 0000000..9216e7b Binary files /dev/null and b/backend/data/test-qr-fresh-3.db differ diff --git a/backend/data/test-qr-fresh-4.db b/backend/data/test-qr-fresh-4.db new file mode 100644 index 0000000..da86d08 Binary files /dev/null and b/backend/data/test-qr-fresh-4.db differ diff --git a/backend/data/test-qr-fresh-5.db b/backend/data/test-qr-fresh-5.db new file mode 100644 index 0000000..1ebff01 Binary files /dev/null and b/backend/data/test-qr-fresh-5.db differ diff --git a/backend/data/test-qr-fresh.db b/backend/data/test-qr-fresh.db new file mode 100644 index 0000000..8679f10 Binary files /dev/null and b/backend/data/test-qr-fresh.db differ diff --git a/backend/data/test_ask_features.db b/backend/data/test_ask_features.db new file mode 100644 index 0000000..2e2db80 Binary files /dev/null and b/backend/data/test_ask_features.db differ diff --git a/backend/schema.sql b/backend/schema.sql index 1838697..ba958d8 100644 --- a/backend/schema.sql +++ b/backend/schema.sql @@ -33,5 +33,5 @@ CREATE TABLE IF NOT EXISTS admin_login_logs ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 提问任务归属运营账号;历史数据允许为空,超级管理员仍可查看全部任务。 --- 新环境建表时请将 owner_id 一并加入 ask_tasks;已有环境由应用启动时自动补列。 +-- 新环境建表时请将 owner_id、shop_name 一并加入 ask_tasks;已有环境由应用启动时自动补列。 -- public_token 是对外分享链接标识,不应使用 task_no 拼接用户链接。 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index bdd8e6e..2593204 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -1,10 +1,18 @@ +import base64 + from fastapi.testclient import TestClient from uuid import uuid4 +import app.main as main_module from app.main import app from app.config import settings +def fake_ask_questions(payload, count, reference_text): + assert "shop_name" not in payload + return [f"本地测试问题{i + 1}?" for i in range(count)] + + def test_admin_login_me_and_logout(): with TestClient(app) as client: login = client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"}) @@ -162,15 +170,24 @@ def test_answer_rejection_reason_syncs_to_public_user(): settings.llm_api_key = previous_key -def test_ask_task_publish_appears_on_task_board(): +def test_ask_task_publish_appears_on_task_board(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 missing_link = client.post("/api/admin/ask-tasks/generate", json={"product_name": "测试商品"}) assert missing_link.status_code == 400 - generated = client.post("/api/admin/ask-tasks/generate", json={"product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3}) + missing_product_access = client.post( + "/api/admin/ask-tasks/generate", + json={"shop_name": "测试店铺", "product_name": "测试商品", "question_count": 3}, + ) + assert missing_product_access.status_code == 400 + missing_shop = client.post("/api/admin/ask-tasks/generate", json={"product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3}) + assert missing_shop.status_code == 400 + generated = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "测试店铺", "product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3}) assert generated.status_code == 200 task = generated.json()["data"] assert task["public_token"] is None + assert task["shop_name"] == "测试店铺" published = client.post(f"/api/admin/ask-tasks/{task['id']}/publish") assert published.status_code == 200 public_token = published.json()["data"]["public_token"] @@ -183,14 +200,20 @@ def test_ask_task_publish_appears_on_task_board(): assert created["task_type_label"] == "提问任务" assert created["qa_count"] == 3 assert created["public_token"] == public_token + assert created["shop_name"] == "测试店铺" + detail = client.get(created["detail_endpoint"]) + assert detail.status_code == 200 + assert detail.json()["data"]["shop_name"] == "测试店铺" -def test_ask_task_generation_honors_question_count(): +def test_ask_task_generation_honors_question_count(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/count-test", "product_name": "数量测试商品", "question_count": 25, @@ -202,12 +225,99 @@ def test_ask_task_generation_honors_question_count(): assert len({item["question"] for item in questions}) == 25 +def test_ask_task_accepts_qr_image_without_token_and_excludes_it_from_ai(monkeypatch): + monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions) + qr_data_url = "data:image/png;base64," + base64.b64encode(b"local-qr-image").decode() + 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": "", + "product_qr_image": qr_data_url, + "product_qr_image_name": "商品二维码.png", + "product_name": "二维码测试商品", + "question_count": 2, + }, + ) + assert generated.status_code == 200 + task = generated.json()["data"] + assert task["product_link"] == "" + assert task["product_qr"]["content_type"] == "image/png" + assert task["product_qr"]["name"] == "商品二维码.png" + assert task["product_qr"]["data_url"] == qr_data_url + 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"] + 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 + assert detail.json()["data"]["product_qr"]["data_url"] == qr_data_url + public_token = published.json()["data"]["public_token"] + assert public_token + + +def test_ask_task_regeneration_appends_to_existing_questions(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 + first = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "追加测试店铺", "product_link": "https://item.example.com/append", "product_name": "追加测试商品", "question_count": 2}) + assert first.status_code == 200 + task = first.json()["data"] + assert len(task["selected_questions"]) == 2 + second = client.post("/api/admin/ask-tasks/generate", json={"task_id": task["id"], "shop_name": "追加测试店铺", "product_link": "https://item.example.com/append", "product_name": "追加测试商品", "question_count": 2}) + assert second.status_code == 200 + questions = second.json()["data"]["selected_questions"] + assert len(questions) == 4 + assert [item["sequence_no"] for item in questions] == [1, 2, 3, 4] + + +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: + 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-IDEMPOTENT", + "product_name": "去重测试商品", + "question_count": 1, + }, + ) + assert created.status_code == 200 + task_id = created.json()["data"]["id"] + first = client.post(f"/api/admin/ask-tasks/{task_id}/questions", json={"question": "问答库重复问题?", "source": "问答库"}) + second = client.post(f"/api/admin/ask-tasks/{task_id}/questions", json={"question": "问答库重复问题?", "source": "问答库"}) + assert first.status_code == 200 and second.status_code == 200 + assert first.json()["data"]["id"] == second.json()["data"]["id"] + assert second.json()["data"]["source"] == "问答库" + question_id = first.json()["data"]["id"] + removed = client.delete(f"/api/admin/ask-tasks/{task_id}/questions/{question_id}") + assert removed.status_code == 200 + refreshed = client.post( + "/api/admin/ask-tasks/generate", + json={ + "task_id": task_id, + "shop_name": "去重测试店铺", + "product_link": "TOKEN-IDEMPOTENT", + "product_name": "去重测试商品", + "question_count": 1, + }, + ) + assert refreshed.status_code == 200 + assert all(item["question"] != "问答库重复问题?" for item in refreshed.json()["data"]["selected_questions"]) + + 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 generated = client.post( "/api/admin/ask-tasks/generate", json={ + "shop_name": "公共提问测试店铺", "product_link": "https://item.example.com/public-ask", "product_name": "公共提问测试商品", "question_count": 8, @@ -275,6 +385,7 @@ def test_public_ask_user_gets_stable_unique_five_question_assignment(): assert payout["status_label"] == "待返款" assert payout["task_type"] == "ask" assert payout["qa_count"] == 1 + assert payout["completed_count"] == 0 payout_detail = client.get(payout["detail_endpoint"]) assert payout_detail.status_code == 200 assert len(payout_detail.json()["data"]["question_screenshots"]) == 1 @@ -291,6 +402,7 @@ def test_public_ask_user_gets_stable_unique_five_question_assignment(): and item["id"] == review["assignment_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) @@ -366,6 +478,7 @@ def test_public_ask_questions_are_unique_across_users_and_eventually_exhausted() generated = client.post( "/api/admin/ask-tasks/generate", json={ + "shop_name": "唯一分配测试店铺", "product_link": "https://item.example.com/unique-allocation", "product_name": "跨用户唯一分配测试商品", "question_count": 10, diff --git a/data/local_login_test.db b/data/local_login_test.db index 71017ec..3afddc9 100644 Binary files a/data/local_login_test.db and b/data/local_login_test.db differ diff --git a/frontend/src/AskUserPortal.jsx b/frontend/src/AskUserPortal.jsx index ceeb99f..97298d7 100644 --- a/frontend/src/AskUserPortal.jsx +++ b/frontend/src/AskUserPortal.jsx @@ -418,17 +418,27 @@ function ConfirmStage({

{assignment.product_name}

- + {(assignment.product_link || assignment.product_qr) && ( +
+ {assignment.product_link && ( + + )} + {assignment.product_qr?.data_url && ( +
+ 商品二维码 +
商品二维码
+
+ )} +
+ )}
diff --git a/frontend/src/CreateAskTasksPage.jsx b/frontend/src/CreateAskTasksPage.jsx index 0bb6191..f2c2986 100644 --- a/frontend/src/CreateAskTasksPage.jsx +++ b/frontend/src/CreateAskTasksPage.jsx @@ -1,10 +1,13 @@ import React, { useEffect, useRef, useState } from "react"; -import { Check, Link2, Plus, Sparkles } from "lucide-react"; +import { Check, Link2, Plus, Sparkles, X } from "lucide-react"; import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js"; -import { readPageCache, writePageCache } from "./pageCache.js"; +import { clearPageCache, readPageCache, writePageCache } from "./pageCache.js"; const blankForm = { + shop_name: "", product_link: "", + product_qr_image: "", + product_qr_image_name: "", product_name: "", category: "", benefits: "", @@ -17,6 +20,26 @@ const blankForm = { claim_questions_per_user: "", }; +const ADDED_QUESTION_SOURCES = new Set(["自主添加", "问答库"]); + +function questionKey(value) { + return String(value || "").trim().replace(/\s+/g, " "); +} + +function dedupeQuestions(items = []) { + const seen = new Set(); + return items.filter((item) => { + const key = questionKey(item?.question); + if (!key || seen.has(key)) return false; + seen.add(key); + return true; + }); +} + +function isAddedQuestion(item) { + return ADDED_QUESTION_SOURCES.has(item?.source) || String(item?.id || "").startsWith("new-"); +} + async function readReferenceFile(file, requestApi) { if (!file) return ""; if (/\.xlsx$/i.test(file.name)) { @@ -28,21 +51,49 @@ const blankForm = { return (await file.text()).slice(0, 20000); } -export default function CreateAskTasksPage({ api }) { +export default function CreateAskTasksPage({ api, librarySelection = [], onLibrarySelectionConsumed }) { const cached = readPageCache("create-ask-task", {}); const [form, setForm] = useState({ ...blankForm, ...(cached.form || {}) }); - const [task, setTask] = useState(cached.task || null); - const [questions, setQuestions] = useState(cached.questions || []); + // Only a task explicitly marked as a draft can be resumed. Older cached + // payloads did not include `status`, so treating them as editable could + // resend a published task id and leave the operator stuck on this page. + const resumableCachedTask = cached.task?.status === "草稿" ? cached.task : null; + const [task, setTask] = useState(resumableCachedTask); + const [questions, setQuestions] = useState(() => + resumableCachedTask ? dedupeQuestions(cached.questions || []) : [], + ); const [editing, setEditing] = useState(null); const [busy, setBusy] = useState(false); const [error, setError] = useState(""); const [referenceFile, setReferenceFile] = useState(null); const referenceInputRef = useRef(null); + const productQrInputRef = useRef(null); const [referenceFileName, setReferenceFileName] = useState(cached.referenceFileName || ""); const [referenceQa, setReferenceQa] = useState(cached.referenceQa || ""); + const [addDialog, setAddDialog] = useState(null); + const processingLibrarySelection = useRef(false); + const consumedLibrarySelectionKey = useRef(""); useEffect(() => { writePageCache("create-ask-task", { form, task, questions, referenceQa, referenceFileName }); }, [form, task, questions, referenceQa, referenceFileName]); + useEffect(() => { + if (!librarySelection.length) { + consumedLibrarySelectionKey.current = ""; + return; + } + const selectionKey = librarySelection + .map((item) => `${item.id}:${questionKey(item.question)}`) + .join("|"); + if (consumedLibrarySelectionKey.current === selectionKey || processingLibrarySelection.current) return; + // Mark before the async requests start. This also makes the effect safe + // under React StrictMode, which intentionally re-runs effects in dev. + consumedLibrarySelectionKey.current = selectionKey; + processingLibrarySelection.current = true; + void addLibraryQuestions(librarySelection).finally(() => { + processingLibrarySelection.current = false; + onLibrarySelectionConsumed?.(); + }); + }, [librarySelection, onLibrarySelectionConsumed]); const update = (key, value) => setForm((current) => ({ ...current, [key]: value })); async function selectReferenceFile(file) { @@ -66,7 +117,50 @@ export default function CreateAskTasksPage({ api }) { if (referenceInputRef.current) referenceInputRef.current.value = ""; } + function selectProductQr(file) { + setError(""); + if (!file) return; + const allowed = new Set(["image/jpeg", "image/png", "image/webp"]); + if (!allowed.has(file.type)) { + setError("商品二维码仅支持 JPG、PNG 或 WEBP 图片"); + if (productQrInputRef.current) productQrInputRef.current.value = ""; + return; + } + if (file.size > 5 * 1024 * 1024) { + setError("商品二维码图片不能超过 5MB"); + if (productQrInputRef.current) productQrInputRef.current.value = ""; + return; + } + const reader = new FileReader(); + reader.onload = () => { + setForm((current) => ({ + ...current, + product_qr_image: String(reader.result || ""), + product_qr_image_name: file.name, + })); + }; + reader.onerror = () => setError("商品二维码图片读取失败,请重试"); + reader.readAsDataURL(file); + } + + function clearProductQr() { + setForm((current) => ({ + ...current, + product_qr_image: "", + product_qr_image_name: "", + })); + if (productQrInputRef.current) productQrInputRef.current.value = ""; + } + async function generate() { + if (!form.shop_name.trim()) { + setError("请填写店铺名称"); + return; + } + if (!form.product_link.trim() && !form.product_qr_image) { + setError("请填写商品淘口令或上传商品二维码"); + return; + } const intentError = analyzeProductIntent([ form.product_name, form.category, @@ -85,26 +179,46 @@ export default function CreateAskTasksPage({ api }) { setBusy(true); setError(""); try { + // A stale browser cache may contain a task that was already published + // before the page received the publish response. Never send that id to + // the draft-generation endpoint; start a fresh draft instead. + const draftTaskId = task?.status === "草稿" ? task.id : null; + if (task && !draftTaskId) { + setTask(null); + setQuestions([]); + } const reference_qa = referenceFile ? await readReferenceFile(referenceFile, api) : referenceQa; setReferenceQa(reference_qa); + const pendingQuestions = dedupeQuestions(questions.filter((item) => String(item.id).startsWith("new-")).map((item) => ({ question: item.question, source: item.source || "自主添加" })).filter((item) => item.question)); const result = await api("/api/admin/ask-tasks/generate", { method: "POST", body: JSON.stringify({ ...form, question_count: Number(form.question_count), - ...(task ? { task_id: task.id } : {}), + ...(draftTaskId ? { task_id: draftTaskId } : {}), reference_qa, }), }); setTask(result.data); - setQuestions( - result.data.selected_questions.map((item) => ({ - ...item, - selected: true, - })), - ); + const generatedQuestions = result.data.selected_questions.map((item) => ({ ...item, selected: true })); + if (!draftTaskId && pendingQuestions.length) { + const added = []; + for (const item of pendingQuestions) { + added.push(await api(`/api/admin/ask-tasks/${result.data.id}/questions`, { method: "POST", body: JSON.stringify({ question: item.question, source: item.source }) })); + } + generatedQuestions.push(...added.map((item, index) => ({ ...item.data, source: pendingQuestions[index].source }))); + } + const priorSourceByQuestion = new Map(questions.map((item) => [item.question, item.source])); + setQuestions(dedupeQuestions(generatedQuestions.map((item) => ({ ...item, source: item.source || priorSourceByQuestion.get(item.question) || "AI生成" })))); } catch (err) { - setError(err.message || "生成失败,请检查参考文件和大模型配置后重试"); + if (String(err.message || "").includes("已发布任务不能重新生成")) { + clearPageCache("create-ask-task"); + setTask(null); + setQuestions([]); + setError("这条任务已经发布,已为你清空旧任务;请再次点击“生成提问”创建新的测试任务。"); + } else { + setError(err.message || "生成失败,请检查参考文件和大模型配置后重试"); + } } finally { setBusy(false); } @@ -139,20 +253,93 @@ export default function CreateAskTasksPage({ api }) { body: JSON.stringify({ selected }), }); } - async function addQuestion() { - const value = window.prompt("请输入提问内容"); - if (!value?.trim()) return; - if (!task) - return setQuestions((current) => [ - ...current, - { id: `new-${Date.now()}`, question: value.trim(), selected: true }, - ]); + function addQuestion() { + setAddDialog("choose"); + } + + async function removeQuestion(index) { + const question = questions[index]; + if (!question || !isAddedQuestion(question)) return; + setBusy(true); + setError(""); try { - const result = await api(`/api/admin/ask-tasks/${task.id}/questions`, { - method: "POST", - body: JSON.stringify({ question: value.trim() }), - }); - setQuestions((current) => [...current, result.data]); + if (task && !String(question.id).startsWith("new-")) { + await api(`/api/admin/ask-tasks/${task.id}/questions/${question.id}`, { method: "DELETE" }); + } + setQuestions((current) => current.filter((_, itemIndex) => itemIndex !== index)); + if (editing === index) setEditing(null); + } catch (err) { + setError(err.message || "移除提问失败"); + } finally { + setBusy(false); + } + } + + async function clearAddedQuestions() { + const addedQuestions = questions.filter(isAddedQuestion); + if (!addedQuestions.length) return; + setBusy(true); + setError(""); + try { + if (task) { + for (const question of addedQuestions) { + if (!String(question.id).startsWith("new-")) { + await api(`/api/admin/ask-tasks/${task.id}/questions/${question.id}`, { method: "DELETE" }); + } + } + } + setQuestions((current) => current.filter((question) => !isAddedQuestion(question))); + setEditing(null); + } catch (err) { + setError(err.message || "清除新增提问失败"); + } finally { + setBusy(false); + } + } + async function addManualQuestion(value) { + const text = value.trim(); + if (!text) return; + if (!task) { + setQuestions((current) => dedupeQuestions([...current, { id: `new-${Date.now()}`, question: text, selected: true, source: "自主添加" }])); + setAddDialog(null); + return; + } + try { + const result = await api(`/api/admin/ask-tasks/${task.id}/questions`, { method: "POST", body: JSON.stringify({ question: text, source: "自主添加" }) }); + setQuestions((current) => dedupeQuestions([...current, { ...result.data, source: result.data.source || "自主添加" }])); + setAddDialog(null); + } catch (err) { + setError(err.message); + } + } + function openLibrary() { + setAddDialog(null); + window.dispatchEvent(new CustomEvent("open-qa-library-picker")); + } + async function addLibraryQuestions(items = []) { + const existingTexts = new Set(questions.map((item) => questionKey(item.question))); + const seenSelection = new Set(); + const selected = items.filter((item) => { + const text = questionKey(item.question); + if (!text || existingTexts.has(text) || seenSelection.has(text)) return false; + seenSelection.add(text); + return true; + }); + if (!selected.length) return; + try { + if (!task) { + setQuestions((current) => dedupeQuestions([...current, ...selected.map((item, index) => ({ id: `new-library-${item.id}-${Date.now()}-${index}`, question: item.question, selected: true, source: "问答库" }))])); + } else { + const added = []; + for (const item of selected) { + added.push(await api(`/api/admin/ask-tasks/${task.id}/questions`, { + method: "POST", + body: JSON.stringify({ question: item.question, source: "问答库" }), + })); + } + setQuestions((current) => dedupeQuestions([...current, ...added.map((result) => ({ ...result.data, source: result.data.source || "问答库" }))])); + } + setAddDialog(null); } catch (err) { setError(err.message); } @@ -165,7 +352,11 @@ export default function CreateAskTasksPage({ api }) { const result = await api(`/api/admin/ask-tasks/${task.id}/publish`, { method: "POST", }); - setTask(result.data); + // A successful response from this endpoint is authoritative even when + // an older backend build omits the raw workflow state from its payload. + // Keep the state explicit so the success/link view renders on the first + // click instead of waiting for a second request to return 409. + setTask({ ...result.data, status: result.data.status || "已发布" }); } catch (err) { setError(err.message); } finally { @@ -173,7 +364,7 @@ export default function CreateAskTasksPage({ api }) { } } - if (task?.status === "已发布") + if (task?.status === "已发布" || task?.status_label === "已发布") return ( <>
@@ -190,6 +381,10 @@ export default function CreateAskTasksPage({ api }) {

任务ID:{task.task_id}

+
+ 店铺名称{task.shop_name || form.shop_name || "--"} + 商品名称{task.product_name} +
@@ -212,10 +407,12 @@ export default function CreateAskTasksPage({ api }) { +
+
+ ) : ( + + )} + 淘口令和二维码至少填写一种,也可以两者都填写 +

商品信息

- +
+ + +
@@ -397,41 +629,51 @@ export default function CreateAskTasksPage({ api }) {
-
{questions.length === 0 ? (
@@ -464,20 +706,44 @@ export default function CreateAskTasksPage({ api }) { {question.question} )} + {isAddedQuestion(question) && ( + + )} - 来源:AI生成 · 可编辑后发布 + 来源:{question.source || "AI生成"} · 可编辑后发布
)) )}
+ {addDialog === "choose" && ( +
event.target === event.currentTarget && setAddDialog(null)}> +
+

添加提问

+

选择添加方式,新增提问不会覆盖已生成的问题。

+
+ + +
+
+
+ )} + {addDialog === "manual" && setAddDialog(null)} onAdd={addManualQuestion} />} ); } + +function ManualAskQuestionModal({ onClose, onAdd }) { + const [value, setValue] = useState(""); + return
event.target === event.currentTarget && onClose()}>
{ event.preventDefault(); onAdd(value); }}>

自主添加提问