{question.question}
{question.answer}
diff --git a/.gitignore b/.gitignore
index 805eb75..a87165c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,7 +1,5 @@
-.env
-__pycache__/
-*.py[cod]
-.pytest_cache/
+backend/.env
frontend/node_modules/
frontend/dist/
-data/uploads/
+backend/data/uploads/
+backend/keys/
diff --git a/README.md b/README.md
index 0854552..9a75b54 100644
--- a/README.md
+++ b/README.md
@@ -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"
diff --git a/.env.example b/backend/.env.example
similarity index 61%
rename from .env.example
rename to backend/.env.example
index 9962716..563adfd 100644
--- a/.env.example
+++ b/backend/.env.example
@@ -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
diff --git a/backend/app/__pycache__/__init__.cpython-313.pyc b/backend/app/__pycache__/__init__.cpython-313.pyc
new file mode 100644
index 0000000..d143c2c
Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-313.pyc differ
diff --git a/backend/app/__pycache__/__init__.cpython-314.pyc b/backend/app/__pycache__/__init__.cpython-314.pyc
new file mode 100644
index 0000000..b3c23eb
Binary files /dev/null and b/backend/app/__pycache__/__init__.cpython-314.pyc differ
diff --git a/backend/app/__pycache__/alipay_provider.cpython-313.pyc b/backend/app/__pycache__/alipay_provider.cpython-313.pyc
new file mode 100644
index 0000000..d8ad69b
Binary files /dev/null and b/backend/app/__pycache__/alipay_provider.cpython-313.pyc differ
diff --git a/backend/app/__pycache__/alipay_provider.cpython-314.pyc b/backend/app/__pycache__/alipay_provider.cpython-314.pyc
new file mode 100644
index 0000000..7fb1824
Binary files /dev/null and b/backend/app/__pycache__/alipay_provider.cpython-314.pyc differ
diff --git a/backend/app/__pycache__/config.cpython-313.pyc b/backend/app/__pycache__/config.cpython-313.pyc
new file mode 100644
index 0000000..6d651b9
Binary files /dev/null and b/backend/app/__pycache__/config.cpython-313.pyc differ
diff --git a/backend/app/__pycache__/config.cpython-314.pyc b/backend/app/__pycache__/config.cpython-314.pyc
new file mode 100644
index 0000000..a25f81b
Binary files /dev/null and b/backend/app/__pycache__/config.cpython-314.pyc differ
diff --git a/backend/app/__pycache__/database.cpython-313.pyc b/backend/app/__pycache__/database.cpython-313.pyc
new file mode 100644
index 0000000..b207709
Binary files /dev/null and b/backend/app/__pycache__/database.cpython-313.pyc differ
diff --git a/backend/app/__pycache__/database.cpython-314.pyc b/backend/app/__pycache__/database.cpython-314.pyc
new file mode 100644
index 0000000..c571f64
Binary files /dev/null and b/backend/app/__pycache__/database.cpython-314.pyc differ
diff --git a/backend/app/__pycache__/llm.cpython-313.pyc b/backend/app/__pycache__/llm.cpython-313.pyc
new file mode 100644
index 0000000..5f59926
Binary files /dev/null and b/backend/app/__pycache__/llm.cpython-313.pyc differ
diff --git a/backend/app/__pycache__/llm.cpython-314.pyc b/backend/app/__pycache__/llm.cpython-314.pyc
new file mode 100644
index 0000000..9f407a7
Binary files /dev/null and b/backend/app/__pycache__/llm.cpython-314.pyc differ
diff --git a/backend/app/__pycache__/main.cpython-313.pyc b/backend/app/__pycache__/main.cpython-313.pyc
new file mode 100644
index 0000000..ef62d60
Binary files /dev/null and b/backend/app/__pycache__/main.cpython-313.pyc differ
diff --git a/backend/app/__pycache__/main.cpython-314.pyc b/backend/app/__pycache__/main.cpython-314.pyc
new file mode 100644
index 0000000..22f1d11
Binary files /dev/null and b/backend/app/__pycache__/main.cpython-314.pyc differ
diff --git a/backend/app/__pycache__/models.cpython-313.pyc b/backend/app/__pycache__/models.cpython-313.pyc
new file mode 100644
index 0000000..a5b2ea2
Binary files /dev/null and b/backend/app/__pycache__/models.cpython-313.pyc differ
diff --git a/backend/app/__pycache__/models.cpython-314.pyc b/backend/app/__pycache__/models.cpython-314.pyc
new file mode 100644
index 0000000..54600a1
Binary files /dev/null and b/backend/app/__pycache__/models.cpython-314.pyc differ
diff --git a/backend/app/__pycache__/schemas.cpython-313.pyc b/backend/app/__pycache__/schemas.cpython-313.pyc
new file mode 100644
index 0000000..48d29dc
Binary files /dev/null and b/backend/app/__pycache__/schemas.cpython-313.pyc differ
diff --git a/backend/app/__pycache__/schemas.cpython-314.pyc b/backend/app/__pycache__/schemas.cpython-314.pyc
new file mode 100644
index 0000000..9028158
Binary files /dev/null and b/backend/app/__pycache__/schemas.cpython-314.pyc differ
diff --git a/backend/app/__pycache__/screenshot_storage.cpython-313.pyc b/backend/app/__pycache__/screenshot_storage.cpython-313.pyc
new file mode 100644
index 0000000..4405081
Binary files /dev/null and b/backend/app/__pycache__/screenshot_storage.cpython-313.pyc differ
diff --git a/backend/app/__pycache__/screenshot_storage.cpython-314.pyc b/backend/app/__pycache__/screenshot_storage.cpython-314.pyc
new file mode 100644
index 0000000..d41e230
Binary files /dev/null and b/backend/app/__pycache__/screenshot_storage.cpython-314.pyc differ
diff --git a/backend/app/__pycache__/security.cpython-313.pyc b/backend/app/__pycache__/security.cpython-313.pyc
new file mode 100644
index 0000000..860fe44
Binary files /dev/null and b/backend/app/__pycache__/security.cpython-313.pyc differ
diff --git a/backend/app/__pycache__/security.cpython-314.pyc b/backend/app/__pycache__/security.cpython-314.pyc
new file mode 100644
index 0000000..3e2f620
Binary files /dev/null and b/backend/app/__pycache__/security.cpython-314.pyc differ
diff --git a/backend/app/alipay_provider.py b/backend/app/alipay_provider.py
index 55d902a..f7effa7 100644
--- a/backend/app/alipay_provider.py
+++ b/backend/app/alipay_provider.py
@@ -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
diff --git a/backend/app/config.py b/backend/app/config.py
index f29656a..e6db07d 100644
--- a/backend/app/config.py
+++ b/backend/app/config.py
@@ -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()
diff --git a/backend/app/main.py b/backend/app/main.py
index ab62016..595d155 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -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)
diff --git a/backend/app/models.py b/backend/app/models.py
index 0facce5..87610ec 100644
--- a/backend/app/models.py
+++ b/backend/app/models.py
@@ -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)
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index ee12fbd..4ebc056 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -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)
diff --git a/backend/data/pool_audit_temp.db b/backend/data/pool_audit_temp.db
deleted file mode 100644
index 86cff2b..0000000
Binary files a/backend/data/pool_audit_temp.db and /dev/null differ
diff --git a/backend/data/prepush_check.db b/backend/data/prepush_check.db
deleted file mode 100644
index eba2606..0000000
Binary files a/backend/data/prepush_check.db and /dev/null differ
diff --git a/backend/data/prepush_check3.db b/backend/data/prepush_check3.db
deleted file mode 100644
index 18b3266..0000000
Binary files a/backend/data/prepush_check3.db and /dev/null differ
diff --git a/backend/data/prepush_check4.db b/backend/data/prepush_check4.db
deleted file mode 100644
index b7b13b3..0000000
Binary files a/backend/data/prepush_check4.db and /dev/null differ
diff --git a/backend/data/prepush_check5.db b/backend/data/prepush_check5.db
deleted file mode 100644
index d3bba66..0000000
Binary files a/backend/data/prepush_check5.db and /dev/null differ
diff --git a/backend/data/prepush_targeted2.db b/backend/data/prepush_targeted2.db
deleted file mode 100644
index fdc5b22..0000000
Binary files a/backend/data/prepush_targeted2.db and /dev/null differ
diff --git a/backend/data/test-completed-count-focused.db b/backend/data/test-completed-count-focused.db
deleted file mode 100644
index b392c47..0000000
Binary files a/backend/data/test-completed-count-focused.db and /dev/null differ
diff --git a/backend/data/test-completed-count.db b/backend/data/test-completed-count.db
deleted file mode 100644
index 332ce0d..0000000
Binary files a/backend/data/test-completed-count.db and /dev/null differ
diff --git a/backend/data/test-local-qr.db b/backend/data/test-local-qr.db
deleted file mode 100644
index 287d2cc..0000000
Binary files a/backend/data/test-local-qr.db and /dev/null differ
diff --git a/backend/data/test-publish-status-2.db b/backend/data/test-publish-status-2.db
deleted file mode 100644
index 09aecdf..0000000
Binary files a/backend/data/test-publish-status-2.db and /dev/null differ
diff --git a/backend/data/test-publish-status.db b/backend/data/test-publish-status.db
deleted file mode 100644
index 1660d01..0000000
Binary files a/backend/data/test-publish-status.db and /dev/null differ
diff --git a/backend/data/test-qr-final.db b/backend/data/test-qr-final.db
deleted file mode 100644
index 387bb26..0000000
Binary files a/backend/data/test-qr-final.db and /dev/null differ
diff --git a/backend/data/test-qr-fresh-2.db b/backend/data/test-qr-fresh-2.db
deleted file mode 100644
index a51f26c..0000000
Binary files a/backend/data/test-qr-fresh-2.db and /dev/null differ
diff --git a/backend/data/test-qr-fresh-3.db b/backend/data/test-qr-fresh-3.db
deleted file mode 100644
index 9216e7b..0000000
Binary files a/backend/data/test-qr-fresh-3.db and /dev/null differ
diff --git a/backend/data/test-qr-fresh-4.db b/backend/data/test-qr-fresh-4.db
deleted file mode 100644
index da86d08..0000000
Binary files a/backend/data/test-qr-fresh-4.db and /dev/null differ
diff --git a/backend/data/test-qr-fresh-5.db b/backend/data/test-qr-fresh-5.db
deleted file mode 100644
index 1ebff01..0000000
Binary files a/backend/data/test-qr-fresh-5.db and /dev/null differ
diff --git a/backend/data/test-qr-fresh.db b/backend/data/test-qr-fresh.db
deleted file mode 100644
index 8679f10..0000000
Binary files a/backend/data/test-qr-fresh.db and /dev/null differ
diff --git a/backend/data/test_ask_features.db b/backend/data/test_ask_features.db
deleted file mode 100644
index 2e2db80..0000000
Binary files a/backend/data/test_ask_features.db and /dev/null differ
diff --git a/backend/requirements.txt b/backend/requirements.txt
index 13f417a..dbc08d5 100644
--- a/backend/requirements.txt
+++ b/backend/requirements.txt
@@ -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
diff --git a/backend/schema.sql b/backend/schema.sql
index 634ca1f..3781381 100644
--- a/backend/schema.sql
+++ b/backend/schema.sql
@@ -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,
diff --git a/backend/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc
new file mode 100644
index 0000000..9c411ce
Binary files /dev/null and b/backend/tests/__pycache__/conftest.cpython-313-pytest-9.1.1.pyc differ
diff --git a/backend/tests/__pycache__/conftest.cpython-313.pyc b/backend/tests/__pycache__/conftest.cpython-313.pyc
new file mode 100644
index 0000000..11b6791
Binary files /dev/null and b/backend/tests/__pycache__/conftest.cpython-313.pyc differ
diff --git a/backend/tests/__pycache__/conftest.cpython-314-pytest-8.4.1.pyc b/backend/tests/__pycache__/conftest.cpython-314-pytest-8.4.1.pyc
new file mode 100644
index 0000000..1546420
Binary files /dev/null and b/backend/tests/__pycache__/conftest.cpython-314-pytest-8.4.1.pyc differ
diff --git a/backend/tests/__pycache__/test_alipay_callback.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_alipay_callback.cpython-313-pytest-9.1.1.pyc
new file mode 100644
index 0000000..279cb96
Binary files /dev/null and b/backend/tests/__pycache__/test_alipay_callback.cpython-313-pytest-9.1.1.pyc differ
diff --git a/backend/tests/__pycache__/test_alipay_provider.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_alipay_provider.cpython-313-pytest-9.1.1.pyc
new file mode 100644
index 0000000..432bf2c
Binary files /dev/null and b/backend/tests/__pycache__/test_alipay_provider.cpython-313-pytest-9.1.1.pyc differ
diff --git a/backend/tests/__pycache__/test_alipay_refunds.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_alipay_refunds.cpython-313-pytest-9.1.1.pyc
new file mode 100644
index 0000000..4ace523
Binary files /dev/null and b/backend/tests/__pycache__/test_alipay_refunds.cpython-313-pytest-9.1.1.pyc differ
diff --git a/backend/tests/__pycache__/test_auth.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_auth.cpython-313-pytest-9.1.1.pyc
new file mode 100644
index 0000000..390e3f7
Binary files /dev/null and b/backend/tests/__pycache__/test_auth.cpython-313-pytest-9.1.1.pyc differ
diff --git a/backend/tests/__pycache__/test_auth.cpython-313.pyc b/backend/tests/__pycache__/test_auth.cpython-313.pyc
new file mode 100644
index 0000000..c927859
Binary files /dev/null and b/backend/tests/__pycache__/test_auth.cpython-313.pyc differ
diff --git a/backend/tests/__pycache__/test_llm_ocr.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_llm_ocr.cpython-313-pytest-9.1.1.pyc
new file mode 100644
index 0000000..6b11447
Binary files /dev/null and b/backend/tests/__pycache__/test_llm_ocr.cpython-313-pytest-9.1.1.pyc differ
diff --git a/backend/tests/__pycache__/test_llm_ocr.cpython-313.pyc b/backend/tests/__pycache__/test_llm_ocr.cpython-313.pyc
new file mode 100644
index 0000000..8f8865c
Binary files /dev/null and b/backend/tests/__pycache__/test_llm_ocr.cpython-313.pyc differ
diff --git a/backend/tests/__pycache__/test_payment_account_authorization.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_payment_account_authorization.cpython-313-pytest-9.1.1.pyc
new file mode 100644
index 0000000..0379eb4
Binary files /dev/null and b/backend/tests/__pycache__/test_payment_account_authorization.cpython-313-pytest-9.1.1.pyc differ
diff --git a/backend/tests/__pycache__/test_payment_account_authorization.cpython-314-pytest-8.4.1.pyc b/backend/tests/__pycache__/test_payment_account_authorization.cpython-314-pytest-8.4.1.pyc
new file mode 100644
index 0000000..2a4f821
Binary files /dev/null and b/backend/tests/__pycache__/test_payment_account_authorization.cpython-314-pytest-8.4.1.pyc differ
diff --git a/backend/tests/__pycache__/test_refund_amounts.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_refund_amounts.cpython-313-pytest-9.1.1.pyc
new file mode 100644
index 0000000..a34a925
Binary files /dev/null and b/backend/tests/__pycache__/test_refund_amounts.cpython-313-pytest-9.1.1.pyc differ
diff --git a/backend/tests/__pycache__/test_refund_amounts.cpython-314-pytest-8.4.1.pyc b/backend/tests/__pycache__/test_refund_amounts.cpython-314-pytest-8.4.1.pyc
new file mode 100644
index 0000000..1a60383
Binary files /dev/null and b/backend/tests/__pycache__/test_refund_amounts.cpython-314-pytest-8.4.1.pyc differ
diff --git a/backend/tests/__pycache__/test_refund_payment_accounts.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_refund_payment_accounts.cpython-313-pytest-9.1.1.pyc
new file mode 100644
index 0000000..9a2be28
Binary files /dev/null and b/backend/tests/__pycache__/test_refund_payment_accounts.cpython-313-pytest-9.1.1.pyc differ
diff --git a/backend/tests/__pycache__/test_refund_records.cpython-313-pytest-9.1.1.pyc b/backend/tests/__pycache__/test_refund_records.cpython-313-pytest-9.1.1.pyc
new file mode 100644
index 0000000..bfd9557
Binary files /dev/null and b/backend/tests/__pycache__/test_refund_records.cpython-313-pytest-9.1.1.pyc differ
diff --git a/backend/tests/__pycache__/test_refund_records.cpython-314-pytest-8.4.1.pyc b/backend/tests/__pycache__/test_refund_records.cpython-314-pytest-8.4.1.pyc
new file mode 100644
index 0000000..5ff4ca3
Binary files /dev/null and b/backend/tests/__pycache__/test_refund_records.cpython-314-pytest-8.4.1.pyc differ
diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py
index 0da2b30..c3e7120 100644
--- a/backend/tests/conftest.py
+++ b/backend/tests/conftest.py
@@ -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"
diff --git a/backend/tests/test_alipay_callback.py b/backend/tests/test_alipay_callback.py
new file mode 100644
index 0000000..126b4a3
--- /dev/null
+++ b/backend/tests/test_alipay_callback.py
@@ -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"
diff --git a/backend/tests/test_alipay_provider.py b/backend/tests/test_alipay_provider.py
new file mode 100644
index 0000000..fa3a03e
--- /dev/null
+++ b/backend/tests/test_alipay_provider.py
@@ -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
diff --git a/backend/tests/test_alipay_refunds.py b/backend/tests/test_alipay_refunds.py
deleted file mode 100644
index 9690167..0000000
--- a/backend/tests/test_alipay_refunds.py
+++ /dev/null
@@ -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
diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py
index d7e7db1..7ab794f 100644
--- a/backend/tests/test_auth.py
+++ b/backend/tests/test_auth.py
@@ -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)
diff --git a/backend/tests/test_payment_account_authorization.py b/backend/tests/test_payment_account_authorization.py
new file mode 100644
index 0000000..f8f47cd
--- /dev/null
+++ b/backend/tests/test_payment_account_authorization.py
@@ -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
diff --git a/backend/tests/test_refund_amounts.py b/backend/tests/test_refund_amounts.py
new file mode 100644
index 0000000..792761b
--- /dev/null
+++ b/backend/tests/test_refund_amounts.py
@@ -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
diff --git a/backend/tests/test_refund_payment_accounts.py b/backend/tests/test_refund_payment_accounts.py
new file mode 100644
index 0000000..bd43a65
--- /dev/null
+++ b/backend/tests/test_refund_payment_accounts.py
@@ -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
diff --git a/backend/tests/test_refund_records.py b/backend/tests/test_refund_records.py
new file mode 100644
index 0000000..261f193
--- /dev/null
+++ b/backend/tests/test_refund_records.py
@@ -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
diff --git a/data/app.db b/data/app.db
deleted file mode 100644
index 8a0e5ed..0000000
Binary files a/data/app.db and /dev/null differ
diff --git a/data/async-smoke.db b/data/async-smoke.db
deleted file mode 100644
index 2c3d059..0000000
Binary files a/data/async-smoke.db and /dev/null differ
diff --git a/data/local_login_test.db b/data/local_login_test.db
deleted file mode 100644
index 3afddc9..0000000
Binary files a/data/local_login_test.db and /dev/null differ
diff --git a/data/local_test.db b/data/local_test.db
deleted file mode 100644
index 1cd7320..0000000
Binary files a/data/local_test.db and /dev/null differ
diff --git a/frontend/src/AlipayRefundPanel.jsx b/frontend/src/AlipayRefundPanel.jsx
deleted file mode 100644
index 0683828..0000000
--- a/frontend/src/AlipayRefundPanel.jsx
+++ /dev/null
@@ -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 = 待返款任务按每次提交分别列出。可单笔提交,或勾选后批量提交,每笔金额可不同。 正在读取待返款任务…支付宝返款
{onClose && }
- {rows.map(row => 0 && available.every(row => selected.includes(key(row)))} onChange={event => setSelected(event.target.checked ? available.map(key) : [])} aria-label="全选待提交返款" />
- 任务 / 提交记录 支付宝账号 收款人姓名 金额(元) 支付状态 操作
-
- )}{!rows.length && setSelected(current => event.target.checked ? [...current, key(row)] : current.filter(id => id !== key(row)))} />
- {row.task_no}{row.taobao_id || '未填写淘宝ID'} · {row.target_kind === 'answer_submission' ? '回答' : '提问'}提交 #{row.target_id}{row.product_name || '待识别商品'} · {row.shop_name || '未填写店铺'}
- edit(row, 'account', event.target.value)} />
- edit(row, 'payee_name', event.target.value)} />
- edit(row, 'amount', event.target.value)} />
- {row.payment_status}
- }暂无待返款任务
请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。
点击复制参考回答即可前往淘宝粘贴提交
{question.answer}
请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传任务截图并填写收款信息。
点击复制参考回答即可前往淘宝粘贴提交
{question.answer}