feat: 分俱乐部付呗收款通道,默认仍走微信直连

小程序前端仍传 wechat;后台可切换 wechat/fubei,支持配置多套付呗并退款按原通道路由。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
XingQue
2026-07-21 20:02:24 +08:00
parent f36db77f6b
commit 5762f00d40
20 changed files with 1105 additions and 0 deletions

View File

@@ -28,3 +28,37 @@ ADMIN_SCOPE_CLUB = 'CLUB'
# 集团最高权限账号(写死超级管理员,拥有全部功能权限与集团视图)
SUPER_ADMIN_PHONES = frozenset({'1383714110'})
# —— 支付通道 ——
PAY_CHANNEL_WECHAT_JSAPI = 'wechat_jsapi'
PAY_CHANNEL_WECHAT_V3 = 'wechat_v3'
PAY_CHANNEL_ALIPAY_WAP = 'alipay_wap'
PAY_CHANNEL_FUBEI_WX_MINI = 'fubei_wx_mini'
# 俱乐部「小程序收款」选用哪条通道(前端仍传 pay_method=wechat后端按此路由
MINI_PAY_CHANNEL_WECHAT = 'wechat'
MINI_PAY_CHANNEL_FUBEI = 'fubei'
MINI_PAY_CHANNEL_CHOICES = (MINI_PAY_CHANNEL_WECHAT, MINI_PAY_CHANNEL_FUBEI)
PAY_METHOD_WECHAT = 'wechat'
PAY_METHOD_ALIPAY = 'alipay'
PAY_METHOD_FUBEI = 'fubei'
PAY_METHOD_LABELS = {
PAY_METHOD_WECHAT: '微信支付',
PAY_METHOD_ALIPAY: '支付宝',
PAY_METHOD_FUBEI: '微信支付', # 前端不感知,展示仍为微信
}
PAY_METHOD_TO_CHANNEL = {
PAY_METHOD_WECHAT: PAY_CHANNEL_WECHAT_JSAPI,
PAY_METHOD_ALIPAY: PAY_CHANNEL_ALIPAY_WAP,
PAY_METHOD_FUBEI: PAY_CHANNEL_FUBEI_WX_MINI,
}
FUBEI_GATEWAY_PROD = 'https://shq-api.51fubei.com/gateway'
FUBEI_GATEWAY_SANDBOX = 'https://shq-api.51fubei.com/gateway' # 付呗文档未单独给沙箱网关时同正式
# 付呗下单 biz 标记(写入 attach / 流水表,回调与退款用)
FUBEI_BIZ_ORDER = 'order'
FUBEI_BIZ_CZJILU = 'czjilu'

View File

@@ -0,0 +1,41 @@
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('jituan', '0013_club_pay_withdraw_split'),
]
operations = [
migrations.AddField(
model_name='club',
name='mini_pay_channel',
field=models.CharField(
blank=True,
default='wechat',
help_text='wechat=微信直连fubei=付呗;默认 wechat不影响现网',
max_length=16,
verbose_name='小程序收款通道',
),
),
migrations.CreateModel(
name='PayChannelLedger',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('out_trade_no', models.CharField(db_index=True, max_length=64, unique=True, verbose_name='商户订单号')),
('club_id', models.CharField(db_index=True, default='', max_length=16)),
('channel', models.CharField(db_index=True, max_length=32, verbose_name='通道')),
('channel_config_id', models.IntegerField(blank=True, null=True)),
('biz_type', models.CharField(blank=True, default='', max_length=16, verbose_name='order/czjilu')),
('provider_order_sn', models.CharField(blank=True, default='', max_length=64, verbose_name='通道侧订单号')),
('amount_yuan', models.DecimalField(decimal_places=2, default=0, max_digits=12)),
('CreateTime', models.DateTimeField(auto_now_add=True)),
('UpdateTime', models.DateTimeField(auto_now=True)),
],
options={
'verbose_name': '支付通道流水',
'db_table': 'pay_channel_ledger',
},
),
]

View File

