feat: UFO H5 服务号 OAuth 登录与同商户公众号 JSAPI 支付

This commit is contained in:
XingQue
2026-07-30 00:12:37 +08:00
parent 531639731a
commit 148a35f04b
11 changed files with 449 additions and 64 deletions

View File

@@ -143,6 +143,62 @@ def wechat_jscode2session_for_request(request, code):
return jscode2session(code, club_id=club_id)
def get_club_official_credentials(club_id):
"""服务号 AppID/SecretH5 网页授权 + 公众号 JSAPI"""
club_id = club_id or CLUB_ID_DEFAULT
club = None
try:
club = Club.query.get(club_id=club_id)
except Club.DoesNotExist:
club = None
except Exception:
logger.exception('读取俱乐部失败 club_id=%s', club_id)
club = None
if not club:
return '', '', None
appid = (club.official_appid or '').strip()
secret = (club.official_secret or '').strip()
return appid, secret, club
def oauth2_access_token(code, club_id=None):
"""
服务号网页授权code → openidsns/oauth2/access_token
"""
appid, secret, club = get_club_official_credentials(club_id)
if not appid or not secret:
return {'errmsg': '俱乐部服务号配置未设置official_appid/official_secret'}
url = 'https://api.weixin.qq.com/sns/oauth2/access_token'
params = {
'appid': appid,
'secret': secret,
'code': code,
'grant_type': 'authorization_code',
}
try:
response = requests.get(url, params=params, timeout=10)
result = response.json()
if 'openid' in result:
return {
'openid': result['openid'],
'access_token': result.get('access_token', ''),
'refresh_token': result.get('refresh_token', ''),
'scope': result.get('scope', ''),
'unionid': result.get('unionid', ''),
'club_id': (club.club_id if club else None) or club_id or CLUB_ID_DEFAULT,
'wx_appid': appid,
}
errcode = result.get('errcode', 'unknown')
errmsg = result.get('errmsg', '未知错误')
return {'errmsg': f'[{errcode}]{errmsg}'}
except requests.exceptions.Timeout:
return {'errmsg': '请求微信接口超时'}
except Exception as e:
logger.exception('oauth2_access_token 异常 club_id=%s', club_id)
return {'errmsg': f'请求微信接口异常: {str(e)}'}
def jscode2session(code, club_id=None, app_id=None):
"""
按俱乐部微信配置换取 openid。

View File

@@ -99,7 +99,12 @@ def find_user_by_wx_openid(club_id, openid):
return None
def ensure_wx_openid_binding(club_id, openid, user, unionid=''):
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)
@@ -108,25 +113,40 @@ def ensure_wx_openid_binding(club_id, openid, user, unionid=''):
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():
# 同一用户在同一俱乐部只保留当前 openidAppID 变更/重新登录须覆盖旧绑定)
stale = UserWxOpenid.query.filter(yonghuid=uid, club_id=club_id).exclude(openid=openid)
stale_count = stale.count()
# 仅清理同一 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 清除 %d 条过期 openid 绑定',
uid, club_id, stale_count,
'[WX_OPENID_BIND] uid=%s club=%s appid=%s 清除 %d 条过期 openid 绑定',
uid, club_id, wx_appid or '(legacy)', stale_count,
)
stale.delete()
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(
@@ -134,6 +154,7 @@ def ensure_wx_openid_binding(club_id, openid, user, unionid=''):
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
@@ -143,7 +164,7 @@ def ensure_wx_openid_binding(club_id, openid, user, unionid=''):
return UserWxOpenid.query.filter(club_id=club_id, openid=openid).first()
def resolve_or_create_wx_user(club_id, openid, generate_uid, unionid=''):
def resolve_or_create_wx_user(club_id, openid, generate_uid, unionid='', wx_appid=''):
"""
微信登录/注册统一找用户,避免 UserName(openid) 唯一键冲突。
星之界等俱乐部用户常 UserName=openid 且 OpenID 为空,不可 get_or_create(OpenID=...)。
@@ -154,14 +175,31 @@ def resolve_or_create_wx_user(club_id, openid, generate_uid, unionid=''):
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)
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(
@@ -177,10 +215,10 @@ def resolve_or_create_wx_user(club_id, openid, generate_uid, unionid=''):
user = find_user_by_wx_openid(club_id, openid)
if not user:
raise
ensure_wx_openid_binding(club_id, openid, user, unionid)
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)
ensure_wx_openid_binding(club_id, openid, user, unionid, wx_appid=wx_appid)
return user, True
@@ -221,11 +259,42 @@ def _looks_like_wx_openid(value):
return len(v) >= 20 and v.startswith('o')
def resolve_club_payment_openid(user, club_id=None):
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'):
"""按端解析应付的 AppIDH5→服务号小程序→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,与 club 表 miniapp AppID 对应
1) user_wx_openid(yonghuid, club_id)
2) 无本 club 绑定但有其他 club → 拒绝(须在本小程序重新登录)
支付专用:按俱乐部取 openid。
prefer_wx_appid 有值时优先取该 AppID 绑定H5 服务号 / 小程序分流)。
1) user_wx_openid(yonghuid, club_id[, wx_appid])
2) 无本 club 绑定但有其他 club → 拒绝(须在本端重新登录)
3) 无任何绑定时 lazy backfillxq→User.OpenIDxzj 等→UserName(openid)
"""
if not user:
@@ -235,14 +304,37 @@ def resolve_club_payment_openid(user, club_id=None):
if not uid or not cid:
return '', 'none'
binding = (
UserWxOpenid.query.filter(yonghuid=uid, club_id=cid)
.order_by('-id')
.first()
)
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)
@@ -277,29 +369,38 @@ def resolve_club_payment_openid(user, club_id=None):
return '', 'missing'
def get_wx_openid_for_user(user, club_id=None):
openid, _ = resolve_club_payment_openid(user, club_id)
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):
return resolve_club_payment_openid(user, club_id)
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。"""
"""下单/充值:按请求俱乐部 + 端类型解析 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)
openid, source = get_wx_openid_for_user_with_source(user, cid)
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 openid=%s… source=%s',
getattr(user, 'UserUID', ''), cid,
openid[:6] + '...' if len(openid) > 10 else openid, source)
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 source=%s user_club=%s',
uid, cid, source, get_user_club_id(user),
'[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):
"""供统一下单选用 AppIDh5→official其它→mini。"""
return 'official' if _client_type_from_request(request) == 'h5' else 'mini'

