package service import ( "context" "fmt" commonHttp "gitea.redpowerfuture.com/red-future/common/http" "gitea.redpowerfuture.com/red-future/common/utils" "github.com/gogf/gf/v2/frame/g" ) // ====================== shop-user-trade 计价对接(独立 module,本地 JSON 对齐) ====================== // shopPricingConfig shop-user-trade config/get 响应(仅取启用标记与最低余额门限;费率规则在 shop-user-trade 侧,model-gateway 不消费) type shopPricingConfig struct { Enabled int `json:"enabled"` MinBalance float64 `json:"minBalance" dc:"门禁:调用前可用余额须>=该值(元),0=不校验"` } // shopWalletAccount shop-user-trade wallet/account/get 响应 type shopWalletAccount struct { ID int64 `json:"id"` UserID int64 `json:"userId"` Balance float64 `json:"balance" dc:"可用余额(元),负值=欠费"` Currency string `json:"currency"` Status int `json:"status" dc:"状态:1启用 0禁用 -1冻结"` } // shopCalcFeeRes shop-user-trade /calc 响应 type shopCalcFeeRes struct { Cost float64 `json:"cost"` } // pricingURL 组装 shop-user-trade 计价接口地址(common http.RouteRegister 推导前缀,同 ai-agent billing.go) func pricingURL(sub string) string { return "shop-user-trade/pricing/controller/" + sub } // walletURL 组装 shop-user-trade 钱包接口地址(accountController → account/controller,与 pricing 同 RouteRegister 推导规则) func walletURL(sub string) string { return "shop-user-trade/account/controller/" + sub } // modelBillable 调用前门禁:模型须在 shop-user-trade 已配置且启用计价,否则阻塞调用。 // 替换原 CheckTenantBalance(admin-go 租户余额门禁);未配置→config/get 返回错误,未启用→enabled!=1。 // minBalance>0 时追加最低余额门禁:钱包须存在且可用余额 >= 门限(与 shop-user-trade open_order 同语义,0=不校验)。 func modelBillable(ctx context.Context, modelId int64) error { var cfg shopPricingConfig err := commonHttp.Get(ctx, pricingURL("config/get"), utils.HeadersFromCtx(ctx, utils.HeadersOptions{ResolveToken: true}), &cfg, "subjectType", "model", "subjectId", fmt.Sprintf("%d", modelId)) if err != nil { return fmt.Errorf("模型未配置计价,无法调用: %w", err) } if cfg.Enabled != 1 { return fmt.Errorf("模型未启用计价,无法调用") } if cfg.MinBalance <= 0 { return nil } user, e := utils.GetUserInfo(ctx) if e != nil || user == nil || user.Id == 0 { return fmt.Errorf("取不到用户,无法校验最低余额") } var acc shopWalletAccount if e = commonHttp.Get(ctx, walletURL("get"), utils.HeadersFromCtx(ctx, utils.HeadersOptions{ResolveToken: true}), &acc, "userId", fmt.Sprintf("%d", user.Id)); e != nil { return fmt.Errorf("获取钱包失败,无法校验最低余额: %w", e) } if acc.Status != 1 { return fmt.Errorf("钱包不可用,无法调用") } if acc.Balance < cfg.MinBalance { return fmt.Errorf("余额不足:可用余额须不低于 %.2f 元才能发起", cfg.MinBalance) } return nil } // buildModelUsage 组装算费用量 JSON 对象(ChargeUsage 形状)。mediaType 为 shop 计费词汇 // (audio/video,空=无媒体引用走默认价;DetectMediaType/异步快照已直接为该词汇,不再二次转换)。 // per_char 模型把输出字数映射到 completionTokens(TokenMapping),随该字段传给 shop /calc 计价。 func buildModelUsage(prompt, completion, cached int64, mediaType string, durationSec int64) map[string]any { if durationSec < 0 { durationSec = 0 } return map[string]any{ "promptTokens": prompt, "completionTokens": completion, "cachedTokens": cached, "mediaType": mediaType, "durationSec": durationSec, } } // calcModelCost 调 shop-user-trade /calc 按用量算费(不建单不扣费)。 // 调用前门禁已保证配置存在;此处失败(配置中途删除/网络抖动)→ 记日志返回 0,不拖垮已完成的模型调用。 func calcModelCost(ctx context.Context, modelId int64, usage map[string]any) float64 { var res shopCalcFeeRes err := commonHttp.Post(ctx, pricingURL("calc"), utils.HeadersFromCtx(ctx, utils.HeadersOptions{ResolveToken: true}), &res, &struct { SubjectType string `json:"subjectType"` SubjectID string `json:"subjectId"` Usage map[string]any `json:"usage"` }{ SubjectType: "model", SubjectID: fmt.Sprintf("%d", modelId), Usage: usage, }) if err != nil { g.Log().Errorf(ctx, "[算费] 调用 shop-user-trade 失败 modelId=%d: %v", modelId, err) return 0 } return res.Cost }