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 && }
-

待返款任务按每次提交分别列出。可单笔提交,或勾选后批量提交,每笔金额可不同。

-
提交后保存为“待支付”。代付接口暂未接入,不会实际转账,也不会将任务标记为已返款。
- {message &&
{message}
} - {loading ?

正在读取待返款任务…

:
- - - {rows.map(row => - - - - - - - )}{!rows.length && }
0 && available.every(row => selected.includes(key(row)))} onChange={event => setSelected(event.target.checked ? available.map(key) : [])} aria-label="全选待提交返款" />任务 / 提交记录支付宝账号收款人姓名金额(元)支付状态操作
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}
暂无待返款任务
} -
{onClose && }
- {detail && setDetail(null)} readOnly hasNextRefund={false} nextBusy={false} />} -
; - return standalone ? content :
{content}
; -} diff --git a/frontend/src/AnswerUserPortal.jsx b/frontend/src/AnswerUserPortal.jsx index 94afe5c..aeb7a01 100644 --- a/frontend/src/AnswerUserPortal.jsx +++ b/frontend/src/AnswerUserPortal.jsx @@ -71,7 +71,7 @@ function ReviewStage({ latest, reload, busy, startNew }) { function AssignmentStage({ api, session, latest, reload, setError, linkToken }) { const selected = latest.questions.filter(question => question.selected); const progressKey = `answer_progress_${latest.id}` const [done, setDone] = useState(() => { try { return JSON.parse(localStorage.getItem(progressKey) || '[]') } catch { return [] } }) - const [tab, setTab] = useState('list'); const [files, setFiles] = useState([]); const [payFiles, setPayFiles] = useState([]); const [busy, setBusy] = useState(false) + const [tab, setTab] = useState('list'); const [files, setFiles] = useState([]); const [busy, setBusy] = useState(false) const [alipayAccount, setAlipayAccount] = useState(''); const [payeeName, setPayeeName] = useState('') const completed = selected.filter(question => done.includes(question.id)).length useEffect(() => { @@ -83,13 +83,13 @@ function AssignmentStage({ api, session, latest, reload, setError, linkToken }) function mark(id) { saveDone(done.includes(id) ? done.filter(value => value !== id) : [...done, id]) } async function copy(question) { try { await navigator.clipboard.writeText(question.answer || ''); if (!done.includes(question.id)) saveDone([...done, question.id]) } catch { setError('复制失败,请长按参考回答手动复制') } } async function complete(event) { - event.preventDefault(); setError(''); if (!files.length) return setError('请至少上传一张完成回答截图'); if (!payFiles.length) return setError('请上传收款二维码后再提交') + event.preventDefault(); setError(''); if (!files.length) return setError('请至少上传一张完成回答截图') if (!alipayAccount.trim() || !payeeName.trim()) return setError('请填写支付宝账号和收款人姓名') - const body = new FormData(); body.append('token', session.token); if (linkToken) body.append('owner_token', linkToken); files.forEach(file => body.append('files', file)); payFiles.forEach(file => body.append('payment_files', file)); setBusy(true) + const body = new FormData(); body.append('token', session.token); if (linkToken) body.append('owner_token', linkToken); files.forEach(file => body.append('files', file)); setBusy(true) body.append('alipay_account', alipayAccount.trim()); body.append('payee_name', payeeName.trim()) try { const result = await api(`/api/answer/me/submissions/${latest.id}/completion`, { method: 'POST', body }); await reload(result.data) } catch (err) { setError(err.message) } finally { setBusy(false) } } -return <>
✓ 任务已派发 · 完成任务阶段

完成问答并上传任务凭证

请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传截图与收款码。

{tab === 'list' ?

待回答问题清单

点击复制参考回答即可前往淘宝粘贴提交

回答进度 {completed}/{selected.length}
完成所有问题后,进入“任务上传” 
{selected.map((question, index) =>
{String(index + 1).padStart(2, '0')}

{question.question}

#{question.product_title || '商品问题'}{question.operator_images?.length > 0 &&
运营指定图片(请在回答本题时添加)
{question.operator_images.map((image, imageIndex) => {`运营指定回答图片${imageIndex)}
}
▱ 参考回答:

{question.answer}

{done.includes(question.id) ? '已复制并标记完成' : '待复制进入淘宝回答'}
)}
:

上传任务完成凭证

请上传已发布回答的截图;收款二维码为必传材料,用于后续核验。

} +return <>
✓ 任务已派发 · 完成任务阶段

完成问答并上传任务凭证

请逐一复制下方参考回答,前往淘宝对应“被邀请回答”进行提交,完成后上传任务截图并填写收款信息。

{tab === 'list' ?

待回答问题清单

点击复制参考回答即可前往淘宝粘贴提交

回答进度 {completed}/{selected.length}
完成所有问题后,进入“任务上传” 
{selected.map((question, index) =>
{String(index + 1).padStart(2, '0')}

{question.question}

#{question.product_title || '商品问题'}{question.operator_images?.length > 0 &&
运营指定图片(请在回答本题时添加)
{question.operator_images.map((image, imageIndex) => {`运营指定回答图片${imageIndex)}
}
▱ 参考回答:

{question.answer}

{done.includes(question.id) ? '已复制并标记完成' : '待复制进入淘宝回答'}
)}
:

上传任务完成凭证

请上传已发布回答的截图,并填写准确的支付宝账号及收款人姓名。

} } function FinalStage({ latest, reload, busy, reopen, startNew }) { diff --git a/frontend/src/AskUserPortal.jsx b/frontend/src/AskUserPortal.jsx index 87324d9..f1fa5cd 100644 --- a/frontend/src/AskUserPortal.jsx +++ b/frontend/src/AskUserPortal.jsx @@ -429,14 +429,12 @@ function ConfirmStage({ (item) => item.selected, ); const [files, setFiles] = useState([]); - const [paymentFile, setPaymentFile] = useState(null); const [payeeName, setPayeeName] = useState(""); const [alipayAccount, setAlipayAccount] = useState(""); const [reviewOpen, setReviewOpen] = useState(false); const [busy, setBusy] = useState(false); const [copiedId, setCopiedId] = useState(null); const inputRef = useRef(null); - const paymentInputRef = useRef(null); const previews = useMemo( () => files.map((file) => ({ file, url: URL.createObjectURL(file) })), [files], @@ -474,7 +472,6 @@ function ConfirmStage({ const body = new FormData(); body.append("token", session.token); files.forEach((file) => body.append("files", file)); - body.append("payment_file", paymentFile); body.append("payee_name", payeeName); body.append("alipay_account", alipayAccount.trim()); try { @@ -606,37 +603,10 @@ function ConfirmStage({
2 - 上传收款码 - 审核通过后将按原路径结算至此账户 + 填写收款信息 + 请填写准确的支付宝账号及实名姓名
- {paymentFile ? "已上传" : "未上传"}
- {paymentFile ? ( -
- {paymentFile.name} - - -
- ) : ( - - )} - setPaymentFile(event.target.files?.[0] || null)} - />