@@ -52,6 +52,14 @@ class Club(QModel):
goeasy_appkey = models.CharField(max_length=64, blank=True, default='')
goeasy_secret = models.CharField(max_length=128, blank=True, default='')
config_json = models.JSONField(default=dict, blank=True)
# 小程序「微信支付」入口实际走哪条通道wechat=直连商户号fubei=付呗(前端仍传 wechat
mini_pay_channel = models.CharField(
max_length=16,
blank=True,
default='wechat',
verbose_name='小程序收款通道',
help_text='wechat=微信直连fubei=付呗;默认 wechat不影响现网',
)
company_uuid = models.BinaryField(max_length=16, null=True, blank=True)
sort_order = models.IntegerField(default=0)
CreateTime = models.DateTimeField(auto_now_add=True)
@@ -62,6 +70,70 @@ class Club(QModel):
verbose_name = '俱乐部'
class ClubPaymentChannel(QModel):
"""俱乐部支付通道配置(可多条;同 channel 用 is_default 选主配置)。"""
CHANNEL_CHOICES = (
('wechat_jsapi', '微信直连-小程序JSAPI'),
('wechat_v3', '微信直连-V3转账/退款'),
('alipay_wap', '支付宝-手机网站'),
('fubei_wx_mini', '付呗-微信小程序'),
)
club_id = models.CharField(max_length=16, db_index=True, verbose_name='俱乐部ID')
channel = models.CharField(max_length=32, choices=CHANNEL_CHOICES, db_index=True)
name = models.CharField(max_length=64, verbose_name='配置名称', help_text='如:主付呗、备用微信')
enabled = models.IntegerField(default=1, verbose_name='1启用0停用')
is_default = models.BooleanField(default=True, verbose_name='同通道默认配置')
sort_order = models.IntegerField(default=0, verbose_name='前端展示顺序')
sandbox = models.BooleanField(default=False, verbose_name='沙箱/联调')
app_id = models.CharField(max_length=64, blank=True, default='', verbose_name='AppID/付呗ID')
mch_id = models.CharField(max_length=64, blank=True, default='', verbose_name='商户号')
api_key_enc = models.TextField(blank=True, default='', verbose_name='API密钥/付呗Secret(加密)')
private_key_enc = models.TextField(blank=True, default='', verbose_name='应用私钥(加密)')
public_key_enc = models.TextField(blank=True, default='', verbose_name='平台公钥(加密或明文)')
cert_serial_no = models.CharField(max_length=64, blank=True, default='')
private_key_path = models.CharField(max_length=255, blank=True, default='')
platform_cert_dir = models.CharField(max_length=255, blank=True, default='')
notify_url = models.CharField(max_length=512, blank=True, default='', verbose_name='异步回调URL')
return_url = models.CharField(max_length=512, blank=True, default='', verbose_name='同步返回URL')
gateway_url = models.CharField(max_length=255, blank=True, default='', verbose_name='API网关')
mini_app_id = models.CharField(
max_length=32, blank=True, default='',
verbose_name='支付用小程序AppID',
help_text='付呗/微信JSAPI空则回落 club.wx_appid',
)
extra_json = models.JSONField(blank=True, default=dict, verbose_name='扩展配置')
remark = models.CharField(max_length=255, blank=True, default='')
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'club_payment_channel'
verbose_name = '俱乐部支付通道'
unique_together = [['club_id', 'channel', 'name']]
indexes = [
models.Index(fields=['club_id', 'channel', 'enabled'], name='idx_cpc_club_ch_en'),
models.Index(fields=['club_id', 'is_default'], name='idx_cpc_club_default'),
]
class PayChannelLedger(QModel):
"""下单时记录实际支付通道,供回调路由与退款选用(前端无感知)。"""
out_trade_no = models.CharField(max_length=64, unique=True, db_index=True, verbose_name='商户订单号')
club_id = models.CharField(max_length=16, db_index=True, default='')
channel = models.CharField(max_length=32, db_index=True, verbose_name='通道')
channel_config_id = models.IntegerField(null=True, blank=True)
biz_type = models.CharField(max_length=16, blank=True, default='', verbose_name='order/czjilu')
provider_order_sn = models.CharField(max_length=64, blank=True, default='', verbose_name='通道侧订单号')
amount_yuan = models.DecimalField(max_digits=12, decimal_places=2, default=0)
CreateTime = models.DateTimeField(auto_now_add=True)
UpdateTime = models.DateTimeField(auto_now=True)
class Meta:
db_table = 'pay_channel_ledger'
verbose_name = '支付通道流水'
class UserWxOpenid(QModel):
"""微信 openid 与俱乐部用户绑定club_id + openid 唯一)。"""
club_id = models.CharField(max_length=16, db_index=True)

View File

@@ -0,0 +1,201 @@
"""付呗开放平台客户端:签名、小程序下单、退款、回调验签。"""
from __future__ import annotations
import hashlib
import json
import logging
import random
import string
import time
from decimal import Decimal, ROUND_HALF_UP
from typing import Any, Dict, Optional, Tuple
import requests
logger = logging.getLogger(__name__)
METHOD_MINA = 'openapi.payment.order.mina'
METHOD_REFUND = 'openapi.payment.order.refund'
def _yuan_str(amount) -> str:
d = Decimal(str(amount)).quantize(Decimal('0.01'), rounding=ROUND_HALF_UP)
return format(d, 'f')
def build_sign(params: Dict[str, Any], app_secret: str) -> str:
"""付呗签名:参数 ASCII 排序后 key=value&... 再无缝拼接 secretMD5 大写。"""
items = []
for k in sorted(params.keys()):
if k == 'sign':
continue
v = params[k]
if v is None or v == '':
continue
items.append(f'{k}={v}')
raw = '&'.join(items) + (app_secret or '')
return hashlib.md5(raw.encode('utf-8')).hexdigest().upper()
def verify_callback_sign(params: Dict[str, Any], app_secret: str) -> bool:
got = (params.get('sign') or '').strip().upper()
if not got:
return False
expect = build_sign(params, app_secret)
return got == expect
def _nonce(n: int = 24) -> str:
return ''.join(random.choices(string.ascii_letters + string.digits, k=n))
def call_fubei_api(
*,
gateway_url: str,
app_id: str,
app_secret: str,
method: str,
biz: Dict[str, Any],
timeout: int = 15,
) -> Dict[str, Any]:
"""调用付呗 gateway成功返回 data 字典;失败抛 Exception。"""
biz_content = json.dumps(biz, ensure_ascii=False, separators=(',', ':'))
payload = {
'app_id': app_id,
'method': method,
'format': 'json',
'sign_method': 'md5',
'nonce': _nonce(),
'version': '1.0',
'biz_content': biz_content,
}
payload['sign'] = build_sign(payload, app_secret)
url = (gateway_url or '').strip() or 'https://shq-api.51fubei.com/gateway'
logger.info('[FUBEI] method=%s app_id=%s biz_keys=%s', method, app_id, list(biz.keys()))
resp = requests.post(url, json=payload, timeout=timeout, headers={'Content-Type': 'application/json; charset=utf-8'})
try:
body = resp.json()
except Exception:
logger.error('[FUBEI] 非JSON响应 status=%s body=%s', resp.status_code, resp.text[:500])
raise Exception(f'付呗接口异常: HTTP {resp.status_code}')
result_code = body.get('result_code')
if str(result_code) not in ('200', '200.0'):
msg = body.get('result_message') or body.get('sub_code') or '付呗下单失败'
logger.warning('[FUBEI] 业务失败 method=%s code=%s msg=%s body=%s', method, result_code, msg, body)
raise Exception(str(msg))
data = body.get('data')
if isinstance(data, str):
try:
data = json.loads(data)
except Exception:
pass
if not isinstance(data, dict):
raise Exception('付呗返回 data 格式异常')
return data
def mina_prepay(
*,
gateway_url: str,
app_id: str,
app_secret: str,
merchant_order_sn: str,
sub_openid: str,
total_fee,
store_id: int,
call_back_url: str,
body: str = '',
attach: str = '',
) -> Dict[str, Any]:
"""小程序支付下单,返回含 sign_params / order_sn 等。"""
biz = {
'merchant_order_sn': merchant_order_sn,
'sub_openid': sub_openid,
'total_fee': float(_yuan_str(total_fee)),
'store_id': int(store_id),
}
if call_back_url:
biz['call_back_url'] = call_back_url
if body:
biz['body'] = (body or '')[:128]
if attach:
biz['attach'] = (attach or '')[:127]
return call_fubei_api(
gateway_url=gateway_url,
app_id=app_id,
app_secret=app_secret,
method=METHOD_MINA,
biz=biz,
)
def refund_order(
*,
gateway_url: str,
app_id: str,
app_secret: str,
merchant_order_sn: str,
merchant_refund_sn: str,
refund_money=None,
order_sn: str = '',
) -> Dict[str, Any]:
biz: Dict[str, Any] = {
'merchant_refund_sn': merchant_refund_sn,
}
if merchant_order_sn:
biz['merchant_order_sn'] = merchant_order_sn
if order_sn:
biz['order_sn'] = order_sn
if refund_money is not None:
biz['refund_money'] = float(_yuan_str(refund_money))
return call_fubei_api(
gateway_url=gateway_url,
app_id=app_id,
app_secret=app_secret,
method=METHOD_REFUND,
biz=biz,
)
def parse_callback_form(post_dict: Dict[str, Any]) -> Tuple[Dict[str, Any], Dict[str, Any]]:
"""解析付呗 form 回调,返回 (顶层参数含 sign, data 业务字典)。"""
params = {k: (v if not isinstance(v, list) else (v[0] if v else '')) for k, v in post_dict.items()}
# 兼容 QueryDict.dict()
data_raw = params.get('data') or ''
data: Dict[str, Any] = {}
if isinstance(data_raw, dict):
data = data_raw
elif isinstance(data_raw, str) and data_raw.strip():
try:
data = json.loads(data_raw)
except Exception:
# 偶发双重编码
try:
data = json.loads(data_raw.replace("'", '"'))
except Exception:
logger.error('[FUBEI_NOTIFY] data 解析失败: %s', data_raw[:300])
data = {}
return params, data
def extract_jsapi_pay_params(sign_params: Dict[str, Any]) -> Dict[str, str]:
"""从付呗 sign_params 抽出小程序 wx.requestPayment 所需字段。"""
if not sign_params:
raise Exception('付呗未返回支付签名包')
# 部分字段可能混有 store_id 等,只取调起支付字段
out = {
'appId': str(sign_params.get('appId') or sign_params.get('appid') or ''),
'timeStamp': str(sign_params.get('timeStamp') or sign_params.get('timestamp') or ''),
'nonceStr': str(sign_params.get('nonceStr') or sign_params.get('noncestr') or ''),
'package': str(sign_params.get('package') or ''),
'signType': str(sign_params.get('signType') or sign_params.get('sign_type') or 'MD5'),
'paySign': str(sign_params.get('paySign') or sign_params.get('pay_sign') or ''),
}
if not out['timeStamp'] or not out['package'] or not out['paySign']:
raise Exception('付呗签名包字段不完整')
return out

