diff --git a/backend/app/__pycache__/config.cpython-313.pyc b/backend/app/__pycache__/config.cpython-313.pyc index 4b2033e..da43880 100644 Binary files a/backend/app/__pycache__/config.cpython-313.pyc and b/backend/app/__pycache__/config.cpython-313.pyc differ diff --git a/backend/app/__pycache__/llm.cpython-313.pyc b/backend/app/__pycache__/llm.cpython-313.pyc index 5f59926..18dd7fc 100644 Binary files a/backend/app/__pycache__/llm.cpython-313.pyc and b/backend/app/__pycache__/llm.cpython-313.pyc differ diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc index d2a315b..1ce64da 100644 Binary files a/backend/app/__pycache__/main.cpython-313.pyc and b/backend/app/__pycache__/main.cpython-313.pyc differ diff --git a/backend/app/__pycache__/models.cpython-313.pyc b/backend/app/__pycache__/models.cpython-313.pyc index a5b2ea2..58ff459 100644 Binary files a/backend/app/__pycache__/models.cpython-313.pyc and b/backend/app/__pycache__/models.cpython-313.pyc differ diff --git a/backend/app/__pycache__/schemas.cpython-313.pyc b/backend/app/__pycache__/schemas.cpython-313.pyc index 48d29dc..ad595d2 100644 Binary files a/backend/app/__pycache__/schemas.cpython-313.pyc and b/backend/app/__pycache__/schemas.cpython-313.pyc differ diff --git a/backend/app/__pycache__/screenshot_storage.cpython-313.pyc b/backend/app/__pycache__/screenshot_storage.cpython-313.pyc index 4405081..d2e6648 100644 Binary files a/backend/app/__pycache__/screenshot_storage.cpython-313.pyc and b/backend/app/__pycache__/screenshot_storage.cpython-313.pyc differ diff --git a/backend/app/main.py b/backend/app/main.py index 400fa56..4fe60ad 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,7 +1,7 @@ from datetime import datetime, timedelta from decimal import Decimal, InvalidOperation -from .models import RefundRecipient, RefundPaymentAccount, RefundRecord -from .schemas import RefundPaymentAccountSave, PaymentAccountAuthorization +from .models import RefundAccountAdjustment, RefundRecipient, RefundPaymentAccount, RefundRecord +from .schemas import RefundFundAdjustment, RefundPaymentAccountSave, PaymentAccountAuthorization from sqlalchemy.exc import IntegrityError import base64 @@ -52,6 +52,7 @@ app.add_middleware( # confirms a selection, the upload screen has a separate thirty-minute window. ASK_SELECTION_TIMEOUT = timedelta(minutes=1) ASK_UPLOAD_TIMEOUT = timedelta(minutes=30) +ANSWER_TASK_TIMEOUT = timedelta(hours=24) # Bumping this prefix invalidates every previously issued public-user token. # Only Taobao-ID login can issue a token accepted by the current user portals. PUBLIC_USER_SESSION_PREFIX = "tb2_" @@ -88,6 +89,24 @@ def _release_expired_ask_assignments(db: Session, task: AskTask | None = None) - return len(expired) +def _expire_answer_tasks(db: Session, submission_id: int | None = None) -> int: + """Mark dispatched answer tasks as expired after a strict 24-hour window.""" + now = datetime.utcnow() + statement = select(AnswerSubmission).where( + AnswerSubmission.status == "待用户完成", + AnswerSubmission.updated_at < now - ANSWER_TASK_TIMEOUT, + ) + if submission_id is not None: + statement = statement.where(AnswerSubmission.id == submission_id) + expired = db.scalars(statement).unique().all() + for record in expired: + record.status = "已超时" + record.updated_at = now + if expired: + db.flush() + return len(expired) + + def _ask_assignment_expired(assignment: AskAssignment | None, now: datetime | None = None) -> bool: if not assignment or assignment.status not in {"待选择", "进行中"}: return False @@ -171,6 +190,101 @@ def _product_token_error(value: object, qr_image: object = None) -> str | None: return None +_COMMERCE_PLATFORMS = ( + (("taobao.com", "tmall.com", "tb.cn", "e.tb.cn"), "淘宝"), + (("jd.com", "3.cn"), "京东"), + (("pinduoduo.com", "yangkeduo.com"), "拼多多"), + (("douyin.com", "iesdouyin.com"), "抖音电商"), + (("vip.com",), "唯品会"), +) + + +def _commerce_platform(hostname: str, text_value: str = "") -> str: + host = str(hostname or "").lower().strip(".") + for domains, label in _COMMERCE_PLATFORMS: + if any(host == domain or host.endswith("." + domain) for domain in domains): + return label + compact = str(text_value or "") + for marker, label in (("【淘宝】", "淘宝"), ("京东", "京东"), ("拼多多", "拼多多"), ("抖音", "抖音电商")): + if marker in compact: + return label + return "" + + +def _extract_product_metadata(source: object, fetched_url: str = "", fetched_text: str = "") -> dict: + raw = str(source or "").strip() + combined = "\n".join(value for value in (raw, fetched_url, fetched_text) if value) + urls = re.findall(r"https?://[^\s<>\"',。]+", combined, flags=re.IGNORECASE) + parsed_urls = [urllib.parse.urlparse(url.replace("&", "&")) for url in urls] + platform = next( + (_commerce_platform(parsed.hostname or "", combined) for parsed in parsed_urls if _commerce_platform(parsed.hostname or "", combined)), + "", + ) or _commerce_platform("", combined) + product_id = "" + for pattern in ( + r"(?:itemIds?|item_id|itemId)[=:\"'\s]+(\d{6,})", + r"[?&]id=(\d{6,})", + r"/item/(\d{6,})", + r"/product/(\d{6,})", + ): + match = re.search(pattern, combined, flags=re.IGNORECASE) + if match: + product_id = match.group(1) + break + shop_name = "" + for pattern in ( + r'["\'](?:shopName|shop_name|sellerNick|seller_name)["\']\s*[:=]\s*["\']([^"\']{1,255})', + r"(?:shopName|shop_name|sellerNick|seller_name)=([^&\"'\s]{1,255})", + r"店铺(?:名称)?[::]\s*([^\n,,]{1,255})", + ): + match = re.search(pattern, combined, flags=re.IGNORECASE) + if match: + shop_name = urllib.parse.unquote(match.group(1)).strip() + break + title_match = re.search(r"「([^」]{1,255})」", raw) + return { + "ecommerce_platform": platform, + "product_id": product_id, + "shop_name": shop_name, + "product_name": title_match.group(1).strip() if title_match else "", + "resolved_url": fetched_url or (urls[0] if urls else ""), + } + + +def _resolve_product_metadata(source: object) -> dict: + raw = str(source or "").strip() + if not raw: + raise HTTPException(status_code=400, detail="请粘贴淘口令、商品链接或先上传商品二维码") + initial = _extract_product_metadata(raw) + url_match = re.search(r"https?://[^\s<>\"',。]+", raw, flags=re.IGNORECASE) + if not url_match: + return initial + source_url = url_match.group(0) + parsed = urllib.parse.urlparse(source_url) + if parsed.scheme not in {"http", "https"} or not _commerce_platform(parsed.hostname or "", raw): + return initial + class CommerceRedirectHandler(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + target = urllib.parse.urlparse(newurl) + if target.scheme not in {"http", "https"} or not _commerce_platform(target.hostname or "", raw): + raise urllib.error.URLError("商品短链跳转到了不受支持的地址") + return super().redirect_request(req, fp, code, msg, headers, newurl) + + request = urllib.request.Request( + source_url, + headers={"User-Agent": "Mozilla/5.0 (compatible; WendajiaProductResolver/1.0)"}, + ) + try: + with urllib.request.build_opener(CommerceRedirectHandler()).open(request, timeout=10) as response: + final_url = response.geturl() + body = response.read(1024 * 1024).decode( + response.headers.get_content_charset() or "utf-8", errors="ignore" + ) + except Exception: + return initial + return _extract_product_metadata(raw, final_url, body) + + def _decode_product_qr_image(value: object, filename: object = "") -> tuple[bytes | None, str | None, str | None]: """Decode the small data URL sent by the create-task form.""" if value is None: @@ -330,6 +444,10 @@ def startup(): if "amount_fen" not in {column["name"] for column in inspect(engine).get_columns("refund_recipients")}: with engine.begin() as connection: connection.execute(text("ALTER TABLE refund_recipients ADD COLUMN amount_fen INTEGER NULL")) + adjustment_columns = {column["name"] for column in inspect(engine).get_columns("refund_account_adjustments")} + if "adjustment_type" not in adjustment_columns: + with engine.begin() as connection: + connection.execute(text("ALTER TABLE refund_account_adjustments ADD COLUMN adjustment_type VARCHAR(32) NOT NULL DEFAULT '资金调整'")) 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)} @@ -348,6 +466,14 @@ def startup(): if "shop_name" not in columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN shop_name VARCHAR(255) NOT NULL DEFAULT ''")) + for column_name, column_type in { + "ecommerce_platform": "VARCHAR(64) NOT NULL DEFAULT ''", + "product_id": "VARCHAR(128) NOT NULL DEFAULT ''", + }.items(): + if column_name not in columns: + with engine.begin() as connection: + connection.execute(text(f"ALTER TABLE ask_tasks ADD COLUMN {column_name} {column_type}")) + columns.add(column_name) if "owner_id" not in columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE ask_tasks ADD COLUMN owner_id INTEGER NULL")) @@ -391,6 +517,7 @@ def startup(): "is_dept_manager": "BOOLEAN NOT NULL DEFAULT 0", "answer_public_token": "VARCHAR(96) NULL", "last_answer_shop_name": "VARCHAR(255) NULL", + "last_answer_ecommerce_platform": "VARCHAR(64) NULL", } for column_name, column_type in admin_user_migrations.items(): if column_name not in admin_user_columns: @@ -403,6 +530,22 @@ def startup(): if "shop_name" not in answer_submission_columns: with engine.begin() as connection: connection.execute(text("ALTER TABLE answer_submissions ADD COLUMN shop_name VARCHAR(255) NOT NULL DEFAULT ''")) + for column_name, column_type in { + "ecommerce_platform": "VARCHAR(64) NOT NULL DEFAULT ''", + "product_id": "VARCHAR(128) NOT NULL DEFAULT ''", + }.items(): + if column_name not in answer_submission_columns: + with engine.begin() as connection: + connection.execute(text(f"ALTER TABLE answer_submissions ADD COLUMN {column_name} {column_type}")) + refund_record_columns = {column["name"] for column in inspect(engine).get_columns("refund_records")} + for column_name, column_type in { + "ecommerce_platform": "VARCHAR(64) NOT NULL DEFAULT ''", + "product_id": "VARCHAR(128) NOT NULL DEFAULT ''", + "payer_account_id": "INTEGER NULL", + }.items(): + if column_name not in refund_record_columns: + with engine.begin() as connection: + connection.execute(text(f"ALTER TABLE refund_records ADD COLUMN {column_name} {column_type}")) answer_user_columns = {column["name"] for column in inspect(engine).get_columns("answer_users")} if "taobao_id" not in answer_user_columns: with engine.begin() as connection: @@ -493,7 +636,10 @@ def _migrate_alipay_transfer_attempts(bind): "out_biz_no": "VARCHAR(64) NULL", "order_no": "VARCHAR(64) NOT NULL DEFAULT ''", "shop_name": "VARCHAR(255) NOT NULL DEFAULT ''", + "ecommerce_platform": "VARCHAR(64) NOT NULL DEFAULT ''", + "product_id": "VARCHAR(128) NOT NULL DEFAULT ''", "owner_id": "BIGINT NULL", + "payer_account_id": "BIGINT NULL", "payer_account": "VARCHAR(128) NOT NULL DEFAULT ''", "operator_name": "VARCHAR(64) NOT NULL DEFAULT ''", "created_by_id": "BIGINT NOT NULL DEFAULT 0", @@ -571,6 +717,11 @@ def require_user_manager(user: AdminUser = Depends(current_user)) -> AdminUser: return user +@app.post("/api/admin/product-metadata/resolve") +def resolve_product_metadata(payload: dict, _: AdminUser = Depends(current_user)): + return {"success": True, "data": _resolve_product_metadata(payload.get("source"))} + + @app.get("/api/health") def health(): return {"success": True, "message": "ok"} @@ -1450,6 +1601,8 @@ def _submission_data( "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 "", + "ecommerce_platform": record.ecommerce_platform or "", + "product_id": record.product_id or "", "status": record.status, "recognition_failed": record.status == "识别失败" or _has_legacy_fake_ocr_questions(record), "created_at": record.created_at.isoformat(), @@ -1487,6 +1640,8 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True, imag "taobao_id": record.user.taobao_id, "phone": _public_user_account(record.user), "shop_name": record.shop_name or "", + "ecommerce_platform": record.ecommerce_platform or "", + "product_id": record.product_id or "", "status": record.status, "created_at": record.created_at.isoformat(), "updated_at": record.updated_at.isoformat(), @@ -1505,6 +1660,7 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True, imag "已通过": "待返款", "已完成": "已完成", "未通过": "进行中", + "已超时": "已超时", "已撤销": "已撤销", } # "待审核" and "待返款" mean the user has submitted evidence, not that @@ -1519,7 +1675,9 @@ def _task_board_data(record: AnswerSubmission, include_images: bool = True, imag "creator_name": record.owner.display_name if record.owner else "运营后台", "qa_count": len(selected), "claimed_count": 1, "submitted_count": 1 if record.status in {"待审核", "已通过", "已完成"} else 0, "pending_review_count": 1 if record.status == "待审核" else 0, "completed_count": completed_count, "total_count": len(selected), - "can_cancel": record.status in {"待用户完成", "待审核", "未通过"}, + "can_cancel": record.status in {"待用户完成", "待审核", "未通过", "已超时"}, + "can_reactivate": record.status == "已超时", + "reactivate_endpoint": f"/api/admin/task-board/{record.id}/reactivate" if record.status == "已超时" else None, "can_delete": True, "delete_endpoint": f"/api/admin/task-board/{record.id}", "refund_endpoint": f"/api/admin/task-board/{record.id}/confirm-refund" if record.status == "已通过" else None, @@ -1582,6 +1740,8 @@ def _ask_assignment_child_data(assignment: AskAssignment) -> dict: "task_type_label": "提问任务", "product_name": task.product_name, "shop_name": task.shop_name, + "ecommerce_platform": task.ecommerce_platform, + "product_id": task.product_id, "product_link": task.product_link, "status": assignment.status, "status_label": status_label, @@ -1673,6 +1833,8 @@ def _ask_batch_child_data(batch: AskAssignmentBatch) -> dict: "task_type_label": "提问任务", "product_name": task.product_name, "shop_name": task.shop_name, + "ecommerce_platform": task.ecommerce_platform, + "product_id": task.product_id, "product_link": task.product_link, "status": batch.status, "status_label": status_label, @@ -1779,6 +1941,8 @@ def _ask_task_data(task: AskTask, include_questions: bool = True) -> dict: "task_id": task.task_no, "task_type": "ask", "task_type_label": "提问任务", "public_token": task.public_token or task.task_no, "shop_name": task.shop_name, + "ecommerce_platform": task.ecommerce_platform, + "product_id": task.product_id, "product_name": task.product_name, "product_link": task.product_link, # Keep the persisted task state alongside the display label. The # create-task page uses this value to switch to the publish-success @@ -1966,6 +2130,7 @@ def admin_answer_link(user: AdminUser = Depends(current_user), db: Session = Dep "token": stored_user.answer_public_token, "path": f"/answer/{stored_user.answer_public_token}", "last_shop_name": stored_user.last_answer_shop_name or "", + "last_ecommerce_platform": stored_user.last_answer_ecommerce_platform or "", }} @@ -2439,6 +2604,8 @@ def ask_assignment_review_decision(assignment_id: int, payload: dict, user: Admi def answer_my_submissions(token: str, owner_token: str, include_images: bool = False, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) owner = _answer_link_owner(owner_token, db) + if _expire_answer_tasks(db): + db.commit() statement = select(AnswerSubmission).where( AnswerSubmission.user_id == user.id, AnswerSubmission.owner_id == owner.id, @@ -2451,6 +2618,8 @@ def answer_my_submissions(token: str, owner_token: str, include_images: bool = F def answer_submission_detail(submission_id: int, token: str, owner_token: str, db: Session = Depends(get_db)): user = _answer_user_from_token(token, db) owner = _answer_link_owner(owner_token, db) + if _expire_answer_tasks(db, submission_id): + db.commit() 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="任务不存在") @@ -2573,6 +2742,8 @@ async def answer_create_submission( @app.get("/api/admin/answer-task-drafts") def admin_answer_task_drafts(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + if _expire_answer_tasks(db): + db.commit() records = db.scalars(select(AnswerSubmission).where(AnswerSubmission.status.in_(["待筛选", "识别失败", "待运营派发", "已派发", "待用户完成"])).where(_answer_owner_clause(user)).order_by(AnswerSubmission.created_at.desc())).unique().all() return {"success": True, "data": [_submission_data(record, include_images=False) for record in records]} @@ -2674,6 +2845,12 @@ def reject_answer_task_draft(submission_id: int, user: AdminUser = Depends(curre @app.post("/api/admin/ask-tasks/generate") def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): shop_name = str(payload.get("shop_name", "")).strip() + ecommerce_platform = str(payload.get("ecommerce_platform", "")).strip() + if len(ecommerce_platform) > 64: + raise HTTPException(status_code=400, detail="电商平台不能超过64个字符") + product_id = str(payload.get("product_id", "")).strip() + if len(product_id) > 128: + raise HTTPException(status_code=400, detail="商品ID不能超过128个字符") product_link = str(payload.get("product_link", "")).strip() product_qr_data, product_qr_content_type, product_qr_name = _decode_product_qr_image( payload.get("product_qr_image"), payload.get("product_qr_image_name") @@ -2735,7 +2912,16 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db if task.status != "草稿": raise HTTPException(status_code=409, detail="已发布任务不能重新生成") _dedupe_draft_ask_questions(task, db) + # Regeneration is a full replacement. The resulting draft must contain + # exactly the requested number of questions, regardless of how the old + # draft questions were added. + for existing_question in list(task.questions): + db.delete(existing_question) + db.flush() + db.expire(task, ["questions"]) task.shop_name = shop_name + task.ecommerce_platform = ecommerce_platform + task.product_id = product_id task.product_link = product_link task.product_qr_image_data = product_qr_data task.product_qr_image_content_type = product_qr_content_type @@ -2750,7 +2936,7 @@ def generate_ask_task(payload: dict, user: AdminUser = Depends(current_user), db task.purchase_channel = str(payload.get("purchase_channel", "")) task.claim_questions_per_user = claim_limit else: - task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", owner_id=user.id, shop_name=shop_name, product_link=product_link, product_qr_image_data=product_qr_data, product_qr_image_content_type=product_qr_content_type, product_qr_image_name=product_qr_name, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), claim_questions_per_user=claim_limit, status="草稿") + task = AskTask(task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", owner_id=user.id, shop_name=shop_name, ecommerce_platform=ecommerce_platform, product_id=product_id, product_link=product_link, product_qr_image_data=product_qr_data, product_qr_image_content_type=product_qr_content_type, product_qr_image_name=product_qr_name, product_name=product_name or "待补充商品", category=str(payload.get("category", "")), benefits=str(payload.get("benefits", "")), selling_points=str(payload.get("selling_points", "")), language_style=str(payload.get("language_style", "")), target_group=str(payload.get("target_group", "")), usage_scene=str(payload.get("usage_scene", "")), purchase_channel=str(payload.get("purchase_channel", "")), claim_questions_per_user=claim_limit, status="草稿") db.add(task); db.flush() next_sequence = max((question.sequence_no for question in task.questions), default=0) + 1 for index, question in enumerate(generated): @@ -2861,12 +3047,22 @@ def delete_ask_question(task_id: int, question_id: int, user: AdminUser = Depend return {"success": True, "data": {"id": question_id}} -def _ask_publish_fields(payload: dict) -> tuple[str, str, bytes | None, str | None, str | None, int]: +def _ask_publish_fields(payload: dict) -> tuple[str, str, str, str, bytes | None, str | None, str | None, int]: shop_name = str(payload.get("shop_name", "")).strip() if not shop_name: raise HTTPException(status_code=400, detail="请填写店铺名称") if len(shop_name) > 255: raise HTTPException(status_code=400, detail="店铺名称不能超过255个字符") + ecommerce_platform = str(payload.get("ecommerce_platform", "")).strip() + if not ecommerce_platform: + raise HTTPException(status_code=400, detail="请填写电商平台") + if len(ecommerce_platform) > 64: + raise HTTPException(status_code=400, detail="电商平台不能超过64个字符") + product_id = str(payload.get("product_id", "")).strip() + if not product_id: + raise HTTPException(status_code=400, detail="请填写商品ID") + if len(product_id) > 128: + raise HTTPException(status_code=400, detail="商品ID不能超过128个字符") product_link = str(payload.get("product_link", "")).strip() qr_data, qr_content_type, qr_name = _decode_product_qr_image( payload.get("product_qr_image"), payload.get("product_qr_image_name") @@ -2883,12 +3079,14 @@ def _ask_publish_fields(payload: dict) -> tuple[str, str, bytes | None, str | No raise HTTPException(status_code=400, detail="每个用户可领取的问题数量必须是 2-20 的整数") if claim_limit < 2 or claim_limit > 20: raise HTTPException(status_code=400, detail="每个用户可领取的问题数量必须在 2-20 条之间") - return shop_name, product_link, qr_data, qr_content_type, qr_name, claim_limit + return shop_name, ecommerce_platform, product_id, product_link, qr_data, qr_content_type, qr_name, claim_limit def _apply_ask_publish_fields(task: AskTask, payload: dict) -> None: - shop_name, product_link, qr_data, qr_content_type, qr_name, claim_limit = _ask_publish_fields(payload) + shop_name, ecommerce_platform, product_id, product_link, qr_data, qr_content_type, qr_name, claim_limit = _ask_publish_fields(payload) task.shop_name = shop_name + task.ecommerce_platform = ecommerce_platform + task.product_id = product_id task.product_link = product_link task.product_qr_image_data = qr_data task.product_qr_image_content_type = qr_content_type @@ -2935,6 +3133,8 @@ def create_and_publish_ask_task(payload: dict, user: AdminUser = Depends(current task_no=f"TASK{datetime.utcnow().strftime('%Y%m%d%H%M%S')}{uuid.uuid4().hex[:4].upper()}", owner_id=user.id, shop_name="", + ecommerce_platform="", + product_id="", product_link="", product_name=str(payload.get("product_name", "")).strip() or "待补充商品", category=str(payload.get("category", "")), @@ -3004,6 +3204,10 @@ def publish_ask_task(task_id: int, payload: dict = Body(default={}), user: Admin raise HTTPException(status_code=400, detail=f"无法发布任务:{product_token_error}") if not task.shop_name: raise HTTPException(status_code=400, detail="请填写店铺名称") + if not task.ecommerce_platform: + raise HTTPException(status_code=400, detail="请填写电商平台") + if not task.product_id: + raise HTTPException(status_code=400, detail="请填写商品ID") if not any(q.selected for q in task.questions): raise HTTPException(status_code=400, detail="请至少选择一个提问") if not task.public_token: task.public_token = _new_ask_public_token(db) @@ -3076,13 +3280,28 @@ def publish_answer_task(submission_id: int, payload: dict = Body(default={}), us raise HTTPException(status_code=400, detail="派发前必须填写店铺名称") if len(shop_name) > 255: raise HTTPException(status_code=400, detail="店铺名称不能超过255个字符") + ecommerce_platform = str(payload.get("ecommerce_platform", "")).strip() + if not ecommerce_platform: + raise HTTPException(status_code=400, detail="派发前必须填写电商平台") + if len(ecommerce_platform) > 64: + raise HTTPException(status_code=400, detail="电商平台不能超过64个字符") + product_id = str(payload.get("product_id", "")).strip() + if not product_id: + raise HTTPException(status_code=400, detail="派发前必须填写商品ID") + if len(product_id) > 128: + raise HTTPException(status_code=400, detail="商品ID不能超过128个字符") record.shop_name = shop_name + record.ecommerce_platform = ecommerce_platform + record.product_id = product_id if record.owner_id is None: record.owner_id = user.id stored_user = db.get(AdminUser, user.id) if stored_user: stored_user.last_answer_shop_name = shop_name - record.status = "待用户完成"; db.commit() + stored_user.last_answer_ecommerce_platform = ecommerce_platform + record.status = "待用户完成" + record.updated_at = datetime.utcnow() + db.commit() return {"success": True, "data": _submission_data(record, include_images=False)} @@ -3100,9 +3319,11 @@ def admin_task_board( ): if _release_expired_ask_assignments(db): db.commit() + if _expire_answer_tasks(db): + db.commit() page = max(page, 1) page_size = min(max(page_size, 10), 100) - task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"] + task_statuses = ["待用户完成", "待审核", "已通过", "已完成", "未通过", "已超时", "已撤销"] answer_candidate = ( select( literal("answer").label("record_kind"), @@ -3435,10 +3656,13 @@ def _snapshot_refund(db, record, kind, user, payer_account="", alipay_trade_no=" snapshot = RefundRecord( target_kind=kind, target_id=record.id, owner_id=task.owner_id, shop_name=task.shop_name or "", + ecommerce_platform=getattr(task, "ecommerce_platform", "") or "", + product_id=getattr(task, "product_id", "") or "", order_no=record.submission_no if kind == "answer_submission" else task.task_no, payee_name=identity["payee_name"], payee_account=identity["alipay_account"], amount_fen=identity["refund_amount_fen"], operator_name=user.display_name, - payer_account=payer_account.strip(), alipay_trade_no=alipay_trade_no.strip(), + payer_account_id=getattr(payer_account, "id", None), + payer_account=getattr(payer_account, "name", payer_account).strip(), alipay_trade_no=alipay_trade_no.strip(), ) db.add(snapshot) return snapshot @@ -3469,10 +3693,13 @@ def _complete_alipay_refund_order(db: Session, order: AlipayRefundOrder, trade_n target_id=order.target_id, owner_id=order.owner_id, shop_name=order.shop_name or "", + ecommerce_platform=order.ecommerce_platform or "", + product_id=order.product_id or "", order_no=order.order_no or "", payee_name=order.payee_name or "", payee_account=order.account or "", amount_fen=order.amount_fen, + payer_account_id=order.payer_account_id, operator_name=order.operator_name or "系统自动对账", payer_account=order.payer_account or "", alipay_trade_no=trade_no, @@ -3522,7 +3749,7 @@ def _finalize_refund_confirmation( ) return - _snapshot_refund(db, record, kind, user, payer.name, trade_no) + _snapshot_refund(db, record, kind, user, payer, trade_no) if kind == "ask_batch": record.status = "paid" record.updated_at = datetime.utcnow() @@ -3646,7 +3873,7 @@ def _refund_record_query(user, q, start_date, end_date): def _refund_record_data(record): return {key: getattr(record, key) for key in ( - "id", "shop_name", "order_no", "payee_name", "payee_account", "amount_fen", + "id", "shop_name", "ecommerce_platform", "product_id", "order_no", "payee_name", "payee_account", "amount_fen", "payer_account", "operator_name", "alipay_trade_no", "transferred_at", )} @@ -3667,15 +3894,15 @@ def export_refund_records(q: str = "", start_date: str = "", end_date: str = "", query = _refund_record_query(user, q, start_date, end_date) output = io.StringIO() writer = csv.writer(output) - writer.writerow(["店铺名称", "转账时间", "订单号", "收款人", "收款账号", "返款金额(元)", "付款账户", "操作人", "支付宝流水"]) + writer.writerow(["店铺名称", "电商平台", "商品ID", "转账时间", "订单号", "收款人", "收款账号", "返款金额(元)", "付款账户", "操作人", "支付宝流水"]) total = 0 def safe(value): value = str(value or "") return "'" + value if value.lstrip().startswith(("=", "+", "-", "@")) or value.startswith(("\t", "\r", "\n")) else value for record in db.scalars(query.order_by(RefundRecord.transferred_at.desc(), RefundRecord.id.desc()).execution_options(yield_per=500)): total += record.amount_fen - writer.writerow([safe(record.shop_name), (record.transferred_at + timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S"), safe(record.order_no), safe(record.payee_name), safe(record.payee_account), f"{record.amount_fen / 100:.2f}", safe(record.payer_account), safe(record.operator_name), safe(record.alipay_trade_no)]) - writer.writerow(["合计", "", "", "", "", f"{total / 100:.2f}", "", "", ""]) + writer.writerow([safe(record.shop_name), safe(record.ecommerce_platform), safe(record.product_id), (record.transferred_at + timedelta(hours=8)).strftime("%Y-%m-%d %H:%M:%S"), safe(record.order_no), safe(record.payee_name), safe(record.payee_account), f"{record.amount_fen / 100:.2f}", safe(record.payer_account), safe(record.operator_name), safe(record.alipay_trade_no)]) + writer.writerow(["合计", "", "", "", "", "", "", f"{total / 100:.2f}", "", "", ""]) return Response(content="\ufeff" + output.getvalue(), media_type="text/csv; charset=utf-8", headers={"Content-Disposition": 'attachment; filename="refund-records.csv"'}) @@ -3774,6 +4001,9 @@ def _perform_alipay_refund(db: Session, record, kind: str, user: AdminUser, paye order.error_message = "支付宝转账仍在处理中,请稍后重新查询" db.commit() raise HTTPException(status_code=409, detail=order.error_message) + available_fen = _payment_account_usage(db, payer)["available_fen"] + if amount_fen > available_fen: + raise HTTPException(status_code=400, detail="付款账户可用余额不足,请先追加资金") out_biz_no = f"RF{datetime.utcnow():%Y%m%d%H%M%S}{uuid.uuid4().hex[:12].upper()}" if not order: task = record.assignment.task if kind == "ask_batch" else record.task if kind == "ask_assignment" else record @@ -3783,6 +4013,8 @@ def _perform_alipay_refund(db: Session, record, kind: str, user: AdminUser, paye out_biz_no=out_biz_no, order_no=record.submission_no if kind == "answer_submission" else task.task_no, shop_name=task.shop_name or "", + ecommerce_platform=getattr(task, "ecommerce_platform", "") or "", + product_id=getattr(task, "product_id", "") or "", owner_id=task.owner_id, operator_name=user.display_name, ) @@ -3791,6 +4023,7 @@ def _perform_alipay_refund(db: Session, record, kind: str, user: AdminUser, paye order.amount_fen = amount_fen order.account = identity["alipay_account"] order.payee_name = identity["payee_name"] + order.payer_account_id = payer.id order.payer_account = payer.name order.created_by_id = user.id order.operator_name = user.display_name @@ -3909,6 +4142,8 @@ async def confirm_ask_assignment_refund(assignment_id: int, refund_amount: str | @app.get("/api/admin/task-board/{submission_id}") def admin_task_board_detail(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + if _expire_answer_tasks(db, submission_id): + db.commit() record = db.scalar( select(AnswerSubmission) .where(AnswerSubmission.id == submission_id) @@ -3920,7 +4155,7 @@ def admin_task_board_detail(submission_id: int, user: AdminUser = Depends(curren AnswerScreenshot.content_type, )) ) - if not record or record.status not in {"待用户完成", "待审核", "已通过", "已完成", "未通过", "已撤销"} or not _can_access_answer_submission(record, user): + if not record or record.status not in {"待用户完成", "待审核", "已通过", "已完成", "未通过", "已超时", "已撤销"} or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="任务不存在") return {"success": True, "data": _task_board_data(record, image_endpoints=True)} @@ -3954,6 +4189,8 @@ async def confirm_answer_refund(submission_id: int, refund_amount: str | None = @app.post("/api/admin/task-board/{submission_id}/cancel") def admin_task_board_cancel(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + if _expire_answer_tasks(db, submission_id): + db.commit() record = db.get(AnswerSubmission, submission_id) if not record or not _can_access_answer_submission(record, user): raise HTTPException(status_code=404, detail="任务不存在") @@ -3961,19 +4198,38 @@ def admin_task_board_cancel(submission_id: int, user: AdminUser = Depends(curren raise HTTPException(status_code=409, detail="任务已经撤销") if record.status == "已通过": raise HTTPException(status_code=409, detail="已完成任务不能撤销") - if record.status not in {"待用户完成", "待审核", "未通过"}: + if record.status not in {"待用户完成", "待审核", "未通过", "已超时"}: raise HTTPException(status_code=400, detail="当前状态不能撤销") record.status = "已撤销" db.commit(); db.refresh(record) return {"success": True, "data": _task_board_data(record)} +@app.post("/api/admin/task-board/{submission_id}/reactivate") +def admin_task_board_reactivate(submission_id: int, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): + if _expire_answer_tasks(db, submission_id): + db.commit() + record = db.get(AnswerSubmission, submission_id) + if not record or not _can_access_answer_submission(record, user): + raise HTTPException(status_code=404, detail="任务不存在") + if record.status != "已超时": + raise HTTPException(status_code=409, detail="只有已超时任务可以重新激活") + record.status = "待用户完成" + record.updated_at = datetime.utcnow() + db.commit(); db.refresh(record) + return {"success": True, "data": _task_board_data(record, include_images=True, image_endpoints=True)} + + @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=[]), 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) + user = _answer_user_from_token(token, db); owner = _answer_link_owner(owner_token, db) + if _expire_answer_tasks(db, submission_id): + db.commit() + record = db.get(AnswerSubmission, submission_id) if not record or record.user_id != user.id or record.owner_id != owner.id: raise HTTPException(status_code=404, detail="任务不存在") record_status = record.status + if record_status == "已超时": raise HTTPException(status_code=409, detail="任务已超时,请联系运营重新激活") if record_status not in ("待用户完成", "未通过"): raise HTTPException(status_code=400, detail="当前任务不能提交完成截图") if not files or len(files) > 10: raise HTTPException(status_code=400, detail="最多上传10张截图") if len(payment_files) > 1: raise HTTPException(status_code=400, detail="最多上传一张收款二维码") @@ -5077,12 +5333,33 @@ def login_logs(_: AdminUser = Depends(require_super_admin), db: Session = Depend return {"success": True, "data": [{"username": x.username, "success": x.success, "ip_address": x.ip_address, "created_at": x.created_at.isoformat()} for x in logs]} -def _payment_account_data(account: RefundPaymentAccount) -> dict: +def _payment_account_usage(db: Session, account: RefundPaymentAccount) -> dict: + used_fen = db.scalar(select(func.coalesce(func.sum(RefundRecord.amount_fen), 0)).where( + or_(RefundRecord.payer_account_id == account.id, + (RefundRecord.payer_account_id.is_(None)) & (RefundRecord.payer_account == account.name)), + )) or 0 + processing_fen = db.scalar(select(func.coalesce(func.sum(AlipayRefundOrder.amount_fen), 0)).where( + or_(AlipayRefundOrder.payer_account_id == account.id, + (AlipayRefundOrder.payer_account_id.is_(None)) & (AlipayRefundOrder.payer_account == account.name)), + AlipayRefundOrder.status == "pending", + )) or 0 + return { + "used_fen": int(used_fen), + "processing_fen": int(processing_fen), + "available_fen": max(int(account.balance_fen) - int(used_fen) - int(processing_fen), 0), + } + + +def _payment_account_data(account: RefundPaymentAccount, db: Session | None = None) -> dict: + usage = _payment_account_usage(db, account) if db is not None else { + "used_fen": 0, "processing_fen": 0, "available_fen": account.balance_fen, + } return { "id": account.id, "name": account.name, "type": "alipay", "alipay_account": account.alipay_account, "balance_fen": account.balance_fen, "single_limit_fen": account.single_limit_fen, "is_active": account.is_active, "updated_at": account.updated_at, + **usage, } @@ -5092,7 +5369,7 @@ def available_refund_payment_accounts(user: AdminUser = Depends(current_user), d if user.role_code not in {"super_admin", "dept_manager"}: statement = statement.where(RefundPaymentAccount.id == user.refund_payment_account_id) accounts = db.scalars(statement.order_by(RefundPaymentAccount.id)).all() - return {"success": True, "data": [_payment_account_data(account) for account in accounts]} + return {"success": True, "data": [_payment_account_data(account, db) for account in accounts]} @app.get("/api/admin/refund-payment-accounts") @@ -5107,39 +5384,143 @@ def refund_payment_accounts(q: str = "", page: int = 1, page_size: int = 10, RefundPaymentAccount.alipay_account.contains(term, autoescape=True))) total = db.scalar(select(func.count()).select_from(statement.subquery())) or 0 records = db.scalars(statement.order_by(RefundPaymentAccount.id.desc()).offset((page - 1) * page_size).limit(page_size)).all() - return {"success": True, "data": {"items": [_payment_account_data(item) for item in records], + items = [_payment_account_data(item, db) for item in records] + all_accounts = db.scalars(select(RefundPaymentAccount)).all() + all_items = [_payment_account_data(item, db) for item in all_accounts] + summary = { + "balance_fen": sum(item["balance_fen"] for item in all_items), + "used_fen": sum(item["used_fen"] for item in all_items), + "processing_fen": sum(item["processing_fen"] for item in all_items), + "available_fen": sum(item["available_fen"] for item in all_items), + } + return {"success": True, "data": {"items": items, "summary": summary, "total": total, "page": page, "page_size": page_size}} -def _save_payment_account(db: Session, account: RefundPaymentAccount, payload: RefundPaymentAccountSave): +def _save_payment_account(db: Session, account: RefundPaymentAccount, payload: RefundPaymentAccountSave, user: AdminUser): + is_new = account.id is None account.name = payload.name account.alipay_account = payload.alipay_account - account.balance_fen = int(payload.balance * 100) + if is_new: + account.balance_fen = int(payload.balance * 100) account.single_limit_fen = int(payload.single_limit * 100) account.is_active = payload.is_active db.add(account) try: + db.flush() + if is_new and account.balance_fen: + db.add(RefundAccountAdjustment( + account_id=account.id, + amount_fen=account.balance_fen, + balance_after_fen=account.balance_fen, + adjustment_type="初始资金", + note="新增账户初始资金", + operator_name=user.display_name, + )) db.commit() except IntegrityError: db.rollback() raise HTTPException(status_code=409, detail="该支付宝账户已存在,请编辑现有账户") db.refresh(account) - return {"success": True, "data": _payment_account_data(account)} + return {"success": True, "data": _payment_account_data(account, db)} @app.post("/api/admin/refund-payment-accounts", status_code=201) def create_refund_payment_account(payload: RefundPaymentAccountSave, - _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): - return _save_payment_account(db, RefundPaymentAccount(), payload) + user: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): + return _save_payment_account(db, RefundPaymentAccount(), payload, user) @app.patch("/api/admin/refund-payment-accounts/{account_id}") def update_refund_payment_account(account_id: int, payload: RefundPaymentAccountSave, - _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): - account = db.get(RefundPaymentAccount, account_id) + user: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): + account = db.scalar(select(RefundPaymentAccount).where(RefundPaymentAccount.id == account_id).with_for_update()) if account is None: raise HTTPException(status_code=404, detail="付款账户不存在") - return _save_payment_account(db, account, payload) + return _save_payment_account(db, account, payload, user) + + +@app.post("/api/admin/refund-payment-accounts/{account_id}/funds") +def adjust_refund_payment_account_funds( + account_id: int, payload: RefundFundAdjustment, + user: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db), +): + account = db.scalar(select(RefundPaymentAccount).where(RefundPaymentAccount.id == account_id).with_for_update()) + if account is None: + raise HTTPException(status_code=404, detail="付款账户不存在") + amount_fen = int(payload.amount * 100) + if payload.adjustment_type == "追加资金" and amount_fen <= 0: + raise HTTPException(status_code=400, detail="追加资金金额必须大于 0") + if payload.adjustment_type == "资金调整" and not payload.note: + raise HTTPException(status_code=400, detail="资金调整必须填写原因") + usage = _payment_account_usage(db, account) + new_balance_fen = account.balance_fen + amount_fen + committed_fen = usage["used_fen"] + usage["processing_fen"] + if new_balance_fen < committed_fen: + raise HTTPException(status_code=400, detail="调整后的累计入账不能低于已使用与处理中金额之和") + account.balance_fen = new_balance_fen + db.add(RefundAccountAdjustment( + account_id=account.id, + amount_fen=amount_fen, + balance_after_fen=new_balance_fen, + adjustment_type=payload.adjustment_type, + note=payload.note or "追加资金", + operator_name=user.display_name, + )) + db.commit() + db.refresh(account) + return {"success": True, "data": _payment_account_data(account, db)} + + +@app.get("/api/admin/refund-payment-accounts/{account_id}/ledger") +def refund_payment_account_ledger(account_id: int, page: int = 1, page_size: int = 10, + _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): + account = db.get(RefundPaymentAccount, account_id) + if not account: + raise HTTPException(status_code=404, detail="付款账户不存在") + page = max(page, 1) + page_size = min(max(page_size, 1), 50) + adjustments = select( + literal("adjustment").label("type"), RefundAccountAdjustment.id.label("record_id"), + RefundAccountAdjustment.adjustment_type.label("type_label"), RefundAccountAdjustment.amount_fen.label("amount_fen"), + literal("已完成").label("status"), literal("").label("order_no"), + RefundAccountAdjustment.note.label("note"), RefundAccountAdjustment.operator_name.label("operator_name"), + RefundAccountAdjustment.created_at.label("created_at"), + ).where(RefundAccountAdjustment.account_id == account_id) + refunds = select( + literal("refund").label("type"), RefundRecord.id.label("record_id"), + literal("返款支出").label("type_label"), (-RefundRecord.amount_fen).label("amount_fen"), + literal("已完成").label("status"), RefundRecord.order_no.label("order_no"), + func.coalesce(RefundRecord.shop_name, RefundRecord.payee_name, "").label("note"), + RefundRecord.operator_name.label("operator_name"), RefundRecord.transferred_at.label("created_at"), + ).where(or_(RefundRecord.payer_account_id == account.id, + (RefundRecord.payer_account_id.is_(None)) & (RefundRecord.payer_account == account.name))) + pending = select( + literal("processing").label("type"), AlipayRefundOrder.id.label("record_id"), + literal("处理中冻结").label("type_label"), (-AlipayRefundOrder.amount_fen).label("amount_fen"), + literal("处理中").label("status"), AlipayRefundOrder.order_no.label("order_no"), + func.coalesce(AlipayRefundOrder.shop_name, AlipayRefundOrder.payee_name, "").label("note"), + AlipayRefundOrder.operator_name.label("operator_name"), AlipayRefundOrder.created_at.label("created_at"), + ).where( + or_(AlipayRefundOrder.payer_account_id == account.id, + (AlipayRefundOrder.payer_account_id.is_(None)) & (AlipayRefundOrder.payer_account == account.name)), + AlipayRefundOrder.status == "pending", + ) + ledger_rows = union_all(adjustments, refunds, pending).subquery() + total = db.scalar(select(func.count()).select_from(ledger_rows)) or 0 + rows = db.execute( + select(ledger_rows).order_by(ledger_rows.c.created_at.desc(), ledger_rows.c.record_id.desc()) + .offset((page - 1) * page_size).limit(page_size) + ).mappings().all() + items = [{**dict(row), "id": f"{row['type']}-{row['record_id']}"} for row in rows] + return {"success": True, "data": { + "account": _payment_account_data(account, db), + "items": items, + "total": total, + "page": page, + "page_size": page_size, + "total_pages": max(1, (total + page_size - 1) // page_size), + }} @app.delete("/api/admin/refund-payment-accounts/{account_id}") @@ -5150,6 +5531,7 @@ def delete_refund_payment_account(account_id: int, raise HTTPException(status_code=404, detail="付款账户不存在") db.delete(account) db.execute(update(AdminUser).where(AdminUser.refund_payment_account_id == account_id).values(refund_payment_account_id=None)) + db.query(RefundAccountAdjustment).filter(RefundAccountAdjustment.account_id == account_id).delete() db.commit() return {"success": True} diff --git a/backend/app/models.py b/backend/app/models.py index 8adecb0..4a2020f 100644 --- a/backend/app/models.py +++ b/backend/app/models.py @@ -20,6 +20,19 @@ class RefundPaymentAccount(Base): updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) +class RefundAccountAdjustment(Base): + __tablename__ = "refund_account_adjustments" + + id: Mapped[int] = mapped_column(Integer, primary_key=True) + account_id: Mapped[int] = mapped_column(ForeignKey("refund_payment_accounts.id"), index=True) + amount_fen: Mapped[int] = mapped_column(Integer) + balance_after_fen: Mapped[int] = mapped_column(Integer) + adjustment_type: Mapped[str] = mapped_column(String(32), default="资金调整") + note: Mapped[str] = mapped_column(String(255), default="人工调整资金总额") + operator_name: Mapped[str] = mapped_column(String(64), default="") + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) + + class RefundRecord(Base): """Immutable confirmation snapshot; deliberately independent of task deletion.""" __tablename__ = "refund_records" @@ -29,10 +42,13 @@ class RefundRecord(Base): target_id: Mapped[int] = mapped_column(Integer) owner_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) shop_name: Mapped[str] = mapped_column(String(255), default="") + ecommerce_platform: Mapped[str] = mapped_column(String(64), default="") + product_id: Mapped[str] = mapped_column(String(128), default="") order_no: Mapped[str] = mapped_column(String(64), index=True) payee_name: Mapped[str] = mapped_column(String(64), default="") payee_account: Mapped[str] = mapped_column(String(128), default="") amount_fen: Mapped[int] = mapped_column(Integer) + payer_account_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) payer_account: Mapped[str] = mapped_column(String(128), default="") operator_name: Mapped[str] = mapped_column(String(64)) alipay_trade_no: Mapped[str] = mapped_column(String(128), default="") @@ -51,9 +67,12 @@ class AlipayRefundOrder(Base): amount_fen: Mapped[int] = mapped_column(Integer) order_no: Mapped[str] = mapped_column(String(64), default="") shop_name: Mapped[str] = mapped_column(String(255), default="") + ecommerce_platform: Mapped[str] = mapped_column(String(64), default="") + product_id: Mapped[str] = mapped_column(String(128), default="") owner_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) account: Mapped[str] = mapped_column(String(128)) payee_name: Mapped[str] = mapped_column(String(64)) + payer_account_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True) payer_account: Mapped[str] = mapped_column(String(128), default="") created_by_id: Mapped[int] = mapped_column(Integer, index=True) operator_name: Mapped[str] = mapped_column(String(64), default="") @@ -98,6 +117,7 @@ class AdminUser(Base): # cookie and is safe to embed in a long-lived URL. answer_public_token: Mapped[str | None] = mapped_column(String(96), unique=True, index=True, nullable=True) last_answer_shop_name: Mapped[str | None] = mapped_column(String(255), nullable=True) + last_answer_ecommerce_platform: Mapped[str | None] = mapped_column(String(64), nullable=True) is_active: Mapped[bool] = mapped_column(Boolean, default=True, index=True) last_login_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) @@ -225,6 +245,8 @@ class AnswerSubmission(Base): # handle multiple stores and the same user can submit through different # operator links. shop_name: Mapped[str] = mapped_column(String(255), default="") + ecommerce_platform: Mapped[str] = mapped_column(String(64), default="") + product_id: Mapped[str] = mapped_column(String(128), default="") # 用户上传受邀截图后立即进入运营筛选队列。 status: Mapped[str] = mapped_column(String(32), default="待筛选", index=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True) @@ -303,6 +325,8 @@ class AskTask(Base): product_qr_image_content_type: Mapped[str | None] = mapped_column(String(64), nullable=True) product_qr_image_name: Mapped[str | None] = mapped_column(String(255), nullable=True) shop_name: Mapped[str] = mapped_column(String(255), default="") + ecommerce_platform: Mapped[str] = mapped_column(String(64), default="") + product_id: Mapped[str] = mapped_column(String(128), default="") product_name: Mapped[str] = mapped_column(String(255)) category: Mapped[str] = mapped_column(String(100), default="") benefits: Mapped[str] = mapped_column(Text, default="") diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 4ebc056..d7fa282 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -16,6 +16,24 @@ class RefundPaymentAccountSave(BaseModel): return value.strip() if isinstance(value, str) else value +class RefundFundAdjustment(BaseModel): + adjustment_type: str = Field(pattern=r"^(追加资金|资金调整)$") + amount: Decimal = Field(ge=-9999999.99, le=9999999.99, decimal_places=2, allow_inf_nan=False) + note: str = Field(default="", max_length=255) + + @field_validator("note", mode="before") + @classmethod + def strip_note(cls, value): + return value.strip() if isinstance(value, str) else value + + @field_validator("amount") + @classmethod + def reject_zero_amount(cls, value): + if value == 0: + raise ValueError("调整金额不能为 0") + return value + + class LoginRequest(BaseModel): username: str = Field(min_length=1, max_length=64) password: str = Field(min_length=1, max_length=128) diff --git a/backend/tests/__pycache__/test_auth.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_auth.cpython-313-pytest-9.1.1.pyc index 390e3f7..0c58ecd 100644 Binary files a/backend/tests/__pycache__/test_auth.cpython-313-pytest-9.1.1.pyc and b/backend/tests/__pycache__/test_auth.cpython-313-pytest-9.1.1.pyc differ diff --git a/backend/tests/__pycache__/test_payment_account_authorization.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_payment_account_authorization.cpython-313-pytest-9.1.1.pyc index 0379eb4..735b36e 100644 Binary files a/backend/tests/__pycache__/test_payment_account_authorization.cpython-313-pytest-9.1.1.pyc and b/backend/tests/__pycache__/test_payment_account_authorization.cpython-313-pytest-9.1.1.pyc differ diff --git a/backend/tests/__pycache__/test_refund_payment_accounts.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_refund_payment_accounts.cpython-313-pytest-9.1.1.pyc index 9a2be28..4962d12 100644 Binary files a/backend/tests/__pycache__/test_refund_payment_accounts.cpython-313-pytest-9.1.1.pyc and b/backend/tests/__pycache__/test_refund_payment_accounts.cpython-313-pytest-9.1.1.pyc differ diff --git a/backend/tests/__pycache__/test_refund_records.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_refund_records.cpython-313-pytest-9.1.1.pyc index bfd9557..b03e456 100644 Binary files a/backend/tests/__pycache__/test_refund_records.cpython-313-pytest-9.1.1.pyc and b/backend/tests/__pycache__/test_refund_records.cpython-313-pytest-9.1.1.pyc differ diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py index 7701db6..9635d5a 100644 --- a/backend/tests/test_auth.py +++ b/backend/tests/test_auth.py @@ -439,6 +439,77 @@ def test_task_board_is_available_to_operator(): assert count_data["total"] == count_data["ask"] + count_data["answer"] +def test_answer_task_expires_after_24_hours_and_can_be_reactivated(): + unique = uuid4().hex[:10] + submission_id = None + with TestClient(app) as client: + assert client.post( + "/api/admin/auth/login", + json={"username": "operator", "password": "operator123456"}, + ).status_code == 200 + with main_module.SessionLocal() as db: + owner = db.scalar( + main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator") + ) + public_user = main_module.AnswerUser( + public_id=f"U{unique[:7]}", + phone=f"137{uuid4().int % 100_000_000:08d}", + auth_token=uuid4().hex, + ) + db.add(public_user) + db.flush() + expired_at = datetime.utcnow() - timedelta(hours=24, seconds=1) + submission = main_module.AnswerSubmission( + submission_no=f"ANS{unique.upper()}", + user_id=public_user.id, + owner_id=owner.id, + status="待用户完成", + shop_name="超时测试店铺", + ecommerce_platform="淘宝", + product_id="TIMEOUT-1", + created_at=expired_at, + updated_at=expired_at, + ) + db.add(submission) + db.flush() + db.add(main_module.AnswerRecognizedQuestion( + submission_id=submission.id, + sequence_no=1, + product_title="超时测试商品", + question_text="这个任务会超时吗", + answer_text="会", + selected=True, + )) + db.commit() + submission_id = submission.id + + drafts = client.get("/api/admin/answer-task-drafts") + assert drafts.status_code == 200 + assert submission_id not in {item["id"] for item in drafts.json()["data"]} + + board_item = next(item for item in task_board_items(client) if item.get("id") == submission_id) + assert board_item["status"] == "已超时" + assert board_item["status_label"] == "已超时" + assert board_item["can_reactivate"] is True + assert board_item["can_cancel"] is True + + detail = client.get(f"/api/admin/task-board/{submission_id}") + assert detail.status_code == 200 + assert detail.json()["data"]["status"] == "已超时" + + reactivated = client.post(f"/api/admin/task-board/{submission_id}/reactivate") + assert reactivated.status_code == 200 + assert reactivated.json()["data"]["status"] == "待用户完成" + assert reactivated.json()["data"]["status_label"] == "进行中" + assert reactivated.json()["data"]["can_reactivate"] is False + + cancelled = client.post(f"/api/admin/task-board/{submission_id}/cancel") + assert cancelled.status_code == 200 + assert cancelled.json()["data"]["status"] == "已撤销" + + assert client.delete(f"/api/admin/task-board/{submission_id}").status_code == 200 + + def test_taobao_id_login_is_shared_by_both_user_portals(): taobao_id = f"tb{uuid4().hex[:14]}" with TestClient(app) as client: @@ -555,14 +626,31 @@ def test_answer_task_publish_remembers_operator_shop_name(): db.commit() submission_id = submission.id + missing_platform = client.post( + f"/api/admin/answer-task-drafts/{submission_id}/publish", + json={"shop_name": remembered_shop, "product_id": "ANSWER-MEMORY-1"}, + ) + assert missing_platform.status_code == 400 + assert missing_platform.json()["detail"] == "派发前必须填写电商平台" + + missing_product_id = client.post( + f"/api/admin/answer-task-drafts/{submission_id}/publish", + json={"shop_name": remembered_shop, "ecommerce_platform": "淘宝"}, + ) + assert missing_product_id.status_code == 400 + assert missing_product_id.json()["detail"] == "派发前必须填写商品ID" + published = client.post( f"/api/admin/answer-task-drafts/{submission_id}/publish", - json={"shop_name": remembered_shop}, + json={"shop_name": remembered_shop, "ecommerce_platform": "淘宝", "product_id": "ANSWER-MEMORY-1"}, ) assert published.status_code == 200 + assert published.json()["data"]["ecommerce_platform"] == "淘宝" + assert published.json()["data"]["product_id"] == "ANSWER-MEMORY-1" link_settings = client.get("/api/admin/auth/answer-link") assert link_settings.status_code == 200 assert link_settings.json()["data"]["last_shop_name"] == remembered_shop + assert link_settings.json()["data"]["last_ecommerce_platform"] == "淘宝" with main_module.SessionLocal() as db: owner = db.scalar( main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator") @@ -836,7 +924,7 @@ def test_answer_rejection_reason_syncs_to_public_user(monkeypatch): assert client.patch(f"/api/admin/answer-task-drafts/questions/{question_id}", json={"selected": True}).status_code == 200 assert client.post( f"/api/admin/answer-task-drafts/{submission['id']}/publish", - json={"shop_name": "测试店铺"}, + json={"shop_name": "测试店铺", "ecommerce_platform": "淘宝", "product_id": "ANSWER-COMPLETE-1"}, ).status_code == 200 completed = client.post( f"/api/answer/me/submissions/{submission['id']}/completion", @@ -869,7 +957,7 @@ def test_ask_task_publish_appears_on_task_board(monkeypatch): ) assert generation_without_publish_fields.status_code == 200 assert generation_without_publish_fields.json()["data"]["status"] == "草稿" - generated = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "测试店铺", "product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3}) + generated = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "测试店铺", "ecommerce_platform": "淘宝", "product_id": "911640908772", "product_link": "https://item.example.com/1", "product_name": "测试商品", "question_count": 3}) assert generated.status_code == 200 task = generated.json()["data"] assert task["public_token"] is None @@ -887,9 +975,23 @@ def test_ask_task_publish_appears_on_task_board(monkeypatch): assert created["qa_count"] == 3 assert created["public_token"] == public_token assert created["shop_name"] == "测试店铺" + assert created["ecommerce_platform"] == "淘宝" + assert created["product_id"] == "911640908772" detail = client.get(created["detail_endpoint"]) assert detail.status_code == 200 assert detail.json()["data"]["shop_name"] == "测试店铺" + assert detail.json()["data"]["ecommerce_platform"] == "淘宝" + assert detail.json()["data"]["product_id"] == "911640908772" + + +def test_taobao_product_metadata_extracts_platform_and_item_id(): + source = "【淘宝】https://e.tb.cn/h.example?tk=ABC 「测试商品」" + html = "var url='https://pages-fast.m.taobao.com/detail?itemIds=911640908772&shopName=%E6%B5%8B%E8%AF%95%E5%BA%97%E9%93%BA';" + result = main_module._extract_product_metadata(source, "https://e.tb.cn/h.example", html) + assert result["ecommerce_platform"] == "淘宝" + assert result["product_id"] == "911640908772" + assert result["shop_name"] == "测试店铺" + assert result["product_name"] == "测试商品" def test_imported_questions_can_publish_without_ai_generation(): @@ -899,6 +1001,8 @@ def test_imported_questions_can_publish_without_ai_generation(): "/api/admin/ask-tasks/publish", json={ "shop_name": "直接发布测试店铺", + "ecommerce_platform": "淘宝", + "product_id": "911640908772", "product_link": "TOKEN-DIRECT-PUBLISH", "product_name": "直接发布测试商品", "claim_questions_per_user": 20, @@ -912,6 +1016,8 @@ def test_imported_questions_can_publish_without_ai_generation(): data = published.json()["data"] assert data["status"] == "已发布" assert data["shop_name"] == "直接发布测试店铺" + assert data["ecommerce_platform"] == "淘宝" + assert data["product_id"] == "911640908772" assert data["claim_questions_per_user"] == 20 assert [item["question"] for item in data["selected_questions"]] == ["批量导入问题一?", "自主添加问题二?"] assert [item["source"] for item in data["selected_questions"]] == ["批量导入", "自主添加"] @@ -922,10 +1028,38 @@ def test_imported_questions_can_publish_without_ai_generation(): ) assert missing_fields.status_code == 400 + missing_platform = client.post( + "/api/admin/ask-tasks/publish", + json={ + "shop_name": "必填校验店铺", + "product_id": "REQUIRED-1", + "product_link": "TOKEN-REQUIRED-PLATFORM", + "claim_questions_per_user": 5, + "questions": [{"question": "缺少电商平台?", "source": "自主添加"}], + }, + ) + assert missing_platform.status_code == 400 + assert missing_platform.json()["detail"] == "请填写电商平台" + + missing_product_id = client.post( + "/api/admin/ask-tasks/publish", + json={ + "shop_name": "必填校验店铺", + "ecommerce_platform": "淘宝", + "product_link": "TOKEN-REQUIRED-PRODUCT-ID", + "claim_questions_per_user": 5, + "questions": [{"question": "缺少商品ID?", "source": "自主添加"}], + }, + ) + assert missing_product_id.status_code == 400 + assert missing_product_id.json()["detail"] == "请填写商品ID" + over_limit = client.post( "/api/admin/ask-tasks/publish", json={ "shop_name": "领取上限测试店铺", + "ecommerce_platform": "淘宝", + "product_id": "CLAIM-LIMIT-1", "product_link": "TOKEN-CLAIM-LIMIT", "claim_questions_per_user": 21, "questions": [{"question": "超过领取上限?", "source": "自主添加"}], @@ -988,19 +1122,31 @@ def test_ask_task_accepts_qr_image_without_token_and_excludes_it_from_ai(monkeyp assert public_token -def test_ask_task_regeneration_appends_to_existing_questions(monkeypatch): +def test_ask_task_regeneration_replaces_all_questions_with_requested_count(monkeypatch): monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions) with TestClient(app) as client: assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 - first = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "追加测试店铺", "product_link": "https://item.example.com/append", "product_name": "追加测试商品", "question_count": 2}) + first = client.post("/api/admin/ask-tasks/generate", json={"shop_name": "覆盖测试店铺", "product_link": "https://item.example.com/replace", "product_name": "覆盖测试商品", "question_count": 2}) assert first.status_code == 200 task = first.json()["data"] assert len(task["selected_questions"]) == 2 - second = client.post("/api/admin/ask-tasks/generate", json={"task_id": task["id"], "shop_name": "追加测试店铺", "product_link": "https://item.example.com/append", "product_name": "追加测试商品", "question_count": 2}) + added = client.post( + f"/api/admin/ask-tasks/{task['id']}/questions", + json={"question": "运营手动保留的问题", "source": "自主添加"}, + ) + assert added.status_code == 200 + second = client.post("/api/admin/ask-tasks/generate", json={"task_id": task["id"], "shop_name": "覆盖测试店铺", "product_link": "https://item.example.com/replace", "product_name": "覆盖测试商品", "question_count": 5}) assert second.status_code == 200 questions = second.json()["data"]["selected_questions"] - assert len(questions) == 4 - assert [item["sequence_no"] for item in questions] == [1, 2, 3, 4] + assert len(questions) == 5 + assert all(item["source"] == "AI生成" for item in questions) + assert all(item["question"] != "运营手动保留的问题" for item in questions) + + ai_question = next(item for item in questions if item["source"] == "AI生成") + deleted = client.delete(f"/api/admin/ask-tasks/{task['id']}/questions/{ai_question['id']}") + assert deleted.status_code == 200 + with main_module.SessionLocal() as db: + assert db.get(main_module.AskQuestion, ai_question["id"]) is None def test_public_ask_assignment_expires_after_selection_timeout(monkeypatch): @@ -1308,7 +1454,7 @@ def test_approved_answer_appears_as_pending_refund_with_detail(monkeypatch): assert client.patch(f"/api/admin/answer-task-drafts/questions/{question_id}", json={"selected": True}).status_code == 200 assert client.post( f"/api/admin/answer-task-drafts/{created['id']}/publish", - json={"shop_name": "测试店铺"}, + json={"shop_name": "测试店铺", "ecommerce_platform": "淘宝", "product_id": "ANSWER-REFUND-1"}, ).status_code == 200 completed = client.post( f"/api/answer/me/submissions/{created['id']}/completion", diff --git a/backend/tests/test_refund_payment_accounts.py b/backend/tests/test_refund_payment_accounts.py index bd43a65..0668c47 100644 --- a/backend/tests/test_refund_payment_accounts.py +++ b/backend/tests/test_refund_payment_accounts.py @@ -6,7 +6,7 @@ from sqlalchemy.pool import StaticPool from app import main from app.database import Base, get_db -from app.models import AdminUser, RefundPaymentAccount +from app.models import AdminUser, AlipayRefundOrder, RefundPaymentAccount, RefundRecord ENDPOINT = "/api/admin/refund-payment-accounts" @@ -43,12 +43,94 @@ def test_create_edit_and_persist_cents(setup): assert response.status_code == 200, response.text db.expire_all() saved = db.get(RefundPaymentAccount, record["id"]) - assert saved.balance_fen == 0 + assert saved.balance_fen == 500001 assert not saved.is_active assert saved.updated_at is not None assert client.get(ENDPOINT).json()["data"]["items"][0]["is_active"] is False +def test_append_and_adjust_funds_with_ledger(setup): + db, client, _ = setup + account = client.post(ENDPOINT, json=PAYLOAD).json()["data"] + response = client.post(f"{ENDPOINT}/{account['id']}/funds", json={ + "adjustment_type": "追加资金", "amount": "1000.00", "note": "追加预算", + }) + assert response.status_code == 200, response.text + assert response.json()["data"]["balance_fen"] == 600001 + response = client.post(f"{ENDPOINT}/{account['id']}/funds", json={ + "adjustment_type": "资金调整", "amount": "-10.01", "note": "修正录入", + }) + assert response.status_code == 200, response.text + assert response.json()["data"]["balance_fen"] == 599000 + db.expire_all() + assert db.get(RefundPaymentAccount, account["id"]).balance_fen == 599000 + labels = {item["type_label"] for item in client.get(f"{ENDPOINT}/{account['id']}/ledger").json()["data"]["items"]} + assert {"初始资金", "追加资金", "资金调整"}.issubset(labels) + + +def test_fund_adjustment_validation_and_committed_floor(setup): + db, client, admin = setup + account = client.post(ENDPOINT, json=PAYLOAD).json()["data"] + assert client.post(f"{ENDPOINT}/{account['id']}/funds", json={ + "adjustment_type": "追加资金", "amount": "-1.00", "note": "", + }).status_code == 400 + assert client.post(f"{ENDPOINT}/{account['id']}/funds", json={ + "adjustment_type": "资金调整", "amount": "1.00", "note": "", + }).status_code == 400 + assert client.post(f"{ENDPOINT}/{account['id']}/funds", json={ + "adjustment_type": "资金调整", "amount": "0.00", "note": "无效调整", + }).status_code == 422 + db.add(RefundRecord( + target_kind="answer_submission", target_id=991001, owner_id=admin.id, order_no="ANS-FLOOR-1", + payee_name="测试用户", payee_account="floor@example.com", amount_fen=400000, + payer_account_id=account["id"], payer_account=account["name"], operator_name=admin.display_name, + alipay_trade_no="TRADE-FLOOR-1", + )) + db.commit() + response = client.post(f"{ENDPOINT}/{account['id']}/funds", json={ + "adjustment_type": "资金调整", "amount": "-1000.02", "note": "过度调减", + }) + assert response.status_code == 400 + assert "不能低于" in response.json()["detail"] + + +def test_account_usage_summary_and_ledger(setup): + db, client, admin = setup + account = client.post(ENDPOINT, json=PAYLOAD).json()["data"] + db.add(RefundRecord( + target_kind="answer_submission", target_id=900001, owner_id=admin.id, + shop_name="测试店铺", order_no="ANS-USAGE-1", payee_name="测试用户", + payee_account="user@example.com", amount_fen=12345, + payer_account_id=account["id"], payer_account=account["name"], + operator_name=admin.display_name, alipay_trade_no="TRADE-USAGE-1", + )) + db.add(AlipayRefundOrder( + target_kind="answer_submission", target_id=900002, out_biz_no="RF-USAGE-PENDING", + amount_fen=5000, order_no="ANS-USAGE-2", account="user2@example.com", + payee_name="测试用户2", payer_account_id=account["id"], payer_account=account["name"], + created_by_id=admin.id, operator_name=admin.display_name, status="pending", + )) + db.commit() + + item = client.get(ENDPOINT).json()["data"]["items"][0] + assert item["used_fen"] == 12345 + assert item["processing_fen"] == 5000 + assert item["available_fen"] == 482656 + ledger = client.get(f"{ENDPOINT}/{account['id']}/ledger") + assert ledger.status_code == 200 + ledger_data = ledger.json()["data"] + assert ledger_data["total"] == 3 + assert ledger_data["page"] == 1 + assert ledger_data["page_size"] == 10 + types = {row["type"] for row in ledger_data["items"]} + assert {"adjustment", "refund", "processing"}.issubset(types) + first_page = client.get(f"{ENDPOINT}/{account['id']}/ledger", params={"page": 1, "page_size": 2}).json()["data"] + second_page = client.get(f"{ENDPOINT}/{account['id']}/ledger", params={"page": 2, "page_size": 2}).json()["data"] + assert first_page["total_pages"] == 2 + assert len(first_page["items"]) == 2 + assert len(second_page["items"]) == 1 + + def test_duplicate_and_failed_edit_preserve_accounts(setup): db, client, _ = setup first = client.post(ENDPOINT, json=PAYLOAD).json()["data"] @@ -88,11 +170,13 @@ def test_reject_invalid_account(setup, changes): def test_permissions_and_missing_account(setup): db, client, admin = setup assert client.patch(f"{ENDPOINT}/999", json=PAYLOAD).status_code == 404 + assert client.post(f"{ENDPOINT}/999/funds", json={"adjustment_type": "追加资金", "amount": "1.00", "note": ""}).status_code == 404 admin.role_code = "operator" db.commit() assert client.get(ENDPOINT).status_code == 403 assert client.post(ENDPOINT, json=PAYLOAD).status_code == 403 assert client.patch(f"{ENDPOINT}/999", json=PAYLOAD).status_code == 403 + assert client.post(f"{ENDPOINT}/999/funds", json={"adjustment_type": "追加资金", "amount": "1.00", "note": ""}).status_code == 403 assert client.delete(f"{ENDPOINT}/999").status_code == 403 diff --git a/backend/tests/test_refund_records.py b/backend/tests/test_refund_records.py index d3acc40..dd8b5d3 100644 --- a/backend/tests/test_refund_records.py +++ b/backend/tests/test_refund_records.py @@ -120,8 +120,8 @@ def test_scope_date_range_pagination_and_export(setup): assert len(page["items"]) == 2 and page["total"] == 12 and page["total_amount_fen"] == 600 exported = client.get(f"/api/admin/refund-records/export?{query}") rows = list(csv.reader(io.StringIO(exported.content.decode("utf-8-sig")))) - assert len(rows) == 14 and rows[-1][5] == "6.00" - assert rows[1][0] == "'=unsafe" and rows[1][1] == "2026-10-22 00:00:00" + assert len(rows) == 14 and rows[-1][7] == "6.00" + assert rows[1][0] == "'=unsafe" and rows[1][3] == "2026-10-22 00:00:00" owner.role_code = "dept_manager" db.commit() assert client.get(f"/api/admin/refund-records?{query}").json()["data"]["total"] == 13 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 4d9509b..0697681 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -9,13 +9,13 @@ "version": "0.1.0", "dependencies": { "@vitejs/plugin-react": "latest", + "jsqr": "^1.4.0", "lucide-react": "latest", "react": "latest", "react-dom": "latest", "typescript": "latest", "vite": "latest" - }, - "devDependencies": {} + } }, "node_modules/@oxc-project/types": { "version": "0.146.0", @@ -679,6 +679,12 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, + "node_modules/jsqr": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsqr/-/jsqr-1.4.0.tgz", + "integrity": "sha512-dxLob7q65Xg2DvstYkRpkYtmKm2sPJ9oFhrhmudT1dZvNFFTlroai3AWSpLey/w5vMcLBXRgOJsbXpdN9HzU/A==", + "license": "Apache-2.0" + }, "node_modules/lightningcss": { "version": "1.33.0", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index cc60149..c1b402f 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -3,7 +3,18 @@ "private": true, "version": "0.1.0", "type": "module", - "scripts": {"dev": "vite", "build": "vite build", "preview": "vite preview"}, - "dependencies": {"@vitejs/plugin-react": "latest", "vite": "latest", "typescript": "latest", "react": "latest", "react-dom": "latest", "lucide-react": "latest"}, - "devDependencies": {} + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@vitejs/plugin-react": "latest", + "jsqr": "^1.4.0", + "lucide-react": "latest", + "react": "latest", + "react-dom": "latest", + "typescript": "latest", + "vite": "latest" + } } diff --git a/frontend/src/CreateAnswerTasksPage.jsx b/frontend/src/CreateAnswerTasksPage.jsx index 356ecf5..7d528cf 100644 --- a/frontend/src/CreateAnswerTasksPage.jsx +++ b/frontend/src/CreateAnswerTasksPage.jsx @@ -7,13 +7,15 @@ export default function CreateAnswerTasksPageV2({ api, user }) { const [detailBusy, setDetailBusy] = useState('') const [error, setError] = useState('') const [rejecting, setRejecting] = useState(null) - const [shopNames, setShopNames] = useState({}) + const [publishing, setPublishing] = useState(null) + const [publishForm, setPublishForm] = useState({ ecommerce_platform: '', shop_name: '', product_id: '' }) const [answerLink, setAnswerLink] = useState('') const [linkCopied, setLinkCopied] = useState(false) const editingAnswersRef = useRef(new Set()) - const editedShopNamesRef = useRef(new Set()) const shopMemoryKey = `answer_last_shop_name_${user?.id || 'current'}` + const platformMemoryKey = `answer_last_ecommerce_platform_${user?.id || 'current'}` const lastShopNameRef = useRef(window.localStorage.getItem(shopMemoryKey) || '') + const lastPlatformRef = useRef(window.localStorage.getItem(platformMemoryKey) || '') useEffect(() => { let active = true @@ -24,10 +26,11 @@ export default function CreateAnswerTasksPageV2({ api, user }) { if (remembered) { lastShopNameRef.current = remembered window.localStorage.setItem(shopMemoryKey, remembered) - setShopNames(current => Object.fromEntries(Object.entries(current).map(([recordId, value]) => [ - recordId, - !value && !editedShopNamesRef.current.has(Number(recordId)) ? remembered : value, - ]))) + } + const rememberedPlatform = String(result.data?.last_ecommerce_platform || '').trim() + if (rememberedPlatform) { + lastPlatformRef.current = rememberedPlatform + window.localStorage.setItem(platformMemoryKey, rememberedPlatform) } }).catch(() => {}) return () => { active = false } @@ -66,13 +69,6 @@ export default function CreateAnswerTasksPageV2({ api, user }) { })), } })) - setShopNames(current => { - const next = { ...current } - result.data.forEach(record => { - if (next[record.id] === undefined) next[record.id] = record.shop_name || lastShopNameRef.current || '' - }) - return next - }) setExpanded(current => { const next = { ...current } result.data.forEach((record, index) => { @@ -165,31 +161,63 @@ export default function CreateAnswerTasksPageV2({ api, user }) { } } - async function publish(record) { - const shopName = String(shopNames[record.id] ?? record.shop_name ?? '').trim() - if (!shopName) { - setError(`请先为用户 ${record.user_id} 填写店铺名`) - setExpanded(current => ({ ...current, [record.id]: true })) - return - } + function openPublish(record) { if (!record.questions.some(question => question.selected)) { setError(`请先在用户 ${record.user_id} 下选择至少一个问题`) setExpanded(current => ({ ...current, [record.id]: true })) return } + setError('') + setPublishing(record) + setPublishForm({ + ecommerce_platform: record.ecommerce_platform || lastPlatformRef.current || '', + shop_name: record.shop_name || lastShopNameRef.current || '', + product_id: '', + }) + } + + function updatePublishField(field, value) { + setPublishForm(current => ({ ...current, [field]: value })) + const remembered = value.trim() + if (!remembered) return + if (field === 'shop_name') { + lastShopNameRef.current = remembered + window.localStorage.setItem(shopMemoryKey, remembered) + } else if (field === 'ecommerce_platform') { + lastPlatformRef.current = remembered + window.localStorage.setItem(platformMemoryKey, remembered) + } + } + + function closePublish() { + if (busy) return + setPublishing(null) + setPublishForm(current => ({ ...current, product_id: '' })) + setError('') + } + + async function publish() { + const record = publishing + if (!record) return + const ecommercePlatform = publishForm.ecommerce_platform.trim() + const shopName = publishForm.shop_name.trim() + const productId = publishForm.product_id.trim() + if (!ecommercePlatform) return setError('请填写电商平台') + if (!shopName) return setError('请填写店铺名称') + if (!productId) return setError('请填写商品ID') setBusy(record.id) setError('') try { await api(`/api/admin/answer-task-drafts/${record.id}/publish`, { method: 'POST', - body: JSON.stringify({ shop_name: shopName }), + body: JSON.stringify({ ecommerce_platform: ecommercePlatform, shop_name: shopName, product_id: productId }), }) lastShopNameRef.current = shopName + lastPlatformRef.current = ecommercePlatform window.localStorage.setItem(shopMemoryKey, shopName) - setShopNames(current => Object.fromEntries(Object.entries(current).map(([recordId, value]) => [ - recordId, - !value && !editedShopNamesRef.current.has(Number(recordId)) ? shopName : value, - ]))) + window.localStorage.setItem(platformMemoryKey, ecommercePlatform) + setPublishing(null) + setPublishForm({ ecommerce_platform: ecommercePlatform, shop_name: shopName, product_id: '' }) await load() } catch (err) { setError(err.message) @@ -198,16 +226,6 @@ export default function CreateAnswerTasksPageV2({ api, user }) { } } - function updateShopName(recordId, value) { - editedShopNamesRef.current.add(recordId) - setShopNames(current => ({ ...current, [recordId]: value })) - const remembered = value.trim() - if (remembered) { - lastShopNameRef.current = remembered - window.localStorage.setItem(shopMemoryKey, remembered) - } - } - async function reject(record) { if (!record || busy) return setBusy(record.id) @@ -282,11 +300,10 @@ export default function CreateAnswerTasksPageV2({ api, user }) {
{record.submission_no}已选 {selected}/{record.questions.length} 题