修改返款系统,添加支付宝付款管理,添加支付宝配置证书
This commit is contained in:
parent
ccae5a3a3c
commit
5f9264fbb2
8
.gitignore
vendored
8
.gitignore
vendored
@ -1,7 +1,5 @@
|
||||
.env
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.pytest_cache/
|
||||
backend/.env
|
||||
frontend/node_modules/
|
||||
frontend/dist/
|
||||
data/uploads/
|
||||
backend/data/uploads/
|
||||
backend/keys/
|
||||
|
||||
@ -29,7 +29,7 @@ npm run dev
|
||||
|
||||
## 钉钉工作台免登
|
||||
|
||||
从钉钉工作台打开时,前端通过钉钉 JSAPI `requestAuthCode` 获取临时授权码,后端换取用户身份并建立会话,不再跳转到 OAuth 确认页。请在钉钉开放平台创建并发布企业内部应用,开通工作台免登/获取用户身份权限,然后在项目根目录 `.env` 填写:
|
||||
从钉钉工作台打开时,前端通过钉钉 JSAPI `requestAuthCode` 获取临时授权码,后端换取用户身份并建立会话,不再跳转到 OAuth 确认页。请在钉钉开放平台创建并发布企业内部应用,开通工作台免登/获取用户身份权限,然后在 `backend/.env` 填写:
|
||||
|
||||
```env
|
||||
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 回调,但保留回调配置兼容旧入口。未配置免登参数时,普通浏览器仍可使用账号密码登录。
|
||||
|
||||
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 接口。启动后端前设置:
|
||||
|
||||
也可以在项目根目录创建 `.env` 文件填写这些配置;后端启动时会自动读取。
|
||||
也可以在 `backend/.env` 文件填写这些配置;后端启动时会自动读取。
|
||||
|
||||
```powershell
|
||||
$env:LLM_API_KEY="你的 API Key"
|
||||
|
||||
@ -33,3 +33,23 @@ DINGTALK_SUPER_ADMIN_UNION_IDS=
|
||||
# 以下部门及其下级部门成员自动授予超级管理员;部门 ID 比名称更稳定
|
||||
DINGTALK_SUPER_ADMIN_DEPT_IDS=
|
||||
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
|
||||
BIN
backend/app/__pycache__/__init__.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/__init__.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/__init__.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/alipay_provider.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/alipay_provider.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/alipay_provider.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/alipay_provider.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/config.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/config.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/config.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/config.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/database.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/database.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/database.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/database.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/llm.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/llm.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/llm.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/llm.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/main.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/main.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/main.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/main.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/models.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/models.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/models.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/models.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/schemas.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/schemas.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/schemas.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/schemas.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/screenshot_storage.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/screenshot_storage.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/screenshot_storage.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/screenshot_storage.cpython-314.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/security.cpython-313.pyc
Normal file
BIN
backend/app/__pycache__/security.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/app/__pycache__/security.cpython-314.pyc
Normal file
BIN
backend/app/__pycache__/security.cpython-314.pyc
Normal file
Binary file not shown.
@ -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):
|
||||
raise NotImplementedError("支付宝代付接口尚未接入,禁止将订单标记为支付成功")
|
||||
class AlipayTransferError(RuntimeError):
|
||||
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
|
||||
|
||||
@ -4,10 +4,18 @@ from pathlib import Path
|
||||
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")
|
||||
|
||||
|
||||
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:
|
||||
app_name = "问大家运营管理后台"
|
||||
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.
|
||||
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()}
|
||||
# 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()
|
||||
|
||||
@ -1,7 +1,8 @@
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from .models import RefundRecipient, RefundPaymentAccount, RefundRecord
|
||||
from .schemas import RefundPaymentAccountSave, PaymentAccountAuthorization
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from .models import RefundRecipient, AlipayRefundOrder
|
||||
|
||||
import base64
|
||||
import csv
|
||||
@ -29,8 +30,9 @@ from sqlalchemy.orm import Session, defer, noload, object_session, selectinload
|
||||
from .config import settings
|
||||
from .database import Base, SessionLocal, engine, get_db
|
||||
from .llm import extract_questions_from_images, generate_answer_for_question, generate_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 .schemas import AdminUserCreate, AdminUserUpdate, LoginRequest, QABulkAnswerRequest, QAGenerateRequest, RegisterRequest
|
||||
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 .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 .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_PERMISSIONS = {
|
||||
"super_admin": ["assistant", "library", "create", "board", "review", "refunds", "usage", "users", "logs", "settings"],
|
||||
"dept_manager": ["assistant", "library", "create", "board", "review", "refunds"],
|
||||
"operator": ["assistant", "library", "create", "board", "review", "refunds"],
|
||||
"super_admin": ["assistant", "library", "create", "board", "review", "refund-records", "usage", "users", "logs", "settings", "refund-management"],
|
||||
"dept_manager": ["assistant", "library", "create", "board", "review", "refund-records"],
|
||||
"operator": ["assistant", "library", "create", "board", "review", "refund-records"],
|
||||
}
|
||||
|
||||
|
||||
@ -320,6 +322,13 @@ def seed_users(db: Session):
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
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)
|
||||
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)}
|
||||
@ -370,6 +379,7 @@ def startup():
|
||||
connection.execute(text("ALTER TABLE ask_questions ADD COLUMN source VARCHAR(32) NOT NULL DEFAULT 'AI生成'"))
|
||||
admin_user_columns = {column["name"] for column in inspect(engine).get_columns("admin_users")}
|
||||
admin_user_migrations = {
|
||||
"refund_payment_account_id": "INTEGER NULL",
|
||||
"dingtalk_user_id": "VARCHAR(128) NULL",
|
||||
"dingtalk_union_id": "VARCHAR(128) NULL",
|
||||
"dingtalk_corp_id": "VARCHAR(128) NULL",
|
||||
@ -473,6 +483,62 @@ def startup():
|
||||
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:
|
||||
token = request.cookies.get(settings.cookie_name)
|
||||
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 {}
|
||||
|
||||
|
||||
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:
|
||||
"""Best-effort enrichment of a DingTalk profile with department data."""
|
||||
data = dict(profile or {})
|
||||
@ -1113,17 +1276,25 @@ def _ask_screenshot_url(
|
||||
def _refund_recipient_data(record, target_kind: str) -> dict:
|
||||
"""Expose the payout identity saved alongside a user submission."""
|
||||
db = object_session(record)
|
||||
if db is None:
|
||||
return {"alipay_account": "", "payee_name": ""}
|
||||
recipient = db.scalar(
|
||||
select(RefundRecipient).where(
|
||||
RefundRecipient.target_kind == target_kind,
|
||||
RefundRecipient.target_id == record.id,
|
||||
)
|
||||
)
|
||||
) 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 {
|
||||
"alipay_account": recipient.account 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,
|
||||
token: str = Form(...),
|
||||
files: list[UploadFile] = File(...),
|
||||
payment_file: UploadFile = File(...),
|
||||
payment_file: UploadFile | None = File(None),
|
||||
payee_name: str = Form(default=""),
|
||||
alipay_account: str = Form(default=""),
|
||||
db: Session = Depends(get_db),
|
||||
@ -2124,13 +2295,13 @@ async def submit_public_ask_task(
|
||||
if not files:
|
||||
raise HTTPException(status_code=400, detail="请至少上传一张提问截图")
|
||||
allowed = {"image/jpeg", "image/png", "image/webp"}
|
||||
if any(upload.content_type not in allowed for upload in [*files, payment_file]):
|
||||
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 图片")
|
||||
selected_ids = [item.question_id for item in assignment.items if item.selected]
|
||||
assignment_id = assignment.id
|
||||
db.rollback()
|
||||
question_uploads = [(upload.filename or "question", upload.content_type, await upload.read()) for upload in files]
|
||||
payment_upload = (payment_file.filename or "payment", payment_file.content_type, await payment_file.read())
|
||||
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)
|
||||
if not assignment or assignment.status not in {"进行中", "未通过"} or _ask_assignment_expired(assignment):
|
||||
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"),
|
||||
))
|
||||
_save_refund_recipient(db, "ask_batch", batch.id, alipay_account, payee_name)
|
||||
filename, content_type, image_data = payment_upload
|
||||
db.add(AskAssignmentBatchScreenshot(
|
||||
batch_id=batch.id, stage="payment",
|
||||
**screenshot_fields(image_data, filename, content_type, "ask-batches"),
|
||||
))
|
||||
if payment_upload:
|
||||
filename, content_type, image_data = payment_upload
|
||||
db.add(AskAssignmentBatchScreenshot(
|
||||
batch_id=batch.id, stage="payment",
|
||||
**screenshot_fields(image_data, filename, content_type, "ask-batches"),
|
||||
))
|
||||
assignment.status = "待审核"
|
||||
assignment.reservation_started_at = None
|
||||
used_ids = {item.question_id for item in assignment.used_questions}
|
||||
@ -3247,6 +3419,47 @@ def admin_task_board_delete_ask_assignment(assignment_id: int, user: AdminUser =
|
||||
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):
|
||||
# Legacy clients can still submit screenshots without bank details.
|
||||
if not account.strip():
|
||||
@ -3261,69 +3474,6 @@ def _save_refund_recipient(db, kind, target_id, account, name):
|
||||
recipient.payee_name = name.strip()
|
||||
|
||||
|
||||
def _payable_target(db, kind, target_id, user):
|
||||
target, payable = _refund_session_target_for_payload(kind, target_id, db)
|
||||
if not target or not payable:
|
||||
raise HTTPException(status_code=409, detail="任务已不在待返款状态")
|
||||
assignment = target.assignment if kind == "ask_batch" else target
|
||||
allowed = _can_access_ask_task(assignment.task, user) if kind != "answer_submission" else _can_access_answer_submission(target, user)
|
||||
if not allowed:
|
||||
raise HTTPException(status_code=404, detail="无权操作该任务")
|
||||
return target
|
||||
|
||||
|
||||
@app.get("/api/admin/alipay-refunds")
|
||||
def list_alipay_refunds(user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||
result = []
|
||||
for kind, model, payable_status in [("answer_submission", AnswerSubmission, "已通过"), ("ask_batch", AskAssignmentBatch, "approved"), ("ask_assignment", AskAssignment, "已通过")]:
|
||||
for target in db.scalars(select(model).where(model.status == payable_status)).all():
|
||||
try:
|
||||
_payable_target(db, kind, target.id, user)
|
||||
except HTTPException:
|
||||
continue
|
||||
if kind == "ask_assignment" and target.batches:
|
||||
continue
|
||||
recipient = db.scalar(select(RefundRecipient).where(RefundRecipient.target_kind == kind, RefundRecipient.target_id == target.id))
|
||||
order = db.scalar(select(AlipayRefundOrder).where(AlipayRefundOrder.target_kind == kind, AlipayRefundOrder.target_id == target.id))
|
||||
assignment = target.assignment if kind == "ask_batch" else target
|
||||
detail_path = {"answer_submission": f"/api/admin/task-board/{target.id}", "ask_batch": f"/api/admin/task-board/ask-batches/{target.id}", "ask_assignment": f"/api/admin/task-board/ask-assignments/{target.id}"}[kind]
|
||||
result.append({"target_kind": kind, "target_id": target.id, "task_no": target.submission_no if kind == "answer_submission" else assignment.task.task_no, "account": order.account if order else recipient.account if recipient else "", "payee_name": order.payee_name if order else recipient.payee_name if recipient else "", "amount": str(Decimal(order.amount_fen) / 100) if order else "", "payment_status": "待支付(代付接口未接入)" if order else "待提交", "submitted": bool(order), "taobao_id": assignment.user.taobao_id if kind != "answer_submission" else target.user.taobao_id, "shop_name": assignment.task.shop_name if kind != "answer_submission" else target.shop_name, "product_name": assignment.task.product_name if kind != "answer_submission" else (target.questions[0].product_title if target.questions else "待识别商品"), "detail_endpoint": detail_path})
|
||||
return {"success": True, "data": result}
|
||||
|
||||
|
||||
@app.post("/api/admin/alipay-refunds")
|
||||
def submit_alipay_refunds(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||
items = payload.get("items")
|
||||
if not isinstance(items, list) or not 1 <= len(items) <= 100:
|
||||
raise HTTPException(status_code=400, detail="每次请选择1至100笔返款")
|
||||
seen = set()
|
||||
for item in items:
|
||||
try:
|
||||
kind, target_id = item["target_kind"], int(item["target_id"])
|
||||
amount = Decimal(str(item["amount"]))
|
||||
if not amount.is_finite() or amount <= 0 or amount > Decimal("100000") or amount != amount.quantize(Decimal("0.01")):
|
||||
raise ValueError()
|
||||
except (KeyError, TypeError, ValueError, InvalidOperation):
|
||||
raise HTTPException(status_code=400, detail="金额必须大于0,最多两位小数,单笔不超过100000元")
|
||||
if (kind, target_id) in seen:
|
||||
raise HTTPException(status_code=400, detail="请勿重复选择同一笔任务")
|
||||
seen.add((kind, target_id))
|
||||
_payable_target(db, kind, target_id, user)
|
||||
if db.scalar(select(AlipayRefundOrder.id).where(AlipayRefundOrder.target_kind == kind, AlipayRefundOrder.target_id == target_id)):
|
||||
raise HTTPException(status_code=409, detail="该任务已提交返款,请勿重复提交")
|
||||
account, name = str(item.get("account") or "").strip(), str(item.get("payee_name") or "").strip()
|
||||
if not account or not name or len(account) > 128 or len(name) > 64:
|
||||
raise HTTPException(status_code=400, detail="请填写支付宝账号和收款人姓名")
|
||||
db.add(AlipayRefundOrder(target_kind=kind, target_id=target_id, amount_fen=int(amount * 100), account=account, payee_name=name, created_by_id=user.id))
|
||||
try:
|
||||
db.commit()
|
||||
except IntegrityError:
|
||||
db.rollback()
|
||||
raise HTTPException(status_code=409, detail="任务已提交,请刷新后重试")
|
||||
# Provider integration intentionally not invoked; no money is transferred.
|
||||
return {"success": True, "data": {"count": len(items), "status": "pending", "message": "已保存,待支付;代付接口尚未接入"}}
|
||||
|
||||
|
||||
@app.post("/api/admin/refund-upload-sessions")
|
||||
def create_refund_upload_session(payload: dict, user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||
"""Create a short-lived, record-bound upload URL for the operator modal."""
|
||||
@ -3336,7 +3486,7 @@ def create_refund_upload_session(payload: dict, user: AdminUser = Depends(curren
|
||||
if not target or not payable:
|
||||
raise HTTPException(status_code=409, detail="当前任务不在待返款状态")
|
||||
if target_kind in {"ask_batch", "ask_assignment"}:
|
||||
if not _can_access_ask_task(target.assignment.task, user):
|
||||
if not _can_access_ask_task(target.assignment.task if target_kind == "ask_batch" else target.task, user):
|
||||
raise HTTPException(status_code=404, detail="无权操作该返款记录")
|
||||
elif not _can_access_answer_submission(target, user):
|
||||
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()}}
|
||||
|
||||
|
||||
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")
|
||||
async def confirm_ask_batch_refund(batch_id: int, refund_file: UploadFile | None = File(None), upload_token: str | None = Form(None), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||
batch = db.get(AskAssignmentBatch, batch_id)
|
||||
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, with_for_update=True)
|
||||
if not batch or not _can_access_ask_task(batch.assignment.task, user):
|
||||
raise HTTPException(status_code=404, detail="待返款提问记录不存在")
|
||||
if batch.status == "paid":
|
||||
raise HTTPException(status_code=409, detail="该任务已经完成返款")
|
||||
if batch.status != "approved":
|
||||
raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款")
|
||||
if refund_file is not None:
|
||||
refund_data, content_type, filename = await _read_refund_file(refund_file)
|
||||
for image in list(batch.screenshots):
|
||||
if image.stage == "refund":
|
||||
db.delete(image)
|
||||
db.add(AskAssignmentBatchScreenshot(
|
||||
batch_id=batch.id, stage="refund",
|
||||
**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()
|
||||
payer = _authorized_refund_payer(db, user, payer_account_id)
|
||||
_save_refund_amount(db, batch, "ask_batch", refund_amount, snapshot=True)
|
||||
alipay_trade_no = _perform_alipay_refund(db, batch, "ask_batch", user, payer)
|
||||
_snapshot_refund(db, batch, "ask_batch", user, payer.name, alipay_trade_no)
|
||||
batch.status = "paid"
|
||||
batch.updated_at = datetime.utcnow()
|
||||
_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")
|
||||
async def confirm_ask_assignment_refund(assignment_id: int, refund_file: UploadFile | None = File(None), upload_token: str | None = Form(None), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||
assignment = db.get(AskAssignment, assignment_id)
|
||||
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, with_for_update=True)
|
||||
if not assignment or not _can_access_ask_task(assignment.task, user):
|
||||
raise HTTPException(status_code=404, detail="待返款提问记录不存在")
|
||||
if assignment.status == "已完成":
|
||||
raise HTTPException(status_code=409, detail="该任务已经完成返款")
|
||||
if assignment.status != "已通过":
|
||||
raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款")
|
||||
if refund_file is not None:
|
||||
refund_data, content_type, filename = await _read_refund_file(refund_file)
|
||||
for image in list(assignment.screenshots):
|
||||
if image.stage == "refund":
|
||||
db.delete(image)
|
||||
db.add(AskAssignmentScreenshot(
|
||||
assignment_id=assignment.id, stage="refund",
|
||||
**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()
|
||||
payer = _authorized_refund_payer(db, user, payer_account_id)
|
||||
_save_refund_amount(db, assignment, "ask_assignment", refund_amount, snapshot=True)
|
||||
alipay_trade_no = _perform_alipay_refund(db, assignment, "ask_assignment", user, payer)
|
||||
_snapshot_refund(db, assignment, "ask_assignment", user, payer.name, alipay_trade_no)
|
||||
assignment.status = "已完成"
|
||||
assignment.updated_at = datetime.utcnow()
|
||||
_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")
|
||||
async def confirm_answer_refund(submission_id: int, refund_file: UploadFile | None = File(None), upload_token: str | None = Form(None), user: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||
record = db.get(AnswerSubmission, submission_id)
|
||||
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, with_for_update=True)
|
||||
if not record or not _can_access_answer_submission(record, user):
|
||||
raise HTTPException(status_code=404, detail="待返款回答记录不存在")
|
||||
if record.status == "已完成":
|
||||
raise HTTPException(status_code=409, detail="该任务已经完成返款")
|
||||
if record.status != "已通过":
|
||||
raise HTTPException(status_code=409, detail="只有审核通过的任务才能确认返款")
|
||||
if refund_file is not None:
|
||||
refund_data, content_type, filename = await _read_refund_file(refund_file)
|
||||
for image in list(record.screenshots):
|
||||
if image.stage == "refund":
|
||||
db.delete(image)
|
||||
db.add(AnswerScreenshot(
|
||||
submission_id=record.id, stage="refund",
|
||||
**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()
|
||||
payer = _authorized_refund_payer(db, user, payer_account_id)
|
||||
_save_refund_amount(db, record, "answer_submission", refund_amount, snapshot=True)
|
||||
alipay_trade_no = _perform_alipay_refund(db, record, "answer_submission", user, payer)
|
||||
_snapshot_refund(db, record, "answer_submission", user, payer.name, alipay_trade_no)
|
||||
record.status = "已完成"
|
||||
db.commit(); db.refresh(record)
|
||||
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
|
||||
if record_status not in ("待用户完成", "未通过"): raise HTTPException(status_code=400, detail="当前任务不能提交完成截图")
|
||||
if not files or len(files) > 10: raise HTTPException(status_code=400, detail="最多上传10张截图")
|
||||
if not payment_files or len(payment_files) > 1: raise HTTPException(status_code=400, detail="请上传收款二维码后再提交")
|
||||
if len(payment_files) > 1: raise HTTPException(status_code=400, detail="最多上传一张收款二维码")
|
||||
db.rollback()
|
||||
uploaded = []
|
||||
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]}
|
||||
|
||||
|
||||
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")
|
||||
def users(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
|
||||
records = db.scalars(select(AdminUser).order_by(AdminUser.id.asc())).all()
|
||||
def users(current: AdminUser = Depends(current_user), db: Session = Depends(get_db)):
|
||||
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 {
|
||||
"success": True,
|
||||
"data": [
|
||||
@ -4664,6 +5119,8 @@ def users(_: AdminUser = Depends(require_super_admin), db: Session = Depends(get
|
||||
"username": user.username,
|
||||
"display_name": user.display_name,
|
||||
"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),
|
||||
"dingtalk_dept_id": user.dingtalk_dept_id,
|
||||
"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}")
|
||||
def user_detail(user_id: int, _: AdminUser = Depends(require_super_admin), db: Session = Depends(get_db)):
|
||||
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)}}
|
||||
|
||||
|
||||
@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}")
|
||||
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)
|
||||
|
||||
@ -7,6 +7,64 @@ from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
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):
|
||||
__tablename__ = "refund_recipients"
|
||||
__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)
|
||||
account: Mapped[str] = mapped_column(String(128))
|
||||
payee_name: Mapped[str] = mapped_column(String(64))
|
||||
|
||||
|
||||
class AlipayRefundOrder(Base):
|
||||
__tablename__ = "alipay_refund_orders"
|
||||
__table_args__ = (UniqueConstraint("target_kind", "target_id", name="uq_alipay_refund_target"),)
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
target_kind: Mapped[str] = mapped_column(String(32))
|
||||
target_id: Mapped[int] = mapped_column(Integer)
|
||||
amount_fen: Mapped[int] = mapped_column(Integer)
|
||||
account: Mapped[str] = mapped_column(String(128))
|
||||
payee_name: Mapped[str] = mapped_column(String(64))
|
||||
created_by_id: Mapped[int] = mapped_column(Integer)
|
||||
status: Mapped[str] = mapped_column(String(32), default="pending")
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
|
||||
amount_fen: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
|
||||
|
||||
class AdminUser(Base):
|
||||
@ -39,6 +84,7 @@ class AdminUser(Base):
|
||||
password_hash: Mapped[str] = mapped_column(String(255))
|
||||
display_name: Mapped[str] = mapped_column(String(64))
|
||||
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_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)
|
||||
|
||||
@ -1,4 +1,19 @@
|
||||
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):
|
||||
@ -6,6 +21,10 @@ class LoginRequest(BaseModel):
|
||||
password: str = Field(min_length=1, max_length=128)
|
||||
|
||||
|
||||
class PaymentAccountAuthorization(BaseModel):
|
||||
account_id: int | None = Field(..., gt=0, strict=True)
|
||||
|
||||
|
||||
class RegisterRequest(BaseModel):
|
||||
username: str = Field(min_length=3, max_length=64, pattern=r"^[A-Za-z0-9_.-]+$")
|
||||
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)$")
|
||||
|
||||
|
||||
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):
|
||||
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)
|
||||
|
||||
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.
Binary file not shown.
Binary file not shown.
@ -5,5 +5,8 @@ pymysql>=1.1,<2.0
|
||||
python-multipart>=0.0.20,<1.0
|
||||
httpx2>=2.12,<3.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
|
||||
pillow>=11.0,<13.0
|
||||
|
||||
@ -7,6 +7,7 @@ CREATE TABLE IF NOT EXISTS admin_users (
|
||||
password_hash VARCHAR(255) NOT NULL,
|
||||
display_name VARCHAR(64) NOT NULL,
|
||||
role_code VARCHAR(32) NOT NULL DEFAULT 'operator',
|
||||
refund_payment_account_id INTEGER NULL,
|
||||
answer_public_token VARCHAR(96) NULL,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
last_login_at DATETIME NULL,
|
||||
@ -25,6 +26,7 @@ CREATE TABLE IF NOT EXISTS refund_recipients (
|
||||
target_id BIGINT NOT NULL,
|
||||
account VARCHAR(128) NOT NULL,
|
||||
payee_name VARCHAR(64) NOT NULL,
|
||||
amount_fen INTEGER NULL,
|
||||
PRIMARY KEY (id),
|
||||
UNIQUE KEY uk_refund_recipient_target (target_kind, target_id)
|
||||
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
|
||||
@ -44,6 +46,33 @@ CREATE TABLE IF NOT EXISTS alipay_refund_orders (
|
||||
KEY idx_alipay_refund_orders_status (status)
|
||||
) 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 (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
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)
|
||||
) 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 (
|
||||
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
|
||||
job_id BIGINT UNSIGNED NOT NULL,
|
||||
|
||||
BIN
backend/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc
Normal file
BIN
backend/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc
Normal file
Binary file not shown.
BIN
backend/tests/__pycache__/conftest.cpython-313.pyc
Normal file
BIN
backend/tests/__pycache__/conftest.cpython-313.pyc
Normal file
Binary file not shown.
BIN
backend/tests/__pycache__/conftest.cpython-314-pytest-8.4.1.pyc
Normal file
BIN
backend/tests/__pycache__/conftest.cpython-314-pytest-8.4.1.pyc
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
BIN
backend/tests/__pycache__/test_auth.cpython-313-pytest-9.1.1.pyc
Normal file
BIN
backend/tests/__pycache__/test_auth.cpython-313-pytest-9.1.1.pyc
Normal file
Binary file not shown.
BIN
backend/tests/__pycache__/test_auth.cpython-313.pyc
Normal file
BIN
backend/tests/__pycache__/test_auth.cpython-313.pyc
Normal file
Binary file not shown.
Binary file not shown.
BIN
backend/tests/__pycache__/test_llm_ocr.cpython-313.pyc
Normal file
BIN
backend/tests/__pycache__/test_llm_ocr.cpython-313.pyc
Normal file
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.
@ -13,3 +13,6 @@ os.environ["ADMIN_USERNAME"] = "admin"
|
||||
os.environ["ADMIN_PASSWORD"] = "admin123456"
|
||||
os.environ["OPERATOR_USERNAME"] = "operator"
|
||||
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"
|
||||
|
||||
40
backend/tests/test_alipay_callback.py
Normal file
40
backend/tests/test_alipay_callback.py
Normal 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"
|
||||
151
backend/tests/test_alipay_provider.py
Normal file
151
backend/tests/test_alipay_provider.py
Normal 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
|
||||
@ -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
|
||||
@ -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():
|
||||
with TestClient(app) as client:
|
||||
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():
|
||||
with TestClient(app) as client:
|
||||
assert client.post("/api/admin/auth/login", json={"username": "operator", "password": "operator123456"}).status_code == 200
|
||||
payer_id = authorize_test_payer()
|
||||
generated = client.post(
|
||||
"/api/admin/ask-tasks/generate",
|
||||
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"
|
||||
refunded = client.post(
|
||||
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.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"]
|
||||
|
||||
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.post(
|
||||
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
|
||||
refunded = client.post(
|
||||
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.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.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)
|
||||
completed_payout = next(
|
||||
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):
|
||||
monkeypatch.setattr(main_module, "generate_questions", fake_ask_questions)
|
||||
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(
|
||||
"/api/admin/auth/login",
|
||||
json={"username": "operator", "password": "operator123456"},
|
||||
@ -1507,7 +1535,7 @@ def test_ask_batches_survive_reservation_timeout_and_parent_completes_only_after
|
||||
).status_code == 200
|
||||
assert client.post(
|
||||
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
|
||||
|
||||
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
|
||||
assert client.post(
|
||||
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
|
||||
|
||||
completed_board = task_board_items(client, task_type="ask", page_size=100)
|
||||
|
||||
72
backend/tests/test_payment_account_authorization.py
Normal file
72
backend/tests/test_payment_account_authorization.py
Normal 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
|
||||
118
backend/tests/test_refund_amounts.py
Normal file
118
backend/tests/test_refund_amounts.py
Normal 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
|
||||
109
backend/tests/test_refund_payment_accounts.py
Normal file
109
backend/tests/test_refund_payment_accounts.py
Normal 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
|
||||
118
backend/tests/test_refund_records.py
Normal file
118
backend/tests/test_refund_records.py
Normal 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
|
||||
BIN
data/app.db
BIN
data/app.db
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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>;
|
||||
}
|
||||
@ -71,7 +71,7 @@ function ReviewStage({ latest, reload, busy, startNew }) {
|
||||
function AssignmentStage({ api, session, latest, reload, setError, linkToken }) {
|
||||
const selected = latest.questions.filter(question => question.selected); const progressKey = `answer_progress_${latest.id}`
|
||||
const [done, setDone] = useState(() => { try { return JSON.parse(localStorage.getItem(progressKey) || '[]') } catch { return [] } })
|
||||
const [tab, setTab] = useState('list'); const [files, setFiles] = useState([]); const [payFiles, setPayFiles] = useState([]); const [busy, setBusy] = useState(false)
|
||||
const [tab, setTab] = useState('list'); const [files, setFiles] = useState([]); const [busy, setBusy] = useState(false)
|
||||
const [alipayAccount, setAlipayAccount] = useState(''); const [payeeName, setPayeeName] = useState('')
|
||||
const completed = selected.filter(question => done.includes(question.id)).length
|
||||
useEffect(() => {
|
||||
@ -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]) }
|
||||
async function copy(question) { try { await navigator.clipboard.writeText(question.answer || ''); if (!done.includes(question.id)) saveDone([...done, question.id]) } catch { setError('复制失败,请长按参考回答手动复制') } }
|
||||
async function complete(event) {
|
||||
event.preventDefault(); setError(''); if (!files.length) return setError('请至少上传一张完成回答截图'); if (!payFiles.length) return setError('请上传收款二维码后再提交')
|
||||
event.preventDefault(); setError(''); if (!files.length) return setError('请至少上传一张完成回答截图')
|
||||
if (!alipayAccount.trim() || !payeeName.trim()) return setError('请填写支付宝账号和收款人姓名')
|
||||
const body = new FormData(); body.append('token', session.token); if (linkToken) body.append('owner_token', linkToken); files.forEach(file => body.append('files', file)); payFiles.forEach(file => body.append('payment_files', file)); setBusy(true)
|
||||
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())
|
||||
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 }) {
|
||||
|
||||
@ -429,14 +429,12 @@ function ConfirmStage({
|
||||
(item) => item.selected,
|
||||
);
|
||||
const [files, setFiles] = useState([]);
|
||||
const [paymentFile, setPaymentFile] = useState(null);
|
||||
const [payeeName, setPayeeName] = useState("");
|
||||
const [alipayAccount, setAlipayAccount] = useState("");
|
||||
const [reviewOpen, setReviewOpen] = useState(false);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [copiedId, setCopiedId] = useState(null);
|
||||
const inputRef = useRef(null);
|
||||
const paymentInputRef = useRef(null);
|
||||
const previews = useMemo(
|
||||
() => files.map((file) => ({ file, url: URL.createObjectURL(file) })),
|
||||
[files],
|
||||
@ -474,7 +472,6 @@ function ConfirmStage({
|
||||
const body = new FormData();
|
||||
body.append("token", session.token);
|
||||
files.forEach((file) => body.append("files", file));
|
||||
body.append("payment_file", paymentFile);
|
||||
body.append("payee_name", payeeName);
|
||||
body.append("alipay_account", alipayAccount.trim());
|
||||
try {
|
||||
@ -606,37 +603,10 @@ function ConfirmStage({
|
||||
<header>
|
||||
<div>
|
||||
<b>2</b>
|
||||
<strong>上传收款码</strong>
|
||||
<small>审核通过后将按原路径结算至此账户</small>
|
||||
<strong>填写收款信息</strong>
|
||||
<small>请填写准确的支付宝账号及实名姓名</small>
|
||||
</div>
|
||||
<span>{paymentFile ? "已上传" : "未上传"}</span>
|
||||
</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>
|
||||
支付宝账号(必填)
|
||||
<input value={alipayAccount} maxLength={128} onChange={(event) => setAlipayAccount(event.target.value)} placeholder="请输入支付宝账号(手机号或邮箱)" />
|
||||
@ -645,6 +615,7 @@ function ConfirmStage({
|
||||
收款人真实姓名(必填)
|
||||
<input
|
||||
value={payeeName}
|
||||
maxLength={64}
|
||||
onChange={(event) => setPayeeName(event.target.value)}
|
||||
placeholder="请输入收款人姓名"
|
||||
/>
|
||||
@ -652,7 +623,7 @@ function ConfirmStage({
|
||||
</section>
|
||||
<button
|
||||
className="ask-submit-review"
|
||||
disabled={!files.length || !paymentFile || !alipayAccount.trim() || !payeeName.trim()}
|
||||
disabled={!files.length || !alipayAccount.trim() || !payeeName.trim()}
|
||||
onClick={() => setReviewOpen(true)}
|
||||
>
|
||||
下一步:核对并提交审核
|
||||
@ -683,8 +654,8 @@ function ConfirmStage({
|
||||
<b>已上传({files.length}张)</b>
|
||||
</p>
|
||||
<p>
|
||||
<span>收款码</span>
|
||||
<b>已上传</b>
|
||||
<span>支付宝收款信息</span>
|
||||
<b>{payeeName} / {alipayAccount}</b>
|
||||
</p>
|
||||
</div>
|
||||
<aside>
|
||||
|
||||
131
frontend/src/RefundManagementPage.jsx
Normal file
131
frontend/src/RefundManagementPage.jsx
Normal 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>}
|
||||
</>;
|
||||
}
|
||||
92
frontend/src/RefundRecordsPage.jsx
Normal file
92
frontend/src/RefundRecordsPage.jsx
Normal 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>
|
||||
</>;
|
||||
}
|
||||
@ -156,11 +156,10 @@ function ReviewDetail({ record, close, decide, busy, hasNext, onNext, nextBusy }
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
{!!record.payment_screenshots?.length && <section>
|
||||
<h3>③ 返款二维码截图</h3>
|
||||
<div>
|
||||
{record.payment_screenshots?.length ? (
|
||||
record.payment_screenshots.map((image) => (
|
||||
{record.payment_screenshots.map((image) => (
|
||||
<figure key={image.id}>
|
||||
<img
|
||||
src={image.data_url}
|
||||
@ -170,12 +169,9 @@ function ReviewDetail({ record, close, decide, busy, hasNext, onNext, nextBusy }
|
||||
/>
|
||||
<figcaption>审核通过后用于后续人工处理</figcaption>
|
||||
</figure>
|
||||
))
|
||||
) : (
|
||||
<div className="review-v2-no-payment">未单独上传收款码</div>
|
||||
)}
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>}
|
||||
</div>
|
||||
{record.latest_review && !pending && (
|
||||
<div
|
||||
@ -332,7 +328,7 @@ function AskReviewDetail({ record, close, api, onDecision, hasNext, onNext, next
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
<section>
|
||||
{!!record.payment_screenshots?.length && <section>
|
||||
<h3>③ 用户上传的收款码</h3>
|
||||
<div>
|
||||
{record.payment_screenshots.map((image) => (
|
||||
@ -347,7 +343,7 @@ function AskReviewDetail({ record, close, api, onDecision, hasNext, onNext, next
|
||||
</figure>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
</section>}
|
||||
</div>
|
||||
{record.latest_review && record.status !== "待审核" && (
|
||||
<div
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { ChevronRight, LoaderCircle, Search, X } from "lucide-react";
|
||||
import { qrDataUrl } from "./qrCode.js";
|
||||
|
||||
const STATUS_FILTERS = [
|
||||
["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 [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 refundPasteRef = useRef(null);
|
||||
const isAskTask =
|
||||
task.task_type === "ask" || task.task_type_label === "提问任务";
|
||||
const taskLink = isAskTask
|
||||
@ -70,102 +88,28 @@ export function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload,
|
||||
];
|
||||
const paymentScreenshots = task.payment_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(() => {
|
||||
if (!refundOpen) return undefined;
|
||||
const pasteImage = (event) => {
|
||||
const imageItem = Array.from(event.clipboardData?.items || []).find((item) =>
|
||||
item.kind === "file" && item.type.startsWith("image/")
|
||||
);
|
||||
if (!imageItem) {
|
||||
setRefundFileError("剪贴板中没有图片,请先复制或截图后再按 Ctrl+V");
|
||||
return;
|
||||
}
|
||||
const blob = imageItem.getAsFile();
|
||||
if (!blob) {
|
||||
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]);
|
||||
let cancelled = false;
|
||||
setAccountsBusy(true);
|
||||
setRefundError("");
|
||||
setPaymentAccounts([]);
|
||||
api("/api/admin/refund-payment-accounts/available").then((result) => {
|
||||
if (!cancelled) setPaymentAccounts(result.data);
|
||||
}).catch((error) => { if (!cancelled) setRefundError(error.message); })
|
||||
.finally(() => { if (!cancelled) setAccountsBusy(false); });
|
||||
return () => { cancelled = true; };
|
||||
}, [refundOpen, api]);
|
||||
async function confirmRefund() {
|
||||
if (!validAmount) return setRefundError("请输入大于0且最多两位小数的金额");
|
||||
if (!payerAccount) return setRefundError("请选择付款账户");
|
||||
setRefundBusy(true);
|
||||
setRefundError("");
|
||||
try {
|
||||
await onConfirmRefund(task, refundFile, refundSession?.uploaded ? refundSession.token : null);
|
||||
await onConfirmRefund(task, amount, payerAccount);
|
||||
setRefundOpen(false);
|
||||
setRefundSession(null);
|
||||
setRefundFile(null);
|
||||
} catch (error) {
|
||||
window.alert(error.message);
|
||||
setRefundError(error.message);
|
||||
} finally {
|
||||
setRefundBusy(false);
|
||||
}
|
||||
@ -264,6 +208,17 @@ export function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload,
|
||||
<b>{task.payee_name || "未填写(历史任务)"}</b>
|
||||
</span>
|
||||
</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>
|
||||
<div className="task-detail-table">
|
||||
<div className="head">
|
||||
@ -352,7 +307,7 @@ export function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload,
|
||||
<p>未上传完成截图</p>
|
||||
)}
|
||||
</section>
|
||||
<section className="payment">
|
||||
{paymentScreenshots.length > 0 && <section className="payment">
|
||||
<h3>收款码</h3>
|
||||
{paymentScreenshots.length ? (
|
||||
<div>
|
||||
@ -367,9 +322,9 @@ export function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload,
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p>用户未单独上传收款码</p>
|
||||
<p>历史收款码</p>
|
||||
)}
|
||||
</section>
|
||||
</section>}
|
||||
</div>
|
||||
<footer>
|
||||
{!readOnly && task.refund_endpoint && (
|
||||
@ -377,10 +332,7 @@ export function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload,
|
||||
type="button"
|
||||
className="confirm-refund"
|
||||
onClick={() => {
|
||||
setRefundSession(null);
|
||||
setRefundFile(null);
|
||||
setRefundFileError("");
|
||||
setRefundQr("");
|
||||
setRefundError("");
|
||||
setRefundOpen(true);
|
||||
}}
|
||||
>
|
||||
@ -404,76 +356,32 @@ export function TaskDetail({ task, close, onConfirmRefund, onCreateRefundUpload,
|
||||
<div
|
||||
className="refund-confirm-backdrop"
|
||||
onMouseDown={(event) =>
|
||||
event.target === event.currentTarget && setRefundOpen(false)
|
||||
!refundBusy && event.target === event.currentTarget && setRefundOpen(false)
|
||||
}
|
||||
>
|
||||
<section className="refund-confirm-modal">
|
||||
<span>扫码</span>
|
||||
<h2>确认已经完成返款?</h2>
|
||||
<p>
|
||||
请确认已按用户上传的收款码完成付款。确认后,该任务将归入“已完成”,此操作不可撤销。
|
||||
</p>
|
||||
<div className="refund-scan-upload">
|
||||
<div>
|
||||
<strong>扫码上传返款凭证</strong>
|
||||
<small>二维码有效期 10 分钟</small>
|
||||
{refundSessionError && <em>{refundSessionError}</em>}
|
||||
{refundSession?.uploaded && <b>✓ 手机端已上传,运营端可确认返款</b>}
|
||||
</div>
|
||||
{refundQr ? <img src={refundQr} alt="扫码上传返款凭证" /> : <i>正在生成二维码…</i>}
|
||||
</div>
|
||||
<div
|
||||
ref={refundPasteRef}
|
||||
className={`refund-proof-upload${refundFile ? " has-image" : ""}`}
|
||||
tabIndex={0}
|
||||
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>
|
||||
<h2>确认返款</h2>
|
||||
<p>收款人:{task.payee_name || "未填写"}<br />支付宝账号:{task.alipay_account || "未填写"}</p>
|
||||
<fieldset className="refund-record-fields" disabled={refundBusy}>
|
||||
<label>返款金额(元)
|
||||
<input type="number" min="0.01" max="999999.99" step="0.01" value={amount} onChange={(event) => setAmount(event.target.value)} />
|
||||
<small>默认按每条问答 0.50 元计算,可修改金额。</small>
|
||||
</label>
|
||||
<label>付款账户
|
||||
<select value={payerAccount} disabled={accountsBusy} onChange={(event) => setPayerAccount(event.target.value)}>
|
||||
<option value="">{accountsBusy ? "正在加载账户…" : "请选择付款账户"}</option>
|
||||
{paymentAccounts.map((account) => <option key={account.id} value={account.id}>{account.name}({account.alipay_account})</option>)}
|
||||
</select>
|
||||
{!accountsBusy && paymentAccounts.length === 0 && <small>暂无可用付款账户,请联系主管或超级管理员检查账户授权及启用状态。</small>}
|
||||
</label>
|
||||
<label>支付宝流水<input readOnly value={task.alipay_trade_no || "确认后由支付宝返回"} /></label>
|
||||
</fieldset>
|
||||
<p className="refund-confirm-notice">确认后系统将按所选付款账户向收款支付宝账号发起转账;支付宝返回流水号后会同步到返款记录。若接口返回处理中,请先查询状态再重试,避免重复付款。</p>
|
||||
{refundError && <p role="alert" className="refund-confirm-error">{refundError}</p>}
|
||||
<div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={refundBusy}
|
||||
onClick={() => setRefundOpen(false)}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="confirm"
|
||||
disabled={refundBusy || (!refundFile && !refundSession?.uploaded)}
|
||||
onClick={confirmRefund}
|
||||
>
|
||||
{refundBusy ? "确认中…" : "确定返款完成"}
|
||||
<button type="button" disabled={refundBusy} onClick={() => setRefundOpen(false)}>取消</button>
|
||||
<button type="button" className="confirm" disabled={refundBusy || accountsBusy || !validAmount || !paymentAccounts.some((account) => String(account.id) === payerAccount)} onClick={confirmRefund}>
|
||||
{refundBusy ? "确认中…" : "确认返款"}
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
@ -723,22 +631,20 @@ export default function TaskBoardPage({ api, user }) {
|
||||
setBusy("");
|
||||
}
|
||||
}
|
||||
async function createRefundUpload(task) {
|
||||
const result = await api("/api/admin/refund-upload-sessions", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ target_kind: task.record_kind, target_id: task.id }),
|
||||
async function saveRefundAmount(task, amount) {
|
||||
const result = await api("/api/admin/task-board/refund-amount", {
|
||||
method: "PATCH",
|
||||
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) {
|
||||
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("请先粘贴返款凭证,或用手机扫码上传");
|
||||
async function confirmRefund(task, amount, payerAccount) {
|
||||
const body = new FormData();
|
||||
if (refundFile) body.append("refund_file", refundFile);
|
||||
if (uploadToken) body.append("upload_token", uploadToken);
|
||||
body.append("refund_amount", amount);
|
||||
body.append("payer_account_id", payerAccount);
|
||||
const result = await api(task.refund_endpoint, { method: "POST", body });
|
||||
const updatedDetail = {
|
||||
...task,
|
||||
@ -945,8 +851,9 @@ export default function TaskBoardPage({ api, user }) {
|
||||
task={detail}
|
||||
close={() => setDetail(null)}
|
||||
onConfirmRefund={confirmRefund}
|
||||
onCreateRefundUpload={createRefundUpload}
|
||||
onPollRefundUpload={pollRefundUpload}
|
||||
onSaveRefundAmount={saveRefundAmount}
|
||||
api={api}
|
||||
|
||||
hasNextRefund={Boolean(nextRefundTask)}
|
||||
nextBusy={Boolean(detailLoading)}
|
||||
onNextRefund={() => openDetail(nextRefundTask)}
|
||||
|
||||
@ -160,6 +160,50 @@
|
||||
color: #18355c;
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
@ -623,30 +667,6 @@
|
||||
display: grid;
|
||||
gap: 18px;
|
||||
}
|
||||
.alipay-refund-panel {
|
||||
width: min(1200px, 95vw) !important;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.alipay-refund-panel.panel {
|
||||
width: 100% !important;
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
padding: 22px;
|
||||
}
|
||||
.alipay-refund-scroll { overflow: auto; min-height: 100px; }
|
||||
.alipay-refund-scroll table { min-width: 1000px; }
|
||||
.alipay-refund-scroll th,
|
||||
.alipay-refund-scroll td { vertical-align: middle; }
|
||||
.alipay-refund-scroll td:first-child { width: 42px; }
|
||||
.alipay-refund-scroll td:nth-child(2) { min-width: 300px; }
|
||||
.alipay-refund-scroll td:nth-child(3),
|
||||
.alipay-refund-scroll td:nth-child(4) { min-width: 165px; }
|
||||
.alipay-refund-scroll td:nth-child(5) { min-width: 120px; }
|
||||
.alipay-refund-scroll td:last-child { min-width: 170px; white-space: nowrap; }
|
||||
.alipay-refund-scroll input:not([type="checkbox"]) { width: 100%; min-width: 100px; padding: 8px; border: 1px solid #cbdaf0; border-radius: 6px; }
|
||||
.alipay-refund-scroll small { display: block; color: #7187a3; }
|
||||
.usage-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
@ -4613,6 +4633,9 @@
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 18px;
|
||||
}
|
||||
.review-v2-media > section:only-child {
|
||||
grid-column: 1 / -1;
|
||||
}
|
||||
.review-v2-media section > div {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, 1fr);
|
||||
@ -4634,15 +4657,6 @@
|
||||
color: #7f92aa;
|
||||
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 {
|
||||
margin-top: 15px;
|
||||
padding: 12px;
|
||||
@ -6486,3 +6500,72 @@ html.dingtalk-mobile .answer-v2-footer {
|
||||
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; } }
|
||||
|
||||
@ -22,12 +22,13 @@ import CreateAnswerTasksPageV2 from "./CreateAnswerTasksPage.jsx";
|
||||
import AnswerUserPortalV2 from "./AnswerUserPortal.jsx";
|
||||
import TaskBoardPage, { clearTaskBoardCache } from "./TaskBoardPage.jsx";
|
||||
import ReviewCenterPage from "./ReviewCenterPage.jsx";
|
||||
import AlipayRefundPanel from "./AlipayRefundPanel.jsx";
|
||||
import CreateAskTasksPage from "./CreateAskTasksPage.jsx";
|
||||
import AskUserPortal from "./AskUserPortal.jsx";
|
||||
import RefundUploadPage from "./RefundUploadPage.jsx";
|
||||
import RefundManagementPage from "./RefundManagementPage.jsx";
|
||||
import RefundRecordsPage from "./RefundRecordsPage.jsx";
|
||||
import { analyzeProductIntent, validateGenerationOptions } from "./productIntent.js";
|
||||
import { readPageCache, writePageCache } from "./pageCache.js";
|
||||
import { clearPageCache, readPageCache, writePageCache } from "./pageCache.js";
|
||||
import { formatDateTime } from "./time.js";
|
||||
|
||||
// 兼容公共回答链接末尾有无斜杠,避免 /answer 被识别为运营后台路由。
|
||||
@ -332,9 +333,9 @@ const BASE_ITEMS = [
|
||||
group: "\u4efb\u52a1\u7ba1\u7406",
|
||||
},
|
||||
{
|
||||
id: "refunds",
|
||||
id: "refund-records",
|
||||
icon: FileText,
|
||||
label: "支付宝返款",
|
||||
label: "返款记录",
|
||||
group: "任务管理",
|
||||
},
|
||||
{
|
||||
@ -370,6 +371,12 @@ const ADMIN_ITEMS = [
|
||||
label: "\u7cfb\u7edf\u914d\u7f6e",
|
||||
group: "\u7cfb\u7edf\u7ba1\u7406",
|
||||
},
|
||||
{
|
||||
id: "refund-management",
|
||||
icon: Database,
|
||||
label: "返款管理",
|
||||
group: "系统管理",
|
||||
},
|
||||
];
|
||||
|
||||
function Sidebar({ user, active, setActive, reviewBadge }) {
|
||||
@ -379,7 +386,9 @@ function Sidebar({ user, active, setActive, reviewBadge }) {
|
||||
const items =
|
||||
user.role_code === "super_admin"
|
||||
? [...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))];
|
||||
return (
|
||||
<aside className="sidebar">
|
||||
@ -1794,6 +1803,31 @@ function AssistantPageFinal() {
|
||||
setReferenceQa("");
|
||||
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() {
|
||||
const intentError = analyzeProductIntent([
|
||||
form.product_name,
|
||||
@ -2197,6 +2231,14 @@ function AssistantPageFinal() {
|
||||
>
|
||||
↻ 重新生成
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="ask-clear-all"
|
||||
onClick={clearAllAssistant}
|
||||
disabled={loading || saving}
|
||||
>
|
||||
一键清空
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{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 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 [createMode, setCreateMode] = useState("account");
|
||||
const [lookingUp, setLookingUp] = useState(false);
|
||||
const [form, setForm] = useState({
|
||||
username: "",
|
||||
password: "",
|
||||
display_name: "",
|
||||
role_code: "operator",
|
||||
is_active: true,
|
||||
identifier: "",
|
||||
dingtalk_profile: null,
|
||||
});
|
||||
function open(user) {
|
||||
setCreateMode("account");
|
||||
setForm(
|
||||
user
|
||||
? {
|
||||
@ -2258,6 +2343,8 @@ function UserTable({ users = [], onChanged }) {
|
||||
display_name: user.display_name,
|
||||
role_code: user.role_code,
|
||||
is_active: user.is_active,
|
||||
identifier: "",
|
||||
dingtalk_profile: null,
|
||||
}
|
||||
: {
|
||||
username: "",
|
||||
@ -2265,6 +2352,8 @@ function UserTable({ users = [], onChanged }) {
|
||||
display_name: "",
|
||||
role_code: "operator",
|
||||
is_active: true,
|
||||
identifier: "",
|
||||
dingtalk_profile: null,
|
||||
},
|
||||
);
|
||||
setEditing(user || "new");
|
||||
@ -2272,12 +2361,21 @@ function UserTable({ users = [], onChanged }) {
|
||||
async function save(event) {
|
||||
event.preventDefault();
|
||||
try {
|
||||
if (editing === "new")
|
||||
await api("/api/admin/users", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(form),
|
||||
});
|
||||
else
|
||||
if (editing === "new") {
|
||||
await api(
|
||||
createMode === "dingtalk"
|
||||
? "/api/admin/users/dingtalk"
|
||||
: "/api/admin/users",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(
|
||||
createMode === "dingtalk"
|
||||
? { identifier: form.identifier, role_code: form.role_code }
|
||||
: form,
|
||||
),
|
||||
},
|
||||
);
|
||||
} else {
|
||||
await api(`/api/admin/users/${editing.id}`, {
|
||||
method: "PATCH",
|
||||
body: JSON.stringify({
|
||||
@ -2287,12 +2385,38 @@ function UserTable({ users = [], onChanged }) {
|
||||
is_active: form.is_active,
|
||||
}),
|
||||
});
|
||||
}
|
||||
setEditing(null);
|
||||
onChanged();
|
||||
} catch (error) {
|
||||
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) {
|
||||
if (
|
||||
!window.confirm(
|
||||
@ -2310,11 +2434,35 @@ function UserTable({ users = [], onChanged }) {
|
||||
return (
|
||||
<>
|
||||
<div className="user-table-actions">
|
||||
<button className="primary-button" onClick={() => open(null)}>
|
||||
{isSuperAdmin ? <button className="primary-button" onClick={() => open(null)}>
|
||||
<Plus size={16} />
|
||||
{"\u65b0\u589e\u7528\u6237"}
|
||||
</button>
|
||||
</button> : <span>可为本部门及下级部门的普通运营设置付款账户授权。</span>}
|
||||
</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 && (
|
||||
<div className="user-modal-backdrop">
|
||||
<form className="user-modal panel" onSubmit={save}>
|
||||
@ -2324,6 +2472,66 @@ function UserTable({ users = [], onChanged }) {
|
||||
: "\u7f16\u8f91\u7528\u6237"}
|
||||
</h3>
|
||||
{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>
|
||||
{"\u7528\u6237\u540d"}
|
||||
<input
|
||||
@ -2339,26 +2547,29 @@ function UserTable({ users = [], onChanged }) {
|
||||
{"\u663e\u793a\u540d\u79f0"}
|
||||
<input
|
||||
required
|
||||
readOnly={editing === "new" && createMode === "dingtalk"}
|
||||
value={form.display_name}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, display_name: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
{editing === "new"
|
||||
? "\u521d\u59cb\u5bc6\u7801"
|
||||
: "\u91cd\u7f6e\u5bc6\u7801\uff08\u9009\u586b\uff09"}
|
||||
<input
|
||||
type="password"
|
||||
minLength="8"
|
||||
required={editing === "new"}
|
||||
value={form.password}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, password: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{(editing !== "new" || createMode === "account") && (
|
||||
<label>
|
||||
{editing === "new"
|
||||
? "\u521d\u59cb\u5bc6\u7801"
|
||||
: "\u91cd\u7f6e\u5bc6\u7801\uff08\u9009\u586b\uff09"}
|
||||
<input
|
||||
type="password"
|
||||
minLength="8"
|
||||
required={editing === "new"}
|
||||
value={form.password}
|
||||
onChange={(event) =>
|
||||
setForm({ ...form, password: event.target.value })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
)}
|
||||
<label>
|
||||
{"\u89d2\u8272"}
|
||||
<select
|
||||
@ -2404,6 +2615,8 @@ function UserTable({ users = [], onChanged }) {
|
||||
<tr>
|
||||
<th>{"\u8d26\u53f7"}</th>
|
||||
<th>{"\u663e\u793a\u540d\u79f0"}</th>
|
||||
<th>部门</th>
|
||||
<th>付款账户授权</th>
|
||||
<th>{"\u89d2\u8272"}</th>
|
||||
<th>{"\u72b6\u6001"}</th>
|
||||
<th>{"\u6700\u8fd1\u767b\u5f55"}</th>
|
||||
@ -2415,6 +2628,8 @@ function UserTable({ users = [], onChanged }) {
|
||||
<tr key={user.id}>
|
||||
<td>{user.username}</td>
|
||||
<td>{user.display_name}</td>
|
||||
<td>{user.dingtalk_dept_name || "未绑定钉钉"}</td>
|
||||
<td>{user.role_code === "operator" ? user.refund_payment_account_name || "未授权" : "全部启用账户"}</td>
|
||||
<td>
|
||||
<span className="role-pill">{user.role_label}</span>
|
||||
</td>
|
||||
@ -2433,7 +2648,8 @@ function UserTable({ users = [], onChanged }) {
|
||||
: "\u672a\u767b\u5f55"}
|
||||
</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"}
|
||||
</button>
|
||||
<button
|
||||
@ -2441,7 +2657,7 @@ function UserTable({ users = [], onChanged }) {
|
||||
onClick={() => remove(user)}
|
||||
>
|
||||
{"\u5220\u9664"}
|
||||
</button>
|
||||
</button></>}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
@ -2672,7 +2888,7 @@ function UsagePanel({ data }) {
|
||||
);
|
||||
}
|
||||
|
||||
function AdminPage({ type }) {
|
||||
function AdminPage({ type, user }) {
|
||||
const [data, setData] = useState(null);
|
||||
const [error, setError] = useState("");
|
||||
const [reload, setReload] = useState(0);
|
||||
@ -2730,6 +2946,7 @@ function AdminPage({ type }) {
|
||||
<UsagePanel data={data} />
|
||||
) : type === "users" ? (
|
||||
<UserTable
|
||||
currentUser={user}
|
||||
users={Array.isArray(data) ? data : []}
|
||||
onChanged={() => setReload((value) => value + 1)}
|
||||
/>
|
||||
@ -4427,12 +4644,14 @@ function Dashboard({ user, onLogout }) {
|
||||
<TaskBoardPage api={api} user={user} />
|
||||
) : active === "review" ? (
|
||||
<ReviewCenterPage api={api} />
|
||||
) : active === "refunds" ? (
|
||||
<AlipayRefundPanel api={api} standalone />
|
||||
) : active === "library" ? (
|
||||
<QALibraryPage selectionMode={Boolean(window.__qaLibrarySelectionMode)} />
|
||||
) : active === "refund-records" ? (
|
||||
<RefundRecordsPage api={api} />
|
||||
) : active === "refund-management" ? (
|
||||
<RefundManagementPage api={api} />
|
||||
) : isAdminPage ? (
|
||||
<AdminPage type={active} />
|
||||
<AdminPage type={active} user={user} />
|
||||
) : (
|
||||
<AssistantPageFinal />
|
||||
)}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user