修改返款系统,添加支付宝付款管理,添加支付宝配置证书

This commit is contained in:
db_huang 2026-10-29 09:40:31 +08:00
parent ccae5a3a3c
commit 5f9264fbb2
87 changed files with 2562 additions and 590 deletions

8
.gitignore vendored
View File

@ -1,7 +1,5 @@
.env backend/.env
__pycache__/
*.py[cod]
.pytest_cache/
frontend/node_modules/ frontend/node_modules/
frontend/dist/ frontend/dist/
data/uploads/ backend/data/uploads/
backend/keys/

View File

@ -29,7 +29,7 @@ npm run dev
## 钉钉工作台免登 ## 钉钉工作台免登
从钉钉工作台打开时,前端通过钉钉 JSAPI `requestAuthCode` 获取临时授权码,后端换取用户身份并建立会话,不再跳转到 OAuth 确认页。请在钉钉开放平台创建并发布企业内部应用,开通工作台免登/获取用户身份权限,然后在项目根目录 `.env` 填写: 从钉钉工作台打开时,前端通过钉钉 JSAPI `requestAuthCode` 获取临时授权码,后端换取用户身份并建立会话,不再跳转到 OAuth 确认页。请在钉钉开放平台创建并发布企业内部应用,开通工作台免登/获取用户身份权限,然后在 `backend/.env` 填写:
```env ```env
DINGTALK_CORP_ID=企业CorpId DINGTALK_CORP_ID=企业CorpId
@ -46,13 +46,13 @@ DINGTALK_SUPER_ADMIN_UNION_IDS=
`DINGTALK_CLIENT_ID/SECRET` 填应用的 AppKey/AppSecret;`DINGTALK_CORP_ID` 填企业 CorpId;`DINGTALK_AGENT_ID` 用于标识工作台应用。当前免登接口不使用浏览器 OAuth 回调,但保留回调配置兼容旧入口。未配置免登参数时,普通浏览器仍可使用账号密码登录。 `DINGTALK_CLIENT_ID/SECRET` 填应用的 AppKey/AppSecret;`DINGTALK_CORP_ID` 填企业 CorpId;`DINGTALK_AGENT_ID` 用于标识工作台应用。当前免登接口不使用浏览器 OAuth 回调,但保留回调配置兼容旧入口。未配置免登参数时,普通浏览器仍可使用账号密码登录。
MySQL 建表脚本位于 `backend/schema.sql`。未配置 `DATABASE_URL` 时,开发环境会自动使用项目 `data/app.db` 运行,便于本地快速验收。 MySQL 建表脚本位于 `backend/schema.sql`。未配置 `DATABASE_URL` 时,开发环境会自动使用 `backend/data/app.db` 运行,便于本地快速验收;上传截图保存在 `backend/data/uploads/screenshots`。
## 问答助手大模型配置 ## 问答助手大模型配置
问答助手使用 OpenAI 兼容的 Chat Completions 接口。启动后端前设置: 问答助手使用 OpenAI 兼容的 Chat Completions 接口。启动后端前设置:
也可以在项目根目录创建 `.env` 文件填写这些配置;后端启动时会自动读取。 也可以在 `backend/.env` 文件填写这些配置;后端启动时会自动读取。
```powershell ```powershell
$env:LLM_API_KEY="你的 API Key" $env:LLM_API_KEY="你的 API Key"

View File

@ -33,3 +33,23 @@ DINGTALK_SUPER_ADMIN_UNION_IDS=
# 以下部门及其下级部门成员自动授予超级管理员;部门 ID 比名称更稳定 # 以下部门及其下级部门成员自动授予超级管理员;部门 ID 比名称更稳定
DINGTALK_SUPER_ADMIN_DEPT_IDS= DINGTALK_SUPER_ADMIN_DEPT_IDS=
DINGTALK_SUPER_ADMIN_DEPT_NAMES=AI指挥部 DINGTALK_SUPER_ADMIN_DEPT_NAMES=AI指挥部
# 支付宝单笔转账配置(填写后仍需将 ALIPAY_ENABLED 改为 true 才会调用真实接口)
ALIPAY_ENABLED=false
ALIPAY_APP_ID=
ALIPAY_PAYER_ACCOUNT=
# PEM 私钥和支付宝公钥建议保存为服务器文件,这里填写绝对路径
ALIPAY_APP_PRIVATE_KEY_PATH=keys/app-private-key.pem
ALIPAY_PUBLIC_KEY_PATH=keys/alipay-public-key.pem
# 证书模式:分别填写应用公钥证书、支付宝公钥证书、支付宝根证书的绝对路径
ALIPAY_APP_CERT_PATH=keys/appCertPublicKey.crt
ALIPAY_ALIPAY_PUBLIC_CERT_PATH=keys/alipayCertPublicKey_RSA2.crt
ALIPAY_ROOT_CERT_PATH=keys/alipayRootCert.crt
ALIPAY_GATEWAY=https://openapi.alipay.com/gateway.do
ALIPAY_SIGN_TYPE=RSA2
ALIPAY_CHARSET=utf-8
ALIPAY_FORMAT=json
ALIPAY_NOTIFY_URL=https://your-domain.example.com/alipay_callback
ALIPAY_TRANSFER_API=alipay.fund.trans.uni.transfer
ALIPAY_TRANSFER_QUERY_API=alipay.fund.trans.common.query
ALIPAY_HTTP_TIMEOUT_SECONDS=30

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -1,5 +1,332 @@
"""Reserved payout adapter. No external transfer is made in this version.""" """Small adapter around Alipay's official Python SDK.
The SDK is imported lazily so local development and the disabled/simulation
mode do not require credentials or network access.
"""
from decimal import Decimal
import hashlib
import json
from pathlib import Path
import re
from .config import settings
def transfer(order): class AlipayTransferError(RuntimeError):
raise NotImplementedError("支付宝代付接口尚未接入,禁止将订单标记为支付成功") def __init__(self, message: str, code: str = "", ambiguous: bool = False):
super().__init__(message)
self.code = code
self.ambiguous = ambiguous
def validate_configuration() -> None:
missing = [
label for label, value in (
("ALIPAY_APP_ID", settings.alipay_app_id),
("ALIPAY_PAYER_ACCOUNT", settings.alipay_payer_account),
("ALIPAY_APP_PRIVATE_KEY_PATH", settings.alipay_app_private_key_path),
("ALIPAY_PUBLIC_KEY_PATH 或 ALIPAY_ALIPAY_PUBLIC_CERT_PATH", settings.alipay_public_key_path or settings.alipay_alipay_public_cert_path),
) if not str(value or "").strip()
]
if missing:
raise AlipayTransferError(f"支付宝配置不完整:{', '.join(missing)}")
if settings.alipay_notify_url and not settings.alipay_notify_url.lower().startswith("https://"):
raise AlipayTransferError("ALIPAY_NOTIFY_URL 必须使用公网 HTTPS 地址")
_sdk_key(_read_key(settings.alipay_app_private_key_path, "应用私钥"), private=True)
if _certificate_mode_enabled():
app_cert = _load_certificate(settings.alipay_app_cert_path, "应用公钥证书")
alipay_cert = _load_certificate(settings.alipay_alipay_public_cert_path, "支付宝公钥证书")
root_certs = _load_certificate_bundle(settings.alipay_root_cert_path, "支付宝根证书")
_certificate_serial(app_cert)
_certificate_serial(alipay_cert)
if not root_certs:
raise AlipayTransferError("支付宝根证书文件中没有可识别的证书")
_root_certificate_serial(root_certs)
private = _sdk_key(_read_key(settings.alipay_app_private_key_path, "应用私钥"), private=True)
if not _same_rsa_key(private, _certificate_public_key(app_cert)):
raise AlipayTransferError("应用公钥证书与应用私钥不匹配")
else:
_sdk_key(_read_key(settings.alipay_public_key_path, "支付宝公钥"), private=False)
def _certificate_mode_enabled() -> bool:
return any((settings.alipay_app_cert_path, settings.alipay_alipay_public_cert_path, settings.alipay_root_cert_path))
def _load_certificate(path_value: str, label: str):
try:
from cryptography import x509
except ImportError as exc:
raise AlipayTransferError("证书模式依赖 cryptography,请安装后重启服务") from exc
if not path_value or not Path(path_value).is_file():
raise AlipayTransferError(f"{label}文件不存在,请检查对应 .env 绝对路径")
try:
return x509.load_pem_x509_certificate(Path(path_value).read_bytes())
except Exception as exc:
raise AlipayTransferError(f"{label}格式无效,应为 PEM 编码的 X.509 .crt 文件") from exc
def _load_certificate_bundle(path_value: str, label: str):
try:
from cryptography import x509
except ImportError as exc:
raise AlipayTransferError("证书模式依赖 cryptography,请安装后重启服务") from exc
if not path_value or not Path(path_value).is_file():
raise AlipayTransferError(f"{label}文件不存在,请检查对应 .env 绝对路径")
content = Path(path_value).read_bytes()
blocks = re.findall(rb"-----BEGIN CERTIFICATE-----.*?-----END CERTIFICATE-----", content, re.DOTALL)
try:
return [x509.load_pem_x509_certificate(block) for block in blocks]
except Exception as exc:
raise AlipayTransferError(f"{label}格式无效,应为 PEM 编码的 X.509 证书集合") from exc
def _certificate_serial(certificate) -> str:
issuer = certificate.issuer.rfc4514_string()
# Match AntCertificationUtil.getCertSN in Alipay's official Java SDK:
# MD5(issuer X500 name + the certificate serial number in *decimal*).
# Using hexadecimal here makes app_cert_sn/alipay_root_cert_sn invalid.
serial = str(certificate.serial_number)
return hashlib.md5(f"{issuer}{serial}".encode("utf-8")).hexdigest()
def _root_certificate_serial(certificates) -> str:
# Alipay's root bundle can contain RSA, SM2 and ECC certificates. The
# OpenAPI root certificate SN is defined from the RSA certificates only;
# including every certificate produces isv.invalid-alipay-root-cert-sn.
serials = [
_certificate_serial(certificate)
for certificate in certificates
# This is the exact RSA signature OID prefix check used by Alipay's
# AntCertificationUtil.getRootCertSN implementation.
if certificate.signature_algorithm_oid.dotted_string.startswith("1.2.840.113549.1.1")
]
if not serials:
raise AlipayTransferError("支付宝根证书中没有可用于计算序列号的 RSA 证书")
return "_".join(serials)
def _certificate_public_key(certificate) -> str:
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
key = certificate.public_key()
if not isinstance(key, rsa.RSAPublicKey):
raise AlipayTransferError("支付宝证书必须包含 RSA 公钥")
return key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.PKCS1,
).decode("ascii")
def _same_rsa_key(private_key: str, public_key: str) -> bool:
from Crypto.PublicKey import RSA
private = RSA.import_key(private_key)
public = RSA.import_key(public_key)
private_public = private.publickey()
return (private_public.n, private_public.e) == (public.n, public.e)
def _read_key(path_value: str, label: str) -> str:
if not path_value:
raise AlipayTransferError(f"未配置{label}文件路径")
path = Path(path_value)
if not path.is_file():
raise AlipayTransferError(f"{label}文件不存在:{path}")
value = path.read_text(encoding="utf-8-sig").strip()
if not value:
raise AlipayTransferError(f"{label}文件为空")
return value
def _sdk_key(value: str, *, private: bool) -> str:
"""Return a PKCS#1 PEM accepted by the SDK's rsa dependency."""
try:
from Crypto.PublicKey import RSA
normalized = value.strip()
if "-----BEGIN" not in normalized:
marker = "PRIVATE KEY" if private else "PUBLIC KEY"
normalized = f"-----BEGIN {marker}-----\n{normalized}\n-----END {marker}-----"
key = RSA.import_key(normalized.encode("utf-8"))
if private and not key.has_private():
raise ValueError("not a private key")
if private:
return key.export_key(format="PEM", pkcs=1).decode("ascii")
return key.publickey().export_key(format="PEM", pkcs=1).decode("ascii")
except Exception as exc:
raise AlipayTransferError("支付宝密钥格式无效,请确认上传的是完整 RSA2 密钥") from exc
def _client():
try:
from alipay.aop.api.AlipayClientConfig import AlipayClientConfig
from alipay.aop.api.DefaultAlipayClient import DefaultAlipayClient
except ImportError as exc:
raise AlipayTransferError("未安装支付宝 SDK,请安装 alipay-sdk-python") from exc
config = AlipayClientConfig()
config.server_url = settings.alipay_gateway
config.app_id = settings.alipay_app_id
config.app_private_key = _sdk_key(_read_key(settings.alipay_app_private_key_path, "应用私钥"), private=True)
if _certificate_mode_enabled():
app_cert = _load_certificate(settings.alipay_app_cert_path, "应用公钥证书")
alipay_cert = _load_certificate(settings.alipay_alipay_public_cert_path, "支付宝公钥证书")
root_certs = _load_certificate_bundle(settings.alipay_root_cert_path, "支付宝根证书")
config.alipay_public_key = _certificate_public_key(alipay_cert)
app_cert_sn = _certificate_serial(app_cert)
alipay_root_cert_sn = _root_certificate_serial(root_certs)
class CertificateAlipayClient(DefaultAlipayClient):
def _DefaultAlipayClient__get_common_params(self, params):
common = super()._DefaultAlipayClient__get_common_params(params)
common["app_cert_sn"] = app_cert_sn
common["alipay_root_cert_sn"] = alipay_root_cert_sn
return common
client_class = CertificateAlipayClient
else:
config.alipay_public_key = _sdk_key(_read_key(settings.alipay_public_key_path, "支付宝公钥"), private=False)
client_class = DefaultAlipayClient
config.sign_type = settings.alipay_sign_type
config.charset = settings.alipay_charset
config.format = settings.alipay_format
config.timeout = settings.alipay_http_timeout_seconds
return client_class(alipay_client_config=config)
def _parse_response(raw, response_cls):
"""Parse the JSON string returned by DefaultAlipayClient.execute."""
if isinstance(raw, response_cls):
response = raw
else:
response = response_cls()
try:
response.parse_response_content(raw.decode("utf-8") if isinstance(raw, bytes) else raw)
except Exception as exc:
raise AlipayTransferError("支付宝返回内容无法解析", ambiguous=True) from exc
if response.code != "10000" or response.sub_code:
raise AlipayTransferError(
response.sub_msg or response.msg or "支付宝请求失败",
response.sub_code or response.code or "ALIPAY_ERROR",
)
return response
def _provider_error_from_exception(exc: Exception) -> AlipayTransferError | None:
"""Recover Alipay's structured business error from old SDK exceptions.
alipay-sdk-python 3.x raises ResponseException before returning an error
response when the envelope contains ``alipay_cert_sn``. The exception
still embeds the complete JSON response, so preserve the provider's
sub-code/message instead of treating a deterministic rejection as an
ambiguous network failure.
"""
message = str(exc)
json_start = message.find("{")
if json_start < 0:
return None
try:
payload = json.loads(message[json_start:])
except (TypeError, ValueError):
return None
response = next((
value for key, value in payload.items()
if key.endswith("_response") and isinstance(value, dict)
), None)
if not response or not response.get("code"):
return None
return AlipayTransferError(
response.get("sub_msg") or response.get("msg") or "支付宝请求失败",
response.get("sub_code") or response.get("code") or "ALIPAY_ERROR",
ambiguous=False,
)
def transfer(*, out_biz_no: str, amount_fen: int, account: str, payee_name: str, order_title: str) -> dict:
from alipay.aop.api.domain.AlipayFundTransUniTransferModel import AlipayFundTransUniTransferModel
from alipay.aop.api.domain.Participant import Participant
from alipay.aop.api.request.AlipayFundTransUniTransferRequest import AlipayFundTransUniTransferRequest
model = AlipayFundTransUniTransferModel()
model.out_biz_no = out_biz_no
model.trans_amount = f"{Decimal(amount_fen) / Decimal(100):.2f}"
model.product_code = "TRANS_ACCOUNT_NO_PWD"
model.biz_scene = "DIRECT_TRANSFER"
model.order_title = order_title[:128]
payee = Participant()
payee.identity = account
payee.identity_type = "ALIPAY_LOGON_ID"
payee.name = payee_name
model.payee_info = payee
try:
request = AlipayFundTransUniTransferRequest(biz_model=model)
if settings.alipay_notify_url:
request.notify_url = settings.alipay_notify_url
raw = _client().execute(request)
from alipay.aop.api.response.AlipayFundTransUniTransferResponse import AlipayFundTransUniTransferResponse
response = _parse_response(raw, AlipayFundTransUniTransferResponse)
except Exception as exc:
if isinstance(exc, AlipayTransferError):
raise
provider_error = _provider_error_from_exception(exc)
if provider_error:
raise provider_error from exc
raise AlipayTransferError(f"支付宝转账请求失败:{exc}", ambiguous=True) from exc
return {
"out_biz_no": response.out_biz_no or out_biz_no,
"trade_no": response.pay_fund_order_id or response.order_id or "",
"status": response.status or "UNKNOWN",
}
def query(*, out_biz_no: str) -> dict:
from alipay.aop.api.domain.AlipayFundTransCommonQueryModel import AlipayFundTransCommonQueryModel
from alipay.aop.api.request.AlipayFundTransCommonQueryRequest import AlipayFundTransCommonQueryRequest
model = AlipayFundTransCommonQueryModel()
model.out_biz_no = out_biz_no
model.product_code = "TRANS_ACCOUNT_NO_PWD"
request = AlipayFundTransCommonQueryRequest(biz_model=model)
try:
raw = _client().execute(request)
from alipay.aop.api.response.AlipayFundTransCommonQueryResponse import AlipayFundTransCommonQueryResponse
response = _parse_response(raw, AlipayFundTransCommonQueryResponse)
except Exception as exc:
if isinstance(exc, AlipayTransferError):
raise
provider_error = _provider_error_from_exception(exc)
if provider_error:
raise provider_error from exc
raise AlipayTransferError(f"支付宝查询请求失败:{exc}") from exc
return {
"out_biz_no": response.out_biz_no or out_biz_no,
"trade_no": response.pay_fund_order_id or response.order_id or "",
"status": response.status or response.sub_status or "",
"fail_reason": response.fail_reason or response.sub_order_fail_reason or response.sub_msg or "",
}
def verify_notification(params: dict[str, str]) -> bool:
from alipay.aop.api.util.SignatureUtils import verify_with_rsa
signature = params.get("sign")
if not signature or params.get("sign_type", "RSA2").upper() != "RSA2":
return False
if settings.alipay_app_id and params.get("app_id") not in {None, settings.alipay_app_id}:
return False
content = "&".join(
f"{key}={params[key]}" for key in sorted(params)
if key not in {"sign", "sign_type"}
)
try:
alipay_public_key = (
_certificate_public_key(_load_certificate(settings.alipay_alipay_public_cert_path, "支付宝公钥证书"))
if _certificate_mode_enabled()
else _sdk_key(_read_key(settings.alipay_public_key_path, "支付宝公钥"), private=False)
)
return verify_with_rsa(
alipay_public_key,
content.encode(settings.alipay_charset),
signature,
)
except Exception:
return False

View File

@ -4,10 +4,18 @@ from pathlib import Path
from dotenv import load_dotenv from dotenv import load_dotenv
BASE_DIR = Path(__file__).resolve().parents[2] PROJECT_DIR = Path(__file__).resolve().parents[2]
BASE_DIR = Path(__file__).resolve().parents[1]
load_dotenv(BASE_DIR / ".env") load_dotenv(BASE_DIR / ".env")
def _resolve_backend_path(value: str) -> str:
if not value:
return ""
path = Path(value.strip())
return str(path if path.is_absolute() else BASE_DIR / path)
class Settings: class Settings:
app_name = "问大家运营管理后台" app_name = "问大家运营管理后台"
database_url = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'data' / 'app.db'}") database_url = os.getenv("DATABASE_URL", f"sqlite:///{BASE_DIR / 'data' / 'app.db'}")
@ -51,6 +59,25 @@ class Settings:
# IDs are preferred; names are supported as a readable fallback. # 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_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", "AI指挥部").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()}
# Alipay transfer settings. Keep live transfer disabled until credentials and
# the application's transfer permission have been verified in the sandbox.
alipay_enabled = os.getenv("ALIPAY_ENABLED", "false").lower() == "true"
alipay_app_id = os.getenv("ALIPAY_APP_ID", "")
alipay_payer_account = os.getenv("ALIPAY_PAYER_ACCOUNT", "")
alipay_app_private_key_path = _resolve_backend_path(os.getenv("ALIPAY_APP_PRIVATE_KEY_PATH", ""))
alipay_public_key_path = _resolve_backend_path(os.getenv("ALIPAY_PUBLIC_KEY_PATH", ""))
# Certificate-mode signing files from Alipay Open Platform.
alipay_app_cert_path = _resolve_backend_path(os.getenv("ALIPAY_APP_CERT_PATH", ""))
alipay_alipay_public_cert_path = _resolve_backend_path(os.getenv("ALIPAY_ALIPAY_PUBLIC_CERT_PATH", ""))
alipay_root_cert_path = _resolve_backend_path(os.getenv("ALIPAY_ROOT_CERT_PATH", ""))
alipay_gateway = os.getenv("ALIPAY_GATEWAY", "https://openapi.alipay.com/gateway.do")
alipay_sign_type = os.getenv("ALIPAY_SIGN_TYPE", "RSA2")
alipay_charset = os.getenv("ALIPAY_CHARSET", "utf-8")
alipay_format = os.getenv("ALIPAY_FORMAT", "json")
alipay_notify_url = os.getenv("ALIPAY_NOTIFY_URL", "")
alipay_transfer_api = os.getenv("ALIPAY_TRANSFER_API", "alipay.fund.trans.uni.transfer")
alipay_transfer_query_api = os.getenv("ALIPAY_TRANSFER_QUERY_API", "alipay.fund.trans.common.query")
alipay_http_timeout_seconds = int(os.getenv("ALIPAY_HTTP_TIMEOUT_SECONDS", "30"))
settings = Settings() settings = Settings()

View File