View File

@@ -0,0 +1,158 @@
"""付呗支付异步回调:验签后按订单/充值单履约(前端无感知)。"""
from __future__ import annotations
import json
import logging
import traceback
from django.http import HttpResponse
from django.views import View
from jituan.constants import FUBEI_BIZ_CZJILU, FUBEI_BIZ_ORDER
from jituan.services.club_payment_channel import get_fubei_config
from jituan.services import fubei_client
from jituan.services.mini_pay_router import get_pay_ledger
from jituan.services.wechat_pay import club_id_from_czjilu, club_id_from_order
logger = logging.getLogger(__name__)
class FubeiPayNotifyView(View):
"""
付呗异步通知。
GET回调 URL 有效性验证,必须返回小写 success。
POST支付成功通知。
路径:/dingdan/fubei-notify/
"""
def get(self, request):
return HttpResponse('success', content_type='text/plain')
def post(self, request):
try:
post = request.POST.dict() if request.POST else {}
if not post and request.body:
# 偶发 JSON
try:
post = json.loads(request.body.decode('utf-8'))
except Exception:
post = {}
params, data = fubei_client.parse_callback_form(post)
merchant_order_sn = str(data.get('merchant_order_sn') or '').strip()
result_code = str(params.get('result_code') or '')
logger.info(
'[FUBEI_NOTIFY] result_code=%s merchant_order_sn=%s trade_no=%s',
result_code, merchant_order_sn, data.get('trade_no') or data.get('platform_order_no'),
)
if not merchant_order_sn:
logger.warning('[FUBEI_NOTIFY] 缺少 merchant_order_sn')
return HttpResponse('fail', content_type='text/plain')
club_id = self._resolve_club_id(merchant_order_sn, data)
cfg = get_fubei_config(club_id)
if not cfg or not cfg.api_key:
logger.error('[FUBEI_NOTIFY] 无付呗密钥 club=%s order=%s', club_id, merchant_order_sn)
return HttpResponse('fail', content_type='text/plain')
if not fubei_client.verify_callback_sign(params, cfg.api_key):
logger.error('[FUBEI_NOTIFY] 验签失败 order=%s', merchant_order_sn)
return HttpResponse('fail', content_type='text/plain')
if result_code not in ('200', '200.0'):
logger.info('[FUBEI_NOTIFY] 非成功 result_code=%s order=%s', result_code, merchant_order_sn)
return HttpResponse('success', content_type='text/plain')
transaction_id = str(
data.get('trade_no')
or data.get('platform_order_no')
or data.get('order_sn')
or ''
)
total_fee = data.get('total_fee') or data.get('cash_fee') or data.get('order_price')
attach = str(data.get('attach') or '')
biz = self._biz_type(merchant_order_sn, attach)
if biz == FUBEI_BIZ_ORDER:
ok = self._fulfill_order(merchant_order_sn, transaction_id)
else:
ok = self._fulfill_czjilu(merchant_order_sn, total_fee, transaction_id)
return HttpResponse('success' if ok else 'fail', content_type='text/plain')
except Exception as exc:
logger.error('[FUBEI_NOTIFY] 异常: %s', exc)
logger.error(traceback.format_exc())
return HttpResponse('fail', content_type='text/plain')
def _resolve_club_id(self, merchant_order_sn: str, data: dict) -> str:
ledger = get_pay_ledger(merchant_order_sn)
if ledger and ledger.club_id:
return ledger.club_id
# 先试订单,再试充值单
try:
cid = club_id_from_order(merchant_order_sn)
if cid:
return cid
except Exception:
pass
try:
cid = club_id_from_czjilu(merchant_order_sn)
if cid:
return cid
except Exception:
pass
return 'xq'
def _biz_type(self, merchant_order_sn: str, attach: str) -> str:
ledger = get_pay_ledger(merchant_order_sn)
if ledger and ledger.biz_type:
return ledger.biz_type
if 'biz=order' in attach:
return FUBEI_BIZ_ORDER
if 'biz=czjilu' in attach:
return FUBEI_BIZ_CZJILU
# 启发式:点单 Order 表优先
try:
from orders.models import Order
if Order.query.filter(OrderID=merchant_order_sn).exists():
return FUBEI_BIZ_ORDER
except Exception:
pass
return FUBEI_BIZ_CZJILU
def _fulfill_order(self, out_trade_no: str, transaction_id: str) -> bool:
try:
from orders.views.payment import WechatPayNotifyView
view = WechatPayNotifyView()
view._handle_success(out_trade_no, transaction_id, None)
return True
except Exception as exc:
logger.error('[FUBEI_NOTIFY] 订单履约失败 %s: %s', out_trade_no, exc, exc_info=True)
return False
def _fulfill_czjilu(self, out_trade_no: str, total_fee, transaction_id: str) -> bool:
from products.models import Czjilu
from products.czjilu_types import (
CZJILU_PAID_STATUS,
is_czjilu_fulfillable_status,
)
from products.views.alipay_czjilu_notify import _fulfill_czjilu_by_leixing
order = Czjilu.query.filter(dingdan_id=out_trade_no).first()
if not order:
logger.error('[FUBEI_NOTIFY] 充值单不存在: %s', out_trade_no)
return False
if order.zhuangtai == CZJILU_PAID_STATUS:
return True
if not is_czjilu_fulfillable_status(order.zhuangtai):
logger.warning('[FUBEI_NOTIFY] 充值单终态不再履约 %s zhuangtai=%s', out_trade_no, order.zhuangtai)
return True
paid_amount = total_fee if total_fee not in (None, '') else order.jine
try:
_fulfill_czjilu_by_leixing(out_trade_no, order.leixing, paid_amount, source='fubei_callback')
return True
except Exception as exc:
logger.error('[FUBEI_NOTIFY] 充值履约失败 %s: %s', out_trade_no, exc, exc_info=True)
return False

