新加支付板块,修改数据库位置

This commit is contained in:
db_huang 2026-10-17 18:22:39 +08:00
parent 61039682e9
commit ccae5a3a3c
20 changed files with 1111 additions and 124 deletions

View File

@ -1,4 +1,5 @@
DATABASE_URL=mysql+pymysql://ai_agent_test:WGzc6mKawk@49.233.179.206:3306/wendajia
SCREENSHOT_STORAGE_DIR=data/uploads/screenshots
SECRET_KEY=replace-with-a-long-random-secret
COOKIE_SECURE=false
SESSION_HOURS=12
@ -24,7 +25,11 @@ DINGTALK_REDIRECT_URI=http://localhost:8000/api/admin/auth/dingtalk/callback
FRONTEND_URL=http://localhost:5173
# 可选:限制可登录的 unionId;多个值用逗号分隔
DINGTALK_ALLOWED_UNION_IDS=
# 新钉钉用户仅允许来自以下部门(包含其下级部门);部门 ID 比名称更稳定
DINGTALK_ALLOWED_DEPT_IDS=
DINGTALK_ALLOWED_DEPT_NAMES=战狼事业部,雪狼事业部
# 可选:指定钉钉 unionId 自动授予超级管理员
DINGTALK_SUPER_ADMIN_UNION_IDS=
# 以下部门及其下级部门成员自动授予超级管理员;部门 ID 比名称更稳定
DINGTALK_SUPER_ADMIN_DEPT_IDS=
DINGTALK_SUPER_ADMIN_DEPT_NAMES=
DINGTALK_SUPER_ADMIN_DEPT_NAMES=AI指挥部

1
.gitignore vendored
View File

@ -4,3 +4,4 @@ __pycache__/
.pytest_cache/
frontend/node_modules/
frontend/dist/
data/uploads/

View File

@ -0,0 +1,5 @@
"""Reserved payout adapter. No external transfer is made in this version."""
def transfer(order):
raise NotImplementedError("支付宝代付接口尚未接入,禁止将订单标记为支付成功")

View File

@ -11,6 +11,12 @@ load_dotenv(BASE_DIR / ".env")
class Settings:
app_name = "问大家运营管理后台"
database_url = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'data' / 'app.db'}")
_screenshot_storage_value = Path(os.getenv("SCREENSHOT_STORAGE_DIR", "data/uploads/screenshots"))
screenshot_storage_dir = (
_screenshot_storage_value
if _screenshot_storage_value.is_absolute()
else BASE_DIR / _screenshot_storage_value
)
secret_key = os.getenv("SECRET_KEY", "change-this-secret-key-in-production")
cookie_name = "admin_session"
session_hours = int(os.getenv("SESSION_HOURS", "12"))
@ -35,11 +41,16 @@ class Settings:
dingtalk_redirect_uri = os.getenv("DINGTALK_REDIRECT_URI", "http://localhost:8000/api/admin/auth/dingtalk/callback")
frontend_url = os.getenv("FRONTEND_URL", "http://localhost:5173")
dingtalk_allowed_union_ids = {item.strip() for item in os.getenv("DINGTALK_ALLOWED_UNION_IDS", "").split(",") if item.strip()}
# New DingTalk users may enter only when they belong to one of these
# business divisions (including child departments). Existing active
# admin_users are grandfathered and continue to work.
dingtalk_allowed_dept_ids = {item.strip() for item in os.getenv("DINGTALK_ALLOWED_DEPT_IDS", "").split(",") if item.strip()}
dingtalk_allowed_dept_names = {item.strip() for item in os.getenv("DINGTALK_ALLOWED_DEPT_NAMES", "战狼事业部,雪狼事业部").split(",") if item.strip()}
dingtalk_super_admin_union_ids = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_UNION_IDS", "").split(",") if item.strip()}
# Members of these departments receive the same access as super admins.
# IDs are preferred; names are supported as a readable fallback.
dingtalk_super_admin_dept_ids = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_DEPT_IDS", "").split(",") if item.strip()}
dingtalk_super_admin_dept_names = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_DEPT_NAMES", "").split(",") if item.strip()}
dingtalk_super_admin_dept_names = {item.strip() for item in os.getenv("DINGTALK_SUPER_ADMIN_DEPT_NAMES", "AI指挥部").split(",") if item.strip()}
settings = Settings()

View File

