From 167d06cd9358273f891481f2cacd0296069988f9 Mon Sep 17 00:00:00 2001 From: ymq Date: Thu, 27 Aug 2026 10:40:54 +0800 Subject: [PATCH] =?UTF-8?q?feat(purchase):=20=E5=AE=9E=E6=97=B6=E8=B4=AD?= =?UTF-8?q?=E4=B9=B0(=E9=80=89A=E8=B4=AD=E4=B9=B0=E5=8D=B3=E8=AE=B0?= =?UTF-8?q?=E8=B4=A6)+=E7=BB=AD=E8=B4=B9=E5=BB=B6=E9=95=BF+=E5=88=B0?= =?UTF-8?q?=E6=9C=9F=E9=97=A8=E7=A6=81+=E6=88=91=E7=9A=84=E6=9D=83?= =?UTF-8?q?=E7=9B=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心链路: - purchase_realtime:算价→余额预检→计费写用量→实时复式记账→开权益,失败回滚 - _grant_subscription 续费修复:未到期从 end_date 顺延不丢剩余天数,已到期重算 - check_subscription_valid 到期门禁:挂到 execute_product,到期/未购买阻断使用 - get_my_subscriptions 我的权益列表(到期状态) - UI:purchase_confirm 确认页(余额+容量/月数输入)、purchase_realtime 支付、 my_subscriptions 权益列表+续费、usermenu.ui 挂菜单 --- product_management/__init__.py | 25 ++ product_management/core.py | 259 +++++++++++++++++- .../storefront/api/get_product_detail.dspy | 4 +- wwwroot/storefront/api/my_subscriptions.dspy | 119 ++++++++ wwwroot/storefront/api/purchase_confirm.dspy | 174 ++++++++++++ wwwroot/storefront/api/purchase_realtime.dspy | 42 +++ wwwroot/usermenu.ui | 24 ++ 7 files changed, 641 insertions(+), 6 deletions(-) create mode 100644 wwwroot/storefront/api/my_subscriptions.dspy create mode 100644 wwwroot/storefront/api/purchase_confirm.dspy create mode 100644 wwwroot/storefront/api/purchase_realtime.dspy create mode 100644 wwwroot/usermenu.ui diff --git a/product_management/__init__.py b/product_management/__init__.py index f5b9b0b..904a2d8 100644 --- a/product_management/__init__.py +++ b/product_management/__init__.py @@ -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 diff --git a/product_management/core.py b/product_management/core.py index 3b32974..cafbbc4 100644 --- a/product_management/core.py +++ b/product_management/core.py @@ -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): diff --git a/wwwroot/storefront/api/get_product_detail.dspy b/wwwroot/storefront/api/get_product_detail.dspy index 75258a5..4cf6f09 100644 --- a/wwwroot/storefront/api/get_product_detail.dspy +++ b/wwwroot/storefront/api/get_product_detail.dspy @@ -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} } } diff --git a/wwwroot/storefront/api/my_subscriptions.dspy b/wwwroot/storefront/api/my_subscriptions.dspy new file mode 100644 index 0000000..d376d86 --- /dev/null +++ b/wwwroot/storefront/api/my_subscriptions.dspy @@ -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) diff --git a/wwwroot/storefront/api/purchase_confirm.dspy b/wwwroot/storefront/api/purchase_confirm.dspy new file mode 100644 index 0000000..abc4792 --- /dev/null +++ b/wwwroot/storefront/api/purchase_confirm.dspy @@ -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) diff --git a/wwwroot/storefront/api/purchase_realtime.dspy b/wwwroot/storefront/api/purchase_realtime.dspy new file mode 100644 index 0000000..61b7727 --- /dev/null +++ b/wwwroot/storefront/api/purchase_realtime.dspy @@ -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) diff --git a/wwwroot/usermenu.ui b/wwwroot/usermenu.ui new file mode 100644 index 0000000..c22abb6 --- /dev/null +++ b/wwwroot/usermenu.ui @@ -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')}}" + } + ] + } +}