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:
XingQue
2026-07-10 20:57:30 +08:00
parent 5d3e4e32a2
commit 017d176790
6 changed files with 147 additions and 45 deletions

View File

@@ -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 (

View File

@@ -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 backfillxq→User.OpenIDxzj 等→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',

View File

@@ -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

View File

@@ -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': (

View File

@@ -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'),

View File

@@ -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 不变)。