"""用户所属俱乐部解析。""" import logging from django.db import IntegrityError, transaction from jituan.constants import CLUB_ID_DEFAULT from jituan.models import UserWxOpenid logger = logging.getLogger(__name__) def resolve_invite_party_club(user, request_club_id=None): """ 邀请链校验用:只返回已落库的俱乐部归属。 未归属任何俱乐部时返回 None(勿默认 xq,避免星之界扫码误拦)。 """ if not user: return None uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None) req = (request_club_id or '').strip() # 当前小程序 openid 绑定优先(ClubID 字段曾默认 xq,会误判新用户) if req and uid: if UserWxOpenid.query.filter(yonghuid=uid, club_id=req).exists(): return req explicit = (getattr(user, 'ClubID', None) or '').strip() if explicit: if explicit == CLUB_ID_DEFAULT and uid: has_real_xq = ( UserWxOpenid.query.filter(yonghuid=uid, club_id=CLUB_ID_DEFAULT).exists() or bool(getattr(user, 'OpenID', None)) ) if not has_real_xq: explicit = '' if explicit: return explicit if uid: binding = UserWxOpenid.query.filter(yonghuid=uid).order_by('id').first() if binding: return binding.club_id return None def _user_querysets(): """兼容 gvsdsdk User.query 与 Django User.objects。""" from users.business_models import User seen = set() for qs in (getattr(User, 'objects', None), getattr(User, 'query', None)): if qs is None or id(qs) in seen: continue seen.add(id(qs)) yield qs def find_user_by_wx_openid(club_id, openid): """按俱乐部 openid 绑定或 legacy UserName/OpenID 定位用户。""" from users.business_models import User openid = (openid or '').strip() club_id = (club_id or '').strip() if not openid: return None def _user_by_uid(uid): if not uid: return None user = User.objects.filter(UserUID=uid).first() if not user: user = User.query.filter(UserUID=uid).first() return user # 已有 openid 绑定(任意俱乐部)优先,避免重复注册 binding_any = UserWxOpenid.query.filter(openid=openid).order_by('id').first() if binding_any: user = _user_by_uid(binding_any.yonghuid) if user: return user if club_id: binding = UserWxOpenid.query.filter(club_id=club_id, openid=openid).first() if binding: user = _user_by_uid(binding.yonghuid) if user: return user # 星之界等用户常 UserName=openid 且 OpenID 为空 for manager in _user_querysets(): user = manager.filter(UserName=openid).first() if user: return user user = manager.filter(OpenID=openid).first() if user: return user return None def ensure_wx_openid_binding(club_id, openid, user, unionid='', wx_appid=''): """ 绑定 openid。 wx_appid 用于区分小程序 / 服务号;只清理「同 club + 同 wx_appid」的旧绑定, 避免 H5 登录冲掉小程序 openid(或反过来)。 """ if not club_id or not openid or not user: return None uid = getattr(user, 'UserUID', None) if not uid: return None club_id = (club_id or '').strip() openid = (openid or '').strip() wx_appid = (wx_appid or '').strip() binding = UserWxOpenid.query.filter(club_id=club_id, openid=openid).first() if binding and binding.yonghuid == uid: if wx_appid and getattr(binding, 'wx_appid', '') != wx_appid: binding.wx_appid = wx_appid if unionid: binding.unionid = unionid binding.save() return binding try: with transaction.atomic(): # 仅清理同一 AppID 下的过期绑定;小程序与服务号可并存 stale_qs = UserWxOpenid.query.filter(yonghuid=uid, club_id=club_id).exclude(openid=openid) if wx_appid: stale_qs = stale_qs.filter(wx_appid=wx_appid) else: stale_qs = stale_qs.filter(wx_appid='') stale_count = stale_qs.count() if stale_count: logger.info( '[WX_OPENID_BIND] uid=%s club=%s appid=%s 清除 %d 条过期 openid 绑定', uid, club_id, wx_appid or '(legacy)', stale_count, ) stale_qs.delete() binding = UserWxOpenid.query.filter(club_id=club_id, openid=openid).first() if binding: if binding.yonghuid != uid: return binding if wx_appid and getattr(binding, 'wx_appid', '') != wx_appid: binding.wx_appid = wx_appid binding.save(update_fields=['wx_appid']) return binding row = UserWxOpenid.query.create( club_id=club_id, openid=openid, yonghuid=uid, unionid=unionid or getattr(user, 'UnionID', None) or '', wx_appid=wx_appid, ) if club_id == CLUB_ID_DEFAULT and getattr(user, 'OpenID', None) != openid: user.OpenID = openid user.save(update_fields=['OpenID']) return row except IntegrityError: return UserWxOpenid.query.filter(club_id=club_id, openid=openid).first() def resolve_or_create_wx_user(club_id, openid, generate_uid, unionid='', wx_appid=''): """ 微信登录/注册统一找用户,避免 UserName(openid) 唯一键冲突。 星之界等俱乐部用户常 UserName=openid 且 OpenID 为空,不可 get_or_create(OpenID=...)。 返回 (user, created)。 """ import uuid from users.business_models import User openid = (openid or '').strip() club_id = (club_id or '').strip() wx_appid = (wx_appid or '').strip() if not openid: raise ValueError('openid 不能为空') user = find_user_by_wx_openid(club_id, openid) if user: ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid) return user, False # 有 unionid 时尽量合并到已有用户(小程序 / 服务号同人) if unionid: binding_u = UserWxOpenid.query.filter(unionid=unionid).order_by('id').first() if binding_u: user = User.objects.filter(UserUID=binding_u.yonghuid).first() if not user: user = User.query.filter(UserUID=binding_u.yonghuid).first() if user: ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid) return user, False for manager in _user_querysets(): user = manager.filter(UnionID=unionid).first() if user: ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid) return user, False try: with transaction.atomic(): user = User( UserUUID=uuid.uuid4().bytes, UserUID=generate_uid(), UserName=openid, OpenID=openid if club_id == CLUB_ID_DEFAULT else None, UnionID=unionid or None, ClubID='', ) user.save() except IntegrityError: user = find_user_by_wx_openid(club_id, openid) if not user: raise ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid) return user, False ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid) return user, True def get_user_club_id(user): """ 解析用户归属 club(注册/邀请链隔离用)。 优先级:User.ClubID → user_wx_openid → 默认 xq。 """ if not user: return CLUB_ID_DEFAULT club_id = getattr(user, 'ClubID', None) or getattr(user, 'club_id', None) if club_id: return club_id uid = getattr(user, 'UserUID', None) or getattr(user, 'yonghuid', None) if uid: binding = UserWxOpenid.query.filter(yonghuid=uid).order_by('id').first() if binding: return binding.club_id return CLUB_ID_DEFAULT def ensure_user_club_id(user, club_id): """登录/注册后确保 User.ClubID 与绑定 club 一致。""" if not user or not club_id: return current = getattr(user, 'ClubID', None) if current != club_id and hasattr(user, 'ClubID'): user.ClubID = club_id user.save(update_fields=['ClubID']) def _looks_like_wx_openid(value): """微信 openid 常见形态:以 o 开头、长度约 28。""" v = (value or '').strip() return len(v) >= 20 and v.startswith('o') def _client_type_from_request(request): if not request: return 'mini' meta = getattr(request, 'META', None) or {} header = (meta.get('HTTP_X_CLIENT') or meta.get('HTTP_X_PAY_CLIENT') or '').strip().lower() data = getattr(request, 'data', None) or {} body = '' if isinstance(data, dict): body = (data.get('client_type') or data.get('pay_client') or '').strip().lower() val = header or body if val in ('h5', 'official', 'mp-h5', 'oa'): return 'h5' return 'mini' def resolve_prefer_wx_appid(club_id, client_type='mini'): """按端解析应付的 AppID:H5→服务号;小程序→wx_appid。""" from jituan.models import Club cid = (club_id or '').strip() if not cid: return '' try: club = Club.query.get(club_id=cid) except Exception: return '' if client_type == 'h5': return (club.official_appid or '').strip() return (club.wx_appid or club.pay_app_id or '').strip() def resolve_club_payment_openid(user, club_id=None, prefer_wx_appid=''): """ 支付专用:按俱乐部取 openid。 prefer_wx_appid 有值时优先取该 AppID 绑定(H5 服务号 / 小程序分流)。 1) user_wx_openid(yonghuid, club_id[, wx_appid]) 2) 无本 club 绑定但有其他 club → 拒绝(须在本端重新登录) 3) 无任何绑定时 lazy backfill:xq→User.OpenID;xzj 等→UserName(openid) """ 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' prefer = (prefer_wx_appid or '').strip() qs = UserWxOpenid.query.filter(yonghuid=uid, club_id=cid) binding = None if prefer: binding = qs.filter(wx_appid=prefer).order_by('-id').first() if not binding and prefer: # 历史行 wx_appid 为空:仅小程序端可回落 pass if not binding and not prefer: binding = qs.order_by('-id').first() if not binding and prefer: # 小程序端:允许回落空 wx_appid 历史绑定 from jituan.models import Club try: club = Club.query.get(club_id=cid) mini = (club.wx_appid or club.pay_app_id or '').strip() except Exception: mini = '' if prefer == mini: binding = qs.filter(wx_appid='').order_by('-id').first() if binding and binding.openid: return binding.openid.strip(), f'user_wx_openid#{binding.id}' if prefer: logger.warning( '[PAY_OPENID] uid=%s club=%s 无 appid=%s 绑定,须先在对应端完成微信登录', uid, cid, prefer, ) return '', 'missing_appid_bind' 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, prefer_wx_appid=''): openid, _ = resolve_club_payment_openid(user, club_id, prefer_wx_appid=prefer_wx_appid) return openid def get_wx_openid_for_user_with_source(user, club_id=None, prefer_wx_appid=''): return resolve_club_payment_openid(user, club_id, prefer_wx_appid=prefer_wx_appid) def get_payment_openid(request, user=None, club_id=None): """下单/充值:按请求俱乐部 + 端类型解析 openid。""" from jituan.services.club_write import resolve_club_id_for_write user = user or getattr(request, 'user', None) cid = (club_id or '').strip() or resolve_club_id_for_write(request, user) client_type = _client_type_from_request(request) prefer = resolve_prefer_wx_appid(cid, client_type) openid, source = get_wx_openid_for_user_with_source(user, cid, prefer_wx_appid=prefer) if openid: logger.info( '[PAY_OPENID] uid=%s club=%s client=%s appid=%s openid=%s… source=%s', getattr(user, 'UserUID', ''), cid, client_type, prefer or '-', 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 client=%s appid=%s source=%s user_club=%s', uid, cid, client_type, prefer or '-', source, get_user_club_id(user), ) return openid, cid def get_pay_app_source(request): """供统一下单选用 AppID:h5→official,其它→mini。""" return 'official' if _client_type_from_request(request) == 'h5' else 'mini'