"""pipeline_llm.accounting — 异步出账循环(owner 模型三方账)。 扫 llm_usage 中 accounting_status='created' 且 status='SUCCEEDED' 的用量流水, 逐条映射模型→产品,调产品层 product_accounting_generic 记账 (产品层内部按 is_self_use 分流:自用只记 PAY* 采购成本;跨机构另记 PAY 客户应付, 折扣精确到产品),成功置 'accounted',失败置 'failed'(三态:created/accounted/failed)。 2026-09-06 用户定夺:status 统一枚举 SUCCEEDED/FAILED/PENDING/RUNNING; 只有 SUCCEEDED 的行才写 accounting_status(其余 NULL),出账循环自然只拾取 SUCCEEDED。 与账号/存储的同步记账分工明确(2026-09 用户定夺): - 账号/存储:purchase_realtime 同步记账 - 模型(本模块):调用时只记成本侧 + created 流水,出账走本循环(异步) 单循环守卫(多进程部署时防重复出账):Redis SETNX llm_acc:lock(db4,与限流同库)。 """ import asyncio import json import logging import time from ahserver.serverenv import ServerEnv from .gateway import _get_db, _redis logger = logging.getLogger("pipeline_llm.accounting") _SCAN_INTERVAL = 60 # 扫描间隔(秒) _BATCH = 200 # 每批处理条数 _LOCK_KEY = 'llm_acc:lock' _LOCK_TTL = 90 # 锁 TTL(略大于单批预算耗时) async def _get_pending(sor): recs = await sor.sqlExe( "SELECT id, org_id, user_id, model_id, req_tokens, resp_tokens, cost, ppid, usages " "FROM llm_usage WHERE accounting_status='created' AND status='SUCCEEDED' " "ORDER BY created_at LIMIT %d" % _BATCH, {}) await sor.sqlExe("COMMIT", {}) return recs or [] async def _find_product_id(sor, model_id): """llm_model.id → product.id(产品层导入时按 resource_ref_id 建立)。""" recs = await sor.sqlExe( "SELECT id FROM product WHERE resource_ref_id=${r}$ AND status='1' LIMIT 1", {"r": model_id}) await sor.sqlExe("COMMIT", {}) return (getattr(recs[0], 'id', '') or '') if recs else '' async def _settle_one(sor, row): """单条出账。成功置 accounted;失败置 failed 并记录原因(不静默重试死循环)。""" env = ServerEnv() model_id = getattr(row, 'model_id', '') or '' product_id = await _find_product_id(sor, model_id) if not product_id: await sor.U('llm_usage', { 'id': row.id, 'accounting_status': 'failed', 'note': '出账失败:模型(%s)未映射到产品,请先在产品管理导入产线模型' % model_id, }) await sor.sqlExe("COMMIT", {}) logger.warning("pipeline_llm.accounting: model %s 无产品映射,流水 %s 置 failed", model_id, row.id) return False usage_data = { 'prompt_tokens': int(getattr(row, 'req_tokens', 0) or 0), 'completion_tokens': int(getattr(row, 'resp_tokens', 0) or 0), } # 非 token 计价因子(视频时长/分辨率/模型名等,2026-09-05): # llm_usage.usages JSON 合并进定价引擎输入,按量计费模型(视频/图像)用 usages_raw = getattr(row, 'usages', '') or '' if usages_raw: try: extra = json.loads(usages_raw) if isinstance(extra, dict): usage_data.update(extra) except Exception: pass fn = getattr(env, 'product_accounting_generic', None) if fn is None: # 产品模块未加载 → 保留 created 下轮再试 return False result = await fn( product_id=product_id, usage_data=usage_data, userorgid=getattr(row, 'org_id', '') or '', userid=getattr(row, 'user_id', '') or '', ) if not result or not result.get('success', result.get('orderid')): msg = str((result or {}).get('message', result))[:180] await sor.U('llm_usage', { 'id': row.id, 'accounting_status': 'failed', 'note': '出账失败:%s' % msg, }) await sor.sqlExe("COMMIT", {}) logger.error("pipeline_llm.accounting: 流水 %s 出账失败: %s", row.id, msg) return False # 回填客户应付 + 置已出账 await sor.sqlExe( "UPDATE llm_usage SET charge=${c}$, accounting_status='accounted', " "note=CONCAT(COALESCE(note,''),'|accounted:',${o}$) WHERE id=${i}$", {"c": float((result or {}).get('customer_amount', 0) or 0), "o": str((result or {}).get('orderid', ''))[:40], "i": row.id}) await sor.sqlExe("COMMIT", {}) return True async def _run_one_pass(): """一轮扫描-出账。返回处理条数。""" db, dbname = _get_db() async with db.sqlorContext(dbname) as sor: rows = await _get_pending(sor) if not rows: return 0 ok_n = 0 for row in rows: try: db, dbname = _get_db() async with db.sqlorContext(dbname) as sor: if await _settle_one(sor, row): ok_n += 1 except Exception as e: logger.error("pipeline_llm.accounting: 流水 %s 出账异常: %s", getattr(row, 'id', ''), e) try: db, dbname = _get_db() async with db.sqlorContext(dbname) as sor: await sor.U('llm_usage', { 'id': row.id, 'accounting_status': 'failed', 'note': '出账异常:%s' % str(e)[:180], }) await sor.sqlExe("COMMIT", {}) except Exception: pass return ok_n async def llm_usage_accounting_loop(): """后台循环:抢分布式锁 → 出账一批 → 睡。永不抛出。 只由独立记账进程(app/pipeline_accounting_worker.py)运行, 不挂进 web 服务器的事件循环(同步记账在 web,异步记账在独立进程)。 """ logger.info("[pipeline_llm] 异步出账循环已启动(间隔 %ds)", _SCAN_INTERVAL) while True: try: r = _redis() if r is not None: # 抢锁(多进程只跑一个);Redis 故障则直接跑(与限流 fail-open 一致) got = False try: got = bool(r.set(_LOCK_KEY, str(time.time()), nx=True, ex=_LOCK_TTL)) except Exception: got = True if not got: await asyncio.sleep(_SCAN_INTERVAL) continue await _run_one_pass() except Exception as e: logger.error("pipeline_llm.accounting: 循环异常(继续): %s", e) await asyncio.sleep(_SCAN_INTERVAL)