diff --git a/.env.example b/.env.example index ee940f6..6e16486 100644 --- a/.env.example +++ b/.env.example @@ -100,5 +100,15 @@ CLICKHOUSE_PASSWORD= CLICKHOUSE_DATABASE=default CLICKHOUSE_SECURE=False +# Withdrawal contract signing (FaDaDa) +WITHDRAWAL_CONTRACT_SIGNING_REQUIRED=False +FDD_API_HOST= +FDD_APP_ID= +FDD_APP_SECRET= +FDD_API_SUBVERSION=5.1 +FDD_WEBHOOK_TOKEN= +FDD_ACCESS_TOKEN_CACHE_SECONDS=3000 +FDD_TIMEOUT_SECONDS=10 + # Misc (optional overrides) # DEFAULT_AUTO_FIELD=django.db.models.BigAutoField diff --git a/config/settings.py b/config/settings.py index 1d27f22..2a4603b 100644 --- a/config/settings.py +++ b/config/settings.py @@ -18,6 +18,14 @@ str, "PLACEHOLDER_SECRET_KEY_CHANGE_ME!", ), + WITHDRAWAL_CONTRACT_SIGNING_REQUIRED=(bool, False), + FDD_API_HOST=(str, ""), + FDD_APP_ID=(str, ""), + FDD_APP_SECRET=(str, ""), + FDD_API_SUBVERSION=(str, "5.1"), + FDD_WEBHOOK_TOKEN=(str, ""), + FDD_ACCESS_TOKEN_CACHE_SECONDS=(int, 50 * 60), + FDD_TIMEOUT_SECONDS=(int, 10), DATABASE_URL=(str, "sqlite:///db.sqlite3"), ALLOWED_HOSTS=(list, []), TIME_ZONE=(str, "Asia/Shanghai"), @@ -85,6 +93,14 @@ TIME_ZONE = env("TIME_ZONE") USE_I18N = env("USE_I18N") USE_TZ = env("USE_TZ") +WITHDRAWAL_CONTRACT_SIGNING_REQUIRED = env("WITHDRAWAL_CONTRACT_SIGNING_REQUIRED") +FDD_API_HOST = env("FDD_API_HOST") +FDD_APP_ID = env("FDD_APP_ID") +FDD_APP_SECRET = env("FDD_APP_SECRET") +FDD_API_SUBVERSION = env("FDD_API_SUBVERSION") +FDD_WEBHOOK_TOKEN = env("FDD_WEBHOOK_TOKEN") +FDD_ACCESS_TOKEN_CACHE_SECONDS = env("FDD_ACCESS_TOKEN_CACHE_SECONDS") +FDD_TIMEOUT_SECONDS = env("FDD_TIMEOUT_SECONDS") AWS_S3_ACCESS_KEY_ID = env("AWS_S3_ACCESS_KEY_ID") AWS_S3_SECRET_ACCESS_KEY = env("AWS_S3_SECRET_ACCESS_KEY") AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME") diff --git a/config/urls.py b/config/urls.py index db565af..1e506c0 100644 --- a/config/urls.py +++ b/config/urls.py @@ -32,6 +32,7 @@ path("", include("social_django.urls", namespace="social")), path("accounts/", include("accounts.urls")), path("accounts/", include("points.urls")), + path("webhooks/", include("points.webhook_urls")), path("messages/", include("messages.urls")), path("", include("homepage.urls")), # Public profile route - must be last to avoid conflicts diff --git a/design.md b/design.md new file mode 100644 index 0000000..83fb02a --- /dev/null +++ b/design.md @@ -0,0 +1,224 @@ +# 提现签署合同方案设计 + +## 提现要求 + +1. 用户提现时应该已经完成了提现合同签署; +2. 用户提现合同的签署需要提交姓名、身份证号、手机号、银行卡号。我们在合同签署时会传递这些参数过去; + +## 交互步骤 + +1. 用户点击进入提现页面; +2. 用户选择要提现的积分; +3. 系统检测是否签署合同; + 3.1 如果已经签署完成,则直接发起提现申请; + 3.2 如果用户未完成签署,则让用户填写提现申请的表格,存储相关信息,并发起合同签署。发起合同签署后,提醒用户查收短信签署; + +## 要开发的工作量 + +1. 改造当前的签署的工作流程; +2. 存储签署记录,并提供 Admin +3. 提供一个 Webhook,用于让法大大在签署完成后回调回来。 + + +## 法大大参考代码 + +我已经写好了一个法大大的接入代码,请你参考这个封装一个 Client,提供自动算签名和请求 Access Token 的能力;此外,封装一个 signWithTemplate 的 method 占位。发起合同签署时调用这个函数,传入姓名、身份证号、手机号、银行卡号和存储的签署记录ID(方便实现回调。) + +```python +import hashlib +import hmac +import json +import os +import secrets +import time + +import requests + +API_SUBVERSION = "5.1" +SIGN_TYPE = "HMAC-SHA256" +DEFAULT_TIMEOUT_SECONDS = 10 + +API_HOST = os.getenv("FDD_API_HOST", "https://uat-api.fadada.com/api/v5/") +APP_ID = os.getenv("FDD_APP_ID", "80004155") +APP_SECRET = os.getenv("FDD_APP_SECRET", "2T42FYODG7VQYQUMVZMWGNXQTORQKZYV") + + +def _now_millis() -> str: + return str(int(time.time() * 1000)) + + +def _nonce_32_digits() -> str: + return f"{secrets.randbelow(10**32):032d}" + + +def generate_fdd_sign(app_secret: str, param_map: dict) -> str: + """ + 生成法大大 v5 API 的签名 (HMAC-SHA256)。 + + 规则(与官方 Java 伪代码一致): + 1) 过滤空值参数;按 ASCII 升序排序;拼接成 k=v&k2=v2... + 2) signText = sha256Hex(paramToSignStr) + 3) secretSigning = hmacSha256(appSecret, timestamp) + 4) signature = hex(hmacSha256(secretSigning, signText)).toLowerCase() + """ + filtered_params = { + k: v for k, v in param_map.items() + if v is not None and str(v).strip() != "" + } + # 按 ASCII 升序排序后拼接 + sorted_keys = sorted(filtered_params.keys()) + param_to_sign_str = "&".join([f"{k}={filtered_params[k]}" for k in sorted_keys]) + + timestamp = str(filtered_params.get("X-FASC-Timestamp", "")).strip() + if not timestamp: + raise ValueError("X-FASC-Timestamp is required for signature generation") + + sign_text = hashlib.sha256(param_to_sign_str.encode("utf-8")).hexdigest() + secret_signing = hmac.new( + app_secret.encode("utf-8"), + timestamp.encode("utf-8"), + digestmod=hashlib.sha256, + ).digest() + signature = hmac.new( + secret_signing, + sign_text.encode("utf-8"), + digestmod=hashlib.sha256, + ).hexdigest() + + return signature + + +def _build_signed_headers( + *, + app_id: str, + app_secret: str, + access_token: str | None = None, + biz_content: str | None = None, + extra_params: dict | None = None, + api_subversion: str = API_SUBVERSION, +) -> dict[str, str]: + timestamp = _now_millis() + nonce = _nonce_32_digits() + + param_map: dict = { + "X-FASC-App-Id": app_id, + "X-FASC-Sign-Type": SIGN_TYPE, + "X-FASC-Timestamp": timestamp, + "X-FASC-Nonce": nonce, + "X-FASC-Api-SubVersion": api_subversion, + } + if access_token: + param_map["X-FASC-AccessToken"] = access_token + if biz_content is not None: + param_map["bizContent"] = biz_content + if extra_params: + param_map.update(extra_params) + + signature = generate_fdd_sign(app_secret, param_map) + + headers = {k: str(v) for k, v in param_map.items() if k != "bizContent"} + headers["X-FASC-Sign"] = signature + return headers + + +def get_fdd_access_token(api_host: str, app_id: str, app_secret: str) -> dict | None: + """ + 获取法大大 Access Token + :param api_host: 环境域名, 例如 'https://openapi.fadada.com' + :param app_id: 你的 AppId + :param app_secret: 你的 AppSecret + """ + url = f"{api_host.rstrip('/')}/service/get-access-token" + + # 获取 Token 时不需要 X-FASC-AccessToken / bizContent + headers = _build_signed_headers( + app_id=app_id, + app_secret=app_secret, + extra_params={"X-FASC-Grant-Type": "client_credential"}, + ) + + try: + # 发送 POST 请求 (虽然参数都在 header,但接口要求 POST) + response = requests.post(url, headers=headers, timeout=DEFAULT_TIMEOUT_SECONDS) + response.raise_for_status() # 检查 HTTP 状态码 + + result = response.json() + return result + except Exception as e: # noqa: BLE001 - 示例脚本,保留统一异常提示 + print(f"请求异常: {e}") + return None + + +def get_app_info( + api_host: str, + app_id: str, + app_secret: str, + access_token: str, + queried_app_id: str | None = None, +) -> dict | None: + """ + 查询应用基本信息 (/app/get-info) + :param api_host: 环境域名 + :param app_id: 你的 AppId + :param app_secret: 你的 AppSecret + :param access_token: 之前获取到的 AccessToken + :param queried_app_id: (可选) 待查询的应用 AppId + """ + url = f"{api_host.rstrip('/')}/app/get-info" + + # 1. 准备业务参数 (bizContent) + # 逻辑:如果不传 queriedAppId,可以传空字典 + biz_data: dict = {} + if queried_app_id: + biz_data["queriedAppId"] = queried_app_id + + # 将业务参数转为紧凑的 JSON 字符串(无空格) + biz_content = json.dumps(biz_data, separators=(',', ':'), ensure_ascii=False) + + # 2. 构建请求头 (签名时需要 bizContent,但 Header 中不包含 bizContent) + headers = _build_signed_headers( + app_id=app_id, + app_secret=app_secret, + access_token=access_token, + biz_content=biz_content, + ) + # 接口要求表单提交,否则会返回 Content-Type 不正确 + headers["Content-Type"] = "application/x-www-form-urlencoded" + + try: + response = requests.post( + url, + data={"bizContent": biz_content}, + headers=headers, + timeout=DEFAULT_TIMEOUT_SECONDS, + ) + response.raise_for_status() + return response.json() + except Exception as e: # noqa: BLE001 - 示例脚本,保留统一异常提示 + print(f"请求 [get-app-info] 异常: {e}") + return None + + +def main(): + resp = get_fdd_access_token(API_HOST, APP_ID, APP_SECRET) + if not resp: + print("获取 Access Token 失败") + return + + try: + access_token = resp["data"]["accessToken"] + except (KeyError, TypeError): + print(f"Access Token 响应结构不符合预期: {resp}") + return + + print(resp) + print(access_token) + + resp2 = get_app_info(API_HOST, APP_ID, APP_SECRET, access_token, APP_ID) + print(resp2) + + +if __name__ == "__main__": + main() + +``` \ No newline at end of file diff --git a/points/admin.py b/points/admin.py index 7e9600c..16c8833 100644 --- a/points/admin.py +++ b/points/admin.py @@ -3,10 +3,18 @@ from django import forms from django.contrib import admin from django.contrib.auth import get_user_model +from django.utils import timezone from django.utils.html import format_html -from .models import PointSource, PointTransaction, Tag, WithdrawalRequest +from .models import ( + PointSource, + PointTransaction, + Tag, + WithdrawalContractSigning, + WithdrawalRequest, +) from .services import approve_withdrawal, reject_withdrawal +from .withdrawal_contracts import handle_withdrawal_contract_webhook User = get_user_model() @@ -403,3 +411,151 @@ def reject_selected(self, request, queryset): f"{error_count} 个提现申请拒绝失败。", level="WARNING", ) + + +@admin.register(WithdrawalContractSigning) +class WithdrawalContractSigningAdmin(admin.ModelAdmin): + """Admin for WithdrawalContractSigning model.""" + + list_display = ( + "id", + "user", + "status", + "real_name", + "phone_number", + "signed_at", + "created_at", + "withdrawal_request_count", + ) + list_filter = ("status", "created_at", "signed_at") + search_fields = ( + "user__username", + "user__email", + "real_name", + "id_number", + "phone_number", + "bank_account", + ) + ordering = ("-created_at",) + date_hierarchy = "created_at" + actions = ["mark_signed_and_create_withdrawals"] + + readonly_fields = ( + "user", + "status", + "real_name", + "id_number", + "phone_number", + "bank_name", + "bank_account", + "withdrawal_payload", + "created_withdrawal_request_ids", + "withdrawal_error", + "fdd_request_payload", + "fdd_response_payload", + "fdd_webhook_payload", + "signed_at", + "created_at", + "updated_at", + ) + + fieldsets = ( + ( + "签署信息", + { + "fields": ( + "user", + "status", + "signed_at", + "created_at", + "updated_at", + ), + }, + ), + ( + "身份信息", + { + "fields": ( + "real_name", + "id_number", + "phone_number", + ), + }, + ), + ( + "收款信息", + { + "fields": ( + "bank_name", + "bank_account", + ), + }, + ), + ( + "提现申请", + { + "fields": ( + "withdrawal_payload", + "created_withdrawal_request_ids", + "withdrawal_error", + ), + }, + ), + ( + "法大大记录", + { + "fields": ( + "fdd_request_payload", + "fdd_response_payload", + "fdd_webhook_payload", + ), + }, + ), + ) + + @admin.display(description="已创建提现") + def withdrawal_request_count(self, obj): + """Display count of created withdrawal requests.""" + if not obj.created_withdrawal_request_ids: + return 0 + return len(obj.created_withdrawal_request_ids) + + @admin.action(description="标记为已签署并创建提现申请") + def mark_signed_and_create_withdrawals(self, request, queryset): + """Manually mark selected records as signed and create withdrawals.""" + updated = 0 + failed = 0 + + for record in queryset: + if record.status == WithdrawalContractSigning.Status.SIGNED: + continue + try: + handle_withdrawal_contract_webhook( + { + "signing_record_id": record.id, + "status": "SIGNED", + "signed_at": timezone.now().isoformat(), + "source": "admin", + } + ) + updated += 1 + except Exception as exc: + failed += 1 + self.message_user( + request, + f"处理签署记录 #{record.id} 失败: {exc!s}", + level="ERROR", + ) + + if updated: + self.message_user( + request, + f"已处理 {updated} 条签署记录。", + level="SUCCESS", + ) + if failed: + self.message_user( + request, + f"{failed} 条签署记录处理失败。", + level="WARNING", + ) diff --git a/points/fadada.py b/points/fadada.py new file mode 100644 index 0000000..5e6fd16 --- /dev/null +++ b/points/fadada.py @@ -0,0 +1,240 @@ +""" +FaDaDa (法大大) OpenAPI client helpers. + +This module keeps request/signing logic inside a small client for business usage and +test mocking. +""" + +from __future__ import annotations + +import hashlib +import hmac +import json +import logging +import secrets +import time +from dataclasses import dataclass +from typing import Any, Protocol + +import requests +from django.conf import settings +from django.core.cache import cache + +logger = logging.getLogger(__name__) + +API_SUBVERSION = "5.1" +SIGN_TYPE = "HMAC-SHA256" +DEFAULT_TIMEOUT_SECONDS = 10 + + +class FadadaClientError(Exception): + """Base error for FaDaDa client failures.""" + + +class FadadaNotConfiguredError(FadadaClientError): + """Raised when required configuration is missing.""" + + +class FadadaRequestError(FadadaClientError): + """Raised when HTTP request fails.""" + + +@dataclass(frozen=True, slots=True) +class FadadaAccessToken: + """Access token returned by FaDaDa.""" + + token: str + expires_in: int | None = None + + +class _WithdrawalSigningData(Protocol): + real_name: str + id_number: str + phone_number: str + bank_name: str + bank_account: str + + +def _now_millis() -> str: + return str(int(time.time() * 1000)) + + +def _nonce_32_digits() -> str: + return f"{secrets.randbelow(10**32):032d}" + + +def generate_fdd_sign(app_secret: str, param_map: dict[str, Any]) -> str: + """ + Generate FaDaDa v5 API signature (HMAC-SHA256). + + Rules (aligned with the official SDK pseudo code): + 1) Filter out empty params; sort by ASCII; join into k=v&k2=v2... + 2) sign_text = sha256Hex(param_to_sign_str) + 3) secret_signing = hmacSha256(appSecret, timestamp) + 4) signature = hex(hmacSha256(secret_signing, sign_text)).toLowerCase() + """ + filtered_params = { + key: value + for key, value in param_map.items() + if value is not None and str(value).strip() != "" + } + sorted_keys = sorted(filtered_params.keys()) + param_to_sign_str = "&".join( + [f"{key}={filtered_params[key]}" for key in sorted_keys] + ) + + timestamp = str(filtered_params.get("X-FASC-Timestamp", "")).strip() + if not timestamp: + msg = "X-FASC-Timestamp is required for signature generation" + raise ValueError(msg) + + sign_text = hashlib.sha256(param_to_sign_str.encode("utf-8")).hexdigest() + secret_signing = hmac.new( + app_secret.encode("utf-8"), + timestamp.encode("utf-8"), + digestmod=hashlib.sha256, + ).digest() + signature = hmac.new( + secret_signing, + sign_text.encode("utf-8"), + digestmod=hashlib.sha256, + ).hexdigest() + return signature + + +class FadadaClient: + """ + A minimal FaDaDa OpenAPI v5.1 client. + + Note: `sign_with_template` is intentionally left as a placeholder. Implementing + the actual signing workflow depends on the selected product capabilities and + template configuration on FaDaDa side. + """ + + def __init__( + self, + *, + api_host: str, + app_id: str, + app_secret: str, + api_subversion: str = API_SUBVERSION, + timeout_seconds: int = DEFAULT_TIMEOUT_SECONDS, + ) -> None: + """Create a FaDaDa API client configured for a specific app.""" + self.api_host = api_host.rstrip("/") + "/" + self.app_id = app_id + self.app_secret = app_secret + self.api_subversion = api_subversion + self.timeout_seconds = timeout_seconds + + if not self.api_host or not self.app_id or not self.app_secret: + msg = ( + "FaDaDa client is not configured (missing api_host/app_id/app_secret)." + ) + raise FadadaNotConfiguredError(msg) + + @classmethod + def from_settings(cls) -> FadadaClient: + """Build a client instance from Django settings.""" + return cls( + api_host=getattr(settings, "FDD_API_HOST", ""), + app_id=getattr(settings, "FDD_APP_ID", ""), + app_secret=getattr(settings, "FDD_APP_SECRET", ""), + api_subversion=getattr(settings, "FDD_API_SUBVERSION", API_SUBVERSION), + timeout_seconds=getattr( + settings, "FDD_TIMEOUT_SECONDS", DEFAULT_TIMEOUT_SECONDS + ), + ) + + def _build_signed_headers( + self, + *, + access_token: str | None = None, + biz_content: str | None = None, + extra_params: dict[str, Any] | None = None, + ) -> dict[str, str]: + timestamp = _now_millis() + nonce = _nonce_32_digits() + + param_map: dict[str, Any] = { + "X-FASC-App-Id": self.app_id, + "X-FASC-Sign-Type": SIGN_TYPE, + "X-FASC-Timestamp": timestamp, + "X-FASC-Nonce": nonce, + "X-FASC-Api-SubVersion": self.api_subversion, + } + if access_token: + param_map["X-FASC-AccessToken"] = access_token + if biz_content is not None: + param_map["bizContent"] = biz_content + if extra_params: + param_map.update(extra_params) + + signature = generate_fdd_sign(self.app_secret, param_map) + + headers = { + key: str(value) for key, value in param_map.items() if key != "bizContent" + } + headers["X-FASC-Sign"] = signature + return headers + + def get_access_token(self, *, force_refresh: bool = False) -> FadadaAccessToken: + """Return a cached access token, refreshing when needed.""" + cache_key = f"fadada_access_token:{self.app_id}" + if not force_refresh: + cached = cache.get(cache_key) + if isinstance(cached, dict) and cached.get("token"): + return FadadaAccessToken( + token=str(cached["token"]), + expires_in=cached.get("expires_in"), + ) + + url = f"{self.api_host}service/get-access-token" + headers = self._build_signed_headers( + extra_params={"X-FASC-Grant-Type": "client_credential"}, + ) + + try: + response = requests.post(url, headers=headers, timeout=self.timeout_seconds) + response.raise_for_status() + except requests.RequestException as exc: # pragma: no cover - network failure + msg = f"FaDaDa request failed: {exc}" + raise FadadaRequestError(msg) from exc + + data = response.json() + try: + token = data["data"]["accessToken"] + except (KeyError, TypeError) as exc: + msg = f"Unexpected access token response: {data}" + raise FadadaRequestError(msg) from exc + + expires_in = data.get("data", {}).get("expiresIn") + token_obj = FadadaAccessToken(token=str(token), expires_in=expires_in) + cache.set( + cache_key, + {"token": token_obj.token, "expires_in": token_obj.expires_in}, + timeout=getattr(settings, "FDD_ACCESS_TOKEN_CACHE_SECONDS", 50 * 60), + ) + return token_obj + + def sign_with_template( + self, + *, + withdrawal_data: _WithdrawalSigningData, + signing_record_id: int, + ) -> dict[str, Any]: + """ + Initiate template-based signing. + + The withdrawal flow will call this method to initiate a signing task and + typically trigger SMS signing for the actor. The exact endpoint/payload + depends on your FaDaDa product configuration and template setup. + """ + _ = (withdrawal_data, signing_record_id) + msg = "sign_with_template is not implemented yet." + raise NotImplementedError(msg) + + @staticmethod + def dumps_biz_content(data: dict[str, Any]) -> str: + """Serialize bizContent with a stable JSON format.""" + return json.dumps(data, separators=(",", ":"), ensure_ascii=False) diff --git a/points/forms.py b/points/forms.py index ed737a2..d51ea3e 100644 --- a/points/forms.py +++ b/points/forms.py @@ -75,10 +75,11 @@ class Meta: "bank_account": "银行账号", } - def __init__(self, *args, point_source=None, **kwargs): + def __init__(self, *args, point_source=None, signed_contract=None, **kwargs): """初始化表单, 保存积分来源引用.""" super().__init__(*args, **kwargs) self.point_source = point_source + self.signed_contract = signed_contract # 如果有积分来源,设置提现积分的最大值 if point_source: @@ -87,6 +88,24 @@ def __init__(self, *args, point_source=None, **kwargs): "points" ].help_text = f"可提现积分: {point_source.remaining_points}" + if signed_contract: + locked_fields = [ + "real_name", + "id_number", + "phone_number", + "bank_name", + "bank_account", + ] + for field_name in locked_fields: + field = self.fields[field_name] + field.required = False + field.disabled = True + field.initial = getattr(signed_contract, field_name, "") + field.widget.attrs["readonly"] = True + self.fields["points"].help_text = ( + (self.fields["points"].help_text or "") + "(已完成合同签署)" + ).strip() + def clean_points(self): """验证提现积分数量.""" points = self.cleaned_data.get("points") @@ -294,9 +313,25 @@ def clean_bank_account(self): def __init__(self, *args, point_source=None, **kwargs): """初始化表单, 保存积分来源引用.""" + signed_contract = kwargs.pop("signed_contract", None) super().__init__(*args, **kwargs) self.point_source = point_source + if signed_contract: + locked_fields = [ + "real_name", + "id_number", + "phone_number", + "bank_name", + "bank_account", + ] + for field_name in locked_fields: + field = self.fields[field_name] + field.required = False + field.disabled = True + field.initial = getattr(signed_contract, field_name, "") + field.widget.attrs["readonly"] = True + # 如果有积分来源,设置提现积分的最大值 if point_source: self.fields["points"].widget.attrs["max"] = point_source.remaining_points diff --git a/points/migrations/0015_withdrawal_contract_signing.py b/points/migrations/0015_withdrawal_contract_signing.py new file mode 100644 index 0000000..0c00698 --- /dev/null +++ b/points/migrations/0015_withdrawal_contract_signing.py @@ -0,0 +1,113 @@ +# Generated by Django 5.2.7 on 2025-12-18 00:00 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ("points", "0014_create_default_tag"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="WithdrawalContractSigning", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "status", + models.CharField( + choices=[ + ("PENDING", "待签署"), + ("SIGNED", "已签署"), + ("FAILED", "发起失败"), + ], + db_index=True, + default="PENDING", + max_length=20, + verbose_name="状态", + ), + ), + ("real_name", models.CharField(max_length=100, verbose_name="真实姓名")), + ("id_number", models.CharField(max_length=18, verbose_name="身份证号")), + ( + "phone_number", + models.CharField(max_length=11, verbose_name="手机号"), + ), + ("bank_name", models.CharField(max_length=100, verbose_name="开户银行")), + ("bank_account", models.CharField(max_length=50, verbose_name="银行账号")), + ( + "withdrawal_payload", + models.JSONField( + blank=True, + default=dict, + help_text="用于签署完成后自动创建提现申请(例如 points / point_source_id)。", + verbose_name="提现申请信息", + ), + ), + ( + "created_withdrawal_request_ids", + models.JSONField( + blank=True, default=list, verbose_name="已创建的提现申请ID列表" + ), + ), + ("withdrawal_error", models.TextField(blank=True, verbose_name="提现创建错误")), + ( + "fdd_request_payload", + models.JSONField(blank=True, null=True, verbose_name="法大大请求"), + ), + ( + "fdd_response_payload", + models.JSONField(blank=True, null=True, verbose_name="法大大响应"), + ), + ( + "fdd_webhook_payload", + models.JSONField(blank=True, null=True, verbose_name="法大大回调"), + ), + ( + "signed_at", + models.DateTimeField(blank=True, null=True, verbose_name="签署完成时间"), + ), + ( + "created_at", + models.DateTimeField( + auto_now_add=True, db_index=True, verbose_name="创建时间" + ), + ), + ("updated_at", models.DateTimeField(auto_now=True, verbose_name="更新时间")), + ( + "user", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="withdrawal_contract_signings", + to=settings.AUTH_USER_MODEL, + verbose_name="用户", + ), + ), + ], + options={ + "verbose_name": "提现合同签署", + "verbose_name_plural": "提现合同签署", + "ordering": ["-created_at"], + }, + ), + migrations.AddConstraint( + model_name="withdrawalcontractsigning", + constraint=models.UniqueConstraint( + condition=models.Q(status="PENDING"), + fields=("user",), + name="unique_pending_withdrawal_contract_per_user", + ), + ), + ] diff --git a/points/models.py b/points/models.py index ae06831..42689b6 100644 --- a/points/models.py +++ b/points/models.py @@ -231,6 +231,87 @@ def __str__(self): return f"{self.user.username} - {self.points}积分 - {self.get_status_display()}" +class WithdrawalContractSigning(models.Model): + """提现合同签署记录.""" + + class Status(models.TextChoices): + """签署状态枚举.""" + + PENDING = "PENDING", "待签署" + SIGNED = "SIGNED", "已签署" + FAILED = "FAILED", "发起失败" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="withdrawal_contract_signings", + verbose_name="用户", + ) + status = models.CharField( + max_length=20, + choices=Status.choices, + default=Status.PENDING, + verbose_name="状态", + db_index=True, + ) + + # 签署需要的身份与收款信息 + real_name = models.CharField(max_length=100, verbose_name="真实姓名") + id_number = models.CharField(max_length=18, verbose_name="身份证号") + phone_number = models.CharField(max_length=11, verbose_name="手机号") + bank_name = models.CharField(max_length=100, verbose_name="开户银行") + bank_account = models.CharField(max_length=50, verbose_name="银行账号") + + # 用户在发起签署时选择的提现信息(单笔/批量),用于签署回调后自动创建提现申请 + withdrawal_payload = models.JSONField( + default=dict, + blank=True, + verbose_name="提现申请信息", + help_text="用于签署完成后自动创建提现申请(例如 points / point_source_id)。", + ) + created_withdrawal_request_ids = models.JSONField( + default=list, + blank=True, + verbose_name="已创建的提现申请ID列表", + ) + withdrawal_error = models.TextField(blank=True, verbose_name="提现创建错误") + + # 法大大请求与回调记录,便于排障 + fdd_request_payload = models.JSONField( + null=True, blank=True, verbose_name="法大大请求" + ) + fdd_response_payload = models.JSONField( + null=True, blank=True, verbose_name="法大大响应" + ) + fdd_webhook_payload = models.JSONField( + null=True, blank=True, verbose_name="法大大回调" + ) + + signed_at = models.DateTimeField(null=True, blank=True, verbose_name="签署完成时间") + created_at = models.DateTimeField( + auto_now_add=True, db_index=True, verbose_name="创建时间" + ) + updated_at = models.DateTimeField(auto_now=True, verbose_name="更新时间") + + class Meta: + """模型元数据配置.""" + + ordering = ["-created_at"] + verbose_name = "提现合同签署" + verbose_name_plural = verbose_name + constraints = [ + models.UniqueConstraint( + fields=["user"], + condition=models.Q(status="PENDING"), + name="unique_pending_withdrawal_contract_per_user", + ), + ] + + def __str__(self): + """返回签署记录的字符串表示.""" + return f"{self.user.username} - {self.get_status_display()} - #{self.id}" + + class PointTransaction(models.Model): """积分交易记录模型, 记录用户积分的获得和消费.""" diff --git a/points/tests/test_withdrawal_contract_signing.py b/points/tests/test_withdrawal_contract_signing.py new file mode 100644 index 0000000..99a33c3 --- /dev/null +++ b/points/tests/test_withdrawal_contract_signing.py @@ -0,0 +1,195 @@ +"""Tests for withdrawal contract signing flow.""" + +import json +from unittest.mock import patch + +from django.contrib.auth import get_user_model +from django.contrib.messages import get_messages +from django.test import TestCase, override_settings +from django.urls import reverse + +from points.models import PointSource, Tag, WithdrawalContractSigning, WithdrawalRequest + + +class WithdrawalContractSigningViewTests(TestCase): + """Test withdrawal views when contract signing is required.""" + + def setUp(self): + self.user = get_user_model().objects.create_user( + username="sign_user", email="sign_user@example.com", password="pwd12345" + ) + self.client.login(username="sign_user", password="pwd12345") + + self.withdrawable_tag = Tag.objects.create(name="wd_tag", withdrawable=True) + self.point_source = PointSource.objects.create( + user=self.user, + initial_points=100, + remaining_points=100, + ) + self.point_source.tags.add(self.withdrawable_tag) + + @override_settings( + WITHDRAWAL_CONTRACT_SIGNING_REQUIRED=True, + FDD_API_HOST="https://example.com/api/v5/", + FDD_APP_ID="app_id", + FDD_APP_SECRET="app_secret", + ) + @patch("points.fadada.FadadaClient.sign_with_template") + def test_withdrawal_create_starts_signing_when_not_signed(self, sign_mock): + sign_mock.return_value = {"ok": True} + + url = reverse("points:withdrawal_create", args=[self.point_source.id]) + response = self.client.post( + url, + { + "points": 10, + "real_name": "测试", + "id_number": "110101199001011234", + "phone_number": "13800138000", + "bank_name": "银行", + "bank_account": "6222020200012345678", + }, + follow=True, + ) + + self.assertRedirects(response, reverse("points:withdrawal_list")) + self.assertEqual( + WithdrawalRequest.objects.filter(user=self.user).count(), + 0, + ) + + record = WithdrawalContractSigning.objects.get(user=self.user) + self.assertEqual(record.status, WithdrawalContractSigning.Status.PENDING) + self.assertEqual( + record.withdrawal_payload["point_source_id"], self.point_source.id + ) + self.assertEqual(record.withdrawal_payload["points"], 10) + self.assertEqual(record.fdd_request_payload["signing_record_id"], record.id) + + messages = list(get_messages(response.wsgi_request)) + self.assertTrue( + any("已发起提现合同签署" in str(message) for message in messages), + f"Expected signing message, got: {[str(m) for m in messages]}", + ) + sign_mock.assert_called_once() + + +class WithdrawalContractSigningWebhookTests(TestCase): + """Test webhook callback handling for withdrawal contract signing.""" + + def setUp(self): + self.user = get_user_model().objects.create_user( + username="hook_user", email="hook_user@example.com", password="pwd12345" + ) + self.withdrawable_tag = Tag.objects.create(name="wd_tag", withdrawable=True) + + def _create_source(self, points: int = 100) -> PointSource: + source = PointSource.objects.create( + user=self.user, + initial_points=points, + remaining_points=points, + ) + source.tags.add(self.withdrawable_tag) + return source + + def test_webhook_marks_signed_and_creates_withdrawal_request(self): + source = self._create_source(points=100) + record = WithdrawalContractSigning.objects.create( + user=self.user, + status=WithdrawalContractSigning.Status.PENDING, + real_name="测试", + id_number="110101199001011234", + phone_number="13800138000", + bank_name="银行", + bank_account="6222020200012345678", + withdrawal_payload={"point_source_id": source.id, "points": 15}, + ) + + url = reverse("points_webhooks:fdd_withdrawal_contract_webhook") + response = self.client.post( + url, + data=json.dumps({"signing_record_id": record.id, "status": "SIGNED"}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 200) + record.refresh_from_db() + self.assertEqual(record.status, WithdrawalContractSigning.Status.SIGNED) + self.assertIsNotNone(record.signed_at) + + withdrawals = WithdrawalRequest.objects.filter(user=self.user) + self.assertEqual(withdrawals.count(), 1) + withdrawal = withdrawals.first() + assert withdrawal is not None + self.assertEqual(withdrawal.point_source_id, source.id) + self.assertEqual(withdrawal.points, 15) + self.assertEqual(withdrawal.real_name, "测试") + + self.assertEqual(record.created_withdrawal_request_ids, [withdrawal.id]) + + def test_webhook_creates_batch_withdrawal_requests(self): + source1 = self._create_source(points=100) + source2 = self._create_source(points=200) + record = WithdrawalContractSigning.objects.create( + user=self.user, + status=WithdrawalContractSigning.Status.PENDING, + real_name="测试", + id_number="110101199001011234", + phone_number="13800138000", + bank_name="银行", + bank_account="6222020200012345678", + withdrawal_payload={ + "withdrawal_amounts": {str(source1.id): 10, str(source2.id): 20} + }, + ) + + url = reverse("points_webhooks:fdd_withdrawal_contract_webhook") + response = self.client.post( + url, + data=json.dumps({"signing_record_id": record.id, "status": "SIGNED"}), + content_type="application/json", + ) + + self.assertEqual(response.status_code, 200) + record.refresh_from_db() + self.assertEqual(record.status, WithdrawalContractSigning.Status.SIGNED) + + withdrawals = WithdrawalRequest.objects.filter(user=self.user).order_by("id") + self.assertEqual(withdrawals.count(), 2) + self.assertEqual( + {wr.point_source_id for wr in withdrawals}, {source1.id, source2.id} + ) + self.assertEqual({wr.points for wr in withdrawals}, {10, 20}) + self.assertEqual( + record.created_withdrawal_request_ids, [wr.id for wr in withdrawals] + ) + + @override_settings(FDD_WEBHOOK_TOKEN="secret-token") + def test_webhook_token_required_when_configured(self): + source = self._create_source(points=100) + record = WithdrawalContractSigning.objects.create( + user=self.user, + status=WithdrawalContractSigning.Status.PENDING, + real_name="测试", + id_number="110101199001011234", + phone_number="13800138000", + bank_name="银行", + bank_account="6222020200012345678", + withdrawal_payload={"point_source_id": source.id, "points": 1}, + ) + + url = reverse("points_webhooks:fdd_withdrawal_contract_webhook") + response = self.client.post( + url, + data=json.dumps({"signing_record_id": record.id, "status": "SIGNED"}), + content_type="application/json", + ) + self.assertEqual(response.status_code, 403) + + response = self.client.post( + url, + data=json.dumps({"signing_record_id": record.id, "status": "SIGNED"}), + content_type="application/json", + HTTP_X_FDD_WEBHOOK_TOKEN="secret-token", + ) + self.assertEqual(response.status_code, 200) diff --git a/points/views.py b/points/views.py index aef4761..69c4c56 100644 --- a/points/views.py +++ b/points/views.py @@ -4,6 +4,7 @@ from collections import defaultdict from datetime import timedelta +from django.conf import settings from django.contrib import messages from django.contrib.auth.decorators import login_required from django.core.paginator import Paginator @@ -22,6 +23,11 @@ cancel_withdrawal, create_withdrawal_request, ) +from points.withdrawal_contracts import ( + get_latest_pending_contract, + get_latest_signed_contract, + start_withdrawal_contract_signing, +) TREND_DAYS = 30 @@ -114,6 +120,61 @@ def _build_tag_trends(user, tags, start_date): return datasets +def _get_contract_signing_state(user): + require_contract_signing = getattr( + settings, "WITHDRAWAL_CONTRACT_SIGNING_REQUIRED", False + ) + signed_contract = get_latest_signed_contract(user) + pending_contract = None + if require_contract_signing and not signed_contract: + pending_contract = get_latest_pending_contract(user) + return require_contract_signing, signed_contract, pending_contract + + +def _get_withdrawable_sources(user): + sources = ( + user.point_sources.filter(remaining_points__gt=0) + .prefetch_related("tags") + .order_by("id") + ) + return [source for source in sources if source.is_withdrawable] + + +def _collect_batch_withdrawal_amounts(request, withdrawable_sources, form): + withdrawal_amounts: dict[int, int] = {} + + for source in withdrawable_sources: + field_name = f"points_{source.id}" + points_str = request.POST.get(field_name, "").strip() + if not points_str: + continue + + try: + points = int(points_str) + except ValueError: + messages.error(request, f"积分池 #{source.id} 的提现数量格式不正确。") + form.add_error(None, f"积分池 #{source.id} 的提现数量必须是整数。") + continue + + if points <= 0: + continue + + if points > source.remaining_points: + messages.error( + request, + f"积分池 #{source.id} 的提现数量不能超过剩余积分 {source.remaining_points}。", + ) + form.add_error(None, f"积分池 #{source.id} 的提现数量不能超过剩余积分。") + continue + + withdrawal_amounts[source.id] = points + + if not withdrawal_amounts: + form.add_error(None, "至少需要为一个积分池设置提现数量。") + + return withdrawal_amounts + + @login_required def my_points(request): """ @@ -182,8 +243,20 @@ def withdrawal_create(request, point_source_id): messages.error(request, "该积分来源没有可提现的积分。") return redirect("points:my_points") + require_contract_signing = getattr( + settings, "WITHDRAWAL_CONTRACT_SIGNING_REQUIRED", False + ) + signed_contract = get_latest_signed_contract(request.user) + pending_contract = None + if require_contract_signing and not signed_contract: + pending_contract = get_latest_pending_contract(request.user) + if request.method == "POST": - form = WithdrawalRequestForm(request.POST, point_source=point_source) + form = WithdrawalRequestForm( + request.POST, + point_source=point_source, + signed_contract=signed_contract if require_contract_signing else None, + ) if form.is_valid(): try: withdrawal_data = WithdrawalData( @@ -193,6 +266,28 @@ def withdrawal_create(request, point_source_id): bank_name=form.cleaned_data["bank_name"], bank_account=form.cleaned_data["bank_account"], ) + if require_contract_signing and not signed_contract: + if pending_contract: + messages.info( + request, + "您已发起提现合同签署,请先完成短信签署后再继续。", + ) + return redirect("points:withdrawal_list") + + contract = start_withdrawal_contract_signing( + user=request.user, + withdrawal_data=withdrawal_data, + withdrawal_payload={ + "point_source_id": point_source.id, + "points": int(form.cleaned_data["points"]), + }, + ) + messages.success( + request, + f"已发起提现合同签署(记录ID: #{contract.id})。请查收短信完成签署,签署完成后将自动提交提现申请。", + ) + return redirect("points:withdrawal_list") + withdrawal_request = create_withdrawal_request( user=request.user, point_source_id=point_source.id, @@ -208,14 +303,21 @@ def withdrawal_create(request, point_source_id): PointSource.DoesNotExist, PointSourceNotWithdrawableError, WithdrawalAmountError, + WithdrawalError, ) as e: messages.error(request, str(e)) else: - form = WithdrawalRequestForm(point_source=point_source) + form = WithdrawalRequestForm( + point_source=point_source, + signed_contract=signed_contract if require_contract_signing else None, + ) context = { "form": form, "point_source": point_source, + "signed_contract": signed_contract, + "pending_contract": pending_contract, + "require_contract_signing": require_contract_signing, } return render(request, "points/withdrawal_create.html", context) @@ -332,59 +434,29 @@ def batch_withdrawal(request): from points.forms import BatchWithdrawalInfoForm from points.services import create_batch_withdrawal_requests - # Get all withdrawable point sources for the user - withdrawable_sources = [ - source - for source in request.user.point_sources.filter( - remaining_points__gt=0 - ).prefetch_related("tags") - if source.is_withdrawable - ] + withdrawable_sources = _get_withdrawable_sources(request.user) # Check if user has any withdrawable sources if not withdrawable_sources: messages.warning(request, "您没有可提现的积分池。") return redirect("points:my_points") - if request.method == "POST": - form = BatchWithdrawalInfoForm(request.POST) - - # Collect withdrawal amounts from POST data - withdrawal_amounts = {} - has_any_amount = False - - for source in withdrawable_sources: - field_name = f"points_{source.id}" - points_str = request.POST.get(field_name, "").strip() - - if points_str: - try: - points = int(points_str) - if points > 0: - # Validate amount doesn't exceed remaining points - if points > source.remaining_points: - messages.error( - request, - f"积分池 #{source.id} 的提现数量不能超过剩余积分 {source.remaining_points}。", - ) - form.add_error( - None, - f"积分池 #{source.id} 的提现数量不能超过剩余积分。", - ) - else: - withdrawal_amounts[source.id] = points - has_any_amount = True - except ValueError: - messages.error( - request, f"积分池 #{source.id} 的提现数量格式不正确。" - ) - form.add_error(None, f"积分池 #{source.id} 的提现数量必须是整数。") + require_contract_signing, signed_contract, pending_contract = ( + _get_contract_signing_state(request.user) + ) - # Validate that at least one amount is provided - if not has_any_amount: - form.add_error(None, "至少需要为一个积分池设置提现数量。") + if request.method == "POST": + form = BatchWithdrawalInfoForm( + request.POST, + signed_contract=signed_contract if require_contract_signing else None, + ) + withdrawal_amounts = _collect_batch_withdrawal_amounts( + request, + withdrawable_sources, + form, + ) - if form.is_valid() and has_any_amount: + if form.is_valid() and withdrawal_amounts: try: withdrawal_data = WithdrawalData( real_name=form.cleaned_data["real_name"], @@ -393,6 +465,24 @@ def batch_withdrawal(request): bank_name=form.cleaned_data["bank_name"], bank_account=form.cleaned_data["bank_account"], ) + if require_contract_signing and not signed_contract: + if pending_contract: + messages.info( + request, + "您已发起提现合同签署,请先完成短信签署后再继续。", + ) + return redirect("points:withdrawal_list") + + contract = start_withdrawal_contract_signing( + user=request.user, + withdrawal_data=withdrawal_data, + withdrawal_payload={"withdrawal_amounts": withdrawal_amounts}, + ) + messages.success( + request, + f"已发起提现合同签署(记录ID: #{contract.id})。请查收短信完成签署,签署完成后将自动提交提现申请。", + ) + return redirect("points:withdrawal_list") withdrawal_requests = create_batch_withdrawal_requests( user=request.user, @@ -413,11 +503,16 @@ def batch_withdrawal(request): ) as e: messages.error(request, str(e)) else: - form = BatchWithdrawalInfoForm() + form = BatchWithdrawalInfoForm( + signed_contract=signed_contract if require_contract_signing else None + ) context = { "form": form, "withdrawable_sources": withdrawable_sources, + "signed_contract": signed_contract, + "pending_contract": pending_contract, + "require_contract_signing": require_contract_signing, } return render(request, "points/batch_withdrawal.html", context) diff --git a/points/webhook_urls.py b/points/webhook_urls.py new file mode 100644 index 0000000..46b1b7d --- /dev/null +++ b/points/webhook_urls.py @@ -0,0 +1,15 @@ +"""URL configuration for external webhooks.""" + +from django.urls import path + +from points import webhooks + +app_name = "points_webhooks" + +urlpatterns = [ + path( + "fdd/withdrawal-contract/", + webhooks.fdd_withdrawal_contract_webhook, + name="fdd_withdrawal_contract_webhook", + ), +] diff --git a/points/webhooks.py b/points/webhooks.py new file mode 100644 index 0000000..afc152d --- /dev/null +++ b/points/webhooks.py @@ -0,0 +1,72 @@ +"""Webhook endpoints for external integrations.""" + +from __future__ import annotations + +import json +import logging +from typing import Any + +from django.conf import settings +from django.http import JsonResponse +from django.views.decorators.csrf import csrf_exempt + +from points.models import WithdrawalContractSigning +from points.services import WithdrawalError +from points.withdrawal_contracts import handle_withdrawal_contract_webhook + +logger = logging.getLogger(__name__) + + +def _read_json_body(request) -> dict[str, Any]: + if not request.body: + return {} + payload = json.loads(request.body.decode("utf-8")) + if not isinstance(payload, dict): + msg = "JSON body must be an object." + raise ValueError(msg) + return payload + + +@csrf_exempt +def fdd_withdrawal_contract_webhook(request): + """Webhook receiver for FaDaDa signing callbacks.""" + status = 200 + body: dict[str, Any] = {"ok": False} + + if request.method != "POST": + status = 405 + body = {"detail": "Method not allowed."} + else: + expected_token = getattr(settings, "FDD_WEBHOOK_TOKEN", "") + if expected_token: + received = request.headers.get("X-FDD-Webhook-Token", "") + if received != expected_token: + status = 403 + body = {"detail": "Forbidden."} + if status == 200: + try: + payload = _read_json_body(request) + except (json.JSONDecodeError, ValueError): + status = 400 + body = {"detail": "Invalid JSON."} + else: + try: + record = handle_withdrawal_contract_webhook(payload) + except WithdrawalContractSigning.DoesNotExist: + status = 404 + body = {"detail": "Signing record not found."} + except WithdrawalError as exc: + status = 400 + body = {"detail": str(exc)} + except Exception: # pragma: no cover - defensive + logger.exception("Unhandled error processing FaDaDa webhook") + status = 500 + body = {"detail": "Internal error."} + else: + body = { + "ok": True, + "record_id": record.id, + "status": record.status, + } + + return JsonResponse(body, status=status) diff --git a/points/withdrawal_contracts.py b/points/withdrawal_contracts.py new file mode 100644 index 0000000..c402327 --- /dev/null +++ b/points/withdrawal_contracts.py @@ -0,0 +1,302 @@ +"""Withdrawal contract signing flow (pre-withdrawal signing).""" + +from __future__ import annotations + +import logging +from typing import Any + +from django.db import transaction +from django.utils import timezone + +from points.fadada import ( + FadadaClient, + FadadaClientError, + FadadaNotConfiguredError, + FadadaRequestError, +) +from points.models import WithdrawalContractSigning +from points.services import ( + WithdrawalData, + WithdrawalError, + create_batch_withdrawal_requests, + create_withdrawal_request, +) + +logger = logging.getLogger(__name__) + + +def get_latest_signed_contract( + user, +) -> WithdrawalContractSigning | None: + """Return the most recent signed contract for the given user, if any.""" + return ( + WithdrawalContractSigning.objects.filter( + user=user, status=WithdrawalContractSigning.Status.SIGNED + ) + .order_by("-signed_at", "-created_at") + .first() + ) + + +def get_latest_pending_contract( + user, +) -> WithdrawalContractSigning | None: + """Return the latest pending signing record for the given user, if any.""" + return ( + WithdrawalContractSigning.objects.filter( + user=user, status=WithdrawalContractSigning.Status.PENDING + ) + .order_by("-created_at") + .first() + ) + + +def _extract_signing_record_id(payload: dict[str, Any]) -> int | None: + candidates = ( + "signing_record_id", + "signRecordId", + "record_id", + "bizId", + "biz_id", + "businessId", + "business_id", + ) + nested = payload.get("data") if isinstance(payload.get("data"), dict) else {} + + for key in candidates: + raw = payload.get(key) or nested.get(key) + if raw is None: + continue + try: + return int(raw) + except (TypeError, ValueError): + continue + return None + + +def _payload_indicates_signed(payload: dict[str, Any]) -> bool: + def _upper(value: Any) -> str: + return str(value or "").strip().upper() + + event = _upper(payload.get("event") or payload.get("type")) + status = _upper(payload.get("status") or payload.get("signStatus")) + result = _upper(payload.get("result") or payload.get("signResult")) + + nested = payload.get("data") if isinstance(payload.get("data"), dict) else {} + event = event or _upper(nested.get("event") or nested.get("type")) + status = status or _upper(nested.get("status") or nested.get("signStatus")) + result = result or _upper(nested.get("result") or nested.get("signResult")) + + signed_events = { + "SIGN_FINISH", + "SIGN_FINISHED", + "SIGN_COMPLETE", + "SIGN_COMPLETED", + "CONTRACT_SIGNED", + } + signed_statuses = {"SIGNED", "COMPLETED", "FINISHED", "SUCCESS", "OK"} + + return ( + event in signed_events or status in signed_statuses or result in signed_statuses + ) + + +def _payload_indicates_failed(payload: dict[str, Any]) -> bool: + def _upper(value: Any) -> str: + return str(value or "").strip().upper() + + status = _upper(payload.get("status") or payload.get("signStatus")) + nested = payload.get("data") if isinstance(payload.get("data"), dict) else {} + status = status or _upper(nested.get("status") or nested.get("signStatus")) + + return status in {"FAILED", "FAIL", "REJECTED", "CANCELLED", "CANCELED"} + + +def _withdrawal_data_from_contract( + contract: WithdrawalContractSigning, +) -> WithdrawalData: + return WithdrawalData( + real_name=contract.real_name, + id_number=contract.id_number, + phone_number=contract.phone_number, + bank_name=contract.bank_name, + bank_account=contract.bank_account, + ) + + +@transaction.atomic +def start_withdrawal_contract_signing( + *, + user, + withdrawal_data: WithdrawalData, + withdrawal_payload: dict[str, Any], +) -> WithdrawalContractSigning: + """Create a pending signing record and trigger the external signing workflow.""" + pending = ( + WithdrawalContractSigning.objects.select_for_update() + .filter(user=user, status=WithdrawalContractSigning.Status.PENDING) + .first() + ) + if pending: + return pending + + contract = WithdrawalContractSigning.objects.create( + user=user, + status=WithdrawalContractSigning.Status.PENDING, + real_name=withdrawal_data.real_name, + id_number=withdrawal_data.id_number, + phone_number=withdrawal_data.phone_number, + bank_name=withdrawal_data.bank_name, + bank_account=withdrawal_data.bank_account, + withdrawal_payload=withdrawal_payload, + fdd_request_payload={ + "real_name": withdrawal_data.real_name, + "id_number": withdrawal_data.id_number, + "phone_number": withdrawal_data.phone_number, + "bank_name": withdrawal_data.bank_name, + "bank_account": withdrawal_data.bank_account, + "signing_record_id": None, + }, + ) + contract.fdd_request_payload["signing_record_id"] = contract.id + contract.save(update_fields=["fdd_request_payload", "updated_at"]) + + try: + client = FadadaClient.from_settings() + response = client.sign_with_template( + withdrawal_data=withdrawal_data, + signing_record_id=contract.id, + ) + contract.fdd_response_payload = response + contract.save(update_fields=["fdd_response_payload", "updated_at"]) + except NotImplementedError as exc: + logger.exception( + "提现合同签署接口未实现: user_id=%s, record_id=%s", user.id, contract.id + ) + contract.status = WithdrawalContractSigning.Status.FAILED + contract.fdd_response_payload = { + "error": str(exc), + "error_type": "NOT_IMPLEMENTED", + } + contract.save(update_fields=["status", "fdd_response_payload", "updated_at"]) + msg = f"合同签署接口尚未接入(记录ID: #{contract.id}),请联系管理员。" + raise WithdrawalError(msg) from exc + except FadadaNotConfiguredError as exc: + logger.exception( + "法大大配置缺失,无法发起提现合同签署: user_id=%s, record_id=%s", + user.id, + contract.id, + ) + contract.status = WithdrawalContractSigning.Status.FAILED + contract.fdd_response_payload = { + "error": str(exc), + "error_type": "NOT_CONFIGURED", + } + contract.save(update_fields=["status", "fdd_response_payload", "updated_at"]) + msg = f"合同签署服务未配置(记录ID: #{contract.id}),请联系管理员。" + raise WithdrawalError(msg) from exc + except FadadaRequestError as exc: + logger.exception( + "法大大请求失败,无法发起提现合同签署: user_id=%s, record_id=%s", + user.id, + contract.id, + ) + contract.status = WithdrawalContractSigning.Status.FAILED + contract.fdd_response_payload = { + "error": str(exc), + "error_type": "REQUEST_ERROR", + } + contract.save(update_fields=["status", "fdd_response_payload", "updated_at"]) + msg = "发起提现合同签署失败,请稍后重试。" + raise WithdrawalError(msg) from exc + except FadadaClientError as exc: + logger.exception( + "发起提现合同签署失败: user_id=%s, record_id=%s", + user.id, + contract.id, + ) + contract.status = WithdrawalContractSigning.Status.FAILED + contract.fdd_response_payload = { + "error": str(exc), + "error_type": "CLIENT_ERROR", + } + contract.save(update_fields=["status", "fdd_response_payload", "updated_at"]) + msg = "发起提现合同签署失败,请稍后重试或联系管理员。" + raise WithdrawalError(msg) from exc + + return contract + + +@transaction.atomic +def handle_withdrawal_contract_webhook( + payload: dict[str, Any], +) -> WithdrawalContractSigning: + """Handle FaDaDa webhook payload and create withdrawal requests on success.""" + record_id = _extract_signing_record_id(payload) + if not record_id: + msg = "Webhook payload missing signing record id." + raise WithdrawalError(msg) + + contract = WithdrawalContractSigning.objects.select_for_update().get(id=record_id) + contract.fdd_webhook_payload = payload + contract.save(update_fields=["fdd_webhook_payload", "updated_at"]) + + if contract.status == WithdrawalContractSigning.Status.SIGNED: + return contract + + if _payload_indicates_failed(payload): + contract.status = WithdrawalContractSigning.Status.FAILED + contract.save(update_fields=["status", "updated_at"]) + return contract + + if not _payload_indicates_signed(payload): + return contract + + contract.status = WithdrawalContractSigning.Status.SIGNED + contract.signed_at = timezone.now() + contract.save(update_fields=["status", "signed_at", "updated_at"]) + + if contract.created_withdrawal_request_ids: + return contract + + created_ids: list[int] = [] + try: + wd = _withdrawal_data_from_contract(contract) + payload = contract.withdrawal_payload or {} + if isinstance(payload.get("withdrawal_amounts"), dict): + withdrawal_amounts = { + int(key): int(value) + for key, value in payload["withdrawal_amounts"].items() + } + requests = create_batch_withdrawal_requests( + user=contract.user, + withdrawal_amounts=withdrawal_amounts, + withdrawal_data=wd, + ) + created_ids = [req.id for req in requests] + elif ( + payload.get("point_source_id") is not None + and payload.get("points") is not None + ): + req = create_withdrawal_request( + user=contract.user, + point_source_id=int(payload["point_source_id"]), + points=int(payload["points"]), + withdrawal_data=wd, + ) + created_ids = [req.id] + except Exception as exc: # pragma: no cover - defensive, depends on business state + logger.exception( + "签署完成后创建提现申请失败: record_id=%s, user_id=%s", + contract.id, + contract.user_id, + ) + contract.withdrawal_error = str(exc) + contract.save(update_fields=["withdrawal_error", "updated_at"]) + return contract + + if created_ids: + contract.created_withdrawal_request_ids = created_ids + contract.save(update_fields=["created_withdrawal_request_ids", "updated_at"]) + + return contract diff --git a/templates/points/batch_withdrawal.html b/templates/points/batch_withdrawal.html index 95f840b..0e8824c 100644 --- a/templates/points/batch_withdrawal.html +++ b/templates/points/batch_withdrawal.html @@ -115,9 +115,28 @@
请填写真实的提现信息,这些信息将用于审核和打款
+ {% if require_contract_signing %} + {% if signed_contract %} +