package service import ( "context" "errors" "fmt" "model-gateway/consts/model" "model-gateway/dao" "model-gateway/model/dto" "model-gateway/model/entity" modelUtils "model-gateway/service/utils" "net/http" "time" "gitea.redpowerfuture.com/red-future/common/utils" "github.com/gogf/gf/v2/database/gredis" "github.com/gogf/gf/v2/errors/gerror" "github.com/gogf/gf/v2/frame/g" "github.com/gogf/gf/v2/os/glog" "github.com/gogf/gf/v2/util/gconv" ) var ModelCall = &modelCallService{} type modelCallService struct{} // minTenantSurplusForUse 调用模型前租户余额最低门槛(元),余额须大于该值才允许调用 const minTenantSurplusForUse = 200.0 // CheckTenantBalance 调用模型前检查租户余额:余额须大于 minTenantSurplusForUse 才允许调用。 // 返回当前余额;余额不足或获取失败返回错误。 func CheckTenantBalance(ctx context.Context, tenantId uint64) (surplus float64, err error) { surplus, err = GetTenantSurplus(ctx, tenantId) if err != nil { return 0, fmt.Errorf("获取租户余额失败: %w", err) } if surplus <= minTenantSurplusForUse { return surplus, fmt.Errorf("租户余额不足,无法调用模型(需余额大于%.0f元,当前余额%.2f元)", minTenantSurplusForUse, surplus) } return surplus, nil } func (s *modelCallService) ModelCall(ctx context.Context, req *dto.ModelCallReq) (res *dto.ModelCallRes, err error) { // 1) 检查模型配置 var modelInfo *entity.ModelManage modelInfo, err = dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{ Id: req.ModelId, }) if err != nil { return nil, fmt.Errorf("获取模型配置失败: %v", err) } if modelInfo == nil || (modelInfo.Enabled != nil && !*modelInfo.Enabled) { return nil, fmt.Errorf("模型不存在或未启用") } now := time.Now() userInfo, err := utils.GetUserInfo(ctx) if err != nil { return } // 调用前检查租户余额:余额须大于 minTenantSurplusForUse 才允许调用 if _, err = CheckTenantBalance(ctx, userInfo.TenantId); err != nil { return nil, err } err = queue(ctx, modelInfo.ModelName, userInfo.TenantId, gconv.Int64(modelInfo.MaxConcurrency), func(ctx context.Context) (err error) { if *modelInfo.ResponseType == *model.ResponseTypeSync.Code() || *modelInfo.ResponseType == *model.ResponseTypeStream.Code() { var newRequestParams map[string]any var id int64 id, newRequestParams, err = s.saveModelRequestParams(ctx, now, modelInfo, req) if err != nil { return err } if g.IsEmpty(id) || g.IsEmpty(newRequestParams) { return fmt.Errorf("保存模型请求参数失败") } if *modelInfo.ResponseType == *model.ResponseTypeSync.Code() { res, err = ModelSession.CreateSession(ctx, &dto.CallModelSessionReq{ Id: id, ModelInfo: modelInfo, RequestParams: newRequestParams, }) } else { res, err = ModelSession.CreateSessionStreamOnce(ctx, &dto.CallModelSessionReq{ Id: id, ModelInfo: modelInfo, RequestParams: newRequestParams, }) } } if *modelInfo.ResponseType == *model.ResponseTypeAsync.Code() { if g.IsEmpty(req.MsgTopic) { return fmt.Errorf("请指定消息主题") } var newRequestParams map[string]any var id int64 id, newRequestParams, err = s.saveModelRequestParams(ctx, now, modelInfo, req) if err != nil { return err } if g.IsEmpty(id) || g.IsEmpty(newRequestParams) { return fmt.Errorf("保存模型请求参数失败") } res, err = ModelTaskStart.CreateTask(ctx, &dto.CallModelTaskStartReq{ Id: id, ModelInfo: modelInfo, RequestParams: newRequestParams, }) } // 扣减本次调用费用:同步/一次性流式返回实际费用;异步提交费用为 0 跳过,由任务完成时(handleSingleTask)扣减 if err == nil && res != nil && res.Cost > 0 { if dedErr := DeductBalance(ctx, userInfo.TenantId, res.Cost); dedErr != nil { g.Log().Errorf(ctx, "[扣减余额] 模型调用扣费失败 modelId=%d cost=%.6f err=%v", modelInfo.Id, res.Cost, dedErr) } } return }) return } func (s *modelCallService) ModelCallStream(ctx context.Context, w http.ResponseWriter, req *dto.ModelCallStreamReq) (err error) { // 1) 检查模型配置 var modelInfo *entity.ModelManage modelInfo, err = dao.ModelManage.GetNotTenantId(ctx, &dto.GetModelManageReq{ Id: req.ModelId, }) if err != nil { return fmt.Errorf("获取模型配置失败: %v", err) } if modelInfo == nil || (modelInfo.Enabled != nil && !*modelInfo.Enabled) { return fmt.Errorf("模型不存在或未启用") } if *modelInfo.ResponseType == *model.ResponseTypeStream.Code() { now := time.Now() userInfo, err := utils.GetUserInfo(ctx) if err != nil { return err } // 调用前检查租户余额:余额须大于 minTenantSurplusForUse 才允许调用 if _, err = CheckTenantBalance(ctx, userInfo.TenantId); err != nil { return err } err = queue(ctx, modelInfo.ModelName, userInfo.TenantId, gconv.Int64(modelInfo.MaxConcurrency), func(ctx context.Context) (err error) { var newRequestParams map[string]any var id int64 id, newRequestParams, err = s.saveModelRequestParams(ctx, now, modelInfo, &dto.ModelCallReq{ ModelId: req.ModelId, RequestParams: req.RequestParams, BusinessParams: req.BusinessParams, SessionId: req.SessionId, BizName: req.BizName, }) if err != nil { return err } if g.IsEmpty(id) || g.IsEmpty(newRequestParams) { return fmt.Errorf("保存模型请求参数失败") } var streamRes *dto.ModelCallRes streamRes, err = ModelSession.CreateSessionStream(ctx, w, &dto.CallModelSessionReq{ Id: id, ModelInfo: modelInfo, RequestParams: newRequestParams, }) // 扣减本次流式调用费用(流结束返回实际费用) if err == nil && streamRes != nil && streamRes.Cost > 0 { if dedErr := DeductBalance(ctx, userInfo.TenantId, streamRes.Cost); dedErr != nil { g.Log().Errorf(ctx, "[扣减余额] 流式调用扣费失败 modelId=%d cost=%.6f err=%v", modelInfo.Id, streamRes.Cost, dedErr) } } return }) } else { return fmt.Errorf("模型响应类型错误") } return err } // saveModelRequestParams 保存模型请求参数 func (s *modelCallService) saveModelRequestParams(ctx context.Context, now time.Time, modelInfo *entity.ModelManage, req *dto.ModelCallReq) (id int64, newRequestParams map[string]any, err error) { // 统一走模板校验+构建:requestParams 只装模板字段,businessParams 只装业务字段 out, err := buildChatRequestParams(modelInfo, req.RequestParams, req.BusinessParams) if err != nil { return 0, nil, err } // 1) 上传模型原始请求参数文件(requestParams + businessParams 合并,保证审计完整) originalParams := make(map[string]any, len(req.RequestParams)+len(req.BusinessParams)) for k, v := range req.RequestParams { originalParams[k] = v } for k, v := range req.BusinessParams { originalParams[k] = v } uploadOriginalReq, err := Upload(ctx, &dto.UploadFileBytesReq{ FileBytes: gconv.Bytes(gconv.String(originalParams)), FileName: fmt.Sprintf("modelRequestParams:%v.json", now.UnixMilli()), }) if err != nil { return 0, nil, fmt.Errorf("上传模型原始请求参数文件失败: %v", err) } // 2) 上传模型解析成功的请求参数文件 uploadNewReq, err := Upload(ctx, &dto.UploadFileBytesReq{ FileBytes: gconv.Bytes(gconv.String(out)), FileName: fmt.Sprintf("modelNewRequestParams:%v.json", now.UnixMilli()), }) if err != nil { return 0, nil, fmt.Errorf("上传模型解析请求参数文件失败:%v", err) } // 3) 保存模型请求信息(快照媒体类型供任务完成时换算费用;模型计费配置任务完成时按 modelId 现查) if *modelInfo.ResponseType == *model.ResponseTypeAsync.Code() { id, err = dao.ModelTaskStart.Insert(ctx, &dto.CreateModelTaskStartReq{ ModelId: req.ModelId, BizName: req.BizName, MsgTopic: req.MsgTopic, RequestPath: uploadNewReq.FileURL, OriginalRequestPath: uploadOriginalReq.FileURL, MediaType: DetectMediaType(modelInfo.RequestBusinessFieldMapping, out), }) if err != nil { return 0, nil, fmt.Errorf("保存模型请求信息失败: %v", err) } } else { id, err = dao.ModelSession.Insert(ctx, &dto.CreateModelSessionReq{ ModelId: req.ModelId, BizName: req.BizName, SessionId: req.SessionId, RequestPath: uploadNewReq.FileURL, OriginalRequestPath: uploadOriginalReq.FileURL, }) if err != nil { return 0, nil, fmt.Errorf("保存模型请求信息失败: %v", err) } } return id, out, nil } func queue(ctx context.Context, modelName string, tenantId uint64, maxCon int64, f func(ctx context.Context) (err error)) (err error) { lockKey := fmt.Sprintf("lock:tenantId-%s:model-%s", gconv.String(tenantId), modelName) success, e := lock(ctx, lockKey, -1, int64(time.Minute.Seconds()*5), func(ctx context.Context) error { const ( keyExpireSec = 600 // 计数Key兜底过期时间 10min waitInterval = 10 * time.Second // 轮询等待间隔 ) // Redis 操作统一使用独立上下文,避免外部 ctx canceled redisCtx := context.WithoutCancel(ctx) // 模型并发计数Key concurrencyKey := fmt.Sprintf("model:concurrency:%d:%s", tenantId, modelName) // 循环尝试获取并发名额,超限则等待重试 var held bool // 标记当前是否持有未释放的计数 for { // 检测全局上下文取消 if ctx.Err() != nil { if held { g.Redis().Decr(redisCtx, concurrencyKey) } return ctx.Err() } // 计数自增 currentCon, e := g.Redis().Incr(redisCtx, concurrencyKey) if e != nil { if held { g.Redis().Decr(redisCtx, concurrencyKey) } glog.Errorf(ctx, "redis incr concurrency key err: %v", e) return e } held = true // 首次创建Key时设置过期时间(避免重复执行EXPIRE) exists, errr := g.Redis().Exists(redisCtx, concurrencyKey) if errr == nil && exists == 1 { g.Redis().Expire(redisCtx, concurrencyKey, keyExpireSec) } // 未超限:跳出循环,执行业务 if currentCon <= maxCon { glog.Infof(ctx, "并发数: %s %d/%d", concurrencyKey, currentCon, maxCon) break } // 超限立刻回减,撤销本次计数 g.Redis().Decr(redisCtx, concurrencyKey) held = false glog.Infof(ctx, "并发超限等待: %s %d/%d", concurrencyKey, currentCon, maxCon) time.Sleep(waitInterval) } err = f(ctx) if _, decrErr := g.Redis().Decr(redisCtx, concurrencyKey); decrErr != nil { glog.Errorf(ctx, "redis decr concurrency key err: %v", decrErr) } if err != nil { return err } return nil }) if e != nil { return e } if !success { return gerror.New("任务排队已满,请稍后再试") } return } // lock 分布式锁 纯原生命令、无Lua、隔离上下文防 context canceled func lock(ctx context.Context, key string, limit, expireSeconds int64, fn func(ctx context.Context) error) (success bool, err error) { if limit <= 0 { limit = -1 } // 过期时间合法校验(单位:秒) const maxExpireSec = 86400 * 7 if expireSeconds < 1 || expireSeconds > maxExpireSec { glog.Warningf(ctx, "锁过期时间非法,原值:%d,兜底为60秒", expireSeconds) expireSeconds = 60 } lockVal := "1" LOOP: // 检测父级上下文取消,防止无限重试阻塞 goroutine if ctx.Err() != nil { return false, ctx.Err() } if limit != -1 { if limit < 0 { return false, errors.New("锁重试次数耗尽,获取锁失败") } limit-- } // 核心:创建独立上下文,不受外部 ctx 取消影响 redisCtx := context.WithoutCancel(ctx) // 加锁 val, err := g.Redis().Set(redisCtx, key, lockVal, gredis.SetOption{ TTLOption: gredis.TTLOption{ EX: &expireSeconds, }, NX: true, }) if err != nil { glog.Errorf(ctx, "redis set lock failed: %v", err) time.Sleep(time.Second) goto LOOP } if val.Bool() { // 执行业务逻辑(使用原上下文) runErr := fn(ctx) // 释放锁:同样使用独立上下文 + 先GET再DEL防误删 getRes, err := g.Redis().Get(redisCtx, key) if err != nil { glog.Errorf(ctx, "redis get lock value failed: %v", err) } else if getRes.String() == lockVal { _, delErr := g.Redis().Del(redisCtx, key) if delErr != nil { glog.Errorf(ctx, "redis del lock failed: %v", delErr) } } return true, runErr } // 抢锁失败,休眠重试 time.Sleep(time.Second) goto LOOP } // buildChatRequestParams 按模型配置的请求模板 + 业务字段映射构建请求体(ModelCall 请求路径共用): // 1. requestParams 只装模板字段,按 requestBodyMapping 模板校验(CheckParams)+ 构建(ParseConfigTemplate); // 未配置映射的字段(如未配置映射的 messages/tools)会被模板拒绝,明确报错 // 2. requestParams 为空时按配置模板构建请求结构(模板 defaultValue 生效), // 避免"结构由模板声明、值全走业务字段"的场景因请求体为空而构建失败 // 3. businessParams 只装业务字段,按业务字段名(RequestBusinessFieldMapping 的 key)传值, // TakeBusinessFields 解析为写入路径,构建完成后由 WriteBusinessFields 按路径写入最终请求体 func buildChatRequestParams(modelInfo *entity.ModelManage, requestParams, businessParams map[string]any) (map[string]any, error) { // requestParams 可能混有扁平路径 key(messages.enumValues...)与已是对象/数组的值(stream)。 // IsFlatMap 遇 map/slice 值即整体返回 false 会跳过 unflatten;sjson.Set 能处理任意值类型作为子树, // 带点 key 按路径展开、无点 key 直接赋值,故始终 unflatten var err error requestParams, err = utils.UnFlatBySjson(requestParams) if err != nil { return nil, err } rest := make(map[string]any, len(requestParams)) for k, v := range requestParams { rest[k] = v } // 请求结构源:模板字段兜底(模板 value/defaultValue 生效)+ requestParams 覆盖同名; // 保证模板声明的结构字段(如 stream_options 对象)即使 requestParams 未传也进请求体 // 用户已传字段按模板类型元数据递归合并(补 type 包装、补默认字段),数组字段仅在用户提供时才合并 src := rest for k, v := range modelInfo.RequestBodyMapping { tmplMap, _ := v.(map[string]any) if tmplMap != nil { if t, _ := tmplMap["type"].(string); t == "array" { if _, has := src[k]; !has { continue } src[k] = modelUtils.MergeNode(src[k], v) continue } } if userVal, has := src[k]; has { src[k] = modelUtils.MergeNode(userVal, v) } else { src[k] = modelUtils.DeepCopyNode(v) } } if len(requestParams) > 0 { // requestParams 非空才按模板严格校验模板字段(空值回填 default); // 为空时跳过,避免业务字段未写入就误报必填缺失 if err := modelUtils.CheckParams(src, modelInfo.RequestBodyMapping); err != nil { return nil, err } } out := modelUtils.ParseConfigTemplate(src) if !g.IsEmpty(businessParams) { // 业务字段:businessParams 按业务字段名传值,解析为映射路径后写入 bizValues, err := modelUtils.TakeBusinessFields(businessParams, modelInfo.RequestBusinessFieldMapping) if err != nil { return nil, err } // 业务字段按映射路径写入最终请求体(写 out 而非 rest:rest 只是模板字段容器,out 才是下发模型的请求体) if err = modelUtils.WriteBusinessFields(out, bizValues); err != nil { return nil, err } // 合并后按模板约束整体校验:必填/长度/范围 if err = modelUtils.CheckBody(out, modelInfo.RequestBodyMapping); err != nil { return nil, err } } // 按模板声明的 type 归一字段值类型(模板字段 value / 业务字段写入值都可能与声明类型不符) out = modelUtils.CoerceBodyTypes(out, modelInfo.RequestBodyMapping) return out, nil }