diff --git a/peizhi/migrations/0023_user_wechat_mch_bind.py b/peizhi/migrations/0023_user_wechat_mch_bind.py new file mode 100644 index 0000000..de79f77 --- /dev/null +++ b/peizhi/migrations/0023_user_wechat_mch_bind.py @@ -0,0 +1,30 @@ +# Generated manually: UserWechatMchBind + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('peizhi', '0022_wechat_pay_mch_config'), + ] + + operations = [ + migrations.CreateModel( + name='UserWechatMchBind', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('yonghuid', models.CharField(db_index=True, max_length=32, unique=True, verbose_name='用户ID')), + ('mch_id', models.CharField(db_index=True, max_length=32, verbose_name='绑定商户号')), + ('enabled', models.BooleanField(db_index=True, default=True, verbose_name='是否启用')), + ('remark', models.CharField(blank=True, default='', max_length=200, verbose_name='备注')), + ('create_time', models.DateTimeField(auto_now_add=True, verbose_name='创建时间')), + ('update_time', models.DateTimeField(auto_now=True, verbose_name='更新时间')), + ], + options={ + 'verbose_name': '用户转账商户绑定', + 'verbose_name_plural': '用户转账商户绑定', + 'db_table': 'user_wechat_mch_bind', + }, + ), + ] diff --git a/peizhi/models.py b/peizhi/models.py index c2a7a30..03b081e 100644 --- a/peizhi/models.py +++ b/peizhi/models.py @@ -838,4 +838,25 @@ class WechatPayMchConfig(models.Model): ordering = ['priority', 'id'] def __str__(self): - return f'{self.mch_id}({self.name or "-"})' \ No newline at end of file + return f'{self.mch_id}({self.name or "-"})' + + +class UserWechatMchBind(models.Model): + """ + 用户指定收款商户号:启用后该用户收款强制只用绑定商户,不参与自动轮换。 + 改绑不会改变已发出打款单的查单商户(仍按 TixianAutoRecord.mch_id)。 + """ + yonghuid = models.CharField(max_length=32, unique=True, db_index=True, verbose_name='用户ID') + mch_id = models.CharField(max_length=32, db_index=True, verbose_name='绑定商户号') + enabled = models.BooleanField(default=True, db_index=True, verbose_name='是否启用') + remark = models.CharField(max_length=200, blank=True, default='', verbose_name='备注') + create_time = models.DateTimeField(auto_now_add=True, verbose_name='创建时间') + update_time = models.DateTimeField(auto_now=True, verbose_name='更新时间') + + class Meta: + db_table = 'user_wechat_mch_bind' + verbose_name = '用户转账商户绑定' + verbose_name_plural = verbose_name + + def __str__(self): + return f'{self.yonghuid}->{self.mch_id}' \ No newline at end of file diff --git a/peizhi/urls.py b/peizhi/urls.py index 8af2b58..5fed76c 100644 --- a/peizhi/urls.py +++ b/peizhi/urls.py @@ -11,7 +11,10 @@ from .xcx_config_views import ( XcxSysPeizhiQueryView, XcxSysPeizhiUpdateView, XcxPageZiyuanQueryView, XcxPageZiyuanUpdateView, XcxPageZiyuanUploadView, ) -from .wx_mch_views import WechatMchListView, WechatMchSaveView, WechatMchUploadView +from .wx_mch_views import ( + WechatMchListView, WechatMchSaveView, WechatMchUploadView, + WechatMchUserBindListView, WechatMchUserBindSaveView, WechatMchUserBindDeleteView, +) from .xieyi_views import XieyiListView, XieyiDetailView, XieyiSignStatusView, XieyiSignView, XieyiCheckView urlpatterns = [ @@ -57,6 +60,9 @@ urlpatterns = [ path('wxmchlb', WechatMchListView.as_view(), name='获取转账商户列表'), path('wxmchbj', WechatMchSaveView.as_view(), name='保存转账商户配置'), path('wxmchscwj', WechatMchUploadView.as_view(), name='上传转账商户证书'), + path('wxmchUserLb', WechatMchUserBindListView.as_view(), name='用户绑定转账商户列表'), + path('wxmchUserBj', WechatMchUserBindSaveView.as_view(), name='保存用户绑定转账商户'), + path('wxmchUserSc', WechatMchUserBindDeleteView.as_view(), name='删除用户绑定转账商户'), path('xieyihq', XieyiListView.as_view(), name='小程序获取协议列表'), path('xieyixq', XieyiDetailView.as_view(), name='小程序获取协议详情'), path('xieyijc', XieyiCheckView.as_view(), name='小程序检查协议签署'), diff --git a/peizhi/wx_mch_views.py b/peizhi/wx_mch_views.py index 30e89c1..c8d7873 100644 --- a/peizhi/wx_mch_views.py +++ b/peizhi/wx_mch_views.py @@ -9,7 +9,7 @@ from rest_framework.parsers import FormParser, JSONParser, MultiPartParser from rest_framework.response import Response from rest_framework.views import APIView -from peizhi.models import WechatPayMchConfig +from peizhi.models import UserWechatMchBind, WechatPayMchConfig from peizhi.xcx_config_views import _authorize_miniapp_config, _mask_secret from utils.wechat_mch_service import cert_base_dir, seed_from_settings, wx_cfg_is_complete, row_to_wx_cfg @@ -202,3 +202,143 @@ class WechatMchUploadView(APIView): 'original_name': safe, }, }) + + +def _serialize_bind(row): + mch = WechatPayMchConfig.objects.filter(mch_id=row.mch_id).first() + return { + 'id': row.id, + 'yonghuid': row.yonghuid, + 'mch_id': row.mch_id, + 'mch_name': (mch.name if mch else '') or '', + 'mch_enabled': bool(mch.enabled) if mch else False, + 'enabled': bool(row.enabled), + 'remark': row.remark or '', + 'create_time': row.create_time.strftime('%Y-%m-%d %H:%M:%S') if row.create_time else '', + 'update_time': row.update_time.strftime('%Y-%m-%d %H:%M:%S') if row.update_time else '', + } + + +class WechatMchUserBindListView(APIView): + """POST /peizhi/wxmchUserLb — 用户绑定商户列表""" + permission_classes = [permissions.IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + ok, err = _authorize_miniapp_config(request) + if not ok: + return err + + yonghuid = str(request.data.get('yonghuid') or '').strip() + qs = UserWechatMchBind.objects.all().order_by('-update_time', '-id') + if yonghuid: + qs = qs.filter(yonghuid=yonghuid) + + try: + page = max(1, int(request.data.get('page') or 1)) + limit = min(100, max(1, int(request.data.get('limit') or 50))) + except (TypeError, ValueError): + page, limit = 1, 50 + total = qs.count() + start = (page - 1) * limit + rows = list(qs[start:start + limit]) + return Response({ + 'code': 0, + 'data': { + 'list': [_serialize_bind(r) for r in rows], + 'total': total, + 'page': page, + 'limit': limit, + }, + }) + + +class WechatMchUserBindSaveView(APIView): + """POST /peizhi/wxmchUserBj — 新增/更新用户绑定商户""" + permission_classes = [permissions.IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + ok, err = _authorize_miniapp_config(request) + if not ok: + return err + + data = request.data + yonghuid = str(data.get('yonghuid') or '').strip() + mch_id = str(data.get('mch_id') or '').strip() + if not yonghuid: + return Response({'code': 400, 'msg': '用户ID不能为空'}) + if not mch_id: + return Response({'code': 400, 'msg': '商户号不能为空'}) + if not WechatPayMchConfig.objects.filter(mch_id=mch_id).exists(): + return Response({'code': 400, 'msg': f'商户号 {mch_id} 不在转账商户配置中'}) + + remark = str(data.get('remark') or '').strip()[:200] + enabled = bool(data.get('enabled', True)) + row_id = data.get('id') + + try: + with transaction.atomic(): + if row_id: + row = UserWechatMchBind.objects.select_for_update().get(id=int(row_id)) + # 改用户ID时防冲突 + if row.yonghuid != yonghuid: + if UserWechatMchBind.objects.filter(yonghuid=yonghuid).exclude(id=row.id).exists(): + return Response({'code': 400, 'msg': '该用户已有绑定记录'}) + row.yonghuid = yonghuid + row.mch_id = mch_id + row.remark = remark + row.enabled = enabled + row.save() + else: + row, created = UserWechatMchBind.objects.update_or_create( + yonghuid=yonghuid, + defaults={ + 'mch_id': mch_id, + 'remark': remark, + 'enabled': enabled, + }, + ) + if not created: + # update_or_create 已更新 + pass + except UserWechatMchBind.DoesNotExist: + return Response({'code': 404, 'msg': '绑定记录不存在'}) + except (TypeError, ValueError) as e: + return Response({'code': 400, 'msg': f'参数错误: {e}'}) + + logger.warning( + '保存用户商户绑定 yonghuid=%s mch_id=%s enabled=%s by=%s', + yonghuid, mch_id, enabled, getattr(request.user, 'username', ''), + ) + return Response({ + 'code': 0, + 'msg': '保存成功(已发出的待确认单不会因改绑新建第二笔)', + 'data': _serialize_bind(row), + }) + + +class WechatMchUserBindDeleteView(APIView): + """POST /peizhi/wxmchUserSc — 删除用户绑定""" + permission_classes = [permissions.IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + ok, err = _authorize_miniapp_config(request) + if not ok: + return err + + row_id = request.data.get('id') + yonghuid = str(request.data.get('yonghuid') or '').strip() + if not row_id and not yonghuid: + return Response({'code': 400, 'msg': '请传 id 或 yonghuid'}) + + qs = UserWechatMchBind.objects.all() + if row_id: + qs = qs.filter(id=int(row_id)) + else: + qs = qs.filter(yonghuid=yonghuid) + deleted, _ = qs.delete() + if not deleted: + return Response({'code': 404, 'msg': '记录不存在'}) + return Response({'code': 0, 'msg': '已删除绑定', 'data': {'deleted': deleted}}) diff --git a/utils/wechat_mch_service.py b/utils/wechat_mch_service.py index a0643d4..ba4149d 100644 --- a/utils/wechat_mch_service.py +++ b/utils/wechat_mch_service.py @@ -6,7 +6,7 @@ from __future__ import annotations import logging import os -from typing import Dict, List, Optional +from typing import Dict, List, Optional, Tuple from django.conf import settings @@ -88,6 +88,62 @@ def list_enabled_wx_cfgs() -> List[Dict]: return [] +def get_bound_mch_id(yonghuid: str) -> Optional[str]: + """启用中的用户绑定商户号;无绑定返回 None。""" + yonghuid = str(yonghuid or '').strip() + if not yonghuid: + return None + from peizhi.models import UserWechatMchBind + + row = ( + UserWechatMchBind.objects.filter(yonghuid=yonghuid, enabled=True) + .only('mch_id') + .first() + ) + if not row: + return None + return (row.mch_id or '').strip() or None + + +def list_wx_cfgs_for_collect(yonghuid: str) -> Tuple[List[Dict], Optional[str], bool]: + """ + 收款选户: + - 有启用绑定 → 仅返回该一户配置;证书不全/不存在则报错,绝不偷偷落到默认户 + - 无绑定 → list_enabled_wx_cfgs() + 返回 (cfgs, err_msg, is_user_bound) + """ + bound_mch = get_bound_mch_id(yonghuid) + if not bound_mch: + cfgs = list_enabled_wx_cfgs() + return cfgs, None, False + + cfg = get_wx_cfg_by_mch_id(bound_mch) + if not cfg or not wx_cfg_is_complete(cfg): + logger.error( + '用户绑定商户不可用 yonghuid=%s mch_id=%s', + yonghuid, bound_mch, + ) + return [], ( + f'该用户已绑定商户号 {bound_mch},但配置不完整或未启用,' + f'请在后台检查证书/启用状态或改绑' + ), True + + # 绑定户若在配置表被停用:get_wx_cfg_by_mch_id 仍可能因私钥完整返回 cfg, + # 强制要求绑定目标在启用列表或行本身 enabled + from peizhi.models import WechatPayMchConfig + row = WechatPayMchConfig.objects.filter(mch_id=bound_mch).first() + if row is not None and not row.enabled: + return [], ( + f'该用户已绑定商户号 {bound_mch},但该商户已停用,请后台改绑或重新启用' + ), True + + logger.warning( + '收款使用用户绑定商户 yonghuid=%s mch_id=%s(强制单户,不轮换)', + yonghuid, bound_mch, + ) + return [cfg], None, True + + def list_wx_cfgs_for_callback() -> List[Dict]: """ 回调验签/解密:包含已停用商户。 diff --git a/yonghu/tixian_shenhe_services.py b/yonghu/tixian_shenhe_services.py index 9cbbcb1..c515e8b 100644 --- a/yonghu/tixian_shenhe_services.py +++ b/yonghu/tixian_shenhe_services.py @@ -1444,19 +1444,18 @@ def reconcile_shenhe_wechat_bills(shenhe_danhao): ) continue - # 已失败记录:仅当微信实际已到账才复活; - # 勿把 WAIT_USER_CONFIRM 再甩给前端(确认页受限后本地已放弃该户,必须换下一商户) + # 已失败记录:仍问微信。若仍待确认必须回旧 package,禁止新建第二笔(防双收) if rec.zhuangtai == 2: wx_data = query_wechat_transfer_bill(rec.tixian_id) if wx_data is None or wx_data.get('_not_found'): continue action, payload = _sync_record_from_wx_query(rec, wx_data) - if action == RECONCILE_COMPLETED: + if action in (RECONCILE_COMPLETED, RECONCILE_WAIT_CONFIRM): + logger.warning( + '本地失败单微信仍有效 shenhe=%s bill=%s mch=%s action=%s(禁止新建第二笔)', + shenhe_danhao, rec.tixian_id, rec.mch_id, action, + ) return action, payload - logger.warning( - '本地已失败单忽略微信待确认 shenhe=%s bill=%s mch=%s action=%s', - shenhe_danhao, rec.tixian_id, rec.mch_id, action, - ) continue if pending_with_package: diff --git a/yonghu/tixian_shenhe_views.py b/yonghu/tixian_shenhe_views.py index 8411638..f4714bf 100644 --- a/yonghu/tixian_shenhe_views.py +++ b/yonghu/tixian_shenhe_views.py @@ -103,24 +103,43 @@ def _is_wx_ip_denied(err_code='', err_msg=''): )) -def _is_wx_clear_switchable_fail(err_code='', err_msg='', status_code=None): - """ - 明确未建单的业务失败:可静默换下一商户(日额度/余额不足/IP白名单/无权限等)。 - """ - if _is_wx_ip_denied(err_code, err_msg): - return True - if _is_wx_unsafe_to_switch(err_code, err_msg, status_code): - return False - if status_code in (400, 401, 403): - return True - blob = f'{err_code} {err_msg}'.upper() +def _is_wx_user_personal_quota_msg(err_msg=''): + """单用户/个人侧额度:禁止当作商户整户日限换号。""" + msg = str(err_msg or '') keys = ( - 'NOT_ENOUGH', 'INVALID_REQUEST', 'NO_AUTH', 'ACCOUNTERROR', 'ACCOUNT_ERROR', - 'PARAM_ERROR', 'SIGN_ERROR', 'APPID_MCHID_NOT_MATCH', 'CERT_ERROR', - '额度', '限额', '上限', '余额不足', '资金不足', '无权限', '商户号异常', '账户异常', - '收款受限', '付款受限', '受限', '管控', '不收不付', '日限额', '超出', + '向同一用户', '单用户', '同一用户', '收款用户', '该用户', + '用户收款', '收款受限', '付款受限', ) - return any(k in blob for k in keys) + return any(k in msg for k in keys) + + +def _is_wx_mch_total_quota_exhausted(err_code='', err_msg=''): + """ + 仅识别微信「商户整户」日/月转账额度耗尽(如超出商户单日转账额度)。 + 个人侧限额、笼统「受限/管控」、余额不足一律 False。 + """ + if _is_wx_unsafe_to_switch(err_code, err_msg): + return False + if _is_wx_user_personal_quota_msg(err_msg): + return False + msg = str(err_msg or '') + # 官方文案:超出商户单日转账额度,请核实产品设置是否准确 + allow_phrases = ( + '超出商户单日转账额度', + '超出商户单月转账额度', + '商户单日转账额度', + '商户单月转账额度', + '商户日转账额度', + '商户月转账额度', + ) + if any(p in msg for p in allow_phrases): + return True + # 宽松但仍要求「商户」+「日/月」+「额度」,避免「受限」误匹配 + if '商户' in msg and ('额度' in msg or '限额' in msg): + if any(k in msg for k in ('单日', '日转账', '单月', '月转账')): + if not _is_wx_user_personal_quota_msg(msg): + return True + return False def _log_wx_collect(tag, **kwargs): @@ -289,19 +308,20 @@ def process_audit_collect(request): ) try: - from utils.wechat_mch_service import list_enabled_wx_cfgs, wx_cfg_is_complete + from utils.wechat_mch_service import list_wx_cfgs_for_collect, wx_cfg_is_complete if not user_main.openid: return Response({'code': 10, 'msg': '用户未绑定微信,无法收款'}) - mch_cfgs = list_enabled_wx_cfgs() + mch_cfgs, bind_err, is_user_bound = list_wx_cfgs_for_collect(yonghuid) + if bind_err: + return Response({'code': 11, 'msg': bind_err}) if not mch_cfgs or not wx_cfg_is_complete(mch_cfgs[0]): - logger.error('微信打款配置不完整 enabled_count=%s', len(mch_cfgs)) + logger.error('微信打款配置不完整 enabled_count=%s bound=%s', len(mch_cfgs), is_user_bound) return Response({'code': 11, 'msg': '系统配置异常,请联系客服'}) - if len(mch_cfgs) < 2: - logger.error( - '启用且配置完整的转账商户仅 %s 个,无法轮换 mch_ids=%s ' - '(后台多个启用但私钥路径无效会被跳过;微信商户日额度失败时将无法换号)', + if not is_user_bound and len(mch_cfgs) < 2: + logger.warning( + '启用且配置完整的转账商户仅 %s 个,整户日限额时无法换号 mch_ids=%s', len(mch_cfgs), [c.get('MCHID') for c in mch_cfgs], ) @@ -399,39 +419,52 @@ def process_audit_collect(request): quota_reserved = True - # 多商户轮换:仅在发起阶段未进入待确认时切换;成功后 mch_id 必须落库 - # 本审核单已失败过的商户本轮优先跳过,直接试下一户 - failed_mchs = set( - TixianAutoRecord.objects.filter( - shenhe_danhao=shenhe_danhao, zhuangtai=2, - ).exclude(mch_id='').values_list('mch_id', flat=True) - ) - prefer_cfgs = [c for c in mch_cfgs if c.get('MCHID') not in failed_mchs] - if prefer_cfgs and len(prefer_cfgs) < len(mch_cfgs): - logger.warning( - '收款跳过已失败商户 shenhe=%s skip=%s try=%s', - shenhe_danhao, sorted(failed_mchs), - [c.get('MCHID') for c in prefer_cfgs], - ) - mch_cfgs = prefer_cfgs - elif failed_mchs and not prefer_cfgs: - # 可用商户都失败过:仍按原序再试一轮(例如限额日切后可能恢复) - logger.warning( - '可用商户均曾失败,按原序再试 shenhe=%s failed=%s', - shenhe_danhao, sorted(failed_mchs), + # 选户:绑定用户仅 1 户;未绑定按 priority。 + # 仅「商户整户日/月额度」+ 查单确认未建单 才静默换号;其它失败立刻回前端。 + if not is_user_bound and len(mch_cfgs) > 1: + failed_mchs = set( + TixianAutoRecord.objects.filter( + shenhe_danhao=shenhe_danhao, zhuangtai=2, + ).exclude(mch_id='').values_list('mch_id', flat=True) ) + prefer_cfgs = [c for c in mch_cfgs if c.get('MCHID') not in failed_mchs] + if prefer_cfgs and len(prefer_cfgs) < len(mch_cfgs): + logger.warning( + '收款优先未失败商户 shenhe=%s skip=%s try=%s', + shenhe_danhao, sorted(failed_mchs), + [c.get('MCHID') for c in prefer_cfgs], + ) + mch_cfgs = prefer_cfgs + _log_wx_collect( - '开始轮换', + '开始选户发起', shenhe=shenhe_danhao, + bound=is_user_bound, count=len(mch_cfgs), mch_ids=','.join(c.get('MCHID') for c in mch_cfgs), ) last_user_msg = '微信收款发起失败,请稍后重试' - last_was_balance = False tixian_id = None tried_mchs = [] tried_details = [] + def _fail_front(msg, code=99): + nonlocal quota_reserved + if quota_reserved and tixian_id: + release_collect_quota_for_record( + TixianAutoRecord.objects.get(tixian_id=tixian_id), + ) + quota_reserved = False + return Response({ + 'code': code, + 'msg': msg[:800], + 'data': { + 'source': 'wechat_official', + 'tried_mch_ids': tried_mchs, + 'user_bound': is_user_bound, + }, + }) + for mch_idx, wx_cfg in enumerate(mch_cfgs): tixian_id = generate_tixian_id() mch_id = wx_cfg['MCHID'] @@ -441,6 +474,7 @@ def process_audit_collect(request): idx=f'{mch_idx + 1}/{len(mch_cfgs)}', mch_id=mch_id, bill=tixian_id, + bound=is_user_bound, ) with transaction.atomic(): TixianAutoRecord.objects.create( @@ -477,29 +511,16 @@ def process_audit_collect(request): 'msg': '收款请求已提交,系统正在核对状态,请稍后再试,切勿重复点击', }) except (OSError, IOError, ValueError) as e: - # 证书/私钥本地问题:未真正发到微信,可换下一商户 + # 证书/私钥本地问题:未发到微信;按计划不换号,直接报错 logger.error( '微信打款本地配置异常: bill=%s mch=%s err=%s', tixian_id, mch_id, e, exc_info=True, ) - err_code, err_msg = 'CERT_ERROR', f'商户证书/私钥异常: {e}' - last_user_msg = err_msg - last_was_balance = False + err_msg = f'商户证书/私钥异常: {e}' _mark_auto_record_failed(tixian_id, f'[{mch_id}] {err_msg}') - if mch_idx < len(mch_cfgs) - 1: - logger.warning( - '商户本地配置失败,切换下一商户 shenhe=%s from=%s idx=%s/%s', - shenhe_danhao, mch_id, mch_idx + 1, len(mch_cfgs), - ) - continue - if quota_reserved: - release_collect_quota_for_record( - TixianAutoRecord.objects.get(tixian_id=tixian_id), - ) - quota_reserved = False - return Response({'code': 99, 'msg': '微信商户配置异常,请联系管理员'}) + return _fail_front(f'【微信官方】商户{mch_id}配置异常,请联系管理员') - # 成功进待确认 → 立刻回前端(这是唯一「中途成功回包」的路径) + # 成功进待确认 → 立刻回前端 if resp.status_code == 200 and wx_result.get('state') == 'WAIT_USER_CONFIRM': package_info = wx_result.get('package_info') auto_record = TixianAutoRecord.objects.get(tixian_id=tixian_id) @@ -558,19 +579,12 @@ def process_audit_collect(request): tried_details.append( f'商户{mch_id}: HTTP{resp.status_code}/{err_code or "-"} {err_msg}' ) + user_msg = err_msg or '微信收款发起失败,请稍后重试' + last_user_msg = user_msg + has_next = (not is_user_bound) and (mch_idx < len(mch_cfgs) - 1) - has_next = mch_idx < len(mch_cfgs) - 1 - ip_denied = _is_wx_ip_denied(err_code, err_msg) - clear_fail = _is_wx_clear_switchable_fail(err_code, err_msg, resp.status_code) - # 限额/额度/受限/IP/4xx:一律不先查单(查单常一起挂),直接换号 - skip_query = ( - ip_denied - or clear_fail - or any(k in err_msg for k in ('额度', '限额', '上限', '受限', '管控')) - or resp.status_code in (400, 401, 403) - ) - - if not skip_query: + # 结果可能已受理:查单,禁止换号 + if _is_wx_unsafe_to_switch(err_code, err_msg, resp.status_code): wx_q = query_wechat_transfer_bill(tixian_id, wx_cfg=wx_cfg) if wx_q is not None and not wx_q.get('_not_found'): ar = TixianAutoRecord.objects.get(tixian_id=tixian_id) @@ -592,8 +606,46 @@ def process_audit_collect(request): 'tixianjilu_id': tixianjilu_id_val, }, }) - # PENDING 有下一户:放弃本户继续;无下一户才告诉前端处理中 - if sync_action == RECONCILE_PENDING and not has_next: + return Response({ + 'code': 12, + 'msg': '收款请求结果不明确,系统正在核对,请稍后再试,切勿重复点击', + }) + + # 唯一可静默换号:商户整户日/月额度 + 查单确认未建单 + mch_total_quota = _is_wx_mch_total_quota_exhausted(err_code, err_msg) + if mch_total_quota and has_next: + wx_q = query_wechat_transfer_bill(tixian_id, wx_cfg=wx_cfg) + if wx_q is None: + # 查单失败:不确定是否已建单,禁止换号 + _log_wx_collect( + '整户额度但查单失败禁止换号', + mch_id=mch_id, bill=tixian_id, + ) + return Response({ + 'code': 12, + 'msg': '商户额度异常且状态核对中,请稍后再试,切勿重复点击', + }) + if not wx_q.get('_not_found'): + ar = TixianAutoRecord.objects.get(tixian_id=tixian_id) + sync_action, sync_payload = _sync_record_from_wx_query(ar, wx_q) + if sync_action == RECONCILE_WAIT_CONFIRM: + if isinstance(sync_payload, dict): + sync_payload.setdefault('mch_id', mch_id) + sync_payload.setdefault('tixianjilu_id', tixianjilu_id_val) + _bind_collect_mch(audit, tixian_id, mch_id) + return _build_collect_success_response(sync_payload) + if sync_action == RECONCILE_COMPLETED: + _bind_collect_mch(audit, tixian_id, mch_id) + ensure_shenhe_collect_completed(shenhe_danhao) + return Response({ + 'code': 0, + 'msg': '提现已成功到账', + 'data': { + 'already_completed': True, + 'tixianjilu_id': tixianjilu_id_val, + }, + }) + if sync_action == RECONCILE_PENDING: return Response({ 'code': 12, 'msg': ( @@ -601,16 +653,15 @@ def process_audit_collect(request): if isinstance(sync_payload, dict) else sync_payload ) or '有收款处理中,请稍后再试', }) - - user_msg = err_msg or '微信收款发起失败,请稍后重试' - last_user_msg = user_msg - last_was_balance = _is_wx_balance_insufficient(err_code, err_msg) - _mark_auto_record_failed(tixian_id, f'[{mch_id}] {user_msg}') - - # 资格校验已过:商户用不了就静默换下一个,中间失败文案绝不回前端 - if has_next: + # ALLOW_NEW(明确失败)才允许继续换号 + if sync_action != RECONCILE_ALLOW_NEW: + return Response({ + 'code': 12, + 'msg': '有收款处理中,请稍后再试', + }) + _mark_auto_record_failed(tixian_id, f'[{mch_id}] {user_msg}') _log_wx_collect( - '本商户官方拒绝→静默换下一户', + '商户整户额度耗尽→静默换下一户', from_mch=mch_id, next_idx=f'{mch_idx + 2}/{len(mch_cfgs)}', wx_code=err_code, @@ -618,34 +669,32 @@ def process_audit_collect(request): ) continue - if quota_reserved: - release_collect_quota_for_record( - TixianAutoRecord.objects.get(tixian_id=tixian_id), - ) - quota_reserved = False - tried = '、'.join(tried_mchs) or mch_id + # 其它失败(含绑定用户整户额度、个人限额、余额、IP):不换号,回前端 + _mark_auto_record_failed(tixian_id, f'[{mch_id}] {user_msg}') detail = ';'.join(tried_details) if tried_details else user_msg - _log_wx_collect('全部商户失败回前端', tried=tried, detail=detail) - if len(mch_cfgs) < 2: - return Response({ - 'code': 99, - 'msg': ( - f'【微信官方】仅1个可用打款商户({tried}),无法轮换。{detail}' - f'。请后台再完善第二个商户私钥。' - )[:800], - 'data': {'source': 'wechat_official', 'tried_mch_ids': tried_mchs}, - }) - if last_was_balance: - return Response({ - 'code': 99, - 'msg': f'【微信官方】已尝试商户 {tried},资金不足:{detail}'[:800], - 'data': {'source': 'wechat_official', 'tried_mch_ids': tried_mchs}, - }) - return Response({ - 'code': 99, - 'msg': f'【微信官方】已尝试商户 {tried} 均失败:{detail}'[:800], - 'data': {'source': 'wechat_official', 'tried_mch_ids': tried_mchs}, - }) + tried = '、'.join(tried_mchs) or mch_id + if is_user_bound: + _log_wx_collect('绑定商户失败回前端', tried=tried, detail=detail) + return _fail_front( + f'【微信官方】绑定商户 {tried} 发起失败:{detail}' + ) + if mch_total_quota: + _log_wx_collect('整户额度无下一户回前端', tried=tried, detail=detail) + return _fail_front( + f'【微信官方】商户整户转账额度已满({tried}):{detail}' + ) + if _is_wx_balance_insufficient(err_code, err_msg): + return _fail_front( + f'【微信官方】商户资金不足({tried}):{detail}' + ) + if _is_wx_ip_denied(err_code, err_msg): + return _fail_front( + f'【微信官方】商户IP未放行({tried}):{detail}' + ) + _log_wx_collect('非整户额度失败不换号回前端', tried=tried, detail=detail) + return _fail_front( + f'【微信官方】商户 {tried} 发起失败:{detail}' + ) if quota_reserved and tixian_id: release_collect_quota_for_record( @@ -654,14 +703,18 @@ def process_audit_collect(request): quota_reserved = False tried = '、'.join(tried_mchs) if tried_mchs else '' detail = ';'.join(tried_details) if tried_details else last_user_msg - _log_wx_collect('轮换结束失败回前端', tried=tried, detail=detail) + _log_wx_collect('选户结束失败回前端', tried=tried, detail=detail) return Response({ 'code': 99, 'msg': ( f'【微信官方】已尝试商户 {tried} 均失败:{detail}' if tried else f'【微信官方】{last_user_msg}' )[:800], - 'data': {'source': 'wechat_official', 'tried_mch_ids': tried_mchs}, + 'data': { + 'source': 'wechat_official', + 'tried_mch_ids': tried_mchs, + 'user_bound': is_user_bound, + }, }) finally: diff --git a/yonghu/views.py b/yonghu/views.py index 146e1a0..3cb320e 100644 --- a/yonghu/views.py +++ b/yonghu/views.py @@ -13256,7 +13256,7 @@ class TixianQueRenAutoView(APIView): mark_transfer_success(auto_record, with_accounting=True) return Response({'code': 0, 'msg': '提现成功', 'data': None}) - # 用户取消或失败(确认页被微信限额/受限常见) + # 用户取消或失败:本地标失败;若微信侧仍待确认,再收款仍只能拿到原单(防双笔) raw_fail = (request.data.get('fail_reason') or request.data.get('errMsg') or '').strip() fail_reason = (raw_fail or '用户取消收款')[:500] abandoned_mch = (auto_record.mch_id or '').strip() @@ -13265,12 +13265,12 @@ class TixianQueRenAutoView(APIView): auto_record.save(update_fields=['zhuangtai', 'fail_reason']) release_collect_quota_for_record(auto_record) logger.error( - '【微信官方收款】确认失败已放弃商户 | bill=%s | mch_id=%s | reason=%s', + '【微信官方收款】确认失败 | bill=%s | mch_id=%s | reason=%s', tixian_id, abandoned_mch, fail_reason, ) if is_audit_flow: - # 新审核流程:申请时已扣款,取消不退余额,审核单恢复 6 待收款,可再次点收款 + # 新审核流程:申请时已扣款,取消不退余额,审核单恢复 6 待收款 from .models import TixianShenheJilu from .tixian_shenhe_services import sync_audit_and_jilu_status try: @@ -13278,11 +13278,9 @@ class TixianQueRenAutoView(APIView): shenhe_danhao=auto_record.shenhe_danhao, ) audit.tixian_auto_id = None - if hasattr(audit, 'dakuan_mch_id'): - audit.dakuan_mch_id = '' sync_audit_and_jilu_status( audit, 6, - fail_reason='确认收款失败,可换其他商户重试', + fail_reason='用户取消收款,可重新发起', ) except TixianShenheJilu.DoesNotExist: logger.warning(f'确认取消但审核记录不存在: {auto_record.shenhe_danhao}') @@ -13308,16 +13306,7 @@ class TixianQueRenAutoView(APIView): zuzhang.save() decrease_tixian_daily_stat(auto_record.leixing, auto_record.shijidaozhang) - # 告知前端可立刻再调 tixiansq:会跳过刚失败的 mch_id 换下一户 - return Response({ - 'code': 0, - 'msg': '已记录失败,可换商户重试', - 'data': { - 'can_retry_switch': bool(is_audit_flow), - 'abandoned_mch_id': abandoned_mch, - 'fail_reason': fail_reason, - }, - }) + return Response({'code': 0, 'msg': '提现已取消', 'data': None}) except Exception as e: logger.error(f'提现确认异常: {str(e)}', exc_info=True)