3910 lines
193 KiB
Python
3910 lines
193 KiB
Python
from datetime import datetime, timedelta
|
||
|
||
import base64
|
||
import csv
|
||
import hashlib
|
||
import json
|
||
import random
|
||
import re
|
||
import secrets
|
||
import time
|
||
import urllib.parse
|
||
import urllib.request
|
||
import urllib.error
|
||
import uuid
|
||
import io
|
||
import zipfile
|
||
import xml.etree.ElementTree as ET
|
||
from pathlib import Path
|
||
|
||
from fastapi import BackgroundTasks, Body, Depends, FastAPI, File, Form, HTTPException, Request, Response, UploadFile, status
|
||
from fastapi.middleware.cors import CORSMiddleware
|
||
from sqlalchemy import func, inspect, literal, or_, select, text, union_all
|
||
from sqlalchemy.orm import Session, defer, noload, selectinload
|
||
|
||
from .config import settings
|
||
from .database import Base, SessionLocal, engine, get_db
|
||
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, RefundUploadSession
|
||
from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QAGenerateRequest, RegisterRequest
|
||
from .security import create_session_token, decode_session_token, hash_password, verify_password
|
||
|
||
|
||
app = FastAPI(title=settings.app_name, version="0.1.0")
|
||
app.add_middleware(
|
||
CORSMiddleware,
|
||
allow_origins=["http://localhost:5173", "http://127.0.0.1:5173"],
|
||
allow_credentials=True,
|
||
allow_methods=["GET", "POST", "PATCH", "DELETE", "OPTIONS"],
|
||
allow_headers=["Content-Type"],
|
||
)
|
||
|
||
# A question allocation is temporary until the user submits a completion
|
||
# batch. The one-minute window covers the selection screen; after the user
|
||
# confirms a selection, the upload screen has a separate thirty-minute window.
|
||
ASK_SELECTION_TIMEOUT = timedelta(minutes=1)
|
||
ASK_UPLOAD_TIMEOUT = timedelta(minutes=30)
|
||
|
||
|
||
def _release_expired_ask_assignments(db: Session, task: AskTask | None = None) -> int:
|
||
"""Release stale ask-task allocations back to the published task pool.
|
||
|
||
Submitted batches are intentionally preserved (including rejected batches)
|
||
because they are part of the review/history flow. Only the current,
|
||
unsubmitted reservation is returned to the task pool.
|
||
"""
|
||
now = datetime.utcnow()
|
||
statement = select(AskAssignment).where(
|
||
or_(
|
||
(AskAssignment.status == "待选择")
|
||
& (func.coalesce(AskAssignment.reservation_started_at, AskAssignment.created_at) < now - ASK_SELECTION_TIMEOUT),
|
||
(AskAssignment.status == "进行中")
|
||
& (func.coalesce(AskAssignment.reservation_started_at, AskAssignment.updated_at) < now - ASK_UPLOAD_TIMEOUT),
|
||
),
|
||
)
|
||
if task is not None:
|
||
statement = statement.where(AskAssignment.task_id == task.id)
|
||
expired = db.scalars(statement).unique().all()
|
||
for assignment in expired:
|
||
_release_ask_assignment_reservation(db, assignment)
|
||
if expired:
|
||
db.flush()
|
||
return len(expired)
|
||
|
||
|
||
def _ask_assignment_expired(assignment: AskAssignment | None, now: datetime | None = None) -> bool:
|
||
if not assignment or assignment.status not in {"待选择", "进行中"}:
|
||
return False
|
||
current = now or datetime.utcnow()
|
||
timeout = ASK_SELECTION_TIMEOUT if assignment.status == "待选择" else ASK_UPLOAD_TIMEOUT
|
||
started_at = assignment.reservation_started_at or (
|
||
assignment.created_at if assignment.status == "待选择" else assignment.updated_at
|
||
)
|
||
return started_at < current - timeout
|
||
|
||
|
||
def _ask_assignment_expires_at(assignment: AskAssignment) -> datetime | None:
|
||
started_at = assignment.reservation_started_at or (
|
||
assignment.created_at if assignment.status == "待选择" else assignment.updated_at
|
||
)
|
||
if assignment.status == "待选择":
|
||
return started_at + ASK_SELECTION_TIMEOUT
|
||
if assignment.status == "进行中":
|
||
return started_at + ASK_UPLOAD_TIMEOUT
|
||
return None
|
||
|
||
|
||
def _batch_question_ids(batch: AskAssignmentBatch | None) -> set[int]:
|
||
if not batch:
|
||
return set()
|
||
try:
|
||
return {int(value) for value in (batch.selected_question_ids or "").split(",") if value}
|
||
except ValueError:
|
||
return set()
|
||
|
||
|
||
def _release_ask_assignment_reservation(db: Session, assignment: AskAssignment) -> None:
|
||
"""Release unfinished questions without deleting submitted batch history."""
|
||
if not assignment.batches:
|
||
db.delete(assignment)
|
||
return
|
||
used_ids = {item.question_id for item in assignment.used_questions}
|
||
latest = max(assignment.batches, key=lambda value: value.created_at)
|
||
rejected_ids = _batch_question_ids(latest) if latest.status == "rejected" else set()
|
||
for item in list(assignment.items):
|
||
if item.question_id not in used_ids:
|
||
db.delete(item)
|
||
else:
|
||
item.selected = item.question_id in rejected_ids
|
||
assignment.status = {
|
||
"pending": "待审核",
|
||
"approved": "已通过",
|
||
"paid": "已通过",
|
||
"rejected": "未通过",
|
||
}.get(latest.status, latest.status)
|
||
assignment.reservation_started_at = None
|
||
assignment.updated_at = datetime.utcnow()
|
||
|
||
|
||
def _product_intent_error(values: list[object]) -> str | None:
|
||
"""Reject obvious placeholder input before creating an AI task."""
|
||
text = " ".join(str(value or "").strip() for value in values if str(value or "").strip())
|
||
if not text:
|
||
return "请至少填写一项商品信息"
|
||
compact = re.sub(r"[\s\W_]+", "", text, flags=re.UNICODE)
|
||
if not compact:
|
||
return "商品信息不能只有符号,请填写真实的商品内容"
|
||
if compact.isdigit():
|
||
return "商品信息看起来像占位内容,请填写真实的商品名称、品类、功效或卖点"
|
||
if re.fullmatch(r"(.{1,3})\1+", compact, flags=re.UNICODE):
|
||
return "商品信息看起来是重复的占位内容,请补充真实的商品信息"
|
||
if not re.search(r"[\u4e00-\u9fffA-Za-z]", compact):
|
||
return "请填写包含文字的真实商品信息"
|
||
return None
|
||
|
||
|
||
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() 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", "提问生成条数")]
|
||
missing = [label for key, label in required if not str(payload.get(key, "")).strip()]
|
||
if missing:
|
||
return f"请填写{'、'.join(missing)}"
|
||
try:
|
||
question_count = int(payload.get("question_count"))
|
||
except (TypeError, ValueError):
|
||
return "提问生成条数必须是大于 0 的整数"
|
||
if question_count < 1:
|
||
return "提问生成条数必须是大于 0 的整数"
|
||
return None
|
||
|
||
|
||
# 回答用户链接的 MVP 数据暂存于内存:截图只在当前服务进程中保留,后续接入对象存储/持久化表。
|
||
ANSWER_TASKS = {
|
||
"TASK20260822002": {
|
||
"task_id": "TASK20260822002",
|
||
"token": "demo-answer-token",
|
||
"product_name": "泊泉雅三色修护凝精华",
|
||
"status": "待上传",
|
||
"questions": [
|
||
{"id": "Q001", "question": "这个精华敏感肌可以使用吗?", "answer": "质地温和,使用前建议先做局部测试。"},
|
||
{"id": "Q002", "question": "用了会不会觉得油腻?", "answer": "吸收速度较快,肤感清爽不黏腻。"},
|
||
{"id": "Q003", "question": "这款成分里面有致痘成分吗?", "answer": "配方清爽,建议结合自己的肤质进行选择。"},
|
||
{"id": "Q004", "question": "吸收速度怎么样?使用感如何?", "answer": "延展性好,吸收后肤感柔润,日常使用很舒服。"},
|
||
],
|
||
"invited_screenshots": [],
|
||
"final_screenshots": [],
|
||
"recognized_questions": [],
|
||
"selected_question_ids": [],
|
||
"user_name": "",
|
||
"updated_at": datetime.utcnow().isoformat(),
|
||
}
|
||
}
|
||
|
||
PUBLIC_ANSWER_SUBMISSIONS: list[dict] = []
|
||
PHONE_CODES: dict[str, dict] = {}
|
||
DINGTALK_STATES: dict[str, float] = {}
|
||
|
||
|
||
ROLE_LABELS = {"super_admin": "超级管理员", "dept_manager": "部门主管", "operator": "普通运营"}
|
||
ROLE_PERMISSIONS = {
|
||
"super_admin": ["assistant", "library", "create", "board", "review", "users", "logs", "settings"],
|
||
"dept_manager": ["assistant", "library", "create", "board", "review"],
|
||
"operator": ["assistant", "library", "create", "board", "review"],
|
||
}
|
||
|
||
|
||
def public_user(user: AdminUser) -> dict:
|
||
return {
|
||
"id": user.id,
|
||
"username": user.username,
|
||
"display_name": user.display_name,
|
||
"role_code": user.role_code,
|
||
"role_label": ROLE_LABELS.get(user.role_code, user.role_code),
|
||
"permissions": ROLE_PERMISSIONS.get(user.role_code, []),
|
||
"dingtalk_user_id": user.dingtalk_user_id,
|
||
"dingtalk_dept_id": user.dingtalk_dept_id,
|
||
"dingtalk_dept_name": user.dingtalk_dept_name,
|
||
"is_dept_manager": user.is_dept_manager,
|
||
"answer_public_token": user.answer_public_token,
|
||
}
|
||
|
||
|
||
def seed_users(db: Session):
|
||
if db.scalar(select(AdminUser).limit(1)):
|
||
return
|
||
db.add_all([
|
||
AdminUser(username=settings.admin_username, password_hash=hash_password(settings.admin_password), display_name="系统管理员", role_code="super_admin"),
|
||
AdminUser(username=settings.operator_username, password_hash=hash_password(settings.operator_password), display_name="普通运营", role_code="operator"),
|
||
])
|
||
db.commit()
|
||
|
||
|
||
@app.on_event("startup")
|
||
def startup():
|
||
Base.metadata.create_all(bind=engine)
|
||
# Add fields introduced after the initial SQLite database was created.
|
||
columns = {column["name"] for column in inspect(engine).get_columns("ask_tasks")}
|
||
if "claim_questions_per_user" not in columns:
|
||
with engine.begin() as connection:
|
||
connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN claim_questions_per_user INTEGER NOT NULL DEFAULT 5"))
|
||
if "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"))
|
||
assignment_columns = {column["name"] for column in inspect(engine).get_columns("ask_assignments")}
|
||
if "reservation_started_at" not in assignment_columns:
|
||
with engine.begin() as connection:
|
||
connection.execute(text("ALTER TABLE ask_assignments ADD COLUMN reservation_started_at DATETIME NULL"))
|
||
with engine.begin() as connection:
|
||
connection.execute(text("CREATE INDEX ix_ask_assignments_reservation_started_at ON ask_assignments (reservation_started_at)"))
|
||
question_table_columns = {column["name"] for column in inspect(engine).get_columns("ask_questions")}
|
||
if "source" not in question_table_columns:
|
||
with engine.begin() as connection:
|
||
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",
|
||
"dingtalk_union_id": "VARCHAR(128) NULL",
|
||
"dingtalk_corp_id": "VARCHAR(128) NULL",
|
||
"dingtalk_dept_id": "VARCHAR(64) NULL",
|
||
"dingtalk_dept_name": "VARCHAR(255) NULL",
|
||
"dingtalk_dept_path": "VARCHAR(1000) NULL",
|
||
"dingtalk_title": "VARCHAR(255) NULL",
|
||
"is_dept_manager": "BOOLEAN NOT NULL DEFAULT 0",
|
||
"answer_public_token": "VARCHAR(96) NULL",
|
||
"last_answer_shop_name": "VARCHAR(255) NULL",
|
||
}
|
||
for column_name, column_type in admin_user_migrations.items():
|
||
if column_name not in admin_user_columns:
|
||
with engine.begin() as connection:
|
||
connection.execute(text(f"ALTER TABLE admin_users ADD COLUMN {column_name} {column_type}"))
|
||
answer_submission_columns = {column["name"] for column in inspect(engine).get_columns("answer_submissions")}
|
||
if "owner_id" not in answer_submission_columns:
|
||
with engine.begin() as connection:
|
||
connection.execute(text("ALTER TABLE answer_submissions ADD COLUMN owner_id INTEGER NULL"))
|
||
if "shop_name" not in answer_submission_columns:
|
||
with engine.begin() as connection:
|
||
connection.execute(text("ALTER TABLE answer_submissions ADD COLUMN shop_name VARCHAR(255) NOT NULL DEFAULT ''"))
|
||
answer_user_columns = {column["name"] for column in inspect(engine).get_columns("answer_users")}
|
||
if "taobao_id" not in answer_user_columns:
|
||
with engine.begin() as connection:
|
||
connection.execute(text("ALTER TABLE answer_users ADD COLUMN taobao_id VARCHAR(64) NULL"))
|
||
answer_user_indexes = inspect(engine).get_indexes("answer_users")
|
||
answer_user_constraints = inspect(engine).get_unique_constraints("answer_users")
|
||
taobao_id_index_exists = any(
|
||
index.get("name") == "ix_answer_users_taobao_id"
|
||
or index.get("column_names") == ["taobao_id"]
|
||
for index in answer_user_indexes
|
||
) or any(
|
||
constraint.get("column_names") == ["taobao_id"]
|
||
for constraint in answer_user_constraints
|
||
)
|
||
if not taobao_id_index_exists:
|
||
with engine.begin() as connection:
|
||
connection.execute(text("CREATE UNIQUE INDEX ix_answer_users_taobao_id ON answer_users (taobao_id)"))
|
||
question_columns = {column["name"] for column in inspect(engine).get_columns("answer_recognized_questions")}
|
||
if "operator_image_screenshot_id" not in question_columns:
|
||
with engine.begin() as connection:
|
||
connection.execute(text("ALTER TABLE answer_recognized_questions ADD COLUMN operator_image_screenshot_id INTEGER NULL"))
|
||
if "operator_image_screenshot_ids" not in question_columns:
|
||
with engine.begin() as connection:
|
||
connection.execute(text("ALTER TABLE answer_recognized_questions ADD COLUMN operator_image_screenshot_ids TEXT NULL"))
|
||
db = SessionLocal()
|
||
try:
|
||
legacy_tasks = db.scalars(
|
||
select(AskTask).where(AskTask.public_token.is_(None), AskTask.status != "草稿")
|
||
).all()
|
||
for task in legacy_tasks:
|
||
task.public_token = secrets.token_urlsafe(24)
|
||
if legacy_tasks:
|
||
db.commit()
|
||
index_names = {index["name"] for index in inspect(engine).get_indexes("ask_tasks")}
|
||
if "ix_ask_tasks_public_token" not in index_names:
|
||
with engine.begin() as connection:
|
||
connection.execute(text("CREATE UNIQUE INDEX ix_ask_tasks_public_token ON ask_tasks (public_token)"))
|
||
seed_users(db)
|
||
# Every operator keeps one stable public answer-task URL. Generate
|
||
# tokens after seeding so the default accounts receive one as well.
|
||
answer_users = db.scalars(
|
||
select(AdminUser).where(
|
||
AdminUser.answer_public_token.is_(None),
|
||
AdminUser.role_code != "super_admin",
|
||
)
|
||
).all()
|
||
for operator in answer_users:
|
||
operator.answer_public_token = _new_answer_public_token(db)
|
||
if answer_users:
|
||
db.commit()
|
||
admin_user_indexes = inspect(engine).get_indexes("admin_users")
|
||
admin_user_unique_constraints = inspect(engine).get_unique_constraints("admin_users")
|
||
answer_token_index_exists = any(
|
||
index.get("name") == "ix_admin_users_answer_public_token"
|
||
or index.get("column_names") == ["answer_public_token"]
|
||
for index in admin_user_indexes
|
||
) or any(
|
||
constraint.get("column_names") == ["answer_public_token"]
|
||
for constraint in admin_user_unique_constraints
|
||
)
|
||
if not answer_token_index_exists:
|
||
with engine.begin() as connection:
|
||
connection.execute(text("CREATE UNIQUE INDEX ix_admin_users_answer_public_token ON admin_users (answer_public_token)"))
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
def current_user(request: Request, db: Session = Depends(get_db)) -> AdminUser:
|
||
token = request.cookies.get(settings.cookie_name)
|
||
payload = decode_session_token(token) if token else None
|
||
if not payload:
|
||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="登录状态已失效,请重新登录")
|
||
user = db.get(AdminUser, int(payload["sub"]))
|
||
if not user or not user.is_active:
|
||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="账号不可用")
|
||
# Authentication only needs the loaded user. End the implicit SELECT
|
||
# transaction here so a subsequent long-running route operation (LLM,
|
||
# file I/O, or an external API call) does not keep a pool connection
|
||
# checked out for the whole request.
|
||
db.expunge(user)
|
||
db.rollback()
|
||
return user
|
||
|
||
|
||
def require_super_admin(user: AdminUser = Depends(current_user)) -> AdminUser:
|
||
if user.role_code != "super_admin":
|
||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="仅超级管理员可访问此功能")
|
||
return user
|
||
|
||
|
||
@app.get("/api/health")
|
||
def health():
|
||
return {"success": True, "message": "ok"}
|
||
|
||
|
||
def _answer_task(task_id: str, token: str) -> dict:
|
||
task = ANSWER_TASKS.get(task_id)
|
||
if not task or token != task["token"]:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="任务链接无效或已失效")
|
||
return task
|
||
|
||
|
||
def _public_answer_task(task: dict) -> dict:
|
||
selected = set(task.get("selected_question_ids", []))
|
||
return {
|
||
"task_id": task["task_id"],
|
||
"product_name": task["product_name"],
|
||
"status": task["status"],
|
||
"recognized_questions": task.get("recognized_questions", []),
|
||
"selected_questions": [q for q in task["questions"] if q["id"] in selected],
|
||
"updated_at": task["updated_at"],
|
||
}
|
||
|
||
|
||
def _answer_user_from_token(token: str, db: Session) -> AnswerUser:
|
||
user = db.scalar(select(AnswerUser).where(AnswerUser.auth_token == token))
|
||
if not user:
|
||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="回答用户登录已失效")
|
||
db.expunge(user)
|
||
db.rollback()
|
||
return user
|
||
|
||
|
||
def _phone_number(payload: dict) -> str:
|
||
phone = str(payload.get("phone", "")).strip()
|
||
if not phone.isdigit() or len(phone) != 11 or not phone.startswith("1"):
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="请输入正确的11位手机号")
|
||
return phone
|
||
|
||
|
||
def _send_phone_code(payload: dict) -> dict:
|
||
phone = _phone_number(payload)
|
||
now = datetime.utcnow()
|
||
previous = PHONE_CODES.get(phone)
|
||
if previous and (now - previous["sent_at"]).total_seconds() < 60:
|
||
wait = 60 - int((now - previous["sent_at"]).total_seconds())
|
||
raise HTTPException(status_code=429, detail=f"验证码发送过于频繁,请{max(1, wait)}秒后再试")
|
||
code = f"{random.randint(0, 999999):06d}"
|
||
PHONE_CODES[phone] = {"code": code, "sent_at": now, "expires_at": now + timedelta(minutes=5), "attempts": 0}
|
||
result = {"expires_in": 300, "retry_after": 60}
|
||
if settings.sms_dev_mode:
|
||
result["dev_code"] = code
|
||
return result
|
||
|
||
|
||
def _phone_login(payload: dict, db: Session) -> AnswerUser:
|
||
phone = _phone_number(payload)
|
||
code = str(payload.get("code", "")).strip()
|
||
challenge = PHONE_CODES.get(phone)
|
||
if not challenge:
|
||
raise HTTPException(status_code=400, detail="请先获取短信验证码")
|
||
if challenge["attempts"] >= 5:
|
||
PHONE_CODES.pop(phone, None)
|
||
raise HTTPException(status_code=400, detail="验证码错误次数过多,请重新获取验证码")
|
||
if datetime.utcnow() > challenge["expires_at"]:
|
||
PHONE_CODES.pop(phone, None)
|
||
raise HTTPException(status_code=400, detail="验证码已过期,请重新获取")
|
||
if code != challenge["code"]:
|
||
challenge["attempts"] += 1
|
||
raise HTTPException(status_code=400, detail="验证码错误,请重新输入")
|
||
PHONE_CODES.pop(phone, None)
|
||
user = db.scalar(select(AnswerUser).where(AnswerUser.phone == phone))
|
||
if not user:
|
||
user = AnswerUser(public_id=f"U{uuid.uuid4().int % 10_000_000:07d}", phone=phone, auth_token=uuid.uuid4().hex)
|
||
db.add(user)
|
||
else:
|
||
user.last_login_at = datetime.utcnow()
|
||
if not user.auth_token:
|
||
user.auth_token = uuid.uuid4().hex
|
||
db.commit(); db.refresh(user)
|
||
return user
|
||
|
||
|
||
def _taobao_id(payload: dict) -> str:
|
||
value = str(payload.get("taobao_id", "")).strip()
|
||
if not re.fullmatch(r"[A-Za-z0-9_.-]{3,64}", value):
|
||
raise HTTPException(
|
||
status_code=status.HTTP_400_BAD_REQUEST,
|
||
detail="请输入正确的淘宝ID(3-64位字母、数字、下划线、点或短横线)",
|
||
)
|
||
return value
|
||
|
||
|
||
def _taobao_login(payload: dict, db: Session) -> AnswerUser:
|
||
taobao_id = _taobao_id(payload)
|
||
user = db.scalar(select(AnswerUser).where(AnswerUser.taobao_id == taobao_id))
|
||
if not user:
|
||
# Existing databases require the legacy phone column. This internal
|
||
# unique value is never returned or displayed for Taobao-ID users.
|
||
legacy_phone_key = f"tb_{hashlib.sha256(taobao_id.encode('utf-8')).hexdigest()[:16]}"
|
||
user = AnswerUser(
|
||
public_id=f"U{uuid.uuid4().int % 10_000_000:07d}",
|
||
phone=legacy_phone_key,
|
||
taobao_id=taobao_id,
|
||
auth_token=uuid.uuid4().hex,
|
||
)
|
||
db.add(user)
|
||
else:
|
||
user.last_login_at = datetime.utcnow()
|
||
if not user.auth_token:
|
||
user.auth_token = uuid.uuid4().hex
|
||
db.commit(); db.refresh(user)
|
||
return user
|
||
|
||
|
||
def _public_user_id(user: AnswerUser) -> str:
|
||
return user.taobao_id or user.public_id
|
||
|
||
|
||
def _public_user_account(user: AnswerUser) -> str:
|
||
return user.taobao_id or user.phone
|
||
|
||
|
||
def _public_phone_session(user: AnswerUser) -> dict:
|
||
return {
|
||
"user_id": _public_user_id(user),
|
||
"taobao_id": user.taobao_id,
|
||
"account": _public_user_account(user),
|
||
"phone": user.phone if not user.taobao_id else "",
|
||
"token": user.auth_token,
|
||
}
|
||
|
||
|
||
def _dingtalk_configured() -> bool:
|
||
return bool(settings.dingtalk_client_id and settings.dingtalk_client_secret)
|
||
|
||
|
||
def _dingtalk_workbench_configured() -> bool:
|
||
return bool(settings.dingtalk_corp_id and settings.dingtalk_agent_id and settings.dingtalk_client_id and settings.dingtalk_client_secret)
|
||
|
||
|
||
def _dingtalk_exchange_code(code: str) -> dict:
|
||
request = urllib.request.Request(
|
||
"https://api.dingtalk.com/v1.0/oauth2/userAccessToken",
|
||
data=json.dumps({
|
||
"clientId": settings.dingtalk_client_id,
|
||
"clientSecret": settings.dingtalk_client_secret,
|
||
"code": code,
|
||
"grantType": "authorization_code",
|
||
}).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=10) as response:
|
||
return json.loads(response.read().decode("utf-8"))
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=502, detail="钉钉授权码校验失败,请稍后重试") from exc
|
||
|
||
|
||
def _dingtalk_user(access_token: str) -> dict:
|
||
request = urllib.request.Request(
|
||
"https://api.dingtalk.com/v1.0/contact/users/me",
|
||
headers={"x-acs-dingtalk-access-token": access_token},
|
||
method="GET",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=10) as response:
|
||
return json.loads(response.read().decode("utf-8"))
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=502, detail="无法读取钉钉用户信息,请稍后重试") from exc
|
||
|
||
|
||
def _dingtalk_oapi_post(path: str, access_token: str, payload: dict) -> dict:
|
||
request = urllib.request.Request(
|
||
"https://oapi.dingtalk.com" + path + "?" + urllib.parse.urlencode({"access_token": access_token}),
|
||
data=json.dumps(payload).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(request, timeout=10) as response:
|
||
return json.loads(response.read().decode("utf-8"))
|
||
except Exception:
|
||
# Organization data is supplemental to authentication. A temporary
|
||
# directory API failure must not prevent an otherwise valid SSO login.
|
||
return {}
|
||
|
||
|
||
def _dingtalk_org_profile(profile: dict, access_token: str | None) -> dict:
|
||
"""Best-effort enrichment of a DingTalk profile with department data."""
|
||
data = dict(profile or {})
|
||
data["_org_data_available"] = False
|
||
user_id = str(data.get("userid") or data.get("userId") or data.get("userid") or "").strip()
|
||
if access_token and user_id:
|
||
detail_response = _dingtalk_oapi_post(
|
||
"/topapi/v2/user/get",
|
||
access_token,
|
||
{"userid": user_id, "language": "zh_CN"},
|
||
)
|
||
detail = detail_response.get("result") or detail_response.get("data") or {}
|
||
if isinstance(detail, dict) and detail:
|
||
data.update(detail)
|
||
data.setdefault("userid", user_id)
|
||
data["_org_data_available"] = True
|
||
|
||
raw_dept_ids = data.get("dept_id_list") or data.get("deptIdList") or data.get("dept_ids") or []
|
||
if isinstance(raw_dept_ids, (str, int)):
|
||
raw_dept_ids = [raw_dept_ids]
|
||
dept_ids = [str(value).strip() for value in raw_dept_ids if str(value).strip()]
|
||
data["_primary_dept_id"] = dept_ids[0] if dept_ids else ""
|
||
data["_dept_name"] = ""
|
||
data["_dept_path"] = ""
|
||
if access_token and dept_ids:
|
||
current_id = dept_ids[0]
|
||
chain = []
|
||
visited = set()
|
||
while current_id and current_id not in visited and len(chain) < 20:
|
||
visited.add(current_id)
|
||
result = _dingtalk_oapi_post(
|
||
"/topapi/v2/department/get",
|
||
access_token,
|
||
{"dept_id": int(current_id) if current_id.isdigit() else current_id, "language": "zh_CN"},
|
||
).get("result") or {}
|
||
if not isinstance(result, dict):
|
||
break
|
||
chain.append((current_id, str(result.get("name") or "").strip()))
|
||
parent_id = result.get("parent_id") or result.get("parentId")
|
||
current_id = str(parent_id).strip() if parent_id not in (None, "", 0, "0") else ""
|
||
if chain:
|
||
chain.reverse()
|
||
data["_dept_name"] = chain[-1][1] or ""
|
||
data["_dept_path"] = "," + ",".join(item[0] for item in chain) + ","
|
||
|
||
leader_info = data.get("leader_in_dept") or data.get("leaderInDept") or []
|
||
is_manager = bool(data.get("is_dept_manager") or data.get("isDeptManager"))
|
||
if isinstance(leader_info, bool):
|
||
is_manager = is_manager or leader_info
|
||
elif isinstance(leader_info, dict):
|
||
is_manager = is_manager or bool(leader_info.get("is_leader") or leader_info.get("isLeader"))
|
||
elif isinstance(leader_info, list):
|
||
is_manager = is_manager or any(
|
||
isinstance(item, dict) and bool(item.get("is_leader") or item.get("isLeader"))
|
||
for item in leader_info
|
||
)
|
||
data["_is_dept_manager"] = is_manager
|
||
return data
|
||
|
||
|
||
def _dingtalk_workbench_user(code: str) -> dict:
|
||
"""Resolve a workbench JSAPI requestAuthCode code."""
|
||
token_request = urllib.request.Request(
|
||
"https://oapi.dingtalk.com/gettoken?" + urllib.parse.urlencode({
|
||
"appkey": settings.dingtalk_client_id,
|
||
"appsecret": settings.dingtalk_client_secret,
|
||
}),
|
||
method="GET",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(token_request, timeout=10) as response:
|
||
token_data = json.loads(response.read().decode("utf-8"))
|
||
except urllib.error.HTTPError as exc:
|
||
detail = exc.read().decode("utf-8", errors="replace")[:500]
|
||
raise HTTPException(status_code=502, detail=f"钉钉应用凭证校验失败:{detail}") from exc
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=502, detail=f"钉钉应用凭证校验失败:{exc}") from exc
|
||
access_token = token_data.get("access_token")
|
||
if not access_token:
|
||
raise HTTPException(status_code=502, detail="钉钉未返回应用访问令牌,请检查应用权限")
|
||
user_request = urllib.request.Request(
|
||
"https://oapi.dingtalk.com/topapi/v2/user/getuserinfo?" + urllib.parse.urlencode({"access_token": access_token}),
|
||
data=json.dumps({"code": code}).encode("utf-8"),
|
||
headers={"Content-Type": "application/json"},
|
||
method="POST",
|
||
)
|
||
try:
|
||
with urllib.request.urlopen(user_request, timeout=10) as response:
|
||
user_data = json.loads(response.read().decode("utf-8"))
|
||
except urllib.error.HTTPError as exc:
|
||
detail = exc.read().decode("utf-8", errors="replace")[:500]
|
||
raise HTTPException(status_code=502, detail=f"无法读取钉钉用户信息:{detail}") from exc
|
||
except Exception as exc:
|
||
raise HTTPException(status_code=502, detail=f"无法读取钉钉用户信息:{exc}") from exc
|
||
if user_data.get("errcode", 0) not in (0, None):
|
||
raise HTTPException(status_code=502, detail=f"钉钉用户信息读取失败:{user_data.get('errmsg', '请检查应用权限')}")
|
||
result = user_data.get("result") or user_data
|
||
if isinstance(result, dict):
|
||
result = dict(result)
|
||
result["_dingtalk_access_token"] = access_token
|
||
return result
|
||
|
||
|
||
def _dingtalk_create_session(profile: dict, db: Session, response: Response) -> AdminUser:
|
||
union_id = str(profile.get("unionId") or profile.get("unionid") or profile.get("openId") or profile.get("userid") or profile.get("userId") or "").strip()
|
||
if not union_id:
|
||
raise HTTPException(status_code=502, detail="钉钉用户信息缺少唯一标识")
|
||
if settings.dingtalk_allowed_union_ids and union_id not in settings.dingtalk_allowed_union_ids:
|
||
raise HTTPException(status_code=403, detail="当前钉钉账号未被授权使用运营后台")
|
||
username = f"dingtalk_{union_id}"[:64]
|
||
user = db.scalar(select(AdminUser).where(AdminUser.username == username))
|
||
display_name = str(profile.get("nick") or profile.get("name") or "钉钉用户").strip()[:64]
|
||
role_code = (
|
||
"super_admin" if union_id in settings.dingtalk_super_admin_union_ids
|
||
else "dept_manager" if bool(profile.get("_is_dept_manager"))
|
||
else "operator"
|
||
)
|
||
dingtalk_user_id = str(profile.get("userid") or profile.get("userId") or "").strip() or None
|
||
dept_id = str(profile.get("_primary_dept_id") or "").strip() or None
|
||
dept_name = str(profile.get("_dept_name") or profile.get("dept_name") or "").strip() or None
|
||
dept_path = str(profile.get("_dept_path") or "").strip() or None
|
||
is_dept_manager = bool(profile.get("_is_dept_manager"))
|
||
if not user:
|
||
user = AdminUser(username=username, password_hash=hash_password(secrets.token_urlsafe(32)), display_name=display_name, role_code=role_code,
|
||
dingtalk_user_id=dingtalk_user_id, dingtalk_union_id=union_id,
|
||
dingtalk_corp_id=settings.dingtalk_corp_id or None, dingtalk_dept_id=dept_id,
|
||
dingtalk_dept_name=dept_name, dingtalk_dept_path=dept_path,
|
||
dingtalk_title=str(profile.get("title") or "").strip()[:255] or None,
|
||
is_dept_manager=is_dept_manager)
|
||
db.add(user)
|
||
elif not user.is_active:
|
||
raise HTTPException(status_code=403, detail="运营账号已被停用")
|
||
else:
|
||
user.display_name = display_name or user.display_name
|
||
user.dingtalk_user_id = dingtalk_user_id or user.dingtalk_user_id
|
||
user.dingtalk_union_id = union_id or user.dingtalk_union_id
|
||
user.dingtalk_corp_id = settings.dingtalk_corp_id or user.dingtalk_corp_id
|
||
if profile.get("_org_data_available"):
|
||
user.dingtalk_dept_id = dept_id
|
||
user.dingtalk_dept_name = dept_name
|
||
user.dingtalk_dept_path = dept_path
|
||
user.dingtalk_title = str(profile.get("title") or "").strip()[:255] or user.dingtalk_title
|
||
user.is_dept_manager = is_dept_manager
|
||
if union_id in settings.dingtalk_super_admin_union_ids:
|
||
user.role_code = "super_admin"
|
||
elif is_dept_manager:
|
||
user.role_code = "dept_manager"
|
||
elif user.role_code == "dept_manager" and profile.get("_org_data_available"):
|
||
user.role_code = "operator"
|
||
user.last_login_at = datetime.utcnow()
|
||
db.flush()
|
||
db.add(AdminLoginLog(user_id=user.id, username=username, success=True))
|
||
db.commit(); db.refresh(user)
|
||
response.set_cookie(settings.cookie_name, create_session_token(user.id, user.role_code), httponly=True, secure=settings.cookie_secure, samesite="lax", max_age=settings.session_hours * 3600)
|
||
return user
|
||
|
||
|
||
def _dingtalk_login(code: str, db: Session, response: Response) -> AdminUser:
|
||
if not _dingtalk_configured():
|
||
raise HTTPException(status_code=503, detail="钉钉登录尚未配置,请联系管理员")
|
||
code = code.strip()
|
||
if not code:
|
||
raise HTTPException(status_code=400, detail="钉钉授权码不能为空")
|
||
token_data = _dingtalk_exchange_code(code)
|
||
access_token = token_data.get("accessToken") or token_data.get("access_token")
|
||
if not access_token:
|
||
raise HTTPException(status_code=502, detail="钉钉未返回有效授权令牌")
|
||
profile = _dingtalk_user(access_token)
|
||
profile = _dingtalk_org_profile(profile, access_token)
|
||
return _dingtalk_create_session(profile, db, response)
|
||
|
||
|
||
def _can_access_ask_task(task: AskTask, user: AdminUser) -> bool:
|
||
if user.role_code == "super_admin" or task.owner_id == user.id:
|
||
return True
|
||
if user.role_code != "dept_manager" or not user.dingtalk_dept_id or not task.owner:
|
||
return False
|
||
owner_path = task.owner.dingtalk_dept_path or ""
|
||
return (
|
||
task.owner.dingtalk_dept_id == user.dingtalk_dept_id
|
||
or f",{user.dingtalk_dept_id}," in owner_path
|
||
)
|
||
|
||
|
||
def _require_ask_task_access(task: AskTask | None, user: AdminUser) -> AskTask:
|
||
if not task or not _can_access_ask_task(task, user):
|
||
raise HTTPException(status_code=404, detail="提问任务不存在")
|
||
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
|
||
if user.role_code != "dept_manager" or not user.dingtalk_dept_id:
|
||
return AskTask.owner_id == user.id
|
||
descendant_owners = select(AdminUser.id).where(
|
||
or_(
|
||
AdminUser.id == user.id,
|
||
AdminUser.dingtalk_dept_path.like(f"%,{user.dingtalk_dept_id},%"),
|
||
AdminUser.dingtalk_dept_id == user.dingtalk_dept_id,
|
||
)
|
||
)
|
||
return AskTask.owner_id.in_(descendant_owners)
|
||
|
||
|
||
def _can_access_answer_submission(record: AnswerSubmission | None, user: AdminUser) -> bool:
|
||
if not record:
|
||
return False
|
||
if user.role_code == "super_admin" or record.owner_id == user.id:
|
||
return True
|
||
if record.owner_id is None:
|
||
# Unassigned legacy records are visible only to the super administrator
|
||
# so they cannot leak into every operator's queue.
|
||
return False
|
||
if user.role_code != "dept_manager" or not user.dingtalk_dept_id or not record.owner:
|
||
return False
|
||
return (
|
||
record.owner.dingtalk_dept_id == user.dingtalk_dept_id
|
||
or f",{user.dingtalk_dept_id}," in (record.owner.dingtalk_dept_path or "")
|
||
)
|
||
|
||
|
||
def _answer_owner_clause(user: AdminUser):
|
||
if user.role_code == "super_admin":
|
||
return True
|
||
if user.role_code != "dept_manager" or not user.dingtalk_dept_id:
|
||
return AnswerSubmission.owner_id == user.id
|
||
descendant_owners = select(AdminUser.id).where(
|
||
or_(
|
||
AdminUser.id == user.id,
|
||
AdminUser.dingtalk_dept_path.like(f"%,{user.dingtalk_dept_id},%"),
|
||
AdminUser.dingtalk_dept_id == user.dingtalk_dept_id,
|
||
)
|
||
)
|
||
return 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:
|
||
while True:
|
||
token = secrets.token_urlsafe(24)
|
||
if not db.scalar(select(AskTask.id).where(AskTask.public_token == token)):
|
||
return token
|
||
|
||
|
||
def _new_answer_public_token(db: Session) -> str:
|
||
while True:
|
||
token = secrets.token_urlsafe(32)
|
||
if not db.scalar(select(AdminUser.id).where(AdminUser.answer_public_token == token)):
|
||
return token
|
||
|
||
|
||
def _answer_link_owner(token: str, db: Session) -> AdminUser:
|
||
normalized = str(token or "").strip()
|
||
if not normalized:
|
||
raise HTTPException(status_code=400, detail="回答任务专属链接无效")
|
||
owner = db.scalar(
|
||
select(AdminUser).where(
|
||
AdminUser.answer_public_token == normalized,
|
||
AdminUser.is_active.is_(True),
|
||
)
|
||
)
|
||
if not owner:
|
||
raise HTTPException(status_code=404, detail="回答任务专属链接无效或已停用")
|
||
return owner
|
||
|
||
|
||
def _published_ask_task(public_token: str, db: Session, *, lock: bool = False) -> AskTask:
|
||
statement = select(AskTask).where(
|
||
# New links use a random public token. Keep the task number as a
|
||
# read-compatible alias for links that were already sent before this
|
||
# change; all newly generated links use public_token.
|
||
(AskTask.public_token == public_token) | (AskTask.task_no == public_token),
|
||
AskTask.status == "已发布",
|
||
)
|
||
# Serialize allocation for the same task. This prevents two users who
|
||
# open the link at the same moment from receiving the same question.
|
||
if lock:
|
||
statement = statement.with_for_update()
|
||
task = db.scalar(statement)
|
||
if not task:
|
||
raise HTTPException(status_code=404, detail="提问任务链接无效、未发布或已结束")
|
||
return task
|
||
|
||
|
||
def _ask_screenshot_url(
|
||
image: AskAssignmentScreenshot | AskAssignmentBatchScreenshot,
|
||
image_kind: str,
|
||
use_endpoint: bool,
|
||
) -> str:
|
||
if use_endpoint:
|
||
return f"/api/admin/ask-{image_kind}-screenshots/{image.id}"
|
||
return f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"
|
||
|
||
|
||
def _ask_assignment_data(
|
||
assignment: AskAssignment,
|
||
include_images: bool = True,
|
||
include_refund_images: bool = False,
|
||
image_endpoints: bool = False,
|
||
) -> dict:
|
||
items = sorted(assignment.items, key=lambda item: item.question.sequence_no)
|
||
used_ids = {item.question_id for item in assignment.used_questions}
|
||
# List endpoints must not touch the screenshot relationship. The image
|
||
# BLOBs are large and loading them for every row was the main cause of the
|
||
# multi-second task-board/review-center delay.
|
||
screenshots = list(assignment.screenshots) if include_images or (include_refund_images and assignment.status == "已完成") else []
|
||
question_screenshots = [item for item in screenshots if item.stage == "question"]
|
||
payment_screenshots = [item for item in screenshots if item.stage == "payment"]
|
||
refund_screenshots = [item for item in screenshots if item.stage == "refund"]
|
||
latest_review = (
|
||
max(assignment.review_decisions, key=lambda item: item.created_at)
|
||
if assignment.review_decisions
|
||
else None
|
||
)
|
||
# Backfill the read model for approvals created before the used-question
|
||
# table was introduced. New approvals are persisted in that table below.
|
||
if assignment.status in {"待审核", "已通过"}:
|
||
# Submission itself consumes the selected questions. The fallback
|
||
# also makes records created before the used-question table behave
|
||
# correctly without a data migration.
|
||
used_ids.update(item.question_id for item in items if item.selected)
|
||
return {
|
||
"assignment_id": assignment.id,
|
||
"status": assignment.status,
|
||
"max_questions": len(items),
|
||
"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": _public_user_id(assignment.user),
|
||
"taobao_id": assignment.user.taobao_id,
|
||
"phone": _public_user_account(assignment.user),
|
||
"created_at": assignment.created_at.isoformat(),
|
||
"updated_at": assignment.updated_at.isoformat(),
|
||
"expires_at": (
|
||
_ask_assignment_expires_at(assignment).isoformat()
|
||
if _ask_assignment_expires_at(assignment)
|
||
else None
|
||
),
|
||
"question_screenshots": [{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "assignment", image_endpoints)} for image in question_screenshots] if include_images else [],
|
||
"payment_screenshots": [{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "assignment", image_endpoints)} for image in payment_screenshots] if include_images else [],
|
||
"refund_screenshots": [{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "assignment", image_endpoints)} for image in refund_screenshots] if include_images or include_refund_images else [],
|
||
"refund_completed": assignment.status == "已完成" or bool(refund_screenshots),
|
||
"latest_review": ({
|
||
"approved": latest_review.approved,
|
||
"reason_code": latest_review.reason_code,
|
||
"reason_text": latest_review.reason_text,
|
||
"created_at": latest_review.created_at.isoformat(),
|
||
} if latest_review else None),
|
||
"questions": [{
|
||
"id": item.question.id,
|
||
"sequence_no": item.question.sequence_no,
|
||
"question": item.question.question_text,
|
||
"selected": item.selected,
|
||
"used": item.question_id in used_ids,
|
||
} for item in items],
|
||
}
|
||
|
||
|
||
def _ask_batch_data(
|
||
batch: AskAssignmentBatch,
|
||
include_images: bool = True,
|
||
include_refund_images: bool = False,
|
||
image_endpoints: bool = False,
|
||
) -> dict:
|
||
"""Serialize one independent submission batch for review/history views."""
|
||
assignment = batch.assignment
|
||
# Batch screenshots are authoritative for batch history. Avoid touching the
|
||
# legacy assignment screenshot relationship while serializing a batch.
|
||
parent = _ask_assignment_data(assignment, include_images=False, include_refund_images=False)
|
||
try:
|
||
selected_ids = {int(value) for value in (batch.selected_question_ids or "").split(",") if value}
|
||
except ValueError:
|
||
selected_ids = set()
|
||
used_ids = {item.question_id for item in assignment.used_questions}
|
||
latest_review = (
|
||
max(batch.review_decisions, key=lambda item: item.created_at)
|
||
if batch.review_decisions
|
||
else None
|
||
)
|
||
screenshots = list(batch.screenshots) if include_images or (include_refund_images and batch.status == "paid") else []
|
||
question_images = [item for item in screenshots if item.stage == "question"]
|
||
payment_images = [item for item in screenshots if item.stage == "payment"]
|
||
refund_images = [item for item in screenshots if item.stage == "refund"]
|
||
questions = []
|
||
for item in sorted(assignment.items, key=lambda value: value.question.sequence_no):
|
||
questions.append({
|
||
"id": item.question.id,
|
||
"sequence_no": item.question.sequence_no,
|
||
"question": item.question.question_text,
|
||
"selected": item.question_id in selected_ids,
|
||
"used": item.question_id in used_ids,
|
||
})
|
||
data = {
|
||
**parent,
|
||
"assignment_id": batch.id,
|
||
"batch_id": batch.id,
|
||
"parent_assignment_id": assignment.id,
|
||
# A paid batch remains an approved audit record in the review center;
|
||
# payout completion is represented separately on the task board.
|
||
"status": {"pending": "待审核", "approved": "已通过", "paid": "已完成", "rejected": "未通过"}.get(batch.status, batch.status),
|
||
"created_at": batch.created_at.isoformat(),
|
||
"updated_at": batch.updated_at.isoformat(),
|
||
"questions": questions,
|
||
"question_screenshots": [
|
||
{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "batch", image_endpoints)}
|
||
for image in question_images
|
||
] if include_images else [],
|
||
"payment_screenshots": [
|
||
{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "batch", image_endpoints)}
|
||
for image in payment_images
|
||
] if include_images else [],
|
||
"refund_screenshots": [
|
||
{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "batch", image_endpoints)}
|
||
for image in refund_images
|
||
] if include_images or include_refund_images else [],
|
||
"refund_completed": batch.status == "paid" or bool(refund_images),
|
||
"latest_review": ({
|
||
"approved": latest_review.approved,
|
||
"reason_code": latest_review.reason_code,
|
||
"reason_text": latest_review.reason_text,
|
||
"created_at": latest_review.created_at.isoformat(),
|
||
} if latest_review else None),
|
||
}
|
||
return data
|
||
|
||
|
||
def _sync_ask_assignment_status(assignment: AskAssignment) -> None:
|
||
"""Keep the aggregate assignment status aligned with its latest batch."""
|
||
if not assignment.batches:
|
||
return
|
||
# Batch order means submission order; a later review decision must not
|
||
# make an older batch replace the user's newest pending batch.
|
||
latest = max(assignment.batches, key=lambda value: value.created_at)
|
||
assignment.status = {
|
||
"pending": "待审核",
|
||
"approved": "已通过",
|
||
"paid": "已通过",
|
||
"rejected": "未通过",
|
||
}.get(latest.status, latest.status)
|
||
assignment.reservation_started_at = None
|
||
assignment.updated_at = datetime.utcnow()
|
||
|
||
|
||
def _image_url(image: AnswerScreenshot, use_endpoint: bool = False) -> str:
|
||
if use_endpoint:
|
||
return f"/api/admin/answer-screenshots/{image.id}"
|
||
return f"data:{image.content_type};base64,{base64.b64encode(image.image_data).decode()}"
|
||
|
||
|
||
LEGACY_FAKE_OCR_QUESTIONS = {
|
||
"这款商品使用起来感觉怎么样?",
|
||
"敏感肌可以使用这款商品吗?",
|
||
"使用后的肤感和效果怎么样?",
|
||
}
|
||
|
||
|
||
def _has_legacy_fake_ocr_questions(record: AnswerSubmission) -> bool:
|
||
questions = list(record.questions)
|
||
return bool(questions) and len(questions) <= 3 and all(
|
||
question.product_title == "待识别商品"
|
||
and question.question_text in LEGACY_FAKE_OCR_QUESTIONS
|
||
for question in questions
|
||
)
|
||
|
||
|
||
def _submission_data(
|
||
record: AnswerSubmission,
|
||
include_images: bool = True,
|
||
include_refund_images: bool = False,
|
||
image_endpoints: bool = False,
|
||
) -> dict:
|
||
# List endpoints deliberately omit screenshot BLOBs. Do not even touch
|
||
# the lazy relationship in that mode, otherwise SQLAlchemy fetches every
|
||
# image from remote MySQL before returning the lightweight response.
|
||
screenshots = list(record.screenshots) if include_images or (include_refund_images and record.status == "已完成") else []
|
||
invited = [x for x in screenshots if x.stage == "invited"]
|
||
completion = [x for x in screenshots if x.stage == "completion"]
|
||
payment = [x for x in screenshots if x.stage == "payment"]
|
||
refund = [x for x in screenshots if x.stage == "refund"]
|
||
images = {x.id: x for x in screenshots}
|
||
latest_review = max(record.review_decisions, key=lambda value: value.created_at) if record.review_decisions else None
|
||
return {
|
||
"id": record.id,
|
||
"submission_no": record.submission_no,
|
||
"user_id": _public_user_id(record.user),
|
||
"taobao_id": record.user.taobao_id,
|
||
"phone": _public_user_account(record.user),
|
||
"shop_name": record.shop_name or "",
|
||
"status": record.status,
|
||
"recognition_failed": record.status == "识别失败" or _has_legacy_fake_ocr_questions(record),
|
||
"created_at": record.created_at.isoformat(),
|
||
"updated_at": record.updated_at.isoformat(),
|
||
"invited_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in invited] if include_images else [],
|
||
"completion_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in completion] if include_images else [],
|
||
"payment_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in payment] if include_images else [],
|
||
"refund_screenshots": [{"id": x.id, "name": x.filename, "data_url": _image_url(x, image_endpoints)} for x in refund] if include_images or include_refund_images else [],
|
||
"refund_completed": record.status == "已完成" or bool(refund),
|
||
"latest_review": ({
|
||
"approved": latest_review.approved,
|
||
"reason_code": latest_review.reason_code,
|
||
"reason_text": latest_review.reason_text,
|
||
"created_at": latest_review.created_at.isoformat(),
|
||
} if latest_review else None),
|
||
"questions": [{
|
||
"id": q.id, "sequence_no": q.sequence_no, "product_title": q.product_title,
|
||
"product_image": _image_url(images[q.product_image_screenshot_id], image_endpoints) if include_images and q.product_image_screenshot_id in images else None,
|
||
"source_image": _image_url(images[q.source_screenshot_id], image_endpoints) if include_images and q.source_screenshot_id in images else None,
|
||
"operator_images": [
|
||
_image_url(images[int(image_id)], image_endpoints) for image_id in ((q.operator_image_screenshot_ids or str(q.operator_image_screenshot_id or "")).split(","))
|
||
if image_id.strip().isdigit() and int(image_id) in images
|
||
] if include_images else [],
|
||
"question": q.question_text, "answer": q.answer_text, "answer_status": q.answer_status, "selected": q.selected,
|
||
} for q in sorted(record.questions, key=lambda value: value.sequence_no)],
|
||
}
|
||
|
||
|
||
def _task_board_data(record: AnswerSubmission, include_images: bool = True, image_endpoints: bool = False) -> dict:
|
||
selected = [q for q in sorted(record.questions, key=lambda value: value.sequence_no) if q.selected]
|
||
product_titles = list(dict.fromkeys(q.product_title for q in selected if q.product_title))
|
||
summary = _submission_data(record, include_images=True, image_endpoints=image_endpoints) if include_images else {
|
||
"id": record.id,
|
||
"submission_no": record.submission_no,
|
||
"user_id": _public_user_id(record.user),
|
||
"taobao_id": record.user.taobao_id,
|
||
"phone": _public_user_account(record.user),
|
||
"shop_name": record.shop_name or "",
|
||
"status": record.status,
|
||
"created_at": record.created_at.isoformat(),
|
||
"updated_at": record.updated_at.isoformat(),
|
||
"invited_screenshots": [],
|
||
"completion_screenshots": [],
|
||
"payment_screenshots": [],
|
||
"refund_screenshots": [],
|
||
"refund_completed": record.status == "已完成",
|
||
"latest_review": None,
|
||
"questions": [],
|
||
}
|
||
status_labels = {
|
||
"待用户完成": "进行中",
|
||
"待审核": "待审核",
|
||
# Passing the content audit means the submission is ready for the
|
||
# finance check; it is not fully complete until the refund is handled.
|
||
"已通过": "待返款",
|
||
"已完成": "已完成",
|
||
"未通过": "进行中",
|
||
"已撤销": "已撤销",
|
||
}
|
||
# "待审核" 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",
|
||
"task_id": record.submission_no, "task_type": "answer", "task_type_label": "回答任务",
|
||
"status_label": status_labels.get(record.status, record.status), "shop_name": record.shop_name or "", "product_name": " / ".join(product_titles) or "待识别商品",
|
||
"creator_name": record.owner.display_name if record.owner else "运营后台",
|
||
"qa_count": len(selected), "claimed_count": 1, "submitted_count": 1 if record.status in {"待审核", "已通过", "已完成"} else 0,
|
||
"pending_review_count": 1 if record.status == "待审核" else 0, "completed_count": completed_count, "total_count": len(selected),
|
||
"can_cancel": record.status in {"待用户完成", "待审核", "未通过"},
|
||
"can_delete": True,
|
||
"delete_endpoint": f"/api/admin/task-board/{record.id}",
|
||
"refund_endpoint": f"/api/admin/task-board/{record.id}/confirm-refund" if record.status == "已通过" else None,
|
||
"refund_screenshot_count": len(summary.get("refund_screenshots") or []) or (1 if record.status == "已完成" else 0),
|
||
"assigned_users": [{"user_id": _public_user_id(record.user), "phone": _public_user_account(record.user), "taobao_id": record.user.taobao_id, "status": status_labels.get(record.status, record.status)}],
|
||
"selected_questions": ([{"id": q.id, "sequence_no": q.sequence_no, "question": q.question_text, "answer": q.answer_text, "product_title": q.product_title} for q in selected] if include_images else []),
|
||
}
|
||
|
||
|
||
def _ask_assignment_child_data(assignment: AskAssignment) -> dict:
|
||
"""Return a compact user-claim row nested under its system ask task."""
|
||
task = assignment.task
|
||
selected_ids = {
|
||
item.question_id for item in assignment.items if item.selected
|
||
}
|
||
used_ids = {item.question_id for item in assignment.used_questions}
|
||
latest_batch = max(assignment.batches, key=lambda value: value.updated_at or value.created_at, default=None)
|
||
# A batch can only enter ``paid`` through the refund endpoint, which now
|
||
# requires a refund screenshot. Keep the board list lightweight and do
|
||
# not load image BLOBs just to render this indicator.
|
||
refund_proof_uploaded = bool(latest_batch and latest_batch.status == "paid") or assignment.status == "已完成"
|
||
batch_selected_ids = set()
|
||
if latest_batch and latest_batch.selected_question_ids:
|
||
batch_selected_ids = {
|
||
int(value) for value in latest_batch.selected_question_ids.split(",") if value.strip().isdigit()
|
||
}
|
||
question_count = len(batch_selected_ids or selected_ids or used_ids)
|
||
refund_completed = bool(latest_batch and latest_batch.status == "paid") or assignment.status == "已完成"
|
||
refund_pending = bool(latest_batch and latest_batch.status == "approved") or assignment.status == "已通过"
|
||
if refund_completed:
|
||
status_label = "已完成"
|
||
elif refund_pending:
|
||
status_label = "待返款"
|
||
elif assignment.status == "待审核" or (latest_batch and latest_batch.status == "pending"):
|
||
status_label = "待审核"
|
||
elif assignment.status == "已撤销":
|
||
status_label = "已撤销"
|
||
else:
|
||
status_label = "进行中"
|
||
detail_endpoint = None
|
||
refund_endpoint = None
|
||
if latest_batch and latest_batch.status in {"approved", "paid"}:
|
||
detail_endpoint = f"/api/admin/task-board/ask-batches/{latest_batch.id}"
|
||
if not refund_completed:
|
||
refund_endpoint = f"/api/admin/task-board/ask-batches/{latest_batch.id}/confirm-refund"
|
||
elif assignment.status in {"已通过", "已完成"} and not assignment.batches:
|
||
detail_endpoint = f"/api/admin/task-board/ask-assignments/{assignment.id}"
|
||
if not refund_completed:
|
||
refund_endpoint = f"/api/admin/task-board/ask-assignments/{assignment.id}/confirm-refund"
|
||
return {
|
||
"id": assignment.id,
|
||
"board_key": f"ask-assignment-{assignment.id}",
|
||
"record_kind": "ask_assignment",
|
||
"is_child": True,
|
||
"task_id": task.task_no,
|
||
"task_type": "ask",
|
||
"task_type_label": "提问任务",
|
||
"product_name": task.product_name,
|
||
"shop_name": task.shop_name,
|
||
"product_link": task.product_link,
|
||
"status": assignment.status,
|
||
"status_label": status_label,
|
||
"user_id": _public_user_id(assignment.user),
|
||
"taobao_id": assignment.user.taobao_id,
|
||
"phone": _public_user_account(assignment.user),
|
||
"qa_count": question_count,
|
||
"claimed_count": question_count,
|
||
"submitted_count": len(used_ids),
|
||
"pending_review_count": 1 if status_label == "待审核" 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(),
|
||
"updated_at": assignment.updated_at.isoformat(),
|
||
"can_cancel": False,
|
||
"can_complete": False,
|
||
"can_delete": False,
|
||
"detail_endpoint": detail_endpoint,
|
||
"refund_endpoint": refund_endpoint,
|
||
"assigned_users": [{"user_id": _public_user_id(assignment.user), "phone": _public_user_account(assignment.user), "taobao_id": assignment.user.taobao_id, "status": status_label}],
|
||
"selected_questions": [],
|
||
"question_screenshots": [],
|
||
"completion_screenshots": [],
|
||
"payment_screenshots": [],
|
||
"refund_screenshots": [],
|
||
"refund_proof_uploaded": refund_proof_uploaded,
|
||
"refund_proof_count": 1 if refund_proof_uploaded else 0,
|
||
"latest_review": None,
|
||
}
|
||
|
||
|
||
def _ask_batch_child_data(batch: AskAssignmentBatch) -> dict:
|
||
"""Return one compact board row for one independent user submission.
|
||
|
||
A user may submit an assignment in several batches. The parent
|
||
assignment is still the user's question pool, but each batch must remain
|
||
visible as its own audit/payout row instead of the latest batch replacing
|
||
the previous one.
|
||
"""
|
||
assignment = batch.assignment
|
||
task = assignment.task
|
||
try:
|
||
selected_ids = {
|
||
int(value) for value in (batch.selected_question_ids or "").split(",")
|
||
if value.strip().isdigit()
|
||
}
|
||
except (TypeError, ValueError):
|
||
selected_ids = set()
|
||
items_by_id = {item.question_id: item for item in assignment.items}
|
||
selected_items = [
|
||
items_by_id[question_id]
|
||
for question_id in selected_ids
|
||
if question_id in items_by_id
|
||
]
|
||
question_count = len(selected_items)
|
||
status_label = {
|
||
"pending": "待审核",
|
||
"approved": "待返款",
|
||
"paid": "已完成",
|
||
# Rejected batches can be submitted again, so keep them in the
|
||
# existing board's "进行中" bucket while retaining their history.
|
||
"rejected": "进行中",
|
||
}.get(batch.status, "进行中")
|
||
refund_completed = batch.status == "paid"
|
||
selected_questions = [
|
||
{
|
||
"id": item.question.id,
|
||
"sequence_no": item.question.sequence_no,
|
||
"question": item.question.question_text,
|
||
"answer": "",
|
||
"product_title": task.product_name,
|
||
}
|
||
for item in sorted(selected_items, key=lambda value: value.question.sequence_no)
|
||
]
|
||
detail_endpoint = (
|
||
f"/api/admin/task-board/ask-batches/{batch.id}"
|
||
if batch.status in {"approved", "paid"}
|
||
else None
|
||
)
|
||
return {
|
||
"id": batch.id,
|
||
"batch_id": batch.id,
|
||
"parent_assignment_id": assignment.id,
|
||
"board_key": f"ask-batch-{batch.id}",
|
||
"record_kind": "ask_batch",
|
||
"is_child": True,
|
||
"task_id": task.task_no,
|
||
"task_type": "ask",
|
||
"task_type_label": "提问任务",
|
||
"product_name": task.product_name,
|
||
"shop_name": task.shop_name,
|
||
"product_link": task.product_link,
|
||
"status": batch.status,
|
||
"status_label": status_label,
|
||
"user_id": _public_user_id(assignment.user),
|
||
"taobao_id": assignment.user.taobao_id,
|
||
"phone": _public_user_account(assignment.user),
|
||
"qa_count": question_count,
|
||
"claimed_count": question_count,
|
||
"submitted_count": question_count if batch.status in {"pending", "approved", "paid"} else 0,
|
||
"pending_review_count": 1 if batch.status == "pending" else 0,
|
||
"completed_count": question_count if refund_completed else 0,
|
||
"total_count": question_count,
|
||
"creator_name": task.owner.display_name if task.owner else "运营后台",
|
||
"created_at": batch.created_at.isoformat(),
|
||
"updated_at": batch.updated_at.isoformat(),
|
||
"can_cancel": False,
|
||
"can_complete": False,
|
||
"can_delete": True,
|
||
"detail_endpoint": detail_endpoint,
|
||
"delete_endpoint": f"/api/admin/task-board/ask-batches/{batch.id}",
|
||
"refund_endpoint": (
|
||
f"/api/admin/task-board/ask-batches/{batch.id}/confirm-refund"
|
||
if batch.status == "approved"
|
||
else None
|
||
),
|
||
"assigned_users": [{
|
||
"user_id": _public_user_id(assignment.user),
|
||
"phone": _public_user_account(assignment.user),
|
||
"taobao_id": assignment.user.taobao_id,
|
||
"status": status_label,
|
||
}],
|
||
"selected_questions": selected_questions,
|
||
"question_screenshots": [],
|
||
"completion_screenshots": [],
|
||
"payment_screenshots": [],
|
||
"refund_screenshots": [],
|
||
"refund_proof_uploaded": refund_completed,
|
||
"refund_proof_count": 1 if refund_completed else 0,
|
||
"latest_review": None,
|
||
}
|
||
|
||
|
||
def _ask_task_is_fully_completed(task: AskTask) -> bool:
|
||
selected_ids = {question.id for question in task.questions if question.selected}
|
||
if not selected_ids:
|
||
return False
|
||
completed_ids: set[int] = set()
|
||
for assignment in task.assignments:
|
||
for batch in assignment.batches:
|
||
if batch.status == "paid":
|
||
completed_ids.update(_batch_question_ids(batch))
|
||
if not assignment.batches and assignment.status == "已完成":
|
||
completed_ids.update(item.question_id for item in assignment.items if item.selected)
|
||
return selected_ids.issubset(completed_ids)
|
||
|
||
|
||
def _sync_ask_task_status(task: AskTask) -> None:
|
||
if task.status != "已撤销":
|
||
task.status = "已完成" if _ask_task_is_fully_completed(task) else "已发布"
|
||
task.updated_at = datetime.utcnow()
|
||
|
||
|
||
def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict:
|
||
questions = sorted(task.questions, key=lambda value: value.sequence_no)
|
||
selected_questions = [q for q in questions if q.selected]
|
||
# Keep every submission batch as an independent child row. Falling back
|
||
# to the assignment row preserves compatibility with legacy assignments
|
||
# created before batch submissions were introduced.
|
||
claimed_tasks = []
|
||
for assignment in task.assignments:
|
||
if assignment.batches:
|
||
ordered_batches = sorted(
|
||
assignment.batches,
|
||
key=lambda value: value.created_at or datetime.min,
|
||
)
|
||
for batch_no, batch in enumerate(ordered_batches, start=1):
|
||
child = _ask_batch_child_data(batch)
|
||
child["batch_no"] = batch_no
|
||
child["batch_label"] = f"第{batch_no}批提交"
|
||
claimed_tasks.append(child)
|
||
else:
|
||
claimed_tasks.append(_ask_assignment_child_data(assignment))
|
||
claimed_tasks.sort(
|
||
key=lambda value: value.get("updated_at") or value.get("created_at") or "",
|
||
reverse=True,
|
||
)
|
||
assigned_count = len(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_completed = _ask_task_is_fully_completed(task)
|
||
if task.status == "已撤销":
|
||
status_label = "已撤销"
|
||
elif fully_completed:
|
||
status_label = "已完成"
|
||
else:
|
||
status_label = "进行中"
|
||
return {
|
||
"id": task.id, "board_key": f"ask-task-{task.id}", "record_kind": "ask_task",
|
||
"task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务",
|
||
"public_token": task.public_token,
|
||
"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": 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(),
|
||
"can_cancel": task.status == "已发布", "can_complete": False, "can_delete": True,
|
||
"delete_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}",
|
||
# A system-dispatched task is created by an operator rather than an
|
||
# answer user, so show the creator in the user-id column instead of
|
||
# 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, "source": q.source or "AI生成"} for q in selected_questions] if include_questions else []),
|
||
# One user can contribute several submission batches. Keep the
|
||
# parent detail summary as a unique-user list; the child rows below
|
||
# still expose every batch independently.
|
||
"assigned_users": list({
|
||
child["assigned_users"][0]["user_id"]: child["assigned_users"][0]
|
||
for child in claimed_tasks
|
||
if child.get("assigned_users")
|
||
}.values()),
|
||
"claimed_tasks": claimed_tasks,
|
||
"invited_screenshots": [], "completion_screenshots": [], "payment_screenshots": [], "latest_review": None,
|
||
"detail_endpoint": f"/api/admin/task-board/ask-tasks/{task.id}" if not include_questions else None,
|
||
}
|
||
|
||
|
||
def _answer_review_data(
|
||
record: AnswerSubmission,
|
||
include_images: bool = True,
|
||
image_endpoints: bool = False,
|
||
) -> dict:
|
||
"""Keep completed refunds visible as approved audit history."""
|
||
data = _submission_data(
|
||
record,
|
||
include_images=include_images,
|
||
image_endpoints=image_endpoints,
|
||
)
|
||
if record.status == "已完成":
|
||
data["status"] = "已通过"
|
||
data["refund_completed"] = True
|
||
else:
|
||
data["refund_completed"] = False
|
||
return data
|
||
|
||
|
||
def _ask_review_board_data(
|
||
record: AskAssignmentBatch | AskAssignment,
|
||
include_images: bool = False,
|
||
image_endpoints: bool = False,
|
||
) -> dict:
|
||
"""Convert one approved ask submission into an independent payout row."""
|
||
is_batch = isinstance(record, AskAssignmentBatch)
|
||
assignment = record.assignment if is_batch else record
|
||
if include_images:
|
||
data = _ask_batch_data(record, include_images=True, include_refund_images=True, image_endpoints=image_endpoints) if is_batch else _ask_assignment_data(record, include_images=True, include_refund_images=True, image_endpoints=image_endpoints)
|
||
selected = [question for question in data["questions"] if question["selected"]]
|
||
elif is_batch:
|
||
selected_ids = {int(value) for value in (record.selected_question_ids or "").split(",") if value}
|
||
selected = []
|
||
data = {
|
||
"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": _public_user_id(assignment.user),
|
||
"taobao_id": assignment.user.taobao_id,
|
||
"phone": _public_user_account(assignment.user),
|
||
"created_at": record.created_at.isoformat(),
|
||
"updated_at": record.updated_at.isoformat(),
|
||
"questions": [],
|
||
"question_screenshots": [],
|
||
"payment_screenshots": [],
|
||
"latest_review": None,
|
||
}
|
||
else:
|
||
data = _ask_assignment_data(record, include_images=False, include_refund_images=True)
|
||
selected = [question for question in data["questions"] if question["selected"]]
|
||
selected_ids = {question["id"] for question in selected}
|
||
data["questions"] = []
|
||
refund_completed = record.status == "paid" if is_batch else record.status == "已完成"
|
||
selected_count = len(selected) if include_images else len(selected_ids)
|
||
return {
|
||
**data,
|
||
"id": record.id,
|
||
"board_key": f"ask-{'batch' if is_batch else 'assignment'}-{record.id}",
|
||
"record_kind": "ask_batch" if is_batch else "ask_assignment",
|
||
"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 if refund_completed else 0,
|
||
"total_count": selected_count,
|
||
"creator_name": "运营后台",
|
||
"can_cancel": False,
|
||
"can_delete": True,
|
||
"delete_endpoint": (
|
||
f"/api/admin/task-board/ask-batches/{record.id}"
|
||
if is_batch
|
||
else f"/api/admin/task-board/ask-assignments/{record.id}"
|
||
),
|
||
"user_id": _public_user_id(assignment.user),
|
||
"taobao_id": assignment.user.taobao_id,
|
||
"phone": _public_user_account(assignment.user),
|
||
"assigned_users": [{
|
||
"user_id": _public_user_id(assignment.user),
|
||
"phone": _public_user_account(assignment.user),
|
||
"taobao_id": assignment.user.taobao_id,
|
||
"status": "已完成" if refund_completed else "待返款",
|
||
}],
|
||
"selected_questions": ([{
|
||
"id": question["id"],
|
||
"sequence_no": question["sequence_no"],
|
||
"question": question["question"],
|
||
"answer": "",
|
||
"product_title": assignment.task.product_name,
|
||
} for question in selected] if include_images else []),
|
||
"detail_endpoint": (
|
||
f"/api/admin/task-board/ask-batches/{record.id}"
|
||
if is_batch
|
||
else f"/api/admin/task-board/ask-assignments/{record.id}"
|
||
),
|
||
"refund_endpoint": (
|
||
None
|
||
if refund_completed
|
||
else (
|
||
f"/api/admin/task-board/ask-batches/{record.id}/confirm-refund"
|
||
if is_batch
|
||
else f"/api/admin/task-board/ask-assignments/{record.id}/confirm-refund"
|
||
)
|
||
),
|
||
}
|
||
|
||
|
||
@app.get("/api/admin/auth/answer-link")
|
||
def admin_answer_link(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
"""Return the current operator's stable public answer-task URL token."""
|
||
stored_user = db.get(AdminUser, user.id)
|
||
if not stored_user:
|
||
raise HTTPException(status_code=401, detail="登录状态已失效,请重新登录")
|
||
if not stored_user.answer_public_token:
|
||
stored_user.answer_public_token = _new_answer_public_token(db)
|
||
db.commit()
|
||
db.refresh(stored_user)
|
||
return {"success": True, "data": {
|
||
"token": stored_user.answer_public_token,
|
||
"path": f"/answer/{stored_user.answer_public_token}",
|
||
"last_shop_name": stored_user.last_answer_shop_name or "",
|
||
}}
|
||
|
||
|
||
@app.post("/api/answer/auth/phone-login")
|
||
def answer_phone_login(payload: dict, db: Session = Depends(get_db)):
|
||
# Validate the link before issuing a user session. The token is carried
|
||
# by the client on subsequent requests so a phone number can safely use
|
||
# more than one operator's link without changing the user account model.
|
||
owner_token = str(payload.get("owner_token") or payload.get("operator_token") or "").strip()
|
||
_answer_link_owner(owner_token, db)
|
||
session = _public_phone_session(_phone_login(payload, db))
|
||
session["owner_token"] = owner_token
|
||
return {"success": True, "data": session}
|
||
|
||
|
||
@app.post("/api/answer/auth/taobao-login")
|
||
def answer_taobao_login(payload: dict, db: Session = Depends(get_db)):
|
||
owner_token = str(payload.get("owner_token") or payload.get("operator_token") or "").strip()
|
||
_answer_link_owner(owner_token, db)
|
||
session = _public_phone_session(_taobao_login(payload, db))
|
||
session["owner_token"] = owner_token
|
||
return {"success": True, "data": session}
|
||
|
||
|
||
@app.post("/api/answer/auth/send-code")
|
||
def answer_send_code(payload: dict, db: Session = Depends(get_db)):
|
||
owner_token = str(payload.get("owner_token") or payload.get("operator_token") or "").strip()
|
||
_answer_link_owner(owner_token, db)
|
||
return {"success": True, "data": _send_phone_code(payload)}
|
||
|
||
|
||
@app.post("/api/ask/auth/phone-login")
|
||
def ask_phone_login(payload: dict, db: Session = Depends(get_db)):
|
||
return {"success": True, "data": _public_phone_session(_phone_login(payload, db))}
|
||
|
||
|
||
@app.post("/api/ask/auth/taobao-login")
|
||
def ask_taobao_login(payload: dict, db: Session = Depends(get_db)):
|
||
return {"success": True, "data": _public_phone_session(_taobao_login(payload, db))}
|
||
|
||
|
||
@app.post("/api/ask/auth/send-code")
|
||
def ask_send_code(payload: dict):
|
||
return {"success": True, "data": _send_phone_code(payload)}
|
||
|
||
|
||
def _start_ask_reservation(db: Session, task: AskTask, assignment: AskAssignment) -> bool:
|
||
"""Prepare the user's next selectable batch without touching old batches."""
|
||
used_ids = {item.question_id for item in assignment.used_questions}
|
||
claim_limit = task.claim_questions_per_user or 5
|
||
if len(used_ids) >= claim_limit:
|
||
return False
|
||
existing_ids = {item.question_id for item in assignment.items}
|
||
allocated_elsewhere = set(db.scalars(
|
||
select(AskAssignmentQuestion.question_id)
|
||
.join(AskAssignment, AskAssignmentQuestion.assignment_id == AskAssignment.id)
|
||
.where(AskAssignment.task_id == task.id, AskAssignment.id != assignment.id)
|
||
).all())
|
||
needed = max(claim_limit - len(existing_ids), 0)
|
||
available = [
|
||
question
|
||
for question in task.questions
|
||
if question.selected
|
||
and question.id not in existing_ids
|
||
and question.id not in allocated_elsewhere
|
||
]
|
||
for question in random.sample(available, min(needed, len(available))):
|
||
db.add(AskAssignmentQuestion(
|
||
assignment_id=assignment.id,
|
||
question_id=question.id,
|
||
selected=False,
|
||
))
|
||
db.flush()
|
||
db.expire(assignment, ["items"])
|
||
active_items = [item for item in assignment.items if item.question_id not in used_ids]
|
||
if not active_items:
|
||
return False
|
||
for item in active_items:
|
||
item.selected = False
|
||
assignment.status = "待选择"
|
||
assignment.reservation_started_at = datetime.utcnow()
|
||
assignment.updated_at = assignment.reservation_started_at
|
||
return True
|
||
|
||
|
||
@app.get("/api/ask/tasks/{task_no}")
|
||
def public_ask_task(task_no: str, token: str, refresh: bool = False, db: Session = Depends(get_db)):
|
||
user = _answer_user_from_token(token, db)
|
||
task = _published_ask_task(task_no, db, lock=True)
|
||
current_assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
|
||
current_expired = _ask_assignment_expired(current_assignment)
|
||
released = _release_expired_ask_assignments(db, task)
|
||
if released:
|
||
db.commit()
|
||
db.expire_all()
|
||
if current_expired:
|
||
raise HTTPException(status_code=409, detail="提问任务已失效,请点击刷新重新分配任务")
|
||
if released:
|
||
task = _published_ask_task(task_no, db, lock=True)
|
||
assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
|
||
if not assignment:
|
||
# Keep each question reserved while the user is working. Expired
|
||
# unfinished reservations have already been removed above, so these
|
||
# are the currently allocated questions that must stay excluded.
|
||
allocated_ids = set(db.scalars(
|
||
select(AskAssignmentQuestion.question_id)
|
||
.join(AskAssignment, AskAssignmentQuestion.assignment_id == AskAssignment.id)
|
||
.where(AskAssignment.task_id == task.id)
|
||
).all())
|
||
available = [
|
||
question
|
||
for question in task.questions
|
||
if question.selected and question.id not in allocated_ids
|
||
]
|
||
if not available:
|
||
raise HTTPException(
|
||
status_code=409,
|
||
detail="本次任务已被领取完,请关注下一个任务!",
|
||
)
|
||
claim_limit = task.claim_questions_per_user or 5
|
||
assigned = random.sample(available, min(claim_limit, len(available)))
|
||
assignment = AskAssignment(
|
||
task_id=task.id,
|
||
user_id=user.id,
|
||
status="待选择",
|
||
reservation_started_at=datetime.utcnow(),
|
||
)
|
||
db.add(assignment); db.flush()
|
||
for question in assigned:
|
||
db.add(AskAssignmentQuestion(assignment_id=assignment.id, question_id=question.id, selected=False))
|
||
db.commit(); db.refresh(assignment)
|
||
elif refresh and assignment.status in {"已通过", "已完成"}:
|
||
if not _start_ask_reservation(db, task, assignment):
|
||
raise HTTPException(status_code=409, detail="本次任务已被领取完,请关注下一个任务!")
|
||
db.commit(); db.refresh(assignment)
|
||
latest_batch = db.scalar(
|
||
select(AskAssignmentBatch)
|
||
.where(AskAssignmentBatch.assignment_id == assignment.id)
|
||
.order_by(AskAssignmentBatch.created_at.desc())
|
||
)
|
||
# While the user is selecting a new batch, the parent assignment contains
|
||
# the current selection. Do not replace it with the previous submitted
|
||
# batch or a refresh would appear to undo the user's choices.
|
||
if latest_batch and assignment.status not in {"待选择", "进行中"}:
|
||
data = _ask_batch_data(latest_batch, include_images=False, include_refund_images=latest_batch.status == "paid")
|
||
db.rollback()
|
||
return {"success": True, "data": data}
|
||
data = _ask_assignment_data(assignment, include_images=False)
|
||
db.rollback()
|
||
return {"success": True, "data": data}
|
||
|
||
|
||
@app.post("/api/ask/tasks/{task_no}/release")
|
||
def release_expired_public_ask_assignment(task_no: str, payload: dict, db: Session = Depends(get_db)):
|
||
"""Release an expired reservation without allocating a replacement yet."""
|
||
user = _answer_user_from_token(str(payload.get("token", "")), db)
|
||
task = _published_ask_task(task_no, db)
|
||
assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
|
||
if assignment and _ask_assignment_expired(assignment):
|
||
_release_ask_assignment_reservation(db, assignment)
|
||
db.commit()
|
||
return {"success": True, "data": {"released": True}}
|
||
db.rollback()
|
||
return {"success": True, "data": {"released": False}}
|
||
|
||
|
||
@app.get("/api/ask/me/assignments")
|
||
def public_ask_assignments(token: str, db: Session = Depends(get_db)):
|
||
user = _answer_user_from_token(token, db)
|
||
if _release_expired_ask_assignments(db):
|
||
db.commit()
|
||
records = db.scalars(
|
||
select(AskAssignment)
|
||
.where(AskAssignment.user_id == user.id)
|
||
.order_by(AskAssignment.updated_at.desc())
|
||
).unique().all()
|
||
data = []
|
||
for record in records:
|
||
batches = sorted(record.batches, key=lambda value: value.created_at, reverse=True)
|
||
if batches:
|
||
data.extend(_ask_batch_data(batch, include_images=False, include_refund_images=batch.status == "paid") for batch in batches)
|
||
else:
|
||
data.append(_ask_assignment_data(record, include_images=False, include_refund_images=record.status == "已完成"))
|
||
db.rollback()
|
||
return {"success": True, "data": data}
|
||
|
||
|
||
@app.post("/api/ask/tasks/{task_no}/selection")
|
||
def select_public_ask_questions(task_no: str, payload: dict, db: Session = Depends(get_db)):
|
||
user = _answer_user_from_token(str(payload.get("token", "")), db)
|
||
task = _published_ask_task(task_no, db)
|
||
current_assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
|
||
current_expired = _ask_assignment_expired(current_assignment)
|
||
if _release_expired_ask_assignments(db, task):
|
||
db.commit()
|
||
if current_expired:
|
||
raise HTTPException(status_code=409, detail="提问任务已失效,请点击刷新重新分配任务")
|
||
assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
|
||
if not assignment:
|
||
raise HTTPException(status_code=409, detail="请先领取提问任务")
|
||
try:
|
||
selected_ids = {int(value) for value in payload.get("question_ids", [])}
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(status_code=400, detail="问题选择数据无效")
|
||
assigned_ids = {item.question_id for item in assignment.items}
|
||
if not selected_ids:
|
||
raise HTTPException(status_code=400, detail="请至少选择一个问题")
|
||
if not selected_ids.issubset(assigned_ids):
|
||
raise HTTPException(status_code=400, detail="选择中包含未分配给当前用户的问题")
|
||
used_ids = {item.question_id for item in assignment.used_questions}
|
||
if selected_ids & used_ids:
|
||
raise HTTPException(status_code=400, detail="已认领的问题不能重复选择")
|
||
for item in assignment.items:
|
||
# The selection represents only the next batch. Questions in
|
||
# ``used_ids`` remain locked/read-only and are not resubmitted.
|
||
item.selected = item.question_id in selected_ids
|
||
assignment.status = "进行中"
|
||
assignment.reservation_started_at = datetime.utcnow()
|
||
assignment.updated_at = assignment.reservation_started_at
|
||
db.commit(); db.refresh(assignment)
|
||
data = _ask_assignment_data(assignment, include_images=False)
|
||
db.rollback()
|
||
return {"success": True, "data": data}
|
||
|
||
|
||
@app.post("/api/ask/tasks/{task_no}/submission")
|
||
async def submit_public_ask_task(
|
||
task_no: str,
|
||
token: str = Form(...),
|
||
files: list[UploadFile] = File(...),
|
||
payment_file: UploadFile = File(...),
|
||
payee_name: str = Form(default=""),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
user = _answer_user_from_token(token, db)
|
||
task = _published_ask_task(task_no, db)
|
||
current_assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
|
||
current_expired = _ask_assignment_expired(current_assignment)
|
||
if _release_expired_ask_assignments(db, task):
|
||
db.commit()
|
||
if current_expired:
|
||
raise HTTPException(status_code=409, detail="提问任务已失效,请点击刷新重新分配任务")
|
||
assignment = db.scalar(select(AskAssignment).where(AskAssignment.task_id == task.id, AskAssignment.user_id == user.id))
|
||
if not assignment:
|
||
raise HTTPException(status_code=404, detail="提问任务不存在")
|
||
if assignment.status not in {"进行中", "未通过"}:
|
||
raise HTTPException(status_code=409, detail="当前任务不能提交审核")
|
||
if not any(item.selected for item in assignment.items):
|
||
raise HTTPException(status_code=400, detail="请至少选择一个问题")
|
||
if not files:
|
||
raise HTTPException(status_code=400, detail="请至少上传一张提问截图")
|
||
allowed = {"image/jpeg", "image/png", "image/webp"}
|
||
if any(upload.content_type not in allowed for upload in [*files, payment_file]):
|
||
raise HTTPException(status_code=400, detail="仅支持 JPG、PNG、WEBP 图片")
|
||
selected_ids = [item.question_id for item in assignment.items if item.selected]
|
||
assignment_id = assignment.id
|
||
db.rollback()
|
||
question_uploads = [(upload.filename or "question", upload.content_type, await upload.read()) for upload in files]
|
||
payment_upload = (payment_file.filename or "payment", payment_file.content_type, await payment_file.read())
|
||
assignment = db.get(AskAssignment, assignment_id)
|
||
if not assignment or assignment.status not in {"进行中", "未通过"} or _ask_assignment_expired(assignment):
|
||
raise HTTPException(status_code=409, detail="当前任务不能提交审核")
|
||
selected_ids = [item.question_id for item in assignment.items if item.selected]
|
||
if not selected_ids:
|
||
raise HTTPException(status_code=409, detail="当前没有可提交的问题,请刷新后重试")
|
||
batch = AskAssignmentBatch(
|
||
assignment_id=assignment.id,
|
||
status="pending",
|
||
selected_question_ids=",".join(str(value) for value in selected_ids),
|
||
)
|
||
db.add(batch)
|
||
db.flush()
|
||
for filename, content_type, image_data in question_uploads:
|
||
db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="question", filename=filename, content_type=content_type, image_data=image_data))
|
||
filename, content_type, image_data = payment_upload
|
||
db.add(AskAssignmentBatchScreenshot(batch_id=batch.id, stage="payment", filename=filename, content_type=content_type, image_data=image_data))
|
||
assignment.status = "待审核"
|
||
assignment.reservation_started_at = None
|
||
used_ids = {item.question_id for item in assignment.used_questions}
|
||
for item in assignment.items:
|
||
if item.selected and item.question_id not in used_ids:
|
||
db.add(AskAssignmentUsedQuestion(assignment_id=assignment.id, question_id=item.question_id))
|
||
used_ids.add(item.question_id)
|
||
assignment.updated_at = datetime.utcnow()
|
||
db.commit(); db.refresh(batch); db.refresh(assignment)
|
||
data = _ask_batch_data(batch, include_images=False)
|
||
db.rollback()
|
||
return {"success": True, "data": data}
|
||
|
||
|
||
@app.get("/api/admin/ask-completion-reviews")
|
||
def ask_completion_reviews(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
batches = db.scalars(
|
||
select(AskAssignmentBatch).join(AskAssignmentBatch.assignment).join(AskAssignment.task)
|
||
.where(AskAssignmentBatch.status == "pending")
|
||
.where(_ask_task_owner_clause(user))
|
||
.order_by(AskAssignmentBatch.updated_at.desc())
|
||
).unique().all()
|
||
legacy = db.scalars(
|
||
select(AskAssignment).join(AskAssignment.task).where(
|
||
AskAssignment.status == "待审核",
|
||
~AskAssignment.batches.any(),
|
||
AskAssignment.screenshots.any(),
|
||
_ask_task_owner_clause(user),
|
||
).order_by(AskAssignment.updated_at.desc())
|
||
).unique().all()
|
||
data = [_ask_batch_data(batch, include_images=False) for batch in batches]
|
||
data.extend(_ask_assignment_data(record, include_images=False) for record in legacy)
|
||
return {"success": True, "data": data}
|
||
|
||
|
||
@app.get("/api/admin/review-pending-count")
|
||
def review_pending_count(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
"""Return lightweight sidebar counts without loading review rows or images."""
|
||
answer_count = db.scalar(
|
||
select(func.count(AnswerSubmission.id)).where(
|
||
AnswerSubmission.status == "待审核",
|
||
_answer_owner_clause(user),
|
||
)
|
||
) or 0
|
||
ask_batch_count = db.scalar(
|
||
select(func.count(AskAssignmentBatch.id)).join(AskAssignmentBatch.assignment).join(AskAssignment.task).where(AskAssignmentBatch.status == "pending", _ask_task_owner_clause(user))
|
||
) or 0
|
||
legacy_ask_count = db.scalar(
|
||
select(func.count(AskAssignment.id)).join(AskAssignment.task).where(
|
||
AskAssignment.status == "待审核",
|
||
~AskAssignment.batches.any(),
|
||
AskAssignment.screenshots.any(),
|
||
_ask_task_owner_clause(user),
|
||
)
|
||
) or 0
|
||
ask_count = ask_batch_count + legacy_ask_count
|
||
return {
|
||
"success": True,
|
||
"data": {
|
||
"ask": ask_count,
|
||
"answer": answer_count,
|
||
"total": ask_count + answer_count,
|
||
},
|
||
}
|
||
|
||
|
||
@app.get("/api/admin/ask-batch-reviews/{batch_id}")
|
||
def ask_batch_review_detail(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
batch = db.scalar(
|
||
select(AskAssignmentBatch)
|
||
.where(AskAssignmentBatch.id == batch_id)
|
||
.options(selectinload(AskAssignmentBatch.screenshots).load_only(
|
||
AskAssignmentBatchScreenshot.id,
|
||
AskAssignmentBatchScreenshot.batch_id,
|
||
AskAssignmentBatchScreenshot.stage,
|
||
AskAssignmentBatchScreenshot.filename,
|
||
AskAssignmentBatchScreenshot.content_type,
|
||
))
|
||
)
|
||
if not batch:
|
||
raise HTTPException(status_code=404, detail="提问批次审核任务不存在")
|
||
_require_ask_task_access(batch.assignment.task, user)
|
||
return {"success": True, "data": _ask_batch_data(batch, image_endpoints=True)}
|
||
|
||
|
||
@app.get("/api/admin/ask-assignment-reviews/{assignment_id}")
|
||
def ask_assignment_review_detail(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
assignment = db.scalar(
|
||
select(AskAssignment)
|
||
.where(AskAssignment.id == assignment_id)
|
||
.options(selectinload(AskAssignment.screenshots).load_only(
|
||
AskAssignmentScreenshot.id,
|
||
AskAssignmentScreenshot.assignment_id,
|
||
AskAssignmentScreenshot.stage,
|
||
AskAssignmentScreenshot.filename,
|
||
AskAssignmentScreenshot.content_type,
|
||
))
|
||
)
|
||
if not assignment or not _can_access_ask_task(assignment.task, user):
|
||
raise HTTPException(status_code=404, detail="提问审核任务不存在")
|
||
return {"success": True, "data": _ask_assignment_data(assignment, image_endpoints=True)}
|
||
|
||
|
||
@app.get("/api/admin/ask-batch-screenshots/{screenshot_id}")
|
||
def admin_ask_batch_screenshot(screenshot_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
image = db.get(AskAssignmentBatchScreenshot, screenshot_id)
|
||
if not image or not _can_access_ask_task(image.batch.assignment.task, user):
|
||
raise HTTPException(status_code=404, detail="截图不存在")
|
||
return Response(
|
||
content=image.image_data,
|
||
media_type=image.content_type,
|
||
headers={"Cache-Control": "private, max-age=600"},
|
||
)
|
||
|
||
|
||
@app.get("/api/admin/ask-assignment-screenshots/{screenshot_id}")
|
||
def admin_ask_assignment_screenshot(screenshot_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
image = db.get(AskAssignmentScreenshot, screenshot_id)
|
||
if not image or not _can_access_ask_task(image.assignment.task, user):
|
||
raise HTTPException(status_code=404, detail="截图不存在")
|
||
return Response(
|
||
content=image.image_data,
|
||
media_type=image.content_type,
|
||
headers={"Cache-Control": "private, max-age=600"},
|
||
)
|
||
|
||
|
||
@app.post("/api/admin/ask-batch-reviews/{batch_id}/decision")
|
||
def ask_batch_review_decision(batch_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
batch = db.get(AskAssignmentBatch, batch_id)
|
||
if not batch:
|
||
raise HTTPException(status_code=404, detail="提问批次审核任务不存在")
|
||
_require_ask_task_access(batch.assignment.task, user)
|
||
if batch.status != "pending":
|
||
raise HTTPException(status_code=409, detail="任务不在待审核状态")
|
||
approved = bool(payload.get("approved"))
|
||
reason_code = str(payload.get("reason_code", "")).strip() or None
|
||
reason_text = str(payload.get("reason_text", "")).strip() or None
|
||
if not approved and not (reason_code or reason_text):
|
||
raise HTTPException(status_code=400, detail="请选择或填写审核不通过原因")
|
||
db.add(AskAssignmentBatchReview(batch_id=batch.id, approved=approved, reason_code=reason_code, reason_text=reason_text))
|
||
batch.status = "approved" if approved else "rejected"
|
||
_sync_ask_assignment_status(batch.assignment)
|
||
db.commit(); db.refresh(batch)
|
||
return {"success": True, "data": _ask_batch_data(batch, include_images=False)}
|
||
|
||
|
||
@app.post("/api/admin/ask-assignment-reviews/{assignment_id}/decision")
|
||
def ask_assignment_review_decision(assignment_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
assignment = db.get(AskAssignment, assignment_id)
|
||
if not assignment or not _can_access_ask_task(assignment.task, user):
|
||
raise HTTPException(status_code=404, detail="提问审核任务不存在")
|
||
if assignment.status != "待审核":
|
||
raise HTTPException(status_code=409, detail="任务不在待审核状态")
|
||
approved = bool(payload.get("approved"))
|
||
reason_code = str(payload.get("reason_code", "")).strip() or None
|
||
reason_text = str(payload.get("reason_text", "")).strip() or None
|
||
if not approved and not (reason_code or reason_text):
|
||
raise HTTPException(status_code=400, detail="请选择或填写审核不通过原因")
|
||
db.add(AskAssignmentReview(assignment_id=assignment.id, approved=approved, reason_code=reason_code, reason_text=reason_text))
|
||
if approved:
|
||
used_ids = {item.question_id for item in assignment.used_questions}
|
||
for item in assignment.items:
|
||
if item.selected and item.question_id not in used_ids:
|
||
db.add(AskAssignmentUsedQuestion(assignment_id=assignment.id, question_id=item.question_id))
|
||
used_ids.add(item.question_id)
|
||
assignment.status = "已通过" if approved else "未通过"
|
||
assignment.updated_at = datetime.utcnow()
|
||
db.commit(); db.refresh(assignment)
|
||
return {"success": True, "data": _ask_assignment_data(assignment, include_images=False)}
|
||
|
||
|
||
@app.get("/api/answer/me/submissions")
|
||
def answer_my_submissions(token: str, owner_token: str, include_images: bool = False, db: Session = Depends(get_db)):
|
||
user = _answer_user_from_token(token, db)
|
||
owner = _answer_link_owner(owner_token, db)
|
||
statement = select(AnswerSubmission).where(
|
||
AnswerSubmission.user_id == user.id,
|
||
AnswerSubmission.owner_id == owner.id,
|
||
)
|
||
records = db.scalars(statement.order_by(AnswerSubmission.created_at.desc())).unique().all()
|
||
return {"success": True, "data": [_submission_data(record, include_images=include_images) for record in records]}
|
||
|
||
|
||
@app.get("/api/answer/me/submissions/{submission_id}")
|
||
def answer_submission_detail(submission_id: int, token: str, owner_token: str, db: Session = Depends(get_db)):
|
||
user = _answer_user_from_token(token, db)
|
||
owner = _answer_link_owner(owner_token, db)
|
||
record = db.get(AnswerSubmission, submission_id)
|
||
if not record or record.user_id != user.id or record.owner_id != owner.id:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
return {"success": True, "data": _submission_data(record, include_images=True, include_refund_images=True)}
|
||
|
||
|
||
def _process_answer_submission(submission_id: int):
|
||
"""Run OCR and answer drafting after the upload response has been sent."""
|
||
db = SessionLocal()
|
||
try:
|
||
record = db.get(AnswerSubmission, submission_id)
|
||
if not record or record.status != "待筛选":
|
||
return
|
||
saved = sorted(
|
||
(shot for shot in record.screenshots if shot.stage == "invited"),
|
||
key=lambda shot: shot.id,
|
||
)
|
||
# A SQLAlchemy Session keeps its transaction/connection until
|
||
# commit/rollback. Do not hold that connection while OCR/LLM calls
|
||
# (which can take up to the configured timeout) are running.
|
||
saved_payload = [(shot.image_data, shot.content_type) for shot in saved]
|
||
db.rollback()
|
||
extracted = extract_questions_from_images(saved_payload)
|
||
if not extracted:
|
||
record = db.get(AnswerSubmission, submission_id)
|
||
if record and record.status == "待筛选" and not record.questions:
|
||
record.status = "识别失败"
|
||
record.updated_at = datetime.utcnow()
|
||
db.commit()
|
||
return
|
||
drafted = [
|
||
{
|
||
"product_title": item["product_title"],
|
||
"question": item["question"],
|
||
"source_index": int(item.get("source_index", 0)),
|
||
"answer": generate_answer_for_question(item["product_title"], item["question"]),
|
||
}
|
||
for item in extracted
|
||
]
|
||
# Re-read after the external calls so the write happens in a short,
|
||
# fresh transaction and does not overwrite a concurrent state change.
|
||
record = db.get(AnswerSubmission, submission_id)
|
||
if not record or record.status != "待筛选":
|
||
db.rollback()
|
||
return
|
||
saved = sorted(
|
||
(shot for shot in record.screenshots if shot.stage == "invited"),
|
||
key=lambda shot: shot.id,
|
||
)
|
||
for index, item in enumerate(drafted):
|
||
product, question = item["product_title"], item["question"]
|
||
source_index = min(max(item["source_index"], 0), len(saved) - 1)
|
||
source = saved[source_index]
|
||
db.add(AnswerRecognizedQuestion(
|
||
submission_id=record.id,
|
||
source_screenshot_id=source.id,
|
||
product_image_screenshot_id=source.id,
|
||
sequence_no=index + 1,
|
||
product_title=product,
|
||
question_text=question,
|
||
answer_text=item["answer"],
|
||
answer_status="已生成",
|
||
))
|
||
record.updated_at = datetime.utcnow()
|
||
db.commit()
|
||
except Exception:
|
||
db.rollback()
|
||
# Keep the upload and expose an explicit retry state. Never invent
|
||
# questions when OCR or answer drafting fails.
|
||
try:
|
||
record = db.get(AnswerSubmission, submission_id)
|
||
if record and record.status == "待筛选" and not record.questions:
|
||
record.status = "识别失败"
|
||
record.updated_at = datetime.utcnow()
|
||
db.commit()
|
||
except Exception:
|
||
db.rollback()
|
||
finally:
|
||
db.close()
|
||
|
||
|
||
@app.post("/api/answer/me/submissions")
|
||
async def answer_create_submission(
|
||
background_tasks: BackgroundTasks,
|
||
token: str = Form(...),
|
||
owner_token: str = Form(...),
|
||
files: list[UploadFile] = File(...),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
user = _answer_user_from_token(token, db)
|
||
owner = _answer_link_owner(owner_token, db)
|
||
user_id = user.id
|
||
db.rollback()
|
||
if not files or len(files) > 2:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="一次最多上传2张截图")
|
||
allowed = {"image/jpeg", "image/png", "image/webp"}
|
||
uploaded = []
|
||
for upload in files:
|
||
if upload.content_type not in allowed:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片")
|
||
image_data = await upload.read()
|
||
if not image_data:
|
||
raise HTTPException(status_code=400, detail="不能上传空图片")
|
||
if len(image_data) > 10 * 1024 * 1024:
|
||
raise HTTPException(status_code=413, detail="单张图片不能超过10MB")
|
||
uploaded.append((upload.filename or "screenshot", upload.content_type, image_data))
|
||
# Persist only after all asynchronous file reads have completed.
|
||
record = AnswerSubmission(submission_no=f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", user_id=user_id, owner_id=owner.id, status="待筛选")
|
||
db.add(record); db.flush()
|
||
for filename, content_type, image_data in uploaded:
|
||
db.add(AnswerScreenshot(submission_id=record.id, stage="invited", filename=filename, content_type=content_type, image_data=image_data))
|
||
db.commit(); db.refresh(record)
|
||
background_tasks.add_task(_process_answer_submission, record.id)
|
||
# 先返回轻量结果,图片由后续状态查询按需加载。
|
||
return {"success": True, "data": _submission_data(record, include_images=False)}
|
||
|
||
|
||
@app.get("/api/admin/answer-task-drafts")
|
||
def admin_answer_task_drafts(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.status.in_(["待筛选", "识别失败", "待运营派发", "已派发", "待用户完成"])).where(_answer_owner_clause(user)).order_by(AnswerSubmission.created_at.desc())).unique().all()
|
||
return {"success": True, "data": [_submission_data(record, include_images=False) for record in records]}
|
||
|
||
|
||
@app.get("/api/admin/answer-task-drafts/{submission_id}")
|
||
def admin_answer_task_draft_detail(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
record = db.get(AnswerSubmission, submission_id)
|
||
if not record or record.status not in {"待筛选", "识别失败", "待运营派发", "已派发", "待用户完成"} or not _can_access_answer_submission(record, user):
|
||
raise HTTPException(status_code=404, detail="回答任务不存在")
|
||
return {"success": True, "data": _submission_data(record, include_images=True)}
|
||
|
||
|
||
@app.post("/api/admin/answer-task-drafts/{submission_id}/retry-recognition")
|
||
def retry_answer_task_recognition(
|
||
submission_id: int,
|
||
background_tasks: BackgroundTasks,
|
||
user: AdminUser = Depends(current_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
record = db.get(AnswerSubmission, submission_id)
|
||
if not record or not _can_access_answer_submission(record, user):
|
||
raise HTTPException(status_code=404, detail="回答任务不存在")
|
||
legacy_fake_questions = _has_legacy_fake_ocr_questions(record)
|
||
if record.status != "识别失败" and not legacy_fake_questions:
|
||
raise HTTPException(status_code=409, detail="当前任务不需要重新识别")
|
||
if not any(image.stage == "invited" for image in record.screenshots):
|
||
raise HTTPException(status_code=400, detail="没有可重新识别的截图")
|
||
if legacy_fake_questions:
|
||
for question in list(record.questions):
|
||
db.delete(question)
|
||
db.flush()
|
||
record.status = "待筛选"
|
||
record.updated_at = datetime.utcnow()
|
||
db.commit()
|
||
background_tasks.add_task(_process_answer_submission, record.id)
|
||
return {"success": True, "data": _submission_data(record, include_images=False)}
|
||
|
||
|
||
@app.post("/api/admin/answer-task-drafts/questions/{question_id}/operator-image")
|
||
async def upload_answer_operator_images(
|
||
question_id: int,
|
||
files: list[UploadFile] = File(...),
|
||
user: AdminUser = Depends(current_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
"""Attach up to three optional operation-specified images to one question."""
|
||
question = db.get(AnswerRecognizedQuestion, question_id)
|
||
record = db.get(AnswerSubmission, question.submission_id) if question else None
|
||
if not question or not record or record.status not in {"待筛选", "待运营派发", "已派发"} or not _can_access_answer_submission(record, user):
|
||
raise HTTPException(status_code=404, detail="回答问题不存在或已经发布")
|
||
if not files or len(files) > 3:
|
||
raise HTTPException(status_code=400, detail="每道问题最多上传3张运营指定图片")
|
||
record_id = record.id
|
||
question_id_value = question.id
|
||
db.rollback()
|
||
uploaded = []
|
||
for file in files:
|
||
if file.content_type not in {"image/jpeg", "image/png", "image/webp"}:
|
||
raise HTTPException(status_code=400, detail="运营指定图片仅支持 JPG、PNG、WEBP")
|
||
image_data = await file.read()
|
||
if not image_data:
|
||
raise HTTPException(status_code=400, detail="不能上传空图片")
|
||
uploaded.append((file.filename or "operator-image", file.content_type, image_data))
|
||
question = db.get(AnswerRecognizedQuestion, question_id_value)
|
||
record = db.get(AnswerSubmission, record_id)
|
||
if not question or not record or record.status not in {"待筛选", "待运营派发", "已派发"} or not _can_access_answer_submission(record, user):
|
||
raise HTTPException(status_code=404, detail="回答问题不存在或已经发布")
|
||
old_ids = [value for value in (question.operator_image_screenshot_ids or "").split(",") if value.strip().isdigit()]
|
||
for old_id in old_ids:
|
||
old = db.get(AnswerScreenshot, int(old_id))
|
||
if old:
|
||
db.delete(old)
|
||
new_ids = []
|
||
for filename, content_type, image_data in uploaded:
|
||
image = AnswerScreenshot(submission_id=record.id, stage="operator", filename=filename, content_type=content_type, image_data=image_data)
|
||
db.add(image); db.flush(); new_ids.append(str(image.id))
|
||
question.operator_image_screenshot_ids = ",".join(new_ids)
|
||
question.operator_image_screenshot_id = int(new_ids[0]) if new_ids else None
|
||
db.commit()
|
||
db.refresh(record)
|
||
return {"success": True, "data": _submission_data(record, include_images=True)}
|
||
|
||
|
||
@app.post("/api/admin/answer-task-drafts/{submission_id}/reject")
|
||
def reject_answer_task_draft(submission_id: int, 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="回答任务不存在")
|
||
if record.status not in {"待筛选", "识别失败", "待运营派发"}:
|
||
raise HTTPException(status_code=409, detail="当前任务不能打回")
|
||
db.delete(record)
|
||
db.commit()
|
||
return {"success": True, "data": {"id": submission_id}}
|
||
|
||
|
||
@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()
|
||
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)
|
||
options_error = _generation_options_error(payload, include_answers=False)
|
||
if options_error:
|
||
raise HTTPException(status_code=400, detail=options_error)
|
||
try:
|
||
count = int(payload.get("question_count", 5))
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(status_code=400, detail="提问生成条数必须是正整数")
|
||
if count < 1:
|
||
raise HTTPException(status_code=400, detail="提问生成条数必须大于 0")
|
||
raw_claim_limit = payload.get("claim_questions_per_user", "")
|
||
if str(raw_claim_limit).strip():
|
||
try:
|
||
claim_limit = int(raw_claim_limit)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(status_code=400, detail="每个用户可认领的问题数量必须是 2-5 的整数")
|
||
if claim_limit < 2 or claim_limit > 5:
|
||
raise HTTPException(status_code=400, detail="每个用户可认领的问题数量必须在 2-5 条之间")
|
||
else:
|
||
claim_limit = 5
|
||
existing_task_id = payload.get("task_id")
|
||
try:
|
||
task = db.get(AskTask, int(existing_task_id)) if existing_task_id else None
|
||
except (TypeError, ValueError):
|
||
task = None
|
||
if task:
|
||
_require_ask_task_access(task, user)
|
||
if task.status != "草稿":
|
||
raise HTTPException(status_code=409, detail="已发布任务不能重新生成")
|
||
existing_id = task.id if task else None
|
||
# Release the authentication/existence-check connection before any LLM
|
||
# request. The task is reloaded below before writing the result.
|
||
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(generation_payload, count, reference_text)
|
||
# 自定义问题优先,不足的数量用候选问题补齐,保证返回数量与运营填写值一致。
|
||
generated: list[str] = []
|
||
for value in questions:
|
||
question = str(value).strip()
|
||
if question and question not in generated:
|
||
generated.append(question)
|
||
if len(generated) == count:
|
||
break
|
||
if len(generated) < count:
|
||
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", ""))
|
||
task.selling_points = str(payload.get("selling_points", ""))
|
||
task.language_style = str(payload.get("language_style", ""))
|
||
task.target_group = str(payload.get("target_group", ""))
|
||
task.usage_scene = str(payload.get("usage_scene", ""))
|
||
task.purchase_channel = str(payload.get("purchase_channel", ""))
|
||
task.claim_questions_per_user = claim_limit
|
||
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=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()
|
||
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)}
|
||
|
||
|
||
@app.patch("/api/admin/ask-tasks/{task_id}/questions/{question_id}")
|
||
def update_ask_question(task_id: int, question_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
question = db.scalar(select(AskQuestion).where(AskQuestion.id == question_id, AskQuestion.task_id == task_id))
|
||
if not question or not _can_access_ask_task(question.task, user): raise HTTPException(status_code=404, detail="提问不存在")
|
||
if question.task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能修改")
|
||
if "question" in payload:
|
||
value = str(payload.get("question", "")).strip()
|
||
if not value: raise HTTPException(status_code=400, detail="提问内容不能为空")
|
||
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, "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)):
|
||
# 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="提问内容不能为空")
|
||
# 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, "source": question.source}}
|
||
|
||
|
||
@app.post("/api/admin/ask-tasks/{task_id}/questions/bulk")
|
||
def add_ask_questions_bulk(task_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
"""Append an imported question list to an editable ask-task draft."""
|
||
task = db.scalar(select(AskTask).where(AskTask.id == task_id).with_for_update())
|
||
_require_ask_task_access(task, user)
|
||
if task.status != "草稿":
|
||
raise HTTPException(status_code=409, detail="已发布任务不能添加提问")
|
||
values = payload.get("questions")
|
||
if not isinstance(values, list) or not values:
|
||
raise HTTPException(status_code=400, detail="没有可导入的问题")
|
||
if len(values) > 500:
|
||
raise HTTPException(status_code=400, detail="单次最多导入 500 条问题")
|
||
existing = {question_key for question_key in (
|
||
re.sub(r"\s+", " ", item.question_text.strip()) for item in task.questions
|
||
) if question_key}
|
||
incoming = []
|
||
seen = set()
|
||
for value in values:
|
||
question_text = re.sub(r"\s+", " ", str(value or "").strip())
|
||
if not question_text or len(question_text) > 500 or question_text in existing or question_text in seen:
|
||
continue
|
||
seen.add(question_text)
|
||
incoming.append(question_text)
|
||
next_sequence = max((item.sequence_no for item in task.questions), default=0) + 1
|
||
added = [
|
||
AskQuestion(
|
||
task_id=task.id,
|
||
sequence_no=next_sequence + index,
|
||
question_text=question_text,
|
||
source="批量导入",
|
||
selected=True,
|
||
)
|
||
for index, question_text in enumerate(incoming)
|
||
]
|
||
db.add_all(added)
|
||
db.commit()
|
||
for question in added:
|
||
db.refresh(question)
|
||
return {"success": True, "data": {
|
||
"added_count": len(added),
|
||
"skipped_count": len(values) - len(added),
|
||
"items": [{
|
||
"id": question.id,
|
||
"sequence_no": question.sequence_no,
|
||
"question": question.question_text,
|
||
"selected": question.selected,
|
||
"source": question.source,
|
||
} for question in added],
|
||
}}
|
||
|
||
|
||
@app.delete("/api/admin/ask-tasks/{task_id}/questions/{question_id}")
|
||
def delete_ask_question(task_id: int, question_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
question = db.scalar(select(AskQuestion).where(AskQuestion.id == question_id, AskQuestion.task_id == task_id))
|
||
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")
|
||
def publish_ask_task(task_id: int, 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 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)
|
||
task.status = "已发布"; db.commit(); db.refresh(task)
|
||
return {"success": True, "data": _ask_task_data(task)}
|
||
|
||
|
||
@app.post("/api/admin/ask-tasks/{task_id}/cancel")
|
||
def cancel_ask_task(task_id: int, 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="当前任务不能撤销")
|
||
task.status = "已撤销"; db.commit(); db.refresh(task)
|
||
return {"success": True, "data": _ask_task_data(task)}
|
||
|
||
|
||
@app.post("/api/admin/ask-tasks/{task_id}/complete")
|
||
def complete_ask_task(task_id: int, 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="当前任务不能完成归档")
|
||
if not _ask_task_is_fully_completed(task):
|
||
raise HTTPException(status_code=409, detail="全部问题完成返款后,任务才会自动显示为已完成")
|
||
task.status = "已完成"
|
||
task.updated_at = datetime.utcnow()
|
||
db.commit()
|
||
db.refresh(task)
|
||
return {"success": True, "data": _ask_task_data(task)}
|
||
|
||
|
||
@app.patch("/api/admin/answer-task-drafts/questions/{question_id}")
|
||
def update_answer_question(question_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
question = db.get(AnswerRecognizedQuestion, question_id)
|
||
record = db.get(AnswerSubmission, question.submission_id) if question else None
|
||
if not question or not record or not _can_access_answer_submission(record, user):
|
||
raise HTTPException(status_code=404, detail="问题不存在")
|
||
if payload.get("regenerate"):
|
||
product_title, question_text = question.product_title, question.question_text
|
||
selected = bool(payload["selected"]) if "selected" in payload else None
|
||
# Do not keep the ORM connection checked out during the model request.
|
||
db.rollback()
|
||
answer_text = generate_answer_for_question(product_title, question_text)
|
||
question = db.get(AnswerRecognizedQuestion, question_id)
|
||
record = db.get(AnswerSubmission, question.submission_id) if question else None
|
||
if not question or not record or not _can_access_answer_submission(record, user):
|
||
raise HTTPException(status_code=404, detail="问题不存在")
|
||
if selected is not None:
|
||
question.selected = selected
|
||
question.answer_text = answer_text
|
||
else:
|
||
if "selected" in payload: question.selected = bool(payload["selected"])
|
||
# An empty answer is a valid edit: the operator may intentionally clear
|
||
# the generated response. Checking truthiness here used to restore the
|
||
# previous text whenever a textarea was cleared and blurred.
|
||
if "answer" in payload:
|
||
question.answer_text = str(payload["answer"] or "").strip()
|
||
db.commit(); db.refresh(question)
|
||
return {"success": True, "data": {"id": question.id, "answer": question.answer_text, "selected": question.selected}}
|
||
|
||
|
||
@app.post("/api/admin/answer-task-drafts/{submission_id}/publish")
|
||
def publish_answer_task(submission_id: int, payload: dict = Body(default={}), 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="提交记录不存在")
|
||
if record.status not in {"待筛选", "待运营派发", "已派发"}: raise HTTPException(status_code=409, detail="当前任务已经发布或不能发布")
|
||
if not any(q.selected for q in record.questions): raise HTTPException(status_code=400, detail="请至少选择一个问题")
|
||
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个字符")
|
||
record.shop_name = shop_name
|
||
if record.owner_id is None:
|
||
record.owner_id = user.id
|
||
stored_user = db.get(AdminUser, user.id)
|
||
if stored_user:
|
||
stored_user.last_answer_shop_name = shop_name
|
||
record.status = "待用户完成"; db.commit()
|
||
return {"success": True, "data": _submission_data(record, include_images=False)}
|
||
|
||
|
||
@app.get("/api/admin/task-board")
|
||
def admin_task_board(
|
||
page: int = 1,
|
||
page_size: int = 20,
|
||
task_type: str = "all",
|
||
status_filter: str = "all",
|
||
keyword: str = "",
|
||
date_from: str = "",
|
||
date_to: str = "",
|
||
user: AdminUser = Depends(current_user),
|
||
db: Session = Depends(get_db),
|
||
):
|
||
if _release_expired_ask_assignments(db):
|
||
db.commit()
|
||
page = max(page, 1)
|
||
page_size = min(max(page_size, 10), 100)
|
||
task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"]
|
||
answer_candidate = (
|
||
select(
|
||
literal("answer").label("record_kind"),
|
||
AnswerSubmission.id.label("record_id"),
|
||
AnswerSubmission.updated_at.label("sort_at"),
|
||
)
|
||
.join(AnswerSubmission.user)
|
||
.where(AnswerSubmission.status.in_(task_statuses), _answer_owner_clause(user))
|
||
)
|
||
ask_candidate = (
|
||
select(
|
||
literal("ask").label("record_kind"),
|
||
AskTask.id.label("record_id"),
|
||
AskTask.updated_at.label("sort_at"),
|
||
)
|
||
.where(
|
||
AskTask.status.in_(["已发布", "已完成", "已撤销"]),
|
||
_ask_task_owner_clause(user),
|
||
)
|
||
)
|
||
|
||
normalized_type = task_type if task_type in {"ask", "answer"} else "all"
|
||
candidates = []
|
||
if normalized_type in {"all", "answer"}:
|
||
if status_filter == "进行中":
|
||
answer_candidate = answer_candidate.where(AnswerSubmission.status.in_(["待用户完成", "未通过"]))
|
||
elif status_filter == "待审核":
|
||
answer_candidate = answer_candidate.where(AnswerSubmission.status == "待审核")
|
||
elif status_filter == "待返款":
|
||
answer_candidate = answer_candidate.where(AnswerSubmission.status == "已通过")
|
||
elif status_filter == "已完成":
|
||
answer_candidate = answer_candidate.where(AnswerSubmission.status == "已完成")
|
||
elif status_filter == "已撤销":
|
||
answer_candidate = answer_candidate.where(AnswerSubmission.status == "已撤销")
|
||
candidates.append(answer_candidate)
|
||
if normalized_type in {"all", "ask"}:
|
||
if status_filter == "进行中":
|
||
ask_candidate = ask_candidate.where(AskTask.status == "已发布")
|
||
elif status_filter == "待审核":
|
||
ask_candidate = ask_candidate.where(
|
||
or_(
|
||
AskTask.assignments.any(AskAssignment.batches.any(AskAssignmentBatch.status == "pending")),
|
||
AskTask.assignments.any(
|
||
(AskAssignment.status == "待审核") & ~AskAssignment.batches.any()
|
||
),
|
||
)
|
||
)
|
||
elif status_filter == "待返款":
|
||
ask_candidate = ask_candidate.where(
|
||
or_(
|
||
AskTask.assignments.any(AskAssignment.batches.any(AskAssignmentBatch.status == "approved")),
|
||
AskTask.assignments.any(
|
||
(AskAssignment.status == "已通过") & ~AskAssignment.batches.any()
|
||
),
|
||
)
|
||
)
|
||
elif status_filter == "已完成":
|
||
ask_candidate = ask_candidate.where(AskTask.status == "已完成")
|
||
elif status_filter == "已撤销":
|
||
ask_candidate = ask_candidate.where(AskTask.status == "已撤销")
|
||
candidates.append(ask_candidate)
|
||
|
||
normalized_keyword = keyword.strip()
|
||
if normalized_keyword:
|
||
pattern = f"%{normalized_keyword}%"
|
||
answer_filter = or_(
|
||
AnswerSubmission.submission_no.like(pattern),
|
||
AnswerSubmission.shop_name.like(pattern),
|
||
AnswerSubmission.product_name.like(pattern),
|
||
AnswerSubmission.user.has(or_(
|
||
AnswerUser.taobao_id.like(pattern),
|
||
AnswerUser.public_id.like(pattern),
|
||
AnswerUser.phone.like(pattern),
|
||
)),
|
||
)
|
||
ask_filter = or_(
|
||
AskTask.task_no.like(pattern),
|
||
AskTask.shop_name.like(pattern),
|
||
AskTask.product_name.like(pattern),
|
||
AskTask.owner.has(AdminUser.display_name.like(pattern)),
|
||
AskTask.assignments.any(AskAssignment.user.has(or_(
|
||
AnswerUser.taobao_id.like(pattern),
|
||
AnswerUser.public_id.like(pattern),
|
||
AnswerUser.phone.like(pattern),
|
||
))),
|
||
)
|
||
candidates = [
|
||
statement.where(answer_filter if normalized_type == "answer" or index == 0 and normalized_type == "all" else ask_filter)
|
||
for index, statement in enumerate(candidates)
|
||
]
|
||
|
||
try:
|
||
start_at = datetime.fromisoformat(date_from) if date_from else None
|
||
end_at = datetime.fromisoformat(date_to) + timedelta(days=1) if date_to else None
|
||
except ValueError:
|
||
raise HTTPException(status_code=400, detail="日期筛选格式无效")
|
||
dated_candidates = []
|
||
for index, statement in enumerate(candidates):
|
||
is_answer = normalized_type == "answer" or (normalized_type == "all" and index == 0)
|
||
created_column = AnswerSubmission.created_at if is_answer else AskTask.created_at
|
||
if start_at:
|
||
statement = statement.where(created_column >= start_at)
|
||
if end_at:
|
||
statement = statement.where(created_column < end_at)
|
||
dated_candidates.append(statement)
|
||
candidates = dated_candidates
|
||
|
||
combined = candidates[0].subquery() if len(candidates) == 1 else union_all(*candidates).subquery()
|
||
total = int(db.scalar(select(func.count()).select_from(combined)) or 0)
|
||
total_pages = max((total + page_size - 1) // page_size, 1)
|
||
page = min(page, total_pages)
|
||
page_rows = db.execute(
|
||
select(combined.c.record_kind, combined.c.record_id)
|
||
.order_by(combined.c.sort_at.desc(), combined.c.record_kind, combined.c.record_id.desc())
|
||
.offset((page - 1) * page_size)
|
||
.limit(page_size)
|
||
).all()
|
||
answer_ids = [record_id for kind, record_id in page_rows if kind == "answer"]
|
||
ask_ids = [record_id for kind, record_id in page_rows if kind == "ask"]
|
||
records = db.scalars(
|
||
select(AnswerSubmission).options(
|
||
selectinload(AnswerSubmission.user),
|
||
selectinload(AnswerSubmission.questions),
|
||
noload(AnswerSubmission.screenshots),
|
||
noload(AnswerSubmission.review_decisions),
|
||
)
|
||
.where(AnswerSubmission.id.in_(answer_ids))
|
||
).unique().all()
|
||
# The board serializer walks several levels below an ask task. Loading
|
||
# those relationships lazily used to issue multiple SQL statements per
|
||
# task (more than 160 queries for only 65 board rows). Batch-load the
|
||
# complete lightweight graph and leave image BLOB relationships unloaded.
|
||
ask_assignment_loader = selectinload(AskTask.assignments)
|
||
ask_statement = (
|
||
select(AskTask)
|
||
.options(
|
||
defer(AskTask.product_qr_image_data),
|
||
selectinload(AskTask.owner),
|
||
selectinload(AskTask.questions),
|
||
ask_assignment_loader.selectinload(AskAssignment.user),
|
||
ask_assignment_loader.selectinload(AskAssignment.items).selectinload(
|
||
AskAssignmentQuestion.question
|
||
),
|
||
ask_assignment_loader.selectinload(AskAssignment.used_questions),
|
||
ask_assignment_loader.selectinload(AskAssignment.batches).options(
|
||
noload(AskAssignmentBatch.screenshots),
|
||
noload(AskAssignmentBatch.review_decisions),
|
||
),
|
||
ask_assignment_loader.options(
|
||
noload(AskAssignment.screenshots),
|
||
noload(AskAssignment.review_decisions),
|
||
),
|
||
)
|
||
.where(AskTask.id.in_(ask_ids))
|
||
)
|
||
ask_tasks = db.scalars(ask_statement).unique().all()
|
||
answer_map = {record.id: _task_board_data(record, include_images=False) for record in records}
|
||
ask_map = {task.id: _ask_task_data(task, include_questions=False) for task in ask_tasks}
|
||
data = [
|
||
(answer_map if kind == "answer" else ask_map)[record_id]
|
||
for kind, record_id in page_rows
|
||
]
|
||
return {"success": True, "data": {
|
||
"items": data,
|
||
"page": page,
|
||
"page_size": page_size,
|
||
"total": total,
|
||
"total_pages": total_pages,
|
||
}}
|
||
|
||
|
||
@app.get("/api/admin/task-board/ask-tasks/{task_id}")
|
||
def admin_task_board_ask_task_detail(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
task = db.get(AskTask, task_id)
|
||
if not task or task.status not in {"已发布", "已完成", "已撤销"} or not _can_access_ask_task(task, user):
|
||
raise HTTPException(status_code=404, detail="提问任务不存在")
|
||
return {"success": True, "data": _ask_task_data(task, include_questions=True)}
|
||
|
||
|
||
@app.delete("/api/admin/task-board/ask-tasks/{task_id}")
|
||
def admin_task_board_delete_ask_task(task_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
task = db.get(AskTask, task_id)
|
||
_require_ask_task_access(task, user)
|
||
# AskTask's assignment relationship is intentionally not orphan-cascaded
|
||
# because assignments may be created by users. Remove those dependent
|
||
# records explicitly before deleting the task to keep every status
|
||
# deletable without leaving invalid foreign keys behind.
|
||
for assignment in list(task.assignments):
|
||
db.delete(assignment)
|
||
db.delete(task)
|
||
db.commit()
|
||
return {"success": True, "data": {"id": task_id}}
|
||
|
||
|
||
@app.get("/api/admin/task-board/ask-batches/{batch_id}")
|
||
def admin_task_board_ask_batch_detail(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
batch = db.scalar(
|
||
select(AskAssignmentBatch)
|
||
.where(AskAssignmentBatch.id == batch_id)
|
||
.options(selectinload(AskAssignmentBatch.screenshots).load_only(
|
||
AskAssignmentBatchScreenshot.id,
|
||
AskAssignmentBatchScreenshot.batch_id,
|
||
AskAssignmentBatchScreenshot.stage,
|
||
AskAssignmentBatchScreenshot.filename,
|
||
AskAssignmentBatchScreenshot.content_type,
|
||
))
|
||
)
|
||
if not batch or batch.status not in {"approved", "paid"} or not _can_access_ask_task(batch.assignment.task, user):
|
||
raise HTTPException(status_code=404, detail="待返款提问记录不存在")
|
||
return {"success": True, "data": _ask_review_board_data(batch, include_images=True, image_endpoints=True)}
|
||
|
||
|
||
@app.delete("/api/admin/task-board/ask-batches/{batch_id}")
|
||
def admin_task_board_delete_ask_batch(batch_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
batch = db.get(AskAssignmentBatch, batch_id)
|
||
if not batch or not _can_access_ask_task(batch.assignment.task, user):
|
||
raise HTTPException(status_code=404, detail="提问提交记录不存在")
|
||
task = batch.assignment.task
|
||
db.delete(batch)
|
||
db.flush()
|
||
db.expire(task, ["assignments"])
|
||
_sync_ask_task_status(task)
|
||
db.commit()
|
||
return {"success": True, "data": {"id": batch_id}}
|
||
|
||
|
||
@app.get("/api/admin/task-board/ask-assignments/{assignment_id}")
|
||
def admin_task_board_ask_assignment_detail(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
assignment = db.scalar(
|
||
select(AskAssignment)
|
||
.where(AskAssignment.id == assignment_id)
|
||
.options(selectinload(AskAssignment.screenshots).load_only(
|
||
AskAssignmentScreenshot.id,
|
||
AskAssignmentScreenshot.assignment_id,
|
||
AskAssignmentScreenshot.stage,
|
||
AskAssignmentScreenshot.filename,
|
||
AskAssignmentScreenshot.content_type,
|
||
))
|
||
)
|
||
if not assignment or assignment.status not in {"已通过", "已完成"} or not _can_access_ask_task(assignment.task, user):
|
||
raise HTTPException(status_code=404, detail="待返款提问记录不存在")
|
||
return {"success": True, "data": _ask_review_board_data(assignment, include_images=True, image_endpoints=True)}
|
||
|
||
|
||
@app.delete("/api/admin/task-board/ask-assignments/{assignment_id}")
|
||
def admin_task_board_delete_ask_assignment(assignment_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
assignment = db.get(AskAssignment, assignment_id)
|
||
if not assignment or not _can_access_ask_task(assignment.task, user):
|
||
raise HTTPException(status_code=404, detail="提问分配记录不存在")
|
||
task = assignment.task
|
||
db.delete(assignment)
|
||
db.flush()
|
||
db.expire(task, ["assignments"])
|
||
_sync_ask_task_status(task)
|
||
db.commit()
|
||
return {"success": True, "data": {"id": assignment_id}}
|
||
|
||
|
||
@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 | 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="待返款提问记录不存在")
|
||
if batch.status == "paid":
|
||
raise HTTPException(status_code=409, detail="该任务已经完成返款")
|
||
if batch.status != "approved":
|
||
raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款")
|
||
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()
|
||
_sync_ask_task_status(batch.assignment.task)
|
||
db.commit(); db.refresh(batch)
|
||
return {"success": True, "data": _ask_review_board_data(batch, include_images=True, image_endpoints=True)}
|
||
|
||
|
||
@app.post("/api/admin/task-board/ask-assignments/{assignment_id}/confirm-refund")
|
||
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="待返款提问记录不存在")
|
||
if assignment.status == "已完成":
|
||
raise HTTPException(status_code=409, detail="该任务已经完成返款")
|
||
if assignment.status != "已通过":
|
||
raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款")
|
||
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()
|
||
_sync_ask_task_status(assignment.task)
|
||
db.commit(); db.refresh(assignment)
|
||
return {"success": True, "data": _ask_review_board_data(assignment, include_images=True, image_endpoints=True)}
|
||
|
||
|
||
@app.get("/api/admin/task-board/{submission_id}")
|
||
def admin_task_board_detail(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
record = db.scalar(
|
||
select(AnswerSubmission)
|
||
.where(AnswerSubmission.id == submission_id)
|
||
.options(selectinload(AnswerSubmission.screenshots).load_only(
|
||
AnswerScreenshot.id,
|
||
AnswerScreenshot.submission_id,
|
||
AnswerScreenshot.stage,
|
||
AnswerScreenshot.filename,
|
||
AnswerScreenshot.content_type,
|
||
))
|
||
)
|
||
if not record or record.status not in {"待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"} or not _can_access_answer_submission(record, user):
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
return {"success": True, "data": _task_board_data(record, image_endpoints=True)}
|
||
|
||
|
||
@app.delete("/api/admin/task-board/{submission_id}")
|
||
def admin_task_board_delete_submission(submission_id: int, 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="任务不存在")
|
||
db.delete(record)
|
||
db.commit()
|
||
return {"success": True, "data": {"id": submission_id}}
|
||
|
||
|
||
@app.post("/api/admin/task-board/{submission_id}/confirm-refund")
|
||
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="待返款回答记录不存在")
|
||
if record.status == "已完成":
|
||
raise HTTPException(status_code=409, detail="该任务已经完成返款")
|
||
if record.status != "已通过":
|
||
raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款")
|
||
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, image_endpoints=True)}
|
||
|
||
|
||
@app.post("/api/admin/task-board/{submission_id}/cancel")
|
||
def admin_task_board_cancel(submission_id: int, 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="任务不存在")
|
||
if record.status == "已撤销":
|
||
raise HTTPException(status_code=409, detail="任务已经撤销")
|
||
if record.status == "已通过":
|
||
raise HTTPException(status_code=409, detail="已完成任务不能撤销")
|
||
if record.status not in {"待用户完成", "待审核", "未通过"}:
|
||
raise HTTPException(status_code=400, detail="当前状态不能撤销")
|
||
record.status = "已撤销"
|
||
db.commit(); db.refresh(record)
|
||
return {"success": True, "data": _task_board_data(record)}
|
||
|
||
|
||
@app.post("/api/answer/me/submissions/{submission_id}/completion")
|
||
async def answer_completion(submission_id: int, token: str = Form(...), owner_token: str = Form(...), files: list[UploadFile] = File(...), payment_files: list[UploadFile] = File(default=[]), db: Session = Depends(get_db)):
|
||
user = _answer_user_from_token(token, db); owner = _answer_link_owner(owner_token, db); record = db.get(AnswerSubmission, submission_id)
|
||
if not record or record.user_id != user.id or record.owner_id != owner.id:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
record_status = record.status
|
||
if record_status not in ("待用户完成", "未通过"): raise HTTPException(status_code=400, detail="当前任务不能提交完成截图")
|
||
if not files or len(files) > 10: raise HTTPException(status_code=400, detail="最多上传10张截图")
|
||
if not payment_files or len(payment_files) > 1: raise HTTPException(status_code=400, detail="请上传收款二维码后再提交")
|
||
db.rollback()
|
||
uploaded = []
|
||
for upload in files:
|
||
if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: raise HTTPException(status_code=400, detail="仅支持图片")
|
||
uploaded.append(("completion", upload.filename or "completion", upload.content_type, await upload.read()))
|
||
for upload in payment_files[:1]:
|
||
if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}: raise HTTPException(status_code=400, detail="收款码仅支持图片")
|
||
uploaded.append(("payment", upload.filename or "payment", upload.content_type, await upload.read()))
|
||
record = db.get(AnswerSubmission, submission_id)
|
||
if not record or record.user_id != user.id or record.status != record_status:
|
||
raise HTTPException(status_code=409, detail="任务状态已发生变化,请刷新后重试")
|
||
if record.owner_id != owner.id:
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
if record_status == "未通过":
|
||
for old_screenshot in list(record.screenshots):
|
||
if old_screenshot.stage in {"completion", "payment"}:
|
||
db.delete(old_screenshot)
|
||
for stage, filename, content_type, image_data in uploaded:
|
||
db.add(AnswerScreenshot(submission_id=record.id, stage=stage, filename=filename, content_type=content_type, image_data=image_data))
|
||
record.status = "待审核"; db.commit()
|
||
# 只返回轻量状态,避免重新编码截图阻塞审核状态切换。
|
||
return {"success": True, "data": _submission_data(record, include_images=False)}
|
||
|
||
|
||
@app.get("/api/admin/answer-completion-reviews")
|
||
def answer_completion_reviews(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
records = db.scalars(
|
||
select(AnswerSubmission)
|
||
.where(AnswerSubmission.status == "待审核")
|
||
.where(_answer_owner_clause(user))
|
||
.order_by(AnswerSubmission.updated_at.desc())
|
||
).unique().all()
|
||
return {"success": True, "data": [_answer_review_data(record, include_images=False) for record in records]}
|
||
|
||
|
||
@app.get("/api/admin/answer-completion-reviews/{submission_id}")
|
||
def answer_completion_review_detail(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
record = db.scalar(
|
||
select(AnswerSubmission)
|
||
.where(AnswerSubmission.id == submission_id)
|
||
.options(
|
||
selectinload(AnswerSubmission.screenshots).load_only(
|
||
AnswerScreenshot.id,
|
||
AnswerScreenshot.submission_id,
|
||
AnswerScreenshot.stage,
|
||
AnswerScreenshot.filename,
|
||
AnswerScreenshot.content_type,
|
||
)
|
||
)
|
||
)
|
||
if not record or not _can_access_answer_submission(record, user):
|
||
raise HTTPException(status_code=404, detail="任务不存在")
|
||
return {"success": True, "data": _answer_review_data(record, image_endpoints=True)}
|
||
|
||
|
||
@app.get("/api/admin/answer-screenshots/{screenshot_id}")
|
||
def admin_answer_screenshot(screenshot_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
image = db.get(AnswerScreenshot, screenshot_id)
|
||
if not image or not _can_access_answer_submission(image.submission, user):
|
||
raise HTTPException(status_code=404, detail="截图不存在")
|
||
return Response(
|
||
content=image.image_data,
|
||
media_type=image.content_type,
|
||
headers={"Cache-Control": "private, max-age=600"},
|
||
)
|
||
|
||
|
||
@app.post("/api/admin/answer-completion-reviews/{submission_id}/decision")
|
||
def answer_completion_decision(submission_id: int, payload: dict, 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="任务不存在")
|
||
if record.status != "待审核": raise HTTPException(status_code=409, detail="任务不在待审核状态")
|
||
approved = bool(payload.get("approved"))
|
||
reason_code = str(payload.get("reason_code", "")).strip() or None
|
||
reason_text = str(payload.get("reason_text", "")).strip() or None
|
||
if not approved and not (reason_code or reason_text):
|
||
raise HTTPException(status_code=400, detail="请选择或填写审核不通过原因")
|
||
db.add(AnswerReviewDecision(submission_id=record.id, approved=approved, reason_code=reason_code, reason_text=reason_text))
|
||
record.status = "已通过" if approved else "未通过"; db.commit(); db.refresh(record)
|
||
return {"success": True, "data": _submission_data(record, include_images=False)}
|
||
|
||
|
||
@app.post("/api/answer/public/submissions")
|
||
async def public_answer_submission(
|
||
client_id: str = Form(default="anonymous-browser"),
|
||
user_name: str = Form(default=""),
|
||
files: list[UploadFile] = File(...),
|
||
):
|
||
if not files or len(files) > 10:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最多上传10张截图")
|
||
screenshots = []
|
||
for upload in files:
|
||
if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片")
|
||
raw = await upload.read()
|
||
screenshots.append({"name": upload.filename or "screenshot", "content_type": upload.content_type, "data_url": f"data:{upload.content_type};base64,{base64.b64encode(raw).decode()}"})
|
||
submission_id = f"ANS{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}"
|
||
recognized = [{"id": f"Q{index + 1:03d}", "question": question["question"], "source_image": screenshots[index % len(screenshots)]["name"]} for index, question in enumerate(ANSWER_TASKS["TASK20260822002"]["questions"])]
|
||
record = {"task_id": submission_id, "client_id": client_id.strip()[:64], "user_name": user_name.strip()[:64] or "未填写", "product_name": "待识别商品", "status": "待筛选", "recognized_questions": recognized, "invited_screenshots": screenshots, "final_screenshots": [], "selected_question_ids": [], "updated_at": datetime.utcnow().isoformat()}
|
||
PUBLIC_ANSWER_SUBMISSIONS.append(record)
|
||
return {"success": True, "data": record}
|
||
|
||
|
||
@app.get("/api/answer/public/submissions")
|
||
def public_answer_submission_history(client_id: str):
|
||
if not client_id:
|
||
return {"success": True, "data": []}
|
||
records = [item for item in PUBLIC_ANSWER_SUBMISSIONS if item.get("client_id") == client_id]
|
||
return {"success": True, "data": sorted(records, key=lambda item: item["updated_at"], reverse=True)}
|
||
|
||
|
||
@app.get("/api/answer/tasks/{task_id}")
|
||
def answer_task_detail(task_id: str, token: str):
|
||
return {"success": True, "data": _public_answer_task(_answer_task(task_id, token))}
|
||
|
||
|
||
@app.post("/api/answer/tasks/{task_id}/invited-screenshots")
|
||
async def upload_invited_screenshots(
|
||
task_id: str,
|
||
token: str = Form(...),
|
||
user_name: str = Form(default=""),
|
||
files: list[UploadFile] = File(...),
|
||
):
|
||
task = _answer_task(task_id, token)
|
||
if not files or len(files) > 10:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最多上传10张截图")
|
||
screenshots = []
|
||
for upload in files:
|
||
if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片")
|
||
raw = await upload.read()
|
||
# MVP 暂存内存,后续接入对象存储;不写入数据库或磁盘。
|
||
screenshots.append({"name": upload.filename or "screenshot", "content_type": upload.content_type, "data_url": f"data:{upload.content_type};base64,{base64.b64encode(raw).decode()}"})
|
||
task["invited_screenshots"] = screenshots
|
||
task["user_name"] = user_name.strip()[:64]
|
||
task["recognized_questions"] = [
|
||
{"id": question["id"], "question": question["question"], "source_image": screenshots[index % len(screenshots)]["name"]}
|
||
for index, question in enumerate(task["questions"])
|
||
]
|
||
task["status"] = "待筛选"
|
||
task["updated_at"] = datetime.utcnow().isoformat()
|
||
return {"success": True, "data": {"status": "已提交审核", "recognized_count": len(task["recognized_questions"])}}
|
||
|
||
|
||
@app.post("/api/answer/tasks/{task_id}/select")
|
||
def select_answer_questions(task_id: str, payload: dict):
|
||
task = _answer_task(task_id, str(payload.get("token", "")))
|
||
question_ids = payload.get("question_ids") or []
|
||
valid_ids = {question["id"] for question in task["questions"]}
|
||
task["selected_question_ids"] = [str(value) for value in question_ids if str(value) in valid_ids]
|
||
task["status"] = "待完成"
|
||
task["updated_at"] = datetime.utcnow().isoformat()
|
||
return {"success": True, "data": _public_answer_task(task)}
|
||
|
||
|
||
@app.post("/api/answer/tasks/{task_id}/final-screenshots")
|
||
async def upload_final_screenshots(
|
||
task_id: str,
|
||
token: str = Form(...),
|
||
files: list[UploadFile] = File(...),
|
||
):
|
||
task = _answer_task(task_id, token)
|
||
if not files or len(files) > 10:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="最多上传10张截图")
|
||
screenshots = []
|
||
for upload in files:
|
||
if upload.content_type not in {"image/jpeg", "image/png", "image/webp"}:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="仅支持 JPG、PNG、WEBP 图片")
|
||
raw = await upload.read()
|
||
screenshots.append({"name": upload.filename or "screenshot", "content_type": upload.content_type, "data_url": f"data:{upload.content_type};base64,{base64.b64encode(raw).decode()}"})
|
||
task["final_screenshots"] = screenshots
|
||
task["status"] = "待审核"
|
||
task["updated_at"] = datetime.utcnow().isoformat()
|
||
return {"success": True, "data": {"status": "已提交审核"}}
|
||
|
||
|
||
@app.get("/api/admin/reviews")
|
||
def reviews(_: AdminUser = Depends(current_user)):
|
||
records = list(PUBLIC_ANSWER_SUBMISSIONS)
|
||
for task in ANSWER_TASKS.values():
|
||
if task["status"] in {"待筛选", "待审核", "已通过", "未通过"}:
|
||
records.append({
|
||
"task_id": task["task_id"], "user_name": task.get("user_name") or "未填写",
|
||
"status": task["status"], "product_name": task["product_name"],
|
||
"recognized_questions": task.get("recognized_questions", []),
|
||
"selected_questions": [q for q in task["questions"] if q["id"] in set(task.get("selected_question_ids", []))],
|
||
"invited_screenshots": [{"name": x["name"], "data_url": x["data_url"]} for x in task.get("invited_screenshots", [])],
|
||
"final_screenshots": [{"name": x["name"], "data_url": x["data_url"]} for x in task.get("final_screenshots", [])],
|
||
"updated_at": task["updated_at"],
|
||
})
|
||
return {"success": True, "data": records}
|
||
|
||
|
||
@app.post("/api/admin/reviews/{task_id}/decision")
|
||
def review_decision(task_id: str, payload: dict, _: AdminUser = Depends(current_user)):
|
||
task = ANSWER_TASKS.get(task_id) or next((item for item in PUBLIC_ANSWER_SUBMISSIONS if item["task_id"] == task_id), None)
|
||
if not task:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="审核记录不存在")
|
||
task["status"] = "已通过" if bool(payload.get("approved")) else "未通过"
|
||
task["updated_at"] = datetime.utcnow().isoformat()
|
||
return {"success": True, "data": {"task_id": task_id, "status": task["status"]}}
|
||
|
||
|
||
@app.post("/api/admin/qa/generate")
|
||
def qa_generate(payload: QAGenerateRequest, _: 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)
|
||
options_error = _generation_options_error(payload.model_dump(), include_answers=True)
|
||
if options_error:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=options_error)
|
||
try:
|
||
return {"success": True, "data": {"items": generate_qa(payload)}}
|
||
except HTTPException:
|
||
raise
|
||
except Exception as exc:
|
||
# Do not leak a raw 500 to the UI. Unexpected provider/parser errors
|
||
# are surfaced as a retryable upstream error with a server traceback.
|
||
import logging
|
||
logging.getLogger(__name__).exception("QA 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)
|
||
except (TypeError, ValueError):
|
||
answers = [entry.answers_json] if entry.answers_json else []
|
||
return {
|
||
"id": entry.id,
|
||
"product_name": entry.product_name,
|
||
"category": entry.category,
|
||
"benefits": entry.benefits,
|
||
"selling_points": entry.selling_points,
|
||
"question": entry.question_text,
|
||
"answers": answers,
|
||
"source": entry.source,
|
||
"created_by": entry.created_by,
|
||
"created_at": entry.created_at.isoformat(),
|
||
}
|
||
|
||
|
||
@app.get("/api/admin/qa-library")
|
||
def qa_library(search: str = "", _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
statement = select(QALibraryEntry)
|
||
keyword = search.strip()
|
||
if keyword:
|
||
pattern = f"%{keyword}%"
|
||
statement = statement.where(
|
||
or_(
|
||
QALibraryEntry.product_name.like(pattern),
|
||
QALibraryEntry.category.like(pattern),
|
||
QALibraryEntry.question_text.like(pattern),
|
||
QALibraryEntry.answers_json.like(pattern),
|
||
)
|
||
)
|
||
records = db.scalars(statement.order_by(QALibraryEntry.created_at.desc()).limit(500)).all()
|
||
return {"success": True, "data": [_qa_library_data(record) for record in records]}
|
||
|
||
|
||
@app.post("/api/admin/qa-library")
|
||
def save_qa_library(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
raw_items = payload.get("items") or []
|
||
if not isinstance(raw_items, list) or not raw_items:
|
||
raise HTTPException(status_code=400, detail="请选择需要采纳的问答")
|
||
product_name = str(payload.get("product_name", "")).strip()
|
||
category = str(payload.get("category", "")).strip()
|
||
benefits = str(payload.get("benefits", "")).strip()
|
||
selling_points = str(payload.get("selling_points", "")).strip()
|
||
prepared = []
|
||
for item in raw_items:
|
||
question = str(item.get("question", "")).strip()
|
||
answers = [str(value).strip() for value in (item.get("answers") or []) if str(value).strip()]
|
||
if not question or not answers:
|
||
continue
|
||
normalized = json.dumps(
|
||
{"product": product_name, "question": question, "answers": answers},
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
)
|
||
fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||
prepared.append((fingerprint, question, answers, str(item.get("source", "AI生成")).strip() or "AI生成"))
|
||
if not prepared:
|
||
raise HTTPException(status_code=400, detail="问答内容不能为空")
|
||
fingerprints = [item[0] for item in prepared]
|
||
existing = set(
|
||
db.scalars(select(QALibraryEntry.fingerprint).where(QALibraryEntry.fingerprint.in_(fingerprints))).all()
|
||
)
|
||
created = []
|
||
for fingerprint, question, answers, source in prepared:
|
||
if fingerprint in existing:
|
||
continue
|
||
entry = QALibraryEntry(
|
||
fingerprint=fingerprint,
|
||
product_name=product_name,
|
||
category=category,
|
||
benefits=benefits,
|
||
selling_points=selling_points,
|
||
question_text=question,
|
||
answers_json=json.dumps(answers, ensure_ascii=False),
|
||
source=source,
|
||
created_by=user.display_name,
|
||
)
|
||
db.add(entry)
|
||
created.append(entry)
|
||
existing.add(fingerprint)
|
||
db.commit()
|
||
for entry in created:
|
||
db.refresh(entry)
|
||
return {
|
||
"success": True,
|
||
"data": {
|
||
"added_count": len(created),
|
||
"skipped_count": len(prepared) - len(created),
|
||
"items": [_qa_library_data(entry) for entry in created],
|
||
},
|
||
}
|
||
|
||
|
||
@app.patch("/api/admin/qa-library/{entry_id}")
|
||
def update_qa_library(entry_id: int, payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
entry = db.get(QALibraryEntry, entry_id)
|
||
if not entry:
|
||
raise HTTPException(status_code=404, detail="问答记录不存在")
|
||
question = str(payload.get("question", entry.question_text)).strip()
|
||
answers = [str(value).strip() for value in (payload.get("answers") or []) if str(value).strip()]
|
||
if not question or not answers:
|
||
raise HTTPException(status_code=400, detail="问题和回答不能为空")
|
||
product_name = str(payload.get("product_name", entry.product_name)).strip()
|
||
category = str(payload.get("category", entry.category)).strip()
|
||
benefits = str(payload.get("benefits", entry.benefits)).strip()
|
||
selling_points = str(payload.get("selling_points", entry.selling_points)).strip()
|
||
normalized = json.dumps(
|
||
{"product": product_name, "question": question, "answers": answers},
|
||
ensure_ascii=False,
|
||
sort_keys=True,
|
||
)
|
||
fingerprint = hashlib.sha256(normalized.encode("utf-8")).hexdigest()
|
||
duplicate = db.scalar(
|
||
select(QALibraryEntry).where(
|
||
QALibraryEntry.fingerprint == fingerprint,
|
||
QALibraryEntry.id != entry.id,
|
||
)
|
||
)
|
||
if duplicate:
|
||
raise HTTPException(status_code=409, detail="修改后的内容已存在于问答库")
|
||
entry.fingerprint = fingerprint
|
||
entry.product_name = product_name
|
||
entry.category = category
|
||
entry.benefits = benefits
|
||
entry.selling_points = selling_points
|
||
entry.question_text = question
|
||
entry.answers_json = json.dumps(answers, ensure_ascii=False)
|
||
entry.created_by = user.display_name
|
||
db.commit()
|
||
db.refresh(entry)
|
||
return {"success": True, "data": _qa_library_data(entry)}
|
||
|
||
|
||
@app.delete("/api/admin/qa-library/{entry_id}")
|
||
def delete_qa_library(entry_id: int, _: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||
entry = db.get(QALibraryEntry, entry_id)
|
||
if not entry:
|
||
raise HTTPException(status_code=404, detail="问答记录不存在")
|
||
db.delete(entry)
|
||
db.commit()
|
||
return {"success": True, "data": {"id": entry_id}}
|
||
|
||
|
||
@app.post("/api/admin/auth/login")
|
||
def login(payload: LoginRequest, request: Request, response: Response, db: Session = Depends(get_db)):
|
||
user = db.scalar(select(AdminUser).where(AdminUser.username == payload.username))
|
||
valid = bool(user and user.is_active and verify_password(payload.password, user.password_hash))
|
||
db.add(AdminLoginLog(user_id=user.id if user else None, username=payload.username, success=valid, ip_address=request.client.host if request.client else None, user_agent=request.headers.get("user-agent")))
|
||
if not valid:
|
||
db.commit()
|
||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="用户名或密码错误")
|
||
user.last_login_at = datetime.utcnow()
|
||
db.commit()
|
||
response.set_cookie(settings.cookie_name, create_session_token(user.id, user.role_code), httponly=True, secure=settings.cookie_secure, samesite="lax", max_age=settings.session_hours * 3600)
|
||
return {"success": True, "data": {"user": public_user(user)}}
|
||
|
||
|
||
IMPORT_QUESTION_HEADERS = {"问题", "提问", "问题内容", "题目", "question", "question_text", "q"}
|
||
|
||
|
||
def _clean_import_question(value: object) -> str:
|
||
text_value = re.sub(r"\s+", " ", str(value or "").replace("\ufeff", "").strip())
|
||
text_value = re.sub(r"^(?:[-*+]\s+|#{1,6}\s+|\d+[.、)]\s*)", "", text_value).strip()
|
||
text_value = re.sub(r"^(?:问题|提问|question|q)\s*\d*\s*[::]\s*", "", text_value, flags=re.IGNORECASE).strip()
|
||
if text_value.lower() in IMPORT_QUESTION_HEADERS:
|
||
return ""
|
||
if re.match(r"^(?:回答|答案|answer|a)\s*\d*\s*[::]", text_value, flags=re.IGNORECASE):
|
||
return ""
|
||
return text_value if 0 < len(text_value) <= 500 else ""
|
||
|
||
|
||
def _dedupe_import_questions(values: list[object]) -> list[str]:
|
||
result = []
|
||
seen = set()
|
||
for value in values:
|
||
question = _clean_import_question(value)
|
||
key = question.casefold()
|
||
if not question or key in seen:
|
||
continue
|
||
seen.add(key)
|
||
result.append(question)
|
||
return result
|
||
|
||
|
||
def _decode_import_file(raw: bytes) -> str:
|
||
for encoding in ("utf-8-sig", "gb18030"):
|
||
try:
|
||
return raw.decode(encoding)
|
||
except UnicodeDecodeError:
|
||
continue
|
||
raise ValueError("文件编码无法识别")
|
||
|
||
|
||
def _questions_from_rows(rows: list[list[object]]) -> list[str]:
|
||
rows = [list(row) for row in rows if any(str(value or "").strip() for value in row)]
|
||
if not rows:
|
||
return []
|
||
header = [str(value or "").replace("\ufeff", "").strip().lower() for value in rows[0]]
|
||
question_index = next((index for index, value in enumerate(header) if value in IMPORT_QUESTION_HEADERS), None)
|
||
data_rows = rows[1:] if question_index is not None else rows
|
||
values = []
|
||
for row in data_rows:
|
||
if question_index is not None:
|
||
values.append(row[question_index] if question_index < len(row) else "")
|
||
else:
|
||
values.append(next((value for value in row if str(value or "").strip()), ""))
|
||
return _dedupe_import_questions(values)
|
||
|
||
|
||
def _questions_from_json(value: object) -> list[str]:
|
||
if isinstance(value, dict):
|
||
for container_key in ("questions", "items", "data", "list"):
|
||
if container_key in value:
|
||
return _questions_from_json(value[container_key])
|
||
for question_key in ("question", "question_text", "问题", "提问", "问题内容", "题目"):
|
||
if question_key in value:
|
||
return _dedupe_import_questions([value[question_key]])
|
||
return []
|
||
if isinstance(value, list):
|
||
questions = []
|
||
for item in value:
|
||
questions.extend(_questions_from_json(item) if isinstance(item, (dict, list)) else _dedupe_import_questions([item]))
|
||
return _dedupe_import_questions(questions)
|
||
return _dedupe_import_questions([value])
|
||
|
||
|
||
@app.post("/api/admin/ask-questions/import")
|
||
async def import_ask_questions(file: UploadFile = File(...), _: AdminUser = Depends(current_user)):
|
||
"""Parse a local question list without changing an existing task."""
|
||
filename = (file.filename or "").strip()
|
||
suffix = Path(filename).suffix.lower()
|
||
if suffix not in {".txt", ".csv", ".json", ".md", ".xlsx"}:
|
||
raise HTTPException(status_code=400, detail="仅支持 TXT、CSV、JSON、Markdown、XLSX 文件")
|
||
raw = await file.read()
|
||
if not raw:
|
||
raise HTTPException(status_code=400, detail="导入文件不能为空")
|
||
if len(raw) > 5 * 1024 * 1024:
|
||
raise HTTPException(status_code=400, detail="导入文件不能超过 5MB")
|
||
try:
|
||
if suffix == ".xlsx":
|
||
from openpyxl import load_workbook
|
||
workbook = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
|
||
sheet = workbook.active
|
||
questions = _questions_from_rows([list(row) for row in sheet.iter_rows(values_only=True)])
|
||
workbook.close()
|
||
elif suffix == ".json":
|
||
questions = _questions_from_json(json.loads(_decode_import_file(raw)))
|
||
elif suffix == ".csv":
|
||
questions = _questions_from_rows(list(csv.reader(io.StringIO(_decode_import_file(raw)))))
|
||
else:
|
||
lines = _decode_import_file(raw).splitlines()
|
||
explicit_questions = [
|
||
line for line in lines
|
||
if re.match(r"^\s*(?:[-*+]\s*)?(?:\d+[.、)]\s*)?(?:问题|提问|question|q)\s*\d*\s*[::]", line, flags=re.IGNORECASE)
|
||
]
|
||
questions = _dedupe_import_questions(explicit_questions or lines)
|
||
except (ValueError, UnicodeError) as exc:
|
||
raise HTTPException(status_code=400, detail="无法读取文件,请确认格式和编码正确") from exc
|
||
except Exception as exc:
|
||
import logging
|
||
logging.getLogger(__name__).exception("Question import parsing failed")
|
||
raise HTTPException(status_code=400, detail="文件解析失败,请确认文件未损坏") from exc
|
||
if not questions:
|
||
raise HTTPException(status_code=400, detail="文件中没有识别到问题,请按每行一个问题整理后重试")
|
||
if len(questions) > 500:
|
||
questions = questions[:500]
|
||
return {"success": True, "data": {
|
||
"filename": filename,
|
||
"questions": questions,
|
||
"count": len(questions),
|
||
}}
|
||
|
||
|
||
@app.post("/api/admin/reference-qa/parse")
|
||
async def parse_reference_qa(file: UploadFile = File(...), _: AdminUser = Depends(current_user)):
|
||
"""Extract readable question/answer text from an uploaded XLSX workbook.
|
||
|
||
XLSX is a ZIP/XML format; parsing it on the server avoids treating the
|
||
binary workbook as UTF-8 text in the browser (which produces mojibake).
|
||
"""
|
||
filename = (file.filename or "").lower()
|
||
if not filename.endswith(".xlsx"):
|
||
raise HTTPException(status_code=400, detail="仅支持解析 .xlsx 文件,请另存为 XLSX 后上传")
|
||
raw = await file.read()
|
||
try:
|
||
try:
|
||
from openpyxl import load_workbook
|
||
workbook = load_workbook(io.BytesIO(raw), read_only=True, data_only=True)
|
||
sheet = workbook.active
|
||
rows = [[str(value).strip() if value is not None else "" for value in row] for row in sheet.iter_rows(values_only=True)]
|
||
rows = [row for row in rows if any(row)]
|
||
workbook.close()
|
||
except ImportError:
|
||
rows = None
|
||
if rows is not None:
|
||
if not rows:
|
||
raise ValueError("工作表中没有可读取内容")
|
||
header = [str(value).lower() for value in rows[0]]
|
||
question_index = next((i for i, value in enumerate(header) if any(key in value for key in ("问题", "question", "q"))), None)
|
||
answer_index = next((i for i, value in enumerate(header) if any(key in value for key in ("回答", "问答", "answer", "a"))), None)
|
||
data_rows = rows[1:] if (question_index is not None or answer_index is not None) else rows
|
||
lines = []
|
||
for values in data_rows:
|
||
if question_index is not None and question_index < len(values):
|
||
question = values[question_index]
|
||
answer = values[answer_index] if answer_index is not None and answer_index < len(values) else ""
|
||
if question or answer:
|
||
lines.append(f"问题:{question}\n回答:{answer}")
|
||
else:
|
||
lines.append("\t".join(values))
|
||
text = "\n".join(lines).strip()[:20000]
|
||
return {"success": True, "data": {"text": text, "filename": file.filename}}
|
||
archive = zipfile.ZipFile(io.BytesIO(raw))
|
||
ns = {"x": "http://schemas.openxmlformats.org/spreadsheetml/2006/main"}
|
||
def xml_root(path: str):
|
||
data = archive.read(path)
|
||
# Some Windows exports declare UTF-8 but actually store Chinese
|
||
# XML text as GB18030. Decode the XML bytes before parsing so the
|
||
# original Chinese strings are not replaced with <20> characters.
|
||
try:
|
||
decoded = data.decode("utf-8")
|
||
if "<EFBFBD>" in decoded:
|
||
decoded = data.decode("gb18030")
|
||
except UnicodeDecodeError:
|
||
decoded = data.decode("gb18030")
|
||
return ET.fromstring(decoded)
|
||
shared = []
|
||
if "xl/sharedStrings.xml" in archive.namelist():
|
||
root = xml_root("xl/sharedStrings.xml")
|
||
shared = ["".join(node.itertext()) for node in root.findall("x:si", ns)]
|
||
sheet_name = "xl/worksheets/sheet1.xml"
|
||
if sheet_name not in archive.namelist():
|
||
raise ValueError("工作簿缺少首个工作表")
|
||
root = xml_root(sheet_name)
|
||
rows = []
|
||
for row in root.findall(".//x:sheetData/x:row", ns):
|
||
values = []
|
||
for cell in row.findall("x:c", ns):
|
||
cell_type = cell.attrib.get("t", "")
|
||
value = cell.find("x:v", ns)
|
||
if cell_type == "s" and value is not None:
|
||
text = shared[int(value.text or "0")] if shared else ""
|
||
elif cell_type == "inlineStr":
|
||
text = "".join(cell.itertext())
|
||
else:
|
||
text = value.text if value is not None and value.text else ""
|
||
values.append(text.strip())
|
||
if any(values):
|
||
rows.append(values)
|
||
if not rows:
|
||
raise ValueError("工作表中没有可读取内容")
|
||
header = [str(value).lower() for value in rows[0]]
|
||
question_index = next((i for i, value in enumerate(header) if any(key in value for key in ("问题", "question", "q"))), None)
|
||
answer_index = next((i for i, value in enumerate(header) if any(key in value for key in ("回答", "问答", "answer", "a"))), None)
|
||
lines = []
|
||
header = [str(value).lower() for value in rows[0]]
|
||
question_index = next((i for i, value in enumerate(header) if any(key in value for key in ("问题", "question", "q"))), None)
|
||
answer_index = next((i for i, value in enumerate(header) if any(key in value for key in ("回答", "问答", "answer", "a"))), None)
|
||
data_rows = rows[1:] if (question_index is not None or answer_index is not None) else rows
|
||
for values in data_rows:
|
||
if question_index is not None and question_index < len(values):
|
||
question = values[question_index]
|
||
answer = values[answer_index] if answer_index is not None and answer_index < len(values) else ""
|
||
if question or answer:
|
||
lines.append(f"问题:{question}\n回答:{answer}")
|
||
else:
|
||
lines.append("\t".join(values))
|
||
text = "\n".join(lines).strip()[:20000]
|
||
return {"success": True, "data": {"text": text, "filename": file.filename}}
|
||
except (KeyError, ValueError, IndexError, ET.ParseError, zipfile.BadZipFile, UnicodeError) as exc:
|
||
raise HTTPException(status_code=400, detail="无法读取该 XLSX 文件,请确认文件未损坏") from exc
|
||
except Exception as exc:
|
||
import logging
|
||
logging.getLogger(__name__).exception("Reference XLSX parsing failed")
|
||
raise HTTPException(status_code=400, detail="读取参考文件失败,请确认文件格式正确后重试") from exc
|
||
|
||
|
||
@app.get("/api/admin/auth/dingtalk/config")
|
||
def dingtalk_login_config():
|
||
return {"success": True, "data": {"enabled": _dingtalk_workbench_configured(), "corp_id": settings.dingtalk_corp_id, "agent_id": settings.dingtalk_agent_id, "redirect_uri": settings.dingtalk_redirect_uri}}
|
||
|
||
|
||
@app.post("/api/admin/auth/dingtalk/workbench")
|
||
def dingtalk_workbench_login(payload: dict, response: Response, db: Session = Depends(get_db)):
|
||
if not _dingtalk_workbench_configured():
|
||
raise HTTPException(status_code=503, detail="钉钉工作台免登尚未配置,请联系管理员")
|
||
code = str(payload.get("code") or "").strip()
|
||
if not code:
|
||
raise HTTPException(status_code=400, detail="钉钉免登授权码不能为空")
|
||
profile = _dingtalk_workbench_user(code)
|
||
access_token = profile.pop("_dingtalk_access_token", None)
|
||
profile = _dingtalk_org_profile(profile, access_token)
|
||
user = _dingtalk_create_session(profile, db, response)
|
||
return {"success": True, "data": {"user": public_user(user)}}
|
||
|
||
|
||
@app.get("/api/admin/auth/dingtalk/start")
|
||
def dingtalk_login_start():
|
||
if not _dingtalk_configured():
|
||
raise HTTPException(status_code=503, detail="钉钉登录尚未配置,请联系管理员")
|
||
state = secrets.token_urlsafe(24)
|
||
DINGTALK_STATES[state] = time.time() + 300
|
||
params = urllib.parse.urlencode({"redirect_uri": settings.dingtalk_redirect_uri, "client_id": settings.dingtalk_client_id, "response_type": "code", "scope": "openid", "state": state})
|
||
return {"success": True, "data": {"url": f"https://login.dingtalk.com/oauth2/auth?{params}"}}
|
||
|
||
|
||
@app.get("/api/admin/auth/dingtalk/callback")
|
||
def dingtalk_login_callback(response: Response, code: str = "", state: str = "", db: Session = Depends(get_db)):
|
||
expires_at = DINGTALK_STATES.pop(state, 0)
|
||
if not expires_at or expires_at < time.time():
|
||
raise HTTPException(status_code=400, detail="钉钉登录链接已失效,请重新从工作台打开")
|
||
user = _dingtalk_login(code, db, response)
|
||
response.headers["Location"] = f"{settings.frontend_url}/?dingtalk=success"
|
||
response.status_code = 302
|
||
return response
|
||
|
||
|
||
@app.post("/api/admin/auth/register", status_code=status.HTTP_201_CREATED)
|
||
def register(_: RegisterRequest):
|
||
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="运营后台已关闭自助注册,请联系管理员创建账号")
|
||
|
||
|
||
@app.get("/api/admin/auth/me")
|
||
def me(user: AdminUser = Depends(current_user)):
|
||
return {"success": True, "data": {"user": public_user(user)}}
|
||
|
||
|
||
@app.post("/api/admin/auth/logout")
|
||
def logout(response: Response):
|
||
response.delete_cookie(settings.cookie_name)
|
||
return {"success": True}
|
||
|
||
|
||
@app.get("/api/admin/auth/login-logs")
|
||
def login_logs(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
|
||
logs = db.scalars(select(AdminLoginLog).order_by(AdminLoginLog.created_at.desc()).limit(100)).all()
|
||
return {"success": True, "data": [{"username": x.username, "success": x.success, "ip_address": x.ip_address, "created_at": x.created_at.isoformat()} for x in logs]}
|
||
|
||
|
||
@app.get("/api/admin/users")
|
||
def users(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
|
||
records = db.scalars(select(AdminUser).order_by(AdminUser.id.asc())).all()
|
||
return {
|
||
"success": True,
|
||
"data": [
|
||
{
|
||
"id": user.id,
|
||
"username": user.username,
|
||
"display_name": user.display_name,
|
||
"role_code": user.role_code,
|
||
"role_label": ROLE_LABELS.get(user.role_code, user.role_code),
|
||
"dingtalk_dept_id": user.dingtalk_dept_id,
|
||
"dingtalk_dept_name": user.dingtalk_dept_name,
|
||
"is_dept_manager": user.is_dept_manager,
|
||
"is_active": user.is_active,
|
||
"last_login_at": user.last_login_at.isoformat() if user.last_login_at else None,
|
||
}
|
||
for user in records
|
||
],
|
||
}
|
||
|
||
|
||
@app.get("/api/admin/users/{user_id}")
|
||
def user_detail(user_id: int, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
|
||
user = db.get(AdminUser, user_id)
|
||
if not user:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||
return {"success": True, "data": public_user(user) | {"is_active": user.is_active, "last_login_at": user.last_login_at.isoformat() if user.last_login_at else None}}
|
||
|
||
|
||
@app.post("/api/admin/users", status_code=status.HTTP_201_CREATED)
|
||
def create_user(payload: AdminUserCreate, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
|
||
if db.scalar(select(AdminUser).where(AdminUser.username == payload.username)):
|
||
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="用户名已存在")
|
||
user = AdminUser(username=payload.username, password_hash=hash_password(payload.password), display_name=payload.display_name, role_code=payload.role_code)
|
||
db.add(user)
|
||
db.commit()
|
||
db.refresh(user)
|
||
return {"success": True, "data": {"user": public_user(user)}}
|
||
|
||
|
||
@app.patch("/api/admin/users/{user_id}")
|
||
def update_user(user_id: int, payload: AdminUserUpdate, current: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
|
||
user = db.get(AdminUser, user_id)
|
||
if not user:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||
changes = payload.model_dump(exclude_unset=True)
|
||
if user.id == current.id and (changes.get("is_active") is False or changes.get("role_code") == "operator"):
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能停用或降级当前登录账号")
|
||
if changes.get("role_code") == "operator" and user.role_code == "super_admin":
|
||
super_count = db.scalar(select(func.count()).select_from(AdminUser).where(AdminUser.role_code == "super_admin", AdminUser.is_active.is_(True))) or 0
|
||
if super_count <= 1:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少需要保留一个启用中的超级管理员")
|
||
if "password" in changes:
|
||
user.password_hash = hash_password(changes.pop("password"))
|
||
for key, value in changes.items():
|
||
setattr(user, key, value)
|
||
db.commit()
|
||
db.refresh(user)
|
||
return {"success": True, "data": {"user": public_user(user)}}
|
||
|
||
|
||
@app.delete("/api/admin/users/{user_id}")
|
||
def delete_user(user_id: int, current: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
|
||
user = db.get(AdminUser, user_id)
|
||
if not user:
|
||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="用户不存在")
|
||
if user.id == current.id:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能删除当前登录账号")
|
||
if user.role_code == "super_admin":
|
||
super_count = db.scalar(select(func.count()).select_from(AdminUser).where(AdminUser.role_code == "super_admin", AdminUser.is_active.is_(True))) or 0
|
||
if super_count <= 1:
|
||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="至少需要保留一个启用中的超级管理员")
|
||
db.delete(user)
|
||
db.commit()
|
||
return {"success": True}
|
||
|
||
|
||
@app.get("/api/admin/settings")
|
||
def system_settings(_: AdminUser = Depends(require_super_admin)):
|
||
return {
|
||
"success": True,
|
||
"data": {
|
||
"app_name": settings.app_name,
|
||
"session_hours": settings.session_hours,
|
||
"database_driver": settings.database_url.split(":", 1)[0],
|
||
"cookie_secure": settings.cookie_secure,
|
||
"available_roles": [{"code": code, "label": label} for code, label in ROLE_LABELS.items()],
|
||
},
|
||
}
|