新增 TakeBusinessFields、WriteBusinessFields、SetByPath 与 GetByPath 等工具,支持按映射路径写入请求体与解析响应,并更新相关依赖。
319 lines
10 KiB
Go
319 lines
10 KiB
Go
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:
|
||
// 用任务体注入的 user(Creator/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 头并验证 Flusher;stream=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] 流式读取正常结束")
|
||
}
|