feat(purchase): 实时购买(选A购买即记账)+续费延长+到期门禁+我的权益

核心链路:
- purchase_realtime:算价→余额预检→计费写用量→实时复式记账→开权益,失败回滚
- _grant_subscription 续费修复:未到期从 end_date 顺延不丢剩余天数,已到期重算
- check_subscription_valid 到期门禁:挂到 execute_product,到期/未购买阻断使用
- get_my_subscriptions 我的权益列表(到期状态)
- UI:purchase_confirm 确认页(余额+容量/月数输入)、purchase_realtime 支付、
  my_subscriptions 权益列表+续费、usermenu.ui 挂菜单
This commit is contained in:
ymq 2026-08-27 10:40:54 +08:00
parent 29f1c1fc92
commit 167d06cd93
7 changed files with 641 additions and 6 deletions

View File

@ -37,6 +37,28 @@ async def purchase_product(product_id, quantity=1, purchase_data=None, user_id=N
return await manager.purchase_product(product_id, quantity, purchase_data, user_id)
async def purchase_realtime(product_id=None, product_code=None, user_id=None,
user_org_id=None, charge_mode=None, quantity=1,
storage_gb=0, valid_months=1):
"""实时购买:算价→余额预检→计费→实时记账→开权益(选 A 购买即记账)。"""
manager = get_manager()
return await manager.purchase_realtime(product_id, product_code, user_id,
user_org_id, charge_mode, quantity,
storage_gb, valid_months)
async def get_my_subscriptions(user_org_id=None):
"""我的权益列表(订阅 + 到期状态)。"""
manager = get_manager()
return await manager.get_my_subscriptions(user_org_id)
async def check_subscription_valid(product_id, user_org_id):
"""到期门禁:订阅型产品使用前校验未到期。到期/未购买返回 valid=False。"""
manager = get_manager()
return await manager.check_subscription_valid(product_id, user_org_id)
async def use_product(product_id, order_id=None, use_data=None, user_id=None):
"""Use a product via standardized interface."""
manager = get_manager()
@ -141,6 +163,9 @@ def load_product_management():
env.get_product_brief = get_product_brief
env.get_product_detail = get_product_detail
env.purchase_product = purchase_product
env.purchase_realtime = purchase_realtime
env.get_my_subscriptions = get_my_subscriptions
env.check_subscription_valid = check_subscription_valid
env.use_product = use_product
env.get_category_tree = get_category_tree
env.get_products_by_category = get_products_by_category

View File

@ -1355,6 +1355,14 @@ class ProductManager:
if not user_org_id:
user_org_id = self._get_current_org_id()
# 到期门禁:订阅型产品(账号/存储)使用/开通前校验订阅未到期。
# 到期 → 阻断使用,须续费(走 purchase_realtime后恢复。
if product.get('product_type') in ('account', 'workspace_storage'):
gate = await self.check_subscription_valid(product.get('id'), user_org_id)
if not gate.get('valid'):
return {'success': False, 'message': gate.get('reason', '订阅不可用'),
'status': 'EXPIRED', 'end_date': gate.get('end_date', '')}
result = await fn(ref_id, user_id, user_org_id, request_data or {})
if not isinstance(result, dict) or not result.get('success'):
return result
@ -1372,13 +1380,14 @@ class ProductManager:
async def _grant_subscription(self, product, entitlement, user_id, user_org_id):
"""写 product_subscription订阅有效期 + 配额(账号工作空间数/存储容量GB
幂等同一产品+机构已有活跃订阅则更新有效期与配额不重复建
幂等同一产品+机构已有活跃订阅则延长有效期与配额不重复建
续费语义现有订阅未到期时从 end_date 起顺延不丢剩余天数
已到期则从今天重新起算
"""
dbname = self._get_dbname()
now = time.strftime('%Y-%m-%d %H:%M:%S')
today = datetime.date.today()
days = int(entitlement.get('duration_days', 0) or 0)
end_date = (today + datetime.timedelta(days=days)).isoformat() if days else today.isoformat()
quota_total = 0.0
quota_unit = entitlement.get('quota_unit', '')
@ -1395,16 +1404,24 @@ class ProductManager:
sub_id = getID()
async with DBPools().sqlorContext(dbname) as sor:
existing = await sor.sqlExe(
"SELECT id FROM product_subscription WHERE product_id=${pid}$ "
"SELECT id, end_date FROM product_subscription WHERE product_id=${pid}$ "
"AND user_org_id=${org}$ AND status='1' LIMIT 1",
{'pid': product.get('id'), 'org': user_org_id})
if existing:
sub_id = existing[0].id
# 续费:未到期从 end_date 顺延,已到期从今天重算
try:
old_end = datetime.date.fromisoformat(str(existing[0].end_date)[:10])
except Exception:
old_end = today
base = old_end if old_end >= today else today
end_date = (base + datetime.timedelta(days=days)).isoformat()
await sor.U('product_subscription', {
'id': sub_id, 'start_date': today.isoformat(),
'id': sub_id,
'end_date': end_date, 'quota_total': quota_total,
'quota_unit': quota_unit, 'updated_at': now})
else:
end_date = (today + datetime.timedelta(days=days)).isoformat() if days else today.isoformat()
await sor.C('product_subscription', {
'id': sub_id, 'product_id': product.get('id'),
'user_id': user_id, 'user_org_id': user_org_id,
@ -1417,6 +1434,240 @@ class ProductManager:
'created_at': now, 'updated_at': now})
return sub_id
# ═══════════════════════════════════════════════════════════════
# 实时购买(选 A购买即记账
# ═══════════════════════════════════════════════════════════════
async def purchase_realtime(self, product_id=None, product_code=None,
user_id=None, user_org_id=None,
charge_mode=None, quantity=1,
storage_gb=0, valid_months=1):
"""实时购买:点击支付 → 算价 → 余额预检 → 计费写用量 → 实时复式记账 → 开权益。
账号产品accountcharge_mode(trial/month/year) 决定有效期与定价
存储产品workspace_storagestorage_gb × valid_months 直购买断
任一步失败整体回滚删除已写用量记录保证不落半截账
返回 {'success','message','orderid','amount','subscription_id','end_date'}
"""
env = ServerEnv()
dbname = self._get_dbname()
iface, product, err = await self._get_product_interface(product_id, product_code)
if err:
return {'success': False, 'message': err}
product = product or {}
product_type = product.get('product_type', '')
ref_id, err = self._get_ref_id(product)
if err:
return {'success': False, 'message': err}
if not user_id:
user_id = await env.get_user()
if not user_org_id:
user_org_id = await env.get_userorgid() if hasattr(env, 'get_userorgid') \
else self._get_current_org_id()
user_org_id = user_org_id or '0'
quantity = int(quantity or 1)
storage_gb = float(storage_gb or 0)
valid_months = max(1, int(valid_months or 1))
usage_rec = None
charging_module = None # 记录计费来源,失败时回滚用
try:
if product_type == 'account':
# 解析 charge_mode未传则取该规格缺省定价映射
charge_mode, valid_days = await self._resolve_account_charge(ref_id, charge_mode)
from account_resource.init import account_charging as _acc_charging
charging_module = 'account'
usage_rec = await _acc_charging(
ref_id, charge_mode, user_id, user_org_id,
quantity=quantity)
duration_days = int(valid_days or 0) * quantity
entitlement = await self._build_account_entitlement(ref_id, charge_mode, duration_days)
usage_data = {'charge_mode': charge_mode, 'duration': quantity}
elif product_type == 'workspace_storage':
if storage_gb <= 0:
return {'success': False, 'message': '请填写购买容量(GB)'}
from storage_resource.init import storage_direct_charging as _sto_charging
charging_module = 'storage'
usage_rec = await _sto_charging(
ref_id, storage_gb, user_id, user_org_id,
valid_months=valid_months)
duration_days = 30 * valid_months
entitlement = {'storage_gb': storage_gb, 'quota_unit': 'GB',
'duration_days': duration_days}
usage_data = {'meter_mode': 'direct', 'storage_gb': storage_gb,
'valid_months': valid_months}
else:
return {'success': False,
'message': f'产品类型({product_type})暂不支持实时购买'}
# ── 算价(售价,含客户折扣)用于余额预检 ──
cost = await self.calculate_product_cost(
product_id=product.get('id'), usage_data=usage_data,
user_org_id=user_org_id)
sell_amount = float((cost or {}).get('amount', 0) or 0)
if not (cost or {}).get('success', True):
raise Exception((cost or {}).get('message', '算价失败'))
# ── 余额预检:客户资金账户余额 ≥ 售价 ──
balance = await self._check_balance(user_org_id)
if balance is None:
raise Exception('账户未开通,请先完成开户')
if balance < sell_amount:
raise Exception(f'余额不足:可用 {balance:.2f},应付 {sell_amount:.2f},请先充值')
# ── 实时复式记账(内部写 biz_order + 分录,余额不足会抛 AccountOverDraw──
rec_obj = DictObject(**usage_rec) if isinstance(usage_rec, dict) else usage_rec
if charging_module == 'account':
acc = await self.account_resource_accounting(rec_obj)
else:
acc = await self.storage_resource_accounting(rec_obj)
if not acc or not acc.get('success'):
raise Exception(f'记账失败: {acc}')
# ── 开权益:写订阅(配额 + 到期日),续费自动顺延 ──
sub_id = await self._grant_subscription(product, entitlement, user_id, user_org_id)
end_date = await self._get_subscription_end(sub_id)
return {'success': True, 'message': '购买成功,已实时记账',
'orderid': acc.get('orderid', ''),
'amount': round(float(acc.get('customer_amount', sell_amount)), 2),
'subscription_id': sub_id, 'end_date': end_date,
'product_name': product.get('product_name', '')}
except Exception as e:
exception(f'purchase_realtime failed: {e}')
# 回滚:删除已写的待记账用量记录,避免残留半截账
if usage_rec is not None:
try:
rid = usage_rec.get('id') if isinstance(usage_rec, dict) \
else getattr(usage_rec, 'id', '')
tbl = 'acctres_usage' if charging_module == 'account' else 'storres_usage'
async with DBPools().sqlorContext(dbname) as sor:
await sor.D(tbl, {'id': rid})
except Exception:
pass
return {'success': False, 'message': str(e)}
async def _resolve_account_charge(self, spec_id, charge_mode):
"""解析账号 charge_mode 与 valid_days未传则取缺省映射"""
dbname = self._get_dbname()
cond = "spec_id=${sid}$ AND status='active'"
ns = {'sid': spec_id}
if charge_mode:
cond += " AND charge_mode=${cm}$"
ns['cm'] = charge_mode
else:
cond += " AND is_default='1'"
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
f"SELECT charge_mode, valid_days FROM acctres_pricing_map WHERE {cond}", ns)
if recs:
return recs[0].charge_mode, int(recs[0].valid_days or 0)
# 兜底
return (charge_mode or 'month'), {'trial': 7, 'month': 30, 'year': 365}.get(charge_mode or 'month', 30)
async def _build_account_entitlement(self, spec_id, charge_mode, duration_days):
"""账号权益:有效期 + 规格配额(工作空间数/容量/并发/成员)。"""
env = ServerEnv()
ent = {'duration_days': duration_days, 'charge_mode': charge_mode}
try:
spec = await env.get_account_spec(spec_id)
if spec:
ent['workspace_max'] = int(getattr(spec, 'workspace_max', 0) or 0)
ent['workspace_gb'] = float(getattr(spec, 'workspace_gb', 0) or 0)
ent['concurrent_task'] = int(getattr(spec, 'concurrent_task', 0) or 0)
ent['member_max'] = int(getattr(spec, 'member_max', 0) or 0)
except Exception:
pass
return ent
async def _check_balance(self, user_org_id):
"""查客户资金账户余额。未开户返回 None。"""
env = ServerEnv()
try:
acc_dbname = env.get_module_dbname('accounting')
async with DBPools().sqlorContext(acc_dbname) as sor:
return await env.getCustomerBalance(sor, user_org_id)
except Exception as e:
debug(f'_check_balance failed: {e}')
return None
async def _get_subscription_end(self, sub_id):
"""读订阅到期日(供购买结果展示)。"""
dbname = self._get_dbname()
try:
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT end_date FROM product_subscription WHERE id=${id}$",
{'id': sub_id})
if recs:
return str(recs[0].end_date)[:10]
except Exception:
pass
return ''
async def get_my_subscriptions(self, user_org_id=None):
"""我的权益列表:当前机构所有订阅 + 到期状态。"""
dbname = self._get_dbname()
if not user_org_id:
env = ServerEnv()
user_org_id = await env.get_userorgid() if hasattr(env, 'get_userorgid') \
else self._get_current_org_id()
today = datetime.date.today().isoformat()
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"""SELECT s.*, p.product_name, p.product_code, p.product_type
FROM product_subscription s
LEFT JOIN product p ON s.product_id = p.id
WHERE s.user_org_id=${org}$ AND s.status='1'
ORDER BY s.end_date DESC""", {'org': user_org_id})
result = []
for r in recs:
end = str(getattr(r, 'end_date', '') or '')[:10]
expired = end < today if end else True
result.append({
'id': getattr(r, 'id', ''),
'product_name': getattr(r, 'product_name', ''),
'product_code': getattr(r, 'product_code', ''),
'product_type': getattr(r, 'product_type', ''),
'product_id': getattr(r, 'product_id', ''),
'quota_total': float(getattr(r, 'quota_total', 0) or 0),
'quota_used': float(getattr(r, 'quota_used', 0) or 0),
'quota_unit': getattr(r, 'quota_unit', ''),
'start_date': str(getattr(r, 'start_date', '') or '')[:10],
'end_date': end,
'expired': expired,
'status_text': '已到期' if expired else '生效中',
})
return result
# ═══════════════════════════════════════════════════════════════
# 到期检查
# ═══════════════════════════════════════════════════════════════
async def check_subscription_valid(self, product_id, user_org_id):
"""到期门禁:订阅型产品使用前校验订阅未到期。
返回 {'valid': bool, 'reason': str, 'end_date': str}
valid=False 时调用方必须阻断使用动作无订阅记录视为未购买阻断
"""
dbname = self._get_dbname()
today = datetime.date.today().isoformat()
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT end_date FROM product_subscription WHERE product_id=${pid}$ "
"AND user_org_id=${org}$ AND status='1' ORDER BY end_date DESC LIMIT 1",
{'pid': product_id, 'org': user_org_id})
if not recs:
return {'valid': False, 'reason': '未购买该产品,请先购买', 'end_date': ''}
end = str(getattr(recs[0], 'end_date', '') or '')[:10]
if not end or end < today:
return {'valid': False, 'reason': f'订阅已到期({end}),请续费后使用',
'end_date': end}
return {'valid': True, 'reason': '', 'end_date': end}
async def execute_product_stream(self, product_id=None, product_code=None,
request_data=None, user_id=None,
user_org_id=None):

