feat(storefront): purchase popup shows live price - calc_price.dspy preview endpoint + changed-event refresh on storage_gb/valid_months/charge_mode (same cost basis as purchase_realtime)

This commit is contained in:
yumoqing 2026-09-08 17:53:58 +08:00
parent e870de8985
commit b0b74a2b9f
3 changed files with 146 additions and 3 deletions

View File

@ -147,6 +147,7 @@ PATHS_LOGINED = [
f"/{MOD}/storefront/api/purchase.dspy",
f"/{MOD}/storefront/api/purchase_confirm.dspy",
f"/{MOD}/storefront/api/purchase_realtime.dspy",
f"/{MOD}/storefront/api/calc_price.dspy",
f"/{MOD}/storefront/api/my_subscriptions.dspy",
# CRUD auto-generated .dspy (new tables)

View File

@ -0,0 +1,64 @@
# 购买弹窗实时算价:按当前输入参数试算售价(与 purchase_realtime 扣款同口径,只读不写)
# 存储storage_gb × valid_monthsmeter_mode=direct 直购)
# 产线charge_mode month/year年付=月价×10months 因子由定价映射解析)
from ahserver.serverenv import ServerEnv
product_id = params_kw.get('product_id', '')
userid = await get_user()
if not product_id:
result = {'success': False, 'message': '缺少产品ID'}
elif not userid:
result = {'success': False, 'message': '请先登录'}
else:
try:
userorgid = await get_userorgid()
dbname = get_module_dbname('product_management')
async with DBPools().sqlorContext(dbname) as sor:
recs = await sor.sqlExe(
"SELECT id, product_type FROM product WHERE id=${id}$ AND status='1'",
{'id': product_id})
await sor.sqlExe("COMMIT", {})
if not recs:
raise Exception('产品不存在或已下架')
product_type = getattr(recs[0], 'product_type', '') or ''
# usage_data 构造与 purchase_realtime 完全一致(同口径预览,所见即所付)
usage_data = None
if product_type == 'workspace_storage':
storage_gb = float(params_kw.get('storage_gb') or 0)
valid_months = max(1, int(params_kw.get('valid_months') or 1))
if storage_gb <= 0:
result = {'success': False, 'message': '请填写购买容量(GB)'}
else:
usage_data = {'meter_mode': 'direct', 'storage_gb': storage_gb,
'valid_months': valid_months}
elif product_type == 'pipeline':
cm = params_kw.get('charge_mode') or 'month'
if cm not in ('month', 'year'):
cm = 'month'
usage_data = {'charge_mode': cm}
else:
usage_data = {}
if usage_data is not None:
env = ServerEnv()
cost = await env.calculate_product_cost(
product_id=product_id, usage_data=usage_data,
user_org_id=userorgid)
if not cost or cost.get('success') is False:
result = {'success': False,
'message': (cost or {}).get('message', '定价计算失败')}
else:
result = {
'success': True,
'amount': float(cost.get('amount', 0) or 0),
'original_amount': float(cost.get('original_amount', 0) or 0),
'discount': float(cost.get('discount', 1.0) or 1.0),
'product_type': product_type,
}
except Exception as e:
debug(f'calc_price error: {format_exc()}')
result = {'success': False, 'message': str(e)[:200]}
return json.dumps(result, ensure_ascii=False)

View File

@ -50,12 +50,86 @@ else:
rows.append(_row('产品名称', getattr(p, 'product_name', ''), '#222', True))
# ── 实时算价2026-09-08 用户定夺:弹窗显示价格,时长/容量/周期变化实时更新)──
# 初始价服务端算好(与 purchase_realtime 扣款同口径calculate_product_cost
# 含客户折扣,所见即所付);输入变化时前端 fetch calc_price.dspy 刷新。
env = ServerEnv()
init_usage = None
if product_type == 'workspace_storage':
init_usage = {'meter_mode': 'direct', 'storage_gb': 100, 'valid_months': 1}
elif product_type == 'pipeline':
init_usage = {'charge_mode': 'month'}
init_price_text = '应付金额:—'
if init_usage is not None:
try:
_c = await env.calculate_product_cost(
product_id=product_id, usage_data=init_usage,
user_org_id=userorgid)
if _c and _c.get('success') is not False:
_amt = float(_c.get('amount', 0) or 0)
_orig = float(_c.get('original_amount', 0) or 0)
_disc = float(_c.get('discount', 1.0) or 1.0)
init_price_text = '应付金额:¥ %.2f' % _amt
if abs(_disc - 1.0) > 1e-9:
init_price_text += '(原价 ¥ %.2f × 折扣 %.4g' % (_orig, _disc)
else:
init_price_text = '算价失败:%s' % ((_c or {}).get('message', '') or '未知')[:80]
except Exception as _pe:
init_price_text = '算价失败:%s' % str(_pe)[:80]
price_text_widget = {
'widgettype': 'Text',
'id': 'buy_price_text',
'options': {
'text': init_price_text,
'fontSize': '16px', 'color': '#e74c3c', 'fontWeight': '700',
'padding': '10px 0 2px 0'
}
}
# 参数输入区(存储需容量+月数;产线选月付/年付;账号走默认月租)
# ⚠️ 控件名必须是 bricks 实际注册的:数字输入 = UiInt不是 InputNumber
# 该名未注册 → widgetBuild 返回 null控件静默不渲染2026-09-06 修复)。
# UiType 系控件同时传 name + idname 供 getValue/表单id 供 getWidgetById 定位
# widgetBuild 用 set_id(desc.id) 覆盖 DOM id
param_widgets = []
# 实时刷新价格的共享 script读当前输入 → fetch calc_price → set_text。
# UiInt 的 resultValue() 在 changed 触发时 this.value 已更新,直接读 DOM 控件值最稳。
# getWidgetById 必须传第二参数(查找起点),否则 TypeError2026-09-06 既有 bug
price_refresh_script = (
"var pid='" + product_id + "';"
"var pt='" + product_type + "';"
"var params=new URLSearchParams();"
"params.append('product_id',pid);"
"if(pt=='workspace_storage'){"
" var gb=bricks.getWidgetById('buy_storage_gb',bricks.app.root);"
" var mo=bricks.getWidgetById('buy_valid_months',bricks.app.root);"
" var gbv=gb?gb.resultValue():0; var mov=mo?mo.resultValue():1;"
" if(gbv&&gbv>0)params.append('storage_gb',gbv);"
" if(mov&&mov>0)params.append('valid_months',mov);"
"}else if(pt=='pipeline'){"
" var cm=bricks.getWidgetById('buy_charge_mode',bricks.app.root);"
" params.append('charge_mode',cm?(cm.value||'month'):'month');"
"}"
"var tw=bricks.getWidgetById('buy_price_text',bricks.app.root);"
"if(tw)tw.set_text('计算中...');"
"fetch('/product_management/storefront/api/calc_price.dspy',"
"{method:'POST',body:params}).then(function(r){return r.json()}).then(function(d){"
" var t=bricks.getWidgetById('buy_price_text',bricks.app.root); if(!t)return;"
" if(d.success){var s='应付金额:¥ '+(Math.round(d.amount*100)/100).toFixed(2);"
" if(Math.abs((d.discount||1)-1)>1e-9){"
" s+='(原价 ¥ '+(Math.round(d.original_amount*100)/100).toFixed(2)"
" +' × 折扣 '+d.discount+'';}"
" t.set_text(s);t.dom_element.style.color='#e74c3c';"
" }else{t.set_text('算价失败:'+(d.message||''));t.dom_element.style.color='#e67e22';}"
"}).catch(function(e){"
" var t=bricks.getWidgetById('buy_price_text',bricks.app.root);"
" if(t){t.set_text('算价请求失败:'+e);t.dom_element.style.color='#e67e22';}"
"})"
)
_price_bind = [{'wid': 'self', 'event': 'changed', 'actiontype': 'script',
'target': 'self', 'script': price_refresh_script}]
if product_type == 'workspace_storage':
param_widgets.append({
'widgettype': 'HBox',
@ -64,7 +138,8 @@ else:
{'widgettype': 'Text', 'options': {
'text': '购买容量(GB)', 'width': '90px', 'fontSize': '13px', 'color': '#666'}},
{'widgettype': 'UiInt', 'id': 'buy_storage_gb',
'options': {'name': 'buy_storage_gb', 'value': 100, 'width': '120px'}}
'options': {'name': 'buy_storage_gb', 'value': 100, 'width': '120px'},
'binds': _price_bind}
]
})
param_widgets.append({
@ -74,7 +149,8 @@ else:
{'widgettype': 'Text', 'options': {
'text': '购买月数', 'width': '90px', 'fontSize': '13px', 'color': '#666'}},
{'widgettype': 'UiInt', 'id': 'buy_valid_months',
'options': {'name': 'buy_valid_months', 'value': 1, 'width': '120px'}}
'options': {'name': 'buy_valid_months', 'value': 1, 'width': '120px'},
'binds': _price_bind}
]
})
rows.append(_row('计费方式', '按容量直购(元/GB月', '#666'))
@ -89,7 +165,8 @@ else:
{'widgettype': 'UiCode', 'id': 'buy_charge_mode',
'options': {'name': 'buy_charge_mode', 'value': 'month', 'width': '200px',
'data': [{'value': 'month', 'text': '包月30天'},
{'value': 'year', 'text': '包年365天付10个月'}]}}
{'value': 'year', 'text': '包年365天付10个月'}]},
'binds': _price_bind}
]
})
rows.append(_row('计费方式', '订阅制使用费(模型/存储消耗另按量计费)', '#666'))
@ -188,6 +265,7 @@ else:
{'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},
price_text_widget,
{
'widgettype': 'Text',
'options': {