feat(wallet): 账号钱包门禁+会话粘性轮询+出账后扣减+余额不足待办(2026-09-11用户定夺)——①门禁:balance<阈值(params缺省5元)从轮询集剔除,全剔除报可行动错误;②粘性:同会话同模型固定账号(Redis llm_sticky TTL1h滑动续期)省上游缓存钱,粘性失效自动重选,session_id经payload._session_id透传;③轮询:跨会话INCR取模替代余额加权;④扣减:产品记账成功后按流水account_id扣调用账号钱包(supplier_cost),与cost回填同事务原子,cost≠0幂等跳过防重置重跑重复扣,允许透支为负;⑤待办:状态派生provider(充值到账自动消失天然单发)给属主机构operator角色(params可配,按.后缀动态匹配)+详情弹窗内直接充值
This commit is contained in:
parent
69fe362dde
commit
6d0ecc5d21
27
README.md
27
README.md
@ -30,11 +30,34 @@
|
|||||||
|
|
||||||
```
|
```
|
||||||
① 个人限流 → ② 组织限流 → ③ 个人额度 → ④ 组织池(预授权)
|
① 个人限流 → ② 组织限流 → ③ 个人额度 → ④ 组织池(预授权)
|
||||||
→ ⑤ 组织策略选模型(主→备) → ⑥ 账号×端点候选池(偏好过滤+余额加权+冷却避让)
|
→ ⑤ 组织策略选模型(主→备) → ⑥ 账号×端点候选池(偏好过滤+钱包门禁+冷却避让+会话粘性+轮询)
|
||||||
→ ⑦ 调用 → ⑧ 结算(实际用量,多退少补,双维度记账)
|
→ ⑦ 调用 → ⑧ 结算(实际用量,双维度记账)
|
||||||
```
|
```
|
||||||
任何一级不过:快速失败 + 明确中文原因(不挂起、不静默)。
|
任何一级不过:快速失败 + 明确中文原因(不挂起、不静默)。
|
||||||
|
|
||||||
|
### 账号钱包门禁与会话粘性(2026-09-11 用户定夺)
|
||||||
|
|
||||||
|
- **钱包粒度**:1 个供应商 = 多个 llm_account(每个 APIKEY 账号一个钱包,
|
||||||
|
balance 字段;充值入口 llm_account_recharge)。accounting 的供应商记账
|
||||||
|
粒度不变(记账金额 = supplier_cost)。
|
||||||
|
- **门禁**:选账号时 balance < 阈值(params `llm_wallet_balance_threshold`,
|
||||||
|
缺省 5 元)→ 从轮询集剔除;全部剔除 → 报可行动错误(禁静默回退)。
|
||||||
|
- **粘性**:同一会话对同一模型固定同一账号(Redis `llm_sticky:{session_id}:
|
||||||
|
{model_id}`,TTL 1h 滑动续期)——上游 KV/前缀缓存按账号(api_key)隔离,
|
||||||
|
固定账号命中缓存省钱;粘性账号冷却/剔除时自动重选。session_id 经
|
||||||
|
llm_bridge payload `_session_id` 透传(不进 token 缓存键)。
|
||||||
|
- **轮询**:跨会话 Redis INCR `llm_rr:{model_id}` 取模轮流用不同账号;
|
||||||
|
Redis 不可用退化为首个可用账号(fail-open)。
|
||||||
|
- **扣减**:产品记账(product_accounting_generic)**成功之后**,出账 worker
|
||||||
|
按流水 account_id 扣「调用模型的那个钱包」balance -= supplier_cost,与
|
||||||
|
cost 回填同一事务(原子);cost≠0 = 已扣过,幂等跳过(防 accounted 行
|
||||||
|
重置重跑重复扣)。允许透支为负(60s 出账窗口并发超扣是真实成本,宁可
|
||||||
|
透支不可丢钱流/打死流水)。
|
||||||
|
- **待办**:余额不足是状态 → todos.py provider 状态派生待办(充值到账自动
|
||||||
|
消失,天然单发)给属主机构 operator 角色(params `llm_wallet_todo_role`
|
||||||
|
可配,按角色名 `.` 后缀动态匹配不锁 orgtypeid);详情弹窗内直接充值
|
||||||
|
(llm_wallet_todo_popup.dspy)。
|
||||||
|
|
||||||
## 集成方式(复用不改建)
|
## 集成方式(复用不改建)
|
||||||
|
|
||||||
模块只提供治理引擎与配置管理;实际 LLM 转发仍由宿主已有的
|
模块只提供治理引擎与配置管理;实际 LLM 转发仍由宿主已有的
|
||||||
|
|||||||
@ -36,7 +36,7 @@ _trace_cleanup_day = '' # trace 过期清理已跑日期(进程内每日一
|
|||||||
|
|
||||||
async def _get_pending(sor):
|
async def _get_pending(sor):
|
||||||
recs = await sor.sqlExe(
|
recs = await sor.sqlExe(
|
||||||
"SELECT id, org_id, user_id, model_id, cost, ppid, usages, "
|
"SELECT id, org_id, user_id, model_id, account_id, cost, ppid, usages, "
|
||||||
"created_at "
|
"created_at "
|
||||||
"FROM llm_usage WHERE accounting_status='created' AND status='SUCCEEDED' "
|
"FROM llm_usage WHERE accounting_status='created' AND status='SUCCEEDED' "
|
||||||
"ORDER BY created_at LIMIT %d" % _BATCH, {})
|
"ORDER BY created_at LIMIT %d" % _BATCH, {})
|
||||||
@ -44,6 +44,52 @@ async def _get_pending(sor):
|
|||||||
return recs or []
|
return recs or []
|
||||||
|
|
||||||
|
|
||||||
|
async def _deduct_wallet(sor, row, supplier_cost):
|
||||||
|
"""产品记账完成后扣减调用账号的钱包(2026-09-11 用户定夺)。
|
||||||
|
|
||||||
|
钱包 = llm_account.balance。1 个供应商财务账户(accounting.account) : n 个
|
||||||
|
llm_account 钱包——一个供应商可配多账号(钱包),扣减主体按流水 account_id
|
||||||
|
定位「**调用模型的那个钱包**」,扣减金额 = accounting 对该供应商的记账额
|
||||||
|
(product_accounting_generic 返回的 supplier_cost,即 PAY* 分录的采购成本)。
|
||||||
|
|
||||||
|
幂等守卫:流水 cost 列在出账成功时回填 supplier_cost(govern_settle 创建时
|
||||||
|
cost=0)。cost≠0 ⟺ 本行已扣过钱包——accounted 行被手动重置回 created 重跑时
|
||||||
|
跳过扣减,防重复扣。(failed→created 追回流程安全:failed 行在扣减前就
|
||||||
|
return False,从未回填 cost,重跑正常扣。)
|
||||||
|
|
||||||
|
允许扣成负值:调用→出账有 ~60s 窗口且门禁阈值(缺省5元)是事前闸,并发超扣
|
||||||
|
是真实成本,宁可透支为负(待办 provider 派生待办催充值),不可丢失钱流或把
|
||||||
|
已记账流水打回 failed(账务不可逆)。扣减失败只 warning 不回滚客户侧记账。
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
prior_cost = float(getattr(row, 'cost', 0) or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
prior_cost = 0.0
|
||||||
|
if prior_cost != 0:
|
||||||
|
logger.info("pipeline_llm.accounting: 流水 %s 已回填 cost=%.6f(钱包已扣),"
|
||||||
|
"幂等跳过重复扣减", getattr(row, 'id', ''), prior_cost)
|
||||||
|
return
|
||||||
|
account_id = getattr(row, 'account_id', '') or ''
|
||||||
|
if not account_id:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
amount = float(supplier_cost or 0)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
amount = 0.0
|
||||||
|
if amount <= 0:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
# 只发 UPDATE 不单独 COMMIT——与调用方回填 cost/accounted 的 UPDATE 同一
|
||||||
|
# 事务提交(原子):钱包扣减与 cost 回填要么都落要么都不落,杜绝
|
||||||
|
# "已扣钱但 cost 未回填→重跑重复扣" 的窗口。
|
||||||
|
await sor.sqlExe(
|
||||||
|
"UPDATE llm_account SET balance=balance-${c}$ WHERE id=${i}$",
|
||||||
|
{"c": round(amount, 6), "i": account_id})
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("pipeline_llm.accounting: 账号钱包扣减失败(不回滚记账,对账修正)"
|
||||||
|
"account=%s cost=%.6f: %s", account_id, amount, str(e)[:160])
|
||||||
|
|
||||||
|
|
||||||
async def _find_product_id(sor, model_id):
|
async def _find_product_id(sor, model_id):
|
||||||
"""llm_model.id → product.id(产品层导入时按 resource_ref_id 建立)。"""
|
"""llm_model.id → product.id(产品层导入时按 resource_ref_id 建立)。"""
|
||||||
recs = await sor.sqlExe(
|
recs = await sor.sqlExe(
|
||||||
@ -140,11 +186,18 @@ async def _settle_one(sor, row):
|
|||||||
await sor.sqlExe("COMMIT", {})
|
await sor.sqlExe("COMMIT", {})
|
||||||
logger.error("pipeline_llm.accounting: 流水 %s 出账失败: %s", row.id, msg)
|
logger.error("pipeline_llm.accounting: 流水 %s 出账失败: %s", row.id, msg)
|
||||||
return False
|
return False
|
||||||
# 回填客户应付 + 置已出账
|
# 产品记账完成(2026-09-11 用户定夺):记账成功之后扣减调用账号的钱包——
|
||||||
|
# supplier_cost 是定价引擎算出的真实进货成本,扣 llm_account.balance(流水
|
||||||
|
# account_id 定位"调用模型的钱包");同时把 cost 回填进流水(对账可查每笔
|
||||||
|
# 钱包扣了多少)。扣减失败只 warning,不回滚客户侧账务。
|
||||||
|
supplier_cost = float((result or {}).get('supplier_cost', 0) or 0)
|
||||||
|
await _deduct_wallet(sor, row, supplier_cost)
|
||||||
|
# 回填客户应付 + 成本侧金额 + 置已出账
|
||||||
await sor.sqlExe(
|
await sor.sqlExe(
|
||||||
"UPDATE llm_usage SET charge=${c}$, accounting_status='accounted', "
|
"UPDATE llm_usage SET charge=${c}$, cost=${k}$, accounting_status='accounted', "
|
||||||
"note=CONCAT(COALESCE(note,''),'|accounted:',${o}$) WHERE id=${i}$",
|
"note=CONCAT(COALESCE(note,''),'|accounted:',${o}$) WHERE id=${i}$",
|
||||||
{"c": float((result or {}).get('customer_amount', 0) or 0),
|
{"c": float((result or {}).get('customer_amount', 0) or 0),
|
||||||
|
"k": round(supplier_cost, 6),
|
||||||
"o": str((result or {}).get('orderid', ''))[:40],
|
"o": str((result or {}).get('orderid', ''))[:40],
|
||||||
"i": row.id})
|
"i": row.id})
|
||||||
await sor.sqlExe("COMMIT", {})
|
await sor.sqlExe("COMMIT", {})
|
||||||
|
|||||||
@ -30,6 +30,7 @@ logger = logging.getLogger("pipeline_llm.gateway")
|
|||||||
DBNAME = "pipeline"
|
DBNAME = "pipeline"
|
||||||
GOVERN_REDIS_DB = 4 # 治理计数器命名空间(会话用 db3,互不干扰)
|
GOVERN_REDIS_DB = 4 # 治理计数器命名空间(会话用 db3,互不干扰)
|
||||||
_COOLDOWN_SEC = 60 # 429 冷却窗口
|
_COOLDOWN_SEC = 60 # 429 冷却窗口
|
||||||
|
_STICKY_TTL_SEC = 3600 # 会话粘性键 TTL(滑动续期,1h 无调用自动失效)
|
||||||
_EST_TOKENS_PER_CALL = 800 # 历史常量(预授权已废弃,保留防外部引用)
|
_EST_TOKENS_PER_CALL = 800 # 历史常量(预授权已废弃,保留防外部引用)
|
||||||
|
|
||||||
# 治理开关缓存:机构是否启用治理(有策略记录或有 llm_model 记录)
|
# 治理开关缓存:机构是否启用治理(有策略记录或有 llm_model 记录)
|
||||||
@ -106,6 +107,30 @@ def _row_to_dict(r):
|
|||||||
return {k: v for k, v in vars(r).items() if not callable(v)}
|
return {k: v for k, v in vars(r).items() if not callable(v)}
|
||||||
|
|
||||||
|
|
||||||
|
async def _get_param(sor, name, default):
|
||||||
|
"""读 appbase params 表配置(params_name→params_value),表不存在/无值兜底 default。"""
|
||||||
|
try:
|
||||||
|
recs = await sor.sqlExe(
|
||||||
|
"SELECT params_value FROM params WHERE params_name=${n}$ LIMIT 1", {"n": name})
|
||||||
|
if recs:
|
||||||
|
v = str(getattr(recs[0], 'params_value', '') or '').strip()
|
||||||
|
if v:
|
||||||
|
return v
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
return default
|
||||||
|
|
||||||
|
|
||||||
|
async def _wallet_threshold(sor) -> float:
|
||||||
|
"""账号钱包余额门禁阈值(2026-09-11 用户定夺:余额<阈值的账号从轮询集剔除
|
||||||
|
+ 派待办给属主机构 operator 催充值)。params 可配,缺省 5 元。"""
|
||||||
|
v = await _get_param(sor, 'llm_wallet_balance_threshold', '5')
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 5.0
|
||||||
|
|
||||||
|
|
||||||
def _parse_json(s, default):
|
def _parse_json(s, default):
|
||||||
if not s:
|
if not s:
|
||||||
return default
|
return default
|
||||||
@ -303,8 +328,20 @@ async def _model_protocol(sor, model: dict) -> str:
|
|||||||
return (getattr(recs[0], 'protocol', '') or '').strip() if recs else ''
|
return (getattr(recs[0], 'protocol', '') or '').strip() if recs else ''
|
||||||
|
|
||||||
|
|
||||||
async def _pick_candidate(sor, model: dict, pref: str):
|
async def _pick_candidate(sor, model: dict, pref: str, session_id: str = ''):
|
||||||
"""构建候选并选一个:协议过滤 → 区域偏好过滤 → 冷却避让 → 余额加权。
|
"""构建候选并选一个:协议过滤 → 区域偏好过滤 → 钱包余额门禁 →
|
||||||
|
冷却避让 → 会话粘性(同会话固定账号省上游缓存钱)→ 跨会话轮询。
|
||||||
|
|
||||||
|
账号钱包门禁(2026-09-11 用户定夺):llm_account.balance 是该账号的钱包
|
||||||
|
(充值入口 llm_account_recharge);出账成功后按 llm_usage.account_id 扣减。
|
||||||
|
balance < 阈值(params llm_wallet_balance_threshold,缺省 5 元)→ 账号从
|
||||||
|
轮询集剔除;余额状态派生待办给属主机构 operator 催充值(todos.py provider)。
|
||||||
|
全部剔除 → 报可行动错误(禁静默回退)。
|
||||||
|
|
||||||
|
会话粘性(2026-09-11 用户定夺):同一会话对同一模型固定同一账号——上游
|
||||||
|
KV/前缀缓存按账号(api_key)隔离,固定账号可命中缓存省钱。粘性键
|
||||||
|
llm_sticky:{session_id}:{model_id}(TTL 1h 滑动续期);粘性账号失效
|
||||||
|
(停用/冷却/余额不足)时自动重选并写回。无 session_id 的调用退化为纯轮询。
|
||||||
|
|
||||||
返回 (ok, result):ok 时 result=(account_dict, endpoint_dict);否则 result=错误信息。
|
返回 (ok, result):ok 时 result=(account_dict, endpoint_dict);否则 result=错误信息。
|
||||||
"""
|
"""
|
||||||
@ -365,25 +402,67 @@ async def _pick_candidate(sor, model: dict, pref: str):
|
|||||||
return False, ('端点偏好为「仅 %s」,但没有可用的该区域端点。按合规要求不跨端点降级——'
|
return False, ('端点偏好为「仅 %s」,但没有可用的该区域端点。按合规要求不跨端点降级——'
|
||||||
'请配置该区域端点或调整组织策略的端点偏好' %
|
'请配置该区域端点或调整组织策略的端点偏好' %
|
||||||
('国内' if 'domestic' in pref else '国际'))
|
('国内' if 'domestic' in pref else '国际'))
|
||||||
# 冷却避让 + 余额加权选择
|
# 账号钱包门禁(2026-09-11 用户定夺):余额 < 阈值的账号从轮询集剔除。
|
||||||
|
# 阈值 params 可配(llm_wallet_balance_threshold,缺省 5 元);余额不足由
|
||||||
|
# 待办 provider 派生待办催充值(同一事项只发一次=状态派生,充值到账自动消失)。
|
||||||
|
wallet_min = await _wallet_threshold(sor)
|
||||||
|
low = [(acc, ep, i) for acc, ep, i in pairs if _fnum(acc.get('balance')) < wallet_min]
|
||||||
|
pairs = [(acc, ep, i) for acc, ep, i in pairs if _fnum(acc.get('balance')) >= wallet_min]
|
||||||
|
if not pairs:
|
||||||
|
names = '、'.join(sorted({str(a.get('name') or a.get('id', '')) for a, _, _ in low}))
|
||||||
|
return False, ('模型「%s」的全部候选账号钱包余额低于 %.2f 元(%s),已从轮询集剔除——'
|
||||||
|
'请为账号充值(模型治理→供应商→供应商账号→充值)或调低门禁阈值'
|
||||||
|
% (model.get('name', ''), wallet_min, names or '无'))
|
||||||
r = _redis()
|
r = _redis()
|
||||||
best, best_score = None, -1.0
|
|
||||||
for acc, ep, i in pairs:
|
def _in_cooldown(acc, i):
|
||||||
cool_key = 'llm_cool:%s:%d' % (acc.get('id', ''), i)
|
if r is None:
|
||||||
acct_key = 'llm_cool:%s:*' % (acc.get('id', ''),)
|
return False
|
||||||
try:
|
try:
|
||||||
if r is not None and (r.get(cool_key) or r.get(acct_key)):
|
return bool(r.get('llm_cool:%s:%d' % (acc.get('id', ''), i))
|
||||||
continue # 限流冷却中(端点级或账号级)
|
or r.get('llm_cool:%s:*' % (acc.get('id', ''),)))
|
||||||
|
except Exception:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# 会话粘性(2026-09-11 用户定夺:同会话固定账号,省上游缓存钱):
|
||||||
|
# 粘性账号仍在可用集且未冷却 → 直接用并续期;失效 → 落到轮询重选并写回。
|
||||||
|
sticky_key = ''
|
||||||
|
if session_id:
|
||||||
|
sticky_key = 'llm_sticky:%s:%s' % (session_id, model.get('id', ''))
|
||||||
|
try:
|
||||||
|
if r is not None:
|
||||||
|
want = r.get(sticky_key) or ''
|
||||||
|
if isinstance(want, bytes):
|
||||||
|
want = want.decode()
|
||||||
|
want = str(want).strip()
|
||||||
|
if want:
|
||||||
|
hit = [(acc, ep, i) for acc, ep, i in pairs
|
||||||
|
if acc.get('id', '') == want and not _in_cooldown(acc, i)]
|
||||||
|
if hit:
|
||||||
|
r.set(sticky_key, want, ex=_STICKY_TTL_SEC) # 滑动续期
|
||||||
|
acc, ep, i = hit[0]
|
||||||
|
return True, (acc, ep)
|
||||||
|
except Exception as e:
|
||||||
|
logger.warning("pipeline_llm: 会话粘性查询失败(退化为轮询): %s", e)
|
||||||
|
# 冷却避让 + 跨会话轮询:Redis INCR 原子计数对可用集取模(同模型同供应商
|
||||||
|
# 各会话轮流用不同账号;Redis 不可用时退化为首个可用账号,fail-open)。
|
||||||
|
usable = [(acc, ep, i) for acc, ep, i in pairs if not _in_cooldown(acc, i)]
|
||||||
|
if not usable:
|
||||||
|
return False, ('模型「%s」的全部候选账号都在限流冷却中,请稍后重试'
|
||||||
|
% model.get('name', ''))
|
||||||
|
idx = 0
|
||||||
|
try:
|
||||||
|
if r is not None:
|
||||||
|
idx = int(r.incr('llm_rr:%s' % model.get('id', ''))) % len(usable)
|
||||||
|
except Exception:
|
||||||
|
idx = 0
|
||||||
|
acc, ep, i = usable[idx]
|
||||||
|
if sticky_key:
|
||||||
|
try:
|
||||||
|
if r is not None:
|
||||||
|
r.set(sticky_key, acc.get('id', ''), ex=_STICKY_TTL_SEC)
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
score = _fnum(acc.get('balance'))
|
|
||||||
if score > best_score:
|
|
||||||
best, best_score = (acc, ep, i), score
|
|
||||||
if best is None:
|
|
||||||
return False, '模型「%s」的全部候选(账号×端点)都在限流冷却中,请稍后重试' % model.get('name', '')
|
|
||||||
acc, ep, i = best
|
|
||||||
if _fnum(acc.get('balance')) <= 0:
|
|
||||||
return False, '模型「%s」的候选账号余额均不足,请为账号充值' % model.get('name', '')
|
|
||||||
return True, (acc, ep)
|
return True, (acc, ep)
|
||||||
|
|
||||||
|
|
||||||
@ -462,7 +541,7 @@ async def _active_policy_org(sor, org_id: str) -> str:
|
|||||||
|
|
||||||
async def govern_resolve(org_id: str, user_id: str = '', model_name: str = '',
|
async def govern_resolve(org_id: str, user_id: str = '', model_name: str = '',
|
||||||
est_tokens: int = 0, task_ref: str = '', purpose: str = '',
|
est_tokens: int = 0, task_ref: str = '', purpose: str = '',
|
||||||
project_id: str = ''):
|
project_id: str = '', session_id: str = ''):
|
||||||
"""门禁链 ①-⑥。
|
"""门禁链 ①-⑥。
|
||||||
|
|
||||||
策略来源(2026-09 商业化默认):
|
策略来源(2026-09 商业化默认):
|
||||||
@ -473,6 +552,10 @@ async def govern_resolve(org_id: str, user_id: str = '', model_name: str = '',
|
|||||||
|
|
||||||
purpose='utility':辅助任务(分类/选择/摘要),链 = 辅助→主→备。显式 model_name 优先。
|
purpose='utility':辅助任务(分类/选择/摘要),链 = 辅助→主→备。显式 model_name 优先。
|
||||||
|
|
||||||
|
session_id(2026-09-11 用户定夺):会话粘性键——同一会话对同一模型固定同一
|
||||||
|
账号(上游 KV/前缀缓存按账号隔离,固定账号命中缓存省钱);跨会话轮询。
|
||||||
|
为空(如运行环境 token 路径只绑 project_id)退化为纯轮询,不报错。
|
||||||
|
|
||||||
记账决策(2026-09):**不做组织池预授权**——
|
记账决策(2026-09):**不做组织池预授权**——
|
||||||
本机构模型只记成本侧(供应商账号扣减),不产生组织/个人消费;
|
本机构模型只记成本侧(供应商账号扣减),不产生组织/个人消费;
|
||||||
owner 模型三方账(客户/商户/供应商)走异步产品计费链路。
|
owner 模型三方账(客户/商户/供应商)走异步产品计费链路。
|
||||||
@ -539,7 +622,7 @@ async def govern_resolve(org_id: str, user_id: str = '', model_name: str = '',
|
|||||||
last_err = ''
|
last_err = ''
|
||||||
chosen = None
|
chosen = None
|
||||||
for m in models:
|
for m in models:
|
||||||
ok, cand = await _pick_candidate(sor, m, pref)
|
ok, cand = await _pick_candidate(sor, m, pref, session_id)
|
||||||
if ok and isinstance(cand, tuple):
|
if ok and isinstance(cand, tuple):
|
||||||
chosen = (m, cand)
|
chosen = (m, cand)
|
||||||
break
|
break
|
||||||
@ -561,7 +644,7 @@ async def govern_resolve(org_id: str, user_id: str = '', model_name: str = '',
|
|||||||
if (m.get('capability') or 't2t').strip().lower()
|
if (m.get('capability') or 't2t').strip().lower()
|
||||||
in (_want, _alt)]
|
in (_want, _alt)]
|
||||||
for m in models0:
|
for m in models0:
|
||||||
ok, cand = await _pick_candidate(sor, m, pref0)
|
ok, cand = await _pick_candidate(sor, m, pref0, session_id)
|
||||||
if ok and isinstance(cand, tuple):
|
if ok and isinstance(cand, tuple):
|
||||||
chosen = (m, cand)
|
chosen = (m, cand)
|
||||||
policy_org = '0'
|
policy_org = '0'
|
||||||
@ -590,6 +673,7 @@ async def govern_resolve(org_id: str, user_id: str = '', model_name: str = '',
|
|||||||
'org_id': org_id or '',
|
'org_id': org_id or '',
|
||||||
'task_ref': task_ref or '',
|
'task_ref': task_ref or '',
|
||||||
'project_id': project_id or '',
|
'project_id': project_id or '',
|
||||||
|
'session_id': session_id or '',
|
||||||
'policy_org_id': policy_org,
|
'policy_org_id': policy_org,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -624,8 +708,12 @@ async def govern_settle(ctx: dict, ok_call: bool, note: str = '', usages: dict =
|
|||||||
try:
|
try:
|
||||||
model = ctx.get('model_row') or {}
|
model = ctx.get('model_row') or {}
|
||||||
# 2026-09-04:模型表不再存单价/成本四字段——成本与售价统一走 ppid 定价引擎,
|
# 2026-09-04:模型表不再存单价/成本四字段——成本与售价统一走 ppid 定价引擎,
|
||||||
# 出账由异步记账循环(accounting.py)经产品层计算;此处 cost 恒 0,
|
# 出账由异步记账循环(accounting.py)经产品层计算;此处 cost 恒 0。
|
||||||
# 供应商账号余额不再按调用扣减(真实成本以定价引擎结算为准)。
|
# 2026-09-11 用户定夺:账号钱包(llm_account.balance)扣减在**产品记账
|
||||||
|
# 完成之后**——出账 worker 记账成功拿到 supplier_cost 再扣本流水
|
||||||
|
# account_id 对应账号的余额(accounting.py::_deduct_wallet);调用时点
|
||||||
|
# 不扣(真实成本只有定价引擎能算)。流水的 account_id 就是"调用模型的
|
||||||
|
# 钱包"指针,扣减与对账都按它定位。
|
||||||
cost = 0.0
|
cost = 0.0
|
||||||
caller_org = ctx.get('org_id') or ''
|
caller_org = ctx.get('org_id') or ''
|
||||||
async with db.sqlorContext(dbname) as sor:
|
async with db.sqlorContext(dbname) as sor:
|
||||||
@ -633,13 +721,6 @@ async def govern_settle(ctx: dict, ok_call: bool, note: str = '', usages: dict =
|
|||||||
# 上游限流(429)失败 → 账号级冷却,轮转避让下一轮不再选它
|
# 上游限流(429)失败 → 账号级冷却,轮转避让下一轮不再选它
|
||||||
if '429' in str(note or ''):
|
if '429' in str(note or ''):
|
||||||
_mark_cooldown(ctx.get('account_id', ''), '*')
|
_mark_cooldown(ctx.get('account_id', ''), '*')
|
||||||
else:
|
|
||||||
# 成本侧:供应商账号余额扣减(本机构/owner 模型都扣——真实钱流)
|
|
||||||
if ctx.get('account_id') and cost > 0:
|
|
||||||
await sor.sqlExe(
|
|
||||||
"UPDATE llm_account SET balance=balance-${c}$ WHERE id=${i}$",
|
|
||||||
{"c": cost, "i": ctx.get('account_id', '')})
|
|
||||||
await sor.sqlExe("COMMIT", {})
|
|
||||||
from appPublic.uniqueID import getID
|
from appPublic.uniqueID import getID
|
||||||
usages_str = ''
|
usages_str = ''
|
||||||
if usages:
|
if usages:
|
||||||
|
|||||||
@ -142,12 +142,16 @@ def _texts_len(messages):
|
|||||||
# ────────────────────────── 解析链 ──────────────────────────
|
# ────────────────────────── 解析链 ──────────────────────────
|
||||||
|
|
||||||
async def _resolve_call(sor, org_id, user_id, model_name, capability,
|
async def _resolve_call(sor, org_id, user_id, model_name, capability,
|
||||||
est_tokens, task_ref, purpose='', project_id=''):
|
est_tokens, task_ref, purpose='', project_id='',
|
||||||
|
session_id=''):
|
||||||
"""门禁链 ①-⑥ + 加载模型/适配模板。
|
"""门禁链 ①-⑥ + 加载模型/适配模板。
|
||||||
|
|
||||||
purpose='utility':辅助任务(分类/选择/摘要),模型链为 辅助→主→备
|
purpose='utility':辅助任务(分类/选择/摘要),模型链为 辅助→主→备
|
||||||
(需机构策略配置 utility_model_id;未配则等同普通链)。
|
(需机构策略配置 utility_model_id;未配则等同普通链)。
|
||||||
|
|
||||||
|
session_id:会话粘性(2026-09-11 用户定夺:同会话固定账号省上游缓存钱,
|
||||||
|
跨会话轮询);空=纯轮询。
|
||||||
|
|
||||||
返回 dict(govern ctx + payload 所需全部信息)。任何一级不过抛 GovernError。
|
返回 dict(govern ctx + payload 所需全部信息)。任何一级不过抛 GovernError。
|
||||||
⚠️ 本层无 __LEGACY__ 兜底——新接口只认新表;机构未配策略直接报可行动错误。
|
⚠️ 本层无 __LEGACY__ 兜底——新接口只认新表;机构未配策略直接报可行动错误。
|
||||||
"""
|
"""
|
||||||
@ -158,7 +162,7 @@ async def _resolve_call(sor, org_id, user_id, model_name, capability,
|
|||||||
ok, res = await govern_resolve(
|
ok, res = await govern_resolve(
|
||||||
org_id=org_id, user_id=user_id or '', model_name=model_name or '',
|
org_id=org_id, user_id=user_id or '', model_name=model_name or '',
|
||||||
est_tokens=int(est_tokens or 0), task_ref=task_ref or '', purpose=purpose or '',
|
est_tokens=int(est_tokens or 0), task_ref=task_ref or '', purpose=purpose or '',
|
||||||
project_id=project_id or '')
|
project_id=project_id or '', session_id=session_id or '')
|
||||||
if not ok:
|
if not ok:
|
||||||
if res == '__LEGACY__':
|
if res == '__LEGACY__':
|
||||||
# 全平台无策略/指定模型不在新表:不再有旧表兜底,报可行动错误
|
# 全平台无策略/指定模型不在新表:不再有旧表兜底,报可行动错误
|
||||||
@ -842,10 +846,12 @@ async def _async_inference(ctx, payload, req_timeout):
|
|||||||
# ────────────────────────── 对外主入口 ──────────────────────────
|
# ────────────────────────── 对外主入口 ──────────────────────────
|
||||||
|
|
||||||
async def chat_inference(org_id, user_id, payload, model_name='', task_ref='',
|
async def chat_inference(org_id, user_id, payload, model_name='', task_ref='',
|
||||||
project_id=''):
|
project_id='', session_id=''):
|
||||||
# project_id(2026-09-10 用户定夺):token 绑定的项目 ID 透传落 llm_usage.project_id,
|
# project_id(2026-09-10 用户定夺):token 绑定的项目 ID 透传落 llm_usage.project_id,
|
||||||
# 非项目调用不传(govern_settle 兜底哨兵'0'),支撑按项目统计费用。
|
# 非项目调用不传(govern_settle 兜底哨兵'0'),支撑按项目统计费用。
|
||||||
# 调用方(llm_proxy/v1 dspy)负责传入。
|
# 调用方(llm_proxy/v1 dspy)负责传入。
|
||||||
|
# session_id(2026-09-11 用户定夺):会话粘性账号键——同会话固定账号省上游
|
||||||
|
# 缓存钱;调用方经 payload._session_id 或显式参数传入(llm_bridge 透传)。
|
||||||
"""统一推理:门禁链 → 上游调用 → 结算。返回上游响应 dict(OpenAI 兼容形态)。
|
"""统一推理:门禁链 → 上游调用 → 结算。返回上游响应 dict(OpenAI 兼容形态)。
|
||||||
|
|
||||||
payload: 客户端请求体(messages 必需;tools/temperature/max_tokens 透传;
|
payload: 客户端请求体(messages 必需;tools/temperature/max_tokens 透传;
|
||||||
@ -871,13 +877,17 @@ async def chat_inference(org_id, user_id, payload, model_name='', task_ref='',
|
|||||||
purpose = (payload.pop('_purpose', '') or '')
|
purpose = (payload.pop('_purpose', '') or '')
|
||||||
# 单次调用超时覆盖(长文本提取等慢任务):剥离不透传,上限 900 秒
|
# 单次调用超时覆盖(长文本提取等慢任务):剥离不透传,上限 900 秒
|
||||||
req_timeout = int(_fnum(payload.pop('_timeout', 0)) or 0)
|
req_timeout = int(_fnum(payload.pop('_timeout', 0)) or 0)
|
||||||
|
# 会话粘性键(2026-09-11 用户定夺):payload._session_id 优先于显式参数
|
||||||
|
# (llm_bridge 经 payload 透传,与 _purpose/_timeout 同通道);剥离不上行
|
||||||
|
_sid = (payload.pop('_session_id', '') or '')
|
||||||
|
session_id = str(_sid or session_id or '')
|
||||||
|
|
||||||
est = max(_texts_len(payload.get('messages')) // EST_TOKENS_PER_CHAR, 200)
|
est = max(_texts_len(payload.get('messages')) // EST_TOKENS_PER_CHAR, 200)
|
||||||
db, dbname = _get_db()
|
db, dbname = _get_db()
|
||||||
async with db.sqlorContext(dbname) as sor:
|
async with db.sqlorContext(dbname) as sor:
|
||||||
ctx = await _resolve_call(
|
ctx = await _resolve_call(
|
||||||
sor, org_id, user_id or '', model_name, '', est, task_ref, purpose,
|
sor, org_id, user_id or '', model_name, '', est, task_ref, purpose,
|
||||||
project_id=project_id or '')
|
project_id=project_id or '', session_id=session_id)
|
||||||
# 调用批次 ID(2026-09-10 用户定夺:上行下行原文唯一落盘 + llm_usage 关联):
|
# 调用批次 ID(2026-09-10 用户定夺:上行下行原文唯一落盘 + llm_usage 关联):
|
||||||
# 一次 chat_inference = N 次上游往返(重试/异步轮询),trace 按 call_id+seq 组织,
|
# 一次 chat_inference = N 次上游往返(重试/异步轮询),trace 按 call_id+seq 组织,
|
||||||
# llm_usage.call_id 同值 → 流水可反查全部原文(trace.record_roundtrip / llm_call_trace)
|
# llm_usage.call_id 同值 → 流水可反查全部原文(trace.record_roundtrip / llm_call_trace)
|
||||||
|
|||||||
@ -828,6 +828,17 @@ def load_pipeline_llm():
|
|||||||
'load_product_category_product': load_product_category_product,
|
'load_product_category_product': load_product_category_product,
|
||||||
}
|
}
|
||||||
env.load_product_category_product_llm = load_product_category_product
|
env.load_product_category_product_llm = load_product_category_product
|
||||||
|
# 平台待办 provider(软注册,2026-09-11:账号钱包余额不足 → 属主机构
|
||||||
|
# operator 角色催充值待办;pipeline-service 没装/没钩子时只告警不崩,
|
||||||
|
# 与 ticket 模块同款模式)
|
||||||
|
try:
|
||||||
|
from pipeline_service.human_task_capability import register_todo_provider
|
||||||
|
from .todos import list_wallet_todos
|
||||||
|
register_todo_provider(list_wallet_todos)
|
||||||
|
logger.info("[pipeline_llm] wallet todo provider registered")
|
||||||
|
except ImportError:
|
||||||
|
logger.warning("[pipeline_llm] pipeline_service.register_todo_provider 不可用,"
|
||||||
|
"钱包余额待办不会出现在平台待办(模块可独立运行)")
|
||||||
logger.info("[pipeline_llm] v1.0.0 loaded — 模型治理模块就绪")
|
logger.info("[pipeline_llm] v1.0.0 loaded — 模型治理模块就绪")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
114
pipeline_llm/todos.py
Normal file
114
pipeline_llm/todos.py
Normal file
@ -0,0 +1,114 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
"""pipeline_llm.todos — 平台待办 provider:账号钱包余额不足(2026-09-11 用户定夺)。
|
||||||
|
|
||||||
|
钱包余额改变的两个目的(用户原话):
|
||||||
|
1) 哪个钱包什么时候该充值了 → 本 provider 派生待办给属主机构 operator 催充值
|
||||||
|
2) 哪个钱包不能再调用 llm 了 → gateway._pick_candidate 门禁剔除(balance<阈值)
|
||||||
|
|
||||||
|
待办由**状态派生**(不建通知表):balance < 阈值 的 active 账号即待办;充值
|
||||||
|
到账余额回升 → 待办自动消失。「同一事项只发一次」不变量天然满足(human-task-
|
||||||
|
issuance-gate 模式,与 ticket.todos 同款)。
|
||||||
|
|
||||||
|
接收人:账号属主机构(llm_account.org_id,空则回溯 llm_vendor.org_id)中持有
|
||||||
|
operator 角色的用户。角色名 params 可配(llm_wallet_todo_role,缺省 operator)
|
||||||
|
——角色/机构类型运行时可动态增删,禁硬编码(用户铁律);roles 为
|
||||||
|
「{orgtypeid}.{name}」格式,按 . 后的名字匹配,不锁死 orgtypeid。
|
||||||
|
|
||||||
|
provider 签名(pipeline_service.human_task_capability.register_todo_provider):
|
||||||
|
async fn(user_id, roles, limit) -> [todo dict]
|
||||||
|
"""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from .gateway import _get_db, _get_param
|
||||||
|
|
||||||
|
logger = logging.getLogger("pipeline_llm.todos")
|
||||||
|
|
||||||
|
_DEFAULT_ROLE = 'operator'
|
||||||
|
|
||||||
|
|
||||||
|
async def _wallet_threshold(sor) -> float:
|
||||||
|
v = await _get_param(sor, 'llm_wallet_balance_threshold', '5')
|
||||||
|
try:
|
||||||
|
return float(v)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return 5.0
|
||||||
|
|
||||||
|
|
||||||
|
def _match_role(roles, want):
|
||||||
|
"""roles=['reseller.operator',...];匹配 . 后的角色名(orgtypeid 运行时动态)。"""
|
||||||
|
for r in (roles or []):
|
||||||
|
name = str(r).split('.')[-1].strip().lower()
|
||||||
|
if name == want:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def list_wallet_todos(user_id, roles=None, limit=100):
|
||||||
|
"""平台待办聚合器调用的 provider。roles 缺省时自查。"""
|
||||||
|
if not user_id:
|
||||||
|
return []
|
||||||
|
db, dbname = _get_db()
|
||||||
|
async with db.sqlorContext(dbname) as sor:
|
||||||
|
threshold = await _wallet_threshold(sor)
|
||||||
|
want_role = str(await _get_param(
|
||||||
|
sor, 'llm_wallet_todo_role', _DEFAULT_ROLE)).strip().lower()
|
||||||
|
# 用户机构(属主机构匹配用)
|
||||||
|
recs = await sor.sqlExe(
|
||||||
|
"SELECT orgid FROM users WHERE id=${u}$ LIMIT 1", {"u": str(user_id)})
|
||||||
|
await sor.sqlExe("COMMIT", {})
|
||||||
|
user_org = str(getattr(recs[0], 'orgid', '') or '') if recs else ''
|
||||||
|
if roles is None:
|
||||||
|
rrecs = await sor.sqlExe(
|
||||||
|
"SELECT r.orgtypeid, r.name FROM userrole ur JOIN role r ON ur.roleid=r.id "
|
||||||
|
"WHERE ur.userid=${u}$", {"u": str(user_id)})
|
||||||
|
await sor.sqlExe("COMMIT", {})
|
||||||
|
roles = []
|
||||||
|
for r in (rrecs or []):
|
||||||
|
o = getattr(r, 'orgtypeid', '') or ''
|
||||||
|
n = getattr(r, 'name', '') or ''
|
||||||
|
if o and n:
|
||||||
|
roles.append(f"{o}.{n}")
|
||||||
|
if not _match_role(roles, want_role):
|
||||||
|
return []
|
||||||
|
# 余额不足的 active 账号(属主机构=用户机构;账号 org_id 空回溯供应商 org_id)
|
||||||
|
recs = await sor.sqlExe(
|
||||||
|
"SELECT a.id, a.name, a.balance, a.org_id, a.vendor_id, v.name AS vendor_name, "
|
||||||
|
"COALESCE(NULLIF(a.org_id,''), v.org_id, '0') AS owner_org "
|
||||||
|
"FROM llm_account a LEFT JOIN llm_vendor v ON v.id=a.vendor_id "
|
||||||
|
"WHERE a.status='active' AND a.balance < ${t}$ "
|
||||||
|
"ORDER BY a.balance ASC LIMIT ${lim}$",
|
||||||
|
{"t": threshold, "lim": int(limit) * 4})
|
||||||
|
await sor.sqlExe("COMMIT", {})
|
||||||
|
|
||||||
|
todos = []
|
||||||
|
seen = 0
|
||||||
|
for r in (recs or []):
|
||||||
|
owner_org = str(getattr(r, 'owner_org', '') or '0')
|
||||||
|
if owner_org != (user_org or '0'):
|
||||||
|
continue # 只发给属主机构的人
|
||||||
|
if seen >= int(limit):
|
||||||
|
break
|
||||||
|
seen += 1
|
||||||
|
acc_id = str(getattr(r, 'id', '') or '')
|
||||||
|
balance = float(getattr(r, 'balance', 0) or 0)
|
||||||
|
acc_name = str(getattr(r, 'name', '') or acc_id)
|
||||||
|
vendor_name = str(getattr(r, 'vendor_name', '') or '')
|
||||||
|
todos.append({
|
||||||
|
'id': 'llm_wallet_' + acc_id,
|
||||||
|
'source': 'llm_wallet',
|
||||||
|
'task_type': 'llm_wallet_recharge',
|
||||||
|
'badge': '钱包充值',
|
||||||
|
'title': '【钱包充值】%s%s' % (
|
||||||
|
('%s·' % vendor_name) if vendor_name else '', acc_name),
|
||||||
|
'description': '模型供应商账号钱包余额 %.2f 元,低于门禁阈值 %.2f 元——'
|
||||||
|
'该账号已从模型调用轮询集剔除(充值到账后自动恢复并关闭本待办)'
|
||||||
|
% (balance, threshold),
|
||||||
|
'account_id': acc_id,
|
||||||
|
'balance': round(balance, 4),
|
||||||
|
'threshold': threshold,
|
||||||
|
'project_id': '',
|
||||||
|
'status': 'pending',
|
||||||
|
'created_at': None,
|
||||||
|
})
|
||||||
|
return todos
|
||||||
119
wwwroot/api/llm_wallet_todo_popup.dspy
Normal file
119
wwwroot/api/llm_wallet_todo_popup.dspy
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
# llm_wallet_todo_popup.dspy — 钱包余额不足待办详情弹窗(2026-09-11 用户定夺)
|
||||||
|
# 平台待办 source='llm_wallet' 的「查看内容并处理」入口。
|
||||||
|
# 入参: id=llm_wallet_<account_id> 或 account_id=<账号id>
|
||||||
|
# 展示账号钱包状态 + 充值表单(金额/备注)→ POST llm_account_recharge.dspy;
|
||||||
|
# 充值到账余额回升 → 待办由状态派生自动消失(provider 不再返回)。
|
||||||
|
|
||||||
|
import json as _json
|
||||||
|
|
||||||
|
uid = await get_user()
|
||||||
|
if not uid:
|
||||||
|
return {"widgettype": "Message", "options": {"title": "未登录", "message": "请先登录后查看待办"}}
|
||||||
|
|
||||||
|
pk = params_kw or {}
|
||||||
|
acc_id = str(pk.get('account_id') or '').strip()
|
||||||
|
if not acc_id:
|
||||||
|
raw = str(pk.get('id') or '').strip()
|
||||||
|
if raw.startswith('llm_wallet_'):
|
||||||
|
acc_id = raw[len('llm_wallet_'):]
|
||||||
|
else:
|
||||||
|
acc_id = raw
|
||||||
|
if not acc_id:
|
||||||
|
return {"widgettype": "Message", "options": {"title": "打开失败", "message": "缺少账号 id"}}
|
||||||
|
|
||||||
|
user_org = str(await get_userorgid() or '0')
|
||||||
|
|
||||||
|
dbname = get_module_dbname('pipeline-llm')
|
||||||
|
async with DBPools().sqlorContext(dbname) as sor:
|
||||||
|
recs = await sor.sqlExe(
|
||||||
|
"SELECT a.id, a.name, a.balance, a.total_recharge, a.status, a.org_id, "
|
||||||
|
"v.name AS vendor_name, COALESCE(NULLIF(a.org_id,''), v.org_id, '0') AS owner_org "
|
||||||
|
"FROM llm_account a LEFT JOIN llm_vendor v ON v.id=a.vendor_id "
|
||||||
|
"WHERE a.id=${i}$ LIMIT 1", {"i": acc_id})
|
||||||
|
await sor.sqlExe("COMMIT", {})
|
||||||
|
trecs = await sor.sqlExe(
|
||||||
|
"SELECT params_value FROM params WHERE params_name='llm_wallet_balance_threshold' LIMIT 1", {})
|
||||||
|
await sor.sqlExe("COMMIT", {})
|
||||||
|
|
||||||
|
if not recs:
|
||||||
|
return {"widgettype": "Message", "options": {"title": "打开失败", "message": "账号不存在或已删除"}}
|
||||||
|
|
||||||
|
row = recs[0]
|
||||||
|
owner_org = str(row['owner_org'] or '0')
|
||||||
|
# 机构隔离:平台业主('0')可看全部;否则只看本属主机构的账号
|
||||||
|
if user_org != '0' and owner_org not in ('', user_org):
|
||||||
|
return {"widgettype": "Message", "options": {"title": "无权访问", "message": "该账号属于其他机构"}}
|
||||||
|
|
||||||
|
balance = float(row['balance'] or 0)
|
||||||
|
threshold = 5.0
|
||||||
|
try:
|
||||||
|
if trecs and str(trecs[0]['params_value'] or '').strip():
|
||||||
|
threshold = float(str(trecs[0]['params_value']).strip())
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
acc_name = str(row['name'] or acc_id)
|
||||||
|
vendor_name = str(row['vendor_name'] or '')
|
||||||
|
|
||||||
|
head_md = ['## 供应商账号钱包', '',
|
||||||
|
'**账号**:' + (('【%s】' % vendor_name) if vendor_name else '') + acc_name,
|
||||||
|
'**当前余额**:%.4f 元 | **门禁阈值**:%.2f 元 | **累计充值**:%.4f 元'
|
||||||
|
% (balance, threshold, float(row['total_recharge'] or 0)),
|
||||||
|
'**账号状态**:' + str(row['status'] or ''), '']
|
||||||
|
if balance < threshold:
|
||||||
|
head_md += ['> ⚠️ 余额低于门禁阈值——该账号已从模型调用轮询集剔除,'
|
||||||
|
'调用其下模型将顺延到其他账号;全部账号不足时调用报可行动错误。',
|
||||||
|
'> 充值到账后待办自动消失、账号自动恢复轮询。', '']
|
||||||
|
else:
|
||||||
|
head_md += ['> ✅ 余额已恢复到阈值以上,账号在轮询集中正常可用,本待办即将自动关闭。', '']
|
||||||
|
|
||||||
|
recharge_url = entire_url('/pipeline-llm/api/llm_account_recharge.dspy')
|
||||||
|
|
||||||
|
|
||||||
|
def _read_input_js(wid):
|
||||||
|
return ("var cw=bricks.getWidgetById(" + _json.dumps(wid) + ",bricks.app);var cv='';"
|
||||||
|
"if(cw){cv=(typeof cw.resultValue==='function')?cw.resultValue():"
|
||||||
|
"((cw.dom_element&&cw.dom_element.value)||'');}"
|
||||||
|
"cv=(cv===null||cv===undefined)?'':String(cv).trim();")
|
||||||
|
|
||||||
|
|
||||||
|
_tail = ("var pw=bricks.getWidgetById('llm_wallet_todo_pw',bricks.app);"
|
||||||
|
"if(pw&&pw.destroy){pw.destroy();}"
|
||||||
|
"if(window.refreshTodo){window.refreshTodo();}"
|
||||||
|
"if(d&&d.success){var mo=new bricks.Message({title:'充值成功',message:d.message||'钱包已充值'});mo.open();}"
|
||||||
|
"else{var mf=new bricks.Message({title:'充值失败',message:(d&&(d.message||d.error))||'充值失败'});mf.open();}")
|
||||||
|
|
||||||
|
do_js = (_read_input_js('llm_wallet_amt')
|
||||||
|
+ "var amt=parseFloat(cv);"
|
||||||
|
+ "if(isNaN(amt)||amt<=0){new bricks.Message({title:'请填写金额',message:'充值金额必须是大于 0 的数字'}).open();return;}"
|
||||||
|
+ _read_input_js('llm_wallet_note')
|
||||||
|
+ "var r=await fetch(" + _json.dumps(recharge_url) + ",{method:'POST',"
|
||||||
|
"headers:{'Content-Type':'application/json'},"
|
||||||
|
"body:JSON.stringify({account_id:" + _json.dumps(acc_id) + ",amount:amt,note:cv})});"
|
||||||
|
"var d=await r.json();" + _tail)
|
||||||
|
|
||||||
|
sub = [
|
||||||
|
{"widgettype": "MdWidget", "options": {"mdtext": '\n'.join(head_md), "width": "100%"}},
|
||||||
|
{"widgettype": "HBox",
|
||||||
|
"options": {"width": "100%", "gap": "8px", "alignItems": "center"},
|
||||||
|
"subwidgets": [
|
||||||
|
{"widgettype": "Text", "options": {"text": "充值金额(元):", "cfontsize": 0.9, "color": "#334155"}},
|
||||||
|
{"widgettype": "UiText", "id": "llm_wallet_amt",
|
||||||
|
"options": {"name": "llm_wallet_amt", "placeholder": "如 100.00", "width": "160px"}},
|
||||||
|
{"widgettype": "UiText", "id": "llm_wallet_note",
|
||||||
|
"options": {"name": "llm_wallet_note", "placeholder": "备注(可选,如银行流水号)",
|
||||||
|
"flex": "1 1 auto"}},
|
||||||
|
{"widgettype": "Button",
|
||||||
|
"options": {"label": "💰 确认充值", "css": "primary small"},
|
||||||
|
"binds": [{"wid": "self", "event": "click", "actiontype": "script",
|
||||||
|
"target": "self", "script": do_js}]}]}]
|
||||||
|
|
||||||
|
return {
|
||||||
|
"widgettype": "PopupWindow",
|
||||||
|
"id": "llm_wallet_todo_pw",
|
||||||
|
"options": {"title": "钱包充值 — " + acc_name, "width": "52%", "height": "46%",
|
||||||
|
"auto_open": True, "resizable": True},
|
||||||
|
"subwidgets": [{
|
||||||
|
"widgettype": "VScrollPanel",
|
||||||
|
"options": {"css": "filler", "width": "100%", "height": "100%", "padding": "14px"},
|
||||||
|
"subwidgets": sub}]
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user