product_management/wwwroot/storefront/api/purchase_confirm.dspy
yumoqing 1f399d6b00 feat(storefront): 显示价=定价×客户折扣率(三类统一)+产线订阅购买链路+修既有控件bug
1) get_customer_price_display 新增(core.py + __init__.py + env 注册)
   统一三类产品对外报价口径:显示价 = 资源真实定价 × 客户折扣率
   - 产线 pipeline_pricing_map / 存储 storres_pricing_map / 模型 llm_model.ppid
     各自解析 ppid → get_pricing_display 取单位价 → × 折扣
   - 折扣率解析走 discount 模块 get_min_product_discount(专属优先/'*'兜底/1.0)
     >1 加价(存储1.5)、<1 让利(百炼0.9);折扣≠1 时返回 original_text 原价
   - 未登录(user_org_id 空)传 '' 只命中 '*' 通用折扣 → 访客可见报价

2) purchase_realtime 加产线分支(product_type=='pipeline')
   订阅制:无 usage 表,直接算价→余额预检→product_accounting_generic 实时记账→开订阅权益
   charge_mode month/year,entitlement.duration_days 30/365

3) 展示页 index.ui:接 get_customer_price_display;产线卡显示月/年价+原价划线+订阅态文案;
   按钮按订阅状态分流(已订阅→进入产线 / 未订阅→立即购买);product_code 兼容
   opportunity_general/bidding_general/sdlc_general 与旧 PIPE-* 两套映射

4) 修两个既有 bug(购买页原本就坏):
   - InputNumber 控件 bricks 未注册 → widgetBuild 返回 null 静默不渲染,改 UiInt
   - 支付脚本 getWidgetById 缺第二参数(查找起点) → from_widget undefined 抛 TypeError
     点击支付无反应,补 bricks.app.root;UiType 取值 .value → resultValue()
   - UiType 系控件同时传 name(表单/getValue)+id(set_id 覆盖 DOM id 供 getWidgetById)

本地 mock 渲染测试 6 项全过:三类价格/订阅态分流/规划中占位/未登录访客可见价无购买按钮
2026-09-06 16:55:49 +08:00

206 lines
9.6 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 购买确认页:算价预览 + 余额显示 + 参数输入 + 确认支付
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))
# 参数输入区(存储需容量+月数;产线选月付/年付;账号走默认月租)
# ⚠️ 控件名必须是 bricks 实际注册的:数字输入 = UiInt(不是 InputNumber,
# 该名未注册 → widgetBuild 返回 null,控件静默不渲染,2026-09-06 修复)。
# UiType 系控件同时传 name + id:name 供 getValue/表单,id 供 getWidgetById 定位
# (widgetBuild 用 set_id(desc.id) 覆盖 DOM id)。
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': 'UiInt', 'id': 'buy_storage_gb',
'options': {'name': 'buy_storage_gb', 'value': 100, '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': 'UiInt', 'id': 'buy_valid_months',
'options': {'name': 'buy_valid_months', 'value': 1, 'width': '120px'}}
]
})
rows.append(_row('计费方式', '按容量直购(元/GB月)', '#666'))
elif product_type == 'pipeline':
# 产线使用费:月付 / 年付(年费=月费×10,即付10个月用12个月)
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': 'UiCode', 'id': 'buy_charge_mode',
'options': {'name': 'buy_charge_mode', 'value': 'month', 'width': '200px',
'data': [{'value': 'month', 'text': '包月(30天)'},
{'value': 'year', 'text': '包年(365天,付10个月)'}]}}
]
})
rows.append(_row('计费方式', '订阅制使用费(模型/存储消耗另按量计费)', '#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 提交,确保读取输入框当前值
# ⚠️ getWidgetById 必须传第二参数(查找起点):bricks.getWidgetById(idset, from_widget)
# 里 get_by_id 直接读 fromw.dom_element,from_widget 为 undefined 会抛 TypeError
# → 点击支付无任何反应(既有 bug,2026-09-06 修复)。用 bricks.app.root 作起点。
# UiInt 取值用 resultValue()(返回 int),UiCode 取值用 .value(返回字符串)。
is_storage = (product_type == 'workspace_storage')
is_pipeline = (product_type == 'pipeline')
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',bricks.app.root);"
"var mo=bricks.getWidgetById('buy_valid_months',bricks.app.root);"
"var gbv=gb?gb.resultValue():100; var mov=mo?mo.resultValue():1;"
"if(!gbv||gbv<=0){alert('请输入购买容量');return;}"
"if(!mov||mov<=0){alert('请输入购买月数');return;}"
"params.append('storage_gb',gbv);"
"params.append('valid_months',mov);"
)
if is_pipeline:
pay_script += (
"var cm=bricks.getWidgetById('buy_charge_mode',bricks.app.root);"
"var cmv=cm?(cm.value||'month'):'month';"
"params.append('charge_mode',cmv);"
)
pay_script += (
"var btn=this; btn.disabled=true;"
"fetch('/product_management/storefront/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)