View File

@ -81,10 +81,10 @@ else:
'event': 'click',
'actiontype': 'urlwidget',
'target': 'PopupWindow',
'popup_options': {'title': '购买结果', 'cwidth': 20, 'cheight': 8},
'popup_options': {'title': '确认购买', 'cwidth': 36, 'cheight': 24},
'options': {
'method': 'POST',
'url': '{{entire_url("./api/purchase.dspy")}}',
'url': '{{entire_url("./api/purchase_confirm.dspy")}}',
'params': {'product_id': p.id}
}
}

View File

@ -0,0 +1,119 @@
# 我的权益:订阅列表 + 到期状态 + 续费入口
from ahserver.serverenv import ServerEnv
userid = await get_user()
result = {'widgettype': 'Text', 'options': {'text': '加载中...'}}
if not userid:
result = {'widgettype': 'Text', 'options': {'text': '请先登录', 'color': '#999'}}
else:
try:
env = ServerEnv()
subs = await env.get_my_subscriptions()
if not subs:
result = {
'widgettype': 'VBox',
'options': {'width': '100%', 'padding': '24px', 'alignItems': 'center'},
'subwidgets': [
{'widgettype': 'Text', 'options': {
'text': '暂无已购权益,请先前往产品商城购买',
'fontSize': '14px', 'color': '#999'}}
]
}
else:
cards = []
for s in subs:
expired = s.get('expired', False)
end_date = s.get('end_date', '')
status_color = '#e74c3c' if expired else '#27ae60'
quota = s.get('quota_total', 0)
quota_unit = s.get('quota_unit', '')
quota_text = ('配额: %g %s' % (quota, quota_unit)) if quota else ''
renew_btn = {
'widgettype': 'Button',
'options': {
'label': '续费' if expired else '续费(延长)',
'bgcolor': '#3498db' if expired else '#95a5a6',
'color': '#fff',
'padding': '6px 18px',
'borderRadius': '4px',
'fontSize': '13px'
},
'binds': [{
'wid': 'self',
'event': 'click',
'actiontype': 'urlwidget',
'target': 'PopupWindow',
'popup_options': {'title': '确认续费', 'cwidth': 36, 'cheight': 24},
'options': {
'method': 'POST',
'url': '{{entire_url("./purchase_confirm.dspy")}}',
'params': {'product_id': s.get('product_id', '')}
}
}]
}
info_rows = [
{'label': '有效期', 'value': '%s ~ %s' % (s.get('start_date', ''), end_date)},
]
if quota_text:
info_rows.append({'label': '配额', 'value': quota_text})
card_widgets = [
{'widgettype': 'HBox',
'options': {'gap': '12px', 'alignItems': 'center', 'marginBottom': '6px'},
'subwidgets': [
{'widgettype': 'Text', 'options': {
'text': s.get('product_name', ''), 'fontWeight': '700',
'fontSize': '15px', 'color': '#222'}},
{'widgettype': 'Text', 'options': {
'text': s.get('status_text', ''), 'fontSize': '12px',
'color': '#fff', 'bgcolor': status_color,
'padding': '2px 8px', 'borderRadius': '10px'}}
]},
]
for row in info_rows:
card_widgets.append({
'widgettype': 'HBox',
'options': {'gap': '8px', 'padding': '2px 0'},
'subwidgets': [
{'widgettype': 'Text', 'options': {
'text': row['label'] + ':', 'width': '60px',
'fontSize': '13px', 'color': '#666'}},
{'widgettype': 'Text', 'options': {
'text': str(row['value']), 'fontSize': '13px'}}
]
})
card_widgets.append({
'widgettype': 'HBox',
'options': {'marginTop': '10px', 'justifyContent': 'flex-end'},
'subwidgets': [renew_btn]
})
cards.append({
'widgettype': 'VBox',
'options': {
'width': '100%', 'padding': '16px',
'border': '1px solid #e0e0e0', 'borderRadius': '8px',
'marginBottom': '12px',
'bgcolor': '#fff' if not expired else '#fafafa'
},
'subwidgets': card_widgets
})
result = {
'widgettype': 'VBox',
'options': {'width': '100%', 'padding': '16px', 'overflowY': 'auto'},
'subwidgets': [
{'widgettype': 'Title4', 'options': {
'text': '我的权益 (%d 项)' % len(subs), 'fontWeight': '700',
'marginBottom': '16px'}},
{'widgettype': 'VBox', 'options': {'width': '100%'}, 'subwidgets': cards}
]
}
except Exception as e:
debug(f'my_subscriptions error: {format_exc()}')
result = {'widgettype': 'Text', 'options': {'text': str(e), 'color': '#e74c3c'}}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,174 @@
# 购买确认页:算价预览 + 余额显示 + 参数输入 + 确认支付
from ahserver.serverenv import ServerEnv
product_id = params_kw.get('product_id', '')
userid = await get_user()
result = {'widgettype': 'Text', 'options': {'text': '加载中...'}}
if not product_id:
result = {'widgettype': 'Text', 'options': {'text': '缺少产品ID', 'color': '#e74c3c'}}
elif not userid:
result = {'widgettype': 'Text', 'options': {'text': '请先登录后购买', 'color': '#e74c3c'}}
else:
userorgid = await get_userorgid()
dbname = get_module_dbname('product_management')
try:
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT * FROM product WHERE id=${id}$ AND status='1'",
{'id': product_id})
if not recs:
raise Exception('产品不存在或已下架')
p = recs[0]
product_type = getattr(p, 'product_type', '') or ''
# 余额
balance = None
try:
env = ServerEnv()
acc_dbname = env.get_module_dbname('accounting')
async with DBPools().sqlorContext(acc_dbname) as asor:
balance = await env.getCustomerBalance(asor, userorgid)
except Exception:
pass
rows = []
def _row(label, value, color='#333', bold=False):
return {
'widgettype': 'HBox',
'options': {'padding': '8px 0', 'gap': '8px'},
'subwidgets': [
{'widgettype': 'Text', 'options': {
'text': label, 'width': '90px', 'fontSize': '13px',
'color': '#666', 'fontWeight': '600' if bold else '400'}},
{'widgettype': 'Text', 'options': {
'text': str(value), 'fontSize': '13px', 'color': color,
'fontWeight': '700' if bold else '400'}}
]
}
rows.append(_row('产品名称', getattr(p, 'product_name', ''), '#222', True))
# 参数输入区(存储需容量+月数;账号走默认月租)
param_widgets = []
if product_type == 'workspace_storage':
param_widgets.append({
'widgettype': 'HBox',
'options': {'padding': '8px 0', 'gap': '8px', 'alignItems': 'center'},
'subwidgets': [
{'widgettype': 'Text', 'options': {
'text': '购买容量(GB)', 'width': '90px', 'fontSize': '13px', 'color': '#666'}},
{'widgettype': 'InputNumber', 'id': 'buy_storage_gb',
'options': {'value': 100, 'min': 1, 'step': 10, 'width': '120px'}}
]
})
param_widgets.append({
'widgettype': 'HBox',
'options': {'padding': '8px 0', 'gap': '8px', 'alignItems': 'center'},
'subwidgets': [
{'widgettype': 'Text', 'options': {
'text': '购买月数', 'width': '90px', 'fontSize': '13px', 'color': '#666'}},
{'widgettype': 'InputNumber', 'id': 'buy_valid_months',
'options': {'value': 1, 'min': 1, 'max': 12, 'step': 1, 'width': '120px'}}
]
})
rows.append(_row('计费方式', '按容量直购(元/GB月', '#666'))
elif product_type == 'account':
rows.append(_row('计费方式', '订阅制(月/年/试用)', '#666'))
else:
rows.append(_row('计费方式', '按量计费', '#666'))
# 余额展示
if balance is None:
balance_text = '未开户'
balance_color = '#e67e22'
else:
balance_text = '¥ %.2f' % balance
balance_color = '#27ae60' if balance > 0 else '#e74c3c'
rows.append(_row('账户余额', balance_text, balance_color, True))
# 确认支付按钮script+fetch 提交,确保读取输入框当前值
is_storage = (product_type == 'workspace_storage')
pay_script = (
"var pid='" + product_id + "';"
"var params=new URLSearchParams();"
"params.append('product_id',pid);"
)
if is_storage:
pay_script += (
"var gb=bricks.getWidgetById('buy_storage_gb');"
"var mo=bricks.getWidgetById('buy_valid_months');"
"var gbv=gb?gb.value:100; var mov=mo?mo.value:1;"
"if(!gbv||gbv<=0){alert('请输入购买容量');return;}"
"params.append('storage_gb',gbv);"
"params.append('valid_months',mov);"
)
pay_script += (
"var btn=this; btn.disabled=true;"
"fetch('{{entire_url(\"./api/purchase_realtime.dspy\")}}',"
"{method:'POST',body:params}).then(function(r){return r.json()})"
".then(function(msg){btn.disabled=false;"
"if(msg.widgettype){bricks.widgetBuild(msg).then(function(w){if(w)w.open()})}"
"else{alert(msg.message||'支付完成')}})"
".catch(function(e){btn.disabled=false;alert('支付请求失败: '+e)})"
)
pay_btn = {
'widgettype': 'Button',
'options': {
'label': '确认支付',
'bgcolor': '#e74c3c',
'color': '#fff',
'padding': '10px 36px',
'borderRadius': '6px',
'fontWeight': '600',
'marginTop': '16px'
},
'binds': [{
'wid': 'self',
'event': 'click',
'actiontype': 'script',
'target': 'self',
'script': pay_script
}]
}
cancel_btn = {
'widgettype': 'Button',
'options': {
'label': '取消',
'bgcolor': '#f0f0f0',
'color': '#666',
'padding': '10px 24px',
'borderRadius': '6px',
'marginTop': '16px'
},
'binds': [{
'wid': 'self', 'event': 'click', 'actiontype': 'script',
'target': 'self', 'script': 'if(window.closePopup) closePopup()'
}]
}
result = {
'widgettype': 'VBox',
'options': {'width': '100%', 'padding': '16px'},
'subwidgets': [
{'widgettype': 'Title4', 'options': {'text': '确认购买', 'fontWeight': '700', 'marginBottom': '12px'}},
{'widgettype': 'VBox', 'options': {'gap': '0', 'marginBottom': '8px'}, 'subwidgets': rows},
{'widgettype': 'VBox', 'options': {'gap': '0', 'marginBottom': '8px'}, 'subwidgets': param_widgets},
{
'widgettype': 'Text',
'options': {
'text': '支付将从账户余额实时扣款并完成记账,购买成功后权益立即生效。',
'fontSize': '12px', 'color': '#999', 'marginTop': '8px'}
},
{'widgettype': 'HBox', 'options': {'gap': '12px', 'marginTop': '8px'},
'subwidgets': [pay_btn, cancel_btn]}
]
}
except Exception as e:
debug(f'purchase_confirm error: {format_exc()}')
result = {'widgettype': 'Text', 'options': {'text': str(e), 'color': '#e74c3c'}}
return json.dumps(result, ensure_ascii=False)

