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:
parent
29f1c1fc92
commit
167d06cd93
@ -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
|
||||
|
||||
@ -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):
|
||||
"""实时购买:点击支付 → 算价 → 余额预检 → 计费写用量 → 实时复式记账 → 开权益。
|
||||
|
||||
账号产品(account):charge_mode(trial/month/year) 决定有效期与定价。
|
||||
存储产品(workspace_storage):storage_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):
|
||||
|
||||
@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
119
wwwroot/storefront/api/my_subscriptions.dspy
Normal file
119
wwwroot/storefront/api/my_subscriptions.dspy
Normal 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)
|
||||
174
wwwroot/storefront/api/purchase_confirm.dspy
Normal file
174
wwwroot/storefront/api/purchase_confirm.dspy
Normal 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)
|
||||
42
wwwroot/storefront/api/purchase_realtime.dspy
Normal file
42
wwwroot/storefront/api/purchase_realtime.dspy
Normal 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
24
wwwroot/usermenu.ui
Normal 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')}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user