From a3cf7803d8832431796e336a68b5410e18410fc7 Mon Sep 17 00:00:00 2001 From: XingQue Date: Fri, 10 Jul 2026 15:11:53 +0800 Subject: [PATCH] =?UTF-8?q?chore:=20=E6=8B=89=E5=8F=96=E8=BF=9C=E7=A8=8B?= =?UTF-8?q?=E5=89=8D=E4=BF=9D=E7=95=99=E6=9C=AC=E5=9C=B0=E6=94=B9=E5=8A=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 含 kefu_base 修改及 club_payment_channel 相关文件。 Co-authored-by: Cursor --- .../migrations/0011_club_payment_channel.py | 68 ++ jituan/services/club_payment_channel.py | 244 ++++++ users/views/kefu_base.py | 704 +++++++++--------- 3 files changed, 664 insertions(+), 352 deletions(-) create mode 100644 jituan/migrations/0011_club_payment_channel.py create mode 100644 jituan/services/club_payment_channel.py diff --git a/jituan/migrations/0011_club_payment_channel.py b/jituan/migrations/0011_club_payment_channel.py new file mode 100644 index 0000000..ab7f6b5 --- /dev/null +++ b/jituan/migrations/0011_club_payment_channel.py @@ -0,0 +1,68 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('jituan', '0010_club_huiyuan_bundle_include'), + ] + + operations = [ + migrations.CreateModel( + name='ClubPaymentChannel', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('club_id', models.CharField(db_index=True, max_length=16, verbose_name='俱乐部ID')), + ('channel', models.CharField( + choices=[ + ('wechat_jsapi', '微信直连-小程序JSAPI'), + ('wechat_v3', '微信直连-V3转账/退款'), + ('alipay_wap', '支付宝-手机网站'), + ('fubei_wx_mini', '付呗-微信小程序'), + ], + db_index=True, + max_length=32, + )), + ('name', models.CharField(help_text='如:主付呗、备用微信', max_length=64, verbose_name='配置名称')), + ('enabled', models.IntegerField(default=1, verbose_name='1启用0停用')), + ('is_default', models.BooleanField(default=True, verbose_name='同通道默认配置')), + ('sort_order', models.IntegerField(default=0, verbose_name='前端展示顺序')), + ('sandbox', models.BooleanField(default=False, verbose_name='沙箱/联调')), + ('app_id', models.CharField(blank=True, default='', max_length=64, verbose_name='AppID/付呗ID')), + ('mch_id', models.CharField(blank=True, default='', max_length=64, verbose_name='商户号')), + ('api_key_enc', models.TextField(blank=True, default='', verbose_name='API密钥/付呗Secret(加密)')), + ('private_key_enc', models.TextField(blank=True, default='', verbose_name='应用私钥(加密)')), + ('public_key_enc', models.TextField(blank=True, default='', verbose_name='平台公钥(加密或明文)')), + ('cert_serial_no', models.CharField(blank=True, default='', max_length=64)), + ('private_key_path', models.CharField(blank=True, default='', max_length=255)), + ('platform_cert_dir', models.CharField(blank=True, default='', max_length=255)), + ('notify_url', models.CharField(blank=True, default='', max_length=512, verbose_name='异步回调URL')), + ('return_url', models.CharField(blank=True, default='', max_length=512, verbose_name='同步返回URL')), + ('gateway_url', models.CharField(blank=True, default='', max_length=255, verbose_name='API网关')), + ('mini_app_id', models.CharField( + blank=True, + default='', + help_text='付呗/微信JSAPI;空则回落 club.wx_appid', + max_length=32, + verbose_name='支付用小程序AppID', + )), + ('extra_json', models.JSONField(blank=True, default=dict, verbose_name='扩展配置')), + ('remark', models.CharField(blank=True, default='', max_length=255)), + ('CreateTime', models.DateTimeField(auto_now_add=True)), + ('UpdateTime', models.DateTimeField(auto_now=True)), + ], + options={ + 'verbose_name': '俱乐部支付通道', + 'db_table': 'club_payment_channel', + 'unique_together': {('club_id', 'channel', 'name')}, + }, + ), + migrations.AddIndex( + model_name='clubpaymentchannel', + index=models.Index(fields=['club_id', 'channel', 'enabled'], name='idx_cpc_club_ch_en'), + ), + migrations.AddIndex( + model_name='clubpaymentchannel', + index=models.Index(fields=['club_id', 'is_default'], name='idx_cpc_club_default'), + ), + ] diff --git a/jituan/services/club_payment_channel.py b/jituan/services/club_payment_channel.py new file mode 100644 index 0000000..5dd204d --- /dev/null +++ b/jituan/services/club_payment_channel.py @@ -0,0 +1,244 @@ +"""俱乐部支付通道:读配置、加解密、付呗/微信/支付宝字段归一化。""" +import logging +from dataclasses import dataclass, field +from typing import Any, Dict, List, Optional + +from django.conf import settings +from django.core.cache import cache + +from gvsdsdk.payment.models import EncryptField, _decrypt_field + +from jituan.constants import ( + CLUB_ID_DEFAULT, + FUBEI_GATEWAY_PROD, + FUBEI_GATEWAY_SANDBOX, + PAY_CHANNEL_ALIPAY_WAP, + PAY_CHANNEL_FUBEI_WX_MINI, + PAY_CHANNEL_WECHAT_JSAPI, + PAY_METHOD_ALIPAY, + PAY_METHOD_FUBEI, + PAY_METHOD_LABELS, + PAY_METHOD_TO_CHANNEL, + PAY_METHOD_WECHAT, +) +from jituan.models import Club, ClubPaymentChannel + +logger = logging.getLogger(__name__) + +CACHE_TTL = 60 + + +def encrypt_secret_field(plain: str) -> str: + if not plain: + return '' + return EncryptField(plain) + + +def decrypt_secret_field(encrypted: str) -> str: + if not encrypted: + return '' + return _decrypt_field(encrypted) + + +@dataclass +class PaymentChannelConfig: + """归一化后的通道配置,供支付网关读取。""" + club_id: str + channel: str + config_id: int + name: str + enabled: bool + sandbox: bool + app_id: str = '' + mch_id: str = '' + api_key: str = '' + private_key: str = '' + public_key: str = '' + cert_serial_no: str = '' + private_key_path: str = '' + platform_cert_dir: str = '' + notify_url: str = '' + return_url: str = '' + gateway_url: str = '' + mini_app_id: str = '' + extra: Dict[str, Any] = field(default_factory=dict) + + @property + def store_id(self): + return self.extra.get('store_id') or self.extra.get('fubei_store_id') + + @property + def fubei_vendor_sn(self): + return self.extra.get('vendor_sn') or self.extra.get('fubei_vendor_sn') or self.app_id + + +def _cache_key(club_id: str, channel: str) -> str: + return f'club_pay_ch:{club_id}:{channel}' + + +def _default_notify_path(channel: str) -> str: + if channel == PAY_CHANNEL_FUBEI_WX_MINI: + return '/hqhd/dingdan/fubei-notify/' + if channel == PAY_CHANNEL_ALIPAY_WAP: + return '/hqhd/dingdan/alipay-notify/' + return '/hqhd/dingdan/pay-notify/' + + +def _build_base_url(club: Club) -> str: + domain = (club.h5_domain or '').strip().rstrip('/') + if domain: + return domain + return 'https://www.abas.asia' + + +def _resolve_notify_url(club: Club, row: ClubPaymentChannel) -> str: + if row.notify_url: + return row.notify_url.strip() + return f'{_build_base_url(club)}{_default_notify_path(row.channel)}' + + +def _row_to_config(club: Club, row: ClubPaymentChannel) -> PaymentChannelConfig: + extra = dict(row.extra_json or {}) + gateway = (row.gateway_url or '').strip() + if not gateway and row.channel == PAY_CHANNEL_FUBEI_WX_MINI: + gateway = FUBEI_GATEWAY_SANDBOX if row.sandbox else FUBEI_GATEWAY_PROD + + mini_app_id = (row.mini_app_id or club.pay_app_id or club.wx_appid or '').strip() + + return PaymentChannelConfig( + club_id=row.club_id, + channel=row.channel, + config_id=row.id, + name=row.name, + enabled=row.enabled == 1, + sandbox=bool(row.sandbox), + app_id=(row.app_id or '').strip(), + mch_id=(row.mch_id or '').strip(), + api_key=decrypt_secret_field(row.api_key_enc), + private_key=decrypt_secret_field(row.private_key_enc), + public_key=decrypt_secret_field(row.public_key_enc) or (row.public_key_enc or '').strip(), + cert_serial_no=row.cert_serial_no or '', + private_key_path=row.private_key_path or '', + platform_cert_dir=row.platform_cert_dir or '', + notify_url=_resolve_notify_url(club, row), + return_url=(row.return_url or '').strip(), + gateway_url=gateway, + mini_app_id=mini_app_id, + extra=extra, + ) + + +def get_club_payment_channel( + club_id: Optional[str], + channel: str, + *, + config_id: Optional[int] = None, + require_enabled: bool = True, +) -> Optional[PaymentChannelConfig]: + """读取俱乐部某通道配置;优先 is_default,可指定 config_id。""" + cid = (club_id or '').strip() or CLUB_ID_DEFAULT + cache_k = _cache_key(cid, channel) + if config_id is None and require_enabled: + cached = cache.get(cache_k) + if cached is not None: + return cached if cached != '__none__' else None + + club = Club.query.filter(club_id=cid).first() + if not club: + logger.warning('club %s 不存在,无法读取支付通道 %s', cid, channel) + return None + + qs = ClubPaymentChannel.query.filter(club_id=cid, channel=channel) + if require_enabled: + qs = qs.filter(enabled=1) + if config_id is not None: + row = qs.filter(id=config_id).first() + else: + row = qs.filter(is_default=True).order_by('sort_order', 'id').first() + if not row: + row = qs.order_by('sort_order', 'id').first() + + if not row: + if config_id is None and require_enabled: + cache.set(cache_k, '__none__', CACHE_TTL) + return None + + cfg = _row_to_config(club, row) + if config_id is None and require_enabled: + cache.set(cache_k, cfg, CACHE_TTL) + return cfg + + +def invalidate_club_payment_cache(club_id: str, channel: Optional[str] = None): + cid = (club_id or '').strip() or CLUB_ID_DEFAULT + if channel: + cache.delete(_cache_key(cid, channel)) + return + for ch in ( + PAY_CHANNEL_WECHAT_JSAPI, + PAY_CHANNEL_ALIPAY_WAP, + PAY_CHANNEL_FUBEI_WX_MINI, + ): + cache.delete(_cache_key(cid, ch)) + + +def get_fubei_config(club_id: Optional[str] = None) -> Optional[PaymentChannelConfig]: + """付呗微信小程序通道配置。""" + return get_club_payment_channel(club_id, PAY_CHANNEL_FUBEI_WX_MINI) + + +def list_payment_methods_for_miniapp(club_id: Optional[str] = None) -> List[Dict[str, Any]]: + """小程序下单页:返回用户可见的支付方式(enabled 且配置完整)。""" + cid = (club_id or '').strip() or CLUB_ID_DEFAULT + methods = [] + + wechat_direct = get_club_payment_channel(cid, PAY_CHANNEL_WECHAT_JSAPI) + fubei = get_fubei_config(cid) + alipay = get_club_payment_channel(cid, PAY_CHANNEL_ALIPAY_WAP) + + if fubei and fubei.app_id and fubei.api_key: + methods.append({ + 'code': PAY_METHOD_FUBEI, + 'label': PAY_METHOD_LABELS[PAY_METHOD_FUBEI], + 'channel': PAY_CHANNEL_FUBEI_WX_MINI, + 'sort_order': _sort_for(cid, PAY_CHANNEL_FUBEI_WX_MINI), + }) + elif wechat_direct and wechat_direct.mch_id and wechat_direct.api_key: + methods.append({ + 'code': PAY_METHOD_WECHAT, + 'label': PAY_METHOD_LABELS[PAY_METHOD_WECHAT], + 'channel': PAY_CHANNEL_WECHAT_JSAPI, + 'sort_order': _sort_for(cid, PAY_CHANNEL_WECHAT_JSAPI), + }) + + if alipay and alipay.app_id and alipay.private_key and alipay.public_key: + methods.append({ + 'code': PAY_METHOD_ALIPAY, + 'label': PAY_METHOD_LABELS[PAY_METHOD_ALIPAY], + 'channel': PAY_CHANNEL_ALIPAY_WAP, + 'sort_order': _sort_for(cid, PAY_CHANNEL_ALIPAY_WAP), + }) + + methods.sort(key=lambda x: x.get('sort_order', 0)) + return methods + + +def _sort_for(club_id: str, channel: str) -> int: + row = ClubPaymentChannel.query.filter( + club_id=club_id, channel=channel, enabled=1, + ).order_by('-is_default', 'sort_order', 'id').first() + return row.sort_order if row else 0 + + +def channel_for_pay_method(pay_method: str) -> Optional[str]: + return PAY_METHOD_TO_CHANNEL.get((pay_method or '').strip().lower()) + + +# 付呗通道后台必填字段说明(文档/校验用) +FUBEI_REQUIRED_FIELDS = { + 'app_id': '付呗开放平台 vendor_sn / 付呗ID', + 'api_key_enc': '付呗 Secret(入库前加密)', + 'mini_app_id': '微信小程序 AppID(可与 club.wx_appid 相同;须在付呗后台关联)', + 'notify_url': '异步回调,默认 /hqhd/dingdan/fubei-notify/', + 'extra_json.store_id': '付呗门店 store_id(服务商开户后提供,统一下单必填)', +} diff --git a/users/views/kefu_base.py b/users/views/kefu_base.py index 445d77d..5230cbe 100644 --- a/users/views/kefu_base.py +++ b/users/views/kefu_base.py @@ -1,352 +1,352 @@ -"""users.views.kefu_base - auto-generated by split script.""" -"""users.views.kefu - auto-generated by split script.""" -import os -import sys -import json -import re -import time -import uuid -import random -import string -import requests -import hashlib -import traceback -import decimal -import jwt -import ipaddress -import xmltodict -from datetime import datetime, timedelta -from decimal import Decimal -import defusedxml.ElementTree as ET - -from django.conf import settings -from django.db import models, transaction, IntegrityError, DatabaseError, connection -from django.db.models import Q, F, Sum, Count, Case, When, IntegerField, Prefetch -from django.core.cache import cache -from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger -from django.core.exceptions import ObjectDoesNotExist -from django.shortcuts import get_object_or_404 -from django.utils import timezone -from django.http import HttpResponse -from django.views import View -from django.views.decorators.csrf import csrf_exempt -from django.contrib.auth.hashers import make_password - -from rest_framework.views import APIView -from rest_framework.response import Response -from rest_framework import permissions, status -from rest_framework.permissions import AllowAny, IsAuthenticated -from rest_framework.parsers import MultiPartParser, FormParser, JSONParser -from rest_framework.throttling import AnonRateThrottle -from rest_framework_simplejwt.tokens import RefreshToken -from rest_framework_simplejwt.authentication import JWTAuthentication - -from gvsdsdk.fluent import db, func, FQ - -from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss -from utils.money import yuan_to_fen -from utils.fadan_utils import check_fadan_qiangdan_eligible -from utils.invitationcode_utils import CreateInvitationCode, VerifyInvitationCode -from users.fadan_fenhong_utils import process_fadan_fenhong - -from orders.utils import ( - update_daily_payout, - settle_shangjia_order_guanshi_fenhong -) -from backend.utils import ( - update_dashou_daily_by_action, update_guanshi_daily_by_action, - update_shangjia_daily, update_zuzhang_daily_by_action, -) -from jituan.services.admin_context import is_kefu_backend_account -from rank.utils import get_tag_fee, create_shenhe_jilu, validate_shenheguan - -from ..models import ( - UserBoss, UserDashou, UserShangjia, UserGuanshi, - UserZuzhang, UserKefu, AdminProfile, OfficialAccountUser, - TixianAutoRecord, TixianShenheJilu, Tixianjilu, Xiugaijilu, - RankingRecord -) -from users.business_models import User -from ..tixian_shenhe_services import ( - load_audit_meta_map, refund_balance, sync_audit_and_jilu_status, - mark_transfer_success, release_collect_quota_for_record, - close_audit_wechat_failed, query_wechat_transfer_bill, - check_shijidaozhang_within_limit, - check_dashou_trial_blocks_commission_withdraw, - WX_STATE_FAIL, WX_STATE_SUCCESS, WX_STATE_WAIT, WX_STATE_CANCELING, -) -from ..tixian_shenhe_views import process_audit_collect - -from orders.models import ( - Order, PlatformOrderExt, MerchantOrderExt, CommissionRate, - PenaltyRecord, PenaltyEvidenceImage, RefundRecord, PlayerDeliveryImage, - Penalty, PenaltyAppealImage -) -from products.models import ( - ShangpinLeixing, Huiyuan, Huiyuangoumai, Gsfenhong, Czjilu -) -from config.models import Qunpeizhi -from backend.models import MerchantDailyStats -from rank.models import ( - KaohePayTemp, Chenghao, ShenheJilu, - KaoheCishuFeiyong, YonghuChenghao -) -from users.models import UserShenheguan -from products.utils import update_shangpin_daily_stat -from shop.utils import update_dianpu_daily_stat -from rank.utils import create_shenhe_jilu_from_temp - -import logging - -logger = logging.getLogger(__name__) - - -class KefuLoginView(APIView): - """ - 客服/管理员登录接口(支持手机号重复) - 请求:POST /yonghu/kefujinru - 请求体:{"phone": "13800138000", "password": "xxx", "erjimima": "yyy"} - 返回: - code=0 成功,data包含token,nicheng,yonghuid - code=1 失败(msg区分:用户不存在/密码错误/二级密码错误) - code=4 账号被封禁 - - 管理员(IsSuperuser 或 UserType='admin')无需 erjimima 和 KefuProfile。 - """ - throttle_classes = [AnonRateThrottle] # 限制匿名请求频率 - permission_classes = [AllowAny] # 任何人都可访问 - - def post(self, request): - # 1. 获取参数 - phone = request.data.get('phone', '').strip() - password = request.data.get('password', '') - erjimima = request.data.get('erjimima', '') - - if not phone or not password: - return Response({ - 'code': 1, - 'msg': '请填写手机号和密码', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 2. 获取客户端真实IP - client_ip = self.get_client_ip(request) - - # 3. 查询该手机号下所有账号(客服 + 管理员) - # 先查客服(有 KefuProfile),再查管理员(IsSuperuser 或 UserType='admin') - user_mains = list( - User.query.filter( - Phone=phone, - KefuProfile__isnull=False, - ).select_related('KefuProfile') - ) - - # 如果没有客服账号,尝试查找管理员账号 - if not user_mains: - admin_candidates = User.query.filter(Phone=phone) - for admin_user in admin_candidates: - if admin_user.IsSuperuser or admin_user.UserType == 'admin': - user_mains = [admin_user] - break - - if not user_mains: - logger.warning(f"登录失败,用户不存在: {phone}, IP: {client_ip}") - return Response({ - 'code': 1, - 'msg': '用户不存在', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 4. 遍历所有匹配的账号,验证密码和二级密码 - target_user = None - target_kefu = None - password_matched = False - - for user in user_mains: - # 验证主密码(bcrypt 哈希验证) - if not user.CheckPassword(password): - continue - password_matched = True - - # 管理员:跳过二级密码和客服扩展表检查 - is_admin = user.IsSuperuser or user.UserType == 'admin' - if is_admin: - target_user = user - target_kefu = None - break - - # 客服:验证二级密码 - kefu = user.KefuProfile - stored_erji = kefu.erjimima or '' - if stored_erji.startswith(('$2b$', '$2a$')) and len(stored_erji) == 60: - import bcrypt as _bcrypt - if not _bcrypt.checkpw(erjimima.encode('utf-8'), stored_erji.encode('utf-8')): - continue - elif stored_erji != erjimima: - continue - - # 如果账号被禁用,记录但继续查找 - if kefu.zhuangtai != 1: - if target_user is None: - target_user = user - target_kefu = kefu - continue - - # 找到完全匹配且状态正常的账号,立即使用 - target_user = user - target_kefu = kefu - break - else: - # 循环结束未找到正常账号 - if target_user and target_kefu: - # 只有被禁用的账号匹配,返回封禁提示 - logger.warning(f"登录失败,客服账号已禁用: {phone}, IP: {client_ip}") - return Response({ - 'code': 4, - 'msg': '账号已被封禁', - 'data': None - }, status=status.HTTP_403_FORBIDDEN) - elif password_matched: - # 密码正确但二级密码错误 - logger.warning(f"登录失败,二级密码错误: {phone}, IP: {client_ip}") - return Response({ - 'code': 1, - 'msg': '二级密码错误', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - else: - # 密码错误 - logger.warning(f"登录失败,密码错误: {phone}, IP: {client_ip}") - return Response({ - 'code': 1, - 'msg': '密码错误', - 'data': None - }, status=status.HTTP_400_BAD_REQUEST) - - # 5. 更新IP和最后登录时间 - target_user.IP = client_ip - target_user.UserLastLoginDate = timezone.now() - target_user.save(update_fields=['IP', 'UserLastLoginDate']) - - # 6. 生成JWT token - refresh = RefreshToken.for_user(target_user) - token = str(refresh.access_token) - - # 7. 返回成功数据(管理员无 KefuProfile,nicheng 用 UserName 或 Phone 兜底) - nicheng = target_kefu.nicheng if target_kefu else (target_user.UserName or target_user.Phone) - return Response({ - 'code': 0, - 'msg': '登录成功', - 'data': { - 'token': token, - 'nicheng': nicheng, - 'yonghuid': target_user.UserUID, - } - }) - - def get_client_ip(self, request): - """ - 获取客户端真实IP - """ - x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') - if x_forwarded_for: - ip = x_forwarded_for.split(',')[0].strip() - if ip: - return ip - return request.META.get('REMOTE_ADDR', '0.0.0.0') - - -class KefuStatsView(APIView): - """ - 客服统计数据接口 - 请求:POST /yonghu/kfjrhqzl - 请求体:{"phone": "13800138000"} - 认证:必须携带有效的 JWT token(放在 Authorization 头) - 返回: - code=0 成功,data 包含 jinrichuli, jinyuechuli, zongchuli - 验证失败统一返回 401,不透露具体原因 - """ - - permission_classes = [IsAuthenticated] # 必须登录 - - - def post(self, request): - # 1. 获取请求中的 phone - phone = request.data.get('phone', '').strip() - if not phone: - # 缺少参数,但按约定返回 401(可以改为 400,但为了统一,用401) - logger.warning(f"统计接口缺少 phone 参数,用户: {request.user.UserUID}") - return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 2. 获取当前登录用户(从 JWT 中解析) - current_user = request.user - - # 3. 验证前端传来的 phone 是否与当前用户的 phone 一致 - if current_user.Phone != phone: - # 不一致,可能 token 被冒用或参数错误 - logger.warning(f"统计接口 phone 不匹配: 请求phone={phone}, 用户phone={current_user.Phone}") - return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 4. 验证是否为后台客服账号 - from jituan.services.admin_context import is_kefu_backend_account, is_system_super_admin - if not is_kefu_backend_account(current_user): - logger.warning(f"统计接口用户类型非客服: {current_user.UserUID}") - return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 5. 尝试获取客服扩展表数据(管理员跳过) - is_admin = is_system_super_admin(current_user) - if is_admin: - return Response({ - 'code': 0, - 'data': { - 'jinrichuli': 0, - 'jinyuechuli': 0, - 'zongchuli': 0, - } - }, status=status.HTTP_200_OK) - - try: - kefu_profile = current_user.KefuProfile # OneToOneField 反向关系 - except UserKefu.DoesNotExist: - logger.warning(f"统计接口客服扩展表不存在: {current_user.UserUID}") - return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) - - # 6. 返回统计数据(字段名与前端约定一致) - return Response({ - 'code': 0, - 'data': { - 'jinrichuli': kefu_profile.jinrichuli, - 'jinyuechuli': kefu_profile.jinyuechuli, - 'zongchuli': kefu_profile.zongchuli, - } - }, status=status.HTTP_200_OK) - - -class KefuGetOrderTypesView(APIView): - """ - 客服获取订单类型列表(平台/商家/跨平台订单页共用) - 请求:POST /yonghu/kfhqptddlx - 认证:JWT + 与 menu-access 相同的 is_kefu_backend_account - 返回:code=0 + 类型列表 - """ - permission_classes = [IsAuthenticated] - parser_classes = [JSONParser] - - def post(self, request): - if not is_kefu_backend_account(request.user): - return Response({'code': 403, 'msg': '非后台账号'}, status=403) - - try: - types_qs = ShangpinLeixing.query.all().only('id', 'jieshao', 'tupian_url') - type_list = [ - { - 'id': t.id, - 'biaoti': t.jieshao or '', - 'tupian_url': t.tupian_url or '' - } - for t in types_qs - ] - return Response({'code': 0, 'data': type_list}) - except Exception as e: - logger.error(f"查询商品类型表失败: {str(e)}") - return Response({'code': 0, 'data': []}) +"""users.views.kefu_base - auto-generated by split script.""" +"""users.views.kefu - auto-generated by split script.""" +import os +import sys +import json +import re +import time +import uuid +import random +import string +import requests +import hashlib +import traceback +import decimal +import jwt +import ipaddress +import xmltodict +from datetime import datetime, timedelta +from decimal import Decimal +import defusedxml.ElementTree as ET + +from django.conf import settings +from django.db import models, transaction, IntegrityError, DatabaseError, connection +from django.db.models import Q, F, Sum, Count, Case, When, IntegerField, Prefetch +from django.core.cache import cache +from django.core.paginator import Paginator, EmptyPage, PageNotAnInteger +from django.core.exceptions import ObjectDoesNotExist +from django.shortcuts import get_object_or_404 +from django.utils import timezone +from django.http import HttpResponse +from django.views import View +from django.views.decorators.csrf import csrf_exempt +from django.contrib.auth.hashers import make_password + +from rest_framework.views import APIView +from rest_framework.response import Response +from rest_framework import permissions, status +from rest_framework.permissions import AllowAny, IsAuthenticated +from rest_framework.parsers import MultiPartParser, FormParser, JSONParser +from rest_framework.throttling import AnonRateThrottle +from rest_framework_simplejwt.tokens import RefreshToken +from rest_framework_simplejwt.authentication import JWTAuthentication + +from gvsdsdk.fluent import db, func, FQ + +from utils.oss_utils import validate_image, upload_to_oss, delete_from_oss +from utils.money import yuan_to_fen +from utils.fadan_utils import check_fadan_qiangdan_eligible +from utils.invitationcode_utils import CreateInvitationCode, VerifyInvitationCode +from users.fadan_fenhong_utils import process_fadan_fenhong + +from orders.utils import ( + update_daily_payout, + settle_shangjia_order_guanshi_fenhong +) +from backend.utils import ( + update_dashou_daily_by_action, update_guanshi_daily_by_action, + update_shangjia_daily, update_zuzhang_daily_by_action, +) +from jituan.services.admin_context import is_kefu_backend_account +from rank.utils import get_tag_fee, create_shenhe_jilu, validate_shenheguan + +from ..models import ( + UserBoss, UserDashou, UserShangjia, UserGuanshi, + UserZuzhang, UserKefu, AdminProfile, OfficialAccountUser, + TixianAutoRecord, TixianShenheJilu, Tixianjilu, Xiugaijilu, + RankingRecord +) +from users.business_models import User +from ..tixian_shenhe_services import ( + load_audit_meta_map, refund_balance, sync_audit_and_jilu_status, + mark_transfer_success, release_collect_quota_for_record, + close_audit_wechat_failed, query_wechat_transfer_bill, + check_shijidaozhang_within_limit, + check_dashou_trial_blocks_commission_withdraw, + WX_STATE_FAIL, WX_STATE_SUCCESS, WX_STATE_WAIT, WX_STATE_CANCELING, +) +from ..tixian_shenhe_views import process_audit_collect + +from orders.models import ( + Order, PlatformOrderExt, MerchantOrderExt, CommissionRate, + PenaltyRecord, PenaltyEvidenceImage, RefundRecord, PlayerDeliveryImage, + Penalty, PenaltyAppealImage +) +from products.models import ( + ShangpinLeixing, Huiyuan, Huiyuangoumai, Gsfenhong, Czjilu +) +from config.models import Qunpeizhi +from backend.models import MerchantDailyStats +from rank.models import ( + KaohePayTemp, Chenghao, ShenheJilu, + KaoheCishuFeiyong, YonghuChenghao +) +from users.models import UserShenheguan +from products.utils import update_shangpin_daily_stat +from shop.utils import update_dianpu_daily_stat +from rank.utils import create_shenhe_jilu_from_temp + +import logging + +logger = logging.getLogger(__name__) + + +class KefuLoginView(APIView): + """ + 客服/管理员登录接口(支持手机号重复) + 请求:POST /yonghu/kefujinru + 请求体:{"phone": "13800138000", "password": "xxx", "erjimima": "yyy"} + 返回: + code=0 成功,data包含token,nicheng,yonghuid + code=1 失败(msg区分:用户不存在/密码错误/二级密码错误) + code=4 账号被封禁 + + 管理员(IsSuperuser 或 UserType='admin')无需 erjimima 和 KefuProfile。 + """ + throttle_classes = [AnonRateThrottle] # 限制匿名请求频率 + permission_classes = [AllowAny] # 任何人都可访问 + + def post(self, request): + # 1. 获取参数 + phone = request.data.get('phone', '').strip() + password = request.data.get('password', '') + erjimima = request.data.get('erjimima', '') + + if not phone or not password: + return Response({ + 'code': 1, + 'msg': '请填写手机号和密码', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 2. 获取客户端真实IP + client_ip = self.get_client_ip(request) + + # 3. 查询该手机号下所有账号(客服 + 管理员) + # 先查客服(有 KefuProfile),再查管理员(IsSuperuser 或 UserType='admin') + user_mains = list( + User.query.filter( + Phone=phone, + KefuProfile__isnull=False, + ).select_related('KefuProfile') + ) + + # 如果没有客服账号,尝试查找管理员账号 + if not user_mains: + admin_candidates = User.query.filter(Phone=phone) + for admin_user in admin_candidates: + if admin_user.IsSuperuser or admin_user.UserType == 'admin': + user_mains = [admin_user] + break + + if not user_mains: + logger.warning(f"登录失败,用户不存在: {phone}, IP: {client_ip}") + return Response({ + 'code': 1, + 'msg': '用户不存在', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 4. 遍历所有匹配的账号,验证密码和二级密码 + target_user = None + target_kefu = None + password_matched = False + + for user in user_mains: + # 验证主密码(bcrypt 哈希验证) + if not user.CheckPassword(password): + continue + password_matched = True + + # 管理员:跳过二级密码和客服扩展表检查 + is_admin = user.IsSuperuser or user.UserType == 'admin' + if is_admin: + target_user = user + target_kefu = None + break + + # 客服:验证二级密码 + kefu = user.KefuProfile + stored_erji = kefu.erjimima or '' + if stored_erji.startswith(('$2b$', '$2a$')) and len(stored_erji) == 60: + import bcrypt as _bcrypt + if not _bcrypt.checkpw(erjimima.encode('utf-8'), stored_erji.encode('utf-8')): + continue + elif stored_erji != erjimima: + continue + + # 如果账号被禁用,记录但继续查找 + if kefu.zhuangtai != 1: + if target_user is None: + target_user = user + target_kefu = kefu + continue + + # 找到完全匹配且状态正常的账号,立即使用 + target_user = user + target_kefu = kefu + break + else: + # 循环结束未找到正常账号 + if target_user and target_kefu: + # 只有被禁用的账号匹配,返回封禁提示 + logger.warning(f"登录失败,客服账号已禁用: {phone}, IP: {client_ip}") + return Response({ + 'code': 4, + 'msg': '账号已被封禁', + 'data': None + }, status=status.HTTP_403_FORBIDDEN) + elif password_matched: + # 密码正确但二级密码错误 + logger.warning(f"登录失败,二级密码错误: {phone}, IP: {client_ip}") + return Response({ + 'code': 1, + 'msg': '二级密码错误', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + else: + # 密码错误 + logger.warning(f"登录失败,密码错误: {phone}, IP: {client_ip}") + return Response({ + 'code': 1, + 'msg': '密码错误', + 'data': None + }, status=status.HTTP_400_BAD_REQUEST) + + # 5. 更新IP和最后登录时间 + target_user.IP = client_ip + target_user.UserLastLoginDate = timezone.now() + target_user.save(update_fields=['IP', 'UserLastLoginDate']) + + # 6. 生成JWT token + refresh = RefreshToken.for_user(target_user) + token = str(refresh.access_token) + + # 7. 返回成功数据(管理员无 KefuProfile,nicheng 用 UserName 或 Phone 兜底) + nicheng = target_kefu.nicheng if target_kefu else (target_user.UserName or target_user.Phone) + return Response({ + 'code': 0, + 'msg': '登录成功', + 'data': { + 'token': token, + 'nicheng': nicheng, + 'yonghuid': target_user.UserUID, + } + }) + + def get_client_ip(self, request): + """ + 获取客户端真实IP + """ + x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR') + if x_forwarded_for: + ip = x_forwarded_for.split(',')[0].strip() + if ip: + return ip + return request.META.get('REMOTE_ADDR', '0.0.0.0') + + +class KefuStatsView(APIView): + """ + 客服统计数据接口 + 请求:POST /yonghu/kfjrhqzl + 请求体:{"phone": "13800138000"} + 认证:必须携带有效的 JWT token(放在 Authorization 头) + 返回: + code=0 成功,data 包含 jinrichuli, jinyuechuli, zongchuli + 验证失败统一返回 401,不透露具体原因 + """ + + permission_classes = [IsAuthenticated] # 必须登录 + + + def post(self, request): + # 1. 获取请求中的 phone + phone = request.data.get('phone', '').strip() + if not phone: + # 缺少参数,但按约定返回 401(可以改为 400,但为了统一,用401) + logger.warning(f"统计接口缺少 phone 参数,用户: {request.user.UserUID}") + return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 2. 获取当前登录用户(从 JWT 中解析) + current_user = request.user + + # 3. 验证前端传来的 phone 是否与当前用户的 phone 一致 + if current_user.Phone != phone: + # 不一致,可能 token 被冒用或参数错误 + logger.warning(f"统计接口 phone 不匹配: 请求phone={phone}, 用户phone={current_user.Phone}") + return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 4. 验证是否为后台客服账号 + from jituan.services.admin_context import is_kefu_backend_account, is_system_super_admin + if not is_kefu_backend_account(current_user): + logger.warning(f"统计接口用户类型非客服: {current_user.UserUID}") + return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 5. 尝试获取客服扩展表数据(管理员跳过) + is_admin = is_system_super_admin(current_user) + if is_admin: + return Response({ + 'code': 0, + 'data': { + 'jinrichuli': 0, + 'jinyuechuli': 0, + 'zongchuli': 0, + } + }, status=status.HTTP_200_OK) + + try: + kefu_profile = current_user.KefuProfile # OneToOneField 反向关系 + except UserKefu.DoesNotExist: + logger.warning(f"统计接口客服扩展表不存在: {current_user.UserUID}") + return Response({'detail': '认证失败'}, status=status.HTTP_401_UNAUTHORIZED) + + # 6. 返回统计数据(字段名与前端约定一致) + return Response({ + 'code': 0, + 'data': { + 'jinrichuli': kefu_profile.jinrichuli, + 'jinyuechuli': kefu_profile.jinyuechuli, + 'zongchuli': kefu_profile.zongchuli, + } + }, status=status.HTTP_200_OK) + + +class KefuGetOrderTypesView(APIView): + """ + 客服获取订单类型列表(平台/商家/跨平台订单页共用) + 请求:POST /yonghu/kfhqptddlx + 认证:JWT + 与 menu-access 相同的 is_kefu_backend_account + 返回:code=0 + 类型列表 + """ + permission_classes = [IsAuthenticated] + parser_classes = [JSONParser] + + def post(self, request): + if not is_kefu_backend_account(request.user): + return Response({'code': 403, 'msg': '非后台账号'}, status=403) + + try: + types_qs = ShangpinLeixing.query.all().only('id', 'jieshao', 'tupian_url') + type_list = [ + { + 'id': t.id, + 'biaoti': t.jieshao or '', + 'tupian_url': t.tupian_url or '' + } + for t in types_qs + ] + return Response({'code': 0, 'data': type_list}) + except Exception as e: + logger.error(f"查询商品类型表失败: {str(e)}") + return Response({'code': 0, 'data': []})