View File

@@ -0,0 +1,152 @@
"""小程序「微信支付」通道路由:按俱乐部选择微信直连或付呗;前端仍传 pay_method=wechat。"""
from __future__ import annotations
import logging
from decimal import Decimal
from typing import Any, Dict, Optional
from django.conf import settings
from jituan.constants import (
CLUB_ID_DEFAULT,
FUBEI_BIZ_CZJILU,
FUBEI_BIZ_ORDER,
MINI_PAY_CHANNEL_FUBEI,
MINI_PAY_CHANNEL_WECHAT,
PAY_CHANNEL_FUBEI_WX_MINI,
)
from jituan.models import Club, PayChannelLedger
from jituan.services.club_payment_channel import get_fubei_config, invalidate_club_payment_cache
from jituan.services import fubei_client
logger = logging.getLogger(__name__)
def get_club_mini_pay_channel(club_id: Optional[str] = None) -> str:
"""返回 wechat / fubei缺省 wechat现网行为"""
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
club = Club.query.filter(club_id=cid).first()
if not club:
return MINI_PAY_CHANNEL_WECHAT
ch = (getattr(club, 'mini_pay_channel', None) or MINI_PAY_CHANNEL_WECHAT).strip().lower()
if ch == MINI_PAY_CHANNEL_FUBEI:
return MINI_PAY_CHANNEL_FUBEI
return MINI_PAY_CHANNEL_WECHAT
def set_club_mini_pay_channel(club_id: str, channel: str) -> None:
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
ch = (channel or '').strip().lower()
if ch not in (MINI_PAY_CHANNEL_WECHAT, MINI_PAY_CHANNEL_FUBEI):
raise ValueError('mini_pay_channel 仅支持 wechat / fubei')
club = Club.query.filter(club_id=cid).first()
if not club:
raise ValueError(f'俱乐部 {cid} 不存在')
club.mini_pay_channel = ch
club.save(update_fields=['mini_pay_channel'])
invalidate_club_payment_cache(cid)
def _default_fubei_notify_url(club_id: Optional[str] = None) -> str:
base = 'https://www.abas.asia'
club = Club.query.filter(club_id=(club_id or CLUB_ID_DEFAULT)).first()
if club and (club.h5_domain or '').strip():
base = club.h5_domain.strip().rstrip('/')
return f'{base}/hqhd/dingdan/fubei-notify/'
def _remember_ledger(
*,
out_trade_no: str,
club_id: str,
channel: str,
channel_config_id: Optional[int],
biz_type: str,
provider_order_sn: str,
amount_yuan,
) -> None:
row = PayChannelLedger.query.filter(out_trade_no=out_trade_no).first()
fields = {
'club_id': club_id or CLUB_ID_DEFAULT,
'channel': channel,
'channel_config_id': channel_config_id,
'biz_type': biz_type or '',
'provider_order_sn': provider_order_sn or '',
'amount_yuan': Decimal(str(amount_yuan or 0)),
}
if row:
for k, v in fields.items():
setattr(row, k, v)
row.save()
else:
PayChannelLedger.objects.create(out_trade_no=out_trade_no, **fields)
def get_pay_ledger(out_trade_no: str) -> Optional[PayChannelLedger]:
if not out_trade_no:
return None
return PayChannelLedger.query.filter(out_trade_no=out_trade_no).first()
def maybe_create_fubei_jsapi_params(
*,
club_id: Optional[str],
out_trade_no: str,
amount_yuan,
body: str,
openid: str,
notify_url: str = '',
biz_type: str = FUBEI_BIZ_ORDER,
) -> Optional[Dict[str, Any]]:
"""若俱乐部选用付呗则下单并返回 wx.requestPayment 参数;否则返回 None走原微信逻辑"""
cid = (club_id or '').strip() or CLUB_ID_DEFAULT
if get_club_mini_pay_channel(cid) != MINI_PAY_CHANNEL_FUBEI:
return None
cfg = get_fubei_config(cid)
if not cfg or not cfg.app_id or not cfg.api_key:
raise Exception('俱乐部已切换付呗收款,但付呗开放平台 ID/密钥未配置完整')
store_id = cfg.store_id
if store_id in (None, '', 0, '0'):
raise Exception('俱乐部已切换付呗收款,但门店 store_id 未配置')
if not openid:
raise Exception('付呗支付缺少用户 openid')
cb = (notify_url or '').strip() or (cfg.notify_url or '').strip() or _default_fubei_notify_url(cid)
# 强制走付呗专用回调(原微信 XML 回调无法解析付呗 form
if 'fubei-notify' not in cb:
cb = _default_fubei_notify_url(cid)
attach = f'biz={biz_type or FUBEI_BIZ_ORDER}'
data = fubei_client.mina_prepay(
gateway_url=cfg.gateway_url,
app_id=cfg.app_id,
app_secret=cfg.api_key,
merchant_order_sn=out_trade_no,
sub_openid=openid,
total_fee=amount_yuan,
store_id=int(store_id),
call_back_url=cb,
body=body or '',
attach=attach,
)
sign_params = data.get('sign_params') or {}
if isinstance(sign_params, str):
import json
sign_params = json.loads(sign_params)
pay_params = fubei_client.extract_jsapi_pay_params(sign_params)
_remember_ledger(
out_trade_no=out_trade_no,
club_id=cid,
channel=PAY_CHANNEL_FUBEI_WX_MINI,
channel_config_id=cfg.config_id,
biz_type=biz_type or FUBEI_BIZ_ORDER,
provider_order_sn=str(data.get('order_sn') or ''),
amount_yuan=amount_yuan,
)
logger.info(
'[FUBEI_PAY] club=%s out_trade_no=%s biz=%s order_sn=%s',
cid, out_trade_no, biz_type, data.get('order_sn'),
)
return pay_params

