Files
model-gateway/service/model_http_service.go
T
19904408334 76c55fbb73 feat: 新增业务字段路径读写工具
新增 TakeBusinessFields、WriteBusinessFields、SetByPath 与 GetByPath 等工具,支持按映射路径写入请求体与解析响应,并更新相关依赖。
2026-08-18 10:11:09 +08:00

319 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package service
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"mime/multipart"
"model-gateway/model/dto"
"net/http"
"strings"
"time"
commonHttp "gitea.redpowerfuture.com/red-future/common/http"
"gitea.redpowerfuture.com/red-future/common/utils"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/net/gclient"
"github.com/gogf/gf/v2/util/gconv"
)
// DeductBalanceReq 扣减余额请求
type DeductBalanceReq struct {
Id uint64 `json:"id"`
Surplus float64 `json:"surplus"`
}
// DeductBalance 扣减租户余额。走 admin-go 内部接口 /pub/tenant/deduct(无 gftoken/Auth,供 model-gateway 内部调用)。
// admin-go 的 tenant/edit 对 surplus 走 gdb.Counter 增量(正加负减),故扣减须传负值;调用方在本次未产生费用(cost<=0)时应跳过。
func DeductBalance(ctx context.Context, tenantId uint64, amount float64) error {
apiURL := "admin-go/api/v1/pub/tenant/deduct"
headers := setCtxHeader(ctx)
body := DeductBalanceReq{
Id: tenantId,
Surplus: -amount,
}
jsonData, _ := json.Marshal(body)
var resp struct{}
err := commonHttp.Post(ctx, apiURL, headers, &resp, jsonData)
if err != nil {
g.Log().Warningf(ctx, "[扣减余额] 失败 tenantId=%d amount=%.6f err=%v", tenantId, amount, err)
return err
}
g.Log().Infof(ctx, "[扣减余额] 成功 tenantId=%d amount=%.6f", tenantId, amount)
return nil
}
// TenantSurplusResp 租户余额返回
type TenantSurplusResp struct {
Tenant struct {
Surplus float64 `json:"surplus"`
} `json:"tenant"`
}
// GetTenantSurplus 获取租户余额(走 admin-go 内部接口 /pub/tenant/balance,无 gftoken/Auth
func GetTenantSurplus(ctx context.Context, tenantId uint64) (float64, error) {
apiURL := fmt.Sprintf("admin-go/api/v1/pub/tenant/balance?tenantId=%d", tenantId)
headers := setCtxHeader(ctx)
var resp TenantSurplusResp
err := commonHttp.Get(ctx, apiURL, headers, &resp, nil)
if err != nil {
g.Log().Warningf(ctx, "[获取余额] 失败 tenantId=%d err=%v", tenantId, err)
return 0, err
}
return resp.Tenant.Surplus, nil
}
// IsSuperAdmin 调用admin-go服务检查是否是管理员
func IsSuperAdmin(ctx context.Context) (res bool, err error) {
headers := setCtxHeader(ctx)
var r = make(map[string]bool)
if err = commonHttp.Get(ctx, "admin-go/api/v1/system/user/checkIsSuperAdmin", headers, &r); err != nil {
return false, err
}
return r["isSuperAdmin"], err
}
func Upload(ctx context.Context, req *dto.UploadFileBytesReq) (*dto.UploadFileBytesRes, error) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
part, err := writer.CreateFormFile("file", req.FileName)
if err != nil {
return nil, err
}
if _, err = part.Write(req.FileBytes); err != nil {
return nil, err
}
if err = writer.Close(); err != nil {
return nil, err
}
headers := setCtxHeader(ctx)
headers["Content-Type"] = writer.FormDataContentType()
// 发起上传请求
res := &dto.UploadFileBytesRes{}
httpUrl := "oss/file/uploadFile"
if err = commonHttp.Post(ctx, httpUrl, headers, res, body.Bytes()); err != nil {
return nil, err
}
return res, nil
}
func setCtxHeader(ctx context.Context) map[string]string {
headers := make(map[string]string)
if r := g.RequestFromCtx(ctx); r != nil {
for k, v := range r.Request.Header {
if len(v) > 0 {
headers[k] = v[0]
}
}
}
// 后台异步任务(临时路径转存 OSS)ctx 无 HTTP 请求、无 token
// 用任务体注入的 userCreator/TenantId)生成 X-User-Info,供 OSS GetUserInfo 识别用户与桶名
if headers["X-User-Info"] == "" {
if user := ctx.Value("user"); !g.IsNil(user) {
headers["X-User-Info"] = gconv.String(user)
}
}
// 直连场景(请求头无 X-User-Info、ctx 未注入 user):解析调用方 token 得到用户,
// 生成 X-User-Info,供 admin-go 内部租户接口做归属校验(调用方只能操作自己所属租户)
if headers["X-User-Info"] == "" {
if user, err := utils.GetUserInfo(ctx); err == nil && user != nil {
headers["X-User-Info"] = gconv.String(user)
}
}
return headers
}
// modelCallHeaderTimeout 模型响应头等待超时。
// commonHttp 底层 gclient 默认 ResponseHeaderTimeout 只有 30s,模型生成首字节
// (尤其非流式、大 max_tokens)经常超过 30s,导致 http2: timeout awaiting response
// headers。模型调用必须用独立 client 并把该超时调大,与模型配置的超时保持一致。
const modelCallHeaderTimeout = 30 * time.Minute
// modelHTTPClient 构建模型调用专用 HTTP client
// 克隆 commonHttp 客户端(保留 ContentJson、header 注入等行为),但把
// ResponseHeaderTimeout 从默认 30s 调大到 modelCallHeaderTimeout。
func modelHTTPClient() *gclient.Client {
client := commonHttp.Httpclient.Clone()
if tr, ok := client.Transport.(*http.Transport); ok {
tr = tr.Clone() // 独立拷贝,避免改动全局共享 transport
tr.ResponseHeaderTimeout = modelCallHeaderTimeout
client.Transport = tr
}
return client
}
// modelDoRaw 模型 HTTP 请求(等价 commonHttp.doRequestRaw,但使用调大超时的 client)
func modelDoRaw(ctx context.Context, method string, url string, headers map[string]string, data ...any) (*gclient.Response, error) {
client := modelHTTPClient()
if (method == http.MethodPost || method == http.MethodPut || method == http.MethodDelete) && len(data) > 0 {
client = client.ContentJson()
}
if len(headers) > 0 {
client.SetHeaderMap(headers)
} else if r := g.RequestFromCtx(ctx); r != nil {
client.SetHeader("Authorization", r.Request.Header.Get("Authorization"))
}
var response *gclient.Response
var err error
if method == http.MethodGet && len(data) > 0 && len(data)%2 == 0 {
queryParams := make(map[string]string)
for i := 0; i < len(data); i += 2 {
if key, ok := data[i].(string); ok && i+1 < len(data) {
queryParams[key] = gconv.String(data[i+1])
}
}
response, err = client.DoRequest(ctx, method, url, queryParams)
} else if len(data) == 1 {
response, err = client.DoRequest(ctx, method, url, data[0])
} else {
response, err = client.DoRequest(ctx, method, url, data...)
}
return response, err
}
// ModelHttpNormalRequest 同步/异步 普通HTTP全量请求
func ModelHttpNormalRequest(ctx context.Context, url string, headers map[string]string, httpMethod string, body map[string]any) (res []byte, err error) {
response, err := modelDoRaw(ctx, httpMethod, url, headers, body)
if err != nil {
g.Log().Errorf(ctx, "[HttpModel] 模型请求失败 [Error]: %v", err)
return nil, fmt.Errorf("模型请求失败: %w", err)
}
defer response.Close()
return response.ReadAll(), nil
}
// ModelHttpStreamRequest 通用流式请求
// stream=true 时设置 SSE 头并验证 Flusherstream=false 时只返回 Reader,不设置响应头
func ModelHttpStreamRequest(ctx context.Context, w http.ResponseWriter, url string, headers map[string]string, httpMethod string, body map[string]any) (io.Reader, error) {
// 1) 先发起上游请求(此时还没写任何 SSE 头,失败可以正常返回 error)
response, err := modelDoRaw(ctx, httpMethod, url, headers, body)
if err != nil {
g.Log().Errorf(ctx, "[HttpModel] 模型流式请求失败 [Error]: %v", err)
return nil, fmt.Errorf("模型流式请求失败: %w", err)
}
// 检查 HTTP 状态码
if response.StatusCode < 200 || response.StatusCode >= 300 {
bodyBytes, _ := io.ReadAll(response.Body)
response.Close()
return nil, fmt.Errorf("[HTTP][Stream] 状态码异常: %d, body=%s", response.StatusCode, string(bodyBytes))
}
if w != nil {
// 2) 上游连接成功,再设置 SSE 头
h := w.Header()
h.Set("Content-Type", "text/event-stream; charset=utf-8")
h.Set("Cache-Control", "no-cache")
h.Set("Connection", "keep-alive")
h.Set("X-Accel-Buffering", "no")
if _, ok := w.(http.Flusher); !ok {
response.Close()
return nil, errors.New("response writer not support flush")
}
}
// 下层统一托管关闭:用包装器保证流最终关闭
return &autoCloseReader{r: response.Body}, nil
}
// autoCloseReader 包装 io.ReadCloser,读取结束/销毁时自动 Close
type autoCloseReader struct {
r io.ReadCloser
}
func (a *autoCloseReader) Read(p []byte) (int, error) {
n, err := a.r.Read(p)
// 读取完毕 / 读出错,主动关闭流
if err != nil {
_ = a.r.Close()
}
return n, err
}
// SSE 常量
const (
ssePrefixData = "data:"
ssePrefixEvent = "event:"
ssePrefixComment = ":"
sseStreamDone = "[DONE]"
scanBufInitSize = 64 * 1024 // 64KB
scanMaxLineSize = 1024 * 1024 // 单行最大 1MB
)
// ParseSSEStream 标准 SSE 流式解析,逐分片回调,支持多行data、上下文取消
func ParseSSEStream(ctx context.Context, respBody io.Reader, onChunk func(ctx context.Context, chunk map[string]any) error) {
scanner := bufio.NewScanner(respBody)
scanner.Buffer(make([]byte, 0, scanBufInitSize), scanMaxLineSize)
var dataBuilder strings.Builder
for scanner.Scan() {
// 监听上下文取消,及时终止
select {
case <-ctx.Done():
g.Log().Infof(ctx, "[SSE] 上下文取消,终止流读取: %v", ctx.Err())
return
default:
}
line := scanner.Text()
// 跳过注释、事件行
if strings.HasPrefix(line, ssePrefixComment) || strings.HasPrefix(line, ssePrefixEvent) {
continue
}
lineTrim := strings.TrimSpace(line)
// 空行 = 一个SSE事件结束
if lineTrim == "" {
if dataBuilder.Len() == 0 {
continue
}
dataStr := dataBuilder.String()
dataBuilder.Reset()
if dataStr == sseStreamDone {
continue
}
var chunk map[string]any
if err := json.Unmarshal([]byte(dataStr), &chunk); err != nil {
g.Log().Debugf(ctx, "[SSE] JSON解析失败: %s, err: %v", dataStr, err)
continue
}
if onChunk != nil {
onChunk(ctx, chunk)
}
continue
}
// 拼接多行 data 数据
if strings.HasPrefix(line, ssePrefixData) {
raw := strings.TrimPrefix(line, ssePrefixData)
dataBuilder.WriteString(strings.TrimSpace(raw))
}
}
// 捕获读取异常
if err := scanner.Err(); err != nil {
g.Log().Errorf(ctx, "[SSE] 流读取异常: %v", err)
return
}
g.Log().Infof(ctx, "[SSE] 流式读取正常结束")
}