@ -1,4 +1,7 @@
from datetime import datetime, timedelta
from decimal import Decimal, InvalidOperation
from sqlalchemy.exc import IntegrityError
from .models import RefundRecipient, AlipayRefundOrder
import base64
import csv
@ -21,7 +24,7 @@ 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, update
from sqlalchemy.orm import Session, defer, noload, selectinload
from sqlalchemy.orm import Session, defer, noload, object_session, selectinload
from .config import settings
from .database import Base, SessionLocal, engine, get_db
@ -29,6 +32,7 @@ from .llm import extract_questions_from_images, generate_answer_for_question, ge
from .models import AdminLoginLog, AdminUsageEntry, AdminUser, AnswerRecognizedQuestion, AnswerReviewDecision, AnswerScreenshot, AnswerSubmission, AnswerUser, AskAssignment, AskAssignmentBatch, AskAssignmentBatchReview, AskAssignmentBatchScreenshot, AskAssignmentQuestion, AskAssignmentReview, AskAssignmentScreenshot, AskAssignmentUsedQuestion, AskQuestion, AskTask, QABulkGenerationItem, QABulkGenerationJob, QALibraryEntry, RefundUploadSession
from .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QABulkAnswerRequest, QAGenerateRequest, RegisterRequest
from .security import create_session_token, decode_session_token, hash_password, verify_password
from .screenshot_storage import screenshot_bytes, screenshot_data_url, screenshot_fields, screenshot_response
app = FastAPI(title=settings.app_name, version="0.1.0")
@ -241,20 +245,50 @@ DINGTALK_STATES: dict[str, float] = {}
ROLE_LABELS = {"super_admin": "超级管理员", "dept_manager": "部门主管", "operator": "普通运营"}
ROLE_PERMISSIONS = {
"super_admin": ["assistant", "library", "create", "board", "review", "usage", "users", "logs", "settings"],
"dept_manager": ["assistant", "library", "create", "board", "review"],
"operator": ["assistant", "library", "create", "board", "review"],
"super_admin": ["assistant", "library", "create", "board", "review", "refunds", "usage", "users", "logs", "settings"],
"dept_manager": ["assistant", "library", "create", "board", "review", "refunds"],
"operator": ["assistant", "library", "create", "board", "review", "refunds"],
}
def _dingtalk_department_is_super_admin(dept_id: str | None, dept_name: str | None, dept_path: str | None = None) -> bool:
def _dingtalk_department_is_allowed(
dept_id: str | None,
dept_name: str | None,
dept_path: str | None = None,
dept_name_path: str | None = None,
) -> bool:
configured_ids = settings.dingtalk_allowed_dept_ids
configured_names = settings.dingtalk_allowed_dept_names
if dept_id and dept_id in configured_ids:
return True
if dept_path and any(f",{candidate}," in dept_path for candidate in configured_ids):
return True
department_names = {
item.strip()
for item in str(dept_name_path or dept_name or "").split("/")
if item.strip()
}
return bool(department_names & configured_names)
def _dingtalk_department_is_super_admin(
dept_id: str | None,
dept_name: str | None,
dept_path: str | None = None,
dept_name_path: str | None = None,
) -> bool:
configured_ids = settings.dingtalk_super_admin_dept_ids
configured_names = settings.dingtalk_super_admin_dept_names
if dept_id and dept_id in configured_ids:
return True
if dept_path and any(f",{candidate}," in dept_path for candidate in configured_ids):
return True
return bool(dept_name and dept_name in configured_names)
department_names = {
item.strip()
for item in str(dept_name_path or dept_name or "").split("/")
if item.strip()
}
return bool(department_names & configured_names)
def public_user(user: AdminUser) -> dict:
@ -286,6 +320,16 @@ def seed_users(db: Session):
@app.on_event("startup")
def startup():
Base.metadata.create_all(bind=engine)
settings.screenshot_storage_dir.mkdir(parents=True, exist_ok=True)
for screenshot_table in ("answer_screenshots", "ask_assignment_batch_screenshots", "ask_assignment_screenshots"):
screenshot_column_defs = {column["name"]: column for column in inspect(engine).get_columns(screenshot_table)}
screenshot_columns = set(screenshot_column_defs)
if "file_path" not in screenshot_columns:
with engine.begin() as connection:
connection.execute(text(f"ALTER TABLE {screenshot_table} ADD COLUMN file_path VARCHAR(1000) NULL"))
if engine.dialect.name == "mysql" and not screenshot_column_defs["image_data"].get("nullable", False):
with engine.begin() as connection:
connection.execute(text(f"ALTER TABLE {screenshot_table} MODIFY COLUMN image_data MEDIUMBLOB NULL"))
# 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:
@ -386,22 +430,19 @@ def startup():
with engine.begin() as connection:
connection.execute(text("CREATE UNIQUE INDEX ix_ask_tasks_public_token ON ask_tasks (public_token)"))
seed_users(db)
# Apply configured department-wide administrator access to existing
# DingTalk accounts. New accounts are handled during SSO login below.
configured_dept_ids = settings.dingtalk_super_admin_dept_ids
configured_dept_names = settings.dingtalk_super_admin_dept_names
if configured_dept_ids or configured_dept_names:
department_users = []
if configured_dept_ids:
department_users.extend(db.scalars(select(AdminUser).where(AdminUser.dingtalk_dept_id.in_(configured_dept_ids))).all())
if configured_dept_names:
department_users.extend(db.scalars(select(AdminUser).where(AdminUser.dingtalk_dept_name.in_(configured_dept_names))).all())
changed = False
for department_user in {user.id: user for user in department_users}.values():
if department_user.role_code != "super_admin":
department_user.role_code = "super_admin"
changed = True
if changed:
# Restore department-wide administrator access for existing rows. ID
# paths also cover members stored in child departments.
department_admins = db.scalars(select(AdminUser).where(AdminUser.is_active.is_(True))).all()
promoted = False
for department_admin in department_admins:
if _dingtalk_department_is_super_admin(
department_admin.dingtalk_dept_id,
department_admin.dingtalk_dept_name,
department_admin.dingtalk_dept_path,
) and department_admin.role_code != "super_admin":
department_admin.role_code = "super_admin"
promoted = True
if promoted:
db.commit()
# Every operator keeps one stable public answer-task URL. Generate
# tokens after seeding so the default accounts receive one as well.
@ -670,26 +711,48 @@ def _dingtalk_org_profile(profile: dict, access_token: str | None) -> dict:
data["_primary_dept_id"] = dept_ids[0] if dept_ids else ""
data["_dept_name"] = ""
data["_dept_path"] = ""
data["_dept_name_path"] = ""
if access_token and dept_ids:
current_id = dept_ids[0]
department_cache = {}
candidates = []
for member_dept_id in dept_ids:
current_id = member_dept_id
chain = []
visited = set()
while current_id and current_id not in visited and len(chain) < 20:
visited.add(current_id)
result = _dingtalk_oapi_post(
if current_id not in department_cache:
department_cache[current_id] = _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):
result = department_cache[current_id]
if not isinstance(result, dict) or not result:
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) + ","
candidates.append({
"dept_id": member_dept_id,
"dept_name": chain[-1][1] or "",
"dept_path": "," + ",".join(item[0] for item in chain) + ",",
"dept_name_path": "/".join(item[1] for item in chain if item[1]),
})
selected = next((item for item in candidates if _dingtalk_department_is_super_admin(
item["dept_id"], item["dept_name"], item["dept_path"], item["dept_name_path"]
)), None)
selected = selected or next((item for item in candidates if _dingtalk_department_is_allowed(
item["dept_id"], item["dept_name"], item["dept_path"], item["dept_name_path"]
)), None)
selected = selected or (candidates[0] if candidates else None)
if selected:
data["_primary_dept_id"] = selected["dept_id"]
data["_dept_name"] = selected["dept_name"]
data["_dept_path"] = selected["dept_path"]
data["_dept_name_path"] = selected["dept_name_path"]
leader_info = data.get("leader_in_dept") or data.get("leaderInDept") or []
is_manager = bool(data.get("is_dept_manager") or data.get("isDeptManager"))
@ -698,9 +761,16 @@ def _dingtalk_org_profile(profile: dict, access_token: str | None) -> dict:
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):
selected_dept_id = data.get("_primary_dept_id")
matching_leaders = [
item for item in leader_info
if isinstance(item, dict)
and str(item.get("dept_id") or item.get("deptId") or "") == str(selected_dept_id or "")
]
leader_candidates = matching_leaders or [item for item in leader_info if isinstance(item, dict)]
is_manager = is_manager or any(
isinstance(item, dict) and bool(item.get("is_leader") or item.get("isLeader"))
for item in leader_info
bool(item.get("is_leader") or item.get("isLeader") or item.get("leader"))
for item in leader_candidates
)
data["_is_dept_manager"] = is_manager
return data
@ -753,8 +823,6 @@ def _dingtalk_create_session(profile: dict, db: Session, response: Response) ->
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]
@ -762,8 +830,19 @@ def _dingtalk_create_session(profile: dict, db: Session, response: Response) ->
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
dept_name_path = str(profile.get("_dept_name_path") or dept_name or "").strip() or None
is_dept_manager = bool(profile.get("_is_dept_manager"))
department_super_admin = _dingtalk_department_is_super_admin(dept_id, dept_name, dept_path)
department_super_admin = _dingtalk_department_is_super_admin(
dept_id, dept_name, dept_path, dept_name_path
)
# Existing active rows form the explicit user-management allowlist. New
# rows may be created only for the two authorized divisions or for a
# department whose members are explicitly configured as administrators.
if not user and not (
_dingtalk_department_is_allowed(dept_id, dept_name, dept_path, dept_name_path)
or department_super_admin
):
raise HTTPException(status_code=403, detail="当前账号不在系统开放部门,请联系管理员")
role_code = (
"super_admin" if union_id in settings.dingtalk_super_admin_union_ids or department_super_admin
else "dept_manager" if is_dept_manager
@ -792,7 +871,7 @@ def _dingtalk_create_session(profile: dict, db: Session, response: Response) ->
user.is_dept_manager = is_dept_manager
if union_id in settings.dingtalk_super_admin_union_ids or department_super_admin:
user.role_code = "super_admin"
elif is_dept_manager:
elif is_dept_manager and user.role_code != "super_admin":
user.role_code = "dept_manager"
elif user.role_code == "dept_manager" and profile.get("_org_data_available"):
user.role_code = "operator"
@ -1028,7 +1107,24 @@ def _ask_screenshot_url(
) -> 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()}"
return screenshot_data_url(image)
def _refund_recipient_data(record, target_kind: str) -> dict:
"""Expose the payout identity saved alongside a user submission."""
db = object_session(record)
if db is None:
return {"alipay_account": "", "payee_name": ""}
recipient = db.scalar(
select(RefundRecipient).where(
RefundRecipient.target_kind == target_kind,
RefundRecipient.target_id == record.id,
)
)
return {
"alipay_account": recipient.account if recipient else "",
"payee_name": recipient.payee_name if recipient else "",
}
def _ask_assignment_data(
@ -1077,6 +1173,7 @@ def _ask_assignment_data(
"user_id": _public_user_id(assignment.user),
"taobao_id": assignment.user.taobao_id,
"phone": _public_user_account(assignment.user),
**(_refund_recipient_data(assignment, "ask_assignment") if include_images else {"alipay_account": "", "payee_name": ""}),
"created_at": assignment.created_at.isoformat(),
"updated_at": assignment.updated_at.isoformat(),
"expires_at": (
@ -1148,6 +1245,7 @@ def _ask_batch_data(
"status": {"pending": "待审核", "approved": "已通过", "paid": "已完成", "rejected": "未通过"}.get(batch.status, batch.status),
"created_at": batch.created_at.isoformat(),
"updated_at": batch.updated_at.isoformat(),
**(_refund_recipient_data(batch, "ask_batch") if include_images else {"alipay_account": "", "payee_name": ""}),
"questions": questions,
"question_screenshots": [
{"id": image.id, "name": image.filename, "data_url": _ask_screenshot_url(image, "batch", image_endpoints)}
@ -1192,7 +1290,7 @@ def _sync_ask_assignment_status(assignment: AskAssignment) -> None:
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()}"
return screenshot_data_url(image)
LEGACY_FAKE_OCR_QUESTIONS = {
@ -1233,6 +1331,7 @@ def _submission_data(
"user_id": _public_user_id(record.user),
"taobao_id": record.user.taobao_id,
"phone": _public_user_account(record.user),
**(_refund_recipient_data(record, "answer_submission") if include_images else {"alipay_account": "", "payee_name": ""}),
"shop_name": record.shop_name or "",
"status": record.status,
"recognition_failed": record.status == "识别失败" or _has_legacy_fake_ocr_questions(record),
@ -2004,6 +2103,7 @@ async def submit_public_ask_task(
files: list[UploadFile] = File(...),
payment_file: UploadFile = File(...),
payee_name: str = Form(default=""),
alipay_account: str = Form(default=""),
db: Session = Depends(get_db),
):
user = _answer_user_from_token(token, db)
@ -2045,9 +2145,16 @@ async def submit_public_ask_task(
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))
db.add(AskAssignmentBatchScreenshot(
batch_id=batch.id, stage="question",
**screenshot_fields(image_data, filename, content_type, "ask-batches"),
))
_save_refund_recipient(db, "ask_batch", batch.id, alipay_account, payee_name)
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))
db.add(AskAssignmentBatchScreenshot(
batch_id=batch.id, stage="payment",
**screenshot_fields(image_data, filename, content_type, "ask-batches"),
))
assignment.status = "待审核"
assignment.reservation_started_at = None
used_ids = {item.question_id for item in assignment.used_questions}
@ -2156,11 +2263,7 @@ def admin_ask_batch_screenshot(screenshot_id: int, user: AdminUser = Depends(cur
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"},
)
return screenshot_response(image)
@app.get("/api/admin/ask-assignment-screenshots/{screenshot_id}")
@ -2168,11 +2271,7 @@ def admin_ask_assignment_screenshot(screenshot_id: int, user: AdminUser = Depend
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"},
)
return screenshot_response(image)
@app.post("/api/admin/ask-batch-reviews/{batch_id}/decision")
@ -2256,7 +2355,7 @@ def _process_answer_submission(submission_id: int):
# 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]
saved_payload = [(screenshot_bytes(shot), shot.content_type) for shot in saved]
db.rollback()
extracted = extract_questions_from_images(saved_payload)
if not extracted:
@ -2346,7 +2445,10 @@ async def answer_create_submission(
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.add(AnswerScreenshot(
submission_id=record.id, stage="invited",
**screenshot_fields(image_data, filename, content_type, "answers"),
))
db.commit(); db.refresh(record)
background_tasks.add_task(_process_answer_submission, record.id)
# 先返回轻量结果,图片由后续状态查询按需加载。
@ -2429,7 +2531,10 @@ async def upload_answer_operator_images(
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)
image = AnswerScreenshot(
submission_id=record.id, stage="operator",
**screenshot_fields(image_data, filename, content_type, "answers"),
)
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
@ -3142,6 +3247,83 @@ def admin_task_board_delete_ask_assignment(assignment_id: int, user: AdminUser =
return {"success": True, "data": {"id": assignment_id}}
def _save_refund_recipient(db, kind, target_id, account, name):
# Legacy clients can still submit screenshots without bank details.
if not account.strip():
return
if not account.strip() or not name.strip() or len(account.strip()) > 128 or len(name.strip()) > 64:
raise HTTPException(status_code=400, detail="请填写支付宝账号和收款人姓名")
recipient = db.scalar(select(RefundRecipient).where(RefundRecipient.target_kind == kind, RefundRecipient.target_id == target_id))
if not recipient:
recipient = RefundRecipient(target_kind=kind, target_id=target_id)
db.add(recipient)
recipient.account = account.strip()
recipient.payee_name = name.strip()
def _payable_target(db, kind, target_id, user):
target, payable = _refund_session_target_for_payload(kind, target_id, db)
if not target or not payable:
raise HTTPException(status_code=409, detail="任务已不在待返款状态")
assignment = target.assignment if kind == "ask_batch" else target
allowed = _can_access_ask_task(assignment.task, user) if kind != "answer_submission" else _can_access_answer_submission(target, user)
if not allowed:
raise HTTPException(status_code=404, detail="无权操作该任务")
return target
@app.get("/api/admin/alipay-refunds")
def list_alipay_refunds(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
result = []
for kind, model, payable_status in [("answer_submission", AnswerSubmission, "已通过"), ("ask_batch", AskAssignmentBatch, "approved"), ("ask_assignment", AskAssignment, "已通过")]:
for target in db.scalars(select(model).where(model.status == payable_status)).all():
try:
_payable_target(db, kind, target.id, user)
except HTTPException:
continue
if kind == "ask_assignment" and target.batches:
continue
recipient = db.scalar(select(RefundRecipient).where(RefundRecipient.target_kind == kind, RefundRecipient.target_id == target.id))
order = db.scalar(select(AlipayRefundOrder).where(AlipayRefundOrder.target_kind == kind, AlipayRefundOrder.target_id == target.id))
assignment = target.assignment if kind == "ask_batch" else target
detail_path = {"answer_submission": f"/api/admin/task-board/{target.id}", "ask_batch": f"/api/admin/task-board/ask-batches/{target.id}", "ask_assignment": f"/api/admin/task-board/ask-assignments/{target.id}"}[kind]
result.append({"target_kind": kind, "target_id": target.id, "task_no": target.submission_no if kind == "answer_submission" else assignment.task.task_no, "account": order.account if order else recipient.account if recipient else "", "payee_name": order.payee_name if order else recipient.payee_name if recipient else "", "amount": str(Decimal(order.amount_fen) / 100) if order else "", "payment_status": "待支付(代付接口未接入)" if order else "待提交", "submitted": bool(order), "taobao_id": assignment.user.taobao_id if kind != "answer_submission" else target.user.taobao_id, "shop_name": assignment.task.shop_name if kind != "answer_submission" else target.shop_name, "product_name": assignment.task.product_name if kind != "answer_submission" else (target.questions[0].product_title if target.questions else "待识别商品"), "detail_endpoint": detail_path})
return {"success": True, "data": result}
@app.post("/api/admin/alipay-refunds")
def submit_alipay_refunds(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
items = payload.get("items")
if not isinstance(items, list) or not 1 <= len(items) <= 100:
raise HTTPException(status_code=400, detail="每次请选择1至100笔返款")
seen = set()
for item in items:
try:
kind, target_id = item["target_kind"], int(item["target_id"])
amount = Decimal(str(item["amount"]))
if not amount.is_finite() or amount <= 0 or amount > Decimal("100000") or amount != amount.quantize(Decimal("0.01")):
raise ValueError()
except (KeyError, TypeError, ValueError, InvalidOperation):
raise HTTPException(status_code=400, detail="金额必须大于0,最多两位小数,单笔不超过100000元")
if (kind, target_id) in seen:
raise HTTPException(status_code=400, detail="请勿重复选择同一笔任务")
seen.add((kind, target_id))
_payable_target(db, kind, target_id, user)
if db.scalar(select(AlipayRefundOrder.id).where(AlipayRefundOrder.target_kind == kind, AlipayRefundOrder.target_id == target_id)):
raise HTTPException(status_code=409, detail="该任务已提交返款,请勿重复提交")
account, name = str(item.get("account") or "").strip(), str(item.get("payee_name") or "").strip()
if not account or not name or len(account) > 128 or len(name) > 64:
raise HTTPException(status_code=400, detail="请填写支付宝账号和收款人姓名")
db.add(AlipayRefundOrder(target_kind=kind, target_id=target_id, amount_fen=int(amount * 100), account=account, payee_name=name, created_by_id=user.id))
try:
db.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="任务已提交,请刷新后重试")
# Provider integration intentionally not invoked; no money is transferred.
return {"success": True, "data": {"count": len(items), "status": "pending", "message": "已保存,待支付;代付接口尚未接入"}}
@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."""
@ -3213,11 +3395,20 @@ async def upload_refund_proof_by_session(token: str, refund_file: UploadFile = F
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))
db.add(AskAssignmentBatchScreenshot(
batch_id=target.id, stage="refund",
**screenshot_fields(data, filename, content_type, "ask-batches"),
))
elif session.target_kind == "ask_assignment":
db.add(AskAssignmentScreenshot(assignment_id=target.id, stage="refund", filename=filename, content_type=content_type, image_data=data))
db.add(AskAssignmentScreenshot(
assignment_id=target.id, stage="refund",
**screenshot_fields(data, filename, content_type, "ask-assignments"),
))
else:
db.add(AnswerScreenshot(submission_id=target.id, stage="refund", filename=filename, content_type=content_type, image_data=data))
db.add(AnswerScreenshot(
submission_id=target.id, stage="refund",
**screenshot_fields(data, filename, content_type, "answers"),
))
session.uploaded_at = datetime.utcnow()
db.commit()
db.refresh(session)
@ -3238,7 +3429,10 @@ async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile | None
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))
db.add(AskAssignmentBatchScreenshot(
batch_id=batch.id, stage="refund",
**screenshot_fields(refund_data, filename, content_type, "ask-batches"),
))
elif not any(image.stage == "refund" for image in batch.screenshots):
raise HTTPException(status_code=400, detail="请先上传返款凭证,或用手机扫描二维码上传")
if upload_token:
@ -3267,7 +3461,10 @@ async def confirm_ask_assignment_refund(assignment_id: int, refund_file: UploadF
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))
db.add(AskAssignmentScreenshot(
assignment_id=assignment.id, stage="refund",
**screenshot_fields(refund_data, filename, content_type, "ask-assignments"),
))
elif not any(image.stage == "refund" for image in assignment.screenshots):
raise HTTPException(status_code=400, detail="请先上传返款凭证,或用手机扫描二维码上传")
if upload_token:
@ -3324,7 +3521,10 @@ async def confirm_answer_refund(submission_id: int, refund_file: UploadFile | No
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))
db.add(AnswerScreenshot(
submission_id=record.id, stage="refund",
**screenshot_fields(refund_data, filename, content_type, "answers"),
))
elif not any(image.stage == "refund" for image in record.screenshots):
raise HTTPException(status_code=400, detail="请先上传返款凭证,或用手机扫描二维码上传")
if upload_token:
@ -3354,7 +3554,7 @@ def admin_task_board_cancel(submission_id: int, user: AdminUser = Depends(curren
@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)):
async def answer_completion(submission_id: int, token: str = Form(...), owner_token: str = Form(...), files: list[UploadFile] = File(...), payment_files: list[UploadFile] = File(default=[]), alipay_account: str = Form(default=""), payee_name: str = Form(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="任务不存在")
@ -3380,7 +3580,11 @@ async def answer_completion(submission_id: int, token: str = Form(...), owner_to
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))
db.add(AnswerScreenshot(
submission_id=record.id, stage=stage,
**screenshot_fields(image_data, filename, content_type, "answers"),
))
_save_refund_recipient(db, "answer_submission", record.id, alipay_account, payee_name)
record.status = "待审核"; db.commit()
# 只返回轻量状态,避免重新编码截图阻塞审核状态切换。
return {"success": True, "data": _submission_data(record, include_images=False)}
@ -3422,11 +3626,7 @@ def admin_answer_screenshot(screenshot_id: int, user: AdminUser = Depends(curren
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"},
)
return screenshot_response(image)
@app.post("/api/admin/answer-completion-reviews/{submission_id}/decision")
@ -4370,6 +4570,7 @@ def admin_usage_stats(_: AdminUser = Depends(require_super_admin), db: Session =
today = now.date()
today_entries = [entry for entry in entries if entry.entered_at.date() == today]
user_totals = {}
department_totals = {}
trend = {}
for offset in range(30):
day = (start + timedelta(days=offset)).date().isoformat()
@ -4380,6 +4581,13 @@ def admin_usage_stats(_: AdminUser = Depends(require_super_admin), db: Session =
trend[day]["count"] += 1
key = (entry.user_id, entry.username, entry.display_name, entry.department_name)
user_totals[key] = user_totals.get(key, 0) + 1
department = entry.department_name or "未分配部门"
department_totals.setdefault(department, {"count": 0, "latest": entry.entered_at, "users": {}})
department_totals[department]["count"] += 1
if entry.entered_at > department_totals[department]["latest"]:
department_totals[department]["latest"] = entry.entered_at
member_key = (entry.user_id, entry.username, entry.display_name)
department_totals[department]["users"][member_key] = department_totals[department]["users"].get(member_key, 0) + 1
for day in trend:
trend[day]["users"] = len({entry.user_id for entry in entries if entry.entered_at.date().isoformat() == day})
users = [
@ -4394,6 +4602,28 @@ def admin_usage_stats(_: AdminUser = Depends(require_super_admin), db: Session =
user_totals.items(), key=lambda item: (-item[1], item[0][2])
)
]
departments = [
{
"name": department_name,
"count": values["count"],
"user_count": len(values["users"]),
"latest_at": values["latest"].isoformat(),
"members": [
{
"user_id": user_id,
"username": username,
"display_name": display_name,
"count": count,
}
for (user_id, username, display_name), count in sorted(
values["users"].items(), key=lambda item: (-item[1], item[0][2])
)
],
}
for department_name, values in sorted(
department_totals.items(), key=lambda item: (-item[1]["count"], item[0])
)
]
return {
"success": True,
"data": {
@ -4401,8 +4631,10 @@ def admin_usage_stats(_: AdminUser = Depends(require_super_admin), db: Session =
"total_count": len(entries),
"today_count": len(today_entries),
"today_users": len({entry.user_id for entry in today_entries}),
"department_count": len(departments),
"trend": list(trend.values()),
"users": users,
"departments": departments,
"records": [
{
"display_name": entry.display_name,
@ -4469,9 +4701,9 @@ def update_user(user_id: int, payload: AdminUserUpdate, current: AdminUser = Dep
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"):
if user.id == current.id and (changes.get("is_active") is False or changes.get("role_code") not in (None, "super_admin")):
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="不能停用或降级当前登录账号")
if changes.get("role_code") == "operator" and user.role_code == "super_admin":
if changes.get("role_code") in {"operator", "dept_manager"} 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="至少需要保留一个启用中的超级管理员")

View File

@ -7,6 +7,30 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base
class RefundRecipient(Base):
__tablename__ = "refund_recipients"
__table_args__ = (UniqueConstraint("target_kind", "target_id", name="uq_refund_recipient_target"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
target_kind: Mapped[str] = mapped_column(String(32))
target_id: Mapped[int] = mapped_column(Integer)
account: Mapped[str] = mapped_column(String(128))
payee_name: Mapped[str] = mapped_column(String(64))
class AlipayRefundOrder(Base):
__tablename__ = "alipay_refund_orders"
__table_args__ = (UniqueConstraint("target_kind", "target_id", name="uq_alipay_refund_target"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
target_kind: Mapped[str] = mapped_column(String(32))
target_id: Mapped[int] = mapped_column(Integer)
amount_fen: Mapped[int] = mapped_column(Integer)
account: Mapped[str] = mapped_column(String(128))
payee_name: Mapped[str] = mapped_column(String(64))
created_by_id: Mapped[int] = mapped_column(Integer)
status: Mapped[str] = mapped_column(String(32), default="pending")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class AdminUser(Base):
__tablename__ = "admin_users"
@ -175,7 +199,8 @@ class AnswerScreenshot(Base):
stage: Mapped[str] = mapped_column(String(20), index=True) # invited/completion
filename: Mapped[str] = mapped_column(String(255))
content_type: Mapped[str] = mapped_column(String(64))
image_data: Mapped[bytes] = mapped_column(LargeBinary(length=16_777_215))
image_data: Mapped[bytes | None] = mapped_column(LargeBinary(length=16_777_215), nullable=True)
file_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
submission: Mapped[AnswerSubmission] = relationship(back_populates="screenshots")
@ -321,7 +346,8 @@ class AskAssignmentBatchScreenshot(Base):
stage: Mapped[str] = mapped_column(String(20), index=True)
filename: Mapped[str] = mapped_column(String(255))
content_type: Mapped[str] = mapped_column(String(64))
image_data: Mapped[bytes] = mapped_column(LargeBinary(length=16_777_215))
image_data: Mapped[bytes | None] = mapped_column(LargeBinary(length=16_777_215), nullable=True)
file_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
batch: Mapped[AskAssignmentBatch] = relationship(back_populates="screenshots")
@ -387,7 +413,8 @@ class AskAssignmentScreenshot(Base):
stage: Mapped[str] = mapped_column(String(20), index=True) # question/payment
filename: Mapped[str] = mapped_column(String(255))
content_type: Mapped[str] = mapped_column(String(64))
image_data: Mapped[bytes] = mapped_column(LargeBinary(length=16_777_215))
image_data: Mapped[bytes | None] = mapped_column(LargeBinary(length=16_777_215), nullable=True)
file_path: Mapped[str | None] = mapped_column(String(1000), nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
assignment: Mapped[AskAssignment] = relationship(back_populates="screenshots")

View File

@ -0,0 +1,82 @@
import hashlib
import mimetypes
import uuid
from pathlib import Path
from fastapi import HTTPException, Response
from .config import settings
def _safe_extension(filename: str, content_type: str) -> str:
extension = Path(filename or "").suffix.lower()
if extension in {".jpg", ".jpeg", ".png", ".webp"}:
return extension
guessed = mimetypes.guess_extension(content_type or "") or ".bin"
return ".jpg" if guessed == ".jpe" else guessed
def _absolute_path(relative_path: str) -> Path:
root = settings.screenshot_storage_dir.resolve()
candidate = (root / relative_path).resolve()
if root != candidate and root not in candidate.parents:
raise ValueError("非法截图文件路径")
return candidate
def store_screenshot(data: bytes, filename: str, content_type: str, category: str) -> str:
if not data:
raise ValueError("截图内容不能为空")
now_path = Path(category) / uuid.uuid4().hex[:2]
relative_path = now_path / f"{uuid.uuid4().hex}{_safe_extension(filename, content_type)}"
target = _absolute_path(relative_path.as_posix())
target.parent.mkdir(parents=True, exist_ok=True)
temporary = target.with_suffix(target.suffix + ".tmp")
temporary.write_bytes(data)
if temporary.stat().st_size != len(data):
temporary.unlink(missing_ok=True)
raise OSError("截图写入不完整")
temporary.replace(target)
return relative_path.as_posix()
def screenshot_fields(data: bytes, filename: str, content_type: str, category: str) -> dict:
return {
"filename": filename,
"content_type": content_type,
"file_path": store_screenshot(data, filename, content_type, category),
"image_data": None,
}
def screenshot_bytes(image) -> bytes:
if image.file_path:
try:
path = _absolute_path(image.file_path)
if path.is_file():
return path.read_bytes()
except (OSError, ValueError):
pass
if image.image_data:
return bytes(image.image_data)
raise HTTPException(status_code=404, detail="截图文件不存在")
def screenshot_response(image) -> Response:
return Response(
content=screenshot_bytes(image),
media_type=image.content_type or "application/octet-stream",
headers={"Cache-Control": "private, max-age=600"},
)
def screenshot_data_url(image) -> str:
import base64
encoded = base64.b64encode(screenshot_bytes(image)).decode()
return f"data:{image.content_type};base64,{encoded}"
def file_digest(relative_path: str) -> tuple[int, str]:
data = _absolute_path(relative_path).read_bytes()
return len(data), hashlib.sha256(data).hexdigest()

View File

@ -19,6 +19,31 @@ CREATE TABLE IF NOT EXISTS admin_users (
KEY idx_admin_users_status (is_active)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS refund_recipients (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
target_kind VARCHAR(32) NOT NULL,
target_id BIGINT NOT NULL,
account VARCHAR(128) NOT NULL,
payee_name VARCHAR(64) NOT NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_refund_recipient_target (target_kind, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS alipay_refund_orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
target_kind VARCHAR(32) NOT NULL,
target_id BIGINT NOT NULL,
amount_fen INT NOT NULL,
account VARCHAR(128) NOT NULL,
payee_name VARCHAR(64) NOT NULL,
created_by_id BIGINT NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
UNIQUE KEY uk_alipay_refund_target (target_kind, target_id),
KEY idx_alipay_refund_orders_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS admin_login_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NULL,

View File

@ -0,0 +1,87 @@
"""Move legacy screenshot BLOBs to server files.
Run from the project root with::
python backend/scripts/migrate_screenshots_to_files.py
Each row is committed only after the written file matches the source byte
length and SHA-256 digest. Re-running is safe: rows with a valid file_path and
an empty image_data value are skipped.
"""
import hashlib
import sys
from pathlib import Path
PROJECT_ROOT = Path(__file__).resolve().parents[2]
BACKEND_ROOT = PROJECT_ROOT / "backend"
if str(BACKEND_ROOT) not in sys.path:
sys.path.insert(0, str(BACKEND_ROOT))
from sqlalchemy import inspect, select, text # noqa: E402
from app.config import settings # noqa: E402
from app.database import SessionLocal, engine # noqa: E402
from app.models import AnswerScreenshot, AskAssignmentBatchScreenshot, AskAssignmentScreenshot # noqa: E402
from app.screenshot_storage import file_digest, store_screenshot # noqa: E402
TARGETS = (
(AnswerScreenshot, "answer_screenshots", "answers"),
(AskAssignmentBatchScreenshot, "ask_assignment_batch_screenshots", "ask-batches"),
(AskAssignmentScreenshot, "ask_assignment_screenshots", "ask-assignments"),
)
def ensure_columns() -> None:
for _model, table, _category in TARGETS:
columns = {column["name"]: column for column in inspect(engine).get_columns(table)}
if "file_path" not in columns:
with engine.begin() as connection:
connection.execute(text(f"ALTER TABLE {table} ADD COLUMN file_path VARCHAR(1000) NULL"))
if engine.dialect.name == "mysql" and not columns["image_data"].get("nullable", False):
with engine.begin() as connection:
connection.execute(text(f"ALTER TABLE {table} MODIFY COLUMN image_data MEDIUMBLOB NULL"))
def migrate() -> None:
ensure_columns()
settings.screenshot_storage_dir.mkdir(parents=True, exist_ok=True)
migrated_rows = 0
migrated_bytes = 0
for model, table, category in TARGETS:
table_rows = 0
table_bytes = 0
while True:
with SessionLocal() as db:
image = db.scalar(
select(model)
.where(model.image_data.is_not(None))
.order_by(model.id)
.limit(1)
)
if image is None:
break
source = bytes(image.image_data)
source_digest = hashlib.sha256(source).hexdigest()
relative_path = store_screenshot(source, image.filename, image.content_type, category)
stored_size, stored_digest = file_digest(relative_path)
if stored_size != len(source) or stored_digest != source_digest:
raise RuntimeError(f"{table}#{image.id} 文件校验失败")
image.file_path = relative_path
image.image_data = None
db.commit()
table_rows += 1
table_bytes += len(source)
if table_rows % 100 == 0:
print(f"{table}: migrated {table_rows} rows", flush=True)
migrated_rows += table_rows
migrated_bytes += table_bytes
print(f"{table}: complete rows={table_rows} bytes={table_bytes}", flush=True)
print(f"TOTAL rows={migrated_rows} bytes={migrated_bytes}", flush=True)
print(f"FILES {settings.screenshot_storage_dir.resolve()}", flush=True)
if __name__ == "__main__":
migrate()

View File

@ -8,6 +8,7 @@ from pathlib import Path
# database must be selected before any test module imports app.main.
_test_dir = Path(tempfile.mkdtemp(prefix="wendajia-tests-"))
os.environ["DATABASE_URL"] = f"sqlite:///{(_test_dir / 'app.db').as_posix()}"
os.environ["SCREENSHOT_STORAGE_DIR"] = str(_test_dir / "screenshots")
os.environ["ADMIN_USERNAME"] = "admin"
os.environ["ADMIN_PASSWORD"] = "admin123456"
os.environ["OPERATOR_USERNAME"] = "operator"

View File

@ -0,0 +1,56 @@
from uuid import uuid4
from fastapi.testclient import TestClient
import app.main as main
def test_refund_submission_is_atomic_idempotent_and_does_not_mark_paid():
with TestClient(main.app) as client:
client.post('/api/admin/auth/login', json={'username': 'operator', 'password': 'operator123456'})
with main.SessionLocal() as db:
owner = db.scalar(main.select(main.AdminUser).where(main.AdminUser.username == 'operator'))
user = main.AnswerUser(public_id=uuid4().hex[:10], phone=uuid4().hex[:20], auth_token=uuid4().hex)
db.add(user)
db.flush()
records = [main.AnswerSubmission(submission_no=uuid4().hex, user_id=user.id, owner_id=owner.id, status='已通过') for _ in range(2)]
db.add_all(records)
db.flush()
ids = [record.id for record in records]
main._save_refund_recipient(db, 'answer_submission', ids[0], 'payer@example.com', '测试收款人')
db.commit()
detail = client.get(f'/api/admin/task-board/{ids[0]}')
assert detail.status_code == 200
assert detail.json()['data']['alipay_account'] == 'payer@example.com'
assert detail.json()['data']['payee_name'] == '测试收款人'
items = [{'target_kind': 'answer_submission', 'target_id': target_id, 'amount': amount, 'account': 'payer@example.com', 'payee_name': '测试收款人'} for target_id, amount in zip(ids, ['1.25', '2.36'])]
bad = [items[0], {**items[1], 'amount': 'NaN'}]
assert client.post('/api/admin/alipay-refunds', json={'items': bad}).status_code == 400
with main.SessionLocal() as db:
assert db.scalar(main.select(main.AlipayRefundOrder).where(main.AlipayRefundOrder.target_id.in_(ids))) is None
assert client.post('/api/admin/alipay-refunds', json={'items': items}).status_code == 200
assert client.post('/api/admin/alipay-refunds', json={'items': items}).status_code == 409
with main.SessionLocal() as db:
assert [db.get(main.AnswerSubmission, target_id).status for target_id in ids] == ['已通过', '已通过']
orders = db.scalars(main.select(main.AlipayRefundOrder).where(main.AlipayRefundOrder.target_id.in_(ids))).all()
assert sorted(order.amount_fen for order in orders) == [125, 236]
assert all(order.status == 'pending' for order in orders)
listed = client.get('/api/admin/alipay-refunds').json()['data']
assert any(row['target_id'] == ids[0] and row['submitted'] for row in listed)
def test_refund_requires_login_and_task_access():
with TestClient(main.app) as client:
assert client.get('/api/admin/alipay-refunds').status_code == 401
client.post('/api/admin/auth/login', json={'username': 'operator', 'password': 'operator123456'})
with main.SessionLocal() as db:
admin = db.scalar(main.select(main.AdminUser).where(main.AdminUser.username == 'admin'))
user = main.AnswerUser(public_id=uuid4().hex[:10], phone=uuid4().hex[:20], auth_token=uuid4().hex)
db.add(user)
db.flush()
record = main.AnswerSubmission(submission_no=uuid4().hex, user_id=user.id, owner_id=admin.id, status='已通过')
db.add(record)
db.flush()
target_id = record.id
db.commit()
response = client.post('/api/admin/alipay-refunds', json={'items': [{'target_kind': 'answer_submission', 'target_id': target_id, 'amount': '1.00', 'account': 'test@example.com', 'payee_name': '测试'}]})
assert response.status_code == 404

View File

@ -66,6 +66,9 @@ def test_admin_usage_entry_is_deduplicated_and_visible_in_stats():
assert stats.status_code == 200
operator = next(item for item in stats.json()["data"]["users"] if item["username"] == "operator")
assert operator["count"] >= 1
department = next(item for item in stats.json()["data"]["departments"] if operator["department_name"] == item["name"])
assert department["user_count"] >= 1
assert any(member["username"] == "operator" for member in department["members"])
def test_operator_cannot_access_super_admin_apis():
@ -77,6 +80,167 @@ def test_operator_cannot_access_super_admin_apis():
assert client.get("/api/admin/settings").status_code == 403
def test_dingtalk_admission_allows_existing_users_and_two_business_divisions(monkeypatch):
from fastapi import HTTPException, Response
monkeypatch.setattr(settings, "dingtalk_allowed_union_ids", set())
monkeypatch.setattr(settings, "dingtalk_allowed_dept_ids", set())
monkeypatch.setattr(settings, "dingtalk_allowed_dept_names", {"战狼事业部", "雪狼事业部"})
monkeypatch.setattr(settings, "dingtalk_super_admin_union_ids", set())
monkeypatch.setattr(settings, "dingtalk_super_admin_dept_ids", set())
monkeypatch.setattr(settings, "dingtalk_super_admin_dept_names", {"AI指挥部"})
suffix = uuid4().hex[:10]
allowed_union = f"allowed_{suffix}"
existing_union = f"existing_{suffix}"
blocked_union = f"blocked_{suffix}"
ai_union = f"ai_{suffix}"
with TestClient(app), main_module.SessionLocal() as db:
existing = main_module.AdminUser(
username=f"dingtalk_{existing_union}",
password_hash=main_module.hash_password("unused-password"),
display_name="已有名单用户",
role_code="operator",
dingtalk_union_id=existing_union,
is_active=True,
)
db.add(existing)
db.commit()
allowed = main_module._dingtalk_create_session({
"unionId": allowed_union,
"userid": f"user_{suffix}",
"name": "目标部门主管",
"_primary_dept_id": "child-101",
"_dept_name": "战狼一组",
"_dept_path": ",1,100,101,",
"_dept_name_path": "深圳市蝶雅化妆品有限公司/战狼事业部/战狼一组",
"_org_data_available": True,
"_is_dept_manager": True,
}, db, Response())
assert allowed.role_code == "dept_manager"
assert main_module._dingtalk_department_is_allowed("snow-child", "雪狼一组", None, "公司/雪狼事业部/雪狼一组")
assert not main_module._dingtalk_department_is_allowed("outside", "其他事业部", None, "公司/其他事业部")
ai_admin = main_module._dingtalk_create_session({
"unionId": ai_union,
"userid": f"ai_user_{suffix}",
"name": "AI指挥部成员",
"_primary_dept_id": "ai-child-1",
"_dept_name": "AI应用开发",
"_dept_path": ",1,ai,ai-child-1,",
"_dept_name_path": "深圳市蝶雅化妆品有限公司/中后台部门/AI指挥部/AI应用开发",
"_org_data_available": True,
"_is_dept_manager": False,
}, db, Response())
assert ai_admin.role_code == "super_admin"
continued = main_module._dingtalk_create_session({
"unionId": existing_union,
"name": "已有名单用户",
"_primary_dept_id": "outside-1",
"_dept_name": "其他事业部",
"_dept_name_path": "深圳市蝶雅化妆品有限公司/其他事业部",
"_org_data_available": True,
}, db, Response())
assert continued.id == existing.id
existing.role_code = "super_admin"
db.commit()
preserved = main_module._dingtalk_create_session({
"unionId": existing_union,
"name": "已有管理员",
"_org_data_available": True,
"_is_dept_manager": True,
}, db, Response())
assert preserved.role_code == "super_admin"
existing.is_active = False
db.commit()
try:
main_module._dingtalk_create_session({"unionId": existing_union}, db, Response())
assert False, "停用账号不能继续登录"
except HTTPException as exc:
assert exc.status_code == 403
try:
main_module._dingtalk_create_session({
"unionId": blocked_union,
"name": "名单外用户",
"_primary_dept_id": "outside-2",
"_dept_name": "其他事业部",
"_dept_name_path": "深圳市蝶雅化妆品有限公司/其他事业部",
"_org_data_available": True,
}, db, Response())
assert False, "名单外非目标部门用户应被拒绝"
except HTTPException as exc:
assert exc.status_code == 403
created_usernames = [f"dingtalk_{allowed_union}", f"dingtalk_{existing_union}", f"dingtalk_{ai_union}"]
created = db.scalars(main_module.select(main_module.AdminUser).where(main_module.AdminUser.username.in_(created_usernames))).all()
for user in created:
db.delete(user)
db.commit()
def test_dingtalk_org_profile_prefers_ai_command_department(monkeypatch):
monkeypatch.setattr(settings, "dingtalk_allowed_dept_ids", set())
monkeypatch.setattr(settings, "dingtalk_allowed_dept_names", {"战狼事业部", "雪狼事业部"})
monkeypatch.setattr(settings, "dingtalk_super_admin_dept_ids", {"200"})
monkeypatch.setattr(settings, "dingtalk_super_admin_dept_names", {"AI指挥部"})
departments = {
"1": {"name": "公司", "parent_id": 0},
"100": {"name": "其他事业部", "parent_id": 1},
"101": {"name": "其他小组", "parent_id": 100},
"200": {"name": "AI指挥部", "parent_id": 1},
"201": {"name": "AI应用开发", "parent_id": 200},
}
def fake_oapi(path, access_token, payload):
if path.endswith("/user/get"):
return {"result": {
"userid": "multi-dept-user",
"dept_id_list": [101, 201],
"leader_in_dept": [
{"dept_id": 101, "leader": True},
{"dept_id": 201, "leader": False},
],
}}
return {"result": departments[str(payload["dept_id"])]}
monkeypatch.setattr(main_module, "_dingtalk_oapi_post", fake_oapi)
profile = main_module._dingtalk_org_profile({"userid": "multi-dept-user"}, "token")
assert profile["_primary_dept_id"] == "201"
assert profile["_dept_name_path"].endswith("AI指挥部/AI应用开发")
assert profile["_is_dept_manager"] is False
def test_operator_and_department_manager_data_scope():
suffix = uuid4().hex[:8]
with TestClient(app), main_module.SessionLocal() as db:
manager = main_module.AdminUser(
username=f"manager_{suffix}", password_hash="unused", display_name="部门主管",
role_code="dept_manager", dingtalk_dept_id="dept-100", dingtalk_dept_path=",root,dept-100,",
)
member = main_module.AdminUser(
username=f"member_{suffix}", password_hash="unused", display_name="部门成员",
role_code="operator", dingtalk_dept_id="dept-101", dingtalk_dept_path=",root,dept-100,dept-101,",
)
outsider = main_module.AdminUser(
username=f"outsider_{suffix}", password_hash="unused", display_name="其他部门",
role_code="operator", dingtalk_dept_id="dept-200", dingtalk_dept_path=",root,dept-200,",
)
db.add_all([manager, member, outsider])
db.flush()
member_task = main_module.AskTask(task_no=f"TASK-M-{suffix}", owner_id=member.id, product_link="", product_name="测试", status="草稿")
outsider_task = main_module.AskTask(task_no=f"TASK-O-{suffix}", owner_id=outsider.id, product_link="", product_name="测试", status="草稿")
db.add_all([member_task, outsider_task])
db.flush()
assert main_module._can_access_ask_task(member_task, member)
assert not main_module._can_access_ask_task(outsider_task, member)
assert main_module._can_access_ask_task(member_task, manager)
assert not main_module._can_access_ask_task(outsider_task, manager)
db.rollback()
def test_register_and_super_admin_user_crud():
username = f"tester_{uuid4().hex[:8]}"
with TestClient(app) as client:

View File

@ -0,0 +1,63 @@
import React, { useEffect, useState } from 'react';
import { TaskDetail } from './TaskBoardPage.jsx';
export default function AlipayRefundPanel({ api, onClose, standalone = false }) {
const [rows, setRows] = useState([]);
const [selected, setSelected] = useState([]);
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(false);
const [message, setMessage] = useState('');
const [detail, setDetail] = useState(null);
const [detailLoading, setDetailLoading] = useState(false);
const key = row => `${row.target_kind}-${row.target_id}`;
async function load() {
setLoading(true);
try { const result = await api('/api/admin/alipay-refunds'); setRows(result.data); setSelected([]); }
catch (error) { setMessage(error.message); }
finally { setLoading(false); }
}
useEffect(() => { load(); }, []);
function edit(row, field, value) {
setRows(current => current.map(item => key(item) === key(row) ? { ...item, [field]: value } : item));
}
async function submit(items) {
if (busy || !items.length) return;
if (items.some(item => !item.account.trim() || !item.payee_name.trim() || !/^\d+(\.\d{1,2})?$/.test(item.amount) || Number(item.amount) <= 0 || Number(item.amount) > 100000)) {
setMessage('请逐笔填写支付宝账号、收款人姓名和金额(0至100000元,最多两位小数)'); return;
}
setBusy(true); setMessage('');
try {
const result = await api('/api/admin/alipay-refunds', { method: 'POST', body: JSON.stringify({ items }) });
setMessage(`已提交 ${result.data.count} 笔,待支付。代付接口未接入,尚未实际付款。`);
await load();
} catch (error) { setMessage(error.message); }
finally { setBusy(false); }
}
const available = rows.filter(row => !row.submitted);
async function showDetail(row) {
setDetailLoading(true); setDetail(null);
try { const result = await api(row.detail_endpoint); setDetail(result.data); }
catch (error) { setMessage(error.message); }
finally { setDetailLoading(false); }
}
const content = <section className={`${standalone ? 'panel' : 'manual-modal'} alipay-refund-panel`} role="dialog" aria-modal={!standalone} aria-label="支付宝返款">
<div className="manual-modal-header"><h3>支付宝返款</h3>{onClose && <button disabled={busy} onClick={onClose}>×</button>}</div>
<p>待返款任务按每次提交分别列出。可单笔提交,或勾选后批量提交,每笔金额可不同。</p>
<div className="review-tip">提交后保存为“待支付”。代付接口暂未接入,不会实际转账,也不会将任务标记为已返款。</div>
{message && <div role="status" className="form-error">{message}</div>}
{loading ? <p>正在读取待返款任务…</p> : <div className="alipay-refund-scroll"><table className="data-table"><thead><tr>
<th><input type="checkbox" disabled={busy || !available.length} checked={available.length > 0 && available.every(row => selected.includes(key(row)))} onChange={event => setSelected(event.target.checked ? available.map(key) : [])} aria-label="全选待提交返款" /></th>
<th>任务 / 提交记录</th><th>支付宝账号</th><th>收款人姓名</th><th>金额(元)</th><th>支付状态</th><th>操作</th>
</tr></thead><tbody>{rows.map(row => <tr key={key(row)}>
<td><input type="checkbox" disabled={busy || row.submitted} checked={selected.includes(key(row))} onChange={event => setSelected(current => event.target.checked ? [...current, key(row)] : current.filter(id => id !== key(row)))} /></td>
<td><strong>{row.task_no}</strong><small>{row.taobao_id || '未填写淘宝ID'} · {row.target_kind === 'answer_submission' ? '回答' : '提问'}提交 #{row.target_id}</small><small>{row.product_name || '待识别商品'} · {row.shop_name || '未填写店铺'}</small></td>
<td><input aria-label="支付宝账号" maxLength={128} disabled={busy || row.submitted} value={row.account} onChange={event => edit(row, 'account', event.target.value)} /></td>
<td><input aria-label="收款人姓名" maxLength={64} disabled={busy || row.submitted} value={row.payee_name} onChange={event => edit(row, 'payee_name', event.target.value)} /></td>
<td><input aria-label="返款金额" type="number" min="0.01" max="100000" step="0.01" disabled={busy || row.submitted} value={row.amount} onChange={event => edit(row, 'amount', event.target.value)} /></td>
<td>{row.payment_status}</td><td><button type="button" className="ghost-button" onClick={() => showDetail(row)}>查看详情</button> <button className="primary-button" disabled={busy || row.submitted} onClick={() => submit([row])}>单笔提交</button></td>
</tr>)}{!rows.length && <tr><td colSpan={7}>暂无待返款任务</td></tr>}</tbody></table></div>}
<div className="manual-modal-actions">{onClose && <button disabled={busy} onClick={onClose}>关闭</button>}<button className="primary-button" disabled={busy || !selected.length} onClick={() => submit(rows.filter(row => selected.includes(key(row))))}>{busy ? '提交中…' : `批量提交(${selected.length}笔)`}</button></div>
{detail && <TaskDetail task={detail} close={() => setDetail(null)} readOnly hasNextRefund={false} nextBusy={false} />}
</section>;
return standalone ? content : <div className="manual-modal-backdrop">{content}</div>;
}

View File

@ -1,12 +1,13 @@
import React, { useEffect, useMemo, useRef, useState } from 'react'
import { Check, ClipboardCheck, Clock3, Copy, Eye, FileCheck2, History, ImagePlus, LogOut, MessageCircle, ReceiptText, RefreshCw, ShieldCheck } from 'lucide-react'
import { isValidTaobaoId, normalizeTaobaoId, TAOBAO_ID_HINT } from './taobaoId.js'
import { formatDateTime } from './time.js'
const SESSION_KEY = 'answer_user_taobao_session_v2'
const LEGACY_SESSION_KEYS = ['answer_user_taobao_session', 'answer_user_session']
const STATUS_TEXT = { '待筛选': '截图审核中', '识别失败': '运营处理中', '待运营派发': '截图审核中', '已派发': '截图审核中', '待用户完成': '待完成回答', '待审核': '完成审核中', '已通过': '已完成', '已完成': '已返款', '未通过': '审核不通过', '已撤销': '任务已撤销' }
const accountName = session => session?.taobao_id || session?.account || session?.user_id || ''
const formatTime = value => value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '--'
const formatTime = formatDateTime
const fileKey = file => `${file.name}-${file.size}-${file.lastModified}`
function Top({ session, latest, logout }) {
@ -71,6 +72,7 @@ function AssignmentStage({ api, session, latest, reload, setError, linkToken })
const selected = latest.questions.filter(question => question.selected); const progressKey = `answer_progress_${latest.id}`
const [done, setDone] = useState(() => { try { return JSON.parse(localStorage.getItem(progressKey) || '[]') } catch { return [] } })
const [tab, setTab] = useState('list'); const [files, setFiles] = useState([]); const [payFiles, setPayFiles] = useState([]); const [busy, setBusy] = useState(false)
const [alipayAccount, setAlipayAccount] = useState(''); const [payeeName, setPayeeName] = useState('')
const completed = selected.filter(question => done.includes(question.id)).length
useEffect(() => {
if (latest.status !== '未通过' || !latest.latest_review) return
@ -82,10 +84,12 @@ function AssignmentStage({ api, session, latest, reload, setError, linkToken })
async function copy(question) { try { await navigator.clipboard.writeText(question.answer || ''); if (!done.includes(question.id)) saveDone([...done, question.id]) } catch { setError('复制失败,请长按参考回答手动复制') } }
async function complete(event) {
event.preventDefault(); setError(''); if (!files.length) return setError('请至少上传一张完成回答截图'); if (!payFiles.length) return setError('请上传收款二维码后再提交')
if (!alipayAccount.trim() || !payeeName.trim()) return setError('请填写支付宝账号和收款人姓名')
const body = new FormData(); body.append('token', session.token); if (linkToken) body.append('owner_token', linkToken); files.forEach(file => body.append('files', file)); payFiles.forEach(file => body.append('payment_files', file)); setBusy(true)
body.append('alipay_account', alipayAccount.trim()); body.append('payee_name', payeeName.trim())
try { const result = await api(`/api/answer/me/submissions/${latest.id}/completion`, { method: 'POST', body }); await reload(result.data) } catch (err) { setError(err.message) } finally { setBusy(false) }
}
return <><section className="answer-v2-hero green"><small>✓ 任务已派发 · 完成任务阶段</small><h1>完成问答并上传任务凭证</h1><p>请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。</p></section><div className="answer-v2-inner-tabs"><button className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>▣ 待回答清单 <small>{completed}/{selected.length}</small></button><button className={tab === 'upload' ? 'active' : ''} onClick={() => setTab('upload')}>✣ 任务上传(截图与收款码)</button></div>{tab === 'list' ? <section className="answer-v2-card assignment-list"><header><div><h2>待回答问题清单</h2><p>点击复制参考回答即可前往淘宝粘贴提交</p></div><span>回答进度 <b>{completed}/{selected.length}</b><i><u style={{width:`${selected.length ? completed / selected.length * 100 : 0}%`}}/></i></span></header><div className="answer-v2-list-tip">完成所有问题后,进入“任务上传” <button onClick={() => saveDone(selected.map(q => q.id))}>一键全部标记</button></div>{selected.map((question, index) => <article key={question.id} className={done.includes(question.id) ? 'complete' : ''}><header><b>{String(index + 1).padStart(2, '0')}</b><h3>{question.question}</h3><button aria-label="标记完成" onClick={() => mark(question.id)}>{done.includes(question.id) ? <Check size={15}/> : ''}</button></header><small>#{question.product_title || '商品问题'}</small>{question.operator_images?.length > 0 && <div className="answer-question-operator-image"><span>运营指定图片(请在回答本题时添加)</span><div>{question.operator_images.map((image, imageIndex) => <img key={image.id || image.data_url || image} src={image.data_url || image.url || image} alt={`运营指定回答图片${imageIndex + 1}`}/>)}</div></div>}<div><strong>▱ 参考回答:</strong><p>{question.answer}</p></div><footer><span>{done.includes(question.id) ? '已复制并标记完成' : '待复制进入淘宝回答'}</span><button onClick={() => copy(question)}><Copy size={15}/>复制回答</button></footer></article>)}<button className="answer-v2-primary wide" disabled={!selected.length} onClick={() => setTab('upload')}>完成回答,进入任务上传</button></section> : <form className="answer-v2-card completion-form" onSubmit={complete}><h2>上传任务完成凭证</h2><p>请上传已发布回答的截图;收款二维码为必传材料,用于后续核验。</p><label>完成回答截图(必传)<input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => setFiles(Array.from(event.target.files).slice(0, 10))}/><small>已选择 {files.length} 张</small></label><label>收款二维码(必传)<input type="file" accept="image/jpeg,image/png,image/webp" onChange={event => setPayFiles(Array.from(event.target.files).slice(0, 1))}/><small>已选择 {payFiles.length} 张</small></label><button className="answer-v2-primary wide" disabled={busy || !files.length}>{busy ? '提交中…' : '提交任务凭证'}</button></form>}</>
return <><section className="answer-v2-hero green"><small>✓ 任务已派发 · 完成任务阶段</small><h1>完成问答并上传任务凭证</h1><p>请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。</p></section><div className="answer-v2-inner-tabs"><button className={tab === 'list' ? 'active' : ''} onClick={() => setTab('list')}>▣ 待回答清单 <small>{completed}/{selected.length}</small></button><button className={tab === 'upload' ? 'active' : ''} onClick={() => setTab('upload')}>✣ 任务上传(截图与收款码)</button></div>{tab === 'list' ? <section className="answer-v2-card assignment-list"><header><div><h2>待回答问题清单</h2><p>点击复制参考回答即可前往淘宝粘贴提交</p></div><span>回答进度 <b>{completed}/{selected.length}</b><i><u style={{width:`${selected.length ? completed / selected.length * 100 : 0}%`}}/></i></span></header><div className="answer-v2-list-tip">完成所有问题后,进入“任务上传” <button onClick={() => saveDone(selected.map(q => q.id))}>一键全部标记</button></div>{selected.map((question, index) => <article key={question.id} className={done.includes(question.id) ? 'complete' : ''}><header><b>{String(index + 1).padStart(2, '0')}</b><h3>{question.question}</h3><button aria-label="标记完成" onClick={() => mark(question.id)}>{done.includes(question.id) ? <Check size={15}/> : ''}</button></header><small>#{question.product_title || '商品问题'}</small>{question.operator_images?.length > 0 && <div className="answer-question-operator-image"><span>运营指定图片(请在回答本题时添加)</span><div>{question.operator_images.map((image, imageIndex) => <img key={image.id || image.data_url || image} src={image.data_url || image.url || image} alt={`运营指定回答图片${imageIndex + 1}`}/>)}</div></div>}<div><strong>▱ 参考回答:</strong><p>{question.answer}</p></div><footer><span>{done.includes(question.id) ? '已复制并标记完成' : '待复制进入淘宝回答'}</span><button onClick={() => copy(question)}><Copy size={15}/>复制回答</button></footer></article>)}<button className="answer-v2-primary wide" disabled={!selected.length} onClick={() => setTab('upload')}>完成回答,进入任务上传</button></section> : <form className="answer-v2-card completion-form" onSubmit={complete}><h2>上传任务完成凭证</h2><label>支付宝账号(必填)<input required maxLength={128} value={alipayAccount} onChange={event => setAlipayAccount(event.target.value)} placeholder="请输入支付宝账号(手机号或邮箱)" /></label><label>收款人姓名(必填)<input required maxLength={64} value={payeeName} onChange={event => setPayeeName(event.target.value)} placeholder="请输入支付宝实名姓名" /></label><p>请上传已发布回答的截图;收款二维码为必传材料,用于后续核验。</p><label>完成回答截图(必传)<input type="file" multiple accept="image/jpeg,image/png,image/webp" onChange={event => setFiles(Array.from(event.target.files).slice(0, 10))}/><small>已选择 {files.length} 张</small></label><label>收款二维码(必传)<input type="file" accept="image/jpeg,image/png,image/webp" onChange={event => setPayFiles(Array.from(event.target.files).slice(0, 1))}/><small>已选择 {payFiles.length} 张</small></label><button className="answer-v2-primary wide" disabled={busy || !files.length}>{busy ? '提交中…' : '提交任务凭证'}</button></form>}</>
}
function FinalStage({ latest, reload, busy, reopen, startNew }) {

View File

@ -12,6 +12,7 @@ import {
Trash2,
} from "lucide-react";
import { isValidTaobaoId, normalizeTaobaoId, TAOBAO_ID_HINT } from "./taobaoId.js";
import { formatDateTime } from "./time.js";
const SESSION_KEY = "ask_user_taobao_session_v2";
const LEGACY_SESSION_KEYS = ["ask_user_taobao_session", "ask_user_session"];
@ -430,6 +431,7 @@ function ConfirmStage({
const [files, setFiles] = useState([]);
const [paymentFile, setPaymentFile] = useState(null);
const [payeeName, setPayeeName] = useState("");
const [alipayAccount, setAlipayAccount] = useState("");
const [reviewOpen, setReviewOpen] = useState(false);
const [busy, setBusy] = useState(false);
const [copiedId, setCopiedId] = useState(null);
@ -474,6 +476,7 @@ function ConfirmStage({
files.forEach((file) => body.append("files", file));
body.append("payment_file", paymentFile);
body.append("payee_name", payeeName);
body.append("alipay_account", alipayAccount.trim());
try {
const result = await api(
`/api/ask/tasks/${encodeURIComponent(taskNo)}/submission`,
@ -635,7 +638,11 @@ function ConfirmStage({
onChange={(event) => setPaymentFile(event.target.files?.[0] || null)}
/>
<label>
收款人真实姓名(选填,用于核验一致性)
支付宝账号(必填)
<input value={alipayAccount} maxLength={128} onChange={(event) => setAlipayAccount(event.target.value)} placeholder="请输入支付宝账号(手机号或邮箱)" />
</label>
<label>
收款人真实姓名(必填)
<input
value={payeeName}
onChange={(event) => setPayeeName(event.target.value)}
@ -645,7 +652,7 @@ function ConfirmStage({
</section>
<button
className="ask-submit-review"
disabled={!files.length || !paymentFile}
disabled={!files.length || !paymentFile || !alipayAccount.trim() || !payeeName.trim()}
onClick={() => setReviewOpen(true)}
>
下一步:核对并提交审核
@ -758,9 +765,7 @@ function AuditStatus({ assignment, reload, busy, onBackHome }) {
</dd>
<dt>提交时间:</dt>
<dd>
{new Date(assignment.updated_at).toLocaleString("zh-CN", {
hour12: false,
})}
{formatDateTime(assignment.updated_at)}
</dd>
<dt>锁定状态:</dt>
<dd>{pending ? "已锁定不可撤销" : assignment.status}</dd>
@ -852,9 +857,7 @@ function TaskStatusPage({ history, onView }) {
认领问题{" "}
{item.questions.filter((question) => question.selected).length} 条
· 更新时间{" "}
{new Date(item.updated_at).toLocaleTimeString("zh-CN", {
hour12: false,
})}
{formatDateTime(item.updated_at)}
</p>
<footer>
<span>

View File

@ -1,5 +1,6 @@
import React, { useEffect, useMemo, useRef, useState } from "react";
import { ChevronRight, LoaderCircle, MessageCircleMore, RefreshCw, X } from "lucide-react";
import { formatDateTime } from "./time.js";
const REASONS = [
"内容不清晰",
@ -10,8 +11,7 @@ const REASONS = [
"其他",
];
const STATUSES = ["全部", "待审核"];
const formatTime = (value) =>
value ? new Date(value).toLocaleString("zh-CN", { hour12: false }) : "--";
const formatTime = formatDateTime;
const productName = (record) =>
[
...new Set(
@ -22,6 +22,22 @@ const StatusBadge = ({ status }) => (
<em className={`review-v2-status ${status}`}>{status}</em>
);
function RefundRecipientInfo({ record }) {
return (
<div className="refund-recipient-info">
<strong>支付宝收款信息</strong>
<span>
支付宝账号
<b>{record.alipay_account || "未填写(历史任务)"}</b>
</span>
<span>
收款人姓名
<b>{record.payee_name || "未填写(历史任务)"}</b>
</span>
</div>
);
}
const reviewKind = (record) => record?.batch_id ? "batch" : "assignment";
const reviewId = (record) => record?.batch_id || record?.assignment_id || record?.id;
const reviewKey = (record) => `${reviewKind(record)}:${reviewId(record)}`;
@ -108,6 +124,7 @@ function ReviewDetail({ record, close, decide, busy, hasNext, onNext, nextBusy }
<span>商品:{productName(record)}</span>
<span>提交时间:{formatTime(record.updated_at)}</span>
</div>
<RefundRecipientInfo record={record} />
<h3>① 问答记录(发布问题 ↔ 用户回答)</h3>
<div className="review-v2-qa">
{selected.map((question, index) => (
@ -287,6 +304,7 @@ function AskReviewDetail({ record, close, api, onDecision, hasNext, onNext, next
<span>创建人:{record.creator_name || "运营后台"}</span>
<span>提交时间:{formatTime(record.updated_at)}</span>
</div>
<RefundRecipientInfo record={record} />
<h3>① 用户认领并完成的提问</h3>
<div className="review-v2-qa">
{selected.map((item, index) => (

View File

@ -48,7 +48,7 @@ function TaskDetailLoading() {
);
}
function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPollRefundUpload, hasNextRefund, onNextRefund, nextBusy }) {
export function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPollRefundUpload, hasNextRefund, onNextRefund, nextBusy, readOnly = false }) {
const [refundOpen, setRefundOpen] = useState(false);
const [refundBusy, setRefundBusy] = useState(false);
const [refundFile, setRefundFile] = useState(null);
@ -253,6 +253,17 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
创建时间<b>{formatTime(task.created_at)}</b>
</span>
</div>
<div className="refund-recipient-info task-detail-recipient-info">
<strong>支付宝收款信息</strong>
<span>
支付宝账号
<b>{task.alipay_account || "未填写(历史任务)"}</b>
</span>
<span>
收款人姓名
<b>{task.payee_name || "未填写(历史任务)"}</b>
</span>
</div>
<h3>认领用户的问题与回答(共 {task.assigned_users.length} 位)</h3>
<div className="task-detail-table">
<div className="head">
@ -361,7 +372,7 @@ function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPoll
</section>
</div>
<footer>
{task.refund_endpoint && (
{!readOnly && task.refund_endpoint && (
<button
type="button"
className="confirm-refund"

View File

@ -623,6 +623,30 @@
display: grid;
gap: 18px;
}
.alipay-refund-panel {
width: min(1200px, 95vw) !important;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.alipay-refund-panel.panel {
width: 100% !important;
max-width: none;
max-height: none;
padding: 22px;
}
.alipay-refund-scroll { overflow: auto; min-height: 100px; }
.alipay-refund-scroll table { min-width: 1000px; }
.alipay-refund-scroll th,
.alipay-refund-scroll td { vertical-align: middle; }
.alipay-refund-scroll td:first-child { width: 42px; }
.alipay-refund-scroll td:nth-child(2) { min-width: 300px; }
.alipay-refund-scroll td:nth-child(3),
.alipay-refund-scroll td:nth-child(4) { min-width: 165px; }
.alipay-refund-scroll td:nth-child(5) { min-width: 120px; }
.alipay-refund-scroll td:last-child { min-width: 170px; white-space: nowrap; }
.alipay-refund-scroll input:not([type="checkbox"]) { width: 100%; min-width: 100px; padding: 8px; border: 1px solid #cbdaf0; border-radius: 6px; }
.alipay-refund-scroll small { display: block; color: #7187a3; }
.usage-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
@ -700,6 +724,58 @@
display: grid;
gap: 0;
}
.usage-department-list {
display: grid;
gap: 7px;
}
.usage-department {
overflow: hidden;
border: 1px solid #d9e5f3;
border-radius: 8px;
background: #f8fbff;
}
.usage-department-header {
display: flex;
width: 100%;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 13px 14px;
border: 0;
color: #173b66;
background: transparent;
text-align: left;
cursor: pointer;
}
.usage-department-name {
display: inline-flex;
align-items: center;
gap: 7px;
font-weight: 600;
}
.usage-department-name svg {
transition: transform .18s ease;
}
.usage-department.expanded .usage-department-name svg {
transform: rotate(180deg);
}
.usage-department-meta {
color: #7187a3;
font-size: 12px;
white-space: nowrap;
}
.usage-department-members {
padding: 0 14px 4px;
border-top: 1px solid #d9e5f3;
background: #fff;
}
.usage-member-heading {
display: flex;
justify-content: space-between;
padding: 10px 0 4px;
color: #8b9db3;
font-size: 12px;
}
.usage-user-row {
display: flex;
align-items: center;
@ -732,6 +808,11 @@
display: grid;
gap: 5px;
}
.usage-department-header {
align-items: flex-start;
flex-direction: column;
gap: 6px;
}
.usage-chart {
gap: 2px;
}
@ -2773,6 +2854,9 @@
text-overflow: ellipsis;
white-space: nowrap;
}
.task-detail-recipient-info {
margin-top: 10px;
}
.task-detail-modal > h3 {
margin: 20px 0 10px;
font-size: 13px;
@ -4484,6 +4568,22 @@
background: #e8f1ff;
color: #1764e8;
}
.refund-recipient-info {
display: flex;
align-items: center;
gap: 14px;
flex-wrap: wrap;
margin-top: 12px;
padding: 11px 13px;
border: 1px solid #cfe0f7;
border-radius: 9px;
background: #f5f9ff;
color: #607998;
font-size: 11px;
}
.refund-recipient-info strong { color: #24558f; font-size: 12px; }
.refund-recipient-info span { display: inline-flex; align-items: center; gap: 6px; }
.refund-recipient-info b { color: #173258; font-weight: 700; }
.review-v2-modal > h3,
.review-v2-media h3 {
margin: 18px 0 9px;
@ -5040,6 +5140,24 @@
color: #748aa3;
font-size: 11px;
}
.qa-library-pagination {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-top: 14px;
padding: 12px 4px;
color: #7187a3;
font-size: 12px;
}
.qa-library-pagination > div {
display: flex;
gap: 8px;
}
.qa-library-pagination button:disabled {
cursor: not-allowed;
opacity: .5;
}
@media (max-width: 700px) {
.qa-library-toolbar {
grid-template-columns: 1fr;
@ -5051,6 +5169,10 @@
align-items: stretch;
flex-direction: column;
}
.qa-library-pagination {
align-items: stretch;
flex-direction: column;
}
}
.review-v2-modal > header h2 {
display: flex;

View File

@ -5,6 +5,7 @@ import {
ClipboardList,
Database,
BarChart3,
ChevronDown,
FileText,
LogOut,
MessageCircle,
@ -21,11 +22,13 @@ import CreateAnswerTasksPageV2 from "./CreateAnswerTasksPage.jsx";
import AnswerUserPortalV2 from "./AnswerUserPortal.jsx";
import TaskBoardPage, { clearTaskBoardCache } from "./TaskBoardPage.jsx";
import ReviewCenterPage from "./ReviewCenterPage.jsx";
import AlipayRefundPanel from "./AlipayRefundPanel.jsx";
import CreateAskTasksPage from "./CreateAskTasksPage.jsx";
import AskUserPortal from "./AskUserPortal.jsx";
import RefundUploadPage from "./RefundUploadPage.jsx";
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
import { readPageCache, writePageCache } from "./pageCache.js";
import { formatDateTime } from "./time.js";
// 兼容公共回答链接末尾有无斜杠,避免 /answer 被识别为运营后台路由。
if (window.location.pathname === "/answer")
@ -328,6 +331,12 @@ const BASE_ITEMS = [
label: "\u5ba1\u6838\u4e2d\u5fc3",
group: "\u4efb\u52a1\u7ba1\u7406",
},
{
id: "refunds",
icon: FileText,
label: "支付宝返款",
group: "任务管理",
},
{
id: "guide",
icon: BookOpen,
@ -1515,6 +1524,13 @@ function QALibraryPage({ selectionMode = false }) {
const [deleting, setDeleting] = useState(null);
const [deleteBusy, setDeleteBusy] = useState(false);
const [selectedIds, setSelectedIds] = useState([]);
const [page, setPage] = useState(1);
const pageSize = 20;
const pageCount = Math.max(1, Math.ceil(records.length / pageSize));
const visibleRecords = records.slice((page - 1) * pageSize, page * pageSize);
useEffect(() => {
setPage((current) => Math.min(current, pageCount));
}, [pageCount]);
async function load(keyword = search) {
setLoading(true);
try {
@ -1522,6 +1538,7 @@ function QALibraryPage({ selectionMode = false }) {
`/api/admin/qa-library?search=${encodeURIComponent(keyword.trim())}`,
);
setRecords(result.data);
setPage(1);
setError("");
} catch (err) {
setError(err.message);
@ -1571,7 +1588,7 @@ function QALibraryPage({ selectionMode = false }) {
</button>
</div>
<div className="qa-library-selection-actions">
{selectionMode && <label className="qa-library-select-all"><input type="checkbox" checked={records.length > 0 && records.every((item) => selectedIds.includes(item.id))} onChange={(event) => setSelectedIds(event.target.checked ? records.map((item) => item.id) : [])} /> 全选当前搜索结果</label>}
{selectionMode && <label className="qa-library-select-all"><input type="checkbox" checked={visibleRecords.length > 0 && visibleRecords.every((item) => selectedIds.includes(item.id))} onChange={(event) => setSelectedIds(event.target.checked ? [...new Set([...selectedIds, ...visibleRecords.map((item) => item.id)])] : selectedIds.filter((id) => !visibleRecords.some((item) => item.id === id)))} /> 全选当前页</label>}
<span>共 {records.length} 条问答</span>
{selectionMode && <button type="button" className="ghost-button" onClick={() => window.dispatchEvent(new CustomEvent("switch-create-ask"))}>返回创建任务</button>}
{selectionMode && <button type="button" className="primary-button" disabled={!selectedIds.length} onClick={() => window.dispatchEvent(new CustomEvent("qa-library-selected", { detail: records.filter((item) => selectedIds.includes(item.id)) }))}>添加已选并返回({selectedIds.length})</button>}
@ -1586,7 +1603,7 @@ function QALibraryPage({ selectionMode = false }) {
暂无已采纳问答,请先在问答助手中采纳内容。
</div>
) : (
records.map((record) => (
visibleRecords.map((record) => (
<article className={`qa-library-card panel${selectionMode ? " selectable" : ""}`} key={record.id}>
<header>
<div>
@ -1597,9 +1614,7 @@ function QALibraryPage({ selectionMode = false }) {
<div className="qa-library-card-tools">
<small>
{record.source} · {record.created_by} ·{" "}
{new Date(record.created_at).toLocaleString("zh-CN", {
hour12: false,
})}
{formatDateTime(record.created_at)}
</small>
<button onClick={() => setEditing(record)}>编辑</button>
<button className="danger-button" onClick={() => setDeleting(record)}>删除</button>
@ -1626,6 +1641,15 @@ function QALibraryPage({ selectionMode = false }) {
))
)}
</section>
{!loading && records.length > 0 && (
<div className="qa-library-pagination">
<span>第 {page} / {pageCount} 页,共 {records.length} 条</span>
<div>
<button type="button" className="ghost-button" disabled={page <= 1} onClick={() => setPage((current) => Math.max(1, current - 1))}>上一页</button>
<button type="button" className="primary-button" disabled={page >= pageCount} onClick={() => setPage((current) => Math.min(pageCount, current + 1))}>下一页</button>
</div>
</div>
)}
{editing && (
<QALibraryEditModal
record={editing}
@ -2215,7 +2239,8 @@ function AssistantPageFinal() {
);
}
function UserTable({ users, onChanged }) {
function UserTable({ users = [], onChanged }) {
const rows = Array.isArray(users) ? users : [];
const [editing, setEditing] = useState(null);
const [form, setForm] = useState({
username: "",
@ -2343,6 +2368,7 @@ function UserTable({ users, onChanged }) {
}
>
<option value="operator">{"\u666e\u901a\u8fd0\u8425"}</option>
<option value="dept_manager">部门主管</option>
<option value="super_admin">
{"\u8d85\u7ea7\u7ba1\u7406\u5458"}
</option>
@ -2385,7 +2411,7 @@ function UserTable({ users, onChanged }) {
</tr>
</thead>
<tbody>
{users.map((user) => (
{rows.map((user) => (
<tr key={user.id}>
<td>{user.username}</td>
<td>{user.display_name}</td>
@ -2403,7 +2429,7 @@ function UserTable({ users, onChanged }) {
</td>
<td>
{user.last_login_at
? new Date(user.last_login_at).toLocaleString()
? formatDateTime(user.last_login_at)
: "\u672a\u767b\u5f55"}
</td>
<td>
@ -2424,7 +2450,8 @@ function UserTable({ users, onChanged }) {
</>
);
}
function LogTable({ logs }) {
function LogTable({ logs = [] }) {
const rows = Array.isArray(logs) ? logs : [];
return (
<table className="data-table">
<thead>
@ -2436,7 +2463,9 @@ function LogTable({ logs }) {
</tr>
</thead>
<tbody>
{logs.map((log, index) => (
{rows.length === 0 ? (
<tr><td colSpan="4"><div className="empty-state">暂无登录日志</div></td></tr>
) : rows.map((log, index) => (
<tr key={`${log.created_at}-${index}`}>
<td>{log.username}</td>
<td>
@ -2447,7 +2476,7 @@ function LogTable({ logs }) {
</span>
</td>
<td>{log.ip_address || "-"}</td>
<td>{new Date(log.created_at).toLocaleString()}</td>
<td>{formatDateTime(log.created_at)}</td>
</tr>
))}
</tbody>
@ -2585,13 +2614,19 @@ function ReviewPanel({ records, onRefresh }) {
);
}
function UsagePanel({ data }) {
const departments = Array.isArray(data.departments) ? data.departments : [];
const [expanded, setExpanded] = useState(departments.length ? [departments[0].name] : []);
const maxCount = Math.max(1, ...(data.trend || []).map((item) => item.count || 0));
function toggleDepartment(name) {
setExpanded((current) => current.includes(name) ? current.filter((item) => item !== name) : [...current, name]);
}
return (
<div className="usage-statistics">
<div className="usage-summary-grid">
<div><span>近30天使用次数</span><strong>{data.total_count || 0}</strong></div>
<div><span>今日使用次数</span><strong>{data.today_count || 0}</strong></div>
<div><span>今日使用人数</span><strong>{data.today_users || 0}</strong></div>
<div><span>覆盖部门</span><strong>{data.department_count || departments.length}</strong></div>
</div>
<section className="usage-chart-section">
<div className="usage-section-heading"><h3>近30天使用趋势</h3><span>每次进入后台计 1 次(10 分钟内不重复)</span></div>
@ -2605,16 +2640,32 @@ function UsagePanel({ data }) {
</div>
</section>
<section className="usage-users-section">
<div className="usage-section-heading"><h3>使用人员</h3><span>按近30天使用次数排序</span></div>
{(data.users || []).length ? (
<div className="usage-user-list">
{data.users.map((item) => (
<div className="usage-user-row" key={`${item.user_id}-${item.username}`}>
<div><strong>{item.display_name}</strong><span>{item.department_name}</span></div>
<b>{item.count} 次</b>
<div className="usage-section-heading"><h3>按部门统计</h3><span>点击部门查看成员明细</span></div>
{departments.length ? (
<div className="usage-department-list">
{departments.map((department) => {
const isExpanded = expanded.includes(department.name);
return (
<div className={`usage-department${isExpanded ? " expanded" : ""}`} key={department.name}>
<button className="usage-department-header" onClick={() => toggleDepartment(department.name)}>
<span className="usage-department-name"><ChevronDown size={15} />{department.name}</span>
<span className="usage-department-meta">使用 {department.count} 次 {department.user_count} 人 最近 {formatDateTime(department.latest_at)}</span>
</button>
{isExpanded && (
<div className="usage-department-members">
<div className="usage-member-heading"><span>成员</span><span>使用次数</span></div>
{department.members.map((member) => (
<div className="usage-user-row" key={`${department.name}-${member.user_id}-${member.username}`}>
<div><strong>{member.display_name}</strong><span>{member.username}</span></div>
<b>{member.count} 次</b>
</div>
))}
</div>
)}
</div>
);
})}
</div>
) : <div className="empty-state">暂无使用记录</div>}
</section>
</div>
@ -2659,10 +2710,14 @@ function AdminPage({ type }) {
useEffect(() => {
setData(null);
setError("");
if (!config) return undefined;
api(config.endpoint)
.then((result) => setData(result.data))
.catch((err) => setError(err.message));
}, [type, reload]);
if (!config) {
return <div className="form-error">页面配置不存在,请刷新后重试</div>;
}
return (
<>
<PageHeading title={config.title} description={config.description} />
@ -2675,7 +2730,7 @@ function AdminPage({ type }) {
<UsagePanel data={data} />
) : type === "users" ? (
<UserTable
users={data}
users={Array.isArray(data) ? data : []}
onChanged={() => setReload((value) => value + 1)}
/>
) : type === "logs" ? (
@ -2996,7 +3051,7 @@ function SubmissionStatusDynamic({
<div className="answer-status-meta">
<div>
<span>{passed || rejected ? "处理时间" : "提交时间"}</span>
<b>{new Date(record.updated_at).toLocaleString()}</b>
<b>{formatDateTime(record.updated_at)}</b>
</div>
<div>
<span>当前状态</span>
@ -3283,7 +3338,7 @@ function AnswerUserPortal() {
{records.map((item) => (
<p key={item.id}>
{item.submission_no} · {item.status} ·{" "}
{new Date(item.created_at).toLocaleString()}
{formatDateTime(item.created_at)}
</p>
))}
</div>
@ -3658,7 +3713,7 @@ function SubmissionStatus({ record, history, onRefresh, onNew, onHistory }) {
<div className="answer-status-meta">
<div>
<span>提交时间</span>
<b>{new Date(record.updated_at).toLocaleString()}</b>
<b>{formatDateTime(record.updated_at)}</b>
</div>
<div>
<span>预计审核耗时</span>
@ -3716,7 +3771,7 @@ function SubmissionHistory({ records, onBack }) {
<span className="status-pill">{record.status}</span>
</div>
<p>
{new Date(record.updated_at).toLocaleString()} ·{" "}
{formatDateTime(record.updated_at)} ·{" "}
{record.invited_screenshots.length} 张截图
</p>
<div className="answer-history-thumbs">
@ -4372,6 +4427,8 @@ function Dashboard({ user, onLogout }) {
<TaskBoardPage api={api} user={user} />
) : active === "review" ? (
<ReviewCenterPage api={api} />
) : active === "refunds" ? (
<AlipayRefundPanel api={api} standalone />
) : active === "library" ? (
<QALibraryPage selectionMode={Boolean(window.__qaLibrarySelectionMode)} />
) : isAdminPage ? (

13
frontend/src/time.js Normal file
View File

@ -0,0 +1,13 @@
// Backend datetime values are stored as UTC. Older records omit the timezone suffix.
export function formatDateTime(value, options = {}) {
if (!value) return "--";
const raw = String(value);
const normalized = /(?:Z|[+-]\d{2}:?\d{2})$/i.test(raw) ? raw : `${raw}Z`;
const date = new Date(normalized);
if (Number.isNaN(date.getTime())) return "--";
return date.toLocaleString("zh-CN", {
hour12: false,
timeZone: "Asia/Shanghai",
...options,
});
}