diff --git a/backend/app/llm.py b/backend/app/llm.py
index c25b722..581089b 100644
--- a/backend/app/llm.py
+++ b/backend/app/llm.py
@@ -13,7 +13,7 @@ from PIL import Image, ImageOps
from fastapi import HTTPException, status
from .config import settings
-from .schemas import QAGenerateRequest
+from .schemas import QABulkAnswerRequest, QAGenerateRequest
logger = logging.getLogger(__name__)
@@ -119,6 +119,102 @@ def generate_qa(payload: QAGenerateRequest) -> list[dict]:
return items[:target]
+def _bulk_answers_prompt(payload: QABulkAnswerRequest, questions: list[str]) -> str:
+ style = "带真实体验的轻度种草语气" if payload.seed_style else "客观自然,不刻意种草"
+ question_lines = "\n".join(f"{index}. {question}" for index, question in enumerate(questions, start=1))
+ return f"""你是电商问大家回答助手。请严格按照给定问题逐条生成参考回答,不要新增、删除、合并或改写问题。
+商品名称:{payload.product_name or '未提供'}
+商品品类:{payload.category or '未提供'}
+商品功效:{payload.benefits or '未提供'}
+商品卖点:{payload.selling_points or '未提供'}
+语言风格:{payload.language_style or '客观自然'}
+目标人群:{payload.target_group or '普通消费者'}
+使用场景:{payload.usage_scene or '日常使用'}
+购买渠道:{payload.purchase_channel or '电商平台'}
+回答要求:{payload.answer_requirement or '直接回应问题,避免夸大功效'}
+回答语气:{style}
+
+问题列表:
+{question_lines}
+
+每个问题生成 {payload.answers_per_question} 条不同角度的回答。回答尽量不超过50字,不虚构成分、检测、认证和医疗功效。
+只返回严格 JSON,不要 Markdown 或解释。必须按问题序号原顺序返回,格式:
+{{"items":[{{"index":1,"answers":["回答1","回答2"]}}]}}"""
+
+
+def _generate_bulk_answers_batch(payload: QABulkAnswerRequest, questions: list[str]) -> list[list[str]]:
+ 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"
+ request_body = {
+ "model": settings.llm_model,
+ "temperature": 0.75,
+ "messages": [
+ {"role": "system", "content": "你只输出合法 JSON,并严格保持输入问题的数量和顺序。"},
+ {"role": "user", "content": _bulk_answers_prompt(payload, questions)},
+ ],
+ }
+ request = urllib.request.Request(
+ url,
+ data=json.dumps(request_body).encode("utf-8"),
+ headers={"Content-Type": "application/json", "Authorization": f"Bearer {settings.llm_api_key}"},
+ method="POST",
+ )
+ try:
+ with urllib.request.urlopen(request, timeout=settings.llm_timeout_seconds) as response:
+ result = json.loads(response.read().decode("utf-8"))
+ content = str(result["choices"][0]["message"]["content"]).strip()
+ cleaned = re.sub(r"^```(?:json)?\s*|\s*```$", "", content, flags=re.I | re.S).strip()
+ parsed = json.loads(cleaned)
+ raw_items = parsed.get("items") if isinstance(parsed, dict) else parsed
+ if not isinstance(raw_items, list):
+ raise ValueError("items is not a list")
+ by_index: dict[int, list[str]] = {}
+ for raw in raw_items:
+ if not isinstance(raw, dict):
+ continue
+ try:
+ index = int(raw.get("index"))
+ except (TypeError, ValueError):
+ continue
+ answers = raw.get("answers") or []
+ if isinstance(answers, str):
+ answers = [answers]
+ clean_answers = [str(answer).strip() for answer in answers if str(answer).strip()]
+ if len(clean_answers) >= payload.answers_per_question:
+ by_index[index] = clean_answers[:payload.answers_per_question]
+ if any(index not in by_index for index in range(1, len(questions) + 1)):
+ raise ValueError("answers are incomplete")
+ return [by_index[index] for index in range(1, len(questions) + 1)]
+ except urllib.error.HTTPError as exc:
+ logger.error("LLM bulk-answer HTTP error: status=%s url=%s", exc.code, url)
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"大模型服务返回错误(HTTP {exc.code})") from exc
+ except (urllib.error.URLError, TimeoutError, json.JSONDecodeError, KeyError, IndexError, TypeError, ValueError) as exc:
+ logger.exception("LLM bulk-answer generation failed")
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="批量回答生成不完整,请重试") from exc
+
+
+def generate_answers_for_questions(payload: QABulkAnswerRequest, questions: list[str]) -> list[dict]:
+ """Generate answers for exact imported questions without rewriting them."""
+ items: list[dict] = []
+ # Keep each response small enough that the provider can return every
+ # requested answer without truncating the JSON payload.
+ batch_size = max(1, min(10, 30 // payload.answers_per_question))
+ for offset in range(0, len(questions), batch_size):
+ batch = questions[offset:offset + batch_size]
+ answer_groups = _generate_bulk_answers_batch(payload, batch)
+ for question, answers in zip(batch, answer_groups):
+ items.append({
+ "id": f"Q{len(items) + 1:03d}",
+ "question": question,
+ "answers": answers,
+ "source": "批量导入(AI回答)",
+ "status": "待采纳",
+ "accepted": False,
+ })
+ return items
+
+
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:
diff --git a/backend/app/main.py b/backend/app/main.py
index e0f9198..8546208 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -24,9 +24,9 @@ from sqlalchemy.orm import Session, defer, 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 .llm import extract_questions_from_images, generate_answer_for_question, generate_answers_for_questions, 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, RefundUploadSession
-from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QAGenerateRequest, RegisterRequest
+from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QABulkAnswerRequest, QAGenerateRequest, RegisterRequest
from .security import create_session_token, decode_session_token, hash_password, verify_password
@@ -513,10 +513,10 @@ def _phone_login(payload: dict, db: Session) -> AnswerUser:
def _taobao_id(payload: dict) -> str:
value = str(payload.get("taobao_id", "")).strip()
- if not re.fullmatch(r"[A-Za-z0-9_.-]{3,64}", value):
+ if not re.fullmatch(r"[\u3400-\u4dbf\u4e00-\u9fffA-Za-z0-9_.-]{2,64}", value):
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
- detail="请输入正确的淘宝ID(3-64位字母、数字、下划线、点或短横线)",
+ detail="请输入正确的淘宝ID(2-64位中文、英文字母、数字、下划线、点或短横线)",
)
return value
@@ -3542,6 +3542,24 @@ def qa_generate(payload: QAGenerateRequest, _: AdminUser = Depends(current_user)
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="问答生成服务异常,请检查大模型配置后重试") from exc
+@app.post("/api/admin/qa/generate-answers")
+def qa_generate_answers(payload: QABulkAnswerRequest, _: AdminUser = Depends(current_user)):
+ intent_error = _product_intent_error([payload.product_name, payload.category, payload.benefits, payload.selling_points])
+ if intent_error:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=intent_error)
+ questions = _dedupe_import_questions(payload.questions)
+ if not questions:
+ raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请至少导入一个有效问题")
+ try:
+ return {"success": True, "data": {"items": generate_answers_for_questions(payload, questions)}}
+ except HTTPException:
+ raise
+ except Exception as exc:
+ import logging
+ logging.getLogger(__name__).exception("Bulk answer generation failed")
+ raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail="批量回答生成服务异常,请稍后重试") from exc
+
+
def _qa_library_data(entry: QALibraryEntry) -> dict:
try:
answers = json.loads(entry.answers_json)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index e488582..ee12fbd 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -42,6 +42,21 @@ class QAGenerateRequest(BaseModel):
seed_style: bool = True
+class QABulkAnswerRequest(BaseModel):
+ product_name: str = Field(default="", max_length=200)
+ category: str = Field(default="", max_length=100)
+ benefits: str = Field(default="", max_length=1000)
+ selling_points: str = Field(default="", max_length=2000)
+ language_style: str = Field(default="口语(亲切随性)", max_length=40)
+ target_group: str = Field(default="女性", max_length=100)
+ usage_scene: str = Field(default="居家", max_length=100)
+ purchase_channel: str = Field(default="电商平台", max_length=100)
+ answers_per_question: int = Field(default=2, ge=1, le=20)
+ answer_requirement: str = Field(default="", max_length=1000)
+ seed_style: bool = True
+ questions: list[str] = Field(min_length=1, max_length=500)
+
+
class UserOut(BaseModel):
id: int
username: str
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
index a738ad2..e691c06 100644
--- a/backend/tests/test_auth.py
+++ b/backend/tests/test_auth.py
@@ -89,6 +89,48 @@ def test_qa_generation_requires_product_and_model_key():
assert client.post("/api/admin/qa/generate", json={"question_count": 2}).status_code == 400
+def test_imported_questions_can_generate_configured_answer_count(monkeypatch):
+ captured = {}
+
+ def fake_bulk_answers(payload, questions):
+ captured["questions"] = questions
+ captured["answers_per_question"] = payload.answers_per_question
+ return [
+ {
+ "id": f"Q{index:03d}",
+ "question": question,
+ "answers": [f"回答{answer_index}" for answer_index in range(1, payload.answers_per_question + 1)],
+ "source": "批量导入(AI回答)",
+ "status": "待采纳",
+ "accepted": False,
+ }
+ for index, question in enumerate(questions, start=1)
+ ]
+
+ monkeypatch.setattr(main_module, "generate_answers_for_questions", fake_bulk_answers)
+ payload = {
+ "product_name": "批量回答测试商品",
+ "questions": ["导入问题一?", "导入问题二?", "导入问题一?"],
+ "answers_per_question": 3,
+ }
+ with TestClient(app) as client:
+ assert client.post("/api/admin/qa/generate-answers", json=payload).status_code == 401
+ assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
+ assert client.post(
+ "/api/admin/qa/generate-answers",
+ json={"questions": ["缺少商品信息?"], "answers_per_question": 2},
+ ).status_code == 400
+ response = client.post("/api/admin/qa/generate-answers", json=payload)
+ assert response.status_code == 200
+ assert captured == {
+ "questions": ["导入问题一?", "导入问题二?"],
+ "answers_per_question": 3,
+ }
+ items = response.json()["data"]["items"]
+ assert [item["question"] for item in items] == captured["questions"]
+ assert all(len(item["answers"]) == 3 for item in items)
+
+
def test_accepted_qa_is_saved_to_library_without_duplicates():
unique_product = f"问答库测试商品-{uuid4().hex[:8]}"
payload = {
@@ -179,6 +221,27 @@ def test_taobao_id_login_is_shared_by_both_user_portals():
)
assert invalid.status_code == 400
+ chinese_taobao_id = f"淘宝用户{uuid4().hex[:6]}"
+ chinese_login = client.post(
+ "/api/ask/auth/taobao-login",
+ json={"taobao_id": chinese_taobao_id},
+ )
+ assert chinese_login.status_code == 200
+ assert chinese_login.json()["data"]["taobao_id"] == chinese_taobao_id
+
+ answer_chinese_login = client.post(
+ "/api/answer/auth/taobao-login",
+ json={"taobao_id": chinese_taobao_id, "owner_token": owner_token},
+ )
+ assert answer_chinese_login.status_code == 200
+ assert answer_chinese_login.json()["data"]["token"] == chinese_login.json()["data"]["token"]
+
+ invalid_symbol = client.post(
+ "/api/ask/auth/taobao-login",
+ json={"taobao_id": "淘宝用户@测试"},
+ )
+ assert invalid_symbol.status_code == 400
+
def test_legacy_public_user_sessions_are_invalidated_and_rotated_on_taobao_login():
taobao_id = f"tb{uuid4().hex[:14]}"
diff --git a/frontend/src/AnswerUserPortal.jsx b/frontend/src/AnswerUserPortal.jsx
index 13a83d1..1165370 100644
--- a/frontend/src/AnswerUserPortal.jsx
+++ b/frontend/src/AnswerUserPortal.jsx
@@ -1,5 +1,6 @@
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'
+import { isValidTaobaoId, normalizeTaobaoId, TAOBAO_ID_HINT } from './taobaoId.js'
const SESSION_KEY = 'answer_user_taobao_session_v2'
const LEGACY_SESSION_KEYS = ['answer_user_taobao_session', 'answer_user_session']
@@ -25,12 +26,13 @@ function Login({ api, onLogin, linkToken }) {
const [taobaoId, setTaobaoId] = useState(''); const [busy, setBusy] = useState(false); const [error, setError] = useState('')
async function submit(event) {
event.preventDefault(); setError('')
- if (!/^[A-Za-z0-9_.-]{3,64}$/.test(taobaoId.trim())) return setError('请输入正确的淘宝ID')
+ const account = normalizeTaobaoId(taobaoId)
+ if (!isValidTaobaoId(account)) return setError(TAOBAO_ID_HINT)
setBusy(true)
- 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) }
+ try { const result = await api('/api/answer/auth/taobao-login', { method: 'POST', body: JSON.stringify({ taobao_id: account, owner_token: linkToken || undefined }) }); localStorage.setItem(SESSION_KEY, JSON.stringify(result.data)); onLogin(result.data) }
catch (err) { setError(err.message) } finally { setBusy(false) }
}
- return