{question.question}
{question.answer}
From ccae5a3a3c1e95b3dbcd0fa147f337db9011356d Mon Sep 17 00:00:00 2001
From: db_huang <18006480240@163.com>
Date: Sat, 17 Oct 2026 18:22:39 +0800
Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=8A=A0=E6=94=AF=E4=BB=98=E6=9D=BF?=
=?UTF-8?q?=E5=9D=97=EF=BC=8C=E4=BF=AE=E6=94=B9=E6=95=B0=E6=8D=AE=E5=BA=93?=
=?UTF-8?q?=E4=BD=8D=E7=BD=AE?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
.env.example | 7 +-
.gitignore | 1 +
backend/app/alipay_provider.py | 5 +
backend/app/config.py | 13 +-
backend/app/main.py | 392 ++++++++++++++----
backend/app/models.py | 33 +-
backend/app/screenshot_storage.py | 82 ++++
backend/schema.sql | 25 ++
.../scripts/migrate_screenshots_to_files.py | 87 ++++
backend/tests/conftest.py | 1 +
backend/tests/test_alipay_refunds.py | 56 +++
backend/tests/test_auth.py | 164 ++++++++
frontend/src/AlipayRefundPanel.jsx | 63 +++
frontend/src/AnswerUserPortal.jsx | 8 +-
frontend/src/AskUserPortal.jsx | 19 +-
frontend/src/ReviewCenterPage.jsx | 22 +-
frontend/src/TaskBoardPage.jsx | 15 +-
frontend/src/admin.css | 122 ++++++
frontend/src/main.jsx | 107 +++--
frontend/src/time.js | 13 +
20 files changed, 1111 insertions(+), 124 deletions(-)
create mode 100644 backend/app/alipay_provider.py
create mode 100644 backend/app/screenshot_storage.py
create mode 100644 backend/scripts/migrate_screenshots_to_files.py
create mode 100644 backend/tests/test_alipay_refunds.py
create mode 100644 frontend/src/AlipayRefundPanel.jsx
create mode 100644 frontend/src/time.js
diff --git a/.env.example b/.env.example
index 75a45ac..9962716 100644
--- a/.env.example
+++ b/.env.example
@@ -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指挥部
diff --git a/.gitignore b/.gitignore
index 9786d39..805eb75 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,4 @@ __pycache__/
.pytest_cache/
frontend/node_modules/
frontend/dist/
+data/uploads/
diff --git a/backend/app/alipay_provider.py b/backend/app/alipay_provider.py
new file mode 100644
index 0000000..55d902a
--- /dev/null
+++ b/backend/app/alipay_provider.py
@@ -0,0 +1,5 @@
+"""Reserved payout adapter. No external transfer is made in this version."""
+
+
+def transfer(order):
+ raise NotImplementedError("支付宝代付接口尚未接入,禁止将订单标记为支付成功")
diff --git a/backend/app/config.py b/backend/app/config.py
index 7e0d4c1..f29656a 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -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()
diff --git a/backend/app/main.py b/backend/app/main.py
index 9faf3ac..ab62016 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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,23 +430,20 @@ 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:
- db.commit()
+ # 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.
answer_users = db.scalars(
@@ -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]
- 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) + ","
+ 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)
+ 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 {}
+ 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()
+ 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="至少需要保留一个启用中的超级管理员")
diff --git a/backend/app/models.py b/backend/app/models.py
index 758ec9e..0facce5 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -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")
diff --git a/backend/app/screenshot_storage.py b/backend/app/screenshot_storage.py
new file mode 100644
index 0000000..71c58ca
--- /dev/null
+++ b/backend/app/screenshot_storage.py
@@ -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()
+
diff --git a/backend/schema.sql b/backend/schema.sql
index 07df6e0..634ca1f 100644
--- a/backend/schema.sql
+++ b/backend/schema.sql
@@ -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,
diff --git a/backend/scripts/migrate_screenshots_to_files.py b/backend/scripts/migrate_screenshots_to_files.py
new file mode 100644
index 0000000..deb7e94
--- /dev/null
+++ b/backend/scripts/migrate_screenshots_to_files.py
@@ -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()
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 7c36c93..0da2b30 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -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"
diff --git a/backend/tests/test_alipay_refunds.py b/backend/tests/test_alipay_refunds.py
new file mode 100644
index 0000000..9690167
--- /dev/null
+++ b/backend/tests/test_alipay_refunds.py
@@ -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
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
index 5a00a95..d7e7db1 100644
--- a/backend/tests/test_auth.py
+++ b/backend/tests/test_auth.py
@@ -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:
diff --git a/frontend/src/AlipayRefundPanel.jsx b/frontend/src/AlipayRefundPanel.jsx
new file mode 100644
index 0000000..0683828
--- /dev/null
+++ b/frontend/src/AlipayRefundPanel.jsx
@@ -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 = 待返款任务按每次提交分别列出。可单笔提交,或勾选后批量提交,每笔金额可不同。 正在读取待返款任务…支付宝返款
{onClose && }
+ {rows.map(row => 0 && available.every(row => selected.includes(key(row)))} onChange={event => setSelected(event.target.checked ? available.map(key) : [])} aria-label="全选待提交返款" />
+ 任务 / 提交记录 支付宝账号 收款人姓名 金额(元) 支付状态 操作
+
+ )}{!rows.length && setSelected(current => event.target.checked ? [...current, key(row)] : current.filter(id => id !== key(row)))} />
+ {row.task_no}{row.taobao_id || '未填写淘宝ID'} · {row.target_kind === 'answer_submission' ? '回答' : '提问'}提交 #{row.target_id}{row.product_name || '待识别商品'} · {row.shop_name || '未填写店铺'}
+ edit(row, 'account', event.target.value)} />
+ edit(row, 'payee_name', event.target.value)} />
+ edit(row, 'amount', event.target.value)} />
+ {row.payment_status}
+ }暂无待返款任务
请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。
点击复制参考回答即可前往淘宝粘贴提交
{question.answer}
请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。
点击复制参考回答即可前往淘宝粘贴提交
{question.answer}