@ -1,7 +1,8 @@
from datetime import datetime, timedelta from datetime import datetime, timedelta
from decimal import Decimal, InvalidOperation from decimal import Decimal, InvalidOperation
from .models import RefundRecipient, RefundPaymentAccount, RefundRecord
from .schemas import RefundPaymentAccountSave, PaymentAccountAuthorization
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from .models import RefundRecipient, AlipayRefundOrder
import base64 import base64
import csv import csv
@ -29,8 +30,9 @@ from sqlalchemy.orm import Session, defer, noload, object_session, selectinload
from .config import settings from .config import settings
from .database import Base, SessionLocal, engine, get_db from .database import Base, SessionLocal, engine, get_db
from .llm import extract_questions_from_images, generate_answer_for_question, generate_answers_batch, generate_answers_for_questions, generate_questions, generate_qa from .llm import extract_questions_from_images, generate_answer_for_question, generate_answers_batch, generate_answers_for_questions, generate_questions, generate_qa
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 .models import AdminLoginLog, AdminUsageEntry, AdminUser, AlipayRefundOrder, 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 .alipay_provider import AlipayTransferError
from .schemas import AdminDingTalkUserCreate, AdminDingTalkUserLookup, AdminUserCreate, AdminUserUpdate, LoginRequest, QABulkAnswerRequest, QAGenerateRequest, RegisterRequest
from .security import create_session_token, decode_session_token, hash_password, verify_password 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 from .screenshot_storage import screenshot_bytes, screenshot_data_url, screenshot_fields, screenshot_response
@ -245,9 +247,9 @@ DINGTALK_STATES: dict[str, float] = {}
ROLE_LABELS = {"super_admin": "超级管理员", "dept_manager": "部门主管", "operator": "普通运营"} ROLE_LABELS = {"super_admin": "超级管理员", "dept_manager": "部门主管", "operator": "普通运营"}
ROLE_PERMISSIONS = { ROLE_PERMISSIONS = {
"super_admin": ["assistant", "library", "create", "board", "review", "refunds", "usage", "users", "logs", "settings"], "super_admin": ["assistant", "library", "create", "board", "review", "refund-records", "usage", "users", "logs", "settings", "refund-management"],
"dept_manager": ["assistant", "library", "create", "board", "review", "refunds"], "dept_manager": ["assistant", "library", "create", "board", "review", "refund-records"],
"operator": ["assistant", "library", "create", "board", "review", "refunds"], "operator": ["assistant", "library", "create", "board", "review", "refund-records"],
} }
@ -320,6 +322,13 @@ def seed_users(db: Session):
@app.on_event("startup") @app.on_event("startup")
def startup(): def startup():
Base.metadata.create_all(bind=engine) Base.metadata.create_all(bind=engine)
if settings.alipay_enabled:
from .alipay_provider import validate_configuration
validate_configuration()
_migrate_alipay_transfer_attempts(engine)
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"))
settings.screenshot_storage_dir.mkdir(parents=True, exist_ok=True) settings.screenshot_storage_dir.mkdir(parents=True, exist_ok=True)
for screenshot_table in ("answer_screenshots", "ask_assignment_batch_screenshots", "ask_assignment_screenshots"): 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_column_defs = {column["name"]: column for column in inspect(engine).get_columns(screenshot_table)}
@ -370,6 +379,7 @@ def startup():
connection.execute(text("ALTER TABLE ask_questions ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'AI生成'")) connection.execute(text("ALTER TABLE ask_questions ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'AI生成'"))
admin_user_columns = {column["name"] for column in inspect(engine).get_columns("admin_users")} admin_user_columns = {column["name"] for column in inspect(engine).get_columns("admin_users")}
admin_user_migrations = { admin_user_migrations = {
"refund_payment_account_id": "INTEGER NULL",
"dingtalk_user_id": "VARCHAR(128) NULL", "dingtalk_user_id": "VARCHAR(128) NULL",
"dingtalk_union_id": "VARCHAR(128) NULL", "dingtalk_union_id": "VARCHAR(128) NULL",
"dingtalk_corp_id": "VARCHAR(128) NULL", "dingtalk_corp_id": "VARCHAR(128) NULL",
@ -473,6 +483,62 @@ def startup():
db.close() db.close()
def _migrate_alipay_transfer_attempts(bind):
"""Bring pre-provider-attempt tables up to the current model in place."""
columns = {column["name"] for column in inspect(bind).get_columns("alipay_transfer_attempts")}
additions = {
"out_biz_no": "VARCHAR(64) NULL",
"order_no": "VARCHAR(64) NOT NULL DEFAULT ''",
"shop_name": "VARCHAR(255) NOT NULL DEFAULT ''",
"owner_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",
"status": "VARCHAR(24) NOT NULL DEFAULT 'pending'",
"alipay_trade_no": "VARCHAR(128) NOT NULL DEFAULT ''",
"error_code": "VARCHAR(128) NOT NULL DEFAULT ''",
"error_message": "VARCHAR(500) NOT NULL DEFAULT ''",
"created_at": "DATETIME NULL",
"updated_at": "DATETIME NULL",
}
for name, definition in additions.items():
if name not in columns:
with bind.begin() as connection:
connection.execute(text(f"ALTER TABLE alipay_transfer_attempts ADD COLUMN {name} {definition}"))
columns.add(name)
# Older installations may have rows from the preliminary bookkeeping-only
# implementation. Give them unique local identifiers so they remain
# inspectable without ever treating those rows as completed transfers.
with bind.begin() as connection:
legacy_ids = connection.execute(text(
"SELECT id FROM alipay_transfer_attempts WHERE out_biz_no IS NULL OR out_biz_no = ''"
)).scalars().all()
for row_id in legacy_ids:
connection.execute(text(
"UPDATE alipay_transfer_attempts SET out_biz_no = :value, status = 'failed', "
"error_message = '旧版本记录,未通过支付宝接口发起转账' WHERE id = :id"
), {"value": f"LEGACY-{row_id}-{uuid.uuid4().hex[:12]}", "id": row_id})
indexes = inspect(bind).get_indexes("alipay_transfer_attempts")
constraints = inspect(bind).get_unique_constraints("alipay_transfer_attempts")
unique_specs = (
("uq_alipay_transfer_attempt_target", ["target_kind", "target_id"]),
("uq_alipay_transfer_attempt_out_biz_no", ["out_biz_no"]),
)
for index_name, field_names in unique_specs:
exists = any(
index.get("unique") and index.get("column_names") == field_names
for index in indexes
) or any(constraint.get("column_names") == field_names for constraint in constraints)
if not exists:
fields = ", ".join(field_names)
with bind.begin() as connection:
connection.execute(text(
f"CREATE UNIQUE INDEX {index_name} ON alipay_transfer_attempts ({fields})"
))
def current_user(request: Request, db: Session = Depends(get_db)) -> AdminUser: def current_user(request: Request, db: Session = Depends(get_db)) -> AdminUser:
token = request.cookies.get(settings.cookie_name) token = request.cookies.get(settings.cookie_name)
payload = decode_session_token(token) if token else None payload = decode_session_token(token) if token else None
@ -687,6 +753,103 @@ def _dingtalk_oapi_post(path: str, access_token: str, payload: dict) -> dict:
return {} return {}
def _dingtalk_app_access_token() -> str:
if not _dingtalk_workbench_configured():
raise HTTPException(status_code=503, detail="钉钉应用尚未配置完整,请先配置应用凭证")
request = urllib.request.Request(
"https://oapi.dingtalk.com/gettoken?" + urllib.parse.urlencode({
"appkey": settings.dingtalk_client_id,
"appsecret": settings.dingtalk_client_secret,
}),
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
data = json.loads(response.read().decode("utf-8"))
except Exception as exc:
raise HTTPException(status_code=502, detail="钉钉应用凭证校验失败,请检查应用配置") from exc
token = data.get("access_token")
if not token:
raise HTTPException(status_code=502, detail="钉钉未返回应用访问令牌,请检查应用权限")
return token
def _dingtalk_lookup_user(identifier: str, db: Session | None = None) -> dict:
"""Resolve a DingTalk user by userid, or by a known unionid."""
identifier = identifier.strip()
if not identifier:
raise HTTPException(status_code=400, detail="请输入钉钉 UserId 或 UnionId")
if db is not None:
known = db.scalar(select(AdminUser).where(or_(
AdminUser.dingtalk_user_id == identifier,
AdminUser.dingtalk_union_id == identifier,
)))
if known:
return {
"userid": known.dingtalk_user_id,
"unionid": known.dingtalk_union_id,
"name": known.display_name,
"dept_id_list": [known.dingtalk_dept_id] if known.dingtalk_dept_id else [],
"_primary_dept_id": known.dingtalk_dept_id or "",
"_dept_name": known.dingtalk_dept_name or "",
"_dept_path": known.dingtalk_dept_path or "",
"_dept_name_path": known.dingtalk_dept_name or "",
"title": known.dingtalk_title or "",
"_is_dept_manager": known.is_dept_manager,
}
access_token = _dingtalk_app_access_token()
profile = _dingtalk_oapi_post(
"/topapi/v2/user/get",
access_token,
{"userid": identifier, "language": "zh_CN"},
).get("result") or {}
if not isinstance(profile, dict) or not profile:
# Some installations expose only the v1.0 contact endpoint. It accepts
# both the directory user id and union id, so use it as a fallback.
request = urllib.request.Request(
"https://api.dingtalk.com/v1.0/contact/users/" + urllib.parse.quote(identifier, safe=""),
headers={"x-acs-dingtalk-access-token": access_token},
method="GET",
)
try:
with urllib.request.urlopen(request, timeout=10) as response:
candidate = json.loads(response.read().decode("utf-8"))
profile = candidate.get("result") if isinstance(candidate, dict) and isinstance(candidate.get("result"), dict) else candidate
except Exception:
profile = {}
if not isinstance(profile, dict) or not profile:
raise HTTPException(status_code=404, detail="未找到对应的钉钉员工,请确认 UserId/UnionId 正确且应用有通讯录权限")
return _dingtalk_org_profile(profile, access_token)
def _dingtalk_user_preview(profile: dict) -> dict:
union_id = str(profile.get("unionId") or profile.get("unionid") or profile.get("union_id") or profile.get("openId") or "").strip()
user_id = str(profile.get("userid") or profile.get("userId") or profile.get("user_id") or "").strip()
display_name = str(profile.get("nick") or profile.get("name") or profile.get("displayName") or "").strip()
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_manager = bool(profile.get("_is_dept_manager"))
department_super_admin = _dingtalk_department_is_super_admin(dept_id, dept_name, dept_path, dept_name_path)
suggested_role = (
"super_admin" if union_id in settings.dingtalk_super_admin_union_ids or department_super_admin
else "dept_manager" if is_manager
else "operator"
)
return {
"user_id": user_id or None,
"union_id": union_id or None,
"display_name": display_name or "钉钉员工",
"department_id": dept_id,
"department_name": dept_name,
"department_path": dept_name_path,
"title": str(profile.get("title") or "").strip() or None,
"is_dept_manager": is_manager,
"suggested_role": suggested_role,
}
def _dingtalk_org_profile(profile: dict, access_token: str | None) -> dict: def _dingtalk_org_profile(profile: dict, access_token: str | None) -> dict:
"""Best-effort enrichment of a DingTalk profile with department data.""" """Best-effort enrichment of a DingTalk profile with department data."""
data = dict(profile or {}) data = dict(profile or {})
@ -1113,17 +1276,25 @@ def _ask_screenshot_url(
def _refund_recipient_data(record, target_kind: str) -> dict: def _refund_recipient_data(record, target_kind: str) -> dict:
"""Expose the payout identity saved alongside a user submission.""" """Expose the payout identity saved alongside a user submission."""
db = object_session(record) db = object_session(record)
if db is None:
return {"alipay_account": "", "payee_name": ""}
recipient = db.scalar( recipient = db.scalar(
select(RefundRecipient).where( select(RefundRecipient).where(
RefundRecipient.target_kind == target_kind, RefundRecipient.target_kind == target_kind,
RefundRecipient.target_id == record.id, RefundRecipient.target_id == record.id,
) )
) ) if db is not None else None
if target_kind == "ask_batch":
count = len({int(value) for value in (record.selected_question_ids or "").split(",") if value.strip().isdigit()})
elif target_kind == "ask_assignment":
count = sum(bool(item.selected) for item in record.items)
else:
count = sum(bool(item.selected) for item in record.questions)
default_amount = count * 50
return { return {
"alipay_account": recipient.account if recipient else "", "alipay_account": recipient.account if recipient else "",
"payee_name": recipient.payee_name if recipient else "", "payee_name": recipient.payee_name if recipient else "",
"refund_default_amount_fen": default_amount,
"refund_amount_fen": recipient.amount_fen if recipient and recipient.amount_fen is not None else default_amount,
"refund_amount_custom": bool(recipient and recipient.amount_fen is not None),
} }
@ -2101,7 +2272,7 @@ async def submit_public_ask_task(
task_no: str, task_no: str,
token: str = Form(...), token: str = Form(...),
files: list[UploadFile] = File(...), files: list[UploadFile] = File(...),
payment_file: UploadFile = File(...), payment_file: UploadFile | None = File(None),
payee_name: str = Form(default=""), payee_name: str = Form(default=""),
alipay_account: str = Form(default=""), alipay_account: str = Form(default=""),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@ -2124,13 +2295,13 @@ async def submit_public_ask_task(
if not files: if not files:
raise HTTPException(status_code=400, detail="请至少上传一张提问截图") raise HTTPException(status_code=400, detail="请至少上传一张提问截图")
allowed = {"image/jpeg", "image/png", "image/webp"} allowed = {"image/jpeg", "image/png", "image/webp"}
if any(upload.content_type not in allowed for upload in [*files, payment_file]): if any(upload.content_type not in allowed for upload in [*files, payment_file] if upload is not None):
raise HTTPException(status_code=400, detail="仅支持 JPG、PNG、WEBP 图片") raise HTTPException(status_code=400, detail="仅支持 JPG、PNG、WEBP 图片")
selected_ids = [item.question_id for item in assignment.items if item.selected] selected_ids = [item.question_id for item in assignment.items if item.selected]
assignment_id = assignment.id assignment_id = assignment.id
db.rollback() db.rollback()
question_uploads = [(upload.filename or "question", upload.content_type, await upload.read()) for upload in files] question_uploads = [(upload.filename or "question", upload.content_type, await upload.read()) for upload in files]
payment_upload = (payment_file.filename or "payment", payment_file.content_type, await payment_file.read()) payment_upload = (payment_file.filename or "payment", payment_file.content_type, await payment_file.read()) if payment_file else None
assignment = db.get(AskAssignment, assignment_id) assignment = db.get(AskAssignment, assignment_id)
if not assignment or assignment.status not in {"进行中", "未通过"} or _ask_assignment_expired(assignment): if not assignment or assignment.status not in {"进行中", "未通过"} or _ask_assignment_expired(assignment):
raise HTTPException(status_code=409, detail="当前任务不能提交审核") raise HTTPException(status_code=409, detail="当前任务不能提交审核")
@ -2150,11 +2321,12 @@ async def submit_public_ask_task(
**screenshot_fields(image_data, filename, content_type, "ask-batches"), **screenshot_fields(image_data, filename, content_type, "ask-batches"),
)) ))
_save_refund_recipient(db, "ask_batch", batch.id, alipay_account, payee_name) _save_refund_recipient(db, "ask_batch", batch.id, alipay_account, payee_name)
filename, content_type, image_data = payment_upload if payment_upload:
db.add(AskAssignmentBatchScreenshot( filename, content_type, image_data = payment_upload
batch_id=batch.id, stage="payment", db.add(AskAssignmentBatchScreenshot(
**screenshot_fields(image_data, filename, content_type, "ask-batches"), batch_id=batch.id, stage="payment",
)) **screenshot_fields(image_data, filename, content_type, "ask-batches"),
))
assignment.status = "待审核" assignment.status = "待审核"
assignment.reservation_started_at = None assignment.reservation_started_at = None
used_ids = {item.question_id for item in assignment.used_questions} used_ids = {item.question_id for item in assignment.used_questions}
@ -3247,6 +3419,47 @@ def admin_task_board_delete_ask_assignment(assignment_id: int, user: AdminUser =
return {"success": True, "data": {"id": assignment_id}} return {"success": True, "data": {"id": assignment_id}}
def _parse_refund_amount(value):
try:
amount = Decimal(str(value))
if not amount.is_finite() or amount <= 0 or amount > Decimal("999999.99") or amount * 100 != (amount * 100).to_integral_value():
raise ValueError()
return int(amount * 100)
except (InvalidOperation, ValueError, TypeError):
raise HTTPException(status_code=400, detail="返款金额须大于0,最多两位小数且不超过999999.99元")
def _save_refund_amount(db, record, kind, value=None, snapshot=False):
amount = _parse_refund_amount(value) if value is not None else None
recipient = db.scalar(select(RefundRecipient).where(RefundRecipient.target_kind == kind, RefundRecipient.target_id == record.id))
if snapshot and value is None:
amount = _refund_recipient_data(record, kind)["refund_amount_fen"]
if not recipient:
recipient = RefundRecipient(target_kind=kind, target_id=record.id, account="", payee_name="")
db.add(recipient)
recipient.amount_fen = amount
@app.patch("/api/admin/task-board/refund-amount")
def update_refund_amount(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
kind = str(payload.get("target_kind") or "")
try:
target_id = int(payload.get("target_id"))
except (ValueError, TypeError):
raise HTTPException(status_code=400, detail="返款记录无效")
record, payable = _refund_session_target_for_payload(kind, target_id, db)
if not record:
raise HTTPException(status_code=404, detail="任务不存在")
allowed = _can_access_ask_task(record.assignment.task if kind == "ask_batch" else record.task, user) if kind in {"ask_batch", "ask_assignment"} else _can_access_answer_submission(record, user)
if not allowed:
raise HTTPException(status_code=404, detail="任务不存在")
if not payable:
raise HTTPException(status_code=409, detail="只能修改待返款任务的金额")
_save_refund_amount(db, record, kind, payload.get("amount"))
db.commit()
return {"success": True, "data": _refund_recipient_data(record, kind)}
def _save_refund_recipient(db, kind, target_id, account, name): def _save_refund_recipient(db, kind, target_id, account, name):
# Legacy clients can still submit screenshots without bank details. # Legacy clients can still submit screenshots without bank details.
if not account.strip(): if not account.strip():
@ -3261,69 +3474,6 @@ def _save_refund_recipient(db, kind, target_id, account, name):
recipient.payee_name = name.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") @app.post("/api/admin/refund-upload-sessions")
def create_refund_upload_session(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): 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.""" """Create a short-lived, record-bound upload URL for the operator modal."""
@ -3336,7 +3486,7 @@ def create_refund_upload_session(payload: dict, user: AdminUser = Depends(curren
if not target or not payable: if not target or not payable:
raise HTTPException(status_code=409, detail="当前任务不在待返款状态") raise HTTPException(status_code=409, detail="当前任务不在待返款状态")
if target_kind in {"ask_batch", "ask_assignment"}: if target_kind in {"ask_batch", "ask_assignment"}:
if not _can_access_ask_task(target.assignment.task, user): if not _can_access_ask_task(target.assignment.task if target_kind == "ask_batch" else target.task, user):
raise HTTPException(status_code=404, detail="无权操作该返款记录") raise HTTPException(status_code=404, detail="无权操作该返款记录")
elif not _can_access_answer_submission(target, user): elif not _can_access_answer_submission(target, user):
raise HTTPException(status_code=404, detail="无权操作该返款记录") raise HTTPException(status_code=404, detail="无权操作该返款记录")
@ -3415,31 +3565,277 @@ async def upload_refund_proof_by_session(token: str, refund_file: UploadFile = F
return {"success": True, "data": {"uploaded": True, "filename": filename, "uploaded_at": session.uploaded_at.isoformat()}} return {"success": True, "data": {"uploaded": True, "filename": filename, "uploaded_at": session.uploaded_at.isoformat()}}
def _snapshot_refund(db, record, kind, user, payer_account="", alipay_trade_no=""):
identity = _refund_recipient_data(record, kind)
task = record.assignment.task if kind == "ask_batch" else record.task if kind == "ask_assignment" else record
db.add(RefundRecord(
target_kind=kind, target_id=record.id, owner_id=task.owner_id,
shop_name=task.shop_name 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(),
))
def _refund_record_query(user, q, start_date, end_date):
query = select(RefundRecord)
if user.role_code != "super_admin":
owners = select(AdminUser.id).where(or_(
AdminUser.id == user.id,
AdminUser.dingtalk_dept_id == user.dingtalk_dept_id,
AdminUser.dingtalk_dept_path.like(f"%,{user.dingtalk_dept_id},%"),
)) if user.role_code == "dept_manager" and user.dingtalk_dept_id else select(AdminUser.id).where(AdminUser.id == user.id)
query = query.where(RefundRecord.owner_id.in_(owners))
if q.strip():
query = query.where(RefundRecord.order_no.contains(q.strip(), autoescape=True))
try:
start = datetime.strptime(start_date, "%Y-%m-%d") if start_date else None
end = datetime.strptime(end_date, "%Y-%m-%d") if end_date else None
except ValueError:
raise HTTPException(400, "日期格式应为 YYYY-MM-DD")
if start and end and start > end:
raise HTTPException(400, "开始日期不能晚于结束日期")
# Database timestamps are UTC; selected dates are Beijing calendar days.
if start:
query = query.where(RefundRecord.transferred_at >= start - timedelta(hours=8))
if end:
query = query.where(RefundRecord.transferred_at < end + timedelta(days=1, hours=-8))
return query
def _refund_record_data(record):
return {key: getattr(record, key) for key in (
"id", "shop_name", "order_no", "payee_name", "payee_account", "amount_fen",
"payer_account", "operator_name", "alipay_trade_no", "transferred_at",
)}
@app.get("/api/admin/refund-records")
def refund_records(q: str = "", start_date: str = "", end_date: str = "", page: int = 1, page_size: int = 10, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
if page < 1 or page_size not in (10, 20, 50, 100):
raise HTTPException(400, "分页参数无效")
query = _refund_record_query(user, q, start_date, end_date)
filtered = query.subquery()
total, amount = db.execute(select(func.count(), func.coalesce(func.sum(filtered.c.amount_fen), 0)).select_from(filtered)).one()
items = db.scalars(query.order_by(RefundRecord.transferred_at.desc(), RefundRecord.id.desc()).offset((page - 1) * page_size).limit(page_size)).all()
return {"success": True, "data": {"items": [_refund_record_data(item) for item in items], "total": total, "total_amount_fen": amount}}
@app.get("/api/admin/refund-records/export")
def export_refund_records(q: str = "", start_date: str = "", end_date: str = "", user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
query = _refund_record_query(user, q, start_date, end_date)
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["店铺名称", "转账时间", "订单号", "收款人", "收款账号", "返款金额(元)", "付款账户", "操作人", "支付宝流水"])
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}", "", "", ""])
return Response(content="\ufeff" + output.getvalue(), media_type="text/csv; charset=utf-8", headers={"Content-Disposition": 'attachment; filename="refund-records.csv"'})
def _authorized_refund_payer(db: Session, user: AdminUser, account_id: int):
payer = db.get(RefundPaymentAccount, account_id, with_for_update=True)
if not payer or not payer.is_active:
raise HTTPException(status_code=400, detail="请选择有效且已启用的付款账户")
if user.role_code not in {"super_admin", "dept_manager"} and user.refund_payment_account_id != account_id:
raise HTTPException(status_code=403, detail="该付款账户未授权,请联系主管或超级管理员")
if settings.alipay_enabled:
configured = settings.alipay_payer_account.strip()
if not configured:
raise HTTPException(status_code=503, detail="支付宝已启用,但尚未配置 ALIPAY_PAYER_ACCOUNT")
if configured != payer.alipay_account.strip():
raise HTTPException(status_code=400, detail="所选付款账户与支付宝应用实际付款账户不一致")
return payer
def _alipay_error_detail(exc: AlipayTransferError, *, querying: bool = False) -> str:
if exc.code == "isv.illegal-client-ip":
return "当前服务器公网 IP 不在支付宝开放平台白名单,请配置服务器 IP 白名单后重试查询"
if exc.code == "isv.invalid-alipay-root-cert-sn":
return "支付宝根证书序列号无效,请检查证书模式配置后重试"
if exc.ambiguous:
return "支付宝请求超时,结果暂不明确;请稍后查询订单状态,勿重复创建转账"
action = "查询" if querying else "转账"
message = str(exc).strip()
return f"支付宝{action}失败:{message}" if message else f"支付宝{action}失败,请查看订单状态"
def _perform_alipay_refund(db: Session, record, kind: str, user: AdminUser, payer: RefundPaymentAccount) -> str:
"""Call Alipay only when explicitly enabled; return its provider trade no.
The order row is committed before the network call so a timeout cannot be
retried as a second payment. A pending row must be queried/reconciled
before another attempt is allowed.
"""
if not settings.alipay_enabled:
return ""
identity = _refund_recipient_data(record, kind)
if not identity["alipay_account"] or not identity["payee_name"]:
raise HTTPException(status_code=400, detail="缺少收款支付宝账号或收款人姓名")
amount_fen = identity["refund_amount_fen"]
if amount_fen <= 0:
raise HTTPException(status_code=400, detail="返款金额必须大于0")
if payer.single_limit_fen and amount_fen > payer.single_limit_fen:
raise HTTPException(status_code=400, detail="返款金额超过付款账户单笔限额")
order = db.scalar(select(AlipayRefundOrder).where(
AlipayRefundOrder.target_kind == kind,
AlipayRefundOrder.target_id == record.id,
).order_by(AlipayRefundOrder.id.desc()).with_for_update())
if order and order.status == "success":
recipient = db.scalar(select(RefundRecipient).where(
RefundRecipient.target_kind == kind, RefundRecipient.target_id == record.id,
))
if recipient:
recipient.amount_fen = order.amount_fen
return order.alipay_trade_no
if order and order.status == "pending":
try:
from .alipay_provider import query
result = query(out_biz_no=order.out_biz_no)
except AlipayTransferError as exc:
raise HTTPException(status_code=503, detail=_alipay_error_detail(exc, querying=True)) from exc
provider_status = str(result.get("status") or "").upper()
if provider_status == "SUCCESS":
trade_no = str(result.get("trade_no") or "").strip()
if not trade_no:
raise HTTPException(status_code=409, detail="支付宝已显示成功但流水号暂不可用,请稍后再次查询")
order.status = "success"
order.alipay_trade_no = trade_no
order.error_message = ""
recipient = db.scalar(select(RefundRecipient).where(
RefundRecipient.target_kind == kind, RefundRecipient.target_id == record.id,
))
if recipient:
recipient.amount_fen = order.amount_fen
return trade_no
if provider_status in {"FAIL", "REFUND"}:
order.status = "failed"
order.error_message = str(result.get("fail_reason") or "支付宝确认转账失败")[:500]
db.commit()
raise HTTPException(status_code=409, detail="支付宝已确认本次转账失败;确认原因后可再次提交")
order.error_message = "支付宝转账仍在处理中,请稍后重新查询"
db.commit()
raise HTTPException(status_code=409, detail=order.error_message)
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
order = AlipayRefundOrder(
target_kind=kind,
target_id=record.id,
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 "",
owner_id=task.owner_id,
operator_name=user.display_name,
)
db.add(order)
order.out_biz_no = out_biz_no
order.amount_fen = amount_fen
order.account = identity["alipay_account"]
order.payee_name = identity["payee_name"]
order.payer_account = payer.name
order.created_by_id = user.id
order.operator_name = user.display_name
order.status = "pending"
order.error_code = ""
order.error_message = ""
db.commit()
try:
from .alipay_provider import transfer
result = transfer(
out_biz_no=out_biz_no,
amount_fen=amount_fen,
account=identity["alipay_account"],
payee_name=identity["payee_name"],
order_title=f"问大家任务返款-{record.id}",
)
except AlipayTransferError as exc:
order = db.get(AlipayRefundOrder, order.id)
# A timeout/network exception is ambiguous: the provider may already
# have accepted the transfer, so keep it pending for query/reconcile.
order.status = "pending" if exc.ambiguous else "failed"
order.error_code = exc.code[:128]
order.error_message = str(exc)[:500]
db.commit()
detail = _alipay_error_detail(exc)
raise HTTPException(status_code=502, detail=detail) from exc
trade_no = str(result.get("trade_no") or "").strip()
order = db.get(AlipayRefundOrder, order.id)
provider_status = str(result.get("status") or "").upper()
if provider_status == "SUCCESS" and trade_no:
order.status = "success"
order.alipay_trade_no = trade_no
return trade_no
if provider_status in {"FAIL", "REFUND"}:
order.status = "failed"
order.error_message = "支付宝确认转账失败"
db.commit()
raise HTTPException(status_code=502, detail="支付宝确认转账失败,请核对收款信息后重试")
order.error_message = "支付宝已受理但尚未确认成功,请稍后查询订单状态"
db.commit()
raise HTTPException(status_code=409, detail=order.error_message)
@app.post("/alipay_callback", response_class=Response)
async def alipay_callback(request: Request, db: Session = Depends(get_db)):
"""Receive and verify Alipay's asynchronous transfer notification.
The callback only reconciles the provider order. The normal confirmation
request (or its retry) remains responsible for creating the immutable
refund record and changing the task state, which keeps authorization and
task transitions in one place.
"""
form = await request.form()
params = {str(key): str(value) for key, value in form.multi_items()}
from .alipay_provider import verify_notification
if not verify_notification(params):
return Response(content="failure", media_type="text/plain", status_code=400)
out_biz_no = params.get("out_biz_no", "").strip()
if not out_biz_no:
return Response(content="failure", media_type="text/plain", status_code=400)
order = db.scalar(select(AlipayRefundOrder).where(AlipayRefundOrder.out_biz_no == out_biz_no).with_for_update())
if not order:
# Returning failure asks Alipay to retry; the local transaction may
# not yet be visible if notification beats the originating request.
return Response(content="failure", media_type="text/plain", status_code=404)
provider_status = str(params.get("status") or params.get("sub_status") or "").upper()
if provider_status == "SUCCESS":
trade_no = str(params.get("pay_fund_order_id") or params.get("order_id") or "").strip()
if not trade_no:
return Response(content="failure", media_type="text/plain", status_code=400)
order.status = "success"
order.alipay_trade_no = trade_no
order.error_code = ""
order.error_message = ""
elif provider_status in {"FAIL", "REFUND"}:
order.status = "failed"
order.error_code = str(params.get("error_code") or params.get("sub_order_error_code") or "")[:128]
order.error_message = str(params.get("fail_reason") or params.get("sub_order_fail_reason") or "支付宝确认转账失败")[:500]
else:
order.status = "pending"
db.commit()
return Response(content="success", media_type="text/plain")
@app.post("/api/admin/task-board/ask-batches/{batch_id}/confirm-refund") @app.post("/api/admin/task-board/ask-batches/{batch_id}/confirm-refund")
async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile | None = File(None), upload_token: str | None = Form(None), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): async def confirm_ask_batch_refund(batch_id: int, refund_amount: str | None = Form(None), payer_account_id: int = Form(...), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
batch = db.get(AskAssignmentBatch, batch_id) batch = db.get(AskAssignmentBatch, batch_id, with_for_update=True)
if not batch or not _can_access_ask_task(batch.assignment.task, user): if not batch or not _can_access_ask_task(batch.assignment.task, user):
raise HTTPException(status_code=404, detail="待返款提问记录不存在") raise HTTPException(status_code=404, detail="待返款提问记录不存在")
if batch.status == "paid": if batch.status == "paid":
raise HTTPException(status_code=409, detail="该任务已经完成返款") raise HTTPException(status_code=409, detail="该任务已经完成返款")
if batch.status != "approved": if batch.status != "approved":
raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款")
if refund_file is not None: payer = _authorized_refund_payer(db, user, payer_account_id)
refund_data, content_type, filename = await _read_refund_file(refund_file) _save_refund_amount(db, batch, "ask_batch", refund_amount, snapshot=True)
for image in list(batch.screenshots): alipay_trade_no = _perform_alipay_refund(db, batch, "ask_batch", user, payer)
if image.stage == "refund": _snapshot_refund(db, batch, "ask_batch", user, payer.name, alipay_trade_no)
db.delete(image)
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:
session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == upload_token, RefundUploadSession.target_kind == "ask_batch", RefundUploadSession.target_id == batch.id))
if not session or _refund_session_expired(session) or session.used_at:
raise HTTPException(status_code=409, detail="扫码上传会话无效或已过期,请重新生成")
session.used_at = datetime.utcnow()
batch.status = "paid" batch.status = "paid"
batch.updated_at = datetime.utcnow() batch.updated_at = datetime.utcnow()
_sync_ask_task_status(batch.assignment.task) _sync_ask_task_status(batch.assignment.task)
@ -3448,30 +3844,18 @@ async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile | None
@app.post("/api/admin/task-board/ask-assignments/{assignment_id}/confirm-refund") @app.post("/api/admin/task-board/ask-assignments/{assignment_id}/confirm-refund")
async def confirm_ask_assignment_refund(assignment_id: int, refund_file: UploadFile | None = File(None), upload_token: str | None = Form(None), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): async def confirm_ask_assignment_refund(assignment_id: int, refund_amount: str | None = Form(None), payer_account_id: int = Form(...), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
assignment = db.get(AskAssignment, assignment_id) assignment = db.get(AskAssignment, assignment_id, with_for_update=True)
if not assignment or not _can_access_ask_task(assignment.task, user): if not assignment or not _can_access_ask_task(assignment.task, user):
raise HTTPException(status_code=404, detail="待返款提问记录不存在") raise HTTPException(status_code=404, detail="待返款提问记录不存在")
if assignment.status == "已完成": if assignment.status == "已完成":
raise HTTPException(status_code=409, detail="该任务已经完成返款") raise HTTPException(status_code=409, detail="该任务已经完成返款")
if assignment.status != "已通过": if assignment.status != "已通过":
raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款")
if refund_file is not None: payer = _authorized_refund_payer(db, user, payer_account_id)
refund_data, content_type, filename = await _read_refund_file(refund_file) _save_refund_amount(db, assignment, "ask_assignment", refund_amount, snapshot=True)
for image in list(assignment.screenshots): alipay_trade_no = _perform_alipay_refund(db, assignment, "ask_assignment", user, payer)
if image.stage == "refund": _snapshot_refund(db, assignment, "ask_assignment", user, payer.name, alipay_trade_no)
db.delete(image)
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:
session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == upload_token, RefundUploadSession.target_kind == "ask_assignment", RefundUploadSession.target_id == assignment.id))
if not session or _refund_session_expired(session) or session.used_at:
raise HTTPException(status_code=409, detail="扫码上传会话无效或已过期,请重新生成")
session.used_at = datetime.utcnow()
assignment.status = "已完成" assignment.status = "已完成"
assignment.updated_at = datetime.utcnow() assignment.updated_at = datetime.utcnow()
_sync_ask_task_status(assignment.task) _sync_ask_task_status(assignment.task)
@ -3508,30 +3892,18 @@ def admin_task_board_delete_submission(submission_id: int, user: AdminUser = Dep
@app.post("/api/admin/task-board/{submission_id}/confirm-refund") @app.post("/api/admin/task-board/{submission_id}/confirm-refund")
async def confirm_answer_refund(submission_id: int, refund_file: UploadFile | None = File(None), upload_token: str | None = Form(None), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)): async def confirm_answer_refund(submission_id: int, refund_amount: str | None = Form(None), payer_account_id: int = Form(...), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
record = db.get(AnswerSubmission, submission_id) record = db.get(AnswerSubmission, submission_id, with_for_update=True)
if not record or not _can_access_answer_submission(record, user): if not record or not _can_access_answer_submission(record, user):
raise HTTPException(status_code=404, detail="待返款回答记录不存在") raise HTTPException(status_code=404, detail="待返款回答记录不存在")
if record.status == "已完成": if record.status == "已完成":
raise HTTPException(status_code=409, detail="该任务已经完成返款") raise HTTPException(status_code=409, detail="该任务已经完成返款")
if record.status != "已通过": if record.status != "已通过":
raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款") raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款")
if refund_file is not None: payer = _authorized_refund_payer(db, user, payer_account_id)
refund_data, content_type, filename = await _read_refund_file(refund_file) _save_refund_amount(db, record, "answer_submission", refund_amount, snapshot=True)
for image in list(record.screenshots): alipay_trade_no = _perform_alipay_refund(db, record, "answer_submission", user, payer)
if image.stage == "refund": _snapshot_refund(db, record, "answer_submission", user, payer.name, alipay_trade_no)
db.delete(image)
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:
session = db.scalar(select(RefundUploadSession).where(RefundUploadSession.token == upload_token, RefundUploadSession.target_kind == "answer_submission", RefundUploadSession.target_id == record.id))
if not session or _refund_session_expired(session) or session.used_at:
raise HTTPException(status_code=409, detail="扫码上传会话无效或已过期,请重新生成")
session.used_at = datetime.utcnow()
record.status = "已完成" record.status = "已完成"
db.commit(); db.refresh(record) db.commit(); db.refresh(record)
return {"success": True, "data": _task_board_data(record, include_images=True, image_endpoints=True)} return {"success": True, "data": _task_board_data(record, include_images=True, image_endpoints=True)}
@ -3561,7 +3933,7 @@ async def answer_completion(submission_id: int, token: str = Form(...), owner_to
record_status = record.status record_status = record.status
if record_status not in ("待用户完成", "未通过"): raise HTTPException(status_code=400, 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 not files or len(files) > 10: raise HTTPException(status_code=400, detail="最多上传10张截图")
if not payment_files or len(payment_files) > 1: raise HTTPException(status_code=400, detail="请上传收款二维码后再提交") if len(payment_files) > 1: raise HTTPException(status_code=400, detail="最多上传一张收款二维码")
db.rollback() db.rollback()
uploaded = [] uploaded = []
for upload in files: for upload in files:
@ -4653,9 +5025,92 @@ 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]} 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:
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,
}
@app.get("/api/admin/refund-payment-accounts/available")
def available_refund_payment_accounts(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
statement = select(RefundPaymentAccount).where(RefundPaymentAccount.is_active.is_(True))
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]}
@app.get("/api/admin/refund-payment-accounts")
def refund_payment_accounts(q: str = "", page: int = 1, page_size: int = 10,
_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
page = max(1, page)
page_size = max(1, min(page_size, 100))
statement = select(RefundPaymentAccount)
if q.strip():
term = q.strip()[:128]
statement = statement.where(or_(RefundPaymentAccount.name.contains(term, autoescape=True),
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],
"total": total, "page": page, "page_size": page_size}}
def _save_payment_account(db: Session, account: RefundPaymentAccount, payload: RefundPaymentAccountSave):
account.name = payload.name
account.alipay_account = payload.alipay_account
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.commit()
except IntegrityError:
db.rollback()
raise HTTPException(status_code=409, detail="该支付宝账户已存在,请编辑现有账户")
db.refresh(account)
return {"success": True, "data": _payment_account_data(account)}
@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)
@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)
if account is None:
raise HTTPException(status_code=404, detail="付款账户不存在")
return _save_payment_account(db, account, payload)
@app.delete("/api/admin/refund-payment-accounts/{account_id}")
def delete_refund_payment_account(account_id: int,
_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
account = db.get(RefundPaymentAccount, account_id)
if account is None:
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.commit()
return {"success": True}
@app.get("/api/admin/users") @app.get("/api/admin/users")
def users(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): def users(current: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
records = db.scalars(select(AdminUser).order_by(AdminUser.id.asc())).all() statement = select(AdminUser)
if current.role_code != "super_admin":
if current.role_code != "dept_manager":
raise HTTPException(status_code=403, detail="无权管理用户")
statement = statement.where(AdminUser.role_code == "operator", _authorization_department_scope(current))
records = db.scalars(statement.order_by(AdminUser.id.asc())).all()
accounts = {account.id: account.name for account in db.scalars(select(RefundPaymentAccount)).all()}
return { return {
"success": True, "success": True,
"data": [ "data": [
@ -4664,6 +5119,8 @@ def users(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get
"username": user.username, "username": user.username,
"display_name": user.display_name, "display_name": user.display_name,
"role_code": user.role_code, "role_code": user.role_code,
"refund_payment_account_id": user.refund_payment_account_id,
"refund_payment_account_name": accounts.get(user.refund_payment_account_id),
"role_label": ROLE_LABELS.get(user.role_code, user.role_code), "role_label": ROLE_LABELS.get(user.role_code, user.role_code),
"dingtalk_dept_id": user.dingtalk_dept_id, "dingtalk_dept_id": user.dingtalk_dept_id,
"dingtalk_dept_name": user.dingtalk_dept_name, "dingtalk_dept_name": user.dingtalk_dept_name,
@ -4676,6 +5133,33 @@ def users(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get
} }
def _authorization_department_scope(manager: AdminUser):
if not manager.dingtalk_dept_id:
return literal(False)
return or_(AdminUser.dingtalk_dept_id == manager.dingtalk_dept_id,
AdminUser.dingtalk_dept_path.contains(f",{manager.dingtalk_dept_id},", autoescape=True))
@app.patch("/api/admin/users/{user_id}/payment-account")
def authorize_payment_account(user_id: int, payload: PaymentAccountAuthorization,
current: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
if current.role_code not in {"super_admin", "dept_manager"}:
raise HTTPException(status_code=403, detail="只有主管或超级管理员可以授权付款账户")
statement = select(AdminUser).where(AdminUser.id == user_id)
if current.role_code == "dept_manager":
statement = statement.where(_authorization_department_scope(current))
target = db.scalar(statement.with_for_update())
if not target or target.role_code != "operator":
raise HTTPException(status_code=403, detail="只能为权限范围内的普通运营授权付款账户")
if payload.account_id is not None:
account = db.get(RefundPaymentAccount, payload.account_id, with_for_update=True)
if not account or not account.is_active:
raise HTTPException(status_code=400, detail="请选择有效且已启用的付款账户")
target.refund_payment_account_id = payload.account_id
db.commit()
return {"success": True}
@app.get("/api/admin/users/{user_id}") @app.get("/api/admin/users/{user_id}")
def user_detail(user_id: int, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): def user_detail(user_id: int, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
user = db.get(AdminUser, user_id) user = db.get(AdminUser, user_id)
@ -4695,6 +5179,66 @@ def create_user(payload: AdminUserCreate, _: AdminUser = Depends(require_super_a
return {"success": True, "data": {"user": public_user(user)}} return {"success": True, "data": {"user": public_user(user)}}
@app.post("/api/admin/users/dingtalk/lookup")
def lookup_dingtalk_user(payload: AdminDingTalkUserLookup, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
profile = _dingtalk_lookup_user(payload.identifier, db)
return {"success": True, "data": _dingtalk_user_preview(profile)}
@app.post("/api/admin/users/dingtalk", status_code=status.HTTP_201_CREATED)
def create_dingtalk_user(payload: AdminDingTalkUserCreate, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
profile = _dingtalk_lookup_user(payload.identifier, db)
preview = _dingtalk_user_preview(profile)
user_id = preview["user_id"]
union_id = preview["union_id"]
if not user_id and not union_id:
raise HTTPException(status_code=502, detail="钉钉员工信息缺少 UserId/UnionId")
conditions = []
if user_id:
conditions.append(AdminUser.dingtalk_user_id == user_id)
if union_id:
conditions.append(AdminUser.dingtalk_union_id == union_id)
username = f"dingtalk_{union_id or user_id}"[:64]
conditions.append(AdminUser.username == username)
user = db.scalar(select(AdminUser).where(or_(*conditions)))
role_code = payload.role_code or preview["suggested_role"]
if user and user.dingtalk_user_id and user_id and user.dingtalk_user_id != user_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该钉钉身份已绑定其他后台账号")
if user and user.dingtalk_union_id and union_id and user.dingtalk_union_id != union_id:
raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="该钉钉身份已绑定其他后台账号")
if not user:
user = AdminUser(
username=username,
password_hash=hash_password(secrets.token_urlsafe(32)),
display_name=preview["display_name"],
role_code=role_code,
dingtalk_user_id=user_id,
dingtalk_union_id=union_id,
dingtalk_corp_id=settings.dingtalk_corp_id or None,
dingtalk_dept_id=preview["department_id"],
dingtalk_dept_name=preview["department_name"],
dingtalk_dept_path=str(profile.get("_dept_path") or "").strip() or None,
dingtalk_title=preview["title"],
is_dept_manager=preview["is_dept_manager"],
)
db.add(user)
else:
user.display_name = preview["display_name"]
user.role_code = role_code
user.is_active = True
user.dingtalk_user_id = user_id or user.dingtalk_user_id
user.dingtalk_union_id = union_id or user.dingtalk_union_id
user.dingtalk_corp_id = settings.dingtalk_corp_id or user.dingtalk_corp_id
user.dingtalk_dept_id = preview["department_id"]
user.dingtalk_dept_name = preview["department_name"]
user.dingtalk_dept_path = str(profile.get("_dept_path") or "").strip() or user.dingtalk_dept_path
user.dingtalk_title = preview["title"]
user.is_dept_manager = preview["is_dept_manager"]
db.commit()
db.refresh(user)
return {"success": True, "data": {"user": public_user(user), "profile": preview}}
@app.patch("/api/admin/users/{user_id}") @app.patch("/api/admin/users/{user_id}")
def update_user(user_id: int, payload: AdminUserUpdate, current: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)): def update_user(user_id: int, payload: AdminUserUpdate, current: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
user = db.get(AdminUser, user_id) user = db.get(AdminUser, user_id)

View File

@ -7,6 +7,64 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
from .database import Base from .database import Base
class RefundPaymentAccount(Base):
__tablename__ = "refund_payment_accounts"
id: Mapped[int] = mapped_column(Integer, primary_key=True)
name: Mapped[str] = mapped_column(String(128))
alipay_account: Mapped[str] = mapped_column(String(128), unique=True)
balance_fen: Mapped[int] = mapped_column(Integer, default=0)
single_limit_fen: Mapped[int] = mapped_column(Integer)
is_active: Mapped[bool] = mapped_column(Boolean, default=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class RefundRecord(Base):
"""Immutable confirmation snapshot; deliberately independent of task deletion."""
__tablename__ = "refund_records"
__table_args__ = (UniqueConstraint("target_kind", "target_id", name="uq_refund_record_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)
owner_id: Mapped[int | None] = mapped_column(Integer, nullable=True, index=True)
shop_name: Mapped[str] = mapped_column(String(255), 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: 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="")
transferred_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, index=True)
class AlipayRefundOrder(Base):
"""Idempotency and provider status record for a real Alipay transfer."""
__tablename__ = "alipay_transfer_attempts"
__table_args__ = (UniqueConstraint("target_kind", "target_id", name="uq_alipay_transfer_attempt_target"),)
id: Mapped[int] = mapped_column(Integer, primary_key=True)
target_kind: Mapped[str] = mapped_column(String(32), index=True)
target_id: Mapped[int] = mapped_column(Integer, index=True)
out_biz_no: Mapped[str] = mapped_column(String(64), unique=True, index=True)
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="")
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: 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="")
status: Mapped[str] = mapped_column(String(24), default="pending", index=True)
alipay_trade_no: Mapped[str] = mapped_column(String(128), default="")
error_code: Mapped[str] = mapped_column(String(128), default="")
error_message: Mapped[str] = mapped_column(String(500), default="")
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
updated_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
class RefundRecipient(Base): class RefundRecipient(Base):
__tablename__ = "refund_recipients" __tablename__ = "refund_recipients"
__table_args__ = (UniqueConstraint("target_kind", "target_id", name="uq_refund_recipient_target"),) __table_args__ = (UniqueConstraint("target_kind", "target_id", name="uq_refund_recipient_target"),)
@ -15,20 +73,7 @@ class RefundRecipient(Base):
target_id: Mapped[int] = mapped_column(Integer) target_id: Mapped[int] = mapped_column(Integer)
account: Mapped[str] = mapped_column(String(128)) account: Mapped[str] = mapped_column(String(128))
payee_name: Mapped[str] = mapped_column(String(64)) payee_name: Mapped[str] = mapped_column(String(64))
amount_fen: Mapped[int | None] = mapped_column(Integer, nullable=True)
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): class AdminUser(Base):
@ -39,6 +84,7 @@ class AdminUser(Base):
password_hash: Mapped[str] = mapped_column(String(255)) password_hash: Mapped[str] = mapped_column(String(255))
display_name: Mapped[str] = mapped_column(String(64)) display_name: Mapped[str] = mapped_column(String(64))
role_code: Mapped[str] = mapped_column(String(32), default="operator", index=True) role_code: Mapped[str] = mapped_column(String(32), default="operator", index=True)
refund_payment_account_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
dingtalk_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) dingtalk_user_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
dingtalk_union_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) dingtalk_union_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)
dingtalk_corp_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True) dingtalk_corp_id: Mapped[str | None] = mapped_column(String(128), nullable=True, index=True)

View File

@ -1,4 +1,19 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from pydantic import field_validator
from decimal import Decimal
class RefundPaymentAccountSave(BaseModel):
name: str = Field(min_length=1, max_length=128)
alipay_account: str = Field(min_length=1, max_length=128)
balance: Decimal = Field(ge=0, le=9999999.99, decimal_places=2, allow_inf_nan=False)
single_limit: Decimal = Field(gt=0, le=9999999.99, decimal_places=2, allow_inf_nan=False)
is_active: bool = True
@field_validator("name", "alipay_account", mode="before")
@classmethod
def strip_text(cls, value):
return value.strip() if isinstance(value, str) else value
class LoginRequest(BaseModel): class LoginRequest(BaseModel):
@ -6,6 +21,10 @@ class LoginRequest(BaseModel):
password: str = Field(min_length=1, max_length=128) password: str = Field(min_length=1, max_length=128)
class PaymentAccountAuthorization(BaseModel):
account_id: int | None = Field(..., gt=0, strict=True)
class RegisterRequest(BaseModel): class RegisterRequest(BaseModel):
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$") username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
password: str = Field(min_length=8, max_length=128) password: str = Field(min_length=8, max_length=128)
@ -19,6 +38,15 @@ class AdminUserCreate(BaseModel):
role_code: str = Field(default="operator", pattern=r"^(super_admin|dept_manager|operator)$") role_code: str = Field(default="operator", pattern=r"^(super_admin|dept_manager|operator)$")
class AdminDingTalkUserLookup(BaseModel):
identifier: str = Field(min_length=1, max_length=128)
class AdminDingTalkUserCreate(BaseModel):
identifier: str = Field(min_length=1, max_length=128)
role_code: str | None = Field(default=None, pattern=r"^(super_admin|dept_manager|operator)$")
class AdminUserUpdate(BaseModel): class AdminUserUpdate(BaseModel):
password: str | None = Field(default=None, min_length=8, max_length=128) password: str | None = Field(default=None, min_length=8, max_length=128)
display_name: str | None = Field(default=None, min_length=1, max_length=64) display_name: str | None = Field(default=None, min_length=1, max_length=64)

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -5,5 +5,8 @@ pymysql>=1.1,<2.0
python-multipart>=0.0.20,<1.0 python-multipart>=0.0.20,<1.0
httpx2>=2.12,<3.0 httpx2>=2.12,<3.0
python-dotenv>=1.0,<2.0 python-dotenv>=1.0,<2.0
alipay-sdk-python>=3.7,<4.0
pycryptodome>=3.20,<5.0
cryptography>=43,<47
openpyxl>=3.1,<4.0 openpyxl>=3.1,<4.0
pillow>=11.0,<13.0 pillow>=11.0,<13.0

View File

@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS admin_users (
password_hash VARCHAR(255) NOT NULL, password_hash VARCHAR(255) NOT NULL,
display_name VARCHAR(64) NOT NULL, display_name VARCHAR(64) NOT NULL,
role_code VARCHAR(32) NOT NULL DEFAULT 'operator', role_code VARCHAR(32) NOT NULL DEFAULT 'operator',
refund_payment_account_id INTEGER NULL,
answer_public_token VARCHAR(96) NULL, answer_public_token VARCHAR(96) NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1, is_active TINYINT(1) NOT NULL DEFAULT 1,
last_login_at DATETIME NULL, last_login_at DATETIME NULL,
@ -25,6 +26,7 @@ CREATE TABLE IF NOT EXISTS refund_recipients (
target_id BIGINT NOT NULL, target_id BIGINT NOT NULL,
account VARCHAR(128) NOT NULL, account VARCHAR(128) NOT NULL,
payee_name VARCHAR(64) NOT NULL, payee_name VARCHAR(64) NOT NULL,
amount_fen INTEGER NULL,
PRIMARY KEY (id), PRIMARY KEY (id),
UNIQUE KEY uk_refund_recipient_target (target_kind, target_id) UNIQUE KEY uk_refund_recipient_target (target_kind, target_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
@ -44,6 +46,33 @@ CREATE TABLE IF NOT EXISTS alipay_refund_orders (
KEY idx_alipay_refund_orders_status (status) KEY idx_alipay_refund_orders_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS alipay_transfer_attempts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
target_kind VARCHAR(32) NOT NULL,
target_id BIGINT NOT NULL,
out_biz_no VARCHAR(64) NOT NULL,
amount_fen INT NOT NULL,
order_no VARCHAR(64) NOT NULL DEFAULT '',
shop_name VARCHAR(255) NOT NULL DEFAULT '',
owner_id BIGINT NULL,
account VARCHAR(128) NOT NULL,
payee_name VARCHAR(64) NOT NULL,
payer_account VARCHAR(128) NOT NULL DEFAULT '',
created_by_id BIGINT NOT NULL,
operator_name VARCHAR(64) NOT NULL DEFAULT '',
status VARCHAR(24) NOT NULL DEFAULT 'pending',
alipay_trade_no VARCHAR(128) NOT NULL DEFAULT '',
error_code VARCHAR(128) NOT NULL DEFAULT '',
error_message VARCHAR(500) NOT NULL DEFAULT '',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NULL,
PRIMARY KEY (id),
UNIQUE KEY uk_alipay_transfer_attempt_out_biz_no (out_biz_no),
UNIQUE KEY uq_alipay_transfer_attempt_target (target_kind, target_id),
KEY idx_alipay_transfer_attempt_target (target_kind, target_id),
KEY idx_alipay_transfer_attempt_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS admin_login_logs ( CREATE TABLE IF NOT EXISTS admin_login_logs (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
user_id BIGINT UNSIGNED NULL, user_id BIGINT UNSIGNED NULL,
@ -94,6 +123,37 @@ CREATE TABLE IF NOT EXISTS qa_bulk_generation_jobs (
KEY idx_qa_bulk_generation_jobs_status (status) KEY idx_qa_bulk_generation_jobs_status (status)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS refund_records (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
target_kind VARCHAR(32) NOT NULL,
target_id INT NOT NULL,
owner_id INT NULL,
shop_name VARCHAR(255) NOT NULL DEFAULT '',
order_no VARCHAR(64) NOT NULL,
payee_name VARCHAR(64) NOT NULL DEFAULT '',
payee_account VARCHAR(128) NOT NULL DEFAULT '',
amount_fen INT NOT NULL,
payer_account VARCHAR(128) NOT NULL DEFAULT '',
operator_name VARCHAR(64) NOT NULL,
alipay_trade_no VARCHAR(128) NOT NULL DEFAULT '',
transferred_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE KEY uq_refund_record_target (target_kind, target_id),
KEY ix_refund_records_owner_id (owner_id),
KEY ix_refund_records_order_no (order_no),
KEY ix_refund_records_transferred_at (transferred_at)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS refund_payment_accounts (
id INT NOT NULL AUTO_INCREMENT PRIMARY KEY,
name VARCHAR(128) NOT NULL,
alipay_account VARCHAR(128) NOT NULL UNIQUE,
balance_fen INT NOT NULL DEFAULT 0,
single_limit_fen INT NOT NULL,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
CREATE TABLE IF NOT EXISTS qa_bulk_generation_items ( CREATE TABLE IF NOT EXISTS qa_bulk_generation_items (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
job_id BIGINT UNSIGNED NOT NULL, job_id BIGINT UNSIGNED NOT NULL,

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -13,3 +13,6 @@ os.environ["ADMIN_USERNAME"] = "admin"
os.environ["ADMIN_PASSWORD"] = "admin123456" os.environ["ADMIN_PASSWORD"] = "admin123456"
os.environ["OPERATOR_USERNAME"] = "operator" os.environ["OPERATOR_USERNAME"] = "operator"
os.environ["OPERATOR_PASSWORD"] = "operator123456" os.environ["OPERATOR_PASSWORD"] = "operator123456"
# Integration tests must never call the real payout API or inherit the
# developer machine's real payer-account restriction.
os.environ["ALIPAY_ENABLED"] = "false"

View File

@ -0,0 +1,40 @@
from uuid import uuid4
from fastapi.testclient import TestClient
import app.main as main
from app import alipay_provider
def test_alipay_callback_reconciles_verified_success(monkeypatch):
monkeypatch.setattr(alipay_provider, "verify_notification", lambda params: params.get("sign") == "valid")
out_biz_no = f"RF{uuid4().hex[:20]}"
with TestClient(main.app) as client:
with main.SessionLocal() as db:
user = db.scalar(main.select(main.AdminUser).where(main.AdminUser.username == "operator"))
order = main.AlipayRefundOrder(
target_kind="answer_submission", target_id=int(uuid4().int % 1_000_000_000),
out_biz_no=out_biz_no, amount_fen=300, account="buyer@example.com",
payee_name="测试收款人", created_by_id=user.id, status="pending",
)
db.add(order)
db.commit()
order_id = order.id
response = client.post("/alipay_callback", data={
"sign": "valid", "out_biz_no": out_biz_no, "status": "SUCCESS",
"order_id": "ALI-CALLBACK-001",
})
assert response.status_code == 200
assert response.text == "success"
with main.SessionLocal() as db:
order = db.get(main.AlipayRefundOrder, order_id)
assert order.status == "success"
assert order.alipay_trade_no == "ALI-CALLBACK-001"
def test_alipay_callback_rejects_invalid_signature(monkeypatch):
monkeypatch.setattr(alipay_provider, "verify_notification", lambda _params: False)
with TestClient(main.app) as client:
response = client.post("/alipay_callback", data={"sign": "bad", "out_biz_no": "RF-BAD"})
assert response.status_code == 400
assert response.text == "failure"

View File

@ -0,0 +1,151 @@
import json
import pytest
from hashlib import md5
from sqlalchemy import create_engine, inspect, text
from app import alipay_provider
from app.main import _migrate_alipay_transfer_attempts
class FakeClient:
def __init__(self, payload):
self.payload = payload
def execute(self, _request):
return json.dumps(self.payload, ensure_ascii=False)
def test_transfer_parses_official_sdk_json_response(monkeypatch):
monkeypatch.setattr(alipay_provider, "_client", lambda: FakeClient({
"code": "10000",
"msg": "Success",
"out_biz_no": "RF001",
"order_id": "ALI001",
"status": "SUCCESS",
}))
result = alipay_provider.transfer(
out_biz_no="RF001", amount_fen=300, account="buyer@example.com",
payee_name="测试收款人", order_title="测试返款",
)
assert result == {"out_biz_no": "RF001", "trade_no": "ALI001", "status": "SUCCESS"}
def test_transfer_preserves_provider_business_error(monkeypatch):
monkeypatch.setattr(alipay_provider, "_client", lambda: FakeClient({
"code": "40004",
"msg": "Business Failed",
"sub_code": "PAYEE_NOT_EXIST",
"sub_msg": "收款账户不存在",
}))
with pytest.raises(alipay_provider.AlipayTransferError) as error:
alipay_provider.transfer(
out_biz_no="RF002", amount_fen=300, account="missing@example.com",
payee_name="测试收款人", order_title="测试返款",
)
assert error.value.code == "PAYEE_NOT_EXIST"
assert error.value.ambiguous is False
def test_old_sdk_error_envelope_is_recovered_from_response_exception():
raw = (
'[request-id]response shape maybe illegal. '
'{"alipay_fund_trans_common_query_response":{'
'"code":"40006","msg":"Insufficient Permissions",'
'"sub_code":"isv.illegal-client-ip",'
'"sub_msg":"当前访问IP不在开放平台设置"},'
'"alipay_cert_sn":"abc","sign":"signature"}'
)
error = alipay_provider._provider_error_from_exception(RuntimeError(raw))
assert error is not None
assert error.code == "isv.illegal-client-ip"
assert str(error) == "当前访问IP不在开放平台设置"
assert error.ambiguous is False
def test_query_parses_processing_status(monkeypatch):
monkeypatch.setattr(alipay_provider, "_client", lambda: FakeClient({
"code": "10000",
"msg": "Success",
"out_biz_no": "RF003",
"order_id": "ALI003",
"status": "DEALING",
}))
result = alipay_provider.query(out_biz_no="RF003")
assert result["status"] == "DEALING"
assert result["trade_no"] == "ALI003"
def test_legacy_transfer_table_is_migrated_without_losing_rows():
engine = create_engine("sqlite:///:memory:")
with engine.begin() as connection:
connection.execute(text("""
CREATE TABLE alipay_transfer_attempts (
id INTEGER PRIMARY KEY,
target_kind VARCHAR(32) NOT NULL,
target_id INTEGER NOT NULL,
amount_fen INTEGER NOT NULL,
account VARCHAR(128) NOT NULL,
payee_name VARCHAR(64) NOT NULL,
created_by_id INTEGER NOT NULL,
status VARCHAR(32) NOT NULL DEFAULT 'pending',
created_at DATETIME
)
"""))
connection.execute(text("""
INSERT INTO alipay_transfer_attempts
(id, target_kind, target_id, amount_fen, account, payee_name, created_by_id)
VALUES (1, 'answer_submission', 42, 100, 'buyer@example.com', '测试收款人', 7)
"""))
_migrate_alipay_transfer_attempts(engine)
columns = {column["name"] for column in inspect(engine).get_columns("alipay_transfer_attempts")}
assert {"order_no", "shop_name", "payer_account", "operator_name", "error_message", "out_biz_no"} <= columns
with engine.connect() as connection:
row = connection.execute(text("SELECT * FROM alipay_transfer_attempts WHERE id = 1")).mappings().one()
assert row["amount_fen"] == 100
assert row["out_biz_no"].startswith("LEGACY-1-")
assert row["status"] == "failed"
assert "旧版本记录" in row["error_message"]
engine.dispose()
def test_certificate_serials_use_issuer_and_decimal_serial_number():
class FakeName:
def rfc4514_string(self):
return "CN=Alipay,O=Example,C=CN"
class FakeCertificate:
issuer = FakeName()
serial_number = 0xA12B
class SignatureAlgorithmOid:
dotted_string = "1.2.840.113549.1.1.11"
signature_algorithm_oid = SignatureAlgorithmOid()
expected = md5(b"CN=Alipay,O=Example,C=CN41259").hexdigest()
assert alipay_provider._certificate_serial(FakeCertificate()) == expected
assert alipay_provider._root_certificate_serial([FakeCertificate(), FakeCertificate()]) == f"{expected}_{expected}"
def test_root_certificate_serial_ignores_non_rsa_certificates():
class FakeName:
def rfc4514_string(self):
return "CN=Alipay,O=Example,C=CN"
class FakeCertificate:
issuer = FakeName()
serial_number = 0xA12B
def __init__(self, oid):
self.signature_algorithm_oid = type("Oid", (), {"dotted_string": oid})()
rsa = FakeCertificate("1.2.840.113549.1.1.11")
sm2 = FakeCertificate("1.2.156.10197.1.501")
ecc = FakeCertificate("1.2.840.10045.4.3.3")
expected = md5(b"CN=Alipay,O=Example,C=CN41259").hexdigest()
assert alipay_provider._root_certificate_serial([sm2, rsa, ecc]) == expected

View File

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

View File

@ -35,6 +35,22 @@ def task_board_items(client: TestClient, **params):
] ]
def authorize_test_payer(username: str = "operator") -> int:
"""Create and authorize a payment account for the current refund flow."""
with main_module.SessionLocal() as db:
user = db.scalar(main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == username))
suffix = uuid4().hex[:12]
payer = main_module.RefundPaymentAccount(
name=f"测试付款账户-{suffix}", alipay_account=f"payer-{suffix}@example.com",
single_limit_fen=50000,
)
db.add(payer)
db.flush()
user.refund_payment_account_id = payer.id
db.commit()
return payer.id
def test_admin_login_me_and_logout(): def test_admin_login_me_and_logout():
with TestClient(app) as client: with TestClient(app) as client:
login = client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"}) login = client.post("/api/admin/auth/login", json={"username": "admin", "password": "admin123456"})
@ -1149,6 +1165,7 @@ def test_ask_question_bulk_import_appends_and_deduplicates(monkeypatch):
def test_public_ask_user_gets_stable_unique_five_question_assignment(): def test_public_ask_user_gets_stable_unique_five_question_assignment():
with TestClient(app) as client: with TestClient(app) as client:
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
payer_id = authorize_test_payer()
generated = client.post( generated = client.post(
"/api/admin/ask-tasks/generate", "/api/admin/ask-tasks/generate",
json={ json={
@ -1239,7 +1256,7 @@ def test_public_ask_user_gets_stable_unique_five_question_assignment():
assert client.get(payment_image_url).content == b"payment-image" assert client.get(payment_image_url).content == b"payment-image"
refunded = client.post( refunded = client.post(
payout["refund_endpoint"], payout["refund_endpoint"],
files={"refund_file": ("refund.png", b"refund-image", "image/png")}, data={"payer_account_id": payer_id},
) )
assert refunded.status_code == 200 assert refunded.status_code == 200
assert refunded.json()["data"]["status_label"] == "已完成" assert refunded.json()["data"]["status_label"] == "已完成"
@ -1284,6 +1301,7 @@ def test_approved_answer_appears_as_pending_refund_with_detail(monkeypatch):
question_id = created["questions"][0]["id"] question_id = created["questions"][0]["id"]
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200 assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
payer_id = authorize_test_payer()
assert client.patch(f"/api/admin/answer-task-drafts/questions/{question_id}", json={"selected": True}).status_code == 200 assert client.patch(f"/api/admin/answer-task-drafts/questions/{question_id}", json={"selected": True}).status_code == 200
assert client.post( assert client.post(
f"/api/admin/answer-task-drafts/{created['id']}/publish", f"/api/admin/answer-task-drafts/{created['id']}/publish",
@ -1318,7 +1336,7 @@ def test_approved_answer_appears_as_pending_refund_with_detail(monkeypatch):
assert len(detail.json()["data"]["payment_screenshots"]) == 1 assert len(detail.json()["data"]["payment_screenshots"]) == 1
refunded = client.post( refunded = client.post(
payout["refund_endpoint"], payout["refund_endpoint"],
files={"refund_file": ("refund.png", b"refund-image", "image/png")}, data={"payer_account_id": payer_id},
) )
assert refunded.status_code == 200 assert refunded.status_code == 200
assert refunded.json()["data"]["status_label"] == "已完成" assert refunded.json()["data"]["status_label"] == "已完成"
@ -1328,7 +1346,7 @@ def test_approved_answer_appears_as_pending_refund_with_detail(monkeypatch):
) )
assert user_detail.status_code == 200 assert user_detail.status_code == 200
assert user_detail.json()["data"]["status"] == "已完成" assert user_detail.json()["data"]["status"] == "已完成"
assert len(user_detail.json()["data"]["refund_screenshots"]) == 1 assert len(user_detail.json()["data"]["refund_screenshots"]) == 0
board_after_refund = task_board_items(client, page_size=100) board_after_refund = task_board_items(client, page_size=100)
completed_payout = next( completed_payout = next(
item item
@ -1466,6 +1484,16 @@ def test_answer_submission_requires_owner_link_and_allows_at_most_two_images():
def test_ask_batches_survive_reservation_timeout_and_parent_completes_only_after_all_paid(monkeypatch): def test_ask_batches_survive_reservation_timeout_and_parent_completes_only_after_all_paid(monkeypatch):
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions) monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
with TestClient(app) as client: with TestClient(app) as client:
with main_module.SessionLocal() as db:
payer = main_module.RefundPaymentAccount(
name="测试付款账户", alipay_account="payer@example.com", single_limit_fen=50000,
)
db.add(payer)
operator = db.scalar(main_module.select(main_module.AdminUser).where(main_module.AdminUser.username == "operator"))
db.flush()
operator.refund_payment_account_id = payer.id
db.commit()
payer_id = payer.id
assert client.post( assert client.post(
"/api/admin/auth/login", "/api/admin/auth/login",
json={"username": "operator", "password": "operator123456"}, json={"username": "operator", "password": "operator123456"},
@ -1507,7 +1535,7 @@ def test_ask_batches_survive_reservation_timeout_and_parent_completes_only_after
).status_code == 200 ).status_code == 200
assert client.post( assert client.post(
f"/api/admin/task-board/ask-batches/{first_batch['batch_id']}/confirm-refund", f"/api/admin/task-board/ask-batches/{first_batch['batch_id']}/confirm-refund",
files={"refund_file": ("refund-1.png", b"refund-1", "image/png")}, data={"payer_account_id": payer_id},
).status_code == 200 ).status_code == 200
board = task_board_items(client, task_type="ask", page_size=100) board = task_board_items(client, task_type="ask", page_size=100)
@ -1551,7 +1579,7 @@ def test_ask_batches_survive_reservation_timeout_and_parent_completes_only_after
).status_code == 200 ).status_code == 200
assert client.post( assert client.post(
f"/api/admin/task-board/ask-batches/{second_batch['batch_id']}/confirm-refund", f"/api/admin/task-board/ask-batches/{second_batch['batch_id']}/confirm-refund",
files={"refund_file": ("refund-2.png", b"refund-2", "image/png")}, data={"payer_account_id": payer_id},
).status_code == 200 ).status_code == 200
completed_board = task_board_items(client, task_type="ask", page_size=100) completed_board = task_board_items(client, task_type="ask", page_size=100)

View File

@ -0,0 +1,72 @@
from sqlalchemy import select
from app import main
from app.models import AdminUser, RefundPaymentAccount, RefundRecord
from test_refund_amounts import setup
def test_operator_cannot_grant_or_use_another_account(setup):
db, client, owner, _, task = setup
other = RefundPaymentAccount(name="其他账户", alipay_account="other@example.com", single_limit_fen=50000)
db.add(other)
db.commit()
url = f"/api/admin/users/{owner.id}/payment-account"
assert client.get("/api/admin/users").status_code == 403
assert client.patch(url, json={"account_id": other.id}).status_code == 403
available = client.get("/api/admin/refund-payment-accounts/available").json()["data"]
assert [row["id"] for row in available] == [owner.refund_payment_account_id]
assert client.post(f"/api/admin/task-board/{task.id}/confirm-refund", data={"payer_account_id": other.id}).status_code == 403
assert task.status == "已通过"
assert db.scalar(select(RefundRecord.id)) is None
def test_manager_department_scope_and_revocation(setup):
db, client, owner, _, _ = setup
owner.dingtalk_dept_id = 10
manager = AdminUser(username="manager", display_name="主管", password_hash="x", role_code="dept_manager", dingtalk_dept_id=10)
child = AdminUser(username="child", display_name="下级部门", password_hash="x", dingtalk_dept_id=11, dingtalk_dept_path=",10,11,")
outsider = AdminUser(username="outsider", display_name="其他部门", password_hash="x", dingtalk_dept_id=110, dingtalk_dept_path=",110,")
db.add_all([manager, child, outsider])
db.commit()
account_id = owner.refund_payment_account_id
main.app.dependency_overrides[main.current_user] = lambda: manager
rows = client.get("/api/admin/users").json()["data"]
assert {row["id"] for row in rows} == {owner.id, child.id}
for target in (owner, child):
assert client.patch(f"/api/admin/users/{target.id}/payment-account", json={"account_id": account_id}).status_code == 200
for target in (outsider, manager):
assert client.patch(f"/api/admin/users/{target.id}/payment-account", json={"account_id": account_id}).status_code == 403
assert client.patch(f"/api/admin/users/{owner.id}", json={"role_code": "super_admin"}).status_code == 403
assert client.patch(f"/api/admin/users/{owner.id}/payment-account", json={"account_id": None}).status_code == 200
main.app.dependency_overrides[main.current_user] = lambda: owner
assert client.get("/api/admin/refund-payment-accounts/available").json()["data"] == []
manager.dingtalk_dept_id = None
db.commit()
main.app.dependency_overrides[main.current_user] = lambda: manager
assert client.get("/api/admin/users").json()["data"] == []
assert client.patch(f"/api/admin/users/{owner.id}/payment-account", json={"account_id": account_id}).status_code == 403
def test_admin_grant_validation_and_account_deletion(setup):
db, client, owner, _, task = setup
admin = AdminUser(username="admin-test", display_name="管理员", password_hash="x", role_code="super_admin")
disabled = RefundPaymentAccount(name="停用", alipay_account="off@example.com", is_active=False, single_limit_fen=50000)
db.add_all([admin, disabled])
db.commit()
main.app.dependency_overrides[main.current_user] = lambda: admin
url = f"/api/admin/users/{owner.id}/payment-account"
for account_id in (disabled.id, 99999):
assert client.patch(url, json={"account_id": account_id}).status_code == 400
for payload in ({}, {"account_id": "1"}, {"account_id": True}, {"account_id": 0}):
assert client.patch(url, json=payload).status_code == 422
account_id = owner.refund_payment_account_id
assert client.patch(url, json={"account_id": None}).status_code == 200
main.app.dependency_overrides[main.current_user] = lambda: owner
assert client.post(f"/api/admin/task-board/{task.id}/confirm-refund", data={"payer_account_id": account_id}).status_code == 403
main.app.dependency_overrides[main.current_user] = lambda: admin
assert client.patch(url, json={"account_id": account_id}).status_code == 200
rows = client.get("/api/admin/users").json()["data"]
assert next(row for row in rows if row["id"] == owner.id)["refund_payment_account_name"] == "公司账户"
assert client.delete(f"/api/admin/refund-payment-accounts/{account_id}").status_code == 200
db.refresh(owner)
assert owner.refund_payment_account_id is None

View File

@ -0,0 +1,118 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app import main
from app.database import Base, get_db
from app.models import (
AdminUser, AnswerUser, AnswerSubmission, AnswerRecognizedQuestion,
AnswerScreenshot, AskTask, AskQuestion, AskAssignment,
AskAssignmentQuestion, AskAssignmentBatch, RefundRecipient, RefundPaymentAccount,
)
@pytest.fixture
def setup(monkeypatch):
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
with Session(engine) as db:
owner = AdminUser(username="owner", display_name="运营", password_hash="unused", answer_public_token="owner-link")
person = AnswerUser(public_id="buyer", phone="13800000000", taobao_id="buyer", auth_token=main.PUBLIC_USER_SESSION_PREFIX + "buyer-token")
db.add_all([owner, person, RefundPaymentAccount(name="公司账户", alipay_account="payer@example.com", balance_fen=10000, single_limit_fen=50000)])
db.flush()
owner.refund_payment_account_id = db.scalar(select(RefundPaymentAccount.id))
record = AnswerSubmission(submission_no="ANS-test", user_id=person.id, owner_id=owner.id, status="已通过")
db.add(record)
db.flush()
for i, selected in enumerate([True, True, False]):
db.add(AnswerRecognizedQuestion(submission_id=record.id, sequence_no=i + 1, product_title="商品", question_text=f"问题{i}", selected=selected))
db.add(RefundRecipient(target_kind="answer_submission", target_id=record.id, account="buyer@example.com", payee_name="张三"))
db.commit()
main.app.dependency_overrides[get_db] = lambda: db
main.app.dependency_overrides[main.current_user] = lambda: owner
# Exercise uploads without touching production files or image conversion.
monkeypatch.setattr(main, "screenshot_fields", lambda data, filename, content_type, folder: dict(image_data=data, filename=filename, content_type=content_type))
with TestClient(main.app, base_url="https://testserver") as client:
yield db, client, owner, person, record
main.app.dependency_overrides.clear()
engine.dispose()
def test_amount_default_override_reset_and_scope(setup):
db, client, owner, _, record = setup
data = main._task_board_data(record, include_images=True)
assert data["refund_amount_fen"] == 100
assert data["alipay_account"] == "buyer@example.com"
assert data["payee_name"] == "张三"
payload = dict(target_kind="answer_submission", target_id=record.id, amount="2.35")
response = client.patch("/api/admin/task-board/refund-amount", json=payload)
assert response.status_code == 200, response.text
assert response.json()["data"]["refund_amount_fen"] == 235
db.expire_all()
assert main._refund_recipient_data(record, "answer_submission")["refund_amount_fen"] == 235
response = client.patch("/api/admin/task-board/refund-amount", json={**payload, "amount": None})
assert response.json()["data"]["refund_amount_fen"] == 100
stranger = AdminUser(username="stranger", display_name="其他运营", password_hash="unused")
db.add(stranger)
db.commit()
main.app.dependency_overrides[main.current_user] = lambda: stranger
assert client.patch("/api/admin/task-board/refund-amount", json=payload).status_code == 404
@pytest.mark.parametrize("amount", ["0", "-1", "0.001", "NaN", "Infinity", "1000000", "abc"])
def test_reject_invalid_amount(setup, amount):
_, client, _, _, record = setup
response = client.patch("/api/admin/task-board/refund-amount", json=dict(target_kind="answer_submission", target_id=record.id, amount=amount))
assert response.status_code == 400
def test_confirm_requires_account_without_proof_and_saves_final_amount(setup):
db, client, _, person, record = setup
url = f"/api/admin/task-board/{record.id}/confirm-refund"
assert client.post(url, data={"refund_amount": "1.25"}).status_code == 422
db.rollback()
payer_id = db.scalar(select(RefundPaymentAccount.id))
response = client.post(url, data={"refund_amount": "1.25", "payer_account_id": payer_id})
assert response.status_code == 200, response.text
assert response.json()["data"]["refund_amount_fen"] == 125
assert record.status == "已完成"
assert client.post(url, data={"refund_amount": "2", "payer_account_id": payer_id}).status_code == 409
assert client.patch("/api/admin/task-board/refund-amount", json=dict(target_kind="answer_submission", target_id=record.id, amount="2")).status_code == 409
def test_answer_completion_without_collection_qr(setup):
db, client, _, person, record = setup
record.status = "待用户完成"
db.commit()
response = client.post(f"/api/answer/me/submissions/{record.id}/completion", data=dict(token=person.auth_token, owner_token="owner-link", alipay_account="new@example.com", payee_name="李四"), files={"files": ("task.png", b"proof", "image/png")})
assert response.status_code == 200, response.text
db.expire_all()
assert record.status == "待审核"
assert [image.stage for image in record.screenshots] == ["completion"]
assert main._refund_recipient_data(record, "answer_submission")["payee_name"] == "李四"
def test_ask_completion_without_collection_qr_and_batch_amount(setup):
db, client, owner, person, _ = setup
task = AskTask(task_no="TASK-test", product_link="https://example.com", product_name="商品", owner_id=owner.id, status="已发布")
db.add(task)
db.flush()
assignment = AskAssignment(task_id=task.id, user_id=person.id, status="进行中")
db.add(assignment)
db.flush()
for i in range(2):
question = AskQuestion(task_id=task.id, sequence_no=i + 1, question_text=f"问题{i}")
db.add(question)
db.flush()
db.add(AskAssignmentQuestion(assignment_id=assignment.id, question_id=question.id, selected=True))
db.commit()
response = client.post("/api/ask/tasks/TASK-test/submission", data=dict(token=person.auth_token, alipay_account="ask@example.com", payee_name="王五"), files={"files": ("task.png", b"proof", "image/png")})
assert response.status_code == 200, response.text
batch = db.scalar(select(AskAssignmentBatch).where(AskAssignmentBatch.assignment_id == assignment.id))
assert [image.stage for image in batch.screenshots] == ["question"]
data = main._refund_recipient_data(batch, "ask_batch")
assert data["refund_amount_fen"] == 100
assert data["alipay_account"] == "ask@example.com"
assert main._refund_recipient_data(assignment, "ask_assignment")["refund_default_amount_fen"] == 100

View File

@ -0,0 +1,109 @@
import pytest
from fastapi.testclient import TestClient
from sqlalchemy import create_engine, select
from sqlalchemy.orm import Session
from sqlalchemy.pool import StaticPool
from app import main
from app.database import Base, get_db
from app.models import AdminUser, RefundPaymentAccount
ENDPOINT = "/api/admin/refund-payment-accounts"
PAYLOAD = dict(name="公司付款账户", alipay_account="payer@example.com", balance="5000.01", single_limit="500.50", is_active=True)
@pytest.fixture
def setup():
engine = create_engine("sqlite://", connect_args={"check_same_thread": False}, poolclass=StaticPool)
Base.metadata.create_all(engine)
with Session(engine) as db:
admin = AdminUser(username="payer-admin", display_name="管理员", password_hash="unused", role_code="super_admin")
db.add(admin)
db.commit()
main.app.dependency_overrides[get_db] = lambda: db
main.app.dependency_overrides[main.current_user] = lambda: admin
try:
with TestClient(main.app, base_url="https://testserver") as client:
yield db, client, admin
finally:
main.app.dependency_overrides.clear()
engine.dispose()
def test_create_edit_and_persist_cents(setup):
db, client, _ = setup
response = client.post(ENDPOINT, json={**PAYLOAD, "name": " 公司付款账户 "})
assert response.status_code == 201, response.text
record = response.json()["data"]
assert record["name"] == "公司付款账户"
assert record["balance_fen"] == 500001
assert record["single_limit_fen"] == 50050
response = client.patch(f"{ENDPOINT}/{record['id']}", json={**PAYLOAD, "balance": "0.00", "is_active": False})
assert response.status_code == 200, response.text
db.expire_all()
saved = db.get(RefundPaymentAccount, record["id"])
assert saved.balance_fen == 0
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_duplicate_and_failed_edit_preserve_accounts(setup):
db, client, _ = setup
first = client.post(ENDPOINT, json=PAYLOAD).json()["data"]
second = client.post(ENDPOINT, json={**PAYLOAD, "alipay_account": "second@example.com"}).json()["data"]
assert client.post(ENDPOINT, json=PAYLOAD).status_code == 409
assert client.patch(f"{ENDPOINT}/{second['id']}", json=PAYLOAD).status_code == 409
db.expire_all()
assert len(db.scalars(select(RefundPaymentAccount)).all()) == 2
assert db.get(RefundPaymentAccount, second["id"]).alipay_account == "second@example.com"
assert db.get(RefundPaymentAccount, first["id"]).balance_fen == 500001
def test_search_pagination_and_literal_wildcard(setup):
_, client, _ = setup
for index in range(3):
assert client.post(ENDPOINT, json={**PAYLOAD, "name": f"付款{index}", "alipay_account": f"payer{index}@example.com"}).status_code == 201
data = client.get(ENDPOINT, params={"page": 2, "page_size": 2}).json()["data"]
assert data["total"] == 3
assert len(data["items"]) == 1
data = client.get(ENDPOINT, params={"q": "payer1"}).json()["data"]
assert data["total"] == 1
assert data["items"][0]["name"] == "付款1"
assert client.get(ENDPOINT, params={"q": "%"}).json()["data"]["total"] == 0
@pytest.mark.parametrize("changes", [
{"name": " "}, {"alipay_account": " "}, {"balance": "-1"},
{"balance": "1.001"}, {"single_limit": "0"}, {"single_limit": "10000000"},
{"balance": "NaN"}, {"single_limit": "Infinity"},
])
def test_reject_invalid_account(setup, changes):
_, client, _ = setup
response = client.post(ENDPOINT, json={**PAYLOAD, **changes})
assert response.status_code == 422, response.text
def test_permissions_and_missing_account(setup):
db, client, admin = setup
assert client.patch(f"{ENDPOINT}/999", json=PAYLOAD).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.delete(f"{ENDPOINT}/999").status_code == 403
def test_delete_account_and_preserve_other_accounts(setup):
db, client, _ = setup
first = client.post(ENDPOINT, json=PAYLOAD).json()["data"]
other = client.post(ENDPOINT, json={**PAYLOAD, "alipay_account": "other@example.com"}).json()["data"]
response = client.delete(f"{ENDPOINT}/{first['id']}")
assert response.status_code == 200, response.text
db.expire_all()
assert db.get(RefundPaymentAccount, first["id"]) is None
assert db.get(RefundPaymentAccount, other["id"]) is not None
assert client.get(ENDPOINT).json()["data"]["total"] == 1
assert client.delete(f"{ENDPOINT}/{first['id']}").status_code == 404

View File

@ -0,0 +1,118 @@
import csv
import io
from datetime import datetime
import pytest
from sqlalchemy import select
from test_refund_amounts import setup
from app import main
from app.models import (AdminUser, AnswerScreenshot, RefundRecord, RefundRecipient, RefundPaymentAccount,
AskTask, AskQuestion, AskAssignment, AskAssignmentQuestion,
AskAssignmentBatch, AskAssignmentScreenshot, AskAssignmentBatchScreenshot)
def test_answer_confirmation_snapshot_survives_changes_and_deletion(setup):
db, client, owner, _, task = setup
task.shop_name = "测试店铺"
db.commit()
url = f"/api/admin/task-board/{task.id}/confirm-refund"
payer_id = db.scalar(select(RefundPaymentAccount.id))
response = client.post(url, data={"refund_amount": "2.35", "payer_account_id": payer_id, "payer_account": "伪造账户", "alipay_trade_no": "ALI123"})
assert response.status_code == 200, response.text
assert client.post(url, data={"payer_account_id": payer_id}).status_code == 409
rows = client.get("/api/admin/refund-records").json()["data"]
assert rows["total"] == 1 and rows["total_amount_fen"] == 235
row = rows["items"][0]
assert (row["payee_name"], row["payee_account"], row["operator_name"], row["payer_account"], row["alipay_trade_no"]) == ("张三", "buyer@example.com", owner.display_name, "公司账户", "")
recipient = db.scalar(select(RefundRecipient).where(RefundRecipient.target_kind == "answer_submission"))
recipient.payee_name = "修改后姓名"
db.delete(task)
db.commit()
assert client.get("/api/admin/refund-records").json()["data"]["items"][0] == row
@pytest.mark.parametrize("batched", [True, False])
def test_ask_confirmation_creates_record(setup, batched):
db, client, owner, person, _ = setup
task = AskTask(task_no="TASK-refund", product_link="https://example.com", product_name="商品", shop_name="提问店铺", owner_id=owner.id, status="已发布")
db.add(task)
db.flush()
assignment = AskAssignment(task_id=task.id, user_id=person.id, status="已通过")
question = AskQuestion(task_id=task.id, sequence_no=1, question_text="问题")
db.add_all([assignment, question])
db.flush()
db.add(AskAssignmentQuestion(assignment_id=assignment.id, question_id=question.id, selected=True))
if batched:
record = AskAssignmentBatch(assignment_id=assignment.id, status="approved", selected_question_ids=str(question.id))
db.add(record)
db.flush()
kind, route = "ask_batch", "ask-batches"
else:
record = assignment
kind, route = "ask_assignment", "ask-assignments"
db.add(RefundRecipient(target_kind=kind, target_id=record.id, account="ask@example.com", payee_name="提问人"))
db.commit()
url = f"/api/admin/task-board/{route}/{record.id}/confirm-refund"
payer_id = db.scalar(select(RefundPaymentAccount.id))
owner = db.get(AdminUser, task.owner_id)
owner.refund_payment_account_id = None
db.commit()
assert client.post(url, data={"payer_account_id": payer_id}).status_code == 403
owner.refund_payment_account_id = payer_id
db.commit()
response = client.post(url, data={"payer_account_id": payer_id})
assert response.status_code == 200, response.text
assert client.post(url, data={"payer_account_id": payer_id}).status_code == 409
row = client.get("/api/admin/refund-records").json()["data"]["items"][0]
assert row["amount_fen"] == 50
assert row["payee_name"] == "提问人" and row["order_no"] == "TASK-refund"
def test_available_accounts_and_disabled_account_rejection(setup):
db, client, _, _, task = setup
disabled = RefundPaymentAccount(name="停用账户", alipay_account="disabled@example.com", single_limit_fen=50000, is_active=False)
db.add(disabled)
db.commit()
accounts = client.get("/api/admin/refund-payment-accounts/available")
assert accounts.status_code == 200
assert [item["name"] for item in accounts.json()["data"]] == ["公司账户"]
assert client.get("/api/admin/refund-payment-accounts").status_code == 403
url = f"/api/admin/task-board/{task.id}/confirm-refund"
for account_id in (disabled.id, 99999):
response = client.post(url, data={"payer_account_id": account_id})
assert response.status_code == 400, response.text
assert task.status == "已通过"
assert db.scalar(select(RefundRecord.id)) is None
def test_scope_date_range_pagination_and_export(setup):
db, client, owner, _, _ = setup
owner.dingtalk_dept_id = 10
colleague = AdminUser(username="colleague", display_name="同事", password_hash="x", dingtalk_dept_id=11, dingtalk_dept_path=",10,11,")
stranger = AdminUser(username="stranger", display_name="外部门", password_hash="x", dingtalk_dept_id=20)
db.add_all([colleague, stranger])
db.flush()
for i in range(14):
db.add(RefundRecord(target_kind="answer_submission", target_id=100 + i, owner_id=owner.id if i < 12 else colleague.id if i == 12 else stranger.id,
order_no=f"TASK-{i}", amount_fen=50, operator_name="运营", shop_name="=unsafe",
transferred_at=datetime(2026, 10, 21, 16, 0) if i < 13 else datetime(2026, 10, 22, 16, 0)))
db.commit()
query = "start_date=2026-10-22&end_date=2026-10-22"
page = client.get(f"/api/admin/refund-records?{query}&page=2").json()["data"]
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"
owner.role_code = "dept_manager"
db.commit()
assert client.get(f"/api/admin/refund-records?{query}").json()["data"]["total"] == 13
owner.role_code = "super_admin"
db.commit()
assert client.get("/api/admin/refund-records").json()["data"]["total"] == 14
assert client.get(f"/api/admin/refund-records?{query}&q=TASK-13").json()["data"]["total"] == 0
assert client.get("/api/admin/refund-records?q=TASK-13").json()["data"]["total"] == 1
for path in ["/api/admin/refund-records", "/api/admin/refund-records/export"]:
assert client.get(path + "?start_date=wrong").status_code == 400
assert client.get(path + "?start_date=2026-10-23&end_date=2026-10-22").status_code == 400

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

View File

@ -429,14 +429,12 @@ function ConfirmStage({
(item) => item.selected, (item) => item.selected,
); );
const [files, setFiles] = useState([]); const [files, setFiles] = useState([]);
const [paymentFile, setPaymentFile] = useState(null);
const [payeeName, setPayeeName] = useState(""); const [payeeName, setPayeeName] = useState("");
const [alipayAccount, setAlipayAccount] = useState(""); const [alipayAccount, setAlipayAccount] = useState("");
const [reviewOpen, setReviewOpen] = useState(false); const [reviewOpen, setReviewOpen] = useState(false);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [copiedId, setCopiedId] = useState(null); const [copiedId, setCopiedId] = useState(null);
const inputRef = useRef(null); const inputRef = useRef(null);
const paymentInputRef = useRef(null);
const previews = useMemo( const previews = useMemo(
() => files.map((file) => ({ file, url: URL.createObjectURL(file) })), () => files.map((file) => ({ file, url: URL.createObjectURL(file) })),
[files], [files],
@ -474,7 +472,6 @@ function ConfirmStage({
const body = new FormData(); const body = new FormData();
body.append("token", session.token); body.append("token", session.token);
files.forEach((file) => body.append("files", file)); files.forEach((file) => body.append("files", file));
body.append("payment_file", paymentFile);
body.append("payee_name", payeeName); body.append("payee_name", payeeName);
body.append("alipay_account", alipayAccount.trim()); body.append("alipay_account", alipayAccount.trim());
try { try {
@ -606,37 +603,10 @@ function ConfirmStage({
<header> <header>
<div> <div>
<b>2</b> <b>2</b>
<strong>上传收款码</strong> <strong>填写收款信息</strong>
<small>审核通过后将按原路径结算至此账户</small> <small>请填写准确的支付宝账号及实名姓名</small>
</div> </div>
<span>{paymentFile ? "已上传" : "未上传"}</span>
</header> </header>
{paymentFile ? (
<div className="ask-payment-file">
<span>{paymentFile.name}</span>
<button onClick={() => paymentInputRef.current?.click()}>
重新上传
</button>
<button className="delete" onClick={() => setPaymentFile(null)}>
删除
</button>
</div>
) : (
<button
className="ask-payment-empty"
onClick={() => paymentInputRef.current?.click()}
>
<ImagePlus size={22} />
上传收款码
</button>
)}
<input
ref={paymentInputRef}
hidden
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(event) => setPaymentFile(event.target.files?.[0] || null)}
/>
<label> <label>
支付宝账号(必填) 支付宝账号(必填)
<input value={alipayAccount} maxLength={128} onChange={(event) => setAlipayAccount(event.target.value)} placeholder="请输入支付宝账号(手机号或邮箱)" /> <input value={alipayAccount} maxLength={128} onChange={(event) => setAlipayAccount(event.target.value)} placeholder="请输入支付宝账号(手机号或邮箱)" />
@ -645,6 +615,7 @@ function ConfirmStage({
收款人真实姓名(必填) 收款人真实姓名(必填)
<input <input
value={payeeName} value={payeeName}
maxLength={64}
onChange={(event) => setPayeeName(event.target.value)} onChange={(event) => setPayeeName(event.target.value)}
placeholder="请输入收款人姓名" placeholder="请输入收款人姓名"
/> />
@ -652,7 +623,7 @@ function ConfirmStage({
</section> </section>
<button <button
className="ask-submit-review" className="ask-submit-review"
disabled={!files.length || !paymentFile || !alipayAccount.trim() || !payeeName.trim()} disabled={!files.length || !alipayAccount.trim() || !payeeName.trim()}
onClick={() => setReviewOpen(true)} onClick={() => setReviewOpen(true)}
> >
下一步:核对并提交审核 下一步:核对并提交审核
@ -683,8 +654,8 @@ function ConfirmStage({
<b>已上传({files.length}张)</b> <b>已上传({files.length}张)</b>
</p> </p>
<p> <p>
<span>收款码</span> <span>支付宝收款信息</span>
<b>已上传</b> <b>{payeeName} / {alipayAccount}</b>
</p> </p>
</div> </div>
<aside> <aside>

View File

@ -0,0 +1,131 @@
import React, { useEffect, useState } from "react";
import { Plus, Search, X, Pencil, Trash2 } from "lucide-react";
import { formatDateTime } from "./time.js";
const endpoint = "/api/admin/refund-payment-accounts";
const money = (fen) => (fen / 100).toFixed(2);
const emptyForm = () => ({ name: "", alipay_account: "", balance: "0.00", single_limit: "500.00", is_active: true });
export default function RefundManagementPage({ api }) {
const [data, setData] = useState({ items: [], total: 0 });
const [query, setQuery] = useState("");
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [reload, setReload] = useState(0);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [editing, setEditing] = useState(null);
const [form, setForm] = useState(emptyForm);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const [deleting, setDeleting] = useState(null);
const [deleteError, setDeleteError] = useState("");
useEffect(() => {
let cancelled = false;
setLoading(true);
setError("");
const timer = setTimeout(async () => {
try {
const result = await api(`${endpoint}?${new URLSearchParams({ q: query, page, page_size: pageSize })}`);
if (!cancelled) setData(result.data);
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
}, 250);
return () => { cancelled = true; clearTimeout(timer); };
}, [api, query, page, pageSize, reload]);
function open(account) {
setEditing(account || {});
setForm(account ? { name: account.name, alipay_account: account.alipay_account,
balance: money(account.balance_fen), single_limit: money(account.single_limit_fen), is_active: account.is_active } : emptyForm());
setSaveError("");
}
async function save(event) {
event.preventDefault();
if (saving) return;
setSaving(true);
setSaveError("");
try {
if (!form.name.trim() || !form.alipay_account.trim()) throw new Error("请填写账户名称和支付宝账户");
for (const [key, label] of [["balance", "可用余额"], ["single_limit", "单次付款限额"]]) {
if (!/^\d+(\.\d{1,2})?$/.test(form[key]) || Number(form[key]) > 9999999.99 ||
(key === "single_limit" && Number(form[key]) <= 0)) {
throw new Error(`${label}需填写有效金额,最多两位小数,最高 9999999.99 元${key === "single_limit" ? ",且必须大于 0" : ""}`);
}
}
await api(editing.id ? `${endpoint}/${editing.id}` : endpoint, {
method: editing.id ? "PATCH" : "POST", body: JSON.stringify(form),
});
setEditing(null);
setPage(1);
setReload((n) => n + 1);
} catch (err) {
setSaveError(typeof err.message === "string" ? err.message : "保存失败,请检查填写内容");
} finally { setSaving(false); }
}
async function remove(account) {
if (deleting !== null || !window.confirm(`确定删除付款账户“${account.name}”(${account.alipay_account})吗?删除后无法恢复。`)) return;
setDeleting(account.id);
setDeleteError("");
try {
await api(`${endpoint}/${account.id}`, { method: "DELETE" });
setPage(1);
setReload((n) => n + 1);
} catch (err) {
setDeleteError(err.message || "删除失败,请重试");
} finally { setDeleting(null); }
}
const pages = Math.max(1, Math.ceil(data.total / pageSize));
return <>
<div className="page-heading"><div><h2>返款管理</h2><p>管理返款使用的支付宝付款账户。</p></div></div>
<section className="panel refund-management">
<div className="refund-management-notice">代付接口暂未接入,此处仅维护付款账户资料,不会发起转账。可用余额为人工录入值,后续接入接口后再同步。</div>
<div className="refund-management-toolbar">
<label className="refund-account-search"><Search size={16} /><input aria-label="搜索付款账户" placeholder="搜索账户名称或支付宝账户" value={query} maxLength={128} onChange={(e) => { setQuery(e.target.value); setPage(1); }} /></label>
<button className="primary-button" onClick={() => open(null)}><Plus size={16} />新增付款账户</button>
</div>
{error ? <div className="form-error" role="alert">{error}<button onClick={() => setReload((n) => n + 1)}>重试</button></div> : null}
{deleteError && <div className="form-error" role="alert">{deleteError}</div>}
<div className="refund-account-table-wrap" aria-busy={loading}>
<table className="refund-account-table"><thead><tr>
<th>类型</th><th>账户名称</th><th>支付宝账户</th><th>可用余额</th><th>单次付款限额</th><th>状态</th><th>更新时间</th><th>操作</th>
</tr></thead><tbody>
{loading ? <tr><td colSpan={8} className="empty-state">加载中…</td></tr> : error ? null : data.items.length ? data.items.map((account) => <tr key={account.id}>
<td><span className="refund-account-tag">支付宝</span></td><td>{account.name}</td><td>{account.alipay_account}</td>
<td className="refund-account-balance">¥{money(account.balance_fen)}</td><td>¥{money(account.single_limit_fen)}</td>
<td><span className={`refund-account-tag ${account.is_active ? "enabled" : "disabled"}`}>{account.is_active ? "启用" : "禁用"}</span></td>
<td>{formatDateTime(account.updated_at)}</td><td><div className="refund-account-actions"><button className="refund-account-edit" disabled={deleting !== null} onClick={() => open(account)}><Pencil size={14} />编辑</button><button className="refund-account-edit refund-account-delete" disabled={deleting !== null} onClick={() => remove(account)}><Trash2 size={14} />{deleting === account.id ? "删除中…" : "删除"}</button></div></td>
</tr>) : <tr><td colSpan={8} className="empty-state">{query ? "未找到匹配账户" : "暂无付款账户,请点击新增付款账户"}</td></tr>}
</tbody></table>
</div>
<div className="refund-account-pagination">
<span>共 {data.total} 条记录</span><button disabled={loading || page <= 1} onClick={() => setPage((n) => n - 1)}>上一页</button>
<span>{page} / {pages}</span><button disabled={loading || page >= pages} onClick={() => setPage((n) => n + 1)}>下一页</button>
<select aria-label="每页条数" value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}>{[10, 20, 50].map((n) => <option key={n} value={n}>{n} 条/页</option>)}</select>
</div>
</section>
{editing && <div className="refund-account-overlay"><section className="refund-account-modal" role="dialog" aria-modal="true" aria-labelledby="refund-account-title">
<header><h2 id="refund-account-title">{editing.id ? "编辑付款账户" : "新增付款账户"}</h2><button aria-label="关闭" disabled={saving} onClick={() => setEditing(null)}><X size={20} /></button></header>
<form onSubmit={save}>
<label>账户类型<input value="支付宝" disabled /></label>
<label>账户名称<input required maxLength={128} value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="例如:公司名称或账户别名" /></label>
<label>支付宝账户<input required maxLength={128} value={form.alipay_account} onChange={(e) => setForm({ ...form, alipay_account: e.target.value })} placeholder="支付宝手机号或邮箱" /></label>
<div className="refund-account-money-fields">
<label>可用余额(元)<input required type="number" min="0" max="9999999.99" step="0.01" value={form.balance} onChange={(e) => setForm({ ...form, balance: e.target.value })} /></label>
<label>单次付款限额(元)<input required type="number" min="0.01" max="9999999.99" step="0.01" value={form.single_limit} onChange={(e) => setForm({ ...form, single_limit: e.target.value })} /></label>
</div>
<label>状态<select value={String(form.is_active)} onChange={(e) => setForm({ ...form, is_active: e.target.value === "true" })}><option value="true">启用</option><option value="false">禁用</option></select></label>
<p className="refund-account-hint">余额由人工维护;保存资料不会产生实际付款。</p>
{saveError && <div className="form-error" role="alert">{saveError}</div>}
<footer><button type="button" disabled={saving} onClick={() => setEditing(null)}>取消</button><button className="primary-button" disabled={saving}>{saving ? "保存中…" : "保存"}</button></footer>
</form>
</section></div>}
</>;
}

View File

@ -0,0 +1,92 @@
import React, { useEffect, useState } from "react";
import { Download, Search } from "lucide-react";
import { formatDateTime } from "./time.js";
const endpoint = "/api/admin/refund-records";
const money = (fen) => `¥${(fen / 100).toFixed(2)}`;
export default function RefundRecordsPage({ api }) {
const [filters, setFilters] = useState({ q: "", start_date: "", end_date: "" });
const [page, setPage] = useState(1);
const [pageSize, setPageSize] = useState(10);
const [data, setData] = useState({ items: [], total: 0, total_amount_fen: 0 });
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [exporting, setExporting] = useState(false);
const invalidDates = filters.start_date && filters.end_date && filters.start_date > filters.end_date;
const filterParams = new URLSearchParams(filters).toString();
useEffect(() => {
let cancelled = false;
setLoading(true);
setError("");
const timer = setTimeout(async () => {
try {
if (invalidDates) throw new Error("开始日期不能晚于结束日期");
const result = await api(`${endpoint}?${filterParams}&page=${page}&page_size=${pageSize}`);
if (!cancelled) {
const lastPage = Math.max(1, Math.ceil(result.data.total / pageSize));
if (page > lastPage) setPage(lastPage);
else setData(result.data);
}
} catch (err) {
if (!cancelled) setError(err.message);
} finally {
if (!cancelled) setLoading(false);
}
}, 250);
return () => { cancelled = true; clearTimeout(timer); };
}, [api, filterParams, page, pageSize, invalidDates]);
function changeFilter(key, value) {
setFilters((current) => ({ ...current, [key]: value }));
setPage(1);
}
async function exportRecords() {
setExporting(true);
setError("");
try {
const response = await fetch(`${import.meta.env.VITE_API_BASE || ""}${endpoint}/export?${filterParams}`, { credentials: "include" });
if (!response.ok) {
const result = await response.json().catch(() => ({}));
throw new Error(result.detail || `导出失败(HTTP ${response.status})`);
}
const url = URL.createObjectURL(await response.blob());
const link = document.createElement("a");
link.href = url;
link.download = `返款记录_${filters.start_date || "全部"}_${filters.end_date || "至今"}.csv`;
document.body.appendChild(link);
link.click();
link.remove();
setTimeout(() => URL.revokeObjectURL(url), 1000);
} catch (err) { setError(err.message); }
finally { setExporting(false); }
}
const pages = Math.max(1, Math.ceil(data.total / pageSize));
return <>
<div className="page-heading"><h1>返款记录</h1><p>查看已确认返款的记录,支持按时间筛选及导出。</p></div>
<section className="panel refund-records">
<div className="refund-records-toolbar">
<label className="refund-account-search"><Search size={16} /><input aria-label="搜索订单号" placeholder="输入订单号查找返款记录" value={filters.q} onChange={(e) => changeFilter("q", e.target.value)} /></label>
<label>开始日期<input type="date" value={filters.start_date} onChange={(e) => changeFilter("start_date", e.target.value)} /></label>
<label>结束日期<input type="date" value={filters.end_date} onChange={(e) => changeFilter("end_date", e.target.value)} /></label>
<button onClick={() => { setFilters({ q: "", start_date: "", end_date: "" }); setPage(1); }}>清空筛选</button>
<button className="primary-button" disabled={exporting || !!invalidDates} onClick={exportRecords}><Download size={16} />{exporting ? "正在导出…" : "导出返款记录"}</button>
</div>
<p className="refund-records-hint">时间按确认返款时间(北京时间)记录;订单号暂用任务编号。导出包含筛选范围内全部记录。</p>
{error && <p role="alert" className="refund-records-error">{error}</p>}
<div className="refund-account-table-wrap" aria-busy={loading}>
<table className="refund-account-table refund-records-table">
<thead><tr>{["店铺名称", "转账时间", "订单号", "收款人", "收款账号", "返款金额", "付款账户", "操作人", "支付宝流水"].map((title) => <th key={title}>{title}</th>)}</tr></thead>
<tbody>{loading || error ? <tr><td colSpan={9}>{loading ? "加载中…" : "记录加载失败,请调整筛选或重新进入页面"}</td></tr> : data.items.length ? data.items.map((item) => <tr key={item.id}>
<td>{item.shop_name || "—"}</td><td>{formatDateTime(item.transferred_at)}</td><td>{item.order_no}</td><td>{item.payee_name || "—"}</td><td>{item.payee_account || "—"}</td><td className="refund-account-balance">{money(item.amount_fen)}</td><td>{item.payer_account || "—"}</td><td>{item.operator_name || "—"}</td><td>{item.alipay_trade_no || "—"}</td>
</tr>) : <tr><td colSpan={9}>暂无返款记录</td></tr>}</tbody>
{!loading && !error && <tfoot><tr><th colSpan={5}>合计(当前筛选全部记录)</th><td className="refund-account-balance">{money(data.total_amount_fen)}</td><td colSpan={3} /></tr></tfoot>}
</table>
</div>
<div className="refund-account-pagination">
<span>共 {error || loading ? "—" : data.total} 条记录</span>
<button disabled={loading || page <= 1} onClick={() => setPage(page - 1)}>上一页</button><span>{page} / {pages}</span><button disabled={loading || page >= pages} onClick={() => setPage(page + 1)}>下一页</button>
<select aria-label="每页记录数" value={pageSize} onChange={(e) => { setPageSize(Number(e.target.value)); setPage(1); }}>{[10, 20, 50, 100].map((size) => <option key={size} value={size}>{size} 条/页</option>)}</select>
</div>
</section>
</>;
}

View File

@ -156,11 +156,10 @@ function ReviewDetail({ record, close, decide, busy, hasNext, onNext, nextBusy }
))} ))}
</div> </div>
</section> </section>
<section> {!!record.payment_screenshots?.length && <section>
<h3>③ 返款二维码截图</h3> <h3>③ 返款二维码截图</h3>
<div> <div>
{record.payment_screenshots?.length ? ( {record.payment_screenshots.map((image) => (
record.payment_screenshots.map((image) => (
<figure key={image.id}> <figure key={image.id}>
<img <img
src={image.data_url} src={image.data_url}
@ -170,12 +169,9 @@ function ReviewDetail({ record, close, decide, busy, hasNext, onNext, nextBusy }
/> />
<figcaption>审核通过后用于后续人工处理</figcaption> <figcaption>审核通过后用于后续人工处理</figcaption>
</figure> </figure>
)) ))}
) : (
<div className="review-v2-no-payment">未单独上传收款码</div>
)}
</div> </div>
</section> </section>}
</div> </div>
{record.latest_review && !pending && ( {record.latest_review && !pending && (
<div <div
@ -332,7 +328,7 @@ function AskReviewDetail({ record, close, api, onDecision, hasNext, onNext, next
))} ))}
</div> </div>
</section> </section>
<section> {!!record.payment_screenshots?.length && <section>
<h3>③ 用户上传的收款码</h3> <h3>③ 用户上传的收款码</h3>
<div> <div>
{record.payment_screenshots.map((image) => ( {record.payment_screenshots.map((image) => (
@ -347,7 +343,7 @@ function AskReviewDetail({ record, close, api, onDecision, hasNext, onNext, next
</figure> </figure>
))} ))}
</div> </div>
</section> </section>}
</div> </div>
{record.latest_review && record.status !== "待审核" && ( {record.latest_review && record.status !== "待审核" && (
<div <div

View File

@ -1,6 +1,5 @@
import React, { useEffect, useMemo, useRef, useState } from "react"; import React, { useEffect, useMemo, useRef, useState } from "react";
import { ChevronRight, LoaderCircle, Search, X } from "lucide-react"; import { ChevronRight, LoaderCircle, Search, X } from "lucide-react";
import { qrDataUrl } from "./qrCode.js";
const STATUS_FILTERS = [ const STATUS_FILTERS = [
["all", "全部任务"], ["all", "全部任务"],
@ -48,17 +47,36 @@ function TaskDetailLoading() {
); );
} }
export function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload, onPollRefundUpload, hasNextRefund, onNextRefund, nextBusy, readOnly = false }) { export function TaskDetail({ task, close, onConfirmRefund, onSaveRefundAmount, api, hasNextRefund, onNextRefund, nextBusy, readOnly = false }) {
const [amount, setAmount] = useState("");
const [payerAccount, setPayerAccount] = useState("");
const [paymentAccounts, setPaymentAccounts] = useState([]);
const [accountsBusy, setAccountsBusy] = useState(false);
const [refundError, setRefundError] = useState("");
useEffect(() => { setPayerAccount(""); setRefundError(""); }, [task.board_key]);
const [amountBusy, setAmountBusy] = useState(false);
const [amountError, setAmountError] = useState("");
useEffect(() => {
setAmount(((task.refund_amount_fen ?? (task.selected_questions?.length ?? 0) * 50) / 100).toFixed(2));
setAmountError("");
}, [task.board_key, task.refund_amount_fen]);
const validAmount = /^\d+(\.\d{1,2})?$/.test(amount) && Number(amount) > 0 && Number(amount) <= 999999.99;
async function saveAmount(reset = false) {
if (!reset && !validAmount) return setAmountError("请输入大于0且最多两位小数的金额");
setAmountBusy(true);
setAmountError("");
try {
await onSaveRefundAmount(task, reset ? null : amount);
if (reset) setAmount((task.refund_default_amount_fen / 100).toFixed(2));
} catch (error) {
setAmountError(error.message);
} finally {
setAmountBusy(false);
}
}
const [refundOpen, setRefundOpen] = useState(false); const [refundOpen, setRefundOpen] = useState(false);
const [refundBusy, setRefundBusy] = useState(false); const [refundBusy, setRefundBusy] = useState(false);
const [refundFile, setRefundFile] = useState(null);
const [refundFileError, setRefundFileError] = useState("");
const [refundFilePreview, setRefundFilePreview] = useState("");
const [refundSession, setRefundSession] = useState(null);
const [refundQr, setRefundQr] = useState("");
const [refundSessionError, setRefundSessionError] = useState("");
const [linkCopied, setLinkCopied] = useState(false); const [linkCopied, setLinkCopied] = useState(false);
const refundPasteRef = useRef(null);
const isAskTask = const isAskTask =
task.task_type === "ask" || task.task_type_label === "提问任务"; task.task_type === "ask" || task.task_type_label === "提问任务";
const taskLink = isAskTask const taskLink = isAskTask
@ -70,102 +88,28 @@ export function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload,
]; ];
const paymentScreenshots = task.payment_screenshots || []; const paymentScreenshots = task.payment_screenshots || [];
const refundScreenshots = task.refund_screenshots || []; const refundScreenshots = task.refund_screenshots || [];
function setRefundProofFile(file) {
if (!file) return false;
if (!["image/jpeg", "image/png", "image/webp"].includes(file.type)) {
setRefundFileError("返款凭证仅支持 JPG、PNG、WEBP 图片");
return false;
}
if (file.size > 10 * 1024 * 1024) {
setRefundFileError("返款凭证不能超过 10MB");
return false;
}
setRefundFile(file);
setRefundFileError("");
return true;
}
function clearRefundProofFile() {
setRefundFile(null);
setRefundFileError("");
}
useEffect(() => {
if (!refundOpen || refundSession || !task.refund_endpoint) return undefined;
let cancelled = false;
setRefundSessionError("");
onCreateRefundUpload(task).then((session) => {
if (cancelled) return;
setRefundSession(session);
try {
setRefundQr(qrDataUrl(`${window.location.origin}${session.upload_url}`));
} catch (error) {
setRefundSessionError(error.message || "二维码生成失败,请改用下方粘贴上传");
}
}).catch((error) => !cancelled && setRefundSessionError(error.message));
return () => { cancelled = true; };
}, [refundOpen, refundSession, task.id, onCreateRefundUpload]);
useEffect(() => {
if (!refundOpen || !refundSession || refundSession.uploaded) return undefined;
let cancelled = false;
const poll = async () => {
try {
const current = await onPollRefundUpload(refundSession.token);
if (!cancelled && current.uploaded) setRefundSession((value) => ({ ...value, ...current }));
} catch (error) {
if (!cancelled) setRefundSessionError(error.message);
}
};
const timer = window.setInterval(poll, 2500);
return () => { cancelled = true; window.clearInterval(timer); };
}, [refundOpen, refundSession?.token, refundSession?.uploaded]);
useEffect(() => {
if (!refundFile) {
setRefundFilePreview("");
return undefined;
}
const previewUrl = URL.createObjectURL(refundFile);
setRefundFilePreview(previewUrl);
return () => URL.revokeObjectURL(previewUrl);
}, [refundFile]);
useEffect(() => { useEffect(() => {
if (!refundOpen) return undefined; if (!refundOpen) return undefined;
const pasteImage = (event) => { let cancelled = false;
const imageItem = Array.from(event.clipboardData?.items || []).find((item) => setAccountsBusy(true);
item.kind === "file" && item.type.startsWith("image/") setRefundError("");
); setPaymentAccounts([]);
if (!imageItem) { api("/api/admin/refund-payment-accounts/available").then((result) => {
setRefundFileError("剪贴板中没有图片,请先复制或截图后再按 Ctrl+V"); if (!cancelled) setPaymentAccounts(result.data);
return; }).catch((error) => { if (!cancelled) setRefundError(error.message); })
} .finally(() => { if (!cancelled) setAccountsBusy(false); });
const blob = imageItem.getAsFile(); return () => { cancelled = true; };
if (!blob) { }, [refundOpen, api]);
setRefundFileError("读取剪贴板图片失败,请重新复制后再试");
return;
}
const extension = blob.type === "image/jpeg" ? "jpg" : blob.type.split("/")[1];
const pastedFile = new File(
[blob],
`refund-proof-${Date.now()}.${extension}`,
{ type: blob.type, lastModified: Date.now() },
);
event.preventDefault();
setRefundProofFile(pastedFile);
};
window.addEventListener("paste", pasteImage);
const focusTimer = window.setTimeout(() => refundPasteRef.current?.focus(), 0);
return () => {
window.clearTimeout(focusTimer);
window.removeEventListener("paste", pasteImage);
};
}, [refundOpen]);
async function confirmRefund() { async function confirmRefund() {
if (!validAmount) return setRefundError("请输入大于0且最多两位小数的金额");
if (!payerAccount) return setRefundError("请选择付款账户");
setRefundBusy(true); setRefundBusy(true);
setRefundError("");
try { try {
await onConfirmRefund(task, refundFile, refundSession?.uploaded ? refundSession.token : null); await onConfirmRefund(task, amount, payerAccount);
setRefundOpen(false); setRefundOpen(false);
setRefundSession(null);
setRefundFile(null);
} catch (error) { } catch (error) {
window.alert(error.message); setRefundError(error.message);
} finally { } finally {
setRefundBusy(false); setRefundBusy(false);
} }
@ -264,6 +208,17 @@ export function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload,
<b>{task.payee_name || "未填写(历史任务)"}</b> <b>{task.payee_name || "未填写(历史任务)"}</b>
</span> </span>
</div> </div>
<div className="task-detail-refund-amount">
<label>返款金额(元)
{!readOnly && task.refund_endpoint ? <input aria-label="返款金额" type="number" min="0.01" max="999999.99" step="0.01" value={amount} onChange={(event) => setAmount(event.target.value)} /> : <b>¥{((task.refund_amount_fen ?? 0) / 100).toFixed(2)}</b>}
</label>
<span>默认:{task.refund_default_amount_fen / 50 || 0} 条 × 0.50 元 = ¥{((task.refund_default_amount_fen || 0) / 100).toFixed(2)}{task.refund_amount_custom ? "(已自定义)" : ""}</span>
{!readOnly && task.refund_endpoint && <>
<button type="button" disabled={amountBusy || refundBusy} onClick={() => saveAmount()}>保存金额</button>
<button type="button" disabled={amountBusy || refundBusy} onClick={() => saveAmount(true)}>恢复默认</button>
</>}
{amountError && <p role="alert">{amountError}</p>}
</div>
<h3>认领用户的问题与回答(共 {task.assigned_users.length} 位)</h3> <h3>认领用户的问题与回答(共 {task.assigned_users.length} 位)</h3>
<div className="task-detail-table"> <div className="task-detail-table">
<div className="head"> <div className="head">
@ -352,7 +307,7 @@ export function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload,
<p>未上传完成截图</p> <p>未上传完成截图</p>
)} )}
</section> </section>
<section className="payment"> {paymentScreenshots.length > 0 && <section className="payment">
<h3>收款码</h3> <h3>收款码</h3>
{paymentScreenshots.length ? ( {paymentScreenshots.length ? (
<div> <div>
@ -367,9 +322,9 @@ export function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload,
))} ))}
</div> </div>
) : ( ) : (
<p>用户未单独上传收款码</p> <p>历史收款码</p>
)} )}
</section> </section>}
</div> </div>
<footer> <footer>
{!readOnly && task.refund_endpoint && ( {!readOnly && task.refund_endpoint && (
@ -377,10 +332,7 @@ export function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload,
type="button" type="button"
className="confirm-refund" className="confirm-refund"
onClick={() => { onClick={() => {
setRefundSession(null); setRefundError("");
setRefundFile(null);
setRefundFileError("");
setRefundQr("");
setRefundOpen(true); setRefundOpen(true);
}} }}
> >
@ -404,76 +356,32 @@ export function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload,
<div <div
className="refund-confirm-backdrop" className="refund-confirm-backdrop"
onMouseDown={(event) => onMouseDown={(event) =>
event.target === event.currentTarget && setRefundOpen(false) !refundBusy && event.target === event.currentTarget && setRefundOpen(false)
} }
> >
<section className="refund-confirm-modal"> <section className="refund-confirm-modal">
<span>扫码</span> <h2>确认返款</h2>
<h2>确认已经完成返款?</h2> <p>收款人:{task.payee_name || "未填写"}<br />支付宝账号:{task.alipay_account || "未填写"}</p>
<p> <fieldset className="refund-record-fields" disabled={refundBusy}>
请确认已按用户上传的收款码完成付款。确认后,该任务将归入“已完成”,此操作不可撤销。 <label>返款金额(元)
</p> <input type="number" min="0.01" max="999999.99" step="0.01" value={amount} onChange={(event) => setAmount(event.target.value)} />
<div className="refund-scan-upload"> <small>默认按每条问答 0.50 元计算,可修改金额。</small>
<div> </label>
<strong>扫码上传返款凭证</strong> <label>付款账户
<small>二维码有效期 10 分钟</small> <select value={payerAccount} disabled={accountsBusy} onChange={(event) => setPayerAccount(event.target.value)}>
{refundSessionError && <em>{refundSessionError}</em>} <option value="">{accountsBusy ? "正在加载账户…" : "请选择付款账户"}</option>
{refundSession?.uploaded && <b>✓ 手机端已上传,运营端可确认返款</b>} {paymentAccounts.map((account) => <option key={account.id} value={account.id}>{account.name}({account.alipay_account})</option>)}
</div> </select>
{refundQr ? <img src={refundQr} alt="扫码上传返款凭证" /> : <i>正在生成二维码…</i>} {!accountsBusy && paymentAccounts.length === 0 && <small>暂无可用付款账户,请联系主管或超级管理员检查账户授权及启用状态。</small>}
</div> </label>
<div <label>支付宝流水<input readOnly value={task.alipay_trade_no || "确认后由支付宝返回"} /></label>
ref={refundPasteRef} </fieldset>
className={`refund-proof-upload${refundFile ? " has-image" : ""}`} <p className="refund-confirm-notice">确认后系统将按所选付款账户向收款支付宝账号发起转账;支付宝返回流水号后会同步到返款记录。若接口返回处理中,请先查询状态再重试,避免重复付款。</p>
tabIndex={0} {refundError && <p role="alert" className="refund-confirm-error">{refundError}</p>}
role="button"
aria-label="粘贴剪贴板中的返款凭证图片"
>
{refundFilePreview ? (
<img src={refundFilePreview} alt="已粘贴的返款凭证" />
) : (
<span className="refund-paste-icon">粘贴</span>
)}
<span>剪贴板粘贴上传(可选)</span>
<strong>{refundFile ? "图片已粘贴,可直接确认" : "复制图片后,按 Ctrl+V 粘贴"}</strong>
<small>{refundFile ? `${refundFile.name} · ${(refundFile.size / 1024).toFixed(0)}KB` : "支持 JPG、PNG、WEBP,最大 10MB"}</small>
{refundFile && (
<button
type="button"
className="refund-proof-remove"
onClick={clearRefundProofFile}
>
移除图片
</button>
)}
{refundFileError && <em>{refundFileError}</em>}
</div>
<label className="refund-proof-fallback">
<input
type="file"
accept="image/jpeg,image/png,image/webp"
onChange={(event) => {
setRefundProofFile(event.target.files?.[0]);
event.target.value = "";
}}
/>
<span>无法粘贴?选择本地图片</span>
</label>
<div> <div>
<button <button type="button" disabled={refundBusy} onClick={() => setRefundOpen(false)}>取消</button>
type="button" <button type="button" className="confirm" disabled={refundBusy || accountsBusy || !validAmount || !paymentAccounts.some((account) => String(account.id) === payerAccount)} onClick={confirmRefund}>
disabled={refundBusy} {refundBusy ? "确认中…" : "确认返款"}
onClick={() => setRefundOpen(false)}
>
取消
</button>
<button
type="button"
className="confirm"
disabled={refundBusy || (!refundFile && !refundSession?.uploaded)}
onClick={confirmRefund}
>
{refundBusy ? "确认中…" : "确定返款完成"}
</button> </button>
</div> </div>
</section> </section>
@ -723,22 +631,20 @@ export default function TaskBoardPage({ api, user }) {
setBusy(""); setBusy("");
} }
} }
async function createRefundUpload(task) { async function saveRefundAmount(task, amount) {
const result = await api("/api/admin/refund-upload-sessions", { const result = await api("/api/admin/task-board/refund-amount", {
method: "POST", method: "PATCH",
body: JSON.stringify({ target_kind: task.record_kind, target_id: task.id }), body: JSON.stringify({ target_kind: task.record_kind, target_id: task.id, amount }),
}); });
return result.data; const updated = { ...task, ...result.data };
detailCache.current.set(detailCacheKey(task), updated);
setDetail(updated);
taskBoardCache.delete(accountCacheKey);
} }
async function pollRefundUpload(token) { async function confirmRefund(task, amount, payerAccount) {
const result = await api(`/api/refund-upload-sessions/${encodeURIComponent(token)}`);
return result.data;
}
async function confirmRefund(task, refundFile, uploadToken) {
if (!refundFile && !uploadToken) throw new Error("请先粘贴返款凭证,或用手机扫码上传");
const body = new FormData(); const body = new FormData();
if (refundFile) body.append("refund_file", refundFile); body.append("refund_amount", amount);
if (uploadToken) body.append("upload_token", uploadToken); body.append("payer_account_id", payerAccount);
const result = await api(task.refund_endpoint, { method: "POST", body }); const result = await api(task.refund_endpoint, { method: "POST", body });
const updatedDetail = { const updatedDetail = {
...task, ...task,
@ -945,8 +851,9 @@ export default function TaskBoardPage({ api, user }) {
task={detail} task={detail}
close={() => setDetail(null)} close={() => setDetail(null)}
onConfirmRefund={confirmRefund} onConfirmRefund={confirmRefund}
onCreateRefundUpload={createRefundUpload} onSaveRefundAmount={saveRefundAmount}
onPollRefundUpload={pollRefundUpload} api={api}
hasNextRefund={Boolean(nextRefundTask)} hasNextRefund={Boolean(nextRefundTask)}
nextBusy={Boolean(detailLoading)} nextBusy={Boolean(detailLoading)}
onNextRefund={() => openDetail(nextRefundTask)} onNextRefund={() => openDetail(nextRefundTask)}

View File

@ -160,6 +160,50 @@
color: #18355c; color: #18355c;
font-size: 18px; font-size: 18px;
} }
.user-create-mode {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
padding: 4px;
border-radius: 9px;
background: #f0f4fa;
}
.user-create-mode button {
border: 0;
border-radius: 7px;
padding: 9px 8px;
color: #587092;
background: transparent;
cursor: pointer;
}
.user-create-mode button.active {
color: #215fe1;
background: #fff;
box-shadow: 0 2px 8px rgba(36, 82, 150, 0.12);
}
.user-lookup-row {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.user-lookup-row .ghost-button {
white-space: nowrap;
}
.dingtalk-user-preview {
display: grid;
gap: 5px;
padding: 11px 12px;
border: 1px solid #c9dcfb;
border-radius: 8px;
color: #496487;
background: #f5f9ff;
font-size: 12px;
line-height: 1.45;
}
.dingtalk-user-preview strong {
color: #183c72;
font-size: 14px;
}
.user-modal label { .user-modal label {
display: grid; display: grid;
gap: 7px; gap: 7px;
@ -623,30 +667,6 @@
display: grid; display: grid;
gap: 18px; gap: 18px;
} }
.alipay-refund-panel {
width: min(1200px, 95vw) !important;
max-height: 90vh;
display: flex;
flex-direction: column;
}
.alipay-refund-panel.panel {
width: 100% !important;
max-width: none;
max-height: none;
padding: 22px;
}
.alipay-refund-scroll { overflow: auto; min-height: 100px; }
.alipay-refund-scroll table { min-width: 1000px; }
.alipay-refund-scroll th,
.alipay-refund-scroll td { vertical-align: middle; }
.alipay-refund-scroll td:first-child { width: 42px; }
.alipay-refund-scroll td:nth-child(2) { min-width: 300px; }
.alipay-refund-scroll td:nth-child(3),
.alipay-refund-scroll td:nth-child(4) { min-width: 165px; }
.alipay-refund-scroll td:nth-child(5) { min-width: 120px; }
.alipay-refund-scroll td:last-child { min-width: 170px; white-space: nowrap; }
.alipay-refund-scroll input:not([type="checkbox"]) { width: 100%; min-width: 100px; padding: 8px; border: 1px solid #cbdaf0; border-radius: 6px; }
.alipay-refund-scroll small { display: block; color: #7187a3; }
.usage-summary-grid { .usage-summary-grid {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@ -4613,6 +4633,9 @@
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 18px; gap: 18px;
} }
.review-v2-media > section:only-child {
grid-column: 1 / -1;
}
.review-v2-media section > div { .review-v2-media section > div {
display: grid; display: grid;
grid-template-columns: repeat(2, 1fr); grid-template-columns: repeat(2, 1fr);
@ -4634,15 +4657,6 @@
color: #7f92aa; color: #7f92aa;
font-size: 9px; font-size: 9px;
} }
.review-v2-no-payment {
display: grid !important;
place-items: center;
min-height: 170px;
border: 1px dashed #c7d5e5;
border-radius: 8px;
color: #8ca0b8;
font-size: 11px;
}
.review-v2-result { .review-v2-result {
margin-top: 15px; margin-top: 15px;
padding: 12px; padding: 12px;
@ -6486,3 +6500,72 @@ html.dingtalk-mobile .answer-v2-footer {
flex-wrap: wrap; flex-wrap: wrap;
} }
} }
.task-detail-refund-amount { display: flex; align-items: center; gap: 12px; flex-wrap: wrap; padding: 14px 16px; margin: 12px 0 20px; border: 1px solid #dbe6f7; border-radius: 10px; background: #f7faff; }
.task-detail-refund-amount label { display: flex; align-items: center; gap: 10px; color: #18365f; font-weight: 600; }
.task-detail-refund-amount input { width: 130px; padding: 8px 10px; border: 1px solid #bcd0f2; border-radius: 6px; background: white; }
.task-detail-refund-amount > span { color: #6b82a5; font-size: 12px; }
.task-detail-refund-amount button { padding: 8px 12px; border: 1px solid #bcd0f2; border-radius: 6px; background: white; color: #2460dd; cursor: pointer; }
.task-detail-refund-amount p { flex-basis: 100%; margin: 0; color: #e64059; }
.refund-management { width: 100%; padding: 22px; }
.refund-management-notice { padding: 13px 16px; border: 1px solid #d6e3ff; border-radius: 8px; background: #f5f8ff; color: #54729d; font-size: 13px; line-height: 1.7; }
.refund-management-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; margin: 16px 0; }
.refund-account-search { display: flex; align-items: center; gap: 8px; width: 320px; max-width: 100%; padding: 0 12px; border: 1px solid #d6e1f2; border-radius: 7px; color: #8a9db9; }
.refund-account-search input { width: 100%; min-width: 0; height: 38px; border: 0; outline: 0; background: transparent; }
.refund-management-toolbar > button { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; }
.refund-account-table-wrap { overflow-x: auto; }
.refund-account-table { width: 100%; min-width: 1000px; border-collapse: collapse; text-align: left; font-size: 13px; }
.refund-account-table th { padding: 15px 12px; background: #f6f8fc; color: #6981a4; font-weight: 500; white-space: nowrap; }
.refund-account-table td { padding: 20px 12px; border-bottom: 1px solid #eaf0f8; color: #254369; overflow-wrap: anywhere; max-width: 260px; }
.refund-account-table td:nth-child(4), .refund-account-table td:nth-child(5), .refund-account-table td:nth-child(7) { white-space: nowrap; font-variant-numeric: tabular-nums; }
.refund-account-table td.refund-account-balance { color: #2864ed; font-weight: 600; }
.refund-account-tag { display: inline-block; padding: 3px 7px; border-radius: 4px; background: #edf3ff; color: #2864ed; white-space: nowrap; font-size: 12px; }
.refund-account-tag.enabled { color: #069474; background: #e7f8f2; }
.refund-account-tag.disabled { color: #e34c66; background: #fff0f3; }
.refund-account-edit { display: inline-flex; align-items: center; gap: 5px; padding: 5px; border: 0; background: transparent; color: #2864ed; white-space: nowrap; cursor: pointer; }
.refund-account-actions { display: flex; align-items: center; gap: 8px; }
.refund-account-delete { color: #e34c66; }
.refund-account-edit:disabled { opacity: .45; cursor: not-allowed; }
.refund-account-pagination { display: flex; align-items: center; justify-content: flex-end; flex-wrap: wrap; gap: 12px; margin-top: 22px; color: #6981a4; font-size: 13px; }
.refund-account-pagination button, .refund-account-pagination select { height: 34px; padding: 0 12px; border: 1px solid #d6e1f2; border-radius: 7px; background: white; color: #365782; }
.refund-account-pagination button:disabled { opacity: .45; cursor: not-allowed; }
.refund-account-overlay { position: fixed; inset: 0; z-index: 1200; display: flex; align-items: center; justify-content: center; padding: 20px; background: #0e1b35a6; }
.refund-account-modal { width: 540px; max-width: 100%; max-height: 90vh; overflow-y: auto; padding: 26px; border-radius: 16px; background: white; box-shadow: 0 24px 70px #0d19333d; }
.refund-account-modal header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 22px; }
.refund-account-modal h2 { margin: 0; color: #19365d; font-size: 20px; }
.refund-account-modal header button { display: flex; padding: 4px; border: 0; background: transparent; color: #8a9db9; cursor: pointer; }
.refund-account-modal form > label, .refund-account-money-fields label { display: flex; flex-direction: column; gap: 8px; margin-bottom: 16px; color: #567198; font-size: 13px; }
.refund-account-modal input, .refund-account-modal select { width: 100%; box-sizing: border-box; height: 40px; padding: 0 12px; border: 1px solid #cddcf3; border-radius: 7px; background: white; color: #19365d; }
.refund-account-modal input:focus, .refund-account-modal select:focus { outline: 2px solid #dbe8ff; border-color: #6193f8; }
.refund-account-modal input:disabled { background: #f5f7fb; color: #8293ac; }
.refund-account-money-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
.refund-account-hint { color: #8193af; font-size: 12px; line-height: 1.6; }
.refund-account-modal footer { display: flex; justify-content: flex-end; gap: 10px; margin-top: 22px; }
.refund-account-modal footer button { padding: 10px 20px; border: 1px solid #d6e1f2; border-radius: 7px; cursor: pointer; }
.refund-account-modal footer button:not(.primary-button) { background: white; color: #365782; }
@media (max-width: 650px) {
.refund-management { padding: 14px; }
.refund-management-toolbar { align-items: stretch; flex-direction: column; }
.refund-account-search { width: auto; }
.refund-management-toolbar > button { justify-content: center; }
.refund-account-modal { padding: 20px; }
.refund-account-money-fields { grid-template-columns: 1fr; gap: 0; }
}
.refund-records { padding: 22px; }
.refund-records-toolbar { display: flex; align-items: end; gap: 12px; flex-wrap: wrap; }
.refund-records-toolbar > label:not(.refund-account-search) { display: grid; gap: 6px; font-size: 12px; }
.refund-records-toolbar input[type="date"] { padding: 9px; border: 1px solid #cbdaf2; border-radius: 8px; background: white; color: inherit; }
.refund-records-toolbar > .primary-button { margin-left: auto; display: flex; align-items: center; gap: 6px; }
.refund-records-hint { color: #7186a5; font-size: 12px; margin: 16px 0; }
.refund-records-table { min-width: 1250px; }
.refund-records-table td { overflow-wrap: anywhere; max-width: 230px; }
.refund-records-table tfoot { background: #f6f8fc; }
.refund-records-error { color: #dc2626; }
.refund-record-fields { border: 0; padding: 0; margin: 12px 0; display: grid; grid-template-columns: 1fr 1fr; gap: 12px; text-align: left; }
.refund-record-fields label { display: grid; gap: 6px; font-size: 12px; }
.refund-record-fields input, .refund-record-fields select { width: 100%; min-width: 0; border: 1px solid #cbdaf2; border-radius: 8px; padding: 10px; background: white; color: inherit; }
.refund-confirm-modal .refund-record-fields { grid-template-columns: 1fr; }
.refund-record-fields small { color: #7890ac; line-height: 1.6; font-weight: normal; }
.refund-confirm-modal .refund-confirm-notice { padding: 12px; background: #f0f6ff; border-radius: 8px; text-align: left; }
.refund-confirm-modal .refund-confirm-error { color: #e5484d; }
@media (max-width: 700px) { .refund-record-fields { grid-template-columns: 1fr; } }

View File

@ -22,12 +22,13 @@ import CreateAnswerTasksPageV2 from "./CreateAnswerTasksPage.jsx";
import AnswerUserPortalV2 from "./AnswerUserPortal.jsx"; import AnswerUserPortalV2 from "./AnswerUserPortal.jsx";
import TaskBoardPage, { clearTaskBoardCache } from "./TaskBoardPage.jsx"; import TaskBoardPage, { clearTaskBoardCache } from "./TaskBoardPage.jsx";
import ReviewCenterPage from "./ReviewCenterPage.jsx"; import ReviewCenterPage from "./ReviewCenterPage.jsx";
import AlipayRefundPanel from "./AlipayRefundPanel.jsx";
import CreateAskTasksPage from "./CreateAskTasksPage.jsx"; import CreateAskTasksPage from "./CreateAskTasksPage.jsx";
import AskUserPortal from "./AskUserPortal.jsx"; import AskUserPortal from "./AskUserPortal.jsx";
import RefundUploadPage from "./RefundUploadPage.jsx"; import RefundUploadPage from "./RefundUploadPage.jsx";
import RefundManagementPage from "./RefundManagementPage.jsx";
import RefundRecordsPage from "./RefundRecordsPage.jsx";
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js"; import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
import { readPageCache, writePageCache } from "./pageCache.js"; import { clearPageCache, readPageCache, writePageCache } from "./pageCache.js";
import { formatDateTime } from "./time.js"; import { formatDateTime } from "./time.js";
// 兼容公共回答链接末尾有无斜杠,避免 /answer 被识别为运营后台路由。 // 兼容公共回答链接末尾有无斜杠,避免 /answer 被识别为运营后台路由。
@ -332,9 +333,9 @@ const BASE_ITEMS = [
group: "\u4efb\u52a1\u7ba1\u7406", group: "\u4efb\u52a1\u7ba1\u7406",
}, },
{ {
id: "refunds", id: "refund-records",
icon: FileText, icon: FileText,
label: "支付宝返款", label: "返款记录",
group: "任务管理", group: "任务管理",
}, },
{ {
@ -370,6 +371,12 @@ const ADMIN_ITEMS = [
label: "\u7cfb\u7edf\u914d\u7f6e", label: "\u7cfb\u7edf\u914d\u7f6e",
group: "\u7cfb\u7edf\u7ba1\u7406", group: "\u7cfb\u7edf\u7ba1\u7406",
}, },
{
id: "refund-management",
icon: Database,
label: "返款管理",
group: "系统管理",
},
]; ];
function Sidebar({ user, active, setActive, reviewBadge }) { function Sidebar({ user, active, setActive, reviewBadge }) {
@ -379,7 +386,9 @@ function Sidebar({ user, active, setActive, reviewBadge }) {
const items = const items =
user.role_code === "super_admin" user.role_code === "super_admin"
? [...baseItems, ...ADMIN_ITEMS] ? [...baseItems, ...ADMIN_ITEMS]
: baseItems; : user.role_code === "dept_manager"
? [...baseItems, ...ADMIN_ITEMS.filter((item) => item.id === "users")]
: baseItems;
const groups = [...new Set(items.map((item) => item.group))]; const groups = [...new Set(items.map((item) => item.group))];
return ( return (
<aside className="sidebar"> <aside className="sidebar">
@ -1794,6 +1803,31 @@ function AssistantPageFinal() {
setReferenceQa(""); setReferenceQa("");
if (referenceInputRef.current) referenceInputRef.current.value = ""; if (referenceInputRef.current) referenceInputRef.current.value = "";
} }
function clearAllAssistant() {
if (loading || saving) return;
clearPageCache("assistant");
setForm({
product_name: "",
category: "",
benefits: "",
selling_points: "",
language_style: "",
target_group: "",
usage_scene: "",
purchase_channel: "",
question_count: "",
answers_per_question: "",
answer_requirement: "",
seed_style: false,
});
setItems([]);
setError("");
setSavedMessage("");
setAdoptedDialog(null);
setManualOpen(false);
setImportOpen(false);
clearReferenceFile();
}
async function generate() { async function generate() {
const intentError = analyzeProductIntent([ const intentError = analyzeProductIntent([
form.product_name, form.product_name,
@ -2197,6 +2231,14 @@ function AssistantPageFinal() {
> >
↻ 重新生成 ↻ 重新生成
</button> </button>
<button
type="button"
className="ask-clear-all"
onClick={clearAllAssistant}
disabled={loading || saving}
>
一键清空
</button>
</div> </div>
</div> </div>
{items.length === 0 && ( {items.length === 0 && (
@ -2239,17 +2281,60 @@ function AssistantPageFinal() {
); );
} }
function UserTable({ users = [], onChanged }) { function UserTable({ users = [], onChanged, currentUser }) {
const rows = Array.isArray(users) ? users : []; const rows = Array.isArray(users) ? users : [];
const isSuperAdmin = currentUser?.role_code === "super_admin";
const [authorization, setAuthorization] = useState(null);
const [accounts, setAccounts] = useState([]);
const [accountId, setAccountId] = useState("");
const [authorizationBusy, setAuthorizationBusy] = useState(false);
const [authorizationError, setAuthorizationError] = useState("");
async function openAuthorization(user) {
setAuthorization(user);
setAccountId(user.refund_payment_account_id ? String(user.refund_payment_account_id) : "");
setAccounts([]);
setAuthorizationError("");
setAuthorizationBusy(true);
try {
const result = await api("/api/admin/refund-payment-accounts/available");
setAccounts(result.data || []);
} catch (error) {
setAuthorizationError(error.message);
} finally {
setAuthorizationBusy(false);
}
}
async function saveAuthorization(event) {
event.preventDefault();
setAuthorizationBusy(true);
setAuthorizationError("");
try {
await api(`/api/admin/users/${authorization.id}/payment-account`, {
method: "PATCH",
body: JSON.stringify({ account_id: accountId ? Number(accountId) : null }),
});
setAuthorization(null);
onChanged();
} catch (error) {
setAuthorizationError(error.message);
} finally {
setAuthorizationBusy(false);
}
}
const [editing, setEditing] = useState(null); const [editing, setEditing] = useState(null);
const [createMode, setCreateMode] = useState("account");
const [lookingUp, setLookingUp] = useState(false);
const [form, setForm] = useState({ const [form, setForm] = useState({
username: "", username: "",
password: "", password: "",
display_name: "", display_name: "",
role_code: "operator", role_code: "operator",
is_active: true, is_active: true,
identifier: "",
dingtalk_profile: null,
}); });
function open(user) { function open(user) {
setCreateMode("account");
setForm( setForm(
user user
? { ? {
@ -2258,6 +2343,8 @@ function UserTable({ users = [], onChanged }) {
display_name: user.display_name, display_name: user.display_name,
role_code: user.role_code, role_code: user.role_code,
is_active: user.is_active, is_active: user.is_active,
identifier: "",
dingtalk_profile: null,
} }
: { : {
username: "", username: "",
@ -2265,6 +2352,8 @@ function UserTable({ users = [], onChanged }) {
display_name: "", display_name: "",
role_code: "operator", role_code: "operator",
is_active: true, is_active: true,
identifier: "",
dingtalk_profile: null,
}, },
); );
setEditing(user || "new"); setEditing(user || "new");
@ -2272,12 +2361,21 @@ function UserTable({ users = [], onChanged }) {
async function save(event) { async function save(event) {
event.preventDefault(); event.preventDefault();
try { try {
if (editing === "new") if (editing === "new") {
await api("/api/admin/users", { await api(
method: "POST", createMode === "dingtalk"
body: JSON.stringify(form), ? "/api/admin/users/dingtalk"
}); : "/api/admin/users",
else {
method: "POST",
body: JSON.stringify(
createMode === "dingtalk"
? { identifier: form.identifier, role_code: form.role_code }
: form,
),
},
);
} else {
await api(`/api/admin/users/${editing.id}`, { await api(`/api/admin/users/${editing.id}`, {
method: "PATCH", method: "PATCH",
body: JSON.stringify({ body: JSON.stringify({
@ -2287,12 +2385,38 @@ function UserTable({ users = [], onChanged }) {
is_active: form.is_active, is_active: form.is_active,
}), }),
}); });
}
setEditing(null); setEditing(null);
onChanged(); onChanged();
} catch (error) { } catch (error) {
window.alert(error.message); window.alert(error.message);
} }
} }
async function lookupDingTalkUser() {
if (!form.identifier.trim()) {
window.alert("请输入钉钉 UserId 或 UnionId");
return;
}
setLookingUp(true);
try {
const result = await api("/api/admin/users/dingtalk/lookup", {
method: "POST",
body: JSON.stringify({ identifier: form.identifier.trim() }),
});
const profile = result.data || result;
setForm((current) => ({
...current,
display_name: profile.display_name || current.display_name,
role_code: profile.suggested_role || current.role_code,
dingtalk_profile: profile,
}));
} catch (error) {
setForm((current) => ({ ...current, dingtalk_profile: null }));
window.alert(error.message);
} finally {
setLookingUp(false);
}
}
async function remove(user) { async function remove(user) {
if ( if (
!window.confirm( !window.confirm(
@ -2310,11 +2434,35 @@ function UserTable({ users = [], onChanged }) {
return ( return (
<> <>
<div className="user-table-actions"> <div className="user-table-actions">
<button className="primary-button" onClick={() => open(null)}> {isSuperAdmin ? <button className="primary-button" onClick={() => open(null)}>
<Plus size={16} /> <Plus size={16} />
{"\u65b0\u589e\u7528\u6237"} {"\u65b0\u589e\u7528\u6237"}
</button> </button> : <span>可为本部门及下级部门的普通运营设置付款账户授权。</span>}
</div> </div>
{authorization && (
<div className="user-modal-backdrop">
<form className="user-modal panel" onSubmit={saveAuthorization}>
<h3>付款账户授权</h3>
<p>{authorization.display_name}({authorization.username})</p>
<label>
付款账户
<select value={accountId} disabled={authorizationBusy} onChange={(event) => setAccountId(event.target.value)}>
<option value="">未授权</option>
{accountId && !accounts.some((account) => String(account.id) === accountId) && (
<option value={accountId} disabled>{authorization.refund_payment_account_name || "原授权账户"}(不可用)</option>
)}
{accounts.map((account) => <option key={account.id} value={account.id}>{account.name}({account.alipay_account})</option>)}
</select>
</label>
<small>每位普通运营最多授权一个付款账户;选择“未授权”可取消授权。</small>
{authorizationError && <div className="form-error" role="alert">{authorizationError}</div>}
<div className="modal-actions">
<button type="button" className="ghost-button" disabled={authorizationBusy} onClick={() => setAuthorization(null)}>取消</button>
<button className="primary-button" disabled={authorizationBusy}>{authorizationBusy ? "处理中…" : "保存"}</button>
</div>
</form>
</div>
)}
{editing && ( {editing && (
<div className="user-modal-backdrop"> <div className="user-modal-backdrop">
<form className="user-modal panel" onSubmit={save}> <form className="user-modal panel" onSubmit={save}>
@ -2324,6 +2472,66 @@ function UserTable({ users = [], onChanged }) {
: "\u7f16\u8f91\u7528\u6237"} : "\u7f16\u8f91\u7528\u6237"}
</h3> </h3>
{editing === "new" && ( {editing === "new" && (
<div className="user-create-mode" role="tablist" aria-label="新增方式">
<button
type="button"
className={createMode === "account" ? "active" : ""}
onClick={() => {
setCreateMode("account");
setForm((current) => ({ ...current, dingtalk_profile: null }));
}}
>
新增后台账号
</button>
<button
type="button"
className={createMode === "dingtalk" ? "active" : ""}
onClick={() => {
setCreateMode("dingtalk");
setForm((current) => ({ ...current, dingtalk_profile: null }));
}}
>
添加钉钉员工
</button>
</div>
)}
{editing === "new" && createMode === "dingtalk" && (
<>
<label>
钉钉 UserId / UnionId
<div className="user-lookup-row">
<input
required
value={form.identifier}
placeholder="请输入 UserId 或 UnionId"
onChange={(event) =>
setForm({
...form,
identifier: event.target.value,
dingtalk_profile: null,
})
}
/>
<button
type="button"
className="ghost-button"
disabled={lookingUp}
onClick={lookupDingTalkUser}
>
{lookingUp ? "查询中..." : "查询员工"}
</button>
</div>
</label>
{form.dingtalk_profile && (
<div className="dingtalk-user-preview">
<div><strong>{form.dingtalk_profile.display_name}</strong>{form.dingtalk_profile.title ? ` · ${form.dingtalk_profile.title}` : ""}</div>
<div>部门:{form.dingtalk_profile.department_path || form.dingtalk_profile.department_name || "未获取"}</div>
<div>身份:{form.dingtalk_profile.user_id || "未返回 UserId"} / {form.dingtalk_profile.union_id || "未返回 UnionId"}</div>
</div>
)}
</>
)}
{editing === "new" && createMode === "account" && (
<label> <label>
{"\u7528\u6237\u540d"} {"\u7528\u6237\u540d"}
<input <input
@ -2339,26 +2547,29 @@ function UserTable({ users = [], onChanged }) {
{"\u663e\u793a\u540d\u79f0"} {"\u663e\u793a\u540d\u79f0"}
<input <input
required required
readOnly={editing === "new" && createMode === "dingtalk"}
value={form.display_name} value={form.display_name}
onChange={(event) => onChange={(event) =>
setForm({ ...form, display_name: event.target.value }) setForm({ ...form, display_name: event.target.value })
} }
/> />
</label> </label>
<label> {(editing !== "new" || createMode === "account") && (
{editing === "new" <label>
? "\u521d\u59cb\u5bc6\u7801" {editing === "new"
: "\u91cd\u7f6e\u5bc6\u7801\uff08\u9009\u586b\uff09"} ? "\u521d\u59cb\u5bc6\u7801"
<input : "\u91cd\u7f6e\u5bc6\u7801\uff08\u9009\u586b\uff09"}
type="password" <input
minLength="8" type="password"
required={editing === "new"} minLength="8"
value={form.password} required={editing === "new"}
onChange={(event) => value={form.password}
setForm({ ...form, password: event.target.value }) onChange={(event) =>
} setForm({ ...form, password: event.target.value })
/> }
</label> />
</label>
)}
<label> <label>
{"\u89d2\u8272"} {"\u89d2\u8272"}
<select <select
@ -2404,6 +2615,8 @@ function UserTable({ users = [], onChanged }) {
<tr> <tr>
<th>{"\u8d26\u53f7"}</th> <th>{"\u8d26\u53f7"}</th>
<th>{"\u663e\u793a\u540d\u79f0"}</th> <th>{"\u663e\u793a\u540d\u79f0"}</th>
<th>部门</th>
<th>付款账户授权</th>
<th>{"\u89d2\u8272"}</th> <th>{"\u89d2\u8272"}</th>
<th>{"\u72b6\u6001"}</th> <th>{"\u72b6\u6001"}</th>
<th>{"\u6700\u8fd1\u767b\u5f55"}</th> <th>{"\u6700\u8fd1\u767b\u5f55"}</th>
@ -2415,6 +2628,8 @@ function UserTable({ users = [], onChanged }) {
<tr key={user.id}> <tr key={user.id}>
<td>{user.username}</td> <td>{user.username}</td>
<td>{user.display_name}</td> <td>{user.display_name}</td>
<td>{user.dingtalk_dept_name || "未绑定钉钉"}</td>
<td>{user.role_code === "operator" ? user.refund_payment_account_name || "未授权" : "全部启用账户"}</td>
<td> <td>
<span className="role-pill">{user.role_label}</span> <span className="role-pill">{user.role_label}</span>
</td> </td>
@ -2433,7 +2648,8 @@ function UserTable({ users = [], onChanged }) {
: "\u672a\u767b\u5f55"} : "\u672a\u767b\u5f55"}
</td> </td>
<td> <td>
<button className="table-action" onClick={() => open(user)}> {user.role_code === "operator" && <button className="table-action" onClick={() => openAuthorization(user)}>付款账户授权</button>}
{isSuperAdmin && <><button className="table-action" onClick={() => open(user)}>
{"\u7f16\u8f91"} {"\u7f16\u8f91"}
</button> </button>
<button <button
@ -2441,7 +2657,7 @@ function UserTable({ users = [], onChanged }) {
onClick={() => remove(user)} onClick={() => remove(user)}
> >
{"\u5220\u9664"} {"\u5220\u9664"}
</button> </button></>}
</td> </td>
</tr> </tr>
))} ))}
@ -2672,7 +2888,7 @@ function UsagePanel({ data }) {
); );
} }
function AdminPage({ type }) { function AdminPage({ type, user }) {
const [data, setData] = useState(null); const [data, setData] = useState(null);
const [error, setError] = useState(""); const [error, setError] = useState("");
const [reload, setReload] = useState(0); const [reload, setReload] = useState(0);
@ -2730,6 +2946,7 @@ function AdminPage({ type }) {
<UsagePanel data={data} /> <UsagePanel data={data} />
) : type === "users" ? ( ) : type === "users" ? (
<UserTable <UserTable
currentUser={user}
users={Array.isArray(data) ? data : []} users={Array.isArray(data) ? data : []}
onChanged={() => setReload((value) => value + 1)} onChanged={() => setReload((value) => value + 1)}
/> />
@ -4427,12 +4644,14 @@ function Dashboard({ user, onLogout }) {
<TaskBoardPage api={api} user={user} /> <TaskBoardPage api={api} user={user} />
) : active === "review" ? ( ) : active === "review" ? (
<ReviewCenterPage api={api} /> <ReviewCenterPage api={api} />
) : active === "refunds" ? (
<AlipayRefundPanel api={api} standalone />
) : active === "library" ? ( ) : active === "library" ? (
<QALibraryPage selectionMode={Boolean(window.__qaLibrarySelectionMode)} /> <QALibraryPage selectionMode={Boolean(window.__qaLibrarySelectionMode)} />
) : active === "refund-records" ? (
<RefundRecordsPage api={api} />
) : active === "refund-management" ? (
<RefundManagementPage api={api} />
) : isAdminPage ? ( ) : isAdminPage ? (
<AdminPage type={active} /> <AdminPage type={active} user={user} />
) : ( ) : (
<AssistantPageFinal /> <AssistantPageFinal />
)} )}