Files
team-api/internal/logic/admin/model_import_export.go
T
Qianandchengqian be9e707bfd 修复异步图片导致的多个问题 (#12)
* fix:修复响应污染问题

* fix:修改存储相关文件为中文注释

* fix:修复二次退款问题

* fix:修复多个考虑不全面问题
1.误删用户数据
2.R2存储默认区域
3.删除失败导致孤儿记录
4.网络抖动事变时重试
5.前端无限次请求获取图片
6.文件管理权限

* fix:修复字符串截断异常问题和文件下载超时问题

* fix:修复多图问题

* fix:异步生成图片时未配置oss提示错误信息

* fix:修复关闭异步后同步生成出错问题

* feat:新增文件存储测试功能,防止设置后不生效

* fix:修复同步图片转异步后计费错误

* fix:修复无下限扣款问题

* fix:修复结算重复扣款问题

* fix:修复计费出错导致的0费用结算

* fix:修复回调双重履约

* fix:修复预扣事务

* fix:修复费用字段类型问题

* fix:修复缓存问题

* fix:修复安全问题

* fix:修复数据库操作事务问题

* refactor:重构事务使用方式,改为官方推荐的dao形式

* fix:修复余额更新隐患

* fix:修复N+1查询问题

* fix:修复密钥安全问题

* fix:结算失败时打印错误日志

* improve:优化扣款

* fix:修复用户接口越权

---------

Co-authored-by: chengqian <wuchengqian@kkidc.com>
2026-07-22 17:53:18 +08:00

334 lines
11 KiB
Go

package admin
import (
"bytes"
"context"
"encoding/json"
"io"
"net/url"
"github.com/gogf/gf/v2/database/gdb"
"github.com/gogf/gf/v2/errors/gcode"
"github.com/gogf/gf/v2/errors/gerror"
"github.com/gogf/gf/v2/frame/g"
"github.com/gogf/gf/v2/os/gtime"
"github.com/qianfree/team-api/api/admin/v1"
"github.com/qianfree/team-api/internal/consts"
"github.com/qianfree/team-api/internal/dao"
"github.com/qianfree/team-api/internal/logic/relay"
do "github.com/qianfree/team-api/internal/model/do"
)
// ExportModelsJson 导出模型配置为 JSON 文件(含定价数据,用于跨环境迁移)
func (s *sAdmin) ExportModelsJson(ctx context.Context, req *v1.ModelExportJsonReq) (*v1.ModelExportJsonRes, error) {
var models []struct {
ID int64 `orm:"id" json:"id"`
ModelId string `orm:"model_id" json:"model_id"`
ModelName string `orm:"model_name" json:"model_name"`
Category string `orm:"category" json:"category"`
Status string `orm:"status" json:"status"`
MaxContextTokens int `orm:"max_context_tokens" json:"max_context_tokens"`
MaxOutputTokens int `orm:"max_output_tokens" json:"max_output_tokens"`
Description string `orm:"description" json:"description"`
Tags []string `orm:"tags" json:"tags"`
Capabilities string `orm:"capabilities" json:"capabilities"`
SunsetDate *gtime.Time `orm:"sunset_date" json:"sunset_date"`
ReplacementModel string `orm:"replacement_model" json:"replacement_model"`
}
err := dao.MdlModels.Ctx(ctx).
WhereIn("model_id", req.ModelIds).
Scan(&models)
if err != nil {
return nil, err
}
if len(models) == 0 {
return nil, gerror.New("未找到匹配的模型")
}
type exportPricing struct {
BillingMode string `json:"billing_mode"`
MinTokens int64 `json:"min_tokens"`
MaxTokens *int64 `json:"max_tokens"`
InputPrice float64 `json:"input_price"`
OutputPrice float64 `json:"output_price"`
PerRequestPrice *float64 `json:"per_request_price"`
CacheReadPrice float64 `json:"cache_read_price"`
CacheCreationPrice float64 `json:"cache_creation_price"`
}
type exportModel struct {
ModelId string `json:"model_id"`
ModelName string `json:"model_name"`
Category string `json:"category"`
Status string `json:"status"`
MaxContextTokens int `json:"max_context_tokens"`
MaxOutputTokens int `json:"max_output_tokens"`
Description string `json:"description"`
Tags []string `json:"tags"`
Capabilities map[string]bool `json:"capabilities"`
SunsetDate string `json:"sunset_date,omitempty"`
ReplacementModel string `json:"replacement_model,omitempty"`
Pricing []exportPricing `json:"pricing"`
}
result := make([]exportModel, 0, len(models))
for _, m := range models {
var pricingRows []struct {
BillingMode string `orm:"billing_mode" json:"billing_mode"`
MinTokens int64 `orm:"min_tokens" json:"min_tokens"`
MaxTokens *int64 `orm:"max_tokens" json:"max_tokens"`
InputPrice float64 `orm:"input_price" json:"input_price"`
OutputPrice float64 `orm:"output_price" json:"output_price"`
PerRequestPrice *float64 `orm:"per_request_price" json:"per_request_price"`
CacheReadPrice float64 `orm:"cache_read_price" json:"cache_read_price"`
CacheCreationPrice float64 `orm:"cache_creation_price" json:"cache_creation_price"`
}
err := dao.MdlPricing.Ctx(ctx).Where("model_id", m.ID).Scan(&pricingRows)
if err != nil {
return nil, err
}
pricing := make([]exportPricing, 0, len(pricingRows))
for _, p := range pricingRows {
pricing = append(pricing, exportPricing{
BillingMode: p.BillingMode,
MinTokens: p.MinTokens,
MaxTokens: p.MaxTokens,
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
PerRequestPrice: p.PerRequestPrice,
CacheReadPrice: p.CacheReadPrice,
CacheCreationPrice: p.CacheCreationPrice,
})
}
em := exportModel{
ModelId: m.ModelId,
ModelName: m.ModelName,
Category: m.Category,
Status: m.Status,
MaxContextTokens: m.MaxContextTokens,
MaxOutputTokens: m.MaxOutputTokens,
Description: m.Description,
Capabilities: parseCapabilities(m.Capabilities),
ReplacementModel: m.ReplacementModel,
Pricing: pricing,
}
if m.SunsetDate != nil {
em.SunsetDate = m.SunsetDate.Format("Y-m-d")
}
em.Tags = m.Tags
result = append(result, em)
}
exportData := g.Map{
"version": "1.0",
"exported_at": gtime.Now().Format("Y-m-d\\TH:i:sP"),
"models": result,
}
jsonBytes, err := json.MarshalIndent(exportData, "", " ")
if err != nil {
return nil, err
}
filename := "models_" + gtime.Now().Format("Ymd_His") + ".json"
r := g.RequestFromCtx(ctx)
r.Response.Header().Set("Content-Type", "application/json; charset=utf-8")
r.Response.Header().Set("Content-Disposition", "attachment; filename*=UTF-8''"+url.QueryEscape(filename))
r.Response.Write(jsonBytes)
return nil, nil
}
// ImportModelsPreview 导入模型预览(解析上传文件,检测冲突)
func (s *sAdmin) ImportModelsPreview(ctx context.Context, req *v1.ModelImportPreviewReq) (*v1.ModelImportPreviewRes, error) {
var data []byte
r := g.RequestFromCtx(ctx)
// 优先通过标准文件上传读取
file := r.GetUploadFile("file")
if file != nil {
f, err := file.Open()
if err != nil {
return nil, gerror.NewCode(gcode.New(consts.CodeModelImportInvalidFile, consts.MsgModelImportInvalidFile, nil), consts.MsgModelImportInvalidFile)
}
defer f.Close()
data, err = io.ReadAll(f)
if err != nil {
return nil, gerror.NewCode(gcode.New(consts.CodeModelImportInvalidFile, consts.MsgModelImportInvalidFile, nil), consts.MsgModelImportInvalidFile)
}
}
// 兜底:从已解析的 MultipartForm.File 中读取(文件在 File 而非 Value 中)
if len(data) == 0 {
mf := r.GetMultipartForm()
if mf != nil {
if files := mf.File["file"]; len(files) > 0 {
f, err := files[0].Open()
if err == nil {
defer f.Close()
data, _ = io.ReadAll(f)
}
}
}
}
if len(data) == 0 {
return nil, gerror.NewCode(gcode.New(consts.CodeModelImportInvalidFile, consts.MsgModelImportInvalidFile, nil), consts.MsgModelImportInvalidFile)
}
var exportData struct {
Version string `json:"version"`
Models []v1.ModelImportPreviewItem `json:"models"`
}
// 使用 json.Decoder 而非 json.Unmarshal,只解析第一个完整 JSON 值,
// 兼容旧版本导出文件末尾可能被中间件追加的标准响应体。
if err := json.NewDecoder(bytes.NewReader(data)).Decode(&exportData); err != nil {
return nil, gerror.NewCode(gcode.New(consts.CodeModelImportInvalidFile, consts.MsgModelImportInvalidFile, nil), consts.MsgModelImportInvalidFile)
}
if exportData.Version != "1.0" {
return nil, gerror.NewCode(gcode.New(consts.CodeModelImportBadVersion, consts.MsgModelImportBadVersion, nil), consts.MsgModelImportBadVersion)
}
for i := range exportData.Models {
count, _ := dao.MdlModels.Ctx(ctx).Where("model_id", exportData.Models[i].ModelId).Count()
if count > 0 {
exportData.Models[i].Conflict = "exists"
}
}
return &v1.ModelImportPreviewRes{Models: exportData.Models}, nil
}
// ImportModels 确认导入模型(事务内执行)
func (s *sAdmin) ImportModels(ctx context.Context, req *v1.ModelImportReq) (*v1.ModelImportRes, error) {
if len(req.Models) > 200 {
return nil, gerror.New("单次导入不能超过 200 个模型")
}
res := &v1.ModelImportRes{}
err := g.DB().Transaction(ctx, func(ctx context.Context, tx gdb.TX) error {
for _, item := range req.Models {
var existing *struct {
ID int64 `orm:"id"`
}
err := dao.MdlModels.Ctx(ctx).Where("model_id", item.ModelId).Scan(&existing)
if err != nil {
return err
}
if existing != nil {
if item.ConflictAction == "skip" {
res.Skipped++
continue
}
// overwrite: 更新模型 + 替换定价
updateData := do.MdlModels{
ModelName: item.ModelName,
Category: item.Category,
MaxContextTokens: item.MaxContextTokens,
MaxOutputTokens: item.MaxOutputTokens,
Description: item.Description,
}
if item.Tags != nil {
updateData.Tags = item.Tags
}
if item.Capabilities != nil {
capJson, _ := json.Marshal(item.Capabilities)
updateData.Capabilities = string(capJson)
}
if item.SunsetDate != "" {
updateData.SunsetDate = gtime.NewFromStr(item.SunsetDate)
}
if item.ReplacementModel != "" {
updateData.ReplacementModel = item.ReplacementModel
}
if item.Status != "" {
updateData.Status = item.Status
}
_, err = dao.MdlModels.Ctx(ctx).Where("id", existing.ID).Data(updateData).Update()
if err != nil {
return err
}
_, err = dao.MdlPricing.Ctx(ctx).Where("model_id", existing.ID).Delete()
if err != nil {
return err
}
for _, p := range item.Pricing {
_, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
ModelId: existing.ID,
BillingMode: p.BillingMode,
MinTokens: p.MinTokens,
MaxTokens: p.MaxTokens,
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
PerRequestPrice: p.PerRequestPrice,
CacheReadPrice: p.CacheReadPrice,
CacheCreationPrice: p.CacheCreationPrice,
})
if err != nil {
return err
}
}
relay.NewDataProvider().InvalidateModelCache(item.ModelId)
res.Imported++
} else {
// 新建模型
insertData := do.MdlModels{
ModelId: item.ModelId,
ModelName: item.ModelName,
Category: item.Category,
Status: "active",
MaxContextTokens: item.MaxContextTokens,
MaxOutputTokens: item.MaxOutputTokens,
Description: item.Description,
}
if item.Tags != nil {
insertData.Tags = item.Tags
} else {
insertData.Tags = []string{}
}
if item.Capabilities != nil {
capJson, _ := json.Marshal(item.Capabilities)
insertData.Capabilities = string(capJson)
}
id, err := dao.MdlModels.Ctx(ctx).InsertAndGetId(insertData)
if err != nil {
return err
}
for _, p := range item.Pricing {
_, err = dao.MdlPricing.Ctx(ctx).Insert(do.MdlPricing{
ModelId: id,
BillingMode: p.BillingMode,
MinTokens: p.MinTokens,
MaxTokens: p.MaxTokens,
InputPrice: p.InputPrice,
OutputPrice: p.OutputPrice,
PerRequestPrice: p.PerRequestPrice,
CacheReadPrice: p.CacheReadPrice,
CacheCreationPrice: p.CacheCreationPrice,
})
if err != nil {
return err
}
}
res.Imported++
}
}
return nil
})
return res, err
}