View File

@@ -0,0 +1,62 @@
"""按支付流水选择退款通道:付呗单走付呗,其余走原微信退款逻辑。"""
from __future__ import annotations
import logging
import random
import string
import time
from typing import Any, Dict, Optional
from jituan.constants import PAY_CHANNEL_FUBEI_WX_MINI
from jituan.services.club_payment_channel import get_fubei_config
from jituan.services import fubei_client
from jituan.services.mini_pay_router import get_pay_ledger
logger = logging.getLogger(__name__)
def _refund_sn(out_trade_no: str) -> str:
ts = str(int(time.time()))
rand = ''.join(random.choices('0123456789', k=4))
# 付呗 merchant_refund_sn 最长 32
base = f'{out_trade_no}R{ts}{rand}'
return base[:32]
def try_fubei_refund(
*,
out_trade_no: str,
amount_yuan,
club_id: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""
若该单曾走付呗,则发起付呗退款并返回 {'code': 0/非0, 'msg': ..., 'refund_id': ...}
非付呗单返回 None由调用方继续走原微信退款。
"""
ledger = get_pay_ledger(out_trade_no)
if not ledger or ledger.channel != PAY_CHANNEL_FUBEI_WX_MINI:
return None
cid = (club_id or ledger.club_id or '').strip()
cfg = get_fubei_config(cid)
if not cfg or not cfg.app_id or not cfg.api_key:
logger.error('[FUBEI_REFUND] 配置缺失 club=%s out_trade_no=%s', cid, out_trade_no)
return {'code': 500, 'msg': '付呗退款配置缺失,请检查俱乐部付呗通道'}
merchant_refund_sn = _refund_sn(out_trade_no)
try:
data = fubei_client.refund_order(
gateway_url=cfg.gateway_url,
app_id=cfg.app_id,
app_secret=cfg.api_key,
merchant_order_sn=out_trade_no,
merchant_refund_sn=merchant_refund_sn,
refund_money=amount_yuan,
order_sn=ledger.provider_order_sn or '',
)
refund_id = data.get('refund_sn') or merchant_refund_sn
logger.info('[FUBEI_REFUND] ok out_trade_no=%s refund=%s', out_trade_no, refund_id)
return {'code': 0, 'msg': 'success', 'refund_id': refund_id, 'raw': data}
except Exception as exc:
logger.error('[FUBEI_REFUND] fail out_trade_no=%s: %s', out_trade_no, exc, exc_info=True)
return {'code': 400, 'msg': str(exc)}

View File

@@ -86,6 +86,7 @@ from jituan.views_club_catalog import (
ClubShangpinLeixingListView,
)
from jituan.views_shenhe_manage import ClubKhgglAddView
from jituan.views_payment_channel import ClubPaymentChannelManageView
from jituan.views_identity_tag import (
IdentityTagBindListView,
IdentityTagBindView,
@@ -103,6 +104,7 @@ urlpatterns = [
path('auth/perm-debug', ClubPermDebugView.as_view(), name='jituan_perm_debug'),
path('auth/dashou-register', ClubDashouRegisterView.as_view(), name='jituan_dashou_register'),
path('houtai/club-manage', ClubManageView.as_view(), name='jituan_club_manage'),
path('houtai/payment-channel-manage', ClubPaymentChannelManageView.as_view(), name='jituan_payment_channel_manage'),
path('houtai/club-cert-upload', ClubCertUploadView.as_view(), name='jituan_club_cert_upload'),
path('houtai/admin-assignments', ClubAdminAssignmentListView.as_view(), name='jituan_admin_assignments'),
path('houtai/caiwu', ClubCaiwuView.as_view(), name='jituan_caiwu'),

View File

@@ -453,6 +453,8 @@ class ClubManageView(APIView):
'name', 'status', 'wx_appid', 'wx_secret',
# 收款商户
'mch_id', 'pay_app_id', 'mch_key', 'pay_cert_path', 'pay_key_path',
# 小程序收款通道wechat / fubei
'mini_pay_channel',
# 提现商户
'withdraw_mch_id', 'api_v3_key',
'cert_serial_no', 'private_key_path', 'platform_cert_dir',
@@ -549,6 +551,7 @@ class ClubManageView(APIView):
'mch_id': club.mch_id,
'pay_app_id': club.pay_app_id,
'withdraw_mch_id': getattr(club, 'withdraw_mch_id', '') or '',
'mini_pay_channel': getattr(club, 'mini_pay_channel', None) or 'wechat',
'goeasy_appkey': club.goeasy_appkey,
'template_id': club.template_id,
'sort_order': club.sort_order,
@@ -611,6 +614,10 @@ class ClubManageView(APIView):
val = request.data[field]
if field in ('mch_key', 'api_v3_key', 'wx_secret'):
val = (val or '').strip()
if field == 'mini_pay_channel':
val = (val or 'wechat').strip().lower()
if val not in ('wechat', 'fubei'):
return Response({'code': 400, 'msg': 'mini_pay_channel 仅支持 wechat/fubei'})
setattr(club, field, val)
update_fields.append(field)
if 'mch_key' in request.data:

View File

@@ -0,0 +1,241 @@
"""俱乐部支付通道后台管理(付呗配置 CRUD + 选用 wechat/fubei"""
from __future__ import annotations
import logging
from rest_framework.parsers import JSONParser
from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from backend.utils import verify_kefu_permission
from jituan.constants import (
CLUB_ID_DEFAULT,
MINI_PAY_CHANNEL_FUBEI,
MINI_PAY_CHANNEL_WECHAT,
PAY_CHANNEL_FUBEI_WX_MINI,
)
from jituan.models import Club, ClubPaymentChannel
from jituan.services.club_payment_channel import (
encrypt_secret_field,
invalidate_club_payment_cache,
)
from jituan.services.mini_pay_router import get_club_mini_pay_channel, set_club_mini_pay_channel
from jituan.services.permissions import can_manage_admin_assignments
logger = logging.getLogger(__name__)
def _mask_secret(plain: str) -> str:
s = (plain or '').strip()
if not s:
return ''
if len(s) <= 8:
return '****'
return s[:4] + '****' + s[-4:]
def _serialize_channel(row: ClubPaymentChannel, reveal_secret: bool = False) -> dict:
from jituan.services.club_payment_channel import decrypt_secret_field
secret = decrypt_secret_field(row.api_key_enc) if row.api_key_enc else ''
extra = dict(row.extra_json or {})
return {
'id': row.id,
'club_id': row.club_id,
'channel': row.channel,
'name': row.name,
'enabled': row.enabled,
'is_default': row.is_default,
'sort_order': row.sort_order,
'sandbox': row.sandbox,
'app_id': row.app_id,
'mch_id': row.mch_id,
'mini_app_id': row.mini_app_id,
'store_id': extra.get('store_id') or extra.get('fubei_store_id') or '',
'merchant_id': extra.get('merchant_id') or row.mch_id or '',
'api_key_masked': _mask_secret(secret),
'api_key': secret if reveal_secret else '',
'api_key_configured': bool(secret),
'notify_url': row.notify_url,
'gateway_url': row.gateway_url,
'remark': row.remark,
'extra_json': extra,
}
class ClubPaymentChannelManageView(APIView):
"""POST /jituan/houtai/payment-channel-manage
actions:
- get: 当前俱乐部 mini_pay_channel + 付呗通道列表
- set_mini_pay_channel: {mini_pay_channel: wechat|fubei}
- save_fubei: 新增或更新付呗配置(按 id 或 name
- set_default / enable / disable
- delete
"""
permission_classes = [IsAuthenticated]
parser_classes = [JSONParser]
def post(self, request):
try:
username = (request.data.get('phone') or request.data.get('username') or '').strip()
if not username:
return Response({'code': 401, 'msg': '缺少账号'}, status=401)
kefu_obj, permissions = verify_kefu_permission(request, username)
if kefu_obj is None:
return permissions
if not can_manage_admin_assignments(request.user):
return Response({'code': 403, 'msg': '仅集团超管可管理支付通道'}, status=403)
action = (request.data.get('action') or 'get').strip()
club_id = (request.data.get('club_id') or '').strip() or CLUB_ID_DEFAULT
club = Club.query.filter(club_id=club_id).first()
if not club:
return Response({'code': 1, 'msg': f'俱乐部 {club_id} 不存在'})
if action == 'get':
rows = ClubPaymentChannel.query.filter(
club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI,
).order_by('-is_default', 'sort_order', 'id')
return Response({
'code': 0,
'data': {
'club_id': club_id,
'mini_pay_channel': get_club_mini_pay_channel(club_id),
'fubei_channels': [_serialize_channel(r) for r in rows],
},
})
if action == 'set_mini_pay_channel':
ch = (request.data.get('mini_pay_channel') or '').strip().lower()
try:
set_club_mini_pay_channel(club_id, ch)
except ValueError as exc:
return Response({'code': 400, 'msg': str(exc)})
if ch == MINI_PAY_CHANNEL_FUBEI:
fb = ClubPaymentChannel.query.filter(
club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI, enabled=1,
).first()
if not fb:
return Response({
'code': 0,
'msg': '已切换为付呗,但尚未配置付呗通道,请先保存付呗配置',
'data': {'mini_pay_channel': ch},
'warning': True,
})
return Response({
'code': 0,
'msg': '已切换小程序收款通道',
'data': {'mini_pay_channel': ch},
})
if action == 'save_fubei':
return self._save_fubei(request, club_id)
if action in ('enable', 'disable', 'set_default', 'delete'):
row_id = request.data.get('id')
row = ClubPaymentChannel.query.filter(
id=row_id, club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI,
).first()
if not row:
return Response({'code': 1, 'msg': '配置不存在'})
if action == 'delete':
row.delete()
invalidate_club_payment_cache(club_id, PAY_CHANNEL_FUBEI_WX_MINI)
return Response({'code': 0, 'msg': '已删除'})
if action == 'enable':
row.enabled = 1
row.save(update_fields=['enabled'])
elif action == 'disable':
row.enabled = 0
row.save(update_fields=['enabled'])
elif action == 'set_default':
ClubPaymentChannel.objects.filter(
club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI, is_default=True,
).exclude(id=row.id).update(is_default=False)
row.is_default = True
row.enabled = 1
row.save(update_fields=['is_default', 'enabled'])
invalidate_club_payment_cache(club_id, PAY_CHANNEL_FUBEI_WX_MINI)
return Response({'code': 0, 'msg': 'ok', 'data': _serialize_channel(row)})
return Response({'code': 400, 'msg': f'未知 action: {action}'})
except Exception as exc:
logger.error('payment-channel-manage 异常: %s', exc, exc_info=True)
return Response({'code': 500, 'msg': str(exc)})
def _save_fubei(self, request, club_id: str):
name = (request.data.get('name') or '主付呗').strip() or '主付呗'
row_id = request.data.get('id')
app_id = (request.data.get('app_id') or '').strip()
api_key = request.data.get('api_key')
store_id = request.data.get('store_id')
merchant_id = (request.data.get('merchant_id') or request.data.get('mch_id') or '').strip()
mini_app_id = (request.data.get('mini_app_id') or '').strip()
remark = (request.data.get('remark') or '').strip()
if not app_id:
return Response({'code': 400, 'msg': '请填写付呗开放平台 app_id'})
if store_id in (None, ''):
return Response({'code': 400, 'msg': '请填写门店 store_id'})
row = None
if row_id:
row = ClubPaymentChannel.query.filter(
id=row_id, club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI,
).first()
if not row:
row = ClubPaymentChannel.query.filter(
club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI, name=name,
).first()
creating = row is None
if creating:
row = ClubPaymentChannel(
club_id=club_id,
channel=PAY_CHANNEL_FUBEI_WX_MINI,
name=name,
enabled=1,
is_default=True,
)
row.app_id = app_id
row.mch_id = merchant_id
row.mini_app_id = mini_app_id
row.remark = remark
if api_key is not None and str(api_key).strip():
row.api_key_enc = encrypt_secret_field(str(api_key).strip())
elif creating:
return Response({'code': 400, 'msg': '新建付呗配置必须填写接口密钥'})
extra = dict(row.extra_json or {})
try:
extra['store_id'] = int(store_id)
except (TypeError, ValueError):
return Response({'code': 400, 'msg': 'store_id 必须是数字'})
if merchant_id:
extra['merchant_id'] = merchant_id
row.extra_json = extra
# 设为默认时取消同通道其它默认
if request.data.get('is_default', True):
ClubPaymentChannel.objects.filter(
club_id=club_id, channel=PAY_CHANNEL_FUBEI_WX_MINI, is_default=True,
).exclude(id=getattr(row, 'id', None) or 0).update(is_default=False)
row.is_default = True
row.enabled = 1
row.save()
invalidate_club_payment_cache(club_id, PAY_CHANNEL_FUBEI_WX_MINI)
# 可选:保存后同时切到付呗
if request.data.get('activate'):
set_club_mini_pay_channel(club_id, MINI_PAY_CHANNEL_FUBEI)
return Response({
'code': 0,
'msg': '付呗配置已保存',
'data': {
'channel': _serialize_channel(row),
'mini_pay_channel': get_club_mini_pay_channel(club_id),
},
})

View File

@@ -12,6 +12,7 @@ from .views import CreateOrderView, WechatPayNotifyView, AlipayPayNotifyView, al
ShangjiaFakuanApplyView, ShangjiaFakuaiXiugaiView, ShangjiaShujuTongjiView, LianTongDuiHuaZhunBeiView, \
LianTongPeiDuiDingDanLieBiaoView, LianTongDingDanZhuangTaiView, LianTongDingDanZhuangTaiPiLiangView, \
ZhiDingChaxunView, ZhiDingHuiFuView
from jituan.services.fubei_notify import FubeiPayNotifyView
urlpatterns = [
@@ -19,6 +20,7 @@ urlpatterns = [
#path('pay-notify/', WechatPayNotifyView.as_view(), name='老板下单微信支付回调'),
path('pay-notify/', csrf_exempt(WechatPayNotifyView.as_view()), name='老板下单微信支付回调'),
path('alipay-notify/', csrf_exempt(AlipayPayNotifyView.as_view()), name='老板下单支付宝支付回调'),
path('fubei-notify/', csrf_exempt(FubeiPayNotifyView.as_view()), name='付呗支付回调'),
path('alipay-pay/', csrf_exempt(alipay_pay_page), name='支付宝H5中转页'),
path('shibai', PaymentFailView.as_view(), name='老板支付失败接口'),
path('fucha', PaymentVerifyView.as_view(), name='老板支付成功复查接口'),

View File

@@ -1305,6 +1305,18 @@ class AdTongYiTuiKuanPingTai(APIView):
:return: {'success': True/False, 'msg': '消息', 'refund_id': '退款单号'}
"""
try:
from jituan.services.pay_refund_router import try_fubei_refund
from jituan.services.wechat_pay import club_id_from_order
fb = try_fubei_refund(
out_trade_no=out_trade_no,
amount_yuan=jine,
club_id=club_id_from_order(out_trade_no),
)
if fb is not None:
if fb.get('code') == 0:
return {'success': True, 'msg': fb.get('msg') or 'success', 'refund_id': fb.get('refund_id')}
return {'success': False, 'msg': fb.get('msg') or '付呗退款失败'}
# 检查配置
required_settings = ['WEIXIN_APPID', 'WEIXIN_MCHID', 'WEIXIN_SHANGHUMIYAO', 'WEIXIN_CERT_PATH', 'WEIXIN_KEY_PATH']
for setting in required_settings:

View File

@@ -1035,6 +1035,8 @@ class CreateOrderView(APIView):
调用微信支付统一下单接口,生成支付参数
"""
try:
from jituan.constants import FUBEI_BIZ_ORDER
from jituan.services.mini_pay_router import maybe_create_fubei_jsapi_params
from jituan.services.wechat_pay import get_wechat_v2_config
pay_cfg = get_wechat_v2_config(club_id)
APPID = pay_cfg['appid']
@@ -1042,6 +1044,18 @@ class CreateOrderView(APIView):
KEY = pay_cfg['key']
NOTIFY_URL = getattr(settings, 'WEIXIN_NOTIFY_URL', '')
fb_params = maybe_create_fubei_jsapi_params(
club_id=club_id,
out_trade_no=dingdanid,
amount_yuan=jine,
body=beizhu,
openid=openid,
notify_url=NOTIFY_URL,
biz_type=FUBEI_BIZ_ORDER,
)
if fb_params is not None:
return fb_params
# NOAUTH 诊断日志:打印生效的 club_id / appid / mchid / openid 指纹
_oid_preview = (openid or '')[:6] + '...' + (openid or '')[-4:] if (openid or '') else '(empty)'
logger.warning(

View File

@@ -232,6 +232,21 @@ class ShangjiaChongzhi(APIView):
else:
raise Exception('未知的支付类型')
from jituan.constants import FUBEI_BIZ_CZJILU
from jituan.services.mini_pay_router import maybe_create_fubei_jsapi_params
_club = resolve_club_id_for_write(self.request, self.request.user)
fb_params = maybe_create_fubei_jsapi_params(
club_id=_club,
out_trade_no=dingdanid,
amount_yuan=jine,
body=PAY_DESCRIPTION,
openid=openid,
notify_url=NOTIFY_URL,
biz_type=FUBEI_BIZ_CZJILU,
)
if fb_params is not None:
return fb_params
# 生成随机字符串
nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=32))

View File

@@ -270,6 +270,20 @@ class HuiyuanGoumai(APIView):
else:
raise Exception('未知的支付类型')
from jituan.constants import FUBEI_BIZ_CZJILU
from jituan.services.mini_pay_router import maybe_create_fubei_jsapi_params
fb_params = maybe_create_fubei_jsapi_params(
club_id=pay_club,
out_trade_no=dingdanid,
amount_yuan=jine,
body=PAY_DESCRIPTION,
openid=openid,
notify_url=NOTIFY_URL,
biz_type=FUBEI_BIZ_CZJILU,
)
if fb_params is not None:
return fb_params
# 生成随机字符串
nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=32))

View File

@@ -213,6 +213,21 @@ class JifenBuchong(APIView):
else:
raise Exception('未知的支付类型')
from jituan.constants import FUBEI_BIZ_CZJILU
from jituan.services.mini_pay_router import maybe_create_fubei_jsapi_params
_club = resolve_club_id_for_write(self.request, self.request.user)
fb_params = maybe_create_fubei_jsapi_params(
club_id=_club,
out_trade_no=dingdanid,
amount_yuan=jine,
body=PAY_DESCRIPTION,
openid=openid,
notify_url=NOTIFY_URL,
biz_type=FUBEI_BIZ_CZJILU,
)
if fb_params is not None:
return fb_params
# 生成随机字符串
nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=32))

View File

@@ -227,6 +227,21 @@ class YajinGoumai(APIView):
else:
raise Exception('未知的支付类型')
from jituan.constants import FUBEI_BIZ_CZJILU
from jituan.services.mini_pay_router import maybe_create_fubei_jsapi_params
_club = resolve_club_id_for_write(self.request, self.request.user)
fb_params = maybe_create_fubei_jsapi_params(
club_id=_club,
out_trade_no=dingdanid,
amount_yuan=jine,
body=PAY_DESCRIPTION,
openid=openid,
notify_url=NOTIFY_URL,
biz_type=FUBEI_BIZ_CZJILU,
)
if fb_params is not None:
return fb_params
# 生成随机字符串
nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=32))

View File

@@ -200,6 +200,15 @@ class ShopRefundView(APIView):
# ========== 微信退款 ==========
def _call_wechat_refund(self, order):
"""调用微信支付退款接口"""
from jituan.services.pay_refund_router import try_fubei_refund
fb = try_fubei_refund(
out_trade_no=order.OrderID,
amount_yuan=order.Amount or 0,
club_id=getattr(order, 'ClubID', None),
)
if fb is not None:
return fb
appid = getattr(settings, 'WEIXIN_APPID', '')
mch_id = getattr(settings, 'WEIXIN_MCHID', '')
key = getattr(settings, 'WEIXIN_SHANGHUMIYAO', '')

View File

@@ -858,6 +858,16 @@ class KefuPlatformRefundView(APIView):
:param transaction_id: 微信支付订单号(优先使用)
:return: {'code': 0, 'msg': '成功', 'refund_id': 'xxx'} 或 {'code': 非0, 'msg': '错误信息'}
"""
from jituan.services.pay_refund_router import try_fubei_refund
from jituan.services.wechat_pay import club_id_from_order
fb = try_fubei_refund(
out_trade_no=dingdan_id,
amount_yuan=jine,
club_id=club_id_from_order(dingdan_id),
)
if fb is not None:
return fb
# 获取配置
appid = getattr(settings, 'WEIXIN_APPID', '')
mch_id = getattr(settings, 'WEIXIN_MCHID', '')

View File

@@ -233,6 +233,20 @@ class FaKuanPayView(APIView):
else:
raise Exception('未知的支付类型')
from jituan.constants import FUBEI_BIZ_CZJILU
from jituan.services.mini_pay_router import maybe_create_fubei_jsapi_params
fb_params = maybe_create_fubei_jsapi_params(
club_id=pay_club,
out_trade_no=dingdanid,
amount_yuan=jine,
body=PAY_DESCRIPTION,
openid=openid,
notify_url=NOTIFY_URL,
biz_type=FUBEI_BIZ_CZJILU,
)
if fb_params is not None:
return fb_params
nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=32))
total_fee = yuan_to_fen(jine)
trade_type = 'JSAPI'
@@ -720,6 +734,21 @@ class KaohePayView(APIView):
else:
raise Exception('未知支付类型')
from jituan.constants import FUBEI_BIZ_CZJILU
from jituan.services.mini_pay_router import maybe_create_fubei_jsapi_params
_club = resolve_club_id_for_write(self.request, self.request.user)
fb_params = maybe_create_fubei_jsapi_params(
club_id=_club,
out_trade_no=dingdanid,
amount_yuan=jine,
body=PAY_DESCRIPTION,
openid=openid,
notify_url=NOTIFY_URL,
biz_type=FUBEI_BIZ_CZJILU,
)
if fb_params is not None:
return fb_params
nonce_str = ''.join(random.choices(string.ascii_letters + string.digits, k=32))
total_fee = yuan_to_fen(jine)
trade_type = 'JSAPI'