From 3f15dd0ea3681ceca517977e6eeb9f5693982a18 Mon Sep 17 00:00:00 2001
From: db_huang <18006480240@163.com>
Date: Thu, 17 Sep 2026 10:51:00 +0800
Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E6=89=B9=E9=87=8F=E4=B8=8A?=
=?UTF-8?q?=E4=BC=A0=E5=90=8E=E4=B8=8D=E8=83=BD=E5=8F=91=E5=B8=83=E7=9A=84?=
=?UTF-8?q?BUG=EF=BC=8C=E4=BC=98=E5=8C=96=E5=8F=91=E5=B8=83=E6=97=B6?=
=?UTF-8?q?=E5=A1=AB=E5=86=99=E4=BF=A1=E6=81=AF=EF=BC=8C=E8=AE=A4=E9=A2=86?=
=?UTF-8?q?=E4=B8=8A=E9=99=90=E6=94=B9=E6=88=9020?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
backend/app/main.py | 180 +++++++++++++++++++++++----
backend/tests/test_auth.py | 81 ++++++++++--
frontend/src/AnswerUserPortal.jsx | 10 +-
frontend/src/AskUserPortal.jsx | 8 +-
frontend/src/CreateAskTasksPage.jsx | 183 ++++++++++++++--------------
frontend/src/admin.css | 27 ++++
frontend/src/main.jsx | 4 +-
frontend/src/productIntent.js | 4 +-
8 files changed, 363 insertions(+), 134 deletions(-)
diff --git a/backend/app/main.py b/backend/app/main.py
index 80dfd63..e0f9198 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -44,6 +44,13 @@ app.add_middleware(
# confirms a selection, the upload screen has a separate thirty-minute window.
ASK_SELECTION_TIMEOUT = timedelta(minutes=1)
ASK_UPLOAD_TIMEOUT = timedelta(minutes=30)
+# Bumping this prefix invalidates every previously issued public-user token.
+# Only Taobao-ID login can issue a token accepted by the current user portals.
+PUBLIC_USER_SESSION_PREFIX = "tb2_"
+
+
+def _new_public_user_token() -> str:
+ return f"{PUBLIC_USER_SESSION_PREFIX}{uuid.uuid4().hex}"
def _release_expired_ask_assignments(db: Session, task: AskTask | None = None) -> int:
@@ -444,9 +451,11 @@ def _public_answer_task(task: dict) -> dict:
def _answer_user_from_token(token: str, db: Session) -> AnswerUser:
+ if not str(token or "").startswith(PUBLIC_USER_SESSION_PREFIX):
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户登录已失效,请使用真实淘宝ID重新登录")
user = db.scalar(select(AnswerUser).where(AnswerUser.auth_token == token))
- if not user:
- raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="回答用户登录已失效")
+ if not user or not user.taobao_id:
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户登录已失效,请使用真实淘宝ID重新登录")
db.expunge(user)
db.rollback()
return user
@@ -523,13 +532,13 @@ def _taobao_login(payload: dict, db: Session) -> AnswerUser:
public_id=f"U{uuid.uuid4().int % 10_000_000:07d}",
phone=legacy_phone_key,
taobao_id=taobao_id,
- auth_token=uuid.uuid4().hex,
+ auth_token=_new_public_user_token(),
)
db.add(user)
else:
user.last_login_at = datetime.utcnow()
- if not user.auth_token:
- user.auth_token = uuid.uuid4().hex
+ if not user.auth_token.startswith(PUBLIC_USER_SESSION_PREFIX):
+ user.auth_token = _new_public_user_token()
db.commit(); db.refresh(user)
return user
@@ -2408,13 +2417,8 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db
payload.get("product_qr_image"), payload.get("product_qr_image_name")
)
product_name = str(payload.get("product_name", "")).strip()
- 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")])
if intent_error:
raise HTTPException(status_code=400, detail=intent_error)
@@ -2427,16 +2431,10 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db
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
+ # Claim limits belong to the publish workflow and must never prevent AI
+ # generation. Drafts keep the neutral default until the publish dialog is
+ # confirmed.
+ 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
@@ -2601,15 +2599,149 @@ def delete_ask_question(task_id: int, question_id: int, user: AdminUser = Depend
return {"success": True, "data": {"id": question_id}}
+def _ask_publish_fields(payload: dict) -> tuple[str, str, bytes | None, str | None, str | None, int]:
+ shop_name = str(payload.get("shop_name", "")).strip()
+ if not shop_name:
+ raise HTTPException(status_code=400, detail="请填写店铺名称")
+ if len(shop_name) > 255:
+ raise HTTPException(status_code=400, detail="店铺名称不能超过255个字符")
+ product_link = str(payload.get("product_link", "")).strip()
+ qr_data, qr_content_type, qr_name = _decode_product_qr_image(
+ payload.get("product_qr_image"), payload.get("product_qr_image_name")
+ )
+ product_token_error = _product_token_error(product_link, qr_data)
+ if product_token_error:
+ raise HTTPException(status_code=400, detail=product_token_error)
+ raw_claim_limit = payload.get("claim_questions_per_user", "")
+ if not str(raw_claim_limit).strip():
+ raise HTTPException(status_code=400, detail="请填写每个用户可领取的问题数量")
+ try:
+ claim_limit = int(raw_claim_limit)
+ except (TypeError, ValueError):
+ raise HTTPException(status_code=400, detail="每个用户可领取的问题数量必须是 2-20 的整数")
+ if claim_limit < 2 or claim_limit > 20:
+ raise HTTPException(status_code=400, detail="每个用户可领取的问题数量必须在 2-20 条之间")
+ return shop_name, product_link, qr_data, qr_content_type, qr_name, claim_limit
+
+
+def _apply_ask_publish_fields(task: AskTask, payload: dict) -> None:
+ shop_name, product_link, qr_data, qr_content_type, qr_name, claim_limit = _ask_publish_fields(payload)
+ task.shop_name = shop_name
+ task.product_link = product_link
+ task.product_qr_image_data = qr_data
+ task.product_qr_image_content_type = qr_content_type
+ task.product_qr_image_name = qr_name
+ task.claim_questions_per_user = claim_limit
+
+
+@app.post("/api/admin/ask-tasks/publish")
+def create_and_publish_ask_task(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+ """Publish the questions currently visible in the editor, with or without an AI draft."""
+ raw_questions = payload.get("questions")
+ if not isinstance(raw_questions, list):
+ raise HTTPException(status_code=400, detail="请至少选择一个提问")
+ question_rows = []
+ seen = set()
+ for raw in raw_questions:
+ item = raw if isinstance(raw, dict) else {"question": raw}
+ question_text = re.sub(r"\s+", " ", str(item.get("question", "")).strip())
+ if not question_text or len(question_text) > 500 or question_text in seen:
+ continue
+ seen.add(question_text)
+ source = str(item.get("source", "自主添加")).strip()
+ if source not in {"自主添加", "问答库", "批量导入", "AI生成"}:
+ source = "自主添加"
+ question_rows.append((item, question_text, source))
+ if not question_rows:
+ raise HTTPException(status_code=400, detail="请至少选择一个提问")
+ if len(question_rows) > 500:
+ raise HTTPException(status_code=400, detail="单个任务最多发布 500 条问题")
+
+ raw_task_id = payload.get("task_id")
+ try:
+ task = db.get(AskTask, int(raw_task_id)) if raw_task_id else None
+ except (TypeError, ValueError):
+ task = None
+ if raw_task_id and not task:
+ raise HTTPException(status_code=404, detail="提问任务草稿不存在")
+ if task:
+ _require_ask_task_access(task, user)
+ if task.status != "草稿":
+ raise HTTPException(status_code=409, detail="任务已经发布")
+ 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,
+ shop_name="",
+ product_link="",
+ product_name=str(payload.get("product_name", "")).strip() 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=5,
+ status="草稿",
+ )
+ db.add(task)
+ db.flush()
+
+ _apply_ask_publish_fields(task, payload)
+ if task.questions:
+ by_id = {question.id: question for question in task.questions}
+ selected_ids = set()
+ for item, question_text, _source in question_rows:
+ try:
+ question_id = int(item.get("id"))
+ except (TypeError, ValueError):
+ continue
+ question = by_id.get(question_id)
+ if question:
+ question.question_text = question_text
+ question.selected = True
+ selected_ids.add(question.id)
+ for question in task.questions:
+ question.selected = question.id in selected_ids
+ existing_by_text = {question.question_text: question for question in task.questions}
+ next_sequence = max((question.sequence_no for question in task.questions), default=0) + 1
+ for _item, question_text, source in question_rows:
+ existing_question = existing_by_text.get(question_text)
+ if existing_question:
+ existing_question.selected = True
+ continue
+ question = AskQuestion(task_id=task.id, sequence_no=next_sequence, question_text=question_text, source=source, selected=True)
+ db.add(question)
+ existing_by_text[question_text] = question
+ next_sequence += 1
+ else:
+ db.add_all([
+ AskQuestion(task_id=task.id, sequence_no=index, question_text=question_text, source=source, selected=True)
+ for index, (_item, question_text, source) in enumerate(question_rows, start=1)
+ ])
+ if not task.public_token:
+ task.public_token = _new_ask_public_token(db)
+ task.status = "已发布"
+ db.commit()
+ db.refresh(task)
+ return {"success": True, "data": _ask_task_data(task)}
+
+
@app.post("/api/admin/ask-tasks/{task_id}/publish")
-def publish_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
+def publish_ask_task(task_id: int, payload: dict = Body(default={}), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
task = db.get(AskTask, task_id)
_require_ask_task_access(task, user)
if task.status != "草稿": raise HTTPException(status_code=409, detail="任务已经发布")
_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 payload:
+ _apply_ask_publish_fields(task, payload)
+ else:
+ 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 task.shop_name:
+ raise HTTPException(status_code=400, detail="请填写店铺名称")
if not any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问")
if not task.public_token:
task.public_token = _new_ask_public_token(db)
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
index b00775d..a738ad2 100644
--- a/backend/tests/test_auth.py
+++ b/backend/tests/test_auth.py
@@ -164,6 +164,7 @@ def test_taobao_id_login_is_shared_by_both_user_portals():
assert answer_session["taobao_id"] == taobao_id
assert answer_session["account"] == taobao_id
assert answer_session["phone"] == ""
+ assert answer_session["token"].startswith("tb2_")
ask_login = client.post(
"/api/ask/auth/taobao-login",
@@ -179,6 +180,32 @@ def test_taobao_id_login_is_shared_by_both_user_portals():
assert invalid.status_code == 400
+def test_legacy_public_user_sessions_are_invalidated_and_rotated_on_taobao_login():
+ taobao_id = f"tb{uuid4().hex[:14]}"
+ legacy_token = uuid4().hex
+ with main_module.SessionLocal() as db:
+ user = main_module.AnswerUser(
+ public_id=f"U{uuid4().int % 10_000_000:07d}",
+ phone=f"tb_{uuid4().hex[:16]}",
+ taobao_id=taobao_id,
+ auth_token=legacy_token,
+ )
+ db.add(user)
+ db.commit()
+
+ with TestClient(app) as client:
+ expired = client.get(f"/api/ask/me/assignments?token={legacy_token}")
+ assert expired.status_code == 401
+ assert "淘宝ID重新登录" in expired.json()["detail"]
+
+ renewed = client.post("/api/ask/auth/taobao-login", json={"taobao_id": taobao_id})
+ assert renewed.status_code == 200
+ renewed_token = renewed.json()["data"]["token"]
+ assert renewed_token.startswith("tb2_")
+ assert renewed_token != legacy_token
+ assert client.get(f"/api/ask/me/assignments?token={renewed_token}").status_code == 200
+
+
def test_answer_task_publish_remembers_operator_shop_name():
unique = uuid4().hex[:10]
remembered_shop = f"记忆店铺-{unique}"
@@ -508,15 +535,12 @@ 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
- missing_product_access = client.post(
+ generation_without_publish_fields = client.post(
"/api/admin/ask-tasks/generate",
- json={"shop_name": "测试店铺", "product_name": "测试商品", "question_count": 3},
+ json={"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
+ assert generation_without_publish_fields.status_code == 200
+ assert generation_without_publish_fields.json()["data"]["status"] == "草稿"
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"]
@@ -540,6 +564,49 @@ def test_ask_task_publish_appears_on_task_board(monkeypatch):
assert detail.json()["data"]["shop_name"] == "测试店铺"
+def test_imported_questions_can_publish_without_ai_generation():
+ with TestClient(app) as client:
+ assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
+ published = client.post(
+ "/api/admin/ask-tasks/publish",
+ json={
+ "shop_name": "直接发布测试店铺",
+ "product_link": "TOKEN-DIRECT-PUBLISH",
+ "product_name": "直接发布测试商品",
+ "claim_questions_per_user": 20,
+ "questions": [
+ {"id": "new-import-1", "question": "批量导入问题一?", "source": "批量导入"},
+ {"id": "new-manual-2", "question": "自主添加问题二?", "source": "自主添加"},
+ ],
+ },
+ )
+ assert published.status_code == 200
+ data = published.json()["data"]
+ assert data["status"] == "已发布"
+ assert data["shop_name"] == "直接发布测试店铺"
+ assert data["claim_questions_per_user"] == 20
+ assert [item["question"] for item in data["selected_questions"]] == ["批量导入问题一?", "自主添加问题二?"]
+ assert [item["source"] for item in data["selected_questions"]] == ["批量导入", "自主添加"]
+
+ missing_fields = client.post(
+ "/api/admin/ask-tasks/publish",
+ json={"questions": [{"question": "不能缺少发布信息?", "source": "批量导入"}]},
+ )
+ assert missing_fields.status_code == 400
+
+ over_limit = client.post(
+ "/api/admin/ask-tasks/publish",
+ json={
+ "shop_name": "领取上限测试店铺",
+ "product_link": "TOKEN-CLAIM-LIMIT",
+ "claim_questions_per_user": 21,
+ "questions": [{"question": "超过领取上限?", "source": "自主添加"}],
+ },
+ )
+ assert over_limit.status_code == 400
+ assert over_limit.json()["detail"] == "每个用户可领取的问题数量必须在 2-20 条之间"
+
+
def test_ask_task_generation_honors_question_count(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client:
diff --git a/frontend/src/AnswerUserPortal.jsx b/frontend/src/AnswerUserPortal.jsx
index 1bc9168..13a83d1 100644
--- a/frontend/src/AnswerUserPortal.jsx
+++ b/frontend/src/AnswerUserPortal.jsx
@@ -1,7 +1,8 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ClipboardCheck, Clock3, Copy, Eye, FileCheck2, History, ImagePlus, LogOut, MessageCircle, ReceiptText, RefreshCw, ShieldCheck } from 'lucide-react'
-const SESSION_KEY = 'answer_user_taobao_session'
+const SESSION_KEY = 'answer_user_taobao_session_v2'
+const LEGACY_SESSION_KEYS = ['answer_user_taobao_session', 'answer_user_session']
const STATUS_TEXT = { '待筛选': '截图审核中', '识别失败': '运营处理中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '已完成': '已返款', '未通过': '审核不通过', '已撤销': '任务已撤销' }
const accountName = session => session?.taobao_id || session?.account || session?.user_id || ''
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
@@ -29,7 +30,7 @@ function Login({ api, onLogin, linkToken }) {
try { const result = await api('/api/answer/auth/taobao-login', { method: 'POST', body: JSON.stringify({ taobao_id: taobaoId.trim(), owner_token: linkToken || undefined }) }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data) }
catch (err) { setError(err.message) } finally { setBusy(false) }
}
- return