fix: 星之界/星阙支付按俱乐部分别取 openid 与 AppID,启动静默刷新绑定
- resolve_club_payment_openid: xzj 回落 UserName 并 lazy 写绑定;xq 回落 OpenID;禁止跨俱乐部混用 - 统一 get_club_miniapp_appid;非 xq 禁止回落全局 AppID - 新增 /jituan/auth/refresh-wx-bind;小程序有 token 时静默刷新 openid Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -13,13 +13,27 @@ logger = logging.getLogger(__name__)
|
|||||||
_REFERER_APPID_RE = re.compile(r'servicewechat\.com/(wx[0-9a-fA-F]+)/', re.I)
|
_REFERER_APPID_RE = re.compile(r'servicewechat\.com/(wx[0-9a-fA-F]+)/', re.I)
|
||||||
|
|
||||||
|
|
||||||
|
def get_club_miniapp_appid(club):
|
||||||
|
"""小程序 AppID:登录 jscode2session 与 JSAPI 支付须一致。"""
|
||||||
|
if not club:
|
||||||
|
return ''
|
||||||
|
return (club.wx_appid or club.pay_app_id or '').strip()
|
||||||
|
|
||||||
|
|
||||||
def resolve_club_by_appid(app_id):
|
def resolve_club_by_appid(app_id):
|
||||||
if not app_id:
|
if not app_id:
|
||||||
return None
|
return None
|
||||||
|
app_id = app_id.strip()
|
||||||
try:
|
try:
|
||||||
return Club.query.filter(wx_appid=app_id, status=1).first()
|
club = Club.query.filter(wx_appid=app_id, status=1).first()
|
||||||
|
if club:
|
||||||
|
return club
|
||||||
|
return Club.query.filter(pay_app_id=app_id, status=1).first()
|
||||||
except Exception:
|
except Exception:
|
||||||
return Club.objects.filter(wx_appid=app_id, status=1).first()
|
club = Club.objects.filter(wx_appid=app_id, status=1).first()
|
||||||
|
if club:
|
||||||
|
return club
|
||||||
|
return Club.objects.filter(pay_app_id=app_id, status=1).first()
|
||||||
|
|
||||||
|
|
||||||
def get_club_wx_credentials(club_id):
|
def get_club_wx_credentials(club_id):
|
||||||
@@ -30,8 +44,10 @@ def get_club_wx_credentials(club_id):
|
|||||||
except Club.DoesNotExist:
|
except Club.DoesNotExist:
|
||||||
club = None
|
club = None
|
||||||
|
|
||||||
if club and club.wx_appid and club.wx_secret:
|
appid = get_club_miniapp_appid(club)
|
||||||
return club.wx_appid, club.wx_secret, club
|
secret = (club.wx_secret or '').strip() if club else ''
|
||||||
|
if appid and secret:
|
||||||
|
return appid, secret, club
|
||||||
|
|
||||||
if club_id == CLUB_ID_DEFAULT:
|
if club_id == CLUB_ID_DEFAULT:
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -221,49 +221,69 @@ def _looks_like_wx_openid(value):
|
|||||||
return len(v) >= 20 and v.startswith('o')
|
return len(v) >= 20 and v.startswith('o')
|
||||||
|
|
||||||
|
|
||||||
def get_wx_openid_for_user(user, club_id=None):
|
def resolve_club_payment_openid(user, club_id=None):
|
||||||
"""
|
"""
|
||||||
按俱乐部取微信支付 JSAPI 用 openid(须与 club.wx_appid 登录时换取的一致)。
|
支付专用:按俱乐部取 openid,与 club 表 miniapp AppID 对应。
|
||||||
仅读 user_wx_openid;禁止回落 User.OpenID/UserName(多俱乐部/旧 AppID 会错配)。
|
1) user_wx_openid(yonghuid, club_id)
|
||||||
|
2) 无本 club 绑定但有其他 club → 拒绝(须在本小程序重新登录)
|
||||||
|
3) 无任何绑定时 lazy backfill:xq→User.OpenID;xzj 等→UserName(openid)
|
||||||
"""
|
"""
|
||||||
if not user:
|
if not user:
|
||||||
return ''
|
return '', 'none'
|
||||||
cid = (club_id or '').strip() or get_user_club_id(user)
|
cid = (club_id or '').strip() or get_user_club_id(user)
|
||||||
uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None)
|
uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None)
|
||||||
if not uid or not cid:
|
if not uid or not cid:
|
||||||
return ''
|
return '', 'none'
|
||||||
|
|
||||||
binding = (
|
binding = (
|
||||||
UserWxOpenid.query.filter(yonghuid=uid, club_id=cid)
|
UserWxOpenid.query.filter(yonghuid=uid, club_id=cid)
|
||||||
.order_by('-id')
|
.order_by('-id')
|
||||||
.first()
|
.first()
|
||||||
)
|
)
|
||||||
if binding and binding.openid:
|
if binding and binding.openid:
|
||||||
return binding.openid
|
return binding.openid.strip(), f'user_wx_openid#{binding.id}'
|
||||||
return ''
|
|
||||||
|
other_clubs = list(
|
||||||
|
UserWxOpenid.query.filter(yonghuid=uid)
|
||||||
|
.exclude(club_id=cid)
|
||||||
|
.values_list('club_id', flat=True)[:5]
|
||||||
|
)
|
||||||
|
if other_clubs:
|
||||||
|
logger.warning(
|
||||||
|
'[PAY_OPENID] uid=%s 无 club=%s 绑定,已有其他俱乐部 %s;须在本小程序重新登录',
|
||||||
|
uid, cid, other_clubs,
|
||||||
|
)
|
||||||
|
return '', 'other_club_only'
|
||||||
|
|
||||||
|
username = (getattr(user, 'UserName', None) or '').strip()
|
||||||
|
legacy_oid = (getattr(user, 'OpenID', None) or getattr(user, 'openid', None) or '').strip()
|
||||||
|
candidate = ''
|
||||||
|
source = ''
|
||||||
|
if cid == CLUB_ID_DEFAULT:
|
||||||
|
if legacy_oid and _looks_like_wx_openid(legacy_oid):
|
||||||
|
candidate, source = legacy_oid, 'legacy_OpenID'
|
||||||
|
elif _looks_like_wx_openid(username):
|
||||||
|
candidate, source = username, 'legacy_UserName'
|
||||||
|
|
||||||
|
if candidate:
|
||||||
|
logger.info('[PAY_OPENID] uid=%s club=%s lazy backfill from %s', uid, cid, source)
|
||||||
|
ensure_wx_openid_binding(cid, candidate, user)
|
||||||
|
return candidate, source
|
||||||
|
|
||||||
|
logger.warning(
|
||||||
|
'[PAY_OPENID] uid=%s club=%s 无绑定 legacy_UserName=%s… legacy_OpenID=%s',
|
||||||
|
uid, cid, username[:10] if username else '', bool(legacy_oid),
|
||||||
|
)
|
||||||
|
return '', 'missing'
|
||||||
|
|
||||||
|
|
||||||
|
def get_wx_openid_for_user(user, club_id=None):
|
||||||
|
openid, _ = resolve_club_payment_openid(user, club_id)
|
||||||
|
return openid
|
||||||
|
|
||||||
|
|
||||||
def get_wx_openid_for_user_with_source(user, club_id=None):
|
def get_wx_openid_for_user_with_source(user, club_id=None):
|
||||||
"""返回 (openid, source) 供支付诊断。"""
|
return resolve_club_payment_openid(user, club_id)
|
||||||
if not user:
|
|
||||||
return '', 'none'
|
|
||||||
cid = (club_id or '').strip() or get_user_club_id(user)
|
|
||||||
uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None)
|
|
||||||
if not uid or not cid:
|
|
||||||
return '', 'none'
|
|
||||||
binding = (
|
|
||||||
UserWxOpenid.query.filter(yonghuid=uid, club_id=cid)
|
|
||||||
.order_by('-id')
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
if binding and binding.openid:
|
|
||||||
return binding.openid, f'user_wx_openid#{binding.id}'
|
|
||||||
legacy = (getattr(user, 'OpenID', None) or '').strip()
|
|
||||||
username = (getattr(user, 'UserName', None) or '').strip()
|
|
||||||
logger.warning(
|
|
||||||
'[PAY_OPENID] uid=%s club=%s 无 user_wx_openid 绑定 legacy_OpenID=%s legacy_UserName=%s…',
|
|
||||||
uid, cid, bool(legacy), username[:8] if username else '',
|
|
||||||
)
|
|
||||||
return '', 'missing_binding'
|
|
||||||
|
|
||||||
|
|
||||||
def get_payment_openid(request, user=None, club_id=None):
|
def get_payment_openid(request, user=None, club_id=None):
|
||||||
@@ -272,7 +292,11 @@ def get_payment_openid(request, user=None, club_id=None):
|
|||||||
user = user or getattr(request, 'user', None)
|
user = user or getattr(request, 'user', None)
|
||||||
cid = (club_id or '').strip() or resolve_club_id_for_write(request, user)
|
cid = (club_id or '').strip() or resolve_club_id_for_write(request, user)
|
||||||
openid, source = get_wx_openid_for_user_with_source(user, cid)
|
openid, source = get_wx_openid_for_user_with_source(user, cid)
|
||||||
if not openid and user:
|
if openid:
|
||||||
|
logger.info('[PAY_OPENID] uid=%s club=%s openid=%s… source=%s',
|
||||||
|
getattr(user, 'UserUID', ''), cid,
|
||||||
|
openid[:6] + '...' if len(openid) > 10 else openid, source)
|
||||||
|
elif user:
|
||||||
uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None)
|
uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None)
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'[PAY_OPENID] 无法解析支付 openid uid=%s club=%s source=%s user_club=%s',
|
'[PAY_OPENID] 无法解析支付 openid uid=%s club=%s source=%s user_club=%s',
|
||||||
|
|||||||
@@ -189,3 +189,26 @@ def login_or_register_by_club(code, club_id=None, app_id=None, client_ip=''):
|
|||||||
**staff_fields,
|
**staff_fields,
|
||||||
}
|
}
|
||||||
return data, None
|
return data, None
|
||||||
|
|
||||||
|
|
||||||
|
def refresh_wx_openid_binding(code, user, club_id=None, app_id=None):
|
||||||
|
"""已登录用户:用当前小程序 code 刷新 user_wx_openid(不换 JWT)。"""
|
||||||
|
wx_data = jscode2session(code, club_id=club_id, app_id=app_id)
|
||||||
|
if not wx_data or 'openid' not in wx_data:
|
||||||
|
return None, wx_data.get('errmsg', '微信授权失败')
|
||||||
|
|
||||||
|
openid = wx_data['openid']
|
||||||
|
unionid = wx_data.get('unionid', '')
|
||||||
|
resolved_club_id = wx_data.get('club_id') or club_id or CLUB_ID_DEFAULT
|
||||||
|
|
||||||
|
ensure_wx_openid_binding(resolved_club_id, openid, user, unionid)
|
||||||
|
ensure_user_club_id(user, resolved_club_id)
|
||||||
|
if unionid and unionid != (getattr(user, 'UnionID', None) or ''):
|
||||||
|
user.UnionID = unionid
|
||||||
|
user.save(update_fields=['UnionID'])
|
||||||
|
|
||||||
|
return {
|
||||||
|
'club_id': resolved_club_id,
|
||||||
|
'openid_preview': openid[:6] + '...' + openid[-4:] if len(openid) > 10 else openid,
|
||||||
|
'wx_appid': wx_data.get('wx_appid', ''),
|
||||||
|
}, None
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ from django.conf import settings
|
|||||||
|
|
||||||
from jituan.constants import CLUB_ID_DEFAULT
|
from jituan.constants import CLUB_ID_DEFAULT
|
||||||
from jituan.models import Club
|
from jituan.models import Club
|
||||||
|
from jituan.services.club_resolver import get_club_miniapp_appid
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -51,16 +52,25 @@ def get_wechat_v2_config(club_id=None):
|
|||||||
|
|
||||||
cfg_json = club.config_json or {}
|
cfg_json = club.config_json or {}
|
||||||
mch_key, key_source = _resolve_v2_mch_key(cfg_json, fallback['key'])
|
mch_key, key_source = _resolve_v2_mch_key(cfg_json, fallback['key'])
|
||||||
mini_appid = (club.wx_appid or '').strip()
|
mini_appid = get_club_miniapp_appid(club)
|
||||||
pay_appid = (club.pay_app_id or '').strip()
|
wx_a = (club.wx_appid or '').strip()
|
||||||
if mini_appid and pay_appid and mini_appid != pay_appid:
|
pay_a = (club.pay_app_id or '').strip()
|
||||||
|
if wx_a and pay_a and wx_a != pay_a:
|
||||||
logger.warning(
|
logger.warning(
|
||||||
'[WX_V2_CONFIG] club=%s wx_appid(%s) 与 pay_app_id(%s) 不一致;'
|
'[WX_V2_CONFIG] club=%s wx_appid(%s) 与 pay_app_id(%s) 不一致;'
|
||||||
'小程序 JSAPI 支付以 wx_appid 为准(openid 与登录 AppID 绑定)',
|
'小程序 JSAPI 以 wx_appid 优先(openid 与登录 AppID 绑定)',
|
||||||
cid, mini_appid, pay_appid,
|
cid, wx_a, pay_a,
|
||||||
)
|
)
|
||||||
# openid 由 jscode2session(club.wx_appid) 换取,支付 AppID 须与登录一致
|
if mini_appid:
|
||||||
appid = mini_appid or pay_appid or fallback['appid']
|
appid = mini_appid
|
||||||
|
elif cid == CLUB_ID_DEFAULT:
|
||||||
|
appid = fallback['appid']
|
||||||
|
else:
|
||||||
|
logger.error(
|
||||||
|
'[WX_V2_CONFIG] club=%s 未配置 wx_appid/pay_app_id,禁止回落星阙全局 AppID',
|
||||||
|
cid,
|
||||||
|
)
|
||||||
|
appid = ''
|
||||||
mch_id = club.mch_id or fallback['mch_id']
|
mch_id = club.mch_id or fallback['mch_id']
|
||||||
|
|
||||||
if mch_id and key_source == 'settings.WEIXIN_SHANGHUMIYAO':
|
if mch_id and key_source == 'settings.WEIXIN_SHANGHUMIYAO':
|
||||||
@@ -213,10 +223,8 @@ def get_wechat_v3_config(club_id=None):
|
|||||||
|
|
||||||
cfg_json = club.config_json or {}
|
cfg_json = club.config_json or {}
|
||||||
return {
|
return {
|
||||||
'APPID': (
|
'APPID': get_club_miniapp_appid(club) or (
|
||||||
(club.wx_appid or '').strip()
|
fallback['APPID'] if cid == CLUB_ID_DEFAULT else ''
|
||||||
or (club.pay_app_id or '').strip()
|
|
||||||
or fallback['APPID']
|
|
||||||
),
|
),
|
||||||
'MCHID': club.mch_id or fallback['MCHID'],
|
'MCHID': club.mch_id or fallback['MCHID'],
|
||||||
'PRIVATE_KEY_PATH': (
|
'PRIVATE_KEY_PATH': (
|
||||||
|
|||||||
@@ -56,6 +56,7 @@ from jituan.views import (
|
|||||||
ClubSzxxView,
|
ClubSzxxView,
|
||||||
ClubUserSummaryView,
|
ClubUserSummaryView,
|
||||||
ClubWechatLoginView,
|
ClubWechatLoginView,
|
||||||
|
ClubWxBindRefreshView,
|
||||||
)
|
)
|
||||||
|
|
||||||
from jituan.views_dashou_exam import (
|
from jituan.views_dashou_exam import (
|
||||||
@@ -94,6 +95,7 @@ from jituan.views_identity_tag import (
|
|||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path('auth/wechat-login', ClubWechatLoginView.as_view(), name='jituan_wechat_login'),
|
path('auth/wechat-login', ClubWechatLoginView.as_view(), name='jituan_wechat_login'),
|
||||||
|
path('auth/refresh-wx-bind', ClubWxBindRefreshView.as_view(), name='jituan_refresh_wx_bind'),
|
||||||
path('auth/kefu-login', ClubKefuLoginView.as_view(), name='jituan_kefu_login'),
|
path('auth/kefu-login', ClubKefuLoginView.as_view(), name='jituan_kefu_login'),
|
||||||
path('auth/me-context', ClubMeContextView.as_view(), name='jituan_me_context'),
|
path('auth/me-context', ClubMeContextView.as_view(), name='jituan_me_context'),
|
||||||
path('auth/menu-access', ClubKefuMenuAccessView.as_view(), name='jituan_menu_access'),
|
path('auth/menu-access', ClubKefuMenuAccessView.as_view(), name='jituan_menu_access'),
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ from jituan.constants import SUPER_ADMIN_PHONES, ADMIN_ROLE_LABELS, CLUB_ID_DEFA
|
|||||||
from jituan.models import Club, AdminAssignment
|
from jituan.models import Club, AdminAssignment
|
||||||
from jituan.services.admin_context import build_admin_club_context, is_kefu_backend_account, can_manage_admin_assignments
|
from jituan.services.admin_context import build_admin_club_context, is_kefu_backend_account, can_manage_admin_assignments
|
||||||
from jituan.services.kefu_menu import build_menu_access_payload
|
from jituan.services.kefu_menu import build_menu_access_payload
|
||||||
from jituan.services.wechat_login import login_or_register_by_club
|
from jituan.services.wechat_login import login_or_register_by_club, refresh_wx_openid_binding
|
||||||
from jituan.services.caiwu_stats import build_caiwu_payload
|
from jituan.services.caiwu_stats import build_caiwu_payload
|
||||||
from jituan.services.szxx_stats import build_szxx_payload
|
from jituan.services.szxx_stats import build_szxx_payload
|
||||||
from jituan.services.admin_assignments import (
|
from jituan.services.admin_assignments import (
|
||||||
@@ -75,6 +75,35 @@ class ClubWechatLoginView(APIView):
|
|||||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
|
class ClubWxBindRefreshView(APIView):
|
||||||
|
"""
|
||||||
|
已登录用户静默刷新当前小程序 openid 绑定(支付前 AppID/openid 对齐)。
|
||||||
|
POST /jituan/auth/refresh-wx-bind
|
||||||
|
body: { code, club_id?, app_id? }
|
||||||
|
"""
|
||||||
|
permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def post(self, request):
|
||||||
|
code = (request.data.get('code') or '').strip()
|
||||||
|
club_id = (request.data.get('club_id') or '').strip() or None
|
||||||
|
app_id = (request.data.get('app_id') or '').strip() or None
|
||||||
|
if not code:
|
||||||
|
return Response({'code': 1, 'msg': '微信授权码不能为空', 'data': None},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
try:
|
||||||
|
data, err = refresh_wx_openid_binding(
|
||||||
|
code, request.user, club_id=club_id, app_id=app_id,
|
||||||
|
)
|
||||||
|
if err:
|
||||||
|
return Response({'code': 2, 'msg': err, 'data': None},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST)
|
||||||
|
return Response({'code': 0, 'msg': 'ok', 'data': data})
|
||||||
|
except Exception:
|
||||||
|
logger.exception('ClubWxBindRefreshView 异常')
|
||||||
|
return Response({'code': 99, 'msg': '刷新绑定失败', 'data': None},
|
||||||
|
status=status.HTTP_500_INTERNAL_SERVER_ERROR)
|
||||||
|
|
||||||
|
|
||||||
class ClubKefuLoginView(APIView):
|
class ClubKefuLoginView(APIView):
|
||||||
"""
|
"""
|
||||||
多俱乐部客服/集团后台登录(新接口,旧 /yonghu/kefujinru 不变)。
|
多俱乐部客服/集团后台登录(新接口,旧 /yonghu/kefujinru 不变)。
|
||||||
|
|||||||
Reference in New Issue
Block a user