View File

@ -0,0 +1,42 @@
# 实时支付接口:点击确认支付 → purchase_realtime算价→余额预检→计费→实时记账→开权益
from ahserver.serverenv import ServerEnv
result = {'widgettype': 'Message', 'options': {
'title': '支付失败', 'message': '未知错误', 'type': 'error', 'timeout': 5}}
try:
userid = await get_user()
if not userid:
raise Exception('请先登录')
product_id = params_kw.get('product_id', '')
if not product_id:
raise Exception('缺少产品ID')
env = ServerEnv()
r = await env.purchase_realtime(
product_id=product_id,
user_id=userid,
charge_mode=params_kw.get('charge_mode') or None,
quantity=int(params_kw.get('quantity', 1) or 1),
storage_gb=float(params_kw.get('storage_gb', 0) or 0),
valid_months=int(params_kw.get('valid_months', 1) or 1))
if not r.get('success'):
raise Exception(r.get('message', '支付失败'))
result = {
'widgettype': 'Message',
'options': {
'title': '支付成功',
'type': 'success',
'timeout': 6,
'message': ('%s 购买成功,实付 ¥%.2f,订单号 %s权益有效期至 %s已实时记账'
% (r.get('product_name', ''), r.get('amount', 0),
r.get('orderid', ''), r.get('end_date', '')))
}
}
except Exception as e:
debug(f'purchase_realtime.dspy error: {format_exc()}')
result['options']['message'] = str(e)
return json.dumps(result, ensure_ascii=False)

24
wwwroot/usermenu.ui Normal file
View File

@ -0,0 +1,24 @@
{% set roles = get_user_roles(get_user()) %}
{
"widgettype":"Menu",
"options":{
"cwidth":10,
"target":"PopupWindow",
"popup_options":{
"height":"85%",
"width":"80%"
},
"items":[
{
"name":"storefront",
"label":"产品商城",
"url":"{{entire_url('storefront/index.ui')}}"
},
{
"name":"my_subscriptions",
"label":"我的权益",
"url":"{{entire_url('storefront/api/my_subscriptions.dspy')}}"
}
]
}
}