diff --git a/jituan/services/club_resolver.py b/jituan/services/club_resolver.py index 6404232..686340b 100644 --- a/jituan/services/club_resolver.py +++ b/jituan/services/club_resolver.py @@ -13,13 +13,27 @@ logger = logging.getLogger(__name__) _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): if not app_id: return None + app_id = app_id.strip() 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: - 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): @@ -30,8 +44,10 @@ def get_club_wx_credentials(club_id): except Club.DoesNotExist: club = None - if club and club.wx_appid and club.wx_secret: - return club.wx_appid, club.wx_secret, club + appid = get_club_miniapp_appid(club) + secret = (club.wx_secret or '').strip() if club else '' + if appid and secret: + return appid, secret, club if club_id == CLUB_ID_DEFAULT: return ( diff --git a/jituan/services/club_user.py b/jituan/services/club_user.py index fc72c63..2a96e97 100644 --- a/jituan/services/club_user.py +++ b/jituan/services/club_user.py @@ -221,49 +221,69 @@ def _looks_like_wx_openid(value): 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 登录时换取的一致)。 - 仅读 user_wx_openid;禁止回落 User.OpenID/UserName(多俱乐部/旧 AppID 会错配)。 + 支付专用:按俱乐部取 openid,与 club 表 miniapp AppID 对应。 + 1) user_wx_openid(yonghuid, club_id) + 2) 无本 club 绑定但有其他 club → 拒绝(须在本小程序重新登录) + 3) 无任何绑定时 lazy backfill:xq→User.OpenID;xzj 等→UserName(openid) """ if not user: - return '' + 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 '' + return '', 'none' + binding = ( UserWxOpenid.query.filter(yonghuid=uid, club_id=cid) .order_by('-id') .first() ) if binding and binding.openid: - return binding.openid - return '' + return binding.openid.strip(), f'user_wx_openid#{binding.id}' + + 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): - """返回 (openid, source) 供支付诊断。""" - 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' + return resolve_club_payment_openid(user, club_id) 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) cid = (club_id or '').strip() or resolve_club_id_for_write(request, user) 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) logger.warning( '[PAY_OPENID] 无法解析支付 openid uid=%s club=%s source=%s user_club=%s', diff --git a/jituan/services/wechat_login.py b/jituan/services/wechat_login.py index 5bf1f52..5f5b6c2 100644 --- a/jituan/services/wechat_login.py +++ b/jituan/services/wechat_login.py @@ -189,3 +189,26 @@ def login_or_register_by_club(code, club_id=None, app_id=None, client_ip=''): **staff_fields, } 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 diff --git a/jituan/services/wechat_pay.py b/jituan/services/wechat_pay.py index b461dee..418def2 100644 --- a/jituan/services/wechat_pay.py +++ b/jituan/services/wechat_pay.py @@ -6,6 +6,7 @@ from django.conf import settings from jituan.constants import CLUB_ID_DEFAULT from jituan.models import Club +from jituan.services.club_resolver import get_club_miniapp_appid logger = logging.getLogger(__name__) @@ -51,16 +52,25 @@ def get_wechat_v2_config(club_id=None): cfg_json = club.config_json or {} mch_key, key_source = _resolve_v2_mch_key(cfg_json, fallback['key']) - mini_appid = (club.wx_appid or '').strip() - pay_appid = (club.pay_app_id or '').strip() - if mini_appid and pay_appid and mini_appid != pay_appid: + mini_appid = get_club_miniapp_appid(club) + wx_a = (club.wx_appid or '').strip() + pay_a = (club.pay_app_id or '').strip() + if wx_a and pay_a and wx_a != pay_a: logger.warning( '[WX_V2_CONFIG] club=%s wx_appid(%s) 与 pay_app_id(%s) 不一致;' - '小程序 JSAPI 支付以 wx_appid 为准(openid 与登录 AppID 绑定)', - cid, mini_appid, pay_appid, + '小程序 JSAPI 以 wx_appid 优先(openid 与登录 AppID 绑定)', + cid, wx_a, pay_a, ) - # openid 由 jscode2session(club.wx_appid) 换取,支付 AppID 须与登录一致 - appid = mini_appid or pay_appid or fallback['appid'] + if mini_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'] 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 {} return { - 'APPID': ( - (club.wx_appid or '').strip() - or (club.pay_app_id or '').strip() - or fallback['APPID'] + 'APPID': get_club_miniapp_appid(club) or ( + fallback['APPID'] if cid == CLUB_ID_DEFAULT else '' ), 'MCHID': club.mch_id or fallback['MCHID'], 'PRIVATE_KEY_PATH': ( diff --git a/jituan/urls.py b/jituan/urls.py index 1d49541..5314347 100644 --- a/jituan/urls.py +++ b/jituan/urls.py @@ -56,6 +56,7 @@ from jituan.views import ( ClubSzxxView, ClubUserSummaryView, ClubWechatLoginView, + ClubWxBindRefreshView, ) from jituan.views_dashou_exam import ( @@ -94,6 +95,7 @@ from jituan.views_identity_tag import ( urlpatterns = [ 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/me-context', ClubMeContextView.as_view(), name='jituan_me_context'), path('auth/menu-access', ClubKefuMenuAccessView.as_view(), name='jituan_menu_access'), diff --git a/jituan/views.py b/jituan/views.py index cb2053e..fe39c17 100644 --- a/jituan/views.py +++ b/jituan/views.py @@ -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.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.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.szxx_stats import build_szxx_payload from jituan.services.admin_assignments import ( @@ -75,6 +75,35 @@ class ClubWechatLoginView(APIView): 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): """ 多俱乐部客服/集团后台登录(新接口,旧 /yonghu/kefujinru 不变)。