chore: 拉取远程前保留本地改动
含 kefu_base 修改及 club_payment_channel 相关文件。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
68
jituan/migrations/0011_club_payment_channel.py
Normal file
68
jituan/migrations/0011_club_payment_channel.py
Normal file
@@ -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'),
|
||||
),
|
||||
]
|
||||
244
jituan/services/club_payment_channel.py
Normal file
244
jituan/services/club_payment_channel.py
Normal file
@@ -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(服务商开户后提供,统一下单必填)',
|
||||
}
|
||||
@@ -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': []})
|
||||
|
||||
Reference in New Issue
Block a user