View File

@@ -123,6 +123,7 @@ def login_or_register_by_club(code, club_id=None, app_id=None, client_ip=''):
openid = wx_data['openid']
unionid = wx_data.get('unionid', '')
wx_appid = wx_data.get('wx_appid', '') or ''
resolved_club_id = wx_data.get('club_id') or club_id or CLUB_ID_DEFAULT
with transaction.atomic():
@@ -140,13 +141,16 @@ def login_or_register_by_club(code, club_id=None, app_id=None, client_ip=''):
if not user:
user, created = resolve_or_create_wx_user(
resolved_club_id, openid, _generate_user_uid, unionid=unionid,
resolved_club_id, openid, _generate_user_uid,
unionid=unionid, wx_appid=wx_appid,
)
if created:
_ensure_boss_profile(user)
_ensure_consumer_role(user)
else:
ensure_wx_openid_binding(resolved_club_id, openid, user, unionid)
ensure_wx_openid_binding(
resolved_club_id, openid, user, unionid, wx_appid=wx_appid,
)
if unionid and unionid != (user.UnionID or ''):
user.UnionID = unionid
@@ -208,9 +212,10 @@ def refresh_wx_openid_binding(code, user, club_id=None, app_id=None):
openid = wx_data['openid']
unionid = wx_data.get('unionid', '')
wx_appid = wx_data.get('wx_appid', '') or ''
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_wx_openid_binding(resolved_club_id, openid, user, unionid, wx_appid=wx_appid)
ensure_user_club_id(user, resolved_club_id)
if unionid and unionid != (getattr(user, 'UnionID', None) or ''):
user.UnionID = unionid
@@ -221,3 +226,99 @@ def refresh_wx_openid_binding(code, user, club_id=None, app_id=None):
'openid_preview': openid[:6] + '...' + openid[-4:] if len(openid) > 10 else openid,
'wx_appid': wx_data.get('wx_appid', ''),
}, None
def login_or_register_by_official_oauth(code, club_id=None, client_ip=''):
"""
服务号网页授权登录H5
换 openid 用 club.official_appid/secret建用户逻辑与小程序登录一致。
"""
from jituan.services.club_resolver import oauth2_access_token
wx_data = oauth2_access_token(code, club_id=club_id)
if not wx_data or 'openid' not in wx_data:
return None, (wx_data or {}).get('errmsg', '微信授权失败')
openid = wx_data['openid']
unionid = wx_data.get('unionid', '')
wx_appid = wx_data.get('wx_appid', '') or ''
resolved_club_id = wx_data.get('club_id') or club_id or CLUB_ID_DEFAULT
with transaction.atomic():
binding = UserWxOpenid.query.filter(
club_id=resolved_club_id, openid=openid
).first()
user = None
created = False
if binding:
user = User.query.filter(UserUID=binding.yonghuid).first()
if not user:
user = User.objects.filter(UserUID=binding.yonghuid).first()
if not user:
user, created = resolve_or_create_wx_user(
resolved_club_id, openid, _generate_user_uid,
unionid=unionid, wx_appid=wx_appid,
)
if created:
_ensure_boss_profile(user)
_ensure_consumer_role(user)
else:
ensure_wx_openid_binding(
resolved_club_id, openid, user, unionid, wx_appid=wx_appid,
)
if unionid and unionid != (user.UnionID or ''):
user.UnionID = unionid
if client_ip:
user.IP = client_ip
user.UserLastLoginDate = timezone.now()
user.save()
ensure_user_club_id(user, resolved_club_id)
roles = _collect_role_status(user)
refresh = RefreshToken.for_user(user)
token = str(refresh.access_token)
staff_fields = {}
order_counts = {}
group_info = {}
try:
from merchant_ops.services.authz import staff_fields_for_login
staff_fields = staff_fields_for_login(user) or {}
except Exception:
logger.exception('staff_fields_for_login 失败 uid=%s', getattr(user, 'UserUID', ''))
try:
from users.views import WechatMiniProgramLoginView
login_view = WechatMiniProgramLoginView()
order_counts = login_view.jisuanOrderShuliang(user.UserUID) or {}
group_info = login_view.huoquQunPeizhi() or {}
except Exception:
logger.exception('登录附属信息失败 uid=%s', getattr(user, 'UserUID', ''))
data = {
'token': token,
'nicheng': roles['boss_nickname'],
'uid': user.UserUID,
'touxiang': user.Avatar or '',
'shangjiastatus': roles['shangjia_status'],
'dashoustatus': roles['dashou_status'],
'guanshistatus': roles['guanshi_status'],
'zuzhangstatus': roles['zuzhang_status'],
'kaoheguanstatus': roles['kaoheguan_status'],
'dingdantiaoshu': order_counts,
'dashouqun': group_info.get('dashouqun', ''),
'dashouqunid': group_info.get('dashouqunid', ''),
'guanshiqun': group_info.get('guanshiqun', ''),
'guanshiqunid': group_info.get('guanshiqunid', ''),
'club_id': resolved_club_id,
'is_new_user': created,
'login_client': 'h5',
'wx_appid': wx_appid,
**staff_fields,
}
return data, None

View File

@@ -172,10 +172,12 @@ def _resolve_v2_mch_key(club, cfg_json, fallback_key, club_id=None):
return '', 'missing'
def get_wechat_v2_config(club_id=None):
def get_wechat_v2_config(club_id=None, app_source='mini'):
"""
小程序 JSAPI 支付 V2 配置。
各俱乐部(含 xq优先读 club 表;字段为空时按项回落 settings兼容旧部署。
微信 JSAPI 支付 V2 配置。
app_source:
- mini小程序 AppID默认现网行为
- official服务号 AppIDH5商户号/密钥与小程序相同,仅换 AppID
"""
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
fallback = {
@@ -194,6 +196,7 @@ def get_wechat_v2_config(club_id=None):
cfg_json = club.config_json or {}
mch_key, key_source = _resolve_v2_mch_key(club, cfg_json, fallback['key'], club_id=cid)
mini_appid = get_club_miniapp_appid(club)
official_appid = (club.official_appid or '').strip()
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:
@@ -202,7 +205,13 @@ def get_wechat_v2_config(club_id=None):
'小程序 JSAPI 以 wx_appid 优先openid 与登录 AppID 绑定)',
cid, wx_a, pay_a,
)
if mini_appid:
source = (app_source or 'mini').strip().lower()
if source in ('official', 'h5', 'oa'):
appid = official_appid
if not appid:
logger.error('[WX_V2_CONFIG] club=%s H5 支付需要 official_appid', cid)
elif mini_appid:
appid = mini_appid
elif cid == CLUB_ID_DEFAULT:
appid = fallback['appid']
@@ -236,6 +245,7 @@ def get_wechat_v2_config(club_id=None):
'key': mch_key,
'club_id': cid,
'_key_source': key_source,
'_app_source': source if source in ('official', 'h5', 'oa') else 'mini',
# 明确标记:这是点单/会员收款商户
'merchant_role': 'pay',
'cert_path': pay_cert,